简介:本资源面向具备一定C++与Qt基础的开发者,提供一套基于Qt框架集成科大讯飞在线语音识别服务的完整工程示例,帮助解决桌面端语音转文字功能的落地问题。压缩包共107个文件,约38.76MB,包含cpp、h源码文件、dll动态库、lib库、obj与pdb编译中间文件、vcxproj工程文件及ui界面文件等,覆盖从音频采集、网络请求到结果解析的完整链路。项目围绕信号与槽机制展开,涉及QAudioInput录音、QNetworkAccessManager发送HTTP请求、JSON数据解析以及日志记录与错误处理等关键环节,并配有可运行的exe与配置文件,便于直接编译调试。目前已有33393人学习下载,适合希望快速掌握Qt语音识别集成思路、对照源码理解接口调用流程的开发者参考借鉴。
1. 从零拆解 Qt 接入在线语音识别:为什么我选 WebSocket 而不是 HTTP
很多做 Qt 桌面端的朋友第一次接到语音识别需求时,第一反应是找个 HTTP 接口,录一段音频 POST 上去,等 JSON 回来。我最早也这么干过,结果在真实场景里翻车:一段 10 秒的录音要等 3 到 5 秒才出结果,用户对着麦克风说完话盯着界面发呆,体验直接崩掉。后来换成 WebSocket 长连接流式推送,边说边出字,延迟压到几百毫秒以内,这才算能用。
这篇笔记拆的是一个 Qt 实现科大讯飞在线语音识别的完整工程。核心思路是:Qt 负责采集麦克风 PCM 音频、做重采样和分帧,通过 WebSocket 把音频流实时推给讯飞开放平台的语音听写服务,服务端返回带时间戳的识别结果,Qt 端解析后渲染到界面。适合有 Qt 基础、想给自己的桌面工具加上语音输入能力的开发者,也适合想搞清楚流式语音识别链路到底怎么跑的人。下面从鉴权、音频采集、协议封装到排错,一步步拆开讲。
2. 鉴权与连接建立:把 HMAC-SHA1 签名和 WebSocket 握手讲透
在线语音识别的第一道坎不是音频,是鉴权。讯飞 WebSocket 接口要求每个连接带上签名后的 URL,签名算法是 HMAC-SHA1 再 Base64。这一步如果错了,后面音频推得再对也连不上。我见过不少人卡在这里,报 401 或者 403,翻文档翻半天,其实就是时间戳格式或者签名原文拼错了。
2.1 签名参数的组成与生成逻辑
签名的本质是把你的 apiKey、apiSecret 和当前时间戳按固定格式拼成一个字符串,再用 apiSecret 做 HMAC-SHA1 加密,最后 Base64 编码。拼串的格式是固定的,顺序不能乱:
host: wss://ws-api.xfyun.cn/v2/ia date: Mon, 15 Jan 2024 08:30:00 GMT GET /v2/ia HTTP/1.1注意 date 必须是 RFC1123 格式的 GMT 时间,不是本地时间,也不是 Unix 时间戳。host 不带协议头,request-line 里的路径要和实际请求路径一致。这三行拼好后,用 apiSecret 作为密钥做 HMAC-SHA1,得到二进制摘要,再 Base64。最后把 authorization 拼成:
api_key="xxx", algorithm="hmac-sha1", headers="host date request-line", signature="xxx"再把 authorization 做一次 Base64,作为 query 参数拼到 wss URL 后面,同时带上 host 和 date。整个 URL 长这样:
wss://ws-api.xfyun.cn/v2/ia?authorization=xxx&date=xxx&host=ws-api.xfyun.cn下面是我在 Qt 里用的签名生成代码,依赖 Qt 自带的 QMessageAuthenticationCode:
// 生成 WebSocket 连接所需的鉴权 URL QString buildAuthUrl(const QString &apiKey, const QString &apiSecret) { // 1. 生成 RFC1123 格式的 GMT 时间 QString date = QDateTime::currentDateTimeUtc() .toString("ddd, dd MMM yyyy HH:mm:ss 'GMT'") .toLatin1(); // 2. 拼接签名原文,顺序和换行都不能错 QString signatureOrigin = QString("host: ws-api.xfyun.cn\n" "date: %1\n" "GET /v2/ia HTTP/1.1") .arg(date); // 3. HMAC-SHA1 加密,密钥是 apiSecret QByteArray key = apiSecret.toUtf8(); QByteArray data = signatureOrigin.toUtf8(); QByteArray hmac = QMessageAuthenticationCode::hash( data, key, QCryptographicHash::Sha1) .toBase64(); // 4. 拼接 authorization 原文 QString authOrigin = QString("api_key=\"%1\", algorithm=\"hmac-sha1\", " "headers=\"host date request-line\", " "signature=\"%2\"") .arg(apiKey, QString(hmac)); // 5. authorization 再做一次 Base64 QString authorization = authOrigin.toUtf8().toBase64(); // 6. 拼接最终 URL,注意参数需要 URL 编码 QString url = QString("wss://ws-api.xfyun.cn/v2/ia?authorization=%1&date=%2&host=ws-api.xfyun.cn") .arg(QString(QUrl::toPercentEncoding(authorization)), QString(QUrl::toPercentEncoding(date))); return url; }逻辑说明:第一步生成 GMT 时间,格式里的ddd和MMM是英文缩写,Qt 的 toString 能直接输出。第二步拼签名原文,三行之间用\n连接,最后一行末尾不能有多余换行。第三步用QMessageAuthenticationCode::hash做 HMAC-SHA1,第三个参数指定算法。第五步的 authorization 必须再 Base64 一次,这是最容易漏的。第六步对参数做 URL 编码,否则 date 里的空格和逗号会让 URL 解析出错。
参数说明:apiKey 和 apiSecret 从讯飞开放平台控制台获取,每个应用一对。date 的有效期通常是几分钟,服务端会校验时间偏差,所以本地时间要准。如果机器时间偏差超过 5 分钟,签名会直接失效,报 403。
2.2 WebSocket 连接的建立与心跳维持
签名 URL 拼好后,用 QWebSocket 发起连接。连接建立后不要急着推音频,先等服务端返回握手成功的消息。讯飞的协议里,服务端会在连接成功后返回一个 JSON,包含code字段,0 表示成功。如果 code 非 0,说明鉴权或参数有问题,这时候推音频也是白推。
// 初始化 WebSocket 并连接 void AudioRecognizer::start() { m_webSocket = new QWebSocket(); connect(m_webSocket, &QWebSocket::connected, this, &AudioRecognizer::onConnected); connect(m_webSocket, &QWebSocket::textMessageReceived, this, &AudioRecognizer::onTextMessage); connect(m_webSocket, &QWebSocket::disconnected, this, &AudioRecognizer::onDisconnected); QString url = buildAuthUrl(m_apiKey, m_apiSecret); m_webSocket->open(QUrl(url)); } void AudioRecognizer::onConnected() { // 连接建立后发送业务参数,告诉服务端音频格式和识别配置 QJsonObject business; business["language"] = "zh_cn"; business["domain"] = "iat"; business["accent"] = "mandarin"; business["vad_eos"] = 3000; // 静音 3 秒判定一句话结束 QJsonObject common; common["app_id"] = m_appId; QJsonObject frame; frame["common"] = common; frame["business"] = business; // 第一帧 status=0,表示开始,音频数据为空 frame["data"] = QJsonObject{ {"status", 0}, {"format", "audio/L16;rate=16000"}, {"encoding", "raw"}, {"audio", ""} }; QJsonDocument doc(frame); m_webSocket->sendTextMessage(doc.toJson(QJsonDocument::Compact)); }逻辑说明:onConnected里发送的是第一帧,status 为 0,表示会话开始。business 里的参数决定识别行为,vad_eos是静音断句阈值,单位毫秒,设太小会把正常停顿切成多句,设太大则一句话要等很久才出结果。data 里的 format 声明音频是 16kHz 16bit 单声道 PCM,encoding 为 raw 表示不压缩。
参数说明:language固定 zh_cn,domain用 iat 表示语音听写,accent可选 mandarin 或 cantonese。vad_eos我一般设 3000,对话场景可以设 2000。如果做长语音转写,要用domain为 iat 的持续模式,配合 status 为 1 的中间帧。
心跳方面,讯飞 WebSocket 不需要客户端主动发 ping,但服务端会在长时间无数据时断开。如果做的是按住说话场景,两次说话间隔超过 30 秒,建议断开重连,别让连接空转。我一般会在停止录音后主动 close,下次说话重新建连,避免连接状态不确定。
3. 音频采集与分帧推送:QAudioInput 的坑和 40ms 帧长怎么定
鉴权通了,接下来是音频。Qt 里采集麦克风用 QAudioInput,但它的默认参数和讯飞要求的 16kHz 16bit 单声道往往对不上。直接拿默认的 44.1kHz 立体声推过去,识别结果会变成噪音或者直接报错。这一章讲怎么把音频采对、切对、推对。
3.1 QAudioInput 的格式协商与重采样
QAudioInput 的格式用 QAudioFormat 描述,必须显式设置采样率、声道数、采样格式。讯飞要求 16000Hz、1 声道、16 位有符号整数小端。设置代码如下:
// 配置音频采集格式 QAudioFormat format; format.setSampleRate(16000); format.setChannelCount(1); format.setSampleSize(16); format.setCodec("audio/pcm"); format.setByteOrder(QAudioFormat::LittleEndian); format.setSampleType(QAudioFormat::SignedInt); QAudioDeviceInfo info = QAudioDeviceInfo::defaultInputDevice(); if (!info.isFormatSupported(format)) { // 设备不支持时,退而求其次用最近似的格式,再手动重采样 format = info.nearestFormat(format); qWarning() << "设备不支持目标格式,实际使用:" << format.sampleRate() << format.channelCount() << format.sampleSize(); } m_audioInput = new QAudioInput(info, format, this); m_audioInput->setBufferSize(3200); // 约 100ms 的缓冲 m_ioDevice = m_audioInput->start(); connect(m_ioDevice, &QIODevice::readyRead, this, &AudioRecognizer::onAudioDataReady);逻辑说明:先构造目标格式,用isFormatSupported检查设备是否支持。很多笔记本内置麦克风不支持 16kHz 单声道,这时候nearestFormat会返回一个近似格式,比如 44100Hz 立体声。拿到近似格式后,需要在onAudioDataReady里做重采样和声道合并,否则推给讯飞的音频采样率不对,识别会乱码。
参数说明:setBufferSize(3200)是 16000Hz 16bit 单声道下 100ms 的字节数,缓冲太小会频繁触发 readyRead,太大则延迟高。我一般设 3200 到 6400 之间。start()返回的 QIODevice 是只读的,用 readyRead 信号驱动读取。
重采样如果设备不支持 16kHz,常见做法是用线性插值把 44.1kHz 降到 16kHz,或者用 Qt 的 QAudioRecorder 配合 QAudioProbe 做格式转换。我一般直接要求用户用支持 16kHz 的设备,实在不行才上重采样,因为重采样代码写不好会引入杂音,反而影响识别率。
3.2 40ms 分帧与 status 状态机
音频数据读出来后,不能一股脑全推过去,要按固定帧长切分。讯飞建议每帧 40ms,对应 16000Hz 16bit 单声道下 1280 字节。帧太长会增加延迟,帧太短则协议开销大。切帧的同时要维护 status 状态:第一帧 status=0,中间帧 status=1,最后一帧 status=2。
void AudioRecognizer::onAudioDataReady() { QByteArray data = m_ioDevice->readAll(); m_buffer.append(data); const int frameSize = 1280; // 40ms * 16000Hz * 2字节 while (m_buffer.size() >= frameSize) { QByteArray frame = m_buffer.left(frameSize); m_buffer.remove(0, frameSize); sendAudioFrame(frame, 1); // 中间帧 status=1 } } void AudioRecognizer::sendAudioFrame(const QByteArray &audio, int status) { QJsonObject frame; frame["data"] = QJsonObject{ {"status", status}, {"format", "audio/L16;rate=16000"}, {"encoding", "raw"}, {"audio", QString(audio.toBase64())} }; QJsonDocument doc(frame); m_webSocket->sendTextMessage(doc.toJson(QJsonDocument::Compact)); } void AudioRecognizer::stop() { // 发送最后一帧,status=2,音频为空 sendAudioFrame(QByteArray(), 2); }逻辑说明:onAudioDataReady把新读到的数据追加到缓冲区,然后按 1280 字节切帧。切出来的帧用 status=1 发送。stop()时发送 status=2 的空帧,告诉服务端音频结束,服务端会返回最终识别结果并关闭会话。
参数说明:frameSize 1280 是 40ms 的字节数,计算方式是 16000 * 0.04 * 2。如果设备实际采样率不是 16000,这个值要按比例调整,或者先重采样再切帧。audio 字段是 Base64 编码的 PCM 数据,不是原始二进制,这一点容易搞错,直接发二进制会导致 JSON 解析失败。
状态机的顺序不能乱:必须先发 status=0 的业务帧,再发 status=1 的音频帧,最后发 status=2 的结束帧。如果第一帧就发 status=1,服务端会报参数错误。如果忘记发 status=2,服务端会一直等,直到超时断开,最后一句话的识别结果可能拿不到。
4. 识别结果解析与界面渲染:把 JSON 里的动态修正和标点还原出来
音频推过去,服务端会陆续返回识别结果。这些结果不是一次性的,而是流式的,同一个词可能先返回一个中间结果,后面再修正。比如先说“今天天气”,服务端可能先返回“今天天”,再返回“今天天气”。如果界面直接覆盖显示,用户会看到文字跳变。这一章讲怎么解析这些结果,以及怎么处理动态修正和标点。
4.1 返回 JSON 的结构与字段含义
服务端返回的每一帧都是 JSON,核心字段在data里:
{ "code": 0, "data": { "status": 1, "result": { "ws": [ {"cw": [{"w": "今天", "wp": "n"}], "wb": 0, "we": 20}, {"cw": [{"w": "天气", "wp": "n"}], "wb": 20, "we": 40} ], "sn": 1, "ls": false } } }code为 0 表示成功。data.status为 1 表示中间结果,为 2 表示最终结果。result.ws是词数组,每个元素有cw(候选词)、wb(开始位置)、we(结束位置)。cw里w是词文本,wp是词性。sn是序号,ls表示是否是最后一片。
解析时要注意:中间结果的ws可能只包含当前这句话的部分词,最终结果才是完整的。我一般把每次返回的词按wb排序后拼接,中间结果用于实时显示,最终结果用于确认。
void AudioRecognizer::onTextMessage(const QString &message) { QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8()); QJsonObject obj = doc.object(); int code = obj["code"].toInt(); if (code != 0) { qWarning() << "识别错误:" << obj["message"].toString(); return; } QJsonObject data = obj["data"].toObject(); int status = data["status"].toInt(); QJsonObject result = data["result"].toObject(); QJsonArray ws = result["ws"].toArray(); QString text; for (const QJsonValue &v : ws) { QJsonArray cw = v.toObject()["cw"].toArray(); if (!cw.isEmpty()) { text += cw[0].toObject()["w"].toString(); } } if (status == 2) { emit finalResult(text); // 最终结果,替换显示 } else { emit partialResult(text); // 中间结果,追加显示 } }逻辑说明:先检查 code,非 0 直接返回并打印错误。然后取 data 里的 status 和 result,遍历 ws 数组,每个元素的 cw 数组取第一个候选词的 w 字段拼接。status 为 2 时发 finalResult 信号,界面替换整句;否则发 partialResult,界面追加显示。
参数说明:cw数组可能有多个候选词,第一个是置信度最高的,一般取第一个。wb和we是字符位置,用于对齐时间轴,如果做字幕同步会用到。ls字段在最终结果里为 true,可以用来判断是否结束。
4.2 动态修正与标点的处理策略
讯飞的识别结果里,标点不是单独返回的,而是包含在词文本里。比如“今天天气不错。”里的句号会跟着最后一个词一起返回。但中间结果里可能没有标点,最终结果才有。所以界面渲染时,中间结果先按无标点显示,最终结果再补上标点。
动态修正的处理方式是:维护一个已确认文本和一个临时文本。中间结果来了,更新临时文本;最终结果来了,把临时文本替换成最终文本,并追加到已确认文本后面。这样用户看到的是文字逐渐稳定,而不是反复跳变。
void MainWindow::onPartialResult(const QString &text) { m_tempText = text; updateDisplay(); // 显示 m_confirmedText + m_tempText } void MainWindow::onFinalResult(const QString &text) { m_confirmedText += text; m_tempText.clear(); updateDisplay(); // 只显示 m_confirmedText }逻辑说明:m_confirmedText存已经确认的句子,m_tempText存当前句的临时结果。中间结果更新临时部分,最终结果把临时部分并入确认部分。这样即使中间结果有修正,最终也会被覆盖成正确文本。
参数说明:如果做多句连续识别,每句话的最终结果追加到m_confirmedText后面,中间用换行或空格分隔。如果做单句识别,每次开始新会话前清空m_confirmedText。
5. 避坑与排查:连接、音频、结果三个环节的常见翻车点
这个工程我前后调了两周,大部分时间不是在写功能,而是在排错。下面这几条是血泪经验,每条都按现象、原因、解决来写,照着查能省不少时间。
5.1 连接建立失败:403 和 401 怎么区分
现象:WebSocket 连接直接失败,返回 403 或 401,日志里看不到任何识别结果。
原因:403 通常是签名里的 date 过期或格式不对,服务端校验时间偏差超过阈值。401 是 apiKey 或 apiSecret 错误,或者 authorization 拼接格式不对。还有一种情况是 URL 参数没有做 URL 编码,date 里的空格导致服务端解析失败。
解决:先检查本地时间是否准确,偏差控制在 1 分钟内。然后打印签名原文和生成的 URL,逐段比对格式。authorization 必须做两次 Base64,第一次是 HMAC 结果,第二次是整个 authorization 字符串。URL 编码用QUrl::toPercentEncoding,不要手动替换。
5.2 音频推过去没反应:采样率和帧长对不上
现象:连接成功,音频帧也发了,但服务端一直不返回识别结果,或者返回空结果。
原因:最常见的是采样率不对。设备实际输出 44.1kHz,但 format 声明 16kHz,服务端按 16kHz 解析,音频就变成噪音。其次是帧长不对,比如发了 100ms 的帧,服务端按 40ms 切,导致数据错位。还有可能是 audio 字段没有 Base64 编码,直接发了二进制。
解决:在onAudioDataReady里打印实际读到的数据量和 format 的采样率,确认设备输出和声明一致。如果不一致,先重采样再切帧。帧长严格按 1280 字节切,不足一帧的留在缓冲区。audio 字段用toBase64()编码后再放入 JSON。
5.3 识别结果跳变:中间结果覆盖了最终结果
现象:界面上文字反复跳变,刚显示“今天天气”,马上变成“今天天”,然后又变回来。
原因:中间结果和最终结果的渲染逻辑没分开。中间结果来了直接覆盖显示,最终结果来了又覆盖一次,用户看到的就是跳变。另外,如果多句话连续识别,没有区分当前句和已确认句,也会导致前面的句子被后面的中间结果冲掉。
解决:用m_confirmedText和m_tempText两个变量分开管理。中间结果只更新m_tempText,最终结果把m_tempText并入m_confirmedText并清空临时部分。界面显示时拼接两个变量,这样中间结果的变化只影响当前句,不会动已确认的内容。
5.4 最后一句话丢失:忘记发 status=2
现象:前面几句话识别正常,最后一句说完后界面没反应,等几秒后连接断开,最后一句没出来。
原因:停止录音时只停止了音频采集,没有发送 status=2 的结束帧。服务端一直在等更多音频,直到超时断开,最后一句的最终结果可能还没返回就断了。
解决:在stop()里显式发送 status=2 的空帧,并且等收到 status=2 的返回结果后再关闭 WebSocket。不要立即 close,否则服务端可能来不及返回最终结果。我一般设一个 2 秒的超时,超时后再强制关闭。
5.5 长时间运行内存上涨:缓冲区没清理
现象:程序运行十几分钟后内存占用从几十兆涨到几百兆,界面开始卡顿。
原因:m_buffer在切帧后没有及时清理,或者 WebSocket 的消息队列积压。如果音频采集速度大于推送速度,缓冲区会一直涨。另外,每次识别结束后没有释放 QWebSocket 和 QAudioInput 对象,也会导致内存泄漏。
解决:切帧后立即remove已处理的数据,保证m_buffer只保留不足一帧的余量。每次会话结束后 delete 掉 QWebSocket,下次重新创建。QAudioInput 可以复用,但 stop 后要确保 ioDevice 被正确关闭。定期用任务管理器观察内存曲线,如果持续上涨,检查是否有对象没释放。
6. 进阶技巧:用 VAD 自动断句和本地缓存兜底
基础功能跑通后,我一般会加两个东西:VAD 自动断句和本地缓存兜底。VAD 让用户不用手动点停止,说完自动断句出结果;本地缓存则在网络抖动时先把音频存下来,恢复后重推,避免丢话。
6.1 基于能量阈值的简易 VAD
讯飞服务端有vad_eos参数,但它是服务端判定的,客户端如果能在本地先做一层 VAD,可以更早停止推流,减少无效数据。简易 VAD 用能量阈值判断:计算每帧音频的 RMS,低于阈值持续一定时间就认为说话结束。
// 计算 PCM 帧的 RMS 能量,判断是否静音 bool isSilence(const QByteArray &frame, double threshold = 500.0) { const qint16 *samples = reinterpret_cast<const qint16 *>(frame.constData()); int count = frame.size() / 2; double sum = 0.0; for (int i = 0; i < count; ++i) { sum += samples[i] * samples[i]; } double rms = qSqrt(sum / count); return rms < threshold; }逻辑说明:把 QByteArray 按 qint16 解析,计算平方和再开方得到 RMS。RMS 低于阈值认为静音。阈值 500 是经验值,安静环境下可以设 300,嘈杂环境设 800。
参数说明:阈值需要根据实际环境调整。我一般会在设置界面留一个滑块让用户调灵敏度。静音持续帧数达到vad_eos / 40时触发停止,比如 vad_eos 为 3000ms,就是连续 75 帧静音后停止。
6.2 本地缓存与重推机制
网络不稳定时,WebSocket 可能断开,正在说的音频就丢了。我一般会在推流的同时把音频帧写入本地临时文件,识别成功后删除,失败则保留。重连后先读缓存文件重推,再继续实时采集。
// 推流前先写缓存,识别成功后清理 void AudioRecognizer::sendAudioFrame(const QByteArray &audio, int status) { if (status == 1 && !audio.isEmpty()) { m_cacheFile.write(audio); // 写入本地缓存 m_cacheFile.flush(); } // ... 原有发送逻辑 } void AudioRecognizer::onFinalResult(const QString &text) { m_cacheFile.resize(0); // 识别成功,清空缓存 // ... 原有处理逻辑 }逻辑说明:中间帧发送前先写入缓存文件,最终结果返回后清空缓存。如果连接断开,缓存文件里保留着未识别的音频,重连后可以重新发送。
参数说明:缓存文件用 QTemporaryFile 或固定路径的 QFile,注意磁盘空间,长时间运行要限制缓存大小。重推时按同样的帧长切分,status 从 0 开始重新走一遍状态机。
6.3 参数速查表
下面这张表是我调参时整理的,不同场景可以直接抄:
| 参数 | 推荐值 | 适用场景 | 备注 |
|---|---|---|---|
| vad_eos | 3000 | 对话输入 | 静音 3 秒断句 |
| vad_eos | 2000 | 语音搜索 | 快速出结果 |
| frameSize | 1280 | 16kHz 16bit | 40ms 帧长 |
| bufferSize | 3200 | QAudioInput | 约 100ms 缓冲 |
| 能量阈值 | 500 | 安静环境 | RMS 判定 |
| 能量阈值 | 800 | 嘈杂环境 | 避免误触发 |
| 重连超时 | 2000ms | 网络抖动 | 超时后强制关闭 |
从那以后我每次接语音识别,都先把签名和音频格式这两步单独写测试用例跑通,再往下做界面。这两步不出错,后面基本就是体力活。希望帮到你。
本文还有配套的精品资源,点击获取