🎙️

"ビビる大木AI"を生放送で喋らせた全技術 — ラヴィット!裏側

に公開
91

"ビビる大木AI"を生放送で喋らせた全技術 — ラヴィット!裏側

オンエア画面: AI版ビビる大木が「わっしょーい!」と発話中

はじめに

TBS「ラヴィット!」のミステリー企画で、AI版「ビビる大木」を生放送に出演させるシステムを徹夜二日間で開発しました。初回発話レイテンシ2.5秒、本番の生放送で事故ゼロ

3Dキャラクターが裏方オペレーターの操作やAIの応答に合わせてリアルタイムに発話し、口を動かし、字幕を表示する。いわゆる「AIバーチャルタレント」のライブ出演基盤です。この記事では、音声クローンから3Dリップシンク、日本語処理、AI駆動開発まで、システムの全技術を解説します。

システム全体のアーキテクチャ

音声モデルの学習 — GPT-SoVITSで「ビビる大木の声」を作る

AIキャラクターに「ビビる大木の声」で喋らせるには、まず音声クローンモデルを構築する必要があります。今回採用したGPT-SoVITSは、少量の音声データからターゲット話者の声質を再現できるfew-shot音声クローン技術です。

GPT-SoVITSの仕組み

GPT-SoVITSは2つのモデルの組み合わせで構成されています。

  • SoVITS(Sovereign VITS): VITSベースの音声合成モデル。音素列から自然な音声波形を生成する
  • GPT: テキストから中間表現(セマンティックトークン)を予測するAutoRegressiveモデル。韻律・イントネーションの自然さを担う

この2段構成により、少量データ(数分〜1時間程度)のfine-tuningで話者の声質・韻律を学習できます。

学習データと前処理

番組側から提供された1時間以上の音声素材を使用しました。ただし、テレビ番組の音声にはBGM・SE・他の出演者の声が混在しているため、そのまま学習データには使えません。

  1. ノイズ除去・BGM分離: 音声分離技術でビビる大木の声だけを抽出
  2. Whisperセグメンテーション: OpenAIのWhisperで文字起こし&発話区間を自動分割。各セグメントに対応するテキストが学習時の教師データになる
  3. SoVITS fine-tuning: 声質・音色の学習
  4. GPT fine-tuning: 韻律・イントネーションパターンの学習

リファレンス音声の選び方

GPT-SoVITSの合成時にはリファレンス音声(推論のプロンプトとなる短い音声クリップ)を指定します。このリファレンス選びが合成品質を大きく左右しました。

選定基準:

  • テンション: 中程度が安定。テンションが高すぎるクリップは出力の抑揚が不安定になる
  • 発話速度: 普通〜やや速めが最適。ゆっくりすぎると間延びした音声になりやすい
  • クリアさ: ノイズやBGMが混じっていないクリーンなクリップを選ぶ

最終的に選んだリファレンス音声は app/data/reference_clip.wav としてシステムに組み込まれ、全ての合成リクエストで共通のプロンプトとして使用されています。

ハマりポイント

リアルタイムストリーミングパイプライン

課題: 生放送のレイテンシ制約

生放送では「AIに質問→応答が返る」までの沈黙が致命的です。通常のLLM + TTS構成では、

  1. LLMが全文を生成し終わるまで待つ(2〜5秒)
  2. 全文をTTSに投げて合成完了を待つ(3〜10秒)
  3. フロントエンドに送信して再生開始

という直列処理で、初回発話まで5〜15秒かかります。生放送のテンポ感に合わせるには、この遅延を3秒以内に縮める必要がありました。

解法: LLMストリーミング → 文境界検出 → 並列TTS → 逐次配信

設計の核は「全文完成を待たず、1文できた瞬間にTTS合成を開始し、合成できた順にフロントエンドへ送る」というストリーミングパイプラインです。

文境界検出

LLMからストリーミングされるトークンをバッファに溜め、句点・感嘆符などの文境界を検出した時点で文を確定させます。

# flask_app.py — 文境界検出
_SENTENCE_BOUNDARY_RE = re.compile(r'[。!?!?…]')

def _extract_sentences_from_buffer(buffer):
    """バッファから完成した文を抽出。(完成文リスト, 残りバッファ) を返す"""
    sentences = []
    while True:
        match = _SENTENCE_BOUNDARY_RE.search(buffer)
        if not match:
            break
        end = match.end()
        sentence = buffer[:end].strip()
        if sentence:
            sentences.append(sentence)
        buffer = buffer[end:]
    return sentences, buffer

