jayaspjacob Claude Opus 4.8 (1M context) commited on
Commit
165b02d
·
1 Parent(s): 9756234

Real, saveable episodes in the Library

Browse files

Replace the placeholder Library grid with the user's actual generated
episodes. run_tts now persists each episode's wav under _episodes/ and
returns an episode record; a "Save to library" button on the result screen
stores it (in-session) and opens the Library, which renders a fixed pool of
slots with a real audio player per saved episode. Empty-state shown when none.

allowed_paths covers the episodes + samples dirs so the players can serve
the files. _episodes/ is gitignored (runtime-generated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. .gitignore +3 -0
  2. app.py +127 -39
.gitignore CHANGED
@@ -3,6 +3,9 @@
3
  .venv/
4
  *.wav.tmp
5
 
 
 
 
6
  # Claude Code artifacts — never push these to the Space
7
  .claude/
8
  CLAUDE.md
 
3
  .venv/
4
  *.wav.tmp
5
 
6
+ # Runtime-generated episodes (saved to the in-session library)
7
+ _episodes/
8
+
9
  # Claude Code artifacts — never push these to the Space
10
  .claude/
11
  CLAUDE.md
app.py CHANGED
@@ -12,6 +12,8 @@ from __future__ import annotations
12
 
13
  import html
14
  import os
 
 
15
  from typing import List
16
 
17
  import gradio as gr
@@ -220,6 +222,75 @@ def _episode_meta(topic: str, script: str, bed: str) -> str:
220
  )
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()
@@ -280,11 +351,27 @@ def run_tts(lines, speakers, topic, bed, *voice_names, progress=gr.Progress()):
280
 
281
  # Rebuild a script string from the (possibly edited) lines for the transcript.
