Unity语音交互实战:从零搭建STT语音识别系统
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是用户交互的门面。对于语音输入,我们需要一个清晰的界面来引导用户。我的设计通常是这样:
- 一个文本输入框:用来显示最终识别出的文字,或者手动输入文字。
- 一个语音输入按钮:这是核心。用户长按它说话,松开就结束。按钮上最好有状态提示,比如默认显示“按住说话”,按下后变成“正在聆听...”。
- 一个状态提示文本:用来告诉用户当前在干什么,比如“请开始说话”、“正在识别中...”、“识别成功”。这个对于用户体验很重要,不然用户按了半天没反应,会以为功能坏了。
在Unity的UGUI里搭建这些很简单。关键是那个语音按钮,我们需要为它绑定按下(PointerDown) 和抬起(PointerUp) 两个事件。这里不能直接用Button组件的onClick,因为onClick是点击(按下并抬起)后才触发,不符合我们“按住说话”的需求。我们需要请出EventTrigger组件来帮忙。
2.3 理解核心数据流
在动手敲代码前,脑子里先过一遍数据是怎么流动的,这能帮你少走很多弯路。整个流程可以概括为:
用户界面层(UI) -> 控制层(Controller) -> 音频采集层(Recorder) -> 网络通信层(WebSocket Client) -> 云服务(讯飞服务器) -> 控制层 -> 用户界面层
- 用户按住UI按钮,触发
PointerDown事件。 - 控制层调用
开始录音方法。 - 音频采集层启动Unity的
Microphone类进行录音,将声音存为AudioClip。 - 用户松开按钮,触发
PointerUp事件。 - 控制层调用
停止录音方法,并拿到AudioClip。 - 控制层将
AudioClip转换为字节数组(通常是PCM格式),然后通过WebSocket连接发送给讯飞服务器。 - 讯飞服务器识别后,通过同一个WebSocket连接将识别结果的JSON数据流式地传回来。
- 控制层解析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);
}
这段代码有几个需要特别注意的地方:
- 鉴权URL:
GenerateAuthUrl()这个方法需要你根据讯飞的文档实现,核心是用你的API Key和Secret生成一个带签名的URL。网上有很多C#示例代码。 - 数据格式:
PostData这个类里的字段名(common,business,data)和结构必须和讯飞API要求的一模一样。status字段很关键,通常发送第一帧是0,中间帧是1,最后一帧是2。 - 流式接收:
while循环会持续接收服务器返回的数据包。讯飞是流式识别,你说一句话的过程中,它就可能返回多个中间结果(status=1),最后给一个最终结果(status=2)。我们用一个StringBuilder把它们拼起来。 - 错误处理:
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的理解肯定会加深不少。最重要的是,你获得了一个让应用变得更生动、更自然的强大工具。
更多推荐


所有评论(0)