简介:基于Unity实现ChatGPT与UnityChan语音交互展示的完整项目,面向人工智能、通信工程、自动化、电子信息、物联网等专业的学生与从业者,也适合作为毕业设计、课程设计或项目初期演示,同时兼顾Unity和AI方向的进阶学习。压缩包共1954个文件,大小约233.63MB,包含C#脚本、FBX模型、Prefab预制体、材质与动画控制器、贴图、WAV音频等,并附有Shader、UnityPackage、PDF文档及语音交互相关组件,覆盖从模型到交互的完整资源链路。当前已有78人学习下载。该项目为高分源码,已通过导师指导认可和测试运行,评审答辩分达95分;附带详细文档与全部源码,目录清晰,可直接运行,也方便二次扩展,是语音交互实战与毕设参考的完整方案。
1. 基于 Unity 的 ChatGPT+UnityChan 语音交互链路与交付物定位
把 ChatGPT 的回复放到一个会眨眼、能点头、张嘴说话的 UnityChan 身上,这个标题至少包含三层技术:Unity 的动画与音频链路、ChatGPT 的接口接入,以及中间最容易被低估的语音交互编排。所谓“语音交互展示”,完整链路是:Unity 里采集麦克风音频,转成文字后交给 ChatGPT,拿到回复再合成语音播放,同时驱动 UnityChan 的表情与口型。任何一个环节配置不对,演示就会停在“AI 没反应”上。这篇博客按工程化的顺序拆这条链路,覆盖最小可运行的代码、参数设置和排错手法,适合 Unity 开发者做 AI 数字人原型,也适合产品或独立开发者快速验证大模型加角色动画的方案。如果你是第一次接触这类项目,建议先把第 2 章的最小脚本跑通,再进 Unity 工程。
2. ChatGPT 接口的最小调用与 Unity 侧的工程边界
任何带语音交互的数字人项目,第一步都不是写 Unity 代码,而是先把 ChatGPT 的服务端调用跑通。原因很直接:Unity 里的报错往往混在引擎日志里,很难分清到底是网络问题、Key 问题还是模型参数问题。我一般会先用 Python 或 curl 验证一遍,确认接口本身没问题,再往 Unity 工程里搬。
2.1 先拆链路:哪些编排放 Unity,哪些放服务端
语音交互展示常见的模块划分如下,这个划分决定了工程里要写多少网络代码:
| 链路阶段 | 建议位置 | 工程要点 |
|---|---|---|
| 麦克风采集 | Unity 本地 | 使用 Microphone 类,注意采样率和录音时长 |
| 语音转文字(STT) | 云端服务 | 接收 WAV 字节流,返回文本 |
| 对话生成(ChatGPT) | 云端服务 | 用 HTTP 请求,超时和限流要单独处理 |
| 文字转语音(TTS) | 云端服务 | 返回音频直链或音频字节流 |
| 角色播放与表情口型 | Unity 本地 | 音频播放、Animator 切换、BlendShape 驱动 |
把编排逻辑放在 Unity 里,能让“用户说完话到角色开口”的整个状态机掌握在一处,方便打断和恢复。云端只做单点能力输出,不维护会话状态。演示类项目这样拆最省事,也最容易排错。
2.2 用 Python 先跑通最小的 ChatGPT 调用
在打开 Unity 之前,先用这个脚本确认 API Key、网络和模型参数都正常。这不是多余的步骤,后面 Unity 里报错时,它能帮你把问题锁定在网络层而不是引擎层。
import requests API_KEY = "sk-xxxxxxxxx" def ask_chatgpt(user_text: str, system_prompt: str) -> str: url = "https://api.openai.com/v1/chat/completions" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", } payload = { "model": "gpt-4o-mini", "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_text}, ], "temperature": 0.7, "max_tokens": 200, } resp = requests.post(url, headers=headers, json=payload, timeout=30) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"].strip() if __name__ == "__main__": print(ask_chatgpt("做一个 10 秒钟的自我介绍", "你是UnityChan,语气活泼简短。"))这个脚本里只有 3 个参数需要关注。temperature控制随机性,数字人演示建议 0.7 左右,太高回复会飘,太低则机械;max_tokens设为 200,能限制回复长度,避免 TTS 合成出一段 3 分钟的独白;timeout设 30 秒,防止网络抖动时脚本无限挂起。如果这一步返回 401,先检查 Key 是不是完整复制;返回 429,说明被限流,等几分钟再试,不要立刻改代码。
2.3 Unity 侧请求:协程、手动拼 JSON 与解析
Python 脚本通了之后,把逻辑翻译成 Unity 的 C# 协程。Unity 里的UnityWebRequest配合协程用最顺手,既不会卡主线程,又能拿到完整的请求结果。这里有个常见的坑:JsonUtility不支持序列化Dictionary,所以请求体我一般手动拼接,再用Newtonsoft.Json解析响应。
using System; using System.Collections; using UnityEngine; using UnityEngine.Networking; using Newtonsoft.Json.Linq; public class ChatGPTClient : MonoBehaviour { [Header("OpenAI 配置")] public string apiKey = ""; public string model = "gpt-4o-mini"; public string systemPrompt = "你是UnityChan,请用活泼、简短的语气回答问题,每句不超过30字。"; public IEnumerator Ask(string userText, Action<string> onResult) { string json = BuildJson(userText); using (UnityWebRequest req = new UnityWebRequest( "https://api.openai.com/v1/chat/completions", "POST")) { req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json)); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "application/json"); req.SetRequestHeader("Authorization", "Bearer " + apiKey); req.timeout = 30; yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { Debug.LogError($"ChatGPT 请求失败: {req.responseCode} {req.error}"); onResult?.Invoke(""); yield break; } string content = ParseContent(req.downloadHandler.text); onResult?.Invoke(content); } } private string BuildJson(string userText) { // 手动拼 JSON,避开 JsonUtility 不支持 Dictionary 的问题 string msg = "[{\"role\":\"system\",\"content\":\"" + systemPrompt.Trim() + "\"},{\"role\":\"user\",\"content\":\"" + userText.Trim() + "\"}]"; return "{\"model\":\"" + model + "\",\"messages\":" + msg + ",\"temperature\":0.7,\"max_tokens\":200}"; } private string ParseContent(string json) { JObject obj = JObject.Parse(json); return obj["choices"]?[0]?["message"]?["content"]?.ToString() ?? ""; } }这里手动拼接 JSON 只适合原型阶段,因为userText里如果带了双引号或换行符,会把整个请求体打断。正式项目里建议改用JObject构造 messages 数组,再序列化成字符串。Newtonsoft.Json可以用 Unity Package Manager 安装包com.unity.nuget.newtonsoft-json,不需要额外找第三方来源。协程回调里的onResult是在主线程执行的,可以直接操作 UnityChan 的动画和 UI,这点比写后台线程安全得多。
2.4 接入 ChatGPT 时最常见的 4 个失败点
原型项目里 90% 的“AI 没反应”不是逻辑问题,而是下面四类:
401 / 403:API Key 无效或已过期,先用第 2.2 节的 Python 脚本验证,别在 Unity 里反复试。429 限流:请求太频繁或额度用完,代码里加重试逻辑,退避 1 到 3 秒再请求一次。请求超时:req.timeout = 30是一个合理值,连续超时先查网络连通性,不要盲目扩大超时时间。模型标识不被识别:model字段必须写成当前环境支持的完整标识。如果你同时用 ChatGPT 桌面端,遇到类似“无法加载 config.toml”的提示,多半是配置文件里的 model 字段被改成不存在的值,改回官方支持的模型名即可。
3. 在 Unity 工程里接 UnityChan:麦克风采集、WAV 编码与对话请求
ChatGPT 通道打通之后,回到 Unity。UnityChan 的导入和场景搭建一般会有资源包自带的说明,但语音采集链路往往没人讲清楚。这里需要完成三件事:让麦克风真正录到音频、把 AudioClip 编码成 WAV 字节、再把录到的语音送进对话流程。
3.1 UnityChan 资源导入后先确认这 3 个东西
UnityChan 从资源包导入后,不要急着写代码,先在 Hierarchy 面板里确认三件事。第一,Animator 组件的 Controller 是否正确绑定,默认状态下模型应播放 Idle 动画。第二,模型上必须有 SkinnedMeshRenderer,且 BlendShape 列表里能看到 MTH_A、MTH_I、MTH_U、MTH_E、MTH_O 这类口型名称,不同版本命名有差异,记下你项目里实际的名称。第三,模型 root 节点的位置和旋转归零,避免动画播放时出现偏移。
要验证 Animator 状态,用下面一段挂在模型上的脚本就够了:
using UnityEngine; public class AnimatorDebug : MonoBehaviour { void Update() { if (Input.GetKeyDown(KeyCode.T)) { Animator anim = GetComponent<Animator>(); // 打印当前状态,确认动画系统在工作 Debug.Log(anim.GetCurrentAnimatorStateInfo(0).IsName("Idle")); } } }我一般会用这个脚本确认动画状态切换正常,再开始接语音,否则后面口型动了但模型没反应,很难判断是动画问题还是 BlendShape 问题。
3.2 麦克风采集:开始录音、停止录音与录音时长
Unity 的Microphone类是一个统一的录音入口,但很多人会在采样率上踩坑。Microphone.Start的最后一个参数是采样率,16kHz 是大多数语音识别服务的通用输入,但部分设备不支持直接录 16kHz,会返回空 AudioClip 或不工作。稳妥做法是先按设备默认频率录制,编码时再降采样,或者直接使用 16kHz,失败则回退到 44100。
using UnityEngine; public class MicrophoneCapture : MonoBehaviour { private AudioClip clip; private string selectedDevice; public bool StartRecord(int maxSeconds = 10, int sampleRate = 16000) { if (Microphone.devices.Length == 0) { Debug.LogError("没有检测到麦克风设备"); return false; } selectedDevice = Microphone.devices[0]; // maxSeconds 限制单次录音长度,避免误触导致无限录音 clip = Microphone.Start(selectedDevice, true, maxSeconds, sampleRate); return clip != null; } public float[] StopAndGetSamples() { if (clip == null) return new float[0]; if (selectedDevice != null) Microphone.End(selectedDevice); float[] samples = new float[clip.samples * clip.channels]; clip.GetData(samples, 0); return samples; } }maxSeconds控制在 10 秒左右比较合理,太长用户不知道什么时候该停,太短又来不及说完一句话。采样率先用 16000,如果发现录出来的声音明显变调,说明设备不支持这个采样率,换 44100 再录。
3.3 WAV 编码:把 AudioClip 变成接口能识别的字节流
语音识别服务不会直接收 AudioClip,它们要的是标准 WAV 或 PCM 字节流。下面这个工具类能把 float 数组封装成 16bit 单声道 WAV,这是识别服务通用性最好的格式。
using System.IO; using System.Text; using UnityEngine; public static class WavUtility { public static byte[] Encode(float[] samples, int sampleRate, int channels = 1) { using MemoryStream ms = new MemoryStream(); using BinaryWriter bw = new BinaryWriter(ms, Encoding.ASCII); int dataLen = samples.Length * 2; int byteRate = sampleRate * channels * 2; bw.Write(Encoding.ASCII.GetBytes("RIFF")); bw.Write(36 + dataLen); bw.Write(Encoding.ASCII.GetBytes("WAVE")); bw.Write(Encoding.ASCII.GetBytes("fmt ")); bw.Write(16); // fmt 块大小 bw.Write((short)1); // PCM 编码 bw.Write((short)channels); bw.Write(sampleRate); bw.Write(byteRate); bw.Write((short)(channels * 2)); bw.Write((short)16); // 16bit bw.Write(Encoding.ASCII.GetBytes("data")); bw.Write(dataLen); foreach (float s in samples) { // Clamp 防溢出,再转 short 写入 short pcm = (short)(Mathf.Clamp(s, -1f, 1f) * short.MaxValue); bw.Write(pcm); } return ms.ToArray(); } }WAV 头部的 44 字节结构是固定的,byteRate、blockAlign、bitsPerSample必须和实际写入的数据一致,否则 SoundCloud 这类工具能打开,但识别服务会直接报错。编码完成后,把byte[]转成 Base64 字符串,放进 STT 接口的请求体里;如果服务要求multipart/form-data,则改成对应字段发送。
3.4 从用户说完到“请求发出”的完整流程
把上面的零件拼成一个流程控制器。按钮按下开始录音,松开或再次点击停止录音,然后编码、送 STT、拿文本,再调用第 2 章的 ChatGPTClient,最后进入 TTS 播放流程。
public class ConversationFlow : MonoBehaviour { public MicrophoneCapture mic; public ChatGPTClient chat; public void OnStartTalking() { mic.StartRecord(10, 16000); } public async void OnStopTalking() { float[] samples = mic.StopAndGetSamples(); byte[] wav = WavUtility.Encode(samples, 16000); // 这里把 wav 交给你的 STT 服务,拿到文本 userText string userText = await CallSttAsync(wav); // 再交给 ChatGPT,拿到回复后进入 TTS 环节 StartCoroutine(chat.Ask(userText, reply => OnReplyReady(reply))); } }这个片段里CallSttAsync需要你自己实现,不同语音识别服务的鉴权和请求体差异较大,没有统一写法。关键点是:STT 和 TTS 都是网络 IO,不能让它们阻塞 Unity 主线程;await和协程接着写,回调回到主线程后再操作动画。如果你要发布 WebGL 版本,录音数据请保持在内存里,不要试图写入持久化文件,Unity 的 IDBFS 在浏览器环境下很容易遇到写入失败,日志在浏览器控制台。
4. 让 UnityChan 开口说话:TTS 合成、表情与口型同步
文字回复只是说明链路通了,语音交互展示的核心体验在 TTS 与动画。这章把播放、表情、口型、打断四条线串起来,做一个能实际演示的语音回合。
4.1 三种 TTS 接入路线与选型建议
TTS 的接入方式决定了整个语音链路的复杂度,这里有个简单的对比:
| 方案 | 实现成本 | 延迟 | 适用场景 |
|---|---|---|---|
| 云端 HTTP 接口 | 低,UnityWebRequest 直连 | 中,看服务端响应 | 原型展示、临时演示 |
| 厂商 SDK 内嵌 | 中,需要集成原生插件 | 低,本地处理 | 正式产品、离线需求 |
| 本地合成引擎 | 高,资源占用大 | 最低 | 纯离线展示、对隐私要求高 |
我一般建议从云端 HTTP 开始做,把一整条链路跑通后再考虑 SDK 化。云端 HTTP 的优势是调试方便,返回内容用浏览器或 Postman 就能看,不依赖 Unity 的插件生态。如果你用的是本地合成,注意打包体积会明显变大,移动端上发热和耗电也必须纳入考量。
4.2 TTS 音频在 Unity 里的播放边界
TTS 服务返回的通常是音频文件直链或 Base64 字节流。用UnityWebRequestMultimedia.GetAudioClip拉取并播放是最省事的方式:
using System.Collections; using UnityEngine; using UnityEngine.Networking; public class TtsPlayer : MonoBehaviour { public AudioSource audioSource; public IEnumerator PlayTtsUrl(string audioUrl) { // AudioType 根据实际返回格式调整 using (UnityWebRequest req = UnityWebRequestMultimedia.GetAudioClip(audioUrl, AudioType.WAV)) { yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { Debug.LogError("TTS 音频拉取失败: " + req.error); yield break; } AudioClip clip = DownloadHandlerAudioClip.GetContent(req); audioSource.PlayOneShot(clip); } } }这里有一个很容易掉进去的坑:TTS 服务默认返回 MP3,但AudioType.WAV写死的代码只在返回 WAV 时正常。MP3 不是不能用,但最好让服务端统一转成 16kHz WAV 再返回,Unity 侧解码压力小,也避免不同平台对 MP3 的支持差异。如果无法改服务端,就根据响应头的 Content-Type 动态判断 AudioType。
播放完成后还需要把动画从 Talk 切回 Idle:
IEnumerator PlayThenIdle(AudioClip clip, Animator anim) { audioSource.PlayOneShot(clip); anim.SetTrigger("Talk"); yield return new WaitForSeconds(clip.length); anim.SetTrigger("Idle"); }WaitForSeconds(clip.length)不是精确计时,音频解码和播放器启动都有延迟。更稳的做法是等audioSource.isPlaying从 true 变成 false,或者注册AudioSource的播放完成回调。
4.3 口型同步:振幅驱动还是音素驱动
口型同步有两条技术路线,适用于不同阶段。原型阶段用振幅驱动,代码量小,能快速看到效果;正式展示阶段用音素驱动,嘴型更接近真实发音,但需要拿到 TTS 的文本内容做映射。
振幅驱动的思路是读取AudioSource的输出数据,算出当前音量电平,再映射到 BlendShape 权重上。UnityChan 自带的口型 BlendShape 一般是 MTH_A、MTH_I、MTH_U、MTH_E、MTH_O,具体以模型 Inspector 里显示的为准:
using UnityEngine; [RequireComponent(typeof(AudioSource))] public class LipSyncByVolume : MonoBehaviour { public SkinnedMeshRenderer unityChanFace; public string mouthBlendShape = "MTH_A"; public float amplitude = 100f; public float smoothSpeed = 12f; private AudioSource audioSource; private int shapeIndex = -1; private float current = 0f; private float[] samples = new float[256]; void Start() { audioSource = GetComponent<AudioSource>(); shapeIndex = unityChanFace.sharedMesh.GetBlendShapeIndex(mouthBlendShape); if (shapeIndex < 0) Debug.LogWarning("找不到 BlendShape: " + mouthBlendShape); } void Update() { if (audioSource == null || !audioSource.isPlaying || shapeIndex < 0) { current = 0f; } else { float sum = 0f; audioSource.GetOutputData(samples, 0); for (int i = 0; i < samples.Length; i++) sum += Mathf.Abs(samples[i]); float target = Mathf.Clamp01(sum / samples.Length * amplitude) * 100f; current = Mathf.Lerp(current, target, Time.deltaTime * smoothSpeed); } unityChanFace.SetBlendShapeWeight(shapeIndex, current); } }amplitude是灵敏度,根据 TTS 音量调整,音量小就加大到 150 左右。smoothSpeed控制嘴型变化的惯性,12 是默认手感,往下调到 8 嘴型更像真人,往上调到 20 会显得机械。这套方案的局限是,无论发什么音都只动一个 BlendShape,嘴型没有区分度。
如果要做得更细,用 TTS 返回的文本做音素驱动。把字符映射到口型,常用映射如下:
| 音素 | 触发字符示例 | 建议 BlendShape 与权重 |
|---|---|---|
| a | 啊、发、他 | MTH_A = 80 |
| i | 一、你、机 | MTH_I = 80 |
| u | 五、不、估 | MTH_U = 70 |
| e | 也、ca、can | MTH_E = 70 |
| o | 哦、过、中 | MTH_O = 80 |
| 停顿 | 空格、逗号、句号 | 全部归零 |
音素驱动需要先拿到最终文本,再逐字符遍历设置权重。UnityChan 的 BlendShape 命名在不同发行版里不完全一致,写代码前先检查实际模型。注意没有哪个角色能靠一种映射吃遍所有语言,中文和英文的发音规则不同,映射表要按你的目标语言调。
4.4 长回复和语音竞态:分段播放与打断策略
ChatGPT 的一次回复可能长达几百字,直接 TTS 合成会得到一段很长的音频。用户等太久会认为系统卡住,所以要先按标点分段,逐句播放。
private Queue<string> lineQueue = new Queue<string>(); public void EnqueueTts(string text) { string[] parts = text.Split(new char[] { '。', '!', '?', ',', '.', '!' }); foreach (string p in parts) if (p.Trim().Length > 0) lineQueue.Enqueue(p.Trim()); StartCoroutine(PlayQueue()); } private IEnumerator PlayQueue() { while (lineQueue.Count > 0) { string line = lineQueue.Dequeue(); yield return StartCoroutine(ttsPlayer.PlayTtsText(line)); yield return new WaitForSeconds(0.15f); } }分段之后还需要处理语音竞态:用户可能在 AI 说话时又按下了录音键。此时要立刻停止所有播放,清空未播放队列,并把口型权重归零,否则会出现“角色还在说话,新请求又进来了”的交错。打断方法如下:
public void Interrupt() { StopAllCoroutines(); audioSource.Stop(); lineQueue.Clear(); // 所有口型 BlendShape 归零 string[] shapes = new string[] { "MTH_A", "MTH_I", "MTH_U", "MTH_E", "MTH_O" }; foreach (string s in shapes) { int idx = face.sharedMesh.GetBlendShapeIndex(s); if (idx >= 0) face.SetBlendShapeWeight(idx, 0f); } }在“开始录音”按钮的事件里最先调用Interrupt(),保证每轮对话从干净状态开始。
5. 源码与文档自查清单:从解压到跑通的 4 个检查点
拿到压缩包后,先别急着换模型和调动画,用 4 个检查点从外到内过一遍。多数问题出在配置和环境,而不是代码逻辑。你可以把下表打印出来贴在显示器旁边,每跑通一个就划掉一个。
| 检查点 | 验证方法 | 失败常见原因 |
|---|---|---|
| Unity 版本与资源包 | 导入后 Animator 正常运行 Idle 动画 | 工程用 URP 但模型 Shader 是内置管线 |
| API Key 与网络 | 先用第 2.2 节脚本跑一次 | Key 过期、网络不通、请求被限流 |
| 麦克风权限 | StartRecord 返回 true | 平台权限未开启,编辑器里没授权 |
| TTS 音频格式 | 能播放且口型跟随 | 返回 MP3 但 AudioType 写死 WAV |
跑完这 4 项,整个链路已经从配置层面通到了代码层面,剩下的就是调参。调参之前先让问题可见,我习惯在 Unity 的 Game 视图里实时监视电平和口型权重,用OnGUI画两行文本比反复切窗口看 Inspector 高效得多:
void OnGUI() { GUILayout.Label($"音频电平: {level:F3}"); GUILayout.Label($"口型权重: {current:F1}"); }这个监视器的价值在于,能快速定位问题出在哪一段。如果音频电平一直是 0,说明录音或播放没生效;如果电平有值但口型权重不动,说明 BlendShape 脚本没挂对或命名不匹配。比对着报错日志猜要快得多。
WebGL 发布时有一个额外注意点:浏览器环境下 IDBFS 写入经常失败,日志显示在浏览器控制台,Unity 端不一定报错。方案是把所有音频和文本数据保持在内存里,不要在运行时写持久化文件。录音权限在 WebGL 下必须由用户点击事件触发,别在Awake或Start里自动启动录音,否则权限弹窗不会出现。另外,如果你发布到本机调试,记得确认 web server 的 HTTPS 配置,麦克风权限在非安全上下文里会被浏览器直接拦截。调口型参数时,smoothSpeed从 12 往下调到 8,嘴型会显得更粘滞,更接近真实说话的肌肉运动;超过 20 则像机械振动,反而暴露数字人的合成感。
本文还有配套的精品资源,点击获取