AI VTuber 2人を1台のPCで喋らせる|音声・VTube Studio・字幕で詰まった設計ポイント
目次13項目
AI VTuberを1人だけ動かすところまでは、LLM、音声合成、VTube Studio、OBSを順番につなげれば形になります。しかし、キャラクターを2人に増やすと、単純に同じ処理を2回実行するだけでは済みません。
私が作っている姉妹2人組のAI VTuberでは、YouTube Liveのコメントを受け取り、単独発話、2人の掛け合い、短い後置相づちを生成します。ただし、音声は必ず1ターンずつ順番に再生します。実装を進める中で特に困ったのが、音声再生と字幕表示が「現在喋っている人は1人」という前提で作られていたことでした。
この記事では、実際のコードと動作ログを基に、1台のWindows PCで2人を動かすために分離した部分と、あえて同時発話を見送った理由をまとめます。
この記事で確認しているのは、主に音声経路、字幕、VTube Studio制御の構成です。OBS、VTube Studio、TTSを同時に動かしたときの性能はPC構成で変わるため、「どのPCでも同じ速度で動く」という性能保証ではありません。
音声合成とLLMを初めてつなぐ場合は、先にClaude API×VOICEVOXでAIキャラをしゃべらせる入門記事を読むと、この記事の構成を理解しやすくなります。
この記事で分かること
- キャラクターごとに音声出力先を分ける理由
- VB-CABLEを2系統使うときの注意点
- VTube Studioを2インスタンス制御する設定
- 字幕が1枚のままだと同時発話に対応できない理由
- TTSの並列生成と音声の同時再生が別問題である理由
- 最初から同時発話へ対応する場合に必要な設計
2人構成では何を分ける必要があるか
現在の構成では、姉と妹にそれぞれ専用の音声経路とVTube Studio接続先を割り当てています。一方、コメント取得、会話生成、字幕サーバーは共有しています。

図の見方:左側の1台のPCと会話制御は共有しますが、発話ターンが決まった後は青と赤のキャラクター別経路へ分かれます。さらに各キャラクター内でも、TTSからVB-CABLEを通ってOBSへ入る音声経路と、Public APIからVTube Studioを制御してOBSへ映像を入れる経路は並列です。音声がVTube Studioを経由する構成ではありません。
| 層 | 姉 | 妹 | 共有できるか |
|---|---|---|---|
| TTS設定 | 姉用の声・モデル | 妹用の声・モデル | 分離する |
| 音声出力 | CABLE-A Input | CABLE-B Input | 分離する |
| VTube Studio | 127.0.0.1:8001 | 127.0.0.1:8002 | 分離する |
| VTS認証情報 | 姉用トークンファイル | 妹用トークンファイル | 分離する |
| コメント取得 | 同じ配信コメント | 同じ配信コメント | 共有できる |
| 字幕サーバー | 同じWebSocket | 同じWebSocket | 直列再生なら共有できる |
重要なのは、キャラクターの設定を分けるだけでなく、外部ソフトへ接続する出口も分けることです。
壁1:仮想オーディオケーブルが1本では足りない
現在の1プロセス構成で、OBSから2人の音量、ミュート、フィルターを別々に扱うには、音声を別のデバイスへ出力する必要があります。別プロセス化やOBSのアプリケーション音声キャプチャなど別方式もありますが、この記事では実装済みの仮想オーディオ分離を扱います。VTube Studioのマイクリップシンクを使う場合もキャラクターごとの入力が必要ですが、現在のプログラムは口と表情をPublic APIから制御しているため、VTube Studioのマイク入力をリップシンクには使っていません。
VB-Audio公式ページによると、通常のVB-CABLEとは別に、独立した2本の仮想ケーブルを追加するVB-CABLE A+Bが提供されています。A+Bは通常版と異なり、個人利用でもWebShopでライセンスを購入してダウンロード権を受け取る形式です。「Donationware for download」は金額を選べる販売形式を指し、A+Bを無料で入手できるという意味ではありません。2人分の独立した経路が必要なら、この追加費用を事前に想定しておく必要があります。
VB-Audio公式ライセンス案内では、企業・組織などの業務利用ではライセンス購入が必要と説明されています。収益化配信や業務で使う場合は、用途が条件に該当するかを含め、公開・運用前に最新のライセンスを確認してください。
現在の実装では、通常はキャラクターYAMLで出力先を設定し、必要なときだけ環境変数で上書きします。
# characters/sister_a.yml
audio_device: "CABLE-A Input"
# characters/sister_b.yml
audio_device: "CABLE-B Input"一時的に出力先を変える場合は、次の環境変数がYAMLより優先されます。
SISTER_A_AUDIO_DEVICE=CABLE-A Input
SISTER_B_AUDIO_DEVICE=CABLE-B Inputこの構成では、PythonからCABLE-A Inputへ再生した姉の音声はCABLE-A Output側で受け取り、妹はB側で受け取ります。OBSでは、受け取り側のOutputをキャラクターごとに音声ソースとして追加します。

