diff --git a/.env.example b/.env.example index 948c075..bb218bd 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,9 @@ TWITCH_CLIENT_ID= # channel:read:predictions (or channel:manage:predictions) # Helix actions: moderator:manage:shoutouts (!so) channel:manage:broadcast (stream markers) TWITCH_EVENT_TOKEN= + +# Optional stable token for a Stream Deck or hotkey. Leave empty to keep the +# per-launch session token as the only key. When set, it is accepted as +# X-Cohost-Token only on POST /api/interrupt, /api/tts/mute, /api/ptt, +# /api/marker, /api/mood, and /api/profile. +COHOST_CONTROL_KEY= diff --git a/PLAN-v3.md b/PLAN-v3.md index 524c69e..74c67a6 100644 --- a/PLAN-v3.md +++ b/PLAN-v3.md @@ -251,33 +251,33 @@ Do these on a test stream or with the booth open before relying on the features. ### Phase 4: Booth quality-of-life (💡 suggestions) -- [ ] **V3-P4-01 💡 Stream Deck / hotkey friendly API** +- [x] **V3-P4-01 💡 Stream Deck / hotkey friendly API** - *Why:* every booth action is already an endpoint, but the per-launch token makes external buttons impossible. - *Do:* optional `COHOST_CONTROL_KEY` in `.env`; when set, `BoothGuard` also accepts it in `X-Cohost-Token` for a small allow-list (`/api/interrupt`, `/api/tts/mute`, `/api/ptt`, `/api/marker`, `/api/mood`, `/api/profile`). Document curl/Stream Deck "Website/API" examples. - *Files:* `src/security.py`, `src/main.py`, `.env.example`, `README.md`, `tests/test_api.py` - *Acceptance:* with the key set, `/api/interrupt` works with it and `/api/persona` still rejects it; without the key nothing changes. - *Depends on:* V3-P1-01 -- [ ] **V3-P4-02 💡 Voice and persona per profile** +- [x] **V3-P4-02 💡 Voice and persona per profile** - *Do:* allow `tts_voice`, `length_scale` and an optional `persona_file` in a profile; switching profile swaps them (voice download progress shown as now). - *Files:* `src/settings.py`, `src/orchestrator.py`, `config.yaml`, tests - *Acceptance:* switching to a profile with `tts_voice: en_US-kristin-medium` changes `health.voice`; invalid voices are rejected at load time. - *Depends on:* V3-P2-03 -- [ ] **V3-P4-03 💡 Session transcript export** +- [x] **V3-P4-03 💡 Session transcript export** - *Do:* append every transcript/chat/alert line to `data/sessions/.jsonl` (rotated, honours retention and Forget); dashboard "Download transcript" button. - *Files:* `src/orchestrator.py`, `src/memory.py`, `src/main.py`, dashboard, tests - *Acceptance:* a session produces a JSONL file; **Forget** removes that viewer's lines from it. - *Depends on:* V3-P3-02 -- [ ] **V3-P4-04 💡 Simple custom text commands** +- [x] **V3-P4-04 💡 Simple custom text commands** - *Why:* fixes the "commands that aren't !so" gap from V3-P1-02 in a useful way. - *Do:* `chat.custom_commands: {"!lurk": "enjoy the lurk {user}", "!discord": "…"}` posts fixed text (no model) with per-command cooldown; commands are added to the allowlist automatically. - *Files:* `src/settings.py`, `src/orchestrator.py`, `src/policy.py`, `config.yaml`, tests - *Acceptance:* `!lurk` posts the filled text once per cooldown; it never triggers a shoutout or a model call. - *Depends on:* V3-P1-02 -- [ ] **V3-P4-05 💡 Latency panel with history** +- [x] **V3-P4-05 💡 Latency panel with history** - *Why:* `timings` already captures STT, first token, first audio and chat send for the last reply only. - *Do:* keep a rolling 50-sample window and show p50/p95 per stage on the dashboard; warn when first-audio p95 > 2.5 s with a link to the VRAM tips. - *Files:* `src/orchestrator.py`, `src/health.py`, `src/web/static/app.js`, tests diff --git a/README.md b/README.md index f719442..a6ecb20 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,19 @@ Wake word needs `openwakeword` and never replaces hold-to-talk. Channel-point re The booth binds to localhost. `server.allow_remote` stays false unless you set it on purpose. Writes need `Content-Type: application/json` and the `X-Cohost-Token` from `GET /api/session` (same browser tab). A foreign `Origin` is rejected. The OBS overlay does not get that token and cannot change anything. +Optional `COHOST_CONTROL_KEY` in `.env` is a stable token for a Stream Deck or hotkey. When it is set, that value is accepted as `X-Cohost-Token` only on `POST /api/interrupt`, `/api/tts/mute`, `/api/ptt`, `/api/marker`, `/api/mood`, and `/api/profile`. `/api/persona` and every other write still need the per-launch session token. Leave the key empty and nothing changes. + +```powershell +curl.exe -X POST http://127.0.0.1:8080/api/interrupt -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{}" +curl.exe -X POST http://127.0.0.1:8080/api/tts/mute -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{\"mute\":true}" +curl.exe -X POST http://127.0.0.1:8080/api/ptt -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{\"down\":true}" +curl.exe -X POST http://127.0.0.1:8080/api/marker -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{}" +curl.exe -X POST http://127.0.0.1:8080/api/mood -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{\"mood\":\"hype\"}" +curl.exe -X POST http://127.0.0.1:8080/api/profile -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{\"name\":\"Focus game\"}" +``` + +On a Stream Deck Website button, set the URL to `http://127.0.0.1:8080/api/interrupt`, method POST, header `Content-Type: application/json`, header `X-Cohost-Token` to the control key, and body `{}`. + ## Backup `scripts/backup.ps1` zips `data/`, `persona.yaml`, and `config.yaml` into `backups/YYYYMMDD-HHMM.zip` and keeps the last 10. It never puts `.env` in the zip. `backups/` is gitignored. diff --git a/config.yaml b/config.yaml index 2e55f20..3bcd8f1 100644 --- a/config.yaml +++ b/config.yaml @@ -108,6 +108,13 @@ chat: ask_queue: false # Gap between mod !pixel clip commands. clip_cooldown_seconds: 60 + # Fixed text replies. {user} is the chatter. No model and no shoutout. + # Each command has its own timer of custom_command_cooldown_seconds. + # custom_commands: + # "!lurk": "enjoy the lurk {user}" + # "!discord": "discord is linked in the panels" + custom_commands: {} + custom_command_cooldown_seconds: 30 memory: chat_context_lines: 20 @@ -154,6 +161,7 @@ safety: allow_links: false # Named booth presets. POST /api/profile applies one and stores the name in data/booth.json. +# A profile may also set tts_voice (one of tts.voices), length_scale, and persona_file. profiles: "Just chatting": mood: hype diff --git a/src/core/chat_flow.py b/src/core/chat_flow.py index aefc6e1..1f082c0 100644 --- a/src/core/chat_flow.py +++ b/src/core/chat_flow.py @@ -18,7 +18,7 @@ sanitize_reply, streamer_smalltalk_fallback, ) -from src.policy import chat_action, control_command_names, mention_waits, pixel_control +from src.policy import chat_action, control_command_names, custom_reply, mention_waits, pixel_control from src.safety import DEFLECT, fence_viewer_text, is_blocked from src.twitch.chat import ChatLine @@ -248,6 +248,11 @@ async def _consider_chat_locked(orch) -> bool: await orch._publish_queue() await orch._perform_chat(target, ignore_rate=False) return True + if custom_reply(target, orch.settings.custom_commands): + orch._drop_chat_target(target) + await orch._publish_queue() + await orch._perform_chat(target, ignore_rate=False) + return True action = chat_action(target, welcome_first_time=orch.settings.welcome_first_time) ok, why = orch._rate_ok(mention=action == "speak", user=target.user) if not ok: @@ -284,11 +289,21 @@ async def _perform_chat(orch, target: ChatLine, *, ignore_rate: bool) -> None: orch._last_control_at = time.monotonic() orch._mark_reply(target.user) return - action = chat_action(target, welcome_first_time=orch.settings.welcome_first_time) command = (target.command or "").lower() if command in orch.settings.mod_commands and (not orch._can_run_mod_command(target)): await orch._note_skip("not a mod", target.user) return + filled = custom_reply(target, orch.settings.custom_commands) + if filled: + if not ignore_rate and not _custom_ready(orch, command): + await orch._note_skip("command cooldown", target.user) + return + orch.memory.remember("cohost_chat", orch._cohost_name(), filled, via="chat") + await orch._type_ack(filled) + orch._custom_at[command] = time.monotonic() + orch._mark_reply(target.user) + return + action = chat_action(target, welcome_first_time=orch.settings.welcome_first_time) if command == "!so": who = "" parts = target.text.split() @@ -446,11 +461,22 @@ def _control_names(orch) -> set[str]: return control_command_names(str(orch.persona.get("name") or ""), orch.settings.control_command) +def _custom_ready(orch, command: str) -> bool: + wait = float(getattr(orch.settings, "custom_command_cooldown_seconds", 30) or 0) + if wait <= 0: + return True + last = orch._custom_at.get(command, 0.0) + return time.monotonic() - last >= wait + + def _irc_commands(orch) -> list[str]: commands = list(orch.settings.chat_commands) for name in sorted(orch._control_names()): if name not in commands: commands.append(name) + for name in sorted(getattr(orch.settings, "custom_commands", {}) or {}): + if name not in commands: + commands.append(name) return commands diff --git a/src/core/lifecycle.py b/src/core/lifecycle.py index 4836555..d92d24f 100644 --- a/src/core/lifecycle.py +++ b/src/core/lifecycle.py @@ -79,6 +79,9 @@ def bind( "events_speak": bool(settings.events_speak), "post_voice_replies_to_chat": bool(settings.post_voice_replies_to_chat), "max_replies_per_minute": int(settings.max_replies_per_minute), + "tts_voice": settings.tts_voice, + "length_scale": float(settings.tts_length_scale), + "persona_file": str(orch.persona_path), } orch.booth = load_booth( orch.data_dir / "booth.json", @@ -153,6 +156,13 @@ def bind( "first_audio_ms": None, "chat_send_ms": None, } + orch.timing_samples = { + "stt_ms": deque(maxlen=50), + "first_token_ms": deque(maxlen=50), + "first_audio_ms": deque(maxlen=50), + "chat_send_ms": deque(maxlen=50), + } + orch._custom_at: dict[str, float] = {} def _refresh_prompts(orch) -> None: @@ -230,6 +240,20 @@ def _install_profile(orch, name: str, *, persist: bool) -> str: orch.settings.events_speak = bool(values["events_speak"]) orch.settings.post_voice_replies_to_chat = bool(values["post_voice_replies_to_chat"]) orch.settings.max_replies_per_minute = int(values["max_replies_per_minute"]) + orch.settings.tts_length_scale = float(values.get("length_scale", orch._runtime_base["length_scale"])) + voice = str(values.get("tts_voice") or orch._runtime_base["tts_voice"]) + if voice not in orch.settings.tts_voices: + raise ValueError(f"unknown voice: {voice}") + orch.settings.tts_voice = voice + raw_persona = str(values.get("persona_file") or orch._runtime_base["persona_file"]) + persona_path = Path(raw_persona) + if not persona_path.is_absolute(): + persona_path = _live("ROOT") / persona_path + if persona_path != Path(orch.persona_path): + if not persona_path.exists(): + raise ValueError(f"persona file not found: {persona_path}") + orch.persona_path = persona_path + orch.reload_persona() orch._refresh_prompts() if persist: orch._persist_booth() @@ -237,7 +261,10 @@ def _install_profile(orch, name: str, *, persist: bool) -> str: async def apply_profile(orch, name: str) -> str: + before_voice = orch.settings.tts_voice canonical = orch._install_profile(name, persist=True) + if orch.settings.tts_voice != before_voice: + await orch.set_voice(orch.settings.tts_voice, persist=False) await orch.hub.patch_status( mood=orch.mood, profile=canonical, post_voice=bool(orch.settings.post_voice_replies_to_chat) ) @@ -540,7 +567,7 @@ async def set_language(orch, language: str) -> None: await orch.hub.patch_status(stt_ready=orch.stt.model is not None) -async def set_voice(orch, voice: str) -> None: +async def set_voice(orch, voice: str, *, persist: bool = True) -> None: voice = (voice or "").strip() if voice not in orch.settings.tts_voices: raise ValueError(f"unknown voice: {voice}") @@ -554,7 +581,8 @@ def progress(message: str) -> None: orch.tts.on_progress = progress await asyncio.to_thread(orch.tts.ensure_files) - await asyncio.to_thread(_live("update_config"), {"tts.voice": voice}) + if persist: + await asyncio.to_thread(_live("update_config"), {"tts.voice": voice}) await orch.hub.patch_status(tts_ready=orch.tts.ready) diff --git a/src/core/voice_flow.py b/src/core/voice_flow.py index c86b1e9..6c3f31e 100644 --- a/src/core/voice_flow.py +++ b/src/core/voice_flow.py @@ -25,6 +25,14 @@ log = logging.getLogger("cohost.orch") +def note_timing(orch, key: str, value: int) -> None: + sample = int(value) + orch.timings[key] = sample + bucket = getattr(orch, "timing_samples", {}).get(key) + if bucket is not None: + bucket.append(sample) + + def _bump_voice(orch) -> int: orch._voice_gen += 1 orch._voice_cancel.set() @@ -75,7 +83,7 @@ async def _emit_cohost(orch, reply: str, *, thread: str, speak: bool, post: bool try: t0 = time.perf_counter() await orch.twitch.send(chat_text) - orch.timings["chat_send_ms"] = int((time.perf_counter() - t0) * 1000) + note_timing(orch, "chat_send_ms", int((time.perf_counter() - t0) * 1000)) await orch.hub.broadcast( { "type": "chat", @@ -130,7 +138,7 @@ async def on_utterance(orch, audio: np.ndarray) -> None: await orch.hub.broadcast({"type": "log", "data": f"STT error: {e}"}) await orch.hub.patch_status(phase="listening" if orch.mic.listen_enabled else "idle") return - orch.timings["stt_ms"] = int((time.perf_counter() - started) * 1000) + note_timing(orch, "stt_ms", int((time.perf_counter() - started) * 1000)) if gen != orch._voice_gen: return if not text: @@ -279,7 +287,7 @@ async def _voice_reply(orch, user_text: str, gen: int, started: float) -> None: return if not first_token: first_token = True - orch.timings["first_token_ms"] = int((time.perf_counter() - started) * 1000) + note_timing(orch, "first_token_ms", int((time.perf_counter() - started) * 1000)) sentence = orch._clean(sentence.strip()) if not sentence or is_generic_filler(sentence, orch._banned_extra()): continue @@ -293,7 +301,7 @@ async def _voice_reply(orch, user_text: str, gen: int, started: float) -> None: await orch._speak(sentence) if not first_audio: first_audio = True - orch.timings["first_audio_ms"] = int((time.perf_counter() - started) * 1000) + note_timing(orch, "first_audio_ms", int((time.perf_counter() - started) * 1000)) if gen != orch._voice_gen: return reply = orch._clean(" ".join(full).strip()) @@ -326,7 +334,7 @@ async def _emit_voice_result( try: t0 = time.perf_counter() await orch.twitch.send(short) - orch.timings["chat_send_ms"] = int((time.perf_counter() - t0) * 1000) + note_timing(orch, "chat_send_ms", int((time.perf_counter() - t0) * 1000)) orch.memory.remember( "cohost_chat", orch._cohost_name(), short, via="chat", thread=STREAMER_THREAD ) diff --git a/src/health.py b/src/health.py index 7393fee..be95887 100644 --- a/src/health.py +++ b/src/health.py @@ -120,6 +120,9 @@ async def snapshot(settings: Settings, orch) -> dict[str, Any]: "quality": "Whisper CPU + qwen2.5:7b-instruct-q4_K_M", }, "timings": getattr(orch, "timings", {}), + "timings_p50": _timing_percentile(orch, 50), + "timings_p95": _timing_percentile(orch, 95), + "timing_warning": _timing_warning(orch), "duck": orch.duck.status() if getattr(orch, "duck", None) else {"mode": "off", "note": ""}, "wake": orch.mic.wake.status() if getattr(orch, "mic", None) else {"enabled": False}, "mood": getattr(orch, "mood", "normal"), @@ -151,6 +154,34 @@ async def snapshot(settings: Settings, orch) -> dict[str, Any]: } +def _percentile(values: list[int], pct: float) -> int: + ordered = sorted(int(item) for item in values) + if len(ordered) == 1: + return ordered[0] + rank = (pct / 100.0) * (len(ordered) - 1) + lo = int(rank) + hi = min(lo + 1, len(ordered) - 1) + frac = rank - lo + return int(round(ordered[lo] * (1 - frac) + ordered[hi] * frac)) + + +def _timing_percentile(orch, pct: float) -> dict[str, int]: + samples = getattr(orch, "timing_samples", {}) or {} + stats: dict[str, int] = {} + for key, bucket in samples.items(): + rows = list(bucket) + if rows: + stats[str(key)] = _percentile(rows, pct) + return stats + + +def _timing_warning(orch) -> str: + audio = _timing_percentile(orch, 95).get("first_audio_ms") + if audio is not None and audio > 2500: + return "First-audio p95 is over 2.5 s." + return "" + + def _twitch_feeds(orch) -> dict[str, str]: client = getattr(orch, "eventsub", None) reported = dict(getattr(client, "feed_status", {}) or {}) diff --git a/src/main.py b/src/main.py index 63dd31a..eb8d4c5 100644 --- a/src/main.py +++ b/src/main.py @@ -8,7 +8,7 @@ from pathlib import Path from fastapi import FastAPI, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from starlette.middleware.trustedhost import TrustedHostMiddleware @@ -142,6 +142,7 @@ def create_app(prepared: tuple[Settings, Hub, Orchestrator] | None = None) -> Fa @asynccontextmanager async def life(app: FastAPI): app.state.control_token = secrets.token_urlsafe(32) + app.state.control_key = os.getenv("COHOST_CONTROL_KEY", "").strip() if prepared is not None: settings, hub, orch = prepared app.state.settings = settings @@ -186,6 +187,19 @@ async def overlay(): async def session(): return {"token": app.state.control_token} + @app.get("/api/transcript") + async def transcript(): + memory = app.state.orch.memory + path = memory.transcript_path() + filename = f"{memory.session_id}.jsonl" + if not path.exists(): + return Response( + b"", + media_type="application/x-ndjson", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + return FileResponse(path, filename=filename, media_type="application/x-ndjson") + @app.get("/api/health") async def health(): data = await snapshot(app.state.settings, app.state.orch) diff --git a/src/memory.py b/src/memory.py index 2658384..71e8e2f 100644 --- a/src/memory.py +++ b/src/memory.py @@ -5,6 +5,7 @@ import re import secrets import shutil +import threading import time from collections import deque from dataclasses import asdict, dataclass, field @@ -315,6 +316,7 @@ def __init__( self.streamer_display = "Streamer" self.cohost_name = "Cohost" self.dirty = False + self._transcript_lock = threading.Lock() self.load() def set_streamer_display(self, name: str) -> None: @@ -554,6 +556,7 @@ def remember( ) ) self.save() + self._append_transcript(ev) return ev def note_reply_to(self, user: str, reply: str, thread: str | None = None) -> None: @@ -596,6 +599,7 @@ def forget(self, login: str) -> None: (event for event in self.events if (event.user or "").lower() != key), maxlen=self.event_limit, ) + self._scrub_transcripts(key) self.save() def prune(self, now: float | None = None) -> int: @@ -619,10 +623,87 @@ def prune(self, now: float | None = None) -> int: if viewer.last_ts and viewer.last_ts < cutoff: self.viewers.pop(key, None) removed += 1 + removed += self._prune_transcripts(cutoff) if removed: self.save() return removed + def sessions_dir(self) -> Path: + return self.path.parent / "sessions" + + def transcript_path(self, session_id: str | None = None) -> Path: + sid = session_id or self.session_id + return self.sessions_dir() / f"{sid}.jsonl" + + def _append_transcript(self, ev: Event) -> None: + row = { + "ts": ev.ts, + "kind": ev.kind, + "user": ev.user, + "text": ev.text, + "via": ev.via, + "session_id": ev.session_id, + } + try: + with self._transcript_lock: + folder = self.sessions_dir() + folder.mkdir(parents=True, exist_ok=True) + with self.transcript_path().open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, ensure_ascii=False) + "\n") + except OSError: + log.exception("transcript append failed") + + def _scrub_transcripts(self, login: str) -> None: + folder = self.sessions_dir() + if not folder.exists(): + return + key = (login or "").lower() + try: + with self._transcript_lock: + for path in folder.glob("*.jsonl"): + self._rewrite_transcript(path, key) + except OSError: + log.exception("transcript scrub failed") + + def _rewrite_transcript(self, path: Path, login: str) -> None: + try: + raw = path.read_text(encoding="utf-8") + except OSError: + log.exception("could not read %s", path) + return + kept: list[str] = [] + for line in raw.splitlines(): + if not line.strip(): + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + kept.append(line) + continue + if str(item.get("user") or "").lower() == login: + continue + kept.append(line) + body = ("\n".join(kept) + "\n") if kept else "" + path.write_text(body, encoding="utf-8") + + def _prune_transcripts(self, cutoff: float) -> int: + folder = self.sessions_dir() + if not folder.exists(): + return 0 + removed = 0 + try: + with self._transcript_lock: + for path in folder.glob("*.jsonl"): + try: + if path.stat().st_mtime < cutoff: + path.unlink() + removed += 1 + except OSError: + log.exception("could not prune %s", path) + except OSError: + log.exception("transcript prune failed") + return removed + def viewer(self, name: str) -> Viewer | None: if (name or "").lower() in ("streamer", STREAMER_THREAD): return self.viewers.get(STREAMER_THREAD) diff --git a/src/orchestrator.py b/src/orchestrator.py index ced8d1a..ed52af7 100644 --- a/src/orchestrator.py +++ b/src/orchestrator.py @@ -133,8 +133,11 @@ async def set_devices(self, mic_device, speaker_device, mic_name=None, speaker_n async def set_language(self, language): return await lifecycle.set_language(self, language) - async def set_voice(self, voice): - return await lifecycle.set_voice(self, voice) + async def set_voice(self, voice, *, persist=True): + return await lifecycle.set_voice(self, voice, persist=persist) + + def note_timing(self, key, value): + return voice_flow.note_timing(self, key, value) def set_duck(self, enabled): return lifecycle.set_duck(self, enabled) diff --git a/src/policy.py b/src/policy.py index f8040e3..32d6f93 100644 --- a/src/policy.py +++ b/src/policy.py @@ -53,6 +53,21 @@ def chat_action(line: ChatLine, *, welcome_first_time: bool = False) -> str: return "chat" +def custom_reply(line: ChatLine, commands: dict[str, str] | None) -> str: + """Filled fixed-text reply for a custom command, or empty when this line is not one.""" + table = commands or {} + cmd = (line.command or "").lower() + if not cmd: + return "" + template = table.get(cmd) + if template is None: + return "" + text = str(template).strip() + if not text: + return "" + return text.replace("{user}", line.user or "chat") + + def mention_waits(mention: bool, mention_boost: bool, cooldown: float, per_user: float) -> tuple[float, float]: """Global cooldown and per-user cooldown for this line.""" if mention and mention_boost: diff --git a/src/security.py b/src/security.py index 7308bad..bbec515 100644 --- a/src/security.py +++ b/src/security.py @@ -17,6 +17,17 @@ from starlette.types import ASGIApp, Receive, Scope, Send LOCAL_HOSTS = ("127.0.0.1", "localhost", "[::1]", "::1") +# Stable COHOST_CONTROL_KEY is accepted only on these booth actions. +CONTROL_PATHS = frozenset( + { + "/api/interrupt", + "/api/tts/mute", + "/api/ptt", + "/api/marker", + "/api/mood", + "/api/profile", + } +) def trusted_hosts(port: int = 8080) -> list[str]: @@ -41,6 +52,16 @@ def token_matches(got: str, expected: str) -> bool: return secrets.compare_digest(got, expected) +def write_authorized(got: str, app, path: str) -> bool: + """Session token works everywhere. The optional control key is allow-listed.""" + state = getattr(app, "state", None) + if token_matches(got, getattr(state, "control_token", "")): + return True + if path not in CONTROL_PATHS: + return False + return token_matches(got, str(getattr(state, "control_key", "") or "")) + + class BoothGuard: """Reject foreign origins, non-JSON writes, and POSTs without the control token.""" @@ -73,8 +94,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ) return app = scope.get("app") - expected = getattr(getattr(app, "state", None), "control_token", "") - if not token_matches(headers.get("x-cohost-token") or "", expected): + if not write_authorized(headers.get("x-cohost-token") or "", app, path): await JSONResponse({"ok": False, "error": "missing control token"}, status_code=401)( scope, receive, send ) diff --git a/src/settings.py b/src/settings.py index df5f5d3..96e19b9 100644 --- a/src/settings.py +++ b/src/settings.py @@ -60,7 +60,24 @@ def _reward_spec(value: Any) -> RewardSpec: return RewardSpec("ignore") -def _parse_profiles(raw: Any) -> dict[str, dict[str, Any]]: +def _parse_custom_commands(raw: Any) -> dict[str, str]: + """Fixed chat replies. Keys are normalized to ``!name``.""" + if not isinstance(raw, dict): + return {} + commands: dict[str, str] = {} + for key, value in raw.items(): + name = str(key or "").strip().lower() + if not name: + continue + if not name.startswith("!"): + name = f"!{name}" + text = str(value or "").strip() + if text: + commands[name] = text + return commands + + +def _parse_profiles(raw: Any, voices: list[str] | None = None) -> dict[str, dict[str, Any]]: """Named overrides for the runtime keys a profile is allowed to change.""" if not isinstance(raw, dict): return {} @@ -86,6 +103,17 @@ def _parse_profiles(raw: Any) -> dict[str, dict[str, Any]]: spec["post_voice_replies_to_chat"] = bool(body.get("post_voice_replies_to_chat")) if "max_replies_per_minute" in body: spec["max_replies_per_minute"] = int(body.get("max_replies_per_minute") or 0) + if "tts_voice" in body: + voice = str(body.get("tts_voice") or "").strip() + if voices is not None and voice not in voices: + raise ValueError(f"profile {name} has unknown voice: {voice}") + spec["tts_voice"] = voice + if "length_scale" in body: + spec["length_scale"] = float(body.get("length_scale")) + if "persona_file" in body: + persona_file = str(body.get("persona_file") or "").strip() + if persona_file: + spec["persona_file"] = persona_file profiles[str(name)] = spec return profiles @@ -189,6 +217,8 @@ def __init__(self, data: dict[str, Any]) -> None: self.min_send_gap_seconds = float(_get(data, "chat.min_send_gap_seconds", 1.2)) self.chat_commands = _command_list(data, "chat.commands", ["!so", "!pixel", "!ask"]) self.mod_commands = _command_list(data, "chat.mod_commands", ["!so"]) + self.custom_commands = _parse_custom_commands(_get(data, "chat.custom_commands", {}) or {}) + self.custom_command_cooldown_seconds = float(_get(data, "chat.custom_command_cooldown_seconds", 30)) self.shoutout_cooldown_seconds = float(_get(data, "chat.shoutout_cooldown_seconds", 120)) self.shoutout_line = str(_get(data, "chat.shoutout_line", "go check out {user}") or "go check out {user}") self.shoutout_speak = bool(_get(data, "chat.shoutout_speak", True)) @@ -227,7 +257,7 @@ def __init__(self, data: dict[str, Any]) -> None: for key, value in rewards.items() if str(key).strip() } - self.profiles = _parse_profiles(_get(data, "profiles", {}) or {}) + self.profiles = _parse_profiles(_get(data, "profiles", {}) or {}, voices=list(self.tts_voices)) self.overlay_alert_hold = float(_get(data, "overlay.alert_hold_seconds", 6)) self.log_level = str(_get(data, "logging.level", "INFO") or "INFO").upper() diff --git a/src/web/static/app.css b/src/web/static/app.css index 96584fc..e2fac91 100644 --- a/src/web/static/app.css +++ b/src/web/static/app.css @@ -63,6 +63,8 @@ button.primary { background: #12352f; border-color: #2a6b5f; } button.active { background: #2a1a12; border-color: var(--warn); color: var(--warn); } button.held { background: #3a2218; border-color: #ffb38a; } .hint { color: var(--muted); min-height: 1.4em; margin: 8px 0 0; font-size: 13px; } +.hint.warn { color: var(--warn); } +.hint.warn a { color: var(--warn); } .banner { margin: 0 0 12px; padding: 8px 12px; diff --git a/src/web/static/app.js b/src/web/static/app.js index e73a7c8..5345b57 100644 --- a/src/web/static/app.js +++ b/src/web/static/app.js @@ -456,12 +456,38 @@ function renderSys(health) { btn.title = missing ? state : ""; } const timings = health.timings || {}; + const p50 = health.timings_p50 || {}; + const p95 = health.timings_p95 || {}; const bits = []; - if (timings.stt_ms != null) bits.push(`STT ${timings.stt_ms} ms`); - if (timings.first_token_ms != null) bits.push(`token ${timings.first_token_ms} ms`); - if (timings.first_audio_ms != null) bits.push(`audio ${timings.first_audio_ms} ms`); - if (timings.chat_send_ms != null) bits.push(`chat ${timings.chat_send_ms} ms`); + for (const [key, label] of [ + ["stt_ms", "STT"], + ["first_token_ms", "token"], + ["first_audio_ms", "audio"], + ["chat_send_ms", "chat"], + ]) { + const parts = []; + if (timings[key] != null) parts.push(`${timings[key]} ms`); + if (p50[key] != null) parts.push(`p50 ${p50[key]}`); + if (p95[key] != null) parts.push(`p95 ${p95[key]}`); + if (parts.length) bits.push(`${label} ${parts.join(" ")}`); + } $("timingLine").textContent = bits.join(" · "); + const timingWarn = $("timingWarn"); + if (timingWarn) { + timingWarn.replaceChildren(); + const slow = Number(p95.first_audio_ms); + const message = health.timing_warning || (slow > 2500 ? "First-audio p95 is over 2.5 s." : ""); + if (message) { + timingWarn.hidden = false; + timingWarn.append(document.createTextNode(`${message} `)); + const link = document.createElement("a"); + link.href = "#vram-tips"; + link.textContent = "VRAM tips"; + timingWarn.append(link); + } else { + timingWarn.hidden = true; + } + } if (health.mood && document.activeElement !== $("mood")) $("mood").value = health.mood; const profileSelect = $("profileSelect"); if (profileSelect) { @@ -487,21 +513,25 @@ function renderSys(health) { const profileLine = $("profileLine"); if (profileLine && runtime.mood) { const spoken = runtime.events_speak ? "spoken" : "typed"; - profileLine.textContent = `mood ${runtime.mood} · ${runtime.max_replies_per_minute} replies/min · cooldown ${runtime.cooldown_seconds}s · events ${spoken}`; + const voiceName = health.voice ? ` · ${health.voice}` : ""; + profileLine.textContent = `mood ${runtime.mood} · ${runtime.max_replies_per_minute} replies/min · cooldown ${runtime.cooldown_seconds}s · events ${spoken}${voiceName}`; } if (health.language && document.activeElement !== $("langSelect")) $("langSelect").value = health.language; if (!devicesLoaded && health.devices) { const devices = health.devices; applyDeviceList(devices, health); } - if (health.voices && !$("voiceSelect").options.length) { + const voiceSelect = $("voiceSelect"); + if (health.voices && voiceSelect && !voiceSelect.options.length) { fillSelect( - $("voiceSelect"), + voiceSelect, health.voices.map((v) => ({ value: v, label: v })), health.voice, null, ); - $("voiceSelect").value = health.voice || ""; + } + if (health.voice && voiceSelect && voiceSelect.options.length && document.activeElement !== voiceSelect) { + voiceSelect.value = health.voice; } const vad = health.vad || {}; if (document.activeElement !== $("silence") && vad.silence_ms) { @@ -780,6 +810,27 @@ async function buildRecap(vibe) { } return data; } +const transcriptBtn = $("transcriptBtn"); +if (transcriptBtn) { + transcriptBtn.addEventListener("click", async () => { + try { + const res = await fetch("/api/transcript", { cache: "no-store" }); + if (!res.ok) throw new Error("Transcript download failed."); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + const named = /filename="([^"]+)"/.exec(res.headers.get("Content-Disposition") || ""); + link.href = url; + link.download = named ? named[1] : "transcript.jsonl"; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (e) { + hint.textContent = String(e.message || e); + } + }); +} const recapBtn = $("recapBtn"); if (recapBtn) { recapBtn.addEventListener("click", () => { diff --git a/src/web/static/index.html b/src/web/static/index.html index 157667c..55345f1 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -4,7 +4,7 @@ Local Twitch Cohost - +
@@ -62,6 +62,8 @@