ここでポイントになるのは、読点(、)を文境界にしない判断です。読点で切ると、1〜2語の短い断片が大量にTTSに投げられてしまい、合成品質が著しく低下します。句点・感嘆符など「意味的に文が完結する」記号だけを境界として使うことで、TTS合成に十分な長さのテキストを確保しています。

並列TTS合成と順序保証

文が確定するたびにThreadPoolExecutorでTTS合成をバックグラウンド投入し、完了した順ではなく元の文順で配信します。

# flask_app.py — AIモード ストリーミングパイプライン(核心部分)
tts_executor = ThreadPoolExecutor(max_workers=2)
pending_futures = []  # (index, future, sentence_text)

for token in _llm_chat_stream(messages):
    buffer += token
    full_text += token

    # 文境界で分割
    sentences, buffer = _extract_sentences_from_buffer(buffer)
    for sentence in sentences:
        sentence = clean_response_text(sentence)
        if not sentence:
            continue
        idx = chunk_index
        chunk_index += 1
        # TTS合成をバックグラウンドで即座に開始
        future = tts_executor.submit(_synthesize_sentence, sentence, tts_params)
        pending_futures.append((idx, future, sentence))

    # 先頭から順に、完了済みのfutureをemit(順序保証)
    while pending_futures and pending_futures[0][1].done():
        idx, future, sent_text = pending_futures.pop(0)
        result = future.result()
        socketio.emit('speak_chunk', {
            'text': sent_text,
            'audio': result['audio'],
            'index': idx,
            'duration': result['duration'],
        })

pending_futuresをリスト(FIFO)で管理し、先頭のfutureが完了していなければ後続が完了していても送信しないという制約が順序保証のカギです。max_workers=2にしているのは、GPT-SoVITSのAPIサーバーが並列リクエストを2つまで効率的に捌けるという実測結果に基づいています。

台本キャッシュ — ゼロレイテンシ再生

ミステリー企画には台本がありました。事前に決まっているセリフは、サーバー起動時に全文TTS合成してメモリにキャッシュしておきます。

# flask_app.py — 台本テキスト一覧(事前キャッシュ対象)
SCRIPTED_LINES = [
    "そう、大変なんだよ〜。でも、事件に遭うまでのことは全部覚えてるから...",
    "実は、犯人だと思う人は4人まで絞れているんだよ!...",
    "わっしょーい!",
    "こんばんみ!",
    # ... 全20行
]

def _warmup_cache():
    """全台本テキストを事前にTTS合成しキャッシュに格納"""
    for line in SCRIPTED_LINES:
        result = _synthesize_and_encode(line, **params)
        _audio_cache[line] = result

台本ボタンを押した瞬間、TTS合成を一切待たずに即座にオンエア画面へ音声が飛びます。テレビの進行に合わせて「解析中...」の演出を3秒挟む箇所でも、裏ではキャッシュから瞬時に音声を取得しているだけです。

パフォーマンス実測値

本番環境(Mac Studio M2 Ultra + GPT-SoVITS ローカル)での実測:

指標 台本モード AIモード(3文応答)
初回発話開始(TTFC) ~0.1秒(キャッシュ) ~2.5秒
全文再生完了 セリフ長に依存 ~8秒
LLMストリーム時間 N/A ~1.5秒
TTS合成(1文平均) N/A ~1.2秒

AIモードでも初回チャンク2.5秒は、生放送のテンポ感に十分収まりました。

日本語テキスト処理パイプライン

課題: GPT-SoVITSが日本語を正しく読めない

GPT-SoVITSは優れた音声合成エンジンですが、日本語のG2P(文字→音素変換)に弱点があります。

  • 「20分」→「にじゅうぶん」(正しくは「にじゅっぷん」)
  • 「1日」→「いちにち」(文脈によっては「ついたち」)
  • 「300」→ 無音 or 意味不明な発声
  • 「大木」→「おおき」ではなく「だいき」と読むことがある

LLMの応答は何が返ってくるかわからないので、あらゆる数字・漢字に対応する前処理パイプラインが必要でした。

解法: 3段パイプライン

ステージ1: サニタイズ

LLMの出力にはURL、装飾記号(★♪♡など)、余計な空白が含まれることがあります。これらをTTSに渡すと発声が壊れるので除去します。

