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>
This commit is contained in:
Me Here 2026-06-01 09:15:26 -05:00
parent af79fe6f7f
commit 33dc5ff5cb
3 changed files with 209 additions and 0 deletions

70
hardware/PROTOTYPE.md Normal file
View file

@ -0,0 +1,70 @@
# PM_K-1 — Phase-1 Bench Prototype
Prove the **click → codec → transformer-isolated XLR** chain (and the all-important *no-buzz*
test) on bought boards **before** committing any custom PCB. Keeps the RP2350 firmware. This is
Phase 1 of the approved plan (`deep-honking-lemon`). Firmware lives in `pico-wm8960/code.py`.
## Why this shape (recap)
- The brain (MCU + codec + Bluetooth) is a commodity → buy it.
- The only genuinely-custom hardware is the **Pro Out** stage (line driver → output transformer
→ XLR) + the face → build only that, later.
- The past "buzz" was a **ground loop**; the cure is **galvanic isolation** (a transformer), not
±15 V. ±15 V was only studio headroom and is gone.
## Shopping list (~$4080)
| Item | Why | ~$ |
|---|---|---|
| **Raspberry Pi Pico 2** (RP2350) | Keeps the existing RP2350 firmware/toolchain | 5 |
| **SparkFun WM8960 Audio Codec Breakout** | Stereo codec + HP amp + 1 W class-D speaker; **has an onboard 24 MHz oscillator → supplies the WM8960 MCLK** (resolves Risk R1), and the `adafruit_wm8960` driver is tested against exactly this board | 15 |
| **Output transformer** | The buzz-killer: galvanic isolation + balancing. Bench-start with a budget 1:1 600:600 Ω line transformer (Triad/Würth/Bourns); evaluate **Jensen JT-11** / Lundahl for the keeper. **Pick for full-level low-frequency handling** — a punchy click is transient-rich and small cores saturate/dull the thump | 1050 |
| **Male XLR chassis connector** (Neutrik) + optional ¼" TRS | Stadium-standard, locking, unambiguous output | 5 |
| **Audio op-amp** (OPA1612 etc.) + breadboard/protoboard, jumpers | Low-rail line driver into the transformer primary | 5 |
| *(later)* **Microchip BM83 dev board** | Bluetooth leg (A2DP sink+source) for M5 | 3040 |
| Measurement: USB audio interface + REW (free), or a phone analyzer app | Check output level + LF response (M3) | — |
## Wiring
**Pico 2 ↔ WM8960 breakout (digital):** match `pico-wm8960/code.py`.
| Pico 2 | WM8960 | Note |
|---|---|---|
| GP0 | BCLK | bit clock |
| GP1 | DACLRC (+ tie ADCLRC) | word/LR clock — must be GP(bit_clock)+1 |
| GP2 | DACDAT | Pico transmits the click |
| GP4 | SDA | Qwiic I²C |
| GP5 | SCL | Qwiic I²C |
| 3V3 / GND | 3V3 / GND | power the breakout |
**Analog out (Pro Out, breadboard):** WM8960 line/HP out → op-amp line driver (low rail) →
**transformer primary**; transformer **secondary** → XLR pin 2 (hot) / pin 3 (cold) / pin 1
(shield, tied to chassis). Optionally also a ¼" TRS off the same secondary (tip/ring/sleeve).
The transformer's secondary floats — that floating, isolated output is what cannot form a ground
loop.
## Firmware
1. Flash **CircuitPython** (9.x+) onto the Pico 2.
2. `circup install adafruit_wm8960` (or copy `adafruit_wm8960/` into `CIRCUITPY/lib`).
3. Copy `pico-wm8960/code.py` to `CIRCUITPY/code.py`. It boots a steady 120 BPM click for M1.
## Bench validation milestones
- **M1 — click out:** the synthesized click plays cleanly via the WM8960 on headphones / speaker.
- **M2 — mix:** your guitar on the line input is heard *with* the click (enable the codec's
analog input→output bypass for ~0 ms monitor latency — see the M2 hook in `code.py`).
- **M3 — pro output:** the transformer-isolated XLR drives a real console/interface at usable
level, and **the click keeps its punch** — measure the low-frequency response through the
transformer (this is where a too-small transformer shows up as a thin, gutless click).
- **M4 — the acid test (NO BUZZ):** power the rig from a **mains** USB charger **and** connect the
XLR to a **mains-powered** console/interface. Confirm **no hum**. A/B against a non-isolated
output (tap before the transformer) to prove the transformer is what kills the loop. **This is
the acceptance gate for the whole approach.**
- **M5 (later):** add the BM83 — phone audio in (A2DP sink) mixes with the click; click out to a
Bluetooth speaker (A2DP source).
## Then → Phase 2 (only if M1M4 pass)
Design the **Pro Out module** PCB (op-amp → transformer → XLR) and optionally a custom RP2350 +
WM8960 brain, using the existing SKiDL designs as reference. Not before the bench proves it.
## Open risks to watch
- **Transformer LF/level** (M3) — the "rock the house" punch lives or dies here.
- **WM8960 MCLK** — relying on the SparkFun breakout's onboard 24 MHz osc; a bare WM8960 needs
you to supply MCLK.
- **CircuitPython I²S/mixer throughput** on the Pico 2 — if it can't keep up at 44.1 k, fall back
to the C/Rust path.

16
pico-wm8960/README.md Normal file
View file

@ -0,0 +1,16 @@
# pico-wm8960 — Phase-1 audio-chain bench prototype
A focused bring-up for **Raspberry Pi Pico 2 (RP2350) + SparkFun WM8960 codec breakout** that
validates the new audio path (click → I²S codec → transformer-isolated XLR) before any custom
PCB. **Not** a form-factor firmware — it's the audio-chain validator from the approved plan.
- **Hardware, wiring, shopping list, and the M1M5 bench milestones:** see
[`../hardware/PROTOTYPE.md`](../hardware/PROTOTYPE.md).
- **Firmware:** [`code.py`](code.py) — CircuitPython 9.x+.
- **Dependency:** the `adafruit_wm8960` library (`circup install adafruit_wm8960`, or copy
`adafruit_wm8960/` into `CIRCUITPY/lib`). The SparkFun breakout's onboard 24 MHz oscillator
supplies the WM8960 MCLK, so no host master clock is needed.
`code.py` boots a steady 120 BPM click (accent every 4) so you can hear the chain and run the
**no-buzz** test (M4). The polymetric track engine and Bluetooth (BM83) drop in once the chain
is proven.

123
pico-wm8960/code.py Normal file
View file

@ -0,0 +1,123 @@
# 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()