Files
Assistant/docs/superpowers/plans/2026-06-12-test-3-full-cycle-csharp.md
tes 14690484de Test 3 plan: implementation steps for full-cycle C# probe
Five tasks: scaffold csproj, copy WakewordModel + models with Reset(),
write Program.cs with the four-state machine, write bin/probe-cs-3
deploy script, and hardware verification on the Pi.
2026-06-12 13:19:18 +00:00

27 KiB
Raw Permalink Blame History

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

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):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <RootNamespace>FullCycleProbe</RootNamespace>
    <AssemblyName>Probe</AssemblyName>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RuntimeIdentifier>linux-arm64</RuntimeIdentifier>
    <SelfContained>true</SelfContained>
    <PublishSingleFile>false</PublishSingleFile>
    <InvariantGlobalization>true</InvariantGlobalization>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="PortAudioSharp2" Version="1.0.6" />
    <PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.26.0" />
  </ItemGroup>
  <ItemGroup>
    <Content Include="models/*.onnx">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
  </ItemGroup>
</Project>
  • Step 3: Commit the scaffold
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

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
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:

    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:

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 <OutputType>Exe</OutputType> 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
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:

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<short[]>(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
dotnet build tests/03-full-cycle-cs -c Release

Expected: Build succeeded with 0 errors, 0 warnings.

If <AllowUnsafeBlocks> 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 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>.

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
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

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-cs03-full-cycle-cs, probe-cs-2probe-cs-3, /tmp/probe-cs-2-out/tmp/probe-cs-3-out):

#!/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
chmod +x bin/probe-cs-3
  • Step 3: Commit
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:

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:

./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 <N> ('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 <SelfContained>true</SelfContained> and <RuntimeIdentifier>linux-arm64</RuntimeIdentifier> 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:

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 1731%, 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
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 14. 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.