Spec: C# probe re-implementing Test 1 (record/play) on the Pi

This commit is contained in:
2026-06-12 09:18:26 +00:00
parent 8bc08f451d
commit b7fda48e91
@@ -0,0 +1,122 @@
# 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 `dlopen`s 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`)