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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Changelog

Version `0.2.0` in `pyproject.toml` matches the `v0.2.0` tag. The owner pushes that tag.

## 0.2.0

PLAN-v3.

- #8 PLAN-v3 Phase 1: pin the web stack and fix the first-wave bugs
- #9 PLAN-v3 Phase 2: harden local state and split the orchestrator
- #10 PLAN-v3 Phase 3: stream-day hype, recaps, clips, and questions
- #11 V3 Phase 4: booth quality-of-life
- Docs for every config key, `requirements-extras.txt`, and this changelog

## 0.1.0

PLAN-v2.

- #1 Phase 0: hermetic tests, pinned deps, and CI
- #2 Phase 1: shoutouts, EventSub scopes, Piper pipes, output guard
- #3 Phase 2: booth lock, memory retention, and safe downloads
- #4 Cursor/phase 1 fixes 0e13
- #5 Phase 3: keep the mic, chat, and model paths from stalling
- #6 Phase 4: rewards, recap, queue controls, and profiles
- #7 Phase 5: tests, dashboard sync, docs, and backup
6 changes: 3 additions & 3 deletions PLAN-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,19 +286,19 @@ Do these on a test stream or with the booth open before relying on the features.

### Phase 5: Docs and packaging

- [ ] **V3-P5-01 🟡 README refresh**
- [x] **V3-P5-01 🟡 README refresh**
- *Do:* document duck app lists (V3-P1-07), wake-word tuning, the action scopes list, IPv6 note, recap/session behaviour, custom commands, the control key, and the owner checklist. Fix "endpoint ducking" and "`!so` shouts out the last raider" (it also takes a name).
- *Files:* `README.md`, `config.yaml` comments
- *Acceptance:* every config key in `settings.py` is mentioned in README or `config.yaml` comments (add a test that greps for them).
- *Depends on:* the features you chose

- [ ] **V3-P5-02 🟡 Optional extras file**
- [x] **V3-P5-02 🟡 Optional extras file**
- *Do:* add `requirements-extras.txt` with pinned `pycaw` and `openwakeword`; `start.bat --extras` installs it. Health shows "installed / not installed" for each extra.
- *Files:* `requirements-extras.txt`, `start.bat`, `src/health.py`, `README.md`, `tests/test_start_bat.py`
- *Acceptance:* `start.bat --extras` installs both; without the flag nothing extra is installed.
- *Depends on:* none

- [ ] **V3-P5-03 🟡 Release tag and changelog**
- [x] **V3-P5-03 🟡 Release tag and changelog**
- *Do:* add `CHANGELOG.md` (v0.1 = PLAN-v2 work, v0.2 = this plan), bump `pyproject.toml` version, tag `v0.2.0` after Phase 1–2 merge (owner pushes the tag).
- *Files:* `CHANGELOG.md`, `pyproject.toml`
- *Acceptance:* changelog lists each merged PR; version matches the tag.
Expand Down
151 changes: 143 additions & 8 deletions README.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
server:
# server.host stays on localhost. IPv6 loopback is ::1; the booth also accepts Host: [::1]:port.
host: 127.0.0.1
port: 8080
# The booth has no auth. Stay on localhost unless you set this true on purpose.
Expand Down Expand Up @@ -67,7 +68,8 @@ audio:
barge_in: true
echo_gate: true
echo_gate_ratio: 2.5
# Local wake word via openwakeword. Off by default, and never the only way to talk.
# audio.wake_word uses openwakeword and never replaces hold-to-talk.
# audio.wake_models names the models. audio.wake_threshold is 0 to 1; raise it when a phrase false-triggers.
wake_word: false
wake_models:
- hey_jarvis
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "local-twitch-cohost"
version = "0.1.0"
version = "0.2.0"
description = "Local Twitch cohost"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
4 changes: 4 additions & 0 deletions requirements-extras.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Optional extras. start.bat installs this only when you pass --extras.
# pycaw is Windows-only session ducking. openwakeword is the local wake word.
pycaw==20260927
openwakeword==0.6.0
10 changes: 10 additions & 0 deletions src/health.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import importlib.util
import subprocess
import sys
import time
Expand Down Expand Up @@ -119,6 +120,7 @@ async def snapshot(settings: Settings, orch) -> dict[str, Any]:
"snappy": "Whisper CUDA + qwen2.5:3b",
"quality": "Whisper CPU + qwen2.5:7b-instruct-q4_K_M",
},
"extras": extra_status(),
"timings": getattr(orch, "timings", {}),
"timings_p50": _timing_percentile(orch, 50),
"timings_p95": _timing_percentile(orch, 95),
Expand Down Expand Up @@ -154,6 +156,14 @@ async def snapshot(settings: Settings, orch) -> dict[str, Any]:
}


def extra_status() -> dict[str, str]:
"""Whether the optional Windows extras are importable."""
status: dict[str, str] = {}
for name in ("pycaw", "openwakeword"):
status[name] = "installed" if importlib.util.find_spec(name) is not None else "not installed"
return status


def _percentile(values: list[int], pct: float) -> int:
ordered = sorted(int(item) for item in values)
if len(ordered) == 1:
Expand Down
26 changes: 26 additions & 0 deletions start.bat
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ setlocal
cd /d "%~dp0"

set NOBROWSER=0
set EXTRAS=0
:parse_args
if "%~1"=="" goto args_done
if /I "%~1"=="--no-browser" set NOBROWSER=1
if /I "%~1"=="--extras" set EXTRAS=1
shift
goto parse_args
:args_done

set "PY="
py -3.11 -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" >nul 2>nul
Expand Down Expand Up @@ -48,6 +55,25 @@ if errorlevel 1 (
)
)

if "%EXTRAS%"=="1" (
.venv\Scripts\python.exe -c "import hashlib,pathlib,sys; p=pathlib.Path('requirements-extras.txt'); h=hashlib.sha256(p.read_bytes()).hexdigest(); s=pathlib.Path(r'.venv\requirements-extras.stamp'); sys.exit(0 if s.exists() and s.read_text(encoding='utf-8').strip()==h else 1)"
if errorlevel 1 (
echo Installing optional extras...
.venv\Scripts\python.exe -m pip install -r requirements-extras.txt
if errorlevel 1 (
echo pip install of extras failed. Fix the error above and run start.bat --extras again.
pause
exit /b 1
)
.venv\Scripts\python.exe -c "import hashlib,pathlib; p=pathlib.Path('requirements-extras.txt'); pathlib.Path(r'.venv\requirements-extras.stamp').write_text(hashlib.sha256(p.read_bytes()).hexdigest(), encoding='utf-8')"
if errorlevel 1 (
echo Could not record the extras install stamp. The next launch with --extras will retry pip.
pause
exit /b 1
)
)
)

for /f "tokens=1,2" %%a in ('.venv\Scripts\python.exe -c "from src.settings import load_settings; s=load_settings(); print(s.host, s.port)"') do (
set HOST=%%a
set PORT=%%b
Expand Down
8 changes: 8 additions & 0 deletions tests/test_start_bat.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ def test_start_bat_fails_loudly_and_stamps_only_after_pip():
assert install_at < error_at < stamp_at
assert "Failed to create the virtualenv." in text
assert "Could not read server.host and server.port" in text
assert "set EXTRAS=0" in text
gate = text.index('if "%EXTRAS%"=="1"')
extras_pip = text.index("pip install -r requirements-extras.txt")
assert gate < extras_pip
assert text.index("pip install -r requirements.txt") < gate
extras = (ROOT / "requirements-extras.txt").read_text(encoding="utf-8")
assert "pycaw==20260927" in extras
assert "openwakeword==0.6.0" in extras


def test_shipped_config_keeps_optional_feeds_off():
Expand Down
73 changes: 73 additions & 0 deletions tests/test_v3_phase5.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import asyncio
import importlib.util
import re
from pathlib import Path

from src.health import extra_status, snapshot
from tests.test_orchestrator_policy import make_orch

ROOT = Path(__file__).resolve().parents[1]


def _config_keys() -> list[str]:
text = (ROOT / "src" / "settings.py").read_text(encoding="utf-8")
found = re.findall(r'(?:_get|_command_list)\(\s*data\s*,\s*"([^"]+)"', text, re.S)
return list(dict.fromkeys(found))


def test_every_config_key_is_documented():
docs = "\n".join(
(ROOT / name).read_text(encoding="utf-8") for name in ("README.md", "config.yaml")
)
missing = [key for key in _config_keys() if key not in docs]
assert missing == []
assert _config_keys()


def test_readme_covers_phase5_topics():
readme = (ROOT / "README.md").read_text(encoding="utf-8")
assert "endpoint ducking" not in readme.lower()
assert "not Windows endpoint" in readme
assert "audio.duck_apps" in readme
assert "audio.wake_threshold" in readme
assert "clips:edit" in readme
assert "[::1]" in readme
assert "chat.custom_commands" in readme
assert "COHOST_CONTROL_KEY" in readme
assert "YYYY-MM-DD-HHMM.md" in readme
assert "!so` with no name shouts out the last raider" in readme
assert "Owner checklist" in readme


def test_health_reports_optional_extras(tmp_path, settings, monkeypatch):
assert extra_status() == {"pycaw": "not installed", "openwakeword": "not installed"}

def find_spec(name):
return object() if name == "pycaw" else None

monkeypatch.setattr(importlib.util, "find_spec", find_spec)
assert extra_status() == {"pycaw": "installed", "openwakeword": "not installed"}

async def run():
orch = make_orch(tmp_path, settings)

class _Wake:
def status(self):
return {"enabled": False}

orch.mic.wake = _Wake()
health = await snapshot(settings, orch)
assert health["extras"]["pycaw"] == "installed"
assert health["extras"]["openwakeword"] == "not installed"

asyncio.run(run())


def test_changelog_lists_merged_prs_and_matches_version():
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
assert 'version = "0.2.0"' in pyproject
assert "0.2.0" in changelog
assert "v0.2.0" in changelog
for number in range(1, 12):
assert f"#{number} " in changelog
Loading