news 2026/9/25 20:56:51

Qt 离线语音识别:FFmpeg + sherpa-onnx(SenseVoice)视频转文字工具

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Qt 离线语音识别:FFmpeg + sherpa-onnx(SenseVoice)视频转文字工具

本文内容由 AI 辅助生成,仅供学习参考。

项目:Qt5.14.2 MinGW64 + FFmpeg + sherpa-onnx-offline(SenseVoice 离线语音识别)
方案:QProcess 调用外部 exe,不编译源码

  1. 项目整体架构
  2. 运行依赖清单
  3. UI 控件清单(MainWindow)
  4. 文件目录结构(Release 打包目录)
  5. 各组件下载地址 & 版本注意事项
    • 5.1 FFmpeg 下载
    • 5.2 sherpa-onnx-offline.exe 下载
    • 5.3 语音模型下载 - ModelScope 国内阿里镜像备选
  6. 编译 & 打包 & 测试流程
    • 6.1 组包结构
    • 6.2 测试方法
  7. 调试踩坑问题汇总
  8. 完整源码 mainwindow.h/mainwindow.cpp
  9. 限制说明
  10. 效果

1. 项目整体架构

视频文件 → QProcess调用ffmpeg → 提取16k 单通道pcm wav音频 → QProcess调用sherpa-onnx-offline.exe(SenseVoice模型)→ 输出JSON识别结果 → Qt解析JSON → 展示文本 + 保存txt / 生成srt字幕 → UI:选择视频按钮、导出按钮、文本预览框

2. 运行依赖清单(Release 打包时必须一起复制到 release 目录)

  1. Qt 运行库(MinGW64)
    Qt5 核心 dll:Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll,平台插件platforms/qwindows.dll等,windeployqt自动导出。
  2. FFmpeg 二进制
  • 文件:ffmpeg.exe、ffprobe.exe
  • 作用:视频解封装,提取音频,强制输出16000Hz,单声道,pcm_s16le wav
  • 命令:
ffmpeg -i test.mp4 -vn -acodec pcm_s16le -ar 16000 -ac 1 temp_audio.wav -y
  1. Sherpa-onnx 离线识别程序(sensevoice)
  • sherpa-onnx-offline.exe+ 配套全部 dll

  • 模型文件夹models/

    • model.int8.onnx(SenseVoice int8 模型)
    • tokens.txt
  • 命令:

sherpa-onnx-offline.exe --sense-voice-model=./models/model.int8.onnx --tokens=./models/tokens.txt --sense-voice-language=auto temp_audio.wav
  1. Qt 内置模块(无需额外安装库)
  • QProcess:启动外部进程、读取输出、kill 终止进程
  • QJsonDocument/QJsonObject/QJsonArray:解析识别 JSON 结果
  • QFile/QTextStream:保存 txt、srt 字幕
  • QProgressBar、QPushButton、QTextEdit:UI 界面

3. UI 控件清单(MainWindow)

表格

控件对象名用途
QPushButtonbtnStart开始任务:选择视频,启动 ffmpeg 音频提取
QPushButtonbtnSaveTxt导出txt
QPushButtonbtnSaveSrt导出srt
QTextEdittextResult日志 + 识别文本预览
QTextEdittextSubtitleView显示详细日志信息
QCheckBoxchkDebugLog显示详细调试日志信息
QPushButtonbtnSelectVideo打开文件选择框,选择视频

4. 文件目录结构(Release 打包目录)

release/ ├─ VideoAsr.exe # Qt编译出来的主程序 ├─ ffmpeg.exe ├─ ffprobe.exe ├─ sherpa-onnx-offline.exe ├─ sherpa一堆依赖dll ├─ temp_audio.wav # 临时音频(运行自动生成,结束可选择删除) ├─ models/ │ ├─ model.int8.onnx │ └─ tokens.txt ├─ platforms/ # Qt平台插件(windeployqt生成) └─ Qt一系列dll(windeployqt自动拷贝)

5. 各组件下载地址 & 版本注意事项

5.1 FFmpeg 下载

官方下载网址:https://ffmpeg.org/download.html#build-windows

  • essential:简洁版本,推荐使用,体积小,满足音视频提取需求
  • full:完整版本,包含大量编解码器,体积更大

5.2 sherpa-onnx-offline.exe 下载

