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 "config_manager.h"
#include "core/sense_voice_engine.h"
#include "utils/logger.h"
#include <QFile>
@ -14,6 +15,22 @@ Application::Application(int& argc, char** argv)
configManager_ = std::make_unique<ConfigManager>(this);
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() {
@ -25,4 +42,30 @@ ConfigManager* Application::configManager() const {
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

View File

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

View File

@ -31,11 +31,14 @@ struct VoiceInputService::Impl {
WaylandTextInjector* injector = nullptr;
};
VoiceInputService::VoiceInputService(ConfigManager* configManager, QObject* parent)
VoiceInputService::VoiceInputService(ConfigManager* configManager,
SenseVoiceEngine* sttEngine,
QObject* parent)
: QObject(parent)
, configManager_(configManager)
, impl_(std::make_unique<Impl>())
{
impl_->sttEngine = sttEngine;
longPressTimer_ = new QTimer(this);
longPressTimer_->setSingleShot(true);
connect(longPressTimer_, &QTimer::timeout, this, [this]() {
@ -59,30 +62,7 @@ bool VoiceInputService::start() {
connect(impl_->audioCapture, &AudioCapture::audioDataReady,
this, &VoiceInputService::onAudioData);
// 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, "模型路径为空,请先在配置中设置模型路径");
}
// 2. STT 引擎已作为参数传入(共享全局实例)
// 3. 初始化全局快捷键
impl_->hotkey = new CapsLockVoiceHotkey(this);
@ -124,9 +104,6 @@ void VoiceInputService::stop() {
if (impl_->audioCapture) {
impl_->audioCapture->stop();
}
if (impl_->sttEngine) {
impl_->sttEngine->unloadModel();
}
if (impl_->hotkey) {
impl_->hotkey->stop();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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