diff --git a/openless-all/app/crates/openless-core/src/asr/whisper.rs b/openless-all/app/crates/openless-core/src/asr/whisper.rs index 0765a2e38..7b97e689e 100644 --- a/openless-all/app/crates/openless-core/src/asr/whisper.rs +++ b/openless-all/app/crates/openless-core/src/asr/whisper.rs @@ -440,7 +440,54 @@ pub fn split_pcm_by_duration(pcm: &[u8], max_chunk_duration_ms: Option) -> return vec![pcm]; } - pcm.chunks(bytes_per_chunk).collect() + let mut chunks = Vec::new(); + let mut rest = pcm; + while rest.len() > bytes_per_chunk { + let (chunk, tail) = rest.split_at(quiet_cut_offset(rest, max_chunk_duration_ms)); + chunks.push(chunk); + rest = tail; + } + if !rest.is_empty() { + chunks.push(rest); + } + chunks +} + +/// Picks where to end a chunk: the quietest moment shortly before the limit, +/// so a split lands in a pause instead of cutting a word in two. Ties resolve +/// to the point closest to the limit. +fn quiet_cut_offset(pcm: &[u8], max_chunk_duration_ms: u64) -> usize { + const BYTES_PER_MS: usize = 32; + const STEP_BYTES: usize = 20 * BYTES_PER_MS; + // Wide enough that a stop consonant's brief closure doesn't pass for a pause. + const HALF_WINDOW_BYTES: usize = 100 * BYTES_PER_MS; + const MAX_SEARCH_MS: u64 = 15_000; + + let max_bytes = max_chunk_duration_ms as usize * BYTES_PER_MS; + let search_bytes = (max_chunk_duration_ms / 10).min(MAX_SEARCH_MS) as usize * BYTES_PER_MS; + let lowest = max_bytes + .saturating_sub(search_bytes) + .max(HALF_WINDOW_BYTES); + + let mut best = max_bytes; + let mut best_energy = u64::MAX; + let mut cut = max_bytes; + while cut >= lowest { + if let Some(window) = pcm.get(cut - HALF_WINDOW_BYTES..cut + HALF_WINDOW_BYTES) { + let energy = window + .as_chunks::() + .0 + .iter() + .map(|s| u64::from(i16::from_le_bytes([s[0], s[1]]).unsigned_abs())) + .sum(); + if energy < best_energy { + best_energy = energy; + best = cut; + } + } + cut -= STEP_BYTES; + } + best } fn transcription_url(base_url: &str, endpoint_path: Option<&str>) -> Result { @@ -800,6 +847,37 @@ mod tests { assert_eq!(chunks[2].len(), 32_000 * 5); } + #[test] + fn split_pcm_by_duration_cuts_in_a_pause_and_loses_nothing() { + // Sample 8000: speech. + let mut pcm: Vec = std::iter::repeat_n([0x40u8, 0x1f], 16_000 * 65) + .flatten() + .collect(); + let pause = 32_000 * 28..32_000 * 29; + pcm[pause.clone()].fill(0); + + let chunks = split_pcm_by_duration(&pcm, Some(30_000)); + + assert!(pause.contains(&chunks[0].len()), "{}", chunks[0].len()); + assert!(chunks.iter().all(|chunk| chunk.len() <= 32_000 * 30)); + assert_eq!(chunks.concat(), pcm); + } + + #[test] + fn long_groq_recording_chunks_fit_the_25_mb_upload_limit() { + // 17 min: the recording that came back 413 when sent as one WAV. + let pcm = vec![0u8; 32_000 * 1023]; + let limit = crate::provider_rules::batch_asr_chunk_limit_ms( + "groq", + crate::provider_rules::AdvancedAsrConfig::default(), + ); + + let chunks = split_pcm_by_duration(&pcm, limit); + + assert_eq!(chunks.len(), 2); + assert!(chunks.iter().all(|chunk| chunk.len() + 44 < 25_000_000)); + } + #[test] fn split_pcm_by_duration_zero_limit_falls_back_to_single_chunk() { let pcm = vec![0u8; 96_000]; diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 5c6f3b424..c3faea259 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -1096,10 +1096,19 @@ pub fn advanced_asr_config_for(provider_id: &str, raw: Option<&str>) -> Advanced parse_advanced_asr_config(raw) } +/// Providers cap uploads by size (25 MB on Groq's free tier and OpenAI), so a +/// long dictation sent as one WAV is rejected with 413. Ten minutes of 16 kHz +/// mono PCM is 19.2 MB, safely under that cap. +const DEFAULT_BATCH_ASR_CHUNK_MS: u64 = 600_000; + pub fn batch_asr_chunk_limit_ms(provider_id: &str, advanced: AdvancedAsrConfig) -> Option { match provider_id { "zhipu" | "openrouter" | "zenmux" => Some(30_000), - _ => advanced.chunk_duration_ms, + // Preserve the custom provider's "0/blank = send the whole clip" setting. + OPENAI_COMPATIBLE_ASR_PROVIDER_ID => advanced.chunk_duration_ms, + _ => advanced + .chunk_duration_ms + .or(Some(DEFAULT_BATCH_ASR_CHUNK_MS)), } } @@ -1302,6 +1311,26 @@ mod tests { ); } + #[test] + fn custom_asr_disabled_chunking_keeps_long_recordings_whole() { + let pcm = vec![0u8; 32_000 * 660]; + for raw in [ + None, + Some(""), + Some("{}"), + Some(r#"{"chunkDurationMs":0}"#), + Some(r#"{"chunkDurationMs":null}"#), + ] { + let advanced = advanced_asr_config_for(OPENAI_COMPATIBLE_ASR_PROVIDER_ID, raw); + let limit = batch_asr_chunk_limit_ms(OPENAI_COMPATIBLE_ASR_PROVIDER_ID, advanced); + let chunks = crate::asr::whisper::split_pcm_by_duration(&pcm, limit); + + assert_eq!(chunks.len(), 1, "disabled chunking was overridden: {raw:?}"); + assert_eq!(limit, None); + assert_eq!(chunks[0].len(), pcm.len()); + } + } + #[test] fn request_shape_and_timeout_rules_are_stable() { assert_eq!( @@ -1316,6 +1345,20 @@ mod tests { batch_asr_chunk_limit_ms("openrouter", AdvancedAsrConfig::default()), Some(30_000) ); + assert_eq!( + batch_asr_chunk_limit_ms("groq", AdvancedAsrConfig::default()), + Some(600_000) + ); + assert_eq!( + batch_asr_chunk_limit_ms( + "openai-compatible", + AdvancedAsrConfig { + chunk_duration_ms: Some(45_000), + ..AdvancedAsrConfig::default() + } + ), + Some(45_000) + ); assert_eq!(whisper_transcribe_timeout(10.0), Duration::from_secs(30)); assert_eq!(whisper_transcribe_timeout(60.0), Duration::from_secs(50)); } diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index b7d269dae..d9150a4c6 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -4710,6 +4710,12 @@ impl TauriTextInsertionSession { if self.context.insertion.allow_non_tsf_fallback { return self.copy_fallback(text).await; } + #[cfg(target_os = "macos")] + { + log::warn!("[core-adapter] copying transcript to clipboard: {error}"); + return self.copy_fallback(text).await; + } + #[cfg(not(target_os = "macos"))] return Err(error); } #[cfg(target_os = "windows")]