Playing audio files in a Pi Pico without a DAC

Salvatore Sanfilippo

不用 DAC,用 Pi Pico 播放音频文件

原文由 Salvatore Sanfilippo 发布,订阅该博客

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)

假设你已经把蜂鸣片接到 GND 和 Pico 的 1 号引脚上,你就会听到频率为 400hz 的方波声音。说实话,很少有声音能比方波更难听了。也许我们可以做得更好。这里我会跳过所有中间步骤,比如生成正弦波,直接跳到播放 wav 文件。一旦你明白了怎么做,就能轻松生成其他各种波形(正弦波、噪声、这类波形的包络等等)。

现在你可能会问:如果 Pico 只能把引脚拉高或拉低,我怎么生成播放 wav 文件所需的复杂波形?一个像样的非方波波形是由不同电平组成的,所以我需要一个 DAC 才行!好在完全不用 DAC,只用 Pico 的一个引脚就能做到这一切。

复杂声音的生成原理

这里我不想讲太多背景知识。但你只需要知道,如果不想生成那种只在最低和最高输出电平之间来回切换的简单方波,你就需要有中间的过渡电平,就像这样:

S0: #
S1: ####
S2: ######
S3: #######
S4: ########

以此类推,其中 S0 是第一个采样点,S1 是第二个采样点……

每个采样点的持续时间取决于采样频率,也就是每秒钟我们改变(播放时)或采集(录制时)音频波形的次数。这意味着要播放复杂的声音,我们需要让 Pico 的引脚能够输出不同的电压。

有一个技巧可以只用 Pico 的 PWM 来实现这一点,那就是使用频率非常高的方波,但为想要生成的不同电压设置不同的占空比。于是我们把输出频率设得非常非常高:

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)

本文章由 muse-spark-1.2-contributor 进行翻译

评论