From 41d2d9aa86a1d9bd5b527113ebd07dbdd6d03e46 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 13 May 2026 22:42:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(local-asr):=20=E4=BF=AE=E5=A4=8D=20Qwen3-AS?= =?UTF-8?q?R=20=E9=95=BF=E8=AF=AD=E9=9F=B3=E6=9C=AB=E6=AE=B5=E4=B8=A2?= =?UTF-8?q?=E5=86=85=E5=AE=B9=20+=20=E9=95=BF=E5=BD=95=E9=9F=B3=E8=B6=85?= =?UTF-8?q?=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个独立缺陷一并修: 缺陷 1(主要,丢内容):transcribe_stream 内部按 2s chunk 切片; 用户说完最后一个字立刻松键时录音缓冲没有任何静默尾巴,末 chunk < 2s 拿不到静默帧 → C 引擎不收尾 → 该 chunk 转写结果被丢弃。等 5-10 秒静默再松键时由于尾部静默被录进缓冲反而正常。 修复:local_provider.rs transcribe() 把 PCM 转 f32 后追加 0.5 秒 (8000 个 f32 零值 @ 16kHz)静默,给 C 引擎收尾信号。duration_ms 仍按原始缓冲长度计算,padding 不计入。 缺陷 2(次要,长录音超时):COORDINATOR_GLOBAL_TIMEOUT_SECS = 15s 固定值;用户 RTF ≈ 0.3、慢机可达 0.5,60s 录音需 ~18s 转写就直接 超时。 修复:新增 local_qwen_transcribe_timeout(audio_secs) -> Duration, 公式 max(15, ceil(audio_s × 0.6) + 10);只在 Local Qwen 路径用 (Volcengine / Whisper / Bailian / Foundry / QA 路径不动)。配套 LocalQwenAsr::buffer_duration_ms() 不消费缓冲地读取音频时长。 加 4 条单测覆盖公式:短录音兜底 15s、长录音线性放大、ceil 部分秒、 0 秒边界。39 条 coordinator 测试全过。 --- .../src-tauri/src/asr/local/local_provider.rs | 15 +++++- openless-all/app/src-tauri/src/coordinator.rs | 47 +++++++++++++++++++ .../src-tauri/src/coordinator/dictation.rs | 21 ++++++--- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/asr/local/local_provider.rs b/openless-all/app/src-tauri/src/asr/local/local_provider.rs index 5f9b0b78..bddf375e 100644 --- a/openless-all/app/src-tauri/src/asr/local/local_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/local_provider.rs @@ -41,6 +41,13 @@ impl LocalQwenAsr { } } + /// 当前缓冲音频时长(毫秒)。Coordinator 在 transcribe() 调用前读取, + /// 用来给本地 Qwen ASR 计算动态超时(max(15, ceil(audio_s × 0.6) + 10))。 + /// 不消费缓冲。 + pub fn buffer_duration_ms(&self) -> u64 { + (self.buffer.lock().len() as u64 / 2) * 1000 / 16_000 + } + /// stop 时调用:把 buffer 的 i16 PCM 转 f32,跑流式转写,token 实时 /// 通过事件吐到前端胶囊;最终文本一起返回供 polish/insert。 pub async fn transcribe(self: Arc) -> Result { @@ -52,7 +59,13 @@ impl LocalQwenAsr { }); } let duration_ms = (pcm_bytes.len() as u64 / 2) * 1000 / 16_000; - let samples_f32 = i16_le_bytes_to_f32(&pcm_bytes); + let mut samples_f32 = i16_le_bytes_to_f32(&pcm_bytes); + // `transcribe_stream` 内部按 2s chunk 切片;末 chunk < 2s 且缓冲没有 + // 静默尾巴时,C 引擎不会把它当作"语音已结束",该 chunk 的转写结果 + // 会被丢弃,导致末段内容消失。这里追加 0.5s 静默(@16kHz = 8000 个 + // f32 零值)作为收尾信号。`duration_ms` 仍按原始缓冲长度计算(上面 + // 一行),padding 不计入。 + samples_f32.extend(std::iter::repeat(0.0f32).take(8_000)); // 注册 token 回调:每个稳定 token 抛 `local-asr-token` 事件。 // capsule 前端按 sessionId 累积显示。 diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index cdff19a0..e4d5ac00 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -3096,6 +3096,42 @@ 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 兜底。 + assert_eq!( + local_qwen_transcribe_timeout(5.0), + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) + ); + } + + #[test] + fn local_qwen_timeout_scales_with_audio_duration() { + // 60s 录音:60 × 0.6 = 36, +10 = 46s。覆盖 RTF ≈ 0.5 的边界。 + assert_eq!( + local_qwen_transcribe_timeout(60.0), + std::time::Duration::from_secs(46) + ); + } + + #[test] + fn local_qwen_timeout_ceils_partial_seconds() { + // 10.1s 录音:10.1 × 0.6 = 6.06, ceil = 7, +10 = 17, max(15) = 17。 + assert_eq!( + local_qwen_transcribe_timeout(10.1), + std::time::Duration::from_secs(17) + ); + } + + #[test] + fn local_qwen_timeout_handles_zero_duration() { + // 0 时长(空 buffer 边界):0 × 0.6 = 0, +10 = 10, max(15) = 15。 + assert_eq!( + local_qwen_transcribe_timeout(0.0), + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) + ); + } + #[cfg(target_os = "windows")] #[test] fn foundry_release_uses_foundry_keep_loaded_preference() { @@ -3597,6 +3633,17 @@ fn foundry_audio_transcribe_timeout_duration() -> std::time::Duration { std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) } +/// 本地 Qwen3-ASR 的动态转写超时。固定 15 秒在长录音(≥ 30s)+ 慢机器 +/// (RTF ≈ 0.3–0.5)上必然超时把整段内容丢掉。改用 max(15, ceil(audio_s +/// × 0.6) + 10):基础保留 15s 兜住短录音;长录音按音频长度的 0.6 倍 + +/// 10s 余量,覆盖 RTF ≤ 0.5 的机器。 +fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration { + let secs = ((audio_secs * 0.6).ceil() as u64) + .saturating_add(10) + .max(COORDINATOR_GLOBAL_TIMEOUT_SECS); + std::time::Duration::from_secs(secs) +} + /// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。 /// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在 /// 「准备做下一步副作用前」用。 diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index dc2a37b3..03417688 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1120,10 +1120,18 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { #[cfg(target_os = "macos")] ActiveAsr::Local(local) => { debug_assert!(uses_global_timeout); - // 与 Volcengine/Whisper 一致包一层 global timeout(来自 origin/main)。 - // 注:缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3 - // 提前完成,所以 15s 给 transcribe 本身足够。 - let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + // 缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3 + // 提前完成。但 transcribe 本身受音频长度影响:用户实测 RTF ≈ 0.3,慢机 + // 可达 0.5;15s 固定超时在 ≥ 30s 录音上会把整段结果丢掉。改用动态 + // 超时 max(15, ceil(audio_s × 0.6) + 10),公式与单测见 + // `local_qwen_transcribe_timeout`。 + let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; + let timeout_duration = local_qwen_transcribe_timeout(audio_secs); + log::info!( + "[coord] local Qwen3-ASR transcribe: audio={:.2}s timeout={}s", + audio_secs, + timeout_duration.as_secs() + ); let result = tokio::time::timeout(timeout_duration, local.transcribe()).await; inner.local_asr_cache.touch(); schedule_local_asr_release(inner); @@ -1146,8 +1154,9 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } Err(_) => { log::error!( - "[coord] local Qwen3-ASR 全局超时 {} 秒", - COORDINATOR_GLOBAL_TIMEOUT_SECS + "[coord] local Qwen3-ASR 动态超时 {}s(音频 {:.2}s)", + timeout_duration.as_secs(), + audio_secs ); emit_capsule( inner,