refactor: 全局共享 STT 模型,避免重复加载

将 SenseVoiceEngine 提升为 Application 级别的全局单例,应用启动时
异步加载一次模型,实时语音识别、文件转写和快捷键语音输入共享同一实例。

- Application 创建并管理全局 SenseVoiceEngine,启动时加载模型
- STTTestPage、FileTranscribePage、VoiceInputService 不再各自
  创建引擎,改为接收全局实例
- 移除各模块中冗余的 loadModel/loadModelAsync/unloadModel 调用
- 模型未加载时提供友好的等待提示,而非加载失败的错误弹窗

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alvin Young 2026-05-13 14:10:31 +08:00
parent 85a0890478
commit ef97b962c3
11 changed files with 124 additions and 115 deletions

View File

@ -1,5 +1,6 @@
#include "application.h" #include "application.h"
#include "config_manager.h" #include "config_manager.h"
#include "core/sense_voice_engine.h"
#include "utils/logger.h" #include "utils/logger.h"
#include <QFile> #include <QFile>
@ -14,6 +15,22 @@ Application::Application(int& argc, char** argv)
configManager_ = std::make_unique<ConfigManager>(this); configManager_ = std::make_unique<ConfigManager>(this);
configManager_->loadDefaults(); configManager_->loadDefaults();
// 创建全局 STT 引擎(共享实例)
sttEngine_ = new SenseVoiceEngine(this);
connect(sttEngine_, &SenseVoiceEngine::modelLoaded, this, [this](const QString& path) {
modelLoaded_ = true;
LOG_INFO(kTag, QString("全局模型已加载: %1").arg(path));
emit modelLoaded();
});
connect(sttEngine_, &SenseVoiceEngine::modelLoadError, this, [this](const QString&, const QString& err) {
modelLoaded_ = false;
LOG_ERROR(kTag, QString("全局模型加载失败: %1").arg(err));
emit modelLoadError(err);
});
// 异步加载全局模型
loadGlobalModel();
} }
Application::~Application() { Application::~Application() {
@ -25,4 +42,30 @@ ConfigManager* Application::configManager() const {
return configManager_.get(); return configManager_.get();
} }
SenseVoiceEngine* Application::sttEngine() const {
return sttEngine_;
}
bool Application::isModelLoaded() const {
return modelLoaded_;
}
void Application::loadGlobalModel() {
QString modelPath = configManager_->get("stt.model_path").toString();
if (modelPath.isEmpty()) {
LOG_WARNING(kTag, "模型路径为空,请在配置中设置后重启");
return;
}
QString tokensPath = configManager_->get("stt.tokens_path").toString();
QString device = configManager_->get("stt.device").toString();
int numThreads = configManager_->get("stt.num_threads").toInt();
bool debugSave = configManager_->get("stt.debug_save_audio").toBool();
sttEngine_->setDebugSaveAudio(debugSave);
LOG_INFO(kTag, QString("正在异步加载全局模型: %1").arg(modelPath));
sttEngine_->loadModelAsync(modelPath, tokensPath, device, numThreads);
}
} // namespace impress } // namespace impress

View File

