1use crate::engine::estimate_word_boundaries;
12use crate::types::WordBoundary;
13
14#[derive(Debug, Clone)]
16pub struct EstimateEvent {
17 pub word: String,
19 pub start_s: f32,
21 pub end_s: f32,
23 pub char_offset: i32,
27 pub char_len: i32,
29}
30
31pub struct EstimatePlan {
33 events: Vec<EstimateEvent>,
34}
35
36impl EstimatePlan {
37 #[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 #[must_use]
54 pub fn from_estimates(estimated: &[WordBoundary], plain: &str) -> Self {
55 let mut events = Vec::with_capacity(estimated.len());
56 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 #[must_use]
79 pub fn len(&self) -> usize {
80 self.events.len()
81 }
82
83 #[must_use]
85 pub fn event(&self, idx: usize) -> Option<&EstimateEvent> {
86 self.events.get(idx)
87 }
88
89 #[must_use]
91 pub fn is_empty(&self) -> bool {
92 self.events.is_empty()
93 }
94}
95
96pub 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 #[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 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 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 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}