从8k嘈杂到16k清晰,我是如何使用RNNoise+libresample构建音频降噪管道的?

最近要实现一个需求:需要对输入的8k可能嘈杂的音频(输入为裸的PCM流),做降噪处理,输出16k。网上查了一些资料,完成该模块后,经过测试,降噪效果明显,但是在设计的时候也踩了很多坑,在这里进行一个总结。


在处理实时语音或旧录音设备数据时,通常会面临两个问题,采样率低和背景噪音大,为了解决这个问题,我使用RNNoise和libresample构建了一个音频增强模块,实现了从8kHz采样率提升至16kHz,并同步完成深度学习降噪的闭环流程。

方案选择

**RNNoise:**是Xiph.Org基金会开发的轻量级、实时、深度学习语音降噪库。效果好、模型极小、延迟低。注意支持的音频格式:固定48000kHz、位深16bit(short)、单声道、帧长480样本(10ms)、原始PCM(.pcm),无压缩。

**libresample:**基于带限插值轻量级音频重采样库(高质量的 FIR 滤波器实现),主要用于在不同采样率之间(非整数倍也可以)做转换,在语音处理(尤其实时处理)里用得很多,因为API简单(C语言)、稳定、低延迟、支持流式处理(适合实时音频)。

  • libresample:轻量、实时
  • speexdsp resampler:更稳定
  • soxr:高音质(离线)

RNNoise官方库原生只支持48kHz的音频,不直接支持16kHz的输入,它是专门为48kHz设计和训练的。

可以选择从8k重采样至48kHz,再使用RNNoise官方库深度学习降噪,处理完成之后再重采样至16k,但是可能会存在以下问题:

8kHz采样率意味着:根据奈奎斯特采样定理,最高只能表示4kHz的频率

  • 8k->16k 最高仍然是4kHz(只是插值更平滑)
  • 8k->48k 最高仍然是4kHz(不会变成24kHz)

信息已经丢了,升采样不会恢复。

  • 8k->重采样至48k

插值倍率为6倍,中间会产生大量"虚假高频",高频是"编出来的",RNNoise/ML模型会误判,计算量增加(没收益)

  • 8k->重采样至16k

插值倍率2倍,简单稳定、计算量低、频谱结构更自然,不容易引入伪影。

所以我没有采用8k->重采样48kHz->RNNoise(官方库),因为降噪效果可能变差、高频噪声"假信号增加)、模型输入分布异常,不仅不能提升音质,反而会严重浪费性能,甚至会让降噪效果变差。可以采用8k->重采样16k->RNNoise(魔改后的库)。

网上有魔改后的RNNoise库,支持16kHz,模型是按16kHz训练的,在使用之后,降噪效果也是可以的,下载下来就能用。

https://github.com/YongyuG/rnnoise_16k/tree/master

所以我们选择libresample做音频重采样,使用魔改后的RNNoise(下同)做深度学习降噪。libresample和RNNoise的API非常简单,注意实现时候的细节即可。

核心API介绍

libresample核心三个API:

cpp 复制代码
// 创建重采样器。申请内存,建立滤波器管道
// highQuality音质,
void *resample_open(int      highQuality,  // 1表示高质量、0表示快速
                    double   minFactor,    // 最小重采样倍数
                    double   maxFactor);   // 最大重采样倍数
// 重采样核心:执行重采样
// factor=输出采样率/输入采样率
// 返回值为实际输出了多少采样点
// 必须放在一个 while 循环里反复调用,直到给的数据全部重采样完(不一定全部重采样完,也不一定全部输出完)
int resample_process(void   *handle,       // 重采样句柄
                     double  factor,       // 重采样比例
                     float  *inBuffer,     // 输入float音频
                     int     inBufferLen,  // 输入采样点数量
                     int     lastFlag,     // 1=最后一帧、0=还有数据
                     int    *inBufferUsed, // 输出:实际用了多少输入采样
                     float  *outBuffer,    // 输出:float音频
                     int     outBufferLen);// 输出:缓冲区最大长度 
// 销毁重采样器
void resample_close(void *handle);

