1. 从零开始:为什么要在Unity里做语音识别?

如果你正在开发一款游戏,或者一个教育类的应用,有没有想过让用户直接“说话”就能操作?比如,在游戏里喊一句“释放火球术”,角色就真的搓了个火球扔出去;或者在儿童识字应用里,孩子念出屏幕上的字,应用就能判断他读得对不对。这种体验,远比点点鼠标或者戳戳屏幕要酷得多,也自然得多。

这背后的核心技术,就是语音识别,也就是我们常说的STT。它的全称是“Speech-to-Text”,简单说,就是把我们说的话,实时变成电脑能理解的文字。在Unity里实现这个功能,听起来好像很高深,要搞什么复杂的声学模型、深度学习算法。但说实话,对于大多数开发者而言,我们完全不需要从零开始造轮子。市面上有很多成熟、稳定的云服务API,比如科大讯飞、百度语音、阿里云等,它们已经把最难的识别部分做成了非常易用的服务。我们要做的,其实就是当好一个“桥梁工程师”:在Unity里把用户的声音录下来,打包发送给这些云服务,再把返回的文字结果拿回来用。

我刚开始接触这个的时候,也觉得头大,又是音频采样,又是网络协议。但实际做下来,你会发现核心流程就几步:按下按钮开始录音 -> 停止录音拿到音频数据 -> 把音频数据发送给识别API -> 接收并处理返回的文本。整个链路清晰之后,剩下的就是一些工程上的细节打磨。这篇文章,我就以科大讯飞的语音听写(流式版)API为例,带你一步步在Unity里搭起这座“桥梁”。我会把我在项目里踩过的坑、总结的技巧都分享出来,保证你跟着做,一定能跑通。

2. 动手前的准备:账号、UI与核心思路

在写第一行代码之前,我们需要把“弹药”准备好。这就像做饭前要备好菜一样,准备充分了,后面炒起来才顺手。

2.1 讯飞开放平台配置

首先,你得有一个科大讯飞的开发者账号。去讯飞开放平台官网注册登录后,在控制台找到“语音听写(流式版)”服务。这里有个小技巧:如果你之前做过TTS(文字转语音)功能,那么恭喜你,WebSocket服务的认证信息(AppID、API Key、Secret)很大概率是同一套,不用重复创建。直接记下服务对应的API地址就行,通常是 wss:// 开头的WebSocket链接。

创建应用后,你会得到三样宝贝:AppID、API Key 和 API Secret。这三兄弟是调用所有服务的通行证,务必保管好。我们后面在Unity里生成鉴权URL时,就需要用到API Key和Secret。讯飞的官方文档里有详细的鉴权算法说明,通常是生成一个带时间戳的签名。不过别担心,我们可以在网上找到很多现成的C#鉴权代码,直接拿过来用就好,核心是保证你的服务器时间戳和讯飞服务器不能差太多。

2.2 设计一个简单直观的UI

UI是用户交互的门面。对于语音输入,我们需要一个清晰的界面来引导用户。我的设计通常是这样:

  1. 一个文本输入框:用来显示最终识别出的文字,或者手动输入文字。
  2. 一个语音输入按钮:这是核心。用户长按它说话,松开就结束。按钮上最好有状态提示,比如默认显示“按住说话”,按下后变成“正在聆听...”。
  3. 一个状态提示文本:用来告诉用户当前在干什么,比如“请开始说话”、“正在识别中...”、“识别成功”。这个对于用户体验很重要,不然用户按了半天没反应,会以为功能坏了。

在Unity的UGUI里搭建这些很简单。关键是那个语音按钮,我们需要为它绑定按下(PointerDown) 和抬起(PointerUp) 两个事件。这里不能直接用Button组件的onClick,因为onClick是点击(按下并抬起)后才触发,不符合我们“按住说话”的需求。我们需要请出EventTrigger组件来帮忙。

2.3 理解核心数据流

在动手敲代码前,脑子里先过一遍数据是怎么流动的,这能帮你少走很多弯路。整个流程可以概括为:

用户界面层(UI) -> 控制层(Controller) -> 音频采集层(Recorder) -> 网络通信层(WebSocket Client) -> 云服务(讯飞服务器) -> 控制层 -> 用户界面层

  1. 用户按住UI按钮,触发PointerDown事件。
  2. 控制层调用开始录音方法。
  3. 音频采集层启动Unity的Microphone类进行录音,将声音存为AudioClip。
  4. 用户松开按钮,触发PointerUp事件。
  5. 控制层调用停止录音方法,并拿到AudioClip。
  6. 控制层将AudioClip转换为字节数组(通常是PCM格式),然后通过WebSocket连接发送给讯飞服务器。
  7. 讯飞服务器识别后,通过同一个WebSocket连接将识别结果的JSON数据流式地传回来。
  8. 控制层解析JSON,拼接到完整的句子,最后更新UI上的文本显示。

你看,一环扣一环。我们接下来的代码,就是为这个流水线上的每个环节添砖加瓦。

3. 搭建骨架:对话控制器与事件绑定

现在,我们创建一个C#脚本,比如叫ChatController,把它挂到你的UI Canvas上。这个脚本就是咱们项目的“大脑”,负责协调所有事情。

首先,我们需要在脚本里声明一些UI组件的引用,方便后面操作:

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;

public class ChatController : MonoBehaviour
{
    // 手动在Inspector面板拖拽赋值
    public Button m_CommitMsgBtn; // 发送消息的按钮
    public InputField m_InputWord; // 文本输入框
    public Button m_VoiceInputButton; // 语音输入按钮
    public Text m_VoiceButtonText; // 语音按钮上的文字
    public Text m_RecordTips; // 录音状态提示文字

    // 其他功能组件的引用
    public VoiceInputRecorder m_VoiceInputs; // 录音器组件
    public ChatSettings m_ChatSettings; // 配置类,里面包含STT组件

    private void Awake()
    {
        // 注册发送按钮的点击事件
        m_CommitMsgBtn.onClick.AddListener(delegate { SendData(); });
        // 注册语音按钮的按下/抬起事件
        RegistButtonEvent();
    }
}

重点来了,RegistButtonEvent这个方法。我们要让那个语音按钮响应“按住”和“松开”:

private void RegistButtonEvent()
{
    // 安全检查
    if (m_VoiceInputButton == null || m_VoiceInputButton.GetComponent<EventTrigger>() != null)
        return;

    // 给按钮添加EventTrigger组件
    EventTrigger trigger = m_VoiceInputButton.gameObject.AddComponent<EventTrigger>();

    // 1. 创建“按下”事件的监听条目
    EventTrigger.Entry pointerDownEntry = new EventTrigger.Entry();
    pointerDownEntry.eventID = EventTriggerType.PointerDown; // 指定监听按下事件
    pointerDownEntry.callback = new EventTrigger.TriggerEvent(); // 初始化回调列表

    // 2. 创建“抬起”事件的监听条目
    EventTrigger.Entry pointerUpEntry = new EventTrigger.Entry();
    pointerUpEntry.eventID = EventTriggerType.PointerUp; // 指定监听抬起事件
    pointerUpEntry.callback = new EventTrigger.TriggerEvent();

    // 3. 为这两个事件添加具体的响应函数(委托)
    pointerDownEntry.callback.AddListener((data) => { StartRecord(); });
    pointerUpEntry.callback.AddListener((data) => { StopRecord(); });

    // 4. 将这两个监听条目添加到EventTrigger的列表中
    trigger.triggers.Add(pointerDownEntry);
    trigger.triggers.Add(pointerUpEntry);
}

这段代码是Unity事件系统的标准操作。我解释一下:EventTrigger.Entry就像一个“登记表”,上面写着你要监听什么事件(eventID),以及事件发生后要叫谁来处理(callback)。我们把StartRecord和StopRecord这两个方法“登记”到对应的事件下,系统就会在相应的时候自动调用它们。这样一来,UI交互的骨架就搭好了。

4. 核心功能实现:录音、发送与识别

骨架有了,现在该填充血肉了。我们从最贴近用户的录音开始。

4.1 音频的采集与处理

我们创建一个VoiceInputRecorder脚本来专门负责和麦克风打交道。这个类要做的就是:开始录、停止录、并把录好的音频交出去。

using UnityEngine;
using System;

public class VoiceInputRecorder : MonoBehaviour
{
    private AudioClip recording; // 用来存储录制的音频片段
    private const int m_RecordingLength = 10; // 最大录制时长,比如10秒
    private const int m_SampleRate = 16000; // 采样率,讯飞流式听写常用16000Hz

    // 开始录音
    public void StartRecordAudio()
    {
        // Microphone.Start(设备名, 是否循环, 长度秒数, 采样率)
        // 设备名为null则使用默认麦克风
        recording = Microphone.Start(null, false, m_RecordingLength, m_SampleRate);
        if (recording == null)
        {
            Debug.LogError("启动麦克风失败!请检查权限或设备。");
        }
    }

    // 结束录音,并通过回调返回AudioClip
    public void StopRecordAudio(Action<AudioClip> callback)
    {
        if (Microphone.IsRecording(null))
        {
            Microphone.End(null); // 停止录制
        }

        if (callback != null && recording != null)
        {
            callback(recording); // 将录好的音频传给后续处理者
        }
        recording = null; // 清空引用,便于GC回收
    }
}

这里有几个关键点:采样率(Sample Rate) 我设为了16000Hz。这是讯飞流式听写API推荐的标准采样率之一,音质和识别效率的平衡点。如果你设置成44100Hz(CD音质),可能还需要在发送前进行重采样(Resample),否则API可能不认。最大录制长度 设个合理的值,防止用户一直按着不放把内存撑爆。

回到ChatController,我们来实现StartRecord和StopRecord:

public void StartRecord()
{
    m_VoiceButtonText.text = "正在聆听...";
    m_RecordTips.text = "请开始说话";
    m_VoiceInputs.StartRecordAudio();
}

public void StopRecord()
{
    m_VoiceButtonText.text = "按住说话";
    m_RecordTips.text = "录音结束,正在识别...";
    // 停止录音,并指定录音完成后的处理函数:AcceptClip
    m_VoiceInputs.StopRecordAudio(AcceptClip);
}

注意StopRecordAudio(AcceptClip)这一行。这里用到了回调(Callback) 机制。简单说就是:“录音器啊,你停一下,然后把录好的音频交给AcceptClip这个函数去处理”。这是一种常见的异步处理模式,能让代码结构更清晰。

4.2 音频数据格式转换与发送

AcceptClip函数拿到了AudioClip,但它不能直接通过网络发送。我们需要把它转换成字节数组,通常是PCM格式。

private void AcceptClip(AudioClip audioClip)
{
    if (m_ChatSettings.m_SpeechToText == null)
    {
        Debug.LogError("STT组件未设置!");
        return;
    }
    // 调用STT组件,进行识别,并指定识别完成后的回调:DealingTextCallback
    m_ChatSettings.m_SpeechToText.SpeechToText(audioClip, DealingTextCallback);
}

真正的转换和发送逻辑,我们封装在另一个类里,比如叫XunfeiSTTService,它继承自一个ISpeechToText接口。这样设计的好处是,如果以后你想换用百度或阿里的服务,只需要换一个实现类,控制器代码完全不用动。

在XunfeiSTTService中:

public override void SpeechToText(AudioClip clip, Action<string> callback)
{
    // 1. 将AudioClip转换为PCM字节数组
    byte[] audioData = ConvertClipToBytes(clip);
    // 2. 启动协程发送数据(避免阻塞主线程)
    StartCoroutine(SendAudioDataCoroutine(audioData, callback));
}

private byte[] ConvertClipToBytes(AudioClip clip)
{
    // 获取所有采样点的数据(浮点数数组,范围在-1到1之间)
    float[] samples = new float[clip.samples * clip.channels];
    clip.GetData(samples, 0);

    // 将浮点数转换为16位整数(PCM格式)
    byte[] byteArray = new byte[samples.Length * 2]; // 16位 = 2字节
    for (int i = 0; i < samples.Length; i++)
    {
        // 将-1~1的浮点数映射到-32768~32767的短整型
        short value = (short)(samples[i] * 32767);
        byteArray[i * 2] = (byte)(value & 0xFF); // 低字节
        byteArray[i * 2 + 1] = (byte)((value >> 8) & 0xFF); // 高字节
    }
    return byteArray;
}

public IEnumerator SendAudioDataCoroutine(byte[] audioData, Action<string> callback)
{
    // 这里yield return null只是为了让方法变成协程,可以下一帧再执行连接,避免卡顿。
    yield return null;
    ConnectAndRecognize(audioData, callback);
}

