DEV Community

orca_forge
orca_forge

Posted on • Originally published at forge.workstyle.tech

8つの知覚軸で声をデザインする — 意味軸 話者埋め込みブレンド

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

音声変換モデル(Voice Conversion)や TTS を触っていると、こんな壁に突き当たります。「目標の声は、参照音声(リファレンス)を1本まるごと渡して指定する」という前提です。でもプロダクトのユーザーが本当に言いたいのは、そういうことではありません。

「今の声を、もう少し若く、少しだけハスキーにしたい」

参照音声を差し替える操作では、この「もう少し」「少しだけ」が表現できません。声を連続的なつまみで微調整したいのに、モデルの入力は離散的な「誰かの声」なのです。

この記事では、その隙間を埋める汎用テクニックを紹介します。人間が理解できる知覚軸(若い↔年配、クリア↔ハスキーなど)のスライダー値から、複数の「アンカー話者」の埋め込みを加重合成して、目標の声ベクトルを合成する手法です。特定プロダクトの実装ではなく、話者埋め込みを受け取るモデル全般に適用できる考え方として書きます。

前提:話者埋め込みという「声の座標」

多くの現代的な音声変換・TTS モデルは、話者性(誰の声か)を固定長のベクトル=話者埋め込みとして受け取ります。例えば 192 次元のベクトルが1人の声を表す、といった具合です。

この埋め込み空間の性質として、経験的に「近いベクトルは似た声になる」「2つの声の中間ベクトルは中間的な声になる」というおおよその連続性・加法性が成り立ちます。厳密な線形性が保証されるわけではありませんが、近傍のベクトルを混ぜる程度なら、破綻せず自然な中間の声が得られることが多い。この性質が、これから紹介するブレンドの土台になります。

やることは2段階です。

  1. 意味軸のスライダー値 → 各アンカー話者の重み(softmax で配分)
  2. 重み × 各アンカーの埋め込み → ブレンド埋め込み(加重和)

アンカーとは、あらかじめ用意した「基準となる声」の集合です。ここでは声優のクリーン録音を複数本アンカーとして使います。アンカーが多様なほど、ブレンドで到達できる声の範囲(=声空間)が広がります。

ステップ0:知覚軸を「実測できる音響特徴」に接地する

まず、ユーザーに見せる軸を定義します。今回は8つです。

  • age(年齢感)、gender(性別)、pitch(声の高さ)、build(体格)
  • huskiness(ハスキー度)、clarity(明瞭度)、warmth(温かさ)、roughness(粗さ)

ここで重要なのは、これらの主観的な軸を測定可能な音響特徴の線形結合として定義することです。各アンカーの録音から F0(基本周波数)、フォルマント、HNR(調波雑音比)、スペクトル傾斜、shimmer、jitter といった特徴を抽出し、軸をそれらの重み付き和で表現します。

AXES = (
    Axis("age",       ..., APPROX,    {"f0": -1.0, "formant": -0.5}),
    Axis("gender",    ..., MEASURED,  {"f0": 1.0, "formant": 1.0}),
    Axis("pitch",     ..., MEASURED,  {"f0": 1.0}),
    Axis("build",     ..., MEASURED,  {"formant": 1.0}),
    Axis("huskiness", ..., MEASURED,  {"shimmer": 1.0, "hnr": -0.5}),
    Axis("clarity",   ..., MEASURED,  {"hnr": 1.0}),
    Axis("warmth",    ..., MEASURED,  {"tilt": -1.0}),
    Axis("roughness", ..., MEASURED,  {"jitter": 1.0, "shimmer": 0.5}),
)
Enter fullscreen mode Exit fullscreen mode

読み方はそのまま音声学の常識です。pitch は F0 が高いほど高い。build(体格)はフォルマントが高いほど「細い」声。huskiness は shimmer(振幅ゆらぎ)が大きく HNR が低いほどハスキー。roughness は jitter(周期ゆらぎ)と shimmer で表す、といった具合です。F0 とフォルマントは知覚が対数的なので log2 を取ってから使います。

軸を「実測特徴の式」として書き下しておくと、後述するように新しい声をアンカーに追加したときも、同じ式でその声の軸位置を自動計算できます。ルックアップテーブルや個別のif分岐ではなく、特徴量からの一貫した写像として設計するのがポイントです。

ステップ0.5:軸には「較正の3状態」がある

正直に扱うべき現実として、すべての軸が同じ信頼度で測れるわけではありません。そこで各軸に状態を持たせます。

  • measured — 実測の音響属性できれいに定義できる(pitch, gender, clarity など)
  • approx — 実測の近似。複合的で要検証(age は「F0 が低くフォルマントが低い=年配寄り」という近似で、加齢の声質変化を完全には捉えない)
  • needs_label — 客観指標がなく、人手ラベルが必要(例えば「かわいい」のような軸。較正されるまでマッチングから除外する)

この区別が効くのは、未較正の軸を勝手に使わないという一点です。needs_label の軸にラベルが与えられていなければ、その軸は重み計算から静かに外れます。「それっぽい適当な指標」をでっち上げて精度を損なうより、正直に「この軸はまだ使えません」とする方が、システム全体の信頼性は高くなります。実装上は、較正済み(measured/approx、またはラベル付き)の軸だけを active_axes として集め、以降の計算はこの集合に対してだけ回します。

ステップ1:スライダー値 → アンカー重み

ここが手法の核心です。ユーザーがスライダーを動かすと、各軸に 0〜1 の値が決まります。これを「目標の声はこの座標にいてほしい」という目標点に変換し、各アンカーがその目標にどれだけ近いかで重みを配ります。

前処理として、各アンカーの軸値はアンカー母集団で z 正規化しておきます(平均0・標準偏差1)。母集団の mean/std を保存しておけば、後から来る新しい声も同じ基準で評価できます。

def design_weights(slider_values, bank, temp=0.3, spread=2.5):
    d2 = np.zeros(n)
    for key in bank.active_axes:
        s = clip(slider_values[key], 0, 1)
        target = (2.0 * s - 1.0) * spread    # スライダー → 目標 z スコア
        d2 += (bank.axis_z[key] - target) ** 2
    d2 /= len(bank.active_axes)
    w = np.exp(-d2 / temp)                    # 近いアンカーほど重く
    return w / w.sum()                        # softmax
Enter fullscreen mode Exit fullscreen mode

やっていることは3つです。

  1. スライダー → 目標 z:スライダー中央(0.5)を z=0、両端を ±spread(=±2.5σ)にマップします。spread を広めに取るのは、端に張り付きにくくして外れ声にも寛容にするためです。
  2. 距離の集計:全 active 軸について、アンカーの z 座標と目標 z の二乗距離を足し合わせ、軸数で割って平均化します。
  3. softmax で重み化:距離が小さいアンカーほど大きな重みを付けます。temp(温度、=0.3)が小さいほど「最も近い1本」に重みが集中し、大きいほど多数のアンカーへなだらかに分散します。

最近傍を1本選ぶ(argmin)のではなく softmax でぼかすのがミソです。これにより、どのアンカーにもぴったり一致しない目標でも、複数のアンカーの中間として滑らかに表現できます。

ステップ2:重み → ブレンド埋め込み

重みが決まれば、あとは埋め込みの加重和を取るだけです。

def design_embedding(slider_values, bank, top_k=3, **kw):
    w = design_weights(slider_values, bank, **kw)
    emb = (w[:, None] * bank.embeddings).sum(axis=0)   # (192,) ブレンド埋め込み
    order = np.argsort(w)[::-1][:top_k]                 # 重い順に近傍アンカー
    top = [bank.names[i] for i in order]
    return emb.astype(np.float32), top, w
Enter fullscreen mode Exit fullscreen mode

返すのは3つ。ブレンド埋め込み(モデルにそのまま渡す声ベクトル)、近傍アンカー名の上位 top_k(重みの大きい順。参照プロンプトの構成などに使える)、そして重みベクトル(説明性・デバッグ用)です。

埋め込みを混ぜるだけでなく、「今の声は主にアンカーAとBの中間です」と言えることが、プロダクトとしての説明性につながります。ブラックボックスな1本のベクトルではなく、「既知の声の配合」として提示できるわけです。

おまけ:逆変換で「この声のスライダー位置」を読む

同じ z 正規化の写像を逆にたどれば、任意の声(録音・アップロード音声)を解析してスライダー位置を復元できます。

pct = (z / spread + 1.0) / 2.0 * 100.0   # z スコア → スライダー 0..100%
Enter fullscreen mode Exit fullscreen mode

これは design_weights の「スライダー→目標z」のちょうど逆写像です。ユーザーが自分の声を録音したら、その声が8軸のどこに位置するかをスライダーに反映し、そこから微調整を始められます。さらにその録音をアンカーとして母集団に追加すれば(追加時にバンクを再構築して統計を更新する)、声空間そのものを広げられます。同じ写像を順・逆の両方向で使い回せるのが、軸を式として定義したことの副次的な恩恵です。

落とし穴・学び

  • 埋め込みの加法性は「近傍でのみ」信じる。遠く離れた声どうしを極端な比率で混ぜると、埋め込み空間の非線形性が効いて不自然になりがちです。softmax の温度を上げすぎて無関係なアンカーまで混ぜないこと。
  • z 正規化の統計は母集団に紐づく。アンカーを追加したら統計(mean/std)を必ず再計算します。古い統計のまま新しい声を評価すると軸位置がずれます。
  • 測れない軸を測ったふりをしない。needs_label 状態を用意し、較正前の軸はマッチングから外す。精度を犠牲にした「それっぽい実装」で埋めないことが、長期的な信頼性を守ります。
  • 軸は特徴量からの写像として書く。個別ケースの if 分岐やハードコードされた対応表ではなく、音響特徴の線形結合で定義すると、順写像(設計)・逆写像(解析)・アンカー追加のすべてが一つの式で一貫します。

まとめ

  • 参照音声を丸ごと差し替える代わりに、人間が理解できる知覚軸のスライダーで声を連続的にデザインする
  • 各軸を実測の音響特徴(F0・フォルマント・HNR・shimmer・jitter 等)の線形結合として定義し、アンカー母集団で z 正規化する
  • スライダー値を目標 z にマップ→アンカーとの二乗距離を softmax で重みにし、話者埋め込みを加重合成して目標の声ベクトルを得る
  • 軸には measured / approx / needs_label の3状態を持たせ、較正できていない軸はマッチングから正直に除外する
  • 同じ写像を逆にたどれば任意の声の軸位置を復元でき、アンカー追加で声空間を拡張できる

Top comments (0)