Streamer

Systems

+ +

8 GB VRAM tips: snappy keeps Whisper on the GPU with the 3B model. If first audio stays slow, set vram.profile to quality or whisper.device to cpu.

Twitch

IRC: disconnected

EventSub: disconnected

@@ -187,6 +189,7 @@

Recap

+
@@ -198,6 +201,6 @@

Log

- + diff --git a/tests/test_api.py b/tests/test_api.py index 7a73ac4..2a7eaeb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -259,3 +259,33 @@ async def run(): asyncio.run(run()) assert heard == [] + + +def test_control_key_is_limited_to_booth_actions(tmp_path, settings, monkeypatch): + monkeypatch.setenv("COHOST_CONTROL_KEY", "deck-secret") + client, _orch = _client(tmp_path, settings) + key = { + "x-cohost-token": "deck-secret", + "origin": "http://127.0.0.1:8080", + } + with client: + assert client.post("/api/interrupt", headers=key, json={}).status_code == 200 + assert client.post("/api/mood", headers=key, json={"mood": "hype"}).status_code == 200 + assert client.post("/api/tts/mute", headers=key, json={"mute": True}).status_code == 200 + assert client.post("/api/profile", headers=key, json={"name": ""}).status_code == 200 + assert client.post("/api/persona", headers=key, json={"text": "name: nope"}).status_code == 401 + assert client.post("/api/say", headers=key, json={"text": "hi"}).status_code == 401 + assert client.post("/api/interrupt", headers=_headers(client), json={}).status_code == 200 + + +def test_unset_control_key_changes_nothing(tmp_path, settings, monkeypatch): + monkeypatch.delenv("COHOST_CONTROL_KEY", raising=False) + client, _orch = _client(tmp_path, settings) + key = { + "x-cohost-token": "deck-secret", + "origin": "http://127.0.0.1:8080", + } + with client: + assert client.post("/api/interrupt", headers=key, json={}).status_code == 401 + assert client.post("/api/persona", headers=key, json={"text": "name: nope"}).status_code == 401 + assert client.post("/api/interrupt", headers=_headers(client), json={}).status_code == 200 diff --git a/tests/test_v3_phase4.py b/tests/test_v3_phase4.py new file mode 100644 index 0000000..e62e2fb --- /dev/null +++ b/tests/test_v3_phase4.py @@ -0,0 +1,210 @@ +import asyncio +import copy +import os +import time +from pathlib import Path + +from src.health import snapshot +from src.policy import custom_reply +from src.settings import Settings, load_settings +from src.twitch.chat import ChatLine +from tests.test_api import _client, _headers +from tests.test_orchestrator_policy import PERSONA_FIXTURE, make_orch + + +def _wake(orch) -> None: + class _Wake: + def status(self): + return {"enabled": False} + + orch.mic.wake = _Wake() + + +def test_custom_reply_fills_the_user(): + line = ChatLine(user="bob", text="!lurk", command="!Lurk") + assert custom_reply(line, {"!lurk": "enjoy the lurk {user}"}) == "enjoy the lurk bob" + assert custom_reply(line, {}) == "" + + +def test_unknown_profile_voice_is_rejected_at_load(settings): + data = copy.deepcopy(settings._data) + data["profiles"] = {"Loud": {"tts_voice": "not-a-voice"}} + try: + Settings(data) + except ValueError as exc: + assert "unknown voice" in str(exc) + else: + raise AssertionError("invalid profile voice was accepted") + + +def test_profile_swaps_voice_scale_and_persona(tmp_path, settings, monkeypatch): + written: list[dict] = [] + monkeypatch.setattr("src.orchestrator.update_config", lambda payload: written.append(payload)) + other = tmp_path / "other.yaml" + other.write_text(PERSONA_FIXTURE.read_text(encoding="utf-8").replace("name: Pixel", "name: Nova"), encoding="utf-8") + settings.profiles = { + "Kristin": { + "mood": "hype", + "cooldown_seconds": 8, + "per_user_cooldown_seconds": 12, + "events_speak": True, + "post_voice_replies_to_chat": True, + "max_replies_per_minute": 12, + "tts_voice": "en_US-kristin-medium", + "length_scale": 0.9, + "persona_file": str(other), + } + } + + async def run(): + orch = make_orch(tmp_path, settings) + downloads: list[str] = [] + + def ensure(): + assert callable(orch.tts.on_progress) + orch.tts.on_progress("Downloading voice") + downloads.append(orch.settings.tts_voice) + + orch.tts.ensure_files = ensure + assert await orch.apply_profile("Kristin") == "Kristin" + for task in list(orch._bg): + await task + assert orch.settings.tts_voice == "en_US-kristin-medium" + assert orch.settings.tts_length_scale == 0.9 + assert orch.persona["name"] == "Nova" + assert downloads == ["en_US-kristin-medium"] + _wake(orch) + health = await snapshot(settings, orch) + assert health["voice"] == "en_US-kristin-medium" + assert written == [] + + assert await orch.apply_profile("") == "" + for task in list(orch._bg): + await task + assert orch.settings.tts_voice == "en_US-amy-medium" + assert orch.settings.tts_length_scale == 1.05 + assert orch.persona["name"] == "Pixel" + health = await snapshot(settings, orch) + assert health["voice"] == "en_US-amy-medium" + + asyncio.run(run()) + + +def test_session_transcript_rotates_and_forget_scrubs_it(tmp_path, settings): + async def run(): + orch = make_orch(tmp_path, settings) + orch.memory.remember("viewer", "Sam", "hello there", via="chat") + orch.memory.remember("twitch_event", "Sam", "followed", via="event") + orch.memory.remember("viewer", "Other", "still here", via="chat") + path = orch.memory.transcript_path() + assert path.exists() + body = path.read_text(encoding="utf-8") + assert "hello there" in body + assert "followed" in body + + first = orch.memory.session_id + orch.memory.start_session() + orch.memory.remember("viewer", "Other", "next session", via="chat") + assert (orch.memory.sessions_dir() / f"{first}.jsonl").exists() + assert "next session" in orch.memory.transcript_path().read_text(encoding="utf-8") + assert "hello there" not in orch.memory.transcript_path().read_text(encoding="utf-8") + + orch.forget_viewer("sam") + kept = (orch.memory.sessions_dir() / f"{first}.jsonl").read_text(encoding="utf-8") + assert "hello there" not in kept + assert "followed" not in kept + assert "still here" in kept + + old = orch.memory.sessions_dir() / "oldsession.jsonl" + old.write_text('{"user":"ghost","text":"old"}\n', encoding="utf-8") + stale = time.time() - 40 * 86400 + os.utime(old, (stale, stale)) + orch.memory.prune() + assert not old.exists() + assert orch.memory.transcript_path().exists() + + asyncio.run(run()) + + +def test_custom_command_posts_once_per_cooldown(tmp_path, settings): + async def run(): + settings.custom_commands = {"!lurk": "enjoy the lurk {user}", "!discord": "join the discord"} + settings.custom_command_cooldown_seconds = 30 + orch = make_orch(tmp_path, settings) + model_calls: list[str] = [] + shoutouts: list[str | None] = [] + + async def complete(messages, temperature=0.7, cancel=None): + model_calls.append("complete") + return "from the model" + + async def fake_shoutout(login=None): + shoutouts.append(login) + + orch.llm.complete = complete + orch.shoutout = fake_shoutout + assert "!lurk" not in settings.chat_commands + assert "!lurk" in orch._irc_commands() + assert "!discord" in orch._irc_commands() + + orch._last_chat_reply = time.monotonic() + orch._chat_pending.append(ChatLine(user="bob", text="!lurk what is this?", command="!lurk")) + assert await orch._consider_chat_locked() is True + assert orch.twitch.sent == ["enjoy the lurk bob"] + assert model_calls == [] + assert shoutouts == [] + + orch._chat_pending.append(ChatLine(user="sam", text="!lurk", command="!lurk")) + assert await orch._consider_chat_locked() is True + assert orch.twitch.sent == ["enjoy the lurk bob"] + assert orch._last_skip["reason"] == "command cooldown" + + orch._custom_at["!lurk"] = 0 + orch._chat_pending.append(ChatLine(user="sam", text="!lurk", command="!lurk")) + assert await orch._consider_chat_locked() is True + assert orch.twitch.sent[-1] == "enjoy the lurk sam" + assert model_calls == [] + assert shoutouts == [] + + asyncio.run(run()) + + +def test_latency_window_reports_percentiles_and_warns(tmp_path, settings): + async def run(): + orch = make_orch(tmp_path, settings) + for sample in range(60): + orch.note_timing("stt_ms", 100 + sample) + orch.note_timing("first_audio_ms", 3000) + assert len(orch.timing_samples["first_audio_ms"]) == 50 + assert orch.timings["first_audio_ms"] == 3000 + _wake(orch) + health = await snapshot(settings, orch) + assert health["timings_p50"]["stt_ms"] > 0 + assert health["timings_p95"]["first_audio_ms"] > 2500 + assert health["timing_warning"] == "First-audio p95 is over 2.5 s." + assert health["timings"]["stt_ms"] == 159 + + asyncio.run(run()) + + +def test_shipped_config_keeps_custom_commands_empty(): + loaded = load_settings(Path(__file__).resolve().parents[1] / "config.yaml") + assert loaded.custom_commands == {} + assert loaded.custom_command_cooldown_seconds == 30 + assert "tts_voice" not in loaded.profiles["Just chatting"] + assert "tts_voice" not in loaded.profiles["Focus game"] + + +def test_transcript_route_is_on_the_dashboard(tmp_path, settings): + client, orch = _client(tmp_path, settings) + orch.memory.remember("viewer", "sam", "line one", via="chat") + with client: + page = client.get("/") + assert page.status_code == 200 + assert "Download transcript" in page.text + assert 'id="timingWarn"' in page.text + assert 'id="vram-tips"' in page.text + body = client.get("/api/transcript") + assert "line one" in body.text + client.post("/api/memory/forget", headers=_headers(client), json={"login": "sam"}) + assert "line one" not in client.get("/api/transcript").text