jacky3102 commited on
Commit
1e62943
ยท
verified ยท
1 Parent(s): eadb6e3

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile.txt +39 -0
  2. README.md +6 -6
  3. app.py +356 -0
Dockerfile.txt ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # ๅฎ‰่ฃ…็ณป็ปŸไพ่ต–๏ผˆๅซ soxใ€libsndfile๏ผ‰
4
+ RUN apt-get update && apt-get install -y \
5
+ sox \
6
+ libsox-fmt-all \
7
+ libsndfile1 \
8
+ ffmpeg \
9
+ git \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ WORKDIR /app
13
+
14
+ # ๅ…ˆ่ฃ… CPU ็‰ˆ torch๏ผˆHF ๅ…่ดน Space ๆ—  GPU๏ผ‰
15
+ RUN pip install --no-cache-dir \
16
+ torch==2.3.1+cpu \
17
+ torchaudio==2.3.1+cpu \
18
+ --index-url https://download.pytorch.org/whl/cpu
19
+
20
+ # ๅ†่ฃ…ๅ…ถไป–ไพ่ต–
21
+ RUN pip install --no-cache-dir \
22
+ transformers==4.57.3 \
23
+ accelerate \
24
+ einops \
25
+ gradio \
26
+ fastapi \
27
+ uvicorn[standard] \
28
+ python-multipart \
29
+ librosa \
30
+ soundfile \
31
+ numpy \
32
+ onnxruntime \
33
+ qwen-tts
34
+
35
+ COPY app.py .
36
+
37
+ EXPOSE 7860
38
+
39
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,10 @@
1
  ---
2
- title: Subtitle Gen
3
- emoji: ๐Ÿš€
4
  colorFrom: blue
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Qwen3 TTS
3
+ emoji: ๐ŸŽ™๏ธ
4
  colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: apache-2.0
10
+ ---
 
