2307 字
12 分钟
从零打造古筝MIDI自动播放器:21键音游,诛仙世界,天刀自动弹奏

项目背景#

作为一名古筝爱好者和程序员,我一直在寻找一种方法,让我的21键古筝外设能够自动播放MIDI曲谱,用于练习和演示。市面上的MIDI播放器要么功能过于复杂,要么无法适配古筝的特殊音阶,于是我决定自己开发一套专门的解决方案。

核心需求#

  • ✅ 支持MIDI文件解析和播放
  • ✅ 适配古筝21键七声音阶
  • ✅ 精确的节奏控制和多声部播放
  • ✅ 可变速播放(练习需要)
  • ✅ 播放列表和自动连播
  • ✅ 全局热键控制

技术架构#

技术栈选择#

  • Python 3.x - 主开发语言
  • PyQt5 - 图形界面框架
  • mido - MIDI文件解析库
  • pynput - 全局键盘控制
  • threading - 多线程精确播放

项目结构#

music/
├── gui_app.py # GUI主程序
├── music_player.py # 播放核心引擎
├── music/ # MIDI文件目录
└── requirements.txt # 依赖包

核心技术难点与解决方案#

1. MIDI文件精确解析#

难点:MIDI文件使用tick作为时间单位,需要转换为绝对时间,并处理tempo变化。

解决方案

def tick_to_seconds(target_tick: int) -> float:
"""精确转换tick到绝对时间(秒)"""
seconds = 0.0
prev_tick = 0
current_tempo = tempo_changes[0][1]
for change_tick, new_tempo in tempo_changes:
if change_tick >= target_tick:
break
# 累加从prev_tick到change_tick的时间
if change_tick > prev_tick:
tick_delta = change_tick - prev_tick
seconds += (tick_delta / ticks_per_beat) * (current_tempo / 1_000_000.0)
prev_tick = change_tick
current_tempo = new_tempo
return seconds

关键点

  • 构建完整的tempo时间线映射
  • 支持MIDI文件中的多次tempo变化
  • 将相对时间转换为绝对时间戳

2. 多声部并行播放的精确时序控制#

难点:传统的串行调度会产生累积延迟,导致节奏不准确。

初版方案(存在问题)

# ❌ 主线程串行调度 - 会产生累积延迟
for note in notes:
wait_time = note.start_time - current_time
time.sleep(wait_time) # 阻塞主线程
press_key(note)

优化方案

# ✅ 线程内部精确等待
def _play_note_with_timing(self, note: Note, playback_start: float, session_id: int):
# 计算目标播放时间(绝对时间戳)
scheduled_time = note.start_time / self.speed_multiplier
target_time = playback_start + scheduled_time
# 渐进式精确等待
while self.is_playing and self._play_session_id == session_id:
wait_time = target_time - time.perf_counter()
if wait_time <= 0:
break
# 粗等待 + 精细等待策略
if wait_time > 0.01:
time.sleep(wait_time * 0.5) # 睡一半时间
else:
time.sleep(0.001) # 1ms精度微调
# 执行按键操作
self.keyboard.press(key)
time.sleep(hold_time)
self.keyboard.release(key)

优化效果

  • 每个音符在独立线程中等待
  • 使用绝对时间戳,避免累积误差
  • 渐进式等待策略,兼顾性能和精度
  • 实测精度达到±2ms

3. 古筝七声音阶键位映射#

难点:古筝使用七声音阶(C D E F G A B),21键覆盖3个八度,需要支持两套键位方案。

解决方案

class KeyboardMapper:
def __init__(self, key_range='low_mid_high'):
self.key_range = key_range # 'low_mid_high' 或 'mid_high_super'
def _setup_guzheng_mapping(self):
semitones = [0, 2, 4, 5, 7, 9, 11] # C D E F G A B
low_keys = ['z', 'x', 'c', 'v', 'b', 'n', 'm'] # 7键
middle_keys = ['q', 'w', 'e', 'r', 't', 'y', 'u'] # 7键
high_keys = ['a', 's', 'd', 'f', 'g', 'h', 'j'] # 7键
if self.key_range == 'low_mid_high':
# 方案1: C3-B3, C4-B4, C5-B5
ranges = [(48, low_keys), (60, middle_keys), (72, high_keys)]
else:
# 方案2: C4-B4, C5-B5, C6-B6 (适合高音曲谱)
ranges = [(60, low_keys), (72, middle_keys), (84, high_keys)]
for base_midi, keys in ranges:
for i, key in enumerate(keys):
self.midi_to_key[base_midi + semitones[i]] = key

