news 2026/10/15 16:04:43

STM32电子琴12键设计:PWM发声与按键扫描全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32电子琴12键设计:PWM发声与按键扫描全解析

简介:一份基于STM32的12键电子琴完整工程,面向单片机初学者与嵌入式课程设计,演示按键输入、定时器PWM音调生成、音符时长调节等音乐交互实现。压缩包共186个文件、6.35MB,包含32个C源文件、34个头文件及编译生成的中间文件,另有Keil的uvprojx工程文件、map映射和axf映像,可直接打开工程并烧录验证。目前已有418人学习。项目除12键演奏外,还支持用户调整音调与声音时长;内含stm32f10x_tim.c、stm32f10x_rcc.c等标准外设库代码,对应时钟、定时器配置,配合keilkill.bat清理脚本,可完整梳理MDK工程的编译流程。对于想在STM32上快速落地音乐类项目的开发者,这套工程提供了可运行的参考代码与清晰目录结构,适合直接二次开发。

1. 为什么是 12 键而不是 8 键:stm32 电子琴(12键)到底在解决什么

stm32 电子琴(12键)这个标题看起来像个入门玩具,但拆开看,它把单片机开发里最常用的三样东西串成了一条完整链路:IO 扫描、定时器 PWM、音符频率映射。12 键意味着刚好一个完整八度,从 C4 到 B4,所有半音都在,这意味着你可以弹任意调式,而不是像 8 键玩具琴那样只能对着 C 大调咿咿呀呀。对做课程设计、嵌入式入门或者给孩子做声学玩具的人来说,这个项目最大的价值不是琴键数量,而是让你用最少的物料亲手验证“一个按键对应一个可预期频率”这件事。

这个项目适合两类人:一类是刚学完 GPIO 和定时器、想找个综合练习的单片机初学者;另一类是被课程设计逼着交实物、但又不想一上来碰 LCD 和复杂协议的在校生。它不需要外扩存储、不需要操作系统、不需要音频 Codec,一块最小系统板、一个无源蜂鸣器、12 个轻触按键就能跑起来。这篇文章不打算给你抄一份完整工程,而是讲清楚一个 12 键 stm32 电子琴从零到能出声、能录音回放的过程中,哪些参数是必须调对的,哪些地方会翻车。

2. 发声方案与硬件选型:PWM 是默认解,但前面有几个决定不能省

2.1 无源蜂鸣器 + PWM 和喇叭 + 功放的差距在哪里

先回答最基础的问题:声音从哪里来。常见做法是 MCU 输出 PWM 方波,驱动无源蜂鸣器发声。无源蜂鸣器内部没有振荡源,给它什么频率的方波,它就发出什么频率的声音;而那种一上电就自己响的叫做有源蜂鸣器,内部自带振荡电路,只能发固定频率的“嘀嘀”声,完全不能当乐器用。所以选型第一件事就是确认你手里的是无源蜂鸣器,通常看封装上有没有“+”极标识和线圈阻抗,有源蜂鸣器内部是压电片加振荡电路的组合,直接把 PWM 接上去要么不响要么乱响。

为什么不是喇叭加功放?因为 12 键单音电子琴只需要能听清音高,不需要低频下潜和动态范围。无源蜂鸣器在 2kHz 到 4kHz 频段有天然谐振峰,声音响度足够,驱动电路简单到只有一个三极管。喇叭方案要加功放芯片、滤波电感、更大的电容,物料成本和调试时间都翻倍,对一个课程设计项目来说工作量不成比例。至于 DAC 方案,STM32F103 内部根本没有 DAC,要外扩 MCP4725 之类,I2C 驱动倒不复杂,但 F103 的性价比优势就没了。PWM 带来的方波失真反而让声音带上一股“电音味”,这正是电子琴这个项目名字该有的气质。

2.2 主控选型:为什么 F103C8T6 就够,不用上 F4

12 键项目对主控的要求很低:一路 PWM、一路定时中断、七个 IO 扫矩阵,加一起资源占用不到 F103C8T6 的三分之一。C8T6 有 48 个引脚、64KB Flash、20KB RAM,按最粗糙的算法,20KB RAM 可以存两千多个录音事件(每个事件 4 字节),完全够用。Flash 里放一张 12 元素的频率查找表,剩下的空间够再塞一个 OLED 驱动或者滑音算法。

