原创 · Unity / 语音识别 / 语音合成 / 讯飞开放平台
阅读时长:约 15 分钟 | 文末有关键代码和 FAQ
前言
对着麦克风说一句普通话,程序识别成文字,再用粤语 (或东北话、四川话......)播放出来------这个听起来有点"翻译机"味道的小需求,实际做下来会发现:普通话识别是一片坦途,方言合成才是真正的独木桥。
本文记录从技术调研、协议分析、Unity 实现到最终跑通的完整过程,重点是三个花了我最多时间的坑(JsonUtility 序列化、内置字体加载、WebSocket 静默断连),每一个都附上原始报错和定位过程,希望后来人能少走弯路。
最终效果:Unity 里点击方言按钮选择方言 → 点"开始说话"对麦克风说普通话 → 再点一下 → 1~2 秒后扬声器播出方言版本,字幕同步显示识别文本。
unity语音转方言
目录
- 需求与技术调研------方言 TTS 是瓶颈
- 系统架构与数据流
- 讯飞 WebAPI 协议详解(鉴权 / 听写 / 合成)
- Unity 端实现(录音、WebSocket、协程桥接、播放)
- 踩坑实录(本文精华)
- FAQ
- 扩展方向与总结
一、需求与技术调研------方言 TTS 是瓶颈
1.1 需求拆解
说普通话 → 识别成文本(ASR) → 方言合成(TTS) → 播放
三段里前一段和后一段都有成熟方案,难的是中间的方言 TTS。
1.2 方案对比
| 方案 | 类型 | 方言覆盖 | 门槛 | 结论 |
|---|---|---|---|---|
| 讯飞在线合成 | 云 API | 最全:粤语、四川话、东北话、河南话、湖南话、陕西话、江西话、湖北话、云南话、甘肃话等十余种 | 低,WebSocket 直连 | ✅ 本文选择 |
| Azure Speech | 云 API | 粤语、东北话、河南话、吴语等,音质最好 | 需注册 Azure | 备选(有官方 Unity SDK) |
| CosyVoice2 (阿里开源) | 本地 | 内置粤语、四川话音色,可克隆 | 需 GPU + Python | 进阶方案 |
| GPT-SoVITS | 本地 | 任意方言(1 分钟录音克隆) | 需 GPU + 训练 | 冷门方言方案 |
| 百度/腾讯 TTS | 云 API | 基本只有粤语 | 低 | 覆盖太少 |