# local_tts.py — TTSサニタイズ
_URL_RE = re.compile(r'https?://\S+|www\.\S+')
_DECO_RE = re.compile(r'[★☆♪♡♥●○■□▼▲△▽◆◇→←↑↓─━═~〜※*]{2,}')
_SYMBOL_RE = re.compile(r'[★☆♪♡♥●○■□▼▲△▽◆◇→←↑↓─━═※*]')

def _sanitize_for_tts(text: str) -> str:
    """TTS に渡す前にテキストをクリーニング。英語はそのまま残す。"""
    text = _URL_RE.sub('', text)
    text = _DECO_RE.sub('', text)
    text = _SYMBOL_RE.sub('', text)
    return text.strip()

ステージ2: 数字→日本語読み変換(助数詞17種対応)

日本語の数字読みは助数詞によって大きく変わります。「1本」は「いっぽん」、「2本」は「にほん」、「3本」は「さんぼん」— 半濁音・濁音・促音が助数詞ごとに異なります。

助数詞の読みテーブル(抜粋)
# local_tts.py — 助数詞の読みテーブル(抜粋)
_COUNTER_READINGS = {
    '分': {
        1: 'いっぷん', 2: 'にふん', 3: 'さんぷん', 4: 'よんぷん',
        5: 'ごふん', 6: 'ろっぷん', 7: 'ななふん', 8: 'はっぷん',
        9: 'きゅうふん', 10: 'じゅっぷん',
    },
    '日': {
        1: 'ついたち', 2: 'ふつか', 3: 'みっか', 4: 'よっか',
        5: 'いつか', 6: 'むいか', 7: 'なのか', 8: 'ようか',
        9: 'ここのか', 10: 'とおか', 14: 'じゅうよっか',
        20: 'はつか', 24: 'にじゅうよっか',
    },
    '本': {
        1: 'いっぽん', 2: 'にほん', 3: 'さんぼん', 4: 'よんほん',
        5: 'ごほん', 6: 'ろっぽん', 7: 'ななほん', 8: 'はっぽん',
        9: 'きゅうほん', 10: 'じゅっぽん',
    },
    # ... 他14種(時・人・歳・個・回・枚・杯・匹・冊・階・秒・年・月・件・発)
}

11以上の数字は「十の位 + 一の位のテーブル読み」で合成します。このとき、促音化が必要な助数詞(分・個・回・本・杯・匹・冊・階・件・発・歳)では「じゅう」を「じゅっ」に変換します。

# local_tts.py — 11以上の助数詞読み合成
# 促音化対象の助数詞
_SOKU_COUNTERS = {'分', '個', '回', '本', '杯', '匹', '冊', '階', '件', '発', '歳'}

def _read_with_counter(n: int, counter: str) -> str:
    tbl = _COUNTER_READINGS.get(counter)
    if n in tbl:
        return tbl[n]  # テーブルに完全一致
    if n >= 11:
        juu_digit = n // 10
        ones = n % 10
        juu_prefix = _DIGITS[juu_digit] if juu_digit > 1 else ''
        # 「20分」→「にじゅっぷん」: 促音化
        juu_base = 'じゅっ' if counter in _SOKU_COUNTERS else 'じゅう'
        tens_reading = juu_prefix + juu_base
        if ones == 0:
            suffix = tbl[10].replace('じゅう', '').replace('じゅっ', '')
            return tens_reading + suffix
        elif ones in tbl:
            return tens_reading + tbl[ones]  # 15分 → じゅうごふん
    return _num_to_kana(n) + counter

この設計により、「20分」→「にじゅっぷん」、「15本」→「じゅうごほん」、「1日」→「ついたち」が正しく発声されます。

ステージ3: 漢字→ひらがな変換(fugashi + 多音字判定)

GPT-SoVITSのG2Pが処理できない漢字を、形態素解析器 fugashi(MeCab互換)でひらがなに変換します。ただし、fugashiの出力をそのまま使うと多音字(同じ漢字で複数の読みがある文字)で誤読が発生します。

# local_tts.py — 漢字読み変換
_READING_OVERRIDES = {
    '大木': 'おおき',  # fugashiは「たいぼく」と読む
    '抱負': 'ほうふ',
}

def kanji_to_reading(text: str) -> str:
    """漢字部分のみ読み(ひらがな)に変換。カタカナ・記号はそのまま。"""
    # 固有名詞を先に置換
    for word, reading in _READING_OVERRIDES.items():
        text = text.replace(word, reading)
    words = _fugashi_tagger(text)
    result = []
    for i, w in enumerate(words):
        if _HAS_KANJI.search(w.surface) and w.feature[9] and w.feature[9] != '*':
            reading = jaconv.kata2hira(w.feature[9])
            reading = _fix_polyphonic(w, reading, words, i)  # 多音字補正
            result.append(reading)
        else:
            result.append(w.surface)
    return ''.join(result)

