rust-tts-wrapper 1.6.1

Cross-platform TTS wrapper with C API — mirrors js-tts-wrapper / SwiftTTSWrapper
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
// The split modules resolve shared names through the parent glob.
#![allow(clippy::wildcard_imports)]

use super::*;

pub const QWEN_DEFAULT_MODEL: &str = "qwen-audio-3.0-tts-flash";
pub const QWEN_DEFAULT_VOICE: &str = "longanhuan_v3.6";

/// Configuration for a single cloud TTS provider.
#[derive(Debug, Clone, Default)]
pub struct CloudConfig {
    pub synth_url: String,
    pub auth_header: String,
    pub auth_prefix: String,
    pub voice_param: String,
    pub model_param: Option<String>,
    pub model_default: Option<String>,
    pub default_voice: Option<String>,
    pub text_field: String,
    pub extra_body: HashMap<String, serde_json::Value>,
    /// Whether this engine requires SSML in the request body (Azure).
    pub body_is_ssml: bool,
    /// Content-Type header override for the synthesis request.
    pub content_type: Option<String>,
    /// Additional headers to send with synthesis requests.
    pub extra_headers: HashMap<String, String>,
    /// URL for the voice listing endpoint, if available.
    pub voices_url: Option<String>,
    /// Provider ID string for voice mapping.
    pub provider_id: String,
    /// Whether this engine's synthesis response body is already raw PCM16
    /// (delivered verbatim) rather than MP3 (decoded to PCM before delivery).
    /// Azure returns PCM because we request `raw-24khz-16bit-mono-pcm`;
    /// Cartesia returns raw PCM by design. Everything else returns MP3.
    pub response_is_pcm: bool,
}

