Playing audio files in a Pi Pico without a DAC

Salvatore Sanfilippo

在 Pi Pico 上不用 DAC 播放音訊檔

原文由 Salvatore Sanfilippo 發布,訂閱此部落格

Raspberry Pico 突然成了我做嵌入式開發時的首選晶片。它做工精良、硬體耐用,還有一大堆看得出來是用心與熱情設計出來的功能(用來驅動 GPIO 的狀態機就是個殺手級功能!)。它主要的弱點——缺乏連線能力——也已經隨著 W 版本問世而解決。資料手冊寫得非常棒,把晶片的每個面向都記載得清清楚楚。而且它對 MicroPython 的支援很好(我現在很常用),C SDK 的環境也還算堪用,儘管就像當今流行的那樣,充滿了無謂的複雜度:一個會再去產生 Makefile 的 cmake 建置系統、一堆用來定義東定義西的檔案(用到的函式庫、除錯輸出……),整體來說,為了編譯給小裝置用的迷你程式,這一切根本是殺雞用牛刀。不,說得更難聽一點:為了在一塊功能固定的硬體(除了 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 有支援 threading,所以用一個執行緒來播放音訊或許是個可行的做法。

額外加分:正弦波聲音產生

# 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 進行翻譯

留言