diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46f94c2..c443945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,4 +32,4 @@ jobs: run: python -m ruff check src tests - name: Test - run: python -m pytest -q + run: python -m pytest -q --cov=src --cov-fail-under=72 diff --git a/PLAN-v3.md b/PLAN-v3.md index 3f3e6cc..82955be 100644 --- a/PLAN-v3.md +++ b/PLAN-v3.md @@ -143,49 +143,49 @@ Do these on a test stream or with the booth open before relying on the features. ### Phase 2: Robustness and code health -- [ ] **V3-P2-01 🟡 Atomic writes for booth and identity** (F-08) +- [x] **V3-P2-01 🟡 Atomic writes for booth and identity** (F-08) - *Do:* use `atomic_write_text()` in `save_booth()` and `save_identity()`; log (don't swallow silently) a corrupt file and keep a `.bad` copy like memory does. - *Files:* `src/booth.py`, `src/identity.py`, tests - *Acceptance:* a test that makes `os.replace` raise leaves the old file intact; a corrupt `booth.json` is logged and copied to `booth.json.bad`. - *Depends on:* none -- [ ] **V3-P2-02 🟡 Clean up Piper temp files after interrupts** (F-10) +- [x] **V3-P2-02 🟡 Clean up Piper temp files after interrupts** (F-10) - *Do:* when a persistent request is abandoned, record its `out_path` in a "pending deletes" set; delete it when its ready-line arrives or on the next synth/close. Optionally write temps into `data/tmp/` and clear that folder on start. - *Files:* `src/tts/piper.py`, `tests/test_piper_pipes.py` - *Acceptance:* a fake Piper that writes the file after the interrupt leaves no stray WAVs after the next synth. - *Depends on:* none -- [ ] **V3-P2-03 🟡 Keep the manual mood across restarts** (F-11) +- [x] **V3-P2-03 🟡 Keep the manual mood across restarts** (F-11) - *Do:* store `mood_override` separately in `BoothState`; profiles set the base mood, and a manual change sets the override until the next profile switch. - *Files:* `src/booth.py`, `src/orchestrator.py`, `tests/test_phase4.py` - *Acceptance:* profile "Focus game" + manual `hype` → restart → mood is still `hype`; switching profile clears the override. - *Depends on:* V3-P2-01 -- [ ] **V3-P2-04 🟡 Better shoutout lines** (F-12) +- [x] **V3-P2-04 🟡 Better shoutout lines** (F-12) - *Do:* say "go check out X" style lines (configurable `chat.shoutout_line`, with `{user}`), use "welcome in" only when the target is the last raider, and let `chat.shoutout_speak` (default true) control TTS. - *Files:* `src/orchestrator.py`, `src/settings.py`, `config.yaml`, `tests/test_shoutout.py` - *Acceptance:* `!so friend` with no raid posts the configured line; a raider shoutout keeps the welcome; with `shoutout_speak: false` nothing is spoken. - *Depends on:* V3-P1-02 -- [ ] **V3-P2-05 🟡 Remove hardcoded "Pixel"** (F-13) +- [x] **V3-P2-05 🟡 Remove hardcoded "Pixel"** (F-13) - *Do:* derive the control command from the persona name (`!`) and keep `!pixel` as an alias via `chat.control_command`; use `personaName` in the dashboard hint; pass the persona name into the smalltalk fallback everywhere. - *Files:* `src/policy.py`, `src/orchestrator.py`, `src/settings.py`, `src/web/static/app.js`, `config.yaml`, tests - *Acceptance:* with `name: Nova` and `control_command` unset, `!nova mute` works and `!pixel mute` still works; no user-visible "Pixel" remains in the UI when the persona is renamed. - *Depends on:* none -- [ ] **V3-P2-06 🟡 Split the orchestrator** (F-15) +- [x] **V3-P2-06 🟡 Split the orchestrator** (F-15) - *Do:* move code without changing behaviour into `src/core/chat_flow.py` (pick/rate/decide/perform), `src/core/event_flow.py` (batching, priority, react), `src/core/voice_flow.py` (utterance → reply), `src/core/twitch_link.py` (connect/disconnect/watchdog) and `src/core/queue.py`. Keep `Orchestrator` as a thin façade so `main.py` and tests don't change. - *Files:* `src/orchestrator.py`, new `src/core/*` - *Acceptance:* all existing tests pass unchanged; `orchestrator.py` is under 500 lines; ruff clean. - *Depends on:* Phase 1 done (to avoid merge pain) -- [ ] **V3-P2-07 🟡 Hermetic tests and a coverage floor** (F-16) +- [x] **V3-P2-07 🟡 Hermetic tests and a coverage floor** (F-16) - *Do:* make `conftest.py` set `COHOST_SKIP_SECRET_CHECK=1`, point `ROOT`-based paths at `tmp_path` via a fixture, and never read the real `config.yaml`/`persona.yaml`. Add `--cov=src --cov-fail-under=` to CI. - *Files:* `tests/conftest.py`, `.github/workflows/ci.yml`, `pyproject.toml` - *Acceptance:* tests pass with a deliberately broken `config.yaml` in the repo root; CI fails if coverage drops. - *Depends on:* V3-P1-01 -- [ ] **V3-P2-08 🟡 Configurable logging** (F-17) +- [x] **V3-P2-08 🟡 Configurable logging** (F-17) - *Do:* add `logging.level` (default INFO) and `logging.debug_modules` (e.g. `["cohost.mic","cohost.tts"]`), and a dashboard "Debug log for 10 min" toggle (token-protected POST). - *Files:* `src/logsetup.py`, `src/settings.py`, `config.yaml`, `src/main.py`, `src/web/static/*` - *Acceptance:* setting `debug_modules: [cohost.mic]` produces mic debug lines only; the toggle reverts after 10 minutes. diff --git a/config.yaml b/config.yaml index a5eb344..0081a23 100644 --- a/config.yaml +++ b/config.yaml @@ -93,6 +93,11 @@ chat: mod_commands: - "!so" shoutout_cooldown_seconds: 120 + # Posted for !so. {user} is the login. A shoutout of the last raider still says "welcome in". + shoutout_line: "go check out {user}" + shoutout_speak: true + # Empty derives !. !pixel always stays as an alias. + control_command: "" # Pending viewer lines older than this are dropped (streamer lines and commands stay). max_age_seconds: 45 # First-time chatters get a short typed welcome instead of a spoken reply. @@ -131,6 +136,11 @@ events: overlay: alert_hold_seconds: 6 +logging: + level: INFO + # Logger names that stay at DEBUG, for example ["cohost.mic", "cohost.tts"]. + debug_modules: [] + safety: # Pixel strips links from chat posts unless this is true. allow_links: false diff --git a/pyproject.toml b/pyproject.toml index 22363ae..208f5f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.11" [tool.pytest.ini_options] testpaths = ["tests"] +addopts = "--cov=src --cov-fail-under=72" [tool.ruff] line-length = 110 diff --git a/src/booth.py b/src/booth.py index e257047..4cf28f8 100644 --- a/src/booth.py +++ b/src/booth.py @@ -1,9 +1,17 @@ from __future__ import annotations import json +import logging +import shutil from dataclasses import asdict, dataclass from pathlib import Path +from src.settings import atomic_write_text + +log = logging.getLogger("cohost.booth") + +MOODS = ("chill", "normal", "hype") + @dataclass class BoothState: @@ -12,12 +20,28 @@ class BoothState: post_voice_replies_to_chat: bool = True mood: str = "normal" profile: str = "" + mood_override: str = "" def clamped(self) -> BoothState: - if self.mood not in ("chill", "normal", "hype"): + if self.mood not in MOODS: self.mood = "normal" + if self.mood_override not in ("", *MOODS): + self.mood_override = "" return self + def effective_mood(self) -> str: + """Manual override wins until the next profile switch.""" + return self.mood_override or self.mood + + +def _preserve_bad(path: Path) -> None: + bad = path.with_name(path.name + ".bad") + try: + shutil.copy2(path, bad) + except Exception: + log.exception("could not copy corrupt %s", path.name) + log.error("%s is unreadable; kept a copy at %s", path.name, bad.name) + def load_booth(path: Path, fallback_mute: bool = False, fallback_post: bool = True) -> BoothState: state = BoothState(tts_mute=fallback_mute, post_voice_replies_to_chat=fallback_post) @@ -31,11 +55,13 @@ def load_booth(path: Path, fallback_mute: bool = False, fallback_post: bool = Tr ) state.mood = str(data.get("mood") or state.mood) state.profile = str(data.get("profile") or "") + state.mood_override = str(data.get("mood_override") or "") except Exception: - pass + log.exception("booth.json is unreadable") + _preserve_bad(path) return state.clamped() def save_booth(path: Path, state: BoothState) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(asdict(state.clamped()), ensure_ascii=False, indent=2), encoding="utf-8") + body = json.dumps(asdict(state.clamped()), ensure_ascii=False, indent=2) + atomic_write_text(path, body + "\n") diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..567cec6 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1 @@ +"""Flows moved out of the orchestrator facade.""" diff --git a/src/core/chat_flow.py b/src/core/chat_flow.py new file mode 100644 index 0000000..2ca54cc --- /dev/null +++ b/src/core/chat_flow.py @@ -0,0 +1,429 @@ +"""Chat pick, rate, decide, and perform.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections import deque + +from src.llm.persona import ( + mood_temperature, +) +from src.memory import ( + STREAMER_THREAD, + ensure_chat_mention, + for_speech, + is_generic_filler, + sanitize_reply, + streamer_smalltalk_fallback, +) +from src.policy import chat_action, control_command_names, mention_waits, pixel_control +from src.safety import DEFLECT, fence_viewer_text, is_blocked +from src.twitch.chat import ChatLine + +log = logging.getLogger("cohost.orch") + + +def _kick_chat(orch) -> None: + if orch._chat_scheduled or orch._stopping: + return + orch._chat_scheduled = True + try: + orch._chat_q.put_nowait(1) + except Exception: + orch._chat_scheduled = False + + +async def on_chat(orch, line: ChatLine) -> None: + if orch.identity.is_login(line.user): + name = orch.identity.spoken_name() + stored = "[skipped topic]" if is_blocked(line.text) else line.text + orch.memory.remember("streamer_chat", name, stored, via="chat", thread=STREAMER_THREAD) + await orch.hub.broadcast( + { + "type": "chat", + "data": {"user": name, "text": line.text, "mention": line.mention, "streamer": True}, + } + ) + orch._spawn(orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}), "memory") + orch._chat_pending.append(line) + orch._kick_chat() + return + orch._chat_pending.append(line) + stored = "[skipped topic]" if is_blocked(line.text) else line.text + orch.memory.remember( + "viewer", line.user, stored, via="chat", thread=orch.memory.viewer_thread_key(line.user) + ) + await orch.hub.broadcast( + {"type": "chat", "data": {"user": line.user, "text": line.text, "mention": line.mention}} + ) + orch._spawn(orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}), "memory") + await orch._publish_queue() + orch._kick_chat() + + +def _rate_ok(orch, *, mention: bool, user: str = "") -> tuple[bool, str]: + now = time.monotonic() + while orch._reply_times and now - orch._reply_times[0] > 60: + orch._reply_times.popleft() + if len(orch._reply_times) >= orch.settings.max_replies_per_minute: + return (False, "cooldown") + if orch.identity.is_login(user): + if now - orch._last_chat_reply < 1.5: + return (False, "cooldown") + return (True, "") + global_wait, user_wait = mention_waits( + mention, + orch.settings.mention_boost, + orch.settings.cooldown_seconds, + orch.settings.per_user_cooldown_seconds, + ) + key = (user or "").lower() + if key: + last = orch._user_reply_at.get(key, 0.0) + if now - last < user_wait: + return (False, "per-user cooldown") + if now - orch._last_chat_reply < global_wait: + return (False, "cooldown") + return (True, "") + + +def _mark_reply(orch, user: str = "") -> None: + now = time.monotonic() + orch._last_chat_reply = now + orch._reply_times.append(now) + if user: + orch._user_reply_at[user.lower()] = now + + +async def _chat_scan_loop(orch) -> None: + while not orch._stopping: + try: + await asyncio.sleep(orch.settings.scan_interval_seconds) + orch._kick_chat() + except asyncio.CancelledError: + raise + except Exception: + log.exception("chat scan failed") + + +async def _chat_worker(orch) -> None: + while not orch._stopping: + await orch._chat_q.get() + orch._chat_scheduled = False + try: + await orch._drain_chat() + except asyncio.CancelledError: + raise + except Exception: + log.exception("chat worker failed") + + +async def _expire_stale_chat(orch) -> None: + limit = orch.settings.chat_max_age_seconds + if limit <= 0 or not orch._chat_pending: + return + now = time.monotonic() + kept: deque[ChatLine] = deque(maxlen=orch._chat_pending.maxlen) + for line in list(orch._chat_pending): + age = now - float(getattr(line, "ts", now)) + exempt = orch.identity.is_login(line.user) or bool(line.command) + if age > limit and (not exempt): + await orch._note_skip("stale", line.user) + continue + kept.append(line) + orch._chat_pending.clear() + orch._chat_pending.extend(kept) + + +async def _drain_chat(orch) -> None: + await orch._expire_stale_chat() + while orch._chat_pending and (not orch._stopping): + if orch._busy_voice or orch._event_pending: + reason = "busy on mic" if orch._busy_voice else "alert queued" + await orch._note_skip(reason, "") + await asyncio.sleep(0.25) + orch._kick_chat() + return + handled = await orch._consider_chat_locked() + if not handled: + return + + +def _pick_chat_target(orch) -> ChatLine | None: + if not orch._chat_pending: + return None + streamer_lines = [line for line in orch._chat_pending if orch.identity.is_login(line.user)] + if streamer_lines: + return streamer_lines[0] + commands = [line for line in orch._chat_pending if line.command] + if commands: + return commands[-1] + engaging = [line for line in orch._chat_pending if line.is_engaging()] + if engaging: + return engaging[-1] + if orch.settings.welcome_first_time: + firsts = [line for line in orch._chat_pending if line.first_msg and (not line.command)] + if firsts: + return firsts[-1] + return orch._chat_pending[-1] + + +def _drop_chat_target(orch, target: ChatLine) -> None: + if orch.identity.is_login(target.user): + kept = [line for line in orch._chat_pending if line is not target] + else: + kept = [ + line + for line in orch._chat_pending + if line is not target and line.user.lower() != target.user.lower() + ] + orch._chat_pending.clear() + orch._chat_pending.extend(kept) + + +def _control_ready(orch) -> bool: + wait = orch.settings.control_cooldown_seconds + if wait <= 0: + return True + return time.monotonic() - orch._last_control_at >= wait + + +async def _type_ack(orch, text: str) -> None: + if orch.twitch and orch.twitch.connected: + await orch.twitch.send(text) + await orch.hub.broadcast( + {"type": "chat", "data": {"user": orch.persona.get("name"), "text": text, "bot": True}} + ) + + +async def _run_pixel_control(orch, verb: str) -> None: + if verb == "mute": + await orch.set_mute(True) + await orch._type_ack("Muted.") + elif verb == "unmute": + await orch.set_mute(False) + await orch._type_ack("Voice is back.") + elif verb == "chill": + await orch.set_mood("chill") + await orch._type_ack("Mood is chill.") + elif verb == "hype": + await orch.set_mood("hype") + await orch._type_ack("Mood is hype.") + elif verb == "stop": + await orch.interrupt() + await orch._type_ack("Stopped.") + + +async def _consider_chat_locked(orch) -> bool: + if orch._busy_voice: + return False + if orch._event_pending: + orch._kick_events() + return False + target = orch._pick_chat_target() + if target is None: + return False + if pixel_control(target, orch._control_names()): + 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: + await orch._note_skip(why, target.user) + return False + orch._drop_chat_target(target) + await orch._publish_queue() + await orch._perform_chat(target, ignore_rate=False) + return True + + +async def _perform_chat(orch, target: ChatLine, *, ignore_rate: bool) -> None: + verb = pixel_control(target, orch._control_names()) + if verb: + if not orch._can_run_mod_command(target): + await orch._note_skip("not a mod", target.user) + return + if not ignore_rate and (not orch._control_ready()): + await orch._note_skip("control cooldown", target.user) + return + await orch._run_pixel_control(verb) + 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 + if command == "!so": + who = "" + parts = target.text.split() + if len(parts) > 1: + who = parts[1].lstrip("@") + if not ignore_rate and (not orch._shoutout_ready()): + await orch._note_skip("shoutout cooldown", target.user) + return + try: + await orch.shoutout(who or None) + except Exception as e: + await orch.hub.broadcast({"type": "log", "data": f"Shoutout failed: {e}"}) + else: + orch._last_shoutout_at = time.monotonic() + orch._mark_reply(target.user) + return + if action == "command": + await orch._note_skip("unhandled command", target.user) + return + await orch._decide_chat(target, action) + + +async def _decide_chat(orch, line: ChatLine, action: str) -> None: + if orch._busy_voice: + orch._chat_pending.append(line) + return + await orch.hub.patch_status(phase="thinking") + streamer = orch.identity.is_login(line.user) + previous = "" + prev = orch.memory.viewer(STREAMER_THREAD if streamer else line.user) + if prev and prev.last_reply: + previous = prev.last_reply + recent = [ + msg["content"] + for msg in orch.memory.llm_messages( + STREAMER_THREAD if streamer else orch.memory.viewer_thread_key(line.user) + ) + if msg["role"] == "assistant" + ] + if is_blocked(line.text): + reply = DEFLECT + action = "chat" + else: + try: + reply = orch._clean(await orch._persona_chat_reply(line, ambient=action != "speak")) + if orch._reply_is_bad(reply, previous, recent): + reply = orch._clean( + await orch._persona_chat_reply(line, ambient=action != "speak", retry=True) + ) + except TimeoutError: + await orch._note_skip("llm timeout", line.user) + await orch.hub.patch_status(phase="listening" if orch.mic.listen_enabled else "idle") + return + if orch._reply_is_bad(reply, previous, recent): + fallback = streamer_smalltalk_fallback(line.text, orch._cohost_name()) if streamer else "" + if not fallback: + await orch._note_skip("filler rejected", line.user) + await orch.hub.broadcast( + {"type": "log", "data": f"Chat skip ({line.user}): {line.text[:80]}"} + ) + await orch.hub.patch_status(phase="listening" if orch.mic.listen_enabled else "idle") + return + reply = fallback + if is_blocked(reply): + reply = DEFLECT + action = "chat" + if streamer: + chat_text = orch._chat_text(sanitize_reply(reply, str(orch.persona.get("name") or ""))) + else: + chat_text = orch._chat_text(ensure_chat_mention(reply, line.user), target_user=line.user) + if not chat_text: + await orch._note_skip("output guard", line.user) + await orch.hub.patch_status(phase="listening" if orch.mic.listen_enabled else "idle") + return + spoken = for_speech(chat_text) + thread_key = STREAMER_THREAD if streamer else orch.memory.viewer_thread_key(line.user) + orch._mark_reply(line.user) + orch.memory.remember("cohost_chat", orch._cohost_name(), chat_text, via="chat", thread=thread_key) + orch._remember_caption(spoken) + orch.memory.note_reply_to(line.user, chat_text, thread=thread_key) + if not streamer: + orch.memory.mark_greeted(line.user) + orch._spawn(orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}), "memory") + await orch.hub.broadcast( + {"type": "transcript", "data": {"role": "cohost", "text": spoken, "chat": chat_text}} + ) + await orch.hub.broadcast( + {"type": "chat", "data": {"user": orch.persona.get("name"), "text": chat_text, "bot": True}} + ) + if orch.twitch and orch.twitch.connected: + try: + await orch.twitch.send(chat_text) + except Exception: + log.exception("chat send failed") + want_speak = ( + action == "speak" and (not orch.tts_mute) and (not orch._busy_voice) and (not orch.tts.speaking) + ) + if want_speak: + await orch.hub.patch_status(phase="speaking", speaking=True, caption=spoken) + await orch._speak(spoken) + await orch.hub.patch_status(phase="listening" if orch.mic.listen_enabled else "idle", speaking=False) + else: + await orch.hub.patch_status(phase="listening" if orch.mic.listen_enabled else "idle", caption=spoken) + + +async def _persona_chat_reply(orch, line: ChatLine, ambient: bool = False, retry: bool = False) -> str: + extra = ( + "\nAnswer only the message above. No greeting by name. Do not ask what they want to talk about. Do not mention clips or moments.\n" + if retry + else "" + ) + prev = orch.memory.viewer(STREAMER_THREAD if orch.identity.is_login(line.user) else line.user) + prev_bit = "" + if prev and prev.last_reply and (not is_generic_filler(prev.last_reply, orch._banned_extra())): + prev_bit = f"\nYou already said to them: {prev.last_reply}\nDon't repeat that.\n" + name = orch.identity.spoken_name() + fenced = fence_viewer_text(line.text) + if orch.identity.is_login(line.user): + focus = STREAMER_THREAD + who_line = f"{name} (the streamer, sitting next to you) just typed:\n{fenced}\n" + ask = f"Reply only to the latest line inside the viewer markers. One short line. Do not repeat a joke or sentence you already used. Do not start with Hey {name}. Do not ask them for memes, clips, or a topic." + else: + focus = orch.memory.viewer_thread_key(line.user) + who_line = f"{line.user} in chat:\n{fenced}\nThis is a viewer, not {name}.\n" + ask = f"One short Twitch chat line starting with @{line.user}." + if orch.settings.welcome_first_time and line.first_msg: + ask += " This is their first message ever in this channel. Give a short typed welcome." + history_limit = 4 if ambient else None + messages = [ + {"role": "system", "content": orch.chat_prompt}, + *orch._history_messages(focus, limit=history_limit), + { + "role": "user", + "content": f"{who_line}{prev_bit}{orch.memory.prompt_block(focus, streamer_name=name, is_voice=False, include_alerts=not ambient)}\n\nLive chat:\n{orch._chat_context(orch.settings.ambient_chat_context if ambient else None)}\n{extra}{ask}", + }, + ] + try: + text = orch._clean( + await asyncio.wait_for( + orch.llm.complete(messages, temperature=mood_temperature(orch.mood) if not retry else 0.9), + timeout=orch.settings.chat_timeout_seconds, + ) + ) + except TimeoutError: + orch.llm.last_error = "llm timeout" + raise + if not text: + return "" + target = None if orch.identity.is_login(line.user) else line.user + return orch._chat_text(text, target_user=target) + + +def _control_names(orch) -> set[str]: + return control_command_names(str(orch.persona.get("name") or ""), orch.settings.control_command) + + +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) + return commands + + +def _can_run_mod_command(orch, line: ChatLine) -> bool: + if line.is_mod or line.is_broadcaster: + return True + return orch.identity.is_login(line.user) diff --git a/src/core/event_flow.py b/src/core/event_flow.py new file mode 100644 index 0000000..7afd792 --- /dev/null +++ b/src/core/event_flow.py @@ -0,0 +1,276 @@ +"""Stream-event batching, priority, and replies.""" + +from __future__ import annotations + +import asyncio +import logging +import time + +from src.llm.persona import ( + mood_temperature, +) +from src.memory import ( + STREAMER_THREAD, + is_generic_filler, +) +from src.recap import build_recap, write_recap +from src.safety import DEFLECT, fence_viewer_text, is_blocked +from src.twitch.eventsub import StreamEvent + +log = logging.getLogger("cohost.orch") + +EVENT_PRIORITY = { + "raid": 0, + "gift": 1, + "sub": 2, + "resub": 3, + "cheer": 4, + "hype_begin": 5, + "hype_end": 5, + "redeem": 6, + "follow": 7, + "poll_start": 8, + "poll_end": 8, + "prediction_start": 8, + "prediction_end": 8, +} + + +async def on_stream_event(orch, event: StreamEvent) -> None: + await orch.hub.broadcast( + { + "type": "alert", + "data": {"kind": event.kind, "user": event.user, "people": event.people(), "text": event.scene()}, + } + ) + await orch.hub.broadcast({"type": "log", "data": f"Alert {event.kind}: {event.scene()}"}) + if event.kind == "raid": + orch._last_raider = event.user + orch._last_raider_id = str(event.extra.get("from_id") or "") + if event.kind == "hype_progress": + await orch.hub.patch_status(hype_level=event.extra.get("level")) + return + if event.kind == "redeem": + title = str(event.extra.get("title") or "") + spec = orch.settings.reward_spec(title) + if spec.action == "ignore": + await orch._note_skip("reward ignored", event.user) + return + key = title.strip().lower() + if spec.cooldown > 0 and time.monotonic() - orch._reward_at.get(key, 0.0) < spec.cooldown: + await orch._note_skip("reward cooldown", event.user) + return + if spec.cooldown > 0: + orch._reward_at[key] = time.monotonic() + event.extra["delivery"] = spec.action + if spec.prompt: + event.extra["reward_prompt"] = spec.prompt + if event.kind == "follow": + if not orch.settings.announce_follows: + return + orch._follow_names.extend(event.people()) + if not orch._follow_task or orch._follow_task.done(): + orch._follow_task = orch._spawn(orch._flush_follows(), "follow-batch") + return + if event.kind == "cheer": + orch._cheers.append(event) + if not orch._cheer_task or orch._cheer_task.done(): + orch._cheer_task = orch._spawn(orch._flush_cheers(), "cheer-batch") + return + orch._event_pending.append(event) + orch._kick_events() + + +async def _flush_follows(orch) -> None: + try: + await asyncio.sleep(orch.settings.follow_batch_seconds) + except asyncio.CancelledError: + return + await orch._emit_follows() + + +async def _emit_follows(orch) -> None: + names = [n for n in orch._follow_names if n] + orch._follow_names = [] + if not names: + return + batched = StreamEvent(kind="follow", user=names[0], users=names[1:]) + orch._event_pending.append(batched) + orch._kick_events() + + +async def _flush_cheers(orch) -> None: + try: + await asyncio.sleep(orch.settings.follow_batch_seconds) + except asyncio.CancelledError: + return + await orch._emit_cheers() + + +async def _emit_cheers(orch) -> None: + cheers = list(orch._cheers) + orch._cheers = [] + if not cheers: + return + names: list[str] = [] + total = 0 + for ev in cheers: + names.extend(ev.people()) + try: + total += int(ev.extra.get("bits") or 0) + except (TypeError, ValueError): + pass + batched = StreamEvent( + kind="cheer", + user=names[0] if names else "Someone", + users=names[1:], + extra={"bits": total, "total_bits": total}, + ) + orch._event_pending.append(batched) + orch._kick_events() + + +def _kick_events(orch) -> None: + if orch._event_scheduled or orch._stopping: + return + orch._event_scheduled = True + try: + orch._event_q.put_nowait(1) + except Exception: + orch._event_scheduled = False + + +async def _event_worker(orch) -> None: + while not orch._stopping: + await orch._event_q.get() + orch._event_scheduled = False + try: + await orch._drain_events() + except asyncio.CancelledError: + raise + except Exception: + log.exception("event worker failed") + + +async def _drain_events(orch) -> None: + while orch._event_pending and (not orch._stopping): + if orch._busy_voice: + await orch._note_skip("busy on mic", "") + await asyncio.sleep(0.25) + orch._kick_events() + return + event = min(orch._event_pending, key=lambda e: EVENT_PRIORITY.get(e.kind, 9)) + try: + orch._event_pending.remove(event) + except ValueError: + break + await orch._publish_queue() + await orch._react_event(event) + if orch._chat_pending and (not orch._busy_voice): + orch._kick_chat() + + +async def _react_event(orch, event: StreamEvent) -> None: + if orch._busy_voice: + orch._event_pending.appendleft(event) + orch._kick_events() + return + people = event.people() + primary = people[0] if people else event.user or "chat" + thread = ( + orch.memory.viewer_thread_key(primary) + if primary and primary.lower() not in ("anonymous", "chat") + else STREAMER_THREAD + ) + reward_input = str(event.extra.get("input") or "") + if event.kind == "redeem" and is_blocked(reward_input): + orch.memory.remember("twitch_event", primary, "[skipped topic]", via="event", thread=thread) + orch._spawn(orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}), "memory") + await orch._emit_cohost(DEFLECT, thread=thread, speak=False, post=orch.settings.events_post_to_chat) + return + scene = event.scene() + orch.memory.remember("twitch_event", primary, scene, via="event", thread=thread) + orch._spawn(orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}), "memory") + delivery = str(event.extra.get("delivery") or ("speak" if orch.settings.events_speak else "chat")) + if delivery == "ignore": + return + if is_blocked(scene): + reply = DEFLECT + else: + temp = mood_temperature("hype" if event.kind == "raid" and orch.mood == "normal" else orch.mood) + reward_prompt = str(event.extra.get("reward_prompt") or "").strip() + if event.kind == "redeem" and reward_prompt: + user_content = ( + reward_prompt.replace("{user}", primary).replace("{input}", fence_viewer_text(reward_input)) + + "\n\nWords to speak aloud only." + ) + else: + user_content = f"You're on the couch next to {orch.identity.spoken_name()}. This just happened on stream:\n{scene}\n\nHype it like a cohost for a second, then drop it. No corporate thank-you. Words to speak aloud only." + try: + reply = await orch._finish_reply( + [{"role": "system", "content": orch.voice_prompt}, {"role": "user", "content": user_content}], + temperature=temp, + ) + except TimeoutError: + await orch._note_skip("llm timeout", primary) + await orch.hub.patch_status(phase="listening" if orch.mic.listen_enabled else "idle") + return + if not reply: + await orch.hub.patch_status(phase="listening" if orch.mic.listen_enabled else "idle") + return + await orch._emit_cohost( + reply, + thread=thread, + speak=delivery == "speak" and orch.settings.events_speak, + post=orch.settings.events_post_to_chat, + ) + + +async def _bounded_complete(orch, messages: list[dict[str, str]], temperature: float) -> str: + return await asyncio.wait_for( + orch.llm.complete(messages, temperature=temperature), timeout=orch.settings.chat_timeout_seconds + ) + + +async def _finish_reply( + orch, messages: list[dict[str, str]], *, temperature: float, retry: bool = True +) -> str: + raw = await orch._bounded_complete(messages, temperature) + reply = orch._clean(raw) + if retry and (not reply or is_generic_filler(reply, orch._banned_extra())): + raw = await orch._bounded_complete( + messages + [{"role": "user", "content": "shorter, messier, no greeting, no thank-you template."}], + min(1.0, temperature + 0.05), + ) + reply = orch._clean(raw) + if not reply or is_generic_filler(reply, orch._banned_extra()): + return "" + if is_blocked(reply): + return DEFLECT + return reply + + +async def make_recap(orch, *, vibe: bool = False) -> dict[str, str]: + paragraph = "" + if vibe: + draft = build_recap(orch.memory) + try: + raw = await asyncio.wait_for( + orch.llm.complete( + [ + { + "role": "system", + "content": "Write one short paragraph about this stream. No headings.", + }, + {"role": "user", "content": draft or "Quiet stream."}, + ], + temperature=0.7, + ), + timeout=orch.settings.chat_timeout_seconds, + ) + except TimeoutError: + paragraph = "" + else: + paragraph = (raw or "").strip() + text, path = write_recap(orch.memory, orch.data_dir / "recaps", vibe=paragraph) + return {"markdown": text, "path": path.name} diff --git a/src/core/lifecycle.py b/src/core/lifecycle.py new file mode 100644 index 0000000..45606ed --- /dev/null +++ b/src/core/lifecycle.py @@ -0,0 +1,649 @@ +"""Startup, profiles, mood, and model lifecycle for the orchestrator.""" + +from __future__ import annotations + +import asyncio +import logging +from collections import deque +from pathlib import Path + +from src.audio.duck import Duck +from src.audio.mic import MicListener +from src.audio.stt import SpeechToText +from src.booth import BoothState, load_booth, save_booth +from src.hub import Hub +from src.identity import StreamerIdentity, load_identity, save_identity +from src.llm.ollama import OllamaClient +from src.llm.persona import ( + build_chat_prompt, + build_voice_prompt, + load_persona, + mood_addon, + validate_persona_text, +) +from src.memory import ( + STREAMER_THREAD, + ConversationMemory, + never_say_phrases, +) +from src.settings import WHISPER_LANGUAGES, Settings, atomic_write_text +from src.tts.piper import PiperTTS +from src.twitch.chat import ChatLine, TwitchChat +from src.twitch.eventsub import EventSubClient, StreamEvent + +log = logging.getLogger("cohost.orch") + + +def _live(name: str): + """Look up a name tests patch on src.orchestrator, at call time.""" + import src.orchestrator as mod + + return getattr(mod, name) + + +def bind( + orch, + settings: Settings, + hub: Hub, + data_dir: Path | None = None, + *, + llm: OllamaClient | None = None, + tts: PiperTTS | None = None, + stt: SpeechToText | None = None, + mic: MicListener | None = None, + persona_path: Path | None = None, +) -> None: + orch.settings = settings + orch.hub = hub + orch.llm = OllamaClient(settings) if llm is None else llm + orch.stt = SpeechToText(settings) if stt is None else stt + orch.tts = PiperTTS(settings) if tts is None else tts + orch.mic = ( + MicListener(settings, orch.on_utterance, speaking=orch._tts_speaking, on_barge=orch._barge_in) + if mic is None + else mic + ) + orch.data_dir = data_dir or _live("ROOT") / "data" + orch.duck = Duck(settings, data_dir=orch.data_dir) + orch.twitch: TwitchChat | None = None + orch.eventsub: EventSubClient | None = None + orch.persona_path = persona_path or _live("ROOT") / "persona.yaml" + orch.persona = load_persona(orch.persona_path) + orch.identity: StreamerIdentity = load_identity( + orch.data_dir / "streamer.json", default_login=settings.twitch_channel + ) + orch._runtime_base = { + "mood": "normal", + "cooldown_seconds": float(settings.cooldown_seconds), + "per_user_cooldown_seconds": float(settings.per_user_cooldown_seconds), + "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), + } + orch.booth = load_booth( + orch.data_dir / "booth.json", + fallback_mute=settings.tts_mute, + fallback_post=settings.post_voice_replies_to_chat, + ) + orch._base_mood = orch.booth.mood + orch.mood_override = orch.booth.mood_override + orch.mood = orch.booth.effective_mood() + orch.profile_name = "" + orch.settings.post_voice_replies_to_chat = orch.booth.post_voice_replies_to_chat + orch._refresh_prompts() + orch.memory = ConversationMemory( + orch.data_dir / "memory.json", + event_limit=settings.memory_events, + viewer_limit=settings.memory_viewers, + thread_turns=settings.memory_thread_turns, + retention_days=settings.retention_days, + ) + orch.memory.set_streamer_display(orch.identity.spoken_name()) + orch.memory.set_cohost_name(orch._cohost_name()) + if orch.booth.profile: + try: + orch._install_profile(orch.booth.profile, persist=False) + except ValueError: + orch.profile_name = "" + orch._voice_gen = 0 + orch._voice_cancel = asyncio.Event() + orch._chat_pending: deque[ChatLine] = deque(maxlen=60) + orch._reply_times: deque[float] = deque() + orch._last_chat_reply = 0.0 + orch._user_reply_at: dict[str, float] = {} + orch._tasks: list[asyncio.Task] = [] + orch._bg: set[asyncio.Task] = set() + orch._ollama_interval = 30.0 + orch._chat_q: asyncio.Queue[int] = asyncio.Queue() + orch._event_q: asyncio.Queue[int] = asyncio.Queue() + orch._chat_scheduled = False + orch._event_scheduled = False + orch._busy_voice = False + orch._speak_depth = 0 + orch._event_pending: deque[StreamEvent] = deque(maxlen=40) + orch._follow_names: list[str] = [] + orch._follow_task: asyncio.Task | None = None + orch._cheers: list[StreamEvent] = [] + orch._cheer_task: asyncio.Task | None = None + orch.tts_mute = orch.booth.tts_mute + orch._want_twitch = False + orch._want_events = False + orch._eventsub_error = "" + orch._eventsub_lock = asyncio.Lock() + orch._stopping = False + orch._irc_fatal = False + orch._eventsub_fatal = False + orch._token_check_at = 0.0 + orch.irc_auth_note = "" + orch._loop: asyncio.AbstractEventLoop | None = None + orch._last_skip = {"reason": "", "user": ""} + orch._catchphrase_at: dict[str, float] = {} + orch._last_raider = "" + orch._last_raider_id = "" + orch._last_shoutout_at = 0.0 + orch._last_control_at = 0.0 + orch._reward_at: dict[str, float] = {} + orch._last_caption = "" + orch._queue_seq = 0 + orch.timings: dict[str, int | None] = { + "stt_ms": None, + "first_token_ms": None, + "first_audio_ms": None, + "chat_send_ms": None, + } + + +def _refresh_prompts(orch) -> None: + streamer = orch.identity.spoken_name() + addon = mood_addon(orch.mood) + extra = f"\n{addon}\n" if addon else "" + orch.voice_prompt = build_voice_prompt(orch.persona, streamer) + extra + orch.chat_prompt = build_chat_prompt(orch.persona, streamer) + extra + if getattr(orch, "memory", None) is not None: + orch.memory.set_cohost_name(orch._cohost_name()) + + +def _cohost_name(orch) -> str: + return str(orch.persona.get("name") or "Cohost") + + +def _tts_speaking(orch) -> bool: + return bool(getattr(orch.tts, "speaking", False)) + + +def _barge_in(orch) -> None: + orch._spawn(orch.interrupt(), "barge-in") + + +def _phrases(orch) -> list[str]: + raw = orch.persona.get("catchphrases") or [] + if isinstance(raw, str): + raw = [raw] + return [str(p).strip() for p in raw if str(p).strip()] + + +def _banned_extra(orch) -> list[str]: + return never_say_phrases(orch.persona) + + +def _persist_booth(orch) -> None: + orch.booth = BoothState( + listen=orch.mic.listen_enabled, + tts_mute=orch.tts_mute, + post_voice_replies_to_chat=orch.settings.post_voice_replies_to_chat, + mood=orch._base_mood, + profile=orch.profile_name, + mood_override=orch.mood_override, + ) + save_booth(orch.data_dir / "booth.json", orch.booth) + + +def _install_profile(orch, name: str, *, persist: bool) -> str: + wanted = (name or "").strip() + canonical = "" + if wanted: + for key in orch.settings.profiles: + if key.lower() == wanted.lower(): + canonical = key + break + if not canonical: + raise ValueError(f"Unknown profile: {name}") + orch.profile_name = canonical + values = dict(orch._runtime_base) + if canonical: + values.update(orch.settings.profiles[canonical]) + mood = str(values.get("mood") or "normal").lower() + if mood not in ("chill", "normal", "hype"): + mood = "normal" + orch._base_mood = mood + if persist: + orch.mood_override = "" + orch.mood = mood + elif orch.mood_override: + orch.mood = orch.mood_override + else: + orch.mood = mood + orch.settings.cooldown_seconds = float(values["cooldown_seconds"]) + orch.settings.per_user_cooldown_seconds = float(values["per_user_cooldown_seconds"]) + 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._refresh_prompts() + if persist: + orch._persist_booth() + return canonical + + +async def apply_profile(orch, name: str) -> str: + canonical = orch._install_profile(name, persist=True) + await orch.hub.patch_status( + mood=orch.mood, profile=canonical, post_voice=bool(orch.settings.post_voice_replies_to_chat) + ) + return canonical + + +async def start(orch, loop: asyncio.AbstractEventLoop) -> None: + orch._loop = loop + orch._stopping = False + await orch.hub.patch_status( + phase="starting", + tts_mute=orch.tts_mute, + persona=str(orch.persona.get("name") or "Cohost"), + streamer_name=orch.identity.spoken_name(), + streamer_login=orch.identity.twitch_login, + eventsub="disconnected", + mood=orch.mood, + duck=orch.duck.mode, + post_voice=bool(orch.settings.post_voice_replies_to_chat), + duck_enabled=bool(orch.settings.duck_enabled), + wake=orch._wake_status(), + ) + await orch._resolve_audio_devices() + orch._tasks = [ + orch._spawn(orch._load_models(), "load-models"), + orch._spawn(orch._chat_worker(), "chat-worker"), + orch._spawn(orch._event_worker(), "event-worker"), + orch._spawn(orch._chat_scan_loop(), "chat-scan"), + orch._spawn(orch._twitch_watchdog(), "twitch-watch"), + orch._spawn(orch._memory_flush_loop(), "memory-flush"), + orch._spawn(orch._memory_prune_loop(), "memory-prune"), + orch._spawn(orch._level_loop(), "mic-level"), + orch._spawn(orch._ollama_watch(), "ollama-watch"), + ] + try: + orch.mic.start(loop) + except Exception as e: + log.exception("Microphone failed") + await orch.hub.broadcast({"type": "log", "data": f"Microphone failed: {e}"}) + health = await orch.llm.health() + await orch.hub.patch_status(phase="idle", ollama="ok" if health.get("ok") else "down") + + +async def _load_models(orch) -> None: + try: + await asyncio.to_thread(orch.stt.load) + await orch.hub.broadcast({"type": "log", "data": f"Whisper ready on {orch.stt.device}"}) + except Exception as e: + log.exception("Whisper failed to load") + await orch.hub.broadcast({"type": "log", "data": f"Whisper load failed: {e}"}) + try: + await asyncio.to_thread(orch.tts.ensure_files) + msg = "Piper TTS ready" if orch.tts.ready else f"Piper: {orch.tts.error}" + await orch.hub.broadcast({"type": "log", "data": msg}) + except Exception as e: + log.exception("Piper setup failed") + await orch.hub.broadcast({"type": "log", "data": f"Piper setup failed: {e}"}) + ready, why = await orch._models_ready() + await orch.hub.patch_status( + stt_ready=orch.stt.model is not None, tts_ready=orch.tts.ready or not orch.settings.tts_enabled + ) + if orch.booth.listen: + if ready: + await orch.set_listen(True) + else: + await orch.hub.broadcast({"type": "log", "data": f"Not listening yet: {why}"}) + + +async def _models_ready(orch) -> tuple[bool, str]: + if orch.stt.model is None: + return (False, "Whisper is not loaded") + if orch.settings.tts_enabled and (not orch.tts.ready): + return (False, orch.tts.error or "Piper is not ready") + health = await orch.llm.health() + if not health.get("ok"): + return (False, "Ollama is down") + if not health.get("has_model"): + return (False, f"Pull the model: ollama pull {orch.settings.ollama_model}") + return (True, "") + + +async def shutdown(orch) -> None: + orch._stopping = True + for task in (orch._follow_task, orch._cheer_task): + if task and (not task.done()): + task.cancel() + await orch._emit_follows() + await orch._emit_cheers() + for event in list(orch._event_pending): + orch.memory.remember( + "twitch_event", event.user or "chat", event.scene(), via="event", thread=STREAMER_THREAD + ) + for task in list(orch._bg): + task.cancel() + for task in list(orch._bg): + try: + await task + except (asyncio.CancelledError, Exception): + pass + orch._bg.clear() + orch._tasks.clear() + await orch.mic.stop() + if orch.eventsub: + await orch.eventsub.disconnect() + if orch.twitch: + await orch.twitch.disconnect() + orch.tts.interrupt() + orch.tts.close() + orch.duck.up() + await asyncio.to_thread(orch.memory.flush) + await orch.llm.aclose() + + +def set_identity(orch, display_name: str, twitch_login: str) -> StreamerIdentity: + orch.identity.display_name = (display_name or "").strip() + orch.identity.twitch_login = (twitch_login or "").strip().lstrip("#") + if not orch.identity.twitch_login: + orch.identity.twitch_login = (orch.settings.twitch_channel or "").strip().lstrip("#") + save_identity(orch.data_dir / "streamer.json", orch.identity) + orch.memory.set_streamer_display(orch.identity.spoken_name()) + orch._refresh_prompts() + if orch.twitch: + orch.twitch.set_allow_from([orch.identity.login_key()] if orch.identity.login_key() else []) + orch._spawn( + orch.hub.patch_status( + streamer_name=orch.identity.spoken_name(), streamer_login=orch.identity.twitch_login + ), + "identity-status", + ) + login = orch.identity.twitch_login or "?" + orch._spawn( + orch.hub.broadcast({"type": "log", "data": f"Streamer is {orch.identity.spoken_name()} (@{login})"}), + "identity-log", + ) + return orch.identity + + +def forget_viewer(orch, login: str) -> None: + orch.memory.forget(login) + orch.memory.flush() + orch._spawn(orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}), "memory") + + +def clear_memory(orch) -> None: + orch.memory.clear() + orch._user_reply_at.clear() + orch._spawn(orch.hub.broadcast({"type": "log", "data": "Memory cleared"}), "memory-cleared") + orch._spawn(orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}), "memory") + + +def reload_persona(orch) -> None: + orch.persona = load_persona(orch.persona_path) + orch._refresh_prompts() + try: + asyncio.get_running_loop() + except RuntimeError: + return + orch._spawn(orch._after_persona_reload(), "persona-reload") + + +def save_persona_text(orch, text: str) -> str: + data = validate_persona_text(text) + body = text if text.endswith("\n") else text + "\n" + atomic_write_text(orch.persona_path, body, backup=True) + orch.reload_persona() + return str(data.get("name")) + + +async def _after_persona_reload(orch) -> None: + name = str(orch.persona.get("name") or "Cohost") + await orch.hub.patch_status(persona=name) + await orch.hub.broadcast({"type": "log", "data": f"Persona reloaded: {name}"}) + + +async def set_mood(orch, mood: str) -> str: + mood = (mood or "normal").lower() + if mood not in ("chill", "normal", "hype"): + raise ValueError("mood must be chill, normal, or hype") + orch.mood = mood + orch.mood_override = mood + orch._refresh_prompts() + orch._persist_booth() + await orch.hub.patch_status(mood=mood) + return mood + + +async def set_listen(orch, enabled: bool) -> bool: + if enabled: + ready, why = await orch._models_ready() + if not ready: + await orch.hub.broadcast({"type": "log", "data": f"Start listening refused: {why}"}) + await orch.hub.patch_status( + listen=False, + stt_ready=orch.stt.model is not None, + tts_ready=orch.tts.ready or not orch.settings.tts_enabled, + phase="idle", + ) + return False + orch.mic.set_listen(enabled) + phase = "listening" if enabled else "speaking" if orch.tts.speaking else "idle" + orch._persist_booth() + await orch.hub.patch_status( + listen=enabled, + phase=phase, + stt_ready=orch.stt.model is not None, + tts_ready=orch.tts.ready or not orch.settings.tts_enabled, + ) + return True + + +async def set_ptt(orch, down: bool) -> None: + if down: + ready, why = await orch._models_ready() + if not ready: + await orch.hub.broadcast({"type": "log", "data": f"Hold to talk refused: {why}"}) + return + orch.mic.set_ptt(down) + await orch.hub.patch_status(ptt=down, listen=orch.mic.listen_enabled or down) + + +async def set_mute(orch, mute: bool) -> None: + orch.tts_mute = mute + orch.settings.tts_mute = mute + if mute: + orch._bump_voice() + orch._persist_booth() + await orch.hub.patch_status(tts_mute=mute) + + +def set_post_voice(orch, enabled: bool) -> None: + orch.settings.post_voice_replies_to_chat = bool(enabled) + orch.hub.status["post_voice"] = bool(enabled) + orch._persist_booth() + + +def _wake_status(orch) -> dict: + wake = getattr(orch.mic, "wake", None) + if wake is None or not hasattr(wake, "status"): + return {"enabled": False} + try: + reported = wake.status() + except Exception: + return {"enabled": False} + return reported if isinstance(reported, dict) else {"enabled": False} + + +async def set_vad(orch, silence_ms: int, energy: float, max_seconds: float) -> None: + orch.settings.silence_ms = int(silence_ms) + orch.settings.energy_threshold = float(energy) + orch.settings.max_utterance_seconds = float(max_seconds) + await asyncio.to_thread( + _live("update_config"), + { + "audio.silence_ms": int(silence_ms), + "audio.energy_threshold": float(energy), + "audio.max_utterance_seconds": float(max_seconds), + }, + ) + + +async def set_devices( + orch, + mic_device: int | None, + speaker_device: int | None, + mic_name: str | None = None, + speaker_name: str | None = None, +) -> None: + orch.settings.mic_device = mic_device + orch.settings.speaker_device = speaker_device + if mic_name is not None: + orch.settings.mic_device_name = mic_name or None + if speaker_name is not None: + orch.settings.speaker_device_name = speaker_name or None + await asyncio.to_thread( + _live("update_config"), + { + "audio.mic_device": mic_device, + "audio.speaker_device": speaker_device, + "audio.mic_device_name": orch.settings.mic_device_name, + "audio.speaker_device_name": orch.settings.speaker_device_name, + }, + ) + if orch._loop is None: + return + listen = orch.mic.listen_enabled + ptt = orch.mic.ptt_down + await orch.mic.stop() + orch.mic.listen_enabled = listen + orch.mic.ptt_down = ptt + orch.mic.start(orch._loop) + + +async def set_language(orch, language: str) -> None: + language = (language or "").strip().lower() + if language not in WHISPER_LANGUAGES: + raise ValueError(f"unknown language: {language}") + orch.settings.whisper_language = language + await asyncio.to_thread(_live("update_config"), {"whisper.language": language}) + await asyncio.to_thread(orch.stt.load) + await orch.hub.patch_status(stt_ready=orch.stt.model is not None) + + +async def set_voice(orch, voice: str) -> None: + voice = (voice or "").strip() + if voice not in orch.settings.tts_voices: + raise ValueError(f"unknown voice: {voice}") + orch.tts.close() + orch.settings.tts_voice = voice + + def progress(message: str) -> None: + if orch._loop is None: + return + asyncio.run_coroutine_threadsafe(orch.hub.broadcast({"type": "log", "data": message}), orch._loop) + + orch.tts.on_progress = progress + await asyncio.to_thread(orch.tts.ensure_files) + await asyncio.to_thread(_live("update_config"), {"tts.voice": voice}) + await orch.hub.patch_status(tts_ready=orch.tts.ready) + + +def set_duck(orch, enabled: bool) -> None: + orch.duck.set_enabled(enabled) + orch.hub.status["duck_enabled"] = bool(enabled) + orch.hub.status["duck"] = orch.duck.mode + _live("update_config")({"audio.duck_enabled": enabled}) + + +async def _note_skip(orch, reason: str, user: str) -> None: + if orch._last_skip.get("reason") == reason and orch._last_skip.get("user") == user: + return + orch._last_skip = {"reason": reason, "user": user} + await orch.hub.patch_status(skip=dict(orch._last_skip)) + await orch.hub.broadcast({"type": "skip", "data": dict(orch._last_skip)}) + + +async def _memory_prune_loop(orch) -> None: + while not orch._stopping: + try: + await asyncio.sleep(3600) + removed = await asyncio.to_thread(orch.memory.prune) + if removed and orch.memory.dirty: + await asyncio.to_thread(orch.memory.flush) + except asyncio.CancelledError: + raise + except Exception: + log.exception("memory prune failed") + + +async def _memory_flush_loop(orch) -> None: + while not orch._stopping: + try: + await asyncio.sleep(0.4) + if orch.memory.dirty: + await asyncio.to_thread(orch.memory.flush) + except asyncio.CancelledError: + raise + except Exception: + log.exception("memory flush failed") + + +async def _resolve_audio_devices(orch) -> None: + from src.audio.devices import list_devices, match_device + + try: + devices = await asyncio.to_thread(list_devices) + except Exception: + log.exception("device resolve failed") + return + orch.settings.mic_device = match_device( + list(devices.get("inputs") or []), orch.settings.mic_device, orch.settings.mic_device_name + ) + orch.settings.speaker_device = match_device( + list(devices.get("outputs") or []), orch.settings.speaker_device, orch.settings.speaker_device_name + ) + + +async def _ollama_watch(orch) -> None: + while not orch._stopping: + try: + await asyncio.sleep(orch._ollama_interval) + if orch._stopping: + return + health = await orch.llm.health() + await orch.hub.patch_status(ollama="ok" if health.get("ok") else "down") + except asyncio.CancelledError: + raise + except Exception: + log.exception("ollama watch failed") + + +def restore_log_level(orch) -> None: + previous = getattr(orch, "_debug_log_level", logging.NOTSET) + logging.getLogger("cohost").setLevel(previous) + + +async def enable_debug_log(orch, seconds: float = 600) -> None: + logger = logging.getLogger("cohost") + if not getattr(orch, "_debug_log_active", False): + orch._debug_log_level = logger.level + orch._debug_log_active = True + logger.setLevel(logging.DEBUG) + orch._spawn(orch._debug_log_window(seconds), "debug-log") + + +async def _debug_log_window(orch, seconds: float) -> None: + try: + await asyncio.sleep(seconds) + except asyncio.CancelledError: + raise + orch._debug_log_active = False + orch.restore_log_level() diff --git a/src/core/queue.py b/src/core/queue.py new file mode 100644 index 0000000..bf03db1 --- /dev/null +++ b/src/core/queue.py @@ -0,0 +1,89 @@ +"""Dashboard queue ids, snapshots, and drops.""" + +from __future__ import annotations + +import logging + +from src.core.event_flow import EVENT_PRIORITY +from src.twitch.chat import ChatLine +from src.twitch.eventsub import StreamEvent + +log = logging.getLogger("cohost.orch") + + +def _next_qid(orch) -> str: + orch._queue_seq += 1 + return f"q{orch._queue_seq}" + + +def _assign_queue_ids(orch) -> None: + for line in orch._chat_pending: + if not line.id: + line.id = orch._next_qid() + for event in orch._event_pending: + if not event.id: + event.id = orch._next_qid() + + +def _retain_chat(orch, kept: list[ChatLine]) -> None: + orch._chat_pending.clear() + orch._chat_pending.extend(kept) + + +def _retain_events(orch, kept: list[StreamEvent]) -> None: + orch._event_pending.clear() + orch._event_pending.extend(kept) + + +def queue_snapshot(orch) -> dict[str, list]: + orch._assign_queue_ids() + alerts = sorted(orch._event_pending, key=lambda e: EVENT_PRIORITY.get(e.kind, 9)) + chat = [ + {"id": line.id, "user": line.user, "text": line.text[:120]} for line in list(orch._chat_pending)[:12] + ] + return { + "chat": chat, + "alerts": [{"id": event.id, "kind": event.kind, "text": event.scene()} for event in alerts], + } + + +async def drop_queued(orch, item_id: str) -> bool: + orch._assign_queue_ids() + item_id = (item_id or "").strip() + if not item_id: + return False + for line in list(orch._chat_pending): + if line.id == item_id: + orch._retain_chat([item for item in orch._chat_pending if item is not line]) + await orch._publish_queue() + return True + for event in list(orch._event_pending): + if event.id == item_id: + orch._retain_events([item for item in orch._event_pending if item is not event]) + await orch._publish_queue() + return True + return False + + +async def answer_queued(orch, item_id: str) -> bool: + orch._assign_queue_ids() + item_id = (item_id or "").strip() + if not item_id: + return False + for line in list(orch._chat_pending): + if line.id == item_id: + orch._retain_chat([item for item in orch._chat_pending if item is not line]) + await orch._publish_queue() + await orch._perform_chat(line, ignore_rate=True) + return True + for event in list(orch._event_pending): + if event.id == item_id: + orch._retain_events([item for item in orch._event_pending if item is not event]) + await orch._publish_queue() + await orch._react_event(event) + return True + return False + + +async def _publish_queue(orch) -> None: + await orch.hub.patch_status(queue=orch.queue_snapshot()) diff --git a/src/core/twitch_link.py b/src/core/twitch_link.py new file mode 100644 index 0000000..a880711 --- /dev/null +++ b/src/core/twitch_link.py @@ -0,0 +1,296 @@ +"""IRC and EventSub connect, watchdog, shoutout, and markers.""" + +from __future__ import annotations + +import asyncio +import logging +import time + +from src.memory import ( + STREAMER_THREAD, +) +from src.twitch.chat import TwitchChat, irc_scope_problem, looks_like_auth_failure +from src.twitch.eventsub import EventSubClient, _looks_like_auth +from src.twitch.helix import HelixError + +log = logging.getLogger("cohost.orch") + +TOKEN_REVALIDATE_SECONDS = 3600 + + +def _live(name: str): + """Look up a name tests patch on src.orchestrator, at call time.""" + import src.orchestrator as mod + + return getattr(mod, name) + + +async def twitch_connect(orch) -> None: + await orch.connect_irc() + await orch.connect_events() + + +async def connect_irc(orch) -> None: + orch.settings.reload_secrets() + orch._want_twitch = True + orch._irc_fatal = False + await orch._irc_connect() + await orch._validate_irc_token() + + +async def connect_events(orch) -> None: + orch.settings.reload_secrets() + orch._want_events = True + orch._eventsub_fatal = False + orch._eventsub_error = "" + await orch._eventsub_connect() + if orch._eventsub_error: + raise RuntimeError(orch._eventsub_error) + + +async def _irc_connect(orch) -> None: + if orch.twitch: + await orch.twitch.disconnect() + bot_name = orch.settings.twitch_nick + persona_name = str(orch.persona.get("name") or "") + orch.twitch = TwitchChat( + token=orch.settings.twitch_token, + nick=bot_name, + channel=orch.settings.twitch_channel, + on_line=orch.on_chat, + on_status=orch._twitch_status, + buffer_size=orch.settings.chat_buffer_size, + bot_names=[bot_name, persona_name], + allow_from=[orch.identity.login_key()] if orch.identity.login_key() else [], + commands=orch._irc_commands(), + min_send_gap=orch.settings.min_send_gap_seconds, + ) + try: + await orch.twitch.connect() + except Exception as e: + if looks_like_auth_failure(str(e)) or "token rejected" in str(e).lower(): + orch._irc_fatal = True + await orch.hub.patch_status(twitch="token rejected", twitch_error=str(e)) + raise + + +async def _eventsub_connect(orch) -> None: + async with orch._eventsub_lock: + await orch._eventsub_connect_locked() + + +async def _eventsub_connect_locked(orch) -> None: + if not orch.settings.events_enabled: + await orch.hub.patch_status(eventsub="off") + return + if orch.eventsub and orch.eventsub.alive(): + return + if orch.eventsub: + await orch.eventsub.disconnect() + orch.eventsub = None + try: + orch.eventsub = EventSubClient( + token=orch.settings.event_token(), + client_id=orch.settings.twitch_client_id, + channel=orch.settings.twitch_channel, + on_event=orch.on_stream_event, + on_status=orch._eventsub_status, + subscriptions=orch.settings.event_subscriptions(), + ) + await orch.eventsub.connect() + if orch.eventsub.fatal: + orch._eventsub_fatal = True + except Exception as e: + log.warning("EventSub not connected: %s", e) + orch.eventsub = None + fatal = _looks_like_auth(str(e)) or "is required" in str(e).lower() + orch._eventsub_fatal = fatal + orch._eventsub_error = str(e) + state = "token rejected" if fatal else "error" + await orch.hub.patch_status(eventsub=state, eventsub_error=str(e)) + await orch.hub.broadcast({"type": "log", "data": f"EventSub: {e}"}) + + +async def twitch_disconnect(orch) -> None: + await orch.disconnect_irc() + await orch.disconnect_events() + + +async def disconnect_irc(orch) -> None: + orch._want_twitch = False + orch._irc_fatal = False + if orch.twitch: + await orch.twitch.disconnect() + orch.twitch = None + + +async def disconnect_events(orch) -> None: + orch._want_events = False + orch._eventsub_fatal = False + orch._eventsub_error = "" + if orch.eventsub: + await orch.eventsub.disconnect() + orch.eventsub = None + + +async def _twitch_watchdog(orch) -> None: + delay = 5.0 + while not orch._stopping: + try: + await asyncio.sleep(delay) + if not orch.settings.auto_reconnect_twitch or not (orch._want_twitch or orch._want_events): + delay = 5.0 + continue + if orch._want_twitch and (not (orch.twitch and orch.twitch.alive())): + if orch._irc_fatal: + await orch.hub.patch_status(twitch="token rejected") + else: + await orch.hub.patch_status(twitch="reconnecting") + await orch.hub.broadcast({"type": "log", "data": "Twitch chat dropped — reconnecting"}) + try: + await orch._irc_connect() + delay = 5.0 + except Exception as e: + if orch._irc_fatal: + await orch.hub.patch_status(twitch="token rejected", twitch_error=str(e)) + else: + delay = min(delay * 2, 120.0) + await orch.hub.patch_status(twitch="error", twitch_error=str(e)) + await orch.hub.broadcast({"type": "log", "data": f"Twitch reconnect failed: {e}"}) + if ( + orch._want_events + and orch.settings.events_enabled + and (not orch._eventsub_lock.locked()) + and (not (orch.eventsub and orch.eventsub.alive())) + ): + if orch._eventsub_fatal or (orch.eventsub and orch.eventsub.fatal): + orch._eventsub_fatal = True + else: + await orch.hub.patch_status(eventsub="reconnecting") + await orch.hub.broadcast({"type": "log", "data": "EventSub dropped — reconnecting"}) + await orch._eventsub_connect() + if orch.eventsub and orch.eventsub.connected and (time.monotonic() >= orch._token_check_at): + orch._token_check_at = time.monotonic() + TOKEN_REVALIDATE_SECONDS + await orch._revalidate_event_token() + irc_up = not orch._want_twitch or bool(orch.twitch and orch.twitch.alive()) or orch._irc_fatal + events_up = ( + not orch._want_events + or not orch.settings.events_enabled + or orch._eventsub_fatal + or bool(orch.eventsub and orch.eventsub.alive()) + ) + if irc_up and events_up: + delay = 5.0 + except asyncio.CancelledError: + raise + except Exception: + log.exception("twitch watchdog failed") + + +async def _validate_irc_token(orch) -> None: + from src.twitch.auth import validate + + orch.irc_auth_note = "" + try: + meta = await validate(orch.settings.twitch_token) + except Exception as exc: + message = str(exc) + if "HTTP 401" in message or "HTTP 403" in message: + orch._irc_fatal = True + await orch.hub.patch_status(twitch="token rejected", twitch_error=message) + await orch.hub.broadcast({"type": "log", "data": message}) + return + await orch.hub.broadcast({"type": "log", "data": f"IRC token check failed: {message}"}) + return + problem = irc_scope_problem(meta, orch.settings.twitch_nick) + if not problem: + return + orch.irc_auth_note = problem + await orch.hub.patch_status(twitch_error=problem) + await orch.hub.broadcast({"type": "log", "data": problem}) + + +async def _revalidate_event_token(orch) -> None: + if orch.eventsub is None: + return + state = await orch.eventsub.revalidate() + if state != "token rejected": + return + orch._eventsub_fatal = True + await orch.hub.patch_status(eventsub="token rejected") + await orch.hub.broadcast({"type": "log", "data": "EventSub token rejected"}) + + +async def _twitch_status(orch, state: str) -> None: + if state == "token rejected": + orch._irc_fatal = True + await orch.hub.patch_status(twitch=state) + + +async def _eventsub_status(orch, state: str) -> None: + if state == "token rejected": + orch._eventsub_fatal = True + await orch.hub.patch_status(eventsub=state) + + +def _shoutout_ready(orch) -> bool: + wait = orch.settings.shoutout_cooldown_seconds + if wait <= 0: + return True + return time.monotonic() - orch._last_shoutout_at >= wait + + +async def shoutout(orch, login: str | None) -> None: + target = (login or orch._last_raider or "").lstrip("@").strip() + if not target: + raise ValueError("No raider to shout out") + if not orch.settings.twitch_client_id: + raise RuntimeError("TWITCH_CLIENT_ID is required for shoutouts") + if not orch.eventsub or not orch.eventsub.broadcaster_id or (not orch.eventsub.moderator_id): + raise RuntimeError("EventSub is not connected, so the broadcaster id is unknown") + token = orch.settings.event_token() + if target.lower() == orch._last_raider.lower() and orch._last_raider_id: + to_id = orch._last_raider_id + else: + to_id = await _live("user_id")(token, orch.settings.twitch_client_id, target) + try: + await _live("shoutout")( + token, + orch.settings.twitch_client_id, + from_broadcaster_id=orch.eventsub.broadcaster_id, + to_broadcaster_id=to_id, + moderator_id=orch.eventsub.moderator_id, + ) + except HelixError as e: + if e.status == 429: + log.warning("Helix shoutout rate limited (HTTP 429): %s", e) + if e.status in (401, 403): + await orch.hub.patch_status(eventsub="token rejected", eventsub_error=str(e)) + raise + orch._last_shoutout_at = time.monotonic() + if orch._last_raider and target.lower() == orch._last_raider.lower(): + line = f"{target}, welcome in." + else: + template = orch.settings.shoutout_line or "go check out {user}" + line = template.replace("{user}", target) + await orch.say_line(line, post_chat=True, speak=orch.settings.shoutout_speak) + + +async def mark_moment(orch, note: str) -> dict[str, str]: + text = (note or "bookmark").strip() + orch.memory.add_note(STREAMER_THREAD, text, is_streamer=True) + await asyncio.to_thread(orch.memory.flush) + await orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}) + marker_error = "" + if orch.eventsub and orch.eventsub.broadcaster_id and orch.settings.twitch_client_id: + try: + await _live("create_marker")( + orch.settings.event_token(), + orch.settings.twitch_client_id, + orch.eventsub.broadcaster_id, + text, + ) + except Exception as e: + marker_error = str(e) + log.info("stream marker skipped: %s", e) + return {"note": text, "marker_error": marker_error} diff --git a/src/core/voice_flow.py b/src/core/voice_flow.py new file mode 100644 index 0000000..c86b1e9 --- /dev/null +++ b/src/core/voice_flow.py @@ -0,0 +1,364 @@ +"""Mic utterance to spoken reply.""" + +from __future__ import annotations + +import asyncio +import logging +import time + +import numpy as np + +from src.audio.vad import trim_to_speech +from src.llm.persona import ( + mood_temperature, +) +from src.memory import ( + STREAMER_THREAD, + apply_catchphrase_budget, + for_speech, + is_generic_filler, + repeats_recent, + sanitize_reply, +) +from src.safety import DEFLECT, clean_chat_output, fence_viewer_text, is_blocked + +log = logging.getLogger("cohost.orch") + + +def _bump_voice(orch) -> int: + orch._voice_gen += 1 + orch._voice_cancel.set() + orch._voice_cancel = asyncio.Event() + orch.tts.interrupt() + return orch._voice_gen + + +async def interrupt(orch) -> None: + orch._bump_voice() + orch.duck.up() + await orch.hub.patch_status( + speaking=False, caption="", phase="listening" if orch.mic.listen_enabled else "idle" + ) + + +def _clean(orch, raw: str) -> str: + reply = sanitize_reply(raw, str(orch.persona.get("name") or "")) + reply = apply_catchphrase_budget( + reply, orch._phrases(), orch._catchphrase_at, window=orch.settings.catchphrase_seconds + ) + return reply + + +def _chat_text(orch, text: str, target_user: str | None = None) -> str: + return clean_chat_output( + text, target_user, allow_links=orch.settings.allow_links, max_chars=orch.settings.chat_max_chars + ) + + +async def _emit_cohost(orch, reply: str, *, thread: str, speak: bool, post: bool) -> None: + spoken = for_speech(reply) + chat_text = orch._chat_text(reply) + orch.memory.remember( + "cohost_voice" if speak else "cohost_chat", + orch._cohost_name(), + spoken if speak else chat_text, + via="voice" if speak else "chat", + thread=thread, + ) + orch._spawn(orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}), "memory") + orch._remember_caption(spoken) + await orch.hub.broadcast({"type": "transcript", "data": {"role": "cohost", "text": spoken}}) + if post and orch.twitch and orch.twitch.connected: + if not chat_text: + await orch._note_skip("output guard", "") + else: + try: + t0 = time.perf_counter() + await orch.twitch.send(chat_text) + orch.timings["chat_send_ms"] = int((time.perf_counter() - t0) * 1000) + await orch.hub.broadcast( + { + "type": "chat", + "data": {"user": orch.persona.get("name"), "text": chat_text, "bot": True}, + } + ) + except Exception: + log.exception("event chat send failed") + if speak and (not orch.tts_mute) and (not orch._busy_voice): + await orch.hub.patch_status( + phase="speaking", speaking=True, caption=spoken, timings=dict(orch.timings) + ) + await orch._speak(spoken) + await orch.hub.patch_status( + phase="listening" if orch.mic.listen_enabled else "idle", + speaking=False, + caption=spoken[-280:], + timings=dict(orch.timings), + ) + + +async def _speak(orch, text: str) -> None: + if not text or orch.tts_mute: + return + orch._speak_depth += 1 + if orch._speak_depth == 1: + orch.duck.on_speaking(True) + await orch.hub.patch_status(duck=orch.duck.mode, speaking=True) + try: + await orch.tts.speak(text) + finally: + orch._speak_depth = max(0, orch._speak_depth - 1) + if orch._speak_depth == 0: + orch.duck.on_speaking(False) + + +async def on_utterance(orch, audio: np.ndarray) -> None: + gen = orch._bump_voice() + orch._busy_voice = True + started = time.perf_counter() + try: + if orch.stt.model is None: + await orch.hub.broadcast({"type": "log", "data": "Whisper is still loading or failed to load."}) + return + sr = orch.settings.sample_rate + clipped = await asyncio.to_thread(trim_to_speech, audio, sr) + await orch.hub.patch_status(phase="thinking", speaking=False) + try: + text = await asyncio.to_thread(orch.stt.transcribe, clipped) + except Exception as e: + log.exception("transcribe failed") + 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) + if gen != orch._voice_gen: + return + if not text: + await orch.hub.patch_status( + phase="listening" if orch.mic.listen_enabled else "idle", timings=dict(orch.timings) + ) + return + await orch._handle_voice_text(text, gen, started) + finally: + orch._busy_voice = False + if orch._event_pending: + orch._kick_events() + elif orch._chat_pending: + orch._kick_chat() + + +async def pretend(orch, text: str) -> None: + text = (text or "").strip() + if not text: + return + gen = orch._bump_voice() + orch._busy_voice = True + try: + await orch._handle_voice_text(text, gen, time.perf_counter()) + finally: + orch._busy_voice = False + if orch._event_pending: + orch._kick_events() + elif orch._chat_pending: + orch._kick_chat() + + +async def _handle_voice_text(orch, text: str, gen: int, started: float) -> None: + await orch.hub.patch_status( + last_user=text, streamer_name=orch.identity.spoken_name(), timings=dict(orch.timings) + ) + await orch.hub.broadcast( + { + "type": "transcript", + "data": {"role": "streamer", "text": text, "name": orch.identity.spoken_name()}, + } + ) + stored = "[skipped topic]" if is_blocked(text) else text + orch.memory.remember( + "streamer_voice", orch.identity.spoken_name(), stored, via="voice", thread=STREAMER_THREAD + ) + await orch._voice_reply(text, gen, started) + + +def _remember_caption(orch, text: str) -> None: + cleaned = (text or "").strip() + if cleaned: + orch._last_caption = cleaned + + +async def replay_last(orch) -> bool: + text = (orch._last_caption or "").strip() + if not text: + return False + await orch.say_line(text, post_chat=False) + return True + + +async def say_line(orch, text: str, post_chat: bool = True, speak: bool = True) -> None: + reply = sanitize_reply(text, str(orch.persona.get("name") or "")) + chat_text = orch._chat_text(reply) + if not chat_text: + await orch._note_skip("output guard", "") + return + spoken = for_speech(chat_text) + orch._remember_caption(spoken) + if post_chat and orch.twitch and orch.twitch.connected: + await orch.twitch.send(chat_text) + await orch.hub.broadcast( + {"type": "chat", "data": {"user": orch.persona.get("name"), "text": chat_text, "bot": True}} + ) + await orch.hub.broadcast({"type": "transcript", "data": {"role": "cohost", "text": spoken}}) + if speak and (not orch.tts_mute): + await orch.hub.patch_status(phase="speaking", speaking=True, caption=spoken) + await orch._speak(spoken) + await orch.hub.patch_status( + phase="listening" if orch.mic.listen_enabled else "idle", speaking=False, caption=spoken[-280:] + ) + + +def _chat_context(orch, limit: int | None = None) -> str: + n = orch.settings.chat_context_lines if limit is None else limit + if not orch.twitch: + return "(no chat connected)" + lines = orch.twitch.recent(n) + rendered = [] + for line in lines: + if is_blocked(line.text): + continue + rendered.append(f"{line.user}: {fence_viewer_text(line.text)}") + return "\n".join(rendered) if rendered else "(chat is quiet)" + + +def _history_messages(orch, thread_key: str, limit: int | None = None) -> list[dict[str, str]]: + banned = orch._banned_extra() + kept = [] + for msg in orch.memory.llm_messages(thread_key, limit=limit): + if msg["role"] == "assistant" and is_generic_filler(msg["content"], banned): + continue + kept.append(msg) + return kept + + +def _reply_is_bad(orch, reply: str, previous: str, recent: list[str] | None = None) -> bool: + if not reply or is_generic_filler(reply, orch._banned_extra()): + return True + earlier = [previous, *(recent or [])] + return repeats_recent(reply, earlier) + + +async def _voice_reply(orch, user_text: str, gen: int, started: float) -> None: + cancel = orch._voice_cancel + name = orch.identity.spoken_name() + if is_blocked(user_text): + reply = DEFLECT + if gen != orch._voice_gen: + return + if not orch.tts_mute: + await orch.hub.patch_status(phase="speaking", speaking=True, caption=reply) + await orch._speak(reply) + await orch._emit_voice_result(name, user_text, reply, gen, started, post=False) + return + messages = [ + {"role": "system", "content": orch.voice_prompt}, + *orch._history_messages(STREAMER_THREAD), + { + "role": "user", + "content": f"You're on the couch next to {name}. They just said into the mic:\n{fence_viewer_text(user_text)}\n\n{orch.memory.prompt_block(STREAMER_THREAD, streamer_name=name, is_voice=True)}\n\nPeople talking in chat (not {name}):\n{orch._chat_context()}\n\nReact to {name} like you're sitting next to them. Don't recap. Don't @ anyone.", + }, + ] + full: list[str] = [] + first_token = False + first_audio = False + async for sentence in orch.llm.stream_sentences( + messages, + temperature=mood_temperature(orch.mood), + cancel=cancel, + max_chars=orch.settings.max_sentence_chars, + ): + if gen != orch._voice_gen or cancel.is_set(): + return + if not first_token: + first_token = True + orch.timings["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 + if is_blocked(sentence): + sentence = DEFLECT + full.append(sentence) + await orch.hub.patch_status( + phase="speaking", speaking=True, caption=" ".join(full)[-280:], timings=dict(orch.timings) + ) + if not orch.tts_mute: + await orch._speak(sentence) + if not first_audio: + first_audio = True + orch.timings["first_audio_ms"] = int((time.perf_counter() - started) * 1000) + if gen != orch._voice_gen: + return + reply = orch._clean(" ".join(full).strip()) + if not reply: + err = orch.llm.last_error or "empty model reply" + await orch.hub.broadcast({"type": "log", "data": f"LLM: {err}"}) + await orch.hub.patch_status(phase="listening" if orch.mic.listen_enabled else "idle", speaking=False) + return + if gen != orch._voice_gen: + return + await orch._emit_voice_result(name, user_text, reply, gen, started, post=True) + + +async def _emit_voice_result( + orch, name: str, user_text: str, reply: str, gen: int, started: float, post: bool +) -> None: + if gen != orch._voice_gen: + return + orch.memory.remember("cohost_voice", orch._cohost_name(), reply, via="voice", thread=STREAMER_THREAD) + orch._spawn(orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}), "memory") + orch._remember_caption(reply) + await orch.hub.broadcast({"type": "transcript", "data": {"role": "cohost", "text": reply}}) + if post and orch.settings.post_voice_replies_to_chat and orch.twitch and orch.twitch.connected: + if gen != orch._voice_gen: + return + short = orch._chat_text(reply) + if not short: + await orch._note_skip("output guard", "") + else: + try: + t0 = time.perf_counter() + await orch.twitch.send(short) + orch.timings["chat_send_ms"] = int((time.perf_counter() - t0) * 1000) + orch.memory.remember( + "cohost_chat", orch._cohost_name(), short, via="chat", thread=STREAMER_THREAD + ) + await orch.hub.broadcast( + {"type": "chat", "data": {"user": orch.persona.get("name"), "text": short, "bot": True}} + ) + except Exception: + log.exception("failed to echo voice reply to chat") + await orch.hub.patch_status( + phase="listening" if orch.mic.listen_enabled else "idle", + speaking=False, + caption=reply[-280:], + timings=dict(orch.timings), + ) + + +async def _level_loop(orch) -> None: + while not orch._stopping: + try: + await asyncio.sleep(0.2) + wake = getattr(orch.mic, "wake", None) + wake_loaded = getattr(wake, "model", None) is not None + stream_open = getattr(orch.mic, "_stream", None) is not None + if stream_open and (orch.mic.listen_enabled or orch.mic.ptt_down or wake_loaded): + score = float(getattr(orch.mic, "wake_score", getattr(wake, "score", 0.0)) or 0.0) + await orch.hub.broadcast( + { + "type": "level", + "data": {"level": round(float(orch.mic.level), 4), "wake": round(score, 4)}, + } + ) + except asyncio.CancelledError: + raise + except Exception: + log.exception("level loop failed") diff --git a/src/identity.py b/src/identity.py index bdf9b89..9e8406f 100644 --- a/src/identity.py +++ b/src/identity.py @@ -1,9 +1,15 @@ from __future__ import annotations import json +import logging +import shutil from dataclasses import asdict, dataclass from pathlib import Path +from src.settings import atomic_write_text + +log = logging.getLogger("cohost.identity") + @dataclass class StreamerIdentity: @@ -23,6 +29,15 @@ def is_login(self, name: str) -> bool: return name.strip().lstrip("@").lower() == key +def _preserve_bad(path: Path) -> None: + bad = path.with_name(path.name + ".bad") + try: + shutil.copy2(path, bad) + except Exception: + log.exception("could not copy corrupt %s", path.name) + log.error("%s is unreadable; kept a copy at %s", path.name, bad.name) + + def load_identity(path: Path, default_login: str = "") -> StreamerIdentity: ident = StreamerIdentity(twitch_login=(default_login or "").strip().lstrip("#")) if path.exists(): @@ -32,12 +47,13 @@ def load_identity(path: Path, default_login: str = "") -> StreamerIdentity: login = str(data.get("twitch_login") or ident.twitch_login).strip().lstrip("#") ident.twitch_login = login except Exception: - pass + log.exception("streamer.json is unreadable") + _preserve_bad(path) if not ident.twitch_login and default_login: ident.twitch_login = default_login.strip().lstrip("#") return ident def save_identity(path: Path, ident: StreamerIdentity) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(asdict(ident), ensure_ascii=False, indent=2), encoding="utf-8") + body = json.dumps(asdict(ident), ensure_ascii=False, indent=2) + atomic_write_text(path, body + "\n") diff --git a/src/logsetup.py b/src/logsetup.py index c804fed..7a23df1 100644 --- a/src/logsetup.py +++ b/src/logsetup.py @@ -29,7 +29,12 @@ def filter(self, record: logging.LogRecord) -> bool: return True -def setup_logging(secrets: list[str] | None = None) -> None: +def setup_logging( + secrets: list[str] | None = None, + *, + level: str = "INFO", + debug_modules: list[str] | None = None, +) -> None: log_dir = ROOT / "logs" log_dir.mkdir(parents=True, exist_ok=True) redactor = RedactFilter(secrets) @@ -47,6 +52,8 @@ def setup_logging(secrets: list[str] | None = None) -> None: stream.addFilter(redactor) root = logging.getLogger() root.handlers.clear() - root.setLevel(logging.INFO) + root.setLevel(getattr(logging, (level or "INFO").upper(), logging.INFO)) root.addHandler(stream) root.addHandler(file_handler) + for name in debug_modules or []: + logging.getLogger(str(name)).setLevel(logging.DEBUG) diff --git a/src/main.py b/src/main.py index f09247c..04e254c 100644 --- a/src/main.py +++ b/src/main.py @@ -109,6 +109,10 @@ class RecapBody(BaseModel): vibe: bool = False +class DebugLogBody(BaseModel): + enabled: bool = True + + def _booth_port(prepared: tuple[Settings, Hub, Orchestrator] | None) -> int: """Port for the Host allow list. @@ -123,7 +127,7 @@ def _booth_port(prepared: tuple[Settings, Hub, Orchestrator] | None) -> int: with (ROOT / "config.yaml").open(encoding="utf-8") as handle: data = yaml.safe_load(handle) or {} return int((data.get("server") or {}).get("port") or 8080) - except (OSError, TypeError, ValueError): + except Exception: return 8080 @@ -140,7 +144,11 @@ async def life(app: FastAPI): return ensure_cuda_dlls() settings = load_settings() - setup_logging([settings.twitch_token, settings.twitch_event_token, settings.event_token()]) + setup_logging( + [settings.twitch_token, settings.twitch_event_token, settings.event_token()], + level=settings.log_level, + debug_modules=settings.log_debug_modules, + ) bind = os.getenv("COHOST_BIND_HOST", settings.host) assert_bind_allowed(bind, settings.allow_remote) if settings.missing_secrets() and os.getenv("COHOST_SKIP_SECRET_CHECK") != "1": @@ -264,7 +272,8 @@ async def pretend(body: PretendBody): @app.get("/api/persona") async def persona_get(): - text = (ROOT / "persona.yaml").read_text(encoding="utf-8") + path = app.state.orch.persona_path + text = path.read_text(encoding="utf-8") if path.exists() else "" return {"text": text, "name": app.state.orch.persona.get("name")} @app.post("/api/persona") @@ -280,6 +289,15 @@ async def persona_reload(): app.state.orch.reload_persona() return {"ok": True, "name": app.state.orch.persona.get("name")} + @app.post("/api/logging/debug") + async def debug_logging(body: DebugLogBody): + if body.enabled: + await app.state.orch.enable_debug_log() + return {"ok": True, "debug": True, "seconds": 600} + app.state.orch.restore_log_level() + app.state.orch._debug_log_active = False + return {"ok": True, "debug": False} + @app.post("/api/mood") async def mood(body: MoodBody): try: diff --git a/src/memory.py b/src/memory.py index 3d1ee24..541ce4d 100644 --- a/src/memory.py +++ b/src/memory.py @@ -140,9 +140,9 @@ def toks(raw: str) -> set[str]: return len(left & right) / min(len(left), len(right)) >= 0.6 -def streamer_smalltalk_fallback(text: str, name: str = "Pixel") -> str: +def streamer_smalltalk_fallback(text: str, name: str = "Cohost") -> str: """A short in-character line when the model only produced an interview template.""" - who = (name or "Pixel").strip() or "Pixel" + who = (name or "Cohost").strip() or "Cohost" low = re.sub(r"[^a-z0-9' ?]", "", (text or "").lower()).strip() if re.search(r"\bhow are (you|ya|u)\b", low): return "pretty good, parked on the couch. you?" diff --git a/src/orchestrator.py b/src/orchestrator.py index e5eb1f2..70dfa8b 100644 --- a/src/orchestrator.py +++ b/src/orchestrator.py @@ -1,200 +1,49 @@ +"""Orchestrator facade. Behaviour lives in src.core; this class keeps the public methods.""" + from __future__ import annotations import asyncio import logging -import time -from collections import deque -from pathlib import Path - -import numpy as np - -from src.audio.duck import Duck -from src.audio.mic import MicListener -from src.audio.stt import SpeechToText -from src.audio.vad import trim_to_speech -from src.booth import BoothState, load_booth, save_booth -from src.hub import Hub -from src.identity import StreamerIdentity, load_identity, save_identity -from src.llm.ollama import OllamaClient -from src.llm.persona import ( - build_chat_prompt, - build_voice_prompt, - load_persona, - mood_addon, - mood_temperature, - validate_persona_text, -) -from src.memory import ( - STREAMER_THREAD, - ConversationMemory, - apply_catchphrase_budget, - ensure_chat_mention, - for_speech, - is_generic_filler, - never_say_phrases, - repeats_recent, - sanitize_reply, - streamer_smalltalk_fallback, -) -from src.policy import chat_action, mention_waits, pixel_control -from src.recap import build_recap, write_recap -from src.safety import DEFLECT, clean_chat_output, fence_viewer_text, is_blocked -from src.settings import ROOT, WHISPER_LANGUAGES, Settings, atomic_write_text, update_config -from src.tts.piper import PiperTTS -from src.twitch.chat import ChatLine, TwitchChat, irc_scope_problem, looks_like_auth_failure -from src.twitch.eventsub import EventSubClient, StreamEvent, _looks_like_auth -from src.twitch.helix import HelixError, create_marker, shoutout, user_id + +from src.core import chat_flow, event_flow, lifecycle, queue, twitch_link, voice_flow +from src.core.event_flow import EVENT_PRIORITY +from src.core.twitch_link import TOKEN_REVALIDATE_SECONDS +from src.settings import ROOT, update_config +from src.twitch.helix import create_marker, shoutout, user_id log = logging.getLogger("cohost.orch") -TOKEN_REVALIDATE_SECONDS = 3600 - -EVENT_PRIORITY = { - "raid": 0, - "gift": 1, - "sub": 2, - "resub": 3, - "cheer": 4, - "hype_begin": 5, - "hype_end": 5, - "redeem": 6, - "follow": 7, - "poll_start": 8, - "poll_end": 8, - "prediction_start": 8, - "prediction_end": 8, -} +__all__ = [ + "EVENT_PRIORITY", + "Orchestrator", + "ROOT", + "TOKEN_REVALIDATE_SECONDS", + "create_marker", + "shoutout", + "update_config", + "user_id", +] class Orchestrator: def __init__( - self, - settings: Settings, - hub: Hub, - data_dir: Path | None = None, - *, - llm: OllamaClient | None = None, - tts: PiperTTS | None = None, - stt: SpeechToText | None = None, - mic: MicListener | None = None, - persona_path: Path | None = None, - ) -> None: - self.settings = settings - self.hub = hub - # Tests pass fakes so importing this class never has to open a mic, model, or speaker. - self.llm = OllamaClient(settings) if llm is None else llm - self.stt = SpeechToText(settings) if stt is None else stt - self.tts = PiperTTS(settings) if tts is None else tts - self.mic = ( - MicListener(settings, self.on_utterance, speaking=self._tts_speaking, on_barge=self._barge_in) - if mic is None - else mic - ) - self.data_dir = data_dir or (ROOT / "data") - self.duck = Duck(settings, data_dir=self.data_dir) - self.twitch: TwitchChat | None = None - self.eventsub: EventSubClient | None = None - self.persona_path = persona_path or (ROOT / "persona.yaml") - self.persona = load_persona(self.persona_path) - self.identity: StreamerIdentity = load_identity( - self.data_dir / "streamer.json", - default_login=settings.twitch_channel, + self, settings, hub, data_dir=None, *, llm=None, tts=None, stt=None, mic=None, persona_path=None + ): + return lifecycle.bind( + self, settings, hub, data_dir, llm=llm, tts=tts, stt=stt, mic=mic, persona_path=persona_path ) - self._runtime_base = { - "mood": "normal", - "cooldown_seconds": float(settings.cooldown_seconds), - "per_user_cooldown_seconds": float(settings.per_user_cooldown_seconds), - "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), - } - self.booth = load_booth( - self.data_dir / "booth.json", - fallback_mute=settings.tts_mute, - fallback_post=settings.post_voice_replies_to_chat, - ) - self.mood = self.booth.mood - self.profile_name = "" - self.settings.post_voice_replies_to_chat = self.booth.post_voice_replies_to_chat - self._refresh_prompts() - self.memory = ConversationMemory( - self.data_dir / "memory.json", - event_limit=settings.memory_events, - viewer_limit=settings.memory_viewers, - thread_turns=settings.memory_thread_turns, - retention_days=settings.retention_days, - ) - self.memory.set_streamer_display(self.identity.spoken_name()) - self.memory.set_cohost_name(self._cohost_name()) - if self.booth.profile: - try: - self._install_profile(self.booth.profile, persist=False) - except ValueError: - self.profile_name = "" - self._voice_gen = 0 - self._voice_cancel = asyncio.Event() - self._chat_pending: deque[ChatLine] = deque(maxlen=60) - self._reply_times: deque[float] = deque() - self._last_chat_reply = 0.0 - self._user_reply_at: dict[str, float] = {} - self._tasks: list[asyncio.Task] = [] - self._bg: set[asyncio.Task] = set() - self._ollama_interval = 30.0 - self._chat_q: asyncio.Queue[int] = asyncio.Queue() - self._event_q: asyncio.Queue[int] = asyncio.Queue() - self._chat_scheduled = False - self._event_scheduled = False - self._busy_voice = False - self._speak_depth = 0 - self._event_pending: deque[StreamEvent] = deque(maxlen=40) - self._follow_names: list[str] = [] - self._follow_task: asyncio.Task | None = None - self._cheers: list[StreamEvent] = [] - self._cheer_task: asyncio.Task | None = None - self.tts_mute = self.booth.tts_mute - self._want_twitch = False - self._want_events = False - self._eventsub_error = "" - self._eventsub_lock = asyncio.Lock() - self._stopping = False - self._irc_fatal = False - self._eventsub_fatal = False - self._token_check_at = 0.0 - self.irc_auth_note = "" - self._loop: asyncio.AbstractEventLoop | None = None - self._last_skip = {"reason": "", "user": ""} - self._catchphrase_at: dict[str, float] = {} - self._last_raider = "" - self._last_raider_id = "" - self._last_shoutout_at = 0.0 - self._last_control_at = 0.0 - self._reward_at: dict[str, float] = {} - self._last_caption = "" - self._queue_seq = 0 - self.timings: dict[str, int | None] = { - "stt_ms": None, - "first_token_ms": None, - "first_audio_ms": None, - "chat_send_ms": None, - } - - def _refresh_prompts(self) -> None: - streamer = self.identity.spoken_name() - addon = mood_addon(self.mood) - extra = f"\n{addon}\n" if addon else "" - self.voice_prompt = build_voice_prompt(self.persona, streamer) + extra - self.chat_prompt = build_chat_prompt(self.persona, streamer) + extra - if getattr(self, "memory", None) is not None: - self.memory.set_cohost_name(self._cohost_name()) - - def _cohost_name(self) -> str: - return str(self.persona.get("name") or "Cohost") - - def _tts_speaking(self) -> bool: - return bool(getattr(self.tts, "speaking", False)) - - def _barge_in(self) -> None: - self._spawn(self.interrupt(), "barge-in") + + def _refresh_prompts(self): + return lifecycle._refresh_prompts(self) + + def _cohost_name(self): + return lifecycle._cohost_name(self) + + def _tts_speaking(self): + return lifecycle._tts_speaking(self) + + def _barge_in(self): + return lifecycle._barge_in(self) def _spawn(self, coro, name: str | None = None) -> asyncio.Task: task = asyncio.create_task(coro, name=name) @@ -211,1667 +60,329 @@ def _on_bg_done(self, task: asyncio.Task) -> None: return log.error("background task %s failed", task.get_name(), exc_info=exc) - def _phrases(self) -> list[str]: - raw = self.persona.get("catchphrases") or [] - if isinstance(raw, str): - raw = [raw] - return [str(p).strip() for p in raw if str(p).strip()] - - def _banned_extra(self) -> list[str]: - return never_say_phrases(self.persona) - - def _persist_booth(self) -> None: - self.booth = BoothState( - listen=self.mic.listen_enabled, - tts_mute=self.tts_mute, - post_voice_replies_to_chat=self.settings.post_voice_replies_to_chat, - mood=self.mood, - profile=self.profile_name, - ) - save_booth(self.data_dir / "booth.json", self.booth) - - def _install_profile(self, name: str, *, persist: bool) -> str: - wanted = (name or "").strip() - canonical = "" - if wanted: - for key in self.settings.profiles: - if key.lower() == wanted.lower(): - canonical = key - break - if not canonical: - raise ValueError(f"Unknown profile: {name}") - self.profile_name = canonical - values = dict(self._runtime_base) - if canonical: - values.update(self.settings.profiles[canonical]) - mood = str(values.get("mood") or "normal").lower() - if mood not in ("chill", "normal", "hype"): - mood = "normal" - self.mood = mood - self.settings.cooldown_seconds = float(values["cooldown_seconds"]) - self.settings.per_user_cooldown_seconds = float(values["per_user_cooldown_seconds"]) - self.settings.events_speak = bool(values["events_speak"]) - self.settings.post_voice_replies_to_chat = bool(values["post_voice_replies_to_chat"]) - self.settings.max_replies_per_minute = int(values["max_replies_per_minute"]) - self._refresh_prompts() - if persist: - self._persist_booth() - return canonical - - async def apply_profile(self, name: str) -> str: - canonical = self._install_profile(name, persist=True) - await self.hub.patch_status( - mood=self.mood, - profile=canonical, - post_voice=bool(self.settings.post_voice_replies_to_chat), - ) - return canonical - - async def start(self, loop: asyncio.AbstractEventLoop) -> None: - self._loop = loop - self._stopping = False - await self.hub.patch_status( - phase="starting", - tts_mute=self.tts_mute, - persona=str(self.persona.get("name") or "Cohost"), - streamer_name=self.identity.spoken_name(), - streamer_login=self.identity.twitch_login, - eventsub="disconnected", - mood=self.mood, - duck=self.duck.mode, - post_voice=bool(self.settings.post_voice_replies_to_chat), - duck_enabled=bool(self.settings.duck_enabled), - wake=self._wake_status(), - ) - await self._resolve_audio_devices() - self._tasks = [ - self._spawn(self._load_models(), "load-models"), - self._spawn(self._chat_worker(), "chat-worker"), - self._spawn(self._event_worker(), "event-worker"), - self._spawn(self._chat_scan_loop(), "chat-scan"), - self._spawn(self._twitch_watchdog(), "twitch-watch"), - self._spawn(self._memory_flush_loop(), "memory-flush"), - self._spawn(self._memory_prune_loop(), "memory-prune"), - self._spawn(self._level_loop(), "mic-level"), - self._spawn(self._ollama_watch(), "ollama-watch"), - ] - try: - self.mic.start(loop) - except Exception as e: - log.exception("Microphone failed") - await self.hub.broadcast({"type": "log", "data": f"Microphone failed: {e}"}) - health = await self.llm.health() - await self.hub.patch_status( - phase="idle", - ollama="ok" if health.get("ok") else "down", - ) + def _phrases(self): + return lifecycle._phrases(self) - async def _load_models(self) -> None: - try: - await asyncio.to_thread(self.stt.load) - await self.hub.broadcast({"type": "log", "data": f"Whisper ready on {self.stt.device}"}) - except Exception as e: - log.exception("Whisper failed to load") - await self.hub.broadcast({"type": "log", "data": f"Whisper load failed: {e}"}) - try: - await asyncio.to_thread(self.tts.ensure_files) - msg = "Piper TTS ready" if self.tts.ready else f"Piper: {self.tts.error}" - await self.hub.broadcast({"type": "log", "data": msg}) - except Exception as e: - log.exception("Piper setup failed") - await self.hub.broadcast({"type": "log", "data": f"Piper setup failed: {e}"}) - ready, why = await self._models_ready() - await self.hub.patch_status(stt_ready=self.stt.model is not None, tts_ready=self.tts.ready or not self.settings.tts_enabled) - if self.booth.listen: - if ready: - await self.set_listen(True) - else: - await self.hub.broadcast({"type": "log", "data": f"Not listening yet: {why}"}) - - async def _models_ready(self) -> tuple[bool, str]: - if self.stt.model is None: - return False, "Whisper is not loaded" - if self.settings.tts_enabled and not self.tts.ready: - return False, self.tts.error or "Piper is not ready" - health = await self.llm.health() - if not health.get("ok"): - return False, "Ollama is down" - if not health.get("has_model"): - return False, f"Pull the model: ollama pull {self.settings.ollama_model}" - return True, "" - - async def shutdown(self) -> None: - self._stopping = True - for task in (self._follow_task, self._cheer_task): - if task and not task.done(): - task.cancel() - await self._emit_follows() - await self._emit_cheers() - for event in list(self._event_pending): - self.memory.remember( - "twitch_event", - event.user or "chat", - event.scene(), - via="event", - thread=STREAMER_THREAD, - ) - for task in list(self._bg): - task.cancel() - for task in list(self._bg): - try: - await task - except (asyncio.CancelledError, Exception): - pass - self._bg.clear() - self._tasks.clear() - await self.mic.stop() - if self.eventsub: - await self.eventsub.disconnect() - if self.twitch: - await self.twitch.disconnect() - self.tts.interrupt() - self.tts.close() - self.duck.up() - await asyncio.to_thread(self.memory.flush) - await self.llm.aclose() - - def set_identity(self, display_name: str, twitch_login: str) -> StreamerIdentity: - self.identity.display_name = (display_name or "").strip() - self.identity.twitch_login = (twitch_login or "").strip().lstrip("#") - if not self.identity.twitch_login: - self.identity.twitch_login = (self.settings.twitch_channel or "").strip().lstrip("#") - save_identity(self.data_dir / "streamer.json", self.identity) - self.memory.set_streamer_display(self.identity.spoken_name()) - self._refresh_prompts() - if self.twitch: - self.twitch.set_allow_from([self.identity.login_key()] if self.identity.login_key() else []) - self._spawn( - self.hub.patch_status( - streamer_name=self.identity.spoken_name(), - streamer_login=self.identity.twitch_login, - ), - "identity-status", - ) - self._spawn( - self.hub.broadcast( - {"type": "log", "data": f"Streamer is {self.identity.spoken_name()} (@{self.identity.twitch_login or '?'})"} - ), - "identity-log", - ) - return self.identity - - def forget_viewer(self, login: str) -> None: - self.memory.forget(login) - self.memory.flush() - self._spawn(self.hub.broadcast({"type": "memory", "data": self.memory.snapshot()}), "memory") - - def clear_memory(self) -> None: - self.memory.clear() - self._user_reply_at.clear() - self._spawn(self.hub.broadcast({"type": "log", "data": "Memory cleared"}), "memory-cleared") - self._spawn(self.hub.broadcast({"type": "memory", "data": self.memory.snapshot()}), "memory") - - def reload_persona(self) -> None: - self.persona = load_persona(self.persona_path) - self._refresh_prompts() - try: - asyncio.get_running_loop() - except RuntimeError: - return - self._spawn(self._after_persona_reload(), "persona-reload") - - def save_persona_text(self, text: str) -> str: - data = validate_persona_text(text) - body = text if text.endswith("\n") else text + "\n" - atomic_write_text(self.persona_path, body, backup=True) - self.reload_persona() - return str(data.get("name")) - - async def _after_persona_reload(self) -> None: - name = str(self.persona.get("name") or "Cohost") - await self.hub.patch_status(persona=name) - await self.hub.broadcast({"type": "log", "data": f"Persona reloaded: {name}"}) - - async def set_mood(self, mood: str) -> str: - mood = (mood or "normal").lower() - if mood not in ("chill", "normal", "hype"): - raise ValueError("mood must be chill, normal, or hype") - self.mood = mood - self._refresh_prompts() - self._persist_booth() - await self.hub.patch_status(mood=mood) - return mood - - async def set_listen(self, enabled: bool) -> bool: - if enabled: - ready, why = await self._models_ready() - if not ready: - await self.hub.broadcast({"type": "log", "data": f"Start listening refused: {why}"}) - await self.hub.patch_status( - listen=False, - stt_ready=self.stt.model is not None, - tts_ready=self.tts.ready or not self.settings.tts_enabled, - phase="idle", - ) - return False - self.mic.set_listen(enabled) - phase = "listening" if enabled else ("speaking" if self.tts.speaking else "idle") - self._persist_booth() - await self.hub.patch_status( - listen=enabled, - phase=phase, - stt_ready=self.stt.model is not None, - tts_ready=self.tts.ready or not self.settings.tts_enabled, - ) - return True - - async def set_ptt(self, down: bool) -> None: - if down: - ready, why = await self._models_ready() - if not ready: - await self.hub.broadcast({"type": "log", "data": f"Hold to talk refused: {why}"}) - return - self.mic.set_ptt(down) - await self.hub.patch_status(ptt=down, listen=self.mic.listen_enabled or down) - - async def set_mute(self, mute: bool) -> None: - self.tts_mute = mute - self.settings.tts_mute = mute - if mute: - self._bump_voice() - self._persist_booth() - await self.hub.patch_status(tts_mute=mute) - - def set_post_voice(self, enabled: bool) -> None: - self.settings.post_voice_replies_to_chat = bool(enabled) - self.hub.status["post_voice"] = bool(enabled) - self._persist_booth() - - def _wake_status(self) -> dict: - wake = getattr(self.mic, "wake", None) - if wake is None or not hasattr(wake, "status"): - return {"enabled": False} - try: - reported = wake.status() - except Exception: - return {"enabled": False} - return reported if isinstance(reported, dict) else {"enabled": False} - - async def set_vad(self, silence_ms: int, energy: float, max_seconds: float) -> None: - self.settings.silence_ms = int(silence_ms) - self.settings.energy_threshold = float(energy) - self.settings.max_utterance_seconds = float(max_seconds) - await asyncio.to_thread( - update_config, - { - "audio.silence_ms": int(silence_ms), - "audio.energy_threshold": float(energy), - "audio.max_utterance_seconds": float(max_seconds), - }, - ) + def _banned_extra(self): + return lifecycle._banned_extra(self) - async def set_devices( - self, - mic_device: int | None, - speaker_device: int | None, - mic_name: str | None = None, - speaker_name: str | None = None, - ) -> None: - self.settings.mic_device = mic_device - self.settings.speaker_device = speaker_device - if mic_name is not None: - self.settings.mic_device_name = mic_name or None - if speaker_name is not None: - self.settings.speaker_device_name = speaker_name or None - await asyncio.to_thread( - update_config, - { - "audio.mic_device": mic_device, - "audio.speaker_device": speaker_device, - "audio.mic_device_name": self.settings.mic_device_name, - "audio.speaker_device_name": self.settings.speaker_device_name, - }, - ) - if self._loop is None: - return - listen = self.mic.listen_enabled - ptt = self.mic.ptt_down - await self.mic.stop() - self.mic.listen_enabled = listen - self.mic.ptt_down = ptt - self.mic.start(self._loop) - - async def set_language(self, language: str) -> None: - language = (language or "").strip().lower() - if language not in WHISPER_LANGUAGES: - raise ValueError(f"unknown language: {language}") - self.settings.whisper_language = language - await asyncio.to_thread(update_config, {"whisper.language": language}) - await asyncio.to_thread(self.stt.load) - await self.hub.patch_status(stt_ready=self.stt.model is not None) - - async def set_voice(self, voice: str) -> None: - voice = (voice or "").strip() - if voice not in self.settings.tts_voices: - raise ValueError(f"unknown voice: {voice}") - self.tts.close() - self.settings.tts_voice = voice - - def progress(message: str) -> None: - if self._loop is None: - return - asyncio.run_coroutine_threadsafe(self.hub.broadcast({"type": "log", "data": message}), self._loop) - - self.tts.on_progress = progress - await asyncio.to_thread(self.tts.ensure_files) - await asyncio.to_thread(update_config, {"tts.voice": voice}) - await self.hub.patch_status(tts_ready=self.tts.ready) - - def set_duck(self, enabled: bool) -> None: - self.duck.set_enabled(enabled) - self.hub.status["duck_enabled"] = bool(enabled) - self.hub.status["duck"] = self.duck.mode - update_config({"audio.duck_enabled": enabled}) - - def _bump_voice(self) -> int: - self._voice_gen += 1 - self._voice_cancel.set() - self._voice_cancel = asyncio.Event() - self.tts.interrupt() - return self._voice_gen - - async def interrupt(self) -> None: - self._bump_voice() - self.duck.up() - await self.hub.patch_status( - speaking=False, - caption="", - phase="listening" if self.mic.listen_enabled else "idle", - ) + def _persist_booth(self): + return lifecycle._persist_booth(self) - async def twitch_connect(self) -> None: - await self.connect_irc() - await self.connect_events() - - async def connect_irc(self) -> None: - self.settings.reload_secrets() - self._want_twitch = True - self._irc_fatal = False - await self._irc_connect() - await self._validate_irc_token() - - async def connect_events(self) -> None: - self.settings.reload_secrets() - self._want_events = True - self._eventsub_fatal = False - self._eventsub_error = "" - await self._eventsub_connect() - if self._eventsub_error: - raise RuntimeError(self._eventsub_error) - - async def _irc_connect(self) -> None: - if self.twitch: - await self.twitch.disconnect() - bot_name = self.settings.twitch_nick - persona_name = str(self.persona.get("name") or "") - self.twitch = TwitchChat( - token=self.settings.twitch_token, - nick=bot_name, - channel=self.settings.twitch_channel, - on_line=self.on_chat, - on_status=self._twitch_status, - buffer_size=self.settings.chat_buffer_size, - bot_names=[bot_name, persona_name], - allow_from=[self.identity.login_key()] if self.identity.login_key() else [], - commands=self.settings.chat_commands, - min_send_gap=self.settings.min_send_gap_seconds, - ) - try: - await self.twitch.connect() - except Exception as e: - if looks_like_auth_failure(str(e)) or "token rejected" in str(e).lower(): - self._irc_fatal = True - await self.hub.patch_status(twitch="token rejected", twitch_error=str(e)) - raise - - async def _eventsub_connect(self) -> None: - async with self._eventsub_lock: - await self._eventsub_connect_locked() - - async def _eventsub_connect_locked(self) -> None: - if not self.settings.events_enabled: - await self.hub.patch_status(eventsub="off") - return - if self.eventsub and self.eventsub.alive(): - return - if self.eventsub: - await self.eventsub.disconnect() - self.eventsub = None - try: - self.eventsub = EventSubClient( - token=self.settings.event_token(), - client_id=self.settings.twitch_client_id, - channel=self.settings.twitch_channel, - on_event=self.on_stream_event, - on_status=self._eventsub_status, - subscriptions=self.settings.event_subscriptions(), - ) - await self.eventsub.connect() - if self.eventsub.fatal: - self._eventsub_fatal = True - except Exception as e: - log.warning("EventSub not connected: %s", e) - self.eventsub = None - fatal = _looks_like_auth(str(e)) or "is required" in str(e).lower() - self._eventsub_fatal = fatal - self._eventsub_error = str(e) - state = "token rejected" if fatal else "error" - await self.hub.patch_status(eventsub=state, eventsub_error=str(e)) - await self.hub.broadcast({"type": "log", "data": f"EventSub: {e}"}) - - async def twitch_disconnect(self) -> None: - await self.disconnect_irc() - await self.disconnect_events() - - async def disconnect_irc(self) -> None: - self._want_twitch = False - self._irc_fatal = False - if self.twitch: - await self.twitch.disconnect() - self.twitch = None - - async def disconnect_events(self) -> None: - self._want_events = False - self._eventsub_fatal = False - self._eventsub_error = "" - if self.eventsub: - await self.eventsub.disconnect() - self.eventsub = None - - async def _twitch_watchdog(self) -> None: - delay = 5.0 - while not self._stopping: - try: - await asyncio.sleep(delay) - if not self.settings.auto_reconnect_twitch or not (self._want_twitch or self._want_events): - delay = 5.0 - continue - if self._want_twitch and not (self.twitch and self.twitch.alive()): - if self._irc_fatal: - await self.hub.patch_status(twitch="token rejected") - else: - await self.hub.patch_status(twitch="reconnecting") - await self.hub.broadcast({"type": "log", "data": "Twitch chat dropped — reconnecting"}) - try: - await self._irc_connect() - delay = 5.0 - except Exception as e: - if self._irc_fatal: - await self.hub.patch_status(twitch="token rejected", twitch_error=str(e)) - else: - delay = min(delay * 2, 120.0) - await self.hub.patch_status(twitch="error", twitch_error=str(e)) - await self.hub.broadcast({"type": "log", "data": f"Twitch reconnect failed: {e}"}) - if ( - self._want_events - and self.settings.events_enabled - and not self._eventsub_lock.locked() - and not (self.eventsub and self.eventsub.alive()) - ): - if self._eventsub_fatal or (self.eventsub and self.eventsub.fatal): - self._eventsub_fatal = True - else: - await self.hub.patch_status(eventsub="reconnecting") - await self.hub.broadcast({"type": "log", "data": "EventSub dropped — reconnecting"}) - await self._eventsub_connect() - if self.eventsub and self.eventsub.connected and time.monotonic() >= self._token_check_at: - self._token_check_at = time.monotonic() + TOKEN_REVALIDATE_SECONDS - await self._revalidate_event_token() - irc_up = (not self._want_twitch) or bool(self.twitch and self.twitch.alive()) or self._irc_fatal - events_up = (not self._want_events) or (not self.settings.events_enabled) or self._eventsub_fatal or bool(self.eventsub and self.eventsub.alive()) - if irc_up and events_up: - delay = 5.0 - except asyncio.CancelledError: - raise - except Exception: - log.exception("twitch watchdog failed") - - async def _validate_irc_token(self) -> None: - from src.twitch.auth import validate - - self.irc_auth_note = "" - try: - meta = await validate(self.settings.twitch_token) - except Exception as exc: - message = str(exc) - if "HTTP 401" in message or "HTTP 403" in message: - self._irc_fatal = True - await self.hub.patch_status(twitch="token rejected", twitch_error=message) - await self.hub.broadcast({"type": "log", "data": message}) - return - await self.hub.broadcast({"type": "log", "data": f"IRC token check failed: {message}"}) - return - problem = irc_scope_problem(meta, self.settings.twitch_nick) - if not problem: - return - self.irc_auth_note = problem - await self.hub.patch_status(twitch_error=problem) - await self.hub.broadcast({"type": "log", "data": problem}) + def _install_profile(self, name, *, persist): + return lifecycle._install_profile(self, name, persist=persist) - async def _revalidate_event_token(self) -> None: - if self.eventsub is None: - return - state = await self.eventsub.revalidate() - if state != "token rejected": - return - self._eventsub_fatal = True - await self.hub.patch_status(eventsub="token rejected") - await self.hub.broadcast({"type": "log", "data": "EventSub token rejected"}) - - async def _twitch_status(self, state: str) -> None: - if state == "token rejected": - self._irc_fatal = True - await self.hub.patch_status(twitch=state) - - async def _eventsub_status(self, state: str) -> None: - if state == "token rejected": - self._eventsub_fatal = True - await self.hub.patch_status(eventsub=state) - - async def on_stream_event(self, event: StreamEvent) -> None: - await self.hub.broadcast( - { - "type": "alert", - "data": {"kind": event.kind, "user": event.user, "people": event.people(), "text": event.scene()}, - } - ) - await self.hub.broadcast({"type": "log", "data": f"Alert {event.kind}: {event.scene()}"}) - if event.kind == "raid": - self._last_raider = event.user - self._last_raider_id = str(event.extra.get("from_id") or "") - if event.kind == "hype_progress": - await self.hub.patch_status(hype_level=event.extra.get("level")) - return - if event.kind == "redeem": - title = str(event.extra.get("title") or "") - spec = self.settings.reward_spec(title) - if spec.action == "ignore": - await self._note_skip("reward ignored", event.user) - return - key = title.strip().lower() - if spec.cooldown > 0 and (time.monotonic() - self._reward_at.get(key, 0.0)) < spec.cooldown: - await self._note_skip("reward cooldown", event.user) - return - if spec.cooldown > 0: - self._reward_at[key] = time.monotonic() - event.extra["delivery"] = spec.action - if spec.prompt: - event.extra["reward_prompt"] = spec.prompt - if event.kind == "follow": - if not self.settings.announce_follows: - return - self._follow_names.extend(event.people()) - if not self._follow_task or self._follow_task.done(): - self._follow_task = self._spawn(self._flush_follows(), "follow-batch") - return - if event.kind == "cheer": - self._cheers.append(event) - if not self._cheer_task or self._cheer_task.done(): - self._cheer_task = self._spawn(self._flush_cheers(), "cheer-batch") - return - self._event_pending.append(event) - self._kick_events() + async def apply_profile(self, name): + return await lifecycle.apply_profile(self, name) - async def _flush_follows(self) -> None: - try: - await asyncio.sleep(self.settings.follow_batch_seconds) - except asyncio.CancelledError: - return - await self._emit_follows() + async def start(self, loop): + return await lifecycle.start(self, loop) - async def _emit_follows(self) -> None: - names = [n for n in self._follow_names if n] - self._follow_names = [] - if not names: - return - batched = StreamEvent(kind="follow", user=names[0], users=names[1:]) - self._event_pending.append(batched) - self._kick_events() - - async def _flush_cheers(self) -> None: - try: - await asyncio.sleep(self.settings.follow_batch_seconds) - except asyncio.CancelledError: - return - await self._emit_cheers() + async def _load_models(self): + return await lifecycle._load_models(self) - async def _emit_cheers(self) -> None: - cheers = list(self._cheers) - self._cheers = [] - if not cheers: - return - names: list[str] = [] - total = 0 - for ev in cheers: - names.extend(ev.people()) - try: - total += int(ev.extra.get("bits") or 0) - except (TypeError, ValueError): - pass - batched = StreamEvent( - kind="cheer", - user=names[0] if names else "Someone", - users=names[1:], - extra={"bits": total, "total_bits": total}, - ) - self._event_pending.append(batched) - self._kick_events() + async def _models_ready(self): + return await lifecycle._models_ready(self) - def _kick_events(self) -> None: - if self._event_scheduled or self._stopping: - return - self._event_scheduled = True - try: - self._event_q.put_nowait(1) - except Exception: - self._event_scheduled = False - - def _kick_chat(self) -> None: - if self._chat_scheduled or self._stopping: - return - self._chat_scheduled = True - try: - self._chat_q.put_nowait(1) - except Exception: - self._chat_scheduled = False - - async def _event_worker(self) -> None: - while not self._stopping: - await self._event_q.get() - self._event_scheduled = False - try: - await self._drain_events() - except asyncio.CancelledError: - raise - except Exception: - log.exception("event worker failed") - - async def _drain_events(self) -> None: - while self._event_pending and not self._stopping: - if self._busy_voice: - await self._note_skip("busy on mic", "") - await asyncio.sleep(0.25) - self._kick_events() - return - event = min(self._event_pending, key=lambda e: EVENT_PRIORITY.get(e.kind, 9)) - try: - self._event_pending.remove(event) - except ValueError: - break - await self._publish_queue() - await self._react_event(event) - if self._chat_pending and not self._busy_voice: - self._kick_chat() - - async def _react_event(self, event: StreamEvent) -> None: - if self._busy_voice: - self._event_pending.appendleft(event) - self._kick_events() - return - people = event.people() - primary = people[0] if people else (event.user or "chat") - thread = ( - self.memory.viewer_thread_key(primary) - if primary and primary.lower() not in ("anonymous", "chat") - else STREAMER_THREAD - ) - reward_input = str(event.extra.get("input") or "") - if event.kind == "redeem" and is_blocked(reward_input): - self.memory.remember("twitch_event", primary, "[skipped topic]", via="event", thread=thread) - self._spawn(self.hub.broadcast({"type": "memory", "data": self.memory.snapshot()}), "memory") - await self._emit_cohost(DEFLECT, thread=thread, speak=False, post=self.settings.events_post_to_chat) - return - scene = event.scene() - self.memory.remember("twitch_event", primary, scene, via="event", thread=thread) - self._spawn(self.hub.broadcast({"type": "memory", "data": self.memory.snapshot()}), "memory") - delivery = str(event.extra.get("delivery") or ("speak" if self.settings.events_speak else "chat")) - if delivery == "ignore": - return - if is_blocked(scene): - reply = DEFLECT - else: - temp = mood_temperature("hype" if event.kind == "raid" and self.mood == "normal" else self.mood) - reward_prompt = str(event.extra.get("reward_prompt") or "").strip() - if event.kind == "redeem" and reward_prompt: - user_content = ( - reward_prompt.replace("{user}", primary).replace("{input}", fence_viewer_text(reward_input)) - + "\n\nWords to speak aloud only." - ) - else: - user_content = ( - f"You're on the couch next to {self.identity.spoken_name()}. This just happened on stream:\n{scene}\n\n" - "Hype it like a cohost for a second, then drop it. No corporate thank-you. " - "Words to speak aloud only." - ) - try: - reply = await self._finish_reply( - [ - {"role": "system", "content": self.voice_prompt}, - {"role": "user", "content": user_content}, - ], - temperature=temp, - ) - except TimeoutError: - await self._note_skip("llm timeout", primary) - await self.hub.patch_status(phase="listening" if self.mic.listen_enabled else "idle") - return - if not reply: - await self.hub.patch_status(phase="listening" if self.mic.listen_enabled else "idle") - return - await self._emit_cohost(reply, thread=thread, speak=delivery == "speak" and self.settings.events_speak, post=self.settings.events_post_to_chat) + async def shutdown(self): + return await lifecycle.shutdown(self) - async def _bounded_complete(self, messages: list[dict[str, str]], temperature: float) -> str: - return await asyncio.wait_for( - self.llm.complete(messages, temperature=temperature), - timeout=self.settings.chat_timeout_seconds, - ) + def set_identity(self, display_name, twitch_login): + return lifecycle.set_identity(self, display_name, twitch_login) - async def _finish_reply(self, messages: list[dict[str, str]], *, temperature: float, retry: bool = True) -> str: - raw = await self._bounded_complete(messages, temperature) - reply = self._clean(raw) - if retry and (not reply or is_generic_filler(reply, self._banned_extra())): - raw = await self._bounded_complete( - messages + [{"role": "user", "content": "shorter, messier, no greeting, no thank-you template."}], - min(1.0, temperature + 0.05), - ) - reply = self._clean(raw) - if not reply or is_generic_filler(reply, self._banned_extra()): - return "" - if is_blocked(reply): - return DEFLECT - return reply - - def _clean(self, raw: str) -> str: - reply = sanitize_reply(raw, str(self.persona.get("name") or "")) - reply = apply_catchphrase_budget( - reply, - self._phrases(), - self._catchphrase_at, - window=self.settings.catchphrase_seconds, - ) - return reply - - def _chat_text(self, text: str, target_user: str | None = None) -> str: - return clean_chat_output( - text, - target_user, - allow_links=self.settings.allow_links, - max_chars=self.settings.chat_max_chars, - ) + def forget_viewer(self, login): + return lifecycle.forget_viewer(self, login) - async def _emit_cohost(self, reply: str, *, thread: str, speak: bool, post: bool) -> None: - spoken = for_speech(reply) - chat_text = self._chat_text(reply) - self.memory.remember( - "cohost_voice" if speak else "cohost_chat", - self._cohost_name(), - spoken if speak else chat_text, - via="voice" if speak else "chat", - thread=thread, - ) - self._spawn(self.hub.broadcast({"type": "memory", "data": self.memory.snapshot()}), "memory") - self._remember_caption(spoken) - await self.hub.broadcast({"type": "transcript", "data": {"role": "cohost", "text": spoken}}) - if post and self.twitch and self.twitch.connected: - if not chat_text: - await self._note_skip("output guard", "") - else: - try: - t0 = time.perf_counter() - await self.twitch.send(chat_text) - self.timings["chat_send_ms"] = int((time.perf_counter() - t0) * 1000) - await self.hub.broadcast( - {"type": "chat", "data": {"user": self.persona.get("name"), "text": chat_text, "bot": True}} - ) - except Exception: - log.exception("event chat send failed") - if speak and not self.tts_mute and not self._busy_voice: - await self.hub.patch_status(phase="speaking", speaking=True, caption=spoken, timings=dict(self.timings)) - await self._speak(spoken) - await self.hub.patch_status( - phase="listening" if self.mic.listen_enabled else "idle", - speaking=False, - caption=spoken[-280:], - timings=dict(self.timings), - ) + def clear_memory(self): + return lifecycle.clear_memory(self) - async def _speak(self, text: str) -> None: - if not text or self.tts_mute: - return - self._speak_depth += 1 - if self._speak_depth == 1: - self.duck.on_speaking(True) - await self.hub.patch_status(duck=self.duck.mode, speaking=True) - try: - await self.tts.speak(text) - finally: - self._speak_depth = max(0, self._speak_depth - 1) - if self._speak_depth == 0: - self.duck.on_speaking(False) - - async def on_chat(self, line: ChatLine) -> None: - if self.identity.is_login(line.user): - name = self.identity.spoken_name() - stored = "[skipped topic]" if is_blocked(line.text) else line.text - self.memory.remember("streamer_chat", name, stored, via="chat", thread=STREAMER_THREAD) - await self.hub.broadcast( - { - "type": "chat", - "data": {"user": name, "text": line.text, "mention": line.mention, "streamer": True}, - } - ) - self._spawn(self.hub.broadcast({"type": "memory", "data": self.memory.snapshot()}), "memory") - self._chat_pending.append(line) - self._kick_chat() - return - self._chat_pending.append(line) - stored = "[skipped topic]" if is_blocked(line.text) else line.text - self.memory.remember("viewer", line.user, stored, via="chat", thread=self.memory.viewer_thread_key(line.user)) - await self.hub.broadcast({"type": "chat", "data": {"user": line.user, "text": line.text, "mention": line.mention}}) - self._spawn(self.hub.broadcast({"type": "memory", "data": self.memory.snapshot()}), "memory") - await self._publish_queue() - self._kick_chat() - - async def on_utterance(self, audio: np.ndarray) -> None: - gen = self._bump_voice() - self._busy_voice = True - started = time.perf_counter() - try: - if self.stt.model is None: - await self.hub.broadcast({"type": "log", "data": "Whisper is still loading or failed to load."}) - return - sr = self.settings.sample_rate - clipped = await asyncio.to_thread(trim_to_speech, audio, sr) - await self.hub.patch_status(phase="thinking", speaking=False) - try: - text = await asyncio.to_thread(self.stt.transcribe, clipped) - except Exception as e: - log.exception("transcribe failed") - await self.hub.broadcast({"type": "log", "data": f"STT error: {e}"}) - await self.hub.patch_status(phase="listening" if self.mic.listen_enabled else "idle") - return - self.timings["stt_ms"] = int((time.perf_counter() - started) * 1000) - if gen != self._voice_gen: - return - if not text: - await self.hub.patch_status(phase="listening" if self.mic.listen_enabled else "idle", timings=dict(self.timings)) - return - await self._handle_voice_text(text, gen, started) - finally: - self._busy_voice = False - if self._event_pending: - self._kick_events() - elif self._chat_pending: - self._kick_chat() - - async def pretend(self, text: str) -> None: - text = (text or "").strip() - if not text: - return - gen = self._bump_voice() - self._busy_voice = True - try: - await self._handle_voice_text(text, gen, time.perf_counter()) - finally: - self._busy_voice = False - if self._event_pending: - self._kick_events() - elif self._chat_pending: - self._kick_chat() - - async def _handle_voice_text(self, text: str, gen: int, started: float) -> None: - await self.hub.patch_status(last_user=text, streamer_name=self.identity.spoken_name(), timings=dict(self.timings)) - await self.hub.broadcast( - {"type": "transcript", "data": {"role": "streamer", "text": text, "name": self.identity.spoken_name()}} - ) - stored = "[skipped topic]" if is_blocked(text) else text - self.memory.remember("streamer_voice", self.identity.spoken_name(), stored, via="voice", thread=STREAMER_THREAD) - await self._voice_reply(text, gen, started) - - def _remember_caption(self, text: str) -> None: - cleaned = (text or "").strip() - if cleaned: - self._last_caption = cleaned - - async def replay_last(self) -> bool: - text = (self._last_caption or "").strip() - if not text: - return False - await self.say_line(text, post_chat=False) - return True - - async def say_line(self, text: str, post_chat: bool = True) -> None: - reply = sanitize_reply(text, str(self.persona.get("name") or "")) - chat_text = self._chat_text(reply) - if not chat_text: - await self._note_skip("output guard", "") - return - spoken = for_speech(chat_text) - self._remember_caption(spoken) - if post_chat and self.twitch and self.twitch.connected: - await self.twitch.send(chat_text) - await self.hub.broadcast( - {"type": "chat", "data": {"user": self.persona.get("name"), "text": chat_text, "bot": True}} - ) - await self.hub.broadcast({"type": "transcript", "data": {"role": "cohost", "text": spoken}}) - if not self.tts_mute: - await self.hub.patch_status(phase="speaking", speaking=True, caption=spoken) - await self._speak(spoken) - await self.hub.patch_status( - phase="listening" if self.mic.listen_enabled else "idle", - speaking=False, - caption=spoken[-280:], - ) + def reload_persona(self): + return lifecycle.reload_persona(self) - def _chat_context(self, limit: int | None = None) -> str: - n = self.settings.chat_context_lines if limit is None else limit - if not self.twitch: - return "(no chat connected)" - lines = self.twitch.recent(n) - rendered = [] - for line in lines: - if is_blocked(line.text): - continue - rendered.append(f"{line.user}: {fence_viewer_text(line.text)}") - return "\n".join(rendered) if rendered else "(chat is quiet)" - - def _history_messages(self, thread_key: str, limit: int | None = None) -> list[dict[str, str]]: - banned = self._banned_extra() - kept = [] - for msg in self.memory.llm_messages(thread_key, limit=limit): - if msg["role"] == "assistant" and is_generic_filler(msg["content"], banned): - continue - kept.append(msg) - return kept - - def _reply_is_bad(self, reply: str, previous: str, recent: list[str] | None = None) -> bool: - if not reply or is_generic_filler(reply, self._banned_extra()): - return True - earlier = [previous, *(recent or [])] - return repeats_recent(reply, earlier) - - async def _voice_reply(self, user_text: str, gen: int, started: float) -> None: - cancel = self._voice_cancel - name = self.identity.spoken_name() - if is_blocked(user_text): - reply = DEFLECT - if gen != self._voice_gen: - return - if not self.tts_mute: - await self.hub.patch_status(phase="speaking", speaking=True, caption=reply) - await self._speak(reply) - await self._emit_voice_result(name, user_text, reply, gen, started, post=False) - return - messages = [ - {"role": "system", "content": self.voice_prompt}, - *self._history_messages(STREAMER_THREAD), - { - "role": "user", - "content": ( - f"You're on the couch next to {name}. They just said into the mic:\n{fence_viewer_text(user_text)}\n\n" - f"{self.memory.prompt_block(STREAMER_THREAD, streamer_name=name, is_voice=True)}\n\n" - f"People talking in chat (not {name}):\n{self._chat_context()}\n\n" - f"React to {name} like you're sitting next to them. Don't recap. Don't @ anyone." - ), - }, - ] - full: list[str] = [] - first_token = False - first_audio = False - async for sentence in self.llm.stream_sentences( - messages, - temperature=mood_temperature(self.mood), - cancel=cancel, - max_chars=self.settings.max_sentence_chars, - ): - if gen != self._voice_gen or cancel.is_set(): - return - if not first_token: - first_token = True - self.timings["first_token_ms"] = int((time.perf_counter() - started) * 1000) - sentence = self._clean(sentence.strip()) - if not sentence or is_generic_filler(sentence, self._banned_extra()): - continue - if is_blocked(sentence): - sentence = DEFLECT - full.append(sentence) - await self.hub.patch_status(phase="speaking", speaking=True, caption=" ".join(full)[-280:], timings=dict(self.timings)) - if not self.tts_mute: - await self._speak(sentence) - if not first_audio: - first_audio = True - self.timings["first_audio_ms"] = int((time.perf_counter() - started) * 1000) - if gen != self._voice_gen: - return - reply = self._clean(" ".join(full).strip()) - if not reply: - err = self.llm.last_error or "empty model reply" - await self.hub.broadcast({"type": "log", "data": f"LLM: {err}"}) - await self.hub.patch_status(phase="listening" if self.mic.listen_enabled else "idle", speaking=False) - return - if gen != self._voice_gen: - return - await self._emit_voice_result(name, user_text, reply, gen, started, post=True) + def save_persona_text(self, text): + return lifecycle.save_persona_text(self, text) - async def _emit_voice_result(self, name: str, user_text: str, reply: str, gen: int, started: float, post: bool) -> None: - if gen != self._voice_gen: - return - self.memory.remember( - "cohost_voice", - self._cohost_name(), - reply, - via="voice", - thread=STREAMER_THREAD, - ) - self._spawn(self.hub.broadcast({"type": "memory", "data": self.memory.snapshot()}), "memory") - self._remember_caption(reply) - await self.hub.broadcast({"type": "transcript", "data": {"role": "cohost", "text": reply}}) - if post and self.settings.post_voice_replies_to_chat and self.twitch and self.twitch.connected: - if gen != self._voice_gen: - return - short = self._chat_text(reply) - if not short: - await self._note_skip("output guard", "") - else: - try: - t0 = time.perf_counter() - await self.twitch.send(short) - self.timings["chat_send_ms"] = int((time.perf_counter() - t0) * 1000) - self.memory.remember( - "cohost_chat", - self._cohost_name(), - short, - via="chat", - thread=STREAMER_THREAD, - ) - await self.hub.broadcast( - {"type": "chat", "data": {"user": self.persona.get("name"), "text": short, "bot": True}} - ) - except Exception: - log.exception("failed to echo voice reply to chat") - await self.hub.patch_status( - phase="listening" if self.mic.listen_enabled else "idle", - speaking=False, - caption=reply[-280:], - timings=dict(self.timings), - ) + async def _after_persona_reload(self): + return await lifecycle._after_persona_reload(self) - def _rate_ok(self, *, mention: bool, user: str = "") -> tuple[bool, str]: - now = time.monotonic() - while self._reply_times and now - self._reply_times[0] > 60: - self._reply_times.popleft() - if len(self._reply_times) >= self.settings.max_replies_per_minute: - return False, "cooldown" - if self.identity.is_login(user): - if now - self._last_chat_reply < 1.5: - return False, "cooldown" - return True, "" - global_wait, user_wait = mention_waits( - mention, - self.settings.mention_boost, - self.settings.cooldown_seconds, - self.settings.per_user_cooldown_seconds, - ) - key = (user or "").lower() - if key: - last = self._user_reply_at.get(key, 0.0) - if now - last < user_wait: - return False, "per-user cooldown" - if now - self._last_chat_reply < global_wait: - return False, "cooldown" - return True, "" - - def _mark_reply(self, user: str = "") -> None: - now = time.monotonic() - self._last_chat_reply = now - self._reply_times.append(now) - if user: - self._user_reply_at[user.lower()] = now - - async def _chat_scan_loop(self) -> None: - while not self._stopping: - try: - await asyncio.sleep(self.settings.scan_interval_seconds) - self._kick_chat() - except asyncio.CancelledError: - raise - except Exception: - log.exception("chat scan failed") - - async def _chat_worker(self) -> None: - while not self._stopping: - await self._chat_q.get() - self._chat_scheduled = False - try: - await self._drain_chat() - except asyncio.CancelledError: - raise - except Exception: - log.exception("chat worker failed") - - async def _expire_stale_chat(self) -> None: - limit = self.settings.chat_max_age_seconds - if limit <= 0 or not self._chat_pending: - return - now = time.monotonic() - kept: deque[ChatLine] = deque(maxlen=self._chat_pending.maxlen) - for line in list(self._chat_pending): - age = now - float(getattr(line, "ts", now)) - exempt = self.identity.is_login(line.user) or bool(line.command) - if age > limit and not exempt: - await self._note_skip("stale", line.user) - continue - kept.append(line) - self._chat_pending.clear() - self._chat_pending.extend(kept) - - async def _drain_chat(self) -> None: - await self._expire_stale_chat() - while self._chat_pending and not self._stopping: - if self._busy_voice or self._event_pending: - reason = "busy on mic" if self._busy_voice else "alert queued" - await self._note_skip(reason, "") - await asyncio.sleep(0.25) - self._kick_chat() - return - handled = await self._consider_chat_locked() - if not handled: - return - - def _pick_chat_target(self) -> ChatLine | None: - if not self._chat_pending: - return None - streamer_lines = [line for line in self._chat_pending if self.identity.is_login(line.user)] - if streamer_lines: - return streamer_lines[0] - commands = [line for line in self._chat_pending if line.command] - if commands: - return commands[-1] - engaging = [line for line in self._chat_pending if line.is_engaging()] - if engaging: - return engaging[-1] - if self.settings.welcome_first_time: - firsts = [line for line in self._chat_pending if line.first_msg and not line.command] - if firsts: - return firsts[-1] - return self._chat_pending[-1] - - def _drop_chat_target(self, target: ChatLine) -> None: - if self.identity.is_login(target.user): - kept = [line for line in self._chat_pending if line is not target] - else: - kept = [line for line in self._chat_pending if line is not target and line.user.lower() != target.user.lower()] - self._chat_pending.clear() - self._chat_pending.extend(kept) - - def _control_ready(self) -> bool: - wait = self.settings.control_cooldown_seconds - if wait <= 0: - return True - return (time.monotonic() - self._last_control_at) >= wait - - async def _type_ack(self, text: str) -> None: - if self.twitch and self.twitch.connected: - await self.twitch.send(text) - await self.hub.broadcast( - {"type": "chat", "data": {"user": self.persona.get("name"), "text": text, "bot": True}} - ) + async def set_mood(self, mood): + return await lifecycle.set_mood(self, mood) - async def _run_pixel_control(self, verb: str) -> None: - if verb == "mute": - await self.set_mute(True) - await self._type_ack("Muted.") - elif verb == "unmute": - await self.set_mute(False) - await self._type_ack("Voice is back.") - elif verb == "chill": - await self.set_mood("chill") - await self._type_ack("Mood is chill.") - elif verb == "hype": - await self.set_mood("hype") - await self._type_ack("Mood is hype.") - elif verb == "stop": - await self.interrupt() - await self._type_ack("Stopped.") - - async def _consider_chat_locked(self) -> bool: - if self._busy_voice: - return False - if self._event_pending: - self._kick_events() - return False - target = self._pick_chat_target() - if target is None: - return False - if pixel_control(target): - self._drop_chat_target(target) - await self._publish_queue() - await self._perform_chat(target, ignore_rate=False) - return True - action = chat_action(target, welcome_first_time=self.settings.welcome_first_time) - ok, why = self._rate_ok(mention=action == "speak", user=target.user) - if not ok: - await self._note_skip(why, target.user) - return False - self._drop_chat_target(target) - await self._publish_queue() - await self._perform_chat(target, ignore_rate=False) - return True - - async def _perform_chat(self, target: ChatLine, *, ignore_rate: bool) -> None: - verb = pixel_control(target) - if verb: - if not self._can_run_mod_command(target): - await self._note_skip("not a mod", target.user) - return - if not ignore_rate and not self._control_ready(): - await self._note_skip("control cooldown", target.user) - return - await self._run_pixel_control(verb) - self._last_control_at = time.monotonic() - self._mark_reply(target.user) - return - action = chat_action(target, welcome_first_time=self.settings.welcome_first_time) - command = (target.command or "").lower() - if command in self.settings.mod_commands and not self._can_run_mod_command(target): - await self._note_skip("not a mod", target.user) - return - if command == "!so": - who = "" - parts = target.text.split() - if len(parts) > 1: - who = parts[1].lstrip("@") - if not ignore_rate and not self._shoutout_ready(): - await self._note_skip("shoutout cooldown", target.user) - return - try: - await self.shoutout(who or None) - except Exception as e: - await self.hub.broadcast({"type": "log", "data": f"Shoutout failed: {e}"}) - else: - self._last_shoutout_at = time.monotonic() - self._mark_reply(target.user) - return - if action == "command": - await self._note_skip("unhandled command", target.user) - return - await self._decide_chat(target, action) + async def set_listen(self, enabled): + return await lifecycle.set_listen(self, enabled) - async def _decide_chat(self, line: ChatLine, action: str) -> None: - if self._busy_voice: - self._chat_pending.append(line) - return - await self.hub.patch_status(phase="thinking") - streamer = self.identity.is_login(line.user) - previous = "" - prev = self.memory.viewer(STREAMER_THREAD if streamer else line.user) - if prev and prev.last_reply: - previous = prev.last_reply - recent = [ - msg["content"] - for msg in self.memory.llm_messages(STREAMER_THREAD if streamer else self.memory.viewer_thread_key(line.user)) - if msg["role"] == "assistant" - ] - if is_blocked(line.text): - reply = DEFLECT - action = "chat" - else: - try: - reply = self._clean(await self._persona_chat_reply(line, ambient=action != "speak")) - if self._reply_is_bad(reply, previous, recent): - reply = self._clean(await self._persona_chat_reply(line, ambient=action != "speak", retry=True)) - except TimeoutError: - await self._note_skip("llm timeout", line.user) - await self.hub.patch_status(phase="listening" if self.mic.listen_enabled else "idle") - return - if self._reply_is_bad(reply, previous, recent): - fallback = streamer_smalltalk_fallback(line.text, self._cohost_name()) if streamer else "" - if not fallback: - await self._note_skip("filler rejected", line.user) - await self.hub.broadcast({"type": "log", "data": f"Chat skip ({line.user}): {line.text[:80]}"}) - await self.hub.patch_status(phase="listening" if self.mic.listen_enabled else "idle") - return - reply = fallback - if is_blocked(reply): - reply = DEFLECT - action = "chat" - if streamer: - chat_text = self._chat_text(sanitize_reply(reply, str(self.persona.get("name") or ""))) - else: - chat_text = self._chat_text(ensure_chat_mention(reply, line.user), target_user=line.user) - if not chat_text: - await self._note_skip("output guard", line.user) - await self.hub.patch_status(phase="listening" if self.mic.listen_enabled else "idle") - return - spoken = for_speech(chat_text) - thread_key = STREAMER_THREAD if streamer else self.memory.viewer_thread_key(line.user) - self._mark_reply(line.user) - self.memory.remember( - "cohost_chat", - self._cohost_name(), - chat_text, - via="chat", - thread=thread_key, - ) - self._remember_caption(spoken) - self.memory.note_reply_to(line.user, chat_text, thread=thread_key) - if not streamer: - self.memory.mark_greeted(line.user) - self._spawn(self.hub.broadcast({"type": "memory", "data": self.memory.snapshot()}), "memory") - await self.hub.broadcast({"type": "transcript", "data": {"role": "cohost", "text": spoken, "chat": chat_text}}) - await self.hub.broadcast({"type": "chat", "data": {"user": self.persona.get("name"), "text": chat_text, "bot": True}}) - if self.twitch and self.twitch.connected: - try: - await self.twitch.send(chat_text) - except Exception: - log.exception("chat send failed") - want_speak = action == "speak" and not self.tts_mute and not self._busy_voice and not self.tts.speaking - if want_speak: - await self.hub.patch_status(phase="speaking", speaking=True, caption=spoken) - await self._speak(spoken) - await self.hub.patch_status(phase="listening" if self.mic.listen_enabled else "idle", speaking=False) - else: - await self.hub.patch_status(phase="listening" if self.mic.listen_enabled else "idle", caption=spoken) - - async def _persona_chat_reply(self, line: ChatLine, ambient: bool = False, retry: bool = False) -> str: - extra = ( - "\nAnswer only the message above. No greeting by name. " - "Do not ask what they want to talk about. Do not mention clips or moments.\n" - if retry - else "" - ) - prev = self.memory.viewer(STREAMER_THREAD if self.identity.is_login(line.user) else line.user) - prev_bit = "" - if prev and prev.last_reply and not is_generic_filler(prev.last_reply, self._banned_extra()): - prev_bit = f"\nYou already said to them: {prev.last_reply}\nDon't repeat that.\n" - name = self.identity.spoken_name() - fenced = fence_viewer_text(line.text) - if self.identity.is_login(line.user): - focus = STREAMER_THREAD - who_line = f"{name} (the streamer, sitting next to you) just typed:\n{fenced}\n" - ask = ( - "Reply only to the latest line inside the viewer markers. One short line. " - "Do not repeat a joke or sentence you already used. " - f"Do not start with Hey {name}. Do not ask them for memes, clips, or a topic." - ) - else: - focus = self.memory.viewer_thread_key(line.user) - who_line = f"{line.user} in chat:\n{fenced}\nThis is a viewer, not {name}.\n" - ask = f"One short Twitch chat line starting with @{line.user}." - if self.settings.welcome_first_time and line.first_msg: - ask += " This is their first message ever in this channel. Give a short typed welcome." - history_limit = 4 if ambient else None - messages = [ - {"role": "system", "content": self.chat_prompt}, - *self._history_messages(focus, limit=history_limit), - { - "role": "user", - "content": ( - f"{who_line}" - f"{prev_bit}" - f"{self.memory.prompt_block(focus, streamer_name=name, is_voice=False, include_alerts=not ambient)}\n\n" - f"Live chat:\n{self._chat_context(self.settings.ambient_chat_context if ambient else None)}\n" - f"{extra}" - f"{ask}" - ), - }, - ] - try: - text = self._clean( - await asyncio.wait_for( - self.llm.complete(messages, temperature=mood_temperature(self.mood) if not retry else 0.9), - timeout=self.settings.chat_timeout_seconds, - ) - ) - except TimeoutError: - self.llm.last_error = "llm timeout" - raise - if not text: - return "" - target = None if self.identity.is_login(line.user) else line.user - return self._chat_text(text, target_user=target) - - def _can_run_mod_command(self, line: ChatLine) -> bool: - if line.is_mod or line.is_broadcaster: - return True - return self.identity.is_login(line.user) - - def _shoutout_ready(self) -> bool: - wait = self.settings.shoutout_cooldown_seconds - if wait <= 0: - return True - return (time.monotonic() - self._last_shoutout_at) >= wait - - async def shoutout(self, login: str | None) -> None: - target = (login or self._last_raider or "").lstrip("@").strip() - if not target: - raise ValueError("No raider to shout out") - if not self.settings.twitch_client_id: - raise RuntimeError("TWITCH_CLIENT_ID is required for shoutouts") - if not self.eventsub or not self.eventsub.broadcaster_id or not self.eventsub.moderator_id: - raise RuntimeError("EventSub is not connected, so the broadcaster id is unknown") - token = self.settings.event_token() - if target.lower() == self._last_raider.lower() and self._last_raider_id: - to_id = self._last_raider_id - else: - to_id = await user_id(token, self.settings.twitch_client_id, target) - try: - await shoutout( - token, - self.settings.twitch_client_id, - from_broadcaster_id=self.eventsub.broadcaster_id, - to_broadcaster_id=to_id, - moderator_id=self.eventsub.moderator_id, - ) - except HelixError as e: - if e.status == 429: - log.warning("Helix shoutout rate limited (HTTP 429): %s", e) - if e.status in (401, 403): - await self.hub.patch_status(eventsub="token rejected", eventsub_error=str(e)) - raise - self._last_shoutout_at = time.monotonic() - await self.say_line(f"{target}, welcome in.", post_chat=True) - - async def mark_moment(self, note: str) -> dict[str, str]: - text = (note or "bookmark").strip() - self.memory.add_note(STREAMER_THREAD, text, is_streamer=True) - await asyncio.to_thread(self.memory.flush) - await self.hub.broadcast({"type": "memory", "data": self.memory.snapshot()}) - marker_error = "" - if self.eventsub and self.eventsub.broadcaster_id and self.settings.twitch_client_id: - try: - await create_marker( - self.settings.event_token(), - self.settings.twitch_client_id, - self.eventsub.broadcaster_id, - text, - ) - except Exception as e: - marker_error = str(e) - log.info("stream marker skipped: %s", e) - return {"note": text, "marker_error": marker_error} - - async def _note_skip(self, reason: str, user: str) -> None: - if self._last_skip.get("reason") == reason and self._last_skip.get("user") == user: - return - self._last_skip = {"reason": reason, "user": user} - await self.hub.patch_status(skip=dict(self._last_skip)) - await self.hub.broadcast({"type": "skip", "data": dict(self._last_skip)}) - - def _next_qid(self) -> str: - self._queue_seq += 1 - return f"q{self._queue_seq}" - - def _assign_queue_ids(self) -> None: - for line in self._chat_pending: - if not line.id: - line.id = self._next_qid() - for event in self._event_pending: - if not event.id: - event.id = self._next_qid() - - def _retain_chat(self, kept: list[ChatLine]) -> None: - self._chat_pending.clear() - self._chat_pending.extend(kept) - - def _retain_events(self, kept: list[StreamEvent]) -> None: - self._event_pending.clear() - self._event_pending.extend(kept) - - def queue_snapshot(self) -> dict[str, list]: - self._assign_queue_ids() - alerts = sorted(self._event_pending, key=lambda e: EVENT_PRIORITY.get(e.kind, 9)) - chat = [ - {"id": line.id, "user": line.user, "text": line.text[:120]} - for line in list(self._chat_pending)[:12] - ] - return { - "chat": chat, - "alerts": [{"id": event.id, "kind": event.kind, "text": event.scene()} for event in alerts], - } - - async def drop_queued(self, item_id: str) -> bool: - self._assign_queue_ids() - item_id = (item_id or "").strip() - if not item_id: - return False - for line in list(self._chat_pending): - if line.id == item_id: - self._retain_chat([item for item in self._chat_pending if item is not line]) - await self._publish_queue() - return True - for event in list(self._event_pending): - if event.id == item_id: - self._retain_events([item for item in self._event_pending if item is not event]) - await self._publish_queue() - return True - return False - - async def answer_queued(self, item_id: str) -> bool: - self._assign_queue_ids() - item_id = (item_id or "").strip() - if not item_id: - return False - for line in list(self._chat_pending): - if line.id == item_id: - self._retain_chat([item for item in self._chat_pending if item is not line]) - await self._publish_queue() - await self._perform_chat(line, ignore_rate=True) - return True - for event in list(self._event_pending): - if event.id == item_id: - self._retain_events([item for item in self._event_pending if item is not event]) - await self._publish_queue() - await self._react_event(event) - return True - return False - - async def make_recap(self, *, vibe: bool = False) -> dict[str, str]: - paragraph = "" - if vibe: - draft = build_recap(self.memory) - try: - raw = await asyncio.wait_for( - self.llm.complete( - [ - {"role": "system", "content": "Write one short paragraph about this stream. No headings."}, - {"role": "user", "content": draft or "Quiet stream."}, - ], - temperature=0.7, - ), - timeout=self.settings.chat_timeout_seconds, - ) - except TimeoutError: - paragraph = "" - else: - paragraph = (raw or "").strip() - text, path = write_recap(self.memory, self.data_dir / "recaps", vibe=paragraph) - return {"markdown": text, "path": path.name} - - async def _publish_queue(self) -> None: - await self.hub.patch_status(queue=self.queue_snapshot()) - - async def _memory_prune_loop(self) -> None: - while not self._stopping: - try: - await asyncio.sleep(3600) - removed = await asyncio.to_thread(self.memory.prune) - if removed and self.memory.dirty: - await asyncio.to_thread(self.memory.flush) - except asyncio.CancelledError: - raise - except Exception: - log.exception("memory prune failed") - - async def _memory_flush_loop(self) -> None: - while not self._stopping: - try: - await asyncio.sleep(0.4) - if self.memory.dirty: - await asyncio.to_thread(self.memory.flush) - except asyncio.CancelledError: - raise - except Exception: - log.exception("memory flush failed") - - async def _resolve_audio_devices(self) -> None: - from src.audio.devices import list_devices, match_device - - try: - devices = await asyncio.to_thread(list_devices) - except Exception: - log.exception("device resolve failed") - return - self.settings.mic_device = match_device( - list(devices.get("inputs") or []), - self.settings.mic_device, - self.settings.mic_device_name, - ) - self.settings.speaker_device = match_device( - list(devices.get("outputs") or []), - self.settings.speaker_device, - self.settings.speaker_device_name, - ) + async def set_ptt(self, down): + return await lifecycle.set_ptt(self, down) + + async def set_mute(self, mute): + return await lifecycle.set_mute(self, mute) + + def set_post_voice(self, enabled): + return lifecycle.set_post_voice(self, enabled) + + def _wake_status(self): + return lifecycle._wake_status(self) + + async def set_vad(self, silence_ms, energy, max_seconds): + return await lifecycle.set_vad(self, silence_ms, energy, max_seconds) + + async def set_devices(self, mic_device, speaker_device, mic_name=None, speaker_name=None): + return await lifecycle.set_devices(self, mic_device, speaker_device, mic_name, speaker_name) + + 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) + + def set_duck(self, enabled): + return lifecycle.set_duck(self, enabled) + + def _bump_voice(self): + return voice_flow._bump_voice(self) + + async def interrupt(self): + return await voice_flow.interrupt(self) + + async def twitch_connect(self): + return await twitch_link.twitch_connect(self) + + async def connect_irc(self): + return await twitch_link.connect_irc(self) + + async def connect_events(self): + return await twitch_link.connect_events(self) + + async def _irc_connect(self): + return await twitch_link._irc_connect(self) + + async def _eventsub_connect(self): + return await twitch_link._eventsub_connect(self) + + async def _eventsub_connect_locked(self): + return await twitch_link._eventsub_connect_locked(self) + + async def twitch_disconnect(self): + return await twitch_link.twitch_disconnect(self) + + async def disconnect_irc(self): + return await twitch_link.disconnect_irc(self) + + async def disconnect_events(self): + return await twitch_link.disconnect_events(self) + + async def _twitch_watchdog(self): + return await twitch_link._twitch_watchdog(self) + + async def _validate_irc_token(self): + return await twitch_link._validate_irc_token(self) + + async def _revalidate_event_token(self): + return await twitch_link._revalidate_event_token(self) + + async def _twitch_status(self, state): + return await twitch_link._twitch_status(self, state) + + async def _eventsub_status(self, state): + return await twitch_link._eventsub_status(self, state) + + async def on_stream_event(self, event): + return await event_flow.on_stream_event(self, event) + + async def _flush_follows(self): + return await event_flow._flush_follows(self) + + async def _emit_follows(self): + return await event_flow._emit_follows(self) + + async def _flush_cheers(self): + return await event_flow._flush_cheers(self) + + async def _emit_cheers(self): + return await event_flow._emit_cheers(self) + + def _kick_events(self): + return event_flow._kick_events(self) + + def _kick_chat(self): + return chat_flow._kick_chat(self) + + async def _event_worker(self): + return await event_flow._event_worker(self) + + async def _drain_events(self): + return await event_flow._drain_events(self) + + async def _react_event(self, event): + return await event_flow._react_event(self, event) + + async def _bounded_complete(self, messages, temperature): + return await event_flow._bounded_complete(self, messages, temperature) + + async def _finish_reply(self, messages, *, temperature, retry=True): + return await event_flow._finish_reply(self, messages, temperature=temperature, retry=retry) + + def _clean(self, raw): + return voice_flow._clean(self, raw) + + def _chat_text(self, text, target_user=None): + return voice_flow._chat_text(self, text, target_user) + + async def _emit_cohost(self, reply, *, thread, speak, post): + return await voice_flow._emit_cohost(self, reply, thread=thread, speak=speak, post=post) + + async def _speak(self, text): + return await voice_flow._speak(self, text) + + async def on_chat(self, line): + return await chat_flow.on_chat(self, line) + + async def on_utterance(self, audio): + return await voice_flow.on_utterance(self, audio) + + async def pretend(self, text): + return await voice_flow.pretend(self, text) + + async def _handle_voice_text(self, text, gen, started): + return await voice_flow._handle_voice_text(self, text, gen, started) + + def _remember_caption(self, text): + return voice_flow._remember_caption(self, text) + + async def replay_last(self): + return await voice_flow.replay_last(self) + + async def say_line(self, text, post_chat=True, speak=True): + return await voice_flow.say_line(self, text, post_chat, speak) + + def _chat_context(self, limit=None): + return voice_flow._chat_context(self, limit) + + def _history_messages(self, thread_key, limit=None): + return voice_flow._history_messages(self, thread_key, limit) + + def _reply_is_bad(self, reply, previous, recent=None): + return voice_flow._reply_is_bad(self, reply, previous, recent) + + async def _voice_reply(self, user_text, gen, started): + return await voice_flow._voice_reply(self, user_text, gen, started) + + async def _emit_voice_result(self, name, user_text, reply, gen, started, post): + return await voice_flow._emit_voice_result(self, name, user_text, reply, gen, started, post) + + def _rate_ok(self, *, mention, user=""): + return chat_flow._rate_ok(self, mention=mention, user=user) + + def _mark_reply(self, user=""): + return chat_flow._mark_reply(self, user) + + async def _chat_scan_loop(self): + return await chat_flow._chat_scan_loop(self) + + async def _chat_worker(self): + return await chat_flow._chat_worker(self) + + async def _expire_stale_chat(self): + return await chat_flow._expire_stale_chat(self) + + async def _drain_chat(self): + return await chat_flow._drain_chat(self) + + def _pick_chat_target(self): + return chat_flow._pick_chat_target(self) + + def _drop_chat_target(self, target): + return chat_flow._drop_chat_target(self, target) + + def _control_ready(self): + return chat_flow._control_ready(self) + + async def _type_ack(self, text): + return await chat_flow._type_ack(self, text) + + async def _run_pixel_control(self, verb): + return await chat_flow._run_pixel_control(self, verb) + + async def _consider_chat_locked(self): + return await chat_flow._consider_chat_locked(self) + + async def _perform_chat(self, target, *, ignore_rate): + return await chat_flow._perform_chat(self, target, ignore_rate=ignore_rate) + + async def _decide_chat(self, line, action): + return await chat_flow._decide_chat(self, line, action) + + async def _persona_chat_reply(self, line, ambient=False, retry=False): + return await chat_flow._persona_chat_reply(self, line, ambient, retry) + + def _control_names(self): + return chat_flow._control_names(self) + + def _irc_commands(self): + return chat_flow._irc_commands(self) + + def _can_run_mod_command(self, line): + return chat_flow._can_run_mod_command(self, line) + + def _shoutout_ready(self): + return twitch_link._shoutout_ready(self) + + async def shoutout(self, login): + return await twitch_link.shoutout(self, login) + + async def mark_moment(self, note): + return await twitch_link.mark_moment(self, note) + + async def _note_skip(self, reason, user): + return await lifecycle._note_skip(self, reason, user) + + def _next_qid(self): + return queue._next_qid(self) + + def _assign_queue_ids(self): + return queue._assign_queue_ids(self) + + def _retain_chat(self, kept): + return queue._retain_chat(self, kept) + + def _retain_events(self, kept): + return queue._retain_events(self, kept) + + def queue_snapshot(self): + return queue.queue_snapshot(self) + + async def drop_queued(self, item_id): + return await queue.drop_queued(self, item_id) + + async def answer_queued(self, item_id): + return await queue.answer_queued(self, item_id) + + async def make_recap(self, *, vibe=False): + return await event_flow.make_recap(self, vibe=vibe) + + async def _publish_queue(self): + return await queue._publish_queue(self) + + async def _memory_prune_loop(self): + return await lifecycle._memory_prune_loop(self) + + async def _memory_flush_loop(self): + return await lifecycle._memory_flush_loop(self) + + async def _resolve_audio_devices(self): + return await lifecycle._resolve_audio_devices(self) + + async def _ollama_watch(self): + return await lifecycle._ollama_watch(self) + + def restore_log_level(self): + return lifecycle.restore_log_level(self) + + async def enable_debug_log(self, seconds=600): + return await lifecycle.enable_debug_log(self, seconds) + + async def _debug_log_window(self, seconds): + return await lifecycle._debug_log_window(self, seconds) - async def _ollama_watch(self) -> None: - while not self._stopping: - try: - await asyncio.sleep(self._ollama_interval) - if self._stopping: - return - health = await self.llm.health() - await self.hub.patch_status(ollama="ok" if health.get("ok") else "down") - except asyncio.CancelledError: - raise - except Exception: - log.exception("ollama watch failed") - - async def _level_loop(self) -> None: - while not self._stopping: - try: - await asyncio.sleep(0.2) - wake = getattr(self.mic, "wake", None) - wake_loaded = getattr(wake, "model", None) is not None - stream_open = getattr(self.mic, "_stream", None) is not None - if stream_open and (self.mic.listen_enabled or self.mic.ptt_down or wake_loaded): - score = float(getattr(self.mic, "wake_score", getattr(wake, "score", 0.0)) or 0.0) - await self.hub.broadcast( - { - "type": "level", - "data": {"level": round(float(self.mic.level), 4), "wake": round(score, 4)}, - } - ) - except asyncio.CancelledError: - raise - except Exception: - log.exception("level loop failed") + async def _level_loop(self): + return await voice_flow._level_loop(self) diff --git a/src/policy.py b/src/policy.py index 45e81d3..c21a49a 100644 --- a/src/policy.py +++ b/src/policy.py @@ -6,9 +6,23 @@ CONTROL_VERBS = {"mute", "unmute", "chill", "hype", "stop"} -def pixel_control(line: ChatLine) -> str: - """Return a ``!pixel`` steering verb, or empty when this line is not one.""" - if (line.command or "").lower() != "!pixel": +def control_command_names(persona_name: str = "", configured: str = "") -> set[str]: + """Commands that steer the booth. ``!pixel`` stays available as an alias.""" + names = {"!pixel"} + raw = (configured or "").strip().lower() + if raw: + names.add(raw if raw.startswith("!") else f"!{raw}") + return names + first = (persona_name or "").strip().lower().split() + if first and first[0]: + names.add(f"!{first[0]}") + return names + + +def pixel_control(line: ChatLine, commands: set[str] | None = None) -> str: + """Return a steering verb, or empty when this line is not a control command.""" + allowed = commands or {"!pixel"} + if (line.command or "").lower() not in allowed: return "" parts = (line.text or "").split() if len(parts) < 2: diff --git a/src/settings.py b/src/settings.py index 3268bf5..6fd0932 100644 --- a/src/settings.py +++ b/src/settings.py @@ -190,6 +190,9 @@ def __init__(self, data: dict[str, Any]) -> None: self.chat_commands = _command_list(data, "chat.commands", ["!so", "!pixel", "!ask"]) self.mod_commands = _command_list(data, "chat.mod_commands", ["!so"]) 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)) + self.control_command = str(_get(data, "chat.control_command", "") or "").strip() self.chat_max_age_seconds = float(_get(data, "chat.max_age_seconds", 45)) self.welcome_first_time = bool(_get(data, "chat.welcome_first_time", False)) self.control_cooldown_seconds = float(_get(data, "chat.control_cooldown_seconds", 4)) @@ -222,6 +225,10 @@ def __init__(self, data: dict[str, Any]) -> None: self.profiles = _parse_profiles(_get(data, "profiles", {}) or {}) self.overlay_alert_hold = float(_get(data, "overlay.alert_hold_seconds", 6)) + self.log_level = str(_get(data, "logging.level", "INFO") or "INFO").upper() + self.log_debug_modules = [ + str(name) for name in (_get(data, "logging.debug_modules", []) or []) if str(name).strip() + ] self.twitch_token = os.getenv("TWITCH_TOKEN", "").strip() self.twitch_nick = os.getenv("TWITCH_NICK", "").strip() diff --git a/src/tts/piper.py b/src/tts/piper.py index e8496ad..f1db676 100644 --- a/src/tts/piper.py +++ b/src/tts/piper.py @@ -141,6 +141,7 @@ def __init__(self, settings: Settings) -> None: self._persistent_timeout = 25.0 self._stdout_q: queue.Queue[str] = queue.Queue() self._reader_gen = 0 + self._pending_deletes: set[str] = set() self.on_progress = None @property @@ -209,6 +210,7 @@ def _stop_output(self) -> None: pass def close(self) -> None: + self._sweep_pending() proc = self._proc self._proc = None if proc is None: @@ -309,6 +311,43 @@ def _maybe_retry_persistent(self) -> None: log.info("Retrying persistent Piper") self._json_input = True + def _delete_output(self, out_path: str) -> None: + try: + os.unlink(out_path) + except OSError: + return + self._pending_deletes.discard(out_path) + + def _abandon_output(self, out_path: str) -> None: + """Remember a wav the interrupted request may still write. + + The placeholder file is removed now, but the path stays pending: Piper + can create it again after the interrupt, and the next synth or the + ready line deletes that copy. + """ + if not out_path: + return + self._pending_deletes.add(out_path) + try: + os.unlink(out_path) + except OSError: + pass + + def _sweep_pending(self) -> None: + for path in list(self._pending_deletes): + if os.path.exists(path): + self._delete_output(path) + + def _claim_ready_line(self, line: str) -> None: + for path in list(self._pending_deletes): + if not self._output_ready(line, path): + continue + try: + os.unlink(path) + except OSError: + pass + self._pending_deletes.discard(path) + def _output_ready(self, line: str, out_path: str) -> bool: text = (line or "").strip() if not text: @@ -363,6 +402,7 @@ def _synth_persistent(self, text: str, gen: int) -> tuple[np.ndarray, int] | Non self._proc = self._spawn() if self._proc is not None: self._start_readers(self._proc) + self._sweep_pending() proc = self._proc if proc is None or proc.stdin is None: self._fail_persistent(proc, "Piper json-input did not start") @@ -376,31 +416,23 @@ def _synth_persistent(self, text: str, gen: int) -> tuple[np.ndarray, int] | Non except Exception as e: log.warning("Piper stdin failed (%s); falling back to one-shot", e) self._fail_persistent(proc, "Piper stdin failed") - try: - os.unlink(out_path) - except OSError: - pass + self._abandon_output(out_path) return None deadline = time.monotonic() + self._persistent_timeout while time.monotonic() < deadline: if gen != self._gen: - try: - os.unlink(out_path) - except OSError: - pass + self._abandon_output(out_path) return np.zeros(0, dtype=np.float32), self.sample_rate if proc.poll() is not None: self._proc = None self._fail_persistent(None, f"Piper json-input exited ({proc.returncode})") - try: - os.unlink(out_path) - except OSError: - pass + self._abandon_output(out_path) return None try: line = self._stdout_q.get(timeout=0.05) except queue.Empty: continue + self._claim_ready_line(line) if not self._output_ready(line, out_path): continue try: @@ -411,10 +443,7 @@ def _synth_persistent(self, text: str, gen: int) -> tuple[np.ndarray, int] | Non except OSError: pass self._fail_persistent(proc, "Piper json-input timed out") - try: - os.unlink(out_path) - except OSError: - pass + self._abandon_output(out_path) return None def _synth_oneshot(self, text: str) -> tuple[np.ndarray, int]: diff --git a/src/web/static/app.js b/src/web/static/app.js index 5b3bfc7..d0f7b1f 100644 --- a/src/web/static/app.js +++ b/src/web/static/app.js @@ -177,6 +177,16 @@ $("shoutoutBtn").addEventListener("click", async () => { hint.textContent = "Shoutout sent."; }); +const debugLogBtn = $("debugLogBtn"); +if (debugLogBtn) { + debugLogBtn.addEventListener("click", async () => { + const data = await post("/api/logging/debug", { enabled: !debugLogBtn.classList.contains("active") }); + if (failed(data)) return; + debugLogBtn.classList.toggle("active", !!data.debug); + hint.textContent = data.debug ? "Debug log on for 10 minutes." : "Debug log off."; + }); +} + $("markerBtn").addEventListener("click", async () => { const data = await post("/api/marker", { note: "clip moment" }); if (failed(data)) return; @@ -211,7 +221,7 @@ saveStreamerBtn.addEventListener("click", async () => { }); if (failed(data)) return; streamerLabel = data.spoken_name || streamerName.value.trim() || "Streamer"; - hint.textContent = `Pixel will treat ${streamerLabel} as the streamer.`; + hint.textContent = `${personaName} will treat ${streamerLabel} as the streamer.`; }); $("mood").addEventListener("change", async () => { diff --git a/src/web/static/index.html b/src/web/static/index.html index af97d45..2dfb511 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -187,9 +187,12 @@

