欢迎光临
我们一直在努力

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

前言

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

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

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

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

效果图:
在这里插入图片描述
在这里插入图片描述

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

数据流

整体数据流如下:

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

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

frame 0: L0 R0
frame 1: L1 R1
frame 2: L2 R2

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

关键代码:

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 表示只分析指定声道。

单帧频谱图原理

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

例如:

sampleRate = 44100 Hz
fftSize = 2048

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

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

所以:

bin 0 = 0 Hz
bin 1 ≈ 21.53 Hz
bin 100 ≈ 2153 Hz

核心流程:

  • 以当前时间点为中心截取一段 PCM。
  • 给窗口乘 Hann 窗,减少频谱泄漏。
  • 执行 FFT。
  • 取复数结果的模,得到幅度。
  • 转为 dB。
  • 绘制柱状图。
  • 关键代码:

    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 窗会让窗口两端平滑衰减:

    window = 0.5 0.5 * cos(2 * pi * i / (fftSize 1));

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

    FFT 实现

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

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

    FFT 主体:

    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 绘制坐标轴和柱状图。

    核心属性:

    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)

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

    const SpectrumAnalyzer::SpectrumFrame frame = SpectrumAnalyzer::analyzeFrame(
    m_pcmData, format(), m_fftSize, centerFrame(), m_channel);

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

    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 选择色带,更符合频谱可视化直觉。

    频谱瀑布图原理

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

    窗口 1 -> FFT -> 第 1 列
    窗口 2 -> FFT -> 第 2 列
    窗口 3 -> FFT -> 第 3 列

    它的坐标含义是:

    横轴:时间
    纵轴:频率
    颜色:能量强弱,单位 dB

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

    瀑布图生成代码:

    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 变化时重建图片,普通重绘时直接裁剪和缩放绘制图片。

    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 的源区域:

    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 属性:

    Q_PROPERTY(int colorMap READ colorMap WRITE setColorMap NOTIFY settingsChanged)

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

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

    SpectrumView {
    colorMap: colorMapCombo.currentIndex
    }

    SpectrogramView {
    colorMap: colorMapCombo.currentIndex
    }

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

    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);
    }

    // 其他色带略
    }

    鼠标缩放和平移

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

    频谱图:

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

    瀑布图:

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

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

    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();
    }

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

    右键平移:

    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();
    }

    选区能力

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

    Q_PROPERTY(bool hasSelection READ hasSelection NOTIFY selectionChanged)
    Q_PROPERTY(qreal selectionLowHz READ selectionLowHz NOTIFY selectionChanged)
    Q_PROPERTY(qreal selectionHighHz READ selectionHighHz NOTIFY selectionChanged)

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

    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());
    }

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

    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:

    Q_PROPERTY(int selectionMode READ selectionMode WRITE setSelectionMode NOTIFY settingsChanged)

    模式约定:

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

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

    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:

    ToolComboBox {
    id: selectionModeCombo
    model: [ "框选", "横选", "竖选" ]
    currentIndex: 0
    }

    SpectrogramView {
    selectionMode: selectionModeCombo.currentIndex
    }

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

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

    QML 页面绑定

    页面里直接绑定当前 PCM:

    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
    }

    瀑布图类似:

    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
    }

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

    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")
    : "瀑布图未选区"

    能从图里看出什么

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

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

    常见观察结论:

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

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

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

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

    赞(0)
    未经允许不得转载:171主机测评 » Qt/QML 音频频谱图与频谱瀑布图实现:从 PCM 到频域可视化
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址