Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion openless-all/app/crates/openless-core/src/asr/whisper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,54 @@ pub fn split_pcm_by_duration(pcm: &[u8], max_chunk_duration_ms: Option<u64>) ->
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::<PCM_BYTES_PER_SAMPLE>()
.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<String> {
Expand Down Expand Up @@ -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<u8> = 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];
Expand Down
45 changes: 44 additions & 1 deletion openless-all/app/crates/openless-core/src/provider_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> {
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)),
}
}

Expand Down Expand Up @@ -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!(
Expand All @@ -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));
}
Expand Down
6 changes: 6 additions & 0 deletions openless-all/app/src-tauri/src/core_adapters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading