fix(hotkey): 修饰键热键被当组合键用时不再误唤起听写(150ms 仲裁窗口 + 事后撤销兜底)#858
Conversation
PR Reviewer Guide 🔍(Review updated until commit 195d9bc)Here are some key observations to aid the review process:
|
modifier-only 触发键(如 Option)按住期间只要按下普通键,就说明用户在打 Option+任意字母/数字键这类组合键、不是想说话。此前 tap/hook 只看修饰键自身的边 沿:按下即 Pressed → 开录音,Auto 模式下快速松手还会被判成「短按锁存」,录音一直 开着停不下来。 - hotkey.rs:macOS CGEventTap / Windows WH_KEYBOARD_LL 在触发键按住期间收到普通 键 KEY_DOWN 时发一次新的 TriggerCombined 边沿(每次按住只发一次;修饰键叠加不 算,Shift 翻译修饰键行为不变),之后仍照常发 Released。 - coordinator:TriggerCombined 清掉按住态(随后的 Released 被 was_held 吞掉,不 再走 Hold 松手 / Auto 短按锁存),并只取消「这一次按下开出来的」会话 —— toggle 停止 / 被冷却拦下 / 路由给 QA 的按下什么都不动,绝不误杀正在转写的上一 条。同时清掉冷却与防抖时间戳,紧接着那次真想说话的按下不会被静默吞掉。 - Less Computer 的 modifier 触发键走同一条撤销路径(顺带熄灭整屏描边)。
上一提交是「事后撤销」:Option+任意字母/数字键仍会真的开麦、弹胶囊、烧一次 ASR 建连,然后被取消——功能对了,观感上闪一下。 改为按下后先等 150ms 再开会话,期间监听器若已报告叠加了普通键,这次按下整条作废: 麦克风不开、胶囊不弹。等待只加在 modifier-only 触发键的「开录」分支上: - 自定义组合键(Cmd+Shift+D)本身没有歧义,不等; - toggle 停止 / QA 路由 / 防抖与冷却判定都在窗口之前,时序不变(尤其不挤占 is_queued_chain_press 的 120ms 排队接力窗口); - 窗口没盖住的慢速组合键(按住 Option 半秒再按 Tab)仍由 TriggerCombined 事后撤销 兜底,两条路径互补。 150ms 覆盖绝大多数组合键的「修饰键→普通键」间隔,又低于人从按键到开口的反应时间, 不吃首字。
ed5f5da to
9fa9f63
Compare
|
Persistent review updated to latest commit 9fa9f63 |
…ince_press Android CI(cargo check --target android)红:`mobile_stubs/hotkey.rs` 是移动端的 HotkeyEvent / HotkeyMonitor 替身,桌面端新增的枚举变体与方法必须同步补上,否则 coordinator 里那两条 match 臂和仲裁窗口的调用在移动端找不到符号。 移动端没有键盘监听器,`trigger_combined_since_press` 恒为 false(仲裁窗口等价于 直接放行)。
|
Persistent review updated to latest commit 195d9bc |
|
关于 PR-Agent 提的 Potential False Negative on Windows(合成按键可能误撤销听写)——这层过滤已经存在,就在改动的正上游: https://github.com/Open-Less/openless/blob/beta/openless-all/app/src-tauri/src/hotkey.rs#L1018-L1026 let keyboard = *(lparam.0 as *const KBDLLHOOKSTRUCT);
if keyboard.flags.0 & LLKHF_INJECTED == 0 || accept_injected_events() {
if dispatch_keyboard_event(ctx, keyboard.vkCode, wparam.0) { ... }
}带 不过顺着这条思路,有个真实的平台差异值得记下来:macOS 侧的 CGEventTap 没有对应的 injected 过滤。理论上第三方自动化工具(Keyboard Maestro 之类)在用户按住触发键期间合成按键,会撤销一次听写。实践中够不着——OpenLess 自己的文字插入不会与「触发键按住」重叠(Hold 模式插入发生在松手之后;Auto/Toggle 锁存态下触发键并没有按住)。如果维护者认为值得补齐对称性,可以在 mac tap 的 另外两条 focus area 我的看法:
|
User description
问题
把听写热键设成 modifier-only 触发键(Option / 右 Ctrl 等)后,只要用到含该修饰键的组合键就会误唤起听写:按
Option+任意字母/数字键、Option+Tab、Option+方向键,OpenLess 都以为你要说话。三种录音方式症状不同,但根因是同一个:
Option+任意字母/数字键实际发生什么emptyTranscript记录AUTO_HOLD_THRESHOLD(350ms) 被判成短按 → 锁存,录音卡着不停根因
hotkey.rs的 macOS CGEventTap / Windows low-level hook 对 modifier-only 触发键只看修饰键自身的边沿:FLAGS_CHANGED 里 keycode 匹配 + 标志位置起 → 发Pressed,标志位清零 → 发Released。普通键的 KEY_DOWN 分支此前只认 Esc,其余一律忽略 —— 「这次按住中间夹了哪些普通键」这个信息在最底层就被丢掉了,上层永远只看到一次干净的按下/抬起。改动:两条互补路径
1. 150ms 组合键仲裁窗口(事前)
modifier-only 触发键按下后先等
COMBO_ARBITRATION_GRACE(150ms) 再开会话;这期间监听器若已报告叠加了普通键,这次按下整条作废 —— 麦克风不开、胶囊不弹、不建 ASR 连接。等待只加在「开录」分支上,其它时序一律不变:
Cmd+Shift+D之类)本身没有歧义,不等;is_queued_chain_press的 120ms 排队接力窗口。2.
TriggerCombined事后撤销(兜底)窗口没盖住的慢速组合键(按住 Option 半秒再按 Tab):tap/hook 在触发键按住期间收到普通键 KEY_DOWN 时发一次新的
HotkeyEvent::TriggerCombined(每次按住只发一次),coordinator 收到后:Released被handle_released_edge的was_held检查吞掉,不会再走 Hold 松手结束 / Auto 短按锁存;hotkey_press_began_session标志)。如果这次按下其实是 toggle 停止 / 被冷却拦下 / 路由给了 QA,就什么都不动 —— 绝不误杀正在转写的上一条;修饰键叠加不算「其他键」(macOS 的修饰键走 FLAGS_CHANGED、不进 KEY_DOWN;Windows 侧显式排除修饰键 VK),所以 Shift 翻译模式、
Cmd+Option前缀都不受影响。Less Computer 的 modifier 触发键走同一条撤销路径(顺带熄灭整屏描边)。
实测(macOS 26.5,Auto 模式 + 左 Option)
装机跑了一轮真实使用:
触发键在 150ms 仲裁窗口内叠加了其他键 —— 本次按下作废,不开录音取消本次按下开出的会话→session cancelled (was Listening)session started并完成润色插入auto short-tap latched→ 下一次按下停止session cancelled (was Processing)+cancel detected before insert — discarding outputtranslation modifier seen during Listening,会话未被误取消其中一次特别值得说:某次按下发生在 bridge 被润色 LLM 堵住时,事件在队列里排了 2 秒才被取出 —— 仲裁窗口读到的仍是属于它自己那次按下的标志,正确作废。
代价:按下 → 麦克风起录从 ~70ms 变成 ~210–240ms(150ms 窗口 + 原有 60–85ms 初始化)。实测 10 条录音没有吃首字(人从按键到开口通常 > 250ms)。窗口时长是一个常量,觉得肉可以调小。
已知不覆盖
Option+点击(Figma 复制、Finder 拖拽)、Option+滚轮仍会误触发 —— tap 没监听鼠标事件。加进来容易,但代价是 Hold 模式按住说话时点一下光标位置就会误取消,故意没加。Option+Shift、Option+Cmd单按不会撤销(要等一个普通键跟上),否则 Shift 翻译模式就废了。Option+X:那一下仍会「停止并转写插入」(toggle 语义的停止在窗口之前就发生了)。刻意不撤销 —— 撤销等于cancel_session,会丢掉用户已经说的那段。测试
cargo test799 passed):mac_companion_key_down_aborts_trigger_once_per_hold—— 每次按住只撤销一次、且必须真的按住了触发键windows_companion_key_down_aborts_trigger_but_modifiers_do_not—— 普通键撤销、修饰键叠加不撤销trigger_combined_cancels_session_started_by_this_press—— 撤销后松手边沿不再被当成 Auto 短按锁存,且不留冷却trigger_combined_leaves_session_it_did_not_start—— toggle 停止那一下不误杀正在跑的会话modifier_only_press_waits_out_the_arbitration_window/custom_combo_press_skips_the_arbitration_window—— 仲裁窗口只对 modifier-only 触发键生效PR Type
Bug fix, Enhancement
Description
修复修饰键热键(如 Option)被误当作组合键时触发听写的问题
新增 150ms 组合键仲裁窗口,按下后先等待再开会话,避免误触
事后撤销:组合键检测到后取消本次按下的会话,并清空相关状态
macOS 与 Windows 热键监听层统一实现 TriggerCombined 事件
Diagram Walkthrough
flowchart LR A["热键按下"] --> B{"仲裁窗口 150ms"} B -->|未检测到组合键| C["开始听写会话"] B -->|检测到组合键| D["发送 TriggerCombined"] D --> E["取消本次按下的会话"] E --> F["清空按住态、冷却与防抖时间戳"] C --> G["正常录音"]File Walkthrough
coordinator.rs
增加会话标记与撤销函数openless-all/app/src-tauri/src/coordinator.rs
hotkey_press_began_session标志,标记本次按下是否真正开了会话handle_trigger_combined函数,撤销触发键被组合键时的误激活hotkey_loops.rs
桥接循环处理 TriggerCombined 事件openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
hotkey_bridge_loop中处理HotkeyEvent::TriggerCombinedless_computer_modifier_bridge_loop中增加cancel_less_computer_press处理dictation.rs
仲裁窗口与组合键检测逻辑openless-all/app/src-tauri/src/coordinator/dictation.rs
COMBO_ARBITRATION_GRACE(150ms 仲裁窗口)常数handle_pressed为begin_session_from_press,集成仲裁窗口检测press_resolves_to_combo检测 modifier-only 触发键下的组合键handle_trigger_combined清理状态并取消会话hotkey.rs
热键事件层扩展:TriggerCombined 与组合键检测openless-all/app/src-tauri/src/hotkey.rs
HotkeyEvent::TriggerCombined事件变体trigger_companion_seenlatch 保证同次按住只发一次handle_key_down中检测普通键按下并发出 TriggerCombinedhandle_keyboard_hook中检测非修饰键按下并发出 TriggerCombinedtrigger_combined_since_press接口供仲裁窗口查询hotkey.rs
移动端 stub 更新 HotkeyEventopenless-all/app/src-tauri/src/mobile_stubs/hotkey.rs
TriggerCombined变体(移动端无操作实现)trigger_combined_since_press返回 false(无键盘监听器)