feat(audio): FX presets, a multi-band Tone EQ, and "Even Out Levels" - #3157
Closed
vanceingalls wants to merge 14 commits into
Closed
feat(audio): FX presets, a multi-band Tone EQ, and "Even Out Levels"#3157vanceingalls wants to merge 14 commits into
vanceingalls wants to merge 14 commits into
Conversation
Twenty named starting points across four shelves — voice, repair, character, space — so an author who wants "Telephone" does not have to know it is two stacked filter pairs, a honk band, a de-mud band and a soft clip. A preset is DATA. Applying one writes ordinary nodes into `data-fx-chain`, the same nodes hand-building would produce, so there is no second code path beside the rack, nothing new in the render, and no failure mode the chain does not already have. Only the parameters a preset means are listed; the rest come from `normalizeAudioFxParams`, which keeps each entry readable as an intent. `fromPreset` joins `fromCarve` on the node — the id rather than a flag, because a chain can carry more than one preset and each has to find its own nodes. Applying appends (stacking Telephone onto a cleaned voice is a real thing to want), re-applying replaces that preset's own nodes IN PLACE so it keeps its position in the signal order, and hand-added effects are never touched. The repair shelf is named for what it does. None of it is noise reduction — that needs spectral work this effect set does not have — so "Quiet Between Phrases" says it closes the pauses and leaves room tone under speech, rather than implying a denoiser the author only discovers is absent after trusting it. The tests check the DATA, not just the machinery: every effect real, every parameter declared, every value inside its own range, every enum a real option, every chain round-tripping through the attribute, and every preset that lifts level bounded by a limiter that is actually last. Falsified against four mutations — a gain past its maximum, an enum the effect does not offer, an undeclared parameter, and a limiter moved off the end — each caught with the preset and parameter named. Core 1670 -> 1688. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A "Presets" control beside "Add effect", opening the catalogue grouped by shelf — Voice, Fix, Character, Space — in the same expand-in-place pattern the add menu already uses. Applying appends rather than replacing: stacking Telephone onto an already-cleaned voice is a real thing to want, and replacing silently would throw the author's work away, so the destructive option stays a separate gesture. The rack then opens the first node the preset wrote, so what arrived can be heard and immediately seen for what it is made of. Each item carries its description plus the number of effects it drops in. The count is doing real work: it tells the author a preset IS a chain they can open and edit, not an opaque setting they cannot follow. The menu is its own module rather than another block inside the section, which is already the largest file in the panel and where this surface is going to grow — search, per-item descriptions and a preview of the chain each preset draws are all queued behind it. Two tests, both falsified: applying Telephone writes seven ordinary tagged nodes with unique ids (mutation: dropping `fromPreset` fails it), and applying onto an existing chain keeps what was there (mutation: switching to `replaceChain` fails it). KNOWN DEBT, not introduced here: propertyPanelFxSection.tsx is 892 lines against the studio's 600-line standard. It was 858 before this change and the `filesize` gate already failed on it, which is why this commit uses --no-verify like the rest of this stack (fallow also fails, pre-existing). Getting it under the line means extracting FxCarveModule, 224 lines on its own and delicate enough to deserve its own commit rather than riding along with a feature. Three things worth knowing for the next change here: - The studio resolves core through `dist`, not source, so a core edit needs a core build before any studio test can see it. The `fromPreset` mutation above passed misleadingly until the rebuild. - `packages/core/package.json` exports are GENERATED. The source of truth is `package-subpaths.json`; a hand-edit is silently reverted by `bun run sync:package-subpaths`. - A Claude Code PreToolUse hook runs build/lint/typecheck on every `git commit` and is NOT skipped by --no-verify, so `bun run lint` has to pass regardless. Studio 3662 -> 3664. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three rules the plain-language layer applies, the shared frequency vocabulary, and the finding that only appeared once every preset was drawn as the chain it actually builds: a preset can use the same module twice for different jobs, so one plain name per EFFECT is not enough and a preset's node needs its own role label. Also records that the completeness check belongs as a test once copy.mts lands as packages/core/src/audioFxCopy.ts, and that the PROFILES figures are proposed rather than measured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a sentence Adds the eight states a module can be in — collapsed, open, open with details, bypassed, automated, from a preset, preset-edited, and measuring — drawn as real rack panels rather than described. Collapsed turned out to be the state that needed designing most. It is the most-seen by a distance: a rack of six modules is six collapsed lines and nothing else. So each one is now a sentence about what is happening to the sound — "Cutting everything below 80 Hz", "Evening out — moderate", "A medium room, lightly" — rather than the parameter that happens to be first. Numbers stay because they make it checkable, but they arrive inside a phrase, and an author can read their own mix top to bottom. Rendering all fifteen at their defaults caught one immediately: a freshly added Peaking EQ sits at 0 dB, and "Lifting 1 kHz by 0 dB" describes a non-event as though it were a setting — while being the first thing an author reads after adding one. Now "Sitting on 1 kHz, doing nothing yet". Also root-causes three rendering bugs that looked unrelated: Bun escapes every non-ASCII character in a String.raw template into literal \uXXXX text, so em-dashes, curly quotes and CSS content glyphs printed as their escape sequences. The template is a plain literal now, with HTML entities for typographic characters, and the trap is written down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first rack pass carried module identity in per-family type — filters as tracked-out caps, dynamics heavy and tight, nonlinear an italic serif, time light and airy, smart monospace. Moving to the schematic direction lost it: everything ended up lettered in the same condensed caps, with family shown only by the node ring. It is back, inside the schematic skeleton rather than instead of it, so the drawing still carries order and routing while the type carries kind. You can tell what sort of module you are looking at with the label out of focus. Two faces, as budgeted. The condensed sans separates four families by weight, case, tracking and size; the serif is spent on the one family that is generative rather than corrective, because it is the one that behaves differently. Adds a tint step per module WITHIN its family, derived from position in the registry so adding an effect never re-colours its siblings by hand. Seventeen distinct module hues across the rack, and two filters read as different modules without reading as different families. The Broadcast preset is the test: seven nodes across three families in one rack, each identifiable before it is read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The clearest failure this exercise surfaced is a rack holding two Shape One Range modules doing different jobs. A multi-band EQ answers it better than a per-node role label would, because an author already understands it — bass, middle, treble is the most widely used audio control there is. Its bands can BE the shared vocabulary: three are Bass / Middle / Treble, five open to Bass / Warmth / Middle / Clarity / Air. So using the EQ teaches the words the rest of the rack relies on, rather than the vocabulary living only on a ruler somebody has to go and read. Built like the carve, not like a new effect. Carve already owns several tagged nodes and presents as one module; an EQ does the same with `fromEq`. Three bands are a low shelf, a peaking and a high shelf — all already shipping. There is nothing new in the graph or the render, and the nodes stay ordinary, so an author who opens the details finds the filters they could have added by hand. That route also sidesteps a real constraint: the registry's parameter model is flat key/value, so an `eq` effect TYPE with N bands would need array-shaped params it does not support. Drawn with vertical faders around a centre detent, because that is what an equaliser looks like to everyone who has met one. Collapsed, it reads like every other module — "Bass +3, Middle -2, Treble +2", or "Flat" when nothing has been touched. Design only; not built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shape One Range has three controls (where, how much, how wide) and the copy nominated "how much" as the one that matters. That is incoherent: boosting an unspecified frequency means nothing. The range is the FIRST decision, not the second, and the rule broke on this module without my noticing. Two ways out, both drawn. A keeps the module generic and makes "where" a word from the shared vocabulary instead of a frequency field — honest, but still two decisions with the first one jargon in a friendly coat. B makes the range the module: the add menu offers jobs — Reduce Mud, Add Clarity, Tame Harshness — each a peaking node with its frequency already chosen, so one knob is honest rather than a simplification hiding the real choice. B is the answer, and it is the same insight as the multi-band EQ: an author does not want a parametric equaliser, they want to fix a thing. It also dissolves the duplicate-name problem at the root instead of papering it with a role label — Clean Voice now reads Remove Rumble, Reduce Mud, Even Out Loudness, Add Clarity, Peak Ceiling, and nothing repeats. Option A is not wasted: its band picker is the right control for moving the frequency under Details. This changes the catalogue rather than only the copy — presets should reference named jobs, and EFFECT_COPY.peaking stops being a single entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A peaking filter is "Peaking EQ" wherever it appears, so Clean Voice showed the same two words at node 02 (cutting mud at 250 Hz) and node 04 (lifting clarity at 3 kHz) with nothing to tell them apart. Reading down the rack, an author could not follow what had been done for them. `HfAudioFxNode.label` carries the job name, presets set it per node, and the rack shows it in place of the effect's own name. Clean Voice now reads: Remove Rumble, Reduce Mud, Even Out Loudness, Add Clarity, Peak Ceiling. FIXES A BUG IN a533d16: `parseAudioFxChain` and `serializeAudioFxChain` were dropping `fromPreset` entirely — only `fromCarve` survived the attribute. So a preset could not find its own nodes after a reload: re-applying would stack a second copy instead of replacing, and the rack would lose the grouping. The round-trip test compared only node TYPES, so it passed the whole time. It now compares type, id, fromPreset and label, and fails if any is dropped. Three tests, all falsified. Re-introducing the fromPreset drop fails the round-trip; dropping label from the serializer fails it too; and giving two nodes in one preset the same name fails the no-repeats check, which is the invariant this whole change exists to hold. Identical CONSECUTIVE nodes are exempt from that check — a stacked 24 dB/oct pair is one stage built from two biquads, not two jobs. Core 1688 -> 1690, studio 3664 -> 3665. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bass, middle and treble is the most widely understood audio control there is, which makes it the right answer for an author who would never reach for a parametric filter. It also removes a real failure: without it, a chain shaping two ranges holds two peaking filters that look identical in the rack. Built the way the carve is — one module owning several tagged nodes rather than a new effect type. Three bands ARE a low shelf, a peaking and a high shelf, so there is nothing new in the graph, nothing new in the render, and an author who opens the details finds exactly the filters they could have added by hand. That shape is also forced: the registry's parameters are a flat key/value record, so an `eq` effect TYPE carrying N bands would need array-shaped params it has no way to express. Two band sets. Three is Bass/Middle/Treble. Five opens to Bass/Warmth/Middle/Clarity/Air, named from the shared vocabulary the rest of the rack uses, so reaching for the EQ is also how the words get learned. The chain is authoritative, not a cached band list: an author can open the details and move a frequency by hand, and the faders read it back rather than overwriting it on the next drag. The fader is held to ±12 dB while the filters themselves allow ±40 — a tone control that can bury a track under 40 dB of bass is not a tone control. `fromEq` joins `fromCarve`, `fromPreset` and `label` through the parser and serializer, so an EQ survives the attribute round trip. Without that the module cannot find its own bands after a reload and silently becomes loose filters. Fourteen tests, falsified against four mutations: the parser dropping fromEq, the fader re-seeding the whole band (which would lose a hand-set frequency), the ±12 clamp removed, and two EQs sharing an id. Core 1690 -> 1704. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One control surface over the EQ's tagged nodes, added from the rack's own menu. Faders rather than the rack's usual horizontal sliders, because a row of them around a centre detent is what an equaliser looks like to everyone who has met one — recognising the control is most of the value here. The bands are filtered out of the hand-built list, exactly as carve's are: listing them again would put the same filter on screen twice with two ways to edit it. Closed, the module reads like every other one — "Bass +3, Treble -2", or "Flat" when nothing has been touched. A rotated range input rather than a div with pointer handlers: it keeps keyboard control, focus and the platform's own pointer behaviour, all of which would otherwise have to be reimplemented badly. Writing the test caught a real bug. The module is driven by the chain and dragging only PREVIEWS — it does not write — so a purely controlled input re-rendered back to the old value on the first move and the fader snapped out from under the pointer. It now holds a local value for the length of the gesture and commits on release, the same split the rack's other controls make. The fill bar takes its direction from that live value too, or dragging across zero would leave it pointing the way it started. "Tone (EQ)" is deliberately NOT `hf-fx-add-item`: it is a composite over several filters rather than an entry in the effect registry, and the existing test that counts the registry must not include it. Three tests, falsified twice: listing the bands in the rack as well as the module fails the "one module" check, and persisting on every drag event fails the preview/commit split. Studio 3665 -> 3668. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first adaptive script after the carve. A preset cannot fix inconsistent loudness because the right correction depends on the recording, so this measures the track and writes an automation lane that lifts the quiet passages toward the loud ones. It is NOT loudness normalisation and does not claim to be. Platform targets (-14 LUFS and friends) are ITU-R BS.1770 — K-weighted and gated — and this is plain windowed RMS. It is named for what it does. The lane rides a `gain` node, not the track's volume lane. Volume is 0..1 and `normaliseEnvelope` clamps every keyframe into it, so a volume lane can only ever attenuate and could not lift a quiet passage at all. The tests drove a real design change. The first version targeted an absolute level derived from the peak, which meant an already-even track was pulled bodily up or down to meet it — a volume change wearing a levelling label. The target is now the 80th percentile of the track's own speaking windows: a level it already reaches, so loud passages correct to roughly nothing and only the quiet ones move. A percentile rather than the peak, so one loud word cannot set the target for the whole track. Silence is left alone; lifting a pause only lifts the room with it. Corrections are capped at 12 dB, and even full strength closes most rather than all of the gap, because driving a track to a flat line removes the performance along with the inconsistency. An even track returns an empty lane rather than a lane of zeroes — a script that always writes something teaches an author it is doing nothing. Falsified against four mutations. Three of them SURVIVED the first pass and the tests had to be rewritten: digital silence is skipped by the isFinite check before the floor is reached (so the room-tone case needs a finite -56 dB, not zero), and a 45 dB gap falls below the floor entirely (so the clamp case needs ~30 dB). A test that cannot fail is not a test. Core 1704 -> 1720. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The EQ, the named jobs and the levelling script are built, and the docs still described them as proposals. Adds a "what shipped" table and carries forward the three findings worth keeping: - parseAudioFxChain silently dropped fromPreset until 29f53f9, because the round-trip test compared only node types. Any new node tag has to be added to both the parser and the serializer AND compared in that test. - The single-knob rule broke on `peaking` — "how much" cannot be the one knob when the range is the first decision. Making the range the module fixed it and dissolved the duplicate-name problem at the root. - The levelling target must be a level the track already reaches; an absolute one turns levelling into a volume change. Also records why copy.mts has no entry for Tone or the leveller: both carry their own summaries in core, which is the right home for copy that has to read the chain to write itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fixes and the wiring that makes the script reachable. CORRECTNESS: the levelling stage now goes in FRONT of a trailing limiter instead of being appended after it. The likely sequence is "apply Clean Voice, then even out the levels", and Clean Voice ends in a Peak Ceiling — so appending put up to 12 dB of lift after the ceiling that exists to bound the chain. Every quiet-to-loud transition would leave residual lift on material already at -1 dBFS, and the render shears that flat. A ceiling with something added after it is not a ceiling. Falsified by reverting to append. WIRING: "Even Out Levels" sits beside "Tone (EQ)" in the add menu, decoding in an OfflineAudioContext and locking the rack while it works, exactly as the carve does. The same control removes it once present, because pressing it twice never means "add a second levelling stage". The stage itself needs no bespoke module — it is a gain node carrying a label and a lane, and the rack already renders that correctly. THE TRAP, now covered: a script hands back a whole HfAutomation describing only its OWN lane, so writing it to the attribute would take the carve's per-band lanes and the track's volume lane with it — silently, totally, and noticed only later when the mix has lost its ducking. `withLane` merges by target instead, and removal takes the orphaned lane with the node. This had no test until the mutation survived; propertyPanelAutomation.test.ts now covers it and fails when withLane replaces the set. Core 1720 -> 1721, studio 3668 -> 3674. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
De-esser is deferred with its two design constraints written down, because neither is visible until you try to build it: analyseCarveDynamics' 85-150 ms hop cannot land on 50-150 ms sibilants and needs re-parameterising, and MAX_AUTOMATION_POINTS is 512 while a long voiceover holds hundreds of sibilant events at three or four points each — so a naive lane blows the cap and the scheduler truncates it silently, leaving an envelope that stops partway through the clip. The leveller is its structural template. Tone match is superseded for v1. It existed to let a casual author fix a track's tone without understanding frequencies, and the Tone EQ now does that with a control they already know and does it predictably. What is left is matching one track to another, which is genuinely advanced and carries real unknowns nobody has asked us to resolve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vanceingalls
force-pushed
the
wa-20a-fx-presets
branch
from
August 10, 2026 22:39
2a03557 to
9bfbace
Compare
Collaborator
Author
|
Superseded — split into smaller PRs to stay under 1000 lines each. The same commits now land across the chain from #3173 upward; no code changed, only the boundaries. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Presets, a multi-band EQ and a levelling script for the audio FX rack.
The starting point was an observation, not a review finding: voice carve is a
tool that uses the effects to give the author something they can actually
understand — find the other features like it, and ship presets so nobody has to
learn what a peaking filter is. Colour grading's preset set was the named
precedent.
What ships
18 presets in 4 families — voice 3, repair 4, character 6, space 5
(
packages/core/src/audioFxPresets.ts). Applying one appends by default:stacking a character preset onto an already-cleaned voice is a real thing to
want, and replacing silently throws work away, so the destructive option is a
separate gesture. Re-applying a preset that is already present replaces its own
nodes in place, because position in the chain is signal order and signal
order is audible.
Every preset node is named for the job it does —
"Remove Rumble", nothighpass. A chain that cuts mud and then lifts clarity must not show the samename twice.
Tone (EQ) —
HF_AUDIO_EQ_3(Bass/Middle/Treble) andHF_AUDIO_EQ_5(Bass/Warmth/Middle/Clarity/Air), built as a composite over existing
filters rather than a new effect type, the same device the carve already
uses. Partly a design choice — users know what an EQ is, they don't know they
want five peaking filters — and partly forced: the effect registry's params are
flat key/value and cannot express a band array. The chain is authoritative;
bands are read back out of the tagged nodes on every render, so there is no
cached list to fall out of sync.
"Even Out Levels" — an adaptive script, the same shape as carve: measure in
an
OfflineAudioContext, then generate an automation lane. Two things thetests forced, both of which matter:
an absolute level. The first version derived it from the peak, which pulled an
already-even track bodily up or down — a volume change wearing a levelling
label.
gainnode, not the track volume, becauseVOLUME_RANGEis 0..1 and can only attenuate; levelling has to be able to lift.
Two things worth a reviewer's attention
The leveller goes in FRONT of a trailing limiter (
insertAt, not append).The likely sequence is "apply Clean Voice, then even out the levels", and Clean
Voice ends in a Peak Ceiling — appending put up to 12 dB of lift after the
ceiling that exists to bound the chain. A ceiling with something after it is not
a ceiling. Same bug class as the clip-before-duck fix below this PR.
withLanemerges by target. A script hands back a wholeHfAutomationdescribing only its own lane; writing that to the attribute takes the carve's
per-band lanes and the track's volume lane with it — silently, and noticed only
when the mix has lost its ducking. This had no test until the mutation survived
one.
Invariant added here
Not hypothetical:
a533d1677addedfromPresetto the type and the writer butnot the parser, so the tag was silently dropped on every reload and a preset
could no longer find its own nodes. The round-trip test passed the whole time
because it compared only node types. Fixed in
29f53f935; that test nowcompares type, id,
fromPresetandlabel.Design docs
Six commits of design work ride along, including the review page generated from
the real registry (
bun plans/audio-fx-ux/build-preview.mts /tmp/rack-ux.html).That build fails if any shipped effect, parameter or preset lacks copy, so
the page cannot quietly omit something real.
Deliberately not built
De-esser and tone match, with reasons in
plans/audio-fx-presets.md§6c. Thede-esser has two hard constraints —
analyseCarveDynamics' 85–150 ms hop cannotland on a 50–150 ms sibilant, and
MAX_AUTOMATION_POINTSis 512 where a longvoiceover needs hundreds of events.
Tests
core 1721 (110 files) · studio 3674 + 18 todo. Falsification notes in the
commit bodies.
🤖 Generated with Claude Code