
- Published on
- ·10 min read
從零寫一個跨平台 DAW 引擎——Rust x Flutter 實戰
- Authors

- Name
- Bert / DOTUNE
- Developer
我們做了一個在手機上跑的 DAW(Digital Audio Workstation)。完整的音樂工作站——多軌編輯、MIDI 編曲、SoundFont 合成、即時錄音、混音匯出——在 iOS、Android、Web、Desktop 全部可以跑。
這篇文章回顧整個技術選型和引擎架構,以及實時音頻處理最關鍵的三個關鍵問題。
為什麼從零寫 DAW 引擎
業務需求很明確:我們需要一個能在手機上跨平台運行的音頻 App。不只是播放器,是完整的音樂工作站——使用者可以在手機上編輯 MIDI、載入 SoundFont、錄音、混音,然後在 iOS、Android、Web、Desktop 上打開同一個 project。
現有方案沒有一個能同時滿足這些條件。要嘛綁平台(iOS 獨佔)、要嘛 mobile 版功能閹割嚴重、要嘛根本沒有開放 API 可以客製化。
所以我們就開始自己寫。
為什麼是 Rust x Flutter
這不是偏好問題,是技術約束下的必然結果。
Rust 負責音頻引擎
實時音頻處理有一個不可妥協的約束:音頻回調 thread 絕不能掉幀。48kHz 取樣率、512 samples block size 下,你只有大約 10ms 處理一整批音頻資料。這 10ms 內如果 GC 暫停、thread 阻塞、heap 分配——使用者就會聽到爆音。

Rust 沒有 GC。零成本抽象。記憶體安全在編譯期就搞定,不需要 runtime GC thread 來搶你的 10ms。加上 CPAL(跨平台音頻 I/O)、fundsp(DSP graph)、rustysynth(SoundFont 合成)這些 crate 已經把底層輪子造好了。
Flutter 負責 UI 和跨平台

