From 3ec8c5ef0c08ae2218436aaee5d69ec008f64afa Mon Sep 17 00:00:00 2001 From: Chris233 Date: Sun, 21 Jun 2026 20:27:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(asr):=20Whisper/OpenRouter=20=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E8=B6=85=E6=97=B6=20+=20=E5=85=A8=E5=B1=80=E5=85=9C?= =?UTF-8?q?=E5=BA=95=2015=E2=86=9230s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - COORDINATOR_GLOBAL_TIMEOUT_SECS 从 15 改为 30,为云端 batch ASR 留网络预算 - 新增 whisper_transcribe_timeout(audio_secs):max(30, ceil(audio×0.5)+20) - WhisperBatchASR 新增 buffer_duration_ms() 供 coordinator 计算动态超时 - dictation.rs Whisper 路径从固定 15s 切到动态超时,超时日志包含音频时长 Closes #721 --- openless-all/app/src-tauri/src/asr/whisper.rs | 6 ++++++ openless-all/app/src-tauri/src/coordinator.rs | 17 ++++++++++++++--- .../app/src-tauri/src/coordinator/dictation.rs | 16 ++++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/whisper.rs b/openless-all/app/src-tauri/src/asr/whisper.rs index f5626734..06b75a96 100644 --- a/openless-all/app/src-tauri/src/asr/whisper.rs +++ b/openless-all/app/src-tauri/src/asr/whisper.rs @@ -87,6 +87,12 @@ impl WhisperBatchASR { /// Whisper transcriptions endpoint. /// /// 失败时**保留** PCM buffer,让上层有机会重试或在历史中至少留一个失败记录; + /// 当前缓冲音频时长(毫秒)。Coordinator 在 transcribe() 调用前读取, + /// 用于计算 Whisper / OpenRouter 的动态超时。不消费缓冲。 + pub fn buffer_duration_ms(&self) -> u64 { + pcm_duration_ms(&self.buffer.lock()) + } + /// 之前的实现一进函数就 `mem::take` 把 buffer 清空,凭证错或网络中断都会 /// 让用户的录音直接消失。 pub async fn transcribe(&self) -> Result { diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 3ee50c36..81e506a3 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2897,9 +2897,9 @@ const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; const POST_SESSION_COOLDOWN_MS: u64 = 600; /// Coordinator 全局超时保护:防止 ASR await_final_result() 永远挂起。 -/// 设置为 15 秒(比 ASR 的 12 秒 FINAL_RESULT_TIMEOUT 稍长), -/// 只在 ASR 超时机制失效时作为最后的防线触发。 -const COORDINATOR_GLOBAL_TIMEOUT_SECS: u64 = 15; +/// 设置为 30 秒,为云端 batch ASR(OpenRouter Whisper 等)提供足够的 +/// 网络超时预算;只在 ASR 自身超时机制失效时作为最后的防线触发。 +const COORDINATOR_GLOBAL_TIMEOUT_SECS: u64 = 30; #[cfg(target_os = "windows")] fn foundry_audio_transcribe_timeout_duration() -> std::time::Duration { @@ -2917,6 +2917,17 @@ fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration { std::time::Duration::from_secs(secs) } +/// Whisper / OpenRouter 云端 batch ASR 的动态转写超时。OpenRouter 按 30s +/// 分片,每片是一次 HTTP round-trip;网络抖动、排队、base64 body 都会 +/// 拉长耗时。公式 max(30, ceil(audio_s × 0.5) + 20):30s 是全局兜底; +/// 长录音按音频长度的 0.5 倍 + 20s 余量,覆盖多分片串行请求 + 网络波动。 +fn whisper_transcribe_timeout(audio_secs: f64) -> std::time::Duration { + let secs = ((audio_secs * 0.5).ceil() as u64) + .saturating_add(20) + .max(COORDINATOR_GLOBAL_TIMEOUT_SECS); + std::time::Duration::from_secs(secs) +} + /// sherpa-onnx offline batch 暂与 Foundry 同档;后续按 Windows 真机 CPU/模型 /// 实测结果再调整。 #[cfg(target_os = "windows")] diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 72a34bc8..910a7745 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1691,8 +1691,15 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } ActiveAsr::Whisper(w) => { debug_assert!(uses_global_timeout); - // Whisper 也添加类似的超时保护 - let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + // Whisper / OpenRouter 动态超时:音频越长、分片越多,给更多 + // HTTP round-trip 预算。公式见 `whisper_transcribe_timeout`。 + let audio_secs = (w.buffer_duration_ms() as f64) / 1000.0; + let timeout_duration = whisper_transcribe_timeout(audio_secs); + log::info!( + "[coord] Whisper transcribe: audio={:.2}s timeout={}s", + audio_secs, + timeout_duration.as_secs() + ); match tokio::time::timeout(timeout_duration, w.transcribe()).await { Ok(Ok(r)) => r, Ok(Err(e)) => { @@ -1712,8 +1719,9 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } Err(_) => { log::error!( - "[coord] whisper 全局超时 {} 秒", - COORDINATOR_GLOBAL_TIMEOUT_SECS + "[coord] Whisper 动态超时 {}s(音频 {:.2}s)", + timeout_duration.as_secs(), + audio_secs ); emit_capsule( inner, From e1f24b61a4c2df9390588b56cb1a683b99751e89 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Sun, 21 Jun 2026 12:50:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=20local=5Fqwen?= =?UTF-8?q?=5Ftimeout=20=E6=B5=8B=E8=AF=95=20+=20=E6=96=B0=E5=A2=9E=20whis?= =?UTF-8?q?per=5Ftranscribe=5Ftimeout=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - local_qwen_timeout_ceils_partial_seconds: 10.1s 音频原期望 17s,全局常量 提升到 30s 后改为期望 COORDINATOR_GLOBAL_TIMEOUT_SECS - 更新相关注释中的 15→30 - 新增 3 个 whisper_transcribe_timeout 测试:短音频兜底、长音频缩放、ceil --- openless-all/app/src-tauri/src/coordinator.rs | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 81e506a3..9218eef7 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2326,7 +2326,7 @@ mod tests { #[test] fn local_qwen_timeout_floors_at_global_timeout_for_short_audio() { - // 5s 录音:5 × 0.6 = 3, +10 = 13, max(15) = 15。短录音保留 15s 兜底。 + // 5s 录音:5 × 0.6 = 3, +10 = 13, max(30) = 30。短录音兜底。 assert_eq!( local_qwen_transcribe_timeout(5.0), std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) @@ -2344,22 +2344,50 @@ mod tests { #[test] fn local_qwen_timeout_ceils_partial_seconds() { - // 10.1s 录音:10.1 × 0.6 = 6.06, ceil = 7, +10 = 17, max(15) = 17。 + // 10.1s 录音:10.1 × 0.6 = 6.06, ceil = 7, +10 = 17, max(30) = 30。 + // COORDINATOR_GLOBAL_TIMEOUT_SECS 提升到 30 后,短音频统一被兜底值覆盖。 assert_eq!( local_qwen_transcribe_timeout(10.1), - std::time::Duration::from_secs(17) + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) ); } #[test] fn local_qwen_timeout_handles_zero_duration() { - // 0 时长(空 buffer 边界):0 × 0.6 = 0, +10 = 10, max(15) = 15。 + // 0 时长(空 buffer 边界):0 × 0.6 = 0, +10 = 10, max(30) = 30。 assert_eq!( local_qwen_transcribe_timeout(0.0), std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) ); } + #[test] + fn whisper_timeout_floors_at_global_timeout_for_short_audio() { + // 10s 录音:10 × 0.5 = 5, +20 = 25, max(30) = 30。短音频兜底。 + assert_eq!( + whisper_transcribe_timeout(10.0), + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) + ); + } + + #[test] + fn whisper_timeout_scales_with_audio_duration() { + // 60s 录音:60 × 0.5 = 30, +20 = 50。覆盖多分片 HTTP 请求。 + assert_eq!( + whisper_transcribe_timeout(60.0), + std::time::Duration::from_secs(50) + ); + } + + #[test] + fn whisper_timeout_ceils_partial_seconds() { + // 45.3s 录音:45.3 × 0.5 = 22.65, ceil = 23, +20 = 43, max(30) = 43。 + assert_eq!( + whisper_transcribe_timeout(45.3), + std::time::Duration::from_secs(43) + ); + } + #[cfg(target_os = "windows")] #[test] fn foundry_release_uses_foundry_keep_loaded_preference() {