RK3588 的 NPU 能做 TTS 嗎?ROCK 5C 測了 6 套 NPU 語音合成,最快 RTF 0.07

ROCK 5C 單板電腦的 NPU 晶片發出聲波

同一句 22 個字的中文,在同一塊 ROCK 5C 上,最快的組合 0.26 秒就念完了,最慢的要等 398 秒。差了一千五百倍,而且慢的那個還是「有用到 NPU」的。

這篇是我把 RK3588 上找得到、能用 NPU 加速的 TTS 一個一個裝起來實測的紀錄:Piper、Matcha + Vocos、MeloTTS、Kokoro、F5-TTS、mms_tts,其中 5 套跑得動,mms_tts 卡在轉檔;另外加一個純 CPU 的 GPT-SoVITS(Genie-TTS)當對照組。結論是:要快就選 Piper 或 Matcha,RTF 都在 0.07 上下,記憶體只要幾百 MB;要複製特定角色的聲音,RK3588 目前做得到,但慢到不能量產。

過程中板子還重開了兩次、卡死一次:重開那兩次是電源的鍋,卡死那次是我自己把記憶體吃爆。這些坑我也一併寫進來,免得你重蹈覆轍。

上一篇我用同一塊板子測了 LLM,有興趣可以先看 8GB 的 RK3588 能跑多聰明的 LLM?ROCK 5C 用 NPU 實測 5 個模型 ,這篇的 NPU 驅動與 RKLLM 環境就是延續那時候的設定。

環境與前置條件

項目 版本 / 規格
板子 Radxa ROCK 5C,RK3588S,8GB LPDDR4X
系統 Debian 12 (bookworm),kernel 6.1.84-8-rk2410
NPU runtime librknnrt 2.5.0
轉檔工具 rknn-toolkit2 2.5.0(aarch64,直接裝在板子上)
推論 Python 套件 rknn-toolkit-lite2 2.5.0;Kokoro / Matcha / Piper 走 rkvoice-stream,用另一個 venv 裝它鎖定的 2.3.2
Python 3.11
測試句 「漲得很漂亮,我沒上車。不建議你在沒回檔的地方追。」

兩個 venv 只是 Python 綁定的版本不同,底下呼叫的都是系統同一份 librknnrt 2.5.0,轉檔則一律用板子上的 rknn-toolkit2 2.5.0。

衡量指標用 RTF(Real-Time Factor)= 生成時間 ÷ 音檔長度,小於 1 代表比即時快。每個模型都先跑一次暖機,表格取熱機後的數字。音質這次我沒有做 MOS 評分,這篇只談速度、資源和能不能用,聽感請自己判斷,所有音檔我都留著。

六套 NPU TTS 跑分總表

六套 TTS 在 RK3588 NPU 上的速度競賽

TTS NPU 跑哪一段 輸出音檔長度 RTF(熱機) 記憶體峰值 複製音色 繁體輸入 取得方式
Piper(zh_CN huayan medium) Flow + 解碼器 3.6~3.9 秒 0.07 210MB 不行 可以(espeak 音素化) 自己拆模型、在板子上轉 RKNN
Matcha + Vocos(matcha-icefall-zh-en) Vocos 聲碼器 5.52 秒 0.075 337MB 不行 可以(字典有繁體) Vocos 自己轉,聲學模型留 CPU
MeloTTS-RKNN2 解碼器 5.21 秒 0.23 未量 不行 可以 現成 .rknn
Kokoro ConvOnly 前段 + 部分尾段 7.0 秒(含補靜音,實際語音約 5.5 秒) 0.54(以實際語音算 0.69) 2.9GB 不行(有內建日文聲音) 可以 現成模型包約 478MB
F5-TTS-RKNN2 DiT 主幹 6.52 秒 約 61 README 寫 2.2GB 可以 可以 現成 .rknn
mms_tts(rknn_model_zoo) 編碼器 + 解碼器 — — — 不行 只有英文 解碼器轉檔失敗
(對照)Genie-TTS / GPT-SoVITS v2ProPlus 沒用 NPU 3.64 秒 約 8 5.2GB 可以 可以 pip 套件 + 自己轉 ONNX

