ACloudCenter Claude Sonnet 5 commited on
Commit
248b12c
·
1 Parent(s): 4295d1f

Rebuild frontend as FastAPI + hand-built UI, replacing Gradio

Browse files

Gradio's default styling no longer matched the bar for HF Space UIs, so
the frontend is now a FastAPI app serving a custom static UI (index.html/
app.js/styles.css) instead of gr.Blocks. Feature-for-feature parity with
the old app: AI script generation, example scripts, paste/upload script,
per-turn speaker/voice editing, voice preview, and CFG/model controls.

Generation progress now streams from Modal via real Server-Sent Events
instead of a client-side guessed timer, using the same stage/status/log
payloads modal_runner.py already emitted. Switches the Space to a Docker
SDK (Dockerfile added, requirements swapped from gradio to fastapi/
uvicorn/scipy) since sdk:gradio no longer applies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files changed (9) hide show
  1. .gitignore +3 -0
  2. Dockerfile +18 -0
  3. README.md +20 -17
  4. app.py +281 -858
  5. backend_modal/modal_runner.py +391 -67
  6. requirements.txt +5 -1
  7. static/app.js +481 -0
  8. static/index.html +235 -0
  9. static/styles.css +473 -0
.gitignore CHANGED
@@ -58,3 +58,6 @@ run_phase3.py
58
  # Large sample media hosted externally via GitHub user-attachments CDN
59
  public/sample-generations/*.mp4
60
  public/sample-generations/*.wav
 
 
 
 
58
  # Large sample media hosted externally via GitHub user-attachments CDN
59
  public/sample-generations/*.mp4
60
  public/sample-generations/*.wav
61
+
62
+ # Local working notes (cold-start brief), never committed
63
+ PIPELINE_NOTES.md
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN useradd -m -u 1000 user
4
+ WORKDIR /home/user/app
5
+
6
+ COPY --chown=1000:1000 requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ COPY --chown=1000:1000 . .
10
+
11
+ USER 1000
12
+ ENV HOME=/home/user \
13
+ PATH=/home/user/.local/bin:$PATH \
14
+ PYTHONUNBUFFERED=1
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -3,9 +3,8 @@ title: Conference Generator VibeVoice
3
  emoji: ⭐
4
  colorFrom: indigo
5
  colorTo: red
6
- sdk: gradio
7
- sdk_version: "5.44.1"
8
- app_file: app.py
9
  pinned: false
10
  ---
11
 
@@ -100,21 +99,23 @@ VibeVoice leads on preference, realism, and richness among long-form multi-speak
100
 
101
  ## Architecture
102
 
103
- This project separates the lightweight Gradio frontend (hosted on HF Spaces) from the GPU-heavy model backend (hosted on [Modal](https://modal.com)).
104
 
105
  ```
106
- ┌──────────────────────┐ ┌─────────────────────────┐
107
- │ HF Space (Gradio) │ │ Modal (GPU backend) │
108
- │ ───────────────── │ │ ─────────────────── │
109
- │ • Prompt UI │ ───► │ • VibeVoice-1.5B / 7B │
110
- │ • Script editor │ │ • Voice prompt loader │
111
- │ • Qwen2.5-Coder 32B │ │ • Long-form synthesis │
112
- │ (script writing) │ ◄─── │ • Returns WAV bytes │
113
- └──────────────────────┘ └─────────────────────────┘
 
 
114
  ```
115
 
116
- - **Frontend** (`app.py`): Gradio UI, script generation via HF Inference API (Qwen2.5-Coder-32B), script parsing, playback.
117
- - **Backend** (`backend_modal/`, not included in this repo): deployed separately on Modal as a class-based GPU service exposing `generate_podcast`.
118
 
119
  ---
120
 
@@ -159,8 +160,10 @@ Required env:
159
 
160
  ```
161
  .
162
- ├── app.py # Gradio frontend + script generation
163
- ├── requirements.txt # gradio, modal, huggingface_hub
 
 
164
  ├── public/
165
  │ ├── images/ # Banner, architecture diagram, screenshots
166
  │ ├── voices/ # Voice reference clips (Cherry, Chicago, ...)
@@ -177,6 +180,6 @@ Required env:
177
  - **[VibeVoice](https://github.com/microsoft/VibeVoice)** — Microsoft Research's long-form multi-speaker TTS model
178
  - **[Qwen2.5-Coder-32B](https://huggingface.co/Qwen/Qwen2.5-Coder-32B-Instruct)** — script generation
179
  - **[Modal](https://modal.com)** — GPU compute for inference
180
- - **[Gradio](https://gradio.app)** + **[Hugging Face Spaces](https://huggingface.co/spaces)** — frontend hosting
181
 
182
  ---
 
3
  emoji: ⭐
4
  colorFrom: indigo
5
  colorTo: red
6
+ sdk: docker
7
+ app_port: 7860
 
8
  pinned: false
9
  ---
10
 
 
99
 
100
  ## Architecture
101
 
102
+ This project separates the lightweight FastAPI frontend (hosted on HF Spaces as a Docker Space) from the GPU-heavy model backend (hosted on [Modal](https://modal.com)).
103
 
104
  ```
105
+ ┌──────────────────────────┐ ┌─────────────────────────┐
106
+ │ HF Space (FastAPI + │ │ Modal (GPU backend) │
107
+ │ static HTML/CSS/JS) │ │ ─────────────────── │
108
+ │ ───────────────────── │ │ • VibeVoice-1.5B / 7B │
109
+ │ • Prompt UI │ ───► │ • Voice prompt loader │
110
+ │ • Script editor │ │ • Long-form synthesis │
111
+ │ • Qwen2.5-Coder 32B │ │ • Streams progress + │
112
+ │ (script writing) │ ◄─── │ WAV bytes │
113
+ │ • SSE progress streaming│ │ │
114
+ └──────────────────────────┘ └─────────────────────────┘
115
  ```
116
 
117
+ - **Frontend** (`app.py` + `static/`): FastAPI app serving a hand-built static UI (`index.html`/`app.js`/`styles.css`), script generation via HF Inference API (Qwen2.5-Coder-32B), script parsing, and a `/api/generate` SSE endpoint that streams live progress from Modal.
118
+ - **Backend** (`backend_modal/modal_runner.py`): deployed separately on Modal as a class-based GPU service exposing `generate_podcast`. The VibeVoice model code and voice reference WAVs under `backend_modal/` are gitignored; see Running locally.
119
 
120
  ---
121
 
 
160
 
161
  ```
162
  .
163
+ ├── app.py # FastAPI frontend + script generation
164
+ ├── Dockerfile # HF Docker Space image
165
+ ├── static/ # Hand-built UI (index.html, app.js, styles.css)
166
+ ├── requirements.txt # fastapi, uvicorn, modal, huggingface_hub, numpy, scipy
167
  ├── public/
168
  │ ├── images/ # Banner, architecture diagram, screenshots
169
  │ ├── voices/ # Voice reference clips (Cherry, Chicago, ...)
 
180
  - **[VibeVoice](https://github.com/microsoft/VibeVoice)** — Microsoft Research's long-form multi-speaker TTS model
181
  - **[Qwen2.5-Coder-32B](https://huggingface.co/Qwen/Qwen2.5-Coder-32B-Instruct)** — script generation
182
  - **[Modal](https://modal.com)** — GPU compute for inference
183
+ - **[FastAPI](https://fastapi.tiangolo.com)** + **[Hugging Face Spaces](https://huggingface.co/spaces)** (Docker SDK) — frontend hosting
184
 
185
  ---
app.py CHANGED
@@ -1,11 +1,28 @@
 
 
 
 
 
1
  import os
2
  import re
3
- import gradio as gr
4
- import modal
5
  import traceback
 
 
 
 
 
 
 
 
 
6
  from huggingface_hub import InferenceClient
 
 
7
 
8
  # --- Configuration ---
 
9
  MODAL_STUB_NAME = "vibevoice-generator"
10
  MODAL_CLASS_NAME = "VibeVoiceModel"
11
 
@@ -15,29 +32,24 @@ VOICE_GENDERS = {
15
  "Mantis": "F", "Sponge": "M", "Starchild": "F",
16
  }
17
  AVAILABLE_VOICES = list(VOICE_GENDERS.keys())
18
- VOICE_DISPLAY = [f"{name} ({g})" for name, g in VOICE_GENDERS.items()]
19
- DEFAULT_SPEAKERS_DISPLAY = ["Cherry (F)", "Chicago (M)", "Janus (M)", "Mantis (F)"]
20
-
21
-
22
- def voice_display_to_name(display: str) -> str:
23
- """Strip gender tag: 'Cherry (F)' -> 'Cherry'"""
24
- if display and " (" in display:
25
- return display.rsplit(" (", 1)[0]
26
- return display
27
 
28
  SCRIPT_GEN_MODEL = "Qwen/Qwen2.5-Coder-32B-Instruct"
29
  SCRIPT_MAX_WORDS = 1000 # AI generation cap
30
- MAX_SCRIPT_WORDS = 1500 # Hard limit for audio generation (~10 min)
 
 
31
  MAX_TURNS = 50 # Max conversation turns
 
32
 
33
 
34
  # --- Load example scripts ---
35
  def load_example_scripts():
36
- examples_dir = "text_examples"
37
  example_scripts = []
38
  example_scripts_natural = []
39
 
40
- if not os.path.exists(examples_dir):
41
  return example_scripts, example_scripts_natural
42
 
43
  original_files = [
@@ -52,27 +64,28 @@ def load_example_scripts():
52
  ]
53
 
54
  for txt_file in original_files:
55
- file_path = os.path.join(examples_dir, txt_file)
56
- natural_file = txt_file.replace(".txt", "_natural.txt")
57
- natural_path = os.path.join(examples_dir, natural_file)
58
 
59
- if os.path.exists(file_path):
60
- with open(file_path, "r", encoding="utf-8") as f:
61
- example_scripts.append(f.read())
62
  else:
63
  example_scripts.append("")
64
 
65
- if os.path.exists(natural_path):
66
- with open(natural_path, "r", encoding="utf-8") as f:
67
- example_scripts_natural.append(f.read())
68
  else:
69
- example_scripts_natural.append(
70
- example_scripts[-1] if example_scripts else ""
71
- )
72
 
73
  return example_scripts, example_scripts_natural
74
 
75
 
 
 
 
 
 
 
76
  SCRIPT_SPEAKER_COUNTS = [1, 1, 2, 2, 3, 3, 4, 4]
77
  EXAMPLE_SCRIPTS, EXAMPLE_SCRIPTS_NATURAL = load_example_scripts()
78
 
@@ -180,16 +193,6 @@ def turns_to_script(turns: list[dict]) -> str:
180
  return "\n\n".join(lines)
181
 
182
 
183
- def estimate_duration(turns: list[dict]) -> str:
184
- total_words = sum(len(t.get("text", "").split()) for t in turns)
185
- if total_words == 0:
186
- return ""
187
- minutes = total_words / 150
188
- if minutes < 1:
189
- return f"~{int(minutes * 60)}s"
190
- return f"~{minutes:.1f}m"
191
-
192
-
193
  # --- AI Script Generation ---
194
 
195
  hf_token = os.environ.get("HF_TOKEN")
@@ -309,15 +312,15 @@ def _extract_genders(raw: str) -> tuple[str, dict[int, str]]:
309
  return cleaned, genders
310
 
311
 
312
- def assign_voices_by_gender(genders: dict[int, str], num_speakers: int) -> list[str]:
313
- """Return a list of 4 voice-display strings, picking matching-gender voices without duplicates.
314
 
315
- Falls back to DEFAULT_SPEAKERS_DISPLAY if no gender info for a slot.
316
  """
317
  female_pool = [v for v in AVAILABLE_VOICES if VOICE_GENDERS.get(v) == "F"]
318
  male_pool = [v for v in AVAILABLE_VOICES if VOICE_GENDERS.get(v) == "M"]
319
  used: set[str] = set()
320
- chosen: list[str] = []
321
 
322
  for i in range(4):
323
  slot = i + 1
@@ -326,16 +329,15 @@ def assign_voices_by_gender(genders: dict[int, str], num_speakers: int) -> list[
326
  pool = female_pool if g == "F" else (male_pool if g == "M" else AVAILABLE_VOICES)
327
  pick = next((v for v in pool if v not in used), None)
328
  if pick is None:
329
- # exhausted preferred pool — fall back to any unused voice
330
  pick = next((v for v in AVAILABLE_VOICES if v not in used), AVAILABLE_VOICES[0])
331
  used.add(pick)
332
- chosen.append(f"{pick} ({VOICE_GENDERS.get(pick, '?')})")
333
  else:
334
- chosen.append(DEFAULT_SPEAKERS_DISPLAY[i] if i < len(DEFAULT_SPEAKERS_DISPLAY) else None)
335
  return chosen
336
 
337
 
338
- def generate_script_from_prompt(prompt: str) -> tuple[list[dict], int, str, list[str]]:
339
  """Returns (turns, num_speakers, title, voice_selections)."""
340
  system = SCRIPT_SYSTEM_PROMPT.format(max_words=SCRIPT_MAX_WORDS)
341
  response = llm_client.chat_completion(
@@ -408,6 +410,24 @@ def generate_parody_story(prompt: str) -> list[str]:
408
  return ["Generating your audio... hang tight!"]
409
 
410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  # --- Modal Connection ---
412
  try:
413
  RemoteVibeVoiceModel = modal.Cls.from_name(MODAL_STUB_NAME, MODAL_CLASS_NAME)
@@ -420,834 +440,237 @@ except modal.exception.NotFoundError:
420
  remote_generate_function = None
421
 
422
 
423
- # --- Theme & CSS ---
424
- theme = gr.themes.Ocean(
425
- primary_hue="indigo",
426
- secondary_hue="fuchsia",
427
- neutral_hue="slate",
428
- ).set(button_large_radius="*radius_sm")
429
-
430
- SPEAKER_COLORS = ["#6366f1", "#ec4899", "#22c55e", "#f59e0b"]
431
-
432
- CUSTOM_CSS = """
433
- /* ---- Conversation scroll container ---- */
434
- .conversation-scroll {
435
- max-height: 480px;
436
- overflow-y: auto;
437
- border: 1px solid var(--border-color-primary);
438
- border-radius: 10px;
439
- padding: 10px;
440
- background: var(--background-fill-secondary);
441
- }
442
- .conversation-scroll::-webkit-scrollbar { width: 6px; }
443
- .conversation-scroll::-webkit-scrollbar-thumb {
444
- background: var(--border-color-primary);
445
- border-radius: 3px;
446
- }
447
 
448
- /* ---- Speaker color bars ---- */
449
- """ + "\n".join(f"""
450
- .speaker-{i+1} {{
451
- border-left: 4px solid {c} !important;
452
- padding-left: 10px !important;
453
- margin-bottom: 6px !important;
454
- border-radius: 8px !important;
455
- background: {c}08 !important;
456
- }}""" for i, c in enumerate(SPEAKER_COLORS)) + """
457
-
458
- /* ---- Voice settings row ---- */
459
- .voice-row {
460
- border: 1px solid var(--border-color-primary);
461
- border-radius: 10px;
462
- padding: 12px 16px;
463
- background: var(--background-fill-secondary);
464
- }
465
 
466
- /* ---- Example pill buttons ---- */
467
- .example-btn button {
468
- border-radius: 20px !important;
469
- font-size: 0.85em !important;
470
- }
471
 
472
- /* ---- CTA buttons ---- */
473
- .cta-btn button {
474
- border-radius: 10px !important;
475
- font-size: 1.1em !important;
476
- padding: 14px 24px !important;
477
- letter-spacing: 0.02em;
478
- }
479
 
480
- /* ---- Status text (inline, no chrome) ---- */
481
- .status-text {
482
- font-style: italic;
483
- opacity: 0.8;
484
- padding: 8px 0;
485
- min-height: 0 !important;
486
- }
487
 
488
- /* ---- Empty state ---- */
489
- .empty-state {
490
- text-align: center;
491
- padding: 48px 20px !important;
492
- opacity: 0.6;
493
- }
494
 
