22、音频信号处理:I2S协议、音频编解码芯片配置、FFT频谱分析、回声效果实现

音频处理,说实话,是FPGA应用里特别有意思的一个方向。它不像高速接口那样纯粹拼速率,也不像图像处理那样数据量巨大。音频嘛,每秒几十K的采样率,对FPGA来说简直是小菜一碟。但有意思的地方在于——你得跟模拟世界打交道了。

我最早接触音频项目,是做一款数字调音台。那时候刚上手,觉得I2S协议不就是个串行数据嘛,能有多难?结果被时序折腾了两天。嗯,今天咱们就把这块彻底讲透。

22.1 I2S协议:数字音频的“普通话”

I2S,全称是Inter-IC Sound,说白了就是芯片之间传音频数据的标准。飞利浦公司搞出来的,现在几乎所有音频编解码芯片都支持它。

它有三根线:

  • BCLK(位时钟):每个脉冲对应一个数据位。采样率 × 位宽 × 通道数,就是BCLK的频率。比如44.1kHz采样、16位、立体声,BCLK就是44.1k × 16 × 2 = 1.4112MHz。
  • LRCLK(左右声道时钟):也叫帧时钟。高电平左声道,低电平右声道,频率等于采样率。
  • SDATA(串行数据):数据在BCLK的某个边沿被采样。标准I2S是MSB对齐,数据在LRCLK变化后的第二个BCLK边沿开始传输。

关键点:I2S的数据传输,数据位比BCLK周期少一个。比如16位数据,BCLK要产生17个脉冲?不对,其实是16个。但数据从第二个BCLK开始有效,第一个BCLK是留给前一个数据的最后一位?嗯,这里容易搞混。

我建议你直接看时序图。标准I2S中,SDATA在LRCLK变化后的第一个BCLK上升沿之前就已经准备好了。说白了,数据比BCLK提前半个周期。

在FPGA里实现I2S接收,我习惯用状态机。一个简单的接收状态机大概长这样:

