use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use super::audio::AudioSource;
use super::idle::{trim_trailing_silence, IdleDetector};
use super::wav::{mono_mixdown, Resampler, WavWriter};
#[derive(Debug, Clone)]
pub struct CaptureOpts {
pub output: PathBuf,
pub idle_after_secs: u32,
}
impl CaptureOpts {
#[must_use]
pub fn new(output: impl Into<PathBuf>, idle_after_secs: u32) -> Self {
Self {
output: output.into(),
idle_after_secs,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminationReason {
Idle,
SourceExhausted,
Signal,
}
#[derive(Debug, Clone)]
pub struct CaptureSummary {
pub output: PathBuf,
pub samples_written: u64,
pub trimmed_samples: u64,
pub terminated_by: TerminationReason,
}
pub fn run_capture<S: AudioSource>(
mut source: S,
opts: CaptureOpts,
stop_signal: Arc<AtomicBool>,
) -> Result<CaptureSummary> {
let mut resampler = Resampler::new(source.sample_rate())
.with_context(|| format!("Failed to build resampler at {} Hz", source.sample_rate()))?;
let mut detector = IdleDetector::new(opts.idle_after_secs);
let mut buffer: Vec<f32> = Vec::new();
let channels = source.channels();
let termination = loop {
if stop_signal.load(Ordering::Relaxed) {
break TerminationReason::Signal;
}
let Some(chunk) = source.next_chunk() else {
break TerminationReason::SourceExhausted;
};
let mono = mono_mixdown(&chunk, channels);
let resampled = resampler.push(&mono)?;
detector.push(&resampled);
buffer.extend_from_slice(&resampled);
if detector.is_idle() {
break TerminationReason::Idle;
}
};
if matches!(
termination,
TerminationReason::SourceExhausted | TerminationReason::Idle
) {
let tail = resampler.flush()?;
detector.push(&tail);
buffer.extend_from_slice(&tail);
}
let (samples_to_write, trimmed): (&[f32], u64) = if termination == TerminationReason::Idle {
let tail = detector.trailing_silence_samples();
let trimmed = trim_trailing_silence(&buffer, tail);
let dropped = (buffer.len() - trimmed.len()) as u64;
(trimmed, dropped)
} else {
(buffer.as_slice(), 0)
};
match termination {
TerminationReason::Idle | TerminationReason::SourceExhausted => {
if !detector.has_any_voice() {
return Err(anyhow!(
"No audio detected — every window of the {:.1}s capture was below the \
silence threshold. Is the microphone muted or routed to a different device?",
elapsed_seconds(buffer.len())
));
}
}
TerminationReason::Signal => {
if samples_to_write.is_empty() {
return Err(anyhow!(
"Stopped before any audio was captured — the stop signal arrived before \
the first sample reached the writer."
));
}
}
}
let mut writer = WavWriter::create(&opts.output)?;
writer.write_samples(samples_to_write)?;
let samples_written = writer.samples_written();
writer.finalize()?;
Ok(CaptureSummary {
output: opts.output,
samples_written,
trimmed_samples: trimmed,
terminated_by: termination,
})
}
fn elapsed_seconds(samples_at_16k: usize) -> f32 {
samples_at_16k as f32 / super::wav::TARGET_SAMPLE_RATE as f32
}
pub fn install_ctrl_c_handler() -> Result<Arc<AtomicBool>> {
let flag = Arc::new(AtomicBool::new(false));
signal_hook::flag::register(signal_hook::consts::SIGINT, flag.clone())
.context("Failed to register SIGINT handler")?;
Ok(flag)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::f32::consts::TAU;
use crate::voice::audio::FileAudioSource;
fn voiced_then_silent(rate: u32, voiced_s: f32, silent_s: f32, amplitude: f32) -> Vec<f32> {
let voiced_n = (rate as f32 * voiced_s) as usize;
let silent_n = (rate as f32 * silent_s) as usize;
let mut out: Vec<f32> = (0..voiced_n)
.map(|i| amplitude * (TAU * 440.0 * i as f32 / rate as f32).sin())
.collect();
out.extend(std::iter::repeat_n(0.0, silent_n));
out
}
fn write_to_temp(prefix: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join(format!("{prefix}.wav"));
(tmp, path)
}
fn stop_flag(value: bool) -> Arc<AtomicBool> {
Arc::new(AtomicBool::new(value))
}
#[test]
fn idle_termination_trims_trailing_silence() -> Result<()> {
let source_rate = 48_000;
let samples = voiced_then_silent(source_rate, 1.0, 3.0, 0.4);
let source = FileAudioSource::from_samples(samples, source_rate, 1, 4800);
let (_tmp, path) = write_to_temp("idle");
let summary = run_capture(source, CaptureOpts::new(&path, 2), stop_flag(false))?;
assert_eq!(summary.terminated_by, TerminationReason::Idle);
assert!(
summary.trimmed_samples > 0,
"tail silence should be trimmed"
);
let reader = hound::WavReader::open(&path)?;
let spec = reader.spec();
assert_eq!(spec.channels, 1);
assert_eq!(spec.sample_rate, 16_000);
assert_eq!(spec.bits_per_sample, 16);
let frame_count = reader.duration() as usize;
assert!(
(14_000..=20_000).contains(&frame_count),
"unexpected frame count after trim: {frame_count}"
);
Ok(())
}
#[test]
fn source_exhausted_writes_everything() -> Result<()> {
let source_rate = 16_000; let voiced_n = 8000;
let samples: Vec<f32> = (0..voiced_n)
.map(|i| 0.4 * (TAU * 440.0 * i as f32 / source_rate as f32).sin())
.collect();
let source = FileAudioSource::from_samples(samples, source_rate, 1, 1024);
let (_tmp, path) = write_to_temp("exhausted");
let summary = run_capture(source, CaptureOpts::new(&path, 60), stop_flag(false))?;
assert_eq!(summary.terminated_by, TerminationReason::SourceExhausted);
assert_eq!(summary.trimmed_samples, 0);
assert_eq!(summary.samples_written, voiced_n as u64);
Ok(())
}
struct SignalFlippingSource<S: AudioSource> {
inner: S,
stop: Arc<AtomicBool>,
chunks_returned: u32,
flip_after_chunks: u32,
}
impl<S: AudioSource> AudioSource for SignalFlippingSource<S> {
fn next_chunk(&mut self) -> Option<Vec<f32>> {
let chunk = self.inner.next_chunk();
if chunk.is_some() {
self.chunks_returned += 1;
if self.chunks_returned >= self.flip_after_chunks {
self.stop.store(true, Ordering::Relaxed);
}
}
chunk
}
fn sample_rate(&self) -> u32 {
self.inner.sample_rate()
}
fn channels(&self) -> u16 {
self.inner.channels()
}
}
#[test]
fn signal_termination_does_not_trim() -> Result<()> {
let source_rate = 16_000;
let samples: Vec<f32> = (0..160_000)
.map(|i| 0.4 * (TAU * 440.0 * i as f32 / source_rate as f32).sin())
.collect();
let inner = FileAudioSource::from_samples(samples, source_rate, 1, 4000);
let stop = stop_flag(false);
let source = SignalFlippingSource {
inner,
stop: stop.clone(),
chunks_returned: 0,
flip_after_chunks: 1,
};
let (_tmp, path) = write_to_temp("signal");
let summary = run_capture(source, CaptureOpts::new(&path, 5), stop)?;
assert_eq!(summary.terminated_by, TerminationReason::Signal);
assert_eq!(
summary.trimmed_samples, 0,
"signal termination must not trim"
);
assert!(
summary.samples_written > 0,
"should have captured something"
);
let reader = hound::WavReader::open(&path)?;
assert_eq!(reader.spec().sample_rate, 16_000);
Ok(())
}
#[test]
fn signal_termination_with_no_captured_audio_fails_loudly() {
let source_rate = 16_000;
let source = FileAudioSource::from_samples(vec![0.0; 16_000], source_rate, 1, 16);
let stop = stop_flag(true);
let (_tmp, path) = write_to_temp("signal_empty");
let err = run_capture(source, CaptureOpts::new(&path, 5), stop).unwrap_err();
assert!(
err.to_string()
.contains("Stopped before any audio was captured"),
"expected loud failure, got: {err}"
);
}
#[test]
fn silence_only_input_fails_loudly() {
let source_rate = 16_000;
let silence = vec![0.0_f32; 16_000 * 6]; let source = FileAudioSource::from_samples(silence, source_rate, 1, 1024);
let (_tmp, path) = write_to_temp("silent");
let err = run_capture(source, CaptureOpts::new(&path, 2), stop_flag(false)).unwrap_err();
assert!(
err.to_string().contains("No audio detected"),
"expected loud failure, got: {err}"
);
}
}