起因
去年用 Python + pyglet 写了一个钢琴卷帘 MIDI DAW:鼠标在网格上画音符、拖拽、改长度,支持播放。功能跑通之后,把它重写成了 Rust + egui 的鬼畜采样器,原仓库随后删除。这篇复盘记录 "为什么迁、迁了什么、丢掉了什么、代价是什么"。
原版做了什么
Python 版一共约 765 行,核心结构:
-
editor.py:钢琴卷帘编辑器,431 行。网格绘制、左侧键盘、鼠标添加 / 删除 / 拖动 / 改长度音符、播放头 -
midi_player.py:播放器。mido 走 MIDI 端口 + pyglet 合成正弦波双模式 -
playback.py:播放控制器,事件驱动调度
功能上是一个能用的演示级 DAW:有钢琴卷帘、有网格对齐、有播放头、能出声音。但播放部分踩了一串坑,详见另一篇《用 time.sleep 播 MIDI 踩的坑》。
撑不下去的三个边界
-
实时音频:MIDI 端口依赖设备,合成器用
time.sleep和 GUI 事件循环调度,节奏对不上、卡顿会传导 -
分发成本:
uv add pyglet mido pygame rtmidi,装依赖、跑 Python 环境,别人拿到手要先折腾环境 -
扩展方向:想把 "钢琴卷帘" 从 MIDI 扩展到 "人声切片 + 歌词 + 鬼畜排列",Python 这边每加一个音频处理能力都要绑一个 C 扩展
为什么选 Rust + egui
-
egui 是 immediate mode,绘制和交互写在一个
show()里,状态自持,和 pyglet 手动维护 Batch、shapes、坐标换算相比,改动界面的成本低一个量级 -
cpal 直接控制音频输出,回调里混音,不依赖任何系统播放器或合成器
-
symphonia 纯 Rust 解码 wav/ogg,不需要 ffmpeg
-
编译产物单文件分发,无运行时依赖
迁移不是移植:常量继承与方向改变
重写时最意外的发现:钢琴卷帘的布局常量几乎原样搬了过来。
| 参数 | Python | Rust |
|---|---|---|
| 每拍宽度 | 90 px | 80 px |
| 每半音高度 | 20 px | 18 px |
| 可见节拍 | 32 | 32 |
| 可见音符 | 36 | 36 |
| 最低音 | C2 (36) | C3 (48) |
| 黑键集合 | {1,3,6,8,10} | {1,3,6,8,10} |
网格模型、坐标换算、点击判定这些交互逻辑,在 Rust 版 piano_roll.rs 里基本是逐行对应。但音符的语义变了:Python 版每个音符是一个 MIDI 音号 + 时长,Rust 版每个音符引用一个切片(歌词字 + 音频区域)------ 这就是 "DAW 变鬼畜采样器" 的方向变化。网格是骨架,音符是内容,骨架可以继承,内容换了物种。
音频线程模型:从 sleep 到 ringbuf
Python 版最大的病根是时序调度,Rust 版直接换了架构:
-
音频线程由 cpal 回调驱动,不 sleep
-
GUI 到音频线程走无锁环形队列(ringbuf),只传命令
-
回调里维护 voice 列表,逐帧混音
enum Cmd {
Trigger { samples: Arc<Vec<f32>>, gain: f32 },
}
// 回调里:先排空命令,再混音
while let Some(cmd) = cons.try_pop() {
match cmd {
Cmd::Trigger { samples, gain } => voices.push(Voice { samples, pos: 0, gain }),
}
}
for frame in out.chunks_mut(channels) {
let mut acc = 0.0f32;
for voice in voices.iter_mut() {
if voice.pos < voice.samples.len() {
acc += voice.samples[voice.pos] * voice.gain;
voice.pos += 1;
}
}
...
}
三个细节值得抄:
-
无锁队列的容量按命令数设计(64 条),不是按采样数,内存可控
-
采样格式用泛型分发,
SampleFormat::F32 / I16 / U16各生成一路构建函数,避免回调里每帧做运行时分支 -
音频初始化失败时返回
null()引擎,GUI 照常运行,不会一启动就崩
迁移代价
-
Rust 所有权和借用规则有学习曲线,初期写 UI 状态(选中的切片、拖拽状态)比 Python 慢
-
egui 的 immediate mode 没有 "窗口控件对象",状态要么存在 App 结构体里,要么每次重画重建,思维要切换
-
MIDI 端口播放能力被丢掉了。如果以后要接硬件合成器或 MIDI 输出,得另引 midir 之类的库
-
音频解码从 "合成正弦波" 变成 "读真实文件",symphonia 的多声道转单声道、采样率保留这些都要自己处理
给想迁移的人
-
如果原项目有 "布局常量" 这类设计沉淀,重写时先抄常量再改逻辑,能省一半工作量
-
音频这种实时场景,先定线程模型再写功能,别先功能后架构
-
迁移不是 1:1 移植,趁机把 "用不上的能力"(MIDI 端口)和 "撑不住的能力"(sleep 调度)一起丢掉,比原样搬过来更有价值
-
Rust 版的音频线程模型、命令队列设计,可以原样复用到任何需要 "GUI + 实时音频" 的项目里