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

123 lines
6.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`)