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/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

View File

@ -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<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();
}

View File

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

View File

@ -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, "语音输入已关闭");
}
}

View File

@ -1,5 +1,6 @@
#include "settings_page.h"
#include "app/config_manager.h"
#include "widgets/hotkey_recorder.h"
#include "utils/logger.h"
#include <QVBoxLayout>
@ -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<QString, QVariant> 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() {

View File

@ -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_;

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