図の見方:上段の青い音声と下段の赤い音声は、それぞれ別の仮想ケーブルを通ってOBSへ届きます。OBS側でも2つのチャンネルとして残るため、片方だけの音量調整、ミュート、ノイズ抑制などを行えます。途中で同じデバイスへ合流させると、この個別調整ができなくなります。
デバイス番号より名前を使う
音声デバイスは17や21のようなインデックス番号でも指定できます。しかし、USB機器の抜き差しやデバイス構成の変更後に番号が変わることがあります。そのため、現在の実装では名前からデバイスを探しています。
exact = [device for device in devices if device.name.lower() == lower_hint]
if exact:
return exact[0].index
contains = [device for device in devices if lower_hint in device.name.lower()]
if contains:
return contains[0].index完全一致を先に試し、見つからない場合だけ部分一致を使います。ただし、CABLEのような短すぎる名前を指定すると、AとBの両方に一致する可能性があります。エラーが出ないまま2人が同じデバイスへ出力されるため、設定値にはCABLE-A Inputのような識別可能な名前を使う方が安全です。
見つからない場合は、利用可能な出力デバイス一覧を表示して停止させています。無音のまま処理を続けるより、配信前に設定ミスへ気づきやすくなります。
語尾が切れる場合は末尾無音を試す
この環境では、合成したWAVをそのまま再生すると、発話の最後が短く聞こえる場合がありました。対策として、再生前に発話種別ごとの無音を末尾へ追加しています。
silence_frames = int(samplerate * tail_silence_ms / 1000)
silence = np.zeros(silence_frames, dtype=audio.dtype)
audio = np.concatenate([audio, silence], axis=0)| 発話種別 | 現在の末尾無音 |
|---|---|
| 通常発話 | 140ミリ秒 |
| 短い相づち | 80ミリ秒 |
| 思考・相談・深掘り | 220ミリ秒 |
audio_router.py単体の既定値は300ミリ秒ですが、メインランタイムは発話ごとに上記の値を明示的に渡すため、通常配信へ300ミリ秒がそのまま使われるわけではありません。
これはすべての環境で必須という意味ではなく、現在の音声経路で有効だった実装上の対策です。語尾が切れるときは、TTSモデルだけでなく、再生処理、バッファ、サンプルレート、仮想オーディオ経路も切り分ける必要があります。
実際のTTS経路はStyle-Bert-VITS2
姉妹の音声は、共通のStyle-Bert-VITS2サーバーへPOST /voiceでリクエストし、キャラクターごとにモデル名、speaker ID、styleを変えています。長い台詞は既定85文字を目安に分割し、HTTP 422になったチャンクは短縮して1回再試行します。
response = requests.post(
f"{SBV2_URL}/voice",
params={
"text": normalize_for_tts(text),
"model_name": profile.model_name,
"speaker_id": profile.speaker_id,
"style": delivery.style,
"auto_split": True,
},
timeout=90,
)サーバーは共有でも、返ってきたWAVの再生先はprofile.audio_deviceでA/Bへ分けています。ここを分離しなければ、声モデルを2つ用意してもOBS上では同じ音声ソースへ合流します。
壁2:VTube Studioをキャラクターごとに分離する
VTube Studioの公開APIは、複数インスタンスを識別できる仕組みを持っています。VTube Studio Public APIの公式仕様では、起動したインスタンスごとに異なるinstanceIDが割り当てられ、Windowsではウィンドウ名も区別されると説明されています。
同じPCで2つのAPIサーバーへ接続するには、待ち受けポートを重複させないようにします。現在は次のように設定しています。
characters:
sister_a:
host: "127.0.0.1"
port: 8001
plugin_name: "AI VTuber Duo Live Sister A"
token_file: "secrets/vts_sister_a_token.txt"
sister_b:
host: "127.0.0.1"
port: 8002
plugin_name: "AI VTuber Duo Live Sister B"
token_file: "secrets/vts_sister_b_token.txt"ポートだけでなく、プラグイン名とトークン保存先も分けています。plugin_developerは共通ですが、プラグイン名が異なるため姉妹の認証を区別できます。初回接続時は各VTube Studioで許可操作が必要で、片方が接続できなくても、もう片方はVTS skipとして単独で続行できる実装です。

