Hotdry.
systems

用C99实现轻量级音乐理论库:音程与和弦运算的数据结构设计

聚焦音程分类、和弦生成与识别算法,提供可落地的C99数据结构与工程实现参数。

在数字音频工作站和实时合成器中,轻量级的音乐理论运算是提升交互体验的关键环节。许多开发者倾向于使用现成的庞大库来解决简单的音程计算或和弦识别问题,但实际上,用纯 C99 实现一个核心音乐理论模块只需要几百行代码即可满足大多数工程场景的需求。本文将从数据结构设计出发,逐步展开音程运算、和弦生成与识别算法的工程实现,并给出可直接调用的接口参数与性能优化建议。

一、核心数据类型的设计原则

音乐理论运算的本质是将音乐概念映射到数学模型。在 C 语言中,最直观的映射方式是将音符表示为相对于某个固定参考点的半音阶偏移量。参考点的选择有两种常见策略:MIDI 协议中以 C-1 为零点,理论计算中则可以仅使用 0 到 11 的音高类来表示十二平均律下的每个音名。推荐在工程实现中同时保留两种表示方式,以适应不同的使用场景。

/* 音高类:仅表示0-11的音名,不含八度信息 */
typedef struct {
    unsigned char pc;  /* 0=C, 1=C#, ..., 11=B */
} PitchClass;

/* 绝对音符:包含八度的完整音高 */
typedef struct {
    int midi_note;  /* 标准MIDI编号,60=C4 */
} Note;

/* 音程:以半音数为单位的相对距离 */
typedef struct {
    int semitones;  /* 可为负值表示下行音程 */
} Interval;

在上述结构体设计中,PitchClass 用于不需要区分八度的理论运算场景,例如和弦识别;Note 用于需要生成实际音频或 MIDI 消息的场景;Interval 则充当运算的桥梁。需要特别注意的是,Interval 的 semitones 字段应设计为有符号整数,因为音乐中存在下行音程的概念。

为了保证运算结果的正确性,需要实现一个模 12 的归一化辅助函数。该函数必须妥善处理负数的模运算结果,确保无论输入值为何,最终都能映射到 0 到 11 的范围内。

static inline int mod12(int x) {
    int r = x % 12;
    return (r < 0) ? r + 12 : r;
}

二、音程运算与分类算法

音程分类是音乐理论库的核心功能之一。在十二平均律下,任意音程都可以映射到 0 到 12 半音的范围内,这个范围涵盖了所有基本音程和部分复音程。实现音程分类的第一步是定义音程枚举类型,涵盖从一度到八度的所有基本音程。

typedef enum {
    INT_UNISON = 0,
    INT_MINOR_SECOND,
    INT_MAJOR_SECOND,
    INT_MINOR_THIRD,
    INT_MAJOR_THIRD,
    INT_PERFECT_FOURTH,
    INT_TRITONE,
    INT_PERFECT_FIFTH,
    INT_MINOR_SIXTH,
    INT_MAJOR_SIXTH,
    INT_MINOR_SEVENTH,
    INT_MAJOR_SEVENTH,
    INT_OCTAVE,
    INT_UNKNOWN
} IntervalClass;

有了枚举定义后,可以建立半音数到音程类的查找表。这个查找表的索引即为半音数,值为对应的音程枚举。这种查表方式的时间复杂度为 O (1),非常适合实时音频处理场景。对于超过八度的复音程,需要在分类前进行折叠运算,将其简化为基本音程。

static const IntervalClass interval_class_from_semitones[13] = {
    INT_UNISON, INT_MINOR_SECOND, INT_MAJOR_SECOND,
    INT_MINOR_THIRD, INT_MAJOR_THIRD, INT_PERFECT_FOURTH,
    INT_TRITONE, INT_PERFECT_FIFTH, INT_MINOR_SIXTH,
    INT_MAJOR_SIXTH, INT_MINOR_SEVENTH, INT_MAJOR_SEVENTH,
    INT_OCTAVE
};

