use std::path::PathBuf;
use anyhow::Result;
use chrono::Utc;
use clap::Parser;
use crate::voice::capture::{
install_ctrl_c_handler, run_capture, CaptureOpts, CaptureSummary, TerminationReason,
};
use crate::voice::CpalAudioSource;
pub const DEFAULT_IDLE_AFTER_SECS: u32 = 5;
#[derive(Parser)]
pub struct CaptureCommand {
#[arg(long, default_value_t = DEFAULT_IDLE_AFTER_SECS)]
pub idle_after: u32,
#[arg(long)]
pub output: Option<PathBuf>,
#[arg(long)]
pub device: Option<String>,
}
impl CaptureCommand {
pub fn execute(self) -> Result<()> {
let output = match self.output {
Some(path) => path,
None => default_output_path()?,
};
let opts = CaptureOpts::new(output, self.idle_after);
let stop = install_ctrl_c_handler()?;
let source = CpalAudioSource::new(self.device.as_deref())?;
eprintln!(
"Recording to {} (idle-after: {}s, Ctrl-C to stop)…",
opts.output.display(),
opts.idle_after_secs
);
let summary = run_capture(source, opts, stop)?;
print_summary(&summary);
Ok(())
}
}
fn default_output_path() -> Result<PathBuf> {
let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
Ok(crate::voice::captures_dir()?.join(format!("{timestamp}.wav")))
}
fn print_summary(summary: &CaptureSummary) {
eprintln!("{}", format_summary(summary));
}
fn format_summary(summary: &CaptureSummary) -> String {
let reason = match summary.terminated_by {
TerminationReason::Idle => "silence threshold reached",
TerminationReason::SourceExhausted => "audio source ended",
TerminationReason::Signal => "Ctrl-C",
};
let seconds = samples_to_seconds(summary.samples_written);
format!(
"Captured {seconds:.2}s ({} samples; {} trimmed; stopped: {reason}) → {}",
summary.samples_written,
summary.trimmed_samples,
summary.output.display()
)
}
fn samples_to_seconds(samples: u64) -> f64 {
samples as f64 / f64::from(crate::voice::wav::TARGET_SAMPLE_RATE)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
struct TestCli {
#[command(flatten)]
capture: CaptureCommand,
}
#[test]
fn parses_defaults() {
let cli = TestCli::try_parse_from(["test"]).unwrap();
assert_eq!(cli.capture.idle_after, DEFAULT_IDLE_AFTER_SECS);
assert!(cli.capture.output.is_none());
assert!(cli.capture.device.is_none());
}
#[test]
fn parses_all_flags() {
let cli = TestCli::try_parse_from([
"test",
"--idle-after",
"10",
"--output",
"/tmp/x.wav",
"--device",
"MacBook Pro Microphone",
])
.unwrap();
assert_eq!(cli.capture.idle_after, 10);
assert_eq!(
cli.capture.output.as_deref().map(|p| p.to_str().unwrap()),
Some("/tmp/x.wav")
);
assert_eq!(
cli.capture.device.as_deref(),
Some("MacBook Pro Microphone")
);
}
#[test]
fn parses_idle_after_zero() {
let cli = TestCli::try_parse_from(["test", "--idle-after", "0"]).unwrap();
assert_eq!(cli.capture.idle_after, 0);
}
#[test]
fn rejects_negative_idle_after() {
let result = TestCli::try_parse_from(["test", "--idle-after", "-1"]);
assert!(result.is_err(), "negative idle-after should be rejected");
}
#[test]
fn default_output_path_uses_utc_timestamp() {
let path = default_output_path().unwrap();
let s = path.to_string_lossy();
assert!(s.contains(".omni-dev"));
assert!(s.contains("voice"));
assert!(s.contains("captures"));
assert!(s.ends_with(".wav"));
}
fn summary(reason: TerminationReason, written: u64, trimmed: u64) -> CaptureSummary {
CaptureSummary {
output: PathBuf::from("/tmp/out.wav"),
samples_written: written,
trimmed_samples: trimmed,
terminated_by: reason,
}
}
#[test]
fn format_summary_idle_termination_mentions_silence() {
let s = format_summary(&summary(TerminationReason::Idle, 16_000, 3_200));
assert!(s.contains("silence threshold reached"));
assert!(
s.contains("1.00s"),
"16000 samples @ 16kHz = 1.00s; got: {s}"
);
assert!(s.contains("16000 samples"));
assert!(s.contains("3200 trimmed"));
assert!(s.contains("/tmp/out.wav"));
}
#[test]
fn format_summary_signal_termination_mentions_ctrl_c() {
let s = format_summary(&summary(TerminationReason::Signal, 48_000, 0));
assert!(s.contains("Ctrl-C"));
assert!(
s.contains("3.00s"),
"48000 samples @ 16kHz = 3.00s; got: {s}"
);
assert!(s.contains("0 trimmed"));
}
#[test]
fn format_summary_source_exhausted_mentions_source() {
let s = format_summary(&summary(TerminationReason::SourceExhausted, 8_000, 0));
assert!(s.contains("audio source ended"));
assert!(s.contains("0.50s"));
}
#[test]
fn samples_to_seconds_zero_samples() {
assert!((samples_to_seconds(0) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn samples_to_seconds_exact_one_second() {
assert!((samples_to_seconds(16_000) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn samples_to_seconds_fractional() {
assert!((samples_to_seconds(8_000) - 0.5).abs() < f64::EPSILON);
}
}