Building the Womack Audio Suite: note-aware resonance, a fisheye EQ, and a bus that almost wasn't
I set out to build a small family of macOS audio plugins that feel like they were made by the same hand and know about each other: a note-aware resonant filter, a fuzz/vibe/delay pedalboard, a lyric teleprompter — and, underneath them, a shared “bus” so instances can talk. Along the way I fought a filter into warmth without letting it scream, drew an EQ curve through a fisheye lens, threw out a chord detector that couldn’t name basic chords, and spent an afternoon learning why a plugin can work perfectly in Standalone and be stone dead in Logic.
This is the engineering story — the musical decisions and the programming ones — with the war stories left in.
The suite today:
- Womack FX — a fuzz → univibe → tape-delay pedalboard (
aufx). - Womack Resonote — a note-quantized resonant filter/EQ, single- and multi-band (
aumf). - Womack Lyriqueue — session-stored lyrics with a playhead-following teleprompter (
aufx). - WomackBus — a shared, in-process message bus the plugins register on.
Stack throughout: JUCE 8.0.12, C++20, CMake (Xcode generator), building AU +
VST3 + Standalone, unit-tested with JUCE’s console UnitTest runner, signed and notarized
for distribution.
Part 1 — Resonote: making a filter that knows about notes
Most EQs think in raw Hz. Musicians think in notes. Resonote closes that gap: its cutoff can snap to note frequencies, optionally constrained to a key, with a live cents-from-nearest readout so you always know how in-tune the resonance is.
Note math you can trust
The pitch math lives in a small, pure, unit-tested header (NoteFrequency.h): MIDI↔Hz,
nearestMidi, noteName, centsFromNearest, and scale-aware snapping for chromatic, major,
and minor. Keeping it pure meant I could test the tricky parts directly instead of poking at
a filter and squinting at a spectrum.
One bug from this corner is worth calling out because it’s so ordinary: the cents readout once
showed +9.61706e-05 c. juce::String(cents, 0) doesn’t round — it formats — so a whisker
above zero printed in scientific notation. The fix was a juce::roundToInt before display.
Tiny, but it’s the difference between “precise instrument” and “toy.”
The warmth: one SVF with a tanh in the feedback
The heart of Resonote is a single zero-delay-feedback TPT state-variable filter
(ResonantSVF). It runs in Bell, Low-Pass, or High-Pass mode. The only “analog” trick — and
it’s deliberately the only one — is a tanh nonlinearity in the resonance feedback path.
That tanh does two jobs. It adds a gentle, level-dependent saturation as resonance climbs —
the “warmth” — and it self-limits, so the filter stays stable and never tips into
self-oscillation. That let me be aggressive with the resonance range. resonanceToQ maps a
normalized 0..1 knob to Q ≈ 0.5 … 75:
1 | // 0..1 -> Q 0.5..~75, musically weighted toward the top of the range. |
Cranked, it sings on a note without ever howling — because the tanh in the loop eats the
runaway energy that self-oscillation would need.
The fisheye response curve
An EQ curve on a log-frequency axis wastes its most interesting real estate: the region right
around where you’re working is cramped. So Resonote’s response display bends space. It draws
everything through a horizontal fisheye — an erf “bump” that magnifies the area around
the current cutoff and compresses the far edges — plus a subtle convex vertical bow for a
tactile “glass” feel.
Schematic illustration (not a screenshot).
The horizontal mapping from a normalized log-frequency position u ∈ [0,1] to a pixel x is:
1 | // erf "bump" centred on u0 (the cutoff), pinned at the frame edges. |
Because uToX is monotonic increasing in u, it has a well-defined inverse — and I need
that inverse constantly: to hit-test the mouse against the curve, and to let you drag the
crossover handles between bands and have them land exactly on the boundary they control.
Rather than derive a closed-form inverse of an erf, I just bisect:
1 | // uToX is monotonic in u, so bisect for its inverse. ~30 iterations over [0,1] |
Thirty iterations is nothing per frame, it’s trivially correct, and it means the exact same warp is used for drawing and for interaction. The handles never drift off the slabs they sit on.
Multiband: up to four bells that don’t step on each other
Resonote grew from one band to up to four, each a colour-coded bell with its own
frequency, resonance, and gain, and each confined to a mutually-exclusive range at least an
octave wide. A + / – LED changes the band count; adding a band auto-splits the spectrum,
after which the crossovers are draggable (through that same fisheye inverse). Each band also
gets tempo-synced resonance modulation — an LFO with a per-band depth and a host-synced
rate, plus a global shape.
Schematic illustration (not a screenshot).
The chord detector I had to rip out and rewrite
The multiband version reads out the chord formed by the active bands. My first attempt was
hand-rolled, and it was simply wrong: for the notes of an F minor 7 it
displayed F +3 +5 +7 — printing raw semitone offsets as if they were chord
degrees. For a music app, that’s just not good enough.
There is no clean drop-in C++ chord library, so I ported the algorithm that the JavaScript
world trusts — tonal.js-style detection: normalize the pitch classes, then rotate through
every note as a candidate root, match the resulting interval set against known chord
qualities, and prefer the interpretation with the best root; fall back to slash-chord
inversions (C/E) and, only if nothing matches, to an honest note-name list rather than
fake degrees. Augmented-7 spells as aug7, never +7, precisely because a + had been the
symptom of the old bug.
The lesson wasn’t “chords are hard” (they are). It was: when your domain has a correct answer, either compute the correct answer or say you can’t — don’t invent notation that looks right.
Part 2 — The Logic bug class that bit us three times
Here’s the war story every plugin dev should internalize.
Resonote’s visualizer, note readout, and chord display all read their values from small
std::atomic snapshots that were written inside processBlock. In the Standalone app that
was fine — the audio device runs continuously, so processBlock fires forever and the snapshots
are always fresh. Ship the same binary into Logic, stop the transport, turn a knob… and
nothing moves. The curve freezes. The chord goes stale. It looked broken, and only in the DAW.
The root cause: Logic doesn’t call processBlock when the transport is stopped (and only
does at all when audio flows through the track). Reading UI values from process-time snapshots
couples your interface to the transport state. The fix is a one-liner in spirit — read the
parameters directly on the message thread, not the process-time mirror — and I routed every
UI getter through a single effectiveBandFreqHz() helper so the audio path and the display can
never disagree.
It bit three times: first the band count stuck at 1, then the whole visualizer + chord readout went inert, then Lyriqueue’s transport readout froze for the same reason. “Works in Standalone, dead in Logic” is now a smell I recognize instantly.
The sibling gotcha: change gestures
While chasing the band-count issue I hit its cousin. Writing a parameter from code with
setValueNotifyingHost works in Standalone and silently reverts in Logic — unless you
bracket it in a gesture:
1 | param->beginChangeGesture(); |
Logic treats an un-gestured programmatic write as noise and rolls it back. Standalone doesn’t care. Same shape of bug, same “only in the DAW” signature.
The meta-lesson: Standalone is a liar of omission. It’s a great fast loop, but the DAW is the only ground truth. auval on every build, then quit-and-reopen Logic (it caches AU binaries in memory) before you trust anything.
Part 3 — Lyriqueue: lyrics that follow the playhead
Lyriqueue stores lyrics in the session and shows a teleprompter that follows the DAW’s playhead, cue by cue, in musical time. Cues are stored canonically as absolute PPQ (quarter-notes from song start) and rendered as Bar.Beat.Ticks using the host’s time signature. The active line is simply “the cued line with the greatest cue ≤ now.”
An early version showed impossible Bar.Beat.Ticks values; the fix was to do all arithmetic in PPQ and convert once at the edges, so beats and ticks always roll over musically.
It’s an aufx audio effect that doesn’t touch the audio — which produced a genuinely confusing
support moment: on a silent track, Logic never processes it, so the playhead never arrives
and following looks broken. The right move wasn’t code gymnastics; it was discoverability —
a live transport readout plus a “waiting for transport” hint that appears only when the host has
never processed the track (detected with a processBlock counter that never advances), and
latches off the instant a block arrives so it never nags on a working track.
Part 4 — WomackBus: the shared bus that almost wasn’t
This is the part I’m proudest of, because it looked done, passed its tests, and was quietly wrong.
I wanted the plugins to be a suite — to share musical context in one process. So I built
WomackBus: a process-wide singleton with a client registry, a small blackboard, and change
listeners; each plugin owns a RAII WomackBusClient that registers on construction and
unregisters on destruction, and shows a tiny “Womack ×N” presence badge.
I built WomackCommon as a static library, linked it into each plugin, saw the badge show
×1, shipped it. The unit tests were green. The badge was… always ×1 across different
plugins.
Why static linking silently broke it
A function-local-static singleton (WomackBus::get()) linked statically into two separate
plugin bundles gets duplicated — each bundle carries its own copy. On macOS with the
default two-level namespace, strong duplicate symbols are not coalesced across separately
loaded bundles. So Resonote had one bus, Lyriqueue had another, and they never saw each other.
Two instances of the same plugin shared (same image); two different plugins did not.
nm shows it plainly — every plugin binary defined the symbol (T), instead of importing it:
1 | nm "Womack Resonote" | grep WomackBus3getEv |
The fix: one shared, JUCE-free dylib
WomackCommon became a single shared dylib with an absolute install name under a shared
directory (/Library/Application Support/Womack for installs; ~/Library/Application Support/Womack
for dev). Every plugin records that one path, so at load time dyld maps one copy — one bus
per process. Now nm shows the symbol imported (U) everywhere:
1 | nm "Womack Resonote" | grep WomackBus3getEv |
There was one more trap I designed around from the start of the rewrite: the dylib’s ABI is
deliberately JUCE-free — std::string, a plain std::uint32_t ARGB, plain structs. Why?
Each plugin already embeds its own JUCE runtime. If the shared dylib also linked JUCE and I
passed, say, a juce::Identifier or juce::String across the boundary, I’d be sharing objects
that reference JUCE’s global string pool — but there’d be two JUCE runtimes with two pools.
That’s a corruption waiting to happen. Keeping the bus boundary to std types means it links only
libc++/libSystem (verifiable with otool -L) and can’t entangle the runtimes. Plugins
convert juce::String ↔ std::string at the edge.
The installer ships the dylib as a required component (always installed), Developer-ID signed and notarized alongside the plugins.
The takeaway: green tests are necessary, not sufficient. The bus’s logic was always
correct — N clients produce count N. What was wrong lived in the linking, a layer the unit
tests couldn’t see. nm/otool were the tests that mattered here.
Part 5 — Where it’s going: shared musical context
With one real bus in place, the suite can finally share music, not just presence. The next piece (in progress) adds two things over the bus, both opt-in, both symmetric-peer:
- Shared key — turn on “Sync Key” and changing root/scale in one instance updates the others (with origin-id + value-compare + an “adopting” guard so it can’t echo forever).
- Frequency-signature awareness — each Resonote publishes the frequencies it occupies, and every instance can see the others’ occupied frequencies as coloured ghosts on its own curve, with a warning when two land within 50 cents of each other. Put a Resonote on a piano and another on an organ, and each can carve out its own spectral pocket instead of fighting for the same one. Awareness first; you carve manually; automatic avoidance is on the backlog.
A future Womack Ex Machina on the master bus can then become the leader that drives the whole suite — but that’s a later chapter.
How it’s built
A few process notes, because they mattered as much as the DSP:
- Worktree per task. Every change happens on its own git worktree/branch;
mainonly ever receives tested, confirmed work. Multiple build sessions never collide. - TDD on the pure parts. Note math, band ranges, chord naming, and the sync helpers are all
pure and unit-tested (
ResonoteTests,WomackBusTests) before they touch a filter or a UI. - Subagent-driven development. Larger features are executed task-by-task with a fresh implementer per task and a spec + code-quality review after each — plus a broad review before merge.
- Validate relentlessly.
auvalon every build for all three plugins;nm/otoolfor the linking guarantees; quit-and-reopen Logic before trusting a change.
Closing
The fun of this project has been that the musical problems and the systems problems keep
turning out to be the same problem viewed from two sides. A filter that “knows about notes” is
equal parts pitch math and a fisheye you can drag. A “suite” is equal parts a shared key and a
symbol that must be U and not T. And the recurring humbling lesson — from the scientific-
notation cents, to the F +3 +5 +7 chord, to the bus that passed its tests while being wrong —
is that looking right is not the same as being right. The DAW, the linker, and the music
theory all get a vote.
More soon, once instances can carve out their own place in the mix.
— Womack Audio