深入解析:构建实时和弦识别器
摘要
本文解释了实时和弦识别器的技术架构,详细介绍了使用音级位掩码、候选生成、分数归一化和音乐启发式的四阶段流水线。
<p><a href="https://lobste.rs/s/pliqep/under_hood_building_real_time_chord">评论</a></p>
查看缓存全文
缓存时间: 2026/05/19 16:43
# 深入解析:构建实时和弦识别器 来源: https://whatchord.earthmanmuons.com/articles/under-the-hood
## 问题不在于查找
构建和弦识别器时的第一直觉是建立一个字典。只有12个音高类别,这意味着只有`2^12 = 4096`种可能的音高集合。为每个集合存储一个名称,当用户弹奏 C-E-G 时,查找 {C, E, G} 并返回 "C major"。问题不在于内存。四千个条目微不足道。问题在于含义。一个音高集合所包含的信息不足以决定音乐家会如何称呼它。钢琴演奏者经常省略字典条目可能期望的音符。扩展和弦添加了没有任何固定字典条目能预料的音符。正如配套文章(https://whatchord.earthmanmuons.com/articles/chord-naming.html)中所讨论的,同一组音高类别,根据音乐语境,可以合法地描述为多个不同的和弦。
你实际需要的是一个评分模型。它必须评估任意给定音符集与每种和弦类型的匹配程度,对所有合理的解释进行排序,并在分数接近时应用音乐判断。
## 概览:四阶段流水线
在深入每个组件之前,先了解算法的整体结构。一个正在发声的音符快照从顶部输入;一个排好序的和弦解释列表从底部输出。
输入:发声的音高类别集合 + 最低(低音)音符
↓
音高类别位掩码
12位整数:八度内每个半音对应一位
↓
候选生成
每个发声的音符成为一个候选根音,针对每个和弦模板进行评分,提取扩展音
↓
分数归一化
原始分数被归一化,以便在不同和弦复杂度之间公平比较
↓
排序
音乐启发式规则解决模糊分数;当仅凭分数会选出错误答案时,硬性结构规则会覆盖它
↓
输出:排名靠前的和弦候选,结果缓存于 LRU
本文的其余部分将详细讲解每个阶段,最后讨论已知的局限性。
## 音高类别与位掩码
WhatChord 模拟了 MIDI 键盘使用的常见十二平均律(12-TET)音高类别框架,该框架将每个八度划分为相等的半音位置。一个*音高类别*是音符在该八度内的位置,忽略其所在的八度,因此中央C、它上方的C以及三个八度以下的C都共享音高类别0。在这个引擎中,音高类别编号为0(C)到11(B)。
进行分析时,引擎将发声的音符合并为一组音高类别,并将最低的发声音符作为低音。音高类别集表示为12位整数掩码,其中如果音高类别*n*存在,则位*n*被置位。C major(C=0, E=4, G=7)如下所示:
```
11109876543210
BA♯AG♯GF♯FED♯DC♯C
000010010001
// 音高类别: C=0, E=4, G=7
int pcMask = (1 << 0) | (1 << 4) | (1 << 7); // pcMask == 0b000010010001 == 0x091
```
这种表示法紧凑且快速。检查一个音高类别是否存在只需一次位与操作。计数存在的音高类别是一次 popcount。相对于候选根音旋转集合是对位进行带模运算的循环。所有这些操作都很廉价。
一个关键的设计决策:**只有声部中实际存在的音高类别才会被测试为候选根音。**没有“幽灵根音”,算法也永远不会提出一个根音不在弹奏音符中的解释。这使候选数量保持较小(受发声音符数量的限制,通常为3-7个),并避免了明显错误的解读。
这是一个刻意为之的“独奏键盘”假设。当前引擎针对常见情况进行优化,即同一 MIDI 流同时包含和声和低音音符。未来的合奏模式可以放宽这一规则,适用于其他乐器承载低音的情况,允许无根音声部暗示键盘部分中并未实际存在的根音。
## 和弦模板
和弦性质也定义为位掩码模板。每个模板描述了三组相对于根音的音程:
- **必须音:**必须存在才能识别该性质的音。缺失超过一个必须音会导致模板被完全跳过。
- **可选音:**在真实声部中经常被省略的音(几乎总是纯五度)。演奏时存在,缺失时也不引人注意。
- **罚分音:**积极抵触该性质的音。当试图识别一个小三和弦时,如果存在大三度,则会损害分数。
按复杂度组织的22个模板:
| 性质 | 必须音程 | 可选音 | 关键罚分音 |
|---|---|---|---|
| 大三和弦 | R, M3 | P5 | m3, m7, M7 |
| 小三和弦 | R, m3 | P5 | M3, m7, M7 |
| 减三和弦 | R, m3, ♭5 | — | M3, P5 |
| 增三和弦 | R, M3, ♯5 | — | m3, P5 |
| Sus2 | R, M2, P5 | — | m3, M3, m7, M7 |
| Sus4 | R, P4, P5 | — | m3, M3, m7, M7 |
| 大六和弦 | R, M3, M6 | P5 | m3, m7, M7 |
| 小六和弦 | R, m3, M6 | P5 | M3, m7, M7 |
| 属七和弦 | R, M3, m7 | P5 | M7, m3 |
| 7sus2 | R, M2, m7 | P5 | m3, M3, P4, M7 |
| 7sus4 | R, P4, m7 | P5 | m3, M3, M7 |
| 7♭5 | R, M3, ♭5, m7 | — | P5, M7, m3 |
| 7♯5 | R, M3, ♯5, m7 | — | P5, M7, m3 |
| 大七和弦 | R, M3, M7 | P5 | m7, m3 |
| 大七sus2 | R, M2, M7 | P5 | m3, M3, P4, m7 |
| 大七sus4 | R, P4, M7 | P5 | m3, M3, M2, m7 |
| 大七♭5 | R, M3, ♭5, M7 | — | P5, m7, m3 |
| 大七♯5 | R, M3, ♯5, M7 | — | P5, m7, m3 |
| 小七和弦 | R, m3, m7 | P5 | M7, M3 |
| 小大七和弦 | R, m3, M7 | P5 | M3, m7 |
| 半减七和弦 | R, m3, ♭5, m7 | — | P5, M3, M7 |
| 减七和弦 | R, m3, ♭5, d7 | — | m7, P5, M3, M7 |
注意,对于大多数和弦家族,纯五度是可选的。要求它会导致算法遗漏许多常见用法中的惯用声部。罚分音不是硬拒绝。模板仍然会被评分,只是会扣分。这处理了同一个音符可能同时属于一个和弦并部分适合另一个和弦的情况,让分数反映匹配程度,而不是产生二元的“是/否”。
## 模板评分
对于每个候选根音(声部中存在的每个音高类别),分析器相对于该根音旋转音高类别掩码,得到一个音程掩码。然后它针对所有22个模板对该音程掩码进行评分。
```
// 旋转:为每个发声音符计算相对于根音的音程
int rotateMaskToRoot(int pcMask, int rootPc) {
var rel = 0;
for (var pc = 0; pc < 12; pc++) {
if ((pcMask & (1 << pc)) == 0) continue;
final interval = (pc - rootPc) % 12;
rel |= (1 << (interval < 0 ? interval + 12 : interval));
}
return rel;
}
```
评分公式从多个组成部分累加原始分数:
| 组成部分 | 权重 | 注释 |
|---|---|---|
| 每个存在的必须音 | +4.0 | 结构基础 |
| 每个缺失的必须音 | -6.0 | 最多允许1个;2个或以上会导致模板被拒绝 |
| 每个存在的可选音 | +1.5 | 增加色彩,但并非必要 |
| 每个存在的罚分音 | -3.0 | 与和弦性质相矛盾 |
| 每个未解释的“额外”音 | -0.5 | 在扩展音提取之前;少量是因为扩展音是真实的 |
| 低音是根音或转位音 | +1.0 | 原位、第一转位、第二转位、第三转位 |
| 低音是色彩音(7th家族和弦) | +0.75 | 上层结构声部,合理 |
| 低音是扩展音(三和弦+斜杠) | +0.25 | 加和弦斜杠记法 |
| 低音未被模板解释 | -0.25 | 任意斜杠 |
| 变更罚分(任何变更的扩展音) | -0.60(对于完全减七和弦为 -0.30,见下方减七和弦章节) |
| 利底亚-属十三和弦一致性奖励 | +2.1 | 当原位属和弦同时存在9、♯11和13时应用 |
| 六和弦无五音(三音声部) | -0.60 | 区分 C6(no5) 和 Am7/C |
然后将原始分数除以 `sqrt(requiredToneCount)` 以在不同和弦复杂度之间进行归一化:
```
final denom = reqCount > 0 ? math.sqrt(reqCount.toDouble()) : 1.0;
final normalized = raw / denom;
```
没有归一化,七和弦(有更多必须音)会仅仅因为获得更多 `+4.0` 必须音奖励的机会而持续优于三和弦。一个完美匹配的C大三和弦会输给一个略微不匹配的C属七和弦。平方根归一化(而非线性)在保持有意义的分数区别的同时,防止复杂和弦系统地优于匹配良好的简单和弦。
### 减七和弦罚分
完全减七和弦接受较温和的变更罚分,因为它们的对称性使得备选根音得分异常高。将罚分减半有助于保留音乐家期望的解读,当添加的音符可能使旋转后的减七解读看起来人为地更干净时。
## 扩展音提取
在模板评分之后,任何未被基础模板(必须音 + 可选音 + 罚分音)考虑的音符都会进入“额外”掩码。这些被转换为命名的扩展音:
- **变更音**(来自额外掩码):降九(半音1)、升九(半音3)、升十一(半音6)、降十三(半音8)
- **自然扩展音:** 九(半音2)、十一(半音5)、十三(半音9)
自然扩展音是变成“9/11/13”还是“add9/add11/add13”,取决于它们下方的堆叠结构。在这种保守的命名模型中,九需要七音,十一需要七音和九音。十三需要七音和九音,但不需要有发声的十一音,这符合常见的和弦符号惯例,即十一音经常被省略。没有这些支撑,同一个音高类别的标签就会变成加音。
有一个属和弦语境例外:当有完整的属七和弦外壳时,半音3被解释为升九,而不是小三度罚分。这使得像 G-B-D-F-A♯ 这样的声部,其得分和拼写为 G7♯9,而不是作为一个带有未解释矛盾音的属七和弦。
## 权重是如何调整的
评分权重并非随意设定。它们是针对一组黄金测试用例进行经验调整的:这些是特定的声部,其预期输出是预先选定的。大多数黄金用例捕获的是音乐家会明确命名的和弦;模棱两可的用例则为当前的评分和排序模型固定了主要的预期解读。测试套件涵盖了原位和不同转位的大三、小三、减、属、变更和扩展声部,以及真正模棱两可的情况。
调整循环如下:
1. 运行黄金测试套件。
2. 对于任何失败的用例,使用 `chord-debug` CLI 工具检查完整的排序候选列表及其分数分解。
3. 调整权重、添加规则或添加评分奖励,直到失败的用例通过。
4. 重新运行整个套件以验证没有回归。
`chord-debug` 工具对任何音符集合运行完整的分析流水线,并打印每个候选及其分数、各个权重贡献以及决定其相对于前一个候选位置的排序规则:
```
$ dart run tool/chord_debug.dart F# Bb C E
pcs: Bb, C, E, F# | bass: F# | key: C major
1) F#7b5 8.50 members: root=F# major3=A# flat5=C flat7=E req+16 bass+1 raw=17.00 / sqrt(4) => 8.50
2) C7b5 / Gb 8.50 Δ +0.00 ~tie (vs prev: Prefer root position) members: root=C major3=E flat5=Gb flat7=Bb req+16 bass+1 raw=17.00 / sqrt(4) => 8.50
3) C7#11 / F# 6.73 Δ -1.77 (vs prev: Score outside near-tie window)
```
相同的诊断输出还暴露了等音拼写决策:MIDI 提供音高类别,引擎从获胜的和弦语境中选择音符名称。这种诊断可见性对于理解算法为何选错答案以及需要更改什么至关重要。修复一个用例的权重有时会破坏另一个用例,而在不回归的情况下取得进展的唯一方法是在进行有针对性的调整时能够看到完整的排序列表。
## 排序问题
上面的调试输出显示了为什么评分只是问题的一半。一旦多个解读变得合理,分析引擎需要一个单独的排序层,它比单个数值分数更直接地编码音乐优先级。这不是孤例。几个常见的音符集为多个合理的解读产生几乎相同的分数,而原始分数无法区分音乐家会命名哪一个:
- C-E-G-A:C6 vs. Am7/C(分数相同;原位六和弦应获胜)
- B-D-F-A♭:Bdim7 vs. G♯dim7/B vs. Ddim7/C♭ vs. Fdim7/C♭(C♭ 等音于 B;由于减七和弦的对称性,所有四个解读得分相同)
分析器通过两种排序路径处理这些歧义:针对常规名称尽管分数较低仍应获胜的情况的狭窄结构性覆盖规则,以及针对分数已经接近的候选的有序决胜规则。
### 硬规则
硬规则有意地狭窄。它们覆盖了远程斜杠和弦解读可能得分高于音乐家更可能使用的常规原位属和弦、变更属和弦、变更七和弦或减七和弦名称的情况。它们还覆盖了一个小的小三和弦色彩案例,其中完整的小三升十一转位优于一个依赖于变更十三度的原位大七sus4名称。
### 接近决胜窗口
如果没有硬规则触发,并且分数差异大于 `0.20`(`nearTieWindow` 常量),则分数较高的候选仅凭分数获胜。当分数在接近决胜窗口内时,依次应用决胜规则。第一个产生非平局结果的规则决定顺序:
1. 优先选择原位六和弦而非转位七和弦(C6 vs. Am7/C 的情况)
2. 优先选择上层结构的属七和弦斜杠(色彩低音,无其他变更)
3. 优先选择原位减七和弦(对称和弦默认以低音为根音)
4. 优先选择属七和弦外壳而非减七斜杠
5. 优先选择较少的变更/紧张色彩(包括自然十一度与大三度并存的情况)
6. 优先选择调内和弦(根据调号)
7. 优先选择主和弦(I)而非其他调内选项
8. 当低音为主音音高类别时,优先选择 I
9. 优先选择自然扩展音(9/11/13)而非加音;然后选择总扩展音较少者
10. 优先选择原位
11. 优先选择第一转位而非第二转位
12. 当两者都适合演奏的音符时,优先选择七和弦而非三和弦
13. 优先选择较少的扩展音
14. 避免挂留和弦
如果所有这些规则仍然没有产生胜者,则有一个确定性后备方案:按根音音高类别数字排序。这确保了即使对于非常规的声部,相同输入总是产生一致输出。
这些规则的顺序编码了音乐优先级。结构清晰性(原位、外壳音)优先于语境偏好(调内、主和弦)。常规命名(较少变更、自然扩展音)优先于复杂性。挂留和弦被放在后期降权,因为它们是有效的,但在缺少三度时容易过度检测,因此它们只应在周围证据支持时才获胜。
## 为实时性能进行缓存
在每次 MIDI 状态变化时运行完整的流水线(最多 12 个候选根音 × 22 个模板 = 264 次模板评估)将是浪费的。在实践中,钢琴家在一首乐曲中倾向于产生许多重复的输入状态。引擎使用一个 512 条目的最近最少使用(LRU)缓存,实现为 `LinkedHashMap`。缓存键是三个输入的哈希值:
- 音高类别集合
- 分析语境(调号 + 调性)
- `take` 参数(返回多少候选,默认为 8)
语境包含在键中,因为调内偏好规则依赖于它;不同的调号可能会改变哪个候选排名第一,即使对于相同的声部也是如此。
```
final key = Object.hash(input.cacheKey, context, take);
final cached = _cache[key];
if (cached != null) {
// 命中时提升,以便驱逐时移除 LRU 而非 FIFO
_cache
..remove(key)
..[key] = cached;
return cached;
}
```
LinkedHashMap 保留了插入顺序。缓存命中时,条目被移除并重新插入到末尾(最近使用)。驱逐时,移除第一个键(最近最少使用)。这是 Dart 中标准的 LRU 模式,无需单独的双向链表。
512 条目的容量是根据随机输入、穷举输入、调性进行以及真实世界 MIDI 录音片段上的基准测试选择的。一个 256 条目的缓存偶尔会因为真实歌曲中的快速和声变换而产生驱逐开销。1024 条目的缓存对于大多数使用场景来说产生了可忽略的收益,但相对于更紧凑的 512 条目来说使用了不必要的内存。
相似文章
和弦符号时间序列自适应能携带多少流派特征?多流派和弦符号建模的能力与边界
本文评估了小型自适应接口(LoRA、IA3、BitFit、前缀调优、全微调)如何将冻结的Music Transformer扩展到11个目标流派进行和弦符号时间序列建模。结果显示,虽然和声预测一致性提升,但流派特征表示有限,结论表明仅靠和弦符号不足以捕捉完整的流派特征。
Show HN: Resonate – 低延迟高分辨率频谱分析
Resonate 是一种低延迟、低内存的算法,用于对音频信号进行感知相关的频谱分析,采用带有指数加权移动平均的谐振器模型。
@junmingong: Khala 1.0 刚刚发布——来自北京中央音乐学院的一个音乐生成模型。论文、代码、权重……
Khala 1.0 是一个开源音乐生成模型,用于从文本和歌词生成高保真完整歌曲,采用统一的声学标记管道。由北京中央音乐学院发布,附带论文、代码、权重和演示。
灯光与音乐同步:从截断时间戳中估算精确的搜索时间
一位开发者描述了一个副项目,利用OCR和逆向工程DJ软件自动将灯光与现场音乐同步,并介绍如何通过区间平均值从截断的时间戳中估算精确的搜索时间。
ArtifactNet:通过法证残差物理学检测AI生成音乐
ArtifactNet是一个轻量级神经网络框架,通过分析音频信号中的编码器特定工件来检测AI生成的音乐,在新的6,183轨道基准测试(ArtifactBench)上达到F1=0.9829,参数量比竞争方法少49倍。该方法采用法证物理学原理,通过有界掩码UNet和紧凑型CNN提取编码器残差,编码器感知训练将跨编码器漂移减少83%。