図の見方:青と赤の接続は、ポートだけでなく認証トークンの保存先も独立しています。2本の接続先が同じVTube Studioを指すと同じモデルを操作してしまうため、接続先・プラグイン名・トークンを1組としてキャラクターごとに分けます。
APIで口や表情を動かす場合の注意
VTube Studio APIのInjectParameterDataRequestは、指定したパラメータへ値を送信できます。modeを省略するかsetにすると現在値を上書きし、addにすると現在値へ加算します。公式仕様では、制御を維持したいパラメータは少なくとも1秒に1回再送する必要があるとされています。送信が途切れると制御を失い、以前の入力元または既定値へ戻ります。
現在の設定はuse_parameters: true、use_hotkeys: falseです。0.35秒で表情プリセットへ遷移し、0.2秒間隔でパラメータを再送します。発話終了後はneutralへの送信を約1秒続けてから止めます。この1秒にはneutralへ移る遷移時間も含まれます。
use_parameters: true
use_hotkeys: false
parameter_mode: "set"
send_interval_sec: 0.2
transition_sec: 0.35
after_speech_hold_sec: 1.0接続時にはInputParameterListRequestでモデル側の入力パラメータを取得します。設定に書いたFaceAngleXやMouthOpenなどが存在しなければ、その項目を送信対象から外し、VTS parameter not foundとしてログへ残します。保存されている動作ログでは、姉妹とも8001/8002へ接続し、それぞれ127個の入力パラメータを取得できていました。
VTube Studio公式仕様では、同じ入力パラメータをsetで上書き制御できるAPIプラグインは同時に1つです。一方、addは現在値への加算なので、複数のプラグインが同じパラメータへ同時に値を加えられます。この記事の設定はparameter_mode: "set"のため、1パラメータにつき1プラグインという競合制限の対象です。
なお、addへ変えても、2人のモデルや接続先が自動的に分離されるわけではありません。2人分の接続設定が同じVTube Studioインスタンスを指していると、別キャラクターを動かしているつもりでも、同じモデルのパラメータへ値を送ってしまいます。
現在の実装は発話開始と終了にモーションを連動させる
現在のシステムでは、字幕を更新した後、音声再生の直前にon_turn_start()を呼び、再生終了後にon_turn_end()を呼びます。
broadcast_caption(caption, profile, text, expression)
motion_controller.on_turn_start(
speaker=profile.key,
expression=expression,
text=text,
)
play_character_voice(text, profile, wavs=wavs)
motion_controller.on_turn_end(
speaker=profile.key,
expression=expression,
)これは音声の波形や音素へ完全同期するリップシンクではありません。現在のMouthOpenは表情プリセットに含まれる固定の目標値で、発話中に口を波形どおり開閉する処理はありません。発話開始・終了と表情を同期させる制御と考える方が正確です。
波形に合わせた口の開閉が必要なら、キャラクターごとに分けた仮想マイク入力をVTube Studioへ渡す方法か、音声振幅を解析して口パラメータを継続送信する処理が別途必要です。
VTS通信が切れた場合は自動再接続しない
保存ログでは姉妹とも複数回正常接続できている一方、VTS parameter inject failedのタイムアウトも少数記録されていました。現在のコードはパラメータ送信に失敗すると、そのクライアントを未接続状態にして送信スレッドを終了します。セッション中の自動再接続は未実装です。
表情が途中から動かなくなった場合は、ログのVTS parameter inject failedを確認し、VTube StudioのAPI設定、ポート、許可状態を確認してランタイムを再起動します。長時間配信をより安定させる次の改善候補は、回数制限と待機時間を持つ再接続処理です。
壁3:字幕パネルが1枚しかない
字幕はローカルのWebSocketサーバーから、OBSのブラウザソースへ次のようなデータを送ります。
caption.broadcast({
"type": "speak",
"speaker_label": profile.label,
"text": text,
"expression": expression,
"char_interval_ms": CAPTION_CHAR_INTERVAL_MS,
})speaker_labelを含めているため、順番に喋る構成なら1枚の字幕パネルでも話者を表示できます。HTML側は話者名で色を変え、既定55ミリ秒間隔で文字送りをします。また、サーバーは最後に送ったメッセージを1件だけ保持し、OBSが再接続したときに復元します。
裏返すと、同時に保持できる字幕状態も1件だけです。新しい発話が届くと進行中の文字送りを止めて表示を書き換えるため、2人が同時に喋ると後から届いた字幕が前の字幕と競合します。
同時発話へ対応するなら、字幕データに話者IDと発話IDを持たせ、姉用と妹用の表示領域を分ける必要があります。音声だけ重ねても、視聴者がどちらの台詞か追えなければ配信として見づらくなります。
なぜ「2人の同時発話」を見送ったのか
姉妹の会話では、一方が話している途中にもう一方が短く反応する「かぶせ」を入れたくなります。しかし、現在は本格的な同時発話を実装せず、ターンごとに順番に再生しています。
理由は、音声再生だけを非同期にしても完成しないからです。
sounddeviceのsd.playは重ねて再生できない
現在の音声ルーターは、sounddeviceの便利関数を使っています。
sd.play(audio, samplerate, device=device)
sd.wait()python-sounddeviceの公式ドキュメントには、play()は複数の重なった再生には使えず、呼び出すと実行中のplay()などをstop()で停止すると書かれています。同時再生が必要なら、キャラクターごとにOutputStreamまたはRawOutputStreamを明示的に作る必要があります。
つまり、blocking=Falseへ変えるだけでは解決しません。姉の再生中に妹がsd.play()を呼ぶと、独立した2本のストリームとして重なるのではなく、既存の再生へ影響します。
TTSの並列生成と音声の同時再生は別
現在の実装は、待ち時間を短くするために複数ターンのTTS生成を先読みしています。
with ThreadPoolExecutor(max_workers=max_workers) as executor:
tts_futures = [
executor.submit(synthesize_character_voice, ...)
for turn in prepared_turns
]
for index, turn in enumerate(prepared_turns):
wavs = tts_futures[index].result()
play_character_voice(..., wavs=wavs)正確には、Python側がTTSリクエストを複数ワーカーから先行送信します。Style-Bert-VITS2側で推論が本当に同時実行されるかは、サーバーの実行方式とGPUに依存するため、「GPU推論そのものが必ず並列になる」という意味ではありません。
再生はforループの中で順番に行われます。この方式なら、後続台詞の準備を早く始められますが、2人の音声が同時に鳴るわけではありません。
VTube Studio側はすでにキャラクター別クライアントになっている
先ほどの版では、モーション制御がspeaker/listenerモデルへ強く依存していると説明していました。しかし現行設定を確認すると、この説明は正確ではありませんでした。
DuoVTSMotionControllerは姉妹それぞれに独立したVTubeStudioClientとパラメータ送信スレッドを持っています。聞き手用ホットキーを呼ぶコード経路はありますが、現在はuse_hotkeys: falseなので動作していません。発話していない側へ、明示的なlistenerモーションを送る処理も現在はありません。
同時発話へ進む場合、VTS層を最初から作り直す必要はありません。既存のキャラクター別クライアントを再利用しながら、メイン側で2人のon_turn_start()とon_turn_end()を独立して管理する変更が必要です。一方、自然な掛け合いを目指すなら、talk、listen、idle、reactionを重ねられる状態管理は別途追加した方がよいでしょう。
同時発話は1か所の変更では完成しない
| 変更する層 | 同時発話に必要な変更 |
|---|---|
| 音声 | キャラクター別のOutputStreamと再生キュー |
| 字幕 | 話者別の2パネルと発話IDによる更新管理 |
| VTSモーション | 既存のキャラクター別クライアントを再利用し、開始・終了を独立管理 |
| 進行制御 | speak_turns()の直列ループをキャラクター別タスクへ変更 |
大きな作り直しが必要なのは音声、字幕、進行制御です。VTS接続はすでに分離されていますが、自然な口の動きや聞き手反応まで含めるなら追加設計が必要です。そのため現在は、部分的に音だけ重ねるより、直列再生の安定性を優先しています。
現在採用している処理順
現時点の処理は、次の順番です。
- 配信コメントから姉妹の発話ターンを作る
- 既定2ワーカーで複数ターンのTTSリクエストを先行送信する
- 現在の話者名と台詞を字幕へ送る
- 話者側のVTube Studioへ発話開始を通知する
- 話者専用の仮想オーディオデバイスへ音声を再生する
- 再生終了後に発話終了を通知する
- 発話種別に応じた短い間を空けて次の話者へ進む
この構成は完全な同時会話ではありませんが、音声の衝突、字幕の上書き、モーション状態の競合を避けながら、2人の掛け合いを安定して配信できます。