每一列念的都是同一句測試句,但各模型的語速、補靜音、取樣率都不同,輸出長度差了快一倍,所以 RTF 拿來看數量級就好,0.07 跟 0.075 這種差距沒有意義。F5-TTS 和 Genie 另外各吃一段參考音,條件也不一樣。

數字背後的細節後面一個一個拆,這裡先講最反直覺的一點:跑得快的那幾個,NPU 其實只負責後半段。

為什麼最快的都是「混合推論」

RK3588 的 NPU 標稱 6 TOPS,但那是低精度工作負載的數字(Turing Pi 的架構解析 ),這次測的模型幾乎都跑 FP16。更麻煩的是 RKNN 的模型輸入形狀基本上要固定,F5-TTS-RKNN2 的作者在 README 裡就直說「由於 RKNN2 不支持動態輸入,這裡把序列長度固定為了 1536」。TTS 偏偏到處都是變長的東西:文字長度會變、音素長度會變、duration predictor 算出來的 mel 長度也會變。

所以實際能跑的做法幾乎都長這樣:

文字
 └─> 文字前端:音素化 / 查字典            (CPU)
      └─> 編碼器 + 時長預測,長度會變      (CPU, ONNX Runtime)
           └─> 補齊到固定長度,例如 mel 256 / 600 幀
                └─> Flow / 解碼器 / 聲碼器,計算量最大  (NPU, RKNN)
                     └─> ISTFT / 裁掉補齊的部分         (CPU)
                          └─> 音檔

把長度會變、又牽涉 attention 的前半段留在 CPU,把卷積為主、形狀可以固定的後半段丟給 NPU。rkvoice-stream 的 Piper 部署文件 講得很直白:如果硬把 VITS 編碼器 padding 成固定長度丟上 NPU,時長預測會被 padding 汙染,「生成音訊相關性接近 0,完全不可用」。

Piper 和 Matcha 就是這個模式的典範,計算量最重的部分在 NPU 上一兩百毫秒就跑完,CPU 那段也只要一兩百毫秒,整句 0.26~0.42 秒收工。

逐一實測:快的、慢的、轉不過的

Piper:0.26 秒念完一句,冠軍

Piper 官方的 zh_CN-huayan-medium 是 63MB 的 ONNX,22.05kHz 單一說話人。rkvoice-stream 附了一個拆模型腳本 split_piper_vits.py,把它切成 encoder.onnx(CPU)和 flow_decoder.onnx(固定 mel_len=256),剩下的 RKNN 轉換要自己來。

轉換腳本我寫成最小版,板子上 22 秒轉完,產出 25MB:

# conv_fp16.py — 用法:python conv_fp16.py flow_decoder.onnx flow_decoder.rknn
import sys
from rknn.api import RKNN

src, dst = sys.argv[1], sys.argv[2]
r = RKNN(verbose=False)
assert r.config(target_platform="rk3588", optimization_level=3) == 0
assert r.load_onnx(model=src) == 0
assert r.build(do_quantization=False) == 0   # FP16,不量化
assert r.export_rknn(dst) == 0
print("OK", dst)

中文音素化需要 espeak-ng,板子上沒裝,用 apt 又要 sudo。好在 pip install piper-phonemize 在 aarch64 上有現成 wheel,自帶 espeak-ng 資料,rkvoice-stream 的 Piper 後端會優先用它。三次熱機結果:音檔 3.6~3.9 秒,推論 0.25~0.26 秒,RTF 0.067~0.070,記憶體峰值 210MB。

Matcha + Vocos:Piper 的好對手

matcha-icefall-zh-en 是 sherpa-onnx 釋出的中英混合 Matcha-TTS,聲碼器搭 vocos-16khz-univ(約 54MB)。rkvoice-stream 的 Matcha 後端支援「聲學模型跑 ONNX Runtime、Vocos 跑 RKNN」的組合,只要設 MATCHA_USE_ORT=1。

Vocos 用他們的 convert_vocos_16khz_rknn.py 轉,600 幀(約 9.6 秒)一個 bucket,板子上 18 秒轉完、28MB。實測 5.52 秒的音檔:聲學模型 0.26 秒、Vocos 0.15 秒,總共 0.42 秒,RTF 0.075,記憶體 337MB。