设计亮点

  • 21键完美覆盖3个八度
  • 双键位方案适应不同音域曲谱
  • 七声音阶符合古筝演奏习惯

4. 线程冲突与播放会话管理#

难点:停止播放时,旧线程可能未完全退出,导致新旧播放同时进行。

解决方案 - 会话ID机制

class MusicPlayer:
def __init__(self):
self._play_session_id = 0 # 播放会话ID
def play_notes(self, notes):
# 先停止旧播放
if self.is_playing:
self.stop()
time.sleep(0.15)
# 创建新会话
self._play_session_id += 1
current_session = self._play_session_id
# 所有线程携带会话ID
for note in notes:
thread = threading.Thread(
target=self._play_note_with_timing,
args=(note, playback_start, current_session)
)
thread.start()
def _play_note_with_timing(self, note, playback_start, session_id):
# 检查会话ID,旧会话立即退出
if self._play_session_id != session_id:
return
def stop(self):
self.is_playing = False
self._play_session_id += 1 # 递增ID,使所有旧线程失效

效果

  • 旧会话的所有线程自动失效
  • 支持快速切歌,无混音问题
  • 避免线程泄漏

5. UI线程与工作线程的正确协作#

难点:播放完成回调在工作线程中触发,但QTimer必须在主线程创建。

错误示范

# ❌ 在工作线程中创建QTimer - 不会工作
def on_playback_finished(self):
self.auto_play_timer = QTimer() # 错误!
self.auto_play_timer.start(3000)

正确方案

# ✅ 使用QTimer.singleShot切换到主线程
def on_playback_finished(self):
"""在工作线程中被调用"""
QTimer.singleShot(0, self._on_playback_finished_main_thread)
def _on_playback_finished_main_thread(self):
"""在主线程中执行"""
self.auto_play_timer = QTimer()
self.auto_play_timer.timeout.connect(self.play_next)
self.auto_play_timer.start(3000)

6. 系统级全局热键实现#

需求:即使程序在后台,也能通过热键控制播放。

实现方案

from pynput import keyboard
def setup_hotkeys(self):
self.current_keys = set()
def on_press(key):
self.current_keys.add(key)
# 检测组合键
ctrl_pressed = (keyboard.Key.ctrl in self.current_keys or
keyboard.Key.ctrl_l in self.current_keys)
shift_pressed = (keyboard.Key.shift in self.current_keys)
if ctrl_pressed and shift_pressed:
if hasattr(key, 'char') and key.char == '[':
self.play_music()
elif hasattr(key, 'char') and key.char == ']':
self.stop_music()
def on_release(key):
self.current_keys.discard(key)
self.hotkey_listener = keyboard.Listener(
on_press=on_press,
on_release=on_release
)
self.hotkey_listener.daemon = True
self.hotkey_listener.start()

热键设计

  • Ctrl+Shift+[ - 播放
  • Ctrl+Shift+] - 停止
  • 三键组合避免游戏中误触发

7. 智能速度识别与播放列表管理#

功能1:从文件名提取速度

def extract_speed_from_filename(self, filename):
"""支持格式:xxx(63).mid"""
patterns = [
r'[((](\d+\.?\d*)[))]', # 中英文括号
r'\[(\d+\.?\d*)\]', # 方括号
r'(\d+\.?\d*)' # 中文方括号
]
for pattern in patterns:
match = re.search(pattern, filename)
if match:
speed = float(match.group(1))
# 大于10认为是BPM,转换为倍速
if speed >= 10:
speed = speed / 100.0
return speed if 0.1 <= speed <= 3.0 else None

功能2:自然排序

