omni_dev/cli/voice/
capture.rs1use 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
14pub const DEFAULT_IDLE_AFTER_SECS: u32 = 5;
16
17#[derive(Parser)]
23pub struct CaptureCommand {
24 #[arg(long, default_value_t = DEFAULT_IDLE_AFTER_SECS)]
27 pub idle_after: u32,
28
29 #[arg(long)]
32 pub output: Option<PathBuf>,
33
34 #[arg(long)]
38 pub device: Option<String>,
39}
40
41impl CaptureCommand {
42 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
63fn 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 assert!((samples_to_seconds(8_000) - 0.5).abs() < f64::EPSILON);
213 }
214}