ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# WebAPI封装讯飞语音听写:WebSocket鉴权与音频流转写实战

C# WebAPI封装讯飞语音听写:WebSocket鉴权与音频流转写实战 简介面向C#开发者的科大讯飞语音听写接口集成示例适合需要快速在Web API服务中实现语音转文字、并解决中文编码问题的读者适用场景包括智能客服、在线教育、语音助手等。资源共47个文件压缩包仅5.08MB包含5个C#源码文件、项目工程文件、10个依赖动态库、NuGet配置、说明文档以及2个用于测试的wav音频目录结构直观便于按模块查阅。已有1746人学习浏览。示例以可运行的Demo为基础演示了通过HttpClient构造包含语音文件、API Key和Secret的MultipartFormDataContent请求来调用讯飞语音听写接口并解析JSON响应文本针对gb2312编码报错引入System.Text.Encoding.CodePages包使用Encoding.GetEncoding解码返回内容。代码覆盖控制器端点定义、IFormFile接收上传音频、异常处理与响应解析等关键实现内置测试音频可直接运行验证既可作为C#接入讯飞语音的起步模板也方便开发者提取核心逻辑复用或按需扩展至生产项目。 做C#开发的兄弟应该都有过这种经历项目跑得好好的需求方突然来一句“能不能加个语音转文字功能”。我这次碰到的场景是给内部OA系统做“语音填写工作日志”——用户对着浏览器说话系统自动把内容填到表单里。技术选型时对比了几家语音服务科大讯飞的语音听写在中文识别准确率和接口稳定性上确实能打于是就用C# WebAPI做了一层服务端封装把讯飞的语音听写能力转换成内部接口给前端调用。整个过程从申请账号、生成鉴权、建立WebSocket长连接、推流音频到解析返回结果踩了不少坑。这篇把完整流程和关键代码整理出来给同样用C#接入讯飞语音听写的朋友做个参考。1. 先说清楚整体思路为什么要把讯飞接口再包一层1.1 为什么非要用 WebAPI 做中转讯飞语音听写本身提供的是WebSocket接口理论上前端可以直接连。但实际开发中基本不会这么干原因有三个第一是密钥安全问题。调用讯飞接口需要APPID、APIKey、APISecret三个参数如果直接在前端代码里引入等于把密钥公开了随便谁都能扒走你的调用额度。放在WebAPI后端做中转前端只跟自己的服务器通信密钥永远留在服务端。第二是业务逻辑统一。语音识别往往不是孤立功能它后面还跟着权限校验、调用频次控制、识别结果的存储、敏感词过滤甚至可能要先做音频格式转换。这些逻辑放前端会被绕过去放后端才能统一管控。第三是便于应对接口变化。讯飞接口版本升级、参数调整只需要改服务端的封装前端接口保持一致迭代成本低很多。1.2 讯飞语音听写一次完整的调用长什么样讯飞实时语音听写接口是WebSocket协议的长连接通信一次识别过程的完整调用链路是这样客户端拿到鉴权URL后通过WebSocket客户端建立连接到讯飞服务器。连接建立后先发送一个JSON文本帧里面带上应用ID、音频格式、识别语种等业务参数。然后持续发送音频数据的二进制帧一段音频会按固定大小切成多个帧每帧都带有状态标记首帧status0中间帧status1尾帧status2。服务器接收音频的同时流式返回识别结果也是JSON文本帧里面包含已经识别出的文字。全部推完并收到服务器返回的尾帧后关闭连接。这里的关键点是整个通信过程是双向的客户端边发音频边收结果不是等全部音频传完再一次性识别。所以我们的WebAPI天然支持流式转写也可以支持前面传音频、后面实时拿结果。2. 准备工作账号、密钥与项目骨架2.1 讯飞开放平台的申请与配置在讯飞开放平台注册开发者账号并完成实名认证后创建一个新应用在应用列表里找到“实时语音听写”服务并开通。开通流程中需要特别留意三点一是服务类型要选“实时语音听写”不要选成“录音文件转写”后者是离线转写接口调用方式完全不同二是开通后会有一个免费试用额度一般够开发阶段测试用正式上线再买资源包三是在“服务接口认证信息”里找到APPID、APIKey和APISecret三个参数这三个参数是后续写代码的基础建议先存到自己的密钥管理工具里别直接往代码里贴。有一点容易踩坑老项目里用的是讯飞WebAPI 2.0的旧版调用方式接口地址和参数都和老版本不同一定要确认使用了最新版接口文档。2.2 用VS2022新建一个干净的WebAPI项目VS2022创建WebAPI项目很简单新建项目选择“ASP.NET Core Web API”框架选.NET 8或者你项目实际用的版本勾选“使用控制器”。为了后面调试方便我建议在这个阶段不要勾选“最小API”方式直接用Controller因为后续要接文件上传、WebSocket等服务控制器模式下路由和逻辑都比较清晰。项目建好后先安装两个NuGet包System.Net.WebSockets.Client用于建立WebSocket连接其实ASP.NET Core运行时已内置不装也行真正需要装的是NAudio用于音频格式转换和PCM数据处理后面讲音频处理时再说。还有一个重要的配置改动在Program.cs里加上跨域配置因为前端Vue服务器和WebAPI大概率不同端口跨域访问是必然的。builder.Services.AddCors(options { options.AddPolicy(AllowAll, policy { policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); }); });这里配置Cors策略时如果要带认证信息AllowAnyOrigin会失效需要改成指定来源但开发阶段先用通配方便调试。3. 服务端核心开发签名、建连、推流3.1 鉴权URL生成这一步最容易翻车讯飞WebSocket接口的鉴权方式不是传统的在Header里放Token而是通过URL参数携带签名信息。生成鉴权URL需要四个输入讯飞接口的hostiat-api.xfyun.cn、路径/v2/iat、APIKey和APISecret。签名生成逻辑是通过HMAC-SHA256算法对一段固定格式的字符串计算摘要再经过Base64编码。签名原材料是这三行内容host: 接口域名、date: 当前UTC时间的RFC1123格式字符串、request-line: GET /v2/iat HTTP/1.1三者之间用换行符连接。我一开始以为很复杂其实代码量不大public static string GenerateAuthUrl(string host, string path, string apiKey, string apiSecret) { // date 必须是RFC1123格式的UTC时间 string date DateTime.UtcNow.ToString(r); // 拼接签名原文字符串注意换行符是\n不是\r\n string signatureOrigin $host: {host}\ndate: {date}\nGET {path} HTTP/1.1; // 用APISecret作为密钥对原文做HMAC-SHA256签名结果Base64 string signature; using (var hmac new HMACSHA256(Encoding.UTF8.GetBytes(apiSecret))) { byte[] hash hmac.ComputeHash(Encoding.UTF8.GetBytes(signatureOrigin)); signature Convert.ToBase64String(hash); } // 拼接authorization原文 string authorizationOrigin $api_key\{apiKey}\, algorithm\hmac-sha256\, headers\host date request-line\, signature\{signature}\; string authorization Convert.ToBase64String(Encoding.UTF8.GetBytes(authorizationOrigin)); // 拼到URL上注意URL编码 string url $wss://{host}{path}?authorization{Uri.EscapeDataString(authorization)}date{Uri.EscapeDataString(date)}host{host}; return url; }这里有两个特别容易翻车的点。一个是DateTime.UtcNow.ToString(r)生成的时间字符串是英文格式如Thu, 13 Jun 2024 12:00:00 GMT不要用本地时间否则时间偏差导致鉴权失败。另一个是签名原文字符串的换行符如果在Windows下用Environment.NewLine即\r\n拼接签名结果大概率对不上必须用\n。3.2 WebSocket推流与结果接收鉴权URL生成后就可以建立WebSocket连接并推流了。这部分是整个功能的核心也是我花时间最多的地方。用C#的ClientWebSocket类实现逻辑大致是连接到讯飞服务器先发送业务参数帧再按帧发送音频数据最后接收结果。业务参数帧是关键。讯飞要求首帧必须包含common应用ID、business识别参数、data音频参数三段结构using var ws new ClientWebSocket(); await ws.ConnectAsync(new Uri(authUrl), CancellationToken.None); var firstFrame new Dictionarystring, object { [common] new { app_id appId }, [business] new { language zh_cn, domain iat, accent mandarin, vad_eos 5000, dwa wpgs }, [data] new { status 0, format audio/L16;rate16000, encoding raw, sample_rate 16000 } }; string firstFrameJson JsonSerializer.Serialize(firstFrame); byte[] firstFrameBytes Encoding.UTF8.GetBytes(firstFrameJson); await ws.SendAsync(firstFrameBytes, WebSocketMessageType.Text, true, CancellationToken.None);vad_eos表示静音检测的端点超时单位毫秒设置为5000表示用户停止说话5秒后就自动结束识别这个参数对录音类应用很重要。dwawpgs表示开启动态修正功能可以理解成边说边改识别准确率会高一些。发送完首帧后开始循环推送音频数据。这里有一个终端对齐问题音频数据需要按固定大小切片每一帧都包含data对象其中status字段标记帧位置byte[] audioBytes Convert.FromBase64String(audioBase64); // 传入的PCM音频数据 int frameSize 1280; int offset 0; while (true) { int len Math.Min(frameSize, audioBytes.Length - offset); int status; if (offset 0) status 0; else if (offset len audioBytes.Length) status 2; else status 1; byte[] frame new byte[len]; Array.Copy(audioBytes, offset, frame, 0, len); var dataFrame new Dictionarystring, object { [data] new { status status, format audio/L16;rate16000, encoding raw, audio Convert.ToBase64String(frame) } }; string dataJson JsonSerializer.Serialize(dataFrame); byte[] dataBytes Encoding.UTF8.GetBytes(dataJson); await ws.SendAsync(dataBytes, WebSocketMessageType.Text, true, CancellationToken.None); offset len; if (status 2) break; }帧大小的选择有讲究。我一开始用4096字节结果偶尔出现服务器返回“音频帧顺序错误”后来改成1280字节就好了。讯飞对单帧大小有限制尽量控制在8KB以内而且要确保最后一帧正确标记status2。接收结果是异步的我用了ReceiveAsync循环读取var buffer new byte[8192]; var resultText new StringBuilder(); while (ws.State WebSocketState.Open) { var receiveResult await ws.ReceiveAsync(new ArraySegmentbyte(buffer), CancellationToken.None); if (receiveResult.MessageType WebSocketMessageType.Close) break; string responseJson Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); using JsonDocument doc JsonDocument.Parse(responseJson); JsonElement root doc.RootElement; int code root.GetProperty(code).GetInt32(); if (code ! 0) { string errorMsg root.GetProperty(message).GetString(); Console.WriteLine($识别错误: {errorMsg}); break; } int dataStatus root.GetProperty(data).GetProperty(status).GetInt32(); var wsArray root.GetProperty(data).GetProperty(result).GetProperty(ws); foreach (var item in wsArray.EnumerateArray()) { var cwArray item.GetProperty(cw); var firstCw cwArray[0]; string word firstCw.GetProperty(w).GetString(); resultText.Append(word); } if (dataStatus 2) break; } await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, done, CancellationToken.None); return resultText.ToString();注意讯飞返回的结构里识别结果在data.result.ws数组中每一项代表一个词单元。每项里的cw是候选词数组一般取第一个候选词就行。这里有一个容易忽略的细节开启dwawpgs后返回结果会包含中间结果和最终结果通过data.result.pgs字段判断中间结果不适合直接显示最终结果才稳定。处理逻辑可以根据pgs判断是否完成再累加。4. 前端配合录音上传与结果回显4.1 录音数据怎么处理前端录音是另一个痛点。浏览器的MediaRecorder接口默认输出的是webm或ogg格式讯飞实时语音听写接口不直接支持这些格式要求的输入是16kHz采样率、16bit位深、单声道的PCM裸数据。所以前端不能直接用MediaRecorder得用Web Audio API的AudioContext和ScriptProcessorNode或者AudioWorklet去实时采集PCM数据。核心思路是从getUserMedia拿到麦克风流后接入AudioContext创建一个ScriptProcessorNode处理音频数据块把Web Audio默认的Float32样本格式转换成Int16再通过WebSocket或HTTP上传到我们的WebAPI。navigator.mediaDevices.getUserMedia({ audio: true }) .then(stream { const audioContext new AudioContext({ sampleRate: 16000 }); const source audioContext.createMediaStreamSource(stream); const processor audioContext.createScriptProcessor(4096, 1, 1); processor.onaudioprocess (e) { const inputData e.inputBuffer.getChannelData(0); const pcmData new Int16Array(inputData.length); for (let i 0; i inputData.length; i) { pcmData[i] Math.max(-1, Math.min(1, inputData[i])) * 0x7fff; } // 把pcmData发送到后端接口 }; source.connect(processor); processor.connect(audioContext.destination); });这里需要提醒一下AudioContext({ sampleRate: 16000 })在部分浏览器上可能不生效浏览器会强制用设备默认采样率。如果遇到这种情况有两个处理方案或在前端使用OfflineAudioContext做重采样复杂且性能一般或让WebAPI端用NAudio把收到的音频重采样成16kHz多用一点服务器算力换稳定性。我实际项目是前端保持44.1kHz录音把Float32 PCM转成Int16后Post到WebAPI由服务端用NAudio做降采样。这样前端逻辑简单兼容性也最好。4.2 Vue上传部分的几个细节前端用Vue上传播放时有几个容易踩的细节需要注意。一个是音频上传格式的选择。如果按原始PCM裸数据上传Content-Type用application/octet-stream后端接收byte[]。如果用multipart/form-data上传文件后端用IFormFile接收。我倾向于用后者因为后面可能需要保存原始音频作为录音留存方便追溯和重新转写。另一个是“如何保持文件名不变”的问题这个在Web开发里是个经典坑。用MediaRecorder录制完音频后生成的是一个Blob对象上传时需要给它一个名字const recordedBlob new Blob(chunks, { type: audio/webm }); const file new File([recordedBlob], recording-${Date.now()}.webm, { type: audio/webm });用FormData把file对象附加进去后端接收后保存文件时用file.FileName就是上面传的recording-xxx.webm文件名不会被浏览器篡改。之前有人遇到的“下载文件名变成乱码或变成guid”的情况多半是后端响应头里Content-Disposition没设置正确需要在下载接口指定filename*参数并做URL编码var cd new ContentDispositionHeaderValue(attachment) { FileNameStar Uri.EscapeDataString(fileName) }; Response.Headers.Add(Content-Disposition, cd.ToString());前端再配合axios的responseType: blob去接收文件名就能正常显示。5. 踩坑记录与问题排查5.1 鉴权失败的常见原因鉴权失败是接入讯飞接口第一个遇到的坑错误信息通常显示“签名认证失败”或“时间戳过期”。从我的排查经验看九成以上是下面三个原因第一个是服务器时间不准。签名中使用的date参数是当前UTC时间如果服务器时钟偏差超过5分钟签名校验必然失败。这个在本地开发时不容易出问题部署到云服务器后反而容易踩。检查方法是看date参数和当前UTC时间是否一致。第二个是换行符不对。前面说过签名原文拼接时必须用\n。如果从官方文档复制代码后做了格式化可能被编辑器悄悄替换成\r\n于是签名结果完全不一致。这种情况建议把签名原文先打印出来逐字节核对换行符。第三个是APISecret值弄反了。讯飞控制台里有APIKey和APISecret我见过不少人把APIKey当成APISecret当签名密钥或者反过来。记住签名密钥用的是APISecretAPIKey只作为明文字段放在authorization里。5.2 识别结果一直为空怎么排查如果鉴权顺利通过WebSocket连接正常但返回的结果一直为空数组大概率是音频数据的问题。优先检查格式讯飞实时听写要求PCM编码、16kHz采样率、16bit位深、单声道。如果传的是WAV文件WAV头里的参数和data.format参数必须匹配我自己曾经因为WAV文件的采样率是44100但format声明16000而导致服务器迟迟不返回结果。如果传的是WebM格式那么连识别都启动不了。解决方案是服务端统一走NAudio转成PCM不要依赖前端传什么就是什么。还要检查音频数据是否过短。如果只录了不到一秒的音频讯飞可能直接忽略。另外静音阈值设置太高也会导致识别不出来内容可以在前端录音时做一个简单的音量判断低于阈值的音频块直接丢弃避免静音数据占满推流通道。5.3 并发与性能的一些思考语音听写属于IO密集型操作主要耗时在网络通信和音频转码上CPU消耗倒是不高。WebAPI部署到IIS或Kestrel时单个实例的并发能力受限于两个因素一是WebSocket连接数二是讯飞账号本身的并发配额。免费试用账号的并发通常很低可能只支持1路同时识别。上线前要根据实际用户量购买足够的并发资源包。应用层可以做排队策略把语音识别请求放到队列里设置一个信号量限制最大并发数多余的请求进入等待。另外一个优化点是音频转码的时机。NAudio重采样是CPU密集操作如果前端同时来大量请求会在转码阶段形成瓶颈。可以把“接收音频、转码”和“推送讯飞、获取结果”拆成两个步骤音频文件先落盘后台异步处理前端轮询或通过SignalR接收结果。这样用户体验虽然是异步的但系统吞吐量大幅提升。至于讯飞WebSocket连接的重用问题我研究过但没实现——因为每次识别的音频内容不同服务端无法跨会话复用连接所以本质上“连接池”意义不大。更实用的做法是给WebAPI加超时控制默认30秒没有结果就断开避免连接被白白占用。最后分享一个个人经验不要把讯飞的密钥直接放到代码里哪怕只是内部系统。我在项目里把APPID、APIKey、APISecret放到了appsettings.Production.json并通过环境变量注入这样即使代码仓库泄露密钥还在服务器上。语音识别功能的联调阶段建议先用讯飞官方提供的示例音频文件测试通过后再调通前端录音链路这样能少走很多弯路。这套C# WebAPI封装讯飞语音听写的方案做完后后面接任何WebSocket类的第三方接口都顺手了很多核心就是那套“签名加握手、推流加回调”的套路一通百通。本文还有配套的精品资源点击获取
返回列表