Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@
"types": "./dist/audioFx.d.ts",
"environments": ["browser", "bun", "node"]
},
"./audio-fx-eq": {
"source": "./src/audioFxEq.ts",
"runtime": "./dist/audioFxEq.js",
"types": "./dist/audioFxEq.d.ts",
"environments": ["browser", "bun", "node"]
},
"./audio-leveller": {
"source": "./src/audioLeveller.ts",
"runtime": "./dist/audioLeveller.js",
"types": "./dist/audioLeveller.d.ts",
"environments": ["browser", "bun", "node"]
},
"./audio-fx-presets": {
"source": "./src/audioFxPresets.ts",
"runtime": "./dist/audioFxPresets.js",
"types": "./dist/audioFxPresets.d.ts",
"environments": ["browser", "bun", "node"]
},
"./audio-fx-tail": {
"source": "./src/audio/audioFxTail.ts",
"runtime": "./dist/audio/audioFxTail.js",
Expand Down
30 changes: 30 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@
"import": "./src/audioFx.ts",
"types": "./src/audioFx.ts"
},
"./audio-fx-eq": {
"bun": "./src/audioFxEq.ts",
"node": "./dist/audioFxEq.js",
"import": "./src/audioFxEq.ts",
"types": "./src/audioFxEq.ts"
},
"./audio-leveller": {
"bun": "./src/audioLeveller.ts",
"node": "./dist/audioLeveller.js",
"import": "./src/audioLeveller.ts",
"types": "./src/audioLeveller.ts"
},
"./audio-fx-presets": {
"bun": "./src/audioFxPresets.ts",
"node": "./dist/audioFxPresets.js",
"import": "./src/audioFxPresets.ts",
"types": "./src/audioFxPresets.ts"
},
"./audio-fx-tail": {
"bun": "./src/audio/audioFxTail.ts",
"node": "./dist/audio/audioFxTail.js",
Expand Down Expand Up @@ -392,6 +410,18 @@
"import": "./dist/audioFx.js",
"types": "./dist/audioFx.d.ts"
},
"./audio-fx-eq": {
"import": "./dist/audioFxEq.js",
"types": "./dist/audioFxEq.d.ts"
},
"./audio-leveller": {
"import": "./dist/audioLeveller.js",
"types": "./dist/audioLeveller.d.ts"
},
"./audio-fx-presets": {
"import": "./dist/audioFxPresets.js",
"types": "./dist/audioFxPresets.d.ts"
},
"./audio-fx-tail": {
"import": "./dist/audio/audioFxTail.js",
"types": "./dist/audio/audioFxTail.d.ts"
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/audioFx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,39 @@ export interface HfAudioFxNode {
/** Set on nodes the carve analysis generated, so re-running replaces them
* instead of stacking another set on top of hand-added effects. */
fromCarve?: boolean;
/**
* Id of the preset that wrote this node, for the same reason `fromCarve`
* exists: re-applying a preset replaces its own nodes rather than adding a
* second copy, and the rack can brace them together under the preset's name.
*
* The id rather than a flag, because a chain can carry more than one preset
* and each has to be able to find its own.
*/
fromPreset?: string;
/**
* What the rack calls this node, when the effect's own name is not specific
* enough to be useful.
*
* A peaking filter is "Shape One Range" wherever it appears, so a chain that
* cuts mud at 250 Hz and lifts clarity at 3 kHz shows the same words twice
* and an author cannot tell the two apart. A preset names each node for the
* JOB it is doing instead — "Reduce Mud", "Add Clarity" — and the rack reads
* as a list of things that were done rather than a list of filter types.
*/
label?: string;
/**
* Id of the multi-band EQ that owns this node, when it is one of its bands.
*
* Same device as `fromCarve`: the module gathers its own nodes out of the
* chain and presents them as one control surface, so an EQ needs no new
* effect type and its bands stay ordinary filters underneath.
*/
fromEq?: string;
/**
* Set on the gain stage the leveller writes, so re-running replaces it rather
* than stacking a second one — the same contract `fromCarve` has.
*/
fromLeveller?: boolean;
/** Absent means enabled — chain files written before the field existed still load. */
enabled?: boolean;
params?: HfAudioFxParamValues;
Expand Down Expand Up @@ -853,6 +886,10 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
enabled?: unknown;
params?: unknown;
fromCarve?: unknown;
fromPreset?: unknown;
label?: unknown;
fromEq?: unknown;
fromLeveller?: unknown;
};
if (typeof node.type !== "string" || !BY_ID.has(node.type)) {
throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`);
Expand All @@ -861,6 +898,15 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
type: node.type,
...(typeof node.id === "string" && node.id ? { id: node.id } : {}),
...(node.fromCarve === true ? { fromCarve: true as const } : {}),
// Both survive the round trip or a preset stops being able to find its
// own nodes after a reload: re-applying would stack a second copy and
// the rack would lose the grouping it braces them with.
...(typeof node.fromPreset === "string" && node.fromPreset
? { fromPreset: node.fromPreset }
: {}),
...(typeof node.label === "string" && node.label ? { label: node.label } : {}),
...(typeof node.fromEq === "string" && node.fromEq ? { fromEq: node.fromEq } : {}),
...(node.fromLeveller === true ? { fromLeveller: true as const } : {}),
enabled: node.enabled !== false,
params: normalizeAudioFxParams(
node.type,
Expand All @@ -884,6 +930,10 @@ export function serializeAudioFxChain(chain: HfAudioFxChain): string {
type: node.type,
...(node.id ? { id: node.id } : {}),
...(node.fromCarve === true ? { fromCarve: true } : {}),
...(node.fromPreset ? { fromPreset: node.fromPreset } : {}),
...(node.label ? { label: node.label } : {}),
...(node.fromEq ? { fromEq: node.fromEq } : {}),
...(node.fromLeveller === true ? { fromLeveller: true } : {}),
...(node.enabled === false ? { enabled: false } : {}),
params: normalizeAudioFxParams(node.type, node.params),
})),
Expand Down
157 changes: 157 additions & 0 deletions packages/core/src/audioFxEq.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import {
HF_AUDIO_FX_CHAIN_VERSION,
parseAudioFxChain,
serializeAudioFxChain,
type HfAudioFxChain,
} from "./audioFx.js";
import {
addAudioEq,
audioEqIds,
audioEqSummary,
HF_AUDIO_EQ_3,
HF_AUDIO_EQ_5,
HF_AUDIO_EQ_RANGE_DB,
readAudioEqBands,
removeAudioEq,
setAudioEqBandGain,
} from "./audioFxEq.js";

const empty = (): HfAudioFxChain => ({ version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] });

describe("adding an EQ", () => {
it("writes one ordinary filter per band, in band order", () => {
const { chain } = addAudioEq(empty());
expect(chain.nodes.map((n) => n.type)).toEqual(["lowshelf", "peaking", "highshelf"]);
// Ordinary nodes: an author who opens the details finds filters they could
// have added by hand, not an opaque "eq" the graph has to special-case.
expect(chain.nodes.every((n) => n.enabled)).toBe(true);
expect(chain.nodes.map((n) => n.label)).toEqual(["Bass", "Middle", "Treble"]);
});

it("gives every band an id, because a lane addresses effects by id", () => {
const { chain } = addAudioEq(empty(), HF_AUDIO_EQ_5);
const ids = chain.nodes.map((n) => n.id);
expect(ids.every(Boolean)).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
});

it("starts flat, so adding one changes nothing until a fader moves", () => {
const { chain, eqId } = addAudioEq(empty());
for (const band of readAudioEqBands(chain, eqId)) expect(band.gain).toBe(0);
expect(audioEqSummary(readAudioEqBands(chain, eqId))).toMatch(/^Flat/);
});

it("keeps two EQs apart", () => {
const first = addAudioEq(empty());
const second = addAudioEq(first.chain, HF_AUDIO_EQ_5);
expect(second.eqId).not.toBe(first.eqId);
expect(audioEqIds(second.chain)).toEqual([first.eqId, second.eqId]);
expect(readAudioEqBands(second.chain, first.eqId)).toHaveLength(3);
expect(readAudioEqBands(second.chain, second.eqId)).toHaveLength(5);
expect(new Set(second.chain.nodes.map((n) => n.id)).size).toBe(second.chain.nodes.length);
});

it("leaves effects that were already there alone", () => {
const before: HfAudioFxChain = {
version: HF_AUDIO_FX_CHAIN_VERSION,
nodes: [{ type: "reverb", id: "mine", enabled: true }],
};
const { chain } = addAudioEq(before);
expect(chain.nodes[0]?.id).toBe("mine");
expect(chain.nodes).toHaveLength(4);
});
});

describe("moving a fader", () => {
it("changes only that band", () => {
const { chain, eqId } = addAudioEq(empty());
const next = setAudioEqBandGain(chain, eqId, "Bass", 4.5);
const bands = readAudioEqBands(next, eqId);
expect(bands.find((b) => b.name === "Bass")?.gain).toBe(4.5);
expect(bands.find((b) => b.name === "Middle")?.gain).toBe(0);
expect(bands.find((b) => b.name === "Treble")?.gain).toBe(0);
});

it("leaves the band's frequency and width alone", () => {
// The fader is one control. Moving it must not quietly re-seed the rest of
// the band, or an author who set a frequency by hand loses it on the next drag.
const { chain, eqId } = addAudioEq(empty(), HF_AUDIO_EQ_5);
const before = readAudioEqBands(chain, eqId).find((b) => b.name === "Clarity")!;
const next = setAudioEqBandGain(chain, eqId, "Clarity", -3);
const after = readAudioEqBands(next, eqId).find((b) => b.name === "Clarity")!;
expect(after.frequency).toBe(before.frequency);
expect(after.q).toBe(before.q);
expect(after.gain).toBe(-3);
});

it("holds the fader to a tone control's range, not a repair tool's", () => {
// The filters themselves allow ±40 dB. A tone control that can bury a
// track under 40 dB of bass is not a tone control.
const { chain, eqId } = addAudioEq(empty());
const hot = setAudioEqBandGain(chain, eqId, "Bass", 40);
const cold = setAudioEqBandGain(chain, eqId, "Bass", -40);
expect(readAudioEqBands(hot, eqId)[0]?.gain).toBe(HF_AUDIO_EQ_RANGE_DB);
expect(readAudioEqBands(cold, eqId)[0]?.gain).toBe(-HF_AUDIO_EQ_RANGE_DB);
});

it("ignores a band name that is not in this EQ", () => {
const { chain, eqId } = addAudioEq(empty());
const next = setAudioEqBandGain(chain, eqId, "Nonsense", 6);
expect(readAudioEqBands(next, eqId).every((b) => b.gain === 0)).toBe(true);
});
});

describe("the chain is the truth", () => {
it("reads a frequency the author moved by hand", () => {
// The nodes are authoritative, not a cached band list: opening the details
// and moving a frequency has to show up on the fader's own band.
const { chain, eqId } = addAudioEq(empty());
const edited: HfAudioFxChain = {
...chain,
nodes: chain.nodes.map((n) =>
n.label === "Middle" ? { ...n, params: { ...n.params, frequency: 700 } } : n,
),
};
expect(readAudioEqBands(edited, eqId).find((b) => b.name === "Middle")?.frequency).toBe(700);
});

it("survives being written to an attribute and read back", () => {
const { chain, eqId } = addAudioEq(empty(), HF_AUDIO_EQ_5);
const moved = setAudioEqBandGain(chain, eqId, "Air", 2.5);
const back = parseAudioFxChain(serializeAudioFxChain(moved));
// Without fromEq surviving, the module cannot find its own bands after a
// reload and the EQ silently becomes five loose filters.
expect(audioEqIds(back)).toEqual([eqId]);
expect(readAudioEqBands(back, eqId).map((b) => b.name)).toEqual([
"Bass",
"Warmth",
"Middle",
"Clarity",
"Air",
]);
expect(readAudioEqBands(back, eqId).find((b) => b.name === "Air")?.gain).toBe(2.5);
});

it("removes a whole EQ without touching anything else", () => {
const before: HfAudioFxChain = {
version: HF_AUDIO_FX_CHAIN_VERSION,
nodes: [{ type: "reverb", id: "mine", enabled: true }],
};
const { chain, eqId } = addAudioEq(before);
const gone = removeAudioEq(chain, eqId);
expect(gone.nodes.map((n) => n.id)).toEqual(["mine"]);
});
});

describe("what it says when closed", () => {
it("names only the bands that were moved", () => {
const { chain, eqId } = addAudioEq(empty());
const next = setAudioEqBandGain(setAudioEqBandGain(chain, eqId, "Bass", 3), eqId, "Treble", -2);
expect(audioEqSummary(readAudioEqBands(next, eqId))).toBe("Bass +3, Treble −2");
});

it("says so when nothing has been touched", () => {
expect(audioEqSummary(HF_AUDIO_EQ_3)).toMatch(/^Flat/);
});
});
Loading
Loading