简介:本资源面向具备一定C#基础的开发者,聚焦在.NET环境下通过WebAPI调用科大讯飞语音听写服务这一典型场景,帮助解决语音转文字接口对接、中文编码处理等实际问题,可应用于智能客服、在线教育、语音助手等方向。压缩包共47个文件,约5.08MB,包含5个cs源码文件、10个dll依赖库、10个xml配置、3个config配置文件以及sln解决方案、csproj工程文件、wav测试音频和nupkg包等,结构完整,可直接参考或二次开发。目前已有1747人学习下载。资源围绕HttpClient发送MultipartFormDataContent请求、API Key与Secret参数构造、JSON响应解析等关键环节展开,并针对gb2312编码报错给出System.Text.Encoding.CodePages包的安装与解码思路,附带Demo示例与测试用例,便于读者快速跑通语音听写流程并排查常见编码与调用问题。
1. 语音听写接入 WebAPI:为什么你的第一版总是卡在“能跑但不好用”
很多团队第一次做语音听写,路径都差不多:前端录一段音频,后端 C# WebAPI 收下来,转手丢给语音听写服务,拿到文字返回。Demo 阶段一切顺利,上线后问题全冒出来——长语音超时、并发一上来就报错、识别结果断句混乱、前端拿不到中间结果只能干等。问题不在“能不能调通”,而在“怎么把一次性的调用封装成可复用的服务”。
这篇笔记讲的就是用 C# WebAPI 实现语音听写功能的完整落地路径:从接口鉴权、音频格式选择、分片上传,到结果拼接、并发控制和常见翻车点。适合已经能写 WebAPI、但还没把语音听写做成稳定服务的后端开发者,也适合需要评估这条链路值不值得自建的架构同学。核心结论先放这:语音听写接入的难点从来不是调 API,而是音频生命周期管理和错误重试策略。
2. 接口鉴权与音频格式:先把“送什么进去”定死
2.1 语音听写服务的鉴权模型长什么样
常见的语音听写服务采用「AppId + ApiKey + ApiSecret」三元组鉴权,但真正参与请求签名的是 ApiKey 和 ApiSecret,AppId 只用于标识应用。签名逻辑一般是:把时间戳、随机串等参数按固定顺序拼成待签名字符串,再用 ApiSecret 做 HMAC-SHA256,最后 Base64 编码。
这里有个容易忽略的点:签名用的时间戳必须是服务端时间,且与服务端偏差不能太大,常见容忍窗口是几分钟。如果你的 WebAPI 服务器时间没同步,会出现“本地测试通过、部署后全部 401”的玄学问题。我一般会在启动时做一次时间校验,偏差超过阈值直接打日志告警。
// 生成鉴权签名,参数顺序不能改 public static string BuildAuthUrl(string baseUrl, string apiKey, string apiSecret) { var host = new Uri(baseUrl).Host; var date = DateTime.UtcNow.ToString("r"); // RFC1123 格式 var requestLine = $"GET {new Uri(baseUrl).AbsolutePath} HTTP/1.1"; // 待签名字符串:host + date + requestLine var signatureOrigin = $"host: {host}\ndate: {date}\n{requestLine}"; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiSecret)); var signature = Convert.ToBase64String( hmac.ComputeHash(Encoding.UTF8.GetBytes(signatureOrigin))); // 拼回请求头,authorization 里带 api_key 和 signature var authorization = $"api_key=\"{apiKey}\", algorithm=\"hmac-sha256\", " + $"headers=\"host date request-line\", signature=\"{signature}\""; return $"{baseUrl}?authorization={Uri.EscapeDataString(authorization)}" + $"&date={Uri.EscapeDataString(date)}&host={Uri.EscapeDataString(host)}"; }这段代码的关键在于signatureOrigin的拼接顺序,host、date、requestLine 之间用\n分隔,少一个换行或顺序错了都会签名失败。date用 RFC1123 格式,不是随便的 ISO 字符串。authorization里的headers字段必须和实际参与签名的头一致,否则服务端校验不过。
参数上,apiKey和apiSecret建议放在配置中心或环境变量,不要硬编码进代码。时间戳偏差问题可以通过 NTP 同步解决,但更稳妥的做法是在签名前先请求一次服务端时间接口(如果有),用返回的时间做签名基准。
2.2 音频格式选错,后面全是白干
语音听写对音频格式有明确要求,常见支持的是 PCM、WAV、MP3 等,但不同服务对采样率、位深、声道数的要求不一样。最常见的坑是前端用浏览器 MediaRecorder 录出来的是 webm/opus 格式,直接丢给只认 PCM 的接口,结果就是“请求成功但识别为空”。
我一般会统一成 16kHz、16bit、单声道的 PCM。理由有三:一是绝大多数语音听写服务都支持这个格式;二是采样率固定后,分片大小和时长换算简单;三是单声道省带宽,识别准确率也不会因为立体声而提升。
如果前端只能录 webm,有两个选择:前端用 AudioContext 重采样成 PCM 再上传,或者后端用 FFmpeg 转码。前端转码省服务器资源,但兼容性要测;后端转码稳,但引入 FFmpeg 依赖。我倾向后者,因为 WebAPI 侧统一处理格式,前端可以更薄。
# 后端转码:webm 转 16k 16bit 单声道 pcm ffmpeg -i input.webm -ar 16000 -ac 1 -f s16le -acodec pcm_s16le output.pcm-ar 16000指定采样率,-ac 1指定单声道,-f s16le指定输出格式为小端 16 位 PCM。注意-acodec pcm_s16le和-f s16le要配套,只写一个可能输出格式不对。转码后的 PCM 没有文件头,纯裸数据,正好适合流式发送。
提示:转码是有损的,webm/opus 本身已经压缩过,再转 PCM 不会恢复音质,但语音识别对音质要求没那么高,16kHz 足够覆盖人声频段。
2.3 分片策略:一次发多少才不会被掐断
语音听写服务通常对单次请求的音频时长有限制,常见是 60 秒。超过限制要么直接报错,要么只识别前一段。所以长语音必须分片。
分片有两种做法:按固定时长切,或按静音检测切。按固定时长简单,但可能在词中间切断,导致识别结果拼接后出现断字。按静音切更自然,但实现复杂,需要做 VAD(语音活动检测)。
我一般用固定时长 + 重叠窗口的折中方案:每片 40 秒,相邻片重叠 2 秒。重叠部分在拼接时做去重,能缓解切断问题。40 秒这个值不是拍脑袋,是留出网络传输和识别处理的时间余量,避免刚好卡在 60 秒限制上。
// 按 40 秒切片,16kHz 16bit 单声道,每秒 32000 字节 const int sampleRate = 16000; const int bytesPerSample = 2; const int channels = 1; const int bytesPerSecond = sampleRate * bytesPerSample * channels; // 32000 const int sliceSeconds = 40; const int overlapSeconds = 2; const int sliceBytes = sliceSeconds * bytesPerSecond; const int overlapBytes = overlapSeconds * bytesPerSecond; public static List<byte[]> SlicePcm(byte[] pcm) { var slices = new List<byte[]>(); int offset = 0; while (offset < pcm.Length) { int length = Math.Min(sliceBytes, pcm.Length - offset); var slice = new byte[length]; Buffer.BlockCopy(pcm, offset, slice, 0, length); slices.Add(slice); // 下一片起点回退 overlapBytes,形成重叠 offset += sliceBytes - overlapBytes; if (offset + overlapBytes >= pcm.Length) break; } return slices; }这段代码里bytesPerSecond是换算基准,采样率、位深、声道数任何一个变了都要重算。offset += sliceBytes - overlapBytes是重叠的关键,如果写成offset += sliceBytes就没有重叠,切断问题会变严重。循环退出条件offset + overlapBytes >= pcm.Length是为了避免最后产生一个全是重叠的碎片片。
参数上,sliceSeconds不要贴着服务限制设,留 20% 余量。overlapSeconds一般 1 到 3 秒,太短去重效果差,太长浪费带宽。如果音频本身很短,比如 10 秒,这个逻辑只会产生一片,不会有多余开销。
3. 从 WebAPI 到听写服务:把一次调用拆成可重试的流水线
3.1 用 WebSocket 还是 HTTP 流式
语音听写服务通常提供两种接入方式:HTTP 一次性上传和 WebSocket 流式。HTTP 适合短音频,实现简单,但长音频要等全部传完才开始识别,延迟高。WebSocket 适合实时场景,可以边传边拿中间结果,但连接管理和错误处理复杂得多。
如果你的场景是“用户录完一段话,等几秒出文字”,HTTP 分片就够了。如果是“用户边说边出字”,必须上 WebSocket。我一般先做 HTTP 版本,把鉴权、分片、拼接跑通,再按需升级到 WebSocket。不要一上来就搞流式,调试成本会吃掉大部分时间。
WebSocket 版本的核心是维护一个连接状态机:连接建立后先发鉴权帧,再发音频帧,最后发结束帧。中间任何一步失败都要能重连并从断点续传。这里有个血泪经验:不要用ClientWebSocket的默认超时,语音识别服务可能在 10 秒内没返回任何帧,默认超时会把连接掐掉。要显式设置KeepAliveInterval和接收超时。
// WebSocket 连接配置,超时和缓冲要显式设 var ws = new ClientWebSocket(); ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(20); ws.Options.SetRequestHeader("Authorization", authHeader); using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); await ws.ConnectAsync(new Uri(wsUrl), cts.Token); // 发送音频帧,每帧 40ms 的 PCM 数据 int frameBytes = bytesPerSecond * 40 / 1000; // 1280 字节 for (int i = 0; i < pcm.Length; i += frameBytes) { int len = Math.Min(frameBytes, pcm.Length - i); var frame = new ArraySegment<byte>(pcm, i, len); await ws.SendAsync(frame, WebSocketMessageType.Binary, true, cts.Token); await Task.Delay(40, cts.Token); // 模拟实时发送 } // 发送结束帧,通知服务端音频结束 await ws.SendAsync(new ArraySegment<byte>(Array.Empty<byte>()), WebSocketMessageType.Binary, true, cts.Token);KeepAliveInterval设 20 秒是为了在空闲时维持连接,但语音识别场景下一直在发数据,这个值影响不大。关键是CancellationTokenSource的超时,5 分钟是给长语音留的余量,太短会在识别中途被取消。frameBytes按 40ms 一帧算,这是语音编码的常见帧长,服务端一般按帧处理。Task.Delay(40)是模拟实时发送,如果音频是录好的,可以去掉延迟全速发,但有些服务对发送速率有限制,太快会被限流。
3.2 结果拼接:中间结果和最终结果怎么合
语音听写返回的结果通常有两种:中间结果(is_final=false)和最终结果(is_final=true)。中间结果会不断修正前面的内容,最终结果才是定稿。如果直接把所有中间结果拼起来,会得到一堆重复和错乱。
正确做法是:只保留每个分片的最终结果,中间结果只用于前端实时展示。分片之间的重叠部分,用文本相似度做去重。简单点可以用最长公共子串,复杂点可以用编辑距离。
// 合并两个分片的识别结果,去掉重叠部分 public static string MergeResults(string prev, string current) { if (string.IsNullOrEmpty(prev)) return current; if (string.IsNullOrEmpty(current)) return prev; // 找 prev 后缀和 current 前缀的最长公共子串 int maxOverlap = Math.Min(prev.Length, current.Length); for (int len = maxOverlap; len > 0; len--) { var suffix = prev.Substring(prev.Length - len); var prefix = current.Substring(0, len); if (suffix == prefix) return prev + current.Substring(len); } return prev + current; // 没有重叠,直接拼 }这段逻辑从最大可能重叠长度开始试,找到第一个匹配就返回。prev.Substring(prev.Length - len)取后缀,current.Substring(0, len)取前缀,相等就说明重叠了。返回时prev保留,current去掉重叠部分。如果没有重叠,直接拼接,但这种情况在重叠分片策略下很少出现。
参数上,这个算法是 O(n²) 的,但分片结果一般不长,几十到几百字,性能没问题。如果分片很多,可以只比较最后 50 个字符,减少计算量。注意中文没有空格,直接按字符比较就行,不需要分词。
3.3 并发控制:别让一个用户拖垮整个服务
WebAPI 是并发处理的,如果每个请求都直接调语音听写服务,并发一高就会触发服务端的 QPS 限制。常见做法是加一层信号量或队列,控制同时进行的听写请求数。
我一般用SemaphoreSlim做限流,信号量大小根据服务端 QPS 限制和平均识别耗时来定。比如服务端限制 10 QPS,平均识别 2 秒,那同时最多 20 个请求在飞。设成 15 留点余量,超出的请求排队等待,而不是直接拒绝。
// 全局信号量,限制并发听写请求数 private static readonly SemaphoreSlim _throttle = new SemaphoreSlim(15, 15); public async Task<string> RecognizeAsync(byte[] pcm) { await _throttle.WaitAsync(); try { // 调用听写服务,带重试 return await CallAsrWithRetryAsync(pcm); } finally { _throttle.Release(); } } private async Task<string> CallAsrWithRetryAsync(byte[] pcm) { int retry = 0; while (true) { try { return await CallAsrOnceAsync(pcm); } catch (HttpRequestException ex) when (retry < 3) { retry++; // 指数退避,1s、2s、4s await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retry - 1))); } } }SemaphoreSlim的构造参数是初始许可数和最大许可数,这里都设 15。WaitAsync在许可不足时会异步等待,不会阻塞线程。finally里必须Release,否则许可会泄漏,跑一段时间后所有请求都卡住。重试逻辑用when过滤只重试网络异常,业务错误(比如音频格式不对)重试没意义。指数退避的延迟是 1、2、4 秒,给服务端恢复的时间。
注意:信号量是进程内的,如果 WebAPI 部署了多个实例,每个实例都有自己的信号量,总并发是实例数乘以 15。要全局控制得用分布式限流,比如 Redis 计数器。
4. 避坑与排查:那些让你加班到凌晨的细节
4.1 识别结果为空但接口返回成功
现象:请求返回 200,但识别文本是空字符串。原因通常是音频格式不对,比如采样率不是 16kHz,或者声道数不对。服务端不会报错,只是识别不出内容。解决方法是先用工具确认音频参数,ffprobe可以看采样率和声道数。如果是前端录的,检查 MediaRecorder 的配置,确保audioBitsPerSecond和采样率匹配。
4.2 长语音识别到一半就断了
现象:60 秒以内的音频正常,超过就只返回前半段。原因是没做分片,或者分片大小超过了服务限制。解决方法是按 40 秒切片,留出余量。另外检查 HTTP 客户端的超时设置,默认 100 秒可能不够,长语音要设到 5 分钟以上。
4.3 并发一高就报 429
现象:单请求正常,压测时大量 429 Too Many Requests。原因是没做限流,请求直接打到服务端。解决方法是加信号量控制并发,并配合重试和退避。如果服务端有明确的 QPS 限制,信号量大小不要超过限制值。
4.4 签名偶尔失败,重启就好
现象:大部分请求正常,偶尔 401,重启服务后恢复。原因是服务器时间漂移,签名用的时间戳和服务端偏差超过容忍窗口。解决方法是配置 NTP 同步,并在签名前检查时间偏差,超过阈值打日志。不要用本地时间硬编码,用DateTime.UtcNow。
4.5 拼接结果出现重复句子
现象:识别结果里同一句话出现两次。原因是分片重叠部分没有正确去重,或者中间结果和最终结果混在一起。解决方法是只保留最终结果,并用最长公共子串去重。如果重叠窗口设得太大,去重逻辑要相应加强。
5. 进阶技巧:把听写服务做成可观测、可降级的组件
做到这一步,基本功能已经跑通了。但要让这个服务在生产环境稳定运行,还需要两件事:可观测和可降级。
可观测方面,我一般会记录每个请求的音频时长、分片数、识别耗时、重试次数、结果长度。这些指标能帮你快速定位问题:耗时突然变长可能是服务端限流,重试次数高可能是网络抖动,结果长度为 0 可能是格式问题。用ILogger打结构化日志,配合日志平台做聚合。
_logger.LogInformation( "ASR request completed. Duration={Duration}ms, Slices={Slices}, " + "Retries={Retries}, ResultLength={ResultLength}", sw.ElapsedMilliseconds, sliceCount, retryCount, result.Length);可降级方面,语音听写服务不是永远可用的。当服务端持续报错或超时,应该有降级策略:要么返回“识别失败,请重试”,要么切到备用服务。我一般会设一个熔断器,连续失败 N 次后直接快速失败,避免请求堆积。熔断器可以用Polly实现,策略是:失败率超过 50% 且请求数超过 10 时熔断 30 秒。
// Polly 熔断策略 var circuitBreaker = Policy .Handle<HttpRequestException>() .CircuitBreakerAsync( exceptionsAllowedBeforeBreaking: 5, durationOfBreak: TimeSpan.FromSeconds(30), onBreak: (ex, breakDelay) => _logger.LogWarning("ASR circuit broken for {Delay}s", breakDelay.TotalSeconds), onReset: () => _logger.LogInformation("ASR circuit reset"));exceptionsAllowedBeforeBreaking设 5 是连续失败 5 次才熔断,避免偶发失败误触发。durationOfBreak设 30 秒是给服务端恢复的时间。onBreak和onReset回调用来打日志,方便排查。熔断期间请求直接抛异常,由上层决定是重试还是返回降级结果。
还有一个技巧是音频缓存。如果同一段音频被重复提交(比如用户重试),可以按音频哈希缓存识别结果,避免重复调用。缓存有效期设短一点,比如 5 分钟,因为语音识别结果基本不变。用IMemoryCache就够,不需要分布式缓存。
最后说个我自己的习惯:每次接入新的语音听写服务,先写一个控制台程序把鉴权、格式、分片、拼接全跑一遍,确认没问题再往 WebAPI 里搬。WebAPI 的调试成本比控制台高得多,先在简单环境里把坑踩完,能省不少时间。希望帮到你。
本文还有配套的精品资源,点击获取