From 02dd8c56eae1b0f3c24575178c6e670f218f7cba Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 6 May 2026 19:09:32 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(recording):=20=E5=BD=95=E9=9F=B3?= =?UTF-8?q?=E6=9D=A1=E5=87=BA=E7=8E=B0=E6=97=B6=20mic=20=E5=B7=B2=20captur?= =?UTF-8?q?e=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=90=9E=E5=BC=80=E5=A4=B4?= =?UTF-8?q?=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:按 Option 键后看到录音条,但开口的前几个字被吞掉。 根因:begin_session() 在 line 1128 立刻 emit_capsule(Recording),但 Recorder::start 还要再走 ~57 行(read creds / new ASR / new bridge / acquire mute / cpal init),其中 cpal init 在 macOS 上有 50-200ms 启动 延迟。窗口期内: - 视觉:录音条已显示 - 物理:mic 还没产生第一帧 PCM - 用户开口的前几个字物理上无法被 capture(不是丢字,是没录到) DeferredAsrBridge 已经能覆盖 "recorder 已开 → ASR 没连上" 的间隙(缓存 PCM 等 ASR attach 后按顺序后送),但 "录音条已显 → recorder 没开" 这段 之前没人覆盖。 修复(surgical):把 emit_capsule(Recording) 从 begin_session() 挪到 start_recorder_for_starting() 内部 Recorder::start 成功的 Ok 分支里。 三条 ASR 路径(Local Qwen3 / Whisper / Volcengine)共用同一函数,自动 受益。代价是用户按热键到看到录音条多 50-200ms 延迟,但「录音条出现 = mic 100% 在 capture」体感诚实,开头不再丢。 ASR 慢连接的兜底逻辑(DeferredAsrBridge 按顺序缓冲 + attach 后 drain) 保持不变。 --- openless-all/app/src-tauri/src/coordinator.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 549ac465..32437fe4 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1125,8 +1125,10 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { return Err(message); } - emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); - + // 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在 + // Recorder::start 成功后再发,确保「用户看到录音条」时 mic 已经在 capture。 + // 之前在这一行就 emit 会让用户看到录音条后立刻开口,但 mic 还在 cpal init + // 窗口(50-200ms)内 → 开头几个字物理上录不到。详见 issue 备注。 let active_asr = CredentialsVault::get_active_asr(); #[cfg(target_os = "macos")] @@ -1301,6 +1303,11 @@ fn start_recorder_for_starting( spawn_recorder_error_monitor(inner, runtime_errors); stop_recorder_if_pending_start_stop(inner); log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)"); + // ★ 关键:录音器实际启动后再发 Recording capsule,避免用户「看到录音条但 + // mic 还没开」的 50-200ms 窗口里开口讲话被吞。begin_session 把这一行 + // 挪到这里以后,三条 ASR 路径(Local / Whisper / Volcengine)共享。 + // ASR 连接慢的间隙仍由 DeferredAsrBridge 缓存 PCM,按顺序后送,不丢字。 + emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); } Err(e) => { log::error!("[coord] recorder start failed: {e}"); From 5c5b5758b61ceb820313371fbc1d38c3f47c099e Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 6 May 2026 19:13:50 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(recording):=20emit=20Recording=20?= =?UTF-8?q?=E5=8A=A0=20race=20=E6=A3=80=E6=9F=A5=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E8=A6=86=E7=9B=96=20stop/cancel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 (PR #289):上一版我把 emit_capsule(Recording) 放到 stop_recorder_if_pending_start_stop 之后,且无条件 emit。这会让短按热键 (用户在 Recorder::start 仍在 cpal init 期间松开)的场景出现 UI 闪烁: stop / cancel 路径已经发出 Transcribing / Cancelled,本行又把 UI 覆盖 回 Recording,并在 Volcengine 路径的 open_session().await 期间持续保持。 修复(按 Codex 建议): 1. emit 挪到 stop_recorder_if_pending_start_stop 之前 2. emit 之前在同一 lock 内检查 phase / pending_stop / cancelled / session_id; 只有 recorder 真的会继续运行时才 emit 3. 不满足条件就让 stop / cancel 路径接管 UI 信号 不改变首次说话不丢字的修复目标(emit 仍在 Recorder::start 成功后), 只让 race 路径下的 UI 一致。 --- openless-all/app/src-tauri/src/coordinator.rs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 32437fe4..d008f3c4 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1301,13 +1301,28 @@ fn start_recorder_for_starting( Ok((rec, runtime_errors)) => { store_recorder_for_session(inner, session_id, rec); spawn_recorder_error_monitor(inner, runtime_errors); + // ★ 录音器实际启动后再发 Recording capsule —— 避免用户「看到录音条但 + // mic 还没开」的 50-200ms 窗口里开口讲话被吞(三条 ASR 路径共享)。 + // ASR 连接慢的间隙由 DeferredAsrBridge 缓存 PCM,按顺序后送,不丢字。 + // + // 竞态保护:必须在 stop_recorder_if_pending_start_stop 之前 emit, + // 并且仅当 recorder 真的会继续运行(phase 仍是 Starting、无待处理的 + // stop / cancel)时才 emit。否则用户在 cpal init 期间松开热键时, + // stop / cancel 路径可能已经发出 Transcribing / Cancelled,本行 + // 再无条件覆盖回 Recording 会让 UI 短暂闪烁错误状态(短按尤其明显)。 + // Codex review (PR #289 P2) 指出。 + let should_emit_recording = { + let state = inner.state.lock(); + state.session_id == session_id + && state.phase == SessionPhase::Starting + && !state.pending_stop + && !state.cancelled + }; + if should_emit_recording { + emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); + } stop_recorder_if_pending_start_stop(inner); log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)"); - // ★ 关键:录音器实际启动后再发 Recording capsule,避免用户「看到录音条但 - // mic 还没开」的 50-200ms 窗口里开口讲话被吞。begin_session 把这一行 - // 挪到这里以后,三条 ASR 路径(Local / Whisper / Volcengine)共享。 - // ASR 连接慢的间隙仍由 DeferredAsrBridge 缓存 PCM,按顺序后送,不丢字。 - emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); } Err(e) => { log::error!("[coord] recorder start failed: {e}");