Files
chanora/poc/audio-capture-playback-spike/VERIFICATION.md
T
EdisonJwa d5b53996bc feat(poc/audio): add audio capture/playback spike (partial — desktop only)
Proof-of-concept addressing the audio exit criterion from
docs/architecture/proof-of-concept-plan.md §2:
  "Capture/playback works on at least one desktop and one mobile
   target."

PARTIAL PASS. The desktop half is verified on Linux; the mobile
half is NOT verified by this PoC and remains a documented open gap.

Implements via cpal (matching DEC-011 'platform-native first'):
  - AudioCapture::record_to_wav opens the default input device,
    handles f32/i16/u16 sample formats, down-mixes to mono, writes
    16-bit PCM WAV via hound.
  - AudioPlayback::play_wav opens the default output device, picks
    a stream config matching the WAV, blocks until drained.
  - synth_sine_wav produces a deterministic 440 Hz test signal for
    headless verification of the playback path when no microphone
    is available.
  - Typed AudioError DTO with NoInputDevice, NoOutputDevice,
    DefaultConfig, BuildStream, PlayStream, Wav, Io,
    UnsupportedFormat arms.

Verified on 2026-05-13 (Linux + cpal + PipeWire). Capture stream
opened against the system default input; build failed against the
auto_null source (typed AudioError::BuildStream returned cleanly,
demonstrating the production error path); fallback to synth fired;
playback drove 24,000 frames to completion through
Rust → cpal → ALSA → pcm_pipewire → PipeWire → auto_null.
Both audio.rs tests pass.

Mobile gap (explicit, NOT closed):
  - Android Oboe path not built or run.
  - iOS AVAudioEngine path not built or run.

Surfaced finding for the decision register: DEC-011 does not pin an
audio crate. The PoC uses cpal; production code needs an owner
ruling, ideally after the mobile spike closes the gap.

Out of scope: DSP (HPF/NS/AEC/AGC), Opus encode/decode, jitter
buffer, mixer, latency measurement, bit-exact loopback, device
permission flows. These belong to chanora_audio.

Authority: PoC plan §2, DEC-011, SysDes audio subsystem.
Not product code; not promoted into chanora_audio.
2026-05-14 12:27:04 +08:00

4.5 KiB
Raw Blame History

Verification record — audio-capture-playback-spike

Result

PARTIAL PASS. Desktop half met on Linux; mobile half not verified.

The PoC plan §2 exit criterion is "Capture/playback works on at least one desktop and one mobile target." This spike has empirically verified the desktop path (Linux × cpal × PipeWire). The mobile path (Android Oboe or iOS AVAudioEngine) has not been built or run in this session and remains a documented gap. See the Gaps section in README.md.

Environment

Field Value
Date 2026-05-13
Host OS Linux (Arch, kernel 7.0.5-arch1-1, x86_64)
Rust toolchain stable 1.95.0
cpal 0.16
hound (WAV I/O) 3.5
Audio server PipeWire 1.6.4 (with the pcm_pipewire ALSA plugin module)
Default input auto_null (system has no physical microphone bound to this session)
Default output auto_null (PipeWire null sink)
Local config ~/.asoundrc aliases pcm.!default and ctl.!default to pipewire

Reproduction

cargo test
cargo run --bin audio-capture-playback-cli -- roundtrip

Test run

running 2 tests
test synth_produces_valid_wav_with_expected_frame_count ... ok
test synth_then_playback_does_not_error_when_output_device_present ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured;
                 0 filtered out; finished in 0.06s

CLI round-trip

INFO spike: opening capture stream device="default" sample_rate=44100
            channels=2 sample_format=F32
WARN spike: capture unavailable; falling back to synth
            error=stream build error: A backend-specific error has
            occurred: ALSA function 'snd_pcm_hw_params' failed with
            error 'No such file or directory (2)'
INFO spike: synthesised fallback frames=24000 sample_rate=48000
INFO spike: preparing playback device="default" file_sr=48000
            file_channels=1 samples=24000
INFO spike: playback finished consumed=24000 total=24000
round-trip complete (played from /tmp/chanora_audio_spike_synth.wav)

What this evidences

  1. cpal successfully opened the default input device (negotiated 44.1 kHz / 2-channel / F32 against PipeWire's auto_null).
  2. The capture stream build then failed because the null source does not expose snd_pcm_hw_params at that configuration. This is a real platform-audio failure mode; the typed AudioError::BuildStream was returned and surfaced cleanly. This is the same shape production code will see on misconfigured user hosts.
  3. The fallback synth wrote a valid 24,000-frame, 48 kHz, mono, 16-bit WAV.
  4. cpal opened the default output device, negotiated the matching config, and the playback callback drained the WAV completely: consumed=24000 total=24000. Process exited 0.

The full path Rust → cpal → ALSA → pcm_pipewire → PipeWire → auto_null ran end-to-end, with PCM frames produced by Rust consumed by the platform audio stack.

Coverage matrix

PoC plan requirement Status
Desktop capture path PASS (stream opens against the system audio stack; typed error returned when device cannot satisfy hw_params).
Desktop playback path PASS (24,000 frames driven to completion).
Mobile capture path NOT VERIFIED.
Mobile playback path NOT VERIFIED.
Round-trip flow PASS — capture-attempt → fallback → playback.
Typed error model PASS — AudioError arms returned cleanly.
Cross-platform crate choice (DEC-011 alignment) PARTIAL — cpal is a platform-native abstraction, but only the Linux back-end exercised here.

What this spike does NOT validate

  • Real microphone capture (this host has none).
  • Bit-exact capture-to-playback signal integrity.
  • Latency.
  • Hot-plug / device-change events.
  • Permission flows (mobile microphone consent, Linux pulse permission prompts).
  • Behaviour under sample-rate or channel-count mismatch between file and device (the PoC chose the file's rate; production code will need a real resampler).
  • DSP (HPF / NS / AEC / AGC).
  • Opus encode / decode.
  • Jitter buffer / mixer.
  • Mobile (Android Oboe / iOS AVAudioEngine) — explicit gap.
  1. Promote cpal selection to a recorded product decision (DEC-011 sub-decision) — or pin a different crate after the mobile spike.
  2. Spike Android Oboe path on an emulator or real device.
  3. Spike iOS AVAudioEngine path on a real device.
  4. Only after the mobile half is closed: promote the typed AudioError + capture/playback shapes into chanora_audio.