Skip to main content

omni_dev/cli/voice/
capture.rs

1//! `omni-dev voice capture` — record microphone audio to a 16 kHz mono WAV file.
2
3use std::path::PathBuf;
4
5use anyhow::Result;
6use chrono::Utc;
7use clap::Parser;
8
9use crate::voice::capture::{
10    install_ctrl_c_handler, run_capture, CaptureOpts, CaptureSummary, TerminationReason,
11};
12use crate::voice::CpalAudioSource;
13
14/// Default idle-after threshold in seconds. Matches the issue spec.
15pub const DEFAULT_IDLE_AFTER_SECS: u32 = 5;
16
17/// Captures audio from a microphone to a 16 kHz mono WAV file.
18///
19/// Auto-stops after `--idle-after` seconds of trailing silence (default 5 s)
20/// or when Ctrl-C is pressed. The output WAV is 16 kHz mono 16-bit signed
21/// PCM (whisper.cpp convention).
22#[derive(Parser)]
23pub struct CaptureCommand {
24    /// Stop after this many seconds of trailing silence. `0` disables
25    /// auto-stop — capture runs until Ctrl-C.
26    #[arg(long, default_value_t = DEFAULT_IDLE_AFTER_SECS)]
27    pub idle_after: u32,
28
29    /// Destination WAV path. Defaults to
30    /// `~/.omni-dev/voice/captures/<UTC-timestamp>.wav`.
31    #[arg(long)]
32    pub output: Option<PathBuf>,
33
34    /// Audio input device name. Defaults to the system default input.
35    /// Matching is exact against the platform-reported device name; an
36    /// unknown name errors with a list of detected devices.
37    #[arg(long)]
38    pub device: Option<String>,
39}
40
41impl CaptureCommand {
42    /// Executes the capture command.
43    pub fn execute(self) -> Result<()> {
44        let output = match self.output {
45            Some(path) => path,
46            None => default_output_path()?,
47        };
48        let opts = CaptureOpts::new(output, self.idle_after);
49        let stop = install_ctrl_c_handler()?;
50        let source = CpalAudioSource::new(self.device.as_deref())?;
51
52        eprintln!(
53            "Recording to {} (idle-after: {}s, Ctrl-C to stop)…",
54            opts.output.display(),
55            opts.idle_after_secs
56        );
57        let summary = run_capture(source, opts, stop)?;
58        print_summary(&summary);
59        Ok(())
60    }
61}
62
63/// Resolves the default output path used when `--output` is not supplied:
64/// `~/.omni-dev/voice/captures/<YYYYMMDDTHHMMSSZ>.wav`.
65fn default_output_path() -> Result<PathBuf> {
66    let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
67    Ok(crate::voice::captures_dir()?.join(format!("{timestamp}.wav")))
68}
69
70fn print_summary(summary: &CaptureSummary) {
71    eprintln!("{}", format_summary(summary));
72}
73
74fn format_summary(summary: &CaptureSummary) -> String {
75    let reason = match summary.terminated_by {
76        TerminationReason::Idle => "silence threshold reached",
77        TerminationReason::SourceExhausted => "audio source ended",
78        TerminationReason::Signal => "Ctrl-C",
79    };
80    let seconds = samples_to_seconds(summary.samples_written);
81    format!(
82        "Captured {seconds:.2}s ({} samples; {} trimmed; stopped: {reason}) → {}",
83        summary.samples_written,
84        summary.trimmed_samples,
85        summary.output.display()
86    )
87}
88
89fn samples_to_seconds(samples: u64) -> f64 {
90    samples as f64 / f64::from(crate::voice::wav::TARGET_SAMPLE_RATE)
91}
92
93#[cfg(test)]
94#[allow(clippy::unwrap_used, clippy::expect_used)]
95mod tests {
96    use super::*;
97
98    use clap::Parser;
99
100    #[derive(Parser)]
101    struct TestCli {
102        #[command(flatten)]
103        capture: CaptureCommand,
104    }
105
106    #[test]
107    fn parses_defaults() {
108        let cli = TestCli::try_parse_from(["test"]).unwrap();
109        assert_eq!(cli.capture.idle_after, DEFAULT_IDLE_AFTER_SECS);
110        assert!(cli.capture.output.is_none());
111        assert!(cli.capture.device.is_none());
112    }
113
114    #[test]
115    fn parses_all_flags() {
116        let cli = TestCli::try_parse_from([
117            "test",
118            "--idle-after",
119            "10",
120            "--output",
121            "/tmp/x.wav",
122            "--device",
123            "MacBook Pro Microphone",
124        ])
125        .unwrap();
126        assert_eq!(cli.capture.idle_after, 10);
127        assert_eq!(
128            cli.capture.output.as_deref().map(|p| p.to_str().unwrap()),
129            Some("/tmp/x.wav")
130        );
131        assert_eq!(
132            cli.capture.device.as_deref(),
133            Some("MacBook Pro Microphone")
134        );
135    }
136
137    #[test]
138    fn parses_idle_after_zero() {
139        let cli = TestCli::try_parse_from(["test", "--idle-after", "0"]).unwrap();
140        assert_eq!(cli.capture.idle_after, 0);
141    }
142
143    #[test]
144    fn rejects_negative_idle_after() {
145        let result = TestCli::try_parse_from(["test", "--idle-after", "-1"]);
146        assert!(result.is_err(), "negative idle-after should be rejected");
147    }
148
149    #[test]
150    fn default_output_path_uses_utc_timestamp() {
151        let path = default_output_path().unwrap();
152        let s = path.to_string_lossy();
153        assert!(s.contains(".omni-dev"));
154        assert!(s.contains("voice"));
155        assert!(s.contains("captures"));
156        assert!(s.ends_with(".wav"));
157    }
158
159    fn summary(reason: TerminationReason, written: u64, trimmed: u64) -> CaptureSummary {
160        CaptureSummary {
161            output: PathBuf::from("/tmp/out.wav"),
162            samples_written: written,
163            trimmed_samples: trimmed,
164            terminated_by: reason,
165        }
166    }
167
168    #[test]
169    fn format_summary_idle_termination_mentions_silence() {
170        let s = format_summary(&summary(TerminationReason::Idle, 16_000, 3_200));
171        assert!(s.contains("silence threshold reached"));
172        assert!(
173            s.contains("1.00s"),
174            "16000 samples @ 16kHz = 1.00s; got: {s}"
175        );
176        assert!(s.contains("16000 samples"));
177        assert!(s.contains("3200 trimmed"));
178        assert!(s.contains("/tmp/out.wav"));
179    }
180
181    #[test]
182    fn format_summary_signal_termination_mentions_ctrl_c() {
183        let s = format_summary(&summary(TerminationReason::Signal, 48_000, 0));
184        assert!(s.contains("Ctrl-C"));
185        assert!(
186            s.contains("3.00s"),
187            "48000 samples @ 16kHz = 3.00s; got: {s}"
188        );
189        assert!(s.contains("0 trimmed"));
190    }
191
192    #[test]
193    fn format_summary_source_exhausted_mentions_source() {
194        let s = format_summary(&summary(TerminationReason::SourceExhausted, 8_000, 0));
195        assert!(s.contains("audio source ended"));
196        assert!(s.contains("0.50s"));
197    }
198
199    #[test]
200    fn samples_to_seconds_zero_samples() {
201        assert!((samples_to_seconds(0) - 0.0).abs() < f64::EPSILON);
202    }
203
204    #[test]
205    fn samples_to_seconds_exact_one_second() {
206        assert!((samples_to_seconds(16_000) - 1.0).abs() < f64::EPSILON);
207    }
208
209    #[test]
210    fn samples_to_seconds_fractional() {
211        // 8000 samples @ 16kHz = 0.5s
212        assert!((samples_to_seconds(8_000) - 0.5).abs() < f64::EPSILON);
213    }
214}