A guitar pedal: a thick octave-fuzz feeding a dynamics-driven envelope band-pass ("Q-Tron") filter. Designed and voiced entirely in ngspice, validated against real-part models, ready for KiCad.
Controls (4 knobs + 2 footswitches):
- Volume — output level
- Anger — fuzz amount / sustain (Big-Muff-style)
- Squeal — up-octave level: CCW = none, ~noon = "ring on top", CW = octave-dominant blast
- Quack — envelope band-pass intensity (ganged sweep depth + resonance Q)
- Master true-bypass footswitch, and a dedicated Squeal (octave) footswitch (kick the octave in/out, Hoof-Reaper style)
All knobs CCW = that effect off.
IN ─► Input buffer ─► SPLIT ─┬─ AUDIO: Fuzz(Anger) ─┬─► BP envelope filter(Quack) ──┐
│ └─► Octave-up(Squeal) ─► fixed HP┤─►[Volume]─► OUT
└─ CONTROL: Envelope follower (raw) ─► Iabc (cutoff) ─────┘
Key voicing decisions (each one earned the hard way — see git log / memory):
- Octave AFTER the fuzz (Octavia / Fender-Blender pattern): rectifying the distorted signal yields a prominent ring. Octave before the fuzz buried it ~30 dB under the fuzz's odd harmonics.
- Body and octave on separate filter paths ("hybrid" route): the fuzz body goes through the band-pass envelope filter (it quacks); the octave goes through a fixed high-pass (stays clear/bright instead of being swept under). A single shared filter ("mixed") muffled the octave.
- Envelope tapped from the RAW split (pre-fuzz) so the filter tracks picking dynamics — fuzz compresses dynamics away.
- Single 9 V supply, op-amp-buffered 4.5 V virtual ground (
vref) — a passive divider sags under the high-gain fuzz. Every stage input is AC-coupled. - Fuzz = 3-stage cascaded Big-Muff-style soft-clipper with asymmetric stages for
warmth (
fuzz_muff.cir, default). A single-stage hard clipper (fuzz.cir, DOD-250 family) is kept selectable via themuffparam. - Octave = op-amp precision full-wave rectifier (
fwr.cir), no transformer.octave_gen.cirproduces octave-only; Squeal level is quadratic so noon = ring, full = blast (goct=8, ~+9 dB octave-over-body, calibrated on real DI clips). - Envelope follower = precision rectifier + 2-pole averaging (not peak-detect, which hunted in a ~12 Hz limit cycle that re-triggered the filter on held notes).
- Filter = LM13700 OTA state-variable filter (
vcf.cirbehavioral /vcf_real.cirreal). Has LP/BP/HP/mixed taps (Q-Tron-style mode); the hybrid route uses BP for the body. Quack maps to a linear Q (0.7→3.5) and an envelope-swept cutoff (gm = Iabc). gm control is ground-referenced to avoid a virtual-ground latch. - Behavioral-ideal first to lock topology/voicing, then real part models for build
accuracy (
opamp_real.sub= TL072,lm13700.sub= OTA) — validated: voicing essentially unchanged on real silicon. Seememory/real-parts-and-build-notes.
Design, voicing, real-part validation, and a routed KiCad PCB are complete.
| done | |
|---|---|
| ✅ | All blocks designed + integrated (input buffer → fuzz → octave → filter → volume) |
| ✅ | Voiced by ear on real guitar (MOTU M2), grid of Squeal×Anger×Quack rendered |
| ✅ | Real part models (TL072 + LM13700) — chain converges, voicing holds |
| ✅ | All-real buildable netlist (pedal_build.cir, no B-sources) — re-validated |
| ✅ | Quack sweep restored: V-to-I gain ×73 → body cutoff ~280 Hz→2.1 kHz, auditioned-good |
| ✅ | KiCad PCB: placed, auto-routed, gerbers exported |
| ✅ | Tier-1 de-risk audit (kicad/AUDIT.md): caught + fixed 10 reversed diodes; added per-IC decoupling + Schottky protection + center-neg polarity; DRC 0 unconnected / 0 violations |
Quack note: the real envelope is tiny (~0.04 V peak), so the V-to-I needs high gain
(Rdi/Rdj = 1.5k, Rdf/Rdk = 110k → ×73) for the filter to sweep. Quack is most
audible at lower Squeal — at full Squeal the octave sits on top by design.
Audit caught a board-killer: duck.py (hand-translated) had 10/14 diodes reversed
(fuzz clippers + both rectifiers) — ERC + sim pass anyway since the SPICE is a separate,
correct source. Fixed to match SPICE; see kicad/AUDIT.md. Not yet hardware-validated:
real noise floor + the LM13700 filter's behavior (the /50 attenuation hack, Iabc ceiling) —
confirm by breadboarding the filter + fuzz before fab (kicad/breadboard/).
spice/lib/— models:opamp.sub(ideal),opamp_real.sub(TL072),lm13700.sub(OTA),diode.mod(1N4148),supply.cir(buffered vref)spice/blocks/— one.subcktper block:input_buffer,fuzz,fuzz_muff,fwr,octave_rect/octave_gen,hpf,gmint+vcf/vcf_real,envfollow,duck(mixed)/duck_split(hybrid/parallel),pedalspice/tests/— per-block test benchessim/— Python harness:run.py,render.py,octave_balance.py,fft_report.py,spicelib.py,extract_sample.pyaudio/in/— committed DI samples;audio/out/— rendered WAVs (git-ignored)raw_wavs/— large source recordings (git-ignored)kicad/— the board, generated programmatically:duck.py(SKiDL netlist generator) →duck.net(ERC-clean) +BOM.mdbuild_pcb.py(pcbnew: parse netlist → place → board) ;finish_pcb.py(values + GND pour)duck.kicad_pcb(routed, DRC-clean) ;gerbers/+duck.drl+duck-pos.csv(fab-ready)duck_3d_top.png/duck_3d_persp.png(3D renders; or open the.kicad_pcbin pcbnew →Alt+3)AUDIT.md(netlist-translation audit + gap fixes) ;BOM.md;COST.md(build-cost estimate)pedal_build.cirinspice/blocks/is the all-real buildable schematic-of-record
kicad/breadboard/— prototype-first docs (build it on a breadboard before the PCB):BUILD_GUIDE.md(staged bring-up) +CONNECTIONS.md(per-stage wiring tables, net = row)gen_blocks.py/make_tables.py(generators) ;0*.pngdrawn schematics (simple stages)
audio/out/build_grid/+build_grid_orig/— full real-parts grids (Squeal×Anger×Quack ∈ {0,0.5,1})
The board is generated entirely by script (no GUI), bridging two Pythons because
pcbnew is built for system Python 3.14 while the sim harness uses uv's 3.13:
# 1. netlist (uv / 3.13)
KICAD8_SYMBOL_DIR=/usr/share/kicad/symbols uv run kicad/duck.py # -> duck.net (+ ERC)
# 2. place + board (system python 3.14, needs pcbnew)
/usr/bin/python3 kicad/build_pcb.py # -> duck.kicad_pcb
# 3. route: export DSN, run freerouting (needs a display, e.g. :0), import SES
/usr/bin/python3 -c "import pcbnew,sys; b=pcbnew.LoadBoard('kicad/duck.kicad_pcb'); pcbnew.ExportSpecctraDSN(b,'/tmp/duck.dsn')"
DISPLAY=:0 java -jar freerouting.jar -de /tmp/duck.dsn -do /tmp/duck.ses -mp 14
/usr/bin/python3 -c "import pcbnew; b=pcbnew.LoadBoard('kicad/duck.kicad_pcb'); pcbnew.ImportSpecctraSES(b,'/tmp/duck.ses'); pcbnew.SaveBoard('kicad/duck.kicad_pcb',b)"
# 4. finishing pass (snap values + GND pour) + checks + fab outputs
/usr/bin/python3 kicad/finish_pcb.py
kicad-cli pcb drc kicad/duck.kicad_pcb
kicad-cli pcb export gerbers --output kicad/gerbers/ kicad/duck.kicad_pcb
Board: 2-layer, 175×120 mm (1590DD-class), 6× TL074 + 1× LM13700 + 14× 1N4148,
4 knobs (Volume / Anger / Squeal / Quack-dual-gang). No graphical schematic
(eeschema has no scripting API) — pedal_build.cir + duck.py are the design-of-record.
kicad/breadboard/ holds prototype-first docs. Build + test the circuit on solderless
breadboards one stage at a time before committing to the PCB — see BUILD_GUIDE.md for the
staged bring-up order (supply → input → fuzz → octave → envelope → V-to-I → filter → output)
and CONNECTIONS.md for per-stage wiring tables (each net = one breadboard row; IC pins
annotated). Each stage uses its own op-amp IC (not the PCB's shared packing) so it tests
standalone. Regenerate with gen_blocks.py + make_tables.py. (SKiDL's schematic auto-router
only handles the simple stages, so the dense ones are table-only — better for breadboarding anyway.)
Run a block test bench:
uv run sim/run.py spice/tests/tb_input_buffer.cir
Render guitar through the pedal (→ audio/out/):
# the LOCKED, buildable design (= the PCB): real parts, no B-sources, V-to-I gain x73
uv run sim/render.py [wav] --build --squeal 1 --anger 0.8 --quack 0.6 [--subdir name]
# the behavioral routes (faster iteration / experiments):
uv run sim/render.py [wav] --route hybrid --squeal 1 --anger 0.8 --quack 0.6 [--real]
--build=pedal_build.cir, the all-real buildable pedal that matches the PCB (use this).--real= TL072 + LM13700 models on the behavioral chain ·--route= mixed|hybrid|parallel- Scratch files are unique per render, so render a grid in parallel on many cores:
... | xargs -P 24 -I A sh -c 'uv run sim/render.py A'(the committedaudio/out/build_grid/+build_grid_orig/are the--buildgrids of Squeal×Anger×Quack ∈ {0, 0.5, 1.0} on both DI samples).
Measure octave-vs-body level on real clips (don't trust single-note benches — they over-read the octave ~6 dB):
uv run sim/octave_balance.py --wav audio/in/di_sample.wav --squeal 1.0 --goct 8