Qt + FFmpeg 实战:音频高低通滤波实现

概述

高低通滤波是音频修复和声音整理中非常常用的一类处理。高通滤波(Highpass)用于削减截止频率以下的低频内容,例如麦克风低频隆隆声、风噪、手持震动;低通滤波(Lowpass)用于削减截止频率以上的高频内容,例如底噪嘶声、电磁干扰、过亮齿音等。

当前项目中的"高低通滤波"功能采用 FFmpeg highpass / lowpass 滤镜实现,整体设计保持非破坏式:处理结果会写入独立的预览 PCM 和导出 WAV,不直接覆盖当前解码后的原始 PCM。这样用户可以反复调整参数、预览对比、再决定是否导出。

效果图:

功能入口

后端向 QML 暴露了三类能力:

cpp 复制代码
Q_INVOKABLE bool exportFilteredWav(double highpassHz,
                                   double lowpassHz,
                                   int poles,
                                   const QString &filePath);

Q_INVOKABLE void previewFilter(double highpassHz,
                               double lowpassHz,
                               int poles);

Q_INVOKABLE void clearFilterPreview();

同时通过属性把处理结果返回给界面:

cpp 复制代码
Q_PROPERTY(QVariantMap filterInfo READ filterInfo NOTIFY filterInfoChanged)
Q_PROPERTY(int filteredPcmSize READ filteredPcmSize NOTIFY filteredPcmChanged)
Q_PROPERTY(QByteArray filteredPcmData READ filteredPcmData NOTIFY filteredPcmChanged)
Q_PROPERTY(QVariantMap filteredPcmInfo READ filteredPcmInfo NOTIFY filteredPcmChanged)

QML 页面只负责收集参数和展示结果。真正的音频处理放在 AudioToolProcessor::filterPcmMediaAnalyzer 负责异步调度、状态更新、结果缓存和 WAV 导出。

参数设计

当前滤波功能提供三个核心参数:

参数 含义 处理方式
highpassHz 高通截止频率,单位 Hz 0 表示禁用高通
lowpassHz 低通截止频率,单位 Hz 0 表示禁用低通
poles 滤波器阶数 只允许 12,非法值回退到 2

底层会做参数保护:

cpp 复制代码
if (highpassHz <= 0.0 && lowpassHz <= 0.0) {
    *errorText = QStringLiteral("至少需要启用一个滤波器(高通或低通)。");
    return false;
}

highpassHz = qBound(0.0, highpassHz,
                    static_cast<double>(sampleRate) / 2.0 - 1.0);
lowpassHz = qBound(0.0, lowpassHz,
                   static_cast<double>(sampleRate) / 2.0 - 1.0);
poles = (poles < 1 || poles > 2) ? 2 : poles;

这里最重要的是截止频率不能超过奈奎斯特频率,也就是采样率的一半。项目中保留了 sampleRate / 2 - 1 的余量,避免 FFmpeg 滤镜收到无效频率。

整体处理链路

项目中解码后的 PCM 是 packed / interleaved 格式,例如双声道 16-bit PCM 的内存排列是:

text 复制代码
L0 R0 L1 R1 L2 R2 ...

而 FFmpeg 音频滤镜更适合处理平面浮点格式 FLTP。因此滤波管线分为五步:

text 复制代码
packed PCM
    │
    ▼
SwrContext:原始 PCM → FLTP
    │
    ▼
FFmpeg filter graph:
abuffer → highpass? → lowpass? → aformat → abuffersink
    │
    ▼
SwrContext:FLTP → 原始 PCM 格式
    │
    ▼
packed PCM 输出

这个设计有两个好处:

  1. 输入和输出保持原始采样率、通道数和位深,调用方不用感知中间格式变化。
  2. 滤镜内部统一使用 FLTP,可以减少不同整数位深下的处理分支。

格式检查与 frame 对齐

滤波前会先从 inputInfo 解析采样率、通道数和采样格式:

cpp 复制代码
const int sampleRate = inputInfo.value(QStringLiteral("sampleRate")).toInt();
const int channels = inputInfo.value(QStringLiteral("channels")).toInt();
const SampleSpec sampleSpec = sampleSpecFromInfo(inputInfo);

然后计算一个 frame 的字节数:

cpp 复制代码
const int frameSize = channels * sampleSpec.bytesPerSample;
if (frameSize <= 0 || inputPcm.size() % frameSize != 0) {
    *errorText = QStringLiteral("PCM size does not match its format.");
    return false;
}

这里的 frame 是"同一时间点的所有通道采样"。所有切分和转换都必须按 frame 对齐,否则会破坏多声道排列,造成声道错位或杂音。

packed PCM 转 FLTP

底层使用 SwrContext 把输入 PCM 转成 AV_SAMPLE_FMT_FLTP

cpp 复制代码
swr_alloc_set_opts2(&swrIn,
                    &layout, AV_SAMPLE_FMT_FLTP, sampleRate,
                    &layout, sampleSpec.format, sampleRate,
                    0, nullptr);

转换后的数据通过 av_samples_alloc_array_and_samples 分配为多声道平面数据:

text 复制代码
channel 0: L0 L1 L2 ...
channel 1: R0 R1 R2 ...

这一步把项目自己的 packed PCM 数据变成 FFmpeg filter graph 可直接消费的浮点平面帧。

动态构建滤镜链

高通和低通都可以单独启用,也可以组合启用,因此滤镜描述字符串是动态拼接的:

cpp 复制代码
QStringList filters;

if (highpassHz > 0.0) {
    filters.append(QStringLiteral("highpass=f=%1:p=%2")
                       .arg(QString::number(highpassHz, 'f', 1))
                       .arg(poles));
}

if (lowpassHz > 0.0) {
    filters.append(QStringLiteral("lowpass=f=%1:p=%2")
                       .arg(QString::number(lowpassHz, 'f', 0))
                       .arg(poles));
}

filters.append(QStringLiteral(
    "aformat=sample_fmts=fltp:sample_rates=%1:channel_layouts=%2")
    .arg(sampleRate).arg(channelLayout));

const QString filterDesc = filters.join(QStringLiteral(","));

几个典型结果:

text 复制代码
highpass=f=80.0:p=2,aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo
lowpass=f=8000:p=2,aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo
highpass=f=300.0:p=2,lowpass=f=3400:p=2,aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo

最后追加 aformat 是为了约束滤镜输出格式,保证后续 swrOut 能按预期把 FLTP 转回原始 PCM 格式。

Filter Graph 执行

项目手动构建了一个最小的 FFmpeg filter graph:

text 复制代码
abuffer 作为输入节点
abuffersink 作为输出节点
中间通过 avfilter_graph_parse_ptr 按 filterDesc 自动连接

输入帧创建时指定格式、采样率、样本数和通道布局:

cpp 复制代码
inFrame->format = AV_SAMPLE_FMT_FLTP;
inFrame->sample_rate = sampleRate;
inFrame->nb_samples = convertedIn;
av_channel_layout_copy(&inFrame->ch_layout, &layout);

for (int ch = 0; ch < channels; ++ch)
    inFrame->data[ch] = fltpPlanes[ch];

随后把整段 PCM 推入 abuffer

cpp 复制代码
av_buffersrc_add_frame_flags(abufferCtx, inFrame, AV_BUFFERSRC_FLAG_PUSH);
av_buffersrc_add_frame_flags(abufferCtx, nullptr, AV_BUFFERSRC_FLAG_PUSH);

第二次推入 nullptr 表示输入结束,让滤镜内部可以 flush 尾部缓存。

输出侧循环从 abuffersink 拉取处理后的帧:

cpp 复制代码
while (true) {
    ret = av_buffersink_get_frame(abuffersinkCtx, outFrame);
    if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)
        break;

    for (int ch = 0; ch < channels; ++ch)
        outputFltpPlanes[ch].append(
            reinterpret_cast<const char *>(outFrame->extended_data[ch]),
            outFrame->nb_samples * sizeof(float));
}

这里把每次拉出的平面浮点数据追加到 QVector<QByteArray>,最后再统一转回 packed PCM。

FLTP 转回原始 PCM

输出转换使用第二个 SwrContext

cpp 复制代码
swr_alloc_set_opts2(&swrOut,
                    &layout, sampleSpec.format, sampleRate,
                    &layout, AV_SAMPLE_FMT_FLTP, sampleRate,
                    0, nullptr);

转换完成后,输出 QByteArray 会按原始 frame size 截断到真实转换样本数:

cpp 复制代码
packedOut.resize(convertedOut * frameSize);

并生成新的 pcmInfo