好用归好用,但它也有两个非常容易踩坑的特点:

  1. "群延迟"与输出不固定: 高质量的滤波器就像一个长长的缓冲管道,必然会带来几毫秒的计算延迟。必须配合 Ring Buffer(环形缓存) 才能驯服它。

  2. 它的 API 强制要求输入和输出必须是 float 或 double。这就意味着必须在外面包裹一层 short -> float 和 float -> short 的转换和限幅逻辑稍微增加了一点 CPU 的开销。

RNNoise核心三个API:

cpp 复制代码
// 创建降噪器。申请内存,加载神经网络的初始权重,并分配RNN(循环神经网络)所需的历史隐藏状态
// 在整个音频流(比如一次通话、一个文件)开始时只调用一次。有状态,如果要同时处理两路不同的麦克风(比如双声道、或者会议里的两个人),必须调用两次create,生成两个独立的DenoiseState,绝不能混用,否则两路声音的状态会互相干扰,导致彻底乱码。
RNNOISE_EXPORT DenoiseState *rnnoise_create();

// 降噪处理核心。处理当前帧,传入一帧带噪声音频,经过神经网络计算,吐出一帧干净音频。
// out接收输出后数据的浮点数组,in输入的带噪数据的浮点数组(in和out可以指向同一个数组指针,RNNoise支持原地操作)
// 帧长是死规定:每次传入的数组长度必须严格是固定的(官方48k必须传480个float,魔改后的16k改版必须传160个float),少一个多一个都会越界崩溃。
RNNOISE_EXPORT float rnnoise_process_frame(DenoiseState *st, float *out, const float *in);

// 释放降噪器。释放内存。
RNNOISE_EXPORT void rnnoise_destroy(DenoiseState *st);

代码实现

rnnoise_denoise.h

cpp 复制代码
#ifndef RNNOISE_DENOISE_H
#define RNNOISE_DENOISE_H

#include <stdint.h>
#include <string.h>
#include <stdio.h>
#include "rnnoise.h"
#include "libresample.h"

#ifdef __cplusplus
extern "C"
{
#endif

// 固定配置
#define INPUT_8K_FRAME_SIZE 80     // 8kHz 80采样点 一帧 10ms
#define OUTPUT_16K_FRAME_SIZE 160  // RNNoise 改版支持 16kHz,一帧 10ms

// 将所有状态封装到一个上下文中,避免全局变量污染,并解决重采样吞吐延迟问题
typedef struct {
    DenoiseState *denoise_st;
    void *resample_st;
    
    // 环形/线性缓存区:用于解决 libresample 输出点数不固定的问题
    float resample_buffer[1024]; 
    int buffer_len;
} AudioProcessorState;

// 创建降噪与重采样综合处理器
AudioProcessorState *audio_processor_create(void);

// 销毁处理器
void audio_processor_destroy(AudioProcessorState *st);

/**
 * @brief 单次处理输入的一帧 8kHz 数据
 * @param st          处理器句柄
 * @param in_8k_buf   输入 16bit 单声道 8kHz PCM,固定 80 个采样点
 * @param out_16k_buf 输出 16bit 单声道 16kHz PCM,固定 160 个采样点
 * @return int        本次处理成功输出的 16kHz 采样点数(通常是 0 或 160)
 */
int audio_process_frame(AudioProcessorState *st, short *in_8k_buf, short *out_16k_buf);

#ifdef __cplusplus
} 
#endif

#endif // RNNOISE_DENOISE_H

rnnoise_denoise.c

cpp 复制代码
#include "rnnoise_denoise.h"
#include <stdlib.h>

AudioProcessorState *audio_processor_create(void)
{
    // 分配内存
    AudioProcessorState *st = (AudioProcessorState *)malloc(sizeof(AudioProcessorState));
    if (!st)
        return NULL;

    // 1. 初始化降噪器
    st->denoise_st = rnnoise_create();
    if (!st->denoise_st)
    {
        fprintf(stderr, "rnnoise_create error.\n");
        free(st);
        return NULL;
    }

    // 2. 初始化重采样器 (highQuality=1, factor=2.0)
    st->resample_st = resample_open(1, 2.0, 2.0);
    if (!st->resample_st)
    {
        fprintf(stderr, "resample_open error.\n");
        rnnoise_destroy(st->denoise_st);
        free(st);
        return NULL;
    }

    // 3. 初始化缓存状态
    st->buffer_len = 0;
    memset(st->resample_buffer, 0, sizeof(st->resample_buffer));

    return st;
}