// I2S接收模块 - 简化版
module i2s_rx (
    input  wire        bclk,
    input  wire        lrclk,
    input  wire        sdata,
    output reg  [15:0] left_data,
    output reg  [15:0] right_data,
    output reg         data_valid
);

    reg [4:0] bit_cnt;
    reg [15:0] shift_reg;
    reg lrclk_d1, lrclk_d2;

    always @(posedge bclk) begin
        lrclk_d1 <= lrclk;
        lrclk_d2 <= lrclk_d1;

        // 检测LRCLK边沿,重置位计数
        if (lrclk_d1 != lrclk_d2) begin
            bit_cnt <= 0;
            // 根据LRCLK电平判断左右声道
            if (lrclk_d2 == 1'b1) begin
                // 左声道数据已准备好
                left_data <= shift_reg;
            end else begin
                right_data <= shift_reg;
            end
        end else begin
            // 正常移位
            if (bit_cnt < 15) begin
                shift_reg <= {shift_reg[14:0], sdata};
                bit_cnt <= bit_cnt + 1;
            end
        end
    end

    // 数据有效标志
    always @(posedge bclk) begin
        if (bit_cnt == 15 && lrclk_d1 == lrclk_d2)
            data_valid <= 1'b1;
        else
            data_valid <= 1'b0;
    end

endmodule

小提示:实际项目中,BCLK和LRCLK通常来自音频编解码芯片的主时钟输出。我建议你用PLL生成一个多倍频的时钟来采样BCLK,避免亚稳态。我曾经因为省这个,导致偶尔出现爆音,查了两天才找到原因。

22.2 音频编解码芯片配置:I2C控制接口

常用的音频编解码芯片,比如WM8731、TLV320AIC23B,都通过I2C或SPI来配置寄存器。配置内容包括采样率、位宽、输入输出增益、麦克风偏置等等。

以WM8731为例,它的I2C地址是0x1A(7位地址)。每个寄存器9位数据,传输时先发7位地址+1位读写位,然后发7位寄存器地址+9位数据。嗯,这里要注意,WM8731的数据是左对齐的9位,但I2C是8位一字节,所以需要拆成两个字节发。

我整理了一份常用配置表:

寄存器地址 寄存器名称 典型值 说明
0x00 左声道输入音量 0x017 左声道0dB增益,无静音
0x02 左声道耳机输出 0x079 左声道输出0dB,使能
0x04 模拟音频路径 0x012 使能DAC,选择线路输入
0x06 数字音频路径 0x000 无衰减,无静音
0x08 采样率控制 0x002 48kHz采样,USB模式
0x0A 数字音频接口格式 0x002 I2S格式,16位数据
0x0C 激活控制 0x001 激活接口

注意:配置音频编解码芯片时,一定要先配置所有寄存器,最后再写激活寄存器(0x0C)。如果先激活再配置,芯片可能会进入不确定状态。我踩过这个坑,当时耳机里全是噪声,还以为板子焊坏了。

I2C控制器的实现,说白了就是一个状态机加一个计数器。紫光同创的FPGA里,我习惯直接用逻辑搭一个简易I2C主控,不占用额外的硬核资源。代码大概100多行,核心就是SCL时钟的生成和SDA数据的控制。

22.3 FFT频谱分析:从时域到频域

做音频效果器,FFT是绕不开的。你想显示频谱?用FFT。你想做均衡器?用FFT。你想做降噪?还是FFT。

FPGA里做FFT,我推荐用Xilinx的FFT IP核?等等,咱们用的是紫光同创。紫光的IP核库里也有FFT的IP,但说实话,对于音频应用,采样点数一般不需要太大。1024点FFT,48kHz采样率,频率分辨率是48k/1024 ≈ 46.875Hz。够用了。

如果你不想用IP核,自己写一个基2的FFT也不难。核心就是蝶形运算:

// 蝶形运算单元 - 16位定点数
module butterfly (
    input  wire signed [15:0] a_real, a_imag,
    input  wire signed [15:0] b_real, b_imag,
    input  wire signed [15:0] w_real, w_imag,
    output reg  signed [15:0] out_a_real, out_a_imag,
    output reg  signed [15:0] out_b_real, out_b_imag
);

    wire signed [31:0] mult1, mult2, mult3, mult4;
    wire signed [31:0] temp1, temp2;

    // b * W
    assign mult1 = b_real * w_real;
    assign mult2 = b_imag * w_imag;
    assign mult3 = b_real * w_imag;
    assign mult4 = b_imag * w_real;

    assign temp1 = mult1 - mult2;  // b_real * w_real - b_imag * w_imag
    assign temp2 = mult3 + mult4;  // b_real * w_imag + b_imag * w_real

    // 输出
    always @(*) begin
        out_a_real <= a_real + temp1[30:15];  // 截取高位
        out_a_imag <= a_imag + temp2[30:15];
        out_b_real <= a_real - temp1[30:15];
        out_b_imag <= a_imag - temp2[30:15];
    end

endmodule

这里要注意定点数的位宽问题。16位输入,经过乘法变成32位,再截断回16位。截断时我习惯用四舍五入,而不是直接截断低位,否则会有直流偏置。

经验之谈:做FFT时,窗函数的选择很重要。矩形窗频率泄露严重,汉宁窗是音频分析的首选。我一般在FPGA里预存一个汉宁窗的查找表,数据进来先加窗再做FFT。这样频谱看起来干净很多。

22.4 回声效果实现:延迟与反馈

回声效果,说白了就是把音频信号延迟一段时间,再以一定比例叠加回原信号。数学表达式很简单:

y[n] = x[n] + α × x[n - D]

其中D是延迟采样点数,α是反馈系数(0到1之间)。

在FPGA里实现,核心就是一个环形缓冲区(FIFO)。我习惯用Block RAM来做,延迟时间可配置。比如48kHz采样率,想实现500ms的回声,延迟点数就是48000 × 0.5 = 24000点。16位立体声,需要24000 × 2 × 2 = 96KB的BRAM。紫光同创的PGL25G有大概100KB的BRAM,刚好够用。

代码实现思路:

// 回声效果模块 - 简化版
module echo_effect (
    input  wire        clk,
    input  wire        rst_n,
    input  wire [15:0] audio_in,
    input  wire        data_valid,
    output reg  [15:0] audio_out,
    output reg         out_valid
);

    // 环形缓冲区参数
    parameter DELAY_MAX = 24000;
    reg [14:0] wr_ptr, rd_ptr;
    reg [15:0] buffer [0:DELAY_MAX-1];

    // 反馈系数 - 0.6
    parameter FEEDBACK = 16'd19661;  // Q15格式,0.6

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            wr_ptr <= 0;
            rd_ptr <= 0;
            audio_out <= 0;
            out_valid <= 0;
        end else if (data_valid) begin
            // 写入当前数据
            buffer[wr_ptr] <= audio_in;
            wr_ptr <= wr_ptr + 1;

            // 读取延迟数据
            rd_ptr <= wr_ptr - DELAY + 1;  // 延迟DELAY个点

            // 混合:输出 = 输入 + 0.6 * 延迟数据
            audio_out <= audio_in + (FEEDBACK * buffer[rd_ptr]) >> 16;
            out_valid <= 1'b1;
        end else begin
            out_valid <= 1'b0;
        end
    end

endmodule

优化建议:实际项目中,回声效果往往需要多个延迟叠加,形成混响效果。我做过一个8路延迟的混响器,每路延迟时间不同,反馈系数也不同。听起来就像在音乐厅里一样。但要注意,反馈系数不能超过1,否则会自激振荡——嗯,就是那种刺耳的啸叫声。

22.5 综合实战:音频处理流水线

把上面几个模块串起来,就是一个完整的音频处理流水线:

  1. 音频编解码芯片通过I2S发送数据到FPGA
  2. FPGA接收I2S数据,存入FIFO做缓冲
  3. 数据分两路:一路直接送FFT做频谱分析,另一路送回声效果模块
  4. FFT结果通过串口或LCD显示频谱
  5. 回声处理后的数据通过I2S发送回音频编解码芯片输出

我建议你先在开发板上跑通I2S收发,听到声音了,再逐步添加FFT和回声功能。别一上来就想全搞定,那样出了问题都不知道是哪一环的锅。

记得有一次,我调试回声效果时,发现延迟时间总是不对。查了半天,原来是I2S的采样率配置错了,实际跑的是32kHz,但我代码里写死了48kHz的延迟参数。嗯,这种低级错误,谁还没犯过几次呢?

好了,音频处理这块就讲到这里。下一章咱们聊聊视频接口,那又是另一个有意思的领域了。