Skip to content

Commit 1ab0807

Browse files
authored
Merge pull request #434 from Open-Less/fix/qwen-asr-long-audio-loss
fix(local-asr): Qwen3-ASR 长语音末段丢内容 + 长录音超时
2 parents 61325e3 + 41d2d9a commit 1ab0807

3 files changed

Lines changed: 76 additions & 7 deletions

File tree

openless-all/app/src-tauri/src/asr/local/local_provider.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ impl LocalQwenAsr {
4141
}
4242
}
4343

44+
/// 当前缓冲音频时长(毫秒)。Coordinator 在 transcribe() 调用前读取,
45+
/// 用来给本地 Qwen ASR 计算动态超时(max(15, ceil(audio_s × 0.6) + 10))。
46+
/// 不消费缓冲。
47+
pub fn buffer_duration_ms(&self) -> u64 {
48+
(self.buffer.lock().len() as u64 / 2) * 1000 / 16_000
49+
}
50+
4451
/// stop 时调用:把 buffer 的 i16 PCM 转 f32,跑流式转写,token 实时
4552
/// 通过事件吐到前端胶囊;最终文本一起返回供 polish/insert。
4653
pub async fn transcribe(self: Arc<Self>) -> Result<RawTranscript> {
@@ -52,7 +59,13 @@ impl LocalQwenAsr {
5259
});
5360
}
5461
let duration_ms = (pcm_bytes.len() as u64 / 2) * 1000 / 16_000;
55-
let samples_f32 = i16_le_bytes_to_f32(&pcm_bytes);
62+
let mut samples_f32 = i16_le_bytes_to_f32(&pcm_bytes);
63+
// `transcribe_stream` 内部按 2s chunk 切片;末 chunk < 2s 且缓冲没有
64+
// 静默尾巴时,C 引擎不会把它当作"语音已结束",该 chunk 的转写结果
65+
// 会被丢弃,导致末段内容消失。这里追加 0.5s 静默(@16kHz = 8000 个
66+
// f32 零值)作为收尾信号。`duration_ms` 仍按原始缓冲长度计算(上面
67+
// 一行),padding 不计入。
68+
samples_f32.extend(std::iter::repeat(0.0f32).take(8_000));
5669

5770
// 注册 token 回调:每个稳定 token 抛 `local-asr-token` 事件。
5871
// capsule 前端按 sessionId 累积显示。

openless-all/app/src-tauri/src/coordinator.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3096,6 +3096,42 @@ mod tests {
30963096
);
30973097
}
30983098

3099+
#[test]
3100+
fn local_qwen_timeout_floors_at_global_timeout_for_short_audio() {
3101+
// 5s 录音:5 × 0.6 = 3, +10 = 13, max(15) = 15。短录音保留 15s 兜底。
3102+
assert_eq!(
3103+
local_qwen_transcribe_timeout(5.0),
3104+
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
3105+
);
3106+
}
3107+
3108+
#[test]
3109+
fn local_qwen_timeout_scales_with_audio_duration() {
3110+
// 60s 录音:60 × 0.6 = 36, +10 = 46s。覆盖 RTF ≈ 0.5 的边界。
3111+
assert_eq!(
3112+
local_qwen_transcribe_timeout(60.0),
3113+
std::time::Duration::from_secs(46)
3114+
);
3115+
}
3116+
3117+
#[test]
3118+
fn local_qwen_timeout_ceils_partial_seconds() {
3119+
// 10.1s 录音:10.1 × 0.6 = 6.06, ceil = 7, +10 = 17, max(15) = 17。
3120+
assert_eq!(
3121+
local_qwen_transcribe_timeout(10.1),
3122+
std::time::Duration::from_secs(17)
3123+
);
3124+
}
3125+
3126+
#[test]
3127+
fn local_qwen_timeout_handles_zero_duration() {
3128+
// 0 时长(空 buffer 边界):0 × 0.6 = 0, +10 = 10, max(15) = 15。
3129+
assert_eq!(
3130+
local_qwen_transcribe_timeout(0.0),
3131+
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
3132+
);
3133+
}
3134+
30993135
#[cfg(target_os = "windows")]
31003136
#[test]
31013137
fn foundry_release_uses_foundry_keep_loaded_preference() {
@@ -3597,6 +3633,17 @@ fn foundry_audio_transcribe_timeout_duration() -> std::time::Duration {
35973633
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
35983634
}
35993635

3636+
/// 本地 Qwen3-ASR 的动态转写超时。固定 15 秒在长录音(≥ 30s)+ 慢机器
3637+
/// (RTF ≈ 0.3–0.5)上必然超时把整段内容丢掉。改用 max(15, ceil(audio_s
3638+
/// × 0.6) + 10):基础保留 15s 兜住短录音;长录音按音频长度的 0.6 倍 +
3639+
/// 10s 余量,覆盖 RTF ≤ 0.5 的机器。
3640+
fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration {
3641+
let secs = ((audio_secs * 0.6).ceil() as u64)
3642+
.saturating_add(10)
3643+
.max(COORDINATOR_GLOBAL_TIMEOUT_SECS);
3644+
std::time::Duration::from_secs(secs)
3645+
}
3646+
36003647
/// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。
36013648
/// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在
36023649
/// 「准备做下一步副作用前」用。

openless-all/app/src-tauri/src/coordinator/dictation.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,10 +1120,18 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
11201120
#[cfg(target_os = "macos")]
11211121
ActiveAsr::Local(local) => {
11221122
debug_assert!(uses_global_timeout);
1123-
// 与 Volcengine/Whisper 一致包一层 global timeout(来自 origin/main)。
1124-
// 注:缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3
1125-
// 提前完成,所以 15s 给 transcribe 本身足够。
1126-
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
1123+
// 缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3
1124+
// 提前完成。但 transcribe 本身受音频长度影响:用户实测 RTF ≈ 0.3,慢机
1125+
// 可达 0.5;15s 固定超时在 ≥ 30s 录音上会把整段结果丢掉。改用动态
1126+
// 超时 max(15, ceil(audio_s × 0.6) + 10),公式与单测见
1127+
// `local_qwen_transcribe_timeout`。
1128+
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
1129+
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
1130+
log::info!(
1131+
"[coord] local Qwen3-ASR transcribe: audio={:.2}s timeout={}s",
1132+
audio_secs,
1133+
timeout_duration.as_secs()
1134+
);
11271135
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
11281136
inner.local_asr_cache.touch();
11291137
schedule_local_asr_release(inner);
@@ -1146,8 +1154,9 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
11461154
}
11471155
Err(_) => {
11481156
log::error!(
1149-
"[coord] local Qwen3-ASR 全局超时 {} 秒",
1150-
COORDINATOR_GLOBAL_TIMEOUT_SECS
1157+
"[coord] local Qwen3-ASR 动态超时 {}s(音频 {:.2}s)",
1158+
timeout_duration.as_secs(),
1159+
audio_secs
11511160
);
11521161
emit_capsule(
11531162
inner,

0 commit comments

Comments
 (0)