fix(hotkey): 修饰键热键被当组合键用时不再误唤起听写(150ms 仲裁 + 即时撤销,胶囊不再赖着) - #858
fix(hotkey): 修饰键热键被当组合键用时不再误唤起听写(150ms 仲裁 + 即时撤销,胶囊不再赖着)#858bigsongeth wants to merge 5 commits into
Conversation
PR Reviewer Guide 🔍(Review updated until commit db45227)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 我的看法:
|
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>
|
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>
|
Persistent review updated to latest commit db45227 |
问题
把听写热键设成 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,其余一律忽略 —— 「这次按住中间夹了哪些普通键」这个信息在最底层就被丢掉了,上层永远只看到一次干净的按下/抬起。改动
四个提交,前两个修「误唤起」,后两个修「撤销了但胶囊还赖着」—— 后者是前者上线后真机 dogfood 才暴露出来的,见下面的实测。
1. 150ms 组合键仲裁窗口(事前)
modifier-only 触发键按下后先等
COMBO_ARBITRATION_GRACE(150ms) 再开会话;这期间监听器若已报告叠加了普通键,这次按下整条作废 —— 麦克风不开、胶囊不弹、不建 ASR 连接。等待只加在「开录」分支上,其它时序一律不变:
Cmd+Shift+D之类)本身没有歧义,不等;is_queued_chain_press的 120ms 排队接力窗口。2. 组合键撤销(兜底,覆盖窗口没盖住的慢速组合键)
按住 Option 半秒再按 Tab 这种,仲裁窗口够不着:tap/hook 在触发键按住期间收到普通键 KEY_DOWN 时发一次撤销(每次按住只发一次),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 触发键走同一条撤销路径(顺带熄灭整屏描边)。
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 次真机组合键测试分成干净的两组:
链路:
cancel_session先stop_recorder_for_session才emit_capsule。Recorder::stop()要 join 音频线程,音频线程退出前要 join liveness watchdog,watchdog 睡在 1000ms 的检查间隔里 —— 停采要等它当前那一觉睡完,胶囊排在后面。按 Esc 取消走同一个函数,同样拖一秒。两处一起改:
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,挪到后面语义不变。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 构建)
组合键误唤起:修前每次都开麦;修后按真实按键间隔分两条路径,都不再留下会话。
撤销决策 → 胶囊消失(决策时 recorder 已开的情形):
回归验证(同一构建):正常说一句话松手 → 转写插入正常;转写中按 Esc → 立即停。
cargo test --lib803 passed。