DEV Community

orca_forge
orca_forge

Posted on Originally published at forge.workstyle.tech

「ナレーターっぽい声」を24候補から機械に選ばせる — 耳で選ぶのをやめた日

📝 Originally published (in Japanese) at forge.workstyle.tech.

キャプションと乱数種で声が決まるTTSを使っている。キャプションを固定してseedを振ると、同系統だが異なる声が次々に出てくる。要はガチャだ。

"落ち着いた大人の女性ナレーターの声。ゆったりとした語り口で、
 温かみと信頼感があり、長い文章を丁寧に読み上げる。"

seed 7      → 声A
seed 42     → 声B
seed 1042   → 声C
...
Enter fullscreen mode Exit fullscreen mode

役割ごとに良い声を引き当てたい。ナレーター、カウンセラー、営業、プレゼンター、コールセンター、司会——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
Enter fullscreen mode Exit fullscreen mode

話速はモーラ/秒で出す。文字数ではなく音の数で数えたいので、小書き文字と句読点を除いた文字数を発話秒で割る。

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)
Enter fullscreen mode Exit fullscreen mode

抑揚幅は半音で測るのが重要だった。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},
        #                              ↑ 音圧が高すぎると減点(穏やかさ)
    },
}
Enter fullscreen mode Exit fullscreen mode

スコアはこう。

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))  # 音圧が高すぎると減点
Enter fullscreen mode Exit fullscreen mode

目標帯からの距離という考え方が効いた。「速いほど良い」でも「遅いほど良い」でもなく、役割ごとに帯があって、そこから外れた分だけ減点する。ナレーターは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%
Enter fullscreen mode Exit fullscreen mode

一致率は0.97〜1.00で完璧。語尾の伸びもゼロ。それでもスコアが沈む。犯人は jitter 31〜56% だった。

自己相関でF0を取ると、低い声ほど周期の推定を誤りやすい。倍音を基本周波数と取り違えたり、隣接フレームで推定が飛んだりする。それがジッタとして計上される。

そしてカウンセラーのプリセットは jitter の重みを 2.5 と全役割で最大にしていた(穏やかで安定した声が欲しいので)。しかもキャプションが「低めでゆっくり」なので、F0がさらに下がって計測誤差が悪化する。三重に効いて全員マイナスになった。

つまりこのランキングは、声質ではなく計測誤差の少なさを測っていた。同じ選抜の仕組みでは、別の指標でも似た誤りを踏んでいる([[measuring-factory-defects-as-product-traits|治せる欠陥で候補を落としていた]])。

対処は2つ。

男女を同じ指標で比較しない。 抑揚幅も同様で、男性は25〜40st、女性は10〜25stと出る。同じ土俵に乗せると必ず男性が有利/不利になる。ランキングは性別ごとに独立して作る。

その指標が信頼できない範囲を明示する。 スクリプトの docstring に書いた。

# ⚠️ 低音男声のジッタは自己相関トラッカーが過大評価する。男女の直接比較には使わない。
Enter fullscreen mode Exit fullscreen mode

そして男性カウンセラーは、ジッタの重みを下げてF0と話速を表に出し、耳で選んでもらう形にした。指標が壊れる領域では、指標を無理に使わない。

まとめ

  • 聴いて判断していることを言語化すると、だいたい測れる。話速・抑揚・音圧・安定性で大半はカバーできた
  • 役割ごとに「目標帯」を置く。速い/遅いの一方向ではなく、帯から外れた分を減点する
  • 抑揚は半音で測る。Hz差だと高い声が有利になる
  • 指標は候補を絞る道具で、決める道具ではない。24→6に絞って、最後は聴く
  • 指標が壊れる領域を知っておく。低音のF0推定は誤差が大きい。壊れる領域では重みを下げるか、その指標を使わない

最後の点が一番の学びだった。全候補がマイナスになったら、候補ではなく指標を疑う。


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

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

← 前: [[deterministic-voice-gacha-and-design-ledger|声をガチャで引く]]
→ 次: [[quality-gate-selection-bias-flat-takes|品質ゲートを厳しくするほど、棒読みが生き残る]]

シリーズ全18本

  1. [[diffusion-tts-too-slow-for-conversation|音質で選んだTTSが、会話には遅すぎた]]
  2. [[deterministic-voice-gacha-and-design-ledger|声をガチャで引く]] 3. [[screening-voices-by-metrics-not-ears|「ナレーターっぽい声」を24候補から機械に選ばせる]] ← いまここ
  3. [[quality-gate-selection-bias-flat-takes|品質ゲートを厳しくするほど、棒読みが生き残る]]
  4. [[speaking-style-is-baked-into-the-corpus|学習後に話速は変えられない]]
  5. [[tts-changes-recording-room-every-time|生成するたび「録音場所」が変わるTTS]]
  6. [[one-rough-clip-ruins-the-whole-style|クリップ1本の粗さが、スタイル全体を嗄れさせる]]
  7. [[where-did-the-elongated-ending-come-from|AIが「こんにちわー」と伸ばす癖は、どこで身についたのか]]
  8. [[the-character-that-broke-the-tts-input|「少々」が「しょも」になる — 許可文字リストが日本語を削っていた]]
  9. [[hallucination-guard-that-never-fired|ハルシネーション対策のコードが、ハルシネーションの時だけ動かなかった]]
  10. [[three-chars-became-a-verbal-tic|品質ゲートが許した「3文字」が、モデルの口癖になった]]
  11. [[measuring-factory-defects-as-product-traits|治せる欠陥で候補を落としていた]]
  12. [[defects-invisible-to-transcription|文字起こしでは見つからない欠陥がある]]
  13. [[70-minutes-lost-to-a-network-blink|70分かけた学習素材が、ネットワークの一瞬の瞬きで消えた]]
  14. [[ja-vs-JP-babbling-model|"ja" を "JP" と書いて喃語モデルができるまで]]
  15. [[four-registration-paths-one-exit|登録経路が4つ、管理画面が0]]
  16. [[who-is-rolling-back-whom|デプロイのたびに互いの成果を消していた]]
  17. [[chasing-unmeasured-targets-with-thresholds|測れていないものを閾値で追い込むと、必ず失敗する]]

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

Top comments (0)