原琴演奏中高精度计时解决方案
0. 引言——为什么有这篇文章?
在做「寰墟之叹」原琴视频的时候,我们主要采用 MIDI 文件自动化演奏的方式制作因为没有手(。在开源社区,有诸多优秀的演奏脚本,在尝试很多脚本后我们发现:后期合并音轨的时候总是对不齐,且越靠后越乱。总之最后找到了原因和解决方法,故记录在此。
1. 问题原因
目前,很多支持 MIDI 演奏的脚本均基于 Python 开发。比如:
这种脚本整体设计上并未有很大问题,部分功能也相当出色,然而在它们的计时部分却不够精确。虽足以应付独奏时的时间控制,但是会因为阻塞运行的指令而积累误差,导致计时精度下滑。
def playMidi(m_file_name, m_bpm, m_key_add):
file_name = "." + os.sep + "midi_repo" + os.sep + m_file_name
real_time = float( 120 / m_bpm )
midi = mido.MIDIFile(file_name)
# set bpm
tempo = mido.bpm2tempo(m_bpm)
# Read midi file
for msg in midi:
if(msg.type=="note_on"):
# Press
sleep_time = float(msg.time) * real_time
pyautogui.sleep(sleep_time)
pyautogui.keyDown(noteTrans(int(msg.note)+m_key_add))
pyautogui.keyUp(noteTrans(int(msg.note)+m_key_add))
elif(msg.type=="note_off"):
# Release
sleep_time = float(msg.time) * real_time
pyautogui.sleep(sleep_time)
else:
continue
具体而言,pyautogui.sleep()
函数计时精度并不足够高,多次执行后误差逐渐积累,导致后续计时精度不够。此外, 76 行和 77 行按键的操作也较为耗时,导致一个循环内实际上消耗时间略多于指定的时间,不断积累,最终造成不可忽略的影响。
2. 解决方案
首先可以自己搓一个新的计时机制出来,利用异步编程的任务队列避免积累误差。
不过秉持不重复造轮子的态度(就是懒),我们可以借助以下三个免费软件直接实现高精度的原琴演奏:FreePiano、loopMIDI和MIDIKey2Key。
软件的简要介绍
FreePiano
官网:https://freepiano.tiwb.com/cn/
一款虚拟 MIDI 键盘,把正常 108 键键盘输入映射到 MIDI 信号。不过在这里它并不是干这个的。我们需要它的 MIDI 播放功能。
loopMIDI
官网:https://www.tobias-erichsen.de/software/loopmidi.html
一款 MIDI 信号路由工具,可以创建虚拟的 MIDI In 和 MIDI Out,并在其间传输 MIDI 信号。
MIDIKey2Key
官网:https://midikey2key.de/?lang=en
一款 MIDI 信号到键盘键位的映射工具。
解决思路
FreePiano 作为 MIDI 信号源,通过 loopMIDI 将信号输入到 MidKey2Key,并映射成原琴键位。
其实就是用 FreePiano 作为高精度 MIDI 解析与播放装置罢了……
上操作
注
软件安装请自行解决。
准备 MIDI 文件,需要将乐曲速度写入文件,否则导入后无法精确调整乐曲 BPM。另附写入速度小脚本:
import mido import os def change_midi_tempo(input_file, output_file, new_bpm): mid = mido.MidiFile(input_file) new_tempo = mido.bpm2tempo(new_bpm) # 将BPM转换为微秒/拍 for track in mid.tracks: for i, msg in enumerate(track): if msg.type == 'set_tempo': track[i] = mido.MetaMessage('set_tempo', tempo=new_tempo, time=msg.time) break # 只修改第一个set_tempo事件 else: # 如果没有set_tempo事件,则在开头插入 track.insert( 0, mido.MetaMessage('set_tempo', tempo=new_tempo, time=0)) mid.save(output_file) print(f"已将 {input_file} 的速度更改为 {new_bpm} BPM,并保存为 {output_file}") def batch_change_midi_tempo(input_folder, output_folder, new_bpm): os.makedirs(output_folder, exist_ok=True) for filename in os.listdir(input_folder): if filename.lower().endswith('.mid'): input_path: str = os.path.join(input_folder, filename) output_path: str = os.path.join( output_folder, f"{os.path.splitext(p=filename)[0]}-{new_bpm}.mid") try: change_midi_tempo(input_file=input_path, output_file=output_path, new_bpm=new_bpm) except Exception as e: print(f"处理 {filename} 时出错: {e}") if __name__ == "__main__": # 批量处理 input_midis 文件夹下所有 MIDI 文件,输出到 output_midis 文件夹 batch_change_midi_tempo(input_folder='midi_in', output_folder='midi_out', new_bpm=145)
创建虚拟 MIDI I/O
点击左下角 +
号现在打开 FreePiano,在左上角的
音源
菜单就可以看到新创建的 MIDI I/O 了。默认为loopMIDI Port
。
p.s 如果没看见的话请参考重启大法。在某一个目录(推荐在
~/.midi2key
类似的不容易被删除的地方)创建两个ini
格式的配置文件,分别写入以下内容:提示
以下两个文件绝大部分内容均相同,只有每一节配置中的
Hold
字段不同。 该字段控制是否响应 MIDI 中 Keyoff 消息,如果Hold=0
则在固定的延迟后直接松开对应按键而不持续按下;Hold=1
则反之。其中,
gi-config.ini
是非长音乐器(如风物之诗琴)对应的配置,而gi-config-hold.ini
是圆号对应的配置。勿要错用,错用会导致非长音乐器连奏卡顿。gi-config.ini[WindowPosition] [MidiDevice] MidiIn=loopMIDI Port MidiOut=Select output device [Switches] SysEx=0 LogWindow=1 Ch1=1 Ch2=1 Ch3=1 Ch4=1 Ch5=1 Ch6=1 Ch7=1 Ch8=1 Ch9=1 Ch10=1 Ch11=1 Ch12=1 Ch13=1 Ch14=1 Ch15=1 Ch16=1 MirrorMidi=0 ShowTSConnect=0 [Action0] Data=xxxxxx Comment=STARTUP Name=STARTUP [Action1] Data=9030xx Comment=C3 Name=C3 Keyboard=Z SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action2] Data=9032xx Comment=D3 Name=D3 Keyboard=X SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action3] Data=9034xx Comment=E3 Name=E3 Keyboard=C SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action4] Data=9035xx Comment=F3 Name=F3 Keyboard=V SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action5] Data=9037xx Comment=G3 Name=G3 Keyboard=B SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action6] Data=9039xx Comment=A3 Name=A3 Keyboard=N SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action7] Data=903Bxx Comment=B3 Name=B3 Keyboard=M SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action8] Data=903Cxx Comment=C4 Name=C4 Keyboard=A SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action9] Data=903Exx Comment=D4 Name=D4 Keyboard=S SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action10] Data=9040xx Comment=E4 Name=E4 Keyboard=D SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action11] Data=9041xx Comment=F4 Name=F4 Keyboard=F SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action12] Data=9043xx Comment=G4 Name=G4 Keyboard=G SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action13] Data=9045xx Comment=A4 Name=A4 Keyboard=H SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action14] Data=9047xx Comment=B4 Name=B4 Keyboard=J SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action15] Data=9048xx Comment=C5 Name=C5 Keyboard=Q SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action16] Data=904Axx Comment=D5 Name=D5 Keyboard=W SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action17] Data=904Cxx Comment=E5 Name=E5 Keyboard=E SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action18] Data=904Dxx Comment=F5 Name=F5 Keyboard=R SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action19] Data=904Fxx Comment=G5 Name=G5 Keyboard=T SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action20] Data=9051xx Comment=A5 Name=A5 Keyboard=Y SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0 [Action21] Data=9053xx Comment=B5 Name=B5 Keyboard=U SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=0 KeyboardB= Multiplier=1 KeyboardDelay=5 TS=0 Start= Arguments= WindowState=0
gi-config-hold.ini[WindowPosition] [MidiDevice] MidiIn=loopMIDI Port MidiOut=Select output device [Switches] SysEx=0 LogWindow=1 Ch1=1 Ch2=1 Ch3=1 Ch4=1 Ch5=1 Ch6=1 Ch7=1 Ch8=1 Ch9=1 Ch10=1 Ch11=1 Ch12=1 Ch13=1 Ch14=1 Ch15=1 Ch16=1 MirrorMidi=0 ShowTSConnect=0 [Action0] Data=xxxxxx Comment=STARTUP Name=STARTUP [Action1] Data=9030xx Comment=C3 Name=C3 Keyboard=Z SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action2] Data=9032xx Comment=D3 Name=D3 Keyboard=X SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action3] Data=9034xx Comment=E3 Name=E3 Keyboard=C SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action4] Data=9035xx Comment=F3 Name=F3 Keyboard=V SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action5] Data=9037xx Comment=G3 Name=G3 Keyboard=B SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action6] Data=9039xx Comment=A3 Name=A3 Keyboard=N SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action7] Data=903Bxx Comment=B3 Name=B3 Keyboard=M SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action8] Data=903Cxx Comment=C4 Name=C4 Keyboard=A SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action9] Data=903Exx Comment=D4 Name=D4 Keyboard=S SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action10] Data=9040xx Comment=E4 Name=E4 Keyboard=D SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action11] Data=9041xx Comment=F4 Name=F4 Keyboard=F SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action12] Data=9043xx Comment=G4 Name=G4 Keyboard=G SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action13] Data=9045xx Comment=A4 Name=A4 Keyboard=H SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action14] Data=9047xx Comment=B4 Name=B4 Keyboard=J SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action15] Data=9048xx Comment=C5 Name=C5 Keyboard=Q SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action16] Data=904Axx Comment=D5 Name=D5 Keyboard=W SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action17] Data=904Cxx Comment=E5 Name=E5 Keyboard=E SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action18] Data=904Dxx Comment=F5 Name=F5 Keyboard=R SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action19] Data=904Fxx Comment=G5 Name=G5 Keyboard=T SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action20] Data=9051xx Comment=A5 Name=A5 Keyboard=Y SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0 [Action21] Data=9053xx Comment=B5 Name=B5 Keyboard=U SendMidi=0 SendMidiCommands= SendMidiCommandsB= ControllerAction=0 Hold=1 KeyboardB= Multiplier=1 KeyboardDelay=100 TS=0 Start= Arguments= WindowState=0
以管理员身份启动 MIDIKey2Key,在
File > Load Custom
处选择两个配置之一, 在左上角下拉菜单选择刚刚创建的虚拟 MIDI 接口(图中为loopMIDI Port
),最后在右上角处点击start
开始监听 MIDI 信号。注意
如果此处不以管理员身份启动,则无法将按键输入到原神中(体现为开始播放但是原神没反应)。
在 FreePiano 中开始播放 MIDI 文件,然后迅速使原神窗口获得焦点,避免触发意外。
Enjoy!
至此较为具体的介绍了为什么部分 Python 脚本在合奏(即高时间精度)的场景下不可用的原因与对应解决方案。