メインコンテンツへスキップ
← ブログに戻る
チュートリアル2026年2月22日by K.hirano

Qwen3-TTS に声クローン機能を追加した話 — Gradio UI・自分の声で TTS・ハマり全記録

自宅 RTX 4070 Ti の Qwen3-TTS に Gradio UI と声クローンを追加した記録。モデルの種類の罠・64KB制限・VoiceClonePromptItemの落とし穴・Gradio初期化順序まで、5件のハマりを全部書く。

#AI#TTS#Qwen3#声クローン#Gradio#FastAPI#音声合成#Python#ローカルAI#qwen3-tts

RTX 4070 Ti 自宅サーバーに Qwen3-TTS を入れた実録 — API変更で3回詰まった話BlogRTX 4070 Ti 自宅サーバーに Qwen3-TTS を入れた実録 — API変更で3回詰まった話自宅の RTX 4070 Ti に Qwen3-TTS をデプロイした記録。クラス名・メソッド名・戻り値の形式が全部変わっていて3回詰まった。同じ罠に落ちる人が減るよう全部書く。で、自宅サーバーに Qwen3-TTS を FastAPI で動かすところまで書きました。

動いた。動いたのですが、「curl で叩くだけ」というのはさすがに使いにくい。声クローンもやってみたい。自分の声で TTS できるなら、いろいろ使い道がある。

ということで、続きをやりました。今回追加したのは3つです。

  1. Gradio WebUI を既存の FastAPI に同居させる
  2. 声クローン機能 を実装する(自分の声で読み上げさせる)
  3. 声プロファイル登録 で次回以降は音声ファイル不要にする

前回と同じく、詰まったところを全部書きます。

まずモデルの種類を整理しておく

前回構築したのは CustomVoice モデルです。「声クローンもこれでできるだろう」と実装を進めたら、こんなエラーが出ました。

terminal
tts_model_type: custom_voice does not support generate_voice_clone,
Please check Model Card or Readme for more details.

そういうことか。Qwen3-TTS には実は3種類のモデルがあります。

モデル名できることVRAM
Qwen3-TTS-12Hz-1.7B-CustomVoiceプリセット9話者のみ~4GB
Qwen3-TTS-12Hz-1.7B-Base声クローン対応~4GB
Qwen3-TTS-12Hz-1.7B-VoiceDesignテキスト指示で声を設計~4GB

声クローンには Base モデルが必要です。RTX 4070 Ti(12GB VRAM)なら2モデル同時ロード(合計 ~8GB)で両方使えます。ギリギリですが、収まります。

Gradio を FastAPI に同居させる

gr.mount_gradio_app() の1行で既存の FastAPI にマウントできます。同一プロセスなのでモデルを共有できて、ポートを増やす必要がない。

python
import gradio as gr
from fastapi import FastAPI

app = FastAPI(lifespan=lifespan)  # 既存の FastAPI アプリ

with gr.Blocks(title="Qwen3-TTS") as gradio_app:
    # UI コンポーネントを定義
    pass

# /ui に統合(ポート追加不要)
gr.mount_gradio_app(app, gradio_app, path="/ui")

これで構成はシンプルになります。

terminal
http://server:9001/v1/tts/synthesize  ← REST API(従来通り)
http://server:9001/ui                 ← Gradio WebUI(新規)

詰まり① 64KB 制限が音声ファイルアップロードをブロック

テキスト入力の DoS 対策として 64KB のリクエストサイズ制限を前回の実装に入れていましたが、これが Gradio の音声ファイルアップロードに 413 エラーを返していました。音声ファイルは数百KB あるので当然です。

python
# ❌ 全パスに 64KB 制限をかけると音声アップロードが詰まる
@app.middleware("http")
async def limit_request_size(request: Request, call_next):
    content_length = request.headers.get("content-length")
    if content_length and int(content_length) > 64 * 1024:
        raise HTTPException(413, "リクエストサイズが上限を超えています")
    return await call_next(request)

