自宅 RTX 4070 Ti の Qwen3-TTS に Gradio UI と声クローンを追加した記録。モデルの種類の罠・64KB制限・VoiceClonePromptItemの落とし穴・Gradio初期化順序まで、5件のハマりを全部書く。
BlogRTX 4070 Ti 自宅サーバーに Qwen3-TTS を入れた実録 — API変更で3回詰まった話自宅の RTX 4070 Ti に Qwen3-TTS をデプロイした記録。クラス名・メソッド名・戻り値の形式が全部変わっていて3回詰まった。同じ罠に落ちる人が減るよう全部書く。→で、自宅サーバーに Qwen3-TTS を FastAPI で動かすところまで書きました。
動いた。動いたのですが、「curl で叩くだけ」というのはさすがに使いにくい。声クローンもやってみたい。自分の声で TTS できるなら、いろいろ使い道がある。
ということで、続きをやりました。今回追加したのは3つです。
前回と同じく、詰まったところを全部書きます。
前回構築したのは CustomVoice モデルです。「声クローンもこれでできるだろう」と実装を進めたら、こんなエラーが出ました。
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)で両方使えます。ギリギリですが、収まります。
gr.mount_gradio_app() の1行で既存の FastAPI にマウントできます。同一プロセスなのでモデルを共有できて、ポートを増やす必要がない。
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")
これで構成はシンプルになります。
http://server:9001/v1/tts/synthesize ← REST API(従来通り)
http://server:9001/ui ← Gradio WebUI(新規)
テキスト入力の DoS 対策として 64KB のリクエストサイズ制限を前回の実装に入れていましたが、これが Gradio の音声ファイルアップロードに 413 エラーを返していました。音声ファイルは数百KB あるので当然です。
# ❌ 全パスに 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)
_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配列) のタプルです。そのまま渡せるわけではなく、正規化が必要です。
# 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,
)
| モード | x_vector_only_mode | 書き起こし | 精度 |
|---|---|---|---|
| ICL(推奨) | False | 必須 | 高い(リズム・イントネーションまで再現) |
| X-vector のみ | True | 不要 | 声色のみ(ニュアンスは落ちる) |
ICL モードは「参照音声の続きを生成する」仕組みなので、書き起こしと音声が一字一句一致している必要があります。先に読む文章を決めてから録音するのが正しい手順です。即興で録った音声を後から書き起こすのは逆順で、精度が落ちます。
VoiceClonePromptItem はリストで渡す# ❌ エラー: '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() で保存すると、次回以降は音声ファイル不要になります。
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"]], # ← リストに包む
)
VoiceClonePromptItem は ref_spk_embedding(X-vector)と ref_code(音声トークン列)の PyTorch テンソルを持つ dataclass で、torch.save() で完全に永続化できます。
Gradio の Blocks は Python モジュールのインポート時(lifespan より前)に構築されます。そのため、起動時点ではプロファイルのリストがまだ空です。
# ❌ この時点では _voice_profiles はまだ空
with gr.Blocks() as gradio_app:
speaker_dropdown = gr.Dropdown(
choices=list(_voice_profiles.keys()), # 空リスト
)
# ↓ lifespan でプロファイルをロード(Gradio 構築より後)
解決策: gradio_app.load() イベントでページ読み込み時に動的更新します。
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 ブロック内でしか使えないwith gr.Blocks() as gradio_app:
pass # ← with ブロックを閉じた後に...
# ❌ AttributeError: Cannot call load outside of a gradio.Blocks context.
gradio_app.load(fn=..., outputs=[...])
with gr.Blocks() as gradio_app:
# ✅ with ブロックの内側に書く
gradio_app.load(fn=..., outputs=[...])
エラーメッセージが親切なので気づきやすいですが、with の外に書いたコードが動かない系のバグは少し探しました。
| 項目 | 値 |
|---|---|
| CustomVoice VRAM | 3,985 MiB |
| Base VRAM | 4,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分かかります。速くはないです。バッチで流す前提で使うべき速度感です。
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話者 + 登録済みプロファイル
└── 声クローンタブ ← 参照音声アップロード + プロファイル保存
| # | 症状 | 原因 | 対処 |
|---|---|---|---|
| 1 | custom_voice does not support generate_voice_clone | CustomVoice は声クローン非対応 | Base モデルを別途ロード |
| 2 | 音声アップロードが 413 エラー | 64KB 制限が Gradio にも適用 | /ui パスを除外 |
| 3 | 'VoiceClonePromptItem' object is not subscriptable | 単体オブジェクトをそのまま渡した | [item] とリストに包む |
| 4 | 起動時にプロファイルがドロップダウンに出ない | Blocks が lifespan 前に構築される | gradio_app.load() で動的更新 |
| 5 | Cannot call load outside of a gradio.Blocks context | .load() を with ブロック外に書いた | with 内に移動 |
ブラウザから音声をアップロードして、自分の声で読み上げさせる。それが自宅のサーバーラックで動いている。
前回の記事で「3年前はこんなことになるとは思わなかった」と書きましたが、今回また同じことを思いました。声のクローニングが、VRAM 8GB と Python で動く。
同じサーバーに乗っている ComfyUI や ACE-Step と合わせると、自宅のラックがそこそこ怖いものになってきました。ローカル AI 全般の構成が気になる方は
BlogローカルAI vs クラウドAI、2026年の今どちらが本命なのかを正直に語る2026年のAI環境の中で、ローカルAIとクラウドAIのメリット・デメリットを徹底比較。コスト、プライバシー、性能、手軽さを管理人の視点で解説します。→も見てみてください。
使いこなせる側でいたいとは思います。このスピードを前にすると、少し息苦しいのも本当のことです。
HW系エンジニアとして20年以上、10,000件を超える顧客訪問と2,000件を超える単独ソリューション実績。AIツールを使った個人開発やIoT農園など、Raspberry Piを使ったオートメーション化なども実践中です!エンジニア専門結婚相談所も運営中、ClaudeCodeで解決できない心の課題も解決いたします!
META-MARK × AI
ローカルAIを動かすGPU、ちゃんと選べていますか?
VRAM・性能・コスパをMetaScoreで数値化。AIアプリ別の推奨ハードウェア要件も確認できます。