|
| 1 | +import { describe, expect, it } from "vitest"; |
| 2 | +import { |
| 3 | + getAudioFxDef, |
| 4 | + HF_AUDIO_FX_CHAIN_VERSION, |
| 5 | + normalizeAudioFxParams, |
| 6 | + parseAudioFxChain, |
| 7 | + serializeAudioFxChain, |
| 8 | + type HfAudioFxChain, |
| 9 | + type HfAudioFxNumberParam, |
| 10 | +} from "./audioFx.js"; |
| 11 | +import { |
| 12 | + activeAudioFxPresetIds, |
| 13 | + applyAudioFxPreset, |
| 14 | + audioFxPresetNodes, |
| 15 | + audioFxPresetsByFamily, |
| 16 | + getAudioFxPreset, |
| 17 | + HF_AUDIO_FX_PRESET_FAMILIES, |
| 18 | + HF_AUDIO_FX_PRESETS, |
| 19 | +} from "./audioFxPresets.js"; |
| 20 | + |
| 21 | +const empty = (): HfAudioFxChain => ({ version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] }); |
| 22 | +const need = (id: string) => { |
| 23 | + const p = getAudioFxPreset(id); |
| 24 | + if (!p) throw new Error(`no preset ${id}`); |
| 25 | + return p; |
| 26 | +}; |
| 27 | + |
| 28 | +/** |
| 29 | + * The catalogue is hand-written numbers, which is exactly the kind of thing |
| 30 | + * that rots quietly: a value outside its declared range does not throw, it gets |
| 31 | + * clamped, and the preset then sounds like something nobody chose. These check |
| 32 | + * the data itself rather than the machinery around it. |
| 33 | + */ |
| 34 | +describe("the catalogue is internally valid", () => { |
| 35 | + it("has unique ids and a family the menu knows", () => { |
| 36 | + const ids = HF_AUDIO_FX_PRESETS.map((p) => p.id); |
| 37 | + expect(new Set(ids).size).toBe(ids.length); |
| 38 | + for (const p of HF_AUDIO_FX_PRESETS) { |
| 39 | + expect(HF_AUDIO_FX_PRESET_FAMILIES, `${p.id} sits on no shelf`).toContain(p.family); |
| 40 | + expect(p.nodes.length, `${p.id} is empty`).toBeGreaterThan(0); |
| 41 | + expect(p.label.length).toBeGreaterThan(0); |
| 42 | + expect(p.description.length).toBeGreaterThan(0); |
| 43 | + } |
| 44 | + }); |
| 45 | + |
| 46 | + it("names only real effects", () => { |
| 47 | + for (const p of HF_AUDIO_FX_PRESETS) { |
| 48 | + for (const node of p.nodes) { |
| 49 | + expect(getAudioFxDef(node.type), `${p.id} uses unknown effect "${node.type}"`).toBeTruthy(); |
| 50 | + } |
| 51 | + } |
| 52 | + }); |
| 53 | + |
| 54 | + it("names only parameters those effects declare", () => { |
| 55 | + for (const p of HF_AUDIO_FX_PRESETS) { |
| 56 | + for (const node of p.nodes) { |
| 57 | + const keys = new Set((getAudioFxDef(node.type)?.params ?? []).map((x) => x.key)); |
| 58 | + for (const key of Object.keys(node.params ?? {})) { |
| 59 | + expect(keys.has(key), `${p.id}: ${node.type} has no parameter "${key}"`).toBe(true); |
| 60 | + } |
| 61 | + } |
| 62 | + } |
| 63 | + }); |
| 64 | + |
| 65 | + it("sets every value inside its own declared range", () => { |
| 66 | + // The one that actually catches typos. A value out of range is silently |
| 67 | + // clamped, so without this a preset can ship sounding like nothing anyone |
| 68 | + // chose and still pass every other test here. |
| 69 | + for (const p of HF_AUDIO_FX_PRESETS) { |
| 70 | + for (const node of p.nodes) { |
| 71 | + const def = getAudioFxDef(node.type); |
| 72 | + for (const [key, raw] of Object.entries(node.params ?? {})) { |
| 73 | + const param = def?.params.find((x) => x.key === key); |
| 74 | + if (!param) continue; |
| 75 | + if (param.kind === "enum") { |
| 76 | + expect( |
| 77 | + param.options.some((o) => o.value === raw), |
| 78 | + `${p.id}: ${node.type}.${key} = "${String(raw)}" is not one of its options`, |
| 79 | + ).toBe(true); |
| 80 | + continue; |
| 81 | + } |
| 82 | + const n = param as HfAudioFxNumberParam; |
| 83 | + expect(typeof raw, `${p.id}: ${node.type}.${key} is not a number`).toBe("number"); |
| 84 | + expect( |
| 85 | + raw as number, |
| 86 | + `${p.id}: ${node.type}.${key} = ${String(raw)} is below its minimum ${n.min}`, |
| 87 | + ).toBeGreaterThanOrEqual(n.min); |
| 88 | + expect( |
| 89 | + raw as number, |
| 90 | + `${p.id}: ${node.type}.${key} = ${String(raw)} is above its maximum ${n.max}`, |
| 91 | + ).toBeLessThanOrEqual(n.max); |
| 92 | + } |
| 93 | + } |
| 94 | + } |
| 95 | + }); |
| 96 | + |
| 97 | + it("survives being written to an attribute and read back", () => { |
| 98 | + for (const p of HF_AUDIO_FX_PRESETS) { |
| 99 | + const chain = applyAudioFxPreset(empty(), p); |
| 100 | + const back = parseAudioFxChain(serializeAudioFxChain(chain)); |
| 101 | + expect( |
| 102 | + back.nodes.map((n) => n.type), |
| 103 | + `${p.id} did not round-trip`, |
| 104 | + ).toEqual(chain.nodes.map((n) => n.type)); |
| 105 | + } |
| 106 | + }); |
| 107 | + |
| 108 | + it("ends anything that boosts with a ceiling", () => { |
| 109 | + // A preset that adds presence and makeup gain can push the chain past full |
| 110 | + // scale, and the render clamps what it is handed. Every preset that lifts |
| 111 | + // has to hand the mix something bounded. |
| 112 | + for (const p of HF_AUDIO_FX_PRESETS) { |
| 113 | + const lifts = p.nodes.some((node) => { |
| 114 | + const g = node.params?.["gain"]; |
| 115 | + const makeup = node.params?.["makeup"]; |
| 116 | + const out = node.params?.["output"]; |
| 117 | + return ( |
| 118 | + (typeof g === "number" && g > 0) || |
| 119 | + (typeof makeup === "number" && makeup > 0) || |
| 120 | + (typeof out === "number" && out > 0) |
| 121 | + ); |
| 122 | + }); |
| 123 | + if (!lifts) continue; |
| 124 | + const last = p.nodes[p.nodes.length - 1]; |
| 125 | + const bounded = |
| 126 | + p.nodes.some((n) => n.type === "limiter") || |
| 127 | + // A band-limited character preset cannot run away: its own filters and |
| 128 | + // soft clip cap it, and a limiter would change the effect. |
| 129 | + p.family === "character"; |
| 130 | + expect(bounded, `${p.id} lifts level but never bounds it (ends on ${last?.type})`).toBe(true); |
| 131 | + } |
| 132 | + }); |
| 133 | + |
| 134 | + it("puts the limiter last wherever it has one", () => { |
| 135 | + for (const p of HF_AUDIO_FX_PRESETS) { |
| 136 | + const at = p.nodes.findIndex((n) => n.type === "limiter"); |
| 137 | + if (at === -1) continue; |
| 138 | + expect(at, `${p.id}: a limiter that is not last is not a ceiling`).toBe(p.nodes.length - 1); |
| 139 | + } |
| 140 | + }); |
| 141 | + |
| 142 | + it("keeps every shelf stocked", () => { |
| 143 | + for (const family of HF_AUDIO_FX_PRESET_FAMILIES) { |
| 144 | + expect(audioFxPresetsByFamily(family).length, `${family} is empty`).toBeGreaterThan(0); |
| 145 | + } |
| 146 | + }); |
| 147 | +}); |
| 148 | + |
| 149 | +describe("applying a preset", () => { |
| 150 | + it("writes ordinary nodes with their defaults filled in", () => { |
| 151 | + const chain = applyAudioFxPreset(empty(), need("rumble-cut")); |
| 152 | + expect(chain.nodes).toHaveLength(1); |
| 153 | + const node = chain.nodes[0]!; |
| 154 | + expect(node.type).toBe("highpass"); |
| 155 | + // Named 100 Hz; everything else comes from the effect, so the file holds a |
| 156 | + // complete node rather than a partial one the graph has to guess at. |
| 157 | + expect(node.params).toEqual({ ...normalizeAudioFxParams("highpass", {}), frequency: 100 }); |
| 158 | + expect(node.fromPreset).toBe("rumble-cut"); |
| 159 | + expect(node.enabled).toBe(true); |
| 160 | + }); |
| 161 | + |
| 162 | + it("gives every node an id, because a lane addresses effects by id", () => { |
| 163 | + const chain = applyAudioFxPreset(empty(), need("telephone")); |
| 164 | + const ids = chain.nodes.map((n) => n.id); |
| 165 | + expect(ids.every(Boolean)).toBe(true); |
| 166 | + expect(new Set(ids).size).toBe(ids.length); |
| 167 | + }); |
| 168 | + |
| 169 | + it("appends rather than replacing, so a character preset can stack on a clean voice", () => { |
| 170 | + const voiced = applyAudioFxPreset(empty(), need("voice-clean")); |
| 171 | + const both = applyAudioFxPreset(voiced, need("telephone")); |
| 172 | + expect(both.nodes.length).toBe(voiced.nodes.length + need("telephone").nodes.length); |
| 173 | + expect(activeAudioFxPresetIds(both)).toEqual(["voice-clean", "telephone"]); |
| 174 | + // ids stay unique across the two batches |
| 175 | + expect(new Set(both.nodes.map((n) => n.id)).size).toBe(both.nodes.length); |
| 176 | + }); |
| 177 | + |
| 178 | + it("replaces the whole chain when asked", () => { |
| 179 | + const voiced = applyAudioFxPreset(empty(), need("voice-clean")); |
| 180 | + const only = applyAudioFxPreset(voiced, need("hall"), { replaceChain: true }); |
| 181 | + expect(activeAudioFxPresetIds(only)).toEqual(["hall"]); |
| 182 | + expect(only.nodes).toHaveLength(need("hall").nodes.length); |
| 183 | + }); |
| 184 | + |
| 185 | + it("re-applying swaps its own nodes instead of stacking a second copy", () => { |
| 186 | + const once = applyAudioFxPreset(empty(), need("telephone")); |
| 187 | + const twice = applyAudioFxPreset(once, need("telephone")); |
| 188 | + expect(twice.nodes.length).toBe(once.nodes.length); |
| 189 | + expect(activeAudioFxPresetIds(twice)).toEqual(["telephone"]); |
| 190 | + }); |
| 191 | + |
| 192 | + it("re-applying keeps the preset's place in the signal order", () => { |
| 193 | + // Order is audible: a telephone band before a limiter is a different sound |
| 194 | + // from one after it. Re-applying must not quietly move the preset to the end. |
| 195 | + const start = applyAudioFxPreset(empty(), need("telephone")); |
| 196 | + const withTail = applyAudioFxPreset(start, need("hall")); |
| 197 | + const again = applyAudioFxPreset(withTail, need("telephone")); |
| 198 | + expect(again.nodes.map((n) => n.fromPreset)).toEqual([ |
| 199 | + ...new Array(need("telephone").nodes.length).fill("telephone"), |
| 200 | + ...new Array(need("hall").nodes.length).fill("hall"), |
| 201 | + ]); |
| 202 | + }); |
| 203 | + |
| 204 | + it("leaves hand-added effects alone when a preset is re-applied", () => { |
| 205 | + const hand = { type: "reverb", id: "mine", params: normalizeAudioFxParams("reverb", {}) }; |
| 206 | + const chain: HfAudioFxChain = { version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [hand] }; |
| 207 | + const once = applyAudioFxPreset(chain, need("voice-clean")); |
| 208 | + const twice = applyAudioFxPreset(once, need("voice-clean")); |
| 209 | + expect(twice.nodes.filter((n) => n.id === "mine")).toHaveLength(1); |
| 210 | + expect(twice.nodes.filter((n) => n.fromPreset === "voice-clean")).toHaveLength( |
| 211 | + need("voice-clean").nodes.length, |
| 212 | + ); |
| 213 | + }); |
| 214 | + |
| 215 | + it("mints ids that cannot collide with what is already in the chain", () => { |
| 216 | + const chain: HfAudioFxChain = { |
| 217 | + version: HF_AUDIO_FX_CHAIN_VERSION, |
| 218 | + nodes: [ |
| 219 | + { type: "reverb", id: "n1" }, |
| 220 | + { type: "delay", id: "n2" }, |
| 221 | + ], |
| 222 | + }; |
| 223 | + const made = audioFxPresetNodes(need("voice-clean"), chain); |
| 224 | + for (const node of made) expect(["n1", "n2"]).not.toContain(node.id); |
| 225 | + expect(new Set(made.map((n) => n.id)).size).toBe(made.length); |
| 226 | + }); |
| 227 | +}); |
0 commit comments