Skip to content

fix(hotkey): 修饰键热键被当组合键用时不再误唤起听写(150ms 仲裁窗口 + 事后撤销兜底)#858

Open
bigsongeth wants to merge 3 commits into
Open-Less:betafrom
bigsongeth:fix/modifier-trigger-combo-abort
Open

fix(hotkey): 修饰键热键被当组合键用时不再误唤起听写(150ms 仲裁窗口 + 事后撤销兜底)#858
bigsongeth wants to merge 3 commits into
Open-Less:betafrom
bigsongeth:fix/modifier-trigger-combo-abort

Conversation

@bigsongeth

@bigsongeth bigsongeth commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

User description

问题

把听写热键设成 modifier-only 触发键(Option / 右 Ctrl 等)后,只要用到含该修饰键的组合键就会误唤起听写:按 Option+任意字母/数字键Option+TabOption+方向键,OpenLess 都以为你要说话。

三种录音方式症状不同,但根因是同一个:

模式 Option+任意字母/数字键 实际发生什么
Hold 按住说话 开录几十毫秒 → 松手结束 → 真的发一次 ASR 请求 → 空转写 → 胶囊红字「没有识别到语音」,历史里多一条 emptyTranscript 记录
Toggle 点按 开录后一直录不停(toggle 松手不做事),直到下次按 Option 才停 —— 而那下多半又是个组合键,于是把这期间的环境音转写插到光标处
Auto 同 Toggle:松手时按住时长 < 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 之类)本身没有歧义,不等;
  • toggle 停止 / QA 路由 / 防抖与冷却判定都在窗口之前 —— 尤其不挤占 is_queued_chain_press 的 120ms 排队接力窗口。

2. TriggerCombined 事后撤销(兜底)

窗口没盖住的慢速组合键(按住 Option 半秒再按 Tab):tap/hook 在触发键按住期间收到普通键 KEY_DOWN 时发一次新的 HotkeyEvent::TriggerCombined(每次按住只发一次),coordinator 收到后:

  • 清掉按住态 —— 随后必然到来的 Releasedhandle_released_edgewas_held 检查吞掉,不会再走 Hold 松手结束 / Auto 短按锁存;
  • 只取消「这一次按下开出来的」会话hotkey_press_began_session 标志)。如果这次按下其实是 toggle 停止 / 被冷却拦下 / 路由给了 QA,就什么都不动 —— 绝不误杀正在转写的上一条;
  • 清掉冷却与防抖时间戳:组合键误触不算「刚用完一次听写」,否则紧接着那次真想说话的按下会被 [ui] 听写快速连按应在上一轮完全结束前忽略激活(动画已缓解,逻辑未改) #545 冷却 / 250ms 防抖静默吞掉。

修饰键叠加不算「其他键」(macOS 的修饰键走 FLAGS_CHANGED、不进 KEY_DOWN;Windows 侧显式排除修饰键 VK),所以 Shift 翻译模式、Cmd+Option 前缀都不受影响。

Less Computer 的 modifier 触发键走同一条撤销路径(顺带熄灭整屏描边)。

实测(macOS 26.5,Auto 模式 + 左 Option)

装机跑了一轮真实使用:

场景 日志证据 结果
快速组合键 5 次 触发键在 150ms 仲裁窗口内叠加了其他键 —— 本次按下作废,不开录音 麦克风没开、胶囊没弹、没建 ASR 连接
慢速组合键 1 次:按下 697ms 后才按普通键(远超窗口)→ 取消本次按下开出的会话session cancelled (was Listening) 兜底生效
正常听写 10 次 session started 并完成润色插入 无回归
短按锁存 / 再按停止 auto short-tap latched → 下一次按下停止 照旧
Esc 取消(含 Processing 期间) session cancelled (was Processing) + cancel detected before insert — discarding output 照旧
Shift 翻译修饰键叠加 translation modifier seen during Listening,会话未被误取消 照旧
防抖丢弃 / 冷却拦截 / 空转写 各 0 次 无副作用

其中一次特别值得说:某次按下发生在 bridge 被润色 LLM 堵住时,事件在队列里排了 2 秒才被取出 —— 仲裁窗口读到的仍是属于它自己那次按下的标志,正确作废。

代价:按下 → 麦克风起录从 ~70ms 变成 ~210–240ms(150ms 窗口 + 原有 60–85ms 初始化)。实测 10 条录音没有吃首字(人从按键到开口通常 > 250ms)。窗口时长是一个常量,觉得肉可以调小。

