📝 Originally published (in Japanese) at forge.workstyle.tech.
キャプションと乱数種で声が決まるTTSを使っている。キャプションを固定してseedを振ると、同系統だが異なる声が次々に出てくる。要はガチャだ。
"落ち着いた大人の女性ナレーターの声。ゆったりとした語り口で、
温かみと信頼感があり、長い文章を丁寧に読み上げる。"
seed 7 → 声A
seed 42 → 声B
seed 1042 → 声C
...
役割ごとに良い声を引き当てたい。ナレーター、カウンセラー、営業、プレゼンター、コールセンター、司会——7役割ぶん、男女それぞれ。
最初は全部聴いていた。12seed × 男女 = 24候補、それぞれ5文ずつ喋らせるので120クリップ。1役割でこれをやると30分は溶ける。7役割で3時間半。続かない。
何を測れば「役割に合う」と言えるか
聴きながら何を判断しているのかを言語化した。
- ナレーター: ゆったり喋っているか。落ち着いた起伏があるか
- カウンセラー: 遅いか。柔らかいか(音圧が穏やか)
- 営業: 明るいか。テンポが良いか。安定しているか
- プレゼンター: 明瞭か。強調のメリハリがあるか
- 司会: エネルギーがあるか(音圧・F0レンジが広い)。それでいて破綻していないか
だいたい話速・抑揚幅・音圧・安定性の組み合わせに落ちる。これなら測れる。
計測する
プローブ文を5本読ませて、1候補あたりこれだけ取る。
def acoustics(wav: bytes):
"""(発話秒, 音圧dB, F0中央値, 抑揚幅st) を返す"""
w = wave.open(io.BytesIO(wav)); sr = w.getframerate()
x = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) / 32768
# 有音区間だけを対象にする(無音を含めると音圧が過小になる)
W, H = int(sr*0.025), int(sr*0.010)
rms = np.array([np.sqrt(np.mean(x[i*H:i*H+W]**2))
for i in range(max(0, (len(x)-W)//H))])
act = rms > 0.01
speech = float(act.sum() * 0.010)
level = 20*np.log10(float(np.mean(rms[act])) + 1e-9) if act.any() else -99.0
# F0は自己相関で取る
f0 = []
Wf, Hf = int(sr*0.030), int(sr*0.010)
lo, hi = int(sr/950), int(sr/60) # 60〜950Hz を探索
for i in range(0, len(x)-Wf, Hf):
fr = x[i:i+Wf]
if np.sqrt(np.mean(fr**2)) < 0.01:
continue
fr = fr - fr.mean()
ac = np.correlate(fr, fr, "full")[Wf-1:]
ac = ac / (ac[0] + 1e-12)
seg = ac[lo:min(hi, len(ac)-1)]
if len(seg) >= 3:
f0.append(sr / (lo + int(np.argmax(seg))))
f0 = np.array(f0)
med = float(np.median(f0)) if len(f0) else 0.0
# 抑揚幅は半音(semitone)で見る。Hzの差だと高い声ほど大きく出てしまう
rng = float(12*np.log2(np.percentile(f0,90)/np.percentile(f0,10))) if len(f0) else 0.0
return speech, level, med, rng
話速はモーラ/秒で出す。文字数ではなく音の数で数えたいので、小書き文字と句読点を除いた文字数を発話秒で割る。
def mora(t):
small = "ぁぃぅぇぉゃゅょ"
return max(1, len([c for c in t if c not in "、。!?!?・… " + small]))
rate = mora(text) / max(speech_sec, 0.3)
抑揚幅は半音で測るのが重要だった。F0のHz差で測ると、高い声ほど数値が大きくなって男女比較ができない。90パーセンタイルと10パーセンタイルの比を対数で取る。
安定性(ジッタ)は、周期の揺らぎとオクターブ跳躍を見る。粗い声、嗄れた声を落とすため。
役割ごとに重みを変える
同じ計測値を、役割ごとに違う重みでスコア化する。
PRESETS = {
"narration": {
"captions": {"F": "落ち着いた大人の女性ナレーターの声。ゆったりとした語り口で…",
"M": "深く渋い男性ナレーターの声。低音で落ち着いた語り口…"},
"probes": ["物語は、ここから始まります。",
"創業以来、私たちは品質にこだわり続けてきました。", ...],
"rate_target": (4.5, 6.0), # モーラ/秒の目標帯
"weights": {"ratio": 1.0, "elong": 0.25, "rate_cv": 0.8,
"jitter": 1.5, "rate_fit": 1.0, "range": 0.3, "level": 0.0},
},
"mc": {
...
"rate_target": (6.0, 8.0), # 速め
"weights": {"ratio": 0.8, "elong": 0.10, "rate_cv": 0.3,
"jitter": 1.5, "rate_fit": 0.5, "range": 1.0, "level": 0.0},
# ↑ 抑揚を最重視
},
"counseling": {
...
"rate_target": (4.0, 5.5), # 最もゆっくり
"weights": {..., "rate_fit": 1.2, "level": 0.4},
# ↑ 音圧が高すぎると減点(穏やかさ)
},
}
スコアはこう。
rate_fit = 0.0 if lo <= rate <= hi else min(abs(rate-lo), abs(rate-hi)) / 2.0
score = (w["ratio"] * mean_ratio
- w["elong"] * elongation_count
- w["rate_cv"] * rate_cv # 文ごとの話速のばらつき
- w["jitter"] * jitter
- w["rate_fit"]* rate_fit # 目標帯からの逸脱
+ w["range"] * (range_st / 12.0) # 抑揚は加点(1oct=1.0換算)
- w["level"] * max(0.0, (level + 12.0)/6.0)) # 音圧が高すぎると減点
目標帯からの距離という考え方が効いた。「速いほど良い」でも「遅いほど良い」でもなく、役割ごとに帯があって、そこから外れた分だけ減点する。ナレーターは4.5〜6.0、司会は6.0〜8.0、カウンセラーは4.0〜5.5。
話速のばらつき(cv)も入れた。文によって速さが極端に変わる候補は、実運用で扱いにくい。
出力はランキングと、上位3候補×男女の試聴ページ。24候補を聴くのではなく、6候補を聴けばよくなった。 1役割あたり30分が5分になった。
実際に選んだ結果
ナレーターの上位はこうなった。
| 1位 | 2位 | 3位 | |
|---|---|---|---|
| 女性 | seed 55555(一致0.98・話速6.1) | seed 7(抑揚19.8st) | seed 864200(一致1.00) |
| 男性 | seed 3407(話速5.2=最もゆったり) | seed 101(一致1.00) | seed 2025 |
話速が5.2〜6.1に収束していて、アナウンサー役(6.2〜6.6)より明確にゆっくりだった。キャプションの「ゆったりとした語り口」が効いている。
そして最終的な採用は耳で決めた。上位3つの中から選ぶので、聴く量は激減したが、判断は人が下す。指標は候補を絞る道具であって、決める道具ではない。
指標の限界: 男性候補が全員マイナスになった
カウンセラーの男性候補を出したら、12候補全部がマイナススコアになった。
M/seed7: score=-0.68 一致1.00 話速6.3 抑揚37.6st jitter45%
M/seed42: score=-0.229 一致0.97 話速5.7 抑揚36.4st jitter41%
M/seed101: score= 0.042 一致1.00 話速5.5 抑揚35.0st jitter31%
...
M/seed55555: score=-1.216 一致1.00 話速6.7 抑揚40.6st jitter56%
一致率は0.97〜1.00で完璧。語尾の伸びもゼロ。それでもスコアが沈む。犯人は jitter 31〜56% だった。
自己相関でF0を取ると、低い声ほど周期の推定を誤りやすい。倍音を基本周波数と取り違えたり、隣接フレームで推定が飛んだりする。それがジッタとして計上される。
そしてカウンセラーのプリセットは jitter の重みを 2.5 と全役割で最大にしていた(穏やかで安定した声が欲しいので)。しかもキャプションが「低めでゆっくり」なので、F0がさらに下がって計測誤差が悪化する。三重に効いて全員マイナスになった。
つまりこのランキングは、声質ではなく計測誤差の少なさを測っていた。同じ選抜の仕組みでは、別の指標でも似た誤りを踏んでいる([[measuring-factory-defects-as-product-traits|治せる欠陥で候補を落としていた]])。
対処は2つ。
男女を同じ指標で比較しない。 抑揚幅も同様で、男性は25〜40st、女性は10〜25stと出る。同じ土俵に乗せると必ず男性が有利/不利になる。ランキングは性別ごとに独立して作る。
その指標が信頼できない範囲を明示する。 スクリプトの docstring に書いた。
# ⚠️ 低音男声のジッタは自己相関トラッカーが過大評価する。男女の直接比較には使わない。
そして男性カウンセラーは、ジッタの重みを下げてF0と話速を表に出し、耳で選んでもらう形にした。指標が壊れる領域では、指標を無理に使わない。
まとめ
- 聴いて判断していることを言語化すると、だいたい測れる。話速・抑揚・音圧・安定性で大半はカバーできた
- 役割ごとに「目標帯」を置く。速い/遅いの一方向ではなく、帯から外れた分を減点する
- 抑揚は半音で測る。Hz差だと高い声が有利になる
- 指標は候補を絞る道具で、決める道具ではない。24→6に絞って、最後は聴く
- 指標が壊れる領域を知っておく。低音のF0推定は誤差が大きい。壊れる領域では重みを下げるか、その指標を使わない
最後の点が一番の学びだった。全候補がマイナスになったら、候補ではなく指標を疑う。
シリーズ: 拡散TTSから実用ボイスを量産する
キャプション1行から声を設計し、学習コーパスを製造して、役割別の実用ボイスを量産するまでの記録です。この記事は第1部 設計にあたります。
← 前: [[deterministic-voice-gacha-and-design-ledger|声をガチャで引く]]
→ 次: [[quality-gate-selection-bias-flat-takes|品質ゲートを厳しくするほど、棒読みが生き残る]]
シリーズ全18本
- [[diffusion-tts-too-slow-for-conversation|音質で選んだTTSが、会話には遅すぎた]]
- [[deterministic-voice-gacha-and-design-ledger|声をガチャで引く]] 3. [[screening-voices-by-metrics-not-ears|「ナレーターっぽい声」を24候補から機械に選ばせる]] ← いまここ
- [[quality-gate-selection-bias-flat-takes|品質ゲートを厳しくするほど、棒読みが生き残る]]
- [[speaking-style-is-baked-into-the-corpus|学習後に話速は変えられない]]
- [[tts-changes-recording-room-every-time|生成するたび「録音場所」が変わるTTS]]
- [[one-rough-clip-ruins-the-whole-style|クリップ1本の粗さが、スタイル全体を嗄れさせる]]
- [[where-did-the-elongated-ending-come-from|AIが「こんにちわー」と伸ばす癖は、どこで身についたのか]]
- [[the-character-that-broke-the-tts-input|「少々」が「しょも」になる — 許可文字リストが日本語を削っていた]]
- [[hallucination-guard-that-never-fired|ハルシネーション対策のコードが、ハルシネーションの時だけ動かなかった]]
- [[three-chars-became-a-verbal-tic|品質ゲートが許した「3文字」が、モデルの口癖になった]]
- [[measuring-factory-defects-as-product-traits|治せる欠陥で候補を落としていた]]
- [[defects-invisible-to-transcription|文字起こしでは見つからない欠陥がある]]
- [[70-minutes-lost-to-a-network-blink|70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた]]
- [[ja-vs-JP-babbling-model|"ja" を "JP" と書いて喃語モデルができるまで]]
- [[four-registration-paths-one-exit|登録経路が4つ、管理画面が0]]
- [[who-is-rolling-back-whom|デプロイのたびに互いの成果を消していた]]
- [[chasing-unmeasured-targets-with-thresholds|測れていないものを閾値で追い込むと、必ず失敗する]]
知見の元になったノートは [[拡散TTSから実用ボイスを量産する製造パイプライン]] にまとめてあります。
Top comments (0)