对比某 F401 最小系统板,主频从 72MHz 提到 84MHz,Flash 和 RAM 大了好几倍,但这些提升对一个单音电子琴没有任何感知。F103 的 16 位定时器配合 72MHz 时钟,生成 261Hz 到 494Hz 的音符频率绰绰有余,误差能控制在 0.05Hz 以内。F401 的优势在于有浮点、有更多 DMA 通道、有硬件 AES,这些在这个项目里全用不上。价格上 F103 也有明显优势,某宝上 C8T6 最小系统板的成本不到一张饭卡,而 F401 板子要贵出不少。资源冗余不是坏事,但为 12 键项目上 F4 属于用大炮打蚊子,调试资料还少一大截。

2.3 引脚分配与 4x3 矩阵的接线约定

12 个按键有两种接法:12 个独立 IO 直连,或者排成 4 行 3 列的矩阵。独立 IO 的好处是代码不用扫描,每个键一根线直接判断高低电平;坏处是占 12 个引脚,而且每根线都要接上拉或者下拉,焊线麻烦。F103C8T6 引脚够多,但把 12 个引脚全占掉之后,串口调试、OLED、扩展按键就都得挪窝。矩阵接法用 7 个引脚搞定同样的事,代价是扫描代码多十几行,但对 STM32 来说扫描 4 行 3 列只花不到 0.1ms,完全可以忽略。

我给一个常见的引脚分配方案,照着接基本不会冲突:

功能引脚模式说明
PWM 输出PA0AF 推挽TIM2_CH1,经 1k 电阻接 NPN 基极
矩阵行 0~3PB0 ~ PB3推挽输出默认输出高,扫描时逐行拉低
矩阵列 0~2PA1 ~ PA3上拉输入读列电平判断哪个键被按下
串口调试PA9 / PA10可选优先级不高,先不接也行

按键的接法是:每个轻触开关一端接行线,另一端接列线。行线由 MCU 输出,默认全部拉高;列线内部上拉,也默认为高。扫描到某一行时,把这一行输出拉低,然后读三根列线,哪一列变成低电平,就说明该行该列的交叉点被按下了。这里要特别提醒:行线的推挽输出和列线的内部上拉必须配套,如果列线没开内部上拉,按键悬空时读到的电平是随机的,会直接导致乱触发。

3. 从 CubeMX 到第一个音:PWM 配置、频率表和扫键逻辑

3.1 CubeMX 配置:TIM2 的 PSC/ARR 怎么填

用 STM32CubeMX 生成工程时,核心只有两个数字:预分频器 PSC 和自动重装载值 ARR。PWM 输出频率由这两个参数决定,公式是:PWM 频率 = 定时器时钟 / ((PSC + 1) * (ARR + 1))。F103 内部定时器时钟是 72MHz,如果把 PSC 填成 71,那么 (PSC + 1) = 72,72MHz / 72 = 1MHz,意味着定时器计数器每 1 微秒加一。这时只要设置 ARR,输出频率就直接等于 1MHz / (ARR + 1),一点都不用动脑子。

我在 CubeMX 里的配置习惯是:在 TIM2 的 Parameter Settings 里,把 Prescaler 填 71,Counter Period(也就是 ARR)先填 3821,Pulse 填 1910,其他保持默认,然后勾选 PWM Generation Channel 1。3821 这个数对应的是中央 C,也就是音符 C4 的 ARR,实际输出频率 1MHz / 3822 ≈ 261.64Hz,和标准 261.63Hz 几乎一模一样。Pulse 填 1910 约等于 ARR 的一半,也就是 50% 占空比。这里有个新手最容易踩的坑:CubeMX 的 Pulse 字段单位是计数个数,不是百分比。如果填 50,占空比不是 50%,而是 50 / 3822 ≈ 1.3%,蜂鸣器声音会小到怀疑人生。

时钟树部分,如果你用的开发板是 8MHz 外部晶振,在 Clock Configuration 里把 HSE 设为 8MHz,然后让 PLL 输出 72MHz。如果是像某常见最小系统板那样只有 8MHz 晶振,CubeMX 默认配置就能跑;如果你用的是内部 RC 振荡器,频率精度不如外部晶振,后面音准问题会变明显,建议优先用外部晶振。

