565 lines
21 KiB
Markdown
565 lines
21 KiB
Markdown
# Record-play C# probe — implementation plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Re-implement Test 1 (record 5 s from the USB Speaker Phone → live RMS meter → write `out.wav` → play it back) in C# on the Raspberry Pi, proving .NET-driven PortAudio works on linux-arm64 with the same pass criteria the Python version met.
|
||
|
||
**Architecture:** Single-folder throwaway probe under `tests/01-record-play-cs/`. One `Program.cs` driving PortAudio via the PortAudioSharp2 NuGet binding, plus a tiny hand-rolled `WavWriter.cs` for the PCM16 RIFF file. A new `bin/probe-cs` script builds it as a linux-arm64 self-contained binary, rsyncs to the Pi, and runs it over SSH. The Pi gets no .NET install. No automated tests — verification is the same listening test the Python probe uses (`findings.md`).
|
||
|
||
**Tech Stack:** .NET 9, C#, PortAudioSharp2 (NuGet), bash, sshpass, rsync. Target host: Pi at `192.168.50.115`, user `pi`, password `assistant` (see `CLAUDE.md`).
|
||
|
||
---
|
||
|
||
## File structure
|
||
|
||
| Path | Created in task | Purpose |
|
||
| --- | --- | --- |
|
||
| `tests/01-record-play-cs/Probe.csproj` | Task 1 | net9.0 console project, linux-arm64 RID, PortAudioSharp2 reference |
|
||
| `tests/01-record-play-cs/Program.cs` | Task 1 (sanity spike), expanded in Tasks 4 & 5 | Single entry point; mirrors the Python probe's structure |
|
||
| `tests/01-record-play-cs/WavWriter.cs` | Task 3 | Static `Write(path, samples, sampleRate)` — 44-byte RIFF + raw PCM16 LE |
|
||
| `bin/probe-cs` | Task 2 | `dotnet publish` → `rsync` → `ssh` flow |
|
||
|
||
`tests/01-record-play/` (Python) and the rest of the repo are untouched.
|
||
|
||
---
|
||
|
||
## Task 1: Scaffold project + PortAudio sanity spike on the Pi
|
||
|
||
**Goal:** Stand up the .NET project and prove on the Pi that `PortAudio.Initialize()` + device enumeration work end-to-end from a self-contained linux-arm64 publish. No audio I/O yet. This de-risks the unknown the spec called out: does PortAudioSharp2's NuGet ship a `libportaudio` for linux-arm64, or does it dlopen the system one?
|
||
|
||
**Files:**
|
||
- Create: `tests/01-record-play-cs/Probe.csproj`
|
||
- Create: `tests/01-record-play-cs/Program.cs`
|
||
|
||
- [ ] **Step 1: Find the latest stable PortAudioSharp2 version**
|
||
|
||
Open `https://www.nuget.org/packages/PortAudioSharp2` in a browser (or `curl -s https://api.nuget.org/v3-flatcontainer/portaudiosharp2/index.json | jq -r '.versions[-1]'`). Note the latest non-prerelease version (call it `<PASHARP_VER>` below). Pin that exact version in the csproj — do **not** use a floating `*`. If `curl/jq` are not available, the NuGet web page lists "Latest version" at the top right.
|
||
|
||
- [ ] **Step 2: Write `tests/01-record-play-cs/Probe.csproj`**
|
||
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
<PropertyGroup>
|
||
<OutputType>Exe</OutputType>
|
||
<TargetFramework>net9.0</TargetFramework>
|
||
<RootNamespace>RecordPlayProbe</RootNamespace>
|
||
<AssemblyName>Probe</AssemblyName>
|
||
<Nullable>enable</Nullable>
|
||
<ImplicitUsings>enable</ImplicitUsings>
|
||
<RuntimeIdentifier>linux-arm64</RuntimeIdentifier>
|
||
<SelfContained>true</SelfContained>
|
||
<PublishSingleFile>false</PublishSingleFile>
|
||
<InvariantGlobalization>true</InvariantGlobalization>
|
||
</PropertyGroup>
|
||
<ItemGroup>
|
||
<PackageReference Include="PortAudioSharp2" Version="<PASHARP_VER>" />
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
Replace `<PASHARP_VER>` with the version from Step 1.
|
||
|
||
- [ ] **Step 3: Write `tests/01-record-play-cs/Program.cs` (spike version)**
|
||
|
||
```csharp
|
||
using PortAudioSharp;
|
||
|
||
Environment.SetEnvironmentVariable("PA_ALSA_PLUGHW", "1");
|
||
|
||
PortAudio.Initialize();
|
||
try
|
||
{
|
||
int count = PortAudio.DeviceCount;
|
||
Console.WriteLine($"PortAudio version: {PortAudio.VersionInfo.versionText}");
|
||
Console.WriteLine($"Devices ({count}):");
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
var info = PortAudio.GetDeviceInfo(i);
|
||
Console.WriteLine(
|
||
$" [{i}] {info.name} in={info.maxInputChannels} out={info.maxOutputChannels} " +
|
||
$"defaultSr={info.defaultSampleRate}");
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
PortAudio.Terminate();
|
||
}
|
||
```
|
||
|
||
**Note for the engineer:** The above uses the API surface as documented in PortAudioSharp2's README at time of writing (`PortAudio.Initialize/Terminate`, `PortAudio.DeviceCount`, `PortAudio.GetDeviceInfo(int)`, `PortAudio.VersionInfo`). If a compile error reveals the surface has changed, read PortAudioSharp2's README at the version you pinned in Step 1 and adjust the calls — the intent is "init, dump device table, terminate". Do **not** change to a different binding.
|
||
|
||
- [ ] **Step 4: Build and publish locally**
|
||
|
||
Run from the repo root:
|
||
|
||
```sh
|
||
dotnet publish tests/01-record-play-cs -c Release -r linux-arm64 --self-contained \
|
||
-o /tmp/probe-cs-out
|
||
```
|
||
|
||
Expected: build succeeds. Verify the output contains the native PortAudio binary:
|
||
|
||
```sh
|
||
ls /tmp/probe-cs-out | grep -E 'libportaudio|Probe'
|
||
```
|
||
|
||
Expected: `Probe` (the binary) is present. `libportaudio.so*` may or may not be present — if absent, PortAudioSharp2 will dlopen the system `libportaudio2` already installed by `bin/bootstrap` on the Pi.
|
||
|
||
- [ ] **Step 5: Push the spike to the Pi and run it**
|
||
|
||
```sh
|
||
sshpass -p 'assistant' rsync -az --delete /tmp/probe-cs-out/ \
|
||
pi@192.168.50.115:~/probe-cs/
|
||
sshpass -p 'assistant' ssh pi@192.168.50.115 'cd ~/probe-cs && ./Probe'
|
||
```
|
||
|
||
Expected output: PortAudio version string + a device table that includes at least one entry whose name contains `USB` (the Anhui LISTENAI USB Speaker Phone — exposed as ALSA card 3 per `findings.md`). If you see `dlopen` failures for `libportaudio`, install it on the Pi: `sshpass -p 'assistant' ssh pi@192.168.50.115 'sudo apt-get install -y libportaudio2'` (the prototype `bin/bootstrap` already does this, but the Pi may have been wiped).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```sh
|
||
git add tests/01-record-play-cs/Probe.csproj tests/01-record-play-cs/Program.cs
|
||
git commit -m "Scaffold C# record-play probe + PortAudio device-list spike"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Wrap the deploy flow as `bin/probe-cs`
|
||
|
||
**Goal:** Capture the Task 1 build/rsync/ssh sequence as a single script so subsequent tasks just run `bin/probe-cs`.
|
||
|
||
**Files:**
|
||
- Create: `bin/probe-cs`
|
||
|
||
- [ ] **Step 1: Write `bin/probe-cs`**
|
||
|
||
```sh
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
PI_HOST="${PI_HOST:-192.168.50.115}"
|
||
PI_USER="${PI_USER:-pi}"
|
||
PI_PASS="${PI_PASS:-assistant}"
|
||
PUBLISH_DIR="${PUBLISH_DIR:-/tmp/probe-cs-out}"
|
||
|
||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||
cd "$repo_root"
|
||
|
||
echo ">> dotnet publish (linux-arm64, self-contained)"
|
||
dotnet publish tests/01-record-play-cs \
|
||
-c Release -r linux-arm64 --self-contained \
|
||
-o "$PUBLISH_DIR"
|
||
|
||
echo ">> rsync to $PI_USER@$PI_HOST:~/probe-cs/"
|
||
sshpass -p "$PI_PASS" rsync -az --delete \
|
||
"$PUBLISH_DIR"/ "$PI_USER@$PI_HOST:~/probe-cs/"
|
||
|
||
echo ">> ssh + run on Pi"
|
||
sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=accept-new \
|
||
"$PI_USER@$PI_HOST" 'cd ~/probe-cs && ./Probe'
|
||
```
|
||
|
||
- [ ] **Step 2: Mark it executable**
|
||
|
||
```sh
|
||
chmod +x bin/probe-cs
|
||
```
|
||
|
||
- [ ] **Step 3: Smoke-test the script against Task 1's spike**
|
||
|
||
```sh
|
||
bin/probe-cs
|
||
```
|
||
|
||
Expected output: same device table as in Task 1 Step 5. If it errors, fix the script — do not work around it in later tasks.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```sh
|
||
git add bin/probe-cs
|
||
git commit -m "Add bin/probe-cs: build + rsync + run the C# probe on the Pi"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `WavWriter.cs` — hand-rolled PCM16 RIFF writer
|
||
|
||
**Goal:** A static helper that writes a mono PCM16 WAV file. No external deps.
|
||
|
||
**Files:**
|
||
- Create: `tests/01-record-play-cs/WavWriter.cs`
|
||
|
||
- [ ] **Step 1: Write `tests/01-record-play-cs/WavWriter.cs`**
|
||
|
||
```csharp
|
||
namespace RecordPlayProbe;
|
||
|
||
internal static class WavWriter
|
||
{
|
||
public static void Write(string path, ReadOnlySpan<short> samples, int sampleRate)
|
||
{
|
||
const short channels = 1;
|
||
const short bitsPerSample = 16;
|
||
const short pcmFormat = 1;
|
||
|
||
int byteRate = sampleRate * channels * bitsPerSample / 8;
|
||
short blockAlign = (short)(channels * bitsPerSample / 8);
|
||
int dataBytes = samples.Length * sizeof(short);
|
||
int riffChunkSize = 36 + dataBytes;
|
||
|
||
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write);
|
||
using var bw = new BinaryWriter(fs);
|
||
|
||
// RIFF header
|
||
bw.Write(new[] { (byte)'R', (byte)'I', (byte)'F', (byte)'F' });
|
||
bw.Write(riffChunkSize);
|
||
bw.Write(new[] { (byte)'W', (byte)'A', (byte)'V', (byte)'E' });
|
||
|
||
// fmt chunk
|
||
bw.Write(new[] { (byte)'f', (byte)'m', (byte)'t', (byte)' ' });
|
||
bw.Write(16); // fmt chunk size for PCM
|
||
bw.Write(pcmFormat);
|
||
bw.Write(channels);
|
||
bw.Write(sampleRate);
|
||
bw.Write(byteRate);
|
||
bw.Write(blockAlign);
|
||
bw.Write(bitsPerSample);
|
||
|
||
// data chunk
|
||
bw.Write(new[] { (byte)'d', (byte)'a', (byte)'t', (byte)'a' });
|
||
bw.Write(dataBytes);
|
||
foreach (short s in samples)
|
||
bw.Write(s);
|
||
}
|
||
}
|
||
```
|
||
|
||
`BinaryWriter` writes integers little-endian on every supported platform — that matches the WAV spec. No byte-order conversion needed.
|
||
|
||
- [ ] **Step 2: Build (no test step — spec says no automated tests)**
|
||
|
||
```sh
|
||
dotnet build tests/01-record-play-cs -c Release
|
||
```
|
||
|
||
Expected: success.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```sh
|
||
git add tests/01-record-play-cs/WavWriter.cs
|
||
git commit -m "Add WavWriter: mono PCM16 RIFF writer for the C# probe"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Recording with live RMS meter + WAV write
|
||
|
||
**Goal:** Replace the spike `Program.cs` with the real record + meter + write flow. No playback yet — we want to confirm recording independently of playback so a regression on either is unambiguous.
|
||
|
||
**Files:**
|
||
- Modify: `tests/01-record-play-cs/Program.cs` (rewrite — the spike code is replaced)
|
||
|
||
- [ ] **Step 1: Rewrite `tests/01-record-play-cs/Program.cs`**
|
||
|
||
```csharp
|
||
using PortAudioSharp;
|
||
|
||
const int SampleRate = 16_000;
|
||
const int Channels = 1;
|
||
const int DurationS = 5;
|
||
const uint BlockFrames = 1024;
|
||
const string OutWav = "out.wav";
|
||
|
||
Environment.SetEnvironmentVariable("PA_ALSA_PLUGHW", "1");
|
||
|
||
PortAudio.Initialize();
|
||
try
|
||
{
|
||
int device = FindUsbDevice();
|
||
Console.WriteLine(
|
||
$"Recording {DurationS}s from device {device} " +
|
||
$"('{PortAudio.GetDeviceInfo(device).name}')");
|
||
|
||
short[] buf = new short[SampleRate * DurationS];
|
||
int writeOffset = 0;
|
||
object gate = new();
|
||
|
||
var inParams = new StreamParameters
|
||
{
|
||
device = device,
|
||
channelCount = Channels,
|
||
sampleFormat = SampleFormat.Int16,
|
||
suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowInputLatency,
|
||
hostApiSpecificStreamInfo = IntPtr.Zero,
|
||
};
|
||
|
||
Stream.Callback callback = (IntPtr input, IntPtr _, uint frameCount,
|
||
ref StreamCallbackTimeInfo _2, StreamCallbackFlags status, IntPtr _3) =>
|
||
{
|
||
if (status.HasFlag(StreamCallbackFlags.InputOverflow))
|
||
Console.Error.WriteLine("[status] input overflow");
|
||
|
||
lock (gate)
|
||
{
|
||
int remaining = buf.Length - writeOffset;
|
||
int take = (int)Math.Min(frameCount, (uint)remaining);
|
||
if (take > 0)
|
||
{
|
||
unsafe
|
||
{
|
||
short* src = (short*)input.ToPointer();
|
||
fixed (short* dst = &buf[writeOffset])
|
||
{
|
||
Buffer.MemoryCopy(src, dst, take * sizeof(short), take * sizeof(short));
|
||
}
|
||
}
|
||
writeOffset += take;
|
||
}
|
||
}
|
||
return StreamCallbackResult.Continue;
|
||
};
|
||
|
||
using var stream = new Stream(
|
||
inParams, null, SampleRate, BlockFrames, StreamFlags.NoFlag, callback, IntPtr.Zero);
|
||
stream.Start();
|
||
|
||
var start = DateTime.UtcNow;
|
||
while (true)
|
||
{
|
||
int written;
|
||
lock (gate) { written = writeOffset; }
|
||
if (written >= buf.Length) break;
|
||
|
||
double level = Rms(buf, Math.Max(0, written - 1600), written);
|
||
int bars = (int)(level * 60);
|
||
double elapsed = (DateTime.UtcNow - start).TotalSeconds;
|
||
Console.Write($"\r{elapsed,4:0.0}s |{new string('#', bars).PadRight(60)}|");
|
||
Thread.Sleep(50);
|
||
}
|
||
Console.WriteLine();
|
||
stream.Stop();
|
||
|
||
WavWriter.Write(OutWav, buf, SampleRate);
|
||
Console.WriteLine($"Wrote {OutWav} ({buf.Length} frames @ {SampleRate} Hz)");
|
||
}
|
||
finally
|
||
{
|
||
PortAudio.Terminate();
|
||
}
|
||
|
||
static int FindUsbDevice()
|
||
{
|
||
int count = PortAudio.DeviceCount;
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
var info = PortAudio.GetDeviceInfo(i);
|
||
if (info.name.ToLowerInvariant().Contains("usb") && info.maxInputChannels >= 1)
|
||
return i;
|
||
}
|
||
Console.Error.WriteLine("USB audio device not found. Devices:");
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
var info = PortAudio.GetDeviceInfo(i);
|
||
Console.Error.WriteLine(
|
||
$" [{i}] {info.name} in={info.maxInputChannels} out={info.maxOutputChannels}");
|
||
}
|
||
Environment.Exit(1);
|
||
return -1; // unreachable
|
||
}
|
||
|
||
static double Rms(short[] data, int from, int to)
|
||
{
|
||
if (to <= from) return 0.0;
|
||
double sumSq = 0.0;
|
||
for (int i = from; i < to; i++)
|
||
{
|
||
double x = data[i] / 32768.0;
|
||
sumSq += x * x;
|
||
}
|
||
return Math.Sqrt(sumSq / (to - from) + 1e-12);
|
||
}
|
||
```
|
||
|
||
**RMS formula matches the Python probe** (`np.sqrt((x*x).mean() + 1e-12)`). **Tail window matches** (last 1600 samples, ~100 ms at 16 kHz). **Meter cadence matches** (50 ms sleep → ~20 Hz refresh).
|
||
|
||
The csproj also needs `<AllowUnsafeBlocks>true</AllowUnsafeBlocks>` for the `unsafe` block in the callback. Add it:
|
||
|
||
- [ ] **Step 2: Enable unsafe blocks in `Probe.csproj`**
|
||
|
||
Add `<AllowUnsafeBlocks>true</AllowUnsafeBlocks>` inside the existing `<PropertyGroup>`. The full PropertyGroup should now read:
|
||
|
||
```xml
|
||
<PropertyGroup>
|
||
<OutputType>Exe</OutputType>
|
||
<TargetFramework>net9.0</TargetFramework>
|
||
<RootNamespace>RecordPlayProbe</RootNamespace>
|
||
<AssemblyName>Probe</AssemblyName>
|
||
<Nullable>enable</Nullable>
|
||
<ImplicitUsings>enable</ImplicitUsings>
|
||
<RuntimeIdentifier>linux-arm64</RuntimeIdentifier>
|
||
<SelfContained>true</SelfContained>
|
||
<PublishSingleFile>false</PublishSingleFile>
|
||
<InvariantGlobalization>true</InvariantGlobalization>
|
||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||
</PropertyGroup>
|
||
```
|
||
|
||
- [ ] **Step 3: Build locally**
|
||
|
||
```sh
|
||
dotnet build tests/01-record-play-cs -c Release
|
||
```
|
||
|
||
Expected: success. If you hit a `StreamParameters` / `Stream` / `Callback` API mismatch, see PortAudioSharp2's README at the version you pinned in Task 1 and adjust the type names — the structure (struct of device + channelCount + sampleFormat + latency; Stream constructor that takes input params, output params, sample rate, frames-per-buffer, flags, callback, user data) is the conventional PortAudio shape and should map closely.
|
||
|
||
- [ ] **Step 4: Run on the Pi**
|
||
|
||
```sh
|
||
bin/probe-cs
|
||
```
|
||
|
||
Expected on the Pi:
|
||
- A line `Recording 5s from device <N> ('USB...')`.
|
||
- A live meter: `\r 0.0s |######...|` updating ~20×/sec for 5 seconds. The bar visibly responds to speech vs. silence.
|
||
- A final line `Wrote out.wav (80000 frames @ 16000 Hz)`.
|
||
- No sustained `[status] input overflow` messages (one at startup is acceptable).
|
||
|
||
Confirm the WAV is well-formed:
|
||
|
||
```sh
|
||
sshpass -p 'assistant' ssh pi@192.168.50.115 'soxi ~/probe-cs/out.wav'
|
||
```
|
||
|
||
Expected: `soxi` reports 1 channel, 16000 Hz, 16-bit PCM, ~5 seconds duration. (If `soxi` isn't installed: `sudo apt-get install -y sox` on the Pi, or use `file out.wav` which will at least say `RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 16000 Hz`.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```sh
|
||
git add tests/01-record-play-cs/Probe.csproj tests/01-record-play-cs/Program.cs
|
||
git commit -m "Record loop: callback InputStream + RMS meter + WAV write"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Playback + full pass-criteria verification
|
||
|
||
**Goal:** Add the playback step and verify all three pass criteria from the spec on real hardware.
|
||
|
||
**Files:**
|
||
- Modify: `tests/01-record-play-cs/Program.cs` (append playback after the WAV write)
|
||
|
||
- [ ] **Step 1: Add playback to `Program.cs`**
|
||
|
||
Insert this block immediately after the `Console.WriteLine($"Wrote {OutWav}...");` line and before the `finally` block:
|
||
|
||
```csharp
|
||
Console.WriteLine($"Playing back through device {device}");
|
||
|
||
int playOffset = 0;
|
||
object playGate = new();
|
||
var doneEvent = new ManualResetEventSlim(false);
|
||
|
||
var outParams = new StreamParameters
|
||
{
|
||
device = device,
|
||
channelCount = Channels,
|
||
sampleFormat = SampleFormat.Int16,
|
||
suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowOutputLatency,
|
||
hostApiSpecificStreamInfo = IntPtr.Zero,
|
||
};
|
||
|
||
Stream.Callback playCb = (IntPtr _, IntPtr output, uint frameCount,
|
||
ref StreamCallbackTimeInfo _2, StreamCallbackFlags _3, IntPtr _4) =>
|
||
{
|
||
lock (playGate)
|
||
{
|
||
int remaining = buf.Length - playOffset;
|
||
int take = (int)Math.Min(frameCount, (uint)remaining);
|
||
int silence = (int)frameCount - take;
|
||
|
||
unsafe
|
||
{
|
||
short* dst = (short*)output.ToPointer();
|
||
if (take > 0)
|
||
{
|
||
fixed (short* src = &buf[playOffset])
|
||
Buffer.MemoryCopy(src, dst, take * sizeof(short), take * sizeof(short));
|
||
playOffset += take;
|
||
}
|
||
if (silence > 0)
|
||
{
|
||
for (int i = take; i < frameCount; i++) dst[i] = 0;
|
||
}
|
||
}
|
||
|
||
if (playOffset >= buf.Length)
|
||
{
|
||
doneEvent.Set();
|
||
return StreamCallbackResult.Complete;
|
||
}
|
||
}
|
||
return StreamCallbackResult.Continue;
|
||
};
|
||
|
||
using var playStream = new Stream(
|
||
null, outParams, SampleRate, BlockFrames, StreamFlags.NoFlag, playCb, IntPtr.Zero);
|
||
playStream.Start();
|
||
doneEvent.Wait();
|
||
// Give PortAudio a moment to drain the device buffer before Stop().
|
||
Thread.Sleep(200);
|
||
playStream.Stop();
|
||
```
|
||
|
||
- [ ] **Step 2: Build locally**
|
||
|
||
```sh
|
||
dotnet build tests/01-record-play-cs -c Release
|
||
```
|
||
|
||
Expected: success.
|
||
|
||
- [ ] **Step 3: Run on the Pi and verify all three pass criteria**
|
||
|
||
```sh
|
||
bin/probe-cs
|
||
```
|
||
|
||
Then **at the Pi's audio output** (the USB Speaker Phone is mic and speaker — playback comes out of the same device):
|
||
|
||
1. **Playback is recognisable as what was said into the mic.** Speak something distinct during the 5-second record window (e.g., "the quick brown fox jumps over the lazy dog") and confirm you can clearly understand the playback.
|
||
2. **The meter visibly responds to speech and to silence.** During the recording, alternate between speaking and silence; the `####` bar should clearly grow and shrink.
|
||
3. **No clipping, dropouts, or sustained PortAudio under/overrun warnings.** Scan the stderr output for `[status] input overflow` lines — a single one at start-up is acceptable, sustained ones are not. The playback should not have audible clicks, gaps, or chipmunk artefacts.
|
||
|
||
If any of the three fails: do not "fix" by tweaking thresholds or silencing logs — diagnose. Use `findings.md` ("Force PortAudio through ALSA's `plug` plugin") as the first reference; the `PA_ALSA_PLUGHW=1` env var must be in place before `PortAudio.Initialize()` runs.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```sh
|
||
git add tests/01-record-play-cs/Program.cs
|
||
git commit -m "Playback: append OutputStream playback of recorded buffer"
|
||
```
|
||
|
||
- [ ] **Step 5: Note the probe outcome**
|
||
|
||
The probe is done. Append a short note to `findings.md` under a new section `## C# probe outcome (2026-06-12)` recording: (a) which PortAudioSharp2 version you pinned, (b) whether the NuGet bundled `libportaudio.so` or you used the Pi's system `libportaudio2`, (c) any API drift you had to adjust for vs. the plan, (d) whether all three pass criteria held. This belongs in `findings.md` because the next probe (Test 2 in C#) will benefit from it.
|
||
|
||
Commit:
|
||
|
||
```sh
|
||
git add findings.md
|
||
git commit -m "Findings: C# record-play probe outcome"
|
||
```
|
||
|
||
---
|
||
|
||
## Notes on what is intentionally **not** in this plan
|
||
|
||
- **No automated tests.** The spec is explicit; verification is the listening test on the Pi. WavWriter is verified transitively by the playback step in Task 5.
|
||
- **No queue / consumer thread.** The Python probe has the same shape (callback writes, main thread reads). The findings doc's "callback queue for input" rework belongs to the eventual main client, not this probe.
|
||
- **No cleanup on Ctrl-C mid-recording.** Probe. Crash trace is fine.
|
||
- **No retry on PortAudio init failure.** Crash and surface.
|
||
- **No abstraction over PortAudio.** No `IAudioDevice` interface, no DI. One file, one job.
|
||
|
||
If, during implementation, you feel the urge to add any of the above — stop and re-read the spec. The point of this probe is to be cheap and disposable.
|