cpp 复制代码
QVariantMap pcmInfo = inputInfo;
pcmInfo.insert(QStringLiteral("size"), packedOut.size());
pcmInfo.insert(QStringLiteral("sizeText"),
               FFmpegUtils::formatBytes(packedOut.size()));

这意味着滤波结果仍然是当前项目内部通用的 PCM 表示,可以继续用于波形预览、播放和 WAV 封装。

预览与导出

MediaAnalyzer::previewFilter 会复制当前 PCM 和格式信息,然后异步执行 filterPcm

cpp 复制代码
const QByteArray pcmCopy = m_pcmData;
const QVariantMap pcmInfoCopy = m_mediaInfo.value(QStringLiteral("pcm")).toMap();
const AudioToolProcessor processor;

成功后只更新预览缓存:

cpp 复制代码
setFilteredPcm(r.pcmData, r.pcmInfo);
setFilterInfo(r.info);
setStatus(QStringLiteral("滤波预览完成,可以对比波形"));

exportFilteredWav 则在处理成功后多做一步 WAV 封装:

cpp 复制代码
if (r.ok)
    r.ok = processor.savePcmAsWav(outputPath,
                                  r.pcmData,
                                  r.pcmInfo,
                                  &r.errorText);

导出默认路径采用当前文件名追加 _filtered.wav

cpp 复制代码
return fileInfo.absolutePath()
    + QLatin1Char('/')
    + fileInfo.completeBaseName()
    + QStringLiteral("_filtered.wav");

QML 页面交互

FilterPage.qml 提供了完整的参数界面:

  • 高通开关 + 20Hz 到 500Hz 滑块,默认 80Hz
  • 低通开关 + 1000Hz 到 20000Hz 滑块,默认 12000Hz
  • 滤波器阶数下拉框:1 pole2 poles
  • 常用预设:去除低频隆隆声、去除高频嘶声、电话频段、人声频段、广播频段
  • 处理前后两个 WaveformPlayer,用于波形对比和试听

页面点击"滤波预览"时只调用:

js 复制代码
mediaAnalyzer.previewFilter(hp, lp, p)

点击"导出 WAV"时调用:

js 复制代码
mediaAnalyzer.exportFilteredWav(hp, lp, p, outputPath)

前端不参与任何采样数据处理,职责边界比较清晰。

结果信息

滤波完成后,filterInfo 会记录处理摘要:

cpp 复制代码
info.insert(QStringLiteral("highpassHz"), ...);
info.insert(QStringLiteral("lowpassHz"), ...);
info.insert(QStringLiteral("poles"), poles);
info.insert(QStringLiteral("inputDurationText"), ...);
info.insert(QStringLiteral("outputDurationText"), ...);
info.insert(QStringLiteral("inputSizeText"), ...);
info.insert(QStringLiteral("outputSizeText"), ...);
info.insert(QStringLiteral("sampleRate"), sampleRate);
info.insert(QStringLiteral("channels"), channels);
info.insert(QStringLiteral("bitsPerSample"), sampleSpec.bitsPerSample);

导出成功时还会补充:

cpp 复制代码
info.insert(QStringLiteral("outputPath"), r.outputPath);
info.insert(QStringLiteral("outputSizeText"),
            FFmpegUtils::formatBytes(r.outputSize));

这些字段最终由 QML 的 InfoPanel 展示给用户。

实现特点

  1. 非破坏式处理 :滤波预览和导出都不覆盖 m_pcmData
  2. 格式兼容性好 :输入支持项目当前常用的 8-bit、16-bit、32-bit PCM,内部统一转 FLTP
  3. 滤镜链可组合:高通、低通可以独立启用,也可以串联形成带通效果。
  4. 异步执行MediaAnalyzer 使用 runAsync 调度,避免界面阻塞。
  5. 结果可视化 :前端用两个 WaveformPlayer 展示处理前后波形,支持试听和缩放对比。

适用场景

常用参数可以这样理解:

场景 高通 低通
去除低频隆隆声 80Hz 禁用
去除高频嘶声 禁用 8000Hz
电话频段模拟 300Hz 3400Hz
人声清理 80Hz 12000Hz
广播频段整理 50Hz 15000Hz

高低通滤波看似简单,但项目里的实现重点不是某一个滤镜参数,而是把 FFmpeg filter graph、PCM 格式转换、QML 预览、WAV 导出和状态展示串成了一条稳定的工具链。这个管线后续也可以继续扩展到更多基于 FFmpeg 滤镜的音频处理功能。