"""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/.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"{m.group(1)}", 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"{html.escape(letter[:1].upper())}" ) 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 ( "
Your script will appear here as editable lines once " "Podify finishes writing it.
" ) 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( "
" f"
{_avatar(spk, i)}" f"
{html.escape(spk)}" f"{roles[spk]} ▾
" f"
{_cues_to_chips(html.escape(txt))}
" "
" ) return "
" + "".join(bubbles) + "
" def script_to_transcript(script: str) -> str: """Render the finished-episode transcript with running timestamps.""" lines = parse_script(script or "") if not lines: return "
No transcript available.
" 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"
" f"{ts}" f"{_avatar(spk, idx.get(spk, 0), 26)}" f"{_cues_to_chips(html.escape(txt))}" "
" ) return "
" + "".join(rows) + "
" 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"
" f"{'✓' if state == 'done' else ''}{html.escape(label)}
" for label, state in steps ) return ( "
" "
" f"
{eyebrow}
" f"

{title}

" f"
{items}
" "
" ) 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 ( "
" "
🎙️
" "
" "
EPISODE READY · MP3 · 48KHZ
" f"
{title}
" f"
{chips}  {html.escape(names)} " f" ·  ♫ {html.escape(bed)}  ·  {dur}
" "
" ) # ------------------------------------------------------------------------- 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"" for _ in range(34)) return ( f"
" f"
{bars}
" f"{html.escape(ep.get('dur', ''))}
" "
" f"
{html.escape(ep.get('title', 'Untitled episode'))}
" f"
🎙 {html.escape(ep.get('meta', ''))}
" "
" ) 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="
▥ Your episodes" f"{len(lib)}
" ), 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 = """
Podify
AI PODCAST STUDIO
""" 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"{avatars}  {html.escape(' × '.join(names))}" def _header(title: str, sub: str, voices=None) -> str: return f"""

{title}

{sub}
All systems live {_voice_chip(voices)}
""" # --------------------------------------------------------------------------- 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("
STUDIO
") 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 & 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("
✦ WHAT SHOULD THIS EPISODE BE ABOUT?
") 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( "
🎙 Cast" "pick the voice for each speaker — " "preview them on the Voices page
" ) 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("
") 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( "
2" "Review the script" "Edit any line · voices are set on the Create step
" ) 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("
🎛 Sound design
") 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("
") 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(" Episode generated") 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("
TRANSCRIPT
") 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("
VOICE LAB
" "
⧉ Clone a voice
" "
" "Upload 30 seconds of clean audio. Podify learns the timbre, " "cadence and accent, then lets you narrate anything in that voice.
") 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("
PRO TIP
" "
Make hosts feel human
" "
" "Pair a warm host with a contrasting guest for natural back-and-forth. " "Add a subtle ambient bed so silences breathe.
") gr.HTML( "
" "
🎙 Voice library" f"{len(VOICE_LIBRARY)} voices
" ) 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( "
Clone a voice above " "to preview it here
" ) 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( "
▥ Your episodes" "0
" ) lib_empty = gr.HTML( "
No episodes yet — generate one, then tap " "“Save to library” on the result screen.
" ) 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 & 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"
{_avatar(name, idx, 38)}" f"
{html.escape(name)}
" f"
{html.escape(meta)}
" ) def _voice_card_chips(chips: list) -> str: row = "".join(f"{html.escape(c)}" for c in chips) return f"
{row}
" if __name__ == "__main__": build_ui().launch(allowed_paths=[EPISODES_DIR, _SAMPLES_DIR])