rust-tts-wrapper 1.6.0

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
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
// The split modules resolve shared names through the parent glob.
#![allow(clippy::wildcard_imports)]

use super::*;

/// Size of each audio chunk delivered via `on_audio` for JSON-body engines
/// (ElevenLabs `audio_base64`, Google `audioContent`). 8 KiB matches the
/// streaming-Read buffer used for HTTP-response engines. Exposed as a
/// named constant so tests can pin against the same value the production
/// `speak()` loop uses, rather than against the magic literal `8192`.
#[cfg(feature = "cloud")]
pub(crate) const STREAMING_CHUNK_SIZE: usize = 8192;

/// Decode an MP3 byte buffer to little-endian mono PCM16. Multi-channel input
/// is downmixed by averaging interleaved samples. Returns an empty vec if no
/// frames decode. Used so cloud engines deliver uniform PCM16 through
/// `on_audio` (matching the local SherpaOnnx / SAPI engines) instead of raw
/// MP3 bytes that a SAPI site would have to decode itself.
#[cfg(feature = "cloud")]
pub(crate) fn decode_audio_to_pcm16_mono(bytes: &[u8], ext_hint: &str) -> Vec<u8> {
    use symphonia::core::codecs::DecoderOptions;
    use symphonia::core::formats::FormatOptions;
    use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions};
    use symphonia::core::meta::MetadataOptions;
    use symphonia::core::probe::Hint;

    // Cursor needs an owned buffer: MediaSourceStream boxes the source as
    // `dyn MediaSource + 'static`, so a borrowed `&[u8]` cursor won't compile.
    let mss = MediaSourceStream::new(
        Box::new(std::io::Cursor::new(bytes.to_vec())),
        MediaSourceStreamOptions::default(),
    );
    let mut hint = Hint::new();
    hint.with_extension(ext_hint);
    let mut format = match symphonia::default::get_probe().format(
        &hint,
        mss,
        &FormatOptions::default(),
        &MetadataOptions::default(),
    ) {
        Ok(p) => p.format,
        Err(_) => return Vec::new(),
    };
    let track = format.default_track().cloned();
    let Some(track) = track else {
        return Vec::new();
    };
    let Ok(mut decoder) =
        symphonia::default::get_codecs().make(&track.codec_params, &DecoderOptions::default())
    else {
        return Vec::new();
    };

    let mut pcm: Vec<u8> = Vec::new();
    while let Ok(packet) = format.next_packet() {
        let Ok(decoded_buf) = decoder.decode(&packet) else {
            continue;
        };
        mix_packet_to_mono_pcm16(&decoded_buf, &mut pcm);
    }
    pcm
}

/// MP3 → PCM16 mono via the generic decoder.
pub(crate) fn decode_mp3_to_pcm16_mono(mp3: &[u8]) -> Vec<u8> {
    decode_audio_to_pcm16_mono(mp3, "mp3")
}

/// Scale a normalised f32 sample (`[-1.0, 1.0]`) to little-endian PCM16 and
/// append it. Pulled out so the F32 branch above stays readable.
#[cfg(feature = "cloud")]
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
pub(crate) fn push_mono_f32(out: &mut Vec<u8>, s: f32) {
    let s16 = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
    out.extend_from_slice(&s16.to_le_bytes());
}

// ============================================================================
// Incremental streaming decode
//
// The original delivery path buffered the entire HTTP response
// (`resp.bytes()`) before decoding, so `on_audio` only fired once synthesis
// had fully completed. The helpers below decode compressed audio as it
// arrives over the network: a reader pushes bytes into a shared pipe while
// a symphonia pipeline pulls packets off it, so PCM16 chunks reach the
// caller's `on_audio` callback while the body is still downloading.
// ============================================================================

/// A thread-safe byte pipe: a network reader pushes bytes, the symphonia
/// `MediaSourceStream` pulls them. `finish()` marks end-of-stream; `fail()`
/// propagates a network error to the reading side.
#[cfg(feature = "cloud")]
pub(crate) struct SharedPipe {
    state: std::sync::Mutex<PipeState>,
    ready: std::sync::Condvar,
}

