1use crate::engine::TtsEngine;
4use crate::types::EngineDescriptor;
5use std::sync::Arc;
6
7#[must_use]
12pub fn parse_credentials(credentials_json: &str) -> std::collections::HashMap<String, String> {
13 if credentials_json.is_empty() {
14 return std::collections::HashMap::new();
15 }
16 let parsed: std::collections::HashMap<String, serde_json::Value> =
17 match serde_json::from_str(credentials_json) {
18 Ok(v) => v,
19 Err(e) => {
20 eprintln!(
21 "factory: credentials JSON is malformed ({e}) — \
22 engine will start with no credentials"
23 );
24 return std::collections::HashMap::new();
25 }
26 };
27 parsed
28 .into_iter()
29 .map(|(k, v)| {
30 let s = match v {
31 serde_json::Value::String(s) => s,
32 serde_json::Value::Number(n) => n.to_string(),
33 serde_json::Value::Bool(b) => b.to_string(),
34 serde_json::Value::Null => String::new(),
35 other => other.to_string(),
38 };
39 (k, s)
40 })
41 .collect()
42}
43
44#[cfg(all(feature = "avsynth", target_os = "macos"))]
46use crate::avsynth_engine::AvSynthEngine;
47#[cfg(feature = "cloud")]
48use crate::cloud_engine;
49#[cfg(feature = "qwen3-local")]
50use crate::qwen3_local_engine::Qwen3LocalEngine;
51#[cfg(all(feature = "sapi", target_os = "windows"))]
52use crate::sapi_engine::SapiEngine;
53#[cfg(feature = "sherpaonnx")]
54use crate::sherpaonnx_engine::SherpaOnnxEngine;
55#[cfg(all(feature = "system", target_os = "linux"))]
56use crate::system_engine::SystemEngine;
57
58#[must_use]
63#[allow(unused_variables)]
64#[allow(clippy::too_many_lines)]
65pub fn create_engine(engine_id: &str, credentials_json: &str) -> Option<Arc<dyn TtsEngine>> {
66 match engine_id {
71 "system" => {
72 #[cfg(all(feature = "system", target_os = "linux"))]
73 {
74 return Some(Arc::new(SystemEngine::new()));
75 }
76 #[cfg(not(all(feature = "system", target_os = "linux")))]
77 {
78 eprintln!(
79 "Engine 'system' requires the 'system' feature on Linux. \
80 Current build does not satisfy these conditions."
81 );
82 return None;
83 }
84 }
85 "avsynth" => {
86 #[cfg(all(feature = "avsynth", target_os = "macos"))]
87 {
88 return Some(Arc::new(AvSynthEngine::new()));
89 }
90 #[cfg(not(all(feature = "avsynth", target_os = "macos")))]
91 {
92 eprintln!(
93 "Engine 'avsynth' requires the 'avsynth' feature and macOS. \
94 Current build does not satisfy these conditions."
95 );
96 return None;
97 }
98 }
99 "sapi" => {
100 #[cfg(all(feature = "sapi", target_os = "windows"))]
101 {
102 return Some(Arc::new(SapiEngine::new()));
103 }
104 #[cfg(not(all(feature = "sapi", target_os = "windows")))]
105 {
106 eprintln!(
107 "Engine 'sapi' requires the 'sapi' feature and Windows. \
108 Current build does not satisfy these conditions."
109 );
110 return None;
111 }
112 }
113 "sherpaonnx" => {
114 #[cfg(feature = "sherpaonnx")]
115 {
116 return Some(Arc::new(SherpaOnnxEngine::new(credentials_json)));
117 }
118 #[cfg(not(feature = "sherpaonnx"))]
119 {
120 eprintln!(
121 "Engine 'sherpaonnx' is not enabled in this build. \
122 Rebuild with --features sherpaonnx."
123 );
124 return None;
125 }
126 }
127 "qwen3-local" => {
128 #[cfg(feature = "qwen3-local")]
129 {
130 let creds = parse_credentials(credentials_json);
131 return match Qwen3LocalEngine::new(&creds) {
132 Ok(engine) => Some(Arc::new(engine)),
133 Err(e) => {
134 eprintln!("Engine 'qwen3-local' failed to load: {e}");
135 None
136 }
137 };
138 }
139 #[cfg(not(feature = "qwen3-local"))]
140 {
141 eprintln!(
142 "Engine 'qwen3-local' is not enabled in this build. \
143 Rebuild with --features qwen3-local."
144 );
145 return None;
146 }
147 }
148 "omnivoice-local" => {
149 #[cfg(feature = "omnivoice-local")]
150 {
151 use crate::omnivoice_local_engine::{OmniVoiceLocalEngine, OmniVoiceLocalParams};
152 let creds = parse_credentials(credentials_json);
153 let params = OmniVoiceLocalParams {
154 model_path: creds.get("modelPath").cloned().unwrap_or_default(),
155 codec_path: creds.get("codecPath").cloned().unwrap_or_default(),
156 num_steps: creds
157 .get("numSteps")
158 .and_then(|s| s.parse().ok())
159 .unwrap_or(32),
160 seed: creds.get("seed").and_then(|s| s.parse().ok()).unwrap_or(42),
161 instruct: creds.get("instruct").cloned(),
162 language: creds.get("language").cloned().unwrap_or_default(),
163 ..Default::default()
164 };
165 return match OmniVoiceLocalEngine::new(params) {
166 Ok(engine) => Some(Arc::new(engine)),
167 Err(e) => {
168 eprintln!("Engine 'omnivoice-local' failed to load: {e}");
169 None
170 }
171 };
172 }
173 #[cfg(not(feature = "omnivoice-local"))]
174 {
175 eprintln!(
176 "Engine 'omnivoice-local' is not enabled in this build. \
177 Rebuild with --features omnivoice-local."
178 );
179 return None;
180 }
181 }
182 "floravox" => {
183 #[cfg(feature = "floravox")]
184 {
185 return Some(Arc::new(crate::floravox_engine::FloravoxEngine::new(
186 credentials_json,
187 )));
188 }
189 #[cfg(not(feature = "floravox"))]
190 {
191 eprintln!(
192 "Engine 'floravox' is not enabled in this build. \
193 Rebuild with --features floravox."
194 );
195 return None;
196 }
197 }
198 _ => {}
199 }
200
201 #[cfg(feature = "cloud")]
204 {
205 let result = cloud_engine::create_cloud_engine(engine_id, credentials_json);
206 if result.is_none() {
207 eprintln!(
208 "Unknown engine '{engine_id}'. Available engines: {}",
209 engine_list()
210 .iter()
211 .map(|e| e.id.as_str())
212 .collect::<Vec<_>>()
213 .join(", ")
214 );
215 }
216 result
217 }
218
219 #[cfg(not(feature = "cloud"))]
220 {
221 eprintln!(
222 "Unknown engine '{engine_id}' (cloud feature is disabled; only \
223 built-in engines are available). Available engines: {}",
224 engine_list()
225 .iter()
226 .map(|e| e.id.as_str())
227 .collect::<Vec<_>>()
228 .join(", ")
229 );
230 None
231 }
232}
233
234#[must_use]
236pub fn engine_count() -> usize {
237 engine_list().len()
238}
239
240#[must_use]
242#[allow(clippy::vec_init_then_push)]
243#[allow(clippy::too_many_lines)]
244pub fn engine_list() -> Vec<EngineDescriptor> {
245 #[allow(unused_mut)]
246 let mut engines = Vec::new();
247
248 #[cfg(all(feature = "system", target_os = "linux"))]
249 engines.push(EngineDescriptor {
250 id: "system".into(),
251 name: "System (Speech Dispatcher)".into(),
252 needs_credentials: false,
253 credential_keys_json: "[]".into(),
254 });
255
256 #[cfg(all(feature = "avsynth", target_os = "macos"))]
257 engines.push(EngineDescriptor {
258 id: "avsynth".into(),
259 name: "macOS AVSpeechSynthesizer".into(),
260 needs_credentials: false,
261 credential_keys_json: "[]".into(),
262 });
263
264 #[cfg(all(feature = "sapi", target_os = "windows"))]
265 engines.push(EngineDescriptor {
266 id: "sapi".into(),
267 name: "Windows SAPI".into(),
268 needs_credentials: false,
269 credential_keys_json: "[]".into(),
270 });
271
272 #[cfg(feature = "sherpaonnx")]
273 engines.push(EngineDescriptor {
274 id: "sherpaonnx".into(),
275 name: "Sherpa-ONNX".into(),
276 needs_credentials: false,
277 credential_keys_json: "[]".into(),
278 });
279
280 #[cfg(feature = "qwen3-local")]
281 engines.push(EngineDescriptor {
282 id: "qwen3-local".into(),
283 name: "Qwen3-TTS local (qwen3-tts.cpp)".into(),
284 needs_credentials: true,
285 credential_keys_json: r#"["modelsDir","threads","temperature","topK","maxTokens"]"#.into(),
286 });
287
288 #[cfg(feature = "floravox")]
289 engines.push(EngineDescriptor {
290 id: "floravox".into(),
291 name: "floravox".into(),
292 needs_credentials: true,
293 credential_keys_json: r#"["modelsDir","modelId","misaki","chars","speaker"]"#.into(),
294 });
295
296 #[cfg(feature = "omnivoice-local")]
297 engines.push(EngineDescriptor {
298 id: "omnivoice-local".into(),
299 name: "OmniVoice local (600+ languages, offline)".into(),
300 needs_credentials: true,
301 credential_keys_json:
302 r#"["modelPath","codecPath","numSteps","seed","instruct","language"]"#.into(),
303 });
304
305 #[cfg(feature = "pocket-timing")]
306 engines.push(EngineDescriptor {
307 id: "pocket".into(),
308 name: "Pocket TTS (attention-timed, phoneme-driven)".into(),
309 needs_credentials: true,
310 credential_keys_json: r#"["bundleDir","reference"]"#.into(),
311 });
312
313 #[cfg(feature = "cloud")]
314 {
315 let cloud = [
316 ("openai", "OpenAI", true, r#"["apiKey"]"#),
317 (
318 "elevenlabs",
319 "ElevenLabs",
320 true,
321 r#"["apiKey","modelId","voiceId","language"]"#,
322 ),
323 ("azure", "Azure", true, r#"["subscriptionKey","region"]"#),
324 ("edge", "Microsoft Edge (Read Aloud)", false, "[]"),
325 ("google", "Google Cloud", true, r#"["apiKey"]"#),
326 (
327 "gemini",
328 "Google Gemini (3.8 TTS)",
329 true,
330 r#"["apiKey","modelId","voice","style"]"#,
331 ),
332 (
333 "qwen",
334 "Qwen (Alibaba Cloud Model Studio)",
335 true,
336 r#"["apiKey","modelId","region","instruction","wsUrl","customizationUrl"]"#,
337 ),
338 (
339 "polly",
340 "Amazon Polly",
341 true,
342 r#"["accessKeyId","secretAccessKey","region"]"#,
343 ),
344 ("cartesia", "Cartesia", true, r#"["apiKey"]"#),
345 ("deepgram", "Deepgram", true, r#"["apiKey"]"#),
346 ("playht", "PlayHT", true, r#"["apiKey","userId"]"#),
347 ("fishaudio", "Fish Audio", true, r#"["apiKey"]"#),
348 ("hume", "Hume AI", true, r#"["apiKey"]"#),
349 ("mistral", "Mistral", true, r#"["apiKey"]"#),
350 ("murf", "Murf", true, r#"["apiKey"]"#),
351 ("resemble", "Resemble AI", true, r#"["apiKey"]"#),
352 ("unrealspeech", "Unreal Speech", true, r#"["apiKey"]"#),
353 ("upliftai", "UpliftAI", true, r#"["apiKey"]"#),
354 (
355 "watson",
356 "IBM Watson",
357 true,
358 r#"["apiKey","region","instanceId"]"#,
359 ),
360 ("witai", "Wit.ai", true, r#"["token"]"#),
361 ("xai", "xAI", true, r#"["apiKey"]"#),
362 ("modelslab", "ModelsLab", true, r#"["apiKey"]"#),
363 ];
364 for (id, name, creds, keys) in &cloud {
365 engines.push(EngineDescriptor {
366 id: (*id).into(),
367 name: (*name).into(),
368 needs_credentials: *creds,
369 credential_keys_json: (*keys).into(),
370 });
371 }
372 }
373
374 engines
375}
376
377#[cfg(test)]
378mod credentials_tests {
379 use super::parse_credentials;
380
381 #[test]
382 fn json_numbers_coerce_to_strings() {
383 let creds = parse_credentials(r#"{"modelPath":"/m.gguf","numSteps":16,"seed":42}"#);
384 assert_eq!(creds.get("modelPath").map(String::as_str), Some("/m.gguf"));
385 assert_eq!(creds.get("numSteps").map(String::as_str), Some("16"));
386 assert_eq!(creds.get("seed").map(String::as_str), Some("42"));
387 }
388
389 #[test]
390 fn json_bools_coerce() {
391 let creds = parse_credentials(r#"{"verbose":true,"fast":false}"#);
392 assert_eq!(creds.get("verbose").map(String::as_str), Some("true"));
393 assert_eq!(creds.get("fast").map(String::as_str), Some("false"));
394 }
395
396 #[test]
397 fn malformed_json_warns_not_silences() {
398 let creds = parse_credentials(r#"{"broken": tru}"#);
399 assert!(creds.is_empty());
400 }
401
402 #[test]
403 fn empty_input_is_empty_map() {
404 assert!(parse_credentials("").is_empty());
405 }
406
407 #[test]
408 fn mixed_types_all_coerce() {
409 let creds =
410 parse_credentials(r#"{"path":"/a/b","count":3,"ratio":0.5,"flag":true,"name":"test"}"#);
411 assert_eq!(creds.len(), 5);
412 assert_eq!(creds.get("count").map(String::as_str), Some("3"));
413 assert_eq!(creds.get("ratio").map(String::as_str), Some("0.5"));
414 }
415}