オリジナルキャラクターでYouTube動画を作ってみたくて、Qwen3-TTSで自作キャラクターのTTSを作ってみました。
Qwen3-TTSを選んだのは、話題性、日本語対応、短いリファレンス音声でボイスクローンができること、Macのローカル環境で動くこと、という観点です。
2種類の声を作り、実際に動画のナレーションとして使ってみましたが、実運用には多くの調整が必要でした。SNSで話題になっていますが、そんなに甘くはありませんでした。
使ってみて、とにかく品質が安定しない
使い始めてすぐにぶつかったのが、とにかく品質が安定しないことでした。
まずノイズが入る。冒頭や末尾で単語や音節が切れて、うまく喋りきれていないこともある。そして何より、声質そのものが安定しません。リファレンスとは違う声で生成されることが多々あって、ひどいときは女性の声を参照させているのに男性の声で返ってくる。とても同じ人物の声とは思えないものが出てくることもありました。
発音も怪しくて、本来の読みどおりに発音してくれなかったり、固有名詞や辞書にないような単語はなかなか正しく読めません。
冒頭ノイズについては、調べていくうちに理由が見えてきました。ICLモードでは、リファレンス音声の最後のcodecトークンが生成音声の最初のトークンに影響します。リファレンスが単語の途中でぶつっと終わっていると、その音素が生成音声の頭ににじむ。phoneme bleed-throughと呼ばれる現象です。標準のqwen_tts側にも、リファレンスのcodecトークンを生成トークンの前に結合しておく処理(codec prepend)は入っているのですが、それでも完全には消えませんでした。
対策として、リファレンス末尾に0.5秒の無音を足す方法を試しました。リファレンスがきれいな無音で終われば、頭ににじむ音も無音になる、という理屈です。あわせて、qwen_ttsのaudio_codes_paddedのpadding_valueが0から-1に修正されたバグ(Commit 6cafe55)も踏んでいたので、これは修正済みのものを使うようにしました。
参照音声を長め(10〜20秒)にして末尾を無音で終わらせると冒頭ノイズは多少減りましたが、それでも完全には消えず、声質の不安定さも含めて根本的には安定しませんでした。
1文くらいのセリフを生成するのに5〜10秒。それで当たりが出るまでガチャを回し続ける、という状況になっていました。
ダミーセリフとWhisperに落ち着いた
いろいろ試して、最終的に落ち着いたのが、ダミーセリフを使うやり方でした。
本来喋らせたいセリフの前後に短いセリフを付けて生成してみる、というやり方です。たとえば「〇〇です」と言わせたいなら、「はい。……〇〇です。……はい。」のように、前後に「はい」を挟んで生成する。冒頭ノイズや音のにじみを、前後に付けたこの部分に引き受けさせて、あとで前後だけ切り落とす。本体を残す、という狙いです。
難しかったのは、どこで切るかでした。最初は音の特徴だけで切る位置を判定しようとして、いくつか方法を試しましたが、どれもうまくいきませんでした。
まずVAD(Silero VAD)単独でのトリミング。ノイズや前後のセリフが独立したセグメントとして検出できたときは正しく除去できます。ただ、その部分が本体の発話と融合してしまうと、本体ごと削ってしまう。単独では信頼できず、今はWhisperが失敗したときのフォールバックとしてだけ残しています。
次にエネルギーベースの冒頭ノイズ除去。冒頭200msのRMSの推移で「山→谷→山」パターンを探す方法ですが、閾値のチューニングが難しく、本体を削るリスクが消えませんでした。不採用。
librosaのonset検出(librosa.onset.onset_detect(backtrack=True))も試しました。冒頭ノイズには効くのに、日本語の本体の冒頭を切りすぎます。切りすぎをASRのチェックで検出してリトライすると、毎回無駄打ちになる。これも不採用です。
どれもノイズと本体の境界を音の特徴だけで当てようとして、本体を巻き込むリスクを外せませんでした。境界を判定する材料が足りていなかったんだと思います。
そこで、切る位置はWhisperに任せることにしました。前後に付けたセリフを文字として読ませて、その文字がどこにあるかをタイムスタンプで特定してカットする、というやり方です。
ダミーに使う言葉には条件があります。TTSがはっきり発音して区切りになること、Whisperが確実に書き起こすこと、本文と混ざらない短い独立した発話であること。この3つを満たす必要がありました。
ダミーは言語ごとに変えています。
DUMMY_HEAD = {
"Japanese": "はい。……",
"Chinese": "对。……",
"Korean": "네. ... ",
"Russian": "Да. ... ",
"Spanish": "Sí. ... ",
"French": "Oui. ... ",
"German": "Ja. ... ",
"Italian": "Sì. ... ",
}
末尾用のダミーも、同じ言語構成で反対向きに用意しています。
言語ごとに変えているのには理由があって、最初は欧州語にも英語の「Yes」を使い回していたのですが、これが失敗でした。音声全体がその言語として認識されるので、英語の「Yes」だけWhisperに文字起こしされず、カット位置の検出も、ちゃんと消えたかの確認も、両方すり抜けてしまう。ダミーはその言語のネイティブな肯定語にする必要がありました。
日本語以外を生成するときは、声のクローンのやり方自体も変えています。日本語はリファレンス音声とそのテキスト(ref_text)を両方渡すICLモードで生成しますが、他の言語で同じことをすると、日本語リファレンスの読み方が引きずられます。特に中国語は、日本語読みの発声がめちゃくちゃに混ざる。なので日本語以外では、リファレンスのテキストや波形は渡さず、話者の埋め込み(x_vector)だけを使うモードに切り替えています。
コードでは、日本語用に作ったICLモードのプロンプトから、リファレンスの波形(ref_code)とテキスト(ref_text)を外して、話者埋め込み(ref_spk_embedding)だけを残したものを別に用意しています。
def make_xvector_prompt(icl_prompts):
return [
VoiceClonePromptItem(
ref_code=None,
ref_text=None,
ref_spk_embedding=item.ref_spk_embedding,
x_vector_only_mode=True,
icl_mode=False,
)
for item in icl_prompts
]
生成時は、言語を見てどちらのプロンプトを渡すかを切り替えるだけです。
def _voice_prompt_for(character, language):
if language != "Japanese":
return voice_prompts_xvec[character]
return voice_prompts[character]
生成後は、faster-whisperをword_timestamps=Trueで回してダミーの位置を取ります。
segments, _ = asr_model.transcribe(
audio_16k,
language=whisper_lang,
beam_size=5,
vad_filter=False,
word_timestamps=True,
)
取れたタイムスタンプでカットして、切り口には100msのフェードをかけます。カット位置には300msのマージンを取りつつ、本文の開始位置より先には食い込まないようにガードしています。
def _cut_and_fade(audio, sr, head_sec, tail_sec, main_start_sec=None,
head_margin=0.3, tail_margin=0.02):
cut_start = int((head_sec + head_margin) * sr) if head_sec is not None else 0
if main_start_sec is not None and cut_start > int(main_start_sec * sr):
cut_start = int(main_start_sec * sr)
cut_end = int((tail_sec - tail_margin) * sr) if tail_sec is not None else len(audio)
result = audio[cut_start:cut_end].copy()
fade_samples = int(sr * 0.1)
if cut_start > 0 and len(result) > fade_samples:
result[:fade_samples] *= np.linspace(0, 1, fade_samples)
if cut_end < len(audio) and len(result) > fade_samples:
result[-fade_samples:] *= np.linspace(1, 0, fade_samples)
return result
ちなみに、ダミーを前後に足すならref_text側もダミー込みで正確に書いておいたほうがいいです。実際の発話とref_textがずれると、話者の似せ具合がそのぶん落ちます。
品質チェックは発音に正規化してから比べる
生成した音声が狙いどおりかは、Whisperの書き起こしと、本来言わせたいテキストを突き合わせて判定します。
ただ、文字列そのままだと表記がいくらでも揺れます。「3.5」が「さんてんご」と書き起こされたり、英単語がカタカナになったり。なので、比較の前に、両方を発音の表現に正規化してから比べています。
def _normalize_for_asr(text, language="Japanese"):
text = re.sub(r'[、。!?!?ー~〜・,.\s 「」『』()\(\)\[\]\-_…]+', '', text)
if language == "Japanese":
text = re.sub(r'[A-Za-z]+', lambda m: _english_to_kana(m.group(0)), text)
text = re.sub(r'\d+(?:\.\d+)*', _num_to_kana, text)
return _to_hiragana(text)
elif language == "Chinese":
return ''.join(lazy_pinyin(text))
elif language == "Korean":
return Romanizer(text).romanize().replace(' ', '').lower()
else:
return unidecode(text).lower()
やっていることは言語ごとに違います。日本語は、記号を落として、英単語をカタカナに、数字を読みに変換してから、pykakasiでひらがなに寄せます。中国語はピンイン、韓国語はローマ字、欧州言語はアクセントを落として小文字化。どの言語も、いったん音の並びに落としてから比べる、という考え方です。
正規化したうえで、冒頭と末尾のそれぞれを2つの軸でチェックします。ひとつは発音が合っているか。冒頭・末尾の10文字ずつをSequenceMatcherで類似度比較して、冒頭の発音欠落や末尾の切れを見ます。もうひとつはダミーが残っていないか。冒頭にダミーの読みが残っていないかを前方一致で、末尾に残っていないかを後方一致で確認します。類似度の閾値は0.85で、韓国語だけローマ字化の揺れが大きいので0.9にしました。
判定はこの2つをAndで取っています。冒頭・末尾それぞれについて、発音が合っていて、かつダミーが残っていない、という条件です。
head_ok = _check_head(orig_norm, asr_norm, language) and _check_head_noise(orig_norm, asr_norm, language)
tail_ok = _check_tail(orig_norm, asr_norm, language) and _check_tail_noise(orig_norm, asr_norm, language)
発音の一致を見ている_check_headはこんな感じです。正規化した先頭10文字どうしをSequenceMatcherにかけて、閾値を超えているかどうかを返します。
def _check_head(orig_norm, asr_norm, language="Japanese"):
n = min(ASR_HEAD_CHECK_LEN, len(orig_norm), len(asr_norm))
if n == 0:
return True
similarity = SequenceMatcher(None, orig_norm[:n], asr_norm[:n]).ratio()
threshold = 0.9 if language == "Korean" else 0.85
return similarity >= threshold
当たりが出るまで、温度を下げながら回す
ここまでの処理を通しても、1回の生成でこの品質チェックを通る音声が出るとは限りません。声質が外れることもあれば、ダミーの検出に失敗してカットできないこともあります。なので、1つのセリフにつき、チェックを通る音声が出るまで生成をやり直します。
困ったのは、同じパラメータで何度回しても同じように外し続けることがある点でした。温度(temperature)を高くすると声のバリエーションは出ますが、そのぶん外れも増える。かといって最初から低くすると、ハマった悪い声から動きにくい。そこで、リトライが進むにつれて温度を下げていくスケジュールを組みました。
指定は環境変数で、temp:top_p:countの形式で書けるようにしています。
_raw_quality_schedule = os.getenv(
"TTS_QUALITY_RETRY_SCHEDULE",
"default:default:3,0.7:0.85:3,0.6:0.8:3",
)
def _parse_quality_retry_schedule(raw):
schedule = []
for entry in raw.split(","):
temp_raw, top_p_raw, count_raw = [p.strip() for p in entry.split(":")]
temp = None if temp_raw.lower() == "default" else float(temp_raw)
top_p = None if top_p_raw.lower() == "default" else float(top_p_raw)
schedule.extend((temp, top_p) for _ in range(int(count_raw)))
return schedule
この既定値だと、1〜3回目はリクエストで指定したパラメータのまま、4〜6回目はtemperature 0.7 / top_p 0.85、7〜9回目は0.6 / 0.8、という具合に、後半ほど保守的に振っていきます。品質チェックを通った時点で止めて、9回とも外したときは、その中で一番マシだった候補を返します。無音を返すより、多少劣化していても音声を返したほうが、動画制作のパイプラインが止まらないからです。
カットの成否も、このリトライに絡んでいます。冒頭と末尾の両方のダミーをWhisperが見つけられたときだけカットして品質チェックに回し、どちらかを見つけられなければ、その時点で失敗としてやり直す。それでも最後まで見つけられなかったときだけ、取れなかった側を、さきほどのVADで補います。
3ヶ月使ってみて
ここまで組んでも、リトライで拾いきれない外れは残りました。品質チェックの類似度スコアも声質のスコアも、明らかにおかしい音声が基準を通ってしまうことがあります。
コストもそれなりにかかります。リトライを重ねると、1文のセリフを出すのに5分、10分とかかることもありました。1発で当たれば数秒なので、外れ続けたときの差が大きい。
生成そのままで動画に使える品質を安定して出すことは、結局できませんでした。ダミーセリフでノイズを逃がして、Whisperで位置を取ってカットして、正規化した品質チェックとリトライで当たりを選ぶ。ここまで自動化しても最後は自分の耳で聞いて悪いものを弾く工程が要るので、それを外せないという前提でなら、動画のナレーションとして使えています。