Skip to main content

rust_tts_wrapper/cloud_engine/
config.rs

1// The split modules resolve shared names through the parent glob.
2#![allow(clippy::wildcard_imports)]
3
4use super::*;
5
6pub const QWEN_DEFAULT_MODEL: &str = "qwen-audio-3.0-tts-flash";
7pub const QWEN_DEFAULT_VOICE: &str = "longanhuan_v3.6";
8
9/// Configuration for a single cloud TTS provider.
10#[derive(Debug, Clone, Default)]
11pub struct CloudConfig {
12    pub synth_url: String,
13    pub auth_header: String,
14    pub auth_prefix: String,
15    pub voice_param: String,
16    pub model_param: Option<String>,
17    pub model_default: Option<String>,
18    pub default_voice: Option<String>,
19    pub text_field: String,
20    pub extra_body: HashMap<String, serde_json::Value>,
21    /// Whether this engine requires SSML in the request body (Azure).
22    pub body_is_ssml: bool,
23    /// Content-Type header override for the synthesis request.
24    pub content_type: Option<String>,
25    /// Additional headers to send with synthesis requests.
26    pub extra_headers: HashMap<String, String>,
27    /// URL for the voice listing endpoint, if available.
28    pub voices_url: Option<String>,
29    /// Provider ID string for voice mapping.
30    pub provider_id: String,
31    /// Whether this engine's synthesis response body is already raw PCM16
32    /// (delivered verbatim) rather than MP3 (decoded to PCM before delivery).
33    /// Azure returns PCM because we request `raw-24khz-16bit-mono-pcm`;
34    /// Cartesia returns raw PCM by design. Everything else returns MP3.
35    pub response_is_pcm: bool,
36}
37
38#[allow(clippy::too_many_lines)]
39pub fn build_config(id: &str, creds: &HashMap<String, String>) -> Option<CloudConfig> {
40    match id {
41        "openai" => Some(CloudConfig {
42            synth_url: "https://api.openai.com/v1/audio/speech".into(),
43            auth_header: "Authorization".into(),
44            auth_prefix: "Bearer ".into(),
45            voice_param: "voice".into(),
46            model_param: Some("model".into()),
47            model_default: Some("gpt-4o-mini-tts".into()),
48            default_voice: Some("alloy".into()),
49            text_field: "input".into(),
50            provider_id: "openai".into(),
51            ..Default::default()
52        }),
53        "elevenlabs" => {
54            let voice_id = creds
55                .get("voiceId")
56                .cloned()
57                .unwrap_or_else(|| "21m00Tcm4TlvDq8ikWAM".into());
58            // Model selection matters for the SpeechMarkdown dialect:
59            // eleven_v4*/v3* parse no SSML (audio tags only; v4 also
60            // takes inline "/IPA/" pronunciation), pre-v3 models
61            // understand <break> but read audio tags aloud. v4 is the
62            // default — the current flagship and a net upgrade per
63            // ElevenLabs' own guidance. Unrecognized model IDs surface as
64            // API errors rather than being masked.
65            let model = creds
66                .get("modelId")
67                .filter(|m| !m.is_empty())
68                .cloned()
69                .unwrap_or_else(|| "eleven_v4".into());
70            Some(CloudConfig {
71                synth_url: format!("https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"),
72                auth_header: "xi-api-key".into(),
73                model_param: Some("model_id".into()),
74                model_default: Some(model),
75                text_field: "text".into(),
76                voices_url: Some("https://api.elevenlabs.io/v1/voices".into()),
77                provider_id: "elevenlabs".into(),
78                ..Default::default()
79            })
80        }
81        "azure" => {
82            let region = creds
83                .get("region")
84                .cloned()
85                .unwrap_or_else(|| "eastus".into());
86            let mut extra = HashMap::new();
87            extra.insert(
88                "X-Microsoft-OutputFormat".into(),
89                // Raw PCM16 24 kHz mono so the bytes flow straight to on_audio
90                // without an MP3 decode step (SAPI wants PCM; matches the
91                // SherpaOnnx / SAPI engines' PCM delivery contract).
92                "raw-24khz-16bit-mono-pcm".into(),
93            );
94            extra.insert("User-Agent".into(), "rust-tts-wrapper".into());
95            Some(CloudConfig {
96                synth_url: format!(
97                    "https://{region}.tts.speech.microsoft.com/cognitiveservices/v1"
98                ),
99                auth_header: "Ocp-Apim-Subscription-Key".into(),
100                default_voice: Some("en-US-AriaNeural".into()),
101                body_is_ssml: true,
102                content_type: Some("application/ssml+xml".into()),
103                extra_headers: extra,
104                voices_url: Some(format!(
105                    "https://{region}.tts.speech.microsoft.com/cognitiveservices/voices/list"
106                )),
107                provider_id: "azure".into(),
108                // X-Microsoft-OutputFormat requests raw PCM (see above).
109                response_is_pcm: true,
110                ..Default::default()
111            })
112        }
113        "google" => {
114            let api_key = creds.get("apiKey").cloned().unwrap_or_default();
115            Some(CloudConfig {
116                synth_url: format!(
117                    "https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}"
118                ),
119                text_field: "text".into(),
120                voices_url: Some(format!(
121                    "https://texttospeech.googleapis.com/v1/voices?key={api_key}"
122                )),
123                provider_id: "google".into(),
124                ..Default::default()
125            })
126        }
127        "gemini" => {
128            // Gemini 3.8 TTS via the Interactions API. Model selection
129            // matters: gemini-3.8-flash-tts (default) is the expressive
130            // flagship; gemini-3.8-flash-lite-tts is the high-volume
131            // variant. Both share the exact API schema. Older preview
132            // models (gemini-2.5-*-tts, gemini-3.1-flash-tts-preview)
133            // also work through this path. An unrecognized model ID
134            // surfaces as an API error rather than being masked.
135            let model = creds
136                .get("modelId")
137                .filter(|m| !m.is_empty())
138                .cloned()
139                .unwrap_or_else(|| "gemini-3.8-flash-tts".into());
140            let voice = creds
141                .get("voice")
142                .filter(|v| !v.is_empty())
143                .cloned()
144                .unwrap_or_else(|| "Kore".into());
145            Some(CloudConfig {
146                synth_url: "https://generativelanguage.googleapis.com/v1beta/interactions".into(),
147                auth_header: "x-goog-api-key".into(),
148                auth_prefix: String::new(),
149                model_default: Some(model),
150                default_voice: Some(voice),
151                voices_url: Some("https://generativelanguage.googleapis.com/v1beta/voices".into()),
152                provider_id: "gemini".into(),
153                ..Default::default()
154            })
155        }
156        "cartesia" => Some(CloudConfig {
157            synth_url: "https://api.cartesia.ai/tts/bytes".into(),
158            auth_header: "X-API-Key".into(),
159            voice_param: "voice_id".into(),
160            model_param: Some("model_id".into()),
161            model_default: Some("sonic-3.6".into()),
162            text_field: "text".into(),
163            voices_url: Some("https://api.cartesia.ai/voices".into()),
164            provider_id: "cartesia".into(),
165            // Cartesia's /tts/bytes endpoint returns raw PCM s16le @24 kHz.
166            response_is_pcm: true,
167            ..Default::default()
168        }),
169        "deepgram" => Some(CloudConfig {
170            synth_url: "https://api.deepgram.com/v1/speak".into(),
171            auth_header: "Authorization".into(),
172            auth_prefix: "Token ".into(),
173            voice_param: "model".into(), // Fixed: Deepgram uses "model" not "voice"
174            default_voice: Some("aura-asteria-en".into()),
175            text_field: "text".into(),
176            voices_url: Some("https://api.deepgram.com/v1/voices".into()),
177            provider_id: "deepgram".into(),
178            ..Default::default()
179        }),
180        "playht" => {
181            let user_id = creds.get("userId").cloned().unwrap_or_default();
182            let mut extra_headers = HashMap::new();
183            extra_headers.insert("X-User-ID".into(), user_id);
184            Some(CloudConfig {
185                synth_url: "https://api.play.ht/api/v2/tts".into(),
186                auth_header: "Authorization".into(),
187                auth_prefix: "Bearer ".into(),
188                voice_param: "voice".into(),
189                text_field: "text".into(),
190                voices_url: Some("https://api.play.ht/api/v2/voices".into()),
191                extra_headers,
192                provider_id: "playht".into(),
193                ..Default::default()
194            })
195        }
196        "fishaudio" => Some(CloudConfig {
197            synth_url: "https://api.fish.audio/v1/tts".into(),
198            auth_header: "Authorization".into(),
199            auth_prefix: "Bearer ".into(),
200            voice_param: "reference_id".into(),
201            text_field: "text".into(),
202            voices_url: Some("https://api.fish.audio/v1/model".into()),
203            provider_id: "fishaudio".into(),
204            ..Default::default()
205        }),
206        "hume" => {
207            // Hume API requires voice as object: {"voice": {"name": "..."}}
208            let voice_name = creds.get("voice").cloned().unwrap_or_default();
209            let mut extra_body = HashMap::new();
210            extra_body.insert("voice".into(), serde_json::json!({"name": voice_name}));
211            extra_body.insert(
212                "audio_format".into(),
213                serde_json::Value::String("wav".into()),
214            );
215
216            Some(CloudConfig {
217                synth_url: "https://api.hume.ai/v0/tts".into(),
218                auth_header: "Authorization".into(),
219                auth_prefix: "Bearer ".into(),
220                voice_param: String::new(), // Not used - voice in extra_body
221                text_field: "text".into(),
222                extra_body,
223                provider_id: "hume".into(),
224                ..Default::default()
225            })
226        }
227        "mistral" => Some(CloudConfig {
228            synth_url: "https://api.mistral.ai/v1/tts".into(),
229            auth_header: "Authorization".into(),
230            auth_prefix: "Bearer ".into(),
231            voice_param: "voice".into(),
232            text_field: "text".into(),
233            provider_id: "mistral".into(),
234            ..Default::default()
235        }),
236        "murf" => Some(CloudConfig {
237            synth_url: "https://api.murf.ai/v1/speech/generate".into(),
238            auth_header: "api-key".into(),
239            voice_param: "voice_id".into(),
240            text_field: "text".into(),
241            provider_id: "murf".into(),
242            ..Default::default()
243        }),
244        "resemble" => Some(CloudConfig {
245            synth_url: "https://app.resemble.ai/api/v2/synthesize".into(),
246            auth_header: "Authorization".into(),
247            auth_prefix: "Token ".into(),
248            voice_param: "voice_uuid".into(),
249            text_field: "text".into(),
250            voices_url: Some("https://app.resemble.ai/api/v2/voices".into()),
251            provider_id: "resemble".into(),
252            ..Default::default()
253        }),
254        "unrealspeech" => Some(CloudConfig {
255            synth_url: "https://api.v7.unrealspeech.com/speech".into(),
256            auth_header: "Authorization".into(),
257            auth_prefix: "Bearer ".into(),
258            voice_param: "voice_id".into(),
259            default_voice: Some("Scarlett".into()),
260            text_field: "text".into(),
261            provider_id: "unrealspeech".into(),
262            ..Default::default()
263        }),
264        "upliftai" => Some(CloudConfig {
265            synth_url: "https://api.upliftai.org/v1/tts".into(),
266            auth_header: "Authorization".into(),
267            auth_prefix: "Bearer ".into(),
268            voice_param: "voice".into(),
269            text_field: "text".into(),
270            provider_id: "upliftai".into(),
271            ..Default::default()
272        }),
273        "watson" => {
274            let region = creds
275                .get("region")
276                .cloned()
277                .unwrap_or_else(|| "us-east".into());
278            let instance_id = creds.get("instanceId").cloned().unwrap_or_default();
279            Some(CloudConfig {
280                synth_url: format!(
281                    "https://{region}.text-to-speech.watson.cloud.ibm.com/instances/{instance_id}/v1/synthesize"
282                ),
283                auth_header: "Authorization".into(),
284                auth_prefix: format!(
285                    "Basic {}",
286                    // IBM Watson's Basic-auth scheme requires the literal
287                    // string "apikey" (lowercase, one word) as the username.
288                    // Using "apiKey" (camelCase) here returns HTTP 401 from
289                    // every Watson endpoint.
290                    base64_encode(&format!("apikey:{}", creds.get("apiKey").cloned().unwrap_or_default()))
291                ),
292                voice_param: "voice".into(),
293                text_field: "text".into(),
294                voices_url: Some(format!(
295                    "https://{region}.text-to-speech.watson.cloud.ibm.com/instances/{instance_id}/v1/voices"
296                )),
297                provider_id: "watson".into(),
298                ..Default::default()
299            })
300        }
301        "witai" => Some(CloudConfig {
302            synth_url: "https://api.wit.ai/synthesize?v=20240304".into(),
303            auth_header: "Authorization".into(),
304            auth_prefix: "Bearer ".into(),
305            voices_url: Some("https://api.wit.ai/voices?v=20240304".into()),
306            provider_id: "witai".into(),
307            ..Default::default()
308        }),
309        "xai" => Some(CloudConfig {
310            synth_url: "https://api.x.ai/v1/audio/speech".into(),
311            auth_header: "Authorization".into(),
312            auth_prefix: "Bearer ".into(),
313            voice_param: "voice".into(),
314            text_field: "input".into(),
315            provider_id: "xai".into(),
316            ..Default::default()
317        }),
318        "modelslab" => Some(CloudConfig {
319            synth_url: "https://modelslab.com/api/v1/text_to_speech".into(),
320            voice_param: "voice".into(),
321            text_field: "text".into(),
322            provider_id: "modelslab".into(),
323            ..Default::default()
324        }),
325        "polly" => {
326            // AWS SigV4-signed REST (see sigv4.rs) — the only engine whose
327            // auth is computed per-request rather than a static header.
328            let region = creds
329                .get("region")
330                .cloned()
331                .unwrap_or_else(|| "us-east-1".into());
332            let mut extra_body = HashMap::new();
333            // Polly always requires OutputFormat; MP3 flows through the
334            // standard incremental decode path (symphonia).
335            extra_body.insert(
336                "OutputFormat".to_string(),
337                serde_json::Value::String("mp3".into()),
338            );
339            extra_body.insert(
340                "LanguageCode".to_string(),
341                serde_json::Value::String("en-US".into()),
342            );
343            Some(CloudConfig {
344                // /v1/speech is the synchronous SynthesizeSpeech; /v1/synthesis is
345                // the *async* long-text API and 404s on streaming use.
346                synth_url: format!("https://polly.{region}.amazonaws.com/v1/speech"),
347                voice_param: "VoiceId".into(),
348                model_param: Some("Engine".into()),
349                model_default: Some("neural".into()),
350                default_voice: Some("Joanna".into()),
351                text_field: "Text".into(),
352                extra_body,
353                voices_url: Some(format!("https://polly.{region}.amazonaws.com/v1/voices")),
354                provider_id: "polly".into(),
355                ..Default::default()
356            })
357        }
358        // Qwen cloud TTS (Alibaba Cloud Model Studio / DashScope) via the
359        // duplex WS protocol shared by Qwen-Audio-TTS and CosyVoice.
360        // WS-only — no REST synth endpoint (like edge); the speak path
361        // lives in qwen.rs. The `region` credential picks the WS endpoint
362        // (qwencloud default | intl | beijing) and `modelId` selects the
363        // model (default qwen-audio-3.0-tts-flash; any CosyVoice model
364        // works with the same protocol, subject to its own voice list).
365        "qwen" => {
366            let model = creds
367                .get("modelId")
368                .filter(|m| !m.is_empty())
369                .cloned()
370                .unwrap_or_else(|| QWEN_DEFAULT_MODEL.into());
371            Some(CloudConfig {
372                default_voice: Some(QWEN_DEFAULT_VOICE.into()),
373                model_default: Some(model),
374                provider_id: "qwen".into(),
375                // Binary frames are raw PCM16LE mono 24 kHz (format "pcm"
376                // in the run-task parameters) — delivered verbatim.
377                response_is_pcm: true,
378                ..Default::default()
379            })
380        }
381        // Microsoft Edge "Read Aloud" — the free, no-subscription Windows
382        // neural voices. WS-only (no REST synth endpoint); the URL + Sec-MS-GEC
383        // auth are built at speak time in the WS branch below. Voice list shape
384        // is identical to Azure's (`ShortName`/`Gender`/`Locale`/…). Edge
385        // returns MP3 frames (raw PCM isn't supported on this endpoint), so
386        // `response_is_pcm = false` and the WS loop decodes before delivery.
387        "edge" => Some(CloudConfig {
388            default_voice: Some(EDGE_DEFAULT_VOICE.into()),
389            voices_url: Some(EDGE_VOICE_LIST_URL.into()),
390            provider_id: "edge".into(),
391            response_is_pcm: false,
392            ..Default::default()
393        }),
394        _ => None,
395    }
396}
397
398/// The model that will actually be sent: an `extra_body["model_id"]`
399/// override wins over `model_default` (the JSON-body insert order gives
400/// extra_body the last write).
401pub fn effective_model(config: &CloudConfig) -> Option<&str> {
402    config
403        .extra_body
404        .get("model_id")
405        .and_then(|v| v.as_str())
406        .or(config.model_default.as_deref())
407}