官方下载方法网址(win64 位下载界面。下载依赖网络,有时候可以正常下载,有时候下载不了):
https://k2-fsa.github.io/sherpa/onnx/install/windows/generated/download/windows_x64.html


选这个包下载:sherpa-onnx-v1.13.8-win-x64-shared-MT-Release-no-tts.tar.bz2

  • win-x64:64 位 Windows
  • shared-MT-Release:发布正式版,带 exe + 依赖 dll
  • no-tts:不带语音合成,只包含语音识别,体积更小

⚠️重要:后缀是.tar.bz2,Windows 自带解压工具打不开,必须安装 7-Zip 解压

解压取出 sherpa-onnx-offline.exe 步骤:

  1. 用 7-Zip 打开这个 tar.bz2 压缩包,全部解压到本地文件夹
  2. 进入解压后的bin文件夹
  3. 里面就能找到:sherpa-onnx-offline.exe
  4. ✅把 bin 文件夹里面【所有 exe + 全部 dll 文件一起复制】,放到你的 Qt 程序输出目录(和你的 Qt 生成的 exe 放在同一文件夹,少一个 dll 都会报错)

注意:
如果访问这个链接 https://github.com/k2-fsa/sherpa-onnx/releases/tag/v1.13.8 直接下载 exe 包,这个 exe 包是 GUI 演示程序,不是命令行工具。因此实际运行时会跳出来模型下载说明窗口,不要选用。


5.3 语音模型下载 - ModelScope 国内阿里镜像备选

什么是语音模型,为什么下载语音模型?
sherpa-onnx-offline.exe 只是程序外壳,本身没有听懂语音的能力。语音模型就是训练好的 AI 神经网络文件,负责把音频转成文字。

  1. model.int8.onnx:核心 AI 模型文件(int8 = 量化压缩,CPU 速度快,体积小,中文识别准确率高)
  2. tokens.txt:字符映射字典,AI 识别出来的数字编码,靠这个文件翻译成中文汉字

关于下载版本要求:

  • 不要混用不同发布时间的模型和 sherpa-onnx exe,很容易出现这类 ONNX 输入节点缺失报错
  • 下载模型的时候,看页面标注的 sherpa-onnx 最低版本要求,必须满足

下载地址:https://modelscope.cn/models/baiyang2458/sherpa-onnx-sense-voice-zh-int8/files
文件:sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2
大小:165.78MB

这个压缩包里面就包含我们需要的:
model.int8.onnx + tokens.txt
支持:普通话、英语、粤语、日语、韩语,中文识别完全够用。

6. 编译 & 打包 & 测试流程

6.1 组包结构

严格按照此结构放置文件,参考上面第 4 章节目录结构。

6.2 测试方法

进入 release 文件夹,打开 PowerShell 命令窗口

测试 1:ffmpeg,验证是否运行

.\ffmpeg.exe -i test.mp4 -vn -acodec pcm_s16le -ar 16000 -ac 1 temp_audio.wav -y

✅成功:无报错,文件夹生成temp_audio.wav
❌失败排查:

  • 文件不在当前目录、文件名中文 / 空格(需要英文双引号包裹路径)
  • ffmpeg 缺失

测试 2:验证 sherpa 命令行识别

.\sherpa-onnx-offline.exe --sense-voice-model ./models/model.int8.onnx --tokens ./models/tokens.txt temp_audio.wav

✅成功:不弹出 GUI 窗口,控制台直接打印识别出来的中文文本
❌失败排查:

  1. exe 名字写错,或者放成 GUI 演示程序
  2. models 文件夹缺失、模型文件名不对
  3. 缺少 sherpa 依赖 dll,直接闪退
  4. wav 音频不是 16000Hz 16bit 单声道

完整编译打包步骤:

  1. Qt 切换 Release 模式,编译生成 VideoAsr.exe
  2. release 目录执行命令:windeployqt VideoAsr.exe,自动拷贝 Qt 依赖 dll
  3. 手动复制:ffmpeg.exe、sherpa-onnx-offline.exe、sherpa 所有 dll、models 文件夹到 release 目录
  4. 分步测试(重要!)
    • 测试 1:PowerShell 手动执行 ffmpeg,确认能生成 temp_audio.wav
    • 测试 2:PowerShell 手动执行 sherpa 命令,确认输出 JSON 识别结果
    • 测试 3:界面完整跑一遍:选视频 → 提取音频 → ASR 识别 → 保存 txt/srt
    • 测试 4:中途点击停止按钮,验证进程终止、UI 重置
  5. 打包:压缩整个 release 文件夹,其他 Windows 电脑直接解压使用,无需安装 Qt/Python

