use crate::engine::TtsEngine;
use crate::types::EngineDescriptor;
use std::sync::Arc;
#[must_use]
pub fn parse_credentials(credentials_json: &str) -> std::collections::HashMap<String, String> {
if credentials_json.is_empty() {
return std::collections::HashMap::new();
}
let parsed: std::collections::HashMap<String, serde_json::Value> =
match serde_json::from_str(credentials_json) {
Ok(v) => v,
Err(e) => {
eprintln!(
"factory: credentials JSON is malformed ({e}) — \
engine will start with no credentials"
);
return std::collections::HashMap::new();
}
};
parsed
.into_iter()
.map(|(k, v)| {
let s = match v {
serde_json::Value::String(s) => s,
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
};
(k, s)
})
.collect()
}
#[cfg(all(feature = "avsynth", target_os = "macos"))]
use crate::avsynth_engine::AvSynthEngine;
#[cfg(feature = "cloud")]
use crate::cloud_engine;
#[cfg(feature = "qwen3-local")]
use crate::qwen3_local_engine::Qwen3LocalEngine;
#[cfg(all(feature = "sapi", target_os = "windows"))]
use crate::sapi_engine::SapiEngine;
#[cfg(feature = "sherpaonnx")]
use crate::sherpaonnx_engine::SherpaOnnxEngine;
#[cfg(all(feature = "system", target_os = "linux"))]
use crate::system_engine::SystemEngine;
#[must_use]
#[allow(unused_variables)]
#[allow(clippy::too_many_lines)]
pub fn create_engine(engine_id: &str, credentials_json: &str) -> Option<Arc<dyn TtsEngine>> {
match engine_id {
"system" => {
#[cfg(all(feature = "system", target_os = "linux"))]
{
return Some(Arc::new(SystemEngine::new()));
}
#[cfg(not(all(feature = "system", target_os = "linux")))]
{
eprintln!(
"Engine 'system' requires the 'system' feature on Linux. \
Current build does not satisfy these conditions."
);
return None;
}
}
"avsynth" => {
#[cfg(all(feature = "avsynth", target_os = "macos"))]
{
return Some(Arc::new(AvSynthEngine::new()));
}
#[cfg(not(all(feature = "avsynth", target_os = "macos")))]
{
eprintln!(
"Engine 'avsynth' requires the 'avsynth' feature and macOS. \
Current build does not satisfy these conditions."
);
return None;
}
}
"sapi" => {
#[cfg(all(feature = "sapi", target_os = "windows"))]
{
return Some(Arc::new(SapiEngine::new()));
}
#[cfg(not(all(feature = "sapi", target_os = "windows")))]
{
eprintln!(
"Engine 'sapi' requires the 'sapi' feature and Windows. \
Current build does not satisfy these conditions."
);
return None;
}
}
"sherpaonnx" => {
#[cfg(feature = "sherpaonnx")]
{
return Some(Arc::new(SherpaOnnxEngine::new(credentials_json)));
}
#[cfg(not(feature = "sherpaonnx"))]
{
eprintln!(
"Engine 'sherpaonnx' is not enabled in this build. \
Rebuild with --features sherpaonnx."
);
return None;
}
}
"qwen3-local" => {
#[cfg(feature = "qwen3-local")]
{
let creds = parse_credentials(credentials_json);
return match Qwen3LocalEngine::new(&creds) {
Ok(engine) => Some(Arc::new(engine)),
Err(e) => {
eprintln!("Engine 'qwen3-local' failed to load: {e}");
None
}
};
}
#[cfg(not(feature = "qwen3-local"))]
{
eprintln!(
"Engine 'qwen3-local' is not enabled in this build. \
Rebuild with --features qwen3-local."
);
return None;
}
}
"omnivoice-local" => {
#[cfg(feature = "omnivoice-local")]
{
use crate::omnivoice_local_engine::{OmniVoiceLocalEngine, OmniVoiceLocalParams};
let creds = parse_credentials(credentials_json);
let params = OmniVoiceLocalParams {
model_path: creds.get("modelPath").cloned().unwrap_or_default(),
codec_path: creds.get("codecPath").cloned().unwrap_or_default(),
num_steps: creds
.get("numSteps")
.and_then(|s| s.parse().ok())
.unwrap_or(32),
seed: creds.get("seed").and_then(|s| s.parse().ok()).unwrap_or(42),
instruct: creds.get("instruct").cloned(),
language: creds.get("language").cloned().unwrap_or_default(),
..Default::default()
};
return match OmniVoiceLocalEngine::new(params) {
Ok(engine) => Some(Arc::new(engine)),
Err(e) => {
eprintln!("Engine 'omnivoice-local' failed to load: {e}");
None
}
};
}
#[cfg(not(feature = "omnivoice-local"))]
{
eprintln!(
"Engine 'omnivoice-local' is not enabled in this build. \
Rebuild with --features omnivoice-local."
);
return None;
}
}
"floravox" => {
#[cfg(feature = "floravox")]
{
return Some(Arc::new(crate::floravox_engine::FloravoxEngine::new(
credentials_json,
)));
}
#[cfg(not(feature = "floravox"))]
{
eprintln!(
"Engine 'floravox' is not enabled in this build. \
Rebuild with --features floravox."
);
return None;
}
}
_ => {}
}
#[cfg(feature = "cloud")]
{
let result = cloud_engine::create_cloud_engine(engine_id, credentials_json);
if result.is_none() {
eprintln!(
"Unknown engine '{engine_id}'. Available engines: {}",
engine_list()
.iter()
.map(|e| e.id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
result
}
#[cfg(not(feature = "cloud"))]
{
eprintln!(
"Unknown engine '{engine_id}' (cloud feature is disabled; only \
built-in engines are available). Available engines: {}",
engine_list()
.iter()
.map(|e| e.id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
None
}
}
#[must_use]
pub fn engine_count() -> usize {
engine_list().len()
}
#[must_use]
#[allow(clippy::vec_init_then_push)]
#[allow(clippy::too_many_lines)]
pub fn engine_list() -> Vec<EngineDescriptor> {
#[allow(unused_mut)]
let mut engines = Vec::new();
#[cfg(all(feature = "system", target_os = "linux"))]
engines.push(EngineDescriptor {
id: "system".into(),
name: "System (Speech Dispatcher)".into(),
needs_credentials: false,
credential_keys_json: "[]".into(),
});
#[cfg(all(feature = "avsynth", target_os = "macos"))]
engines.push(EngineDescriptor {
id: "avsynth".into(),
name: "macOS AVSpeechSynthesizer".into(),
needs_credentials: false,
credential_keys_json: "[]".into(),
});
#[cfg(all(feature = "sapi", target_os = "windows"))]
engines.push(EngineDescriptor {
id: "sapi".into(),
name: "Windows SAPI".into(),
needs_credentials: false,
credential_keys_json: "[]".into(),
});
#[cfg(feature = "sherpaonnx")]
engines.push(EngineDescriptor {
id: "sherpaonnx".into(),
name: "Sherpa-ONNX".into(),
needs_credentials: false,
credential_keys_json: "[]".into(),
});
#[cfg(feature = "qwen3-local")]
engines.push(EngineDescriptor {
id: "qwen3-local".into(),
name: "Qwen3-TTS local (qwen3-tts.cpp)".into(),
needs_credentials: true,
credential_keys_json: r#"["modelsDir","threads","temperature","topK","maxTokens"]"#.into(),
});
#[cfg(feature = "floravox")]
engines.push(EngineDescriptor {
id: "floravox".into(),
name: "floravox".into(),
needs_credentials: true,
credential_keys_json: r#"["modelsDir","modelId","misaki","chars","speaker"]"#.into(),
});
#[cfg(feature = "omnivoice-local")]
engines.push(EngineDescriptor {
id: "omnivoice-local".into(),
name: "OmniVoice local (600+ languages, offline)".into(),
needs_credentials: true,
credential_keys_json:
r#"["modelPath","codecPath","numSteps","seed","instruct","language"]"#.into(),
});
#[cfg(feature = "pocket-timing")]
engines.push(EngineDescriptor {
id: "pocket".into(),
name: "Pocket TTS (attention-timed, phoneme-driven)".into(),
needs_credentials: true,
credential_keys_json: r#"["bundleDir","reference"]"#.into(),
});
#[cfg(feature = "cloud")]
{
let cloud = [
("openai", "OpenAI", true, r#"["apiKey"]"#),
(
"elevenlabs",
"ElevenLabs",
true,
r#"["apiKey","modelId","voiceId","language"]"#,
),
("azure", "Azure", true, r#"["subscriptionKey","region"]"#),
("edge", "Microsoft Edge (Read Aloud)", false, "[]"),
("google", "Google Cloud", true, r#"["apiKey"]"#),
(
"gemini",
"Google Gemini (3.8 TTS)",
true,
r#"["apiKey","modelId","voice","style"]"#,
),
(
"qwen",
"Qwen (Alibaba Cloud Model Studio)",
true,
r#"["apiKey","modelId","region","instruction","wsUrl","customizationUrl"]"#,
),
(
"polly",
"Amazon Polly",
true,
r#"["accessKeyId","secretAccessKey","region"]"#,
),
("cartesia", "Cartesia", true, r#"["apiKey"]"#),
("deepgram", "Deepgram", true, r#"["apiKey"]"#),
("playht", "PlayHT", true, r#"["apiKey","userId"]"#),
("fishaudio", "Fish Audio", true, r#"["apiKey"]"#),
("hume", "Hume AI", true, r#"["apiKey"]"#),
("mistral", "Mistral", true, r#"["apiKey"]"#),
("murf", "Murf", true, r#"["apiKey"]"#),
("resemble", "Resemble AI", true, r#"["apiKey"]"#),
("unrealspeech", "Unreal Speech", true, r#"["apiKey"]"#),
("upliftai", "UpliftAI", true, r#"["apiKey"]"#),
(
"watson",
"IBM Watson",
true,
r#"["apiKey","region","instanceId"]"#,
),
("witai", "Wit.ai", true, r#"["token"]"#),
("xai", "xAI", true, r#"["apiKey"]"#),
("modelslab", "ModelsLab", true, r#"["apiKey"]"#),
];
for (id, name, creds, keys) in &cloud {
engines.push(EngineDescriptor {
id: (*id).into(),
name: (*name).into(),
needs_credentials: *creds,
credential_keys_json: (*keys).into(),
});
}
}
engines
}
#[cfg(test)]
mod credentials_tests {
use super::parse_credentials;
#[test]
fn json_numbers_coerce_to_strings() {
let creds = parse_credentials(r#"{"modelPath":"/m.gguf","numSteps":16,"seed":42}"#);
assert_eq!(creds.get("modelPath").map(String::as_str), Some("/m.gguf"));
assert_eq!(creds.get("numSteps").map(String::as_str), Some("16"));
assert_eq!(creds.get("seed").map(String::as_str), Some("42"));
}
#[test]
fn json_bools_coerce() {
let creds = parse_credentials(r#"{"verbose":true,"fast":false}"#);
assert_eq!(creds.get("verbose").map(String::as_str), Some("true"));
assert_eq!(creds.get("fast").map(String::as_str), Some("false"));
}
#[test]
fn malformed_json_warns_not_silences() {
let creds = parse_credentials(r#"{"broken": tru}"#);
assert!(creds.is_empty());
}
#[test]
fn empty_input_is_empty_map() {
assert!(parse_credentials("").is_empty());
}
#[test]
fn mixed_types_all_coerce() {
let creds =
parse_credentials(r#"{"path":"/a/b","count":3,"ratio":0.5,"flag":true,"name":"test"}"#);
assert_eq!(creds.len(), 5);
assert_eq!(creds.get("count").map(String::as_str), Some("3"));
assert_eq!(creds.get("ratio").map(String::as_str), Some("0.5"));
}
}