import asyncio import hashlib from html import escape import json import os from pathlib import Path import tempfile import time from time import sleep from dotenv import load_dotenv # Repo-root .env (HF_TOKEN, etc.). Must run before importing puppet_theater so # load_dotenv is not cwd-dependent (IDEs / `gradio` may use a different cwd). load_dotenv(Path(__file__).resolve().parent / ".env") os.environ["GRADIO_SSR_MODE"] = "False" import gradio as gr from puppet_theater import ( DEFAULT_SHOW_LENGTH, DEFAULT_HF_API_MODEL_ID, DEFAULT_OPENBMB_MODEL_ID, SHOW_LENGTH_PRESETS, TheaterSession, create_show_from_premise, get_backend_status, request_finale, resolve_show_length, run_one_beat, story_phase, story_progress, summon_actor, throw_prop, warm_up_openbmb, ) from puppet_theater.trace import add_trace_event, render_trace_json, render_trace_summary, write_trace_json_file EMPTY_STAGE = """

Tonight

AI Puppet Theater

Enter a premise and raise the curtain.

""" EMPTY_TRANSCRIPT = '
No show yet. The transcript will appear here.
' EMPTY_AGENT_STATE = "
No agents on stage yet.
" EMPTY_DIRECTOR_LOG = "No director notes yet." EMPTY_TRACE = "No trace events yet." EMPTY_TRACE_SUMMARY = "No trace events yet." EMPTY_BACKEND = ( "Actor engine: Deterministic\n" "Active backend: deterministic\n" "Director mode: deterministic\n" "Show length: min=7, target=10, max=12\n" "Available actor engines: Deterministic, Local LoRA Actor model, Local GGUF Actor model, Local OpenBMB, Hugging Face API\n" "Available director modes: deterministic, local OpenBMB, Hugging Face API / LLM\n" "OpenBMB model id: openbmb/MiniCPM5-1B\n" "HF API model id: Qwen/Qwen3-4B-Instruct-2507:nscale\n" "Local LoRA adapter: build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA\n" "Local GGUF model: build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF/minicpm5-actor-q4_k_m.gguf\n" "HF API token configured: false\n" "ZeroGPU enabled: false\n" "spaces available: false\n" "ZeroGPU GPU decorator active: false\n" "Torch version: unknown\n" "CUDA available inside GPU function: not checked\n" "Model status: unloaded\n" "Fallback: deterministic safety path enabled\n" "Note: Deterministic is the hosted-demo default. Local LoRA and GGUF load lazily only when selected." ) BACKEND_CHOICES = [ ("Deterministic", "deterministic"), ("Local LoRA Actor model", "local_lora"), ("Local GGUF Actor model", "local_gguf"), ("Local OpenBMB", "openbmb"), ("Hugging Face API", "hf_api"), ] BACKEND_VALUES = {"deterministic", "local_lora", "local_gguf", "openbmb", "hf_api"} LEGACY_BACKEND_VALUES: set[str] = set() DIRECTOR_MODE_CHOICES = [ ("Deterministic", "deterministic"), ("Local OpenBMB", "openbmb"), ("Hugging Face API / LLM", "hf_api"), ] DIRECTOR_MODE_VALUES = {"deterministic", "hf_api", "openbmb"} OPENBMB_MODEL_ID = os.getenv("OPENBMB_MODEL_ID", DEFAULT_OPENBMB_MODEL_ID) HF_API_MODEL_ID = os.getenv("HF_API_MODEL_ID", DEFAULT_HF_API_MODEL_ID) ACTOR_ENGINE_LABELS = { "deterministic": "Deterministic", "local_lora": "Local LoRA model", "local_gguf": "Local GGUF model", "openbmb": "Local OpenBMB", "hf_api": "Hugging Face API", } DEFAULT_ACTOR_ENGINE = ( os.getenv("ACTOR_MODEL_BACKEND", "deterministic").strip().lower() or "deterministic" ) if DEFAULT_ACTOR_ENGINE not in BACKEND_VALUES: DEFAULT_ACTOR_ENGINE = "deterministic" DEFAULT_MAX_NEW_TOKENS = 120 DEFAULT_TEMPERATURE = 0.75 MIN_PLAYBACK_DELAY_SECONDS = 0.55 MAX_PLAYBACK_DELAY_SECONDS = 1.85 VOICE_MODE_CHOICES = [ ("Off", "off"), ("Browser TTS: Character Voices", "character"), ("Browser TTS: Narrator Only", "narrator"), ("Edge TTS: Character Voices", "edge_character"), ] DEFAULT_VOICE_MODE = "off" EMPTY_TTS_PAYLOAD = "{}" EMPTY_TTS_STATUS = "Voice mode is off." EDGE_TTS_BACKEND = "edge_tts" EDGE_TTS_MAX_CHARS = 500 EDGE_TTS_CACHE_DIR = Path(tempfile.gettempdir()) / "ai-puppet-theater-edge-tts" EDGE_TTS_MAX_FILES = 40 EDGE_TTS_MAX_AGE_SECONDS = 60 * 60 * 6 EDGE_TTS_VOICE_BY_SLOT = [ "en-US-GuyNeural", "en-US-JennyNeural", "en-US-SteffanNeural", "en-US-AriaNeural", ] EDGE_TTS_NARRATOR_VOICE = "en-US-AriaNeural" SHOW_LENGTH_CHOICES = [ ("Short", "short"), ("Standard", "standard"), ("Extended", "extended"), ] SHOW_LENGTH_VALUES = set(SHOW_LENGTH_PRESETS) PROP_EMOJI = { "rubber duck": "๐Ÿค", "duck": "๐Ÿค", "egg": "๐Ÿฅš", "flowers": "๐Ÿ’", "flower": "๐Ÿ’", "tomato": "๐Ÿ…", "crown": "๐Ÿ‘‘", "tiny crown": "๐Ÿ‘‘", "scroll": "๐Ÿ“œ", "banana": "๐ŸŒ", "mirror": "๐Ÿชž", } DEFAULT_STAGE_PROPS: list[str] = [ "rubber duck", "egg", "flowers", "tomato", "tiny crown", "scroll", ] VOICE_STYLE_PRESETS = [ { "label": "Warm Puppet", "pitch": 1.04, "rate": 0.96, "volume": 0.9, "voiceNameHints": ["Google US English", "Samantha", "Jenny", "Aria"], }, { "label": "Tiny Mischief", "pitch": 1.14, "rate": 0.98, "volume": 0.88, "voiceNameHints": ["Google UK English Female", "Samantha", "Jenny", "Aria"], }, { "label": "Grumpy Elder", "pitch": 0.92, "rate": 0.9, "volume": 0.9, "voiceNameHints": ["Daniel", "Alex", "Google UK English Male", "Microsoft Guy"], }, { "label": "Bright Hero", "pitch": 1.02, "rate": 1.02, "volume": 0.9, "voiceNameHints": ["Google US English", "Microsoft David", "Microsoft Mark", "Alex"], }, { "label": "Soft Oracle", "pitch": 0.98, "rate": 0.86, "volume": 0.82, "voiceNameHints": ["Google UK English Female", "Microsoft Zira", "Samantha", "Aria"], }, ] NARRATOR_VOICE_STYLE = { "label": "Narrator", "pitch": 0.96, "rate": 0.88, "volume": 0.82, "voiceNameHints": ["Daniel", "Alex", "Google UK English Male", "Microsoft Guy", "Google US English"], } def _prop_dropdown_label(canonical: str) -> str: emoji = PROP_EMOJI.get(canonical.lower(), "๐ŸŽ") title = " ".join(part.capitalize() for part in canonical.split()) return f"{emoji} {title}" PROP_DROPDOWN_CHOICES: list[tuple[str, str]] = [ (_prop_dropdown_label(name), name) for name in DEFAULT_STAGE_PROPS ] def voice_style_for_actor(session: TheaterSession, speaker_name: str) -> dict[str, object]: for index, actor in enumerate(session.actors): if actor.name == speaker_name: return dict(VOICE_STYLE_PRESETS[index % len(VOICE_STYLE_PRESETS)]) return dict(NARRATOR_VOICE_STYLE) def latest_tts_payload(session: TheaterSession | None) -> str: if session is None or not session.transcript: return EMPTY_TTS_PAYLOAD latest_beat = session.transcript[-1] latest_tool = session.latest_tool_result if latest_tool is not None and latest_beat.tool_request is not None and latest_tool.actor_name == latest_beat.speaker: payload = { "id": f"{session.session_id}:{len(session.transcript)}:tool:{latest_tool.tool_name}", "kind": "tool", "speaker": "Stage Oracle", "text": latest_tool.result, "style": NARRATOR_VOICE_STYLE, "narratorStyle": NARRATOR_VOICE_STYLE, } else: payload = { "id": f"{session.session_id}:{len(session.transcript)}:actor:{latest_beat.speaker}", "kind": "actor", "speaker": latest_beat.speaker, "text": latest_beat.line, "style": voice_style_for_actor(session, latest_beat.speaker), "narratorStyle": NARRATOR_VOICE_STYLE, } return json.dumps(payload, ensure_ascii=True) def latest_tts_data(session: TheaterSession | None) -> dict[str, object] | None: if session is None or not session.transcript: return None try: payload = json.loads(latest_tts_payload(session)) except json.JSONDecodeError: return None return payload if isinstance(payload, dict) else None def _clean_spoken_text(text: str, max_chars: int = EDGE_TTS_MAX_CHARS) -> str: cleaned = " ".join(text.strip().split()) if len(cleaned) <= max_chars: return cleaned truncated = cleaned[:max_chars].rsplit(" ", maxsplit=1)[0].strip() return truncated or cleaned[:max_chars].strip() def _summarize_tts_error(exc: Exception) -> str: summary = " ".join(str(exc).strip().split()) if not summary: summary = exc.__class__.__name__ return summary[:160] def edge_voice_for_latest(session: TheaterSession, payload: dict[str, object]) -> str: if payload.get("kind") == "tool": return EDGE_TTS_NARRATOR_VOICE speaker = str(payload.get("speaker") or "") for index, actor in enumerate(session.actors): if actor.name == speaker: return EDGE_TTS_VOICE_BY_SLOT[index] if index < len(EDGE_TTS_VOICE_BY_SLOT) else EDGE_TTS_VOICE_BY_SLOT[-1] return EDGE_TTS_NARRATOR_VOICE def _edge_tts_cache_path(speaker: str, text: str, voice_name: str) -> Path: digest = hashlib.sha256(f"{speaker}\0{voice_name}\0{text}".encode("utf-8")).hexdigest()[:24] return EDGE_TTS_CACHE_DIR / f"{digest}.mp3" def cleanup_edge_tts_cache() -> None: try: EDGE_TTS_CACHE_DIR.mkdir(parents=True, exist_ok=True) now = time.time() files = sorted( [path for path in EDGE_TTS_CACHE_DIR.glob("*.mp3") if path.is_file()], key=lambda path: path.stat().st_mtime, reverse=True, ) for index, path in enumerate(files): too_many = index >= EDGE_TTS_MAX_FILES too_old = now - path.stat().st_mtime > EDGE_TTS_MAX_AGE_SECONDS if too_many or too_old: path.unlink(missing_ok=True) except OSError: return async def _save_edge_tts_audio(text: str, voice_name: str, output_path: Path, timeout_seconds: float) -> None: import edge_tts # type: ignore[import-not-found] communicate = edge_tts.Communicate(text=text, voice=voice_name) await asyncio.wait_for(communicate.save(str(output_path)), timeout=timeout_seconds) def _run_edge_tts_save(text: str, voice_name: str, output_path: Path, timeout_seconds: float) -> None: try: asyncio.run(_save_edge_tts_audio(text, voice_name, output_path, timeout_seconds)) except RuntimeError: loop = asyncio.new_event_loop() try: loop.run_until_complete(_save_edge_tts_audio(text, voice_name, output_path, timeout_seconds)) finally: loop.close() def generate_edge_tts_audio( session: TheaterSession | None, voice_mode: str | None, ) -> tuple[str | None, str]: if voice_mode != "edge_character": return None, ( "Voice mode is off." if voice_mode == "off" else "Browser TTS is ready." if voice_mode in {"character", "narrator"} else EMPTY_TTS_STATUS ) if session is None: return None, "Edge TTS is ready. Create a show and run a beat to generate audio." payload = latest_tts_data(session) if payload is None: return None, "Edge TTS is ready. Run a beat to generate audio." speaker = str(payload.get("speaker") or "Latest line") text = _clean_spoken_text(str(payload.get("text") or "")) voice_name = edge_voice_for_latest(session, payload) if not text: return None, "Edge TTS has no speakable line yet." add_trace_event( session, "tts_requested", voice_mode=voice_mode, tts_backend=EDGE_TTS_BACKEND, voice_name=voice_name, fallback_used=False, ) try: import edge_tts # noqa: F401 except Exception as exc: error_summary = _summarize_tts_error(exc) add_trace_event( session, "tts_fallback", voice_mode=voice_mode, tts_backend=EDGE_TTS_BACKEND, voice_name=voice_name, fallback_used=True, error_summary=error_summary, ) return None, "Edge TTS is not installed. Use Browser TTS or Off." cleanup_edge_tts_cache() try: EDGE_TTS_CACHE_DIR.mkdir(parents=True, exist_ok=True) audio_path = _edge_tts_cache_path(speaker, text, voice_name) if not audio_path.exists(): timeout_seconds = min(4.5, max(2.5, 1.6 + (len(text) / 120))) _run_edge_tts_save(text, voice_name, audio_path, timeout_seconds) add_trace_event( session, "tts_generated", voice_mode=voice_mode, tts_backend=EDGE_TTS_BACKEND, voice_name=voice_name, fallback_used=False, ) return str(audio_path), f"Edge TTS ready: {voice_name}" except Exception as exc: error_summary = _summarize_tts_error(exc) add_trace_event( session, "tts_fallback", voice_mode=voice_mode, tts_backend=EDGE_TTS_BACKEND, voice_name=voice_name, fallback_used=True, error_summary=error_summary, ) return None, f"Edge TTS failed. Use Browser TTS or Off. ({error_summary})" def playback_delay_for_latest_line(session: TheaterSession, voice_mode: str | None = DEFAULT_VOICE_MODE) -> float: if not session.transcript: return MIN_PLAYBACK_DELAY_SECONDS latest = session.transcript[-1].line word_count = len(latest.split()) if voice_mode == "edge_character": delay = 1.15 + (word_count * 0.34) return max(MIN_PLAYBACK_DELAY_SECONDS, min(6.0, delay)) delay = 0.65 + (word_count * 0.28) return max(MIN_PLAYBACK_DELAY_SECONDS, min(4.25, delay)) CUSTOM_CSS = """ body, .gradio-container { background: radial-gradient(circle at 50% 0%, rgba(127, 29, 29, 0.18), transparent 28rem), linear-gradient(180deg, #0b1020 0%, #070914 100%) !important; color: #f8efe4 !important; } .gradio-container { --theater-burgundy: #3b0a16; --theater-plum: #2a1426; --theater-plum-light: #3d1a35; --theater-gold: #f6c453; --theater-gold-soft: rgba(246, 196, 83, 0.35); --theater-cream: #f8efe4; --theater-amber-top: #fcd34d; --theater-amber-mid: #d97706; --theater-amber-deep: #9a3412; --checkbox-label-padding: 0 !important; box-sizing: border-box !important; width: min(1200px, calc(100vw - 2rem)) !important; max-width: min(1200px, calc(100vw - 2rem)) !important; margin-left: auto !important; margin-right: auto !important; padding: 1rem 1rem 1.5rem !important; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .backstage-stack { align-items: stretch !important; gap: 0.85rem !important; margin-left: auto !important; margin-right: auto !important; margin-top: 0.5rem !important; max-width: 52rem; width: 100% !important; } .backstage-stack > div { min-width: 0 !important; width: 100% !important; } .stage-output.block { border: 1px solid var(--theater-gold-soft) !important; border-radius: 14px !important; box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35) !important; overflow: hidden !important; padding: 0 !important; } .stage-output .label-wrap { background: linear-gradient(180deg, #5c1628 0%, #3b0a16 100%) !important; border-bottom: 2px solid var(--theater-gold) !important; border-radius: 12px 12px 0 0 !important; margin: 0 !important; padding: 0.35rem 0.75rem !important; } .stage-output .label-wrap span, .stage-output label > span { color: var(--theater-gold) !important; font-family: Georgia, "Times New Roman", ui-serif, serif !important; font-size: 0.82rem !important; font-weight: 700 !important; letter-spacing: 0.12em !important; text-transform: uppercase !important; } .stage-output .html-container { border-radius: 0 0 12px 12px !important; } .gradio-container .prose, .gradio-container label, .gradio-container span, .gradio-container p { color: #f8efe4; } .gradio-container textarea, .gradio-container input { background: rgba(10, 12, 23, 0.82) !important; border-color: rgba(246, 196, 83, 0.24) !important; color: #f8efe4 !important; } .gradio-container textarea::placeholder, .gradio-container input::placeholder { color: #9f8c7a !important; } .gradio-container footer { color: rgba(203, 183, 161, 0.62) !important; } .gradio-container .block, .gradio-container .form, .gradio-container .panel, .gradio-container .tabs, .gradio-container .tabitem { background: rgba(34, 17, 31, 0.56) !important; border-color: rgba(246, 196, 83, 0.18) !important; } .gradio-container label, .gradio-container .block-title, .gradio-container .label-wrap { color: #f8efe4 !important; } .gradio-container .block-info, .gradio-container .label-wrap span, .gradio-container label > span { border-radius: 6px !important; color: #ffd166 !important; font-weight: 700 !important; } .gradio-container .accordion .block .label-wrap span { background: transparent !important; border: none !important; border-radius: 0 !important; box-shadow: none !important; color: #cbb7a1 !important; font-weight: 600 !important; letter-spacing: 0.03em !important; text-transform: none !important; } .gradio-container .wrap, .gradio-container .styler, .gradio-container .form, .gradio-container .form > *, .gradio-container .block > div { background-color: transparent !important; } .gradio-container select, .gradio-container [role="listbox"], .gradio-container [role="combobox"] { background: rgba(10, 12, 23, 0.82) !important; border-color: rgba(246, 196, 83, 0.24) !important; color: #f8efe4 !important; } .app-title h1 { color: #fff7ed; font-family: Georgia, "Times New Roman", ui-serif, serif; font-size: 2.15rem; font-weight: 700; letter-spacing: 0.02em; margin-bottom: 0; text-align: center; text-shadow: 0 2px 24px rgba(0, 0, 0, 0.45); } .app-title p { color: #d4c4b4; font-size: 0.95rem; letter-spacing: 0.04em; margin: 0.2rem 0 0.85rem; text-align: center; } .gradio-container h3, .gradio-container h3 span, .gradio-container .prose h3, .gradio-container .prose h3 span { color: #f8efe4 !important; } .premise-panel { background: linear-gradient(145deg, rgba(246, 196, 83, 0.08) 0%, transparent 42%), linear-gradient(180deg, rgba(52, 24, 46, 0.92) 0%, rgba(34, 17, 31, 0.94) 100%); border: 1px solid rgba(246, 196, 83, 0.32) !important; border-radius: 12px !important; box-shadow: 0 0 0 1px rgba(59, 10, 22, 0.55), 0 18px 36px rgba(0, 0, 0, 0.28); box-sizing: border-box !important; overflow: hidden !important; padding: 1rem 1.15rem 1.15rem !important; } .premise-panel .premise-stack, .gradio-container .gr-group.premise-panel .premise-stack { background: transparent !important; border: none !important; box-shadow: none !important; gap: 0.65rem !important; margin: 0 !important; padding: 0 !important; width: 100% !important; } .premise-panel .premise-actions, .gradio-container .gr-group.premise-panel .premise-actions { box-sizing: border-box !important; margin: 0 !important; max-width: 100% !important; padding: 0 !important; width: 100% !important; } .premise-panel .form { gap: 0.55rem !important; padding: 0 !important; } .premise-panel .wrap, .premise-panel .styler, .premise-panel .form, .premise-panel .block > div { background: transparent !important; } .control-panel { background: linear-gradient(160deg, rgba(246, 196, 83, 0.06) 0%, transparent 50%), linear-gradient(180deg, rgba(45, 22, 42, 0.95) 0%, rgba(26, 12, 24, 0.97) 100%); border: 1px solid rgba(246, 196, 83, 0.28) !important; border-radius: 12px !important; box-shadow: 0 0 0 1px rgba(59, 10, 22, 0.45), 0 14px 32px rgba(0, 0, 0, 0.3); padding: 1.15rem 1.2rem 1.25rem !important; } .show-controls-title { padding-left: 8px; padding-right: 8px; padding-top: 6px; } .audience-title { padding-left: 8px; padding-right: 8px; padding-top: 6px; } .control-panel .form { gap: 0.75rem !important; padding: 0 !important; } .control-panel .block, .control-panel .wrap, .control-panel .styler, .control-panel .form, .control-panel .block > div { background: transparent !important; } .control-panel .row { gap: 0.65rem !important; } .control-panel .prose { margin-bottom: 0.65rem !important; } .control-panel .prose h3 { margin-top: 0 !important; } .control-panel .label-wrap span, .control-panel label > span, .premise-panel .label-wrap span, .premise-panel label > span { background: transparent !important; border: none !important; border-radius: 0 !important; box-shadow: none !important; color: #e8ddd4 !important; display: block !important; font-size: 0.78rem !important; font-weight: 600 !important; letter-spacing: 0.04em !important; padding: 0 0 0.28rem 0 !important; text-transform: none !important; } .control-panel .dropdown-container, .premise-panel .dropdown-container { background: transparent !important; border: none !important; box-shadow: none !important; margin-top: 0 !important; padding: 0 !important; } .control-panel .dropdown-container .wrap-inner, .control-panel .dropdown-container .secondary-wrap, .control-panel .single-select, .premise-panel .dropdown-container .wrap-inner, .premise-panel .dropdown-container .secondary-wrap { background: rgba(8, 10, 18, 0.92) !important; border: 1px solid rgba(246, 196, 83, 0.38) !important; border-radius: 10px !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04) !important; } .control-panel .dropdown-container button, .control-panel .dropdown-container .icon, .premise-panel .dropdown-container button { background: transparent !important; border: none !important; box-shadow: none !important; } /* Audience prop picker: clearer label, large click target, gold border, solid option list */ .gradio-container .control-panel .prop-picker .label-wrap span, .gradio-container .control-panel .prop-picker label > span { color: #fcd34d !important; font-family: Georgia, "Times New Roman", ui-serif, serif !important; font-size: 0.82rem !important; font-weight: 700 !important; letter-spacing: 0.07em !important; padding-bottom: 0.35rem !important; text-transform: uppercase !important; } .gradio-container .control-panel .prop-picker .block-info, .gradio-container .control-panel .prop-picker span[data-testid="block-info"] { color: rgba(232, 221, 212, 0.88) !important; font-size: 0.8rem !important; font-weight: 500 !important; line-height: 1.4 !important; margin-top: 0.15rem !important; max-width: 40rem; } .gradio-container .control-panel .prop-picker .single-select input, .gradio-container .control-panel .prop-picker .single-select span { font-size: 1rem !important; letter-spacing: 0.02em !important; } /* Outer rim + padding so the whole control reads as one big control */ .gradio-container .control-panel .prop-picker .dropdown-container { background: rgba(14, 10, 22, 0.94) !important; border: 2px solid rgba(246, 196, 83, 0.62) !important; border-radius: 14px !important; box-shadow: 0 0 0 1px rgba(59, 10, 22, 0.45), 0 8px 26px rgba(0, 0, 0, 0.42) !important; box-sizing: border-box !important; margin-top: 0.1rem !important; padding: 0.35rem !important; } .gradio-container .control-panel .prop-picker .dropdown-container .wrap-inner, .gradio-container .control-panel .prop-picker .dropdown-container .secondary-wrap, .gradio-container .control-panel .prop-picker .single-select { border-radius: 11px !important; min-height: 3.5rem !important; transition: border-color 0.15s ease, box-shadow 0.15s ease !important; } .gradio-container .control-panel .prop-picker .dropdown-container .wrap-inner, .gradio-container .control-panel .prop-picker .dropdown-container .secondary-wrap { align-items: center !important; background: rgba(8, 10, 18, 0.98) !important; border: 2px solid rgba(246, 196, 83, 0.48) !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06) !important; padding: 0.85rem 1.05rem !important; } .gradio-container .control-panel .prop-picker .single-select { min-height: 3.5rem !important; } .gradio-container .control-panel .prop-picker .dropdown-container button { min-height: 2.85rem !important; min-width: 2.85rem !important; } .gradio-container .control-panel .prop-picker .dropdown-container:focus-within { border-color: #f6c453 !important; box-shadow: 0 0 0 2px rgba(246, 196, 83, 0.35), 0 10px 28px rgba(0, 0, 0, 0.48) !important; } .gradio-container .control-panel .prop-picker .dropdown-container:focus-within .wrap-inner, .gradio-container .control-panel .prop-picker .dropdown-container:focus-within .secondary-wrap { border-color: #f6c453 !important; box-shadow: 0 0 0 1px rgba(246, 196, 83, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.07) !important; } /* Closed-state dropdown cue: chevron always visible on the trigger (not the open list) */ .gradio-container .control-panel .prop-picker .dropdown-container, .gradio-container .control-panel .prop-picker .dropdown-container .wrap-inner, .gradio-container .control-panel .prop-picker .dropdown-container .secondary-wrap { overflow: visible !important; } .gradio-container .control-panel .prop-picker .dropdown-container .wrap-inner { position: relative !important; } .gradio-container .control-panel .prop-picker .dropdown-container .wrap-inner::after { border-color: var(--theater-gold) transparent transparent transparent; border-style: solid; border-width: 8px 6px 0 6px; content: ""; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); opacity: 1; pointer-events: none; position: absolute; right: 0.72rem; top: 50%; transform: translateY(-40%); z-index: 50; } .gradio-container .control-panel .prop-picker .dropdown-container button svg { height: 0 !important; opacity: 0 !important; overflow: hidden !important; width: 0 !important; } .control-panel .row, .premise-panel .row, .premise-panel .premise-actions { background: transparent !important; } .control-panel h3 { border-bottom: 1px solid rgba(246, 196, 83, 0.2); color: #fff7ed; font-family: Georgia, "Times New Roman", ui-serif, serif; font-size: 0.92rem; font-weight: 700; letter-spacing: 0.06em; margin: 0 0 0.5rem; padding-bottom: 0.35rem; text-transform: uppercase; } .control-panel .prose, .control-panel .prose h3, .control-panel h3 * { color: #f8efe4 !important; } .puppet-stage { border: 5px solid #3b0a16; border-radius: 14px; background: linear-gradient(90deg, rgba(59, 10, 22, 0.98) 0 10%, transparent 10% 90%, rgba(59, 10, 22, 0.98) 90% 100%), linear-gradient(180deg, rgba(42, 20, 38, 0.96), rgba(13, 6, 14, 0.98)); box-sizing: border-box; color: #f8efe4; display: flex; flex-direction: column; align-items: stretch; justify-content: stretch; height: 600px; max-height: 600px; min-height: 600px; position: relative; overflow: hidden; box-shadow: 0 24px 48px rgba(0, 0, 0, 0.38), inset 0 0 42px rgba(0, 0, 0, 0.58); } .puppet-stage::before, .puppet-stage::after { content: ""; position: absolute; top: 0; bottom: 0; width: 13%; background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0 14px, transparent 14px 28px), linear-gradient(180deg, #8b1e3f 0%, #7f1d1d 54%, #3b0a16 100%); box-shadow: inset -16px 0 28px rgba(0, 0, 0, 0.22); z-index: 2; } .puppet-stage::before { left: 0; } .puppet-stage::after { right: 0; transform: scaleX(-1); } .stage-valance { height: 48px; background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.06) 0 22px, transparent 22px 44px), linear-gradient(180deg, #8b1e3f 0%, #7f1d1d 100%); border-bottom: 4px solid #f6c453; box-shadow: 0 10px 20px rgba(0, 0, 0, 0.34); position: relative; z-index: 3; } .stage-curtains { display: flex; flex-direction: row; inset: 0; overflow: hidden; pointer-events: none; position: absolute; z-index: 15; } .stage-curtain { background: repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.1) 0 2px, transparent 2px 24px), linear-gradient(180deg, #7f1533 0%, #4a0a18 55%, #2d050e 100%); box-shadow: inset 0 0 38px rgba(0, 0, 0, 0.5), inset -5px 0 0 rgba(246, 196, 83, 0.32); flex: 1 1 50%; height: 100%; min-width: 50%; } .stage-curtains-animate .stage-curtain-left { animation: stage-curtain-left-open 1.45s cubic-bezier(0.22, 1, 0.36, 1) 0.1s forwards; transform-origin: left center; } .stage-curtains-animate .stage-curtain-right { animation: stage-curtain-right-open 1.45s cubic-bezier(0.22, 1, 0.36, 1) 0.1s forwards; box-shadow: inset 0 0 38px rgba(0, 0, 0, 0.5), inset 5px 0 0 rgba(246, 196, 83, 0.32); transform-origin: right center; } .stage-curtains-closed .stage-curtain-left, .stage-curtains-closed .stage-curtain-right { animation: none !important; } .stage-curtains-closed .stage-curtain-left { transform: translateX(1.5%); } .stage-curtains-closed .stage-curtain-right { box-shadow: inset 0 0 38px rgba(0, 0, 0, 0.5), inset 5px 0 0 rgba(246, 196, 83, 0.32); transform: translateX(-1.5%); } @keyframes stage-curtain-left-open { from { transform: translateX(0); } to { transform: translateX(-108%); } } @keyframes stage-curtain-right-open { from { transform: translateX(0); } to { transform: translateX(108%); } } @media (prefers-reduced-motion: reduce) { .stage-curtains-animate .stage-curtain-left, .stage-curtains-animate .stage-curtain-right { animation: none !important; opacity: 0; transform: none !important; } } .stage-backdrop { background: radial-gradient(circle at 50% 8%, rgba(255, 224, 150, 0.28), transparent 19rem), radial-gradient(circle at 24% 58%, rgba(255, 224, 150, 0.12), transparent 14rem), linear-gradient(180deg, #2a1426 0%, #22111f 62%, #130911 100%); display: flex; flex: 1 1 0; flex-direction: column; min-height: 0; overflow: hidden; padding: 0.72rem 7.2rem 0.8rem; position: relative; z-index: 1; } .stage-backdrop::after { background: linear-gradient(180deg, transparent 0%, rgba(124, 63, 23, 0.46) 100%); bottom: 0; content: ""; height: 32%; left: 0; position: absolute; right: 0; } .stage-marquee { color: #fff7ed; font-family: Georgia, "Times New Roman", serif; font-size: 1.6rem; font-weight: 700; letter-spacing: 0; text-align: center; text-shadow: 0 4px 18px rgba(0, 0, 0, 0.72); position: relative; z-index: 2; overflow-wrap: anywhere; } .stage-copy { max-width: 54rem; color: #cbb7a1; font-size: 0.84rem; line-height: 1.35; margin: 0.25rem auto 0; text-align: center; position: relative; z-index: 2; } .stage-copy strong { color: #f8efe4; } .stage-empty-playbill { border: 2px solid rgba(246, 196, 83, 0.55); border-radius: 4px; box-shadow: 0 0 0 1px rgba(59, 10, 22, 0.9), 0 0 48px rgba(255, 209, 102, 0.22), inset 0 0 36px rgba(0, 0, 0, 0.35); left: 50%; max-width: min(22rem, 88%); padding: 1.35rem 1.5rem 1.45rem; position: absolute; text-align: center; top: 50%; transform: translate(-50%, -50%); z-index: 22; background: radial-gradient(ellipse 120% 80% at 50% 20%, rgba(255, 224, 160, 0.14), transparent 55%), linear-gradient(180deg, rgba(42, 20, 38, 0.92) 0%, rgba(19, 9, 17, 0.94) 100%); } .playbill-kicker { color: #ffd166; font-family: Georgia, "Times New Roman", serif; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.38em; margin: 0 0 0.35rem; text-transform: uppercase; } .playbill-title { color: #fff7ed; font-family: Georgia, "Times New Roman", serif; font-size: clamp(1.35rem, 3.2vw, 1.85rem); font-weight: 700; letter-spacing: 0.04em; line-height: 1.15; margin: 0; text-shadow: 0 3px 22px rgba(0, 0, 0, 0.75); } .playbill-ornament { color: rgba(246, 196, 83, 0.75); font-size: 0.82rem; letter-spacing: 0.12em; margin: 0.45rem 0 0.35rem; } .playbill-tagline { color: #e8d5c4; font-size: 0.95rem; line-height: 1.45; margin: 0; } .puppet-stage.stage-empty .stage-backdrop::before { background: radial-gradient(ellipse 70% 55% at 50% 42%, rgba(255, 240, 200, 0.12), transparent 62%); content: ""; inset: 0; pointer-events: none; position: absolute; z-index: 1; } .stage-floorboards { height: 58px; background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0 2px, transparent 2px 72px), linear-gradient(180deg, #8a4b22 0%, #7c3f17 100%); border-top: 2px solid rgba(246, 196, 83, 0.28); position: relative; z-index: 3; } .speech-bubble { animation: bubble-in 0.24s ease-out; background: rgba(18, 10, 18, 0.92); border: 1px solid rgba(246, 196, 83, 0.5); border-radius: 16px; box-shadow: 0 18px 30px rgba(0, 0, 0, 0.34); box-sizing: border-box; color: #f8efe4; left: 0; margin: 0 0 0.35rem 0; max-height: 7.25rem; overflow-x: hidden; overflow-y: auto; padding: 0.72rem 0.95rem; position: absolute; right: 0; bottom: 100%; text-align: center; width: auto; z-index: 6; } .speech-bubble::after { border-left: 10px solid transparent; border-right: 10px solid transparent; border-top: 12px solid rgba(246, 196, 83, 0.5); bottom: -12px; content: ""; left: 50%; position: absolute; transform: translateX(-50%); } .speech-speaker { color: #ffd166; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.08em; margin-bottom: 0.18rem; text-transform: uppercase; } .speech-line { color: #f8efe4; font-size: 0.96rem; line-height: 1.35; overflow-wrap: anywhere; } .actor-row { align-items: flex-end; display: flex; flex-shrink: 0; flex-wrap: wrap; gap: 0.55rem; justify-content: center; margin-top: 0.91rem; position: relative; z-index: 3; } .actor-column { align-items: center; display: flex; flex: 0 0 auto; flex-direction: column; justify-content: flex-end; min-width: 0; position: relative; } .actor-card { background: rgba(70, 38, 36, 0.72); border: 1px solid rgba(246, 196, 83, 0.45); border-radius: 16px 16px 10px 10px; box-shadow: 0 14px 28px rgba(0, 0, 0, 0.28); min-height: 132px; padding: 0.58rem 0.62rem 0.72rem; position: relative; transform-origin: bottom center; text-align: center; z-index: 1; } .actor-card::after { background: #7c3f17; border-radius: 0 0 8px 8px; bottom: -22px; box-shadow: inset 0 -5px 8px rgba(0, 0, 0, 0.2); content: ""; height: 22px; left: calc(50% - 8px); position: absolute; width: 16px; } .actor-card.active { animation: puppet-bounce 0.78s ease-in-out infinite alternate; border-color: #ffd166; box-shadow: 0 0 0 2px rgba(255, 209, 102, 0.22), 0 0 34px rgba(255, 209, 102, 0.46), 0 16px 34px rgba(0, 0, 0, 0.34); } .actor-avatar { background: radial-gradient(circle, rgba(255, 209, 102, 0.2), rgba(59, 10, 22, 0.3)); border: 1px solid rgba(246, 196, 83, 0.34); border-radius: 999px; display: inline-grid; font-size: 1.7rem; height: 3rem; overflow: hidden; place-items: center; text-align: center; width: 3rem; } .actor-avatar.has-image { padding: 0; } .actor-avatar .actor-avatar-img { border-radius: 999px; display: block; height: 100%; object-fit: cover; width: 100%; } .actor-name { color: #f8efe4; font-weight: 700; line-height: 1.15; margin-top: 0.35rem; text-align: center; } .speaking-pill { background: #ffd166; border-radius: 999px; color: #3b0a16; display: inline-block; font-size: 0.64rem; font-weight: 800; margin-top: 0.26rem; padding: 0.12rem 0.44rem; text-transform: uppercase; } .actor-detail { color: #cbb7a1; font-size: 0.72rem; line-height: 1.28; margin-top: 0.35rem; } .actor-detail strong { color: #f8efe4; } .held-prop { margin-top: 0.42rem; } .held-prop span { background: rgba(246, 196, 83, 0.14); border: 1px solid rgba(246, 196, 83, 0.32); border-radius: 999px; color: #ffd166; display: inline-block; font-size: 0.68rem; font-weight: 700; padding: 0.12rem 0.42rem; } .beat-counter { color: #ffd166; font-weight: 800; margin-top: 0.55rem; position: relative; text-align: center; z-index: 3; } .stage-events { display: grid; gap: 0.4rem; margin-top: 0.55rem; position: relative; z-index: 3; } .audience-action, .prop-pile, .tool-clue { background: rgba(42, 20, 38, 0.7); border: 1px solid rgba(246, 196, 83, 0.25); border-radius: 999px; color: #f8efe4; margin: 0 auto; max-width: 48rem; padding: 0.38rem; text-align: center; width: 100%; } .audience-action strong, .prop-pile strong, .tool-clue strong { color: #ffd166; } .prop-token { animation: prop-pop 0.22s ease-out; background: rgba(246, 196, 83, 0.17); border: 1px solid rgba(246, 196, 83, 0.5); border-radius: 999px; color: #fff7ed; display: inline-block; margin: 0.2rem; padding: 0.22rem 0.55rem; } .gradio-container button { background: linear-gradient(180deg, #4f2f45 0%, #2f1828 100%) !important; border: 1px solid rgba(246, 196, 83, 0.26) !important; border-radius: 10px !important; color: #f8efe4 !important; font-weight: 600 !important; letter-spacing: 0.03em !important; min-height: 2.45rem !important; padding: 0.5rem 1rem !important; transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0.12s ease !important; } .gradio-container button:hover:not(:disabled) { transform: translateY(-1px); } .gradio-container button:active:not(:disabled) { transform: translateY(0); } .gradio-container button.primary, .gradio-container button.primary-action, .gradio-container button.run-one-action { background: linear-gradient(180deg, #fde68a 0%, #d97706 42%, #b45309 100%) !important; border: 1px solid rgba(252, 211, 77, 0.65) !important; box-shadow: 0 2px 0 rgba(124, 45, 18, 0.65), 0 10px 26px rgba(217, 119, 6, 0.35) !important; color: #1a0a06 !important; text-shadow: 0 1px 0 rgba(255, 247, 237, 0.35); } .gradio-container button.primary:hover:not(:disabled), .gradio-container button.primary-action:hover:not(:disabled), .gradio-container button.run-one-action:hover:not(:disabled) { background: linear-gradient(180deg, #fef3c7 0%, #ea580c 45%, #c2410c 100%) !important; box-shadow: 0 2px 0 rgba(124, 45, 18, 0.55), 0 12px 28px rgba(234, 88, 12, 0.38) !important; } .gradio-container button.secondary, .gradio-container button.audience-action-button { background: linear-gradient(180deg, #6b2a3a 0%, #4a1522 48%, #2d0c14 100%) !important; border: 1px solid rgba(252, 165, 165, 0.35) !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07), 0 6px 18px rgba(0, 0, 0, 0.32) !important; color: #fff5f5 !important; } .gradio-container button.secondary:hover:not(:disabled), .gradio-container button.audience-action-button:hover:not(:disabled) { border-color: rgba(254, 202, 202, 0.55) !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 8px 22px rgba(0, 0, 0, 0.36) !important; } .gradio-container button.cue-action { background: linear-gradient(180deg, #5c4a2f 0%, #3d3020 50%, #261c12 100%) !important; border: 1px solid rgba(246, 196, 83, 0.45) !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 6px 18px rgba(0, 0, 0, 0.3) !important; color: #fff8eb !important; } .gradio-container button.cue-action:hover:not(:disabled) { background: linear-gradient(180deg, #6e5a38 0%, #4a3b26 50%, #322818 100%) !important; border-color: rgba(253, 224, 71, 0.55) !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 8px 22px rgba(0, 0, 0, 0.34) !important; } .gradio-container button.reset-action { background: linear-gradient(180deg, #5c1218 0%, #3b0a16 55%, #2a0508 100%) !important; border: 1px solid rgba(246, 196, 83, 0.28) !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05) !important; color: #f5e6dc !important; } .gradio-container button.reset-action:hover:not(:disabled) { border-color: rgba(255, 209, 102, 0.45) !important; } .transcript-box { background: transparent !important; border: none !important; box-shadow: none !important; } .conversation-empty { color: #cbb7a1; font-size: 0.88rem; padding: 0.45rem 0.2rem; } .conversation-transcript { display: grid; gap: 0.38rem; padding: 0.15rem 0; } .conversation-turn { max-width: min(42rem, 84%); } .conversation-turn.speaker-changed { margin-top: 0.42rem; } .conversation-turn.same-speaker { margin-top: 0.04rem; } .conversation-turn:nth-child(even) { justify-self: end; } .conversation-speaker { color: #ffd166; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.07em; margin: 0 0 0.12rem 0.18rem; text-transform: uppercase; } .conversation-bubble { background: rgba(10, 12, 23, 0.88); border: 1px solid rgba(246, 196, 83, 0.24); border-radius: 11px; box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24); color: #f8efe4; font-size: 0.9rem; line-height: 1.38; padding: 0.54rem 0.68rem; overflow-wrap: anywhere; } .conversation-turn.latest .conversation-bubble { background: rgba(246, 196, 83, 0.13); border-color: rgba(246, 196, 83, 0.52); box-shadow: 0 0 0 1px rgba(246, 196, 83, 0.14), 0 10px 24px rgba(0, 0, 0, 0.28); } .voice-status { color: #cbb7a1; font-size: 0.8rem; min-height: 1.2rem; padding: 0.15rem 0.05rem 0; } .voice-status strong { color: #ffd166; } .edge-audio-player { height: 1px !important; margin: 0 !important; min-height: 1px !important; opacity: 0 !important; overflow: hidden !important; padding: 0 !important; pointer-events: none !important; } .edge-audio-player, .edge-audio-player *, .edge-audio-player .audio-container { max-height: 1px !important; } .edge-audio-player .label-wrap { display: none !important; } .agent-state-grid { display: grid; gap: 0.55rem; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); } .agent-state-card { background: rgba(10, 12, 23, 0.72); border: 1px solid rgba(246, 196, 83, 0.22); border-radius: 8px; box-sizing: border-box; padding: 0.62rem 0.68rem; } .agent-state-name { color: #ffd166; font-weight: 800; line-height: 1.2; margin-bottom: 0.28rem; } .agent-state-avatar { border-radius: 4px; height: 1.15rem; object-fit: cover; vertical-align: text-bottom; width: 1.15rem; } .agent-state-line { color: #f8efe4; font-size: 0.78rem; line-height: 1.35; overflow-wrap: anywhere; } .agent-state-line strong { color: #d8c6b6; } .agent-state-empty { color: #cbb7a1; font-size: 0.86rem; padding: 0.35rem 0; } .gradio-container .accordion { background: rgba(13, 6, 14, 0.72) !important; border: 1px solid rgba(246, 196, 83, 0.22) !important; border-radius: 12px !important; color: #f8efe4 !important; overflow: hidden; } .gradio-container .accordion .block, .gradio-container .accordion .form, .gradio-container .accordion .wrap:not(.label-wrap) { background: transparent !important; border: none !important; box-shadow: none !important; } .gradio-container .accordion textarea, .gradio-container .accordion .wrap-inner { background: rgba(10, 12, 23, 0.88) !important; border: 1px solid rgba(246, 196, 83, 0.22) !important; border-radius: 8px !important; } .gradio-container .accordion .no-field-label .label-wrap { display: none !important; } .gradio-container .accordion .no-field-label .wrap { margin-top: 0 !important; } .gradio-container .accordion > .label-wrap { background: linear-gradient(180deg, rgba(72, 28, 52, 0.98) 0%, rgba(42, 20, 38, 0.99) 100%) !important; border-bottom: 1px solid rgba(246, 196, 83, 0.22) !important; } .gradio-container .accordion > .label-wrap span { background: transparent !important; border: none !important; border-radius: 0 !important; box-shadow: none !important; color: #ffd166 !important; font-family: Georgia, "Times New Roman", ui-serif, serif !important; font-size: 0.78rem !important; letter-spacing: 0.08em !important; text-transform: uppercase !important; } .gradio-container .accordion > .label-wrap button { background: transparent !important; border: none !important; box-shadow: none !important; color: #ffd166 !important; } .gradio-container .accordion > .label-wrap button:hover { background: rgba(255, 209, 102, 0.12) !important; } .gradio-container .accordion .dropdown-container { background: transparent !important; border: none !important; box-shadow: none !important; } .gradio-container .accordion .dropdown-container .wrap-inner, .gradio-container .accordion .dropdown-container .secondary-wrap { background: rgba(8, 10, 18, 0.92) !important; border: 1px solid rgba(246, 196, 83, 0.32) !important; border-radius: 10px !important; } .gradio-container input[type="range"] { accent-color: #f6c453; } .gradio-container .slider input { --slider-color: #f6c453; } @keyframes puppet-bounce { from { transform: translateY(0) rotate(-0.4deg); } to { transform: translateY(-7px) rotate(0.7deg); } } @keyframes bubble-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } @keyframes prop-pop { from { opacity: 0; transform: scale(0.86); } to { opacity: 1; transform: scale(1); } } @media (max-width: 760px) { .puppet-stage { height: 600px; max-height: 600px; min-height: 600px; } .puppet-stage::before, .puppet-stage::after { width: 7%; } .stage-backdrop { padding: 0.8rem 1.4rem; } .stage-marquee { font-size: 1.2rem; } .actor-row { gap: 0.45rem; } .backstage-stack { gap: 0.65rem !important; max-width: 100% !important; } .actor-card { min-height: 126px; } } /* Final Gradio chrome overrides: keep the whole app in the theater palette. */ /* Generic groups only โ€” theater panels use .control-panel / .premise-panel below. */ .gradio-container .gr-group:not(.control-panel):not(.premise-panel) { background: rgba(34, 17, 31, 0.84) !important; border: 1px solid rgba(246, 196, 83, 0.2) !important; border-radius: 8px !important; color: #f8efe4 !important; } .gradio-container .gr-group.control-panel { border-radius: 12px !important; box-sizing: border-box !important; } .gradio-container .gr-group.premise-panel { border-radius: 12px !important; box-sizing: border-box !important; overflow: hidden !important; } .gradio-container .gr-group .form, .gradio-container .gr-group .block, .gradio-container .gr-group .wrap, .gradio-container .gr-group .wrap-inner, .gradio-container .gr-group .secondary-wrap, .gradio-container .gr-group .input-container, .gradio-container .gr-group label { color: #f8efe4 !important; } .gradio-container input, .gradio-container textarea, .gradio-container select { background: rgba(10, 12, 23, 0.9) !important; color: #f8efe4 !important; } .gradio-container input[type="checkbox"] { background: rgba(10, 12, 23, 0.9) !important; border: 1px solid rgba(246, 196, 83, 0.42) !important; box-shadow: inset 0 0 0 2px rgba(10, 12, 23, 0.9) !important; } .gradio-container input[type="checkbox"]:checked { background: #f6c453 !important; border-color: #fcd34d !important; box-shadow: inset 0 0 0 3px rgba(10, 12, 23, 0.9), 0 0 0 2px rgba(246, 196, 83, 0.2) !important; } .gradio-container input[type="checkbox"]:checked + span { color: #fcd34d !important; } .gradio-container .control-panel .dropdown-container, .gradio-container .premise-panel .dropdown-container { background: transparent !important; border: none !important; box-shadow: none !important; } .gradio-container .control-panel .dropdown-container .wrap-inner, .gradio-container .control-panel .dropdown-container .secondary-wrap, .gradio-container .control-panel .single-select, .gradio-container .premise-panel .dropdown-container .wrap-inner, .gradio-container .premise-panel .dropdown-container .secondary-wrap { background: rgba(8, 10, 18, 0.92) !important; border: 1px solid rgba(246, 196, 83, 0.38) !important; border-radius: 10px !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04) !important; } .gradio-container .control-panel input, .gradio-container .control-panel textarea, .gradio-container .premise-panel textarea { background: rgba(8, 10, 18, 0.92) !important; border: 1px solid rgba(246, 196, 83, 0.3) !important; border-radius: 10px !important; } .gradio-container .html-container, .gradio-container .gradio-style { width: 100% !important; } .puppet-stage { height: 600px; max-height: 600px; min-height: 600px; width: 100%; } .puppet-stage::before, .puppet-stage::after { width: clamp(56px, 9%, 110px); } .stage-backdrop { padding: 0.78rem clamp(4.1rem, 11vw, 8.8rem) 0.72rem; } .stage-marquee { font-size: clamp(1.25rem, 2.1vw, 1.72rem); white-space: normal; } .speech-bubble { max-height: 6.75rem; padding: 0.58rem 0.82rem; } .actor-row { align-items: flex-end; display: flex; flex-shrink: 0; flex-wrap: wrap; gap: 0.62rem; justify-content: center; margin-top: 0.82rem; } .actor-column { position: relative; } .actor-card { align-content: start; background: radial-gradient(circle at 50% 18%, rgba(246, 196, 83, 0.13), rgba(70, 38, 36, 0.72) 58%); border-radius: 18px; display: grid; justify-items: center; min-height: 108px; padding: 0.5rem 0.45rem 0.56rem; } .actor-card::after { bottom: -20px; height: 20px; width: 14px; } .actor-avatar { font-size: 2rem; height: 3.3rem; overflow: hidden; width: 3.3rem; } .actor-name { font-size: 0.82rem; margin-top: 0.28rem; } .actor-detail { display: -webkit-box; font-size: 0.66rem; line-height: 1.18; margin-top: 0.2rem; max-width: 11rem; min-height: 1.55rem; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } .held-prop { margin-top: 0.26rem; } .held-prop span { font-size: 0.62rem; padding: 0.08rem 0.34rem; } .speaking-pill { font-size: 0.58rem; margin-top: 0.18rem; padding: 0.08rem 0.36rem; } .stage-events { gap: 0.32rem; margin-top: 0.64rem; } .audience-action, .prop-pile, .tool-clue { max-width: 45rem; padding: 0.3rem 0.55rem; } @media (max-width: 760px) { .gradio-container { width: min(100vw, calc(100vw - 0.75rem)) !important; } .puppet-stage::before, .puppet-stage::after { width: 30px; } .stage-backdrop { padding: 0.75rem 2.45rem; } .actor-row { gap: 0.45rem; } .actor-card { min-height: 102px; padding-left: 0.28rem; padding-right: 0.28rem; } } /* Compact stage pass: keep the theater look, reduce scrolling, and keep controls close. */ .gradio-container { padding-top: 0.65rem !important; } .app-title h1 { font-size: 1.95rem; } .app-title p { margin-bottom: 0.55rem; } .stage-output, .stage-output .html-container, .stage-output .gradio-style { margin-bottom: 0 !important; } .puppet-stage { height: 600px; max-height: 600px; min-height: 600px; } .stage-empty-playbill { max-width: min(19rem, 92%); padding: 1rem 1.1rem 1.12rem; } .playbill-kicker { font-size: 0.62rem; letter-spacing: 0.28em; } .playbill-title { font-size: clamp(1.12rem, 4vw, 1.42rem); } .playbill-tagline { font-size: 0.82rem; } .stage-valance { height: 34px; border-bottom-width: 3px; } .stage-backdrop { padding: 0.48rem clamp(3.9rem, 9vw, 7.3rem) 0.46rem; } .stage-marquee { font-size: clamp(1.15rem, 1.9vw, 1.52rem); } .stage-copy { font-size: 0.76rem; line-height: 1.25; margin-top: 0.14rem; } .speech-bubble { border-radius: 12px; max-height: 5.75rem; padding: 0.42rem 0.7rem; } .speech-speaker { font-size: 0.68rem; } .speech-line { font-size: 0.86rem; overflow-wrap: anywhere; } .actor-row { align-items: flex-end; display: flex; flex-shrink: 0; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-top: 5.50rem; } .actor-column { position: relative; } .actor-card { border-radius: 14px; min-height: 88px; padding: 0.38rem 0.36rem 0.44rem; } .actor-card::after { bottom: -16px; height: 16px; } .actor-avatar { font-size: 1.65rem; height: 2.55rem; overflow: hidden; width: 2.55rem; } .actor-name { font-size: 0.74rem; margin-top: 0.2rem; } .actor-detail { font-size: 0.6rem; line-height: 1.12; margin-top: 0.14rem; min-height: 1.35rem; } .speaking-pill { font-size: 0.52rem; margin-top: 0.14rem; } .held-prop { margin-top: 0.18rem; } .held-prop span { font-size: 0.55rem; } .stage-events { gap: 0.24rem; margin-top: 0.46rem; } .audience-action, .prop-pile, .tool-clue { font-size: 0.78rem; max-width: 39rem; padding: 0.22rem 0.5rem; } .prop-token { margin: 0.08rem; padding: 0.12rem 0.4rem; } .beat-counter { font-size: 0.84rem; margin-top: 0.34rem; } .stage-floorboards { height: 40px; } .control-panel { margin-top: 0 !important; padding: 1rem 1.1rem 1.15rem !important; } .control-panel h3 { margin-bottom: 0.2rem; } .gradio-container .row { gap: 0.55rem !important; padding: 12px !important; } /* Column gap separates Show Controls from Audience; avoid doubling with sibling margin. */ .backstage-stack .control-panel + .control-panel { margin-top: 0 !important; } .transcript-section, .gradio-container .accordion { margin-top: 0.55rem !important; } @media (max-width: 760px) { .puppet-stage { height: 600px; max-height: 600px; min-height: 600px; } .stage-backdrop { padding: 0.52rem 2.15rem; } .actor-row { gap: 0.42rem; } .speech-line { font-size: 0.8rem; } } /* Gradio dropdown option panels are often portaled; keep them opaque and above the stage */ [role="listbox"] { z-index: 10050 !important; background-color: rgba(12, 10, 20, 0.98) !important; border: 2px solid rgba(246, 196, 83, 0.58) !important; border-radius: 12px !important; box-shadow: 0 22px 56px rgba(0, 0, 0, 0.72) !important; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); max-height: min(70vh, 22rem) !important; overflow-y: auto !important; overflow-x: hidden !important; } [role="option"] { padding: 0.55rem 0.85rem !important; font-size: 0.95rem !important; } [role="option"][aria-selected="true"], [role="option"]:hover, [role="option"][data-highlighted] { background-color: rgba(246, 196, 83, 0.18) !important; } /* Gradio 6.5.x: listbox inside .contain โ€” min 40px panel floor + 40px option rows */ .gradio-container.gradio-container-6-5-1 .contain [role="listbox"] { min-height: 40px !important; padding-left: 8px !important; } .gradio-container.gradio-container-6-5-1 .contain [role="listbox"] [role="option"] { align-items: center !important; box-sizing: border-box !important; display: flex !important; min-height: 40px !important; } """ THEATER_THEME = gr.themes.Soft( primary_hue=gr.themes.colors.amber, secondary_hue=gr.themes.colors.rose, neutral_hue=gr.themes.colors.stone, font=(gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"), font_mono=(gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "Consolas", "monospace"), ) BROWSER_TTS_JS = r""" async (mode, payloadJson, previewKind = "", options = {}) => { const root = globalThis; const speech = root.speechSynthesis; root.aiPuppetSpeech = root.aiPuppetSpeech || { lastAutoId: null, voices: [], voicesReady: false, queue: [], speaking: false, }; root.aiPuppetSpeech.voices = Array.isArray(root.aiPuppetSpeech.voices) ? root.aiPuppetSpeech.voices : []; root.aiPuppetSpeech.queue = Array.isArray(root.aiPuppetSpeech.queue) ? root.aiPuppetSpeech.queue : []; root.aiPuppetSpeech.voicesReady = Boolean(root.aiPuppetSpeech.voicesReady); root.aiPuppetSpeech.speaking = Boolean(root.aiPuppetSpeech.speaking); const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[char]); if (mode === "off") { if (speech) { root.aiPuppetSpeech.queue = []; root.aiPuppetSpeech.speaking = false; speech.cancel(); } return '
Voice mode is off.
'; } if (mode !== "character" && mode !== "narrator") { return '
Edge TTS uses generated audio for each latest line.
'; } if (!("speechSynthesis" in root) || typeof SpeechSynthesisUtterance === "undefined") { return '
Browser TTS unavailable. The show still works without voice.
'; } const cleanSpokenText = (value) => { return String(value || "") .replace(/\[[^\]]{1,140}\]/g, " ") .replace(/\*[^*]{1,140}\*/g, " ") .replace(/\([^)]{1,90}\)/g, " ") .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, " ") .replace(/\s+/g, " ") .trim(); }; const loadVoices = () => new Promise((resolve) => { const current = speech.getVoices ? speech.getVoices() : []; if (current.length > 0) { root.aiPuppetSpeech.voices = current; root.aiPuppetSpeech.voicesReady = true; resolve(current); return; } let settled = false; const finish = () => { if (settled) return; settled = true; const voices = speech.getVoices ? speech.getVoices() : []; root.aiPuppetSpeech.voices = voices; root.aiPuppetSpeech.voicesReady = voices.length > 0; resolve(voices); }; speech.onvoiceschanged = () => finish(); setTimeout(finish, 700); }); const selectVoice = (voices, style) => { if (!voices || voices.length === 0) return null; const hints = (style.voiceNameHints || []).map((hint) => String(hint).toLowerCase()); const byHint = voices.find((candidate) => { const name = String(candidate.name || "").toLowerCase(); return hints.some((hint) => name.includes(hint) || hint.includes(name)); }); if (byHint) return byHint; const english = voices.find((candidate) => { const lang = String(candidate.lang || "").toLowerCase(); const name = String(candidate.name || "").toLowerCase(); return lang.startsWith("en") || name.includes("english"); }); if (english) return english; const anyVoice = voices[0] || null; if (anyVoice) return anyVoice; if (Number.isInteger(style.voiceIndex) && voices.length > 0) { return voices[Math.abs(style.voiceIndex) % voices.length] || null; } return null; }; const payloadFromJson = () => { try { return JSON.parse(payloadJson || "{}"); } catch (_error) { return null; } }; const buildTask = (payload) => { const narratorStyle = payload.narratorStyle || payload.style || {}; const activeStyle = mode === "narrator" || previewKind === "narrator" ? narratorStyle : (payload.style || narratorStyle); let text = payload.text; let speaker = payload.speaker ? String(payload.speaker) : "Latest line"; if (previewKind === "actor") { text = `Previewing ${activeStyle.label || "character voice"}. The puppet voice is softer now.`; speaker = activeStyle.label || "Actor voice"; } else if (previewKind === "narrator") { text = "Previewing narrator voice. The curtain rises, and the scene begins."; speaker = activeStyle.label || "Narrator"; } else if (mode === "narrator") { speaker = "Narrator"; } text = cleanSpokenText(text); return { id: payload.id || `${Date.now()}:${speaker}`, text, speaker, style: activeStyle }; }; const updateStatus = (html) => { const status = document.querySelector(".voice-status"); if (status) status.outerHTML = html; }; const drainQueue = async () => { if (root.aiPuppetSpeech.speaking || root.aiPuppetSpeech.queue.length === 0) return; const task = root.aiPuppetSpeech.queue.shift(); try { const voices = await loadVoices(); const utterance = new SpeechSynthesisUtterance(task.text); utterance.pitch = Math.max(0.1, Math.min(2, Number(task.style.pitch ?? 1))); utterance.rate = Math.max(0.1, Math.min(2, Number(task.style.rate ?? 1))); utterance.volume = Math.max(0, Math.min(1, Number(task.style.volume ?? 1))); const voice = selectVoice(voices, task.style); if (voice) utterance.voice = voice; const label = task.style.label ? ` (${escapeHtml(task.style.label)})` : ""; const statusHtml = `
Speaking: ${escapeHtml(task.speaker)}${label}
`; root.aiPuppetSpeech.speaking = true; updateStatus(statusHtml); utterance.onend = () => { root.aiPuppetSpeech.speaking = false; drainQueue(); }; utterance.onerror = () => { root.aiPuppetSpeech.speaking = false; drainQueue(); }; speech.speak(utterance); } catch (error) { root.aiPuppetSpeech.speaking = false; updateStatus('
Browser TTS could not start. Try Speak Latest Line once, then run the show again.
'); } }; const payload = payloadFromJson(); if (!payload) { return '
No speakable line is ready yet.
'; } const task = buildTask(payload); if (!task.text) { return '
No speakable line is ready yet.
'; } if (!options.queue) { root.aiPuppetSpeech.queue = []; root.aiPuppetSpeech.speaking = false; speech.cancel(); } root.aiPuppetSpeech.queue.push(task); drainQueue(); const label = task.style.label ? ` (${escapeHtml(task.style.label)})` : ""; const prefix = options.queue && root.aiPuppetSpeech.speaking ? "Queued" : "Speaking"; return `
${prefix}: ${escapeHtml(task.speaker)}${label}
`; } """ AUTO_BROWSER_TTS_JS = r""" async (mode, autoSpeak, payloadJson) => { const root = globalThis; root.aiPuppetSpeech = root.aiPuppetSpeech || {}; root.aiPuppetSpeech.lastAutoId = root.aiPuppetSpeech.lastAutoId || null; root.aiPuppetSpeech.voices = Array.isArray(root.aiPuppetSpeech.voices) ? root.aiPuppetSpeech.voices : []; root.aiPuppetSpeech.queue = Array.isArray(root.aiPuppetSpeech.queue) ? root.aiPuppetSpeech.queue : []; root.aiPuppetSpeech.voicesReady = Boolean(root.aiPuppetSpeech.voicesReady); root.aiPuppetSpeech.speaking = Boolean(root.aiPuppetSpeech.speaking); const speech = root.speechSynthesis; try { if (mode === "off") { if (speech) speech.cancel(); return '
Voice mode is off.
'; } if (mode !== "character" && mode !== "narrator") { return '
Edge TTS uses generated audio for each latest line.
'; } if (!autoSpeak) { return '
Auto-speak is off. Use Speak Latest Line to replay.
'; } let payload = {}; try { payload = JSON.parse(payloadJson || "{}"); } catch (_error) { return '
No speakable line is ready yet.
'; } if (!payload.id || root.aiPuppetSpeech.lastAutoId === payload.id) { return '
Browser TTS is ready.
'; } root.aiPuppetSpeech.lastAutoId = payload.id; const speakLatest = %s; return await speakLatest(mode, payloadJson, "", { queue: true }); } catch (error) { const detail = error && error.message ? String(error.message) : String(error || "unknown error"); const safeDetail = detail.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[char]); return `
Auto-speak could not start. ${safeDetail}
`; } } """ % BROWSER_TTS_JS REGISTER_BROWSER_TTS_JS = r""" () => { globalThis.aiPuppetSpeakLatest = %s; window.aiPuppetEdgeAudio = window.aiPuppetEdgeAudio || { queue: [], current: null, currentSrc: "", playing: false, lastSeenSrc: "", lastSeenAt: 0, completedSrcAt: {}, drainTimer: null, interLineDelayMs: 650, observerReady: false, }; const edgeStatus = (html) => { const status = document.querySelector(".voice-status"); if (status) status.outerHTML = html; }; const edgeModeActive = () => { const checked = [...document.querySelectorAll('input[type="radio"]')].find((input) => input.checked); return checked && checked.value === "edge_character"; }; const edgePauseNativePlayers = () => { document.querySelectorAll(".edge-audio-player audio").forEach((audio) => { audio.pause(); audio.currentTime = 0; }); }; const edgeDrainQueue = () => { const state = window.aiPuppetEdgeAudio; if (state.drainTimer) { clearTimeout(state.drainTimer); state.drainTimer = null; } if (state.playing || state.queue.length === 0) return; const task = state.queue.shift(); state.playing = true; state.currentSrc = task.src; edgePauseNativePlayers(); const audio = new Audio(task.src); audio.preload = "auto"; state.current = audio; audio.onended = () => { state.playing = false; state.current = null; state.completedSrcAt[task.src] = Date.now(); state.currentSrc = ""; state.drainTimer = setTimeout(edgeDrainQueue, state.interLineDelayMs); }; audio.onerror = () => { state.playing = false; state.current = null; state.currentSrc = ""; edgeStatus('
Edge TTS playback failed. Use Browser TTS or expand/retry audio.
'); state.drainTimer = setTimeout(edgeDrainQueue, state.interLineDelayMs); }; const playResult = audio.play(); if (playResult && typeof playResult.catch === "function") { playResult.catch(() => { state.playing = false; state.current = null; state.currentSrc = ""; edgeStatus('
Edge TTS is ready. Browser blocked autoplay; click the page once and run the next line.
'); }); } }; const edgeEnqueueSrc = (src) => { // Gradio's mounted audio element is the reliable autoplay path for Edge TTS. // Full Act pacing waits between generated files, so this observer no longer // starts a separate Audio() object that browsers may block. return; if (!src || !edgeModeActive()) return; const absoluteSrc = new URL(src, window.location.href).href; const state = window.aiPuppetEdgeAudio; const now = Date.now(); if (state.lastSeenSrc === absoluteSrc && now - state.lastSeenAt < 1200) return; if (state.currentSrc === absoluteSrc) return; if (state.queue.some((task) => task.src === absoluteSrc)) return; if (state.completedSrcAt[absoluteSrc] && now - state.completedSrcAt[absoluteSrc] < 30000) return; state.lastSeenSrc = absoluteSrc; state.lastSeenAt = now; edgePauseNativePlayers(); state.queue.push({ src: absoluteSrc }); edgeStatus(state.playing ? '
Edge TTS queued next line.
' : '
Edge TTS playing latest line.
'); edgeDrainQueue(); }; const edgeScanAudio = () => { const audio = document.querySelector(".edge-audio-player audio"); const source = document.querySelector(".edge-audio-player source"); const src = (audio && (audio.currentSrc || audio.src)) || (source && source.src) || ""; edgeEnqueueSrc(src); }; window.aiPuppetEdgeAudio.enqueueSrc = edgeEnqueueSrc; window.aiPuppetEdgeAudio.clear = () => { const state = window.aiPuppetEdgeAudio; state.queue = []; state.playing = false; state.currentSrc = ""; state.lastSeenSrc = ""; state.completedSrcAt = {}; if (state.drainTimer) { clearTimeout(state.drainTimer); state.drainTimer = null; } if (state.current) { state.current.pause(); state.current.currentTime = 0; state.current = null; } edgePauseNativePlayers(); }; if (!window.aiPuppetEdgeAudio.observerReady) { const observer = new MutationObserver(() => edgeScanAudio()); observer.observe(document.body, { attributes: true, attributeFilter: ["src"], childList: true, subtree: true, }); window.aiPuppetEdgeAudio.observerReady = true; setTimeout(edgeScanAudio, 250); } document.body.dataset.edgeAudioQueueReady = "true"; if ("speechSynthesis" in globalThis && globalThis.speechSynthesis.getVoices) { globalThis.aiPuppetSpeech = globalThis.aiPuppetSpeech || {}; globalThis.aiPuppetSpeech.lastAutoId = globalThis.aiPuppetSpeech.lastAutoId || null; globalThis.aiPuppetSpeech.voices = Array.isArray(globalThis.aiPuppetSpeech.voices) ? globalThis.aiPuppetSpeech.voices : []; globalThis.aiPuppetSpeech.queue = Array.isArray(globalThis.aiPuppetSpeech.queue) ? globalThis.aiPuppetSpeech.queue : []; globalThis.aiPuppetSpeech.voicesReady = Boolean(globalThis.aiPuppetSpeech.voicesReady); globalThis.aiPuppetSpeech.speaking = Boolean(globalThis.aiPuppetSpeech.speaking); const load = () => { const voices = globalThis.speechSynthesis.getVoices(); globalThis.aiPuppetSpeech.voices = voices; globalThis.aiPuppetSpeech.voicesReady = voices.length > 0; }; globalThis.speechSynthesis.onvoiceschanged = load; load(); } globalThis.aiPuppetStopSpeaking = () => { if (window.aiPuppetEdgeAudio && window.aiPuppetEdgeAudio.clear) { window.aiPuppetEdgeAudio.clear(); } document.querySelectorAll("audio").forEach((audio) => { audio.pause(); audio.currentTime = 0; }); if ("speechSynthesis" in globalThis) { globalThis.aiPuppetSpeech = globalThis.aiPuppetSpeech || {}; globalThis.aiPuppetSpeech.queue = []; globalThis.aiPuppetSpeech.speaking = false; globalThis.speechSynthesis.cancel(); return '
Speech stopped.
'; } return '
Browser TTS unavailable. The show still works without voice.
'; }; return '
Voice mode is off.
'; } """ % BROWSER_TTS_JS STOP_SPEAKING_JS = r""" () => { if (globalThis.aiPuppetStopSpeaking) { return globalThis.aiPuppetStopSpeaking(); } document.querySelectorAll("audio").forEach((audio) => { audio.pause(); audio.currentTime = 0; }); if ("speechSynthesis" in globalThis) { globalThis.aiPuppetSpeech = globalThis.aiPuppetSpeech || {}; globalThis.aiPuppetSpeech.queue = []; globalThis.aiPuppetSpeech.speaking = false; globalThis.speechSynthesis.cancel(); return '
Speech stopped.
'; } return '
Browser TTS unavailable. The show still works without voice.
'; } """ def render_stage(session: TheaterSession | None) -> str: if session is None: return EMPTY_STAGE show_opening_curtain = session.play_opening_curtain if session.play_opening_curtain: session.play_opening_curtain = False actor_columns: list[str] = [] latest_beat = session.transcript[-1] if session.transcript else None latest_speaker = latest_beat.speaker if latest_beat else None for actor in session.actors: active_class = " active" if actor.name == latest_speaker else "" active_label = '
Now speaking
' if actor.name == latest_speaker else "" role_line = actor.goal.split(".", maxsplit=1)[0] held_prop = actor.held_prop or "nothing" held_emoji = PROP_EMOJI.get(held_prop.lower(), "๐ŸŽ") if actor.held_prop else "" bubble_html = "" if latest_beat is not None and actor.name == latest_speaker: bubble_html = f"""
{escape(latest_beat.speaker)}
{escape(latest_beat.line)}
""" avatar_class = "actor-avatar has-image" if actor.avatar_image_url else "actor-avatar" if actor.avatar_image_url: avatar_inner = f'' else: avatar_inner = escape(actor.avatar) actor_columns.append( f"""
{bubble_html}
{avatar_inner}
{escape(actor.name)}
{active_label}
{escape(role_line)}
Holding: {escape((held_emoji + " ") if held_emoji else "")}{escape(held_prop)}
""" ) audience_action = "" if session.latest_audience_action is not None: audience_action = f"""
Audience: {escape(session.latest_audience_action)}
""" prop_pile = "" if session.props: prop_tokens = "".join( f'{escape(PROP_EMOJI.get(prop.lower(), "๐ŸŽ"))} {escape(prop)}' for prop in session.props ) prop_pile = f"""
Props on stage: {prop_tokens}
""" tool_clue = "" if session.latest_tool_result is not None: tool_clue = f"""
Tool clue: {escape(session.latest_tool_result.result)}
""" phase = story_phase(session) progress_percent = round(story_progress(session) * 100) performed_beats = len(session.transcript) curtain_html = "" if show_opening_curtain: curtain_html = """ """ backdrop_style = "" if session.backdrop_image_url: u = escape(session.backdrop_image_url) backdrop_style = ( f' style="background-image: linear-gradient(180deg, rgba(42,20,38,0.82) 0%, rgba(19,9,17,0.88) 62%, rgba(13,6,14,0.92) 100%), url({u}); ' 'background-size: cover; background-position: center; background-blend-mode: multiply;"' ) return f"""
{curtain_html}
{escape(session.show_title)}
Setting: {escape(session.setting)}
Premise: {escape(session.premise)}
Phase: {escape(phase.title())} ยท Progress: {progress_percent}%
{''.join(actor_columns)}
{audience_action} {tool_clue} {prop_pile}
Beat {performed_beats} of {session.max_beats} ยท target {session.target_beats} ยท {escape(phase.title())}
""" def render_transcript(session: TheaterSession | None) -> str: if session is None: return EMPTY_TRANSCRIPT if not session.transcript: return '
No puppet lines yet. Run one beat to start the scene.
' turns: list[str] = [] previous_speaker: str | None = None latest_index = len(session.transcript) for index, beat in enumerate(session.transcript, start=1): speaker_changed = previous_speaker is not None and previous_speaker != beat.speaker gap_class = "speaker-changed" if speaker_changed else "same-speaker" latest_class = " latest" if index == latest_index else "" turns.append( f"""
{escape(beat.speaker)}
{escape(beat.line)}
""" ) previous_speaker = beat.speaker return f'
{"".join(turns)}
' def render_agent_state(session: TheaterSession | None) -> str: if session is None: return EMPTY_AGENT_STATE cards: list[str] = [] for actor in session.actors: held_props = actor.held_props or ([actor.held_prop] if actor.held_prop else []) held = ", ".join(held_props) if held_props else "none" recent_memory = actor.recent_memory[-1] if actor.recent_memory else "No memory yet." current_goal = actor.current_goal or actor.goal face = ( f' ' if actor.avatar_image_url else f"{escape(actor.avatar)} " ) cards.append( f"""
{face}{escape(actor.name)}
Mood: {escape(actor.mood)}
Goal: {escape(current_goal)}
Props: {escape(held)}
Secret: {escape(actor.secret_status)}
Memory: {escape(recent_memory)}
""" ) return f'
{"".join(cards)}
' def render_director_log(session: TheaterSession | None) -> str: if session is None: return EMPTY_DIRECTOR_LOG return "\n".join(f"- {entry}" for entry in session.director_log) def render_trace(session: TheaterSession | None) -> str: return render_trace_json(session) def render_trace_summary_text(session: TheaterSession | None) -> str: return render_trace_summary(session) def normalize_backend_name(backend_name: str | None) -> str: return backend_name if backend_name in BACKEND_VALUES or backend_name in LEGACY_BACKEND_VALUES else "deterministic" def actor_engine_label(backend_name: str | None) -> str: return ACTOR_ENGINE_LABELS.get(normalize_backend_name(backend_name), "Deterministic") def actor_model_id_for_backend(backend_name: str | None) -> str | None: backend = normalize_backend_name(backend_name) if backend == "openbmb": return OPENBMB_MODEL_ID if backend == "hf_api": return HF_API_MODEL_ID if backend == "local_lora": return os.getenv( "ACTOR_LORA_ADAPTER", "build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA", ) if backend == "local_gguf": model_path = os.getenv("ACTOR_GGUF_MODEL_PATH", "").strip() if model_path: return Path(model_path).name repo_id = os.getenv( "ACTOR_GGUF_REPO_ID", "build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF", ) filename = os.getenv("ACTOR_GGUF_FILENAME", "minicpm5-actor-q4_k_m.gguf") return f"{repo_id}/{filename}" return None def normalize_director_mode(director_mode: str | None) -> str: return director_mode if director_mode in DIRECTOR_MODE_VALUES else "deterministic" def normalize_show_length(show_length: str | None) -> str: return show_length if show_length in SHOW_LENGTH_VALUES else DEFAULT_SHOW_LENGTH def normalize_max_new_tokens(max_new_tokens: int | float | None) -> int: if max_new_tokens is None: return DEFAULT_MAX_NEW_TOKENS return max(16, min(160, int(max_new_tokens))) def normalize_temperature(temperature: int | float | None) -> float: if temperature is None: return DEFAULT_TEMPERATURE return max(0.0, min(1.5, float(temperature))) def apply_backend_selection( session: TheaterSession | None, backend_name: str | None, director_mode: str | None, max_new_tokens: int | float | None = None, temperature: int | float | None = None, ) -> TheaterSession | None: if session is None: return None session.backend_name = normalize_backend_name(backend_name) session.backend_model_id = actor_model_id_for_backend(session.backend_name) session.director_mode = normalize_director_mode(director_mode) session.backend_max_new_tokens = normalize_max_new_tokens(max_new_tokens) session.backend_temperature = normalize_temperature(temperature) return session def render_backend_settings( session: TheaterSession | None, backend_name: str | None = None, director_mode: str | None = None, max_new_tokens: int | float | None = None, temperature: int | float | None = None, ) -> str: selected_backend = normalize_backend_name(backend_name) selected_director_mode = normalize_director_mode(director_mode) active_backend = session.backend_name if session is not None else selected_backend active_director_mode = session.director_mode if session is not None else selected_director_mode active_model_id = session.backend_model_id if session is not None else None min_beats, target_beats, max_beats = ( (session.min_beats, session.target_beats, session.max_beats) if session is not None else resolve_show_length(DEFAULT_SHOW_LENGTH)[1:] ) if active_backend == "openbmb" or active_director_mode == "openbmb": active_model_id = active_model_id or OPENBMB_MODEL_ID if active_backend == "hf_api" or active_director_mode == "hf_api": active_model_id = active_model_id or HF_API_MODEL_ID active_model_id = active_model_id or actor_model_id_for_backend(active_backend) status = get_backend_status(active_backend) openbmb_status = get_backend_status("openbmb") hf_api_status = get_backend_status("hf_api") lora_status = get_backend_status("local_lora") gguf_status = get_backend_status("local_gguf") configured_max_new_tokens = ( session.backend_max_new_tokens if session is not None else normalize_max_new_tokens(max_new_tokens) ) configured_temperature = ( session.backend_temperature if session is not None else normalize_temperature(temperature) ) latency = f"{status.latest_latency_ms}ms" if status.latest_latency_ms is not None else "none yet" fallback_reason = status.latest_fallback_reason or "none" hf_api_fallback_reason = hf_api_status.latest_fallback_reason or "none" if status.latest_fallback_used: if active_backend == "local_lora": actor_engine_status = "Local LoRA failed, fallback used" elif active_backend == "local_gguf": actor_engine_status = "Local GGUF failed, fallback used" else: actor_engine_status = f"Actor engine: {actor_engine_label(active_backend)} failed, fallback used" else: actor_engine_status = f"Actor engine: {actor_engine_label(active_backend)}" cuda_available = ( str(status.cuda_available_in_gpu_fn).lower() if status.cuda_available_in_gpu_fn is not None else "not checked" ) return ( f"{actor_engine_status}\n" f"Active backend: {active_backend}\n" f"Director mode: {active_director_mode}\n" f"Show length: min={min_beats}, target={target_beats}, max={max_beats}\n" "Available actor engines: Deterministic, Local LoRA Actor model, Local GGUF Actor model, Local OpenBMB, Hugging Face API\n" "Available director modes: deterministic, local OpenBMB, Hugging Face API / LLM\n" f"Active model id: {active_model_id or 'not selected'}\n" f"OpenBMB model id: {OPENBMB_MODEL_ID}\n" f"HF API model id: {HF_API_MODEL_ID}\n" f"Local LoRA adapter: {os.getenv('ACTOR_LORA_ADAPTER', 'build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA')}\n" f"Local LoRA status: {lora_status.load_status}\n" f"Local GGUF model: {gguf_status.model_id or 'not selected'}\n" f"Local GGUF status: {gguf_status.load_status}\n" f"HF API token configured: {str(bool(hf_api_status.token_configured)).lower()}\n" f"ZeroGPU enabled: {str(status.zerogpu_enabled).lower()}\n" f"spaces available: {str(status.spaces_available).lower()}\n" f"ZeroGPU GPU decorator active: {str(status.zerogpu_gpu_active).lower()}\n" f"Torch version: {status.torch_version or 'unknown'}\n" f"CUDA available inside GPU function: {cuda_available}\n" f"Model status: {status.load_status}\n" f"OpenBMB status: {openbmb_status.load_status}\n" f"HF API status: {hf_api_status.load_status}\n" f"Latest latency: {latency}\n" f"Latest fallback reason: {fallback_reason}\n" f"HF API latest fallback reason: {hf_api_fallback_reason}\n" f"Generation: max_new_tokens={configured_max_new_tokens}, temperature={configured_temperature:.2f}\n" "Fallback behavior: model errors or invalid output fall back to deterministic generation\n" "Note: Deterministic is the hosted-demo default. Local LoRA and GGUF load lazily only when selected." ) def render_outputs(session: TheaterSession | None, voice_mode: str | None = DEFAULT_VOICE_MODE): edge_audio, tts_status = generate_edge_tts_audio(session, voice_mode) return ( render_stage(session), render_transcript(session), latest_tts_payload(session), edge_audio, f'
{escape(tts_status)}
', render_agent_state(session), render_director_log(session), render_trace_summary_text(session), render_trace(session), write_trace_json_file(session), render_backend_settings(session), ) def create_show( premise: str, session: TheaterSession | None, show_length: str, backend_name: str, director_mode: str, max_new_tokens: int | float, temperature: int | float, voice_mode: str, ): premise = premise.strip() selected_show_length = normalize_show_length(show_length) selected_backend = normalize_backend_name(backend_name) selected_director_mode = normalize_director_mode(director_mode) selected_max_new_tokens = normalize_max_new_tokens(max_new_tokens) selected_temperature = normalize_temperature(temperature) if not premise: return ( None, EMPTY_STAGE, "No premise yet. Add a premise to raise the curtain.", EMPTY_TTS_PAYLOAD, None, f'
{escape(EMPTY_TTS_STATUS)}
', EMPTY_AGENT_STATE, EMPTY_DIRECTOR_LOG, EMPTY_TRACE_SUMMARY, EMPTY_TRACE, None, render_backend_settings( None, selected_backend, selected_director_mode, selected_max_new_tokens, selected_temperature, ), ) session = create_show_from_premise( premise, backend_name=selected_backend, backend_model_id=actor_model_id_for_backend(selected_backend), backend_max_new_tokens=selected_max_new_tokens, backend_temperature=selected_temperature, director_mode=selected_director_mode, show_length=selected_show_length, ) return session, *render_outputs(session, voice_mode) def reset_show(): return ( None, "", "rubber duck", "", EMPTY_STAGE, EMPTY_TRANSCRIPT, EMPTY_TTS_PAYLOAD, None, f'
{escape(EMPTY_TTS_STATUS)}
', EMPTY_AGENT_STATE, EMPTY_DIRECTOR_LOG, EMPTY_TRACE_SUMMARY, EMPTY_TRACE, None, DEFAULT_SHOW_LENGTH, DEFAULT_ACTOR_ENGINE, "deterministic", DEFAULT_MAX_NEW_TOKENS, DEFAULT_TEMPERATURE, False, DEFAULT_VOICE_MODE, True, EMPTY_BACKEND, ) def advance_one_beat( session: TheaterSession | None, backend_name: str, director_mode: str, max_new_tokens: int | float, temperature: int | float, voice_mode: str, ): if session is None: return ( None, EMPTY_STAGE, "Create a show before running a beat.", EMPTY_TTS_PAYLOAD, None, f'
{escape(EMPTY_TTS_STATUS)}
', EMPTY_AGENT_STATE, EMPTY_DIRECTOR_LOG, EMPTY_TRACE_SUMMARY, EMPTY_TRACE, None, render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature), ) session = apply_backend_selection(session, backend_name, director_mode, max_new_tokens, temperature) session = run_one_beat(session) return session, *render_outputs(session, voice_mode) def advance_full_act( session: TheaterSession | None, backend_name: str, director_mode: str, max_new_tokens: int | float, temperature: int | float, use_deterministic_full_act: bool, voice_mode: str, ): if session is None: yield ( None, EMPTY_STAGE, "Create a show before running the full act.", EMPTY_TTS_PAYLOAD, None, f'
{escape(EMPTY_TTS_STATUS)}
', EMPTY_AGENT_STATE, EMPTY_DIRECTOR_LOG, EMPTY_TRACE_SUMMARY, EMPTY_TRACE, None, render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature), ) return session = apply_backend_selection(session, backend_name, director_mode, max_new_tokens, temperature) selected_backend = session.backend_name selected_director_mode = session.director_mode deterministic_full_act = ( (selected_backend in {"openbmb", "local_lora", "local_gguf"} or selected_director_mode == "openbmb") and use_deterministic_full_act ) if deterministic_full_act: session.director_log.append( "A local model backend is selected, so Run Full Act will use deterministic playback for this run." ) add_trace_event( session, "full_act_deterministic_playback", backend_name=selected_backend, director_mode=selected_director_mode, fallback_used=True, fallback_reason="Local model full-act playback uses deterministic mode to keep the demo responsive.", ) if session.beat_index >= session.max_beats: if deterministic_full_act: session.backend_name = "deterministic" session.director_mode = "deterministic" session = run_one_beat(session) if deterministic_full_act: session.backend_name = selected_backend session.director_mode = selected_director_mode yield session, *render_outputs(session, voice_mode) return while session.beat_index < session.max_beats: if deterministic_full_act: session.backend_name = "deterministic" session.director_mode = "deterministic" session = run_one_beat(session) if deterministic_full_act: session.backend_name = selected_backend session.director_mode = selected_director_mode yield session, *render_outputs(session, voice_mode) if session.beat_index < session.max_beats: sleep(playback_delay_for_latest_line(session, voice_mode)) def throw_audience_prop( session: TheaterSession | None, prop_name: str, backend_name: str, director_mode: str, max_new_tokens: int | float, temperature: int | float, voice_mode: str, ): if session is None: return ( None, EMPTY_STAGE, "Create a show before throwing a prop.", EMPTY_TTS_PAYLOAD, None, f'
{escape(EMPTY_TTS_STATUS)}
', EMPTY_AGENT_STATE, EMPTY_DIRECTOR_LOG, EMPTY_TRACE_SUMMARY, EMPTY_TRACE, None, render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature), ) session = apply_backend_selection(session, backend_name, director_mode, max_new_tokens, temperature) session = throw_prop(session, prop_name) return session, *render_outputs(session, voice_mode) def summon_audience_actor( session: TheaterSession | None, actor_name: str, backend_name: str, director_mode: str, max_new_tokens: int | float, temperature: int | float, voice_mode: str, ): if session is None: return ( None, EMPTY_STAGE, "Create a show before summoning an actor.", EMPTY_TTS_PAYLOAD, None, f'
{escape(EMPTY_TTS_STATUS)}
', EMPTY_AGENT_STATE, EMPTY_DIRECTOR_LOG, EMPTY_TRACE_SUMMARY, EMPTY_TRACE, None, render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature), ) session = apply_backend_selection(session, backend_name, director_mode, max_new_tokens, temperature) session = summon_actor(session, actor_name) return session, *render_outputs(session, voice_mode) def request_audience_finale( session: TheaterSession | None, backend_name: str, director_mode: str, max_new_tokens: int | float, temperature: int | float, voice_mode: str, ): if session is None: return ( None, EMPTY_STAGE, "Create a show before requesting a finale.", EMPTY_TTS_PAYLOAD, None, f'
{escape(EMPTY_TTS_STATUS)}
', EMPTY_AGENT_STATE, EMPTY_DIRECTOR_LOG, EMPTY_TRACE_SUMMARY, EMPTY_TRACE, None, render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature), ) session = apply_backend_selection(session, backend_name, director_mode, max_new_tokens, temperature) session = request_finale(session) return session, *render_outputs(session, voice_mode) def warm_up_backend( session: TheaterSession | None, max_new_tokens: int | float, temperature: int | float, ): selected_max_new_tokens = normalize_max_new_tokens(max_new_tokens) selected_temperature = normalize_temperature(temperature) status = warm_up_openbmb( max_new_tokens=selected_max_new_tokens, temperature=selected_temperature, ) if session is not None: session.backend_max_new_tokens = selected_max_new_tokens session.backend_temperature = selected_temperature if status.load_status == "loaded": session.director_log.append(f"OpenBMB warm-up loaded {status.model_id}.") add_trace_event( session, "backend_warmup", backend_name="openbmb", model_id=status.model_id, latency_ms=status.latest_latency_ms, validation_status="loaded", fallback_used=False, ) else: reason = status.latest_fallback_reason or "unknown error" session.director_log.append(f"OpenBMB warm-up failed: {reason}.") add_trace_event( session, "backend_warmup", backend_name="openbmb", model_id=status.model_id, latency_ms=status.latest_latency_ms, validation_status=status.load_status, fallback_used=True, fallback_reason=reason, ) return ( session, render_director_log(session), render_trace_summary_text(session), render_trace(session), write_trace_json_file(session), render_backend_settings( session, "openbmb", session.director_mode if session is not None else "deterministic", selected_max_new_tokens, selected_temperature, ), ) with gr.Blocks(title="AI Puppet Theater") as app: session_state = gr.State(None) gr.Markdown( """ # AI Puppet Theater Create a tiny AI puppet show, then interrupt it from the audience. """, elem_classes=["app-title"], ) with gr.Group(elem_classes=["control-panel", "premise-panel"]): with gr.Column(elem_classes=["premise-stack"]): premise_input = gr.Textbox( label="Premise", placeholder="A moon detective interrogates a suspicious toaster...", lines=1, ) show_length_select = gr.Dropdown( choices=SHOW_LENGTH_CHOICES, value=DEFAULT_SHOW_LENGTH, label="Show Length", interactive=True, ) with gr.Row(elem_classes=["premise-actions"]): create_button = gr.Button("Create Show", variant="primary", elem_classes=["primary-action"]) reset_button = gr.Button("Reset", elem_classes=["reset-action"]) stage_output = gr.HTML(value=EMPTY_STAGE, label="Stage", elem_classes=["stage-output"]) latest_tts_payload_output = gr.Textbox(value=EMPTY_TTS_PAYLOAD, visible=False) with gr.Column(elem_classes=["backstage-stack"]): with gr.Group(elem_classes=["control-panel"]): gr.Markdown("### Show Controls", elem_classes=["show-controls-title", "panel-heading"]) with gr.Row(): run_one_button = gr.Button( "Run One Beat", variant="primary", elem_classes=["run-one-action"], ) run_full_button = gr.Button("Run Full Act", elem_classes=["cue-action"]) with gr.Group(elem_classes=["control-panel"]): gr.Markdown("### Voice", elem_classes=["show-controls-title", "panel-heading"]) voice_mode_select = gr.Radio( choices=VOICE_MODE_CHOICES, value=DEFAULT_VOICE_MODE, label="Voice Mode", interactive=True, ) auto_speak_checkbox = gr.Checkbox( value=True, label="Auto-speak latest line", interactive=True, ) with gr.Row(): speak_latest_button = gr.Button("Speak Latest Line", elem_classes=["cue-action"]) stop_speaking_button = gr.Button("Stop Speaking", elem_classes=["reset-action"]) with gr.Row(): preview_actor_voice_button = gr.Button("Preview Actor Voice", elem_classes=["cue-action"]) preview_narrator_voice_button = gr.Button("Preview Narrator Voice", elem_classes=["cue-action"]) tts_status_output = gr.HTML( value=f'
{EMPTY_TTS_STATUS}
', label="Voice Status", elem_classes=["no-field-label"], ) edge_tts_audio_output = gr.Audio( value=None, label="Latest Edge TTS line", type="filepath", autoplay=True, interactive=False, elem_classes=["edge-audio-player", "no-field-label"], ) with gr.Group(elem_classes=["control-panel"]): gr.Markdown("### Audience", elem_classes=["audience-title", "panel-heading"]) prop_input = gr.Dropdown( choices=PROP_DROPDOWN_CHOICES, value="rubber duck", allow_custom_value=True, filterable=True, label="Choose a prop", info="Pick from the chest or type your own, then press **Throw Prop**.", elem_classes=["prop-picker"], ) with gr.Row(): throw_prop_button = gr.Button( "Throw Prop", elem_classes=["audience-action-button"], ) request_finale_button = gr.Button( "Request Finale", elem_classes=["audience-action-button"], ) actor_input = gr.Textbox(label="Summon Actor", placeholder="Professor Button") summon_actor_button = gr.Button( "Summon Actor", elem_classes=["audience-action-button"], ) with gr.Accordion("Transcript", open=False, elem_classes=["transcript-section"]): transcript_output = gr.HTML( value=EMPTY_TRANSCRIPT, label="Transcript", elem_classes=["transcript-box", "no-field-label"], ) with gr.Accordion("Agent State", open=False): agent_state_output = gr.HTML( value=EMPTY_AGENT_STATE, label="Agent State", elem_classes=["no-field-label"], ) with gr.Accordion("Behind the Curtain", open=False): trace_summary_output = gr.Textbox( value=EMPTY_TRACE_SUMMARY, label="Trace Summary", lines=8, interactive=False, buttons=["copy"], elem_classes=["no-field-label"], ) director_output = gr.Textbox( value=EMPTY_DIRECTOR_LOG, label="Director Log", lines=6, interactive=False, elem_classes=["no-field-label"], ) with gr.Accordion("Trace / Debug", open=False): trace_output = gr.Textbox( value=EMPTY_TRACE, label="Trace JSON Preview", lines=10, interactive=False, elem_classes=["no-field-label"], ) trace_download = gr.DownloadButton( "Download Trace JSON", value=None, elem_classes=["cue-action"], ) with gr.Accordion("Backend", open=False): backend_select = gr.Dropdown( choices=BACKEND_CHOICES, value=DEFAULT_ACTOR_ENGINE, label="Actor Engine", interactive=True, ) director_mode_select = gr.Dropdown( choices=DIRECTOR_MODE_CHOICES, value="deterministic", label="Director Mode", interactive=True, ) with gr.Row(): max_new_tokens_input = gr.Slider( minimum=16, maximum=160, value=DEFAULT_MAX_NEW_TOKENS, step=8, label="Max New Tokens", interactive=True, ) temperature_input = gr.Slider( minimum=0.0, maximum=1.5, value=DEFAULT_TEMPERATURE, step=0.1, label="Temperature", interactive=True, ) deterministic_full_act_input = gr.Checkbox( value=False, label="Force deterministic playback for local-model full-act runs", interactive=True, ) warm_up_button = gr.Button("Warm up OpenBMB", elem_classes=["cue-action"]) backend_output = gr.Textbox( value=EMPTY_BACKEND, label="Model Settings", lines=8, interactive=False, elem_classes=["no-field-label"], ) create_event = create_button.click( create_show, inputs=[ premise_input, session_state, show_length_select, backend_select, director_mode_select, max_new_tokens_input, temperature_input, voice_mode_select, ], outputs=[ session_state, stage_output, transcript_output, latest_tts_payload_output, edge_tts_audio_output, tts_status_output, agent_state_output, director_output, trace_summary_output, trace_output, trace_download, backend_output, ], ) run_one_event = run_one_button.click( advance_one_beat, inputs=[ session_state, backend_select, director_mode_select, max_new_tokens_input, temperature_input, voice_mode_select, ], outputs=[ session_state, stage_output, transcript_output, latest_tts_payload_output, edge_tts_audio_output, tts_status_output, agent_state_output, director_output, trace_summary_output, trace_output, trace_download, backend_output, ], ) run_full_event = run_full_button.click( advance_full_act, inputs=[ session_state, backend_select, director_mode_select, max_new_tokens_input, temperature_input, deterministic_full_act_input, voice_mode_select, ], outputs=[ session_state, stage_output, transcript_output, latest_tts_payload_output, edge_tts_audio_output, tts_status_output, agent_state_output, director_output, trace_summary_output, trace_output, trace_download, backend_output, ], ) throw_prop_event = throw_prop_button.click( throw_audience_prop, inputs=[ session_state, prop_input, backend_select, director_mode_select, max_new_tokens_input, temperature_input, voice_mode_select, ], outputs=[ session_state, stage_output, transcript_output, latest_tts_payload_output, edge_tts_audio_output, tts_status_output, agent_state_output, director_output, trace_summary_output, trace_output, trace_download, backend_output, ], ) summon_actor_event = summon_actor_button.click( summon_audience_actor, inputs=[ session_state, actor_input, backend_select, director_mode_select, max_new_tokens_input, temperature_input, voice_mode_select, ], outputs=[ session_state, stage_output, transcript_output, latest_tts_payload_output, edge_tts_audio_output, tts_status_output, agent_state_output, director_output, trace_summary_output, trace_output, trace_download, backend_output, ], ) request_finale_event = request_finale_button.click( request_audience_finale, inputs=[ session_state, backend_select, director_mode_select, max_new_tokens_input, temperature_input, voice_mode_select, ], outputs=[ session_state, stage_output, transcript_output, latest_tts_payload_output, edge_tts_audio_output, tts_status_output, agent_state_output, director_output, trace_summary_output, trace_output, trace_download, backend_output, ], ) warm_up_button.click( warm_up_backend, inputs=[session_state, max_new_tokens_input, temperature_input], outputs=[session_state, director_output, trace_summary_output, trace_output, trace_download, backend_output], ) reset_button.click( reset_show, outputs=[ session_state, premise_input, prop_input, actor_input, stage_output, transcript_output, latest_tts_payload_output, edge_tts_audio_output, tts_status_output, agent_state_output, director_output, trace_summary_output, trace_output, trace_download, show_length_select, backend_select, director_mode_select, max_new_tokens_input, temperature_input, deterministic_full_act_input, voice_mode_select, auto_speak_checkbox, backend_output, ], ) app.load( None, outputs=[tts_status_output], js=REGISTER_BROWSER_TTS_JS, ) voice_mode_select.change( None, inputs=[voice_mode_select], outputs=[tts_status_output], js=r""" (mode) => { if (mode === "edge_character") { window.aiPuppetEdgeAudio = window.aiPuppetEdgeAudio || { queue: [], current: null, currentSrc: "", playing: false, lastSeenSrc: "", lastSeenAt: 0, completedSrcAt: {}, drainTimer: null, interLineDelayMs: 650, observerReady: false, }; const edgeStatus = (html) => { const status = document.querySelector(".voice-status"); if (status) status.outerHTML = html; }; const edgePauseNativePlayers = () => { document.querySelectorAll(".edge-audio-player audio").forEach((audio) => { audio.pause(); audio.currentTime = 0; }); }; const edgeDrainQueue = () => { const state = window.aiPuppetEdgeAudio; if (state.drainTimer) { clearTimeout(state.drainTimer); state.drainTimer = null; } if (state.playing || state.queue.length === 0) return; const task = state.queue.shift(); state.playing = true; state.currentSrc = task.src; edgePauseNativePlayers(); const audio = new Audio(task.src); audio.preload = "auto"; state.current = audio; audio.onended = () => { state.playing = false; state.current = null; state.completedSrcAt[task.src] = Date.now(); state.currentSrc = ""; state.drainTimer = setTimeout(edgeDrainQueue, state.interLineDelayMs); }; audio.onerror = () => { state.playing = false; state.current = null; state.currentSrc = ""; edgeStatus('
Edge TTS playback failed. Use Browser TTS or retry the line.
'); state.drainTimer = setTimeout(edgeDrainQueue, state.interLineDelayMs); }; const playResult = audio.play(); if (playResult && typeof playResult.catch === "function") { playResult.catch(() => { state.playing = false; state.current = null; state.currentSrc = ""; edgeStatus('
Edge TTS is ready. Browser blocked autoplay; click the page once and run the next line.
'); }); } }; const edgeEnqueueSrc = (src) => { // Gradio's mounted audio element is the reliable autoplay path for Edge TTS. // Full Act pacing waits between generated files, so this observer no longer // starts a separate Audio() object that browsers may block. return; if (!src) return; const absoluteSrc = new URL(src, window.location.href).href; const state = window.aiPuppetEdgeAudio; const now = Date.now(); if (state.lastSeenSrc === absoluteSrc && now - state.lastSeenAt < 1200) return; if (state.currentSrc === absoluteSrc) return; if (state.queue.some((task) => task.src === absoluteSrc)) return; if (state.completedSrcAt[absoluteSrc] && now - state.completedSrcAt[absoluteSrc] < 30000) return; state.lastSeenSrc = absoluteSrc; state.lastSeenAt = now; edgePauseNativePlayers(); state.queue.push({ src: absoluteSrc }); edgeStatus(state.playing ? '
Edge TTS queued next line.
' : '
Edge TTS playing latest line.
'); edgeDrainQueue(); }; const edgeScanAudio = () => { const audio = document.querySelector(".edge-audio-player audio"); const source = document.querySelector(".edge-audio-player source"); const src = (audio && (audio.currentSrc || audio.src)) || (source && source.src) || ""; edgeEnqueueSrc(src); }; window.aiPuppetEdgeAudio.enqueueSrc = edgeEnqueueSrc; window.aiPuppetEdgeAudio.clear = () => { const state = window.aiPuppetEdgeAudio; state.queue = []; state.playing = false; state.currentSrc = ""; state.lastSeenSrc = ""; state.completedSrcAt = {}; if (state.drainTimer) { clearTimeout(state.drainTimer); state.drainTimer = null; } if (state.current) { state.current.pause(); state.current.currentTime = 0; state.current = null; } edgePauseNativePlayers(); }; if (!window.aiPuppetEdgeAudio.observerReady) { const observer = new MutationObserver(() => edgeScanAudio()); observer.observe(document.body, { attributes: true, attributeFilter: ["src"], childList: true, subtree: true, }); window.aiPuppetEdgeAudio.observerReady = true; } document.body.dataset.edgeAudioQueueReady = "true"; setTimeout(edgeScanAudio, 250); return '
Edge TTS will generate audio for each latest line when available.
'; } if (mode !== "off") { if (!("speechSynthesis" in globalThis) || typeof SpeechSynthesisUtterance === "undefined") { return '
Browser TTS unavailable. The show still works without voice.
'; } if (globalThis.speechSynthesis.getVoices) globalThis.speechSynthesis.getVoices(); try { const unlock = new SpeechSynthesisUtterance(" "); unlock.volume = 0; unlock.rate = 1; globalThis.speechSynthesis.cancel(); globalThis.speechSynthesis.speak(unlock); } catch (_error) { // Some browsers ignore silent unlock utterances; normal speaking still works. } const status = mode === "narrator" ? '
Browser TTS narrator-only mode is ready.
' : '
Browser TTS character voices are ready.
'; return status; } if ("speechSynthesis" in globalThis) globalThis.speechSynthesis.cancel(); return '
Voice mode is off.
'; } """, ) auto_speak_checkbox.change( None, inputs=[voice_mode_select, auto_speak_checkbox], outputs=[tts_status_output], js=r""" (mode, autoSpeak) => { if (mode === "off") { return autoSpeak ? '
Auto-speak is ready, but voice mode is off.
' : '
Auto-speak is off.
'; } return autoSpeak ? '
Auto-speak will read each new line.
' : '
Auto-speak is off. Use Speak Latest Line manually.
'; } """, ) speak_latest_button.click( None, inputs=[voice_mode_select, latest_tts_payload_output], outputs=[tts_status_output], js=BROWSER_TTS_JS, ) preview_actor_voice_button.click( None, inputs=[voice_mode_select, latest_tts_payload_output], outputs=[tts_status_output], js=r""" (mode, payloadJson) => { const activeMode = mode === "off" ? "character" : mode; const payload = payloadJson && payloadJson !== "{}" ? payloadJson : JSON.stringify({ speaker: "Warm Puppet", text: "Previewing actor voice.", style: { label: "Warm Puppet", pitch: 1.04, rate: 0.96, volume: 0.9, voiceNameHints: ["Google US English", "Samantha", "Jenny", "Aria"], }, narratorStyle: { label: "Narrator", pitch: 0.96, rate: 0.88, volume: 0.82, voiceNameHints: ["Daniel", "Alex", "Google UK English Male", "Microsoft Guy", "Google US English"], }, }); const speakLatest = %s; return speakLatest(activeMode, payload, "actor"); } """ % BROWSER_TTS_JS, ) preview_narrator_voice_button.click( None, inputs=[voice_mode_select, latest_tts_payload_output], outputs=[tts_status_output], js=r""" (_mode, payloadJson) => { const payload = payloadJson && payloadJson !== "{}" ? payloadJson : JSON.stringify({ speaker: "Narrator", text: "Previewing narrator voice.", style: { label: "Warm Puppet", pitch: 1.04, rate: 0.96, volume: 0.9, voiceNameHints: ["Google US English", "Samantha", "Jenny", "Aria"], }, narratorStyle: { label: "Narrator", pitch: 0.96, rate: 0.88, volume: 0.82, voiceNameHints: ["Daniel", "Alex", "Google UK English Male", "Microsoft Guy", "Google US English"], }, }); const speakLatest = %s; return speakLatest("narrator", payload, "narrator"); } """ % BROWSER_TTS_JS, ) stop_speaking_button.click( None, outputs=[tts_status_output], js=STOP_SPEAKING_JS, ) reset_button.click( None, outputs=[tts_status_output], js=STOP_SPEAKING_JS, ) latest_tts_payload_output.change( None, inputs=[voice_mode_select, auto_speak_checkbox, latest_tts_payload_output], outputs=[tts_status_output], js=AUTO_BROWSER_TTS_JS, ) for event in [ create_event, run_one_event, run_full_event, throw_prop_event, summon_actor_event, request_finale_event, ]: event.then( None, inputs=[voice_mode_select, auto_speak_checkbox, latest_tts_payload_output], outputs=[tts_status_output], js=AUTO_BROWSER_TTS_JS, ) if __name__ == "__main__": # For live reload on save (CSS in CUSTOM_CSS, layout, handlers), run: # uv run gradio app.py # Spaces and plain `uv run python app.py` stay non-reloading. app.launch(css=CUSTOM_CSS, theme=THEATER_THEME, ssr_mode=False)