易電子鋼琴設(shè)計(jì)與實(shí)現(xiàn):從硬件選型到復(fù)音生成)
1. 項(xiàng)目緣起為什么用樹莓派Pico做一臺(tái)鋼琴幾年前我還在為一個(gè)創(chuàng)客項(xiàng)目尋找一塊合適的微控制器既要便宜、易上手又要能處理一些簡(jiǎn)單的音頻任務(wù)。當(dāng)時(shí)市面上常見的Arduino Uno在模擬信號(hào)處理和內(nèi)存上有些捉襟見肘而更強(qiáng)大的開發(fā)板又超出了預(yù)算。直到樹莓派基金會(huì)推出了那款售價(jià)僅4美元的RP2040芯片和基于它的Pico開發(fā)板局面才豁然開朗。它雙核的Cortex-M0處理器、264KB的SRAM以及那個(gè)可編程的PIO可編程輸入輸出子系統(tǒng)讓我立刻意識(shí)到用它來做一個(gè)能真正發(fā)聲的電子樂器是完全可行的。這個(gè)“Piano using pi pico”項(xiàng)目的核心就是利用這塊小小的開發(fā)板從頭搭建一個(gè)簡(jiǎn)易的、可交互的數(shù)字鋼琴。它不僅僅是讓蜂鳴器嗶嗶叫而是要實(shí)現(xiàn)復(fù)音雖然有限、可調(diào)節(jié)的音色并且通過物理按鍵提供真實(shí)的彈奏觸感。對(duì)于硬件愛好者、音樂科技初學(xué)者或者只是想給孩子做一個(gè)有趣STEM玩具的朋友來說這個(gè)項(xiàng)目融合了嵌入式編程、基礎(chǔ)電路設(shè)計(jì)和數(shù)字音頻原理是一個(gè)絕佳的練手機(jī)會(huì)。你會(huì)發(fā)現(xiàn)用幾十塊錢的成本和一下午的時(shí)間就能創(chuàng)造出一件能與你互動(dòng)的“樂器”這種成就感是單純的代碼練習(xí)無(wú)法比擬的。2. 核心硬件選型與電路設(shè)計(jì)思路一臺(tái)電子鋼琴無(wú)論多么簡(jiǎn)易都離不開三個(gè)核心部分處理大腦、發(fā)聲單元和輸入界面。我們的選型全部圍繞樹莓派Pico展開。2.1 主控樹莓派Pico的獨(dú)特優(yōu)勢(shì)為什么一定是Pico而不是其他更常見的開發(fā)板關(guān)鍵在于RP2040芯片的兩個(gè)特性PIO和充足的RAM。首先可編程輸入輸出PIO是RP2040的“秘密武器”。你可以把它理解為芯片內(nèi)部一個(gè)完全獨(dú)立的小型協(xié)處理器專門用來處理高速、精確的時(shí)序相關(guān)IO操作。對(duì)于音頻生成我們需要產(chǎn)生頻率極其穩(wěn)定的方波、PWM波或更復(fù)雜的波形。如果使用主CPU通過軟件循環(huán)來翻轉(zhuǎn)GPIO引腳產(chǎn)生波形很容易被中斷或其他任務(wù)干擾導(dǎo)致音調(diào)不準(zhǔn)、有雜音。而PIO程序是硬件級(jí)執(zhí)行的不受主程序影響可以確保輸出波形的頻率和占空比分毫不差這對(duì)于樂器的音準(zhǔn)至關(guān)重要。其次264KB的SRAM在微控制器領(lǐng)域算得上是“大內(nèi)存”了。這允許我們?cè)诔绦蛑蓄A(yù)存或?qū)崟r(shí)計(jì)算一些音頻樣本數(shù)據(jù)例如一個(gè)周期的正弦波、三角波數(shù)據(jù)表用于生成比簡(jiǎn)單方波更悅耳的音色。相比之下Arduino Uno只有2KB的RAM做多音色復(fù)音就非常困難。2.2 發(fā)聲方案從無(wú)源蜂鳴器到音頻放大發(fā)聲單元的選擇決定了這臺(tái)鋼琴的“嗓音”。這里有幾種性價(jià)比方案無(wú)源蜂鳴器/壓電陶瓷片這是最簡(jiǎn)單、最廉價(jià)的方案成本僅需幾毛錢。它內(nèi)部沒有振蕩電路需要外部輸入特定頻率的方波信號(hào)才能發(fā)聲。我們可以直接用Pico的GPIO口通過PIO輸出對(duì)應(yīng)音高的方波來驅(qū)動(dòng)它。優(yōu)點(diǎn)是電路極其簡(jiǎn)單缺點(diǎn)是音量小、音色單一只有方波且聲音比較刺耳。有源蜂鳴器不推薦。它有內(nèi)置振蕩電路一通電就以其固定頻率鳴響無(wú)法通過程序控制音高不適合做樂器。小型揚(yáng)聲器 放大電路這是追求更好音質(zhì)的方案。Pico的GPIO輸出電流和電壓有限直接驅(qū)動(dòng)揚(yáng)聲器聲音會(huì)非常微弱。我們需要一個(gè)簡(jiǎn)單的放大電路。最經(jīng)典的是使用一個(gè)NPN三極管如8050或一個(gè)專用的音頻功放芯片如PAM8403這類D類功放模塊。后者效率高、音質(zhì)相對(duì)更好且模塊化接線方便。通過PWM或DAC數(shù)模轉(zhuǎn)換輸出模擬音頻信號(hào)給功放就能驅(qū)動(dòng)揚(yáng)聲器發(fā)出更飽滿的聲音。注意Pico本身沒有真正的模擬DAC引腳但我們可以利用其PWM功能通過低通濾波器來模擬模擬電壓輸出或者直接使用PWM驅(qū)動(dòng)揚(yáng)聲器音質(zhì)會(huì)差一些。對(duì)于入門項(xiàng)目直接使用PWM驅(qū)動(dòng)無(wú)源蜂鳴器或連接功放模塊的PWM輸入是平衡復(fù)雜度和效果的好方法。我的方案選擇為了兼顧可玩性和效果我建議采用“無(wú)源蜂鳴器初步驗(yàn)證 PAM8403功放模塊驅(qū)動(dòng)小揚(yáng)聲器”的路徑。先用蜂鳴器快速驗(yàn)證代碼和按鍵邏輯成功后再接入功放和揚(yáng)聲器提升體驗(yàn)。PAM8403模塊僅需幾元錢通過排針與Pico連接非常方便。2.3 輸入界面矩陣鍵盤與按鍵消抖我們需要至少一個(gè)八度12個(gè)半音鍵7個(gè)白鍵5個(gè)黑鍵的鍵盤。如果每個(gè)按鍵單獨(dú)占用一個(gè)GPIO口Pico的26個(gè)GPIO口很快就會(huì)用完。因此采用矩陣鍵盤是標(biāo)準(zhǔn)做法。將按鍵排列成行和列通過掃描行列來檢測(cè)哪個(gè)鍵被按下。一個(gè)4x4的矩陣鍵盤只需要8個(gè)GPIO口4行4列就能管理16個(gè)按鍵足夠我們做一個(gè)八度再加幾個(gè)控制鍵如音色切換、八度升降。硬件消抖是關(guān)鍵。機(jī)械按鍵在閉合和斷開的瞬間會(huì)產(chǎn)生物理抖動(dòng)導(dǎo)致單片機(jī)在幾毫秒內(nèi)檢測(cè)到多次通斷從而誤判為多次按鍵。解決方法有硬件消抖在每個(gè)按鍵兩端并聯(lián)一個(gè)0.1uF左右的電容成本低效果不錯(cuò)。軟件消抖在代碼中檢測(cè)到按鍵按下后延時(shí)10-20毫秒再次檢測(cè)如果狀態(tài)依然為按下則確認(rèn)為有效按鍵。這是最常用的方法。在布線時(shí)將矩陣鍵盤的行或列通過電阻如10kΩ上拉到VCC3.3V然后將檢測(cè)引腳設(shè)置為內(nèi)部上拉輸入模式是穩(wěn)定可靠的配置。3. 固件開發(fā)音調(diào)生成與復(fù)音實(shí)現(xiàn)這是項(xiàng)目的軟件核心我們將使用MicroPython進(jìn)行開發(fā)因?yàn)樗换バ詮?qiáng)庫(kù)豐富適合快速原型開發(fā)。3.1 開發(fā)環(huán)境搭建與基礎(chǔ)庫(kù)首先將最新的MicroPython固件刷入Pico。然后我們需要一個(gè)代碼編輯器Thonny是一個(gè)對(duì)新手非常友好的選擇它集成了REPL交互式環(huán)境和文件管理可以直接在Pico上運(yùn)行和保存腳本。核心的音頻生成我們將用到兩個(gè)MicroPython模塊machine.PWM用于生成特定頻率和占空比的PWM波驅(qū)動(dòng)蜂鳴器或作為功放的輸入信號(hào)。rp2這是RP2040特有的模塊用于編寫和加載PIO程序?qū)崿F(xiàn)高精度、不占CPU的波形輸出。3.2 單音生成原理與代碼實(shí)現(xiàn)一個(gè)音調(diào)對(duì)應(yīng)一個(gè)特定的頻率。例如中央CC4的頻率是261.63 Hz。我們要做的就是讓一個(gè)GPIO引腳以這個(gè)頻率輸出方波。使用PWM生成方波from machine import Pin, PWM import time # 初始化PWM連接到蜂鳴器或功放輸入的引腳 buzzer PWM(Pin(15)) def play_tone(frequency, duration_ms): # 設(shè)置頻率和占空比50%的占空比是標(biāo)準(zhǔn)的方波 buzzer.freq(frequency) buzzer.duty_u16(32768) # 65536 / 2 32768 即50%占空比 time.sleep_ms(duration_ms) buzzer.duty_u16(0) # 停止發(fā)聲占空比為0 # 播放中央C 1秒鐘 play_tone(262, 1000)這段代碼簡(jiǎn)單明了但有一個(gè)嚴(yán)重問題time.sleep_ms()是阻塞的。在播放一個(gè)音時(shí)整個(gè)程序會(huì)卡住無(wú)法檢測(cè)其他按鍵這意味著無(wú)法實(shí)現(xiàn)同時(shí)按下兩個(gè)鍵的復(fù)音效果。3.3 使用PIO實(shí)現(xiàn)非阻塞復(fù)音生成為了實(shí)現(xiàn)復(fù)音我們必須讓音頻生成在后臺(tái)獨(dú)立運(yùn)行。這時(shí)RP2040的PIO就派上用場(chǎng)了。我們可以編寫一個(gè)PIO程序讓它自動(dòng)、不間斷地生成方波而主程序只負(fù)責(zé)告訴它“開始以XX頻率發(fā)聲”或“停止發(fā)聲”。思路是為每個(gè)可能的音調(diào)或每個(gè)發(fā)聲通道準(zhǔn)備一個(gè)PIO狀態(tài)機(jī)SM。RP2040有8個(gè)獨(dú)立的PIO狀態(tài)機(jī)兩個(gè)PIO塊各4個(gè)。我們可以用其中一個(gè)狀態(tài)機(jī)來生成聲音主程序通過向該狀態(tài)機(jī)的FIFO先入先出隊(duì)列寫入目標(biāo)頻率值來控制音高。下面是一個(gè)簡(jiǎn)化的PIO方波生成程序示例# pio_tone.py - PIO程序用于生成指定頻率的方波 import rp2 from machine import Pin rp2.asm_pio(set_initrp2.PIO.OUT_LOW, autopullTrue) def tone(): wrap_target() pull(noblock) .side(0) # 嘗試從FIFO拉取頻率值非阻塞 mov(x, osr) # 將拉取的值頻率周期數(shù)移動(dòng)到X寄存器 jmp(not_x, “skip”) # 如果X為0表示靜音跳過發(fā)聲 label(“high”) set(pins, 1) .side(1) # 設(shè)置引腳為高電平 jmp(x_dec, “high”) # 延遲X個(gè)周期 mov(x, osr) # 重新加載周期數(shù) label(“l(fā)ow”) set(pins, 0) .side(0) # 設(shè)置引腳為低電平 jmp(x_dec, “l(fā)ow”) # 延遲X個(gè)周期 wrap() label(“skip”) nop() [31] # 如果靜音執(zhí)行空操作等待 wrap()在主程序中我們初始化這個(gè)PIO狀態(tài)機(jī)并計(jì)算每個(gè)音高對(duì)應(yīng)的周期值與頻率成反比。當(dāng)檢測(cè)到按鍵按下時(shí)就將對(duì)應(yīng)頻率的周期值推入狀態(tài)機(jī)的FIFO。狀態(tài)機(jī)會(huì)自動(dòng)讀取并開始以該頻率振蕩。當(dāng)按鍵釋放時(shí)我們向FIFO推入0狀態(tài)機(jī)檢測(cè)到0就會(huì)進(jìn)入靜音循環(huán)。這樣主循環(huán)就可以持續(xù)掃描鍵盤而發(fā)聲完全由硬件PIO狀態(tài)機(jī)在后臺(tái)處理。理論上我們可以初始化多個(gè)這樣的狀態(tài)機(jī)連接到同一個(gè)或不同的引腳來實(shí)現(xiàn)真正的多復(fù)音同時(shí)發(fā)多個(gè)音。但受限于GPIO和狀態(tài)機(jī)數(shù)量通常實(shí)現(xiàn)2-4個(gè)復(fù)音是比較實(shí)際的。3.4 鍵盤掃描與事件處理我們需要一個(gè)非阻塞的主循環(huán)來持續(xù)掃描矩陣鍵盤。這里采用狀態(tài)機(jī)的思想來管理每個(gè)按鍵keys [ [‘C’, ‘C#’, ‘D’, ‘D#’], [‘E’, ‘F’, ‘F#’, ‘G’], [‘G#’, ‘A’, ‘A#’, ‘B’], [‘C5’, ‘CTRL1’, ‘CTRL2’, ‘CTRL3’] # 下一個(gè)八度的C和一些控制鍵 ] key_state {} # 記錄每個(gè)鍵當(dāng)前的狀態(tài)0釋放1按下 key_debounce {} # 記錄每個(gè)鍵的消抖時(shí)間戳 def scan_keyboard(): current_time time.ticks_ms() for row_idx, row_pin in enumerate(row_pins): # 將當(dāng)前行設(shè)置為低電平其他行設(shè)置為高電平輸入模式 row_pin.value(0) for col_idx, col_pin in enumerate(col_pins): key_name keys[row_idx][col_idx] is_pressed (col_pin.value() 0) # 列被拉低表示按鍵按下 if key_name not in key_state: key_state[key_name] 0 key_debounce[key_name] 0 # 軟件消抖邏輯 if is_pressed ! key_state[key_name]: if time.ticks_diff(current_time, key_debounce[key_name]) 20: # 消抖延時(shí)20ms key_state[key_name] is_pressed key_debounce[key_name] current_time # 觸發(fā)按鍵事件 if is_pressed: handle_key_press(key_name) else: handle_key_release(key_name) row_pin.value(1) # 掃描完一行恢復(fù)為高電平 def handle_key_press(key): if key in note_freq_map: # note_freq_map是音名到頻率的字典 freq note_freq_map[key] # 將頻率對(duì)應(yīng)的周期值推入PIO狀態(tài)機(jī)的FIFO觸發(fā)發(fā)聲 tone_sm.put(cycle_count_from_freq(freq)) def handle_key_release(key): # 當(dāng)按鍵釋放我們可以選擇停止該音推入0 # 或者在一個(gè)簡(jiǎn)單的復(fù)音系統(tǒng)中可能不立即停止而是交給音源包絡(luò)處理。 # 這里演示立即停止 tone_sm.put(0)主循環(huán)就是不斷地調(diào)用scan_keyboard()并可以同時(shí)處理其他任務(wù)如讀取旋鈕用于調(diào)節(jié)音量或音色、更新顯示屏等。4. 從方波到更豐富的音色如果只是方波那這臺(tái)“鋼琴”的聲音會(huì)像老式電子游戲機(jī)一樣單調(diào)。我們可以通過一些方法改善音色。4.1 波表合成入門波表合成的思想很簡(jiǎn)單預(yù)計(jì)算一個(gè)周期內(nèi)某種波形如正弦波、三角波、鋸波的幅度樣本存儲(chǔ)在一個(gè)數(shù)組中波表。播放時(shí)PIO或PWM按照當(dāng)前音高所需的速率循環(huán)遍歷這個(gè)波表并將樣本值輸出。例如我們創(chuàng)建一個(gè)包含256個(gè)樣本的正弦波表import math SAMPLE_COUNT 256 sine_table [int((math.sin(2 * math.pi * i / SAMPLE_COUNT) 1) * 32767 / 2) for i in range(SAMPLE_COUNT)]這個(gè)列表中的值范圍在0到32767之間對(duì)應(yīng)PWM的duty_u16范圍0-65535。播放時(shí)我們需要一個(gè)“相位累加器”。每次定時(shí)器中斷或在一個(gè)快速循環(huán)中相位累加器增加一個(gè)“相位增量”該增量由目標(biāo)頻率決定。用相位累加器的高位例如高8位作為索引從sine_table中查找對(duì)應(yīng)的PWM占空比值并輸出。相位增量越大遍歷波表的速度越快產(chǎn)生的音高就越高。在RP2040上我們可以用PIO來高效地完成這件事一個(gè)狀態(tài)機(jī)負(fù)責(zé)根據(jù)頻率計(jì)算相位并讀取波表另一個(gè)狀態(tài)機(jī)負(fù)責(zé)以固定速率例如44.1kHz將樣本值輸出到PWM的占空比寄存器。這需要更復(fù)雜的PIO編程但能產(chǎn)生質(zhì)量高得多的聲音。4.2 利用PWM模擬DAC與低通濾波即使我們生成了漂亮的波形樣本如果直接用PWM輸出由于PWM是高頻數(shù)字開關(guān)會(huì)帶來大量高頻諧波噪聲聲音聽起來很“臟”。解決方法是在PWM輸出引腳和功放輸入之間加入一個(gè)低通濾波器。一個(gè)簡(jiǎn)單的一階RC低通濾波器一個(gè)電阻串聯(lián)一個(gè)電容對(duì)地就足夠。它的截止頻率計(jì)算公式是 f_c 1 / (2πRC)。我們需要讓截止頻率略高于我們想聽到的最高音頻頻率比如20kHz以保留音質(zhì)同時(shí)又能有效濾除PWM本身的高頻開關(guān)噪聲通常幾十到幾百kHz。例如選擇R1kΩ C0.01uF截止頻率約為16kHz這是一個(gè)合理的起點(diǎn)。4.3 動(dòng)態(tài)控制音量與包絡(luò)真實(shí)的樂器聲音不是一按下鍵就立刻以最大音量響起松開就立刻消失。它有起音Attack、衰減Decay、持續(xù)Sustain、釋放Release四個(gè)階段合稱ADSR包絡(luò)。我們可以在軟件中模擬一個(gè)簡(jiǎn)單的ADSR包絡(luò)。為每個(gè)正在發(fā)聲的音符維護(hù)一個(gè)“音量”狀態(tài)0.0到1.0。在handle_key_press時(shí)音量不是立刻跳到1.0而是按照“起音時(shí)間”線性或指數(shù)增長(zhǎng)到1.0在按鍵持續(xù)期間音量先“衰減”到一個(gè)較低的“持續(xù)”電平當(dāng)handle_key_release時(shí)音量再按照“釋放時(shí)間”衰減到0。在每次更新PWM占空比時(shí)將波表樣本值乘以當(dāng)前的音量值就能實(shí)現(xiàn)動(dòng)態(tài)的音量變化讓聲音更自然。5. 系統(tǒng)集成、調(diào)試與優(yōu)化心得將硬件焊接或插接好代碼編寫完畢后真正的挑戰(zhàn)才剛剛開始。以下是我在集成調(diào)試過程中積累的一些關(guān)鍵心得。5.1 供電與噪聲處理數(shù)字電路尤其是Pico和功放模塊是噪聲大戶。如果使用同一個(gè)電源比如一個(gè)USB口同時(shí)為Pico和功放供電很可能會(huì)在音頻中引入明顯的“嗡嗡”底噪或數(shù)字切換噪聲。解決方案星型接地將所有模塊的GND地線都接到電源的一個(gè)點(diǎn)上避免形成地線環(huán)路。電源去耦在Pico的VBUS和GND引腳之間盡可能靠近引腳的地方焊接一個(gè)10uF的電解電容和一個(gè)0.1uF的陶瓷電容并聯(lián)。這能為芯片瞬間的大電流需求提供本地儲(chǔ)能并濾除高頻噪聲。獨(dú)立供電如果條件允許用兩個(gè)獨(dú)立的USB電源分別給Pico和功放模塊供電并確保它們的地線是共接的。這是消除共模噪聲最有效的方法。信號(hào)隔離在Pico的音頻輸出引腳和功放輸入之間串聯(lián)一個(gè)100Ω左右的小電阻有時(shí)能減弱數(shù)字噪聲的耦合。5.2 復(fù)音沖突與“偷音”策略當(dāng)我們的復(fù)音數(shù)有限比如只用了2個(gè)PIO狀態(tài)機(jī)而用戶同時(shí)按下了3個(gè)鍵時(shí)就需要一個(gè)“音符分配”策略。最簡(jiǎn)單的策略是“先到先得”后來的按鍵無(wú)法發(fā)聲。更友好的策略是“偷音”即當(dāng)所有發(fā)音通道都被占用時(shí)新按下的鍵會(huì)搶占最早發(fā)聲的那個(gè)通道。實(shí)現(xiàn)“偷音”需要一個(gè)活躍音符列表。每個(gè)通道記錄它正在播放的音符和開始時(shí)間。當(dāng)新按鍵到來且無(wú)空閑通道時(shí)就找出開始時(shí)間最早的那個(gè)通道將其停止并將新音符分配給它。5.3 性能優(yōu)化與內(nèi)存管理MicroPython雖然方便但效率不如C/C。如果發(fā)現(xiàn)鍵盤掃描有延遲或者復(fù)音數(shù)增加時(shí)出現(xiàn)爆音可以考慮以下優(yōu)化將關(guān)鍵函數(shù)用Viper或Native裝飾器編譯MicroPython支持將部分函數(shù)編譯成更高效的機(jī)器碼。使用array模塊代替list存儲(chǔ)波表array存儲(chǔ)數(shù)字更緊湊訪問更快。避免在音頻生成循環(huán)中動(dòng)態(tài)分配內(nèi)存如創(chuàng)建新列表、字符串格式化這會(huì)導(dǎo)致垃圾回收引起音頻中斷。如果追求極致性能最終可以考慮使用C SDK開發(fā)。RP2040的C SDK可以完全發(fā)揮PIO和硬件的能力實(shí)現(xiàn)更復(fù)雜、更流暢的音頻合成。5.4 擴(kuò)展玩法MIDI接口與錄音回放讓這個(gè)小鋼琴不再孤單。我們可以通過Pico的UART接口添加一個(gè)MIDI輸入例如通過5針DIN口或TRS接口讓它能接收來自其他MIDI鍵盤或電腦的音符信號(hào)這樣Pico就變成了一個(gè)硬件音源。更進(jìn)一步可以利用Pico的Flash存儲(chǔ)空間2MB實(shí)現(xiàn)簡(jiǎn)單的錄音和回放功能。將PIO狀態(tài)機(jī)輸出的音頻樣本經(jīng)過ADSR包絡(luò)處理后的以較低的采樣率如8kHz存入一個(gè)環(huán)形緩沖區(qū)并定期寫入到Flash的文件系統(tǒng)中?;胤艜r(shí)再?gòu)奈募凶x取數(shù)據(jù)通過另一個(gè)PIO狀態(tài)機(jī)播放出來。這雖然音質(zhì)有限但作為一個(gè)演示功能非常有趣。完成這個(gè)項(xiàng)目后你得到的不僅僅是一個(gè)能響的玩具。你深入理解了從機(jī)械振動(dòng)到數(shù)字音頻的鏈條掌握了微控制器中處理實(shí)時(shí)任務(wù)和非阻塞編程的思想還實(shí)踐了硬件設(shè)計(jì)中關(guān)于噪聲和穩(wěn)定性的重要考量。下次當(dāng)你聽到任何電子設(shè)備發(fā)出聲音時(shí)你大概都能在腦海里勾勒出它內(nèi)部的代碼和電流是如何協(xié)作的——這種透過現(xiàn)象看本質(zhì)的能力才是創(chuàng)客項(xiàng)目帶給我們的最大財(cái)富。如果想讓聲音更好聽下一步可以嘗試接入一個(gè)更好的DAC模塊或者用Pico驅(qū)動(dòng)一個(gè)開源的聲音合成芯片那又將是一片新的天地。