Files
chanora/poc/audio-capture-playback-spike/src/main.rs
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

90 lines
3.4 KiB
Rust

//! CLI driver — capture or playback or both.
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use audio_capture_playback_spike::{synth_sine_wav, AudioCapture, AudioPlayback};
use tracing::{info, warn};
fn main() -> ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let mode = std::env::args().nth(1).unwrap_or_else(|| "roundtrip".into());
let tmp = std::env::temp_dir();
let cap_path = tmp.join("chanora_audio_spike_capture.wav");
let play_path = tmp.join("chanora_audio_spike_synth.wav");
let result: anyhow::Result<()> = match mode.as_str() {
"capture" => do_capture(&cap_path),
"synth" => do_synth(&play_path),
"playback" => do_playback(&play_path),
"roundtrip" => do_roundtrip(&cap_path, &play_path),
other => {
eprintln!("unknown mode: {other}. Use one of: capture, synth, playback, roundtrip.");
return ExitCode::from(2);
}
};
if let Err(e) = result {
eprintln!("error: {e:#}");
return ExitCode::from(1);
}
ExitCode::SUCCESS
}
fn do_capture(path: &PathBuf) -> anyhow::Result<()> {
info!(target: "spike", "capture → {}", path.display());
let frames = AudioCapture::record_to_wav(path, Duration::from_secs(1))?;
println!("captured {} frames to {}", frames, path.display());
Ok(())
}
fn do_synth(path: &PathBuf) -> anyhow::Result<()> {
info!(target: "spike", "synth → {}", path.display());
let (frames, sr) = synth_sine_wav(path, 440.0, Duration::from_millis(500), 48_000)?;
println!("synthesised {} frames @ {} Hz to {}", frames, sr, path.display());
Ok(())
}
fn do_playback(path: &PathBuf) -> anyhow::Result<()> {
info!(target: "spike", "playback ← {}", path.display());
AudioPlayback::play_wav(path)?;
println!("playback finished");
Ok(())
}
fn do_roundtrip(cap_path: &PathBuf, play_path: &PathBuf) -> anyhow::Result<()> {
// Try real capture first; if no input device or the capture failed,
// fall back to synthesised audio so the playback path is still
// exercised. This mirrors what production code would do during
// a "device test" UI on headless or permission-denied hosts.
let to_play = match AudioCapture::record_to_wav(cap_path, Duration::from_millis(750)) {
Ok(n) if n > 0 => {
info!(target: "spike", "captured {} frames; playing back", n);
cap_path.clone()
}
Ok(_) => {
warn!(target: "spike", "capture wrote 0 frames; falling back to synth");
let (n, sr) = synth_sine_wav(play_path, 440.0, Duration::from_millis(500), 48_000)?;
info!(target: "spike", frames = n, sample_rate = sr, "synthesised fallback");
play_path.clone()
}
Err(e) => {
warn!(target: "spike", error = %e, "capture unavailable; falling back to synth");
let (n, sr) = synth_sine_wav(play_path, 440.0, Duration::from_millis(500), 48_000)?;
info!(target: "spike", frames = n, sample_rate = sr, "synthesised fallback");
play_path.clone()
}
};
AudioPlayback::play_wav(&to_play)?;
println!("round-trip complete (played from {})", to_play.display());
Ok(())
}