Summary
ローカル完結の音声対話AI(Ryzen AI MAX+ 395 / gfx1151)の STT を、WhisperX-ROCm から
NeMo Speech (nemotron-3.5-asr-streaming-0.6b) に載せ替えた。
WhisperX は消さず、同一プロセス内で実行時フォールバックする2系統構成にしてある。
バッチ推論で 2〜3倍高速化、さらに cache-aware ストリーミングで
発話終了から 56ms で転写確定(音声長に依存しない)。
エンドツーエンド(発話終了 → コテコが喋り出すまで)も 895ms → 735ms(中央値、各30回)。
WhisperX では約4割の発話が1秒を超えていたのが、NeMo では30回すべて1秒未満になった。
ただし長く固有名詞の多い発話では WhisperX の方が正確で、そこは正直に測って残した。
はじめに
以前、AMD Ryzen AI MAX+ 395 (gfx1151) でローカル完結の音声対話AIを作る
という記事で、音声 → STT → LLM → TTS → VRM リップシンクをローカルだけで回すスタックを作りました。
その STT 部分(WhisperX-ROCm)を NeMo Speech に載せ替えた話です。
NeMo 側の ROCm 移植については別記事に書きました
(移植に必要だったのは4行でした)。今回はそれを実際のアプリに組み込むフェーズです。
やったことは単純に見えて、実際には「動くけど遅い」「動くけど精度が落ちる」の罠が何度も出ました。
結果より、途中で踏んだ問題とその原因の方が再利用価値があると思うので、そこを厚めに書きます。
何が変わったか
変更前: Browser → three-vrm → ttllm ─┬─ WhisperX-ROCm (STT)
└─ llama-server
変更後: Browser → three-vrm → ttllm ─┬─ NeMo Speech (STT, 既定)
│ └ 失敗したら ↓ に実行時フォールバック
├─ WhisperX-ROCm (STT, フォールバック)
└─ llama-server
+ ストリーミング経路 (任意):
Browser(AudioWorklet) ══WS══▶ three-vrm ══WS══▶ ttllm
NeMo cache-aware streaming
| 変更前 | 変更後 | |
|---|---|---|
| STT モデル | WhisperX large-v3-turbo | nemotron-3.5-asr-streaming-0.6b |
| 発話終了→転写確定 (3.5秒発話) | 287ms | 119ms(バッチ)/ 49ms(ストリーミング) |
| 発話終了→転写確定 (12.8秒発話) | 545ms | 274ms(バッチ)/ 50ms(ストリーミング) |
| 発話中の部分転写 | なし | あり(約1.1秒ごと) |
| バックエンド切り替え | — | STT_BACKEND=nemo|whisperx|auto |
検証環境
| 項目 | 内容 |
|---|---|
| 機種 | GMKtec NucBox EVO X2 |
| CPU/GPU | AMD Ryzen AI MAX+ 395 / Radeon 8060S (gfx1151) |
| OS | Ubuntu 26.04 LTS |
| ROCm | 7.14.0 (/opt/rocm, gfx1151 ネイティブビルド) |
| PyTorch | 2.8.0+rocm7.12.0 (Python 3.12) |
| STT (既定) | nvidia/nemotron-3.5-asr-streaming-0.6b |
| STT (フォールバック) | WhisperX-ROCm large-v3-turbo
|
| LLM | Qwen3.6-35B-A3B (MoE) via llama.cpp |
| 検証日 | 2026-08-13 |
第1部 — git clone からのセットアップ
前の記事から変わったのは venv の作り方です。以前は WhisperX の venv を共有していましたが、
今回から NeMo と WhisperX を同居させた専用の共用 venv を作ります。
前提
| 項目 | 内容 |
|---|---|
| OS | Ubuntu 26.04 |
| GPU | gfx1151(他アーキは未検証) |
| ROCm | 7.x が /opt/rocm に |
| その他 |
uv / tmux / curl / docker / ffmpeg / libsndfile1
|
HSA_OVERRIDE_GFX_VERSION は設定しません。 gfx1151 ネイティブビルドでは逆に壊れます。
1. リポジトリを並べる
すべてホームディレクトリ直下に置きます。AIassistant 側からはシンボリックリンクで参照します。
cd ~
git clone https://github.com/kotetsuy/AIassistant.git
git clone https://github.com/ggml-org/llama.cpp.git
# NeMo Speech — 既定の STT バックエンド
# ROCm 対応差分は rocm-inference ブランチにしか無いので必ずこれを取る
git clone -b rocm-inference https://github.com/kotetsuy/Speech.git
# フォールバック用 WhisperX
mkdir -p ~/whisperx && cd ~/whisperx
git clone https://github.com/<your_fork>/whisperX-rocm.git
git clone https://github.com/<your_fork>/ctranslate2-rocm.git
CTranslate2-ROCm のビルド、llama.cpp のビルド、Qwen3.6 のダウンロードは
前記事と同じなので割愛します。
2. シンボリックリンクを張る
cd ~/AIassistant
ln -sf ../llama.cpp llama.cpp
ln -sf ../whisperx/whisperX-rocm whisperX-rocm
ln -sf ../Speech Speech # ← 今回追加
ln -sf ../qwen3.6 qwen3.6
Speech は rocm-inference ブランチである必要があります。
git -C ~/Speech branch --show-current # rocm-inference
main のままだと NeMo のロードで
TypeError: Can't instantiate abstract class ASRModel という原因を指していないエラーで落ちます
(本当の原因は Failed to dlopen libcuda.so.1。前記事参照)。
start_all.sh がリンクの存在を、ttllm/install.sh がブランチを確認します。
3. 共用 venv を作る
cd ~/AIassistant/ttllm
./install.sh
ttllm/.venv が作られ、2つの STT バックエンドとブリッジと three-vrm が全部ここに入ります。
-
torch==2.8.0+rocm7.12.0(gfx1151 専用 wheel インデックス) -
whisperx+ ローカルビルドの ROCm 版ctranslate2 -
nemo-toolkit[asr](Speechシンボリックリンク経由) -
fastapi/uvicorn/httpx/aiohttpなど
NeMo モデルの事前キャッシュも install.sh がやります(理由は後述)。
4. 起動
cd ~/AIassistant
./start_all.sh
VOICEVOX → llama-server → ttllm → STT warmup → three-vrm → Chrome の順に立ち上がります。
第2部 — 使い方
バックエンドの切り替え
STT_BACKEND |
挙動 |
|---|---|
auto(既定) |
NeMo を使い、失敗したら WhisperX にフォールバックして以後そのまま |
nemo |
NeMo のみ。失敗は失敗として扱う |
whisperx |
WhisperX のみ(移植前と同じ挙動) |
STT_BACKEND=whisperx ./ttllm/run.sh # 旧バックエンドを強制
curl -s localhost:8001/health | jq .stt # どちらが実際に動いているか
{
"backend": "nemo",
"requested": "auto",
"fallback": "whisperx",
"fallback_active": false,
"last_error": null,
"model": "nvidia/nemotron-3.5-asr-streaming-0.6b",
"language": "ja-JP",
"att_context_size": "[56,13]",
"loaded": true,
"supports_streaming": true
}
フォールバックが起きたら必ず WARNING ログに出て、/health にも反映されます。
WARNING STT falling back from nemo to whisperx and staying there — inference failed: ...
ここは意識して設計しました。無言で別のモデルに変わるのが一番まずいからです。
「なんか最近、認識の癖が変わった気がする」で気づくのでは遅すぎる。
ストリーミング STT(任意)
既定は off です。URL にクエリを付けると有効になります。
http://localhost:8000/zundamon.html?stt=stream
localStorage.sttStreaming = "1" で永続化もできます。
有効にすると発話中から字幕が出て、話し終えた瞬間に確定します。
一括経路は一切変更していないので、off のときや WhisperX バックエンド稼働中は従来どおり動きます。
追加されたエンドポイント
| メソッド | パス | 用途 |
|---|---|---|
| WS | ttllm:8001/transcribe_stream |
生 PCM (float32 16kHz mono) → partial / final |
| POST | ttllm:8001/chat_stream |
テキスト → SSE。STT を別途済ませた呼び出し元用 |
| GET | three-vrm:8000/stt_chat_stream |
WebSocket。発話中の PCM → 部分転写 → LLM → VOICEVOX |
第3部 — 技術的な問題点と解決策
ここが本題です。踏んだ順に書きます。
問題1: そもそも2つのライブラリは同居できるのか
NeMo と WhisperX を同一プロセスで実行時フォールバックさせたい。
でも両者は依存が違います。
| WhisperX venv | NeMo venv | |
|---|---|---|
| torch | 2.8.0+rocm7.12.0 | 2.9.1+rocm7.13.0 |
| numpy | 2.0.2 | 2.5.2 |
| 固有 | ctranslate2, pyannote.audio | nemo-toolkit, lhotse |
解決: WhisperX 側の torch 2.8.0 を土台にしました。
- WhisperX は
torch>=2.8.0,<2.9.0とtorchaudio<2.9を要求します
(pyannote-audio がtorchaudio.infoを使い、これが 2.9 で削除されたため) - NeMo は
torch>=2.6.0なので 2.8.0 で条件を満たす
つまりNeMo を下げる方が制約が緩い。逆(WhisperX を上げる)は torchaudio の壁で無理でした。
心配だったのは「torch を下げたら NeMo が遅くならないか」でしたが、実測すると差は全音源で2%以内。
| 音源 | torch 2.9.1+rocm7.13.0 | torch 2.8.0+rocm7.12.0 |
|---|---|---|
| 3.25秒 | 0.0765s | 0.0771s |
| 3.50秒 | 0.0861s | 0.0871s |
numpy も WhisperX の <2.1.0 制約が支配して 2.0.2 になりましたが、lhotse 含め NeMo 側も問題なく動きました。
ctranslate2 だけは最後に上書きが必要です。
普通に依存解決すると PyPI の ctranslate2(CUDA ビルド)が入ります。ローカルビルドの ROCm 版で潰します。
CTRANSLATE2_ROOT=/usr/local uv pip install --reinstall --no-deps \
pybind11 ~/whisperx/ctranslate2-rocm/python
問題2: model.transcribe() を使うと速くならない
NeMo の一番素直な使い方はこれです。
model.transcribe(audio=["a.wav"], target_lang="ja-JP") # 遅い
これだと 3.5秒の発話で 119ms → 実測すると呼び出しごとに 0.2〜0.4秒余計にかかります。
原因は transcribe() が呼び出しのたびに Lhotse の dataloader を作り直すこと。
解決: encoder と RNNT デコーダを直接叩きます。
with self._lock, torch.inference_mode():
encoded, encoded_len = self._model.forward(
input_signal=signal, input_signal_length=length, prompt_indices=prompts
)
hyps = self._model.decoding.rnnt_decoder_predictions_tensor(encoded, encoded_len)
これで 3.5秒の発話が 119ms → 88ms(推論部分)になります。
dataloader 構築は「推論時間」ではなくセットアップコストなので、切り離すのが正しい。
ちなみにこの違いはベンチマークの数字を倍以上変えます。
最初 CPU で 0.46秒だと思っていた処理が、dataloader を除くと 0.24秒でした。
何を計測区間に含めたかを書かないベンチマークは意味がないという教訓です。
問題3: target_lang を渡しても言語が固定されない
このモデルは language-ID prompt conditioning で言語を指定します。日本語のキーは ja-JP
(ja 単体は prompt_dictionary に無く弾かれます)。
ところが transcribe() に音声パスを渡すと、こうなります。
ValueError: Unknown prompt key: 'None'
プロンプトは manifest の lang フィールドから解決されるためです。
さらに厄介なのが、lang を埋めても default_prompt_mode が unified のままだと:
elif mode == 'unified':
if random.random() < self.unified_auto_ratio: # 既定 0.5
return self.auto_index # ← 言語非依存の auto
return self._get_prompt_index(cut.supervisions[0].language)
言語が分かっていても約半分のサンプルで auto プロンプトが選ばれます。
解決: 今回は直接 forward 経路を使うので、プロンプト ID をテンソルで直接渡すことで回避できました。
prompts = torch.tensor([prompt_id], dtype=torch.long, device=device)
transcribe() を使う場合は、manifest に lang と prompt_mode: langID を両方書く必要があります。
問題4: ブラウザは WAV を送ってこない
WhisperX は whisperx.load_audio() が内部で ffmpeg を呼んでいたので、意識せずに済んでいました。
NeMo 経路には相当する処理がありません。ブラウザ(MediaRecorder)が送ってくるのは webm/opus です。
解決: ffmpeg で自前デコードします。
cmd = ["ffmpeg", "-nostdin", "-i", path,
"-f", "s16le", "-ac", "1", "-ar", "16000", "-"]
raw = subprocess.run(cmd, capture_output=True, check=True).stdout
audio = np.frombuffer(raw, np.int16).astype(np.float32) / 32768.0
デコードは 30ms 程度なので、推論 88ms に対して無視できない量ではありますが許容範囲です。
問題5: 文末の助詞が消える
VOICEVOX で作った音声だけ、ストリーミングで文末が欠けました。
| 原文 | 出力 |
|---|---|
| 日本で二番目に高い山は | 日本で二番目に高い山 |
末尾の無音を測ると原因が見えました。
| 音源 | 末尾無音 |
|---|---|
| 肉声 | 0.812s |
| VOICEVOX ずんだもん | 0.105s |
| VOICEVOX 波音リツ | 0.169s |
cache-aware ストリーミングは発話終端の右 context が足りないと最後のトークンを出せません。
人間の録音は録音停止の操作で前後に余白ができますが、TTS 出力にはそれが無い。
解決: 末尾に 0.5 秒の無音を足します。
audio = np.concatenate([audio, np.zeros(int(16000 * 0.5), dtype=np.float32)])
PTT(プッシュトゥトーク)はボタンを離した瞬間に録音が切れるので、実際のマイク入力でも同じ問題が起きます。
バッチ経路にも保険として入れました。
問題6: 初回リクエストだけ 2.8 秒かかる
モデルをロードして /warmup を返した後、最初の1回だけ異常に遅い。
run1: 2831ms
run2: 114ms
run3: 117ms
解決: ロード時に捨て推論を1回回します。
def _warmup(self) -> None:
silence = np.zeros(SAMPLE_RATE, dtype=np.float32)
self._infer(silence)
これで初回も 129ms になりました。
モデルのロードとカーネルのコンパイルは別物で、前者だけでは足りません。
なお WhisperX は初回 434ms 止まりで、ウォームアップ依存が小さいです。性格の違いが出ます。
問題7: HF_HUB_OFFLINE=1 で起動できない
ttllm/run.sh は起動時のハングを避けるため既定でオフラインにしています。
NeMo の from_pretrained() は HF からモデルを取りに行くので、キャッシュが無いと起動できません。
Cannot reach https://huggingface.co/nvidia/...: offline mode is enabled.
解決: install.sh に事前キャッシュを組み込みました。
"$VENV/bin/hf" download nvidia/nemotron-3.5-asr-streaming-0.6b \
nemotron-3.5-asr-streaming-0.6b.nemo
問題8: MediaRecorder ではストリーミングできない
ストリーミング STT には、発話中から音声を送り続ける必要があります。
MediaRecorder.start(timeslice) で分割送信すればよさそうに見えますが、できません。
webm/opus のチャンクは先頭以外が単独でデコードできないからです。
ヘッダが先頭チャンクにしか無いので、サーバ側で1つずつ処理できません。
解決: AudioWorklet で生 PCM を取り出す。
class PCMTap extends AudioWorkletProcessor {
process(inputs) {
const ch = inputs[0] && inputs[0][0];
if (ch && ch.length) this.port.postMessage(ch.slice());
return true;
}
}
AudioContext は 48kHz で動くことが多いので 16kHz へ間引いてから WebSocket で送ります。
AudioWorklet はデスティネーションに繋がないと駆動されません。
スピーカーに出したくないので、ゲイン0を噛ませて回します。
const mute = ctx.createGain();
mute.gain.value = 0;
sttSource.connect(sttNode).connect(mute).connect(ctx.destination);
問題9: 100ms ずつ送ると認識精度が落ちる
ストリーミングを実装して9音源で検証したら、1件だけ間違えました。
| 原文 | 出力 |
|---|---|
| 日本で一番長い川は | 日本で一番長いかは |
原因: mel スペクトログラムのプリプロセッサは、append_audio() の呼び出しごとに独立してパディングします。
つまり100msごとに送ると、スライス境界ごとに窓のアーティファクトが入ります。
解決: 生 PCM を蓄積して、毎回すべてから mel を作り直す。
self._pcm = np.concatenate([self._pcm, pcm])
processed, _ = self._buffer.preprocess_audio(self._pcm)
self._buffer.buffer = processed # buffer_idx は保つ
一見無駄に見えますが、数秒の音声の mel 計算は encoder 1ステップに比べて無視できるコストです。
これで 9/9 がオフライン経路と一致しました。
あわせて online_normalization=True にしています。オフライン正規化は統計が発話全体に依存するので、
音声が増えるたびに既存フレームがずれてしまい、ストリーミングとは原理的に両立しません。
問題10: 部分転写が最後にまとめて届く
WebSocket 中継でこう書いていました。
reply = await asyncio.wait_for(up.receive(), timeout=0.0) # ダメ
「ブロックせずに溜まっている分だけ吐き出す」つもりでしたが、
timeout=0.0 だとイベントループに処理が回らず、部分転写が全部発話終了時にまとめて届きます。
字幕がライブになりません。
解決: 上流の読み出しを専用タスクに分離します。
pump = asyncio.create_task(pump_up()) # 上流 → ブラウザを流し続ける
async for msg in down: # ブラウザ → 上流はこちらで
...
修正前: partial が全部 +3.55s に到着
修正後: +1.18s / +2.20s / +3.32s に順次到着
問題11: ストリーミングだけ言語タグが残る
バッチ経路では <ja-JP> を除去していましたが、ストリーミング側に同じ処理が無く、
タグ付きのまま LLM に渡っていました。
インターネットで...出てくるでしょう。 <ja-JP>
解決: 単に同じ正規表現を適用しただけですが、同じ処理を2箇所に書くと必ず片方を忘れるという
教訓として残しておきます。共通化すべきでした。
第4部 — 測定結果
発話終了 → 転写確定(20回の中央値)
これがユーザが待たされる時間です。
| 音源長 | A: WhisperX | B: NeMo一括 | C: NeMoストリーミング |
|---|---|---|---|
| 1.06s | 254ms | 84ms | 48ms |
| 3.50s | 287ms | 119ms | 49ms |
| 10.44s | 432ms | 221ms | 53ms |
| 12.84s | 545ms | 274ms | 50ms |
| 14.22s | 460ms | 255ms | 95ms |
ストリーミングの確定時間は音声長にほぼ依存しません。
発話終了の時点で未処理なのは最後のチャンク分だけだからです。
一括は音声長に比例するので、発話が長いほど差が開きます(12.8秒で WhisperX 比 10.8倍)。
C の値が 48〜106ms とばらつくのは、発話終端がチャンク境界のどこに落ちるかで
未処理分が 0〜1120ms 変わるためです。実行ごとのブレではなく構造的なものです。
精度(CER、表記ゆれを正規化後)
| 音源 | A: WhisperX | B: NeMo一括 | C: NeMoストリーミング |
|---|---|---|---|
| 短文9本すべて | 0.0% | 0.0% | 0.0% |
| 10.44s | 0.0% | 2.2% | 2.2% |
| 12.84s | 9.9% | 21.1% | 21.1% |
| 12.54s | 17.9% | 17.9% | 17.9% |
B と C は全音源で完全に一致しました。 ストリーミング化による精度低下はありません。
一方で、長く固有名詞の多い発話では WhisperX の方が正確です。
0.6B と large-v3-turbo のモデルサイズ差が、語彙・固有名詞の弱さとしてそのまま出ています。
CER は正規化してから測る必要があります。
WhisperX は「2番目」(算用数字)+文末句読点、NeMo は「二番目」(漢数字)と書きます。
どちらも転写ミスではないので、そのまま比較すると差が誤差として乗ります。
エンドツーエンド(発話終了 → コテコが喋り出すまで)
ユーザが実際に体感する区間です。3.50秒の発話で各30回、three-vrm の WebSocket を購読して
最初の speak チャンクが届くまでを測りました。
| 構成 | 中央値 | 範囲 | 1秒を超えた回数 |
|---|---|---|---|
| WhisperX 一括(移行前) | 895ms | 608-1099ms | 30回中11回 (37%) |
| NeMo 一括(移行後の既定) | 735ms | 415-939ms | 0回 |
| NeMo ストリーミング | 614ms | 353-903ms | 0回 |
移行によって中央値で約160ms短縮しました。ただし数字以上に効いているのは分布の方です。
WhisperX では約4割の発話が1秒を超えていたのが、NeMo では30回とも1秒未満に収まりました。
「たまに待たされる」が無くなった、というのが体感に一番近い変化です。
この区間の測定はぶれます。 LLM が最初の一文を生成する時間と VOICEVOX の合成時間が
含まれ、LLM の出力内容は実行ごとに変わるからです。最初の文が長ければ合成にも時間がかかります。
実際、WhisperX の中央値は測定セッションごとに 895 / 937 / 1074 ms とばらつきました
(いずれも 15〜30 回の中央値)。単発の中央値を根拠にしてはいけません。
それに対して「1秒を超える割合」は 37〜60% と、どのセッションでも一貫して高く出ます。
分布の形で語る方が頑健です。
一方 NeMo 側は 713 / 723 / 735 ms と安定していました。STT の処理時間そのものが短く、
ばらつきの原因である LLM 側の影響を相対的に受けにくくなっているとも読めます。
ストリーミングは 614ms で最も速いものの、一括との差は約120msにとどまります。
3.5秒の発話だと STT 単体の差は 70ms(119ms → 49ms)しかなく、
LLM の生成ばらつき(同条件でも 350〜900ms)に大部分が埋もれるからです。
ストリーミングが本領を発揮するのは、STT 側の差が大きく開く長い発話です。
第5部 — 積み残し
正直に書いておきます。
1. 長文の精度差をどうするか
未解決の最大の課題です。 固有名詞の多い読み上げ文では WhisperX が明確に優位(9.9% vs 21.1%)。
現状は「短い会話文が用途だから NeMo で十分」という判断で既定にしていますが、
用途が広がったら見直しが必要です。考えられる選択肢:
- 発話長で自動切り替え(短ければ NeMo、長ければ WhisperX)。両方常駐しているので実装は容易
- より大きい NeMo モデルを使う
- そもそも用途を短文に限定する
2. att_context_size=[56,1](160ms チャンク)の再測定
現在は [56,13](1120ms チャンク)です。160ms にすれば確定時間はさらに短くなるはずですが、
Phase 1 の検証では 160ms は 1120ms よりわずかに精度が劣りました。
このトレードオフはきちんと測っていません。
3. 長時間ストリーミングの安定性
検証したのは最長 14.22 秒です。数分の連続ストリーミングで cache がどう振る舞うかは未検証。
同じマシンの WhisperX は 60 秒超で Memory access fault by GPU node-1 が出る実績があるので、
長尺は別途確認が要ります。
4. VAD が無い
WhisperX は silero VAD で無発話を弾いて空文字を返していました。
NeMo 経路には同等の処理がありません。 現状は無音なら空文字が返る想定ですが、
環境ノイズが多い場所での挙動は確認していません。
5. 同時セッションは1つ前提
PTT(1人が押して話す)前提なので、同時ストリーミングセッションは1つしか想定していません。
モデルの forward はロックで直列化していますが、複数人が同時に使う設計にはなっていません。
6. upstream への PR
NeMo 側の ROCm 修正(4行)は、まだ NVIDIA-NeMo/Speech に PR を出していません。
既存の CUDA 動作に影響せず回帰テストも付けたので、出す価値はあると思っています。
まとめ
- STT を WhisperX から NeMo Speech に載せ替え、短文で 2〜3倍、ストリーミングなら発話長によらず 50〜100msで確定するようになった
- WhisperX は消さず実行時フォールバックとして同居させた。ROCm という非公式サポート構成で動かしている以上、逃げ道は要る
- 踏んだ問題の多くは ROCm とは無関係だった。
transcribe()の dataloader オーバヘッド、webm のデコード、TTS 音声の末尾無音、
mel の境界アーティファクト、MediaRecorder のチャンク非デコード性 — どれも
「動くけど期待した結果にならない」タイプで、エラーが出ないぶん厄介 - 長文では WhisperX の方が正確という結果は隠さず残した。速度だけ見て既定を決めると事故る
同じように「速い小さいモデルに載せ替えたい」と考えている人の役に立てば幸いです。
特に 問題2(計測区間)と問題9(mel の境界) は、気づかないまま
「なんか思ったより速くない/精度が出ない」で終わりやすいところだと思います。
差分と記録一式はこちらに置いてあります。
https://github.com/kotetsuy/AIassistant