diff --git a/crates/ai-formatting/src/pipeline.rs b/crates/ai-formatting/src/pipeline.rs index 304f78f..b8d13bc 100644 --- a/crates/ai-formatting/src/pipeline.rs +++ b/crates/ai-formatting/src/pipeline.rs @@ -47,6 +47,7 @@ pub fn post_process_segments(segments: &mut Vec, options: &PostProcessO seg.text = rule_based::to_british_english(&seg.text); } if options.format_mode != FormatMode::Raw { + seg.text = rule_based::collapse_repetitions(&seg.text); seg.text = rule_based::format_text(&seg.text); } } @@ -134,4 +135,24 @@ mod tests { assert!(segments[2].text.starts_with("\n\n")); } + + #[test] + fn post_process_collapses_repeated_phrases_in_clean_modes() { + let mut segments = vec![Segment { + start: 0.0, + end: 1.0, + text: "I need I need to go to the shops".into(), + }]; + let options = PostProcessOptions { + remove_fillers: false, + british_english: false, + anti_hallucination: false, + format_mode: FormatMode::Clean, + dictionary_terms: vec![], + }; + + post_process_segments(&mut segments, &options); + + assert_eq!(segments[0].text, "I need to go to the shops"); + } } diff --git a/crates/ai-formatting/src/rule_based.rs b/crates/ai-formatting/src/rule_based.rs index 7613260..7f8c797 100644 --- a/crates/ai-formatting/src/rule_based.rs +++ b/crates/ai-formatting/src/rule_based.rs @@ -28,6 +28,12 @@ static FILLER_REGEXES: LazyLock> = LazyLock::new(|| { .collect() }); +fn normalise_repetition_token(token: &str) -> String { + token + .trim_matches(|ch: char| !(ch.is_alphanumeric() || ch == '\'' || ch == '-')) + .to_lowercase() +} + /// Remove common filler words from transcription text (case-insensitive). pub fn remove_fillers(text: &str) -> String { let mut result = text.to_string(); @@ -54,6 +60,77 @@ pub fn remove_fillers(text: &str) -> String { collapsed.trim().to_string() } +/// Collapse obvious stutters and immediate repeated short phrases. +/// +/// Examples: +/// - `I I can` -> `I can` +/// - `I need I need to go` -> `I need to go` +/// - `Think think that's that` -> `Think that's that` +pub fn collapse_repetitions(text: &str) -> String { + if text.trim().is_empty() { + return String::new(); + } + + let tokens: Vec<&str> = text.split_whitespace().collect(); + if tokens.len() < 2 { + return text.trim().to_string(); + } + + let normalised: Vec = tokens + .iter() + .map(|token| normalise_repetition_token(token)) + .collect(); + let mut kept_indices: Vec = Vec::with_capacity(tokens.len()); + let mut i = 0; + + while i < tokens.len() { + let mut skipped_phrase = false; + + for phrase_len in (1..=3).rev() { + if kept_indices.len() < phrase_len || i + phrase_len > tokens.len() { + continue; + } + + let repeated = (0..phrase_len).all(|offset| { + let prev_index = kept_indices[kept_indices.len() - phrase_len + offset]; + let prev = &normalised[prev_index]; + let upcoming = &normalised[i + offset]; + !prev.is_empty() && prev == upcoming + }); + + if repeated { + i += phrase_len; + skipped_phrase = true; + break; + } + } + + if skipped_phrase { + continue; + } + + if let Some(&last_index) = kept_indices.last() { + let current = &normalised[i]; + let previous = &normalised[last_index]; + if !current.is_empty() && current == previous { + i += 1; + continue; + } + } + + kept_indices.push(i); + i += 1; + } + + kept_indices + .into_iter() + .map(|index| tokens[index]) + .collect::>() + .join(" ") + .trim() + .to_string() +} + /// Replacement pairs for American to British English conversion. /// /// All entries are plain base words (no regex metacharacters). The @@ -260,6 +337,27 @@ mod tests { assert!(to_british_english("the color is red").contains("colour")); } + #[test] + fn collapse_repetitions_removes_consecutive_duplicate_words() { + assert_eq!(collapse_repetitions("I I can do that"), "I can do that"); + assert_eq!( + collapse_repetitions("Think think that's that"), + "Think that's that" + ); + } + + #[test] + fn collapse_repetitions_removes_repeated_short_phrases() { + assert_eq!( + collapse_repetitions("I need I need to go to the shops"), + "I need to go to the shops" + ); + assert_eq!( + collapse_repetitions("We should review we should review the draft"), + "We should review the draft" + ); + } + #[test] fn format_text_capitalises_after_full_stops() { let result = format_text("hello world. this is a test"); diff --git a/crates/audio/src/capture.rs b/crates/audio/src/capture.rs index c4bcc0f..e8163b8 100644 --- a/crates/audio/src/capture.rs +++ b/crates/audio/src/capture.rs @@ -95,7 +95,7 @@ impl MicrophoneCapture { let host = cpal::default_host(); let default_name = host .default_input_device() - .and_then(|d| d.name().ok()) + .and_then(|d| device_display_name(&d)) .unwrap_or_default(); let devices = host @@ -110,7 +110,7 @@ impl MicrophoneCapture { let mut out = Vec::new(); for device in devices { - let name = device.name().unwrap_or_else(|_| "".to_string()); + let name = device_display_name(&device).unwrap_or_else(|| "".to_string()); let (sample_rate, channels) = match device.default_input_config() { Ok(cfg) => (cfg.sample_rate(), cfg.channels() as u16), Err(_) => (0, 0), @@ -143,7 +143,7 @@ impl MicrophoneCapture { .map_err(|e| KonError::AudioCaptureFailed(format!("input_devices: {e}")))?; for device in devices { - let name = device.name().unwrap_or_default(); + let name = device_display_name(&device).unwrap_or_default(); if name == device_name { eprintln!("[kon-audio] start_with_device: opening explicit device '{name}'"); return open_and_validate(device, &name, /* require_audio = */ true); @@ -169,7 +169,7 @@ impl MicrophoneCapture { let host = cpal::default_host(); let default_name = host .default_input_device() - .and_then(|d| d.name().ok()) + .and_then(|d| device_display_name(&d)) .unwrap_or_default(); let mut all_devices: Vec = @@ -181,7 +181,7 @@ impl MicrophoneCapture { // Sort: default first, then non-monitor, then monitor-as-last-resort. all_devices.sort_by_key(|d| { - let n = d.name().unwrap_or_default(); + let n = device_display_name(d).unwrap_or_default(); let is_default = !default_name.is_empty() && n == default_name; let is_monitor = is_monitor_name(&n); // Smaller key = tried first. @@ -201,7 +201,7 @@ impl MicrophoneCapture { // First pass: require real audio energy. for device in &all_devices { - let name = device.name().unwrap_or_default(); + let name = device_display_name(device).unwrap_or_default(); if is_monitor_name(&name) { continue; // Save monitor sources for second pass. } @@ -219,7 +219,7 @@ impl MicrophoneCapture { "[kon-audio] no non-monitor mic produced audio; falling back to monitor/loopback sources" ); for device in &all_devices { - let name = device.name().unwrap_or_default(); + let name = device_display_name(device).unwrap_or_default(); match open_and_validate(device.clone(), &name, false) { Ok(result) => { eprintln!( @@ -267,6 +267,13 @@ fn is_monitor_name(name: &str) -> bool { || lower.contains("loopback") } +fn device_display_name(device: &cpal::Device) -> Option { + device + .description() + .ok() + .map(|description| description.name().to_string()) +} + /// Pull the CARD= value from an ALSA device string. /// /// `sysdefault:CARD=Microphones` → `Some("Microphones")`