多音字の文脈判定は、品詞情報と周辺トークンを使って行います。

# local_tts.py — 多音字の文脈補正
def _fix_polyphonic(w, reading, words, i):
    # ── 方: かた(人) vs ほう(方向・比較) ──
    if w.surface == '方':
        prev = _prev_pos(words, i)
        # 形容詞・動詞の後 →「ほう」(比較: いい方、悪い方)
        if prev in ('形容詞', '動詞', '助動詞'):
            return 'ほう'
        # あの方/この方 →「かた」(敬称)
        if i > 0 and words[i-1].surface in ('あの', 'この', 'その', 'どの'):
            return 'かた'

    # ── 日: にち vs ひ ──
    if w.surface == '日' and reading == 'にち':
        if i > 0 and words[i-1].surface in ('の', 'な', 'いい', 'ない'):
            return 'ひ'  # 「いい日」「休みの日」

    # ── 数: すう vs かず ──
    if w.surface == '数' and reading in ('すう',):
        nxt_pos = _next_pos(words, i)
        if nxt_pos == '助詞':  # 「数が多い」→「かず」
            return 'かず'

    return reading  # デフォルトはMeCabの判定を信用

面白いエッジケース集

3Dモデル制作 — Meshy AI × Blender MCP

テレビ生放送に映すには、それなりのクオリティの3Dモデルが必要です。しかし3Dモデリングをゼロから手作業でやる時間はありません。AI生成3Dモデルと、AIによるBlender操作を組み合わせたワークフローで解決しました。

制作フロー

Meshy AIでの初期生成

Meshy AIはテキストや画像から3Dメッシュを生成するAIサービスです。ビビる大木の参考画像を入力し、バスト(上半身)の3Dモデルを生成しました。

AI生成3Dモデルには「そのまま使えない」問題があります:

  • 口のトポロジーが雑: AI生成メッシュは口周辺の頂点配置が不規則で、そのままでは自然な口の開閉アニメーションができない
  • Shape Keyなし: 生成されたモデルにはリップシンクに必要なShape Key(モーフターゲット)が含まれていない
  • テクスチャの調整が必要: 色味やディテールがテレビ映えするレベルに達していない場合がある

これらの問題を解決するために、Blender MCPを活用しました。

Blender MCPワークフロー

Blender MCPとは、MCPサーバー経由でClaude等のAIがBlenderのPython APIを実行できる仕組みです。AIに「このメッシュにShape Keyを追加して」と指示すれば、AIがBlenderのPythonスクリプトを生成・実行してくれます。

ただし、AIに「口を開けるShape Keyを作って」と丸投げしてもうまくいきません。AIはメッシュのどこが「口」なのかを自力で判断できないからです。そこで、手動アノテーションというステップを挟みます。

手動アノテーション

Blender上で口の周辺に**空のオブジェクト(Empty)**をマーカーとして配置します。これがAIにとっての「ここが口です」というヒントになります。

  • 上唇の中央・左端・右端
  • 下唇の中央

これらのポイントを配置した上で、AIに次のような指示を出します:

「このアノテーション(Empty)周辺の頂点を使って、mouthClosed という名前のShape Keyを作成してください。下唇付近の頂点を上方向に移動させて、口が閉じた状態を表現してください。」

AIはアノテーションの座標を基準に、周辺頂点を検索し、適切な変形を加えたShape Keyを自動生成します。

イテレーション

一発で完璧なShape Keyはできません。AIが生成→プレビューで確認→「もう少し変形範囲を広げて」「下唇の移動量を減らして」とフィードバック→AIが修正、というサイクルを何度か回します。

このイテレーションの速さがBlender MCPの真価です。手動でPythonスクリプトを書いて頂点を調整するのは骨が折れますが、自然言語で「もう少し右側の頂点も含めて」と言えば数秒で修正版が出てきます。

display.htmlとの連携

最終的にエクスポートされたGLBファイル(app/static/bibiru_oki.glb)には mouthClosed というShape Keyが含まれています。display.htmlのThree.jsコードはモデル読み込み時にこのShape Keyを自動検出し、morphTargetInfluencesを操作するmorph target方式のリップシンクを有効化します。もしShape Keyが見つからなければ、Vertex Shaderによるフォールバック方式に自動的に切り替わります(詳細は次のセクションで解説)。

3Dリップシンク&キャラクターアニメーション

課題: 音声に合わせた3Dモデルの口元制御

音声があっても口が動かなければ「ただの人形がスピーカーで喋ってる」にしか見えません。リアルタイムの口パク(リップシンク)は臨場感の要です。

しかし、3Dモデルによってリップシンクの実装方法が異なります。Shape Key(モーフターゲット)として口の開閉が組み込まれているモデルもあれば、ないモデルもある。両方に対応する必要がありました。

解法: Shape Key / Vertex Shader の2段フォールバック

音量抽出: Web Audio API の AnalyserNode

リップシンクの入力値は、再生中の音声の「音量」です。Web Audio APIのAnalyserNodeで周波数データから正規化した音量(0.0〜1.0)を毎フレーム取得します。

// display.html — 音量抽出
function ensureAudioContext() {
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    analyser = audioCtx.createAnalyser();
    analyser.fftSize = 256;
    analyser.smoothingTimeConstant = 0.8;
    dataArray = new Uint8Array(analyser.frequencyBinCount);
    analyser.connect(audioCtx.destination);
}

function getVolume() {
    if (!analyser) return 0;
    analyser.getByteFrequencyData(dataArray);
    let sum = 0;
    for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
    return sum / (dataArray.length * 255);  // 0.0 〜 1.0
}

音声再生時にAudioBufferSourceNodeanalyserに接続することで、再生中の音声波形をリアルタイムに解析できます。

指数平滑化: 口は速く、体は遅く

生の音量値をそのままリップシンクに使うと口がカクカクします。平滑化が必要ですが、口の動きと体の動きでは最適な追従速度が異なります。

// display.html — アニメーションループ
let smoothVolume = 0;   // 体の動き用(遅い追従)
let smoothMouth = 0;    // リップシンク用(速い追従)

function animate() {
    const volume = getVolume();

    // 体の動き: ゆっくり追従(α = 0.15)→ なめらかなうなずき
    smoothVolume += (volume - smoothVolume) * 0.15;

    // 口の動き: 素早く追従(α = 0.35)→ 子音に反応する口パク
    const mouthTarget = isSpeaking ? Math.min(volume * 3.0, 1.0) : 0;
    smoothMouth += (mouthTarget - smoothMouth) * 0.35;
    // ...
}

α = 0.35(口)vs α = 0.15(体)という非対称な平滑化係数が、「口はキビキビ、体はゆったり」という自然な動きを生み出します。

方式1: Shape Key(Morph Target)

GLBモデルに mouthClosed という名前のShape Keyがある場合、Three.jsのmorphTargetInfluencesを操作して口を開閉します。

// display.html — Morph Target リップシンク
if (lipSyncMode === 'morph' && faceMesh) {
    const idx = faceMesh.morphTargetDictionary['mouthClosed'];
    // smoothMouth=1.0(最大音量)→ targetClosed=0.0(口が開く)
    // smoothMouth=0.0(無音)→ targetClosed=1.0(口が閉じる)
    const targetClosed = Math.max(0, 1.0 - smoothMouth);
    const current = faceMesh.morphTargetInfluences[idx];
    faceMesh.morphTargetInfluences[idx] = current + (targetClosed - current) * 0.3;
}

方式2: Vertex Shader 変形(フォールバック)

Shape Keyがないモデルでは、頂点シェーダーに口元変形ロジックを注入します。

モデルのバウンディングボックスから口の位置を推定し、ガウス関数的な影響半径で周辺頂点を下方向に変位させます。

// display.html — 頂点シェーダー(onBeforeCompile で注入)
uniform float uMouthOpen;    // 0.0(閉)〜 1.0(開)
uniform vec3  uMouthCenter;  // 口の中心座標(ローカル空間)
uniform float uMouthRadius;  // 影響半径

// 水平方向: 口の中心ほど強く、外側へフェードアウト
float hInfl = 1.0 - smoothstep(0.0, uMouthRadius * 2.5, hDist);

// 垂直方向: 口直下にピーク、上方と遠方でゼロ
float vInfl = (1.0 - smoothstep(-uMouthRadius * 0.2, uMouthRadius * 0.3, delta.y))
            * smoothstep(-uMouthRadius * 3.0, -uMouthRadius, delta.y);

float infl = hInfl * vInfl;

