在不使用 DAC 的情况下在 Pi Pico 上播放音频文件
Raspberry Pico 突然成了我做嵌入式开发的首选芯片。它做工精良、硬件耐用,拥有大量看似充满智慧与热情设计的功能(驱动 GPIO 的状态机就是一个杀手级特性!)。它主要缺点——缺乏联网能力——如今已通过 W 版本得到解决。数据手册非常出色,详细记录了芯片的方方面面。此外,它得到了 MicroPython 的良好支持(我经常使用),C SDK 环境也还算不错,尽管像如今流行所要求的那样充斥着无用的复杂性:一个 cmake 构建系统进而生成 Makefile、用来定义各种东西的文件(所用库、调试输出……),总之,为了给微型设备编译微型程序的目标而言,这完全是小题大做。不,说得还轻了:所有这些复杂性,仅仅是为了给硬件固定、功能集固定的设备(除了 W / 非 W 版本的区别)生成程序。关于如今软件有多糟糕的抱怨就到此为止,但这一点必须记住。
想用这样的 MCU 做的有趣事情之一,就是生成一些声音。最直接的方法是使用芯片内置的 PWM 功能。可以将 GPIO 配置为以所需频率在 0 和 1 之间交替切换,就像这样:
from machine import Pin, PWM
pwm = PWM(Pin(1))
pwm.freq(400)
pwm.duty_u16(1000)假设你已将一个压电蜂鸣器连接到 Pico 的 GND 和引脚 1,你就会听到频率为 400hz 的方波声音。现在,很少有声音像方波那样难听。也许我们可以做得更好。这里我将跳过所有中间步骤,比如生成正弦波,直接跳到播放 wav 文件。一旦你明白如何做到这一点,就可以轻松生成其他各种波形(正弦波、噪声、这类波形的包络等等)。
现在你可能会问自己:如果 Pico 只能将引脚切换为高电平或低电平,我怎么能生成播放 wav 文件所需的复杂波形呢?一个真正的非方波波形由不同的电平组成,所以我需要一个 DAC!幸运的是,我们完全可以不用 DAC,仅用 Pico 的一个引脚就能做到这一切。
复杂声音生成的工作原理
我不想在这里讲太多背景知识。但你只需要知道,如果你不想生成那种只在输出的最小电平和最大电平之间交替的简单方波,你就需要有中间的阶梯,比如这样:
S0: #
S1: ####
S2: ######
S3: #######
S4: ########依此类推,其中 S0 是第一个采样,S1 是第二个采样,……
每个采样的持续时间取决于采样频率,也就是每秒钟我们改变(播放时)或采样(录制时)音频波形的次数。这意味着要播放复杂的声音,我们需要让 Pico 的引脚能够输出不同的电压。
有一个技巧可以仅用 PWM 在 Pico 上实现这一点,那就是使用一个频率非常高的方波,但为我们想要生成的不同电压设置不同的占空比。因此我们设置一个非常非常高的输出频率:
pwm.freq(100000)然后,如果我们想产生 S0 采样,就将占空比(其取值在 0 到 65535 之间)设为一个较小的值。如果想产生 S1 采样,就使用更大的值,依此类推。按顺序我们可能想这样做:
pwm.duty_u16(3000) # S0
pwm.duty_u16(12000) # S1
pwm.duty_u16(18000) # S2
pwm.duty_u16(21000) # S3
pwm.duty_u16(24000) # S4占空比是指引脚置为 1 的时间与置为 0 的时间之比。占空比为 65535 意味着引脚 100% 的时间都为高电平。0% 则意味着始终为低电平。所有这些,都在保持所设定的交替频率不变的情况下进行。所以如果我们像用示波器那样放大观察,就可以看到在生成 S2 和 S3 采样期间发生了什么:
S2:
######################
#
#
#
#
######################
#
#
#
#而 S3 则会是这样:
######################
######################
#
#
#
######################
######################
#
#
#引脚以相同的频率上下翻转,但在 S3 的情况下它保持高电平的时间更长。这会产生更高的平均电压。这让我们能够近似模拟出我们的波形。
转换并播放 WAV 文件
为了播放 wav 文件,我们必须将其转换为一种易于用 MicroPython 读取的原始格式。我从 SoundCloud 下载了一个内容为“Oh no!”的 wav 文件。所以我的转换命令如下所示:
ffmpeg -i ohno.wav -ar 24000 -acodec pcm_u8 -f u8 output.raw请注意,我们将文件转换成了 8 位音频(每个采样 256 种不同的输出电平)。反正我们的 PWM 技巧也不会那么精确地近似不同的电平,而且我们资源有限。你也可以尝试 16 位,但我用这种方式已经得到了不错的效果。
然后,通过 mpremote 将 output.raw 文件上传到设备:
mpremote cp output.raw :现在编写一个名为“play.py”或你喜欢的任意名字的文件,内容如下:
from machine import Pin, PWM
pwm = PWM(Pin(1))
pwm.freq(100000)
f = open("output.raw","rb")
buf = bytearray(4096)
while f.readinto(buf) > 0:
for sample in buf:
pwm.duty_u16(sample<<8)
x=1
x=1
x=1
x=1
x=1
f.close()我们在这里所做的,只是每次迭代读取 4096 个采样,然后根据采样值依次设置不同的 PWM 占空比来“播放”它。问题在于,在我们的 PCM 文件中每秒有 24000 个采样(见 ffmpeg 命令行)。我们怎么能确定它与 MicroPython 的速度相匹配呢?嗯,实际上它并不能完美匹配,所以我添加了“x=1”语句来稍微延迟一下,以大致匹配听起来正确的音调。
哦,如果你在好奇 sample<<8 是怎么回事,这只是将 8 位采样重新缩放到设置 PWM 占空比所需的完整 16 位精度。
所有这些做法的缺点是,它会在播放期间让你的程序处于忙碌状态。我还没有测试过,但 MicroPython 支持多线程,所以让一个线程来播放音频可能是可行的方案。
加分项:正弦波声音生成
# Sin wave
wave=[]
wave_samples = 40
pwm.freq(100000)
for i in range(wave_samples):
x = i/wave_samples*3.14*2
dc = int((1+math.sin(x))*65000)
wave.append(dc)
print(wave)
for i in range(1000):
for dc in wave: pwm.duty_u16(dc)随机一篇博客