7. 调试踩坑问题汇总

表格

问题现象原因解决方案
pip 安装 sherpa-onnx-bin 找不到 bin 目录pip 安装完找不到 sherpa-onnx-offline.exesherpa-onnx-bin 是 Python 库,不是独立 exe官网下载 windows x64 tar.bz2 二进制包,不要 pip
.\sherpa-onnx-offline.exe 无法识别powershell 提示找不到程序文件缺失 / 缺少 dll / 文件名错误完整解压 tar.bz2,exe + 所有 dll 拷贝到 release
ffmpeg : 无法将 ffmpeg 识别为命令命令提示找不到 ffmpeg没有写.\ffmpeg.exe,当前目录无程序Qt 代码使用 exe 绝对路径,命令行使用.\ffmpeg.exe
FFmpeg No such file or directoryffmpeg 启动但是找不到视频文件相对路径失效,工作目录错乱代码使用applicationDirPath()拼接绝对路径
connect no matching member function for call to ‘connect’编译报错,QProcess finished 信号歧义finished 存在重载函数使用qOverload<int>(&QProcess::finished)
No such slot MainWindow::onAsrFinished(int)运行控制台提示不存在槽函数h 文件没有在 slots 声明槽函数头文件 private slots 添加函数声明
sherpa 卡在 Started,长时间无 JSON 输出模型加载成功,但是不输出识别结果音频格式不对 / 音频太长 / 模型损坏powershell 单独执行 sherpa 命令排查,确认 wav 为 16k 单通道 pcm
相对路径不稳定Qt IDE 内运行和双击 exe 运行路径不一样程序工作目录变化全部资源使用绝对路径拼接
停止按钮无法终止进程点击停止,后台 ffmpeg/sherpa 继续运行仅销毁 QProcess 对象,没有 killstop 槽调用m_proc->kill(),增加运行状态标记
ASR 无实时进度进度条只能固定模拟sherpa 命令行 exe 不输出推理进度FFmpeg 0~50%,ASR 模拟 50~100%;进阶:使用 sherpa C++ SDK
打包到其他电脑闪退本机正常,别的电脑双击崩溃缺少 Qt dll /sherpa 配套 dllwindeployqt 导出 Qt 库;完整拷贝 sherpa 全部 dll

8. 完整源码

mainwindow.h