void audio_processor_destroy(AudioProcessorState *st)
{
    if (st)
    {
        if (st->denoise_st)
            rnnoise_destroy(st->denoise_st);
        if (st->resample_st)
            resample_close(st->resample_st);
        free(st);
    }
}

int audio_process_frame(AudioProcessorState *st, short *in_8k_buf, short *out_16k_buf)
{
    float in_float_buf[INPUT_8K_FRAME_SIZE];         // short -> float
    float temp_resample_buf[512];                    // 临时接收本次重采样的数据,给够余量防溢出
    float denoised_float_buf[OUTPUT_16K_FRAME_SIZE]; // 存储降噪后的音频

    // 1. 8k short -> float
    for (int i = 0; i < INPUT_8K_FRAME_SIZE; i++)
    {
        in_float_buf[i] = (float)in_8k_buf[i];
    }

    // 2. 重采样 8k -> 16k
    int input_consumed;
    int out_samples = resample_process(st->resample_st,
                                       2.0,
                                       in_float_buf,
                                       INPUT_8K_FRAME_SIZE,
                                       0,
                                       &input_consumed,
                                       temp_resample_buf,
                                       512);

    // 3. 将重采样出来的点数追加到 Buffer 中
    if (out_samples > 0)
    {
        // 防止极端情况下越界(实际正常使用一般不会超出 1024)
        if (st->buffer_len + out_samples > 1024)
        {
            fprintf(stderr, "Buffer overflow warning!\n");
            st->buffer_len = 0; // 强制清空,避免崩溃
        }
        else
        {
            memcpy(st->resample_buffer + st->buffer_len, temp_resample_buf, out_samples * sizeof(float));
            st->buffer_len += out_samples;
        }
    }

    // 4. 判断 Buffer 中的点数是否凑够了一帧 (160点)
    if (st->buffer_len >= OUTPUT_16K_FRAME_SIZE)
    {

        // 5. 核心降噪:从 Buffer 开头取 160 个点处理
        rnnoise_process_frame(st->denoise_st, denoised_float_buf, st->resample_buffer);

        // 6. Buffer 移位:把用掉的 160 个点丢弃,剩余的数据往前挪
        st->buffer_len -= OUTPUT_16K_FRAME_SIZE;
        if (st->buffer_len > 0)
        {
            memmove(st->resample_buffer,
                    st->resample_buffer + OUTPUT_16K_FRAME_SIZE,
                    st->buffer_len * sizeof(float));
        }

        // 7. 为 short 限幅
        for (int i = 0; i < OUTPUT_16K_FRAME_SIZE; i++)
        {
            float sample = denoised_float_buf[i];
            // 严格限幅,防止爆音
            if (sample > 32767.0f)
                sample = 32767.0f;
            if (sample < -32768.0f)
                sample = -32768.0f;

            out_16k_buf[i] = (short)sample;
        }

        return OUTPUT_16K_FRAME_SIZE; // 成功输出了一帧数据
    }

    // 如果没凑够 160 个点,返回 0,告诉调用者这次不用写文件
    return 0;
}

核心架构与"避坑"指南

在开发过程中,我遇到了几个足以让项目流产的"深水坑":

一、线性缓存:解决重采样"断音"

cpp 复制代码
// 将所有状态封装到一个上下文中,避免全局变量污染,并解决重采样吞吐延迟问题
typedef struct {
    DenoiseState *denoise_st;
    void *resample_st;
    
    // 线性缓存区:用于解决 libresample 输出点数不固定的问题
    float resample_buffer[1024]; 
    int buffer_len;
} AudioProcessorState;

a.为什么需要缓存?

缓存的作用主要有两个:

a.解决运算延迟导致的"断流"或"断音"问题。

b.解决算法库本身所需的缓存与实际传递数据的格式不匹配问题。

