Skip to content

fix(hotkey): 修饰键热键被当组合键用时不再误唤起听写(150ms 仲裁 + 即时撤销,胶囊不再赖着) - #858

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

fix(hotkey): 修饰键热键被当组合键用时不再误唤起听写(150ms 仲裁 + 即时撤销,胶囊不再赖着)#858
bigsongeth wants to merge 5 commits into
Open-Less:betafrom
bigsongeth:fix/modifier-trigger-combo-abort

Conversation

@bigsongeth

@bigsongeth bigsongeth commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

问题

把听写热键设成 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,其余一律忽略 —— 「这次按住中间夹了哪些普通键」这个信息在最底层就被丢掉了,上层永远只看到一次干净的按下/抬起。

改动

四个提交,前两个修「误唤起」,后两个修「撤销了但胶囊还赖着」—— 后者是前者上线后真机 dogfood 才暴露出来的,见下面的实测。

1. 150ms 组合键仲裁窗口(事前)

modifier-only 触发键按下后先等 COMBO_ARBITRATION_GRACE(150ms) 再开会话;这期间监听器若已报告叠加了普通键,这次按下整条作废 —— 麦克风不开、胶囊不弹、不建 ASR 连接。

等待只加在「开录」分支上,其它时序一律不变:

  • 自定义组合键(Cmd+Shift+D 之类)本身没有歧义,不等;
  • toggle 停止 / QA 路由 / 防抖与冷却判定都在窗口之前 —— 尤其不挤占 is_queued_chain_press 的 120ms 排队接力窗口。

2. 组合键撤销(兜底,覆盖窗口没盖住的慢速组合键)

按住 Option 半秒再按 Tab 这种,仲裁窗口够不着:tap/hook 在触发键按住期间收到普通键 KEY_DOWN 时发一次撤销(每次按住只发一次),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 触发键走同一条撤销路径(顺带熄灭整屏描边)。

