「単一話者で自然で高音質」なアンカーを機械的に選ぶ
声質変換のアプリを作っていると、変換モデルそのものより「何を目標の声(アンカー)にするか」で品質が決まる場面にたびたび出くわします。私たちの声質変換アプリでは、声優や朗読の録音から4秒〜12秒を切り出してアンカーにしていました。最初はこの切り出しを「耳で良さそうなところ」に頼っていたのですが、これがまったくスケールしません。
数十人ぶんの素材を1つずつ試聴して、良い区間を手で切り出す——退屈な上に、判断がその日の気分でぶれます。しかも録音の質はピンキリで、対談形式で相手の声が混ざっていたり、感情のこもった熱演で抑揚が激しすぎたり、ノイズが乗っていたり。そういう「アンカーに向かない区間」を人力で弾き続けるのは無理があります。
この記事は、その「耳で選ぶ」を 3段のフィルタに置き換えた記録です。狙いは1つ、「単一話者で・自然で・高音質」な区間を機械的に選ぶこと。結果として、アンカー群の最低 PESQ が 1.24 から 2.31 まで底上げされ、最終的に70件あまりのアンカーが揃いました。
前提:アンカーに求められる3条件
そもそも良いアンカーとは何か。声質変換の目標音声として使うなら、次の3つを同時に満たす必要があります。
- 単一話者であること — 12秒の切り出し範囲に別人の声が混ざっていると、話者埋め込みが2人の平均になり、誰でもない声になってしまう
- 自然で落ち着いた発声であること — 叫び・裏声・歌唱・過剰な抑揚は、その人の「素の声」から離れる。変換の目標としては素直な発話がよい
- 録音が高音質であること — ノイズや歪みの多い素材を目標にすると、変換結果にもそれが乗る
厄介なのは、この3条件が別々の性質だという点です。1つのスコアでまとめて評価しようとすると破綻します。そこで、性質ごとに独立したフィルタを直列に並べました。
全体の流れ:安いフィルタから順にかける
計算コストの安い順にフィルタを並べ、候補を絞り込んでいきます。1ファイルにつき、まず等間隔に48個の候補窓を置き、そこから段階的にふるいにかけます。
48候補(等間隔)
→ Stage1: 韻律ベースの自然さでスコアリング → 上位16件
→ Stage2: campplus 話者埋め込みで単一話者性を判定
→ 品質ゲート: SQUIM PESQ でクリーンさを判定
→ 単一話者かつ品質を満たす中で最も自然な窓を採用
pyin や RMS の分析は軽いので全候補にかけ、重い話者埋め込みと SQUIM は上位16件だけにかける、という設計です。
Stage1:韻律で「自然さ」を測る
最初のフィルタは、ピッチ(F0)とエネルギー(RMS)から発声の「落ち着き」を定量化します。librosa の pyin で F0 を取り、いくつかの生指標を計算します。
- 微小安定性(instab): 隣接フレームの半音変動の中央値。小さいほど安定
- 抑揚幅(spread): F0 の p90−p10 を半音で。広いほど感情過多、狭すぎると棒読み
- 有声確度(conf): 周期性の明瞭さ
- エネルギー変動係数(ecv): RMS の標準偏差/平均。大きいほど声量変動=感情的
- 最長ホールド音(hold): F0 が ±0.5 半音以内に留まり続ける最大長。持続発声や歌唱・伸ばしを検出
ここで面白いのは、抑揚を「小さいほど良い」ではなく 目標値からの逸脱で評価している点です。棒読みも熱演もどちらも避けたいので、自然な会話の抑揚の目安 SPREAD_TARGET = 6.0 半音を中心に、そこから離れるほど減点します。
def _composite(c: dict, modal_f0: float) -> float:
# 話者常用音域からの F0 乖離[半音](裏声/叫び/キャラ声を検出)
modal_dev = abs(np.log2(max(c["f0"], 1e-6) / max(modal_f0, 1e-6))) * 12.0
pen = (W_SPREAD * abs(c["spread"] - SPREAD_TARGET) # 抑揚は目標から離れるほど減点
+ W_ECV * c["ecv"] # 声量変動
+ W_HOLD * max(0.0, c["hold"] - HOLD_FREE_S) # 0.5s 超のホールド
+ W_MODAL * modal_dev) # 常用音域からの乖離
base = c["conf"] / (1.0 + c["instab"])
return base / (1.0 + pen)
modal_f0 は、そのファイルの全候補の F0 中央値、つまり その話者が普段使っている音域です。ここから大きく外れる窓(裏声・叫び・キャラ声)を減点することで、同じ人でも「素の声に近い区間」を優先できます。ホールドは自然な母音の伸ばし(0.5秒まで)は許容し、それを超えるぶんだけ強く減点(重み 1.2)します。歌唱や語尾の伸ばしを弾くためです。
このスコアで候補を並べ替え、上位16件だけを次のステージに渡します。
Stage2:話者埋め込みで「単一話者性」を検定する
次が対談・インタビュー音源対策の肝です。Seed-VC のアンカー埋め込みと同じ **campplus 話者埋め込み(192次元)**を使い、12秒の切り出し範囲が1人の声だけかを判定します。
やり方はシンプルな2話者検定です。12秒の範囲を3秒チャンク(1.5秒ホップの重複)に刻み、各チャンクの話者埋め込みを取ります。そのうち 最も似ていないチャンク対を種にして全チャンクを2クラスタに割り当て、2つのクラスタ重心のコサイン類似度を返します。
E = E / (np.linalg.norm(E, axis=1, keepdims=True) + 1e-9)
S = E @ E.T
# 最も非類似なチャンク対を 2 クラスタの種にする
a, b = np.unravel_index(int(np.argmin(S)), S.shape)
assign = (S[a] < S[b]).astype(int) # 0=種a寄り, 1=種b寄り
if assign.min() == assign.max(): # 片寄り(実質単一クラスタ)
return 1.0
c0 = E[assign == 0].mean(axis=0); c1 = E[assign == 1].mean(axis=0)
# ...L2正規化...
return float(np.dot(c0, c1)) # 2重心のコサイン(高=単一話者)
単一話者なら、チャンク間の違いは音素の差くらいなので2重心は近く、コサインは高い値になります。話者が交代していれば重心が離れ、値が落ちます。しきい値 homo-thresh = 0.55 を下回る候補は単一話者とみなしません。
大事なのは、選定に使う埋め込みとアンカー定義に使う埋め込みが同一であることです。選定基準とアンカーそのものが同じ物差しで測られるので、基準がぶれません。
品質ゲート:SQUIM PESQ でクリーンさを担保する
最後が録音品質のゲートです。参照音声なしで PESQ/STOI/SI-SDR を推定できる Torchaudio の SQUIM_OBJECTIVE を使い、切り出し範囲のクリーンさを採点します。参照なしで測れるのが要点で、原音のない野良素材でも品質を数値化できます。
def _pesq(squim, y16, center_s):
seg = y16[i0:i0 + int(EXT_S * SR16)] # 12s 切り出し範囲
with torch.inference_mode():
_stoi, pesq, _sisdr = squim(torch.tensor(seg).unsqueeze(0).float())
return float(pesq.item())
しきい値は pesq-thresh = 2.3。最終的な採用ロジックは、単一話者(homog≥0.55)かつ品質(PESQ≥2.3)を満たす候補の中で、Stage1スコアが最も高い(=最も自然な)窓を選びます。もし条件を満たす候補が無ければ、単一話者を優先しつつ品質順に妥協する、という段階的な緩和を入れています。ハード制約は「単一話者」、優先は「自然さ」という優先順位です。
効果:最低品質が底上げされた
このしきい値をどう決めたかというと、先に全アンカーを audit_anchor_quality.py で SQUIM 採点し、その分布を見て決めました。監査は低品質順にランキングを出すので、「どこで切ると何件残るか」が一目でわかります。
品質ゲートを入れる前は、アンカー群の最低 PESQ は約 1.24 まで落ち込んでいました。ノイズや歪みの多い区間がまぎれ込んでいたのです。ゲート導入後の実データ(anchor_quality.json、72件)を集計すると、分布はこうなりました。
- 最低 PESQ: 2.31
- p25: 2.72 / 中央値: 3.08 / 最高: 4.12
最低値が 1.24 → 2.31 に上がった、というのが数字で見える成果です。「一番ひどいアンカー」の底が上がることは、アプリ全体の体験の底が上がることを意味します。中央値が3を超えているのも、素材選びが安定した証拠です。
落とし穴・学び
- 1つのスコアに混ぜない。単一話者性・自然さ・音質は無相関な性質で、加重和にすると「そこそこ全部」の凡庸な窓が勝ってしまいます。性質ごとに独立フィルタを直列にし、ハード制約(単一話者)と優先(自然さ)を分けたことで意図どおりに効きました。
- 「小さいほど良い」が正しいとは限らない。抑揚は少なければ良いわけではなく、棒読みも避けたい。目標値からの逸脱で評価する発想に切り替えて初めて、自然な会話が選ばれるようになりました。
- 選定基準とアンカー定義は同じ物差しで。単一話者判定に campplus を使ったのは、それがアンカー埋め込みそのものだからです。別のモデルで選ぶと、選定で通っても本番で近くならない、という不整合が起き得ます。
- しきい値は監査ドリブンで決める。勘で 2.3 と置いたのではなく、監査スクリプトで分布を見てから決めました。データを見ずにしきい値を決めるのは、結局また「耳で選ぶ」に戻るのと同じです。
まとめ
- アンカー選定を「耳で選ぶ」から 3段の定量フィルタに置き換えた
- Stage1(韻律)で自然さをスコアリング、Stage2(campplus話者埋め込み)で単一話者性を2話者検定、最後に SQUIM PESQ で品質ゲート
- 抑揚は「小さいほど良い」ではなく 目標6半音からの逸脱で評価。裏声・叫びは話者常用音域からの乖離で弾く
- 品質ゲート(PESQ≥2.3)で最低 PESQ が 1.24 → 2.31 に底上げ。中央値 3.08、最終70件あまり
- しきい値は監査スクリプトで分布を見てから決める。単一話者性の判定はアンカー埋め込みと同一のモデルで行い、基準を一貫させる