1、 运算延迟导致断流:libresample在转换采样率时(例如从8kHz转换到16kHz),并非输入多少数据就立刻输出成比例的数据。由于它使用的是高阶滤波算法,数据进入滤波器后会产生一定的延迟。当第一次输入80个采样点时的数据时,它可能正在"填满管道",因此输出的可能是0个点。如果这时不适用缓存把后续输出的数据暂存起来,就会出现音频播放时的"断流"或"断音"现象。

2、 输入输出格式格式不匹配:重采样后,输出的数据点数往往不是预期的固定数值。例如,期望每次都输出160个采样点(RNNoise处理一帧数据的硬性要求),但重采样器可能由于某些因素,某次只吐出了150个点。如果不使用缓存,直接把这150个点传递给RNNoise处理,程序就会出错,因为RNNoise必须接收满160个点才能进行计算。

因此,代码中通过memmove实现的"线性缓存",像一个"水池"一样,它的工作方式是:

  1. "蓄水":每次从重采样拿到数据后,先不急着处理,而是把它追加存入缓存(st->resample_buffer)中。
  2. "开闸":检查缓存中的数据是否满足了后续处理的最低要求(例如攒够了160个点)。
  3. "放水":只有满足条件了,才从缓存头部截取160个点送给RNNoise处理。
  4. "清空水池前部":处理完毕后,利用memmove将用掉的数据丢弃,把后面未满一帧的"零头数据"(例如剩下的10个点)挪到最前面,等待下一次"蓄水"。

这就完美解决了由于输入输出数据量不稳定而造成的各种问题,让后续的流水线能够平稳、顺滑的运行。

b.什么是"线性移位缓存"和"环形缓存?

判断一个缓存是不是真正的"环形缓存",核心标准只有一个:数据在缓存中是否会发生物理位置的移动。

cpp 复制代码
// 6. Buffer 移位:把用掉的 160 个点丢弃,剩余的数据往前挪
st->buffer_len -= OUTPUT_16K_FRAME_SIZE;
if (st->buffer_len > 0)
{
     memmove(st->resample_buffer,
     st->resample_buffer + OUTPUT_16K_FRAME_SIZE,
     st->buffer_len * sizeof(float));
}

线性移位:当消费掉头部的160个数据后,调用了memmove。这个操作会强制将数组后半部分(剩余的未消费数据)整体复制并粘贴到数组的头部。数据在内存中的物理地址发生了真正的搬运。

环形缓存(标准的Ring Buffer):在真正的环形缓存中,数据一旦存入,其物理地址就再也不会改变。只移动"读指针"和"写指针"。当读指针读到数组的末尾时,它会瞬间"折返"到数组的开头继续读取,仿佛这个数组首尾相连成了一个圆环。

c.为什么代码实现成"线性缓存"?

通常来说,环形缓存因为不需要搬运内存,性能更高,被视为更优雅的数据结构。但在结合RNNoise和音频帧处理的特定场景下,"线性移位"方案不仅是合理的,甚至可以说是最佳实践。

主要原因有以下两点:

核心原因1: 算法接口的强制要求(内存必须连续)

这是最致命的限制。来看RNNoise的调用接口:

cpp 复制代码
// RNNOISE_EXPORT float rnnoise_process_frame(DenoiseState *st, float *out, const float *in);
rnnoise_process_frame(st->denoise_st, denoised_float_buf, st->resample_buffer);

RNNoise的rnnoise_process_frame核心降噪函数,要求传入的输入缓冲区(即第三个参数,const float *in),必须是一个物理内存连续的160个float的数组。

如果使用标准的环形缓存,由于它首位相接的特性,极有可能出现这样的情况:比如要读取的160个点中,前100个点在数组的末尾,后60个点"折返"到了数组的开头。这160个点在物理内存上不连续,被切断了。

那么,在面对这种情况时,怎么把数据喂给RNNoise呢?还需要再分配一个临时的160个大小的数组,把尾部的100个点复制进去,再把头部的60个点复制进去拼接在一起。

这不仅破坏了环形缓存"零拷贝"的初衷,还让代码逻辑变得极其复杂,很容易出现索引越界。

而代码实现的线性缓存,通过每次memmove,保证了未处理的数据永远都从索引0开始,并且永远是连续的。这就让喂给RNNoise的操作变得极其简单且安全。

核心原因2:性能损耗微乎其微(忽略不计)

使用memmove频繁搬运内存,难道不浪费CPU吗?

