#音声処理#話者埋め込み#話者認識#Seed-VC

声質だけで話者を推定する — 出所不明の音源にラベルを付ける


音声のデータセットを育てていると、必ず「これ誰の声だっけ」問題にぶつかります。このアプリのアンカー(声質変換の目標話者)も例外ではありませんでした。素材を集めていくうちに、spk7spk18 のように 番号だけあってラベルが失われたアンカーが混ざっていたのです。

ファイル名が飛んでいたり、途中でリネームしたり、元素材のディレクトリと切り出し済みアンカーの対応が取れなくなったり。理由はいろいろですが、結果は同じで「声はあるが、誰の声か分からない」状態のデータが手元に残ります。

こういう素材は、放っておくと「よく分からないから使わない」死蔵データになりがちです。でも、実は手がかりは音そのものにあります。声質が分かっているのだから、声質で照合すればいい。この記事は、話者埋め込みの類似度だけを頼りに出所不明アンカーへラベルを振り直し、anchor_sources.json を組み直した記録です。

発想:ラベルではなく声で突合する

やりたいことを整理すると、こうなります。

  • 手元には、ラベル付きの元素材(話者名がファイル名に入った wav)が大量にある
  • 一方で、ラベルの落ちたアンカー(spk7 など)がある
  • 両者を 声質でマッチングして、アンカーに正しい名前を復元したい

これは古典的な話者照合(speaker verification)そのものです。ありがたいことに、このアプリはすでにアンカー定義に campplus の話者埋め込みを使っています。同じ埋め込み空間でコサイン類似度を測れば、「このアンカーは、どの元素材の声に一番近いか」が求まります。

素材側:ロバストな話者ベクトルを作る

まず、突合の相手となる「ギャラリー」を作ります。各元素材ファイルから話者ベクトルを1本ずつ抽出するのですが、ここで4秒の窓を1つ取るだけだと不安定です。無音・息継ぎ・言い淀みに当たると、その人の声を代表しないベクトルになってしまう。

そこでファイル全体に分散させた 12個の窓から埋め込みを取り、L2正規化してから平均します。無音チャンクは振幅で弾きます。

def _robust_embed(campplus, y16):
    win = int(WIN_S * SR16); guard = int(5 * SR16)
    hi = len(y16) - guard - win
    embs = []
    for p in np.linspace(guard, hi, N_WIN).astype(int):   # N_WIN=12 窓
        seg = y16[p:p + win]
        if float(np.max(np.abs(seg))) < 1e-3:             # 無音は捨てる
            continue
        e = _embed(campplus, seg)
        embs.append(e / (np.linalg.norm(e) + 1e-9))       # 各窓を正規化
    v = np.mean(embs, axis=0)
    return v / (np.linalg.norm(v) + 1e-9)                 # 平均を再正規化

「正規化してから平均、平均をまた正規化」という手順がポイントです。正規化せずに平均すると、たまたまノルムの大きい窓に引っ張られます。単位ベクトルにしてから平均することで、各窓を等しく扱った頑健な話者ベクトルになります。

アンカー側:既存の埋め込みを再利用する

照合対象のアンカーについては、新たに計算する必要はありません。このプロジェクトは precompute_anchors.py で全アンカーの埋め込みを anchor_embeddings.npz に保存済みなので、そこから読み込んで正規化するだけです。

d = np.load(os.path.join(DATA, "anchor_embeddings.npz"), allow_pickle=True)
names = [str(x) for x in d["names"]]
embs = d["embeddings"].astype(np.float64)
emap = {n: embs[i] / (np.linalg.norm(embs[i]) + 1e-9) for i, n in enumerate(names)}

アンカー生成に使ったベクトルをそのまま推定にも使う——ここでも「選定・定義・推定を同じ物差しで」という一貫性を保っています。

突合:コサイン類似度の上位3件と信頼度

あとは、対象アンカーのベクトルとギャラリー全体の内積(=正規化済みなのでコサイン)を取り、上位を見るだけです。ギャラリーを行列 G に積んでおけば、G @ v の一発で全素材との類似度が出ます。

sims = G @ emap[t]
order = np.argsort(sims)[::-1][:3]
cand = "  ".join(f"{glabels[i]}={sims[i]:.3f}" for i in order)
conf = "○" if sims[order[0]] >= 0.55 else ("△" if sims[order[0]] >= 0.45 else "×")
print(f"{t}: {conf} {cand}")

大事なのは、結果を「1位だけ」で断定しないことです。上位3件を並べ、最良スコアで信頼度を3段階に分けています。

  • ○ (≥0.55): 単一話者しきい値と同じ水準。ほぼ確定と見なせる
  • △ (0.45〜0.55): 候補ではあるが、上位3件を見て人が判断すべきグレーゾーン
  • × (<0.45): ギャラリーに該当なし。別人か、そもそも素材が無い

このしきい値 0.55 は、アンカー選定で単一話者を判定する homo-thresh と同じ値です。「同一話者と呼べる近さ」の基準を、選定でもラベリングでも揃えています。

なぜ「1位だけ」で決めないのか

自動化と聞くと1位を即採用したくなりますが、話者照合はそれでは事故ります。声質の近い話者は実在しますし(低い渋声の男性、高くて明るい女性、など)、収録状態が近い素材同士も似て見えます。1位と2位のスコアが僅差なら、それは「機械には決められない」というシグナルです。

だから上位3件とスコアを並べて出し、△以下は人の目を通す運用にしました。これは応急処置ではなく 役割分担です。○の大量の自明なケースを機械で片付け、人間は本当に紛らわしい少数だけを見る。全件試聴に比べれば桁違いに省力化できます。

推定が確定したら anchor_sources.json(アンカー名 → 元素材ファイル名の対応表)に書き戻します。この対応表があると後段が一気に楽になります。品質監査(audit_anchor_quality.py)や分布可視化(plot_anchor_distribution.py)は、この対応表を引いて spk24 を実際の話者名で表示できるようになる。番号でしか呼べなかったデータが、名前で語れる資産に変わります。

落とし穴・学び

  • 単窓の埋め込みを信じない。話者ベクトルは複数窓の正規化平均で作る。無音や言い淀みの1窓に引っ張られると、同一人物でも別人に見えます。
  • 信頼度を明示し、グレーゾーンは人に渡す。1位即採用は声質の近い話者で必ず事故る。上位3件+3段階の信頼度で、機械と人間の分担点を作りました。
  • 推定と定義で同じ埋め込み・同じしきい値を使う。アンカー生成に使った campplus・0.55 をそのまま推定にも使うことで、「近いと判定したのに本番で似ない」というズレを防げます。
  • ラベルは声から復元できる。メタデータが失われても、声そのものが一次情報です。出所不明を理由に捨てる前に、声質で突合できないかを疑う価値があります。

まとめ

  • 出所不明アンカーのラベルを、話者埋め込みのコサイン類似度だけで復元した
  • 元素材ごとに12窓の埋め込みを正規化平均し、ノイズに強い話者ベクトルを作る
  • アンカー側は生成済みの anchor_embeddings.npz を再利用。推定・定義を同じ物差しで揃える
  • 上位3件+信頼度3段階(○≥0.55 / △≥0.45 / ×)で、自明なケースは機械、紛らわしいケースは人に分担
  • 復元したラベルを anchor_sources.json に書き戻すと、監査も可視化も名前で語れるようになり、死蔵データが資産に変わる
← 記事一覧へ