1use crate::error::{Result, UserError};
8use crate::providers::Segment;
9use crate::remote::stt_chunk::{ChunkWindow, DEFAULT_REMOTE_STT_CHUNK_SECS};
10use serde::{Deserialize, Serialize};
11
12pub const DEFAULT_BOUNDARY_SEARCH_SECS: f64 = 15.0;
14pub const DEFAULT_MIN_SILENCE_SECS: f64 = 0.25;
16pub const DEFAULT_OVERLAP_SECS: f64 = 1.5;
18pub const DEFAULT_MAX_OVERLAP_FRACTION: f64 = 0.05;
20pub const MAX_DEDUPE_TOKENS: usize = 40;
22pub const MIN_DEDUPE_TOKENS: usize = 3;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum TimestampSource {
29 NativeModel,
31 ProviderWord,
33 ProviderSegment,
35 ChunkOffset,
37 Interpolated,
39 SyntheticSpan,
41 #[default]
43 Unavailable,
44}
45
46impl TimestampSource {
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Self::NativeModel => "native_model",
50 Self::ProviderWord => "provider_word",
51 Self::ProviderSegment => "provider_segment",
52 Self::ChunkOffset => "chunk_offset",
53 Self::Interpolated => "interpolated",
54 Self::SyntheticSpan => "synthetic_span",
55 Self::Unavailable => "unavailable",
56 }
57 }
58
59 pub fn is_reliable(self) -> bool {
61 matches!(
62 self,
63 Self::NativeModel | Self::ProviderWord | Self::ProviderSegment | Self::ChunkOffset
64 )
65 }
66
67 pub fn is_approximate(self) -> bool {
69 matches!(
70 self,
71 Self::Interpolated | Self::SyntheticSpan | Self::Unavailable
72 )
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum BoundaryKind {
80 Silence,
81 TargetWithOverlap,
82 ShortSingle,
83 FixedFallback,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct LongFormPolicy {
89 pub target_secs: f64,
91 pub min_secs: f64,
92 pub max_secs: f64,
93 pub search_secs: f64,
95 pub min_silence_secs: f64,
96 pub silence_rms_ratio: f64,
98 pub overlap_secs: f64,
99 pub max_overlap_fraction: f64,
100}
101
102impl Default for LongFormPolicy {
103 fn default() -> Self {
104 Self {
105 target_secs: DEFAULT_REMOTE_STT_CHUNK_SECS,
106 min_secs: 30.0,
107 max_secs: 300.0,
108 search_secs: DEFAULT_BOUNDARY_SEARCH_SECS,
109 min_silence_secs: DEFAULT_MIN_SILENCE_SECS,
110 silence_rms_ratio: 0.08,
111 overlap_secs: DEFAULT_OVERLAP_SECS,
112 max_overlap_fraction: DEFAULT_MAX_OVERLAP_FRACTION,
113 }
114 }
115}
116
117impl LongFormPolicy {
118 pub fn validate(&self) -> Result<()> {
119 for (name, v) in [
120 ("target_secs", self.target_secs),
121 ("min_secs", self.min_secs),
122 ("max_secs", self.max_secs),
123 ("search_secs", self.search_secs),
124 ("min_silence_secs", self.min_silence_secs),
125 ("silence_rms_ratio", self.silence_rms_ratio),
126 ("overlap_secs", self.overlap_secs),
127 ("max_overlap_fraction", self.max_overlap_fraction),
128 ] {
129 if !v.is_finite() || v < 0.0 {
130 return Err(UserError::InvalidConfig {
131 reason: format!("LongFormPolicy.{name} must be finite and non-negative"),
132 }
133 .into());
134 }
135 }
136 if self.target_secs <= 0.0 || self.min_secs <= 0.0 || self.max_secs <= 0.0 {
137 return Err(UserError::InvalidConfig {
138 reason: "LongFormPolicy window sizes must be > 0".into(),
139 }
140 .into());
141 }
142 if self.min_secs > self.target_secs || self.target_secs > self.max_secs {
143 return Err(UserError::InvalidConfig {
144 reason: "LongFormPolicy requires min_secs ≤ target_secs ≤ max_secs".into(),
145 }
146 .into());
147 }
148 if self.silence_rms_ratio > 1.0 {
149 return Err(UserError::InvalidConfig {
150 reason: "LongFormPolicy.silence_rms_ratio must be ≤ 1.0".into(),
151 }
152 .into());
153 }
154 if self.max_overlap_fraction > 0.5 {
155 return Err(UserError::InvalidConfig {
156 reason: "LongFormPolicy.max_overlap_fraction must be ≤ 0.5".into(),
157 }
158 .into());
159 }
160 if self.min_silence_secs <= 0.0 {
162 return Err(UserError::InvalidConfig {
163 reason: "LongFormPolicy.min_silence_secs must be > 0".into(),
164 }
165 .into());
166 }
167 Ok(())
168 }
169
170 pub fn from_env_or_default() -> Self {
172 let mut p = Self::default();
173 if let Ok(s) = std::env::var("AURUM_REMOTE_STT_CHUNK_SECS") {
174 if let Ok(v) = s.trim().parse::<f64>() {
175 if v.is_finite() && v > 0.0 {
176 p.target_secs = v;
177 p.max_secs = p.max_secs.max(v);
178 }
179 }
180 }
181 p
182 }
183}
184
185#[derive(Debug, Clone, PartialEq)]
187pub struct PlannedWindow {
188 pub window: ChunkWindow,
189 pub kind: BoundaryKind,
190 pub overlap_secs: f64,
192}
193
194pub fn plan_boundary_windows(
200 samples: &[f32],
201 sample_rate: u32,
202 policy: &LongFormPolicy,
203) -> Result<Vec<PlannedWindow>> {
204 policy.validate()?;
205 let total = samples.len();
206 if total == 0 || sample_rate == 0 {
207 return Ok(vec![PlannedWindow {
208 window: ChunkWindow {
209 start_sample: 0,
210 end_sample: total,
211 offset_secs: 0.0,
212 },
213 kind: BoundaryKind::ShortSingle,
214 overlap_secs: 0.0,
215 }]);
216 }
217
218 let target = ((policy.target_secs * f64::from(sample_rate)).round() as usize).max(1);
219 if total <= target {
220 return Ok(vec![PlannedWindow {
221 window: ChunkWindow {
222 start_sample: 0,
223 end_sample: total,
224 offset_secs: 0.0,
225 },
226 kind: BoundaryKind::ShortSingle,
227 overlap_secs: 0.0,
228 }]);
229 }
230
231 let min_len = ((policy.min_secs * f64::from(sample_rate)).round() as usize).max(1);
232 let max_len = ((policy.max_secs * f64::from(sample_rate)).round() as usize).max(min_len);
233 let search = ((policy.search_secs * f64::from(sample_rate)).round() as usize).max(1);
234 let min_silence = ((policy.min_silence_secs * f64::from(sample_rate)).round() as usize).max(1);
235 let peak = peak_abs(samples).max(1e-9);
236 let quiet_thresh = peak * policy.silence_rms_ratio as f32;
237
238 let mut out = Vec::new();
239 let mut start = 0usize;
240 let mut pending_overlap_secs = 0.0f64;
242 while start < total {
243 let remaining = total - start;
244 if remaining <= max_len {
245 out.push(PlannedWindow {
246 window: ChunkWindow {
247 start_sample: start,
248 end_sample: total,
249 offset_secs: start as f64 / f64::from(sample_rate),
250 },
251 kind: if out.is_empty() {
252 BoundaryKind::ShortSingle
253 } else if pending_overlap_secs > 0.0 {
254 BoundaryKind::TargetWithOverlap
255 } else {
256 BoundaryKind::Silence
257 },
258 overlap_secs: pending_overlap_secs,
259 });
260 break;
261 }
262
263 let ideal = (start + target).min(total);
264 let search_lo = ideal.saturating_sub(search).max(start + min_len);
265 let search_hi = (ideal + search).min(start + max_len).min(total);
266
267 let silence_cut =
268 find_silence_boundary(samples, search_lo, search_hi, min_silence, quiet_thresh);
269
270 let (end, kind, overlap_for_next) = if let Some(cut) = silence_cut {
271 (cut, BoundaryKind::Silence, 0.0f64)
272 } else {
273 let end = ideal.min(total);
275 let raw_overlap = ((policy.overlap_secs * f64::from(sample_rate)).round() as usize)
276 .min(((end - start) as f64 * policy.max_overlap_fraction).round() as usize);
277 let overlap = raw_overlap.min(end.saturating_sub(start) / 4);
278 (
279 end,
280 BoundaryKind::TargetWithOverlap,
281 overlap as f64 / f64::from(sample_rate),
282 )
283 };
284
285 let end = end.max(start + 1).min(total);
286 out.push(PlannedWindow {
287 window: ChunkWindow {
288 start_sample: start,
289 end_sample: end,
290 offset_secs: start as f64 / f64::from(sample_rate),
291 },
292 kind,
294 overlap_secs: pending_overlap_secs,
295 });
296
297 if end >= total {
298 break;
299 }
300 let overlap_samples = if overlap_for_next > 0.0 {
302 ((overlap_for_next * f64::from(sample_rate)).round() as usize).min(end - start)
303 } else {
304 0
305 };
306 let next = end.saturating_sub(overlap_samples);
307 pending_overlap_secs = overlap_for_next;
308 if next <= start {
309 start = end;
311 pending_overlap_secs = 0.0;
312 } else {
313 start = next;
314 }
315 }
316
317 if let Some(first) = out.first() {
319 if first.window.start_sample != 0 {
320 return Err(UserError::Other {
321 message: "long-form planner: first window must start at sample 0".into(),
322 }
323 .into());
324 }
325 if first.overlap_secs != 0.0 {
326 return Err(UserError::Other {
327 message: "long-form planner: first window must have zero predecessor overlap"
328 .into(),
329 }
330 .into());
331 }
332 }
333 if let Some(last) = out.last() {
334 if last.window.end_sample != total {
335 return Err(UserError::Other {
336 message: "long-form planner: last window must end at total samples".into(),
337 }
338 .into());
339 }
340 }
341 Ok(out)
342}
343
344fn peak_abs(samples: &[f32]) -> f32 {
345 let mut p = 0.0f32;
346 for &s in samples {
347 p = p.max(s.abs());
348 }
349 p
350}
351
352fn find_silence_boundary(
354 samples: &[f32],
355 lo: usize,
356 hi: usize,
357 min_silence: usize,
358 quiet_thresh: f32,
359) -> Option<usize> {
360 if hi <= lo + min_silence {
361 return None;
362 }
363 let mut best: Option<(f64, usize)> = None; let mut i = lo;
365 while i + min_silence <= hi {
366 let window = &samples[i..i + min_silence];
367 let mut energy = 0.0f64;
368 let mut all_quiet = true;
369 for &s in window {
370 let a = s.abs() as f64;
371 energy += a * a;
372 if s.abs() > quiet_thresh {
373 all_quiet = false;
374 break;
375 }
376 }
377 if all_quiet {
378 energy /= min_silence as f64;
379 let cut = i + min_silence / 2;
380 match best {
381 None => best = Some((energy, cut)),
382 Some((e, c)) => {
383 if energy < e - 1e-18 || ((energy - e).abs() < 1e-18 && cut < c) {
384 best = Some((energy, cut));
385 }
386 }
387 }
388 }
389 let step = (min_silence.max(1) / 2).max(1);
391 i += step;
392 }
393 best.map(|(_, cut)| cut)
394}
395
396#[derive(Debug, Clone, PartialEq)]
402pub struct DedupeOutcome {
403 pub text: String,
404 pub dropped_prefix_tokens: usize,
405 pub confident: bool,
406 pub warning: Option<String>,
407}
408
409pub fn normalize_tokens(s: &str) -> Vec<String> {
411 s.split(|c: char| !c.is_alphanumeric())
412 .filter(|w| !w.is_empty())
413 .map(|w| w.to_ascii_lowercase())
414 .collect()
415}
416
417pub fn dedupe_overlap_text(earlier: &str, later: &str) -> DedupeOutcome {
421 let earlier_t = normalize_tokens(earlier);
422 let later_t = normalize_tokens(later);
423 if earlier_t.is_empty() || later_t.is_empty() {
424 return DedupeOutcome {
425 text: later.to_string(),
426 dropped_prefix_tokens: 0,
427 confident: true,
428 warning: None,
429 };
430 }
431
432 let max_n = MAX_DEDUPE_TOKENS.min(earlier_t.len()).min(later_t.len());
433 let mut best = 0usize;
434 for n in (MIN_DEDUPE_TOKENS..=max_n).rev() {
435 let suffix = &earlier_t[earlier_t.len() - n..];
436 let prefix = &later_t[..n];
437 if suffix == prefix {
438 best = n;
439 break;
440 }
441 }
442
443 if best >= MIN_DEDUPE_TOKENS {
444 let stripped = drop_n_tokens(later, best);
446 DedupeOutcome {
447 text: stripped,
448 dropped_prefix_tokens: best,
449 confident: true,
450 warning: None,
451 }
452 } else {
453 DedupeOutcome {
454 text: later.to_string(),
455 dropped_prefix_tokens: 0,
456 confident: false,
457 warning: Some(
458 "overlap could not be resolved confidently; retained full later-chunk text".into(),
459 ),
460 }
461 }
462}
463
464fn drop_n_tokens(s: &str, n: usize) -> String {
465 if n == 0 {
466 return s.to_string();
467 }
468 let mut seen = 0usize;
469 let mut in_tok = false;
470 let mut cut = 0usize;
471 for (i, ch) in s.char_indices() {
472 if ch.is_alphanumeric() {
473 if !in_tok {
474 in_tok = true;
475 seen += 1;
476 if seen > n {
477 cut = i;
478 break;
479 }
480 }
481 } else {
482 in_tok = false;
483 if seen >= n {
484 cut = i;
486 if !ch.is_whitespace() {
487 break;
488 }
489 }
490 }
491 if seen >= n && !in_tok && !ch.is_whitespace() {
492 cut = i;
493 break;
494 }
495 }
496 if seen < n {
497 return String::new();
498 }
499 let rest = s[cut..].trim_start();
501 rest.to_string()
502}
503
504pub fn stitch_text_with_overlap(parts: &[(String, f64)]) -> (String, Vec<String>) {
506 let mut warnings = Vec::new();
507 if parts.is_empty() {
508 return (String::new(), warnings);
509 }
510 let mut out = parts[0].0.trim().to_string();
511 for (text, overlap_secs) in parts.iter().skip(1) {
512 let t = text.trim();
513 if t.is_empty() {
514 continue;
515 }
516 if *overlap_secs > 0.0 {
517 let d = dedupe_overlap_text(&out, t);
518 if let Some(w) = d.warning {
519 warnings.push(w);
520 }
521 if d.text.is_empty() {
522 continue;
523 }
524 if !out.is_empty() && !out.ends_with(char::is_whitespace) {
525 out.push(' ');
526 }
527 out.push_str(d.text.trim());
528 } else {
529 if !out.is_empty() && !out.ends_with(char::is_whitespace) {
530 out.push(' ');
531 }
532 out.push_str(t);
533 }
534 }
535 (out, warnings)
536}
537
538pub fn dedupe_segments_overlap(
540 earlier: &[Segment],
541 later: &[Segment],
542 overlap_secs: f64,
543 later_offset_secs: f64,
544) -> (Vec<Segment>, Option<String>) {
545 if later.is_empty() {
546 return (Vec::new(), None);
547 }
548 if overlap_secs <= 0.0 || earlier.is_empty() {
549 return (later.to_vec(), None);
550 }
551 let last_earlier = earlier.last().map(|s| s.text()).unwrap_or("");
553 let first_later = later[0].text();
554 let d = dedupe_overlap_text(last_earlier, first_later);
555 if d.confident && d.dropped_prefix_tokens > 0 {
556 let mut out = Vec::with_capacity(later.len());
557 if d.text.trim().is_empty() {
558 out.extend(later.iter().skip(1).cloned());
559 } else {
560 let mut first = later[0].clone();
561 first.set_text(d.text);
562 let _ = later_offset_secs;
564 out.push(first);
565 out.extend(later.iter().skip(1).cloned());
566 }
567 return (out, None);
568 }
569 if !d.confident {
570 return (
571 later.to_vec(),
572 Some("segment overlap not confidently deduped; retained later segments".into()),
573 );
574 }
575 (later.to_vec(), None)
576}
577
578pub fn srt_requires_allow_approximate(sources: &[TimestampSource]) -> bool {
580 sources.iter().any(|s| s.is_approximate())
581}
582
583pub fn derive_timestamps_reliable(sources: &[TimestampSource]) -> bool {
585 !sources.is_empty() && sources.iter().all(|s| s.is_reliable())
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::audio::WHISPER_SAMPLE_RATE;
592
593 #[test]
594 fn policy_rejects_inverted_bounds() {
595 let p = LongFormPolicy {
596 min_secs: 250.0,
597 target_secs: 210.0,
598 ..Default::default()
599 };
600 assert!(p.validate().is_err());
601 }
602
603 #[test]
604 fn short_audio_single_window() {
605 let sr = WHISPER_SAMPLE_RATE;
606 let n = sr as usize * 30;
607 let samples = vec![0.1f32; n];
608 let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
609 assert_eq!(plan.len(), 1);
610 assert_eq!(plan[0].kind, BoundaryKind::ShortSingle);
611 assert_eq!(plan[0].window.end_sample, n);
612 }
613
614 #[test]
615 fn silence_boundary_preferred_over_hard_cut() {
616 let sr = WHISPER_SAMPLE_RATE;
617 let n = (400.0 * f64::from(sr)) as usize;
619 let mut samples = vec![0.3f32; n];
620 let silence_at = (205.0 * f64::from(sr)) as usize;
621 let silence_len = (sr as usize) * 2; for s in samples.iter_mut().skip(silence_at).take(silence_len) {
623 *s = 0.0;
624 }
625 let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
626 assert!(plan.len() >= 2);
627 let first_end = plan[0].window.end_sample as f64 / f64::from(sr);
629 assert!((200.0..220.0).contains(&first_end), "first end {first_end}");
630 assert_eq!(plan[0].kind, BoundaryKind::Silence);
631 assert_eq!(plan.last().unwrap().window.end_sample, n);
632 }
633
634 #[test]
635 fn continuous_noise_uses_overlap() {
636 let sr = WHISPER_SAMPLE_RATE;
637 let n = (500.0 * f64::from(sr)) as usize;
638 let samples = vec![0.4f32; n];
639 let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
640 assert!(plan.len() >= 2);
641 assert!(plan.iter().any(|p| matches!(
642 p.kind,
643 BoundaryKind::TargetWithOverlap | BoundaryKind::FixedFallback
644 )));
645 assert_eq!(plan[0].overlap_secs, 0.0);
647 for w in plan.windows(2) {
648 if w[1].overlap_secs > 0.0 {
649 assert!(w[1].window.start_sample < w[0].window.end_sample);
650 }
651 }
652 assert!(
653 plan.iter().skip(1).any(|p| p.overlap_secs > 0.0),
654 "expected at least one later window with predecessor overlap"
655 );
656 assert_eq!(plan[0].window.start_sample, 0);
657 assert_eq!(plan.last().unwrap().window.end_sample, n);
658 }
659
660 #[test]
661 fn min_silence_zero_rejected() {
662 let p = LongFormPolicy {
663 min_silence_secs: 0.0,
664 ..Default::default()
665 };
666 assert!(p.validate().is_err());
667 }
668
669 #[test]
670 fn two_window_hard_cut_overlap_on_later() {
671 let sr = WHISPER_SAMPLE_RATE;
672 let n = (420.0 * f64::from(sr)) as usize;
674 let samples = vec![0.5f32; n];
675 let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
676 assert!(plan.len() >= 2, "plan len {}", plan.len());
677 assert_eq!(plan[0].overlap_secs, 0.0);
678 if plan[0].kind == BoundaryKind::TargetWithOverlap || plan.len() == 2 {
680 assert!(
681 plan[1].overlap_secs > 0.0,
682 "later window overlap_secs={}, kind0={:?}",
683 plan[1].overlap_secs,
684 plan[0].kind
685 );
686 assert!(plan[1].window.start_sample < plan[0].window.end_sample);
687 }
688 }
689
690 #[test]
691 fn dedupe_exact_overlap() {
692 let d = dedupe_overlap_text(
693 "the quick brown fox jumps over",
694 "fox jumps over the lazy dog",
695 );
696 assert!(d.confident);
697 assert!(d.dropped_prefix_tokens >= 3);
698 assert_eq!(d.text.trim(), "the lazy dog");
699 }
700
701 #[test]
702 fn dedupe_low_confidence_retains() {
703 let d = dedupe_overlap_text("alpha beta gamma", "delta epsilon zeta");
704 assert!(!d.confident);
705 assert_eq!(d.text, "delta epsilon zeta");
706 assert!(d.warning.is_some());
707 }
708
709 #[test]
710 fn srt_approximate_gate() {
711 assert!(srt_requires_allow_approximate(&[
712 TimestampSource::ChunkOffset,
713 TimestampSource::Interpolated
714 ]));
715 assert!(!srt_requires_allow_approximate(&[
716 TimestampSource::NativeModel,
717 TimestampSource::ChunkOffset
718 ]));
719 assert!(!derive_timestamps_reliable(&[
720 TimestampSource::Interpolated
721 ]));
722 assert!(derive_timestamps_reliable(&[
723 TimestampSource::ProviderSegment
724 ]));
725 }
726
727 #[test]
728 fn stitch_text_with_overlap_dedupes() {
729 let (text, warns) = stitch_text_with_overlap(&[
730 ("hello world from aurum".into(), 0.0),
731 ("from aurum systems".into(), 1.5),
732 ]);
733 assert!(text.contains("hello"));
734 assert!(text.contains("systems"));
735 assert!(warns.is_empty() || text.contains("from"));
736 }
737}