Skip to main content

voice_bird_cli/
config.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::session::layout::SessionSource;
7
8/// Lives in lib so both `platform::AudioSession` (in the bin crate) and
9/// `AppConfig` (in the lib crate) can reference it.
10///
11/// `App` is per-application audio capture (ScreenCaptureKit on macOS,
12/// WASAPI process loopback on Windows). On those platforms the session's
13/// `device_name` carries the bundle identifier (or PID-stringified
14/// fallback) and `app_name` carries the human-readable label.
15#[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/// Settings that can be overridden per source. Stored in
24/// `AppConfig::source_overrides` keyed by `source_id`. When a section
25/// starts, the effective settings are computed by merging the saved
26/// override (if any) over the global defaults.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct SourceSettingsOverride {
29    pub cloud_on: bool,
30    pub language: String,
31    pub model: String,
32}
33
34/// Stable identifier for a source used to key `source_overrides`. Format:
35/// `device:input:<name>` / `device:output:<name>` /
36/// `app:<bundle-or-name>@device:<device>`.
37pub 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
59/// Variant of [`source_id`] keyed by an explicit device name. Preferred
60/// over [`source_id`] when the actual selected device is known (the
61/// generic Microphone/System variants collapse multiple devices to
62/// `default`, which would conflate per-device overrides).
63pub 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    /// Device name chosen by the user. `None` = use the OS default
86    /// input. Missing-from-config (old configs) deserializes to `None`.
87    #[serde(default)]
88    pub input_device: Option<String>,
89    /// Kind of the saved device. `None` for old configs / default input.
90    /// Lets `start_recording` pick the right capture path without having
91    /// to re-enumerate and match by name first.
92    #[serde(default)]
93    pub input_device_kind: Option<AudioSessionKind>,
94    /// Last app the picker's Apps pane was on. `None` = no app paired.
95    /// Bundle id (macOS) or PID-stringified value (Windows). Restored at
96    /// next launch so the user's app cursor lands where they left it.
97    #[serde(default)]
98    pub last_app_id: Option<String>,
99    /// Optional background refinement model. When set, a second whisper
100    /// engine runs in parallel on wider non-overlapping windows with beam
101    /// search and emits higher-quality segments that replace the streaming
102    /// output in the UI. `None` disables refinement.
103    #[serde(default)]
104    pub refinement_model: Option<String>,
105    /// Window length (ms) of audio fed to each refinement pass.
106    #[serde(default = "default_refinement_window_ms")]
107    pub refinement_window_ms: u32,
108    /// Beam size for refinement. 1 = greedy (fastest, lowest quality).
109    #[serde(default = "default_refinement_beam_size")]
110    pub refinement_beam_size: u8,
111    /// Voice Bird Web API key. Stored in plaintext in config.toml — file
112    /// permissions are the only protection. Empty string = unset.
113    #[serde(default)]
114    pub voicebird_api_key: String,
115
116    /// WebSocket URL of the Voice Bird Web `/api/audio/stream` endpoint
117    /// the desktop client streams to when `cloud_broadcast_enabled` is
118    /// true. Defaults to the hosted production server.
119    #[serde(default = "default_voicebird_server_url")]
120    pub voicebird_server_url: String,
121
122    /// When true, recordings stream PCM to `voicebird_server_url` and
123    /// transcripts are produced by the cloud model. The local Whisper
124    /// engine is bypassed and no session files are written to disk —
125    /// the recording lives entirely on the user's voicebird.app
126    /// account. When false (default), the local engine runs and writes
127    /// `~/voice-bird/sessions/<ts>/`.
128    #[serde(default)]
129    pub cloud_broadcast_enabled: bool,
130
131    /// Per-source setting overrides. Key is the result of [`source_id`]
132    /// or [`device_source_id`]; value carries the cloud/language/model
133    /// to use when starting a section for that source. When absent for
134    /// a given source, [`effective_settings`] falls back to the global
135    /// fields above.
136    #[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            // Best-effort: non-fatal if setting perms fails (e.g., on a
217            // filesystem that doesn't support Unix modes).
218            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    /// Effective per-source settings: the saved override for `key` if
233    /// present, else the global defaults from this config. The returned
234    /// override carries the `cloud_on`, `language`, `model` triple a
235    /// section actually uses at start time.
236    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    /// Convenience: persist or update one source's override and save.
248    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        // Write an old-style config without the new fields.
284        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(); // empty api key
338        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        // Globals: local + en.
387        c.cloud_broadcast_enabled = false;
388        c.language = "en".into();
389        c.default_model = "tiny.en".into();
390        // Override for the EPOS device: cloud + Polish + base.en.
391        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        // Other devices still get global defaults.
404        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        // Microphone with explicit Input kind:
411        let mic = source_id(&SessionSource::Microphone, Some(AudioSessionKind::Input));
412        assert_eq!(mic, "device:input:default");
413        // System with explicit Output kind:
414        let sys = source_id(&SessionSource::System, Some(AudioSessionKind::Output));
415        assert_eq!(sys, "device:output:default");
416        // App variant ignores the kind argument; key includes device pairing.
417        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        // Device-specific keys via device_source_id:
427        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}