# ✅ Gradio のパスは除外する
@app.middleware("http")
async def limit_request_size(request: Request, call_next):
    if request.url.path.startswith("/ui"):  # ← /ui 以下は通す
        return await call_next(request)
    content_length = request.headers.get("content-length")
    if content_length and int(content_length) > 64 * 1024:
        raise HTTPException(413, "リクエストサイズが上限を超えています")
    return await call_next(request)

声クローンの実装

2モデル同時ロード

python
_model_custom = None  # CustomVoice(9話者)
_model_base   = None  # Base(声クローン)

@asynccontextmanager
async def lifespan(app: FastAPI):
    global _model_custom, _model_base
    from qwen_tts import Qwen3TTSModel

    _model_custom = Qwen3TTSModel.from_pretrained(
        "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
        device_map="cuda:0", dtype=torch.bfloat16, attn_implementation="sdpa",
    )
    _model_base = Qwen3TTSModel.from_pretrained(
        "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
        device_map="cuda:0", dtype=torch.bfloat16, attn_implementation="sdpa",
    )
    yield

声クローンの呼び出し方

Gradio から受け取る音声データは (サンプルレート, numpy配列) のタプルです。そのまま渡せるわけではなく、正規化が必要です。

python
# Gradio から受け取った音声を正規化
sr_ref, audio_data = ref_audio_from_gradio

# int16 → float32(Gradio は int16 で返すことがある)
if audio_data.dtype == np.int16:
    audio_data = audio_data.astype(np.float32) / 32768.0
# ステレオ → モノラル
if audio_data.ndim == 2:
    audio_data = audio_data.mean(axis=1)

# 声クローン実行
wavs, sr = _model_base.generate_voice_clone(
    text="読み上げたいテキスト",
    language="Japanese",
    ref_audio=(audio_data, sr_ref),
    ref_text="参照音声で話した内容の書き起こし",  # ICL モード時は必須
    x_vector_only_mode=False,
)

ICL モードと書き起こし不要モードの違い

モードx_vector_only_mode書き起こし精度
ICL(推奨)False必須高い(リズム・イントネーションまで再現)
X-vector のみTrue不要声色のみ(ニュアンスは落ちる)

ICL モードは「参照音声の続きを生成する」仕組みなので、書き起こしと音声が一字一句一致している必要があります。先に読む文章を決めてから録音するのが正しい手順です。即興で録った音声を後から書き起こすのは逆順で、精度が落ちます。

詰まり② VoiceClonePromptItem はリストで渡す

python
# ❌ エラー: 'VoiceClonePromptItem' object is not subscriptable
wavs, sr = model.generate_voice_clone(
    voice_clone_prompt=profile_item  # 単体オブジェクトをそのまま渡すとエラー
)

# ✅ リストに包む
wavs, sr = model.generate_voice_clone(
    voice_clone_prompt=[profile_item]  # 必ずリスト
)

generate_voice_clone はバッチ処理を前提とした設計で、常にリストで受け取ります。単体で渡すとサブスクリプタブルではないというエラーになります。

声プロファイルの登録・永続化

毎回参照音声をアップロードするのは面倒です。create_voice_clone_prompt() でプロファイルを事前生成して torch.save() で保存すると、次回以降は音声ファイル不要になります。

python
from pathlib import Path
import torch

PROFILES_DIR = Path("~/qwen3-tts/profiles")

def save_voice_profile(name: str, ref_audio, ref_text: str):
    """参照音声からプロファイルを作成して保存"""
    audio_data, sr_ref = ref_audio

    items = _model_base.create_voice_clone_prompt(
        ref_audio=(audio_data, sr_ref),
        ref_text=ref_text or None,
        x_vector_only_mode=False,
    )

    path = PROFILES_DIR / f"{name}.pt"
    torch.save(items[0], path)  # VoiceClonePromptItem を保存

def load_profiles() -> dict:
    """起動時に全プロファイルをメモリにロード"""
    profiles = {}
    for path in PROFILES_DIR.glob("*.pt"):
        profiles[path.stem] = torch.load(
            path, map_location="cpu", weights_only=False
        )
    return profiles

# 使用時
wavs, sr = _model_base.generate_voice_clone(
    text="テキスト",
    language="Japanese",
    voice_clone_prompt=[profiles["my_voice"]],  # ← リストに包む
)

VoiceClonePromptItemref_spk_embedding(X-vector)と ref_code(音声トークン列)の PyTorch テンソルを持つ dataclass で、torch.save() で完全に永続化できます。

Gradio UI のプロファイル表示問題

詰まり③ 起動時にプロファイルがドロップダウンに出ない

Gradio の Blocks は Python モジュールのインポート時(lifespan より前)に構築されます。そのため、起動時点ではプロファイルのリストがまだ空です。

python
# ❌ この時点では _voice_profiles はまだ空
with gr.Blocks() as gradio_app:
    speaker_dropdown = gr.Dropdown(
        choices=list(_voice_profiles.keys()),  # 空リスト
    )
# ↓ lifespan でプロファイルをロード(Gradio 構築より後)

解決策: gradio_app.load() イベントでページ読み込み時に動的更新します。

python
with gr.Blocks() as gradio_app:
    speaker_dropdown = gr.Dropdown(choices=[])

    # ページ読み込み時に最新プロファイル一覧を取得
    gradio_app.load(
        fn=lambda: gr.update(choices=list(_voice_profiles.keys())),
        outputs=[speaker_dropdown],
    )

詰まり④ gradio_app.load() は with ブロック内でしか使えない

python
with gr.Blocks() as gradio_app:
    pass  # ← with ブロックを閉じた後に...

# ❌ AttributeError: Cannot call load outside of a gradio.Blocks context.
gradio_app.load(fn=..., outputs=[...])
python
with gr.Blocks() as gradio_app:
    # ✅ with ブロックの内側に書く
    gradio_app.load(fn=..., outputs=[...])

エラーメッセージが親切なので気づきやすいですが、with の外に書いたコードが動かない系のバグは少し探しました。

実測パフォーマンス(RTX 4070 Ti 2モデル同時)

項目
CustomVoice VRAM3,985 MiB
Base VRAM4,009 MiB
合計 VRAM 使用量~8,000 MiB / 12,288 MiB
空き VRAM(推論時)~1.4GB
声クローン生成時間(400文字)約1〜2分

2モデルで 12GB のうち ~8GB を使います。RTX 4070 Ti はギリギリ収まりますが、RTX 3080(10GB)以下は厳しいかもしれません。声クローン生成は400文字で1〜2分かかります。速くはないです。バッチで流す前提で使うべき速度感です。

完成した構成

terminal
http://server:9001/
├── /v1/tts/synthesize  ← REST API(プリセット9話者)
├── /v1/tts/clone       ← REST API(声クローン)
├── /v1/voices          ← REST API(プロファイル管理)
├── /health             ← ヘルスチェック
├── /docs               ← Swagger UI
└── /ui                 ← Gradio WebUI
    ├── 話者選択タブ    ← 9話者 + 登録済みプロファイル
    └── 声クローンタブ  ← 参照音声アップロード + プロファイル保存

ハマりポイントまとめ

#症状原因対処
1custom_voice does not support generate_voice_cloneCustomVoice は声クローン非対応Base モデルを別途ロード
2音声アップロードが 413 エラー64KB 制限が Gradio にも適用/ui パスを除外
3'VoiceClonePromptItem' object is not subscriptable単体オブジェクトをそのまま渡した[item] とリストに包む
4起動時にプロファイルがドロップダウンに出ないBlocks が lifespan 前に構築されるgradio_app.load() で動的更新
5Cannot call load outside of a gradio.Blocks context.load() を with ブロック外に書いたwith 内に移動

