简介:本资源是一套基于C# WebAPI实现科大讯飞语音听写功能的完整开发示例,面向.NET后端开发者及语音识别初学者,解决中文语音转文字在Web服务场景下的集成难题,适用于智能客服、在线教育、语音助手等实际应用。压缩包共47个文件,含5个核心C#源码文件(含控制器与配置逻辑)、10个DLL依赖库(含Newtonsoft.Json等关键组件)、2个测试WAV语音样本、10个XML文档(多为SDK或NuGet元数据)、3个Config配置文件及1个SLN解决方案,整体5.08MB,结构清晰,便于快速编译运行与调试。已有1748人学习下载,提供可直接运行的Demo工程、gb2312编码问题的实操解法、HttpClient调用讯飞API的完整参数封装逻辑,以及含错误处理与响应解析的生产级代码片段,助开发者避开鉴权、编码、Multipart上传等典型坑点。
1. 一个 POST 请求就能把语音转成文字:C# WebAPI 接入科大讯飞语音听写,不是调 SDK,是真·手撕鉴权+流式上传+结果解析
你有没有试过在 Windows 服务里跑个语音识别?不是用现成的桌面客户端,也不是调封装好的 NuGet 包,而是从零开始——自己拼签名、自己建 WebSocket 连接、自己处理二进制音频流、自己解析带标点和语义分段的 JSON 响应?上周我帮某高校实验室对接一个课堂实时语音转写系统,他们要求:不能装任何第三方桌面组件,所有逻辑必须跑在 IIS 托管的 WebAPI 里,音频来自前端 H5 录音(Web Audio API + Blob),后端要返回带时间戳的逐句文本,还要支持断网重连和 5 分钟长语音。最后上线跑通的,就是这个纯 C# WebAPI 实现的科大讯飞语音听写服务。它不依赖XunFeiSDK.dll,不走 .NET Core 的IHttpClientFactory黑盒封装,而是用HttpClient+StreamContent+HMAC-SHA256手搓鉴权头,用System.Text.Json直解ws://返回的data:audio和data:result消息体。适合需要深度定制识别策略、嵌入自有权限体系、或必须满足信创环境白名单要求的开发者。如果你正卡在“怎么让 WebAPI 主动连讯飞 WebSocket”或者“为什么 signature 总是 401”,这篇就是为你写的。
2. 架构拆解:从 HTTP 鉴权到 WebSocket 流式通信的完整链路
2.1 整体通信流程:三步走,缺一不可
科大讯飞语音听写 WebAPI(v2)不是传统 RESTful 接口,而是一个典型的“先 HTTP 获取临时凭证,再 WebSocket 建立长连接,最后双向流式传输”的混合架构。整个流程严格分三阶段:
- HTTP 鉴权阶段:客户端(你的 WebAPI)向
https://api.xfyun.cn/v2/aiui发起POST,携带app_id、cur_time(秒级时间戳)、salt(随机字符串)、signa(HMAC-SHA256 签名)等字段,换取一个有效期 2 小时的Authorizationtoken 和host地址; - WebSocket 握手阶段:用上一步拿到的
token和host,构造标准 WebSocket URL(如wss://iat-api.xfyun.cn/v2/iat?authorization=xxx),发起连接; - 流式交互阶段:连接建立后,客户端分片发送
audio数据帧(Base64 编码的 PCM 音频),服务端异步返回result帧(含type: "partial"/"final"/"success"),最终聚合为完整识别文本。
提示:讯飞文档里把这三步叫“认证 → 连接 → 识别”,但实际开发中,90% 的失败都卡在第一步的 signa 生成或第二步的 URL 构造。别急着写 WebSocket,先把
curl -X POST能打通再说。
2.2 项目目录结构:轻量但关键模块一个不少
这是一个标准的 ASP.NET Core 6 WebAPI 项目(兼容 .NET 5+),无 UI,纯后台服务。目录结构极简,但每个文件都承担明确职责:
Controllers/ ├── IatController.cs // 主入口:接收前端 POST 的 audio blob,触发识别流程 Services/ ├── IatService.cs // 核心业务:封装鉴权、WebSocket 连接、音频分片、结果聚合 ├── IatAuthClient.cs // 鉴权专用:只干一件事——生成合法 signa 并获取 token ├── IatWebSocketClient.cs // WebSocket 封装:继承 WebSocket,重写 OnMessage 处理 result 帧 Models/ ├── IatRequest.cs // 前端传参模型:包含 audioBytes(byte[])、format("pcm")、rate(16000) ├── IatResponse.cs // 后端返回模型:包含 text(最终文本)、segments(带时间戳的句子列表) ├── IatResultFrame.cs // 内部模型:映射讯飞 WebSocket 返回的 result JSON 结构 Utils/ ├── CryptoHelper.cs // HMAC-SHA256 签名工具类(关键!) ├── AudioHelper.cs // PCM 音频预处理:自动补零、字节序校验、采样率转换(可选)注意:没有appsettings.json里硬编码密钥,所有敏感配置(app_id,api_secret,api_key)均通过IConfiguration注入,支持环境变量覆盖。这是上线前必须做的安全基线。
2.3 鉴权核心:CryptoHelper.cs 里的 HMAC-SHA256 签名生成逻辑
讯飞要求的signa是对api_key + cur_time + salt + api_secret四元组做 HMAC-SHA256,再 Base64 编码。这是最常翻车的点——很多人直接拼字符串,却忽略了api_secret必须参与计算,且cur_time必须是秒级时间戳(不是毫秒)。
// Utils/CryptoHelper.cs public static class CryptoHelper { /// <summary> /// 生成讯飞语音听写鉴权签名 signa /// 注意:curTime 是秒级时间戳(DateTimeOffset.UtcNow.ToUnixTimeSeconds()),不是 DateTime.Now.Ticks! /// </summary> public static string GenerateSigna(string apiKey, long curTime, string salt, string apiSecret) { var content = $"{apiKey}{curTime}{salt}{apiSecret}"; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiSecret)); var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(content)); return Convert.ToBase64String(hashBytes); } }逻辑说明:
content字符串顺序必须严格为apiKey + curTime + salt + apiSecret,顺序错一位就 401;curTime必须用DateTimeOffset.UtcNow.ToUnixTimeSeconds()获取,绝对不要用DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")或DateTime.Now.Ticks;salt是任意长度的随机字符串(推荐 16 位 GUID 去掉-),每次请求必须不同,用于防重放;apiSecret是你在讯飞开放平台创建应用时分配的密钥,绝不能泄露,必须存于secrets.json或 Azure Key Vault。
参数说明:
| 参数 | 类型 | 来源 | 示例 |
|---|---|---|---|
apiKey | string | 讯飞控制台应用信息页 | "5f1a2b3c" |
curTime | long | DateTimeOffset.UtcNow.ToUnixTimeSeconds() | 1715234567 |
salt | string | Guid.NewGuid().ToString("N") | "e8a3b4c5d6f7g8h9" |
apiSecret | string | 讯飞控制台应用密钥页(保密!) | "xYzAbC123DefGhi456JklMno789PqrStUvW" |
3. 核心模块拆解:IatService.cs 如何串联鉴权、WebSocket 与音频流
3.1 IatService:协调者角色,控制整个识别生命周期
IatService不直接处理网络或加密,而是调度IatAuthClient和IatWebSocketClient,并管理音频分片、结果聚合、超时控制。它的核心方法RecognizeAsync是一个async Task<IatResponse>,内部逻辑清晰分层:
// Services/IatService.cs public class IatService : IIatService { private readonly IatAuthClient _authClient; private readonly IatWebSocketClient _wsClient; public async Task<IatResponse> RecognizeAsync(IatRequest request) { // Step 1: 获取鉴权 token var authResult = await _authClient.GetTokenAsync(request.AppId, request.ApiKey, request.ApiSecret); if (!authResult.IsSuccess) throw new InvalidOperationException($"鉴权失败: {authResult.ErrorMessage}"); // Step 2: 构建 WebSocket URL 并连接 var wsUrl = $"wss://{authResult.Host}/v2/iat?authorization={authResult.Token}"; await _wsClient.ConnectAsync(wsUrl); // Step 3: 发送音频配置帧(JSON) var configFrame = BuildConfigFrame(request.Format, request.Rate); await _wsClient.SendAsync(configFrame); // Step 4: 分片发送音频数据(PCM raw bytes) await SendAudioChunksAsync(request.AudioBytes, request.ChunkSize ?? 1280); // 1280 = 16kHz * 16bit * 40ms // Step 5: 等待服务端返回 final result 或超时 return await _wsClient.WaitForFinalResultAsync(TimeSpan.FromMinutes(3)); } private string BuildConfigFrame(string format, int rate) => JsonSerializer.Serialize(new { common = new { app_id = request.AppId }, business = new { language = "zh_cn", domain = "iat", accent = "mandarin", vad_eos = 3000, // 语音结束静音时长 ms dwa = "wpgs" // 是否开启语义断句(wpgs=带标点,itn=数字转汉字) }, data = new { status = 0, format = format, audio = "", // 初始为空,后续发 chunk 时填 Base64 encoding = "raw" } }); }逻辑说明:
BuildConfigFrame生成的是 WebSocket 连接后的首帧配置消息,必须是 JSON 字符串,且status=0表示开始;SendAudioChunksAsync将原始 PCM 字节数组按ChunkSize(推荐 1280 字节,对应 40ms 音频)切片,每片 Base64 编码后封装为data帧发送,status依次为1(中间帧)、2(结束帧);WaitForFinalResultAsync是一个带超时的TaskCompletionSource<IatResponse>,监听IatWebSocketClient.OnMessage事件,只在收到type: "success"且data.status == 0时完成任务。
3.2 IatWebSocketClient:手写 WebSocket 客户端,绕过 SignalR 黑盒
讯飞要求 WebSocket 必须使用标准System.Net.WebSockets.ClientWebSocket,不能用 SignalR 或任何高级封装(因其无法精确控制帧格式和status字段)。我们封装了一个轻量客户端,重点重写了OnMessage解析逻辑:
// Services/IatWebSocketClient.cs public class IatWebSocketClient : IDisposable { private ClientWebSocket _ws; private readonly ConcurrentQueue<string> _messageQueue = new(); private TaskCompletionSource<IatResponse> _tcs; public async Task ConnectAsync(string url) { _ws = new ClientWebSocket(); // 讯飞要求 User-Agent 必须为 "Mozilla/5.0",否则拒绝连接 _ws.Options.SetRequestHeader("User-Agent", "Mozilla/5.0"); await _ws.ConnectAsync(new Uri(url), CancellationToken.None); } public async Task SendAsync(string jsonFrame) { var buffer = Encoding.UTF8.GetBytes(jsonFrame); await _ws.SendAsync( new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None); } // 关键:启动后台监听任务 public void StartListening() { _ = Task.Run(async () => { var buffer = new byte[4096]; while (_ws.State == WebSocketState.Open) { var result = await _ws.ReceiveAsync( new ArraySegment<byte>(buffer), CancellationToken.None); if (result.MessageType == WebSocketMessageType.Text) { var message = Encoding.UTF8.GetString(buffer, 0, result.Count); _messageQueue.Enqueue(message); ProcessMessage(message); // 解析 result 帧 } } }); } private void ProcessMessage(string json) { try { var frame = JsonSerializer.Deserialize<IatResultFrame>(json); if (frame?.data?.result?.type == "success") { // 聚合 segments,构建最终 IatResponse var response = new IatResponse { Text = string.Join(",", frame.data.result.segs.Select(s => s.text)), Segments = frame.data.result.segs.ToList() }; _tcs?.TrySetResult(response); } } catch (JsonException) { /* 忽略非法 JSON */ } } }参数说明:
User-Agent头是硬性要求,不设或设错直接403 Forbidden;ReceiveAsync必须用ArraySegment<byte>避免内存拷贝,buffer大小建议4096,足够容纳单条 result 帧;ProcessMessage中的frame.data.result.segs是讯飞返回的语义分句数组,每个seg包含text、begin(毫秒)、end(毫秒)、type("word" or "punc")。
3.3 音频预处理:AudioHelper.cs 如何确保 PCM 格式合规
讯飞只接受16-bit little-endian PCM,采样率必须为8k或16k。前端 H5 录音默认是44.1k,必须降采样。AudioHelper提供两个关键方法:
// Utils/AudioHelper.cs public static class AudioHelper { /// <summary> /// 将任意采样率的 PCM 数据重采样为 16kHz(线性插值) /// 注意:输入必须是 16-bit little-endian,双声道需先转单声道 /// </summary> public static byte[] ResampleTo16KHz(byte[] pcmBytes, int originalRate, int channels = 1) { if (originalRate == 16000) return pcmBytes; // 已合规 // 步骤1:提取左声道(若双声道) var monoBytes = channels == 2 ? ExtractLeftChannel(pcmBytes) : pcmBytes; // 步骤2:线性插值重采样(简化版,生产环境建议用 CSResampler) var ratio = (double)originalRate / 16000; var targetLength = (int)(monoBytes.Length / ratio); var resampled = new byte[targetLength]; for (int i = 0; i < targetLength; i++) { var srcIndex = i * ratio; var left = (int)Math.Floor(srcIndex); var right = Math.Min(left + 1, monoBytes.Length - 1); var weight = srcIndex - left; // 双字节样本:取 left 和 right 的 16-bit 值插值 var leftSample = BitConverter.ToInt16(monoBytes, left * 2); var rightSample = BitConverter.ToInt16(monoBytes, right * 2); var interpolated = (short)(leftSample + (rightSample - leftSample) * weight); BitConverter.GetBytes(interpolated).CopyTo(resampled, i * 2); } return resampled; } private static byte[] ExtractLeftChannel(byte[] stereoBytes) { var monoBytes = new byte[stereoBytes.Length / 2]; for (int i = 0; i < stereoBytes.Length; i += 4) // 每样本4字节(2ch * 16bit) { Array.Copy(stereoBytes, i, monoBytes, i / 2, 2); } return monoBytes; } }逻辑说明:
ResampleTo16KHz是简化版重采样,够用但非专业级;高精度场景请集成CSResamplerNuGet 包;ExtractLeftChannel假设双声道 PCM 是LRLR交错存储,取偶数索引字节对;- 必须确保输出是 16-bit little-endian:
BitConverter.ToInt16默认就是 little-endian,无需额外转换。
4. 部署与调试:IIS 托管、HTTPS 强制、WebSocket 代理穿透
4.1 IIS 配置要点:启用 WebSocket 并绕过 ARR 代理劫持
在 IIS 上托管此 WebAPI,必须显式开启 WebSocket 支持,否则ClientWebSocket.ConnectAsync会抛HttpRequestException: Response status code does not indicate success: 502 (Bad Gateway):
启用 WebSocket 协议:
在服务器管理器 → “添加角色和功能” → “Web 服务器(IIS)” → “Web Socket 协议” → 勾选安装;禁用 Application Request Routing (ARR) 的 WebSocket 代理:
若你用 ARR 做反向代理,必须关闭其 WebSocket 功能,否则会截断 WebSocket 升级头。在 ARR 控制台 → 服务器代理设置 → 取消勾选 “Enable proxy”;Web.config 强制 HTTPS(讯飞要求 wss://):
<system.webServer> <rewrite> <rules> <rule name="HTTP to HTTPS redirect" stopProcessing="true"> <match url="(.*)" /> <conditions> <add input="{HTTPS}" pattern="off" ignoreCase="true" /> </conditions> <action type="Redirect" url="https://{HTTP_HOST}/{R:1}" redirectType="Permanent" /> </rule> </rules> </rewrite> </system.webServer>
注意:讯飞 WebSocket URL 必须是
wss://,HTTP 环境下ws://会被拒绝。本地开发可用ngrok或cloudflared做 HTTPS 隧道。
4.2 前端调用示例:H5 录音 + Fetch 上传到 WebAPI
前端不用 WebSocket,只需用fetch把录音 Blob 传给你的IatController:
// 前端 JS async function startRecognition() { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new MediaRecorder(stream); const chunks = []; mediaRecorder.ondataavailable = e => chunks.push(e.data); mediaRecorder.onstop = async () => { const blob = new Blob(chunks, { type: 'audio/webm' }); // 转为 PCM(需后端配合,或前端用 lamejs 编码) const arrayBuffer = await blob.arrayBuffer(); const formData = new FormData(); formData.append('audio', new Blob([arrayBuffer], { type: 'audio/pcm' })); formData.append('format', 'pcm'); formData.append('rate', '16000'); const res = await fetch('/api/iat/recognize', { method: 'POST', body: formData }); const result = await res.json(); console.log('识别结果:', result.text); }; mediaRecorder.start(); setTimeout(() => mediaRecorder.stop(), 10000); // 录10秒 }关键点:
audio/pcmMIME 类型是约定,后端IatController用[FromForm]接收;- 实际生产中,前端应使用
web-audio-api录制16k/16bitPCM,避免后端重采样损耗; fetch上传比XMLHttpRequest更简洁,且天然支持FormData。
4.3 常见问题排查:5 条血泪经验总结
现象 1:IatAuthClient.GetTokenAsync返回 401,signa无效
原因:cur_time用了毫秒时间戳(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()),而讯飞只要求秒级;或api_secret复制时多了空格。
解决:打印content字符串和signa,用在线 HMAC 工具(如 https://www.liavaag.org/English/SHA-Generator/HMAC/ )验证;确认cur_time是ToUnixTimeSeconds()。
现象 2:ClientWebSocket.ConnectAsync抛HttpRequestException: 403
原因:未设置User-Agent: "Mozilla/5.0",或wss://URL 中的host域名与鉴权返回的host不一致(如返回iat-api.xfyun.cn,你拼成了api.xfyun.cn)。
解决:用 Fiddler 抓包,检查请求头;严格使用鉴权响应中的host字段拼 URL。
现象 3:WebSocket 连接成功,但无result返回,OnMessage从不触发
原因:音频帧status字段错误——首帧status=0,中间帧status=1,末帧status=2;若全设为0,服务端认为是单帧,直接忽略后续。
解决:检查SendAudioChunksAsync中status的赋值逻辑,用Console.WriteLine打印每帧 JSON。
现象 4:识别结果乱码(中文变问号或方块)
原因:HttpClient发送 JSON 时未指定 UTF-8 编码,或Encoding.UTF8.GetBytes()后未在HttpContent.Headers.ContentType中声明charset=utf-8。
解决:在IatAuthClient的PostAsync中,显式设置:
var content = new StringContent(json, Encoding.UTF8, "application/json"); content.Headers.ContentType.CharSet = "utf-8";现象 5:长语音(>2分钟)识别中断,返回{"code":10105,"message":"Invalid parameter"}
原因:讯飞要求单次 WebSocket 连接最长 5 分钟,且音频总时长不能超过 300 秒;若前端录音 10 分钟,必须分段上传。
解决:在IatService.RecognizeAsync中加入时长校验,对 >240 秒的音频主动切分为多个IatRequest并串行识别。
5. 二次开发指南:从支持方言到自定义热词、离线 fallback
5.1 扩展方言识别:修改 business 配置中的accent字段
讯飞支持多种中文口音,只需在BuildConfigFrame方法中调整accent参数即可,无需改核心逻辑:
accent值 | 适用场景 | 示例 |
|---|---|---|
"mandarin" | 普通话(默认) | 北京、上海通用 |
"cantonese" | 粤语 | 广州、香港 |
"shanghainese" | 沪语 | 上海本地话 |
"sichuanese" | 四川话 | 成都、重庆 |
// 修改 IatService.BuildConfigFrame business = new { language = "zh_cn", domain = "iat", accent = request.Accent ?? "mandarin", // 从前端传入 vad_eos = 3000, dwa = "wpgs" }然后在IatRequest模型中增加public string Accent { get; set; },前端调用时传"cantonese"即可。注意:粤语识别需在讯飞控制台单独开通权限,否则返回code:10403。
5.2 注入自定义热词:提升专有名词识别率
讯飞支持在business中传入customization字段,加载你预先在控制台创建的热词表(ID 为字符串):
business = new { language = "zh_cn", domain = "iat", accent = "mandarin", customization = new { id = "your_hotword_id_12345" }, // 控制台热词 ID vad_eos = 3000, dwa = "wpgs" }提示:热词 ID 不是名称,是在讯飞开放平台「我的应用」→「语音听写」→「热词管理」中创建后生成的 UUID 字符串。一个应用可绑定多个热词表,ID 用英文逗号分隔。
5.3 实现离线 fallback:当讯飞服务不可用时,降级到本地 Vosk 模型
真正的高可用不是加机器,而是设计 fallback。我们用try/catch包裹主流程,捕获HttpRequestException或WebSocketException后,自动切换到本地 Vosk(.NET 绑定):
// Services/IatService.cs public async Task<IatResponse> RecognizeAsync(IatRequest request) { try { // 主路径:讯飞云服务 return await RecognizeViaXunfeiAsync(request); } catch (Exception ex) when (ex is HttpRequestException || ex is WebSocketException) { // 降级路径:本地 Vosk(需提前下载模型到 wwwroot/models/vosk-zh) _logger.LogWarning(ex, "讯飞服务不可用,启用 Vosk 离线识别"); return await RecognizeViaVoskAsync(request); } } private async Task<IatResponse> RecognizeViaVoskAsync(IatRequest request) { // 使用 Vosk.NET NuGet 包 using var model = new Model("wwwroot/models/vosk-zh"); using var recognizer = new Recognizer(model, 16000.0f); var result = new StringBuilder(); foreach (var chunk in SplitInto16KHzChunks(request.AudioBytes)) { if (recognizer.AcceptWaveform(chunk)) { var json = recognizer.Result(); var jObj = JsonDocument.Parse(json); result.Append(jObj.RootElement.GetProperty("text").GetString()); } } return new IatResponse { Text = result.ToString() }; }关键点:
- Vosk 模型需提前下载( https://alphacephei.com/vosk/models/ ),推荐
vosk-model-small-zh-cn-0.22(约 40MB); SplitInto16KHzChunks确保音频已重采样为16k,否则 Vosk 报错;- 此方案牺牲部分准确率(Vosk 普通话约 92%,讯飞约 97%),但换来 100% 可用性。
5.4 日志与监控:为每个识别请求打唯一 traceId,便于问题定位
在IatController入口注入ILogger,并为每个请求生成traceId,贯穿鉴权、WebSocket、结果返回全流程:
// Controllers/IatController.cs [HttpPost("recognize")] public async Task<ActionResult<IatResponse>> Recognize([FromForm] IatRequest request) { var traceId = Guid.NewGuid().ToString("N"); _logger.LogInformation("【{TraceId}】语音识别请求开始,格式:{Format},时长:{DurationMs}ms", traceId, request.Format, request.AudioBytes.Length / 32); // 估算时长 try { var response = await _iatService.RecognizeAsync(request); _logger.LogInformation("【{TraceId}】识别成功,文本长度:{Length}", traceId, response.Text.Length); return Ok(response); } catch (Exception ex) { _logger.LogError(ex, "【{TraceId}】识别失败", traceId); return StatusCode(500, new { error = "识别服务异常", traceId }); } }这样当用户反馈“第 3 句识别错了”,你只需查日志中traceId对应的完整上下文,无需让用户复现。
从那以后我每次接入任何云 AI 服务,都强制走一遍「本地 mock → 网络抓包验证 → 异常 fallback」三步。不是 paranoid,而是见过太多“线上一切正常,用户就是说不准”——最后发现是 DNS 缓存了旧 host,或是某次signa生成时DateTimeOffset时区没设对。希望帮到你。
本文还有配套的精品资源,点击获取