metronome/pico-wm8960/code.py
Me Here 33dc5ff5cb PM_K-1 Phase 1: bench prototype firmware + doc (Pico 2 + WM8960, transformer-isolated XLR)
Per the approved audio re-architecture (prototype-first): prove the click -> codec ->
transformer-isolated XLR chain on bought boards before any custom PCB, keeping the RP2350
firmware.

- pico-wm8960/code.py: CircuitPython bring-up for Pico 2 + SparkFun WM8960 breakout.
  Synthesizes the click (familiar piezo pitches) -> I2S -> WM8960 -> HP/speaker; line-in
  monitor hook; stereo/pan ready for polymeter spatialization. Uses the proven adafruit_wm8960
  driver (no hand-rolled register driver).
- hardware/PROTOTYPE.md: shopping list, wiring, and bench milestones M1-M5 (M4 = the no-buzz
  ground-loop test = acceptance gate).

Key findings baked in:
- Buzz was a ground loop; cure = transformer galvanic isolation, NOT +/-15 V (which was only
  studio headroom and is dropped).
- WM8960 needs MCLK (CircuitPython I2SOut doesn't emit it); the SparkFun breakout's onboard
  24 MHz oscillator supplies it -> resolves Risk R1 with zero extra parts.

Track-format conformance (node tests/run.mjs) stays green; DSL untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:15:26 -05:00

123 lines
5.8 KiB
Python

# PM_K-1 prototype bring-up: Raspberry Pi Pico 2 (RP2350) + SparkFun WM8960 codec breakout
# ---------------------------------------------------------------------------------------
# This is the PHASE-1 bench prototype from the approved plan (deep-honking-lemon): prove the
# click -> codec -> (later) transformer -> XLR chain on bought hardware before any custom PCB.
# It does NOT replace the form-factor firmware; it is a focused audio-chain validator.
#
# What this proves, in order (see hardware/PROTOTYPE.md for the milestones):
# M1 click synthesized on the Pico, sent over I2S to the WM8960, heard on headphones/speaker.
# M2 line input (your guitar) monitored alongside the click (see LINE-IN section below).
# M3/M4 are bench/measurement steps on the analog output (transformer + XLR), not firmware.
#
# CLOCKING (Risk R1, resolved): the WM8960 needs an MCLK to clock its ADC/DAC. CircuitPython's
# I2SOut emits only BCLK/WS/DATA -- NO MCLK. The SparkFun WM8960 breakout carries its own 24 MHz
# oscillator, which is why the adafruit_wm8960 driver "assumes 24 MHz MCLK" and runs on a plain
# Pico. If you use a bare WM8960 (e.g. a Pi HAT that expects host MCLK), feed it a 24 MHz / 12.288
# MHz clock yourself -- this code will otherwise get no audio.
#
# DEPENDENCY: the adafruit_wm8960 library (Adafruit_CircuitPython_WM8960). Install with
# circup install adafruit_wm8960
# or copy the adafruit_wm8960/ folder into /lib on the CIRCUITPY drive.
import time
import math
import array
import board
import busio
import audiobusio
import audiocore
import audiomixer
from adafruit_wm8960 import WM8960, Input
# ---- pin map (Pico 2) --------------------------------------------------------------------
# I2S: CircuitPython requires word_select == bit_clock + 1 (consecutive GPIOs).
P_I2S_BCLK = board.GP0 # -> WM8960 BCLK
P_I2S_WS = board.GP1 # -> WM8960 DACLRC (word/LR clock); tie ADCLRC to it too
P_I2S_DATA = board.GP2 # -> WM8960 DACDAT (Pico is the I2S transmitter)
P_I2C_SDA = board.GP4 # -> WM8960 SDA (Qwiic)
P_I2C_SCL = board.GP5 # -> WM8960 SCL (Qwiic)
# ---- audio config ------------------------------------------------------------------------
SAMPLE_RATE = 44100 # matches the driver's tested default + the breakout's 24 MHz osc
BITS = 16
# Click voices: (label, frequency Hz) -- pitches mirror the existing piezo firmware
# so the feel is familiar (accent / normal / sub-division).
CLICK_FREQ = {"accent": 2300, "normal": 1600, "sub": 1050}
CLICK_MS = 28 # burst length; the piezo firmware silences after 22 ms
def make_click(freq, ms=CLICK_MS, rate=SAMPLE_RATE, vol=0.6, pan=0.0):
"""Synthesize one decaying sine 'tick' as a stereo signed-16 buffer.
pan: -1.0 = hard left, 0 = center, +1.0 = hard right. This is the hook for
polymeter spatialization -- give each rhythmic layer its own pan later.
"""
n = int(rate * ms / 1000)
buf = array.array("h", bytes(4 * n)) # 4 bytes/frame => n stereo frames
gl = vol * (1.0 - max(0.0, pan)) # simple constant-ish L/R gain
gr = vol * (1.0 + min(0.0, pan))
two_pi_f = 2.0 * math.pi * freq
for i in range(n):
env = math.exp(-5.0 * i / n) # exponential decay
s = env * math.sin(two_pi_f * i / rate)
buf[2 * i] = int(32767 * gl * s) # L
buf[2 * i + 1] = int(32767 * gr * s) # R
return audiocore.RawSample(buf, channel_count=2, sample_rate=rate)
def main():
# ---- codec ---------------------------------------------------------------------------
i2c = busio.I2C(P_I2C_SCL, P_I2C_SDA, frequency=400_000)
codec = WM8960(i2c, sample_rate=SAMPLE_RATE, bit_depth=BITS)
# DAC path (the click): output mixer -> headphone + class-D speaker.
codec.volume = 0.9 # output-mixer level feeding the amps
codec.headphone = 0.7 # 0.0 powers the HP amp off
codec.speaker = 0.8 # 0.0 powers the class-D speaker off
# ---- LINE-IN monitor (M2) ------------------------------------------------------------
# Route the line input to the ADC. For ZERO-LATENCY guitar monitoring we want the codec's
# analog input->output bypass (not a round-trip through the Pico). The high-level driver
# routes input->ADC; for the analog bypass to the output mixer use adafruit_wm8960.advanced
# on the bench and confirm by ear. Left here as the M2 hook:
codec.input = Input.LINE2 # guitar / line on LINE2 (swap to LINE3 per your wiring)
codec.gain = 0.5
# TODO(bench, M2): enable analog bypass (advanced API) so the guitar is heard with the
# click at ~0 ms latency; confirm level + that it mixes with the click.
# ---- I2S transmitter + mixer ---------------------------------------------------------
i2s = audiobusio.I2SOut(P_I2S_BCLK, P_I2S_WS, P_I2S_DATA)
mixer = audiomixer.Mixer(
voice_count=4, # several voices so overlapping click tails don't cut off
sample_rate=SAMPLE_RATE,
channel_count=2,
bits_per_sample=BITS,
samples_signed=True,
)
i2s.play(mixer)
samples = {k: make_click(f) for k, f in CLICK_FREQ.items()}
# ---- M1: a plain steady click so we can validate the whole chain -------------------
# (The polymetric track engine drops in here later; for bench bring-up a fixed tempo with
# an accent every 4 is all we need to hear the chain and run the no-buzz test.)
bpm = 120
interval = 60.0 / bpm
beat = 0
voice = 0
next_t = time.monotonic()
while True:
now = time.monotonic()
if now >= next_t:
level = "accent" if (beat % 4 == 0) else "normal"
mixer.voice[voice].play(samples[level], loop=False)
voice = (voice + 1) % 4
beat += 1
next_t += interval
time.sleep(0.001)
main()