Qt/QML 音频频谱图与频谱瀑布图实现:从 PCM 到频域可视化

前言

波形图展示的是声音在时间轴上的振幅变化,适合观察音量、峰值、静音段和裁剪位置。但如果想知道声音里有哪些频率成分,例如低频轰鸣、高频噪声、人声谐波、压缩后的高频缺失,仅看波形是不够的。

频谱图和频谱瀑布图解决的是频域观察问题:

  • 频谱图:查看某一个时间点附近,不同频率的能量分布。
  • 频谱瀑布图:查看整段音频中,频率能量如何随时间变化。

本项目中,FFmpeg 仍然只负责把音频解码成 PCM。频谱分析和绘制没有使用 FFmpeg 的 FFT 接口,而是在项目内新增轻量独立模块实现,方便后续移植。

效果图:

支持横选、竖选、框选,只是鼠标滚轮缩放,鼠标右键按住拖动平移、Ctrl+鼠标缩放频率轴、支持设置FFT、四种颜色样式切换。

数据流

整体数据流如下:

text 复制代码
音频文件
  -> FFmpeg 解码
  -> PCM: signed 16-bit little-endian interleaved
  -> SpectrumAnalyzer
  -> FFT 频谱数据 / 瀑布图矩阵
  -> SpectrumView / SpectrogramView 绘制

项目当前 PCM 通常是 s16 交错格式:

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

频谱分析时先根据 frameIndexchannel 找到采样点,再归一化到 [-1, 1]

关键代码:

cpp 复制代码
qreal SpectrumAnalyzer::sampleValue(const QByteArray &pcmData,
                                    const PcmFormat &format,
                                    qint64 frameIndex,
                                    int channel)
{
    const int bytes = bytesPerSample(format);
    const qreal peak = samplePeakValue(format);
    const qint64 frames = frameCount(pcmData, format);
    if (bytes <= 0 || peak <= 0.0 || frameIndex < 0 || frameIndex >= frames)
        return 0.0;

    const int firstChannel = channel >= 0 && channel < format.channels ? channel : 0;
    const int lastChannel = channel >= 0 && channel < format.channels ? channel : format.channels - 1;
    qreal mixed = 0.0;
    int mixedChannels = 0;

    for (int currentChannel = firstChannel; currentChannel <= lastChannel; ++currentChannel) {
        const qint64 index = (frameIndex * format.channels + currentChannel) * bytes;
        const char *sample = pcmData.constData() + index;

        qreal rawValue = 0.0;
        if (format.bitsPerSample == 8)
            rawValue = static_cast<int>(static_cast<unsigned char>(sample[0])) - 128;
        else if (format.bitsPerSample == 16)
            rawValue = readLe16(sample);
        else if (format.bitsPerSample == 32)
            rawValue = readLe32(sample);

        mixed += rawValue / peak;
        ++mixedChannels;
    }

    return mixedChannels > 0 ? mixed / mixedChannels : 0.0;
}

这里 channel == -1 表示混合所有声道,channel >= 0 表示只分析指定声道。

单帧频谱图原理

频谱图分析的是一个窗口内的频率分布。

例如:

text 复制代码
sampleRate = 44100 Hz
fftSize    = 2048

一次 FFT 会输入 2048 个采样点,输出 1025 个有效频率 bin:

text 复制代码
有效 bin 数 = fftSize / 2 + 1
频率间隔   = sampleRate / fftSize
           = 44100 / 2048
           ≈ 21.53 Hz

所以:

text 复制代码
bin 0   = 0 Hz
bin 1   ≈ 21.53 Hz
bin 100 ≈ 2153 Hz

核心流程:

  1. 以当前时间点为中心截取一段 PCM。
  2. 给窗口乘 Hann 窗,减少频谱泄漏。
  3. 执行 FFT。
  4. 取复数结果的模,得到幅度。
  5. 转为 dB。
  6. 绘制柱状图。

关键代码:

