Skip to content

原琴演奏中高精度计时解决方案

约 2630 字大约 9 分钟

原神原琴音乐MIDI

2025-07-22

0. 引言——为什么有这篇文章?

在做「寰墟之叹」原琴视频的时候,我们主要采用 MIDI 文件自动化演奏的方式制作因为没有手(。在开源社区,有诸多优秀的演奏脚本,在尝试很多脚本后我们发现:后期合并音轨的时候总是对不齐,且越靠后越乱。总之最后找到了原因和解决方法,故记录在此。

1. 问题原因

目前,很多支持 MIDI 演奏的脚本均基于 Python 开发。比如:

这种脚本整体设计上并未有很大问题,部分功能也相当出色,然而在它们的计时部分却不够精确。虽足以应付独奏时的时间控制,但是会因为阻塞运行的指令而积累误差,导致计时精度下滑。

Genshin_Zither_Player/GenshinZitherPlayer.py
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 解析与播放装置罢了……

上操作

软件安装请自行解决。

  1. 准备 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)
  2. 创建虚拟 MIDI I/O

    点击左下角  号
    点击左下角 +
  3. 现在打开 FreePiano,在左上角的音源菜单就可以看到新创建的 MIDI I/O 了。默认为 loopMIDI Port
    p.s 如果没看见的话请参考重启大法。 在这里

  4. 在某一个目录(推荐在~/.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
  5. 管理员身份启动 MIDIKey2Key,在 File > Load Custom 处选择两个配置之一, 在左上角下拉菜单选择刚刚创建的虚拟 MIDI 接口(图中为 loopMIDI Port ),最后在右上角处点击 start开始监听 MIDI 信号。 alt text

    注意

    如果此处不以管理员身份启动,则无法将按键输入到原神中(体现为开始播放但是原神没反应)。

  6. 在 FreePiano 中开始播放 MIDI 文件,然后迅速使原神窗口获得焦点,避免触发意外。

  7. Enjoy!

至此较为具体的介绍了为什么部分 Python 脚本在合奏(即高时间精度)的场景下不可用的原因与对应解决方案。