diff --git a/README.md b/README.md index 2680887..bc42a82 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,17 @@ Drei lokale Modelle, verbunden über eine [Pipecat](https://github.com/pipecat-a |---|---|---| | Spracherkennung | Nemotron ASR (streaming) | MLX, on-device | | Sprachmodell | Qwen 3.5 | über natives Ollama `/api/chat` | -| Sprachausgabe | Pocket TTS, Stimme „alba" | MLX, on-device | +| Sprachausgabe | Pocket TTS, 26 deutsche Stimmen wählbar | MLX, on-device | Der Browser spricht per WebRTC direkt mit einem FastAPI-Server auf `localhost:7860`. Der Server ist bewusst nur lokal erreichbar: Host- und Origin-Prüfung auf jedem Request, strikte Content-Security-Policy, keine offenen Ports nach außen. +Die Stimme lässt sich im UI per Dropdown wählen (`/api/voices` listet alle +26, Auswahl wird im Browser gemerkt). Jede Stimme wird beim ersten Gebrauch +lazy geladen und danach für die Laufzeit des Prozesses gecacht. + ## Starten ```bash @@ -42,6 +46,7 @@ gezogen sein. Die Seite öffnen, Mikrofon erlauben, sprechen. | `ASTRA_TTS_LANGUAGE` | `german` | | `ASTRA_VOICE` | `alba` | | `ASTRA_PORT` | `7860` | +| `ASTRA_TAILNET_HOST` | *(leer)* — z. B. `minim4-1.tail0f2cb2.ts.net` | ## Tests @@ -50,9 +55,25 @@ uv run pytest uv run ruff check . ``` +## Im Tailnet freigeben + +Standardmäßig nur `localhost` erreichbar. Für Zugriff von einem anderen +Gerät im selben Tailscale-Netz: + +```bash +tailscale serve --bg 7860 +ASTRA_TAILNET_HOST="$(tailscale status --json | python3 -c 'import json,sys;print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')" \ + uv run python -m astra.server +``` + +Danach ist die Seite unter `https:///` erreichbar (Port 443, +implizit — Tailscale terminiert TLS und proxyt auf 7860). Host- und +Origin-Prüfung lassen dann zusätzlich diesen einen Hostnamen durch. + ## Sicherheit -- Nur `localhost`/`127.0.0.1` erreichbar, alle anderen Hosts bekommen 403 +- Nur `localhost`/`127.0.0.1` (bzw. der optionale Tailnet-Host) erreichbar, + alle anderen Hosts bekommen 403 - POST-Requests werden gegen den erwarteten Origin geprüft - `think` ist im Ollama-Request hart auf `false` gesetzt — die Pipeline wirft, falls das Modell trotzdem Denkausgabe liefert diff --git a/astra/core.py b/astra/core.py index 27406c6..d99ca40 100644 --- a/astra/core.py +++ b/astra/core.py @@ -13,6 +13,7 @@ class Settings: voice: str = "alba" port: int = 7860 context_tokens: int = 4096 + tailnet_host: str | None = None @classmethod def from_env(cls): @@ -23,9 +24,41 @@ class Settings: tts_language=os.getenv("ASTRA_TTS_LANGUAGE", cls.tts_language), voice=os.getenv("ASTRA_VOICE", cls.voice), port=int(os.getenv("ASTRA_PORT", cls.port)), + tailnet_host=os.getenv("ASTRA_TAILNET_HOST", cls.tailnet_host), ) +VOICES = ( + {"name": "alba", "display_name": "Alba", "gender": "weiblich"}, + {"name": "anna", "display_name": "Anna", "gender": "weiblich"}, + {"name": "azelma", "display_name": "Azelma", "gender": "weiblich"}, + {"name": "bill_boerst", "display_name": "Bill Boerst", "gender": "männlich"}, + {"name": "caro_davy", "display_name": "Caro Davy", "gender": "weiblich"}, + {"name": "charles", "display_name": "Charles", "gender": "männlich"}, + {"name": "cosette", "display_name": "Cosette", "gender": "weiblich"}, + {"name": "eponine", "display_name": "Eponine", "gender": "weiblich"}, + {"name": "estelle", "display_name": "Estelle", "gender": "weiblich"}, + {"name": "eve", "display_name": "Eve", "gender": "weiblich"}, + {"name": "fantine", "display_name": "Fantine", "gender": "weiblich"}, + {"name": "george", "display_name": "George", "gender": "männlich"}, + {"name": "giovanni", "display_name": "Giovanni", "gender": "männlich"}, + {"name": "jane", "display_name": "Jane", "gender": "weiblich"}, + {"name": "javert", "display_name": "Javert", "gender": "männlich"}, + {"name": "jean", "display_name": "Jean", "gender": "männlich"}, + {"name": "juergen", "display_name": "Juergen", "gender": "männlich"}, + {"name": "lola", "display_name": "Lola", "gender": "weiblich"}, + {"name": "marius", "display_name": "Marius", "gender": "männlich"}, + {"name": "mary", "display_name": "Mary", "gender": "weiblich"}, + {"name": "michael", "display_name": "Michael", "gender": "männlich"}, + {"name": "paul", "display_name": "Paul", "gender": "männlich"}, + {"name": "peter_yearsley", "display_name": "Peter Yearsley", "gender": "männlich"}, + {"name": "rafael", "display_name": "Rafael", "gender": "männlich"}, + {"name": "stuart_bell", "display_name": "Stuart Bell", "gender": "männlich"}, + {"name": "vera", "display_name": "Vera", "gender": "weiblich"}, +) +VOICE_NAMES = frozenset(voice["name"] for voice in VOICES) + + SYSTEM_PROMPT = ( "Du bist Astra, ein freundlicher deutschsprachiger Gesprächsassistent. " "Antworte natürlich und knapp, normalerweise in ein bis drei kurzen Sätzen. " @@ -76,5 +109,8 @@ def build_request(settings: Settings, messages: list[dict]) -> dict: } -def local_origin_allowed(origin: str, port: int = 7860) -> bool: - return origin in {f"http://localhost:{port}", f"http://127.0.0.1:{port}"} +def local_origin_allowed(origin: str, port: int = 7860, tailnet_host: str | None = None) -> bool: + allowed = {f"http://localhost:{port}", f"http://127.0.0.1:{port}"} + if tailnet_host: + allowed.add(f"https://{tailnet_host}") + return origin in allowed diff --git a/astra/inference.py b/astra/inference.py index f1f81fa..0d79c16 100644 --- a/astra/inference.py +++ b/astra/inference.py @@ -35,7 +35,7 @@ class Models: self.tts_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="astra-tts") self.stt = None self.tts = None - self.voice = None + self.voice_cache: dict[str, dict] = {} def load_stt(self): from mlx_audio.stt import load @@ -51,7 +51,12 @@ class Models: from pocket_tts import TTSModel self.tts = TTSModel.load_model(language=self.settings.tts_language) - self.voice = self.tts.get_state_for_audio_prompt(self.settings.voice) + + def get_voice(self, name: str) -> dict: + """Compute (or reuse) one voice's conditioning state. Runs on tts_executor.""" + if name not in self.voice_cache: + self.voice_cache[name] = self.tts.get_state_for_audio_prompt(name) + return self.voice_cache[name] async def close(self): await asyncio.to_thread(self.stt_executor.shutdown, wait=True, cancel_futures=True) diff --git a/astra/prepare.py b/astra/prepare.py index 1d02b03..4a60e21 100644 --- a/astra/prepare.py +++ b/astra/prepare.py @@ -27,13 +27,14 @@ async def main(): await on_executor(models.stt_executor, recognizer.push, bytes(16000), True) print("Nemotron bereit. Lade deutsche Pocket-TTS-Stimme …", flush=True) await on_executor(models.tts_executor, models.load_tts) + voice_state = await on_executor(models.tts_executor, models.get_voice, settings.voice) def synthesize(): start = time.monotonic() chunks = [] first_ms = None for chunk in models.tts.generate_audio_stream( - models.voice, + voice_state, "Hallo, ich bin Astra. Ich laufe vollständig auf deinem Mac.", copy_state=True, ): diff --git a/astra/server.py b/astra/server.py index f18c7e3..5786125 100644 --- a/astra/server.py +++ b/astra/server.py @@ -15,9 +15,16 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from loguru import logger -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator -from astra.core import SYSTEM_PROMPT, Settings, build_request, local_origin_allowed +from astra.core import ( + SYSTEM_PROMPT, + VOICE_NAMES, + VOICES, + Settings, + build_request, + local_origin_allowed, +) from astra.inference import Models, on_executor ROOT = Path(__file__).resolve().parent.parent @@ -26,13 +33,21 @@ ROOT = Path(__file__).resolve().parent.parent class Offer(BaseModel): sdp: str = Field(min_length=10, max_length=65536) type: Literal["offer"] + voice: str | None = None + + @field_validator("voice") + @classmethod + def voice_must_be_known(cls, value: str | None) -> str | None: + if value is not None and value not in VOICE_NAMES: + raise ValueError("Unbekannte Stimme") + return value class Disconnect(BaseModel): pc_id: str = Field(max_length=100) -async def run_voice(connection, models, config): +async def run_voice(connection, models, config, voice_state, voice_name): from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.frames.frames import ( @@ -107,7 +122,7 @@ async def run_voice(connection, models, config): ) stt = NemotronSTTService(models, notify) llm = NativeOllamaService(config, notify) - tts = LocalPocketTTSService(models) + tts = LocalPocketTTSService(models, voice_state, voice_name) pipeline = Pipeline( [ transport.input(), @@ -192,6 +207,7 @@ def create_app(config=None, *, load_models=True): await on_executor(models.stt_executor, models.load_stt) status["stage"] = "Deutsche Stimme wird geladen" await on_executor(models.tts_executor, models.load_tts) + await on_executor(models.tts_executor, models.get_voice, config.voice) status["stage"] = "Qwen wird vorbereitet" async with httpx.AsyncClient(timeout=180) as client: payload = build_request(config, [{"role": "user", "content": "Sage Hallo."}]) @@ -237,10 +253,13 @@ def create_app(config=None, *, load_models=True): @app.middleware("http") async def local_only(request: Request, call_next): - if request.url.hostname not in {"localhost", "127.0.0.1"}: + allowed_hosts = {"localhost", "127.0.0.1"} + if config.tailnet_host: + allowed_hosts.add(config.tailnet_host) + if request.url.hostname not in allowed_hosts: return JSONResponse({"detail": "Nur lokal erreichbar"}, status_code=403) if request.method == "POST" and not local_origin_allowed( - request.headers.get("origin", ""), config.port + request.headers.get("origin", ""), config.port, config.tailnet_host ): return JSONResponse({"detail": "Ungültiger Ursprung"}, status_code=403) response = await call_next(request) @@ -261,6 +280,10 @@ def create_app(config=None, *, load_models=True): async def health(): return {**status, "busy": bool(sessions), "model": config.model, "thinking": False} + @app.get("/api/voices") + async def voices(): + return {"voices": list(VOICES), "default": config.voice} + @app.post("/api/offer") async def offer(body: Offer): if not status["ready"]: @@ -270,6 +293,8 @@ def create_app(config=None, *, load_models=True): raise HTTPException( 409, "Ein Gespräch läuft bereits. Beende es im anderen Fenster." ) + voice_name = body.voice or config.voice + voice_state = await on_executor(models.tts_executor, models.get_voice, voice_name) from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection connection = SmallWebRTCConnection(ice_servers=[], connection_timeout_secs=20) @@ -281,7 +306,7 @@ def create_app(config=None, *, load_models=True): async def session(): try: - await run_voice(connection, models, config) + await run_voice(connection, models, config, voice_state, voice_name) except asyncio.CancelledError: raise except Exception as exc: diff --git a/astra/services.py b/astra/services.py index 1429fe8..fbefb19 100644 --- a/astra/services.py +++ b/astra/services.py @@ -222,18 +222,19 @@ def next_chunk(stream): class LocalPocketTTSService(TTSService): - def __init__(self, models: Models): + def __init__(self, models: Models, voice_state: dict, voice_name: str): super().__init__( push_start_frame=True, push_stop_frames=True, - settings=TTSSettings(model="pocket-tts", voice=models.settings.voice, language="de"), + settings=TTSSettings(model="pocket-tts", voice=voice_name, language="de"), ) self.models = models + self.voice_state = voice_state async def run_tts(self, text: str, context_id: str): import torch - stream = self.models.tts.generate_audio_stream(self.models.voice, text, copy_state=True) + stream = self.models.tts.generate_audio_stream(self.voice_state, text, copy_state=True) async def audio(): try: diff --git a/tests/test_server.py b/tests/test_server.py index 892bd41..86f3f0f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -34,6 +34,27 @@ def test_unready_returns_actionable_status_and_invalid_sdp_is_rejected(): assert response.json()["detail"] +def test_voices_endpoint_lists_selectable_voices(): + with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client: + response = client.get("/api/voices") + assert response.status_code == 200 + body = response.json() + assert body["default"] == "alba" + names = {voice["name"] for voice in body["voices"]} + assert "alba" in names + assert all({"name", "display_name", "gender"} <= voice.keys() for voice in body["voices"]) + + +def test_offer_rejects_unknown_voice(): + with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client: + response = client.post( + "/api/offer", + headers={"Origin": "http://localhost:7860"}, + json={"sdp": "a" * 20, "type": "offer", "voice": "not-a-real-voice"}, + ) + assert response.status_code == 422 + + def test_disconnect_is_idempotent_and_host_is_checked(): with TestClient(create_app(load_models=False), base_url="http://localhost:7860") as client: response = client.post( diff --git a/web/app.js b/web/app.js index 461e168..2efa06d 100644 --- a/web/app.js +++ b/web/app.js @@ -61,11 +61,33 @@ async function waitIce(pc) { check(); }); } +async function loadVoices() { + try { + const response = await fetch("/api/voices", {signal: AbortSignal.timeout(5000)}); + const data = await response.json(); + const groups = {weiblich: document.createElement("optgroup"), männlich: document.createElement("optgroup")}; + groups.weiblich.label = "Weiblich"; + groups.männlich.label = "Männlich"; + for (const voice of data.voices) { + const option = document.createElement("option"); + option.value = voice.name; + option.textContent = voice.display_name; + groups[voice.gender]?.append(option); + } + $("voice").replaceChildren(groups.weiblich, groups.männlich); + const saved = localStorage.getItem("astra-voice"); + $("voice").value = data.voices.some(v => v.name === saved) ? saved : data.default; + } catch { showError("Stimmenliste konnte nicht geladen werden."); } +} +$("voice").addEventListener("change", () => { + try { localStorage.setItem("astra-voice", $("voice").value); } catch { /* ignore */ } +}); async function connect() { if (connecting || peer) return; connecting = true; $("error").hidden = true; $("connect").disabled = true; + $("voice").disabled = true; state("Mikrofon wird verbunden …"); try { if (!navigator.mediaDevices?.getUserMedia) throw new Error("Bitte diese Seite unter http://localhost:7860 öffnen."); @@ -94,7 +116,7 @@ async function connect() { }; await pc.setLocalDescription(await pc.createOffer()); await waitIce(pc); - const answer = await request("/api/offer", {sdp: pc.localDescription.sdp, type: "offer"}); + const answer = await request("/api/offer", {sdp: pc.localDescription.sdp, type: "offer", voice: $("voice").value}); pcId = answer.pc_id; await pc.setRemoteDescription({sdp: answer.sdp, type: answer.type}); $("connect").textContent = "Gespräch beenden"; @@ -129,6 +151,7 @@ async function disconnect() { output.pause(); output.srcObject = null; muted = false; + $("voice").disabled = !ready; $("mute").hidden = true; $("mute").setAttribute("aria-pressed", "false"); $("mute").textContent = "Mikrofon pausieren"; @@ -167,12 +190,14 @@ async function poll() { ready = status.ready; if (!peer && !connecting) { $("connect").disabled = !ready; + $("voice").disabled = !ready; state(ready ? "Bereit, wenn du es bist." : status.stage); if (status.error) showError(status.error); } } catch { ready = false; - if (!peer) { $("connect").disabled = true; state("Lokaler Server nicht erreichbar."); } + if (!peer) { $("connect").disabled = true; $("voice").disabled = true; state("Lokaler Server nicht erreichbar."); } } finally { setTimeout(poll, ready ? 5000 : 1500); } } void poll(); +void loadVoices(); diff --git a/web/index.html b/web/index.html index 9b38013..52ce380 100644 --- a/web/index.html +++ b/web/index.html @@ -18,6 +18,10 @@

Modelle werden vorbereitet …

+
+ + +

Mikrofon wird erst nach dem Start freigegeben.

diff --git a/web/style.css b/web/style.css index 6fdd19f..5dc943f 100644 --- a/web/style.css +++ b/web/style.css @@ -1,3 +1,3 @@ @font-face{font-family:system;src:local("Avenir Next")} :root{color-scheme:dark;--bg:#111820;--panel:#19222b;--text:#eef2ef;--muted:#a3b1b8;--accent:#b4e5cc;--border:#303c44} -*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 50% 25%,#1c2d34 0%,var(--bg) 58%);color:var(--text);font-family:system,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}main{max-width:1060px;margin:auto;padding:36px 48px 25px}header{display:flex;justify-content:space-between;align-items:center}.brand{text-decoration:none;color:var(--text);font-size:30px;letter-spacing:-1.5px;font-weight:600}.star{color:var(--accent);font-size:35px;vertical-align:middle;margin-right:7px}.local{display:flex;align-items:center;gap:9px;font-size:12px;color:#c3d3ce;border:1px solid var(--border);padding:9px 13px;border-radius:30px}.local i{height:6px;width:6px;background:var(--accent);border-radius:50%}.conversation{text-align:center;padding:60px 0 34px}.eyebrow{font-size:10px;font-weight:600;letter-spacing:2.8px;color:var(--accent);margin-bottom:17px}h1{font-size:clamp(32px,5vw,49px);font-weight:500;letter-spacing:-1.8px;margin:0 0 13px}.description{font-size:14px;color:var(--muted);margin:0}.orb-wrap{position:relative;width:210px;height:210px;display:grid;place-items:center;margin:31px auto 15px}.orb{width:145px;height:145px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at 30% 25%,#a3e1cf77,#4e938a22 55%,#172b3544);border:1px solid #a3d3c35c;box-shadow:inset 0 0 38px #92e1ce19,0 0 65px #84c5b215}.orbit{position:absolute;inset:6px;border:1px solid #9acdb01b;border-radius:50%;box-shadow:0 0 0 16px #98cdb104}.wave{height:40px;display:flex;align-items:center;gap:5px}.wave i{display:block;width:4px;height:13px;border-radius:5px;background:#d0f3e4;transition:height .3s}.wave i:nth-child(2),.wave i:nth-child(6){height:22px}.wave i:nth-child(3),.wave i:nth-child(5){height:32px}.wave i:nth-child(4){height:41px}body[data-state="speaking"] .wave i,body[data-state="listening"] .wave i{animation:voice 1.2s ease-in-out infinite alternate}.wave i:nth-child(2n){animation-delay:-.8s!important}.wave i:nth-child(3n){animation-delay:-.4s!important}@keyframes voice{to{height:8px;opacity:.6}}.live-status{display:flex;justify-content:center;align-items:center;gap:8px;height:27px}.live-status p{font-size:14px;margin:0}#state-dot{height:6px;width:6px;border-radius:50%;background:var(--muted)}body[data-state="listening"] #state-dot,body[data-state="speaking"] #state-dot{background:var(--accent)}.partial{color:var(--muted);font-size:13px;min-height:22px;max-width:640px;margin:13px auto 17px;overflow-wrap:anywhere}.controls{display:flex;justify-content:center;gap:12px;flex-wrap:wrap}button{font:inherit;cursor:pointer;border:0;transition:background .2s,transform .2s}button:active{transform:translateY(1px)}button:focus-visible,a:focus-visible{outline:3px solid #d5f3e5;outline-offset:5px}.primary{background:var(--accent);color:#162c25;border-radius:9px;font-size:14px;font-weight:600;padding:15px 24px;min-width:211px}.primary:hover{background:#cef2df}.primary span{margin-right:9px}.primary:disabled{background:#43594f;color:#bfcbc4;cursor:wait}.secondary{border:1px solid #576b68;border-radius:9px;padding:14px 18px;background:transparent;color:var(--text);font-size:13px}.secondary[aria-pressed="true"]{background:#553a38;border-color:#c2887c}.hint{font-size:11px;color:var(--muted);margin:16px 0 0}.error{color:#ffd5cd;background:#4a2c2a;border:1px solid #8e564f;border-radius:9px;padding:12px;max-width:640px;margin:15px auto;font-size:13px;overflow-wrap:anywhere}.transcript{border:1px solid var(--border);border-radius:14px;background:#19222b9c;padding:22px 26px}.section-top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);padding-bottom:16px}h2{margin:0;font-size:13px;font-weight:500}.text-button{color:var(--muted);background:none;font-size:11px;padding:7px}.text-button:hover{color:var(--text)}#messages{max-height:330px;overflow:auto;scroll-behavior:smooth;padding-top:5px}.empty{font-size:12px;color:var(--muted);line-height:1.9;text-align:center;padding:18px 0 12px}.message{padding:15px 0 7px}.message .speaker{font-size:10px;letter-spacing:1px;font-weight:600;text-transform:uppercase;color:var(--accent)}.message.user .speaker{color:#b1bdcb}.message p{font-size:14px;line-height:1.65;margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere}.message small{color:var(--muted);font-size:11px}footer{display:flex;justify-content:space-between;gap:15px;margin-top:21px;color:var(--muted);font-size:10px}footer b{padding:0 6px;font-weight:400}.credits{display:flex;align-items:center}footer a{color:var(--muted);text-decoration:none}footer a:hover{color:var(--accent)}[hidden]{display:none!important}@media(max-width:620px){main{padding:23px 20px}.conversation{padding-top:44px}.description{max-width:270px;margin:auto;line-height:1.6}.local{font-size:10px;padding:8px 10px}.transcript{padding:18px}footer{flex-direction:column;gap:8px}.orb-wrap{margin-top:21px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} +*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 50% 25%,#1c2d34 0%,var(--bg) 58%);color:var(--text);font-family:system,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}main{max-width:1060px;margin:auto;padding:36px 48px 25px}header{display:flex;justify-content:space-between;align-items:center}.brand{text-decoration:none;color:var(--text);font-size:30px;letter-spacing:-1.5px;font-weight:600}.star{color:var(--accent);font-size:35px;vertical-align:middle;margin-right:7px}.local{display:flex;align-items:center;gap:9px;font-size:12px;color:#c3d3ce;border:1px solid var(--border);padding:9px 13px;border-radius:30px}.local i{height:6px;width:6px;background:var(--accent);border-radius:50%}.conversation{text-align:center;padding:60px 0 34px}.eyebrow{font-size:10px;font-weight:600;letter-spacing:2.8px;color:var(--accent);margin-bottom:17px}h1{font-size:clamp(32px,5vw,49px);font-weight:500;letter-spacing:-1.8px;margin:0 0 13px}.description{font-size:14px;color:var(--muted);margin:0}.orb-wrap{position:relative;width:210px;height:210px;display:grid;place-items:center;margin:31px auto 15px}.orb{width:145px;height:145px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at 30% 25%,#a3e1cf77,#4e938a22 55%,#172b3544);border:1px solid #a3d3c35c;box-shadow:inset 0 0 38px #92e1ce19,0 0 65px #84c5b215}.orbit{position:absolute;inset:6px;border:1px solid #9acdb01b;border-radius:50%;box-shadow:0 0 0 16px #98cdb104}.wave{height:40px;display:flex;align-items:center;gap:5px}.wave i{display:block;width:4px;height:13px;border-radius:5px;background:#d0f3e4;transition:height .3s}.wave i:nth-child(2),.wave i:nth-child(6){height:22px}.wave i:nth-child(3),.wave i:nth-child(5){height:32px}.wave i:nth-child(4){height:41px}body[data-state="speaking"] .wave i,body[data-state="listening"] .wave i{animation:voice 1.2s ease-in-out infinite alternate}.wave i:nth-child(2n){animation-delay:-.8s!important}.wave i:nth-child(3n){animation-delay:-.4s!important}@keyframes voice{to{height:8px;opacity:.6}}.live-status{display:flex;justify-content:center;align-items:center;gap:8px;height:27px}.live-status p{font-size:14px;margin:0}#state-dot{height:6px;width:6px;border-radius:50%;background:var(--muted)}body[data-state="listening"] #state-dot,body[data-state="speaking"] #state-dot{background:var(--accent)}.partial{color:var(--muted);font-size:13px;min-height:22px;max-width:640px;margin:13px auto 17px;overflow-wrap:anywhere}.voice-picker{display:flex;justify-content:center;align-items:center;gap:10px;margin:0 0 18px}.voice-picker label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:1px}.voice-picker select{font:inherit;font-size:13px;background:var(--panel);color:var(--text);border:1px solid #576b68;border-radius:9px;padding:8px 12px;cursor:pointer}.voice-picker select:disabled{opacity:.5;cursor:wait}.voice-picker select:focus-visible{outline:3px solid #d5f3e5;outline-offset:3px}.controls{display:flex;justify-content:center;gap:12px;flex-wrap:wrap}button{font:inherit;cursor:pointer;border:0;transition:background .2s,transform .2s}button:active{transform:translateY(1px)}button:focus-visible,a:focus-visible{outline:3px solid #d5f3e5;outline-offset:5px}.primary{background:var(--accent);color:#162c25;border-radius:9px;font-size:14px;font-weight:600;padding:15px 24px;min-width:211px}.primary:hover{background:#cef2df}.primary span{margin-right:9px}.primary:disabled{background:#43594f;color:#bfcbc4;cursor:wait}.secondary{border:1px solid #576b68;border-radius:9px;padding:14px 18px;background:transparent;color:var(--text);font-size:13px}.secondary[aria-pressed="true"]{background:#553a38;border-color:#c2887c}.hint{font-size:11px;color:var(--muted);margin:16px 0 0}.error{color:#ffd5cd;background:#4a2c2a;border:1px solid #8e564f;border-radius:9px;padding:12px;max-width:640px;margin:15px auto;font-size:13px;overflow-wrap:anywhere}.transcript{border:1px solid var(--border);border-radius:14px;background:#19222b9c;padding:22px 26px}.section-top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);padding-bottom:16px}h2{margin:0;font-size:13px;font-weight:500}.text-button{color:var(--muted);background:none;font-size:11px;padding:7px}.text-button:hover{color:var(--text)}#messages{max-height:330px;overflow:auto;scroll-behavior:smooth;padding-top:5px}.empty{font-size:12px;color:var(--muted);line-height:1.9;text-align:center;padding:18px 0 12px}.message{padding:15px 0 7px}.message .speaker{font-size:10px;letter-spacing:1px;font-weight:600;text-transform:uppercase;color:var(--accent)}.message.user .speaker{color:#b1bdcb}.message p{font-size:14px;line-height:1.65;margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere}.message small{color:var(--muted);font-size:11px}footer{display:flex;justify-content:space-between;gap:15px;margin-top:21px;color:var(--muted);font-size:10px}footer b{padding:0 6px;font-weight:400}.credits{display:flex;align-items:center}footer a{color:var(--muted);text-decoration:none}footer a:hover{color:var(--accent)}[hidden]{display:none!important}@media(max-width:620px){main{padding:23px 20px}.conversation{padding-top:44px}.description{max-width:270px;margin:auto;line-height:1.6}.local{font-size:10px;padding:8px 10px}.transcript{padding:18px}footer{flex-direction:column;gap:8px}.orb-wrap{margin-top:21px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}