1. MIDI音乐播放原理与嵌入式实现基础

MIDI(Musical Instrument Digital Interface)并非音频文件格式,而是一套标准化的通信协议。它不传输声音波形,而是以数字方式编码音乐事件:音符开启/关闭、音高(Note Number)、力度(Velocity)、通道(Channel)、控制器变化(如音量、调制轮)等。在嵌入式系统中实现MIDI播放,核心在于将这些抽象事件转化为可驱动发声器件的物理信号——最常用的方式是通过PWM(脉宽调制)或定时器输出特定频率的方波,驱动蜂鸣器或扬声器振动发声。

这一实现路径跳过了复杂的音频解码与DAC(数模转换)环节,极大降低了硬件门槛和软件复杂度,非常适合资源受限的MCU平台。其本质是将乐谱信息映射为一组离散的频率-时间序列:每个音符对应一个基频(决定音高),持续时间(决定时值)则由延时控制。这种“事件驱动+时序控制”的模型,与嵌入式系统的中断、定时器、状态机等核心机制天然契合。

1.1 音符频率的物理基础与工程映射

人耳可感知的声波频率范围约为20Hz至20kHz。音乐中使用的音符,是在此范围内选取的一系列具有和谐数学关系的离散频率点。标准音高A4(中央C上方的A音)被定义为440Hz。其余音符频率遵循十二平均律计算:相邻半音频率比为$2^{1/12}$。例如,A#4(升A4)频率为$440 \times 2^{1/12} \approx 466.16\text{Hz}$,B4为$440 \times 2^{2/12} \approx 493.88\text{Hz}$,依此类推。

在嵌入式实践中,我们并不需要实时计算每一个音符的精确频率。更高效的做法是预先构建一张音符频率查找表(LUT)。这张表的索引可以是MIDI音符编号(0-127),其值为对应的浮点型或整型频率值。对于本项目所用的蜂鸣器方案,精度要求不高,通常采用整数Hz即可满足听觉辨识需求。例如,常见音符频率如下:

音符 MIDI编号 频率 (Hz) 说明
C4 (中央C) 60 261.63 中音组起点
D4 62 293.66
E4 64 329.63
F4 65 349.23
G4 67 392.00
A4 (标准音) 69 440.00 基准参考
B4 71 493.88
C5 72 523.25 高音组起点

低音组(如C3)频率为对应中音组的一半(约130.81Hz),高音组(如C5)则为其两倍(约523.25Hz)。这种倍频关系源于八度音程的物理定义:频率翻倍即构成一个八度。因此,在代码中,若需快速生成低音,可直接对中音频率进行右移一位( freq >> 1 );生成高音则左移一位( freq << 1 )。这是一种在资源紧张时极具价值的优化技巧。

1.2 节拍、时值与延时控制的工程实现

乐谱中的节拍(Beat)是音乐的时间骨架,决定了音符的相对时长。常见的四分音符为一拍,八分音符为半拍,二分音符为两拍。在嵌入式系统中,我们无法直接“理解”乐理符号,必须将其量化为具体的毫秒(ms)或微秒(μs)延时。

这一步骤没有绝对标准答案,它高度依赖于目标曲目的演奏速度(BPM, Beats Per Minute)以及开发者自身的听觉判断。一个实用的工程方法是: 先确定基准时值,再按比例缩放 。例如,设定四分音符 = 500ms,则八分音符 = 250ms,二分音符 = 1000ms。对于本项目调试的《我的祖国》第一句,其节奏特征是首尾音符悠长,中间音符短促且均等。这提示我们应采用非均匀的时值策略:将首音符设为800ms,末音符设为1000ms,中间五个音符统一设为300ms。这种“听感优先”的调试法,远比机械套用乐理公式更有效。

延时的实现方式至关重要。在裸机或HAL库环境下, HAL_Delay() 是最直观的选择,但它会阻塞CPU,使整个系统在此期间无法响应其他任务或中断。对于一个仅播放单音的简单程序,这尚可接受;但若系统还需处理按键、串口通信或传感器数据,则必须采用非阻塞方式。推荐方案是使用SysTick定时器配合标志位,或在FreeRTOS中创建一个独立的任务,利用 vTaskDelay() 进行精确、非阻塞的延时。后者能确保系统其他功能的实时性不受影响。

2. STM32蜂鸣器驱动的硬件与软件配置

本项目采用STM32F103C8T6作为主控芯片,其GPIO端口具备复用功能,可配置为定时器通道输出(TIMx_CHy),从而产生PWM波。蜂鸣器选用有源蜂鸣器,其内部已集成振荡电路,只需施加直流电压即可发声;但本方案选择驱动无源蜂鸣器,因其音色更丰富,且能通过改变输入频率来精确控制音高,完全符合MIDI的核心诉求。

2.1 硬件连接与GPIO配置逻辑

无源蜂鸣器一端接VCC(通过限流电阻),另一端接MCU的某个GPIO引脚。当该引脚输出高低电平交替的方波时,蜂鸣器膜片随之振动发声。关键在于,这个方波的频率必须精确匹配目标音符的基频。

在STM32F103系列中,最适合作为此用途的是高级定时器(如TIM1)或通用定时器(如TIM2、TIM3)。以TIM2为例,其时钟源来自APB1总线(默认72MHz)。要生成440Hz的方波,需设置定时器的自动重装载值(ARR)和预分频器(PSC),使得计数周期 $T = (PSC + 1) \times (ARR + 1) / f_{CLK}$ 等于目标周期 $1/440 \approx 2272.73\mu s$。

一个典型的配置流程如下:
1. 使能时钟 :调用 __HAL_RCC_TIM2_CLK_ENABLE() 开启TIM2时钟。
2. 配置GPIO :将TIM2_CH1对应的引脚(如PA0)配置为复用推挽输出模式( GPIO_MODE_AF_PP ),并设置合适的上拉/下拉及速度。
3. 配置定时器 :设置PSC和ARR。例如,取PSC=71(即预分频72,使计数器时钟为1MHz),则ARR应设为 $1000000 / 440 - 1 \approx 2271$,以获得接近440Hz的频率。注意,ARR值必须为16位整数,故最终频率会有微小误差,但这在人耳可接受范围内。
4. 配置通道 :将TIM2_CH1配置为PWM模式1( TIM_OCMODE_PWM1 ),并使能该通道。
5. 启动定时器 :调用 HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1)

此配置完成后,只要修改ARR寄存器的值,即可动态改变输出频率,从而切换音符。这是实现MIDI播放的核心硬件基础。

2.2 HAL库下的频率动态更新与中断安全

在播放过程中,我们需要在不同音符间快速、平滑地切换频率。直接在主循环中反复调用 __HAL_TIM_SET_AUTORELOAD() 函数修改ARR是可行的,但存在一个关键隐患:若在定时器计数过程中修改ARR,可能导致输出波形出现毛刺或失真。更稳健的做法是利用定时器的更新事件(Update Event)。

HAL库提供了 HAL_TIM_PWM_Start_IT() 函数,它不仅启动PWM输出,还使能了更新中断。在中断服务函数 HAL_TIM_PeriodElapsedCallback() 中,我们可以安全地更新ARR值。因为更新事件发生在计数器归零的瞬间,此时修改ARR不会打断当前周期,保证了波形的完整性。

// 在main.c中定义全局变量存储当前音符频率
uint16_t current_note_freq = 440;

// 在更新中断回调中动态修改ARR
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if (htim->Instance == TIM2) {
        // 计算新的ARR值:ARR = (f_clk / (PSC+1)) / freq - 1
        uint32_t arr_val = (SystemCoreClock / 72) / current_note_freq - 1;
        __HAL_TIM_SET_AUTORELOAD(htim, arr_val);
        __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_1, arr_val / 2); // 设置占空比50%
    }
}

