From 499dff37645744afa251351bcfe391bf9a004254 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 25 Oct 2026 02:50:11 +0800 Subject: [PATCH 1/3] feat(windows): link recording hotkey to voice activation Add opt-in local-only wake-listener linkage with runtime pause intent, capture prewarm suppression, body-preserving finalization, native combination arbitration, and durable inline settings status. Add Core, recorder, frontend and settings regressions for lifecycle races. Co-Authored-By: Claude Opus 5 --- docs/research/issue-1264.md | 98 +++++ .../app/crates/openless-core/src/api.rs | 274 ++++++++++--- .../src/cloud_sync_e2ee_documents/registry.rs | 7 + .../app/crates/openless-core/src/lib.rs | 1 + .../app/crates/openless-core/src/ports.rs | 3 + .../crates/openless-core/src/shared_types.rs | 7 + .../openless-core/src/voice_activation.rs | 2 + .../src/voice_activation_hotkey.rs | 123 ++++++ .../tests/voice_activation_linked.rs | 384 ++++++++++++++++++ .../app/e2e/settings-regressions.spec.ts | 27 ++ .../tests/voice_activation_linked.rs | 3 + .../app/src-tauri/src/commands/settings.rs | 6 + .../src/coordinator/dictation_core.rs | 11 + .../src-tauri/src/coordinator/hotkey_loops.rs | 27 +- .../app/src-tauri/src/core_adapters.rs | 6 + openless-all/app/src-tauri/src/hotkey.rs | 26 +- openless-all/app/src-tauri/src/lib.rs | 2 + openless-all/app/src-tauri/src/recorder.rs | 89 +++- .../app/src-tauri/src/side_aware_combo.rs | 3 +- openless-all/app/src/i18n/de.ts | 9 + openless-all/app/src/i18n/en.ts | 9 + openless-all/app/src/i18n/es.ts | 9 + openless-all/app/src/i18n/fr.ts | 9 + openless-all/app/src/i18n/ja.ts | 9 + openless-all/app/src/i18n/ko.ts | 9 + openless-all/app/src/i18n/zh-CN.ts | 9 + openless-all/app/src/i18n/zh-TW.ts | 9 + openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/ipc/settings.ts | 9 + openless-all/app/src/lib/types.ts | 1 + .../app/src/lib/voiceActivation.test.ts | 16 + openless-all/app/src/lib/voiceActivation.ts | 17 + .../pages/settings/RecordingInputSection.tsx | 40 +- 33 files changed, 1185 insertions(+), 70 deletions(-) create mode 100644 docs/research/issue-1264.md create mode 100644 openless-all/app/crates/openless-core/src/voice_activation_hotkey.rs create mode 100644 openless-all/app/crates/openless-core/tests/voice_activation_linked.rs create mode 100644 openless-all/app/src-tauri/backend-tests/tests/voice_activation_linked.rs diff --git a/docs/research/issue-1264.md b/docs/research/issue-1264.md new file mode 100644 index 000000000..d9e4d8517 --- /dev/null +++ b/docs/research/issue-1264.md @@ -0,0 +1,98 @@ +# Issue #1264 Windows 语音快捷键联动调研报告 + +> 调研日期:2026-10-25 +> +> 范围:提交 `38fbe1c082e782e566e13769e4675a183ccc812a` 的 Windows/桌面端源码;调研时工作树与 upstream/beta 一致。仅静态调研,不实施功能,不运行构建、测试或打包。 + +## 需求与关联背景 + +[Issue #1264](https://github.com/Open-Less/openless/issues/1264) 要求:总开关开启后,新增“与语音快捷键联动”子选项;第一次点击录音快捷键开启持续本地唤醒监听,第二次点击临时关闭并释放麦克风,而不是反复进入设置修改总开关。维护者已[回复“oka”](https://github.com/Open-Less/openless/issues/1264#issuecomment-6387227197),尚未补充启动、模式冲突或录音中切换的语义。调研时 Issue 仍 OPEN,未发现关联实现 PR。 + +- [#1240](https://github.com/Open-Less/openless/pull/1240) 于 2026-10-21 合并,为本需求提供 Windows 本地唤醒基础;其描述仍列出完成词残留、Windows `0xC0000374` 堆损坏来源未定位、超时按总录音时长等未验收问题。这些是继承风险,不属于本 Issue 应顺手扩大的修复范围。 +- [#1263](https://github.com/Open-Less/openless/pull/1263) 当前 OPEN,只补 ScrollLock 快捷键支持,明确不含语音唤醒开关联动;不是本需求的替代实现,也不是使用其他已支持快捷键的前置条件。 +- [#1235](https://github.com/Open-Less/openless/pull/1235) 当前 OPEN,增加 Toggle 模式 Enter 停止录音,会触及 Core 偏好、coordinator/hotkey_loops 和录音设置页,后续集成需组合回归。 + +## 1. 结论摘要 + +- **[已证实,现状]** 语音唤醒已有 Core 生命周期和 Windows runtime,但没有“与录音快捷键联动”的持久字段,也没有把普通录音快捷键改成“首次开启监听、再次关闭监听”的策略(`ports.rs:117-142`、`api.rs:3415-3432`)。[1] +- **[已证实,隐私]** 唤醒前固定使用本地 Sherpa,standby 音频不发送云端;唤醒后的正式正文走独立 ASR provider,可能是云端,不能把“本地唤醒”表述成“全流程本地”(`shared_types.rs:462-476`、`core_adapters.rs:1942-1957`、`api.rs:6364-6397`)。[2] +- **[已证实,风险]** `VoiceActivationHandle::stop()` 会取消 recognizer、清理 activation consumer,但随后可能按 low-latency 偏好保持 warm stream,不能保证 Issue 所说的“释放麦克风”(`recorder.rs:115-122`、`core_adapters.rs:2236-2274`)。[3] +- **[推断,方案]** 可复用现有 handle、generation、事件和 `WarmCaptureHub`,但必须增加 Core-owned linked policy、临时暂停状态和 effective warm-capture 约束。 + +## 2. 现状与本地/云端边界 + +`UserPreferences` 持久化总开关、唤醒/结束词、超时和设备名,设置变化会触发 runtime 重配(`commands/settings.rs:242-250,309-321`);注释明确写明 standby audio never sent to a cloud provider(`shared_types.rs:462-476`)。[5] Windows runtime 解析固定 `SherpaOnnx` 与 `zipformer-bilingual-zh-en-streaming`,模型未安装就返回 ModelMissing;唤醒 recognizer 通过 `WarmCaptureHub` arm(`core_adapters.rs:1942-1957,2039-2048`)。[6] activation VAD 使用 RMS `0.015`、约 400 ms pre-roll 和 8 个 hangover chunks,静音块不直接送 recognizer(`recorder.rs:49-55,447-487`)。[7][17] 上述本地边界只适用于唤醒前音频,不代表唤醒后的正式听写和后续处理都离线;本轮未做运行时网络抓取验证。 + +唤醒信号经 generation 校验后,Core 调用 `handoff_to_dictation`;Windows 取消 wake ASR,并可创建本地 finish recognizer(`api.rs:3692-3758`、`core_adapters.rs:2115-2173`)。[8][18] 随后正式 dictation 通过 `resolve_dictation_asr_provider` 按当前偏好选 provider;共享云端 provider 仅在特定未配置条件下切到本地 fallback(`api.rs:6364-6397,9253-9264`)。[9][19] 因此“正文必须本地”是**待定义**,不能由现有语音唤醒开关推导。 + +启动阶段 Core 会先使旧 generation 失效,再停止旧 handle;只有偏好启用、host runtime 可用且 capture 能成功 arm 时才进入等待唤醒,旧回调会因 generation 不匹配被丢弃(`openless-all/app/crates/openless-core/src/api.rs:3415-3432,3692-3713`)。这说明 linked mode 应复用现有异步生命周期,而不是在 Tauri coordinator 另建一套状态机。设置保存会校验短语和超时,并在相关字段变化后触发 runtime 重配;临时快捷键状态则不能走持久化设置路径。 +## 3. Core 接缝、临时状态与设备释放 + +最合适的接缝是 `VoiceActivationRuntime::configure`、`VoiceActivationHandle::stop`/`handoff_to_dictation`、generation 和 `VoiceActivationStateChanged`。EventBus 已按 generation 保存最新状态,Tauri 已转发 `voice-activation:state-changed`(`ports.rs:117-142`、`events.rs:410-515`、`tauri_events.rs:213-215`)。[4][10][20] + +建议新增类似 `voiceActivationHotkeyLinked` 的**持久偏好**,但“当前因快捷键开启/暂停”只能是运行时状态,不能写回设置。首次完成按键仲裁的有效点击只 arm listener,第二次 press 进入临时暂停;普通 dictation 的 FIFO、generation 和 stale-event 防护继续由 Core 处理。Issue 没有规定只能 Toggle:Toggle 可以是首个实现选项,也可以另行定义 Auto 短按 toggle、Hold 按住监听,需先确认语义。 + +必须正视释放坑:`warm_capture_enabled` 的公式是 `voice_activation || (low_latency && !low_memory)`(`recorder.rs:115-122`)。`handle.stop()` 清 consumer、取消 wake/finish ASR 后,又以 `voice_activation=false` 按普通偏好配置 warm hub;low-latency 开启时仍可能预热麦克风(`core_adapters.rs:2236-2274`)。所以 stop 可复用,但不是硬释放保证;临时 pause 必须同时关闭 effective warm policy,并在需要时停止 native stream。 + +还要抑制自动 rearm:正式录音释放后存在 `configure_voice_activation()` 重配(`core_adapters.rs:3525-3535`);结束词完成和错误恢复也会在持久 `voice_activation_enabled` 为真时重新 arm(`api.rs:3764-3774,3842-3850,3884-3890,4022-4055`)。[11][13][14][15] 状态机必须区分“持久启用”和“当前暂停”,所有 reconfigure/recovery 都检查后者。 +普通 dictation 的 provisional capture 还会在 warm 与 cold 两条路径之间切换:cold capture 会暂停 warm stream,录音结束再恢复;voice activation handoff 则使用带 pre-roll 的 staged warm capture。因而 Core 的 voice session gate 与 Tauri 的 WarmCaptureHub 是两层资源协调,不能只用一个 Busy 标志解释所有麦克风竞争。 + +## 4. 需定义语义、验证重点 +建议最小运行状态包括 LinkedDisabled、LinkedStarting、LinkedListeningForWake、LinkedStopping、LinkedPaused 和 LinkedUnavailable/Failed;它们与持久 `voice_activation_enabled` 分离。首次点击失败时应保留明确错误而不回退成普通 dictation,第二次点击若处于 handoff 或停止中应按 generation 防止重复 stop;重新启用必须由下一次明确点击或产品定义的恢复动作触发。 + +**[待定义]** 当前 Auto 用 350 ms 区分短按/长按,并有 debounce、cooldown、modifier grace(`hotkey_interpreter.rs:7-19`)。[16] 需明确四种模式的 linked 行为,以及 listener 在 Starting、Handoff、结束词收尾,或 Dictation、LessComputer、SelectionVoice、QA、TTS 活跃时应取消、排队、Busy 还是忽略。本次源码未证实 TTS 的麦克风抑制策略。`VoiceSessionGate` 只明确互斥 Dictation、LessComputer、SelectionVoice、QA(`voice_session.rs:5-79`);standby listener 与 TTS 的关系仍待验证。[12] + +前端已有完整 phase 类型和状态事件通道,但设置页当前主要用事件判断模型缺失;linked mode 还需要把“已暂停但持久开启”与 Disabled、MicBusy、PermissionDenied 分开显示,避免用户误以为持久配置被清除。 +**[测试重点]** 现有单测已覆盖短语规范化、revision 去重、generation/session 防陈旧和 warm policy(`voice_activation.rs:428-565`、`recorder.rs:3244-3255`)。[22]新增用例应覆盖首次/二次点击、重复点击/stale release、暂停不持久化且不自动 rearm、low-latency 下真实 stream 释放、cold/warm 交替、设备不存在、权限拒绝、其他程序占用、模型缺失、唤醒后云端/本地 provider、结束词和超时恢复。资源释放与权限必须在 Windows 实机验证,静态单测不能替代。 +实机还应观察系统麦克风指示灯、设备切换后的实际占用、录音结束后的恢复时序,以及唤醒前 PCM 是否只到本地 recognizer;云端正文则应通过 provider 日志或网络策略单独验证,不能把状态事件当成隐私证明。 + +## 5. 推荐交互与改动边界 + +**推荐方案而非已实现行为:** 联动偏好默认关闭,保证旧用户行为不变;启用联动后,快捷键操作运行时监听状态,不反复写总开关。建议每次应用启动后先保持临时暂停,等待首次显式点击;是否恢复上次监听状态仍需产品确认。联动开启期间是否统一采用单击切换、覆盖原 Hold/Auto/DoubleClick 语义,应明确说明,不能暗中只支持 Toggle 而缩减需求。联动关闭时恢复原录音模式。 + +设置 UI 复用现有 Windows 语音唤醒内联子区域(`RecordingInputSection.tsx:617-665`),新增开关与常驻说明,不需要 Modal/Drawer/二次确认。[23] 显示“唤醒监听中 / 已临时暂停 / 正在切换 / 无法启动”等真实状态;Toast 只辅助反馈,权限、模型缺失等错误保留可操作的恢复入口。文案应写清“等待唤醒时仅本地处理;开始正式听写后按当前服务配置处理”。异步完成后才显示成功,不夺输入焦点;失败不悄悄回退为普通录音。此处为 UI 选型建议,尚未运行 UI 验证。 + +最小改动面不只是一个 TSX 开关:Core 偏好及 serde/wire 往返、前端类型/默认值和文案、Core hotkey dispatch 与监听有效状态、Tauri warm-capture 策略及设置重配入口、必要的偏好同步边界和回归测试。建议让 Core 统一拥有联动策略,复用现有 Windows runtime,暂不扩展到其他平台。持久联动偏好是否跨设备同步可沿用项目偏好分类;临时暂停态不能同步。 + +## 6. 远程验证要求 + +本轮只有文档变更,不触发构建。后续实施并推送代码时,按准确工作分支和提交 SHA 执行 GitHub Actions 的 `ci.yml`、`release-tauri.yml`(桌面全平台)和 `android-apk.yml`(至少 aarch64);优先交付已就绪的桌面产物,不把其他 SHA 的成功结果移用到新代码。[24][25][26] + +现有 CI 的 Windows 完整 Tauri 后端测试使用 `--no-run`,只编译;实际执行的是独立 `backend-tests` 和 recorder 模块测试。完整 Core 测试另在 Linux 检查中执行。[27][28] 因此应把新增联动状态机尽量放到可独立执行的 Rust-only 用例中,再用 Windows 实机验收真实设备释放、快速切换和权限恢复;不能把编译成功、安装包生成或状态事件变化当成硬件生命周期通过。 + +额外回归必须包括:暂停后保存设置不会重新预热(`commands/settings.rs:221-236`)、low-latency 开启时仍能释放唤醒所持资源、旧恢复回调不会重新 arm;其他语音任务仍在使用麦克风时要说明占用来源,不能谎报整个应用已释放设备,也不能静默丢弃进行中的正文。[29] + +## 7. 证据边界 + +本报告完成的是源码与 GitHub 一手材料调研,只新增此 Markdown;未修改功能代码,未运行本地编译、测试、打包或依赖构建,未提交、推送或发布 Issue 评论。所有推荐交互和新增测试均待实施与验证,不把既有 PR 的未验收事项说成已经解决。 + +[1]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/ports.rs#L117-L142 +[2]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src-tauri/src/core_adapters.rs#L1942-L1957 +[3]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src-tauri/src/core_adapters.rs#L2236-L2274 +[4]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/events.rs#L410-L515 +[5]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/shared_types.rs#L462-L476 +[6]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src-tauri/src/core_adapters.rs#L1942-L1957 +[7]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src-tauri/src/recorder.rs#L49-L55 +[8]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/api.rs#L3692-L3758 +[9]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/api.rs#L6364-L6397 +[10]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/events.rs#L410-L515 +[11]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/api.rs#L3764-L3774 +[12]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/voice_session.rs#L5-L79 +[13]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src-tauri/src/core_adapters.rs#L3525-L3535 +[14]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/api.rs#L4022-L4055 +[15]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/api.rs#L3842-L3890 +[16]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/hotkey_interpreter.rs#L7-L19 +[17]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src-tauri/src/recorder.rs#L447-L487 +[18]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src-tauri/src/core_adapters.rs#L2115-L2173 +[19]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/api.rs#L9253-L9264 +[20]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src-tauri/src/tauri_events.rs#L213-L215 +[21]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src-tauri/src/recorder.rs#L115-L122 +[22]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/crates/openless-core/src/voice_activation.rs#L428-L565 + +[23]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src/pages/settings/RecordingInputSection.tsx#L617-L665 +[24]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/.github/workflows/ci.yml#L10-L21 +[25]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/.github/workflows/release-tauri.yml#L31-L42 +[26]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/.github/workflows/android-apk.yml#L27-L41 +[27]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/.github/workflows/ci.yml#L723-L750 +[28]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/.github/workflows/check-linux-egui.yml#L108-L119 +[29]: https://github.com/Open-Less/openless/blob/38fbe1c082e782e566e13769e4675a183ccc812a/openless-all/app/src-tauri/src/commands/settings.rs#L221-L236 diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 3402ab525..c4bc440ba 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2635,6 +2635,7 @@ pub struct OpenLessBackend { voice_activation_generation: Arc, voice_activation_handoff_generation: AtomicU64, voice_activation_configure_gate: tokio::sync::Mutex<()>, + voice_activation_link: Mutex, backend_lifecycle_gate: tokio::sync::Mutex<()>, backend_shutdown_requested: AtomicBool, voice_activation_session: Arc>>, @@ -3385,6 +3386,7 @@ impl OpenLessBackend { voice_activation_generation: Arc::new(AtomicU64::new(0)), voice_activation_handoff_generation: AtomicU64::new(0), voice_activation_configure_gate: tokio::sync::Mutex::new(()), + voice_activation_link: Mutex::new(crate::voice_activation_hotkey::VoiceActivationLink::default()), backend_lifecycle_gate: tokio::sync::Mutex::new(()), backend_shutdown_requested: AtomicBool::new(false), voice_activation_session: Arc::new(Mutex::new(None)), @@ -3409,12 +3411,161 @@ impl OpenLessBackend { Ok(()) } + fn voice_activation_intent(&self) -> (bool, bool, u64) { + let prefs = self.get_preferences(); + let linked = prefs.voice_activation_enabled && prefs.voice_activation_hotkey_linked + && self.deps.voice_activation_runtime.is_some(); + let (requested, revision) = { + let mut state = self.voice_activation_link.lock().expect("voice link lock poisoned"); + state.configure(linked); + (state.requested, state.revision) + }; + let paused = linked && !requested; + if let Some(runtime) = &self.deps.voice_activation_runtime { + runtime.set_capture_paused(revision, paused); + } + (prefs.voice_activation_enabled && !paused, paused, revision) + } + + fn pause_linked_voice_activation_after_failure(&self, generation: u64) { + let prefs = self.get_preferences(); + if !prefs.voice_activation_enabled || !prefs.voice_activation_hotkey_linked { + return; + } + let revision = { + let mut state = self.voice_activation_link.lock().expect("voice link lock poisoned"); + if self.voice_activation_generation.load(Ordering::Acquire) != generation { + return; + } + state.pause(); + state.revision + }; + if let Some(runtime) = &self.deps.voice_activation_runtime { + runtime.set_capture_paused(revision, true); + } + } + + async fn recover_voice_activation_failure(&self, generation: u64, error: BackendError) { + self.pause_linked_voice_activation_after_failure(generation); + match self.stop_voice_activation_if_current(generation, + crate::voice_activation::VoiceActivationStopReason::RuntimeFailure).await + { + Ok(true) => { + if self.voice_activation_intent().1 { + self.events.publish(None, BackendEventKind::VoiceActivationStateChanged( + crate::voice_activation::VoiceActivationStateSnapshot { + phase: voice_activation_failure_phase(error.code), + activation_generation: self.voice_activation_generation.load(Ordering::Acquire), + session_id: None, + message: Some(error.message), + })); + } else if self.voice_activation_is_requested() { + let _ = self.configure_voice_activation().await; + } + } + Ok(false) => {} + Err(error) => log::warn!("voice activation recovery stop failed: {error}"), + } + } + + fn voice_activation_is_requested(&self) -> bool { + self.voice_activation_intent().0 + } + + /// Accept clicks synchronously so native FIFO delivery never waits for ASR startup. + /// Some(false) consumes an edge; Some(true) schedules reconciliation. + pub fn accept_voice_activation_hotkey_edge(&self, edge: DictationHotkeyEdge) -> Option { + self.voice_activation_intent(); + let prefs = self.get_preferences(); + if !prefs.voice_activation_enabled || !prefs.voice_activation_hotkey_linked + || self.deps.voice_activation_runtime.is_none() + { + return None; + } + let (changed, paused, revision, generation) = { + let mut state = self.voice_activation_link.lock().expect("voice link lock poisoned"); + let changed = state.edge(edge); + let generation = if changed { + let handoff = self.voice_activation_handoff_generation.load(Ordering::Acquire); + if !state.requested { + state.finalize_session = self.voice_activation_session.lock() + .expect("voice activation session lock poisoned").as_ref().map(|s| s.session_id); + if handoff != 0 { + state.finalize_generation = Some(handoff); + } + } + // A handoff may already own staged body PCM before it has a + // session ID. Keep its ticket valid until that body can finish. + if handoff != 0 && state.finalize_generation == Some(handoff) { + self.voice_activation_generation.load(Ordering::Acquire) + } else { + self.voice_activation_generation.fetch_add(1, Ordering::AcqRel) + 1 + } + } else { + self.voice_activation_generation.load(Ordering::Acquire) + }; + (changed, !state.requested, state.revision, generation) + }; + if changed { + if let Some(runtime) = &self.deps.voice_activation_runtime { + runtime.set_capture_paused(revision, paused); + } + self.events.publish(None, BackendEventKind::VoiceActivationStateChanged( + crate::voice_activation::VoiceActivationStateSnapshot { + phase: if paused { crate::voice_activation::VoiceActivationPhase::Pausing } + else { crate::voice_activation::VoiceActivationPhase::Starting }, + activation_generation: generation, + session_id: None, + message: None, + })); + } + Some(changed) + } + + pub async fn reconcile_voice_activation_hotkey(&self) -> Result<(), BackendError> { + let paused_session = self.voice_activation_link.lock() + .expect("voice link lock poisoned").finalize_session; + let session_id = paused_session.or_else(|| { + self.voice_activation_intent().1.then(|| self.voice_activation_session.lock() + .expect("voice activation session lock poisoned").as_ref().map(|s| s.session_id)).flatten() + }); + if let Some(session_id) = session_id { + // Recorder stop can reconfigure activation: never hold configure_gate here. + if let Err(error) = self.stop_dictation_session(session_id).await { + if !matches!(error.code, BackendErrorCode::InvalidState | BackendErrorCode::Busy) { + return Err(error); + } + } + } + self.configure_voice_activation().await + } + + async fn settle_stale_voice_dictation(&self, session_id: SessionId) { + let must_finalize = self.voice_activation_link.lock() + .expect("voice link lock poisoned").finalize_session == Some(session_id); + if must_finalize || self.voice_activation_intent().1 { + let _ = self.stop_dictation_session(session_id).await; + let _ = self.configure_voice_activation().await; + } else { + let _ = self.cancel_dictation(Some(session_id)).await; + } + } + /// Configure the opt-in local voice activation listener from the current /// preference snapshot. Reconfiguration replaces the previous native /// handle only after it has been stopped, so one process owns one mic. pub async fn configure_voice_activation(&self) -> Result<(), BackendError> { + self.voice_activation_intent(); let _configure_gate = self.voice_activation_configure_gate.lock().await; + let (effective, paused, revision) = self.voice_activation_intent(); let preferences = self.get_preferences(); + let pending_body = self.voice_activation_link.lock() + .expect("voice link lock poisoned").finalize_generation; + if pending_body.is_some_and(|generation| { + self.voice_activation_handoff_generation.load(Ordering::Acquire) == generation + }) { + return Ok(()); + } if self .voice_activation_session .lock() @@ -3447,7 +3598,7 @@ impl OpenLessBackend { .stop(crate::voice_activation::VoiceActivationStopReason::Disabled) .await; } - if !preferences.voice_activation_enabled + if !effective || self.backend_shutdown_requested.load(Ordering::Acquire) || !self.snapshot().running { @@ -3455,7 +3606,7 @@ impl OpenLessBackend { // activation was never enabled. Existing subscribers expect // BackendStarted to be the first startup event; later disable/error // transitions still publish Disabled below when a visible state exists. - let should_publish_disabled = had_previous_handle + let should_publish_disabled = paused || had_previous_handle || self .events .latest_voice_activation_state() @@ -3468,7 +3619,8 @@ impl OpenLessBackend { None, BackendEventKind::VoiceActivationStateChanged( crate::voice_activation::VoiceActivationStateSnapshot { - phase: crate::voice_activation::VoiceActivationPhase::Disabled, + phase: if paused { crate::voice_activation::VoiceActivationPhase::Paused } + else { crate::voice_activation::VoiceActivationPhase::Disabled }, activation_generation: generation, session_id: None, message: None, @@ -3528,14 +3680,22 @@ impl OpenLessBackend { let sink: Arc = Arc::new(CoreVoiceActivationSink { events: Arc::clone(&self.events), }); - match runtime.configure(config, sink).await { + let configured = if preferences.voice_activation_hotkey_linked && !config.wake_enabled { + Err(crate::ports::VoiceActivationError::InvalidConfig( + "enable the wake phrase before linking the recording shortcut".into())) + } else { + runtime.configure(config, sink).await + }; + match configured { Ok(handle) => { let stale_handle = { let mut slot = self .voice_activation_handle .lock() .expect("voice activation handle lock poisoned"); - if self.voice_activation_generation.load(Ordering::Acquire) != generation { + if self.voice_activation_generation.load(Ordering::Acquire) != generation + || self.voice_activation_link.lock().expect("voice link lock poisoned").revision != revision + { Some(handle) } else { *slot = Some(handle.into()); @@ -3565,7 +3725,11 @@ impl OpenLessBackend { Ok(()) } Err(error) => { + if self.voice_activation_generation.load(Ordering::Acquire) != generation { + return Ok(()); + } let error = map_voice_activation_error(error); + self.pause_linked_voice_activation_after_failure(generation); self.events.publish( None, BackendEventKind::VoiceActivationStateChanged( @@ -3760,22 +3924,22 @@ impl OpenLessBackend { { Ok(handoff) => handoff, Err(error) => { - log::warn!("voice activation handoff failed: {error}"); - match backend - .stop_voice_activation_if_current( - signal_generation, - crate::voice_activation::VoiceActivationStopReason::RuntimeFailure, - ) - .await - { - Ok(true) if backend.get_preferences().voice_activation_enabled => { - let _ = backend.configure_voice_activation().await; - } - Ok(_) => {} - Err(error) => { - log::warn!("voice activation recovery stop failed: {error}"); - } + let paused_handoff = { + let mut link = backend.voice_activation_link.lock() + .expect("voice link lock poisoned"); + if link.finalize_generation == Some(signal_generation) { + link.finalize_generation = None; + true + } else { false } + }; + if paused_handoff { + drop(_handoff_guard); + let _ = backend.configure_voice_activation().await; + return; } + log::warn!("voice activation handoff failed: {error}"); + backend.recover_voice_activation_failure(signal_generation, + map_voice_activation_error(error)).await; return; } }; @@ -3792,12 +3956,14 @@ impl OpenLessBackend { .await { Ok(session_id) => { - if backend + let must_finalize = backend.voice_activation_link.lock() + .expect("voice link lock poisoned").finalize_session == Some(session_id); + if must_finalize || backend .voice_activation_generation .load(Ordering::Acquire) != signal_generation { - let _ = backend.cancel_dictation(Some(session_id)).await; + backend.settle_stale_voice_dictation(session_id).await; return; } let committed = { @@ -3821,7 +3987,7 @@ impl OpenLessBackend { } }; if !committed { - let _ = backend.cancel_dictation(Some(session_id)).await; + backend.settle_stale_voice_dictation(session_id).await; return; } backend.events.publish( @@ -3838,21 +4004,7 @@ impl OpenLessBackend { } Err(error) => { log::warn!("voice dictation start failed: {error}"); - match backend - .stop_voice_activation_if_current( - signal_generation, - crate::voice_activation::VoiceActivationStopReason::RuntimeFailure, - ) - .await - { - Ok(true) if backend.get_preferences().voice_activation_enabled => { - let _ = backend.configure_voice_activation().await; - } - Ok(_) => {} - Err(error) => { - log::warn!("voice activation recovery stop failed: {error}"); - } - } + backend.recover_voice_activation_failure(signal_generation, error).await; } } })); @@ -3878,21 +4030,9 @@ impl OpenLessBackend { } => { let backend = Arc::clone(self); self.deps.task_spawner.spawn(Box::pin(async move { - match backend - .stop_voice_activation_if_current( - activation_generation, - crate::voice_activation::VoiceActivationStopReason::RuntimeFailure, - ) - .await - { - Ok(true) if backend.get_preferences().voice_activation_enabled => { - let _ = backend.configure_voice_activation().await; - } - Ok(_) => {} - Err(error) => { - log::warn!("voice activation recovery stop failed: {error}"); - } - } + backend.recover_voice_activation_failure(activation_generation, + BackendError::new(BackendErrorCode::Platform, + "voice activation microphone capture failed")).await; })); } VoiceActivationSignal::RuntimeStateChanged { .. } => {} @@ -3980,6 +4120,12 @@ impl OpenLessBackend { }, }); } + { + let mut link = self.voice_activation_link.lock().expect("voice link lock poisoned"); + if link.finalize_generation == Some(generation) { + link.finalize_session = Some(session_id); + } + } match self .start_reserved_dictation(reservation, DictationStartOptions::default()) .await @@ -4010,13 +4156,18 @@ impl OpenLessBackend { if !is_voice_session { return; } - let enabled = self.get_preferences().voice_activation_enabled; + { + let mut link = self.voice_activation_link.lock().expect("voice link lock poisoned"); + if link.finalize_session == Some(session_id) { + link.finalize_session = None; + link.finalize_generation = None; + } + } let _ = self .stop_voice_activation(crate::voice_activation::VoiceActivationStopReason::Cancelled) .await; - if enabled { - let _ = self.configure_voice_activation().await; - } + // Paused is a durable enabled-but-idle state, not Disabled. + let _ = self.configure_voice_activation().await; } async fn finish_voice_activation( @@ -4050,7 +4201,7 @@ impl OpenLessBackend { .is_some_and(|session| session.session_id == session_id); if still_voice_session { let _ = self.stop_voice_activation(reason).await; - if self.get_preferences().voice_activation_enabled { + if self.voice_activation_is_requested() { let _ = self.configure_voice_activation().await; } } @@ -5431,6 +5582,13 @@ impl OpenLessBackend { ) -> Result { use crate::hotkey_interpreter::HotkeyIntent; + if let Some(changed) = self.accept_voice_activation_hotkey_edge(edge) { + if changed { + self.reconcile_voice_activation_hotkey().await?; + } + return Ok(CliDispatchOutcome::Noop); + } + // Pressed and Released stay FIFO for toggle-like modes even though start/finalize await // native work. Hold-mode release deliberately bypasses the gate so a physical key-up can // cancel native startup instead of waiting for the recorder to become live. Combined also diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 3b7e806c1..142f656ec 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -207,6 +207,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Boolean, reason: "local_only_microphone_capture", }, + PreferenceField { + rust_name: "voice_activation_hotkey_linked", + key: "voiceActivationHotkeyLinked", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_only_microphone_capture", + }, PreferenceField { rust_name: "voice_activation_wake_enabled", key: "voiceActivationWakeEnabled", diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 1f363893f..2051099ec 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -80,6 +80,7 @@ pub mod testing; pub mod types; pub mod vocabulary; pub mod voice_activation; +mod voice_activation_hotkey; pub mod voice_edit_session; mod voice_session; diff --git a/openless-all/app/crates/openless-core/src/ports.rs b/openless-all/app/crates/openless-core/src/ports.rs index 66dfa69f4..089ce44ac 100644 --- a/openless-all/app/crates/openless-core/src/ports.rs +++ b/openless-all/app/crates/openless-core/src/ports.rs @@ -118,6 +118,9 @@ pub struct VoiceDictationHandoff { /// decides lifecycle and stale-event policy; the host decides how local ASR and /// the native capture stream are implemented. pub trait VoiceActivationRuntime: Send + Sync + 'static { + /// Apply runtime-only standby intent even before a recognizer exists. + fn set_capture_paused(&self, _revision: u64, _paused: bool) {} + fn configure( &self, config: VoiceActivationConfig, diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index ddb570f96..38d0613a8 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -462,6 +462,8 @@ pub struct UserPreferences { /// Opt-in local voice command listener. Standby audio is never sent to a cloud provider. #[serde(default)] pub voice_activation_enabled: bool, + #[serde(default)] + pub voice_activation_hotkey_linked: bool, #[serde(default = "default_true")] pub voice_activation_wake_enabled: bool, #[serde(default = "default_true")] @@ -1045,6 +1047,8 @@ struct UserPreferencesWire { microphone_device_name: String, #[serde(default)] voice_activation_enabled: bool, + #[serde(default)] + voice_activation_hotkey_linked: bool, #[serde(default = "default_true")] voice_activation_wake_enabled: bool, #[serde(default = "default_true")] @@ -1323,6 +1327,7 @@ impl Default for UserPreferencesWire { silence_auto_stop_seconds: prefs.silence_auto_stop_seconds, microphone_device_name: prefs.microphone_device_name, voice_activation_enabled: prefs.voice_activation_enabled, + voice_activation_hotkey_linked: prefs.voice_activation_hotkey_linked, voice_activation_wake_enabled: prefs.voice_activation_wake_enabled, voice_activation_finish_enabled: prefs.voice_activation_finish_enabled, voice_activation_wake_phrase: prefs.voice_activation_wake_phrase, @@ -1544,6 +1549,7 @@ impl<'de> Deserialize<'de> for UserPreferences { silence_auto_stop_seconds: wire.silence_auto_stop_seconds, microphone_device_name: wire.microphone_device_name, voice_activation_enabled: wire.voice_activation_enabled, + voice_activation_hotkey_linked: wire.voice_activation_hotkey_linked, voice_activation_wake_enabled: wire.voice_activation_wake_enabled, voice_activation_finish_enabled: wire.voice_activation_finish_enabled, voice_activation_wake_phrase: wire.voice_activation_wake_phrase, @@ -1943,6 +1949,7 @@ impl Default for UserPreferences { silence_auto_stop_seconds: default_silence_auto_stop_seconds(), microphone_device_name: String::new(), voice_activation_enabled: false, + voice_activation_hotkey_linked: false, voice_activation_wake_enabled: true, voice_activation_finish_enabled: true, voice_activation_wake_phrase: default_voice_activation_wake_phrase(), diff --git a/openless-all/app/crates/openless-core/src/voice_activation.rs b/openless-all/app/crates/openless-core/src/voice_activation.rs index 1faf28ded..aa5786f98 100644 --- a/openless-all/app/crates/openless-core/src/voice_activation.rs +++ b/openless-all/app/crates/openless-core/src/voice_activation.rs @@ -344,6 +344,8 @@ pub enum VoiceActivationSignal { #[serde(rename_all = "snake_case")] pub enum VoiceActivationPhase { Disabled, + Paused, + Pausing, Starting, ListeningForWake, WakeDetected, diff --git a/openless-all/app/crates/openless-core/src/voice_activation_hotkey.rs b/openless-all/app/crates/openless-core/src/voice_activation_hotkey.rs new file mode 100644 index 000000000..c7c1688b2 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/voice_activation_hotkey.rs @@ -0,0 +1,123 @@ +//! Runtime-only intent and physical-click arbitration for linked voice activation. +use std::collections::{HashSet, VecDeque}; +use std::time::{Duration, Instant}; + +use crate::api::DictationHotkeyEdge; + +#[derive(Default)] +pub(crate) struct VoiceActivationLink { + linked: bool, + pub(crate) requested: bool, + pub(crate) revision: u64, + pub(crate) finalize_session: Option, + pub(crate) finalize_generation: Option, + pressed: Option<(u64, Instant)>, + consumed: VecDeque, + combined: HashSet, + last_click: Option, +} + +impl VoiceActivationLink { + pub(crate) fn configure(&mut self, linked: bool) { + if self.linked != linked { + self.linked = linked; + self.requested = false; + self.revision += 1; + self.pressed = None; + self.consumed.clear(); + self.combined.clear(); + self.last_click = None; + } + } + + pub(crate) fn pause(&mut self) { + self.requested = false; + self.revision += 1; + } + + pub(crate) fn edge(&mut self, edge: DictationHotkeyEdge) -> bool { + match edge { + DictationHotkeyEdge::Pressed { press_id, at } => { + if !self.consumed.contains(&press_id) { + // A fresh physical ID supersedes a release lost while the + // native shortcut recorder or registration was suspended. + if self.pressed.map(|press| press.0) != Some(press_id) { + self.pressed = Some((press_id, at)); + } + } + } + DictationHotkeyEdge::Combined { press_id, .. } => { + if self.combined.len() >= 64 { + self.combined.clear(); + } + self.combined.insert(press_id); + } + DictationHotkeyEdge::Released { press_id, at } => { + if self.pressed.map(|press| press.0) != Some(press_id) { + return false; + } + let (_, pressed_at) = self.pressed.take().expect("matched physical press"); + self.consumed.push_back(press_id); + if self.consumed.len() > 64 { + self.consumed.pop_front(); + } + if self.combined.remove(&press_id) || at < pressed_at { + return false; + } + if self.last_click.is_some_and(|last| { + at.saturating_duration_since(last) < Duration::from_millis(250) + }) { + return false; + } + self.last_click = Some(at); + self.requested = !self.requested; + self.revision += 1; + return true; + } + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fresh_press_recovers_after_a_suppressed_release() { + let mut state = VoiceActivationLink::default(); + state.configure(true); + let at = Instant::now(); + state.edge(DictationHotkeyEdge::Pressed { press_id: 1, at }); + state.edge(DictationHotkeyEdge::Pressed { press_id: 2, at }); + assert!(state.edge(DictationHotkeyEdge::Released { press_id: 2, at })); + assert!(state.requested); + assert!(!state.edge(DictationHotkeyEdge::Released { press_id: 1, at })); + } + + #[test] + fn linked_clicks_are_paired_debounced_and_combination_safe() { + let mut state = VoiceActivationLink::default(); + state.configure(true); + let now = Instant::now(); + assert!(!state.requested); + assert!(!state.edge(DictationHotkeyEdge::Released { press_id: 1, at: now })); + assert!(!state.edge(DictationHotkeyEdge::Pressed { press_id: 1, at: now })); + assert!(!state.edge(DictationHotkeyEdge::Pressed { press_id: 1, at: now })); + assert!(state.edge(DictationHotkeyEdge::Released { press_id: 1, at: now })); + assert!(state.requested); + assert!(!state.edge(DictationHotkeyEdge::Released { press_id: 1, at: now })); + state.edge(DictationHotkeyEdge::Pressed { press_id: 2, at: now }); + assert!(!state.edge(DictationHotkeyEdge::Released { press_id: 2, at: now })); + let later = now + Duration::from_secs(1); + state.edge(DictationHotkeyEdge::Combined { press_id: 3, at: later }); + state.edge(DictationHotkeyEdge::Pressed { press_id: 3, at: later }); + assert!(!state.edge(DictationHotkeyEdge::Released { press_id: 3, at: later })); + state.edge(DictationHotkeyEdge::Pressed { press_id: 4, at: later }); + assert!(state.edge(DictationHotkeyEdge::Released { press_id: 4, at: later })); + assert!(!state.requested); + state.configure(false); + state.configure(true); + assert!(!state.requested); + } +} diff --git a/openless-all/app/crates/openless-core/tests/voice_activation_linked.rs b/openless-all/app/crates/openless-core/tests/voice_activation_linked.rs new file mode 100644 index 000000000..be5da5e1e --- /dev/null +++ b/openless-all/app/crates/openless-core/tests/voice_activation_linked.rs @@ -0,0 +1,384 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use openless_core::testing::{FixtureDictationEngine, FixtureTextInserter, RecordingHostActions}; +use openless_core::*; +use tokio::sync::Semaphore; + +type Task = Pin + Send + 'static>>; + +#[derive(Default)] +struct Runtime { + configured: AtomicUsize, + stopped: Arc, + paused: Mutex<(u64, bool)>, + fail: AtomicBool, + delay: Mutex>>, + engine_start_delay: Mutex>>, + engine_finish_delay: Mutex>>, + handoff_delay: Arc>>>, + handoff_started: Arc, +} +impl VoiceActivationRuntime for Runtime { + fn set_capture_paused(&self, revision: u64, paused: bool) { + let mut policy = self.paused.lock().unwrap(); + if revision >= policy.0 { + *policy = (revision, paused); + } + } + fn configure( + &self, + _: VoiceActivationConfig, + _: Arc, + ) -> Task, VoiceActivationError>> { + self.configured.fetch_add(1, Ordering::SeqCst); + let stopped = Arc::clone(&self.stopped); + let fail = self.fail.load(Ordering::SeqCst); + let delay = self.delay.lock().unwrap().take(); + let handoff_delay = self.handoff_delay.clone(); + let handoff_started = self.handoff_started.clone(); + Box::pin(async move { + if let Some(delay) = delay { + delay.acquire_owned().await.unwrap().forget(); + } + if fail { + Err(VoiceActivationError::ModelMissing) + } else { + Ok(Box::new(Handle(stopped, handoff_delay, handoff_started)) as Box) + } + }) + } +} +struct Handle(Arc, Arc>>>, Arc); +impl VoiceActivationHandle for Handle { + fn handoff_to_dictation( + &self, + _: VoiceDictationHandoffRequest, + ) -> Task> { + let delay = self.1.lock().unwrap().take(); + self.2.store(true, Ordering::SeqCst); + Box::pin(async move { + if let Some(delay) = delay { delay.acquire_owned().await.unwrap().forget(); } + Ok(VoiceDictationHandoff { capture: Box::new(Capture) }) + }) + } + fn stop(&self, _: VoiceActivationStopReason) -> Task> { + self.0.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } +} +struct Capture; +impl PendingAudioCapture for Capture { + fn commit(self: Box, _: SessionId, _: Arc, + _: Arc, _: Arc) + -> Task, BackendError>> + { + Box::pin(async { Err(BackendError::new(BackendErrorCode::Unsupported, "fixture capture")) }) + } + fn discard(self: Box) -> Task> { + Box::pin(async { Ok(()) }) + } +} +struct Engine(FixtureDictationEngine, Arc); +impl DictationEngine for Engine { + fn start_with_audio_capture(&self, id: SessionId, context: Arc, + progress: Arc, capture: Box) + -> Task> + { + let start = self.0.start(id, context, progress); + let delay = self.1.engine_start_delay.lock().unwrap().take(); + Box::pin(async move { + if let Some(delay) = delay { delay.acquire_owned().await.unwrap().forget(); } + capture.discard().await?; + start.await + }) + } + fn start(&self, id: SessionId, context: Arc, progress: Arc) + -> Task> { self.0.start(id, context, progress) } + fn finish(&self, id: SessionId, progress: Arc) + -> Task> { + let finish = self.0.finish(id, progress); + let delay = self.1.engine_finish_delay.lock().unwrap().take(); + Box::pin(async move { + if let Some(delay) = delay { delay.acquire_owned().await.unwrap().forget(); } + finish.await + }) + } + fn update_context(&self, id: SessionId, context: Arc) + -> Task> { self.0.update_context(id, context) } + fn cancel(&self, id: SessionId) -> Task> { self.0.cancel(id) } +} +struct Directory(std::path::PathBuf); +impl Drop for Directory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn fixture() -> (Directory, Arc, Arc, FixtureDictationEngine) { + let dir = Directory(std::env::temp_dir().join(format!( + "openless-voice-link-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + ))); + let runtime = Arc::new(Runtime::default()); + let engine = FixtureDictationEngine::successful("body", "body"); + let mut deps = BackendDependencies::unsupported(); + deps.voice_activation_runtime = Some(runtime.clone()); + deps.host_actions = Arc::new(RecordingHostActions::default()); + deps.text_inserter = Arc::new(FixtureTextInserter::with_outcome(InsertOutcome::Inserted)); + deps.dictation_engine = Arc::new(Engine(engine.clone(), runtime.clone())); + deps.task_spawner = Arc::new(TokioTaskSpawner); + let backend = Arc::new(OpenLessBackend::new(BackendConfig { + data_dir: dir.0.clone(), ..Default::default() + }, deps).unwrap()); + (dir, backend, runtime, engine) +} +fn edges(id: u64) -> [DictationHotkeyEdge; 2] { + let at = Instant::now() + Duration::from_secs(id); + [ + DictationHotkeyEdge::Pressed { press_id: id, at }, + DictationHotkeyEdge::Released { press_id: id, at: at + Duration::from_millis(10) }, + ] +} +async fn click(backend: &OpenLessBackend, id: u64) { + for edge in edges(id) { + backend.dispatch_dictation_hotkey_edge(edge).await.unwrap(); + } +} +fn enable(backend: &OpenLessBackend, mode: HotkeyMode) { + let mut prefs = backend.get_preferences(); + prefs.voice_activation_enabled = true; + prefs.voice_activation_hotkey_linked = true; + prefs.hotkey.mode = mode; + backend.repositories().preferences.set(prefs).unwrap(); +} + +#[test] +fn linked_preference_is_local_only_and_old_configs_default_off() { + use openless_core::cloud_sync_e2ee_documents::registry::{PREFERENCE_FIELDS, PreferenceClass}; + let old: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(!old.voice_activation_hotkey_linked); + let prefs = UserPreferences { voice_activation_hotkey_linked: true, ..old }; + let json = serde_json::to_value(&prefs).unwrap(); + assert_eq!(json["voiceActivationHotkeyLinked"], true); + let loaded: UserPreferences = serde_json::from_value(json).unwrap(); + assert!(loaded.voice_activation_hotkey_linked); + let field = PREFERENCE_FIELDS.iter().find(|f| f.key == "voiceActivationHotkeyLinked").unwrap(); + assert_eq!(field.class, PreferenceClass::Excluded); +} + +#[tokio::test] +async fn all_recording_modes_use_one_click_and_never_start_dictation() { + for mode in [HotkeyMode::Toggle, HotkeyMode::Hold, HotkeyMode::Auto, HotkeyMode::DoubleClick] { + let (_dir, backend, runtime, engine) = fixture(); + enable(&backend, mode); + backend.start().await.unwrap(); + assert_eq!(backend.latest_voice_activation_state().unwrap().phase, VoiceActivationPhase::Paused); + assert_eq!(runtime.configured.load(Ordering::SeqCst), 0); + click(&backend, 1).await; + assert_eq!(backend.latest_voice_activation_state().unwrap().phase, VoiceActivationPhase::ListeningForWake); + assert!(!runtime.paused.lock().unwrap().1); + click(&backend, 2).await; + assert_eq!(backend.latest_voice_activation_state().unwrap().phase, VoiceActivationPhase::Paused); + assert!(runtime.paused.lock().unwrap().1); + let mut prefs = backend.get_preferences(); + prefs.low_latency_dictation_enabled = true; + backend.repositories().preferences.set(prefs).unwrap(); + backend.configure_voice_activation().await.unwrap(); + assert_eq!(runtime.configured.load(Ordering::SeqCst), 1); + assert!(engine.actions().is_empty()); + assert!(backend.get_preferences().voice_activation_enabled); + assert!(backend.get_preferences().voice_activation_hotkey_linked); + backend.shutdown().await.unwrap(); + } +} + +#[tokio::test] +async fn pause_invalidates_inflight_start_without_waiting_for_the_model() { + let (_dir, backend, runtime, _) = fixture(); + enable(&backend, HotkeyMode::Hold); + backend.start().await.unwrap(); + let delay = Arc::new(Semaphore::new(0)); + *runtime.delay.lock().unwrap() = Some(delay.clone()); + for edge in edges(1) { backend.accept_voice_activation_hotkey_edge(edge); } + let start = { + let backend = backend.clone(); + tokio::spawn(async move { backend.reconcile_voice_activation_hotkey().await }) + }; + tokio::time::timeout(Duration::from_secs(5), async { + while runtime.configured.load(Ordering::SeqCst) == 0 { tokio::task::yield_now().await; } + }).await.unwrap(); + for edge in edges(2) { backend.accept_voice_activation_hotkey_edge(edge); } + assert!(runtime.paused.lock().unwrap().1); + delay.add_permits(1); + let _ = tokio::time::timeout(Duration::from_secs(5), start).await.unwrap().unwrap(); + backend.reconcile_voice_activation_hotkey().await.unwrap(); + assert_eq!(backend.latest_voice_activation_state().unwrap().phase, VoiceActivationPhase::Paused); + assert_eq!(runtime.stopped.load(Ordering::SeqCst), 1); + backend.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn failed_start_stays_paused_and_the_next_click_retries() { + let (_dir, backend, runtime, engine) = fixture(); + enable(&backend, HotkeyMode::Auto); + backend.start().await.unwrap(); + runtime.fail.store(true, Ordering::SeqCst); + backend.accept_voice_activation_hotkey_edge(edges(1)[0]); + backend.accept_voice_activation_hotkey_edge(edges(1)[1]); + assert!(backend.reconcile_voice_activation_hotkey().await.is_err()); + assert_eq!(backend.latest_voice_activation_state().unwrap().phase, VoiceActivationPhase::Failed); + assert!(runtime.paused.lock().unwrap().1); + runtime.fail.store(false, Ordering::SeqCst); + click(&backend, 2).await; + assert_eq!(runtime.configured.load(Ordering::SeqCst), 2); + assert!(!runtime.paused.lock().unwrap().1); + assert!(engine.actions().is_empty()); + backend.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn pausing_listener_does_not_stop_an_unrelated_explicit_recording() { + let (_dir, backend, _, engine) = fixture(); + enable(&backend, HotkeyMode::Toggle); + backend.start().await.unwrap(); + let id = backend.start_dictation().await.unwrap(); + click(&backend, 1).await; + click(&backend, 2).await; + assert_eq!(backend.snapshot().dictation.session_id, Some(id)); + assert_eq!(backend.snapshot().dictation.phase, DictationPhase::Recording); + assert_eq!(engine.actions().len(), 1); + backend.stop_dictation().await.unwrap(); + backend.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn pause_finalizes_voice_started_body_without_cancelling_or_rearming() { + let (_dir, backend, runtime, engine) = fixture(); + enable(&backend, HotkeyMode::Toggle); + backend.start().await.unwrap(); + click(&backend, 1).await; + let generation = backend.latest_voice_activation_state().unwrap().activation_generation; + backend.handle_voice_activation_signal(VoiceActivationSignal::WakeDetected { + activation_generation: generation, + }); + tokio::time::timeout(Duration::from_secs(5), async { + while backend.snapshot().dictation.phase != DictationPhase::Recording { + tokio::task::yield_now().await; + } + }).await.unwrap(); + click(&backend, 2).await; + assert_eq!(backend.latest_voice_activation_state().unwrap().phase, VoiceActivationPhase::Paused); + assert_eq!(backend.list_history().unwrap()[0].final_text, "body"); + assert!(runtime.paused.lock().unwrap().1); + assert_eq!(runtime.configured.load(Ordering::SeqCst), 1); + assert!(!engine.actions().iter().any(|action| + matches!(action, openless_core::testing::FixtureEngineAction::Cancel(_)))); + backend.shutdown().await.unwrap(); +} + +async fn wait_for_phase(backend: &OpenLessBackend, phase: DictationPhase) { + tokio::time::timeout(Duration::from_secs(5), async { + while backend.snapshot().dictation.phase != phase { tokio::task::yield_now().await; } + }).await.unwrap(); +} + +#[tokio::test] +async fn pause_then_resume_during_voice_start_preserves_and_finishes_the_body() { + let (_dir, backend, runtime, engine) = fixture(); + enable(&backend, HotkeyMode::Hold); + backend.start().await.unwrap(); + click(&backend, 1).await; + let delay = Arc::new(Semaphore::new(0)); + *runtime.engine_start_delay.lock().unwrap() = Some(delay.clone()); + let generation = backend.latest_voice_activation_state().unwrap().activation_generation; + backend.handle_voice_activation_signal(VoiceActivationSignal::WakeDetected { + activation_generation: generation, + }); + tokio::time::timeout(Duration::from_secs(5), async { + while engine.actions().is_empty() { tokio::task::yield_now().await; } + }).await.unwrap(); + assert_eq!(backend.snapshot().dictation.phase, DictationPhase::Starting); + for edge in edges(2) { backend.accept_voice_activation_hotkey_edge(edge); } + assert!(runtime.paused.lock().unwrap().1); + for edge in edges(3) { backend.accept_voice_activation_hotkey_edge(edge); } + assert!(!runtime.paused.lock().unwrap().1); + let reconciliation = { + let backend = backend.clone(); + tokio::spawn(async move { backend.reconcile_voice_activation_hotkey().await }) + }; + delay.add_permits(1); + tokio::time::timeout(Duration::from_secs(5), reconciliation).await.unwrap().unwrap().unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while backend.list_history().unwrap().is_empty() + || backend.latest_voice_activation_state().unwrap().phase != VoiceActivationPhase::ListeningForWake + { tokio::task::yield_now().await; } + }).await.unwrap(); + assert_eq!(backend.list_history().unwrap()[0].final_text, "body"); + assert!(!engine.actions().iter().any(|action| + matches!(action, openless_core::testing::FixtureEngineAction::Cancel(_)))); + backend.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn pausing_an_already_finalizing_voice_body_restores_durable_paused_status() { + let (_dir, backend, runtime, _) = fixture(); + enable(&backend, HotkeyMode::Toggle); + backend.start().await.unwrap(); + click(&backend, 1).await; + let generation = backend.latest_voice_activation_state().unwrap().activation_generation; + backend.handle_voice_activation_signal(VoiceActivationSignal::WakeDetected { + activation_generation: generation, + }); + wait_for_phase(&backend, DictationPhase::Recording).await; + let delay = Arc::new(Semaphore::new(0)); + *runtime.engine_finish_delay.lock().unwrap() = Some(delay.clone()); + let finish = { + let backend = backend.clone(); + tokio::spawn(async move { backend.stop_dictation().await }) + }; + wait_for_phase(&backend, DictationPhase::Transcribing).await; + click(&backend, 2).await; + delay.add_permits(1); + tokio::time::timeout(Duration::from_secs(5), finish).await.unwrap().unwrap().unwrap(); + assert_eq!(backend.latest_voice_activation_state().unwrap().phase, VoiceActivationPhase::Paused); + assert_eq!(backend.list_history().unwrap()[0].final_text, "body"); + assert!(runtime.paused.lock().unwrap().1); + backend.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn pause_preserves_a_staged_body_before_handoff_has_a_formal_session() { + let (_dir, backend, runtime, engine) = fixture(); + enable(&backend, HotkeyMode::Toggle); + backend.start().await.unwrap(); + click(&backend, 1).await; + let delay = Arc::new(Semaphore::new(0)); + *runtime.handoff_delay.lock().unwrap() = Some(delay.clone()); + let generation = backend.latest_voice_activation_state().unwrap().activation_generation; + backend.handle_voice_activation_signal(VoiceActivationSignal::WakeDetected { + activation_generation: generation, + }); + tokio::time::timeout(Duration::from_secs(5), async { + while !runtime.handoff_started.load(Ordering::SeqCst) { tokio::task::yield_now().await; } + }).await.unwrap(); + assert!(backend.snapshot().dictation.session_id.is_none()); + click(&backend, 2).await; + assert!(runtime.paused.lock().unwrap().1); + assert!(backend.voice_activation_generation_is_current(generation)); + assert_eq!(runtime.stopped.load(Ordering::SeqCst), 0); + delay.add_permits(1); + tokio::time::timeout(Duration::from_secs(5), async { + while backend.list_history().unwrap().is_empty() + || backend.latest_voice_activation_state().unwrap().phase != VoiceActivationPhase::Paused + { tokio::task::yield_now().await; } + }).await.unwrap(); + assert_eq!(backend.list_history().unwrap()[0].final_text, "body"); + assert!(!engine.actions().iter().any(|action| + matches!(action, openless_core::testing::FixtureEngineAction::Cancel(_)))); + assert_eq!(runtime.configured.load(Ordering::SeqCst), 1); + backend.shutdown().await.unwrap(); +} diff --git a/openless-all/app/e2e/settings-regressions.spec.ts b/openless-all/app/e2e/settings-regressions.spec.ts index d0bf5de97..646857e7f 100644 --- a/openless-all/app/e2e/settings-regressions.spec.ts +++ b/openless-all/app/e2e/settings-regressions.spec.ts @@ -288,3 +288,30 @@ test('voice activation opens model setup without leaving the multimodal pipeline await categories.getByRole('button', { name: 'AI 服务与模型', exact: true }).click(); await expect(models).toBeDisabled(); }); + +test('Windows wake shortcut linkage is opt-in, keyboard accessible and preserves recording mode', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'platform', { get: () => 'Win32' }); + Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }); + }); + await page.goto('http://127.0.0.1:5326/?os=win'); + await page.getByRole('button', { name: '设置', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '设置', exact: true }); + await dialog.getByRole('button', { name: '录音与输入', exact: true }).click(); + const linkage = dialog.getByRole('switch', { name: '与录音快捷键联动', exact: true }); + await expect(linkage).toHaveCount(0); + await dialog.getByRole('switch', { name: '语音唤醒听写', exact: true }).click(); + await expect(linkage).not.toBeChecked(); + const mode = await page.evaluate(async () => (await import('/src/lib/ipc/mock-data.ts')).mockSettings.hotkey.mode); + await linkage.focus(); + await page.keyboard.press('Space'); + await expect(linkage).toBeChecked(); + await expect(dialog.getByText('已临时暂停唤醒监听', { exact: true })).toBeVisible(); + expect(await page.evaluate(async () => (await import('/src/lib/ipc/mock-data.ts')).mockSettings.hotkey.mode)).toBe(mode); + await dialog.getByRole('button', { name: 'AI 服务与模型', exact: true }).click(); + await dialog.getByRole('button', { name: '录音与输入', exact: true }).click(); + await expect(linkage).toBeChecked(); + await linkage.click(); + await expect(linkage).not.toBeChecked(); + expect(await page.evaluate(async () => (await import('/src/lib/ipc/mock-data.ts')).mockSettings.hotkey.mode)).toBe(mode); +}); diff --git a/openless-all/app/src-tauri/backend-tests/tests/voice_activation_linked.rs b/openless-all/app/src-tauri/backend-tests/tests/voice_activation_linked.rs new file mode 100644 index 000000000..36a1be8dc --- /dev/null +++ b/openless-all/app/src-tauri/backend-tests/tests/voice_activation_linked.rs @@ -0,0 +1,3 @@ +// Execute the same framework-independent lifecycle regressions on Windows runners. +#[path = "../../../crates/openless-core/tests/voice_activation_linked.rs"] +mod linked; diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 2b869abce..6c971cda6 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -8,6 +8,11 @@ pub fn get_settings(core: CoreState<'_>) -> UserPreferences { prefs } +#[tauri::command] +pub fn get_voice_activation_state(core: CoreState<'_>) -> Option { + core.latest_voice_activation_state() +} + #[tauri::command] pub fn get_default_style_system_prompts() -> StyleSystemPrompts { StyleSystemPrompts::default() @@ -241,6 +246,7 @@ pub(crate) fn sync_low_latency_capture_preferences(_prefs: &UserPreferences) {} fn voice_activation_preferences_changed(previous: &UserPreferences, next: &UserPreferences) -> bool { previous.voice_activation_enabled != next.voice_activation_enabled + || previous.voice_activation_hotkey_linked != next.voice_activation_hotkey_linked || previous.voice_activation_wake_enabled != next.voice_activation_wake_enabled || previous.voice_activation_finish_enabled != next.voice_activation_finish_enabled || previous.voice_activation_wake_phrase != next.voice_activation_wake_phrase diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_core.rs b/openless-all/app/src-tauri/src/coordinator/dictation_core.rs index f5b080ae3..44eda3174 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_core.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_core.rs @@ -51,6 +51,17 @@ async fn dispatch( if !inner.backend.snapshot().running { inner.backend.start().await?; } + if let Some(changed) = inner.backend.accept_voice_activation_hotkey_edge(edge) { + if changed { + let backend = Arc::clone(&inner.backend); + inner.host.spawn(async move { + if let Err(error) = backend.reconcile_voice_activation_hotkey().await { + log::warn!("[voice-activation] linked hotkey failed: {error}"); + } + }); + } + return Ok(openless_core::CliDispatchOutcome::Noop); + } inner.backend.dispatch_dictation_hotkey_edge(edge).await } diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 8799a6b6a..cbf215f6f 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -797,7 +797,7 @@ pub(super) fn less_computer_modifier_bridge_loop( handle_less_computer_modifier_pressed(&inner_cloned, press_id, at).await }); } - HotkeyEvent::Released { press_id, at } => { + HotkeyEvent::Released { press_id, at, .. } => { if inner.less_computer_press_generation.load(Ordering::SeqCst) != press_id { continue; } @@ -1457,7 +1457,9 @@ pub(super) fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver, rx: mpsc::Receiver, rx: mpsc::Receiver, rx: mpsc::Receiver { + HotkeyEvent::Released { at, press_id, combined } => { + if combined { + // The abort bridge may lag this FIFO. Suppress linked clicks + // using the verdict captured before native key-up was sent. + inner_cloned.backend.accept_voice_activation_hotkey_edge( + openless_core::DictationHotkeyEdge::Combined { at, press_id }); + } if matches!( hotkey_runtime_target(&inner_cloned).dictation_mode, crate::types::HotkeyMode::Hold - ) { + ) && !(inner_cloned.backend.get_preferences().voice_activation_enabled + && inner_cloned.backend.get_preferences().voice_activation_hotkey_linked) + { let task_inner = Arc::clone(&inner_cloned); let _ = inner_cloned.host.spawn(async move { handle_released_edge(&task_inner, at, press_id).await; @@ -2990,6 +3004,7 @@ pub(crate) mod windows_less_computer_tests { tx.send(HotkeyEvent::Released { at: released, press_id: 47, + combined: false, }) .unwrap(); drop(tx); diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index b395a9efd..a8cd48ee0 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -140,6 +140,8 @@ pub(crate) fn backend_dependencies( #[cfg(target_os = "windows")] { let prefs = preferences.get(); + warm_hub.set_capture_paused(0, + prefs.voice_activation_enabled && prefs.voice_activation_hotkey_linked); warm_hub.configure( crate::recorder::warm_capture_enabled( prefs.low_latency_dictation_enabled, @@ -1927,6 +1929,10 @@ impl TauriVoiceActivationRuntime { #[cfg(target_os = "windows")] impl openless_core::VoiceActivationRuntime for TauriVoiceActivationRuntime { + fn set_capture_paused(&self, revision: u64, paused: bool) { + self.warm_hub.set_capture_paused(revision, paused); + } + fn configure( &self, config: VoiceActivationConfig, diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index b04198e96..6e7eba79f 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -46,6 +46,8 @@ pub enum HotkeyEvent { Released { at: Instant, press_id: u64, + /// Native per-press verdict, independent of the asynchronous abort bridge. + combined: bool, }, // Combo abort is not in this enum: it goes through the separate `combo_abort` channel to avoid // being queued behind the Pressed → begin_session synchronous mic-open flow (see module docs). @@ -929,7 +931,8 @@ mod platform { ctx.shared.trigger_held.store(false, Ordering::SeqCst); let press_id = ctx.shared.trigger_press_id.swap(0, Ordering::SeqCst); if !tap_only_fn { - send_or_log(&ctx.tx, HotkeyEvent::Released { at: now, press_id }); + send_or_log(&ctx.tx, HotkeyEvent::Released { at: now, press_id, + combined: ctx.shared.trigger_companion_seen.load(Ordering::SeqCst) == press_id }); return; } @@ -946,7 +949,8 @@ mod platform { press_id, }, ); - send_or_log(&ctx.tx, HotkeyEvent::Released { at: now, press_id }); + send_or_log(&ctx.tx, HotkeyEvent::Released { at: now, press_id, + combined: ctx.shared.trigger_companion_seen.load(Ordering::SeqCst) == press_id }); } else { log::info!( "[hotkey] Fn 操作未触发听写(held_ms={}, companion_seen={companion_seen})", @@ -1814,6 +1818,7 @@ mod platform { HotkeyEvent::Released { at: std::time::Instant::now(), press_id, + combined: ctx.shared.trigger_companion_seen.load(Ordering::SeqCst) == press_id, }, ); } @@ -2130,6 +2135,23 @@ mod platform { ); } + #[test] + fn windows_release_carries_combo_verdict_even_when_abort_bridge_has_not_drained() { + let (ctx, rx, abort_rx) = callback_context_with_combo(shared(HotkeyTrigger::RightControl)); + dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN); + let press_id = match rx.recv().unwrap() { + HotkeyEvent::Pressed { press_id, .. } => press_id, + other => panic!("expected press, got {other:?}"), + }; + dispatch_keyboard_event(&ctx, 0x41, WM_KEYDOWN); + dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP); + // Intentionally read Released before the independently queued Combined. + assert!(matches!(rx.recv().unwrap(), HotkeyEvent::Released { + press_id: released_id, combined: true, .. + } if released_id == press_id)); + assert_eq!(abort_rx.recv().unwrap().press_id, press_id); + } + #[test] fn windows_modifier_edges_are_deduped_from_mock_hook_events() { let shared = shared(HotkeyTrigger::RightControl); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index e0cf76be2..a8c782655 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -263,6 +263,7 @@ macro_rules! app_invoke_handler_desktop { commands::set_capsule_language_menu, commands::get_settings, commands::get_settings_snapshot, + commands::get_voice_activation_state, commands::update_setting_fields, commands::get_default_style_system_prompts, commands::set_settings, @@ -561,6 +562,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::get_startup_snapshot, $crate::commands::get_settings, $crate::commands::get_settings_snapshot, + $crate::commands::get_voice_activation_state, $crate::commands::update_setting_fields, $crate::commands::get_default_style_system_prompts, $crate::commands::set_settings, diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index 40cf087d1..63f4e1f6d 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -962,6 +962,8 @@ pub(crate) struct WarmCaptureHub { runtime_errors: Mutex>>, runtime_fallback_claims: Mutex>, enabled: AtomicBool, + standby_policy: Mutex<(u64, bool)>, + standby_requested: AtomicBool, preview_active: AtomicBool, cold_capture_active: AtomicBool, pending_configuration: @@ -980,6 +982,8 @@ impl WarmCaptureHub { runtime_errors: Mutex::new(HashMap::new()), runtime_fallback_claims: Mutex::new(HashSet::new()), enabled: AtomicBool::new(false), + standby_policy: Mutex::new((0, false)), + standby_requested: AtomicBool::new(false), preview_active: AtomicBool::new(false), cold_capture_active: AtomicBool::new(false), pending_configuration: Mutex::new(None), @@ -1073,6 +1077,21 @@ impl WarmCaptureHub { } } + /// A linked pause overrides idle prewarming, never a promoted explicit recording. + pub(crate) fn set_capture_paused(&self, revision: u64, paused: bool) { + let _lifecycle = self.lifecycle_gate.lock(); + { + let mut policy = self.standby_policy.lock(); + if revision < policy.0 || *policy == (revision, paused) { + return; + } + *policy = (revision, paused); + } + let device = self.device_name.lock().clone(); + let processing = *self.processing_config.lock(); + self.configure_locked(self.standby_requested.load(Ordering::Acquire), device, processing); + } + pub(crate) fn configure( &self, enabled: bool, @@ -1080,6 +1099,7 @@ impl WarmCaptureHub { processing: openless_core::AudioProcessingConfig, ) { let _lifecycle = self.lifecycle_gate.lock(); + self.standby_requested.store(enabled, Ordering::Release); self.configure_locked(enabled, device_name, processing); } @@ -1089,6 +1109,8 @@ impl WarmCaptureHub { device_name: Option, processing: openless_core::AudioProcessingConfig, ) { + let paused = self.standby_policy.lock().1; + let enabled = enabled && !paused; let device_changed = *self.device_name.lock() != device_name; let config_changed = *self.processing_config.lock() != processing; if self.cold_capture_active.load(Ordering::Acquire) { @@ -1096,6 +1118,12 @@ impl WarmCaptureHub { return; } if (device_changed || config_changed || !enabled) && self.sink.has_claims() { + // A staged handoff already owns body PCM. Let it promote and finalize; + // invalidating it would lose the prefix and reopen a cold microphone. + if paused { + *self.pending_configuration.lock() = Some((enabled, device_name, processing)); + return; + } let staging_claims = self.sink.staging_claim_ids(); self.runtime_fallback_claims.lock().extend(staging_claims); *self.pending_configuration.lock() = Some((enabled, device_name, processing)); @@ -1152,7 +1180,9 @@ impl WarmCaptureHub { } fn ensure_started_locked(&self, device_name: Option) -> Result<(), RecorderError> { - if !self.enabled.load(Ordering::Acquire) { + if !self.enabled.load(Ordering::Acquire) + || (self.standby_policy.lock().1 && !self.sink.has_promoted_claims()) + { return Err(RecorderError::EngineFailed( "warm capture is disabled".into(), )); @@ -1323,6 +1353,9 @@ impl WarmCaptureHub { } loop { let lifecycle = self.lifecycle_gate.lock(); + if self.standby_policy.lock().1 { + return Err(RecorderError::EngineFailed("standby capture is paused".into())); + } let desired_processing = processing.unwrap_or(*self.processing_config.lock()); if *self.device_name.lock() != device_name || *self.processing_config.lock() != desired_processing @@ -3254,6 +3287,60 @@ mod tests { assert!(!warm_capture_enabled(false, true, false)); } + #[test] + fn linked_pause_overrides_prewarm_saved_configuration_and_cold_recovery() { + let hub = WarmCaptureHub::new(); + hub.set_capture_paused(1, true); + hub.configure(true, None, openless_core::AudioProcessingConfig::default()); + assert!(!hub.enabled.load(Ordering::Acquire)); + assert!(hub.recorder.lock().is_none()); + hub.begin_cold_capture().unwrap(); + hub.configure(true, None, openless_core::AudioProcessingConfig::default()); + hub.end_cold_capture(); + hub.resume_after_cold_capture(); + assert!(!hub.enabled.load(Ordering::Acquire)); + assert!(hub.recorder.lock().is_none()); + hub.set_capture_paused(0, false); + assert!(hub.standby_policy.lock().1); + assert!(hub.arm(99, None, openless_core::AudioProcessingConfig::default()).is_err()); + } + + #[test] + fn linked_pause_keeps_staged_voice_body_until_promotion_and_finalization() { + let hub = WarmCaptureHub::new(); + let key = voice_capture_key(9); + let consumer = Arc::new(RecordingConsumer::default()); + let body = [0x00, 0x40].repeat(160); + hub.sink.arm_with_limit(key, false, STAGED_CAPTURE_MAX_BYTES).unwrap(); + hub.sink.consume_pcm_chunk(&body); + hub.enabled.store(true, Ordering::Release); + hub.set_capture_paused(1, true); + hub.configure(true, None, openless_core::AudioProcessingConfig::default()); + assert!(hub.sink.staging_claim_ids().contains(&key)); + assert!(!hub.runtime_fallback_claims.lock().contains(&key)); + hub.sink.promote(key, consumer.clone(), Arc::new(|_| {}), None).unwrap(); + assert_eq!(consumer.chunks.lock().unwrap().concat(), body); + hub.release_claim(key); + assert!(!hub.enabled.load(Ordering::Acquire)); + assert!(hub.recorder.lock().is_none()); + } + + #[test] + fn linked_pause_preserves_promoted_recording_until_its_claim_releases() { + let hub = WarmCaptureHub::new(); + let consumer = Arc::new(RecordingConsumer::default()); + hub.sink.arm_with_limit(7, false, STAGED_CAPTURE_MAX_BYTES).unwrap(); + hub.sink.promote(7, consumer, Arc::new(|_| {}), None).unwrap(); + hub.enabled.store(true, Ordering::Release); + hub.set_capture_paused(1, true); + assert!(hub.sink.has_promoted_claims()); + hub.configure(true, None, openless_core::AudioProcessingConfig::default()); + assert!(hub.sink.has_promoted_claims()); + hub.release_claim(7); + assert!(!hub.enabled.load(Ordering::Acquire)); + assert!(hub.recorder.lock().is_none()); + } + #[test] fn activation_vad_skips_silence_and_preserves_speech_onset() { let sink = WarmCaptureSink::new(STAGED_CAPTURE_MAX_BYTES); diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs index 859110c23..072f5d9ec 100644 --- a/openless-all/app/src-tauri/src/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -125,10 +125,11 @@ impl SideAwareComboState { fn release_active(&mut self) -> Option { let press_id = self.active_press_id.take()?; - self.companion_seen = false; + let combined = std::mem::take(&mut self.companion_seen); Some(HotkeyEvent::Released { at: Instant::now(), press_id, + combined, }) } diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 83ca7fdbd..c39647a5d 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1690,6 +1690,15 @@ export const de: typeof zhCN = { voiceActivationDesc: 'Lokal auf eine Aktivierungsphrase warten, um das Diktat zu starten.', voiceActivationLocalOnly: 'Die Erkennung bleibt auf diesem Gerät; Bereitschaftsaudio wird weder hochgeladen noch archiviert.', + voiceActivationHotkeyLinked: "Aufnahme-Tastenkürzel verknüpfen", + voiceActivationHotkeyLinkedDesc: "Ein Klick schaltet die lokale Aktivierung statt der Aufnahme um. Nach dem Start pausiert. Pausieren beendet die Mikrofon-Vorwärmung, nicht andere Aufnahmen. Diktate nutzen weiterhin den gewählten Dienst.", + voiceActivationDisabled: "Sprachaktivierung deaktiviert", + voiceActivationPaused: "Aktivierung vorübergehend pausiert", + voiceActivationPausing: "Aktivierung wird gestoppt…", + voiceActivationStarting: "Aktivierung wird gestartet…", + voiceActivationListening: "Warten auf Aktivierungsphrase", + voiceActivationDictating: "Diktat oder Abschluss läuft", + voiceActivationError: "Aktivierung nicht verfügbar. Modell, Mikrofonberechtigung und Gerät prüfen.", voiceActivationMultimodalModels: 'Hier das Sprachaktivierungsmodell vorbereiten. Das Diktat nutzt weiterhin den aktuellen multimodalen Dienst.', voiceActivationModelMissing: 'Das lokale Zipformer-Modell ist nicht installiert.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index cabdfbd89..52a6f767b 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1658,6 +1658,15 @@ export const en: typeof zhCN = { voiceActivationDesc: 'Listen locally for a wake phrase to start dictation.', voiceActivationLocalOnly: 'Recognition stays on this device; standby audio is not uploaded or archived.', + voiceActivationHotkeyLinked: "Link recording shortcut", + voiceActivationHotkeyLinkedDesc: "Click the recording shortcut to toggle wake listening instead of the usual recording gesture. Starts paused after launch. Pausing disables idle microphone prewarming, not other recordings. Wake listening is local; dictation still uses your current service.", + voiceActivationDisabled: "Voice activation disabled", + voiceActivationPaused: "Wake listening temporarily paused", + voiceActivationPausing: "Stopping wake listening…", + voiceActivationStarting: "Starting wake listening…", + voiceActivationListening: "Listening for the wake phrase", + voiceActivationDictating: "Dictating or finishing", + voiceActivationError: "Unable to start wake listening. Check the model, microphone permissions and device.", voiceActivationMultimodalModels: 'Prepare the voice activation model here. Dictation still uses your current multimodal service.', voiceActivationModelMissing: 'The local Zipformer model is not installed.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 0378847ea..b470baabe 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1682,6 +1682,15 @@ export const es: typeof zhCN = { voiceActivationDesc: 'Escucha localmente una frase de activación para iniciar el dictado.', voiceActivationLocalOnly: 'El reconocimiento permanece en este dispositivo; el audio de espera no se sube ni se archiva.', + voiceActivationHotkeyLinked: "Vincular atajo de grabación", + voiceActivationHotkeyLinkedDesc: "Pulsa el atajo para activar o pausar la escucha en vez de grabar. Inicia en pausa. Pausar desactiva el precalentamiento del micrófono, no otras grabaciones. La escucha es local; el dictado usa el servicio actual.", + voiceActivationDisabled: "Activación por voz desactivada", + voiceActivationPaused: "Escucha en pausa temporal", + voiceActivationPausing: "Deteniendo la escucha…", + voiceActivationStarting: "Iniciando la escucha…", + voiceActivationListening: "Esperando la frase de activación", + voiceActivationDictating: "Dictando o finalizando", + voiceActivationError: "No se puede iniciar la escucha. Revisa el modelo, los permisos y el micrófono.", voiceActivationMultimodalModels: 'Prepara aquí el modelo de activación por voz. El dictado sigue usando el servicio multimodal actual.', voiceActivationModelMissing: 'El modelo local Zipformer no está instalado.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index b9487f656..be254312b 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1703,6 +1703,15 @@ export const fr: typeof zhCN = { voiceActivationDesc: 'Écouter localement une phrase d’activation pour démarrer la dictée.', voiceActivationLocalOnly: 'La reconnaissance reste sur cet appareil ; l’audio en veille n’est ni envoyé ni archivé.', + voiceActivationHotkeyLinked: "Lier le raccourci d’enregistrement", + voiceActivationHotkeyLinkedDesc: "Un appui active ou suspend la veille au lieu d’enregistrer. En pause au lancement. La pause coupe le préchauffage du micro, pas les autres enregistrements. La veille est locale ; la dictée utilise le service actuel.", + voiceActivationDisabled: "Activation vocale désactivée", + voiceActivationPaused: "Veille vocale temporairement suspendue", + voiceActivationPausing: "Arrêt de la veille…", + voiceActivationStarting: "Démarrage de la veille…", + voiceActivationListening: "En attente de la phrase d’activation", + voiceActivationDictating: "Dictée ou finalisation en cours", + voiceActivationError: "Veille indisponible. Vérifiez le modèle, les autorisations et le microphone.", voiceActivationMultimodalModels: 'Préparez ici le modèle d’activation vocale. La dictée utilise toujours votre service multimodal actuel.', voiceActivationModelMissing: 'Le modèle local Zipformer n’est pas installé.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c1d09e207..07e5ea03b 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1645,6 +1645,15 @@ export const ja: typeof zhCN = { voiceActivationDesc: '起動フレーズをローカルで待ち受けて音声入力を開始します。', voiceActivationLocalOnly: '認識はこの端末内で行われます。待機中の音声は送信・保存されません。', + voiceActivationHotkeyLinked: "録音ショートカットと連動", + voiceActivationHotkeyLinkedDesc: "録音操作の代わりに、1回押すとウェイク監視を開始・一時停止します。起動直後は一時停止します。停止中はマイクの待機予熱を無効にしますが、他の録音は中断しません。監視はローカルで、音声入力は現在のサービスを使用します。", + voiceActivationDisabled: "音声起動は無効です", + voiceActivationPaused: "ウェイク監視を一時停止中", + voiceActivationPausing: "ウェイク監視を停止しています…", + voiceActivationStarting: "ウェイク監視を開始しています…", + voiceActivationListening: "開始フレーズを待機中", + voiceActivationDictating: "音声入力または終了処理中", + voiceActivationError: "監視を開始できません。モデル、マイク権限、デバイスを確認してください。", voiceActivationMultimodalModels: 'ここで音声起動用モデルを準備します。音声入力には引き続き現在のマルチモーダルサービスを使用します。', voiceActivationModelMissing: 'ローカル Zipformer モデルがインストールされていません。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index dff252b94..c7ebfe846 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1632,6 +1632,15 @@ export const ko: typeof zhCN = { voiceActivationDesc: '받아쓰기 시작 구문을 로컬에서 감지합니다.', voiceActivationLocalOnly: '인식은 이 기기에서만 수행되며 대기 오디오는 업로드하거나 보관하지 않습니다.', + voiceActivationHotkeyLinked: "녹음 단축키와 연동", + voiceActivationHotkeyLinkedDesc: "기존 녹음 동작 대신 한 번 눌러 깨우기 대기를 시작하거나 일시 중지합니다. 앱 시작 시에는 중지됩니다. 중지하면 대기 마이크 예열을 끄지만 다른 녹음은 중단하지 않습니다. 깨우기는 로컬에서 처리하며 받아쓰기는 현재 서비스를 사용합니다.", + voiceActivationDisabled: "음성 활성화 꺼짐", + voiceActivationPaused: "깨우기 대기 일시 중지됨", + voiceActivationPausing: "깨우기 대기 중지 중…", + voiceActivationStarting: "깨우기 대기 시작 중…", + voiceActivationListening: "깨우기 명령 대기 중", + voiceActivationDictating: "받아쓰기 또는 마무리 중", + voiceActivationError: "대기를 시작할 수 없습니다. 모델, 마이크 권한 및 장치를 확인하세요.", voiceActivationMultimodalModels: '여기에서 음성 활성화 모델을 준비합니다. 받아쓰기는 현재 멀티모달 서비스를 계속 사용합니다.', voiceActivationModelMissing: '로컬 Zipformer 모델이 설치되지 않았습니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 6b92f58ea..0e428140c 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1502,6 +1502,15 @@ export const zhCN = { '仅在 Windows 桌面端启用本地语音命令唤醒听写。待机音频只在内存中处理,不会上传或保存;会增加功耗并保持麦克风指示灯亮起。', voiceActivationLocalOnly: '本地识别:说“开始语音录入”开始,说“完成语音录入”结束。需要已安装本地 Zipformer 模型。', + voiceActivationHotkeyLinked: "与录音快捷键联动", + voiceActivationHotkeyLinkedDesc: "单击录音快捷键开启或暂停唤醒监听,替代原录音手势。启动应用后先暂停;暂停时关闭后台预热,其他正在录音的任务不受影响。等待唤醒仅本地处理,正式听写仍使用当前服务。", + voiceActivationDisabled: "已关闭语音唤醒", + voiceActivationPaused: "已临时暂停唤醒监听", + voiceActivationPausing: "正在停止唤醒监听…", + voiceActivationStarting: "正在启动唤醒监听…", + voiceActivationListening: "正在等待唤醒命令", + voiceActivationDictating: "正在听写或收尾", + voiceActivationError: "无法启动唤醒监听,请检查模型、麦克风权限和设备。", voiceActivationMultimodalModels: '此处准备语音唤醒模型,听写仍使用当前多模态服务。', voiceActivationModelMissing: '请先在“AI 服务与模型”的本地模型页准备 Zipformer。', voiceActivationDownloadZipformer: '前往 AI 服务与模型下载 Zipformer', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 533136f34..221c3e833 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1573,6 +1573,15 @@ export const zhTW: typeof zhCN = { voiceActivationLabel: '語音啟用', voiceActivationDesc: '在本機等待喚醒詞以開始聽寫。', voiceActivationLocalOnly: '辨識只在此裝置上進行;待機音訊不會上傳或封存。', + voiceActivationHotkeyLinked: "與錄音快捷鍵聯動", + voiceActivationHotkeyLinkedDesc: "單擊錄音快捷鍵開啟或暫停喚醒監聽,取代原錄音手勢。啟動應用程式後先暫停;暫停時關閉背景預熱,其他錄音工作不受影響。等待喚醒僅在本機處理,正式聽寫仍使用目前服務。", + voiceActivationDisabled: "語音喚醒已關閉", + voiceActivationPaused: "喚醒監聽已暫停", + voiceActivationPausing: "正在停止喚醒監聽…", + voiceActivationStarting: "正在啟動喚醒監聽…", + voiceActivationListening: "正在等待喚醒命令", + voiceActivationDictating: "正在聽寫或完成處理", + voiceActivationError: "無法啟動喚醒監聽,請檢查模型、麥克風權限與裝置。", voiceActivationMultimodalModels: '在此準備語音喚醒模型,聽寫仍使用目前的多模態服務。', voiceActivationModelMissing: '請先在「AI 服務與模型」的本機模型頁準備 Zipformer。', voiceActivationDownloadZipformer: '前往 AI 服務與模型下載 Zipformer', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index a9dea163f..1c1a00882 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -52,6 +52,7 @@ export let mockSettings: UserPreferences = { lowMemoryBackgroundEnabled: false, audioEnhancementEnabled: false, voiceActivationEnabled: false, + voiceActivationHotkeyLinked: false, voiceActivationWakeEnabled: true, voiceActivationFinishEnabled: true, voiceActivationWakePhrase: '开始语音录入', diff --git a/openless-all/app/src/lib/ipc/settings.ts b/openless-all/app/src/lib/ipc/settings.ts index fc48e1ba1..dc7b790c1 100644 --- a/openless-all/app/src/lib/ipc/settings.ts +++ b/openless-all/app/src/lib/ipc/settings.ts @@ -1,3 +1,4 @@ +import type { VoiceActivationStateSnapshot } from '../voiceActivation'; import type { NativeCapsuleStatus, StyleSystemPrompts, UserPreferences } from '../types'; export type { UpdateChannel } from '../types'; import { @@ -73,3 +74,11 @@ export function getNativeCapsuleStatus(): Promise { error: null, })); } + +export function getVoiceActivationState(): Promise { + return invokeOrMock('get_voice_activation_state', undefined, () => + mockSettings.voiceActivationEnabled && mockSettings.voiceActivationHotkeyLinked + ? { phase: 'paused' as const, activationGeneration: 0 } + : null, + ); +} diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index e3f0f0e66..1f5f5b7d1 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -397,6 +397,7 @@ export interface UserPreferences { lowMemoryBackgroundEnabled: boolean; /** Enable local-only voice activation while idle (Windows desktop only). */ voiceActivationEnabled: boolean; + voiceActivationHotkeyLinked: boolean; voiceActivationWakeEnabled: boolean; voiceActivationFinishEnabled: boolean; voiceActivationWakePhrase: string; diff --git a/openless-all/app/src/lib/voiceActivation.test.ts b/openless-all/app/src/lib/voiceActivation.test.ts index 46513bb06..4edd5746a 100644 --- a/openless-all/app/src/lib/voiceActivation.test.ts +++ b/openless-all/app/src/lib/voiceActivation.test.ts @@ -1,5 +1,6 @@ import { isVoiceActivationModelMissing, + voiceActivationStatusKey, VOICE_ACTIVATION_MODEL_MISSING_MESSAGE, } from './voiceActivation'; @@ -29,6 +30,8 @@ assertEqual( ); for (const phase of [ 'disabled', + 'paused', + 'pausing', 'starting', 'listening_for_wake', 'listening_for_finish', @@ -41,3 +44,16 @@ for (const phase of [ } assertEqual(isVoiceActivationModelMissing(null), false, 'null state is ignored'); assertEqual(isVoiceActivationModelMissing(undefined), false, 'missing state is ignored'); + +for (const [phase, key] of [ + ['paused', 'voiceActivationPaused'], + ['pausing', 'voiceActivationPausing'], + ['listening_for_wake', 'voiceActivationListening'], + ['failed', 'voiceActivationError'], + ['permission_denied', 'voiceActivationError'], + ['mic_busy', 'voiceActivationError'], +] as const) { + if (voiceActivationStatusKey({ phase }) !== 'settings.recording.' + key) { + throw new Error('wrong persistent status for ' + phase); + } +} diff --git a/openless-all/app/src/lib/voiceActivation.ts b/openless-all/app/src/lib/voiceActivation.ts index 16207d98c..5048e4b4c 100644 --- a/openless-all/app/src/lib/voiceActivation.ts +++ b/openless-all/app/src/lib/voiceActivation.ts @@ -1,5 +1,7 @@ export type VoiceActivationPhase = | 'disabled' + | 'paused' + | 'pausing' | 'starting' | 'listening_for_wake' | 'wake_detected' @@ -28,3 +30,18 @@ export function isVoiceActivationModelMissing( snapshot?.phase === 'failed' && snapshot.message === VOICE_ACTIVATION_MODEL_MISSING_MESSAGE ); } + +export function voiceActivationStatusKey(snapshot: VoiceActivationStateSnapshot): string { + switch (snapshot.phase) { + case 'disabled': return 'settings.recording.voiceActivationDisabled'; + case 'paused': return 'settings.recording.voiceActivationPaused'; + case 'pausing': return 'settings.recording.voiceActivationPausing'; + case 'starting': + case 'wake_detected': + case 'handing_off': return 'settings.recording.voiceActivationStarting'; + case 'listening_for_wake': return 'settings.recording.voiceActivationListening'; + case 'listening_for_finish': + case 'finishing': return 'settings.recording.voiceActivationDictating'; + default: return 'settings.recording.voiceActivationError'; + } +} diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 23373d44f..db05f2480 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -22,10 +22,11 @@ import { import { isTauri, listMicrophoneDevices, setDictationHotkey } from '../../lib/ipc'; import { isVoiceActivationModelMissing, + voiceActivationStatusKey, VOICE_ACTIVATION_MODEL_ALIAS, type VoiceActivationStateSnapshot, } from '../../lib/voiceActivation'; -import { getNativeCapsuleStatus } from '../../lib/ipc/settings'; +import { getNativeCapsuleStatus, getVoiceActivationState } from '../../lib/ipc/settings'; import { getPlatformCapabilities, getCachedPlatformCapabilities } from '../../lib/platform'; import type { NativeCapsuleStatus, @@ -85,6 +86,7 @@ export function RecordingInputSection({ const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false); const [microphoneDevicesError, setMicrophoneDevicesError] = useState(null); const [voiceActivationModelMissing, setVoiceActivationModelMissing] = useState(false); + const [voiceActivationState, setVoiceActivationState] = useState(null); const voiceActivationStateObservedRef = useRef(false); const [displayModeRestartPending, setDisplayModeRestartPending] = useState(false); const [displayModeRestarting, setDisplayModeRestarting] = useState(false); @@ -162,6 +164,10 @@ export function RecordingInputSection({ 'voice-activation:state-changed', (event) => { voiceActivationStateObservedRef.current = true; + setVoiceActivationState((previous) => + (previous?.activationGeneration ?? -1) > (event.payload.activationGeneration ?? 0) + ? previous : event.payload, + ); setVoiceActivationModelMissing(isVoiceActivationModelMissing(event.payload)); }, ); @@ -170,8 +176,15 @@ export function RecordingInputSection({ return; } unlisten = stopListening; + const snapshot = await getVoiceActivationState(); + if (!cancelled && snapshot) { + setVoiceActivationState((previous) => + (previous?.activationGeneration ?? -1) >= (snapshot.activationGeneration ?? 0) + ? previous : snapshot, + ); + } } - void listenForVoiceActivationChanges(); + void listenForVoiceActivationChanges().catch(console.error); return () => { cancelled = true; unlisten?.(); @@ -204,6 +217,12 @@ export function RecordingInputSection({ if (!prefs?.voiceActivationEnabled) setVoiceActivationModelMissing(false); }, [prefs?.voiceActivationEnabled]); + useEffect(() => { + if (!isTauri && os === 'win') { + void getVoiceActivationState().then(setVoiceActivationState); + } + }, [os, prefs?.voiceActivationEnabled, prefs?.voiceActivationHotkeyLinked]); + const saveKeyboardListAffectingPrefs = useCallback( async (nextPrefs: UserPreferences) => { try { @@ -627,6 +646,23 @@ export function RecordingInputSection({
{t('settings.recording.voiceActivationLocalOnly')}
+ + savePrefs({ ...prefs, voiceActivationHotkeyLinked: next })} + /> + + {prefs.voiceActivationHotkeyLinked && ( +
+ {voiceActivationState + ? t(voiceActivationStatusKey(voiceActivationState)) + : t('settings.recording.voiceActivationStarting')} + {voiceActivationState?.message && ` — ${voiceActivationState.message}`} +
+ )} {voiceActivationModelMissing && (
Date: Sun, 25 Oct 2026 03:31:14 +0800 Subject: [PATCH 2/3] fix(voice-activation): align mobile and validation contracts Keep the mobile hotkey release shape in sync, disambiguate settings category navigation in the new Playwright regression, and update the shared IPC audit for the listener state query. Fixes identified from full GitHub Actions CI and Android logs. Co-Authored-By: Claude Opus 5 --- openless-all/app/e2e/settings-regressions.spec.ts | 7 ++++--- openless-all/app/harmony/ipc-audit.json | 11 +++++++++-- openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs | 6 +++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/openless-all/app/e2e/settings-regressions.spec.ts b/openless-all/app/e2e/settings-regressions.spec.ts index 646857e7f..323323c5b 100644 --- a/openless-all/app/e2e/settings-regressions.spec.ts +++ b/openless-all/app/e2e/settings-regressions.spec.ts @@ -297,7 +297,8 @@ test('Windows wake shortcut linkage is opt-in, keyboard accessible and preserves await page.goto('http://127.0.0.1:5326/?os=win'); await page.getByRole('button', { name: '设置', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '设置', exact: true }); - await dialog.getByRole('button', { name: '录音与输入', exact: true }).click(); + const categories = dialog.getByRole('navigation', { name: '设置分类' }); + await categories.getByRole('button', { name: '录音与输入', exact: true }).click(); const linkage = dialog.getByRole('switch', { name: '与录音快捷键联动', exact: true }); await expect(linkage).toHaveCount(0); await dialog.getByRole('switch', { name: '语音唤醒听写', exact: true }).click(); @@ -308,8 +309,8 @@ test('Windows wake shortcut linkage is opt-in, keyboard accessible and preserves await expect(linkage).toBeChecked(); await expect(dialog.getByText('已临时暂停唤醒监听', { exact: true })).toBeVisible(); expect(await page.evaluate(async () => (await import('/src/lib/ipc/mock-data.ts')).mockSettings.hotkey.mode)).toBe(mode); - await dialog.getByRole('button', { name: 'AI 服务与模型', exact: true }).click(); - await dialog.getByRole('button', { name: '录音与输入', exact: true }).click(); + await categories.getByRole('button', { name: 'AI 服务与模型', exact: true }).click(); + await categories.getByRole('button', { name: '录音与输入', exact: true }).click(); await expect(linkage).toBeChecked(); await linkage.click(); await expect(linkage).not.toBeChecked(); diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index d051d9e62..98805d575 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,8 +1,8 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 198, + "total": 199, "wired": 103, - "missing": 95, + "missing": 96, "commands": [ { "name": "accept_pending_correction", @@ -557,6 +557,13 @@ ], "implementation": "unimplemented" }, + { + "name": "get_voice_activation_state", + "calledFrom": [ + "src/lib/ipc/settings.ts" + ], + "implementation": "unimplemented" + }, { "name": "get_voice_edit_state", "calledFrom": [ diff --git a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs index a1e2f7e13..f88c7f91f 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs @@ -11,7 +11,11 @@ use crate::types::{ #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HotkeyEvent { Pressed { at: Instant, press_id: u64 }, - Released { at: Instant, press_id: u64 }, + Released { + at: Instant, + press_id: u64, + combined: bool, + }, // Combo-key abort and Esc cancel have no global keyboard listener on // mobile and are not in this enum (see the hotkey.rs module comment). TranslationModifierPressed, From a798f408ebe61b27c61da3c5aa64fcf08f7ee529 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 25 Oct 2026 04:01:47 +0800 Subject: [PATCH 3/3] fix(voice-activation): stabilize parallel fixtures and mock status Give each integration fixture a UUID data directory instead of relying on Windows clock resolution. Refresh browser listener status on confirmed settings snapshots and ignore obsolete async responses after optimistic writes. Co-Authored-By: Claude Opus 5 --- .../openless-core/tests/voice_activation_linked.rs | 2 +- .../src/pages/settings/RecordingInputSection.tsx | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/openless-all/app/crates/openless-core/tests/voice_activation_linked.rs b/openless-all/app/crates/openless-core/tests/voice_activation_linked.rs index be5da5e1e..8886ba66e 100644 --- a/openless-all/app/crates/openless-core/tests/voice_activation_linked.rs +++ b/openless-all/app/crates/openless-core/tests/voice_activation_linked.rs @@ -121,7 +121,7 @@ fn fixture() -> (Directory, Arc, Arc, FixtureDictation let dir = Directory(std::env::temp_dir().join(format!( "openless-voice-link-{}-{}", std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + SessionId::new() ))); let runtime = Arc::new(Runtime::default()); let engine = FixtureDictationEngine::successful("body", "body"); diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index db05f2480..d9423c1e4 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -218,10 +218,16 @@ export function RecordingInputSection({ }, [prefs?.voiceActivationEnabled]); useEffect(() => { - if (!isTauri && os === 'win') { - void getVoiceActivationState().then(setVoiceActivationState); - } - }, [os, prefs?.voiceActivationEnabled, prefs?.voiceActivationHotkeyLinked]); + if (isTauri || os !== 'win') return; + let cancelled = false; + void getVoiceActivationState() + .then((snapshot) => { + if (!cancelled) setVoiceActivationState(snapshot); + }) + .catch(console.error); + return () => { cancelled = true; }; + // The confirmed preference snapshot also changes after an optimistic write. + }, [os, prefs]); const saveKeyboardListAffectingPrefs = useCallback( async (nextPrefs: UserPreferences) => {