# Test 3 — full-cycle C# probe (wakeword + record + playback)
Combine the C# Test 1 (record + play) and Test 2 (wakeword + beep) into a single state machine that exercises the full assistant conversation cycle on the Pi:
> Idle, listen for "alexa" → beep → record 5 s → play the recording back through the same USB Speaker Phone → idle, listen for "alexa" again. Repeat indefinitely until Ctrl-C.
This is the last throwaway probe before the main Pi-client code starts. Its job is to surface anything the prior two probes couldn't, in particular **whether the wakeword detector and the recording/playback path coexist correctly on one USB device with one persistent input stream**.
Read `findings.md` (§ "C# probe outcome", § "C# wakeword probe outcome") before reading this. Everything below assumes those two outcomes as ground truth.
## Goal
A single-binary C# probe that:
1. Opens one persistent PortAudio input stream and one `WakewordModel` for the lifetime of the process.
2. Runs a four-state machine on each incoming 80 ms audio frame: **IDLE → BEEPING → RECORDING → PLAYBACK → IDLE**.
3. Plays a 200 ms beep on detection (fire-and-forget output stream).
4. Records 5 s of audio into an in-memory `short[]` buffer.
5. Plays that buffer back through the same USB device (fire-and-forget output stream).
6. Resets the wakeword model on every IDLE re-entry so the next "alexa" is detected quickly (target ≤ ~2 s after playback ends; see "Why detector gating works"), not garbage from stale ring buffers.
7. Exits cleanly on Ctrl-C from the workstation (SIGINT propagated via `ssh -t`).
## Non-goals
- No automated tests. Verification is hardware-only (run on Pi, judge by ear + console output).
- No software AEC. The detector is gated during PLAYBACK (state-machine discards frames); that is sufficient to prevent the playback re-triggering the wakeword. The Anhui LISTENAI USB device may have hardware AEC; not in scope to probe.
- No custom wakeword. Stock "alexa" classifier carried forward from Test 2.
- No WAV output. Test 1 wrote `out.wav`; Test 3 keeps the recording in memory. If a future debugging session needs raw captures, add an opt-in flag then.
- No `WakewordModel` extraction into a shared library. The file is copied verbatim from Test 2 into Test 3, and `Reset()` is added only to the Test 3 copy. Test 2's copy is frozen.
- No barge-in (user interrupting playback by speaking). The detector is fully gated during PLAYBACK; there is no way for the user to retrigger mid-playback.
## Architecture
```
┌──────────────────────────────────────────┐
│ PortAudio input stream (persistent) │
│ 16 kHz mono Int16, 1280 frames/cb │
│ callback memcpy → BlockingCollection │
└────────────────┬─────────────────────────┘
│ short[1280] frames
▼
┌──────────────────────────────────────────┐
│ Consumer thread │
│ while (!cts.IsCancellationRequested) { │
│ frame = queue.Take(cts.Token); │
│ switch (_state) { │
│ case IDLE → model.Predict(...) │
│ case BEEPING → discard + count │
│ case RECORDING→ memcpy → recordBuf │
│ case PLAYBACK → discard │
│ } │
│ } │
└────────────────┬─────────────────────────┘
│ on demand
┌──────────────┴──────────────┐
▼ ▼
FireAndForgetBeep FireAndForgetPlayback
(200 ms 880 Hz tone) (5.04 s of recordBuf)
Output stream pattern from Test 2 (Start under
try/catch BEFORE Task.Run + 2 s timeout on done.Wait)
```
One process. One persistent input stream. Per-cycle output streams spawned fire-and-forget. State machine lives entirely in the consumer thread, which is the single writer to `_state`, so `_state` only needs `volatile` — no locks.
## State machine
Frame size is 1280 samples = 80 ms at 16 kHz. All counters below are in frames.
| State | Per-frame action | Transition |
|-|-|-|
| **IDLE** | `score = model.Predict(frame)` | `score ≥ 0.5` AND cooldown elapsed → log `DETECTED`, call `FireAndForgetBeep(...)`, set `beepFrames = 0`, → **BEEPING** |
| **BEEPING** | discard frame; `beepFrames++` | after **3 frames** (240 ms) → allocate `recordBuf = new short[63 * 1280]`, `recordOffset = 0`, → **RECORDING** |
| **RECORDING** | `Buffer.MemoryCopy` frame into `recordBuf` at `recordOffset`; `recordOffset += 1280` | after **63 frames** (5.04 s, full buffer) → call `FireAndForgetPlayback(recordBuf, …)`, → **PLAYBACK** |
| **PLAYBACK** | discard frame; check `volatile bool _playbackDoneFlag` | `_playbackDoneFlag == true` → reset flag, call `model.Reset()`, → **IDLE** |
Rationale for the constants:
- **BEEPING = 3 frames (240 ms).** Beep duration is 200 ms; 3 × 80 ms = 240 ms is the next frame boundary at-or-above 200 ms, leaving a 40 ms margin to absorb device buffering, speaker reverb, and PortAudio's output latency so the recording doesn't open with the tail of the beep. If verification shows audible beep tail still leaking into the recording, raise to 4 frames (320 ms) — easy tweak.
- **RECORDING = 63 frames (5.04 s).** 5 s = 62.5 frames; rounding up to 63 avoids partial-frame handling. The extra 40 ms of recording is inaudibly different from 5 s.
- **Cooldown** applies only to the IDLE→BEEPING transition. The state machine itself blocks re-detection in the other states. 1 s cooldown carries forward from Test 2.
`_playbackDoneFlag` is set by the playback output stream's audio callback when its read offset reaches the end of `recordBuf`. The consumer checks the flag on every PLAYBACK frame (and only on PLAYBACK frames). Between the flag firing and the consumer seeing it, the consumer continues to drain input frames into the discard branch — that's what keeps the input stream from backing up.
## Why detector gating works (and why Reset matters)
The USB Speaker Phone is the same physical device for input and output. During PLAYBACK, the recorded audio comes out the speaker and re-enters the input stream. If `WakewordModel.Predict` were running across all states, any "alexa" buried in the recording would re-trigger detection, kicking the state machine back into BEEPING mid-playback — an infinite loop.
The state-machine gating (PLAYBACK discards every frame, doesn't call `Predict`) prevents the re-trigger directly. That much is straightforward.
The subtler issue is what happens to `WakewordModel`'s internal ring buffers (`_rawRing`, `_melRing`, `_embRing`) during BEEPING + RECORDING + PLAYBACK. With ~10 s of skipped `Predict` calls (240 ms BEEPING + 5.04 s RECORDING + ~5 s PLAYBACK), the rings hold audio from ~10 s ago — pre-detection. When we return to IDLE and resume `Predict`, the next call computes mel features from a window that straddles 10-second-old audio at the bottom and fresh post-playback audio at the top. The first one or two predictions on re-entry would be on this garbage window.
The `WarmupFrames = 16` guard already in `WakewordModel.cs` exists to suppress exactly this kind of cold-start junk on initial process boot. It does NOT re-arm after a pause. So without intervention, the first ~1.3 s after returning to IDLE would be a false-positive vulnerability window.
**Fix:** Add a `WakewordModel.Reset()` method that clears `_rawRingFill = 0`, `_melRing.Clear()`, `_embRing.Clear()`, `_framesSeen = 0`. The consumer calls it on the PLAYBACK→IDLE transition. The existing `WarmupFrames` guard then suppresses the next 16 `Predict` calls (~1.3 s of audio) until the rings refill with fresh post-playback audio. After that, detection runs as it did at startup.
This means the next "alexa" after playback can only be detected on a frame at least 1.3 s after the IDLE re-entry, plus whatever time the user takes to start saying "alexa". The verification budget for "next detection within ~1 s of playback" needs to acknowledge this: the practical lower bound is ~1.3 s (warmup) + ~0.5 s (user reaction + saying the word). See "Pass criteria" below for the adjusted threshold.
## Components and files
```
tests/03-full-cycle-cs/
├── Probe.csproj
│ net9.0
│ linux-arm64
│ true
│ true
│ true
│ PackageReference PortAudioSharp2 1.0.6
│ PackageReference Microsoft.ML.OnnxRuntime 1.26.0
│ Content Include="models/*.onnx" CopyToOutputDirectory=PreserveNewest
│ RootNamespace=FullCycleProbe, AssemblyName=Probe
│
├── Program.cs ~200 lines, top-level statements:
│ 1. Environment.SetEnvironmentVariable + Libc.setenv for
│ PA_ALSA_PLUGHW=1 and ORT_LOGGING_LEVEL=3
│ 2. OrtEnv.CreateInstanceWithOptions with
│ logLevel = ORT_LOGGING_LEVEL_ERROR (BEFORE any InferenceSession)
│ 3. PortAudio.Initialize, FindUsbDevice (lifted from Test 2)
│ 4. WakewordModel ctor with models/{melspectrogram,embedding_model,alexa}.onnx
│ 5. BlockingCollection queue (bounded 16)
│ 6. CancellationTokenSource + Console.CancelKeyPress handler
│ 7. Input StreamParameters + callback (memcpy → queue.TryAdd)
│ 8. Stream construction + Start
│ 9. Consumer loop with state-machine switch (sketched above)
│ 10. finally: stream.Stop, model.Dispose, PortAudio.Terminate
│ Helpers: FireAndForgetBeep (from Test 2), FireAndForgetPlayback (new,
│ same shape but reads recordBuf and sets _playbackDoneFlag),
│ MakeBeep, FindUsbDevice, static class Libc.setenv
│
├── WakewordModel.cs Copied verbatim from tests/02-wakeword-cs/.
│ Add Reset() method (≈5 lines):
│ public void Reset() {
│ _rawRingFill = 0;
│ _melRing.Clear();
│ _embRing.Clear();
│ _framesSeen = 0;
│ }
│ Namespace changes from WakewordProbe → FullCycleProbe.
│
└── models/ Copied from tests/02-wakeword-cs/models/.
melspectrogram.onnx, embedding_model.onnx, alexa.onnx
SHA-256 unchanged from the vendored set in commit 849ad47.
bin/probe-cs-3 Copy of bin/probe-cs-2 with two string swaps:
tests/02-wakeword-cs → tests/03-full-cycle-cs
~/probe-cs-2 → ~/probe-cs-3
PUBLISH_DIR /tmp/probe-cs-2-out → /tmp/probe-cs-3-out
ssh -t is retained (Ctrl-C propagation).
chmod +x at install time.
```
## Concurrency model
Three threads of execution in steady state:
1. **PortAudio input audio thread** (managed by the library). Runs the input callback every 80 ms. Allocates a `short[1280]`, memcpy's the input buffer, calls `queue.TryAdd(frame, 0)`. On `TryAdd` failure (queue full), logs `[status] consumer behind, dropping frame`. On `InputOverflow` status flag, logs `[status] input overflow`. Never blocks.
2. **Consumer thread** (`Task.Run` or just main thread after `stream.Start`). The state-machine loop above. Single writer of `_state`. Calls `model.Predict` in IDLE; calls `model.Reset` on PLAYBACK→IDLE.
3. **PortAudio output audio threads** (one per fire-and-forget beep/playback). Each output stream has its own callback that writes from a managed buffer (beep buffer or recordBuf). Sets `done.Set()` (beep) or `_playbackDoneFlag = true` (playback) when its source buffer is fully consumed.
Shared mutable state:
- `volatile State _state` — single writer (consumer), multiple readers (consumer only, actually). Volatile is enough.
- `volatile bool _playbackDoneFlag` — set by playback audio thread, read+reset by consumer.
- `BlockingCollection queue` — its own concurrency.
No locks. No `Interlocked`. The state machine is intentionally a single-writer design so we don't have to reason about race conditions.
## Error handling
- **Input overflow** (PortAudio callback status flag): log `[status] input overflow`, continue. No corrective action — the buffer dropped audio at the kernel level, nothing the consumer can do.
- **Consumer-behind** (`queue.TryAdd` returns false because the bounded queue is full): log `[status] consumer behind, dropping frame`, continue. In steady state this should never fire; if it does during verification it's a signal that one of the state branches is too slow (likely `Predict` exceeding 80 ms, which Test 2 confirmed doesn't happen on the Pi).
- **Output stream `Start()` throws**: dispose the stream, log `[error] output stream failed to start: `, snap `_state` back to IDLE, call `model.Reset()`. The current cycle is lost; the probe survives. (Test 2 surfaced this as the exact failure mode the fire-and-forget pattern guards against.)
- **`WakewordModel` exceptions** during `Predict` or `Reset`: let propagate. The `finally` block disposes the model and terminates PortAudio. Probe exits non-zero. This is fail-fast on the assumption that any model exception is an invariant violation worth investigating.
- **Ctrl-C from workstation**: `Console.CancelKeyPress` calls `cts.Cancel()`. The consumer's `queue.Take(cts.Token)` throws `OperationCanceledException`, the outer `try` swallows it, the `finally` block runs `stream.Stop` + `model.Dispose` + `PortAudio.Terminate`. Process exits 0. `ssh -t` in `bin/probe-cs-3` ensures the SIGINT reaches the remote process.
## Gotchas carried forward (from findings.md)
1. **Libc.setenv for env vars libc reads via getenv().** `Environment.SetEnvironmentVariable` does NOT propagate to libc on Linux. Apply `Libc.setenv` for both `PA_ALSA_PLUGHW` (PortAudio) and `ORT_LOGGING_LEVEL` (onnxruntime) alongside the managed call.
2. **OrtEnv.CreateInstanceWithOptions BEFORE first InferenceSession.** ORT 1.26.0's GPU-detection logger fires from C++ before managed code runs, so `Libc.setenv("ORT_LOGGING_LEVEL", ...)` alone doesn't suppress the `/sys/class/drm/card0` warnings. Construct `OrtEnv` with `logLevel = ORT_LOGGING_LEVEL_ERROR` *before* `WakewordModel`'s constructor runs.
3. **`using Stream = PortAudioSharp.Stream;`** at the top of `Program.cs` to disambiguate from `System.IO.Stream` that `ImplicitUsings` pulls in.
4. **`true`** in `Probe.csproj` for the callback's `Buffer.MemoryCopy` over `short*`.
5. **Fire-and-forget output stream lifetime.** `stream.Start()` runs synchronously under try/catch *before* `Task.Run` launches; on `Start()` failure, dispose + rethrow without ever creating the cleanup task. The cleanup task waits on `done.Wait(TimeSpan.FromSeconds(2))` — 2 s timeout so a faulting callback that silently fails (PortAudio swallows callback exceptions) can't hang cleanup. Applied to both `FireAndForgetBeep` AND `FireAndForgetPlayback`.
6. **Vendored alexa classifier has static batch dim `[1, 16, 96]`.** `WakewordModel`'s shape assertion intentionally skips dim 0 so both `1` (static) and `-1` (dynamic) pass. Preserve.
7. **Mel-spectrogram output shape `(1, 1, n_frames, 32)`.** `n_frames` lives at `Dimensions[2]`, not `[1]`. Preserve in the copied `WakewordModel.cs`.
8. **`ssh -t` for Ctrl-C propagation.** `bin/probe-cs-3` forces a pseudo-TTY so workstation SIGINT reaches the remote `./Probe` and triggers `Console.CancelKeyPress`.
## Verification (hardware only, no automated tests)
Run from workstation:
```
./bin/probe-cs-3
```
Expected console flow (abridged):
```
[wakeword-model] melspectrogram.onnx
input 'input': shape=[1,-1] dtype=Single
output 'output': shape=[1,1,-1,32] dtype=Single
[wakeword-model] embedding_model.onnx
...
[wakeword-model] alexa.onnx
input 'onnx::Unsqueeze_0': shape=[1,16,96] dtype=Single
output ...
Loaded in 0.6s.
Using device 3 ('USB Speaker Phone (LISTENAI ...)')
Listening. Say 'alexa'. Ctrl-C to exit.
DETECTED alexa score=0.872 t=4.3s
[state] BEEPING
[state] RECORDING
[state] PLAYBACK
[state] IDLE
DETECTED alexa score=0.811 t=15.2s
...
```
(Exact `[wakeword-model]` shape output reflects whatever the ONNX models actually report; the example above is illustrative.)
### Pass criteria
All must hold during a single run on the Pi:
1. **3 full cycles back-to-back** without restarting the probe. Each playback is clearly recognisable as what was spoken into the mic in the preceding 5 s.
2. **Next "alexa" after playback** is detected within ~2 s of playback ending. (The ~1.3 s `WarmupFrames` re-arm after `model.Reset()` is a hard floor; budget another ~0.5 s for user reaction + saying the word. If detection consistently takes longer than ~2 s, that's a regression vs. Test 2's IDLE-state detection latency and worth investigating.)
3. **Wakeword detection rate during IDLE** ≥ 8/10 attempts (carries forward from Test 2's measured rate).
4. **CPU during IDLE steady state** < 50% of one core, measured by `top` on the Pi. (Test 2 measured 17–31% on the same workload; Test 3 adds only a state-machine switch — no extra CPU in IDLE.)
5. **No `[status] input overflow` or `[status] consumer behind` warnings** during any phase of normal operation.
6. **No stuck states.** The probe never sits in BEEPING, RECORDING, or PLAYBACK for longer than its bounded duration. (BEEPING ≤ 240 ms, RECORDING ≤ 5.04 s, PLAYBACK ≤ ~5.5 s including device drain.)
7. **Workstation Ctrl-C** triggers a clean exit: `\nbye` line, process exit 0, no stack trace, terminal returns to prompt.
### What success teaches us for the main assistant
- The "persistent input stream + state-machine consumer + per-cycle output streams" shape works on this hardware. The main assistant can build directly on it.
- Detector gating during playback (without AEC) is sufficient to prevent self-trigger, at least with stock "alexa" and the recorded user audio. Real TTS may need re-evaluation; this probe doesn't answer that.
- `WakewordModel.Reset()` belongs in the production model interface — the main assistant will need it for the same reasons.
### What failure would tell us
- If detection rate drops below 8/10 in IDLE: the state-machine overhead is interfering. Investigate (unlikely — the switch is O(constant)).
- If `[status] consumer behind` warnings appear: one of the consumer branches is too slow. Most likely `Predict` exceeded 80 ms (Test 2 didn't measure this directly). Add per-`Predict` timing and re-run.
- If playback re-triggers detection (probe loops in BEEPING/RECORDING/PLAYBACK indefinitely): `Reset` is wrong, or `_playbackDoneFlag` plumbing has a race. Investigate via logging which state the trigger occurred in.
- If `Ctrl-C` produces a stack trace: `OperationCanceledException` isn't being caught at the right level. Fix.
## Reference paths
- Spec (this doc): `docs/superpowers/specs/2026-06-12-test-3-full-cycle-csharp-design.md`
- Plan (next): `docs/superpowers/plans/2026-06-12-test-3-full-cycle-csharp.md`
- Code (to be written): `tests/03-full-cycle-cs/`
- Deploy (to be written): `bin/probe-cs-3`
- Test 1 reference: `tests/01-record-play-cs/Program.cs`, `tests/01-record-play-cs/WavWriter.cs`, `bin/probe-cs`
- Test 2 reference: `tests/02-wakeword-cs/Program.cs`, `tests/02-wakeword-cs/WakewordModel.cs`, `bin/probe-cs-2`
- Pi access: `pi@192.168.50.115`, password `assistant` (see `CLAUDE.md`)
- Canonical findings: `findings.md` § "C# probe outcome (2026-06-12 — Test 1 ported to C#)" and § "C# wakeword probe outcome (2026-06-12 — Test 2 ported to C#)"