上述代码展示了如何在中断上下文中安全地更新频率。 SystemCoreClock / 72 即为1MHz的计数器时钟, current_note_freq 则由主程序根据乐谱实时更新。这种方法将频率更新与定时器的硬件周期严格同步,是工业级音频应用的标配实践。

3. MIDI乐谱数据结构的设计与解析

将纸质乐谱转化为MCU可执行的代码,本质上是一个数据建模过程。我们需要设计一种紧凑、高效且易于维护的数据结构,来承载音符序列及其属性。

3.1 简化的MIDI事件序列:音符-时值数组

鉴于本项目聚焦于旋律播放,无需处理多通道、力度、控制器等复杂MIDI特性,我们采用最简化的数据模型:一个结构体数组,每个元素代表一个音符事件。

typedef struct {
    uint16_t frequency; // 音符基频,单位Hz
    uint16_t duration;  // 持续时间,单位ms
} NoteEvent_t;

// 《我的祖国》第一句乐谱(简化版)
const NoteEvent_t song_phrase1[] = {
    {440,  800},  // 低音La (A3)
    {659,  300},  // 中音Mi (E4)
    {587,  300},  // 中音Re (D4)
    {523,  300},  // 中音Do (C4)
    {440,  300},  // 中音La (A3)
    {392,  300},  // 中音So (G3)
    {330, 1000},  // 低音Mi (E3), 拖长
};
#define PHRASE1_LENGTH (sizeof(song_phrase1) / sizeof(song_phrase1[0]))

此结构清晰明了: frequency 字段直接对应蜂鸣器所需频率, duration 字段则用于后续的延时控制。整个数组即为一段乐句的完整描述。这种扁平化设计,内存占用极小,遍历访问效率极高,完美契合MCU的资源约束。

3.2 乐谱解析引擎:从数据到声音的驱动逻辑

有了数据结构,下一步便是编写一个“播放引擎”,它负责按顺序读取 NoteEvent_t 数组,并驱动硬件发声。一个健壮的引擎应包含以下核心逻辑:

  1. 初始化 :配置好TIM和GPIO,确保蜂鸣器处于静音状态(可通过设置ARR为0或禁用通道实现)。
  2. 主循环/任务 :遍历 NoteEvent_t 数组。
  3. 音符触发 :对当前 NoteEvent_t ,将 frequency 写入 current_note_freq 变量,这会触发前述中断回调,从而更新TIM的ARR值,开始输出对应频率的方波。
  4. 时值等待 :调用延时函数( HAL_Delay() vTaskDelay() ),等待 duration 毫秒。在此期间,蜂鸣器持续发声。
  5. 音符结束 :延时结束后,将 current_note_freq 设为0(或一个极低的无效频率,如1Hz),并在中断回调中将其映射为一个极大的ARR值,使输出频率趋近于0Hz,即停止发声。或者,更直接地,在延时后调用 HAL_TIM_PWM_Stop() 来彻底关闭PWM输出。
  6. 乐句间隔 :在播放完一个乐句(如 song_phrase1 )后,加入一个较长的静音延时(如1500ms),模拟乐句间的呼吸感。
// 播放一个乐句的函数示例
void PlayPhrase(const NoteEvent_t* phrase, uint16_t length) {
    for (uint16_t i = 0; i < length; i++) {
        // 1. 触发音符
        current_note_freq = phrase[i].frequency;
        HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);

        // 2. 等待时值
        HAL_Delay(phrase[i].duration);

        // 3. 结束音符
        HAL_TIM_PWM_Stop(&htim2, TIM_CHANNEL_1);
        HAL_Delay(50); // 添加微小间隙,避免音符粘连
    }
}

// 在main()中调用
int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_TIM2_Init();

    while (1) {
        PlayPhrase(song_phrase1, PHRASE1_LENGTH);
        HAL_Delay(1500); // 乐句间隔
        // ... 播放下一句
    }
}

此引擎逻辑清晰,易于扩展。若需支持更复杂的乐谱(如带休止符),只需在 NoteEvent_t 中增加一个 is_rest 标志位,并在播放逻辑中跳过发声步骤,仅执行延时即可。

4. 乐谱调试方法论与工程实践技巧

调试一段MIDI乐谱,绝非简单的参数填空,而是一个融合了技术、艺术与经验的系统性工程。它考验的不仅是代码能力,更是对声音的敏感度和对系统行为的深刻理解。

4.1 “听感优先”的迭代式调试流程

初学者常陷入一个误区:试图一次性写出所有音符的精确频率与时值,然后编译烧录,期望得到完美结果。这在实践中几乎必然失败。正确的流程应是 分步、渐进、以听觉反馈为唯一判据

  1. 验证单音 :首先,只编写一个音符(如440Hz),并设置一个足够长的延时(如2000ms)。烧录后,用手机录音笔或专业调音App(如Guitar Tuna)验证其实际频率是否准确。若偏差较大,检查定时器配置(PSC/ARR计算)、时钟源是否正确(确认 SystemCoreClock 值)、GPIO复用功能是否启用。
  2. 构建乐句骨架 :在确认单音准确后,复制7次该音符,形成一个7音符的“骨架”数组。此时所有音符频率相同,但时值各异(如800, 300, 300, 300, 300, 300, 1000)。烧录运行,重点聆听节奏的“轮廓”——首尾的拖沓感与中间的跳跃感是否成立。这是建立乐感的第一步。
  3. 逐音替换与微调 :保持骨架节奏不变,从第一个音符开始,将其替换为目标频率(如659Hz)。烧录,聆听。若感觉不准,不是立刻去查表,而是先凭直觉调整数值(±5Hz),反复尝试,直到耳朵觉得“对了”。再进行第二个音符,依此类推。这种“试错-反馈-修正”的闭环,是掌握音准的最快途径。
  4. 整体听感打磨 :当所有音符都替换完毕,进行整体播放。此时关注点转向:音符切换是否生硬?是否存在“咔哒”声?这往往意味着频率切换时ARR修改过于突兀。解决方案是引入“滑音”(Portamento)效果,即在两个音符之间插入若干个中间频率值,让ARR值逐步过渡,而非一步到位。例如,从440Hz切换到659Hz,可插入523Hz、587Hz作为过渡点。

4.2 常见问题排查与实战经验

在无数次的乐谱调试中,我踩过不少坑,这些经验比任何理论都珍贵:

  • “无声”问题 :最常见的原因并非代码错误,而是硬件连接。请务必用万用表通断档,确认蜂鸣器两端与MCU引脚、GND之间的线路是否导通。一个虚焊的焊点,足以让整个项目停滞数小时。
  • “变调”问题 :若所有音符听起来都偏高或偏低,大概率是系统时钟配置错误。检查 SystemCoreClock 宏定义是否与实际晶振频率(8MHz)和PLL倍频设置匹配。STM32F103的默认 SystemCoreClock 是72MHz,若误设为64MHz,所有频率都会按比例降低。
  • “杂音”问题 :若声音中混有高频嘶嘶声或嗡嗡声,可能是电源噪声。在蜂鸣器供电引脚(VCC端)就近并联一个100nF陶瓷电容到GND,能显著改善信噪比。
  • “卡顿”问题 :若播放过程中出现明显停顿,检查 HAL_Delay() 的调用位置。若它被放在一个高优先级中断服务函数中,会严重阻塞系统。务必确保所有延时都在主循环或低优先级任务中执行。

