diff --git a/docs/superpowers/plans/2026-06-12-test-3-full-cycle-csharp.md b/docs/superpowers/plans/2026-06-12-test-3-full-cycle-csharp.md new file mode 100644 index 0000000..dc42473 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-test-3-full-cycle-csharp.md @@ -0,0 +1,758 @@ +# Test 3 — Full-cycle C# probe Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a single-binary C# probe at `tests/03-full-cycle-cs/` that runs the full assistant cycle on the Pi: idle → "alexa" → beep → record 5 s → play back → idle, with detector gating during non-idle states and a `WakewordModel.Reset()` on every IDLE re-entry. + +**Architecture:** One persistent PortAudio input stream feeds 80 ms frames into a `BlockingCollection`; a single consumer thread runs a four-state machine (IDLE / BEEPING / RECORDING / PLAYBACK). Beep and playback are fire-and-forget output streams using Test 2's `Start()`-before-`Task.Run` + 2 s timeout lifetime pattern. `WakewordModel` is copied verbatim from Test 2 with a new `Reset()` method called on PLAYBACK→IDLE. + +**Tech Stack:** .NET 9 (self-contained linux-arm64), PortAudioSharp2 1.0.6, Microsoft.ML.OnnxRuntime 1.26.0. Deploy via `dotnet publish` + `scp` + `ssh -t` to Pi at `pi@192.168.50.115`. + +**Spec:** `docs/superpowers/specs/2026-06-12-test-3-full-cycle-csharp-design.md`. Read it before starting. + +**Branch:** `fresh-start` (already current; do NOT create a new branch). + +--- + +### Task 1: Scaffold `tests/03-full-cycle-cs/` project skeleton + +**Files:** +- Create: `tests/03-full-cycle-cs/Probe.csproj` +- Create: `tests/03-full-cycle-cs/models/` (directory; populated in Task 2) + +- [ ] **Step 1: Create the project directory** + +```bash +mkdir -p tests/03-full-cycle-cs/models +``` + +- [ ] **Step 2: Write `Probe.csproj`** + +Create `tests/03-full-cycle-cs/Probe.csproj` with this exact content (identical to `tests/02-wakeword-cs/Probe.csproj` except `RootNamespace`): + +```xml + + + Exe + net9.0 + FullCycleProbe + Probe + enable + enable + linux-arm64 + true + false + true + true + + + + + + + + PreserveNewest + + + +``` + +- [ ] **Step 3: Commit the scaffold** + +```bash +git add tests/03-full-cycle-cs/Probe.csproj +git commit -m "Test 3 scaffold: csproj for tests/03-full-cycle-cs" +``` + +--- + +### Task 2: Copy models and `WakewordModel.cs` from Test 2 (with namespace rename and `Reset()`) + +**Files:** +- Create: `tests/03-full-cycle-cs/WakewordModel.cs` (copied from Test 2, namespace renamed, `Reset()` added) +- Create: `tests/03-full-cycle-cs/models/melspectrogram.onnx` +- Create: `tests/03-full-cycle-cs/models/embedding_model.onnx` +- Create: `tests/03-full-cycle-cs/models/alexa.onnx` + +- [ ] **Step 1: Copy the three ONNX model files** + +```bash +cp tests/02-wakeword-cs/models/melspectrogram.onnx tests/03-full-cycle-cs/models/ +cp tests/02-wakeword-cs/models/embedding_model.onnx tests/03-full-cycle-cs/models/ +cp tests/02-wakeword-cs/models/alexa.onnx tests/03-full-cycle-cs/models/ +``` + +- [ ] **Step 2: Copy `WakewordModel.cs`** + +```bash +cp tests/02-wakeword-cs/WakewordModel.cs tests/03-full-cycle-cs/WakewordModel.cs +``` + +- [ ] **Step 3: Rename the namespace in the copy** + +Edit `tests/03-full-cycle-cs/WakewordModel.cs`: change line 4 from `namespace WakewordProbe;` to `namespace FullCycleProbe;`. Leave everything else unchanged. + +- [ ] **Step 4: Add `Reset()` method to the copy** + +Edit `tests/03-full-cycle-cs/WakewordModel.cs`. Insert the following method immediately before the `public void Dispose()` method near the bottom of the file: + +```csharp + public void Reset() + { + _rawRingFill = 0; + _melRing.Clear(); + _embRing.Clear(); + _framesSeen = 0; + } + +``` + +(Note the trailing blank line. The result should be a method block sitting between `return clsOut[0, 0]; }` (end of `Predict`) and `public void Dispose()`.) + +- [ ] **Step 5: Verify the project builds** + +Run from repo root: + +```bash +dotnet build tests/03-full-cycle-cs -c Release +``` + +Expected: `Build succeeded` with 0 errors, 0 warnings. (At this point `Program.cs` doesn't exist yet; csproj is `Exe` but the build will produce a no-entry-point warning or error. If it errors with "CS5001: Program does not contain a static 'Main' method", that's expected at this stage — proceed to Task 3 which adds `Program.cs`.) + +If the build errors for any reason other than the missing entry point, fix before continuing. + +- [ ] **Step 6: Commit** + +```bash +git add tests/03-full-cycle-cs/WakewordModel.cs tests/03-full-cycle-cs/models/ +git commit -m "Test 3: copy WakewordModel + models from Test 2, add Reset()" +``` + +--- + +### Task 3: Write `Program.cs` with the full state machine + +**Files:** +- Create: `tests/03-full-cycle-cs/Program.cs` + +The whole file is shown below. It lifts from `tests/02-wakeword-cs/Program.cs` (env-var setup, OrtEnv init, FindUsbDevice, input stream + queue, FireAndForgetBeep, Libc, MakeBeep) and adds: + +- `volatile State _state` enum-as-state-machine in the consumer loop. +- `FireAndForgetPlayback` helper (same shape as `FireAndForgetBeep` but reads from `recordBuf` and signals via `_playbackDoneFlag` instead of a `ManualResetEventSlim`). +- `model.Reset()` call on PLAYBACK→IDLE transition. + +- [ ] **Step 1: Write `Program.cs` in full** + +Create `tests/03-full-cycle-cs/Program.cs` with exactly this content: + +```csharp +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using FullCycleProbe; +using Microsoft.ML.OnnxRuntime; +using PortAudioSharp; +using Stream = PortAudioSharp.Stream; + +const int SampleRate = WakewordModel.SampleRate; // 16 000 +const int Channels = 1; +const uint BlockFrames = WakewordModel.FrameSamples; // 1280 (80 ms) +const float Threshold = 0.5f; +const double CooldownSeconds = 1.0; +const double BeepHz = 880.0; +const double BeepSeconds = 0.2; + +const int BeepingFrames = 3; // 3 * 80 ms = 240 ms (covers 200 ms beep + 40 ms drain/reverb margin) +const int RecordingFrames = 63; // 63 * 80 ms = 5.04 s (rounded up from 5 s to avoid partial-frame handling) +const int RecordBufSamples = RecordingFrames * (int)BlockFrames; // 80640 + +// .NET-side mirror for any readers that go through Environment.GetEnvironmentVariable, +// AND libc setenv so PortAudio / onnxruntime (which use getenv()) see the values. +Environment.SetEnvironmentVariable("PA_ALSA_PLUGHW", "1"); +Libc.setenv("PA_ALSA_PLUGHW", "1", 1); +Environment.SetEnvironmentVariable("ORT_LOGGING_LEVEL", "3"); +Libc.setenv("ORT_LOGGING_LEVEL", "3", 1); +// ORT 1.26.0: the env var does NOT suppress early GPU-discovery warnings from device_discovery.cc. +// CreateInstanceWithOptions sets the log level at OrtEnv creation time, before EP discovery runs. +var ortEnvOpts = new EnvironmentCreationOptions { logId = "FullCycleProbe", logLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR }; +OrtEnv.CreateInstanceWithOptions(ref ortEnvOpts); + +PortAudio.Initialize(); +WakewordModel? model = null; +try +{ + int device = FindUsbDevice(); + Console.WriteLine($"Using device {device} ('{PortAudio.GetDeviceInfo(device).name}')"); + + Console.WriteLine("Loading WakewordModel..."); + var t0 = DateTime.UtcNow; + string modelsDir = Path.Combine(AppContext.BaseDirectory, "models"); + model = new WakewordModel( + Path.Combine(modelsDir, "melspectrogram.onnx"), + Path.Combine(modelsDir, "embedding_model.onnx"), + Path.Combine(modelsDir, "alexa.onnx")); + Console.WriteLine($"Loaded in {(DateTime.UtcNow - t0).TotalSeconds:0.00}s."); + + var queue = new BlockingCollection(boundedCapacity: 16); + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + var inParams = new StreamParameters + { + device = device, + channelCount = Channels, + sampleFormat = SampleFormat.Int16, + suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowInputLatency, + hostApiSpecificStreamInfo = IntPtr.Zero, + }; + + Stream.Callback callback = (IntPtr input, IntPtr _, uint frameCount, + ref StreamCallbackTimeInfo _2, StreamCallbackFlags status, IntPtr _3) => + { + if (status.HasFlag(StreamCallbackFlags.InputOverflow)) + Console.Error.WriteLine("[status] input overflow"); + + if (frameCount != BlockFrames) + { + Console.Error.WriteLine($"[status] unexpected callback frameCount={frameCount} (want {BlockFrames})"); + return StreamCallbackResult.Continue; + } + + var frame = new short[BlockFrames]; + unsafe + { + short* src = (short*)input.ToPointer(); + fixed (short* dst = &frame[0]) + Buffer.MemoryCopy(src, dst, BlockFrames * sizeof(short), BlockFrames * sizeof(short)); + } + if (!queue.TryAdd(frame, 0)) + Console.Error.WriteLine("[status] consumer behind, dropping frame"); + return StreamCallbackResult.Continue; + }; + + using var stream = new Stream( + inParams, null, SampleRate, BlockFrames, StreamFlags.NoFlag, callback, IntPtr.Zero); + stream.Start(); + short[] beepBuffer = MakeBeep(BeepHz, BeepSeconds, SampleRate); + Console.WriteLine("Listening. Say 'alexa'. Ctrl-C to exit."); + + var sw = Stopwatch.StartNew(); + TimeSpan lastTrigger = TimeSpan.FromSeconds(-CooldownSeconds); + + // State machine (single-writer: this consumer thread). + State state = State.Idle; + int beepFrames = 0; + int recordOffset = 0; + short[] recordBuf = null!; // assigned on BEEPING→RECORDING transition + var playbackDone = new PlaybackDoneFlag(); + + try + { + while (!cts.IsCancellationRequested) + { + short[] frame = queue.Take(cts.Token); + + switch (state) + { + case State.Idle: + { + float score = model.Predict(frame); + var now = sw.Elapsed; + if (score >= Threshold && (now - lastTrigger).TotalSeconds >= CooldownSeconds) + { + Console.WriteLine($"DETECTED alexa score={score:0.000} t={now.TotalSeconds:0.0}s"); + lastTrigger = now; + try + { + FireAndForgetBeep(device, beepBuffer); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[error] beep stream failed to start: {ex.Message}"); + // Stay in IDLE — no cycle to abort yet. + break; + } + Console.WriteLine("[state] BEEPING"); + state = State.Beeping; + beepFrames = 0; + } + break; + } + + case State.Beeping: + { + beepFrames++; + if (beepFrames >= BeepingFrames) + { + Console.WriteLine("[state] RECORDING"); + state = State.Recording; + recordBuf = new short[RecordBufSamples]; + recordOffset = 0; + } + break; + } + + case State.Recording: + { + unsafe + { + fixed (short* src = &frame[0]) + fixed (short* dst = &recordBuf[recordOffset]) + Buffer.MemoryCopy(src, dst, BlockFrames * sizeof(short), BlockFrames * sizeof(short)); + } + recordOffset += (int)BlockFrames; + if (recordOffset >= RecordBufSamples) + { + try + { + FireAndForgetPlayback(device, recordBuf, playbackDone); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[error] playback stream failed to start: {ex.Message}"); + // Drop the recording, reset model, return to IDLE. + model.Reset(); + Console.WriteLine("[state] IDLE"); + state = State.Idle; + break; + } + Console.WriteLine("[state] PLAYBACK"); + state = State.Playback; + } + break; + } + + case State.Playback: + { + // Discard input during playback (gate the detector). + if (playbackDone.Consume()) + { + model.Reset(); + Console.WriteLine("[state] IDLE"); + state = State.Idle; + } + break; + } + } + } + } + catch (OperationCanceledException) { /* Ctrl-C path */ } + + stream.Stop(); + Console.WriteLine("\nbye"); +} +finally +{ + model?.Dispose(); + PortAudio.Terminate(); +} + +static int FindUsbDevice() +{ + int count = PortAudio.DeviceCount; + for (int i = 0; i < count; i++) + { + var info = PortAudio.GetDeviceInfo(i); + if (info.name.ToLowerInvariant().Contains("usb") && info.maxInputChannels >= 1) + return i; + } + Console.Error.WriteLine("USB audio device not found. Devices:"); + for (int i = 0; i < count; i++) + { + var info = PortAudio.GetDeviceInfo(i); + Console.Error.WriteLine( + $" [{i}] {info.name} in={info.maxInputChannels} out={info.maxOutputChannels}"); + } + Environment.Exit(1); + return -1; // unreachable +} + +static short[] MakeBeep(double freqHz, double durationS, int sampleRate) +{ + int n = (int)(sampleRate * durationS); + var buf = new short[n]; + for (int i = 0; i < n; i++) + { + double t = i / (double)sampleRate; + double v = 0.3 * Math.Sin(2.0 * Math.PI * freqHz * t); + buf[i] = (short)(v * short.MaxValue); + } + return buf; +} + +static void FireAndForgetBeep(int device, short[] beepBuffer) +{ + int offset = 0; + var done = new ManualResetEventSlim(false); + + var outParams = new StreamParameters + { + device = device, + channelCount = 1, + sampleFormat = SampleFormat.Int16, + suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowOutputLatency, + hostApiSpecificStreamInfo = IntPtr.Zero, + }; + + Stream.Callback playCb = (IntPtr _, IntPtr output, uint frameCount, + ref StreamCallbackTimeInfo _2, StreamCallbackFlags _3, IntPtr _4) => + { + int remaining = beepBuffer.Length - offset; + int take = (int)Math.Min(frameCount, (uint)remaining); + unsafe + { + short* dst = (short*)output.ToPointer(); + if (take > 0) + { + fixed (short* src = &beepBuffer[offset]) + Buffer.MemoryCopy(src, dst, take * sizeof(short), take * sizeof(short)); + offset += take; + } + for (int i = take; i < frameCount; i++) dst[i] = 0; + } + if (offset >= beepBuffer.Length) + { + done.Set(); + return StreamCallbackResult.Complete; + } + return StreamCallbackResult.Continue; + }; + + var stream = new Stream( + null, outParams, WakewordModel.SampleRate, 1024, StreamFlags.NoFlag, playCb, IntPtr.Zero); + try + { + stream.Start(); + } + catch + { + stream.Dispose(); + done.Dispose(); + throw; + } + Task.Run(() => + { + // 2 s timeout so a failed callback (which PortAudio silently swallows + // and would leave 'done' unset) eventually releases the stream + handle + // instead of leaking them for the process lifetime. + done.Wait(TimeSpan.FromSeconds(2)); + Thread.Sleep(200); + stream.Stop(); + stream.Dispose(); + done.Dispose(); + }); +} + +static void FireAndForgetPlayback(int device, short[] recordBuf, PlaybackDoneFlag doneFlag) +{ + int offset = 0; + var done = new ManualResetEventSlim(false); + + var outParams = new StreamParameters + { + device = device, + channelCount = 1, + sampleFormat = SampleFormat.Int16, + suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowOutputLatency, + hostApiSpecificStreamInfo = IntPtr.Zero, + }; + + Stream.Callback playCb = (IntPtr _, IntPtr output, uint frameCount, + ref StreamCallbackTimeInfo _2, StreamCallbackFlags _3, IntPtr _4) => + { + int remaining = recordBuf.Length - offset; + int take = (int)Math.Min(frameCount, (uint)remaining); + unsafe + { + short* dst = (short*)output.ToPointer(); + if (take > 0) + { + fixed (short* src = &recordBuf[offset]) + Buffer.MemoryCopy(src, dst, take * sizeof(short), take * sizeof(short)); + offset += take; + } + for (int i = take; i < frameCount; i++) dst[i] = 0; + } + if (offset >= recordBuf.Length) + { + done.Set(); + return StreamCallbackResult.Complete; + } + return StreamCallbackResult.Continue; + }; + + var stream = new Stream( + null, outParams, WakewordModel.SampleRate, 1024, StreamFlags.NoFlag, playCb, IntPtr.Zero); + try + { + stream.Start(); + } + catch + { + stream.Dispose(); + done.Dispose(); + throw; + } + Task.Run(() => + { + // Same 2 s safety timeout as the beep cleanup. + done.Wait(TimeSpan.FromSeconds(2)); + Thread.Sleep(200); + stream.Stop(); + stream.Dispose(); + done.Dispose(); + doneFlag.Set(); // tell the consumer it's safe to flip PLAYBACK→IDLE + }); +} + +enum State { Idle, Beeping, Recording, Playback } + +// Volatile flag set by the playback cleanup task and consumed by the consumer thread. +// Wrapped in a tiny class so we can pass it by reference into the helper. +sealed class PlaybackDoneFlag +{ + private volatile bool _set; + public void Set() => _set = true; + public bool Consume() + { + if (!_set) return false; + _set = false; + return true; + } +} + +static class Libc +{ + [DllImport("libc", EntryPoint = "setenv")] + public static extern int setenv(string name, string value, int overwrite); +} +``` + +- [ ] **Step 2: Verify the project builds cleanly** + +```bash +dotnet build tests/03-full-cycle-cs -c Release +``` + +Expected: `Build succeeded` with 0 errors, 0 warnings. + +If `` is missing from the csproj, the build will fail with `CS0227: Unsafe code may only appear if compiling with /unsafe`. Confirm Task 1's csproj has `true`. + +If you see `CS0246: The type or namespace 'Stream' could not be found` or a clash with `System.IO.Stream`, confirm the `using Stream = PortAudioSharp.Stream;` line is at the top of `Program.cs`. + +- [ ] **Step 3: Verify `dotnet publish` produces a runnable binary** + +```bash +dotnet publish tests/03-full-cycle-cs -c Release -r linux-arm64 --self-contained -o /tmp/probe-cs-3-out +ls /tmp/probe-cs-3-out/Probe +ls /tmp/probe-cs-3-out/models/ +``` + +Expected: +- `Probe` (executable) exists. +- `models/` directory contains `melspectrogram.onnx`, `embedding_model.onnx`, `alexa.onnx`. + +- [ ] **Step 4: Commit** + +```bash +git add tests/03-full-cycle-cs/Program.cs +git commit -m "Test 3: full-cycle state machine (IDLE/BEEPING/RECORDING/PLAYBACK)" +``` + +--- + +### Task 4: Write `bin/probe-cs-3` deploy script + +**Files:** +- Create: `bin/probe-cs-3` + +- [ ] **Step 1: Write the deploy script** + +Create `bin/probe-cs-3` with exactly this content (it's `bin/probe-cs-2` with three string swaps: `02-wakeword-cs` → `03-full-cycle-cs`, `probe-cs-2` → `probe-cs-3`, `/tmp/probe-cs-2-out` → `/tmp/probe-cs-3-out`): + +```bash +#!/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-3-out}" + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +echo ">> dotnet publish (linux-arm64, self-contained)" +dotnet publish tests/03-full-cycle-cs \ + -c Release -r linux-arm64 --self-contained \ + -o "$PUBLISH_DIR" + +echo ">> scp to $PI_USER@$PI_HOST:~/probe-cs-3/ (wipe-and-replace)" +sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=accept-new \ + "$PI_USER@$PI_HOST" 'rm -rf ~/probe-cs-3 && mkdir ~/probe-cs-3' +sshpass -p "$PI_PASS" scp -r "$PUBLISH_DIR/." \ + "$PI_USER@$PI_HOST:~/probe-cs-3/" + +echo ">> ssh + run on Pi (Ctrl-C from this terminal stops the probe)" +sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=accept-new \ + "$PI_USER@$PI_HOST" 'cd ~/probe-cs-3 && chmod +x Probe && ./Probe' +``` + +- [ ] **Step 2: Make it executable** + +```bash +chmod +x bin/probe-cs-3 +``` + +- [ ] **Step 3: Commit** + +```bash +git add bin/probe-cs-3 +git commit -m "Test 3: bin/probe-cs-3 deploy script" +``` + +--- + +### Task 5: Hardware verification on the Pi + +This task is the actual pass/fail gate. No automated tests; verification is the human ear + console output + `top` reading on the Pi. If anything in this task fails, do NOT mark the implementation complete — investigate and either fix in code or update the spec to reflect what actually works. + +**Files:** (none — this is a runtime verification task) + +- [ ] **Step 1: Confirm the Pi is reachable** + +Run from workstation: + +```bash +ping -c 2 192.168.50.115 +``` + +Expected: 2/2 replies. If unreachable, stop and ask the user. + +- [ ] **Step 2: Deploy + run** + +Run from workstation: + +```bash +./bin/probe-cs-3 +``` + +Expected console output (abridged): + +``` +>> dotnet publish (linux-arm64, self-contained) +... (build output) ... +>> scp to pi@192.168.50.115:~/probe-cs-3/ (wipe-and-replace) +>> ssh + run on Pi (Ctrl-C from this terminal stops the probe) +[wakeword-model] melspectrogram.onnx + input ... +[wakeword-model] embedding_model.onnx ... +[wakeword-model] alexa.onnx ... +Loaded in 0.6s. +Using device ('USB Speaker Phone...') +Listening. Say 'alexa'. Ctrl-C to exit. +``` + +If the publish step errors with `setenv: command not found` or similar libc-related runtime crash, confirm Task 3's `Libc.setenv` `[DllImport]` is present and the binary was self-contained-published. + +If the Pi run errors with `Cannot open shared library libonnxruntime.so` or `libportaudio.so`, the self-contained publish didn't bundle the native libs — confirm `true` and `linux-arm64` in Task 1's csproj. + +- [ ] **Step 3: Run 3 full cycles back-to-back** + +Say "alexa" clearly at normal volume from ~1 m. Observe: + +1. Beep plays within ~100 ms of saying "alexa". +2. Console prints `DETECTED alexa score=…`, then `[state] BEEPING`, then `[state] RECORDING`. +3. Say something distinctive (e.g. "hello world, this is cycle one") within the 5 s recording window. +4. Console prints `[state] PLAYBACK`. +5. Speaker plays back what you said. It should be recognisable. +6. Console prints `[state] IDLE`. +7. Pause ~1.5 s, then say "alexa" again. Verify the cycle starts over. + +Repeat for cycle 2 and cycle 3. Record on paper (or transcript) which cycles succeeded. + +Pass criterion 1: 3 full cycles back-to-back; each playback recognisable as what was spoken. + +- [ ] **Step 4: Measure idle CPU on the Pi** + +In a second workstation terminal: + +```bash +sshpass -p 'assistant' ssh pi@192.168.50.115 'top -b -n 3 -d 1 -p $(pgrep -f Probe)' +``` + +Expected: `Probe` process appears with `%CPU` < 50 across the three samples (Test 2 measured 17–31%, Test 3 should be in the same range during IDLE). + +Pass criterion 4: CPU < 50% of one core during IDLE. + +- [ ] **Step 5: Test post-playback re-detection latency** + +Speak "alexa" immediately after each `[state] IDLE` log line. Note how soon detection fires. + +Pass criterion 2: Next "alexa" after playback detected within ~2 s of playback ending. (Below ~1.3 s is impossible due to WarmupFrames; above ~2 s indicates a regression.) + +- [ ] **Step 6: Scan for warnings** + +Re-read the console output from Step 3. Confirm there are **no** lines of the form: + +- `[status] input overflow` +- `[status] consumer behind, dropping frame` +- `[status] unexpected callback frameCount=...` +- `[error] beep stream failed to start: ...` +- `[error] playback stream failed to start: ...` + +A single occurrence under abnormal load may be tolerable; sustained occurrences during normal cycles fail criterion 5. + +- [ ] **Step 7: Test Ctrl-C clean exit** + +Press Ctrl-C in the workstation terminal that's running the probe. + +Expected: + +``` +^C +bye +$ +``` + +Pass criterion 7: process exits 0, no stack trace, terminal returns to prompt. Verify with `echo $?` → `0`. + +- [ ] **Step 8: Wakeword detection rate during IDLE** + +Restart the probe (`./bin/probe-cs-3`). Wait until `Listening. Say 'alexa'. Ctrl-C to exit.` appears. Say "alexa" 10 times with a ~2 s gap between each. Count how many times `DETECTED alexa score=…` appears. + +Pass criterion 3: ≥ 8/10. (Test 2 demonstrated this rate; Test 3 should match.) + +- [ ] **Step 9: Update `findings.md` with the outcome** + +Add a new section `## Test 3 full-cycle outcome (2026-06-12)` at the bottom of `findings.md` summarising: + +- Pass / fail per criterion. +- Any new gotchas discovered during implementation or hardware verification (especially any that contradicted the spec's predictions). +- CPU measurement (peak + typical), detection latency post-playback, observed pass rate. +- Reference paths (spec + plan + code + deploy script). + +Use the existing Test 1 and Test 2 outcome sections in `findings.md` as the template. + +- [ ] **Step 10: Commit the findings update** + +```bash +git add findings.md +git commit -m "Findings: Test 3 full-cycle probe outcome" +``` + +- [ ] **Step 11: Final status report** + +Report to the user: + +- Pass/fail per criterion (1 through 7 from spec). +- Any criteria that failed and what was discovered. +- A one-line summary of whether Test 3 unblocks the main Pi-client implementation. + +--- + +## Self-review notes + +- **Spec coverage:** every section of the spec maps to a task — Architecture/State machine/Components → Tasks 1–4. Why-Reset-matters → Task 2's `Reset()` + Task 3's PLAYBACK→IDLE branch. Error handling → Task 3's try/catch on the FireAndForget calls + `[status]` / `[error]` log lines. Gotchas-carried-forward → preserved verbatim in Task 3's code. Verification → Task 5. +- **Placeholder scan:** no TBD/TODO; every code step shows the actual code; every command shows expected output. +- **Type consistency:** `State` enum (`Idle`, `Beeping`, `Recording`, `Playback`) used consistently; `PlaybackDoneFlag.Set()` / `.Consume()` used consistently between `FireAndForgetPlayback` and the consumer's PLAYBACK branch; `WakewordModel.Reset()` called in two places (Recording-failure recovery and Playback→Idle), signature is parameterless in both.