在宏观计算中确实如此,但在当前音频场景下,这种开销小到了可以忽略不计的地步:

1.数据量极小:resample_buffer只有1024个float,总共才4KB的内存。

2.搬运极快:现代的CPU都有专门针对小内存块移动的硬件优化指令,搬运几千个字节连几微妙都用不了。

3.触发频率低:这种搬运每10ms才发生一次,且通常只搬运几十个到一百多个点(因为每次都会尽量凑够160个点消耗掉)。

二、数值范围:RNNoise的"胃口"

这是最坑的一点!通常我们习惯将音频归一化到[-1,0,1.0],但RNNoise的C库实际上是按16bit PCM的数值范围(+-32768)进行内部计算的。

错误做法:传入归一化后的0.0001,导致算法认为那是静音,完全不干活。

正确做法:保持原始浮点数值传入,降噪效果瞬间体现。

降噪测试

魔改的RNNoise库,也提供了测试demo,下载下来,编译后,会在bin目录下生成可执行文件rnn_gao_new,可直接快速测试16kHz的音频降噪效果。

提供16kHz的WAV格式音频,输出降噪后的16kHz的WAV格式音频。

bash 复制代码
[root@VM-8-11-centos rnnoise_16k-master]# ./bin/rnn_gao_new 
Usage:./rnn_gao_new [inputWav] [RNNnoise_output]

下面是自己编写的一段测试代码。

test.c

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include "rnnoise_denoise.h"

int main(int argc, char *argv[])
{
    FILE *fin, *fout;
    short in_8k[INPUT_8K_FRAME_SIZE];
    short out_16k[OUTPUT_16K_FRAME_SIZE];

    if (argc != 3) {
        fprintf(stderr, "用法: %s <输入8k.pcm> <输出16k降噪后.pcm>\n", argv[0]);
        return -1;
    }

    // 打开纯 PCM 文件
    fin = fopen(argv[1], "rb");
    fout = fopen(argv[2], "wb");

    if (!fin || !fout) {
        fprintf(stderr, "文件打开失败\n");
        return -1;
    }

    // 初始化降噪与重采样处理器
    AudioProcessorState *proc_st = audio_processor_create();
    if (!proc_st) {
        fprintf(stderr, "初始化失败\n");
        return -1;
    }

    int total_out_samples = 0;

    // 直接逐帧读取、处理、写入,没有任何文件头的干扰
    while (fread(in_8k, sizeof(short), INPUT_8K_FRAME_SIZE, fin) == INPUT_8K_FRAME_SIZE)
    {
        // 返回 > 0 说明成功凑够了一帧(160点)输出
        int out_count = audio_process_frame(proc_st, in_8k, out_16k);
        
        if (out_count > 0) {
            fwrite(out_16k, sizeof(short), out_count, fout);
            total_out_samples += out_count;
        }
    }

    // 释放资源
    audio_processor_destroy(proc_st);
    fclose(fin);
    fclose(fout);

    printf("处理完成!总共输出 %d 个 16kHz 的采样点\n", total_out_samples);
    return 0;
}

如果音频为8kHz的WAV格式,首选需要将8kHz的WAV音频,转换成8kHz的PCM。绝不能直接读.wav的前44个字节当音频,那是字符信息,必爆音,建议先用sox或ffmpeg转成纯.pcm。

准备一个8kHz的wav音频,使用sox或ffmpeg转成纯.pcm(绝不能直接读.wav的前44个字节当音频,那是字符信息,必爆音),编译生成可执行文件,运行输入8k的.pcm,输出降噪后的16k的.pcm,再使用sox或ffmpeg转成可以收听的wav音频。

例如:

bash 复制代码
// 将8kwav音频转换成纯.pcm
sox 8k_noisy.wav -t raw -c 1 -r 8000 -b 16 -e signed-integer 8k.pcm

// 编译
sudo gcc -std=c99 -o test_denoise rnnoise_denoise.c test.c -I/root/rnnoise_16k-master/include -L /root/rnnoise_16k-master/src -l rnnLib -l resample -lm

// 输入8k的.pcm 输出降噪后的16k的.pcm
./test_denosie 8k.pcm output.pcm

