#include "main_window.h" #include "stt_test_page.h" #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 "app/application.h" #include "utils/logger.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef Q_OS_WIN #include // 枚举并隐藏 Qt 创建的多余工具窗口(无标题栏、无边框、非主窗口的 WS_EX_TOOLWINDOW) static BOOL CALLBACK HideQtToolWindows(HWND hwnd, LPARAM /*lParam*/) { DWORD pid; GetWindowThreadProcessId(hwnd, &pid); if (pid != GetCurrentProcessId()) return TRUE; LONG exStyle = GetWindowLong(hwnd, GWL_EXSTYLE); LONG style = GetWindowLong(hwnd, GWL_STYLE); // 只找 WS_EX_TOOLWINDOW 的窗口 if ((exStyle & WS_EX_TOOLWINDOW) == 0) return TRUE; // 排除有标题栏的窗口 bool hasTitleBar = (style & WS_CAPTION) != 0; if (hasTitleBar) return TRUE; // 排除子窗口 if (style & WS_CHILD) return TRUE; // 获取窗口标题 wchar_t title[256]; int len = GetWindowTextW(hwnd, title, 256); // 排除 Qt 标题栏窗口(_q_titlebar) if (len > 0 && QString::fromWCharArray(title, len).startsWith("_q_")) return TRUE; // 找到了可疑窗口,隐藏它 RECT rect; GetWindowRect(hwnd, &rect); LOG_INFO("MainWindow", QString("发现并隐藏 Qt 内部工具窗口: HWND=%1 标题=\"%2\" 大小=%3x%4") .arg((qulonglong)hwnd) .arg(len > 0 ? QString::fromWCharArray(title, len) : "(无标题)") .arg(rect.right - rect.left) .arg(rect.bottom - rect.top)); ShowWindow(hwnd, SW_HIDE); return TRUE; } #endif static const char* const kTag = "MainWindow"; namespace impress { MainWindow::MainWindow(ConfigManager* configManager, SenseVoiceEngine* sttEngine, QWidget* parent) : QMainWindow(parent) , configManager_(configManager) { LOG_INFO(kTag, "MainWindow 构造函数开始"); setWindowTitle("Impress Voice Input"); resize(1000, 700); // 设置窗口图标 setWindowIcon(QIcon(":/icons/app_icon.png")); LOG_INFO(kTag, "窗口图标已设置"); LOG_INFO(kTag, "开始 setupUI"); setupUI(sttEngine); LOG_INFO(kTag, "setupUI 完成"); LOG_INFO(kTag, "开始 setupMenuBar"); setupMenuBar(); LOG_INFO(kTag, "setupMenuBar 完成"); LOG_INFO(kTag, "开始 setupStatusBar"); setupStatusBar(sttEngine); LOG_INFO(kTag, "setupStatusBar 完成"); LOG_INFO(kTag, "开始 setupTrayIcon"); setupTrayIcon(); LOG_INFO(kTag, "setupTrayIcon 完成"); // 初始化语音输入服务(共享全局引擎) LOG_INFO(kTag, "开始创建 VoiceInputService"); voiceInputService_ = new VoiceInputService(configManager_, sttEngine, this); connect(voiceInputService_, &VoiceInputService::statusChanged, this, [this](const QString& status) { LOG_DEBUG(kTag, QString("语音输入状态: %1").arg(status)); updateTrayIcon(status); }); connect(voiceInputService_, &VoiceInputService::error, this, [this](const QString& err) { LOG_ERROR(kTag, err); }); connect(voiceInputService_, &VoiceInputService::recognitionResult, this, [this](const QString& text) { LOG_INFO(kTag, QString("语音识别结果: %1").arg(text)); }); LOG_INFO(kTag, "VoiceInputService 已创建"); // 监听配置变化,动态启停语音输入服务 connect(configManager_, &ConfigManager::configChanged, this, &MainWindow::onVoiceInputConfigChanged); // 启动时检查配置 LOG_INFO(kTag, "开始 onVoiceInputConfigChanged"); onVoiceInputConfigChanged(); LOG_INFO(kTag, "onVoiceInputConfigChanged 完成"); #ifdef Q_OS_WIN // 延迟隐藏 Qt 在 Windows 上创建的额外工具窗口 QTimer::singleShot(500, this, []() { LOG_INFO(kTag, "开始检查 Qt 内部工具窗口"); EnumWindows(HideQtToolWindows, 0); LOG_INFO(kTag, "Qt 内部工具窗口检查完成"); }); #endif LOG_INFO(kTag, "主窗口已创建"); } MainWindow::~MainWindow() = default; void MainWindow::setupUI(SenseVoiceEngine* sttEngine) { LOG_INFO(kTag, "setupUI: 创建 QTabWidget"); tabWidget_ = new QTabWidget(this); // 禁用可能导致额外窗口的功能 tabWidget_->setDocumentMode(true); tabWidget_->setTabBarAutoHide(false); tabWidget_->tabBar()->setExpanding(true); tabWidget_->tabBar()->setMovable(false); tabWidget_->tabBar()->setDrawBase(true); LOG_INFO(kTag, "setupUI: 创建 STTTestPage"); sttPage_ = new STTTestPage(configManager_, sttEngine, tabWidget_); LOG_INFO(kTag, "setupUI: STTTestPage 创建完成"); LOG_INFO(kTag, "setupUI: 创建 FileTranscribePage"); transcribePage_ = new FileTranscribePage(configManager_, sttEngine, tabWidget_); LOG_INFO(kTag, "setupUI: FileTranscribePage 创建完成"); LOG_INFO(kTag, "setupUI: 创建 SettingsPage"); settingsPage_ = new SettingsPage(configManager_, tabWidget_); LOG_INFO(kTag, "setupUI: SettingsPage 创建完成"); tabWidget_->addTab(sttPage_, "实时语音识别"); tabWidget_->addTab(transcribePage_, "音频文件转写"); tabWidget_->addTab(settingsPage_, "配置"); LOG_INFO(kTag, "setupUI: Tab 页面已添加"); setCentralWidget(tabWidget_); LOG_INFO(kTag, "setupUI: 完成"); } void MainWindow::setupStatusBar(SenseVoiceEngine* sttEngine) { sttEngine_ = sttEngine; auto* bar = statusBar(); bar->setSizeGripEnabled(false); modelStatusLabel_ = new QLabel(this); modelStatusLabel_->setStyleSheet( "QLabel { padding: 2px 8px; font-size: 13px; }"); bar->addPermanentWidget(modelStatusLabel_); // 连接全局引擎信号 connect(sttEngine_, &SenseVoiceEngine::modelLoaded, this, &MainWindow::updateModelStatus); connect(sttEngine_, &SenseVoiceEngine::modelLoadError, this, &MainWindow::updateModelStatus); connect(sttEngine_, &SenseVoiceEngine::modelUnloaded, this, &MainWindow::updateModelStatus); // 初始化显示 updateModelStatus(); } void MainWindow::setupTrayIcon() { if (!QSystemTrayIcon::isSystemTrayAvailable()) { LOG_INFO(kTag, "系统托盘不可用"); return; } trayMenu_ = new QMenu(this); auto* showAction = trayMenu_->addAction("显示主窗口"); connect(showAction, &QAction::triggered, this, [this]() { showNormal(); activateWindow(); raise(); }); auto* restartAction = trayMenu_->addAction("重启"); connect(restartAction, &QAction::triggered, this, [this]() { doRestart(); }); trayMenu_->addSeparator(); auto* exitAction = trayMenu_->addAction("退出"); connect(exitAction, &QAction::triggered, this, [this]() { doExit(); }); trayIcon_ = new QSystemTrayIcon(this); trayIcon_->setContextMenu(trayMenu_); // 默认状态:根据主题颜色生成图标 idleIcon_ = Application::createTrayIcon(false); activeIcon_ = Application::createTrayIcon(true); trayIcon_->setIcon(idleIcon_); trayIcon_->setToolTip("Impress Voice Input - 语音输入就绪"); trayIcon_->show(); // 双击托盘显示窗口 connect(trayIcon_, &QSystemTrayIcon::activated, this, [this](QSystemTrayIcon::ActivationReason reason) { if (reason == QSystemTrayIcon::DoubleClick) { showNormal(); activateWindow(); raise(); } }); LOG_INFO(kTag, "系统托盘图标已创建"); } void MainWindow::updateTrayIcon(const QString& status) { if (!trayIcon_) return; // 录音/识别 → 播放图标,就绪/停止 → 停止图标 if (status.contains("正在录音") || status.contains("正在识别")) { trayIcon_->setIcon(activeIcon_); } else { trayIcon_->setIcon(idleIcon_); } trayIcon_->setToolTip(QString("Impress Voice Input - %1").arg(status)); } void MainWindow::setupMenuBar() { // 文件菜单 auto* fileMenu = menuBar()->addMenu("文件"); auto* restartAction = fileMenu->addAction("重启"); restartAction->setShortcut(QKeySequence("Ctrl+R")); connect(restartAction, &QAction::triggered, this, [this]() { doRestart(); }); fileMenu->addSeparator(); auto* exitAction = fileMenu->addAction("退出"); exitAction->setShortcut(QKeySequence("Ctrl+Q")); connect(exitAction, &QAction::triggered, this, [this]() { doExit(); }); // 帮助菜单 auto* helpMenu = menuBar()->addMenu("帮助"); auto* usageAction = helpMenu->addAction("使用说明"); usageAction->setShortcut(QKeySequence("F1")); connect(usageAction, &QAction::triggered, this, [this] { showUsage(); }); helpMenu->addSeparator(); auto* aboutAction = helpMenu->addAction("关于"); connect(aboutAction, &QAction::triggered, this, [this] { QMessageBox::about(this, "关于", "

