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