はじめに
Whisper + VOICEVOX を使ったリアルタイム音声アプリをローカルで作れるか試していたところ,既存アプリより約130ms遅い問題にぶつかりました.
Whisper 側を最適化しても差は埋まらず,最終的に voicevox_core へ切り替えることで改善できたので記録します.
この記事が向いている人
- Whisper + VOICEVOX でリアルタイム音声アプリを作っている
- VOICEVOX HTTP API のレイテンシを削りたい
- VOICEVOX アプリを起動せずに Python から直接合成したい
最初の構成
マイク → VAD → Whisper → VOICEVOX HTTP API → スピーカー
VOICEVOX を Python から扱う方法としてよく紹介されている構成です.
query = requests.post("http://localhost:50021/audio_query",
params={"text": text, "speaker": 47}).json()
wav = requests.post("http://localhost:50021/synthesis",
params={"speaker": 47}, json=query).content
シンプルで動きますが, 合成のたびに HTTP が 2 往復します.
試したこと
1. Whisper のモデルを small → tiny に変更
転写速度が体感で 5 倍ほど速くなりました.
精度はやや落ちますが, 会話用途では許容範囲です.
model = faster_whisper.WhisperModel("tiny", device="cpu", compute_type="int8")
設定で切り替えられるようにしておくと便利です.
2. beam_size を 5 → 1 に変更
beam_size=1 はグリーディデコードになり, 転写速度が大幅に上がります.
精度低下はほとんど感じませんでした.
segments, _ = model.transcribe(audio, language="ja", beam_size=1)
3. vad_filter を無効化
vad_filter=True を指定していましたが, 自前の VAD で無音区間をすでに除いてから Whisper に渡しているため二重処理になっていました.
無効化したら転写速度が少し上がりました.
# vad_filter=True は削除
segments, _ = model.transcribe(audio, language="ja", beam_size=1,
condition_on_previous_text=False)
4. requests.Session で TCP 接続を使い回す
毎回 requests.get/post を呼ぶと TCP ハンドシェイクが都度発生します.
Session を使い回すだけで数十 ms 削減できました.
session = requests.Session()
# 以降は session.post(...) で呼ぶ
5. 発話前後の無音を除去する
VOICEVOX のデフォルトは発話の前後に無音が入ります.
これを 0 にすることで, 合成音声の再生開始が体感で速くなりました.
query["prePhonemeLength"] = 0.0
query["postPhonemeLength"] = 0.0
6. セグメント単位のパイプライン
Whisper は長い発話を複数セグメントに分割して返します.
全セグメントを待ってから再生するのではなく, セグメントが出るたびに合成して再生キューに投げると, 体感レイテンシが下がります.
play_q = queue.Queue()
def _player():
while True:
item = play_q.get()
if item is None:
break
sd.play(*item, blocking=True)
threading.Thread(target=_player, daemon=True).start()
for seg in segments:
wav, sr = synthesize(seg.text)
play_q.put((wav, sr))
play_q.put(None)
これらを全部やっても VOICEVOX 側のレイテンシは縮まらなかった
Whisper 側の改善はかなり効きました. ですが VOICEVOX の合成レイテンシはほぼ変わりませんでした.
プロファイルをとってみると, 時間の大半は HTTP の往復と VOICEVOX 側の処理時間でした.
ローカル同士の通信でも, JSON シリアライズ・デシリアライズと 2 往復の待機は無視できませんでした.
解決策: voicevox_core でインプロセス合成
voicevox_core は VOICEVOX エンジンのコア部分をライブラリとして利用できるようにしたものです.
Python バインディングが公式から提供されており, VOICEVOX アプリを起動せずに Python プロセス内で直接合成できます.
HTTP API: Python → (TCP) → VOICEVOX プロセス → (TCP) → Python
voicevox_core: Python → [関数呼び出し]
HTTP の往復が不要になり,JSON のシリアライズ/デシリアライズも挟まらなくなるため,
合成レイテンシが約130ms改善しました.
手元での計測結果:
| 方式 | 平均合成時間 |
|---|---|
| VOICEVOX HTTP API | 約 910 ms |
| voicevox_core | 約 780 ms |
計測条件:
- テキスト: "今日はいい天気ですね"(9 文字)
- 試行回数: ウォームアップ 3 回 + 20 回平均 (3 セッション)
※ 上記は筆者環境での計測値です. 環境によって変わります.
数字だけでは実感しにくいので, 参考にしていた既存アプリと同時起動して比較してみました.
結果はほぼ同等のレスポンスで, "数字上の改善が体感にもつながっている"ことを確認できました.
voicevox_core のセットアップ
4 つのものを用意する必要があります.
1. voicevox_core 本体
執筆時点では PyPI には上がっていないため, GitHub リリースから直接 pip インストールします.
pip install https://github.com/VOICEVOX/voicevox_core/releases/download/0.16.4/voicevox_core-0.16.4+cpu-cp310-abi3-win_amd64.whl
cp310-abi3 なので Python 3.10 以上であれば動きます. 執筆時点で Python 3.12 での動作を確認しています.
2. voicevox_onnxruntime DLL
VOICEVOX 独自ビルドの ONNX Runtime です. 標準の onnxruntime パッケージとは別物なので注意してください.
公式ダウンローダー (download-windows-x64.exe) は TTY 必須のライセンス同意が必要でスクリプトから使えないため, GitHub リリースから直接取得します.
Invoke-WebRequest `
"https://github.com/VOICEVOX/onnxruntime-builder/releases/download/1.17.3/voicevox_onnxruntime-win-x64-1.17.3.tgz" `
-OutFile onnxruntime.tgz
tar -xzf onnxruntime.tgz
Copy-Item "voicevox_onnxruntime-win-x64-1.17.3\lib\voicevox_onnxruntime.dll" .
3. open_jtalk 辞書
テキストの読み仮名変換に使う辞書ファイルです. VOICEVOX をインストール済みなら中から流用できます.
Copy-Item -Recurse `
"$env:LOCALAPPDATA\Programs\VOICEVOX\vv-engine\pyopenjtalk\open_jtalk_dic_utf_8-1.11" `
".\open_jtalk_dic"
4. VVM ファイル (音声モデル)
キャラクターごとの音声モデルです. voicevox_vvm で配布されています.
# 例: ナースロボ_タイプT (11.vvm)
Invoke-WebRequest `
"https://github.com/VOICEVOX/voicevox_vvm/releases/download/0.16.4/11.vvm" `
-OutFile "models\11.vvm"
主な VVM とキャラクターの対応 (0.16.4):
| ファイル | キャラクター |
|---|---|
| 0.vvm | 四国めたん, ずんだもん, 春日部つむぎ, 雨晴はう |
| 1.vvm | 冥鳴ひまり |
| 11.vvm | 櫻歌ミコ, ナースロボ_タイプT |
| 15.vvm | 青山龍星, もち子さん, 小夜/SAYO |
| n0.vvm | VOICEVOX Nemo |
全 25 ファイルあります. 詳細は voicevox_vvm の README.txt を参照してください.
ディレクトリ構成
project/
├── voicevox_onnxruntime.dll
├── open_jtalk_dic/
└── models/
├── 0.vvm
├── 11.vvm
└── ...
実装
初期化
import os
import pathlib
from voicevox_core.blocking import Onnxruntime, OpenJtalk, Synthesizer, VoiceModelFile
# __file__ 起点で絶対パスを組む (cwd に依存しないようにする)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DLL_PATH = os.path.join(BASE_DIR, "voicevox_onnxruntime.dll")
DICT_DIR = os.path.join(BASE_DIR, "open_jtalk_dic")
MODELS_DIR = os.path.join(BASE_DIR, "models")
os.add_dll_directory(os.path.dirname(DLL_PATH))
onnxruntime = Onnxruntime.load_once(filename=DLL_PATH)
open_jtalk = OpenJtalk(open_jtalk_dict_dir=DICT_DIR)
synth = Synthesizer(onnxruntime, open_jtalk)
for vvm_path in sorted(pathlib.Path(MODELS_DIR).glob("*.vvm")):
with VoiceModelFile.open(str(vvm_path)) as model:
synth.load_voice_model(model)
Onnxruntime.load_once() はプロセス内で一度しか呼べません. 複数の Synthesizer を作る場合もインスタンスを使い回してください.
パスは os.path.abspath("file.dll") のように文字列を直接渡すと cwd 依存になります. 別ファイルから import した場合や IDE の実行ボタンから起動した場合に DLL が見つからなくなるため, __file__ を起点に絶対パスを組むのが安全です.
合成
import io
import soundfile as sf
def synthesize(text: str, speaker_id: int, speed: float = 1.0):
query = synth.create_audio_query(text, speaker_id)
query.speed_scale = speed
query.pre_phoneme_length = 0.0
query.post_phoneme_length = 0.0
wav_bytes = synth.synthesis(query, speaker_id)
wav, sr = sf.read(io.BytesIO(wav_bytes))
return wav, sr
ロード済みキャラクターの一覧
voicevox_core はバージョンによって metas が property の場合と method の場合があるため, 両方に対応しておきます.
metas = synth.metas
if callable(metas):
metas = metas()
for meta in metas:
print(meta.name)
for style in meta.styles:
# "talk" タイプのみ使う (歌唱スタイルを除外)
if "talk" in str(getattr(style, "type", "talk")).lower():
print(f" {style.name} id={int(style.id)}")
出力例 (11.vvm ロード済み):
ナースロボ_タイプT
ノーマル id=47
楽しい id=48
悲しい id=49
怒り id=50
HTTP API へのフォールバック
voicevox_core が使えない環境でも動くようにフォールバックを用意しておくと安全です.
Session は __init__ で一度だけ作ります. メソッド内で毎回 requests.Session() を呼ぶと TCP 接続が使い回されず, セッションを使う意味がなくなります.
import requests
class VoiceSynth:
def __init__(self):
self._core = self._try_init_core()
self._session = requests.Session() # HTTP fallback 用, ここで一度だけ作る
def _try_init_core(self):
try:
base = os.path.dirname(os.path.abspath(__file__))
dll = os.path.join(base, "voicevox_onnxruntime.dll")
os.add_dll_directory(os.path.dirname(dll))
onnxruntime = Onnxruntime.load_once(filename=dll)
open_jtalk = OpenJtalk(open_jtalk_dict_dir=os.path.join(base, "open_jtalk_dic"))
synth = Synthesizer(onnxruntime, open_jtalk)
for vvm_path in sorted(pathlib.Path(os.path.join(base, "models")).glob("*.vvm")):
with VoiceModelFile.open(str(vvm_path)) as model:
synth.load_voice_model(model)
return synth
except Exception as e:
print(f"voicevox_core unavailable: {e}")
return None
def synthesize(self, text: str, speaker_id: int, speed: float = 1.0):
"""失敗時は None を返す. 呼び出し元でスキップ判定すること."""
if self._core:
return self._synth_core(text, speaker_id, speed)
return self._synth_http(text, speaker_id, speed)
def _synth_core(self, text, speaker_id, speed):
query = self._core.create_audio_query(text, speaker_id)
query.speed_scale = speed
query.pre_phoneme_length = 0.0
query.post_phoneme_length = 0.0
wav_bytes = self._core.synthesis(query, speaker_id)
wav, sr = sf.read(io.BytesIO(wav_bytes))
return wav, sr
def _synth_http(self, text, speaker_id, speed):
try:
query = self._session.post(
"http://localhost:50021/audio_query",
params={"text": text, "speaker": speaker_id},
timeout=10,
).json()
query["speedScale"] = speed
query["prePhonemeLength"] = 0.0
query["postPhonemeLength"] = 0.0
wav_bytes = self._session.post(
"http://localhost:50021/synthesis",
params={"speaker": speaker_id},
json=query,
timeout=30,
).content
wav, sr = sf.read(io.BytesIO(wav_bytes))
return wav, sr
except Exception as e:
print(f"VOICEVOX HTTP error: {e}")
return None
呼び出し側は None チェックを入れておきます.
result = synth.synthesize("こんにちは", speaker_id=47)
if result is not None:
wav, sr = result
sd.play(wav, samplerate=sr, blocking=True)
ハマりどころまとめ
| 問題 | 原因 | 対処 |
|---|---|---|
ImportError: DLL load failed |
os.add_dll_directory() を呼んでいない |
DLL のあるディレクトリを明示的に追加する |
| 公式ダウンローダーが使えない | TTY 必須のライセンス同意プロンプトがある | GitHub リリースから直接 Invoke-WebRequest で取得 |
StyleType の比較が壊れる |
Python バインディングの enum 表現がバージョンで変わる |
str(style.type).lower() で文字列比較する |
synth.metas でエラー |
property か method かがバージョンで違う |
callable() で判定して両方に対応する |
| GUI が再生中にフリーズする |
sd.play(blocking=True) をメインスレッドで呼んでいる |
再生は別スレッドで行う (下記参照) |
sd.play(blocking=True) はスクリプト用途では手軽ですが, GUI アプリのイベントハンドラやボタンコールバックから直接呼ぶと音声が終わるまで画面が固まります. 別スレッドに逃がすのが基本です.
import threading
def play_async(wav, sr):
threading.Thread(target=sd.play, args=(wav,), kwargs={"samplerate": sr, "blocking": True},
daemon=True).start()
注意: play_async を短い間隔で連続呼び出しすると複数スレッドが同時に sd.play を実行して音声が重なります. 1 発話ずつ確実に順番再生したい場合は Queue パターンを使ってください ("セグメント単位のパイプライン"の実装例を参照).
結果
| 施策 | 効果 |
|---|---|
| Whisper tiny + beam_size=1 | 転写速度が数倍に |
| vad_filter 無効化 | 二重 VAD を解消 |
| prePhonemeLength=0 | 再生開始の無音を除去 |
| requests.Session | TCP 接続を使い回し |
| セグメント単位パイプライン | 長い発話の体感レイテンシを削減 |
| voicevox_core インプロセス | HTTP 往復を排除,参考にしていたリアルタイム音声アプリと同等のレスポンスに |
"Whisper が遅い"と思って最適化を続けていましたが, 実際には HTTP 境界がボトルネックでした.
リアルタイム性を重視するなら, VOICEVOX HTTP API より voicevox_core を最初から選ぶ方が近道かもしれません.
さらに削りたい場合は CUDA 版や DirectML 版の voicevox_core を使うと合成処理を GPU にオフロードできます. ただし今回の用途では CPU 版で十分でした.