Skip to main content

rust_tts_wrapper/
factory.rs

1//! Engine factory: create engines by ID and list all registered engines.
2
3use crate::engine::TtsEngine;
4use crate::types::EngineDescriptor;
5use std::sync::Arc;
6
7/// Parse engine credentials JSON into a string map, coercing JSON scalars
8/// (numbers, bools) to their string form. Fixes #58: a single
9/// `{"numSteps": 16}` (JSON number) no longer silently empties ALL
10/// credentials — it coerces to `"16"` like the engine code expects.
11#[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                // objects/arrays become their JSON repr (rarely useful,
36                // but at least visible in error messages)
37                other => other.to_string(),
38            };
39            (k, s)
40        })
41        .collect()
42}
43
44// The unused-import warning is a false positive — TtsEngine is a trait used as a dyn bound.
45#[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/// Create an engine by its string identifier.
59///
60/// `credentials_json` is a JSON object with engine-specific credentials
61/// (e.g. `{"apiKey": "..."}`). Pass `""` for engines that don't need credentials.
62#[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    // Detect engines that exist in the full catalogue but were compiled out
67    // by disabled features, so callers get a useful error rather than a
68    // generic "unknown engine". Only emit these messages when the
69    // caller actually asked for one of the gated engines.
70    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    // Cloud catch-all. If the cloud feature is on we delegate; otherwise the
202    // engine id is unknown to this build.
203    #[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/// Return the number of registered engines.
235#[must_use]
236pub fn engine_count() -> usize {
237    engine_list().len()
238}
239
240/// Return a list of all registered engine descriptors.
241#[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}