Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ TWITCH_CLIENT_ID=
# channel:read:predictions (or channel:manage:predictions)
# Helix actions: moderator:manage:shoutouts (!so) channel:manage:broadcast (stream markers)
TWITCH_EVENT_TOKEN=

# Optional stable token for a Stream Deck or hotkey. Leave empty to keep the
# per-launch session token as the only key. When set, it is accepted as
# X-Cohost-Token only on POST /api/interrupt, /api/tts/mute, /api/ptt,
# /api/marker, /api/mood, and /api/profile.
COHOST_CONTROL_KEY=
10 changes: 5 additions & 5 deletions PLAN-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,33 +251,33 @@ Do these on a test stream or with the booth open before relying on the features.

### Phase 4: Booth quality-of-life (💡 suggestions)

- [ ] **V3-P4-01 💡 Stream Deck / hotkey friendly API**
- [x] **V3-P4-01 💡 Stream Deck / hotkey friendly API**
- *Why:* every booth action is already an endpoint, but the per-launch token makes external buttons impossible.
- *Do:* optional `COHOST_CONTROL_KEY` in `.env`; when set, `BoothGuard` also accepts it in `X-Cohost-Token` for a small allow-list (`/api/interrupt`, `/api/tts/mute`, `/api/ptt`, `/api/marker`, `/api/mood`, `/api/profile`). Document curl/Stream Deck "Website/API" examples.
- *Files:* `src/security.py`, `src/main.py`, `.env.example`, `README.md`, `tests/test_api.py`
- *Acceptance:* with the key set, `/api/interrupt` works with it and `/api/persona` still rejects it; without the key nothing changes.
- *Depends on:* V3-P1-01

- [ ] **V3-P4-02 💡 Voice and persona per profile**
- [x] **V3-P4-02 💡 Voice and persona per profile**
- *Do:* allow `tts_voice`, `length_scale` and an optional `persona_file` in a profile; switching profile swaps them (voice download progress shown as now).
- *Files:* `src/settings.py`, `src/orchestrator.py`, `config.yaml`, tests
- *Acceptance:* switching to a profile with `tts_voice: en_US-kristin-medium` changes `health.voice`; invalid voices are rejected at load time.
- *Depends on:* V3-P2-03

- [ ] **V3-P4-03 💡 Session transcript export**
- [x] **V3-P4-03 💡 Session transcript export**
- *Do:* append every transcript/chat/alert line to `data/sessions/<session>.jsonl` (rotated, honours retention and Forget); dashboard "Download transcript" button.
- *Files:* `src/orchestrator.py`, `src/memory.py`, `src/main.py`, dashboard, tests
- *Acceptance:* a session produces a JSONL file; **Forget** removes that viewer's lines from it.
- *Depends on:* V3-P3-02

- [ ] **V3-P4-04 💡 Simple custom text commands**
- [x] **V3-P4-04 💡 Simple custom text commands**
- *Why:* fixes the "commands that aren't !so" gap from V3-P1-02 in a useful way.
- *Do:* `chat.custom_commands: {"!lurk": "enjoy the lurk {user}", "!discord": "…"}` posts fixed text (no model) with per-command cooldown; commands are added to the allowlist automatically.
- *Files:* `src/settings.py`, `src/orchestrator.py`, `src/policy.py`, `config.yaml`, tests
- *Acceptance:* `!lurk` posts the filled text once per cooldown; it never triggers a shoutout or a model call.
- *Depends on:* V3-P1-02

- [ ] **V3-P4-05 💡 Latency panel with history**
- [x] **V3-P4-05 💡 Latency panel with history**
- *Why:* `timings` already captures STT, first token, first audio and chat send for the last reply only.
- *Do:* keep a rolling 50-sample window and show p50/p95 per stage on the dashboard; warn when first-audio p95 > 2.5 s with a link to the VRAM tips.
- *Files:* `src/orchestrator.py`, `src/health.py`, `src/web/static/app.js`, tests
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,19 @@ Wake word needs `openwakeword` and never replaces hold-to-talk. Channel-point re

The booth binds to localhost. `server.allow_remote` stays false unless you set it on purpose. Writes need `Content-Type: application/json` and the `X-Cohost-Token` from `GET /api/session` (same browser tab). A foreign `Origin` is rejected. The OBS overlay does not get that token and cannot change anything.

Optional `COHOST_CONTROL_KEY` in `.env` is a stable token for a Stream Deck or hotkey. When it is set, that value is accepted as `X-Cohost-Token` only on `POST /api/interrupt`, `/api/tts/mute`, `/api/ptt`, `/api/marker`, `/api/mood`, and `/api/profile`. `/api/persona` and every other write still need the per-launch session token. Leave the key empty and nothing changes.

