1use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use std::path::PathBuf;
6
7#[derive(Debug, Clone)]
14pub enum ModelSize {
15 TinyEn,
16 BaseEn,
17 SmallEn,
18 Tiny,
19 Base,
20 Small,
21}
22
23impl Default for ModelSize {
24 fn default() -> Self {
25 ModelSize::BaseEn
26 }
27}
28
29impl std::fmt::Display for ModelSize {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 ModelSize::TinyEn => write!(f, "tiny.en"),
33 ModelSize::BaseEn => write!(f, "base.en"),
34 ModelSize::SmallEn => write!(f, "small.en"),
35 ModelSize::Tiny => write!(f, "tiny"),
36 ModelSize::Base => write!(f, "base"),
37 ModelSize::Small => write!(f, "small"),
38 }
39 }
40}
41
42impl ModelSize {
43 pub fn is_multilingual(&self) -> bool {
45 matches!(self, ModelSize::Tiny | ModelSize::Base | ModelSize::Small)
46 }
47}
48
49impl std::str::FromStr for ModelSize {
50 type Err = anyhow::Error;
51
52 fn from_str(s: &str) -> Result<Self> {
53 match s {
54 "tiny.en" => Ok(ModelSize::TinyEn),
55 "base.en" => Ok(ModelSize::BaseEn),
56 "small.en" => Ok(ModelSize::SmallEn),
57 "tiny" => Ok(ModelSize::Tiny),
58 "base" => Ok(ModelSize::Base),
59 "small" => Ok(ModelSize::Small),
60 _ => Err(anyhow::anyhow!(
61 "Unknown model size: {}. Valid: tiny.en, tiny, base.en, base, small.en, small",
62 s
63 )),
64 }
65 }
66}
67
68#[derive(Parser, Debug)]
70#[command(name = "opencode-voice", about = "Voice input for OpenCode", version)]
71pub struct CliArgs {
72 #[command(subcommand)]
73 pub command: Option<Commands>,
74
75 #[arg(long, short = 'p', global = true)]
77 pub port: Option<u16>,
78
79 #[arg(long, global = true)]
81 pub device: Option<String>,
82
83 #[arg(long, short = 'm', global = true)]
85 pub model: Option<ModelSize>,
86
87 #[arg(long, short = 'k', global = true)]
89 pub key: Option<char>,
90
91 #[arg(long, global = true)]
93 pub hotkey: Option<String>,
94
95 #[arg(long = "no-global", global = true)]
97 pub no_global: bool,
98
99 #[arg(
101 long = "push-to-talk",
102 global = true,
103 overrides_with = "no_push_to_talk"
104 )]
105 pub push_to_talk: bool,
106
107 #[arg(long = "no-push-to-talk", global = true)]
109 pub no_push_to_talk: bool,
110
111 #[arg(long = "auto-submit", global = true, overrides_with = "no_auto_submit")]
113 pub auto_submit: bool,
114
115 #[arg(long = "no-auto-submit", global = true)]
117 pub no_auto_submit: bool,
118
119 #[arg(
121 long = "handle-prompts",
122 global = true,
123 overrides_with = "no_handle_prompts"
124 )]
125 pub handle_prompts: bool,
126
127 #[arg(long = "no-handle-prompts", global = true)]
129 pub no_handle_prompts: bool,
130
131 #[arg(long, global = true)]
133 pub debug: bool,
134}
135
136#[derive(Subcommand, Debug)]
137pub enum Commands {
138 Run,
140 Setup {
142 #[arg(long, short = 'm')]
144 model: Option<ModelSize>,
145 },
146 Devices,
148 Keys,
150}
151
152#[derive(Debug, Clone)]
154pub struct AppConfig {
155 pub whisper_model_path: PathBuf,
156 pub opencode_port: u16,
157 pub toggle_key: char,
158 pub model_size: ModelSize,
159 pub auto_submit: bool,
160 pub server_password: Option<String>,
161 pub data_dir: PathBuf,
162 pub audio_device: Option<String>,
163 pub use_global_hotkey: bool,
164 pub global_hotkey: String,
165 pub push_to_talk: bool,
166 pub handle_prompts: bool,
167 pub debug: bool,
168}
169
170impl AppConfig {
171 pub fn load(cli: &CliArgs) -> Result<Self> {
174 let data_dir = get_data_dir();
175
176 let port_env = std::env::var("OPENCODE_VOICE_PORT")
178 .ok()
179 .and_then(|s| s.parse::<u16>().ok());
180 let port = cli
181 .port
182 .or(port_env)
183 .or(if cli.debug { Some(0) } else { None })
184 .context("OpenCode server port is required. Use --port or set OPENCODE_VOICE_PORT")?;
185
186 let model_env = std::env::var("OPENCODE_VOICE_MODEL")
188 .ok()
189 .and_then(|s| s.parse::<ModelSize>().ok());
190 let model_size = cli.model.clone().or(model_env).unwrap_or_default();
191
192 let device_env = std::env::var("OPENCODE_VOICE_DEVICE").ok();
194 let audio_device = cli.device.clone().or(device_env);
195
196 let server_password = std::env::var("OPENCODE_SERVER_PASSWORD").ok();
198
199 let auto_submit = if cli.no_auto_submit {
201 false
202 } else if cli.auto_submit {
203 true
204 } else {
205 true
206 };
207 let push_to_talk = if cli.no_push_to_talk {
208 false
209 } else if cli.push_to_talk {
210 true
211 } else {
212 true
213 };
214 let use_global_hotkey = !cli.no_global;
215 let handle_prompts = if cli.no_handle_prompts {
216 false
217 } else if cli.handle_prompts {
218 true
219 } else {
220 true
221 };
222 let whisper_model_path = crate::transcribe::setup::get_model_path(&data_dir, &model_size);
223
224 Ok(AppConfig {
225 opencode_port: port,
226 toggle_key: cli.key.unwrap_or(' '),
227 model_size,
228 auto_submit,
229 server_password,
230 data_dir,
231 audio_device,
232 use_global_hotkey,
233 global_hotkey: cli
234 .hotkey
235 .clone()
236 .unwrap_or_else(|| "right_option".to_string()),
237 push_to_talk,
238 handle_prompts,
239 debug: cli.debug,
240 whisper_model_path,
241 })
242 }
243}
244
245pub fn get_data_dir() -> PathBuf {
250 #[cfg(target_os = "macos")]
251 {
252 dirs::data_dir()
253 .unwrap_or_else(|| {
254 dirs::home_dir()
255 .unwrap_or_else(|| PathBuf::from("."))
256 .join("Library")
257 .join("Application Support")
258 })
259 .join("opencode-voice")
260 }
261 #[cfg(not(target_os = "macos"))]
262 {
263 std::env::var("XDG_DATA_HOME")
265 .map(PathBuf::from)
266 .unwrap_or_else(|_| {
267 dirs::home_dir()
268 .unwrap_or_else(|| PathBuf::from("."))
269 .join(".local")
270 .join("share")
271 })
272 .join("opencode-voice")
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn test_model_size_display() {
282 assert_eq!(ModelSize::TinyEn.to_string(), "tiny.en");
283 assert_eq!(ModelSize::BaseEn.to_string(), "base.en");
284 assert_eq!(ModelSize::SmallEn.to_string(), "small.en");
285 }
286
287 #[test]
288 fn test_model_size_from_str() {
289 assert!(matches!(
290 "tiny.en".parse::<ModelSize>().unwrap(),
291 ModelSize::TinyEn
292 ));
293 assert!(matches!(
294 "tiny".parse::<ModelSize>().unwrap(),
295 ModelSize::Tiny
296 ));
297 assert!(matches!(
298 "base.en".parse::<ModelSize>().unwrap(),
299 ModelSize::BaseEn
300 ));
301 assert!(matches!(
302 "base".parse::<ModelSize>().unwrap(),
303 ModelSize::Base
304 ));
305 assert!(matches!(
306 "small.en".parse::<ModelSize>().unwrap(),
307 ModelSize::SmallEn
308 ));
309 assert!(matches!(
310 "small".parse::<ModelSize>().unwrap(),
311 ModelSize::Small
312 ));
313 }
314
315 #[test]
316 fn test_model_size_from_str_invalid() {
317 assert!("large".parse::<ModelSize>().is_err());
318 assert!("medium.en".parse::<ModelSize>().is_err());
319 }
320
321 #[test]
322 fn test_model_size_default() {
323 assert!(matches!(ModelSize::default(), ModelSize::BaseEn));
324 }
325
326 #[test]
327 fn test_get_data_dir_contains_app_name() {
328 let dir = get_data_dir();
329 let dir_str = dir.to_string_lossy();
330 assert!(
331 dir_str.contains("opencode-voice"),
332 "data dir should contain 'opencode-voice': {}",
333 dir_str
334 );
335 }
336
337 #[cfg(target_os = "macos")]
338 #[test]
339 fn test_get_data_dir_macos() {
340 let dir = get_data_dir();
341 let dir_str = dir.to_string_lossy();
342 assert!(
344 dir_str.contains("Library/Application Support"),
345 "macOS data dir should be under Library/Application Support: {}",
346 dir_str
347 );
348 }
349
350 #[test]
353 fn test_model_size_display_tiny_en() {
354 assert_eq!(ModelSize::TinyEn.to_string(), "tiny.en");
355 }
356
357 #[test]
358 fn test_model_size_display_base_en() {
359 assert_eq!(ModelSize::BaseEn.to_string(), "base.en");
360 }
361
362 #[test]
363 fn test_model_size_display_small_en() {
364 assert_eq!(ModelSize::SmallEn.to_string(), "small.en");
365 }
366
367 #[test]
368 fn test_model_size_fromstr_roundtrip_tiny() {
369 let s = ModelSize::TinyEn.to_string();
370 let parsed: ModelSize = s.parse().unwrap();
371 assert!(matches!(parsed, ModelSize::TinyEn));
372 }
373
374 #[test]
375 fn test_model_size_fromstr_roundtrip_base() {
376 let s = ModelSize::BaseEn.to_string();
377 let parsed: ModelSize = s.parse().unwrap();
378 assert!(matches!(parsed, ModelSize::BaseEn));
379 }
380
381 #[test]
382 fn test_model_size_fromstr_roundtrip_small() {
383 let s = ModelSize::SmallEn.to_string();
384 let parsed: ModelSize = s.parse().unwrap();
385 assert!(matches!(parsed, ModelSize::SmallEn));
386 }
387
388 #[test]
389 fn test_model_size_fromstr_short_aliases_are_multilingual() {
390 assert!(matches!(
392 "tiny".parse::<ModelSize>().unwrap(),
393 ModelSize::Tiny
394 ));
395 assert!(matches!(
396 "base".parse::<ModelSize>().unwrap(),
397 ModelSize::Base
398 ));
399 assert!(matches!(
400 "small".parse::<ModelSize>().unwrap(),
401 ModelSize::Small
402 ));
403 }
404
405 #[test]
406 fn test_model_size_is_multilingual() {
407 assert!(!ModelSize::TinyEn.is_multilingual());
408 assert!(!ModelSize::BaseEn.is_multilingual());
409 assert!(!ModelSize::SmallEn.is_multilingual());
410 assert!(ModelSize::Tiny.is_multilingual());
411 assert!(ModelSize::Base.is_multilingual());
412 assert!(ModelSize::Small.is_multilingual());
413 }
414
415 #[test]
416 fn test_model_size_fromstr_unknown_returns_error() {
417 let result = "large.en".parse::<ModelSize>();
418 assert!(result.is_err());
419 let err_msg = result.unwrap_err().to_string();
420 assert!(
421 err_msg.contains("large.en"),
422 "Error should mention the unknown value"
423 );
424 }
425
426 #[test]
427 fn test_get_data_dir_is_absolute() {
428 let dir = get_data_dir();
429 assert!(
430 dir.is_absolute(),
431 "data dir should be an absolute path: {:?}",
432 dir
433 );
434 }
435
436 #[test]
437 fn test_get_data_dir_ends_with_opencode_voice() {
438 let dir = get_data_dir();
439 let last_component = dir.file_name().unwrap().to_string_lossy();
440 assert_eq!(last_component, "opencode-voice");
441 }
442
443 #[test]
447 fn test_app_config_default_field_values() {
448 let config = AppConfig {
449 whisper_model_path: std::path::PathBuf::from("/tmp/model.bin"),
450 opencode_port: 3000,
451 toggle_key: ' ',
452 model_size: ModelSize::TinyEn,
453 auto_submit: true,
454 server_password: None,
455 data_dir: std::path::PathBuf::from("/tmp"),
456 audio_device: None,
457 use_global_hotkey: true,
458 global_hotkey: "right_option".to_string(),
459 push_to_talk: true,
460 handle_prompts: true,
461 debug: false,
462 };
463
464 assert!(config.auto_submit, "auto_submit default should be true");
465 assert!(config.push_to_talk, "push_to_talk default should be true");
466 assert!(
467 config.handle_prompts,
468 "handle_prompts default should be true"
469 );
470 assert!(
471 config.use_global_hotkey,
472 "use_global_hotkey default should be true"
473 );
474 assert_eq!(config.toggle_key, ' ', "toggle_key default should be space");
475 assert_eq!(config.global_hotkey, "right_option");
476 assert!(config.server_password.is_none());
477 assert!(config.audio_device.is_none());
478 }
479
480 #[test]
481 fn test_app_config_opencode_port() {
482 let config = AppConfig {
483 whisper_model_path: std::path::PathBuf::from("/tmp/model.bin"),
484 opencode_port: 8080,
485 toggle_key: ' ',
486 model_size: ModelSize::BaseEn,
487 auto_submit: true,
488 server_password: None,
489 data_dir: std::path::PathBuf::from("/tmp"),
490 audio_device: None,
491 use_global_hotkey: true,
492 global_hotkey: "right_option".to_string(),
493 push_to_talk: true,
494 handle_prompts: true,
495 debug: false,
496 };
497
498 assert_eq!(config.opencode_port, 8080);
499 }
500}