jayaspjacob Claude Opus 4.8 (1M context) commited on
Commit ·
165b02d
1
Parent(s): 9756234
Real, saveable episodes in the Library
Browse filesReplace 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>
- .gitignore +3 -0
- 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 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)} · ♥ {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])
|