Files
Assistant/docs/superpowers/specs/2026-06-12-record-play-csharp-probe-design.md
T

6.7 KiB
Raw Blame History

Record-play C# probe — design

Date: 2026-06-12 Branch: fresh-start Status: approved (pending written-spec review)

Purpose

Prove that .NET 9 on the Pi can drive PortAudio with the same behaviour we already validated from Python's sounddevice in tests/01-record-play/main.py. This is a throwaway probe. Once it passes the same criteria the Python test passed, its job is done — it does not become the foundation of the main C# Pi client. Any abstractions for the real client will be designed later, after both audio probes (and the wakeword probe) are green.

Non-goals

  • No reusable audio abstraction. One-file program; if it grows, that's a smell, not a feature.
  • No automated tests. Verification is the same listening test the Python probe uses.
  • No changes to bin/bootstrap. The Pi gets a self-contained binary; .NET is never installed on the Pi.
  • No coupling to the Python prototypes. They keep working, untouched.
  • No build of the main C# Pi client. That's a separate spec.

Functional spec

Identical to the Python Test 1 (tests/01-record-play/main.py:4-7):

  • Record 5 s of mono PCM16 audio at 16 kHz from the USB Speaker Phone on the Pi.
  • Render a live RMS level meter to the terminal (\r{elapsed}s |####...|, refresh ~20 Hz).
  • Write the recording to out.wav in the working directory.
  • Play the recording back through the same USB device.

Pass criteria

Same as the original spec carried in findings.md:

  1. Playback is recognisable as what was said into the mic.
  2. The meter visibly responds to speech and to silence.
  3. No clipping, dropouts, or sustained PortAudio under/overrun warnings.

Layout

tests/01-record-play-cs/
  Probe.csproj      # net9.0, RuntimeIdentifier=linux-arm64, PortAudioSharp2 NuGet ref
  Program.cs        # entry point: env var → init → device pick → record → write → play
  WavWriter.cs      # static helper, PCM16 mono RIFF/fmt/data header + raw bytes

bin/probe-cs        # build (locally) → rsync (to Pi) → run (over SSH)

The Python prototypes under tests/01-record-play/ and tests/02-wakeword/ are untouched.

Components

Program.cs

Single entry point with the full record-play loop. Mirrors the Python structure for diffability.

  • Set PA_ALSA_PLUGHW=1 via Environment.SetEnvironmentVariable before PortAudio.Initialize() — same ordering rule documented in findings.md (the env var must be in place before the PortAudio host-API table is built).
  • Initialise PortAudio.
  • Enumerate devices; pick the first one whose name (case-insensitively) contains usb and whose maxInputChannels >= 1. If none found, print the device table to stderr and exit 1. Mirrors find_usb_device in the Python probe.
  • Open a callback-driven input stream: 16 kHz, 1 channel, Int16 format, 1024-frame block size. The callback copies into a preallocated Int16[5 * 16000] at a running write offset. The PortAudio status flag is checked on every callback; on overflow, write [status] input overflow to stderr and continue (matches Python [status] {status} behaviour).
  • Main thread render loop: sleep 50 ms, snapshot the last 1600 samples written, compute RMS as in Python (sqrt(mean((x/32768)^2) + 1e-12)), print \r{elapsed:0.1}s |####...| where ####... has 60 chars and the bar length is int(rms * 60).
  • After the buffer is full, close the input stream. Call WavWriter.Write("out.wav", buf, 16000). Open an output stream on the same device (16 kHz, 1 channel, Int16, 1024-frame block) that pulls from the same buffer in a callback, with a completion signal. Wait for completion, close, terminate PortAudio.

WavWriter.cs

static void Write(string path, ReadOnlySpan<short> samples, int sampleRate)

Writes a standard 44-byte RIFF header (RIFF/fmt /data chunks, PCM format = 1, channels = 1, bits = 16, byte rate / block align computed from sampleRate) followed by samples as little-endian Int16. ~30 lines. No streaming, no chunking — the full buffer fits in memory (160 KB at the spec rate × duration).

Data flow

PortAudio input callback (RT thread)
    └─ memcpy into Int16[80000] at writeOffset; advance writeOffset
       (no allocation in callback)

Main thread (every 50 ms)
    ├─ snapshot last 1600 samples of buf up to writeOffset
    ├─ RMS → 60-char bar → \r-update on stdout
    └─ exit when writeOffset == buf.Length

Then (still main thread):
    ├─ WavWriter.Write("out.wav", buf, 16000)
    ├─ open OUTPUT stream on same device
    ├─ output callback drains buf into PortAudio
    └─ wait for completion, terminate

No queue, no extra threads. The callback writes; the main thread reads a tail snapshot for the meter only. Mirrors what sounddevice does in the Python version.

Error handling

  • USB device not found → device table to stderr, exit 1.
  • PortAudio.Initialize(), PortAudio.OpenStream(), PortAudio.StartStream() → on PaError != NoError, write the error name to stderr and exit 1.
  • Input overflow status → [status] input overflow to stderr, keep recording. Sustained overflow violates pass criterion 3 and gets noticed by the human listener.
  • File write failure → uncaught IOException (this is a probe; crash trace is fine).

Anything else (PortAudio buffer underrun on playback, codec errors, device disappears mid-stream) → uncaught throw. Again — probe, not production.

Deploy & run (bin/probe-cs)

A new shell script alongside bin/deploy. It does:

  1. dotnet publish tests/01-record-play-cs -c Release -r linux-arm64 --self-contained -o /tmp/probe-cs-out on this sandbox.
  2. rsync -az --delete /tmp/probe-cs-out/ pi@192.168.50.115:~/probe-cs/ (sshpass with the Pi password, as in bin/deploy).
  3. ssh pi@192.168.50.115 'cd ~/probe-cs && ./Probe' — the meter and the playback are interactive on the Pi's audio output; we observe via SSH.

Cleanup (when the probe is retired): ssh pi@... 'rm -rf ~/probe-cs' plus git rm -r tests/01-record-play-cs bin/probe-cs.

Verification

The pass criteria above. There are no automated tests. Run bin/probe-cs, listen to the playback, watch the meter, confirm no warnings.

Open assumption (verify at implementation time)

PortAudioSharp2's NuGet package on linux-arm64 either bundles its own libportaudio.so or dlopens the system one. The Pi already has libportaudio2 from bin/bootstrap, so either way the binary should resolve PortAudio. Confirm via the PortAudioSharp2 README / Context7 lookup at implementation, not now — does not affect the design.

References

  • Python probe: tests/01-record-play/main.py
  • Prototype findings: findings.md (esp. "Force PortAudio through ALSA's plug plugin", "USB device discovery", "Beep / TTS blocks the input stream")
  • Pi access: pi@192.168.50.115, password assistant (see CLAUDE.md)