ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用 time.sleep 播 MIDI 踩的坑:一个 pyglet 钢琴卷帘的播放器折腾记

用 time.sleep 播 MIDI 踩的坑:一个 pyglet 钢琴卷帘的播放器折腾记 起因写过一个小型钢琴卷帘 DAWpyglet 渲染鼠标在网格上画音符、拖动、改长度播放头跟着走。界面部分一天就写完了真正耗掉大量时间的是把音符播出来这一步。播放器前后折腾了三版方案每一版都有各自的坑。这篇只讲播放器界面部分不展开。第一版mido 走 MIDI 端口最初的思路是标准 MIDI 路线mido 打开输出端口按节拍把音符发出去。当时的实现逻辑mido 枚举输出端口取第一个可用端口找不到端口时回退到mido.open_output()开虚拟端口起一个后台线程按start_beat排序音符time.sleep等到时间点发 note_on再 sleep 一个音符时长发 note_off代码结构大致是这样fornoteinsorted_notes:wait_timenote.start_beat*(self.tempo/1000000.0)elapsedtime.time()-start_timeifwait_timeelapsed:time.sleep(wait_time-elapsed)self.note_on(note.pitch,note.velocity)durationnote.duration*(self.tempo/1000000.0)time.sleep(duration)self.note_off(note.pitch)这一版踩了四个坑设备依赖没有 MIDI 输出设备的机器上整条路径直接哑火。虚拟端口只是能开不代表有声音出来sleep 精度time.sleep在 macOS 上精度只能到毫秒级且误差会沿时间轴累积。音符多、跨度长的时候后半段节奏明显对不上串行播放一个音符播完才播下一个同一时刻只能响一个音。两个音符 start_beat 相同后发的会直接把前一个覆盖掉和声根本做不出来停止收尾停止时遍历 128 个音高全发 note_off再对线程join(timeout1)。逻辑粗暴但这是当时能想到的最可靠收尾方式第二版pyglet 合成正弦波MIDI 路线被设备依赖卡死之后换成了纯软件合成pyglet 自带pyglet.media.synthesis.Sine可以直接生成指定频率的正弦波音源从 MIDI 音号换算频率播放。当时的实现按(note, duration)缓存生成的音源避免重复合成每触发一个音符新建一个pyglet.media.Player播放音量用 velocity / 127 映射这一版踩的坑更隐蔽synthesis.Sine在不同 pyglet 版本里 API 不稳定有的版本直接不可用。于是被迫写了完整兜底纯 Python 手算 ADSR 包络用array生成 int16 采样wave模块写进 BytesIO再pyglet.media.load(tone.wav, filebuffer)从文件对象加载每响一个音就 new 一个 Playeractive_players越积越多靠pyglet.clock.schedule_once延迟清理。长会话里内存只涨不降批量播放用pyglet.clock.schedule_once在 GUI 线程里调度窗口拖动卡一下音频就跟着卡一下兜底那段生成 WAV 的代码反而成了整个播放器最有价值的部分defenvelope_at(i:int)-float:ifattack_samples0andiattack_samples:returni/attack_samplesifdecay_samples0andiattack_samplesdecay_samples:t(i-attack_samples)/decay_samplesreturn1.0(sustain_level-1.0)*tifrelease_samples0andin_samples-release_samples:t(i-(n_samples-release_samples))/release_samplesreturnsustain_level*(1.0-t)returnsustain_level第三版放弃实时回到事件驱动最终版其实是个妥协不在后台线程里做时序而是把到点触发交给 pyglet 的事件循环。播放时按start_beat排序音符记录播放位置每帧更新里把start_beat play_position的音符逐个触发音符只播一次不排队、不调度代码简化成defupdate(self,dt:float):self.editor.play_positiondt*self.beats_per_secondwhileself.play_indexlen(self.notes_to_play):noteself.notes_to_play[self.play_index]ifnote.start_beatself.editor.play_position:self.preview_note(note)self.play_index1else:break这一版把时序问题从精确调度降级成了到点就响演示够用节奏对得上。但本质问题没解决GUI 线程承担音频调度事件循环一卡声音就卡。而且每个音符仍是独立 Player多声部依然做不了。结论Python 实时音频的边界三版下来结论很明确time.sleep做不了精确时序误差沿时间轴累积GUI 事件循环做音频调度卡顿会传导pyglet 的合成器 API 不稳定兜底代码比主路径还长设备依赖MIDI 端口会直接废掉整条播放路径Python 做演示级播放可以做工具级实时播放不行。这组限制正是后来把整个项目迁移到 Rust cpal ringbuf 的直接原因——音频线程独立、无锁队列传命令、回调里直接混音这些在 Python 生态里要么没有要么得靠一堆 C 扩展拼装。如果重来一次播放器部分会直接按独立音频线程 命令队列 回调混音的模型设计而不是先 MIDI、再合成、再事件驱动地绕三圈。
返回列表