From 33dc5ff5cb412d7a0df82f8516b1ed847f2a7011 Mon Sep 17 00:00:00 2001 From: Me Here Date: Mon, 1 Jun 2026 09:15:26 -0500 Subject: [PATCH] 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) --- hardware/PROTOTYPE.md | 70 ++++++++++++++++++++++++ pico-wm8960/README.md | 16 ++++++ pico-wm8960/code.py | 123 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 hardware/PROTOTYPE.md create mode 100644 pico-wm8960/README.md create mode 100644 pico-wm8960/code.py diff --git a/hardware/PROTOTYPE.md b/hardware/PROTOTYPE.md new file mode 100644 index 0000000..13cf95b --- /dev/null +++ b/hardware/PROTOTYPE.md @@ -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 (~$40–80) +| 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 | 10–50 | +| **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 | 30–40 | +| 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 M1–M4 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. diff --git a/pico-wm8960/README.md b/pico-wm8960/README.md new file mode 100644 index 0000000..0809d9e --- /dev/null +++ b/pico-wm8960/README.md @@ -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 M1–M5 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. diff --git a/pico-wm8960/code.py b/pico-wm8960/code.py new file mode 100644 index 0000000..3ad2874 --- /dev/null +++ b/pico-wm8960/code.py @@ -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()