设计思路
计划实现功能
- 实现音符文件的读取:音符文件内存储音符的音调、音符的shortname(唯一标识符)、音符绑定的按键键码。
- 实现配置文件的读取:配置文件内存储当前选择的MIDI输出设备和是否使用命令行模式。
- 实现日志系统:支持INFO、WARN、SERIOUS、DUBUG四个级别的日志输出,并自动生成日志文件并存档。
- 实现键盘映射管理器:实现键盘映射的便捷管理。
- 实现演奏功能:实现基本的电子琴演奏功能,包括音量和音色调节。
- 实现曲谱的录制和播放:实现曲谱的录制并保存为曲谱文件,曲谱文件的读取、曲谱的自动播放。
- 实现命令行菜单,包括帮助、选择MIDI输出设备、开始演奏、录制曲谱、播放曲谱,打印键盘映射六个命令。
- 实现GUI界面:包括主界面和演奏界面两个界面,以及演奏界面的按钮控件、琴键变灰动画。
软件整体实现思路
软件整体有两个模式:命令行模式和GUI模式,可在配置文件中选择使用的模式。
项目由Commands(命令)、Entities(实体)、KeyManager(键盘映射管理器),Logger(日志记录器)、主源文件(包含主函数,作为程序入口)构成。
命令部分源文件:
- HelpCommand:帮助命令所在源文件,只用于命令行模式下帮助信息的打印。
- MusicPlayCommand:曲谱演奏命令所在源文件,GUI模式和命令行模式共用,用于启动曲谱的演奏。
- RecordCommand:曲谱录制命令所在源文件,GUI模式和命令行模式共用,用于启动曲谱的录制。
- SelectCommand:MIDI输出设备选取命令所在源文件,仅用于命令行模式下MIDI输出设备的选择。
- StartCommand:开启电子琴演奏命令所在源文件,GUI模式和命令行模式共用,用于开始电子琴弹奏。
实体部分源文件: - MusicEntity:曲谱类,定义了曲谱名和储存音符的数据结构。
- MusicNoteEntity:曲谱中的音符类,定义了音符标识符,音符操作时间等。
- NoteEntity:音符类,定义了音高,音符shortname(唯一标识符),绑定的键盘键码
功能实现思路
(1)实现音符文件的读取:
定义函数”initNoteFile”,函数中先检查在软件的同级下有没有”notes”文件夹,若没有,则创建该文件夹,若有,则读取文件夹下的所有文件,使用文件输入流读取文件内的音高、唯一标识符、对应键码三个字段,若输入正确且无重复唯一标识符的其他音符,就调用NoteEntity类的构造函数构造相应音符对象并存储在一个map表中,并添加键盘映射。当文件夹下所有文件读取结束,则音符文件读取结束。
代码(有删减):
if (!std::filesystem::exists("./notes")) {//创建音符文件夹
std::filesystem::create_directory("./notes");
}
for(const auto &entry: std::filesystem::directory_iterator("./notes")) {//读取文件夹内所有音符文件
std::ifstream noteFile(entry.path());
int noteNo;
std::string shortName;
int key;
noteFile >> noteNo >> shortName >> key;
if (noteFile.fail()) {
Logger::serious("音符文件格式错误 按任意键退出程序");
}
if (noteMap.find(shortName) != noteMap.end()) {
Logger::serious("音符文件" + shortName + "重复 按任意键退出程序");
}
noteMap.insert(std::pair(shortName, NoteEntity(noteNo, shortName, key)));
keyManager.addMapping(key, shortName);
}
}
(2)实现音符文件的读取:
函数”initCfgFile”,检查程序同级目录下是否有”config.cfg”文件,若没有,则创建该文件,并使用文件输出流添加默认值,若存在该文件,则循环使用getline逐行获取文件内容,然后使用string的find和substr方法解析文件,并把对应值存在相应的全局变量中。
核心代码:
std::ifstream cfgFile("./config.cfg");//打开文件输出流
std::string line;// 存储每一行
while (getline(cfgFile, line)) {// 逐行解析
if (line.find("selectedMidiDev=") != std::string::npos) {
selectedMidiDev = stoi(line.substr(line.find('=') + 1)) - 1;
} else if (line.find("isCommandLineMode=") != std::string::npos) {
isCommandLineMode = line.find("true") != std::string::npos;
}
}
(3)实现日志系统:
定义静态类”Logger”,拥有四个静态公共方法,分别对应四个等级的日志输出。一个私有的枚举”logLevel”,对应四个日志等级,以及两个私有方法sentMessage、getCurrentTime。在四个静态公共方法中,调用sentMessage,向sentMessage方法传递日志等级和要输出的字符串,在sentMessage中拼接日志等级,和使用getCurrentTime获取的当前时间以及要输出的字符串为一个字符串。并根据现在是否处于控制台模式选择是否使用cout输出,最后将要输出的字符串通过文件输出流添加到日志文件。
(4)实现键盘映射管理器:
定义类”KeyManager”,拥有四个公共方法,分别为:”addMapping”(添加键盘映射),”getKeyNote”(根据键码获取音符标识符),”getNoteKey”(根据音符标识符获取相应键码),”commandMap”(打印出现有所有的映射关系)。拥有一个私有变量,为键码(int类型)和音符标识符(string类型)的map表,即通过addMapping进行对该map的”增”操作,通过其他三个方法实现对该map的”查”的操作。
(5)实现演奏功能:
函数”commandStart”,函数参数为选取的midi输出设备代码。该函数调用函数”initMidiOut”初始化MIDI输出设备,赋值全局变量MIDI输出设备的句柄,注册键盘钩子,进入消息循环。
①键盘钩子回调函数”KeyboardProc”:
监听键盘事件,调用函数”keyHandler”进行键盘处理,并调用下一个回调函数。
②键盘消息处理函数”keyHandler”:
拥有两个参数,分别为要处理按键的键码,和按键是被按下还是松开。该函数判断按下的是否是功能键(退出、调节音量、调节音色),若是,则执行对应功能的函数;若不是,则执行”noteKeyHandler”函数,处理音符按键。
③音符按键处理函数”noteKeyHandler”:
与”keyHandler”的参数一样。该函数从keyManager类的实例中取出键码对应音符,并取出相应音符对象,并向MIDI输出设备发送相应音符开启和音符关闭消息。
代码:
void noteKeyHandler(DWORD key, bool sign) {
if (sign) {// sign为true则为键盘按下
std::string noteName = keyManager.getKeyNote(static_cast(key));
if (!noteName.empty() && !keyState[key]) {
keyState[key] = true;
DWORD noteOnMsg = 0x90 | nowChannel; // 音符开启消息
noteOnMsg |= (noteMap[noteName].noteNo & 0x7F) << 8; // 音符号
noteOnMsg |= (nowVelocity & 0x7F) << 16; // 力度
midiOutShortMsg(hMidiOut, noteOnMsg);
if (isRecording) {
//录制相关代码 在此省略
}
return;
}
} else {
std::string noteName = keyManager.getKeyNote(static_cast(key)); // 获取音符名
if (!noteName.empty()) {
keyState[key] = false;
DWORD noteOnMsg = 0x80 | nowChannel; // 0x90表示Note Off消息
noteOnMsg |= (noteMap[noteName].noteNo & 0x7F) << 8; // 音符号
noteOnMsg |= (0 & 0x7F) << 16; // 力度
midiOutShortMsg(hMidiOut, noteOnMsg);
if (isRecording) {
//录制相关代码 在此省略
}
}
return;
}
}
(6)实现曲谱的录制和播放:
①曲谱录制:
函数”commandRecord”,有一个参数为曲谱的名字。首先判断调用方是否来自GUI界面(GUI界面原来就有演奏线程,不需要另开线程,故在此特判),如果是,则不开启演奏线程,如果不是,开启一个演奏线程,通过C++11的特性之一chrono头文件中的steady_clock记录开始录制的时间,再标记全局变量”isRecording”为true,接下来等待演奏线程加入,演奏线程加入后,保存曲谱文件,曲谱录制成功。
在演奏线程中,有函数noteKeyHandler,在其中若全局变量”isRecording”为true,即正处于录制模式,就计算当前时间与录制开始时间的时间差,并把音符标识符,时间差,和键位是按下还是抬起输出到曲谱文件中。
代码:
if (isRecording) {
std::chrono::steady_clock::time_point via = std::chrono::steady_clock::now(); //获取当前时间
std::chrono::duration duration = std::chrono::duration_cast>(via -startRecordTime); // 计算时间差
musicFile << noteName << " " << duration.count() << " " << true << std::endl; // 写入文件
}
②曲谱文件的读取:
曲谱文件的读取和音符文件读取相似,利用文件输入流获取数据,构造对象存入相应的数据结构内,在这里不多赘述。
③曲谱的播放:
函数”commandMusicPlay”,有一个参数,为播放的曲谱名,在全局的存储曲谱的数据结构中查询是否有该曲谱,若曲谱不存在则提示警告信息并退出,若存在,则从数据结构中取出曲谱信息,记录开始时间,进入播放循环。在播放循环中,程序遍历曲谱对象中的曲谱音符向量,每次循环记录一次当前时间与开始播放时间的时间差,若时间差大于等于对应音符的时间差,则调用演奏线程的方法”noteKeyHandler”处理对应音符,演奏出音符。
(7)实现命令行菜单:
进入死循环,每次输入一个字符串,若与命令匹配则调用相关函数,若输入exit则退出循环,程序结束。
(8)实现GUI界面:
函数”initGui”,首先绘制主界面,包括标题和背景图,然后调用函数”keyAndMouseHandler”处理键盘和鼠标信息
①函数”keyAndMouseHandler”:
进入死循环,循环获取信息ExMessange,监听鼠标左键按下消息,判断按下坐标是否在各按钮内,若在各按钮内则执行相应的函数;监听键盘按下消息,若按下ESC且不处于录制模式则关闭窗口退出程序(录制模式需要按下ESC返回正常演奏模式),在主界面按下任意键则开启绘制界面的绘制线程,并使其分离,开始演奏。
②函数”drawPianoGUI”:
绘制标题,状态栏,按钮栏,并调用绘制琴键函数”drawPianoKeys”
③函数”drawPianoKeys”:
绘制三排琴键,按照物理键盘排序,绘制按键名,通过keyManager的实例获取键位对应音符名,并绘制音符名,若按键的状态为被按下,则使琴键变灰
所使用的技术栈
windows.h
windows.h是windows操作系统提供的头文件之一,它包含了很多用于Windows应用程序开发的声明和定义,该头文件提供了对Windows API的访问,允许开发者编写使用Windows特定功能的程序
(1) 键盘钩子Keyboard Hook:
键盘钩子是一种系统级别的编程技术,用于监视和拦截键盘输入时间,这一技术允许程序在操作系统层面截获并处理用户的键盘输入。使用”SetWindowsHookEx”函数来安装一个键盘钩子,在参数中提供一个回调函数,该函数会在用户按下或释放键盘上的按键时被调用,我们就可以在回调函数中实现监听键盘输入的逻辑,处理特定的键盘输入。
mmeapi.h
Mmeapi.h是Windows多媒体扩展的一部分,它提供了一些常量、结构和函数,用于访问和控制多媒体设备。
(1) 函数midiOutOpen:
用于打开MIDI输出设备,参数包括指向MIDI设备句柄类型的指针,MIDI设备的设备标识符,回调函数的地址(可以设为NULL,表示不使用回调函数)等。如果函数执行成功,该函数会返回” MMSYSERR_NOERROR”,如果其执行失败,则会返回错误代码。
(2) 函数midiOutShortMsg:
用于向MIDI输出设备发送一条短消息,其参数包括MIDI输出设备的句柄,和要发送给MIDI输出设备的消息,该消息是一个32位值,格式为:”0x00SSSSCC”其中的”SSSS”为状态字节,”CC”为通道字节,他们定义了MIDI事件类型和通道。如果函数执行成功,函数会返回” MMSYSERR_NOERROR”, 如果其执行失败,则会返回错误代码。
(3) 函数midiOutClose:
用于关闭MIDI输出设备,其参数为MIDI输出设备的句柄。返回值则与上面两个函数一样。
(4) 函数midiOutGetNumDevs:
用于获取系统中可用的MIDI输出设备数量,它没有参数。返回值就是MIDI输出设备的数量
(5) 函数midiOutGetDevCaps:
用于获取指定的MIDI输出设备的能力信息。其参数为MIDI输出设备的标识符、一个指向”MIDIOUTCAPS”结构体的指针和该结构体的大小。其返回值与前三个函数一样。
(6) 结构MIDIOUTCAPS:
存储MIDI输出设备的能力信息,它包括:
①wMid:制造商ID,标识设备的制造商。
②wPid:产品ID,X用于表示设备的型号。
③vDriverVersion:驱动程序版本。
④szPname:设备名称。
⑤dwSupport:设备支持的功能标志,包括支持的最大通道,音符数等等。
时间库chrono
Chrono时间库是C++标准库中的头文件,在C++11被引入,他提供了强大的时间相关的功能。
(1)时间点:
std::chrono::time_point表示时间轴上的一个点,可以用来表示一个时刻。
(2)时间段:
std::chrono::duration 表示持续的一段时间,可以以不同的事件单位来表示,最精确为纳秒。
(3)时钟类:
chrono提供了三个时钟,分别是system_clock、steady_clock、high_resolution_clock。分别为系统时间,即系统左下角显示的日期时间,可以被用户手动调整;稳定的时钟,不能被用户手动调整,适合用于记录时间差等;高精度时钟,它和steady_clock作用相似,但精度可能更高。
EasyX图形库
EasyX是一个简单易用的图形库,用于Windows平台的图形界面的绘制和图形程序的开发,提供了一系列简单的API,使得用户能较方便的绘制窗口、绘制图形。
(1)函数initgraph:
用于初始化绘图环境,两个参数分别为窗口的长和宽,单位为像素。
(2)函数setfillcolor:
用于设置填充图形的颜色,其参数为EasyX提供的颜色常量或用函数RGB(a,b,c)表示的颜色。
(3)函数settextcolor:
用于设置字符的颜色,其参数与setfillcolor相同。
(4)函数settextstyle:
用于设置字符的样式,包括大小,字体,是否斜体,粗细等。
(5)函数outtextstyle:
用于在界面上显示字符,其参数包括字符左上角点的x,y坐标和要输出的字符串
(6)函数solidrectangle:
用于绘制实心矩形,其参数为矩形左上角和右上角点的坐标。
(7)函数loadimage:
用于加载图片,其参数包括一个IMAGE类型的指针和图片路径。
(8)函数putimage:
用于在界面上显示图片,其参数包括图片左上角的x,y坐标和IMAGE指针。
代码仓库
本项目仓库地址为https://gitee.com/ly1093322955/MIDIPiano
感谢带佬开源(bushi)