IntervalClass interval_classify(Interval i) {
    int abs_semis = (i.semitones < 0) ? -i.semitones : i.semitones;
    while (abs_semis > 12) abs_semis -= 12;
    if (abs_semis > 12) return INT_UNKNOWN;
    return interval_class_from_semitones[abs_semis];
}

音程运算的基础操作包括音符与音程的加法、两个音符相减得到音程、以及音高类的移调。这些运算全部可以通过简单的整数加减完成,无需复杂的浮点运算,非常适合在音频线程中调用。

三、和弦公式与生成算法

和弦在数据结构层面可以表示为根音加上若干特定音程的组合。最简洁的实现方式是定义和弦公式结构体,其中包含和弦名称、根音到各和弦音的半音偏移量数组,以及和弦音的数量。

typedef struct {
    const char *name;
    unsigned char count;
    int semitones[8];  /* 最大支持八音和弦 */
} ChordFormula;

static const ChordFormula CHORD_MAJOR = { "M", 3, { 0, 4, 7 } };
static const ChordFormula CHORD_MINOR = { "m", 3, { 0, 3, 7 } };
static const ChordFormula CHORD_DOM7 = { "7", 4, { 0, 4, 7, 10 } };
static const ChordFormula CHORD_MAJ7 = { "maj7", 4, { 0, 4, 7, 11 } };
static const ChordFormula CHORD_MIN7 = { "m7", 4, { 0, 3, 7, 10 } };
static const ChordFormula CHORD_DIM = { "dim", 3, { 0, 3, 6 } };
static const ChordFormula CHORD_AUG = { "aug", 3, { 0, 4, 8 } };
static const ChordFormula CHORD_SUS2 = { "sus2", 3, { 0, 2, 7 } };
static const ChordFormula CHORD_SUS4 = { "sus4", 3, { 0, 5, 7 } };

在实际工程中,建议将常用的和弦公式声明为静态只读数据,避免运行时分配内存。和弦生成函数接收根音和公式指针,返回包含所有和弦音的数组。

typedef struct {
    Note notes[8];
    unsigned char count;
} Chord;

Chord chord_from_root(Note root, const ChordFormula *f) {
    Chord c;
    c.count = f->count;
    for (unsigned char i = 0; i < f->count; ++i) {
        c.notes[i].midi_note = root.midi_note + f->semitones[i];
    }
    return c;
}

这个生成函数的时间复杂度为 O (n),其中 n 为和弦音数量,常用的三和弦和七和弦均为常数时间。生成结果可以直接用于 MIDI 消息发送或频率计算。

四、和弦识别与根音推断

相比和弦生成,和弦识别是一个更具挑战性的问题。给定一组音符,算法需要推断出可能的根音位置以及和弦类型。一种实用的方法是遍历所有可能的根音候选,计算从该根音到其他音符的音程模式,然后与已知的和弦模式进行匹配。

typedef struct {
    const char *name;
    unsigned char count;
    int pattern[8];  /* 排序后的音程,无根音 */
} ChordPattern;

static const ChordPattern PATTERNS[] = {
    { "M", 2, { 4, 7 } },
    { "m", 2, { 3, 7 } },
    { "dim", 2, { 3, 6 } },
    { "aug", 2, { 4, 8 } },
    { "sus2", 2, { 2, 7 } },
    { "sus4", 2, { 5, 7 } },
    { "7", 3, { 4, 7, 10 } },
    { "maj7", 3, { 4, 7, 11 } },
    { "m7", 3, { 3, 7, 10 } },
};

识别算法的实现需要先将输入音符转换为音高类集合,然后对每个可能的根音计算音程模式。计算得到的音程数组需要排序以便与预定义模式进行比较。

const char *identify_chord(const Note *notes, int n, int *out_root_pc) {
    unsigned char seen[12] = {0};
    PitchClass pcs[12];
    int pc_count = 0;

    for (int i = 0; i < n; ++i) {
        int pc = mod12(notes[i].midi_note);
        if (!seen[pc]) {
            seen[pc] = 1;
            pcs[pc_count++].pc = pc;
        }
    }

    for (int i = 0; i < pc_count; ++i) {
        int root = pcs[i].pc;
        int intervals[12];
        int iv_count = 0;

        for (int j = 0; j < pc_count; ++j) {
            int d = mod12(pcs[j].pc - root);
            if (d > 0 && d <= 12) {
                intervals[iv_count++] = d;
            }
        }

        qsort(intervals, iv_count, sizeof(int), cmp_int);

        for (size_t p = 0; p < sizeof(PATTERNS)/sizeof(PATTERNS[0]); ++p) {
            if (PATTERNS[p].count != iv_count) continue;
            int match = 1;
            for (int k = 0; k < iv_count; ++k) {
                if (PATTERNS[p].pattern[k] != intervals[k]) {
                    match = 0;
                    break;
                }
            }
            if (match) {
                if (out_root_pc) *out_root_pc = root;
                return PATTERNS[p].name;
            }
        }
    }
    return NULL;
}

该识别算法的时间复杂度为 O (m × n × log n),其中 m 为可能的根音数量(最多 12 个),n 为输入音符数量。在实际应用中,m 和 n 的取值通常很小,整体运算可在微秒级完成,不会对音频线程造成负担。

五、与 DSP 算法的衔接要点

将音乐理论库集成到 DSP 流程中需要注意几个工程细节。首先是指针和内存管理:和弦生成和识别函数均采用值传递返回结构体,避免了动态内存分配,这是在实时音频线程中安全使用的前提条件。如果需要在中断服务例程中使用,务必将静态查找表声明为 const 类型,确保其位于只读存储段。

其次是频率计算与音高转换。已知 MIDI 编号后,标准十二平均律下的频率计算公式为 f = 440 × 2^((midi-69)/12)。在实际实现中,可以预先计算一个包含 128 个 MIDI 编号对应频率的查找表,以空间换时间的方式消除每次调用时的指数运算。

static const float midi_to_freq[128] = {
    /* 预计算的频率表,0-127对应MIDI编号 */
};

static inline float note_frequency(Note n) {
    return midi_to_freq[n.midi_note & 0x7F];
}

最后是多线程安全性的考量。上述实现中的所有函数均为纯函数,不涉及全局状态,因此天然具备线程安全性。但在实际项目中,如果需要缓存和弦识别结果或维护调性上下文,应使用线程局部存储或加锁保护共享数据。

六、扩展建议与参数清单

构建一个生产级的音乐理论库还需要考虑以下扩展方向:七和弦、九和弦、挂留和弦等更多和弦类型的支持;大小调体系下的调性分析与级数运算;变化音的等音变换处理;五度圈与调号生成功能。每个扩展方向都可以基于现有的音程和和弦框架逐步添加。

以下是实现轻量级音乐理论库的核心参数清单,供开发者在工程实践中参考:结构体设计采用 PitchClass(pc 字段 0-11)、Note(midi_note 字段 0-127)、Interval(semitones 字段有符号整数)三种基础类型;音程分类查找表大小为 13,索引 0-12 对应基本音程;和弦公式最大支持 8 个音;和弦识别遍历 12 个根音候选,结果排序使用快速排序;MIDI 频率查找表大小为 128,440Hz 标准音对应 MIDI 编号 69。

这种设计在保证代码简洁性的同时,能够覆盖绝大多数音乐制作和实时合成场景的计算需求。开发者可以根据具体项目要求在此基础上进行裁剪或扩展,无需引入重量级的第三方依赖即可实现功能完备的音乐理论运算模块。

资料来源:本文代码示例参考了 Coltrane 音乐理论库的核心实现思路,该库采用类似的半音阶偏移量模型进行音程与和弦运算。

查看归档