AI不只coding:让2012年老ARM板用蜂鸣器唱AI生成的歌
1. 项目缘起与整体设计思路1.1 一块2012年的老板子能做什么手头这块板子是我在整理旧物时翻出来的2012年前后出厂的ARM开发板双核Cortex-A91GB DDR3内存板载NAND Flash只有4GB跑的是当年定制的Linux 3.0内核。屏幕接口是RGB并口音频输出只有一路3.5mm模拟口和I2S排针。按今天的标准看这配置连个像样的手机都不如但它的好处是——接口全、资料多、GPIO随便玩而且功耗低到可以用充电宝供电。我最初的想法很简单让它发出声音。不是那种蜂鸣器滴滴答答的响而是真正能“唱歌”——有旋律、有节奏、有音色变化。这个念头来自一次偶然的测试我用GPIO翻转驱动一个无源蜂鸣器发现它能发出不同频率的音调虽然音色单薄但确实能听出音阶。于是问题变成了能不能让这块老硬件借助AI的能力完成一次完整的“演唱”这里的“AI不只coding”有两层意思。第一层是字面意思AI不只是用来写代码的它还能参与音频生成、旋律编排、甚至音色优化。第二层是实践层面的整个项目里AI承担了旋律生成、音高映射、节奏对齐三个核心任务而coding只是把这些能力落到硬件上的手段。换句话说AI是大脑板子是嗓子代码是神经。1.2 为什么选择“AI生成旋律硬件发声”这条路线传统让开发板唱歌的做法无非两种一是预存WAV文件用PWM或DAC播放二是用定时器中断驱动蜂鸣器演奏预编曲目。这两种方案我都试过问题很明显——前者受限于存储空间和采样率4GB的NAND装不了几首歌而且音质受DAC精度限制后者虽然轻量但旋律是死的改一个音符就要重新编译烧录。引入AI之后整个逻辑变了。我不需要预先准备完整的曲谱而是让模型根据一个种子动机比如三个音符生成一段完整的旋律再把旋律转换成频率序列通过GPIO或I2S输出。这样做的好处有三个第一每次生成的旋律都不同板子“唱”的歌是活的第二AI可以针对硬件特性做优化比如避开蜂鸣器发不出的低频、把旋律限制在板子能稳定输出的频率范围内第三整个流程可以做成自动化的上电后板子自己生成旋律、自己演奏不需要人工干预。注意这里说的“AI生成”不是指在板子上跑大模型那不现实。实际做法是在PC端用轻量级模型生成MIDI或音符序列转换成频率表后写入板子或者通过串口实时传输。板子只负责发声不负责推理。1.3 整体架构与数据流整个系统的数据流是这样的PC端AI模型接收一个种子音符序列输出一段MIDI格式的旋律然后一个Python脚本把MIDI解析成“音符-时长-频率”的三元组列表接着通过串口把列表发给开发板板子上的C程序接收数据用定时器产生对应频率的方波或PWM信号驱动蜂鸣器或音频功放发声。硬件连接上我选的是无源蜂鸣器接在GPIO口因为它的驱动电路最简单一个三极管加一个限流电阻就够了。后来为了音色好一点换成了I2S接口的音频Codec模块但那是后话。第一版就用蜂鸣器目标是先让它响起来、唱出来。这个架构的关键在于频率映射。蜂鸣器不是线性器件它在不同频率下的响度和音色差异很大。比如同样占空比50%的方波1kHz听起来很响5kHz就刺耳200Hz又几乎听不见。所以AI生成的旋律不能直接映射到蜂鸣器上中间需要一层“硬件适配”——把标准音高映射到蜂鸣器表现最好的频段必要时做八度平移。2. 核心细节解析与实操要点2.1 AI旋律生成从种子音符到完整曲谱我用的模型是一个轻量级的LSTM网络训练数据是几百首简单的儿歌和民谣MIDI。选择LSTM而不是Transformer原因很实际训练数据少LSTM在小数据集上不容易过拟合而且推理速度快PC端CPU就能跑。模型输入是前8个音符的one-hot编码输出是下一个音符的概率分布采样时用温度参数控制随机性——温度高则旋律跳跃温度低则平稳重复。实际操作中我一般给3到5个种子音符比如C-D-E然后让模型续写32到64个音符。生成结果不会每次都完美有时候会出现大跳或者长时间重复同一个音。这时候需要加一个后处理规则限制相邻音符的音程不超过纯五度禁止连续四个以上相同音符强制在乐句结尾回到主音。这些规则不复杂但能显著提升听感。实操心得种子音符的选择很关键。用大调音阶的前三个音做种子生成出来的旋律最稳定用小调或者五声音阶会得到更“东方”的味道。我试过用半音阶做种子结果生成了一堆不协和的音程蜂鸣器放出来像警报器。2.2 频率映射与硬件适配标准MIDI音符编号0到127对应频率从8.18Hz到12543.85Hz。蜂鸣器的最佳工作频段大概在500Hz到4kHz之间超出这个范围要么听不见要么刺耳。所以映射策略是先把MIDI音符转成标准频率然后判断是否在最佳频段内如果不在就做八度平移直到落入区间。举个例子MIDI音符60是中央C频率261.63Hz低于500Hz所以上移一个八度变成523.25Hz。MIDI音符84是1046.5Hz在区间内保持不变。MIDI音符100是2637Hz也在区间内。MIDI音符120是8372Hz超出上限下移一个八度变成4186Hz还是超再下移变成2093HzOK。这个映射过程用Python写就是一个循环加条件判断但要注意八度平移会改变旋律的音高关系如果一首歌里多个音符都被平移了不同的八度旋律就乱了。所以更好的做法是整体平移——找到旋律中最高和最低音符计算它们跨越的频段然后整体平移一个八度使所有音符都尽量落在最佳区间内。2.3 定时器精度与音长控制开发板上的定时器精度决定了音高是否准确。这块2012年的板子主频1GHz定时器时钟源是24MHz分频后可以做到微秒级精度。对于蜂鸣器发声我需要的频率范围是500Hz到4kHz对应周期2ms到250微秒。用定时器产生方波就是在半个周期时翻转GPIO电平。音长控制有两种方式一是用另一个定时器做延时到时间后停止翻转二是在主循环里计数每演奏完一个音符就查表取下一个。我选的是第二种因为实现简单而且方便在音符之间插入短静音让旋律有呼吸感。实际测试中发现如果音符之间没有间隔连续相同频率的音符会连成一个长音听不出节奏。所以我在每个音符后加了20ms的静音这个值不能太大否则节奏拖沓也不能太小否则区分不明显。20ms是试出来的经验值对应每分钟300个音符的极限速度下仍然能听出断奏。2.4 供电与驱动电路注意事项蜂鸣器虽然是小电流器件但开发板的GPIO驱动能力有限一般只有几毫安。无源蜂鸣器的工作电流在20mA到30mA之间直接接GPIO可能会烧口或者声音微弱。所以必须加一级三极管驱动我用的是S8050 NPN管基极串1k电阻接GPIO集电极接蜂鸣器负极蜂鸣器正极接3.3V发射极接地。这里有个坑蜂鸣器是感性负载断电瞬间会产生反向电动势可能击穿三极管。所以要在蜂鸣器两端反并联一个续流二极管1N4148就行。我第一版没加结果玩了半小时后三极管发热严重声音开始失真换了三极管加了二极管才稳定。注意如果板子有I2S接口建议直接用音频Codec模块音质和音量都好得多。蜂鸣器方案胜在简单但音色上限很低适合做“能唱歌”的demo不适合追求音质。3. 实操过程与核心环节实现3.1 PC端AI旋律生成脚本PC端的脚本用Python写依赖music21和tensorflow。music21负责MIDI解析和音符操作tensorflow跑LSTM模型。模型结构很简单一层Embedding两层LSTM各128个单元一层全连接输出128维的softmax。训练数据我用了大约200首MIDI每首截取前64个音符做成序列样本。生成旋律的代码逻辑是这样的加载模型输入种子音符序列循环调用model.predict每次取概率最高的音符作为输出同时把输出追加到输入序列末尾滑动窗口保持长度为8。生成64个音符后用music21写成MIDI文件。import numpy as np from music21 import stream, note, midi from tensorflow.keras.models import load_model model load_model(melody_lstm.h5) seed [60, 62, 64, 65, 67] # C D E F G sequence seed.copy() for _ in range(64): input_seq np.array(sequence[-8:]).reshape(1, 8) pred model.predict(input_seq, verbose0) next_note np.argmax(pred[0]) sequence.append(next_note) s stream.Stream() for n in sequence: s.append(note.Note(n)) s.write(midi, fpgenerated.mid)这段代码跑一次大概3秒生成64个音符。实际使用中我会生成10次挑一个听感最好的。挑的标准很主观旋律有起伏但不跳脱结尾能回到主音没有连续超过3个相同音符。3.2 MIDI到频率表的转换拿到MIDI文件后用music21解析出每个音符的MIDI编号和时长然后转成频率。转换公式是f 440 * 2^((n-69)/12)其中n是MIDI编号。然后做八度平移使所有频率落在500Hz到4kHz之间。from music21 import converter score converter.parse(generated.mid) notes score.flat.notes freq_list [] for n in notes: midi_num n.pitch.midi freq 440 * (2 ** ((midi_num - 69) / 12)) while freq 500: freq * 2 while freq 4000: freq / 2 duration n.quarterLength * 0.5 # 假设四分音符500ms freq_list.append((int(freq), int(duration * 1000)))这里duration的换算需要根据实际演奏速度调整。我一般设四分音符为500ms对应120BPM。如果旋律听起来太慢就改成400ms太快就改成600ms。这个参数直接影响“唱”出来的感觉。3.3 串口传输协议设计频率表通过串口发给开发板协议很简单每帧5个字节前两个字节是频率小端后两个字节是时长毫秒小端最后一个字节是校验和。板子收到后先校验通过则存入缓冲区缓冲区满了就开始演奏。串口波特率设的是115200传输64个音符需要64*5320字节加上帧头帧尾大概350字节传输时间约30ms完全可以接受。板子端的接收程序用中断实现每收到一个字节就存入环形缓冲区主循环里解析。实操心得串口传输最容易出问题的是地线没接好导致数据错位。我一开始只接了TX和RX忘了接地结果收到的频率全是乱的。后来把GND也接上立刻稳定了。另外如果PC端和板子端波特率不一致收到的就是乱码这个用示波器看一下波形就能确认。3.4 板端发声程序实现板子端的C程序核心是一个定时器中断服务函数。定时器配置为每半个周期触发一次中断在中断里翻转GPIO电平。频率改变时重新计算定时器的重载值。void timer_isr(void) { static int half_period 0; gpio_toggle(BUZZER_PIN); timer_set_reload(half_period); timer_clear_interrupt(); } void play_note(int freq, int duration_ms) { half_period 12000000 / (2 * freq); // 24MHz时钟 timer_set_reload(half_period); timer_start(); delay_ms(duration_ms); timer_stop(); delay_ms(20); // 音符间静音 }主循环里不断从缓冲区取音符调用play_note。缓冲区空了就等待串口数据。整个程序不到200行编译出来二进制只有十几KB跑在这块老板子上毫无压力。实际测试时发现如果delay_ms用忙等待实现会阻塞串口接收导致数据丢失。所以我把串口接收放在中断里主循环只负责演奏这样就不会丢数据了。另外定时器中断的频率不能太高4kHz对应半周期125微秒中断服务函数必须在125微秒内完成否则会丢中断。我实测下来这个函数执行时间大约2微秒完全没问题。3.5 完整演奏流程与效果整个流程串起来是这样的PC端生成MIDI转成频率表通过串口发送板子接收后存入缓冲区主循环依次演奏每个音符。演奏过程中板子上的LED会随音符闪烁算是视觉反馈。第一版跑通的时候板子唱了一段C大调的旋律虽然音色像电子玩具但确实能听出调子。后来我优化了八度映射让旋律更连贯加了音符间静音节奏更清晰换了更大的蜂鸣器音量也上来了。最终效果是一块2012年的老板子用AI生成的旋律唱出了一段30秒左右的“歌”。4. 常见问题与排查技巧实录4.1 蜂鸣器不响或声音微弱这是最常见的问题排查顺序如下先确认GPIO有没有输出用万用表测电压如果一直是高电平或低电平说明程序没跑起来再确认三极管有没有接错基极电阻是不是太大1k到10k之间都行太大则驱动不足最后确认蜂鸣器是不是无源的有源蜂鸣器接上去只会响一个固定频率不会随频率变化。我遇到过一次蜂鸣器声音特别小查了半天发现是限流电阻用了10k换成1k后声音立刻大了。还有一次是蜂鸣器正负极接反了虽然也能响但声音发闷换过来就好了。4.2 音高不准或旋律跑调音高不准通常是定时器重载值算错了。检查公式重载值 时钟频率 / (2 * 目标频率)。比如24MHz时钟目标1kHz重载值应该是12000。如果算成24000频率就变成500Hz低了一个八度。另一个原因是八度映射把不同音符平移了不同的八度导致音程关系错乱。解决办法是整体平移找到旋律中最高和最低音符计算它们跨越的频段然后整体平移一个八度使所有音符都尽量落在最佳区间内。4.3 串口数据丢失或乱码数据丢失一般是缓冲区太小或者中断处理太慢。我把环形缓冲区设成256字节中断里只做存数据解析放在主循环就没再丢过。乱码则多半是波特率不匹配或者地线没接这两个检查一下基本能解决。还有一种情况是PC端发送太快板子来不及处理。我在PC端加了10ms的帧间延时给板子留出解析时间问题就消失了。4.4 演奏过程中板子死机死机的原因通常是中断嵌套或者堆栈溢出。定时器中断和串口中断如果同时触发而中断服务函数里又调用了不可重入函数就可能死机。解决办法是给中断设优先级定时器中断优先级高于串口中断或者在中断里只做最简单的操作复杂逻辑放到主循环。我遇到过一次死机查到最后发现是delay_ms函数在中断里被调用了而delay_ms内部用了系统滴答定时器和蜂鸣器定时器冲突。把delay_ms改成简单的循环延时就好了。4.5 常见问题速查表现象可能原因排查方法解决措施蜂鸣器不响GPIO无输出万用表测电压检查程序是否运行声音微弱驱动电流不足测三极管基极电压减小基极电阻音高不准定时器重载值错误计算重载值重新计算并烧录旋律跑调八度映射不一致检查映射逻辑改为整体平移串口乱码波特率不匹配示波器看波形统一波特率数据丢失缓冲区太小增大缓冲区改环形缓冲区演奏死机中断冲突检查中断优先级调整优先级或简化ISR避坑技巧调试蜂鸣器的时候先用固定频率测试比如1kHz确认硬件通路没问题再上旋律。这样能把硬件问题和软件问题分开省很多时间。另外串口传输的数据最好加个校验和虽然简单但能过滤掉大部分干扰导致的错误。5. 后续扩展与个人体会这个项目跑通之后我试过几个扩展方向。一是换用I2S音频Codec音质提升非常明显从“电子玩具”变成了“能听的音乐”但电路复杂了不少需要额外的功放和滤波。二是把AI模型换成生成和弦的让板子同时发出两个声部但蜂鸣器只能发单音所以这个方向需要换硬件。三是把整个流程做成上电自启动板子一通电就自动生成旋律并演奏这个已经实现了用了一个简单的shell脚本在启动时调用Python和串口发送程序。我个人在实际操作中的体会是老硬件的价值不在于性能而在于它的确定性和可掌控性。这块2012年的板子每一个寄存器、每一个时钟周期都是透明的没有隐藏的中断控制器没有复杂的电源管理你想让它做什么它就做什么。AI的加入不是让它变“聪明”而是让它变“有趣”——同样的硬件每次唱出来的歌都不一样这种不确定性本身就是一种乐趣。最后分享一个小技巧如果你也想用蜂鸣器做类似的项目建议先用Arduino或者树莓派快速验证旋律和映射逻辑跑通了再移植到老板子上。因为老板子的开发环境往往很麻烦编译工具链、烧录方式、调试手段都不如现代平台方便。把逻辑验证和硬件适配分开能省掉很多重复劳动。