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>
123 lines
5.8 KiB
Python
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()
|