app.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import gc
4
+ import tempfile
5
+ import torch
6
+ import soundfile as sf
7
+ import numpy as np
8
+ import gradio as gr
9
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
10
+ from fastapi.responses import StreamingResponse
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from typing import Optional
13
+ import uvicorn
14
+
15
+ # โ”€โ”€ ่ฎพๅค‡ & ็ฒพๅบฆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
16
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
17
+ DTYPE = torch.bfloat16
18
+
19
+ print(f"[TTS] Device={DEVICE}, dtype={DTYPE}")
20
+ from qwen_tts import Qwen3TTSModel
21
+
22
+ # โ”€โ”€ ๅฏๅŠจๅชๅŠ ่ฝฝ Base โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
23
+ print("[TTS] Loading Base model...")
24
+ model_base = Qwen3TTSModel.from_pretrained(
25
+ "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
26
+ device_map=DEVICE,
27
+ dtype=DTYPE,
28
+ )
29
+ print("[TTS] Base model ready.")
30
+ LANGUAGES = model_base.get_supported_languages()
31
+
32
+ # โ”€โ”€ CustomVoice ๆŒ‰้œ€ๅŠ ่ฝฝ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
33
+ model_custom = None
34
+
35
+ def _load_custom():
36
+ global model_custom
37
+ if model_custom is not None:
38
+ return
39
+ print("[TTS] Loading CustomVoice...")
40
+ model_custom = Qwen3TTSModel.from_pretrained(
41
+ "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
42
+ device_map=DEVICE,
43
+ dtype=DTYPE,
44
+ )
45
+ print("[TTS] CustomVoice ready.")
46
+
47
+ def _unload_custom():
48
+ global model_custom
49
+ if model_custom is None:
50
+ return
51
+ print("[TTS] Unloading CustomVoice...")
52
+ del model_custom
53
+ model_custom = None
54
+ gc.collect()
55
+ if DEVICE == "cuda":
56
+ torch.cuda.empty_cache()
57
+ print("[TTS] CustomVoice unloaded.")
58
+
59
+ # โ”€โ”€ Speaker ๅˆ—่กจ๏ผˆๅฏๅŠจๆ—ถ่Žทๅ–ไธ€ๆฌก๏ผ‰โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
60
+ print("[TTS] Fetching speaker list...")
61
+ _load_custom()
62
+ SPEAKERS = model_custom.get_supported_speakers()
63
+ _unload_custom()
64
+ print(f"[TTS] Speakers: {SPEAKERS}")
65
+
66
+ # โ”€โ”€ ๅ…จๅฑ€ anchor ๅญ˜ๅ‚จ role -> {sr, audio, text} โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
67
+ _anchors: dict = {}
68
+
69
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
70
+ # ๅทฅๅ…ท
71
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
72
+
73
+ def _wav_bytes(audio: np.ndarray, sr: int) -> bytes:
74
+ buf = io.BytesIO()
75
+ sf.write(buf, audio, sr, format="WAV")
76
+ buf.seek(0)
77
+ return buf.read()
78
+
79
+ def _wav_response(audio: np.ndarray, sr: int, name: str = "out.wav") -> StreamingResponse:
80
+ return StreamingResponse(
81
+ io.BytesIO(_wav_bytes(audio, sr)),
82
+ media_type="audio/wav",
83
+ headers={"Content-Disposition": f"attachment; filename={name}"},
84
+ )
85
+
86
+ def _tmp_wav(sr: int, audio: np.ndarray) -> str:
87
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
88
+ sf.write(f.name, audio, sr)
89
+ return f.name
90
+
91
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
92
+ # FastAPI
93
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
94
+
95
+ app_api = FastAPI(title="Qwen3-TTS", version="4.0.0")
96
+ app_api.add_middleware(CORSMiddleware, allow_origins=["*"],
97
+ allow_methods=["*"], allow_headers=["*"])
98
+
99
+ @app_api.get("/health")
100
+ def health():
101
+ return {
102
+ "status": "ok", "device": DEVICE,
103
+ "custom_loaded": model_custom is not None,
104
+ "anchors": list(_anchors.keys()),
105
+ "languages": LANGUAGES,
106
+ }
107
+
108
+ # โ”€โ”€ /batch_anchor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
109
+ # ไธ€ๆฌกๆ€งๅปบ็ซ‹ๆ‰€ๆœ‰่ง’่‰ฒ anchor๏ผŒCustomVoice ๅชๅŠ ่ฝฝไธ€ๆฌก
110
+ @app_api.post("/batch_anchor")
111
+ async def batch_anchor(
112
+ roles: str = Form(...), # JSON list
113
+ speakers: str = Form(...), # JSON list
114
+ texts: str = Form(...), # JSON list
115
+ languages: str = Form(...), # JSON list
116
+ instructs: str = Form(...), # JSON list
117
+ ):
118
+ import json, base64
119
+ roles_ = json.loads(roles)
120
+ speakers_ = json.loads(speakers)
121
+ texts_ = json.loads(texts)
122
+ languages_ = json.loads(languages)
123
+ instructs_ = json.loads(instructs)
124
+
125
+ if not (len(roles_) == len(speakers_) == len(texts_) == len(languages_) == len(instructs_)):
126
+ raise HTTPException(400, "all lists must be same length")
127
+
128
+ results = {}
129
+ try:
130
+ _load_custom()
131
+ for role, spk, text, lang, inst in zip(
132
+ roles_, speakers_, texts_, languages_, instructs_):
133
+ inst = inst if inst and inst.strip() else None
134
+ wavs, sr = model_custom.generate_custom_voice(
135
+ text=text, language=lang, speaker=spk, instruct=inst)
136
+ audio = wavs[0]
137
+ _anchors[role] = {"sr": sr, "audio": audio, "text": text}
138
+ wav_b64 = base64.b64encode(_wav_bytes(audio, sr)).decode()
139
+ results[role] = {"sr": sr, "wav_b64": wav_b64}
140
+ print(f"[batch_anchor] โœ“ {role} ({spk})")
141
+ finally:
142
+ _unload_custom()
143
+
144
+ return results
145
+
146
+ # โ”€โ”€ /tts (ๅ…ผๅฎนๆ—ง็‰ˆ๏ผŒๅ•่ง’่‰ฒ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
147
+ @app_api.post("/tts")
148
+ async def tts_single(
149
+ text: str = Form(...),
150
+ speaker: str = Form("Vivian"),
151
+ language: str = Form("chinese"),
152
+ instruct: str = Form(""),
153
+ role: str = Form("_narrator"),
154
+ set_anchor: bool = Form(False), # FIX: ไป…ๅฝ“ๆ˜พๅผ่ฆๆฑ‚ๆ—ถๆ‰(้‡ๆ–ฐ)ๅ†™ๅ…ฅ/่ฆ†็›– _anchors[role]
155
+ ):
156
+ if not text.strip():
157
+ raise HTTPException(400, "text is empty")
158
+ inst = instruct if instruct.strip() else None
159
+ try:
160
+ _load_custom()
161
+ wavs, sr = model_custom.generate_custom_voice(
162
+ text=text, language=language, speaker=speaker, instruct=inst)
163
+ audio = wavs[0]
164
+ # FIX: ไน‹ๅ‰่ฟ™้‡Œๆ— ๆกไปถ่ฆ†็›– _anchors[role]๏ผŒๅฏผ่‡ดไปปไฝ•ไธ€ๆฌกๅธฆ instruct ็š„
165
+ # ๆƒ…็ปชๅฐ่ฏ๏ผˆspeak_emotion / dialogue ้‡Œ้ž narrator ่กŒ๏ผ‰้ƒฝไผšๆŠŠๅทฒ็ปๅปบ็ซ‹ๅฅฝ็š„
166
+ # anchor ้กถๆ›ฟๆމ๏ผŒๅŽ็ปญ /clone ๅฐฑไผš็”จ่ฟ™ๅฅ"ๅธฆๆƒ…็ปช"็š„้Ÿณ้ข‘ๅฝ“ๅ‚่€ƒ๏ผŒ้€ ๆˆ้Ÿณ่‰ฒ่ท‘ๅใ€‚
167
+ # ็Žฐๅœจ๏ผšๅชๆœ‰ๆ˜พๅผไผ  set_anchor=true๏ผˆๅปบ็ซ‹/้‡ๅปบ้”š็‚นๆ—ถ๏ผ‰ๆ‰ๅ†™ๅ…ฅใ€‚
168
+ if set_anchor or role not in _anchors:
169
+ _anchors[role] = {"sr": sr, "audio": audio, "text": text}
170
+ return _wav_response(audio, sr, "speech.wav")
171
+ except Exception as e:
172
+ raise HTTPException(500, str(e))
173
+ finally:
174
+ _unload_custom()
175
+
176
+ # โ”€โ”€ /clone โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
177
+ # โš ๏ธ ไฟฎๆ”น๏ผšๆ”ฏๆŒไธค็งๆ–นๅผ - ไฝฟ็”จๆœๅŠก็ซฏ anchor ๆˆ–็›ดๆŽฅไธŠไผ ๅ‚่€ƒ้Ÿณ้ข‘
178
+ @app_api.post("/clone")
179
+ async def clone(
180
+ text: str = Form(...),
181
+ language: str = Form("chinese"),
182
+ role: str = Form("_narrator"),
183
+ reference_text: str = Form(""),
184
+ reference_audio: UploadFile = File(None),
185
+ ):
186
+ if not text.strip():
187
+ raise HTTPException(400, "text is empty")
188
+
189
+ tmp_path = None
190
+ try:
191
+ # FIX: ไน‹ๅ‰่ฟ™้‡Œ"ๆœๅŠก็ซฏ anchor"ๆฐธ่ฟœไผ˜ๅ…ˆ๏ผŒๅฏผ่‡ดๅช่ฆ role ๅœจ _anchors ้‡Œ
192
+ # ๏ผˆๆฏ”ๅฆ‚็”จ create ๅปบ็ซ‹่ฟ‡๏ผ‰๏ผŒ/clone ๅฐฑๅฎŒๅ…จๆ— ่ง†ๅฎขๆˆท็ซฏไผ ๆฅ็š„ reference_audio/
193
+ # reference_text โ€”โ€” workflow.sh ้‡Œ็š„"ๆปšๅŠจๅ‚่€ƒ"ๆœบๅˆถๅฝขๅŒ่™š่ฎพ๏ผŒ่€Œไธ”ไธ€ๆ—ฆ
194
+ # _anchors[role] ่ขซๅˆซ็š„่ฐƒ็”จ๏ผˆๅฆ‚ๆƒ…็ปชๅฐ่ฏ๏ผ‰่ฆ†็›–๏ผŒๅ…จ็จ‹้ƒฝไผš่ทŸ็€่ท‘ๅใ€‚
195
+ # ็Žฐๅœจๆ”นไธบ๏ผšๅฎขๆˆท็ซฏๆ˜พๅผไผ ไบ† reference_audio + reference_text ๅฐฑไผ˜ๅ…ˆ็”จๅฎƒ
196
+ # ๏ผˆๆปšๅŠจๅ‚่€ƒ / ๅผบๅˆถๅ›ž้”š้ƒฝ้€š่ฟ‡่ฟ™ไธชๅ‚ๆ•ฐๆŽงๅˆถ๏ผ‰๏ผŒๅฎขๆˆท็ซฏๆฒกไผ ๆ—ถๆ‰้€€ๅ›žๆœๅŠก็ซฏ anchorใ€‚
197
+ if reference_audio is not None and reference_text.strip():
198
+ suffix = os.path.splitext(reference_audio.filename)[-1] or ".wav"
199
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
200
+ f.write(await reference_audio.read())
201
+ tmp_path = f.name
202
+ ref_text = reference_text
203
+ print(f"[clone] ไฝฟ็”จๅฎขๆˆท็ซฏๅ‚่€ƒ้Ÿณ้ข‘: {reference_audio.filename}")
204
+
205
+ elif role in _anchors:
206
+ anchor = _anchors[role]
207
+ tmp_path = _tmp_wav(anchor["sr"], anchor["audio"])
208
+ ref_text = anchor["text"]
209
+ print(f"[clone] ไฝฟ็”จๆœๅŠก็ซฏ anchor: {role}")
210
+
211
+ else:
212
+ raise HTTPException(
213
+ 400, f"No anchor for role='{role}'. Call /batch_anchor or /tts first, or provide reference_audio and reference_text.")
214
+
215
+ # ็”Ÿๆˆๅ…‹้š†
216
+ wavs, sr = model_base.generate_voice_clone(
217
+ text=text, ref_audio=tmp_path,
218
+ ref_text=ref_text, language=language)
219
+ return _wav_response(wavs[0], sr, "clone.wav")
220
+
221
+ except HTTPException:
222
+ raise
223
+ except Exception as e:
224
+ raise HTTPException(500, str(e))
225
+ finally:
226
+ if tmp_path and os.path.exists(tmp_path):
227
+ os.unlink(tmp_path)
228
+
229
+ # โ”€โ”€ /clear_anchors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
230
+ @app_api.post("/clear_anchors")
231
+ def clear_anchors():
232
+ _anchors.clear()
233
+ return {"status": "cleared"}
234
+
235
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
236
+ # Gradio UI
237
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
238
+
239
+ def ui_first(text, language, speaker, instruct):
240
+ if not text.strip():
241
+ return None, "โŒ ่ฏท่พ“ๅ…ฅๆ–‡ๆœฌ"
242
+ try:
243
+ _load_custom()
244
+ wavs, sr = model_custom.generate_custom_voice(
245
+ text=text, language=language, speaker=speaker,
246
+ instruct=instruct or None)
247
+ audio = wavs[0]
248
+ _anchors["_ui"] = {"sr": sr, "audio": audio, "text": text}
249
+ return (sr, audio), f"โœ… ็”ŸๆˆๅฎŒๆฏ•๏ผŒๅฃฐ้Ÿณๅทฒ้”ๅฎš๏ผˆ{speaker}๏ผ‰"
250
+ except Exception as e:
251
+ return None, f"โŒ {e}"
252
+ finally:
253
+ _unload_custom()
254
+
255
+ def ui_clone(text, language):
256
+ if not text.strip():
257
+ return None, "โŒ ่ฏท่พ“ๅ…ฅๆ–‡ๆœฌ"
258
+ if "_ui" not in _anchors:
259
+ return None, "โŒ ่ฏทๅ…ˆๅœจใ€Œ็ฌฌไธ€ๆฎตใ€Tab ็”Ÿๆˆๅนถ้”ๅฎšๅฃฐ้Ÿณ"
260
+ anchor = _anchors["_ui"]
261
+ tmp = _tmp_wav(anchor["sr"], anchor["audio"])
262
+ try:
263
+ wavs, sr = model_base.generate_voice_clone(
264
+ text=text, ref_audio=tmp,
265
+ ref_text=anchor["text"], language=language)
266
+ return (sr, wavs[0]), "โœ… ็”ŸๆˆๆˆๅŠŸ"
267
+ except Exception as e:
268
+ return None, f"โŒ {e}"
269
+ finally:
270
+ os.unlink(tmp)
271
+
272
+ def ui_manual_clone(text, language, ref_audio, ref_text):
273
+ if not text.strip() or ref_audio is None:
274
+ return None, "โŒ ่ฏทๅกซๅ†™ๆ–‡ๆœฌๅนถไธŠไผ ๅ‚่€ƒ้Ÿณ้ข‘"
275
+ if not ref_text.strip():
276
+ return None, "โŒ ๅ‚่€ƒๆ–‡ๆœฌไธบๅฟ…ๅกซ้กน"
277
+ sr_r, audio_np = ref_audio
278
+ tmp = _tmp_wav(sr_r, audio_np)
279
+ try:
280
+ wavs, sr = model_base.generate_voice_clone(
281
+ text=text, ref_audio=tmp,
282
+ ref_text=ref_text, language=language)
283
+ return (sr, wavs[0]), "โœ… ๅ…‹้š†ๆˆๅŠŸ"
284
+ except Exception as e:
285
+ return None, f"โŒ {e}"
286
+ finally:
287
+ os.unlink(tmp)
288
+
289
+ def ui_unlock():
290
+ _anchors.pop("_ui", None)
291
+ return "๐Ÿ”“ ๅทฒ่งฃ้”"
292
+
293
+ with gr.Blocks(title="Qwen3-TTS", theme=gr.themes.Soft()) as gradio_ui:
294
+ gr.Markdown(
295
+ "# ๐ŸŽ™๏ธ Qwen3-TTS 1.7B\n"
296
+ "**ๅทฅไฝœๆต**๏ผš็ฌฌไธ€ๆฎตๅปบ็ซ‹ anchor โ†’ ๅŽ็ปญๅ…จ้ƒจ clone ไฟๆŒๅฃฐ้Ÿณไธ€่‡ด\n\n"
297
+ "> CustomVoice ๅชๅœจ็”Ÿๆˆ็ฌฌไธ€ๆฎตๆ—ถไธดๆ—ถๅŠ ่ฝฝ๏ผŒ็”จๅฎŒ็ซ‹ๅˆป้‡Šๆ”พใ€‚"
298
+ )
299
+ with gr.Tabs():
300
+ with gr.Tab("โ‘  ็ฌฌไธ€ๆฎต"):
301
+ with gr.Row():
302
+ with gr.Column():
303
+ f_text = gr.Textbox(label="็ฌฌไธ€ๆฎตๆ–‡ๆœฌ", lines=4)
304
+ f_lang = gr.Dropdown(LANGUAGES, value="chinese", label="่ฏญ่จ€")
305
+ f_speaker = gr.Dropdown(SPEAKERS, value=SPEAKERS[0], label="Speaker")
306
+ f_inst = gr.Textbox(label="้ฃŽๆ ผๆŒ‡ไปค๏ผˆๅฏ้€‰๏ผ‰",
307
+ placeholder="ไพ‹๏ผš็”จๆฒ‰็จณ็š„ๆ’ญ้Ÿณ่…”")
308
+ with gr.Row():
309
+ f_btn = gr.Button("๐ŸŽต ็”Ÿๆˆๅนถ้”ๅฎš", variant="primary")
310
+ f_unlock = gr.Button("๐Ÿ”“ ่งฃ้”", variant="secondary")
311
+ with gr.Column():
312
+ f_audio = gr.Audio(label="่พ“ๅ‡บ", type="numpy")
313
+ f_status = gr.Textbox(label="็Šถๆ€", interactive=False)
314
+ f_btn.click(ui_first, [f_text, f_lang, f_speaker, f_inst], [f_audio, f_status])
315
+ f_unlock.click(ui_unlock, [], f_status)
316
+
317
+ with gr.Tab("โ‘ก ๅŽ็ปญๆฎต่ฝ"):
318
+ gr.Markdown("ไฝฟ็”จ้”ๅฎšๅฃฐ้Ÿณ๏ผŒๆฏๆฌกๅช้œ€่พ“ๅ…ฅๆ–‡ๆœฌ๏ผŒๅฃฐ้ŸณๅฎŒๅ…จไธ€่‡ดใ€‚")
319
+ with gr.Row():
320
+ with gr.Column():
321
+ b_text = gr.Textbox(label="็›ฎๆ ‡ๆ–‡ๆœฌ", lines=5)
322
+ b_lang = gr.Dropdown(LANGUAGES, value="chinese", label="่ฏญ่จ€")
323
+ b_btn = gr.Button("๐ŸŽต ็”Ÿๆˆ", variant="primary")
324
+ with gr.Column():
325
+ b_audio = gr.Audio(label="่พ“ๅ‡บ", type="numpy")
326
+ b_status = gr.Textbox(label="็Šถๆ€", interactive=False)
327
+ b_btn.click(ui_clone, [b_text, b_lang], [b_audio, b_status])
328
+
329
+ with gr.Tab("โ‘ข ๆ‰‹ๅŠจๅ…‹้š†"):
330
+ with gr.Row():
331
+ with gr.Column():
332
+ c_ref = gr.Audio(label="ๅ‚่€ƒ้Ÿณ้ข‘๏ผˆ3~10 ็ง’๏ผ‰", type="numpy")
333
+ c_rtext = gr.Textbox(label="ๅ‚่€ƒ้Ÿณ้ข‘ๅฏนๅบ”ๆ–‡ๆœฌ๏ผˆๅฟ…ๅกซ๏ผ‰", lines=2)
334
+ c_text = gr.Textbox(label="็›ฎๆ ‡ๆ–‡ๆœฌ", lines=4)
335
+ c_lang = gr.Dropdown(LANGUAGES, value="chinese", label="่ฏญ่จ€")
336
+ c_btn = gr.Button("๐ŸŽต ็”Ÿๆˆ", variant="primary")
337
+ with gr.Column():
338
+ c_audio = gr.Audio(label="่พ“ๅ‡บ", type="numpy")
339
+ c_status = gr.Textbox(label="็Šถๆ€", interactive=False)
340
+ c_btn.click(ui_manual_clone,
341
+ [c_text, c_lang, c_ref, c_rtext],
342
+ [c_audio, c_status])
343
+
344
+ gr.Markdown(
345
+ "---\n### API\n"
346
+ "- `POST /batch_anchor` โ€” ๆ‰น้‡ๅปบ็ซ‹ๆ‰€ๆœ‰่ง’่‰ฒ anchor๏ผˆCustomVoice ๅชๅŠ ่ฝฝไธ€ๆฌก๏ผ‰\n"
347
+ "- `POST /tts` โ€” ๅ•่ง’่‰ฒๅปบ็ซ‹ anchor๏ผˆๅ…ผๅฎนๆ—ง็‰ˆ๏ผ‰\n"
348
+ "- `POST /clone` โ€” ็”จ anchor ๆˆ–ๅ‚่€ƒ้Ÿณ้ข‘ๅ…‹้š†็”Ÿๆˆ\n"
349
+ "- `POST /clear_anchors` โ€” ๆธ…้™คๆ‰€ๆœ‰ anchor\n"
350
+ "- `GET /health` โ€” ็Šถๆ€\n"
351
+ )
352
+
353
+ final_app = gr.mount_gradio_app(app_api, gradio_ui, path="/")
354
+
355
+ if __name__ == "__main__":
356
+ uvicorn.run(final_app, host="0.0.0.0", port=7860)