我本來擔心字典只有簡體字,結果翻 lexicon.txt 發現「漲涨、沒没、車车、議议、檔档」繁簡都在,繁體直接餵沒問題。唯一的取捨是輸出只有 16kHz。

MeloTTS-RKNN2:最好上手,但每次啟動要等

happyme531/MeloTTS-RKNN2 是現成可下載的 .rknn,作者標的是 RK3588 上約 5 倍即時、0.2GB 記憶體。我實測編碼器 0.1 秒、NPU 解碼器每段 0.2~0.3 秒,一句 5.21 秒的音檔推論約 1.2 秒,RTF 約 0.23,換算約 4.3 倍即時,比作者寫的 5 倍略慢。README 沒寫測試句和條件;我這邊把 CPU 部分限制在 4 顆小核,這可能是原因之一,但我沒有拿大核跑對照組確認。繁體也能念。

麻煩在它的 demo 每次都是從頭啟動,載 jieba 字典、初始化 RKNN 就吃掉約 30 秒,整支程式跑完 32 秒。要拿來用,一定得改成常駐服務,不然根本是在等開機。

Kokoro:唯一有日文聲音,但會偷補靜音

Kokoro 82M 整張圖直接轉 RKNN 是不可行的。rkvoice-stream 的 可行性分析 是在 RK3576 上做的,指出 ISTFTNet 聲碼器內部時間維度到 13081(文件附的報錯值是 0x3318 = 13080,跟內文差 1,原文如此),超過 NPU 單層上限 8191(0x1fff)。RK3588 也有同一個上限,我後面轉 mms_tts 時就撞到一模一樣的 limit: 0x1fff。分析文件裡的報錯長這樣:

REGTASK: The bit width of field value exceeds the limit,
         target: f2, limit: 0x1fff, value: 0x3318 (13080)

作者最後拆成一大堆小 RKNN 子圖,發布成 harvestsu/seeed-local-voice-rk-artifacts 的 ConvOnly 模型包,RK3588 版約 478MB,搭 pip install "rkvoice-stream[kokoro-zh,kokoro-runtime]==0.2.0" 使用:

import os
root = os.path.expanduser("~/tts-bench/kokoro-art/rk3588/kokoro-convonly-v1_0")
os.environ.update(
    KOKORO_CONVONLY_ROOT=root,
    RK_PLATFORM="rk3588",
    # 官方文件公布的 RK3588 manifest SHA256,不符會拒絕載入
    KOKORO_CONVONLY_MANIFEST_SHA256="83733c717e0ce5b76ac1295e4827cf3ad2e111955259e9d670897e100fabeb6e",
)
from rkvoice_stream.backends.tts.kokoro_convonly import KokoroConvOnlyBackend

tts = KokoroConvOnlyBackend()
tts.preload()
text = "漲得很漂亮,我沒上車。不建議你在沒回檔的地方追。"
wav_bytes, meta = tts.synthesize(text, language="zh")  # 日文用 "ja"
print(meta["engine"], meta["full_rtf"])

中文聲音是 zf_xiaobei,熱機後 3.78 秒生成一段「7.0 秒」的音檔,RTF 0.54;日文 jf_alpha 念的是同一句的日文翻譯,也跑得動,RTF 0.47。記憶體峰值 2.9GB,跟 Piper 比是一個數量級的差距。

注意那個「7.0 秒」。我量了波形,真正有聲音的只有 0.4~5.89 秒,後面一秒多是補的靜音,這是固定長度 bucket 的副作用。用實際語音長度算,RTF 其實是 0.69,跟 rkvoice-stream README 跑分表裡 RK3588 的 0.77 很接近。直接拿 meta 裡的 RTF 去比,會讓 Kokoro 看起來比實際快。

mms_tts:model zoo 裡的那個,轉不過

官方 rknn_model_zoo 裡就有 mms_tts 範例,但預設模型是 facebook/mms-tts-eng,只會念英文。我還是轉轉看當 NPU 基準,結果編碼器順利,解碼器第一關就卡住:

ValueError: Found Random node 'RandomNormalLike_7', which is not supported,
please remove it manually and try again!

VITS 會用 randn_like 產生雜訊。model zoo 的匯出文件其實有提到要先固定亂數輸入,所以我寫了一個小工具把它換成固定的亂數常數:

# fix_random.py — 把 RandomNormalLike 換成固定亂數常數
import sys, onnx, numpy as np
from onnx import numpy_helper, shape_inference

src, dst = sys.argv[1], sys.argv[2]
m = shape_inference.infer_shapes(onnx.load(src))
g = m.graph
shapes = {vi.name: [d.dim_value for d in vi.type.tensor_type.shape.dim]
          for vi in list(g.value_info) + list(g.input) + list(g.output)}
rng = np.random.default_rng(0)
for n in [n for n in g.node if n.op_type == "RandomNormalLike"]:
    shp = shapes.get(n.output[0]) or shapes.get(n.input[0])
    assert shp and all(shp), f"unknown shape for {n.name}"
    g.node.remove(n)
    g.initializer.append(numpy_helper.from_array(
        rng.standard_normal(shp).astype(np.float32), n.output[0]))
onnx.save(m, dst)

這次過了 RandomNormalLike 這關,但轉檔跑了 10 分鐘、記憶體吃到 6.1GB 還在漲,log 裡一直刷 REGTASK: The bit width of field value exceeds the limit,跟 Kokoro 撞到的是同一面牆。反正只會念英文,我就停掉了。

能複製聲音的,都慢到哭

NPU 聲音複製的概念圖

我做這輪測試的初衷,其實是想知道能不能在 RK3588 上幫遊戲角色補台詞。原本的流程是 GPU 上跑 GPT-SoVITS v4,用 12 段日語參考音念中文台詞,做出 12 個日腔角色。

F5-TTS:唯一用到 NPU 的零樣本複製

happyme531/F5-TTS-RKNN2 把 F5-TTS 的 DiT 主幹轉成 RKNN,前處理和解碼留在 CPU。我拿一句已經用 GPU 做好的角色台詞當參考音,讓它念測試句。結果:每步 12.4 秒,跑 32 步,6.52 秒的音檔花了 398 秒,RTF 約 61。作者寫的是每步 11 秒、生成 9 秒音訊總共約 352 秒,換算 RTF 約 39。因為序列長度固定在 1536,每一步的計算量跟音檔長短無關,所以總時間差不多,RTF 的差距大多來自我這句比較短(6.52 秒對 9 秒)。剩下每步慢了約 13%,原因我沒有細查。

更要注意的是 log 最後這兩行:

Adjusting audio speed...
Applying speed change with factor: 1.920

作者在 README 講了原因:RKNN2 不支援動態輸入,所以把序列長度固定成 1536,再用變速來補償長度差。這次差距大到要加速 1.92 倍,參考音加生成內容遠短於 1536 幀時就會這樣,音質有沒有被拖累,得自己聽。

Genie-TTS:GPT-SoVITS 的 CPU 版,反而比 NPU 快

GPT-SoVITS 找不到 RKNN 移植。它的 T2S 是逐 token 自迴歸生成,每個 token 都要呼叫一次、KV cache 長度又一直變,RKLLM 不支援這個自訂架構,RKNN 也不適合。v3/v4 改用 flow matching 加 48kHz 聲碼器,計算量更重。

所以我改走 High-Logic 的 Genie-TTS ,它把 GPT-SoVITS 轉成 ONNX 並針對 CPU 優化,但截至 2026 年 10 月,README 寫的是 convert_to_onnx 只支援 V2 / V2ProPlus,V3、V4 還在待辦清單上。我用的是 2.0.2 版。我在 Mac 上用 convert_to_onnx 把官方 V2ProPlus 基礎權重轉成 320MB 的 ONNX,再丟上板子。結果 3.64 秒音檔要 29.5 秒,RTF 約 8,峰值記憶體 5.2GB。

RTF 8 對上 F5 的 61,純 CPU 的 Genie 反而快了 7 倍多,這也說明「有用到 NPU」不等於快。當然這不全是 NPU 的問題:F5 被固定 1536 的序列長度和 32 步擴散拖累,兩個模型的架構也完全不同。不過 V2ProPlus 和原本用的 v4 是不同模型,聲音接不上既有的檔案。真要大量補台詞,還是乖乖回 GPU。

