fix(dictation): 转写阶段可中断——按 Esc 立即取消在途 ASR 请求 (#798)

* fix(dictation): 转写阶段可中断——按 Esc 立即取消在途 ASR 请求

进入 Processing(转写)后,end_session 已把 ASR 句柄从 inner.asr 槽 take 走,
cancel_session 走的 cancel_asr_for_session 是 no-op,够不到在途请求:用户按 Esc
后仍要等整段转写跑完才停,且结果被静默丢弃,白等一场。

改为把转写与「用户取消」轮询用 tokio::select! 赛跑:命中取消即 drop 掉 transcribe
future(中断 reqwest HTTP / 停止等待流式最终结果 / 停止本地转写),再显式
cancel_active_asr 促流式 WebSocket 立即关闭,然后 finish_cancelled_processing 收尾
静默返回。覆盖云端流式 / 云端批量 / 本地引擎全部 provider。

顺带删除 Foundry / Sherpa / Apple Speech 分支里各自的 if cancelled 早退补丁——
取消现在由外层 select! 统一处理,那些分支已冗余。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dictation): clean up cancelled ASR sessions

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-07-14 10:05:36 +08:00
1 parent a49d02b908
commit a77cbd4722
4 files changed
+564 -334

No files matched your search

@@ -7,6 +7,8 @@
//! engine 现在由 `LocalAsrCache` 提供——Coordinator 在 build_local_qwen3 里
//! 取已缓存的引擎再传进来,避免每次会话都重加载 1.2GB+ 模型。
#[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(target_os = "macos")]
use std::sync::Arc;
@@ -25,6 +27,9 @@ use crate::asr::RawTranscript;
#[cfg(target_os = "macos")]
pub struct LocalQwenAsr {
engine: Arc<QwenAsrEngine>,
/// `spawn_blocking` 已经启动后不能被 JoinHandle 强制终止;取消先关闭
/// 当前会话的 token 门控,确保 native worker 返回前不会向新 UI 会话泄漏旧 token。
cancelled: Arc<AtomicBool>,
/// 16-bit LE PCM 字节缓冲(recorder 推什么我们存什么),在 transcribe 时再
/// 转 f32 喂给 C 端。一次会话最多几 MB,clone 一次成本可接受。
buffer: Mutex<Vec<u8>>,
@@ -36,6 +41,7 @@ impl LocalQwenAsr {
pub fn new(app: AppHandle, engine: Arc<QwenAsrEngine>) -> Self {
Self {
engine,
cancelled: Arc::new(AtomicBool::new(false)),
buffer: Mutex::new(Vec::new()),
app,
}
@@ -51,6 +57,7 @@ impl LocalQwenAsr {
/// stop 时调用:把 buffer 的 i16 PCM 转 f32,跑流式转写,token 实时
/// 通过事件吐到前端胶囊;最终文本一起返回供 polish/insert。
pub async fn transcribe(self: Arc<Self>) -> Result<RawTranscript> {
self.cancelled.store(false, Ordering::Release);
let pcm_bytes = std::mem::take(&mut *self.buffer.lock());
if pcm_bytes.is_empty() {
return Ok(RawTranscript {
@@ -68,32 +75,35 @@ impl LocalQwenAsr {
samples_f32.extend(std::iter::repeat(0.0f32).take(8_000));
// 注册 token 回调:每个稳定 token 抛 `local-asr-token` 事件。
// capsule 前端按 sessionId 累积显示。
// capsule 前端按 sessionId 累积显示。回调安装、native 调用和解绑
// 在 QwenAsrEngine 的同一把 context 锁内完成。
let app = self.app.clone();
self.engine.set_token_handler(Some(move |piece: &str| {
if let Err(e) = app.emit("local-asr-token", piece.to_string()) {
log::warn!("[local-asr] emit token failed: {e}");
}
}));
let cancelled = Arc::clone(&self.cancelled);
// qwen_transcribe_stream 是阻塞调用;用 spawn_blocking 防止占住 tokio runtime。
// 用 tauri::async_runtime::spawn_blocking 而非 tokio 的 —— 同 download.rs 注释,
// 走 Tauri 持有的 runtime handle,不依赖调用方上下文(虽然这里目前都在 async 路径上调,
// 但保持一致更稳)。
let engine = Arc::clone(&self.engine);
let text =
tauri::async_runtime::spawn_blocking(move || engine.transcribe_stream(&samples_f32))
.await
.context("transcribe spawn_blocking join 失败")?
.context("qwen_transcribe_stream 失败")?;
// 解绑回调,避免 idle 期 C 端任何后续触发。
self.engine.set_token_handler::<fn(&str)>(None);
let text = tauri::async_runtime::spawn_blocking(move || {
engine.transcribe_stream_with_handler(&samples_f32, move |piece: &str| {
if cancelled.load(Ordering::Acquire) {
return;
}
if let Err(e) = app.emit("local-asr-token", piece.to_string()) {
log::warn!("[local-asr] emit token failed: {e}");
}
})
})
.await
.context("transcribe spawn_blocking join 失败")?
.context("qwen_transcribe_stream 失败")?;
Ok(RawTranscript { text, duration_ms })
}
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Release);
self.buffer.lock().clear();
}
}
@@ -7,7 +7,7 @@ use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_void};
use std::path::Path;
use std::ptr;
use std::sync::Mutex;
use std::sync::{Mutex, MutexGuard};
use anyhow::{Context, Result};
@@ -22,14 +22,18 @@ type TokenHandlerBox = Box<Box<TokenHandler>>;
pub struct QwenAsrEngine {
ctx: *mut QwenCtx,
/// 同一个 C context 不能并发转写,也不能在转写期间替换 token 回调。
/// 取消时上层只会丢弃 `spawn_blocking` 的 JoinHandle,已经开始运行的
/// native worker 仍会继续到返回;这把锁保证它返回前不会被下一次会话复用。
run_lock: Mutex<()>,
/// 持有 token 回调的所有权;C 端拿到的是 `&**handler` 派生出来的 raw ptr,
/// 只要这个 Box 还活着,那个 raw ptr 就有效。Mutex 防止并发 set。
token_handler: Mutex<Option<TokenHandlerBox>>,
}
/// SAFETY: `qwen_ctx_t` 内部的 pthread/buffer 仅在单次 transcribe 期间被 C 端
/// 自己用;外层不会从两个 Rust 线程并发调进同一个 ctx(由 coordinator 串行
/// 化保证)。Send/Sync 在这一约束下成立。
/// 自己用;`run_lock` 保证同一 context 不会被两个 Rust 线程并发调用。Send/Sync
/// 在这一约束下成立。
unsafe impl Send for QwenAsrEngine {}
unsafe impl Sync for QwenAsrEngine {}
@@ -50,12 +54,27 @@ impl QwenAsrEngine {
Ok(Self {
ctx,
run_lock: Mutex::new(()),
token_handler: Mutex::new(None),
})
}
/// 注册流式 token 回调;传 `None` 清空。重新注册会先解绑再装新回调。
pub fn set_token_handler<F>(&self, handler: Option<F>)
where
F: FnMut(&str) + Send + 'static,
{
let _run_guard = self.lock_run();
self.set_token_handler_locked(handler);
}
fn lock_run(&self) -> MutexGuard<'_, ()> {
self.run_lock
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn set_token_handler_locked<F>(&self, handler: Option<F>)
where
F: FnMut(&str) + Send + 'static,
{
@@ -84,6 +103,11 @@ impl QwenAsrEngine {
/// 批式转写:一次性给完整音频(mono f32 16kHz)。
pub fn transcribe_audio(&self, samples: &[f32]) -> Result<String> {
let _run_guard = self.lock_run();
self.transcribe_audio_locked(samples)
}
fn transcribe_audio_locked(&self, samples: &[f32]) -> Result<String> {
if self.ctx.is_null() {
anyhow::bail!("engine already freed — cannot transcribe");
}
@@ -103,6 +127,11 @@ impl QwenAsrEngine {
/// 流式转写:内部按 2s chunk 切片,token 通过 `set_token_handler` 注册的
/// 回调实时吐出;返回值是最终完整文本。
pub fn transcribe_stream(&self, samples: &[f32]) -> Result<String> {
let _run_guard = self.lock_run();
self.transcribe_stream_locked(samples)
}
fn transcribe_stream_locked(&self, samples: &[f32]) -> Result<String> {
if self.ctx.is_null() {
anyhow::bail!("engine already freed — cannot transcribe");
}
@@ -117,6 +146,22 @@ impl QwenAsrEngine {
unsafe { libc::free(raw as *mut c_void) };
Ok(text)
}
/// 在同一把 context 锁内安装回调、运行 native 转写并解绑回调。
///
/// `spawn_blocking` 的 JoinHandle 被取消时,已经启动的 blocking closure
/// 仍可能运行;把回调解绑放进 closure 自身的同步收尾路径,避免旧会话
/// 在下一次会话期间继续持有或调用失效的 userdata。
pub fn transcribe_stream_with_handler<F>(&self, samples: &[f32], handler: F) -> Result<String>
where
F: FnMut(&str) + Send + 'static,
{
let _run_guard = self.lock_run();
self.set_token_handler_locked(Some(handler));
let result = self.transcribe_stream_locked(samples);
self.set_token_handler_locked::<fn(&str)>(None);
result
}
}
impl Drop for QwenAsrEngine {
@@ -370,6 +370,42 @@ struct PreparedWindowsImeSessionSlot {
prepared: PreparedWindowsImeSession,
}
/// 历史音频静默重试的 ASR 资源护栏。
///
/// 重试 future 被 select 丢弃时,局部 QaAsrStart 不会再经过正常的
/// end_session 收尾;这里用 Drop 补 cancel 和本地模型释放,尤其覆盖
/// spawn_blocking 已经开始运行的本地 ASR。
struct CancellableRetranscribeGuard {
inner: Arc<Inner>,
asr: Option<ActiveAsr>,
session_id: SessionId,
}
impl CancellableRetranscribeGuard {
fn new(inner: Arc<Inner>, asr: ActiveAsr, session_id: SessionId) -> Self {
Self {
inner,
asr: Some(asr),
session_id,
}
}
fn disarm(mut self) {
self.asr.take();
}
}
impl Drop for CancellableRetranscribeGuard {
fn drop(&mut self) {
let Some(asr) = self.asr.take() else {
return;
};
let asr_for_release = asr.clone();
cancel_active_asr(asr);
dictation::schedule_cancelled_asr_release(&self.inner, &asr_for_release, self.session_id);
}
}
impl Coordinator {
pub fn new() -> Self {
#[cfg(target_os = "windows")]
@@ -1556,9 +1592,33 @@ impl Coordinator {
}
pub async fn retranscribe_pcm(&self, pcm: Vec<u8>) -> Result<String, String> {
self.retranscribe_pcm_inner(pcm, false).await
}
pub(super) async fn retranscribe_pcm_until_cancelled(
&self,
pcm: Vec<u8>,
) -> Result<String, String> {
self.retranscribe_pcm_inner(pcm, true).await
}
async fn retranscribe_pcm_inner(
&self,
pcm: Vec<u8>,
cancel_on_drop: bool,
) -> Result<String, String> {
let inner = &self.inner;
let active_asr = CredentialsVault::get_active_asr();
let start = build_qa_asr_start(inner, &active_asr).await?;
let retry_guard = if cancel_on_drop {
Some(CancellableRetranscribeGuard::new(
Arc::clone(inner),
start.active_asr(),
inner.state.lock().session_id,
))
} else {
None
};
start.open_streaming_session().await?;
let consumer = start.recorder_consumer();
consumer.consume_pcm_chunk(&pcm);
@@ -1627,6 +1687,9 @@ impl Coordinator {
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?,
};
if let Some(guard) = retry_guard {
guard.disarm();
}
Ok(raw.text)
}
@@ -2174,6 +2174,12 @@ const SILENT_RETRY_MAX: u32 = 2;
/// 网络/服务端一点缓冲再打。
const SILENT_RETRY_BACKOFF_MS: u64 = 500;
enum SilentRetryOutcome {
Transcript(RawTranscript),
Exhausted,
Cancelled,
}
/// 归档 wav 是 16k/mono/16-bit、固定 44 字节标准头(asr::wav::encode_wav_16k_mono);取出
/// PCM 负载。长度 <= 44(空/损坏)返回 None。
fn pcm_from_wav_bytes(wav: &[u8]) -> Option<Vec<u8>> {
@@ -2196,49 +2202,70 @@ async fn retranscribe_pcm_via_inner(inner: &Arc<Inner>, pcm: Vec<u8>) -> Result<
Coordinator {
inner: Arc::clone(inner),
}
.retranscribe_pcm(pcm)
.retranscribe_pcm_until_cancelled(pcm)
.await
}
/// 自动静默重试:从刚归档的 wav 读 PCM,用当前 provider 重转最多 SILENT_RETRY_MAX 次(线性
/// 退避)。任一次拿到非空文本立即 Some(当作正常转写继续走润色/插入);没有归档音频、读不到、
/// 或全部失败则 None(交回 fail_dictation 做「失败保留 + 报错」)。全程不改胶囊文案——对用户
/// 静默,只是「转写中」多停留一会儿。
async fn try_silent_retranscribe(
inner: &Arc<Inner>,
session_id: SessionId,
) -> Option<RawTranscript> {
if !inner.audio_archive_active.load(Ordering::Relaxed) {
return None; // 没归档音频,无从重试
/// 退避)。任一次拿到非空文本立即返回 Transcript(当作正常转写继续走润色/插入);没有归档
/// 音频、读不到或全部失败返回 Exhausted(交回 fail_dictation 做「失败保留 + 报错」)。如果
/// 用户在退避或重试请求期间按 Esc,则返回 Cancelled,直接完成取消收尾。全程不改胶囊文案——
/// 对用户静默,只是「转写中」多停留一会儿。
async fn try_silent_retranscribe(inner: &Arc<Inner>, session_id: SessionId) -> SilentRetryOutcome {
if inner.state.lock().cancelled {
return SilentRetryOutcome::Cancelled;
}
let path = crate::persistence::recording_path_for_session(&session_id.to_string()).ok()?;
let wav = tokio::fs::read(&path).await.ok()?;
let pcm = pcm_from_wav_bytes(&wav)?;
if !inner.audio_archive_active.load(Ordering::Relaxed) {
return SilentRetryOutcome::Exhausted; // 没归档音频,无从重试
}
let Some(path) = crate::persistence::recording_path_for_session(&session_id.to_string()).ok()
else {
return SilentRetryOutcome::Exhausted;
};
let wav = tokio::select! {
biased;
_ = wait_for_processing_cancel(inner) => return SilentRetryOutcome::Cancelled,
result = tokio::fs::read(&path) => match result {
Ok(wav) => wav,
Err(_) => return SilentRetryOutcome::Exhausted,
},
};
let Some(pcm) = pcm_from_wav_bytes(&wav) else {
return SilentRetryOutcome::Exhausted;
};
let duration_ms = pcm_duration_ms(pcm.len());
for attempt in 1..=SILENT_RETRY_MAX {
tokio::time::sleep(std::time::Duration::from_millis(
SILENT_RETRY_BACKOFF_MS * attempt as u64,
))
.await;
match retranscribe_pcm_via_inner(inner, pcm.clone()).await {
tokio::select! {
biased;
_ = wait_for_processing_cancel(inner) => return SilentRetryOutcome::Cancelled,
_ = tokio::time::sleep(std::time::Duration::from_millis(
SILENT_RETRY_BACKOFF_MS * attempt as u64,
)) => {}
}
let result = tokio::select! {
biased;
_ = wait_for_processing_cancel(inner) => return SilentRetryOutcome::Cancelled,
result = retranscribe_pcm_via_inner(inner, pcm.clone()) => result,
};
match result {
Ok(text) if !text.trim().is_empty() => {
log::info!(
"[coord] 自动静默重试第 {attempt}/{SILENT_RETRY_MAX} 次成功({} 字)",
text.chars().count()
);
return Some(RawTranscript { text, duration_ms });
return SilentRetryOutcome::Transcript(RawTranscript { text, duration_ms });
}
Ok(_) => {
// 重试得到空转写——多半真没说话,再重试无意义,省流量直接放弃。
log::info!("[coord] 自动静默重试得到空转写,停止重试");
return None;
return SilentRetryOutcome::Exhausted;
}
Err(e) => {
log::warn!("[coord] 自动静默重试第 {attempt}/{SILENT_RETRY_MAX} 次失败: {e}");
}
}
}
None
SilentRetryOutcome::Exhausted
}
fn finish_cancelled_processing(inner: &Arc<Inner>, session_id: SessionId) -> bool {
@@ -2252,6 +2279,54 @@ fn finish_cancelled_processing(inner: &Arc<Inner>, session_id: SessionId) -> boo
finished
}
pub(super) fn schedule_cancelled_asr_release(
inner: &Arc<Inner>,
asr: &ActiveAsr,
session_id: SessionId,
) {
match asr {
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(_) => {
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Dictation(session_id));
}
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(_) => {
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Dictation(session_id));
}
#[cfg(target_os = "macos")]
ActiveAsr::Local(_) => {
inner.local_asr_cache.touch();
schedule_local_asr_release(inner);
}
_ => {}
}
}
/// end_session 转写阶段与「用户取消」赛跑的结果。
enum TranscribeRace {
Done(Result<RawTranscript, TranscribeFail>),
/// 用户在 Processing(转写)阶段按 Esc / 取消:drop 掉在途 transcribe future。
Cancelled,
}
/// 轮询 Processing 阶段的取消标志。用户在转写阶段按 Esc 时,cancel_session 只把
/// `state.cancelled` 置 true —— 此刻 ASR 句柄已被 end_session 从 `inner.asr` 槽 take 走,
/// cancel_session 走的 `cancel_asr_for_session` 是 no-op,够不到在途请求。end_session 用
/// 本函数与在途 transcribe future 赛跑:命中即 drop future,从而中断 reqwest HTTP /
/// 停止等待流式最终结果 / 停止本地转写。
///
/// 用 75ms 轮询而非 notify:转写通常 0.2–3s,几次定时器唤醒的开销可忽略,用户也感知不到
/// 这点延迟;换来的是不依赖任何唤醒信号、没有「取消边沿在注册 waiter 之前触发就丢失」的
/// 竞态,逻辑上更稳。
async fn wait_for_processing_cancel(inner: &Arc<Inner>) {
loop {
if inner.state.lock().cancelled {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(75)).await;
}
}
pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let current_session_id = {
let mut state = inner.state.lock();
@@ -2282,306 +2357,333 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
};
let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr);
// ASR 句柄内部是 Arc,clone 只是 +1 引用。留一份给取消路径:transcribe future 会把
// `asr` move 进去,命中取消时那个 future 会被 drop(连同它持有的 Arc),我们再用这份
// clone 显式 cancel,促使流式 WebSocket 立刻关闭、不残留后台 worker。
let asr_for_cancel = asr.clone();
// 每个引擎分支产出 Ok(RawTranscript) 或 Err(TranscribeFail);失败/超时不再就地 return,
// 而是把失败值交给 match 之后统一处理:先自动静默重试(从归档音频重转,应对网络/服务端
// 瞬时抖动),重试拿回文本就当正常转写继续;彻底失败才 fail_dictation 保留录音 + 报错。
let transcribe_outcome: Result<RawTranscript, TranscribeFail> = match asr {
ActiveAsr::Volcengine(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] send last frame failed: {e}");
}
// 添加全局超时保护:防止 await_final_result() 永远挂起
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] await final failed: {e}");
// 关闭 WebSocket 连接,避免流式 ASR 资源泄漏
asr.cancel();
Err(TranscribeFail::new(format!("识别失败: {e}"), e.to_string()))
}
Err(_) => {
// 全局超时:最后的防线
log::error!(
"[coord] 全局超时 {} 秒 - 强制恢复",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
// 清理 ASR session,避免资源泄漏
asr.cancel();
Err(TranscribeFail::new(
"识别超时".to_string(),
"global timeout".to_string(),
))
}
}
}
ActiveAsr::Whisper(w) => {
debug_assert!(uses_global_timeout);
// 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)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] whisper transcribe failed: {e}");
Err(TranscribeFail::new(format!("识别失败: {e}"), e.to_string()))
}
Err(_) => {
log::error!(
"[coord] Whisper 动态超时 {}s(音频 {:.2}s)",
timeout_duration.as_secs(),
audio_secs
);
Err(TranscribeFail::new(
"识别超时".to_string(),
"whisper global timeout".to_string(),
))
}
}
}
ActiveAsr::Mimo(m) => {
debug_assert!(uses_global_timeout);
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, m.transcribe()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] MiMo ASR transcribe failed: {e}");
Err(TranscribeFail::new(format!("识别失败: {e}"), e.to_string()))
}
Err(_) => {
log::error!(
"[coord] MiMo ASR 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
Err(TranscribeFail::new(
"识别超时".to_string(),
"mimo global timeout".to_string(),
))
}
}
}
ActiveAsr::Bailian(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] Bailian send last frame failed: {e}");
}
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] Bailian await final failed: {e}");
// 关闭 WebSocket 连接,避免流式 ASR 资源泄漏
asr.cancel();
Err(TranscribeFail::new(format!("识别失败: {e}"), e.to_string()))
}
Err(_) => {
log::error!(
"[coord] Bailian 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
Err(TranscribeFail::new(
"识别超时".to_string(),
"bailian global timeout".to_string(),
))
}
}
}
ActiveAsr::Qwen3Realtime(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] Qwen3 realtime send last frame failed: {e}");
}
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] Qwen3 realtime await final failed: {e}");
// 关闭 WebSocket 连接,避免流式 ASR 资源泄漏
asr.cancel();
Err(TranscribeFail::new(format!("识别失败: {e}"), e.to_string()))
}
Err(_) => {
log::error!(
"[coord] Qwen3 realtime 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
Err(TranscribeFail::new(
"识别超时".to_string(),
"qwen3 realtime global timeout".to_string(),
))
}
}
}
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => {
debug_assert!(!uses_global_timeout);
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
let timeout_duration = windows_local_asr_transcribe_timeout(audio_secs);
log::info!(
"[coord] Foundry Local Whisper transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
match local.transcribe(timeout_duration).await {
Ok(r) => {
schedule_foundry_local_asr_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
Ok(r)
}
Err(e) => {
if inner.state.lock().cancelled {
log::info!(
"[coord] Foundry Local Whisper transcribe cancelled — discarding transcript"
);
schedule_foundry_local_asr_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
restore_prepared_windows_ime_session(inner, current_session_id);
finish_cancelled_processing(inner, current_session_id);
return Ok(());
//
// 整段转写与「用户在 Processing 阶段取消」赛跑:命中取消就直接 drop 掉 transcribe future
// 中断在途请求,不再傻等它跑完(见 issue「转写中按 Esc 停不下来」)。
let raced: TranscribeRace = {
let transcribe_fut = async move {
let transcribe_outcome: Result<RawTranscript, TranscribeFail> = match asr {
ActiveAsr::Volcengine(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] send last frame failed: {e}");
}
log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}");
schedule_foundry_local_asr_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
Err(TranscribeFail::new(format!("本地识别失败: {e}"), e.to_string()))
}
}
}
// Windows sherpa-onnx offline batch:停止录音后整段转写,再复用现有
// polish / insert / history 收尾路径。
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(local) => {
debug_assert!(!uses_global_timeout);
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
let timeout_duration = windows_local_asr_transcribe_timeout(audio_secs);
log::info!(
"[coord] sherpa-onnx transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
match local.transcribe(timeout_duration).await {
Ok(r) => {
schedule_sherpa_onnx_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
Ok(r)
}
Err(e) => {
if inner.state.lock().cancelled {
log::info!(
"[coord] sherpa-onnx transcribe cancelled — discarding transcript"
);
schedule_sherpa_onnx_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
restore_prepared_windows_ime_session(inner, current_session_id);
finish_cancelled_processing(inner, current_session_id);
return Ok(());
// 添加全局超时保护:防止 await_final_result() 永远挂起
let timeout_duration =
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] await final failed: {e}");
// 关闭 WebSocket 连接,避免流式 ASR 资源泄漏
asr.cancel();
Err(TranscribeFail::new(format!("识别失败: {e}"), e.to_string()))
}
Err(_) => {
// 全局超时:最后的防线
log::error!(
"[coord] 全局超时 {} 秒 - 强制恢复",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
// 清理 ASR session,避免资源泄漏
asr.cancel();
Err(TranscribeFail::new(
"识别超时".to_string(),
"global timeout".to_string(),
))
}
}
log::error!("[coord] sherpa-onnx transcribe failed: {e:#}");
schedule_sherpa_onnx_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
}
ActiveAsr::Whisper(w) => {
debug_assert!(uses_global_timeout);
// 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()
);
Err(TranscribeFail::new(format!("本地识别失败: {e}"), e.to_string()))
}
}
}
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => {
debug_assert!(uses_global_timeout);
// 缓存命中时 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);
match result {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}");
Err(TranscribeFail::new(format!("本地识别失败: {e}"), e.to_string()))
}
Err(_) => {
log::error!(
"[coord] local Qwen3-ASR 动态超时 {}s(音频 {:.2}s)",
timeout_duration.as_secs(),
audio_secs
);
Err(TranscribeFail::new(
"识别超时".to_string(),
"local global timeout".to_string(),
))
}
}
}
// Apple Speech:系统语音识别,无模型加载耗时。批处理 transcribe 受音频
// 长度影响,沿用 local_qwen_transcribe_timeout 的动态超时公式。
#[cfg(target_os = "macos")]
ActiveAsr::AppleSpeech(local) => {
debug_assert!(uses_global_timeout);
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
log::info!(
"[coord] Apple Speech transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
match tokio::time::timeout(timeout_duration, local.transcribe()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
if inner.state.lock().cancelled {
log::info!(
"[coord] Apple Speech transcribe cancelled - discarding transcript"
);
restore_prepared_windows_ime_session(inner, current_session_id);
finish_cancelled_processing(inner, current_session_id);
return Ok(());
match tokio::time::timeout(timeout_duration, w.transcribe()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] whisper transcribe failed: {e}");
Err(TranscribeFail::new(format!("识别失败: {e}"), e.to_string()))
}
Err(_) => {
log::error!(
"[coord] Whisper 动态超时 {}s(音频 {:.2}s)",
timeout_duration.as_secs(),
audio_secs
);
Err(TranscribeFail::new(
"识别超时".to_string(),
"whisper global timeout".to_string(),
))
}
}
log::error!("[coord] Apple Speech transcribe failed: {e:#}");
Err(TranscribeFail::new(format!("本地识别失败: {e}"), e.to_string()))
}
Err(_) => {
log::error!(
"[coord] Apple Speech 动态超时 {}s(音频 {:.2}s)",
timeout_duration.as_secs(),
audio_secs
ActiveAsr::Mimo(m) => {
debug_assert!(uses_global_timeout);
let timeout_duration =
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, m.transcribe()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] MiMo ASR transcribe failed: {e}");
Err(TranscribeFail::new(format!("识别失败: {e}"), e.to_string()))
}
Err(_) => {
log::error!(
"[coord] MiMo ASR 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
Err(TranscribeFail::new(
"识别超时".to_string(),
"mimo global timeout".to_string(),
))
}
}
}
ActiveAsr::Bailian(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] Bailian send last frame failed: {e}");
}
let timeout_duration =
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] Bailian await final failed: {e}");
// 关闭 WebSocket 连接,避免流式 ASR 资源泄漏
asr.cancel();
Err(TranscribeFail::new(format!("识别失败: {e}"), e.to_string()))
}
Err(_) => {
log::error!(
"[coord] Bailian 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
Err(TranscribeFail::new(
"识别超时".to_string(),
"bailian global timeout".to_string(),
))
}
}
}
ActiveAsr::Qwen3Realtime(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] Qwen3 realtime send last frame failed: {e}");
}
let timeout_duration =
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] Qwen3 realtime await final failed: {e}");
// 关闭 WebSocket 连接,避免流式 ASR 资源泄漏
asr.cancel();
Err(TranscribeFail::new(format!("识别失败: {e}"), e.to_string()))
}
Err(_) => {
log::error!(
"[coord] Qwen3 realtime 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
Err(TranscribeFail::new(
"识别超时".to_string(),
"qwen3 realtime global timeout".to_string(),
))
}
}
}
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => {
debug_assert!(!uses_global_timeout);
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
let timeout_duration = windows_local_asr_transcribe_timeout(audio_secs);
log::info!(
"[coord] Foundry Local Whisper transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
Err(TranscribeFail::new(
"识别超时".to_string(),
"apple-speech global timeout".to_string(),
))
match local.transcribe(timeout_duration).await {
Ok(r) => {
schedule_foundry_local_asr_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
Ok(r)
}
Err(e) => {
// 用户取消现在由外层 select! 统一处理(drop 掉本 future 中断在途转写),
// 到这里的 Err 一律当作真失败:调度引擎释放 + 交给 match 后的重试/报错。
log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}");
schedule_foundry_local_asr_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
Err(TranscribeFail::new(
format!("本地识别失败: {e}"),
e.to_string(),
))
}
}
}
}
// Windows sherpa-onnx offline batch:停止录音后整段转写,再复用现有
// polish / insert / history 收尾路径。
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(local) => {
debug_assert!(!uses_global_timeout);
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
let timeout_duration = windows_local_asr_transcribe_timeout(audio_secs);
log::info!(
"[coord] sherpa-onnx transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
match local.transcribe(timeout_duration).await {
Ok(r) => {
schedule_sherpa_onnx_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
Ok(r)
}
Err(e) => {
// 取消由外层 select! 统一处理,见 Foundry 分支同款注释。
log::error!("[coord] sherpa-onnx transcribe failed: {e:#}");
schedule_sherpa_onnx_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
Err(TranscribeFail::new(
format!("本地识别失败: {e}"),
e.to_string(),
))
}
}
}
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => {
debug_assert!(uses_global_timeout);
// 缓存命中时 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);
match result {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}");
Err(TranscribeFail::new(
format!("本地识别失败: {e}"),
e.to_string(),
))
}
Err(_) => {
log::error!(
"[coord] local Qwen3-ASR 动态超时 {}s(音频 {:.2}s)",
timeout_duration.as_secs(),
audio_secs
);
Err(TranscribeFail::new(
"识别超时".to_string(),
"local global timeout".to_string(),
))
}
}
}
// Apple Speech:系统语音识别,无模型加载耗时。批处理 transcribe 受音频
// 长度影响,沿用 local_qwen_transcribe_timeout 的动态超时公式。
#[cfg(target_os = "macos")]
ActiveAsr::AppleSpeech(local) => {
debug_assert!(uses_global_timeout);
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
log::info!(
"[coord] Apple Speech transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
match tokio::time::timeout(timeout_duration, local.transcribe()).await {
Ok(Ok(r)) => Ok(r),
Ok(Err(e)) => {
// 取消由外层 select! 统一处理,见 Foundry 分支同款注释。
log::error!("[coord] Apple Speech transcribe failed: {e:#}");
Err(TranscribeFail::new(
format!("本地识别失败: {e}"),
e.to_string(),
))
}
Err(_) => {
log::error!(
"[coord] Apple Speech 动态超时 {}s(音频 {:.2}s)",
timeout_duration.as_secs(),
audio_secs
);
Err(TranscribeFail::new(
"识别超时".to_string(),
"apple-speech global timeout".to_string(),
))
}
}
}
};
transcribe_outcome
};
tokio::select! {
// biased:每次先查取消标志,取消优先于「转写恰好同时完成」。
biased;
_ = wait_for_processing_cancel(inner) => TranscribeRace::Cancelled,
outcome = transcribe_fut => TranscribeRace::Done(outcome),
}
};
// ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。
let transcribe_outcome: Result<RawTranscript, TranscribeFail> = match raced {
TranscribeRace::Cancelled => {
log::info!("[coord] cancel during transcribe — 中断在途 ASR 请求,丢弃转写");
// 上面 select! 已把 transcribe_fut drop 掉(中断 reqwest / 停止等待流式结果 /
// 停止本地转写);这里再显式 cancel 一次,促使流式 WebSocket 立即关闭、不残留
// 后台 worker。asr_for_cancel 与被 drop 的 future 共享同一 Arc 底层。
let asr_for_release = asr_for_cancel.clone();
cancel_active_asr(asr_for_cancel);
// end_session 已经把 ASR 从 inner.asr 取走,cancel_session 无法再触发
// provider 的释放调度;取消路径必须自己补上,否则本地模型会一直占用缓存。
schedule_cancelled_asr_release(inner, &asr_for_release, current_session_id);
restore_prepared_windows_ime_session(inner, current_session_id);
// 与下方「ASR 完成后 cancel 检查」同款收尾(finish_cancelled_processing 负责
// 把 phase 收回 Idle、清 focus_target)。
finish_cancelled_processing(inner, current_session_id);
return Ok(());
}
TranscribeRace::Done(outcome) => outcome,
};
// ASR 完成后 cancel 检查:转写恰好跑完、用户几乎同时按 Esc(select! 走了 Done 分支)时
// 这里兜底命中。上面赛跑分支处理的是「转写还在途中」的取消。
// 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。
if inner.state.lock().cancelled {
log::info!("[coord] cancel detected after ASR — discarding transcript");
@@ -2600,15 +2702,25 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let raw = match transcribe_outcome {
Ok(raw) => raw,
Err(fail) => match try_silent_retranscribe(inner, current_session_id).await {
Some(raw) => raw,
None => {
return fail_dictation(
inner,
current_session_id,
elapsed,
fail.user_msg,
fail.err,
)
SilentRetryOutcome::Transcript(raw) => raw,
SilentRetryOutcome::Cancelled => {
log::info!("[coord] cancel during silent ASR retry — discarding transcript");
restore_prepared_windows_ime_session(inner, current_session_id);
finish_cancelled_processing(inner, current_session_id);
return Ok(());
}
SilentRetryOutcome::Exhausted => {
// 处理最后一次重试结果时也复查一次取消标志,覆盖「重试刚返回
// Exhausted 与用户同时按 Esc」的窄竞态,避免误走失败提示。
if inner.state.lock().cancelled {
log::info!(
"[coord] cancel after silent ASR retry — discarding transcript"
);
restore_prepared_windows_ime_session(inner, current_session_id);
finish_cancelled_processing(inner, current_session_id);
return Ok(());
}
return fail_dictation(inner, current_session_id, elapsed, fail.user_msg, fail.err)
}
},
};