单片机播放音乐的方式
音频格式:
PCM(不是音乐格式,而是一种概念):人耳听到的声音是连续模拟电压波形,单片机只能存 0/1 数字,PCM 就是把模拟声音转成一串数字采样点的标准格式:按固定频率(8k/16k/44.1kHz)每隔一小段时间采集一次波形高度
WAV:= 文件头 + PCM原始数据。文件头(前 44 字节左右) 记录配置信息:采样率、8/16bit、单 / 双声道、总采样数量;后半段全部内容 = 裸 PCM 数据流。可以直接给DAC播放。播放最简单,但占用较大的存储空间
MP3:是 WAV 有损压缩产物,播放前必须解码还原成 PCM 波形才能给 I2S ,对单片机要求较高,需要实时解码能力
MIDI:不存声音、只存演奏指令,必须搭配音源合成出声波 ,最简单的方式就是转成WAV再播放,不然就需要单片机实现MIDI合成器来播放
VGM:是一种专门记录老式游戏机 / 街机声音芯片指令的高精度音频格式,按时间顺序记录对 SN76489、YM2612、YM2413、AY-3-8910 等芯片的寄存器写入指令,不是采样、不是 MIDI,回放时需要模拟对应芯片才能发声
VGZ:就是 Gzip 压缩后的 VGM(本质是 .vgm.gz,后缀简化为 .vgz)
概念:
I2S:单片机存储的音乐都是数字二进制数据,没法直接给喇叭。I2S 负责把连续的音频数据流,规范、同步地传给DAC音频处理芯片(比如 MAX98357、PCM5102)
DAC:单片机里音乐是一堆 0、1 数字采样点;喇叭需要连续变化的电压才能震动出声。DAC 把一串数字,转换成高低连续变化的模拟电压波形
- 单片机内置 DAC:ESP32 (25/26 脚)、STM32F103 自带,直接输出模拟音频电压,功率极小,只能接耳机,带不动喇叭,必须外加功放。
方式 1:软件直接写 DAC 寄存器,把数字采样值直接写入 DAC 硬件寄存器,CPU 全程阻塞循环读写,采样率一高就卡顿
方式 2:搭配硬件 I2S 外设转发数据,ESP32 可以把 I2S 外设设置为DAC 直通模式,I2S 硬件自动把音频数据流推给内置 DAC,靠 DMA 搬运,几乎不占用 CPU
- 外置 I2S DAC 芯片(PCM5102、MAX98357 内置 DAC),接收来自单片机的 I2S 数字流,高精度转换成干净模拟音频
- 外置 SPI/IIC DAC 芯片(MCP4725、PCF8591),对于没有 I2S 的单片机,通过普通 SPI/I2C 把音频采样值发给 DAC → DAC 输出模拟音频 → 再接模拟 D 类功放 (PAM8403) 放大喇叭
- 单片机通过 PWM+RC 滤波 模拟 DAC
A/AB 类功放:功率管持续线性导通放大信号,效率低发热大,但音质好,如 8002D芯片
D类功放:D 类功放功率管仅通断开关调制信号,效率高发热小,音质相对没那么好,用于蓝牙音箱,掌机等功耗敏感的地方。
模块:
MAX98357:I2S 输入 + 内置 DAC + 内置 D 类功放,整个模块淘宝2.8
NS4168:国产MAX98357平替,8Pin引脚,所需原件少,淘宝1.4一个芯片
HT517:另一款国产MAX98357平替,也是8Pin引脚,外围原件稍多
PCM5102:只有 DAC,无功放,输出微弱音频信号,必须后级功放
方案:
1.无源蜂鸣器通过定时器 PWM 音符播放(纯软件):
- 存储简谱数组:音符频率 + 持续时长,定时器翻转 IO 输出方波
- 例:Arduino tone () 函数、STM32 定时器生成指定频率 PWM、RP2040 PIO 输出方波
- 一个PWM通道只能同时播放一个音轨,要实现多音轨,就需要每个音轨一个PWM通道连一个蜂鸣器
参考项目:
buzzer_music:支持多音轨,多音轨就是多个PWM引脚接多个蜂鸣器
arduino-playtune:支持多音轨,好像用的不是PWM,而是IO口寄存器翻转来实现。该项目的音乐源可以用作者的另一个项目 miditones 将 midi 音乐转为 byte 数组,然后直接在代码里播放。我让AI写了一个MPY版本,参考下面的代码(附注Code1)。Cartridge:NES APU 模拟器,可以播放VGM格式的数据,4音轨连接4个蜂鸣器(NES就是5个音轨,4个波形,一个采样)
ESP32 PWM:网友提供的4音轨播放音乐的代码片段(未验证)
2.模拟DAC,PWM+RC 滤波 + 功放(8bit 简易波形,可短真人声、和弦)ΔΣ(Delta-Sigma):PWM通过RC低通滤波器模拟DAC_pwm经过rc滤波后电压
- Arduino:analogWrite+RC 滤波,简易 PCM 播放库
- STM32F103:定时器高速 PWM,DMA 搬运 PCM 数据,无卡顿
- RP2040:PIO 硬件高速 PWM,独立硬件不占用 CPU
- ESP8266/ESP32:ledc PWM 外设输出
参考项目:
Playing Wave File Using Arduino(库 TMRpcm):SPI 读取 SD 卡 8bit 单声道 PCM-WAV 文件 + 硬件 PWM 充当简易 DAC 输出模拟音频
pretendo :适用于 Arduino Uno、Due 和 ESP32 的高性能 NES APU 模拟器(未验证,该项目好像是模拟DAC输出的?我看示例图只有一个音频输出口)
ESP8266Audio: 比较流行的Arduino库,支持8266,esp32,rp2040。有2种输出方案,一种是硬件DAC(AudioOutputI2S);另一种PWM模拟DAC(AudioOutputI2SNoDAC)
Arduino PCM 音频入门指南、使用Arduino直接发声(库 damellis/PCM):这个库比较简单,代码不多,但只有AVR架构能用
3.通过I2S传送数据给内部的DAC(ESP32),然后DAC引脚接外部D类功放(8002),再连接喇叭
4.通过I2S传送数据给外部I2S DAC模块(MAX98357),直接连喇叭
ESP8266Audio: 比较流行的Arduino库,支持8266,esp32,rp2040。有2种输出方案,一种是硬件DAC(AudioOutputI2S);另一种PWM模拟DAC(AudioOutputI2SNoDAC)
5.通过I2C传送数据给外部I2C DAC模块(MCP4725),模块再接D类功放(8002),连接喇叭
参考项目:
MCP4725 演示播放器——简单的 DIY 电子音乐制作项目 --- MCP4725 Sample Player – Simple DIY Electronic Music Projects
wayoda/micropython-mcp4725: A micropython driver for the mcp4725 I²C DAC
6.直接将PCM数据写入 DAC 寄存器(比如一些便宜的单片机只有DAC,没有I2S)
Cartridge:NES APU 模拟器,可以播放VGM格式的数据,4音轨合成一个通道,直接写入DAC(ESP32 25引脚)输出
附注Code1:
import machine import time import array # ====================== 配置区 ====================== # 输出引脚,最多6路(对应原版mega6声道) CHANNEL_PINS = [4, 5, 19, 22] # 系统时钟 16MHz(和Arduino一致,用于计算音符分频) F_CPU = 16000000 # MIDI*2 频率表,直接复制原版PROGMEM查表 FREQ2_TABLE = array.array('H', [ 16, 17, 18, 19, 21, 22, 23, 24, 26, 28, 29, 31, 33, 35, 37, 39, 41, 44, 46, 49, 52, 55, 58, 62, 65, 69, 73, 78, 82, 87, 92, 98, 104, 110, 117, 123, 131, 139, 147, 156, 165, 175, 185, 196, 208, 220, 233, 247, 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, 523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988, 1047, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976, 2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951, 4186, 4435, 4699, 4978, 5274, 5588, 5920, 6272, 6645, 7040, 7459, 7902, 8372, 8870, 9397, 9956, 10548, 11175, 11840, 12544, 13290, 14080, 14917, 15804, 16744, 17740, 18795, 19912, 21096, 22351, 23680, 25088 ]) # ==================================================== class PlaytuneESP32: def __init__(self): self.chan_num = len(CHANNEL_PINS) self.pwms = [] # 初始化所有PWM通道,初始关闭 for pin in CHANNEL_PINS: pwm = machine.PWM(machine.Pin(pin)) pwm.duty(0) self.pwms.append(pwm) # 乐谱状态 self.score_data = None self.cursor = 0 self.score_start = 0 self.timer = None self.wait_tick = 0 self.is_playing = False def play_note(self, chan: int, midi_note: int): """指定通道播放MIDI音符""" if chan >= self.chan_num: return if midi_note > 127: midi_note = 127 if midi_note < 24: # 16M时钟低于24无法播放 self.pwms[chan].duty(0) return freq2 = FREQ2_TABLE[midi_note] freq = freq2 // 2 self.pwms[chan].freq(freq) self.pwms[chan].duty(512) # 50%占空比方波 def stop_note(self, chan: int): """关闭指定通道音符""" if chan >= self.chan_num: return self.pwms[chan].duty(0) def stop_all(self): """停止所有声道""" for pwm in self.pwms: pwm.duty(0) self.is_playing = False if self.timer: self.timer.deinit() self.timer = None def tune_delay(self, ms: int): """替代原版tune_delay,毫秒阻塞延时""" time.sleep_ms(ms) def _parse_step(self, t): """定时器回调:解析乐谱指令""" if not self.is_playing: return if self.wait_tick > 0: self.wait_tick -= 1 return data = self.score_data cmd = data[self.cursor] self.cursor += 1 if cmd < 0x80: # 延时指令 15bit大端毫秒 dur_high = cmd dur_low = data[self.cursor] self.cursor += 1 duration_ms = (dur_high << 8) | dur_low # 定时器1ms中断一次,tick计数=ms self.wait_tick = duration_ms return opcode = cmd & 0xF0 chan = cmd & 0x0F if opcode == 0x80: # 停止音符 8t self.stop_note(chan) elif opcode == 0x90: # 播放音符 9t nn note = data[self.cursor] self.cursor += 1 self.play_note(chan, note) elif opcode == 0xC0: # 乐器切换,忽略参数 self.cursor += 1 elif opcode == 0xE0: # 从头循环 self.cursor = self.score_start elif opcode == 0xF0: # 停止乐曲 self.stop_all() def play_score(self, score_bytes): """加载并播放Playtune乐谱字节数组""" self.stop_all() self.score_data = score_bytes self.cursor = 0 self.score_start = 0 self.wait_tick = 0 self.is_playing = True # 1ms定时器,用于节拍解析 self.timer = machine.Timer(0) self.timer.init(period=1, mode=machine.Timer.PERIODIC, callback=self._parse_step) # 执行第一条指令 self._parse_step(0) def is_busy(self): """判断是否正在播放""" return self.is_playing # ====================== 测试乐谱(截取你提供的bach片段,简化版) ====================== # 原版bach_bwv847 前一小段字节流,用bytes存储,通过 miditones 这个来将 mid 音乐转为序列 TEST_SCORE = bytes([ 0x90,44, 0,119, 0x91,74, 0x90,48, 0,119, 0x92,65, 0x91,75, 0x90,49, 0,119, 0x81, 0x82, 0,119, 0x91,65, 0x92,75, 0x90,42, 0,119, 0x80, 0x81, 0x82, 0,119, 0x90,65, 0x91,75, 0x92,42, 0,119, 0x80,0x81,0x82, 0xF0 # 乐曲结束 ]) # ====================== 主程序 ====================== if __name__ == "__main__": pt = PlaytuneESP32() while True: print("开始播放乐曲...") pt.play_score(TEST_SCORE) # 循环等待播放结束 while pt.is_busy(): time.sleep_ms(50) print("播放完毕,等待1秒重播") pt.tune_delay(1000)

浙公网安备 33010602011771号