From b7fda48e91bace9064f1cd3bedebc9d28ed1f3db Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Fri, 12 Jun 2026 09:18:26 +0000 Subject: [PATCH] Spec: C# probe re-implementing Test 1 (record/play) on the Pi --- ...6-06-12-record-play-csharp-probe-design.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-record-play-csharp-probe-design.md diff --git a/docs/superpowers/specs/2026-06-12-record-play-csharp-probe-design.md b/docs/superpowers/specs/2026-06-12-record-play-csharp-probe-design.md new file mode 100644 index 0000000..6441817 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-record-play-csharp-probe-design.md @@ -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 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`)