282
  script = "\n".join(f"{spk}: {txt}" for spk, txt in lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  return (
284
  (sr, audio),
285
- tts_engine.write_wav(sr, audio),
286
  _episode_meta(topic, script, bed),
287
  script_to_transcript(script),
 
288
  )
289
 
290
 
@@ -550,6 +637,8 @@ def build_ui():
550
  with gr.Blocks(title="Podify — AI Podcast Studio", theme=theme, css=CUSTOM_CSS) as demo:
551
  lines_state = gr.State([])
552
  speakers_state = gr.State([])
 
 
553
 
554
  with gr.Row(elem_id="pf-shell"):
555
  # ---------------------------------------------------------- sidebar
@@ -652,6 +741,7 @@ def build_ui():
652
  with gr.Column(visible=False) as step3:
653
  with gr.Row(elem_classes=["pf-eyebrow-row"]):
654
  gr.HTML("<span class='pf-pill'><span class='pf-dot'></span> Episode generated</span>")
 
655
  new_ep_btn2 = gr.Button("+ New episode", elem_classes=["pf-ghost"], scale=0)
656
  with gr.Column(elem_classes=["pf-card"]):
657
  result_meta = gr.HTML(_episode_meta("", "", "Ambient Drift"))
@@ -714,13 +804,38 @@ def build_ui():
714
  # ===================== LIBRARY PAGE =====================
715
  with gr.Column(visible=False) as page_library:
716
  gr.HTML(_header("Library", "Every episode you've produced"))
717
- gr.HTML(_library_html())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
 
719
  # ------------------------------------------------------------- wiring
720
  nav_outputs = [page_create, page_voices, page_library, nav_create, nav_voices, nav_library]
 
 
 
 
721
  nav_create.click(goto_create, outputs=nav_outputs)
722
  nav_voices.click(goto_voices, outputs=nav_outputs)
723
- nav_library.click(goto_library, outputs=nav_outputs)
 
 
724
 
725
  for btn in suggestion_btns:
726
  btn.click(lambda s=btn.value: s, outputs=[topic])
@@ -777,9 +892,16 @@ def build_ui():
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
 
 
 
 
 
 
 
 
783
  # New episode -> back to a fresh Step 1 on the Create page
784
  def reset_create():
785
  return (*goto_create(), *show_step(1), "")
@@ -805,39 +927,5 @@ def _voice_card_chips(chips: list) -> str:
805
  return f"<div class='pf-chips'>{row}</div>"
806
 
807
 
808
- def _library_html() -> str:
809
- import random
810
- eps = [
811
- ("Could we actually live on Mars?", "Space & Science · Today", "12:40", "linear-gradient(135deg,#4d8dff,#7c5cff)", "1.2k"),
812
- ("The science of why we dream", "Psychology · Yesterday", "08:15", "linear-gradient(135deg,#b14dff,#ff5cce)", "840"),
813
- ("How AI is reshaping music", "Technology · 2 days ago", "15:02", "linear-gradient(135deg,#3ad0ff,#4d7bff)", "3.4k"),
814
- ("The hidden history of coffee", "Culture · 4 days ago", "06:48", "linear-gradient(135deg,#ffb45c,#ff7a59)", "560"),
815
- ("Why do cities feel lonely?", "Society · Last week", "10:30", "linear-gradient(135deg,#c9a3ff,#a78bfa)", "2.1k"),
816
- ("Deep ocean: the final frontier", "Nature · Last week", "18:22", "linear-gradient(135deg,#3ddf9f,#2bbf8f)", "4.9k"),
817
- ]
818
- cards = []
819
- rnd = random.Random(7)
820
- for title, meta, dur, grad, likes in eps:
821
- bars = "".join(
822
- f"<i style='height:{rnd.randint(20,90)}%'></i>" for _ in range(34)
823
- )
824
- cards.append(
825
- "<div class='pf-ep-card'>"
826
- f"<div class='pf-ep-art' style='background:{grad}'>"
827
- f"<div class='pf-ep-wave'>{bars}</div>"
828
- f"<span class='pf-ep-dur'>{dur}</span></div>"
829
- "<div class='pf-ep-body'>"
830
- f"<div class='pf-ep-title'>{html.escape(title)}</div>"
831
- f"<div class='pf-ep-meta'>🌐 {html.escape(meta)} &nbsp;·&nbsp; ♥ {likes}</div>"
832
- "</div></div>"
833
- )
834
- return (
835
- "<div class='pf-eyebrow-row'>"
836
- "<div class='pf-step-h'><span class='t'>▥ Your episodes</span>"
837
- "<span class='h'>6</span></div></div>"
838
- "<div class='pf-grid'>" + "".join(cards) + "</div>"
839
- )
840
-
841
-
842
  if __name__ == "__main__":
843
- build_ui().launch()
 
12
 
13
  import html
14
  import os
15
+ import shutil
16
+ import uuid
17
  from typing import List
18
 
19
  import gradio as gr
 
222
  )
223
 
224
 