#[cfg(feature = "cloud")]
pub(crate) struct PipeState {
    buf: std::collections::VecDeque<u8>,
    eof: bool,
    error: Option<String>,
}

#[cfg(feature = "cloud")]
impl SharedPipe {
    pub(crate) fn new() -> Self {
        Self {
            state: std::sync::Mutex::new(PipeState {
                buf: std::collections::VecDeque::new(),
                eof: false,
                error: None,
            }),
            ready: std::sync::Condvar::new(),
        }
    }

    /// Push downloaded bytes for the reader side.
    pub(crate) fn push(&self, bytes: &[u8]) {
        let mut st = self.state.lock().unwrap();
        st.buf.extend(bytes.iter().copied());
        drop(st);
        self.ready.notify_all();
    }

    /// Signal that no more bytes will arrive.
    pub(crate) fn finish(&self) {
        let mut st = self.state.lock().unwrap();
        st.eof = true;
        drop(st);
        self.ready.notify_all();
    }

    /// Signal a network failure.
    pub(crate) fn fail(&self, msg: String) {
        let mut st = self.state.lock().unwrap();
        st.error = Some(msg);
        st.eof = true;
        drop(st);
        self.ready.notify_all();
    }

    /// Blocking pull of up to `out.len()` bytes.
    pub(crate) fn pull(&self, out: &mut [u8]) -> std::io::Result<usize> {
        let mut st = self.state.lock().unwrap();
        loop {
            if !st.buf.is_empty() {
                let n = out.len().min(st.buf.len());
                for slot in out.iter_mut().take(n) {
                    *slot = st.buf.pop_front().expect("buf non-empty checked");
                }
                return Ok(n);
            }
            if let Some(err) = st.error.take() {
                return Err(std::io::Error::other(err));
            }
            if st.eof {
                return Ok(0);
            }
            st = self.ready.wait(st).unwrap();
        }
    }
}

#[cfg(feature = "cloud")]
impl std::io::Read for SharedPipe {
    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
        SharedPipe::pull(self, out)
    }
}

#[cfg(feature = "cloud")]
impl std::io::Seek for SharedPipe {
    fn seek(&mut self, _pos: std::io::SeekFrom) -> std::io::Result<u64> {
        Err(std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            "network stream is not seekable",
        ))
    }
}

#[cfg(feature = "cloud")]
impl symphonia::core::io::MediaSource for SharedPipe {
    fn is_seekable(&self) -> bool {
        false
    }
    fn byte_len(&self) -> Option<u64> {
        None // unknown while streaming
    }
}

/// Mix one decoded packet down to mono PCM16 LE bytes, appending to `out`.
/// Shared by the whole-buffer decoder and the incremental one so their
/// output stays byte-identical.
#[cfg(feature = "cloud")]
pub(crate) fn mix_packet_to_mono_pcm16(
    decoded: &symphonia::core::audio::AudioBufferRef,
    out: &mut Vec<u8>,
) {
    use symphonia::core::audio::AudioBufferRef;

    let frames = decoded.frames();
    #[allow(clippy::cast_precision_loss)]
    let nch = decoded.spec().channels.count().max(1) as f32;
    match decoded {
        AudioBufferRef::F32(buf) => {
            let planes = buf.planes();
            let slices = planes.planes();
            for f in 0..frames {
                let sum: f32 = slices
                    .iter()
                    .map(|s| s.get(f).copied().unwrap_or(0.0))
                    .sum();
                push_mono_f32(out, sum / nch);
            }
        }
        AudioBufferRef::S16(buf) => {
            let planes = buf.planes();
            let slices = planes.planes();
            for f in 0..frames {
                let sum: i32 = slices
                    .iter()
                    .map(|s| s.get(f).copied().unwrap_or(0))
                    .map(i32::from)
                    .sum();
                #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
                let avg = (sum as f32 / nch) as i16;
                out.extend_from_slice(&avg.to_le_bytes());
            }
        }
        _ => {}
    }
}

/// Incremental pull-decoder over a [`SharedPipe`]. Lazily probes the format
/// once enough bytes are buffered, then yields mono PCM16 bytes packet by
/// packet via `next_chunk`.
#[cfg(feature = "cloud")]
pub(crate) struct IncrementalDecoder {
    pipe: Arc<SharedPipe>,
    format: Option<Box<dyn symphonia::core::formats::FormatReader>>,
    decoder: Option<Box<dyn symphonia::core::codecs::Decoder>>,
    track_id: u32,
    sample_buf: Option<symphonia::core::audio::SampleBuffer<i16>>,
    /// Sample rate observed in the first decoded packet (None until then).
    sample_rate: Option<u32>,
}

#[cfg(feature = "cloud")]
impl IncrementalDecoder {
    pub(crate) fn new(pipe: Arc<SharedPipe>) -> Self {
        Self {
            pipe,
            format: None,
            decoder: None,
            track_id: 0,
            sample_buf: None,
            sample_rate: None,
        }
    }

    /// Sample rate observed so far (known after the first decoded packet).
    pub(crate) fn sample_rate(&self) -> Option<u32> {
        self.sample_rate
    }

    /// Hand the pipe to symphonia and set up the demuxer + decoder. The
    /// probe blocks through the pipe until enough header bytes arrive, so
    /// this returns only once the format is known (or genuinely undecodable).
    pub(crate) fn ensure_probed(&mut self) -> Result<(), String> {
        if self.format.is_some() {
            return Ok(());
        }
        let mss = symphonia::core::io::MediaSourceStream::new(
            Box::new(SharedPipeReader {
                pipe: Arc::clone(&self.pipe),
            }),
            symphonia::core::io::MediaSourceStreamOptions::default(),
        );
        let mut hint = symphonia::core::probe::Hint::new();
        hint.with_extension("mp3");
        let probed = symphonia::default::get_probe()
            .format(
                &hint,
                mss,
                &symphonia::core::formats::FormatOptions::default(),
                &symphonia::core::meta::MetadataOptions::default(),
            )
            .map_err(|e| format!("probe failed: {e}"))?;
        let format = probed.format;
        let track = format
            .default_track()
            .cloned()
            .ok_or_else(|| "no decodable track".to_string())?;
        self.track_id = track.id;
        let decoder = symphonia::default::get_codecs()
            .make(
                &track.codec_params,
                &symphonia::core::codecs::DecoderOptions::default(),
            )
            .map_err(|e| format!("decoder init failed: {e}"))?;
        self.decoder = Some(decoder);
        self.format = Some(format);
        Ok(())
    }

