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