495
- /* ---- Generation status banner ---- */
496
- .gen-status {
497
- border-radius: 12px;
498
- padding: 18px 24px;
499
- margin-top: 10px;
500
- text-align: center;
501
- font-size: 1.05em;
502
- min-height: 0;
503
- }
504
- .gen-status-active {
505
- border: 2px solid #6366f1;
506
- background: linear-gradient(
507
- 90deg,
508
- rgba(99, 102, 241, 0.05) 0%,
509
- rgba(99, 102, 241, 0.18) 30%,
510
- rgba(236, 72, 153, 0.14) 50%,
511
- rgba(99, 102, 241, 0.18) 70%,
512
- rgba(99, 102, 241, 0.05) 100%
513
- );
514
- background-size: 300% 100%;
515
- animation: gradient-sweep 2.5s ease-in-out infinite, border-glow 2s ease-in-out infinite;
516
- box-shadow: 0 0 15px rgba(99, 102, 241, 0.15), 0 0 30px rgba(99, 102, 241, 0.05);
517
- }
518
- @keyframes gradient-sweep {
519
- 0% { background-position: 100% 0; }
520
- 50% { background-position: 0% 0; }
521
- 100% { background-position: 100% 0; }
522
- }
523
- @keyframes border-glow {
524
- 0%, 100% {
525
- border-color: #6366f1;
526
- box-shadow: 0 0 15px rgba(99, 102, 241, 0.15), 0 0 30px rgba(99, 102, 241, 0.05);
527
- }
528
- 33% {
529
- border-color: #ec4899;
530
- box-shadow: 0 0 15px rgba(236, 72, 153, 0.2), 0 0 30px rgba(236, 72, 153, 0.08);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  }
532
- 66% {
533
- border-color: #22c55e;
534
- box-shadow: 0 0 15px rgba(34, 197, 94, 0.2), 0 0 30px rgba(34, 197, 94, 0.08);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  }
536
- }
537
- """
538
-
539
-
540
- # --- Status helpers ---
541
- AUDIO_LABEL_DEFAULT = "Generated Audio"
542
- PRIMARY_STAGE_MESSAGES = {
543
- "connecting": ("Submitted", "Provisioning GPU resources... cold starts can take up to a minute."),
544
- "queued": ("Queued", "Worker is spinning up. Cold starts may take 30-60 seconds."),
545
- "loading_model": ("Loading Model", "Streaming VibeVoice weights to the GPU."),
546
- "loading_voices": ("Loading Voices", None),
547
- "preparing_inputs": ("Preparing", "Formatting the conversation for the model."),
548
- "generating_audio": ("Generating", "Synthesizing speech — this is the longest step."),
549
- "processing_audio": ("Finalizing", "Converting tensors into a playable waveform."),
550
- "complete": ("Complete", "Press play below or download your audio."),
551
- "error": ("Error", "Check the log for details."),
552
- }
553
- AUDIO_STAGE_LABELS = {
554
- "connecting": "Audio (requesting GPU...)",
555
- "queued": "Audio (GPU warming up...)",
556
- "loading_model": "Audio (loading model...)",
557
- "loading_voices": "Audio (loading voices...)",
558
- "preparing_inputs": "Audio (preparing...)",
559
- "generating_audio": "Audio (generating...)",
560
- "processing_audio": "Audio (finalizing...)",
561
- "error": "Audio (error)",
562
- }
563
 
564
 
565
- def build_status_html(stage: str, status_line: str) -> str:
566
- """Build an HTML status banner for the generation progress."""
567
- title, default_desc = PRIMARY_STAGE_MESSAGES.get(stage, ("Working", "Processing..."))
568
- desc = status_line or default_desc or ""
569
-
570
- if stage == "complete":
571
- icon = '<span style="color:#22c55e; font-size:1.4em; vertical-align:middle;">&#10003;</span>'
572
- cls = "gen-status"
573
- elif stage == "error":
574
- icon = '<span style="color:#ef4444; font-size:1.4em; vertical-align:middle;">&#10007;</span>'
575
- cls = "gen-status"
576
- else:
577
- # Pulsing animated dot
578
- icon = (
579
- '<span style="display:inline-block; width:12px; height:12px; '
580
- 'border-radius:50%; background:#6366f1; vertical-align:middle; '
581
- 'animation: dot-pulse 1.2s ease-in-out infinite;"></span>'
 
 
 
 
 
582
  )
583
- cls = "gen-status gen-status-active"
584
-
585
- return (
586
- f'<style>@keyframes dot-pulse {{ 0%,100% {{ opacity:1; transform:scale(1); }} 50% {{ opacity:0.4; transform:scale(0.7); }} }}</style>'
587
- f'<div class="{cls}">'
588
- f'{icon} <strong style="font-size:1.1em;">{title}</strong>'
589
- f'<br><span style="opacity:0.7; font-size:0.9em;">{desc}</span>'
590
- f'</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  )
592
 
593
 
594
- # ========================================================
595
- # BUILD INTERFACE
596
- # ========================================================
 
597
 
598
- def create_demo_interface():
599
- with gr.Blocks(
600
- title="VibeVoice - Conference Generator",
601
- theme=theme,
602
- css=CUSTOM_CSS,
603
- ) as interface:
604
-
605
- # --- State ---
606
- turns_state = gr.State([])
607
- script_title_state = gr.State("")
608
- parody_lines_state = gr.State([]) # funny loading story for audio generation
609
- # Current voice selection per speaker slot (list of 4 display strings like "Cherry (F)")
610
- voice_selections_state = gr.State(list(DEFAULT_SPEAKERS_DISPLAY))
611
-
612
- # ---- BANNER ----
613
- gr.HTML("""
614
- <div style="width:100%; margin-bottom:12px;">
615
- <img src="https://huggingface.co/spaces/ACloudCenter/Conference-Generator-VibeVoice/resolve/main/public/images/banner.png"
616
- style="width:100%; height:auto; border-radius:14px; box-shadow:0 8px 32px rgba(0,0,0,0.25);"
617
- alt="VibeVoice Banner">
618
- </div>
619
- """)
620
-
621
- with gr.Tabs():
622
- # ==================== GENERATE TAB ====================
623
- with gr.Tab("Generate"):
624
-
625
- # ---- STEP 1: DESCRIBE ----
626
- gr.HTML("""
627
- <p style="margin:0 0 4px 0; opacity:0.65; font-size:0.9em;">
628
- Describe any scenario and AI writes the script. Then edit, assign voices, and generate audio.
629
- </p>
630
- """)
631
- script_prompt = gr.Textbox(
632
- label="Describe your conversation",
633
- placeholder="A wizard and an orc debating battle strategy before a siege...",
634
- lines=2,
635
- max_lines=3,
636
- )
637
- generate_script_btn = gr.Button(
638
- "Write Script with AI", variant="primary",
639
- size="lg", elem_classes="cta-btn",
640
- )
641
- script_gen_status = gr.HTML(value="", elem_classes="status-text")
642
-
643
- # ---- EXAMPLES ----
644
- example_names = [
645
- "AI TED Talk", "Political Speech",
646
- "Finance IPO", "Telehealth",
647
- "Military Briefing", "Oil & Energy",
648
- "Game Dev Meeting", "Product Review",
649
- ]
650
- with gr.Row():
651
- example_buttons = []
652
- for name in example_names:
653
- btn = gr.Button(name, size="sm", variant="secondary",
654
- elem_classes="example-btn", min_width=80)
655
- example_buttons.append(btn)
656
-
657
- # ---- STEP 2: SCRIPT EDITOR ----
658
- with gr.Row():
659
- script_title_display = gr.HTML(value="<h3 style='margin:0'>Script</h3>")
660
- duration_display = gr.HTML(value="")
661
-
662
- with gr.Column(elem_classes="conversation-scroll"):
663
- @gr.render(inputs=[turns_state, voice_selections_state])
664
- def render_turns(turns, voice_sels):
665
- if not turns:
666
- gr.Markdown(
667
- "Your conversation will appear here.\n\n"
668
- "Type a prompt above and click **Write Script with AI**, "
669
- "or pick an example to get started.",
670
- elem_classes="empty-state",
671
- )
672
- return
673
-
674
- # Build speaker choice labels from the CURRENT voice selections,
675
- # so changing a Voice dropdown below propagates to the tags above.
676
- speaker_choices = []
677
- for i in range(4):
678
- sel = voice_sels[i] if voice_sels and i < len(voice_sels) else None
679
- if sel:
680
- # sel looks like "Cherry (F)" — display directly
681
- speaker_choices.append(f"Speaker {i+1} - {sel}")
682
- else:
683
- speaker_choices.append(f"Speaker {i+1}")
684
-
685
- for idx, turn in enumerate(turns):
686
- spk_num = turn["speaker"]
687
- color_class = f"speaker-{spk_num}" if 1 <= spk_num <= 4 else "speaker-1"
688
- spk_value = speaker_choices[spk_num - 1] if 1 <= spk_num <= 4 else speaker_choices[0]
689
-
690
- with gr.Row(key=f"turn-{idx}", elem_classes=color_class):
691
- spk_dd = gr.Dropdown(
692
- choices=speaker_choices,
693
- value=spk_value,
694
- label="",
695
- scale=1, min_width=115,
696
- container=False,
697
- key=f"spk-{idx}",
698
- )
699
- txt = gr.Textbox(
700
- value=turn["text"],
701
- label="",
702
- lines=2, max_lines=8,
703
- scale=6,
704
- container=False,
705
- key=f"txt-{idx}",
706
- )
707
- del_btn = gr.Button(
708
- "X", size="sm", variant="stop",
709
- scale=0, min_width=36, key=f"del-{idx}",
710
- )
711
-
712
- def on_text_change(new_text, current_turns, i=idx):
713
- if i < len(current_turns):
714
- current_turns[i]["text"] = new_text
715
- return current_turns
716
-
717
- txt.change(fn=on_text_change, inputs=[txt, turns_state],
718
- outputs=[turns_state], queue=False)
719
-
720
- def on_speaker_change(new_spk, current_turns, i=idx):
721
- if i < len(current_turns):
722
- # Parse "Speaker 2 - Chicago (M)" -> 2
723
- m = re.match(r"Speaker (\d+)", new_spk)
724
- if m:
725
- current_turns[i]["speaker"] = int(m.group(1))
726
- return current_turns
727
-
728
- spk_dd.change(fn=on_speaker_change, inputs=[spk_dd, turns_state],
729
- outputs=[turns_state], queue=False)
730
-
731
- def on_delete(current_turns, i=idx):
732
- if i < len(current_turns):
733
- current_turns.pop(i)
734
- return current_turns
735
-
736
- del_btn.click(fn=on_delete, inputs=[turns_state],
737
- outputs=[turns_state])
738
-
739
- add_turn_btn = gr.Button("+ Add Turn", size="sm", variant="secondary")
740
-
741
- # ---- STEP 3: VOICE & MODEL ----
742
- num_speakers = gr.Slider(
743
- minimum=1, maximum=4, value=2, step=1, visible=False,
744
- )
745
-
746
- with gr.Group(elem_classes="voice-row"):
747
- with gr.Row():
748
- model_dropdown = gr.Dropdown(
749
- choices=AVAILABLE_MODELS,
750
- value=AVAILABLE_MODELS[0],
751
- label="Model",
752
- scale=1,
753
- )
754
- speaker_selections = []
755
- for i in range(4):
756
- s = gr.Dropdown(
757
- choices=VOICE_DISPLAY,
758
- value=DEFAULT_SPEAKERS_DISPLAY[i] if i < len(DEFAULT_SPEAKERS_DISPLAY) else None,
759
- label=f"Voice {i+1}",
760
- visible=(i < 2),
761
- scale=1,
762
- )
763
- speaker_selections.append(s)
764
- with gr.Row():
765
- cfg_scale = gr.Slider(
766
- minimum=1.0, maximum=2.0, value=2.0, step=0.05,
767
- label="CFG Scale",
768
- )
769
-
770
- # ---- Voice preview ----
771
- with gr.Accordion("🔊 Preview voices before generating", open=False):
772
- with gr.Row():
773
- preview_voice = gr.Dropdown(
774
- choices=VOICE_DISPLAY,
775
- value=VOICE_DISPLAY[0] if VOICE_DISPLAY else None,
776
- label="Pick a voice",
777
- scale=2,
778
- )
779
- preview_audio = gr.Audio(
780
- label="Sample",
781
- value=os.path.join("public", "voices", f"{AVAILABLE_VOICES[0]}.mp3") if AVAILABLE_VOICES else None,
782
- autoplay=False,
783
- show_download_button=False,
784
- scale=3,
785
- )
786
-
787
- def _load_preview(display: str):
788
- name = voice_display_to_name(display) if display else None
789
- if not name:
790
- return gr.update(value=None)
791
- path = os.path.join("public", "voices", f"{name}.mp3")
792
- if not os.path.exists(path):
793
- return gr.update(value=None)
794
- return gr.update(value=path)
795
-
796
- preview_voice.change(
797
- fn=_load_preview,
798
- inputs=[preview_voice],
799
- outputs=[preview_audio],
800
- queue=False,
801
- )
802
-
803
- # ---- STEP 4: GENERATE ----
804
- generate_btn = gr.Button(
805
- "Generate Conference Audio", size="lg", variant="primary",
806
- elem_classes="cta-btn",
807
- )
808
- primary_status = gr.HTML(value="", elem_classes="gen-status")
809
-
810
- # ---- OUTPUT ----
811
- complete_audio_output = gr.Audio(
812
- label=AUDIO_LABEL_DEFAULT,
813
- type="numpy",
814
- autoplay=False,
815
- show_download_button=True,
816
- )
817
- with gr.Accordion("Generation Log", open=False):
818
- log_output = gr.Textbox(
819
- label="Log", lines=8, max_lines=15, interactive=False,
820
- )
821
-
822
- # ==================== EVENT HANDLERS ====================
823
-
824
- def update_speaker_visibility(n):
825
- return [gr.update(visible=(i < n)) for i in range(4)]
826
-
827
- num_speakers.change(
828
- fn=update_speaker_visibility,
829
- inputs=[num_speakers],
830
- outputs=speaker_selections,
831
- )
832
-
833
- # Two-way sync: when a Voice dropdown changes, update voice_selections_state
834
- # so the script turn tags re-render with the new voice label.
835
- def _sync_voice_state(*voices):
836
- return list(voices)
837
-
838
- for sel in speaker_selections:
839
- sel.change(
840
- fn=_sync_voice_state,
841
- inputs=speaker_selections,
842
- outputs=[voice_selections_state],
843
- queue=False,
844
- )
845
-
846
- def add_turn(turns):
847
- if len(turns) >= MAX_TURNS:
848
- gr.Warning(f"Maximum {MAX_TURNS} turns reached.")
849
- return turns, estimate_duration(turns)
850
- if not turns:
851
- next_speaker = 1
852
- else:
853
- max_spk = max(t["speaker"] for t in turns)
854
- last = turns[-1]["speaker"]
855
- next_speaker = (last % max_spk) + 1
856
- turns.append({"speaker": next_speaker, "text": ""})
857
- return turns, estimate_duration(turns)
858
-
859
- add_turn_btn.click(
860
- fn=add_turn,
861
- inputs=[turns_state],
862
- outputs=[turns_state, duration_display],
863
- )
864
-
865
- turns_state.change(
866
- fn=lambda turns: estimate_duration(turns),
867
- inputs=[turns_state],
868
- outputs=[duration_display],
869
- queue=False,
870
- )
871
-
872
- # --- AI Script Generation ---
873
- import time as _time
874
- import threading as _threading
875
-
876
- SCRIPT_GEN_MESSAGES = [
877
- "Writing script...",
878
- "Still generating...",
879
- "Making magic happen...",
880
- "Bossing around robot writers...",
881
- "Entering the matrix...",
882
- "Crafting dialogue...",
883
- "Teaching AI to be dramatic...",
884
- "Consulting the creative robots...",
885
- "Spilling digital ink...",
886
- "Herding AI cats into a script...",
887
- "Negotiating with the muse...",
888
- "Downloading inspiration...",
889
- "Warming up the plot engine...",
890
- "Shaking the idea tree...",
891
- "Feeding the word machine...",
892
- "Polishing virtual microphones...",
893
- "Rehearsing in the AI green room...",
894
- "Bribing the creativity daemon...",
895
- "Untangling narrative spaghetti...",
896
- "Summoning fictional characters...",
897
- "Tuning the dialogue generator...",
898
- "Spinning up the story factory...",
899
- "Convincing electrons to be eloquent...",
900
- "Wrangling syllables into sentences...",
901
- "Asking the AI to use its inside voice...",
902
- "Loading dramatic tension...",
903
- "Calibrating the sass levels...",
904
- "Assembling words in the right order...",
905
- "Generating witty banter...",
906
- "Overthinking your prompt (in a good way)...",
907
- "Running it by the robot editor...",
908
- "Adding a pinch of personality...",
909
- "Almost done, probably...",
910
- "Spell-checking the AI's homework...",
911
- "Giving characters their motivation...",
912
- "Practicing dramatic pauses...",
913
- "Reticulating splines (just kidding)...",
914
- "The AI is in the zone...",
915
- "Finalizing the masterpiece...",
916
- "One more revision, we promise...",
917
- ]
918
-
919
- # outputs: turns, duration, status, title, audio, script_btn, gen_btn, parody, num_speakers, *4 voices, voice_selections_state
920
- def _script_no_change(status_html):
921
- return (gr.update(), gr.update(), status_html,
922
- gr.update(), gr.update(),
923
- gr.update(), gr.update(),
924
- gr.update(),
925
- gr.update(), *[gr.update()] * 4, gr.update())
926
-
927
- def _script_buttons_busy(status_html):
928
- return (gr.update(), gr.update(), status_html,
929
- gr.update(), gr.update(),
930
- gr.update(interactive=False, value="Writing..."),
931
- gr.update(interactive=False),
932
- gr.update(),
933
- gr.update(), *[gr.update()] * 4, gr.update())
934
-
935
- def _script_buttons_ready(status_html=""):
936
- return (gr.update(), gr.update(), status_html,
937
- gr.update(), gr.update(),
938
- gr.update(interactive=True, value="Write Script with AI"),
939
- gr.update(interactive=True),
940
- gr.update(),
941
- gr.update(), *[gr.update()] * 4, gr.update())
942
-
943
- def _make_title_html(title):
944
- if title:
945
- return f"<h3 style='margin:0'>{title}</h3>"
946
- return "<h3 style='margin:0'>Script</h3>"
947
-
948
- def on_generate_script(prompt):
949
- if not prompt or not prompt.strip():
950
- gr.Warning("Please enter a prompt.")
951
- yield _script_no_change("")
952
- return
953
-
954
- # Disable both buttons
955
- yield _script_buttons_busy(f"<em>{SCRIPT_GEN_MESSAGES[0]}</em>")
956
-
957
- # Run script + parody generation in threads
958
- result = {}
959
- error = {}
960
- parody_result = {"lines": []}
961
-
962
- def _run():
963
- try:
964
- result["data"] = generate_script_from_prompt(prompt.strip())
965
- except Exception as e:
966
- error["err"] = e
967
-
968
- def _run_parody():
969
- parody_result["lines"] = generate_parody_story(prompt.strip())
970
-
971
- thread = _threading.Thread(target=_run, daemon=True)
972
- parody_thread = _threading.Thread(target=_run_parody, daemon=True)
973
- thread.start()
974
- parody_thread.start()
975
-
976
- msg_idx = 1
977
- while thread.is_alive():
978
- _time.sleep(3)
979
- msg = SCRIPT_GEN_MESSAGES[msg_idx % len(SCRIPT_GEN_MESSAGES)]
980
- msg_idx += 1
981
- yield _script_buttons_busy(f"<em>{msg}</em>")
982
-
983
- thread.join()
984
- parody_thread.join()
985
-
986
- if "err" in error:
987
- e = error["err"]
988
- print(f"Script generation error: {e}")
989
- traceback.print_exc()
990
- msg = str(e)
991
- if "api_key" in msg or "log in" in msg or "token" in msg.lower():
992
- yield _script_buttons_ready("<em>HF_TOKEN not configured. Add it in Space Settings.</em>")
993
- else:
994
- yield _script_buttons_ready(f"<em>Error: {msg[:200]}</em>")
995
- return
996
-
997
- turns, detected, title, voice_picks = result["data"]
998
- if not turns:
999
- yield _script_buttons_ready("<em>Empty result — try a more descriptive prompt.</em>")
1000
- return
1001
-
1002
- # voice_picks is a list of 4 display strings from assign_voices_by_gender
1003
- voices = list(voice_picks)
1004
- while len(voices) < 4:
1005
- voices.append(None)
1006
-
1007
- # Strip "Speaker N - " style prefixes so the Voice dropdowns get clean values.
1008
- # assign_voices_by_gender already returns display strings like "Cherry (F)".
1009
- clean_voices = voices[:4]
1010
-
1011
- audio_label = title if title else AUDIO_LABEL_DEFAULT
1012
- yield (turns, estimate_duration(turns), "",
1013
- _make_title_html(title),
1014
- gr.update(label=audio_label),
1015
- gr.update(interactive=True, value="Write Script with AI"),
1016
- gr.update(interactive=True),
1017
- parody_result["lines"],
1018
- detected, *clean_voices, clean_voices)
1019
-
1020
- generate_script_btn.click(
1021
- fn=on_generate_script,
1022
- inputs=[script_prompt],
1023
- outputs=[turns_state, duration_display, script_gen_status,
1024
- script_title_display, complete_audio_output,
1025
- generate_script_btn, generate_btn,
1026
- parody_lines_state,
1027
- num_speakers] + speaker_selections + [voice_selections_state],
1028
- )
1029
-
1030
- # --- Load examples ---
1031
- def load_example(idx):
1032
- if idx >= len(EXAMPLE_SCRIPTS):
1033
- return [], 2, "", "<h3 style='margin:0'>Script</h3>", gr.update(), *[None] * 4, list(DEFAULT_SPEAKERS_DISPLAY)
1034
-
1035
- title = example_names[idx]
1036
- script = EXAMPLE_SCRIPTS_NATURAL[idx]
1037
- num = SCRIPT_SPEAKER_COUNTS[idx] if idx < len(SCRIPT_SPEAKER_COUNTS) else 1
1038
- turns = parse_script_to_turns(script)
1039
-
1040
- voices = list(VOICE_DISPLAY[:num])
1041
- while len(voices) < 4:
1042
- voices.append(None)
1043
-
1044
- return (turns, num, estimate_duration(turns),
1045
- f"<h3 style='margin:0'>{title}</h3>",
1046
- gr.update(label=title),
1047
- *voices[:4], voices[:4])
1048
-
1049
- for idx, btn in enumerate(example_buttons):
1050
- btn.click(
1051
- fn=lambda i=idx: load_example(i),
1052
- inputs=[],
1053
- outputs=[turns_state, num_speakers, duration_display,
1054
- script_title_display, complete_audio_output]
1055
- + speaker_selections + [voice_selections_state],
1056
- queue=False,
1057
- )
1058
-
1059
- # --- Generate audio ---
1060
- def _gen_yield(status_html, btn_label, btn_interactive, audio_update, log_text):
1061
- return (
1062
- status_html,
1063
- gr.update(value=btn_label, interactive=btn_interactive),
1064
- gr.update(interactive=btn_interactive),
1065
- audio_update,
1066
- log_text,
1067
- )
1068
-
1069
- def generate_podcast_wrapper(
1070
- model_choice, num_speakers_val, turns, parody_lines, *speakers_and_params
1071
- ):
1072
- BTN_BUSY = "Generating..."
1073
- BTN_READY = "Generate Conference Audio"
1074
-
1075
- # Set up parody line cycling
1076
- parody_idx = [0] # mutable counter
1077
- def _next_parody():
1078
- if not parody_lines:
1079
- return None
1080
- line = parody_lines[parody_idx[0] % len(parody_lines)]
1081
- parody_idx[0] += 1
1082
- return line
1083
-
1084
- if remote_generate_function is None:
1085
- yield _gen_yield(
1086
- build_status_html("error", "Modal backend is offline."),
1087
- BTN_READY, True,
1088
- gr.update(), "ERROR: Modal function not deployed.",
1089
- )
1090
- return
1091
-
1092
- script = turns_to_script(turns)
1093
- if not script.strip():
1094
- yield _gen_yield(
1095
- build_status_html("error", "No script to generate."),
1096
- BTN_READY, True,
1097
- gr.update(), "Add dialogue before generating.",
1098
- )
1099
- return
1100
-
1101
- word_count = len(script.split())
1102
- if word_count > MAX_SCRIPT_WORDS:
1103
- yield _gen_yield(
1104
- build_status_html("error",
1105
- f"Script too long: {word_count} words (max {MAX_SCRIPT_WORDS}). "
1106
- "Shorten some turns."),
1107
- BTN_READY, True,
1108
- gr.update(), f"Script has {word_count} words, max is {MAX_SCRIPT_WORDS}.",
1109
- )
1110
- return
1111
-
1112
- # Disable buttons, show connecting status
1113
- first_line = _next_parody() or "Provisioning GPU resources..."
1114
- yield _gen_yield(
1115
- build_status_html("connecting", first_line),
1116
- BTN_BUSY, False,
1117
- gr.update(label=AUDIO_STAGE_LABELS.get("connecting", AUDIO_LABEL_DEFAULT)),
1118
- "Requesting GPU on Modal.com...",
1119
- )
1120
-
1121
- try:
1122
- speakers = [voice_display_to_name(s) for s in speakers_and_params[:4]]
1123
- cfg_scale_val = speakers_and_params[4]
1124
- current_log = ""
1125
- last_stage = "connecting"
1126
-
1127
- for update in remote_generate_function.remote_gen(
1128
- num_speakers=int(num_speakers_val),
1129
- script=script,
1130
- speaker_1=speakers[0],
1131
- speaker_2=speakers[1],
1132
- speaker_3=speakers[2],
1133
- speaker_4=speakers[3],
1134
- cfg_scale=cfg_scale_val,
1135
- model_name=model_choice,
1136
- ):
1137
- if not update:
1138
- continue
1139
-
1140
- if isinstance(update, dict):
1141
- audio_payload = update.get("audio")
1142
- stage_key = update.get("stage", last_stage) or last_stage
1143
- status_line = update.get("status") or "Processing..."
1144
- current_log = update.get("log", current_log)
1145
-
1146
- audio_label = AUDIO_STAGE_LABELS.get(stage_key,
1147
- f"Audio ({stage_key.replace('_',' ')})")
1148
- is_done = stage_key in ("complete", "error")
1149
- if stage_key == "complete":
1150
- audio_label = AUDIO_LABEL_DEFAULT
1151
-
1152
- audio_update = gr.update(label=audio_label)
1153
- if audio_payload is not None:
1154
- audio_update = gr.update(value=audio_payload, label=AUDIO_LABEL_DEFAULT)
1155
-
1156
- # Use parody line for active stages, real status for done
1157
- if is_done:
1158
- display_line = status_line
1159
- else:
1160
- display_line = _next_parody() or status_line
1161
-
1162
- yield _gen_yield(
1163
- build_status_html(stage_key, display_line),
1164
- BTN_READY if is_done else BTN_BUSY,
1165
- is_done,
1166
- audio_update,
1167
- current_log,
1168
- )
1169
- last_stage = stage_key
1170
- else:
1171
- audio_payload, log_text = (
1172
- update if isinstance(update, (tuple, list)) else (None, str(update))
1173
- )
1174
- if log_text:
1175
- current_log = log_text
1176
- if audio_payload is not None:
1177
- yield _gen_yield(
1178
- build_status_html("complete", "Ready."),
1179
- BTN_READY, True,
1180
- gr.update(value=audio_payload, label=AUDIO_LABEL_DEFAULT),
1181
- current_log,
1182
- )
1183
- else:
1184
- display_line = _next_parody() or (
1185
- current_log.splitlines()[-1] if current_log else "Processing...")
1186
- yield _gen_yield(
1187
- build_status_html("generating_audio", display_line),
1188
- BTN_BUSY, False,
1189
- gr.update(), current_log,
1190
- )
1191
- except Exception as e:
1192
- tb = traceback.format_exc()
1193
- print(f"Error calling Modal: {e}")
1194
- yield _gen_yield(
1195
- build_status_html("error", "Inference failed."),
1196
- BTN_READY, True,
1197
- gr.update(), f"Error: {e}\n\n{tb}",
1198
- )
1199
-
1200
- generate_btn.click(
1201
- fn=generate_podcast_wrapper,
1202
- inputs=[model_dropdown, num_speakers, turns_state, parody_lines_state] + speaker_selections + [cfg_scale],
1203
- outputs=[primary_status, generate_btn, generate_script_btn, complete_audio_output, log_output],
1204
- )
1205
-
1206
- # ==================== ARCHITECTURE TAB ====================
1207
- with gr.Tab("Architecture"):
1208
- gr.Markdown("## VibeVoice: A Frontier Open-Source Text-to-Speech Model")
1209
- gr.Markdown(
1210
- """VibeVoice generates expressive, long-form, multi-speaker conversational audio from text.
1211
- It uses continuous speech tokenizers at an ultra-low 7.5 Hz frame rate and a next-token diffusion
1212
- framework to synthesize up to 90 minutes of speech with up to 4 distinct speakers."""
1213
- )
1214
- with gr.Row():
1215
- with gr.Column():
1216
- gr.Markdown("""
1217
- ### Key Features
1218
- - **Multi-Speaker**: Up to 4 distinct voices
1219
- - **Long-Form**: Up to 90 minutes of audio
1220
- - **Natural Flow**: Turn-taking, interruptions, filler words
1221
- - **Efficient**: 7.5 Hz tokenizers for low compute cost
1222
-
1223
- ### Architecture
1224
- 1. **Continuous Speech Tokenizers** — Acoustic + Semantic at 7.5 Hz
1225
- 2. **Next-Token Diffusion** — LLM context + diffusion generation
1226
- 3. **Diffusion Head** — High-fidelity acoustic output
1227
-
1228
- ### Models
1229
- - **1.5B** — Fast inference, great for iteration
1230
- - **7B** — Higher fidelity, longer generation time
1231
- """)
1232
- with gr.Column():
1233
- gr.Image(value="public/images/diagram.jpg",
1234
- label="Architecture", show_download_button=False)
1235
- gr.Image(value="public/images/chart.png",
1236
- label="Performance", show_download_button=False)
1237
-
1238
- return interface
1239
-
1240
-
1241
- # --- Main ---
1242
  if __name__ == "__main__":
 
 
1243
  if remote_generate_function is None:
1244
- with gr.Blocks(theme=theme) as interface:
1245
- gr.Markdown("# Configuration Error")
1246
- gr.Markdown(
1247
- "Cannot connect to Modal backend. "
1248
- "Run `modal deploy backend_modal/modal_runner.py` and refresh."
1249
- )
1250
- interface.launch()
1251
- else:
1252
- interface = create_demo_interface()
1253
- interface.queue().launch(show_error=True)
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import io
5
+ import json
6
  import os
7
  import re
8
+ import threading
9
+ import time
10
  import traceback
11
+ import uuid
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import modal
16
+ import numpy as np
17
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
18
+ from fastapi.responses import FileResponse, Response, StreamingResponse
19
+ from fastapi.staticfiles import StaticFiles
20
  from huggingface_hub import InferenceClient
21
+ from pydantic import BaseModel
22
+ from scipy.io import wavfile
23
 
24
  # --- Configuration ---
25
+ ROOT = Path(__file__).resolve().parent
26
  MODAL_STUB_NAME = "vibevoice-generator"
27
  MODAL_CLASS_NAME = "VibeVoiceModel"
28
 
 
32
  "Mantis": "F", "Sponge": "M", "Starchild": "F",
33
  }
34
  AVAILABLE_VOICES = list(VOICE_GENDERS.keys())
35
+ DEFAULT_SPEAKERS = ["Cherry", "Chicago", "Janus", "Mantis"]
 
 
 
 
 
 
 
 
36
 
37
  SCRIPT_GEN_MODEL = "Qwen/Qwen2.5-Coder-32B-Instruct"
38
  SCRIPT_MAX_WORDS = 1000 # AI generation cap
39
+ MAX_SCRIPT_WORDS = 100000 # Effectively uncapped (2026-08-14, Josh) — backend chunking has no
40
+ # real ceiling; the old 20,000 cap was a leftover UI guess that
41
+ # blocked genuine long-form renders below the backend's actual limit
42
  MAX_TURNS = 50 # Max conversation turns
43
+ AUDIO_TTL_SECONDS = 900
44
 
45
 
46
  # --- Load example scripts ---
47
  def load_example_scripts():
48
+ examples_dir = ROOT / "text_examples"
49
  example_scripts = []
50
  example_scripts_natural = []
51
 
52
+ if not examples_dir.exists():
53
  return example_scripts, example_scripts_natural
54
 
55
  original_files = [
 
64
  ]
65
 
66
  for txt_file in original_files:
67
+ file_path = examples_dir / txt_file
68
+ natural_path = examples_dir / txt_file.replace(".txt", "_natural.txt")
 
69
 
70
+ if file_path.exists():
71
+ example_scripts.append(file_path.read_text(encoding="utf-8"))
 
72
  else:
73
  example_scripts.append("")
74
 
75
+ if natural_path.exists():
76
+ example_scripts_natural.append(natural_path.read_text(encoding="utf-8"))
 
77
  else:
78
+ example_scripts_natural.append(example_scripts[-1] if example_scripts else "")
 
 
79
 
80
  return example_scripts, example_scripts_natural
81
 
82
 
83
+ EXAMPLE_NAMES = [
84
+ "AI TED Talk", "Political Speech",
85
+ "Finance IPO", "Telehealth",
86
+ "Military Briefing", "Oil & Energy",
87
+ "Game Dev Meeting", "Product Review",
88
+ ]
89
  SCRIPT_SPEAKER_COUNTS = [1, 1, 2, 2, 3, 3, 4, 4]
90
  EXAMPLE_SCRIPTS, EXAMPLE_SCRIPTS_NATURAL = load_example_scripts()
91
 
 
193
  return "\n\n".join(lines)
194
 
195
 
 
 
 
 
 
 
 
 
 
 
196
  # --- AI Script Generation ---
197
 
198
  hf_token = os.environ.get("HF_TOKEN")
 
312
  return cleaned, genders
313
 
314
 
315
+ def assign_voices_by_gender(genders: dict[int, str], num_speakers: int) -> list[str | None]:
316
+ """Return a list of 4 plain voice names, picking matching-gender voices without duplicates.
317
 