(https://console.xfyun.cn/services/tts 进入讯飞控制台 → 在线语音合成 → 发音人列表,截方言部分,能看到每种方言对应的 vcn 值)
1.3 为什么是 "Unity 直连讯飞"
Unity(C#)没法直接跑 FunASR/CosyVoice 这些 Python 生态模型,理论上有三条路:
| 路线 | 说明 | 取舍 |
|---|---|---|
| A. 云 API 直连 | Unity 内用 WebSocket 调讯飞 | ✅ 无本地部署、开发最快 |
| B. Unity 前端 + Python 后端 | 本地跑开源模型 | 适合离线/克隆声音,部署重 |
| C. Unity 端侧推理 | whisper.cpp / Sentis | 方言 TTS 无解,只覆盖一半 |
demo 阶段果断选 A。
二、系统架构与数据流
2.1 数据流
┌──────────┐ 44.1kHz float ┌───────────────┐ 16kHz PCM16 ┌─────────────┐
│ Microphone│ ───────────────▶ │ MicRecorder │ ────────────▶ │ XfyunIat │
│ (循环缓冲) │ │ 混音/降采样/转码 │ 分帧上传 │ 听写 WebSocket│
└──────────┘ └───────────────┘ └──────┬──────┘
│ 普通话文本
▼
┌──────────┐ mp3 字节 ┌───────────────┐ 文本+vcn ┌─────────────┐
│AudioSource│ ◀────────────── │ DialectSpeaker │ ─────────────▶ │ XfyunTts │
│ 播放方言 │ (临时文件加载) │ 主控制器/状态机 │ │ 合成 WebSocket│
└──────────┘ └───────────────┘ └─────────────┘
2.2 场景结构
Canvas (Screen Space - Overlay)
├── DialectGroup Horizontal Layout Group,顶部
│ ├── DialectBtn_0 粤语·小玥
│ ├── DialectBtn_1 普通话·小燕
│ └── ...(加方言就加按钮)
├── RecordBtn 中间大按钮
│ └── Text "🎤 点击说话"
├── Subtitle 识别字幕 Text
└── Status 状态栏 Text
EventSystem

2.3 脚本分工
| 脚本 | 职责 | 行数级别 |
|---|---|---|
XfyunConfig.cs |
AppId/ApiKey/ApiSecret(唯一要改的文件) | ~10 |
XfyunAuth.cs |
HMAC-SHA256 鉴权签名,生成 wss 地址 | ~35 |
XfyunIat.cs |
听写:PCM 分帧上传 + 增量结果拼接 | ~130 |
XfyunTts.cs |
合成:文本 + vcn → mp3 字节 | ~100 |
MicRecorder.cs |
录音 → 混音/降采样 → PCM16 | ~110 |
DialectSpeaker.cs |
主控:Inspector 接线、状态机、流水线 | ~180 |
三、讯飞 WebAPI 协议详解
先去 讯飞开放平台控制台 建一个应用,拿到三件套(AppId / ApiKey / ApiSecret),然后分别开通「语音听写」和「在线语音合成」两个服务(各有免费额度,demo 完全够用)。

3.1 鉴权:签名不在 header 里,在 URL 里
这是讯飞 WebAPI 和大多数云 API 不同的地方------它不用 header 传凭证,而是把签名结果拼进 WebSocket URL 的查询参数:
csharp
// 完整实现 XfyunAuth.cs
using System;
using System.Security.Cryptography;
using System.Text;
public static class XfyunAuth
{
public static string BuildWsUrl(string host, string path, string apiKey, string apiSecret)
{
// RFC1123 格式的 GMT 时间,如 "Mon, 24 Aug 2026 03:00:00 GMT"
string date = DateTime.UtcNow.ToString("R");
// 1. 拼签名原文:host + date + 请求行,用 \n 分隔
string signatureOrigin = $"host: {host}\ndate: {date}\nGET {path} HTTP/1.1";
// 2. HMAC-SHA256 后 base64
string signature = HmacSha256Base64(apiSecret, signatureOrigin);
// 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 编码后拼到查询串
return $"wss://{host}{path}" +
$"?authorization={Uri.EscapeDataString(authorization)}" +
$"&date={Uri.EscapeDataString(date)}" +
$"&host={Uri.EscapeDataString(host)}";
}
static string HmacSha256Base64(string key, string data)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key));
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(data)));
}
}
三个细节,错一个就 401:
date必须是 RFC1123 GMT 格式(ToString("R")刚好是),且取自本机时钟------系统时间偏差大会直接鉴权失败(后面坑 3 会再遇到它)- base64 出来的
+ / =必须经过Uri.EscapeDataString,手拼字符串容易漏 host、path要和最终连接的地址完全一致(听写和合成的 host 不同)
3.2 语音听写(IAT):一个上传协议 + 一个下载协议
- 地址:
wss://iat-api.xfyun.cn/v2/iat - 音频硬性要求:16000Hz / 16bit / 单声道 ,
encoding="raw"(裸 PCM) - 上行:音频按 1280 字节/帧(即 40ms) 发送,间隔约 40ms
三种上行帧:
json
// 首帧:带 common + business
{"common":{"app_id":"xxx"},
"business":{"language":"zh_cn","domain":"iat","accent":"mandarin"},
"data":{"status":0,"format":"audio/L16;rate=16000","encoding":"raw","audio":"<base64>"}}
// 续帧:只有 data
{"data":{"status":1,"format":"audio/L16;rate=16000","encoding":"raw","audio":"<base64>"}}
// 结束帧
{"data":{"status":2}}
下行是增量返回的,不是一次性给全句:
json
{"code":0,"message":"success","sid":"iat000daff1@dx1a...",
"data":{"result":{"sn":1,"ws":[{"cw":[{"w":"今天","sc":0}]}]},"status":2}}
识别文本藏在 data.result.ws[].cw[].w 里,要在客户端自己拼接。data.status==2 表示全部返回完毕。
实现上的一个关键设计:收发并行。 我第一版是"发完所有帧再开始收",后来改成接收任务后台先启动、发送同时进行------服务端是边收边回的,避免长音频时结果积压:
csharp
// XfyunIat.cs 核心结构(完整版见文末代码获取)
var recvTask = Task.Run(async () =>
{
bool done = false;
while (!done && ws.State == WebSocketState.Open)
{
var r = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (r.MessageType == WebSocketMessageType.Close) break;
string json = Encoding.UTF8.GetString(buffer, 0, r.Count);
Debug.Log($"[IAT] <- {json}"); // 排查利器,后文详述
var resp = JsonUtility.FromJson<IatResponse>(json);
if (resp.code != 0)
throw new Exception($"语音听写失败 code={resp.code}: {resp.message}");
if (resp.data?.result?.ws != null)
foreach (var w in resp.data.result.ws)
foreach (var cw in w.cw) sb.Append(cw.w); // 拼接增量文本
if (resp.data != null && resp.data.status == 2) done = true;
}
});
// 发送循环(略)...
await recvTask; // 接收任务的异常在这里重新抛出
return sb.ToString();
3.3 在线合成(TTS):一个参数决定方言
- 地址:
wss://tts-api.xfyun.cn/v2/tts - 一帧把全部文本发完(文本要
base64(UTF8编码))
json
{"common":{"app_id":"xxx"},
"business":{"aue":"lame","sfl":1,"vcn":"xiaoyue","tte":"UTF8",
"speed":50,"volume":50,"pitch":50},
"data":{"status":2,"text":"<base64 of UTF8 text>"}}
business 参数速查:
| 参数 | 值 | 说明 |
|---|---|---|
| vcn | 如 xiaoyue |
发音人 = 方言开关,从控制台发音人列表抄 |
| aue | lame |
输出 mp3(raw 是 pcm) |
| sfl | 1 |
流式返回,须与 aue=lame 配套 |
| tte | UTF8 |
文本编码 |
| speed/volume/pitch | 0~100 | 语速音量音调,50 默认 |
下行是 mp3 的 base64 分片,全部拼起来就是完整音频文件,直接存成 .mp3 就能放。
四、Unity 端实现
4.1 录音:三个容易忽略的坑全在格式上
csharp
// MicRecorder.cs 核心逻辑
clip = Microphone.Start(device, true, 60, 44100); // 循环缓冲 60 秒
// 停止时:
int pos = Microphone.GetPosition(device); // 当前写入位置 = 实际录了多长
Microphone.End(device);
float[] all = new float[clip.samples * clip.channels];
clip.GetData(all, 0);
int recordedFloats = Mathf.Min(pos * clip.channels, all.Length); // 只取录到的部分!
坑点:
Microphone.Start的循环缓冲意味着超过 60s 会从头覆盖 ,所以要么限制录音时长,要么用GetPosition处理回绕(demo 直接限制 55s 自动停)- Unity 录出来的是 float-1,1,讯飞要 int16,需要手动转
- 采样率 44100,讯飞要 16000,线性插值降采样(顺带多声道取平均变单声道):
csharp
static float[] Resample(float[] src, int srcRate, int dstRate)
{
float ratio = (float)srcRate / dstRate;
int outLen = Mathf.FloorToInt(src.Length / ratio);
var dst = new float[outLen];
for (int i = 0; i < outLen; i++)
{
float srcPos = i * ratio;
int i0 = Mathf.Min((int)srcPos, src.Length - 1);
int i1 = Mathf.Min(i0 + 1, src.Length - 1);
dst[i] = Mathf.Lerp(src[i0], src[i1], srcPos - i0); // 线性插值
}
return dst;
}
4.2 WebSocket 前置设置(不改就编译不过)
Project Settings → Player → Api Compatibility Level 必须选 .NET Framework (或 .NET Standard 2.1),否则 System.Net.WebSockets.ClientWebSocket 不存在。

