看见旋律 - WPF 实现音乐监听:频谱图展示

目录

[1. 窗口概览:四条泳道](#1. 窗口概览:四条泳道)

[2. 数据流水线:从扬声器到像素](#2. 数据流水线:从扬声器到像素)

[3. 核心算法 ①:FFT 与 Hann 窗](#3. 核心算法 ①:FFT 与 Hann 窗)

[4. 核心算法 ②:低频谱通量(Spectral Flux)](#4. 核心算法 ②:低频谱通量(Spectral Flux))

[5. 核心算法 ③:动态阈值与重拍判定](#5. 核心算法 ③:动态阈值与重拍判定)

[5.1 动态阈值公式](#5.1 动态阈值公式)

[5.2 原始检测 vs 确认重拍](#5.2 原始检测 vs 确认重拍)

[5.3 低频能量包络(第 4 泳道)](#5.3 低频能量包络(第 4 泳道))

[6. 核心算法 ④:BPM 估算](#6. 核心算法 ④:BPM 估算)

[7. 时间轴缓冲与渲染](#7. 时间轴缓冲与渲染)

[7.1 环形缓冲 RhythmTimelineBuffer](#7.1 环形缓冲 RhythmTimelineBuffer)

[7.2 四泳道绘制 RhythmTimelinePlot](#7.2 四泳道绘制 RhythmTimelinePlot)

[8. 总结](#8. 总结)



1. 窗口概览:四条泳道

BeatDetector 产出的原始节奏信号以「线条化」方式画出来。

窗口右侧的 RhythmTimelinePlot 控件将可视区域均分为四条水平泳道:

泳道 数据字段 颜色 含义
第 1 道 · 振幅 WaveformRms 浅紫灰 当前 FFT 帧内样本的 RMS,反映整体响度
第 2 道 · 低频通量 BassFlux 紫色实线 40--150 Hz 频段的谱通量,重拍检测的核心输入
第 3 道 · 检测阈值 FluxThreshold 橙色虚线 基于近期通量历史计算的动态阈值
第 4 道 · 低频能量 BassLevel 青色 低频段幅值经包络平滑后的能量水平

节奏通量(Bass Flux)

动态阈值(Threshold)

确认重拍(Confirmed Kick)

音符标记(Note Head)

**设计意图:**把「检测输入(通量)」「判定标准(阈值)」「物理能量(低频 level)」 和「原始响度(RMS)」分层展示,当鼓点到来时,你能直观看到紫色尖峰是否越过橙色虚线, 以及是否触发了红色竖线标记。

四条泳道协同:紫色尖峰越过橙色阈值时,出现确认重拍标记。


2. 数据流水线:从扬声器到像素

整个系统可以概括为一条单向流水线:

WASAPI Loopback 采集→逐样本BeatDetector→FFT + 通量+ 阈值判定→AudioAnalysisFrame→

RhythmTimelineBuffer(1200 帧环形缓冲)→RhythmTimelinePlot(~30fps 重绘)关键代码路径:

  1. LoopbackAudioCapture 通过 NAudio 的 WasapiLoopbackCapture 捕获系统播放的音频;
  2. 每个浮点样本送入 BeatDetector.ProcessSample(),攒满 2048 个样本后输出一帧分析结果;
  3. CompanionAudioSession 将每帧写入 RhythmTimelineBuffer
  4. RhythmObserverWindow 以 33ms(约 30fps)定时器调用 TimelinePlot.Refresh() 触发重绘。

3. 核心算法 ①:FFT 与 Hann 窗

节奏检测不是直接在时域波形上找峰值,而是先把音频变换到频域。 FftAnalyzer 维护一个长度为 2048(2 的幂)的环形缓冲区, 每收到一个样本就乘以 Hann 窗 后写入,攒满后做一次 FFT。

复制代码
// FftAnalyzer.cs --- 攒满一帧后做 FFT
_buffer[_writeIndex].X = sample * (float)FastFourierTransform.HannWindow(_writeIndex, _fftSize);
FastFourierTransform.FFT(true, _m, _buffer);

// 计算每个频率 bin 的幅值
magnitudes[i] = sqrt(X² + Y²)

// 同时计算帧 RMS(用于第 1 泳道)
frameRms = sqrt(Σ sample² / fftSize)

几个关键参数:

  • FFT 大小 2048 :在 44100 Hz 采样率下,频率分辨率为 44100/2048 ≈ 21.5 Hz/bin
  • Hann 窗:减少频谱泄漏,让相邻频率 bin 的边界更干净;
  • 输出频率 :每 2048 个样本产出一帧,约 44100/2048 ≈ 21.5 帧/秒

频率到 bin 的映射公式:

bin = round( f × fftSize / sampleRate )

BeatDetector 据此划分频段:

  • 低频(Bass):40 -- 150 Hz → 鼓、底鼓的主要能量区
  • 中频(Mid):150 -- 2000 Hz
  • 高频(High):2000 -- 12000 Hz

4. 核心算法 ②:低频谱通量(Spectral Flux)

谱通量 衡量的是「频谱幅值的变化量」,而非绝对能量。 它的直觉是:鼓点到来时,低频 bin 的幅值会突然增大, 这种「增量」比单纯的能量大小更能突出瞬态打击。

算法采用半波整流(Half-wave Rectification):只累加正向变化,忽略能量下降:

Flux = Σ max( 0, |Xt(k)| − |Xt−1(k)| ) (k 在目标频段内)

复制代码
// FftAnalyzer.ComputeSpectralFlux --- 半波整流谱通量
for (var i = minBin; i <= maxBin; i++)
{
    var delta = magnitudes[i] - _previousMagnitudes[i];
    if (delta > 0)
        flux += delta;

    _previousMagnitudes[i] = magnitudes[i];  // 更新历史
}
return flux;

BeatDetector 分别计算 Bass / Mid / High 三个频段的通量, 但重拍检测只使用 BassFlux(40--150 Hz)。 在观察窗口中,紫色实线就是这条 BassFlux 的时间序列。

**为什么不用 RMS?**RMS 反映整体响度,人声、镲片也会抬升 RMS; 而低频谱通量专门捕捉「低频段的突然变化」,对 Kick / Snare 等打击乐更敏感。


5. 核心算法 ③:动态阈值与重拍判定

固定阈值无法适应不同歌曲的响度:有的歌 BassFlux 峰值只有 0.01,有的能达到 0.1。 因此系统使用滑动窗口 + 统计自适应来计算动态阈值。

5.1 动态阈值公式

维护最近 43 帧的 BassFlux 历史,计算均值 μ 与方差 σ²,阈值定义为:

Threshold = μ + 1.4 × √σ² + 0.002

复制代码
// BeatDetector.cs --- 动态阈值
_fluxHistory[_fluxHistoryIndex] = bassFlux;
_fluxHistoryIndex = (_fluxHistoryIndex + 1) % 43;

var averageFlux = _fluxHistory.Average();
var variance = _fluxHistory.Select(v => (v - averageFlux)²).Average();
var threshold = averageFlux + Sqrt(variance) * 1.4f + 0.002f;

这本质上是一个 均值 + 1.4 倍标准差 的自适应门限(类似 z-score 阈值), 外加 0.002 的底噪保护。43 帧历史在 ~21.5 fps 下约覆盖 2 秒 的通量变化。

5.2 原始检测 vs 确认重拍

检测分两级,避免误触发:

复制代码
// 原始检测:通量超过阈值且大于最小底噪
var isKick = bassFlux > threshold && bassFlux > 0.004f;

// 确认重拍:原始检测 + 120ms 去抖
if (isKick && timestampSeconds - _lastKickTime > 0.12)
{
    isConfirmedKick = true;
    _lastKickTime = timestampSeconds;
}
标记 字段 可视化 说明
原始检测 RawKick / IsKick 浅黄色短竖线(通量泳道内) 通量越过阈值,但可能过于密集
确认重拍 ConfirmedKick / IsConfirmedKick 红色全高竖线 + 音符圆点 经 120ms 去抖后的最终判定,驱动主窗口动画

5.3 低频能量包络(第 4 泳道)

第 4 泳道的 BassLevel 来自低频段 bin 幅值的平均值, 再经过 Attack/Release 平滑(类似压缩器的包络跟随):

复制代码
// 低频段平均幅值
bassLevel = AverageBand(magnitudes, bassBinStart, bassBinEnd);

// 包络平滑:上升快(attack=0.45),下降慢(release=0.08)
_bassEnvelope = Smooth(_bassEnvelope, bassLevel, attack: 0.45f, release: 0.08f);

// 归一化到 0--1 供显示
BassLevel = Clamp(_bassEnvelope * 10f, 0f, 1f);

与 BassFlux 不同,BassLevel 反映的是持续的低频能量,而非瞬态变化。 在观察窗口中,它作为背景参考,帮助区分「一直有低音」和「突然来了一个鼓点」。


6. 核心算法 ④:BPM 估算

每次确认重拍时,将时间戳加入一个最多 8 个元素的队列。 当队列中有 ≥ 3 个时间戳时,计算相邻间隔的中位数,转换为 BPM:

BPM = 60 / median(Δt1, Δt2, ...)

复制代码
// 中位数间隔 → BPM,并折叠到 60--180 范围
var median = intervals.OrderBy(v => v).ElementAt(intervals.Count / 2);
var bpm = 60.0 / median;

while (bpm < 60)  bpm *= 2;   // 防止八度误差
while (bpm > 180) bpm /= 2;

// 指数平滑,避免 BPM 数字跳动
_estimatedBpm = _estimatedBpm * 0.82f + bpm * 0.18f;

窗口右下角的 Flux ... | Threshold ... | BPM ... 指标栏 每 33ms 刷新一次,展示最新一帧的实时数值。


7. 时间轴缓冲与渲染

7.1 环形缓冲 RhythmTimelineBuffer

分析帧以 ~21.5 fps 产生,缓冲容量为 1200 帧,可回溯约 55 秒 的历史。 写入采用环形索引,读取时按时间顺序拷贝最新 N 帧到渲染快照:

复制代码
// 环形写入
_samples[_writeIndex] = sample;
_writeIndex = (_writeIndex + 1) % capacity;

// 按时间顺序拷贝最新数据
var start = (_writeIndex - copyCount + capacity) % capacity;
for (var i = 0; i < copyCount; i++)
    destination[i] = _samples[(start + i) % capacity];

7.2 四泳道绘制 RhythmTimelinePlot

渲染逻辑在 OnRender 中完成,核心步骤:

  1. 自动缩放:每帧对 max 值做 0.998 衰减 + 1.25 倍余量,让波形始终填满泳道但不剧烈跳动;

  2. 坐标映射 :X 轴按样本索引等分宽度,Y 轴按 value / maxValue 归一化到泳道高度;

  3. 折线绘制 :用 StreamGeometry 连接各采样点,阈值泳道使用虚线样式;

  4. 重拍标记:遍历快照,在 RawKick 处画短竖线,在 ConfirmedKick 处画全高红线和音符椭圆。

    // Y 轴映射:值越大,点越靠泳道顶部
    var normalized = Clamp(value / maxValue, 0, 1.2);
    var y = laneTop + laneHeight - normalized * laneHeight * 0.92 - laneHeight * 0.04;

窗口以 DispatcherTimer 33ms 间隔(~30fps)调用 Refresh(), 高于分析帧率,保证滚动波形足够流畅。


8. 总结

「节奏观察」窗口的价值在于透明化:它不隐藏检测逻辑,而是把算法的中间量全部画出来:

  • FFT + Hann 窗 → 将时域音频变换为频域幅值;
  • 半波整流谱通量 → 在 40--150 Hz 捕捉低频瞬态变化(紫色线);
  • 均值 + 1.4σ 动态阈值 → 自适应不同歌曲响度(橙色虚线);
  • 120ms 去抖 → 从原始检测升级为确认重拍(红色标记);
  • 中位数间隔 BPM → 从重拍序列估算 tempo;
  • 四泳道 + 环形缓冲 + 30fps 渲染 → 将上述信号以时间轴方式持续展示。

电脑在随意播放音乐的时候,监听状态下,输出的频谱图:

上图的节拍旋律来自歌曲《You Still the One》: