fix: 修复跨平台快捷键设置和录制功能
- HotkeyRecorder 补全 installEventFilter,修复设置页按键捕获失效 - 快捷键默认值从 CapsLock 改为 Ctrl+Alt+C - CapsLockVoiceHotkey 支持 Ctrl+Alt+X 等组合键解析 - VoiceInputService 配置变更增加日志输出 - 主窗口集成录音指示器和快捷键状态显示 - CMakeLists.txt 更新新增源文件和头文件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2c8b2a8ad9
commit
5e0651453c
@ -79,6 +79,7 @@ set(SOURCES
|
|||||||
src/ui/widgets/text_output.cpp
|
src/ui/widgets/text_output.cpp
|
||||||
src/ui/widgets/progress_panel.cpp
|
src/ui/widgets/progress_panel.cpp
|
||||||
src/ui/widgets/hotkey_recorder.cpp
|
src/ui/widgets/hotkey_recorder.cpp
|
||||||
|
src/ui/widgets/recording_indicator.cpp
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
src/utils/logger.cpp
|
src/utils/logger.cpp
|
||||||
@ -114,6 +115,7 @@ set(HEADERS
|
|||||||
src/ui/widgets/text_output.h
|
src/ui/widgets/text_output.h
|
||||||
src/ui/widgets/progress_panel.h
|
src/ui/widgets/progress_panel.h
|
||||||
src/ui/widgets/hotkey_recorder.h
|
src/ui/widgets/hotkey_recorder.h
|
||||||
|
src/ui/widgets/recording_indicator.h
|
||||||
|
|
||||||
src/utils/logger.h
|
src/utils/logger.h
|
||||||
src/utils/timer.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)
|
list(APPEND HEADERS src/core/mac_hotkey.h src/core/mac_text_injector.h)
|
||||||
add_compile_definitions(PLATFORM_MACOS)
|
add_compile_definitions(PLATFORM_MACOS)
|
||||||
else()
|
else()
|
||||||
# Linux 实现(libei + D-Bus Portal + XTest)
|
# Linux 实现(X11 XGrabKey / evdev / libei / D-Bus Portal)
|
||||||
list(APPEND SOURCES src/core/caps_lock_voice_hotkey.cpp src/core/wayland_text_injector.cpp)
|
list(APPEND SOURCES
|
||||||
list(APPEND HEADERS src/core/caps_lock_voice_hotkey.h src/core/wayland_text_injector.h)
|
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)
|
add_compile_definitions(PLATFORM_LINUX)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
@ -200,6 +214,26 @@ if(NOT WIN32 AND NOT APPLE)
|
|||||||
endif()
|
endif()
|
||||||
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 复制(交叉编译时自动拷贝到输出目录)
|
# Windows DLL 复制(交叉编译时自动拷贝到输出目录)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -239,6 +273,17 @@ install(TARGETS ${PROJECT_NAME}
|
|||||||
BUNDLE DESTINATION .
|
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()
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 测试
|
# 测试
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -96,7 +96,7 @@ void ConfigManager::loadDefaults() {
|
|||||||
{"show_confidence", true}
|
{"show_confidence", true}
|
||||||
}},
|
}},
|
||||||
{"shortcuts", QVariantMap{
|
{"shortcuts", QVariantMap{
|
||||||
{"voice_hotkey", "CapsLock"}
|
{"voice_hotkey", "Ctrl+Alt+C"}
|
||||||
}}
|
}}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
#include "caps_lock_voice_hotkey.h"
|
#include "caps_lock_voice_hotkey.h"
|
||||||
|
#include "caps_lock_backend.h"
|
||||||
#include "utils/logger.h"
|
#include "utils/logger.h"
|
||||||
|
|
||||||
#include <QSocketNotifier>
|
#include <QSocketNotifier>
|
||||||
|
#include <QProcessEnvironment>
|
||||||
|
|
||||||
#ifdef HAVE_LIBEI
|
#ifdef HAVE_LIBEI
|
||||||
extern "C" {
|
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 kGlobalShortcutsIface = "org.freedesktop.portal.GlobalShortcuts";
|
||||||
static const char* const kRequestIface = "org.freedesktop.portal.Request";
|
static const char* const kRequestIface = "org.freedesktop.portal.Request";
|
||||||
|
|
||||||
// Linux CapsLock keycode (scan code)
|
// GNOME 扩展 D-Bus 接口
|
||||||
static const uint32_t kCapsLockKeycode = 58;
|
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 {
|
namespace impress {
|
||||||
|
|
||||||
@ -33,9 +37,13 @@ struct CapsLockVoiceHotkey::Impl {
|
|||||||
QString sessionPath;
|
QString sessionPath;
|
||||||
QString pendingRequestPath;
|
QString pendingRequestPath;
|
||||||
|
|
||||||
enum State { Idle, WaitingSession, WaitingBind, Active };
|
enum State { Idle, WaitingSession, WaitingBind, Active, GnomeActive, LibeiActive };
|
||||||
State state = Idle;
|
State state = Idle;
|
||||||
|
|
||||||
|
// 快捷键配置
|
||||||
|
int modifiers = 0;
|
||||||
|
int key = 0;
|
||||||
|
|
||||||
// libei 相关
|
// libei 相关
|
||||||
#ifdef HAVE_LIBEI
|
#ifdef HAVE_LIBEI
|
||||||
struct ei* eiCtx = nullptr;
|
struct ei* eiCtx = nullptr;
|
||||||
@ -65,56 +73,200 @@ struct CapsLockVoiceHotkey::Impl {
|
|||||||
CapsLockVoiceHotkey::CapsLockVoiceHotkey(QObject* parent)
|
CapsLockVoiceHotkey::CapsLockVoiceHotkey(QObject* parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
, impl_(std::make_unique<Impl>())
|
, impl_(std::make_unique<Impl>())
|
||||||
{}
|
{
|
||||||
|
hotkeyCombo_ = "Ctrl+Alt+C";
|
||||||
|
}
|
||||||
|
|
||||||
CapsLockVoiceHotkey::~CapsLockVoiceHotkey() {
|
CapsLockVoiceHotkey::~CapsLockVoiceHotkey() {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool CapsLockVoiceHotkey::start() {
|
void CapsLockVoiceHotkey::setHotkeyCombo(const QString& combo) {
|
||||||
if (active_) return true;
|
hotkeyCombo_ = combo;
|
||||||
|
|
||||||
#ifdef HAVE_LIBEI
|
// 如果原生后端已注册,更新它
|
||||||
// 优先尝试 libei(GNOME 47+ 推荐方案)
|
if (nativeBackend_) {
|
||||||
LOG_INFO(kTag, "尝试 libei 后端...");
|
int mods, key;
|
||||||
startLibei();
|
if (parseHotkeyCombo(combo, mods, key)) {
|
||||||
if (active_) {
|
impl_->modifiers = mods;
|
||||||
LOG_INFO(kTag, "libei 快捷键已就绪");
|
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<CapsLockBackend>(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;
|
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
|
#endif
|
||||||
|
|
||||||
// 回退到 Portal(KDE 等支持 GlobalShortcuts 的桌面)
|
// 4. 回退到 Portal(KDE 等支持 GlobalShortcuts 的桌面)
|
||||||
startPortal();
|
LOG_INFO(kTag, "尝试 Portal GlobalShortcuts 后端...");
|
||||||
return active_;
|
LOG_INFO(kTag, " 需设置 XDG_DESKTOP_PORTAL_APP_ID 环境变量");
|
||||||
|
return startPortal();
|
||||||
}
|
}
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::stop() {
|
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
|
#ifdef HAVE_LIBEI
|
||||||
stopLibei();
|
stopLibei();
|
||||||
#endif
|
#endif
|
||||||
stopPortal();
|
stopPortal();
|
||||||
|
|
||||||
active_ = false;
|
active_ = false;
|
||||||
|
pending_ = false;
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
impl_->state = Impl::Idle;
|
impl_->state = Impl::Idle;
|
||||||
impl_->sessionPath.clear();
|
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 的桌面)
|
// Portal 后端(KDE 等支持 GlobalShortcuts 的桌面)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::startPortal() {
|
bool CapsLockVoiceHotkey::startPortal() {
|
||||||
QDBusConnection bus = Impl::bus();
|
QDBusConnection bus = Impl::bus();
|
||||||
if (!bus.isConnected()) {
|
if (!bus.isConnected()) {
|
||||||
LOG_ERROR(kTag, "无法连接到 D-Bus session bus");
|
LOG_ERROR(kTag, "无法连接到 D-Bus session bus");
|
||||||
emit error("无法连接到 D-Bus session bus");
|
emit error("无法连接到 D-Bus session bus");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 连接信号
|
// 连接信号
|
||||||
@ -122,10 +274,6 @@ void CapsLockVoiceHotkey::startPortal() {
|
|||||||
kGlobalShortcutsIface, "Activated",
|
kGlobalShortcutsIface, "Activated",
|
||||||
this, SLOT(handleActivated(QString)));
|
this, SLOT(handleActivated(QString)));
|
||||||
|
|
||||||
bus.connect(kPortalService, kPortalObjectPath,
|
|
||||||
kGlobalShortcutsIface, "Deactivated",
|
|
||||||
this, SLOT(handleDeactivated(QString)));
|
|
||||||
|
|
||||||
// 连接 Response 信号
|
// 连接 Response 信号
|
||||||
bus.connect(kPortalService, QString(),
|
bus.connect(kPortalService, QString(),
|
||||||
kRequestIface, "Response",
|
kRequestIface, "Response",
|
||||||
@ -148,16 +296,18 @@ void CapsLockVoiceHotkey::startPortal() {
|
|||||||
emit error(QString("CreateSession 失败: %1").arg(reply.errorMessage()));
|
emit error(QString("CreateSession 失败: %1").arg(reply.errorMessage()));
|
||||||
LOG_ERROR(kTag, reply.errorMessage());
|
LOG_ERROR(kTag, reply.errorMessage());
|
||||||
impl_->state = Impl::Idle;
|
impl_->state = Impl::Idle;
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存预期 session path
|
// CreateSession 已接受,进入等待授权状态
|
||||||
QString sender = bus.baseService();
|
QString sender = bus.baseService();
|
||||||
impl_->sessionPath = Impl::makeSessionPath(sender, sessionToken);
|
impl_->sessionPath = Impl::makeSessionPath(sender, sessionToken);
|
||||||
impl_->state = Impl::WaitingSession;
|
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));
|
LOG_DEBUG(kTag, QString("Session path: %1").arg(impl_->sessionPath));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::stopPortal() {
|
void CapsLockVoiceHotkey::stopPortal() {
|
||||||
@ -165,9 +315,6 @@ void CapsLockVoiceHotkey::stopPortal() {
|
|||||||
bus.disconnect(kPortalService, kPortalObjectPath,
|
bus.disconnect(kPortalService, kPortalObjectPath,
|
||||||
kGlobalShortcutsIface, "Activated",
|
kGlobalShortcutsIface, "Activated",
|
||||||
this, SLOT(handleActivated(QString)));
|
this, SLOT(handleActivated(QString)));
|
||||||
bus.disconnect(kPortalService, kPortalObjectPath,
|
|
||||||
kGlobalShortcutsIface, "Deactivated",
|
|
||||||
this, SLOT(handleDeactivated(QString)));
|
|
||||||
bus.disconnect(kPortalService, QString(),
|
bus.disconnect(kPortalService, QString(),
|
||||||
kRequestIface, "Response",
|
kRequestIface, "Response",
|
||||||
this, SLOT(onPortalResponse(uint, QVariantMap)));
|
this, SLOT(onPortalResponse(uint, QVariantMap)));
|
||||||
@ -185,6 +332,7 @@ void CapsLockVoiceHotkey::handleSessionResponse(uint response, const QVariantMap
|
|||||||
if (impl_->state != Impl::WaitingSession) return;
|
if (impl_->state != Impl::WaitingSession) return;
|
||||||
|
|
||||||
if (response != 0) {
|
if (response != 0) {
|
||||||
|
pending_ = false;
|
||||||
emit error(QString("Session 被拒绝 (response=%1)").arg(response));
|
emit error(QString("Session 被拒绝 (response=%1)").arg(response));
|
||||||
LOG_ERROR(kTag, QString("Session 被拒绝: %1").arg(response));
|
LOG_ERROR(kTag, QString("Session 被拒绝: %1").arg(response));
|
||||||
impl_->state = Impl::Idle;
|
impl_->state = Impl::Idle;
|
||||||
@ -195,7 +343,7 @@ void CapsLockVoiceHotkey::handleSessionResponse(uint response, const QVariantMap
|
|||||||
if (!actualPath.isEmpty()) {
|
if (!actualPath.isEmpty()) {
|
||||||
impl_->sessionPath = actualPath;
|
impl_->sessionPath = actualPath;
|
||||||
}
|
}
|
||||||
LOG_INFO(kTag, QString("Session 已授权: %1").arg(impl_->sessionPath));
|
LOG_INFO(kTag, QString("Portal Session 已授权: %1").arg(impl_->sessionPath));
|
||||||
|
|
||||||
// 发送 BindShortcuts
|
// 发送 BindShortcuts
|
||||||
impl_->state = Impl::WaitingBind;
|
impl_->state = Impl::WaitingBind;
|
||||||
@ -206,7 +354,7 @@ void CapsLockVoiceHotkey::handleSessionResponse(uint response, const QVariantMap
|
|||||||
QString bindToken = Impl::makeToken("io_impress_bind");
|
QString bindToken = Impl::makeToken("io_impress_bind");
|
||||||
|
|
||||||
QVariantMap shortcutProps;
|
QVariantMap shortcutProps;
|
||||||
shortcutProps["description"] = "语音输入(CapsLock)";
|
shortcutProps["description"] = QString("语音输入(%1)").arg(hotkeyCombo_);
|
||||||
|
|
||||||
QList<QVariant> shortcuts;
|
QList<QVariant> shortcuts;
|
||||||
QVariantMap shortcutEntry;
|
QVariantMap shortcutEntry;
|
||||||
@ -239,6 +387,7 @@ void CapsLockVoiceHotkey::handleBindResponse(uint response, const QVariantMap&)
|
|||||||
if (impl_->state != Impl::WaitingBind) return;
|
if (impl_->state != Impl::WaitingBind) return;
|
||||||
|
|
||||||
if (response != 0) {
|
if (response != 0) {
|
||||||
|
pending_ = false;
|
||||||
emit error(QString("快捷键绑定被拒绝 (response=%1)").arg(response));
|
emit error(QString("快捷键绑定被拒绝 (response=%1)").arg(response));
|
||||||
LOG_ERROR(kTag, QString("Bind 被拒绝: %1").arg(response));
|
LOG_ERROR(kTag, QString("Bind 被拒绝: %1").arg(response));
|
||||||
impl_->state = Impl::Idle;
|
impl_->state = Impl::Idle;
|
||||||
@ -246,23 +395,15 @@ void CapsLockVoiceHotkey::handleBindResponse(uint response, const QVariantMap&)
|
|||||||
}
|
}
|
||||||
|
|
||||||
active_ = true;
|
active_ = true;
|
||||||
|
pending_ = false;
|
||||||
impl_->state = Impl::Active;
|
impl_->state = Impl::Active;
|
||||||
emit ready();
|
emit ready();
|
||||||
LOG_INFO(kTag, "快捷键已注册(Portal),CapsLock 语音输入已就绪");
|
LOG_INFO(kTag, "快捷键已注册(Portal),语音输入已就绪");
|
||||||
}
|
}
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::handleActivated(const QString& shortcutId) {
|
void CapsLockVoiceHotkey::handleActivated(const QString& shortcutId) {
|
||||||
if (!active_ || ignoreEvents_) return;
|
LOG_DEBUG(kTag, QString("快捷键触发: %1").arg(shortcutId));
|
||||||
LOG_DEBUG(kTag, QString("快捷键按下: %1").arg(shortcutId));
|
toggleRecording();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -296,9 +437,9 @@ void CapsLockVoiceHotkey::startLibei() {
|
|||||||
LOG_INFO(kTag, "libei 已连接,等待键盘事件...");
|
LOG_INFO(kTag, "libei 已连接,等待键盘事件...");
|
||||||
|
|
||||||
active_ = true;
|
active_ = true;
|
||||||
impl_->state = Impl::Active;
|
impl_->state = Impl::LibeiActive;
|
||||||
emit ready();
|
emit ready();
|
||||||
LOG_INFO(kTag, "CapsLock 语音快捷键已就绪(libei)");
|
LOG_INFO(kTag, "语音快捷键已就绪(libei)");
|
||||||
}
|
}
|
||||||
|
|
||||||
void CapsLockVoiceHotkey::stopLibei() {
|
void CapsLockVoiceHotkey::stopLibei() {
|
||||||
@ -324,20 +465,11 @@ void CapsLockVoiceHotkey::onLibeiEvent() {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case EI_EVENT_KEYBOARD_KEY: {
|
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);
|
bool isPress = ei_event_keyboard_get_key_is_press(ev);
|
||||||
|
if (isPress) {
|
||||||
if (keycode == kCapsLockKeycode) {
|
LOG_DEBUG(kTag, "libei: 快捷键触发");
|
||||||
if (ignoreEvents_) break;
|
toggleRecording();
|
||||||
if (isPress) {
|
|
||||||
LOG_DEBUG(kTag, "libei: CapsLock 按下");
|
|
||||||
recording_ = true;
|
|
||||||
emit recordingStarted();
|
|
||||||
} else {
|
|
||||||
LOG_DEBUG(kTag, "libei: CapsLock 松开");
|
|
||||||
recording_ = false;
|
|
||||||
emit recordingStopped();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,18 +6,21 @@
|
|||||||
|
|
||||||
namespace impress {
|
namespace impress {
|
||||||
|
|
||||||
|
class CapsLockBackend;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief CapsLock 长按语音输入快捷键管理器
|
* @brief 全局语音输入快捷键管理器
|
||||||
*
|
*
|
||||||
* 双后端实现:
|
* 支持可配置的组合键(默认 Ctrl+Alt+C),切换模式:
|
||||||
* - Portal (KDE/默认):freedesktop GlobalShortcuts D-Bus Portal
|
* 按一次 → 开始录音
|
||||||
* - libei (GNOME 47+):libinput-emulator,直接监听键盘事件
|
* 再按一次 → 停止录音并转写
|
||||||
*
|
*
|
||||||
* 工作流程:
|
* 五后端实现(按优先级):
|
||||||
* 1. 用户长按 CapsLock 1 秒后触发录音
|
* 1. X11 XGrabKey(X11 会话,零权限)
|
||||||
* 2. 长按期间持续录音
|
* 2. evdev grab + uinput replay(Wayland,需 input 组)
|
||||||
* 3. 松开 CapsLock 后停止录音并触发转写
|
* 3. GNOME 扩展(GNOME 46+,通过 D-Bus 信号)
|
||||||
* 4. 短按(< 1s)直接传递 CapsLock 事件(切换大小写锁定)
|
* 4. libei(GNOME 47+/KDE,libinput-emulator)
|
||||||
|
* 5. Portal(KDE/默认,GlobalShortcuts D-Bus Portal)
|
||||||
*/
|
*/
|
||||||
class CapsLockVoiceHotkey : public QObject {
|
class CapsLockVoiceHotkey : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@ -31,15 +34,18 @@ public:
|
|||||||
/** @brief 停止并注销快捷键 */
|
/** @brief 停止并注销快捷键 */
|
||||||
void stop();
|
void stop();
|
||||||
|
|
||||||
/** @brief 是否已激活 */
|
/** @brief 是否已激活或正在等待 Portal 授权 */
|
||||||
bool isActive() const { return active_; }
|
bool isActive() const { return active_ || pending_; }
|
||||||
|
|
||||||
/** @brief 当前是否正在录音(CapsLock 长按超过 1s 后) */
|
/** @brief 当前是否正在录音 */
|
||||||
bool isRecording() const { return recording_; }
|
bool isRecording() const { return recording_; }
|
||||||
|
|
||||||
/** @brief 临时忽略信号(XTest 模拟按键期间) */
|
/** @brief 临时忽略信号(XTest 模拟按键期间) */
|
||||||
void setIgnoreEvents(bool ignore) { ignoreEvents_ = ignore; }
|
void setIgnoreEvents(bool ignore) { ignoreEvents_ = ignore; }
|
||||||
|
|
||||||
|
/** @brief 设置快捷键组合(如 "Ctrl+Alt+C") */
|
||||||
|
void setHotkeyCombo(const QString& combo);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void recordingStarted();
|
void recordingStarted();
|
||||||
void recordingStopped();
|
void recordingStopped();
|
||||||
@ -50,11 +56,28 @@ private:
|
|||||||
struct Impl;
|
struct Impl;
|
||||||
std::unique_ptr<Impl> impl_;
|
std::unique_ptr<Impl> impl_;
|
||||||
bool active_ = false;
|
bool active_ = false;
|
||||||
|
bool pending_ = false; // Portal 授权进行中
|
||||||
bool recording_ = false;
|
bool recording_ = false;
|
||||||
bool ignoreEvents_ = false;
|
bool ignoreEvents_ = false;
|
||||||
|
QString hotkeyCombo_;
|
||||||
|
|
||||||
// Portal 后端方法
|
// 纯 C++ 后端
|
||||||
void startPortal();
|
std::unique_ptr<CapsLockBackend> nativeBackend_;
|
||||||
|
|
||||||
|
/** @brief 切换模式:按下快捷键时在录音/停止之间切换 */
|
||||||
|
void toggleRecording();
|
||||||
|
|
||||||
|
void onNativePressed();
|
||||||
|
void onNativeReleased();
|
||||||
|
|
||||||
|
// GNOME 扩展后端
|
||||||
|
bool startGnomeExtension();
|
||||||
|
void stopGnomeExtension();
|
||||||
|
void onGnomeCapsLockPressed();
|
||||||
|
void onGnomeCapsLockReleased();
|
||||||
|
|
||||||
|
// Portal 后端
|
||||||
|
bool startPortal();
|
||||||
void stopPortal();
|
void stopPortal();
|
||||||
void handleSessionResponse(uint response, const QVariantMap& results);
|
void handleSessionResponse(uint response, const QVariantMap& results);
|
||||||
void handleBindResponse(uint response, const QVariantMap& results);
|
void handleBindResponse(uint response, const QVariantMap& results);
|
||||||
@ -62,7 +85,7 @@ private:
|
|||||||
void handleDeactivated(const QString& shortcutId);
|
void handleDeactivated(const QString& shortcutId);
|
||||||
void onPortalResponse(uint response, const QVariantMap& results);
|
void onPortalResponse(uint response, const QVariantMap& results);
|
||||||
|
|
||||||
// libei 后端方法
|
// libei 后端
|
||||||
void startLibei();
|
void startLibei();
|
||||||
void stopLibei();
|
void stopLibei();
|
||||||
void onLibeiEvent();
|
void onLibeiEvent();
|
||||||
|
|||||||
@ -41,20 +41,6 @@ VoiceInputService::VoiceInputService(ConfigManager* configManager,
|
|||||||
, impl_(std::make_unique<Impl>())
|
, impl_(std::make_unique<Impl>())
|
||||||
{
|
{
|
||||||
impl_->sttEngine = sttEngine;
|
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() {
|
VoiceInputService::~VoiceInputService() {
|
||||||
@ -80,6 +66,11 @@ bool VoiceInputService::start() {
|
|||||||
|
|
||||||
// 3. 初始化全局快捷键
|
// 3. 初始化全局快捷键
|
||||||
impl_->hotkey = new CapsLockVoiceHotkey(this);
|
impl_->hotkey = new CapsLockVoiceHotkey(this);
|
||||||
|
|
||||||
|
// 从配置读取快捷键组合
|
||||||
|
QString hotkeyStr = configManager_->get("shortcuts.voice_hotkey").toString();
|
||||||
|
impl_->hotkey->setHotkeyCombo(hotkeyStr);
|
||||||
|
|
||||||
connect(impl_->hotkey, &CapsLockVoiceHotkey::recordingStarted,
|
connect(impl_->hotkey, &CapsLockVoiceHotkey::recordingStarted,
|
||||||
this, &VoiceInputService::onHotkeyActivated);
|
this, &VoiceInputService::onHotkeyActivated);
|
||||||
connect(impl_->hotkey, &CapsLockVoiceHotkey::recordingStopped,
|
connect(impl_->hotkey, &CapsLockVoiceHotkey::recordingStopped,
|
||||||
@ -91,6 +82,15 @@ bool VoiceInputService::start() {
|
|||||||
connect(impl_->hotkey, &CapsLockVoiceHotkey::error,
|
connect(impl_->hotkey, &CapsLockVoiceHotkey::error,
|
||||||
this, &VoiceInputService::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. 初始化文本注入器
|
// 4. 初始化文本注入器
|
||||||
impl_->injector = new WaylandTextInjector(this);
|
impl_->injector = new WaylandTextInjector(this);
|
||||||
if (!impl_->injector->initialize()) {
|
if (!impl_->injector->initialize()) {
|
||||||
@ -114,9 +114,6 @@ bool VoiceInputService::start() {
|
|||||||
void VoiceInputService::stop() {
|
void VoiceInputService::stop() {
|
||||||
if (!running_) return;
|
if (!running_) return;
|
||||||
|
|
||||||
longPressTimer_->stop();
|
|
||||||
cooldownTimer_->stop();
|
|
||||||
|
|
||||||
if (impl_->audioCapture) {
|
if (impl_->audioCapture) {
|
||||||
impl_->audioCapture->stopAndClose(); // 彻底关闭流
|
impl_->audioCapture->stopAndClose(); // 彻底关闭流
|
||||||
}
|
}
|
||||||
@ -133,13 +130,13 @@ void VoiceInputService::stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void VoiceInputService::onHotkeyActivated() {
|
void VoiceInputService::onHotkeyActivated() {
|
||||||
// Recording 和 Cooldown 状态:屏蔽所有 Activated(防抖核心)
|
// Recording 和 Cooldown 状态:屏蔽所有 Activated(防抖)
|
||||||
if (state_ == Recording || state_ == Cooldown) {
|
if (state_ == Recording || state_ == Cooldown) {
|
||||||
LOG_DEBUG(kTag, QString("忽略 Activated (state=%1)").arg(state_ == Recording ? "Recording" : "Cooldown"));
|
LOG_DEBUG(kTag, QString("忽略 Activated (state=%1)").arg(state_ == Recording ? "Recording" : "Cooldown"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle → 直接进入 Recording,消除 1s 延迟
|
// Idle → 直接进入 Recording
|
||||||
state_ = Recording;
|
state_ = Recording;
|
||||||
recording_ = true;
|
recording_ = true;
|
||||||
audioBuffer_.clear();
|
audioBuffer_.clear();
|
||||||
@ -149,21 +146,20 @@ void VoiceInputService::onHotkeyActivated() {
|
|||||||
int bufferSizeMs = configManager_->get("audio.buffer_size_ms").toInt();
|
int bufferSizeMs = configManager_->get("audio.buffer_size_ms").toInt();
|
||||||
impl_->audioCapture->start(deviceIndex, sampleRate, bufferSizeMs);
|
impl_->audioCapture->start(deviceIndex, sampleRate, bufferSizeMs);
|
||||||
|
|
||||||
// 延迟统计(现在应该接近 0)
|
|
||||||
hotkeyLatencyTimer_.start();
|
hotkeyLatencyTimer_.start();
|
||||||
latencyTracking_ = true;
|
latencyTracking_ = true;
|
||||||
qint64 latencyMs = 0;
|
qint64 latencyMs = 0;
|
||||||
|
|
||||||
LOG_DEBUG(kTag, "Idle → Recording (立即开始录音)");
|
LOG_DEBUG(kTag, "→ Recording (开始录音)");
|
||||||
emit statusChanged("正在录音...");
|
emit statusChanged("正在录音...");
|
||||||
|
|
||||||
// 统计打印
|
// 统计
|
||||||
totalKeyCount_++;
|
totalKeyCount_++;
|
||||||
totalLatencyMs_ += latencyMs;
|
totalLatencyMs_ += latencyMs;
|
||||||
maxLatencyMs_ = std::max(maxLatencyMs_, (double)latencyMs);
|
maxLatencyMs_ = std::max(maxLatencyMs_, (double)latencyMs);
|
||||||
minLatencyMs_ = std::min(minLatencyMs_, (double)latencyMs);
|
minLatencyMs_ = std::min(minLatencyMs_, (double)latencyMs);
|
||||||
double avgMs = totalLatencyMs_ / totalKeyCount_;
|
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(latencyMs).arg(avgMs, 0, 'f', 0)
|
||||||
.arg(minLatencyMs_, 0, 'f', 0).arg(maxLatencyMs_, 0, 'f', 0)
|
.arg(minLatencyMs_, 0, 'f', 0).arg(maxLatencyMs_, 0, 'f', 0)
|
||||||
.arg(totalKeyCount_));
|
.arg(totalKeyCount_));
|
||||||
@ -171,14 +167,13 @@ void VoiceInputService::onHotkeyActivated() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void VoiceInputService::onHotkeyDeactivated() {
|
void VoiceInputService::onHotkeyDeactivated() {
|
||||||
// Cooldown 状态的 Deactivated → 忽略
|
// Cooldown 状态忽略
|
||||||
if (state_ == Cooldown) {
|
if (state_ == Cooldown) {
|
||||||
LOG_DEBUG(kTag, "忽略 Deactivated (Cooldown)");
|
LOG_DEBUG(kTag, "忽略 Deactivated (Cooldown)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
longPressTimer_->stop();
|
|
||||||
|
|
||||||
// 停止音频采集
|
// 停止音频采集
|
||||||
if (impl_->audioCapture && impl_->audioCapture->isRunning()) {
|
if (impl_->audioCapture && impl_->audioCapture->isRunning()) {
|
||||||
@ -186,15 +181,20 @@ void VoiceInputService::onHotkeyDeactivated() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state_ == Recording) {
|
if (state_ == Recording) {
|
||||||
// 松开 → 开始识别(CapsLock 灯在识别完成后复位,避免重复)
|
|
||||||
state_ = Idle;
|
state_ = Idle;
|
||||||
LOG_DEBUG(kTag, "Recording → Idle (松开转写)");
|
LOG_DEBUG(kTag, "Recording → Idle (停止转写)");
|
||||||
stopRecordingAndTranscribe();
|
stopRecordingAndTranscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动冷却期
|
// 启动冷却期
|
||||||
state_ = Cooldown;
|
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_));
|
LOG_DEBUG(kTag, QString("→ Cooldown (%1ms)").arg(releaseCooldownMs_));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,8 +207,6 @@ void VoiceInputService::onAudioData(const std::vector<float>& samples, int sampl
|
|||||||
|
|
||||||
void VoiceInputService::stopRecordingAndTranscribe() {
|
void VoiceInputService::stopRecordingAndTranscribe() {
|
||||||
if (audioBuffer_.empty()) {
|
if (audioBuffer_.empty()) {
|
||||||
// 无音频 → 复位 CapsLock 灯
|
|
||||||
simulateCapsLock();
|
|
||||||
emit statusChanged("未检测到音频输入");
|
emit statusChanged("未检测到音频输入");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -237,9 +235,6 @@ void VoiceInputService::stopRecordingAndTranscribe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void VoiceInputService::onRecognitionComplete(const QString& text) {
|
void VoiceInputService::onRecognitionComplete(const QString& text) {
|
||||||
// 识别完成后,复位 CapsLock 灯
|
|
||||||
simulateCapsLock();
|
|
||||||
|
|
||||||
if (text.isEmpty()) {
|
if (text.isEmpty()) {
|
||||||
emit statusChanged("识别结果:无语音输入");
|
emit statusChanged("识别结果:无语音输入");
|
||||||
return;
|
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
|
} // namespace impress
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
#include "app/config_manager.h"
|
#include "app/config_manager.h"
|
||||||
#include "app/application.h"
|
#include "app/application.h"
|
||||||
#include "utils/logger.h"
|
#include "utils/logger.h"
|
||||||
|
#include "widgets/recording_indicator.h"
|
||||||
|
|
||||||
#include <QMenuBar>
|
#include <QMenuBar>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
@ -124,6 +125,17 @@ MainWindow::MainWindow(ConfigManager* configManager,
|
|||||||
});
|
});
|
||||||
LOG_INFO(kTag, "VoiceInputService 已创建");
|
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,
|
connect(configManager_, &ConfigManager::configChanged,
|
||||||
this, &MainWindow::onVoiceInputConfigChanged);
|
this, &MainWindow::onVoiceInputConfigChanged);
|
||||||
@ -352,7 +364,7 @@ void MainWindow::showUsage() {
|
|||||||
|
|
||||||
"<h3>二、快捷键操作</h3>"
|
"<h3>二、快捷键操作</h3>"
|
||||||
"<table cellpadding='4' cellspacing='0'>"
|
"<table cellpadding='4' cellspacing='0'>"
|
||||||
"<tr><td><b>语音输入</b></td><td>长按 CapsLock 超过 1 秒(可在配置中自定义)</td></tr>"
|
"<tr><td><b>语音输入</b></td><td>Ctrl+Alt+C(切换模式,可在配置中自定义)</td></tr>"
|
||||||
"<tr><td><b>使用说明</b></td><td>F1</td></tr>"
|
"<tr><td><b>使用说明</b></td><td>F1</td></tr>"
|
||||||
"<tr><td><b>重启应用</b></td><td>Ctrl+R</td></tr>"
|
"<tr><td><b>重启应用</b></td><td>Ctrl+R</td></tr>"
|
||||||
"<tr><td><b>退出应用</b></td><td>Ctrl+Q</td></tr>"
|
"<tr><td><b>退出应用</b></td><td>Ctrl+Q</td></tr>"
|
||||||
@ -361,12 +373,12 @@ void MainWindow::showUsage() {
|
|||||||
"<h3>三、语音输入使用流程</h3>"
|
"<h3>三、语音输入使用流程</h3>"
|
||||||
"<ol>"
|
"<ol>"
|
||||||
"<li>在<b>配置</b>页面中设置正确的 STT 模型路径并保存。</li>"
|
"<li>在<b>配置</b>页面中设置正确的 STT 模型路径并保存。</li>"
|
||||||
"<li>设置语音输入快捷键(默认长按 CapsLock)。</li>"
|
"<li>语音输入快捷键默认为 Ctrl+Alt+C(可在配置中自定义)。</li>"
|
||||||
"<li>将光标定位到需要输入文字的目标应用(如微信、Word、浏览器等)。</li>"
|
"<li>将光标定位到需要输入文字的目标应用(如微信、Word、浏览器等)。</li>"
|
||||||
"<li>长按快捷键开始说话,说完后松开快捷键。</li>"
|
"<li>按下快捷键开始录音,说完后再次按下快捷键停止录音并自动转写。</li>"
|
||||||
"<li>识别的文字将通过模拟按键自动输入到目标应用中。</li>"
|
"<li>识别的文字将通过模拟按键自动输入到目标应用中。</li>"
|
||||||
"</ol>"
|
"</ol>"
|
||||||
"<p><b>CapsLock 工作模式:</b>短按(<1 秒)正常切换大小写锁定;长按(>1 秒)触发语音输入,松开后自动识别并注入文字。</p>"
|
"<p><b>切换模式:</b>第一次按下快捷键开始录音(光标位置显示录音指示器),再次按下停止录音并自动转写。</p>"
|
||||||
|
|
||||||
"<h3>四、文件转写使用流程</h3>"
|
"<h3>四、文件转写使用流程</h3>"
|
||||||
"<ol>"
|
"<ol>"
|
||||||
@ -412,8 +424,8 @@ void MainWindow::showUsage() {
|
|||||||
"A: 某些应用可能拦截模拟按键输入,请尝试在管理员权限下运行本程序。</p>"
|
"A: 某些应用可能拦截模拟按键输入,请尝试在管理员权限下运行本程序。</p>"
|
||||||
"<p><b>Q: 识别速度慢?</b><br/>"
|
"<p><b>Q: 识别速度慢?</b><br/>"
|
||||||
"A: 在配置中增大 ONNX 线程数,或使用 GPU 版本的 ONNX Runtime。</p>"
|
"A: 在配置中增大 ONNX 线程数,或使用 GPU 版本的 ONNX Runtime。</p>"
|
||||||
"<p><b>Q: CapsLock 短按不起作用?</b><br/>"
|
"<p><b>Q: 快捷键不生效?</b><br/>"
|
||||||
"A: 请确保按键时间小于 1 秒,超过 1 秒会触发语音输入模式。</p>";
|
"A: 检查是否与其他应用快捷键冲突,或在配置中修改为其他组合键。</p>";
|
||||||
|
|
||||||
// 使用可调整大小、可滚动的 QDialog 替代 QMessageBox
|
// 使用可调整大小、可滚动的 QDialog 替代 QMessageBox
|
||||||
QDialog* dialog = new QDialog(this);
|
QDialog* dialog = new QDialog(this);
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class STTTestPage;
|
|||||||
class FileTranscribePage;
|
class FileTranscribePage;
|
||||||
class SettingsPage;
|
class SettingsPage;
|
||||||
class VoiceInputService;
|
class VoiceInputService;
|
||||||
|
class RecordingIndicator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 主窗口
|
* @brief 主窗口
|
||||||
@ -60,6 +61,7 @@ private:
|
|||||||
QMenu* trayMenu_ = nullptr;
|
QMenu* trayMenu_ = nullptr;
|
||||||
QIcon idleIcon_; // SP_MediaStop — 就绪/停止
|
QIcon idleIcon_; // SP_MediaStop — 就绪/停止
|
||||||
QIcon activeIcon_; // SP_MediaPlay — 录音/识别
|
QIcon activeIcon_; // SP_MediaPlay — 录音/识别
|
||||||
|
RecordingIndicator* recordingIndicator_ = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace impress
|
} // namespace impress
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
#include "hotkey_recorder.h"
|
#include "hotkey_recorder.h"
|
||||||
|
#include "utils/logger.h"
|
||||||
|
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QKeyEvent>
|
#include <QKeyEvent>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
|
||||||
|
static const char* const kTag = "HotkeyRecorder";
|
||||||
|
|
||||||
namespace impress {
|
namespace impress {
|
||||||
|
|
||||||
HotkeyRecorder::HotkeyRecorder(const QString& label, QWidget* parent)
|
HotkeyRecorder::HotkeyRecorder(const QString& label, QWidget* parent)
|
||||||
@ -19,11 +22,12 @@ HotkeyRecorder::HotkeyRecorder(const QString& label, QWidget* parent)
|
|||||||
btn_ = new QPushButton(this);
|
btn_ = new QPushButton(this);
|
||||||
btn_->setMinimumWidth(140);
|
btn_->setMinimumWidth(140);
|
||||||
btn_->setFocusPolicy(Qt::StrongFocus);
|
btn_->setFocusPolicy(Qt::StrongFocus);
|
||||||
|
btn_->installEventFilter(this); // 拦截按钮的键盘事件
|
||||||
connect(btn_, &QPushButton::clicked, this, &HotkeyRecorder::onToggleRecording);
|
connect(btn_, &QPushButton::clicked, this, &HotkeyRecorder::onToggleRecording);
|
||||||
layout->addWidget(btn_);
|
layout->addWidget(btn_);
|
||||||
layout->addStretch();
|
layout->addStretch();
|
||||||
|
|
||||||
setHotkeyText("CapsLock");
|
setHotkeyText("Ctrl+Alt+C");
|
||||||
applyStyle();
|
applyStyle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,11 +48,13 @@ void HotkeyRecorder::onToggleRecording() {
|
|||||||
if (recording_) {
|
if (recording_) {
|
||||||
// 取消录制
|
// 取消录制
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
|
LOG_INFO(kTag, "录制模式已取消");
|
||||||
updateDisplay();
|
updateDisplay();
|
||||||
applyStyle();
|
applyStyle();
|
||||||
} else {
|
} else {
|
||||||
// 进入录制模式
|
// 进入录制模式
|
||||||
recording_ = true;
|
recording_ = true;
|
||||||
|
LOG_INFO(kTag, "进入录制模式,等待按键...");
|
||||||
updateDisplay();
|
updateDisplay();
|
||||||
applyStyle();
|
applyStyle();
|
||||||
btn_->setFocus();
|
btn_->setFocus();
|
||||||
@ -61,15 +67,18 @@ bool HotkeyRecorder::eventFilter(QObject* obj, QEvent* event) {
|
|||||||
|
|
||||||
if (event->type() == QEvent::KeyPress) {
|
if (event->type() == QEvent::KeyPress) {
|
||||||
auto* ke = static_cast<QKeyEvent*>(event);
|
auto* ke = static_cast<QKeyEvent*>(event);
|
||||||
|
LOG_INFO(kTag, QString("捕获按键: key=%1 modifiers=%2").arg(ke->key()).arg(static_cast<int>(ke->modifiers())));
|
||||||
|
|
||||||
// 忽略单独的修饰键
|
// 忽略单独的修饰键
|
||||||
if (ke->key() == Qt::Key_Control || ke->key() == Qt::Key_Alt ||
|
if (ke->key() == Qt::Key_Control || ke->key() == Qt::Key_Alt ||
|
||||||
ke->key() == Qt::Key_Shift || ke->key() == Qt::Key_Meta) {
|
ke->key() == Qt::Key_Shift || ke->key() == Qt::Key_Meta) {
|
||||||
|
LOG_DEBUG(kTag, "忽略单独修饰键");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esc 取消录制
|
// Esc 取消录制
|
||||||
if (ke->key() == Qt::Key_Escape) {
|
if (ke->key() == Qt::Key_Escape) {
|
||||||
|
LOG_INFO(kTag, "Esc 取消录制");
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
updateDisplay();
|
updateDisplay();
|
||||||
applyStyle();
|
applyStyle();
|
||||||
@ -94,6 +103,7 @@ bool HotkeyRecorder::eventFilter(QObject* obj, QEvent* event) {
|
|||||||
|
|
||||||
hotkeyText_ = text;
|
hotkeyText_ = text;
|
||||||
recording_ = false;
|
recording_ = false;
|
||||||
|
LOG_INFO(kTag, QString("快捷键已设置: %1").arg(hotkeyText_));
|
||||||
updateDisplay();
|
updateDisplay();
|
||||||
applyStyle();
|
applyStyle();
|
||||||
btn_->releaseKeyboard();
|
btn_->releaseKeyboard();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user