fix(dictation): 转写中按热键改为静默丢弃,移除排队接力(#856) (#891)

- 删除「排队接力」grace 窗口(HOTKEY_QUEUE_GRACE_MS / is_queued_chain_press),
  handle_pressed 的 Toggle/Auto 冷却检查恢复为纯 #545 拦截:识别中按下热键的
  Pressed 在会话收尾后一律静默丢弃,不再放行开录下一条
- fail_dictation / 空转写收尾回 Idle 时补设冷却,失败/空结果场景排队按下同样丢弃
- 更新 session_cooldown_until 契约注释;新增回归测试 toggle_press_within_cooldown_is_dropped
This commit is contained in:
Chris233 authored and GitHub committed 2026-08-03 20:29:08 +08:00
1 parent f5fc5fd0c1
commit 9bfec8aedd
2 files changed
+61 -84

No files matched your search

+21 -3
View File
@@ -500,9 +500,9 @@ struct Inner {
/// 判定短按(Toggle 锁存)还是长按(Hold 松手即停)。见 dictation.rs 的
/// AUTO_HOLD_THRESHOLD。
hotkey_press_at: Mutex<Option<std::time::Instant>>,
/// end_session 成功收尾后将 phase 设为 Idle 时记录的时间戳 + POST_SESSION_COOLDOWN_MS。
/// handle_pressed 在 (Toggle, Idle) 分支检查此字段:未过期则忽略该次按键,
/// 防止胶囊离场动画期间误激活新听写(issue #545)。
/// 会话收尾(成功 / 取消 / 失败)将 phase 设为 Idle 时记录的时间戳 + POST_SESSION_COOLDOWN_MS。
/// handle_pressed 在 (Toggle, Idle) 分支检查此字段:未过期则忽略该次按键,防止胶囊离场
/// 动画期间误激活新听写(issue #545);也让识别中排队的热键按下在收尾后一律静默丢弃(issue #856)。
session_cooldown_until: Mutex<Option<std::time::Instant>>,
shortcut_recording_active: AtomicBool,
/// Less Computer modifier 热键的按下代次与待处理组合键事件。
@@ -3673,6 +3673,24 @@ mod tests {
assert_eq!(state.session_id, session_id(41));
}
// #856:识别中按下热键想录下一条的 Pressed 会在会话收尾后被串行 bridge 取出(落在
// 冷却期内)—— 现在一律静默丢弃,不再像「排队接力」那样放行开录下一条(无反馈排队 +
// 延迟开录的惊吓成本大于省下的等待时间;Esc 取消后也不会因此再弹出一条新录音)。
#[tokio::test]
async fn toggle_press_within_cooldown_is_dropped() {
let coordinator = Coordinator::new();
// Idle + 冷却未过期:模拟「识别中按下 → 会话收尾 → bridge 取出该 Pressed」的时刻。
*coordinator.inner.session_cooldown_until.lock() = Some(
std::time::Instant::now()
+ std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS),
);
handle_pressed_edge(&coordinator.inner, std::time::Instant::now(), 1).await;
// 静默丢弃:没有开录下一条(phase 仍是 Idle)。
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
}
#[tokio::test]
async fn repeated_pressed_edge_during_hold_session_does_not_restart() {
let coordinator = Coordinator::new();
@@ -775,26 +775,6 @@ pub(super) async fn handle_pressed_edge(
}
}
/// 「排队接力」放行窗口(ms)。识别中按下热键想录下一条时,那个 Pressed 在处理期间就被缓进
/// hotkey channel,bridge 串行阻塞到本条会话收尾(Idle)才取出 —— 取出几乎在 Idle 后 0ms
/// (bridge 立即 recv)。它和 #545「会话结束后胶囊离场动画期间误触」的区别在于物理按下时刻:
/// 误触是会话结束后才按,处理时刻离 Idle 已隔人类反应时间(>150ms)。所以冷却期内、但距 Idle
/// < 该窗口的按下 = 排队接力,放行开录下一条;其余冷却期按下仍按 #545 拦截。120ms 远低于
/// 人类反应、又足够覆盖 end_session 收尾尾巴 + bridge recv 延迟。
const HOTKEY_QUEUE_GRACE_MS: u64 = 120;
/// 判断一次落在冷却期内的 Toggle 激活是否为「排队接力」按下(见 HOTKEY_QUEUE_GRACE_MS)。
/// since_idle = POST_SESSION_COOLDOWN_MS − 剩余冷却;落在 grace 窗口内即认为是处理期间缓进、
/// 收尾后立刻取出的接力按下。剩余冷却异常(> 全程,理论不会发生)时返回 false,从严不放行。
fn is_queued_chain_press(now: std::time::Instant, cooldown_until: std::time::Instant) -> bool {
let cooldown = std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS);
let remaining = cooldown_until.saturating_duration_since(now);
cooldown
.checked_sub(remaining)
.map(|since_idle| since_idle < std::time::Duration::from_millis(HOTKEY_QUEUE_GRACE_MS))
.unwrap_or(false)
}
pub(super) async fn handle_pressed(
inner: &Arc<Inner>,
pressed_at: std::time::Instant,
@@ -805,25 +785,21 @@ pub(super) async fn handle_pressed(
log::info!("[coord] hotkey pressed (mode={mode:?}, phase={phase:?})");
match (mode, phase) {
(HotkeyMode::Toggle, SessionPhase::Idle) => {
// 冷却检查:end_session 刚收尾时禁止短时间内再次激活,避免三连按第 3 次误触
// (此时胶囊仍在离场动画周期内,issue #545)。例外「排队接力」:识别中按下想录
// 下一条的 Pressed 被缓在 channel 里、会话收尾后立刻取出(距 Idle < grace),放行
// 直接开录下一条(用户选的「安全版排队接力」)。
// 冷却检查:end_session / 取消收尾后禁止短时间内再次激活,避免三连按第 3 次误触
// (此时胶囊仍在离场动画周期内,issue #545)。识别中按下想录下一条的 Pressed 会被
// 缓在 hotkey channel 里、会话收尾后(距 Idle 落在冷却期内)才取出 —— 一律静默
// 丢弃,不再放行开录(issue #856:无反馈排队 + 延迟开录的惊吓成本大于收益)。
let now = std::time::Instant::now();
let cooldown_until = *inner.session_cooldown_until.lock();
if let Some(deadline) = cooldown_until {
if now < deadline {
if is_queued_chain_press(now, deadline) {
log::info!(
"[coord] queued-chain activation: 识别中按下,会话收尾后接力开录下一条"
);
} else {
log::info!(
"[coord] toggle activation blocked by cooldown (session still winding down)"
);
return;
}
}
let on_cooldown = inner
.session_cooldown_until
.lock()
.map(|deadline| now < deadline)
.unwrap_or(false);
if on_cooldown {
log::info!(
"[coord] toggle activation blocked by cooldown (session still winding down)"
);
return;
}
begin_session_from_press(inner, press_id).await;
}
@@ -841,22 +817,18 @@ pub(super) async fn handle_pressed(
// Auto 模式:按下即开录(与 Hold 一样不丢首字)。是短按还是长按要到松手时才知道,
// 所以这里只负责「开始」并记下按下时刻,语义交给 handle_released 判定。
(HotkeyMode::Auto, SessionPhase::Idle) => {
// 复用 Toggle 的冷却 / 排队接力检查:#545 离场动画期间误触保护。
// 复用 Toggle 的冷却检查:#545 离场动画期间误触保护;识别中排队的按下同样丢弃(#856)。
let now = std::time::Instant::now();
let cooldown_until = *inner.session_cooldown_until.lock();
if let Some(deadline) = cooldown_until {
if now < deadline {
if is_queued_chain_press(now, deadline) {
log::info!(
"[coord] queued-chain activation (auto): 识别中按下,会话收尾后接力开录下一条"
);
} else {
log::info!(
"[coord] auto activation blocked by cooldown (session still winding down)"
);
return;
}
}
let on_cooldown = inner
.session_cooldown_until
.lock()
.map(|deadline| now < deadline)
.unwrap_or(false);
if on_cooldown {
log::info!(
"[coord] auto activation blocked by cooldown (session still winding down)"
);
return;
}
*inner.hotkey_press_at.lock() = Some(pressed_at);
begin_session_from_press(inner, press_id).await;
@@ -932,7 +904,7 @@ async fn begin_session_from_press(inner: &Arc<Inner>, press_id: u64) {
///
/// 只对 modifier-only 触发键等待 —— 自定义组合键(Cmd+Shift+D 之类)本身就没有歧义,
/// 让它白等这一下纯粹是掉延迟。等待放在防抖 / 冷却判定之后,那些判定用的仍是未被本
/// 窗口推迟的时刻(尤其别把「排队接力」窗口挤掉,见 is_queued_chain_press)。
/// 窗口推迟的时刻。
async fn press_resolves_to_combo(inner: &Arc<Inner>, press_id: u64) -> bool {
let binding = inner.prefs.get().dictation_hotkey;
if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_none() {
@@ -2601,6 +2573,14 @@ fn fail_dictation(
);
restore_prepared_windows_ime_session(inner, session_id);
inner.state.lock().phase = SessionPhase::Idle;
// 与成功 / 取消收尾一致:回 Idle 即设冷却,把识别中缓存在 hotkey channel 里的 Pressed
// 一并静默丢弃(issue #856)——否则失败收尾后那条排队按下会立刻开出一条新录音,用户以为
// 「全部停下了」却再次弹出胶囊;同时覆盖错误胶囊离场动画期间的误触(issue #545)。
{
let now = std::time::Instant::now();
*inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
}
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
Err(err)
}
@@ -3368,6 +3348,12 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
// 与成功 / 取消 / 失败收尾一致:回 Idle 即设冷却,识别中排队的热键按下同样丢弃(#856)。
{
let now = std::time::Instant::now();
*inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
}
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err("ASR returned empty transcript".to_string());
}
@@ -4247,33 +4233,6 @@ mod tests {
assert_eq!(pcm_duration_ms(0), 0);
}
#[test]
fn queued_chain_press_allowed_within_grace_blocked_after() {
use super::{is_queued_chain_press, HOTKEY_QUEUE_GRACE_MS};
use std::time::{Duration, Instant};
let cooldown_ms = crate::coordinator::POST_SESSION_COOLDOWN_MS;
// 模拟 end_session 收尾:cooldown_until = idle + POST_SESSION_COOLDOWN_MS。
let idle = Instant::now();
let cooldown_until = idle + Duration::from_millis(cooldown_ms);
// 排队接力:识别中按下、会话收尾后 ~0ms 被取出处理 → 放行开录下一条。
assert!(is_queued_chain_press(idle, cooldown_until));
// grace 窗口边缘内 → 仍放行。
assert!(is_queued_chain_press(
idle + Duration::from_millis(HOTKEY_QUEUE_GRACE_MS - 1),
cooldown_until
));
// #545 误触:会话结束后隔了人类反应时间才物理按下 → 仍在冷却期但超出 grace → 拦截。
assert!(!is_queued_chain_press(
idle + Duration::from_millis(300),
cooldown_until
));
// 冷却已过:函数对超期返回 false(此时调用方本就放行,从严也安全)。
assert!(!is_queued_chain_press(
idle + Duration::from_millis(cooldown_ms + 10),
cooldown_until
));
}
#[test]
fn polish_context_resets_when_active_style_pack_changes() {
let sessions = vec![