Upload app.py
Browse files
app.py
CHANGED
|
@@ -145,20 +145,128 @@ def generate_voice_design(text, language, voice_description):
|
|
| 145 |
return path
|
| 146 |
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
@spaces.GPU
|
| 149 |
-
def generate_voice_clone(text, language, ref_audio, ref_text):
|
| 150 |
if not text.strip():
|
| 151 |
raise gr.Error("Please enter some text.")
|
| 152 |
if ref_audio is None:
|
| 153 |
raise gr.Error("Please upload a reference audio sample.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
model = get_model("clone")
|
| 155 |
lang = language if language != "Auto" else "Auto"
|
| 156 |
-
kwargs = {"text":
|
| 157 |
if ref_text and ref_text.strip():
|
| 158 |
kwargs["ref_text"] = ref_text.strip()
|
| 159 |
else:
|
| 160 |
kwargs["x_vector_only_mode"] = True
|
| 161 |
-
print(f"[TTS] Clone: lang={lang}")
|
| 162 |
wavs, sr = model.generate_voice_clone(**kwargs)
|
| 163 |
path = os.path.join(OUTPUT_DIR, f"clone_{int(time.time())}.wav")
|
| 164 |
sf.write(path, wavs[0], sr)
|
|
@@ -445,7 +553,16 @@ The old man was silent for a long time. "I already did," he finally whispered. "
|
|
| 445 |
|
| 446 |
DESCRIPTION = """
|
| 447 |
# Qwen3-TTS Demo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
|
|
|
|
| 449 |
"""
|
| 450 |
|
| 451 |
with gr.Blocks(title="Qwen3-TTS Demo") as demo:
|
|
@@ -497,7 +614,10 @@ with gr.Blocks(title="Qwen3-TTS Demo") as demo:
|
|
| 497 |
|
| 498 |
# ββ Tab 3: Voice Clone ββ
|
| 499 |
with gr.Tab("Voice Clone"):
|
| 500 |
-
gr.Markdown(
|
|
|
|
|
|
|
|
|
|
| 501 |
with gr.Row():
|
| 502 |
with gr.Column():
|
| 503 |
vc_text = gr.Textbox(label="Text (in cloned voice)", lines=4, placeholder="What should the cloned voice say...")
|
|
@@ -505,11 +625,13 @@ with gr.Blocks(title="Qwen3-TTS Demo") as demo:
|
|
| 505 |
vc_ref = gr.Audio(label="Reference Audio (3+ seconds)", type="filepath")
|
| 506 |
vc_ref_text = gr.Textbox(label="Transcript (optional, improves quality)",
|
| 507 |
placeholder="What the person says in the reference...")
|
|
|
|
|
|
|
| 508 |
vc_btn = gr.Button("Clone & Generate", variant="primary")
|
| 509 |
with gr.Column():
|
| 510 |
vc_audio = gr.Audio(label="Cloned Voice Speech", type="filepath")
|
| 511 |
vc_btn.click(fn=generate_voice_clone,
|
| 512 |
-
inputs=[vc_text, vc_lang, vc_ref, vc_ref_text], outputs=vc_audio)
|
| 513 |
|
| 514 |
# ββ Tab 4: Multi-Speaker Story ββ
|
| 515 |
with gr.Tab("Multi-Speaker Story"):
|
|
@@ -517,8 +639,9 @@ with gr.Blocks(title="Qwen3-TTS Demo") as demo:
|
|
| 517 |
"Paste a story with dialogue and the AI automatically:\n"
|
| 518 |
"1. Detects all characters and their genders\n"
|
| 519 |
"2. Assigns unique voices (male/female matched)\n"
|
| 520 |
-
"3. Adds emotion instructions per line
|
| 521 |
"4. Generates the full audiobook with different voices\n\n"
|
|
|
|
| 522 |
"*Requires DASHSCOPE_API_KEY for character/emotion analysis. TTS is local.*"
|
| 523 |
)
|
| 524 |
with gr.Row():
|
|
@@ -526,6 +649,18 @@ with gr.Blocks(title="Qwen3-TTS Demo") as demo:
|
|
| 526 |
ms_text = gr.Textbox(label="Story Text", lines=10, placeholder="Paste your story with dialogue...")
|
| 527 |
ms_file = gr.File(label="Or Upload (.txt, .pdf, .docx)",
|
| 528 |
file_types=[".txt", ".md", ".pdf", ".docx"], type="filepath")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
ms_lang = gr.Dropdown(choices=LANGUAGES, value="English", label="Language")
|
| 530 |
ms_sample = gr.Button("Load Sample Story", variant="secondary", size="sm")
|
| 531 |
ms_btn = gr.Button("Generate Multi-Speaker Audiobook", variant="primary", size="lg")
|
|
@@ -535,14 +670,57 @@ with gr.Blocks(title="Qwen3-TTS Demo") as demo:
|
|
| 535 |
with gr.Accordion("Full Transcript (with emotions)", open=False):
|
| 536 |
ms_transcript = gr.Markdown()
|
| 537 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
ms_sample.click(fn=lambda: SAMPLE_STORY, outputs=ms_text)
|
| 539 |
ms_btn.click(fn=generate_multi_speaker_story,
|
| 540 |
inputs=[ms_text, ms_file, ms_lang],
|
| 541 |
outputs=[ms_audio, ms_stats, ms_transcript])
|
| 542 |
|
|
|
|
| 543 |
gr.Markdown(
|
| 544 |
"---\n"
|
|
|
|
| 545 |
"**Languages:** EN, ZH, JA, KO, DE, FR, RU, PT, ES, IT | "
|
|
|
|
| 546 |
"**Character Analysis:** Cloud AI (optional, for Multi-Speaker mode)"
|
| 547 |
)
|
| 548 |
|
|
|
|
| 145 |
return path
|
| 146 |
|
| 147 |
|
| 148 |
+
def enhance_text_with_emotions(client, text):
|
| 149 |
+
"""Use AI to rewrite text with emotional delivery cues that TTS picks up naturally."""
|
| 150 |
+
if not client:
|
| 151 |
+
return text
|
| 152 |
+
try:
|
| 153 |
+
response = client.chat.completions.create(
|
| 154 |
+
model=OMNI_MODEL, modalities=["text"],
|
| 155 |
+
messages=[
|
| 156 |
+
{
|
| 157 |
+
"role": "system",
|
| 158 |
+
"content": (
|
| 159 |
+
"You are an audiobook performance coach. Rewrite the text to add natural "
|
| 160 |
+
"emotional delivery cues that a TTS voice will pick up from context.\n\n"
|
| 161 |
+
"Techniques:\n"
|
| 162 |
+
"- Add pause markers: '...' for dramatic pauses\n"
|
| 163 |
+
"- Use punctuation for emphasis: '!' for energy, '?' for curiosity\n"
|
| 164 |
+
"- Add breath/pacing cues with commas and dashes\n"
|
| 165 |
+
"- Extend words for emphasis: 'sooo beautiful'\n"
|
| 166 |
+
"- Add interjections: 'Oh!', 'Hmm...', 'Ah,'\n"
|
| 167 |
+
"- Use ellipsis for trailing off: 'I never thought...'\n\n"
|
| 168 |
+
"Rules:\n"
|
| 169 |
+
"1. Keep ALL the original meaning and content\n"
|
| 170 |
+
"2. Do NOT add stage directions or [brackets]\n"
|
| 171 |
+
"3. The output must be speakable text only\n"
|
| 172 |
+
"4. Be subtle β enhance, don't overdo\n"
|
| 173 |
+
"5. Output ONLY the enhanced text"
|
| 174 |
+
),
|
| 175 |
+
},
|
| 176 |
+
{"role": "user", "content": f"Enhance this for emotional TTS delivery:\n\n{text}"},
|
| 177 |
+
],
|
| 178 |
+
)
|
| 179 |
+
enhanced = response.choices[0].message.content.strip()
|
| 180 |
+
print(f"[Emotions] Original: {len(text)} chars -> Enhanced: {len(enhanced)} chars")
|
| 181 |
+
return enhanced
|
| 182 |
+
except Exception as e:
|
| 183 |
+
print(f"[Emotions] Enhancement failed: {e}")
|
| 184 |
+
return text
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def extract_pdf_sections(filepath):
|
| 188 |
+
"""Extract sections/chapters from a PDF and return them as a list."""
|
| 189 |
+
try:
|
| 190 |
+
import pypdf
|
| 191 |
+
except ImportError:
|
| 192 |
+
raise gr.Error("pypdf not installed.")
|
| 193 |
+
|
| 194 |
+
reader = pypdf.PdfReader(filepath)
|
| 195 |
+
full_text = ""
|
| 196 |
+
for page in reader.pages:
|
| 197 |
+
t = page.extract_text()
|
| 198 |
+
if t:
|
| 199 |
+
full_text += t + "\n\n"
|
| 200 |
+
|
| 201 |
+
# Try to detect chapters/sections
|
| 202 |
+
sections = []
|
| 203 |
+
# Common patterns: "Chapter X", "CHAPTER X", "Part X", "Section X", numbered headings
|
| 204 |
+
pattern = r'(?m)^(?:(?:Chapter|CHAPTER|Part|PART|Section|SECTION)\s+\w+[.:)?\s].*|(?:\d+[.)]\s+[A-Z].*)|(?:[IVXLCDM]+[.)]\s+.*))'
|
| 205 |
+
matches = list(re.finditer(pattern, full_text))
|
| 206 |
+
|
| 207 |
+
if matches:
|
| 208 |
+
for i, match in enumerate(matches):
|
| 209 |
+
start = match.start()
|
| 210 |
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(full_text)
|
| 211 |
+
title = match.group().strip()[:80]
|
| 212 |
+
content = full_text[start:end].strip()
|
| 213 |
+
if len(content) > 20:
|
| 214 |
+
sections.append({"title": title, "content": content, "chars": len(content)})
|
| 215 |
+
|
| 216 |
+
# If no sections found, split by double newlines into chunks
|
| 217 |
+
if not sections:
|
| 218 |
+
paragraphs = re.split(r'\n\s*\n', full_text)
|
| 219 |
+
# Group into ~3000 char sections
|
| 220 |
+
current_section = ""
|
| 221 |
+
section_num = 1
|
| 222 |
+
for para in paragraphs:
|
| 223 |
+
para = para.strip()
|
| 224 |
+
if not para:
|
| 225 |
+
continue
|
| 226 |
+
if len(current_section) + len(para) > 3000 and current_section:
|
| 227 |
+
sections.append({
|
| 228 |
+
"title": f"Section {section_num}",
|
| 229 |
+
"content": current_section.strip(),
|
| 230 |
+
"chars": len(current_section),
|
| 231 |
+
})
|
| 232 |
+
section_num += 1
|
| 233 |
+
current_section = para
|
| 234 |
+
else:
|
| 235 |
+
current_section += "\n\n" + para
|
| 236 |
+
if current_section.strip():
|
| 237 |
+
sections.append({
|
| 238 |
+
"title": f"Section {section_num}",
|
| 239 |
+
"content": current_section.strip(),
|
| 240 |
+
"chars": len(current_section),
|
| 241 |
+
})
|
| 242 |
+
|
| 243 |
+
return sections, full_text
|
| 244 |
+
|
| 245 |
+
|
| 246 |
@spaces.GPU
|
| 247 |
+
def generate_voice_clone(text, language, ref_audio, ref_text, add_emotions):
|
| 248 |
if not text.strip():
|
| 249 |
raise gr.Error("Please enter some text.")
|
| 250 |
if ref_audio is None:
|
| 251 |
raise gr.Error("Please upload a reference audio sample.")
|
| 252 |
+
|
| 253 |
+
# Enhance text with emotions if requested
|
| 254 |
+
final_text = text
|
| 255 |
+
if add_emotions:
|
| 256 |
+
client = get_llm_client()
|
| 257 |
+
if client:
|
| 258 |
+
final_text = enhance_text_with_emotions(client, text)
|
| 259 |
+
else:
|
| 260 |
+
print("[Clone] No LLM client for emotion enhancement, using raw text")
|
| 261 |
+
|
| 262 |
model = get_model("clone")
|
| 263 |
lang = language if language != "Auto" else "Auto"
|
| 264 |
+
kwargs = {"text": final_text, "language": lang, "ref_audio": ref_audio}
|
| 265 |
if ref_text and ref_text.strip():
|
| 266 |
kwargs["ref_text"] = ref_text.strip()
|
| 267 |
else:
|
| 268 |
kwargs["x_vector_only_mode"] = True
|
| 269 |
+
print(f"[TTS] Clone: lang={lang}, emotions={'yes' if add_emotions else 'no'}")
|
| 270 |
wavs, sr = model.generate_voice_clone(**kwargs)
|
| 271 |
path = os.path.join(OUTPUT_DIR, f"clone_{int(time.time())}.wav")
|
| 272 |
sf.write(path, wavs[0], sr)
|
|
|
|
| 553 |
|
| 554 |
DESCRIPTION = """
|
| 555 |
# Qwen3-TTS Demo
|
| 556 |
+
### Open-Source Text-to-Speech (1.7B) β Self-Hosted, No API Keys for TTS
|
| 557 |
+
|
| 558 |
+
| Mode | What it does |
|
| 559 |
+
|------|-------------|
|
| 560 |
+
| **Custom Voice** | Pick a preset voice + optional emotion instruction |
|
| 561 |
+
| **Voice Design** | Describe any voice in natural language |
|
| 562 |
+
| **Voice Clone** | Clone a voice from 3 seconds of audio |
|
| 563 |
+
| **Multi-Speaker Story** | Auto-detect characters, assign voices, add emotions β full audiobook |
|
| 564 |
|
| 565 |
+
10 languages supported. Running on ZeroGPU (free).
|
| 566 |
"""
|
| 567 |
|
| 568 |
with gr.Blocks(title="Qwen3-TTS Demo") as demo:
|
|
|
|
| 614 |
|
| 615 |
# ββ Tab 3: Voice Clone ββ
|
| 616 |
with gr.Tab("Voice Clone"):
|
| 617 |
+
gr.Markdown(
|
| 618 |
+
"Clone any voice from a short audio sample (3+ seconds).\n"
|
| 619 |
+
"Enable 'Enhance with emotions' to have AI add natural emotional cues to the text."
|
| 620 |
+
)
|
| 621 |
with gr.Row():
|
| 622 |
with gr.Column():
|
| 623 |
vc_text = gr.Textbox(label="Text (in cloned voice)", lines=4, placeholder="What should the cloned voice say...")
|
|
|
|
| 625 |
vc_ref = gr.Audio(label="Reference Audio (3+ seconds)", type="filepath")
|
| 626 |
vc_ref_text = gr.Textbox(label="Transcript (optional, improves quality)",
|
| 627 |
placeholder="What the person says in the reference...")
|
| 628 |
+
vc_emotions = gr.Checkbox(value=False, label="Enhance with emotions",
|
| 629 |
+
info="AI adds natural emotional cues (pauses, emphasis, interjections) to make the delivery more expressive. Requires DASHSCOPE_API_KEY.")
|
| 630 |
vc_btn = gr.Button("Clone & Generate", variant="primary")
|
| 631 |
with gr.Column():
|
| 632 |
vc_audio = gr.Audio(label="Cloned Voice Speech", type="filepath")
|
| 633 |
vc_btn.click(fn=generate_voice_clone,
|
| 634 |
+
inputs=[vc_text, vc_lang, vc_ref, vc_ref_text, vc_emotions], outputs=vc_audio)
|
| 635 |
|
| 636 |
# ββ Tab 4: Multi-Speaker Story ββ
|
| 637 |
with gr.Tab("Multi-Speaker Story"):
|
|
|
|
| 639 |
"Paste a story with dialogue and the AI automatically:\n"
|
| 640 |
"1. Detects all characters and their genders\n"
|
| 641 |
"2. Assigns unique voices (male/female matched)\n"
|
| 642 |
+
"3. Adds emotion instructions per line\n"
|
| 643 |
"4. Generates the full audiobook with different voices\n\n"
|
| 644 |
+
"**PDF users:** Upload a PDF, click 'Detect Sections' to pick a specific chapter.\n\n"
|
| 645 |
"*Requires DASHSCOPE_API_KEY for character/emotion analysis. TTS is local.*"
|
| 646 |
)
|
| 647 |
with gr.Row():
|
|
|
|
| 649 |
ms_text = gr.Textbox(label="Story Text", lines=10, placeholder="Paste your story with dialogue...")
|
| 650 |
ms_file = gr.File(label="Or Upload (.txt, .pdf, .docx)",
|
| 651 |
file_types=[".txt", ".md", ".pdf", ".docx"], type="filepath")
|
| 652 |
+
|
| 653 |
+
# PDF section selection
|
| 654 |
+
with gr.Accordion("PDF Section Selection (optional)", open=False):
|
| 655 |
+
ms_detect_btn = gr.Button("Detect Sections from PDF", variant="secondary", size="sm")
|
| 656 |
+
ms_sections_info = gr.Markdown(value="Upload a PDF then click 'Detect Sections' to see chapters.")
|
| 657 |
+
ms_section_choice = gr.Dropdown(
|
| 658 |
+
choices=[], label="Select Section to Generate",
|
| 659 |
+
visible=False, allow_custom_value=False,
|
| 660 |
+
info="Pick a chapter/section, or close this accordion to process the full text",
|
| 661 |
+
)
|
| 662 |
+
ms_load_section = gr.Button("Load Selected Section", variant="secondary", size="sm", visible=False)
|
| 663 |
+
|
| 664 |
ms_lang = gr.Dropdown(choices=LANGUAGES, value="English", label="Language")
|
| 665 |
ms_sample = gr.Button("Load Sample Story", variant="secondary", size="sm")
|
| 666 |
ms_btn = gr.Button("Generate Multi-Speaker Audiobook", variant="primary", size="lg")
|
|
|
|
| 670 |
with gr.Accordion("Full Transcript (with emotions)", open=False):
|
| 671 |
ms_transcript = gr.Markdown()
|
| 672 |
|
| 673 |
+
# Section detection state
|
| 674 |
+
_section_cache = gr.State(value=[])
|
| 675 |
+
|
| 676 |
+
def detect_sections_from_pdf(file_input):
|
| 677 |
+
if file_input is None:
|
| 678 |
+
raise gr.Error("Upload a PDF first.")
|
| 679 |
+
ext = os.path.splitext(file_input)[1].lower()
|
| 680 |
+
if ext != ".pdf":
|
| 681 |
+
raise gr.Error("Section detection works with PDF files. For other formats, paste text directly.")
|
| 682 |
+
sections, _ = extract_pdf_sections(file_input)
|
| 683 |
+
if not sections:
|
| 684 |
+
return "No sections detected.", gr.update(visible=False), gr.update(visible=False), []
|
| 685 |
+
choices = [f"{s['title']} ({s['chars']:,} chars)" for s in sections]
|
| 686 |
+
info = f"**Found {len(sections)} sections:**\n\n"
|
| 687 |
+
for i, s in enumerate(sections):
|
| 688 |
+
preview = s["content"][:100].replace("\n", " ")
|
| 689 |
+
info += f"{i+1}. **{s['title']}** ({s['chars']:,} chars) β {preview}...\n"
|
| 690 |
+
return (
|
| 691 |
+
info,
|
| 692 |
+
gr.update(visible=True, choices=choices, value=choices[0]),
|
| 693 |
+
gr.update(visible=True),
|
| 694 |
+
sections,
|
| 695 |
+
)
|
| 696 |
+
|
| 697 |
+
def load_selected_section(choice, sections):
|
| 698 |
+
if not sections or not choice:
|
| 699 |
+
return ""
|
| 700 |
+
idx = next((i for i, s in enumerate(sections)
|
| 701 |
+
if f"{s['title']} ({s['chars']:,} chars)" == choice), 0)
|
| 702 |
+
return sections[idx]["content"]
|
| 703 |
+
|
| 704 |
+
ms_detect_btn.click(
|
| 705 |
+
fn=detect_sections_from_pdf, inputs=[ms_file],
|
| 706 |
+
outputs=[ms_sections_info, ms_section_choice, ms_load_section, _section_cache],
|
| 707 |
+
)
|
| 708 |
+
ms_load_section.click(
|
| 709 |
+
fn=load_selected_section, inputs=[ms_section_choice, _section_cache],
|
| 710 |
+
outputs=[ms_text],
|
| 711 |
+
)
|
| 712 |
+
|
| 713 |
ms_sample.click(fn=lambda: SAMPLE_STORY, outputs=ms_text)
|
| 714 |
ms_btn.click(fn=generate_multi_speaker_story,
|
| 715 |
inputs=[ms_text, ms_file, ms_lang],
|
| 716 |
outputs=[ms_audio, ms_stats, ms_transcript])
|
| 717 |
|
| 718 |
+
|
| 719 |
gr.Markdown(
|
| 720 |
"---\n"
|
| 721 |
+
"**Models:** Qwen3-TTS-12Hz-1.7B (Apache 2.0) | "
|
| 722 |
"**Languages:** EN, ZH, JA, KO, DE, FR, RU, PT, ES, IT | "
|
| 723 |
+
"**TTS:** Self-hosted on ZeroGPU | "
|
| 724 |
"**Character Analysis:** Cloud AI (optional, for Multi-Speaker mode)"
|
| 725 |
)
|
| 726 |
|