fix: 修复配置保存死锁,添加快捷键录制组件
- 修复 ConfigManager::set() 在持有锁时发射信号导致的死锁 - 添加 setBatch() 方法批量更新配置,只发射一次 configChanged - 新增 HotkeyRecorder 组件:点击按钮后按键录制任意快捷键 - SettingsPage 保存配置改为批量写入,避免多次触发服务重启 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
33ae22ce62
commit
a2b216092f
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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, "语音输入已关闭");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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_;
|
||||
|
||||
|
||||
138
src/ui/widgets/hotkey_recorder.cpp
Normal file
138
src/ui/widgets/hotkey_recorder.cpp
Normal 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
|
||||
52
src/ui/widgets/hotkey_recorder.h
Normal file
52
src/ui/widgets/hotkey_recorder.h
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user