```powershell
curl.exe -X POST http://127.0.0.1:8080/api/interrupt -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{}"
curl.exe -X POST http://127.0.0.1:8080/api/tts/mute -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{\"mute\":true}"
curl.exe -X POST http://127.0.0.1:8080/api/ptt -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{\"down\":true}"
curl.exe -X POST http://127.0.0.1:8080/api/marker -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{}"
curl.exe -X POST http://127.0.0.1:8080/api/mood -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{\"mood\":\"hype\"}"
curl.exe -X POST http://127.0.0.1:8080/api/profile -H "Content-Type: application/json" -H "X-Cohost-Token: YOUR_CONTROL_KEY" -d "{\"name\":\"Focus game\"}"
```

On a Stream Deck Website button, set the URL to `http://127.0.0.1:8080/api/interrupt`, method POST, header `Content-Type: application/json`, header `X-Cohost-Token` to the control key, and body `{}`.

## Backup

`scripts/backup.ps1` zips `data/`, `persona.yaml`, and `config.yaml` into `backups/YYYYMMDD-HHMM.zip` and keeps the last 10. It never puts `.env` in the zip. `backups/` is gitignored.
Expand Down
8 changes: 8 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ chat:
ask_queue: false
# Gap between mod !pixel clip commands.
clip_cooldown_seconds: 60
# Fixed text replies. {user} is the chatter. No model and no shoutout.
# Each command has its own timer of custom_command_cooldown_seconds.
# custom_commands:
# "!lurk": "enjoy the lurk {user}"
# "!discord": "discord is linked in the panels"
custom_commands: {}
custom_command_cooldown_seconds: 30

memory:
chat_context_lines: 20
Expand Down Expand Up @@ -154,6 +161,7 @@ safety:
allow_links: false

# Named booth presets. POST /api/profile applies one and stores the name in data/booth.json.
# A profile may also set tts_voice (one of tts.voices), length_scale, and persona_file.
profiles:
"Just chatting":
mood: hype
Expand Down
30 changes: 28 additions & 2 deletions src/core/chat_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
sanitize_reply,
streamer_smalltalk_fallback,
)
from src.policy import chat_action, control_command_names, mention_waits, pixel_control
from src.policy import chat_action, control_command_names, custom_reply, mention_waits, pixel_control
from src.safety import DEFLECT, fence_viewer_text, is_blocked
from src.twitch.chat import ChatLine

Expand Down Expand Up @@ -248,6 +248,11 @@ async def _consider_chat_locked(orch) -> bool:
await orch._publish_queue()
await orch._perform_chat(target, ignore_rate=False)
return True
if custom_reply(target, orch.settings.custom_commands):
orch._drop_chat_target(target)
await orch._publish_queue()
await orch._perform_chat(target, ignore_rate=False)
return True
action = chat_action(target, welcome_first_time=orch.settings.welcome_first_time)
ok, why = orch._rate_ok(mention=action == "speak", user=target.user)
if not ok:
Expand Down Expand Up @@ -284,11 +289,21 @@ async def _perform_chat(orch, target: ChatLine, *, ignore_rate: bool) -> None:
orch._last_control_at = time.monotonic()
orch._mark_reply(target.user)
return
action = chat_action(target, welcome_first_time=orch.settings.welcome_first_time)
command = (target.command or "").lower()
if command in orch.settings.mod_commands and (not orch._can_run_mod_command(target)):
await orch._note_skip("not a mod", target.user)
return
filled = custom_reply(target, orch.settings.custom_commands)
if filled:
if not ignore_rate and not _custom_ready(orch, command):
await orch._note_skip("command cooldown", target.user)
return
orch.memory.remember("cohost_chat", orch._cohost_name(), filled, via="chat")
await orch._type_ack(filled)
orch._custom_at[command] = time.monotonic()
orch._mark_reply(target.user)
return
action = chat_action(target, welcome_first_time=orch.settings.welcome_first_time)
if command == "!so":
who = ""
parts = target.text.split()
Expand Down Expand Up @@ -446,11 +461,22 @@ def _control_names(orch) -> set[str]:
return control_command_names(str(orch.persona.get("name") or ""), orch.settings.control_command)


def _custom_ready(orch, command: str) -> bool:
wait = float(getattr(orch.settings, "custom_command_cooldown_seconds", 30) or 0)
if wait <= 0:
return True
last = orch._custom_at.get(command, 0.0)
return time.monotonic() - last >= wait


def _irc_commands(orch) -> list[str]:
commands = list(orch.settings.chat_commands)
for name in sorted(orch._control_names()):
if name not in commands:
commands.append(name)
for name in sorted(getattr(orch.settings, "custom_commands", {}) or {}):
if name not in commands:
commands.append(name)
return commands


