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.
This commit is contained in:
@@ -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
|
||||||
|
<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**
|
||||||
|
|
||||||
|
```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 `<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**
|
||||||
|
|
||||||
|
```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<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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```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 <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:
|
||||||
|
|
||||||
|
```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.
|
||||||
Reference in New Issue
Block a user