Skip to main content

speakers_core/
config.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7use crate::lang::{DEFAULT_LANGUAGE, DEFAULT_PRESET_VOICE};
8use crate::model::ModelVariant;
9use crate::paths;
10use crate::protocol::VoiceSelection;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default)]
14pub struct Config {
15    pub daemon: DaemonConfig,
16    pub speech_dispatcher: SpeechDispatcherConfig,
17}
18
19impl Default for Config {
20    fn default() -> Self {
21        Self {
22            daemon: DaemonConfig::default(),
23            speech_dispatcher: SpeechDispatcherConfig::default(),
24        }
25    }
26}
27
28impl Config {
29    pub fn load_or_create() -> Result<Self> {
30        let path = paths::existing_config_path().unwrap_or_else(paths::config_path);
31        if !path.exists() {
32            let cfg = Self::default();
33            cfg.save()?;
34            return Ok(cfg);
35        }
36
37        Self::load_from_path(&path)
38    }
39
40    pub fn save(&self) -> Result<()> {
41        let path = paths::config_path();
42        paths::ensure_parent(&path)?;
43        let body = toml::to_string_pretty(self).context("failed to serialize config")?;
44        std::fs::write(&path, body)
45            .with_context(|| format!("failed to write config: {}", path.display()))
46    }
47
48    fn load_from_path(path: &std::path::Path) -> Result<Self> {
49        let raw = std::fs::read_to_string(path)
50            .with_context(|| format!("failed to read config: {}", path.display()))?;
51        toml::from_str::<Self>(&raw)
52            .with_context(|| format!("failed to parse config: {}", path.display()))
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(default)]
58pub struct DaemonConfig {
59    pub model: ModelVariant,
60    pub socket_path: Option<PathBuf>,
61    pub request_timeout_ms: u64,
62    pub synthesis_timeout_ms: u64,
63}
64
65impl Default for DaemonConfig {
66    fn default() -> Self {
67        Self {
68            model: ModelVariant::CustomVoice,
69            socket_path: None,
70            request_timeout_ms: 60_000,
71            synthesis_timeout_ms: 90_000,
72        }
73    }
74}
75
76impl DaemonConfig {
77    pub fn resolved_socket_path(&self) -> PathBuf {
78        self.socket_path.clone().unwrap_or_else(paths::socket_path)
79    }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(default)]
84pub struct SpeechDispatcherConfig {
85    pub playback_command: Option<String>,
86    pub default_language: String,
87    pub default_symbolic_voice: String,
88    pub allow_icl: bool,
89    pub fallback_profile: Option<String>,
90    pub voice_map: BTreeMap<String, String>,
91}
92
93impl Default for SpeechDispatcherConfig {
94    fn default() -> Self {
95        let mut voice_map = BTreeMap::new();
96        voice_map.insert("MALE1".to_string(), "preset:ryan".to_string());
97        voice_map.insert("MALE2".to_string(), "preset:aiden".to_string());
98        voice_map.insert("MALE3".to_string(), "preset:dylan".to_string());
99        voice_map.insert("FEMALE1".to_string(), "preset:vivian".to_string());
100        voice_map.insert("FEMALE2".to_string(), "preset:serena".to_string());
101        voice_map.insert("FEMALE3".to_string(), "preset:sohee".to_string());
102        voice_map.insert("CHILD_MALE".to_string(), "preset:eric".to_string());
103        voice_map.insert("CHILD_FEMALE".to_string(), "preset:ono_anna".to_string());
104
105        Self {
106            playback_command: None,
107            default_language: DEFAULT_LANGUAGE.to_string(),
108            default_symbolic_voice: "MALE1".to_string(),
109            allow_icl: false,
110            fallback_profile: None,
111            voice_map,
112        }
113    }
114}
115
116impl SpeechDispatcherConfig {
117    pub fn resolve_voice_selection(&self, symbolic_voice: Option<&str>) -> Result<VoiceSelection> {
118        let key = symbolic_voice
119            .map(str::trim)
120            .filter(|v| !v.is_empty())
121            .unwrap_or(&self.default_symbolic_voice)
122            .to_ascii_uppercase();
123
124        let binding = self
125            .voice_map
126            .get(&key)
127            .map(|s| s.as_str())
128            .unwrap_or(DEFAULT_PRESET_VOICE);
129
130        parse_voice_binding(binding)
131    }
132
133    pub fn fallback_voice_selection(&self) -> Option<VoiceSelection> {
134        self.fallback_profile
135            .as_deref()
136            .map(str::trim)
137            .filter(|s| !s.is_empty())
138            .map(VoiceSelection::profile)
139    }
140}
141
142pub fn parse_voice_binding(binding: &str) -> Result<VoiceSelection> {
143    let trimmed = binding.trim();
144    if let Some(name) = trimmed.strip_prefix("preset:") {
145        return Ok(VoiceSelection::preset(name.trim().to_string()));
146    }
147
148    if let Some(name) = trimmed.strip_prefix("profile:") {
149        return Ok(VoiceSelection::profile(name.trim().to_string()));
150    }
151
152    // Backward-compatible shorthand: bare voice means preset.
153    Ok(VoiceSelection::preset(trimmed.to_string()))
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn map_profile_binding() {
162        let resolved = parse_voice_binding("profile:my_voice").expect("parse");
163        match resolved {
164            VoiceSelection::Profile { name } => assert_eq!(name, "my_voice"),
165            _ => panic!("unexpected voice kind"),
166        }
167    }
168
169    #[test]
170    fn map_preset_binding() {
171        let resolved = parse_voice_binding("preset:ryan").expect("parse");
172        match resolved {
173            VoiceSelection::Preset { name } => assert_eq!(name, "ryan"),
174            _ => panic!("unexpected voice kind"),
175        }
176    }
177
178    #[test]
179    fn fallback_profile_maps_to_profile_voice() {
180        let cfg = SpeechDispatcherConfig {
181            fallback_profile: Some("backup_xvec".to_string()),
182            ..SpeechDispatcherConfig::default()
183        };
184
185        let selection = cfg.fallback_voice_selection().expect("fallback voice");
186        match selection {
187            VoiceSelection::Profile { name } => assert_eq!(name, "backup_xvec"),
188            _ => panic!("unexpected fallback voice kind"),
189        }
190    }
191
192    #[test]
193    fn empty_fallback_profile_is_ignored() {
194        let cfg = SpeechDispatcherConfig {
195            fallback_profile: Some("   ".to_string()),
196            ..SpeechDispatcherConfig::default()
197        };
198
199        assert!(cfg.fallback_voice_selection().is_none());
200    }
201}