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

相关推荐
“AI国潮设计-小江”1 小时前
【Python实战】SDXL精准控制“普宁英歌舞×星空蛋糕”IP落地,附核心Prompt与商用授权思路
开发语言·人工智能·python·prompt·aigc
C++ 老炮儿的技术栈1 小时前
MFC CPtrArray的用法
开发语言·数据结构·c++·算法·mfc·c
佳児素花痴╮1 小时前
C++基础速通
开发语言·c++
是立不是利1 小时前
深入理解JavaScript事件委托
开发语言·前端·javascript
啊阿狸不会拉杆1 小时前
《计算机网络-自顶向下方法》6.4 交换局域网 读书笔记
开发语言·计算机网络·php
tryCbest1 小时前
FastAPI中passlib包的作用
python·fastapi·passlib
心中有你02141 小时前
Python Tkinter 情侣恋爱日记桌面小程序(本地文件存储,无数据库)
开发语言·python
PFFstronger1 小时前
从 0 到 1 搭建接口自动化测试框架:分层架构 + 数据驱动 + 接口依赖编排
python·架构·自动化
Andya_net2 小时前
Spring Boot | 条件注解完全指南:从 @Conditional 到 @ConditionalOnExpression 的原理、实践与避坑
spring boot·后端·python