#設計#運用#マイクロサービス#データモデリング

登録経路が4つ、管理画面が0 — 音声資産のカタログが管理不能になるまで


「この音声、どの画面で管理できますか?」と聞かれて、答えられなかった。

キャラクターに割り当てられている参照音声について、設定を変えたいという話だった。カタログには載っている。キャラに割り当てもできる。なのに、その行を編集・削除する画面がどこにも無い。

調べたら、カタログには4つの登録経路があり、そのうち3つは管理画面が存在しなかった

カタログの中身

音声資産は voice_catalog という1つのテーブルに集約されている。設計としては正しい。キャラクターへの割り当ては、このテーブルのIDを参照するだけで済む。

生存している76件の内訳はこうだった。

kind provider 件数 管理画面
sbv2_finetuned Style-Bert-VITS2 26 ✅ あり
sovits_reference GPT-SoVITS 22 ❌ なし
cosyvoice_reference CosyVoice2 22 ❌ なし
voicevox_preset VOICEVOX 6 ❌ なし

管理画面があるのは26件だけ。残り50件は、カタログに載っていて使えるのに、一覧して編集する手段が無い。

なぜこうなったか

原因は、登録経路ごとに書き込み先が違ったことだった。

[学習画面]         → voice_models テーブル → voice_catalog に派生登録
[感情音声パイプライン] → voice_catalog に直接
[参照音声の登録]     → voice_catalog に直接
[初期投入スクリプト]  → voice_catalog に直接

                    [キャラクターの音声選択UI] ← 唯一の出口(読み取り+割り当てのみ)

管理画面は voice_models テーブルを一覧している。学習画面から作ったモデルはここに行が立つので出てくる。

一方、他の3経路は voice_catalog に直接書く。voice_models に行が無いので、管理画面には原理的に出てこない

そして voice_catalog を一覧している画面は、キャラクターの音声選択UIが1つだけ。しかもこれは割り当て専用で、できるのは試聴と選択だけだった。カタログ行そのものを編集・削除する導線がどこにも無い。

バックエンドには編集用のAPIが実装されていた。それを叩く画面が無いだけだった。

「一覧が食い違う」の正体

この構造が、別の症状として現れていた。3つの画面でボイスの一覧が食い違う、という報告が以前からあった。

学習画面のリスト     → voice_models (26件)
音声一覧の画面       → voice_models のサブセット
キャラクターの選択UI → voice_catalog (76件)

見ているテーブルが違うので、一致しないのが当然だった。片方はモデルの管理台帳、片方は「キャラに割り当てられる音声」のカタログ。役割が違うテーブルを、両方「ボイスの一覧」として見せていた。

これを「同期がずれている」と捉えて同期処理を足そうとしていた時期があったが、方向が違った。同期ではなく、それぞれが何の一覧なのかを明示するべきだった。

棚卸しAPIを作る

まず現状を把握するために、カタログ全件を実態つきで返すAPIを作った。

@router.get("/inventory")
async def catalog_inventory(db: AsyncDB, token_data: dict = Depends(verify_token)):
    """カタログの棚卸し(管理画面用)。

    行の存在=使えるモデル、ではない。推論サーバ上でスタイルを失ったモデルが
    カタログに残ることがあり、キャラ割当の選択肢に混ざる。
    話体・スタイル数・参照キャラ数を1回で返し、画面側で判別できるようにする。
    """
    rows = await fetch_catalog(db, owner_ids)

    # 話体は「製造ジョブ」まで辿らないと分からない
    jobs = {str(vm_id): {"conv_style": cs, "seed": seed}
            for vm_id, cs, seed in await db.execute(sqltext(
                "SELECT vm.id, j.progress->'params'->>'conv_style', j.seed "
                "FROM voice_design_jobs j JOIN voice_models vm ON vm.id = j.voice_model_id"))}

    # 誰が使っているか
    assigned = {cid: n for cid, n in await db.execute(sqltext(
        "SELECT profile->>'voice_catalog_id', count(*) FROM characters "
        "WHERE profile->>'voice_catalog_id' IS NOT NULL GROUP BY 1"))}

    # 推論サーバに実体があるか(スタイル一覧が空 = 使えない)
    styles = await fetch_styles_parallel(rows)

    return {"items": [{
        "id": str(v.id), "name": v.name, "kind": v.kind, "provider": v.provider,
        "conv_style": jobs.get(str(v.source_voice_model_id), {}).get("conv_style"),
        "style_count": len(styles[v.inference_model_id]) if ... else None,
        "alive": None if st is None else len(st) > 0,
        "has_scream": "Scream" in (st or []),        # 極端演技スタイルの有無
        "assigned_characters": assigned.get(str(v.id), 0),
        ...
    } for v in rows]}

返すのは4種類の情報だ。

話体(conv_style)。どの用途で製造されたか。ただしこれは voice_catalog には無く、voice_models を経由して製造ジョブまで辿らないと分からない。

スタイル数と生死。推論サーバに問い合わせて、実体があるかを確認する。スタイル一覧が空なら「登録は残っているが使えない」状態だ。

極端演技スタイルの有無。この環境では「盛り上がったら全力でリアクションする」機能が "Scream" in styles で発火するので、業務用途の声には持たせたくない(学習後に話速は変えられない)。誤選択の警告に使う。

参照しているキャラクター数。使われていない行を判別する。