図の見方:青い話者の字幕、モーション、音声を開始し、再生が終わってから赤い話者へ進みます。音声波形が時間上で重ならないため、1枚の字幕パネルと現在の再生処理でも話者が競合しません。短い相づちも同じ順序に乗せ、前の話者が終わった直後へ配置します。
通常ブロックで条件が合う場合は、既定20%で片側だけの発話、既定22%で2人目の短い相づちを選ぶ仕組みがあります。相づちは音声を重ねず、1人目の終了後に0.08〜0.22秒の短い間を置いて再生します。これは「かぶせ」ではなく、後置相づちで会話の空白を減らす方法です。
また、conversation_metrics.jsonlへTTS生成時間、WAVの実音声長、コメント採用から可聴開始までの時間、発話開始・終了を記録するコードがあります。同時発話へ進む前に、この計測を使って直列構成の遅延と会話テンポを評価できます。
現行実装で残っている改善点
| 改善点 | 現在の状態 | 次の変更候補 |
|---|---|---|
| VTS再接続 | 送信失敗後は未接続のまま | 待機時間と上限回数を持つ再接続 |
| 口の動き | 表情プリセットの固定値 | WAV振幅または仮想マイク連動 |
| 聞き手モーション | direct parameterモードでは未実装 | talk/listen/idleの状態レイヤー |
| 字幕識別 | speaker_labelのみ | speaker_keyとutterance_idを追加 |
| 音声同時再生 | sd.play()による直列再生 | キャラクター別OutputStreamとキュー |
| デバイス名解決 | 完全一致後に部分一致 | 部分一致が複数ならエラーにする |
| TTS失敗時 | 字幕とVTS開始処理まで進む場合がある | 音声0件なら字幕・モーションを止めて再試行 |
| 1台PCの負荷 | TTS、OBS、VTSが資源を共有 | TTS時間とOBSのフレーム落ちを一緒に監視 |
私の実装で行う配信前の切り分け
2人構成は、すべてを起動してから原因を探すと切り分けが難しくなります。以下は私のローカル実装に含めている診断コマンドです。同じコード一式がなくても、音声デバイス、A/B経路、字幕、VTSの順で確認する考え方は流用できます。
音声を使う手順では、先にvoice/Style-Bert-VITS2/Server.batを起動し、音声合成サーバーがhttp://127.0.0.1:5000で応答できる状態にしておきます。
1. 音声デバイス名を確認する
.\voice\Style-Bert-VITS2\venv\Scripts\python.exe .\scripts\audio_router.py --listここでCABLE-A InputとCABLE-B Inputが別々に表示されることを確認します。
2. 姉妹の音声経路だけを確認する
.\voice\Style-Bert-VITS2\venv\Scripts\python.exe .\scripts\duo_voice_route_test.py --list-devices
.\voice\Style-Bert-VITS2\venv\Scripts\python.exe .\scripts\duo_voice_route_test.py姉のテスト音声ではOBSのA側だけ、妹のテスト音声ではB側だけメーターが動くことを確認します。両方が同時に動くなら、Pythonより後ろのWindowsまたはOBS側で経路が合流しています。
この経路テストはルーティング確認用で、メインランタイムの発話種別別TTS設定や末尾無音を完全に再現するテストではありません。
3. 固定台詞で字幕と音声を確認する
.\run_duo_live_chat.bat --demo --no-vtsLLMとVTube Studioを外した状態で、字幕とA/B音声を確認します。ここで問題が出るなら、原因は会話生成やVTSではありません。
4. VTube Studioだけを確認する
.\run_duo_live_chat.bat --vts-test姉妹それぞれの表情プリセットを順番に送ります。初回は2つのVTube Studioで接続許可を行い、ログにVTS connectedとVTS input parametersが両方出ることを確認します。
この順番なら、音声デバイス、音声経路、字幕、VTS接続を分けて確認できます。
これから2人構成を作る人への設計チェックリスト
- 音声出力先をキャラクターごとに独立させる
- デバイス番号ではなく識別可能な名前を設定する
- VTube Studioのポートとトークン保存先を分ける
- 初回は2つのVTube StudioでそれぞれAPI接続を許可する
InputParameterListRequest後の不足パラメータログを確認する- 字幕データへ話者IDを含める
- TTS生成の並列化と音声再生の並列化を区別する
- 同時発話を行うか、設計の最初に決める
- 同時発話するなら音声・字幕・モーションを一緒に設計する
- 配信前に姉だけ、妹だけ、交互発話の順で疎通確認する
まとめ
AI VTuberを2人に増やすときの本当の難しさは、LLMを2回呼ぶことではありません。音声デバイス、VTube Studio、字幕、モーションが持っている「現在の話者は1人」という前提を、どこまで残し、どこから分離するかを決めることです。
現在の構成では、音声経路とVTube Studio接続をキャラクターごとに分け、TTSを先読みしながら音声は順番に再生しています。VTube Studioクライアントはすでに2人分へ分離できていますが、同時発話には音声ストリーム、字幕、進行制御の変更が必要です。
これから複数キャラクターのAI VTuberを作る場合は、将来2人が同時に喋る可能性があるかを、音声再生の実装を始める前に決めておくことをおすすめします。
あわせて読みたい
参考にした公式資料
- VB-Audio Virtual Cable公式ページ
- VTube Studio Public API公式仕様
- python-sounddevice Convenience Functions公式ドキュメント
この記事は、実際の姉妹AI VTuber実装と動作ログ、各ソフトの公式仕様を基にしています。VTube Studio連携や音声デバイスの挙動は、使用バージョンとWindows側の構成によって変わる可能性があります。

コメント欄に近づくと読み込みます。