
简介面向使用C#的开发者与Web API初学者这份资源演示了如何调用科大讯飞语音听写服务将上传的语音文件实时转换为文字可适用于智能客服、在线教育、语音助手等场景。项目实现中重点展示了WebAPI控制器、HttpClient发送请求、MultipartFormDataContent构造上传内容、JSON响应解析以及gb2312中文乱码的处理方法同时指明需安装System.Text.Encoding.CodePages包来支持gb2312编码对调试语音转写流程很有帮助。资源包共47个文件约5.08MB以cs源码、dll依赖、xml工程文件、wav测试音频和说明文档为主附带解决方案sln与demo示例结构紧凑适合直接打开工程对照学习。已有1746人浏览学习参考Demo可快速掌握ApiKey/Secret签名传递、语音上传、结果解析等关键代码对于后续扩展中的错误重试、调用限频和日志记录等工程化问题也能从中获得清晰的排错思路。 科大讯飞的语音听写几乎是国内做语音转文字绕不开的一个方案。最近项目里正好需要把“录音文件转文字”的能力整合到现有的一套C# WebAPI服务里前前后后踩了不少坑也把整个流程完整跑通了。这篇文章就围绕“C# WebAPI 实现科大讯飞语音听写功能”这件事把从接口鉴权、后端封装、到前端Vue对接的完整链路拆开讲清楚给准备接讯飞听写、或者正在纠结怎么把语音能力揉进现有业务系统的朋友一个可以直接抄作业的参考。这个方案解决的核心问题很明确业务系统里需要一个统一的语音转文字服务入口。你可以让前端把录音文件传上来WebAPI负责跟讯飞交互、解析结果、再返回给前端也可以把能力封装成内部服务供其他地方复用。适合的场景包括但不限于会议录音归档、客服质检、语音笔记、工单语音描述转文本等。如果你用的是C#技术栈又想保留WebAPI的通用性这篇内容基本能覆盖你从零到一的全过程。1. 整体设计与技术选型拆解先把话放在前面讯飞语音听写API本身是WebSocket接口不是普通的HTTP POST就能搞定的。这跟很多人一开始的想法不太一样以为拿个HTTP请求把音频丢过去就能返回文字实际上讯飞的实时语音听写iat走的是WebSocket长连接客户端和服务端需要持续通信服务端不断返回中间识别结果最后返回最终结果。这是第一个需要接受的事实。1.1 为什么用WebAPI包一层而不是前端直连讯飞前端直连讯飞理论上是可以的讯飞也提供了WebSocket的JS SDK。但实际业务里我强烈不建议前端直接持有讯飞AppID和APIKey。原因有三点一是密钥暴露在前端代码里等于把接口额度拱手让人容易被人刷爆配额二是识别逻辑如果完全放在前端后续想换语音服务商、加后处理比如敏感词过滤、标点优化、语义纠错就得改所有前端三是WebAPI层可以统一做鉴权、限流、日志、计费统计这些是业务系统迟早要的东西。所以我的方案是前端只负责录音和上传后端WebAPI接收音频文件由后端去跟讯飞建立WebSocket连接拿到识别结果后返回给前端。前端甚至不需要关心讯飞的任何协议细节它只面对一个普通的HTTP接口传文件拿文本。这个解耦思路在多次对接第三方能力之后回过头看依然是最稳的架构选择。1.2 大流程梳理C# WebAPI如何与讯飞WebSocket交互整个链路的时序大概是这样的前端Vue录音生成音频文件并通过FormData上传到WebAPI的某个接口C#后端接口接收到文件后读取音频字节流然后封装一个专门负责与讯飞通信的客户端类用WebSocket连接到讯飞的听写服务地址。连接建立后C#端把音频数据分帧发送每帧1280字节对应40毫秒的音频同时实时接收讯飞返回的JSON格式中间结果最后把完整文本组装好以HTTP JSON响应的形式回传给前端。这个过程中有几个关键点鉴权URL的签名算法、音频分帧的节奏、中间结果和最终结果的判断、以及音频格式的约束。下面逐个展开。2. 讯飞语音听写API的鉴权机制与核心细节科大讯飞开放平台的语音听写接口要求每次连接WebSocket时都带一个动态生成的鉴权URL而不是拿固定的APIKey直接连。这个鉴权算法是很多人在第一步就卡住的地方而且太平洋时间官的文档写得不算太友好尤其是RFC3986编码和HMAC-SHA256签名那块容易踩坑。2.1 鉴权URL的生成规则一次说清楚先看官方要求的鉴权URL参数需要 host、date、authorization三个参数拼接在WebSocket地址后面。其中host是请求的主机名date是当前时间的GMT格式字符串authorization是一个基于HMAC-SHA256签名的Base64字符串。具体的签名逻辑是先用APIKey和APISecret生成一个签名摘要然后把签名摘要拼成authorization。我这里直接给出一个实测可用的C#实现比对着文档反复调整要省事得多using System; using System.Collections.Generic; using System.Net; using System.Security.Cryptography; using System.Text; public static class XunfeiAuth { public static string GenerateAuthUrl(string host, string path, string apiKey, string apiSecret) { // 1. 准备date和signature_origin string date DateTime.Now.ToUniversalTime().ToString(r); // RFC1123格式 string signatureOrigin $host: {host}\ndate: {date}\nGET {path} HTTP/1.1; // 2. HMAC-SHA256签名密钥是apiSecret byte[] secretBytes Encoding.UTF8.GetBytes(apiSecret); byte[] originBytes Encoding.UTF8.GetBytes(signatureOrigin); string signature ; using (var hmac new HMACSHA256(secretBytes)) { byte[] hashBytes hmac.ComputeHash(originBytes); signature Convert.ToBase64String(hashBytes); } // 3. 拼接authorization原始字符串并Base64 string authorizationOrigin $api_key\{apiKey}\, algorithm\hmac-sha256\, headers\host date request-line\, signature\{signature}\; string authorization Convert.ToBase64String(Encoding.UTF8.GetBytes(authorizationOrigin)); // 4. 拼最终URL注意需要UrlEncode string url $wss://{host}{path}?authorization{Uri.EscapeDataString(authorization)} $date{Uri.EscapeDataString(date)}host{WebUtility.UrlEncode(host)}; return url; } }提示date参数必须使用RFC1123格式也就是ddd, dd MMM yyyy HH:mm:ss GMT并且要用UTC时间。如果用了本地时间或者格式不对通讯鉴权必挂这是实际踩过的坑。另外signature_origin中的换行符必须是\n不能是\r\n否则签名怎么算都不对。2.2 鉴权常见错误10163、10164分别意味着什么接讯飞接口的时候返回码是最直接的排障工具。我整理了几个高频出现的错误码错误码含义常见原因10160参数无效请求JSON格式不对或audio字段缺失10163签名错误签名拼接顺序、date格式、换行符有问题10164AppID不存在传入的AppID有误或服务未开通10165配额不足免费额度耗尽需要充值或申请扩容10166并发超限同时建立的连接数超过账号限制如果你遇到了10163优先检查那三行字符串的换行符到底是不是\n以及date是不是新的。如果确认无误再看一下APIKey和APISecret是否复制完整不要漏掉末尾的字符。3. WebAPI后端实现从搭建项目到完整接口前面鉴权问题解决后后端实现就顺理成章了。这里直接以VS2022创建的ASP.NET Core WebAPI项目为例从新建项目到完整封装一步步来。3.1 VS2022创建WebAPI项目与基础配置如果你还没建项目直接打开VS2022选择“创建新项目”模板选“ASP.NET Core Web API”目标框架选.NET 6或.NET 8都行我这边实际用的是.NET 8下面代码也是基于.NET 8的写法。创建完成后项目会自带一个WeatherForecastController示例删掉或保留都无所谓不影响我们新增控制器。然后需要安装一个NuGet包System.Net.WebSockets.Client。这个是用来发起WebSocket客户端连接的.NET 6以后其实已经集成在框架里了但如果你的项目引用有问题可以手动安装这份包版本选最新的稳定版就行。安装完毕后在项目里新建一个文件夹Services专门用来放讯飞语音相关的服务类。这样控制器层只负责接收HTTP请求具体的讯飞交互逻辑全部下沉到服务层保持代码清晰。3.2 核心服务类封装讯飞语音听写调用我写了一个XunfeiIatService类负责跟讯飞建立WebSocket连接、发送音频数据、接收并解析结果。这里面有几个核心方法ConnectAsync负责建立连接并鉴权SendAudioAsync负责分帧发送音频ReceiveAsync负责循环接收服务端返回的JSON消息。整个类的骨架大概是这样的using System.Net.WebSockets; using System.Text; using System.Text.Json; public class XunfeiIatService { private readonly string _appId; private readonly string _apiKey; private readonly string _apiSecret; private readonly string _host iat-api.xfyun.cn; private readonly string _path /v2/iat; public XunfeiIatService(string appId, string apiKey, string apiSecret) { _appId appId; _apiKey apiKey; _apiSecret apiSecret; } public async Taskstring RecognizeAsync(byte[] audioData, CancellationToken ct) { // 1. 生成鉴权URL string url XunfeiAuth.GenerateAuthUrl(_host, _path, _apiKey, _apiSecret); using (var ws new ClientWebSocket()) { await ws.ConnectAsync(new Uri(url), ct); // 2. 发送第一帧包含common和business参数 string firstFrame BuildFirstFrame(); byte[] firstFrameBytes Encoding.UTF8.GetBytes(firstFrame); await ws.SendAsync(firstFrameBytes, WebSocketMessageType.Binary, true, ct); // 3. 分帧发送音频数据 await SendAudioFramesAsync(ws, audioData, ct); // 4. 接收并解析识别结果 string result await ReceiveResultAsync(ws, ct); return result; } } }3.3 音频分帧发送的细节为什么是1280字节讯飞的听写接口要求音频数据按帧发送每一帧是一个二进制消息包含一段音频数据。官方建议每帧的大小是1280字节对应采样率为16kHz、16bit、单声道的PCM音频约40毫秒。前40毫秒可以先发送第一帧音频然后再循环发送剩下的帧直到全部发完最后发一个status2的空帧标识结束。这里为什么要分帧因为讯飞支持边录音边识别实时性就是靠这种流式传输实现的。虽然我们这里是上传一个完整的录音文件但分帧发送依然是最稳妥的做法讯飞也建议每发1280字节就稍微等一下避免瞬间把所有数据打过去导致服务端处理不过来。实际测试下来每帧发送后加一个极短的延时比如Task.Delay(40)识别稳定性明显更好。发送帧的时候第一帧的帧头需要设置status0中间帧设置status1最后一帧设置status2。这个状态标志在请求JSON里体现但要注意的是讯飞听写接口的帧结构比较特殊音频数据本身是通过data.audio字段传递的但这个字段需要的是Base64编码后的音频内容而不是裸字节。我在这里封装了一个方法把每个1280字节的片段转为Base64然后包成JSON发送。核心代码大概长这样private async Task SendAudioFramesAsync(ClientWebSocket ws, byte[] audioData, CancellationToken ct) { int frameSize 1280; int offset 0; int totalLength audioData.Length; int index 0; while (offset totalLength) { int count Math.Min(frameSize, totalLength - offset); byte[] frame new byte[count]; Array.Copy(audioData, offset, frame, 0, count); int status 0; // 第一帧 if (offset count totalLength) { status 2; // 最后一帧 } else if (offset 0) { status 1; // 中间帧 } string frameJson BuildDataFrame(frame, status); byte[] frameBytes Encoding.UTF8.GetBytes(frameJson); await ws.SendAsync(frameBytes, WebSocketMessageType.Binary, true, ct); await Task.Delay(40, ct); offset count; index; } // 发一个status2的空帧确保服务端知道音频结束 string endJson {\data\:{\status\:2,\format\:\audio/L16;rate16000\,\encoding\:\raw\,\audio\:\\}}; byte[] endBytes Encoding.UTF8.GetBytes(endJson); await ws.SendAsync(endBytes, WebSocketMessageType.Binary, true, ct); }注意format字段里的audio/L16;rate16000表示的是16kHz采样率、16位线性PCM编码。如果你的音频不是这个格式讯飞会直接报参数错误。很多真实业务场景里前端录出来的是mp3或webm格式这时候就不能直接丢给讯飞了需要先在服务端做转码。转码方案我后面会讲。3.4 接收识别结果解析中间结果与最终文本讯飞服务端会持续返回JSON消息格式类似于{ code: 0, data: { status: 1, result: { sn: 10, ls: true, ws: [ { bg: 0, cw: [ { w: 你好, sc: 88 } ] } ] } } }其中data.status表示当前结果的状态1表示中间结果还会继续追加2表示最终结果。data.result.ws数组则是识别出的词序列每个元素里有一个cw数组所有cw里的w字段拼接起来就是当前这一轮返回的文本。要注意的是中间结果可能是重叠的比如第一轮返回你好世界第二轮可能又返回你好世界。所以不能简单地把每一轮的结果拼起来而是要么只取最后一轮status2的完整结果要么自己维护一个去重逻辑根据sn和ls字段判断。我的做法是只要拿到status2的最终结果就取这一轮的所有w拼接成完整文本直接返回放弃所有中间结果。因为中间结果只是用于实时展示的对于最终归档场景完整结果才是最准的。接收和解析的代码private async Taskstring ReceiveResultAsync(ClientWebSocket ws, CancellationToken ct) { StringBuilder finalText new StringBuilder(); byte[] buffer new byte[8192]; while (true) { WebSocketReceiveResult receiveResult await ws.ReceiveAsync(new ArraySegmentbyte(buffer), ct); if (receiveResult.MessageType WebSocketMessageType.Close) { break; } string json Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); using JsonDocument doc JsonDocument.Parse(json); JsonElement root doc.RootElement; int code root.GetProperty(code).GetInt32(); if (code ! 0) { throw new Exception($讯飞返回错误: code{code}, message{root.GetProperty(message).GetString()}); } JsonElement data root.GetProperty(data); int status data.GetProperty(status).GetInt32(); if (data.TryGetProperty(result, out JsonElement result)) { if (result.TryGetProperty(ws, out JsonElement wsArray)) { StringBuilder sentence new StringBuilder(); foreach (JsonElement wsItem in wsArray.EnumerateArray()) { if (wsItem.TryGetProperty(cw, out JsonElement cwArray)) { foreach (JsonElement cwItem in cwArray.EnumerateArray()) { string w cwItem.GetProperty(w).GetString(); sentence.Append(w); } } } finalText.Append(sentence); } } if (status 2) { break; } } return finalText.ToString(); }这个方法里有一点需要注意如果中间结果和最终结果都回传了最终拿到的是各轮拼接后的文本。遇到会话中断或识别失败的情况会抛出异常由上层接口统一处理。3.5 控制器层实现接收上传文件并调用服务控制器层的职责很纯粹接收IFormFile文件转成字节数组调用XunfeiIatService.RecognizeAsync最后返回识别文本。这里有一个小细节上传录音文件的时候前端通常是用FormData格式POST所以控制器参数直接用一个IFormFile file就能接收。[ApiController] [Route(api/[controller])] public class SpeechController : ControllerBase { private readonly XunfeiIatService _iatService; public SpeechController(IConfiguration configuration) { string appId configuration[Xunfei:AppId]; string apiKey configuration[Xunfei:ApiKey]; string apiSecret configuration[Xunfei:ApiSecret]; _iatService new XunfeiIatService(appId, apiKey, apiSecret); } [HttpPost(recognize)] public async TaskIActionResult Recognize(IFormFile file) { if (file null || file.Length 0) { return BadRequest(new { message 请上传音频文件 }); } using var ms new MemoryStream(); await file.CopyToAsync(ms); byte[] audioData ms.ToArray(); try { string result await _iatService.RecognizeAsync(audioData, HttpContext.RequestAborted); return Ok(new { text result }); } catch (Exception ex) { return StatusCode(500, new { message ex.Message }); } } }在appsettings.json里配置好讯飞的三个凭证{ Xunfei: { AppId: 你的appid, ApiKey: 你的apikey, ApiSecret: 你的apisecret } }这样后端接口就通了。调用方式很简单前端或Postman向/api/speech/recognizePOST一个名为file的文件就能拿到JSON格式的识别文本。4. 音频格式处理这是最容易翻车的一环很多人在讯飞听写接口上栽的跟头其实不是C#代码的问题而是音频格式不对。讯飞听写有两种主流音频格式支持一种是裸PCMraw一种是WAV包含文件头。实际测试下来对PCM的兼容性最好。但现实中前端录出来的音频几乎都是webm、mp3或m4a格式这些格式讯飞听写接口不认识。4.1 方案一前端录音时直接输出PCM如果你的前端录音是自己用MediaRecorder实现的其实可以设置输出格式。MediaRecorder默认输出通常是webm或mp4但你可以通过请求AudioContext的ScriptProcessorNode或AudioWorklet拿到原始的PCM数据然后封装成WAV文件上传。这种方式的好处是后端不需要做任何转码直接就能把字节流丢给讯飞。不过这种方式实现起来稍微复杂一点前端代码量会变大。如果项目不追求极致实时性我更推荐方案二。4.2 方案二后端集成转码工具上传任意格式都转成PCM在后端做转码的好处是前端完全不用操心格式问题录音、上传、识别的链路最简单。C#这边做音频转码最省事的方案是调用FFmpeg。你可以通过Process启动FFmpeg把上传的mp3/webm文件转成16kHz单声道PCM。我在项目里就是这么做实测效果很稳定。public static byte[] ConvertToPcm(byte[] inputAudio, string inputExtension) { string tempInput Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() inputExtension); string tempOutput Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() .pcm); File.WriteAllBytes(tempInput, inputAudio); ProcessStartInfo psi new ProcessStartInfo { FileName ffmpeg, RedirectStandardOutput true, RedirectStandardError true, UseShellExecute false, CreateNoWindow true, ArgumentList { -y, -i, tempInput, -ar, 16000, -ac, 1, -f, s16le, tempOutput } }; using Process process Process.Start(psi)!; process.WaitForExit(); byte[] result File.ReadAllBytes(tempOutput); File.Delete(tempInput); File.Delete(tempOutput); return result; }这里-ar 16000表示采样率16kHz-ac 1表示单声道-f s16le表示16位有符号小端PCM正好对应讯飞要求的格式。FFmpeg的安装方式就不赘述了记得把它加到系统PATH里或者在后端配置文件里指定完整路径。5. 前端Vue对接与文件名保持后端接口Ready之后前端对接其实很轻松。这里顺便把热搜词里提到的“vue前端、如何保持文件名不变 blob”一并讲清楚因为语音上传和文件下载在这类项目里经常同时出现。5.1 Vue端录音并上传文件录音部分我用的是MediaRecorder核心代码async function startRecording() { const stream await navigator.mediaDevices.getUserMedia({ audio: true }); mediaRecorder new MediaRecorder(stream); chunks []; mediaRecorder.ondataavailable (e) chunks.push(e.data); mediaRecorder.start(); } function stopRecordingAndUpload() { mediaRecorder.stop(); mediaRecorder.onstop async () { const blob new Blob(chunks, { type: audio/webm }); const formData new FormData(); formData.append(file, blob, recording.webm); const response await fetch(/api/speech/recognize, { method: POST, body: formData }); const data await response.json(); console.log(data.text); }; }上传时formData.append的第三个参数就是文件名后端IFormFile.FileName能拿到这个值。如果你需要保留原始文件名用于存档建议在后端再把它保存下来。5.2 返回文件时如何保持文件名不变另一个常见的需求是WebAPI端开发一个下载文件的接口前端拿到blob后下载但下载出来的文件名总是乱的。这个问题的根源在于URL.createObjectURL生成的临时URL不包含文件名信息浏览器下载时只能靠download属性来指定文件名但如果响应头里带了Content-Disposition且附带了filename浏览器一般会优先使用响应头里的文件名。后端设置响应头的方式[HttpGet(download)] public IActionResult DownloadFile() { byte[] fileBytes System.IO.File.ReadAllBytes(D:\temp\report.pdf); string fileName 月度会议纪要.pdf; string encodedFileName Uri.EscapeDataString(fileName); Response.Headers.Add(Content-Disposition, $attachment; filename\{encodedFileName}\); return File(fileBytes, application/octet-stream); }前端下载时再配合download属性兜底const response await fetch(/api/file/download); const blob await response.blob(); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download 月度会议纪要.pdf; a.click(); URL.revokeObjectURL(url);这一套走下来文件名就不会再变成一串随机字符串了。6. 常见问题与排查技巧实录最后这部分把我在整个开发过程中遇到的典型问题集中梳理一下你可能也会碰上。6.1 问题速查表现象原因解决办法连接时直接返回10163date格式不对或签名换行符错误检查date是否为r格式且为UTC检查\n返回10160业务参数错误比如domain、language写错核对business参数domain必须为iat返回10165免费配额用完去控制台看剩余量或者换一个测试账号识别结果为空字符串音频太短或静音太多确保录音至少1秒以上且音频有实际人声音频上传后报参数错误音频格式不是PCM/WAV用FFmpeg统一转成16k单声道PCMWebSocket连接一直Pending鉴权URL生成失败或网络限制检查签名算法、用wss://而非ws://前端拿到的文件名乱码响应头Content-Disposition未编码用Uri.EscapeDataString处理文件名6.2 调试时的三个小技巧第一个技巧是正式连讯飞之前先用一个在线的WebSocket测试工具比如浏览器控制台或Postman的WebSocket功能把鉴权URL验证一遍。如果工具能拿到识别结果就说明签名没问题如果工具也报错那就是签名算法或参数本身的问题不用先怀疑C#代码。第二个技巧是在SendAudioAsync和ReceiveResultAsync里多打一些日志。音频帧发送的起始偏移、状态码、每一轮返回的sn和ls字段都打印出来。这种日志在你对着文档排查时特别有用一眼就能看出来是哪一帧出了问题。第三个技巧是开发环境建议开后端日志记录讯飞返回的完整JSON。有些错误信息字段在message里比如Invalid parameter: format如果你只取code不取message会很长时间都摸不着头脑。把原始JSON存下来问题定位效率会高很多。6.3 关于并发和性能的一点经验讯飞的免费并发额度通常比较低比如2路并发如果你的系统预测会有多人同时调用一定要在WebAPI层做排队或限流不然分分钟触发10166。我这边是在控制器入口加了一个SemaphoreSlim控制并发数超出部分直接返回一个友好提示“系统繁忙请稍后重试”比让用户直接看到错误码体验好得多。另外讯飞识别是耗时的默认接口超时时间建议设置到30秒以上尤其是长录音文件识别耗时可能会超过10秒前端要有对应的loading状态。从整体架构来看用C# WebAPI包一层讯飞语音听写既能把第三方API的复杂度隔离在后端又能给前端提供一个干净统一的接口后续不管是要换语音引擎还是加后处理逻辑改动范围都被限制在一个Service类里性价比还是挺高的。如果你正在折腾类似的需求希望这篇文章能帮你少踩几个坑。我就说到这。本文还有配套的精品资源点击获取