Recap

Log

+
+ +
- + diff --git a/tests/conftest.py b/tests/conftest.py index 9545fc1..20652a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -150,6 +150,24 @@ async def _offline_validate(token, client=None): # noqa: ARG001 } monkeypatch.setattr("src.twitch.auth.validate", _offline_validate) + monkeypatch.setenv("COHOST_SKIP_SECRET_CHECK", "1") + + +@pytest.fixture(autouse=True) +def isolated_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Point repo-root file reads at a temp tree so tests do not need the real config.""" + root = tmp_path / "root" + root.mkdir() + (root / "logs").mkdir() + (root / "data").mkdir() + (root / "persona.yaml").write_text(PERSONA_FIXTURE.read_text(encoding="utf-8"), encoding="utf-8") + (root / "config.yaml").write_text( + "server:\n host: 127.0.0.1\n port: 8080\n allow_remote: false\n", + encoding="utf-8", + ) + for name, module in list(sys.modules.items()): + if (name == "src" or name.startswith("src.")) and hasattr(module, "ROOT"): + monkeypatch.setattr(module, "ROOT", root) @pytest.fixture diff --git a/tests/test_hermetic.py b/tests/test_hermetic.py new file mode 100644 index 0000000..606b05f --- /dev/null +++ b/tests/test_hermetic.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from src.main import _booth_port, create_app +from tests.test_orchestrator_policy import make_orch + + +def test_broken_root_config_still_builds_a_booth(tmp_path, settings, monkeypatch): + import src.main as main + + repo = Path(__file__).resolve().parents[1] + target = repo / "config.yaml" + original = target.read_bytes() + try: + target.write_text(":\n [broken\n", encoding="utf-8") + monkeypatch.setattr(main, "ROOT", repo) + assert _booth_port(None) == 8080 + orch = make_orch(tmp_path, settings) + app = create_app(prepared=(settings, orch.hub, orch)) + assert app is not None + finally: + target.write_bytes(original) diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..c295d2b --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,52 @@ +import asyncio +import logging + +from src.logsetup import setup_logging +from tests.test_orchestrator_policy import make_orch + + +def test_debug_modules_open_only_those_loggers(): + setup_logging(level="INFO", debug_modules=["cohost.mic"]) + mic = logging.getLogger("cohost.mic") + tts = logging.getLogger("cohost.tts") + assert mic.isEnabledFor(logging.DEBUG) + assert not tts.isEnabledFor(logging.DEBUG) + seen: list[str] = [] + + class _Grab(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + seen.append(f"{record.name}:{record.getMessage()}") + + handler = _Grab() + handler.setLevel(logging.DEBUG) + root = logging.getLogger() + root.addHandler(handler) + try: + mic.debug("mic-only-line") + tts.debug("tts-should-stay-quiet") + finally: + root.removeHandler(handler) + assert seen == ["cohost.mic:mic-only-line"] + + +def test_debug_toggle_reverts_after_ten_minutes(tmp_path, settings, monkeypatch): + setup_logging(level="INFO") + delays: list[float] = [] + real_sleep = asyncio.sleep + + async def fast(seconds): + delays.append(float(seconds)) + if float(seconds) < 600: + await real_sleep(0) + + monkeypatch.setattr("src.orchestrator.asyncio.sleep", fast) + + async def run(): + orch = make_orch(tmp_path, settings) + await orch.enable_debug_log() + assert logging.getLogger("cohost").isEnabledFor(logging.DEBUG) + await real_sleep(0) + assert not logging.getLogger("cohost").isEnabledFor(logging.DEBUG) + + asyncio.run(run()) + assert 600 in delays diff --git a/tests/test_phase4.py b/tests/test_phase4.py index c9d61bc..0227d87 100644 --- a/tests/test_phase4.py +++ b/tests/test_phase4.py @@ -394,10 +394,63 @@ def test_phase4_routes(tmp_path, settings): assert missing.status_code == 400 +def test_manual_mood_survives_restart_until_the_next_profile(tmp_path, settings): + settings.profiles = { + "Focus game": { + "mood": "chill", + "cooldown_seconds": 30, + "per_user_cooldown_seconds": 45, + "events_speak": False, + "post_voice_replies_to_chat": False, + "max_replies_per_minute": 3, + } + } + + async def run(): + orch = make_orch(tmp_path, settings) + await orch.apply_profile("Focus game") + assert orch.mood == "chill" + await orch.set_mood("hype") + assert orch.mood == "hype" + restarted = make_orch(tmp_path, settings) + assert restarted.profile_name == "Focus game" + assert restarted.mood == "hype" + await restarted.apply_profile("Focus game") + assert restarted.mood == "chill" + assert restarted.mood_override == "" + + asyncio.run(run()) + + +def test_persona_name_is_a_control_command_and_pixel_stays(tmp_path, settings): + async def run(): + settings.control_cooldown_seconds = 0 + orch = make_orch(tmp_path, settings) + orch.persona["name"] = "Nova" + settings.control_command = "" + viewer = ChatLine(user="bob", text="!nova mute", command="!nova") + orch._chat_pending.append(viewer) + assert await orch._consider_chat_locked() is True + assert orch._last_skip["reason"] == "not a mod" + + mod = ChatLine(user="moddy", text="!nova mute", command="!nova", is_mod=True) + orch._chat_pending.append(mod) + assert await orch._consider_chat_locked() is True + assert orch.tts_mute is True + assert "Muted." in orch.twitch.sent + + pixel = ChatLine(user="moddy", text="!pixel unmute", command="!pixel", is_mod=True) + orch._chat_pending.append(pixel) + assert await orch._consider_chat_locked() is True + assert orch.tts_mute is False + + asyncio.run(run()) + + def test_shipped_config_and_overlay_hooks(): from pathlib import Path - loaded = load_settings() + loaded = load_settings(Path(__file__).resolve().parents[1] / "config.yaml") assert loaded.reward_action("hydrate") == "speak" assert loaded.welcome_first_time is True assert loaded.profiles["Focus game"]["mood"] == "chill" diff --git a/tests/test_piper_pipes.py b/tests/test_piper_pipes.py index f285aea..e56fbef 100644 --- a/tests/test_piper_pipes.py +++ b/tests/test_piper_pipes.py @@ -140,3 +140,72 @@ def spawn(): assert int(audio.size) > 0 finally: tts.close() + + +LATE_STUB = """\ +import json +import sys +import time +import wave + +signal = sys.argv[1] +line = sys.stdin.buffer.readline() +if not line: + sys.exit(0) +req = json.loads(line.decode()) +path = req["output_file"] +open(signal, "w", encoding="utf-8").write(path) +time.sleep(0.5) + +def write(target): + with wave.open(target, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(22050) + wf.writeframes(b"\\x00\\x10" * 40) + sys.stdout.buffer.write((target + "\\n").encode()) + sys.stdout.buffer.flush() + +write(path) +line = sys.stdin.buffer.readline() +if not line: + sys.exit(0) +write(json.loads(line.decode())["output_file"]) +""" + + +def test_interrupt_then_next_synth_removes_the_late_wav(tmp_path, settings): + script = tmp_path / "late_piper.py" + script.write_text(LATE_STUB, encoding="utf-8") + signal = tmp_path / "started.txt" + tts = _ready(settings, tmp_path) + tts._persistent_timeout = 3 + tts._spawn = lambda: _popen(script, str(signal)) + held: dict = {} + + def first() -> None: + held["first"] = tts._synth("hello", tts._gen) + + thread = threading.Thread(target=first) + thread.start() + for _ in range(50): + if signal.exists(): + break + time.sleep(0.02) + assert signal.exists() + abandoned = Path(signal.read_text(encoding="utf-8")) + tts.interrupt() + thread.join(3) + assert thread.is_alive() is False + assert str(abandoned) in tts._pending_deletes or not abandoned.exists() + + def second() -> None: + held["second"] = tts._synth("again", tts._gen) + + follow = threading.Thread(target=second) + follow.start() + follow.join(3) + tts.close() + assert follow.is_alive() is False + assert not abandoned.exists() + assert int(held["second"][0].size) > 0 diff --git a/tests/test_shoutout.py b/tests/test_shoutout.py index 4408b50..63ce4a9 100644 --- a/tests/test_shoutout.py +++ b/tests/test_shoutout.py @@ -110,7 +110,17 @@ async def fake_shoutout(token, client_id, **kwargs): "moderator_id": "22", } ] - assert any(item.startswith("sam, welcome in.") or item == "sam, welcome in." for item in orch.twitch.sent) + assert "go check out sam" in orch.twitch.sent + assert orch.tts.spoken + + orch.tts.spoken.clear() + orch.twitch.sent.clear() + orch._last_raider = "sam" + orch._last_shoutout_at = 0 + settings.shoutout_speak = False + await orch.shoutout("sam") + assert "sam, welcome in." in orch.twitch.sent + assert orch.tts.spoken == [] asyncio.run(run()) diff --git a/tests/test_start_bat.py b/tests/test_start_bat.py index 93188e1..9334e99 100644 --- a/tests/test_start_bat.py +++ b/tests/test_start_bat.py @@ -22,7 +22,7 @@ def test_start_bat_fails_loudly_and_stamps_only_after_pip(): def test_shipped_config_keeps_optional_feeds_off(): from src.settings import load_settings - settings = load_settings() + settings = load_settings(Path(__file__).resolve().parents[1] / "config.yaml") assert settings.bits_enabled is False assert settings.channel_points_enabled is False assert settings.hype_train_enabled is False diff --git a/tests/test_state_files.py b/tests/test_state_files.py new file mode 100644 index 0000000..4f30e80 --- /dev/null +++ b/tests/test_state_files.py @@ -0,0 +1,44 @@ +import json +import logging + +import pytest + +from src.booth import BoothState, load_booth, save_booth +from src.identity import StreamerIdentity, load_identity, save_identity + + +def test_replace_failure_keeps_the_old_booth_file(tmp_path, monkeypatch): + path = tmp_path / "booth.json" + save_booth(path, BoothState(mood="chill", profile="Focus game")) + before = path.read_text(encoding="utf-8") + + def explode(*_args, **_kwargs): + raise OSError("disk full") + + monkeypatch.setattr("src.settings.os.replace", explode) + with pytest.raises(OSError): + save_booth(path, BoothState(mood="hype")) + assert path.read_text(encoding="utf-8") == before + assert load_booth(path).mood == "chill" + + +def test_corrupt_booth_and_identity_are_logged_and_copied(tmp_path, caplog): + booth = tmp_path / "booth.json" + booth.write_text("{not json", encoding="utf-8") + with caplog.at_level(logging.ERROR): + loaded = load_booth(booth, fallback_mute=True) + assert loaded.mood == "normal" + assert loaded.tts_mute is True + assert (tmp_path / "booth.json.bad").read_text(encoding="utf-8") == "{not json" + assert "booth.json" in caplog.text + + ident = tmp_path / "streamer.json" + ident.write_text("{still bad", encoding="utf-8") + caplog.clear() + with caplog.at_level(logging.ERROR): + who = load_identity(ident, default_login="luna") + assert who.twitch_login == "luna" + assert (tmp_path / "streamer.json.bad").exists() + assert "streamer.json" in caplog.text + save_identity(ident, StreamerIdentity(display_name="Luna", twitch_login="luna")) + assert json.loads(ident.read_text(encoding="utf-8"))["display_name"] == "Luna" diff --git a/tests/test_text.py b/tests/test_text.py index b700bab..1c47521 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -30,7 +30,7 @@ def test_similar_replies_and_smalltalk_fallback(): "Hey luna! Clip or moment or something caught your eye?", ) assert streamer_smalltalk_fallback("how are you?") == "pretty good, parked on the couch. you?" - assert streamer_smalltalk_fallback("what is your name?") == "Pixel." + assert streamer_smalltalk_fallback("what is your name?") == "Cohost." assert is_generic_filler("hey lunaversevt—got any good memes?") assert streamer_smalltalk_fallback("I just died again") == ""