资讯动态

Web 端实时和弦识别算法:从麦克风单音采集到频域基频谐波分解

发布时间:2026/10/15 12:29:09 来源:尧图企业网站定制
弹贝斯的人对泛音列和基频天生敏感。一把四弦电贝斯的四根空弦音高分别是 E141.2 Hz、A155.0 Hz、D273.4 Hz和 G298.0 Hz。在地下室排练的时候如果吉他手踩下一个复杂的 Maj7 和弦底鼓又在低频轰鸣人耳要从混沌的声场中抓出当前是什么和弦靠的是大脑神经对瞬态基频和泛音排列的生物傅里叶解构。但在 Web 端做实时和弦识别麦克风采集进来的信号往往是充斥着环境底噪、机身震动和扬声器串扰的浮点 PCM 数据。要在浏览器每秒 60 帧的渲染周期内毫秒级推断出当前正在演奏的吉他或键盘和弦必须在音频流切片、窗函数平滑、频域基频识别以及色度直方图Pitch Class Profile / Chroma模板匹配之间建立一条严丝合缝的计算流水线。音频输入流采集与帧切片设计在现代浏览器中实时捕获麦克风信号的首选是AudioWorkletNode彻底告别早已废弃且会在主线程引发卡顿的ScriptProcessorNode。但考虑到算法演示与通用兼容性无论是 Worklet 还是主线程处理核心都在于如何对 PCM 流进行定长时窗分帧。采集音频流时采样率通常为 44100 Hz 或 48000 Hz。人耳可识别的和弦变化频率一般不超过 10 Hz。为了保证频域分辨率FFT 窗口大小 $N$ 至少需要 4096 点。在 44100 Hz 下4096 点对应约 92.8 毫秒的时长频率分辨率 $\Delta f \frac{44100}{4096} \approx 10.77 \text{ Hz}$。这对于区分中高频和弦已经足够但要准确捕获低频基音如 A1 的 55 Hz 与 A#1 的 58.27 Hz相差不到 3.3 Hz就必须结合泛音谐波结构进行联合推断。为降低瞬态突变带来的吉布斯效应Gibbs phenomenon切片数据在进入 FFT 计算前必须加施加汉宁窗Hanning Window$$w(n) 0.5 \times \left(1 - \cos\left(\frac{2\pi n}{N - 1}\right)\right)$$从频谱到十二平均律色度图Chromagram识别和弦的关键不在于具体的物理频率值而在于将所有物理频率的能量折叠归一化到十二平均律的 12 个半音阶上C, C#, D, D#, E, F, F#, G, G#, A, A#, B这一能量分布向量被称为色度特征向量Chroma Vector。标准音 A4 设定为 440 Hz。任意频率 $f$ 对应的 MIDI 音符编号公式为$$p(f) 69 12 \times \log_2\left(\frac{f}{440}\right)$$对应的音级Pitch Class, 0 表示 C1 表示 C#以此类推直至 11 表示 B为$$c(f) \lfloor \text{round}(p(f)) \rfloor \pmod{12}$$单个音符不仅包含基频 $f_0$还包含一系列整数倍的泛音谐波 $2f_0, 3f_0, 4f_0, \dots$。如果吉他弹响一个 A2110 Hz频谱上不仅在 A 处聚集能量第三谐波330 Hz接近 E4也会贡献能量这恰好是五度音的频率。如果直接简单统计频点能量五度泛音很容易被误判为独立音符。因此需要引入谐波能量压缩因子降低高次谐波在色度图中的干扰。和弦模板匹配引擎得到归一化的 12 维色度向量后和弦识别就转化为向量相似度匹配问题。我们建立常用和弦的理论二值或权重模板例如C Major (C - E - G):[1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0]A Minor (A - C - E):[1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0]G Dominant 7th (G - B - D - F):[0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1]通过计算当前输入向量与各和弦模板之间的余弦相似度Cosine Similarity取相似度最高且跨过置信度阈值的候选结果。完整实现实时和弦识别分析器下面是纯 TypeScript 实现的轻量级和弦识别引擎包含汉宁窗滤波、色度特征提取以及余弦模板匹配export type ChordQuality Major | Minor | 7 | maj7 | m7 | Dim; export interface ChordResult { root: string; quality: ChordQuality; confidence: number; chroma: number[]; } export class RealtimeChordDetector { private sampleRate: number; private fftSize: number; private hanningWindow: Float32Array; // 十二半音音名映射表 (以 C 为基准索引 0) private readonly NOTE_NAMES [C, C#, D, D#, E, F, F#, G, G#, A, A#, B]; // 预定义三和弦与七和弦相对半音阶间隔 private readonly CHORD_TEMPLATES: RecordChordQuality, number[] { Major: [0, 4, 7], // 根音、大三度、纯五度 Minor: [0, 3, 7], // 根音、小三度、纯五度 7: [0, 4, 7, 10], // 属七和弦 maj7: [0, 4, 7, 11], // 大七和弦 m7: [0, 3, 7, 10], // 小七和弦 Dim: [0, 3, 6], // 减三和弦 }; constructor(sampleRate: number 44100, fftSize: number 4096) { this.sampleRate sampleRate; this.fftSize fftSize; this.hanningWindow new Float32Array(fftSize); // 预计算汉宁窗函数表避免每次分析时重复计算三角函数 for (let i 0; i fftSize; i) { this.hanningWindow[i] 0.5 * (1 - Math.cos((2 * Math.PI * i) / (fftSize - 1))); } } /** * 将线性频谱幅值聚合为 12 维半音色度直方图 * param spectrum Magnitude spectrum (长度为 fftSize / 2) */ public extractChromaVector(spectrum: Float32Array): number[] { const chroma new Array(12).fill(0); const binCount spectrum.length; const binWidth this.sampleRate / this.fftSize; // 仅在人声与吉他贝斯主要基频与低次谐波区间 (65Hz ~ 2000Hz) 采样 const minBin Math.max(1, Math.floor(65 / binWidth)); const maxBin Math.min(binCount - 1, Math.ceil(2000 / binWidth)); for (let bin minBin; bin maxBin; bin) { const freq bin * binWidth; const magnitude spectrum[bin]; if (magnitude 0.001) continue; // 计算对应 MIDI 音符 const midiNote 69 12 * Math.log2(freq / 440); const pitchClass (Math.round(midiNote) % 12 12) % 12; // 引入对数幅度与频率衰减加权降低极低频杂音与极高频泛音权重 const weightedMag Math.log1p(magnitude * 10); chroma[pitchClass] weightedMag; } // 向量模长归一化 (L2 范数) let norm 0; for (let i 0; i 12; i) { norm chroma[i] * chroma[i]; } norm Math.sqrt(norm); if (norm 0.0001) { for (let i 0; i 12; i) { chroma[i] / norm; } } return chroma; } /** * 计算两个归一化向量的余弦相似度 */ private cosineSimilarity(vecA: number[], vecB: number[]): number { let dot 0; for (let i 0; i 12; i) { dot vecA[i] * vecB[i]; } return dot; } /** * 基于模板比对当前色度向量推断最可能的和弦 */ public detectChord(spectrum: Float32Array): ChordResult | null { const chroma this.extractChromaVector(spectrum); let bestScore -1; let bestRoot ; let bestQuality: ChordQuality Major; // 遍历 12 个根音与所有和弦类型 for (let rootIdx 0; rootIdx 12; rootIdx) { const rootName this.NOTE_NAMES[rootIdx]; for (const [quality, intervals] of Object.entries(this.CHORD_TEMPLATES)) { // 构建当前测试和弦的理论二值模板并归一化 const template new Array(12).fill(0); for (const interval of intervals) { const noteIndex (rootIdx interval) % 12; template[noteIndex] 1.0; } const templateNorm Math.sqrt(intervals.length); for (let i 0; i 12; i) { template[i] / templateNorm; } const similarity this.cosineSimilarity(chroma, template); if (similarity bestScore) { bestScore similarity; bestRoot rootName; bestQuality quality as ChordQuality; } } } // 设定置信度底线避免静音或无规则白噪声误判 if (bestScore 0.65) { return null; } return { root: bestRoot, quality: bestQuality, confidence: bestScore, chroma, }; } }工程集成中的防抖与性能开销考量直接在前端每一帧requestAnimationFrame运行和弦识别往往是不明智的。吉他扫弦和贝斯拨弦都会产生几毫秒的敲击瞬态Transient Attack此时波形在时域表现为宽频带脉冲频域呈现几乎全频段能量喷发的混沌噪声。如果此时立即做模板匹配识别器必然会跳出完全错误的和弦。在实际集成中通常采用双缓冲滑动窗口滤波策略瞬态静默窗口当通过时域均方根能量RMS检测到音频突变峰值拨弦那一瞬间时强制延时 30~50 毫秒再采样等待物理琴弦进入稳定的基频振动驻波期。三帧历史投票Temporal Smoothing设立长度为 3 的循环缓冲区只有连续两帧以上命中同一和弦且置信度均高于 0.7 时才向 UI 派发和弦更新事件。计算下沉如果需要在主线程保证极端复杂的 WebGL 动效或 DOM 渲染可以将上述extractChromaVector逻辑直接封装进AudioWorkletProcessor通过postMessage向主线程以 15~20 Hz 的低刷新率推送推断和弦将主线程开销压低到几乎为零。在浏览器声学计算的世界里声学物理规律永远优先于模型推理。把泛音谐波的数学拓扑摸透哪怕不用几十兆的重型神经网络几百行扎实的原生算法也能在毫秒级内给出稳定清脆的吉他转位和弦反馈。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