123 lines
6.7 KiB
Markdown
123 lines
6.7 KiB
Markdown
# 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`)
|