前言
波形图展示的是声音在时间轴上的振幅变化,适合观察音量、峰值、静音段和裁剪位置。但如果想知道声音里有哪些频率成分,例如低频轰鸣、高频噪声、人声谐波、压缩后的高频缺失,仅看波形是不够的。
频谱图和频谱瀑布图解决的是频域观察问题:
- 频谱图:查看某一个时间点附近,不同频率的能量分布。
- 频谱瀑布图:查看整段音频中,频率能量如何随时间变化。
本项目中,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
...
频谱分析时先根据 frameIndex 和 channel 找到采样点,再归一化到 [-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
核心流程:
- 以当前时间点为中心截取一段 PCM。
- 给窗口乘 Hann 窗,减少频谱泄漏。
- 执行 FFT。
- 取复数结果的模,得到幅度。
- 转为 dB。
- 绘制柱状图。
关键代码:
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
底部长期亮线:可能存在低频噪声或工频干扰。
顶部大面积发暗:高频较少,可能音频偏闷或经过低通处理。
竖向亮块:短时瞬态声音,例如鼓点、敲击、爆破音。
中频区域有连续纹理:常见于人声或乐器主体。
新增的选区能力让这些观察更容易落地:
- 用横选圈出某一段异常时间。
- 用竖选圈出持续噪声频段。
- 用框选圈出某段时间内的特定频率事件。
后续可以基于这些选区继续做导出、局部平均频谱、噪声标记、滤波器参数预填等功能。