1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::session::layout::SessionSource;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum AudioSessionKind {
18 Input,
19 Output,
20 App,
21}
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct SourceSettingsOverride {
29 pub cloud_on: bool,
30 pub language: String,
31 pub model: String,
32}
33
34pub fn source_id(source: &SessionSource, kind: Option<AudioSessionKind>) -> String {
38 match source {
39 SessionSource::Microphone => format!(
40 "device:{}:default",
41 kind_str(kind.unwrap_or(AudioSessionKind::Input))
42 ),
43 SessionSource::System => format!(
44 "device:{}:default",
45 kind_str(kind.unwrap_or(AudioSessionKind::Output))
46 ),
47 SessionSource::App {
48 name, device_name, ..
49 } => {
50 if device_name.is_empty() {
51 format!("app:{name}")
52 } else {
53 format!("app:{name}@device:{device_name}")
54 }
55 }
56 }
57}
58
59pub fn device_source_id(name: &str, kind: AudioSessionKind) -> String {
64 format!("device:{}:{}", kind_str(kind), name)
65}
66
67fn kind_str(kind: AudioSessionKind) -> &'static str {
68 match kind {
69 AudioSessionKind::Input => "input",
70 AudioSessionKind::Output => "output",
71 AudioSessionKind::App => "app",
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct AppConfig {
77 pub default_model: String,
78 pub language: String,
79 pub session_dir: String,
80 pub hop_ms: u32,
81 pub min_window_ms: u32,
82 #[serde(rename = "engine_prefer")]
83 pub engine_prefer: String,
84 pub audio_default_source: String,
85 #[serde(default)]
88 pub input_device: Option<String>,
89 #[serde(default)]
93 pub input_device_kind: Option<AudioSessionKind>,
94 #[serde(default)]
98 pub last_app_id: Option<String>,
99 #[serde(default)]
104 pub refinement_model: Option<String>,
105 #[serde(default = "default_refinement_window_ms")]
107 pub refinement_window_ms: u32,
108 #[serde(default = "default_refinement_beam_size")]
110 pub refinement_beam_size: u8,
111 #[serde(default)]
114 pub voicebird_api_key: String,
115
116 #[serde(default = "default_voicebird_server_url")]
120 pub voicebird_server_url: String,
121
122 #[serde(default)]
129 pub cloud_broadcast_enabled: bool,
130
131 #[serde(default)]
137 pub source_overrides: BTreeMap<String, SourceSettingsOverride>,
138}
139
140fn default_voicebird_server_url() -> String {
141 "wss://voicebird.app/api/audio/stream".into()
142}
143
144fn default_refinement_window_ms() -> u32 {
145 20_000
146}
147
148fn default_refinement_beam_size() -> u8 {
149 5
150}
151
152impl Default for AppConfig {
153 fn default() -> Self {
154 Self {
155 default_model: "distil-small.en".into(),
156 language: "en".into(),
157 session_dir: "~/voice-bird/sessions".into(),
158 hop_ms: 750,
159 min_window_ms: 1000,
160 engine_prefer: "auto".into(),
161 audio_default_source: "microphone".into(),
162 input_device: None,
163 input_device_kind: None,
164 last_app_id: None,
165 refinement_model: None,
166 refinement_window_ms: default_refinement_window_ms(),
167 refinement_beam_size: default_refinement_beam_size(),
168 voicebird_api_key: String::new(),
169 voicebird_server_url: default_voicebird_server_url(),
170 cloud_broadcast_enabled: false,
171 source_overrides: BTreeMap::new(),
172 }
173 }
174}
175
176impl AppConfig {
177 pub fn config_path() -> anyhow::Result<PathBuf> {
178 let base = dirs::config_dir().ok_or_else(|| anyhow::anyhow!("no config dir"))?;
179 Ok(base.join("voice-bird").join("config.toml"))
180 }
181
182 pub fn load() -> anyhow::Result<Self> {
183 let path = Self::config_path()?;
184 if path.exists() {
185 Self::load_from(&path)
186 } else {
187 Ok(Self::default())
188 }
189 }
190
191 pub fn save(&self) -> anyhow::Result<()> {
192 let path = Self::config_path()?;
193 self.save_to(&path)
194 }
195
196 pub fn load_from(path: &Path) -> anyhow::Result<Self> {
197 let s = std::fs::read_to_string(path)?;
198 Ok(toml::from_str(&s)?)
199 }
200
201 pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
202 if let Some(p) = path.parent() {
203 std::fs::create_dir_all(p)?;
204 }
205 let body = toml::to_string_pretty(self)?;
206 let out = if self.voicebird_api_key.is_empty() {
207 body
208 } else {
209 format!("# Contains secrets. Do not share.\n{body}")
210 };
211 std::fs::write(path, out)?;
212 #[cfg(unix)]
213 {
214 use std::os::unix::fs::PermissionsExt;
215 let perms = std::fs::Permissions::from_mode(0o600);
216 let _ = std::fs::set_permissions(path, perms);
219 }
220 Ok(())
221 }
222
223 pub fn session_dir_expanded(&self) -> String {
224 if let Some(rest) = self.session_dir.strip_prefix("~/") {
225 if let Some(home) = dirs::home_dir() {
226 return home.join(rest).to_string_lossy().into_owned();
227 }
228 }
229 self.session_dir.clone()
230 }
231
232 pub fn effective_override(&self, key: &str) -> SourceSettingsOverride {
237 if let Some(o) = self.source_overrides.get(key) {
238 return o.clone();
239 }
240 SourceSettingsOverride {
241 cloud_on: self.cloud_broadcast_enabled,
242 language: self.language.clone(),
243 model: self.default_model.clone(),
244 }
245 }
246
247 pub fn upsert_source_override(&mut self, key: String, ov: SourceSettingsOverride) {
249 self.source_overrides.insert(key, ov);
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use tempfile::TempDir;
257
258 #[test]
259 fn defaults_when_file_missing() {
260 let c = AppConfig::default();
261 assert_eq!(c.default_model, "distil-small.en");
262 assert_eq!(c.hop_ms, 750);
263 assert_eq!(c.engine_prefer, "auto");
264 }
265
266 #[test]
267 fn voicebird_api_key_roundtrips_through_toml() {
268 let dir = TempDir::new().unwrap();
269 let path = dir.path().join("config.toml");
270 let c = AppConfig {
271 voicebird_api_key: "vb-fake-12345".into(),
272 ..AppConfig::default()
273 };
274 c.save_to(&path).unwrap();
275 let loaded = AppConfig::load_from(&path).unwrap();
276 assert_eq!(loaded.voicebird_api_key, "vb-fake-12345");
277 }
278
279 #[test]
280 fn missing_voicebird_fields_deserialize_to_defaults() {
281 let dir = TempDir::new().unwrap();
282 let path = dir.path().join("config.toml");
283 std::fs::write(
285 &path,
286 r#"
287default_model = "distil-small.en"
288language = "en"
289session_dir = "~/voice-bird/sessions"
290hop_ms = 750
291min_window_ms = 1000
292engine_prefer = "auto"
293audio_default_source = "microphone"
294refinement_window_ms = 20000
295refinement_beam_size = 5
296"#,
297 )
298 .unwrap();
299 let loaded = AppConfig::load_from(&path).unwrap();
300 assert_eq!(loaded.voicebird_api_key, "");
301 assert_eq!(loaded.voicebird_server_url, default_voicebird_server_url());
302 assert!(!loaded.cloud_broadcast_enabled);
303 }
304
305 #[test]
306 #[cfg(unix)]
307 fn save_sets_0600_permissions() {
308 use std::os::unix::fs::PermissionsExt;
309 let dir = TempDir::new().unwrap();
310 let path = dir.path().join("config.toml");
311 let c = AppConfig::default();
312 c.save_to(&path).unwrap();
313 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
314 assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
315 }
316
317 #[test]
318 fn save_with_secret_prepends_warning_comment() {
319 let dir = TempDir::new().unwrap();
320 let path = dir.path().join("config.toml");
321 let c = AppConfig {
322 voicebird_api_key: "vb-secret".into(),
323 ..AppConfig::default()
324 };
325 c.save_to(&path).unwrap();
326 let text = std::fs::read_to_string(&path).unwrap();
327 assert!(
328 text.starts_with("# Contains secrets. Do not share.\n"),
329 "missing warning header; file was:\n{text}",
330 );
331 }
332
333 #[test]
334 fn save_without_secret_has_no_warning_comment() {
335 let dir = TempDir::new().unwrap();
336 let path = dir.path().join("config.toml");
337 let c = AppConfig::default(); c.save_to(&path).unwrap();
339 let text = std::fs::read_to_string(&path).unwrap();
340 assert!(!text.contains("Contains secrets"));
341 }
342
343 #[test]
344 fn roundtrip_through_toml() {
345 let dir = TempDir::new().unwrap();
346 let path = dir.path().join("config.toml");
347 let c = AppConfig {
348 default_model: "large-v3-turbo".into(),
349 language: "auto".into(),
350 session_dir: "~/foo".into(),
351 hop_ms: 600,
352 min_window_ms: 800,
353 engine_prefer: "whisperkit".into(),
354 audio_default_source: "system".into(),
355 input_device: Some("MacBook Pro Microphone".into()),
356 input_device_kind: Some(AudioSessionKind::Input),
357 last_app_id: Some("us.zoom.xos".into()),
358 refinement_model: Some("large-v3-turbo".into()),
359 refinement_window_ms: 20_000,
360 refinement_beam_size: 5,
361 voicebird_api_key: "vb-test".into(),
362 voicebird_server_url: "wss://example.test/api/audio/stream".into(),
363 cloud_broadcast_enabled: true,
364 source_overrides: BTreeMap::new(),
365 };
366 c.save_to(&path).unwrap();
367 let loaded = AppConfig::load_from(&path).unwrap();
368 assert_eq!(loaded, c);
369 }
370
371 #[test]
372 fn effective_override_falls_back_to_globals_when_unset() {
373 let mut c = AppConfig::default();
374 c.cloud_broadcast_enabled = true;
375 c.language = "ru".into();
376 c.default_model = "tiny.en".into();
377 let eff = c.effective_override("device:input:not-saved");
378 assert!(eff.cloud_on);
379 assert_eq!(eff.language, "ru");
380 assert_eq!(eff.model, "tiny.en");
381 }
382
383 #[test]
384 fn effective_override_uses_saved_entry_when_present() {
385 let mut c = AppConfig::default();
386 c.cloud_broadcast_enabled = false;
388 c.language = "en".into();
389 c.default_model = "tiny.en".into();
390 c.source_overrides.insert(
392 "device:input:EPOS PC 8 USB".into(),
393 SourceSettingsOverride {
394 cloud_on: true,
395 language: "pl".into(),
396 model: "base.en".into(),
397 },
398 );
399 let eff = c.effective_override("device:input:EPOS PC 8 USB");
400 assert!(eff.cloud_on);
401 assert_eq!(eff.language, "pl");
402 assert_eq!(eff.model, "base.en");
403 let other = c.effective_override("device:input:Other");
405 assert!(!other.cloud_on);
406 }
407
408 #[test]
409 fn source_id_distinguishes_kind_and_app_variants() {
410 let mic = source_id(&SessionSource::Microphone, Some(AudioSessionKind::Input));
412 assert_eq!(mic, "device:input:default");
413 let sys = source_id(&SessionSource::System, Some(AudioSessionKind::Output));
415 assert_eq!(sys, "device:output:default");
416 let zoom = source_id(
418 &SessionSource::App {
419 id: "us.zoom.xos".into(),
420 name: "Zoom".into(),
421 device_name: "MacBook Pro Speakers".into(),
422 },
423 None,
424 );
425 assert_eq!(zoom, "app:Zoom@device:MacBook Pro Speakers");
426 let epos = device_source_id("EPOS PC 8 USB", AudioSessionKind::Input);
428 assert_eq!(epos, "device:input:EPOS PC 8 USB");
429 }
430
431 #[test]
432 fn app_source_key_separates_same_app_on_different_devices() {
433 let zoom_speakers = source_id(
434 &SessionSource::App {
435 id: "us.zoom.xos".into(),
436 name: "Zoom".into(),
437 device_name: "MacBook Pro Speakers".into(),
438 },
439 None,
440 );
441 let zoom_airpods = source_id(
442 &SessionSource::App {
443 id: "us.zoom.xos".into(),
444 name: "Zoom".into(),
445 device_name: "AirPods Pro".into(),
446 },
447 None,
448 );
449 assert_ne!(zoom_speakers, zoom_airpods);
450 }
451
452 #[test]
453 fn source_overrides_round_trip_through_toml() {
454 let dir = TempDir::new().unwrap();
455 let path = dir.path().join("config.toml");
456 let mut c = AppConfig::default();
457 c.source_overrides.insert(
458 "device:input:EPOS PC 8 USB".into(),
459 SourceSettingsOverride {
460 cloud_on: true,
461 language: "pl".into(),
462 model: "base.en".into(),
463 },
464 );
465 c.source_overrides.insert(
466 "app:Zoom".into(),
467 SourceSettingsOverride {
468 cloud_on: false,
469 language: "en".into(),
470 model: "tiny.en".into(),
471 },
472 );
473 c.save_to(&path).unwrap();
474 let loaded = AppConfig::load_from(&path).unwrap();
475 assert_eq!(loaded.source_overrides.len(), 2);
476 assert_eq!(
477 loaded.source_overrides["device:input:EPOS PC 8 USB"].language,
478 "pl"
479 );
480 assert_eq!(loaded.source_overrides["app:Zoom"].model, "tiny.en");
481 }
482}