fix(local-asr): 修复 Qwen3-ASR 长语音末段丢内容 + 长录音超时

两个独立缺陷一并修:

缺陷 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 测试全过。
This commit is contained in:
baiqing committed 2026-05-13 22:42:42 +08:00
1 parent 38f202a010
commit 41d2d9aa86
3 files changed
+76 -7

No files matched your search

@@ -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<Self>) -> Result<RawTranscript> {
@@ -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 累积显示。
@@ -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 名字提醒只在
/// 「准备做下一步副作用前」用。
@@ -1120,10 +1120,18 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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<Inner>) -> 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,