語音交互實戰(zhàn):sherpa-onnx集成ASR與TTS全指南)
1. 端側(cè)語音交互的現(xiàn)狀與 sherpa-onnx 的切入點把語音識別和語音合成塞進(jìn)手機(jī)這件事放在三五年前還屬于“實驗室里跑得動、真機(jī)上跑不動”的范疇。我最早接觸端側(cè)語音是在一個離線字幕工具的項目里當(dāng)時用某款云端 ASR 接口網(wǎng)絡(luò)一抖字幕就斷用戶罵聲一片。后來轉(zhuǎn)向端側(cè)方案試過 TensorFlow Lite 的語音模型、也試過 PyTorch Mobile 的量化版本踩了不少坑直到遇到 sherpa-onnx才算找到一個在 Android 上真正能落地的方案。sherpa-onnx 是 k2-fsa 團(tuán)隊開源的一套端側(cè)語音推理框架底層基于 onnxruntime支持流式和非流式 ASR、TTS、VAD、說話人分離等任務(wù)。它的核心價值在于模型體積小、推理速度快、跨平臺一致性好而且提供了完整的 Android 預(yù)編譯庫和 Kotlin/Java API。換句話說你不需要自己從 PyTorch 導(dǎo)出 ONNX、不需要手寫 JNI 膠水層、不需要折騰 NDK 編譯直接拿 AAR 包就能在 Android 項目里跑起來。這篇文章面向的是有一定 Android 開發(fā)基礎(chǔ)、想在 App 里集成離線語音能力的開發(fā)者。不管你是想做語音輸入法、離線字幕、語音助手還是想做 TTS 朗讀功能sherpa-onnx 都能給你一條相對平滑的路徑。我會從架構(gòu)設(shè)計、模型選型、實操集成、性能調(diào)優(yōu)、問題排查幾個維度把我在實際項目中積累的經(jīng)驗完整地分享出來。2. 為什么選 sherpa-onnx 而不是其他端側(cè)方案2.1 端側(cè)語音推理的三種主流路線對比在決定用 sherpa-onnx 之前我認(rèn)真對比過市面上幾條主流路線這里把關(guān)鍵差異整理成表格方便你根據(jù)自己的場景做判斷。方案底層引擎模型格式Android 支持流式 ASRTTS 支持集成難度sherpa-onnxonnxruntimeONNX官方 AAR支持支持低TensorFlow LiteTFLiteTFLite官方庫需自行實現(xiàn)有限中PyTorch MobilePyTorchTorchScript官方庫需自行實現(xiàn)有限中高云端 API服務(wù)端不涉及HTTP 調(diào)用支持支持低但依賴網(wǎng)絡(luò)從表格能看出來sherpa-onnx 最大的優(yōu)勢是“開箱即用”——它把流式 ASR 和 TTS 這兩塊最難啃的骨頭都封裝好了而且模型格式統(tǒng)一為 ONNX社區(qū)里現(xiàn)成的模型也最多。TensorFlow Lite 和 PyTorch Mobile 雖然也是端側(cè)方案但流式推理的邏輯需要你自己寫TTS 的支持也不如 sherpa-onnx 成熟。2.2 sherpa-onnx 的架構(gòu)設(shè)計思路sherpa-onnx 的架構(gòu)可以分成三層來理解。最底層是 onnxruntime負(fù)責(zé)實際的矩陣運(yùn)算和算子執(zhí)行中間層是 sherpa-onnx 自己實現(xiàn)的語音處理邏輯包括特征提取fbank、解碼器greedy search 或 beam search、端點檢測VAD等最上層是各語言的 API 封裝Android 這邊提供的是 Kotlin/Java 接口。這種分層設(shè)計的好處是模型和推理引擎解耦你可以隨時替換 ONNX 模型而不需要改代碼同時特征提取和解碼邏輯用 C 實現(xiàn)性能比純 Java 實現(xiàn)高一個數(shù)量級。我在實測中對比過同樣的模型sherpa-onnx 的 C 解碼比用 Java 自己寫的貪心解碼快了將近 3 倍尤其是在長語音場景下差距更明顯。2.3 模型選型的核心考量sherpa-onnx 支持的 ASR 模型主要有幾類Paraformer中文效果好、Zipformer中英混合強(qiáng)、Whisper多語言但體積大、Conformer通用。TTS 模型則有 VITS、Matcha、Kokoro 等。選型時我一般看三個指標(biāo)模型體積、實時率RTF、識別準(zhǔn)確率。以中文場景為例Paraformer 的小模型大概 70MB 左右RTF 在驍龍 8 Gen 1 上能跑到 0.15 以下也就是說 10 秒的語音 1.5 秒就能出結(jié)果。Zipformer 的中英混合模型大概 120MBRTF 稍高但也在 0.3 以內(nèi)。Whisper tiny 雖然只有 40MB但中文識別準(zhǔn)確率明顯不如 Paraformer而且不支持流式。所以如果你的場景是純中文離線識別Paraformer 是首選如果需要中英混說Zipformer 更合適。注意模型體積和準(zhǔn)確率永遠(yuǎn)是一對矛盾。我見過有開發(fā)者為了追求小體積用了 Whisper tiny結(jié)果中文識別錯字率高達(dá) 15%用戶體驗極差。建議在體積和準(zhǔn)確率之間找平衡點一般 70-150MB 是比較合理的區(qū)間。3. Android 集成實操從零到跑通第一個 Demo3.1 環(huán)境準(zhǔn)備與依賴引入先說一下我的開發(fā)環(huán)境Android Studio Hedgehog 以上、Gradle 8.x、minSdk 24、targetSdk 34、NDK 版本 25.2。sherpa-onnx 的 AAR 包對 NDK 版本有要求太低會報鏈接錯誤太高也可能有兼容問題25.x 是比較穩(wěn)的區(qū)間。依賴引入有兩種方式。第一種是直接從 Maven Central 拉取dependencies { implementation(com.k2fsa.sherpa.onnx:sherpa-onnx:1.10.0) }第二種是手動下載 AAR 放到 libs 目錄適合需要定制編譯或者內(nèi)網(wǎng)環(huán)境的情況。我一般推薦第一種版本管理方便升級也簡單。引入依賴后需要在build.gradle里加上 abiFilters只保留 arm64-v8a 和 armeabi-v7a這樣能顯著減小 APK 體積android { defaultConfig { ndk { abiFilters listOf(arm64-v8a, armeabi-v7a) } } }3.2 模型文件的放置與加載策略模型文件不能放在 assets 里直接讀因為 onnxruntime 需要文件路徑而不是 InputStream。我的做法是在 App 首次啟動時把 assets 里的模型拷貝到filesDir下之后直接從文件系統(tǒng)加載??截愡壿嬕⒁庥煤笈_線程并且加一個版本標(biāo)記模型更新時重新拷貝。fun copyModelIfNeeded(context: Context, modelName: String) { val targetDir File(context.filesDir, sherpa-models/$modelName) val versionFile File(targetDir, version.txt) val currentVersion 1.0 if (versionFile.exists() versionFile.readText() currentVersion) return targetDir.mkdirs() context.assets.open(models/$modelName).use { input - File(targetDir, model.onnx).outputStream().use { output - input.copyTo(output) } } versionFile.writeText(currentVersion) }模型加載本身是同步的但耗時可能到幾百毫秒建議放在 Application 啟動時或者用懶加載。我實測 Paraformer 小模型加載大概 200msZipformer 大概 400msTTS 的 VITS 模型大概 300ms。如果放在主線程會明顯卡頓一定要放到子線程。3.3 流式 ASR 的完整調(diào)用流程流式 ASR 的核心是“邊錄邊識別”需要把錄音線程和識別線程分開。錄音線程負(fù)責(zé)從 AudioRecord 讀取 PCM 數(shù)據(jù)識別線程負(fù)責(zé)把數(shù)據(jù)喂給 sherpa-onnx 的 stream 對象。val config OnlineRecognizerConfig( featConfig FeatureConfig(sampleRate 16000, featureDim 80), modelConfig OnlineModelConfig( transducer OnlineTransducerModelConfig( encoder $modelDir/encoder.onnx, decoder $modelDir/decoder.onnx, joiner $modelDir/joiner.onnx ), tokens $modelDir/tokens.txt, numThreads 2, provider cpu ), decodingMethod greedy_search ) val recognizer OnlineRecognizer(config) val stream recognizer.createStream()錄音線程每次讀到 1600 個采樣點100ms就調(diào)用stream.acceptWaveform(samples, 16000)然后調(diào)用recognizer.isReady(stream)判斷是否有可解碼的幀有的話就recognizer.decode(stream)再通過recognizer.getResult(stream)拿到當(dāng)前識別結(jié)果。這套流程看起來簡單但有幾個坑我踩過一是采樣率必須是 16000錄音時如果設(shè)備默認(rèn)是 44100 需要重采樣二是 numThreads 不要設(shè)太大2 線程在多數(shù)手機(jī)上已經(jīng)夠用設(shè) 4 反而會因為線程調(diào)度開銷導(dǎo)致延遲增加。3.4 TTS 合成的參數(shù)調(diào)優(yōu)TTS 這邊相對簡單核心是文本前端和聲學(xué)模型的配合。sherpa-onnx 的 TTS 支持中英文混合但需要模型本身支持。VITS 中文模型大概 60MB合成一句話10 個字左右耗時在 200-400ms 之間。val ttsConfig OfflineTtsConfig( model OfflineTtsModelConfig( vits OfflineTtsVitsModelConfig( model $ttsModelDir/model.onnx, tokens $ttsModelDir/tokens.txt, lexicon $ttsModelDir/lexicon.txt ), numThreads 2 ), ruleFsts $ttsModelDir/rule.fst ) val tts OfflineTts(ttsConfig) val audio tts.generate(你好歡迎使用語音合成, sid 0, speed 1.0f)speed 參數(shù)控制語速1.0 是正常0.8 偏慢1.2 偏快。sid 是說話人 ID多說話人模型才有意義。生成的 audio 是 FloatArray需要轉(zhuǎn)成 16bit PCM 再用 AudioTrack 播放。這里有個細(xì)節(jié)AudioTrack 的 buffer size 要設(shè)夠太小會斷音我一般設(shè)成采樣率的 0.5 秒對應(yīng)的字節(jié)數(shù)。4. 性能調(diào)優(yōu)與真機(jī)適配經(jīng)驗4.1 不同芯片平臺的實測數(shù)據(jù)我在幾臺不同芯片的手機(jī)上跑了同一套 Paraformer 流式模型記錄下 RTF 和首字延遲數(shù)據(jù)如下芯片平臺代表機(jī)型檔位RTF首字延遲內(nèi)存占用驍龍 8 Gen 2旗艦0.08180ms180MB驍龍 778G中端0.18320ms160MB天璣 8100中高端0.12240ms170MB驍龍 665入門0.45680ms140MB從數(shù)據(jù)能看出來旗艦機(jī)和中端機(jī)的差距主要在首字延遲上RTF 差距反而不大。入門機(jī)驍龍 665 這個級別的 RTF 到了 0.45意味著 10 秒語音要 4.5 秒才能出結(jié)果體驗就比較勉強(qiáng)了。所以如果你的 App 要覆蓋低端機(jī)建議用更小的模型或者干脆只在中高端機(jī)上開啟流式識別。4.2 內(nèi)存與線程的平衡策略sherpa-onnx 默認(rèn)用 2 線程這個設(shè)置在中高端機(jī)上表現(xiàn)最好。我試過在驍龍 778G 上把 numThreads 調(diào)到 4RTF 從 0.18 降到 0.15但首字延遲反而從 320ms 漲到 380ms原因是線程創(chuàng)建和同步的開銷增加了。所以我的建議是除非你的場景對吞吐量要求極高比如批量處理錄音文件否則 numThreads 保持 2 就行。內(nèi)存方面模型加載后會常駐內(nèi)存Paraformer 大概占 150-180MB。如果同時加載 ASR 和 TTS內(nèi)存占用會到 300MB 左右。在 4GB 內(nèi)存的低端機(jī)上這個占用已經(jīng)比較危險了可能觸發(fā)系統(tǒng)殺后臺。我的做法是 ASR 和 TTS 按需加載用完就釋放雖然重新加載有幾百毫秒開銷但能顯著降低內(nèi)存壓力。4.3 音頻采集的坑與解決方案Android 的 AudioRecord 在不同設(shè)備上行為不一致這是端側(cè)語音最頭疼的問題之一。我遇到過幾種典型情況某些機(jī)型默認(rèn)采樣率是 48000 而不是 16000需要手動重采樣某些機(jī)型在藍(lán)牙耳機(jī)連接時采樣率會變還有的機(jī)型在錄音時會有明顯的底噪。重采樣我一般用線性插值雖然音質(zhì)不如 sinc 插值但計算量小對識別準(zhǔn)確率影響可以忽略。底噪問題可以通過 VAD 過濾sherpa-onnx 自帶的 VAD 模型silero-vad效果不錯能在識別前把靜音段切掉既省算力又提高準(zhǔn)確率。提示錄音權(quán)限在 Android 6.0 以上需要動態(tài)申請而且從 Android 11 開始如果 App 在后臺錄音需要額外聲明 foregroundServiceType。這些系統(tǒng)限制一定要提前處理否則用戶反饋“錄不了音”你排查半天才發(fā)現(xiàn)是權(quán)限問題。5. 常見問題排查與避坑指南5.1 模型加載失敗的幾種典型原因模型加載失敗是最常見的問題我整理了一個排查表報錯信息可能原因解決方法Failed to load model文件路徑錯誤檢查 filesDir 下文件是否存在Invalid ONNX model模型文件損壞重新從官方倉庫下載Unsupported operatoronnxruntime 版本不匹配升級 sherpa-onnx 到最新版Out of memory模型太大換小模型或降低 numThreadsTokens file not foundtokens.txt 缺失確認(rèn)模型目錄包含所有文件其中“Unsupported operator”這個坑我踩過最深。當(dāng)時用了一個自己導(dǎo)出的 ONNX 模型里面有個算子 onnxruntime 不支持報錯信息又很模糊排查了一整天才發(fā)現(xiàn)是導(dǎo)出時用了 opset 17 而 onnxruntime 只支持到 15。后來我養(yǎng)成了一個習(xí)慣導(dǎo)出模型后先用 onnxruntime 的 Python 版跑一遍確認(rèn)沒問題再放到 Android 上。5.2 識別結(jié)果異常的調(diào)試思路識別結(jié)果異常一般分三種完全沒結(jié)果、結(jié)果亂碼、結(jié)果延遲高。完全沒結(jié)果通常是音頻格式不對檢查采樣率、聲道數(shù)、位深是否匹配。結(jié)果亂碼多半是 tokens.txt 和模型不匹配或者模型本身是英文的卻用來識別中文。結(jié)果延遲高則要看 RTF如果 RTF 超過 0.5 就要考慮換模型或降 numThreads。我一般會在調(diào)試階段把每幀的識別結(jié)果打日志觀察是從第幾幀開始出字的。正常情況下流式模型在 300-500ms 內(nèi)應(yīng)該出第一個字如果超過 1 秒還沒出說明模型或配置有問題。5.3 打包與混淆的注意事項sherpa-onnx 的 AAR 里包含 native 庫混淆時要注意保留相關(guān)類。我在proguard-rules.pro里加了這幾條-keep class com.k2fsa.sherpa.onnx.** { *; } -keep class ai.onnxruntime.** { *; }另外如果用了abiFilters只保留 arm64-v8a在 32 位設(shè)備上會直接崩潰。雖然現(xiàn)在 32 位設(shè)備越來越少但為了保險起見建議同時保留 armeabi-v7aAPK 體積會增加大概 8MB但兼容性更好。6. 從 Demo 到產(chǎn)品的工程化建議6.1 模型熱更新的設(shè)計思路模型文件不應(yīng)該硬編碼在 APK 里否則每次換模型都要發(fā)版。我的做法是把模型放在 CDN 上App 啟動時檢查版本號有更新就下載到 filesDir。下載要用斷點續(xù)傳模型文件動輒幾十上百 MB用戶網(wǎng)絡(luò)不穩(wěn)定時很容易斷。下載完成后要做 MD5 校驗防止文件損壞。版本管理用一個 JSON 文件描述包含模型名稱、版本號、下載地址、MD5、大小等字段。App 端對比本地版本號和遠(yuǎn)端版本號不一致就觸發(fā)下載。這個機(jī)制我在實際項目里跑了半年多更新過三次模型用戶側(cè)無感知。6.2 多場景下的資源調(diào)度一個 App 里可能同時用到 ASR 和 TTS甚至還有 VAD。如果全部常駐內(nèi)存低端機(jī)扛不住。我的策略是分優(yōu)先級VAD 常駐模型小才幾 MBASR 按需加載用戶點錄音按鈕時加載松開后延遲 30 秒釋放TTS 也是按需加載。這樣峰值內(nèi)存能控制在 250MB 以內(nèi)大多數(shù)設(shè)備都能接受。釋放模型時要注意調(diào)用recognizer.release()和tts.release()否則 native 內(nèi)存不會回收。我見過有開發(fā)者只置空引用結(jié)果 native 內(nèi)存泄漏跑幾次就 OOM 了。6.3 用戶體驗層面的細(xì)節(jié)打磨端側(cè)語音和云端最大的區(qū)別是“沒有網(wǎng)絡(luò)等待”所以用戶體驗的預(yù)期也更高。首字延遲超過 500ms 用戶就會覺得卡超過 1 秒就會覺得壞了。所以優(yōu)化首字延遲比優(yōu)化整體 RTF 更重要。我的做法是錄音開始后立刻喂數(shù)據(jù)不要等緩沖區(qū)滿解碼用 greedy search 而不是 beam search雖然準(zhǔn)確率略低但速度快很多VAD 的靜音閾值調(diào)低一點讓用戶一開口就能觸發(fā)識別。另外識別結(jié)果的展示也要注意。流式識別是逐字出結(jié)果的如果每出一個字就刷新 UI界面會閃得厲害。我一般用 100ms 的節(jié)流每 100ms 更新一次顯示既流暢又不卡頓。7. 我個人的一些實操體會這套方案我在兩個項目里落地過一個是離線會議記錄工具一個是語音朗讀助手。會議記錄那個項目對準(zhǔn)確率要求高用的是 Zipformer 中英混合模型120MB在驍龍 870 上 RTF 0.15體驗很好。朗讀助手對延遲敏感用的是 Paraformer 小模型加 VITS首字延遲控制在 300ms 以內(nèi)用戶反饋“跟云端差不多”。踩過的坑里最深刻的是音頻采集的兼容性問題。同一套代碼在小米上正常在華為上就沒聲音排查后發(fā)現(xiàn)是華為某些機(jī)型默認(rèn)采樣率是 48000而我沒做重采樣。后來加了一個采樣率檢測邏輯如果設(shè)備默認(rèn)不是 16000 就強(qiáng)制重采樣問題就解決了。所以我的建議是不要假設(shè)設(shè)備行為一致所有和硬件相關(guān)的參數(shù)都要做兼容處理。還有一個經(jīng)驗是模型不是越大越好。我一開始追求準(zhǔn)確率用了大模型結(jié)果低端機(jī)上卡得沒法用。后來換成小模型準(zhǔn)確率只降了 2 個百分點但流暢度提升了一倍。端側(cè)場景下流暢度往往比那一點點準(zhǔn)確率更重要畢竟用戶能容忍偶爾錯字但容忍不了卡頓。最后分享一個小技巧如果你不確定該選哪個模型可以先用 sherpa-onnx 官方提供的預(yù)編譯 APK 跑一遍里面內(nèi)置了多個模型可以切換。實測哪個模型在你的目標(biāo)機(jī)型上表現(xiàn)最好再決定集成哪個。這樣能省掉大量試錯時間。