最后,关于乐理知识的学习,我的建议是“按需索取”。当你发现某段旋律无论如何调整都显得“不自然”,那很可能触及了调式(如大调、小调)或和声(如属七和弦)的边界。此时,再去查阅相关资料,你的理解会无比深刻。知识的价值,在于它能立刻解决你眼前的问题,而非堆砌在硬盘里等待某个遥远的“未来”。

5. 从MIDI播放到综合项目的演进路径

MIDI音乐播放程序,看似只是一个趣味小实验,实则是嵌入式工程师能力图谱中一块关键的拼图。它串联起了时钟树、GPIO、定时器、中断、延时、数据结构、调试方法论等一系列核心概念。而它的真正价值,在于为更宏大的综合项目——如即将展开的“升光电子琴”——铺平了道路。

5.1 升光电子琴项目的技术延伸点

“升光电子琴”项目,其核心是将MIDI播放能力与用户交互、实时处理相结合。它不再是被动地播放预设乐谱,而是要成为一个主动的音乐创作与演奏工具。这意味着,本项目中奠定的基础,将在此处被深度拓展:

  • 输入扩展 :MIDI播放的“音符源”从静态数组,变为动态的GPIO按键扫描矩阵或ADC模拟电位器。你需要编写可靠的去抖动算法,并将按键ID实时映射为MIDI音符编号。
  • 实时性挑战 :用户按键的响应延迟必须控制在毫秒级。这要求你深入理解中断优先级分组(NVIC Priority Grouping)的配置,确保按键中断(EXTI)的优先级高于其他非关键中断,避免被阻塞。
  • 多任务协同 :电子琴可能同时需要处理按键、LED指示灯、OLED屏幕显示、甚至蓝牙MIDI传输。这正是FreeRTOS大显身手的舞台。你将把“播放引擎”封装为一个独立的任务( xTaskCreate() ),并与其他任务(如 key_scan_task display_task )通过队列( xQueueSend() / xQueueReceive() )进行松耦合通信。一个按键事件被扫描任务捕获后,发送一个包含音符编号和力度的结构体到“播放队列”,播放任务从中取出并执行发声。
  • 协议栈集成 :若项目升级为蓝牙MIDI键盘,你将直接调用ESP-IDF或STM32CubeMX生成的BLE MIDI服务。此时, NoteEvent_t 结构体将直接由蓝牙协议栈的回调函数提供,你的工作重心将转向如何将这些异步到达的MIDI事件,无缝、无损地注入到现有的播放引擎中。

5.2 工程师的跨界思维:技术交界处的创新机遇

本课程中反复强调的“知识只是工具,价值才是追求”,其深意正在于此。一名优秀的嵌入式工程师,其竞争力绝不在于对某一款MCU手册的倒背如流,而在于能否敏锐地识别出技术的交界点,并在其中创造出独特的价值。

我自己的第一个商业化项目——一台为金属雕刻艺术家定制的数控刀具辅助器,其核心就是一个经过深度定制的STM32系统。它融合了步进电机精准控制(类似定时器PWM)、力传感信号采集(ADC+滤波)、实时运动轨迹规划(PID算法)、以及一个为艺术家量身打造的触摸屏UI(LVGL图形库)。没有一项技术是全新的,但它们的组合,解决了艺术家在手工创作中长期存在的痛点:如何在保持手工温度的同时,获得机械加工的精度与可重复性。

这启示我们,学习嵌入式,不应画地为牢。当你掌握了MIDI播放,不妨去了解一点基本的音频心理声学;当你精通了FreeRTOS,可以研究一下LVGL如何在有限内存中渲染出流畅的动画;当你玩转了BLE,就去探索一下Apple的MIDI over BLE规范。这些看似“不务正业”的知识,终将在某个交叉点汇聚,点燃你独一无二的创新火花。你的下一个项目,或许就诞生于此刻对《我的祖国》第一句乐谱的反复调试之中——那每一次频率的微调,都是在为未来那个改变世界的构想,校准着最初的音准。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