Unity接入百度语音识别:麦克风录音转文字完整指南
上周帮一个客户调Pico 4端的数字孪生项目需求是用户戴着VR头显说一句打开三号流水线系统就要切到对应设备的状态面板。一开始想用本地语音识别方案模型挑来挑去最小的也得几十MB起装进APK后整个包体积涨了300多MB一体机上加载还明显掉帧。后来换成百度智能云ASR思路彻底反转——麦克风录音上传云端几秒内返回文字结果Unity工程里只多了两个脚本加起来不到几十KB。今天把这个完整方案整理出来从账号准备、麦克风采集、音频格式转换到云端API调用所有代码都验证过照着抄5分钟能跑通。文章会覆盖一个完整的技术闭环Unity里怎么用Microphone采集声音、AudioClip怎么转成百度要求的WAV格式、Access Token怎么拿、短语音识别API怎么调以及我实际接入时踩过的各种坑。适合给Unity项目加语音指令控制、语音输入、VR/AR交互的开发者参考Unity 2021及以上版本通用。1. 为什么非要用Unity做语音转文字以及百度ASR选型的真实理由1.1 Unity语音交互的典型场景语音转文字在Unity项目里不是花活是实打实的功能需求。我接触过的常见场景有这么几类VR/AR交互Pico、Quest这类一体机上打字不方便语音是效率最高的指令入口。数字孪生项目里打开三号流水线切换夜班模式这类指令走语音远好过手柄点菜单。硬件联动不少开发者用Unity写上位机界面配合串口控制单片机。语音指令温度调高两度转成文字后再解析成串口命令下发交互会自然很多。会议纪要/内容工具仿生人、虚拟主播、培训考核类项目需要把用户的发言实时转成文字做字幕或内容审核。无障碍辅助一些适老化项目或者特殊人群工具语音输入比键盘输入友好得多。这些场景的共同点是用户手里有更重要的事操作设备、看场景、跟人交流键盘不是好选择语音是补充输入的最高效方式。1.2 本地识别和云API我的选型依据很多人第一反应是在Unity里集成本地语音识别模型比如whisper.cpp或者国人开源的PaddleSpeech。我做个直白的对比维度本地识别方案百度智能云ASR包体积模型动辄50-300MB无额外体积延迟低端设备推理慢高端设备尚可网络佳时约0.5-1.5秒返回离线可用可以不可以必须联网中文识别率小模型一般大模型吃配置专门优化过识别率较好开发成本要折腾模型转换、推理库绑定一个HTTP请求搞定算力占用录制时CPU/GPU都会受影响基本为零我的结论是如果项目必须完全离线运行那没得选只能上本地模型只要允许联网云API方案的开发效率和稳定性明显划算。尤其是原型验证阶段先跑通再优化永远是对的。1.3 百度ASR短语音识别接口选哪个百度智能云提供了好几个语音接口做Unity集成主要看这两个实时语音识别WebSocket和短语音识别HTTP。我的建议是短语音识别/server_api适合按下说话松开识别这类一段话不超过60秒的场景HTTP请求就能搞定实时语音识别适合需要边说话边出字的场景比如语音助手式的连续对话但要走WebSocketUnity里实现和维护成本都高不少。标题说的5分钟搞定用的是前者——短语音识别一次性把一整段录音发上去返回完整文字。游戏里做语音指令、语音问答这类需求基本够用。2. 三分钟准备百度智能云账号密钥、Unity工程与麦克风权限2.1 创建应用并获取API Key / Secret Key访问百度智能云控制台登录后搜索短语音识别开通服务。重点说下创建应用时要注意的地方进入控制台后找到语音技术的应用列表创建一个新应用。应用名称随意但接口类型里一定要选上短语音识别标准版否则后面调用时鉴权会提示没有权限。创建完成后页面上能看到API Key和Secret Key两个字符串复制保存好。这两个值就是整个接入的核心凭据泄露可能导致别人用你的配额项目代码里注意别提交到公开仓库。2.2 获取Access Token百度ASR所有接口都要求带token参数。这个token要拿API Key和Secret Key去换GET https://aip.baidubce.com/oauth/2.0/token?grant_typeclient_credentialsclient_id你的APIKeyclient_secret你的SecretKey返回的JSON里有access_token字段提取出来就是。Token有效期是30天所以不建议每次识别前都重新获取最好在游戏启动时获取一次缓存复用。注意token可以在多个接口间共用只要别把API Key和Secret Key直接暴露到客户端发给所有人就行。正式项目建议由自己的后端转发请求隐藏密钥。2.3 Unity工程配置与权限设置Unity版本建议2021.3 LTS或更新版本。低版本也能跑但UnityWebRequest的API行为差异大没必要给自己埋坑。在Unity中确认两件事第一设置麦克风权限。Android平台需要在Player Settings → Other Settings里勾选Microphone权限否则安卓上Microphone.Start会返回空Clip。iOS平台要在Info.plist里加NSMicrophoneUsageDescription否则首帧调用麦克风会直接闪退或静默失败。第二确认录音HTTP请求都走同一线程。Unity协程天然运行在主线程遇到网络请求用UnityWebRequest配合协程处理别开裸线程去搞音频数据回调。3. 录音模块的核心Microphone采集与AudioClip转WAV3.1 Microphone类基础用法Unity叫UnityEngine.Microphone访问麦克风非常简单AudioClip clip Microphone.Start(null, false, 30, 16000);四个参数分别是设备名传null用默认麦克风、是否循环录制false录一次就停、录制最大时长秒、采样率Hz。停止录音时调用Microphone.End(null);但这里有个特别坑的地方AudioClip的GetData只能在录音停止后正确获取到数据吗其实不完全是——Microphone.Start会立即返回一个可以写入数据的AudioClip在录音过程中就能GetData拿实时数据但通常我们做一段话的识别时都是等Microphone.End之后再取完整数据这样最稳。判断录音是否准备好可以用Microphone.IsRecording也可以等一帧再操作。3.2 WavUtility手写AudioClip转WAV的完整工具类百度短语音识别接口指定音频格式是pcm、wav或amr推荐用WAV。而Unity的AudioClip内部是浮点数PCM直接没法上传必须转成WAV字节流。一个容易自己写的WAV转换函数整理如下using System.IO; using UnityEngine; public static class WavUtility { public static byte[] ConvertToWav(AudioClip clip) { int sampleCount clip.samples * clip.channels; float[] rawSamples new float[sampleCount]; clip.GetData(rawSamples, 0); // 如果设备返回立体声先降混为单声道 float[] samples rawSamples; if (clip.channels 1) { samples new float[clip.samples]; for (int i 0; i clip.samples; i) { float sum 0f; for (int ch 0; ch clip.channels; ch) sum rawSamples[i * clip.channels ch]; samples[i] sum / clip.channels; } } int dataSize samples.Length * 2; // 16bit: 每个采样占2字节 int fileSize 36 dataSize; using (MemoryStream stream new MemoryStream()) using (BinaryWriter writer new BinaryWriter(stream)) { writer.Write(RIFF.ToCharArray()); writer.Write(fileSize); writer.Write(WAVE.ToCharArray()); writer.Write(fmt .ToCharArray()); writer.Write(16); // fmt块大小 writer.Write((short)1); // PCM格式 writer.Write((short)1); // 单声道 writer.Write(clip.frequency); // 采样率 writer.Write(clip.frequency * 2); // Byte rate 采样率 * 声道数 * 位深/8 writer.Write((short)2); // Block align 声道数 * 位深/8 writer.Write((short)16); // 位深 writer.Write(data.ToCharArray()); writer.Write(dataSize); for (int i 0; i samples.Length; i) { short pcm (short)(Mathf.Clamp(samples[i], -1f, 1f) * short.MaxValue); writer.Write(pcm); } writer.Flush(); return stream.ToArray(); } } }这里有两个关键细节为什么做降混部分手机麦克风采集回来是双声道而百度识别不认立体声WAV。不做降混直接传上去可能报音频参数错误。代码里通过clip.channels判断大于1就取平均合成单声道成本极低但能避开一个高频返工点。为什么位深选16bit百度ASR官方文档的示例都按16bit PCM来处理的这也是WAV最通用的格式。Unity的AudioClip里浮点数是[-1, 1]区间乘以short.MaxValue32767转成16bit整数注意Mathf.Clamp防止边界溢出。3.3 采样率16000Hz是什么讲究Microphone.Start第三个参数采样率我直接写16000。这是百度短语音识别文档里推荐的配置。8000Hz也能用但那是电话音质识别率掉得厉害44100Hz又是CD音质数据量变大识别效果不会更好徒增上传带宽和延迟。16000Hz是中文语音识别精度和体积的平衡点。如果设备不支持16000Hz采样率Microphone.Start会返回一个采样率不同的Clip。稳妥的方式是录制完检查clip.frequency字段如果跟预期不一致可以在转换时用插值重采样。不过实测绝大多数设备都能正常返回16000Hz直接忽略这步问题也不大。4. 百度ASR接入Token获取与短语音识别完整实现4.1 获取并缓存Access Token前面说了token有效期30天。Unity端完整实现如下using System; using System.Collections; using UnityEngine; using UnityEngine.Networking; [Serializable] public class TokenResponse { public string access_token; public int expires_in; public string error; public string error_description; } public class BaiduASR : MonoBehaviour { [SerializeField] private string apiKey 你的API Key; [SerializeField] private string secretKey 你的Secret Key; private string accessToken ; private IEnumerator FetchToken(Actionbool callback) { string url $https://aip.baidubce.com/oauth/2.0/token?grant_typeclient_credentialsclient_id{apiKey}client_secret{secretKey}; using (UnityWebRequest req UnityWebRequest.Get(url)) { yield return req.SendWebRequest(); if (req.result ! UnityWebRequest.Result.Success) { Debug.LogError($获取Token失败: {req.error}); callback?.Invoke(false); yield break; } TokenResponse resp JsonUtility.FromJsonTokenResponse(req.downloadHandler.text); if (!string.IsNullOrEmpty(resp.error)) { Debug.LogError($获取Token错误: {resp.error} - {resp.error_description}); callback?.Invoke(false); yield break; } accessToken resp.access_token; Debug.Log($Token获取成功有效期{resp.expires_in}秒); callback?.Invoke(true); } } }JsonUtility.FromJson是Unity自带的轻量JSON解析器对于这种单层结构的响应完全够用不需要额外引入Newtonsoft.Json或LitJson。但注意[Serializable]特性必须加上否则JsonUtility不认。4.2 组装短语音识别POST请求拿到token后核心的就是把base64化的WAV数据和参数一起POST到https://vop.baidu.com/server_api。请求体是JSON格式字段如下字段类型说明formatstring音频格式填wavrateint采样率填16000channelint声道数必须填1cuidstring用户唯一标识可用设备IDtokenstringAccess Tokenlenint音频数据的原始字节长度speechstring完整WAV数据base64编码后的字符串完整请求代码using System; using System.Collections; using System.Text; using UnityEngine; using UnityEngine.Networking; [Serializable] public class ASRRequestBody { public string format wav; public int rate 16000; public int channel 1; public string cuid; public string token; public int len; public string speech; } [Serializable] public class ASRResponse { public int err_no; public string err_msg; public string sn; public string[] result; } public partial class BaiduASR : MonoBehaviour { private IEnumerator Recognize(byte[] wavData, Actionstring onResult, Actionstring onError) { if (string.IsNullOrEmpty(accessToken)) { bool tokenOk false; yield return FetchToken(ok tokenOk ok); if (!tokenOk) { onError?.Invoke(Token获取失败无法识别); yield break; } } ASRRequestBody body new ASRRequestBody { cuid SystemInfo.deviceUniqueIdentifier, token accessToken, len wavData.Length, speech Convert.ToBase64String(wavData) }; string json JsonUtility.ToJson(body); byte[] postData Encoding.UTF8.GetBytes(json); using (UnityWebRequest req new UnityWebRequest(https://vop.baidu.com/server_api, POST)) { req.uploadHandler new UploadHandlerRaw(postData); req.downloadHandler new DownloadHandlerBuffer(); req.SetRequestHeader(Content-Type, application/json); yield return req.SendWebRequest(); if (req.result ! UnityWebRequest.Result.Success) { onError?.Invoke($网络错误: {req.error}); yield break; } ASRResponse resp JsonUtility.FromJsonASRResponse(req.downloadHandler.text); if (resp.err_no 0 resp.result ! null resp.result.Length 0) { onResult?.Invoke(resp.result[0]); } else { onError?.Invoke($识别失败 err_no{resp.err_no}, err_msg{resp.err_msg}); } } } }注意几个细节Convert.ToBase64String(wavData)把整个WAV文件字节流转成base64字符串。WAV本身比PCM多44字节头但这不影响识别因为格式是wav百度会根据头信息解析。Encoding.UTF8.GetBytes(json)UnityWebRequest默认不会帮你做UTF8编码必须手动转字节数组否则中文参数会乱码。SystemInfo.deviceUniqueIdentifier用作cuid。这个字段标识客户端论文档要求格式建议机器唯一标识MAC地址或者机器ip地址实际用设备ID即可。4.3 响应解析与错误码速查响应格式固定为{ err_no: 0, err_msg: success., sn: 1234567890, result: [识别出来的文字内容] }result是数组但短语音识别结果一般只有一个元素。判断成功条件一定是err_no 0不要只判断result ! null。实际接通过程中可能遇到下面这些错误码err_no含义常见原因3300输入参数不正确缺少token/cuid/format等必填字段3301音频质量过差录音太短或设备静音3302音频过长录音超过60秒3304base64解码失败speech字段不是合法base643305音频格式不支持位深、采样率与参数不一致3307token无效或过期重新获取token3314语音过长同上33023315文件过大音频超过2MB检查采样率或时长调通之前可以先自己用Audacity录一段16kHz单声道WAV用在线工具转成base64测试下接口排除Unity侧问题。5. 5分钟跑通整合完整可运行脚本5.1 完整对话流程脚本把前面几块拼在一起加一个简单的UI入口就是完整可运行的实现。下面这个脚本可以直接挂到场景空物体上using System; using System.Collections; using System.IO; using System.Text; using UnityEngine; using UnityEngine.Networking; public class VoiceRecognitionDemo : MonoBehaviour { [Header(百度智能云密钥)] public string apiKey 你的API Key; public string secretKey 你的Secret Key; [Header(识别回调)] public string recognizedText ; private AudioClip recordedClip; private bool isRecording false; private string accessToken ; void OnGUI() { GUILayout.Space(20); GUILayout.Label(语音识别状态: (isRecording ? 录音中... : 空闲)); GUILayout.Label(识别结果: recognizedText); if (!isRecording) { if (GUILayout.Button(开始说话, GUILayout.Width(200), GUILayout.Height(60))) { StartCoroutine(StartRecordCoroutine()); } } else { if (GUILayout.Button(停止并识别, GUILayout.Width(200), GUILayout.Height(60))) { StopAndRecognize(); } } if (GUILayout.Button(重置, GUILayout.Width(200), GUILayout.Height(40))) { recognizedText ; } } private IEnumerator StartRecordCoroutine() { // 确保token已拿到 if (string.IsNullOrEmpty(accessToken)) { yield return FetchToken(); } if (Microphone.devices.Length 0) { recognizedText 没有检测到麦克风设备; yield break; } recordedClip Microphone.Start(null, false, 30, 16000); isRecording true; } private void StopAndRecognize() { Microphone.End(null); isRecording false; if (recordedClip null) { recognizedText 录音失败Clip为空; return; } byte[] wavData WavUtility.ConvertToWav(recordedClip); StartCoroutine(Recognize(wavData, result recognizedText result, error recognizedText 错误: error)); } private IEnumerator FetchToken() { string url $https://aip.baidubce.com/oauth/2.0/token?grant_typeclient_credentialsclient_id{apiKey}client_secret{secretKey}; using (UnityWebRequest req UnityWebRequest.Get(url)) { yield return req.SendWebRequest(); if (req.result UnityWebRequest.Result.Success) { TokenResponse resp JsonUtility.FromJsonTokenResponse(req.downloadHandler.text); if (string.IsNullOrEmpty(resp.error)) { accessToken resp.access_token; } else { recognizedText Token错误: resp.error; } } else { recognizedText Token请求失败: req.error; } } } private IEnumerator Recognize(byte[] wavData, Actionstring onSuccess, Actionstring onError) { ASRRequestBody body new ASRRequestBody { cuid SystemInfo.deviceUniqueIdentifier, token accessToken, len wavData.Length, speech Convert.ToBase64String(wavData) }; string json JsonUtility.ToJson(body); byte[] postData Encoding.UTF8.GetBytes(json); using (UnityWebRequest req new UnityWebRequest(https://vop.baidu.com/server_api, POST)) { req.uploadHandler new UploadHandlerRaw(postData); req.downloadHandler new DownloadHandlerBuffer(); req.SetRequestHeader(Content-Type, application/json); yield return req.SendWebRequest(); if (req.result ! UnityWebRequest.Result.Success) { onError?.Invoke(req.error); yield break; } ASRResponse resp JsonUtility.FromJsonASRResponse(req.downloadHandler.text); if (resp.err_no 0 resp.result ! null resp.result.Length 0) { onSuccess?.Invoke(resp.result[0]); } else { onError?.Invoke($err_no{resp.err_no}, err_msg{resp.err_msg}); } } } } [Serializable] public class TokenResponse { public string access_token; public int expires_in; public string error; public string error_description; } [Serializable] public class ASRRequestBody { public string format wav; public int rate 16000; public int channel 1; public string cuid; public string token; public int len; public string speech; } [Serializable] public class ASRResponse { public int err_no; public string err_msg; public string sn; public string[] result; } public static class WavUtility { public static byte[] ConvertToWav(AudioClip clip) { float[] rawSamples new float[clip.samples * clip.channels]; clip.GetData(rawSamples, 0); float[] samples rawSamples; if (clip.channels 1) { samples new float[clip.samples]; for (int i 0; i clip.samples; i) { float sum 0f; for (int ch 0; ch clip.channels; ch) sum rawSamples[i * clip.channels ch]; samples[i] sum / clip.channels; } } int dataSize samples.Length * 2; int fileSize 36 dataSize; using (MemoryStream stream new MemoryStream()) using (BinaryWriter writer new BinaryWriter(stream)) { writer.Write(RIFF.ToCharArray()); writer.Write(fileSize); writer.Write(WAVE.ToCharArray()); writer.Write(fmt .ToCharArray()); writer.Write(16); writer.Write((short)1); writer.Write((short)1); writer.Write(clip.frequency); writer.Write(clip.frequency * 2); writer.Write((short)2); writer.Write((short)16); writer.Write(data.ToCharArray()); writer.Write(dataSize); for (int i 0; i samples.Length; i) { short pcm (short)(Mathf.Clamp(samples[i], -1f, 1f) * short.MaxValue); writer.Write(pcm); } writer.Flush(); return stream.ToArray(); } } }5.2 场景搭建三步走创建空物体命名为VoiceRecognitionDemo挂上VoiceRecognitionDemo脚本。在Inspector里填入你在百度智能云拿到的API Key和Secret Key。运行点开始说话说完点停止并识别两三秒后屏幕上的recognizedText就是识别结果。就是这么简单。用OnGUI画按钮是为了避免你还要先搭一套UGUI环境真正集成时把这几个按钮换成你项目的UI控件即可。整个脚本没有任何第三方依赖纯Unity引擎API。5.3 测试时怎么判断系统是否正常工作分三阶段看录音阶段点击开始说话后状态显示录音中...电脑/手机的麦克风指示灯亮起说明Microphone设备和权限正常。网络阶段点停止后如果进度条或日志长时间没反应多半是网络不通检查防火墙和目标服务器可达性。识别阶段看Debug日志里的返回值。err_no0且result有内容说明链路全通。如果报3305格式错误基本就是WAV文件头和请求参数不一致对照4.3的错误码表一把梭。6. 实战中踩过的坑Token缓存、音频格式与平台差异6.1 Token过期带来的隐蔽Bug接口报3307或者token invalid第一时间想的是不是token过期了。Access Token有效期30天但Unity项目跑在用户手机上手机时间不准会导致本地判断失效吗不会token校验完全在服务端本地缓存多久都行。但我在实际项目里踩过另一个坑把token存在静态变量里编辑器模式和打包后的真机模式切换时因为时间不同步或者API Key不一致拿到的是空token或者旧token。最稳妥的做法是每次启动时都先调用一次FetchToken刷新缓存不要指望持久化存储里的token能一直用。6.2 音频数据过大接口不报格式错报大小错百度短语音识别限制音频时长60秒、文件大小2MB。如果你用44100Hz双声道录音转WAV30秒可能就接近5MB直接被3315拒掉。这也是为什么我一直强调16000Hz单声道——它不仅是识别率平衡点更直接决定了你的请求不会被文件体积卡死。6.3 平台差异移动端和WebGL的真实表现我在Pico 4一体机和Android真机上实测Microphone采集16000Hz单声道表现稳定。但有两个额外要注意的Android 12及以上系统对麦克风权限更严除了Manifest里声明RECORD_AUDIO还需要运行时权限申请。Unity的Android Player Settings勾选Microphone后一般会自动申请但部分国产ROM需要手动去应用设置里确认授权。WebGL平台Unity WebGL对Microphone的支持跟浏览器版本强绑定。Chrome较新版本能正常采集但在Safari上经常拿不到真实采样率或直接弹权限框失败。如果目标平台是WebGL建议先做一个极简WebRTC数据采集中间层绕过Unity的Microphone封装成本可控。6.4 调试录音数据的一个小技巧判断录音本身有没有问题最直接的办法是本地回放验证AudioSource.PlayClipSAtPoint(recordedClip, Vector3.zero);把录音Clip放出来听一遍。如果播放有声音但识别结果为空问题大概率在WAV编码或参数上如果播放也没声音那是录音源头的问题往麦克风权限和设备方向排查别在API层浪费精力。6.5 生产环境的架构建议最后提一个很多人忽略的点API Key和Secret Key千万别硬编码在终端应用里。做原型demo无所谓但正式上线的项目里客户端直接持有密钥意味着任何人都可以从你的包里反编译掏出来接着刷爆你的免费配额。正确姿势是加一个极简的服务端中转客户端把录音POST给自建后端后端带密钥调百度API再把结果给回客户端。加这一层不只是安全考虑也方便后续做识别日志、限流、多用户权限管理。Unity端代码完全不用变改一下请求地址就行。说实在的从需求确认到跑通整个流程我第一版在这个方案上花了不到一天其中一半时间是花在理解WAV格式和文档纠错上。代码层面真正核心的就三件事录音、转格式、发请求。如果你也是Unity开发者不妨先把这个脚本丢进工程跑通一次再往你的业务逻辑里迁会顺手很多。后面如果你们项目有实时连续识别的需求新的文章里可以再聊WebSocket方案怎么接。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →