Unity 实战:用讯飞 WebAPI 做一个“说普通话、播方言“的语音程序

原创 · Unity / 语音识别 / 语音合成 / 讯飞开放平台

阅读时长:约 15 分钟 | 文末有关键代码和 FAQ


前言

对着麦克风说一句普通话,程序识别成文字,再用粤语 (或东北话、四川话......)播放出来------这个听起来有点"翻译机"味道的小需求,实际做下来会发现:普通话识别是一片坦途,方言合成才是真正的独木桥

本文记录从技术调研、协议分析、Unity 实现到最终跑通的完整过程,重点是三个花了我最多时间的坑(JsonUtility 序列化、内置字体加载、WebSocket 静默断连),每一个都附上原始报错和定位过程,希望后来人能少走弯路。

最终效果:Unity 里点击方言按钮选择方言 → 点"开始说话"对麦克风说普通话 → 再点一下 → 1~2 秒后扬声器播出方言版本,字幕同步显示识别文本。

unity语音转方言


目录

  1. 需求与技术调研------方言 TTS 是瓶颈
  2. 系统架构与数据流
  3. 讯飞 WebAPI 协议详解(鉴权 / 听写 / 合成)
  4. Unity 端实现(录音、WebSocket、协程桥接、播放)
  5. 踩坑实录(本文精华)
  6. FAQ
  7. 扩展方向与总结

一、需求与技术调研------方言 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:

  1. date 必须是 RFC1123 GMT 格式(ToString("R") 刚好是),且取自本机时钟------系统时间偏差大会直接鉴权失败(后面坑 3 会再遇到它)
  2. base64 出来的 + / = 必须经过 Uri.EscapeDataString,手拼字符串容易漏
  3. hostpath 要和最终连接的地址完全一致(听写和合成的 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); // 只取录到的部分!

坑点:

  1. Microphone.Start 的循环缓冲意味着超过 60s 会从头覆盖 ,所以要么限制录音时长,要么用 GetPosition 处理回绕(demo 直接限制 55s 自动停)
  2. Unity 录出来的是 float-1,1,讯飞要 int16,需要手动转
  3. 采样率 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 握手,你只能拿到这一句万金油报错。

排查思路(按概率从高到低):

  1. 控制台里该应用是否开通了对应服务(听写、合成要分别开通)
  2. AppId / ApiKey / ApiSecret 是否来自同一应用、复制时有没有带空格换行
  3. 本机系统时间是否准确(签名里的 date 来自本机时钟)
  4. 请求参数是否非法(见坑 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#。真正消耗时间的是三个坑:

  1. JsonUtility 全字段序列化 → 严格 API 报 10163(请求/响应类必须分离)
  2. Unity 内置字体不可靠 → 运行时生成 UI 行不通,中文项目第一天就该配好字体
  3. 服务端静默断连 → 不给错误 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);
    }
}

参考资料


如果对你有帮助,欢迎点赞收藏评论~具体可私信。

相关推荐
略略略咯咯2 小时前
C#Unity
开发语言·unity·c#
Behavior4 小时前
Unity文字显示带竖线问题
c#·unity3d
qq_589666055 小时前
C语言、C++与C#的区别详解
c语言·c++·c#
淡海水6 小时前
04-02-哈希-Dictionary-TKey-TValue-上-核心数据结构
数据结构·算法·c#·哈希算法·编译·字典·dictionary
longxiaozhang67 小时前
C#异常处理:程序出错了怎么办?
开发语言·数据库·c#
小贺儿开发8 小时前
Unity 局域网遥控幻灯片展示工具 1.0
unity·网络通信·工具·ppt·控制·网页·互动
界面开发小八哥8 小时前
界面控件DevExpress WinForms中文帮助文档 - 入门指南
ai·c#·.net·devexpress·ui开发·winforms
清风与日月9 小时前
Yitter.IdGenerator:高性能分布式唯一ID生成器详解
分布式·c#·.net·.netcore
CODER03049 小时前
本地部署语音识别框架FunAudioLLM——Fun-ASR-Nano-2512模型
人工智能·conda·语音识别·audiolm