ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity语音对话实战:阿里云流式合成与实时播放全链路解析

Unity语音对话实战:阿里云流式合成与实时播放全链路解析 1. 项目缘起与整体架构设计做过Unity语音交互项目的人大概都有这种体会录音不难难的是把录到的声音稳定、低延迟地送到云端做识别再把识别结果交给大模型生成回复最后把回复的文字实时合成语音播回来。整条链路涉及音频采集、网络传输、流式协议解析、音频解码播放四个环节任何一个环节出问题用户感受到的就是“卡顿”“没反应”“声音断断续续”。我这次的项目需求很明确在Unity客户端里实现一套完整的语音对话能力用户按住按钮说话松开后系统自动完成语音识别、智能回复、语音合成三个步骤最终以流式方式播放合成音频。之所以选择阿里云的语音AI能力核心原因是它的实时语音合成支持流式返回首包延迟可以控制在几百毫秒以内这对交互体验至关重要。如果等整段音频合成完再播放用户会明显感觉到“说完话之后要等好几秒才有回应”这种体验在对话场景里是不可接受的。整体架构我分成了三层采集层负责麦克风录音和音频格式转换传输层负责与云端建立连接、发送请求、接收流式数据播放层负责将接收到的音频数据实时解码并通过Unity的AudioSource播放出来。三层之间通过事件和队列解耦避免网络波动直接影响录音或播放。注意阿里云语音AI的接口调用需要提前在控制台开通对应服务并获取AccessKey建议使用RAM子账号并只授予语音服务的最小权限不要把主账号密钥硬编码在客户端里。1.1 为什么选择流式方案而不是一次性请求一次性请求的方案实现简单录完音把整段音频文件上传等云端返回完整结果。但问题在于延迟不可控。一段3秒的语音上传加上云端处理加上返回保守估计也要2到3秒。如果再加上大模型生成回复的时间用户等待5秒以上是常态。流式方案的核心优势在于边合成边播放。云端每合成一小段音频就立即推送给客户端客户端收到第一包数据就可以开始播放后续数据在播放过程中持续到达。这样用户感知到的延迟只有首包时间通常在300到800毫秒之间。代价是实现复杂度上升需要处理流式协议的分包、粘包问题以及播放缓冲的管理。我实测下来流式方案在4G网络下的首包延迟大约在600毫秒左右WiFi环境下可以降到300毫秒以内。这个延迟水平已经接近真人对话的响应速度用户基本感觉不到等待。1.2 音频格式的统一约定整条链路里最容易出问题的就是音频格式。Unity的Microphone类录出来的音频是PCM格式采样率取决于设备常见的是44100Hz或48000Hz声道数通常是1或2。而阿里云语音服务对输入音频有明确的格式要求采样率16000Hz、单声道、16位深、PCM编码。这意味着录音之后必须做重采样和声道混合。我试过直接在Unity里用Microphone录16000Hz但部分Android设备不支持这个采样率录出来的音频会变调。所以稳妥的做法是按设备支持的采样率录音然后在代码里做重采样。重采样算法我用的是线性插值虽然音质不如sinc插值但对于语音识别场景完全够用而且计算量小不会造成主线程卡顿。播放端的格式也要注意。阿里云实时语音合成返回的音频格式是可以指定的我选择的是PCM 16000Hz单声道这样和识别端的格式保持一致解码逻辑可以复用。如果选择MP3或Opus格式虽然传输数据量小但需要额外的解码库在Unity里集成起来比较麻烦。2. 麦克风录音与音频预处理的关键细节录音这块看起来简单实际上坑不少。Unity的Microphone类在不同平台上的行为差异很大尤其是Android和iOS的权限处理、采样率支持、录音延迟这几个方面需要逐一处理。2.1 录音设备的初始化与权限申请在Unity里调用Microphone.Start之前必须先确认两件事设备有麦克风可用以及应用已经获得了录音权限。Android平台上录音权限需要在AndroidManifest.xml里声明RECORD_AUDIO权限并且在运行时动态申请。iOS平台上需要在Info.plist里添加NSMicrophoneUsageDescription描述。我封装了一个简单的权限检查方法在录音前调用#if UNITY_ANDROID !UNITY_EDITOR if (!Permission.HasUserAuthorizedPermission(Permission.Microphone)) { Permission.RequestUserPermission(Permission.Microphone); yield return new WaitUntil(() Permission.HasUserAuthorizedPermission(Permission.Microphone)); } #endif录音设备的初始化我建议放在场景启动时做一次而不是每次录音都重新初始化。Microphone.Start的首次调用会有明显的延迟大概在200到500毫秒之间如果放在按下按钮的时候做用户会感觉按钮响应迟钝。我的做法是在游戏启动时用一个极短的静音录音预热一次后续录音直接复用设备。2.2 录音数据的读取与环形缓冲区设计Microphone.Start之后录音数据会写入一个AudioClip。读取数据的方式是调用AudioClip.GetData传入一个float数组。这里有个关键点GetData读取的是当前写入位置之前的数据如果读取速度跟不上写入速度旧数据会被覆盖。我的做法是维护一个环形缓冲区每帧从AudioClip里读取新增的采样数据写入环形缓冲区。录音结束时从环形缓冲区里取出完整的一段音频。环形缓冲区的大小根据最大录音时长来定比如最长录60秒16000Hz单声道16位那就是16000 * 60 * 2 1.92MB完全可以接受。// 每帧读取新增采样 int currentPos Microphone.GetPosition(deviceName); if (currentPos ! lastPos) { int sampleCount currentPos - lastPos; if (sampleCount 0) sampleCount clip.samples; float[] temp new float[sampleCount * clip.channels]; clip.GetData(temp, lastPos); ringBuffer.Write(temp); lastPos currentPos; }提示GetData的第二个参数是偏移量单位是采样帧而不是采样点。如果声道数是2那么一帧包含2个采样点。这个细节很容易搞错导致读出来的数据错位。2.3 重采样与声道混合的实现录完音之后需要把音频转换成阿里云要求的16000Hz单声道格式。重采样我用的是线性插值核心思路是对于目标采样率下的每一个采样点找到它在原始采样率下对应的位置然后用相邻两个采样点做线性插值。假设原始采样率是48000Hz目标采样率是16000Hz那么比例是3:1。目标第i个采样点对应原始的第i*3个采样点。如果比例不是整数比如44100到16000比例是2.75625那就需要计算浮点位置然后插值。float ratio (float)sourceRate / targetRate; for (int i 0; i targetCount; i) { float srcPos i * ratio; int srcIndex (int)srcPos; float frac srcPos - srcIndex; if (srcIndex 1 sourceCount) target[i] source[srcIndex] * (1 - frac) source[srcIndex 1] * frac; else target[i] source[srcIndex]; }声道混合更简单如果是双声道直接把左右声道求平均即可。但要注意有些设备的双声道数据是交错的即LRLRLR读取的时候要按帧处理。重采样之后还要把float数组转换成16位PCM的byte数组。float的范围是-1到1乘以32767然后取整即可。这里要注意clamp防止溢出。3. 与阿里云语音服务的网络通信实现网络通信这块是整个项目里最复杂的部分。阿里云的实时语音合成走的是WebSocket协议需要先建立连接然后发送请求再持续接收音频数据流。Unity里可以用System.Net.WebSockets.ClientWebSocket也可以用第三方库。我选择用ClientWebSocket因为它是.NET标准库的一部分不需要额外引入依赖。3.1 WebSocket连接的建立与鉴权阿里云语音服务的WebSocket地址需要带上鉴权参数。鉴权方式是在URL里带上tokentoken的生成需要用到AccessKey和AccessSecret。我建议在服务端生成token然后下发给客户端不要把AccessKey硬编码在客户端里。连接建立的过程是异步的需要await。在Unity里async/await是可以用的但要注意异常处理因为WebSocket连接失败的原因很多比如网络不通、token过期、服务未开通等。using var ws new ClientWebSocket(); ws.Options.SetRequestHeader(X-NLS-Token, token); await ws.ConnectAsync(new Uri(serviceUrl), CancellationToken.None);连接建立之后需要发送一个Start指令告诉云端要开始合成。Start指令是一个JSON包含音频格式、采样率、音量等参数。发送之后云端会返回一个Started事件表示准备就绪。3.2 流式数据的接收与分包处理WebSocket接收数据是以消息为单位的但阿里云返回的音频数据可能被拆分成多个消息。每个消息的前几个字节是头部包含消息类型和长度信息。需要先解析头部判断是音频数据还是控制指令。我封装了一个ReceiveLoop方法持续从WebSocket读取数据解析后分发给不同的处理器byte[] buffer new byte[8192]; while (ws.State WebSocketState.Open) { var result await ws.ReceiveAsync(new ArraySegmentbyte(buffer), token); if (result.MessageType WebSocketMessageType.Binary) { // 解析头部提取音频数据 int headerSize 4; int audioLength result.Count - headerSize; if (audioLength 0) { byte[] audioData new byte[audioLength]; Array.Copy(buffer, headerSize, audioData, 0, audioLength); audioQueue.Enqueue(audioData); } } }注意ReceiveAsync返回的result.Count可能小于实际消息长度如果消息很大需要循环接收直到result.EndOfMessage为true。这个坑我踩过导致音频数据不完整播放出来有杂音。3.3 发送文本请求与结束指令合成请求的发送很简单就是把要合成的文本通过WebSocket发过去。但要注意文本需要是UTF-8编码而且如果文本很长可能需要分多次发送。阿里云对单次合成的文本长度有限制超过限制需要分段合成。发送完文本之后要发送一个Stop指令告诉云端文本已经发完可以开始合成。如果不发Stop云端会一直等待更多文本不会返回音频数据。string textMessage JsonConvert.SerializeObject(new { header new { name Synthesis, namespace_ SpeechSynthesizer }, payload new { text textToSynthesize } }); await ws.SendAsync(Encoding.UTF8.GetBytes(textMessage), WebSocketMessageType.Text, true, token);4. 音频播放与实时流式处理播放这块的核心挑战是音频数据是流式到达的不能等全部到齐再播放但也不能来一包播一包因为网络抖动会导致播放断断续续。解决方案是维护一个播放缓冲区当缓冲区里的数据达到一定量时开始播放播放过程中持续向缓冲区追加数据。4.1 Unity AudioSource的流式播放方案Unity的AudioSource播放的是AudioClip而AudioClip需要预先填充数据。要实现流式播放有两种方案一种是用AudioClip.Create创建一个足够长的clip然后用SetData动态更新数据另一种是用OnAudioRead回调在回调里从缓冲区取数据填充。我选择的是OnAudioRead方案因为它更灵活不需要预先分配大块内存。AudioClip.Create的最后一个参数是一个AudioClip.PCMReaderCallback委托Unity会在需要音频数据时调用这个回调。AudioClip clip AudioClip.Create(streaming, sampleRate * 60, 1, sampleRate, true, OnAudioRead); void OnAudioRead(float[] data) { for (int i 0; i data.Length; i) { if (playBuffer.Count 0) data[i] playBuffer.Dequeue(); else data[i] 0f; // 缓冲区空时输出静音 } }这个方案的关键是缓冲区管理。如果缓冲区太小网络一抖动就断音如果太大延迟就高。我实测下来缓冲区保持在200到400毫秒的音频数据比较合适。低于200毫秒容易断高于400毫秒延迟明显。4.2 PCM数据到float数组的转换从WebSocket收到的音频数据是16位PCM的byte数组需要转换成float数组才能喂给AudioClip。转换公式很简单short值除以32768.0f。for (int i 0; i byteData.Length; i 2) { short sample BitConverter.ToInt16(byteData, i); float floatSample sample / 32768.0f; playBuffer.Enqueue(floatSample); }这里要注意字节序。阿里云返回的是小端序BitConverter在大多数平台上也是小端序所以直接转换没问题。但如果是在大端序平台上运行就需要手动处理字节序。4.3 播放状态管理与打断处理对话场景里用户可能会在AI说话的时候打断。这时候需要立即停止播放清空缓冲区并发送取消指令给云端。打断处理的逻辑是检测到用户按下录音按钮时如果当前正在播放就调用AudioSource.Stop清空playBuffer然后关闭当前的WebSocket连接。提示关闭WebSocket连接时最好发送一个Close帧而不是直接Abort。直接Abort可能导致云端认为连接异常影响后续的连接建立。播放结束的检测也很重要。当云端发送完所有音频数据后会发送一个SynthesisCompleted事件。收到这个事件后不要立即停止播放因为缓冲区里可能还有数据没播完。正确的做法是等缓冲区清空后再停止。5. 常见问题排查与实战避坑指南这个项目我在不同平台上跑了不下几十次遇到的问题五花八门。下面整理了几个最典型的附上排查思路和解决方法。5.1 录音无声或音量极低这个问题最常见原因通常有三个权限没给、设备选错了、采样率不匹配。排查顺序是先确认权限再确认Microphone.devices列表里有没有设备最后确认采样率是否被设备支持。Android平台上还有一个坑如果同时有其他应用在占用麦克风Unity这边录出来就是静音。这种情况下需要提示用户关闭其他应用。另外部分Android设备在蓝牙耳机连接时麦克风会切换到蓝牙设备但Unity的Microphone类可能识别不到需要手动处理音频路由。5.2 合成音频播放卡顿或断断续续卡顿的根源通常是缓冲区管理不当。如果OnAudioRead回调里取数据的速度跟不上消耗速度就会输出静音听起来就是断断续续。解决方法是增大缓冲区或者在网络接收线程里做预缓冲等缓冲区达到一定量再开始播放。另一个可能的原因是主线程卡顿。OnAudioRead是在音频线程上调用的但如果主线程在做大量计算音频线程可能被抢占导致回调不及时。这种情况下需要优化主线程性能或者把网络接收和数据处理放到子线程。5.3 WebSocket连接频繁断开连接断开的原因很多常见的有token过期、网络切换、服务端超时。token的有效期通常是24小时如果客户端长时间运行需要定期刷新token。网络切换比如WiFi切4G会导致连接断开需要监听网络状态变化并自动重连。我封装了一个重连机制检测到连接断开后等待1秒重新连接最多重试3次。如果3次都失败就提示用户检查网络。重连的时候要注意之前的合成任务已经中断需要重新发起。5.4 音频格式不匹配导致识别率低如果录制的音频格式和云端要求的不一致识别率会明显下降。最常见的错误是采样率不对比如录了44100Hz但告诉云端是16000Hz这样云端解析出来的音频是变调的识别结果自然一塌糊涂。排查方法是把录制的音频保存成wav文件用音频编辑软件打开确认采样率、声道数、位深是否和预期一致。我建议在开发阶段加一个调试开关把原始音频和重采样后的音频都保存下来对比。问题现象可能原因排查方法解决方案录音无声权限未授予检查权限状态动态申请权限录音变调采样率不匹配保存音频对比重采样到16000Hz播放卡顿缓冲区太小监控缓冲区长度增大到300ms以上连接断开token过期检查token有效期定期刷新token识别率低格式不对检查音频参数统一为16k单声道5.5 内存泄漏与资源释放长时间运行后内存持续增长通常是因为WebSocket连接没有正确关闭或者AudioClip没有释放。每次合成结束后要确保调用ws.Dispose()和AudioClip.Destroy()。另外环形缓冲区和播放队列在每次会话结束后要清空避免旧数据残留。我在项目里加了一个资源管理器统一管理WebSocket连接和AudioClip的生命周期。每次会话开始时创建结束时释放。这样即使频繁对话内存也能保持稳定。6. 性能优化与跨平台适配经验这套方案在PC上跑得很稳但移植到移动端之后遇到了不少新问题。移动端的网络环境更复杂设备性能差异也大需要针对性优化。6.1 移动端网络延迟优化移动端网络延迟普遍比PC高尤其是4G环境下RTT可能在50到100毫秒。为了降低首包延迟我做了两件事一是把WebSocket连接提前建立好在用户按下录音按钮之前就完成握手二是把文本请求的发送和录音结束的检测并行处理录音一结束立即发送请求不等音频数据完全处理完。另外阿里云的服务节点选择也很重要。如果客户端在国内选择华东或华北节点延迟更低。我实测下来同城节点延迟可以控制在20毫秒以内跨省节点在40到60毫秒之间。6.2 音频线程与主线程的协作Unity的音频线程和主线程是分开的OnAudioRead在音频线程上调用不能访问Unity的大多数API。所以播放缓冲区的读写需要加锁或者用无锁队列。我用的是ConcurrentQueue读写分别在音频线程和网络线程上进行不需要额外加锁。但要注意ConcurrentQueue的Enqueue和TryDequeue虽然线程安全但性能不如普通Queue。如果每帧入队的数据量很大可能会成为瓶颈。我的优化是批量入队网络线程收到数据后先攒在一个本地列表里攒够一定量再一次性入队。6.3 不同平台的采样率适配Windows和Mac上Microphone支持的采样率比较全16000Hz通常没问题。但Android和iOS上设备只支持特定的采样率常见的是44100Hz和48000Hz。所以移动端必须做重采样。我封装了一个AudioFormatHelper类根据平台自动选择最合适的录音采样率然后统一重采样到16000Hz。这样上层逻辑不需要关心平台差异拿到的永远是16k单声道的数据。提示iOS上还有一个坑AVAudioSession的category会影响录音和播放的互斥。如果category设置不当录音的时候播放会中断或者播放的时候录音会失败。建议设置为PlayAndRecord并允许蓝牙。6.4 电量与流量消耗的平衡流式合成虽然延迟低但流量消耗比一次性请求大因为每个音频包都有协议头开销。如果用户对流量敏感可以提供两种模式高质量模式用流式省流量模式用一次性请求。我实测下来流式合成的流量开销大约比一次性请求多15%到20%在可接受范围内。电量方面WebSocket长连接比短连接省电因为不需要反复握手。但如果长时间没有对话连接空转会消耗电量。我的做法是设置一个空闲超时超过5分钟没有对话就自动断开连接下次对话时再重连。7. 完整实现流程与核心代码结构把上面的内容串起来整个项目的实现流程可以分成六个步骤。下面我按实际编码顺序梳理一遍附上关键代码结构。7.1 初始化阶段设备检查与连接预热启动时做三件事检查麦克风设备、申请权限、预热WebSocket连接。预热连接的时候可以发送一个空的Start指令收到Started事件后保持连接等用户真正说话时直接发文本。async Task InitializeAsync() { // 检查设备 if (Microphone.devices.Length 0) throw new Exception(No microphone found); // 申请权限 yield return RequestMicrophonePermission(); // 预热连接 await ConnectWebSocketAsync(); await SendStartCommandAsync(); }7.2 录音阶段数据采集与缓冲用户按下按钮开始录音每帧从AudioClip读取新增数据写入环形缓冲区。松开按钮停止录音从缓冲区取出完整音频。void StartRecording() { lastPos 0; ringBuffer.Clear(); clip Microphone.Start(deviceName, false, maxDuration, sampleRate); isRecording true; } void Update() { if (!isRecording) return; int pos Microphone.GetPosition(deviceName); if (pos lastPos) return; // 读取新增数据写入环形缓冲区 ReadNewSamples(pos); lastPos pos; }7.3 预处理阶段重采样与格式转换录音结束后对环形缓冲区里的数据做重采样和声道混合转换成16000Hz单声道16位PCM。byte[] PreprocessAudio(float[] rawData, int sourceRate, int channels) { float[] mono MixToMono(rawData, channels); float[] resampled Resample(mono, sourceRate, 16000); return FloatToPCM16(resampled); }7.4 请求阶段发送识别与合成指令把预处理后的音频发送给语音识别服务拿到识别文本后再把文本发送给语音合成服务。这两个步骤可以串行也可以并行优化。async Taskstring RecognizeAsync(byte[] audioData) { await SendAudioAsync(audioData); await SendStopAsync(); return await WaitForRecognitionResultAsync(); } async Task SynthesizeAsync(string text) { await SendTextAsync(text); await SendStopAsync(); // 音频数据会在ReceiveLoop里持续到达 }7.5 播放阶段流式接收与实时播放ReceiveLoop在后台线程持续接收音频数据解析后写入播放缓冲区。OnAudioRead在音频线程从缓冲区取数据填充AudioClip。void OnAudioRead(float[] data) { int i 0; while (i data.Length playBuffer.TryDequeue(out float sample)) { data[i] sample; } // 剩余部分填静音 while (i data.Length) data[i] 0f; }7.6 清理阶段资源释放与状态重置每次对话结束后关闭WebSocket连接释放AudioClip清空缓冲区。如果用户连续对话可以复用连接只重置缓冲区。async Task CleanupAsync() { if (ws ! null ws.State WebSocketState.Open) await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, , token); ws?.Dispose(); if (clip ! null) AudioClip.Destroy(clip); playBuffer.Clear(); ringBuffer.Clear(); }8. 实际项目中的经验体会这套方案我在三个项目里用过从PC端到移动端从单机到联网踩过的坑基本都在这了。有几点体会比较深分享出来供参考。第一音频格式的统一比想象中重要。项目初期我为了省事录音用44100Hz识别用16000Hz结果识别率一直上不去。后来统一成16000Hz之后识别率提升了将近20个百分点。这个教训告诉我音频链路里的每一个环节都要严格对齐格式不能有侥幸心理。第二缓冲区大小需要根据实际网络调整。我在WiFi环境下测出来200毫秒缓冲区就够了但到了4G环境下200毫秒经常断音调到400毫秒才稳定。所以这个参数不要写死最好做成可配置的根据网络质量动态调整。第三打断处理是体验的关键。用户说话的时候AI还在播报如果不打断用户会觉得AI“不听话”。打断的逻辑要做得干脆立即停止播放、清空缓冲区、发送取消指令。我试过延迟100毫秒再打断用户就能明显感觉到“愣了一下”。第四异常处理要覆盖所有网络操作。WebSocket的ConnectAsync、SendAsync、ReceiveAsync都可能抛异常而且异常类型很多。我一开始只catch了WebSocketException结果遇到TaskCanceledException就崩了。后来改成catch所有Exception记录日志后走重连流程稳定性好了很多。最后分享一个小技巧在开发阶段把每次录制的原始音频和重采样后的音频都保存成wav文件方便对比排查。我写了一个简单的WavWriter把float数组写成标准wav文件用Audacity打开就能看到波形和频谱。这个工具帮我定位了好几个音频格式的问题比看日志直观多了。
返回列表