// 下顎を下げて口を開く
transformed.y -= uMouthOpen * infl * uMouthRadius * 0.3;
// わずかに前方へ押し出す(自然な開口感)
transformed.z += uMouthOpen * infl * uMouthRadius * 0.3;

smoothstepによる影響範囲の制御が重要で、口の周辺だけが変形し、目や額は動きません。uMouthRadius * 2.5(水平)と uMouthRadius * 3.0(垂直)の非対称な半径設定により、顎が開く自然な動きを近似しています。

発話アニメーション: 周波数設計

口パクだけでなく、発話中のうなずき・首振り・体揺れも音量に連動させます。各動きに異なる周波数の正弦波を割り当てることで、規則的すぎないオーガニックな動きになります。

// display.html — 発話中アニメーション
if (isSpeaking && smoothVolume > 0.01) {
    // うなずき: 5Hz(早め) — 話すリズムに合わせて小刻みに
    model.position.y = modelBaseY + Math.sin(t * 5) * smoothVolume * 0.06;
    // 首の傾き: 3.2Hz — うなずきと非同期にすることで自然さUP
    model.rotation.z = Math.sin(t * 3.2) * smoothVolume * 0.08;
    // 前後のうなずき: 4.5Hz
    model.rotation.x = Math.sin(t * 4.5) * smoothVolume * 0.05;
    // 左右の微揺れ: 2.8Hz(最も遅い)— 体全体のゆったりした揺れ
    model.rotation.y = Math.sin(t * 2.8) * smoothVolume * 0.04;
}

5Hz, 3.2Hz, 4.5Hz, 2.8Hz — これらの周波数が互いに整数比にならない(非公約数的)ことで、周期的なループ感が薄まり、人間らしい不規則な動きが生まれます。

字幕同期: timing配列によるミリ秒精度の文表示

TTS合成時に各文の開始時刻(秒)を計算し、timing配列としてフロントエンドに送ります。フロントエンドではsetTimeoutでタイミングに合わせて字幕を切り替えます。

// display.html — 文同期字幕
function showSentencesWithTiming(timing) {
    clearSentenceTimers();
    // 最初の文を即表示
    subtitleText.textContent = timing[0].text;
    subtitleText.classList.add('visible');

    // 2文目以降はタイミングに合わせて切替
    for (let i = 1; i < timing.length; i++) {
        const delay = timing[i].start * 1000; // 秒→ミリ秒
        const text = timing[i].text;
        const tid = setTimeout(() => {
            subtitleText.classList.remove('visible');
            setTimeout(() => {
                subtitleText.textContent = text;
                subtitleText.classList.add('visible');
            }, 150); // 短いフェード間隔
        }, delay);
        sentenceTimers.push(tid);
    }
}

150msのフェードアウト→フェードイン間隔が、字幕の切り替わりを視認しやすくしています。

生放送運用の工夫

操作パネルの3モード設計

生放送は「何が起こるかわからない」ので、操作パネルには3つのモードを用意しました。

操作パネル: 発話・AI応答・解析の3モードと台本ボタン
裏方オペレーターが使う操作パネル。テキスト入力+3つのモードボタン、下部に台本セリフのワンクリック発話ボタンが並ぶ

モード 用途 実装
手動発話 (manual_speak) 台本通りのセリフ、定型文 テキスト→TTS→即座に再生
AI応答 (ai_respond) 共演者のアドリブへの応答 LLMストリーミング→文単位TTS
解析演出 (analyze_result) 証拠品の解析シーン 3秒の「解析中...」演出→TTS再生

台本セリフには「登場」「第1章」「第2章」「第3章」「最終章」の場面ラベルが付いており、ワンクリックで発話できます。番組の進行に合わせてボタンを押すだけで、キャッシュ済み音声が瞬時に再生されます。

音声プリセット

GPT-SoVITSの合成パラメータは、場面に合わせて切り替えられるようプリセットを用意しました。

// control.html — 音声プリセット
const voicePresets = {
    default: { temp: 1.0, topK: 15, topP: 1.0, rep: 1.35, speed: 1.3, volume: 1.1 },
    lively:  { temp: 1.4, topK: 42, topP: 0.85, rep: 1.35, speed: 1.4, volume: 1.2 },
    calm:    { temp: 0.6, topK: 20, topP: 1.0, rep: 1.35, speed: 1.1, volume: 1.0 },
    stable:  { temp: 0.8, topK: 5,  topP: 0.7, rep: 1.5,  speed: 1.3, volume: 1.1 },
};
  • ハツラツ (lively): temperature=1.4, Top-K=42。感情豊かで抑揚がある。テンション高めの場面に
  • 落ち着き (calm): temperature=0.6, speed=1.1。穏やかで聞き取りやすい。解析結果報告など
  • 安定重視 (stable): Top-K=5, Top-P=0.7。発音の揺れを最小化。確実に伝えたい場面に

本番では「ハツラツ」をデフォルトで使用し、解析シーンでは「落ち着き」に手動切替する運用でした。

RMSベース音量正規化

GPT-SoVITSの出力音量にはバラつきがあり、同じパラメータでも合成のたびに音量が変わることがあります。特に極端に音量が小さい「ハズレ」出力が問題でした。

# local_tts.py — RMSベース品質管理
MIN_RMS_THRESHOLD = 0.02  # これ以下は「ハズレ」
TARGET_RMS = 0.08         # 正規化の目標値
MAX_RETRIES = 3           # リトライ上限

def _synthesize_single(self, text, speed, ...):
    sr, audio = self._call_api(text, speed, ...)
    rms = float(np.sqrt(np.mean(audio ** 2)))

    # RMS が閾値以下なら再合成を試みる
    if rms < self.MIN_RMS_THRESHOLD:
        for attempt in range(1, MAX_RETRIES):
            sr_retry, audio_retry = self._call_api(text, speed, ...)
            rms_retry = float(np.sqrt(np.mean(audio_retry ** 2)))
            if rms_retry >= self.MIN_RMS_THRESHOLD:
                audio, rms = audio_retry, rms_retry
                break

    # それでも低い場合はゲイン正規化
    if rms > 0 and rms < self.TARGET_RMS:
        gain = self.TARGET_RMS / rms
        audio = np.clip(audio * gain, -1.0, 1.0)

    return sr, audio

この2段構え(リトライ + ゲイン正規化)により、生放送中に「聞こえない」事故をゼロにできました。

マルチLLMバックエンド

LLM障害に備えて、3つのバックエンドを切り替え可能にしました。

# flask_app.py — マルチLLMバックエンド
def _llm_chat_stream(messages):
    if LLM_BACKEND == 'gemini' and gemini_client:
        for chunk in gemini_client.models.generate_content_stream(...):
            yield chunk.text
    elif LLM_BACKEND == 'claude' and anthropic_client:
        with anthropic_client.messages.stream(...) as stream:
            for text in stream.text_stream:
                yield text
    else:  # OpenAI(デフォルト)
        for chunk in openai_client.chat.completions.create(..., stream=True):
            yield chunk.choices[0].delta.content

APIインターフェースの違い(OpenAIのdelta.content、Geminiのchunk.text、Claudeのtext_stream)を吸収し、上位のパイプラインには統一的なジェネレーターとして見せています。

キャラクタープロンプト — 「犯人をバラさない」制約

ミステリー企画で一番怖いのは、AIが犯人の名前を言ってしまうこと。

最も重要な制約は「AIが犯人を暴露しないこと」でした。プロンプトで明示的に禁止事項を列挙しています。

実際のプロンプト(ネタバレ注意)
# 犯人に関するスタンス
あなたは犯人が誰か本当にわからない。「俺もわかんないんだよな〜」が基本姿勢。
犯人を推理するのは探偵役(共演者)の仕事。あなたは聞かれたことに答えるだけ。
※ 以下は絶対にやらない:
- 特定の容疑者を怪しいとほのめかす
- 「桃」「果物」など犯人を連想させるワード
- 録音、歌声、クッキーが違った等、犯人のトリックに繋がる情報を自分から言う

これに加え、共演者の呼び方(「若槻千夏」→「ちなってぃ」)や口癖(「わっしょーい!」を4回に1回)まで細かくプロンプトに組み込むことで、生放送中のAI応答がキャラクターから逸脱しないようにしました。

開発プロセス — Claude Code × Codex CLI で徹夜二日間開発

このシステム、開発期間は徹夜二日間です。短すぎると思うかもしれませんが、AI駆動開発をフルに活用することで実現しました。

ツールスタック

  • Claude Code: メインの開発ツール。コード生成・リファクタ・デバッグ・設計相談まで全部これでやった。ターミナルから直接コードベースを操作して、ファイルの読み書きからテスト実行まで一貫して回せる
  • Codex CLI: Claude Codeから適宜呼び出して、コードレビュー役として使用。別のAIモデルにレビューさせることで、単一AIの盲点を補う

開発サイクル

