Skip to main content

aurum_core/remote/
long_form.rs

1//! Boundary-aware long-form STT planning, overlap stitch, and policy (JOE-2219).
2//!
3//! Replaces blind non-overlapping windows with silence-aware cuts and a bounded
4//! overlap when no quiet region exists. Deduplication is deterministic and never
5//! drops low-confidence text.
6
7use crate::error::{Result, UserError};
8use crate::providers::Segment;
9use crate::remote::stt_chunk::{ChunkWindow, DEFAULT_REMOTE_STT_CHUNK_SECS};
10use serde::{Deserialize, Serialize};
11
12/// Default silence search half-width around the target cut (±seconds).
13pub const DEFAULT_BOUNDARY_SEARCH_SECS: f64 = 15.0;
14/// Default minimum quiet duration to accept a silence boundary (seconds).
15pub const DEFAULT_MIN_SILENCE_SECS: f64 = 0.25;
16/// Default overlap when cutting without silence (seconds).
17pub const DEFAULT_OVERLAP_SECS: f64 = 1.5;
18/// Maximum overlap as a fraction of chunk duration.
19pub const DEFAULT_MAX_OVERLAP_FRACTION: f64 = 0.05;
20/// Max tokens examined for overlap dedupe.
21pub const MAX_DEDUPE_TOKENS: usize = 40;
22/// Minimum exact token overlap required to drop a later-chunk prefix.
23pub const MIN_DEDUPE_TOKENS: usize = 3;
24
25/// How a segment's timing was obtained (JOE-2219).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum TimestampSource {
29    /// Local model timing.
30    NativeModel,
31    /// Word timing from a remote ASR provider.
32    ProviderWord,
33    /// Segment timing from a provider.
34    ProviderSegment,
35    /// Provider timing shifted by chunk start offset.
36    ChunkOffset,
37    /// Aurum split text and estimated time proportionally.
38    Interpolated,
39    /// One full-duration span created because no provider timing existed.
40    SyntheticSpan,
41    /// No usable timing.
42    #[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    /// Conservative reliability for the legacy `timestamps_reliable` boolean.
60    pub fn is_reliable(self) -> bool {
61        matches!(
62            self,
63            Self::NativeModel | Self::ProviderWord | Self::ProviderSegment | Self::ChunkOffset
64        )
65    }
66
67    /// Approximate / non-native timing that SRT rejects by default.
68    pub fn is_approximate(self) -> bool {
69        matches!(
70            self,
71            Self::Interpolated | Self::SyntheticSpan | Self::Unavailable
72        )
73    }
74}
75
76/// Why a planned cut was chosen.
77#[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/// Validated long-form chunking policy (not free-form env vars).
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct LongFormPolicy {
89    /// Target window length in seconds (~210 product default).
90    pub target_secs: f64,
91    pub min_secs: f64,
92    pub max_secs: f64,
93    /// Search ± this many seconds around the target for silence.
94    pub search_secs: f64,
95    pub min_silence_secs: f64,
96    /// RMS threshold relative to peak (0..1) for "quiet".
97    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    /// Build from env `AURUM_REMOTE_STT_CHUNK_SECS` when set, else defaults.
164    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/// One planned window with boundary metadata.
179#[derive(Debug, Clone, PartialEq)]
180pub struct PlannedWindow {
181    pub window: ChunkWindow,
182    pub kind: BoundaryKind,
183    /// Overlap duration in seconds with the previous window (0 for first).
184    pub overlap_secs: f64,
185}
186
187/// Plan silence-aware (or overlap) windows covering all samples.
188pub 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            // Hard cut at ideal with overlap into next window.
259            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        // Advance: for overlap cuts, next window starts `overlap` samples before end.
285        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            // Degenerate: force progress without infinite loop.
293            start = end;
294        } else {
295            start = next;
296        }
297    }
298
299    // Coverage: first starts at 0, last ends at total, no gaps in exclusive coverage of unique audio.
300    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
327/// Find the earliest quiet region of `min_silence` samples with lowest mean energy.
328fn 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; // (energy, cut_end)
339    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// ---------------------------------------------------------------------------
373// Overlap text / segment deduplication
374// ---------------------------------------------------------------------------
375
376/// Result of stitching two transcript pieces across an overlap.
377#[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
385/// Normalize tokens for dedupe (lowercase alnum words).
386pub 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
393/// Deduplicate `later` against the suffix of `earlier` within a bounded token window.
394///
395/// Never drops content when confidence is below threshold.
396pub 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        // Drop the first `best` raw tokens from later, preserving remaining punctuation.
421        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                // skip whitespace after dropped tokens
461                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    // Advance past leftover whitespace
476    let rest = s[cut..].trim_start();
477    rest.to_string()
478}
479
480/// Join transcript parts with overlap-aware dedupe (deterministic).
481pub 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
514/// Deduplicate segments across an overlap boundary using text + time evidence.
515pub 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    // If the first later segment text is a prefix-overlap of the last earlier segment, drop it.
528    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            // Keep times; source remains whatever it was (usually chunk_offset).
539            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
554/// Whether SRT should fail closed for this provenance set.
555pub fn srt_requires_allow_approximate(sources: &[TimestampSource]) -> bool {
556    sources.iter().any(|s| s.is_approximate())
557}
558
559/// Derive legacy `timestamps_reliable` from provenance.
560pub 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        // 400s of speech-like noise with silence near 210s.
594        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; // 2s silence
598        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        // First cut should land near silence, not exactly 210 if silence found.
604        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        // Overlap means next start < previous end
622        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}