Impress Voice Input

" "

基于 ONNX 的实时语音转文本输入法

" "

版本: 0.1.0

"); }); } void MainWindow::loadStyleSheet() { QFile styleFile(":/styles/main.qss"); if (styleFile.open(QIODevice::ReadOnly)) { setStyleSheet(styleFile.readAll()); styleFile.close(); } } void MainWindow::closeEvent(QCloseEvent* event) { // 关闭按钮隐藏到托盘,不退出程序 LOG_INFO(kTag, "主窗口隐藏到托盘"); hide(); event->ignore(); } void MainWindow::doExit() { LOG_INFO(kTag, "应用退出"); if (voiceInputService_) { voiceInputService_->stop(); } if (trayIcon_) { trayIcon_->hide(); } qApp->quit(); } void MainWindow::doRestart() { LOG_INFO(kTag, "应用重启"); if (voiceInputService_) { voiceInputService_->stop(); } if (trayIcon_) { trayIcon_->hide(); } QString appPath = qApp->applicationFilePath(); QString workDir = qApp->applicationDirPath(); bool ok = QProcess::startDetached(appPath, {}, workDir); if (!ok) { LOG_ERROR(kTag, QString("重启失败: 无法启动 %1").arg(appPath)); } qApp->quit(); } void MainWindow::showUsage() { const QString usageText = "

使用说明

" "

一、基本功能

" "

实时语音识别:按下快捷键开始录音,语音实时转为文字显示在识别结果区。

" "

音频文件转写:选择本地音频文件(支持 WAV/MP3/FLAC/OGG 等格式),点击开始转写即可将整个文件转为文字。

" "

配置管理:在配置页面设置模型路径、ONNX 线程数、识别语言、快捷键等参数。

" "

二、快捷键操作

" "" "" "" "" "" "
语音输入长按 CapsLock 超过 1 秒(可在配置中自定义)
使用说明F1
重启应用Ctrl+R
退出应用Ctrl+Q
" "

三、语音输入使用流程

" "
    " "
  1. 配置页面中设置正确的 STT 模型路径并保存。
  2. " "
  3. 设置语音输入快捷键(默认长按 CapsLock)。
  4. " "
  5. 将光标定位到需要输入文字的目标应用(如微信、Word、浏览器等)。
  6. " "
  7. 长按快捷键开始说话,说完后松开快捷键。
  8. " "
  9. 识别的文字将通过模拟按键自动输入到目标应用中。
  10. " "
" "

CapsLock 工作模式:短按(<1 秒)正常切换大小写锁定;长按(>1 秒)触发语音输入,松开后自动识别并注入文字。

" "

四、文件转写使用流程

" "
    " "
  1. 切换到音频文件转写标签页。
  2. " "
  3. 点击选择文件按钮选择音频文件,支持拖放文件到窗口。
  4. " "
  5. 点击开始转写,等待处理完成。
  6. " "
  7. 转写结果显示在下方文本区,可点击复制结果复制到剪贴板,或点击导出结果保存为文本文件。
  8. " "
" "

五、配置说明

" "" "" "" "" "" "" "" "" "
模型路径SenseVoice ONNX 模型文件路径(.onnx)
词表路径Tokenizer 词表文件路径(tokens.txt)
推理设备CPU / GPU(需 GPU 版本 ONNX Runtime)
线程数ONNX 推理线程数,建议 2-4
语音快捷键触发语音输入的快捷键
主题深色 / 浅色界面主题
字体大小全局界面字体大小
" "

六、系统托盘

" "

