Repository navigation
feat: configurable words per caption segment #3284
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1430,6 +1430,7 @@ fn process_with_whisper( | |
| context: Arc<WhisperContext>, | ||
| language: &str, | ||
| transcription_hints: &[String], | ||
| segment_words: usize, | ||
| ) -> Result<Vec<CaptionSegment>, String> { | ||
| let mut params = FullParams::new(SamplingStrategy::BeamSearch { | ||
| beam_size: 5, | ||
|
|
@@ -1555,7 +1556,7 @@ fn process_with_whisper( | |
| continue; | ||
| } | ||
|
|
||
| for (chunk_idx, chunk_words) in caption_word_chunks(&words).into_iter().enumerate() { | ||
| for (chunk_idx, chunk_words) in caption_word_chunks_with(&words, segment_words).into_iter().enumerate() { | ||
| let segment_text = caption_text_from_words(chunk_words); | ||
| let segment_start = chunk_words | ||
| .first() | ||
|
|
@@ -1623,6 +1624,7 @@ fn parakeet_chunk_ranges(samples: &[f32], sample_rate: usize) -> Vec<std::ops::R | |
| fn process_with_parakeet( | ||
| audio_path: &Path, | ||
| model_dir: &str, | ||
| segment_words: usize, | ||
| ) -> Result<Vec<CaptionSegment>, String> { | ||
| let cached_model = { | ||
| let guard = PARAKEET_CONTEXT | ||
|
|
@@ -1707,14 +1709,15 @@ fn process_with_parakeet( | |
|
|
||
| tracing::info!("Parakeet produced {} timed tokens", tokens.len()); | ||
|
|
||
| caption_segments_from_tokens(tokens) | ||
| caption_segments_from_tokens(tokens, segment_words) | ||
| } | ||
|
|
||
| /// Parakeet's timed word tokens -> caption segments: drop empty tokens, | ||
| /// normalize the words and chunk them. | ||
| #[cfg(any(test, not(all(target_os = "macos", target_arch = "x86_64"))))] | ||
| fn caption_segments_from_tokens( | ||
| tokens: impl IntoIterator<Item = CaptionWord>, | ||
| segment_words: usize, | ||
| ) -> Result<Vec<CaptionSegment>, String> { | ||
| let words = normalize_caption_words( | ||
| tokens | ||
|
|
@@ -1733,7 +1736,7 @@ fn caption_segments_from_tokens( | |
| } | ||
|
|
||
| let mut segments = Vec::new(); | ||
| for (chunk_idx, chunk) in caption_word_chunks(&words).into_iter().enumerate() { | ||
| for (chunk_idx, chunk) in caption_word_chunks_with(&words, segment_words).into_iter().enumerate() { | ||
| segments.push(CaptionSegment { | ||
| id: format!("segment-{chunk_idx}"), | ||
| start: chunk.first().map_or(0.0, |word| word.start), | ||
|
|
@@ -1750,6 +1753,7 @@ fn caption_segments_from_tokens( | |
| fn process_with_parakeet( | ||
| _audio_path: &Path, | ||
| _model_dir: &str, | ||
| _segment_words: usize, | ||
| ) -> Result<Vec<CaptionSegment>, String> { | ||
| Err(PARAKEET_UNSUPPORTED_MESSAGE.to_string()) | ||
| } | ||
|
|
@@ -1766,6 +1770,7 @@ pub fn transcribe_blocking( | |
| project_path: &Path, | ||
| model: &str, | ||
| language: &str, | ||
| segment_words: usize, | ||
| ) -> Result<Vec<CaptionSegment>, String> { | ||
| let requested = model; | ||
| // A download writes straight to the model's final path, so a model still | ||
|
|
@@ -1794,14 +1799,15 @@ pub fn transcribe_blocking( | |
| let _guard = lock_transcription_worker_slot(); | ||
| if is_parakeet_model(model) { | ||
| tracing::info!("Using Parakeet TDT engine"); | ||
| let result = process_with_parakeet(audio_path, model_file.to_string_lossy().as_ref()); | ||
| let result = | ||
| process_with_parakeet(audio_path, model_file.to_string_lossy().as_ref(), segment_words); | ||
| schedule_parakeet_release(); | ||
| result | ||
| } else { | ||
| tracing::info!("Using Whisper engine"); | ||
| let hints = transcription_hints(); | ||
| let context = get_whisper_context(model_file.to_string_lossy().as_ref())?; | ||
| let result = process_with_whisper(audio_path, context, language, &hints); | ||
| let result = process_with_whisper(audio_path, context, language, &hints, segment_words); | ||
| release_whisper_context_after_transcription(); | ||
| result | ||
| } | ||
|
|
@@ -1856,167 +1862,10 @@ fn extract_audio_from_video(video_path: &Path, output_path: &Path) -> Result<(), | |
| // Caption words & chunking | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /// `MAX_CAPTION_WORD_DURATION` (`captions.rs:75`): Whisper/Parakeet sometimes | ||
| /// stretch a trailing word's end across a following silence, which leaves the | ||
| /// rendered caption stuck on screen and duplicates the word across timeline | ||
| /// cuts once projected. | ||
| pub const MAX_CAPTION_WORD_DURATION: f32 = 2.5; | ||
| /// `captions.rs:67-69`. | ||
| const TARGET_CAPTION_WORDS_PER_SEGMENT: usize = 6; | ||
| const MAX_CAPTION_WORDS_PER_SEGMENT: usize = 8; | ||
| const MIN_FINAL_CAPTION_WORDS: usize = 3; | ||
|
|
||
| /// `caption_char_attaches_to_previous` (`captions.rs:745-767`). | ||
| fn caption_char_attaches_to_previous(value: char) -> bool { | ||
| matches!( | ||
| value, | ||
| ',' | '.' | ||
| | '!' | ||
| | '?' | ||
| | ';' | ||
| | ':' | ||
| | '%' | ||
| | ')' | ||
| | ']' | ||
| | '}' | ||
| | '\'' | ||
| | '’' | ||
| | '、' | ||
| | '。' | ||
| | '!' | ||
| | '?' | ||
| | ';' | ||
| | ':' | ||
| | ',' | ||
| ) | ||
| } | ||
|
|
||
| fn caption_token_attaches_to_previous(text: &str) -> bool { | ||
| text.trim() | ||
| .chars() | ||
| .next() | ||
| .is_some_and(caption_char_attaches_to_previous) | ||
| } | ||
|
|
||
| /// `caption_boundary_word_is_weak` (`captions.rs:769-801`). | ||
| fn caption_boundary_word_is_weak(word: &CaptionWord) -> bool { | ||
| let normalized = word | ||
| .text | ||
| .trim() | ||
| .chars() | ||
| .filter(|c| c.is_alphanumeric()) | ||
| .collect::<String>() | ||
| .to_lowercase(); | ||
|
|
||
| normalized.chars().count() <= 1 | ||
| || matches!( | ||
| normalized.as_str(), | ||
| "an" | "as" | ||
| | "at" | ||
| | "be" | ||
| | "by" | ||
| | "do" | ||
| | "he" | ||
| | "if" | ||
| | "in" | ||
| | "is" | ||
| | "it" | ||
| | "me" | ||
| | "my" | ||
| | "of" | ||
| | "on" | ||
| | "or" | ||
| | "so" | ||
| | "to" | ||
| | "up" | ||
| | "we" | ||
| ) | ||
| } | ||
|
|
||
| /// `normalize_caption_words` (`captions.rs:803-831`). | ||
| fn normalize_caption_words(words: Vec<CaptionWord>) -> Vec<CaptionWord> { | ||
| let mut normalized: Vec<CaptionWord> = Vec::with_capacity(words.len()); | ||
|
|
||
| for word in words { | ||
| let text = word.text.trim(); | ||
| if text.is_empty() { | ||
| continue; | ||
| } | ||
|
|
||
| if caption_token_attaches_to_previous(text) | ||
| && let Some(previous) = normalized.last_mut() | ||
| { | ||
| previous.text.push_str(text); | ||
| previous.end = word.end; | ||
| } else { | ||
| normalized.push(CaptionWord { | ||
| text: text.to_string(), | ||
| start: word.start, | ||
| end: word.end, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| for word in &mut normalized { | ||
| word.end = word.end.min(word.start + MAX_CAPTION_WORD_DURATION); | ||
| } | ||
|
|
||
| normalized | ||
| } | ||
|
|
||
| /// `caption_text_from_words` (`captions.rs:833-849`). | ||
| fn caption_text_from_words<'a>(words: impl IntoIterator<Item = &'a CaptionWord>) -> String { | ||
| let mut text = String::new(); | ||
|
|
||
| for word in words { | ||
| let word_text = word.text.trim(); | ||
| if word_text.is_empty() { | ||
| continue; | ||
| } | ||
|
|
||
| if !text.is_empty() && !caption_token_attaches_to_previous(word_text) { | ||
| text.push(' '); | ||
| } | ||
| text.push_str(word_text); | ||
| } | ||
|
|
||
| text | ||
| } | ||
|
|
||
| /// `caption_word_chunks` (`captions.rs:851-883`). | ||
| fn caption_word_chunks(words: &[CaptionWord]) -> Vec<&[CaptionWord]> { | ||
| let mut chunks = Vec::new(); | ||
| let mut start = 0; | ||
|
|
||
| while start < words.len() { | ||
| let remaining = words.len() - start; | ||
| if remaining <= TARGET_CAPTION_WORDS_PER_SEGMENT { | ||
| chunks.push(&words[start..]); | ||
| break; | ||
| } | ||
|
|
||
| let mut end = (start + TARGET_CAPTION_WORDS_PER_SEGMENT).min(words.len()); | ||
| while end < words.len() | ||
| && caption_boundary_word_is_weak(&words[end - 1]) | ||
| && end - start < MAX_CAPTION_WORDS_PER_SEGMENT | ||
| { | ||
| end += 1; | ||
| } | ||
|
|
||
| let remaining_after = words.len() - end; | ||
| if remaining_after > 0 | ||
| && remaining_after < MIN_FINAL_CAPTION_WORDS | ||
| && caption_boundary_word_is_weak(&words[end]) | ||
| { | ||
| end = words.len(); | ||
| } | ||
|
|
||
| chunks.push(&words[start..end]); | ||
| start = end; | ||
| } | ||
|
|
||
| chunks | ||
| } | ||
| use cap_project::caption_words::{ | ||
| caption_text_from_words, caption_word_chunks, caption_word_chunks_with, normalize_caption_words, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/desktop-gpui/src/transcription.rs
Line: 1866
Comment:
**Normal builds gain unused imports**
`caption_word_chunks` is now used only by tests, but both transcription modules import it in normal builds. This adds an unused-import warning. If a check treats warnings as errors, it fails. Move this import into the test modules or gate it with `#[cfg(test)]`.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| MAX_CAPTION_WORD_DURATION, | ||
| }; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Track derivation -- deriveCaptionTrackSegments, in Rust | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding
segment_wordsleaves existing tests calling the old signatures.verify_tests.rscallscaption_segments_from_tokenswith one argument twice, andbenchmark_parakeet_transcriptioncallsprocess_with_parakeetwith two. These missing arguments prevent the GPUI test target from compiling;#[ignore]does not skip compilation. Pass the default size in these existing tests.Prompt To Fix With AI