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.
90 lines
3.4 KiB
Rust
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(())
|
|
}
|