#ifndef MAINWINDOW_H #define MAINWINDOW_H #include <QMainWindow> #include <QProcess> #include <QDir> #include <QCoreApplication> #include <QProgressBar> #include <QTimer> QT_BEGIN_NAMESPACE namespace Ui { class MainWindow; } QT_END_NAMESPACE class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(QWidget *parent = nullptr); ~MainWindow(); private slots: /** * @brief on_btnSelectVideo_clicked 【选择视频按钮】 * 弹出文件选择框,选中视频后自动启动FFmpeg提取音频,开始整个ASR任务 */ void on_btnSelectVideo_clicked(); /** * @brief onFfmpegStdErr 【FFmpeg stderr输出回调】 * 读取ffmpeg输出流,解析time字段,实时更新当前已转换音频时长 */ void onFfmpegStdErr(); /** * @brief onFfmpegFinished 【FFmpeg进程结束回调】 * 音频提取完成/失败后触发,成功则启动sherpa-onnx离线识别 * @param exitCode 进程退出码,0代表正常成功 */ void onFfmpegFinished(int exitCode); /** * @brief onAsrReadOutput 【Sherpa识别输出回调】 * 实时读取识别文本输出,追加到结果缓存,实时预览字幕 */ void onAsrReadOutput(); /** * @brief onAsrFinished 【Sherpa识别进程结束回调】 * 语音识别全部完成,汇总结果,更新UI状态 * @param exitCode 进程退出码 */ void onAsrFinished(int exitCode); /** * @brief onAsrStdErr 【接收 sherpa 的 stderr 调试信息】 * 接收 sherpa 的 stderr 调试信息 */ void onAsrStdErr(); /** * @brief on_btnStop_clicked 【停止任务按钮】 * 强制kill FFmpeg和Sherpa进程,终止当前任务,重置UI */ void on_btnStop_clicked(); /** * @brief on_btnSaveTxt_clicked 【保存TXT按钮】 * 将全部识别文本保存为utf-8 txt文本文件 */ void on_btnSaveTxt_clicked(); /** * @brief on_btnSaveSrt_clicked 【保存SRT字幕按钮】 * 简易SRT字幕保存,整段识别内容作为单条字幕 */ void on_btnSaveSrt_clicked(); /** * @brief on_chkDebugLog_clicked 【调试日志复选框点击事件】 * 切换是否开启详细调试日志 */ void on_chkDebugLog_clicked(); private: Ui::MainWindow *ui; QString m_videoPath; // 选中的视频完整路径 QString m_tempWavPath; // 临时wav音频文件完整路径 QProcess* m_ffmpegProc = nullptr; QProcess* m_asrProc = nullptr; bool m_isRunning = false; // 任务运行标记:true=正在处理 QString m_fullResultText; // 完整识别结果文本缓存 double m_videoTotalSec = 0.0; // 视频总时长(秒),用于计算FFmpeg进度百分比 bool m_enableDebugLog = false;// 详细调试日志总开关 QProgressBar* m_progressBar; // 状态栏进度条 /** * @brief getVideoTotalDuration 使用ffprobe读取视频总时长(秒) * @param videoPath 视频文件路径 * @return 总秒数,读取失败返回0 */ double getVideoTotalDuration(const QString &videoPath); /** * @brief parseFfmpegTime 解析ffmpeg time字符串 "00:01:23.45" 转为总秒数 * @param timeStr 时间字符串 * @return 浮点秒数 */ double parseFfmpegTime(const QString &timeStr); /** * @brief updateSubtitlePreview 更新字幕预览框 * @param text 识别文本,实时显示在预览控件 */ void updateSubtitlePreview(const QString &text); /** * @brief resetUiState 重置UI控件状态,任务结束/中断后调用 */ void resetUiState(); /** * @brief cleanTempAudio 删除临时wav音频文件 */ void cleanTempAudio(); /** * @brief appendLog 统一日志输出函数 * @param msg 日志文本 * @param isDebugMsg true=调试详细日志,存入m_fullGlobalLog;false=基础状态日志,永远输出到textResult */ void appendLog(const QString &msg, bool isDebugMsg = false); QList<double> m_tsList; // 存储timestamps时间戳数组 QString m_rawAsrJson; // 保存sherpa原始完整json字符串 QByteArray m_stdoutBuffer;//缓存 QString m_fullAsrDebugLog;//显示全部调试信息 QString m_fullGlobalLog; //【新增】全局完整日志,后台持续保存所有操作日志 void showProgressBar(); void hideProgressBar(); }; #endif // MAINWINDOW_H

mainwindow.cpp

#include "mainwindow.h" #include "ui_mainwindow.h" #include <QMessageBox> #include <QRegularExpression> #include <QDateTime> #include <QFileDialog> #include <QTextStream> #include <QFile> #include <QJsonDocument> #include <QJsonObject> #include <QJsonArray> #include <QScrollBar> /** * @brief MainWindow 构造函数 * 初始化UI、初始化进程指针,绑定信号槽,初始化状态栏进度条 */ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow) { ui->setupUi(this); // ========== 状态栏进度条初始化 ========== m_progressBar = new QProgressBar(this); m_progressBar->setRange(0,100); m_progressBar->setValue(0); m_progressBar->setFixedWidth(280); // 加宽一点,更好看 m_progressBar->setFixedHeight(16); // 高度调小,适配状态栏 m_progressBar->setTextVisible(true); // 显示百分比文字 m_progressBar->setAlignment(Qt::AlignCenter); hideProgressBar(); // 默认隐藏 m_progressBar->setStyleSheet(R"( QProgressBar{ border-radius:8px; background-color:#E5E7EB; text-align:center; color:#222222; font-size:10px; } QProgressBar::chunk{ border-radius:8px; background-color:#2563EB; } )"); // 添加到状态栏永久右侧控件 statusBar()->addPermanentWidget(m_progressBar); statusBar()->showMessage("就绪"); ui->btnStop->setEnabled(false); ui->chkDebugLog->setChecked(false); // 默认关闭调试日志 m_enableDebugLog = false; ui->textSubtitleView->setVisible(false); //【新增】默认隐藏详细日志窗口 m_fullGlobalLog.clear(); //初始化全局日志 } /** * @brief ~MainWindow 析构函数 * 程序关闭时强制杀死残留的ffmpeg、sherpa进程,释放资源 */ MainWindow::~MainWindow() { if(m_ffmpegProc) { m_ffmpegProc->kill(); m_ffmpegProc->waitForFinished(1000); } if(m_asrProc) { m_asrProc->kill(); m_asrProc->waitForFinished(1000); } delete ui; } void MainWindow::showProgressBar() { m_progressBar->show(); } void MainWindow::hideProgressBar() { m_progressBar->hide(); } /** * @brief appendLog 统一日志输出函数 * @param msg 日志文本 * @param isDebugMsg true=调试详细日志,存入m_fullGlobalLog;false=基础状态日志,永远输出到textResult */ void MainWindow::appendLog(const QString &msg, bool isDebugMsg) { if(isDebugMsg) { // 调试日志:追加到全局日志缓存,带时间戳 QString timeStr = QDateTime::currentDateTime().toString("HH:mm:ss"); QString line = QString("[%1] %2").arg(timeStr, msg); m_fullGlobalLog += line + "\n"; // 勾选调试框,实时刷新textSubtitleView并滚动到底 if(m_enableDebugLog) { ui->textSubtitleView->setPlainText(m_fullGlobalLog); QScrollBar *vbar = ui->textSubtitleView->verticalScrollBar(); if(vbar) { vbar->setValue(vbar->maximum()); } } } else { // 基础业务日志,直接输出到textResult,不受调试开关控制 ui->textResult->append(msg); } } /** * @brief on_chkDebugLog_clicked 【调试日志复选框点击事件】 * 切换是否开启详细调试日志 */ void MainWindow::on_chkDebugLog_clicked() { m_enableDebugLog = ui->chkDebugLog->isChecked(); ui->textSubtitleView->setVisible(m_enableDebugLog); if(m_enableDebugLog) { appendLog("✅已开启详细调试日志", false); // 打开日志窗口,加载已经收集的全部历史日志 ui->textSubtitleView->setPlainText(m_fullGlobalLog); QScrollBar *vbar = ui->textSubtitleView->verticalScrollBar(); if(vbar) { vbar->setValue(vbar->maximum()); } } else { appendLog("✅已关闭详细调试日志", false); } } /** * @brief on_btnSelectVideo_clicked 【选择视频按钮】 * 弹出文件选择框,选中视频后自动启动FFmpeg提取音频,开始整个ASR任务 */ void MainWindow::on_btnSelectVideo_clicked() { if(m_isRunning) { QMessageBox::warning(this,"提示","任务正在运行,请先停止!"); return; } QString videoFile = QFileDialog::getOpenFileName(this,tr("选择视频"),QDir::homePath(),tr("视频文件 (*.mp4 *.mov *.avi *.mkv)")); if(videoFile.isEmpty()) return; m_videoPath = videoFile; // ========== 状态栏显示视频路径,不再使用labelVideoPath ========== statusBar()->showMessage(QString("已选中视频:%1").arg(m_videoPath)); // 临时wav放在程序exe同目录,避免工作目录错乱 QString appDir = QCoreApplication::applicationDirPath(); m_tempWavPath = QDir(appDir).filePath("temp_audio.wav"); // 清空旧数据 m_fullResultText.clear(); ui->textResult->clear(); ui->textSubtitleView->clear(); m_fullGlobalLog.clear(); //【重点】新视频,清空全局调试日志 m_progressBar->setValue(0); m_isRunning = true; ui->btnStop->setEnabled(true); ui->btnSelectVideo->setEnabled(false); // 任务运行时禁用选择视频按钮 showProgressBar(); // 开始任务,显示状态栏进度条 // 【第一步】读取视频总时长,用于进度百分比计算 m_videoTotalSec = getVideoTotalDuration(m_videoPath); if(m_videoTotalSec <= 0) { appendLog("⚠️警告:读取视频总时长失败,进度条只能显示原始时间,无法计算百分比", false); } // 启动ffmpeg提取音频 m_ffmpegProc = new QProcess(this); QStringList args; args << "-i" << m_videoPath << "-vn" << "-acodec" << "pcm_s16le" << "-ar" << "16000" << "-ac" << "1" << "-y" << m_tempWavPath; // 合并通道,捕获stderr解析进度 m_ffmpegProc->setProcessChannelMode(QProcess::MergedChannels); connect(m_ffmpegProc, SIGNAL(readyReadStandardError()), this, SLOT(onFfmpegStdErr())); connect(m_ffmpegProc, SIGNAL(finished(int)), this, SLOT(onFfmpegFinished(int))); m_ffmpegProc->start(QDir(appDir).filePath("ffmpeg.exe"), args); appendLog(QString("选中视频:%1").arg(m_videoPath), false); appendLog("开始音频提取...", false); } /** * @brief onFfmpegStdErr 【FFmpeg stderr输出回调】 * 读取ffmpeg输出流,解析time字段,实时更新当前已转换音频时长,计算进度百分比 */ void MainWindow::onFfmpegStdErr() { if(!m_ffmpegProc) return; QString data = QString::fromUtf8(m_ffmpegProc->readAllStandardError()); static QRegularExpression re("time=(\\d+):(\\d+):(\\d+)\\.(\\d+)"); auto match = re.match(data); if(match.hasMatch()) { int h = match.captured(1).toInt(); int m = match.captured(2).toInt(); int s = match.captured(3).toInt(); double currentSec = h*3600 + m*60 + s; // 标记为调试日志,存入m_fullGlobalLog appendLog(QString("FFmpeg已转换:%1 秒").arg(currentSec), true); // 有总时长,计算FFmpeg阶段进度(0~50%) if(m_videoTotalSec > 0) { double percent = currentSec / m_videoTotalSec * 50.0; if(percent > 50) percent = 50; m_progressBar->setValue(static_cast<int>(percent)); } } } /** * @brief onFfmpegFinished 【FFmpeg进程结束回调】 * 音频提取完成/失败后触发,成功则启动sherpa-onnx离线识别 * @param exitCode 进程退出码,0代表正常成功 */ void MainWindow::onFfmpegFinished(int exitCode) { m_ffmpegProc->deleteLater(); m_ffmpegProc = nullptr; if(!m_isRunning) { resetUiState(); return; } if(exitCode != 0) { appendLog("❌FFmpeg音频提取失败", false); resetUiState(); return; } appendLog("✅音频提取完成,开始离线语音识别", false); m_progressBar->setValue(50); statusBar()->showMessage("正在ASR语音识别..."); // 启动 sherpa-onnx-offline 识别 m_asrProc = new QProcess(this); QString appDir = QCoreApplication::applicationDirPath(); m_asrProc->setWorkingDirectory(appDir); QStringList args; args << "--sense-voice-model=./models/model.int8.onnx"; args << "--tokens=./models/tokens.txt"; args << "--sense-voice-language=auto"; args << m_tempWavPath; // ===== 重点:绑定stdout和stderr两个信号 ===== connect(m_asrProc, SIGNAL(readyReadStandardOutput()), this, SLOT(onAsrReadOutput())); connect(m_asrProc, SIGNAL(readyReadStandardError()), this, SLOT(onAsrStdErr())); connect(m_asrProc, SIGNAL(finished(int)), this, SLOT(onAsrFinished(int))); // 清空缓存,防止上次任务残留 m_stdoutBuffer.clear(); m_fullAsrDebugLog.clear(); m_asrProc->start(QDir(appDir).filePath("sherpa-onnx-offline.exe"), args); appendLog("sherpa进程已启动,等待识别输出...", false); } /** * @brief onAsrReadOutput 【Sherpa识别输出回调】 * 只收集stdout原始数据,存入调试日志 */ void MainWindow::onAsrReadOutput() { if(!m_asrProc) return; QByteArray data = m_asrProc->readAllStandardOutput(); m_stdoutBuffer += data; QString stdoutText = QString::fromUtf8(data); appendLog(QString("[SHERPA STDOUT] %1").arg(stdoutText), true); m_fullAsrDebugLog += stdoutText; } /** * @brief onAsrStdErr 【接收 sherpa 的 stderr 调试信息】 */ void MainWindow::onAsrStdErr() { if(!m_asrProc) return; QString errText = QString::fromUtf8(m_asrProc->readAllStandardError()); appendLog(QString("[SHERPA STDERR] %1").arg(errText), true); m_fullAsrDebugLog += errText; } /** * @brief onAsrFinished 【Sherpa识别进程结束回调】 * 语音识别全部完成,汇总结果,解析完整JSON * @param exitCode 进程退出码 */ void MainWindow::onAsrFinished(int exitCode) { m_asrProc->deleteLater(); m_asrProc = nullptr; m_progressBar->setValue(100); QString out = QString::fromUtf8(m_stdoutBuffer).trimmed(); m_rawAsrJson = out; if(exitCode == 0) { appendLog("✅识别任务全部完成", false); statusBar()->showMessage("识别完成"); m_progressBar->setValue(100); QJsonParseError jsonErr; QJsonDocument doc = QJsonDocument::fromJson(m_stdoutBuffer, &jsonErr); if(jsonErr.error == QJsonParseError::NoError && doc.isObject()) { QJsonObject obj = doc.object(); QString pureText = obj["text"].toString().trimmed(); m_fullResultText = pureText; QJsonArray tsArr = obj["timestamps"].toArray(); m_tsList.clear(); for(auto item : tsArr) { m_tsList.append(item.toDouble()); } appendLog(QString("【识别文本】:%1").arg(m_fullResultText), false); appendLog(QString("Sherpa原始JSON:%1").arg(out), true); } else { appendLog("⚠️JSON解析失败!原始输出:" + out, false); appendLog(QString("JSON解析失败,原始输出:%1").arg(out), true); } } else { appendLog(QString("❌识别进程异常退出,退出码:%1").arg(exitCode), false); appendLog(QString("❌识别进程异常退出,exitCode=%1").arg(exitCode), true); statusBar()->showMessage("识别任务失败"); m_progressBar->setValue(0); } cleanTempAudio(); resetUiState(); } /** * @brief on_btnStop_clicked 【停止任务按钮】 * 强制kill FFmpeg和Sherpa进程,终止当前任务,重置UI */ void MainWindow::on_btnStop_clicked() { appendLog("⚠️用户点击停止按钮,终止任务", true); if(m_ffmpegProc) { m_ffmpegProc->kill(); m_ffmpegProc->waitForFinished(1000); m_ffmpegProc->deleteLater(); m_ffmpegProc = nullptr; } if(m_asrProc) { m_asrProc->kill(); m_asrProc->waitForFinished(1000); m_asrProc->deleteLater(); m_asrProc = nullptr; } appendLog("⚠️任务已手动停止", false); statusBar()->showMessage("任务手动终止"); cleanTempAudio(); resetUiState(); } /** * @brief on_btnSaveTxt_clicked 【保存TXT按钮】 * 将全部识别文本保存为utf-8 txt文本文件 */ void MainWindow::on_btnSaveTxt_clicked() { if(m_fullResultText.trimmed().isEmpty()) { QMessageBox::warning(this,"提示","暂无识别结果!"); return; } QString savePath = QFileDialog::getSaveFileName(this,"保存文本",QDir::homePath()+"/result.txt","Text (*.txt)"); if(savePath.isEmpty()) return; QFile f(savePath); if(!f.open(QIODevice::WriteOnly|QIODevice::Text)) { QMessageBox::critical(this,"错误","保存失败,无法打开文件"); return; } QTextStream ts(&f); ts.setCodec("UTF-8"); ts << m_fullResultText; f.close(); appendLog(QString("TXT识别结果保存至:%1").arg(savePath), true); QMessageBox::information(this,"成功","TXT文本已保存"); } /** * @brief on_btnSaveSrt_clicked 【保存SRT字幕按钮】 * 简易SRT字幕保存,整段识别内容作为单条字幕 */ void MainWindow::on_btnSaveSrt_clicked() { if(m_fullResultText.trimmed().isEmpty() || m_tsList.isEmpty()) { QMessageBox::warning(this,"提示","暂无识别结果或时间戳数据!"); return; } QString savePath = QFileDialog::getSaveFileName(this,"保存SRT字幕",QDir::homePath()+"/result.srt","Subtitle (*.srt)"); if(savePath.isEmpty()) return; QFile f(savePath); if(!f.open(QIODevice::WriteOnly|QIODevice::Text)) { QMessageBox::critical(this,"错误","SRT保存失败,无法打开文件"); return; } QTextStream ts(&f); ts.setCodec("UTF-8"); ts << "1\n"; ts << "00:00:00,000 --> 99:59:59,000\n"; ts << m_fullResultText.trimmed() << "\n\n"; f.close(); appendLog(QString("SRT字幕保存至:%1").arg(savePath), true); QMessageBox::information(this,"成功","SRT字幕已保存"); } /** * @brief parseFfmpegTime 解析ffmpeg time字符串 "00:01:23.45" 转为总秒数 * @param timeStr 时间字符串 * @return 浮点秒数 */ double MainWindow::parseFfmpegTime(const QString &timeStr) { QStringList parts = timeStr.split(":"); if(parts.size()<3) return 0; double h = parts[0].toDouble(); double m = parts[1].toDouble(); double s = parts[2].toDouble(); return h*3600 + m*60 + s; } /** * @brief getVideoTotalDuration 使用ffprobe读取视频总时长(秒) * @param videoPath 视频文件路径 * @return 总秒数,读取失败返回0 */ double MainWindow::getVideoTotalDuration(const QString &videoPath) { QProcess probe; QString appDir = QCoreApplication::applicationDirPath(); QStringList args; args << "-v" << "error" << "-select_streams" << "v:0" << "-show_entries" << "stream=duration" << "-of" << "default=noprint_wrappers=1:nokey=1" << videoPath; probe.start(QDir(appDir).filePath("ffprobe.exe"), args); if(!probe.waitForFinished(3000)) return 0; QString durStr = QString::fromUtf8(probe.readAllStandardOutput()).trimmed(); return durStr.toDouble(); } /** * @brief updateSubtitlePreview 更新字幕预览框 * @param text 识别文本,实时显示在预览控件 */ void MainWindow::updateSubtitlePreview(const QString &text) { ui->textSubtitleView->setPlainText(text); } /** * @brief resetUiState 重置UI控件状态,任务结束/中断后调用 */ void MainWindow::resetUiState() { m_isRunning = false; ui->btnStop->setEnabled(false); ui->btnSelectVideo->setEnabled(true); hideProgressBar(); //任务结束隐藏状态栏进度条 } /** * @brief cleanTempAudio 删除临时wav音频文件 */ void MainWindow::cleanTempAudio() { QFile tempFile(m_tempWavPath); if(tempFile.exists()) { tempFile.remove(); appendLog("临时音频文件已自动删除", false); appendLog("临时音频文件已自动删除", true); } }

9. 限制说明

  1. 外部 exe 方案
  2. 会生成临时 wav 音频文件;
  3. 分发需要附带 ffmpeg、sherpa exe、dll、模型文件夹;
  4. 模型文件体积较大,首次加载模型存在几秒耗时。

后续扩展:
拿到 sherpa 输出完整 JSON 后,补充 JSON 解析代码,提取 text 和 timestamps,调用 saveTxt/saveSrt 保存字幕。


10.效果

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 20:53:17

Wireshark抓包入门:从乱码到网络协议解析

1. 为什么你第一次打开Wireshark时&#xff0c;看到的全是“看不懂的乱码”&#xff1f;我第一次在公司网络运维岗上手Wireshark&#xff0c;是被一个“用户打不开某内部系统”的工单推到桌前的。主管甩来一句&#xff1a;“抓个包看看”&#xff0c;我就点开Wireshark&#xf…

作者头像 李华
网站建设 2026/9/25 20:51:00

Linux 7.4内核HDMI全面升级:FreeSync VRR与ALLM原理及实践指南

如果你还停留在“Linux下插HDMI只要屏幕能亮就算成功”的阶段&#xff0c;那么Linux 7.4内核这次在HDMI功能上的全面升级&#xff0c;很可能会刷新你对Linux桌面显示体验的认知。新增的FreeSync VRR&#xff08;可变刷新率&#xff09;与自动低延迟模式&#xff08;ALLM&#x…

作者头像 李华
网站建设 2026/9/25 20:44:10

飞书项目主数据同步插件实战:一处维护枚举,自动同步到多个空间

企业用飞书项目做多空间协作时&#xff0c;常踩一个隐蔽的坑&#xff1a;下拉字段口径对不齐。项目类型、优先级、客户行业、合同状态……这些枚举一旦要跨空间统计&#xff0c;各有一套版本就全乱了。飞书项目主数据同步插件由北京高远科技开发、作为飞书项目插件运行&#xf…

作者头像 李华
网站建设 2026/9/25 20:43:33

华为Atlas 300V 24G AI推理加速卡解析与YOLO部署实践

“Atlas 300V 24G 是运算加速卡吗&#xff1f;”这个问题&#xff0c;我在后台和技术群里看到过不下五六次。能理解&#xff0c;名字里带个V、宣传材料里又老提“视频分析”&#xff0c;很多人第一反应是“这难道是块视频采集卡、转码卡”&#xff0c;压根没往“跑深度学习模型…

作者头像 李华