// 将降噪后的16k的.pcm转换成16kwav音频
sox -t raw -c 1 -r 16000 -b 16 -e signed-integer output.pcm 降噪后8k_noisy.wav

视觉验证

下面是我测试的一段嘈杂音频,降噪前后的频谱图。

通过生成的频谱图,可以清晰看到降噪后的战果。

测试前频谱图:

测试后频谱图:

1、降噪算法缺失在卖力干活:

看降噪前频谱图:整个画面充满了大面积的红色和橙色,说明背景里存在极其巨大的全频段底噪。

看降噪后频谱图:刺眼的红色/橙色噪点变成了深蓝色/黑色,证明RNNoise极其准确地识别并压制了环境噪音,同时保留了中间那坨红色的语音能量。

2、完美印证了重采样的成功:

看降噪前频谱图纵轴:原本的8k音频,频谱纵轴最高只到大约4000Hz的位置,右下角文字也显示了8000Hz:mono。

看降噪后频谱图纵轴:频谱纵轴的上限被拉高到了接近8000Hz,右下角文字也显示16000Hz:mono,证明libresample重采样代码毫无瑕疵的完成了从8k到16k的重采样。

经过视觉和听觉的验证,降噪效果是非常明显的。

集成模块

该模块集成到项目中,应该这样使用。

cpp 复制代码
#include "rnnoise_denoise.h"

// ==========================================
// 1. 初始化(在程序启动,或打开麦克风时调用一次)
// ==========================================
AudioProcessorState *proc_st = audio_processor_create();
if (!proc_st) {
    // 处理初始化失败逻辑...
}

// ==========================================
// 2. 音频回调/循环处理(假设每 10ms 触发一次)
// ==========================================
short input_8k[80];    // 你的业务输入:8kHz音频,固定80个点 (10ms)
short output_16k[160]; // 用于接收输出:16kHz降噪后音频,160个点

// -> 这里填充你的 8kHz 原始音频到 input_8k 中
// read_from_mic(input_8k, 80); 

// -> 调用处理核心
int out_count = audio_process_frame(proc_st, input_8k, output_16k);

// -> 判断是否有有效输出(非常重要!)
if (out_count > 0) {
    // 成功!output_16k 中现在包含了 160 个点的 16kHz 干净声音
    // 可以拿去播放、推流、编码或保存
    // send_to_speaker(output_16k, out_count);
} else {
    // 内部缓存还没凑够一帧,本次暂无输出。
    // 这是正常现象,直接跳过本次写入,等下一次循环继续喂数据即可。
}

// ==========================================
// 3. 退出时释放(关闭程序,或关闭麦克风时调用)
// ==========================================
audio_processor_destroy(proc_st);

扩展:

彻底告别"爆音":还可以将一个软启动(Fade-in):在音频流开启的前100ms施加一个平滑的增益渐变,压制算法冷启动时的脉冲噪声。

总结:音频处理不仅仅是算法的堆砌,更是对数据流细节的极致掌控。虽然由于8kHz原始高频信息丢失,我们无法完全还原成"录音室音质",但在实时通讯和语音识别的处理中,这套音频降噪模块已经足够强大。

相关推荐
YWamy6 小时前
音视频SDK赋能智能硬件:实时RTC技术的应用难点与落地实践
音视频·实时音视频·智能硬件
@小码农6 小时前
2026年信息素养大赛【星火征途】图形化编程复赛和决赛模拟题B
开发语言·数据结构·c++·算法
科研前沿6 小时前
深耕数字孪生与视频孪生,打造行业标杆
音视频
tjl521314_216 小时前
02C++ 静态变量与链接性
java·jvm·c++
(Charon)6 小时前
【C++/Qt】Qt 实现 WebSocket 测试工具:连接、消息收发与通信日志
c++·qt·websocket
Hello eveybody6 小时前
学习C++的好处
开发语言·c++
机器小乙6 小时前
AI客户端架构演进:从套壳插件到C++原生护城河
c++·人工智能·架构
十五年专注C++开发6 小时前
CMake基础: Qt之qt5_wrap_ui
开发语言·c++·qt·ui
南境十里·墨染春水6 小时前
C++日志 1——日志系统的概念与分类
开发语言·c++