已知不覆盖

  • 鼠标组合不算Option+点击(Figma 复制、Finder 拖拽)、Option+滚轮 仍会误触发 —— tap 没监听鼠标事件。加进来容易,但代价是 Hold 模式按住说话时点一下光标位置就会误取消,故意没加。
  • 纯修饰键组合不算Option+ShiftOption+Cmd 单按不会撤销(要等一个普通键跟上),否则 Shift 翻译模式就废了。
  • 已锁存录音时按 Option+X:那一下仍会「停止并转写插入」(toggle 语义的停止在窗口之前就发生了)。刻意不撤销 —— 撤销等于 cancel_session,会丢掉用户已经说的那段。
  • Linux(fcitx5)不覆盖:热键事件来自插件的 DBus 信号,不走这个 tap;既没修复也没回归。
  • 目前默认开启、无开关

测试

  • 新增单测 5 个(cargo test 799 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["正常录音"]
Loading

File Walkthrough

Relevant files
Bug fix
coordinator.rs
增加会话标记与撤销函数                                                                                           

openless-all/app/src-tauri/src/coordinator.rs

  • 新增 hotkey_press_began_session 标志,标记本次按下是否真正开了会话
  • 引入 handle_trigger_combined 函数,撤销触发键被组合键时的误激活
  • 添加单元测试验证组合键撤销逻辑(不误杀已存在的会话)
+67/-1   
hotkey_loops.rs
桥接循环处理 TriggerCombined 事件                                                               

openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs

  • hotkey_bridge_loop 中处理 HotkeyEvent::TriggerCombined
  • less_computer_modifier_bridge_loop 中增加 cancel_less_computer_press 处理
+22/-0   
Enhancement
dictation.rs
仲裁窗口与组合键检测逻辑                                                                                         

openless-all/app/src-tauri/src/coordinator/dictation.rs

  • 定义 COMBO_ARBITRATION_GRACE(150ms 仲裁窗口)常数
  • 重构 handle_pressedbegin_session_from_press,集成仲裁窗口检测
  • 实现 press_resolves_to_combo 检测 modifier-only 触发键下的组合键
  • handle_trigger_combined 清理状态并取消会话
  • 增加单元测试:仲裁窗口等待、自定义快捷键跳过窗口
+132/-3 
hotkey.rs
热键事件层扩展:TriggerCombined 与组合键检测                                                     

openless-all/app/src-tauri/src/hotkey.rs

  • 新增 HotkeyEvent::TriggerCombined 事件变体
  • 新增 trigger_companion_seen latch 保证同次按住只发一次
  • macOS 端:在 handle_key_down 中检测普通键按下并发出 TriggerCombined
  • Windows 端:在 handle_keyboard_hook 中检测非修饰键按下并发出 TriggerCombined
  • 增加 trigger_combined_since_press 接口供仲裁窗口查询
  • 增加平台相关的单元测试
+148/-0 
Miscellaneous
hotkey.rs
移动端 stub 更新 HotkeyEvent                                                                   

openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs

  • 添加 TriggerCombined 变体(移动端无操作实现)
  • 实现 trigger_combined_since_press 返回 false(无键盘监听器)
+7/-0     

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 195d9bc)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

545 - Partially compliant

Compliant requirements:

Non-compliant requirements:

  • 三连按语义(Toggle):第1激活;第2关闭;第3次若上一轮尚未完全结束 → 忽略(无 begin_session、无新的听写会话),与是否播放收起动画无关。
  • 完全结束后才允许再激活:胶囊 UI 与 coordinator 均进入维护者定义的「可接受下一次激活」边界后,下一次按键才计为新激活。
  • 与动画修复不冲突:保留现有「避免反复收起/弹出」的 UI 行为;在此基础上增加 coordinator 层(或前后端统一的 busy gate)忽略无效激活。
  • 关闭/取消路径可用:第2次关闭、Hold 松开、Esc 取消等正常结束路径不受影响。
  • 回归:单次 Toggle、Starting pending_stop(fix: Starting 阶段忽略热键边沿 — hold 快速松开 / toggle 快速双击会卡死会话 #51)、Hold 模式不受影响。

Requires further human verification:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Possible Race Condition

In press_resolves_to_combo, after sleeping for COMBO_ARBITRATION_GRACE (150ms), it checks hotkey.lock().as_ref().is_some_and(|monitor| monitor.trigger_combined_since_press()). However, the HotkeyMonitor is behind a Mutex that can be contended if other tasks (e.g., hotkey event dispatch) also lock it during the sleep. More importantly, the press event that triggered begin_session_from_press is already consumed by the time the arbitration window fires: the hotkey listener thread might have already processed subsequent events (including the companion key down) and updated trigger_companion_seen before this async task resumes after the sleep. If the listener runs on a different thread, there is no guarantee that the flag will be read after the companion key down event has been delivered. The arbitration window relies on the fact that the 150ms sleep will allow any companion key down to occur and set the flag before the check runs. In practice this is likely true because the user cannot press another key within 1-2ms after pressing the trigger, but the comment "窗口没盖住的慢速组合键…由 TriggerCombined 事后撤销兜底" suggests the arbitration is only best-effort. However, there is no explicit synchronization between the async check and the hotkey listener: the listener sets trigger_companion_seen atomically and sends a TriggerCombined event which is then processed by hotkey_bridge_loop -> handle_trigger_combined. But the arbitration window's check (trigger_combined_since_press) returns the flag directly, which could be out of sync with whether TriggerCombined has already been enqueued. If the TriggerCombined event is processed before the arbitration window check, the flag may be reset to false (if note_companion_key_down was already called for this hold, then subsequent calls are no-ops due to swap(true) and the flag remains true). Actually, looking at the code: trigger_companion_seen is only set to true when the first companion key down is detected, and it is never explicitly reset to false until the next Pressed event sets it to false. So if the arbitration window check reads true, it correctly identifies combo. The real risk is: if a companion key down happens exactly between the sleep returning and the is_some_and closure executing, then that key down event will not be captured because the flag is already true but the event might not yet be processed? Actually, the flag is set atomically inside note_companion_key_down, which is called from the hotkey listener thread. The arbitration window runs on an async task on the main runtime. There is no memory ordering issue because both use SeqCst. However, the timing: after sleep(150ms), the code immediately acquires the hotkey lock and reads trigger_companion_seen. If a companion key down occurred during the sleep, the flag will be true because the listener thread sets it before the sleep expires. The only concern is if the companion key down occurs after the check: then the arbitration window would have already returned false, and begin_session_from_press goes ahead and opens a session. But then the TriggerCombined event will be sent later (when the companion key down is processed) and handle_trigger_combined will cancel that session. So the worst-case is a brief false activation that gets cancelled almost immediately. This is acceptable and matches the PR's design. Therefore, this is not a real bug, but the code comment about "慢速组合键由 TriggerCombined 事后撤销兜底" correctly describes the fallback. Not flagging as bug.

async fn press_resolves_to_combo(inner: &Arc<Inner>) -> bool {
    let binding = inner.prefs.get().dictation_hotkey;
    if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_none() {
        return false;
    }
    tokio::time::sleep(COMBO_ARBITRATION_GRACE).await;
    let combined = inner
        .hotkey
        .lock()
        .as_ref()
        .is_some_and(|monitor| monitor.trigger_combined_since_press());
    if combined {
        log::info!(
            "[coord] 触发键在 {}ms 仲裁窗口内叠加了其他键 —— 本次按下作废,不开录音",
            COMBO_ARBITRATION_GRACE.as_millis()
        );
    }
    combined
}
Potential False Negative on Windows

On Windows, note_companion_key_down is called from dispatch_keyboard_event for every non-modifier key down message (WM_KEYDOWN/WM_SYSKEYDOWN). However, the Windows low-level hook might also receive synthetic key events (e.g., from On-Screen Keyboard, AutoHotkey, or games). These could trigger a TriggerCombined event even though the user never intended to type alongside the hotkey. This could cause a false revocation of a legitimate dictation session. The PR does not filter out synthetic events. While this might be a low-probability scenario, it could cause user frustration if dictation is randomly cancelled. Consider checking the event flags for LLKHF_INJECTED or similar to ignore injected events.

if pressed && !is_modifier_vk(vk_code) {
    note_companion_key_down(ctx);
}

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 覆盖绝大多数组合键的「修饰键→普通键」间隔,又低于人从按键到开口的反应时间,
不吃首字。
@bigsongeth
bigsongeth force-pushed the fix/modifier-trigger-combo-abort branch from ed5f5da to 9fa9f63 Compare July 24, 2026 12:18
@github-actions

Copy link
Copy Markdown
Contributor

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(仲裁窗口等价于
直接放行)。
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 195d9bc

@bigsongeth

Copy link
Copy Markdown
Contributor Author

关于 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) { ... }
}

LLKHF_INJECTED 的事件在进 dispatch_keyboard_event 之前就被丢掉了,本 PR 新增的 note_companion_key_down 在它内部、拿不到合成按键(唯一例外是显式设了 OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS=1 的测试路径)。所以屏幕键盘 / AutoHotkey / 游戏的合成输入不会撤销听写,无需额外处理。

不过顺着这条思路,有个真实的平台差异值得记下来:macOS 侧的 CGEventTap 没有对应的 injected 过滤。理论上第三方自动化工具(Keyboard Maestro 之类)在用户按住触发键期间合成按键,会撤销一次听写。实践中够不着——OpenLess 自己的文字插入不会与「触发键按住」重叠(Hold 模式插入发生在松手之后;Auto/Toggle 锁存态下触发键并没有按住)。如果维护者认为值得补齐对称性,可以在 mac tap 的 handle_key_down 里加一次 kCGEventSourceStateID 判定;本 PR 没有加,是不想为一个够不着的场景引入「误判真实按键 → 修复直接失效」的风险。

另外两条 focus area 我的看法:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant