Files
Assistant/docs/superpowers/plans/2026-06-12-record-play-csharp-probe.md

571 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<PASHARP_VER>` 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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>RecordPlayProbe</RootNamespace>
<AssemblyName>Probe</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RuntimeIdentifier>linux-arm64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PortAudioSharp2" Version="<PASHARP_VER>" />
</ItemGroup>
</Project>
```
Replace `<PASHARP_VER>` 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' ssh pi@192.168.50.115 'rm -rf ~/probe-cs && mkdir ~/probe-cs'
sshpass -p 'assistant' scp -r /tmp/probe-cs-out/. pi@192.168.50.115:~/probe-cs/
sshpass -p 'assistant' ssh pi@192.168.50.115 'cd ~/probe-cs && chmod +x Probe && ./Probe'
```
(We use `scp -r` instead of `rsync` because the build sandbox does not have rsync installed. The remote `rm -rf` + `mkdir` gives us the `--delete` semantics we want.)
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 ">> scp to $PI_USER@$PI_HOST:~/probe-cs/ (wipe-and-replace)"
sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=accept-new \
"$PI_USER@$PI_HOST" 'rm -rf ~/probe-cs && mkdir ~/probe-cs'
sshpass -p "$PI_PASS" scp -r "$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 && chmod +x Probe && ./Probe'
```
(Using `scp -r` rather than `rsync` because the build sandbox does not have rsync installed. The remote `rm -rf` + `mkdir` gives us the `--delete` semantics we want.)
- [ ] **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<short> 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 `<AllowUnsafeBlocks>true</AllowUnsafeBlocks>` for the `unsafe` block in the callback. Add it:
- [ ] **Step 2: Enable unsafe blocks in `Probe.csproj`**
Add `<AllowUnsafeBlocks>true</AllowUnsafeBlocks>` inside the existing `<PropertyGroup>`. The full PropertyGroup should now read:
```xml
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>RecordPlayProbe</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>
```
- [ ] **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 <N> ('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.