318
+ Falls back to DEFAULT_SPEAKERS if no gender info for a slot.
319
  """
320
  female_pool = [v for v in AVAILABLE_VOICES if VOICE_GENDERS.get(v) == "F"]
321
  male_pool = [v for v in AVAILABLE_VOICES if VOICE_GENDERS.get(v) == "M"]
322
  used: set[str] = set()
323
+ chosen: list[str | None] = []
324
 
325
  for i in range(4):
326
  slot = i + 1
 
329
  pool = female_pool if g == "F" else (male_pool if g == "M" else AVAILABLE_VOICES)
330
  pick = next((v for v in pool if v not in used), None)
331
  if pick is None:
 
332
  pick = next((v for v in AVAILABLE_VOICES if v not in used), AVAILABLE_VOICES[0])
333
  used.add(pick)
334
+ chosen.append(pick)
335
  else:
336
+ chosen.append(DEFAULT_SPEAKERS[i] if i < len(DEFAULT_SPEAKERS) else None)
337
  return chosen
338
 
339
 
340
+ def generate_script_from_prompt(prompt: str) -> tuple[list[dict], int, str, list[str | None]]:
341
  """Returns (turns, num_speakers, title, voice_selections)."""
342
  system = SCRIPT_SYSTEM_PROMPT.format(max_words=SCRIPT_MAX_WORDS)
343
  response = llm_client.chat_completion(
 
410
  return ["Generating your audio... hang tight!"]
411
 
412
 
413
+ # --- Precomputed example index (parsed once at startup) ---
414
+ def _build_examples_index() -> list[dict]:
415
+ index = []
416
+ for i, name in enumerate(EXAMPLE_NAMES):
417
+ script = EXAMPLE_SCRIPTS_NATURAL[i] if i < len(EXAMPLE_SCRIPTS_NATURAL) else ""
418
+ turns = parse_script_to_turns(script)
419
+ num = SCRIPT_SPEAKER_COUNTS[i] if i < len(SCRIPT_SPEAKER_COUNTS) else 1
420
+ voices = (AVAILABLE_VOICES[:num] + [None, None, None, None])[:4]
421
+ index.append({
422
+ "id": i, "title": name, "turns": turns,
423
+ "num_speakers": num, "voices": voices,
424
+ })
425
+ return index
426
+
427
+
428
+ EXAMPLES_INDEX = _build_examples_index()
429
+
430
+
431
  # --- Modal Connection ---
432
  try:
433
  RemoteVibeVoiceModel = modal.Cls.from_name(MODAL_STUB_NAME, MODAL_CLASS_NAME)
 
440
  remote_generate_function = None
441
 
442
 
443
+ def _encode_wav(sample_rate: int, audio: np.ndarray) -> bytes:
444
+ audio = np.asarray(audio)
445
+ if audio.dtype.kind == "f":
446
+ audio = np.clip(audio, -1.0, 1.0)
447
+ audio = (audio * 32767).astype(np.int16)
448
+ elif audio.dtype != np.int16:
449
+ audio = audio.astype(np.int16)
450
+ buf = io.BytesIO()
451
+ wavfile.write(buf, sample_rate, audio)
452
+ return buf.getvalue()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
+ AUDIO_STORE: dict[str, tuple[float, bytes]] = {}
 
 
 
 
456
 
 
 
 
 
 
 
 
457
 
458
+ def _prune_audio_store() -> None:
459
+ now = time.time()
460
+ stale = [k for k, (ts, _) in AUDIO_STORE.items() if now - ts > AUDIO_TTL_SECONDS]
461
+ for k in stale:
462
+ AUDIO_STORE.pop(k, None)
 
 
463
 
 
 
 
 
 
 
464
 
465
+ # ========================================================
466
+ # FASTAPI APP
467
+ # ========================================================
468
+
469
+ app = FastAPI(title="VibeVoice Conference Generator")
470
+ app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static")
471
+ app.mount("/public", StaticFiles(directory=ROOT / "public"), name="public")
472
+
473
+
474
+ @app.get("/", include_in_schema=False)
475
+ async def index() -> FileResponse:
476
+ return FileResponse(ROOT / "static" / "index.html")
477
+
478
+
479
+ @app.get("/api/status")
480
+ async def api_status() -> dict:
481
+ return {"backend": "ready" if remote_generate_function is not None else "offline"}
482
+
483
+
484
+ @app.get("/api/models")
485
+ async def api_models() -> list[str]:
486
+ return AVAILABLE_MODELS
487
+
488
+
489
+ @app.get("/api/voices")
490
+ async def api_voices() -> list[dict]:
491
+ return [
492
+ {"name": name, "gender": VOICE_GENDERS[name], "preview_url": f"/public/voices/{name}.mp3"}
493
+ for name in AVAILABLE_VOICES
494
+ ]
495
+
496
+
497
+ @app.get("/api/examples")
498
+ async def api_examples() -> list[dict]:
499
+ return EXAMPLES_INDEX
500
+
501
+
502
+ @app.post("/api/parse-script")
503
+ async def api_parse_script(
504
+ text: Annotated[str, Form()] = "",
505
+ file: Annotated[UploadFile | None, File()] = None,
506
+ ) -> dict:
507
+ script = ""
508
+ if file is not None and file.filename:
509
+ raw = await file.read()
510
+ script = raw.decode("utf-8", errors="replace")
511
+ await file.close()
512
+ if not script.strip():
513
+ script = text or ""
514
+ if not script.strip():
515
+ raise HTTPException(status_code=400, detail="Paste a script or upload a .txt file first.")
516
+
517
+ turns = parse_script_to_turns(script)
518
+ if not turns:
519
+ raise HTTPException(status_code=400, detail="Couldn't find any dialogue in that script.")
520
+
521
+ total_words = sum(len(t["text"].split()) for t in turns)
522
+ num = max(1, min(4, max(t["speaker"] for t in turns)))
523
+ voices = (AVAILABLE_VOICES[:num] + [None, None, None, None])[:4]
524
+
525
+ return {
526
+ "turns": turns,
527
+ "num_speakers": num,
528
+ "title": "Uploaded Script",
529
+ "voices": voices,
530
+ "word_count": total_words,
531
+ "over_limit": total_words > MAX_SCRIPT_WORDS,
532
  }
533
+
534
+
535
+ class ScriptPromptRequest(BaseModel):
536
+ prompt: str
537
+
538
+
539
+ @app.post("/api/generate-script")
540
+ async def api_generate_script(payload: ScriptPromptRequest) -> dict:
541
+ prompt = (payload.prompt or "").strip()
542
+ if not prompt:
543
+ raise HTTPException(status_code=400, detail="Please enter a prompt.")
544
+
545
+ try:
546
+ script_result, parody_lines = await asyncio.gather(
547
+ asyncio.to_thread(generate_script_from_prompt, prompt),
548
+ asyncio.to_thread(generate_parody_story, prompt),
549
+ )
550
+ except Exception as e:
551
+ print(f"Script generation error: {e}")
552
+ traceback.print_exc()
553
+ msg = str(e)
554
+ if "api_key" in msg or "log in" in msg or "token" in msg.lower():
555
+ raise HTTPException(status_code=502, detail="HF_TOKEN not configured. Add it in Space Settings.")
556
+ raise HTTPException(status_code=502, detail=f"Error: {msg[:200]}")
557
+
558
+ turns, detected, title, voice_picks = script_result
559
+ if not turns:
560
+ raise HTTPException(status_code=422, detail="Empty result — try a more descriptive prompt.")
561
+
562
+ voices = list(voice_picks)[:4]
563
+ while len(voices) < 4:
564
+ voices.append(None)
565
+
566
+ return {
567
+ "turns": turns,
568
+ "num_speakers": detected,
569
+ "title": title,
570
+ "voices": voices,
571
+ "parody_lines": parody_lines,
572
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
573
 
574
 
575
+ class GenerateRequest(BaseModel):
576
+ model: str
577
+ num_speakers: int
578
+ turns: list[dict]
579
+ speakers: list[str | None]
580
+ cfg_scale: float
581
+
582
+
583
+ @app.post("/api/generate")
584
+ async def api_generate(payload: GenerateRequest) -> StreamingResponse:
585
+ if remote_generate_function is None:
586
+ raise HTTPException(status_code=503, detail="Modal backend is offline.")
587
+
588
+ script = turns_to_script(payload.turns)
589
+ if not script.strip():
590
+ raise HTTPException(status_code=400, detail="Add dialogue before generating.")
591
+
592
+ word_count = len(script.split())
593
+ if word_count > MAX_SCRIPT_WORDS:
594
+ raise HTTPException(
595
+ status_code=400,
596
+ detail=f"Script too long: {word_count} words (max {MAX_SCRIPT_WORDS}). Shorten some turns.",
597
  )
598
+
599
+ speakers = (list(payload.speakers) + [None, None, None, None])[:4]
600
+
601
+ async def event_stream():
602
+ _prune_audio_store()
603
+ loop = asyncio.get_event_loop()
604
+ q: asyncio.Queue = asyncio.Queue()
605
+ sentinel = object()
606
+
607
+ def worker():
608
+ try:
609
+ for update in remote_generate_function.remote_gen(
610
+ num_speakers=payload.num_speakers,
611
+ script=script,
612
+ speaker_1=speakers[0],
613
+ speaker_2=speakers[1],
614
+ speaker_3=speakers[2],
615
+ speaker_4=speakers[3],
616
+ cfg_scale=payload.cfg_scale,
617
+ model_name=payload.model,
618
+ ):
619
+ loop.call_soon_threadsafe(q.put_nowait, update)
620
+ except Exception as e:
621
+ loop.call_soon_threadsafe(q.put_nowait, {
622
+ "stage": "error",
623
+ "status": "Inference failed.",
624
+ "log": f"{e}\n\n{traceback.format_exc()}",
625
+ })
626
+ finally:
627
+ loop.call_soon_threadsafe(q.put_nowait, sentinel)
628
+
629
+ threading.Thread(target=worker, daemon=True).start()
630
+
631
+ while True:
632
+ item = await q.get()
633
+ if item is sentinel:
634
+ break
635
+ if not item:
636
+ yield ": keep-alive\n\n"
637
+ continue
638
+
639
+ event = dict(item)
640
+ audio_payload = event.pop("audio", None)
641
+ if audio_payload is not None:
642
+ sample_rate, audio_array = audio_payload
643
+ wav_bytes = _encode_wav(sample_rate, audio_array)
644
+ audio_id = uuid.uuid4().hex
645
+ AUDIO_STORE[audio_id] = (time.time(), wav_bytes)
646
+ event["audio_id"] = audio_id
647
+ event["audio_duration"] = len(audio_array) / float(sample_rate)
648
+ yield f"data: {json.dumps(event)}\n\n"
649
+
650
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
651
+
652
+
653
+ @app.get("/api/audio/{audio_id}")
654
+ async def api_audio(audio_id: str) -> Response:
655
+ entry = AUDIO_STORE.get(audio_id)
656
+ if entry is None:
657
+ raise HTTPException(status_code=404, detail="Audio not found or expired.")
658
+ _, wav_bytes = entry
659
+ return Response(
660
+ content=wav_bytes,
661
+ media_type="audio/wav",
662
+ headers={"Content-Disposition": 'attachment; filename="conference.wav"'},
663
  )
664
 
665
 
666
+ @app.get("/health")
667
+ async def health() -> dict:
668
+ return {"status": "healthy" if remote_generate_function is not None else "backend offline"}
669
+
670
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  if __name__ == "__main__":
672
+ import uvicorn
673
+
674
  if remote_generate_function is None:
675
+ print("WARNING: Modal function not deployed — run `modal deploy backend_modal/modal_runner.py`.")
676
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
 
 
 
 
 
 
 
 
backend_modal/modal_runner.py CHANGED
@@ -1,10 +1,9 @@
 
 
 
1
  import os
2
  import time
3
  import threading
4
- import numpy as np
5
- import librosa
6
- import soundfile as sf
7
- import torch
8
  from datetime import datetime
9
  import hashlib
10
  import json
@@ -38,6 +37,7 @@ image = (
38
  "ln -s /root/voices /root/vibevoice/voices",
39
  "ln -s /root/schedule /root/vibevoice/schedule"
40
  )
 
41
  .add_local_dir("backend_modal/modular", remote_path="/root/modular")
42
  .add_local_dir("backend_modal/processor", remote_path="/root/processor")
43
  .add_local_dir("backend_modal/voices", remote_path="/root/voices")
@@ -50,12 +50,21 @@ app = modal.App(
50
  image=image,
51
  )
52
 
 
 
 
 
 
 
 
 
53
  # Create a volume for caching generated audio
54
  cache_volume = modal.Volume.from_name("vibevoice-cache", create_if_missing=True)
55
 
56
  @app.cls(
57
- gpu="A100-40GB",
58
  scaledown_window=300,
 
59
  volumes={"/cache": cache_volume}
60
  )
61
  class VibeVoiceModel:
@@ -89,7 +98,7 @@ class VibeVoiceModel:
89
  self.processors = {}
90
  self.current_model_name = None
91
 
92
- # Load all models directly to GPU (A10G has enough memory)
93
  for name, path in self.model_paths.items():
94
  print(f" - Loading {name} from {path}")
95
  proc = VibeVoiceProcessor.from_pretrained(path)
@@ -105,9 +114,14 @@ class VibeVoiceModel:
105
 
106
  # Set default model
107
  self.current_model_name = "VibeVoice-1.5B"
108
-
109
  self.setup_voice_presets()
110
- print("Model loading complete.")
 
 
 
 
 
111
 
112
  def _place_model(self, target_name: str):
113
  """Both models stay on GPU. Just update the active selection."""
@@ -149,7 +163,8 @@ class VibeVoiceModel:
149
  "model": model_name,
150
  "speakers": sorted(speakers), # Sort for consistency
151
  "cfg_scale": cfg_scale,
152
- "inference_steps": self.inference_steps
 
153
  }
154
  cache_str = json.dumps(cache_data, sort_keys=True)
155
  return hashlib.sha256(cache_str.encode()).hexdigest()
@@ -228,6 +243,125 @@ class VibeVoiceModel:
228
  print(f"Error reading audio {audio_path}: {e}")
229
  return np.array([])
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  @staticmethod
232
  def _infer_num_speakers_from_script(script: str) -> int:
233
  """