cpp 复制代码
QVector<qreal> analyzeWindow(const QByteArray &pcmData,
                             const SpectrumAnalyzer::PcmFormat &format,
                             int fftSize,
                             qint64 startFrame,
                             int channel)
{
    QVector<std::complex<double>> input(fftSize);
    double windowSum = 0.0;

    for (int i = 0; i < fftSize; ++i) {
        const double window = 0.5 - 0.5 * std::cos(2.0 * kPi * i / (fftSize - 1));
        windowSum += window;
        const qreal sample = SpectrumAnalyzer::sampleValue(pcmData, format, startFrame + i, channel);
        input[i] = std::complex<double>(sample * window, 0.0);
    }

    fft(input);

    QVector<qreal> dbValues(fftSize / 2 + 1);
    const double scale = std::max(1.0, windowSum / 2.0);
    for (int bin = 0; bin < dbValues.size(); ++bin) {
        const double magnitude = std::abs(input[bin]) / scale;
        dbValues[bin] = 20.0 * std::log10(std::max(kMinMagnitude, magnitude));
    }

    return dbValues;
}

Hann 窗的作用

如果直接截取一段 PCM 做 FFT,窗口边界往往是不连续的,会产生频谱泄漏。表现到频谱图上,就是某个频率的能量扩散到附近多个频率 bin。

Hann 窗会让窗口两端平滑衰减:

cpp 复制代码
window = 0.5 - 0.5 * cos(2 * pi * i / (fftSize - 1));

这样可以让频谱显示更稳定,尤其适合可视化。

FFT 实现

这里使用项目内置的 radix-2 Cooley-Tukey FFT,要求 FFT size 是 2 的整数次幂。为了避免用户传入非法值,统一做归一化:

cpp 复制代码
int SpectrumAnalyzer::normalizedFftSize(int requestedSize)
{
    int size = kMinFftSize;
    while (size < requestedSize && size < kMaxFftSize)
        size <<= 1;
    return clampValue(size, kMinFftSize, kMaxFftSize);
}

FFT 主体:

cpp 复制代码
void fft(QVector<std::complex<double>> &values)
{
    const int n = values.size();
    int j = 0;
    for (int i = 1; i < n; ++i) {
        int bit = n >> 1;
        while (j & bit) {
            j ^= bit;
            bit >>= 1;
        }
        j ^= bit;
        if (i < j)
            std::swap(values[i], values[j]);
    }

    for (int len = 2; len <= n; len <<= 1) {
        const double angle = -2.0 * kPi / len;
        const std::complex<double> wlen(std::cos(angle), std::sin(angle));
        for (int i = 0; i < n; i += len) {
            std::complex<double> w(1.0, 0.0);
            for (int k = 0; k < len / 2; ++k) {
                const std::complex<double> u = values[i + k];
                const std::complex<double> v = values[i + k + len / 2] * w;
                values[i + k] = u + v;
                values[i + k + len / 2] = u - v;
                w *= wlen;
            }
        }
    }
}

这个实现足够支撑当前桌面工具里的 1024、2048、4096、8192 点频谱分析。后续如果需要更高性能,可以在 SpectrumAnalyzer 内部替换为 KissFFT、FFTW 或 FFmpeg FFT,外部控件接口不用变。

频谱图绘制

SpectrumViewItem 继承 QQuickPaintedItem,通过 QPainter 绘制坐标轴和柱状图。

核心属性:

cpp 复制代码
Q_PROPERTY(QByteArray pcmData READ pcmData WRITE setPcmData NOTIFY pcmDataChanged)
Q_PROPERTY(int sampleRate READ sampleRate WRITE setSampleRate NOTIFY formatChanged)
Q_PROPERTY(int channels READ channels WRITE setChannels NOTIFY formatChanged)
Q_PROPERTY(int bitsPerSample READ bitsPerSample WRITE setBitsPerSample NOTIFY formatChanged)
Q_PROPERTY(int fftSize READ fftSize WRITE setFftSize NOTIFY settingsChanged)
Q_PROPERTY(int channel READ channel WRITE setChannel NOTIFY settingsChanged)
Q_PROPERTY(qreal positionMs READ positionMs WRITE setPositionMs NOTIFY positionChanged)

绘制时先分析当前时间点:

cpp 复制代码
const SpectrumAnalyzer::SpectrumFrame frame = SpectrumAnalyzer::analyzeFrame(
    m_pcmData, format(), m_fftSize, centerFrame(), m_channel);

