6.7 KiB
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.wavin the working directory. - Play the recording back through the same USB device.
Pass criteria
Same as the original spec carried in findings.md:
- Playback is recognisable as what was said into the mic.
- The meter visibly responds to speech and to silence.
- 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=1viaEnvironment.SetEnvironmentVariablebeforePortAudio.Initialize()— same ordering rule documented infindings.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
usband whosemaxInputChannels >= 1. If none found, print the device table to stderr and exit 1. Mirrorsfind_usb_devicein 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 overflowto 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 isint(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()→ onPaError != NoError, write the error name to stderr and exit 1.- Input overflow status →
[status] input overflowto 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:
dotnet publish tests/01-record-play-cs -c Release -r linux-arm64 --self-contained -o /tmp/probe-cs-outon this sandbox.rsync -az --delete /tmp/probe-cs-out/ pi@192.168.50.115:~/probe-cs/(sshpass with the Pi password, as inbin/deploy).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'splugplugin", "USB device discovery", "Beep / TTS blocks the input stream") - Pi access:
pi@192.168.50.115, passwordassistant(seeCLAUDE.md)