@@ -338,6 +472,21 @@ class VibeVoiceModel:
338
  )
339
  self._place_model(model_name)
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  model = self.models[model_name]
342
  processor = self.processors[model_name]
343
  model.set_ddpm_inference_steps(num_steps=self.inference_steps)
@@ -347,7 +496,7 @@ class VibeVoiceModel:
347
  if not script.strip():
348
  raise ValueError("Error: Please provide a script.")
349
 
350
- script = script.replace("'", "'")
351
 
352
  if not 1 <= num_speakers <= 4:
353
  raise ValueError("Error: Number of speakers must be between 1 and 4.")
@@ -406,68 +555,249 @@ class VibeVoiceModel:
406
  log_text=log_text,
407
  )
408
 
409
- inputs = processor(
410
- text=[formatted_script],
411
- voice_samples=[voice_samples],
412
- padding=True,
413
- return_tensors="pt",
414
- return_attention_mask=True,
415
- ).to(self.device)
416
 
417
- log_lines.append("Inputs prepared; starting diffusion generation…")
 
 
 
 
 
418
  log_text = "\n".join(log_lines)
419
  yield self._emit_progress(
420
  stage="generating_audio",
421
  pct=70,
422
- status="Running VibeVoice diffusion (this may take 1-2 minutes)…",
423
  log_text=log_text,
424
  )
425
 
