jayaspjacob Claude Opus 4.8 (1M context) commited on
Commit ·
4c38fd0
1
Parent(s): 310ca13
Select podcast voices on the Create page
Browse filesAdd a "Cast" picker on the Create step with one voice dropdown per speaker
(Nova/Atlas/Echo/Sage/Vivi/Onyx/Rune), shown per the speaker-count slider.
The picked voices drive the actual TTS: each script speaker maps to that
voice's reference clip (tts/voices/samples/<id>.wav), so episodes use the
chosen voices and hosts sound distinct. Selected names are also passed as
the script's speaker labels when distinct, so dialogue reads "Nova: …".
Removes the old step-2 per-speaker preset/clone assignment (voices are now
chosen up front); setup_voices just parses lines + speakers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
app.py
CHANGED
|
@@ -17,7 +17,6 @@ from typing import List
|
|
| 17 |
import gradio as gr
|
| 18 |
|
| 19 |
from research.graph import generate_script, parse_script
|
| 20 |
-
from tts import voices as voice_lib
|
| 21 |
from tts import engine as tts_engine
|
| 22 |
|
| 23 |
MAX_SPEAKERS = 4
|
|
@@ -75,6 +74,26 @@ def _sample_path(voice_id):
|
|
| 75 |
return p if os.path.isfile(p) else None
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
# --------------------------------------------------------------------- HTML builders
|
| 79 |
def _avatar(letter: str, idx: int, size: int = 30) -> str:
|
| 80 |
grad = _AVATAR_GRADIENTS[idx % len(_AVATAR_GRADIENTS)]
|
|
@@ -202,48 +221,41 @@ def _episode_meta(topic: str, script: str, bed: str) -> str:
|
|
| 202 |
|
| 203 |
|
| 204 |
# ------------------------------------------------------------------- Phase 1 handler
|
| 205 |
-
def run_research(topic, style, duration, num_speakers, progress=gr.Progress()):
|
| 206 |
topic = (topic or "").strip()
|
| 207 |
if not topic:
|
| 208 |
raise gr.Error("Please enter a topic for the podcast.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
progress(0.1, desc="Planning research…")
|
| 210 |
state = generate_script(
|
| 211 |
topic,
|
| 212 |
style=style,
|
| 213 |
duration_min=int(duration),
|
| 214 |
-
num_speakers=
|
|
|
|
| 215 |
)
|
| 216 |
progress(0.9, desc="Finalizing script…")
|
| 217 |
script = state.get("script", "")
|
| 218 |
return script, script, script_to_bubbles(script)
|
| 219 |
|
| 220 |
|
| 221 |
-
# -------------------------------------------------
|
| 222 |
def setup_voices(script_text):
|
| 223 |
lines = parse_script(script_text or "")
|
| 224 |
speakers: List[str] = []
|
| 225 |
for spk, _ in lines:
|
| 226 |
if spk not in speakers:
|
| 227 |
speakers.append(spk)
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
updates = [lines, speakers] # lines_state, speakers_state
|
| 231 |
-
presets = voice_lib.list_presets()
|
| 232 |
-
for i in range(MAX_SPEAKERS):
|
| 233 |
-
if i < len(speakers):
|
| 234 |
-
updates.append(gr.update(visible=True))
|
| 235 |
-
updates.append(gr.update(value=f"### 🎤 Voice for “{speakers[i]}”"))
|
| 236 |
-
default_choice = presets[min(i + 1, len(presets) - 1)] if len(presets) > 1 else presets[0]
|
| 237 |
-
updates.append(gr.update(choices=presets, value=default_choice))
|
| 238 |
-
else:
|
| 239 |
-
updates.append(gr.update(visible=False))
|
| 240 |
-
updates.append(gr.update())
|
| 241 |
-
updates.append(gr.update())
|
| 242 |
-
return updates
|
| 243 |
|
| 244 |
|
| 245 |
# ------------------------------------------------------------------- Phase 2 handler
|
| 246 |
-
def run_tts(lines, speakers, topic, bed, *
|
| 247 |
if not tts_engine.is_available():
|
| 248 |
raise gr.Error(
|
| 249 |
"The TTS stack (fish-speech + GPU) is not available in this environment. "
|
|
@@ -253,20 +265,15 @@ def run_tts(lines, speakers, topic, bed, *voice_inputs, progress=gr.Progress()):
|
|
| 253 |
raise gr.Error("No script is loaded. Generate a script, then review it before "
|
| 254 |
"producing the episode.")
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
texts = list(voice_inputs[2 * MAX_SPEAKERS:3 * MAX_SPEAKERS])
|
| 259 |
-
|
| 260 |
voice_map = {}
|
| 261 |
for i, speaker in enumerate(speakers):
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
voice_map[speaker] = tts_engine.VoiceConfig(
|
| 265 |
-
ref_audio=clone_path, ref_text=(texts[i] or "").strip()
|
| 266 |
-
)
|
| 267 |
else:
|
| 268 |
-
|
| 269 |
-
voice_map[speaker] =
|
| 270 |
|
| 271 |
progress(0.05, desc="Analyzing script & pacing…")
|
| 272 |
sr, audio = tts_engine.generate_podcast(lines, voice_map, progress=progress)
|
|
@@ -589,6 +596,25 @@ def build_ui():
|
|
| 589 |
[b[0] for b in SOUND_BEDS], value="Ambient Drift", label="SOUND BED",
|
| 590 |
)
|
| 591 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
with gr.Row():
|
| 593 |
gr.HTML("<div style='flex:1'></div>")
|
| 594 |
generate_script_btn = gr.Button("⌁ Generate script", elem_classes=["pf-cta"], scale=0)
|
|
@@ -599,7 +625,7 @@ def build_ui():
|
|
| 599 |
gr.HTML(
|
| 600 |
"<div class='pf-step-h'><span class='num'>2</span>"
|
| 601 |
"<span class='t'>Review the script</span>"
|
| 602 |
-
"<span class='h'>Edit any line ·
|
| 603 |
)
|
| 604 |
regenerate_btn = gr.Button("↻ Regenerate", elem_classes=["pf-ghost"], scale=0)
|
| 605 |
|
|
@@ -617,33 +643,6 @@ def build_ui():
|
|
| 617 |
show_label=False, elem_id="pf-bed", container=False,
|
| 618 |
)
|
| 619 |
|
| 620 |
-
gr.HTML("<div class='pf-section-label'>VOICE ASSIGNMENT</div>")
|
| 621 |
-
preset_dropdowns, clone_audios, clone_texts = [], [], []
|
| 622 |
-
speaker_rows, speaker_labels = [], []
|
| 623 |
-
for i in range(MAX_SPEAKERS):
|
| 624 |
-
with gr.Group(visible=False, elem_classes=["pf-card"]) as row:
|
| 625 |
-
speaker_label = gr.Markdown(f"### Speaker {i + 1}")
|
| 626 |
-
with gr.Row():
|
| 627 |
-
preset = gr.Dropdown(
|
| 628 |
-
voice_lib.list_presets(),
|
| 629 |
-
value=voice_lib.list_presets()[0],
|
| 630 |
-
label="Preset voice",
|
| 631 |
-
)
|
| 632 |
-
clone = gr.Audio(
|
| 633 |
-
sources=["upload", "microphone"],
|
| 634 |
-
type="filepath",
|
| 635 |
-
label="…or clone a voice (10–30s reference)",
|
| 636 |
-
)
|
| 637 |
-
ref_text = gr.Textbox(
|
| 638 |
-
label="Reference transcript (optional, improves cloning)",
|
| 639 |
-
lines=1,
|
| 640 |
-
)
|
| 641 |
-
speaker_rows.append(row)
|
| 642 |
-
speaker_labels.append(speaker_label)
|
| 643 |
-
preset_dropdowns.append(preset)
|
| 644 |
-
clone_audios.append(clone)
|
| 645 |
-
clone_texts.append(ref_text)
|
| 646 |
-
|
| 647 |
with gr.Row():
|
| 648 |
back_btn = gr.Button("← Back to topic", elem_classes=["pf-ghost"], scale=0)
|
| 649 |
gr.HTML("<div style='flex:1'></div>")
|
|
@@ -729,10 +728,13 @@ def build_ui():
|
|
| 729 |
# keep the bubble preview in sync with raw-script edits
|
| 730 |
script_box2.change(script_to_bubbles, inputs=[script_box2], outputs=[review_bubbles])
|
| 731 |
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
|
|
|
|
|
|
| 735 |
|
|
|
|
| 736 |
step_views = [step1, step2, step3, step_loading]
|
| 737 |
|
| 738 |
# Step 1 -> loading -> generate -> Step 2
|
|
@@ -742,7 +744,7 @@ def build_ui():
|
|
| 742 |
outputs=step_views + [loading_panel],
|
| 743 |
).then(
|
| 744 |
run_research,
|
| 745 |
-
inputs=[topic, style, duration, num_speakers],
|
| 746 |
outputs=[script_box, script_box2, review_bubbles],
|
| 747 |
).then(
|
| 748 |
setup_voices, inputs=[script_box2], outputs=setup_outputs,
|
|
@@ -759,7 +761,7 @@ def build_ui():
|
|
| 759 |
outputs=step_views + [loading_panel],
|
| 760 |
).then(
|
| 761 |
run_research,
|
| 762 |
-
inputs=[topic, style, duration, num_speakers],
|
| 763 |
outputs=[script_box, script_box2, review_bubbles],
|
| 764 |
).then(
|
| 765 |
setup_voices, inputs=[script_box2], outputs=setup_outputs,
|
|
@@ -774,8 +776,7 @@ def build_ui():
|
|
| 774 |
outputs=step_views + [loading_panel],
|
| 775 |
).then(
|
| 776 |
run_tts,
|
| 777 |
-
inputs=[lines_state, speakers_state, topic, bed]
|
| 778 |
-
+ preset_dropdowns + clone_audios + clone_texts,
|
| 779 |
outputs=[audio_out, file_out, result_meta, result_transcript],
|
| 780 |
).then(lambda: show_step(3), outputs=step_views)
|
| 781 |
|
|
|
|
| 17 |
import gradio as gr
|
| 18 |
|
| 19 |
from research.graph import generate_script, parse_script
|
|
|
|
| 20 |
from tts import engine as tts_engine
|
| 21 |
|
| 22 |
MAX_SPEAKERS = 4
|
|
|
|
| 74 |
return p if os.path.isfile(p) else None
|
| 75 |
|
| 76 |
|
| 77 |
+
# Selectable library voices (those with a bundled sample), by display name.
|
| 78 |
+
_VOICE_NAME_TO_ID = {name: vid for vid, name, *_ in VOICE_LIBRARY if vid}
|
| 79 |
+
LIBRARY_VOICE_NAMES = list(_VOICE_NAME_TO_ID.keys())
|
| 80 |
+
_VOICE_DEFAULTS = ["Nova", "Atlas", "Echo", "Sage"]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _voice_config_for(name):
|
| 84 |
+
"""Resolve a library voice name (e.g. 'Nova') to its cloning reference clip + text."""
|
| 85 |
+
vid = _VOICE_NAME_TO_ID.get(name)
|
| 86 |
+
if not vid:
|
| 87 |
+
return tts_engine.VoiceConfig()
|
| 88 |
+
wav = os.path.join(_SAMPLES_DIR, f"{vid}.wav")
|
| 89 |
+
txt = os.path.join(_SAMPLES_DIR, f"{vid}.txt")
|
| 90 |
+
ref_text = ""
|
| 91 |
+
if os.path.isfile(txt):
|
| 92 |
+
with open(txt, encoding="utf-8") as f:
|
| 93 |
+
ref_text = f.read().strip()
|
| 94 |
+
return tts_engine.VoiceConfig(ref_audio=wav if os.path.isfile(wav) else None, ref_text=ref_text)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
# --------------------------------------------------------------------- HTML builders
|
| 98 |
def _avatar(letter: str, idx: int, size: int = 30) -> str:
|
| 99 |
grad = _AVATAR_GRADIENTS[idx % len(_AVATAR_GRADIENTS)]
|
|
|
|
| 221 |
|
| 222 |
|
| 223 |
# ------------------------------------------------------------------- Phase 1 handler
|
| 224 |
+
def run_research(topic, style, duration, num_speakers, *voice_names, progress=gr.Progress()):
|
| 225 |
topic = (topic or "").strip()
|
| 226 |
if not topic:
|
| 227 |
raise gr.Error("Please enter a topic for the podcast.")
|
| 228 |
+
n = int(num_speakers)
|
| 229 |
+
# Use the picked voice names as the script's speaker names when they're distinct, so the
|
| 230 |
+
# dialogue reads "Nova: …" / "Atlas: …". (Audio mapping is order-based regardless, so a
|
| 231 |
+
# duplicate pick simply falls back to generic Host/Guest labels.)
|
| 232 |
+
names = [voice_names[i] for i in range(n) if i < len(voice_names) and voice_names[i]]
|
| 233 |
+
speaker_names = names if (len(names) == n and len(set(names)) == n) else None
|
| 234 |
progress(0.1, desc="Planning research…")
|
| 235 |
state = generate_script(
|
| 236 |
topic,
|
| 237 |
style=style,
|
| 238 |
duration_min=int(duration),
|
| 239 |
+
num_speakers=n,
|
| 240 |
+
speaker_names=speaker_names,
|
| 241 |
)
|
| 242 |
progress(0.9, desc="Finalizing script…")
|
| 243 |
script = state.get("script", "")
|
| 244 |
return script, script, script_to_bubbles(script)
|
| 245 |
|
| 246 |
|
| 247 |
+
# ------------------------------------------------- parse script into lines + speakers
|
| 248 |
def setup_voices(script_text):
|
| 249 |
lines = parse_script(script_text or "")
|
| 250 |
speakers: List[str] = []
|
| 251 |
for spk, _ in lines:
|
| 252 |
if spk not in speakers:
|
| 253 |
speakers.append(spk)
|
| 254 |
+
return lines, speakers[:MAX_SPEAKERS]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
|
| 257 |
# ------------------------------------------------------------------- Phase 2 handler
|
| 258 |
+
def run_tts(lines, speakers, topic, bed, *voice_names, progress=gr.Progress()):
|
| 259 |
if not tts_engine.is_available():
|
| 260 |
raise gr.Error(
|
| 261 |
"The TTS stack (fish-speech + GPU) is not available in this environment. "
|
|
|
|
| 265 |
raise gr.Error("No script is loaded. Generate a script, then review it before "
|
| 266 |
"producing the episode.")
|
| 267 |
|
| 268 |
+
# Map each speaker to the voice picked on the Create page. Prefer a name match (the
|
| 269 |
+
# script's speaker labels are the picked voice names), else fall back to slot order.
|
|
|
|
|
|
|
| 270 |
voice_map = {}
|
| 271 |
for i, speaker in enumerate(speakers):
|
| 272 |
+
if speaker in _VOICE_NAME_TO_ID:
|
| 273 |
+
voice_map[speaker] = _voice_config_for(speaker)
|
|
|
|
|
|
|
|
|
|
| 274 |
else:
|
| 275 |
+
name = voice_names[i] if i < len(voice_names) else (voice_names[0] if voice_names else None)
|
| 276 |
+
voice_map[speaker] = _voice_config_for(name)
|
| 277 |
|
| 278 |
progress(0.05, desc="Analyzing script & pacing…")
|
| 279 |
sr, audio = tts_engine.generate_podcast(lines, voice_map, progress=progress)
|
|
|
|
| 596 |
[b[0] for b in SOUND_BEDS], value="Ambient Drift", label="SOUND BED",
|
| 597 |
)
|
| 598 |
|
| 599 |
+
# ---- Cast: pick the voice used for each speaker ----
|
| 600 |
+
with gr.Column(elem_classes=["pf-card"]):
|
| 601 |
+
gr.HTML(
|
| 602 |
+
"<div class='pf-step-h'><span class='t'>🎙 Cast</span>"
|
| 603 |
+
"<span class='h'>pick the voice for each speaker — "
|
| 604 |
+
"preview them on the Voices page</span></div>"
|
| 605 |
+
)
|
| 606 |
+
with gr.Row():
|
| 607 |
+
voice_pickers = []
|
| 608 |
+
for i in range(MAX_SPEAKERS):
|
| 609 |
+
default = (_VOICE_DEFAULTS[i] if i < len(_VOICE_DEFAULTS)
|
| 610 |
+
else LIBRARY_VOICE_NAMES[i % len(LIBRARY_VOICE_NAMES)])
|
| 611 |
+
vp = gr.Dropdown(
|
| 612 |
+
LIBRARY_VOICE_NAMES, value=default,
|
| 613 |
+
label=f"Speaker {i + 1}", visible=(i < 2),
|
| 614 |
+
elem_classes=["pf-config-card"],
|
| 615 |
+
)
|
| 616 |
+
voice_pickers.append(vp)
|
| 617 |
+
|
| 618 |
with gr.Row():
|
| 619 |
gr.HTML("<div style='flex:1'></div>")
|
| 620 |
generate_script_btn = gr.Button("⌁ Generate script", elem_classes=["pf-cta"], scale=0)
|
|
|
|
| 625 |
gr.HTML(
|
| 626 |
"<div class='pf-step-h'><span class='num'>2</span>"
|
| 627 |
"<span class='t'>Review the script</span>"
|
| 628 |
+
"<span class='h'>Edit any line · voices are set on the Create step</span></div>"
|
| 629 |
)
|
| 630 |
regenerate_btn = gr.Button("↻ Regenerate", elem_classes=["pf-ghost"], scale=0)
|
| 631 |
|
|
|
|
| 643 |
show_label=False, elem_id="pf-bed", container=False,
|
| 644 |
)
|
| 645 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
with gr.Row():
|
| 647 |
back_btn = gr.Button("← Back to topic", elem_classes=["pf-ghost"], scale=0)
|
| 648 |
gr.HTML("<div style='flex:1'></div>")
|
|
|
|
| 728 |
# keep the bubble preview in sync with raw-script edits
|
| 729 |
script_box2.change(script_to_bubbles, inputs=[script_box2], outputs=[review_bubbles])
|
| 730 |
|
| 731 |
+
# show one voice picker per selected speaker
|
| 732 |
+
num_speakers.change(
|
| 733 |
+
lambda n: [gr.update(visible=i < int(n)) for i in range(MAX_SPEAKERS)],
|
| 734 |
+
inputs=[num_speakers], outputs=voice_pickers,
|
| 735 |
+
)
|
| 736 |
|
| 737 |
+
setup_outputs = [lines_state, speakers_state]
|
| 738 |
step_views = [step1, step2, step3, step_loading]
|
| 739 |
|
| 740 |
# Step 1 -> loading -> generate -> Step 2
|
|
|
|
| 744 |
outputs=step_views + [loading_panel],
|
| 745 |
).then(
|
| 746 |
run_research,
|
| 747 |
+
inputs=[topic, style, duration, num_speakers] + voice_pickers,
|
| 748 |
outputs=[script_box, script_box2, review_bubbles],
|
| 749 |
).then(
|
| 750 |
setup_voices, inputs=[script_box2], outputs=setup_outputs,
|
|
|
|
| 761 |
outputs=step_views + [loading_panel],
|
| 762 |
).then(
|
| 763 |
run_research,
|
| 764 |
+
inputs=[topic, style, duration, num_speakers] + voice_pickers,
|
| 765 |
outputs=[script_box, script_box2, review_bubbles],
|
| 766 |
).then(
|
| 767 |
setup_voices, inputs=[script_box2], outputs=setup_outputs,
|
|
|
|
| 776 |
outputs=step_views + [loading_panel],
|
| 777 |
).then(
|
| 778 |
run_tts,
|
| 779 |
+
inputs=[lines_state, speakers_state, topic, bed] + voice_pickers,
|
|
|
|
| 780 |
outputs=[audio_out, file_out, result_meta, result_transcript],
|
| 781 |
).then(lambda: show_step(3), outputs=step_views)
|
| 782 |
|