利用FFmpeg进行音频降噪

之前负责的一个项目是音频关键词识别,就是要从音频中提取出关键词,用于给视频打标签等功能。然而在实际的应用场景中,音频的质量参差不齐,各种噪音会影响语音识别的效果。因此,在做语音识别之前,首先要做的是音频降噪,FFmpeg是使用最广泛的音视频处理工具,这篇文章就是记录一下在C++中用FFmpeg进行音频降噪的处理流程。

关于FFmpeg

什么是FFmpeg

FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。采用LGPL或GPL许可证。它提供了录制、转换以及流化音视频的完整解决方案(摘自百度百科)。

FFmpeg应该是音视频领域用的最多的一款开源工具了,几乎你需要的所有功能都能在FFmpeg中找到,关于FFmpeg的教程,推荐在CSDN上看雷霄骅的专栏,写的非常好,可惜天妒英才,雷霄骅于2016年过世,愿天堂没有劳累,Respect

在Windows平台下使用FFmpeg(C++)

FFmpeg是在Linux下开发的工具,所以在Linux平台下使用很简单,在Windows平台中使用FFmpeg主要分为两种方式,一种方式是将FFmpeg编译成lib或者dll进行调用,另一种方式是通过cmd启动ffmpeg.exe以输入命令的方式调用,本文介绍第二种方式。

首先下载FFmpeg,可通过官网进行下载,选择Windows版本,下载好会得到FFmpeg.exe,要在C++中通过cmd命令行使用FFmpeg,需要以下代码

void RunProcess(string parameters, string ffmpeg_path){
//STARTUPINFO用作子进程应用程序
STARTUPINFO si = { sizeof(STARTUPINFO) };
PROCESS_INFORMATION pi;
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);

//隐藏掉可能出现的cmd命令窗口
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
ZeroMemory(&pi, sizeof(pi));

// ffmpeg_path为ffmpeg.exe的路径
parameters = "cmd /C " + ffmpeg_path + parameters;
wstring parameters_wstr = StringToWstring(parameters);
LPTSTR szCmdLine = const_cast<LPTSTR>(parameters_wstr.c_str());

// Start the child process.
if (!CreateProcess(L"c:\\Windows\\System32\\cmd.exe", // No module name (use command line)
szCmdLine, // Command line
NULL, // Process handle not inheritable
NULL, // Thread handle not inheritable
FALSE, // Set handle inheritance to FALSE
CREATE_NO_WINDOW, // No creation flags
NULL, // Use parent's environment block
NULL, // Use parent's starting directory
&si, // Pointer to STARTUPINFO structure
&pi) // Pointer to PROCESS_INFORMATION structure
) {
WriteErrorLog("命令行执行失败!" + to_string(long long(GetLastError())));
}

WaitForSingleObject(pi.hProcess, INFINITE);

// CLOSE PROCESS AND THREAD HANDLES.
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}

然后就是使用了,使用的代码如下

// 查看音频audio.mp3的信息,命令为:ffmpeg -i audio.mp3
string parameter = "-i audio.mp3"
RunProcess(parameter.c_str(), "C:\\ffmpeg.exe"); // 执行命令行指令

利用FFmpeg进行音频降噪

噪声对于语音识别来说是一个巨大的干扰,且噪声的建模较为复杂,根据声源和麦克风阵列距离的远近,声场模型可分为近场模型和远场模型,我们的应用场景比较固定,声源离麦克风不会特别远,因此只考虑近场模型。

在降噪之前,先对应用场景中可能出现的噪声进行分析,其中部分噪声类型为麦克风底噪(宽频)、电源噪音(50Hz)、喷麦噪音(60-100Hz)、环境底噪(如空调声音/风扇声音等,<200Hz)、人群背景音(和讲话人声音频谱重叠,85-1100Hz),处理这些类型的噪声常用的方法为频域滤波时域滤波幅值均衡

频域滤波

基于噪声类型的频率特点,采用带通滤波器(60-3000Hz,根据应用场景进行调整)滤除低频、高频噪音,频域滤波的代码如下

// 频域滤波,且输出音频类型为采样精度16bits、采样率16kHz、单声道
string parameter = "-y -i \"" + audio_path +
"\" -ac 1 -ar 16000 -acodec pcm_s16le -filter \"bandpass=frequency=1470:width_type=h:width=2940\" "
+ "\"" + return_path + "\""; // 构造ffmpeg完成格式转换和滤波的命令行
RunProcess(parameter.c_str(), "C:\\ffmpeg.exe"); // 执行命令行指令

时域滤波+幅值均衡

