simple_editor\engine/
transcribe.rs

1//! Speech → text (whisper.cpp), the subtitle generator on top of it, and the double-take detector.
2//!
3//! Nothing here ships in the exe: no model, no ML crate. The model is downloaded once into
4//! `Settings::cache_dir()\models` (visible progress, size named before the click) and the inference is a
5//! child process, exactly like ffmpeg (`media/ffpipe.rs`) and yt-dlp: locate the exe, run it, parse its
6//! stdout, never block the UI. With neither installed the app is untouched — the pane just says what to
7//! install and where it looked.
8//!
9//! whisper.cpp prints one line per segment, `[00:00:01.000 --> 00:00:02.400]   text`, so the transcript
10//! streams in while it runs and the progress fraction is the last timestamp over the audio length.
11//! `-ml 1 -sow` makes those lines one word each — that is where the word timings come from, regrouped
12//! into sentences by `group_words`.
13//!
14//! The subtitle conventions follow the usual auto-subs ones: ~42 characters a line, split on word
15//! boundaries, a minimum on-screen time that never eats the next cue.
16
17use crate::engine::export::{self, Progress};
18use crate::media::ffpipe;
19use crate::settings::Settings;
20use std::io::{BufRead, BufReader};
21use std::path::PathBuf;
22use std::process::Stdio;
23use std::sync::atomic::Ordering;
24use std::sync::{Arc, Mutex, OnceLock};
25
26/// Downloadable whisper.cpp models: (name, file, download size in MB).
27pub const MODELS: [(&str, &str, u32); 4] = [
28    ("tiny.en — fastest", "ggml-tiny.en.bin", 75),
29    ("base.en — recommended", "ggml-base.en.bin", 142),
30    ("small.en — best", "ggml-small.en.bin", 466),
31    ("base — any language", "ggml-base.bin", 142),
32];
33
34const HOST: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/";
35
36/// Fewer words than this and a repeat is just normal speech ("yeah", "okay"), not a second take.
37const MIN_WORDS: usize = 2;
38/// Duplicate ranges closer than this are cut as one.
39const MERGE_GAP: f64 = 0.35;
40
41// ponytail: the settings are read once per process and never written back from the pane — a
42// load-modify-save here would race the app's in-memory copy. Edit settings.json to change them.
43fn settings() -> &'static (String, String) {
44    static S: OnceLock<(String, String)> = OnceLock::new();
45    S.get_or_init(|| {
46        let s = Settings::load();
47        (s.whisper_dir, s.transcribe_model)
48    })
49}
50
51/// Index into `MODELS` of `Settings.transcribe_model` (by name or file), else base.en.
52pub fn default_model() -> usize {
53    let want = settings().1.trim();
54    if want.is_empty() {
55        return 1;
56    }
57    MODELS.iter().position(|(n, f, _)| *f == want || n.starts_with(want)).unwrap_or(1)
58}
59
60/// `%LOCALAPPDATA%\SimpleEditor\cache\models` — where downloaded models live.
61pub fn models_dir() -> PathBuf {
62    Settings::cache_dir().join("models")
63}
64
65/// Where the app looks for a whisper binary it did not find on PATH.
66pub fn exe_dir() -> PathBuf {
67    Settings::cache_dir().join("whisper")
68}
69
70pub fn model_path(file: &str) -> PathBuf {
71    models_dir().join(file)
72}
73
74pub fn model_url(file: &str) -> String {
75    format!("{HOST}{file}")
76}
77
78/// Is the model there and not a truncated download?
79pub fn have_model(file: &str) -> bool {
80    std::fs::metadata(model_path(file)).map(|m| m.len() > 1_000_000).unwrap_or(false)
81}
82
83/// A whisper.cpp binary, or None. `main.exe` is only accepted from a directory we were pointed at —
84/// a `main.exe` picked up off PATH would be anything at all.
85pub fn exe() -> Option<PathBuf> {
86    let dirs = [PathBuf::from(&settings().0), exe_dir()];
87    for name in ["whisper-cli.exe", "whisper.exe", "main.exe"] {
88        if let Some(p) = dirs.iter().map(|d| d.join(name)).find(|p| p.is_file()) {
89            return Some(p);
90        }
91        if name != "main.exe" {
92            if let Some(p) = ffpipe::find_exe(name, "") {
93                return Some(p);
94            }
95        }
96    }
97    None
98}
99
100/// Exactly what to install and where it is expected (shown in the pane when `exe()` is None).
101pub fn install_hint() -> String {
102    format!(
103        "whisper.cpp not found. Put whisper-cli.exe (github.com/ggml-org/whisper.cpp → Releases) in {}, \
104         next to SimpleEditor.exe, or on PATH.",
105        exe_dir().display()
106    )
107}
108
109// ---------------------------------------------------------------- model download
110
111/// Download a model into the cache. `curl.exe` ships with Windows 10 1803+; progress is the size of the
112/// `.part` file against the published one, because curl's own meter needs a console.
113pub fn download_model(file: &str) -> Arc<Progress> {
114    let file = file.to_string();
115    export::spawn_job("whisper-model", move |prog| download(&file, prog))
116}
117
118fn download(file: &str, prog: &Progress) -> Result<(), String> {
119    if have_model(file) {
120        return Ok(());
121    }
122    let dir = models_dir();
123    std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
124    let curl = ffpipe::find_exe("curl.exe", "").ok_or("curl.exe not found (it ships with Windows 10 1803+)")?;
125    let tmp = dir.join(format!("{file}.part"));
126    let _ = std::fs::remove_file(&tmp);
127    let expect = MODELS.iter().find(|m| m.1 == file).map_or(0.0, |m| m.2 as f64 * 1_000_000.0);
128    prog.set(0.0, "Connecting…");
129
130    let mut child = ffpipe::command(&curl)
131        .args(["-L", "--fail", "-sS", "-o"])
132        .arg(&tmp)
133        .arg(model_url(file))
134        .stdin(Stdio::null())
135        .stdout(Stdio::null())
136        .stderr(Stdio::piped())
137        .spawn()
138        .map_err(|e| format!("curl: {e}"))?;
139    let tail = export::stderr_tail(&mut child);
140    let status = loop {
141        if prog.is_cancelled() {
142            let _ = child.kill();
143            let _ = child.wait();
144            let _ = std::fs::remove_file(&tmp);
145            return Err(export::CANCELLED.into());
146        }
147        match child.try_wait() {
148            Ok(Some(st)) => break st,
149            Ok(None) => {}
150            Err(e) => return Err(format!("curl: {e}")),
151        }
152        let got = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0) as f64;
153        let f = if expect > 0.0 { (got / expect) as f32 } else { 0.0 };
154        prog.set(f.clamp(0.0, 0.99), format!("Downloading… {:.0} of {:.0} MB", got / 1e6, expect / 1e6));
155        std::thread::sleep(std::time::Duration::from_millis(200));
156    };
157    let msg = tail.and_then(|t| t.join().ok()).unwrap_or_default();
158    if !status.success() {
159        let _ = std::fs::remove_file(&tmp);
160        return Err(if msg.is_empty() { format!("download failed ({status})") } else { msg });
161    }
162    std::fs::rename(&tmp, model_path(file)).map_err(|e| format!("{}: {e}", model_path(file).display()))?;
163    prog.set(1.0, "Done");
164    Ok(())
165}
166
167// ---------------------------------------------------------------- transcription
168
169/// One transcribed span. `words` is filled only when the run asked for word timings.
170#[derive(Clone, Debug, Default, PartialEq)]
171pub struct Segment {
172    pub start: f64,
173    pub end: f64,
174    pub text: String,
175    pub words: Vec<(f64, f64, String)>,
176}
177
178impl Segment {
179    fn plain(start: f64, end: f64, text: String) -> Self {
180        Self { start, end, text, words: Vec::new() }
181    }
182}
183
184#[derive(Clone, Debug)]
185pub struct Options {
186    pub path: String,
187    /// Source seconds to transcribe (the clip's own range).
188    pub src_start: f64,
189    pub src_duration: f64,
190    /// Model file name inside `models_dir()`.
191    pub model: String,
192    /// "auto", "en", "de", …
193    pub language: String,
194    /// Ask whisper for one segment per word and regroup here.
195    pub words: bool,
196    /// whisper.cpp `--prompt`: vocabulary/style hints (names, jargon, punctuation style). Not commands —
197    /// the model only mimics it, it does not follow instructions.
198    pub prompt: String,
199}
200
201/// A running transcription. `segments()` grows while it runs; `progress` drives the UI and cancels it.
202pub struct Job {
203    pub progress: Arc<Progress>,
204    out: Arc<Mutex<Vec<Segment>>>,
205}
206
207impl Job {
208    pub fn segments(&self) -> Vec<Segment> {
209        self.out.lock().unwrap_or_else(|e| e.into_inner()).clone()
210    }
211    pub fn cancel(&self) {
212        self.progress.cancel.store(true, Ordering::SeqCst);
213    }
214}
215
216pub fn start(opts: Options) -> Job {
217    let out: Arc<Mutex<Vec<Segment>>> = Arc::new(Mutex::new(Vec::new()));
218    let sink = out.clone();
219    let progress = export::spawn_job("transcribe", move |prog| run(&opts, prog, &sink));
220    Job { progress, out }
221}
222
223/// 16 kHz mono wav of the clip's range, through the ffmpeg we already ship next to.
224fn extract_wav(opts: &Options, prog: &Progress) -> Result<PathBuf, String> {
225    let ffmpeg = ffpipe::ffmpeg_exe().ok_or("ffmpeg.exe not found")?;
226    let wav = std::env::temp_dir().join(format!("se-transcribe-{}.wav", std::process::id()));
227    let mut child = ffpipe::command(&ffmpeg)
228        .args(["-v", "error", "-y", "-ss"])
229        .arg(format!("{:.3}", opts.src_start.max(0.0)))
230        .arg("-t")
231        .arg(format!("{:.3}", opts.src_duration.max(0.1)))
232        .arg("-i")
233        .arg(&opts.path)
234        .args(["-vn", "-ac", "1", "-ar", "16000", "-f", "wav"])
235        .arg(&wav)
236        .stdin(Stdio::null())
237        .stdout(Stdio::null())
238        .stderr(Stdio::piped())
239        .spawn()
240        .map_err(|e| format!("ffmpeg: {e}"))?;
241    let tail = export::stderr_tail(&mut child);
242    export::wait_ffmpeg(&mut child, tail, prog)?;
243    if std::fs::metadata(&wav).map(|m| m.len()).unwrap_or(0) < 1024 {
244        return Err("that clip has no audio to transcribe".into());
245    }
246    Ok(wav)
247}
248
249fn run(opts: &Options, prog: &Progress, sink: &Mutex<Vec<Segment>>) -> Result<(), String> {
250    let exe = exe().ok_or_else(install_hint)?;
251    let model = model_path(&opts.model);
252    if !model.is_file() {
253        return Err(format!("model {} is not downloaded", opts.model));
254    }
255    prog.set(0.01, "Extracting audio…");
256    let wav = extract_wav(opts, prog)?;
257    prog.set(0.05, "Transcribing…");
258
259    let lang = if opts.language.trim().is_empty() { "auto".to_string() } else { opts.language.trim().to_string() };
260    let mut cmd = ffpipe::command(&exe);
261    cmd.arg("-m").arg(&model).arg("-f").arg(&wav).args(["-l", &lang]);
262    if opts.words {
263        cmd.args(["-ml", "1", "-sow"]);
264    }
265    if !opts.prompt.trim().is_empty() {
266        cmd.arg("--prompt").arg(opts.prompt.trim());
267    }
268    let mut child = cmd
269        .stdin(Stdio::null())
270        .stdout(Stdio::piped())
271        .stderr(Stdio::piped())
272        .spawn()
273        .map_err(|e| format!("{}: {e}", exe.display()))?;
274    let tail = export::stderr_tail(&mut child);
275    let total = opts.src_duration.max(0.1);
276    let mut raw: Vec<(f64, f64, String)> = Vec::new();
277    if let Some(stdout) = child.stdout.take() {
278        for line in BufReader::new(stdout).lines().map_while(Result::ok) {
279            if prog.is_cancelled() {
280                break; // wait_ffmpeg kills the child and reports CANCELLED
281            }
282            let Some(seg) = parse_line(&line) else { continue };
283            prog.set((0.05 + 0.94 * (seg.1 / total)).clamp(0.05, 0.99) as f32, "Transcribing…");
284            raw.push(seg.clone());
285            // raw segments, one word each with -ml 1: the UI regroups them into sentences, so the
286            // grouping can be re-run with new parameters without re-transcribing
287            sink.lock().unwrap_or_else(|e| e.into_inner()).push(Segment::plain(seg.0, seg.1, seg.2));
288        }
289    }
290    let r = export::wait_ffmpeg(&mut child, tail, prog);
291    let _ = std::fs::remove_file(&wav);
292    r?;
293    if raw.is_empty() {
294        return Err("nothing was transcribed (no speech, or the model failed to load)".into());
295    }
296    prog.set(1.0, format!("{} segments", raw.len()));
297    Ok(())
298}
299
300// ---------------------------------------------------------------- parsing
301
302/// `[00:00:01.000 --> 00:00:02.400]   Hello there` → (1.0, 2.4, "Hello there").
303/// Anything else (whisper's banner, timings, blank-audio markers) is None.
304pub fn parse_line(line: &str) -> Option<(f64, f64, String)> {
305    let (span, text) = line.trim().strip_prefix('[')?.split_once(']')?;
306    let (a, b) = span.split_once("-->")?;
307    let start = crate::engine::subtitles::parse_time(a.trim())?;
308    let end = crate::engine::subtitles::parse_time(b.trim())?;
309    let text = text.trim();
310    // "[BLANK_AUDIO]", "(silence)", "[MUSIC]" — a whole-line annotation is not speech
311    let noise = text.is_empty()
312        || (text.starts_with('[') && text.ends_with(']'))
313        || (text.starts_with('(') && text.ends_with(')'));
314    if end < start || noise {
315        return None;
316    }
317    Some((start, end, text.to_string()))
318}
319
320/// How one-word segments are regrouped into sentences — every knob the "Regenerate" pass turns.
321#[derive(Clone, Debug, PartialEq)]
322pub struct GroupOpts {
323    /// A sentence ends once it reaches this many characters.
324    pub max_chars: usize,
325    /// … or after a silence longer than this (seconds).
326    pub max_gap: f64,
327    /// … or on a word ending with any of these characters ("" = never split on punctuation).
328    pub punct: String,
329    /// … or after this many words (0 = no word limit).
330    pub max_words: usize,
331}
332
333impl Default for GroupOpts {
334    fn default() -> Self {
335        Self { max_chars: 120, max_gap: 0.8, punct: ".?!".into(), max_words: 0 }
336    }
337}
338
339/// One-word segments (`-ml 1 -sow`) → sentences, keeping the word timings. A sentence ends per
340/// `GroupOpts`: on punctuation, on a long gap, at `max_chars`, or at `max_words`.
341pub fn group_words(words: &[(f64, f64, String)], opts: &GroupOpts) -> Vec<Segment> {
342    let mut out: Vec<Segment> = Vec::new();
343    let mut cur = Segment::default();
344    for (i, (a, b, w)) in words.iter().enumerate() {
345        let w = w.trim();
346        if w.is_empty() {
347            continue;
348        }
349        if cur.words.is_empty() {
350            cur.start = *a;
351        }
352        if !cur.text.is_empty() {
353            cur.text.push(' ');
354        }
355        cur.text.push_str(w);
356        cur.end = *b;
357        cur.words.push((*a, *b, w.to_string()));
358        let gap = words.get(i + 1).map_or(f64::INFINITY, |n| n.0 - *b);
359        let full =
360            cur.text.chars().count() >= opts.max_chars || (opts.max_words > 0 && cur.words.len() >= opts.max_words);
361        if w.ends_with(|c: char| opts.punct.contains(c)) || gap > opts.max_gap || full {
362            out.push(std::mem::take(&mut cur));
363        }
364    }
365    if !cur.words.is_empty() {
366        out.push(cur);
367    }
368    out
369}
370
371/// Source seconds → timeline seconds: the clip's speed is one linear map over its whole range.
372pub fn retime(segs: &mut [Segment], offset: f64, scale: f64) {
373    let map = |t: &mut f64| *t = offset + *t * scale;
374    for s in segs {
375        map(&mut s.start);
376        map(&mut s.end);
377        for w in &mut s.words {
378            map(&mut w.0);
379            map(&mut w.1);
380        }
381    }
382}
383
384// ---------------------------------------------------------------- auto-subs
385
386/// Greedy line wrap on word boundaries; a single word longer than `max_chars` gets its own line.
387fn wrap_words(text: &str, max_chars: usize) -> Vec<Vec<&str>> {
388    let mut out: Vec<Vec<&str>> = Vec::new();
389    let mut cur: Vec<&str> = Vec::new();
390    let mut len = 0usize;
391    for w in text.split_whitespace() {
392        let n = w.chars().count();
393        if !cur.is_empty() && len + 1 + n > max_chars {
394            out.push(std::mem::take(&mut cur));
395            len = 0;
396        }
397        len += if cur.is_empty() { n } else { n + 1 };
398        cur.push(w);
399    }
400    if !cur.is_empty() {
401        out.push(cur);
402    }
403    out
404}
405
406/// Segments → subtitle cues: wrapped to `max_chars` per line, `lines` lines to a cue (joined with \n),
407/// timed by the word timings when there are any and proportionally to the characters otherwise, each
408/// held for at least `min_dur` unless the next cue needs the time. Where a sentence continues across the
409/// cue split, `cont` = (prefix, suffix) marks it: the suffix goes on the cut-off cue, the prefix on its
410/// continuation (e.g. `("…", " —")`).
411/// ponytail: the marks are added after the wrap, so a marked line can run a few chars past `max_chars`.
412pub fn to_cues(
413    segs: &[Segment],
414    max_chars: usize,
415    lines: usize,
416    min_dur: f64,
417    cont: (&str, &str),
418) -> Vec<(f64, f64, String)> {
419    let max_chars = max_chars.max(8);
420    let mut out: Vec<(f64, f64, String)> = Vec::new();
421    for s in segs {
422        let chunks = wrap_words(&s.text, max_chars);
423        if chunks.is_empty() {
424            continue;
425        }
426        let packs: Vec<&[Vec<&str>]> = chunks.chunks(lines.max(1)).collect();
427        let span = (s.end - s.start).max(0.0);
428        let total: usize = chunks.iter().map(|c| c.join(" ").chars().count()).sum::<usize>().max(1);
429        let (mut cum, mut cursor, mut t0) = (0usize, 0usize, s.start);
430        for (i, pack) in packs.iter().enumerate() {
431            let mut text = pack.iter().map(|ch| ch.join(" ")).collect::<Vec<_>>().join("\n");
432            let words: usize = pack.iter().map(|ch| ch.len()).sum();
433            cum += pack.iter().map(|ch| ch.join(" ").chars().count()).sum::<usize>();
434            let last = i + 1 == packs.len();
435            let (mut a, mut b) = (t0, if last { s.end } else { s.start + span * cum as f64 / total as f64 });
436            if cursor + words <= s.words.len() {
437                a = s.words[cursor].0;
438                b = s.words[cursor + words - 1].1;
439                cursor += words;
440            }
441            t0 = b;
442            if i > 0 {
443                text.insert_str(0, cont.0);
444            }
445            if !last {
446                text.push_str(cont.1);
447            }
448            out.push((a, b.max(a), text));
449        }
450    }
451    for i in 0..out.len() {
452        let next = out.get(i + 1).map_or(f64::INFINITY, |c| c.0);
453        let want = out[i].0 + min_dur;
454        if out[i].1 < want {
455            out[i].1 = want.min(next.max(out[i].1));
456        }
457    }
458    out
459}
460
461// ---------------------------------------------------------------- double takes
462
463/// Lower-cased words with the punctuation stripped ("So, THAT one!" → ["so", "that", "one"]).
464pub fn normalize(text: &str) -> Vec<String> {
465    text.split_whitespace()
466        .map(|w| w.chars().filter(|c| c.is_alphanumeric() || *c == '\'').collect::<String>().to_lowercase())
467        .filter(|w| !w.is_empty())
468        .collect()
469}
470
471/// How alike two token lists are, 0..=1: twice their longest common subsequence over their combined
472/// length. Order-aware (unlike a bag overlap) and forgiving of the "um"s a retake adds.
473pub fn similarity(a: &[String], b: &[String]) -> f32 {
474    if a.is_empty() || b.is_empty() {
475        return 0.0;
476    }
477    let mut prev = vec![0u32; b.len() + 1];
478    let mut cur = vec![0u32; b.len() + 1];
479    for x in a {
480        for (j, y) in b.iter().enumerate() {
481            cur[j + 1] = if x == y { prev[j] + 1 } else { cur[j].max(prev[j + 1]) };
482        }
483        std::mem::swap(&mut prev, &mut cur);
484    }
485    2.0 * prev[b.len()] as f32 / (a.len() + b.len()) as f32
486}
487
488/// Groups of segments that say the same thing again — a flubbed line and its retakes. Each group is in
489/// time order with at least two members and the LAST one is the keeper. `window` is how long a silence
490/// may sit between one take and the next.
491///
492/// ponytail: segment against segment, so a take spanning several segments only matches segment-wise.
493/// Align token runs across segment boundaries if multi-sentence takes ever need catching.
494pub fn duplicate_takes(segs: &[Segment], threshold: f32, window: f64) -> Vec<Vec<usize>> {
495    let toks: Vec<Vec<String>> = segs.iter().map(|s| normalize(&s.text)).collect();
496    let mut taken = vec![false; segs.len()];
497    let mut out: Vec<Vec<usize>> = Vec::new();
498    for i in 0..segs.len() {
499        if taken[i] || toks[i].len() < MIN_WORDS {
500            continue;
501        }
502        let mut group = vec![i];
503        let mut last_end = segs[i].end;
504        for j in i + 1..segs.len() {
505            if segs[j].start - last_end > window {
506                break;
507            }
508            if taken[j] || toks[j].len() < MIN_WORDS {
509                continue;
510            }
511            if similarity(&toks[i], &toks[j]) >= threshold {
512                group.push(j);
513                last_end = segs[j].end;
514            }
515        }
516        if group.len() > 1 {
517            for &g in &group {
518                taken[g] = true;
519            }
520            out.push(group);
521        }
522    }
523    out
524}
525
526/// Timeline ranges of every take but the last of each group — what "cut the duplicates" removes.
527/// Sorted and merged so two duplicates in a row are one cut.
528pub fn dup_ranges(segs: &[Segment], groups: &[Vec<usize>]) -> Vec<(f64, f64)> {
529    let mut r: Vec<(f64, f64)> = groups
530        .iter()
531        .flat_map(|g| g[..g.len().saturating_sub(1)].iter())
532        .filter_map(|&i| segs.get(i))
533        .map(|s| (s.start, s.end))
534        .collect();
535    r.sort_by(|a, b| a.0.total_cmp(&b.0));
536    let mut out: Vec<(f64, f64)> = Vec::new();
537    for (a, b) in r {
538        match out.last_mut() {
539            Some(l) if a <= l.1 + MERGE_GAP => l.1 = l.1.max(b),
540            _ => out.push((a, b)),
541        }
542    }
543    out
544}
545
546/// Where `t` ends up after those ranges are rippled out — None when `t` was inside one of them.
547/// Used to drag the cues and the transcript along with the cut.
548pub fn ripple_time(t: f64, removed: &[(f64, f64)]) -> Option<f64> {
549    let mut shift = 0.0;
550    for &(a, b) in removed {
551        if t >= b {
552            shift += b - a;
553        } else if t >= a {
554            return None; // the ranges are half-open: the start goes, the end survives
555        }
556    }
557    Some(t - shift)
558}
559
560/// Name for a marker on a duplicate take: the transcript, short enough to read on the timeline.
561pub fn short_label(text: &str, max: usize) -> String {
562    let mut s: String = text.chars().take(max).collect();
563    if text.chars().count() > max {
564        s.push('…');
565    }
566    s
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    fn seg(start: f64, end: f64, text: &str) -> Segment {
574        Segment::plain(start, end, text.into())
575    }
576
577    #[test]
578    fn parses_whisper_lines() {
579        assert_eq!(
580            parse_line("[00:00:01.000 --> 00:00:02.400]   Hello there"),
581            Some((1.0, 2.4, "Hello there".to_string()))
582        );
583        // whisper's own chatter, blank audio and reversed spans are not transcript
584        assert_eq!(parse_line("whisper_init_from_file_with_params_no_state: loading model"), None);
585        assert_eq!(parse_line("[00:00:03.000 --> 00:00:05.000]   [BLANK_AUDIO]"), None);
586        assert_eq!(parse_line("[00:00:03.000 --> 00:00:05.000]   (soft music)"), None);
587        assert_eq!(parse_line("[00:00:03.000 --> 00:00:05.000]"), None);
588        assert_eq!(parse_line("[00:00:05.000 --> 00:00:03.000]  backwards"), None);
589        assert_eq!(parse_line(""), None);
590    }
591
592    #[test]
593    fn parses_whole_output() {
594        let out = "whisper_model_load: loading model\n\n[00:00:00.000 --> 00:00:02.000]   One two.\n\
595                   [00:00:02.000 --> 00:00:04.000]   [BLANK_AUDIO]\n[00:00:04.000 --> 00:00:06.000]   Three.\n\
596                   whisper_print_timings: total time = 1234 ms\n";
597        // exactly what the worker does with the child's stdout
598        let segs: Vec<Segment> = out.lines().filter_map(parse_line).map(|(a, b, t)| Segment::plain(a, b, t)).collect();
599        assert_eq!(segs.len(), 2);
600        assert_eq!(segs[0], seg(0.0, 2.0, "One two."));
601        assert_eq!(segs[1].start, 4.0);
602    }
603
604    #[test]
605    fn word_segments_group_into_sentences() {
606        let words: Vec<(f64, f64, String)> = [
607            (0.0, 0.3, "One"),
608            (0.3, 0.6, "two."),
609            (0.7, 1.0, "Then"),
610            (1.0, 1.3, "three"),
611            (5.0, 5.4, "later"), // a long gap starts a new sentence
612        ]
613        .iter()
614        .map(|(a, b, w)| (*a, *b, w.to_string()))
615        .collect();
616        let segs = group_words(&words, &GroupOpts::default());
617        assert_eq!(segs.len(), 3, "{segs:?}");
618        assert_eq!(segs[0].text, "One two.");
619        assert_eq!((segs[0].start, segs[0].end), (0.0, 0.6));
620        assert_eq!(segs[1].text, "Then three");
621        assert_eq!(segs[2].text, "later");
622        assert_eq!(segs[1].words.len(), 2, "word timings survive the grouping");
623        // max_chars breaks a run that never punctuates
624        let long: Vec<(f64, f64, String)> = (0..10).map(|i| (i as f64, i as f64 + 0.5, "word".to_string())).collect();
625        let by_chars = GroupOpts { max_chars: 12, max_gap: 5.0, ..GroupOpts::default() };
626        assert!(group_words(&long, &by_chars).len() > 2);
627        // max_words and custom punctuation are further splits
628        let by_words = GroupOpts { max_gap: 5.0, max_words: 3, ..GroupOpts::default() };
629        assert!(group_words(&long, &by_words).iter().all(|s| s.words.len() <= 3));
630        let commas: Vec<(f64, f64, String)> =
631            [(0.0, 0.4, "so,"), (0.5, 0.9, "yes")].iter().map(|(a, b, w)| (*a, *b, w.to_string())).collect();
632        let on_comma = GroupOpts { punct: ".?!,".into(), max_gap: 5.0, ..GroupOpts::default() };
633        assert_eq!(group_words(&commas, &on_comma).len(), 2);
634        let no_punct = GroupOpts { punct: String::new(), max_gap: 5.0, ..GroupOpts::default() };
635        assert_eq!(group_words(&commas, &no_punct).len(), 1);
636    }
637
638    #[test]
639    fn cues_wrap_on_words_and_respect_the_minimum() {
640        let segs = vec![seg(0.0, 4.0, "the quick brown fox jumps over the lazy dog")];
641        let cues = to_cues(&segs, 20, 1, 0.5, ("", ""));
642        assert!(cues.len() >= 2, "{cues:?}");
643        for c in &cues {
644            assert!(c.2.chars().count() <= 20, "line too long: {:?}", c.2);
645            assert!(c.1 > c.0);
646        }
647        assert_eq!(cues.iter().map(|c| c.2.clone()).collect::<Vec<_>>().join(" "), segs[0].text);
648        assert_eq!(cues[0].0, 0.0);
649        // the tail word "dog" is too short to read, so it lingers past the segment (nothing follows it)
650        let tail = cues.last().unwrap();
651        assert!(tail.1 >= 4.0 && tail.1 <= 4.5, "{cues:?}");
652        // a one-word flash is held for min_dur, but never past the next cue
653        let segs = vec![seg(0.0, 0.2, "Hi"), seg(0.5, 3.0, "there")];
654        let cues = to_cues(&segs, 42, 1, 1.0, ("", ""));
655        assert_eq!(cues.len(), 2);
656        assert!((cues[0].1 - 0.5).abs() < 1e-9, "clamped to the next cue: {cues:?}");
657        assert!((cues[1].1 - 3.0).abs() < 1e-9);
658        assert!(to_cues(&[seg(0.0, 1.0, "   ")], 42, 1, 1.0, ("", "")).is_empty());
659    }
660
661    #[test]
662    fn lines_per_cue_joins_wrapped_lines_with_newlines() {
663        let segs = vec![seg(0.0, 4.0, "the quick brown fox jumps over the lazy dog")];
664        let one = to_cues(&segs, 20, 1, 0.5, ("", ""));
665        let two = to_cues(&segs, 20, 2, 0.5, ("", ""));
666        assert_eq!(two.len(), one.len().div_ceil(2), "{two:?}");
667        assert!(two[0].2.contains('\n') && !two[0].2.contains("  "));
668        assert_eq!(two.iter().map(|c| c.2.replace('\n', " ")).collect::<Vec<_>>().join(" "), segs[0].text);
669        assert_eq!(two[0].0, 0.0);
670        assert!(two.last().unwrap().1 >= 4.0);
671    }
672
673    #[test]
674    fn continuation_marks_only_the_split_edges() {
675        let segs = vec![seg(0.0, 6.0, "the quick brown fox jumps over the lazy dog"), seg(7.0, 8.0, "Done.")];
676        let cues = to_cues(&segs, 20, 1, 0.5, ("…", " —"));
677        let n = cues.len();
678        assert!(n >= 3, "{cues:?}");
679        // first chunk: suffix only; middle chunks: both; last chunk of the split segment: prefix only
680        assert!(cues[0].2.ends_with(" —") && !cues[0].2.starts_with('…'), "{:?}", cues[0].2);
681        assert!(cues[n - 2].2.starts_with('…') && !cues[n - 2].2.ends_with(" —"), "{:?}", cues[n - 2].2);
682        assert_eq!(cues[n - 1].2, "Done.", "an unsplit segment is untouched");
683    }
684
685    #[test]
686    fn cues_use_word_timings_when_present() {
687        let mut s = seg(0.0, 6.0, "one two three four");
688        s.words = vec![
689            (0.0, 0.5, "one".into()),
690            (0.5, 1.0, "two".into()),
691            (4.0, 4.5, "three".into()),
692            (4.5, 6.0, "four".into()),
693        ];
694        let cues = to_cues(&[s], 12, 1, 0.1, ("", ""));
695        assert_eq!(cues.len(), 2, "{cues:?}");
696        assert_eq!(cues[0], (0.0, 1.0, "one two".to_string()), "the pause is not covered by cue 1");
697        assert_eq!(cues[1], (4.0, 6.0, "three four".to_string()));
698    }
699
700    #[test]
701    fn similarity_is_order_aware() {
702        let n = normalize;
703        assert!((similarity(&n("So this is the take"), &n("So this is the take")) - 1.0).abs() < 1e-6);
704        // a retake with a filler word is still the same line
705        assert!(similarity(&n("So this is the take"), &n("So uh this is the take")) > 0.9);
706        assert!(similarity(&n("So this is the take"), &n("Completely different words here")) < 0.3);
707        assert_eq!(similarity(&n("hello"), &[]), 0.0);
708        // punctuation and case do not count
709        assert!((similarity(&n("Hello, world!"), &n("hello world")) - 1.0).abs() < 1e-6);
710    }
711
712    #[test]
713    fn duplicate_takes_group_retakes_and_keep_the_last() {
714        let segs = vec![
715            seg(0.0, 2.0, "Welcome to the channel"),
716            seg(2.5, 4.5, "Welcome to the, uh, channel"), // retake
717            seg(5.0, 7.0, "Welcome to the channel!"),     // final take
718            seg(8.0, 10.0, "Today we are building a video editor"),
719            seg(60.0, 62.0, "Welcome to the channel"), // a minute later: not the same take
720        ];
721        let groups = duplicate_takes(&segs, 0.8, 10.0);
722        assert_eq!(groups, vec![vec![0, 1, 2]], "{groups:?}");
723        let ranges = dup_ranges(&segs, &groups);
724        assert_eq!(ranges, vec![(0.0, 2.0), (2.5, 4.5)], "both flubbed takes go, the keeper stays");
725        // takes back to back are cut as one range
726        let tight = vec![seg(0.0, 2.0, "Welcome to the channel"), seg(2.1, 4.0, "Welcome to the channel")];
727        let g = duplicate_takes(&tight, 0.8, 10.0);
728        assert_eq!(dup_ranges(&tight, &g), vec![(0.0, 2.0)]);
729        // raising the bar past the filler word splits the group
730        assert_eq!(duplicate_takes(&segs, 0.99, 10.0), vec![vec![0, 2]]);
731        // "yeah" twice is not a double take
732        let short = vec![seg(0.0, 0.5, "Yeah"), seg(1.0, 1.5, "Yeah")];
733        assert!(duplicate_takes(&short, 0.8, 10.0).is_empty());
734        assert!(duplicate_takes(&[], 0.8, 10.0).is_empty());
735    }
736
737    #[test]
738    fn ripple_time_follows_the_cut() {
739        let removed = [(1.0, 3.0), (5.0, 6.0)];
740        assert_eq!(ripple_time(0.5, &removed), Some(0.5));
741        assert_eq!(ripple_time(2.0, &removed), None, "inside a cut");
742        assert_eq!(ripple_time(1.0, &removed), None, "the cut's own start goes with it");
743        assert_eq!(ripple_time(3.0, &removed), Some(1.0), "its end is the first surviving instant");
744        assert_eq!(ripple_time(4.0, &removed), Some(2.0));
745        assert_eq!(ripple_time(7.0, &removed), Some(4.0));
746        assert_eq!(ripple_time(7.0, &[]), Some(7.0));
747    }
748
749    #[test]
750    fn model_lookup_is_sane() {
751        assert!(MODELS.iter().all(|(_, f, mb)| f.starts_with("ggml-") && *mb > 10));
752        assert!(model_url("ggml-base.en.bin").ends_with("/ggml-base.en.bin"));
753        assert!(model_path("ggml-base.en.bin").ends_with("ggml-base.en.bin"));
754        assert!(!have_model("ggml-nope-does-not-exist.bin"));
755        assert!(install_hint().contains("whisper-cli.exe"));
756        assert_eq!(short_label("a very long transcript line", 6), "a very…");
757        assert_eq!(short_label("short", 20), "short");
758    }
759}