AI キャラクターに合う声をローカルの音声合成モデルで作る

生成AI

概要

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-TTSStyle-Bert-VITS2 JP-Extra
生成の方式言語モデルが音声トークンを予測VITS が波形を直接生成
新しい声の作り方説明文 (VoiceDesign) か数秒の参照音声 (Base)その声の録音で追加学習
日本語対応する 10 言語の 1 つ日本語に特化
ライセンスApache-2.0コードは AGPL-3.0、配布モデルはコーパスごとの規約

日本語のアクセントが足りないときは、VoiceDesign で作った音声を学習データにして、Style-Bert-VITS2 で読み上げる組み合わせも取れる。

声を固定する方法

声は VoiceDesign で 1 回だけ作り、その後はその音声を参照にして Base で読み上げる。VoiceDesign は音声トークンを確率で選ぶので、同じ説明文でも呼ぶたびに違う声になる。文ごとに VoiceDesign を呼ぶと、文ごとに別人が話すことになる。

  1. 声の生成: VoiceDesign に説明文と短い文を渡して、候補を何本か作る。聴き比べて 1 本を選ぶ
  2. 読み上げ: 選んだ候補の音声とその文を Base に渡し、読ませたい文を読ませる

この手順では、キャラクターの声の正体は、選んだ候補の wav ファイルになる。説明文は、あとで作り直すときの記録として残しておく。Base には、参照音声から声の特徴を 1 回だけ取り出して使い回す関数 (create_voice_clone_prompt) があるので、文ごとに参照音声を処理し直さなくてよい。

動作環境

項目内容
マシンApple Silicon の Mac (メモリー 64 GB)
GPUPyTorch の MPS (Metal)
Python3.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 design

design_1.wav から design_3.wav の 3 本ができる。同じ説明文でも、3 本はそれぞれ違う声になる。聴き比べて、キャラクターに合う 1 本を選ぶ。

4. 選んだ声で別の文を読ませる

python try_tts.py clone design_2.wav

clone_1.wav から clone_3.wav ができる。3 本が同じ声に聞こえれば、声を固定できている。

計測結果

まだ計測していない。試したら、下の表に値と聞こえ方を書き込む。会話に使える目安は、実時間比 (合成時間 ÷ 音声の長さ) が 1 を下回ることである。

ファイルモデル合成時間 (秒)音声の長さ (秒)実時間比
design_1.wavVoiceDesign9.184.801.91
design_2.wavVoiceDesign3.844.000.96
design_3.wavVoiceDesign4.665.040.92
clone_1.wavBase3.903.681.06
clone_2.wavBase5.685.920.96
clone_3.wavBase3.753.761.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、配布モデルはコーパスごとの規約に従うので、商用で使う前に確かめる

参考文献

関連記事

カテゴリー

アーカイブ

Lang »