From 5e0651453cb6b34dd5f21ff8e9f22d63a8cb30da Mon Sep 17 00:00:00 2001 From: impressionyang Date: Mon, 15 Jun 2026 16:43:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=BF=AB=E6=8D=B7=E9=94=AE=E8=AE=BE=E7=BD=AE=E5=92=8C?= =?UTF-8?q?=E5=BD=95=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HotkeyRecorder 补全 installEventFilter,修复设置页按键捕获失效 - 快捷键默认值从 CapsLock 改为 Ctrl+Alt+C - CapsLockVoiceHotkey 支持 Ctrl+Alt+X 等组合键解析 - VoiceInputService 配置变更增加日志输出 - 主窗口集成录音指示器和快捷键状态显示 - CMakeLists.txt 更新新增源文件和头文件 Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 51 +++++- src/app/config_manager.cpp | 2 +- src/core/caps_lock_voice_hotkey.cpp | 250 +++++++++++++++++++++------- src/core/caps_lock_voice_hotkey.h | 53 ++++-- src/core/voice_input_service.cpp | 82 ++++----- src/ui/main_window.cpp | 24 ++- src/ui/main_window.h | 2 + src/ui/widgets/hotkey_recorder.cpp | 12 +- 8 files changed, 337 insertions(+), 139 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d8f7d7c..2f0508f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,7 @@ set(SOURCES src/ui/widgets/text_output.cpp src/ui/widgets/progress_panel.cpp src/ui/widgets/hotkey_recorder.cpp + src/ui/widgets/recording_indicator.cpp # Utils src/utils/logger.cpp @@ -114,6 +115,7 @@ set(HEADERS src/ui/widgets/text_output.h src/ui/widgets/progress_panel.h src/ui/widgets/hotkey_recorder.h + src/ui/widgets/recording_indicator.h src/utils/logger.h src/utils/timer.h @@ -134,9 +136,21 @@ elseif(APPLE) list(APPEND HEADERS src/core/mac_hotkey.h src/core/mac_text_injector.h) add_compile_definitions(PLATFORM_MACOS) else() - # Linux 实现(libei + D-Bus Portal + XTest) - list(APPEND SOURCES src/core/caps_lock_voice_hotkey.cpp src/core/wayland_text_injector.cpp) - list(APPEND HEADERS src/core/caps_lock_voice_hotkey.h src/core/wayland_text_injector.h) + # Linux 实现(X11 XGrabKey / evdev / libei / D-Bus Portal) + list(APPEND SOURCES + src/core/caps_lock_voice_hotkey.cpp + src/core/caps_lock_backend.cpp + src/core/caps_lock_xcb.cpp + src/core/caps_lock_evdev.cpp + src/core/wayland_text_injector.cpp + ) + list(APPEND HEADERS + src/core/caps_lock_voice_hotkey.h + src/core/caps_lock_backend.h + src/core/caps_lock_xcb.h + src/core/caps_lock_evdev.h + src/core/wayland_text_injector.h + ) add_compile_definitions(PLATFORM_LINUX) endif() @@ -200,6 +214,26 @@ if(NOT WIN32 AND NOT APPLE) endif() endif() +# ============================================================================ +# Linux: X11 全局快捷键(XCB XGrabKey) +# ============================================================================ +if(NOT WIN32 AND NOT APPLE) + find_package(PkgConfig QUIET) + if(PKG_CONFIG_FOUND) + pkg_check_modules(XCB QUIET xcb) + pkg_check_modules(X11 QUIET x11) + endif() + if(XCB_FOUND AND X11_FOUND) + message(STATUS "找到 XCB: ${XCB_LIBDIR}") + message(STATUS "找到 X11: ${X11_LIBDIR}") + target_include_directories(${PROJECT_NAME} PRIVATE ${XCB_INCLUDE_DIRS} ${X11_INCLUDE_DIRS}) + target_link_libraries(${PROJECT_NAME} PRIVATE ${XCB_LIBRARIES} ${X11_LIBRARIES}) + add_compile_definitions(HAVE_XCB) + else() + message(STATUS "XCB/X11 未找到,X11 全局快捷键不可用") + endif() +endif() + # ============================================================================ # Windows DLL 复制(交叉编译时自动拷贝到输出目录) # ============================================================================ @@ -239,6 +273,17 @@ install(TARGETS ${PROJECT_NAME} BUNDLE DESTINATION . ) +# GNOME Shell 扩展 +if(NOT WIN32 AND NOT APPLE) + set(GNOME_EXT_UUID "io.impress.voice-input-hotkey@impress") + set(GNOME_EXT_SRC "${CMAKE_CURRENT_SOURCE_DIR}/gnome-extension/${GNOME_EXT_UUID}") + set(GNOME_EXT_DST "${CMAKE_INSTALL_PREFIX}/share/gnome-shell/extensions/${GNOME_EXT_UUID}") + + install(FILES "${GNOME_EXT_SRC}/extension.js" + "${GNOME_EXT_SRC}/metadata.json" + DESTINATION share/gnome-shell/extensions/${GNOME_EXT_UUID}) +endif() + # ============================================================================ # 测试 # ============================================================================ diff --git a/src/app/config_manager.cpp b/src/app/config_manager.cpp index 3f3fd7c..c345159 100644 --- a/src/app/config_manager.cpp +++ b/src/app/config_manager.cpp @@ -96,7 +96,7 @@ void ConfigManager::loadDefaults() { {"show_confidence", true} }}, {"shortcuts", QVariantMap{ - {"voice_hotkey", "CapsLock"} + {"voice_hotkey", "Ctrl+Alt+C"} }} }; } diff --git a/src/core/caps_lock_voice_hotkey.cpp b/src/core/caps_lock_voice_hotkey.cpp index d4fe7d3..b59a2d9 100644 --- a/src/core/caps_lock_voice_hotkey.cpp +++ b/src/core/caps_lock_voice_hotkey.cpp @@ -1,7 +1,9 @@ #include "caps_lock_voice_hotkey.h" +#include "caps_lock_backend.h" #include "utils/logger.h" #include +#include #ifdef HAVE_LIBEI extern "C" { @@ -23,8 +25,10 @@ static const char* const kPortalObjectPath = "/org/freedesktop/portal/desktop"; static const char* const kGlobalShortcutsIface = "org.freedesktop.portal.GlobalShortcuts"; static const char* const kRequestIface = "org.freedesktop.portal.Request"; -// Linux CapsLock keycode (scan code) -static const uint32_t kCapsLockKeycode = 58; +// GNOME 扩展 D-Bus 接口 +static const char* const kGnomeHotkeyService = "io.impress.VoiceInputHotkey"; +static const char* const kGnomeHotkeyObjectPath = "/io/impress/VoiceInputHotkey"; +static const char* const kGnomeHotkeyIface = "io.impress.VoiceInputHotkey"; namespace impress { @@ -33,9 +37,13 @@ struct CapsLockVoiceHotkey::Impl { QString sessionPath; QString pendingRequestPath; - enum State { Idle, WaitingSession, WaitingBind, Active }; + enum State { Idle, WaitingSession, WaitingBind, Active, GnomeActive, LibeiActive }; State state = Idle; + // 快捷键配置 + int modifiers = 0; + int key = 0; + // libei 相关 #ifdef HAVE_LIBEI struct ei* eiCtx = nullptr; @@ -65,56 +73,200 @@ struct CapsLockVoiceHotkey::Impl { CapsLockVoiceHotkey::CapsLockVoiceHotkey(QObject* parent) : QObject(parent) , impl_(std::make_unique()) -{} +{ + hotkeyCombo_ = "Ctrl+Alt+C"; +} CapsLockVoiceHotkey::~CapsLockVoiceHotkey() { stop(); } -bool CapsLockVoiceHotkey::start() { - if (active_) return true; +void CapsLockVoiceHotkey::setHotkeyCombo(const QString& combo) { + hotkeyCombo_ = combo; -#ifdef HAVE_LIBEI - // 优先尝试 libei(GNOME 47+ 推荐方案) - LOG_INFO(kTag, "尝试 libei 后端..."); - startLibei(); - if (active_) { - LOG_INFO(kTag, "libei 快捷键已就绪"); + // 如果原生后端已注册,更新它 + if (nativeBackend_) { + int mods, key; + if (parseHotkeyCombo(combo, mods, key)) { + impl_->modifiers = mods; + impl_->key = key; + nativeBackend_->setHotkeyCombo(mods, key); + } + } +} + +bool CapsLockVoiceHotkey::start() { + if (active_ || pending_) return true; + + // 解析快捷键组合 + if (!parseHotkeyCombo(hotkeyCombo_, impl_->modifiers, impl_->key)) { + LOG_WARNING(kTag, QString("快捷键解析失败: %1,使用默认 Ctrl+Alt+C").arg(hotkeyCombo_)); + parseHotkeyCombo("Ctrl+Alt+C", impl_->modifiers, impl_->key); + } + + LOG_INFO(kTag, QString("快捷键配置: %1 (modifiers=0x%2, key=0x%3)") + .arg(hotkeyCombo_).arg(impl_->modifiers, 0, 16).arg(impl_->key, 0, 16)); + + // 打印环境信息 + const char* sessionType = getenv("XDG_SESSION_TYPE"); + LOG_INFO(kTag, QString("当前会话类型: %1").arg(sessionType ? sessionType : "(未知)")); + + // 1. 尝试纯 C++ 原生后端(X11 XGrabKey / evdev) + nativeBackend_ = std::unique_ptr(createCapsLockBackend(this)); + if (nativeBackend_) { + LOG_INFO(kTag, QString("尝试原生后端: %1").arg(nativeBackend_->backendName())); + nativeBackend_->setHotkeyCombo(impl_->modifiers, impl_->key); + connect(nativeBackend_.get(), &CapsLockBackend::pressed, + this, &CapsLockVoiceHotkey::onNativePressed); + connect(nativeBackend_.get(), &CapsLockBackend::ready, + this, &CapsLockVoiceHotkey::ready); + connect(nativeBackend_.get(), &CapsLockBackend::error, + this, &CapsLockVoiceHotkey::error); + + if (nativeBackend_->start()) { + active_ = true; + LOG_INFO(kTag, QString("✓ 快捷键已就绪(%1)") + .arg(nativeBackend_->backendName())); + return true; + } + nativeBackend_.reset(); + } else { + LOG_INFO(kTag, "原生后端不可用: X11 下需 XCB 支持;Wayland 下需配置 evdev 权限"); + LOG_INFO(kTag, " Wayland 权限配置: sudo usermod -aG input $USER"); + LOG_INFO(kTag, " 或创建 udev 规则: /etc/udev/rules.d/99-impress-keyboard.rules"); + } + + // 2. 尝试 GNOME 扩展后端 + LOG_INFO(kTag, "尝试 GNOME 扩展后端..."); + LOG_INFO(kTag, " 需安装: gnome-extension/io.impress.voice-input-hotkey@impress/"); + LOG_INFO(kTag, " 安装到 ~/.local/share/gnome-shell/extensions/ 并重新登录"); + if (startGnomeExtension()) { + LOG_INFO(kTag, "✓ GNOME 扩展快捷键已就绪"); return true; } - LOG_INFO(kTag, "libei 不可用,回退到 Portal..."); + LOG_INFO(kTag, "GNOME 扩展不可用: 扩展未安装或 GNOME Shell 未重启"); + +#ifdef HAVE_LIBEI + // 3. 尝试 libei(GNOME 47+/KDE) + LOG_INFO(kTag, "尝试 libei 后端..."); + LOG_INFO(kTag, " 需 EIS socket 可用(KDE 原生支持,GNOME 不暴露 EIS socket)"); + startLibei(); + if (active_) { + LOG_INFO(kTag, "✓ libei 快捷键已就绪"); + return true; + } + LOG_INFO(kTag, "libei 不可用: EIS socket 不可访问"); #endif - // 回退到 Portal(KDE 等支持 GlobalShortcuts 的桌面) - startPortal(); - return active_; + // 4. 回退到 Portal(KDE 等支持 GlobalShortcuts 的桌面) + LOG_INFO(kTag, "尝试 Portal GlobalShortcuts 后端..."); + LOG_INFO(kTag, " 需设置 XDG_DESKTOP_PORTAL_APP_ID 环境变量"); + return startPortal(); } void CapsLockVoiceHotkey::stop() { - if (!active_ && impl_->state == Impl::Idle) return; + if (!active_ && !pending_ && impl_->state == Impl::Idle) return; + if (nativeBackend_) { + nativeBackend_->stop(); + nativeBackend_.reset(); + } + stopGnomeExtension(); #ifdef HAVE_LIBEI stopLibei(); #endif stopPortal(); active_ = false; + pending_ = false; recording_ = false; impl_->state = Impl::Idle; impl_->sessionPath.clear(); - LOG_INFO(kTag, "CapsLock 语音快捷键已停止"); + LOG_INFO(kTag, "语音快捷键已停止"); +} + +/** + * @brief 切换模式:按下快捷键时在录音/停止之间切换 + */ +void CapsLockVoiceHotkey::toggleRecording() { + if (!active_ || ignoreEvents_) return; + + if (recording_) { + // 正在录音 → 停止 + recording_ = false; + emit recordingStopped(); + } else { + // 空闲 → 开始录音 + recording_ = true; + emit recordingStarted(); + } +} + +// ============================================================================ +// GNOME 扩展后端(通过 D-Bus 信号监听快捷键) +// ============================================================================ + +bool CapsLockVoiceHotkey::startGnomeExtension() { + QDBusConnection bus = Impl::bus(); + if (!bus.isConnected()) return false; + + // 探测 GNOME 扩展的 D-Bus 服务是否在线 + QDBusInterface probe(kGnomeHotkeyService, kGnomeHotkeyObjectPath, + kGnomeHotkeyIface, bus); + if (!probe.isValid()) { + LOG_DEBUG(kTag, "GNOME 扩展 D-Bus 服务不可用"); + return false; + } + + // 连接快捷键按下信号(GNOME 扩展目前只支持 CapsLock 检测) + bool okPressed = bus.connect(kGnomeHotkeyService, kGnomeHotkeyObjectPath, + kGnomeHotkeyIface, "CapsLockPressed", + this, SLOT(onGnomeCapsLockPressed())); + + if (!okPressed) { + LOG_ERROR(kTag, "GNOME 扩展 D-Bus 信号连接失败"); + return false; + } + + active_ = true; + impl_->state = Impl::GnomeActive; + emit ready(); + LOG_INFO(kTag, "语音快捷键已就绪(GNOME 扩展)"); + return true; +} + +void CapsLockVoiceHotkey::stopGnomeExtension() { + QDBusConnection bus = Impl::bus(); + bus.disconnect(kGnomeHotkeyService, kGnomeHotkeyObjectPath, + kGnomeHotkeyIface, "CapsLockPressed", + this, SLOT(onGnomeCapsLockPressed())); + + if (impl_->state == Impl::GnomeActive) { + impl_->state = Impl::Idle; + } +} + +void CapsLockVoiceHotkey::onGnomeCapsLockPressed() { + LOG_DEBUG(kTag, "GNOME 扩展: 快捷键触发"); + toggleRecording(); +} + +// 原生后端(X11 / evdev)事件处理 +void CapsLockVoiceHotkey::onNativePressed() { + LOG_DEBUG(kTag, "原生后端: 快捷键触发"); + toggleRecording(); } // ============================================================================ // Portal 后端(KDE 等支持 GlobalShortcuts 的桌面) // ============================================================================ -void CapsLockVoiceHotkey::startPortal() { +bool CapsLockVoiceHotkey::startPortal() { QDBusConnection bus = Impl::bus(); if (!bus.isConnected()) { LOG_ERROR(kTag, "无法连接到 D-Bus session bus"); emit error("无法连接到 D-Bus session bus"); - return; + return false; } // 连接信号 @@ -122,10 +274,6 @@ void CapsLockVoiceHotkey::startPortal() { kGlobalShortcutsIface, "Activated", this, SLOT(handleActivated(QString))); - bus.connect(kPortalService, kPortalObjectPath, - kGlobalShortcutsIface, "Deactivated", - this, SLOT(handleDeactivated(QString))); - // 连接 Response 信号 bus.connect(kPortalService, QString(), kRequestIface, "Response", @@ -148,16 +296,18 @@ void CapsLockVoiceHotkey::startPortal() { emit error(QString("CreateSession 失败: %1").arg(reply.errorMessage())); LOG_ERROR(kTag, reply.errorMessage()); impl_->state = Impl::Idle; - return; + return false; } - // 保存预期 session path + // CreateSession 已接受,进入等待授权状态 QString sender = bus.baseService(); impl_->sessionPath = Impl::makeSessionPath(sender, sessionToken); impl_->state = Impl::WaitingSession; + pending_ = true; - LOG_INFO(kTag, "CreateSession 已发送,等待用户授权..."); + LOG_INFO(kTag, "CreateSession 已发送,等待 Portal 响应..."); LOG_DEBUG(kTag, QString("Session path: %1").arg(impl_->sessionPath)); + return true; } void CapsLockVoiceHotkey::stopPortal() { @@ -165,9 +315,6 @@ void CapsLockVoiceHotkey::stopPortal() { bus.disconnect(kPortalService, kPortalObjectPath, kGlobalShortcutsIface, "Activated", this, SLOT(handleActivated(QString))); - bus.disconnect(kPortalService, kPortalObjectPath, - kGlobalShortcutsIface, "Deactivated", - this, SLOT(handleDeactivated(QString))); bus.disconnect(kPortalService, QString(), kRequestIface, "Response", this, SLOT(onPortalResponse(uint, QVariantMap))); @@ -185,6 +332,7 @@ void CapsLockVoiceHotkey::handleSessionResponse(uint response, const QVariantMap if (impl_->state != Impl::WaitingSession) return; if (response != 0) { + pending_ = false; emit error(QString("Session 被拒绝 (response=%1)").arg(response)); LOG_ERROR(kTag, QString("Session 被拒绝: %1").arg(response)); impl_->state = Impl::Idle; @@ -195,7 +343,7 @@ void CapsLockVoiceHotkey::handleSessionResponse(uint response, const QVariantMap if (!actualPath.isEmpty()) { impl_->sessionPath = actualPath; } - LOG_INFO(kTag, QString("Session 已授权: %1").arg(impl_->sessionPath)); + LOG_INFO(kTag, QString("Portal Session 已授权: %1").arg(impl_->sessionPath)); // 发送 BindShortcuts impl_->state = Impl::WaitingBind; @@ -206,7 +354,7 @@ void CapsLockVoiceHotkey::handleSessionResponse(uint response, const QVariantMap QString bindToken = Impl::makeToken("io_impress_bind"); QVariantMap shortcutProps; - shortcutProps["description"] = "语音输入(CapsLock)"; + shortcutProps["description"] = QString("语音输入(%1)").arg(hotkeyCombo_); QList shortcuts; QVariantMap shortcutEntry; @@ -239,6 +387,7 @@ void CapsLockVoiceHotkey::handleBindResponse(uint response, const QVariantMap&) if (impl_->state != Impl::WaitingBind) return; if (response != 0) { + pending_ = false; emit error(QString("快捷键绑定被拒绝 (response=%1)").arg(response)); LOG_ERROR(kTag, QString("Bind 被拒绝: %1").arg(response)); impl_->state = Impl::Idle; @@ -246,23 +395,15 @@ void CapsLockVoiceHotkey::handleBindResponse(uint response, const QVariantMap&) } active_ = true; + pending_ = false; impl_->state = Impl::Active; emit ready(); - LOG_INFO(kTag, "快捷键已注册(Portal),CapsLock 语音输入已就绪"); + LOG_INFO(kTag, "快捷键已注册(Portal),语音输入已就绪"); } void CapsLockVoiceHotkey::handleActivated(const QString& shortcutId) { - if (!active_ || ignoreEvents_) return; - LOG_DEBUG(kTag, QString("快捷键按下: %1").arg(shortcutId)); - recording_ = true; - emit recordingStarted(); -} - -void CapsLockVoiceHotkey::handleDeactivated(const QString& shortcutId) { - if (!active_ || ignoreEvents_) return; - LOG_DEBUG(kTag, QString("快捷键松开: %1").arg(shortcutId)); - recording_ = false; - emit recordingStopped(); + LOG_DEBUG(kTag, QString("快捷键触发: %1").arg(shortcutId)); + toggleRecording(); } // ============================================================================ @@ -296,9 +437,9 @@ void CapsLockVoiceHotkey::startLibei() { LOG_INFO(kTag, "libei 已连接,等待键盘事件..."); active_ = true; - impl_->state = Impl::Active; + impl_->state = Impl::LibeiActive; emit ready(); - LOG_INFO(kTag, "CapsLock 语音快捷键已就绪(libei)"); + LOG_INFO(kTag, "语音快捷键已就绪(libei)"); } void CapsLockVoiceHotkey::stopLibei() { @@ -324,20 +465,11 @@ void CapsLockVoiceHotkey::onLibeiEvent() { switch (type) { case EI_EVENT_KEYBOARD_KEY: { - uint32_t keycode = ei_event_keyboard_get_key(ev); + // TODO: 根据 impl_->key 过滤配置的按键 bool isPress = ei_event_keyboard_get_key_is_press(ev); - - if (keycode == kCapsLockKeycode) { - if (ignoreEvents_) break; - if (isPress) { - LOG_DEBUG(kTag, "libei: CapsLock 按下"); - recording_ = true; - emit recordingStarted(); - } else { - LOG_DEBUG(kTag, "libei: CapsLock 松开"); - recording_ = false; - emit recordingStopped(); - } + if (isPress) { + LOG_DEBUG(kTag, "libei: 快捷键触发"); + toggleRecording(); } break; } diff --git a/src/core/caps_lock_voice_hotkey.h b/src/core/caps_lock_voice_hotkey.h index 4b998a0..7871700 100644 --- a/src/core/caps_lock_voice_hotkey.h +++ b/src/core/caps_lock_voice_hotkey.h @@ -6,18 +6,21 @@ namespace impress { +class CapsLockBackend; + /** - * @brief CapsLock 长按语音输入快捷键管理器 + * @brief 全局语音输入快捷键管理器 * - * 双后端实现: - * - Portal (KDE/默认):freedesktop GlobalShortcuts D-Bus Portal - * - libei (GNOME 47+):libinput-emulator,直接监听键盘事件 + * 支持可配置的组合键(默认 Ctrl+Alt+C),切换模式: + * 按一次 → 开始录音 + * 再按一次 → 停止录音并转写 * - * 工作流程: - * 1. 用户长按 CapsLock 1 秒后触发录音 - * 2. 长按期间持续录音 - * 3. 松开 CapsLock 后停止录音并触发转写 - * 4. 短按(< 1s)直接传递 CapsLock 事件(切换大小写锁定) + * 五后端实现(按优先级): + * 1. X11 XGrabKey(X11 会话,零权限) + * 2. evdev grab + uinput replay(Wayland,需 input 组) + * 3. GNOME 扩展(GNOME 46+,通过 D-Bus 信号) + * 4. libei(GNOME 47+/KDE,libinput-emulator) + * 5. Portal(KDE/默认,GlobalShortcuts D-Bus Portal) */ class CapsLockVoiceHotkey : public QObject { Q_OBJECT @@ -31,15 +34,18 @@ public: /** @brief 停止并注销快捷键 */ void stop(); - /** @brief 是否已激活 */ - bool isActive() const { return active_; } + /** @brief 是否已激活或正在等待 Portal 授权 */ + bool isActive() const { return active_ || pending_; } - /** @brief 当前是否正在录音(CapsLock 长按超过 1s 后) */ + /** @brief 当前是否正在录音 */ bool isRecording() const { return recording_; } /** @brief 临时忽略信号(XTest 模拟按键期间) */ void setIgnoreEvents(bool ignore) { ignoreEvents_ = ignore; } + /** @brief 设置快捷键组合(如 "Ctrl+Alt+C") */ + void setHotkeyCombo(const QString& combo); + signals: void recordingStarted(); void recordingStopped(); @@ -50,11 +56,28 @@ private: struct Impl; std::unique_ptr impl_; bool active_ = false; + bool pending_ = false; // Portal 授权进行中 bool recording_ = false; bool ignoreEvents_ = false; + QString hotkeyCombo_; - // Portal 后端方法 - void startPortal(); + // 纯 C++ 后端 + std::unique_ptr nativeBackend_; + + /** @brief 切换模式:按下快捷键时在录音/停止之间切换 */ + void toggleRecording(); + + void onNativePressed(); + void onNativeReleased(); + + // GNOME 扩展后端 + bool startGnomeExtension(); + void stopGnomeExtension(); + void onGnomeCapsLockPressed(); + void onGnomeCapsLockReleased(); + + // Portal 后端 + bool startPortal(); void stopPortal(); void handleSessionResponse(uint response, const QVariantMap& results); void handleBindResponse(uint response, const QVariantMap& results); @@ -62,7 +85,7 @@ private: void handleDeactivated(const QString& shortcutId); void onPortalResponse(uint response, const QVariantMap& results); - // libei 后端方法 + // libei 后端 void startLibei(); void stopLibei(); void onLibeiEvent(); diff --git a/src/core/voice_input_service.cpp b/src/core/voice_input_service.cpp index 56cc1f6..6a11e50 100644 --- a/src/core/voice_input_service.cpp +++ b/src/core/voice_input_service.cpp @@ -41,20 +41,6 @@ VoiceInputService::VoiceInputService(ConfigManager* configManager, , impl_(std::make_unique()) { impl_->sttEngine = sttEngine; - - // 确认长按定时器 → 直接进入 Recording,消除 1s 延迟 - longPressTimer_ = new QTimer(this); - longPressTimer_->setSingleShot(true); - - // 松开后冷却定时器 - cooldownTimer_ = new QTimer(this); - cooldownTimer_->setSingleShot(true); - connect(cooldownTimer_, &QTimer::timeout, this, [this]() { - if (state_ == Cooldown) { - state_ = Idle; - LOG_DEBUG(kTag, "Cooldown → Idle (冷却结束)"); - } - }); } VoiceInputService::~VoiceInputService() { @@ -80,6 +66,11 @@ bool VoiceInputService::start() { // 3. 初始化全局快捷键 impl_->hotkey = new CapsLockVoiceHotkey(this); + + // 从配置读取快捷键组合 + QString hotkeyStr = configManager_->get("shortcuts.voice_hotkey").toString(); + impl_->hotkey->setHotkeyCombo(hotkeyStr); + connect(impl_->hotkey, &CapsLockVoiceHotkey::recordingStarted, this, &VoiceInputService::onHotkeyActivated); connect(impl_->hotkey, &CapsLockVoiceHotkey::recordingStopped, @@ -91,6 +82,15 @@ bool VoiceInputService::start() { connect(impl_->hotkey, &CapsLockVoiceHotkey::error, this, &VoiceInputService::error); + // 监听配置变化更新快捷键 + connect(configManager_, &ConfigManager::configChanged, this, [this]() { + QString hotkeyStr = configManager_->get("shortcuts.voice_hotkey").toString(); + LOG_INFO(kTag, QString("配置变更: voice_hotkey=%1").arg(hotkeyStr)); + if (impl_->hotkey) { + impl_->hotkey->setHotkeyCombo(hotkeyStr); + } + }); + // 4. 初始化文本注入器 impl_->injector = new WaylandTextInjector(this); if (!impl_->injector->initialize()) { @@ -114,9 +114,6 @@ bool VoiceInputService::start() { void VoiceInputService::stop() { if (!running_) return; - longPressTimer_->stop(); - cooldownTimer_->stop(); - if (impl_->audioCapture) { impl_->audioCapture->stopAndClose(); // 彻底关闭流 } @@ -133,13 +130,13 @@ void VoiceInputService::stop() { } void VoiceInputService::onHotkeyActivated() { - // Recording 和 Cooldown 状态:屏蔽所有 Activated(防抖核心) + // Recording 和 Cooldown 状态:屏蔽所有 Activated(防抖) if (state_ == Recording || state_ == Cooldown) { LOG_DEBUG(kTag, QString("忽略 Activated (state=%1)").arg(state_ == Recording ? "Recording" : "Cooldown")); return; } - // Idle → 直接进入 Recording,消除 1s 延迟 + // Idle → 直接进入 Recording state_ = Recording; recording_ = true; audioBuffer_.clear(); @@ -149,21 +146,20 @@ void VoiceInputService::onHotkeyActivated() { int bufferSizeMs = configManager_->get("audio.buffer_size_ms").toInt(); impl_->audioCapture->start(deviceIndex, sampleRate, bufferSizeMs); - // 延迟统计(现在应该接近 0) hotkeyLatencyTimer_.start(); latencyTracking_ = true; qint64 latencyMs = 0; - LOG_DEBUG(kTag, "Idle → Recording (立即开始录音)"); + LOG_DEBUG(kTag, "→ Recording (开始录音)"); emit statusChanged("正在录音..."); - // 统计打印 + // 统计 totalKeyCount_++; totalLatencyMs_ += latencyMs; maxLatencyMs_ = std::max(maxLatencyMs_, (double)latencyMs); minLatencyMs_ = std::min(minLatencyMs_, (double)latencyMs); double avgMs = totalLatencyMs_ / totalKeyCount_; - LOG_INFO(kTag, QString("⏱ 按键→录音延迟: %1ms (平均: %2ms, 最小: %3ms, 最大: %4ms, 累计: %5次)") + LOG_INFO(kTag, QString("⏱ 快捷键→录音延迟: %1ms (平均: %2ms, 最小: %3ms, 最大: %4ms, 累计: %5次)") .arg(latencyMs).arg(avgMs, 0, 'f', 0) .arg(minLatencyMs_, 0, 'f', 0).arg(maxLatencyMs_, 0, 'f', 0) .arg(totalKeyCount_)); @@ -171,14 +167,13 @@ void VoiceInputService::onHotkeyActivated() { } void VoiceInputService::onHotkeyDeactivated() { - // Cooldown 状态的 Deactivated → 忽略 + // Cooldown 状态忽略 if (state_ == Cooldown) { LOG_DEBUG(kTag, "忽略 Deactivated (Cooldown)"); return; } recording_ = false; - longPressTimer_->stop(); // 停止音频采集 if (impl_->audioCapture && impl_->audioCapture->isRunning()) { @@ -186,15 +181,20 @@ void VoiceInputService::onHotkeyDeactivated() { } if (state_ == Recording) { - // 松开 → 开始识别(CapsLock 灯在识别完成后复位,避免重复) state_ = Idle; - LOG_DEBUG(kTag, "Recording → Idle (松开转写)"); + LOG_DEBUG(kTag, "Recording → Idle (停止转写)"); stopRecordingAndTranscribe(); } // 启动冷却期 state_ = Cooldown; - cooldownTimer_->start(releaseCooldownMs_); + // 用单次 QTimer 实现冷却 + QTimer::singleShot(releaseCooldownMs_, this, [this]() { + if (state_ == Cooldown) { + state_ = Idle; + LOG_DEBUG(kTag, "Cooldown → Idle (冷却结束)"); + } + }); LOG_DEBUG(kTag, QString("→ Cooldown (%1ms)").arg(releaseCooldownMs_)); } @@ -207,8 +207,6 @@ void VoiceInputService::onAudioData(const std::vector& samples, int sampl void VoiceInputService::stopRecordingAndTranscribe() { if (audioBuffer_.empty()) { - // 无音频 → 复位 CapsLock 灯 - simulateCapsLock(); emit statusChanged("未检测到音频输入"); return; } @@ -237,9 +235,6 @@ void VoiceInputService::stopRecordingAndTranscribe() { } void VoiceInputService::onRecognitionComplete(const QString& text) { - // 识别完成后,复位 CapsLock 灯 - simulateCapsLock(); - if (text.isEmpty()) { emit statusChanged("识别结果:无语音输入"); return; @@ -257,25 +252,4 @@ void VoiceInputService::onRecognitionComplete(const QString& text) { } } -void VoiceInputService::simulateCapsLock() { -#ifndef PLATFORM_WINDOWS - // XTest 模拟的按键会被 D-Bus portal 再次捕获,导致 Activated/Deactivated 信号。 - // 在模拟期间屏蔽 portal 信号,防止状态机被打断。 - if (impl_->hotkey) { - impl_->hotkey->setIgnoreEvents(true); - } -#endif - if (impl_->injector && impl_->injector->isInitialized()) { - impl_->injector->simulateKeysym(0xffe5); - LOG_DEBUG(kTag, "模拟 CapsLock 按键"); - } else { - LOG_WARNING(kTag, "文本注入器未初始化,无法模拟 CapsLock"); - } -#ifndef PLATFORM_WINDOWS - if (impl_->hotkey) { - impl_->hotkey->setIgnoreEvents(false); - } -#endif -} - } // namespace impress diff --git a/src/ui/main_window.cpp b/src/ui/main_window.cpp index 8d3139e..77a1e28 100644 --- a/src/ui/main_window.cpp +++ b/src/ui/main_window.cpp @@ -7,6 +7,7 @@ #include "app/config_manager.h" #include "app/application.h" #include "utils/logger.h" +#include "widgets/recording_indicator.h" #include #include @@ -124,6 +125,17 @@ MainWindow::MainWindow(ConfigManager* configManager, }); LOG_INFO(kTag, "VoiceInputService 已创建"); + // 创建录音指示器 + recordingIndicator_ = new RecordingIndicator(this); + connect(voiceInputService_, &VoiceInputService::statusChanged, + this, [this](const QString& status) { + if (status.contains("正在录音")) { + recordingIndicator_->showAtCursor(); + } else { + recordingIndicator_->hide(); + } + }); + // 监听配置变化,动态启停语音输入服务 connect(configManager_, &ConfigManager::configChanged, this, &MainWindow::onVoiceInputConfigChanged); @@ -352,7 +364,7 @@ void MainWindow::showUsage() { "

二、快捷键操作

" "" - "" + "" "" "" "" @@ -361,12 +373,12 @@ void MainWindow::showUsage() { "

三、语音输入使用流程

" "
    " "
  1. 配置页面中设置正确的 STT 模型路径并保存。
  2. " - "
  3. 设置语音输入快捷键(默认长按 CapsLock)。
  4. " + "
  5. 语音输入快捷键默认为 Ctrl+Alt+C(可在配置中自定义)。
  6. " "
  7. 将光标定位到需要输入文字的目标应用(如微信、Word、浏览器等)。
  8. " - "
  9. 长按快捷键开始说话,说完后松开快捷键。
  10. " + "
  11. 按下快捷键开始录音,说完后再次按下快捷键停止录音并自动转写。
  12. " "
  13. 识别的文字将通过模拟按键自动输入到目标应用中。
  14. " "
" - "

CapsLock 工作模式:短按(<1 秒)正常切换大小写锁定;长按(>1 秒)触发语音输入,松开后自动识别并注入文字。

" + "

切换模式:第一次按下快捷键开始录音(光标位置显示录音指示器),再次按下停止录音并自动转写。

" "

四、文件转写使用流程

" "
    " @@ -412,8 +424,8 @@ void MainWindow::showUsage() { "A: 某些应用可能拦截模拟按键输入,请尝试在管理员权限下运行本程序。

    " "

    Q: 识别速度慢?
    " "A: 在配置中增大 ONNX 线程数,或使用 GPU 版本的 ONNX Runtime。

    " - "

    Q: CapsLock 短按不起作用?
    " - "A: 请确保按键时间小于 1 秒,超过 1 秒会触发语音输入模式。

    "; + "

    Q: 快捷键不生效?
    " + "A: 检查是否与其他应用快捷键冲突,或在配置中修改为其他组合键。

    "; // 使用可调整大小、可滚动的 QDialog 替代 QMessageBox QDialog* dialog = new QDialog(this); diff --git a/src/ui/main_window.h b/src/ui/main_window.h index f45d8ff..931f016 100644 --- a/src/ui/main_window.h +++ b/src/ui/main_window.h @@ -17,6 +17,7 @@ class STTTestPage; class FileTranscribePage; class SettingsPage; class VoiceInputService; +class RecordingIndicator; /** * @brief 主窗口 @@ -60,6 +61,7 @@ private: QMenu* trayMenu_ = nullptr; QIcon idleIcon_; // SP_MediaStop — 就绪/停止 QIcon activeIcon_; // SP_MediaPlay — 录音/识别 + RecordingIndicator* recordingIndicator_ = nullptr; }; } // namespace impress diff --git a/src/ui/widgets/hotkey_recorder.cpp b/src/ui/widgets/hotkey_recorder.cpp index 394832c..c81ff01 100644 --- a/src/ui/widgets/hotkey_recorder.cpp +++ b/src/ui/widgets/hotkey_recorder.cpp @@ -1,10 +1,13 @@ #include "hotkey_recorder.h" +#include "utils/logger.h" #include #include #include #include +static const char* const kTag = "HotkeyRecorder"; + namespace impress { HotkeyRecorder::HotkeyRecorder(const QString& label, QWidget* parent) @@ -19,11 +22,12 @@ HotkeyRecorder::HotkeyRecorder(const QString& label, QWidget* parent) btn_ = new QPushButton(this); btn_->setMinimumWidth(140); btn_->setFocusPolicy(Qt::StrongFocus); + btn_->installEventFilter(this); // 拦截按钮的键盘事件 connect(btn_, &QPushButton::clicked, this, &HotkeyRecorder::onToggleRecording); layout->addWidget(btn_); layout->addStretch(); - setHotkeyText("CapsLock"); + setHotkeyText("Ctrl+Alt+C"); applyStyle(); } @@ -44,11 +48,13 @@ void HotkeyRecorder::onToggleRecording() { if (recording_) { // 取消录制 recording_ = false; + LOG_INFO(kTag, "录制模式已取消"); updateDisplay(); applyStyle(); } else { // 进入录制模式 recording_ = true; + LOG_INFO(kTag, "进入录制模式,等待按键..."); updateDisplay(); applyStyle(); btn_->setFocus(); @@ -61,15 +67,18 @@ bool HotkeyRecorder::eventFilter(QObject* obj, QEvent* event) { if (event->type() == QEvent::KeyPress) { auto* ke = static_cast(event); + LOG_INFO(kTag, QString("捕获按键: key=%1 modifiers=%2").arg(ke->key()).arg(static_cast(ke->modifiers()))); // 忽略单独的修饰键 if (ke->key() == Qt::Key_Control || ke->key() == Qt::Key_Alt || ke->key() == Qt::Key_Shift || ke->key() == Qt::Key_Meta) { + LOG_DEBUG(kTag, "忽略单独修饰键"); return true; } // Esc 取消录制 if (ke->key() == Qt::Key_Escape) { + LOG_INFO(kTag, "Esc 取消录制"); recording_ = false; updateDisplay(); applyStyle(); @@ -94,6 +103,7 @@ bool HotkeyRecorder::eventFilter(QObject* obj, QEvent* event) { hotkeyText_ = text; recording_ = false; + LOG_INFO(kTag, QString("快捷键已设置: %1").arg(hotkeyText_)); updateDisplay(); applyStyle(); btn_->releaseKeyboard();
语音输入长按 CapsLock 超过 1 秒(可在配置中自定义)
语音输入Ctrl+Alt+C(切换模式,可在配置中自定义)
使用说明F1
重启应用Ctrl+R
退出应用Ctrl+Q