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

21 KiB
Raw Permalink Blame History

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 publishrsyncssh 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
<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)
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:

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:

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

#!/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
chmod +x bin/probe-cs
  • Step 3: Smoke-test the script against Task 1's spike
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
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

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)
dotnet build tests/01-record-play-cs -c Release

Expected: success.

  • Step 3: Commit
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

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:

<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
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
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:

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
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:

    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
dotnet build tests/01-record-play-cs -c Release

Expected: success.

  • Step 3: Run on the Pi and verify all three pass criteria
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
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:

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.