ConvertClipToBytes是音频处理的一个小核心。Unity的AudioClip数据默认是float数组,而很多语音识别API要求的是16位有符号整数(PCM16)的字节流。这个转换过程就是做这个事的。当然,如果你的音频已经是单声道、16000Hz了,那直接转换就行。如果不是,可能还需要先做声道转换和重采样,这里为了简化先不提。

4.3 WebSocket通信与讯飞API对接

最“硬核”的部分来了——和讯飞服务器通信。我们使用System.Net.WebSockets命名空间下的ClientWebSocket类。整个过程是异步的(async/await),所以我们的ConnectAndRecognize方法也是async的。

private async void ConnectAndRecognize(byte[] audioData, Action<string> callback)
{
    ClientWebSocket webSocket = null;
    try
    {
        // 1. 建立WebSocket连接
        webSocket = new ClientWebSocket();
        // 这里需要生成带鉴权的WebSocket URL,具体生成方法参考讯飞文档
        Uri serviceUri = new Uri(GenerateAuthUrl());
        await webSocket.ConnectAsync(serviceUri, CancellationToken.None);

        // 2. 发送音频数据
        await SendVoiceDataAsync(webSocket, audioData);

        // 3. 接收识别结果(流式)
        StringBuilder recognizedText = new StringBuilder();
        while (webSocket.State == WebSocketState.Open)
        {
            ArraySegment<byte> buffer = new ArraySegment<byte>(new byte[4096]);
            WebSocketReceiveResult result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);

            if (result.MessageType == WebSocketMessageType.Close)
            {
                await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Close", CancellationToken.None);
                break;
            }

            // 处理接收到的数据
            byte[] receivedBytes = buffer.Array.Take(result.Count).ToArray();
            string jsonResponse = Encoding.UTF8.GetString(receivedBytes);

            // 解析JSON
            XunfeiResponse responseData = JsonUtility.FromJson<XunfeiResponse>(jsonResponse);
            if (responseData.code == 0) // 成功
            {
                // 从responseData中提取出本次识别到的片段文字
                string segment = ExtractTextFromResponse(responseData);
                recognizedText.Append(segment);
                // 可以在这里实时更新UI,实现“边说边转”的效果
                Debug.Log("实时结果: " + segment);
            }
            else
            {
                Debug.LogError($"识别错误,代码: {responseData.code}, 信息: {responseData.message}");
                break;
            }

            // 如果服务端表示这是最后一条消息,则结束循环
            if (responseData.data?.status == 2)
            {
                break;
            }
        }

        // 4. 识别完成,回调最终结果
        string finalResult = recognizedText.ToString();
        callback?.Invoke(finalResult);

    }
    catch (Exception ex)
    {
        Debug.LogError($"WebSocket通信异常: {ex.Message}");
        callback?.Invoke("识别失败,请重试");
    }
    finally
    {
        webSocket?.Dispose();
    }
}

private async Task SendVoiceDataAsync(ClientWebSocket socket, byte[] audioData)
{
    if (socket.State != WebSocketState.Open) return;

    // 构造请求数据体,格式需严格按照讯飞API文档
    PostData postData = new PostData
    {
        common = new Common { app_id = yourAppId },
        business = new Business { language = "zh_cn", domain = "iat", accent = "mandarin" },
        data = new Data
        {
            status = 2, // 2表示最后一帧音频
            format = "audio/L16;rate=16000",
            encoding = "raw",
            audio = Convert.ToBase64String(audioData) // 音频数据需要Base64编码
        }
    };

    string json = JsonUtility.ToJson(postData);
    byte[] jsonBytes = Encoding.UTF8.GetBytes(json);

    await socket.SendAsync(new ArraySegment<byte>(jsonBytes), WebSocketMessageType.Text, true, CancellationToken.None);
}

这段代码有几个需要特别注意的地方:

  1. 鉴权URL:GenerateAuthUrl()这个方法需要你根据讯飞的文档实现,核心是用你的API Key和Secret生成一个带签名的URL。网上有很多C#示例代码。
  2. 数据格式:PostData这个类里的字段名(common, business, data)和结构必须和讯飞API要求的一模一样。status字段很关键,通常发送第一帧是0,中间帧是1,最后一帧是2。
  3. 流式接收:while循环会持续接收服务器返回的数据包。讯飞是流式识别,你说一句话的过程中,它就可能返回多个中间结果(status=1),最后给一个最终结果(status=2)。我们用一个StringBuilder把它们拼起来。
  4. 错误处理:try-catch必不可少,网络请求随时可能出问题。一定要在finally块里关闭和释放WebSocket连接。