基本的なワークフローはこの繰り返しです:

  1. Claude Codeでコードを書く
  2. 動作確認(ブラウザでの実機テスト)
  3. 必要に応じてCodex CLIでコードレビュー
  4. レビュー指摘を反映して修正

AIが「初稿」を高速に生成し、人間が「方向性の判断」と「最終確認」を担当する分業です。AIに任せるのは「正しいコードを書く」こと。人間が判断するのは「何を作るか」と「これで本当にいいか」。

AIコーディングが特に効いた場面

Vertex ShaderのGLSLコード。口元変形のためのsmoothstepベースの影響関数は、人間がゼロから書くと試行錯誤に時間がかかります。AIに「口の周辺だけ変形させるシェーダーを書いて、影響範囲はガウス的に減衰させて」と指示したら、数十秒で動くコードが出てきました。

助数詞17種の読みテーブル。「1分=いっぷん、2分=にふん、3分=さんぷん...」を17種類分、正確に書き上げるのは人間には地味で間違えやすい作業です。AIに初稿を作らせて、人間がネイティブの知識で「3階は"さんかい"じゃなくて"さんがい"だよ」と直していく方が速い。

Three.jsのセットアップ。シーン・カメラ・ライティング・GLTFLoader・OrbitControls...ボイラープレートが多いThree.jsの初期化コードをAIに任せることで、本質的なリップシンクのロジックに集中できました。

WebSocketイベント設計speak_chunkspeak_completethinkingといったイベント名の設計から、クライアント←→サーバー間のペイロード構造まで、整合性を保ちながらAIが一貫して設計・実装してくれました。

二日間タイムライン

Day 1: TTS統合(GPT-SoVITS APIの接続)→ ストリーミングパイプライン構築 → 操作パネルUI → 台本キャッシュ機構 → マルチLLMバックエンド

Day 2: 3Dモデル制作(Meshy AI + Blender MCP)→ リップシンク実装(Shape Key + Vertex Shader)→ 日本語テキスト処理パイプライン → 字幕同期 → 本番環境テスト

「AIに任せる / 人間が判断する」の境界線

AI駆動開発で重要なのは、AIに任せる範囲と人間が判断する範囲の線引きです。

AIに任せたこと 人間が判断したこと
GLSLシェーダーの実装 口元変形の見た目が自然か
助数詞テーブルの初稿生成 テーブルの正確性(ネイティブチェック)
WebSocketのペイロード設計 イベント粒度の設計方針
Three.jsボイラープレート カメラアングル・ライティングの調整
CSSアニメーション テレビ映えするフォントサイズ・色
リトライ・エラーハンドリング リトライ回数・閾値の決定

技術的に「正解がある」コードはAIが速くて正確。「良し悪しは主観」の判断は人間がやる。この切り分けが、二日間で生放送に間に合わせられた理由だと思います。

まとめ

ここまで、生放送でAI版ビビる大木を喋らせるシステムの裏側を紹介してきました。

GPT-SoVITSで音声クローンモデルを作り、ストリーミングパイプラインで初回発話2.5秒を達成し、助数詞17種の日本語処理で「にじゅっぷん」を正しく読ませ、Meshy AI + Blender MCPで3Dモデルを仕上げ、Shape Key / Vertex Shaderの2段フォールバックでリップシンクを実装し、Claude Code + Codex CLIで徹夜二日間で全部組み上げた。

振り返ると、生放送という「失敗できない」環境で一番大事だったのは、あらゆるレイヤーに冗長性を持たせることでした。台本キャッシュ、RMSリトライ、マルチLLMバックエンド。どこか一つが落ちても番組が止まらない設計を、限られた時間の中でどこまで詰められるかが勝負でした。

この記事が、リアルタイム音声システムを作る方の参考になれば幸いです。


書いた人

このシステムを徹夜で開発した僕は現在、株式会社メロンを創業してCTOをやっています。

メロンは時系列解析技術を核としたAI開発企業で、需要予測・在庫最適化から生成AI活用まで幅広くやっています。「テレビの生放送でAIキャラクターが喋る」みたいな、ちょっと変わったAI開発も大好物です。時系列解析に関係ないと思われるかもしれませんが、音声合成やリップシンク・体の動きを実現した周波数解析もまた、時系列解析の得意とするところです。

こういう面白いAI開発に興味がある方は、ぜひメロンのWebサイトを覗いてみてください。一緒に面白いAIを作りましょう。

https://melloninc.jp

91

Discussion

ログインするとコメントできます
91