踩坑紀錄:重開是電源的鍋,卡死是記憶體

坑 1:滿載時板子直接重開

電源不足導致單板電腦重置

第一次是 Genie 讓 onnxruntime 吃滿 8 顆核心,跑幾秒板子就重開了。journal 最後一筆紀錄停在啟動後幾秒,沒有任何關機訊息,像被直接拔電。第二次更扯:F5-TTS 才剛載入模型、NPU 和小核開始動,又重開,當時可用記憶體還有 6.3GB,跟 OOM 無關。

換了電源之後,我寫了一個分段壓力測試,狀態每秒寫進檔案並 sync,就算又重開也知道死在哪一段:4 顆大核 60 秒 → 8 核全開 60 秒 → 8 核加 NPU 60 秒。三段全過,大核一直維持 2.3GHz,最高 63°C,風扇在 60°C 時全速。之後 F5-TTS 用全部核心跑完 398 秒也沒出事。

Radxa 官方的 ROCK 5C Quick Start 寫的是 5V/3A 起跳,並推薦官方 PD 30W 變壓器。我這次沒有量舊電源的實際輸出,只能說換一顆新電源後問題就消失了。我的心得是:要跑 NPU + CPU 重負載,直接上 PD 30W,別拿手邊隨便一顆手機充電器將就。 突然斷電還可能讓 eMMC 上的檔案系統受損,這個風險不值得賭。

坑 2:8GB 被吃滿,系統卡到只剩 ping

8GB 記憶體被塞滿的概念圖

這個坑是我自己挖的。那時 rkllm-api 還載著 Qwen3.5-2B(佔 2.7GB),我又同時開 rknn-toolkit2 轉 mms_tts 解碼器、pip 裝套件、下載模型。結果板子 ping 得通、22 port 也接受連線,但 SSH 一直卡在 Connection timed out during banner exchange,等了 15 分鐘都沒恢復,只能拔電。這是典型的記憶體耗盡後瘋狂 swap(thrashing),核心還活著,使用者程序幾乎動不了。

之後我寫了一個保護腳本,所有重工作都包著它跑,可用記憶體低於門檻就整組砍掉,寧可工作失敗也不要板子卡死:

#!/bin/bash
# guard.sh <cmd...> — MemAvailable < GUARD_MB(預設 800)時砍掉整個程序群組
LIMIT=${GUARD_MB:-800}
setsid "$@" &
PID=$!
while kill -0 $PID 2>/dev/null; do
  AV=$(awk '/MemAvailable/{print int($2/1024)}' /proc/meminfo)
  if [ "$AV" -lt "$LIMIT" ]; then
    echo "GUARD: MemAvailable=${AV}MB < ${LIMIT}MB, killing pgid $PID" >&2
    kill -9 -- -$PID 2>/dev/null; wait $PID 2>/dev/null; exit 137
  fi
  sleep 0.5
done
wait $PID

用法是 GUARD_MB=1200 ./guard.sh python convert.py ...。我先用一個故意吃 2GB 的 Python 程式驗證過,它確實會在門檻被觸發時砍掉,記憶體也回到原本的水位。另外一條鐵律:qwen 和重量級 TTS 不要同時載。Genie 一開始就是因為 qwen 還在記憶體裡,直接被核心砍掉(exit 137)。

坑 3:rknn-toolkit2 可以直接在板子上轉檔

很多教學還在說轉 RKNN 一定要 x86 主機,這已經過時了。rknn-toolkit2 從 2.3.0 版開始支援 ARM64 ,我板子上裝的 2.5.0 轉 Vocos 18 秒、轉 Piper flow_decoder 22 秒,完全不用另外開 VM。轉檔時 log 會刷一堆 E RKNN: Unkown op target: 0,看起來嚇人,但模型照樣能用。真正要擔心的是記憶體,大模型轉檔請務必先停掉其他服務。

坑 4:其他小雷

  • Piper 拆模型腳本的 --verify 會誤報:它把長度 17 的資料直接送進固定 256 的模型,噴 INVALID_ARGUMENT ... Got: 17 Expected: 256。但拆出來的兩個 ONNX 是正常的,跳過驗證即可。
  • log 被 \r 蓋掉:RKNN 原生程式的輸出夾帶 carriage return,Python 印的結果行會被覆蓋,grep 看起來像程式只跑了一半。用 tr '\r' '\n' < log | grep ... 先換行再撈。
  • RTF 要看實際語音長度:Kokoro 這類固定 bucket 的模型會補靜音,直接用輸出長度算會高估速度。
  • 風扇 60°C 才轉,而且會一直開開關關:溫控用 step_wise、沒有遲滯,連續推論時溫度卡在 59~61°C,風扇十幾秒切換一次。在意噪音的話要改 device tree overlay。

我的選型建議

RK3588 在家中做語音播報

你的需求 推薦 理由
智慧家庭播報、念通知、語音助理回話 Piper zh_CN 或 Matcha + Vocos RTF 0.07,記憶體幾百 MB,可以跟本地 LLM 共存
想要最少折騰、下載就能跑 MeloTTS-RKNN2 現成 .rknn,記得改成常駐服務
需要日文聲音 Kokoro jf_alpha 唯一的選擇,但要預留 3GB 記憶體
複製特定角色聲音 回 GPU,或接受 F5 / Genie 的速度 NPU 版 F5 RTF 61,CPU 版 Genie RTF 8,都只適合偶爾補幾句
只要英文 Piper 英文模型 mms_tts 解碼器目前轉不過

如果你跟我一樣,板子上本來就跑著 RKLLM 的本地 LLM,那 Piper 和 Matcha 的優勢會更明顯:LLM 吃掉 2~5GB 之後,剩下的空間只放得下這種輕量的 TTS。語音辨識的部分我在 sherpa-onnx 對上 Whisper:離線語音辨識怎麼選? 比較過,接起來就是一條完全離線的語音管線。

Troubleshooting

Q:SSH 卡在 banner exchange,ping 卻通? 多半是記憶體耗盡在 thrashing,等不回來,只能斷電重開。重開後先停掉吃記憶體的服務,再用 guard.sh 包住重工作。

Q:板子在滿載時沒有任何紀錄就重開? 先懷疑電源。換 PD 30W 後跑分段壓測(大核、全核、全核加 NPU 各 60 秒),能撐過再繼續。

Q:轉 RKNN 報 Found Random node 'RandomNormalLike'? 把隨機節點換成固定亂數常數(上面的 fix_random.py),或者匯出 ONNX 時就把雜訊改成輸入。

Q:轉檔報 REGTASK: The bit width of field value exceeds the limit? 某一層的時間維度超過 NPU 上限 8191。縮小輸入長度通常沒用,要把那段模型拆出來留在 CPU,或分段成多個小 RKNN。

結語

RK3588 的 NPU 拿來做 TTS,以我測到的這幾套來看:固定形狀、以卷積為主的聲碼器最適合丟給 NPU;能複製聲音的兩個樣本(F5 的擴散、GPT-SoVITS 的自迴歸)目前在這塊板子上都還快不起來。 嚴格說,真正用到 NPU 的複製音色樣本只有 F5 一個,Genie 是純 CPU 的對照;F5 的慢也有一部分來自固定序列長度。所以我不敢說這類模型永遠不行,只能說現成的移植還不夠快。 Piper 和 Matcha 已經快到可以當即時語音助理的嘴巴,而且省資源;想讓板子模仿某個人的聲音,現階段還是太勉強。

如果你手上也有 RK3588 的板子,我會建議先從 Piper zh_CN 開始,半小時內就能聽到自己的板子開口說話。有測到其他能跑 NPU 的 TTS,或是把 GPT-SoVITS 搬上 RKNN 的做法,很歡迎留言跟我分享。

參考資料

相關文章

沒有留言:

張貼留言

91APP 被駭的起點竟是 Appsmith:自架低代碼工具為何成了企業後門

上個月有朋友傳來一則簡訊截圖問我是不是詐騙。對方報得出她在某品牌官網買了什麼、寄到哪裡、哪天下單,然後說「訂單設定錯誤,請加 LINE 協助解除分期」。這種劇本大家都聽過,但這次的細節準到讓人頭皮發麻,因為資料是真的。 兩週後答案揭曉:台灣電商 SaaS 龍頭 91APP 的...