#[allow(clippy::too_many_lines)]
pub fn build_config(id: &str, creds: &HashMap<String, String>) -> Option<CloudConfig> {
    match id {
        "openai" => Some(CloudConfig {
            synth_url: "https://api.openai.com/v1/audio/speech".into(),
            auth_header: "Authorization".into(),
            auth_prefix: "Bearer ".into(),
            voice_param: "voice".into(),
            model_param: Some("model".into()),
            model_default: Some("gpt-4o-mini-tts".into()),
            default_voice: Some("alloy".into()),
            text_field: "input".into(),
            provider_id: "openai".into(),
            ..Default::default()
        }),
        "elevenlabs" => {
            let voice_id = creds
                .get("voiceId")
                .cloned()
                .unwrap_or_else(|| "21m00Tcm4TlvDq8ikWAM".into());
            // Model selection matters for the SpeechMarkdown dialect:
            // eleven_v4*/v3* parse no SSML (audio tags only; v4 also
            // takes inline "/IPA/" pronunciation), pre-v3 models
            // understand <break> but read audio tags aloud. v4 is the
            // default — the current flagship and a net upgrade per
            // ElevenLabs' own guidance. Unrecognized model IDs surface as
            // API errors rather than being masked.
            let model = creds
                .get("modelId")
                .filter(|m| !m.is_empty())
                .cloned()
                .unwrap_or_else(|| "eleven_v4".into());
            Some(CloudConfig {
                synth_url: format!("https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"),
                auth_header: "xi-api-key".into(),
                model_param: Some("model_id".into()),
                model_default: Some(model),
                text_field: "text".into(),
                voices_url: Some("https://api.elevenlabs.io/v1/voices".into()),
                provider_id: "elevenlabs".into(),
                ..Default::default()
            })
        }
        "azure" => {
            let region = creds
                .get("region")
                .cloned()
                .unwrap_or_else(|| "eastus".into());
            let mut extra = HashMap::new();
            extra.insert(
                "X-Microsoft-OutputFormat".into(),
                // Raw PCM16 24 kHz mono so the bytes flow straight to on_audio
                // without an MP3 decode step (SAPI wants PCM; matches the
                // SherpaOnnx / SAPI engines' PCM delivery contract).
                "raw-24khz-16bit-mono-pcm".into(),
            );
            extra.insert("User-Agent".into(), "rust-tts-wrapper".into());
            Some(CloudConfig {
                synth_url: format!(
                    "https://{region}.tts.speech.microsoft.com/cognitiveservices/v1"
                ),
                auth_header: "Ocp-Apim-Subscription-Key".into(),
                default_voice: Some("en-US-AriaNeural".into()),
                body_is_ssml: true,
                content_type: Some("application/ssml+xml".into()),
                extra_headers: extra,
                voices_url: Some(format!(
                    "https://{region}.tts.speech.microsoft.com/cognitiveservices/voices/list"
                )),
                provider_id: "azure".into(),
                // X-Microsoft-OutputFormat requests raw PCM (see above).
                response_is_pcm: true,
                ..Default::default()
            })
        }
        "google" => {
            let api_key = creds.get("apiKey").cloned().unwrap_or_default();
            Some(CloudConfig {
                synth_url: format!(
                    "https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}"
                ),
                text_field: "text".into(),
                voices_url: Some(format!(
                    "https://texttospeech.googleapis.com/v1/voices?key={api_key}"
                )),
                provider_id: "google".into(),
                ..Default::default()
            })
        }
        "gemini" => {
            // Gemini 3.8 TTS via the Interactions API. Model selection
            // matters: gemini-3.8-flash-tts (default) is the expressive
            // flagship; gemini-3.8-flash-lite-tts is the high-volume
            // variant. Both share the exact API schema. Older preview
            // models (gemini-2.5-*-tts, gemini-3.1-flash-tts-preview)
            // also work through this path. An unrecognized model ID
            // surfaces as an API error rather than being masked.
            let model = creds
                .get("modelId")
                .filter(|m| !m.is_empty())
                .cloned()
                .unwrap_or_else(|| "gemini-3.8-flash-tts".into());
            let voice = creds
                .get("voice")
                .filter(|v| !v.is_empty())
                .cloned()
                .unwrap_or_else(|| "Kore".into());
            Some(CloudConfig {
                synth_url: "https://generativelanguage.googleapis.com/v1beta/interactions".into(),
                auth_header: "x-goog-api-key".into(),
                auth_prefix: String::new(),
                model_default: Some(model),
                default_voice: Some(voice),
                voices_url: Some("https://generativelanguage.googleapis.com/v1beta/voices".into()),
                provider_id: "gemini".into(),
                ..Default::default()
            })
        }
        "cartesia" => Some(CloudConfig {
            synth_url: "https://api.cartesia.ai/tts/bytes".into(),
            auth_header: "X-API-Key".into(),
            voice_param: "voice_id".into(),
            model_param: Some("model_id".into()),
            model_default: Some("sonic-3.6".into()),
            text_field: "text".into(),
            voices_url: Some("https://api.cartesia.ai/voices".into()),
            provider_id: "cartesia".into(),
            // Cartesia's /tts/bytes endpoint returns raw PCM s16le @24 kHz.
            response_is_pcm: true,
            ..Default::default()
        }),
        "deepgram" => Some(CloudConfig {
            synth_url: "https://api.deepgram.com/v1/speak".into(),
            auth_header: "Authorization".into(),
            auth_prefix: "Token ".into(),
            voice_param: "model".into(), // Fixed: Deepgram uses "model" not "voice"
            default_voice: Some("aura-asteria-en".into()),
            text_field: "text".into(),
            voices_url: Some("https://api.deepgram.com/v1/voices".into()),
            provider_id: "deepgram".into(),
            ..Default::default()
        }),
        "playht" => {
            let user_id = creds.get("userId").cloned().unwrap_or_default();
            let mut extra_headers = HashMap::new();
            extra_headers.insert("X-User-ID".into(), user_id);
            Some(CloudConfig {
                synth_url: "https://api.play.ht/api/v2/tts".into(),
                auth_header: "Authorization".into(),
                auth_prefix: "Bearer ".into(),
                voice_param: "voice".into(),
                text_field: "text".into(),
                voices_url: Some("https://api.play.ht/api/v2/voices".into()),
                extra_headers,
                provider_id: "playht".into(),
                ..Default::default()
            })
        }
        "fishaudio" => Some(CloudConfig {
            synth_url: "https://api.fish.audio/v1/tts".into(),
            auth_header: "Authorization".into(),
            auth_prefix: "Bearer ".into(),
            voice_param: "reference_id".into(),
            text_field: "text".into(),
            voices_url: Some("https://api.fish.audio/v1/model".into()),
            provider_id: "fishaudio".into(),
            ..Default::default()
        }),
        "hume" => {
            // Hume API requires voice as object: {"voice": {"name": "..."}}
            let voice_name = creds.get("voice").cloned().unwrap_or_default();
            let mut extra_body = HashMap::new();
            extra_body.insert("voice".into(), serde_json::json!({"name": voice_name}));
            extra_body.insert(
                "audio_format".into(),
                serde_json::Value::String("wav".into()),
            );

            Some(CloudConfig {
                synth_url: "https://api.hume.ai/v0/tts".into(),
                auth_header: "Authorization".into(),
                auth_prefix: "Bearer ".into(),
                voice_param: String::new(), // Not used - voice in extra_body
                text_field: "text".into(),
                extra_body,
                provider_id: "hume".into(),
                ..Default::default()
            })
        }
        "mistral" => Some(CloudConfig {
            synth_url: "https://api.mistral.ai/v1/tts".into(),
            auth_header: "Authorization".into(),
            auth_prefix: "Bearer ".into(),
            voice_param: "voice".into(),
            text_field: "text".into(),
            provider_id: "mistral".into(),
            ..Default::default()
        }),
        "murf" => Some(CloudConfig {
            synth_url: "https://api.murf.ai/v1/speech/generate".into(),
            auth_header: "api-key".into(),
            voice_param: "voice_id".into(),
            text_field: "text".into(),
            provider_id: "murf".into(),
            ..Default::default()
        }),
        "resemble" => Some(CloudConfig {
            synth_url: "https://app.resemble.ai/api/v2/synthesize".into(),
            auth_header: "Authorization".into(),
            auth_prefix: "Token ".into(),
            voice_param: "voice_uuid".into(),
            text_field: "text".into(),
            voices_url: Some("https://app.resemble.ai/api/v2/voices".into()),
            provider_id: "resemble".into(),
            ..Default::default()
        }),
        "unrealspeech" => Some(CloudConfig {
            synth_url: "https://api.v7.unrealspeech.com/speech".into(),
            auth_header: "Authorization".into(),
            auth_prefix: "Bearer ".into(),
            voice_param: "voice_id".into(),
            default_voice: Some("Scarlett".into()),
            text_field: "text".into(),
            provider_id: "unrealspeech".into(),
            ..Default::default()
        }),
        "upliftai" => Some(CloudConfig {
            synth_url: "https://api.upliftai.org/v1/tts".into(),
            auth_header: "Authorization".into(),
            auth_prefix: "Bearer ".into(),
            voice_param: "voice".into(),
            text_field: "text".into(),
            provider_id: "upliftai".into(),
            ..Default::default()
        }),
        "watson" => {
            let region = creds
                .get("region")
                .cloned()
                .unwrap_or_else(|| "us-east".into());
            let instance_id = creds.get("instanceId").cloned().unwrap_or_default();
            Some(CloudConfig {
                synth_url: format!(
                    "https://{region}.text-to-speech.watson.cloud.ibm.com/instances/{instance_id}/v1/synthesize"
                ),
                auth_header: "Authorization".into(),
                auth_prefix: format!(
                    "Basic {}",
                    // IBM Watson's Basic-auth scheme requires the literal
                    // string "apikey" (lowercase, one word) as the username.
                    // Using "apiKey" (camelCase) here returns HTTP 401 from
                    // every Watson endpoint.
                    base64_encode(&format!("apikey:{}", creds.get("apiKey").cloned().unwrap_or_default()))
                ),
                voice_param: "voice".into(),
                text_field: "text".into(),
                voices_url: Some(format!(
                    "https://{region}.text-to-speech.watson.cloud.ibm.com/instances/{instance_id}/v1/voices"
                )),
                provider_id: "watson".into(),
                ..Default::default()
            })
        }
        "witai" => Some(CloudConfig {
            synth_url: "https://api.wit.ai/synthesize?v=20240304".into(),
            auth_header: "Authorization".into(),
            auth_prefix: "Bearer ".into(),
            voices_url: Some("https://api.wit.ai/voices?v=20240304".into()),
            provider_id: "witai".into(),
            ..Default::default()
        }),
        "xai" => Some(CloudConfig {
            synth_url: "https://api.x.ai/v1/audio/speech".into(),
            auth_header: "Authorization".into(),
            auth_prefix: "Bearer ".into(),
            voice_param: "voice".into(),
            text_field: "input".into(),
            provider_id: "xai".into(),
            ..Default::default()
        }),
        "modelslab" => Some(CloudConfig {
            synth_url: "https://modelslab.com/api/v1/text_to_speech".into(),
            voice_param: "voice".into(),
            text_field: "text".into(),
            provider_id: "modelslab".into(),
            ..Default::default()
        }),
        "polly" => {
            // AWS SigV4-signed REST (see sigv4.rs) — the only engine whose
            // auth is computed per-request rather than a static header.
            let region = creds
                .get("region")
                .cloned()
                .unwrap_or_else(|| "us-east-1".into());
            let mut extra_body = HashMap::new();
            // Polly always requires OutputFormat; MP3 flows through the
            // standard incremental decode path (symphonia).
            extra_body.insert(
                "OutputFormat".to_string(),
                serde_json::Value::String("mp3".into()),
            );
            extra_body.insert(
                "LanguageCode".to_string(),
                serde_json::Value::String("en-US".into()),
            );
            Some(CloudConfig {
                // /v1/speech is the synchronous SynthesizeSpeech; /v1/synthesis is
                // the *async* long-text API and 404s on streaming use.
                synth_url: format!("https://polly.{region}.amazonaws.com/v1/speech"),
                voice_param: "VoiceId".into(),
                model_param: Some("Engine".into()),
                model_default: Some("neural".into()),
                default_voice: Some("Joanna".into()),
                text_field: "Text".into(),
                extra_body,
                voices_url: Some(format!("https://polly.{region}.amazonaws.com/v1/voices")),
                provider_id: "polly".into(),
                ..Default::default()
            })
        }
        // Qwen cloud TTS (Alibaba Cloud Model Studio / DashScope) via the
        // duplex WS protocol shared by Qwen-Audio-TTS and CosyVoice.
        // WS-only — no REST synth endpoint (like edge); the speak path
        // lives in qwen.rs. The `region` credential picks the WS endpoint
        // (qwencloud default | intl | beijing) and `modelId` selects the
        // model (default qwen-audio-3.0-tts-flash; any CosyVoice model
        // works with the same protocol, subject to its own voice list).
        "qwen" => {
            let model = creds
                .get("modelId")
                .filter(|m| !m.is_empty())
                .cloned()
                .unwrap_or_else(|| QWEN_DEFAULT_MODEL.into());
            Some(CloudConfig {
                default_voice: Some(QWEN_DEFAULT_VOICE.into()),
                model_default: Some(model),
                provider_id: "qwen".into(),
                // Binary frames are raw PCM16LE mono 24 kHz (format "pcm"
                // in the run-task parameters) — delivered verbatim.
                response_is_pcm: true,
                ..Default::default()
            })
        }
        // Microsoft Edge "Read Aloud" — the free, no-subscription Windows
        // neural voices. WS-only (no REST synth endpoint); the URL + Sec-MS-GEC
        // auth are built at speak time in the WS branch below. Voice list shape
        // is identical to Azure's (`ShortName`/`Gender`/`Locale`/…). Edge
        // returns MP3 frames (raw PCM isn't supported on this endpoint), so
        // `response_is_pcm = false` and the WS loop decodes before delivery.
        "edge" => Some(CloudConfig {
            default_voice: Some(EDGE_DEFAULT_VOICE.into()),
            voices_url: Some(EDGE_VOICE_LIST_URL.into()),
            provider_id: "edge".into(),
            response_is_pcm: false,
            ..Default::default()
        }),
        _ => None,
    }
}

/// The model that will actually be sent: an `extra_body["model_id"]`
/// override wins over `model_default` (the JSON-body insert order gives
/// extra_body the last write).
pub fn effective_model(config: &CloudConfig) -> Option<&str> {
    config
        .extra_body
        .get("model_id")
        .and_then(|v| v.as_str())
        .or(config.model_default.as_deref())
}