K7DJ

Unity中基于OnAudioFilterRead实现实时多段均衡器:从Biquad滤波器到灵活可控的音频塑造

82 0 声波塑造者

在Unity中打造一个灵活且强大的实时音频处理器,特别是像多段均衡器(Multi-band EQ)这样的工具,往往会涉及到深入的数字信号处理(DSP)知识和Unity音频系统的巧妙运用。OnAudioFilterRead回调函数正是我们实现这一切的核心入口。今天,我们就来聊聊如何基于它,一步步构建一个可配置的、带有Q值和增益控制的多段EQ。

OnAudioFilterRead:实时音频处理的心脏

首先,理解OnAudioFilterRead至关重要。它是Unity提供的一个低级音频回调,挂载在任何带有AudioSource组件的游戏对象上,只要该对象激活并播放声音,这个回调就会被触发。它的作用是在音频数据被送到声卡播放之前,让你有机会对它进行读取和修改。

该回调函数的签名通常是 void OnAudioFilterRead(float[] data, int channels)

  • data:这是一个浮点数组,包含了当前的音频采样数据。值得注意的是,这些采样是交错排列的(interleaved)。例如,如果是立体声,数据会是左声道采样1、右声道采样1、左声道采样2、右声道采样2……依此类推。数组的长度是采样数乘以声道数。
  • channels:表示当前音频的声道数(1为单声道,2为立体声等)。

每次调用时,这个data数组会填充大约几十毫秒的音频数据,具体取决于AudioSettings.dspBufferSizeAudioSettings.outputSampleRate。我们需要在这个函数内部高效地处理每一个采样,因为任何延迟都可能导致音频卡顿或爆音。

多段均衡器与Biquad滤波器

多段均衡器通过对不同频率范围的音频信号进行增益或衰减来塑造音色。实现这种效果,数字滤波器是核心。在实时音频处理中,双二阶(Biquad)滤波器因其计算效率高、参数可控性强而被广泛使用。

Biquad滤波器是一种IIR(Infinite Impulse Response,无限脉冲响应)滤波器,其通用差分方程表示为:

y[n] = (b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]) / a0

其中:

  • x[n] 是当前输入采样。
  • x[n-1], x[n-2] 是前一两个输入采样。
  • y[n] 是当前输出采样。
  • y[n-1], y[n-2] 是前一两个输出采样。
  • a0, a1, a2, b0, b1, b2 是滤波器的系数,它们决定了滤波器的类型(低通、高通、带通、峰值等)以及特性(截止频率、Q值、增益)。

对于多段均衡器,我们最常用的是峰值滤波器(Peaking Filter)。它允许你在一个中心频率周围进行增益或衰减,同时通过Q值控制带宽。虽然提示中提到了“带通滤波器”,但对于均衡器而言,峰值滤波器正是实现“带通增益/衰减”功能的理想选择,它在本质上表现出一种“带通”的响应特性,但增加了增益控制。

峰值滤波器系数推导

要构建一个可配置的峰值滤波器,我们需要根据目标频率(freq)、Q值(Q)和增益(gain,通常以dB为单位)来计算Biquad滤波器的ab系数。以下是常用的计算公式(基于数字滤波器设计领域的标准方法,如RBJ Cookbook):

假设:

  • A = pow(10, gain / 40.0f) (线性增益,因为EQ的增益通常在-15dB到15dB之间,我们除以40而非20是因为功率增益)
  • omega = 2 * PI * freq / sampleRate (角频率,sampleRate是音频采样率,例如44100Hz)
  • alpha = sin(omega) / (2 * Q)
  • cos_omega = cos(omega)

对于峰值滤波器(Peaking EQ):

  • b0 = 1 + alpha * A
  • b1 = -2 * cos_omega
  • b2 = 1 - alpha * A
  • a0 = 1 + alpha / A
  • a1 = -2 * cos_omega
  • a2 = 1 - alpha / A

请注意,这里a0不是1,所以最终的差分方程需要除以a0

每次改变freqQgain时,都需要重新计算这些系数。

构建多段EQ的结构

为了实现多段EQ,我们可以设计一个BiquadFilter类来封装单个Biquad滤波器的逻辑和状态,然后在一个主MonoBehaviour脚本中实例化多个BiquadFilter对象。