然后把频率 bin 聚合到屏幕像素列,避免 bin 数和像素宽度不一致:

cpp 复制代码
for (int px = 0; px < pixelColumns; ++px) {
    const qreal binStartHz = startHz + (endHz - startHz) * px / pixelColumns;
    const qreal binEndHz = startHz + (endHz - startHz) * (px + 1) / pixelColumns;
    const int startBin = clampValue<int>(std::floor(binStartHz / frame.frequencyStep), 0, maxBin);
    const int endBin = clampValue<int>(std::ceil(binEndHz / frame.frequencyStep), startBin, maxBin);

    qreal peakDb = m_minDb;
    for (int bin = startBin; bin <= endBin; ++bin)
        peakDb = std::max<qreal>(peakDb, frame.dbValues.value(bin, m_minDb));

    const qreal normalized = clampValue<qreal>((peakDb - m_minDb) / (m_maxDb - m_minDb), 0.0, 1.0);
    const qreal x = rect.left() + px + 0.5;
    const qreal y = rect.bottom() - normalized * rect.height();
    painter->setPen(QPen(colorStop(normalized, m_colorMap), 1));
    painter->drawLine(QPointF(x, rect.bottom()), QPointF(x, y));
}

注意这里的颜色不再只按频率变化,而是根据能量强弱 normalized 选择色带,更符合频谱可视化直觉。

频谱瀑布图原理

频谱瀑布图可以理解为"把很多张频谱图按时间排起来"。

text 复制代码
窗口 1 -> FFT -> 第 1 列
窗口 2 -> FFT -> 第 2 列
窗口 3 -> FFT -> 第 3 列
...

它的坐标含义是:

text 复制代码
横轴:时间
纵轴:频率
颜色:能量强弱,单位 dB

例如一段音频中持续存在 60Hz 电流声,瀑布图底部会出现一条横向亮线。如果高频被低通滤掉,瀑布图上半部分会明显变暗。

瀑布图生成代码:

cpp 复制代码
SpectrumAnalyzer::SpectrogramData SpectrumAnalyzer::analyzeSpectrogram(
    const QByteArray &pcmData,
    const PcmFormat &format,
    int fftSize,
    int hopSize,
    int maxColumns,
    int channel)
{
    SpectrogramData data;
    data.fftSize = normalizedFftSize(fftSize);
    data.sampleRate = std::max(1, format.sampleRate);
    data.frequencyStep = data.sampleRate / static_cast<qreal>(data.fftSize);
    data.bins = data.fftSize / 2 + 1;

    const qint64 frames = frameCount(pcmData, format);
    if (frames <= 0 || maxColumns <= 0)
        return data;

    qint64 requestedHop = hopSize > 0 ? hopSize : data.fftSize / 4;
    requestedHop = std::max<qint64>(1, requestedHop);

    const qint64 naturalColumns = std::max<qint64>(1, (frames + requestedHop - 1) / requestedHop);
    if (naturalColumns > maxColumns)
        requestedHop = std::max<qint64>(requestedHop, (frames + maxColumns - 1) / maxColumns);

    data.hopSize = requestedHop;
    data.columns = static_cast<int>(std::min<qint64>(
        maxColumns,
        std::max<qint64>(1, (frames + data.hopSize - 1) / data.hopSize)));
    data.dbValues.resize(data.columns * data.bins);

    for (int column = 0; column < data.columns; ++column) {
        const qint64 start = column * data.hopSize;
        const QVector<qreal> bins = analyzeWindow(pcmData, format, data.fftSize, start, channel);
        for (int bin = 0; bin < data.bins; ++bin)
            data.dbValues[column * data.bins + bin] = bins.value(bin, -120.0);
    }

    return data;
}

这里 hopSize 表示每次窗口向前移动多少采样点。hopSize 越小,时间分辨率越高,但计算量也越大。

瀑布图绘制与缓存

瀑布图不直接逐个矩形画到屏幕,而是先生成一张 QImage 缓存。参数或 PCM 变化时重建图片,普通重绘时直接裁剪和缩放绘制图片。

