Skip to content

fix(linux-egui): 听写可用性两修 —— Menu 热键 / 录音掉线不丢字(+ 诊断日志) - #1255

Merged
H-Chris233 merged 4 commits into
Open-Less:betafrom
TheTouYu:fix/linux-dictation-hotkey-style
Oct 22, 2026
Merged

H-Chris233 merged 4 commits into
Open-Less:betafrom
TheTouYu:fix/linux-dictation-hotkey-style

Conversation

@TheTouYu

@TheTouYu TheTouYu commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

背景

在 Arch Linux + KDE Wayland 上从源码构建并运行 Linux egui 宿主(2.0.2-Beta.1-141,基线 beta@df73ad0)时,遇到两个影响日常可用性的问题,都在本机实测定位,补丁很小、互不耦合;顺手补了两处诊断日志。

改动

1) fix(linux-egui): Menu 键无法注册为听写热键

fcitx5 的 Key::parse 按 X11 keysym 名做大小写敏感查表:

SetCustomDictationTrigger("Menu") → sym=0xff67   ✅
SetCustomDictationTrigger("menu") → invalid key  ❌

而 normalize_fcitx_primary() 会把主键名无条件转小写,所以 Menu(右 Alt 右边那个键)这类命名 keysym 永远注册不上。现在只对查表中确实首字母大写的名字还原大小写,space / f5 / 单字符等行为不变;新增单测 menu_primary_keeps_the_case_fcitx_expects。

2) fix(core): 录音流掉线时不再丢掉已识别内容

蓝牙 / 虚拟输入设备在录音过程中可能报 Linux audio input stream failed: Device disconnected(PipeWire 节点被挂起、蓝牙 SCO 掉线等)。此前 recording.stop() 的错误会直接中止听写并丢弃已经流进 ASR 的音频:history 记 transcribeFailed,用户看到的是「识别到一半整段消失,既不插入也不进剪贴板」。

现在掉线只记警告,继续用已捕获的音频完成转写;是否真的没有内容,交给下游「未收到有效音频」守卫判断。

3) feat(diagnostics): 失败原因与设备选择可观测

  • mark_dictation_failed 记录 code + message。此前只把 errorCode 写进 history,日志里完全看不出失败发生在哪一步(录音停止 / 转写 / 润色)。
  • 输入设备名精确匹配失败时打印可选设备列表,并记录最终选用的设备名——之前只有一句「using the default device: <填的名字>」,没法判断实际用的是哪只麦。

验证

  • cargo test -p openless-linux-egui --lib settings:: → 11/11(含新增用例)
  • cargo test -p openless-core --lib dictation_engine::tests → 54/54
  • cargo build -p openless-linux-egui → Finished dev profile(撤掉第 3 条后复跑 1m57s)
  • 本机实测:
    • Menu 注册:日志 [fcitx] registered SetCustomDictationTrigger key=Menu
    • 掉线场景:insertStatus=inserted,finalText 正常

备注

2026-10-23 更新:原第 3 条(风格「激活」后界面不更新)经维护者指出,在当前 beta 上已由 StylePacksChanged → load_library() 事件驱动刷新覆盖,前提不成立,已从分支撤回(见 #issuecomment-6344628205)。分支现为 3 个提交。

第 2、3 条与具体 ASR provider 无关;第 1 条对任何非修饰键热键都有效(不止 Menu)。测试环境:Arch Linux / KDE Wayland / Rust 1.99。

fcitx5 的 Key::parse 按 X11 keysym 名做大小写敏感查表:SetCustomDictationTrigger("Menu")
→ sym=0xff67 成功,("menu") → invalid key 被拒。而 normalize_fcitx_primary 会把主键名
统一转小写,于是任何非修饰键的命名 keysym(Menu 等)都注册不上。

只对查表中确实首字母大写的名字还原大小写,其余(space / f5 / 单字符)维持原行为。
新增单测 menu_primary_keeps_the_case_fcitx_expects。
蓝牙/虚拟输入设备在录音过程中可能报 "Device disconnected"(PipeWire 节点被挂起或
蓝牙 SCO 掉线)。此前 recording.stop() 返回错误会取消转写并丢弃已经流进 ASR 的音频,
用户看到的就是「识别到一半整段消失、既不插入也不进剪贴板」(history 记 transcribeFailed)。

改为:掉线只记警告,继续用已捕获音频完成转写;是否真的没有内容交给下游
「未收到有效音频」守卫判断。
@aeoform

aeoform commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

第 3 条(风格「激活」后界面不更新)我在本地用 beta 对照走了一遍链路,没能在当前 beta 上复现「蓝框/『当前』不移动」,怀疑这个前提已经不成立了,麻烦你确认一下复现场景。

高亮确实来自 self.style_packs,但它会被事件主动刷新

  • 卡片字段:style_pack_view() 里 is_active: pack.active(linux-egui/src/main.rs:8136)
  • 后端激活时会推事件(crates/openless-core/src/api.rs:6942):
self.publish_preferences_changed();
self.publish_style_packs_changed();   // ← 关键
pack.active = true;
  • Linux host 订阅了这个事件(linux-egui/src/main.rs:4035):
BackendEventKind::VocabularyChanged(_) | BackendEventKind::StylePacksChanged(_) => {
    self.load_library()
}
  • load_library() 里就是 backend.list_style_packs(&preferences.active_style_pack_id)?(main.rs:2442),回来的 Library 直接覆盖 self.style_packs(main.rs:5147)

也就是说:点击 → 一次 IPC 往返 → StylePacksChanged → list_style_packs → 蓝框移动。延迟是一个往返(毫秒级),而不是「要等下一次手动刷新」。所以 PR 描述里「卡片高亮要等到下一次 list_style_packs 才动」这句,在当前 beta 上应该是不成立的。

会不会你复现的那一版还没有 StylePacksChanged → load_library() 这条订阅?如果是更早的提交,那这条改动就是「补历史遗留」,需要在描述里注明基线版本,否则后来的人看到会以为现在还有这个 bug。

如果只是想把延迟从「一个往返」压到 0,那就是微优化 —— 但那条 LibraryRefresh 建议去掉

self.spawn 换成手写 tokio.spawn 之后多发的这条:

let _ = refresh.send(UiResult::LibraryRefresh);

和事件触发的刷新是重复的,而 load_library() 不是只刷风格包,它一次会打四个后端调用:

backend.get_preferences();
backend.list_vocabulary_presets()?;
backend.list_vocabulary()?;
backend.list_correction_rules()?;
backend.list_style_packs(&preferences.active_style_pack_id)?;

一次点击 = 事件触发的全量库刷新 + 这条再触发一次 = 双份。真要保证对齐,靠现有的 StylePacksChanged 就够了。

另外一个细节:本地翻转只改了 active,没改 enabled

Core 的激活对「未启用」的风格包会自动启用(api.rs:6934):

if !pack.enabled {
    pack = self.style_packs.set_enabled(id, true)?;
}

所以本地翻转后,一个原本 enabled: false 的包会出现 is_active: true / enabled: false 这种中间态(一般只闪一帧,但如果在那一帧渲染就有概率看到「当前」和高亮状态不一致)。本地翻转时把 enabled 一起置上会更稳。

我的建议

  • 第 1、2 条我这边验证过没问题:settings:: 单测 11/11,dictation_engine::tests 54/54,clippy --all-targets -D warnings 干净。
  • 第 3 条:日志可以留;本地翻转如果保留,请顺手把 enabled 也置上;建议删掉多余的 LibraryRefresh。
  • 第 4 条:mark_dictation_failed 加日志没问题(beta 里它现在只写 error.code,确实看不出失败在哪一步);设备列表那半也可以留。

@aeoform

aeoform commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

或者你可以补充一下复现的日志,debian + kde目前看没发现3号问题

- core: mark_dictation_failed 记录 code + message。此前只把 errorCode 写进 history,
  日志里看不到失败发生在哪一步(录音停止 / 转写 / 润色)。
- linux-egui: 输入设备精确匹配失败时打印可选设备列表,并记录最终选用的设备名。
  之前只有一句「using the default device: <填的名字>」,无法判断实际用的是哪只麦。
@TheTouYu
TheTouYu force-pushed the fix/linux-dictation-hotkey-style branch from 422c875 to 0c9c153 Compare October 22, 2026 15:31
@TheTouYu

Copy link
Copy Markdown
Contributor Author

谢谢指正,复核后你说得对,第 3 条的前提在我们这个基线上不成立,我已经把它从分支里撤掉了。

复核结果(基线 df73ad0,也就是 PR 的 base 头)

// crates/openless-core/src/api.rs:6825  activate_style_pack 内
self.publish_preferences_changed();
self.publish_style_packs_changed();   // ← 确实有
// linux-egui/src/main.rs:4035
BackendEventKind::VocabularyChanged(_) | BackendEventKind::StylePacksChanged(_) => {
    self.load_library()
}

所以点击 → StylePacksChanged → load_library() → Library 覆盖 self.style_packs,蓝框应该在一个 IPC 往返内就位。我们那版基线(clone 时的 beta 头 df73ad0)已经包含这条订阅,不是"更早的提交"。

我们当时的证据错在哪(诚实交代)

只有两条读数:

[style] activate: pack=builtin.structured
preferences.json: activeStylePackId = builtin.structured  (mtime 同步更新)

即"点击到达了后端且已落盘"——没有截到"蓝框没动"的画面。用户反馈"点了没反应",我据此推断成 badge 未刷新,这是用推断冒充读数,撤回该结论。

一个更可能的现场解释:用户点的是卡片本体而不是【激活】按钮。style_pack_card() 里卡片 rect 只做了 Sense::click() 供 hover,激活动作只绑在最左边的【激活】按钮上,点卡片其它位置确实不会有任何反应。如果你觉得合适,我可以另开一个 PR 把卡片本体点击也接到"激活"(纯 UX 变更,先问你们意见,不混进这个 PR)。

已处理

  • 删掉第 3 个提交(风格修复 + 你说重复的 LibraryRefresh 那一段),现在分支是 3 个提交:Menu 热键 / 录音掉线不丢字 / 诊断日志。
  • 已 force-push(422c875…0c9c153),PR 描述同步更新。
  • 重新编译通过:cargo build -p openless-linux-egui → Finished dev profile in 1m 57s。

复现日志(第 2 条,供参考)

[asr] 发送总结:38 audio frames, 238252 bytes (~7445 ms)
[WARN] [dictation] recorder stop failed; transcribing the captured audio anyway:
       Linux audio input stream failed: Device disconnected
[INFO] insert-diagnostics … operation=finalize result=Ok(Inserted)
history.json: insertStatus=inserted  finalText="今天下午开会。"

第 1 条(Menu)与第 2 条(掉线不丢字)如果你们希望换一种实现形态(比如 keysym 名做成通用映射表、或掉线时给 UI 一个明确的"设备掉线"提示),我可以按你们的偏好改。

@TheTouYu TheTouYu changed the title fix(linux-egui): 听写可用性三修 —— Menu 热键 / 录音掉线不丢字 / 风格切换即时反馈 fix(linux-egui): 听写可用性两修 —— Menu 热键 / 录音掉线不丢字(+ 诊断日志) Oct 22, 2026
@TheTouYu

Copy link
Copy Markdown
Contributor Author

再来一条收尾:上次那个"点了没反应"的现场找到了,是乌龙,跟风格激活逻辑无关——不用为它改任何代码。

后来在本机复现时命中了日志里的实锤:

wgpu error: Out of Memory                     ← UI 进程 panic(exit 101)
[ui-host] UI window process exited (exit status: 101)
[ERROR] OpenLess host failed: UI window process failed: exit status: 101

现场条件:本机开着游戏时 dGPU 显存 5793 / 6144 MiB(94%)(YuanShen.exe 2200 MiB + 一个本地嵌入服务 3300 MiB),OpenLess 的 UI(wgpu)在临界点上分配到失败直接崩了,宿主也跟着退出;所以那段时间"点激活/点空白处都没反应"其实是窗口进程已经不在了/在濒死抖动,事件根本没进 UI。

把 UI 钉到核显 Vulkan ICD(VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/intel_icd.json)后,同一场景下连续跑 1400 帧(≈43s / 32fps)零崩溃、点击正常。⇒ 与 StylePacksChanged 那条事件链完全无关,你的分析是对的,第 3 条已撤回、不需要替代方案。

顺带留一个可选的上游话题(不混进本 PR):UI 子进程崩溃时宿主会整体退出(OpenLess host failed: UI window process failed),如果希望"窗口崩了只关窗口、宿主继续跑并给出提示",可以单独开 issue;需要的话我可以另开 PR 做,但这属于行为变更,先问你们意见。

本 PR 剩下的两条(Menu 热键 / 录音流掉线继续转写)不受影响,等你们 review。

@aeoform

aeoform commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

我按你 force-push 后的 0c9c153e(3 个 commit)在本地重跑了一遍,基线是当前的 beta 头 f0744e10:

cargo test -p openless-linux-egui --lib settings::            11 passed
cargo test -p openless-core --lib dictation_engine::tests     54 passed
cargo clippy -p openless-core -p openless-linux-egui --all-targets -D warnings   干净

diff 现在只有 4 个文件(api.rs / dictation_engine.rs / audio.rs / settings.rs),第 3 条确实不在里面了,LibraryRefresh 那段冗余刷新也一起消失了 —— 谢谢配合。

关于你后来找到的现场,那个结论很有价值:

wgpu error: Out of Memory
[ui-host] UI window process exited (exit status: 101)

它说明「点了没反应」类反馈在 Linux 上还有一个与业务逻辑无关的成因:UI 子进程在显存临界点上崩掉,宿主跟着退出。这跟我们这边选设备的策略有关(wgpu_device::device_descriptor() 是按显存优先挑适配器),在 dGPU 被别的进程占满时正好踩坑。你提的两个后续我都认:

  1. UI 子进程崩溃时宿主整体退出 —— 这确实是行为问题,值得单开 issue:窗口崩了应该只关窗口、宿主继续跑并给出可读提示(现在用户看到的是「OpenLess 直接消失」)。
  2. 卡片本体点击是否也激活 —— UX 变更,单独 PR 提,我们评审时一起看。

这两条都不该混进本 PR,我同意你的处理方式。

CI 那边你的分支之前卡在 action_required(fork PR 的 workflow 需要维护者放行),我已经批准运行了,等结果绿了就走合并。

aeoform
aeoform previously approved these changes Oct 22, 2026

@aeoform aeoform left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本地复核通过(settings 11/11、dictation_engine 54/54、clippy -D warnings 干净),改动只涉及 Menu 热键、录音掉线继续转写、诊断日志三条。第 3 条已按讨论撤回。

`stop_during_provider_preparation_releases_capture_before_finalization` 的
`stop_fails` 分支断言的是旧契约:录音流 `stop()` 报错时整次听写以
`BackendErrorCode::Platform` 失败、history 记 `transcribeFailed`。

而本 PR 要修的正是这一点 —— 蓝牙麦掉线 / PipeWire 节点被挂起时,已经流进
ASR 的音频不该丢,`stop()` 的失败降级为 warn 后继续转写。于是 CI 上
`openless-core --lib` 稳定挂在这一条(本地 3/3 复现,beta 上通过)。

行为变了,断言要跟着变:`stop_fails` 现在与成功路径结果一致,只多一条 warn。
断言改为「返回 Ok + 文本正确 + history 不记 transcribeFailed」,
并保留原有的 stop_count / archive / has_audio_recording 检查。
@aeoform

aeoform commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

CI 上 test-linux-egui 挂了,我本地复现后定位到了,不是 flake,是这次行为变更漏改了一条断言。我直接在你分支上补了一个 commit(486143c3,走的是 maintainer 可编辑,没有 force-push 你的历史)。

失败点

---- api::tests::stop_during_provider_preparation_releases_capture_before_finalization stdout ----
thread panicked at crates/openless-core/src/api.rs:18504:
called `Result::unwrap_err()` on an `Ok` value:
  DictationResult { raw_text: "captured prefix", polished_text: "captured prefix", inserted: NotRequested }

本地 openless-core --lib 跑 3 次挂 3 次,在 beta 上跑通过 —— 确定性失败。

原因

那个测试的 stop_fails 分支写的是旧契约:录音流 stop() 报错时整次听写以 Platform 失败、history 记 transcribeFailed:

if stop_fails {
    assert_eq!(result.unwrap_err().code, BackendErrorCode::Platform);
    assert_eq!(backend.list_history().unwrap()[0].error_code.as_deref(), Some("transcribeFailed"));
} else { ... }

而这次改的就是这一点 —— 掉线降级为 warn、继续用已捕获音频转写。所以旧断言必然失败:它断言的那条错误路径已经不存在了。

改法

行为变了,断言跟着变,stop_fails 现在与成功路径结果一致(只多一条 warn):

let result = result.unwrap();
assert_eq!(result.polished_text, "captured prefix");
assert_eq!(transcriber.pcm(), vec![1, 0, 2, 0]);
assert_eq!(backend.list_history().unwrap()[0].error_code.as_deref(), None,
           "a dropped capture stream must not be recorded as a transcription failure");

后面的 stop_count / archive 可用 / has_audio_recording 三项检查原样保留。

验证:

cargo test -p openless-core --lib                1390 passed / 0 failed / 3 ignored
cargo clippy -p openless-core --all-targets -D warnings   干净

如果你希望「掉线时仍然给用户一个提示」(比如胶囊上显示「音频中断」而不是静默成功),那是另一层产品决策,可以另开 PR;这条只负责让管线不再丢字。

CI 我这边已经重新触发了,等绿灯。

@aeoform aeoform left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

重新 approve:新增的 commit(486143c3)只是把单测断言对齐到本次行为变更,CI 12/12 全绿,本地 openless-core --lib 1381 passed、clippy -D warnings 干净。

@aeoform

aeoform commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

CI 12/12 全绿,但我这边没法点合并——不是代码问题,是仓库规则:

Repository rule violations found
New changes require approval from someone other than aeoform because they were the last pusher.

beta 的 ruleset(Protect #beta)里开了 require_last_push_approval,而我为了让 CI 能跑通,在 486143c3 上直接补了那条测试断言 —— 于是「最后推送者」变成了我,我的 approve 在这个规则下不算数(reviewDecision 仍然显示 REVIEW_REQUIRED)。我也没有 admin/maintain 权限,--admin 绕不过 ruleset(bypass 列表为空)。

两个解法,选一个就行:

  1. 你随便推一个 commit(git commit --allow-empty -m "ci: retrigger" 也可以)—— 那样「最后推送者」变成你,我这次的 approve 就生效了,我立刻合。当然你想顺手改点别的也行。
  2. 等另一位维护者 approve —— 规则只要求「最后推送者以外的人」批一次,任何其他维护者点一下 Appove 就能解锁。

代码本身不用再动:本地 openless-core --lib 1381 passed、clippy -D warnings 干净、CI 12/12。

再次感谢配合,这次的两条(Menu 热键、掉线不丢字)都是真实的可用性问题,改得很准。

@H-Chris233
H-Chris233 merged commit 2fb1b3d into Open-Less:beta Oct 22, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants