Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions book/PLAN-figures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# PLAN: figures for the rest of the book

Drafting record, 2026-07-23. A full survey of every chapter (14 user-facing,
13 machine deep-dives) for image/diagram/visualization opportunities, graded
and sequenced. The overdrive chapters are the shipped precedent: measured
SVGs generated by a committed script driving the shipping kernel through the
C ABI (`book/figures/overdrive.py`), plus one hand-authored signal-flow
diagram (`src/images/overdrive/block-diagram.svg`). Everything below follows
the same two rules:

- **Measured, not remembered.** A plot is a measurement of the shipping DSP
(via `notebooks/taptools_py.py`) or a faithful rendering of the math the
chapter derives — never an illustration of what the code *should* do.
- **Diagrams are of the actual topology.** Boxes and loops mirror the kernel
header's structure, in the house diagram style (grey main path, colored
emphasis paths, dashed rate regions).

Conventions carried forward from the overdrive set: one generation script
per chapter under `book/figures/`, images under `src/images/<object>/`,
house palette with the amber snapped to `#b8890f` (passes the lightness-band
and CVD checks on a light page), direct labels on every multi-series figure.

## The constraint that shapes the sequencing

`taptools_py` reaches: `Convolver, Svf, Ladder, Diode, TB303, Vco, Wah,
Overdrive, TriggerRow, NoteRow, Kick, Tune, Yin`. **Not reachable**: the
comb bank, pitchaccum, vocoder, nr/spectra (the STFT pair), and every TR-808
voice except the kick. Measured figures for those need `tools/capi` +
bridge extensions first (per CLAUDE.md, bindings are extended alongside the
kernel). Hand-authored diagrams and pure-math plots carry no such
dependency — which is why they lead the plan.

## Wave 1 — signal-flow diagrams (no dependencies, highest leverage)

Hand-authored SVGs in the overdrive diagram style. Ranked; the top four are
topologies the prose visibly strains to convey.

