17 KiB
Test 2 wakeword C# probe — design
Date: 2026-06-12
Branch: fresh-start
Status: approved (pending written-spec review)
Purpose
Prove that openwakeword's stock "alexa" detector can be driven from .NET on the Pi, by porting openwakeword/model.py's predict() pipeline (three ONNX models chained: mel-spectrogram → speech embedding → keyword classifier) to C# via Microsoft.ML.OnnxRuntime. End-to-end behaviour replicates the Python probe at tests/02-wakeword/main.py.
This is a throwaway probe. Once it passes the criteria the Python probe passed, its job is done — it does not become the foundation of the main C# Pi client. The WakewordModel.cs class it produces is the natural lift-and-shift candidate for the eventual client, but that's a future decision, not a constraint on the probe.
Test 1's record-play C# port (tests/01-record-play-cs/) is the immediate ancestor. Its patterns — Libc.setenv for env knobs the C runtime reads, callback-driven PortAudioSharp.Stream, <AllowUnsafeBlocks>, using Stream = PortAudioSharp.Stream; — carry over verbatim. See findings.md § "C# probe outcome (2026-06-12 — Test 1 ported to C#)".
Non-goals
- No numerical-parity check against the Python implementation. We trust the port if hardware passes.
- No custom wakeword training. Stock "alexa" model only. Custom wakeword is a separate task (see
findings.md§ C). - No fix for "speaker is the mic" self-triggering. Main-assistant concern (
findings.md§ B), out of probe scope. - No reusable audio abstraction. One-folder probe; if it grows beyond ~400 lines that's a smell.
- No automated tests. Verification is the same listening test the Python probe uses.
- No changes to
bin/bootstrap. The Pi gets a self-contained binary; .NET is never installed on the Pi. - No coupling to the Python prototypes. They keep working, untouched.
Functional spec
Identical to the Python Test 2 (tests/02-wakeword/main.py:4-7):
- Open a 16 kHz mono PCM16 input stream on the USB Speaker Phone.
- Run each 80 ms (1280-sample) frame through the openwakeword "alexa" pipeline.
- When the classifier score crosses
THRESHOLD = 0.5and at leastCOOLDOWN_S = 1.0seconds have passed since the last trigger, printDETECTED alexa score={s:0.000} t={elapsed:0.1}sand play a 200 ms 880 Hz beep through the same USB device. - Loop until Ctrl-C.
Pass criteria
Same as the original Python spec:
- "alexa" at normal volume from ~1 m triggers within ~1 s in ≥ 8/10 attempts.
- ≤ 1 false positive per minute of unrelated speech (~3 minutes of reading aloud).
- Process stays under 50% of one core, observed via
htopon the Pi.
Layout
tests/02-wakeword-cs/
Probe.csproj # net9.0, RuntimeIdentifier=linux-arm64, PortAudioSharp2 + Microsoft.ML.OnnxRuntime
Program.cs # entry point: env vars → init → device pick → input stream → main loop → beep dispatch
WakewordModel.cs # IDisposable; loads three ONNX sessions; Predict(short[] frame1280) → float score
models/
melspectrogram.onnx # ~14 KB, openwakeword resource
embedding_model.onnx # ~10 MB, Google speech embedding model (openwakeword resource)
alexa.onnx # ~1.5 MB, stock alexa classifier (openwakeword resource)
bin/probe-cs-2 # build (locally) → scp (to Pi) → run (over SSH); copy of bin/probe-cs with new paths
The Python prototypes under tests/01-record-play/ and tests/02-wakeword/ and the Test 1 C# probe under tests/01-record-play-cs/ are untouched.
Sourcing the ONNX files for the repo
The three .onnx files are sourced from the Pi's existing openwakeword install at implementation time:
sshpass -p assistant scp \
pi@192.168.50.115:.local/lib/python3.13/site-packages/openwakeword/resources/models/{melspectrogram,embedding_model,alexa}.onnx \
tests/02-wakeword-cs/models/
Their SHA-256 hashes are recorded in the implementation plan for reproducibility. The same files openwakeword's Python download_models() helper fetches at first run — we vendor them rather than introducing a startup network dependency. ~11 MB total in git is acceptable for a probe.
Components
WakewordModel.cs — the substantive port
Public surface:
class WakewordModel : IDisposable {
WakewordModel(string melPath, string embeddingPath, string classifierPath);
float Predict(short[] frame1280); // returns score in [0, 1]
void Dispose();
}
Constructor loads three Microsoft.ML.OnnxRuntime.InferenceSession instances (one per ONNX file). Each session is configured with SessionOptions { IntraOpNumThreads = 1, InterOpNumThreads = 1, LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR } to keep CPU bounded and the console quiet.
Predict is called once per 80 ms input frame (1280 PCM16 samples at 16 kHz) and returns the most recent classifier score.
Pipeline (matches openwakeword/model.py's Model.predict()):
- Audio buffer. Append the incoming 1280 samples to a raw-audio ring of recent samples.
- Mel stage. Run
melspectrogram.onnxover the latest window of audio. Append output mel frames to a mel-frame ring. - Embedding stage. When the mel ring has accumulated enough fresh mel frames to fill the embedding model's input window (76 frames per openwakeword's source — but verify), slice the window, run
embedding_model.onnx. Append the resulting embedding to an embedding ring. - Classifier stage. Slice the most recent N embeddings (N = whatever shape
alexa.onnxdeclares on its input). Run the classifier. Return the score.
Per 80 ms input, the classifier runs exactly once. Between calls, the three ring buffers hold all cross-call state.
Ground truth — do not guess tensor shapes. The exact buffer sizes (raw-audio window length, mel-frame count for embedder input, embedding count for classifier input) MUST be derived during implementation from two sources:
- The Python reference:
openwakeword/model.pyon the Pi at~/.local/lib/python3.13/site-packages/openwakeword/model.py. Read it side-by-side with the C# code. Numbers quoted in this spec are from memory and authoritative only after verification. - The ONNX files themselves:
InferenceSession.InputMetadataexposes the declared input shapes. The constructor MUST print the declared input + output shapes of all three models at startup the first time the probe runs, and SHOULD assert against them to fail fast on geometry mismatch.
Threading. Predict is called only from the main thread (per the data-flow section below). No internal locking. Dispose cleans up all three sessions.
Why three sessions instead of one combined graph. openwakeword ships them as three separate files because the embedding model is Google's pretrained TF Hub model. Combining them offline is possible but a deliberate optimisation, not needed for the probe.
Program.cs — entry point and audio I/O
Single top-level-statements file. Reuses every Test 1 pattern:
-
Set
PA_ALSA_PLUGHW=1viaLibc.setenv("PA_ALSA_PLUGHW", "1", 1)+Environment.SetEnvironmentVariable(the dual-call workaround documented infindings.md). -
Set
ORT_LOGGING_LEVEL=3(error-only) viaLibc.setenvto suppress onnxruntime's/sys/class/drm/card0GPU-detection warnings on the Pi. -
PortAudio.Initialize(). -
FindUsbDevice()— same body as Test 1; first device whose name containsusb(case-insensitive) and hasmaxInputChannels >= 1. On failure: print device table to stderr, exit 1. -
var model = new WakewordModel(...)— paths resolved relative toAppContext.BaseDirectory + "models/". Print load time (mirrors Python's"Model loaded in {t}s"). -
Pre-compute the 200 ms 880 Hz sine-wave beep as a
short[3200]once at startup. Reused for every detection. -
Open input stream: callback-driven, 16 kHz, 1 channel, Int16, 1280-frame block size. Callback copies into a fresh
short[1280]and pushes into aBlockingCollection<short[]>(boundedCapacity: 16). OnStreamCallbackFlags.InputOverflow: log[status] input overflowto stderr. OnTryAddfailure (queue full): log[status] consumer behindand drop the frame. -
stream.Start(). PrintListening on device {i} ({name}). Say 'alexa'. Ctrl-C to exit. -
Main loop:
while (!cts.IsCancellationRequested) { var frame = queue.Take(cts.Token); float score = model.Predict(frame); var now = sw.Elapsed; if (score >= 0.5f && (now - lastTrigger).TotalSeconds >= 1.0) { Console.WriteLine($"DETECTED alexa score={score:0.000} t={now.TotalSeconds:0.0}s"); lastTrigger = now; FireAndForgetBeep(device, beepBuffer); } } -
Ctrl-C via
Console.CancelKeyPress→cts.Cancel()→ break out ofTake, stop input stream, disposeWakewordModel,PortAudio.Terminate(). Graceful.
FireAndForgetBeep(int device, short[] beepBuffer) — the bug fix
The Python probe blocks the input thread for ~200 ms on each beep (findings.md § A). The C# probe fixes this:
- On detection, open a new output-only
Streaminstance (16 kHz, 1 channel, Int16, 1024-frame block). Callback drainsbeepBufferat a write offset; on exhaustion sets aManualResetEventSlimand returnsStreamCallbackResult.Complete. - A small
Task.Runwaits the event, thenStream.Stop()+Stream.Dispose()s the stream. Main thread does not wait. - The 1.0 s cooldown is well past the 200 ms playback duration, so the previous beep stream has always finished disposing before the next one is opened. If for any reason it hasn't, opening a fresh one is fine — they're independent.
This is the first time we run two PortAudio streams concurrently on the same USB device. Validating that this works is part of what the probe surfaces.
Data flow
PortAudio input callback (RT thread)
└─ alloc short[1280], memcpy from PortAudio buffer
└─ queue.TryAdd(frame, 0ms)
├─ success → return Continue
└─ failure → log "[status] consumer behind", drop frame, return Continue
Main thread
└─ for each frame from queue:
├─ WakewordModel.Predict(frame) # runs all three ONNX models
├─ if score >= 0.5 AND cooldown ok:
│ ├─ print DETECTED line
│ └─ FireAndForgetBeep(device, beepBuffer):
│ ├─ create ManualResetEventSlim 'done'
│ ├─ create output Stream (callback drains beepBuffer, Sets 'done' on exhaustion)
│ ├─ Task.Run(() => { done.Wait(); stream.Stop(); stream.Dispose(); })
│ └─ stream.Start(); return immediately
└─ loop
Output callback (separate RT thread, one per beep)
└─ drain beepBuffer → return Complete on exhaustion
└─ Sets 'done' event → cleanup Task wakes and disposes the stream
Three concerns isolated to three threads: PortAudio's input callback (allocate + enqueue only), the main thread (all inference + dispatch), per-beep PortAudio output callbacks. No locks in the steady state — the queue and the per-beep events are the synchronisation surface.
Error handling
- USB device not found → device table to stderr, exit 1. (Same as Test 1.)
PortAudio.Initialize,Streamctor,Stream.Start→PaError != NoErrorthrowsPortAudioExceptionfrom PortAudioSharp; uncaught, crashes with a useful trace. (Same as Test 1.)WakewordModelconstructor:- Missing ONNX file at given path → throw
FileNotFoundExceptionwith the expected path. - Loaded model's
InputMetadatashape disagrees with what the pipeline expects → throwInvalidOperationExceptionwith which model and what shape was declared vs expected.
- Missing ONNX file at given path → throw
- Input overflow flag → log
[status] input overflowto stderr, keep going. Same as Test 1 and the Python probe. - Queue full at enqueue time → log
[status] consumer behind, drop frame, keep going. Sustained occurrence is a probe finding worth documenting infindings.mdpost-run. - Anything else (PortAudio mid-stream device disappearance, onnxruntime inference errors, CoreCLR issues) → uncaught throw. Probe, not production.
Deploy & run (bin/probe-cs-2)
A copy of bin/probe-cs with three changes: project path, remote dir name, run path. Body:
#!/usr/bin/env bash
set -euo pipefail
PI_HOST="${PI_HOST:-192.168.50.115}"
PI_USER="${PI_USER:-pi}"
PI_PASS="${PI_PASS:-assistant}"
PUBLISH_DIR="${PUBLISH_DIR:-/tmp/probe-cs-2-out}"
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"
echo ">> dotnet publish (linux-arm64, self-contained)"
dotnet publish tests/02-wakeword-cs \
-c Release -r linux-arm64 --self-contained \
-o "$PUBLISH_DIR"
echo ">> scp to $PI_USER@$PI_HOST:~/probe-cs-2/ (wipe-and-replace)"
sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=accept-new \
"$PI_USER@$PI_HOST" 'rm -rf ~/probe-cs-2 && mkdir ~/probe-cs-2'
sshpass -p "$PI_PASS" scp -r "$PUBLISH_DIR/." \
"$PI_USER@$PI_HOST:~/probe-cs-2/"
echo ">> ssh + run on Pi"
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=accept-new \
"$PI_USER@$PI_HOST" 'cd ~/probe-cs-2 && chmod +x Probe && ./Probe'
Note ssh -t (pseudo-terminal) so Ctrl-C from the workstation propagates as SIGINT to the remote process for the Console.CancelKeyPress graceful-shutdown path. Test 1 didn't need this because the probe self-terminated.
Cleanup (when the probe is retired): ssh pi@... 'rm -rf ~/probe-cs-2' plus git rm -r tests/02-wakeword-cs bin/probe-cs-2.
Verification
The pass criteria above. There are no automated tests.
Hardware test protocol.
bin/probe-cs-2from the workstation. Wait forListening...prompt.- Position yourself ~1 m from the USB Speaker Phone.
- Say "alexa" at normal volume, 10 times, pausing ≥ 2 s between attempts. Count detections (printed line + beep).
- Read three minutes of arbitrary text aloud (newspaper or book — anything that isn't "alexa"). Count false-positive beeps.
- From a second terminal:
sshpass -p assistant ssh pi@192.168.50.115 htop. Read theProbeprocess CPU% column.
Pass = ≥ 8/10 detections, ≤ 1 false positive per minute, < 50% of one core. Ctrl-C to exit.
Diagnostic levers if it fails
- Detections < 8/10 but scores log near threshold. Likely buffer geometry off-by-one in the embedding stage; re-verify
WakewordModel.csagainstopenwakeword/model.pyside-by-side. - No detections, scores ≈ 0. Tensor-shape or dtype mismatch upstream of the classifier — the startup assertion should have caught it; if it didn't, the assertion is incomplete. Fix the assertion first, then re-check.
- Excess false positives. Threshold too low for this model + room. The Python probe passed at 0.5; if C# scores are systematically higher, the bug is in the port. If lower, also a port bug. Either way, do not blindly retune the threshold.
- CPU > 50%. Confirm
IntraOpNumThreads = 1was set on all sessions. Inspect withhtopwhether the spike is on input frames or on a sustained background — the latter suggests something is spinning. - Input-overflow / consumer-behind warnings. Inference latency spike. Log per-frame
WakewordModel.Predictduration; spikes above 80 ms mean inference cannot keep up.
Open assumption (verify at implementation time)
Microsoft.ML.OnnxRuntime NuGet package on linux-arm64 ships its own native libonnxruntime.so for the RID, so the self-contained publish includes it. Verify by listing the publish dir after dotnet publish. If for some reason it doesn't (older versions of the package split the native runtime separately), fall back to Microsoft.ML.OnnxRuntime.Managed + an explicit native-runtime package for the RID, or fetch the prebuilt binary from the ONNX Runtime GitHub releases. Confirmed at implementation, not now — does not affect the design.
Post-run: findings.md update
After hardware passes, append a ## C# wakeword probe outcome (2026-06-12 — Test 2 ported to C#) section to findings.md in the same shape as the Test 1 section:
- What we proved (the three pass criteria, plus "two PortAudio streams on one device works", plus "ONNX runtime on Pi performant enough").
- Key gotchas hit during the port (anything not already captured by the Test 1 section).
- Open questions deferred to the main assistant build (custom wakeword training, AEC for speaker-is-mic, queue depth in real conditions).
References
- Python probe:
tests/02-wakeword/main.py - Test 1 C# probe (patterns reused):
tests/01-record-play-cs/Program.cs,tests/01-record-play-cs/Probe.csproj,bin/probe-cs - openwakeword inference reference (ground truth for buffering geometry):
~/.local/lib/python3.13/site-packages/openwakeword/model.pyon the Pi - Prototype findings:
findings.md— especially "C# probe outcome (2026-06-12 — Test 1 ported to C#)", "Force PortAudio through ALSA'splugplugin", "Beep / TTS blocks the input stream", "Cosmetic / known-warnings" (ORT GPU detection) - Microsoft.ML.OnnxRuntime — fetch current package docs via Context7 at implementation time (
/microsoft/onnxruntime). - Test 1 C# probe spec / plan (precedent for this spec's shape):
docs/superpowers/specs/2026-06-12-record-play-csharp-probe-design.md,docs/superpowers/plans/2026-06-12-record-play-csharp-probe.md - Pi access:
pi@192.168.50.115, passwordassistant(seeCLAUDE.md)