Spaces:
Running on Zero
Running on Zero
| """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 glob | |
| import html | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import time | |
| 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 _speaker_count(value, default: int = 2) -> int: | |
| """Normalize transient Gradio slider values such as None or 2.0.""" | |
| try: | |
| n = int(float(value)) | |
| except (TypeError, ValueError): | |
| n = default | |
| return max(1, min(MAX_SPEAKERS, n)) | |
| 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} {html.escape(names)} " | |
| f" · ♫ {html.escape(bed)} · {dur}</div>" | |
| "</div></div>" | |
| ) | |
| # ------------------------------------------------------------------------- library | |
| MAX_LIBRARY = 9 | |
| _HERE = os.path.dirname(os.path.abspath(__file__)) | |
| # Freshly generated audio (for the result-page player) lives in a local, ephemeral dir. | |
| EPISODES_DIR = os.path.join(_HERE, "_episodes") | |
| os.makedirs(EPISODES_DIR, exist_ok=True) | |
| def _resolve_store_dir() -> str: | |
| """Shared, persistent episode store. | |
| On the Space the bucket hf://buckets/build-small-hackathon/podify is mounted | |
| read-write at /data, so saved episodes persist there and are visible to every | |
| visitor. Locally (no mount) we fall back to a repo dir so the app still runs. | |
| """ | |
| base = "/data" if os.path.isdir("/data") else os.path.join(_HERE, "_library") | |
| d = os.path.join(base, "episodes") | |
| try: | |
| os.makedirs(d, exist_ok=True) | |
| return d | |
| except OSError: | |
| d = os.path.join(_HERE, "_library", "episodes") | |
| os.makedirs(d, exist_ok=True) | |
| return d | |
| STORE_DIR = _resolve_store_dir() | |
| print(f"[podify] episode store: {STORE_DIR} " | |
| f"({'shared bucket /data' if STORE_DIR.startswith('/data') else 'LOCAL fallback'})", flush=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 _load_saved_episodes(): | |
| """Read every saved episode (newest first) from the shared store.""" | |
| eps = [] | |
| for jp in glob.glob(os.path.join(STORE_DIR, "*.json")): | |
| try: | |
| with open(jp, encoding="utf-8") as f: | |
| m = json.load(f) | |
| except Exception: | |
| continue | |
| wav = os.path.join(STORE_DIR, f"{m.get('id')}.wav") | |
| if not os.path.isfile(wav): | |
| continue | |
| m["wav_path"] = wav | |
| eps.append(m) | |
| eps.sort(key=lambda e: e.get("created", 0), reverse=True) | |
| return eps | |
| def render_library(): | |
| """Build slot updates from the shared store (visible to every visitor).""" | |
| eps = _load_saved_episodes()[:MAX_LIBRARY] | |
| out = [ | |
| gr.update( | |
| value="<div class='pf-step-h'><span class='t'>▥ Your episodes</span>" | |
| f"<span class='h'>{len(eps)}</span></div>" | |
| ), | |
| gr.update(visible=len(eps) == 0), | |
| ] | |
| for i in range(MAX_LIBRARY): | |
| if i < len(eps): | |
| out += [gr.update(visible=True), | |
| gr.update(value=_episode_card_html(eps[i], i)), | |
| gr.update(value=eps[i]["wav_path"])] | |
| else: | |
| out += [gr.update(visible=False), gr.update(), gr.update()] | |
| return out | |
| def save_episode(ep): | |
| """Persist the episode (audio + metadata) to the shared store/bucket.""" | |
| if not ep: | |
| raise gr.Error("Generate an episode first, then save it.") | |
| src = ep.get("wav_path") | |
| if not src or not os.path.isfile(src): | |
| raise gr.Error("Episode audio not found — try generating again.") | |
| eid = ep.get("id") or uuid.uuid4().hex[:10] | |
| dst_wav = os.path.join(STORE_DIR, f"{eid}.wav") | |
| try: | |
| if not os.path.exists(dst_wav): | |
| shutil.copyfile(src, dst_wav) | |
| meta = {k: ep.get(k) for k in ("id", "title", "voices", "bed", "dur", "meta", "created")} | |
| meta["id"] = eid | |
| with open(os.path.join(STORE_DIR, f"{eid}.json"), "w", encoding="utf-8") as f: | |
| json.dump(meta, f) | |
| except OSError as e: | |
| raise gr.Error(f"Couldn't save to the library store: {e}") | |
| gr.Info("Saved to 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 = _speaker_count(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…") | |
| try: | |
| sr, audio = tts_engine.generate_podcast(tts_lines, voice_map, progress=progress) | |
| except tts_engine.TTSModelAccessError as e: | |
| raise gr.Error(str(e)) from e | |
| # 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) | |
| # Write the wav to the local (ephemeral) dir for the result-page player. It's only | |
| # copied into the shared store/bucket when the user clicks "Save to library". | |
| eid = uuid.uuid4().hex[:10] | |
| wav_file = os.path.join(EPISODES_DIR, f"{eid}.wav") | |
| shutil.copyfile(tts_engine.write_wav(sr, audio), wav_file) | |
| voices = " & ".join(speakers) if speakers else "Podify" | |
| dur = _episode_duration(script) | |
| episode = { | |
| "id": eid, | |
| "title": (topic or "").strip() or "Untitled episode", | |
| "script": script, | |
| "wav_path": wav_file, | |
| "bed": bed, | |
| "dur": dur, | |
| "voices": voices, | |
| "meta": f"{voices} · {bed} · {dur}", | |
| "created": time.time(), | |
| } | |
| 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-card .form { | |
| background:transparent !important; border:none !important; box-shadow:none !important; | |
| padding:0 !important; | |
| } | |
| .pf-card .form > .pf-config-card { | |
| background:rgba(255,255,255,.035) !important; | |
| border-color:rgba(255,255,255,.08) !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-card, .pf-config-card { | |
| --block-background-fill:transparent; | |
| --block-border-color:transparent; | |
| --input-background-fill:rgba(255,255,255,.035); | |
| --input-border-color:rgba(255,255,255,.10); | |
| --input-text-color:var(--pf-text); | |
| --body-text-color:var(--pf-text); | |
| --block-label-text-color:#b9bbcf; | |
| } | |
| #pf-topic, #pf-topic .block, #pf-topic .form, #pf-topic .wrap, | |
| #pf-topic .container, #pf-topic .input-container, | |
| #pf-topic-card .block, #pf-topic-card .form, #pf-topic-card .wrap, | |
| #pf-topic-card .container, #pf-topic-card .input-container { | |
| background:transparent !important; border:none !important; box-shadow:none !important; | |
| } | |
| #pf-topic textarea { background:rgba(255,255,255,.035) !important; | |
| border:1px solid rgba(255,255,255,.10) !important; border-radius:14px !important; | |
| font-size:21px !important; color:var(--pf-text) !important; -webkit-text-fill-color:var(--pf-text) !important; | |
| line-height:1.5 !important; box-shadow:none !important; padding:18px 20px !important; } | |
| #pf-topic textarea::placeholder { color:#c3c5d5 !important; -webkit-text-fill-color:#c3c5d5 !important; opacity:1 !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:#aeb1c6 !important; | |
| -webkit-text-fill-color:#aeb1c6 !important; font-weight:700 !important; } | |
| .pf-config-card .block, .pf-config-card .form, .pf-config-card .gr-box, | |
| .pf-config-card .container, .pf-config-card .wrap { | |
| background:transparent !important; border:none !important; box-shadow:none !important; | |
| } | |
| .pf-config-card .secondary-wrap, .pf-config-card .input-container, | |
| .pf-config-card input, .pf-config-card select, | |
| .pf-config-card [data-testid="dropdown"], .pf-config-card [role="button"] { | |
| background:rgba(255,255,255,.045) !important; | |
| border:1px solid rgba(255,255,255,.10) !important; border-radius:10px !important; | |
| color:var(--pf-text) !important; -webkit-text-fill-color:var(--pf-text) !important; | |
| box-shadow:none !important; | |
| } | |
| .pf-config-card input, .pf-config-card select { min-height:40px !important; padding:7px 10px !important; } | |
| .pf-config-card input::placeholder { color:#c3c5d5 !important; -webkit-text-fill-color:#c3c5d5 !important; opacity:1 !important; } | |
| .pf-config-card .secondary-wrap *, .pf-config-card [data-testid="dropdown"] *, | |
| .pf-config-card [role="button"] * { | |
| color:var(--pf-text) !important; -webkit-text-fill-color:var(--pf-text) !important; | |
| } | |
| .pf-config-card svg { color:#c9b8ff !important; fill:currentColor !important; } | |
| .pf-config-card option { background:var(--pf-panel-2) !important; color:var(--pf-text) !important; } | |
| .pf-config-card .slider input, .pf-config-card input[type="range"] { | |
| background:transparent !important; border:none !important; -webkit-text-fill-color:initial !important; | |
| } | |
| .pf-config-card .input-container input[type="number"], | |
| .pf-config-card input[data-testid="number-input"] { | |
| width:74px !important; min-width:74px !important; max-width:74px !important; | |
| height:40px !important; min-height:40px !important; padding:0 10px !important; | |
| text-align:center !important; background:rgba(255,255,255,.045) !important; | |
| border:1px solid rgba(255,255,255,.10) !important; border-radius:9px !important; | |
| color:var(--pf-text) !important; -webkit-text-fill-color:var(--pf-text) !important; | |
| } | |
| #pf-voices input[data-testid="number-input"] { | |
| width:54px !important; min-width:54px !important; max-width:54px !important; | |
| height:34px !important; min-height:34px !important; line-height:34px !important; | |
| padding:0 !important; box-sizing:border-box !important; text-align:center !important; | |
| font-size:15px !important; font-weight:800 !important; | |
| background:rgba(255,255,255,.055) !important; | |
| border:1px solid rgba(255,255,255,.13) !important; border-radius:9px !important; | |
| } | |
| #pf-voices input[data-testid="number-input"]::-webkit-outer-spin-button, | |
| #pf-voices input[data-testid="number-input"]::-webkit-inner-spin-button { | |
| -webkit-appearance:none !important; margin:0 !important; | |
| } | |
| .pf-config-card .slider, .pf-config-card .slider * { | |
| color:var(--pf-text) !important; -webkit-text-fill-color:var(--pf-text) !important; | |
| } | |
| .gradio-container .options, .gradio-container .options ul, | |
| .gradio-container [role="listbox"] { | |
| background:var(--pf-panel-2) !important; border:1px solid rgba(124,92,255,.34) !important; | |
| color:var(--pf-text) !important; box-shadow:0 18px 46px rgba(0,0,0,.55) !important; | |
| } | |
| .gradio-container .options li, .gradio-container .options .item, | |
| .gradio-container [role="option"] { | |
| background:transparent !important; color:var(--pf-text) !important; | |
| -webkit-text-fill-color:var(--pf-text) !important; | |
| } | |
| .gradio-container .options li:hover, .gradio-container .options .item:hover, | |
| .gradio-container [role="option"]:hover, | |
| .gradio-container [role="option"][aria-selected="true"] { | |
| background:rgba(124,92,255,.18) !important; color:#fff !important; -webkit-text-fill-color:#fff !important; | |
| } | |
| /* dropdowns open downward — keep the popup above the cards that follow it, otherwise | |
| the next card paints over all but the first option. Raise the row's stacking context | |
| (no overflow change, so nothing gets clipped). */ | |
| .pf-config { position: relative; z-index: 30; } | |
| .pf-card:focus-within { position: relative; z-index: 30; } | |
| /* 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} {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([]) | |
| 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 & 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", | |
| filterable=False) | |
| with gr.Column(elem_classes=["pf-config-card"]): | |
| duration = gr.Dropdown( | |
| [("1 min", 1), ("2 min", 2)], | |
| value=2, label="LENGTH", filterable=False, | |
| ) | |
| with gr.Column(elem_classes=["pf-config-card"]): | |
| num_speakers = gr.Slider( | |
| 1, MAX_SPEAKERS, value=2, step=1, label="VOICES", | |
| elem_id="pf-voices", show_reset_button=False, | |
| ) | |
| 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", | |
| filterable=False, | |
| ) | |
| # ---- 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"], filterable=False, | |
| ) | |
| 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, outputs=lib_render_outputs | |
| ) | |
| # populate the shared library for every visitor on page load | |
| demo.load(render_library, 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 | |
| def _voice_picker_visibility(n): | |
| count = _speaker_count(n) | |
| return [gr.update(visible=i < count) for i in range(MAX_SPEAKERS)] | |
| num_speakers.change( | |
| _voice_picker_visibility, | |
| inputs=[num_speakers], outputs=voice_pickers, | |
| ) | |
| # keep the header cast chip in sync with the picked voices (all pages) | |
| def _refresh_headers(n, *names): | |
| count = _speaker_count(n) | |
| sel = [names[i] for i in range(count) 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 shared store, then show the library. | |
| save_btn.click( | |
| save_episode, inputs=[current_episode], outputs=None | |
| ).then( | |
| render_library, 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, STORE_DIR, _SAMPLES_DIR]) | |