225
+ # ------------------------------------------------------------------------- library
226
+ MAX_LIBRARY = 9
227
+ EPISODES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_episodes")
228
+ os.makedirs(EPISODES_DIR, exist_ok=True)
229
+ _CARD_GRADIENTS = [
230
+ "linear-gradient(135deg,#4d8dff,#7c5cff)",
231
+ "linear-gradient(135deg,#b14dff,#ff5cce)",
232
+ "linear-gradient(135deg,#3ad0ff,#4d7bff)",
233
+ "linear-gradient(135deg,#ffb45c,#ff7a59)",
234
+ "linear-gradient(135deg,#c9a3ff,#a78bfa)",
235
+ "linear-gradient(135deg,#3ddf9f,#2bbf8f)",
236
+ ]
237
+
238
+
239
+ def _episode_duration(script: str) -> str:
240
+ total = 0.0
241
+ for _, txt in parse_script(script or ""):
242
+ total += max(3.0, len(txt.split()) / 2.6)
243
+ return f"{int(total // 60)}:{int(total % 60):02d}"
244
+
245
+
246
+ def _episode_card_html(ep: dict, idx: int) -> str:
247
+ import random
248
+
249
+ grad = _CARD_GRADIENTS[idx % len(_CARD_GRADIENTS)]
250
+ rnd = random.Random(hash(ep.get("title", "")) & 0xFFFF)
251
+ bars = "".join(f"<i style='height:{rnd.randint(20, 90)}%'></i>" for _ in range(34))
252
+ return (
253
+ f"<div class='pf-ep-art' style='background:{grad}'>"
254
+ f"<div class='pf-ep-wave'>{bars}</div>"
255
+ f"<span class='pf-ep-dur'>{html.escape(ep.get('dur', ''))}</span></div>"
256
+ "<div class='pf-ep-body'>"
257
+ f"<div class='pf-ep-title'>{html.escape(ep.get('title', 'Untitled episode'))}</div>"
258
+ f"<div class='pf-ep-meta'>🎙 {html.escape(ep.get('meta', ''))}</div>"
259
+ "</div>"
260
+ )
261
+
262
+
263
+ def render_library(lib):
264
+ """Build updates for the fixed pool of library slots from the saved-episode list."""
265
+ lib = lib or []
266
+ out = [
267
+ gr.update(
268
+ value="<div class='pf-step-h'><span class='t'>▥ Your episodes</span>"
269
+ f"<span class='h'>{len(lib)}</span></div>"
270
+ ),
271
+ gr.update(visible=len(lib) == 0),
272
+ ]
273
+ for i in range(MAX_LIBRARY):
274
+ if i < len(lib):
275
+ out += [gr.update(visible=True),
276
+ gr.update(value=_episode_card_html(lib[i], i)),
277
+ gr.update(value=lib[i]["wav_path"])]
278
+ else:
279
+ out += [gr.update(visible=False), gr.update(), gr.update()]
280
+ return out
281
+
282
+
283
+ def save_episode(ep, lib):
284
+ if not ep:
285
+ raise gr.Error("Generate an episode first, then save it.")
286
+ lib = list(lib or [])
287
+ if any(e.get("wav_path") == ep.get("wav_path") for e in lib):
288
+ gr.Info("This episode is already in your library.")
289
+ return lib
290
+ gr.Info("Saved to library.")
291
+ return ([ep] + lib)[:MAX_LIBRARY]
292
+
293
+
294
  # ------------------------------------------------------------------- Phase 1 handler
295
  def run_research(topic, style, duration, num_speakers, *voice_names, progress=gr.Progress()):
296
  topic = (topic or "").strip()
 
351
 
352
  # Rebuild a script string from the (possibly edited) lines for the transcript.
353
  script = "\n".join(f"{spk}: {txt}" for spk, txt in lines)
354
+
355
+ # Persist the wav under EPISODES_DIR so it survives for library playback.
356
+ wav_file = os.path.join(EPISODES_DIR, f"episode_{uuid.uuid4().hex[:8]}.wav")
357
+ shutil.copyfile(tts_engine.write_wav(sr, audio), wav_file)
358
+
359
+ voices = " & ".join(speakers) if speakers else "Podify"
360
+ dur = _episode_duration(script)
361
+ episode = {
362
+ "title": (topic or "").strip() or "Untitled episode",
363
+ "script": script,
364
+ "wav_path": wav_file,
365
+ "bed": bed,
366
+ "dur": dur,
367
+ "meta": f"{voices} · {bed} · {dur}",
368
+ }
369
  return (
370
  (sr, audio),
371
+ wav_file,
372
  _episode_meta(topic, script, bed),
373
  script_to_transcript(script),
374
+ episode,
375
  )
376
 
377
 
 
637
  with gr.Blocks(title="Podify — AI Podcast Studio", theme=theme, css=CUSTOM_CSS) as demo:
638
  lines_state = gr.State([])
639
  speakers_state = gr.State([])
640
+ library_state = gr.State([])
641
+ current_episode = gr.State(None)
642
 
643
  with gr.Row(elem_id="pf-shell"):
644
  # ---------------------------------------------------------- sidebar
 
741
  with gr.Column(visible=False) as step3:
742
  with gr.Row(elem_classes=["pf-eyebrow-row"]):
743
  gr.HTML("<span class='pf-pill'><span class='pf-dot'></span> Episode generated</span>")
744
+ save_btn = gr.Button("⭳ Save to library", elem_classes=["pf-ghost"], scale=0)
745
  new_ep_btn2 = gr.Button("+ New episode", elem_classes=["pf-ghost"], scale=0)
746
  with gr.Column(elem_classes=["pf-card"]):
747
  result_meta = gr.HTML(_episode_meta("", "", "Ambient Drift"))
 
804
  # ===================== LIBRARY PAGE =====================
805
  with gr.Column(visible=False) as page_library:
806
  gr.HTML(_header("Library", "Every episode you've produced"))
807
+ lib_header = gr.HTML(
808
+ "<div class='pf-step-h'><span class='t'>▥ Your episodes</span>"
809
+ "<span class='h'>0</span></div>"
810
+ )
811
+ lib_empty = gr.HTML(
812
+ "<div class='pf-empty'>No episodes yet — generate one, then tap "
813
+ "“Save to library” on the result screen.</div>"
814
+ )
815
+ lib_cards, lib_headers, lib_audios = [], [], []
816
+ for _r in range(0, MAX_LIBRARY, 3):
817
+ with gr.Row():
818
+ for _i in range(_r, min(_r + 3, MAX_LIBRARY)):
819
+ with gr.Column(elem_classes=["pf-ep-card"], visible=False,
820
+ min_width=240) as _col:
821
+ _h = gr.HTML()
822
+ _a = gr.Audio(show_label=False, interactive=False,
823
+ container=False, elem_classes=["pf-voice-audio"])
824
+ lib_cards.append(_col)
825
+ lib_headers.append(_h)
826
+ lib_audios.append(_a)
827
 
828
  # ------------------------------------------------------------- wiring
829
  nav_outputs = [page_create, page_voices, page_library, nav_create, nav_voices, nav_library]
830
+ lib_render_outputs = [lib_header, lib_empty]
831
+ for _c, _h, _a in zip(lib_cards, lib_headers, lib_audios):
832
+ lib_render_outputs += [_c, _h, _a]
833
+
834
  nav_create.click(goto_create, outputs=nav_outputs)
835
  nav_voices.click(goto_voices, outputs=nav_outputs)
836
+ nav_library.click(goto_library, outputs=nav_outputs).then(
837
+ render_library, inputs=[library_state], outputs=lib_render_outputs
838
+ )
839
 
840
  for btn in suggestion_btns:
841
  btn.click(lambda s=btn.value: s, outputs=[topic])
 
892
  ).then(
893
  run_tts,
894
  inputs=[lines_state, speakers_state, topic, bed] + voice_pickers,
895
+ outputs=[audio_out, file_out, result_meta, result_transcript, current_episode],
896
  ).then(lambda: show_step(3), outputs=step_views)
897
 
898
+ # Save the produced episode to the library, then show it there.
899
+ save_btn.click(
900
+ save_episode, inputs=[current_episode, library_state], outputs=[library_state]
901
+ ).then(
902
+ render_library, inputs=[library_state], outputs=lib_render_outputs
903
+ ).then(goto_library, outputs=nav_outputs)
904
+
905
  # New episode -> back to a fresh Step 1 on the Create page
906
  def reset_create():
907
  return (*goto_create(), *show_step(1), "")
 
927
  return f"<div class='pf-chips'>{row}</div>"
928
 
929
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
930
  if __name__ == "__main__":
931
+ build_ui().launch(allowed_paths=[EPISODES_DIR, _SAMPLES_DIR])