3. 撤销改走独立通道(ec85916

上面第 2 条上线后 dogfood 发现:功能对了,但胶囊要晚几百毫秒才消失,观感上像录音真的起来了。

原因是撤销事件和 Pressed/Released 挤在同一条 channel 上,而那条 bridge 为修 #468/#475 的 latch 竞态改成了串行 block_on —— 一次按下的 begin_session(开麦 + ASR 握手)就在 bridge 线程上同步跑,期间 bridge 无法 recv。tap 在你按下第二个键的那一微秒就知道了,事件却要排队等开麦跑完才被取出。

修法照搬 #853 的 Esc 通道:把撤销从 HotkeyEvent 枚举拆出来走独立 Sender<()>,由专用 combo_abort_bridge_loop 线程消费。

一个随之而来的并发面:撤销不再保证排在 Released 后面。所以撤不撤销只看 hotkey_press_began_session(每个 Pressed 边沿都会重置它),不再看 hotkey_trigger_held —— 万一 Released 抢先跑完把按住态清了,撤销仍然认得出这条会话是自己那次按下开的。补了回归测试 trigger_combined_still_cancels_when_released_edge_wins_the_race

撤销落在 begin_session 还在 await 的中途,由既有的 startup_race_status_for_starting / CancelRaced 检查点接住(audit HIGH #1),与 Esc 取消同一条路径。

4. 取消先收胶囊再拆麦克风 + watchdog 碎觉(db45227

第 3 条把撤销决策做到了 0.14ms,但胶囊仍然晚 0.8~1 秒才消失。11 次真机组合键测试分成干净的两组:

决策时 recorder 已开 撤销决策 → 胶囊消失
是(5 次) 769 / 900 / 931 / 965 / 986 ms
否(6 次) 全部 0 ms

链路:cancel_sessionstop_recorder_for_sessionemit_capsuleRecorder::stop() 要 join 音频线程,音频线程退出前要 join liveness watchdog,watchdog 睡在 1000ms 的检查间隔里 —— 停采要等它当前那一觉睡完,胶囊排在后面。按 Esc 取消走同一个函数,同样拖一秒。

两处一起改:

  • cancel_session 把 UI 收尾(finish_cancel_session_stateemit_capsuleschedule_capsule_idle → 熄描边)整块提到三个拆资源调用之前。emit_capsule 仍排在 finish_cancel_session_state 之后 —— 它要读 state.voice_agent / phase 拼 payload,再提前会发出「还在进行中」的那一帧。拆资源都按 session_id 取,不依赖 phase,挪到后面语义不变。
  • watchdog 的一个检查间隔切成 50ms 的碎觉来睡,每觉醒来重看 stop_flag。停采的等待从最坏 1000ms 降到 50ms。判据(回调静默 3s / 首帧 5s)用的是真实时间差而非醒来次数,灵敏度不变

为什么必须成对:UI 先收之后,胶囊消失到麦克风真正释放之间有一段窗口,其间旧 recorder 还占着设备。Recorder 没有 Drop 停采,recorder 槽被下一条会话覆盖后旧音频线程会继续跑、抓着麦克风不放。只做第一处会把这段窗口留在 ~1 秒,而紧接着那次真想说话的按下最快也要 250ms 防抖 + 150ms 仲裁 = 400ms 才开麦 —— 撞得上;watchdog 切碎后窗口降到几十毫秒,够不着。

顺带:正常说完话松手结束(end_session 也走 Recorder::stop())同样少等将近一秒。
可观察的代价:胶囊消失后系统菜单栏的录音小圆点会多亮几十毫秒。

实测(macOS 26.5,Auto 模式 + 左 Option,真机 dogfood 构建)

组合键误唤起:修前每次都开麦;修后按真实按键间隔分两条路径,都不再留下会话。

快组合键(间隔 119ms,仲裁窗口盖住)
  20:33:09.232  hotkey pressed
  20:33:09.351  组合键撤销(本次按下没开出会话,无需撤销)
  20:33:09.385  150ms 仲裁窗口内叠加了其他键 —— 本次按下作废,不开录音
  → 麦克风没开、胶囊没出现

慢组合键(间隔 >150ms,走事后撤销)
  20:34:16.557277  [hotkey] tap 看到普通键
  20:34:16.557416  [coord] 取消本次按下开出的会话     ← +0.14ms

撤销决策 → 胶囊消失(决策时 recorder 已开的情形):

改前 改后
组合键撤销 769~986 ms 0 ms

回归验证(同一构建):正常说一句话松手 → 转写插入正常;转写中按 Esc → 立即停。

cargo test --lib 803 passed。

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit db45227)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

545 - Partially compliant

Compliant requirements:

  • 关闭/取消路径可用:handle_trigger_combined 仅在本次按下真的开出会话时取消,不影响正常 end_session 路径
  • 与动画修复不冲突:未改动 Capsule.tsx,保留现有 UI 行为

Non-compliant requirements:

  • 三连按语义:组合键撤销时主动清除 session_cooldown_until 和 last_hotkey_dispatch_at,使得紧接着的按下不再受冷却/防抖保护,可能允许第3次立即激活(与“上一轮完全结束前忽略”的要求不符)
  • 完全结束后才允许再激活:同上,冷却被清除后相当于允许在“未完全结束”时立即再激活

Requires further human verification:

  • 回归测试:需要手动验证单次 Toggle、Hold 模式是否仍正常工作(单元测试覆盖有限)
  • 动画层 leaving 状态与冷却的交互是否一致(特别是胶囊消失时机)
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ No major issues detected

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 我的看法:

150ms 仲裁窗口只盖住了「修饰键→普通键」间隔够快的组合键;慢一点的(实测日志里
press→combined 中位数 428ms)仍然走事后撤销兜底,本该只是「闪一下」,实际却晚了
几百毫秒才收——观感上像录音真的起来了。

原因不是手速,是排队:TriggerCombined 和 Pressed/Released 挤同一条 channel,而
bridge 为修 Open-Less#468/Open-Less#475 的 latch 竞态改成了串行 block_on —— 这次按下自己的
begin_session(开麦 + ASR 握手)正卡在 bridge 线程上,撤销只能在队列里等它跑完。
日志里那个 428ms 因此是上界,真实键间隔被 begin_session 的耗时撑大了。

修法同 Open-Less#853(Esc 取消):把撤销从 HotkeyEvent 枚举拆出,tap/hook 回调改发独立的
Sender<()>,由专用 combo-abort-bridge 线程消费。macOS CGEventTap 与 Windows
WH_KEYBOARD_LL 同步修改;Less Computer 的修饰键 monitor 走同一条通道,只是 handler
换成 cancel_less_computer_press。

并发面两处:
- 撤销与 Released 之间不再有先后保证。改为只看 hotkey_press_began_session
  (每个 Pressed 边沿都会重置)判断要不要撤销,不再看 hotkey_trigger_held ——
  否则 Released 抢先跑完清了按住态,撤销会认不出这条会话是自己开的、放着不管,
  Auto 模式下就是一条停不下来的录音。清 hotkey_trigger_held 只为吞掉后面的
  Released,与撤销与否无关。加了回归测试覆盖这个倒序。
- 撤销落在 begin_session 还在 await 的中途,由既有的
  startup_race_status_for_starting / CancelRaced 检查点接住(audit HIGH Open-Less#1),
  与 Esc 取消同一条路径。

至此两条路径分工:仲裁窗口内的快组合键胶囊根本不出现;窗口没盖住的慢组合键胶囊
冒个头,按下第二个键即刻消失。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit ec85916

上一提交把撤销决策做到了「按下第二个键的那一帧」(tap → handle_trigger_combined
实测 0.14ms),但真机日志显示胶囊仍然晚 0.8~1 秒才消失。11 次组合键测试分成干净的
两组:这次按下已经把 recorder 开起来的 5 次是 769/900/931/965/986ms,还没开起来的
6 次是 0ms。

链路:cancel_session 先 stop_recorder_for_session 才 emit_capsule。
Recorder::stop() 要 join 音频线程,音频线程退出前要 join liveness watchdog,
watchdog 睡在 1000ms 的检查间隔里 —— 所以停采要等它当前那一觉睡完,胶囊排在后面。
按 Esc 取消走同一个函数,同样拖一秒。

两处一起改(缺一不可,见下):

- coordinator/dictation.rs:cancel_session 把 UI 收尾(finish_cancel_session_state
  → emit_capsule → schedule_capsule_idle → 熄描边)整块提到三个拆资源调用之前。
  emit_capsule 仍排在 finish_cancel_session_state 之后 —— 它要读 state.voice_agent /
  phase 拼 payload,再提前会发出「还在进行中」的那一帧。拆资源都按 session_id 取,
  不依赖 phase,挪到后面语义不变。
- recorder.rs:watchdog 的一个检查间隔切成 50ms 的碎觉来睡,每觉醒来重看 stop_flag。
  停采的等待从最坏 1000ms 降到 50ms。判据(回调静默 3s / 首帧 5s)用的是真实时间差
  而非醒来次数,灵敏度不变;代价是录音期间线程多醒几次,醒来只读一个时间戳。

为什么必须成对:UI 先收之后,胶囊消失到麦克风真正释放之间有一段窗口,其间旧
recorder 还占着设备。`Recorder` 没有 Drop 停采,recorder 槽被下一条会话覆盖后旧音频
线程会继续跑、抓着麦克风不放。只做第一处会把这段窗口留在 ~1 秒,紧接着那次真想说话
的按下(最快也要 250ms 防抖 + 150ms 仲裁)就可能撞上;watchdog 切碎后窗口降到几十
毫秒,够不着。

副作用:正常说完话松手结束(end_session 也走 Recorder::stop())同样少等将近一秒。
可观察的代价:胶囊消失后系统菜单栏的录音小圆点会多亮几十毫秒。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit db45227

@bigsongeth bigsongeth changed the title fix(hotkey): 修饰键热键被当组合键用时不再误唤起听写(150ms 仲裁窗口 + 事后撤销兜底) fix(hotkey): 修饰键热键被当组合键用时不再误唤起听写(150ms 仲裁 + 即时撤销,胶囊不再赖着) Jul 30, 2026
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