From a2b216092f6145ca4ba43902c83e6b24c4fd2ac8 Mon Sep 17 00:00:00 2001 From: impressionyang Date: Wed, 13 May 2026 14:49:55 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=AD=BB=E9=94=81=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=94=AE=E5=BD=95=E5=88=B6=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 ConfigManager::set() 在持有锁时发射信号导致的死锁 - 添加 setBatch() 方法批量更新配置,只发射一次 configChanged - 新增 HotkeyRecorder 组件:点击按钮后按键录制任意快捷键 - SettingsPage 保存配置改为批量写入,避免多次触发服务重启 Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 2 + src/app/config_manager.cpp | 84 +++++++++++------- src/app/config_manager.h | 3 + src/ui/main_window.cpp | 8 +- src/ui/settings_page.cpp | 56 +++++++----- src/ui/settings_page.h | 3 +- src/ui/widgets/hotkey_recorder.cpp | 138 +++++++++++++++++++++++++++++ src/ui/widgets/hotkey_recorder.h | 52 +++++++++++ 8 files changed, 284 insertions(+), 62 deletions(-) create mode 100644 src/ui/widgets/hotkey_recorder.cpp create mode 100644 src/ui/widgets/hotkey_recorder.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 817b0d3..2362600 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,6 +76,7 @@ set(SOURCES src/ui/widgets/audio_waveform.cpp src/ui/widgets/text_output.cpp src/ui/widgets/progress_panel.cpp + src/ui/widgets/hotkey_recorder.cpp # Utils src/utils/logger.cpp @@ -109,6 +110,7 @@ set(HEADERS src/ui/widgets/audio_waveform.h src/ui/widgets/text_output.h src/ui/widgets/progress_panel.h + src/ui/widgets/hotkey_recorder.h src/utils/logger.h src/utils/timer.h diff --git a/src/app/config_manager.cpp b/src/app/config_manager.cpp index 5f12524..5781285 100644 --- a/src/app/config_manager.cpp +++ b/src/app/config_manager.cpp @@ -62,38 +62,40 @@ bool ConfigManager::saveAs(const QString& path) { } void ConfigManager::loadDefaults() { - QMutexLocker locker(&mutex_); - config_ = QVariantMap{ - {"stt", QVariantMap{ - {"model_path", ""}, - {"model_type", "sense_voice"}, - {"tokens_path", ""}, - {"device", "cpu"}, - {"num_threads", 4}, - {"sample_rate", 16000}, - {"language", "zh"}, - {"streaming", true}, - {"beam_size", 5}, - {"temperature", 0.0}, - {"debug_save_audio", false}, - {"capslock_voice_enabled", false} - }}, - {"audio", QVariantMap{ - {"input_device", -1}, - {"buffer_size_ms", 20}, - {"chunk_duration_ms", 3000}, - {"padding_ms", 500} - }}, - {"ui", QVariantMap{ - {"theme", "light"}, - {"font_size", 14}, - {"show_waveform", true}, - {"show_confidence", true} - }}, - {"shortcuts", QVariantMap{ - {"toggle_recording", "Ctrl+Space"} - }} - }; + { + QMutexLocker locker(&mutex_); + config_ = QVariantMap{ + {"stt", QVariantMap{ + {"model_path", ""}, + {"model_type", "sense_voice"}, + {"tokens_path", ""}, + {"device", "cpu"}, + {"num_threads", 4}, + {"sample_rate", 16000}, + {"language", "zh"}, + {"streaming", true}, + {"beam_size", 5}, + {"temperature", 0.0}, + {"debug_save_audio", false}, + {"capslock_voice_enabled", false} + }}, + {"audio", QVariantMap{ + {"input_device", -1}, + {"buffer_size_ms", 20}, + {"chunk_duration_ms", 3000}, + {"padding_ms", 500} + }}, + {"ui", QVariantMap{ + {"theme", "light"}, + {"font_size", 14}, + {"show_waveform", true}, + {"show_confidence", true} + }}, + {"shortcuts", QVariantMap{ + {"voice_hotkey", "CapsLock"} + }} + }; + } emit configChanged(); } @@ -116,8 +118,22 @@ QVariant ConfigManager::getValue(const QVariantMap& map, const QStringList& part } void ConfigManager::set(const QString& key, const QVariant& value) { - QMutexLocker locker(&mutex_); - setValue(config_, key.split('.'), 0, value); + { + QMutexLocker locker(&mutex_); + setValue(config_, key.split('.'), 0, value); + } + // 锁释放后再发射信号,防止槽函数中调用 get() 时死锁 + emit configChanged(); +} + +void ConfigManager::setBatch(const QMap& pairs) { + { + QMutexLocker locker(&mutex_); + for (auto it = pairs.constBegin(); it != pairs.constEnd(); ++it) { + setValue(config_, it.key().split('.'), 0, it.value()); + } + } + // 锁释放后再发射信号,只发射一次 emit configChanged(); } diff --git a/src/app/config_manager.h b/src/app/config_manager.h index 8b99419..6db92b4 100644 --- a/src/app/config_manager.h +++ b/src/app/config_manager.h @@ -36,6 +36,9 @@ public: /** @brief 设置配置值 */ void set(const QString& key, const QVariant& value); + /** @brief 批量设置多个配置值(只发射一次 configChanged) */ + void setBatch(const QMap& pairs); + /** @brief 重置为默认配置 */ void resetToDefaults(); diff --git a/src/ui/main_window.cpp b/src/ui/main_window.cpp index fadc3b9..43aed47 100644 --- a/src/ui/main_window.cpp +++ b/src/ui/main_window.cpp @@ -168,13 +168,15 @@ void MainWindow::onVoiceInputConfigChanged() { // 更新模型状态显示 updateModelStatus(); - bool enabled = configManager_->get("stt.capslock_voice_enabled").toBool(); + // 当设置了语音快捷键时启用语音输入服务 + QString hotkey = configManager_->get("shortcuts.voice_hotkey").toString(); + bool enabled = !hotkey.isEmpty() && hotkey != "未设置"; if (enabled && !voiceInputService_->isRunning()) { voiceInputService_->start(); - LOG_INFO(kTag, "CapsLock 语音输入已启用"); + LOG_INFO(kTag, QString("语音输入已启用(快捷键: %1)").arg(hotkey)); } else if (!enabled && voiceInputService_->isRunning()) { voiceInputService_->stop(); - LOG_INFO(kTag, "CapsLock 语音输入已关闭"); + LOG_INFO(kTag, "语音输入已关闭"); } } diff --git a/src/ui/settings_page.cpp b/src/ui/settings_page.cpp index 07c8553..e41d2df 100644 --- a/src/ui/settings_page.cpp +++ b/src/ui/settings_page.cpp @@ -1,5 +1,6 @@ #include "settings_page.h" #include "app/config_manager.h" +#include "widgets/hotkey_recorder.h" #include "utils/logger.h" #include @@ -87,9 +88,14 @@ void SettingsPage::setupUI() { debugSaveAudioCheck_->setToolTip("开启后,每次识别会将原始音频保存为 WAV 文件到系统临时目录,用于调试音频质量问题"); sttLayout->addRow("调试录音:", debugSaveAudioCheck_); - capslockVoiceCheck_ = new QCheckBox("启用 CapsLock 长按语音输入", this); - capslockVoiceCheck_->setToolTip("长按 CapsLock 键 1 秒后触发录音,松开后自动转写并输入到光标位置"); - sttLayout->addRow("快捷语音:", capslockVoiceCheck_); + // 快捷键录制按钮 + hotkeyRecorder_ = new HotkeyRecorder("语音快捷键:", this); + hotkeyRecorder_->setToolTip("点击按钮后按下快捷键(如 Ctrl+Alt+K),支持组合键。Esc 取消录制。"); + connect(hotkeyRecorder_, &HotkeyRecorder::hotkeyChanged, + this, [this](const QString& key) { + configManager_->set("shortcuts.voice_hotkey", key); + }); + sttLayout->addRow(hotkeyRecorder_); beamSizeSpin_ = new QSpinBox(this); beamSizeSpin_->setRange(1, 20); @@ -183,7 +189,7 @@ void SettingsPage::loadFromConfig() { languageCombo_->setCurrentText(configManager_->get("stt.language").toString()); streamingCheck_->setChecked(configManager_->get("stt.streaming").toBool()); debugSaveAudioCheck_->setChecked(configManager_->get("stt.debug_save_audio").toBool()); - capslockVoiceCheck_->setChecked(configManager_->get("stt.capslock_voice_enabled").toBool()); + hotkeyRecorder_->setHotkeyText(configManager_->get("shortcuts.voice_hotkey").toString()); beamSizeSpin_->setValue(configManager_->get("stt.beam_size").toInt()); temperatureSpin_->setValue(configManager_->get("stt.temperature").toDouble()); @@ -198,27 +204,29 @@ void SettingsPage::loadFromConfig() { } void SettingsPage::saveToConfig() { - configManager_->set("stt.model_path", modelPathEdit_->text()); - configManager_->set("stt.tokens_path", tokensPathEdit_->text()); - configManager_->set("stt.model_type", modelTypeCombo_->currentText()); - configManager_->set("stt.device", deviceCombo_->currentText()); - configManager_->set("stt.num_threads", threadSpin_->value()); - configManager_->set("stt.sample_rate", sampleRateSpin_->value()); - configManager_->set("stt.language", languageCombo_->currentText()); - configManager_->set("stt.streaming", streamingCheck_->isChecked()); - configManager_->set("stt.debug_save_audio", debugSaveAudioCheck_->isChecked()); - configManager_->set("stt.capslock_voice_enabled", capslockVoiceCheck_->isChecked()); - configManager_->set("stt.beam_size", beamSizeSpin_->value()); - configManager_->set("stt.temperature", temperatureSpin_->value()); + // 批量写入所有配置,只发射一次 configChanged 信号 + QMap batch; + batch["stt.model_path"] = modelPathEdit_->text(); + batch["stt.tokens_path"] = tokensPathEdit_->text(); + batch["stt.model_type"] = modelTypeCombo_->currentText(); + batch["stt.device"] = deviceCombo_->currentText(); + batch["stt.num_threads"] = threadSpin_->value(); + batch["stt.sample_rate"] = sampleRateSpin_->value(); + batch["stt.language"] = languageCombo_->currentText(); + batch["stt.streaming"] = streamingCheck_->isChecked(); + batch["stt.debug_save_audio"] = debugSaveAudioCheck_->isChecked(); + batch["stt.beam_size"] = beamSizeSpin_->value(); + batch["stt.temperature"] = temperatureSpin_->value(); + batch["shortcuts.voice_hotkey"] = hotkeyRecorder_->hotkeyText(); + batch["audio.buffer_size_ms"] = bufferSizeSpin_->value(); + batch["audio.chunk_duration_ms"] = chunkDurationSpin_->value(); + batch["audio.padding_ms"] = paddingSpin_->value(); + batch["ui.theme"] = themeCombo_->currentText(); + batch["ui.font_size"] = fontSizeSpin_->value(); + batch["ui.show_waveform"] = showWaveformCheck_->isChecked(); + batch["ui.show_confidence"] = showConfidenceCheck_->isChecked(); - configManager_->set("audio.buffer_size_ms", bufferSizeSpin_->value()); - configManager_->set("audio.chunk_duration_ms", chunkDurationSpin_->value()); - configManager_->set("audio.padding_ms", paddingSpin_->value()); - - configManager_->set("ui.theme", themeCombo_->currentText()); - configManager_->set("ui.font_size", fontSizeSpin_->value()); - configManager_->set("ui.show_waveform", showWaveformCheck_->isChecked()); - configManager_->set("ui.show_confidence", showConfidenceCheck_->isChecked()); + configManager_->setBatch(batch); } void SettingsPage::onBrowseModelPath() { diff --git a/src/ui/settings_page.h b/src/ui/settings_page.h index e57f130..aa28d37 100644 --- a/src/ui/settings_page.h +++ b/src/ui/settings_page.h @@ -15,6 +15,7 @@ class QGroupBox; namespace impress { class ConfigManager; +class HotkeyRecorder; /** * @brief 配置页面 @@ -52,7 +53,7 @@ private: QComboBox* languageCombo_; QCheckBox* streamingCheck_; QCheckBox* debugSaveAudioCheck_; - QCheckBox* capslockVoiceCheck_; + HotkeyRecorder* hotkeyRecorder_; QSpinBox* beamSizeSpin_; QDoubleSpinBox* temperatureSpin_; diff --git a/src/ui/widgets/hotkey_recorder.cpp b/src/ui/widgets/hotkey_recorder.cpp new file mode 100644 index 0000000..394832c --- /dev/null +++ b/src/ui/widgets/hotkey_recorder.cpp @@ -0,0 +1,138 @@ +#include "hotkey_recorder.h" + +#include +#include +#include +#include + +namespace impress { + +HotkeyRecorder::HotkeyRecorder(const QString& label, QWidget* parent) + : QWidget(parent) +{ + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + label_ = new QLabel(label, this); + layout->addWidget(label_); + + btn_ = new QPushButton(this); + btn_->setMinimumWidth(140); + btn_->setFocusPolicy(Qt::StrongFocus); + connect(btn_, &QPushButton::clicked, this, &HotkeyRecorder::onToggleRecording); + layout->addWidget(btn_); + layout->addStretch(); + + setHotkeyText("CapsLock"); + applyStyle(); +} + +HotkeyRecorder::~HotkeyRecorder() = default; + +QString HotkeyRecorder::hotkeyText() const { + return hotkeyText_; +} + +void HotkeyRecorder::setHotkeyText(const QString& text) { + hotkeyText_ = text.isEmpty() ? "未设置" : text; + recording_ = false; + updateDisplay(); + applyStyle(); +} + +void HotkeyRecorder::onToggleRecording() { + if (recording_) { + // 取消录制 + recording_ = false; + updateDisplay(); + applyStyle(); + } else { + // 进入录制模式 + recording_ = true; + updateDisplay(); + applyStyle(); + btn_->setFocus(); + btn_->grabKeyboard(); + } +} + +bool HotkeyRecorder::eventFilter(QObject* obj, QEvent* event) { + if (!recording_) return QWidget::eventFilter(obj, event); + + if (event->type() == QEvent::KeyPress) { + auto* ke = static_cast(event); + + // 忽略单独的修饰键 + if (ke->key() == Qt::Key_Control || ke->key() == Qt::Key_Alt || + ke->key() == Qt::Key_Shift || ke->key() == Qt::Key_Meta) { + return true; + } + + // Esc 取消录制 + if (ke->key() == Qt::Key_Escape) { + recording_ = false; + updateDisplay(); + applyStyle(); + btn_->releaseKeyboard(); + return true; + } + + // 构建快捷键文本 + QString text = hotkeyFromModifiers(ke->modifiers()); + if (ke->key() >= Qt::Key_F1 && ke->key() <= Qt::Key_F35) { + text += QString("F%1").arg(ke->key() - Qt::Key_F1 + 1); + } else if (ke->key() >= Qt::Key_0 && ke->key() <= Qt::Key_9) { + text += QString::number(ke->key() - Qt::Key_0); + } else if (ke->key() >= Qt::Key_A && ke->key() <= Qt::Key_Z) { + text += QChar(ke->key()); + } else if (ke->key() == Qt::Key_Space) { + text += "Space"; + } else { + // 其他按键使用 Qt 名称 + text += QKeySequence(ke->key()).toString(); + } + + hotkeyText_ = text; + recording_ = false; + updateDisplay(); + applyStyle(); + btn_->releaseKeyboard(); + emit hotkeyChanged(hotkeyText_); + return true; + } + + return QWidget::eventFilter(obj, event); +} + +void HotkeyRecorder::updateDisplay() { + if (recording_) { + btn_->setText("⏳ 请按键...(Esc 取消)"); + } else { + btn_->setText(hotkeyText_.isEmpty() ? "未设置" : hotkeyText_); + } +} + +void HotkeyRecorder::applyStyle() { + if (recording_) { + btn_->setStyleSheet( + "QPushButton { background-color: #f39c12; color: white; font-weight: bold; " + "padding: 6px 12px; border-radius: 4px; }" + "QPushButton:hover { background-color: #e67e22; }"); + } else { + btn_->setStyleSheet( + "QPushButton { background-color: #3498db; color: white; font-weight: bold; " + "padding: 6px 12px; border-radius: 4px; }" + "QPushButton:hover { background-color: #2980b9; }"); + } +} + +QString HotkeyRecorder::hotkeyFromModifiers(int modifiers) const { + QString text; + if (modifiers & Qt::ControlModifier) text += "Ctrl+"; + if (modifiers & Qt::AltModifier) text += "Alt+"; + if (modifiers & Qt::ShiftModifier) text += "Shift+"; + if (modifiers & Qt::MetaModifier) text += "Meta+"; + return text; +} + +} // namespace impress diff --git a/src/ui/widgets/hotkey_recorder.h b/src/ui/widgets/hotkey_recorder.h new file mode 100644 index 0000000..2d0ccd0 --- /dev/null +++ b/src/ui/widgets/hotkey_recorder.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +class QLabel; +class QPushButton; + +namespace impress { + +/** + * @brief 快捷键录制按钮 + * + * 点击后进入录制模式,用户按下任意组合键即可设置快捷键。 + * 支持 Ctrl/Alt/Shift/Meta 与字母、数字、功能键的组合。 + */ +class HotkeyRecorder : public QWidget { + Q_OBJECT +public: + explicit HotkeyRecorder(const QString& label = "快捷键", QWidget* parent = nullptr); + ~HotkeyRecorder() override; + + /** @brief 获取当前设置的快捷键文本(如 "Ctrl+Alt+K") */ + QString hotkeyText() const; + + /** @brief 设置快捷键文本 */ + void setHotkeyText(const QString& text); + + /** @brief 是否处于录制模式 */ + bool isRecording() const { return recording_; } + +signals: + void hotkeyChanged(const QString& hotkey); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + +private slots: + void onToggleRecording(); + +private: + void applyStyle(); + void updateDisplay(); + QString hotkeyFromModifiers(int modifiers) const; + + QLabel* label_; + QPushButton* btn_; + QString hotkeyText_; + bool recording_ = false; +}; + +} // namespace impress