4.4 回到主线程:处理识别结果

当ConnectAndRecognize方法拿到最终识别文本finalResult后,它通过callback回调给了ChatController的DealingTextCallback方法。但这里有个Unity的重要规则:所有涉及UI更新的操作,必须在主线程执行。而WebSocket的回调很可能是在另一个线程触发的。

private void DealingTextCallback(string msg)
{
    // 确保在主线程更新UI
    // 可以使用MainThreadDispatcher插件,或者用以下方式:
    // Unity的API(如设置Text.text)本身是线程安全的,但为了保险和更好的控制,通常用以下方法:
    StartCoroutine(UpdateUITextCoroutine(msg));
}

private IEnumerator UpdateUITextCoroutine(string msg)
{
    yield return null; // 确保回到主线程的下一帧执行
    m_RecordTips.text = "识别成功!";
    m_InputWord.text = msg; // 将识别结果填入输入框

    // 如果你设置了“自动发送”,可以在这里触发发送
    if (m_AutoSend)
    {
        SendData(msg);
    }
}

我在这里加了一个StartCoroutine来更新UI,这是一种简单有效的确保在主线程操作的方法。m_AutoSend是一个布尔变量,如果为true,识别完就会自动把文字发送出去(比如模拟一个语音对话);如果为false,就只是把文字显示在输入框里,让用户确认后再手动发送。

5. 避坑指南与性能优化

功能跑通只是第一步,要让它在真实项目里稳定好用,还得注意下面这些我踩过的“坑”。

坑一:麦克风权限。 在移动平台(iOS/Android)上,首次使用麦克风会弹窗向用户请求权限。如果你的代码在用户授权前就调用了Microphone.Start,会直接失败。比较稳妥的做法是,在应用启动时或进入语音功能前,先检查并请求权限。Unity有Application.RequestUserAuthorization方法可以用。

坑二:音频格式 mismatch。 这是最常见的问题。讯飞API对音频格式有明确要求:单声道(Mono)、16000Hz采样率、PCM16编码。如果你用Microphone.Start录制的默认格式不符,就需要转换。可以在录音后,或者发送前,对AudioClip的数据进行重采样和声道混合。网上有很多音频处理的C#库可以帮忙。

坑三:网络状态与超时。 移动网络环境复杂,WebSocket连接可能不稳定。一定要设置连接超时和接收超时。可以在ConnectAsync和ReceiveAsync时传入带超时的CancellationToken。并且要做好重连机制,比如识别失败后,给用户一个“重试”的按钮。

坑四:内存与性能。 录音的AudioClip和转换后的字节数组都挺占内存的。记得在不需要的时候及时置为null,或者用using语句包裹。对于长时间语音输入,可以考虑分片发送音频数据,而不是等全部录完一次发,这样能降低延迟,也更符合“流式”的理念。

优化建议一:实现“边说边识”。 上面我们是一次性发送整个音频。其实讯飞流式API支持分帧发送。你可以在录音的同时,定时(比如每200ms)从Microphone获取当前音频缓冲区的数据,切成小段,实时发送。这样用户就能在说话的同时,看到文字一个一个跳出来,体验更好。这需要用到Microphone.GetPosition和更精细的音频数据切片逻辑。

优化建议二:加入VAD(语音活动检测)。 让用户一直按着按钮说话挺累的。可以集成一个简单的VAD算法,或者使用一些开源库,自动检测用户什么时候开始说话、什么时候停止。这样UI可以设计成“点击一次开始聆听,检测到静音自动结束并识别”,体验更人性化。

优化建议三:离线识别备选。 完全依赖网络在某些场景下(如弱网环境)体验不好。可以考虑集成一个轻量级的离线识别引擎作为备选。虽然离线识别精度一般不如云端,但能保证核心功能可用。这需要引入额外的插件或SDK,比如一些开源的移动端ASR引擎。

把上面这些点都考虑到,你的Unity语音识别功能就从一个“玩具”升级为一个真正能在产品中使用的“功能”了。整个过程从UI交互到网络通信,涉及了Unity开发的多个方面,走通一遍,你对Unity的理解肯定会加深不少。最重要的是,你获得了一个让应用变得更生动、更自然的强大工具。

更多推荐