相较于频域滤波,时域滤波就有点复杂了,若以讲话人的麦克风为中心,则收声距离造成音量分贝大小关系为讲话人>> 人群> 环境噪音,基于此,我们提出了低分贝静音时域降噪法,即低于某分贝的时间段将其音量设为静音,低分贝静音时域降噪法的实现原理如下:

  1. 对频域降噪后的音频采样点的幅值求绝对值

  2. 采用低通滤波器降低毛刺等高频信号的影响;(巴特沃斯低通滤波器,截止频率1000Hz,根据应用场景进行调整)

  3. 对滤波后的音频求包络面(峰值包络面,即峰值点相连获得的包络面)

  4. 求静音门限值,并将经频域降噪后的音频中采样点幅值的绝对值低于此门限值的部分设为静音,求静音门限值的公式如下,其中,n需要通过大量测试来确定一个比较好的值,我这里默认为4

    =1n×静音门限值=\frac{1}{n}\times包络面均值

那么如何求包络面均值呢,包络面的图形如下所示

  • 如中蓝线为经过低通滤波器后的音频信号(幅值上归一化处理),黄线为包络线
  • 包络线用峰值法求得,步长为100,即每100个点中取峰值后连接成的曲线为包络线

计算包络面均值的代码如下,大致原理是计算峰值包络面,即每一百个点中取最大的值作为这个区间的包络值

// 先经过低通滤波器处理,此处计算的门限值即供幅值均衡使用
string parameter = "-y -i " + freq_filter_file +
" -filter \"lowpass=frequency=1000:width_type=h\" \"" + time_filter_file +
"\" 2>> " + audio_filter_log_path;
RunProcess(parameter.c_str());
LOG(INFO) << "时域滤波完成";
   //读取音频wav文件为数组
ifstream infile;
vector<int> array_list; //音频数组

infile.open(temp_url_2, ios::in | ios::binary);
infile.seekg(44); //wav头文件44字节,记载音频的基本信息,在计算音频平均幅值时应去掉不参与计算。
// seekg: 首先是 seek(寻找)到文件中的某个地方,其次是 "g" 表示 "get"
if (!infile) {
WriteErrorLog("静音降噪时,文件打开失败!");
return -16;
}
else {
short int byt = 0;
// 按照16bit音频的保存格式,每16位读取一次
while (true) {
//char* temp = new char[sizeof(byt)];
infile.read((char*)&byt, sizeof(byt));
array_list.push_back(byt); // push_back导致bad_alloc问题
if (infile.eof()) { // eof()判断文件是否为空或者是否读到文件结尾
break;
}
//delete[] temp;
}
LOG(INFO) << "音频已转换为数组,大小为" << array_list.size();
}
infile.close();

//求音频文件长度
// 公式为:time = FileLength / (Sample Rate * Channels * Bits per sample /8)
audio_duration = array_list.size() / (16000.0);
//取绝对值
for (int i = 0; i < (int)array_list.size(); i++) {
array_list[i] = abs(array_list[i]);
}

//求静音门限值
double array_list_mean = 0;//均值
vector<int> temp_list;
vector<int> max_temp_list;

for (int i = 0; i < (int)array_list.size() - 100; i = i + 100) {
temp_list.assign(array_list.begin() + i, array_list.begin() + i + 100); //assign()顺次地把一个 string 对象的部分内容拷贝到另一个 string 对象上
sort(temp_list.begin(), temp_list.end());
max_temp_list.push_back(temp_list[temp_list.size() - 1]);
array_list_mean = (max_temp_list[max_temp_list.size() - 1]) + array_list_mean;
temp_list.clear();
}

//计算幅值均衡的各个转折点的坐标值
array_list_mean = array_list_mean / max_temp_list.size();//包络面均值
double threshold_value = array_list_mean*threshold_factor;
double dBFS_mean_value_1 = 20 * log10(abs(threshold_value) / 32768.0);//静音门限值,dBFS。作为第二转折点的横坐标
dBFS_mean_value = (int)round(dBFS_mean_value_1); //round四舍五入
LOG(INFO) << "门限值为" << dBFS_mean_value << "dBFS";

//ffmpeg -y -i 16k.wav -af "compand=0.1:0.1:-90/-90 -66/-90 -25/0:.02:0.01:-90:0" 5.wav
double abscissa_3_1 = dBFS_mean_value + (90 / equilibrium_factor);//第三转折点的横坐标,equilibrium_factor=2
//第三转折点=dBFS_mean_value + 45
int abscissa_3 = (int)round(abscissa_3_1);
string dBFS_mean_value_string = to_string(dBFS_mean_value);//第2转折点的横坐标
string abscissa_3_string = to_string(abscissa_3);//第3转折点的横坐标

//生成命令行指令,进行幅值均衡
string para_threshold = "-y -i " + temp_url_1 +
" -af \"compand=0.1:0.1:-90/-90 " + dBFS_mean_value_string + "/-90 " +
abscissa_3_string + "/0:.02:0.01:-90:0\" " + temp_url_3 + " 2>> " + audio_filter_log_path;
RunProcess(para_threshold.c_str(), "C:\\ffmpeg.exe");

vector<int>().swap(array_list); // 释放vector
Author: Hongyi Guo
Link: https://guohongyi.com/2020/09/28/利用FFmpeg进行音频预处理/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.