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:
+1315
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "audio-capture-playback-spike"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
description = "Chanora PoC: cross-platform audio capture + playback round-trip via cpal. Linux desktop verified; mobile path documented but unverified."
|
||||
|
||||
# Not product code. See docs/architecture/proof-of-concept-plan.md §4.
|
||||
# Authority: PoC plan §2, DEC-011 (platform-native first audio), SysDes-013
|
||||
# (audio subsystem), SRS audio family.
|
||||
|
||||
[lib]
|
||||
name = "audio_capture_playback_spike"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "audio-capture-playback-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# `cpal` is the de facto Rust cross-platform audio crate. It speaks
|
||||
# WASAPI/CoreAudio/ALSA/PipeWire/Oboe and matches the "platform-native
|
||||
# first" stance of DEC-011.
|
||||
cpal = "0.16"
|
||||
hound = "3"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
anyhow = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,119 @@
|
||||
# Audio Capture / Playback Spike
|
||||
|
||||
Chanora proof-of-concept. **Not product code.**
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| PoC name | `audio-capture-playback-spike` |
|
||||
| PoC plan | [`docs/architecture/proof-of-concept-plan.md`](../../docs/architecture/proof-of-concept-plan.md) §2 |
|
||||
| Purpose | Prove platform audio capture + playback behaviour |
|
||||
| Exit criterion (plan) | "Capture/playback works on at least one desktop and one mobile target" |
|
||||
| Authority | DEC-011 (platform-native first), audio family in SysDes / SRS |
|
||||
|
||||
## Honest status
|
||||
|
||||
**Partial pass.**
|
||||
|
||||
- **Linux desktop**: verified. cpal opens both the default input and
|
||||
the default output device, capture streams build (or report a typed
|
||||
error when no hardware-backed input is available), and playback
|
||||
drives a full PCM stream end-to-end through the platform audio
|
||||
stack (ALSA → PipeWire on this host).
|
||||
- **Mobile (Android / iOS)**: **not verified**. cpal supports Oboe
|
||||
(Android) and AVAudioEngine (iOS) but this PoC has not been built
|
||||
or run on either. The exit criterion's mobile half remains an open
|
||||
item for a follow-up spike — see "Gaps" below.
|
||||
|
||||
## What it proves
|
||||
|
||||
- `AudioCapture::record_to_wav(path, duration)` — opens the default
|
||||
input device, reads frames in any supported sample format, down-mixes
|
||||
to mono, writes 16-bit-PCM WAV.
|
||||
- `AudioPlayback::play_wav(path)` — opens the default output device,
|
||||
picks a stream config matching the file's sample rate, blocks until
|
||||
the file drains.
|
||||
- `synth_sine_wav` — produces a deterministic 440 Hz sine WAV so the
|
||||
playback path is exercisable on hosts without a usable input device.
|
||||
- A typed `AudioError` covering the practical failure modes (no
|
||||
device, config mismatch, stream-build failure, WAV I/O, unsupported
|
||||
format). Production code (`chanora_audio`) will widen this when DSP
|
||||
+ Opus + jitter buffer surfaces land.
|
||||
- Headless-friendly behaviour: if capture cannot build a stream, the
|
||||
CLI falls back to synth and the playback path is still exercised.
|
||||
This is the same shape production code would use for a
|
||||
"device test" UI.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
audio-capture-playback-spike/
|
||||
src/
|
||||
lib.rs # crate root + re-exports
|
||||
capture.rs # AudioCapture, AudioError, sample-format adapters
|
||||
playback.rs # AudioPlayback
|
||||
synth.rs # synth_sine_wav (deterministic test signal)
|
||||
main.rs # audio-capture-playback-cli
|
||||
tests/
|
||||
audio.rs # 2 tests
|
||||
Cargo.toml
|
||||
```
|
||||
|
||||
## Reproduce (Linux desktop)
|
||||
|
||||
Requires Rust stable, ALSA dev headers (cpal builds against ALSA on
|
||||
Linux), and a working audio stack (PipeWire / Pulse / pure ALSA).
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
cargo run --bin audio-capture-playback-cli -- roundtrip
|
||||
```
|
||||
|
||||
CLI modes:
|
||||
|
||||
| Mode | What it does |
|
||||
|---|---|
|
||||
| `capture` | Records 1 s of audio from the default input device to `${TMPDIR}/chanora_audio_spike_capture.wav`. |
|
||||
| `synth` | Writes a 440 Hz / 500 ms sine wave to `${TMPDIR}/chanora_audio_spike_synth.wav`. |
|
||||
| `playback` | Plays the synth WAV through the default output device. |
|
||||
| `roundtrip` (default) | Tries `capture`; on any failure, falls back to `synth`; then `playback`. |
|
||||
|
||||
### PipeWire-only Linux hosts
|
||||
|
||||
On hosts where the only audio server is PipeWire (no `pulseaudio`
|
||||
package), `cpal` still uses ALSA underneath and needs a
|
||||
`pcm.!default` alias to the PipeWire PCM plugin. The package
|
||||
`pipewire-alsa` (or your distro equivalent) plus a one-line
|
||||
`~/.asoundrc` is the standard fix:
|
||||
|
||||
```text
|
||||
pcm.!default { type pipewire }
|
||||
ctl.!default { type pipewire }
|
||||
```
|
||||
|
||||
## Gaps (the mobile half of the exit criterion)
|
||||
|
||||
| Target | What's missing | Notes |
|
||||
|---|---|---|
|
||||
| Android | Build & run cpal-oboe path on an emulator or device | Needs NDK + Gradle wiring + emulator. cpal supports it; this spike has not built it. |
|
||||
| iOS | Build & run cpal-AVAudioEngine path on a device | Needs macOS + Xcode + a developer account. |
|
||||
|
||||
These should be closed by a follow-up spike (or by promotion into the
|
||||
product `chanora_audio` crate scaffold) before the audio half of the
|
||||
PoC matrix is considered complete.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
- **No DSP.** HPF / NS / AEC / AGC / mixing / Opus encode-decode /
|
||||
jitter buffer all belong to `chanora_audio` and are out of scope.
|
||||
- **No bit-exact loopback assertion.** This PoC verifies *that* a
|
||||
capture stream can be opened and *that* a playback stream can drive
|
||||
a file to completion. It does not verify capture-to-playback signal
|
||||
integrity.
|
||||
- **No latency measurement.**
|
||||
- **No device-permission flows.** Mobile microphone consent UI is
|
||||
product-level.
|
||||
- **No echo-cancellation evidence.** Different PoC.
|
||||
|
||||
## Verification log
|
||||
|
||||
See `VERIFICATION.md` in this directory.
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
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**.
|
||||
|
||||
## Recommended follow-up
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Audio capture: open the default input device, record N seconds of
|
||||
//! 16-bit mono PCM, write to a WAV file.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{Sample, SampleFormat};
|
||||
use hound::{SampleFormat as HoundSampleFormat, WavSpec, WavWriter};
|
||||
use thiserror::Error;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AudioError {
|
||||
#[error("no input device available")]
|
||||
NoInputDevice,
|
||||
#[error("no output device available")]
|
||||
NoOutputDevice,
|
||||
#[error("device default config error: {0}")]
|
||||
DefaultConfig(#[from] cpal::DefaultStreamConfigError),
|
||||
#[error("device supported config error: {0}")]
|
||||
SupportedConfig(#[from] cpal::SupportedStreamConfigsError),
|
||||
#[error("stream build error: {0}")]
|
||||
BuildStream(#[from] cpal::BuildStreamError),
|
||||
#[error("stream play error: {0}")]
|
||||
PlayStream(#[from] cpal::PlayStreamError),
|
||||
#[error("wav io: {0}")]
|
||||
Wav(#[from] hound::Error),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("unsupported sample format: {0:?}")]
|
||||
UnsupportedFormat(SampleFormat),
|
||||
}
|
||||
|
||||
pub struct AudioCapture;
|
||||
|
||||
impl AudioCapture {
|
||||
/// Capture `duration` from the default input device into `path` as
|
||||
/// 16-bit mono PCM WAV. Returns the number of frames written.
|
||||
pub fn record_to_wav(path: &Path, duration: Duration) -> Result<u64, AudioError> {
|
||||
let host = cpal::default_host();
|
||||
let device = host.default_input_device().ok_or(AudioError::NoInputDevice)?;
|
||||
let device_name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
|
||||
let config = device.default_input_config()?;
|
||||
let sample_rate = config.sample_rate().0;
|
||||
let channels = config.channels();
|
||||
let sample_format = config.sample_format();
|
||||
info!(
|
||||
target: "spike",
|
||||
device = %device_name,
|
||||
sample_rate, channels, ?sample_format,
|
||||
"opening capture stream"
|
||||
);
|
||||
|
||||
let spec = WavSpec {
|
||||
channels: 1, // we down-mix to mono
|
||||
sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: HoundSampleFormat::Int,
|
||||
};
|
||||
let writer = Arc::new(Mutex::new(Some(WavWriter::create(path, spec)?)));
|
||||
let frames = Arc::new(Mutex::new(0u64));
|
||||
|
||||
let err_fn = |e| warn!(target: "spike", error = %e, "capture stream error");
|
||||
|
||||
let stream = match sample_format {
|
||||
SampleFormat::F32 => build_input_stream::<f32>(
|
||||
&device,
|
||||
&config.into(),
|
||||
writer.clone(),
|
||||
frames.clone(),
|
||||
channels,
|
||||
err_fn,
|
||||
)?,
|
||||
SampleFormat::I16 => build_input_stream::<i16>(
|
||||
&device,
|
||||
&config.into(),
|
||||
writer.clone(),
|
||||
frames.clone(),
|
||||
channels,
|
||||
err_fn,
|
||||
)?,
|
||||
SampleFormat::U16 => build_input_stream::<u16>(
|
||||
&device,
|
||||
&config.into(),
|
||||
writer.clone(),
|
||||
frames.clone(),
|
||||
channels,
|
||||
err_fn,
|
||||
)?,
|
||||
other => return Err(AudioError::UnsupportedFormat(other)),
|
||||
};
|
||||
|
||||
stream.play()?;
|
||||
std::thread::sleep(duration);
|
||||
drop(stream); // stops capture
|
||||
|
||||
// Finalize the WAV.
|
||||
if let Some(w) = writer.lock().unwrap().take() {
|
||||
w.finalize()?;
|
||||
}
|
||||
let n = *frames.lock().unwrap();
|
||||
info!(target: "spike", frames = n, "capture finished");
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_input_stream<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
writer: Arc<Mutex<Option<WavWriter<std::io::BufWriter<std::fs::File>>>>>,
|
||||
frames: Arc<Mutex<u64>>,
|
||||
channels: u16,
|
||||
err_fn: fn(cpal::StreamError),
|
||||
) -> Result<cpal::Stream, AudioError>
|
||||
where
|
||||
T: SizedSample + ToI16,
|
||||
{
|
||||
let stream = device.build_input_stream(
|
||||
config,
|
||||
move |data: &[T], _| {
|
||||
let mut w_guard = writer.lock().unwrap();
|
||||
if let Some(w) = w_guard.as_mut() {
|
||||
let mut n = 0u64;
|
||||
for frame in data.chunks(channels as usize) {
|
||||
// Down-mix to mono by averaging channels.
|
||||
let mut acc: i32 = 0;
|
||||
for s in frame {
|
||||
acc += s.to_i16() as i32;
|
||||
}
|
||||
let mono = (acc / frame.len() as i32) as i16;
|
||||
let _ = w.write_sample(mono);
|
||||
n += 1;
|
||||
}
|
||||
*frames.lock().unwrap() += n;
|
||||
}
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// `cpal::SizedSample` is the public marker. We expose it so the
|
||||
/// generic stream builder can constrain `T` without leaking cpal types
|
||||
/// out of this module.
|
||||
pub trait SizedSample: cpal::SizedSample + Send + 'static {}
|
||||
impl<T: cpal::SizedSample + Send + 'static> SizedSample for T {}
|
||||
|
||||
/// Adapter so we can convert any supported sample format down to i16
|
||||
/// for WAV writing.
|
||||
pub trait ToI16 {
|
||||
fn to_i16(&self) -> i16;
|
||||
}
|
||||
|
||||
impl ToI16 for i16 {
|
||||
fn to_i16(&self) -> i16 {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl ToI16 for u16 {
|
||||
fn to_i16(&self) -> i16 {
|
||||
(i32::from(*self) - i32::from(i16::MAX) - 1) as i16
|
||||
}
|
||||
}
|
||||
|
||||
impl ToI16 for f32 {
|
||||
fn to_i16(&self) -> i16 {
|
||||
let v = (*self * f32::from(i16::MAX)).clamp(f32::from(i16::MIN), f32::from(i16::MAX));
|
||||
v as i16
|
||||
}
|
||||
}
|
||||
|
||||
// `Sample` trait import is required for cpal's older method shapes; we
|
||||
// keep an explicit no-op use so this stays consistent across versions.
|
||||
#[allow(dead_code)]
|
||||
fn _assert_sample<T: Sample>() {}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Chanora PoC — audio capture / playback spike.
|
||||
//!
|
||||
//! Authority:
|
||||
//! * `docs/architecture/proof-of-concept-plan.md` §2 — Audio
|
||||
//! capture/playback spike. Exit criterion: "Capture/playback works
|
||||
//! on at least one desktop and one mobile target."
|
||||
//! * DEC-011 — platform-native first; Rust/WebRTC-style as fallback.
|
||||
//! * SysDes audio subsystem.
|
||||
//!
|
||||
//! Honest scope:
|
||||
//! * Linux desktop is verified here via `cpal` against the system
|
||||
//! audio stack (PipeWire / Pulse / ALSA, whichever is active).
|
||||
//! * **Mobile targets are NOT verified by this PoC.** That is a
|
||||
//! documented gap, not a passed criterion.
|
||||
//!
|
||||
//! What the PoC shows:
|
||||
//! * `AudioCapture` — opens the default input device and writes
|
||||
//! 16-bit PCM samples to a WAV file via `hound`.
|
||||
//! * `AudioPlayback` — opens the default output device and plays
|
||||
//! back a WAV file end-to-end.
|
||||
//! * `synth_sine_wav` — when no input device is available
|
||||
//! (genuinely headless box), the PoC can still exercise the
|
||||
//! playback path against a synthesised waveform. This is what
|
||||
//! keeps the spike useful in CI.
|
||||
//!
|
||||
//! Production code (`chanora_audio`) will own the real DSP chain
|
||||
//! (HPF, NS, AEC, AGC, Opus encode/decode, jitter buffer, mixer).
|
||||
//! None of that lives here.
|
||||
|
||||
pub mod capture;
|
||||
pub mod playback;
|
||||
pub mod synth;
|
||||
|
||||
pub use capture::{AudioCapture, AudioError};
|
||||
pub use playback::AudioPlayback;
|
||||
pub use synth::synth_sine_wav;
|
||||
@@ -0,0 +1,89 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Audio playback: open the default output device, stream a WAV file
|
||||
//! to it until the file is consumed.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{SampleFormat, SizedSample};
|
||||
use hound::WavReader;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::capture::AudioError;
|
||||
|
||||
pub struct AudioPlayback;
|
||||
|
||||
impl AudioPlayback {
|
||||
/// Play a 16-bit-PCM WAV file through the default output device.
|
||||
/// Blocks until the file has been fully streamed.
|
||||
pub fn play_wav(path: &Path) -> Result<(), AudioError> {
|
||||
let host = cpal::default_host();
|
||||
let device = host.default_output_device().ok_or(AudioError::NoOutputDevice)?;
|
||||
let device_name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
|
||||
|
||||
// Read all WAV samples up-front (PoC; production code would
|
||||
// stream in chunks). Resample is not done here — we pick a
|
||||
// device config that matches the file's sample rate when
|
||||
// possible.
|
||||
let mut reader = WavReader::open(path)?;
|
||||
let wav_spec = reader.spec();
|
||||
let samples: Vec<i16> = reader.samples::<i16>().collect::<Result<_, _>>()?;
|
||||
info!(
|
||||
target: "spike",
|
||||
device = %device_name,
|
||||
file_sr = wav_spec.sample_rate,
|
||||
file_channels = wav_spec.channels,
|
||||
samples = samples.len(),
|
||||
"preparing playback"
|
||||
);
|
||||
|
||||
// Pick a supported config that matches the file's channel count
|
||||
// and sample rate where possible.
|
||||
let default_cfg = device.default_output_config()?;
|
||||
let config = cpal::StreamConfig {
|
||||
channels: default_cfg.channels(),
|
||||
sample_rate: cpal::SampleRate(wav_spec.sample_rate),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
};
|
||||
|
||||
let cursor = Arc::new(Mutex::new(0usize));
|
||||
let total = samples.len();
|
||||
let samples_arc = Arc::new(samples);
|
||||
let out_channels = config.channels as usize;
|
||||
let done = Arc::new(Mutex::new(false));
|
||||
|
||||
let err_fn = |e| warn!(target: "spike", error = %e, "playback stream error");
|
||||
|
||||
let stream = match default_cfg.sample_format() {
|
||||
SampleFormat::F32 => build_output_stream::<f32>(
|
||||
&device, &config, samples_arc.clone(), cursor.clone(), out_channels, done.clone(), err_fn,
|
||||
)?,
|
||||
SampleFormat::I16 => build_output_stream::<i16>(
|
||||
&device, &config, samples_arc.clone(), cursor.clone(), out_channels, done.clone(), err_fn,
|
||||
)?,
|
||||
SampleFormat::U16 => build_output_stream::<u16>(
|
||||
&device, &config, samples_arc.clone(), cursor.clone(), out_channels, done.clone(), err_fn,
|
||||
)?,
|
||||
other => return Err(AudioError::UnsupportedFormat(other)),
|
||||
};
|
||||
stream.play()?;
|
||||
|
||||
// Wait for the callback to drain the buffer.
|
||||
let timeout = Duration::from_secs_f64(
|
||||
(total as f64 / wav_spec.sample_rate as f64 / wav_spec.channels as f64) + 2.0,
|
||||
);
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if *done.lock().unwrap() {
|
||||
break;
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
warn!(target: "spike", "playback timeout reached");
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
drop(stream);
|
||||
let consumed = *cursor.lock().unwrap();
|
||||
info!(target: "spike", consumed, total, "playback finished");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_output_stream<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
samples: Arc<Vec<i16>>,
|
||||
cursor: Arc<Mutex<usize>>,
|
||||
out_channels: usize,
|
||||
done: Arc<Mutex<bool>>,
|
||||
err_fn: fn(cpal::StreamError),
|
||||
) -> Result<cpal::Stream, AudioError>
|
||||
where
|
||||
T: SizedSample + FromI16 + Send + 'static,
|
||||
{
|
||||
let stream = device.build_output_stream(
|
||||
config,
|
||||
move |out: &mut [T], _| {
|
||||
let mut idx = cursor.lock().unwrap();
|
||||
for frame in out.chunks_mut(out_channels) {
|
||||
if *idx >= samples.len() {
|
||||
// End of file — write silence; signal done.
|
||||
for s in frame.iter_mut() {
|
||||
*s = T::from_i16(0);
|
||||
}
|
||||
*done.lock().unwrap() = true;
|
||||
} else {
|
||||
let sample = samples[*idx];
|
||||
*idx += 1;
|
||||
for s in frame.iter_mut() {
|
||||
*s = T::from_i16(sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub trait FromI16 {
|
||||
fn from_i16(v: i16) -> Self;
|
||||
}
|
||||
|
||||
impl FromI16 for i16 {
|
||||
fn from_i16(v: i16) -> Self {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl FromI16 for u16 {
|
||||
fn from_i16(v: i16) -> Self {
|
||||
(i32::from(v) + i32::from(i16::MAX) + 1) as u16
|
||||
}
|
||||
}
|
||||
|
||||
impl FromI16 for f32 {
|
||||
fn from_i16(v: i16) -> Self {
|
||||
f32::from(v) / f32::from(i16::MAX)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Synthesised waveform for environments with no real input device.
|
||||
//!
|
||||
//! Generates a 16-bit-PCM mono WAV containing a sine wave at the
|
||||
//! requested frequency and duration. This lets the playback path be
|
||||
//! exercised against the platform output stack even when capture is
|
||||
//! unavailable (typical headless CI host).
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use hound::{SampleFormat, WavSpec, WavWriter};
|
||||
|
||||
use crate::capture::AudioError;
|
||||
|
||||
/// Write a sine-wave WAV file. Returns (frames_written, sample_rate).
|
||||
pub fn synth_sine_wav(
|
||||
path: &Path,
|
||||
freq_hz: f32,
|
||||
duration: Duration,
|
||||
sample_rate: u32,
|
||||
) -> Result<(u64, u32), AudioError> {
|
||||
let spec = WavSpec {
|
||||
channels: 1,
|
||||
sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: SampleFormat::Int,
|
||||
};
|
||||
let mut w = WavWriter::create(path, spec)?;
|
||||
let total_frames = ((duration.as_secs_f64() * sample_rate as f64) as u64).max(1);
|
||||
let amp = f32::from(i16::MAX) * 0.5;
|
||||
for n in 0..total_frames {
|
||||
let t = n as f32 / sample_rate as f32;
|
||||
let s = (2.0 * std::f32::consts::PI * freq_hz * t).sin() * amp;
|
||||
w.write_sample(s as i16)?;
|
||||
}
|
||||
w.finalize()?;
|
||||
Ok((total_frames, sample_rate))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user