Adding a hands-free wake word with Vosk keyphrase spotting
I built hands-free wake-word listening for Foreman. The push-to-talk hotkey still works. This is additive.
Vosk over openWakeWord for licensing
I started with openWakeWord, then pivoted mid-build to Vosk. openWakeWord's code is Apache 2.0 but its stock models are CC BY-NC-SA 4.0, which is non-commercial. Vosk's toolkit and vosk-model-small-en-us-0.15 are both Apache 2.0. Nothing blocks Foreman shipping as a product.
Name-derived wake phrase
The wake phrase is hey plus the assistant name lowercased. Emma becomes "hey emma". It reads get_assistant_name() at start, so renaming the assistant in the panel retunes the wake word next launch.
Swappable detector boundary
I isolated the detector behind a tiny interface: phrase, reset(), and accept(pcm)->bool. The swap point is _make_detector(). A dedicated acoustic wake model can replace Vosk without touching the mic, capture, or pipeline.
Passive audio never transcribed
The wake listener runs a continuous mic with an energy-VAD command capture. It listens but does not transcribe or store passive audio. Only the command after the wake word goes to transcription.
One mic at a time
Both hotkey and wake mode share a memory lock with PID-based staleness reclaim. Only one can hold the mic.
Problems during the build
git-bash $! writes an MSYS pseudo-PID, not a Win32 PID. ctypes OpenProcess saw it as dead and reclaimed the lock incorrectly. I fixed the test to use a real Windows PID via Python subprocess.Popen. The guard itself is correct.
Buffered stdout through pipes hid the startup line during smoke tests. Run with python -u for unbuffered output.
Still needs a live mic test
I verified offline: compile, depth harness at 29, and a real Kokoro-TTS to Vosk spot test where "hey emma" spotted and "what is the git status" rejected as unknown. Wake accuracy, false-wake rate, VAD end-of-speech feel, and no-self-wake-while-speaking are all unverified until the room test.
Date and time via prompt injection
In an earlier session today I made Emma always know the current date and time. I added it by prompt injection, not a tool. Every routing call carries a fresh "Current date and time" line at the front of the system prompt, plus a directive to answer date and time questions directly from it. This fixed the earlier honest refusal.
I also fixed TTS reading clock times digit-by-digit. "9:07 AM" was spoken "nine zero seven". I normalized it so Kokoro says "nine oh seven". The normalization lives in say() so every current and future spoken time is covered. Printed output is untouched.
Weather tool with shaped answers
In the first session today I added Foreman's first external data tool: weather via Open-Meteo. It is free, keyless, no signup, and SAFE-classified. I set the pattern for external tools: one named tool, one fixed source, read-only, not general web access.
Weather answers shape to the question. "Is it going to rain today?" gets a yes/no verdict plus numbers. "What's the weather?" gets a general two-sentence summary. The router's only say is the focus enum. Every number and the yes/no verdict come straight from the tool dict via a fixed template. The model cannot invent a temperature or verdict.
Rain verdict thresholds: precip_chance under 20 is "No rain expected", 20 to 60 is "Maybe", over 60 is "Likely".
Forecast is today-only. "Will it rain tomorrow?" routes to the rain focus but answers for today. I documented it as a known limit rather than silently wrong.