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)