1. BiquadFilter类:

public class BiquadFilter
{
    // 滤波器系数
    private float b0, b1, b2, a1, a2;
    // 滤波器状态变量(前两个输入和前两个输出)
    private float x1, x2, y1, y2;

    public BiquadFilter()
    {
        // 初始化状态变量
        x1 = x2 = y1 = y2 = 0;
        // 默认设置为直通,即不影响音频
        SetCoefficients(1, 0, 0, 0, 0);
    }

    // 更新滤波器系数的方法
    public void SetCoefficients(float b0, float b1, float b2, float a1, float a2)
    {
        // 这里a0通常被归一化为1,所以方程是y[n] = b0*x[n] + ... - a1*y[n-1]...
        // 如果a0不是1,则所有系数需要除以a0
        this.b0 = b0;
        this.b1 = b1;
        this.b2 = b2;
        this.a1 = a1;
        this.a2 = a2;
    }

    // 计算峰值EQ系数(传入频率、Q值、增益dB、采样率)
    public void CalculatePeakingCoefficients(float freq, float Q, float gainDb, float sampleRate)
    {
        // 防止Q值过低或频率异常导致NaN或Infinity
        Q = Mathf.Max(0.01f, Q); 
        freq = Mathf.Clamp(freq, 20f, sampleRate / 2 - 20f); // 钳制频率在合理范围

        float A = Mathf.Pow(10, gainDb / 40.0f); // 线性增益
        float omega = 2 * Mathf.PI * freq / sampleRate;
        float alpha = Mathf.Sin(omega) / (2 * Q);
        float cos_omega = Mathf.Cos(omega);

        // Peaking EQ coefficients
        float B0 = 1 + alpha * A;
        float B1 = -2 * cos_omega;
        float B2 = 1 - alpha * A;
        float A0 = 1 + alpha / A;
        float A1 = -2 * cos_omega;
        float A2 = 1 - alpha / A;

        // 归一化a系数,确保a0为1
        SetCoefficients(B0 / A0, B1 / A0, B2 / A0, A1 / A0, A2 / A0);
    }

    // 处理单个采样点
    public float Process(float input)
    {
        float output = b0 * input + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;

        // 更新状态变量
        x2 = x1;
        x1 = input;
        y2 = y1;
        y1 = output;

        return output;
    }

    // 重置滤波器状态(例如在停止播放时)
    public void Reset()
    {
        x1 = x2 = y1 = y2 = 0;
    }
}

2. 主MonoBehaviour脚本(例如 MultiBandEQ):

using UnityEngine;
using System.Collections.Generic;

[RequireComponent(typeof(AudioSource))]
public class MultiBandEQ : MonoBehaviour
{
    [System.Serializable]
    public class EQBand
    {
        [Range(20, 20000)] public float frequency = 1000f; // 中心频率
        [Range(0.1f, 10f)] public float Q = 1f; // Q值,影响带宽
        [Range(-15f, 15f)] public float gainDb = 0f; // 增益(dB)
        [HideInInspector] public BiquadFilter filterLeft; // 左声道滤波器实例
        [HideInInspector] public BiquadFilter filterRight; // 右声道滤波器实例

        public void Initialize()
        {
            filterLeft = new BiquadFilter();
            filterRight = new BiquadFilter();
        }

        public void UpdateCoefficients(float sampleRate)
        {
            filterLeft.CalculatePeakingCoefficients(frequency, Q, gainDb, sampleRate);
            filterRight.CalculatePeakingCoefficients(frequency, Q, gainDb, sampleRate);
        }

        public void ResetFilters()
        {
            filterLeft.Reset();
            filterRight.Reset();
        }
    }

    [SerializeField] private List<EQBand> eqBands = new List<EQBand>();

    private int sampleRate;

    void Awake()
    {
        sampleRate = AudioSettings.outputSampleRate;
        foreach (var band in eqBands)
        {
            band.Initialize();
            band.UpdateCoefficients(sampleRate);
        }
    }

    void OnValidate() // 在编辑器中修改参数时调用,实时更新滤波器
    {
        if (Application.isPlaying)
        {
            sampleRate = AudioSettings.outputSampleRate;
            foreach (var band in eqBands)
            {
                band.UpdateCoefficients(sampleRate);
            }
        }
    }

