use crate::engine::estimate_word_boundaries;
use crate::types::WordBoundary;
#[derive(Debug, Clone)]
pub struct EstimateEvent {
pub word: String,
pub start_s: f32,
pub end_s: f32,
pub char_offset: i32,
pub char_len: i32,
}
pub struct EstimatePlan {
events: Vec<EstimateEvent>,
}
impl EstimatePlan {
#[must_use]
pub fn build(text: &str) -> Self {
let plain = if text.trim_start().to_ascii_lowercase().starts_with("<speak") {
crate::engine::strip_ssml_to_text(text)
} else {
text.to_string()
};
let estimated = estimate_word_boundaries(&plain);
Self::from_estimates(&estimated, &plain)
}
#[must_use]
pub fn from_estimates(estimated: &[WordBoundary], plain: &str) -> Self {
let mut events = Vec::with_capacity(estimated.len());
let mut search = crate::word_search::WordSearch::new(plain);
for b in estimated {
let (char_offset, char_len) = search.find_next(&b.text);
#[allow(clippy::cast_precision_loss)]
let start = b.offset as f32 / 1000.0;
#[allow(clippy::cast_precision_loss)]
let end = (b.offset + b.duration) as f32 / 1000.0;
events.push(EstimateEvent {
word: b.text.clone(),
start_s: start,
end_s: end,
char_offset: char_offset.max(0),
char_len,
});
}
Self { events }
}
#[must_use]
pub fn len(&self) -> usize {
self.events.len()
}
#[must_use]
pub fn event(&self, idx: usize) -> Option<&EstimateEvent> {
self.events.get(idx)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
pub struct EstimateFirer {
plan: Box<EstimatePlan>,
next: usize,
samples: u64,
rate: Option<u32>,
time_scale: f32,
}
impl EstimateFirer {
#[must_use]
pub fn new(plan: EstimatePlan, time_scale: f32) -> Self {
Self {
plan: Box::new(plan),
next: 0,
samples: 0,
rate: None,
time_scale,
}
}
pub fn on_samples(
&mut self,
samples: u64,
rate_now: Option<u32>,
fire: &mut dyn FnMut(&EstimateEvent),
) {
self.samples += samples;
if let Some(r) = rate_now {
self.rate = Some(r);
}
let Some(rate) = self.rate else { return };
#[allow(clippy::cast_precision_loss)]
let rate_f = rate as f32;
while self.next < self.plan.events.len() {
let e = &self.plan.events[self.next];
#[allow(clippy::cast_precision_loss)]
let threshold = (e.start_s * self.time_scale * rate_f) as u64;
if self.samples >= threshold {
fire(e);
self.next += 1;
} else {
break;
}
}
}
pub fn flush(&mut self, fire: &mut dyn FnMut(&EstimateEvent)) {
while self.next < self.plan.events.len() {
fire(&self.plan.events[self.next]);
self.next += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn estimate_event(word: &str, start_s: f32, end_s: f32) -> EstimateEvent {
EstimateEvent {
word: word.into(),
start_s,
end_s,
char_offset: -1,
char_len: word.len() as i32,
}
}
#[test]
fn firer_fires_at_scaled_thresholds() {
let events = vec![
estimate_event("one", 0.0, 0.5),
estimate_event("two", 0.5, 1.0),
];
let plan = EstimatePlan { events };
let mut firer = EstimateFirer::new(plan, 1.0);
let mut seen: Vec<String> = Vec::new();
firer.on_samples(6000, Some(24_000), &mut |e| seen.push(e.word.clone()));
assert_eq!(seen, vec!["one"], "0.25s of audio → only first word");
firer.on_samples(6000, None, &mut |e| seen.push(e.word.clone()));
assert_eq!(seen, vec!["one", "two"], "0.5s total → second word");
}
#[test]
fn firer_applies_time_scale() {
let events = vec![
estimate_event("one", 0.0, 0.5),
estimate_event("two", 0.5, 1.0),
];
let plan = EstimatePlan { events };
let mut firer = EstimateFirer::new(plan, 0.5);
let mut count = 0usize;
firer.on_samples(6000, Some(24_000), &mut |_| count += 1);
assert_eq!(count, 2, "0.25s audio at 2× covers both estimates");
}
#[test]
fn firer_flush_fires_remainder() {
let events = vec![estimate_event("one", 10.0, 10.5)];
let plan = EstimatePlan { events };
let mut firer = EstimateFirer::new(plan, 1.0);
let mut count = 0usize;
firer.on_samples(1000, Some(8000), &mut |_| count += 1);
assert_eq!(count, 0);
firer.flush(&mut |_| count += 1);
assert_eq!(count, 1);
}
#[test]
fn plan_from_estimates_resolves_offsets() {
use crate::types::WordBoundary;
let est = vec![
WordBoundary {
text: "hello".into(),
offset: 0,
duration: 400,
estimated: false,
},
WordBoundary {
text: "world".into(),
offset: 400,
duration: 400,
estimated: false,
},
];
let plan = EstimatePlan::from_estimates(&est, "hello world");
assert_eq!(plan.len(), 2);
assert_eq!(plan.events[0].char_offset, 0);
assert_eq!(plan.events[1].char_offset, 6);
}
}