ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity内录全解析:捕获AudioListener音频输出并导出WAV

Unity内录全解析:捕获AudioListener音频输出并导出WAV 在 Unity 开发里“内录”通常指把引擎内部播放出来的声音比如 BGM、角色语音、UI 点击音效、3D 空间音效原样捕获成一份原始 PCM 数据再保存成 WAV 文件。它和麦克风录音不同麦克风录的是外部环境声而内录取的是 AudioListener 最终输出的声音。做游戏演示视频、录制音效素材、排查音频 Bug或者做节奏检测、音量可视化时都需要先有这一层数据捕获能力。由于 Unity 主程序并没有提供一个带录制按钮的“录音机”所谓“自带插件”在开发语境下通常指向两种东西一种是通过 Package Manager 安装的官方 Recorder 录制包它主要录画面也能带音频另一种是 Unity 官方开源仓库和社区广泛使用的 AudioRecorder 组件思路它完全基于引擎自带的 AudioListener 音频管线不依赖第三方 DLL。本文以第二种为主线先讲清楚音频采样原理再给出一个能直接运行的最小内录组件最后补充官方 Recorder、WAV 文件校验和常见排错。1. 先搞清楚内录的底层链路声音在到达 AudioListener 之后才算“最终输出”1.1 内录和麦克风录音的区别很多刚接触 Unity 音频的开发者会把“录音”直接理解为调用Microphone类。Microphone.Start()确实能获取设备输入但它拿的是麦克风采集到的声音而不是游戏本身播放的声音。内录的对象是引擎的音频输出链路。游戏中一个AudioSource播放音效会经过可能存在的AudioMixer分组处理最终汇聚到场景中的AudioListener。这个 Listener 是 Unity 音频系统的“耳朵”它把混音结果送给系统声卡。内录的本质是在 Listener 这一层把即将输出的 PCM 采样值截获一份供开发者自行保存或处理。用通俗的话说麦克风录音是录“现场”内录是录“给用户听的那条信号”。1.2 从 AudioSource 到 AudioListener声音在 Unity 里的流动路径Unity 的音频处理链路可以抽象成下面这条路径AudioSource播放音频 Clip | v AudioMixer可选分组、加效果、调音量 | v AudioListener最终混音输出节点 | v 系统声卡 / 音频设备内录脚本要挂在带有AudioListener的 GameObject 上原因就在这里。挂到普通物体上的OnAudioFilterRead只能处理该物体上AudioSource产生的数据只有挂到AudioListener上才能拿到整条总线混音后的最终结果。AudioListener.GetOutputData()也能拿到一段输出数据但它更适合做频谱分析和波形可视化适合每帧取一个快照。要做高保真内录更可靠的是用OnAudioFilterRead回调它会像音频插件一样收到连续的数据块。1.3 三种“官方方案”怎么选自写组件、开源 AudioRecorder、Unity Recorder 包网上一搜“Unity 内录”常见答案主要有三类这里先做一个对比。方案来源输出格式适合场景是否依赖第三方自写OnAudioFilterRead捕获组件本文代码基于引擎自带 APIWAV只要音频、要完全可控、要二次处理否官方开源 AudioRecorder 组件思路Unity 官方仓库和社区常见组件WAV快速接入官方推荐思路少写底层代码否Unity Recorder 包Package Manager 安装的官方录制包MP4、MOV 等视频文件录制演示视频、制作上线演示素材否如果你的需求是“只要一个干净的声音文件”最稳妥的路径是自写组件或者参考官方 AudioRecorder 的开源实现。你需要处理的只有三件事拿到 PCM 数据、把float采样转成 WAV 需要的short、拼一个合法的 WAV 文件头。如果你的需求是“录制游戏画面并带上声音”直接用官方 Recorder 包即可它不需要你关心音频层细节。2. 环境准备不装第三方库但要先把音频设置和测试音源对齐2.1 Unity 版本与工程设置本文示例代码使用 Unity 2019.4 以上的 APIUnity 2021、2022 LTS 和 Unity 6 都能直接使用。如果工程是从旧版本升级上来先确认AudioSettings.outputSampleRate和AudioSettings.speakerMode这两个 API 能用它们是内录初始化时的关键参数。新建工程后打开菜单Edit Project Settings Audio重点确认两个选项。配置项建议值说明Default Speaker ModeStereo内录示例按双声道处理Mono 场景代码也能兼容System Sample Rate保持默认不要在代码里写死 44100要用AudioSettings.outputSampleRate读取不要写死采样率是内录最常见的坑。某些设备实际输出是 48000 Hz如果代码里写死 44100最终 WAV 文件的时长和音高都会有问题。2.2 准备一段确定的测试声音避免“听都没听到就去录”排错时最怕声音源本身不确定。测试内录时不要在场景里放一堆复杂音效建议先放一段固定频率的持续音或者一段你能确认节奏的音乐。如果没有现成音频资源可以用脚本动态生成一段正弦波。下面这段代码会在运行时创建一个 660 Hz 的测试音时长 2 秒循环播放using UnityEngine; public class DebugSoundSource : MonoBehaviour { void Start() { AudioSource source gameObject.AddComponentAudioSource(); source.loop true; source.playOnAwake false; source.clip CreateTestClip(660f, 2f); source.Play(); } AudioClip CreateTestClip(float frequency, float duration) { int sampleRate AudioSettings.outputSampleRate; int length Mathf.CeilToInt(sampleRate * duration); float[] samples new float[length]; for (int i 0; i samples.Length; i) { samples[i] Mathf.Sin(2f * Mathf.PI * frequency * i / sampleRate); } AudioClip clip AudioClip.Create(TestTone, length, 1, sampleRate, false); clip.SetData(samples, 0); return clip; } }这里故意生成单声道 Clip播放后 Unity 会把它混入 Listener 的最终输出。如果你在 Game 视图里能听到稳定音调说明测试音源没有问题。注意先确认能听到声音再开始录。如果进入排错阶段时连声音都听不到你会分不清是录音代码的问题还是音频链路本身的问题。2.3 不同目标平台对环境的影响学习环境通常是在 PC 编辑器里运行这时候能直接用项目里的 AudioClip输出到系统声卡。发布到移动端、WebGL 或 XR 设备时情况不同。目标平台主要风险处理思路PC / macOS 编辑器采样率和声道因声卡驱动变化用AudioSettings.outputSampleRate读取真实值Android 手机系统音频焦点可能被来电、语音助手打断录音前检查焦点处理暂停和恢复iOS静音拨片会影响音频输出用 AVAudioSession 配置播放类别WebGL / 微信小游戏浏览器沙箱限制文件写入persistentDataPath不可靠录音数据放在内存中供上传服务器或即时预览XR 设备如 Pico、Quest系统音频路由可能切换到蓝牙耳机录音前确认音频输出设备测试蓝牙重连场景3. 最小内录实现一个挂在 Camera 上的组件捕获 AudioListener 输出并保存 WAV3.1 创建脚本并挂载到带 AudioListener 的对象在Assets/Scripts下创建InternalAudioRecorder.cs然后把它挂到场景中带AudioListener的对象上。默认新建的 Main Camera 自带AudioListener直接挂到 Camera 即可。脚本挂载后不需要额外配置任何原生插件。它会在Awake阶段读取音频输出参数在OnAudioFilterRead阶段持续收集 PCM 数据在停止录音时完成 WAV 头回填并写文件。3.2 InternalAudioRecorder完整录制组件下面是完整代码。它包含了开始录音、停止录音、WAV 头写入、文件保存和资源释放using System; using System.IO; using UnityEngine; public class InternalAudioRecorder : MonoBehaviour { private readonly object writeLock new object(); [Header(输出配置)] public string fileName internal_capture; public bool autoAppendTime true; private MemoryStream stream; private BinaryWriter writer; private bool isRecording; private int sampleRate; private int channelCount; private readonly int bitDepth 16; public bool IsRecording { get { lock (writeLock) { return isRecording; } } } private void Awake() { sampleRate AudioSettings.outputSampleRate; channelCount AudioSettings.speakerMode AudioSpeakerMode.Mono ? 1 : 2; Debug.Log($[InternalAudioRecorder] 初始化 sampleRate{sampleRate}, channels{channelCount}); } private void OnAudioFilterRead(float[] data, int channels) { lock (writeLock) { if (!isRecording || writer null || data null || data.Length 0) { return; } // data 中的每个元素是 [-1, 1] 的浮点采样值。 // PCM16 需要转成 short先做 clamp避免极端值被截断后产生爆音。 for (int i 0; i data.Length; i) { float s data[i]; if (s -1f) s -1f; if (s 1f) s 1f; writer.Write((short)(s * short.MaxValue)); } } } public void StartRecording() { if (isRecording) { StopRecording(); } lock (writeLock) { stream new MemoryStream(); writer new BinaryWriter(stream); WriteWavHeader(writer, sampleRate, channelCount, bitDepth); isRecording true; Debug.Log($[InternalAudioRecorder] 开始录音 sampleRate{sampleRate}, channels{channelCount}); } } public void StopRecording() { lock (writeLock) { if (!isRecording) { return; } isRecording false; long dataSize stream.Length - 44; stream.Seek(4, SeekOrigin.Begin); writer.Write((int)(36 dataSize)); stream.Seek(40, SeekOrigin.Begin); writer.Write((int)dataSize); stream.Seek(0, SeekOrigin.End); byte[] allBytes stream.ToArray(); writer.Dispose(); stream.Dispose(); writer null; stream null; string directory Application.persistentDataPath; if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } string finalName autoAppendTime ? ${fileName}_{DateTime.Now:yyyyMMdd_HHmmss}.wav : ${fileName}.wav; string outputPath Path.Combine(directory, finalName); try { File.WriteAllBytes(outputPath, allBytes); float duration allBytes.Length * 1f / (sampleRate * channelCount * bitDepth / 8); Debug.Log($[InternalAudioRecorder] 保存完成: {outputPath}); Debug.Log($[InternalAudioRecorder] 文件大小: {allBytes.Length / 1024f:F2} KB, 时长: {duration:F2}s); } catch (Exception e) { Debug.LogError($[InternalAudioRecorder] 保存失败: {e.Message}); } } } private static void WriteWavHeader(BinaryWriter writer, int sampleRate, int channels, int bitDepth) { int blockAlign channels * bitDepth / 8; int byteRate sampleRate * blockAlign; writer.Write(RIFF.ToCharArray()); writer.Write(0); // 文件大小停止录音时回填 writer.Write(WAVE.ToCharArray()); writer.Write(fmt .ToCharArray()); writer.Write(16); // fmt 块长度 writer.Write((short)1); // PCM writer.Write((short)channels); writer.Write(sampleRate); writer.Write(byteRate); writer.Write((short)blockAlign); writer.Write((short)bitDepth); writer.Write(data.ToCharArray()); writer.Write(0); // 数据区大小停止录音时回填 } private void OnDestroy() { lock (writeLock) { if (writer ! null) { writer.Dispose(); writer null; } if (stream ! null) { stream.Dispose(); stream null; } } } }这段代码的关键逻辑是OnAudioFilterRead只负责把 PCM 数据追加到内存流真正写文件和回填 WAV 头都在StopRecording中完成。这样可以避免在音频回调里访问磁盘降低卡顿和爆音风险。3.3 RecordingDemoUI用 OnGUI 快速控制开始与停止为了快速测试不引入 UGUI 的 Canvas 和按钮依赖用OnGUI搭一个最简单的控制面板using UnityEngine; public class RecordingDemoUI : MonoBehaviour { public InternalAudioRecorder recorder; void OnGUI() { GUILayout.BeginArea(new Rect(20, 20, 260, 120)); if (recorder null) { GUILayout.Label(请把 InternalAudioRecorder 挂到 Camera 上); GUILayout.EndArea(); return; } GUILayout.Label($状态: {(recorder.IsRecording ? 录制中 : 未录制)}); if (!recorder.IsRecording) { if (GUILayout.Button(开始录音)) { recorder.StartRecording(); } } else { if (GUILayout.Button(停止并保存)) { recorder.StopRecording(); } } GUILayout.Label($保存目录: {Application.persistentDataPath}); GUILayout.EndArea(); } }把RecordingDemoUI.cs挂到任意对象上再从 Inspector 把InternalAudioRecorder拖到recorder字段运行后点按钮就能测试。4. 关键代码拆解采样数据怎么来、WAV 头怎么写、线程安全怎么做4.1 OnAudioFilterRead 回调里的 data 到底是什么OnAudioFilterRead(float[] data, int channels)是 Unity 音频插件机制的一部分。当它挂在AudioListener上时引擎会在最终输出前把一段混音结果以float[]方式传入。data里的每个元素代表一个采样点范围在[-1, 1]之间。channels是当时的声道数可能是 1 或 2。假设采样率是 48000双声道那么每次回调传入的数据量等于回调时间长度 data.Length / (channels * sampleRate)如果data.Length是 1024且双声道 48000 Hz那么这段数据大约是 10.6 毫秒。Unity 会按照固定音频块大小反复调用回调所以只要录音状态为 true就能连续拿到 PCM 数据。这里要特别注意不要在回调里做Debug.Log、创建新数组、访问文件或网络。音频回调对实时性要求极高任何耗时操作都会直接表现为卡顿、爆音、杂音。4.2 为什么 WAV 头要预留 44 字节并在停止时回填WAV 文件由文件头和数据区组成。PCM16 双声道、44100 Hz 的标准 WAV 头是 44 字节结构如下偏移长度内容说明04RIFF固定标识44文件大小等于 36 数据区大小84WAVE固定标识124fmtfmt 块标识16416fmt 块长度2021音频格式1 表示 PCM222声道数1 或 2244采样率例如 48000284字节率sampleRate * blockAlign322blockAlignchannelCount * bitDepth / 8342位深16364datadata 块标识404数据区大小所有 PCM 数据的总字节数录制开始时不知道最终数据有多大所以先写 0 占位停止时回到偏移 4 回填文件大小再回到偏移 40 回填数据区大小。这样生成的文件才能被 Windows 播放器、浏览器等工具正常识别。4.3 音频线程和主线程的协调方式锁、双缓冲区与写入时机OnAudioFilterRead在音频线程中被调用而开始录音和停止录音通常在主线程按钮事件中触发。两个线程访问同一个MemoryStream必须做同步。上面的代码使用lock保护关键区域。StopRecording把isRecording置为 false 后音频线程即使再次进入回调也会因为条件判断不通过而直接返回不会出现对象已释放还在写入的情况。这种方式适合学习和大部分中小型项目。如果录音时间很长、数据量很大lock在音频回调里带来的竞争仍然可能造成短暂卡顿生产环境更推荐双缓冲队列音频线程只把数据块写入队列专用写入线程从队列取出并写入文件。这样音频线程的临界区极短。4.4 录制时长、文件体积和内存增长的估算录制产生的文件体积可以提前估算。以双声道、16 位、48000 Hz 为例每秒数据量 48000 * 2 * 2 192000 字节 187.5 KB3 分钟大约 34 MB。代码里的MemoryStream会在内存中保留完整的 PCM 数据因此录制时间越长内存占用越大。学习时用MemoryStream没问题。正式项目如果需要长时间录制应该改为先写入临时文件停止后再补写 WAV 头或者分段保存多个文件。否则录制到第 10 分钟内存里可能会积累上百 MB 数据。5. 运行验证听得到、能看到文件、还能校验 WAV 头5.1 操作步骤与预期日志完成脚本挂载后按下面顺序验证运行场景确认 Game 视图里能听到测试音。点击“开始录音”观察 Console 日志出现开始录音 sampleRate... channels...。等待几秒点击“停止并保存”。观察 Console 日志出现保存完成: ...路径...和时长信息。用系统播放器打开 WAV 文件确认能播放且声音是测试音。预期日志大致如下[InternalAudioRecorder] 初始化 sampleRate44100, channels2 [InternalAudioRecorder] 开始录音 sampleRate44100, channels2 [InternalAudioRecorder] 保存完成: C:/Users/.../AppData/LocalLow/DefaultCompany/.../internal_capture_20260101_120000.wav [InternalAudioRecorder] 文件大小: 1875.00 KB, 时长: 10.00s5.2 用一段 C# 代码校验 WAV 时长和信息如果系统播放器打开文件正常但你想在 Unity 里自动校验可以在控制器里加一个方法using System; using System.IO; using UnityEngine; public static class WavInspector { public static void Inspect(string path) { byte[] bytes File.ReadAllBytes(path); int sampleRate BitConverter.ToInt32(bytes, 24); short channels BitConverter.ToInt16(bytes, 22); short bits BitConverter.ToInt16(bytes, 34); int dataSize BitConverter.ToInt32(bytes, 40); float duration dataSize * 1f / (sampleRate * channels * bits / 8); Debug.Log($[WavInspector] 采样率{sampleRate}, 声道{channels}, 位深{bits}, 数据区{dataSize} 字节, 时长{duration:F2}s); } }在停止录音后调用WavInspector.Inspect(outputPath)如果输出的采样率、声道数和录制时初始化日志一致说明文件头正确。5.3 合法目标和异常表现对照表验证项正常表现异常表现音频链路Game 视图能听到测试音完全无声录音状态点击后状态变为“录制中”点按钮没反应文件生成保存目录出现 WAV 文件没有文件或保存失败文件可播放系统播放器正常播放播放器提示文件损坏时长信息与录音时间基本一致时长明显偏长或偏短音质干净、无明显杂音爆音、断续、音量极小6. 常见问题排查静音、爆音、文件损坏和平台限制6.1 录出来是静音按这条链路逐级排查静音是最常见的问题但大多数人一开始会怀疑录音代码实际上问题往往出在音频链路本身。建议按下面顺序检查Game 视图能不能听到声音。如果听不到先检查AudioSource.Mute、AudioSource.volume、AudioMixer主分组是否静音。AudioListener所在的 GameObject 是否处于激活状态。如果 Listener 被 DisableOnAudioFilterRead根本不会被调用。录音脚本是否挂在带AudioListener的对象上。如果挂到普通对象录到的不是全局混音结果。录音状态是否真的为 true。某些脚本重新加载场景后isRecording被重置但录音逻辑已提前开启。检查Application.persistentDataPath下是否生成了文件以及文件大小是否为 0。问题现象常见原因检查方式处理建议文件正常但全静音Listener 被禁用或 Master 静音听一下游戏声音是否还在确认 Listener 激活混音输出正常文件只有十几字节录音状态从未开启看日志有没有“开始录音”检查按钮事件和脚本挂载有时有声有时无声音频焦点被系统抢占观察来电、蓝牙耳机重连处理音频焦点恢复逻辑音量极小AudioMixer 输出音量很低看 Mixer 分组电平和音量控制调整AudioSource.volume或分组音量6.2 爆音和杂音看浮点转换与回调内的耗时操作录制结果如果有“咔咔”声、爆音常见原因有两个。第一个是浮点转 short 时没有做 clamp。极端情况下data[i]可能超过[-1, 1]直接(short)(s * short.MaxValue)会导致整数溢出或截断产生刺耳噪声。代码里先 clamp 再转换能避免。第二个是音频回调里做了耗时操作。OnAudioFilterRead是音频线程的实时回调如果在这里打印日志、分配大数组、做字符串拼接音频缓冲区就可能来不及填充最终表现为断断续续。把所有耗时操作移到主线程回调里只做内存追加。6.3 WAV 打不开或时长不对先看头的 44 字节WAV 打不开绝大多数是文件头写错了。最常见的问题是数据区大小没有回填或者回填时偏移写错。Windows 播放器对文件头非常敏感。可以用十六进制工具打开 WAV 文件确认偏移 4 处的值是36 数据区大小偏移 40 处的值是实际 PCM 数据字节数。如果这两处为 0播放器会认为文件不完整。时长不对则要先确认采样率。比如设备实际输出 48000 Hz代码写死 44100那么播放器会以 44100 解释数据播放速度偏慢时长偏长。解决办法就是从AudioSettings.outputSampleRate读取真实采样率。6.4 WebGL、微信小游戏与 XR 设备的特殊处理WebGL 平台没有传统文件系统Application.persistentDataPath不可靠。内录数据可以保存在内存中也可以直接把 WAV 的byte[]上传到服务器或交给浏览器侧的 JS 处理。微信小游戏运行在浏览器环境需要遵循相同的沙箱限制。对于一些 XR 设备蓝牙耳机连接状态可能影响系统音频输出录音前应该重新确认音频设备。这些场景下日志和状态回调必须比 PC 端更完整否则用户反馈问题时很难定位是设备问题还是代码问题。7. 从原型到发布官方 Recorder 选型与工程化建议7.1 需要视频素材时安装官方 Recorder 包录制带音轨的画面如果内录只是用来生成演示视频素材并不需要自己写 PCM 捕获代码。通过 Package Manager 安装官方 Recorder 包流程如下Window Package Manager Unity Registry 搜索 Recorder Install安装后打开Window General Recorder新建一个 Video Recorder设置 Source 为 Game View 或 Targeted Camera指定分辨率和帧率。在 Audio Track 部分选择 AudioListener录制出来的视频就会包含场景内播放的声音。需要注意官方 Recorder 输出的是带音轨的视频不是独立 WAV。它适合做游戏演示、提交视频作品不适合做纯音频文件或者需要实时处理音频数据的场景。7.2 把内录功能放进正式版本前要做完这些事从原型到正式发布不能只把InternalAudioRecorder拖到场景里就结束。下面是一份发布前检查清单录音状态机要完整防止重复点击开始或停止。长时间录音不能无限占用内存优先使用临时文件或分段录制。文件保存路径和文件名要支持配置不能依赖默认时间戳。录音失败要有日志和回调方便用户反馈。录制生成的文件要考虑清理策略防止用户存储空间被写满。手机上需要检查存储权限和音频焦点不能想当然认为 iOS 和 Android 一样。发布版本建议增加最大时长限制避免用户忘记停止录音导致磁盘写满。注意正式版本里的内录功能不应该是“开发调试工具”。它可能需要后台运行、异常恢复、文件校验、上传失败重试等逻辑这些都要按正式功能对待。7.3 扩展方向压缩转码、分轨录制和系统级录制的边界WAV 是未压缩格式体积大。如果产品需要 MP3 或 OGG需要在 C# 层接入字节码库或者把 PCM 数据交给原生插件编码。这里要注意第三方编码库的授权商业游戏发布前要确认许可。如果希望分别录制 BGM、音效、角色语音不能简单地靠一个AudioListener完成因为 Listener 已经是混音后的最终输出。需要把不同AudioSource路由到不同的AudioMixer分组再在各分组出口采集数据或者直接在音频源端单独捕获。还容易混淆的一点是“内录”和“系统级录音”。Unity 的内录只能捕获引擎内部输出不能录制其他程序的系统声音比如浏览器、视频会议软件的声音。想要录制系统声音需要调用系统 API例如 Windows 的 WASAPI 回环录音这已经超出 Unity 自带插件的能力范围。明确这个边界可以避免在技术选型时绕远路。总的来说使用 Unity 自带的音频 API 内录并不复杂核心只是在AudioListener上接收 PCM 数据正确生成 WAV 文件。把这个最小链路跑通之后再根据平台限制、内存策略和文件格式需求一步步扩展比一开始就去接付费 SDK 要更可控也能真正理解 Unity 音频管线的运行方式。
返回列表