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 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}