PlotweaverModel commited on
Commit
0ff5f89
Β·
verified Β·
1 Parent(s): 41ac87f

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +4 -3
  2. app.py +419 -116
  3. packages.txt +3 -0
  4. requirements.txt +3 -0
README.md CHANGED
@@ -12,18 +12,19 @@ license: apache-2.0
12
 
13
  # Qwen3-TTS Demo
14
 
15
- Open-source text-to-speech with three modes:
16
 
17
  1. **Custom Voice** - Pick a preset speaker with optional emotion instructions
18
  2. **Voice Design** - Describe any voice in natural language and the AI creates it
19
  3. **Voice Clone** - Clone a voice from a 3-second audio sample
 
20
 
21
  ## Setup
22
 
23
- No API keys needed. The models load automatically from HuggingFace.
24
-
25
  Hardware: Requires GPU (runs on ZeroGPU for free on HF Spaces).
26
 
 
 
27
  ## Supported Languages
28
 
29
  English, Chinese, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian
 
12
 
13
  # Qwen3-TTS Demo
14
 
15
+ Open-source text-to-speech with four modes:
16
 
17
  1. **Custom Voice** - Pick a preset speaker with optional emotion instructions
18
  2. **Voice Design** - Describe any voice in natural language and the AI creates it
19
  3. **Voice Clone** - Clone a voice from a 3-second audio sample
20
+ 4. **Multi-Speaker Story** - Auto-detect characters, assign voices, add emotions, generate full audiobook
21
 
22
  ## Setup
23
 
 
 
24
  Hardware: Requires GPU (runs on ZeroGPU for free on HF Spaces).
25
 
26
+ Optional: Add DASHSCOPE_API_KEY in Secrets for the Multi-Speaker Story mode (character detection + emotion analysis).
27
+
28
  ## Supported Languages
29
 
30
  English, Chinese, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian
app.py CHANGED
@@ -1,11 +1,17 @@
1
  """
2
  Qwen3-TTS Demo β€” Self-hosted Text-to-Speech
3
- Three modes: Custom Voice, Voice Design, Voice Clone
4
  Runs on HF Spaces with ZeroGPU (free)
5
  """
6
 
7
  import os
 
 
8
  import tempfile
 
 
 
 
9
  import torch
10
  import spaces
11
  import gradio as gr
@@ -13,14 +19,44 @@ import soundfile as sf
13
 
14
  from qwen_tts import Qwen3TTSModel
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  # ==========================================
17
  # MODEL LOADING
18
  # ==========================================
19
- # Models are loaded on-demand per mode to save VRAM
20
  _models = {}
21
 
22
  def get_model(model_type):
23
- """Load model lazily. Models share the tokenizer so memory is manageable."""
24
  if model_type not in _models:
25
  model_map = {
26
  "custom": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
@@ -37,215 +73,482 @@ def get_model(model_type):
37
  return _models[model_type]
38
 
39
 
40
- # ==========================================
41
- # CONFIG
42
- # ==========================================
43
- LANGUAGES = ["Auto", "English", "Chinese", "Japanese", "Korean", "German",
44
- "French", "Russian", "Portuguese", "Spanish", "Italian"]
45
 
46
- SPEAKERS = {
47
- "Vivian": "Bright, edgy young female (Chinese)",
48
- "Serena": "Warm, gentle young female (Chinese)",
49
- "Uncle_Fu": "Seasoned male, low mellow timbre (Chinese)",
50
- "Dylan": "Youthful Beijing male, clear natural (Chinese)",
51
- "Eric": "Lively Chengdu male, slightly husky (Chinese/Sichuan)",
52
- "Ryan": "Dynamic male, strong rhythmic drive (English)",
53
- "Aiden": "Sunny American male, clear midrange (English)",
54
- "Ono_Anna": "Playful Japanese female, light nimble (Japanese)",
55
- "Sohee": "Warm Korean female, rich emotion (Korean)",
56
- }
57
-
58
- SPEAKER_CHOICES = [f"{name} -- {desc}" for name, desc in SPEAKERS.items()]
59
 
60
- EMOTION_EXAMPLES = [
61
- "Very happy and excited",
62
- "Speak sadly, with a heavy heart",
63
- "Whisper softly and mysteriously",
64
- "Angry and frustrated tone",
65
- "Calm, warm bedtime story narrator",
66
- "Professional news anchor delivery",
67
- "Dramatic storytelling with suspense",
68
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
 
71
  # ==========================================
72
- # TTS FUNCTIONS
73
  # ==========================================
74
  @spaces.GPU
75
  def generate_custom_voice(text, language, speaker_label, instruction):
76
- """Mode 1: Custom Voice β€” pick a preset speaker with optional instruction."""
77
  if not text.strip():
78
  raise gr.Error("Please enter some text.")
79
-
80
  model = get_model("custom")
81
  speaker = speaker_label.split("--")[0].strip()
82
  lang = language if language != "Auto" else "Auto"
83
-
84
- kwargs = {
85
- "text": text,
86
- "language": lang,
87
- "speaker": speaker,
88
- }
89
  if instruction and instruction.strip():
90
  kwargs["instruct"] = instruction.strip()
91
-
92
- print(f"[TTS] Custom voice: speaker={speaker}, lang={lang}, instruct={instruction[:50] if instruction else 'none'}")
93
  wavs, sr = model.generate_custom_voice(**kwargs)
94
-
95
- output_path = os.path.join(tempfile.mkdtemp(), "custom_voice.wav")
96
- sf.write(output_path, wavs[0], sr)
97
- print(f"[TTS] Generated: {output_path}, {len(wavs[0])/sr:.1f}s")
98
- return output_path
99
 
100
 
101
  @spaces.GPU
102
  def generate_voice_design(text, language, voice_description):
103
- """Mode 2: Voice Design β€” describe the voice you want in natural language."""
104
  if not text.strip():
105
  raise gr.Error("Please enter some text.")
106
  if not voice_description.strip():
107
  raise gr.Error("Please describe the voice you want.")
108
-
109
  model = get_model("design")
110
  lang = language if language != "Auto" else "Auto"
111
-
112
- print(f"[TTS] Voice design: lang={lang}, desc={voice_description[:80]}")
113
- wavs, sr = model.generate_voice_design(
114
- text=text,
115
- language=lang,
116
- instruct=voice_description,
117
- )
118
-
119
- output_path = os.path.join(tempfile.mkdtemp(), "voice_design.wav")
120
- sf.write(output_path, wavs[0], sr)
121
- print(f"[TTS] Generated: {output_path}, {len(wavs[0])/sr:.1f}s")
122
- return output_path
123
 
124
 
125
  @spaces.GPU
126
  def generate_voice_clone(text, language, ref_audio, ref_text):
127
- """Mode 3: Voice Clone β€” clone a voice from a 3+ second audio sample."""
128
  if not text.strip():
129
  raise gr.Error("Please enter some text.")
130
  if ref_audio is None:
131
  raise gr.Error("Please upload a reference audio sample.")
132
-
133
  model = get_model("clone")
134
  lang = language if language != "Auto" else "Auto"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
  kwargs = {
137
  "text": text,
138
  "language": lang,
139
- "ref_audio": ref_audio,
140
  }
141
- if ref_text and ref_text.strip():
142
- kwargs["ref_text"] = ref_text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  else:
144
- kwargs["x_vector_only_mode"] = True
145
 
146
- print(f"[TTS] Voice clone: lang={lang}, ref_text={'yes' if ref_text else 'speaker-embed only'}")
147
- wavs, sr = model.generate_voice_clone(**kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- output_path = os.path.join(tempfile.mkdtemp(), "voice_clone.wav")
150
- sf.write(output_path, wavs[0], sr)
151
- print(f"[TTS] Generated: {output_path}, {len(wavs[0])/sr:.1f}s")
152
- return output_path
153
 
154
 
155
  # ==========================================
156
  # GRADIO UI
157
  # ==========================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  DESCRIPTION = """
159
  # Qwen3-TTS Demo
160
- ### Open-Source Text-to-Speech (1.7B)
161
-
162
- Three modes for generating speech:
163
 
164
  | Mode | What it does |
165
  |------|-------------|
166
- | **Custom Voice** | Pick a preset voice + optional emotion/style instruction |
167
- | **Voice Design** | Describe the voice you want in plain English |
168
- | **Voice Clone** | Clone any voice from a 3-second audio sample |
 
169
 
170
- Supports 10 languages: English, Chinese, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian.
171
- Running on ZeroGPU β€” completely free.
172
  """
173
 
174
  with gr.Blocks(title="Qwen3-TTS Demo") as demo:
175
 
176
  gr.Markdown(DESCRIPTION)
177
 
 
178
  with gr.Tab("Custom Voice"):
179
- gr.Markdown("Pick a preset speaker and optionally add emotion/style instructions.")
180
  with gr.Row():
181
  with gr.Column():
182
- cv_text = gr.Textbox(label="Text to Speak", lines=4,
183
- placeholder="Enter the text you want spoken...")
184
  cv_lang = gr.Dropdown(choices=LANGUAGES, value="Auto", label="Language")
185
  cv_speaker = gr.Dropdown(choices=SPEAKER_CHOICES,
186
  value="Ryan -- Dynamic male, strong rhythmic drive (English)",
187
  label="Speaker")
188
- cv_instruct = gr.Textbox(label="Emotion / Style Instruction (optional)",
189
- placeholder="e.g. Very happy and excited, Speak sadly, Whisper softly...")
190
  cv_btn = gr.Button("Generate", variant="primary")
191
  with gr.Column():
192
  cv_audio = gr.Audio(label="Generated Speech", type="filepath")
193
-
194
  cv_btn.click(fn=generate_custom_voice,
195
- inputs=[cv_text, cv_lang, cv_speaker, cv_instruct],
196
- outputs=cv_audio)
197
 
 
198
  with gr.Tab("Voice Design"):
199
- gr.Markdown("Describe the voice you want in natural language β€” the AI creates it from scratch.")
200
  with gr.Row():
201
  with gr.Column():
202
- vd_text = gr.Textbox(label="Text to Speak", lines=4,
203
- placeholder="Enter the text you want spoken...")
204
  vd_lang = gr.Dropdown(choices=LANGUAGES, value="English", label="Language")
205
  vd_desc = gr.Textbox(label="Voice Description", lines=3,
206
- placeholder="e.g. Warm, captivating storyteller with a slight British accent, male...")
207
  gr.Examples(
208
- examples=[[ex] for ex in [
209
- "Warm, captivating male storyteller with a slight British accent",
210
- "Young energetic female voice, cheerful and bright, American",
211
- "Deep authoritative male voice, news anchor style, very clear",
212
- "Gentle elderly grandmother voice, kind and soothing",
213
- "Speak in an incredulous tone with panic creeping into the voice",
214
- ]],
215
- inputs=[vd_desc],
216
- label="Example Descriptions",
217
  )
218
  vd_btn = gr.Button("Generate", variant="primary")
219
  with gr.Column():
220
  vd_audio = gr.Audio(label="Generated Speech", type="filepath")
221
-
222
  vd_btn.click(fn=generate_voice_design,
223
- inputs=[vd_text, vd_lang, vd_desc],
224
- outputs=vd_audio)
225
 
 
226
  with gr.Tab("Voice Clone"):
227
- gr.Markdown("Clone any voice from a short audio sample (3+ seconds). Provide the transcript for best quality.")
228
  with gr.Row():
229
  with gr.Column():
230
- vc_text = gr.Textbox(label="Text to Speak (in the cloned voice)", lines=4,
231
- placeholder="Enter what you want the cloned voice to say...")
232
  vc_lang = gr.Dropdown(choices=LANGUAGES, value="English", label="Language")
233
- vc_ref_audio = gr.Audio(label="Reference Audio (3+ seconds)", type="filepath")
234
- vc_ref_text = gr.Textbox(label="Transcript of Reference Audio (optional, improves quality)",
235
- placeholder="Type what the person says in the reference audio...")
236
  vc_btn = gr.Button("Clone & Generate", variant="primary")
237
  with gr.Column():
238
- vc_audio = gr.Audio(label="Generated Speech (Cloned Voice)", type="filepath")
239
-
240
  vc_btn.click(fn=generate_voice_clone,
241
- inputs=[vc_text, vc_lang, vc_ref_audio, vc_ref_text],
242
- outputs=vc_audio)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
  gr.Markdown(
245
  "---\n"
246
- "**Model:** Qwen3-TTS-12Hz-1.7B (Apache 2.0) | "
247
  "**Languages:** EN, ZH, JA, KO, DE, FR, RU, PT, ES, IT | "
248
- "**Running on:** HF Spaces ZeroGPU"
 
249
  )
250
 
251
  if __name__ == "__main__":
 
1
  """
2
  Qwen3-TTS Demo β€” Self-hosted Text-to-Speech
3
+ Modes: Custom Voice, Voice Design, Voice Clone, Multi-Speaker Story
4
  Runs on HF Spaces with ZeroGPU (free)
5
  """
6
 
7
  import os
8
+ import json
9
+ import re
10
  import tempfile
11
+ import subprocess
12
+ import shutil
13
+ import struct
14
+
15
  import torch
16
  import spaces
17
  import gradio as gr
 
19
 
20
  from qwen_tts import Qwen3TTSModel
21
 
22
+ # Optional: DashScope for smart character detection + emotion injection
23
+ try:
24
+ from openai import OpenAI
25
+ HAS_OPENAI = True
26
+ except ImportError:
27
+ HAS_OPENAI = False
28
+
29
+ # ==========================================
30
+ # CONFIG
31
+ # ==========================================
32
+ OMNI_MODEL = "qwen3.5-omni-plus"
33
+ DASHSCOPE_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
34
+
35
+ LANGUAGES = ["Auto", "English", "Chinese", "Japanese", "Korean", "German",
36
+ "French", "Russian", "Portuguese", "Spanish", "Italian"]
37
+
38
+ SPEAKERS = {
39
+ "Vivian": {"desc": "Bright, edgy young female", "lang": "Chinese", "gender": "female"},
40
+ "Serena": {"desc": "Warm, gentle young female", "lang": "Chinese", "gender": "female"},
41
+ "Uncle_Fu": {"desc": "Seasoned male, low mellow timbre", "lang": "Chinese", "gender": "male"},
42
+ "Dylan": {"desc": "Youthful Beijing male, clear natural", "lang": "Chinese", "gender": "male"},
43
+ "Eric": {"desc": "Lively Chengdu male, slightly husky", "lang": "Chinese", "gender": "male"},
44
+ "Ryan": {"desc": "Dynamic male, strong rhythmic drive", "lang": "English", "gender": "male"},
45
+ "Aiden": {"desc": "Sunny American male, clear midrange", "lang": "English", "gender": "male"},
46
+ "Ono_Anna": {"desc": "Playful Japanese female, light nimble", "lang": "Japanese", "gender": "female"},
47
+ "Sohee": {"desc": "Warm Korean female, rich emotion", "lang": "Korean", "gender": "female"},
48
+ }
49
+
50
+ SPEAKER_CHOICES = [f"{name} -- {info['desc']} ({info['lang']})" for name, info in SPEAKERS.items()]
51
+ MALE_SPEAKERS = [n for n, s in SPEAKERS.items() if s["gender"] == "male"]
52
+ FEMALE_SPEAKERS = [n for n, s in SPEAKERS.items() if s["gender"] == "female"]
53
+
54
  # ==========================================
55
  # MODEL LOADING
56
  # ==========================================
 
57
  _models = {}
58
 
59
  def get_model(model_type):
 
60
  if model_type not in _models:
61
  model_map = {
62
  "custom": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
 
73
  return _models[model_type]
74
 
75
 
76
+ def get_llm_client():
77
+ ds_key = os.environ.get("DASHSCOPE_API_KEY", "")
78
+ if ds_key and HAS_OPENAI:
79
+ return OpenAI(api_key=ds_key, base_url=DASHSCOPE_BASE_URL)
80
+ return None
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
+ # ==========================================
84
+ # AUDIO HELPERS
85
+ # ==========================================
86
+ def concatenate_wavs(wav_files, output_path):
87
+ if not wav_files:
88
+ return
89
+ if len(wav_files) == 1:
90
+ shutil.copy2(wav_files[0], output_path)
91
+ return
92
+ list_file = output_path + ".txt"
93
+ with open(list_file, "w") as f:
94
+ for w in wav_files:
95
+ f.write(f"file '{w}'\n")
96
+ subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0",
97
+ "-i", list_file, "-c", "copy", output_path],
98
+ capture_output=True, check=True)
99
+ os.remove(list_file)
100
+
101
+
102
+ def generate_silence(dur, path):
103
+ subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono",
104
+ "-t", str(dur), "-acodec", "pcm_s16le", path],
105
+ capture_output=True, check=True)
106
 
107
 
108
  # ==========================================
109
+ # SINGLE-SPEAKER MODES
110
  # ==========================================
111
  @spaces.GPU
112
  def generate_custom_voice(text, language, speaker_label, instruction):
 
113
  if not text.strip():
114
  raise gr.Error("Please enter some text.")
 
115
  model = get_model("custom")
116
  speaker = speaker_label.split("--")[0].strip()
117
  lang = language if language != "Auto" else "Auto"
118
+ kwargs = {"text": text, "language": lang, "speaker": speaker}
 
 
 
 
 
119
  if instruction and instruction.strip():
120
  kwargs["instruct"] = instruction.strip()
121
+ print(f"[TTS] Custom: speaker={speaker}, lang={lang}")
 
122
  wavs, sr = model.generate_custom_voice(**kwargs)
123
+ path = os.path.join(tempfile.mkdtemp(), "custom.wav")
124
+ sf.write(path, wavs[0], sr)
125
+ return path
 
 
126
 
127
 
128
  @spaces.GPU
129
  def generate_voice_design(text, language, voice_description):
 
130
  if not text.strip():
131
  raise gr.Error("Please enter some text.")
132
  if not voice_description.strip():
133
  raise gr.Error("Please describe the voice you want.")
 
134
  model = get_model("design")
135
  lang = language if language != "Auto" else "Auto"
136
+ print(f"[TTS] Design: lang={lang}, desc={voice_description[:60]}")
137
+ wavs, sr = model.generate_voice_design(text=text, language=lang, instruct=voice_description)
138
+ path = os.path.join(tempfile.mkdtemp(), "design.wav")
139
+ sf.write(path, wavs[0], sr)
140
+ return path
 
 
 
 
 
 
 
141
 
142
 
143
  @spaces.GPU
144
  def generate_voice_clone(text, language, ref_audio, ref_text):
 
145
  if not text.strip():
146
  raise gr.Error("Please enter some text.")
147
  if ref_audio is None:
148
  raise gr.Error("Please upload a reference audio sample.")
 
149
  model = get_model("clone")
150
  lang = language if language != "Auto" else "Auto"
151
+ kwargs = {"text": text, "language": lang, "ref_audio": ref_audio}
152
+ if ref_text and ref_text.strip():
153
+ kwargs["ref_text"] = ref_text.strip()
154
+ else:
155
+ kwargs["x_vector_only_mode"] = True
156
+ print(f"[TTS] Clone: lang={lang}")
157
+ wavs, sr = model.generate_voice_clone(**kwargs)
158
+ path = os.path.join(tempfile.mkdtemp(), "clone.wav")
159
+ sf.write(path, wavs[0], sr)
160
+ return path
161
+
162
+
163
+ # ==========================================
164
+ # MULTI-SPEAKER: Character Detection + Emotion
165
+ # ==========================================
166
+ def detect_characters_and_emotions(client, text):
167
+ """Use AI to detect characters, split segments, and add emotion instructions."""
168
+ response = client.chat.completions.create(
169
+ model=OMNI_MODEL, modalities=["text"],
170
+ messages=[
171
+ {
172
+ "role": "system",
173
+ "content": (
174
+ "You are an audiobook director. Analyze this story text and:\n"
175
+ "1. Identify all characters (including a Narrator for non-dialogue)\n"
176
+ "2. Detect each character's gender\n"
177
+ "3. Split the text into segments by speaker\n"
178
+ "4. For each segment, add an emotion/delivery instruction for the voice actor\n\n"
179
+ "Output ONLY valid JSON:\n"
180
+ "{\n"
181
+ ' "characters": [\n'
182
+ ' {"name": "Narrator", "gender": "neutral"},\n'
183
+ ' {"name": "Elena", "gender": "female"},\n'
184
+ ' {"name": "Grandfather", "gender": "male"}\n'
185
+ " ],\n"
186
+ ' "segments": [\n'
187
+ ' {"speaker": "Narrator", "text": "The lighthouse stood tall.", "emotion": "Calm, atmospheric storytelling tone"},\n'
188
+ ' {"speaker": "Elena", "text": "One day I will leave.", "emotion": "Wistful, dreamy, with quiet determination"},\n'
189
+ ' {"speaker": "Narrator", "text": "The old man smiled.", "emotion": "Warm, gentle narration"}\n'
190
+ " ]\n"
191
+ "}\n\n"
192
+ "Rules:\n"
193
+ "- Narrator handles all non-dialogue text\n"
194
+ "- Emotion should describe HOW to speak: tone, mood, energy, pacing\n"
195
+ "- Be specific with emotions: not just 'sad' but 'quietly heartbroken, voice barely above a whisper'\n"
196
+ "- Match emotions to the story context\n"
197
+ "- Include ALL text, do not skip anything\n"
198
+ "- Merge consecutive segments by the same speaker\n"
199
+ "- Output ONLY JSON, no markdown, no backticks"
200
+ ),
201
+ },
202
+ {"role": "user", "content": f"Direct this story:\n\n{text[:8000]}"},
203
+ ],
204
+ )
205
+
206
+ raw = response.choices[0].message.content.strip()
207
+ raw = re.sub(r'^```json\s*', '', raw)
208
+ raw = re.sub(r'\s*```$', '', raw)
209
+
210
+ try:
211
+ data = json.loads(raw)
212
+ return data.get("characters", []), data.get("segments", [])
213
+ except json.JSONDecodeError as e:
214
+ print(f"[MultiSpeaker] JSON parse failed: {e}\n{raw[:500]}")
215
+ return [{"name": "Narrator", "gender": "neutral"}], [{"speaker": "Narrator", "text": text, "emotion": "Calm narration"}]
216
+
217
+
218
+ def assign_voices(characters):
219
+ """Auto-assign voices to characters based on gender."""
220
+ voice_map = {}
221
+ male_idx, female_idx = 0, 0
222
+
223
+ for char in characters:
224
+ name = char["name"]
225
+ gender = char.get("gender", "neutral")
226
+
227
+ if name == "Narrator":
228
+ voice_map[name] = {"speaker": "Ryan", "type": "custom"}
229
+ elif gender == "male":
230
+ voice_map[name] = {"speaker": MALE_SPEAKERS[male_idx % len(MALE_SPEAKERS)], "type": "custom"}
231
+ male_idx += 1
232
+ elif gender == "female":
233
+ voice_map[name] = {"speaker": FEMALE_SPEAKERS[female_idx % len(FEMALE_SPEAKERS)], "type": "custom"}
234
+ female_idx += 1
235
+ else:
236
+ voice_map[name] = {"speaker": "Ryan", "type": "custom"}
237
+
238
+ return voice_map
239
+
240
+
241
+ @spaces.GPU
242
+ def generate_segment_audio(text, language, speaker, emotion, seg_idx, tmp_dir):
243
+ """Generate audio for a single segment with emotion instruction."""
244
+ model = get_model("custom")
245
+ lang = language if language != "Auto" else "Auto"
246
 
247
  kwargs = {
248
  "text": text,
249
  "language": lang,
250
+ "speaker": speaker,
251
  }
252
+ if emotion and emotion.strip():
253
+ kwargs["instruct"] = emotion.strip()
254
+
255
+ try:
256
+ wavs, sr = model.generate_custom_voice(**kwargs)
257
+ path = os.path.join(tmp_dir, f"seg_{seg_idx:04d}.wav")
258
+ sf.write(path, wavs[0], sr)
259
+ return path, None
260
+ except Exception as e:
261
+ return None, str(e)
262
+
263
+
264
+ def generate_multi_speaker_story(text_input, file_input, language, progress=gr.Progress()):
265
+ """Full multi-speaker pipeline with auto character detection and emotions."""
266
+ # Resolve text
267
+ if file_input is not None:
268
+ ext = os.path.splitext(file_input)[1].lower()
269
+ if ext == ".pdf":
270
+ try:
271
+ import pypdf
272
+ reader = pypdf.PdfReader(file_input)
273
+ text = "\n\n".join(p.extract_text().strip() for p in reader.pages if p.extract_text())
274
+ except Exception as e:
275
+ raise gr.Error(f"PDF read failed: {e}")
276
+ elif ext == ".docx":
277
+ try:
278
+ import docx
279
+ doc = docx.Document(file_input)
280
+ text = "\n\n".join(p.text.strip() for p in doc.paragraphs if p.text.strip())
281
+ except Exception as e:
282
+ raise gr.Error(f"DOCX read failed: {e}")
283
+ else:
284
+ with open(file_input, "r", encoding="utf-8", errors="replace") as f:
285
+ text = f.read()
286
+ elif text_input and text_input.strip():
287
+ text = text_input.strip()
288
  else:
289
+ raise gr.Error("Please provide a story text.")
290
 
291
+ if len(text) < 30:
292
+ raise gr.Error("Text too short for multi-speaker generation.")
293
+
294
+ # Check for LLM client
295
+ client = get_llm_client()
296
+ if not client:
297
+ raise gr.Error(
298
+ "DASHSCOPE_API_KEY needed for character detection and emotion analysis. "
299
+ "Add it in Settings > Secrets."
300
+ )
301
+
302
+ tmp_dir = tempfile.mkdtemp(prefix="multispeaker_")
303
+
304
+ # Step 1: Detect characters and emotions
305
+ progress(0.05, desc="Analyzing story characters and emotions...")
306
+ characters, segments = detect_characters_and_emotions(client, text)
307
+ print(f"[MultiSpeaker] {len(characters)} characters, {len(segments)} segments")
308
+
309
+ # Step 2: Assign voices
310
+ voice_map = assign_voices(characters)
311
+ print(f"[MultiSpeaker] Voice map: { {k: v['speaker'] for k, v in voice_map.items()} }")
312
+
313
+ # Step 3: Generate audio per segment
314
+ audio_files = []
315
+ all_transcripts = []
316
+ total = len(segments)
317
+
318
+ # Create pause files
319
+ speaker_pause = os.path.join(tmp_dir, "sp.wav")
320
+ section_pause = os.path.join(tmp_dir, "sec.wav")
321
+ generate_silence(0.4, speaker_pause)
322
+ generate_silence(1.0, section_pause)
323
+
324
+ prev_speaker = None
325
+
326
+ for i, seg in enumerate(segments):
327
+ frac = 0.10 + 0.80 * (i / max(total, 1))
328
+ speaker_name = seg.get("speaker", "Narrator")
329
+ seg_text = seg.get("text", "").strip()
330
+ emotion = seg.get("emotion", "")
331
+
332
+ if not seg_text:
333
+ continue
334
+
335
+ voice_info = voice_map.get(speaker_name, voice_map.get("Narrator", {"speaker": "Ryan"}))
336
+ progress(frac, desc=f"[{speaker_name}] Segment {i+1}/{total}...")
337
+
338
+ # Pause between different speakers
339
+ if prev_speaker and prev_speaker != speaker_name:
340
+ audio_files.append(speaker_pause)
341
+
342
+ # Split long segments
343
+ if len(seg_text) > 1500:
344
+ sub_texts = []
345
+ sentences = re.split(r'(?<=[.!?])\s+', seg_text)
346
+ current = ""
347
+ for s in sentences:
348
+ if len(current) + len(s) + 1 <= 1500:
349
+ current = (current + " " + s).strip()
350
+ else:
351
+ if current:
352
+ sub_texts.append(current)
353
+ current = s
354
+ if current:
355
+ sub_texts.append(current)
356
+ else:
357
+ sub_texts = [seg_text]
358
+
359
+ for j, sub in enumerate(sub_texts):
360
+ seg_idx = i * 100 + j
361
+ wav_path, error = generate_segment_audio(
362
+ sub, language, voice_info["speaker"], emotion, seg_idx, tmp_dir
363
+ )
364
+ if wav_path:
365
+ audio_files.append(wav_path)
366
+ else:
367
+ print(f"[MultiSpeaker] Seg {i} failed: {error}")
368
+ fail = os.path.join(tmp_dir, f"fail_{seg_idx}.wav")
369
+ generate_silence(1.5, fail)
370
+ audio_files.append(fail)
371
+ all_transcripts.append(f"**[{speaker_name}]** FAILED: {error}")
372
+
373
+ emotion_tag = f" *({emotion})*" if emotion else ""
374
+ all_transcripts.append(f"**[{speaker_name}]**{emotion_tag} {seg_text[:200]}{'...' if len(seg_text) > 200 else ''}")
375
+
376
+ # Section pause
377
+ if i < total - 1:
378
+ audio_files.append(section_pause)
379
+
380
+ prev_speaker = speaker_name
381
+
382
+ if not audio_files:
383
+ raise gr.Error("No audio generated.")
384
+
385
+ # Step 4: Assemble
386
+ progress(0.92, desc="Assembling audiobook...")
387
+ final_wav = os.path.join(tmp_dir, "story.wav")
388
+ concatenate_wavs(audio_files, final_wav)
389
+
390
+ progress(0.96, desc="Converting to MP3...")
391
+ final_mp3 = os.path.join(tmp_dir, "story.mp3")
392
+ subprocess.run(["ffmpeg", "-y", "-i", final_wav, "-codec:a", "libmp3lame",
393
+ "-b:a", "128k", "-ar", "24000", "-ac", "1", final_mp3],
394
+ capture_output=True, check=True)
395
+
396
+ progress(1.0, desc="Done!")
397
+
398
+ # Stats
399
+ size_mb = os.path.getsize(final_mp3) / (1024 * 1024)
400
+ cast = "\n".join(f" - **{c['name']}** ({c.get('gender', '?')}) β†’ {voice_map.get(c['name'], {}).get('speaker', '?')}"
401
+ for c in characters)
402
+ stats = (
403
+ f"**Multi-Speaker Story Generated!**\n\n"
404
+ f"- **Segments:** {total}\n"
405
+ f"- **Characters:** {len(characters)}\n"
406
+ f"- **File size:** {size_mb:.1f} MB\n\n"
407
+ f"**Cast:**\n{cast}\n"
408
+ )
409
+ transcript = "\n\n".join(all_transcripts)
410
 
411
+ return final_mp3, stats, transcript
 
 
 
412
 
413
 
414
  # ==========================================
415
  # GRADIO UI
416
  # ==========================================
417
+ SAMPLE_STORY = """Chapter 1: The Lighthouse
418
+
419
+ The old lighthouse stood at the edge of the world. Each morning, Elena climbed one hundred and forty-seven iron steps to the lamp room and watched the sun rise from the sea.
420
+
421
+ "One day," she whispered to the seagulls, "I'll follow that sun to wherever it goes."
422
+
423
+ The gulls said nothing. They merely tilted their heads and launched themselves into the wind.
424
+
425
+ Her grandfather was a man of few words but many stories.
426
+
427
+ "Tell me about the ships," Elena would say, curling up in the worn armchair by the fire.
428
+
429
+ And he would smile that slow, careful smile and begin: "There was a ship once, long ago, that sailed beyond the edge of every map. Its captain was a woman with eyes like starlight and a voice that could calm any storm."
430
+
431
+ "What happened to her?" Elena asked, leaning forward.
432
+
433
+ "She found what she was looking for," her grandfather said quietly. "But the price was higher than she imagined."
434
+
435
+ Elena stared into the fire. "Would you pay it? The price, I mean."
436
+
437
+ The old man was silent for a long time. "I already did," he finally whispered. "I already did."
438
+ """
439
+
440
  DESCRIPTION = """
441
  # Qwen3-TTS Demo
442
+ ### Open-Source Text-to-Speech (1.7B) β€” Self-Hosted, No API Keys for TTS
 
 
443
 
444
  | Mode | What it does |
445
  |------|-------------|
446
+ | **Custom Voice** | Pick a preset voice + optional emotion instruction |
447
+ | **Voice Design** | Describe any voice in natural language |
448
+ | **Voice Clone** | Clone a voice from 3 seconds of audio |
449
+ | **Multi-Speaker Story** | Auto-detect characters, assign voices, add emotions β€” full audiobook |
450
 
451
+ 10 languages supported. Running on ZeroGPU (free).
 
452
  """
453
 
454
  with gr.Blocks(title="Qwen3-TTS Demo") as demo:
455
 
456
  gr.Markdown(DESCRIPTION)
457
 
458
+ # ── Tab 1: Custom Voice ──
459
  with gr.Tab("Custom Voice"):
460
+ gr.Markdown("Pick a preset speaker with optional emotion/style.")
461
  with gr.Row():
462
  with gr.Column():
463
+ cv_text = gr.Textbox(label="Text", lines=4, placeholder="Enter text...")
 
464
  cv_lang = gr.Dropdown(choices=LANGUAGES, value="Auto", label="Language")
465
  cv_speaker = gr.Dropdown(choices=SPEAKER_CHOICES,
466
  value="Ryan -- Dynamic male, strong rhythmic drive (English)",
467
  label="Speaker")
468
+ cv_instruct = gr.Textbox(label="Emotion / Style (optional)",
469
+ placeholder="e.g. Very happy, Whisper softly, Speak with authority...")
470
  cv_btn = gr.Button("Generate", variant="primary")
471
  with gr.Column():
472
  cv_audio = gr.Audio(label="Generated Speech", type="filepath")
 
473
  cv_btn.click(fn=generate_custom_voice,
474
+ inputs=[cv_text, cv_lang, cv_speaker, cv_instruct], outputs=cv_audio)
 
475
 
476
+ # ── Tab 2: Voice Design ──
477
  with gr.Tab("Voice Design"):
478
+ gr.Markdown("Describe the voice you want β€” the AI creates it.")
479
  with gr.Row():
480
  with gr.Column():
481
+ vd_text = gr.Textbox(label="Text", lines=4, placeholder="Enter text...")
 
482
  vd_lang = gr.Dropdown(choices=LANGUAGES, value="English", label="Language")
483
  vd_desc = gr.Textbox(label="Voice Description", lines=3,
484
+ placeholder="e.g. Warm male storyteller with British accent...")
485
  gr.Examples(
486
+ examples=[
487
+ ["Warm, captivating male storyteller with a slight British accent"],
488
+ ["Young energetic female, cheerful and bright, American"],
489
+ ["Deep authoritative male, news anchor style"],
490
+ ["Gentle elderly grandmother, kind and soothing"],
491
+ ["Speak with panic creeping into the voice, incredulous"],
492
+ ],
493
+ inputs=[vd_desc], label="Examples",
 
494
  )
495
  vd_btn = gr.Button("Generate", variant="primary")
496
  with gr.Column():
497
  vd_audio = gr.Audio(label="Generated Speech", type="filepath")
 
498
  vd_btn.click(fn=generate_voice_design,
499
+ inputs=[vd_text, vd_lang, vd_desc], outputs=vd_audio)
 
500
 
501
+ # ── Tab 3: Voice Clone ──
502
  with gr.Tab("Voice Clone"):
503
+ gr.Markdown("Clone any voice from a short audio sample (3+ seconds).")
504
  with gr.Row():
505
  with gr.Column():
506
+ vc_text = gr.Textbox(label="Text (in cloned voice)", lines=4, placeholder="What should the cloned voice say...")
 
507
  vc_lang = gr.Dropdown(choices=LANGUAGES, value="English", label="Language")
508
+ vc_ref = gr.Audio(label="Reference Audio (3+ seconds)", type="filepath")
509
+ vc_ref_text = gr.Textbox(label="Transcript (optional, improves quality)",
510
+ placeholder="What the person says in the reference...")
511
  vc_btn = gr.Button("Clone & Generate", variant="primary")
512
  with gr.Column():
513
+ vc_audio = gr.Audio(label="Cloned Voice Speech", type="filepath")
 
514
  vc_btn.click(fn=generate_voice_clone,
515
+ inputs=[vc_text, vc_lang, vc_ref, vc_ref_text], outputs=vc_audio)
516
+
517
+ # ── Tab 4: Multi-Speaker Story ──
518
+ with gr.Tab("Multi-Speaker Story"):
519
+ gr.Markdown(
520
+ "Paste a story with dialogue and the AI automatically:\n"
521
+ "1. Detects all characters and their genders\n"
522
+ "2. Assigns unique voices (male/female matched)\n"
523
+ "3. Adds emotion instructions per line (sad, whispered, excited...)\n"
524
+ "4. Generates the full audiobook with different voices\n\n"
525
+ "*Requires DASHSCOPE_API_KEY for character/emotion analysis. TTS is local.*"
526
+ )
527
+ with gr.Row():
528
+ with gr.Column():
529
+ ms_text = gr.Textbox(label="Story Text", lines=10, placeholder="Paste your story with dialogue...")
530
+ ms_file = gr.File(label="Or Upload (.txt, .pdf, .docx)",
531
+ file_types=[".txt", ".md", ".pdf", ".docx"], type="filepath")
532
+ ms_lang = gr.Dropdown(choices=LANGUAGES, value="English", label="Language")
533
+ ms_sample = gr.Button("Load Sample Story", variant="secondary", size="sm")
534
+ ms_btn = gr.Button("Generate Multi-Speaker Audiobook", variant="primary", size="lg")
535
+ with gr.Column():
536
+ ms_audio = gr.Audio(label="Multi-Speaker Audiobook", type="filepath")
537
+ ms_stats = gr.Markdown(label="Cast & Stats")
538
+ with gr.Accordion("Full Transcript (with emotions)", open=False):
539
+ ms_transcript = gr.Markdown()
540
+
541
+ ms_sample.click(fn=lambda: SAMPLE_STORY, outputs=ms_text)
542
+ ms_btn.click(fn=generate_multi_speaker_story,
543
+ inputs=[ms_text, ms_file, ms_lang],
544
+ outputs=[ms_audio, ms_stats, ms_transcript])
545
 
546
  gr.Markdown(
547
  "---\n"
548
+ "**Models:** Qwen3-TTS-12Hz-1.7B (Apache 2.0) | "
549
  "**Languages:** EN, ZH, JA, KO, DE, FR, RU, PT, ES, IT | "
550
+ "**TTS:** Self-hosted on ZeroGPU | "
551
+ "**Character Analysis:** Cloud AI (optional, for Multi-Speaker mode)"
552
  )
553
 
554
  if __name__ == "__main__":
packages.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ sox
2
+ ffmpeg
3
+ libsox-fmt-all
requirements.txt CHANGED
@@ -3,3 +3,6 @@ torch>=2.1.0
3
  soundfile>=0.12.0
4
  gradio>=5.25.0
5
  audioop-lts; python_version >= "3.13"
 
 
 
 
3
  soundfile>=0.12.0
4
  gradio>=5.25.0
5
  audioop-lts; python_version >= "3.13"
6
+ openai>=1.52.0
7
+ pypdf>=4.0.0
8
+ python-docx>=1.1.0