From 91020cc49b1471b21d900eb46e972ab974a800ea Mon Sep 17 00:00:00 2001 From: Grzegorz <19194188+farce1@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:32:10 +0200 Subject: [PATCH] fix(transcription): persist real segment end times, not a fixed 1s span Every transcript segment was stored with end_time_ms = start + 1000ms, and the diarization alignment query recomputed the same fabricated span instead of reading the stored column. VAD segments span 0.15-25s, so speaker-to-text overlap was wrong for any segment != ~1s, degrading diarization and exports. Compute the real duration from the decoded chunk's sample count (samples_to_ms) and carry it on SegmentResult; store start+duration as end_time_ms and read the real end_time_ms column during alignment. --- src-tauri/src/diarization/worker.rs | 2 +- src-tauri/src/transcription/mod.rs | 4 +- src-tauri/src/transcription/worker.rs | 98 +++++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/diarization/worker.rs b/src-tauri/src/diarization/worker.rs index 4cdec4f..4c644bf 100644 --- a/src-tauri/src/diarization/worker.rs +++ b/src-tauri/src/diarization/worker.rs @@ -217,7 +217,7 @@ pub fn run_worker( let transcript_rows = runtime.block_on(async { sqlx::query_as::<_, TranscriptAlignRow>( - "SELECT id, start_time_ms, (start_time_ms + 1000) AS end_time_ms + "SELECT id, start_time_ms, end_time_ms FROM transcripts WHERE meeting_id = ? ORDER BY segment_index", diff --git a/src-tauri/src/transcription/mod.rs b/src-tauri/src/transcription/mod.rs index 7eae782..bdc8cee 100644 --- a/src-tauri/src/transcription/mod.rs +++ b/src-tauri/src/transcription/mod.rs @@ -40,6 +40,7 @@ pub enum WorkerCommand { pub struct SegmentResult { pub text: String, pub elapsed_ms: u64, + pub duration_ms: u64, pub detected_language: Option, } @@ -165,6 +166,7 @@ pub fn start_transcription_worker( let text_clone = text.clone(); let index = i64::from(segment_index); let elapsed_ms = segment.elapsed_ms as i64; + let end_time_ms = segment.elapsed_ms.saturating_add(segment.duration_ms) as i64; tauri::async_runtime::spawn(async move { if let Err(err) = sqlx::query( "INSERT INTO transcripts (meeting_id, segment_index, text, start_time_ms, end_time_ms, is_final) @@ -174,7 +176,7 @@ pub fn start_transcription_worker( .bind(index) .bind(text_clone) .bind(elapsed_ms) - .bind(elapsed_ms + 1000) + .bind(end_time_ms) .execute(&pool_clone) .await { diff --git a/src-tauri/src/transcription/worker.rs b/src-tauri/src/transcription/worker.rs index f29885a..b3cb0df 100644 --- a/src-tauri/src/transcription/worker.rs +++ b/src-tauri/src/transcription/worker.rs @@ -13,6 +13,34 @@ const ASR_SAMPLE_RATE: u32 = 16_000; const WHISPER_MAX_DECODE_SECONDS: usize = 25; const WHISPER_MAX_DECODE_SAMPLES: usize = (ASR_SAMPLE_RATE as usize) * WHISPER_MAX_DECODE_SECONDS; +fn samples_to_ms(num_samples: u64, sample_rate: u32) -> u64 { + if sample_rate == 0 { + return 0; + } + num_samples.saturating_mul(1000) / u64::from(sample_rate) +} + +fn build_segment_result( + text: String, + detected_language: Option, + recording_start_ms: u64, + segment_start_samples: u64, + chunk_offset_samples: u64, + chunk_len_samples: usize, + sample_rate: u32, +) -> SegmentResult { + let start_samples = segment_start_samples.saturating_add(chunk_offset_samples); + let elapsed_ms = recording_start_ms.saturating_add(samples_to_ms(start_samples, sample_rate)); + let duration_ms = samples_to_ms(chunk_len_samples as u64, sample_rate); + + SegmentResult { + text, + elapsed_ms, + duration_ms, + detected_language, + } +} + pub struct WorkerConfig { pub model_dir: PathBuf, pub vad_model: String, @@ -50,15 +78,17 @@ fn process_completed_segments( }; let chunk_offset_samples = (chunk_index as u64) * (max_decode_samples as u64); - let elapsed_ms = recording_start_ms.saturating_add( - ((segment_start_samples + chunk_offset_samples) * 1000) / (ASR_SAMPLE_RATE as u64), - ); - - let _ = result_tx.send(SegmentResult { + let segment_result = build_segment_result( text, - elapsed_ms, detected_language, - }); + recording_start_ms, + segment_start_samples, + chunk_offset_samples, + samples.len(), + ASR_SAMPLE_RATE, + ); + + let _ = result_tx.send(segment_result); } vad.pop(); @@ -194,3 +224,57 @@ pub fn run_worker( config.recording_start_ms, ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn samples_to_ms_converts_at_16khz() { + assert_eq!(samples_to_ms(16_000, 16_000), 1_000); + assert_eq!(samples_to_ms(8_000, 16_000), 500); + assert_eq!(samples_to_ms(0, 16_000), 0); + } + + #[test] + fn samples_to_ms_guards_zero_sample_rate() { + assert_eq!(samples_to_ms(16_000, 0), 0); + } + + #[test] + fn build_segment_result_uses_real_duration_not_fixed_span() { + // 2-second chunk (32000 samples @ 16kHz) starting 1s into the recording. + let result = build_segment_result( + "hello".to_string(), + Some("en".to_string()), + 0, + 16_000, + 0, + 32_000, + 16_000, + ); + + assert_eq!(result.elapsed_ms, 1_000); + // Regression guard: real duration, not the old hardcoded 1000ms span. + assert_eq!(result.duration_ms, 2_000); + assert_eq!(result.text, "hello"); + assert_eq!(result.detected_language.as_deref(), Some("en")); + } + + #[test] + fn build_segment_result_applies_chunk_offset_and_recording_start() { + // Second 25s chunk (offset 400000 samples), recording started at 5000ms. + let result = build_segment_result( + "world".to_string(), + None, + 5_000, + 0, + 400_000, + 16_000, + 16_000, + ); + + assert_eq!(result.elapsed_ms, 5_000 + 25_000); + assert_eq!(result.duration_ms, 1_000); + } +}