1. **tb303 voice** (`acid.md` + `machine/tb303.md`) — the chapter's literal
thesis ("the couplings are the instrument"): osc → coupling HP → diode
ladder → VCA main path, with accent fanning to MEG clock/VCA/C13 sweep
and slide as an RC on the pitch CV. One diagram serves both chapters.
2. **pitchaccum loop** (`pitchaccum.md` + `machine/pitchaccum.md`) — feedback
re-entering *upstream of the transposer taps* is the whole object; drawn
against the ordinary shifter-in-feedback patch that never climbs.
3. **comb voice ring** (`fivecomb.md` + `machine/comb.md`) — Hermite tap →
loop lowpass → normalized DC blocker → warp allpass → ×fb ring, with the
d/2 pickup branch. (Both GRM chapters are otherwise blocked on bindings,
so the diagrams are their fastest wins.)
4. **diode diffusion line** (`machine/diode.md`) — four coupled capacitor
nodes with bidirectional charge/steal arrows, halved top cap, tanh on
every edge, `−k·hp(v4)` feedback — the picture that separates a
diffusion line from a buffered cascade.
5. **vocoder banks** (`vocoder.md` + `machine/vocoder.md`) — modulator bank
+ followers over carrier bank into 24 multipliers; the canonical diagram.
6. **STFT scaffold** (`machine/spectral.md`, referenced by `nr.md` /
`spectra.md`) — ring → analysis window → FFT → pluggable op → IFFT →
overlap-add, latency = N annotated.
7. **conv FDL** (`convolve.md` + `machine/conv.md`) — the frequency-domain
delay line ring MAC'd against partition spectra; plus the small
overlap-save keep/discard framing diagram.
8. **ladder loop** (`ladder.md` + `machine/ladder.md`) — four tanh one-pole
stages, `−4·resonance` feedback tap annotated with the ¼-gain/−180°
oscillation condition.
9. **svf TPT core** (`svf.md` + `machine/svf.md`) — two trapezoidal
integrators in a ZDF loop, damping tap, m0/m1/m2 output mixer ("morph is
three multiplies downstream").
10. **vco fan-out** (`vco.md` + `machine/vco.md`) — one master phase read
four ways, polyBLEP correction summed in, FM/sync/analog injections.
(The chapter already has this as ASCII art begging to be an SVG.)
11. **autowah chain** (`autowah.md` + `machine/autowah.md`) — rectifier →
RC follower → sweep law → *boxed borrowed `svf_filter`*, showing the
composition and the two patch points.
12. **tune pipeline** (`tune.md` + `machine/tune.md`) — per-hop YIN brain
over the per-sample glide/ratio path into three swappable backends
behind one seam; hop/sample clock boundary marked.
13. **seq phase math** (`machine/seq.md`) — one phase ramp deriving step
index for rows of different lengths (12 vs 16); polymeter as arithmetic.
14. **808 bridged-T** (`drums.md` + `machine/tr808.md`) — the shared
resonator core fanning to the eight voices; the kick's leg-modulation
loop drawn in.

## Wave 2 — measured figures, zero new plumbing

One `book/figures/<object>.py` per chapter, the overdrive script pattern.
HIGH items only (the surveys hold the MED/LOW backlog):

- **vco**: naive-vs-polyBLEP folded-harmonic spectrum (the 47 dB headline);
the `track 5` cents-error line (±15 cents across ±3 octaves).
- **svf**: order 2/4/8 response family (−3.01 dB at fc at every order);
morph-corner vs discrete-mode overlay (difference exactly 0); the
five-octave 90 Hz-LFO modulation-survival timeline.
- **ladder**: THD-vs-drive walk (0.5→33%); measured self-oscillation
frequency vs cutoff (tuning to 0.11% at 8 kHz).
- **autowah**: envelope + cutoff trace on a plucked note (the `Wah` class
returns both per sample — purpose-built for this); measured sweep law vs
the design curve (0.000 cents); the STFT-extracted-peak vs kernel-cutoff
overlay from `autowah_validation.ipynb` (0.979 correlation).
- **convolve**: error-vs-`np.convolve` across block sizes (3×10⁻¹² floor);
the 2×2 true-stereo path-gain matrix.
- **acid**: diode response vs Stinchcombe's published transfer function
(0.028 dB); the C13 accent "wow" trace (cutoff peak ×1.94 build and
decay) — the marquee behavior; MEG plain-vs-accent envelopes.
- **drums (kick only)**: modeled-vs-measured spectrum + decay envelope (the
calibration honesty claim); the three-behavior trace (attack punch,
retrigger, sigh) on one waveform/pitch plot.
- **tune**: the `speed` pitch-track figure (three glides onto the same
46-cent-sharp note — the notebook already draws it); hard-snap vibrato
terracing; the period-lock cents-vs-window curve (the +5.41¢ war story,
measured with the `Yin` oracle).
- **seq**: gate/pitch trace of the 16-step 3-slide pattern (exactly 13
note-ons); swing boundary timeline.

## Wave 3 — pure-math plots (no kernel needed, machine chapters)

Derivation conclusions made visible; cheap and high-value:

- ladder: Hⁿ magnitude/phase, n = 1..4, the Barkhausen ¼/−180° point.
- vco: the polyBLEP residual r(τ) with its ±½ midpoints.
- spectral: the COLA proof (four shifted Hann² summing to 3/2); the nr
expander knee family (slope 0/2/∞).
- pitchaccum: the cos²/sin² partition-of-unity envelope pair.
- vocoder: RBJ constant-peak bandpass family pinned at 0 dB vs the
constant-skirt variant.
- diode: s-plane pole map, spread-real vs Moog's coincident four; required
k vs cutoff (why a stock 303 never sings).
- comb: the loop-gain-crosses-unity war story (|H_lp·H_dc| un-normalized vs
normalized).
- pitch: raw d(τ) vs cumulative-mean-normalized d′(τ) (YIN's octave trap
disarmed); the naive-remap-scatters vs rigid-region-translate bin diagram.
- tb303: the envmod offset law (2/3 above, 1/3 below the knob).

## Wave 4 — blocked on C-ABI bindings

Measured figures needing `tools/capi` + `taptools_py` extensions first
(each also unlocks a future verification notebook, so the plumbing pays
twice):

| Binding needed | Unlocks (HIGH items) |
|---|---|
| comb bank | fractional-vs-integer tuning beats; RT60 flat vs pitch; warp/phase spectra |
| pitchaccum | the +7→+14→+21 accumulation spectrogram; bounded-at-0.99 trace |
| vocoder | bank response at narrow/wide q; linearity pin |
| nr / spectra (STFT pair) | before/after spectrogram; per-bin gain curve; remap staircase + octave-fold spectra |
| 808 voices beyond kick | per-voice calibration panels; metal-bank seed spread |

## Suggested execution order

Wave 1 items 1–4 first (the strain-relieving diagrams), then Wave 2 one
chapter per PR in reading order, folding in that chapter's Wave 1 diagram
and Wave 3 math plots so each PR ships a chapter *complete*. Wave 4 as a
separate plumbing PR per binding, kernel-repo CLAUDE.md rules applying
(capi + bridge + notebook together).

Full per-chapter detail (every MED/LOW item, quoted claims, exact data
sources) lives in the survey transcripts behind this plan; this file keeps
the decisions.
193 changes: 193 additions & 0 deletions book/figures/overdrive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Generate the measured figures for the overdrive book chapters.

Drives the *shipping* kernel (overdrive.h) through the C ABI via the notebooks'
ctypes bridge — the same rule as the verification notebooks: figures are
measurements of the real DSP, never illustrations of what it should do. The
companion notebook (notebooks/overdrive.ipynb) carries the same measurements
with commentary; this script renders the book-styled SVGs.

Regenerate after a kernel behavior change:

python3 book/figures/overdrive.py # writes book/src/images/overdrive/*.svg

Colors: the house categorical hues (notebooks/taptools_py.py PALETTE), with the
amber snapped darker (#efb118 -> #b8890f) so the triple passes the print/CVD
lightness-band and separation checks on a light page. Every multi-series figure
carries direct labels, so identity never rides on color alone.
"""

import pathlib
import sys

import numpy as np
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "notebooks"))
import taptools_py as tap

OUT = pathlib.Path(__file__).resolve().parents[1] / "src" / "images" / "overdrive"
OUT.mkdir(parents=True, exist_ok=True)

BLUE, AMBER, RED = "#4269d0", "#b8890f", "#ff725c"
INK, MUTED = "#1a1a1a", "#666666"

plt.rcParams.update({
"figure.dpi": 96, "figure.figsize": (7.2, 3.1),
"svg.fonttype": "none", "font.family": "sans-serif", "font.size": 9.5,
"axes.grid": True, "grid.alpha": 0.22, "grid.linewidth": 0.5,
"axes.spines.top": False, "axes.spines.right": False,
"axes.edgecolor": MUTED, "axes.labelcolor": INK,
"xtick.color": MUTED, "ytick.color": MUTED,
"axes.titlesize": 10, "axes.titlecolor": INK,
"lines.linewidth": 2.0, "legend.frameon": False, "legend.fontsize": 8.5,
})

fs = 48000


def tone(f, amp, seconds):
t = np.arange(int(seconds * fs)) / fs
return amp * np.sin(2 * np.pi * f * t)


def level(y, f):
n = len(y)
t = np.arange(n) / fs
win = np.hanning(n)
return 2.0 * np.abs(np.dot(y * win, np.exp(-2j * np.pi * f * t))) / win.sum()


def gain_db(f, amp=5e-4, settle=0.25, meas=0.5, **params):
o = tap.Overdrive(fs, **params)
x = tone(f, amp, settle + meas)
y = o.process(x)
n0 = int(settle * fs)
return 20 * np.log10(level(y[n0:], f) / level(x[n0:], f))


def spectrum_db(y, ref):
win = np.hanning(len(y))
mag = np.abs(np.fft.rfft(y * win)) / (win.sum() / 2)
freqs = np.fft.rfftfreq(len(y), 1 / fs)
return freqs, 20 * np.log10(np.maximum(mag / ref, 1e-9))


def save(fig, name):
path = OUT / name
fig.savefig(path, bbox_inches="tight")
plt.close(fig)
print(f"wrote {path}")


# -- 1. the headline: gain tilt grows with drive ---------------------------------------

freqs = np.geomspace(30, 12000, 22)
fig, ax = plt.subplots()
for drive, color in ((0.0, BLUE), (0.5, AMBER), (0.9, RED)):
g = np.array([gain_db(f, drive=drive) for f in freqs])
ax.semilogx(freqs, g, color=color, label=f"drive {drive}")
ax.annotate(f"drive {drive}", (freqs[-1] * 1.06, g[-1]), color=color,
fontsize=8.5, va="center")
ax.set(xlabel="frequency (Hz)", ylabel="small-signal gain (dB)", xlim=(28, 26000),
title="The loop's tilt grows with drive: bass pinned, mids take the gain")
ax.legend(loc="upper left")
save(fig, "tilt.svg")

# -- 2. the transfer never flattens ----------------------------------------------------

amps = np.geomspace(0.05, 1.0, 12)
fig, ax = plt.subplots(figsize=(5.4, 3.1))
for drive, color in ((0.0, BLUE), (0.5, AMBER), (1.0, RED)):
peaks = []
for amp in amps:
o = tap.Overdrive(fs, drive=drive)
y = o.process(tone(500, amp, 0.75))
peaks.append(np.abs(y[int(0.25 * fs):]).max())
ax.plot(amps, peaks, "o-", ms=3.5, color=color, label=f"drive {drive}")
ax.annotate(f"drive {drive}", (amps[-1] * 1.03, peaks[-1]), color=color,
fontsize=8.5, va="center")
ax.set(xlabel="input peak", ylabel="output peak", xlim=(0, 1.18),
title="Peak transfer at 500 Hz: rising at every drive, never a plateau")
ax.legend(loc="upper left")
save(fig, "transfer.svg")

# -- 3. even harmonics vs asymmetry ----------------------------------------------------

f0 = 220.5
fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.1), sharey=True)
for ax, asym in ((axes[0], 0.0), (axes[1], 0.6)):
o = tap.Overdrive(fs, drive=0.6, asymmetry=asym)
y = o.process(tone(f0, 0.5, 1.5))[int(0.5 * fs):]
fr, db = spectrum_db(y, ref=level(y, f0))
ax.plot(fr, db, color=BLUE, lw=0.9)
for h in (2, 4): # flag the even harmonics
ax.annotate(f"H{h}", (h * f0, 20 * np.log10(max(level(y, h * f0) / level(y, f0), 1e-8)) + 6),
color=RED, fontsize=8.5, ha="center")
ax.set(xlim=(0, 2000), ylim=(-100, 8), xlabel="frequency (Hz)",
title=f"asymmetry {asym}")
axes[0].set_ylabel("level re fundamental (dB)")
fig.suptitle("Asymmetry turns on the even series (220.5 Hz, drive 0.6)", fontsize=10, color=INK, y=1.04)
save(fig, "harmonics.svg")

# -- 4. body voicing -------------------------------------------------------------------

freqs_b = np.geomspace(25, 16000, 24)
fig, ax = plt.subplots()
# The curves converge at the top of the band, so the direct labels sit at the
# low end where the three are far apart (~15 dB of separation at 40 Hz).
for body, color, label in ((-1.0, BLUE, "body −1 (full lows)"),
(0.0, AMBER, "body 0"),
(1.0, RED, "body +1 (tight, mid push)")):
g = np.array([gain_db(f, drive=0.3, body=body) for f in freqs_b])
ax.semilogx(freqs_b, g, color=color, label=label)
ax.annotate(label, (freqs_b[0] * 1.15, g[0]), color=color, fontsize=8.5, va="bottom")
ax.set(xlabel="frequency (Hz)", ylabel="small-signal gain (dB)", xlim=(23, 17000),
title="body: the voicing control (drive 0.3)")
ax.legend(loc="lower right")
save(fig, "body.svg")

# -- 5. alias floor: 1x vs the default 4x (machine chapter) ----------------------------

f0a = 5001.0
alias_f = fs - 7 * f0a
fig, ax = plt.subplots()
# 1x behind, 4x on top with slight transparency: every red peak visible above
# the blue mass is alias energy the default 4x removes.
for os_, color, alpha in ((1, RED, 1.0), (4, BLUE, 0.88)):
o = tap.Overdrive(fs, drive=0.9, asymmetry=0.2, oversample=os_)
y = o.process(tone(f0a, 0.6, 1.5))[int(0.5 * fs):]
fr, db = spectrum_db(y, ref=level(y, f0a))
ax.plot(fr, db, lw=0.8, color=color, alpha=alpha, label=f"oversample {os_}×")
ax.axvline(alias_f, color=MUTED, lw=0.8, ls="--")
ax.annotate("folded H7\n(12993 Hz)", (alias_f, 2), color=MUTED, fontsize=8.5,
ha="center", va="bottom")
ax.set(xlim=(0, fs / 2), ylim=(-110, 14), xlabel="frequency (Hz)",
ylabel="level re fundamental (dB)",
title="5001 Hz at drive 0.9: the alias floor at 1× vs the default 4×")
ax.legend(loc="upper right")
save(fig, "alias.svg")

# -- 6. the shaper vs its rejected alternatives (machine chapter) ----------------------

u = np.linspace(-4, 4, 400)
fig, ax = plt.subplots(figsize=(5.4, 3.3))
curves = (
(u / np.sqrt(1 + u * u), BLUE, "u/√(1+u²) (chosen)", 2.4),
(np.tanh(u), AMBER, "tanh (libm cost)", -1.5),
(np.clip(u, -1, 1), RED, "hard clip (aliases)", 1.2),
)
for y, color, label, lx in curves:
ax.plot(u, y, color=color, lw=2.0, label=label)
ax.annotate("u/√(1+u²)", (2.5, 2.5 / np.sqrt(1 + 2.5**2) - 0.16), color=BLUE, fontsize=8.5)
ax.annotate("tanh", (-2.4, np.tanh(-2.4) - 0.18), color=AMBER, fontsize=8.5, ha="right")
ax.annotate("hard clip", (1.35, 1.06), color=RED, fontsize=8.5)
ax.set(xlabel="input u", ylabel="shape(u)", ylim=(-1.35, 1.35),
title="The static curve: smooth, monotonic, asymptotic — no plateau, no seam")
ax.legend(loc="upper left")
save(fig, "shape.svg")

print("done")
9 changes: 9 additions & 0 deletions book/src/acid.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ round out the `tap.ladder~` surface.
The attributes mirror the seven-knob panel; the calibrations are Open303's
measured laws.

![Signal-flow diagram of the 303 voice: pitch through slide into oscillator,
shaper, coupling highpass, diode ladder, and VCA, with the accent bus
fanning to the envelope, the C13 sweep capacitor, and the VCA, and the
envelope-driven cutoff CV feeding the ladder](images/tb303/block-diagram.svg)

*The blocks are ordinary; the red and amber wires are the 303. Accent
touches three destinations at once, and C13 remembers across notes — the
couplings are the instrument.*

- **`waveform`** — `saw` or `square`. The square is the hardware's shaped
saw: `−tanh(10^(36.9/20)·saw + 4.37)`, Open303's measured constants
verbatim — rounded and notched, audibly not a 50 % pulse.
Expand Down
4 changes: 4 additions & 0 deletions book/src/autowah.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ law. The measured control behavior:
measures 256 ms, and the release fits a pure exponential with residual
σ = 0.004 — an RC discharge, like the hardware.

![Signal-flow diagram of the autowah: the audio path through the borrowed SVF core and dry/wet mix, with the detector chain of sensitivity gain, rectifier, RC follower, tanh knee, and exponential sweep law driving the cutoff](images/autowah/block-diagram.svg)

*A detector, a law, and a borrowed filter — the amber chain is everything this object adds to the SVF it composes.*

## The knobs, one by one

### `sensitivity` — the trigger level, and the secret mode
Expand Down
4 changes: 4 additions & 0 deletions book/src/convolve.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ restart). The rest of the surface mirrors `tap.verb~` so the two reverbs read
as siblings: `mix`, `gain`, `predelay`, `normalize` (energy-based, so quiet
and hot IRs land at comparable levels), `bypass`, `mute`.

![Diagram of uniformly partitioned overlap-save convolution: framed input blocks through an FFT into the frequency-domain delay line, multiplied per bin against the double-buffered IR partition spectra, then IFFT with the aliased half discarded](images/conv/block-diagram.svg)

*The wiring: one FFT in, one IFFT out, and a multiply-accumulate that is the only cost growing with IR length.*

## True stereo, by channel count

A stereo room isn't two mono rooms: sound from the left source arrives at the
Expand Down
4 changes: 4 additions & 0 deletions book/src/drums.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ noise layer. The rim channel is `@model rimshot|claves`: the rimshot's
~1667 + 455 Hz crack with the swing-VCA's harmonics, versus the claves' pure
~2500 Hz tick. Tunings sit within ~4 % of the measured unit.

![The bridged-T resonator circuit: an op-amp with capacitive arms, a resistive bridge, and a leg to ground, triggered through an injection resistor, with the kick's per-sample leg modulation drawn in red — and the eight voices grouped by how they use it](images/tr808/bridged-t.svg)

*Roland's universal voice circuit. Eight voices, one network — the kick earns its punch by modulating the leg per sample.*

## The calibration pass, honestly

The §7.2 calibration ran against a real TR-808 (s/n 103852) recorded from
Expand Down
Loading
Loading