From c6bff719dbdc3fe1b9706418e99d1faf380c66f6 Mon Sep 17 00:00:00 2001 From: master5d Date: Wed, 3 Jun 2026 01:15:28 -0500 Subject: [PATCH] feat(transcribe): speaker-grouped output format + CLI hint for diarization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an OutputFormat::Speaker that renders diarized turns as the requested markup — consecutive same-speaker segments collapse into one block: [Speaker 2] (0:01 - 0:16) Hi, my name is Dan Shaw... [Speaker 1] (0:17 - 0:56) Vastu is actually... - transcript_format.rs: `Speaker` variant, `fmt_clock` (M:SS, no leading-zero minute), render arm grouping consecutive same-speaker sub-segments; tests. - CLI: `--format speaker` (also `speakers`/`diarized`); doc + error text. - GUI: a note under the diarization toggle steering long-file/​reliable diarization to the CLI (`echo --transcribe-file … --diarize --format speaker`), since in-app diarization is CPU-only and slow. New i18n key `diarizeCliNote` across all 20 locales (en/ru translated, en fallback elsewhere). No bindings change (the command already takes `format: String`). Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/cli.rs | 3 +- src-tauri/src/cli_transcription.rs | 2 +- src-tauri/src/transcript_format.rs | 90 +++++++++++++++++++ .../settings/transcribe/TranscribeFile.tsx | 6 ++ src/i18n/locales/ar/translation.json | 3 +- src/i18n/locales/bg/translation.json | 3 +- src/i18n/locales/cs/translation.json | 3 +- src/i18n/locales/de/translation.json | 3 +- src/i18n/locales/en/translation.json | 3 +- src/i18n/locales/es/translation.json | 3 +- src/i18n/locales/fr/translation.json | 3 +- src/i18n/locales/he/translation.json | 3 +- src/i18n/locales/it/translation.json | 3 +- src/i18n/locales/ja/translation.json | 3 +- src/i18n/locales/ko/translation.json | 3 +- src/i18n/locales/pl/translation.json | 3 +- src/i18n/locales/pt/translation.json | 3 +- src/i18n/locales/ru/translation.json | 3 +- src/i18n/locales/sv/translation.json | 3 +- src/i18n/locales/tr/translation.json | 3 +- src/i18n/locales/uk/translation.json | 3 +- src/i18n/locales/vi/translation.json | 3 +- src/i18n/locales/zh-TW/translation.json | 3 +- src/i18n/locales/zh/translation.json | 3 +- 24 files changed, 139 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 1f3c486..b7459cc 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -46,7 +46,8 @@ pub struct CliArgs { #[arg(long, value_name = "MODEL")] pub model: Option, - /// Output format for offline transcription: plain (default), inline, srt, vtt, json, karaoke. + /// Output format for offline transcription: plain (default), inline, srt, vtt, json, karaoke, speaker. + /// `speaker` groups diarized turns as `[Speaker N] (M:SS - M:SS)` blocks. #[arg(long, value_name = "FORMAT")] pub format: Option, diff --git a/src-tauri/src/cli_transcription.rs b/src-tauri/src/cli_transcription.rs index 2c73aa2..fbd074e 100644 --- a/src-tauri/src/cli_transcription.rs +++ b/src-tauri/src/cli_transcription.rs @@ -35,7 +35,7 @@ is auto-detected); the value is ignored." // Resolve output format: explicit --format wins, else infer from -o extension, else plain. let fmt = match format { Some(f) => OutputFormat::from_cli(f).with_context(|| { - format!("Unknown --format '{f}'. Use plain|inline|srt|vtt|json|karaoke.") + format!("Unknown --format '{f}'. Use plain|inline|srt|vtt|json|karaoke|speaker.") })?, None => output .and_then(|p| p.extension()) diff --git a/src-tauri/src/transcript_format.rs b/src-tauri/src/transcript_format.rs index d7c7187..6df14b1 100644 --- a/src-tauri/src/transcript_format.rs +++ b/src-tauri/src/transcript_format.rs @@ -32,6 +32,10 @@ pub enum OutputFormat { Vtt, Json, Karaoke, + /// Speaker-grouped transcript: consecutive same-speaker segments collapsed + /// into one block headed `[Speaker N] (M:SS - M:SS)` followed by the text. + /// Intended for diarized output (`--diarize`). + Speaker, } impl OutputFormat { @@ -44,6 +48,7 @@ impl OutputFormat { "vtt" | "webvtt" => Some(Self::Vtt), "json" => Some(Self::Json), "karaoke" => Some(Self::Karaoke), + "speaker" | "speakers" | "diarized" => Some(Self::Speaker), _ => None, } } @@ -90,6 +95,13 @@ pub fn fmt_inline(secs: f32) -> String { format!("{:02}:{:02}", total / 60, total % 60) } +/// `M:SS` clock for speaker-block headers — minutes have no leading zero and may +/// exceed 59 (e.g. `0:01`, `2:14`, `75:30`). Matches the `(0:01 - 0:16)` style. +pub fn fmt_clock(secs: f32) -> String { + let total = secs.max(0.0).round() as u64; + format!("{}:{:02}", total / 60, total % 60) +} + fn hmsms(secs: f32) -> (u64, u64, u64, u64) { let clamped = secs.max(0.0); let total_ms = (clamped * 1000.0).round() as u64; @@ -421,6 +433,41 @@ pub fn render( } OutputFormat::Karaoke => render_karaoke(words.unwrap_or(&[]), speakers), + + // Speaker-grouped blocks: `[Speaker N] (M:SS - M:SS)\n` with a + // blank line between turns. Consecutive same-speaker sub-segments collapse + // into one block. + OutputFormat::Speaker => { + let mut out = String::new(); + let mut i = 0; + while i < resolved.len() { + let sp = resolved[i].1; + let mut j = i + 1; + while j < resolved.len() && resolved[j].1 == sp { + j += 1; + } + let start = resolved[i].0.start; + let end = resolved[j - 1].0.end; + let label = sp + .map(speaker_label) + .unwrap_or_else(|| "Speaker ?".to_string()); + let text = resolved[i..j] + .iter() + .map(|(s, _)| s.text.trim()) + .filter(|t| !t.is_empty()) + .collect::>() + .join(" "); + out.push_str(&format!( + "[{}] ({} - {})\n{}\n\n", + label, + fmt_clock(start), + fmt_clock(end), + text + )); + i = j; + } + out.trim_end().to_string() + } } } @@ -699,4 +746,47 @@ mod tests { assert!(OutputFormat::Karaoke.is_word_level()); assert!(!OutputFormat::Srt.is_word_level()); } + + #[test] + fn fmt_clock_has_no_leading_zero_minute() { + assert_eq!(fmt_clock(1.0), "0:01"); + assert_eq!(fmt_clock(16.0), "0:16"); + assert_eq!(fmt_clock(134.0), "2:14"); + assert_eq!(fmt_clock(4530.0), "75:30"); + } + + #[test] + fn speaker_format_groups_turns_with_clock_headers() { + let segs = vec![seg(1.0, 16.0, "Hi there."), seg(17.0, 56.0, "Vastu is.")]; + let turns = vec![ + SpeakerTurn { + start: 0.0, + end: 16.5, + speaker: SpeakerId(1), + }, + SpeakerTurn { + start: 16.5, + end: 60.0, + speaker: SpeakerId(0), + }, + ]; + let out = render("", &segs, None, Some(&turns), OutputFormat::Speaker); + assert!(out.contains("[Speaker 2] (0:01 - 0:16)\nHi there.")); + assert!(out.contains("[Speaker 1] (0:17 - 0:56)\nVastu is.")); + // one blank line between turn blocks, no trailing blank + assert!(out.contains("Hi there.\n\n[Speaker 1]")); + assert!(!out.ends_with('\n')); + } + + #[test] + fn speaker_format_parses_from_cli() { + assert_eq!( + OutputFormat::from_cli("speaker"), + Some(OutputFormat::Speaker) + ); + assert_eq!( + OutputFormat::from_cli("diarized"), + Some(OutputFormat::Speaker) + ); + } } diff --git a/src/components/settings/transcribe/TranscribeFile.tsx b/src/components/settings/transcribe/TranscribeFile.tsx index 94996b7..06b4b67 100644 --- a/src/components/settings/transcribe/TranscribeFile.tsx +++ b/src/components/settings/transcribe/TranscribeFile.tsx @@ -152,6 +152,12 @@ export const TranscribeFile: FC = () => { /> )} + {diarize && ( +

+ {t("settings.transcribe.diarizeCliNote")} +

+ )} + {busy && progress && (