jayaspjacob Claude Opus 4.8 (1M context) commited on
Commit
4c38fd0
·
1 Parent(s): 310ca13

Select podcast voices on the Create page

Browse files

Add 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>

Files changed (1) hide show
  1. app.py +67 -66
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=int(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
- # ------------------------------------------------------- set up per-speaker voice rows
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
- speakers = speakers[:MAX_SPEAKERS]
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, *voice_inputs, progress=gr.Progress()):
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
- presets = list(voice_inputs[0:MAX_SPEAKERS])
257
- clones = list(voice_inputs[MAX_SPEAKERS:2 * MAX_SPEAKERS])
258
- texts = list(voice_inputs[2 * MAX_SPEAKERS:3 * MAX_SPEAKERS])
259
-
260
  voice_map = {}
261
  for i, speaker in enumerate(speakers):
262
- clone_path = clones[i]
263
- if clone_path: # cloning overrides preset
264
- voice_map[speaker] = tts_engine.VoiceConfig(
265
- ref_audio=clone_path, ref_text=(texts[i] or "").strip()
266
- )
267
  else:
268
- v = voice_lib.get_voice(presets[i])
269
- voice_map[speaker] = tts_engine.VoiceConfig(ref_audio=v.ref_audio, ref_text=v.ref_text)
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 · tap a speaker to reassign</span></div>"
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
- setup_outputs = [lines_state, speakers_state]
733
- for i in range(MAX_SPEAKERS):
734
- setup_outputs += [speaker_rows[i], speaker_labels[i], preset_dropdowns[i]]
 
 
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