    void OnAudioFilterRead(float[] data, int channels)
    {
        // 如果没有定义频段,则直接返回,不处理
        if (eqBands == null || eqBands.Count == 0) return;

        for (int i = 0; i < data.Length; i += channels)
        {
            float currentSampleLeft = data[i];
            float currentSampleRight = (channels == 2) ? data[i + 1] : data[i]; // 单声道时左右相同

            // 依次通过每个EQ频段进行处理(通常多段EQ是并行应用各个峰值滤波器)
            // 这里简单地将每个频段的效果累加到当前采样上
            // 更严谨的做法是对原始信号应用每个滤波器,然后将每个滤波器的输出(调整后的增益部分)加回原始信号,或使用串行处理。
            // 对于峰值滤波器,我们直接对信号进行处理即可,因为其设计已考虑了增益。
            
            foreach (var band in eqBands)
            {
                currentSampleLeft = band.filterLeft.Process(currentSampleLeft);
                if (channels == 2)
                {
                    currentSampleRight = band.filterRight.Process(currentSampleRight);
                }
            }

            data[i] = currentSampleLeft;
            if (channels == 2)
            {
                data[i + 1] = currentSampleRight;
            }
        }
    }

    void OnDisable()
    {
        // 禁用时重置滤波器状态,避免下次启用时出现不连续
        foreach (var band in eqBands)
        {
            band.ResetFilters();
        }
    }
}

关键实现细节与注意事项

  1. Q值调整: Q值(Quality Factor)直接控制了滤波器带宽。Q值越大,滤波器越尖锐,影响的频率范围越窄;Q值越小,滤波器越平坦,影响的频率范围越宽。在我们的公式中,alpha的计算直接与Q值挂钩。
  2. 增益控制: gainDb参数通过A(线性增益)因子影响b0b2以及a0a2系数,从而改变指定频率处的音量。正值表示提升,负值表示衰减,0dB表示直通。
  3. 实时参数更新:OnValidate中更新滤波器系数,可以在Unity编辑器中实时调整EQ参数并立即听到效果,这对于调试和音色设计非常有用。但在运行时动态改变参数时,直接更新系数可能会导致“拉链效应”(zipper noise)。更平滑的过渡通常需要对系数进行插值,或者使用AudioSettings.dspTime来定时更新,但对于本例的演示,直接更新是可接受的。
  4. 性能优化: OnAudioFilterRead是在音频线程中执行的,要求极高的效率。避免在此函数内部进行任何内存分配(new操作,List的增删改,字符串操作等),因为这会导致垃圾回收(GC)并可能造成爆音。所有对象和变量应在AwakeStart中预先分配好。
  5. 滤波器状态: 每个Biquad滤波器实例必须维护其自身的x1, x2, y1, y2状态变量。这意味着每个声道的每个频段都需要独立的滤波器实例,以正确处理立体声信号。在我们的EQBand类中,我们为左右声道分别创建了BiquadFilter实例。
  6. 防止削波(Clipping): 均衡器可能会提升某些频率的音量,导致总音量超过1.0(或-1.0),从而产生数字削波失真。在实际应用中,你可能需要在EQ处理之后添加一个限幅器(limiter)来防止这种情况,或者对最终输出进行归一化处理。
  7. 串行与并行处理: 典型的多段均衡器会将多个峰值滤波器“并联”应用于原始音频信号。这意味着每个滤波器独立地作用于原始信号,然后将所有滤波器的输出叠加起来。在上面的代码中,我们采用了串行处理,即将一个滤波器的输出作为下一个滤波器的输入。对于峰值滤波器而言,这两种方式在增益控制上会有细微差异,但对于实现常见的EQ效果,串行处理也足够直观和有效。如果你需要更精细的控制,可以考虑将每个频段的“增益部分”叠加到原始信号上。

通过上述方法,你就可以在Unity中搭建一个功能完备且可实时调节的多段均衡器了。这不仅提升了你在游戏音频中进行精细音色塑造的能力,也为更复杂的音频效果链打下了坚实的基础。去尝试吧,感受数字信号处理的魅力!

评论