Skip to main content

rust_tts_wrapper/
boundaries.rs

1//! Progressive word-boundary firing, shared by the cloud and sherpa-onnx
2//! engines.
3//!
4//! Engines without real API timing data get 150-wpm estimates. Firing
5//! them in one batch after synthesis leaves callers that interleave marks
6//! with playback (e.g. the VoiceGarden-SPD speech-dispatcher module)
7//! unable to highlight in sync on long utterances. [`EstimateFirer`]
8//! anchors the estimates onto the delivered-audio clock instead: estimate
9//! *i* fires once ≥ its start-time worth of PCM has actually been emitted.
10
11use crate::engine::estimate_word_boundaries;
12use crate::types::WordBoundary;
13
14/// One estimated boundary event with source-text position resolved.
15#[derive(Debug, Clone)]
16pub struct EstimateEvent {
17    /// The spoken word.
18    pub word: String,
19    /// Estimate start time in seconds (rate-1.0 baseline).
20    pub start_s: f32,
21    /// Estimate end time in seconds (rate-1.0 baseline).
22    pub end_s: f32,
23    /// Byte offset into the spoken plain text (0 when unresolvable —
24    /// the plan holds the last known position). Named `char_offset` for
25    /// callback-contract continuity; the value is a byte index.
26    pub char_offset: i32,
27    /// Byte length of the word (-1 when unresolvable).
28    pub char_len: i32,
29}
30
31/// Pre-resolved estimated boundaries for an utterance, in firing order.
32pub struct EstimatePlan {
33    events: Vec<EstimateEvent>,
34}
35
36impl EstimatePlan {
37    /// Build from the crate's 150-wpm estimator, resolving char offsets in
38    /// the spoken text. SSML input is stripped first so offsets and word
39    /// lists match what is actually spoken.
40    #[must_use]
41    pub fn build(text: &str) -> Self {
42        let plain = if text.trim_start().to_ascii_lowercase().starts_with("<speak") {
43            crate::engine::strip_ssml_to_text(text)
44        } else {
45            text.to_string()
46        };
47        let estimated = estimate_word_boundaries(&plain);
48        Self::from_estimates(&estimated, &plain)
49    }
50
51    /// Build from pre-computed boundaries against `plain` text (used by
52    /// engines that already have the plain text and estimates in hand).
53    #[must_use]
54    pub fn from_estimates(estimated: &[WordBoundary], plain: &str) -> Self {
55        let mut events = Vec::with_capacity(estimated.len());
56        // Byte-true positions via the shared matcher: exact →
57        // case/accent-insensitive → hold-last (never a -1 offset, so
58        // consumers can pair offset+length to slice the text safely).
59        let mut search = crate::word_search::WordSearch::new(plain);
60        for b in estimated {
61            let (char_offset, char_len) = search.find_next(&b.text);
62            #[allow(clippy::cast_precision_loss)]
63            let start = b.offset as f32 / 1000.0;
64            #[allow(clippy::cast_precision_loss)]
65            let end = (b.offset + b.duration) as f32 / 1000.0;
66            events.push(EstimateEvent {
67                word: b.text.clone(),
68                start_s: start,
69                end_s: end,
70                char_offset: char_offset.max(0),
71                char_len,
72            });
73        }
74        Self { events }
75    }
76
77    /// Number of events in the plan.
78    #[must_use]
79    pub fn len(&self) -> usize {
80        self.events.len()
81    }
82
83    /// The event at `idx` (firing order).
84    #[must_use]
85    pub fn event(&self, idx: usize) -> Option<&EstimateEvent> {
86        self.events.get(idx)
87    }
88
89    /// True when the plan has no events.
90    #[must_use]
91    pub fn is_empty(&self) -> bool {
92        self.events.is_empty()
93    }
94}
95
96/// Fires [`EstimatePlan`] events as cumulative delivered audio crosses
97/// each estimate's start time (scaled by `time_scale` to account for the
98/// engine's actual speech rate). Owns the plan so it can be moved into
99/// 'static callbacks.
100pub struct EstimateFirer {
101    plan: Box<EstimatePlan>,
102    next: usize,
103    samples: u64,
104    rate: Option<u32>,
105    time_scale: f32,
106}
107
108impl EstimateFirer {
109    /// Create a firer for `plan`. `time_scale` converts estimate seconds
110    /// into delivered-audio seconds (e.g. `1/speed` for engines whose
111    /// rate parameter compresses duration; 1.0 when estimates already
112    /// match delivery).
113    #[must_use]
114    pub fn new(plan: EstimatePlan, time_scale: f32) -> Self {
115        Self {
116            plan: Box::new(plan),
117            next: 0,
118            samples: 0,
119            rate: None,
120            time_scale,
121        }
122    }
123
124    /// Record `samples` newly-emitted PCM16-mono samples and fire every
125    /// estimate whose (scaled) start time has been reached.
126    pub fn on_samples(
127        &mut self,
128        samples: u64,
129        rate_now: Option<u32>,
130        fire: &mut dyn FnMut(&EstimateEvent),
131    ) {
132        self.samples += samples;
133        if let Some(r) = rate_now {
134            self.rate = Some(r);
135        }
136        let Some(rate) = self.rate else { return };
137        #[allow(clippy::cast_precision_loss)]
138        let rate_f = rate as f32;
139        while self.next < self.plan.events.len() {
140            let e = &self.plan.events[self.next];
141            #[allow(clippy::cast_precision_loss)]
142            let threshold = (e.start_s * self.time_scale * rate_f) as u64;
143            if self.samples >= threshold {
144                fire(e);
145                self.next += 1;
146            } else {
147                break;
148            }
149        }
150    }
151
152    /// Fire every remaining estimate (stream ended before their times).
153    pub fn flush(&mut self, fire: &mut dyn FnMut(&EstimateEvent)) {
154        while self.next < self.plan.events.len() {
155            fire(&self.plan.events[self.next]);
156            self.next += 1;
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn estimate_event(word: &str, start_s: f32, end_s: f32) -> EstimateEvent {
166        EstimateEvent {
167            word: word.into(),
168            start_s,
169            end_s,
170            char_offset: -1,
171            char_len: word.len() as i32,
172        }
173    }
174
175    #[test]
176    fn firer_fires_at_scaled_thresholds() {
177        let events = vec![
178            estimate_event("one", 0.0, 0.5),
179            estimate_event("two", 0.5, 1.0),
180        ];
181        let plan = EstimatePlan { events };
182        let mut firer = EstimateFirer::new(plan, 1.0);
183        let mut seen: Vec<String> = Vec::new();
184        firer.on_samples(6000, Some(24_000), &mut |e| seen.push(e.word.clone()));
185        assert_eq!(seen, vec!["one"], "0.25s of audio → only first word");
186        firer.on_samples(6000, None, &mut |e| seen.push(e.word.clone()));
187        assert_eq!(seen, vec!["one", "two"], "0.5s total → second word");
188    }
189
190    #[test]
191    fn firer_applies_time_scale() {
192        // Speed 2× → audio half as long → estimates scale by 1/2.
193        let events = vec![
194            estimate_event("one", 0.0, 0.5),
195            estimate_event("two", 0.5, 1.0),
196        ];
197        let plan = EstimatePlan { events };
198        let mut firer = EstimateFirer::new(plan, 0.5);
199        let mut count = 0usize;
200        firer.on_samples(6000, Some(24_000), &mut |_| count += 1);
201        assert_eq!(count, 2, "0.25s audio at 2× covers both estimates");
202    }
203
204    #[test]
205    fn firer_flush_fires_remainder() {
206        let events = vec![estimate_event("one", 10.0, 10.5)];
207        let plan = EstimatePlan { events };
208        let mut firer = EstimateFirer::new(plan, 1.0);
209        let mut count = 0usize;
210        firer.on_samples(1000, Some(8000), &mut |_| count += 1);
211        assert_eq!(count, 0);
212        firer.flush(&mut |_| count += 1);
213        assert_eq!(count, 1);
214    }
215
216    #[test]
217    fn plan_from_estimates_resolves_offsets() {
218        use crate::types::WordBoundary;
219        let est = vec![
220            WordBoundary {
221                text: "hello".into(),
222                offset: 0,
223                duration: 400,
224                estimated: false,
225            },
226            WordBoundary {
227                text: "world".into(),
228                offset: 400,
229                duration: 400,
230                estimated: false,
231            },
232        ];
233        let plan = EstimatePlan::from_estimates(&est, "hello world");
234        assert_eq!(plan.len(), 2);
235        assert_eq!(plan.events[0].char_offset, 0);
236        assert_eq!(plan.events[1].char_offset, 6);
237    }
238}