1use 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
26pub 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
36const MIN_WORDS: usize = 2;
38const MERGE_GAP: f64 = 0.35;
40
41fn 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
51pub 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
60pub fn models_dir() -> PathBuf {
62 Settings::cache_dir().join("models")
63}
64
65pub 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
78pub 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
83pub 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
100pub 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
109pub 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#[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 pub src_start: f64,
189 pub src_duration: f64,
190 pub model: String,
192 pub language: String,
194 pub words: bool,
196 pub prompt: String,
199}
200
201pub 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
223fn 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; }
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 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
300pub 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 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#[derive(Clone, Debug, PartialEq)]
322pub struct GroupOpts {
323 pub max_chars: usize,
325 pub max_gap: f64,
327 pub punct: String,
329 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
339pub 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
371pub 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
384fn 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
406pub 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
461pub 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
471pub 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
488pub 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
526pub 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
546pub 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; }
556 }
557 Some(t - shift)
558}
559
560pub 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 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 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"), ]
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 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 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 let tail = cues.last().unwrap();
651 assert!(tail.1 >= 4.0 && tail.1 <= 4.5, "{cues:?}");
652 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 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 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 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"), seg(5.0, 7.0, "Welcome to the channel!"), seg(8.0, 10.0, "Today we are building a video editor"),
719 seg(60.0, 62.0, "Welcome to the channel"), ];
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 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 assert_eq!(duplicate_takes(&segs, 0.99, 10.0), vec![vec![0, 2]]);
731 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}