在Unity中简单的语音发送集成方案
集成了 UniMic 的录制逻辑、Concentus 的异步压缩逻辑。
—
Unity 语音消息功能开发文档 (异步 Opus 方案)
本方案旨在通过 UniMic 进行音频采集,利用 Concentus (Opus) 在后台线程进行高效压缩,最后通过自研 Socket 发送。
1 . 开源库引用与下载
| 库名 | 用途 | GitHub 地址 / 获取方式 |
|---|---|---|
| UniMic | 高性能麦克风采集 | adrenak/unimic |
| Concentus | 纯 C # Opus 编解码 | Logan _S _A/Concentus |
| Concentus (UPM) | Unity 专用包镜像 | https://github.com/adrenak/concentus-unity.git (通过 Package Manager 安装) |
注意:如果 GitHub 无法访问,请从 NuGet 官网 下载 .nupkg 压缩包,解压后将其中的 Concentus.dll 放入 Unity 的 Assets/Plugins 目录。
—
2 . 核心技术参数
- 采样率: 24,000 Hz (语音清晰且体积平衡)
- 声道数: 1 (单声道)
- 帧时长: 20 ms (对应 480 个采样点)
- 压缩比: 约 10:1 (30秒语音约为 150KB - 200KB 字节流)
—
3 . 核心工具类 (VoiceTool.cs)
负责异步编解码,不产生 UI 卡顿。
C #
using System;
using System.Collections.Generic;
using Concentus.Enums;
using Concentus.Structs;
using Cysharp.Threading.Tasks;
public static class VoiceTool
{
private static int SampleRate = 24000;
private static int FrameSize = 480;
private static OpusEncoder encoder = null;
public static void Init(int sampleRate = 24000, int frameSize = 0)
{
SampleRate = sampleRate;
// 如果没有显式指定 frameSize,则默认使用 20ms 的帧长
FrameSize = frameSize > 0 ? frameSize : (sampleRate * 20 / 1000);
encoder = new OpusEncoder(SampleRate, 1, OpusApplication.OPUS_APPLICATION_VOIP);
}
/// <summary >
/// 异步压缩:将原始采样 List 转换为 Opus 字节数组
/// </summary >
public static async UniTask<byte[]> CompressSamplesAsync(List<float> samples)
{
return await UniTask.RunOnThreadPool(() =>
{
if (encoder == null)
{
encoder = new OpusEncoder(SampleRate, 1, OpusApplication.OPUS_APPLICATION_VOIP);
}
else
{
encoder.ResetState(); // 清空状态,避免上一段语音的残留影响
}
List<byte> output = new List<byte>();
short[] frame = new short[FrameSize];
byte[] encodedPacket = new byte[1024];
for (int i = 0; i < samples.Count; i += FrameSize)
{
int count = Math.Min(FrameSize, samples.Count - i);
Array.Clear(frame, 0, FrameSize);
// 彻底规避 Concentus 在 IL2CPP 真机下的 float->short 转换 Bug(OverflowException,内部算法导致数值超调溢出)。
// 我们在外部手动且极度安全地把原始 float 转好 short 再送进编码器。
for (int j = 0; j < count; j++)
{
float val = samples[i + j];
if (float.IsNaN(val) || float.IsInfinity(val))
val = 0f;
float scaled = val * 32767f;
if (scaled > 32767f) scaled = 32767f;
else if (scaled < -32768f) scaled = -32768f;
frame[j] = (short)scaled;
}
int packetSize = encoder.Encode(frame, 0, FrameSize, encodedPacket, 0, encodedPacket.Length);
// 写入协议:[2字节长度] + [数据]
output.AddRange(BitConverter.GetBytes((short)packetSize));
byte[] actualData = new byte[packetSize];
Array.Copy(encodedPacket, actualData, packetSize);
output.AddRange(actualData);
}
return output.ToArray();
});
}
/// <summary >
/// 异步解压:将收到的 Opus 字节流还原为采样数组
/// </summary >
public static async UniTask<float[]> DecompressSamplesAsync(byte[] opusData, int sampleRate)
{
return await UniTask.RunOnThreadPool(() =>
{
var decoder = new OpusDecoder(sampleRate, 1);
var frameSize = (sampleRate * 20 / 1000);
List<float> pcmList = new List<float>();
int offset = 0;
while (offset < opusData.Length)
{
short packetSize = BitConverter.ToInt16(opusData, offset);
offset += 2;
short[] decodedFrame = new short[frameSize];
decoder.Decode(opusData, offset, packetSize, decodedFrame, 0, frameSize);
for (int j = 0; j < frameSize; j++)
{
pcmList.Add(decodedFrame[j] / 32768f);
}
offset += packetSize;
}
return pcmList.ToArray();
});
}
}
—
4 . 录制逻辑 (VoiceRecorder.cs)
结合 UniMic 实现高效录制与自动发送。
C #
using UnityEngine;
using System.Collections.Generic;
using UniMic; // 必须导入 UniMic 命名空间
public class VoiceRecorder : MonoBehaviour
{
private List <float > recordedSamples = new List <float >();
private bool isRecording = false;
void Start()
{
// 绑定 UniMic 的采样回调
Mic.Instance.OnSamplesReady.AddListener(OnSamplesReady);
}
private void OnSamplesReady(float[] samples)
{
if (isRecording)
{
recordedSamples.AddRange(samples);
// 安全限制:30秒上限
if (recordedSamples.Count >= 24000 * 30) StopRecording();
}
}
public void StartRecording()
{
if (isRecording) return;
recordedSamples.Clear();
isRecording = true;
// 使用 24k 采样率启动录音
Mic.Instance.StartRecording(24000, 100);
}
public async void StopRecording()
{
if ( !isRecording) return;
isRecording = false;
Mic.Instance.StopRecording();
if (recordedSamples.Count > 0)
{
// 在后台线程进行 Opus 压缩
byte[] compressedData = await VoiceTool.CompressSamplesAsync(recordedSamples);
// TODO: 调用你的 Socket 发送逻辑
// SocketClient.Instance.Send(compressedData);
recordedSamples.Clear();
}
}
}
—
5 . 接收端播放逻辑
当收到来自 Socket 的语音消息包时,执行以下操作。
C #
public async void PlayReceivedVoice(byte[] voiceData)
{
// 1. 异步解压
float[] pcmData = await VoiceTool.DecompressSamplesAsync(voiceData);
// 2. 切回主线程创建 AudioClip (await 之后自动切回)
AudioClip clip = AudioClip.Create("ReceivedVoice", pcmData.Length, 1, 24000, false);
clip.SetData(pcmData, 0);
// 3. 播放
AudioSource.PlayClipAtPoint(clip, Camera.main.transform.position);
}
6 . 使用说明与最佳实践
A. 接收与播放流程
收到 Socket 消息后,异步还原并播放:
- 调用 await VoiceTool.DecompressSamplesAsync(data)。
- 在返回主线程后,通过 AudioClip.Create 生成音频并赋值给 AudioSource。
B. 性能注意事项
- GC 优化:文档中的 CompressSamplesAsync 直接遍历 List 以最大程度减少内存碎片。
- 异步安全:由于编解码在 Task.Run 中执行,请勿在此代码块中访问 Unity 的 Transform 或其他非线程安全的引擎组件。
- 取消机制:若用户上滑取消发送,只需调用 StopRecording() 并 recordedSamples.Clear(),跳过 CompressSamplesAsync 的调用即可。
2221

被折叠的 条评论
为什么被折叠?