@ -6,9 +6,13 @@
namespace impress { namespace impress {
class ConfigManager; class ConfigManager;
class SenseVoiceEngine;
/** /**
* @brief * @brief
*
* STT
* STT
*/ */
class Application : public QApplication { class Application : public QApplication {
Q_OBJECT Q_OBJECT
@ -19,8 +23,25 @@ public:
/** @brief 获取全局配置管理器 */ /** @brief 获取全局配置管理器 */
ConfigManager* configManager() const; ConfigManager* configManager() const;
/** @brief 获取全局 STT 引擎(共享实例) */
SenseVoiceEngine* sttEngine() const;
/** @brief 获取全局 STT 引擎加载状态 */
bool isModelLoaded() const;
signals:
/** @brief 模型加载完成 */
void modelLoaded();
/** @brief 模型加载失败 */
void modelLoadError(const QString& error);
private: private:
void loadGlobalModel();
std::unique_ptr<ConfigManager> configManager_; std::unique_ptr<ConfigManager> configManager_;
SenseVoiceEngine* sttEngine_ = nullptr;
bool modelLoaded_ = false;
}; };
} // namespace impress } // namespace impress

View File

@ -31,11 +31,14 @@ struct VoiceInputService::Impl {
WaylandTextInjector* injector = nullptr; WaylandTextInjector* injector = nullptr;
}; };
VoiceInputService::VoiceInputService(ConfigManager* configManager, QObject* parent) VoiceInputService::VoiceInputService(ConfigManager* configManager,
SenseVoiceEngine* sttEngine,
QObject* parent)
: QObject(parent) : QObject(parent)
, configManager_(configManager) , configManager_(configManager)
, impl_(std::make_unique<Impl>()) , impl_(std::make_unique<Impl>())
{ {
impl_->sttEngine = sttEngine;
longPressTimer_ = new QTimer(this); longPressTimer_ = new QTimer(this);
longPressTimer_->setSingleShot(true); longPressTimer_->setSingleShot(true);
connect(longPressTimer_, &QTimer::timeout, this, [this]() { connect(longPressTimer_, &QTimer::timeout, this, [this]() {
@ -59,30 +62,7 @@ bool VoiceInputService::start() {
connect(impl_->audioCapture, &AudioCapture::audioDataReady, connect(impl_->audioCapture, &AudioCapture::audioDataReady,
this, &VoiceInputService::onAudioData); this, &VoiceInputService::onAudioData);
// 2. 初始化 STT 引擎并加载模型 // 2. STT 引擎已作为参数传入(共享全局实例)
impl_->sttEngine = new SenseVoiceEngine(this);
// 从配置加载模型
QString modelPath = configManager_->get("stt.model_path").toString();
QString tokensPath = configManager_->get("stt.tokens_path").toString();
QString device = configManager_->get("stt.device").toString();
int numThreads = configManager_->get("stt.num_threads").toInt();
if (!modelPath.isEmpty()) {
LOG_INFO(kTag, QString("正在加载 STT 模型: %1").arg(modelPath));
bool modelLoaded = impl_->sttEngine->loadModelSync(modelPath, tokensPath, device, numThreads);
if (!modelLoaded) {
emit error(QString("STT 模型加载失败: %1").arg(modelPath));
LOG_ERROR(kTag, "STT 模型加载失败");
} else {
LOG_INFO(kTag, "STT 模型加载成功");
// 同步调试音频设置
bool debugSave = configManager_->get("stt.debug_save_audio").toBool();
impl_->sttEngine->setDebugSaveAudio(debugSave);
}
} else {
LOG_WARNING(kTag, "模型路径为空,请先在配置中设置模型路径");
}
// 3. 初始化全局快捷键 // 3. 初始化全局快捷键
impl_->hotkey = new CapsLockVoiceHotkey(this); impl_->hotkey = new CapsLockVoiceHotkey(this);
@ -124,9 +104,6 @@ void VoiceInputService::stop() {
if (impl_->audioCapture) { if (impl_->audioCapture) {
impl_->audioCapture->stop(); impl_->audioCapture->stop();
} }
if (impl_->sttEngine) {
impl_->sttEngine->unloadModel();
}
if (impl_->hotkey) { if (impl_->hotkey) {
impl_->hotkey->stop(); impl_->hotkey->stop();
} }

View File

@ -27,7 +27,9 @@ class ConfigManager;
class VoiceInputService : public QObject { class VoiceInputService : public QObject {
Q_OBJECT Q_OBJECT
public: public:
explicit VoiceInputService(ConfigManager* configManager, QObject* parent = nullptr); explicit VoiceInputService(ConfigManager* configManager,
SenseVoiceEngine* sttEngine,
QObject* parent = nullptr);
~VoiceInputService() override; ~VoiceInputService() override;
/** @brief 启动服务(初始化所有组件) */ /** @brief 启动服务(初始化所有组件) */

View File

@ -56,8 +56,8 @@ int main(int argc, char* argv[])
configManager->set("stt.model_path", modelPath); configManager->set("stt.model_path", modelPath);
} }
// 创建并显示主窗口 // 创建并显示主窗口(传入全局引擎)
impress::MainWindow mainWindow(configManager); impress::MainWindow mainWindow(configManager, app.sttEngine());
mainWindow.show(); mainWindow.show();
return app.exec(); return app.exec();

View File

@ -31,10 +31,12 @@ static const char* const kTag = "FileTranscribePage";
namespace impress { namespace impress {
FileTranscribePage::FileTranscribePage(ConfigManager* configManager, QWidget* parent) FileTranscribePage::FileTranscribePage(ConfigManager* configManager,
SenseVoiceEngine* sttEngine,
QWidget* parent)
: QWidget(parent) : QWidget(parent)
, configManager_(configManager) , configManager_(configManager)
, sttEngine_(new SenseVoiceEngine(this)) , sttEngine_(sttEngine)
, audioDecoder_(new AudioDecoder(this)) , audioDecoder_(new AudioDecoder(this))
{ {
setupUI(); setupUI();
@ -148,46 +150,25 @@ void FileTranscribePage::onStartTranscribe() {
return; return;
} }
QString modelPath = configManager_->get("stt.model_path").toString(); // 检查全局模型是否已加载
if (modelPath.isEmpty()) { if (!sttEngine_->isLoaded()) {
QMessageBox::warning(this, "提示", "请先在配置页面设置模型路径"); QMessageBox::warning(this, "提示",
"模型尚未加载完成,请稍候再试");
return; return;
} }
// 在后台线程中加载模型(不阻塞 UI // 从配置同步调试开关到引擎
statusLabel_->setText("正在加载模型..."); sttEngine_->setDebugSaveAudio(
startBtn_->setEnabled(false); configManager_->get("stt.debug_save_audio").toBool());
activeWorkers_ = 1; // 标记正在加载模型
(void)QtConcurrent::run([this, modelPath]() { isTranscribing_ = true;
bool success = sttEngine_->loadModelSync(modelPath, currentTaskIndex_ = 0;
configManager_->get("stt.tokens_path").toString(), progressBar_->setVisible(true);
configManager_->get("stt.device").toString(), updateUIState();
configManager_->get("stt.num_threads").toInt()); statusLabel_->setText("开始批量转写...");
QMetaObject::invokeMethod(this, [this, success]() { // 启动后台转写队列
activeWorkers_--; startBatchTranscription();
if (!success) {
QMessageBox::critical(this, "错误", "模型加载失败");
statusLabel_->setText("模型加载失败");
startBtn_->setEnabled(true);
return;
}
// 从配置同步调试开关到引擎
sttEngine_->setDebugSaveAudio(
configManager_->get("stt.debug_save_audio").toBool());
isTranscribing_ = true;
currentTaskIndex_ = 0;
progressBar_->setVisible(true);
updateUIState();
statusLabel_->setText("开始批量转写...");
// 启动后台转写队列
startBatchTranscription();
}, Qt::QueuedConnection);
});
} }
void FileTranscribePage::onStopTranscribe() { void FileTranscribePage::onStopTranscribe() {
@ -195,7 +176,6 @@ void FileTranscribePage::onStopTranscribe() {
activeWorkers_ = 0; activeWorkers_ = 0;
progressBar_->setVisible(false); progressBar_->setVisible(false);
statusLabel_->setText("已停止"); statusLabel_->setText("已停止");
sttEngine_->unloadModel();
updateUIState(); updateUIState();
} }
@ -296,7 +276,6 @@ void FileTranscribePage::onAllComplete() {
isTranscribing_ = false; isTranscribing_ = false;
statusLabel_->setText("全部完成"); statusLabel_->setText("全部完成");
progressBar_->setVisible(false); progressBar_->setVisible(false);
sttEngine_->unloadModel();
updateUIState(); updateUIState();
} }

View File

@ -36,7 +36,9 @@ struct TranscribeTask {
class FileTranscribePage : public QWidget { class FileTranscribePage : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit FileTranscribePage(ConfigManager* configManager, QWidget* parent = nullptr); explicit FileTranscribePage(ConfigManager* configManager,
SenseVoiceEngine* sttEngine,
QWidget* parent = nullptr);
~FileTranscribePage() override; ~FileTranscribePage() override;
private slots: private slots:

View File

@ -3,6 +3,7 @@
#include "file_transcribe_page.h" #include "file_transcribe_page.h"
#include "settings_page.h" #include "settings_page.h"
#include "core/voice_input_service.h" #include "core/voice_input_service.h"
#include "core/sense_voice_engine.h"
#include "app/config_manager.h" #include "app/config_manager.h"
#include "utils/logger.h" #include "utils/logger.h"
@ -16,19 +17,21 @@ static const char* const kTag = "MainWindow";
namespace impress { namespace impress {
MainWindow::MainWindow(ConfigManager* configManager, QWidget* parent) MainWindow::MainWindow(ConfigManager* configManager,
SenseVoiceEngine* sttEngine,
QWidget* parent)
: QMainWindow(parent) : QMainWindow(parent)
, configManager_(configManager) , configManager_(configManager)
{ {
setWindowTitle("Impress Voice Input"); setWindowTitle("Impress Voice Input");
resize(1000, 700); resize(1000, 700);
setupUI(); setupUI(sttEngine);
setupMenuBar(); setupMenuBar();
loadStyleSheet(); loadStyleSheet();
// 初始化语音输入服务 // 初始化语音输入服务(共享全局引擎)
voiceInputService_ = new VoiceInputService(configManager_, this); voiceInputService_ = new VoiceInputService(configManager_, sttEngine, this);
connect(voiceInputService_, &VoiceInputService::statusChanged, connect(voiceInputService_, &VoiceInputService::statusChanged,
this, [this](const QString& status) { this, [this](const QString& status) {
LOG_DEBUG(kTag, QString("语音输入状态: %1").arg(status)); LOG_DEBUG(kTag, QString("语音输入状态: %1").arg(status));
@ -54,11 +57,11 @@ MainWindow::MainWindow(ConfigManager* configManager, QWidget* parent)
MainWindow::~MainWindow() = default; MainWindow::~MainWindow() = default;
void MainWindow::setupUI() { void MainWindow::setupUI(SenseVoiceEngine* sttEngine) {
tabWidget_ = new QTabWidget(this); tabWidget_ = new QTabWidget(this);
sttPage_ = new STTTestPage(configManager_, tabWidget_); sttPage_ = new STTTestPage(configManager_, sttEngine, tabWidget_);
transcribePage_ = new FileTranscribePage(configManager_, tabWidget_); transcribePage_ = new FileTranscribePage(configManager_, sttEngine, tabWidget_);
settingsPage_ = new SettingsPage(configManager_, tabWidget_); settingsPage_ = new SettingsPage(configManager_, tabWidget_);
tabWidget_->addTab(sttPage_, "实时语音识别"); tabWidget_->addTab(sttPage_, "实时语音识别");

View File

@ -7,6 +7,7 @@
namespace impress { namespace impress {
class ConfigManager; class ConfigManager;
class SenseVoiceEngine;
class STTTestPage; class STTTestPage;
class FileTranscribePage; class FileTranscribePage;
class SettingsPage; class SettingsPage;
@ -15,19 +16,21 @@ class VoiceInputService;
/** /**
* @brief * @brief
* *
* 使 Tab * 使 Tab STT
*/ */
class MainWindow : public QMainWindow { class MainWindow : public QMainWindow {
Q_OBJECT Q_OBJECT
public: public:
explicit MainWindow(ConfigManager* configManager, QWidget* parent = nullptr); explicit MainWindow(ConfigManager* configManager,
SenseVoiceEngine* sttEngine,
QWidget* parent = nullptr);
~MainWindow() override; ~MainWindow() override;
protected: protected:
void closeEvent(QCloseEvent* event) override; void closeEvent(QCloseEvent* event) override;
private: private:
void setupUI(); void setupUI(SenseVoiceEngine* sttEngine);
void setupMenuBar(); void setupMenuBar();
void loadStyleSheet(); void loadStyleSheet();
void onVoiceInputConfigChanged(); void onVoiceInputConfigChanged();

View File

@ -25,10 +25,12 @@ static const char* const kTag = "STTTestPage";
namespace impress { namespace impress {
STTTestPage::STTTestPage(ConfigManager* configManager, QWidget* parent) STTTestPage::STTTestPage(ConfigManager* configManager,
SenseVoiceEngine* sttEngine,
QWidget* parent)
: QWidget(parent) : QWidget(parent)
, configManager_(configManager) , configManager_(configManager)
, sttEngine_(new SenseVoiceEngine(this)) , sttEngine_(sttEngine)
, audioCapture_(new AudioCapture(this)) , audioCapture_(new AudioCapture(this))
, inferenceTimer_(new QTimer(this)) , inferenceTimer_(new QTimer(this))
{ {
@ -122,16 +124,14 @@ void STTTestPage::onToggleRecording() {
if (isRecording_) { if (isRecording_) {
audioCapture_->stop(); audioCapture_->stop();
inferenceTimer_->stop(); inferenceTimer_->stop();
sttEngine_->unloadModel();
isRecording_ = false; isRecording_ = false;
isInferencing_ = false; isInferencing_ = false;
audioBuffer_.clear(); audioBuffer_.clear();
} else { } else {
// 读取配置 // 检查全局模型是否已加载
QString modelPath = configManager_->get("stt.model_path").toString(); if (!sttEngine_->isLoaded()) {
if (modelPath.isEmpty()) {
QMessageBox::warning(this, "提示", QMessageBox::warning(this, "提示",
"请先在「配置」页面设置模型路径并保存"); "模型尚未加载完成,请稍候再试");
return; return;
} }
@ -139,38 +139,17 @@ void STTTestPage::onToggleRecording() {
sttEngine_->setDebugSaveAudio( sttEngine_->setDebugSaveAudio(
configManager_->get("stt.debug_save_audio").toBool()); configManager_->get("stt.debug_save_audio").toBool());
// 异步加载模型 startAudioCapture();
if (!sttEngine_->isLoaded() ||
currentModelPath_ != modelPath) {
isLoadingModel_ = true;
statusLabel_->setText("正在加载模型,请稍候...");
updateUIState();
sttEngine_->loadModelAsync(modelPath,
configManager_->get("stt.tokens_path").toString(),
configManager_->get("stt.device").toString(),
configManager_->get("stt.num_threads").toInt());
currentModelPath_ = modelPath;
// 注意startAudioCapture() 将在 onModelLoaded() 回调中调用
} else {
startAudioCapture();
}
} }
updateUIState(); updateUIState();
} }
void STTTestPage::onModelLoaded(const QString& modelPath) { void STTTestPage::onModelLoaded(const QString& modelPath) {
LOG_INFO(kTag, QString("模型加载成功: %1").arg(modelPath)); LOG_INFO(kTag, QString("全局模型加载成功: %1").arg(modelPath));
isLoadingModel_ = false; isLoadingModel_ = false;
statusLabel_->setText(QString("模型就绪: %1").arg( statusLabel_->setText(QString("模型就绪: %1").arg(
QFileInfo(modelPath).fileName())); QFileInfo(modelPath).fileName()));
updateUIState(); updateUIState();
// 如果用户仍在录音状态(已切换 UI启动采集
if (!isRecording_) {
startAudioCapture();
}
} }
void STTTestPage::onModelLoadError(const QString& modelPath, const QString& error) { void STTTestPage::onModelLoadError(const QString& modelPath, const QString& error) {
@ -206,8 +185,7 @@ void STTTestPage::startAudioCapture() {
// 启动周期性推理定时器 // 启动周期性推理定时器
startInferenceTimer(); startInferenceTimer();
statusLabel_->setText(QString("录音中 | 模型: %1").arg( statusLabel_->setText("录音中 | 模型已加载");
QFileInfo(currentModelPath_).fileName()));
updateUIState(); updateUIState();
} }

View File

@ -27,7 +27,9 @@ class AudioCapture;
class STTTestPage : public QWidget { class STTTestPage : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit STTTestPage(ConfigManager* configManager, QWidget* parent = nullptr); explicit STTTestPage(ConfigManager* configManager,
SenseVoiceEngine* sttEngine,
QWidget* parent = nullptr);
~STTTestPage() override; ~STTTestPage() override;
private slots: private slots:
@ -64,7 +66,6 @@ private:
bool isInferencing_ = false; bool isInferencing_ = false;
int audioSampleRate_ = 16000; int audioSampleRate_ = 16000;
std::vector<float> audioBuffer_; std::vector<float> audioBuffer_;
QString currentModelPath_;
}; };
} // namespace impress } // namespace impress