概要
AI キャラクターに合う声を、文章の説明だけからローカルの音声合成モデルで作り、その声で好きな文を読み上げる。クラウドの音声合成サービスには、アニメのキャラクターのような高めでかわいい声がほとんど無い。そこで、声そのものをモデルで作る方法を調べ、Mac で試す手順をまとめる。
この記事で確かめるのは次の 2 点である。
- 「明るく高めの声の、元気な女の子」のような説明文だけで、キャラクターに使える声ができるか
- その声で、会話に使える速さで読み上げられるか
声を用意する方法の比較
キャラクターの声を用意する方法は、大きく 3 つある。
| 方法 | 声の自由度 | 必要なもの | 課題 |
|---|---|---|---|
| クラウドの音声合成の用意された声 | 低い | API キー | 落ち着いた大人の声が中心で、キャラクターらしい声が少ない |
| 声の学習 (ファインチューニング) | 高い | その声の録音 数十分 | 元になる録音が要る。実在の人の声を使うなら本人の同意が要る |
| 説明文から声を作る (Voice Design) | 高い | 説明文 | 新しい技術で、日本語の品質と速さを確かめる必要がある |
録音を用意せずにキャラクターの声を作れるのは、3 つ目の方法だけである。この記事では、説明文から声を作れる Qwen3-TTS を中心に試す。
音声合成モデルの仕組み
Qwen3-TTS は、言語モデルが文章の次の単語を予測するのと同じやり方で、音声を少しずつ予測する。公開されている説明では、次の 3 つの部品から成る。
- 音声 tokenizer は、波形を 1 秒あたり 12 個の離散トークン (複数の codebook の組) に変換し、逆にトークンから波形を復元する
- 言語モデルは、読み上げる文と声の条件を入力に取り、音声トークンを 1 つずつ予測する。トークンを順に出すので、文の合成が終わる前に最初の音声を返せる
- 声の条件の与え方で、モデルが分かれる。VoiceDesign は声の説明文を条件にし、Base は参照音声とその文を条件にする
Base は、参照音声を「すでに話された部分」として言語モデルの入力の先頭に置き、その続きを予測する。続きは同じ話者が話すのが自然なので、声が引き継がれる。参照音声の文も渡すのは、モデルが音声と文字の対応を取り、声の特徴と文の中身を分けて扱うためである。
比較の候補として、日本語に特化した Style-Bert-VITS2 JP-Extra も挙げておく。こちらは別の型のモデルで、文を音素に変換し、BERT で文脈から抑揚を推定し、VITS で波形を直接作る。声は話者ごとの学習で決まるので、新しい声にはその声の録音での追加学習が要る。
| 項目 | Qwen3-TTS | Style-Bert-VITS2 JP-Extra |
|---|---|---|
| 生成の方式 | 言語モデルが音声トークンを予測 | VITS が波形を直接生成 |
| 新しい声の作り方 | 説明文 (VoiceDesign) か数秒の参照音声 (Base) | その声の録音で追加学習 |
| 日本語 | 対応する 10 言語の 1 つ | 日本語に特化 |
| ライセンス | Apache-2.0 | コードは AGPL-3.0、配布モデルはコーパスごとの規約 |
日本語のアクセントが足りないときは、VoiceDesign で作った音声を学習データにして、Style-Bert-VITS2 で読み上げる組み合わせも取れる。
声を固定する方法
声は VoiceDesign で 1 回だけ作り、その後はその音声を参照にして Base で読み上げる。VoiceDesign は音声トークンを確率で選ぶので、同じ説明文でも呼ぶたびに違う声になる。文ごとに VoiceDesign を呼ぶと、文ごとに別人が話すことになる。
- 声の生成: VoiceDesign に説明文と短い文を渡して、候補を何本か作る。聴き比べて 1 本を選ぶ
- 読み上げ: 選んだ候補の音声とその文を Base に渡し、読ませたい文を読ませる
この手順では、キャラクターの声の正体は、選んだ候補の wav ファイルになる。説明文は、あとで作り直すときの記録として残しておく。Base には、参照音声から声の特徴を 1 回だけ取り出して使い回す関数 (create_voice_clone_prompt) があるので、文ごとに参照音声を処理し直さなくてよい。
動作環境
| 項目 | 内容 |
|---|---|
| マシン | Apple Silicon の Mac (メモリー 64 GB) |
| GPU | PyTorch の MPS (Metal) |
| Python | 3.12 |
| モデル | Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign、Qwen/Qwen3-TTS-12Hz-1.7B-Base |
公式の例は NVIDIA の GPU (CUDA) 向けだけである。Mac では device_map を mps にし、CUDA 専用の FlashAttention 2 は使わない。
手順
1. 環境を作る
mkdir qwen3-tts && cd qwen3-tts
python3.12 -m venv .venv && source .venv/bin/activate
pip install -U qwen-tts soundfile初回の実行で、モデル (1.7B を 2 つ、合わせて数 GB) を Hugging Face からダウンロードする。
2. 試しの script を置く
次の内容を try_tts.py として保存する。INSTRUCT がキャラクターの声の説明文で、好きな説明に書き換えてよい。
import sys, time
import torch
import soundfile as sf
from qwen_tts import Qwen3TTSModel
DEVICE = "mps"
DTYPE = torch.float16 # エラーや雑音が出たら torch.float32 にする
INSTRUCT = "明るく高めの声の、アニメに出てくる元気な女の子。語尾がはずみ、親しみやすい話し方。"
REF_TEXT = "こんにちは!今日はどんなお手伝いをしましょうか?"
LINES = [
"いらっしゃいませ!ご予約のお名前を教えてください。",
"えっと、その件でしたら、明日の午後にはお返事できると思います。",
"わあ、ありがとうございます!とってもうれしいです。",
]
def load(model_id):
t = time.perf_counter()
m = Qwen3TTSModel.from_pretrained(model_id, device_map=DEVICE, dtype=DTYPE)
print(f"load {model_id}: {time.perf_counter() - t:.1f}s")
return m
def report(name, wav, sr, sec):
dur = len(wav) / sr
print(f"{name}: 合成 {sec:.2f}s / 音声 {dur:.2f}s / 実時間比 {sec / dur:.2f}")
if sys.argv[1] == "design":
m = load("Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign")
for i in range(1, 4):
t = time.perf_counter()
wavs, sr = m.generate_voice_design(text=REF_TEXT, language="Japanese", instruct=INSTRUCT)
report(f"design_{i}.wav", wavs[0], sr, time.perf_counter() - t)
sf.write(f"design_{i}.wav", wavs[0], sr)
else:
ref = sys.argv[2]
m = load("Qwen/Qwen3-TTS-12Hz-1.7B-Base")
prompt = m.create_voice_clone_prompt(ref_audio=ref, ref_text=REF_TEXT)
for i, line in enumerate(LINES, 1):
t = time.perf_counter()
wavs, sr = m.generate_voice_clone(text=line, language="Japanese", voice_clone_prompt=prompt)
report(f"clone_{i}.wav", wavs[0], sr, time.perf_counter() - t)
sf.write(f"clone_{i}.wav", wavs[0], sr)3. 説明文から声の候補を作る
python try_tts.py designdesign_1.wav から design_3.wav の 3 本ができる。同じ説明文でも、3 本はそれぞれ違う声になる。聴き比べて、キャラクターに合う 1 本を選ぶ。
4. 選んだ声で別の文を読ませる
python try_tts.py clone design_2.wavclone_1.wav から clone_3.wav ができる。3 本が同じ声に聞こえれば、声を固定できている。
計測結果
まだ計測していない。試したら、下の表に値と聞こえ方を書き込む。会話に使える目安は、実時間比 (合成時間 ÷ 音声の長さ) が 1 を下回ることである。
| ファイル | モデル | 合成時間 (秒) | 音声の長さ (秒) | 実時間比 |
|---|---|---|---|---|
| design_1.wav | VoiceDesign | 9.18 | 4.80 | 1.91 |
| design_2.wav | VoiceDesign | 3.84 | 4.00 | 0.96 |
| design_3.wav | VoiceDesign | 4.66 | 5.04 | 0.92 |
| clone_1.wav | Base | 3.90 | 3.68 | 1.06 |
| clone_2.wav | Base | 5.68 | 5.92 | 0.96 |
| clone_3.wav | Base | 3.75 | 3.76 | 1.00 |
モデルの読み込み時間、dtype (float16 か float32)、メモリーの使用量もここに書き足す。
会話で使うときの考え方
AI キャラクターとの音声会話では、話し終えてから最初の声が聞こえるまでの時間が体験を決める。作った声を会話に使うときは、次の点を押さえる。
- 文ごとに合成する: 合成の時間は文の長さにほぼ比例する。応答の全文を待たずに文ごとに合成し、最初の文ができた時点で再生を始める
- 実時間比を 1 未満にする: 実時間比が 1 を下回れば、1 文目の再生中に 2 文目の合成が終わり、声が途切れない
- モデルは常駐させる: モデルの読み込みには時間がかかるので、リクエストのたびに読み込まない。GPU を持つ別のプロセスに常駐させ、アプリからは接続を張ったまま文を送る
- 参照音声の処理は 1 回にする: 声の特徴は最初に 1 回だけ取り出し、以後の文で使い回す
Streamingをためしてみる
仕組み
- 今の qwen-tts には、作りながら音声を返す streaming がありません。non_streaming_mode=False は文字の入力を模すだけで、出力は最後にまとめて返る、と package の中に書かれています。
- そこで、言語モデルが音のトークンを 1 つ出すたびに横取りしています。4 フレーム (約 0.3 秒) たまるごとに、音声に戻します。
- 戻すときは、つなぎ目に雑音が出ないよう、前の 25 フレームを文脈として付けてから、新しい部分だけを切り出します。qwen-tts の中にも、同じ方法で分けて戻す関数があり、それに倣いました。
出力
1 文ごとに、次のように表示します。1 回目は GPU の準備が入るので、計測から外しています。
stream_1.wav: 最初の音声 0.xxs / 合成 x.xxs / 音声 x.xxs / 実時間比 x.xx / 途切れ n 回 計 x.xxs
- 「最初の音声」は、合成を始めてから最初の 0.3 秒分ができるまでの時間です。
- 「途切れ」は、最初の音声ができた時点で再生を始めたと仮定して、次の音声が再生に間に合わなかった回数と、その合計時間です。
- stream_1〜3.wav は、切り出した音声をつないだものです。つなぎ目で音がぷつっと切れていないかを聴いてください。
次の内容をstream_tts.pyとして保存する。
"""Qwen3-TTS の streaming の試し。
qwen-tts は生成が終わってから音声を返す。ここでは、言語モデルが音のトークンを
1 ステップ出すたびに横取りし、CHUNK_FRAMES 個たまったら音声に戻す。
最初の音声ができるまでの時間と、再生が途切れるかを測る。
python stream_tts.py design_2.wav
stream_N.wav は音声をつないだもの、live_N.wav は途切れを無音で再現したもの。
"""
import functools, sys, time
import numpy as np
import torch
import soundfile as sf
from qwen_tts import Qwen3TTSModel
DEVICE = "mps"
DTYPE = torch.float16
REF_TEXT = "こんにちは!今日はどんなお手伝いをしましょうか?"
LINES = [
"いらっしゃいませ!ご予約のお名前を教えてください。",
"えっと、その件でしたら、明日の午後にはお返事できると思います。",
"わあ、ありがとうございます!とってもうれしいです。",
]
CHUNK_FRAMES = 4 # 何フレームごとに音声に戻すか (12.5 フレーム = 約 1 秒)
LEFT_CONTEXT = 25 # 音声に戻すときに前に付けるフレーム数 (つなぎ目の雑音を防ぐ)
class Streamer:
"""talker の forward を包み、1 ステップごとの音のトークンを受け取って音声に戻す。"""
def __init__(self, model, ref_code):
self.m = model
self.tok = model.model.speech_tokenizer
self.eos = model.model.config.talker_config.codec_eos_token_id
self.ref_code = ref_code # 参照音声のトークン。最初の chunk の前の文脈に使う
def run(self, text, prompt):
self.frames, self.chunks, self.sent = [], [], 0
self.done = False
talker = self.m.model.talker
orig = talker.forward
# transformers は forward の引数の名前で generate の引数を検査するので、
# wraps で元の signature を引き継ぐ
@functools.wraps(orig)
def hooked(*args, **kwargs):
out = orig(*args, **kwargs)
codes = out.hidden_states[1]
if codes is not None and not self.done:
if int(codes[0, 0]) == self.eos:
self.done = True
self._flush(final=True)
else:
self.frames.append(codes[0].detach())
self._flush(final=False)
return out
talker.forward = hooked
self.t0 = time.perf_counter()
try:
self.m.generate_voice_clone(text=text, language="Japanese", voice_clone_prompt=prompt)
finally:
talker.forward = orig
if not self.done:
self._flush(final=True)
return self.chunks
def _flush(self, final):
new = len(self.frames) - self.sent
if new <= 0 or (new < CHUNK_FRAMES and not final):
return
codes = torch.stack(self.frames)
if self.ref_code is not None:
codes = torch.cat([self.ref_code.to(codes.device), codes])
base = len(self.ref_code)
else:
base = 0
end = base + len(self.frames)
start = base + self.sent
ctx_start = max(0, start - LEFT_CONTEXT)
wavs, sr = self.tok.decode([{"audio_codes": codes[ctx_start:end]}])
wav = wavs[0]
per_frame = len(wav) / (end - ctx_start)
wav = wav[int(round((start - ctx_start) * per_frame)):]
self.sr = sr
self.chunks.append((time.perf_counter() - self.t0, wav))
self.sent = len(self.frames)
def report(name, chunks, sr):
# 最初の chunk ができた時点で再生を始めたとして、各 chunk が再生に間に合ったかを数える。
# 間に合わなかった分は無音を挟み、実際に聞こえる音声を live として返す
first = chunks[0][0]
play_at, stalls, stall_sec = first, 0, 0.0
live = [np.zeros(int(first * sr), dtype=np.float32)] # 合成を始めてから最初の音声までの無音
for t, wav in chunks:
if t > play_at:
stalls += 1
stall_sec += t - play_at
live.append(np.zeros(int((t - play_at) * sr), dtype=np.float32))
play_at = t
live.append(wav.astype(np.float32))
play_at += len(wav) / sr
total = sum(len(w) for _, w in chunks) / sr
gen = chunks[-1][0]
print(f"{name}: 最初の音声 {first:.2f}s / 合成 {gen:.2f}s / 音声 {total:.2f}s / "
f"実時間比 {gen / total:.2f} / 途切れ {stalls} 回 計 {stall_sec:.2f}s")
return np.concatenate(live)
m = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-Base", device_map=DEVICE, dtype=DTYPE)
prompt = m.create_voice_clone_prompt(ref_audio=sys.argv[1], ref_text=REF_TEXT)
s = Streamer(m, prompt[0].ref_code)
s.run(LINES[0], prompt) # 1 回目は GPU の準備が入るので、計測から外す
for i, line in enumerate(LINES, 1):
chunks = s.run(line, prompt)
live = report(f"stream_{i}.wav", chunks, s.sr)
sf.write(f"stream_{i}.wav", np.concatenate([w for _, w in chunks]), s.sr)
sf.write(f"live_{i}.wav", live, s.sr) # 途切れを無音で再現した、実際に聞こえる音声stream_1.wav: 最初の音声 0.41s / 合成 4.65s / 音声 4.16s / 実時間比 1.12 / 途切れ 12 回 計 0.40s
Setting `pad_token_id` to `eos_token_id`:2150 for open-end generation.
stream_2.wav: 最初の音声 0.41s / 合成 6.20s / 音声 5.20s / 実時間比 1.19 / 途切れ 16 回 計 0.68s
Setting `pad_token_id` to `eos_token_id`:2150 for open-end generation.
stream_3.wav: 最初の音声 0.43s / 合成 5.08s / 音声 4.24s / 実時間比 1.20 / 途切れ 13 回 計 0.49s
途切れた部分も無音にした音声:
権利と利用の注意
- Base は、数秒の録音があれば実在の人の声も写せる。本人の同意の無い録音を参照音声にしない
- Qwen3-TTS は Apache-2.0 で公開されている。Style-Bert-VITS2 は、コードが AGPL-3.0、配布モデルはコーパスごとの規約に従うので、商用で使う前に確かめる