0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカル音声対話AIのSTTをWhisperXからNeMoに載せ替えて、発話終了から56msで確定させるまで

0
Posted at

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

差分・記録一式: https://github.com/kotetsuy/AIassistant


第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

Speechrocm-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.0torchaudio<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_modeunified のままだと:

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 に langprompt_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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?