def natural_sort_key(path):
"""数字按数值大小排序"""
filename = os.path.basename(path)
return [int(text) if text.isdigit() else text.lower()
for text in re.split(r'(\d+)', filename)]
# 应用:1.mid, 2.mid, ..., 10.mid, 11.mid
self.playlist = sorted(file_list, key=natural_sort_key)

性能优化总结#

时间精度优化#

方案精度说明
主线程串行sleep±50ms累积误差严重
线程池固定sleep±10ms仍有累积误差
绝对时间戳+渐进等待±2ms最终方案

内存与线程优化#

  • 使用daemon线程自动清理
  • 会话ID机制避免线程泄漏
  • 异步停止避免UI卡顿

项目亮点#

1. 用户体验#

  • 🎵 拖拽式播放列表管理
  • ⚡ 全局热键后台控制
  • 🔄 自动连播与循环播放
  • 📊 可视化音符预览
  • 🎚️ 精确速度控制(0.1-3.0倍速)

2. 技术创新#

  • 独创的会话ID线程管理机制
  • 渐进式精确时序控制算法
  • 双键位方案适配不同音域
  • 跨线程UI更新的正确实现

3. 代码质量#

  • 清晰的模块划分
  • 完善的异常处理
  • 详细的注释文档
  • 零依赖冲突

使用效果#

测试数据#

  • 支持MIDI音符数:0-500个/曲
  • 播放时长:10秒-5分钟
  • 节奏准确度:±2ms
  • CPU占用:<5%
  • 内存占用:~50MB

实际应用场景#

  1. 古筝练习 - 降速播放,跟着练习指法
  2. 曲谱演示 - 自动播放完整曲目
  3. 多曲连播 - 背景音乐循环播放
  4. 音域测试 - 快速验证曲谱适配性

踩过的坑#

1. Qt线程安全问题#

错误:在工作线程直接操作UI ✅ 正确:使用QTimer.singleShot切换到主线程

2. 时间累积误差#

错误:使用相对时间sleep ✅ 正确:使用绝对时间戳计算

3. 线程混乱#

错误:依赖is_playing标志控制 ✅ 正确:引入会话ID机制

4. Windows文件系统大小写#

错误*.mid*.MID都匹配导致重复 ✅ 正确:使用set去重并规范化路径

未来展望#

计划功能#

  • 支持更多乐器键位映射(吉他、钢琴等)
  • 录制功能(记录实时演奏)
  • 云端曲谱库同步
  • MIDI实时生成与导出
  • 节拍器和调音器集成

技术优化方向#

  • 使用Cython优化时序控制
  • 引入音频反馈(播放声音)
  • 支持MIDI输入设备
  • 跨平台打包(Windows/Mac/Linux)

开源与交流#

本项目已在开发中实现了完整的古筝MIDI自动播放功能,适合音乐爱好者和开发者学习参考。

项目特点#

  • 🎯 精确的节奏控制
  • 🎹 专业的古筝音阶映射
  • 🚀 流畅的用户体验
  • 📖 清晰的代码结构

技术栈#

Python 3.x
PyQt5 (GUI框架)
mido (MIDI解析)
pynput (全局热键)
threading (多线程)

安装使用#

Terminal window
pip install -r requirements.txt
python gui_app.py

总结#

这个项目从一个简单的想法开始,逐步演化成一个功能完善的MIDI播放器。通过解决精确时序控制、多线程管理、UI响应等技术难题,我深入理解了Python多线程编程、Qt框架的正确使用方式,以及音乐软件开发的独特挑战。

最重要的是,这个工具真正解决了我的实际需求,让21键古筝外设发挥了最大价值。如果你也在开发类似的音乐相关项目,希望这篇文章能给你一些启发。


作者注:本文记录了实际开发过程中的真实问题和解决方案,所有代码均经过测试验证。如有问题欢迎讨论交流!

关键词:#MIDI播放器 #古筝 #Python #PyQt5 #多线程 #音乐软件开发

从零打造古筝MIDI自动播放器:21键音游,诛仙世界,天刀自动弹奏
https://www.oferry.com/posts/a18/
作者
晨平安
发布于
2025-12-14
许可协议
CC BY-NC-SA 4.0
封面
示例歌曲
示例艺术家
封面
示例歌曲
示例艺术家
0:00 / 0:00