結果: 話体が引けるのは19件だけ

棚卸しの結果、はっきりしたことがある。

生存 76件
  ├ 話体あり 19 … voice-design パイプライン産
  └ 話体なし 57 … 旧レシピ / 感情パイプライン産 / 参照音声 / プリセット

4分の3は話体が記録されていない。 用途から声を自動で選ぶ仕組みを作っても、この57件は解決対象にならない。

ただし内訳を見ると、印象より軽い問題だった。

kind 件数 話体あり キャラから参照
sbv2_finetuned 26 19 16
sovits_reference 22 0 6
cosyvoice_reference 22 0 0
voicevox_preset 6 0 0

話体なし57件のうち28件は、キャラクターから一度も参照されていない。 実質デッドストックだ。

実際に使われている22件で見ると、15件(68%)は話体が引ける。「4分の3が引けない」は在庫全体の比率であって、稼働している資産の比率ではなかった。

在庫の統計を見て設計判断をしそうになったが、「実際に使われているか」で絞ると絵が変わる。棚卸しでは、総数だけでなく利用状況を同時に取るべきだった。

遡ってラベルを付けない判断

「話体が無い57件に、後から話体を付けられないか」という話が出た。付けないことにした。

conv_style は「そのモデルがどう振る舞うか」のラベルではなく、どの台本コーパスで学習したかの記録だからだ。旧レシピのモデルに後から narration と書いても、実際にナレーション用のコーパスで学習したわけではない。品質ゲートも当時のものしか通っていない。

ラベルと実体が乖離した状態をDBに固定することになる。そして自動選択の仕組みがそれを信じて声を選ぶと、原因の追いにくい事故になる。

代わりに「話体不明」として明示し、UIに「用途未記録」と出す。欠損ではなく事実として扱う方針にした。

同じ環境で、これと同型の問題を他にも踏んでいる。ある真偽値カラムが「ギャラリーへの公開」と「ゲスト経路の認可」を兼ねていて、公開を切ったつもりが接続エラーになった。別のカラムは role という名前なのに全行同じ値で、職務ではなく技術種別を表していた。

1つのカラムが2つの意味を持つと、片方のつもりで倒したときにもう片方が壊れる。 ラベルを後から足すのは、この構造を自分から作りにいく行為だった。

管理画面を作る

棚卸しAPIができたので、それを表示する画面を1つ足した。

名前 | 種別 | 話体 | seed | スタイル数 | 状態 | 使用キャラ数 | 操作

操作は改名と能力フラグの切り替えだけに絞った。

割り当てはキャラクター管理に残す。 ここでやると、カタログの管理と割り当ての責務が混ざる。

学習・再学習は学習画面に残す。 経路を増やすと、また同じ問題が起きる。

削除は入れなかった。 参照しているキャラクターがある行を消すと壊れる。無効化フラグで隠す方が安全だが、今回は必要になっていないので実装していない。

「実体なし」を赤字で出すようにした。カタログに載っているが推論サーバに無い行は、選ぶと動かない。これを一覧で判別できるようにした。

まとめ

  • 登録経路が複数あるなら、その数だけ管理経路が要る。1つの画面が全部を見ているかを確認する
  • 「一覧が食い違う」は同期の問題とは限らない。見ているテーブルが違うだけのことがある。同期を足す前に、それぞれが何の一覧かを確認する
  • 棚卸しでは総数と利用状況を同時に取る。在庫の比率と稼働の比率は違う絵を見せる
  • 行の存在は「使える」を意味しない。実体の生死を別に確認する必要がある
  • 後から付けられないラベルは、付けない。実体と乖離した記録は、自動化の判断材料として害になる
  • 1つのカラムに2つの意味を持たせない。片方のつもりで倒すと、もう片方が壊れる

シリーズ: 拡散TTSから実用ボイスを量産する

キャプション1行から声を設計し、学習コーパスを製造して、役割別の実用ボイスを量産するまでの記録です。この記事は第4部 運用にあたります。

← 前: “ja” を “JP” と書いて喃語モデルができるまで → 次: デプロイのたびに互いの成果を消していた

シリーズ全18本
  1. 音質で選んだTTSが、会話には遅すぎた
  2. 声をガチャで引く
  3. 「ナレーターっぽい声」を24候補から機械に選ばせる
  4. 品質ゲートを厳しくするほど、棒読みが生き残る
  5. 学習後に話速は変えられない
  6. 生成するたび「録音場所」が変わるTTS
  7. クリップ1本の粗さが、スタイル全体を嗄れさせる
  8. AIが「こんにちわー」と伸ばす癖は、どこで身についたのか
  9. 「少々」が「しょも」になる — 許可文字リストが日本語を削っていた
  10. ハルシネーション対策のコードが、ハルシネーションの時だけ動かなかった
  11. 品質ゲートが許した「3文字」が、モデルの口癖になった
  12. 治せる欠陥で候補を落としていた
  13. 文字起こしでは見つからない欠陥がある
  14. 70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた
  15. “ja” を “JP” と書いて喃語モデルができるまで 16. 登録経路が4つ、管理画面が0 ← いまここ
  16. デプロイのたびに互いの成果を消していた
  17. 測れていないものを閾値で追い込むと、必ず失敗する

知見の元になったノートは 拡散TTSから実用ボイスを量産する製造パイプライン にまとめてあります。

← 記事一覧へ