声質だけで話者を推定する — 出所不明の音源にラベルを付ける
音声のデータセットを育てていると、必ず「これ誰の声だっけ」問題にぶつかります。このアプリのアンカー(声質変換の目標話者)も例外ではありませんでした。素材を集めていくうちに、spk7〜spk18 のように 番号だけあってラベルが失われたアンカーが混ざっていたのです。
ファイル名が飛んでいたり、途中でリネームしたり、元素材のディレクトリと切り出し済みアンカーの対応が取れなくなったり。理由はいろいろですが、結果は同じで「声はあるが、誰の声か分からない」状態のデータが手元に残ります。
こういう素材は、放っておくと「よく分からないから使わない」死蔵データになりがちです。でも、実は手がかりは音そのものにあります。声質が分かっているのだから、声質で照合すればいい。この記事は、話者埋め込みの類似度だけを頼りに出所不明アンカーへラベルを振り直し、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に書き戻すと、監査も可視化も名前で語れるようになり、死蔵データが資産に変わる