4.3 async/await 和 Unity 协程的桥接
网络调用是 async Task,但 UI 更新和 StartCoroutine 在主线程。Unity 的 SynchronizationContext 会让 await 之后的代码回到主线程,但异常和完成时机还是用一个小包装最稳:
csharp
// 把 Task 包装成协程:主循环轮询完成状态,异常带回主线程
IEnumerator RunAsync<T>(Task<T> task, Action<T> onDone, Action<string> onError)
{
while (!task.IsCompleted) yield return null;
if (task.IsFaulted)
onError?.Invoke(task.Exception?.GetBaseException()?.Message
?? task.Exception.Message); // GetBaseException 拿最内层真实错误
else
onDone?.Invoke(task.Result);
}
主流程就是一条流水线协程:
csharp
IEnumerator Pipeline(byte[] pcm)
{
SetStatus("识别中...");
yield return RunAsync(XfyunIat.RecognizeAsync(pcm), t => text = t, e => error = e);
// ...校验...
SetStatus($"合成中...({方言名})");
yield return RunAsync(XfyunTts.SynthesizeAsync(text, vcn), m => mp3 = m, e => error = e);
// ...落盘、加载、播放...
}
4.4 mp3 播放:Unity 不能直接从内存解 mp3
讯飞返回的是 mp3 字节流。AudioClip.Create 只能喂 PCM,所以最省事的做法是写临时文件再加载:
csharp
string file = Path.Combine(Application.temporaryCachePath, $"tts_{Guid.NewGuid():N}.mp3");
File.WriteAllBytes(file, mp3);
IEnumerator LoadAndPlay(string file)
{
using var req = UnityWebRequestMultimedia.GetAudioClip("file://" + file, AudioType.MPEG);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) { /* 报错 */ yield break; }
audioSource.PlayOneShot(DownloadHandlerAudioClip.GetContent(req));
}
4.5 UI:从"代码生成"退回"编辑器搭建"
(这段是踩坑后的教训,详见坑 2。)最终方案:UI 全部编辑器手动搭,脚本只留 public 字段由 Inspector 拖入;方言按钮的 onClick 在 Awake 里按数组下标自动绑定,并把按钮文字同步成配置里的 label------配置和场景不会两张皮。
csharp
public Button[] dialectButtons; // 顺序与 dialects 列表一一对应
public Button recordButton;
public Text recordLabel, subtitleText, statusText;
void WireButtons()
{
for (int i = 0; i < dialectButtons.Length && i < dialects.Count; i++)
{
int idx = i; // 闭包捕获必须复制局部变量!
dialectButtons[i].onClick.AddListener(() => SelectDialect(idx));
var t = dialectButtons[i].GetComponentInChildren<Text>();
if (t != null) t.text = dialects[i].label;
}
recordButton.onClick.AddListener(OnRecordClicked);
}
方言配置用 [Serializable] 类暴露到 Inspector,加方言不用改代码:
csharp
[Serializable] public class DialectOption { public string label; public string vcn; }
public List<DialectOption> dialects = new List<DialectOption> {
new DialectOption { label = "粤语·小玥", vcn = "xiaoyue" },
new DialectOption { label = "普通话·小燕", vcn = "xiaoyan" },
};

五、踩坑实录(本文精华,每条都附原始报错)
坑 1 ⭐ JsonUtility 全字段序列化 → 10163 param validate error
现象:连接成功、鉴权通过、音频发出去了,服务端回:
json
{"code":10163,"message":"param validate error:'$.code' unknown field;
'$.message' unknown field; '$.data.result' unknown field; ","sid":"iat000daff1@dx1a..."}
定位过程 :报错里的 $.code、$.data.result 是 JSONPath,指向我发出去的请求体 里的字段。可我的代码明明"没写"这些字段------直到想起 JsonUtility.ToJson 的特性:
它序列化类上定义的全部 public 字段,不管你有没有赋值。
我为了省事用同一个类同时承担发送和接收结构,于是只有响应才该有的 code/message/result 被序列化进了每一帧请求,而讯飞服务端严格拒绝任何未知字段。
解法:发送/接收拆成两套类:
csharp
// 发送:只有请求需要的字段
[Serializable] class IatReqFirst { public IatCommon common; public IatBusiness business; public IatReqData data; }
[Serializable] class IatReqContinue { public IatReqData data; }
[Serializable] class IatReqData { public int status; public string format; public string encoding; public string audio; }
// 接收:只有响应才有的字段
[Serializable] class IatResponse { public int code; public string message; public string sid; public IatRespData data; }
[Serializable] class IatRespData { public IatResult result; public int status; }
通用原则 :用 JsonUtility 对接任何严格校验的 API,请求类和响应类永远分开定义。这和 Newtonsoft.Json 的习惯很不一样,从 Newtonsoft 转过来的尤其容易中招。
坑 2 ⭐ WebSocket 静默断连:服务端不报错,直接掐线
现象:
❌ 识别失败: The remote party closed the WebSocket connection
without completing the close handshake.
讨厌之处 :讯飞很多错误(鉴权失败、服务未开通、参数非法)不返回错误 JSON,直接断开 TCP 且不走 close 握手,你只能拿到这一句万金油报错。
排查思路(按概率从高到低):
- 控制台里该应用是否开通了对应服务(听写、合成要分别开通)
- AppId / ApiKey / ApiSecret 是否来自同一应用、复制时有没有带空格换行
- 本机系统时间是否准确(签名里的 date 来自本机时钟)
- 请求参数是否非法(见坑 4)
代码层面的两个改进,让这类问题从"盲猜"变成"看日志":
csharp
// 1. ConnectAsync 单独 try/catch ------ HTTP 层被拒(401/403)和会话中断原因不同
try { await ws.ConnectAsync(new Uri(url), CancellationToken.None); }
catch (WebSocketException e)
{
throw new Exception($"连接讯飞听写失败: {e.Message}\n内部异常: {e.InnerException?.Message}\n" +
"排查: 1)是否已开通「语音听写」 2)三件套是否同一应用 3)系统时间是否准确");
}
// 2. 接收循环打印原始报文 + 断开时的 CloseStatus
Debug.Log($"[IAT] <- {json}"); // 每条原始返回
Debug.LogWarning($"[IAT] 服务端断开: {e.Message}, " +
$"closeStatus={ws.CloseStatus} {ws.CloseStatusDescription}"); // 断开详情
正是这两行日志让我拿到了坑 1 的 10163 原始报文。接任何第三方 WebSocket API,第一件事就是把原始报文日志加上。
坑 3:domain 参数的版本陷阱
听写 business.domain 的标准值是 "iat"(日常用语)。一些老文章/示例里能搜到 "slt" 等取值,新接口下可能直接触发参数校验失败或断连。以官方文档当前版本为准,别信搜索引擎里的旧 demo。
坑 4:三个 C# 小细节
csharp
// 1. 参数重名编译错误:语义化命名
static Button MakeButton(string label, int size /*字号*/, Vector2 size /*尺寸*/) // ❌
static Button MakeButton(string label, int fontSize, Vector2 size) // ✅
// 2. 闭包捕获循环变量
for (int i = 0; i < n; i++)
btn.onClick.AddListener(() => Select(i)); // ❌ 所有回调都拿到 n
int idx = i; btn.onClick.AddListener(() => Select(idx)); // ✅
// 3. Task 异常要取最内层
task.Exception.Message // 是 "One or more errors occurred."
task.Exception.GetBaseException().Message // ✅ 真实错误信息
六、总结
讯飞 WebAPI 的协议本身不复杂:签名拼 URL → WebSocket → 分帧上传 → 拼接结果,全部逻辑不到 600 行 C#。真正消耗时间的是三个坑:
- JsonUtility 全字段序列化 → 严格 API 报 10163(请求/响应类必须分离)
- Unity 内置字体不可靠 → 运行时生成 UI 行不通,中文项目第一天就该配好字体
- 服务端静默断连 → 不给错误 JSON,只能靠原始报文日志 + CloseStatus 定位
对应的通用习惯:请求与响应类型分离、字体/资源显式配置、第三方 API 先上原始报文日志。这三条适用于几乎所有 Unity + 第三方 API 的场景,与讯飞无关也成立。
关键代码
csharp
using System;
using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
/// <summary>
/// 讯飞【在线语音合成 WebAPI】(wss://tts-api.xfyun.cn/v2/tts)
/// 输入文本 + 方言发音人 vcn,返回 mp3 字节数组。
/// 文档: https://www.xfyun.cn/doc/tts/online_tts/API.html
/// </summary>
public static class XfyunTts
{
const string Host = "tts-api.xfyun.cn";
const string Path = "/v2/tts";
/// <param name="vcn">发音人,决定方言。在讯飞控制台「发音人列表」查看可选值</param>
public static async Task<byte[]> SynthesizeAsync(string text, string vcn)
{
if (string.IsNullOrEmpty(text)) throw new Exception("合成文本为空");
string url = XfyunAuth.BuildWsUrl(Host, Path, XfyunConfig.ApiKey, XfyunConfig.ApiSecret);
using var ws = new ClientWebSocket();
// 连接失败(HTTP 层就被拒)几乎都是鉴权问题,单独报出来
try
{
await ws.ConnectAsync(new Uri(url), CancellationToken.None);
}
catch (WebSocketException e)
{
throw new Exception(
$"连接讯飞合成失败: {e.Message}\n" +
$"内部异常: {e.InnerException?.Message}\n" +
"排查: 1)控制台是否已开通「在线语音合成」 2)AppId/ApiKey/ApiSecret 是否同一应用且无多余空格 3)电脑系统时间是否准确");
}
// 一帧发送全部文本(status=2 表示最后一块)
var req = new TtsRequest
{
common = new IatCommon { app_id = XfyunConfig.AppId },
business = new TtsBusiness
{
aue = "lame", // mp3 格式
sfl = 1, // 流式返回
vcn = vcn, // ★ 方言由这个参数决定
tte = "UTF8",
speed = 50,
volume = 50,
pitch = 50
},
data = new TtsReqData
{
status = 2,
text = Convert.ToBase64String(Encoding.UTF8.GetBytes(text))
}
};
await XfyunIat.SendTextAsync(ws, JsonUtility.ToJson(req));
// 接收 mp3 分片(base64),拼接为完整音频
using var ms = new MemoryStream();
var buffer = new byte[64 * 1024];
while (ws.State == WebSocketState.Open)
{
WebSocketReceiveResult r;
try
{
r = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
}
catch (WebSocketException) { break; }
if (r.MessageType == WebSocketMessageType.Close) break;
string json = Encoding.UTF8.GetString(buffer, 0, r.Count);
Debug.Log($"[TTS] <- {json}"); // 排查期保留,跑通后可注释
var resp = JsonUtility.FromJson<TtsResponse>(json);
if (resp.code != 0)
throw new Exception($"语音合成失败 code={resp.code}: {resp.message}(检查 vcn 是否有效)");
if (!string.IsNullOrEmpty(resp.data?.audio))
{
byte[] chunk = Convert.FromBase64String(resp.data.audio);
ms.Write(chunk, 0, chunk.Length);
}
if (resp.data != null && resp.data.status == 2) break; // 最后一块
}
if (ms.Length == 0) throw new Exception("合成返回了空音频");
return ms.ToArray();
}
}
// ---------------- JSON 结构 ----------------
// 发送与接收拆成两套类(混用会把 code/message 序列化进请求,
// 服务端报 code=10163 "unknown field",与听写侧同类问题)。
// ---- 发送 ----
[Serializable] public class TtsBusiness { public string aue; public int sfl; public string vcn; public string tte; public int speed; public int volume; public int pitch; }
[Serializable] public class TtsRequest { public IatCommon common; public TtsBusiness business; public TtsReqData data; }
[Serializable] public class TtsReqData { public int status; public string text; } // text: UTF8 文本的 base64
// ---- 接收 ----
[Serializable] public class TtsResponse { public int code; public string message; public TtsRespData data; }
[Serializable] public class TtsRespData { public string audio; public int status; } // audio: mp3 分片 base64
using System;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
/// <summary>
/// 讯飞【语音听写 WebAPI】(wss://iat-api.xfyun.cn/v2/iat)
/// 输入 16kHz/16bit/单声道 PCM,返回普通话文本。
/// 文档: https://www.xfyun.cn/doc/asr/ICS/API.html
/// </summary>
public static class XfyunIat
{
const string Host = "iat-api.xfyun.cn";
const string Path = "/v2/iat";
public static async Task<string> RecognizeAsync(byte[] pcm16k)
{
string url = XfyunAuth.BuildWsUrl(Host, Path, XfyunConfig.ApiKey, XfyunConfig.ApiSecret);
using var ws = new ClientWebSocket();
// 连接失败(HTTP 层就被拒)几乎都是鉴权问题,单独报出来
try
{
await ws.ConnectAsync(new Uri(url), CancellationToken.None);
}
catch (WebSocketException e)
{
throw new Exception(
$"连接讯飞听写失败: {e.Message}\n" +
$"内部异常: {e.InnerException?.Message}\n" +
"排查: 1)控制台是否已开通「语音听写」 2)AppId/ApiKey/ApiSecret 是否同一应用且无多余空格 3)电脑系统时间是否准确");
}
var sb = new StringBuilder();
var buffer = new byte[64 * 1024];
// ---- 后台接收任务:与发送并行,打印服务端原始返回便于排查 ----
var recvTask = Task.Run(async () =>
{
bool done = false;
while (!done && ws.State == WebSocketState.Open)
{
WebSocketReceiveResult r;
try
{
r = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
}
catch (WebSocketException e)
{
Debug.LogWarning($"[IAT] 服务端断开连接: {e.Message},closeStatus={ws.CloseStatus} {ws.CloseStatusDescription}");
break;
}
if (r.MessageType == WebSocketMessageType.Close)
{
Debug.LogWarning($"[IAT] 服务端请求关闭: closeStatus={ws.CloseStatus} {ws.CloseStatusDescription}");
break;
}
string json = Encoding.UTF8.GetString(buffer, 0, r.Count);
Debug.Log($"[IAT] <- {json}"); // 排查期保留,跑通后可注释
var resp = JsonUtility.FromJson<IatResponse>(json);
if (resp.code != 0)
throw new Exception($"语音听写失败 code={resp.code}: {resp.message}");
if (resp.data?.result?.ws != null)
foreach (var w in resp.data.result.ws)
foreach (var cw in w.cw)
sb.Append(cw.w);
if (resp.data != null && resp.data.status == 2) done = true; // 2 = 全部结果返回完毕
}
});
// ---- 发送音频:每帧 1280 字节(40ms),第一帧带 common/business,最后发 status=2 结束帧 ----
const int frameBytes = 1280;
int offset = 0;
bool firstSent = false;
while (offset < pcm16k.Length || !firstSent)
{
int len = Math.Min(frameBytes, pcm16k.Length - offset);
var chunk = new byte[len];
if (len > 0) Array.Copy(pcm16k, offset, chunk, 0, len);
var reqData = new IatReqData
{
status = firstSent ? 1 : 0,
format = "audio/L16;rate=16000",
encoding = "raw",
audio = Convert.ToBase64String(chunk)
};
string json = firstSent
? JsonUtility.ToJson(new IatReqContinue { data = reqData })
: JsonUtility.ToJson(new IatReqFirst
{
common = new IatCommon { app_id = XfyunConfig.AppId },
business = new IatBusiness { language = "zh_cn", domain = "iat", accent = "mandarin" },
data = reqData
});
await SendTextAsync(ws, json);
firstSent = true;
offset += len;
await Task.Delay(40); // 讯飞要求约 40ms 间隔发送
}
// 结束帧
var end = new IatReqContinue { data = new IatReqData { status = 2 } };
await SendTextAsync(ws, JsonUtility.ToJson(end));
await recvTask; // 接收任务的异常会在此抛出
return sb.ToString();
}
internal static Task SendTextAsync(ClientWebSocket ws, string json)
{
byte[] bytes = Encoding.UTF8.GetBytes(json);
return ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
}
}
// ---------------- JSON 结构 ----------------
// 发送与接收拆成两套类:JsonUtility 会序列化类上的全部字段,
// 若混用(旧版把 code/message/result 带进请求),服务端会报
// code=10163 "param validate error: unknown field"。
// ---- 发送 ----
[Serializable] public class IatCommon { public string app_id; }
[Serializable] public class IatBusiness { public string language; public string domain; public string accent; }
[Serializable] public class IatReqFirst { public IatCommon common; public IatBusiness business; public IatReqData data; }
[Serializable] public class IatReqContinue { public IatReqData data; }
[Serializable] public class IatReqData
{
public int status;
public string format; // "audio/L16;rate=16000"
public string encoding; // "raw"
public string audio; // 原始音频 base64
}
// ---- 接收 ----
[Serializable] public class IatResponse { public int code; public string message; public string sid; public IatRespData data; }
[Serializable] public class IatRespData { public IatResult result; public int status; }
[Serializable] public class IatResult { public int sn; public IatWs[] ws; }
[Serializable] public class IatWs { public IwCw[] cw; }
[Serializable] public class IwCw { public string w; public int sc; }
using System;
using System.Security.Cryptography;
using System.Text;
/// <summary>
/// 讯飞 WebAPI v2 鉴权:HMAC-SHA256 对 (host + date + request-line) 签名,
/// 生成带 authorization/date/host 查询参数的 WebSocket 地址。
/// </summary>
public static class XfyunAuth
{
public static string BuildWsUrl(string host, string path, string apiKey, string apiSecret)
{
// RFC1123 格式的 GMT 时间,如 "Tue, 24 Aug 2026 03:00:00 GMT"
string date = DateTime.UtcNow.ToString("R");
string signatureOrigin = $"host: {host}\ndate: {date}\nGET {path} HTTP/1.1";
string signature = HmacSha256Base64(apiSecret, signatureOrigin);
string authorizationOrigin =
$"api_key=\"{apiKey}\", algorithm=\"hmac-sha256\", " +
$"headers=\"host date request-line\", signature=\"{signature}\"";
string authorization = Convert.ToBase64String(Encoding.UTF8.GetBytes(authorizationOrigin));
return $"wss://{host}{path}" +
$"?authorization={Uri.EscapeDataString(authorization)}" +
$"&date={Uri.EscapeDataString(date)}" +
$"&host={Uri.EscapeDataString(host)}";
}
static string HmacSha256Base64(string key, string data)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key));
byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
return Convert.ToBase64String(hash);
}
}
参考资料
- 讯飞语音听写 WebAPI 文档:https://www.xfyun.cn/doc
- 讯飞在线语音合成 WebAPI 文档:https://www.xfyun.cn/doc/tts/online_tts/API.html
- 讯飞控制台:https://console.xfyun.cn
- Unity Microphone API:https://docs.unity3d.com/ScriptReference/Microphone.html
如果对你有帮助,欢迎点赞收藏评论~具体可私信。