Files
hyperframes/packages/core/src/audioFxPresets.ts
T
Vance IngallsandClaude Sonnet 5 5e0cc75115 feat(core,studio): the character presets pitch shift unlocks (#3277)
Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet
P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with
weight and a compressor to hold the extra low end together, Monster pitches
down further with saturation growl and a close, tight reverb. Every param
verified against the live effect registry rather than sketched — the
compressor/reverb/saturate/shelf keys all match exactly.

Each gets its own title treatment (font, size, tracking, hue) so the FX
rack's per-preset styling coverage and hue-distance/background-uniqueness
tests extend cleanly to the three new entries, and complaint-line copy in
the non-voice vocabulary the audit test enforces (no speech words — "Giant"
over CapCut's "Deep Voice", as the design doc records).

Updates plans/audio-fx-presets.md's two limits paragraphs to record that
pitch shift landed and this half of the character list now ships; Robot and
Alien stay out of scope (ring modulation, still unbuilt).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:08:32 -07:00

489 lines
19 KiB
TypeScript

/**
* Named starting points for the FX rack.
*
* Voice carve turned a pile of effects into one understandable feature. These
* do the same for the cases an analysis cannot decide: a preset is a chain
* somebody already tuned, applied in one click and editable immediately
* afterwards.
*
* A preset is DATA, deliberately. Applying one writes ordinary nodes into
* `data-fx-chain` — the same nodes hand-building would produce — so there is no
* second code path to keep in agreement with the rack, nothing new in the
* render, and no failure mode the chain does not already have. The author can
* see everything that was written on their behalf and change any of it.
*
* Only the parameters a preset actually means are listed; `normalizeAudioFxParams`
* fills the rest from the effect's own defaults. That keeps each entry readable
* as an intent rather than a dump of every knob.
*
* Node ORDER is load-bearing — the chain is serial, so a limiter first and a
* limiter last are different sounds. Every preset below runs
* subtractive filtering → dynamics → tone → character → limiter, the order
* `skills/hyperframes-audio` already teaches.
*/
import {
HF_AUDIO_FX_CHAIN_VERSION,
mintAudioFxNodeId,
normalizeAudioFxParams,
type HfAudioFxChain,
type HfAudioFxNode,
type HfAudioFxParamValues,
} from "./audioFx.js";
/**
* Which shelf of the menu a preset sits on.
*
* Deliberately NOT the effect registry's own `group` (filter/dynamics/…): that
* groups by what an effect *is*, and an author picking a preset is shopping for
* what they *want*. "Telephone" is filters and saturation; nobody looks for it
* under either.
*/
export type HfAudioFxPresetFamily = "voice" | "repair" | "character" | "space";
export interface HfAudioFxPresetNode {
/** Effect id from HF_AUDIO_FX. */
type: string;
/**
* What the rack calls this node — the JOB it is doing, not its filter type.
*
* A peaking filter is "Shape One Range" wherever it appears, so a chain that
* cuts mud and then lifts clarity shows the same words twice and an author
* cannot follow it. Naming each node for its job is what lets a preset read
* as a list of things that were done.
*/
label?: string;
/** Only what this preset means to set; the rest come from the effect's defaults. */
params?: HfAudioFxParamValues;
}
export interface HfAudioFxPreset {
id: string;
label: string;
family: HfAudioFxPresetFamily;
/** One line, in the author's language — what it does, not which effects it uses. */
description: string;
nodes: readonly HfAudioFxPresetNode[];
}
const preset = (
id: string,
family: HfAudioFxPresetFamily,
label: string,
description: string,
nodes: readonly HfAudioFxPresetNode[],
): HfAudioFxPreset => ({ id, label, family, description, nodes });
/**
* A 24 dB/oct skirt is two of these stacked: `poles` tops out at 2 (12 dB/oct)
* because a BiquadFilterNode is two-pole and that is the honest maximum for one
* node. The telephone band wants the steeper slope, so it pays for two.
*/
const steep = (
type: "highpass" | "lowpass",
frequency: number,
label: string,
): HfAudioFxPresetNode[] => [
{ type, label, params: { frequency, q: 0.707, poles: "2" } },
{ type, label, params: { frequency, q: 0.707, poles: "2" } },
];
export const HF_AUDIO_FX_PRESETS: readonly HfAudioFxPreset[] = [
// ---------------------------------------------------------------- voice --
preset(
"voice-clean",
"voice",
"Clean Voice",
"Cuts rumble and mud, evens out the level, adds a little clarity.",
[
{ type: "highpass", label: "Remove Rumble", params: { frequency: 80, q: 0.707, poles: "2" } },
{ type: "peaking", label: "Reduce Mud", params: { frequency: 250, gain: -3, q: 1.2 } },
{
type: "compressor",
label: "Even Out Loudness",
params: { threshold: -20, ratio: 3, attack: 12, release: 180, makeup: 3 },
},
{ type: "peaking", label: "Add Clarity", params: { frequency: 3000, gain: 2.5, q: 1 } },
{ type: "limiter", label: "Peak Ceiling", params: { limit: -1, attack: 5, release: 50 } },
],
),
preset(
"voice-broadcast",
"voice",
"Broadcast",
"Denser and more forward — a radio-presenter sound.",
[
{ type: "highpass", label: "Remove Rumble", params: { frequency: 90, q: 0.707, poles: "2" } },
{ type: "peaking", label: "Reduce Boxiness", params: { frequency: 400, gain: -3, q: 1.4 } },
{
type: "compressor",
label: "Even Out Loudness",
params: { threshold: -24, ratio: 4, attack: 8, release: 150, makeup: 5 },
},
{ type: "peaking", label: "Add Clarity", params: { frequency: 2500, gain: 3, q: 0.9 } },
{ type: "highshelf", label: "Add Air", params: { frequency: 8000, gain: 2 } },
{ type: "saturate", label: "Warmth", params: { type: "tanh", threshold: -12, output: 0 } },
{ type: "limiter", label: "Peak Ceiling", params: { limit: -1, attack: 5, release: 60 } },
],
),
preset(
"voice-warm",
"voice",
"Close & Warm",
"Intimate and lightly handled, for a voice close to the mic.",
[
{ type: "highpass", label: "Remove Rumble", params: { frequency: 70, q: 0.707, poles: "2" } },
{ type: "lowshelf", label: "Add Weight", params: { frequency: 180, gain: 2 } },
{
type: "compressor",
label: "Even Out Loudness",
params: { threshold: -18, ratio: 2.5, attack: 20, release: 250, makeup: 2 },
},
{ type: "peaking", label: "Add Clarity", params: { frequency: 3000, gain: 1.5, q: 0.8 } },
{ type: "limiter", label: "Peak Ceiling", params: { limit: -1.5 } },
],
),
// --------------------------------------------------------------- repair --
// Named for what they DO. None of these is noise reduction: that needs
// spectral work this effect set does not have, and a preset implying
// otherwise would be a lie the author only discovers after trusting it.
preset(
"rumble-cut",
"repair",
"Cut Rumble",
"Removes traffic, handling and air-conditioning from under a voice.",
[{ type: "highpass", label: "Cut Rumble", params: { frequency: 100, q: 0.707, poles: "2" } }],
),
preset(
"room-gate",
"repair",
"Quiet Between Phrases",
"Silences the gaps between words. Room tone under speech stays — this closes the pauses, it does not remove noise.",
[
{
type: "gate",
label: "Silence the Gaps",
params: { threshold: -45, range: -18, ratio: 10, attack: 2, release: 180 },
},
],
),
preset(
"boom-tame",
"repair",
"Tame Boominess",
"Takes out the chestiness of a voice too close to the mic.",
[{ type: "peaking", label: "Tame Boominess", params: { frequency: 200, gain: -4, q: 1.4 } }],
),
preset(
"harsh-tame",
"repair",
"Soften Harshness",
"Rounds off a brittle upper-mid. Broad and always-on; sibilance proper wants the measuring version.",
[{ type: "peaking", label: "Soften Harshness", params: { frequency: 3200, gain: -3, q: 1.6 } }],
),
// ------------------------------------------------------------ character --
preset(
"telephone",
"character",
"Telephone",
"Down the line — the narrow band of a phone call.",
[
...steep("highpass", 300, "Strip the Bass"),
...steep("lowpass", 3400, "Strip the Treble"),
{ type: "peaking", label: "Phone Honk", params: { frequency: 1200, gain: 6, q: 1.2 } },
{ type: "peaking", label: "De-mud", params: { frequency: 550, gain: -4, q: 1 } },
{
type: "saturate",
label: "Circuit Grit",
params: { type: "tanh", threshold: -9, output: -2 },
},
],
),
preset("radio-am", "character", "AM Radio", "Narrow, gritty and a little crushed.", [
// Narrower than the megaphone at BOTH ends, which is most of the difference
// between them: an AM channel is a few kHz wide and the receiver rolls off
// well before a horn does.
{ type: "highpass", label: "Strip the Bass", params: { frequency: 220, q: 0.707, poles: "2" } },
{
type: "lowpass",
label: "Strip the Treble",
params: { frequency: 2200, q: 0.707, poles: "2" },
},
// Where the telephone honks, a receiver DIPS: the IF filter's droop, and the
// reason the two stop sounding alike. Telephone's band is its identity (it
// is the G.712 passband), so the radio is what moves.
{ type: "peaking", label: "IF Droop", params: { frequency: 1200, gain: -5, q: 0.9 } },
// And a lift at the bottom of the band — AM is boxy where a phone is thin.
{ type: "lowshelf", label: "Boxy", params: { frequency: 500, gain: 4 } },
// Soft — a receiver compressing, not a driver being overdriven. `tanh`
// rounds the peaks where the megaphone's `hard` clips them flat.
{ type: "saturate", label: "Radio Grit", params: { type: "tanh", threshold: -8, output: -1 } },
// The crush is the AM signature: quantisation noise reads as carrier hiss,
// and it is the one thing the megaphone has none of.
{ type: "bitcrush", label: "Carrier Hiss", params: { bits: 8, samples: 1, mix: 0.45 } },
]),
preset(
"megaphone",
"character",
"Megaphone",
"Shouted through a horn, with the slap that comes with it.",
[
{
type: "highpass",
label: "Strip the Bass",
params: { frequency: 700, q: 0.707, poles: "2" },
},
{
type: "lowpass",
label: "Strip the Treble",
params: { frequency: 4000, q: 0.707, poles: "2" },
},
// A horn is a resonant tube and that resonance IS the sound: one narrow
// peak with a second ringing above it, where the radio has none at all.
{ type: "peaking", label: "Horn Honk", params: { frequency: 1900, gain: 14, q: 3 } },
{ type: "peaking", label: "Horn Ring", params: { frequency: 3200, gain: 6, q: 3 } },
// A driver pushed past its limit — flat-topped, not rounded. Measured on a
// log sweep, the threshold is the whole ballgame: at -14 the clipper
// flattened the response to a dead -19 dB line and ERASED the horn peaks
// above, leaving this indistinguishable from AM Radio. Backed off until
// the resonance survives the clipping that is supposed to sit on top of it.
{
type: "saturate",
label: "Overdrive",
params: { type: "hard", threshold: -5, output: -3 },
},
// The outdoor reflection that comes back off whatever is being shouted at.
// Far enough to be a slap rather than a thickening.
{ type: "delay", label: "Horn Slap", params: { time: 65, feedback: 0.2, mix: 0.28 } },
],
),
preset(
"lofi-tape",
"character",
"Tape",
"Worn, warm and slightly unsteady, like a played-out cassette.",
[
{ type: "lowpass", label: "Tape Rolloff", params: { frequency: 6500, q: 0.707, poles: "2" } },
{ type: "lowshelf", label: "Add Weight", params: { frequency: 120, gain: 2 } },
{
type: "saturate",
label: "Tape Warmth",
params: { type: "tanh", threshold: -14, output: 0 },
},
{ type: "bitcrush", label: "Tape Noise", params: { bits: 12, samples: 2, mix: 0.35 } },
// A slow, shallow chorus is what wow and flutter actually are.
{
type: "chorus",
label: "Wow & Flutter",
params: { delay: 6, depth: 0.6, speed: 0.4, mix: 0.15 },
},
],
),
preset("pa-system", "character", "Tannoy", "Announced across a concourse.", [
{ type: "highpass", label: "Strip the Bass", params: { frequency: 250, q: 0.707, poles: "2" } },
{
type: "lowpass",
label: "Strip the Treble",
params: { frequency: 5000, q: 0.707, poles: "2" },
},
// Higher and harder than the telephone's honk, which is what a big horn
// does — and it keeps the top the phone throws away.
{ type: "peaking", label: "Tannoy Honk", params: { frequency: 2400, gain: 9, q: 2 } },
{
type: "saturate",
label: "Driver Grit",
params: { type: "tanh", threshold: -10, output: -1 },
},
{
type: "reverb",
label: "Concourse",
params: { size: 0.54, damping: 0.48, wet: 0.25, dry: 0.8 },
},
]),
preset("intercom", "character", "Intercom", "Buzzed through a door panel, squelch and all.", [
{
type: "gate",
label: "Squelch",
params: { threshold: -40, range: -30, ratio: 10, attack: 1, release: 120 },
},
{ type: "highpass", label: "Strip the Bass", params: { frequency: 500, q: 0.707, poles: "2" } },
{
type: "lowpass",
label: "Strip the Treble",
params: { frequency: 3000, q: 0.707, poles: "2" },
},
{ type: "peaking", label: "Panel Honk", params: { frequency: 2000, gain: 6, q: 2 } },
{ type: "bitcrush", label: "Crunch", params: { bits: 11, samples: 1, mix: 0.3 } },
]),
// The chorus with its wobble dialled up until it stops being width and starts
// being the effect: fast (10 Hz, the top of the range) and fully wet, so none
// of the straight signal is left to anchor the pitch.
preset(
"doofus-worble",
"character",
"Doofus Worble",
"Seasick and wobbling — no straight signal left.",
[
{
type: "chorus",
label: "Worble",
params: { delay: 14.6, depth: 2.57, speed: 10, mix: 1 },
},
],
),
preset("chipmunk", "character", "Chipmunk", "Small, fast and squeaky.", [
{ type: "pitchshift", label: "Up High", params: { semitones: 7, mix: 1 } },
{ type: "highshelf", label: "Extra Sparkle", params: { frequency: 4000, gain: 3 } },
]),
preset("giant", "character", "Giant", "Huge, slow and deep.", [
{ type: "pitchshift", label: "Down Low", params: { semitones: -5, mix: 1 } },
{ type: "lowshelf", label: "Add Weight", params: { frequency: 150, gain: 4 } },
{
type: "compressor",
label: "Hold It Together",
params: { threshold: -18, ratio: 3, attack: 10, release: 120, knee: 6, makeup: 2, mix: 1 },
},
]),
preset("monster", "character", "Monster", "Deep, rough and too close.", [
{ type: "pitchshift", label: "Down Low", params: { semitones: -8, mix: 1 } },
{ type: "saturate", label: "Growl", params: { type: "tanh", threshold: -12, output: -1 } },
{
type: "reverb",
label: "Right Behind You",
params: { size: 0.3, damping: 0.5, wet: 0.22, dry: 0.85 },
},
]),
// ---------------------------------------------------------------- space --
preset("room-tight", "space", "Tight Room", "A small hard room — presence without wash.", [
{
type: "reverb",
label: "Tight Room",
params: { size: 0.25, damping: 0.6, wet: 0.18, dry: 0.9 },
},
]),
preset(
"room-natural",
"space",
"Natural Room",
"Sounds recorded somewhere rather than nowhere.",
[
{
type: "reverb",
label: "Natural Room",
params: { size: 0.5, damping: 0.5, wet: 0.25, dry: 0.85 },
},
],
),
preset("hall", "space", "Hall", "Long and open, for something that should sit far back.", [
{ type: "reverb", label: "Hall", params: { size: 0.9, damping: 0.3, wet: 0.4, dry: 0.75 } },
]),
preset("slap-echo", "space", "Slap Echo", "One quick repeat — rockabilly vocal, not a wash.", [
{ type: "delay", label: "Slap Echo", params: { time: 110, feedback: 0.12, mix: 0.22 } },
]),
preset("dub-throw", "space", "Dub Throw", "Repeats that trail off well behind the beat.", [
{ type: "delay", label: "Dub Throw", params: { time: 375, feedback: 0.55, mix: 0.3 } },
]),
];
export const HF_AUDIO_FX_PRESET_IDS: readonly string[] = HF_AUDIO_FX_PRESETS.map((p) => p.id);
const BY_ID = new Map(HF_AUDIO_FX_PRESETS.map((p) => [p.id, p]));
export function getAudioFxPreset(id: string): HfAudioFxPreset | undefined {
return BY_ID.get(id);
}
/** Menu order: the shelves, in the order the panel lists them. */
export const HF_AUDIO_FX_PRESET_FAMILIES: readonly HfAudioFxPresetFamily[] = [
"voice",
"repair",
"character",
"space",
];
export function audioFxPresetsByFamily(family: HfAudioFxPresetFamily): HfAudioFxPreset[] {
return HF_AUDIO_FX_PRESETS.filter((p) => p.family === family);
}
/**
* Realise a preset as nodes ready to splice into `chain`.
*
* Ids are minted against the chain the nodes are joining, not against the
* preset, so applying the same preset twice cannot collide — and every node
* gets one, because an automation lane addresses its effect by id and a node
* without one can never be automated.
*
* Params are normalised here rather than at apply time: a preset that names a
* value the effect would clamp should land in the file as the value that will
* actually be rendered, so the rack never shows a number the graph is not using.
*/
export function audioFxPresetNodes(
preset: HfAudioFxPreset,
chain: HfAudioFxChain,
): HfAudioFxNode[] {
const out: HfAudioFxNode[] = [];
// Minted against a growing chain, so ids are unique within this batch too.
let running: HfAudioFxChain = { ...chain, nodes: [...chain.nodes] };
for (const node of preset.nodes) {
const made: HfAudioFxNode = {
type: node.type,
id: mintAudioFxNodeId(running),
fromPreset: preset.id,
...(node.label ? { label: node.label } : {}),
enabled: true,
params: normalizeAudioFxParams(node.type, node.params),
};
out.push(made);
running = { ...running, nodes: [...running.nodes, made] };
}
return out;
}
/**
* The chain after applying a preset.
*
* Appends by default: stacking Telephone onto an already-cleaned voice is a
* real thing to want, and replacing silently would throw away work. Re-applying
* a preset that is already present replaces ITS OWN nodes in place instead of
* adding a second copy — which is what `fromPreset` is for, and mirrors how the
* carve replaces its own bands rather than stacking new ones on hand-added
* effects.
*/
export function applyAudioFxPreset(
chain: HfAudioFxChain,
preset: HfAudioFxPreset,
options: { replaceChain?: boolean } = {},
): HfAudioFxChain {
if (options.replaceChain) {
return {
version: HF_AUDIO_FX_CHAIN_VERSION,
nodes: audioFxPresetNodes(preset, { version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] }),
};
}
const existing = chain.nodes.findIndex((n) => n.fromPreset === preset.id);
if (existing === -1) {
return { ...chain, nodes: [...chain.nodes, ...audioFxPresetNodes(preset, chain)] };
}
// Re-apply: drop this preset's old nodes, then rebuild them where the first
// one stood, so the preset keeps its place in the signal order.
const kept = chain.nodes.filter((n) => n.fromPreset !== preset.id);
const before = kept.slice(0, existing);
const after = kept.slice(existing);
const made = audioFxPresetNodes(preset, { ...chain, nodes: kept });
return { ...chain, nodes: [...before, ...made, ...after] };
}
/** Every preset whose nodes are still present, for the rack's group braces. */
export function activeAudioFxPresetIds(chain: HfAudioFxChain): string[] {
const seen: string[] = [];
for (const node of chain.nodes) {
if (node.fromPreset && !seen.includes(node.fromPreset)) seen.push(node.fromPreset);
}
return seen;
}