DAW 的 UI 是出了名的複雜——時間軸、鋼琴捲簾、混音面板、和弦編輯器——全部在一套 Dart code 裡搞定,iOS、Android、Desktop 同時出。Flutter 自己畫像素的渲染模型,對時間軸這類大量自訂繪製的 UI 很適合,不會被平台原生元件卡住。
其他方案為什麼不行
Kotlin Multiplatform 共用 business logic,但 UI 還是要寫兩套——iOS 用 SwiftUI、Android 用 Compose,工作量翻倍而且兩套 UI 行為不一致是日常。React Native 的 Bridge 架構碰到即時音頻這種高頻雙向傳輸,序列化成本會直接把效能吃掉。純原生各寫一套——那是大團隊才玩得起的。
Rust 的高執行效率 + Flutter 的高開發效率,是小團隊能在合理時間內做出跨平台 DAW 的唯一路徑。更完整的論述在 Flutter + Rust 那篇。
引擎架構——Command/Event + Audio Graph
整個引擎有兩條線:控制流和音頻流。
控制流:Command → Engine → Event
Flutter UI 從不直接碰音頻引擎的狀態。所有操作都是發一個 Command 下去,Rust 處理完之後廣播 Event 回來:
// Flutter 端:只發 command,不直接改狀態
engine.sendCommand(PlayRequest());
engine.sendCommand(AddTrackRequest(track));
engine.sendCommand(SetPositionRequest(beat: 16.0));
// Flutter 端:監聽 Rust 回傳的 event stream
engine.eventStream.listen((event) {
switch (event) {
case PlaybackStarted(): ...
case PlayHeadStateUpdated(:final position): ...
case TrackAdded(:final trackId): ...
}
});
Rust 端跑在獨立 thread 上,不吃 UI thread 的時間:
// Rust 端:command processing
fn process_command(engine: &mut Engine, cmd: EngineRequest) {
match cmd {
EngineRequest::Play => engine.start_playback(),
EngineRequest::Stop => engine.stop_playback(),
EngineRequest::AddTrack(track) => {
engine.project.add_track(track);
engine.rebuild_audio_graph();
engine.broadcast(AudioEvent::TrackAdded { track_id: track.id });
}
// ...
}
}
Rust 是唯一真相來源。Flutter 端不持有 project 狀態——UI 拿到的所有資料都是從 event stream 推過來的。這跟 Riverpod/Bloc 那篇 討論的單向資料流是同一回事,只是把狀態管理從 Dart 搬到了 Rust。
音頻流:Node Graph
音頻處理的核心是一個有向無環圖(DAG)。每個 track 在 graph 裡是一條處理鏈:
PortNode (MIDI 輸入)
→ TrackInputBridge
→ ClipsNode (時間軸 clip 播放)
→ RustySynthNode (SoundFont 合成) / WaveNode (音頻播放)
→ GainNode (音量、mute、solo)
→ SummingNode (混音匯流)
→ 輸出
10 種 node 類型,topological sort 決定執行順序。Graph 只在結構性變更時重建(新增/刪除 track、改變 routing),屬性變更(調音量、移動 clip)直接改 node 的 atomic flag 或 mutable reference,不用 rebuild。
這個設計直接參考各種開源引擎的 node graph——成熟的 DAW 引擎都是這樣做的.
實時音頻的三個關鍵問題
一、音頻回調絕不能阻塞——try_lock 全場
音頻回調跑在系統級 audio thread 上,優先級極高。如果這個 thread 被阻塞超過一個 buffer 的時間,audio buffer underrun = 爆音。
所以全場只用 try_lock(),絕不等:
// 音頻回調內:拿不到 lock 就輸出靜音,絕不阻塞
if let Ok(mut processor) = shared.processor.try_lock() {
processor.process_block_with_snapshot(&mut block, &mut *playhead);
} else {
block.fill(0.0); // 寧可短暫無聲,也不要 glitch
}
Command thread 在重建 audio graph 的時候會短暫持有這個 lock(通常 1-2ms),這時候音頻回調拿不到就直接輸出靜音。短暫無聲使用者聽不出來,但 glitch 會。
不只 processor——preview player、preview synth、metronome synth、performance stats,全部都走 try_lock()。音頻回調裡沒有一個地方是會 lock() 等下去的。
二、Denormal Float——聽不見但吃掉你 CPU
這是音頻工程師的經典噩夢。當訊號衰減到極小值(例如 reverb tail、fade out 尾端),浮點數會進入 denormal 範圍——數值小到 IEEE 754 無法用正常指數表示,CPU 必須切換微碼處理,一次運算從 1 cycle 變成 10-100 cycles。無聲的時候反而 CPU 飆最高。
x86_64 上設 MXCSR register 就能解決:
#[cfg(target_arch = "x86_64")]
unsafe {
// FTZ:運算結果是 denormal → 直接變 0
std::arch::x86_64::_MM_SET_FLUSH_ZERO_MODE(
std::arch::x86_64::_MM_FLUSH_ZERO_ON
);
// DAZ:讀到 denormal → 直接視為 0
let mut mxcsr = std::arch::x86_64::_mm_getcsr();
mxcsr |= 1 << 6;
std::arch::x86_64::_mm_setcsr(mxcsr);
}
ARM(aarch64)預設就開 FTZ,所以 iOS 和大部分 Android 裝置不需要手動處理。但如果你在桌面上跑 x86_64——沒設這個的話,一首歌放到尾端 CPU 會莫名其妙飆上去。
三、Panic 不能殺掉 App——catch_unwind
Rust 的 panic 如果在音頻回調裡發生,會一路 unwind 穿過 C FFI 邊界。Android 的 AAudio 收到 unwinding panic = SIGABRT,App 直接閃退。
所有音頻回調內的處理全部包 catch_unwind:
let result = std::panic::catch_unwind(
std::panic::AssertUnwindSafe(|| {
processor.process_block_with_snapshot(&mut block, &mut *playhead);
})
);
if result.is_err() {
block.fill(0.0); // panic 了就靜音繼續,不要死
}
這層保護在開發階段尤其重要——graph node 裡可能有幾十個懶載入的音頻檔、SoundFont、plugin,任何一個出錯都不能讓整個 App 陪葬。
其他問題——iOS DAC 的硬體級 fade-in(解法:注入 -100dB DC offset 保持 DAC 活躍)、Android AAudio 的 warmup、block-size 適配、iOS 麥克風權限動態管理——細節太多,點到為止。
跨平台的其他問題
Android AAudio 是最大的變數。CPAL 的 supported_output_configs() 在部分 Android 裝置上會直接 panic——不是回傳 error,是 panic。所以我們在 Android 上不走裝置列舉,直接用 default_output_device()、sample rate 寫死 48000Hz、buffer size 走系統預設(AAudio 不支援 Fixed)。藍牙裝置切換會讓 AAudio stream 斷開,所以要開獨立的 stream owner thread 監控裝置變化、自動重建——連 drop(stream) 都要包 catch_unwind,因為 CPAL 在 Android 上 drop stream 也可能 panic。
iOS 相對穩定,但麥克風權限的 UX 很敏感。App 啟動時不能直接開 input stream——iOS 會立刻彈出權限對話框,使用者根本還沒用到錄音就被問「是否允許存取麥克風」。解法是 input stream 預設關閉,只在用戶按錄音鍵時才打開,錄完馬上釋放。
結語
使用這個引擎的產品 Faso 已經在 faso.dotune.com 上線了。Rust x Flutter 是獨立團隊開發專業音頻應用最短路徑——Rust 扛即時音頻、Flutter 扛多平台 UI。
如果你也在做類似的專案,或需要音頻引擎、跨平台 DAW 的開發協助,歡迎聯繫我們。
這個系列的其他文章: