にじボイスとAivisSpeechとそっくりな声の声優を探そう!【技術編】
概要
日本語の最近のテキスト音声合成 (TTS, Text-to-Speech) サービスとして、にじボイスやAivisSpeechが、まるで声優さんの演技のような自然な音声を生成できるとして有名です。
これらのサービスが自然な声優さんのような演技音声を合成できるということは、TTSの学習の都合上、そのような声優さんが喋っている音声を学習データとして用いている可能性が高いことになります(c.f. この謎記事)。
この記事では、その「学習に使われたかもしれないキャラクターや声優さんをどうやったら探せるか?」という視点から、関連技術である 話者認識 (Speaker Recognition) や 話者埋め込み (speaker embedding) について解説をし、目的を達するための手順を共有していきます。
リンク
デモ
実際にこの記事の技術を用いた成果物がHugging Face spacesのデモとして以下のリンクで公開されているので、ぜひ御覧ください。
🤗 にじボイスとAivisSpeechとそっくりな声の声優を探そう!
声優さんについての所感記事
技術的なことは別として、個人的にどのキャラがどの声優さんの声に感じるかについては、noteにまとめました。もし興味があればご一読ください。
にじボイスとAivisSpeechとそっくりな声の声優を探そう!【所感編】
話者認識 (Speaker Recognition) について
音声のAI分野には、様々なタスクが存在し、よく2・3文字の英単語で略されます、そのうちの1つがASV(話者認証)です。
- TTS (Text-to-Speech, 音声合成): テキストを受け取り、音声を生成するタスク
- ASR (Automatic Speech Recognition, 音声認識): 音声を受け取り、そこで発言されているテキストを書き起こすタスク
- VC (Voice Conversion, 音声変換): 元音声と、ターゲット話者音声を受け取り、元音声をターゲット話者音声の声音に変換するタスク
- SER (Speech Emotion Recognition, 音声感情認識): 音声を受け取り、その発話者の感情を予測するタスク
- VAD (Voice Activity Detection, 音声区間検出): 音声を受け取り、その中で本当に人が発話している区間はどこかを予測するタスク
以上がよく見るタスクで、略語もそれなりに見ますが、今回のテーマはそれらとは別のタスクで、話者認識 (Speaker Recognition) と呼ばれるものです。これは、ざっくりいうと「音声を受け取り、その話者を認識する」タスクですが、より詳しくは、次の2つのサブタスクが存在します。
- ASV (Automatic Speaker Verification, 話者検証): 音声を受け取り、その話者が事前登録された1つの音声の話者と一致しているかを検証するタスク
- SI (Speaker Identification, 話者識別): 音声を受け取り、その話者が多数の話者候補のうちどの話者であるかを識別するタスク
つまり、ASVは「声紋認証でちゃんと本人の声かを識別したい」モチベ、SIは「複数の人物候補の中から、与えられた声が誰の声か知りたい」モチベです。今回の「にじボイスとAivisSpeechとそっくりな声のキャラクターを探そう!」というタスクは、たかだか声優さんの演じたキャラクター数は有限個なので、その有限集合のうち、与えられた音声(TTSサービスで合成した音声)と一番似ているキャラクターの声を探るという意味で、SIのタスクだと結論付けられます。
関連タスクとして、話者ダイアライゼーションという、「音声を受け取り、誰がどのタイミングでしゃべっているのかをアノテートする」タスクもありますが、基本的な考えや手法は(オーバーラップ部分がある場合を除けば)話者認証とVADとクラスタリングの組み合わせとみなせるので、この記事では省略します。
(余談ですが、ASVはまあまあ見かけますがSIという略語はあまり見ない気がするので、界隈でもあまり通じないのかもしれません。TTS、ASR、SER、VADは非常によく見ます。以下は話者識別と単に呼ぶことにします。)
定式化
もう少し厳密に設定を述べると、話者識別とは、次のようなタスクを実行するものです。
- 事前に、N人の人物の音声ファイルが(1つまたは複数)登録される
- モデルは、入力として音声ファイル1つを受け取る
- モデルは、出力として、その音声が「事前に登録されたN人の音声のうち誰の音声か」を表す確率やスコアを返す
では、一体どのようにこの話者識別を行うことができるのでしょうか?アイデアは、「音声から、声紋のような、その人固有の声音情報を持つ、何らかの特徴量を取り出して、それを比較する」というものです。
話者埋め込み
その「音声に対して計算される、発話者の声音の特徴を表している何らかの量を取り出す」行為は 話者埋め込み (speaker embedding) と呼ばれます。
厳密には、話者埋め込みとは「1つの音声ファイルに対して、ある固定次元ユークリッド空間の元(ベクトル)を1つ返す」決定論的な関数だと述べることができます。
そのような話者埋め込み器が存在するとすると、ある音声の話者埋め込みと、別の音声の話者埋め込みという、2つのベクトルを比較することで、その話者がどれだけ似ているかどうかを数値的に表すことができます。2つのベクトルの類似性は、典型的には単純に2つのベクトルのなす角度を求める(球面に正規化してから内積を取る)コサイン類似度で計算されます。
このプロセスで得られた類似度スコアは、cosなので-1から1の実数値を取り、1の場合は2つのベクトルが(方向のみを考えると)完全に一致、-1だと反対方向、という解釈となり、「1に近いほど2つの音声の話者は似ている」とみなせます。
なので、例えば話者埋め込み器が与えられたとすると、話者識別は次のように解答を与えることができます。
- N人の登録された音声について、話者埋め込みを事前計算し、その話者について1つの(あるいは場合によっては複数の)ベクトルを得る
- 与えられた音声に対して、話者埋め込みを計算する
- その埋め込みと、事前登録されたN人の埋め込みのコサイン類似度を計算し、一番スコアが高い話者が、求める話者である
典型的な話者識別はこのようなパイプラインに従っており、すると話者識別の性能で重要なのは、きちんと話者の特徴を捉えた話者埋め込み器を作ることだということになります。もちろん同じ話者の埋め込みが同じような向きを向いていることも大事ですが、それと同時に「違う話者の埋め込みは違う方向を向いている」ことの両方を満たすことが重要です。
話者埋め込み器の歴史
これについては、筆者は専門家や当事者ではなくあまり知らないし、興味がある人は調べればいろいろ出てくるので詳細は省きます。正直x-vector以降しかあまり見ないので詳しく知りません。
- MFCC等: 信号処理の文脈で、音声から何らかの特徴量を得るという視点からは、MFCC特徴量が古くから存在します。これは音声認識にも(少なくとも昔は)用いられていた、人間の知覚を反映した何らかの特徴量です(詳しくわかっていない)。初期には話者識別にも用いられていたようです。
- i-vector (Interspeech2010): あまり知りませんが古い何らかの特徴量です
- d-vector (ICASSP2014): あまり知りませんがこれもよく見ます
- x-vector (ICASSP2018): 深層学習ベースのやつです。ここらへんからDNNの性能向上により話者認識(に限らず音声AI全般)がDNN前提で性能も格段に上がっていきます。
- r-vector (arXivプレプリントでは2019): 画像で有名なResNetをうまく使った性能がいいモデルです、今回のデモ作成で用いた埋め込みはこれに基づいています
- ECAPA-TDNN (Interspeech2020) 等、より話者の特徴を掴んで埋め込みを作る工夫をした数多のモデルたちがあります
- SSLの中間層の出力: 話者認識に限らずいろんなところでよく見ます。WavLMやらHuBERTやらwav2vec2.0やらの音声SSLモデルに音声を与えたときの中間層の出力が、何らかの音声の情報を持っているという信念のもと、それを特徴量として取り出すやつです。
話者識別タスク以外への応用
話者埋め込み自体は話者識別を念頭に置いたものですが、他の音声タスクへの応用もいろいろ考えられています。例えばTTSに話者埋め込みを入れることで簡易的なゼロショット性を与える論文があったり、また「声の特徴」という性質から、もちろんVCにも使われています。
(余談ですがStyle-Bert-VITS2 の スタイルベクトル とは、実は話者埋め込みのことです。これはスタイルの強さの制御に話者埋め込み特徴量を用いています。)
OSSで使える話者埋め込みの紹介
いくつかピックアップします。
- sarulab-speech/xvector_jtubespeech: x-vectorをYouTubeからの日本語音声で学習したモデルです
-
pyannote.audio: 話者ダイアライゼーションのためのツールキットですが、いくつかの話者埋め込みが簡単に使えるようになっています:
- pyannote/wespeaker-voxceleb-resnet34-LM: 今回使用したのはこれです、Style-Bert-VITS2でも使われています、r-vector系
- pyannote/embedding
-
WeSpeaker
- 話者埋め込みに特化したOSSライブラリで、上のpyannoteのモデルもここのモデルからの移植です
-
3D-Speaker
- これも同じく話者埋め込み・ダイアライゼーション特化のOSSライブラリで、いろんなモデルが試せるはずです(実はあまり使ったことない)
-
SpeechBrain
- 話者識別に限らない音声全般のツールキットですが、ECAPA-TDNN含めいろんなモデルが公開されて試せます
-
FunASR
- これも音声全般のツールキットですが、話者埋め込み funasr/campplus が公開されており使えます
ありすぎる
はい。自分もすべて試したわけではありません。あくまで体感ですが、それぞれにそれぞれの特徴がありますが、アニメ声演技音声データでは、上の pyannote/wespeaker-voxceleb-resnet34-LM が何だかんだそこそこ強く、知覚類似性とコサイン類似度が大体一致している場合がよく、愛用しています。
(それはそれとして、でも満足できない点もあるので、最近はECAPA-TDNNのファインチューニングをして遊んでいます、それについては別にそのうち公開するかもしれません。)
話者埋め込みを使ってみよう
とりあえず pyannote/wespeaker-voxceleb-resnet34-LM を使ってみる手順を紹介します。
環境構築
uv venv
uv pip install pyannote.audio
必要ならCUDA付きtorchを入れましょう。
推論
こののち、次のようにすぐ使えます。
from pyannote.audio import Model, Inference
import torch
model = Model.from_pretrained("pyannote/wespeaker-voxceleb-resnet34-LM")
inference = Inference(model, window="whole")
inference.to(torch.device("cuda")) # gpuを使う場合
embedding = inference("test.wav") # np.ndarray with shape (256,)
また、大量の音声ファイルがある場合は、ThreadPoolExecutor
で並列処理するとまあまあ速いです。例:
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import numpy as np
import torch
from pyannote.audio import Inference, Model
from tqdm.auto import tqdm
model = Model.from_pretrained("pyannote/wespeaker-voxceleb-resnet34-LM")
inference = Inference(model, window="whole")
inference.to(torch.device("cuda")) # gpuを使う場合
AUDIO_DIR = Path("audio")
def get_embedding(audio_path):
return inference(audio_path)
audio_files = list(AUDIO_DIR.rglob("*.wav"))
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(
tqdm(executor.map(get_embedding, audio_files), total=len(audio_files))
)
embeddings = np.array(results) # shape: (num_files, 256)
結果をt-SNEやUMAPで可視化すると面白いかもしれません。
今回のタスクをやってみよう!
復習すると、今回のタスクは、「与えられた音声(にじボイスやAivisSpeechで合成された音声)」に対して、その声が誰の声かを識別する のが目的です。
事前登録
そのため、話者埋め込み器を固定したあとは、話者の候補を事前にリストアップし、その声の話者埋め込みを計算しておく必要があります。
生成音声のドメインがアニメ演技系の音声なので、そのような声優さんの演技したデータセットとして、非常に大規模なものが OOPPEENN/VisualNovel_Dataset を使うことにします(以前はGalgame_Datasetという名称でした)。その中には、日本の約600弱のゲームの音声が、700万ファイル以上、合計1万時間以上あります。
基本的には、「このデータをすべてダウンロードして、全音声に対して上記の方法で話者埋め込みを計算する」だけです。
にじボイスとAivisSpeechでの音声サンプル作成
次に、検証する音声をそれぞれTTSサービスから取得する必要があります。
最初は、公式のデフォルトサンプル音声を使用しようとしたのですが、セリフの長さが短かったり(当たり前ですが話者識別の精度のためにはある程度の秒数があることが望ましいです)、また特徴的すぎるセリフでうまくヒットしなかったりがあるので、今回は以下の文章を固定で読み上げさせるようにしました:
国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。
にじボイスは、このためだけにAPIを登録課金して、以下のコードで生成しました。API詳細は公式を見に行ってください。
from __future__ import annotations
import time
from pathlib import Path
import requests
# ==== 事前設定 ====
API_KEY = "your-api-key-here"
BASE_URL = "https://api.nijivoice.com/api/platform/v1"
SCRIPT_TEXT = "国境の長いトンネルを抜けると雪国であった。夜の底が白くなった。信号所に汽車が止まった。あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。"
OUT_DIR = Path("nijivoice_wav") # 保存先フォルダ
OUT_DIR.mkdir(exist_ok=True)
COMMON_HEADERS = {
"accept": "application/json",
"x-api-key": API_KEY,
}
# ==== 1. 声優一覧を取得 ====
actors_resp = requests.get(f"{BASE_URL}/voice-actors", headers=COMMON_HEADERS)
actors_resp.raise_for_status()
voice_actors = actors_resp.json().get("voiceActors", [])
print(f"取得した声優数: {len(voice_actors)}")
# ==== 2. 各声優で音声生成 ====
for actor in voice_actors:
actor_id: str = actor["id"]
actor_name: str = actor["name"]
speed: float = actor.get("recommendedVoiceSpeed", 1.0)
# ファイル名を作成(重複時は ID でユニーク化)
safe_name = actor_name.replace(" ", "_")
file_path = OUT_DIR / f"{safe_name}.wav"
if file_path.exists():
print(f" → スキップ: {file_path} (すでに存在)")
continue
print(f"[{actor_name}] 音声生成中… (speed={speed})")
payload = {
"format": "wav",
"speed": str(speed),
"script": SCRIPT_TEXT,
}
gen_url = f"{BASE_URL}/voice-actors/{actor_id}/generate-voice"
gen_resp = requests.post(
gen_url,
json=payload,
headers={**COMMON_HEADERS, "content-type": "application/json"},
timeout=60,
)
gen_resp.raise_for_status()
audio_info = gen_resp.json()["generatedVoice"]
download_url: str = audio_info["audioFileDownloadUrl"]
# ==== 3. wav をダウンロードして保存 ====
wav_resp = requests.get(download_url, timeout=120)
wav_resp.raise_for_status()
# file_path = OUT_DIR / f"{safe_name}_{actor_id[:8]}.wav"
file_path.write_bytes(wav_resp.content)
print(f" → 保存完了: {file_path}")
# レートリミットを避けるための控えめなスリープ
time.sleep(0.5)
print("=== すべて完了しました ===")
AivisSpeechについては、UI上で生成して一瞬です。AivisについてはデフォモデルのAnneliちゃんのみの検証です。
では似ているキャラ検索をしよう
データが準備できたところで、あとは「どのように1つの音声に対してキャラクターを割り当てるか」が問題になります。
これについて、いろいろなやりかたがあるかと思いますが、上記公開デモでは次のような手法を取っています:
- 話者埋込モデル pyannote/wespeaker-voxceleb-resnet34-LM を用いて、 OOPPEENN/VisualNovel_Dataset の(エラー等で除外された以外の)700万程度の音声ファイルの話者埋め込みをすべて計算する
- ターゲットとなる音声の話者埋め込みを計算する、そのターゲット音声を固定する
- ターゲット音声の話者埋め込みと、1の結果とのコサイン類似度を計算する
- 各キャラクターについて、類似度が高い順に10個の音声を選び、その類似度の平均を計算する
- その平均が一番高いキャラクターを「似ている声のキャラ」として選び、その10音声を「類似音声」として選ぶ
この手順は、筆者が自分で勝手に考えたものなので、実際のベストプラクティスが何かは正直わかりません。が、この手順を取っている理由は以下のとおりです。
- 単純に「コサイン類似度が高い順に音声をソートし、1番類似度が高い音声ファイルのキャラクターを、求めるキャラクターとする」という手法では、「ある音声だけ偶然にちょっとそのキャラの声に似てしまっているだけ(他の声はそんなに似ていない)」を弾けない(実際そういう現象がよくありました)
- なので、「各キャラごとに上位類似度の平均」を考えて、それをそのキャラのスコアとすることで、それを防ぐことができる
具体的なコードは、ChatGPTに以上のことをきちんと詰めて話せば書いてくれるので省略します(自分もそうしただけです。)
成果物
再掲ですが、Hugging Face Spacesのデモに公開しています。
🤗 にじボイスとAivisSpeechとそっくりな声の声優を探そう!
Future Work
- 他の話者埋め込みを使った場合との違いは時間があれば見てみたいです。
- 話者識別器を学習する際は、そもそも「話者埋め込み器 + 分類ヘッド」を分類タスクとして学習して、その埋め込み器部分を使う、というふうにします。なので、そもそもの OOPPEENN/VisualNovel_Dataset で話者識別器を学習すれば、分類ヘッド付きで、(コサイン類似度を使わず直接的に)「ある音声が、このデータセットにある話者のどの話者に似ているか」を出力できるので、それを試してみるのもやってみたいです(実はいまその学習をずっと家で回し続けています)
- もともとのモチベは「TTSモデルの学習元を特定したい」ですので、そこから考えると、モデル内部をprobingしたり、ブラックボックスと見なしても挙動を観察することで、学習元についての何らかの情報を得ることもできそうで、考えていきたいです。あまりそういう研究を見たことがないですが、やってみたいことの一つです。
- たとえば特定音素列に関する層の発火状態がデフォモデルとやたら違っていたら、そのテキストが学習データにある可能性が高かったりしそう
- ある単語だけやたら流暢に発音したりしたら、それが学習データにありそう、というのもありますね
- ブラックボックス状態でのにじボイス学習元考察についての謎記事: にじボイス(旧DMMボイス)はエロゲーを学習している
Discussion