cpp 复制代码
void SpectrogramItem::ensureImage()
{
    if (!m_dirty)
        return;

    m_dirty = false;
    m_image = QImage();

    const SpectrumAnalyzer::SpectrogramData data = SpectrumAnalyzer::analyzeSpectrogram(
        m_pcmData, format(), m_fftSize, m_hopSize, m_maxColumns, m_channel);
    if (data.columns <= 0 || data.bins <= 0)
        return;

    const qreal maxHz = baseMaxFrequency();
    const int visibleBins = clampValue<int>(std::floor(maxHz / data.frequencyStep) + 1, 1, data.bins);
    QImage image(data.columns, visibleBins, QImage::Format_RGB32);

    for (int x = 0; x < data.columns; ++x) {
        for (int y = 0; y < visibleBins; ++y) {
            const int bin = visibleBins - 1 - y;
            image.setPixelColor(x, y, colorForDb(data.value(x, bin)));
        }
    }

    m_image = image;
}

缩放和平移时,不需要重新 FFT,只需要改变 drawImage 的源区域:

cpp 复制代码
const QRectF sourceRect(m_offsetMs / totalMs * m_image.width(),
                        (maxHz - m_offsetHz - visibleFrequencyRange()) / maxHz * m_image.height(),
                        visibleDurationMs() / totalMs * m_image.width(),
                        visibleFrequencyRange() / maxHz * m_image.height());
painter->drawImage(rect, m_image, sourceRect.intersected(m_image.rect()));

这也是瀑布图能支持鼠标缩放和平移的关键。

颜色样式切换

页面提供了几种色带:

  • 经典
  • Inferno
  • Viridis
  • 灰度
  • 火焰

两个自绘控件都暴露了 colorMap 属性:

cpp 复制代码
Q_PROPERTY(int colorMap READ colorMap WRITE setColorMap NOTIFY settingsChanged)

QML 中直接绑定同一个下拉框:

qml 复制代码
ToolComboBox {
    id: colorMapCombo
    model: [ "经典", "Inferno", "Viridis", "灰度", "火焰" ]
    currentIndex: 0
}

SpectrumView {
    colorMap: colorMapCombo.currentIndex
}

SpectrogramView {
    colorMap: colorMapCombo.currentIndex
}

颜色映射的核心是把 dB 归一化到 [0, 1] 后映射到不同色带:

cpp 复制代码
QColor SpectrogramItem::colorForDb(qreal db) const
{
    const qreal ratio = clampValue<qreal>((db - m_minDb) / (m_maxDb - m_minDb), 0.0, 1.0);

    if (m_colorMap == 1) {
        if (ratio < 0.33)
            return lerpColor(QColor("#14051f"), QColor("#781c6d"), ratio / 0.33);
        if (ratio < 0.66)
            return lerpColor(QColor("#781c6d"), QColor("#ed6925"), (ratio - 0.33) / 0.33);
        return lerpColor(QColor("#ed6925"), QColor("#fcffa4"), (ratio - 0.66) / 0.34);
    }

    if (m_colorMap == 3) {
        const int value = static_cast<int>(std::round(28 + ratio * 227));
        return QColor(value, value, value);
    }

    // 其他色带略
}

鼠标缩放和平移

频谱图和瀑布图都支持类似波形图的交互。

频谱图:

text 复制代码
鼠标滚轮:缩放频率轴
右键拖动:平移频率范围
左键拖拽:选择频率范围

瀑布图:

text 复制代码
鼠标滚轮:缩放时间轴
Ctrl + 滚轮:缩放频率轴
右键拖动:同时平移时间和频率视图
左键拖拽:按选区模式进行选择

瀑布图滚轮缩放的关键代码:

cpp 复制代码
void SpectrogramItem::wheelEvent(QWheelEvent *event)
{
    const QRectF rect = plotRect();
    const qreal cursorMs = timeAtX(event->pos().x());
    const qreal cursorHz = frequencyAtY(event->pos().y());
    const qreal factor = event->angleDelta().y() > 0 ? 1.25 : 0.8;

    if (event->modifiers() & Qt::ControlModifier) {
        m_zoomY = clampValue<qreal>(m_zoomY * factor, kMinZoom, kMaxZoom);
        const qreal ratioY = clampValue<qreal>((rect.bottom() - event->pos().y()) / rect.height(), 0.0, 1.0);
        m_offsetHz = cursorHz - ratioY * visibleFrequencyRange();
    } else {
        m_zoomX = clampValue<qreal>(m_zoomX * factor, kMinZoom, kMaxZoom);
        const qreal ratioX = clampValue<qreal>((event->pos().x() - rect.left()) / rect.width(), 0.0, 1.0);
        m_offsetMs = cursorMs - ratioX * visibleDurationMs();
    }

    clampView();
    emit viewChanged();
    update();
    event->accept();
}

这里的细节是:缩放时以鼠标所在位置为锚点,而不是简单从左侧或底部缩放。这样交互更接近波形图的使用体验。

右键平移:

cpp 复制代码
if (m_dragging && (event->buttons() & Qt::RightButton)) {
    const QRectF rect = plotRect();
    const qreal dx = event->pos().x() - m_lastMousePos.x();
    const qreal dy = event->pos().y() - m_lastMousePos.y();
    m_offsetMs -= dx / std::max<qreal>(1.0, rect.width()) * visibleDurationMs();
    m_offsetHz += dy / std::max<qreal>(1.0, rect.height()) * visibleFrequencyRange();
    m_lastMousePos = event->pos();
    clampView();
    emit viewChanged();
    update();
}

选区能力

频谱图支持频率范围选区:

cpp 复制代码
Q_PROPERTY(bool hasSelection READ hasSelection NOTIFY selectionChanged)
Q_PROPERTY(qreal selectionLowHz READ selectionLowHz NOTIFY selectionChanged)
Q_PROPERTY(qreal selectionHighHz READ selectionHighHz NOTIFY selectionChanged)

左键拖拽时根据鼠标位置换算频率:

cpp 复制代码
qreal SpectrumViewItem::frequencyAtX(qreal x) const
{
    const QRectF rect = plotRect();
    const qreal ratio = clampValue<qreal>((x - rect.left()) / rect.width(), 0.0, 1.0);
    return clampValue<qreal>(m_offsetHz + ratio * visibleFrequencyRange(), 0.0, baseMaxFrequency());
}

瀑布图支持更完整的时间 + 频率选区:

cpp 复制代码
Q_PROPERTY(bool hasSelection READ hasSelection NOTIFY selectionChanged)
Q_PROPERTY(qreal selectionStartMs READ selectionStartMs NOTIFY selectionChanged)
Q_PROPERTY(qreal selectionEndMs READ selectionEndMs NOTIFY selectionChanged)
Q_PROPERTY(qreal selectionLowHz READ selectionLowHz NOTIFY selectionChanged)
Q_PROPERTY(qreal selectionHighHz READ selectionHighHz NOTIFY selectionChanged)

新增了 selectionMode

cpp 复制代码
Q_PROPERTY(int selectionMode READ selectionMode WRITE setSelectionMode NOTIFY settingsChanged)

模式约定:

text 复制代码
0 = 框选:选择时间 + 频率矩形区域
1 = 横选:只选择时间段,频率覆盖全频段
2 = 竖选:只选择频率范围,时间覆盖整段音频

鼠标拖动时按不同模式生成选区:

cpp 复制代码
if (m_selecting && (event->buttons() & Qt::LeftButton)) {
    if (m_selectionMode == 1)
        setSelection(m_selectionAnchorMs, 0.0, timeAtX(event->pos().x()), baseMaxFrequency());
    else if (m_selectionMode == 2)
        setSelection(0.0, m_selectionAnchorHz, durationMs(), frequencyAtY(event->pos().y()));
    else
        setSelection(m_selectionAnchorMs, m_selectionAnchorHz, timeAtX(event->pos().x()), frequencyAtY(event->pos().y()));
}

对应 UI:

qml 复制代码
ToolComboBox {
    id: selectionModeCombo
    model: [ "框选", "横选", "竖选" ]
    currentIndex: 0
}

SpectrogramView {
    selectionMode: selectionModeCombo.currentIndex
}

这样用户可以根据分析目标选择更合适的选区方式:

  • 想分析某段时间:用横选。
  • 想定位某个噪声频段:用竖选。
  • 想限定某段时间内的某个频段:用框选。

QML 页面绑定

页面里直接绑定当前 PCM:

qml 复制代码
SpectrumView {
    id: spectrum
    pcmData: mediaAnalyzer.pcmData
    sampleRate: page.pcmSampleRate
    channels: page.pcmChannels
    bitsPerSample: page.pcmBits
    fftSize: page.selectedFftSize
    channel: page.selectedChannel
    minDb: -96
    maxDb: 0
    colorMap: page.selectedColorMap
}

瀑布图类似:

qml 复制代码
SpectrogramView {
    id: spectrogram
    pcmData: mediaAnalyzer.pcmData
    sampleRate: page.pcmSampleRate
    channels: page.pcmChannels
    bitsPerSample: page.pcmBits
    fftSize: page.selectedFftSize
    hopSize: page.selectedHopSize
    maxColumns: 1200
    channel: page.selectedChannel
    minDb: -96
    maxDb: 0
    colorMap: page.selectedColorMap
    selectionMode: page.selectedSelectionMode
}

页面还显示当前选区摘要:

qml 复制代码
var specSelection = spectrum.hasSelection
    ? ("频谱选区 " + spectrum.selectionLowHz.toFixed(0) + " - " + spectrum.selectionHighHz.toFixed(0) + " Hz")
    : "频谱未选区"

var gramSelection = spectrogram.hasSelection
    ? ("瀑布图选区 " + formatMs(spectrogram.selectionStartMs) + " - " + formatMs(spectrogram.selectionEndMs)
       + "," + spectrogram.selectionLowHz.toFixed(0) + " - " + spectrogram.selectionHighHz.toFixed(0) + " Hz")
    : "瀑布图未选区"

能从图里看出什么

频谱图和瀑布图能辅助判断:

  • 音频主要能量集中在哪些频段。
  • 是否存在持续低频嗡声。
  • 是否存在高频嘶声或底噪。
  • 高频是否被压缩或低通削掉。
  • 鼓点、爆破音、敲击声等瞬态出现在哪些位置。
  • 静音段是否真的安静。
  • 处理前后频率分布是否发生变化。

常见观察结论:

text 复制代码
底部长期亮线:可能存在低频噪声或工频干扰。
顶部大面积发暗:高频较少,可能音频偏闷或经过低通处理。
竖向亮块:短时瞬态声音,例如鼓点、敲击、爆破音。
中频区域有连续纹理:常见于人声或乐器主体。

新增的选区能力让这些观察更容易落地:

  • 用横选圈出某一段异常时间。
  • 用竖选圈出持续噪声频段。
  • 用框选圈出某段时间内的特定频率事件。

后续可以基于这些选区继续做导出、局部平均频谱、噪声标记、滤波器参数预填等功能。

相关推荐
爱吃生蚝的于勒1 小时前
QT开发第三章——常用控件
linux·服务器·开发语言·前端·javascript·c++·qt
潜创微科技1 小时前
2026选网线延长器芯片方案需关注哪些核心维度?潜创微科技方案商专业解析
音视频
潜创微科技1 小时前
ITE IT920X 4K60 HDMI+USB over IP 远距离传输与视频墙单芯片方案
网络协议·tcp/ip·音视频
Shadow(⊙o⊙)1 小时前
QT常用控件1.0,enabled() geometry() QIcon的.qrc文件导入
开发语言·c++·qt
elirlove12 小时前
AI制作视频的关键点:从模型到工作流的完整技术解析
人工智能·音视频
“码”力全开2 小时前
深入解构企业级 AI 视频管理平台:基于 Docker 的异构计算架构,支持 GB28181/RTSP 多协议接入与全面源码交付
人工智能·docker·音视频
小短腿的代码世界2 小时前
高性能订单路由与智能拆单算法:Qt在量化交易系统中的核心架构——毫秒级延迟下如何隐藏你的交易意图?
开发语言·qt·架构
油炸自行车2 小时前
【bug】Qt 6 Q_NAMESPACE 跨 DLL 链接错误:LNK2019 无法解析 staticMetaObject
数据库·c++·qt·bug·link2019·q_namespace_exp·namespaceexport
Dovis(誓平步青云)2 小时前
《QT学习第五篇:QSS美化界面与API绘图》
开发语言·数据库·qt·学习·时序数据库·开源智能体