fix(qa): use active ASR provider in floating panel

This commit is contained in:
H-Chris233 committed 2026-05-31 21:04:37 +08:00
1 parent bbc78d1461
commit 4e30c7461d
2 files changed
+440 -83

No files matched your search

+416 -70
View File
@@ -71,8 +71,9 @@ use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState};
#[cfg(test)]
use resources::discard_startup_resources_for_session;
use resources::{
acquire_recording_mute, release_recording_mute, selected_microphone_device_name,
stop_microphone_preview_monitor, stop_qa_recorder, SessionResource, SharedRecordingMuteState,
acquire_recording_mute, cancel_active_asr, release_recording_mute,
selected_microphone_device_name, stop_microphone_preview_monitor, stop_qa_recorder,
SessionResource, SharedRecordingMuteState,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -136,6 +137,7 @@ fn show_capsule_window_for_recording<R: tauri::Runtime>(
}
}
#[derive(Clone)]
enum ActiveAsr {
Volcengine(Arc<VolcengineStreamingASR>),
Whisper(Arc<WhisperBatchASR>),
@@ -162,6 +164,30 @@ fn asr_transcribe_uses_global_timeout(asr: &ActiveAsr) -> bool {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActiveAsrProviderKind {
Bailian,
WhisperCompatible,
Volcengine,
}
fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind {
if is_bailian_provider(id) {
ActiveAsrProviderKind::Bailian
} else if is_whisper_compatible_provider(id) {
ActiveAsrProviderKind::WhisperCompatible
} else {
ActiveAsrProviderKind::Volcengine
}
}
fn batch_asr_chunk_limit_ms(provider_id: &str) -> Option<u64> {
match provider_id {
"zhipu" => Some(30_000),
_ => None,
}
}
pub struct Coordinator {
inner: Arc<Inner>,
}
@@ -225,8 +251,8 @@ struct Inner {
/// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发
/// resize / reposition。
capsule_layout: Mutex<Option<CapsuleLayoutState>>,
/// QA 用的 ASR 句柄(始终是 Volcengine 流式)。
qa_asr: Mutex<Option<Arc<VolcengineStreamingASR>>>,
/// QA 用的 ASR 句柄。必须跟 active_asr_provider 保持一致,避免浮窗走不同入口。
qa_asr: Mutex<Option<ActiveAsr>>,
/// QA 用的 Recorder 句柄。
qa_recorder: Mutex<Option<Recorder>>,
/// QA SSE 流取消标志。begin_qa_session 重置为 false;cancel_qa_session 设 true;
@@ -2363,12 +2389,22 @@ fn foundry_local_asr_release_keep_secs(inner: &Arc<Inner>) -> u32 {
}
#[cfg(target_os = "windows")]
fn foundry_release_session_is_current(inner: &Arc<Inner>, session_id: SessionId) -> bool {
inner.state.lock().session_id == session_id
#[derive(Clone, Copy)]
enum AsrReleaseSession {
Dictation(SessionId),
Qa(SessionId),
}
#[cfg(target_os = "windows")]
fn schedule_foundry_local_asr_release(inner: &Arc<Inner>, session_id: SessionId) {
fn asr_release_session_is_current(inner: &Arc<Inner>, session: AsrReleaseSession) -> bool {
match session {
AsrReleaseSession::Dictation(session_id) => inner.state.lock().session_id == session_id,
AsrReleaseSession::Qa(session_id) => inner.qa_state.lock().session_id == session_id,
}
}
#[cfg(target_os = "windows")]
fn schedule_foundry_local_asr_release(inner: &Arc<Inner>, session: AsrReleaseSession) {
let keep_secs = foundry_local_asr_release_keep_secs(inner);
let runtime = Arc::clone(&inner.foundry_local_runtime);
let inner = Arc::clone(inner);
@@ -2376,7 +2412,7 @@ fn schedule_foundry_local_asr_release(inner: &Arc<Inner>, session_id: SessionId)
if keep_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await;
}
if !foundry_release_session_is_current(&inner, session_id) {
if !asr_release_session_is_current(&inner, session) {
return;
}
if let Err(error) = runtime.release_now().await {
@@ -2393,7 +2429,7 @@ fn sherpa_onnx_release_keep_secs(inner: &Arc<Inner>) -> u32 {
/// 与 `schedule_foundry_local_asr_release` 同形:session_id 老旧则不释放,
/// 避免下一轮 session 立即重加载同一个 offline batch 模型。
#[cfg(target_os = "windows")]
fn schedule_sherpa_onnx_release(inner: &Arc<Inner>, session_id: SessionId) {
fn schedule_sherpa_onnx_release(inner: &Arc<Inner>, session: AsrReleaseSession) {
let keep_secs = sherpa_onnx_release_keep_secs(inner);
let runtime = Arc::clone(&inner.sherpa_onnx_runtime);
let inner = Arc::clone(inner);
@@ -2401,7 +2437,7 @@ fn schedule_sherpa_onnx_release(inner: &Arc<Inner>, session_id: SessionId) {
if keep_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await;
}
if !foundry_release_session_is_current(&inner, session_id) {
if !asr_release_session_is_current(&inner, session) {
return;
}
if let Err(error) = runtime.release_now().await {
@@ -2469,15 +2505,159 @@ fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) ->
}
}
/// QA 路径专用:begin_qa_session 永远走 Volcengine 流式(低延迟要求),所以
/// 凭据校验也只看 Volcengine 字段,不依赖 active_asr。dictation 路径请用
/// `ensure_asr_credentials`。
fn ensure_qa_volcengine_credentials() -> Result<(), String> {
let creds = read_volc_credentials();
if creds.app_id.trim().is_empty() || creds.access_token.trim().is_empty() {
Err("请先在设置中填写火山引擎 ASR App Key 和 Access Key".to_string())
} else {
Ok(())
enum QaAsrStart {
Volcengine {
asr: Arc<VolcengineStreamingASR>,
bridge: Arc<DeferredAsrBridge>,
},
Bailian {
asr: Arc<BailianRealtimeASR>,
bridge: Arc<DeferredAsrBridge>,
},
Ready {
active: ActiveAsr,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
},
}
impl QaAsrStart {
fn active_asr(&self) -> ActiveAsr {
match self {
QaAsrStart::Volcengine { asr, .. } => ActiveAsr::Volcengine(Arc::clone(asr)),
QaAsrStart::Bailian { asr, .. } => ActiveAsr::Bailian(Arc::clone(asr)),
QaAsrStart::Ready { active, .. } => active.clone(),
}
}
fn recorder_consumer(&self) -> Arc<dyn crate::recorder::AudioConsumer> {
match self {
QaAsrStart::Volcengine { bridge, .. } => Arc::clone(bridge) as _,
QaAsrStart::Bailian { bridge, .. } => Arc::clone(bridge) as _,
QaAsrStart::Ready { consumer, .. } => Arc::clone(consumer),
}
}
async fn open_streaming_session(&self) -> Result<(), String> {
match self {
QaAsrStart::Volcengine { asr, bridge } => {
asr.open_session().await.map_err(|e| e.to_string())?;
let target: Arc<dyn crate::asr::AudioConsumer> = Arc::clone(asr) as _;
let flushed = bridge.attach(target);
log::info!("[coord] QA ASR connected; flushed {flushed} deferred audio bytes");
Ok(())
}
QaAsrStart::Bailian { asr, bridge } => {
asr.open_session().await.map_err(|e| e.to_string())?;
let target: Arc<dyn crate::asr::AudioConsumer> = Arc::clone(asr) as _;
let flushed = bridge.attach(target);
log::info!(
"[coord] QA Bailian ASR connected; flushed {flushed} deferred audio bytes"
);
Ok(())
}
QaAsrStart::Ready { .. } => Ok(()),
}
}
}
async fn build_qa_asr_start(inner: &Arc<Inner>, active_asr: &str) -> Result<QaAsrStart, String> {
#[cfg(target_os = "windows")]
if foundry::is_foundry_local_whisper(active_asr) {
let prefs = inner.prefs.get();
let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) {
prefs.foundry_local_asr_model.clone()
} else {
foundry::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let local = Arc::new(FoundryLocalWhisperAsr::new(
Arc::clone(&inner.foundry_local_runtime),
model_alias,
prefs.foundry_local_runtime_source.clone(),
language_hint,
));
let active = ActiveAsr::FoundryLocalWhisper(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
#[cfg(target_os = "windows")]
if sherpa::is_sherpa_onnx_local(active_asr) {
let prefs = inner.prefs.get();
let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) {
prefs.sherpa_onnx_model.clone()
} else {
sherpa::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let token_handler = inner.app.lock().clone().map(|app| {
Arc::new(move |piece: String| {
if let Err(error) = app.emit("local-asr-token", piece) {
log::warn!("[sherpa-asr] emit token failed: {error}");
}
}) as crate::asr::local::sherpa_provider::SherpaTokenHandler
});
let local = SherpaOnnxAsr::new_for_model(
Arc::clone(&inner.sherpa_onnx_runtime),
model_alias,
language_hint,
token_handler,
)
.await
.map_err(|e| format!("sherpa-onnx init failed: {e}"))?;
let local = Arc::new(local);
let active = ActiveAsr::SherpaOnnxLocal(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
#[cfg(target_os = "macos")]
if crate::asr::local::is_local_qwen3(active_asr) {
let local = build_local_qwen3(inner)
.await
.map_err(|e| format!("local ASR init failed: {e}"))?;
let active = ActiveAsr::Local(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
match active_asr_provider_kind(active_asr) {
ActiveAsrProviderKind::Bailian => Ok(QaAsrStart::Bailian {
asr: Arc::new(BailianRealtimeASR::new(read_bailian_credentials())),
bridge: Arc::new(DeferredAsrBridge::new()),
}),
ActiveAsrProviderKind::WhisperCompatible => {
let (api_key, base_url, model) = read_whisper_credentials();
let whisper_prompt =
crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner));
let whisper = Arc::new(WhisperBatchASR::new(
api_key,
base_url,
model,
whisper_prompt,
batch_asr_chunk_limit_ms(active_asr),
));
let active = ActiveAsr::Whisper(Arc::clone(&whisper));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = whisper;
Ok(QaAsrStart::Ready { active, consumer })
}
ActiveAsrProviderKind::Volcengine => Ok(QaAsrStart::Volcengine {
asr: Arc::new(VolcengineStreamingASR::new(
read_volc_credentials(),
enabled_hotwords(inner),
)),
bridge: Arc::new(DeferredAsrBridge::new()),
}),
}
}
@@ -2900,15 +3080,11 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
);
}
// 2. 凭据缺失走静默 fallback:与 dictation 一致的"用户的话不丢"约定。
// 缺火山凭据 → 后续 Recorder 仍会跑,只是 ASR 拿不到结果,end_qa_session
// 会发 idle 事件关浮窗。
// 注意:QA 强制走 Volcengine 流式(见下方注释),所以这里必须直接校验
// Volcengine 字段,不能复用 `ensure_asr_credentials`——后者会按用户在设置
// 里选的 active_asr 走 OpenAI 兼容分支,让 QA 把 `asr.api_key` 当成必要项,
// 或在 Volcengine 凭据其实为空时误判通过。Codex P1,PR #213。
if let Err(message) = ensure_qa_volcengine_credentials() {
log::warn!("[coord] QA: ASR credentials missing: {message}");
// 2. QA 与 dictation 使用同一个 active ASR 入口。不要回退火山,否则用户配置
// 百炼 / Whisper / 本地 ASR 后,浮窗仍会偷偷走另一套凭据。
let active_asr = CredentialsVault::get_active_asr();
if let Err(message) = ensure_asr_credentials() {
log::warn!("[coord] QA: active ASR credentials missing: {message}");
finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}"));
return Err(message);
}
@@ -2919,13 +3095,16 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
return Err(message);
}
// 3. 启动 Recorder + ASR(强制走 Volcengine 流式:QA 必须低延迟)。
let hotwords = enabled_hotwords(inner);
let creds = read_volc_credentials();
let asr = Arc::new(VolcengineStreamingASR::new(creds, hotwords));
let bridge = Arc::new(DeferredAsrBridge::new());
let consumer: Arc<dyn crate::recorder::AudioConsumer> = bridge.clone();
*inner.qa_asr.lock() = Some(Arc::clone(&asr));
let qa_asr = match build_qa_asr_start(inner, &active_asr).await {
Ok(qa_asr) => qa_asr,
Err(message) => {
log::error!("[coord] QA active ASR init failed: {message}");
finish_qa_with_error(inner, format!("ASR 初始化失败: {message}"));
return Err(message);
}
};
let consumer = qa_asr.recorder_consumer();
*inner.qa_asr.lock() = Some(qa_asr.active_asr());
// QA recorder 不需要 RMS 节流到胶囊;前端 QA 浮窗有自己的电平视图,
// 这里发一份事件给 "qa" label 用就够了。
@@ -2980,36 +3159,36 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
}
Err(e) => {
log::error!("[coord] QA recorder start failed: {e}");
inner.qa_asr.lock().take();
if let Some(asr) = inner.qa_asr.lock().take() {
cancel_active_asr(asr);
}
release_recording_mute(inner, "qa");
finish_qa_with_error(inner, format!("录音启动失败: {e}"));
return Err(e.to_string());
}
}
if let Err(e) = asr.open_session().await {
if let Err(e) = qa_asr.open_streaming_session().await {
log::error!("[coord] QA: open ASR session failed: {e}");
stop_qa_recorder(inner);
if let Some(asr) = inner.qa_asr.lock().take() {
asr.cancel();
cancel_active_asr(asr);
}
finish_qa_with_error(inner, format!("ASR 连接失败: {e}"));
return Err(e.to_string());
return Err(e);
}
// cancel race:在 await 期间用户可能 dismiss 了浮窗。
if inner.qa_state.lock().cancelled {
log::info!("[coord] QA cancel raced during open_session — aborting begin");
asr.cancel();
if let Some(asr) = inner.qa_asr.lock().take() {
cancel_active_asr(asr);
}
stop_qa_recorder(inner);
inner.qa_state.lock().phase = QaPhase::Idle;
return Ok(());
}
let target: Arc<dyn crate::asr::AudioConsumer> = asr;
let flushed = bridge.attach(target);
log::info!("[coord] QA ASR connected; flushed {flushed} deferred audio bytes");
// 显式弹胶囊到 Recording。level_handler 后续会持续推电平,胶囊里"录音中…"
// 的视觉反馈跟主听写完全一致。
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
@@ -3043,28 +3222,159 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
}
};
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] QA: send last frame failed: {e}");
}
// 添加全局超时保护:防止 await_final_result() 永远挂起
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
let raw = match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: await final failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
let qa_session_id = inner.qa_state.lock().session_id;
let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr);
let raw = match asr {
ActiveAsr::Volcengine(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] QA: 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)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: await final failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: 全局超时 {} 秒 - 强制恢复",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
finish_qa_with_error(inner, "识别超时".to_string());
return Err("global timeout".to_string());
}
}
}
Err(_) => {
// 全局超时:最后的防线
log::error!(
"[coord] QA: 全局超时 {} 秒 - 强制恢复",
COORDINATOR_GLOBAL_TIMEOUT_SECS
ActiveAsr::Bailian(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] QA: 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)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: Bailian await final failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: Bailian 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
finish_qa_with_error(inner, "识别超时".to_string());
return Err("bailian global timeout".to_string());
}
}
}
ActiveAsr::Whisper(w) => {
debug_assert!(uses_global_timeout);
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, w.transcribe()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: whisper transcribe failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: whisper 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
finish_qa_with_error(inner, "识别超时".to_string());
return Err("whisper global timeout".to_string());
}
}
}
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => {
debug_assert!(!uses_global_timeout);
match local
.transcribe(foundry_audio_transcribe_timeout_duration())
.await
{
Ok(r) => {
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id));
r
}
Err(e) => {
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id));
if inner.qa_state.lock().cancelled {
log::info!(
"[coord] QA Foundry Local Whisper transcribe cancelled — discarding transcript"
);
finish_qa_idle_silently(inner);
return Ok(());
}
log::error!("[coord] QA Foundry Local Whisper transcribe failed: {e:#}");
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
return Err(e.to_string());
}
}
}
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(local) => {
debug_assert!(!uses_global_timeout);
match local
.transcribe(sherpa_audio_transcribe_timeout_duration())
.await
{
Ok(r) => {
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id));
r
}
Err(e) => {
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id));
if inner.qa_state.lock().cancelled {
log::info!(
"[coord] QA sherpa-onnx transcribe cancelled — discarding transcript"
);
finish_qa_idle_silently(inner);
return Ok(());
}
log::error!("[coord] QA sherpa-onnx transcribe failed: {e:#}");
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
return Err(e.to_string());
}
}
}
#[cfg(target_os = "macos")]
ActiveAsr::Local(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] QA local Qwen3-ASR transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
// 清理 ASR session,避免资源泄漏
asr.cancel();
finish_qa_with_error(inner, "识别超时".to_string());
return Err("global timeout".to_string());
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)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA local Qwen3-ASR transcribe failed: {e:#}");
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA local Qwen3-ASR transcribe timeout after {}s",
timeout_duration.as_secs()
);
finish_qa_with_error(inner, "本地识别超时".to_string());
return Err("local qwen transcribe timeout".to_string());
}
}
}
};
@@ -3313,7 +3623,7 @@ fn cancel_qa_session(inner: &Arc<Inner>) {
inner.qa_stream_cancelled.store(true, Ordering::SeqCst);
stop_qa_recorder(inner);
if let Some(asr) = inner.qa_asr.lock().take() {
asr.cancel();
cancel_active_asr(asr);
}
// Processing 阶段保持 phase 让 end_qa_session 自然走完 cancel 检查;
// 否则直接复位。
@@ -3570,6 +3880,22 @@ mod tests {
));
}
#[test]
fn qa_asr_provider_kind_tracks_active_provider() {
assert_eq!(
active_asr_provider_kind(crate::asr::bailian::PROVIDER_ID),
ActiveAsrProviderKind::Bailian
);
assert_eq!(
active_asr_provider_kind("whisper"),
ActiveAsrProviderKind::WhisperCompatible
);
assert_eq!(
active_asr_provider_kind("volcengine"),
ActiveAsrProviderKind::Volcengine
);
}
#[cfg(target_os = "windows")]
#[test]
fn coordinator_shares_app_foundry_runtime() {
@@ -3658,21 +3984,41 @@ mod tests {
#[cfg(target_os = "windows")]
#[test]
fn foundry_release_guard_rejects_stale_session() {
fn foundry_release_guard_rejects_stale_dictation_session() {
let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new());
let coordinator = Coordinator::new_with_foundry_runtime(runtime);
let old_session_id = coordinator.inner.state.lock().session_id;
assert!(foundry_release_session_is_current(
assert!(asr_release_session_is_current(
&coordinator.inner,
old_session_id
AsrReleaseSession::Dictation(old_session_id)
));
coordinator.inner.state.lock().session_id = new_session_id();
assert!(!foundry_release_session_is_current(
assert!(!asr_release_session_is_current(
&coordinator.inner,
old_session_id
AsrReleaseSession::Dictation(old_session_id)
));
}
#[cfg(target_os = "windows")]
#[test]
fn local_asr_release_guard_rejects_stale_qa_session() {
let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new());
let coordinator = Coordinator::new_with_foundry_runtime(runtime);
let old_session_id = coordinator.inner.qa_state.lock().session_id;
assert!(asr_release_session_is_current(
&coordinator.inner,
AsrReleaseSession::Qa(old_session_id)
));
coordinator.inner.qa_state.lock().session_id = new_session_id();
assert!(!asr_release_session_is_current(
&coordinator.inner,
AsrReleaseSession::Qa(old_session_id)
));
}
@@ -892,13 +892,6 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
Ok(())
}
fn batch_asr_chunk_limit_ms(provider_id: &str) -> Option<u64> {
match provider_id {
"zhipu" => Some(30_000),
_ => None,
}
}
pub(super) async fn start_recorder_for_starting(
inner: &Arc<Inner>,
session_id: SessionId,
@@ -1280,7 +1273,10 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
.await
{
Ok(r) => {
schedule_foundry_local_asr_release(inner, current_session_id);
schedule_foundry_local_asr_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
r
}
Err(e) => {
@@ -1288,13 +1284,19 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
log::info!(
"[coord] Foundry Local Whisper transcribe cancelled — discarding transcript"
);
schedule_foundry_local_asr_release(inner, current_session_id);
schedule_foundry_local_asr_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}");
schedule_foundry_local_asr_release(inner, current_session_id);
schedule_foundry_local_asr_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
emit_capsule(
inner,
CapsuleState::Error,
@@ -1320,7 +1322,10 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
.await
{
Ok(r) => {
schedule_sherpa_onnx_release(inner, current_session_id);
schedule_sherpa_onnx_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
r
}
Err(e) => {
@@ -1328,13 +1333,19 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
log::info!(
"[coord] sherpa-onnx transcribe cancelled — discarding transcript"
);
schedule_sherpa_onnx_release(inner, current_session_id);
schedule_sherpa_onnx_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
log::error!("[coord] sherpa-onnx transcribe failed: {e:#}");
schedule_sherpa_onnx_release(inner, current_session_id);
schedule_sherpa_onnx_release(
inner,
AsrReleaseSession::Dictation(current_session_id),
);
emit_capsule(
inner,
CapsuleState::Error,