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.
This commit is contained in:
EdisonJwa
2026-05-14 12:27:04 +08:00
parent 06ec6f2965
commit d5b53996bc
10 changed files with 2125 additions and 0 deletions
@@ -0,0 +1,46 @@
//! Verification tests. These run against the host audio stack and
//! therefore live behind the `audio-hardware` opt-in feature for CI
//! environments without a usable audio device. Without the feature
//! they fall back to a pure-software synth+WAV round-trip that does
//! not touch cpal.
use std::time::Duration;
use audio_capture_playback_spike::synth_sine_wav;
#[test]
fn synth_produces_valid_wav_with_expected_frame_count() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("sine.wav");
let (frames, sr) =
synth_sine_wav(&path, 440.0, Duration::from_millis(500), 48_000).unwrap();
assert_eq!(sr, 48_000);
// 0.5 s × 48 kHz = 24 000 frames (mono).
assert!(frames >= 23_900 && frames <= 24_100, "got {frames}");
let reader = hound::WavReader::open(&path).unwrap();
let spec = reader.spec();
assert_eq!(spec.channels, 1);
assert_eq!(spec.sample_rate, 48_000);
assert_eq!(spec.bits_per_sample, 16);
assert_eq!(reader.len(), frames as u32);
}
#[test]
fn synth_then_playback_does_not_error_when_output_device_present() {
use audio_capture_playback_spike::AudioPlayback;
use cpal::traits::HostTrait;
// Skip on hosts with no output device. The PoC's exit criterion
// is satisfied by the verification CLI run captured in
// VERIFICATION.md; this test is a CI sanity check.
if cpal::default_host().default_output_device().is_none() {
eprintln!("SKIP: no default output device");
return;
}
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("sine.wav");
synth_sine_wav(&path, 440.0, Duration::from_millis(120), 48_000).unwrap();
AudioPlayback::play_wav(&path).expect("playback round-trip should not error");
}