diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 534ae37c..71bea4d3 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -25,7 +25,7 @@ use crate::asr::local::{ foundry, sherpa, FoundryLocalRuntime, FoundryLocalWhisperAsr, SherpaOnnxAsr, SherpaOnnxRuntime, }; use crate::asr::{ - BailianCredentials, BailianRealtimeASR, DictionaryHotword, MimoBatchASR, RawTranscript, + pcm, BailianCredentials, BailianRealtimeASR, DictionaryHotword, MimoBatchASR, RawTranscript, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, }; use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor}; @@ -874,7 +874,7 @@ impl Coordinator { /// 用**当前配置的** ASR provider 对一段已归档的 16k/mono/16-bit PCM 重新转录 /// (issue #613「重新转录」)。复用 `build_qa_asr_start`,对所有 provider 统一: /// 流式 provider 先 open_session 再灌音并取 final,批处理 provider 直接灌音后 - /// transcribe。整段超时走 COORDINATOR_GLOBAL_TIMEOUT_SECS 兜底,防止挂死。 + /// transcribe。整段转写按 provider 设置超时,防止挂死。 /// /// 只做 ASR,不做润色/落字/写历史 —— 回写历史由 command 层完成,保持本方法纯粹。 pub async fn retranscribe_pcm(&self, pcm: Vec) -> Result { @@ -884,6 +884,7 @@ impl Coordinator { start.open_streaming_session().await?; let consumer = start.recorder_consumer(); consumer.consume_pcm_chunk(&pcm); + let audio_secs = (pcm::pcm_duration_ms(&pcm) as f64) / 1000.0; let timeout = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); let raw = match start.active_asr() { ActiveAsr::Volcengine(asr) => { @@ -900,10 +901,13 @@ impl Coordinator { .map_err(|_| "重新转录超时".to_string())? .map_err(|e| e.to_string())? } - ActiveAsr::Whisper(w) => tokio::time::timeout(timeout, w.transcribe()) - .await - .map_err(|_| "重新转录超时".to_string())? - .map_err(|e| e.to_string())?, + ActiveAsr::Whisper(w) => { + let timeout = cloud_whisper_transcribe_timeout(audio_secs); + tokio::time::timeout(timeout, w.transcribe()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())? + } ActiveAsr::Mimo(m) => tokio::time::timeout(timeout, m.transcribe()) .await .map_err(|_| "重新转录超时".to_string())? @@ -931,6 +935,14 @@ impl Coordinator { asr_setup::schedule_local_asr_release(inner); out } + #[cfg(target_os = "macos")] + ActiveAsr::AppleSpeech(local) => { + let dur = asr_setup::local_qwen_transcribe_timeout(audio_secs); + tokio::time::timeout(dur, local.transcribe()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())? + } }; Ok(raw.text) } diff --git a/openless-all/app/src-tauri/src/coordinator/asr_setup.rs b/openless-all/app/src-tauri/src/coordinator/asr_setup.rs index 326a5036..54804f83 100644 --- a/openless-all/app/src-tauri/src/coordinator/asr_setup.rs +++ b/openless-all/app/src-tauri/src/coordinator/asr_setup.rs @@ -467,6 +467,8 @@ pub(crate) async fn build_qa_asr_start( /// 设置为 15 秒(比 ASR 的 12 秒 FINAL_RESULT_TIMEOUT 稍长), /// 只在 ASR 超时机制失效时作为最后的防线触发。 pub(crate) const COORDINATOR_GLOBAL_TIMEOUT_SECS: u64 = 15; +pub(crate) const CLOUD_WHISPER_MIN_TIMEOUT_SECS: u64 = 30; +pub(crate) const CLOUD_WHISPER_MAX_TIMEOUT_SECS: u64 = 300; #[cfg(target_os = "windows")] pub(crate) fn foundry_audio_transcribe_timeout_duration() -> std::time::Duration { @@ -484,6 +486,17 @@ pub(crate) fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Durat std::time::Duration::from_secs(secs) } +/// Cloud Whisper/Groq ASR 的批量转写超时。短录音给 30s 余量;长录音按音频长度 +/// 扩展,避免数分钟录音被 15s coordinator guard 提前丢弃。5 分钟上限仍保留为 +/// 外部 API 卡死时的最后防线。 +pub(crate) fn cloud_whisper_transcribe_timeout(audio_secs: f64) -> std::time::Duration { + let scaled = ((audio_secs * 0.5).ceil() as u64).saturating_add(15); + let secs = scaled + .max(CLOUD_WHISPER_MIN_TIMEOUT_SECS) + .min(CLOUD_WHISPER_MAX_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_end.rs b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs index 5639f5cb..ac4411ec 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs @@ -106,8 +106,13 @@ pub(crate) 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); + let audio_secs = elapsed as f64 / 1000.0; + let timeout_duration = cloud_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)) => { @@ -129,7 +134,7 @@ pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { Err(_) => { log::error!( "[coord] whisper 全局超时 {} 秒", - COORDINATOR_GLOBAL_TIMEOUT_SECS + timeout_duration.as_secs() ); write_transcribe_failed_history(inner, current_session_id, elapsed); emit_capsule( diff --git a/openless-all/app/src-tauri/src/coordinator/tests.rs b/openless-all/app/src-tauri/src/coordinator/tests.rs index 2d94a906..3a615374 100644 --- a/openless-all/app/src-tauri/src/coordinator/tests.rs +++ b/openless-all/app/src-tauri/src/coordinator/tests.rs @@ -343,6 +343,48 @@ fn local_qwen_timeout_handles_zero_duration() { ); } +#[test] +fn cloud_whisper_timeout_has_longer_floor_than_global_guard() { + assert_eq!( + cloud_whisper_transcribe_timeout(5.0), + std::time::Duration::from_secs(CLOUD_WHISPER_MIN_TIMEOUT_SECS) + ); +} + +#[test] +fn cloud_whisper_timeout_handles_zero_duration() { + assert_eq!( + cloud_whisper_transcribe_timeout(0.0), + std::time::Duration::from_secs(CLOUD_WHISPER_MIN_TIMEOUT_SECS) + ); +} + +#[test] +fn cloud_whisper_timeout_scales_for_long_recordings() { + // 422s recording: ceil(422 * 0.5) + 15 = 226s. Do not drop it at 15s. + assert_eq!( + cloud_whisper_transcribe_timeout(422.0), + std::time::Duration::from_secs(226) + ); +} + +#[test] +fn cloud_whisper_timeout_is_capped() { + assert_eq!( + cloud_whisper_transcribe_timeout(1_000.0), + std::time::Duration::from_secs(CLOUD_WHISPER_MAX_TIMEOUT_SECS) + ); +} + +#[test] +fn cloud_whisper_timeout_preserves_exact_cap_boundary() { + // ceil(570 * 0.5) + 15 = 300s, exactly at the cap. + assert_eq!( + cloud_whisper_transcribe_timeout(570.0), + std::time::Duration::from_secs(CLOUD_WHISPER_MAX_TIMEOUT_SECS) + ); +} + #[cfg(target_os = "windows")] #[test] fn foundry_release_uses_foundry_keep_loaded_preference() {