use smol_str::SmolStr;
use crate::{
align::script::{CharClass, SegmentContext, script_to_lang},
types::Lang,
};
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Run {
language: Lang,
text: SmolStr,
audio_t0_ms: i64,
audio_t1_ms: i64,
source_segment_idx: i32,
bounds_source: BoundsSource,
}
impl Run {
pub(crate) const fn new(
language: Lang,
text: SmolStr,
audio_t0_ms: i64,
audio_t1_ms: i64,
source_segment_idx: i32,
bounds_source: BoundsSource,
) -> Self {
Self {
language,
text,
audio_t0_ms,
audio_t1_ms,
source_segment_idx,
bounds_source,
}
}
#[must_use]
pub fn language(&self) -> &Lang {
&self.language
}
#[must_use]
pub fn text(&self) -> &str {
self.text.as_str()
}
#[must_use]
pub const fn audio_t0_ms(&self) -> i64 {
self.audio_t0_ms
}
#[must_use]
pub const fn audio_t1_ms(&self) -> i64 {
self.audio_t1_ms
}
#[must_use]
pub const fn source_segment_idx(&self) -> i32 {
self.source_segment_idx
}
#[must_use]
pub const fn bounds_source(&self) -> BoundsSource {
self.bounds_source
}
#[must_use]
pub fn with_language(mut self, language: Lang) -> Self {
self.language = language;
self
}
pub fn set_language(&mut self, language: Lang) {
self.language = language;
}
#[must_use]
pub fn with_text(mut self, text: SmolStr) -> Self {
self.text = text;
self
}
pub fn set_text(&mut self, text: SmolStr) {
self.text = text;
}
#[must_use]
pub const fn with_audio_t0_ms(mut self, audio_t0_ms: i64) -> Self {
self.audio_t0_ms = audio_t0_ms;
self
}
pub const fn set_audio_t0_ms(&mut self, audio_t0_ms: i64) {
self.audio_t0_ms = audio_t0_ms;
}
#[must_use]
pub const fn with_audio_t1_ms(mut self, audio_t1_ms: i64) -> Self {
self.audio_t1_ms = audio_t1_ms;
self
}
pub const fn set_audio_t1_ms(&mut self, audio_t1_ms: i64) {
self.audio_t1_ms = audio_t1_ms;
}
#[must_use]
pub const fn with_source_segment_idx(mut self, source_segment_idx: i32) -> Self {
self.source_segment_idx = source_segment_idx;
self
}
pub const fn set_source_segment_idx(&mut self, source_segment_idx: i32) {
self.source_segment_idx = source_segment_idx;
}
#[must_use]
pub const fn with_bounds_source(mut self, bounds_source: BoundsSource) -> Self {
self.bounds_source = bounds_source;
self
}
pub const fn set_bounds_source(&mut self, bounds_source: BoundsSource) {
self.bounds_source = bounds_source;
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BoundsSource {
Dtw,
Segment,
Wholeclip,
}
pub trait SegmentLike {
fn text(&self) -> &str;
fn t0(&self) -> i64;
fn t1(&self) -> i64;
fn tokens(&self) -> Vec<TokenInfo>;
}
#[derive(Clone, Copy, Debug)]
pub struct TokenInfo {
byte_offset: usize,
byte_len: usize,
t_dtw_cs: Option<i64>,
}
impl TokenInfo {
#[must_use]
pub const fn new(byte_offset: usize, byte_len: usize, t_dtw_cs: Option<i64>) -> Self {
Self {
byte_offset,
byte_len,
t_dtw_cs,
}
}
#[must_use]
pub const fn byte_offset(&self) -> usize {
self.byte_offset
}
#[must_use]
pub const fn byte_len(&self) -> usize {
self.byte_len
}
#[must_use]
pub const fn t_dtw_cs(&self) -> Option<i64> {
self.t_dtw_cs
}
pub const fn byte_range(&self) -> core::ops::Range<usize> {
self.byte_offset..self.byte_offset + self.byte_len
}
}
const fn cs_to_ms(cs: i64) -> i64 {
cs.saturating_mul(10)
}
#[must_use]
pub fn dispatch_segments<S: SegmentLike>(segments: &[S], state_lang: Option<Lang>) -> Vec<Run> {
let mut runs = Vec::new();
let state_lang_ref = state_lang.as_ref();
for (idx, seg) in segments.iter().enumerate() {
let text = seg.text();
if text.is_empty() {
continue;
}
let ctx = SegmentContext::from_text(text);
let seg_t0_cs = seg.t0();
let seg_t1_cs = seg.t1();
let tokens = seg.tokens();
let source_idx = i32::try_from(idx).unwrap_or(i32::MAX);
let mut carved: Vec<CarvedRun> = Vec::new();
let mut current_lang: Option<Lang> = None;
let mut run_byte_start: usize = 0;
let mut run_char_start: usize = 0;
let mut char_idx: usize = 0;
for (byte_idx, ch) in text.char_indices() {
let class = script_to_lang(ch, ctx, state_lang_ref);
match class {
CharClass::Carry => {}
CharClass::Lang(lang) => match ¤t_lang {
None => {
current_lang = Some(lang);
}
Some(active) if *active == lang => {}
Some(_) => {
let active = current_lang.take().expect("checked Some above");
carved.push(CarvedRun {
lang: active,
byte_range: run_byte_start..byte_idx,
char_range: run_char_start..char_idx,
});
run_byte_start = byte_idx;
run_char_start = char_idx;
current_lang = Some(lang);
}
},
}
char_idx += 1;
}
let text_char_count = char_idx;
if let Some(active) = current_lang {
carved.push(CarvedRun {
lang: active,
byte_range: run_byte_start..text.len(),
char_range: run_char_start..text_char_count,
});
}
let mut raw_bounds: Vec<RawBounds> = Vec::with_capacity(carved.len());
for run in &carved {
let run_dtw = collect_run_dtw(&tokens, run.byte_range.start, run.byte_range.end);
let mut bounds = extract_raw_bounds(&run_dtw.dtw);
if run_dtw.had_boundary_token {
bounds.all_some = false;
}
raw_bounds.push(bounds);
}
let multi_run = carved.len() > 1;
let span_cs_opt: Option<i64> = if seg_t0_cs == i64::MIN || seg_t1_cs == i64::MIN {
None
} else {
seg_t1_cs.checked_sub(seg_t0_cs)
};
for (i, run) in carved.iter().enumerate() {
let next_lo_cs = if i + 1 < carved.len() {
let next_raw = &raw_bounds[i + 1];
if next_raw.all_some
&& let Some(lo) = next_raw.lo_dtw_cs
{
Some(lo)
} else if let (Some(span), true) = (span_cs_opt, text_char_count > 0) {
let frac = carved[i + 1].char_range.start as f64 / text_char_count as f64;
Some((seg_t0_cs as f64 + frac * span as f64).round() as i64)
} else {
None
}
} else {
None
};
let (t0_ms, t1_ms, bounds_source) = compute_run_bounds(
seg_t0_cs,
seg_t1_cs,
&raw_bounds[i],
run.char_range.start,
run.char_range.end,
text_char_count,
multi_run,
next_lo_cs,
);
push_run(
&mut runs,
run.lang.clone(),
&text[run.byte_range.clone()],
t0_ms,
t1_ms,
source_idx,
bounds_source,
);
}
}
runs
}
struct CarvedRun {
lang: Lang,
byte_range: core::ops::Range<usize>,
char_range: core::ops::Range<usize>,
}
struct RawBounds {
lo_dtw_cs: Option<i64>,
hi_dtw_cs: Option<i64>,
all_some: bool,
}
struct RunDtw {
dtw: Vec<Option<i64>>,
had_boundary_token: bool,
}
fn collect_run_dtw(tokens: &[TokenInfo], run_start: usize, run_end: usize) -> RunDtw {
let mut dtw = Vec::new();
let mut had_boundary_token = false;
for tok in tokens {
if tok.byte_len == 0 {
continue;
}
let tok_start = tok.byte_offset;
let tok_end = tok.byte_offset.saturating_add(tok.byte_len);
if tok_start >= run_start && tok_end <= run_end {
dtw.push(tok.t_dtw_cs);
continue;
}
let overlap_start = tok_start.max(run_start);
let overlap_end = tok_end.min(run_end);
if overlap_end > overlap_start {
had_boundary_token = true;
}
}
RunDtw {
dtw,
had_boundary_token,
}
}
fn extract_raw_bounds(dtw_slice: &[Option<i64>]) -> RawBounds {
if dtw_slice.is_empty() {
return RawBounds {
lo_dtw_cs: None,
hi_dtw_cs: None,
all_some: false,
};
}
let all_some = dtw_slice.iter().all(Option::is_some);
let mut lo: Option<i64> = None;
let mut hi: Option<i64> = None;
for v in dtw_slice.iter().filter_map(|v| *v) {
lo = Some(match lo {
None => v,
Some(l) => l.min(v),
});
hi = Some(match hi {
None => v,
Some(h) => h.max(v),
});
}
RawBounds {
lo_dtw_cs: lo,
hi_dtw_cs: hi,
all_some,
}
}
fn compute_run_bounds(
seg_t0_cs: i64,
seg_t1_cs: i64,
raw: &RawBounds,
run_char_start: usize,
run_char_end: usize,
text_char_count: usize,
multi_run: bool,
next_run_lo_dtw_cs: Option<i64>,
) -> (i64, i64, BoundsSource) {
if raw.all_some
&& let (Some(lo), Some(hi_point)) = (raw.lo_dtw_cs, raw.hi_dtw_cs)
{
let exclusive_hi_opt: Option<i64> = if let Some(next_lo) = next_run_lo_dtw_cs {
if next_lo <= lo {
None
} else if seg_t1_cs != i64::MIN {
Some(next_lo.min(seg_t1_cs))
} else {
Some(next_lo)
}
} else if seg_t1_cs != i64::MIN && seg_t1_cs > hi_point {
Some(seg_t1_cs)
} else {
Some(hi_point.saturating_add(1))
};
if let Some(exclusive_hi) = exclusive_hi_opt
&& exclusive_hi > lo
{
return (cs_to_ms(lo), cs_to_ms(exclusive_hi), BoundsSource::Dtw);
}
}
if seg_t0_cs == i64::MIN || seg_t1_cs == i64::MIN {
return (i64::MIN, i64::MAX, BoundsSource::Wholeclip);
}
if !multi_run || text_char_count == 0 {
return (
cs_to_ms(seg_t0_cs),
cs_to_ms(seg_t1_cs),
BoundsSource::Segment,
);
}
let lo_frac = run_char_start as f64 / text_char_count as f64;
let hi_frac = run_char_end as f64 / text_char_count as f64;
let span = match seg_t1_cs.checked_sub(seg_t0_cs) {
Some(s) => s as f64,
None => return (i64::MIN, i64::MAX, BoundsSource::Wholeclip),
};
let interp_lo_cs = (seg_t0_cs as f64 + lo_frac * span).round() as i64;
let mut interp_hi_cs = (seg_t0_cs as f64 + hi_frac * span).round() as i64;
if interp_hi_cs <= interp_lo_cs {
interp_hi_cs = interp_lo_cs.saturating_add(1);
}
(
cs_to_ms(interp_lo_cs),
cs_to_ms(interp_hi_cs),
BoundsSource::Segment,
)
}
#[allow(clippy::too_many_arguments)]
fn push_run(
runs: &mut Vec<Run>,
language: Lang,
text: &str,
audio_t0_ms: i64,
audio_t1_ms: i64,
source_segment_idx: i32,
bounds_source: BoundsSource,
) {
if text.is_empty() {
return;
}
runs.push(Run::new(
language,
SmolStr::new(text),
audio_t0_ms,
audio_t1_ms,
source_segment_idx,
bounds_source,
));
}
fn compute_bounds(
seg_t0_cs: i64,
seg_t1_cs: i64,
dtw_cs: &[Option<i64>],
) -> (i64, i64, BoundsSource) {
if !dtw_cs.is_empty() && dtw_cs.iter().all(Option::is_some) {
let mut iter = dtw_cs.iter().filter_map(|v| *v);
if let Some(first) = iter.next() {
let mut lo = first;
let mut hi_point = first;
for v in iter {
if v < lo {
lo = v;
}
if v > hi_point {
hi_point = v;
}
}
let exclusive_hi = if seg_t1_cs != i64::MIN && seg_t1_cs > hi_point {
seg_t1_cs
} else {
hi_point.saturating_add(1)
};
if exclusive_hi > lo {
return (cs_to_ms(lo), cs_to_ms(exclusive_hi), BoundsSource::Dtw);
}
}
}
if seg_t0_cs != i64::MIN && seg_t1_cs != i64::MIN {
return (
cs_to_ms(seg_t0_cs),
cs_to_ms(seg_t1_cs),
BoundsSource::Segment,
);
}
(i64::MIN, i64::MAX, BoundsSource::Wholeclip)
}
#[cfg(feature = "runner")]
mod runner_glue {
use super::{SegmentLike, TokenInfo};
pub(super) struct SegmentRef<'a, 'seg> {
pub seg: &'a whispercpp::Segment<'seg>,
pub ctx: &'a whispercpp::Context,
}
impl<'a, 'seg> SegmentLike for SegmentRef<'a, 'seg> {
fn text(&self) -> &str {
self.seg.text().unwrap_or("")
}
fn t0(&self) -> i64 {
self.seg.t0()
}
fn t1(&self) -> i64 {
self.seg.t1()
}
fn tokens(&self) -> Vec<TokenInfo> {
let segment_text = self.seg.text().unwrap_or("");
let mut out = Vec::new();
let mut offset: usize = 0;
let mut accumulated: Vec<u8> = Vec::with_capacity(segment_text.len());
let mut tokens_iter = self.seg.tokens_iter();
let mut any_token_seen = false;
for tok in tokens_iter.by_ref() {
any_token_seen = true;
let bytes = self.ctx.token_to_bytes(tok.id()).unwrap_or(&[]);
let byte_len = bytes.len();
accumulated.extend_from_slice(bytes);
out.push(TokenInfo {
byte_offset: offset,
byte_len,
t_dtw_cs: tok.t_dtw(),
});
offset = offset.saturating_add(byte_len);
}
if any_token_seen && accumulated.as_slice() != segment_text.as_bytes() {
std::eprintln!(
"[asry] script_dispatch: token-byte stream diverges from segment text \
(tokens={} bytes={} segment={} bytes); falling back to segment bounds for this \
segment to avoid mis-attributing DTW timestamps. Likely cause: model tokenisation \
normalises bytes (leading-space stripping, special-token rendering) the dispatcher \
cannot reconstruct from token ids.",
out.len(),
accumulated.len(),
segment_text.len(),
);
for tok in &mut out {
tok.byte_len = 0;
}
}
out
}
}
}
#[cfg(feature = "runner")]
#[must_use]
pub fn dispatch(
ctx: &whispercpp::Context,
segments: &[whispercpp::Segment<'_>],
state_lang: Option<Lang>,
) -> Vec<Run> {
let wrapped: Vec<runner_glue::SegmentRef<'_, '_>> = segments
.iter()
.map(|seg| runner_glue::SegmentRef { seg, ctx })
.collect();
dispatch_segments(&wrapped, state_lang)
}
#[cfg(test)]
mod tests {
use super::*;
struct MockSeg {
text: String,
t0_cs: i64,
t1_cs: i64,
tokens: Vec<TokenInfo>,
}
impl SegmentLike for MockSeg {
fn text(&self) -> &str {
&self.text
}
fn t0(&self) -> i64 {
self.t0_cs
}
fn t1(&self) -> i64 {
self.t1_cs
}
fn tokens(&self) -> Vec<TokenInfo> {
self.tokens.clone()
}
}
fn seg(text: &str, t0_cs: i64, t1_cs: i64, dtw_cs: Vec<Option<i64>>) -> MockSeg {
let n = dtw_cs.len();
let text_byte_len = text.len();
let tokens = if n == 0 {
Vec::new()
} else {
let chunk = text_byte_len.div_ceil(n).max(1);
dtw_cs
.iter()
.enumerate()
.map(|(i, &t)| {
let off = (i * chunk).min(text_byte_len);
let len = chunk.min(text_byte_len.saturating_sub(off));
TokenInfo {
byte_offset: off,
byte_len: len,
t_dtw_cs: t,
}
})
.collect()
};
MockSeg {
text: String::from(text),
t0_cs,
t1_cs,
tokens,
}
}
fn seg_with_tokens(
text: &str,
t0_cs: i64,
t1_cs: i64,
tokens: Vec<(usize, usize, Option<i64>)>,
) -> MockSeg {
MockSeg {
text: String::from(text),
t0_cs,
t1_cs,
tokens: tokens
.into_iter()
.map(|(byte_offset, byte_len, t_dtw_cs)| TokenInfo {
byte_offset,
byte_len,
t_dtw_cs,
})
.collect(),
}
}
#[test]
fn empty_segments_produce_no_runs() {
let runs = dispatch_segments::<MockSeg>(&[], None);
assert!(runs.is_empty());
}
#[test]
fn empty_text_segment_produces_no_runs() {
let segs = vec![seg("", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert!(runs.is_empty());
}
#[test]
fn pure_english_one_run() {
let segs = vec![seg("hello world", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[0].text(), "hello world");
assert_eq!(runs[0].source_segment_idx(), 0);
}
#[test]
fn pure_chinese_one_run() {
let segs = vec![seg("你好世界", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::Zh);
assert_eq!(runs[0].text(), "你好世界");
}
#[test]
fn pure_japanese_with_kana() {
let segs = vec![seg("これは日本語です", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::Ja);
assert_eq!(runs[0].text(), "これは日本語です");
}
#[test]
fn pure_korean_with_hangul() {
let segs = vec![seg("안녕하세요", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::Ko);
assert_eq!(runs[0].text(), "안녕하세요");
}
#[test]
fn english_chinese_codeswitch() {
let segs = vec![seg("hello 你好", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[1].language(), &Lang::Zh);
assert_eq!(runs[0].text(), "hello ");
assert_eq!(runs[1].text(), "你好");
}
#[test]
fn ja_zh_kana_precedence_makes_all_han_ja() {
let segs = vec![seg("漢字あ漢字", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::Ja);
}
#[test]
fn hangul_makes_han_ko_in_segment() {
let segs = vec![seg("漢字한국", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::Ko);
assert_eq!(runs[0].text(), "漢字한국");
}
#[test]
fn punctuation_does_not_split_runs() {
let segs = vec![seg("hello, world.", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[0].text(), "hello, world.");
}
#[test]
fn digits_carry_into_active_run() {
let segs = vec![seg("test 123", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[0].text(), "test 123");
}
#[test]
fn leading_punctuation_attaches_to_first_run() {
let segs = vec![seg(" hello", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text(), " hello");
}
#[test]
fn pure_punctuation_produces_no_run() {
let segs = vec![seg("...!?", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert!(runs.is_empty());
}
#[test]
fn dtw_available_uses_dtw_bounds_widened_to_segment_end() {
let segs = vec![seg(
"hello",
50,
200,
vec![Some(70), Some(90), Some(120), Some(180), Some(190)],
)];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].bounds_source(), BoundsSource::Dtw);
assert_eq!(runs[0].audio_t0_ms(), 700);
assert_eq!(runs[0].audio_t1_ms(), 2000);
}
#[test]
fn dtw_single_token_widens_to_segment_end() {
let segs = vec![seg("hi", 50, 200, vec![Some(70)])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].bounds_source(), BoundsSource::Dtw);
assert_eq!(runs[0].audio_t0_ms(), 700);
assert_eq!(runs[0].audio_t1_ms(), 2000);
}
#[test]
fn dtw_collapsed_timestamps_widen_to_segment_end() {
let segs = vec![seg("hi", 50, 200, vec![Some(80), Some(80), Some(80)])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].bounds_source(), BoundsSource::Dtw);
assert_eq!(runs[0].audio_t0_ms(), 800);
assert_eq!(runs[0].audio_t1_ms(), 2000);
}
#[test]
fn codeswitch_segment_dtw_runs_are_monotonic_non_overlapping() {
let segs = vec![seg_with_tokens(
"hello你好world",
50,
300,
vec![
(0, 5, Some(70)), (5, 6, Some(140)), (11, 5, Some(210)), ],
)];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 3);
assert_eq!(runs[0].audio_t0_ms(), 700);
assert_eq!(runs[0].audio_t1_ms(), 1400);
assert_eq!(runs[0].bounds_source(), BoundsSource::Dtw);
assert_eq!(runs[1].audio_t0_ms(), 1400);
assert_eq!(runs[1].audio_t1_ms(), 2100);
assert_eq!(runs[2].audio_t0_ms(), 2100);
assert_eq!(runs[2].audio_t1_ms(), 3000);
assert_eq!(runs[0].audio_t1_ms(), runs[1].audio_t0_ms());
assert_eq!(runs[1].audio_t1_ms(), runs[2].audio_t0_ms());
}
#[test]
fn codeswitch_segment_without_dtw_uses_character_ratio_not_byte_ratio() {
let segs = vec![seg("hello你好world", 50, 300, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 3);
assert!(
runs
.iter()
.all(|r| r.bounds_source() == BoundsSource::Segment)
);
let r0_t0 = runs[0].audio_t0_ms();
let r0_t1 = runs[0].audio_t1_ms();
let r1_t0 = runs[1].audio_t0_ms();
let r1_t1 = runs[1].audio_t1_ms();
let r2_t0 = runs[2].audio_t0_ms();
let r2_t1 = runs[2].audio_t1_ms();
assert_eq!(r0_t0, 500);
assert!((r0_t1 - 1542).abs() <= 10, "En1 hi ≈ 1542ms, got {r0_t1}");
assert!((r1_t1 - r1_t0 - 417).abs() <= 10, "Zh span ≈ 417ms");
assert_eq!(r0_t1, r1_t0);
assert_eq!(r1_t1, r2_t0);
assert_eq!(r2_t1, 3000);
assert!(
r1_t1 - r1_t0 < r0_t1 - r0_t0,
"Zh (2 glyphs) must get less audio than En1 (5 glyphs); got {r1_t0}..{r1_t1} vs {r0_t0}..{r0_t1}"
);
assert!(
r1_t1 - r1_t0 < r2_t1 - r2_t0,
"Zh (2 glyphs) must get less audio than En2 (5 glyphs); got {r1_t0}..{r1_t1} vs {r2_t0}..{r2_t1}"
);
}
#[test]
fn boundary_spanning_dtw_token_does_not_double_count() {
let segs = vec![seg_with_tokens(
"hello你好",
50,
300,
vec![(0, 5, Some(70)), (4, 2, Some(120)), (5, 6, Some(180))],
)];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 2);
assert_eq!(
runs[0].bounds_source(),
BoundsSource::Segment,
"En run with a boundary-spanning token must fall back to Segment, not Dtw"
);
assert_eq!(runs[1].bounds_source(), BoundsSource::Segment);
assert!(runs[0].audio_t0_ms() < runs[1].audio_t0_ms());
assert!(runs[0].audio_t1_ms() <= runs[1].audio_t0_ms());
}
#[test]
fn dtw_run_capped_at_immediate_next_segment_run_not_skipping_ahead() {
let segs = vec![seg_with_tokens(
"abc你def",
0,
200,
vec![
(0, 3, Some(50)), (3, 3, None), (6, 3, Some(180)), ],
)];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 3);
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[1].language(), &Lang::Zh);
assert_eq!(runs[2].language(), &Lang::En);
assert_eq!(runs[0].bounds_source(), BoundsSource::Dtw);
assert_eq!(runs[1].bounds_source(), BoundsSource::Segment);
assert_eq!(runs[2].bounds_source(), BoundsSource::Dtw);
assert!(
runs[0].audio_t1_ms() <= runs[1].audio_t0_ms(),
"Run 0 (Dtw) end {} must not extend past Run 1 (Segment) start {}",
runs[0].audio_t1_ms(),
runs[1].audio_t0_ms()
);
assert!(
runs[1].audio_t1_ms() <= runs[2].audio_t0_ms(),
"Run 1 (Segment) end {} must not extend past Run 2 (Dtw) start {}",
runs[1].audio_t1_ms(),
runs[2].audio_t0_ms()
);
}
#[test]
fn segment_with_min_t0_does_not_panic_or_wrap() {
let segs = vec![seg("hello你好", i64::MIN, 200, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 2);
for r in &runs {
assert_eq!(
r.bounds_source(),
BoundsSource::Wholeclip,
"sentinel t0 should route to Wholeclip; got {:?}",
r.bounds_source()
);
}
}
#[test]
fn dtw_cap_skips_partial_dtw_neighbour() {
let segs = vec![seg_with_tokens(
"abc你好def",
0,
200,
vec![
(0, 3, Some(50)), (3, 3, Some(120)), (6, 3, None), (9, 3, Some(180)), ],
)];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 3);
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[1].language(), &Lang::Zh);
assert_eq!(runs[2].language(), &Lang::En);
assert_eq!(runs[0].bounds_source(), BoundsSource::Dtw);
assert_eq!(
runs[1].bounds_source(),
BoundsSource::Segment,
"partial DTW slice → Segment fallback"
);
assert_eq!(runs[2].bounds_source(), BoundsSource::Dtw);
assert!(
runs[0].audio_t1_ms() <= runs[1].audio_t0_ms(),
"Run 0 (Dtw) end {} must not extend past Run 1 (Segment) start {}",
runs[0].audio_t1_ms(),
runs[1].audio_t0_ms()
);
assert!(
runs[1].audio_t1_ms() <= runs[2].audio_t0_ms(),
"Run 1 (Segment) end {} must not extend past Run 2 (Dtw) start {}",
runs[1].audio_t1_ms(),
runs[2].audio_t0_ms()
);
}
#[test]
fn dtw_lo_after_next_interp_lo_demotes_to_segment() {
let segs = vec![seg_with_tokens(
"abc你好",
0,
200,
vec![
(0, 3, Some(180)), (3, 3, Some(150)), (6, 3, None),
],
)];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[1].language(), &Lang::Zh);
assert_eq!(
runs[0].bounds_source(),
BoundsSource::Segment,
"DTW lo > next interp lo must demote to Segment, got {:?}",
runs[0].bounds_source()
);
assert_eq!(runs[1].bounds_source(), BoundsSource::Segment);
assert!(
runs[0].audio_t1_ms() <= runs[1].audio_t0_ms(),
"post-fix runs must be monotone: run0 ends at {}, run1 starts at {}",
runs[0].audio_t1_ms(),
runs[1].audio_t0_ms()
);
assert_eq!(runs[0].audio_t0_ms(), 0);
assert_eq!(runs[0].audio_t1_ms(), 1200);
assert_eq!(runs[1].audio_t0_ms(), 1200);
assert_eq!(runs[1].audio_t1_ms(), 2000);
}
#[test]
fn neutralised_tokens_fall_back_to_segment_bounds() {
let segs = vec![seg_with_tokens(
"hello",
50,
200,
vec![(0, 0, Some(70)), (0, 0, Some(140))],
)];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[0].bounds_source(), BoundsSource::Segment);
assert_eq!(runs[0].audio_t0_ms(), 500);
assert_eq!(runs[0].audio_t1_ms(), 2000);
}
#[test]
fn dtw_single_token_without_segment_envelope_widens_one_quantum() {
let segs = vec![seg("hi", i64::MIN, i64::MIN, vec![Some(70)])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].bounds_source(), BoundsSource::Dtw);
assert_eq!(runs[0].audio_t0_ms(), 700);
assert_eq!(runs[0].audio_t1_ms(), 710);
}
#[test]
fn dtw_partial_falls_back_to_segment() {
let segs = vec![seg("hello", 50, 200, vec![Some(70), None, Some(120)])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].bounds_source(), BoundsSource::Segment);
assert_eq!(runs[0].audio_t0_ms(), 500);
assert_eq!(runs[0].audio_t1_ms(), 2000);
}
#[test]
fn dtw_absent_uses_segment() {
let segs = vec![seg("hello", 50, 200, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].bounds_source(), BoundsSource::Segment);
assert_eq!(runs[0].audio_t0_ms(), 500);
assert_eq!(runs[0].audio_t1_ms(), 2000);
}
#[test]
fn segment_unavailable_falls_back_to_wholeclip() {
let segs = vec![seg("hello", i64::MIN, i64::MIN, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].bounds_source(), BoundsSource::Wholeclip);
assert_eq!(runs[0].audio_t0_ms(), i64::MIN);
assert_eq!(runs[0].audio_t1_ms(), i64::MAX);
}
#[test]
fn state_lang_disambiguates_latin_to_es() {
let segs = vec![seg("hola", 0, 100, vec![])];
let runs = dispatch_segments(&segs, Some(Lang::Es));
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::Es);
}
#[test]
fn latin_with_cjk_state_lang_routes_to_en() {
let segs = vec![seg("hello", 0, 100, vec![])];
let runs = dispatch_segments(&segs, Some(Lang::Zh));
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0].language(),
&Lang::En,
"Latin under Zh state_lang must route to En, not stay as Zh",
);
}
#[test]
fn mixed_latin_han_under_zh_hint_emits_two_runs() {
let segs = vec![seg("hello 你好", 0, 100, vec![])];
let runs = dispatch_segments(&segs, Some(Lang::Zh));
assert_eq!(
runs.len(),
2,
"expected separate En + Zh runs for code-switched chunk; got {runs:?}",
);
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[0].text(), "hello ");
assert_eq!(runs[1].language(), &Lang::Zh);
assert_eq!(runs[1].text(), "你好");
}
#[test]
fn latin_with_no_hint_still_falls_back_to_en() {
let segs = vec![seg("hello", 0, 100, vec![])];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].language(), &Lang::En);
}
#[test]
fn mixed_cjk_latin_segment_splits_under_cjk_hint() {
let segs = vec![seg("USAで", 0, 100, vec![])];
let runs = dispatch_segments(&segs, Some(Lang::Ja));
assert_eq!(runs.len(), 2, "expected En + Ja runs; got {runs:?}");
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[0].text(), "USA");
assert_eq!(runs[1].language(), &Lang::Ja);
assert_eq!(runs[1].text(), "で");
}
#[test]
fn multiple_segments_preserve_indices() {
let segs = vec![
seg("hello", 0, 50, vec![]),
seg("你好", 50, 100, vec![]),
seg("world", 100, 150, vec![]),
];
let runs = dispatch_segments(&segs, None);
assert_eq!(runs.len(), 3);
assert_eq!(runs[0].source_segment_idx(), 0);
assert_eq!(runs[1].source_segment_idx(), 1);
assert_eq!(runs[2].source_segment_idx(), 2);
assert_eq!(runs[0].language(), &Lang::En);
assert_eq!(runs[1].language(), &Lang::Zh);
assert_eq!(runs[2].language(), &Lang::En);
}
#[test]
fn run_accessors_round_trip() {
let r = Run::new(Lang::En, SmolStr::new("hi"), 100, 200, 3, BoundsSource::Dtw);
assert_eq!(r.language(), &Lang::En);
assert_eq!(r.text(), "hi");
assert_eq!(r.audio_t0_ms(), 100);
assert_eq!(r.audio_t1_ms(), 200);
assert_eq!(r.source_segment_idx(), 3);
assert_eq!(r.bounds_source(), BoundsSource::Dtw);
}
#[test]
fn run_with_setters_builder_style() {
let r = Run::new(
Lang::En,
SmolStr::new("hi"),
0,
100,
0,
BoundsSource::Segment,
)
.with_language(Lang::Es)
.with_text(SmolStr::new("hola"))
.with_audio_t0_ms(50)
.with_audio_t1_ms(150)
.with_source_segment_idx(7)
.with_bounds_source(BoundsSource::Dtw);
assert_eq!(r.language(), &Lang::Es);
assert_eq!(r.text(), "hola");
assert_eq!(r.audio_t0_ms(), 50);
assert_eq!(r.audio_t1_ms(), 150);
assert_eq!(r.source_segment_idx(), 7);
assert_eq!(r.bounds_source(), BoundsSource::Dtw);
}
#[test]
fn run_set_inplace() {
let mut r = Run::new(
Lang::En,
SmolStr::new("hi"),
0,
100,
0,
BoundsSource::Segment,
);
r.set_language(Lang::Es);
r.set_text(SmolStr::new("hola"));
r.set_audio_t0_ms(50);
r.set_audio_t1_ms(150);
r.set_source_segment_idx(7);
r.set_bounds_source(BoundsSource::Dtw);
assert_eq!(r.language(), &Lang::Es);
assert_eq!(r.text(), "hola");
assert_eq!(r.audio_t0_ms(), 50);
assert_eq!(r.audio_t1_ms(), 150);
assert_eq!(r.source_segment_idx(), 7);
assert_eq!(r.bounds_source(), BoundsSource::Dtw);
}
}