diff --git a/docs/superpowers/plans/2026-06-12-record-play-csharp-probe.md b/docs/superpowers/plans/2026-06-12-record-play-csharp-probe.md new file mode 100644 index 0000000..23f857d --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-record-play-csharp-probe.md @@ -0,0 +1,564 @@ +# Record-play 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:** Re-implement Test 1 (record 5 s from the USB Speaker Phone → live RMS meter → write `out.wav` → play it back) in C# on the Raspberry Pi, proving .NET-driven PortAudio works on linux-arm64 with the same pass criteria the Python version met. + +**Architecture:** Single-folder throwaway probe under `tests/01-record-play-cs/`. One `Program.cs` driving PortAudio via the PortAudioSharp2 NuGet binding, plus a tiny hand-rolled `WavWriter.cs` for the PCM16 RIFF file. A new `bin/probe-cs` script builds it as a linux-arm64 self-contained binary, rsyncs to the Pi, and runs it over SSH. The Pi gets no .NET install. No automated tests — verification is the same listening test the Python probe uses (`findings.md`). + +**Tech Stack:** .NET 9, C#, PortAudioSharp2 (NuGet), bash, sshpass, rsync. Target host: Pi at `192.168.50.115`, user `pi`, password `assistant` (see `CLAUDE.md`). + +--- + +## File structure + +| Path | Created in task | Purpose | +| --- | --- | --- | +| `tests/01-record-play-cs/Probe.csproj` | Task 1 | net9.0 console project, linux-arm64 RID, PortAudioSharp2 reference | +| `tests/01-record-play-cs/Program.cs` | Task 1 (sanity spike), expanded in Tasks 4 & 5 | Single entry point; mirrors the Python probe's structure | +| `tests/01-record-play-cs/WavWriter.cs` | Task 3 | Static `Write(path, samples, sampleRate)` — 44-byte RIFF + raw PCM16 LE | +| `bin/probe-cs` | Task 2 | `dotnet publish` → `rsync` → `ssh` flow | + +`tests/01-record-play/` (Python) and the rest of the repo are untouched. + +--- + +## Task 1: Scaffold project + PortAudio sanity spike on the Pi + +**Goal:** Stand up the .NET project and prove on the Pi that `PortAudio.Initialize()` + device enumeration work end-to-end from a self-contained linux-arm64 publish. No audio I/O yet. This de-risks the unknown the spec called out: does PortAudioSharp2's NuGet ship a `libportaudio` for linux-arm64, or does it dlopen the system one? + +**Files:** +- Create: `tests/01-record-play-cs/Probe.csproj` +- Create: `tests/01-record-play-cs/Program.cs` + +- [ ] **Step 1: Find the latest stable PortAudioSharp2 version** + +Open `https://www.nuget.org/packages/PortAudioSharp2` in a browser (or `curl -s https://api.nuget.org/v3-flatcontainer/portaudiosharp2/index.json | jq -r '.versions[-1]'`). Note the latest non-prerelease version (call it `` below). Pin that exact version in the csproj — do **not** use a floating `*`. If `curl/jq` are not available, the NuGet web page lists "Latest version" at the top right. + +- [ ] **Step 2: Write `tests/01-record-play-cs/Probe.csproj`** + +```xml + + + Exe + net9.0 + RecordPlayProbe + Probe + enable + enable + linux-arm64 + true + false + true + + + + + +``` + +Replace `` with the version from Step 1. + +- [ ] **Step 3: Write `tests/01-record-play-cs/Program.cs` (spike version)** + +```csharp +using PortAudioSharp; + +Environment.SetEnvironmentVariable("PA_ALSA_PLUGHW", "1"); + +PortAudio.Initialize(); +try +{ + int count = PortAudio.DeviceCount; + Console.WriteLine($"PortAudio version: {PortAudio.VersionInfo.versionText}"); + Console.WriteLine($"Devices ({count}):"); + for (int i = 0; i < count; i++) + { + var info = PortAudio.GetDeviceInfo(i); + Console.WriteLine( + $" [{i}] {info.name} in={info.maxInputChannels} out={info.maxOutputChannels} " + + $"defaultSr={info.defaultSampleRate}"); + } +} +finally +{ + PortAudio.Terminate(); +} +``` + +**Note for the engineer:** The above uses the API surface as documented in PortAudioSharp2's README at time of writing (`PortAudio.Initialize/Terminate`, `PortAudio.DeviceCount`, `PortAudio.GetDeviceInfo(int)`, `PortAudio.VersionInfo`). If a compile error reveals the surface has changed, read PortAudioSharp2's README at the version you pinned in Step 1 and adjust the calls — the intent is "init, dump device table, terminate". Do **not** change to a different binding. + +- [ ] **Step 4: Build and publish locally** + +Run from the repo root: + +```sh +dotnet publish tests/01-record-play-cs -c Release -r linux-arm64 --self-contained \ + -o /tmp/probe-cs-out +``` + +Expected: build succeeds. Verify the output contains the native PortAudio binary: + +```sh +ls /tmp/probe-cs-out | grep -E 'libportaudio|Probe' +``` + +Expected: `Probe` (the binary) is present. `libportaudio.so*` may or may not be present — if absent, PortAudioSharp2 will dlopen the system `libportaudio2` already installed by `bin/bootstrap` on the Pi. + +- [ ] **Step 5: Push the spike to the Pi and run it** + +```sh +sshpass -p 'assistant' rsync -az --delete /tmp/probe-cs-out/ \ + pi@192.168.50.115:~/probe-cs/ +sshpass -p 'assistant' ssh pi@192.168.50.115 'cd ~/probe-cs && ./Probe' +``` + +Expected output: PortAudio version string + a device table that includes at least one entry whose name contains `USB` (the Anhui LISTENAI USB Speaker Phone — exposed as ALSA card 3 per `findings.md`). If you see `dlopen` failures for `libportaudio`, install it on the Pi: `sshpass -p 'assistant' ssh pi@192.168.50.115 'sudo apt-get install -y libportaudio2'` (the prototype `bin/bootstrap` already does this, but the Pi may have been wiped). + +- [ ] **Step 6: Commit** + +```sh +git add tests/01-record-play-cs/Probe.csproj tests/01-record-play-cs/Program.cs +git commit -m "Scaffold C# record-play probe + PortAudio device-list spike" +``` + +--- + +## Task 2: Wrap the deploy flow as `bin/probe-cs` + +**Goal:** Capture the Task 1 build/rsync/ssh sequence as a single script so subsequent tasks just run `bin/probe-cs`. + +**Files:** +- Create: `bin/probe-cs` + +- [ ] **Step 1: Write `bin/probe-cs`** + +```sh +#!/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-out}" + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +echo ">> dotnet publish (linux-arm64, self-contained)" +dotnet publish tests/01-record-play-cs \ + -c Release -r linux-arm64 --self-contained \ + -o "$PUBLISH_DIR" + +echo ">> rsync to $PI_USER@$PI_HOST:~/probe-cs/" +sshpass -p "$PI_PASS" rsync -az --delete \ + "$PUBLISH_DIR"/ "$PI_USER@$PI_HOST:~/probe-cs/" + +echo ">> ssh + run on Pi" +sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=accept-new \ + "$PI_USER@$PI_HOST" 'cd ~/probe-cs && ./Probe' +``` + +- [ ] **Step 2: Mark it executable** + +```sh +chmod +x bin/probe-cs +``` + +- [ ] **Step 3: Smoke-test the script against Task 1's spike** + +```sh +bin/probe-cs +``` + +Expected output: same device table as in Task 1 Step 5. If it errors, fix the script — do not work around it in later tasks. + +- [ ] **Step 4: Commit** + +```sh +git add bin/probe-cs +git commit -m "Add bin/probe-cs: build + rsync + run the C# probe on the Pi" +``` + +--- + +## Task 3: `WavWriter.cs` — hand-rolled PCM16 RIFF writer + +**Goal:** A static helper that writes a mono PCM16 WAV file. No external deps. + +**Files:** +- Create: `tests/01-record-play-cs/WavWriter.cs` + +- [ ] **Step 1: Write `tests/01-record-play-cs/WavWriter.cs`** + +```csharp +namespace RecordPlayProbe; + +internal static class WavWriter +{ + public static void Write(string path, ReadOnlySpan samples, int sampleRate) + { + const short channels = 1; + const short bitsPerSample = 16; + const short pcmFormat = 1; + + int byteRate = sampleRate * channels * bitsPerSample / 8; + short blockAlign = (short)(channels * bitsPerSample / 8); + int dataBytes = samples.Length * sizeof(short); + int riffChunkSize = 36 + dataBytes; + + using var fs = new FileStream(path, FileMode.Create, FileAccess.Write); + using var bw = new BinaryWriter(fs); + + // RIFF header + bw.Write(new[] { (byte)'R', (byte)'I', (byte)'F', (byte)'F' }); + bw.Write(riffChunkSize); + bw.Write(new[] { (byte)'W', (byte)'A', (byte)'V', (byte)'E' }); + + // fmt chunk + bw.Write(new[] { (byte)'f', (byte)'m', (byte)'t', (byte)' ' }); + bw.Write(16); // fmt chunk size for PCM + bw.Write(pcmFormat); + bw.Write(channels); + bw.Write(sampleRate); + bw.Write(byteRate); + bw.Write(blockAlign); + bw.Write(bitsPerSample); + + // data chunk + bw.Write(new[] { (byte)'d', (byte)'a', (byte)'t', (byte)'a' }); + bw.Write(dataBytes); + foreach (short s in samples) + bw.Write(s); + } +} +``` + +`BinaryWriter` writes integers little-endian on every supported platform — that matches the WAV spec. No byte-order conversion needed. + +- [ ] **Step 2: Build (no test step — spec says no automated tests)** + +```sh +dotnet build tests/01-record-play-cs -c Release +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```sh +git add tests/01-record-play-cs/WavWriter.cs +git commit -m "Add WavWriter: mono PCM16 RIFF writer for the C# probe" +``` + +--- + +## Task 4: Recording with live RMS meter + WAV write + +**Goal:** Replace the spike `Program.cs` with the real record + meter + write flow. No playback yet — we want to confirm recording independently of playback so a regression on either is unambiguous. + +**Files:** +- Modify: `tests/01-record-play-cs/Program.cs` (rewrite — the spike code is replaced) + +- [ ] **Step 1: Rewrite `tests/01-record-play-cs/Program.cs`** + +```csharp +using PortAudioSharp; + +const int SampleRate = 16_000; +const int Channels = 1; +const int DurationS = 5; +const uint BlockFrames = 1024; +const string OutWav = "out.wav"; + +Environment.SetEnvironmentVariable("PA_ALSA_PLUGHW", "1"); + +PortAudio.Initialize(); +try +{ + int device = FindUsbDevice(); + Console.WriteLine( + $"Recording {DurationS}s from device {device} " + + $"('{PortAudio.GetDeviceInfo(device).name}')"); + + short[] buf = new short[SampleRate * DurationS]; + int writeOffset = 0; + object gate = new(); + + 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"); + + lock (gate) + { + int remaining = buf.Length - writeOffset; + int take = (int)Math.Min(frameCount, (uint)remaining); + if (take > 0) + { + unsafe + { + short* src = (short*)input.ToPointer(); + fixed (short* dst = &buf[writeOffset]) + { + Buffer.MemoryCopy(src, dst, take * sizeof(short), take * sizeof(short)); + } + } + writeOffset += take; + } + } + return StreamCallbackResult.Continue; + }; + + using var stream = new Stream( + inParams, null, SampleRate, BlockFrames, StreamFlags.NoFlag, callback, IntPtr.Zero); + stream.Start(); + + var start = DateTime.UtcNow; + while (true) + { + int written; + lock (gate) { written = writeOffset; } + if (written >= buf.Length) break; + + double level = Rms(buf, Math.Max(0, written - 1600), written); + int bars = (int)(level * 60); + double elapsed = (DateTime.UtcNow - start).TotalSeconds; + Console.Write($"\r{elapsed,4:0.0}s |{new string('#', bars).PadRight(60)}|"); + Thread.Sleep(50); + } + Console.WriteLine(); + stream.Stop(); + + WavWriter.Write(OutWav, buf, SampleRate); + Console.WriteLine($"Wrote {OutWav} ({buf.Length} frames @ {SampleRate} Hz)"); +} +finally +{ + 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 double Rms(short[] data, int from, int to) +{ + if (to <= from) return 0.0; + double sumSq = 0.0; + for (int i = from; i < to; i++) + { + double x = data[i] / 32768.0; + sumSq += x * x; + } + return Math.Sqrt(sumSq / (to - from) + 1e-12); +} +``` + +**RMS formula matches the Python probe** (`np.sqrt((x*x).mean() + 1e-12)`). **Tail window matches** (last 1600 samples, ~100 ms at 16 kHz). **Meter cadence matches** (50 ms sleep → ~20 Hz refresh). + +The csproj also needs `true` for the `unsafe` block in the callback. Add it: + +- [ ] **Step 2: Enable unsafe blocks in `Probe.csproj`** + +Add `true` inside the existing ``. The full PropertyGroup should now read: + +```xml + + Exe + net9.0 + RecordPlayProbe + Probe + enable + enable + linux-arm64 + true + false + true + true + +``` + +- [ ] **Step 3: Build locally** + +```sh +dotnet build tests/01-record-play-cs -c Release +``` + +Expected: success. If you hit a `StreamParameters` / `Stream` / `Callback` API mismatch, see PortAudioSharp2's README at the version you pinned in Task 1 and adjust the type names — the structure (struct of device + channelCount + sampleFormat + latency; Stream constructor that takes input params, output params, sample rate, frames-per-buffer, flags, callback, user data) is the conventional PortAudio shape and should map closely. + +- [ ] **Step 4: Run on the Pi** + +```sh +bin/probe-cs +``` + +Expected on the Pi: +- A line `Recording 5s from device ('USB...')`. +- A live meter: `\r 0.0s |######...|` updating ~20×/sec for 5 seconds. The bar visibly responds to speech vs. silence. +- A final line `Wrote out.wav (80000 frames @ 16000 Hz)`. +- No sustained `[status] input overflow` messages (one at startup is acceptable). + +Confirm the WAV is well-formed: + +```sh +sshpass -p 'assistant' ssh pi@192.168.50.115 'soxi ~/probe-cs/out.wav' +``` + +Expected: `soxi` reports 1 channel, 16000 Hz, 16-bit PCM, ~5 seconds duration. (If `soxi` isn't installed: `sudo apt-get install -y sox` on the Pi, or use `file out.wav` which will at least say `RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 16000 Hz`.) + +- [ ] **Step 5: Commit** + +```sh +git add tests/01-record-play-cs/Probe.csproj tests/01-record-play-cs/Program.cs +git commit -m "Record loop: callback InputStream + RMS meter + WAV write" +``` + +--- + +## Task 5: Playback + full pass-criteria verification + +**Goal:** Add the playback step and verify all three pass criteria from the spec on real hardware. + +**Files:** +- Modify: `tests/01-record-play-cs/Program.cs` (append playback after the WAV write) + +- [ ] **Step 1: Add playback to `Program.cs`** + +Insert this block immediately after the `Console.WriteLine($"Wrote {OutWav}...");` line and before the `finally` block: + +```csharp + Console.WriteLine($"Playing back through device {device}"); + + int playOffset = 0; + object playGate = new(); + var doneEvent = new ManualResetEventSlim(false); + + var outParams = new StreamParameters + { + device = device, + channelCount = Channels, + 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) => + { + lock (playGate) + { + int remaining = buf.Length - playOffset; + int take = (int)Math.Min(frameCount, (uint)remaining); + int silence = (int)frameCount - take; + + unsafe + { + short* dst = (short*)output.ToPointer(); + if (take > 0) + { + fixed (short* src = &buf[playOffset]) + Buffer.MemoryCopy(src, dst, take * sizeof(short), take * sizeof(short)); + playOffset += take; + } + if (silence > 0) + { + for (int i = take; i < frameCount; i++) dst[i] = 0; + } + } + + if (playOffset >= buf.Length) + { + doneEvent.Set(); + return StreamCallbackResult.Complete; + } + } + return StreamCallbackResult.Continue; + }; + + using var playStream = new Stream( + null, outParams, SampleRate, BlockFrames, StreamFlags.NoFlag, playCb, IntPtr.Zero); + playStream.Start(); + doneEvent.Wait(); + // Give PortAudio a moment to drain the device buffer before Stop(). + Thread.Sleep(200); + playStream.Stop(); +``` + +- [ ] **Step 2: Build locally** + +```sh +dotnet build tests/01-record-play-cs -c Release +``` + +Expected: success. + +- [ ] **Step 3: Run on the Pi and verify all three pass criteria** + +```sh +bin/probe-cs +``` + +Then **at the Pi's audio output** (the USB Speaker Phone is mic and speaker — playback comes out of the same device): + +1. **Playback is recognisable as what was said into the mic.** Speak something distinct during the 5-second record window (e.g., "the quick brown fox jumps over the lazy dog") and confirm you can clearly understand the playback. +2. **The meter visibly responds to speech and to silence.** During the recording, alternate between speaking and silence; the `####` bar should clearly grow and shrink. +3. **No clipping, dropouts, or sustained PortAudio under/overrun warnings.** Scan the stderr output for `[status] input overflow` lines — a single one at start-up is acceptable, sustained ones are not. The playback should not have audible clicks, gaps, or chipmunk artefacts. + +If any of the three fails: do not "fix" by tweaking thresholds or silencing logs — diagnose. Use `findings.md` ("Force PortAudio through ALSA's `plug` plugin") as the first reference; the `PA_ALSA_PLUGHW=1` env var must be in place before `PortAudio.Initialize()` runs. + +- [ ] **Step 4: Commit** + +```sh +git add tests/01-record-play-cs/Program.cs +git commit -m "Playback: append OutputStream playback of recorded buffer" +``` + +- [ ] **Step 5: Note the probe outcome** + +The probe is done. Append a short note to `findings.md` under a new section `## C# probe outcome (2026-06-12)` recording: (a) which PortAudioSharp2 version you pinned, (b) whether the NuGet bundled `libportaudio.so` or you used the Pi's system `libportaudio2`, (c) any API drift you had to adjust for vs. the plan, (d) whether all three pass criteria held. This belongs in `findings.md` because the next probe (Test 2 in C#) will benefit from it. + +Commit: + +```sh +git add findings.md +git commit -m "Findings: C# record-play probe outcome" +``` + +--- + +## Notes on what is intentionally **not** in this plan + +- **No automated tests.** The spec is explicit; verification is the listening test on the Pi. WavWriter is verified transitively by the playback step in Task 5. +- **No queue / consumer thread.** The Python probe has the same shape (callback writes, main thread reads). The findings doc's "callback queue for input" rework belongs to the eventual main client, not this probe. +- **No cleanup on Ctrl-C mid-recording.** Probe. Crash trace is fine. +- **No retry on PortAudio init failure.** Crash and surface. +- **No abstraction over PortAudio.** No `IAudioDevice` interface, no DI. One file, one job. + +If, during implementation, you feel the urge to add any of the above — stop and re-read the spec. The point of this probe is to be cheap and disposable.