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 Ok(())
161 }
162
163 pub fn from_env_or_default() -> Self {
165 let mut p = Self::default();
166 if let Ok(s) = std::env::var("AURUM_REMOTE_STT_CHUNK_SECS") {
167 if let Ok(v) = s.trim().parse::<f64>() {
168 if v.is_finite() && v > 0.0 {
169 p.target_secs = v;
170 p.max_secs = p.max_secs.max(v);
171 }
172 }
173 }
174 p
175 }
176}
177
178#[derive(Debug, Clone, PartialEq)]
180pub struct PlannedWindow {
181 pub window: ChunkWindow,
182 pub kind: BoundaryKind,
183 pub overlap_secs: f64,
185}
186
187pub fn plan_boundary_windows(
189 samples: &[f32],
190 sample_rate: u32,
191 policy: &LongFormPolicy,
192) -> Result<Vec<PlannedWindow>> {
193 policy.validate()?;
194 let total = samples.len();
195 if total == 0 || sample_rate == 0 {
196 return Ok(vec![PlannedWindow {
197 window: ChunkWindow {
198 start_sample: 0,
199 end_sample: total,
200 offset_secs: 0.0,
201 },
202 kind: BoundaryKind::ShortSingle,
203 overlap_secs: 0.0,
204 }]);
205 }
206
207 let target = ((policy.target_secs * f64::from(sample_rate)).round() as usize).max(1);
208 if total <= target {
209 return Ok(vec![PlannedWindow {
210 window: ChunkWindow {
211 start_sample: 0,
212 end_sample: total,
213 offset_secs: 0.0,
214 },
215 kind: BoundaryKind::ShortSingle,
216 overlap_secs: 0.0,
217 }]);
218 }
219
220 let min_len = ((policy.min_secs * f64::from(sample_rate)).round() as usize).max(1);
221 let max_len = ((policy.max_secs * f64::from(sample_rate)).round() as usize).max(min_len);
222 let search = ((policy.search_secs * f64::from(sample_rate)).round() as usize).max(1);
223 let min_silence = ((policy.min_silence_secs * f64::from(sample_rate)).round() as usize).max(1);
224 let peak = peak_abs(samples).max(1e-9);
225 let quiet_thresh = peak * policy.silence_rms_ratio as f32;
226
227 let mut out = Vec::new();
228 let mut start = 0usize;
229 while start < total {
230 let remaining = total - start;
231 if remaining <= max_len {
232 out.push(PlannedWindow {
233 window: ChunkWindow {
234 start_sample: start,
235 end_sample: total,
236 offset_secs: start as f64 / f64::from(sample_rate),
237 },
238 kind: if out.is_empty() {
239 BoundaryKind::ShortSingle
240 } else {
241 BoundaryKind::Silence
242 },
243 overlap_secs: 0.0,
244 });
245 break;
246 }
247
248 let ideal = (start + target).min(total);
249 let search_lo = ideal.saturating_sub(search).max(start + min_len);
250 let search_hi = (ideal + search).min(start + max_len).min(total);
251
252 let silence_cut =
253 find_silence_boundary(samples, search_lo, search_hi, min_silence, quiet_thresh);
254
255 let (end, kind, overlap_secs) = if let Some(cut) = silence_cut {
256 (cut, BoundaryKind::Silence, 0.0f64)
257 } else {
258 let end = ideal.min(total);
260 let raw_overlap = ((policy.overlap_secs * f64::from(sample_rate)).round() as usize)
261 .min(((end - start) as f64 * policy.max_overlap_fraction).round() as usize);
262 let overlap = raw_overlap.min(end.saturating_sub(start) / 4);
263 (
264 end,
265 BoundaryKind::TargetWithOverlap,
266 overlap as f64 / f64::from(sample_rate),
267 )
268 };
269
270 let end = end.max(start + 1).min(total);
271 out.push(PlannedWindow {
272 window: ChunkWindow {
273 start_sample: start,
274 end_sample: end,
275 offset_secs: start as f64 / f64::from(sample_rate),
276 },
277 kind,
278 overlap_secs,
279 });
280
281 if end >= total {
282 break;
283 }
284 let overlap_samples = if matches!(kind, BoundaryKind::TargetWithOverlap) {
286 ((overlap_secs * f64::from(sample_rate)).round() as usize).min(end - start)
287 } else {
288 0
289 };
290 let next = end.saturating_sub(overlap_samples);
291 if next <= start {
292 start = end;
294 } else {
295 start = next;
296 }
297 }
298
299 if let Some(first) = out.first() {
301 if first.window.start_sample != 0 {
302 return Err(UserError::Other {
303 message: "long-form planner: first window must start at sample 0".into(),
304 }
305 .into());
306 }
307 }
308 if let Some(last) = out.last() {
309 if last.window.end_sample != total {
310 return Err(UserError::Other {
311 message: "long-form planner: last window must end at total samples".into(),
312 }
313 .into());
314 }
315 }
316 Ok(out)
317}
318
319fn peak_abs(samples: &[f32]) -> f32 {
320 let mut p = 0.0f32;
321 for &s in samples {
322 p = p.max(s.abs());
323 }
324 p
325}
326
327fn find_silence_boundary(
329 samples: &[f32],
330 lo: usize,
331 hi: usize,
332 min_silence: usize,
333 quiet_thresh: f32,
334) -> Option<usize> {
335 if hi <= lo + min_silence {
336 return None;
337 }
338 let mut best: Option<(f64, usize)> = None; let mut i = lo;
340 while i + min_silence <= hi {
341 let window = &samples[i..i + min_silence];
342 let mut energy = 0.0f64;
343 let mut all_quiet = true;
344 for &s in window {
345 let a = s.abs() as f64;
346 energy += a * a;
347 if s.abs() > quiet_thresh {
348 all_quiet = false;
349 break;
350 }
351 }
352 if all_quiet {
353 energy /= min_silence as f64;
354 let cut = i + min_silence / 2;
355 match best {
356 None => best = Some((energy, cut)),
357 Some((e, c)) => {
358 if energy < e - 1e-18 || ((energy - e).abs() < 1e-18 && cut < c) {
359 best = Some((energy, cut));
360 }
361 }
362 }
363 }
364 i += min_silence.max(1) / 2;
365 if i == lo {
366 i += 1;
367 }
368 }
369 best.map(|(_, cut)| cut)
370}
371
372#[derive(Debug, Clone, PartialEq)]
378pub struct DedupeOutcome {
379 pub text: String,
380 pub dropped_prefix_tokens: usize,
381 pub confident: bool,
382 pub warning: Option<String>,
383}
384
385pub fn normalize_tokens(s: &str) -> Vec<String> {
387 s.split(|c: char| !c.is_alphanumeric())
388 .filter(|w| !w.is_empty())
389 .map(|w| w.to_ascii_lowercase())
390 .collect()
391}
392
393pub fn dedupe_overlap_text(earlier: &str, later: &str) -> DedupeOutcome {
397 let earlier_t = normalize_tokens(earlier);
398 let later_t = normalize_tokens(later);
399 if earlier_t.is_empty() || later_t.is_empty() {
400 return DedupeOutcome {
401 text: later.to_string(),
402 dropped_prefix_tokens: 0,
403 confident: true,
404 warning: None,
405 };
406 }
407
408 let max_n = MAX_DEDUPE_TOKENS.min(earlier_t.len()).min(later_t.len());
409 let mut best = 0usize;
410 for n in (MIN_DEDUPE_TOKENS..=max_n).rev() {
411 let suffix = &earlier_t[earlier_t.len() - n..];
412 let prefix = &later_t[..n];
413 if suffix == prefix {
414 best = n;
415 break;
416 }
417 }
418
419 if best >= MIN_DEDUPE_TOKENS {
420 let stripped = drop_n_tokens(later, best);
422 DedupeOutcome {
423 text: stripped,
424 dropped_prefix_tokens: best,
425 confident: true,
426 warning: None,
427 }
428 } else {
429 DedupeOutcome {
430 text: later.to_string(),
431 dropped_prefix_tokens: 0,
432 confident: false,
433 warning: Some(
434 "overlap could not be resolved confidently; retained full later-chunk text".into(),
435 ),
436 }
437 }
438}
439
440fn drop_n_tokens(s: &str, n: usize) -> String {
441 if n == 0 {
442 return s.to_string();
443 }
444 let mut seen = 0usize;
445 let mut in_tok = false;
446 let mut cut = 0usize;
447 for (i, ch) in s.char_indices() {
448 if ch.is_alphanumeric() {
449 if !in_tok {
450 in_tok = true;
451 seen += 1;
452 if seen > n {
453 cut = i;
454 break;
455 }
456 }
457 } else {
458 in_tok = false;
459 if seen >= n {
460 cut = i;
462 if !ch.is_whitespace() {
463 break;
464 }
465 }
466 }
467 if seen >= n && !in_tok && !ch.is_whitespace() {
468 cut = i;
469 break;
470 }
471 }
472 if seen < n {
473 return String::new();
474 }
475 let rest = s[cut..].trim_start();
477 rest.to_string()
478}
479
480pub fn stitch_text_with_overlap(parts: &[(String, f64)]) -> (String, Vec<String>) {
482 let mut warnings = Vec::new();
483 if parts.is_empty() {
484 return (String::new(), warnings);
485 }
486 let mut out = parts[0].0.trim().to_string();
487 for (text, overlap_secs) in parts.iter().skip(1) {
488 let t = text.trim();
489 if t.is_empty() {
490 continue;
491 }
492 if *overlap_secs > 0.0 {
493 let d = dedupe_overlap_text(&out, t);
494 if let Some(w) = d.warning {
495 warnings.push(w);
496 }
497 if d.text.is_empty() {
498 continue;
499 }
500 if !out.is_empty() && !out.ends_with(char::is_whitespace) {
501 out.push(' ');
502 }
503 out.push_str(d.text.trim());
504 } else {
505 if !out.is_empty() && !out.ends_with(char::is_whitespace) {
506 out.push(' ');
507 }
508 out.push_str(t);
509 }
510 }
511 (out, warnings)
512}
513
514pub fn dedupe_segments_overlap(
516 earlier: &[Segment],
517 later: &[Segment],
518 overlap_secs: f64,
519 later_offset_secs: f64,
520) -> (Vec<Segment>, Option<String>) {
521 if later.is_empty() {
522 return (Vec::new(), None);
523 }
524 if overlap_secs <= 0.0 || earlier.is_empty() {
525 return (later.to_vec(), None);
526 }
527 let last_earlier = earlier.last().map(|s| s.text()).unwrap_or("");
529 let first_later = later[0].text();
530 let d = dedupe_overlap_text(last_earlier, first_later);
531 if d.confident && d.dropped_prefix_tokens > 0 {
532 let mut out = Vec::with_capacity(later.len());
533 if d.text.trim().is_empty() {
534 out.extend(later.iter().skip(1).cloned());
535 } else {
536 let mut first = later[0].clone();
537 first.set_text(d.text);
538 let _ = later_offset_secs;
540 out.push(first);
541 out.extend(later.iter().skip(1).cloned());
542 }
543 return (out, None);
544 }
545 if !d.confident {
546 return (
547 later.to_vec(),
548 Some("segment overlap not confidently deduped; retained later segments".into()),
549 );
550 }
551 (later.to_vec(), None)
552}
553
554pub fn srt_requires_allow_approximate(sources: &[TimestampSource]) -> bool {
556 sources.iter().any(|s| s.is_approximate())
557}
558
559pub fn derive_timestamps_reliable(sources: &[TimestampSource]) -> bool {
561 !sources.is_empty() && sources.iter().all(|s| s.is_reliable())
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use crate::audio::WHISPER_SAMPLE_RATE;
568
569 #[test]
570 fn policy_rejects_inverted_bounds() {
571 let p = LongFormPolicy {
572 min_secs: 250.0,
573 target_secs: 210.0,
574 ..Default::default()
575 };
576 assert!(p.validate().is_err());
577 }
578
579 #[test]
580 fn short_audio_single_window() {
581 let sr = WHISPER_SAMPLE_RATE;
582 let n = sr as usize * 30;
583 let samples = vec![0.1f32; n];
584 let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
585 assert_eq!(plan.len(), 1);
586 assert_eq!(plan[0].kind, BoundaryKind::ShortSingle);
587 assert_eq!(plan[0].window.end_sample, n);
588 }
589
590 #[test]
591 fn silence_boundary_preferred_over_hard_cut() {
592 let sr = WHISPER_SAMPLE_RATE;
593 let n = (400.0 * f64::from(sr)) as usize;
595 let mut samples = vec![0.3f32; n];
596 let silence_at = (205.0 * f64::from(sr)) as usize;
597 let silence_len = (sr as usize) * 2; for s in samples.iter_mut().skip(silence_at).take(silence_len) {
599 *s = 0.0;
600 }
601 let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
602 assert!(plan.len() >= 2);
603 let first_end = plan[0].window.end_sample as f64 / f64::from(sr);
605 assert!((200.0..220.0).contains(&first_end), "first end {first_end}");
606 assert_eq!(plan[0].kind, BoundaryKind::Silence);
607 assert_eq!(plan.last().unwrap().window.end_sample, n);
608 }
609
610 #[test]
611 fn continuous_noise_uses_overlap() {
612 let sr = WHISPER_SAMPLE_RATE;
613 let n = (500.0 * f64::from(sr)) as usize;
614 let samples = vec![0.4f32; n];
615 let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
616 assert!(plan.len() >= 2);
617 assert!(plan.iter().any(|p| matches!(
618 p.kind,
619 BoundaryKind::TargetWithOverlap | BoundaryKind::FixedFallback
620 )));
621 for w in plan.windows(2) {
623 if w[0].overlap_secs > 0.0 {
624 assert!(w[1].window.start_sample < w[0].window.end_sample);
625 }
626 }
627 assert_eq!(plan[0].window.start_sample, 0);
628 assert_eq!(plan.last().unwrap().window.end_sample, n);
629 }
630
631 #[test]
632 fn dedupe_exact_overlap() {
633 let d = dedupe_overlap_text(
634 "the quick brown fox jumps over",
635 "fox jumps over the lazy dog",
636 );
637 assert!(d.confident);
638 assert!(d.dropped_prefix_tokens >= 3);
639 assert_eq!(d.text.trim(), "the lazy dog");
640 }
641
642 #[test]
643 fn dedupe_low_confidence_retains() {
644 let d = dedupe_overlap_text("alpha beta gamma", "delta epsilon zeta");
645 assert!(!d.confident);
646 assert_eq!(d.text, "delta epsilon zeta");
647 assert!(d.warning.is_some());
648 }
649
650 #[test]
651 fn srt_approximate_gate() {
652 assert!(srt_requires_allow_approximate(&[
653 TimestampSource::ChunkOffset,
654 TimestampSource::Interpolated
655 ]));
656 assert!(!srt_requires_allow_approximate(&[
657 TimestampSource::NativeModel,
658 TimestampSource::ChunkOffset
659 ]));
660 assert!(!derive_timestamps_reliable(&[
661 TimestampSource::Interpolated
662 ]));
663 assert!(derive_timestamps_reliable(&[
664 TimestampSource::ProviderSegment
665 ]));
666 }
667
668 #[test]
669 fn stitch_text_with_overlap_dedupes() {
670 let (text, warns) = stitch_text_with_overlap(&[
671 ("hello world from aurum".into(), 0.0),
672 ("from aurum systems".into(), 1.5),
673 ]);
674 assert!(text.contains("hello"));
675 assert!(text.contains("systems"));
676 assert!(warns.is_empty() || text.contains("from"));
677 }
678}