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