Files
hyperframes/packages/core/src/audioFxCopy.ts
T
Vance IngallsandClaude Sonnet 5 6c128620e9 feat(core,studio): the character presets pitch shift unlocks
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-20 02:08:26 -07:00

413 lines
16 KiB
TypeScript

/**
* The plain-language layer over the effect registry.
*
* Every entry is written for somebody who has never opened a mixer. The rule
* used throughout: name the OUTCOME, never the mechanism, and describe a control
* by what changes in the sound rather than what it does to the signal.
*
* This is a layer *over* the registry, not a replacement for it. `HF_AUDIO_FX`
* stays the authority on what an effect is and what its parameters do; this says
* what to call those things in front of an author. `audioFxCopy.test.ts` holds
* the two together — every shipped effect, every one of its parameters, and
* every preset must have an entry here, so adding one to the registry without
* copy fails a test rather than shipping a rack panel labelled `highpass`.
*
* Tone and the levelling module are deliberately absent: both carry their own
* copy in core already (`audioEqSummary`, `levellingSummary`), because a summary
* that has to read the chain belongs beside the code that writes it.
*
* Design rationale, and the review page built from this, in
* `plans/audio-fx-ux/README.md`.
*/
export interface Ends {
/** What the low end of the control sounds like. */
low: string;
high: string;
}
export interface ParamCopy {
label: string;
hint?: string;
ends?: Ends;
}
export interface EffectCopy {
/** What the module is called in the rack. Never the DSP name. */
title: string;
/** One line: what it is for. Present tense, second person implied. */
does: string;
/** The problem an author would say out loud that leads here. */
reachFor: string;
/**
* The single control that carries the module. Either a real parameter key,
* or "strength" — meaning the module gets one derived knob and the real
* parameters live behind Details.
*/
primary: string;
primaryEnds: Ends;
/** Plain names for the real parameters, shown only under Details. */
params: Record<string, ParamCopy>;
/** Which frequencies it acts on, for the shared ruler. Omit if not spectral. */
band?: [number, number];
}
export const EFFECT_COPY: Record<string, EffectCopy> = {
gain: {
title: "Volume",
does: "Turns this track up or down.",
reachFor: "It's too loud, or too quiet, against everything else.",
primary: "gain",
primaryEnds: { low: "Silent", high: "Louder" },
params: { gain: { label: "Level", ends: { low: "Silent", high: "Louder" } } },
},
highpass: {
title: "Remove Rumble",
does: "Cuts the very bottom — traffic, footsteps, air conditioning, hands on the mic.",
reachFor: "There's a low hum or thump under everything.",
primary: "frequency",
primaryEnds: { low: "Only the deepest", high: "Thins it out" },
band: [20, 300],
params: {
frequency: {
label: "Cut below",
hint: "Everything under this is removed.",
ends: { low: "Only the deepest", high: "Thins it out" },
},
q: { label: "Sharpness", hint: "How abruptly the cut starts." },
poles: { label: "Steepness", hint: "How fast it falls away below the point." },
},
},
lowpass: {
title: "Muffle",
does: "Takes the top off, like the sound is coming through a door.",
reachFor: "You want something to sound distant, or behind something else.",
primary: "frequency",
primaryEnds: { low: "Very muffled", high: "Barely changed" },
band: [1000, 20000],
params: {
frequency: { label: "Cut above", ends: { low: "Very muffled", high: "Barely changed" } },
q: { label: "Sharpness" },
poles: { label: "Steepness" },
},
},
peaking: {
title: "Shape One Range",
does: "Lifts or lowers one part of the sound and leaves the rest alone.",
reachFor: "One quality is wrong — boomy, boxy, harsh — but the rest is fine.",
primary: "gain",
primaryEnds: { low: "Take it out", high: "Bring it forward" },
band: [20, 20000],
params: {
frequency: { label: "Where", hint: "Which part of the sound to change." },
gain: { label: "How much", ends: { low: "Take it out", high: "Bring it forward" } },
q: {
label: "How wide",
hint: "A narrow setting fixes one note; a wide one changes the whole character.",
},
},
},
lowshelf: {
title: "Bass",
does: "More or less weight underneath everything.",
reachFor: "It sounds thin, or too heavy.",
primary: "gain",
primaryEnds: { low: "Thinner", high: "Heavier" },
band: [20, 300],
params: {
frequency: { label: "Up to", hint: "Everything below this is lifted or dropped." },
gain: { label: "How much", ends: { low: "Thinner", high: "Heavier" } },
},
},
highshelf: {
title: "Brightness",
does: "More or less sparkle at the top.",
reachFor: "It sounds dull, or too fizzy.",
primary: "gain",
primaryEnds: { low: "Duller", high: "Brighter" },
band: [2000, 20000],
params: {
frequency: { label: "From", hint: "Everything above this is lifted or dropped." },
gain: { label: "How much", ends: { low: "Duller", high: "Brighter" } },
},
},
compressor: {
title: "Even Out Loudness",
does: "Brings the quiet parts up and holds the loud parts down, so nothing jumps out at the listener.",
reachFor: "Some parts are much louder than others.",
primary: "strength",
primaryEnds: { low: "Barely touched", high: "Very even, quite squashed" },
params: {
threshold: { label: "Starts working at", hint: "Anything louder than this gets held down." },
ratio: { label: "How hard", hint: "How much of the excess is removed." },
attack: { label: "How fast it grabs", ends: { low: "Instant", high: "Lets peaks through" } },
release: { label: "How fast it lets go", ends: { low: "Snappy", high: "Smooth" } },
knee: { label: "How gradual" },
makeup: {
label: "Volume back up",
hint: "Compression makes things quieter; this puts the level back.",
},
mix: { label: "Blend with the original" },
},
},
limiter: {
title: "Peak Ceiling",
does: "Nothing gets louder than this, ever. A safety net at the end of the chain.",
reachFor: "You want to be sure it never clips or spikes.",
primary: "limit",
primaryEnds: { low: "A lot of headroom", high: "Right up to the edge" },
params: {
limit: {
label: "Never exceed",
ends: { low: "A lot of headroom", high: "Right up to the edge" },
},
attack: { label: "How fast it catches" },
release: { label: "How fast it recovers" },
level_out: { label: "Level after" },
},
},
gate: {
title: "Silence the Gaps",
does: "Mutes the pauses. Whatever sits underneath stays — this closes the gaps, it does not remove noise.",
reachFor: "You can hear the room in the gaps.",
primary: "strength",
primaryEnds: { low: "Only true silence", high: "Cuts quiet parts too" },
params: {
threshold: { label: "Quieter than this is a gap" },
range: {
label: "How far to duck the gaps",
hint: "Not all the way down, usually — total silence sounds broken.",
},
ratio: { label: "How hard" },
attack: { label: "How fast it opens" },
release: {
label: "How fast it closes",
ends: { low: "Clips tails short", high: "Leaves tails intact" },
},
knee: { label: "How gradual" },
},
},
saturate: {
title: "Warmth",
does: "Adds a little grit and density, the way analogue gear does.",
reachFor: "It sounds clean but lifeless.",
primary: "strength",
primaryEnds: { low: "Just a sheen", high: "Openly distorted" },
params: {
type: {
label: "Character",
hint: "Different flavours of the same idea. Tanh is the gentle one.",
},
threshold: {
label: "How much drive",
ends: { low: "Just a sheen", high: "Openly distorted" },
},
output: { label: "Level after", hint: "Drive makes things louder; this puts it back." },
oversample: { label: "Quality", hint: "Higher costs more but sounds cleaner." },
},
},
bitcrush: {
title: "Lo-Fi",
does: "Crushes the sound down to fewer steps, like an old sampler or a bad phone line.",
reachFor: "You want it to sound cheap or digital on purpose.",
primary: "strength",
primaryEnds: { low: "Slightly gritty", high: "Destroyed" },
params: {
bits: { label: "How many steps", ends: { low: "Destroyed", high: "Clean" } },
samples: {
label: "How rough",
hint: "Holds each value for longer, which dulls and grits it.",
},
mix: { label: "Blend with the original" },
},
},
pitchshift: {
title: "Higher or Lower",
does: "Shifts everything up or down without changing its speed.",
reachFor: "It should sound squeakier, or deeper.",
primary: "semitones",
primaryEnds: { low: "Much deeper", high: "Much higher" },
params: {
semitones: { label: "How far", ends: { low: "Much deeper", high: "Much higher" } },
mix: { label: "Blend with the original" },
},
},
delay: {
title: "Echo",
does: "Repeats the sound after a gap.",
reachFor: "You want space, or a rhythmic effect.",
primary: "mix",
primaryEnds: { low: "A hint", high: "Washed out" },
params: {
time: { label: "Gap between repeats" },
feedback: { label: "How many repeats", ends: { low: "One", high: "Trails away for ages" } },
mix: { label: "How loud", ends: { low: "A hint", high: "Washed out" } },
},
},
reverb: {
title: "Room",
does: "Puts the sound somewhere, instead of nowhere.",
reachFor: "It sounds dry and stuck to the speaker.",
primary: "strength",
primaryEnds: { low: "A small tight room", high: "A big open hall" },
params: {
size: { label: "How big the space is" },
damping: {
label: "How soft the walls are",
ends: { low: "Hard and bright", high: "Soft and dark" },
},
wet: { label: "How much room" },
dry: { label: "How much original" },
},
},
chorus: {
title: "Thicken",
does: "Doubles the sound slightly out of tune, which makes it wider and less exact.",
reachFor: "It sounds thin or too plain on its own.",
primary: "mix",
primaryEnds: { low: "Just wider", high: "Obviously wobbling" },
params: {
delay: { label: "Spread" },
depth: { label: "How much wobble" },
speed: { label: "How fast it wobbles" },
mix: { label: "How much", ends: { low: "Just wider", high: "Obviously wobbling" } },
},
},
phaser: {
title: "Swirl",
does: "A filter that sweeps up and down, giving a moving, hollow shimmer.",
reachFor: "You want movement, or a 1970s flavour.",
primary: "out_gain",
primaryEnds: { low: "Subtle", high: "Strong" },
params: {
in_gain: { label: "Depth in" },
out_gain: { label: "How strong", ends: { low: "Subtle", high: "Strong" } },
delay: { label: "Where it sweeps" },
decay: { label: "How resonant" },
speed: { label: "How fast it sweeps" },
type: { label: "Shape of the sweep" },
},
},
};
/**
* The shared vocabulary. Frequencies mean nothing to somebody who has not been
* taught them; these words are what the same person would say unprompted, and
* naming the ranges once teaches them everywhere they appear.
*/
export const BANDS: { from: number; to: number; name: string; says: string }[] = [
{ from: 20, to: 80, name: "Rumble", says: "traffic, footsteps, handling" },
{ from: 80, to: 250, name: "Weight", says: "body, warmth, low end" },
{ from: 250, to: 600, name: "Mud", says: "boxy, muffled, cardboard" },
{ from: 600, to: 2000, name: "Middle", says: "the body of the sound" },
{ from: 2000, to: 5000, name: "Presence", says: "definition, consonants" },
{ from: 5000, to: 10000, name: "Edge", says: "harshness, sibilance" },
{ from: 10000, to: 20000, name: "Air", says: "sparkle, openness" },
];
/**
* Which named range a frequency falls in.
*
* The whole point of `BANDS` is that the words get taught, and they only get
* taught if a module can say which one it is working in. Below the first band
* and above the last both clamp rather than returning nothing: 15 Hz is still
* rumble to anybody who can hear it, and the alternative is a filter at the edge
* of its range having no name at all.
*/
export function audioBandAt(hz: number): (typeof BANDS)[number] | undefined {
if (!Number.isFinite(hz)) return undefined;
const first = BANDS[0];
const last = BANDS.at(-1);
if (first && hz < first.from) return first;
if (last && hz >= last.to) return last;
return BANDS.find((band) => hz >= band.from && hz < band.to);
}
/** Which everyday complaint each preset answers. Presets ARE the product here. */
export const PRESET_PROBLEM: Record<string, string> = {
"voice-clean": "My voice sounds amateur",
"voice-broadcast": "I want it to sound like radio",
"voice-warm": "I want it intimate and close",
"rumble-cut": "There's a hum or thump underneath",
"room-gate": "I can hear the room in the gaps",
"boom-tame": "It sounds boomy",
"harsh-tame": "It's harsh and tiring to listen to",
telephone: "Make it sound like a phone call",
"radio-am": "Make it sound like an old radio",
megaphone: "Make it sound shouted through a horn",
"lofi-tape": "Make it sound like an old tape",
"pa-system": "Make it sound like a station announcement",
intercom: "Make it sound like a door intercom",
"doofus-worble": "Make it wobble like it is seasick",
chipmunk: "Make it small and squeaky",
giant: "Make it huge and deep",
monster: "Make it a monster",
"room-tight": "It sounds dry and stuck to the speaker",
"room-natural": "It should sound like a real place",
hall: "It should sound far away and big",
"slap-echo": "I want one quick echo",
"dub-throw": "I want long trailing echoes",
};
/**
* What a module says when it is CLOSED.
*
* The most-seen state by a distance: a rack with six modules is six of these
* and nothing else. So it is a sentence about what is happening to the sound,
* not a dump of the parameter that happens to be first. An author should be
* able to read the rack top to bottom and understand their own mix.
*
* Numbers stay in — they are what makes it checkable rather than vague — but
* they arrive inside a phrase instead of on their own.
*/
type P = Record<string, unknown>;
const n = (v: unknown, fallback = 0) => (typeof v === "number" ? v : fallback);
const hz = (v: unknown) => {
const x = n(v);
return x >= 1000 ? `${(x / 1000).toFixed(x % 1000 === 0 ? 0 : 1)} kHz` : `${Math.round(x)} Hz`;
};
const strength = (x: number, words: [string, string, string]) =>
x < 0.34 ? words[0] : x < 0.67 ? words[1] : words[2];
export const SUMMARY: Record<string, (p: P) => string> = {
gain: (p) =>
n(p.gain) === 0
? "No change"
: n(p.gain) > 0
? `Up ${n(p.gain)} dB`
: `Down ${Math.abs(n(p.gain))} dB`,
highpass: (p) => `Cutting everything below ${hz(p.frequency)}`,
lowpass: (p) => `Muffled above ${hz(p.frequency)}`,
// A band at 0 dB is doing nothing, and saying "lifting by 0 dB" describes a
// non-event as though it were a setting. Freshly added effects sit exactly
// here, so this is the FIRST thing an author reads after adding one.
peaking: (p) =>
n(p.gain) === 0
? `Sitting on ${hz(p.frequency)}, doing nothing yet`
: `${n(p.gain) > 0 ? "Lifting" : "Cutting"} ${hz(p.frequency)} by ${Math.abs(n(p.gain))} dB`,
lowshelf: (p) =>
n(p.gain) === 0
? "Doing nothing yet"
: `${n(p.gain) > 0 ? "More" : "Less"} weight below ${hz(p.frequency)}`,
highshelf: (p) =>
n(p.gain) === 0
? "Doing nothing yet"
: `${n(p.gain) > 0 ? "More" : "Less"} sparkle above ${hz(p.frequency)}`,
compressor: (p) =>
`Evening out — ${strength(Math.min(1, (n(p.ratio, 3) - 1) / 7), ["gentle", "moderate", "firm"])}`,
limiter: (p) => `Nothing louder than ${n(p.limit, -1)} dB`,
gate: (p) => `Closing gaps quieter than ${n(p.threshold, -45)} dB`,
saturate: (p) =>
`${strength(Math.min(1, Math.abs(n(p.threshold, -6)) / 30), ["A little", "Some", "Heavy"])} warmth`,
bitcrush: (p) => `Crushed to ${n(p.bits, 8)} bits`,
pitchshift: (p) =>
n(p.semitones, 0) === 0
? "Unchanged pitch"
: `${n(p.semitones, 0) > 0 ? "Up" : "Down"} ${Math.abs(n(p.semitones, 0))} semitones`,
delay: (p) => `Echo every ${n(p.time, 250)} ms`,
reverb: (p) =>
`${strength(n(p.size, 0.7), ["A small", "A medium", "A large"])} room, ${strength(n(p.wet, 0.35), ["lightly", "moderately", "heavily"])}`,
chorus: (p) => `Thickened${n(p.mix, 0.5) > 0.6 ? ", wobbling" : ""}`,
phaser: () => "Swirling",
};