3.2 12 音频率表:一个完整八度的查表数组

音阶频率是确定的物理常数,不需要每次用 sin 或者 pow 去算。做法是把 12 个半音的 ARR 值做成一张表,按键按下时直接查表赋值给定时器。下面这张表是按计数时钟 1MHz 算出来的,PSC 固定 71,ARR = 1000000 / 频率 - 1,四舍五入取整:

序号音符频率 (Hz)ARR
0C4261.633821
1C#4277.183607
2D4293.663405
3D#4311.133214
4E4329.633033
5F4349.232863
6F#4369.992702
7G4392.002550
8G#4415.302407
9A4440.002272
10A#4466.162144
11B4493.882024

对应到 C 语言里就是一张静态数组:

// 计数时钟 1MHz,PSC=71,ARR = 1000000 / f - 1 static const uint16_t note_arr[12] = { 3821, 3607, 3405, 3214, 3033, 2863, 2702, 2550, 2407, 2272, 2144, 2024 };

为什么用查表而不是在代码里算?嵌入式里浮点除法要被尽可能避免,虽然 F103 算一次也无妨,但查表能保证每次触发音符的延迟都是一致的,不会出现“这个音快一点那个音慢一点”的玄学问题。这张表整个工程里只用一处,所有按键、录音回放、将来的滑音逻辑都从它取值,换音域时只改数组不改代码逻辑。需要提醒的是,表里的所有数字都绑定了一个前提:PSC = 71,定时器时钟 = 72MHz。如果你把 PSC 改成别的值,整张表全部要重算,不能只改一个音。

3.3 主循环里最朴素的扫键与发声逻辑

先写两个最基础函数:一个负责把指定音符写到定时器,一个负责停止发声。代码尽量精简,方便你理解每一步在干什么:

void play_note(uint8_t idx) { if (idx >= 12) return; uint16_t arr = note_arr[idx]; // 先更新占空比,再更新频率,避免 ARR 突变瞬间占空比错误 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, arr >> 1); __HAL_TIM_SET_AUTORELOAD(&htim2, arr); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); } void stop_note(void) { HAL_TIM_PWM_Stop(&htim2, TIM_CHANNEL_1); }

逻辑说明:arr >> 1 相当于 arr / 2,因为音符频率变了之后,原来基于 3821 算出来的 Pulse 已经是错的,必须先按新 arr 重新设置比较值。先设 CCR 再设 ARR 是为了避免 ARR 变小之后 CCR 大于等于周期,导致占空比瞬间变成 100%,硬件上会爆一声。HAL_TIM_PWM_Start 每次调用都会重新使能输出通道和计数器,重复调用不会产生副作用,所以这个函数既支持第一次发声也支持换音。

接下来是矩阵扫描。4 行 3 列,每次拉低一行,读三列:

uint8_t key_scan(void) { static const uint16_t row_pins[4] = {ROW0_Pin, ROW1_Pin, ROW2_Pin, ROW3_Pin}; static const uint16_t col_pins[3] = {COL0_Pin, COL1_Pin, COL2_Pin}; for (uint8_t row = 0; row < 4; row++) { HAL_GPIO_WritePin(ROW_GPIO_Port, row_pins[row], GPIO_PIN_RESET); HAL_Delay(1); // 等行线电平稳定 for (uint8_t col = 0; col < 3; col++) { if (HAL_GPIO_ReadPin(COL_GPIO_Port, col_pins[col]) == GPIO_PIN_RESET) { return row * 3 + col; } } HAL_GPIO_WritePin(ROW_GPIO_Port, row_pins[row], GPIO_PIN_SET); } return 0xFF; // 表示没有按键按下 }

参数说明:ROW0_Pin、COL0_Pin 这些宏是 CubeMX 在 main.h 里生成的,具体名字跟你起的引脚标签一致。HAL_Delay(1) 是为了防止电平还没建立就读取,在低速扫描里多花 4ms 无伤大雅。扫描函数的返回值 0~11 恰好对应音符数组下标,返回 0xFF 表示无按键,这样主循环里不需要做偏移换算。主循环再加一个简单消抖:

uint8_t last = 0xFF; while (1) { uint8_t k = key_scan(); if (k != last) { HAL_Delay(10); // 消抖,确认不是抖动 if (key_scan() == k) { last = k; if (k == 0xFF) stop_note(); else play_note(k); } } }

这个版本是能跑的,但 HAL_Delay(10) 会阻塞整个主循环,录音回放一旦跑起来按键就会失效。它适合用来验证“按键能发声、频率都对”这个第一目标,真正要加录音回放,得把消抖改成状态机,后面会讲到。现在先把这一段跑通,确认每个键的音高都对,再往下一步走。

4. 把玩具做成乐器感:包络、延音和录音回放

4.1 用定时中断做音量包络,而不是直接开/关

直接按下一个键就输出 50% 占空比方波,声音是“嘀——”的持续音,听起来像消防报警器而不像乐器。想让声音有“弹下去”的感觉,要给音量加包络:按下瞬间音量最大,然后快速衰减或者慢速保持。F103 上最常见的做法是用 TIM3 做 1ms 时基中断,每次中断让 PWM 的比较值递减一点,占空比慢慢变小,音量和响度就跟着变化。

CubeMX 里把 TIM3 的 Prescaler 设 71,Counter Period 设 999,就是 1ms 一次中断。然后在 stm32f1xx_it.c 里找到 TIM3_IRQHandler,CubeMX 默认已经生成了 HAL_TIM_IRQHandler 的调用,你只需要在 stm32f1xx_hal.c 里实现 HAL_TIM_PeriodElapsedCallback 回调:

volatile uint16_t g_env = 0; // 当前占空比计数 volatile uint8_t g_note_on = 0; // 是否正在发声 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { if (g_note_on && g_env > 10) { g_env -= 6; // 每 1ms 衰减 6 个计数 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, g_env); } } }

逻辑说明:每次 TIM3 中断到来,如果当前有音符在响,就把 g_env 减 6,然后写回 TIM2 的比较寄存器。假设初始 g_env = 1910,衰减到 10 大约需要 317ms,也就是一个短促的“叮”声。这个衰减步长不是物理标准,纯粹是听感调出来的。改成 2 就是大约一秒的长音,改成 15 就是非常短促的拨弦声。g_env > 10 的判断是为了给声音留一点底,避免完全静音后突然又响起造成爆音。

按下按键时,play_note 里要同步初始化 g_env 和 g_note_on:

void play_note(uint8_t idx) { uint16_t arr = note_arr[idx]; g_note_on = 1; g_env = arr >> 1; // 初始音量等于 50% 占空比 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, g_env); __HAL_TIM_SET_AUTORELOAD(&htim2, arr); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); }

这里有个重要的交互:包络一直在动占空比,如果此时再切换音符,必须先重置 g_env 为新音符 arr 的一半,否则会出现“上一个音弹到一半,下一个音却从小音量慢慢起来”的怪声。要不要这么做取决于你想要的演奏手感,我习惯是按下新键立即重置音量,让它更像复音合成器而不是单音连奏。

4.2 录音回放:用事件数组而不是波形数组

12 键电子琴要录音回放,千万别去录 PWM 波形,也不要去采样麦克风。正确做法是只记录“什么时候按了哪个键、什么时候松开”,这些事件数据量极小,F103 的 RAM 装几百个事件绰绰有余。定义一个事件结构体:

#define MAX_EVT 256 typedef struct { uint16_t dt; // 距上一个事件的时间,单位 ms uint8_t note; // 0~11 有效,0xFF 表示松开 } RecEvent_t; RecEvent_t rec_buf[MAX_EVT]; uint16_t rec_cnt = 0; uint32_t rec_last = 0;

录音逻辑挂在上面的消抖判断里,当检测到按键状态从无到有,或者从有到无时,记录一个事件。dt 用当前 HAL_GetTick() 减去 rec_last,这样回放时用累加间隔时间就能还原节奏,不需要存绝对时间戳:

void record_event(uint8_t k) { if (rec_cnt >= MAX_EVT) return; uint32_t now = HAL_GetTick(); rec_buf[rec_cnt].dt = (uint16_t)(now - rec_last); rec_buf[rec_cnt].note = k; rec_cnt++; rec_last = now; }

回放时就一个循环,顺序读取每个事件,按 dt 延时后执行对应动作:

void playback_events(void) { HAL_TIM_PWM_Stop(&htim2, TIM_CHANNEL_1); for (uint16_t i = 0; i < rec_cnt; i++) { HAL_Delay(rec_buf[i].dt); if (rec_buf[i].note == 0xFF) { stop_note(); } else { play_note(rec_buf[i].note); } } stop_note(); }

这个回放的精度瓶颈是 HAL_Delay,它受 SysTick 影响,误差在几个毫秒内,对玩具级电子琴完全够用。要注意的是,回放期间主循环被阻塞,用户在回放时按键会被忽略,体验上有瑕疵。一个折中做法是引入“回放打断标志位”:在 SysTick 或者按键中断里置一个标志,回放循环每走一步检查一次该标志,发现被按下就提前退出回放。这样既保证录音节奏不混乱,又允许随时按停。

4.3 音色切换的一种廉价做法:改包络参数而不是换波形

无源蜂鸣器的音色很大程度上被它自己的谐振特性锁死了,2kHz 到 4kHz 的方波听起来都是“哔哔”的一路货色,想靠改频率实现音色变化不现实。真正低成本能做的音色区分,就是改包络的衰减形状。拨弦音色用大步长快速衰减,风琴音色不衰减保持长音,钢琴音色先快衰减再保持一个低音量尾巴。这些参数可以做成一个枚举变量,提供一个键去切换:

音色模式衰减步长保持电平听感
拨弦6 / 1ms无,衰减到 10 停止短促、颗粒感强
风琴0恒为 50%持续音,适合练音阶
钢琴2 / 1ms,到 20% 后保持保持 20%有延音尾巴

具体实现只需要改中断回调里的步进逻辑。比如风琴模式就直接不执行递减,钢琴模式在 g_env 降到 arr 的 20% 之后进入保持分支。这段代码每加一种模式,其实只是在回调里多加一个判断分支,整件事的核心仍然是那个 1ms 时基中断。如果你对音色要求再高,那就得跳出 PWM 蜂鸣器方案,去用 DAC 配波形表了,那是另一个量级的项目,不在 12 键电子琴的讨论范围内。

5. 避坑与排查:从无声到异响的 5 个经典问题

5.1 上电完全无声:先查有源/无源,再查 PWM 有没有真正输出

现象:程序烧进去后按任意键都没有任何声音,或者只有微弱的“滋滋”声。

原因:第一责任人通常是蜂鸣器类型。如果你用的是有源蜂鸣器,它内部自带振荡器,上电就响,你给它 PWM 方波反而会干扰内部振荡,导致不响或者乱响。第二个常见原因是 CubeMX 里 PA0 的模式没选对,选了 GPIO_Output 而不是 TIM2_CH1 的 Alternate Function。PWM 是外设功能,引脚必须工作在复用功能模式,普通推挽输出配置根本不会让定时器信号走到引脚上。

解决:先把蜂鸣器换成无源蜂鸣器,再检查 CubeMX 的 PA0 配置是不是 Alternate Function Push Pull。如果这两个都对,把 PA0 接到示波器上看有没有方波;没有示波器就找一根耳机线,串一个 0.1uF 电容到 PA0 和地之间听有没有“嘶嘶”声,有声音说明 PWM 输出正常,问题在蜂鸣器驱动电路。

5.2 高音发尖低音闷,频率听起来不对:ARR 和 Pulse 的配合

现象:低音勉强能听,到了 B4 或 A4 这些高音,声音变得特别尖细,或者干脆像卡住一样。

原因:绝大多数情况是切换音符时只改了 ARR,没有同步改比较值 CCR。比如初始 ARR = 3821,CCR = 1910,占空比 50%;切到 B4 时 ARR 变成 2024,但 CCR 还是 1910,这时 CCR 已经比 ARR 大了,输出占空比变成 100%,方波变成直流,蜂鸣器线圈被长时间吸住,声音自然不对。

解决:每次设置 ARR 之前先设置 CCR 为新 ARR 的一半,也就是我在 play_note 里写的 arr >> 1 那一步。另一个坑是 CubeMX 里 Pulse 按百分比填了 50,实际应该按计数个数填 1910,这个顺序错了,所有音符的声音都会很小。判断方法是:如果低音声音正常、高音失真,几乎可以断定是 CCR 没跟随 ARR 变化。

5.3 按键抖动和矩阵鬼影:同时按两个键读出一个不存在的键

现象:快速连按或者同时按两个键时,电子琴发出一个你根本没按的音符,而且音高是乱的。

原因:矩阵扫描的固有缺陷叫鬼影。4 行 3 列矩阵里,如果同时按下三个键并恰好构成三角形的三个顶点,第四个交叉点会被误判为按下。另外机械按键的抖动会让扫描函数一次读到一个错误的下降沿,如果没有消抖,就会触发一次假音符。

解决:单音电子琴最简单的方式是只接受一个键。扫描函数里如果发现同一行有两列同时为低电平,就丢弃本次扫描结果,等于禁止了多键同时发声。消抖用前面主循环里的 10ms 延时或者状态机都可以。如果要求支持双音弹奏,矩阵方案必须给每个按键串联二极管防止电流倒灌,或者干脆改成 12 个独立 IO,单个按键独立判断,鬼影问题从根上消失。12 个独立 IO 对 F103C8T6 来说完全放得下,代码还更简单。

5.4 按下就复位或者有严重爆音:驱动能力和电源去耦

现象:手一按按键,整个板子复位重启,或者每次发声都伴随“啪”的一声爆音。

原因:蜂鸣器是感性负载,开关瞬间会产生反电动势,把 MCU 电源电压拉垮,导致过压复位或者欠压复位。如果用 GPIO 直接驱动蜂鸣器,GPIO 的驱动电流本就不够,还会把芯片内部电源轨搅乱。爆音则是 PWM 启动瞬间占空比从 0 突变到 50% 造成的电流冲击。

解决:在 PA0 和蜂鸣器之间加一个 NPN 三极管驱动级,PA0 经 1k 电阻接三极管基极,蜂鸣器接在三极管的集电极和电源之间,发射极接地,同时在蜂鸣器电源两端并联 100uF 电解电容和 0.1uF 瓷片电容去耦。爆音靠包络解决,就是第 4 章的衰减逻辑,让占空比从最大平滑下降而不是瞬间切断。排查时先量一下按下去的瞬间 VDD 有没有明显跌落,如果跌落超过 0.3V,优先查电源线粗细和电容位置。

5.5 录音回放节奏不对:消抖把事件时间吃掉了

现象:录的时候节奏是对的,回放出来要么音符被吞掉,要么长音变短音,整体节奏一卡一卡。

原因:录音代码放在主循环的 HAL_Delay(10) 消抖之后,这个延时本身就会让快速连按被当成一次抖动吃掉。另外回放时如果只记录了“按下”事件,没有记录“松开”事件,长音的持续时间就完全丢失了,回放出来的每个音都一样长。

解决:录音时按下和松开的瞬间都要记录,松开记为 note = 0xFF。回放的循环按事件逐个执行,遇到 0xFF 就 stop_note,这样音长和节奏都能还原。消抖改成状态机会更稳妥:按键电平变化先记下时间戳,等 10ms 再确认最终状态,确认通过后才调用 record_event,而不是先延时再读。这套改法比最初的 HAL_Delay 版本多写十几行,但对录放音体验的提升非常明显。

6. 让 12 键电子琴好维护的一个习惯:用数据表驱动音律,不用一堆 if

做到这一步,你的电子琴已经能弹、能录、能回放了。但如果音符切换逻辑是写成“if (key == 0) { arr = 3821; } else if (key == 1) { arr = 3607; }”这种风格,后面想加滑音、加 OLED 显示音名、或者扩展到 18 键,代码会变得极其难改。我自己的习惯是把音符数据收敛成一张结构体表,频率数组只在表里出现一次。

typedef struct { uint16_t arr; // 定时器自动重装载值 uint8_t note_idx; // 音符序号,0=C4 char name[3]; // 音名字符串,例如 "C4" } NoteDef_t; static const NoteDef_t note_table[12] = { {3821, 0, "C4"}, {3607, 1, "C#4"}, {3405, 2, "D4"}, {3214, 3, "D#4"}, {3033, 4, "E4"}, {2863, 5, "F4"}, {2702, 6, "F#4"}, {2550, 7, "G4"}, {2407, 8, "G#4"}, {2272, 9, "A4"}, {2144, 10, "A#4"}, {2024, 11, "B4"}, };

以后所有功能都只访问这张表:按键回调查 arr 去发声,OLED 显示查 name 去打印,滑音算法查相邻两项做插值。要扩到 18 键,只需要再定义 6 个 C5 到 G5 的条目,发声逻辑一行都不用改。这张表还承担了一个文档职责:表头的注释必须写清“计数时钟 1MHz、PSC=71”这个前提,否则半年后你自己回来看代码,看到 3821 这个数字根本不知道它是怎么来的。

我后来养成了一个习惯:每次调完一个音阶,都用示波器量一下 PA0 的方波周期,量出来的频率跟标准表对不上就先去查时钟树,而不是怀疑代码逻辑。这个习惯救过我很多次,尤其是换了一块晶振电容配置不同的板子时,整个音表全部偏移,问题根本不在数组而在时钟。做电子琴这类跟频率打交道的项目,数据表驱动加上一个示波器,能省下大半的玄学时间。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 16:03:29

分数阶傅里叶变换在MATLAB中的实现与chirp信号检测

简介&#xff1a;分数傅里叶变换&#xff08;FRFT&#xff09;是传统傅里叶变换在角度域上的推广&#xff0c;可对信号进行连续阶次的时-频分析。这份代码包面向信号处理与图像加密方向的研究者、工程师及高年级学生&#xff0c;提供4种典型MATLAB实现&#xff0c;帮助理解FRFT…

作者头像 李华
网站建设 2026/10/15 16:03:02

EDID读写程序实战:从I2C通道解析到分辨率改写与排错

简介&#xff1a;一份面向嵌入式开发者、硬件工程师及显示驱动学习者的 EDID 读写调试工程包&#xff0c;定位于帮助用户通过集成电路间总线读取和修改显示器中的扩展显示标识数据&#xff0c;解决显示器参数识别、默认分辨率调整与定制化适配问题。压缩包共包含 21 个文件&…

作者头像 李华
网站建设 2026/10/15 16:03:00

HART防爆温度传感器直通上位机:罐区数字化改造实践

接到这个改造需求的时候&#xff0c;我正在喝一杯已经凉掉的咖啡。某油库的工艺负责人把一叠手写温度报表拍在桌上&#xff0c;问能不能把这二十几个罐的温度全部自动送到中控室&#xff0c;别再让人每天爬罐去抄表。我看了看现场条件&#xff1a;罐区全是防爆环境&#xff0c;…

作者头像 李华
网站建设 2026/10/15 16:02:58

基于璞致电子PZSDR P201Pro| 接收端基础知识-Costas环追不上时,前导码如何完成载波粗同步?(8)

前言上一篇已经通过P201Pro双机QPSK实测看到&#xff1a;当初始频偏增大、Costas环带宽又较窄时&#xff0c;环路需要较长时间才能建立正确校正&#xff0c;甚至可能无法稳定锁定。这一篇只解决其中的载波捕获问题。发送端在帧头放入两段完全相同的训练序列A|A&#xff1b;接收…

作者头像 李华
网站建设 2026/10/15 16:02:58

幂等性学习笔记

&#x1f4d8; WPF 幂等性学习笔记核心一句话&#xff1a;同一个操作&#xff0c;执行一次和执行 N 次&#xff0c;对系统产生的最终影响必须完全一样。一、概念篇 1.1 定义 幂等&#xff08;Idempotent&#xff09;源自数学&#xff1a;f(f(x)) f(x)&#xff0c;即函数重复作…

作者头像 李华
网站建设 2026/10/15 16:02:51

白帽GEO搜索结果优化工程化: 用schema与溯源把真实·可溯·不操纵写进系统

技术视角看白帽GEO优化&#xff1a;它不只是「别作弊」的口号&#xff0c;而是一套可工程化的信息治理。白帽三原则——真实、可溯、不操纵&#xff0c;对应 GEO优化 九级信任跃迁模型的第③级数据可计算性、第⑤级信源验证、第⑥级声誉信任度。结论先说&#xff1a;把三原则写…

作者头像 李华