ブラウザから音声をアップロードして、自分の声で読み上げさせる。それが自宅のサーバーラックで動いている。

前回の記事で「3年前はこんなことになるとは思わなかった」と書きましたが、今回また同じことを思いました。声のクローニングが、VRAM 8GB と Python で動く。

同じサーバーに乗っている ComfyUI や ACE-Step と合わせると、自宅のラックがそこそこ怖いものになってきました。ローカル AI 全般の構成が気になる方は ローカルAI vs クラウドAI、2026年の今どちらが本命なのかを正直に語るBlogローカルAI vs クラウドAI、2026年の今どちらが本命なのかを正直に語る2026年のAI環境の中で、ローカルAIとクラウドAIのメリット・デメリットを徹底比較。コスト、プライバシー、性能、手軽さを管理人の視点で解説します。も見てみてください。

使いこなせる側でいたいとは思います。このスピードを前にすると、少し息苦しいのも本当のことです。

関連記事

関連リンク

  • 実際の構成を探すなら、GPU 比較ページやローカルLLM向け構成記事もあわせて見ると判断しやすいです。
  • ハードウェア候補は用途別の AI 構成ガイドからたどると、単体製品より違和感なく検討できます。

この記事を書いた人

HW系エンジニアとして20年以上、10,000件を超える顧客訪問と2,000件を超える単独ソリューション実績。AIツールを使った個人開発やIoT農園など、Raspberry Piを使ったオートメーション化なども実践中です!エンジニア専門結婚相談所も運営中、ClaudeCodeで解決できない心の課題も解決いたします!

AIに発言だけ見せて「この2体は誰だ」と聞いたらGeminiを当てた。でも根拠はこちらのバグだった
2026年8月13日
事例紹介

AIに発言だけ見せて「この2体は誰だ」と聞いたらGeminiを当てた。でも根拠はこちらのバグだった

AI同士に正体を伏せたまま20回会話させ、その発言ログだけを第三のAIに渡して「同じモデルか、どちらが上か」を推理させました。正体は当てましたが、根拠にはこちらの設定ミスが混ざっていました。同じ勝負を2回撮って分かったことの全記録です。

#AIコロシアム#AIアリーナ#AI#LLM比較#ベンチマーク
スマホだけで英語YouTubeを同時通訳:Gemini 3.5 Live Translateを耳で使う実践メモ
2026年6月12日
チュートリアル

スマホだけで英語YouTubeを同時通訳:Gemini 3.5 Live Translateを耳で使う実践メモ

2026年6月公開のGemini 3.5 Live Translateを、スマホとBluetoothイヤホンだけで実機検証。スピーカーだと待たされるのにイヤホンだとほぼ完璧な同時通訳になる——その分かれ目と、英語YouTubeを耳で理解する一番実用的な使い方を共有します。

#Gemini#翻訳#AI#スマホ活用#tutorial
Hy-MT2 + OllamaをAPIとして使う——DeepL API代替ローカル翻訳をPython20行で作る
2026年5月25日
開発ログ

Hy-MT2 + OllamaをAPIとして使う——DeepL API代替ローカル翻訳をPython20行で作る

Hy-MT2+OllamaをREST API化し、DeepL代替の翻訳・用語辞書・Markdown保護をコピペ実装。月額コスト比較も掲載。

#hy-mt2#Ollama#deepl代替#Python#ローカル翻訳
コードでわかる、エンジニアの婚活が積み上がらない3つのバグ
2026年5月1日
開発ログ

コードでわかる、エンジニアの婚活が積み上がらない3つのバグ

for文に隠れたバグから、エンジニアの婚活が動かない本当の理由を読み解く番外編コラム。

#dev-log#婚活#エンジニア#Python#metamarry

関連AIツール

META-MARK × AI

ローカルAIを動かすGPU、ちゃんと選べていますか?

VRAM・性能・コスパをMetaScoreで数値化。AIアプリ別の推奨ハードウェア要件も確認できます。