简介:面向C#开发者的科大讯飞语音听写WebAPI集成资源,适合需要快速实现语音转文字服务的中级及以上.NET工程师。资源围绕语音听写功能展开,从NuGet依赖安装、WebAPI控制器搭建,到MultipartFormDataContent构造、HTTP请求发送及JSON响应解析均有涉及,并针对gb2312中文乱码问题提供了System.Text.Encoding.CodePages的解决思路。压缩包共47个文件,约5.08MB,包含cs源码、csproj工程配置、配置文件、示例音频wav文件及依赖DLL,另有说明文档辅助理解工程结构。已有1747人学习下载,适合参考工程骨架、接口调用方式及编码问题排错方案,可直接对照代码在Visual Studio中还原项目并调试语音听写流程。
1. 在 C# WebAPI 里接入科大讯飞语音听写:先想清楚这一跳值不值得做
语音听写这类能力,听起来就是「把音频丢给讯飞、拿回文字」这么简单,但放到 C# WebAPI 项目里,实际上多了一个中间层:你既要负责接收前端上传的音频文件,又要处理讯飞接口的鉴权签名、音频格式校验、超时控制,最后还要把识别结果“翻译”成自己业务里的结构再返回。很多人第一次接讯飞,不是死在算法上,而是死在签名日期、音频编码这类不起眼的位置。
这篇文章适合两类人:一类是从零开始给现有 ASP.NET Core 项目加语音转写能力的后端开发,另一类是已经调通讯飞但想优化参数、排掉隐藏坑的工程师。全程不依赖官方 SDK,只用 HttpClient 按 HTTP 协议直连讯飞 WebAPI,这样你能完全掌控请求细节,也方便在日志里排查问题。先说明一点:标题里这个方案,指的是「录音文件 / 短音频听写」场景,不是实时麦克风流式识别;这两者的接口选型完全不同,后面会专门讲清楚边界在哪里。
2. 讯飞听写接口的鉴权与请求结构:先看懂签名算法再写第一行代码
2.1 WebAPI 与 WebSocket 两种接入方式怎么选
科大讯飞的语音听写能力,公开的接入方式里常见的是 WebSocket 流式接口和 WebAPI 短音频接口。标题既然写的是 C# WebAPI 实现,那业务形态一般是这样:前端录音完成后,把整段音频文件传到你的后端,后端再调讯飞识别。这种「先落文件、再转写」的模式,用 WebAPI 最合适,原因很实际:HTTP 请求无状态,进程重启不丢连接,日志里能完整记录每一段音频的请求和响应,出了问题好回溯。
而 WebSocket 流式接口适合对实时性要求极高的场景,比如语音对话、实时字幕,要求边说话边出字。如果只是把一段录音转成文字,走 WebSocket 反而平白多了一堆连接管理和分片发送的逻辑。我的判断标准很简单:音频完整率低于 80% 时用户能不能接受,能接受就走 WebAPI;不能接受、必须边说边出字,才去碰 WebSocket。多数 WebAPI 场景,一次 HTTP POST 把音频带过去就够了。
2.2 鉴权签名:HMAC-SHA256 与 Date 头的组合
讯飞听写 WebAPI 的鉴权,常见的是 HTTP Signature 方式,核心是用你的 ApiSecret 对「当前 UTC 时间 + 请求行」做 HMAC-SHA256 签名,然后放在 Authorization 头里。网上也能搜到 Basic Base64(appid:secret) 的老式写法,那是早期遗留,新接口基本都要求带签名的版本,建议直接按新版写,省得以后接口升级再来返工。
using System.Globalization; using System.Security.Cryptography; using System.Text; public static class IflySignature { public static string Build( string apiKey, string apiSecret, DateTime utcNow) { // 标准要求:date 必须是 UTC 时间,并用 RFC1123 格式输出 var date = utcNow.ToString("R", CultureInfo.InvariantCulture); // 请求行是固定格式,路径要和实际请求地址一致 var requestLine = "POST /v2/iat HTTP/1.1"; var message = $"date: {date}\n{requestLine}"; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiSecret)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); var signature = Convert.ToBase64String(hash); return $"hmac username=\"{apiKey}\", algorithm=\"hmac-sha256\", " + $"headers=\"date request-line\", signature=\"{signature}\""; } }这个方法里最容易被忽视的是DateTime.UtcNow,不是DateTime.Now。签名里的时间必须和服务器实际收到请求的时间一致,如果本机时区、VM 时间不同步,讯飞那边校验会直接失败。另外ToString("R")输出的是英文星期和 GMT 结尾的格式,CultureInfo.InvariantCulture一定要传,不然在中文系统上可能输出中文月份名,整个签名全部作废。
2.3 请求体三层结构与必调参数
讯飞听写的 HTTP 请求体是典型的 JSON 三层结构:common放应用信息,business放识别参数,data放音频数据。下面这段构造代码基本是模板级的,直接把参数说清楚:
var payload = new { common = new { app_id = options.AppId }, business = new { language = "zh_cn", // 语种:中文普通话 domain = "iat", // 领域:听写 accent = "mandarin", // 口音:普通话 vad_eos = 3000, // 静默多久算一句话结束,单位 ms d = durationSeconds // 音频实际时长(秒) }, data = new { status = 2, // 2 表示这是最后一个分片(一次性上传) format = "audio/L16;rate=16000", // 16k 采样、16bit PCM encoding = "raw", // raw 表示裸 PCM 数据 audio = Convert.ToBase64String(audioBytes) } };vad_eos是个影响体验很明显的参数。它决定语音里的静音段超过多少毫秒就被判断为句子的结束点,值越小断句越碎,值越大越容易出现一大段黏在一起。我一般先设 3000,也就是 3 秒静音断句,跑完测试音频再看效果微调。d这个参数代表音频时长,传小了可能被截断,传大了服务端会一直等,浪费响应时间;如果音频是标准 16k PCM,可以直接用audioBytes.Length / (16000 * 2)算秒数。
这里有个高频误区要提醒:请求里的data.status和响应里的data.status不是一个概念。请求里 status 表示「数据发送状态」,一次性上传就是 2;响应里的 status 表示「本次识别是否结束」,2 才是最终结果。混用这两个概念,是后面排查问题最常见的误区之一。
3. 在 ASP.NET Core 控制器里实现音频上传与讯飞调用:最小可跑代码
3.1 项目结构与配置读取
这一步开始动手。先建一个 ASP.NET Core WebAPI 项目,目录结构我一般这样分:Controllers放上传接口,Services放讯飞调用逻辑,Models放配置和响应模型。这样做的原因是把「HTTP 接入层」和「讯飞对接层」分开,将来如果要从讯飞换成其他语音服务,只需要替换 Service 层,控制器不用动。
public class IflyOptions { public string AppId { get; set; } public string ApiKey { get; set; } public string ApiSecret { get; set; } public string Host { get; set; } = "https://iat-api.xfyun.cn"; }在appsettings.json里把密钥配置好:
{ "Ifly": { "AppId": "your_app_id", "ApiKey": "your_api_key", "ApiSecret": "your_api_secret", "Host": "https://iat-api.xfyun.cn" } }敏感配置不要写死在代码里,这是老生常谈但确实有人图省事。发布 WebAPI 项目之后,appsettings.Production.json里覆盖掉这几个值,或者用环境变量Ifly__ApiSecret注入,密钥不进代码仓库,这是底线。
3.2 注册 HttpClient 并封装 Service
在Program.cs里注册 HttpClient 和配置。这里我推荐用IHttpClientFactory,而不是在 Service 里手动new HttpClient():工厂会自动管理连接池和超时,避免 socket 耗尽这类只有上线后才暴露的问题。
builder.Services.Configure<IflyOptions>( builder.Configuration.GetSection("Ifly")); builder.Services.AddHttpClient("ifly", client => { client.BaseAddress = new Uri("https://iat-api.xfyun.cn"); client.Timeout = TimeSpan.FromSeconds(30); });Service 里的核心方法负责三件事:生成签名、构造请求体、发送请求并解析结果。请求头用TryAddWithoutValidation是因为 Authorization 的值里包含逗号和引号,走常规校验有可能被HttpClient拦掉;用这个方法可以绕开严格的标头格式校验。
public async Task<string> IatAsync( byte[] audioBytes, int durationSeconds, CancellationToken ct) { var utcDate = DateTime.UtcNow; var authorization = IflySignature.Build( _options.ApiKey, _options.ApiSecret, utcDate); var request = new HttpRequestMessage(HttpMethod.Post, "/v2/iat"); request.Headers.TryAddWithoutValidation("Authorization", authorization); request.Headers.TryAddWithoutValidation("Date", utcDate.ToString("R", CultureInfo.InvariantCulture)); var payload = BuildPayload(audioBytes, durationSeconds); request.Content = JsonContent.Create(payload); using var response = await _httpClient.SendAsync(request, ct); var json = await response.Content.ReadAsStringAsync(ct); return ParseResult(json); }注意HttpRequestMessage是一次性的,不要复用同一个实例。CancellationToken一定要从控制器一路传进来,这样前端断开请求时,讯飞那边的等待也会被取消,不会白白占一个连接。JsonContent.Create(payload)用的是 System.Net.Http.Json 这个包,内部会按 camelCase 序列化,刚好和讯飞要求的字段命名对上。
3.3 控制器的上传接口怎么设计
控制器负责接收前端上传的音频文件,做基础校验,然后调 Service。这里有一个重要的设计决策:接口参数用IFormFile,即multipart/form-data上传,而不是 base64 字符串。原因很实际:录音文件动辄几百 KB 到几 MB,base64 会膨胀 33%,而且multipart/form-data是前端上传文件的天然格式,浏览器和移动端都支持得很好。
[ApiController] [Route("api/[controller]")] public class IatController : ControllerBase { private readonly IflyIatService _iatService; public IatController(IflyIatService iatService) { _iatService = iatService; } [HttpPost("upload")] [RequestSizeLimit(20 * 1024 * 1024)] public async Task<IActionResult> Upload( [FromForm] IFormFile file, CancellationToken ct) { if (file == null || file.Length == 0) { return BadRequest(new { code = 400, msg = "音频文件为空" }); } using var ms = new MemoryStream(); await file.CopyToAsync(ms, ct); var audioBytes = ms.ToArray(); var durationSeconds = audioBytes.Length / (16000 * 2); var text = await _iatService.IatAsync(audioBytes, durationSeconds, ct); return Ok(new { code = 0, text }); } }durationSeconds的算法有个前提:音频必须是 16k 采样率、16bit、单声道的裸 PCM。这个前提必须在 Service 层或控制器层强校验,不能指望前端自己转好格式;实际项目里,前端传 m4a 或微信语音 amr 的情况非常常见。我的做法是:如果音频是 WAV 或 PCM,可以用上面的公式直接算;如果是其他编码,先在 Service 里用 NAudio 或 ffmpeg 统一转成标准 PCM,再计算时长、再调讯飞。转码逻辑在避坑章节里展开写。
4. 返回结果解析与前端对接:从嵌套的 JSON 里拼出能用的文本
4.1 讯飞返回结果的字段结构
讯飞听写接口的响应不是直接给你一整段话,而是把所有词按句子组织成ws数组,每个句子里的候选词再放到cw数组。第一次对接的人打开返回 JSON 往往一脸懵,感觉像是被分成了碎片,其实结构并不复杂,核心字段就这几个:
| 字段 | 含义 | 取值说明 |
|---|---|---|
code | 整体结果码 | 0 代表成功,非 0 是业务错误 |
sid | 会话 ID | 排查问题时靠它去讯飞后台看日志 |
data.status | 识别状态 | 1 是中间结果,2 是最终结果 |
data.result.ws | 句子数组 | 每个元素包含bg开始时间、cw词数组 |
data.result.ws[].cw[].w | 词文本 | 默认取第一项即最优候选 |
下面对应一个真实的返回片段,你看完就明白了:
{ "code": 0, "data": { "status": 2, "result": { "sn": 1, "ws": [ { "bg": 0, "cw": [ { "w": "你好", "sc": 0 } ] } ] } }, "sid": "XFZ-XXXX-XXXX" }sn是句子序号,ls字段标志这句话是不是整段音频的最后一句。如果只听写了一句短音频,通常一次响应就带全了;长一点的内容,服务端会在同一份响应里给你多个ws元素,所以解析时要做「遍历所有 ws 并拼接」而不是只取第一个。
4.2 用 System.Text.Json 解析并拼接文本
解析时我习惯用JsonNode,因为返回结构嵌套深而且cw里的数组是动态的,用强类型 DTO 反而容易因为字段不匹配抛异常。JsonNode是 System.Text.Json 提供的基础节点 API,写起来像操作字典,灵活度正好够用。
using System.Text; using System.Text.Json; using System.Text.Json.Nodes; public static string ParseResult(string json) { var root = JsonNode.Parse(json); var code = root?["code"]?.GetValue<int>() ?? -1; if (code != 0) { // 非 0 时,讯飞会在 message 字段里给出错误说明 var msg = root?["message"]?.GetValue<string>() ?? "unknown"; throw new InvalidOperationException($"讯飞返回错误: code={code}, msg={msg}"); } var ws = root?["data"]?["result"]?["ws"]?.AsArray(); if (ws == null) { return string.Empty; } var sb = new StringBuilder(); foreach (var sentence in ws) { // cw 数组默认第一个是最优候选词 var word = sentence?["cw"]?[0]?["w"]?.GetValue<string>(); if (!string.IsNullOrEmpty(word)) { sb.Append(word); } } return sb.ToString(); }关于cw[0]为什么是最终结果:讯飞在默认参数下会返回一个最优候选放在数组第一位,后面的候选词用于多结果扩展场景。如果你没有显式开启多候选,直接取第一项就是正常的识别文本;如果你开了多候选,cw里会有多个词,此时要自己决定是取置信度最高的还是全部返回给前端。
4.3 前端对接的返回格式与超时设计
识别完成后,Controller 返回的统一结构要保持简单:状态码 + 文本 + 可选会话 ID。sid一定要返回给前端,这样用户反馈识别出错时,你能拿着 sid 去讯飞后台拉日志,属于排查问题的关键凭据。
return Ok(new { code = 0, text = text, sid = sid, duration = durationSeconds });前端拿到这个 JSON 后,需要处理的边界情况只有一个:text为空字符串。这时候不要直接显示“识别失败”,因为你得先搞清楚是音频本身是静音、全是语气词,还是格式转换出了问题。我的习惯是在响应里额外加一个isSilence布尔值,在 Service 层预先做一遍音频能量检测,如果整体能量过低,直接提示用户重新录音,而不是把空结果丢给讯飞。顺带说一句,HttpClient 的 30 秒超时在这里要守住,如果讯飞那边因为音频参数问题拖到超时,前端已经断开了,后端还在傻等,这种体验属于很容易避免的低级翻车。
5. 接入讯飞语音听写的 5 个高频踩坑:现象、原因与解决
5.1 签名校验失败:先检查服务器 UTC 时间
现象:调用讯飞接口返回鉴权失败、签名无效、401 之类的错误,但代码检查了好几遍没发现问题。
原因:签名里用的是DateTime.UtcNow,但服务器系统时间本身不准。这种情况在虚拟机、容器环境里特别常见,宿主机休眠、NTP 没配好,系统时间漂移几分钟,签名里的时间与实际时间对不上,讯飞那边做时间窗校验直接拒绝。这是整个接入过程里最「玄学」的问题,因为本地开发永远不会复现。
解决:先不查代码,先跑date -u看 UTC 时间是否准确。容器里默认不带 NTP,需要在镜像里装chrony或用宿主机的clock挂载;如果是云主机,检查系统自带的chronyd服务是否在运行。时间校准后签名一般马上恢复。另外一个可靠的防御手段是:在 Service 里把签名的生成和Date头共用一个DateTime.UtcNow变量,不要分别取两次时间,否则刚好跨秒时也会出现签名不一致。
5.2 音频格式报错:m4a 直接上会被拒
现象:前端传 m4a 或 amr 格式的录音,接口返回音频格式不支持的错误,或者识别结果全是乱码。
原因:讯飞听写 WebAPI 对音频编码有明确要求,最稳的是 16k 采样率、16bit、单声道的裸 PCM,WAV 也可以;而手机录音默认是 m4a(AAC 编码),微信语音是 amr,这些编码讯飞的 WebAPI 不一定认,强行提交识别质量也很差。
解决:后端统一做格式转换。常见做法是用 ffmpeg 处理,把任意输入转成标准 PCM:
ffmpeg -i input.m4a -ar 16000 -ac 1 -f s16le output.pcm参数拆开解释:-ar 16000重采样到 16k,-ac 1强制单声道,-f s16le输出 16bit 小端序裸流。如果你的生产环境不方便装 ffmpeg,可以在 Windows 上用 NAudio 读 WAV 头、在 Linux 上用移植版 ffmpeg 或调用讯飞官方转码服务,但最常见的可靠方案就是 ffmpeg。转完之后用ffprobe -f s16le -ar 16000 -ac 1 output.pcm校验格式,再交给讯飞。
5.3 长音频超时:听写接口只适合短音频
现象:上传一段 30 分钟的会议录音,接口返回时长超限或者 HTTP 401,有时干脆一直 hang 到超时。
原因:听写接口的设计目标是短音频近实时转写,一次性上传适合的是几十秒到几分钟的量级。长音频应该用录音文件识别,那是异步接口:提交文件后拿 taskId,轮询结果,十分钟的音频也不怕。
解决:先明确业务边界。录音时长不超过几分钟、又要快速出结果的,用本方案;超过 10 分钟的,直接换录音文件识别接口,并且把 Controller 设计成「先返回 taskId,再异步回调结果」的模式。这里千万不要硬把长音频拆成多段塞给听写接口,断句衔接和上下文一致性会让你调参调到怀疑人生。
5.4 并发触发资源限制:应用层要自己限流
现象:上线后并发一多,接口频繁报流量超限、并发超限,但单个请求单独测试都正常。
原因:讯飞按 AppId 做资源和 QPS 限制,免费或者低配额的应用并发能力很有限。你的 WebAPI 可以并发处理 100 个请求,但讯飞只给这个 AppId 放行几个并发,多出来的就被打回。
解决:应用层加信号量,把并发压到讯飞允许的阈值以下:
private static readonly SemaphoreSlim Gate = new(4); public async Task<string> IatAsync(byte[] audioBytes, int duration, CancellationToken ct) { await Gate.WaitAsync(ct); try { // 调用讯飞的逻辑 } finally { Gate.Release(); } }SemaphoreSlim(4)表示最多 4 个并发调用,剩余请求排队等待。这个值按你账号的实际配额设置,保守一点总比被打回好。真实业务里我还会在前面加一层基于内存计数器的简单限流,队列过长直接返回 429,让前端明确知道是后端忙,而不是傻等。
5.5 断句碎、文本漏字:vad_eos 与语种参数背锅
现象:识别结果一句话被切成好几段,标点全丢了,或者某些词频繁听错、漏字。
原因:vad_eos设得太小,比如 800,静音稍微一长就被当成句子结束;口音参数选错,普通话录音用成了方言模型,也会有一批词识别不准。
解决:先把vad_eos调到 3000~5000 试,观察断句是否恢复你预想中的句子边界。注意,讯飞返回的文本默认是没有任何标点的,所有标点都需要你在后处理阶段自己按语义补上,这不是接口缺陷,是设计如此。如果有大量专业词汇,可以在请求里带上热词表参数,把业务相关的词(产品名、地名、人名)塞进去,识别率提升非常明显;不加热词表就追求 100% 正确率,那纯属为难接口。
6. 进阶验证:用一段 18 秒的测试音频给整条链路定标准
接入完成之后,别急着联调前端,先建立一套自己可控的验收流程。我的习惯是准备一段 18 秒左右、内容固定的中文朗读音频,存成标准 16k 单声道 PCM,放到仓库的testdata目录下,同时维护一份人工转写的正确文本。每次改完代码,先跑一遍这条端到端用例,看输出和人工转写是否逐字一致。
有了标准音频,还要把「验证」这件事自动化。用 xUnit 写一个最小的集成测试,调 WebApplicationFactory 起一个测试环境的 WebAPI,直接上传这段 PCM,断言返回文本等于人工转写文本。这一步能把签名、请求体、解析逻辑全链路覆盖,以后重构任何一段代码都不怕悄悄弄坏识别流程。没有自动化测试兜底,你会发现在排查其他 bug 时,反而经常被「识别结果时好时坏」干扰判断。
发布 WebAPI 项目之后,用 curl 做一次线上环境的冒烟验证,命令如下:
curl -X POST http://localhost:5000/api/iat/upload \ -F "file=@testdata/standard_18s.pcm" \ -H "Content-Type: multipart/form-data"建议在真实服务器上跑,不要只在本地跑。因为容器或云主机的时间同步、网络到讯飞机房的延迟、代理环境的 HTTP 头改写,都可能导致本地正常、线上失败。还有一个我踩过的教训:调试时把请求和响应日志完整打出来,包括签名、Date 头、响应 code 和 sid,排错能省一半时间。这套测试音频加日志的闭环做好之后,讯飞语音听写这个功能对你来说就不再是黑匣子了;后续调参、换接口、加并发,都有了可以依赖的基准线。希望这些经验帮到你。
本文还有配套的精品资源,点击获取