You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多
文档控制台
免费开始使用

Unity音频库Delay类:是否应使用Queue<float>存储延迟采样?

重构Unity音频库的Delay类:解决固定数组采样的局限

我最近在为Unity开发音频工具库时,刚好重构过类似的Delay模块,原来用float[]存储采样的设计确实踩了不少坑——就像你说的,初始化时定死延迟时长,后续完全没法灵活调整,内存浪费还不说,想做动态延迟效果根本行不通。结合我的实践经验,给你分享几个重构思路:

原有方案的核心局限

  • 固定缓冲区大小,无法动态适配延迟需求:比如48kHz采样率下,1000ms延迟要创建48000长度的数组,一旦初始化完成,延迟时长既不能超过初始值(数组不够存新采样),也没法缩小(多余的内存一直占用)。
  • 缺乏灵活性:做游戏音频经常需要根据场景实时调整延迟(比如回声随场景大小变化),固定数组完全满足不了这种动态需求。

重构核心:动态缓冲区+线程安全设计

1. 用动态容器替代固定数组

把原来的float[]换成List<float>(或者自定义动态环形缓冲区),这样可以随时调整缓冲区容量。不过要注意:Unity的音频处理是在独立线程运行的,直接在主线程修改容器会引发线程竞争,所以必须加锁或者用线程安全的同步机制。

2. 实现平滑的延迟时长调整逻辑

调整延迟时,先根据新的延迟时长和采样率计算所需的缓冲区大小,然后分两种情况处理:

  • 扩容缓冲区:如果新延迟更长,直接给容器添加静音采样扩展长度,保留原有缓冲区的所有数据,避免音频断档。
  • 缩容缓冲区:如果新延迟更短,只保留最近的N个采样(N为新缓冲区大小),同时调整读写索引,保证延迟效果的连贯性。

3. 线程安全的实时调整

在音频回调(比如OnAudioFilterRead)中处理采样时,要用锁保护缓冲区的读写操作;主线程修改延迟参数时,也要通过锁确保缓冲区不会在读写过程中被修改。

简化版代码示例

public class DynamicDelay
{
    private List<float> _delayBuffer;
    private int _writePos;
    private int _readPos;
    private int _sampleRate;
    private object _lockObj = new object();

    public DynamicDelay(int sampleRate, float initialDelayMs)
    {
        _sampleRate = sampleRate;
        int initialSize = Mathf.CeilToInt(sampleRate * initialDelayMs / 1000f);
        _delayBuffer = new List<float>(initialSize);
        // 初始化静音缓冲区
        for (int i = 0; i < initialSize; i++)
        {
            _delayBuffer.Add(0f);
        }
        _writePos = 0;
        _readPos = 0;
    }

    // 主线程调用:设置新的延迟时长
    public void UpdateDelay(float newDelayMs)
    {
        lock (_lockObj)
        {
            int newBufferSize = Mathf.CeilToInt(_sampleRate * newDelayMs / 1000f);
            if (newBufferSize == _delayBuffer.Count) return;

            if (newBufferSize > _delayBuffer.Count)
            {
                // 扩容:添加静音采样
                int addCount = newBufferSize - _delayBuffer.Count;
                for (int i = 0; i < addCount; i++)
                {
                    _delayBuffer.Add(0f);
                }
            }
            else
            {
                // 缩容:保留最近的采样
                int removeCount = _delayBuffer.Count - newBufferSize;
                _delayBuffer.RemoveRange(0, removeCount);
                // 修正读写位置,避免越界
                _readPos = Math.Max(0, _readPos - removeCount);
                _writePos = Math.Max(0, _writePos - removeCount);
            }
        }
    }

    // 音频线程调用:处理单声道采样
    public float ProcessSample(float input)
    {
        lock (_lockObj)
        {
            float delayedSample = _delayBuffer[_readPos];
            // 写入新采样
            _delayBuffer[_writePos] = input;
            // 更新环形缓冲区索引
            _writePos = (_writePos + 1) % _delayBuffer.Count;
            _readPos = (_readPos + 1) % _delayBuffer.Count;
            // 湿干混合(可自定义比例)
            return input + delayedSample * 0.4f;
        }
    }
}

额外优化建议

  • 减少GC开销:如果延迟调整频繁,可以预先设置一个最大缓冲区容量,只在需要超过最大值时再扩容,避免频繁的内存分配和回收。
  • 平滑过渡避免爆音:调整延迟时长时,不要直接跳变,而是用线性插值在几帧内逐步调整延迟时间,让缓冲区的变化更平缓,消除咔哒声。
  • 多声道支持:如果需要处理立体声,可以给每个声道单独维护一个缓冲区,或者用List<float[]>存储多声道采样。

内容的提问来源于stack exchange,提问作者David Rodriguez

火山引擎 最新活动