fix: 修复配置保存死锁,添加快捷键录制组件

- 修复 ConfigManager::set() 在持有锁时发射信号导致的死锁
- 添加 setBatch() 方法批量更新配置,只发射一次 configChanged
- 新增 HotkeyRecorder 组件:点击按钮后按键录制任意快捷键
- SettingsPage 保存配置改为批量写入,避免多次触发服务重启

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alvin Young 2026-05-13 14:49:55 +08:00
parent 33ae22ce62
commit a2b216092f
8 changed files with 284 additions and 62 deletions

View File

@ -76,6 +76,7 @@ set(SOURCES
src/ui/widgets/audio_waveform.cpp src/ui/widgets/audio_waveform.cpp
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
# Utils # Utils
src/utils/logger.cpp src/utils/logger.cpp
@ -109,6 +110,7 @@ set(HEADERS
src/ui/widgets/audio_waveform.h src/ui/widgets/audio_waveform.h
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/utils/logger.h src/utils/logger.h
src/utils/timer.h src/utils/timer.h

View File

@ -62,38 +62,40 @@ bool ConfigManager::saveAs(const QString& path) {
} }
void ConfigManager::loadDefaults() { void ConfigManager::loadDefaults() {
QMutexLocker locker(&mutex_); {
config_ = QVariantMap{ QMutexLocker locker(&mutex_);
{"stt", QVariantMap{ config_ = QVariantMap{
{"model_path", ""}, {"stt", QVariantMap{
{"model_type", "sense_voice"}, {"model_path", ""},
{"tokens_path", ""}, {"model_type", "sense_voice"},
{"device", "cpu"}, {"tokens_path", ""},
{"num_threads", 4}, {"device", "cpu"},
{"sample_rate", 16000}, {"num_threads", 4},
{"language", "zh"}, {"sample_rate", 16000},
{"streaming", true}, {"language", "zh"},
{"beam_size", 5}, {"streaming", true},
{"temperature", 0.0}, {"beam_size", 5},
{"debug_save_audio", false}, {"temperature", 0.0},
{"capslock_voice_enabled", false} {"debug_save_audio", false},
}}, {"capslock_voice_enabled", false}
{"audio", QVariantMap{ }},
{"input_device", -1}, {"audio", QVariantMap{
{"buffer_size_ms", 20}, {"input_device", -1},
{"chunk_duration_ms", 3000}, {"buffer_size_ms", 20},
{"padding_ms", 500} {"chunk_duration_ms", 3000},
}}, {"padding_ms", 500}
{"ui", QVariantMap{ }},
{"theme", "light"}, {"ui", QVariantMap{
{"font_size", 14}, {"theme", "light"},
{"show_waveform", true}, {"font_size", 14},
{"show_confidence", true} {"show_waveform", true},
}}, {"show_confidence", true}
{"shortcuts", QVariantMap{ }},
{"toggle_recording", "Ctrl+Space"} {"shortcuts", QVariantMap{
}} {"voice_hotkey", "CapsLock"}
}; }}
};
}
emit configChanged(); emit configChanged();
} }
@ -116,8 +118,22 @@ QVariant ConfigManager::getValue(const QVariantMap& map, const QStringList& part
} }
void ConfigManager::set(const QString& key, const QVariant& value) { 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<QString, QVariant>& pairs) {
{
QMutexLocker locker(&mutex_);
for (auto it = pairs.constBegin(); it != pairs.constEnd(); ++it) {
setValue(config_, it.key().split('.'), 0, it.value());
}
}
// 锁释放后再发射信号,只发射一次
emit configChanged(); emit configChanged();
} }

View File

@ -36,6 +36,9 @@ public:
/** @brief 设置配置值 */ /** @brief 设置配置值 */
void set(const QString& key, const QVariant& value); void set(const QString& key, const QVariant& value);
/** @brief 批量设置多个配置值(只发射一次 configChanged */
void setBatch(const QMap<QString, QVariant>& pairs);
/** @brief 重置为默认配置 */ /** @brief 重置为默认配置 */
void resetToDefaults(); void resetToDefaults();

View File

@ -168,13 +168,15 @@ void MainWindow::onVoiceInputConfigChanged() {
// 更新模型状态显示 // 更新模型状态显示
updateModelStatus(); 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()) { if (enabled && !voiceInputService_->isRunning()) {
voiceInputService_->start(); voiceInputService_->start();
LOG_INFO(kTag, "CapsLock 语音输入已启用"); LOG_INFO(kTag, QString("语音输入已启用(快捷键: %1").arg(hotkey));
} else if (!enabled && voiceInputService_->isRunning()) { } else if (!enabled && voiceInputService_->isRunning()) {
voiceInputService_->stop(); voiceInputService_->stop();
LOG_INFO(kTag, "CapsLock 语音输入已关闭"); LOG_INFO(kTag, "语音输入已关闭");
} }
} }

View File

@ -1,5 +1,6 @@
#include "settings_page.h" #include "settings_page.h"
#include "app/config_manager.h" #include "app/config_manager.h"
#include "widgets/hotkey_recorder.h"
#include "utils/logger.h" #include "utils/logger.h"
#include <QVBoxLayout> #include <QVBoxLayout>
@ -87,9 +88,14 @@ void SettingsPage::setupUI() {
debugSaveAudioCheck_->setToolTip("开启后,每次识别会将原始音频保存为 WAV 文件到系统临时目录,用于调试音频质量问题"); debugSaveAudioCheck_->setToolTip("开启后,每次识别会将原始音频保存为 WAV 文件到系统临时目录,用于调试音频质量问题");
sttLayout->addRow("调试录音:", debugSaveAudioCheck_); sttLayout->addRow("调试录音:", debugSaveAudioCheck_);
capslockVoiceCheck_ = new QCheckBox("启用 CapsLock 长按语音输入", this); // 快捷键录制按钮
capslockVoiceCheck_->setToolTip("长按 CapsLock 键 1 秒后触发录音,松开后自动转写并输入到光标位置"); hotkeyRecorder_ = new HotkeyRecorder("语音快捷键:", this);
sttLayout->addRow("快捷语音:", capslockVoiceCheck_); 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_ = new QSpinBox(this);
beamSizeSpin_->setRange(1, 20); beamSizeSpin_->setRange(1, 20);
@ -183,7 +189,7 @@ void SettingsPage::loadFromConfig() {
languageCombo_->setCurrentText(configManager_->get("stt.language").toString()); languageCombo_->setCurrentText(configManager_->get("stt.language").toString());
streamingCheck_->setChecked(configManager_->get("stt.streaming").toBool()); streamingCheck_->setChecked(configManager_->get("stt.streaming").toBool());
debugSaveAudioCheck_->setChecked(configManager_->get("stt.debug_save_audio").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()); beamSizeSpin_->setValue(configManager_->get("stt.beam_size").toInt());
temperatureSpin_->setValue(configManager_->get("stt.temperature").toDouble()); temperatureSpin_->setValue(configManager_->get("stt.temperature").toDouble());
@ -198,27 +204,29 @@ void SettingsPage::loadFromConfig() {
} }
void SettingsPage::saveToConfig() { void SettingsPage::saveToConfig() {
configManager_->set("stt.model_path", modelPathEdit_->text()); // 批量写入所有配置,只发射一次 configChanged 信号
configManager_->set("stt.tokens_path", tokensPathEdit_->text()); QMap<QString, QVariant> batch;
configManager_->set("stt.model_type", modelTypeCombo_->currentText()); batch["stt.model_path"] = modelPathEdit_->text();
configManager_->set("stt.device", deviceCombo_->currentText()); batch["stt.tokens_path"] = tokensPathEdit_->text();
configManager_->set("stt.num_threads", threadSpin_->value()); batch["stt.model_type"] = modelTypeCombo_->currentText();
configManager_->set("stt.sample_rate", sampleRateSpin_->value()); batch["stt.device"] = deviceCombo_->currentText();
configManager_->set("stt.language", languageCombo_->currentText()); batch["stt.num_threads"] = threadSpin_->value();
configManager_->set("stt.streaming", streamingCheck_->isChecked()); batch["stt.sample_rate"] = sampleRateSpin_->value();
configManager_->set("stt.debug_save_audio", debugSaveAudioCheck_->isChecked()); batch["stt.language"] = languageCombo_->currentText();
configManager_->set("stt.capslock_voice_enabled", capslockVoiceCheck_->isChecked()); batch["stt.streaming"] = streamingCheck_->isChecked();
configManager_->set("stt.beam_size", beamSizeSpin_->value()); batch["stt.debug_save_audio"] = debugSaveAudioCheck_->isChecked();
configManager_->set("stt.temperature", temperatureSpin_->value()); 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_->setBatch(batch);
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());
} }
void SettingsPage::onBrowseModelPath() { void SettingsPage::onBrowseModelPath() {

View File

@ -15,6 +15,7 @@ class QGroupBox;
namespace impress { namespace impress {
class ConfigManager; class ConfigManager;
class HotkeyRecorder;
/** /**
* @brief * @brief
@ -52,7 +53,7 @@ private:
QComboBox* languageCombo_; QComboBox* languageCombo_;
QCheckBox* streamingCheck_; QCheckBox* streamingCheck_;
QCheckBox* debugSaveAudioCheck_; QCheckBox* debugSaveAudioCheck_;
QCheckBox* capslockVoiceCheck_; HotkeyRecorder* hotkeyRecorder_;
QSpinBox* beamSizeSpin_; QSpinBox* beamSizeSpin_;
QDoubleSpinBox* temperatureSpin_; QDoubleSpinBox* temperatureSpin_;

View File

@ -0,0 +1,138 @@
#include "hotkey_recorder.h"
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QPushButton>
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<QKeyEvent*>(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

View File

@ -0,0 +1,52 @@
#pragma once
#include <QWidget>
#include <QString>
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