podify / app.py
jayaspjacob
Mix procedural background music under the podcast
60cefb1
Raw
History Blame
56.5 kB
"""Podify — AI Podcast Studio (Gradio + HuggingFace Spaces).
Phase 1: research agents (LangGraph + HF LLM + DuckDuckGo) write a script.
Phase 2: Fish Audio (OpenAudio S1-mini) voices the script, with presets + cloning.
The UI is themed to match the Podify "AI Podcast Studio" design: a dark, gradient
studio shell with a left sidebar (Create / Voices / Library) and a three-step Create
flow — topic → review the script → the finished episode.
"""
from __future__ import annotations
import html
import os
import re
import shutil
import uuid
from typing import List
import gradio as gr
from research.graph import generate_script, parse_script
from tts import engine as tts_engine
from tts import music as music_lib
MAX_SPEAKERS = 4
STYLES = ["interview", "conversational", "storytelling", "educational", "debate", "news"]
# Decorative sound-design beds (the demo TTS engine voices speech only; the bed is a
# label that travels with the episode for flavour, matching the design mockups).
SOUND_BEDS = [
("No music", "Pure voice"),
("Ambient Drift", "Soft, airy pads"),
("Lo-fi Pulse", "Mellow beats"),
("Cinematic Rise", "Epic & swelling"),
("Newsroom Bed", "Tense underscore"),
]
# Avatar gradients keyed by speaker order of appearance.
_AVATAR_GRADIENTS = [
"linear-gradient(135deg,#ff5cce,#b14dff)",
"linear-gradient(135deg,#3ad0ff,#4d7bff)",
"linear-gradient(135deg,#3ddf9f,#2bbf8f)",
"linear-gradient(135deg,#ffb45c,#ff7a59)",
]
SUGGESTIONS = [
"The science of why we dream",
"Could we actually live on Mars?",
"How AI is reshaping music",
"The hidden history of coffee",
"Why do cities feel lonely?",
]
# Voice library shown on the Voices page. Each id maps to a playable sample clip under
# tts/voices/samples/<id>.wav (built by scripts/build_voice_samples.py from CMU ARCTIC).
# id=None is the "clone your own" slot (no bundled sample). Labels reflect the real
# underlying speaker so playback matches the description.
VOICE_LIBRARY = [
("nova", "Nova", "Female · American", 0, ["Warm", "Conversational", "Host"]),
("atlas", "Atlas", "Male · Scottish", 1, ["Deep", "Cinematic", "Narration"]),
("echo", "Echo", "Male · American", 2, ["Crisp", "News", "Authoritative"]),
("sage", "Sage", "Male · Canadian", 2, ["Calm", "Thoughtful", "Smooth"]),
("vivi", "Vivi", "Female · American", 3, ["Energetic", "Upbeat", "Bright"]),
("onyx", "Onyx", "Male · American", 1, ["Smooth", "Late-night", "Radio"]),
("rune", "Rune", "Male · Indian", 2, ["Gravelly", "Documentary", "Rich"]),
(None, "My Voice", "You · Cloned", 3, ["Cloned", "Custom", "Clone to use"]),
]
_SAMPLES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tts", "voices", "samples")
def _sample_path(voice_id):
"""Absolute path to a voice's sample clip, or None if not bundled."""
if not voice_id:
return None
p = os.path.join(_SAMPLES_DIR, f"{voice_id}.wav")
return p if os.path.isfile(p) else None
# Selectable library voices (those with a bundled sample), by display name.
_VOICE_NAME_TO_ID = {name: vid for vid, name, *_ in VOICE_LIBRARY if vid}
_VOICE_AVATAR_IDX = {name: idx for vid, name, meta, idx, chips in VOICE_LIBRARY if vid}
LIBRARY_VOICE_NAMES = list(_VOICE_NAME_TO_ID.keys())
_VOICE_DEFAULTS = ["Nova", "Atlas", "Echo", "Sage"]
def _voice_config_for(name):
"""Resolve a library voice name (e.g. 'Nova') to its cloning reference clip + text."""
vid = _VOICE_NAME_TO_ID.get(name)
if not vid:
return tts_engine.VoiceConfig()
wav = os.path.join(_SAMPLES_DIR, f"{vid}.wav")
txt = os.path.join(_SAMPLES_DIR, f"{vid}.txt")
ref_text = ""
if os.path.isfile(txt):
with open(txt, encoding="utf-8") as f:
ref_text = f.read().strip()
return tts_engine.VoiceConfig(ref_audio=wav if os.path.isfile(wav) else None, ref_text=ref_text)
# --------------------------------------------------------------------------- cues
# OpenAudio emotion/tone cues like "(excited)" / "(chuckling)". The 0.5B S1-mini tends
# to *read* these aloud rather than act on them, so we strip them from the audio text
# and instead show them as styled "direction" tags in the script/transcript.
_CUE_RE = re.compile(r"\(([a-z][a-z \-']{1,28})\)")
def _strip_cues(text: str) -> str:
"""Remove parenthetical emotion cues from text destined for the TTS engine."""
return re.sub(r"\s{2,}", " ", _CUE_RE.sub("", text)).strip()
def _cues_to_chips(escaped_text: str) -> str:
"""Wrap cues (in already-HTML-escaped text) as small styled tags for display."""
return _CUE_RE.sub(lambda m: f"<span class='pf-cue'>{m.group(1)}</span>", escaped_text)
# --------------------------------------------------------------------- HTML builders
def _avatar(letter: str, idx: int, size: int = 30) -> str:
grad = _AVATAR_GRADIENTS[idx % len(_AVATAR_GRADIENTS)]
fs = max(11, size // 2 - 1)
return (
f"<span class='pf-avatar' style='background:{grad};width:{size}px;height:{size}px;"
f"font-size:{fs}px'>{html.escape(letter[:1].upper())}</span>"
)
def _speaker_index_map(script: str) -> dict:
order: List[str] = []
for spk, _ in parse_script(script or ""):
if spk not in order:
order.append(spk)
return {spk: i for i, spk in enumerate(order)}
def script_to_bubbles(script: str) -> str:
"""Render the review-step chat bubbles from a `Speaker: text` script."""
lines = parse_script(script or "")
if not lines:
return (
"<div class='pf-empty'>Your script will appear here as editable lines once "
"Podify finishes writing it.</div>"
)
idx = _speaker_index_map(script)
roles = {}
bubbles = []
for spk, txt in lines:
i = idx.get(spk, 0)
# First distinct speaker reads as HOST, the rest as GUEST.
role = "HOST" if i == 0 else "GUEST"
roles.setdefault(spk, role)
bubbles.append(
"<div class='pf-bubble'>"
f"<div class='pf-bubble-side'>{_avatar(spk, i)}"
f"<div class='pf-bubble-meta'><span class='pf-name'>{html.escape(spk)}</span>"
f"<span class='pf-role'>{roles[spk]} ▾</span></div></div>"
f"<div class='pf-bubble-text'>{_cues_to_chips(html.escape(txt))}</div>"
"</div>"
)
return "<div class='pf-bubbles'>" + "".join(bubbles) + "</div>"
def script_to_transcript(script: str) -> str:
"""Render the finished-episode transcript with running timestamps."""
lines = parse_script(script or "")
if not lines:
return "<div class='pf-empty'>No transcript available.</div>"
idx = _speaker_index_map(script)
rows = []
elapsed = 0.0
for n, (spk, txt) in enumerate(lines):
ts = f"{int(elapsed // 60)}:{int(elapsed % 60):02d}"
# ~2.6 words/sec speaking rate for a plausible running clock.
elapsed += max(3.0, len(txt.split()) / 2.6)
active = " pf-tr-active" if n == 0 else ""
rows.append(
f"<div class='pf-tr-row{active}'>"
f"<span class='pf-ts'>{ts}</span>"
f"{_avatar(spk, idx.get(spk, 0), 26)}"
f"<span class='pf-tr-text'>{_cues_to_chips(html.escape(txt))}</span>"
"</div>"
)
return "<div class='pf-transcript'>" + "".join(rows) + "</div>"
def loading_html(topic: str, mode: str = "script") -> str:
"""Full-panel 'producing' loader matching the studio mockup."""
if mode == "script":
title = "Writing your script"
steps = [
("Planning the research", "done"),
("Searching the web", "done"),
("Outlining the episode", "done"),
("Writing the script", "active"),
]
else:
title = "Producing your episode"
steps = [
("Analyzing script & pacing", "done"),
("Synthesizing voices", "done"),
("Mixing the sound bed", "done"),
("Mastering audio", "active"),
]
eyebrow = "GENERATING · " + html.escape((topic or "your episode").strip().upper())
items = "".join(
f"<div class='pf-lstep {state}'><span class='pf-ldot'>"
f"{'✓' if state == 'done' else ''}</span>{html.escape(label)}</div>"
for label, state in steps
)
return (
"<div class='pf-loading'>"
"<div class='pf-orb'></div>"
f"<div class='pf-eyebrow'>{eyebrow}</div>"
f"<h2>{title}</h2>"
f"<div class='pf-load-steps'>{items}</div>"
"</div>"
)
def _episode_meta(topic: str, script: str, bed: str) -> str:
speakers = list(_speaker_index_map(script).keys())[:MAX_SPEAKERS]
chips = "".join(
_avatar(s, i, 22) for i, s in enumerate(speakers)
)
names = " & ".join(speakers) if speakers else "Podify voices"
lines = parse_script(script or "")
total = 0.0
for _, txt in lines:
total += max(3.0, len(txt.split()) / 2.6)
dur = f"{int(total // 60)}:{int(total % 60):02d}"
title = html.escape(topic.strip() or "Untitled episode")
return (
"<div class='pf-player-head'>"
"<div class='pf-player-art'>🎙️</div>"
"<div class='pf-player-info'>"
"<div class='pf-eyebrow'>EPISODE READY · MP3 · 48KHZ</div>"
f"<div class='pf-player-title'>{title}</div>"
f"<div class='pf-player-sub'>{chips}&nbsp; {html.escape(names)} "
f"&nbsp;·&nbsp; ♫ {html.escape(bed)} &nbsp;·&nbsp; {dur}</div>"
"</div></div>"
)
# ------------------------------------------------------------------------- library
MAX_LIBRARY = 9
EPISODES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_episodes")
os.makedirs(EPISODES_DIR, exist_ok=True)
_CARD_GRADIENTS = [
"linear-gradient(135deg,#4d8dff,#7c5cff)",
"linear-gradient(135deg,#b14dff,#ff5cce)",
"linear-gradient(135deg,#3ad0ff,#4d7bff)",
"linear-gradient(135deg,#ffb45c,#ff7a59)",
"linear-gradient(135deg,#c9a3ff,#a78bfa)",
"linear-gradient(135deg,#3ddf9f,#2bbf8f)",
]
def _episode_duration(script: str) -> str:
total = 0.0
for _, txt in parse_script(script or ""):
total += max(3.0, len(txt.split()) / 2.6)
return f"{int(total // 60)}:{int(total % 60):02d}"
def _episode_card_html(ep: dict, idx: int) -> str:
import random
grad = _CARD_GRADIENTS[idx % len(_CARD_GRADIENTS)]
rnd = random.Random(hash(ep.get("title", "")) & 0xFFFF)
bars = "".join(f"<i style='height:{rnd.randint(20, 90)}%'></i>" for _ in range(34))
return (
f"<div class='pf-ep-art' style='background:{grad}'>"
f"<div class='pf-ep-wave'>{bars}</div>"
f"<span class='pf-ep-dur'>{html.escape(ep.get('dur', ''))}</span></div>"
"<div class='pf-ep-body'>"
f"<div class='pf-ep-title'>{html.escape(ep.get('title', 'Untitled episode'))}</div>"
f"<div class='pf-ep-meta'>🎙 {html.escape(ep.get('meta', ''))}</div>"
"</div>"
)
def render_library(lib):
"""Build updates for the fixed pool of library slots from the saved-episode list."""
lib = lib or []
out = [
gr.update(
value="<div class='pf-step-h'><span class='t'>▥ Your episodes</span>"
f"<span class='h'>{len(lib)}</span></div>"
),
gr.update(visible=len(lib) == 0),
]
for i in range(MAX_LIBRARY):
if i < len(lib):
out += [gr.update(visible=True),
gr.update(value=_episode_card_html(lib[i], i)),
gr.update(value=lib[i]["wav_path"])]
else:
out += [gr.update(visible=False), gr.update(), gr.update()]
return out
def save_episode(ep, lib):
if not ep:
raise gr.Error("Generate an episode first, then save it.")
lib = list(lib or [])
if any(e.get("wav_path") == ep.get("wav_path") for e in lib):
gr.Info("This episode is already in your library.")
return lib
gr.Info("Saved to library.")
return ([ep] + lib)[:MAX_LIBRARY]
# ------------------------------------------------------------------- Phase 1 handler
def run_research(topic, style, duration, num_speakers, *voice_names, progress=gr.Progress()):
topic = (topic or "").strip()
if not topic:
raise gr.Error("Please enter a topic for the podcast.")
n = int(num_speakers)
# Use the picked voice names as the script's speaker names when they're distinct, so the
# dialogue reads "Nova: …" / "Atlas: …". (Audio mapping is order-based regardless, so a
# duplicate pick simply falls back to generic Host/Guest labels.)
names = [voice_names[i] for i in range(n) if i < len(voice_names) and voice_names[i]]
speaker_names = names if (len(names) == n and len(set(names)) == n) else None
progress(0.1, desc="Planning research…")
state = generate_script(
topic,
style=style,
duration_min=int(duration),
num_speakers=n,
speaker_names=speaker_names,
)
progress(0.9, desc="Finalizing script…")
script = state.get("script", "")
return script, script, script_to_bubbles(script)
# ------------------------------------------------- parse script into lines + speakers
def setup_voices(script_text):
lines = parse_script(script_text or "")
speakers: List[str] = []
for spk, _ in lines:
if spk not in speakers:
speakers.append(spk)
return lines, speakers[:MAX_SPEAKERS]
# ------------------------------------------------------------------- Phase 2 handler
def run_tts(lines, speakers, topic, bed, *voice_names, progress=gr.Progress()):
if not tts_engine.is_available():
raise gr.Error(
"The TTS stack (fish-speech + GPU) is not available in this environment. "
"Deploy to a ZeroGPU Space to generate audio."
)
if not lines:
raise gr.Error("No script is loaded. Generate a script, then review it before "
"producing the episode.")
# Map each speaker to the voice picked on the Create page. Prefer a name match (the
# script's speaker labels are the picked voice names), else fall back to slot order.
voice_map = {}
for i, speaker in enumerate(speakers):
if speaker in _VOICE_NAME_TO_ID:
voice_map[speaker] = _voice_config_for(speaker)
else:
name = voice_names[i] if i < len(voice_names) else (voice_names[0] if voice_names else None)
voice_map[speaker] = _voice_config_for(name)
# Strip emotion cues from the spoken text (S1-mini would read them aloud).
tts_lines = [(spk, _strip_cues(txt)) for spk, txt in lines]
progress(0.05, desc="Analyzing script & pacing…")
sr, audio = tts_engine.generate_podcast(tts_lines, voice_map, progress=progress)
# Mix the selected background-music bed under the voices (no-op for "No music").
progress(0.95, desc="Mixing the sound bed…")
audio = music_lib.mix(audio, bed, sr)
# Rebuild a script string from the (possibly edited) lines for the transcript.
script = "\n".join(f"{spk}: {txt}" for spk, txt in lines)
# Persist the wav under EPISODES_DIR so it survives for library playback.
wav_file = os.path.join(EPISODES_DIR, f"episode_{uuid.uuid4().hex[:8]}.wav")
shutil.copyfile(tts_engine.write_wav(sr, audio), wav_file)
voices = " & ".join(speakers) if speakers else "Podify"
dur = _episode_duration(script)
episode = {
"title": (topic or "").strip() or "Untitled episode",
"script": script,
"wav_path": wav_file,
"bed": bed,
"dur": dur,
"meta": f"{voices} · {bed} · {dur}",
}
return (
(sr, audio),
wav_file,
_episode_meta(topic, script, bed),
script_to_transcript(script),
episode,
)
# ----------------------------------------------------------------------------- views
def _show(create=False, voices=False, library=False):
return (
gr.update(visible=create),
gr.update(visible=voices),
gr.update(visible=library),
gr.update(elem_classes=["pf-nav"] + (["active"] if create else [])),
gr.update(elem_classes=["pf-nav"] + (["active"] if voices else [])),
gr.update(elem_classes=["pf-nav"] + (["active"] if library else [])),
)
def goto_create():
return _show(create=True)
def goto_voices():
return _show(voices=True)
def goto_library():
return _show(library=True)
def show_step(step): # step in {1, 2, 3, "loading"}
return (
gr.update(visible=step == 1),
gr.update(visible=step == 2),
gr.update(visible=step == 3),
gr.update(visible=step == "loading"),
)
def enter_loading_script(topic):
# Validate before swapping to the loader so an empty topic keeps step 1 visible.
if not (topic or "").strip():
raise gr.Error("Please enter a topic for the podcast.")
return (*show_step("loading"), loading_html(topic, "script"))
def enter_loading_podcast(topic):
return (*show_step("loading"), loading_html(topic, "podcast"))
# -------------------------------------------------------------------------------- CSS
CUSTOM_CSS = """
:root, .dark {
--pf-bg:#08080c; --pf-panel:#101017; --pf-panel-2:#15151f; --pf-line:rgba(255,255,255,.07);
--pf-text:#e9e9f2; --pf-mut:#7d7d8e; --pf-faint:#5a5a6b;
--pf-grad:linear-gradient(135deg,#7c5cff 0%,#4d8dff 100%);
}
.gradio-container, .gradio-container.dark { background:var(--pf-bg) !important; max-width:100% !important;
padding:0 !important; color:var(--pf-text); font-family:Inter,system-ui,sans-serif; }
footer { display:none !important; }
.gradio-container * { --button-secondary-text-color:var(--pf-text); }
/* ---------- shell ---------- */
#pf-shell { gap:0 !important; min-height:100vh; flex-wrap:nowrap !important; }
#pf-sidebar { background:#0a0a10; border-right:1px solid var(--pf-line); min-width:248px !important;
max-width:248px !important; flex:0 0 248px !important; padding:22px 16px !important; gap:6px !important; }
#pf-main { padding:26px 34px !important; gap:0 !important; min-width:0 !important; }
/* brand */
.pf-brand { display:flex; align-items:center; gap:11px; padding:4px 6px 14px; }
.pf-brand .logo { width:42px;height:42px;border-radius:12px;background:var(--pf-grad);
display:flex;align-items:center;justify-content:center;font-size:21px;box-shadow:0 6px 18px rgba(124,92,255,.4); }
.pf-brand .name { font-size:20px;font-weight:800;
background:linear-gradient(90deg,#b9a3ff,#7fb6ff);-webkit-background-clip:text;background-clip:text;color:transparent; }
.pf-brand .tag { font-size:9.5px;letter-spacing:2px;color:var(--pf-faint);font-weight:600; }
/* new episode button */
#pf-newep { background:var(--pf-grad) !important; border:none !important; color:#fff !important;
font-weight:700 !important; border-radius:13px !important; box-shadow:0 8px 22px rgba(124,92,255,.35) !important;
margin:4px 0 16px !important; }
.pf-section-label { font-size:10px;letter-spacing:2.5px;color:var(--pf-faint);font-weight:700;
padding:6px 8px 4px; }
/* nav buttons */
.pf-nav { background:transparent !important; border:1px solid transparent !important; color:var(--pf-mut) !important;
text-align:left !important; justify-content:flex-start !important; font-weight:600 !important;
border-radius:11px !important; padding:11px 13px !important; box-shadow:none !important; min-height:0 !important; }
.pf-nav:hover { background:rgba(255,255,255,.04) !important; color:var(--pf-text) !important; }
.pf-nav.active { background:rgba(124,92,255,.12) !important; border-color:rgba(124,92,255,.35) !important;
color:#fff !important; }
/* ---------- header ---------- */
.pf-header { display:flex;align-items:flex-start;justify-content:space-between;
border-bottom:1px solid var(--pf-line); padding-bottom:18px; margin-bottom:22px; }
.pf-header h1 { font-size:25px;font-weight:800;margin:0;color:#fff; }
.pf-header .sub { color:var(--pf-mut);font-size:13px;margin-top:3px; }
.pf-status { display:flex;gap:12px;align-items:center; }
.pf-pill { display:inline-flex;align-items:center;gap:7px;background:var(--pf-panel-2);
border:1px solid var(--pf-line);border-radius:999px;padding:7px 13px;font-size:12px;color:var(--pf-mut); }
.pf-dot { width:7px;height:7px;border-radius:50%;background:#34d399;box-shadow:0 0 8px #34d399; }
.pf-voicepill { display:inline-flex;align-items:center;gap:8px;background:var(--pf-panel-2);
border:1px solid var(--pf-line);border-radius:999px;padding:5px 14px 5px 6px;font-size:12.5px;color:var(--pf-text); }
.pf-avatar { display:inline-flex;align-items:center;justify-content:center;border-radius:50%;
color:#fff;font-weight:700;vertical-align:middle;flex:0 0 auto; }
.pf-voicepill .pf-avatar + .pf-avatar { margin-left:-9px; }
/* ---------- cards / panels ---------- */
.pf-card { background:var(--pf-panel) !important; border:1px solid var(--pf-line) !important;
border-radius:18px !important; padding:20px 22px !important; }
.pf-eyebrow { font-size:10.5px;letter-spacing:2px;color:var(--pf-faint);font-weight:700;
display:flex;align-items:center;gap:7px;margin-bottom:8px; }
.pf-step-h { display:flex;align-items:center;gap:11px;margin:4px 0 16px; }
.pf-step-h .num { width:24px;height:24px;border-radius:7px;background:var(--pf-grad);color:#fff;
display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:800; }
.pf-step-h .t { font-size:18px;font-weight:800;color:#fff; }
.pf-step-h .h { font-size:12.5px;color:var(--pf-mut);font-weight:500; }
/* topic textbox */
#pf-topic textarea { background:transparent !important; border:none !important; font-size:21px !important;
color:var(--pf-text) !important; line-height:1.5 !important; box-shadow:none !important; }
#pf-topic-card { background:var(--pf-panel) !important; border:1px solid var(--pf-line) !important;
border-radius:18px !important; padding:20px 22px 22px !important; }
/* suggestion chips */
#pf-suggest { gap:9px !important; }
#pf-suggest button { background:var(--pf-panel-2) !important; border:1px solid var(--pf-line) !important;
color:var(--pf-mut) !important; border-radius:999px !important; font-weight:500 !important;
font-size:12.5px !important; padding:8px 15px !important; min-width:0 !important; flex:0 0 auto !important;
box-shadow:none !important; }
#pf-suggest button:hover { color:#fff !important; border-color:rgba(124,92,255,.5) !important; }
/* config cards row */
.pf-config .wrap, .pf-config { background:transparent !important; }
.pf-config-card { background:var(--pf-panel) !important; border:1px solid var(--pf-line) !important;
border-radius:15px !important; padding:14px 16px !important; }
.pf-config-card label span { font-size:10px !important; letter-spacing:1.5px !important;
text-transform:uppercase !important; color:var(--pf-faint) !important; font-weight:700 !important; }
.pf-config-card .wrap, .pf-config-card input, .pf-config-card select,
.pf-config-card .secondary-wrap { background:transparent !important; border:none !important; color:#fff !important; }
.pf-config-card .gr-box, .pf-config-card .container { border:none !important; background:transparent !important; }
/* anchor the dropdown options popup to its input wrapper so it opens in place
(otherwise the absolutely-positioned list jumps to the page top-left) */
.pf-config-card .wrap { position: relative !important; }
.pf-config-card { position: relative; z-index: 1; }
.pf-config-card:focus-within { z-index: 50; }
/* primary CTA buttons */
.pf-cta { background:var(--pf-grad) !important; border:none !important; color:#fff !important;
font-weight:700 !important; border-radius:13px !important; padding:13px 26px !important;
box-shadow:0 10px 30px rgba(124,92,255,.45) !important; }
.pf-ghost { background:var(--pf-panel-2) !important; border:1px solid var(--pf-line) !important;
color:var(--pf-text) !important; font-weight:600 !important; border-radius:12px !important; box-shadow:none !important; }
/* ---------- review bubbles ---------- */
.pf-bubbles { display:flex;flex-direction:column;gap:11px; }
.pf-bubble { display:flex;gap:16px;background:var(--pf-panel);border:1px solid var(--pf-line);
border-radius:15px;padding:15px 18px;align-items:flex-start; }
.pf-bubble-side { display:flex;align-items:center;gap:10px;min-width:120px; }
.pf-bubble-meta { display:flex;flex-direction:column;gap:1px; }
.pf-name { font-weight:700;font-size:13.5px;color:#fff; }
.pf-role { font-size:9.5px;letter-spacing:1.5px;color:var(--pf-faint);font-weight:600; }
.pf-bubble-text { color:#d6d6e2;font-size:14px;line-height:1.55;flex:1; }
/* emotion/tone direction tags */
.pf-cue { display:inline-block;font-size:10.5px;font-weight:600;letter-spacing:.3px;
color:#c9b8ff;background:rgba(124,92,255,.16);border:1px solid rgba(124,92,255,.3);
border-radius:6px;padding:1px 7px;margin:0 4px 0 0;vertical-align:middle;
text-transform:lowercase; }
.pf-empty { color:var(--pf-mut);background:var(--pf-panel);border:1px dashed var(--pf-line);
border-radius:14px;padding:26px;text-align:center;font-size:13.5px; }
/* sound design radio as chips */
#pf-bed .wrap { gap:10px !important; flex-wrap:wrap !important; background:transparent !important; }
#pf-bed label { background:var(--pf-panel-2) !important; border:1px solid var(--pf-line) !important;
border-radius:13px !important; padding:13px 16px !important; color:var(--pf-mut) !important;
min-width:120px !important; font-weight:600 !important; }
#pf-bed label.selected, #pf-bed input:checked + label, #pf-bed label:has(input:checked) {
border-color:rgba(124,92,255,.6) !important; color:#fff !important; box-shadow:0 0 0 1px rgba(124,92,255,.4) inset !important; }
/* ---------- result / transcript ---------- */
.pf-player-head { display:flex;gap:16px;align-items:center;margin-bottom:6px; }
.pf-player-art { width:62px;height:62px;border-radius:15px;background:var(--pf-grad);
display:flex;align-items:center;justify-content:center;font-size:27px;
box-shadow:0 10px 26px rgba(124,92,255,.4);flex:0 0 auto; }
.pf-player-title { font-size:22px;font-weight:800;color:#fff;margin:2px 0; }
.pf-player-sub { color:var(--pf-mut);font-size:13px; }
.pf-transcript { display:flex;flex-direction:column;gap:4px;margin-top:6px; }
.pf-tr-row { display:flex;gap:14px;align-items:flex-start;padding:13px 14px;border-radius:12px; }
.pf-tr-row:hover { background:rgba(255,255,255,.02); }
.pf-tr-active { background:rgba(124,92,255,.08);border:1px solid rgba(124,92,255,.25); }
.pf-ts { color:var(--pf-faint);font-size:11.5px;font-family:ui-monospace,monospace;min-width:34px;padding-top:5px; }
.pf-tr-text { color:#d6d6e2;font-size:14px;line-height:1.55; }
.pf-eyebrow-row { display:flex;align-items:center;justify-content:space-between;margin-bottom:16px; }
/* voices/library page tiles */
.pf-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:16px; }
.pf-tile { background:var(--pf-panel);border:1px solid var(--pf-line);border-radius:16px;padding:16px; }
.pf-tile .vh { display:flex;align-items:center;gap:11px;margin-bottom:12px; }
.pf-tile .vn { font-weight:700;color:#fff;font-size:14.5px; }
.pf-tile .vm { font-size:10.5px;letter-spacing:1px;color:var(--pf-faint);text-transform:uppercase; }
.pf-wave { height:34px;border-radius:8px;margin:10px 0;
background:repeating-linear-gradient(90deg,rgba(124,92,255,.5) 0 2px,transparent 2px 5px);opacity:.55; }
/* compact, on-theme audio player inside a voice tile */
.pf-voice-audio { margin:10px 0 !important; }
.pf-voice-audio .audio-container, .pf-voice-audio .component-wrapper { background:transparent !important; }
.pf-voice-audio audio { width:100% !important; height:36px !important; }
.pf-voice-audio button { color:var(--pf-text) !important; }
.pf-clone-slot { height:36px;margin:10px 0;border:1px dashed var(--pf-line);border-radius:8px;
display:flex;align-items:center;justify-content:center;color:var(--pf-faint);font-size:11.5px;
text-align:center;padding:0 8px; }
.pf-chips { display:flex;gap:6px;flex-wrap:wrap; }
.pf-chiplet { font-size:11px;color:var(--pf-mut);background:var(--pf-panel-2);
border:1px solid var(--pf-line);border-radius:7px;padding:3px 9px; }
.pf-ep-card { border-radius:16px;overflow:hidden;border:1px solid var(--pf-line);background:var(--pf-panel); }
.pf-ep-art { height:130px;display:flex;align-items:flex-end;justify-content:flex-end;padding:10px;
position:relative; }
.pf-ep-wave { position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:3px; }
.pf-ep-wave i { width:4px;background:rgba(255,255,255,.7);border-radius:2px; }
.pf-ep-dur { background:rgba(0,0,0,.55);color:#fff;font-size:11px;padding:2px 8px;border-radius:7px;
font-family:ui-monospace,monospace;position:relative; }
.pf-ep-body { padding:14px 16px; }
.pf-ep-title { font-weight:700;color:#fff;font-size:14.5px; }
.pf-ep-meta { color:var(--pf-faint);font-size:11.5px;margin-top:3px; }
/* ---------- loading / producing screen ---------- */
@keyframes pf-spin { to { transform:rotate(360deg); } }
@keyframes pf-glow { 0%,100%{opacity:.75;transform:scale(.96);} 50%{opacity:1;transform:scale(1.04);} }
@keyframes pf-rise { from{opacity:0;transform:translateY(6px);} to{opacity:1;transform:none;} }
.pf-loading { background:var(--pf-panel);border:1px solid var(--pf-line);border-radius:20px;
padding:54px 40px;text-align:center;max-width:660px;margin:14px auto;
box-shadow:0 30px 80px rgba(0,0,0,.45); animation:pf-rise .4s ease; }
.pf-orb { width:96px;height:96px;margin:0 auto 24px;position:relative; }
.pf-orb::before { content:'';position:absolute;inset:0;border-radius:50%;border:3px solid transparent;
border-top-color:#9d7bff;border-right-color:#4d8dff;animation:pf-spin 1.1s linear infinite; }
.pf-orb::after { content:'';position:absolute;inset:24px;border-radius:50%;background:var(--pf-grad);
box-shadow:0 0 34px rgba(124,92,255,.65);animation:pf-glow 1.8s ease-in-out infinite; }
.pf-loading .pf-eyebrow { justify-content:center;margin-bottom:6px; }
.pf-loading h2 { font-size:23px;font-weight:800;color:#fff;margin:2px 0 28px; }
.pf-load-steps { display:inline-flex;flex-direction:column;gap:11px;text-align:left; }
.pf-lstep { display:flex;align-items:center;gap:13px;font-size:14.5px;color:var(--pf-mut); }
.pf-lstep.done { color:#d6d6e2; }
.pf-lstep.active { color:#fff; }
.pf-ldot { width:22px;height:22px;border-radius:50%;flex:0 0 auto;display:flex;
align-items:center;justify-content:center;font-size:12px;color:#fff;font-weight:800; }
.pf-lstep.done .pf-ldot { background:var(--pf-grad); }
.pf-lstep.active .pf-ldot { border:2px solid rgba(124,92,255,.3);border-top-color:#9d7bff;
animation:pf-spin .8s linear infinite; }
/* ===================== rich animations & polish ===================== */
@keyframes pf-aurora { to { transform: rotate(360deg); } }
@keyframes pf-float { 0%,100%{ transform:translateY(0); } 50%{ transform:translateY(-5px); } }
@keyframes pf-rise2 { from{ opacity:0; transform:translateY(16px) scale(.985); } to{ opacity:1; transform:none; } }
@keyframes pf-fade { from{ opacity:0; } to{ opacity:1; } }
@keyframes pf-ctaPulse { 0%,100%{ box-shadow:0 10px 30px rgba(124,92,255,.45); }
50%{ box-shadow:0 16px 50px rgba(124,92,255,.85); } }
@keyframes pf-dotPulse { 0%{ box-shadow:0 0 0 0 rgba(52,211,153,.55); }
70%{ box-shadow:0 0 0 7px rgba(52,211,153,0); } 100%{ box-shadow:0 0 0 0 rgba(52,211,153,0); } }
@keyframes pf-shimmer { to { background-position:200% center; } }
@keyframes pf-eq { 0%,100%{ transform:scaleY(.45); } 50%{ transform:scaleY(1); } }
/* moving aurora behind the whole studio */
.gradio-container { overflow-x:hidden; }
.gradio-container::before {
content:''; position:fixed; inset:-25%; z-index:0; pointer-events:none; opacity:.65;
background:
radial-gradient(38% 38% at 20% 25%, rgba(124,92,255,.20), transparent 60%),
radial-gradient(34% 34% at 82% 18%, rgba(77,141,255,.16), transparent 60%),
radial-gradient(44% 44% at 72% 82%, rgba(255,92,206,.13), transparent 60%),
radial-gradient(40% 40% at 22% 86%, rgba(61,223,159,.11), transparent 60%);
filter: blur(36px); animation: pf-aurora 48s linear infinite;
}
#pf-shell { position:relative; z-index:1; }
/* entrance — replays each time a step/page becomes visible (display toggles).
NOTE: containers holding dropdowns (.pf-config, .pf-card) must NOT animate
`transform` — a transformed ancestor reparents the dropdown's popup and sends it
to the top-left. Those use an opacity-only fade instead. */
#pf-main .pf-header { animation: pf-fade .55s ease both; }
#pf-main #pf-topic-card,
#pf-main .pf-bubble,
#pf-main .pf-tile,
#pf-main .pf-ep-card,
#pf-main .pf-loading { animation: pf-rise2 .5s cubic-bezier(.2,.7,.3,1) both; }
#pf-main .pf-config,
#pf-main .pf-card { animation: pf-fade .5s ease both; }
#pf-main #pf-topic-card { animation-delay:.04s; }
#pf-main .pf-config { animation-delay:.10s; }
#pf-main .pf-bubble:nth-child(1){animation-delay:.04s} #pf-main .pf-bubble:nth-child(2){animation-delay:.10s}
#pf-main .pf-bubble:nth-child(3){animation-delay:.16s} #pf-main .pf-bubble:nth-child(4){animation-delay:.22s}
#pf-main .pf-bubble:nth-child(5){animation-delay:.28s} #pf-main .pf-bubble:nth-child(6){animation-delay:.34s}
#pf-main .pf-tile:nth-child(2){animation-delay:.06s} #pf-main .pf-tile:nth-child(3){animation-delay:.12s}
#pf-main .pf-tile:nth-child(4){animation-delay:.18s}
/* sidebar brand */
.pf-brand .logo { animation: pf-float 4.5s ease-in-out infinite; transition: transform .2s ease; }
.pf-brand:hover .logo { transform: scale(1.06) rotate(-3deg); }
.pf-brand .name { background-image:linear-gradient(90deg,#b9a3ff,#7fb6ff,#ff8fe0,#b9a3ff);
background-size:200% auto; -webkit-background-clip:text; background-clip:text;
animation: pf-shimmer 5s linear infinite; }
/* live status dot */
.pf-dot { animation: pf-dotPulse 2.2s ease-out infinite; }
/* primary CTAs glow + lift */
.pf-cta { animation: pf-ctaPulse 2.8s ease-in-out infinite;
transition: transform .16s cubic-bezier(.2,.7,.3,1), box-shadow .2s ease, filter .2s; }
.pf-cta:hover { transform: translateY(-2px) scale(1.025); filter:brightness(1.06); }
.pf-cta:active { transform: translateY(0) scale(.985); }
#pf-newep { transition: transform .16s ease, box-shadow .25s ease, filter .2s; }
#pf-newep:hover { transform: translateY(-2px); filter:brightness(1.07);
box-shadow:0 14px 34px rgba(124,92,255,.55) !important; }
/* nav */
.pf-nav { transition: background .2s, color .2s, border-color .2s, transform .14s ease; }
.pf-nav:hover { transform: translateX(3px); }
/* topic focus glow */
#pf-topic-card { transition: border-color .25s ease, box-shadow .3s ease; }
#pf-topic-card:focus-within { border-color: rgba(124,92,255,.55) !important;
box-shadow:0 0 0 3px rgba(124,92,255,.16), 0 18px 50px rgba(124,92,255,.14) !important; }
/* suggestion chips */
#pf-suggest button { transition: transform .18s ease, box-shadow .2s ease, color .2s, border-color .2s; }
#pf-suggest button:hover { transform: translateY(-2px); box-shadow:0 8px 20px rgba(124,92,255,.28); }
/* voice tiles / episode cards — hover lift (no dropdowns inside, transform is safe) */
.pf-tile, .pf-ep-card {
transition: transform .22s cubic-bezier(.2,.7,.3,1), border-color .22s, box-shadow .28s ease; }
.pf-tile:hover { transform: translateY(-3px);
border-color: rgba(124,92,255,.32) !important; box-shadow:0 16px 40px rgba(0,0,0,.45); }
.pf-ep-card:hover { transform: translateY(-4px); box-shadow:0 22px 54px rgba(0,0,0,.55); }
/* config cards hold dropdowns — glow only, NO transform (would misplace the popup) */
.pf-config-card { transition: border-color .22s, box-shadow .28s ease; }
.pf-config-card:hover { border-color: rgba(124,92,255,.32) !important;
box-shadow:0 12px 34px rgba(0,0,0,.4); }
.pf-ep-card:hover .pf-ep-art { filter: saturate(1.18) brightness(1.06); }
.pf-ep-art { transition: filter .25s ease; }
/* review bubbles hover */
.pf-bubble { transition: border-color .2s, transform .16s ease, box-shadow .22s; }
.pf-bubble:hover { border-color: rgba(124,92,255,.32); transform: translateX(3px);
box-shadow:0 8px 26px rgba(0,0,0,.32); }
/* avatars */
.pf-avatar { transition: transform .2s cubic-bezier(.2,.7,.3,1); }
.pf-voicepill:hover .pf-avatar, .pf-bubble:hover .pf-avatar, .pf-tile:hover .pf-avatar {
transform: scale(1.1); }
/* ghost buttons */
.pf-ghost { transition: transform .16s ease, border-color .2s, color .2s, background .2s; }
.pf-ghost:hover { transform: translateY(-1px); color:#fff !important;
border-color: rgba(124,92,255,.45) !important; }
/* library card waveform = live equalizer */
.pf-ep-wave i { transform-origin:bottom; animation: pf-eq 1.1s ease-in-out infinite; }
.pf-ep-wave i:nth-child(2n){ animation-delay:.15s; } .pf-ep-wave i:nth-child(3n){ animation-delay:.32s; }
.pf-ep-wave i:nth-child(5n){ animation-delay:.5s; } .pf-ep-wave i:nth-child(7n){ animation-delay:.7s; }
.pf-ep-card:hover .pf-ep-wave i { animation-duration:.7s; }
/* sound-design chips + transcript rows */
#pf-bed label { transition: border-color .2s, color .2s, box-shadow .2s, transform .14s; }
#pf-bed label:hover { transform: translateY(-2px); }
.pf-tr-row { transition: background .18s ease; }
/* respect reduced-motion */
@media (prefers-reduced-motion: reduce) {
.gradio-container::before, .pf-brand .logo, .pf-brand .name, .pf-dot,
.pf-cta, .pf-ep-wave i { animation: none !important; }
#pf-main * { animation-duration:.001s !important; }
}
"""
LOGO_HTML = """
<div class='pf-brand'>
<div class='logo'>🎙️</div>
<div><div class='name'>Podify</div><div class='tag'>AI PODCAST STUDIO</div></div>
</div>
"""
def _voice_chip(voices=None) -> str:
names = [n for n in (voices or []) if n] or ["Nova", "Atlas"]
names = names[:MAX_SPEAKERS]
avatars = "".join(_avatar(n, _VOICE_AVATAR_IDX.get(n, i), 24) for i, n in enumerate(names))
return f"<span class='pf-voicepill'>{avatars}&nbsp; {html.escape(' × '.join(names))}</span>"
def _header(title: str, sub: str, voices=None) -> str:
return f"""
<div class='pf-header'>
<div><h1>{title}</h1><div class='sub'>{sub}</div></div>
<div class='pf-status'>
<span class='pf-pill'><span class='pf-dot'></span> All systems live</span>
{_voice_chip(voices)}
</div>
</div>
"""
# --------------------------------------------------------------------------- UI build
def build_ui():
theme = gr.themes.Base(
primary_hue="purple",
neutral_hue="slate",
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
)
with gr.Blocks(title="Podify — AI Podcast Studio", theme=theme, css=CUSTOM_CSS) as demo:
lines_state = gr.State([])
speakers_state = gr.State([])
library_state = gr.State([])
current_episode = gr.State(None)
with gr.Row(elem_id="pf-shell"):
# ---------------------------------------------------------- sidebar
with gr.Column(elem_id="pf-sidebar"):
gr.HTML(LOGO_HTML)
new_ep_btn = gr.Button("+ New episode", elem_id="pf-newep")
gr.HTML("<div class='pf-section-label'>STUDIO</div>")
nav_create = gr.Button("⌁ Create", elem_classes=["pf-nav", "active"])
nav_voices = gr.Button("🎙 Voices 8", elem_classes=["pf-nav"])
nav_library = gr.Button("▥ Library 6", elem_classes=["pf-nav"])
# ---------------------------------------------------------- main
with gr.Column(elem_id="pf-main"):
# ===================== CREATE PAGE =====================
with gr.Column(visible=True) as page_create:
create_header = gr.HTML(
_header("Create", "Type a topic — Podify writes &amp; voices it",
voices=_VOICE_DEFAULTS[:2])
)
# ---------- STEP 1: topic ----------
with gr.Column(visible=True) as step1:
with gr.Column(elem_id="pf-topic-card"):
gr.HTML("<div class='pf-eyebrow'>✦ WHAT SHOULD THIS EPISODE BE ABOUT?</div>")
topic = gr.Textbox(
elem_id="pf-topic",
placeholder="Describe a topic, paste an article, or drop an idea — Podify writes the script…",
lines=3,
show_label=False,
container=False,
)
with gr.Row(elem_id="pf-suggest"):
suggestion_btns = [gr.Button(s, size="sm") for s in SUGGESTIONS]
with gr.Row(elem_classes=["pf-config"], equal_height=True):
with gr.Column(elem_classes=["pf-config-card"]):
style = gr.Dropdown(STYLES, value="interview", label="FORMAT")
with gr.Column(elem_classes=["pf-config-card"]):
duration = gr.Dropdown(
[("1 min", 1), ("2 min", 2)],
value=2, label="LENGTH",
)
with gr.Column(elem_classes=["pf-config-card"]):
num_speakers = gr.Slider(1, MAX_SPEAKERS, value=2, step=1, label="VOICES")
with gr.Column(elem_classes=["pf-config-card"]):
bed_step1 = gr.Dropdown(
[b[0] for b in SOUND_BEDS], value="Ambient Drift", label="SOUND BED",
)
# ---- Cast: pick the voice used for each speaker ----
with gr.Column(elem_classes=["pf-card"]):
gr.HTML(
"<div class='pf-step-h'><span class='t'>🎙 Cast</span>"
"<span class='h'>pick the voice for each speaker — "
"preview them on the Voices page</span></div>"
)
with gr.Row():
voice_pickers = []
for i in range(MAX_SPEAKERS):
default = (_VOICE_DEFAULTS[i] if i < len(_VOICE_DEFAULTS)
else LIBRARY_VOICE_NAMES[i % len(LIBRARY_VOICE_NAMES)])
vp = gr.Dropdown(
LIBRARY_VOICE_NAMES, value=default,
label=f"Speaker {i + 1}", visible=(i < 2),
elem_classes=["pf-config-card"],
)
voice_pickers.append(vp)
with gr.Row():
gr.HTML("<div style='flex:1'></div>")
generate_script_btn = gr.Button("⌁ Generate script", elem_classes=["pf-cta"], scale=0)
# ---------- STEP 2: review ----------
with gr.Column(visible=False) as step2:
with gr.Row(elem_classes=["pf-eyebrow-row"]):
gr.HTML(
"<div class='pf-step-h'><span class='num'>2</span>"
"<span class='t'>Review the script</span>"
"<span class='h'>Edit any line · voices are set on the Create step</span></div>"
)
regenerate_btn = gr.Button("↻ Regenerate", elem_classes=["pf-ghost"], scale=0)
review_bubbles = gr.HTML(script_to_bubbles(""))
with gr.Accordion("✎ Edit raw script", open=False):
script_box2 = gr.Textbox(
label="Script (use `Speaker: text` per line)", lines=12,
)
with gr.Column(elem_classes=["pf-card"]):
gr.HTML("<div class='pf-step-h'><span class='t'>🎛 Sound design</span></div>")
bed = gr.Radio(
[b[0] for b in SOUND_BEDS], value="Ambient Drift",
show_label=False, elem_id="pf-bed", container=False,
)
with gr.Row():
back_btn = gr.Button("← Back to topic", elem_classes=["pf-ghost"], scale=0)
gr.HTML("<div style='flex:1'></div>")
generate_btn = gr.Button("⚡ Generate podcast", elem_classes=["pf-cta"], scale=0)
# ---------- STEP 3: result ----------
with gr.Column(visible=False) as step3:
with gr.Row(elem_classes=["pf-eyebrow-row"]):
gr.HTML("<span class='pf-pill'><span class='pf-dot'></span> Episode generated</span>")
save_btn = gr.Button("⭳ Save to library", elem_classes=["pf-ghost"], scale=0)
new_ep_btn2 = gr.Button("+ New episode", elem_classes=["pf-ghost"], scale=0)
with gr.Column(elem_classes=["pf-card"]):
result_meta = gr.HTML(_episode_meta("", "", "Ambient Drift"))
audio_out = gr.Audio(label="Episode audio", type="numpy")
file_out = gr.File(label="Export MP3 / WAV")
gr.HTML("<div class='pf-section-label'>TRANSCRIPT</div>")
result_transcript = gr.HTML(script_to_transcript(""))
# ---------- LOADING (script + podcast production) ----------
with gr.Column(visible=False) as step_loading:
loading_panel = gr.HTML(loading_html("", "script"))
# hidden mirror of the canonical script (kept in sync, used for reset)
script_box = gr.Textbox(visible=False)
# ===================== VOICES PAGE =====================
with gr.Column(visible=False) as page_voices:
voices_header = gr.HTML(
_header("Voices", "Browse the library or clone your own",
voices=_VOICE_DEFAULTS[:2])
)
with gr.Row():
with gr.Column(scale=2, elem_classes=["pf-card"]):
gr.HTML("<div class='pf-eyebrow'>VOICE LAB</div>"
"<div class='pf-step-h'><span class='t'>⧉ Clone a voice</span></div>"
"<div class='sub' style='color:var(--pf-mut);font-size:13px'>"
"Upload 30 seconds of clean audio. Podify learns the timbre, "
"cadence and accent, then lets you narrate anything in that voice.</div>")
gr.Audio(sources=["upload", "microphone"], type="filepath",
label="Drop an audio sample (WAV, MP3 or M4A · 30s+)")
with gr.Column(scale=1, elem_classes=["pf-card"]):
gr.HTML("<div class='pf-eyebrow'>PRO TIP</div>"
"<div class='pf-step-h'><span class='t'>Make hosts feel human</span></div>"
"<div style='color:var(--pf-mut);font-size:13px;line-height:1.6'>"
"Pair a warm host with a contrasting guest for natural back-and-forth. "
"Add a subtle ambient bed so silences breathe.</div>")
gr.HTML(
"<div class='pf-eyebrow-row' style='margin-top:26px'>"
"<div class='pf-step-h'><span class='t'>🎙 Voice library</span>"
f"<span class='h'>{len(VOICE_LIBRARY)} voices</span></div></div>"
)
for _r in range(0, len(VOICE_LIBRARY), 4):
with gr.Row(equal_height=True):
for vid, vname, vmeta, vidx, vchips in VOICE_LIBRARY[_r:_r + 4]:
with gr.Column(elem_classes=["pf-tile"], min_width=210):
gr.HTML(_voice_card_header(vname, vmeta, vidx))
_sp = _sample_path(vid)
if _sp:
gr.Audio(
value=_sp,
show_label=False,
interactive=False,
container=False,
elem_classes=["pf-voice-audio"],
)
else:
gr.HTML(
"<div class='pf-clone-slot'>Clone a voice above "
"to preview it here</div>"
)
gr.HTML(_voice_card_chips(vchips))
# ===================== LIBRARY PAGE =====================
with gr.Column(visible=False) as page_library:
library_header = gr.HTML(
_header("Library", "Every episode you've produced",
voices=_VOICE_DEFAULTS[:2])
)
lib_header = gr.HTML(
"<div class='pf-step-h'><span class='t'>▥ Your episodes</span>"
"<span class='h'>0</span></div>"
)
lib_empty = gr.HTML(
"<div class='pf-empty'>No episodes yet — generate one, then tap "
"“Save to library” on the result screen.</div>"
)
lib_cards, lib_headers, lib_audios = [], [], []
for _r in range(0, MAX_LIBRARY, 3):
with gr.Row():
for _i in range(_r, min(_r + 3, MAX_LIBRARY)):
with gr.Column(elem_classes=["pf-ep-card"], visible=False,
min_width=240) as _col:
_h = gr.HTML()
_a = gr.Audio(show_label=False, interactive=False,
container=False, elem_classes=["pf-voice-audio"])
lib_cards.append(_col)
lib_headers.append(_h)
lib_audios.append(_a)
# ------------------------------------------------------------- wiring
nav_outputs = [page_create, page_voices, page_library, nav_create, nav_voices, nav_library]
lib_render_outputs = [lib_header, lib_empty]
for _c, _h, _a in zip(lib_cards, lib_headers, lib_audios):
lib_render_outputs += [_c, _h, _a]
nav_create.click(goto_create, outputs=nav_outputs)
nav_voices.click(goto_voices, outputs=nav_outputs)
nav_library.click(goto_library, outputs=nav_outputs).then(
render_library, inputs=[library_state], outputs=lib_render_outputs
)
for btn in suggestion_btns:
btn.click(lambda s=btn.value: s, outputs=[topic])
# keep the bubble preview in sync with raw-script edits
script_box2.change(script_to_bubbles, inputs=[script_box2], outputs=[review_bubbles])
# show one voice picker per selected speaker
num_speakers.change(
lambda n: [gr.update(visible=i < int(n)) for i in range(MAX_SPEAKERS)],
inputs=[num_speakers], outputs=voice_pickers,
)
# keep the header cast chip in sync with the picked voices (all pages)
def _refresh_headers(n, *names):
sel = [names[i] for i in range(int(n)) if i < len(names) and names[i]]
return (
gr.update(value=_header("Create",
"Type a topic — Podify writes &amp; voices it", sel)),
gr.update(value=_header("Voices",
"Browse the library or clone your own", sel)),
gr.update(value=_header("Library",
"Every episode you've produced", sel)),
)
_header_outputs = [create_header, voices_header, library_header]
num_speakers.change(_refresh_headers, inputs=[num_speakers] + voice_pickers,
outputs=_header_outputs)
for _vp in voice_pickers:
_vp.change(_refresh_headers, inputs=[num_speakers] + voice_pickers,
outputs=_header_outputs)
setup_outputs = [lines_state, speakers_state]
step_views = [step1, step2, step3, step_loading]
# Step 1 -> loading -> generate -> Step 2
generate_script_btn.click(
enter_loading_script,
inputs=[topic],
outputs=step_views + [loading_panel],
).then(
run_research,
inputs=[topic, style, duration, num_speakers] + voice_pickers,
outputs=[script_box, script_box2, review_bubbles],
).then(
setup_voices, inputs=[script_box2], outputs=setup_outputs,
).then(
lambda: show_step(2), outputs=step_views,
)
# carry the step-1 bed selection into the review radio
bed_step1.change(lambda b: gr.update(value=b), inputs=[bed_step1], outputs=[bed])
regenerate_btn.click(
enter_loading_script,
inputs=[topic],
outputs=step_views + [loading_panel],
).then(
run_research,
inputs=[topic, style, duration, num_speakers] + voice_pickers,
outputs=[script_box, script_box2, review_bubbles],
).then(
setup_voices, inputs=[script_box2], outputs=setup_outputs,
).then(lambda: show_step(2), outputs=step_views)
back_btn.click(lambda: show_step(1), outputs=step_views)
# Step 2 -> loading -> generate podcast -> Step 3
generate_btn.click(
enter_loading_podcast,
inputs=[topic],
outputs=step_views + [loading_panel],
).then(
run_tts,
inputs=[lines_state, speakers_state, topic, bed] + voice_pickers,
outputs=[audio_out, file_out, result_meta, result_transcript, current_episode],
).then(lambda: show_step(3), outputs=step_views)
# Save the produced episode to the library, then show it there.
save_btn.click(
save_episode, inputs=[current_episode, library_state], outputs=[library_state]
).then(
render_library, inputs=[library_state], outputs=lib_render_outputs
).then(goto_library, outputs=nav_outputs)
# New episode -> back to a fresh Step 1 on the Create page
def reset_create():
return (*goto_create(), *show_step(1), "")
reset_outputs = nav_outputs + step_views + [topic]
new_ep_btn.click(reset_create, outputs=reset_outputs)
new_ep_btn2.click(reset_create, outputs=reset_outputs)
return demo
# -------------------------------------------------------------- static page builders
def _voice_card_header(name: str, meta: str, idx: int) -> str:
return (
f"<div class='vh'>{_avatar(name, idx, 38)}"
f"<div><div class='vn'>{html.escape(name)}</div>"
f"<div class='vm'>{html.escape(meta)}</div></div></div>"
)
def _voice_card_chips(chips: list) -> str:
row = "".join(f"<span class='pf-chiplet'>{html.escape(c)}</span>" for c in chips)
return f"<div class='pf-chips'>{row}</div>"
if __name__ == "__main__":
build_ui().launch(allowed_paths=[EPISODES_DIR, _SAMPLES_DIR])