单片机播放音乐的方式

音频格式:

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滤波后电压

高速 PWM 模拟 DAC,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)

 

posted @ 2026-07-01 11:11  JeasonBoy  阅读(35)  评论(0)    收藏  举报