diff --git a/packages/core/src/audio/audioFxGraph.test.ts b/packages/core/src/audio/audioFxGraph.test.ts index 316deff7a..f1eca2b74 100644 --- a/packages/core/src/audio/audioFxGraph.test.ts +++ b/packages/core/src/audio/audioFxGraph.test.ts @@ -216,6 +216,39 @@ describe("buildFxChain", () => { expect(c.created.find((x) => x.kind === "biquad")!.frequency.value).toBe(2000); }); + it("lines new values up against the built nodes, skipping a bypassed one", () => { + // A bypassed node is not in the graph, so the update has to walk the + // ENABLED nodes to stay aligned with what was built. Walking `next.nodes` + // instead shifts everything after the bypass by one and pushes each node's + // parameters into its neighbour — and the shape is unchanged either way, + // so nothing forces a rebuild that would hide it. + const c = ctx(); + const withBypass = (frequency: number): HfAudioFxChain => ({ + version: 1, + nodes: [ + { + type: "highpass", + enabled: true, + params: { ...defaultAudioFxParams("highpass"), frequency: 100 }, + }, + { type: "peaking", enabled: false, params: defaultAudioFxParams("peaking") }, + { + type: "lowpass", + enabled: true, + params: { ...defaultAudioFxParams("lowpass"), frequency }, + }, + ], + }); + const h = buildFxChain(asCtx(c), withBypass(8000)); + const biquads = c.created.filter((n) => n.kind === "biquad"); + expect(biquads).toHaveLength(2); + + expect(h.update(withBypass(3000))).toBe(true); + // The lowpass took the new cutoff; the highpass was left where it was. + expect(biquads[0]!.frequency.value).toBe(100); + expect(biquads[1]!.frequency.value).toBe(3000); + }); + it("reports that a rebuild is needed when the chain shape changes", () => { const c = ctx(); const h = buildFxChain(asCtx(c), chain("peaking")); diff --git a/packages/core/src/audio/audioFxGraph.ts b/packages/core/src/audio/audioFxGraph.ts index f7c7130cb..bcd0c8c7c 100644 --- a/packages/core/src/audio/audioFxGraph.ts +++ b/packages/core/src/audio/audioFxGraph.ts @@ -9,6 +9,7 @@ */ import { + enabledAudioFxNodes, getAudioFxDef, normalizeAudioFxParams, type HfAudioFxChain, @@ -484,8 +485,7 @@ export interface FxChainHandle { * into the running nodes; when it changes, the caller rebuilds. */ function shapeOf(chain: HfAudioFxChain): string { - return chain.nodes - .filter((node) => node.enabled !== false) + return enabledAudioFxNodes(chain) .map((node) => { const p = normalizeAudioFxParams(node.type, node.params); const poles = p.poles !== undefined ? `:${p.poles}` : ""; @@ -510,8 +510,7 @@ export function buildFxChain(ctx: BaseAudioContext, chain: HfAudioFxChain): FxCh const handles: { id?: string; type: string; handle: FxNodeHandle }[] = []; let tail: AudioNode = input; - for (const node of chain.nodes) { - if (node.enabled === false) continue; + for (const node of enabledAudioFxNodes(chain)) { const handle = buildFxNode(ctx, node.type, node.params ?? {}); tail.connect(handle.input); tail = handle.output; @@ -519,7 +518,7 @@ export function buildFxChain(ctx: BaseAudioContext, chain: HfAudioFxChain): FxCh } tail.connect(output); - let shape = shapeOf(chain); + const shape = shapeOf(chain); return { input, @@ -527,8 +526,7 @@ export function buildFxChain(ctx: BaseAudioContext, chain: HfAudioFxChain): FxCh nodes: handles, update(next) { if (shapeOf(next) !== shape) return false; - const active = next.nodes.filter((node) => node.enabled !== false); - active.forEach((node, i) => { + enabledAudioFxNodes(next).forEach((node, i) => { const held = handles[i]; if (!held) return; held.handle.update(normalizeAudioFxParams(node.type, node.params)); @@ -541,7 +539,9 @@ export function buildFxChain(ctx: BaseAudioContext, chain: HfAudioFxChain): FxCh if (node.id === undefined) delete held.id; else held.id = node.id; }); - shape = shapeOf(next); + // `shape` is not reassigned: the early return above already established + // that `shapeOf(next)` equals it, so recomputing was a whole normalise + + // join per observer tick to write back the string that was already there. return true; }, dispose() { diff --git a/packages/core/src/audioAutomation.test.ts b/packages/core/src/audioAutomation.test.ts index b73ef35cd..04e3a3b88 100644 --- a/packages/core/src/audioAutomation.test.ts +++ b/packages/core/src/audioAutomation.test.ts @@ -3,6 +3,8 @@ import { applyCurve, shapeProgress, fxAutomationTarget, + HF_AUDIO_AUTOMATION_ATTR, + HF_AUDIO_AUTOMATION_DATA_KEY, isConstantLane, parseAutomation, parseAutomationTarget, @@ -30,6 +32,14 @@ const lane = (points: HfAutomationLane["points"], target = "volume"): HfAutomati points, }); +describe("the automation attribute's two spellings", () => { + it("names the same attribute either way", () => { + // Same split as the FX chain: written as an attribute, read as a dataset + // key. Derived, so a rename cannot half-land. + expect(HF_AUDIO_AUTOMATION_ATTR).toBe(`data-${HF_AUDIO_AUTOMATION_DATA_KEY}`); + }); +}); + describe("targets", () => { it("reads volume and fx targets, and rejects anything else", () => { expect(parseAutomationTarget("volume")).toEqual({ kind: "volume" }); diff --git a/packages/core/src/audioAutomation.ts b/packages/core/src/audioAutomation.ts index a47edf13f..01b0ab7bf 100644 --- a/packages/core/src/audioAutomation.ts +++ b/packages/core/src/audioAutomation.ts @@ -11,10 +11,13 @@ * curve, three consumers, or the picture and the sound disagree. */ -import { getAudioFxDef, type HfAudioFxChain, type HfAudioFxNumberParam } from "./audioFx.js"; +import { getAudioFxDef, type HfAudioFxChain } from "./audioFx.js"; export const HF_AUDIO_AUTOMATION_ATTR = "data-automation"; +/** The same attribute as a `dataset` / `dataAttributes` key. See `HF_AUDIO_FX_DATA_KEY`. */ +export const HF_AUDIO_AUTOMATION_DATA_KEY = HF_AUDIO_AUTOMATION_ATTR.slice("data-".length); + /** Automation files are versioned; a reader must refuse a version it doesn't know. */ export const HF_AUDIO_AUTOMATION_VERSION = 1; @@ -138,15 +141,14 @@ export function resolveAutomationRange( const def = getAudioFxDef(node.type); const param = def?.params.find((p) => p.key === parsed.param); if (!param || param.kind !== "number") return null; - const p = param as HfAudioFxNumberParam; return { - min: p.min, - max: p.max, - step: p.step, - unit: p.unit, - label: `${def?.label ?? node.type} · ${p.label}`, - scale: p.scale === "log" && p.min > 0 ? "log" : "linear", - default: p.default, + min: param.min, + max: param.max, + step: param.step, + unit: param.unit, + label: `${def?.label ?? node.type} · ${param.label}`, + scale: param.scale === "log" && param.min > 0 ? "log" : "linear", + default: param.default, }; } diff --git a/packages/core/src/audioFx.test.ts b/packages/core/src/audioFx.test.ts index 3caaa2464..09b60ae66 100644 --- a/packages/core/src/audioFx.test.ts +++ b/packages/core/src/audioFx.test.ts @@ -5,7 +5,9 @@ import { enabledAudioFxNodes, getAudioFxDef, HF_AUDIO_FX, + HF_AUDIO_FX_ATTR, HF_AUDIO_FX_CHAIN_VERSION, + HF_AUDIO_FX_DATA_KEY, HF_AUDIO_FX_IDS, normalizeAudioFxParams, parseAudioFxChain, @@ -47,6 +49,15 @@ describe("effect registry", () => { }); }); +describe("the chain attribute's two spellings", () => { + it("names the same attribute either way", () => { + // The studio WRITES through the attribute and READS through the dataset + // key, so the read side used to carry its own `"fx-chain"` literal and a + // rename would only half-land. Derived now; this is what keeps it derived. + expect(HF_AUDIO_FX_ATTR).toBe(`data-${HF_AUDIO_FX_DATA_KEY}`); + }); +}); + describe("normalizeAudioFxParams", () => { it("fills missing keys with defaults", () => { expect(normalizeAudioFxParams("peaking", {})).toEqual(defaultAudioFxParams("peaking")); diff --git a/packages/core/src/audioFx.ts b/packages/core/src/audioFx.ts index 32be62bc6..8ab6c60cc 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -16,6 +16,16 @@ export const HF_AUDIO_FX_ATTR = "data-fx-chain"; +/** + * The same attribute as a `dataset` / `dataAttributes` key — the `data-` prefix + * is not part of that spelling. + * + * Derived rather than restated: the studio writes through + * `HF_AUDIO_FX_ATTR` and reads through the key, so a hardcoded `"fx-chain"` + * on the read side is a rename waiting to half-land. + */ +export const HF_AUDIO_FX_DATA_KEY = HF_AUDIO_FX_ATTR.slice("data-".length); + /** Chain files are versioned; a reader must refuse a version it doesn't know. */ export const HF_AUDIO_FX_CHAIN_VERSION = 1; diff --git a/packages/studio/src/components/editor/audioFxSummary.ts b/packages/studio/src/components/editor/audioFxSummary.ts index 441217a8a..a56ee5323 100644 --- a/packages/studio/src/components/editor/audioFxSummary.ts +++ b/packages/studio/src/components/editor/audioFxSummary.ts @@ -7,11 +7,11 @@ * grouping exists to prevent — that they are seven things to manage. */ -import { parseAudioFxChain } from "@hyperframes/core/audio-fx"; +import { HF_AUDIO_FX_DATA_KEY, parseAudioFxChain } from "@hyperframes/core/audio-fx"; import type { DomEditSelection } from "./domEditingTypes"; export function audioFxSummary(element: DomEditSelection): string { - const raw = element.dataAttributes?.["fx-chain"]; + const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY]; const carveAttr = element.dataAttributes?.["fx-carve"]; let handBuilt = 0; let carveNodes = 0; diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index c9eb2f972..287cd519a 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -11,6 +11,7 @@ import { useEffect, useState } from "react"; import { defaultAudioFxParams, HF_AUDIO_FX_ATTR, + HF_AUDIO_FX_DATA_KEY, mintAudioFxNodeId, parseAudioFxChain, serializeAudioFxChain, @@ -41,6 +42,7 @@ import { automatedTargetsOf, automationAttrValue, HF_AUDIO_AUTOMATION_ATTR, + HF_AUDIO_AUTOMATION_DATA_KEY, readPanelAutomation, resolveAutomationRange, withoutLane, @@ -109,7 +111,7 @@ export function AudioFxGroup({ onSetAttributeLive: (attr: string, value: string | null) => void | Promise; }) { const chain = ((): HfAudioFxChain => { - const raw = element.dataAttributes?.["fx-chain"]; + const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY]; if (!raw) return { version: 1, nodes: [] }; try { return parseAudioFxChain(raw); @@ -120,7 +122,10 @@ export function AudioFxGroup({ } })(); - const automation = readPanelAutomation(element.dataAttributes?.["automation"], chain); + const automation = readPanelAutomation( + element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY], + chain, + ); const automatedTargets = automatedTargetsOf(automation); /** @@ -485,11 +490,24 @@ export function AudioFxGroup({ const analyse = async (active: HfCarveSettings | null = carve): Promise => { if (!active?.sources.length) return; const doc = element.element?.ownerDocument; + if (!doc) return; // Every named voice that is actually there with something to decode. A source // naming a deleted track is skipped rather than failing the whole analysis. - const voices = active.sources - .map((id) => doc?.getElementById(id) as HTMLAudioElement | null) - .filter((el): el is HTMLAudioElement => Boolean(el?.getAttribute("src"))); + // + // Read out to plain values here rather than carrying elements around: it is + // what lets the src and the start be non-null by construction downstream + // instead of by assertion. + const voices: { src: string; start: string | null }[] = []; + for (const id of active.sources) { + const el = doc.getElementById(id); + // By tag name, not `instanceof HTMLAudioElement`: these elements belong to + // the composition's iframe document, so the constructor they were made + // from is not this realm's and the instanceof is false for every one. + if (el?.tagName !== "AUDIO") continue; + const src = el.getAttribute("src"); + if (!src) continue; + voices.push({ src, start: el.getAttribute("data-start") }); + } if (voices.length === 0) return; setAnalysing(true); try { @@ -502,7 +520,7 @@ export function AudioFxGroup({ .webkitOfflineAudioContext; if (!Ctor) return; const decode = async (relative: string): Promise => { - const res = await fetch(new URL(relative, doc!.baseURI).href); + const res = await fetch(new URL(relative, doc.baseURI).href); return new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(await res.arrayBuffer()); }; const bedStart = clipStart(element.dataAttributes?.["start"]); @@ -512,9 +530,9 @@ export function AudioFxGroup({ // is also what lets the bands and the envelopes stay a single set: the chain is // fixed, so there is no per-voice filter to switch between. const decoded = await Promise.all( - voices.map(async (el) => ({ - samples: (await decode(el.getAttribute("src")!)).getChannelData(0), - offsetSeconds: clipStart(el.getAttribute("data-start")) - bedStart, + voices.map(async (voice) => ({ + samples: (await decode(voice.src)).getChannelData(0), + offsetSeconds: clipStart(voice.start) - bedStart, })), ); const voiceMix = mixCarveSources(decoded, DECODE_SAMPLE_RATE); diff --git a/packages/studio/src/components/editor/propertyPanelAutomation.ts b/packages/studio/src/components/editor/propertyPanelAutomation.ts index d940eedfa..3469ce29f 100644 --- a/packages/studio/src/components/editor/propertyPanelAutomation.ts +++ b/packages/studio/src/components/editor/propertyPanelAutomation.ts @@ -8,6 +8,7 @@ import { HF_AUDIO_AUTOMATION_ATTR, + HF_AUDIO_AUTOMATION_DATA_KEY, parseAutomation, resolveAutomation, resolveAutomationRange, @@ -77,4 +78,4 @@ export function automationAttrValue(automation: HfAutomation): string { return automation.lanes.length > 0 ? serializeAutomation(automation) : ""; } -export { HF_AUDIO_AUTOMATION_ATTR, resolveAutomationRange }; +export { HF_AUDIO_AUTOMATION_ATTR, HF_AUDIO_AUTOMATION_DATA_KEY, resolveAutomationRange }; diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index 75fba8fc1..5b3906ab1 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -165,6 +165,55 @@ describe("FxSection chain", () => { expect(next.nodes.map((n) => n.type)).toEqual(["reverb", "peaking"]); }); + it("keeps a half-typed value with its own effect across a reorder", () => { + // Rows used to be keyed `${type}-${index}`, so two effects of the same type + // kept their keys through a reorder and React reused each row where it + // stood. The controls hold real state — a number field mid-edit is held as + // text — so the buffer stayed at the position and landed on whichever + // effect moved into it. + const peaking = (id: string, frequency: number) => ({ + type: "peaking", + id, + enabled: true, + params: { ...defaultAudioFxParams("peaking"), frequency }, + }); + const a = peaking("pa", 400); + const b = peaking("pb", 1600); + const chainOfNodes = (...nodes: unknown[]): HfAudioFxChain => + ({ version: 1, nodes }) as HfAudioFxChain; + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const render = (chain: HfAudioFxChain) => + act(() => { + root.render( + , + ); + }); + + render(chainOfNodes(a, b)); + // Only the first card is open, which is the one being edited. + const openFrequency = (): HTMLInputElement => + host.querySelector(".hf-fx-node .hf-fx-number")!; + + expect(openFrequency().value).toBe("400"); + typeInto(openFrequency(), "123"); + expect(openFrequency().value).toBe("123"); + + // The author moves that effect down; the other one takes the open slot. + render(chainOfNodes(b, a)); + + expect(openFrequency().value).toBe("1600"); + }); + it("cannot move the ends past themselves", () => { const { host } = mount({ chain: chainOf("peaking", "reverb") }); const ups = host.querySelectorAll('.hf-fx-move[title="Move up"]'); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index ab696119a..33fda2689 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -795,7 +795,13 @@ export function FxSection({ handBuilt.map(({ node, i }) => { return ( [0]): void => { // Quiet: clicking the toggle used to reload the preview and restart every // playing track, while the same click on an effect parameter did not. diff --git a/plans/audio-fx-presets.md b/plans/audio-fx-presets.md new file mode 100644 index 000000000..f13c63d79 --- /dev/null +++ b/plans/audio-fx-presets.md @@ -0,0 +1,473 @@ +# Audio treatment presets — research and proposed catalog + +Written 2026-08-09. The ask: voice carve turned a pile of effects into one +understandable feature; what else can, using only the effects already shipped? + +This is a research + design doc. Nothing here is built. + +--- + +## 1. The governing precedent + +**`wiki/decisions/vst-studio-integration-cancelled.md`** is the most important +prior art, and it is a warning. A VST spectral-carve integration was built to +completion — 4-PR stack, published PyPI host — and then **cancelled**, because +of its standing costs: + +- a spawned WebSocket sidecar with its own lifecycle +- an external GPL repo with its own release cadence +- a non-bundled runtime dependency users had to install +- VST logic permanently in the render path, hard-failing renders on a missing + plugin + +Everything proposed here is the **inverse of all four**: preset values are +data, and the adaptive scripts reuse analysis code that already ships. No new +process, no new dependency, no new render-path failure mode. A preset that goes +wrong produces a chain the author can see and edit in the rack — the same chain +they could have built by hand. + +That is the argument for doing this at all, and the bar any proposal here has +to keep clearing. + +### What already exists, so this does not duplicate it + +- **`skills/hyperframes-audio`** teaches the effect _families_ and when to + reach for each ("Reach for a family by the problem, not the name"), and + documents carve. It contains **no recipes and no named looks** — it explains + the why, and stops short of the one-click. This catalog is the complement, + not a rewrite. It also already fixes the canonical order this doc adopts: + _"corrective filtering goes early, character in the middle, and a limiter + last."_ +- **`carveBandsToChain()`** already proves the mechanism: an analysis produces + an ordinary `HfAudioFxChain`, which the rack then owns like any other. +- **Demand is real and internal.** Cortex carries user asks for + [audio reprocessing for studio acoustics](https://heygen.slack.com/archives/C0A9ZHLSQFN/p1781063751377189), + [voice loudness and gain boosting](https://heygen.slack.com/archives/C07BR8QRE4T/p1769062551169729), + and auto-enhance across scenes. Nobody has built a preset catalog for it. + +--- + +## 2. What consumer tools actually ship + +Three distinct tiers, and only two are reachable for us. + +### Tier 1 — ML enhancement (NOT reachable) + +Adobe Podcast Enhance Speech and Descript Studio Sound are **one button** that +strips background noise, echo and room reverb, and rebuild the voice as if +recorded in a studio. Adobe's v3 does source separation and exposes a strength +slider. This is generative ML, not filtering — there is no arrangement of EQ, +compression or gating that reaches it. Denoising and de-reverberation are the +single most-requested thing in our own internal feedback, and we cannot do +them with this effect set. Any catalog that quietly implies otherwise is lying. + +### Tier 2 — intent panels over conventional DSP (the model to copy) + +Premiere Pro's **Essential Sound** panel is the closest analogue to what is +being asked for, and it is the design worth stealing. The author tags a clip by +_what it is_ — Dialogue, Music, SFX, Ambience — and then gets a small set of +outcome-named controls rather than effects: + +- **Loudness**, with an Auto-Match that normalises to a broadcast target +- **Repair** — Reduce Noise, Reduce Rumble, DeHum, DeEss, Reduce Reverb, each a + single intensity slider +- **Clarity**, which boosts the bands that make consonants distinct +- **Creative**, for reverb/space + +Note the shape: **one noun per problem, one slider per noun, no frequencies on +screen.** That is exactly what carve does with its strength knob, and it is the +contract this catalog should hold to. + +### Tier 3 — character effects (fully reachable, and where consumer volume is) + +CapCut ships ~100 voice filters and ~100 "voice characters". The named ones +that recur across tools: Telephone, Megaphone, Radio/Radio Fuzz, Loudspeaker / +PA Speaker, Intercom, Lo-Fi, Cassette Tape, Cave, Echo, Broadcast, Retro. + +These are ordinary filter + saturation + bitcrush recipes and we can do all of +them. **The character half of CapCut's list we cannot do**: Chipmunk, Deep +Voice, Robot, Alien, Monster, Elf — every one of those is pitch, formant or +ring modulation, and the registry has no pitch shifter, no time-stretch and no +ring mod. Out of scope unless a new effect is added. + +--- + +## 3. What our 15 effects reach + +| Family | Effects | Reaches | +| --------- | ----------------------------------------------------- | ---------------------------------------------------------- | +| Filter | `highpass` `lowpass` `peaking` `lowshelf` `highshelf` | tone shaping, band isolation, rumble/mud removal, presence | +| Dynamics | `gain` `compressor` `limiter` `gate` | levelling, consistency, ceilings, room-tone gating | +| Nonlinear | `saturate` `bitcrush` | warmth, grit, distortion, digital degradation | +| Time | `delay` `reverb` `chorus` `phaser` | space, slap, width, wow/flutter, movement | + +Plus two force multipliers the catalog depends on: + +- **Automation lanes** on `gain` and on most effect parameters — so a preset can + be a _moving_ treatment, not just a static one. +- **Offline analysis** (`powerSpectrum`, `windowDb`, `analyseCarveDynamics`, + `analyseCarveDuck`) already runs in the panel over decoded audio. This is what + makes the adaptive tier possible without new machinery. + +**Hard limits, stated once:** no noise reduction, no de-reverberation, no pitch +or formant shift, no time-stretch, no spectral repair, no stereo widening +beyond `chorus`, no true multiband compression (though stacked peaking + a +`mix`-blended compressor approximates it). + +--- + +## 4. Two mechanisms, and carve is already the second + +The user's instinct to say "presets/**scripts**" is the right split. + +### Static presets — a chain literal + +Mirrors `HF_COLOR_GRADING_PRESETS` exactly: named, fixed parameter values, +one click, fully editable afterwards. Suits anything whose right answer does +not depend on the audio — every character effect, every space, and the +corrective voice chains at a sensible default. + +### Adaptive scripts — measure, then generate + +The carve pattern: read the decoded samples, measure something, emit a chain +and/or automation lanes. **The machinery for this is already built and shipped** +— what carve does with a voice against a bed generalises: + +| Script | Reuses | Emits | +| ----------------------------------- | -------------------------------------------------- | ----------------------------- | +| Voice carve _(shipped)_ | `analyseCarveBands` | peaking cuts + per-band lanes | +| Auto-duck _(shipped, inside carve)_ | `analyseCarveDuck` | one volume lane | +| De-esser | `analyseCarveDynamics`, re-parameterised (see §5e) | lane on a peaking cut | +| Leveller | `windowDb` walk | lane on a `gain` node | +| Tone match | `powerSpectrum` vs a target curve | 3–5 peaking nodes | + +This is the strongest argument in the doc: **none of them needs new DSP** — +only a different question asked of code that already runs. One (de-ess) needs +that code re-parameterised for a shorter timescale; the rest reuse it as-is. + +--- + +## 5. Proposed catalog + +Every value below is inside the registry's declared range. Node order is the +canonical one the skill already states: **gate → subtractive EQ → compressor → +presence EQ → saturation → limiter last.** + +### 5a. Voice (static) + +**`voice-clean` — "Clean Voice"** · the safe default, roughly Premiere's +Dialogue preset + +``` +highpass frequency 80 q 0.707 poles 2 +peaking frequency 250 gain -3 q 1.2 (mud) +compressor threshold -20 ratio 3 attack 12 release 180 makeup 3 +peaking frequency 3000 gain +2.5 q 1.0 (presence / consonants) +limiter limit -1 attack 5 release 50 +``` + +**`voice-broadcast` — "Broadcast"** · denser, more forward + +``` +highpass frequency 90 q 0.707 poles 2 +peaking frequency 400 gain -3 q 1.4 +compressor threshold -24 ratio 4 attack 8 release 150 makeup 5 +peaking frequency 2500 gain +3 q 0.9 +highshelf frequency 8000 gain +2 (air) +saturate type tanh threshold -12 output 0 (glue) +limiter limit -1 attack 5 release 60 +``` + +**`voice-warm` — "Close & Warm"** · intimate, less processed + +``` +highpass frequency 70 q 0.707 poles 2 +lowshelf frequency 180 gain +2 +compressor threshold -18 ratio 2.5 attack 20 release 250 makeup 2 +peaking frequency 3000 gain +1.5 q 0.8 +limiter limit -1.5 +``` + +Frequency choices are the documented conventions: high-pass at 60–80 Hz for +voice, 150–300 Hz is where proximity-effect mud lives, and presence for +consonant intelligibility sits around 2–4 kHz. + +### 5b. Repair — honestly named (static) + +These are the _reachable half_ of Premiere's Repair section. The names must not +promise noise removal. + +**`rumble-cut` — "Cut Rumble"** — `highpass` 100 Hz, poles 2. Two stacked nodes +for 24 dB/oct when a source is badly affected. + +**`room-gate` — "Quiet Between Phrases"** — `gate` threshold −45, range −18, +ratio 10, attack 2, release 180. **This gates, it does not denoise**: room tone +still sits under speech, it is only silenced in the gaps. Label it that way in +the UI or it will be mistaken for Tier 1. + +**`boom-tame` — "Tame Boominess"** — `peaking` 200 Hz, −4 dB, Q 1.4. + +**`harsh-tame` — "Soften Harshness"** — `peaking` 3.2 kHz, −3 dB, Q 1.6. Static +and broad on purpose; sibilance proper is a script (§5e), not a fixed cut. + +### 5c. Character (static) + +**`telephone` — "Telephone"** · the researched 300–3400 Hz band, steepened + +``` +highpass frequency 300 q 0.707 poles 2 ×2 stacked (24 dB/oct) +lowpass frequency 3400 q 0.707 poles 2 ×2 stacked +peaking frequency 1200 gain +6 q 1.2 (the "honk") +peaking frequency 550 gain -4 q 1.0 (de-mud) +saturate type tanh threshold -18 output -2 (circuit colour) +``` + +The registry maxes at 12 dB/oct per node (`poles: "2"`), so the classic +24 dB/oct skirts need **two stacked nodes each**. Worth encoding once here +rather than having every author rediscover it. + +**`radio-am` — "AM Radio"** — band 400–3000 Hz, `saturate` tanh threshold −15 +output −2, `bitcrush` bits 10 samples 1 mix 0.25. + +**`megaphone` — "Megaphone"** — highpass 500, lowpass 4000, `peaking` 1800 +8 +Q 1.5, `saturate` **hard** threshold −12 output −3, `delay` time 40 ms feedback +0.15 mix 0.15 (the horn's slap). + +**`lofi-tape` — "Tape"** — `lowpass` 6500, `lowshelf` 120 +2, `bitcrush` bits 12 +samples 2 mix 0.35, `saturate` tanh threshold −14, and **`chorus` delay 6, +depth 0.6, speed 0.4, mix 0.15** — a slow shallow chorus is exactly how you +fake wow and flutter, which is a genuinely nice use of an effect we have. + +**`pa-system` — "Tannoy / PA"** — band 350–3500, `peaking` 1500 +5 Q 1.2, +`saturate` tanh threshold −16, `reverb` size 0.5 damping 0.7 wet 0.25 dry 0.8. + +**`intercom` — "Intercom"** — band 500–3000, `peaking` 2000 +6 Q 2, +`bitcrush` bits 11 mix 0.3, `gate` threshold −40 range −30 (the squelch). + +### 5d. Space (static) + +**`room-tight`** — `reverb` size 0.25, damping 0.6, wet 0.18, dry 0.9 +**`room-natural`** — size 0.5, damping 0.5, wet 0.25, dry 0.85 +**`hall`** — size 0.9, damping 0.3, wet 0.4, dry 0.75 +**`slap-echo`** — `delay` time 110 ms, feedback 0.12, mix 0.22 +**`dub-throw`** — `delay` time 375 ms, feedback 0.55, mix 0.3 + +### 5e. Adaptive scripts + +**`de-ess` — "Soften Sibilance"** — _must be a script, not a preset._ A fixed +−6 dB at 7 kHz dulls the whole voice; a de-esser only acts when sibilance is +present. Run the carve pattern against the track's own 5–8 kHz band and emit a +`peaking` node at the measured centre with a **lane on its gain** that dips only +during sibilant windows. Sibilance centres around 5–6 kHz for lower voices, +7–8 kHz for higher — measure it rather than assuming. + +`analyseCarveDynamics` cannot be reused _unchanged_ here, and this is the one +place in §5e that needs work rather than a new caller. Its hop is +`max(FRAME, length / POINT_BUDGET)` — 85 ms at best and ~150 ms on a +real-length track — while sibilants are 50–150 ms events, so at that resolution +the envelope cannot land on them. Its `ATTACK_S`/`RELEASE_S` ballistics are +tuned for musical ducking and are far too slow as well. Same machinery, same +FFT, but it needs a sibilance-scale hop and much faster attack/release. + +**`level-out` — "Even Out Levels"** — walk `windowDb`, emit a lane that lifts +quiet passages and holds loud ones, against a target range rather than a fixed +gain. + +**The lane must ride a `gain` node, not the volume lane.** `VOLUME_RANGE` is +0..1 and `normaliseEnvelope` clamps every keyframe into it, so a volume lane can +only ever attenuate — it cannot lift anything. A `gain` node spans −60..+12 dB +and is automatable, which is exactly what the audio skill means when it calls +`gain` "what an automation lane rides when a track has to move". So this script +emits both a chain (one `gain` node) and the lane addressing it. **Do not call this LUFS.** `windowDb` is plain RMS; platform +targets (−14 LUFS for Spotify/YouTube/TikTok/Instagram, −16 for Apple +Music/podcasts) are ITU-R BS.1770 K-weighted and gated. Either implement the +K-weighting prefilter — two biquads, entirely feasible offline with the filters +we have — or name the feature "even out" and cite the targets as context only. + +**`tone-match` — "Match Tone"** — `powerSpectrum` of the track against a target +curve (a built-in "broadcast voice" curve, or another clip in the project), +emitting 3–5 corrective `peaking` nodes. This is iZotope's Tonal Balance idea +at a tenth of the complexity, and `powerSpectrum` already returns exactly the +data it needs. + +--- + +## 5f. Module identity in the rack + +The rack is a 292 px column of stacked modules — which is, near enough, a +Eurorack case. Worth leaning into: a faceplate recognised by colour and +lettering before the label is read. Carve already gets a distinct treatment +(`hf-fx-carve-module`); this generalises it. + +**Direction chosen 2026-08-09: schematic** — picked from four mocked +alternatives (hardware silkscreen, vintage test equipment, risograph, +schematic). Recorded with its principles in `.impeccable.md` at the repo root. + +The rack is drawn as the signal path it is: numbered nodes on a dashed spine +running from an `IN` terminal to an `OUT`, leader-dotted dimension lines, +three-letter stage tags, family colour restricted to the node ring and the tag. + +**Why it won over the louder options:** it is the only one that _adds +information_. Chain order is load-bearing — a limiter first and a limiter last +are different sounds — and nothing in the panel communicated it. It is also the +quietest, which matters on a surface authors stare at while mixing. + +**What the drawing carries that the current rack cannot say:** + +| Drawn as | Instead of | +| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| `IN` names the source, `OUT` names the destination and the FX tail | nothing — the tail was invisible | +| Numbered nodes 01…06 | implicit top-to-bottom order | +| Bypassed node: ring drawn open, wire beside it solid and unbroken | a row at 50% opacity | +| Preset nodes gathered under a right-hand brace with its name | no grouping at all | +| An automated parameter's value reads **live at the playhead** and ticks while the transport runs, marked `~` | the stale seed the lane already replaced | +| A measuring module gets a second ring | nothing distinguishes carve from a hand-set chain | + +That last pair is the static-versus-script distinction from §4, delivered as +drawing rather than documentation. + +**Automated parameters show numbers, not shapes.** An earlier pass drew each +lane's envelope inside the module; that was dropped. The lane already has a home +in the timeline, and a second small drawing of it in the rack is decoration — +what the rack is missing is the _current_ value. The panel already receives it: +`FxSectionProps.liveAutomationValues` exists precisely because "an automated +parameter's stored number is only the seed the lane replaced, so a rack that +shows it stands still while the carve is audibly working". So the row is an +ordinary parameter row whose number is live, `~` on the label saying it is +driven rather than set, and the marker moving with it. Stopped, the values go +grey and hold at the playhead. + +**The preset menu preview is the chain** — each row previews the nodes it will +draw as small connected rings in their family colours, so the shape you pick is +the shape you get. Smart entries read `MEASURES` instead. + +**The empty state keeps the wire.** `IN` and `OUT` remain, connected, with "no +effects, signal passes straight through" between them — which teaches that the +rack is a path before the author has added anything to it. + +**Not carried over from the first pass:** the 3 px coloured rail down each +module's left edge. It is the most overused device in dashboard UI, and +forcing each direction to find another answer is what separated them. + +**Family hues**, chosen to sit on `#0C0C0E` without competing with the studio's +`#3CE6AC` accent: + +| Family | Hue | Why | +| --------- | --------- | -------------------------------------------------------------------- | +| Filter | `#4FA8FF` | the measuring family | +| Dynamics | `#FFB443` | grips the signal | +| Nonlinear | `#FF6B5C` | the only generative family | +| Time | `#B98CFF` | atmosphere, not control | +| Smart | `#3CE6AC` | the studio's own accent — reserved for modules that act on their own | + +**This needs almost no new plumbing.** `group` is already on every effect in the +registry, so hue and lettering are derived, not hand-assigned. And every module +already renders `data-fx-node=""`, so the whole identity layer is CSS on +an attribute that exists today. Only "Smart" is new, and carve already lives +there in spirit. + +**Type budget is the real decision.** Fonts must be self-hosted (no CDN). +Recommended: **two faces** — the UI sans plus one characterful display face +split across families. One face is too subtle at 11 px; five is a bundle cost +and starts reading as a collage on a surface authors stare at while mixing. + +**Preset menu**: grouped by the same families, so the colour picked in the menu +is the colour that appears in the rack. Each row carries the number of modules +it drops in — which quietly teaches that a preset _is_ a chain. Smart entries +say "measures" instead of a count, which is the entire static-vs-adaptive +distinction delivered in one word. + +Open decisions: whether applying a preset appends or replaces (suggest append, +replace on modifier, re-apply swapping its own `fromPreset` nodes); the type +budget above; and whether character presets get drawn glyphs on their plates — +the biggest step toward "fun", and the only part needing artwork rather than +CSS. + +Mockup at the real panel width: published as an artifact, 2026-08-09. + +## 6. Architecture + +Mirror colour grading, because the author already understands that surface. + +```ts +export interface HfAudioFxPreset { + id: string; // "telephone" + label: string; // "Telephone" + family: "voice" | "repair" | "character" | "space"; + description: string; // one line, in the author's language + chain: HfAudioFxChain; // an ordinary chain — nothing special + automation?: HfAutomation; // for presets that move +} +``` + +Four notes: + +1. **A preset is just a chain.** Applying one writes `data-fx-chain` exactly as + hand-building would. It is inspectable, editable and undoable, and it cannot + introduce a failure mode the rack does not already have. +2. **Tag the nodes.** `HfAudioFxNode` already carries `fromCarve`; an analogous + `fromPreset: "telephone"` lets the panel show "Telephone (edited)" and lets a + re-apply replace its own nodes without touching hand-added ones. +3. **Fixed values in v1, no intensity knob.** Colour grading's presets are + fixed; carve earned its strength knob by being adaptive. Most effects carry a + `mix` parameter, which is the natural hook if intensity is wanted later — + note it, don't build it. +4. **Scripts need a separate registry** with an `analyse(samples, sampleRate) → +{ chain, automation }` contract, because they cannot be static data. Carve is + the reference implementation. + +### Two things that will otherwise be filed as bugs + +- **Boost presets must end in a limiter.** `voice-broadcast` adds presence and + +5 dB makeup; without its limiter it will overshoot full scale. The + clip-before-duck fix (`55f852a72`) makes that survivable, but the measured + damage still scales with overshoot — 2.7× peak destroys 19% of samples. A + preset that ships hot is a bad preset regardless of what the render does + about it. +- **Worklet-backed presets play dry for a moment in preview.** Anything + containing `compressor`, `limiter`, `gate` or `bitcrush` waits for + `ensureAudioFxWorklets` before it can be built — so nearly every voice preset + is momentarily dry on first apply, then swaps in. Expected, not a defect. + +--- + +## 7. What to build first + +1. **Character presets** (§5c) — highest ratio of delight to risk. Pure + filter/saturation data, instantly recognisable, nothing to measure, and the + category consumer tools get the most use out of. +2. **Voice presets** (§5a/b) — the practical value, and the answer to the + internal asks already in cortex. +3. **`level-out`** — the most-wanted adaptive one, and the smallest script. +4. **`de-ess`**, then **`tone-match`** — both reuse carve's analysis directly. + +Deliberately not proposed: anything requiring a new effect. If pitch shifting +ever lands, the whole character half of CapCut's list opens up at once — but +that is a new DSP conversation, and this doc is explicitly the one that does not +need one. + +### If this ships + +`CLAUDE.md`'s skill-catalog rules apply: a preset catalog changes what +`/hyperframes-audio` covers, so the skill's `description:` and the surfaces +listed under "Skill catalog maintenance" have to move in lockstep. + +--- + +## Sources + +Internal: `wiki/decisions/vst-studio-integration-cancelled.md`; +`skills/hyperframes-audio/SKILL.md`; `packages/core/src/audioFx.ts` (ranges); +`packages/core/src/audioCarve.ts` (the script pattern); +`packages/core/src/colorGrading.ts` (the preset pattern). Cortex: +[studio acoustics reprocessing](https://heygen.slack.com/archives/C0A9ZHLSQFN/p1781063751377189), +[voice loudness and gain boosting](https://heygen.slack.com/archives/C07BR8QRE4T/p1769062551169729). + +External: + +- [Adobe Podcast Enhance Speech guide](https://thepodcastconsultant.com/blog/adobe-podcast-enhance) and [Adobe vs Descript shootout](https://thepodcasthaven.com/adobe-speech-enhancement-vs-descript-studio-sound-a-shootout/) — Tier 1 boundary +- [Premiere Pro Essential Sound panel guide](https://josephnilo.com/blog/the-ultimate-guide-to-the-premiere-pro-essential-sound-panel/) and [Envato's dialogue walkthrough](https://photography.tutsplus.com/articles/how-to-use-the-essential-sound-panel-to-edit-dialogue-in-premiere-pro--cms-41936) — the intent-panel model +- [Rode's podcast processing guide](https://rode.com/en-us/about/news-info/a-guide-to-audio-processing-and-fx-for-podcasting), [Podigy on podcast EQ](https://www.podigy.co/podcasters-eq) and [iZotope on de-essing](https://www.izotope.com/en/learn/the-dos-and-donts-of-de-essing.html) — voice chain values and sibilance ranges +- [CapCut voice filters](https://www.capcut.com/tools/voice-filters) and [its full effect list]() — Tier 3 vocabulary +- [Telephone effect settings](https://voxbooster.com/blog/telephone-voice-effect-online/) and [Audiotent's walkthrough](https://www.audiotent.com/blogs/production-tips/create-telephone-vocal-effect) — the 300–3400 Hz band and its refinements +- [LUFS targets per platform 2026](https://www.forasoft.com/learn/audio-for-video/articles-audio/lufs-targets-per-platform-2026) and [podcast loudness standards](https://sone.app/blog/podcast-loudness-standards-2026-spotify-apple-youtube) — why `level-out` must not claim LUFS