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

從零寫一個跨平台 DAW 引擎——Rust x Flutter 實戰

Authors
  • avatar
    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 的開發協助,歡迎聯繫我們。


這個系列的其他文章: