从 Python 搬到 Rust:pyglet MIDI DAW 变成 egui 鬼畜采样器的迁移复盘

起因

去年用 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 + 实时音频" 的项目里

相关推荐
进击的明明9 小时前
当AI开始“边想边干”:深入聊聊ReAct,以及如何用Python手搓一个简单Agent
python·面试·agent
全栈弄潮儿9 小时前
Python实战第8期:函数
后端·python·编程语言
卷无止境11 小时前
暗流与破局:AI时代的代码安全审查工具全景指南
后端·python
卷无止境14 小时前
一人公司的全栈选型指南:让 AI Agent 写得快,也让生意跑得稳
后端·python
WJ.Polar15 小时前
Java封装、继承、多态
java·开发语言
摇滚侠15 小时前
Java 中如何表示负数,有符号和无符号的区别,补码的计算规则,char byte short int long 的范围
java·开发语言
滴啦嘟啦哒15 小时前
【具身笔记】【π0.5 微调与仿真】五、在MuJoCo中实现可视化仿真
笔记·python·vla·具身
weixin_3077791315 小时前
C++代码实现MATLAB中的fitrtree函数功能
开发语言·c++·算法·matlab
全栈练习生16 小时前
AI安全与红队测试
python·ai
爱读书的小胖16 小时前
Codewars:Rot13(C)解题分析
c语言·开发语言