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

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

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

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

相关推荐
RTC实战笔记3 天前
实时互动数字人怎么做,才不是一个只会说话的视频?
音视频·数字人·rtc·数字人接入
用户805533698034 天前
不止三件套:QObject 属性系统全关键字与运行时反射!
c++·qt
xcyxiner4 天前
DicomViewer (vcpkg Windows和ubuntu编译)7
qt
Quz9 天前
QML Hello World 入门示例
qt
xcyxiner12 天前
DicomViewer (dcmtk读取dcm文件)5
qt
xcyxiner13 天前
DicomViewer (后台线程处理文件)4
qt
xcyxiner13 天前
DicomViewer (添加模型类)3
qt
xcyxiner14 天前
DicomViewer (目录调整) 2
qt
xcyxiner14 天前
dcmtk vtk vtk-dicom(gdcm) 编译(debug) v2
qt
RTC实战笔记15 天前
Android 实时音视频接入教程:媒体补充增强信息(SEI)
音视频·媒体·rtc