426
- start_time = time.time()
427
- result_container = {}
428
- exception_container = {}
429
-
430
- def _run_generation():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  try:
432
- with torch.inference_mode():
433
- result_container['outputs'] = model.generate(
434
- **inputs,
435
- max_new_tokens=None,
436
- cfg_scale=cfg_scale,
437
- tokenizer=processor.tokenizer,
438
- generation_config={'do_sample': False},
439
- verbose=False,
440
- )
441
- except Exception as gen_err:
442
- exception_container['error'] = gen_err
443
-
444
- generation_thread = threading.Thread(target=_run_generation, daemon=True)
445
- generation_thread.start()
446
-
447
- # Emit keep-alive progress while the heavy generation is running
448
- while generation_thread.is_alive():
449
- elapsed = time.time() - start_time
450
- status_msg = f"Running VibeVoice diffusion… {int(elapsed)}s elapsed"
451
- pct_hint = min(88, 70 + int(elapsed // 5))
452
- yield self._emit_progress(
453
- stage="generating_audio",
454
- pct=pct_hint,
455
- status=status_msg,
456
- log_text=log_text,
457
- )
458
- time.sleep(5)
459
-
460
- generation_thread.join()
461
- if 'error' in exception_container:
462
- raise exception_container['error']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
 
464
- outputs = result_container.get('outputs')
465
- if outputs is None:
466
- raise RuntimeError("Generation thread finished without producing outputs.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
 
468
  generation_time = time.time() - start_time
469
-
470
- log_lines.append(f"Generation completed in {generation_time:.2f} seconds")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  log_lines.append("Processing audio output…")
472
  log_text = "\n".join(log_lines)
473
  yield self._emit_progress(
@@ -477,15 +807,9 @@ class VibeVoiceModel:
477
  log_text=log_text,
478
  )
479
 
480
- if hasattr(outputs, 'speech_outputs') and outputs.speech_outputs[0] is not None:
481
- audio_tensor = outputs.speech_outputs[0]
482
- audio = audio_tensor.cpu().float().numpy()
483
- else:
484
  raise RuntimeError("Error: No audio was generated by the model.")
485
 
486
- if audio.ndim > 1:
487
- audio = audio.squeeze()
488
-
489
  sample_rate = 24000
490
  total_duration = len(audio) / sample_rate
491
  log_lines.append(f"Audio duration: {total_duration:.2f} seconds")
 
1
+ from __future__ import annotations # keep np.ndarray hints lazy at deploy time
2
+
3
+ import gc
4
  import os
5
  import time
6
  import threading
 
 
 
 
7
  from datetime import datetime
8
  import hashlib
9
  import json
 
37
  "ln -s /root/voices /root/vibevoice/voices",
38
  "ln -s /root/schedule /root/vibevoice/schedule"
39
  )
40
+ .env({"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) # fights fragmentation across waves
41
  .add_local_dir("backend_modal/modular", remote_path="/root/modular")
42
  .add_local_dir("backend_modal/processor", remote_path="/root/processor")
43
  .add_local_dir("backend_modal/voices", remote_path="/root/voices")
 
50
  image=image,
51
  )
52
 
53
+ # Heavy imports run only inside the container (deploy-time on a laptop
54
+ # doesn't have torch/numpy/librosa — and doesn't need them)
55
+ with image.imports():
56
+ import numpy as np
57
+ import librosa
58
+ import soundfile as sf
59
+ import torch
60
+
61
  # Create a volume for caching generated audio
62
  cache_volume = modal.Volume.from_name("vibevoice-cache", create_if_missing=True)
63
 
64
  @app.cls(
65
+ gpu="A100-40GB",
66
  scaledown_window=300,
67
+ timeout=3600, # long-form renders (90-min scripts ~25 min wall) need headroom
68
  volumes={"/cache": cache_volume}
69
  )
70
  class VibeVoiceModel:
 
98
  self.processors = {}
99
  self.current_model_name = None
100
 
101
+ # Load all models directly to GPU (A100-40GB holds both; ~17 GB baseline)
102
  for name, path in self.model_paths.items():
103
  print(f" - Loading {name} from {path}")
104
  proc = VibeVoiceProcessor.from_pretrained(path)
 
114
 
115
  # Set default model
116
  self.current_model_name = "VibeVoice-1.5B"
117
+
118
  self.setup_voice_presets()
119
+ self.ready_at = time.time() # for cold-start detection in timing reports
120
+ # VRAM baseline right after model load: requests arriving to a GPU far
121
+ # above this are hitting a poisoned container (e.g. a cancelled run's
122
+ # zombie generation thread) and must recycle, not proceed (2026-08-14)
123
+ self.baseline_alloc = torch.cuda.memory_allocated()
124
+ print(f"Model loading complete. VRAM baseline: {self.baseline_alloc/1e9:.1f} GB")
125
 
126
  def _place_model(self, target_name: str):
127
  """Both models stay on GPU. Just update the active selection."""
 
163
  "model": model_name,
164
  "speakers": sorted(speakers), # Sort for consistency
165
  "cfg_scale": cfg_scale,
166
+ "inference_steps": self.inference_steps,
167
+ "pipeline": "chunked-parallel-v1", # renditions differ from monolithic
168
  }
169
  cache_str = json.dumps(cache_data, sort_keys=True)
170
  return hashlib.sha256(cache_str.encode()).hexdigest()
 
243
  print(f"Error reading audio {audio_path}: {e}")
244
  return np.array([])
245
 
246
+ # ---- chunked-parallel generation (2026-08-14) ----------------------------
247
+ # Rendering the whole script as ONE sequential generate call is the slow
248
+ # path (~1.4x slower than realtime). Grouping whole turns into chunks and
249
+ # rendering them as a BATCH in a single call is ~3x faster at batch-4 with
250
+ # no measured quality loss (audio-level parity: WER 0.000/0.071/0.000/0.000
251
+ # solo-vs-batched; seams inaudible with a 0.25 s crossfade). Validation
252
+ # record: LongFlow experiments/p1_flow_head/NOTES.md, Gate Nights 4-5.
253
+ CHUNK_TARGET_WORDS = 200 # ~80-90 s of audio per chunk
254
+ CHUNK_MIN_SCRIPT_WORDS = 350 # below this, monolithic is fine
255
+ CROSSFADE_S = 0.25 # GN5 ear-validated
256
+ # Ambitious caps: with OOM backoff (waves split in half and retry), an
257
+ # overshoot costs one halving, never the job. Validated floors were 8/4.
258
+ MAX_BATCH = {"VibeVoice-1.5B": 12, "VibeVoice-7B": 6} # A100-40GB
259
+
260
+ @classmethod
261
+ def _split_turns_into_chunks(cls, turn_lines: list) -> list:
262
+ """Group whole 'Speaker N:' turn lines into ~CHUNK_TARGET_WORDS chunks.
263
+
264
+ Never splits inside a turn (turn boundaries are what keep pacing
265
+ natural and identity anchored), so each chunk is itself a valid
266
+ mini-script for the same speaker set.
267
+ """
268
+ chunks, cur, cur_words = [], [], 0
269
+ for line in turn_lines:
270
+ cur.append(line)
271
+ cur_words += len(line.split())
272
+ if cur_words >= cls.CHUNK_TARGET_WORDS:
273
+ chunks.append("\n".join(cur))
274
+ cur, cur_words = [], 0
275
+ if cur:
276
+ # avoid a tiny trailing chunk: merge into the previous one
277
+ if chunks and cur_words < cls.CHUNK_TARGET_WORDS // 3:
278
+ chunks[-1] = chunks[-1] + "\n" + "\n".join(cur)
279
+ else:
280
+ chunks.append("\n".join(cur))
281
+ return chunks
282
+
283
+ @classmethod
284
+ def _crossfade_concat(cls, pieces: list, sample_rate: int) -> np.ndarray:
285
+ """Concatenate audio pieces with a short linear crossfade at each seam."""
286
+ pieces = [p for p in pieces if p is not None and len(p) > 0]
287
+ if not pieces:
288
+ return np.array([])
289
+ xf = int(cls.CROSSFADE_S * sample_rate)
290
+ out = pieces[0]
291
+ for p in pieces[1:]:
292
+ n = min(xf, len(out), len(p))
293
+ if n <= 0:
294
+ out = np.concatenate([out, p])
295
+ continue
296
+ fade = np.linspace(1.0, 0.0, n)
297
+ seam = out[-n:] * fade + p[:n] * (1.0 - fade)
298
+ out = np.concatenate([out[:-n], seam, p[n:]])
299
+ return out
300
+
301
+ # ---- detect-and-reroll quality gate (2026-08-14) ---------------------
302
+ # Transient-babble glitch rate is <1 per 46 min, so the gate must be
303
+ # CONSERVATIVE: only re-roll on egregious failures, and fail OPEN (any
304
+ # gate error = pass) so it can never block a render. Metrics per chunk:
305
+ # rate — words/sec of audio; healthy renders measure ~2.2-2.5
306
+ # silence — fraction of 50 ms frames near-silent (drone/stall symptom)
307
+ # flatness — mean spectral flatness (static/babble is noise-like, ~1.0;
308
+ # speech is ~0.01-0.2)
309
+ # One reroll attempt per failing chunk on a fresh torch seed; keep the
310
+ # reroll only if it gates better than the original.
311
+ GATE_WPS_MIN = 1.2 # slower = audio stretched/droning
312
+ GATE_WPS_MAX = 4.2 # faster = audio truncated/skipped words
313
+ GATE_SILENCE_MAX = 0.5 # >50% near-silent frames
314
+ GATE_FLATNESS_MAX = 0.5 # white noise ≈ 1.0, speech ≪ 0.5
315
+
316
+ @classmethod
317
+ def _chunk_quality(cls, chunk_text: str, audio, sample_rate: int):
318
+ """Score one rendered chunk. Returns (ok, badness, reason).
319
+
320
+ badness is a monotone severity score so a reroll can be compared
321
+ against the original even when both fail. Fails open on any error.
322
+ """
323
+ import re as _re
324
+ try:
325
+ if audio is None or len(audio) == 0:
326
+ return False, float("inf"), "no audio"
327
+ words = len(_re.sub(r"(?mi)^\s*Speaker\s+\d+\s*:", "", chunk_text).split())
328
+ dur = len(audio) / sample_rate
329
+ if words == 0 or dur <= 0:
330
+ return True, 0.0, "unscorable"
331
+ wps = words / dur
332
+
333
+ frame = int(0.05 * sample_rate)
334
+ n = (len(audio) // frame) * frame
335
+ frames = np.asarray(audio[:n], dtype=np.float64).reshape(-1, frame)
336
+ rms = np.sqrt((frames ** 2).mean(axis=1))
337
+ overall = rms.mean() or 1e-9
338
+ silence_frac = float((rms < 0.05 * overall).mean())
339
+
340
+ # Spectral flatness on up to 40 evenly-spaced frames (cheap):
341
+ # geometric/arithmetic mean ratio of the power spectrum.
342
+ idx = np.linspace(0, len(frames) - 1, min(40, len(frames))).astype(int)
343
+ spec = np.abs(np.fft.rfft(frames[idx], axis=1)) ** 2 + 1e-12
344
+ flatness = float(np.exp(np.log(spec).mean(axis=1)).mean()
345
+ / spec.mean(axis=1).mean())
346
+
347
+ badness = 0.0
348
+ reasons = []
349
+ if wps < cls.GATE_WPS_MIN or wps > cls.GATE_WPS_MAX:
350
+ badness += abs(wps - np.clip(wps, cls.GATE_WPS_MIN, cls.GATE_WPS_MAX))
351
+ reasons.append(f"rate {wps:.2f} wps")
352
+ if silence_frac > cls.GATE_SILENCE_MAX:
353
+ badness += silence_frac - cls.GATE_SILENCE_MAX
354
+ reasons.append(f"silence {silence_frac:.2f}")
355
+ if flatness > cls.GATE_FLATNESS_MAX:
356
+ badness += flatness - cls.GATE_FLATNESS_MAX
357
+ reasons.append(f"flatness {flatness:.2f}")
358
+ if reasons:
359
+ return False, badness, ", ".join(reasons)
360
+ return True, 0.0, f"ok (wps {wps:.2f}, sil {silence_frac:.2f}, flat {flatness:.3f})"
361
+ except Exception as gate_err: # fail open — never block a render
362
+ print(f"Quality gate error (passing chunk through): {gate_err}")
363
+ return True, 0.0, "gate error"
364
+
365
  @staticmethod
366
  def _infer_num_speakers_from_script(script: str) -> int:
367
  """
 
472
  )
473
  self._place_model(model_name)
474
 
475
+ # GPU health check: a poisoned container (zombie thread from a
476
+ # cancelled run) shows allocated VRAM far above the post-load
477
+ # baseline. Proceeding would OOM every wave — recycle instead.
478
+ gc.collect()
479
+ torch.cuda.empty_cache()
480
+ alloc = torch.cuda.memory_allocated()
481
+ baseline = getattr(self, "baseline_alloc", alloc)
482
+ if alloc > baseline + 6e9:
483
+ yield self._emit_progress(
484
+ stage="error", pct=0,
485
+ status="GPU busy from a previous cancelled job — container is restarting. Please retry in about a minute.",
486
+ log_text=log_text + "\nGPU poisoned (leftover allocation detected); recycling container.",
487
+ )
488
+ os._exit(1)
489
+
490
  model = self.models[model_name]
491
  processor = self.processors[model_name]
492
  model.set_ddpm_inference_steps(num_steps=self.inference_steps)
 
496
  if not script.strip():
497
  raise ValueError("Error: Please provide a script.")
498
 
499
+ script = script.replace("’", "'").replace("‘", "'")
500
 
501
  if not 1 <= num_speakers <= 4:
502
  raise ValueError("Error: Number of speakers must be between 1 and 4.")
 
555
  log_text=log_text,
556
  )
557
 
558
+ # ---- chunked-parallel fast path (falls back to monolithic) ------
559
+ script_words = len(formatted_script.split())
560
+ chunks = (self._split_turns_into_chunks(formatted_script_lines)
561
+ if script_words >= self.CHUNK_MIN_SCRIPT_WORDS else [formatted_script])
562
+ batch_cap = self.MAX_BATCH.get(model_name, 4)
563
+ use_parallel = len(chunks) > 1
 
564
 
565
+ if use_parallel:
566
+ log_lines.append(
567
+ f"Parallel mode: {len(chunks)} chunks, batches of up to {batch_cap}"
568
+ )
569
+ else:
570
+ log_lines.append("Short script: single-pass generation")
571
  log_text = "\n".join(log_lines)
572
  yield self._emit_progress(
573
  stage="generating_audio",
574
  pct=70,
575
+ status="Running VibeVoice diffusion…",
576
  log_text=log_text,
577
  )
578
 
579
+ def _generate_batch(text_list):
580
+ """One batched generate call; returns list of np audio (or None)."""
581
+ batch_inputs = processor(
582
+ text=text_list,
583
+ voice_samples=[voice_samples] * len(text_list),
584
+ padding=True,
585
+ return_tensors="pt",
586
+ return_attention_mask=True,
587
+ ).to(self.device)
588
+ result, exc = {}, {}
589
+
590
+ def _run():
591
+ try:
592
+ with torch.inference_mode():
593
+ result['outputs'] = model.generate(
594
+ **batch_inputs,
595
+ max_new_tokens=None,
596
+ cfg_scale=cfg_scale,
597
+ tokenizer=processor.tokenizer,
598
+ generation_config={'do_sample': False},
599
+ verbose=False,
600
+ )
601
+ except Exception as gen_err:
602
+ # store the MESSAGE, never the exception object: its
603
+ # traceback pins every GPU tensor of the failed attempt
604
+ # and makes OOM-backoff retries futile (2026-08-14 bug)
605
+ exc['error'] = f"{type(gen_err).__name__}: {gen_err}"
606
+
607
+ t = threading.Thread(target=_run, daemon=True)
608
+ t.start()
609
  try:
610
+ while t.is_alive():
611
+ yield None # keep-alive tick for the caller
612
+ time.sleep(5)
613
+ except GeneratorExit:
614
+ # Client cancelled mid-generation. Python cannot kill the
615
+ # worker thread, and a surviving thread becomes a zombie
616
+ # that eats the GPU for every later request on this warm
617
+ # container (observed 2026-08-14). Recycle the container.
618
+ print("Run cancelled mid-generation; recycling container "
619
+ "so no zombie thread survives.")
620
+ os._exit(1)
621
+ t.join()
622
+ if 'error' in exc:
623
+ result.clear()
624
+ del batch_inputs
625
+ gc.collect()
626
+ torch.cuda.empty_cache()
627
+ raise RuntimeError(exc['error'])
628
+ outputs = result.get('outputs')
629
+ if outputs is None or not hasattr(outputs, 'speech_outputs'):
630
+ raise RuntimeError("Generation produced no outputs.")
631
+ pieces = []
632
+ for so in outputs.speech_outputs[:len(text_list)]:
633
+ if so is None:
634
+ pieces.append(None)
635
+ else:
636
+ a = so.cpu().float().numpy()
637
+ pieces.append(a.squeeze() if a.ndim > 1 else a)
638
+ # release this wave's GPU memory before the next wave starts
639
+ del outputs
640
+ result.clear()
641
+ del batch_inputs
642
+ gc.collect()
643
+ torch.cuda.empty_cache()
644
+ yield pieces
645
+
646
+ def _wave_with_backoff(wave):
647
+ """Run a wave; on CUDA OOM, split in half and retry recursively.
648
+
649
+ Yields None keep-alive ticks, then exactly one final list of
650
+ audio pieces (in wave order)."""
651
+ try:
652
+ for tick in _generate_batch(wave):
653
+ yield tick
654
+ return
655
+ except RuntimeError as e:
656
+ msg = str(e).lower()
657
+ if len(wave) == 1 or ("out of memory" not in msg and "cuda" not in msg):
658
+ raise
659
+ print(f"OOM at batch {len(wave)}; backing off to halves.")
660
+ gc.collect() # must run BEFORE empty_cache to actually free tensors
661
+ torch.cuda.empty_cache()
662
+ mid = (len(wave) + 1) // 2
663
+ combined = []
664
+ for sub in (wave[:mid], wave[mid:]):
665
+ if not sub:
666
+ continue
667
+ for tick in _wave_with_backoff(sub):
668
+ if tick is None:
669
+ yield None
670
+ else:
671
+ combined.extend(tick)
672
+ yield combined
673
 
674
+ start_time = time.time()
675
+ audio = None
676
+ ran_parallel = False
677
+ try:
678
+ waves = [chunks[i:i + batch_cap] for i in range(0, len(chunks), batch_cap)]
679
+ all_pieces = []
680
+ rerolled_total = 0
681
+ for wi, wave in enumerate(waves):
682
+ wave_pieces = []
683
+ for tick in _wave_with_backoff(wave):
684
+ if tick is None:
685
+ elapsed = time.time() - start_time
686
+ pct_hint = min(88, 70 + int(elapsed // 5))
687
+ yield self._emit_progress(
688
+ stage="generating_audio",
689
+ pct=pct_hint,
690
+ status=(f"Rendering wave {wi + 1}/{len(waves)} "
691
+ f"({len(wave)} chunks in parallel)… "
692
+ f"{int(elapsed)}s elapsed"),
693
+ log_text=log_text,
694
+ )
695
+ else:
696
+ wave_pieces = tick
697
+
698
+ # ---- quality gate: score every chunk, reroll failures --
699
+ if len(wave_pieces) == len(wave):
700
+ gates = [self._chunk_quality(wave[ci], wave_pieces[ci], 24000)
701
+ for ci in range(len(wave))]
702
+ failed = [ci for ci, (ok, _, _) in enumerate(gates) if not ok]
703
+ for ci, (ok, _, reason) in enumerate(gates):
704
+ chunk_no = wi * batch_cap + ci + 1
705
+ # container-log every chunk's metrics (calibration data)
706
+ print(f"gate: chunk {chunk_no}: {reason}")
707
+ if not ok:
708
+ log_lines.append(
709
+ f"Quality gate: chunk {chunk_no} FAILED ({reason}) — rerolling")
710
+ if failed:
711
+ log_text = "\n".join(log_lines)
712
+ torch.manual_seed((int(start_time * 1000) + wi * 7919) % (2 ** 31))
713
+ reroll_pieces = []
714
+ for tick in _wave_with_backoff([wave[ci] for ci in failed]):
715
+ if tick is None:
716
+ elapsed = time.time() - start_time
717
+ yield self._emit_progress(
718
+ stage="generating_audio",
719
+ pct=min(88, 70 + int(elapsed // 5)),
720
+ status=(f"Re-rolling {len(failed)} flagged "
721
+ f"chunk(s) on a new seed… "
722
+ f"{int(elapsed)}s elapsed"),
723
+ log_text=log_text,
724
+ )
725
+ else:
726
+ reroll_pieces = tick
727
+ for j, ci in enumerate(failed):
728
+ if j >= len(reroll_pieces):
729
+ break
730
+ ok2, bad2, reason2 = self._chunk_quality(
731
+ wave[ci], reroll_pieces[j], 24000)
732
+ chunk_no = wi * batch_cap + ci + 1
733
+ if ok2 or bad2 < gates[ci][1]:
734
+ wave_pieces[ci] = reroll_pieces[j]
735
+ rerolled_total += 1
736
+ log_lines.append(
737
+ f"Quality gate: chunk {chunk_no} reroll "
738
+ f"{'passed' if ok2 else f'improved ({reason2})'} — kept")
739
+ else:
740
+ log_lines.append(
741
+ f"Quality gate: chunk {chunk_no} reroll no better "
742
+ f"({reason2}) — keeping original")
743
+ log_text = "\n".join(log_lines)
744
+ all_pieces.extend(wave_pieces)
745
+ if any(p is None for p in all_pieces):
746
+ raise RuntimeError("A chunk produced no audio.")
747
+ if rerolled_total == 0:
748
+ log_lines.append(f"Quality gate: all {len(all_pieces)} chunks passed")
749
+ log_text = "\n".join(log_lines)
750
+ audio = (self._crossfade_concat(all_pieces, 24000)
751
+ if use_parallel else all_pieces[0])
752
+ ran_parallel = use_parallel
753
+ except Exception as fast_err:
754
+ # Fall back to SEQUENTIAL PER-CHUNK, never monolithic: a long
755
+ # monolithic render triggers the model's rate/drift defects
756
+ # (finding N8) — turn-split chunks must be preserved even at
757
+ # batch size 1. Only truly short scripts run monolithic.
758
+ if not use_parallel:
759
+ raise
760
+ print(f"Parallel path failed ({fast_err}); sequential per-chunk fallback.")
761
+ log_lines.append("Parallel path failed; sequential per-chunk fallback.")
762
+ log_text = "\n".join(log_lines)
763
+ gc.collect()
764
+ torch.cuda.empty_cache()
765
+ seq_pieces = []
766
+ for ci, chunk in enumerate(chunks):
767
+ for tick in _generate_batch([chunk]):
768
+ if tick is None:
769
+ elapsed = time.time() - start_time
770
+ yield self._emit_progress(
771
+ stage="generating_audio",
772
+ pct=min(88, 70 + int(elapsed // 5)),
773
+ status=(f"Sequential fallback: chunk {ci + 1}/"
774
+ f"{len(chunks)}… {int(elapsed)}s elapsed"),
775
+ log_text=log_text,
776
+ )
777
+ else:
778
+ seq_pieces.extend(tick)
779
+ if any(p is None for p in seq_pieces) or not seq_pieces:
780
+ raise RuntimeError("Error: No audio was generated by the model.")
781
+ audio = self._crossfade_concat(seq_pieces, 24000)
782
 
783
  generation_time = time.time() - start_time
784
+ audio_s = len(audio) / 24000 if audio is not None else 0
785
+ container_age = time.time() - getattr(self, "ready_at", time.time())
786
+ cold = container_age < 120 # this request likely paid the container boot
787
+ speed = (audio_s / generation_time) if generation_time > 0 else 0
788
+ log_lines.append("—— timing ——")
789
+ log_lines.append(
790
+ f"Container: {'COLD START (boot + model load happened before this job)' if cold else 'warm'}"
791
+ )
792
+ log_lines.append(
793
+ f"Pure generation: {generation_time:.1f}s for {audio_s:.1f}s of audio "
794
+ f"→ {speed:.2f}x realtime"
795
+ )
796
+ if ran_parallel:
797
+ n_waves = (len(chunks) + batch_cap - 1) // batch_cap
798
+ log_lines.append(f"Mode: parallel — {len(chunks)} chunks in {n_waves} wave(s)")
799
+ else:
800
+ log_lines.append("Mode: sequential chunks (fallback)" if use_parallel else "Mode: single-pass")
801
  log_lines.append("Processing audio output…")
802
  log_text = "\n".join(log_lines)
803
  yield self._emit_progress(
 
807
  log_text=log_text,
808
  )
809
 
810
+ if audio is None or len(audio) == 0:
 
 
 
811
  raise RuntimeError("Error: No audio was generated by the model.")
812
 
 
 
 
813
  sample_rate = 24000
814
  total_duration = len(audio) / sample_rate
815
  log_lines.append(f"Audio duration: {total_duration:.2f} seconds")
requirements.txt CHANGED
@@ -1,3 +1,7 @@
1
- gradio
 
 
2
  modal
3
  huggingface_hub
 
 
 
1
+ fastapi>=0.115,<1.0
2
+ uvicorn[standard]>=0.30
3
+ python-multipart>=0.0.9
4
  modal
5
  huggingface_hub
6
+ numpy
7
+ scipy
static/app.js ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const MAX_TURNS = 50;
2
+
3
+ const SCRIPT_GEN_MESSAGES = [
4
+ "Writing script...", "Still generating...", "Making magic happen...",
5
+ "Bossing around robot writers...", "Entering the matrix...", "Crafting dialogue...",
6
+ "Teaching AI to be dramatic...", "Consulting the creative robots...", "Spilling digital ink...",
7
+ "Herding AI cats into a script...", "Negotiating with the muse...", "Downloading inspiration...",
8
+ "Warming up the plot engine...", "Shaking the idea tree...", "Feeding the word machine...",
9
+ "Polishing virtual microphones...", "Rehearsing in the AI green room...",
10
+ "Bribing the creativity daemon...", "Untangling narrative spaghetti...",
11
+ "Summoning fictional characters...", "Tuning the dialogue generator...",
12
+ "Spinning up the story factory...", "Convincing electrons to be eloquent...",
13
+ "Wrangling syllables into sentences...", "Loading dramatic tension...",
14
+ "Calibrating the sass levels...", "Generating witty banter...",
15
+ "Overthinking your prompt (in a good way)...", "Adding a pinch of personality...",
16
+ "Almost done, probably...", "Finalizing the masterpiece...",
17
+ ];
18
+
19
+ const PRIMARY_STAGE_MESSAGES = {
20
+ connecting: ["Submitted", "Provisioning GPU resources... cold starts can take up to a minute."],
21
+ queued: ["Queued", "Worker is spinning up. Cold starts may take 30-60 seconds."],
22
+ loading_model: ["Loading Model", "Streaming VibeVoice weights to the GPU."],
23
+ loading_voices: ["Loading Voices", null],
24
+ preparing_inputs: ["Preparing", "Formatting the conversation for the model."],
25
+ generating_audio: ["Generating", "Synthesizing speech — this is the longest step."],
26
+ processing_audio: ["Finalizing", "Converting tensors into a playable waveform."],
27
+ complete: ["Complete", "Press play below or download your audio."],
28
+ error: ["Error", "Check the log for details."],
29
+ };
30
+
31
+ const state = {
32
+ turns: [],
33
+ numSpeakers: 2,
34
+ voices: [],
35
+ voiceSelections: [null, null, null, null],
36
+ models: [],
37
+ examples: [],
38
+ parodyLines: [],
39
+ parodyIndex: 0,
40
+ currentAudioId: null,
41
+ };
42
+
43
+ const el = {
44
+ runtimeStatus: document.querySelector("#runtimeStatus"),
45
+ runtimeLabel: document.querySelector("#runtimeLabel"),
46
+ scriptPrompt: document.querySelector("#scriptPrompt"),
47
+ generateScriptBtn: document.querySelector("#generateScriptBtn"),
48
+ scriptGenStatus: document.querySelector("#scriptGenStatus"),
49
+ examplePills: document.querySelector("#examplePills"),
50
+ pastedScript: document.querySelector("#pastedScript"),
51
+ scriptFileUpload: document.querySelector("#scriptFileUpload"),
52
+ loadScriptBtn: document.querySelector("#loadScriptBtn"),
53
+ scriptTitle: document.querySelector("#scriptTitle"),
54
+ scriptDuration: document.querySelector("#scriptDuration"),
55
+ turnsScroll: document.querySelector("#turnsScroll"),
56
+ addTurnBtn: document.querySelector("#addTurnBtn"),
57
+ modelSelect: document.querySelector("#modelSelect"),
58
+ cfgScale: document.querySelector("#cfgScale"),
59
+ cfgScaleValue: document.querySelector("#cfgScaleValue"),
60
+ previewVoiceSelect: document.querySelector("#previewVoiceSelect"),
61
+ previewAudio: document.querySelector("#previewAudio"),
62
+ generateBtn: document.querySelector("#generateBtn"),
63
+ primaryStatus: document.querySelector("#primaryStatus"),
64
+ primaryStatusTitle: document.querySelector("#primaryStatusTitle"),
65
+ primaryStatusDesc: document.querySelector("#primaryStatusDesc"),
66
+ outputEmpty: document.querySelector("#outputEmpty"),
67
+ outputResult: document.querySelector("#outputResult"),
68
+ resultWaveform: document.querySelector("#resultWaveform"),
69
+ resultAudio: document.querySelector("#resultAudio"),
70
+ generationTime: document.querySelector("#generationTime"),
71
+ audioDuration: document.querySelector("#audioDuration"),
72
+ resultModel: document.querySelector("#resultModel"),
73
+ downloadBtn: document.querySelector("#downloadBtn"),
74
+ logBox: document.querySelector("#logBox"),
75
+ };
76
+
77
+ function genderOf(name) {
78
+ const v = state.voices.find((x) => x.name === name);
79
+ return v ? v.gender : "?";
80
+ }
81
+
82
+ function estimateDuration(turns) {
83
+ const totalWords = turns.reduce((sum, t) => sum + (t.text || "").trim().split(/\s+/).filter(Boolean).length, 0);
84
+ if (totalWords === 0) return "";
85
+ const minutes = totalWords / 150;
86
+ return minutes < 1 ? `~${Math.round(minutes * 60)}s` : `~${minutes.toFixed(1)}m`;
87
+ }
88
+
89
+ function formatDuration(seconds) {
90
+ if (!Number.isFinite(seconds)) return "--";
91
+ return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
92
+ }
93
+
94
+ /* ---------------- Views ---------------- */
95
+ document.querySelectorAll(".view-tab").forEach((tab) => {
96
+ tab.addEventListener("click", () => {
97
+ document.querySelectorAll(".view-tab").forEach((t) => t.classList.toggle("active", t === tab));
98
+ document.querySelectorAll("#generate-view, #architecture-view").forEach((section) => {
99
+ section.classList.toggle("visible", section.id === `${tab.dataset.view}-view`);
100
+ });
101
+ });
102
+ });
103
+
104
+ /* ---------------- Status polling ---------------- */
105
+ async function updateStatus() {
106
+ try {
107
+ const res = await fetch("/api/status", { cache: "no-store" });
108
+ const payload = await res.json();
109
+ const ready = payload.backend === "ready";
110
+ el.runtimeStatus.classList.toggle("ready", ready);
111
+ el.runtimeLabel.textContent = ready ? "Model ready" : "Backend offline";
112
+ } catch {
113
+ el.runtimeStatus.classList.remove("ready");
114
+ el.runtimeLabel.textContent = "Connecting";
115
+ }
116
+ }
117
+
118
+ /* ---------------- Voice / model selects ---------------- */
119
+ function renderVoiceControls() {
120
+ el.modelSelect.innerHTML = state.models.map((m) => `<option value="${m}">${m}</option>`).join("");
121
+
122
+ document.querySelectorAll(".voice-select").forEach((select) => {
123
+ const idx = Number(select.dataset.index);
124
+ select.innerHTML = state.voices
125
+ .map((v) => `<option value="${v.name}">${v.name} (${v.gender})</option>`)
126
+ .join("");
127
+ if (state.voiceSelections[idx]) select.value = state.voiceSelections[idx];
128
+ select.closest(".voice-slot").hidden = idx >= state.numSpeakers;
129
+ });
130
+
131
+ el.previewVoiceSelect.innerHTML = state.voices.map((v) => `<option value="${v.name}">${v.name} (${v.gender})</option>`).join("");
132
+ if (state.voices.length) {
133
+ el.previewVoiceSelect.value = state.voices[0].name;
134
+ el.previewAudio.src = state.voices[0].preview_url;
135
+ }
136
+ }
137
+
138
+ document.querySelectorAll(".voice-select").forEach((select) => {
139
+ select.addEventListener("change", () => {
140
+ state.voiceSelections[Number(select.dataset.index)] = select.value;
141
+ renderTurns();
142
+ });
143
+ });
144
+
145
+ el.previewVoiceSelect.addEventListener("change", () => {
146
+ const v = state.voices.find((x) => x.name === el.previewVoiceSelect.value);
147
+ if (v) el.previewAudio.src = v.preview_url;
148
+ });
149
+
150
+ el.cfgScale.addEventListener("input", () => {
151
+ el.cfgScaleValue.textContent = Number(el.cfgScale.value).toFixed(2);
152
+ });
153
+
154
+ /* ---------------- Turn editor ---------------- */
155
+ function speakerChoiceLabel(i) {
156
+ const sel = state.voiceSelections[i];
157
+ return sel ? `Speaker ${i + 1} - ${sel} (${genderOf(sel)})` : `Speaker ${i + 1}`;
158
+ }
159
+
160
+ function renderTurns() {
161
+ el.turnsScroll.innerHTML = "";
162
+ if (!state.turns.length) {
163
+ const empty = document.createElement("div");
164
+ empty.className = "empty-turns";
165
+ empty.id = "emptyTurns";
166
+ empty.innerHTML = "Your conversation will appear here.<br />Type a prompt above and click <strong>Write Script with AI</strong>, or pick an example to get started.";
167
+ el.turnsScroll.append(empty);
168
+ updateDuration();
169
+ return;
170
+ }
171
+
172
+ state.turns.forEach((turn, idx) => {
173
+ const spk = Math.min(4, Math.max(1, turn.speaker || 1));
174
+ const row = document.createElement("div");
175
+ row.className = `turn-row speaker-${spk}`;
176
+
177
+ const spkSelect = document.createElement("select");
178
+ for (let i = 1; i <= 4; i += 1) {
179
+ const opt = document.createElement("option");
180
+ opt.value = String(i);
181
+ opt.textContent = speakerChoiceLabel(i - 1);
182
+ if (i === spk) opt.selected = true;
183
+ spkSelect.append(opt);
184
+ }
185
+ spkSelect.addEventListener("change", () => {
186
+ state.turns[idx].speaker = Number(spkSelect.value);
187
+ renderTurns();
188
+ });
189
+
190
+ const textArea = document.createElement("textarea");
191
+ textArea.rows = 2;
192
+ textArea.value = turn.text || "";
193
+ textArea.addEventListener("input", () => {
194
+ state.turns[idx].text = textArea.value;
195
+ updateDuration();
196
+ });
197
+
198
+ const delBtn = document.createElement("button");
199
+ delBtn.type = "button";
200
+ delBtn.className = "btn btn-danger";
201
+ delBtn.textContent = "✕";
202
+ delBtn.addEventListener("click", () => {
203
+ state.turns.splice(idx, 1);
204
+ renderTurns();
205
+ });
206
+
207
+ row.append(spkSelect, textArea, delBtn);
208
+ el.turnsScroll.append(row);
209
+ });
210
+ updateDuration();
211
+ }
212
+
213
+ function updateDuration() {
214
+ el.scriptDuration.textContent = estimateDuration(state.turns);
215
+ }
216
+
217
+ el.addTurnBtn.addEventListener("click", () => {
218
+ if (state.turns.length >= MAX_TURNS) {
219
+ alert(`Maximum ${MAX_TURNS} turns reached.`);
220
+ return;
221
+ }
222
+ let nextSpeaker = 1;
223
+ if (state.turns.length) {
224
+ const maxSpk = Math.max(...state.turns.map((t) => t.speaker));
225
+ const last = state.turns[state.turns.length - 1].speaker;
226
+ nextSpeaker = (last % maxSpk) + 1;
227
+ }
228
+ state.turns.push({ speaker: nextSpeaker, text: "" });
229
+ renderTurns();
230
+ });
231
+
232
+ function loadScriptResult(result, titleFallback) {
233
+ state.turns = result.turns;
234
+ state.numSpeakers = result.num_speakers;
235
+ const voices = (result.voices || []).slice(0, 4);
236
+ while (voices.length < 4) voices.push(null);
237
+ state.voiceSelections = voices;
238
+ el.scriptTitle.textContent = result.title || titleFallback || "Script";
239
+ renderVoiceControls();
240
+ renderTurns();
241
+ el.outputEmpty.hidden = false;
242
+ el.outputResult.classList.remove("visible");
243
+ }
244
+
245
+ /* ---------------- Examples ---------------- */
246
+ function renderExamplePills() {
247
+ el.examplePills.innerHTML = "";
248
+ state.examples.forEach((example) => {
249
+ const btn = document.createElement("button");
250
+ btn.type = "button";
251
+ btn.className = "btn btn-sm btn-pill";
252
+ btn.textContent = example.title;
253
+ btn.addEventListener("click", () => loadScriptResult(example, example.title));
254
+ el.examplePills.append(btn);
255
+ });
256
+ }
257
+
258
+ /* ---------------- Paste / upload ---------------- */
259
+ el.loadScriptBtn.addEventListener("click", async () => {
260
+ const text = el.pastedScript.value.trim();
261
+ const file = el.scriptFileUpload.files[0];
262
+ if (!text && !file) {
263
+ alert("Paste a script or upload a .txt file first.");
264
+ return;
265
+ }
266
+ const formData = new FormData();
267
+ formData.set("text", text);
268
+ if (file) formData.set("file", file);
269
+
270
+ try {
271
+ const res = await fetch("/api/parse-script", { method: "POST", body: formData });
272
+ const payload = await res.json();
273
+ if (!res.ok) throw new Error(payload.detail || "Could not parse script.");
274
+ if (payload.over_limit) {
275
+ alert(`Script is ${payload.word_count} words; loading anyway — trim before generating.`);
276
+ }
277
+ loadScriptResult(payload, "Uploaded Script");
278
+ } catch (error) {
279
+ alert(error.message);
280
+ }
281
+ });
282
+
283
+ /* ---------------- AI script generation ---------------- */
284
+ el.generateScriptBtn.addEventListener("click", async () => {
285
+ const prompt = el.scriptPrompt.value.trim();
286
+ if (!prompt) {
287
+ alert("Please enter a prompt.");
288
+ return;
289
+ }
290
+
291
+ el.generateScriptBtn.disabled = true;
292
+ el.generateScriptBtn.textContent = "Writing...";
293
+ el.loadScriptBtn.disabled = true;
294
+ let msgIdx = 0;
295
+ el.scriptGenStatus.textContent = SCRIPT_GEN_MESSAGES[0];
296
+ const ticker = window.setInterval(() => {
297
+ msgIdx += 1;
298
+ el.scriptGenStatus.textContent = SCRIPT_GEN_MESSAGES[msgIdx % SCRIPT_GEN_MESSAGES.length];
299
+ }, 3000);
300
+
301
+ try {
302
+ const res = await fetch("/api/generate-script", {
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/json" },
305
+ body: JSON.stringify({ prompt }),
306
+ });
307
+ const payload = await res.json();
308
+ if (!res.ok) throw new Error(payload.detail || "Script generation failed.");
309
+ state.parodyLines = payload.parody_lines || [];
310
+ state.parodyIndex = 0;
311
+ loadScriptResult(payload, "Script");
312
+ el.scriptGenStatus.textContent = "";
313
+ } catch (error) {
314
+ el.scriptGenStatus.textContent = error.message;
315
+ } finally {
316
+ window.clearInterval(ticker);
317
+ el.generateScriptBtn.disabled = false;
318
+ el.generateScriptBtn.textContent = "Write Script with AI";
319
+ el.loadScriptBtn.disabled = false;
320
+ }
321
+ });
322
+
323
+ /* ---------------- Waveform ---------------- */
324
+ async function drawWaveform(url, canvas, color = "#6366f1") {
325
+ const response = await fetch(url);
326
+ if (!response.ok) return;
327
+ const data = await response.arrayBuffer();
328
+ const context = new AudioContext();
329
+ try {
330
+ const buffer = await context.decodeAudioData(data.slice(0));
331
+ const samples = buffer.getChannelData(0);
332
+ const width = canvas.width;
333
+ const height = canvas.height;
334
+ const blocks = Math.min(160, Math.max(40, Math.floor(width / 6)));
335
+ const blockSize = Math.max(1, Math.floor(samples.length / blocks));
336
+ const peaks = [];
337
+ for (let block = 0; block < blocks; block += 1) {
338
+ let peak = 0;
339
+ const start = block * blockSize;
340
+ const end = Math.min(samples.length, start + blockSize);
341
+ for (let i = start; i < end; i += 1) peak = Math.max(peak, Math.abs(samples[i]));
342
+ peaks.push(peak);
343
+ }
344
+ const maxPeak = Math.max(...peaks, 0.001);
345
+ const draw = canvas.getContext("2d");
346
+ draw.clearRect(0, 0, width, height);
347
+ draw.fillStyle = color;
348
+ const barWidth = Math.max(2, width / blocks - 2);
349
+ peaks.forEach((peak, i) => {
350
+ const normalized = peak / maxPeak;
351
+ const barHeight = Math.max(3, normalized * height * 0.9);
352
+ const x = i * (width / blocks) + 1;
353
+ draw.fillRect(x, (height - barHeight) / 2, barWidth, barHeight);
354
+ });
355
+ } finally {
356
+ await context.close();
357
+ }
358
+ }
359
+
360
+ /* ---------------- Generation status banner ---------------- */
361
+ function nextParodyLine() {
362
+ if (!state.parodyLines.length) return null;
363
+ const line = state.parodyLines[state.parodyIndex % state.parodyLines.length];
364
+ state.parodyIndex += 1;
365
+ return line;
366
+ }
367
+
368
+ function setPrimaryStatus(stage, fallbackText) {
369
+ const [title, defaultDesc] = PRIMARY_STAGE_MESSAGES[stage] || ["Working", "Processing..."];
370
+ el.primaryStatus.classList.add("visible");
371
+ el.primaryStatus.classList.toggle("active", stage !== "complete" && stage !== "error");
372
+ el.primaryStatus.classList.toggle("complete", stage === "complete");
373
+ el.primaryStatus.classList.toggle("error", stage === "error");
374
+ el.primaryStatusTitle.textContent = title;
375
+ el.primaryStatusDesc.textContent = fallbackText || defaultDesc || "";
376
+ }
377
+
378
+ /* ---------------- Generate ---------------- */
379
+ el.generateBtn.addEventListener("click", async () => {
380
+ const script = state.turns.map((t) => (t.text || "").trim()).filter(Boolean).join(" ");
381
+ if (!script) {
382
+ alert("Add dialogue before generating.");
383
+ return;
384
+ }
385
+
386
+ el.generateBtn.disabled = true;
387
+ el.generateBtn.textContent = "Generating...";
388
+ el.generateScriptBtn.disabled = true;
389
+ el.outputResult.classList.remove("visible");
390
+ el.outputEmpty.hidden = false;
391
+ el.logBox.textContent = "";
392
+ const started = performance.now();
393
+ setPrimaryStatus("connecting", nextParodyLine() || "Provisioning GPU resources...");
394
+
395
+ const payload = {
396
+ model: el.modelSelect.value,
397
+ num_speakers: state.numSpeakers,
398
+ turns: state.turns,
399
+ speakers: state.voiceSelections,
400
+ cfg_scale: Number(el.cfgScale.value),
401
+ };
402
+
403
+ try {
404
+ const response = await fetch("/api/generate", {
405
+ method: "POST",
406
+ headers: { "Content-Type": "application/json" },
407
+ body: JSON.stringify(payload),
408
+ });
409
+ if (!response.ok) {
410
+ const body = await response.json().catch(() => ({}));
411
+ throw new Error(body.detail || "The model could not generate audio.");
412
+ }
413
+
414
+ const reader = response.body.getReader();
415
+ const decoder = new TextDecoder();
416
+ let buffer = "";
417
+ let done = false;
418
+
419
+ while (!done) {
420
+ const chunk = await reader.read();
421
+ done = chunk.done;
422
+ if (chunk.value) buffer += decoder.decode(chunk.value, { stream: true });
423
+
424
+ let boundary;
425
+ while ((boundary = buffer.indexOf("\n\n")) >= 0) {
426
+ const rawEvent = buffer.slice(0, boundary);
427
+ buffer = buffer.slice(boundary + 2);
428
+ if (!rawEvent.startsWith("data: ")) continue;
429
+ const evt = JSON.parse(rawEvent.slice(6));
430
+ const isDone = evt.stage === "complete" || evt.stage === "error";
431
+ const displayLine = isDone ? evt.status : nextParodyLine() || evt.status;
432
+ setPrimaryStatus(evt.stage, displayLine);
433
+ if (evt.log) el.logBox.textContent = evt.log;
434
+
435
+ if (evt.stage === "complete" && evt.audio_id) {
436
+ const audioRes = await fetch(`/api/audio/${evt.audio_id}`);
437
+ const blob = await audioRes.blob();
438
+ const url = URL.createObjectURL(blob);
439
+ el.resultAudio.src = url;
440
+ el.downloadBtn.href = url;
441
+ el.generationTime.textContent = formatDuration((performance.now() - started) / 1000);
442
+ el.audioDuration.textContent = formatDuration(evt.audio_duration);
443
+ el.resultModel.textContent = el.modelSelect.value;
444
+ await drawWaveform(url, el.resultWaveform);
445
+ el.outputEmpty.hidden = true;
446
+ el.outputResult.classList.add("visible");
447
+ }
448
+ }
449
+ }
450
+ } catch (error) {
451
+ setPrimaryStatus("error", error.message);
452
+ } finally {
453
+ el.generateBtn.disabled = false;
454
+ el.generateBtn.textContent = "Generate Conference Audio";
455
+ el.generateScriptBtn.disabled = false;
456
+ }
457
+ });
458
+
459
+ /* ---------------- Init ---------------- */
460
+ async function init() {
461
+ const [models, voices, examples] = await Promise.all([
462
+ fetch("/api/models").then((r) => r.json()),
463
+ fetch("/api/voices").then((r) => r.json()),
464
+ fetch("/api/examples").then((r) => r.json()),
465
+ ]);
466
+ state.models = models;
467
+ state.voices = voices;
468
+ state.examples = examples;
469
+ state.voiceSelections = voices.slice(0, 4).map((v) => v.name);
470
+ while (state.voiceSelections.length < 4) state.voiceSelections.push(null);
471
+
472
+ renderVoiceControls();
473
+ renderTurns();
474
+ renderExamplePills();
475
+ updateStatus();
476
+ window.setInterval(updateStatus, 8000);
477
+ }
478
+
479
+ init().catch((error) => {
480
+ el.scriptGenStatus.textContent = `Failed to load app data: ${error.message}`;
481
+ });
static/index.html ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="theme-color" content="#0d0d14" />
7
+ <meta
8
+ name="description"
9
+ content="Generate realistic multi-speaker conference calls, meetings, and podcasts from a single prompt, powered by VibeVoice."
10
+ />
11
+ <title>Conference Generator — VibeVoice</title>
12
+ <link rel="stylesheet" href="/static/styles.css" />
13
+ <script src="/static/app.js" defer></script>
14
+ </head>
15
+ <body>
16
+ <header class="topbar">
17
+ <div class="topbar-inner">
18
+ <a class="brand" href="/">
19
+ <span class="brand-mark">🎙️</span>
20
+ <span>Conference Generator</span>
21
+ </a>
22
+ <nav class="view-tabs" aria-label="View">
23
+ <button class="view-tab active" type="button" data-view="generate">Generate</button>
24
+ <button class="view-tab" type="button" data-view="architecture">Architecture</button>
25
+ </nav>
26
+ <div class="runtime-status" id="runtimeStatus" aria-live="polite">
27
+ <span class="status-dot"></span>
28
+ <span id="runtimeLabel">Connecting</span>
29
+ </div>
30
+ </div>
31
+ </header>
32
+
33
+ <main>
34
+ <img
35
+ class="banner"
36
+ src="https://huggingface.co/spaces/ACloudCenter/Conference-Generator-VibeVoice/resolve/main/public/images/banner.png"
37
+ alt="VibeVoice Conference Generator"
38
+ />
39
+
40
+ <section id="generate-view" class="visible">
41
+ <!-- STEP 1: DESCRIBE -->
42
+ <section class="step-card">
43
+ <div class="step-heading">
44
+ <div class="step-heading-left">
45
+ <span class="step-index">01</span>
46
+ <h2>Describe your conversation</h2>
47
+ </div>
48
+ </div>
49
+ <p class="field-hint" style="margin-top: -6px; margin-bottom: 12px;">
50
+ Describe any scenario and AI writes the script. Then edit, assign voices, and generate audio.
51
+ </p>
52
+ <div class="script-prompt-row">
53
+ <textarea
54
+ id="scriptPrompt"
55
+ rows="2"
56
+ placeholder="A wizard and an orc debating battle strategy before a siege..."
57
+ ></textarea>
58
+ <div class="row">
59
+ <button id="generateScriptBtn" class="btn btn-primary btn-cta" type="button" style="width: auto; flex: 1;">
60
+ Write Script with AI
61
+ </button>
62
+ </div>
63
+ <span id="scriptGenStatus" class="status-text"></span>
64
+ </div>
65
+
66
+ <div id="examplePills" class="row example-pills"></div>
67
+
68
+ <details class="accordion">
69
+ <summary>📋 Paste or upload a full script</summary>
70
+ <div class="accordion-body">
71
+ <p class="field-hint">
72
+ Bring your own script. Use <code>Speaker 1:</code> … <code>Speaker 4:</code> tags (one turn per
73
+ line). Untagged text becomes Speaker 1. Named characters (<code>Host:</code>, <code>Mom:</code>)
74
+ are auto-mapped to speaker slots.
75
+ </p>
76
+ <textarea
77
+ id="pastedScript"
78
+ rows="6"
79
+ placeholder="Speaker 1: Welcome to the show...&#10;Speaker 2: Thanks for having me..."
80
+ ></textarea>
81
+ <div class="row" style="margin-top: 10px;">
82
+ <input id="scriptFileUpload" type="file" accept=".txt,.md" style="flex: 1;" />
83
+ <button id="loadScriptBtn" class="btn btn-primary" type="button">Load Script</button>
84
+ </div>
85
+ </div>
86
+ </details>
87
+ </section>
88
+
89
+ <!-- STEP 2: SCRIPT EDITOR -->
90
+ <section class="step-card">
91
+ <div class="step-heading">
92
+ <div class="step-heading-left">
93
+ <span class="step-index">02</span>
94
+ <h2>Script editor</h2>
95
+ </div>
96
+ </div>
97
+ <div class="script-title-row">
98
+ <h3 id="scriptTitle">Script</h3>
99
+ <span id="scriptDuration" class="script-duration"></span>
100
+ </div>
101
+ <div id="turnsScroll" class="turns-scroll">
102
+ <div class="empty-turns" id="emptyTurns">
103
+ Your conversation will appear here.<br />
104
+ Type a prompt above and click <strong>Write Script with AI</strong>, or pick an example to get started.
105
+ </div>
106
+ </div>
107
+ <button id="addTurnBtn" class="btn btn-sm add-turn-btn" type="button">+ Add Turn</button>
108
+ </section>
109
+
110
+ <!-- STEP 3: VOICE & MODEL -->
111
+ <section class="step-card">
112
+ <div class="step-heading">
113
+ <div class="step-heading-left">
114
+ <span class="step-index">03</span>
115
+ <h2>Voice &amp; model</h2>
116
+ </div>
117
+ </div>
118
+ <div class="voice-grid">
119
+ <div>
120
+ <label class="field-label" for="modelSelect">Model</label>
121
+ <select id="modelSelect"></select>
122
+ </div>
123
+ <div class="voice-slot" data-slot="0">
124
+ <label class="field-label">Voice 1</label>
125
+ <select class="voice-select" data-index="0"></select>
126
+ </div>
127
+ <div class="voice-slot" data-slot="1">
128
+ <label class="field-label">Voice 2</label>
129
+ <select class="voice-select" data-index="1"></select>
130
+ </div>
131
+ <div class="voice-slot" data-slot="2" hidden>
132
+ <label class="field-label">Voice 3</label>
133
+ <select class="voice-select" data-index="2"></select>
134
+ </div>
135
+ <div class="voice-slot" data-slot="3" hidden>
136
+ <label class="field-label">Voice 4</label>
137
+ <select class="voice-select" data-index="3"></select>
138
+ </div>
139
+ </div>
140
+ <label class="field-label" for="cfgScale">CFG Scale</label>
141
+ <div class="slider-row">
142
+ <input id="cfgScale" type="range" min="1.0" max="2.0" step="0.05" value="2.0" />
143
+ <span id="cfgScaleValue" class="slider-value">2.00</span>
144
+ </div>
145
+
146
+ <details class="accordion">
147
+ <summary>🔊 Preview voices before generating</summary>
148
+ <div class="accordion-body preview-row">
149
+ <select id="previewVoiceSelect"></select>
150
+ <audio id="previewAudio" controls></audio>
151
+ </div>
152
+ </details>
153
+ </section>
154
+
155
+ <!-- STEP 4: GENERATE -->
156
+ <section class="step-card">
157
+ <div class="step-heading">
158
+ <div class="step-heading-left">
159
+ <span class="step-index">04</span>
160
+ <h2>Generate</h2>
161
+ </div>
162
+ </div>
163
+ <button id="generateBtn" class="btn btn-primary btn-cta" type="button">Generate Conference Audio</button>
164
+ <div id="primaryStatus" class="gen-status">
165
+ <div class="gen-status-title"><span class="dot"></span><span id="primaryStatusTitle">Working</span></div>
166
+ <div class="gen-status-desc" id="primaryStatusDesc"></div>
167
+ </div>
168
+
169
+ <div class="output-panel">
170
+ <div id="outputEmpty" class="output-empty">Your generated conference audio will appear here.</div>
171
+ <div id="outputResult" class="output-result">
172
+ <canvas id="resultWaveform" width="960" height="90"></canvas>
173
+ <audio id="resultAudio" controls></audio>
174
+ <div class="result-metrics">
175
+ <div><span>Generation</span><strong id="generationTime">--</strong></div>
176
+ <div><span>Audio</span><strong id="audioDuration">--</strong></div>
177
+ <div><span>Model</span><strong id="resultModel">--</strong></div>
178
+ </div>
179
+ <a id="downloadBtn" class="btn btn-sm" download="conference.wav">Download WAV</a>
180
+ </div>
181
+ </div>
182
+
183
+ <details class="accordion">
184
+ <summary>Generation log</summary>
185
+ <div class="accordion-body">
186
+ <div id="logBox" class="log-box"></div>
187
+ </div>
188
+ </details>
189
+ </section>
190
+ </section>
191
+
192
+ <section id="architecture-view">
193
+ <section class="step-card">
194
+ <h2 style="margin-top:0;">VibeVoice: A Frontier Open-Source Text-to-Speech Model</h2>
195
+ <p style="color: var(--text-dim);">
196
+ VibeVoice generates expressive, long-form, multi-speaker conversational audio from text. It uses
197
+ continuous speech tokenizers at an ultra-low 7.5&nbsp;Hz frame rate and a next-token diffusion
198
+ framework to synthesize up to 90 minutes of speech with up to 4 distinct speakers.
199
+ </p>
200
+ <div class="arch-grid">
201
+ <div>
202
+ <h3>Key features</h3>
203
+ <ul>
204
+ <li><strong>Multi-speaker</strong> — up to 4 distinct voices</li>
205
+ <li><strong>Long-form</strong> — up to 90 minutes of audio</li>
206
+ <li><strong>Natural flow</strong> — turn-taking, interruptions, filler words</li>
207
+ <li><strong>Efficient</strong> — 7.5&nbsp;Hz tokenizers for low compute cost</li>
208
+ </ul>
209
+ <h3>Architecture</h3>
210
+ <ul>
211
+ <li>Continuous speech tokenizers — acoustic + semantic at 7.5&nbsp;Hz</li>
212
+ <li>Next-token diffusion — LLM context + diffusion generation</li>
213
+ <li>Diffusion head — high-fidelity acoustic output</li>
214
+ </ul>
215
+ <h3>Models</h3>
216
+ <ul>
217
+ <li><strong>1.5B</strong> — fast inference, great for iteration</li>
218
+ <li><strong>7B</strong> — higher fidelity, longer generation time</li>
219
+ </ul>
220
+ </div>
221
+ <div>
222
+ <img src="/public/images/diagram.jpg" alt="VibeVoice architecture diagram" />
223
+ <img src="/public/images/chart.png" alt="VibeVoice benchmark chart" />
224
+ </div>
225
+ </div>
226
+ </section>
227
+ </section>
228
+ </main>
229
+
230
+ <footer>
231
+ <span>Conference Generator — powered by VibeVoice</span>
232
+ <span>Runs speech synthesis on Modal GPU workers; script writing via Qwen2.5-Coder-32B.</span>
233
+ </footer>
234
+ </body>
235
+ </html>
static/styles.css ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0d0d14;
3
+ --bg-elevated: #14141f;
4
+ --bg-card: #191926;
5
+ --border: #2a2a3a;
6
+ --border-soft: #232332;
7
+ --text: #eceaf5;
8
+ --text-dim: #9a97ad;
9
+ --text-faint: #6e6b82;
10
+ --indigo: #6366f1;
11
+ --fuchsia: #ec4899;
12
+ --green: #22c55e;
13
+ --amber: #f59e0b;
14
+ --red: #ef4444;
15
+ --radius: 14px;
16
+ --radius-sm: 9px;
17
+ --shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
18
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
19
+ }
20
+
21
+ * { box-sizing: border-box; }
22
+
23
+ html, body {
24
+ margin: 0;
25
+ padding: 0;
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ font-family: var(--font);
29
+ -webkit-font-smoothing: antialiased;
30
+ }
31
+
32
+ body {
33
+ min-height: 100vh;
34
+ display: flex;
35
+ flex-direction: column;
36
+ }
37
+
38
+ a { color: inherit; }
39
+
40
+ .sr-only {
41
+ position: absolute;
42
+ width: 1px; height: 1px;
43
+ overflow: hidden;
44
+ clip: rect(0 0 0 0);
45
+ }
46
+
47
+ /* ---- Topbar ---- */
48
+ .topbar {
49
+ position: sticky;
50
+ top: 0;
51
+ z-index: 20;
52
+ background: rgba(13, 13, 20, 0.85);
53
+ backdrop-filter: blur(10px);
54
+ border-bottom: 1px solid var(--border-soft);
55
+ }
56
+ .topbar-inner {
57
+ max-width: 1180px;
58
+ margin: 0 auto;
59
+ padding: 14px 24px;
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: space-between;
63
+ gap: 16px;
64
+ }
65
+ .brand {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 10px;
69
+ text-decoration: none;
70
+ font-weight: 700;
71
+ font-size: 1.05rem;
72
+ color: var(--text);
73
+ }
74
+ .brand-mark {
75
+ width: 28px; height: 28px;
76
+ border-radius: 8px;
77
+ background: linear-gradient(135deg, var(--indigo), var(--fuchsia));
78
+ display: flex; align-items: center; justify-content: center;
79
+ font-size: 1rem;
80
+ }
81
+ .view-tabs {
82
+ display: flex;
83
+ gap: 4px;
84
+ background: var(--bg-elevated);
85
+ border: 1px solid var(--border-soft);
86
+ border-radius: 999px;
87
+ padding: 3px;
88
+ }
89
+ .view-tab {
90
+ border: none;
91
+ background: transparent;
92
+ color: var(--text-dim);
93
+ font: inherit;
94
+ font-size: 0.85rem;
95
+ font-weight: 600;
96
+ padding: 7px 16px;
97
+ border-radius: 999px;
98
+ cursor: pointer;
99
+ transition: background 0.15s, color 0.15s;
100
+ }
101
+ .view-tab.active {
102
+ background: var(--indigo);
103
+ color: #fff;
104
+ }
105
+ .runtime-status {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 8px;
109
+ font-size: 0.8rem;
110
+ color: var(--text-dim);
111
+ border: 1px solid var(--border-soft);
112
+ border-radius: 999px;
113
+ padding: 6px 12px;
114
+ }
115
+ .status-dot {
116
+ width: 8px; height: 8px;
117
+ border-radius: 50%;
118
+ background: var(--red);
119
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
120
+ }
121
+ .runtime-status.ready .status-dot {
122
+ background: var(--green);
123
+ box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
124
+ }
125
+
126
+ /* ---- Layout ---- */
127
+ main {
128
+ max-width: 1180px;
129
+ width: 100%;
130
+ margin: 0 auto;
131
+ padding: 24px;
132
+ flex: 1;
133
+ }
134
+ .banner {
135
+ width: 100%;
136
+ border-radius: var(--radius);
137
+ box-shadow: var(--shadow);
138
+ margin-bottom: 24px;
139
+ display: block;
140
+ }
141
+
142
+ .step-card {
143
+ background: var(--bg-card);
144
+ border: 1px solid var(--border-soft);
145
+ border-radius: var(--radius);
146
+ padding: 22px;
147
+ margin-bottom: 18px;
148
+ }
149
+ .step-heading {
150
+ display: flex;
151
+ align-items: baseline;
152
+ gap: 10px;
153
+ margin-bottom: 14px;
154
+ flex-wrap: wrap;
155
+ justify-content: space-between;
156
+ }
157
+ .step-heading-left { display: flex; align-items: baseline; gap: 10px; }
158
+ .step-index {
159
+ font-size: 0.75rem;
160
+ font-weight: 700;
161
+ color: var(--indigo);
162
+ background: rgba(99, 102, 241, 0.12);
163
+ border-radius: 6px;
164
+ padding: 2px 7px;
165
+ letter-spacing: 0.03em;
166
+ }
167
+ .step-heading h2 {
168
+ margin: 0;
169
+ font-size: 1.05rem;
170
+ }
171
+
172
+ textarea, input[type="text"], input[type="number"], select {
173
+ width: 100%;
174
+ background: var(--bg-elevated);
175
+ border: 1px solid var(--border);
176
+ color: var(--text);
177
+ border-radius: var(--radius-sm);
178
+ padding: 10px 12px;
179
+ font: inherit;
180
+ font-size: 0.92rem;
181
+ resize: vertical;
182
+ }
183
+ textarea:focus, input:focus, select:focus {
184
+ outline: none;
185
+ border-color: var(--indigo);
186
+ }
187
+
188
+ .field-hint {
189
+ font-size: 0.8rem;
190
+ color: var(--text-faint);
191
+ margin: 6px 0 0;
192
+ }
193
+
194
+ .btn {
195
+ font: inherit;
196
+ font-size: 0.88rem;
197
+ font-weight: 600;
198
+ border-radius: var(--radius-sm);
199
+ border: 1px solid var(--border);
200
+ background: var(--bg-elevated);
201
+ color: var(--text);
202
+ padding: 9px 16px;
203
+ cursor: pointer;
204
+ transition: filter 0.15s, transform 0.05s;
205
+ }
206
+ .btn:hover { filter: brightness(1.15); }
207
+ .btn:active { transform: scale(0.98); }
208
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
209
+ .btn-primary {
210
+ background: linear-gradient(135deg, var(--indigo), #4f46e5);
211
+ border-color: transparent;
212
+ color: #fff;
213
+ }
214
+ .btn-cta {
215
+ font-size: 1rem;
216
+ padding: 13px 22px;
217
+ width: 100%;
218
+ border-radius: 12px;
219
+ }
220
+ .btn-danger {
221
+ background: transparent;
222
+ border-color: var(--border);
223
+ color: var(--red);
224
+ padding: 4px 9px;
225
+ font-size: 0.8rem;
226
+ }
227
+ .btn-sm { padding: 6px 12px; font-size: 0.8rem; }
228
+ .btn-pill { border-radius: 999px; }
229
+
230
+ .row { display: flex; gap: 10px; flex-wrap: wrap; }
231
+
232
+ /* ---- Prompt / script gen ---- */
233
+ .script-prompt-row { display: flex; flex-direction: column; gap: 10px; }
234
+ .status-text {
235
+ font-size: 0.85rem;
236
+ color: var(--text-dim);
237
+ font-style: italic;
238
+ min-height: 1.1em;
239
+ }
240
+ .example-pills { margin-top: 12px; }
241
+ .example-pills .btn { border-radius: 999px; font-size: 0.8rem; }
242
+
243
+ details.accordion {
244
+ margin-top: 14px;
245
+ border: 1px solid var(--border-soft);
246
+ border-radius: var(--radius-sm);
247
+ padding: 0;
248
+ }
249
+ details.accordion > summary {
250
+ cursor: pointer;
251
+ padding: 11px 14px;
252
+ font-size: 0.88rem;
253
+ font-weight: 600;
254
+ list-style: none;
255
+ display: flex;
256
+ align-items: center;
257
+ justify-content: space-between;
258
+ }
259
+ details.accordion > summary::-webkit-details-marker { display: none; }
260
+ details.accordion > summary::after {
261
+ content: "\2304";
262
+ transition: transform 0.15s;
263
+ color: var(--text-faint);
264
+ }
265
+ details.accordion[open] > summary::after { transform: rotate(180deg); }
266
+ .accordion-body { padding: 4px 14px 16px; }
267
+
268
+ /* ---- Script editor ---- */
269
+ .script-title-row {
270
+ display: flex;
271
+ align-items: baseline;
272
+ justify-content: space-between;
273
+ gap: 10px;
274
+ margin-bottom: 12px;
275
+ }
276
+ .script-title-row h3 { margin: 0; font-size: 1rem; }
277
+ .script-duration { font-size: 0.8rem; color: var(--text-faint); }
278
+
279
+ .turns-scroll {
280
+ max-height: 460px;
281
+ overflow-y: auto;
282
+ border: 1px solid var(--border-soft);
283
+ border-radius: var(--radius-sm);
284
+ padding: 10px;
285
+ background: var(--bg-elevated);
286
+ }
287
+ .turns-scroll::-webkit-scrollbar { width: 6px; }
288
+ .turns-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
289
+
290
+ .empty-turns {
291
+ text-align: center;
292
+ padding: 40px 16px;
293
+ color: var(--text-faint);
294
+ font-size: 0.88rem;
295
+ }
296
+
297
+ .turn-row {
298
+ display: flex;
299
+ align-items: flex-start;
300
+ gap: 8px;
301
+ padding: 8px;
302
+ border-radius: 10px;
303
+ margin-bottom: 6px;
304
+ border-left: 4px solid var(--border);
305
+ }
306
+ .turn-row select {
307
+ width: auto;
308
+ min-width: 130px;
309
+ max-width: 190px;
310
+ font-size: 0.78rem;
311
+ padding: 8px;
312
+ }
313
+ .turn-row textarea {
314
+ flex: 1;
315
+ font-size: 0.88rem;
316
+ min-height: 44px;
317
+ }
318
+ .speaker-1 { border-left-color: #6366f1; background: #6366f108; }
319
+ .speaker-2 { border-left-color: #ec4899; background: #ec489908; }
320
+ .speaker-3 { border-left-color: #22c55e; background: #22c55e08; }
321
+ .speaker-4 { border-left-color: #f59e0b; background: #f59e0b08; }
322
+
323
+ .add-turn-btn { margin-top: 12px; }
324
+
325
+ /* ---- Voice / model panel ---- */
326
+ .voice-grid {
327
+ display: grid;
328
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
329
+ gap: 12px;
330
+ margin-bottom: 14px;
331
+ }
332
+ .field-label {
333
+ font-size: 0.78rem;
334
+ color: var(--text-dim);
335
+ display: block;
336
+ margin-bottom: 6px;
337
+ font-weight: 600;
338
+ }
339
+ .slider-row {
340
+ display: flex;
341
+ align-items: center;
342
+ gap: 12px;
343
+ }
344
+ input[type="range"] {
345
+ flex: 1;
346
+ accent-color: var(--indigo);
347
+ }
348
+ .slider-value {
349
+ font-size: 0.82rem;
350
+ color: var(--text-dim);
351
+ min-width: 34px;
352
+ text-align: right;
353
+ }
354
+
355
+ .preview-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
356
+ .preview-row select { max-width: 220px; }
357
+ .preview-row audio { flex: 1; min-width: 220px; height: 36px; }
358
+
359
+ /* ---- Generate status banner ---- */
360
+ .gen-status {
361
+ border-radius: 12px;
362
+ padding: 16px 20px;
363
+ margin-top: 14px;
364
+ text-align: center;
365
+ border: 2px solid var(--border-soft);
366
+ display: none;
367
+ }
368
+ .gen-status.visible { display: block; }
369
+ .gen-status-title { font-size: 1rem; font-weight: 700; }
370
+ .gen-status-desc { font-size: 0.85rem; color: var(--text-dim); margin-top: 4px; }
371
+ .gen-status .dot {
372
+ display: inline-block;
373
+ width: 10px; height: 10px;
374
+ border-radius: 50%;
375
+ background: var(--indigo);
376
+ margin-right: 8px;
377
+ vertical-align: middle;
378
+ animation: dot-pulse 1.2s ease-in-out infinite;
379
+ }
380
+ .gen-status.active {
381
+ border-color: var(--indigo);
382
+ background: linear-gradient(90deg,
383
+ rgba(99,102,241,0.05) 0%, rgba(99,102,241,0.16) 30%,
384
+ rgba(236,72,153,0.13) 50%, rgba(99,102,241,0.16) 70%, rgba(99,102,241,0.05) 100%);
385
+ background-size: 300% 100%;
386
+ animation: gradient-sweep 2.5s ease-in-out infinite;
387
+ }
388
+ .gen-status.complete { border-color: var(--green); }
389
+ .gen-status.error { border-color: var(--red); }
390
+ @keyframes dot-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.7); } }
391
+ @keyframes gradient-sweep { 0% { background-position: 100% 0; } 50% { background-position: 0 0; } 100% { background-position: 100% 0; } }
392
+
393
+ /* ---- Output ---- */
394
+ .output-panel { margin-top: 16px; }
395
+ .output-empty {
396
+ text-align: center;
397
+ padding: 32px 16px;
398
+ color: var(--text-faint);
399
+ font-size: 0.85rem;
400
+ border: 1px dashed var(--border);
401
+ border-radius: var(--radius-sm);
402
+ }
403
+ .output-result { display: none; }
404
+ .output-result.visible { display: block; }
405
+ #resultWaveform {
406
+ width: 100%;
407
+ height: 90px;
408
+ border-radius: var(--radius-sm);
409
+ background: var(--bg-elevated);
410
+ display: block;
411
+ margin-bottom: 10px;
412
+ }
413
+ .output-result audio { width: 100%; margin-bottom: 10px; }
414
+ .result-metrics {
415
+ display: flex;
416
+ gap: 20px;
417
+ font-size: 0.8rem;
418
+ color: var(--text-dim);
419
+ margin-bottom: 10px;
420
+ }
421
+ .result-metrics strong { color: var(--text); display: block; font-size: 0.95rem; }
422
+
423
+ .log-box {
424
+ width: 100%;
425
+ background: var(--bg-elevated);
426
+ border: 1px solid var(--border);
427
+ border-radius: var(--radius-sm);
428
+ color: var(--text-dim);
429
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
430
+ font-size: 0.75rem;
431
+ padding: 10px;
432
+ max-height: 220px;
433
+ overflow-y: auto;
434
+ white-space: pre-wrap;
435
+ }
436
+
437
+ /* ---- Architecture view ---- */
438
+ #architecture-view { display: none; }
439
+ #architecture-view.visible { display: block; }
440
+ .arch-grid {
441
+ display: grid;
442
+ grid-template-columns: 1.1fr 1fr;
443
+ gap: 24px;
444
+ align-items: start;
445
+ }
446
+ .arch-grid img {
447
+ width: 100%;
448
+ border-radius: var(--radius-sm);
449
+ margin-bottom: 14px;
450
+ border: 1px solid var(--border-soft);
451
+ }
452
+ .step-card ul { padding-left: 20px; margin: 8px 0; }
453
+ .step-card li { margin-bottom: 4px; font-size: 0.9rem; color: var(--text-dim); }
454
+ @media (max-width: 860px) {
455
+ .arch-grid { grid-template-columns: 1fr; }
456
+ }
457
+
458
+ footer {
459
+ border-top: 1px solid var(--border-soft);
460
+ padding: 18px 24px;
461
+ display: flex;
462
+ justify-content: space-between;
463
+ gap: 12px;
464
+ flex-wrap: wrap;
465
+ font-size: 0.78rem;
466
+ color: var(--text-faint);
467
+ }
468
+
469
+ @media (max-width: 640px) {
470
+ .topbar-inner { flex-wrap: wrap; }
471
+ main { padding: 16px; }
472
+ .step-card { padding: 16px; }
473
+ }