关闭主窗口时程序最小化到系统托盘,托盘图标菜单支持:

" "
    " "
  • 显示主窗口:恢复主窗口显示
  • " "
  • 重启:重启应用程序
  • " "
  • 退出:完全退出程序
  • " "
" "

双击托盘图标可快速显示主窗口。

" "

七、状态栏

" "

底部状态栏右侧显示 STT 模型加载状态:

" "
    " "
  • 模型已就绪 — 模型加载成功,可以正常使用
  • " "
  • 模型路径未设置 — 请在配置页面设置模型路径
  • " "
  • 模型加载失败 — 模型文件路径错误或文件损坏
  • " "
" "

八、常见问题

" "

Q: 语音输入没有反应?
" "A: 请确认:① 模型已加载(状态栏显示模型已就绪);" "② 已设置语音快捷键;③ 麦克风正常工作。

" "

Q: 识别文字没有输入到目标应用?
" "A: 某些应用可能拦截模拟按键输入,请尝试在管理员权限下运行本程序。

" "

Q: 识别速度慢?
" "A: 在配置中增大 ONNX 线程数,或使用 GPU 版本的 ONNX Runtime。

" "

Q: CapsLock 短按不起作用?
" "A: 请确保按键时间小于 1 秒,超过 1 秒会触发语音输入模式。

"; // 使用可调整大小、可滚动的 QDialog 替代 QMessageBox QDialog* dialog = new QDialog(this); dialog->setWindowTitle("使用说明"); dialog->resize(650, 550); dialog->setMinimumSize(400, 300); auto* layout = new QVBoxLayout(dialog); layout->setContentsMargins(12, 12, 12, 12); auto* textBrowser = new QTextBrowser(dialog); textBrowser->setOpenExternalLinks(false); textBrowser->setHtml(usageText); layout->addWidget(textBrowser); // 底部关闭按钮 auto* btnLayout = new QHBoxLayout(); btnLayout->addStretch(); auto* closeBtn = new QPushButton("关闭", dialog); closeBtn->setMinimumWidth(80); btnLayout->addWidget(closeBtn); layout->addLayout(btnLayout); connect(closeBtn, &QPushButton::clicked, dialog, &QDialog::accept); dialog->exec(); dialog->deleteLater(); } void MainWindow::updateModelStatus() { if (!sttEngine_ || !modelStatusLabel_) return; QString modelPath = configManager_->get("stt.model_path").toString(); QString modelName = modelPath.isEmpty() ? QString() : QFileInfo(modelPath).fileName(); if (sttEngine_->isLoaded()) { QString statusText = modelName.isEmpty() ? "模型已就绪" : QString("模型已就绪: %1").arg(modelName); modelStatusLabel_->setText(statusText); modelStatusLabel_->setStyleSheet( "QLabel { padding: 2px 8px; font-size: 13px; color: #27ae60; font-weight: bold; }"); } else if (modelPath.isEmpty()) { modelStatusLabel_->setText("⚠️ 模型路径未设置"); modelStatusLabel_->setStyleSheet( "QLabel { padding: 2px 8px; font-size: 13px; color: #e74c3c; }"); } else { modelStatusLabel_->setText( QString("⚠️ 模型加载失败: %1").arg(modelName)); modelStatusLabel_->setStyleSheet( "QLabel { padding: 2px 8px; font-size: 13px; color: #e67e22; }"); } } void MainWindow::onVoiceInputConfigChanged() { if (!voiceInputService_) return; // 动态应用主题和字体 QString theme = configManager_->get("ui.theme").toString(); int fontSize = configManager_->get("ui.font_size").toInt(); Application::applyTheme(theme); if (fontSize > 0) Application::applyFontSize(fontSize); // 刷新托盘图标颜色 idleIcon_ = Application::createTrayIcon(false); activeIcon_ = Application::createTrayIcon(true); updateTrayIcon("语音输入就绪"); // 更新模型状态显示 updateModelStatus(); // 当设置了语音快捷键时启用语音输入服务 QString hotkey = configManager_->get("shortcuts.voice_hotkey").toString(); bool enabled = !hotkey.isEmpty() && hotkey != "未设置"; if (enabled && !voiceInputService_->isRunning()) { voiceInputService_->start(); LOG_INFO(kTag, QString("语音输入已启用(快捷键: %1)").arg(hotkey)); } else if (!enabled && voiceInputService_->isRunning()) { voiceInputService_->stop(); LOG_INFO(kTag, "语音输入已关闭"); } } } // namespace impress