ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity集成ChatGPT打造UnityChan语音交互:网关、TTS与口型同步全解析

Unity集成ChatGPT打造UnityChan语音交互:网关、TTS与口型同步全解析 简介基于Unity实现的ChatGPTUnityChan语音交互展示项目是一套适合人工智能、通信、自动化、电子信息、物联网等专业学生及开发者学习参考的完整工程可用于课程设计、毕业设计或作为语音交互项目起步原型。压缩包共收录1954个文件整体约233.63MB涵盖cs脚本、prefab预制体、fbx模型、mat材质与png贴图以及controller、wav语音、shader、unitypackage等资源脚本负责对话与交互逻辑预制体和材质搭建场景表现音频与动画文件支撑语音反馈目录和文档结构便于按模块查阅。目前在站内已有78人学习浏览。资料内附带详细说明文档和完整源码项目经导师指导并通过答辩核心功能均测试通过可在此基础上二次开发对理解Unity与ChatGPT结合、语音交互落地流程及项目工程组织较有帮助。1. 从“能对话”到“像在对话”这个项目卡在哪如果只是让 ChatGPT 返回一段文字一个控制台脚本就够了但把 ChatGPT 和 UnityChan 放到同一个场景里做语音交互展示难度就从“调 API”变成了“对节奏”。麦克风采集、语音识别、大模型回复、TTS 合成、UnityChan 口型与身体动画这五条链路必须按序跑完中间任何一环延迟超过一两秒用户就会觉得对面是个迟钝的机器人。很多人拿到这类源码包后第一反应是去找 ChatGPT 的 Key 填进去结果发现声音出不来或者 UnityChan 嘴巴不动问题往往不在模型而在语音数据如何从线程安全地流进 Unity 的主循环。这篇内容就围绕“语音交互展示”这个核心把端到端的工程链路拆开讲覆盖网关设计、语音对时、动画驱动三个容易翻车的层面。适合打算在 Unity 里做 AI 虚拟助手、数字人展示或课程项目的开发者和技术负责人也适合想把一个能跑的 demo 改成可维护工程的人。2. 先搭网关ChatGPT 对话请求为什么不能直连 Unity2.1 直连的问题密钥暴露与流式响应难处理常见的错误做法是把 ChatGPT 的 API Key 直接写进 Unity 的 C# 脚本里。对于本地演示项目这确实能跑通但一旦项目打包发布成 WebGL 或桌面应用密钥会随包体分发任何人反编译都能拿到。更麻烦的是 HTTP 请求本身是阻塞式一问一答ChatGPT 接口返回完整内容往往要几秒用户在这段时间里只能干等Unity 主线程如果同步等待还会造成界面卡死。我一般会在 Unity 外面加一个本地网关服务用 WebSocket 和 Unity 通信。这个服务持有 API Key负责拼接请求、解析流式响应再按约定格式把结果推给 Unity。这样 Unity 侧不需要知道模型版本、密钥和请求格式只需要维护一条长连接收到消息后做展示。后续如果从 GPT-4 换成其他型号或者接入国内大模型Unity 端一行代码都不用改。2.2 消息协议设计用 type 区分事件类型WebSocket 是文本帧协议Unity 端和网关之间约定一套 JSON 消息格式。我习惯这样定义{ type: tts_audio, payload: { text: 你好我是 UnityChan, audio_base64: ...., session_id: abc-123 } }type字段是协议核心它决定了 Unity 收到消息后走哪条处理链路。常见类型包括tts_audioTTS 音频数据、chat_delta流式文字增量、chat_done本轮对话结束、error错误信息。用session_id做会话关联是用来防止异步回调乱序——用户连续说两句话时第一句的 TTS 可能还在生成第二句的回复已经推过来了没有会话 ID 就没法判断哪段音频该先播。协议不要做得太复杂能覆盖文本、音频、状态三类数据就够。文本用于在屏幕上显示对话气泡音频用于驱动 UnityChan 的语音播放状态则告诉 Unity 当前处于“思考中”还是“播报中”这样动画层才能跟着切换。2.3 Unity 侧 WebSocket 客户端的最小实现C# 侧可以用原生ClientWebSocket也可以用第三方库。对于 Unity 2021 以上版本我建议直接用原生实现减少依赖冲突using System; using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEngine; public class ChatGatewayClient : MonoBehaviour { public string gatewayUrl ws://127.0.0.1:8765/chat; private ClientWebSocket _ws; async void Start() { _ws new ClientWebSocket(); await _ws.ConnectAsync(new Uri(gatewayUrl), CancellationToken.None); _ ReceiveLoop(); } private async Task ReceiveLoop() { var buffer new byte[81920]; while (_ws.State WebSocketState.Open) { var result await _ws.ReceiveAsync(buffer, CancellationToken.None); if (result.MessageType WebSocketMessageType.Close) break; string json Encoding.UTF8.GetString(buffer, 0, result.Count); // 解析消息分发到对应处理函数 HandleMessage(json); } } public void SendText(string userInput) { var msg {\type\:\user_speech\,\payload\:{\text\:\ userInput \}}; var bytes Encoding.UTF8.GetBytes(msg); _ws.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); } }ReceiveLoop是核心Unity 的主线程在 Update 里跑渲染和动画WebSocket 的收包在异步线程上执行因此HandleMessage里绝不能直接修改场景对象。收到消息后应该把数据放进队列或者用UnityMainThreadDispatcher切回主线程再去创建 AudioClip、播放动画。很多 Unity 语音项目表现异常、断断续续根源就在这里——子线程改了 AudioSource 的状态Unity 在下一帧才发现冲突表现就是播一段卡一段。提示ReceiveAsync的 buffer 大小建议设大一些TTS 的音频 base64 字符串单帧可能超过 64KB。但注意 WebSocket 的文本帧可能分片到达严谨做法是自行拼接EndOfMessage。如果只是 demo用 8 万字节缓冲配合单帧完整发送也能工作。3. 语音链路麦克风采集与 TTS 合成怎么对时3.1 麦克风采集Unity Microphone 类的适用边界Unity 自带的Microphone类在 Windows 和 macOS 上够用采集参数这样设置比较稳妥public class MicRecorder : MonoBehaviour { private AudioClip _clip; public int sampleRate 44100; public int maxRecordTime 15; public void StartRecording() { if (Microphone.IsRecording(null)) return; _clip Microphone.Start(null, false, maxRecordTime, sampleRate); } public void StopRecordingAndSend() { if (!Microphone.IsRecording(null)) return; Microphone.End(null); float[] samples new float[_clip.samples * _clip.channels]; _clip.GetData(samples, 0); // 转成 WAV 字节流发送到网关做语音识别 } }maxRecordTime建议设为 10 到 15 秒太短用户话没说完就被截断太长又会拖慢交互节奏。sampleRate用 44100 是通用做法但如果语音识别服务限制 16k可在网关端做重采样不必在 Unity 端折腾。这里有一个关键的体验问题用户按下说话键到语音识别结果返回通常有 1 到 2 秒延迟。如果界面没有任何反馈用户会以为没录上。我一般会在开始录音时让 UnityChan 进入“聆听”状态比如眼睛看向用户、身体微微前倾这会极大缓解等待焦虑。这也是这个项目在“展示”层面的真正加分项比单纯把文字显示在屏幕上要有效得多。3.2 TTS 合成为什么优先选云端 TTS 而不是本地TTS 引擎的选择直接影响音色和延迟。本地 TTS比如 Windows 自带的 SAPI延迟低、无需联网但音色机械感强UnityChan 这种日系二次元形象配上机器音会非常违和。云端 TTS如 Azure 语音服务、OpenAI TTS 接口音色自然还能调整语速和情感但需要网络往返延迟在 300ms 到 1 秒之间。实际工程里我会在网关层做缓存相同文本的 TTS 结果存本地文件第二次请求直接返回。演示场景中用户常说的“你好”“你是谁”“介绍下自己”这些固定话术缓存命中后几乎零延迟体验会明显上一个档次。Unity 收到音频数据后如何处理播放和动画的对时是下一小节的关键。3.3 用 BlendShape 做轻量口型不引入 Lipsync SDK网上的很多做法是直接引入 SALSA 或 UMA Lipsync 这类插件但这类动画插件对新手有学习成本而且会引入额外的第三方依赖。对于 ChatGPT 语音交互这个场景口型不需要做到逐音素级别的精准只要能随语音音量起伏即可。UnityChan 的面部自带 BlendShapeBlend Shape其中最常用的是嘴巴相关的权重。using UnityEngine; public class LipSyncByVolume : MonoBehaviour { public AudioSource voiceSource; public SkinnedMeshRenderer faceMesh; [Range(1f, 20f)] public float sensitivity 8f; // 音量映射灵敏度 private float _smoothWeight; void Update() { if (!voiceSource.isPlaying) { CloseMouth(); return; } float[] spectrum new float[256]; voiceSource.GetSpectrumData(spectrum, 0, FFTWindow.BlackmanHarris); float amplitude 0f; for (int i 0; i spectrum.Length; i) amplitude spectrum[i]; amplitude / spectrum.Length; float target Mathf.Clamp01(amplitude * sensitivity); _smoothWeight Mathf.Lerp(_smoothWeight, target, 12f * Time.deltaTime); faceMesh.SetBlendShapeWeight(16, _smoothWeight * 100f); } void CloseMouth() { _smoothWeight Mathf.Lerp(_smoothWeight, 0f, 8f * Time.deltaTime); faceMesh.SetBlendShapeWeight(16, _smoothWeight * 100f); } }这里用GetSpectrumData取频域能量而不是GetVolume因为音量曲线跳变太快直接映射会让嘴巴像抽搐。sensitivity参数决定张嘴幅度UnityChan 默认面部模型上BlendShape 第 16 号通常是嘴巴张开mouth open相关的动画具体索引可以在 Inspector 里展开 SkinnedMeshRenderer 查看。不同版本的 UnityChan 模型索引可能有差异用之前先手动拖一个 Slider 验证。参数建议值作用sensitivity8控制声音到口型的放大倍数值越小张嘴越小Lerp 速度12口型跟随的平滑度太快会抖太慢会迟钝频谱窗口256采样点越多越精细但计算量也大BlendShape 索引16 或 17需按实际模型确认写死前务必验证提示如果播放和口型总是对不上先检查 AudioSource 的PlayDelayed或音轨缓冲。音频从网关到达 Unity 后最快解码方式是AudioClip.Create然后用SetData把 PCM 数据填进去。音频数据前段的空白字符会自动被 TTS 引擎忽略但这段空白会让口型提前开合工程上可以在音频数据里截掉前 50ms 空白。4. 驱动 UnityChanAnimator 分层与动态对话节奏4.1 官方 UnityChan 导入后必须做的两个设置从 Asset Store 或官方 GitHub 拉下来的 UnityChan 模型导入后直接拖进场景会出现两个问题材质变紫、动画不自然。材质变紫是因为项目默认渲染管线是 URP 或 HDRP而 UnityChan 用的是 Built-in 管线的 shader。解决方式是在 Project Settings 里把模型材质替换为Standard或者 URP 对应的 Lit shader这一步不做后面的所有动画都是白屏紫色。第二个必须做的是确认 Animator 的 Avatar 配置正确。UnityChan 自带 Animator Controller但如果模型从 FBX 重新导入Avatar 可能丢失。需要在模型导入面板的 Rig 页签下将 Animation Type 设为 Humanoid并点击 Configure 检查骨骼映射。这里有个省事技巧直接把整个UnityChan预制体拖进场景而不是重新从 FBX 去搭建预制体里已经配好了所有组件。4.2 上下半身分层播报时的自然姿态对话场景中 UnityChan 不可能一直播放同一个待机动画。如果整段角色动画只有一个 Idle Clip语音播报时会显得僵硬。我一般会把 Animator Layer 拆成两层Base Layer 控制下半身和整体位移Speak Layer 控制上半身的轻微呼吸起伏和手势。通过 Avatar Mask 实现public Animator chanAnimator; // 启用上半身说话层 chanAnimator.SetLayerWeight(1, 1f); chanAnimator.SetBool(IsSpeaking, true); // 播报结束 chanAnimator.SetLayerWeight(1, 0f); chanAnimator.SetBool(IsSpeaking, false);Animator 第 1 层默认 Base Layer 是第 0 层挂一个单独的 Speak 状态状态里放两个 Clip一个是呼吸动画一个是轻微歪头动作通过 Blend Tree 按随机权重混合。IsSpeaking为 true 时上部身体的呼吸运动会跟语音节奏形成“吸气—说话—放松”的循环。注意 Layer Weight 不要从 0 直接跳到 1用协程或 DOTween 做 0.2 秒的渐变过渡否则会出现上半身瞬移。4.3 动态语音时长下用参数驱动而不是 Clip 驱动对话场景的音频时长是完全动态的用户问一句长的ChatGPT 可能回复 30 秒的语音问一句短的可能 2 秒就结束了。如果想让 UnityChan 的“说话动画”恰好匹配这段语音不能用固定时长的 Animation Clip因为 Clip 播完就停了语音还在继续。正确做法是把动画当作“状态”而不是“片段”语音开始播放时设置IsSpeaking true语音结束后延迟 0.3 秒设置IsSpeaking false。更精细的做法是结合语音的音量实时微调动作幅度。例如 TTS 音频的平均音量高说明语气激昂此时让 UnityChan 的抬手动画权重加大音量低且平稳时保持微低头姿态。这需要先从音频中提取 RMS再映射到 Animator 的 float 参数public class AudioDrivenPose : MonoBehaviour { public AudioSource voiceSource; public Animator chanAnimator; void Update() { float rms ComputeRms(voiceSource); // SpeakPose 参数在 Animator 里驱动一个 BlendTree chanAnimator.SetFloat(SpeakPose, rms * 3f); } float ComputeRms(AudioSource source) { if (!source.isPlaying) return 0f; float[] samples new float[256]; source.GetOutputData(samples, 0); float sum 0f; for (int i 0; i samples.Length; i) sum samples[i] * samples[i]; return Mathf.Sqrt(sum / samples.Length); } }SpeakPose参数在 Animator 里驱动一个 1D BlendTree左侧是低头等待姿态右侧是抬手动作用这样就把音频的情绪强度自然转化为身体语言。它比单纯的 Bool 参数在高频变化场景下平滑得多也更容易调试。5. 把四段链路收敛进状态机事件总线替代回调地狱当 ASR、LLM、TTS、动画这四段流程都跑起来后最容易出现的问题是嵌套回调。用户一句“介绍一下你自己”流程会依次经过录音结束 → 发送网关 → 等待识别 → 等待 ChatGPT 回复 → 等待 TTS 合成 → 播放音频 → 动画复位。这个过程如果用async/await逐层调用任何一个环节报错都很难定位。我的做法是用一个极简静态事件总线把每个环节的完成事件广播出去再由一个总控状态机负责流转。public static class ChatEventBus { public static event System.Actionstring OnUserTextReady; public static event System.ActionAudioClip OnTTSReady; public static event System.Action OnError; public static void EmitUserText(string text) OnUserTextReady?.Invoke(text); public static void EmitTTSAudio(AudioClip clip) OnTTSReady?.Invoke(clip); }总控组件只需要订阅事件不需要知道是谁发起的public class ChatFlowController : MonoBehaviour { public MicRecorder mic; public ChatGatewayClient gateway; public AudioSource voiceSource; void OnEnable() { ChatEventBus.OnUserTextReady HandleUserText; ChatEventBus.OnTTSReady HandleTTSAudio; } async void HandleUserText(string text) { // 进入“思考中”动画状态 await gateway.SendAndWaitDone(text); } void HandleTTSAudio(AudioClip clip) { voiceSource.Stop(); voiceSource.clip clip; voiceSource.Play(); // 在此设置 Animator 的 IsSpeaking音频播完监听时间后复位 StartCoroutine(ResetAfterPlay(clip.length)); } }在这个状态机上我还会加一个超时保护网关 10 秒内没有返回任何消息强制复位所有状态避免 UnityChan 卡在“聆听”或“思考”姿态。状态机每切换一次就把当前状态写到 UI 上调试时能直观看到卡在哪一环。把项目从单体脚本升级为这种事件驱动结构后后续接任何新能力比如加表情切换、加聊天记录持久化都只需要新增一个事件订阅者不需要翻之前所有代码逻辑——这是这个类型项目最值得保留的架构技巧。本文还有配套的精品资源点击获取
返回列表