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:
parent
af79fe6f7f
commit
33dc5ff5cb
3 changed files with 209 additions and 0 deletions
70
hardware/PROTOTYPE.md
Normal file
70
hardware/PROTOTYPE.md
Normal 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 (~$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.
|
||||||
16
pico-wm8960/README.md
Normal file
16
pico-wm8960/README.md
Normal 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 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.
|
||||||
123
pico-wm8960/code.py
Normal file
123
pico-wm8960/code.py
Normal 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()
|
||||||
Loading…
Reference in a new issue