    /// Decode the next available packet. `Ok(None)` = stream complete.
    pub(crate) fn next_chunk(&mut self) -> Result<Option<Vec<u8>>, String> {
        self.ensure_probed()?;
        let format = self.format.as_mut().expect("ensure_probed guarantees Some");
        let decoder = self
            .decoder
            .as_mut()
            .expect("ensure_probed guarantees Some");
        loop {
            let packet = match format.next_packet() {
                Ok(p) => p,
                Err(symphonia::core::errors::Error::IoError(ref e))
                    if e.kind() == std::io::ErrorKind::UnexpectedEof =>
                {
                    return Ok(None); // clean EOF
                }
                Err(symphonia::core::errors::Error::IoError(ref e))
                    if e.kind() == std::io::ErrorKind::Other =>
                {
                    // Network failure surfaced through the pipe.
                    return Err(format!("network read failed: {e}"));
                }
                Err(symphonia::core::errors::Error::ResetRequired) => {
                    let track = format
                        .tracks()
                        .iter()
                        .find(|t| t.id == self.track_id)
                        .ok_or("track disappeared")?;
                    *decoder = symphonia::default::get_codecs()
                        .make(
                            &track.codec_params,
                            &symphonia::core::codecs::DecoderOptions::default(),
                        )
                        .map_err(|e| format!("decoder re-init failed: {e}"))?;
                    self.sample_buf = None;
                    continue;
                }
                Err(e) => return Err(format!("demux error: {e}")),
            };
            if packet.track_id() != self.track_id {
                continue;
            }
            let decoded_pkt = match decoder.decode(&packet) {
                Ok(d) => d,
                Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
                Err(e) => return Err(format!("decode error: {e}")),
            };
            let spec = *decoded_pkt.spec();
            if self.sample_rate.is_none() {
                self.sample_rate = Some(spec.rate);
            }
            let capacity = decoded_pkt.capacity() as u64;
            let buf = self.sample_buf.get_or_insert_with(|| {
                symphonia::core::audio::SampleBuffer::<i16>::new(capacity, spec)
            });
            buf.copy_interleaved_ref(decoded_pkt);
            let samples = buf.samples();
            // Interleaved → mono mix (channels are averaged).
            let nch = spec.channels.count().max(1);
            let mut out = Vec::with_capacity(samples.len() / nch * 2);
            if nch == 1 {
                for s in samples {
                    out.extend_from_slice(&s.to_le_bytes());
                }
            } else {
                for frame in samples.chunks(nch) {
                    let sum: i32 = frame.iter().copied().map(i32::from).sum();
                    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
                    let avg = (sum as f32 / nch as f32) as i16;
                    out.extend_from_slice(&avg.to_le_bytes());
                }
            }
            return Ok(Some(out));
        }
    }
}

/// `Read` adapter handing the pipe to symphonia while the decoder keeps
/// the `Arc` alive.
#[cfg(feature = "cloud")]
pub(crate) struct SharedPipeReader {
    pipe: Arc<SharedPipe>,
}

#[cfg(feature = "cloud")]
impl std::io::Read for SharedPipeReader {
    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
        self.pipe.pull(out)
    }
}

#[cfg(feature = "cloud")]
impl std::io::Seek for SharedPipeReader {
    fn seek(&mut self, _pos: std::io::SeekFrom) -> std::io::Result<u64> {
        Err(std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            "network stream is not seekable",
        ))
    }
}

#[cfg(feature = "cloud")]
impl symphonia::core::io::MediaSource for SharedPipeReader {
    fn is_seekable(&self) -> bool {
        false
    }
    fn byte_len(&self) -> Option<u64> {
        None
    }
}

/// Stream an HTTP response body to `on_audio` as mono PCM16, decoding
/// incrementally so audio reaches the caller while the body downloads.
///
/// * `is_pcm` — the body is already raw PCM16 mono; chunk it straight
///   through without spawning a decode thread.
///
/// Returns the total bytes delivered. Empty delivery (probe failure on a
/// zero-byte or undecodable body) is not an error, matching the buffered
/// path's behaviour.
///
/// A streamed event: audio bytes, or an estimated word boundary fired
/// progressively during streaming.
#[cfg(feature = "cloud")]
pub(crate) enum StreamEvt<'x> {
    Audio(&'x [u8]),
    Boundary(&'x str, f32, f32, i32, i32),
}

/// When `plan` is given, estimated word boundaries fire **progressively**
/// — estimate *i* fires once ≥ its start-time worth of audio has been
/// emitted — instead of all at once after the response completes, so
/// callers interleaving marks with playback (e.g. the VoiceGarden-SPD
/// speech-dispatcher module) can report them in sync.
#[cfg(feature = "cloud")]
#[allow(clippy::too_many_lines)]
pub(crate) fn stream_body_to_on_audio(
    mut body: impl std::io::Read + Send + 'static,
    is_pcm: bool,
    pcm_rate: u32,
    plan: Option<EstimatePlan>,
    on_event: &mut dyn FnMut(StreamEvt<'_>),
) -> Result<usize, String> {
    let mut firer = plan.map(|p| EstimateFirer::new(p, 1.0));

    if is_pcm {
        let mut buf = [0u8; STREAMING_CHUNK_SIZE];
        let mut total = 0usize;
        loop {
            let n = body
                .read(&mut buf)
                .map_err(|e| format!("network read failed: {e}"))?;
            if n == 0 {
                break;
            }
            on_event(StreamEvt::Audio(&buf[..n]));
            total += n;
            if let Some(f) = firer.as_mut() {
                // PCM16 mono: 2 bytes per sample.
                f.on_samples((n / 2) as u64, Some(pcm_rate), &mut |ev| {
                    on_event(StreamEvt::Boundary(
                        &ev.word,
                        ev.start_s,
                        ev.end_s,
                        ev.char_offset,
                        ev.char_len,
                    ));
                });
            }
        }
        if let Some(f) = firer.as_mut() {
            f.flush(&mut |ev| {
                on_event(StreamEvt::Boundary(
                    &ev.word,
                    ev.start_s,
                    ev.end_s,
                    ev.char_offset,
                    ev.char_len,
                ));
            });
        }
        return Ok(total);
    }

    let pipe = Arc::new(SharedPipe::new());
    let reader_pipe = Arc::clone(&pipe);
    let reader = std::thread::Builder::new()
        .name("cloud-audio-reader".into())
        .spawn(move || {
            let mut buf = [0u8; STREAMING_CHUNK_SIZE];
            loop {
                match body.read(&mut buf) {
                    Ok(0) => break,
                    Ok(n) => reader_pipe.push(&buf[..n]),
                    Err(e) => {
                        reader_pipe.fail(format!("network read failed: {e}"));
                        return;
                    }
                }
            }
            reader_pipe.finish();
        })
        .map_err(|e| format!("failed to spawn reader thread: {e}"))?;

    let mut dec = IncrementalDecoder::new(Arc::clone(&pipe));
    let mut total = 0usize;
    loop {
        match dec.next_chunk() {
            Ok(Some(chunk)) => {
                if chunk.is_empty() {
                    continue;
                }
                on_event(StreamEvt::Audio(&chunk));
                total += chunk.len();
                if let Some(f) = firer.as_mut() {
                    // Decoded PCM16 mono: one i16 per sample.
                    f.on_samples(chunk.len() as u64, dec.sample_rate(), &mut |ev| {
                        on_event(StreamEvt::Boundary(
                            &ev.word,
                            ev.start_s,
                            ev.end_s,
                            ev.char_offset,
                            ev.char_len,
                        ));
                    });
                }
            }
            Ok(None) => break,
            Err(e) => {
                // Drain the reader thread, then surface the error — but
                // only if nothing was delivered (a decode hiccup after
                // valid audio matches the buffered path's tolerance).
                let _ = reader.join();
                if total == 0 {
                    return Err(e);
                }
                eprintln!("rust-tts-wrapper: streaming decode error after {total} bytes: {e}");
                if let Some(f) = firer.as_mut() {
                    f.flush(&mut |ev| {
                        on_event(StreamEvt::Boundary(
                            &ev.word,
                            ev.start_s,
                            ev.end_s,
                            ev.char_offset,
                            ev.char_len,
                        ));
                    });
                }
                return Ok(total);
            }
        }
    }
    let _ = reader.join();
    if let Some(f) = firer.as_mut() {
        f.flush(&mut |ev| {
            on_event(StreamEvt::Boundary(
                &ev.word,
                ev.start_s,
                ev.end_s,
                ev.char_offset,
                ev.char_len,
            ));
        });
    }
    Ok(total)
}

/// Sniff the first few bytes for an MP3 sync word or ID3 tag. Kept as a
/// diagnostic helper but not used for delivery routing — raw PCM16 audio
/// frequently contains 0xFF 0xE0+ byte pairs that false-positive, so format
/// routing uses the explicit `CloudConfig::response_is_pcm` flag instead.
#[cfg(test)]
pub(crate) fn looks_like_mp3(b: &[u8]) -> bool {
    if b.len() >= 3 && &b[..3] == b"ID3" {
        return true;
    }
    let scan = b.len().min(4096).saturating_sub(1);
    (0..scan).any(|i| b[i] == 0xFF && (b[i + 1] & 0xE0) == 0xE0)
}