Expand Down
32 changes: 30 additions & 2 deletions src/core/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ def bind(
"events_speak": bool(settings.events_speak),
"post_voice_replies_to_chat": bool(settings.post_voice_replies_to_chat),
"max_replies_per_minute": int(settings.max_replies_per_minute),
"tts_voice": settings.tts_voice,
"length_scale": float(settings.tts_length_scale),
"persona_file": str(orch.persona_path),
}
orch.booth = load_booth(
orch.data_dir / "booth.json",
Expand Down Expand Up @@ -153,6 +156,13 @@ def bind(
"first_audio_ms": None,
"chat_send_ms": None,
}
orch.timing_samples = {
"stt_ms": deque(maxlen=50),
"first_token_ms": deque(maxlen=50),
"first_audio_ms": deque(maxlen=50),
"chat_send_ms": deque(maxlen=50),
}
orch._custom_at: dict[str, float] = {}


def _refresh_prompts(orch) -> None:
Expand Down Expand Up @@ -230,14 +240,31 @@ def _install_profile(orch, name: str, *, persist: bool) -> str:
orch.settings.events_speak = bool(values["events_speak"])
orch.settings.post_voice_replies_to_chat = bool(values["post_voice_replies_to_chat"])
orch.settings.max_replies_per_minute = int(values["max_replies_per_minute"])
orch.settings.tts_length_scale = float(values.get("length_scale", orch._runtime_base["length_scale"]))
voice = str(values.get("tts_voice") or orch._runtime_base["tts_voice"])
if voice not in orch.settings.tts_voices:
raise ValueError(f"unknown voice: {voice}")
orch.settings.tts_voice = voice
raw_persona = str(values.get("persona_file") or orch._runtime_base["persona_file"])
persona_path = Path(raw_persona)
if not persona_path.is_absolute():
persona_path = _live("ROOT") / persona_path
if persona_path != Path(orch.persona_path):
if not persona_path.exists():
raise ValueError(f"persona file not found: {persona_path}")
orch.persona_path = persona_path
orch.reload_persona()
orch._refresh_prompts()
if persist:
orch._persist_booth()
return canonical


async def apply_profile(orch, name: str) -> str:
before_voice = orch.settings.tts_voice
canonical = orch._install_profile(name, persist=True)
if orch.settings.tts_voice != before_voice:
await orch.set_voice(orch.settings.tts_voice, persist=False)
await orch.hub.patch_status(
mood=orch.mood, profile=canonical, post_voice=bool(orch.settings.post_voice_replies_to_chat)
)
Expand Down Expand Up @@ -540,7 +567,7 @@ async def set_language(orch, language: str) -> None:
await orch.hub.patch_status(stt_ready=orch.stt.model is not None)


async def set_voice(orch, voice: str) -> None:
async def set_voice(orch, voice: str, *, persist: bool = True) -> None:
voice = (voice or "").strip()
if voice not in orch.settings.tts_voices:
raise ValueError(f"unknown voice: {voice}")
Expand All @@ -554,7 +581,8 @@ def progress(message: str) -> None:

orch.tts.on_progress = progress
await asyncio.to_thread(orch.tts.ensure_files)
await asyncio.to_thread(_live("update_config"), {"tts.voice": voice})
if persist:
await asyncio.to_thread(_live("update_config"), {"tts.voice": voice})
await orch.hub.patch_status(tts_ready=orch.tts.ready)


Expand Down
18 changes: 13 additions & 5 deletions src/core/voice_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
log = logging.getLogger("cohost.orch")


def note_timing(orch, key: str, value: int) -> None:
sample = int(value)
orch.timings[key] = sample
bucket = getattr(orch, "timing_samples", {}).get(key)
if bucket is not None:
bucket.append(sample)


def _bump_voice(orch) -> int:
orch._voice_gen += 1
orch._voice_cancel.set()
Expand Down Expand Up @@ -75,7 +83,7 @@ async def _emit_cohost(orch, reply: str, *, thread: str, speak: bool, post: bool
try:
t0 = time.perf_counter()
await orch.twitch.send(chat_text)
orch.timings["chat_send_ms"] = int((time.perf_counter() - t0) * 1000)
note_timing(orch, "chat_send_ms", int((time.perf_counter() - t0) * 1000))
await orch.hub.broadcast(
{
"type": "chat",
Expand Down Expand Up @@ -130,7 +138,7 @@ async def on_utterance(orch, audio: np.ndarray) -> None:
await orch.hub.broadcast({"type": "log", "data": f"STT error: {e}"})
await orch.hub.patch_status(phase="listening" if orch.mic.listen_enabled else "idle")
return
orch.timings["stt_ms"] = int((time.perf_counter() - started) * 1000)
note_timing(orch, "stt_ms", int((time.perf_counter() - started) * 1000))
if gen != orch._voice_gen:
return
if not text:
Expand Down Expand Up @@ -279,7 +287,7 @@ async def _voice_reply(orch, user_text: str, gen: int, started: float) -> None:
return
if not first_token:
first_token = True
orch.timings["first_token_ms"] = int((time.perf_counter() - started) * 1000)
note_timing(orch, "first_token_ms", int((time.perf_counter() - started) * 1000))
sentence = orch._clean(sentence.strip())
if not sentence or is_generic_filler(sentence, orch._banned_extra()):
continue
Expand All @@ -293,7 +301,7 @@ async def _voice_reply(orch, user_text: str, gen: int, started: float) -> None:
await orch._speak(sentence)
if not first_audio:
first_audio = True
orch.timings["first_audio_ms"] = int((time.perf_counter() - started) * 1000)
note_timing(orch, "first_audio_ms", int((time.perf_counter() - started) * 1000))
if gen != orch._voice_gen:
return
reply = orch._clean(" ".join(full).strip())
Expand Down Expand Up @@ -326,7 +334,7 @@ async def _emit_voice_result(
try:
t0 = time.perf_counter()
await orch.twitch.send(short)
orch.timings["chat_send_ms"] = int((time.perf_counter() - t0) * 1000)
note_timing(orch, "chat_send_ms", int((time.perf_counter() - t0) * 1000))
orch.memory.remember(
"cohost_chat", orch._cohost_name(), short, via="chat", thread=STREAMER_THREAD
)
Expand Down
31 changes: 31 additions & 0 deletions src/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ async def snapshot(settings: Settings, orch) -> dict[str, Any]:
"quality": "Whisper CPU + qwen2.5:7b-instruct-q4_K_M",
},
"timings": getattr(orch, "timings", {}),
"timings_p50": _timing_percentile(orch, 50),
"timings_p95": _timing_percentile(orch, 95),
"timing_warning": _timing_warning(orch),
"duck": orch.duck.status() if getattr(orch, "duck", None) else {"mode": "off", "note": ""},
"wake": orch.mic.wake.status() if getattr(orch, "mic", None) else {"enabled": False},
"mood": getattr(orch, "mood", "normal"),
Expand Down Expand Up @@ -151,6 +154,34 @@ async def snapshot(settings: Settings, orch) -> dict[str, Any]:
}


def _percentile(values: list[int], pct: float) -> int:
ordered = sorted(int(item) for item in values)
if len(ordered) == 1:
return ordered[0]
rank = (pct / 100.0) * (len(ordered) - 1)
lo = int(rank)
hi = min(lo + 1, len(ordered) - 1)
frac = rank - lo
return int(round(ordered[lo] * (1 - frac) + ordered[hi] * frac))


def _timing_percentile(orch, pct: float) -> dict[str, int]:
samples = getattr(orch, "timing_samples", {}) or {}
stats: dict[str, int] = {}
for key, bucket in samples.items():
rows = list(bucket)
if rows:
stats[str(key)] = _percentile(rows, pct)
return stats


def _timing_warning(orch) -> str:
audio = _timing_percentile(orch, 95).get("first_audio_ms")
if audio is not None and audio > 2500:
return "First-audio p95 is over 2.5 s."
return ""


def _twitch_feeds(orch) -> dict[str, str]:
client = getattr(orch, "eventsub", None)
reported = dict(getattr(client, "feed_status", {}) or {})
Expand Down
16 changes: 15 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pathlib import Path

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from starlette.middleware.trustedhost import TrustedHostMiddleware
Expand Down Expand Up @@ -142,6 +142,7 @@ def create_app(prepared: tuple[Settings, Hub, Orchestrator] | None = None) -> Fa
@asynccontextmanager
async def life(app: FastAPI):
app.state.control_token = secrets.token_urlsafe(32)
app.state.control_key = os.getenv("COHOST_CONTROL_KEY", "").strip()
if prepared is not None:
settings, hub, orch = prepared
app.state.settings = settings
Expand Down Expand Up @@ -186,6 +187,19 @@ async def overlay():
async def session():
return {"token": app.state.control_token}

@app.get("/api/transcript")
async def transcript():
memory = app.state.orch.memory
path = memory.transcript_path()
filename = f"{memory.session_id}.jsonl"
if not path.exists():
return Response(
b"",
media_type="application/x-ndjson",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
return FileResponse(path, filename=filename, media_type="application/x-ndjson")

@app.get("/api/health")
async def health():
data = await snapshot(app.state.settings, app.state.orch)
Expand Down
Loading
Loading