Skip to content

Commit 0b032ca

Browse files
committed
fix: seven defects a max-effort review found in the preset work
All seven are in code from this stack, and five are in the preset wet/dry wrap and its removal path — the newest work, and the least exercised. **Two produce a wrong render.** `shapeOf` carried no preset identity, so a reorder that preserved the effect-type sequence took the in-place update path and left the wet/dry wrap bracketing the wrong node. Add "Tame Boominess" (a peaking node), add the "Add Clarity" job (also peaking), move one past the other: the shape string is unchanged, so preview keeps its old wiring and blends out the author's own effect while the render, which rebuilds, blends out the preset's. Same class as the one-pole cutoff and the phaser waveform already encoded there — state that is WIRED at construction has to be in the signature. `FxChainHandle.presets` was a Record keyed by preset id, but `presetRuns` legitimately emits several runs for one preset once a reorder splits it. The second wrap overwrote the first, and `update` looked wraps up with `find`, so a split preset wrote one wrap twice and never touched the other: the switch read "Off" while the audio was unchanged, and an `fx.preset.<id>` lane drove one fragment. Targets accumulate per id now, and the update walks wraps in build order. **Two lose or resurrect automation lanes.** `removeRun` called `onRemoveNodeAutomation` per node in a loop. Each call recomputes from the same render-time snapshot and replaces the whole attribute, so every write but the last was discarded and the earlier nodes' lanes survived as orphans — which the next effect added inherits, because ids are minted lowest-free, arriving "Automated" with an envelope nobody drew. One batched call now takes every id at once. `removeEq` had the identical loop; it was only unreachable because EQ bands expose no automation toggle. It also never removed the whole-preset `fx.preset.<id>` lane, which belongs to no node, so re-applying the preset later resurrected an old ramp. The batched call takes the preset id too. **Three more.** The levelling script measured the whole decoded file from sample 0 while lane times are clip-local, so any clip with `data-media-start` got an envelope offset by exactly the trim — corrections landing on the wrong passages, worse than not levelling. It now measures the clip's own window. `audioFxProfileStrength` inverted every profile with a straight line, but the reverb's `size` curve is piecewise because the design's anchors are not evenly spaced. Setting Space to 0.5 read back as 0.46, and each reopen moved the sound again. It searches the curve at the knob's own 0.01 resolution instead — searching only reachable values is what makes the round trip exact rather than one step off. The `audio_volume_double_automation` probe was narrowed to `[^;)]` in this stack to stop it blaming the wrong element in a chained timeline. That silenced it for any object holding a call — `{ duration: fadeTime(2), volume: 0.2 }` — which is ordinary rather than exotic. A depth-counted scan crosses nested parens and still stops at the end of the selector's own call, which neither fixed bound could do. Each fix has a test that fails without it; every one was falsified by reverting the fix and watching it fail. Not fixed, and reported as a false positive: the claim that a node id of `"preset"` would have its lanes reinterpreted. `mintAudioFxNodeId` only ever produces `n1`, `n2`, …, which is why that slot was reserved. core 1774 · studio 3732 + 18 todo · engine services 739 + 3 · lint 520.
1 parent 22fa5fa commit 0b032ca

10 files changed

Lines changed: 369 additions & 38 deletions

packages/core/src/audio/audioFxGraph.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,3 +698,66 @@ describe("a preset's run is wrapped in a wet/dry blend", () => {
698698
expect(live).toEqual([]);
699699
});
700700
});
701+
702+
describe("a preset's wrap stays with the nodes it belongs to", () => {
703+
const peak = (over: Partial<HfAudioFxNode>): HfAudioFxNode =>
704+
({
705+
type: "peaking",
706+
enabled: true,
707+
params: defaultAudioFxParams("peaking"),
708+
...over,
709+
}) as HfAudioFxNode;
710+
711+
it("rebuilds when a reorder moves a node across a preset boundary", () => {
712+
// The type sequence is identical either way, so without preset identity in
713+
// the shape the chain updated in place and left the wet/dry wrap bracketing
714+
// the hand-added effect instead of the preset's — preview blending out the
715+
// author's own node while the render, which rebuilds, blended out the
716+
// preset's.
717+
const before: HfAudioFxChain = {
718+
version: 1,
719+
nodes: [peak({ id: "p1", fromPreset: "boom-tame" }), peak({ id: "own" })],
720+
};
721+
const after: HfAudioFxChain = {
722+
version: 1,
723+
nodes: [peak({ id: "own" }), peak({ id: "p1", fromPreset: "boom-tame" })],
724+
};
725+
expect(buildFxChain(asCtx(ctx()), before).update(after)).toBe(false);
726+
});
727+
728+
it("gives every run of one preset its own blend", () => {
729+
// A preset pulled apart by a reorder occupies two runs. Keyed by id and
730+
// assigned, the second wrap overwrote the first, so a whole-preset lane
731+
// reached one fragment and the switch silently left the rest applied.
732+
const split: HfAudioFxChain = {
733+
version: 1,
734+
nodes: [
735+
peak({ id: "t1", fromPreset: "telephone" }),
736+
{ type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
737+
peak({ id: "t2", fromPreset: "telephone" }),
738+
],
739+
};
740+
const built = buildFxChain(asCtx(ctx()), split);
741+
// Two wraps, so two wet/dry pairs — four params under the one id.
742+
expect(built.presets.telephone).toHaveLength(4);
743+
});
744+
745+
it("pushes an amount into each run of a split preset, not one of them twice", () => {
746+
const at = (amount: number): HfAudioFxChain => ({
747+
version: 1,
748+
nodes: [
749+
peak({ id: "t1", fromPreset: "telephone", presetAmount: amount }),
750+
{ type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
751+
peak({ id: "t2", fromPreset: "telephone", presetAmount: amount }),
752+
],
753+
});
754+
const built = buildFxChain(asCtx(ctx()), at(1));
755+
expect(built.update(at(0))).toBe(true);
756+
// Every wet leg off and every dry leg fully open: switching the preset off
757+
// has to silence all of it, not the last fragment only.
758+
const wets = (built.presets.telephone ?? []).filter((_, i) => i % 2 === 0);
759+
const drys = (built.presets.telephone ?? []).filter((_, i) => i % 2 === 1);
760+
for (const t of wets) expect(t.param.value).toBe(0);
761+
for (const t of drys) expect(t.param.value).toBe(1);
762+
});
763+
});

packages/core/src/audio/audioFxGraph.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,16 @@ function shapeOf(chain: HfAudioFxChain): string {
595595
// same reason: pushed into the running graph it would be a no-op, and
596596
// preview would keep sweeping on a triangle while the render used a sine.
597597
const wave = node.type === "phaser" ? `~${p.type}` : "";
598-
return `${node.type}${poles}${fixedFreq}${wave}`;
598+
// Which preset run this node belongs to, for the same reason again: the
599+
// wet/dry wrap is WIRED around a run at construction, so moving a node
600+
// across a preset boundary changes the graph's shape even when the type
601+
// sequence is identical. Without this, reordering a hand-added peaking
602+
// filter past a preset's peaking filter kept the in-place update path and
603+
// left the wrap bracketing the wrong effect — preview blending out the
604+
// author's own node while the render, which rebuilds, blended out the
605+
// preset's.
606+
const run = node.fromPreset ? `%${node.fromPreset}` : "";
607+
return `${node.type}${poles}${fixedFreq}${wave}${run}`;
599608
})
600609
.join("|");
601610
}
@@ -670,8 +679,14 @@ export function buildFxChain(
670679

671680
const shape = shapeOf(chain);
672681

682+
// Accumulated, not assigned. A preset pulled apart by a reorder occupies more
683+
// than one run, and each run gets its own wrap — keying by id and assigning
684+
// dropped every wrap but the last, so a whole-preset lane drove one fragment
685+
// and left the rest at full strength while the switch read "Off".
673686
const presetTargets: Record<string, FxParamTarget[]> = {};
674-
for (const p of presets) presetTargets[p.id] = mixTargets(p.wet.gain, p.dry.gain);
687+
for (const p of presets) {
688+
(presetTargets[p.id] ??= []).push(...mixTargets(p.wet.gain, p.dry.gain));
689+
}
675690

676691
return {
677692
input,
@@ -696,10 +711,14 @@ export function buildFxChain(
696711
// The blend is a value like any other: switching a preset off writes
697712
// `presetAmount`, and pushing it into the running graph is what keeps that
698713
// from being a rebuild — and from restarting the audio underneath it.
714+
// Walked in step with the build, not looked up by id: `find` returned the
715+
// first wrap for every run sharing a preset id, so a split preset wrote
716+
// one wrap twice and never touched the other.
717+
let wrapIndex = 0;
699718
for (const run of presetRuns(enabledAudioFxNodes(next))) {
700719
if (!run.preset) continue;
701-
const wrap = presets.find((p) => p.id === run.preset);
702-
if (!wrap) continue;
720+
const wrap = presets[wrapIndex++];
721+
if (!wrap || wrap.id !== run.preset) continue;
703722
wrap.wet.gain.value = run.amount;
704723
wrap.dry.gain.value = 1 - run.amount;
705724
}

packages/core/src/audioFxProfiles.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,32 @@ describe("derived one-knob profiles", () => {
8787
// so it has to be authoritative. Reopening a project has to put the knob
8888
// back where it was.
8989
for (const [id, profile] of Object.entries(HF_AUDIO_FX_PROFILES)) {
90+
// Tight, because the failure this guards against is small and cumulative:
91+
// a piecewise curve read back with a straight line put the reverb's Space
92+
// knob at 0.46 when the author set 0.5, and every reopen moved it again.
93+
// One knob step is 0.01, so anything beyond that is a value the author
94+
// did not choose.
9095
for (const s of [0, 0.25, 0.5, 0.75, 1]) {
9196
const params = applyAudioFxProfile(id, s, defaultAudioFxParams(id));
92-
expect(audioFxProfileStrength(id, params), `${id} at ${s}`).toBeCloseTo(s, 1);
97+
expect(
98+
Math.abs(audioFxProfileStrength(id, params) - s),
99+
`${id} at ${s} read back as ${audioFxProfileStrength(id, params)}`,
100+
).toBeLessThanOrEqual(0.01);
93101
}
94102
void profile;
95103
}
96104
});
97105

106+
it("reads a piecewise curve back at the value that produced it", () => {
107+
// The reverb's `size` is piecewise — the design's anchors 0.25/0.55/0.90 are
108+
// not evenly spaced — so a linear inverse is wrong by construction, and it
109+
// was: 0.5 in, 0.46 out. This is the case the general round-trip above
110+
// cannot isolate.
111+
const params = applyAudioFxProfile("reverb", 0.5, defaultAudioFxParams("reverb"));
112+
expect(params.size).toBe(0.55);
113+
expect(audioFxProfileStrength("reverb", params)).toBe(0.5);
114+
});
115+
98116
it("passes through the figures the design proposed", () => {
99117
// The curves replaced a three-point table, and these are the points it
100118
// named. Continuous beats three settings, but not at the price of landing

packages/core/src/audioFxProfiles.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,28 @@ export function audioFxProfileStrength(type: string, params: HfAudioFxParamValue
202202
if (key === undefined) return 0.5;
203203
const value = params[key];
204204
if (typeof value !== "number") return 0.5;
205-
const low = profile.at(0)[key];
206-
const high = profile.at(1)[key];
207-
if (typeof low !== "number" || typeof high !== "number" || low === high) return 0.5;
208-
return to2(Math.min(1, Math.max(0, (value - low) / (high - low))));
205+
// Searched, not inverted algebraically: a curve is free to be piecewise — the
206+
// reverb's `size` is, because the design's three anchors are not evenly
207+
// spaced — and a straight line between the endpoints reads such a curve back
208+
// at the wrong place. Setting Space to 0.5 wrote size 0.55 and reopening the
209+
// project drew the knob at 0.46, so every reopen nudged the sound.
210+
//
211+
// The grid IS the knob's own resolution (0.01), not something finer: a finer
212+
// grid lands between two settable positions and rounds to a neighbour, which
213+
// is how a search can be off by a step even where the curve is exact.
214+
// Searching only reachable values makes the round trip exact.
215+
const STEPS = 100;
216+
let best = 0.5;
217+
let bestErr = Infinity;
218+
for (let i = 0; i <= STEPS; i += 1) {
219+
const s = to2(i / STEPS);
220+
const at = profile.at(s)[key];
221+
if (typeof at !== "number") continue;
222+
const err = Math.abs(at - value);
223+
if (err < bestErr) {
224+
bestErr = err;
225+
best = s;
226+
}
227+
}
228+
return bestErr === Infinity ? 0.5 : to2(best);
209229
}

packages/lint/src/rules/media.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,18 @@ describe("audio_volume_double_automation", () => {
457457
}
458458
});
459459

460+
it("still warns when another value in the same call is a function result", async () => {
461+
// Bounding the scan at the first `)` to fix the chained-timeline case
462+
// silenced the rule for the ordinary shape of a tween whose object holds a
463+
// call — the paren closing `fadeTime(2)` ended the match before `volume`.
464+
// The lane and the tween still both drive volume, and the author still gets
465+
// no warning about it.
466+
const res = await lintHyperframeHtml(
467+
withScript(LANE, `tl.to("#bgm", { duration: fadeTime(2), volume: 0.2 });`),
468+
);
469+
expect(res.findings.some((f) => f.code === "audio_volume_double_automation")).toBe(true);
470+
});
471+
460472
it("does not blame the wrong element in a chained timeline", async () => {
461473
// A chain has no semicolon until its very end, so a run that could cross `)`
462474
// reached the `volume` in a LATER call and reported the element from an

packages/lint/src/rules/media.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@ import type { LintContext, HyperframeLintFinding } from "../context";
22
import { readAttr, readDecodedAttr, stripJsComments, truncateSnippet, isMediaTag } from "../utils";
33
import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
44

5+
/**
6+
* Does the GSAP call that names `#id` also set `volume` in the same call?
7+
*
8+
* Depth-counted rather than regex-bounded: the selector opens somewhere inside a
9+
* call, and the interesting region ends when THAT call closes — a nested
10+
* `fadeTime(2)` opens and closes on the way and must not end the scan. A regex
11+
* cannot count parens, and both fixed bounds were wrong in opposite directions:
12+
* unbounded blamed a later element, first-paren missed a whole ordinary shape.
13+
*/
14+
function tweensVolumeInSameCall(script: string, id: string): boolean {
15+
const selector = new RegExp(`#${escapeRegExp(id)}(?![\\w-])`, "g");
16+
for (let hit = selector.exec(script); hit; hit = selector.exec(script)) {
17+
let depth = 0;
18+
// Cap the scan so a malformed script cannot walk the whole file.
19+
const limit = Math.min(script.length, hit.index + 2000);
20+
for (let i = hit.index; i < limit; i += 1) {
21+
const ch = script[i];
22+
if (ch === "(") depth += 1;
23+
else if (ch === ")") {
24+
// Past the end of the call the selector sits in.
25+
if (depth === 0) break;
26+
depth -= 1;
27+
} else if (ch === ";" && depth === 0) break;
28+
else if (ch === "v" && /^volume\s*:/.test(script.slice(i))) return true;
29+
}
30+
}
31+
return false;
32+
}
33+
534
function escapeRegExp(value: string): string {
635
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
736
}
@@ -624,15 +653,15 @@ function findVolumeDoubleAutomationFindings(ctx: LintContext): HyperframeLintFin
624653
// the element's own selector, rather than by parsing the timeline. It reads
625654
// the same call the runtime's own probe would pick up, and the rule only
626655
// warns, so a miss costs nothing.
627-
const escaped = escapeRegExp(id);
628-
// `[^;)]`, not `[^;]`: a chained timeline has no semicolon until the end of
629-
// the whole chain, so a run that could cross `)` matched `volume` in a LATER
630-
// `.to()` call and named the wrong element — and this rule's fixHint tells
631-
// the author to delete their lane. Refusing to cross the closing paren keeps
632-
// the match inside the call the selector belongs to. It costs a false
633-
// negative when some other value in the same object is a call result, which
634-
// is the safe direction for a warning that already only guesses.
635-
const tweened = new RegExp(`#${escaped}(?![\\w-])[^;)]{0,200}?\\bvolume\\s*:`).test(script);
656+
// Scan to the end of the call the selector opened, rather than to the first
657+
// `)`. A chained timeline has no semicolon until the end of the whole chain,
658+
// so an unbounded run matched `volume` in a LATER `.to()` and named the
659+
// wrong element — but stopping at the first `)` instead silenced the rule
660+
// for any object holding a call, e.g.
661+
// `gsap.to("#bgm", { duration: fadeTime(2), volume: 0.2 })`, which is the
662+
// ordinary case rather than an exotic one. Counting depth keeps the match
663+
// inside the selector's own call AND lets it cross a nested one.
664+
const tweened = tweensVolumeInSameCall(script, id);
636665
if (!tweened) continue;
637666
findings.push({
638667
code: "audio_volume_double_automation",

packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,109 @@ describe("AudioFxGroup dynamic carve", () => {
435435
* nobody asked to level, through a channel that does not persist: audible,
436436
* absent from the document, and gone on the next reload.
437437
*/
438+
it("levels the part of the file the clip plays, not the file from its start", async () => {
439+
// A lane's `t` is seconds from the start of the CLIP, but the decode is the
440+
// whole file — so a trimmed clip got an envelope offset by exactly
441+
// `media-start`, and every correction landed early.
442+
//
443+
// The file is loud 0-2s, quiet 2-5s, loud again 5-8s, and the clip trims the
444+
// first 2s. Measured from the clip's own zero, t=0.5 sits in the quiet
445+
// passage and wants a real lift; measured from the file's zero it sits in
446+
// the loud head and wants none. That gap is the bug.
447+
const sampleRate = 48000;
448+
const data = new Float32Array(sampleRate * 8);
449+
for (let i = 0; i < data.length; i++) {
450+
const t = i / sampleRate;
451+
const amp = t < 2 ? 0.5 : t < 5 ? 0.05 : 0.5;
452+
data[i] = amp * Math.sin(2 * Math.PI * 300 * t);
453+
}
454+
vi.stubGlobal(
455+
"fetch",
456+
vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })),
457+
);
458+
vi.stubGlobal(
459+
"OfflineAudioContext",
460+
class {
461+
decodeAudioData = async () => ({ sampleRate, getChannelData: () => data });
462+
},
463+
);
464+
465+
const { host, onSetAttributeQuiet } = mount({
466+
"fx-chain": CHAIN,
467+
"media-start": "2",
468+
duration: "6",
469+
});
470+
document.getElementById("bed")?.setAttribute("src", "bed.wav");
471+
act(() => byTextButton(host, "Audio FX")?.click());
472+
act(() => byTextButton(host, "Add effect")?.click());
473+
await act(async () => {
474+
byTextButton(host, "Even Out Levels")?.click();
475+
await new Promise((r) => setTimeout(r, 0));
476+
await new Promise((r) => setTimeout(r, 0));
477+
});
478+
479+
const write = onSetAttributeQuiet.mock.calls.filter((c) => c[0] === "data-automation").at(-1);
480+
if (!write) throw new Error("no levelling lane written");
481+
const lane = (
482+
JSON.parse(String(write[1])).lanes as { target: string; points: { t: number; v: number }[] }[]
483+
).find((l) => l.target.startsWith("fx."));
484+
if (!lane) throw new Error("no fx lane");
485+
const near = (t: number) =>
486+
lane.points.reduce((best, p) => (Math.abs(p.t - t) < Math.abs(best.t - t) ? p : best));
487+
// The quiet passage, from the clip's zero, gets its lift.
488+
expect(near(0.5).v).toBeGreaterThan(4);
489+
});
490+
491+
it("removes every lane a preset owned, not just the last node's", () => {
492+
// Each write is computed from the same render-time snapshot and replaces the
493+
// whole attribute, so removing lanes one node at a time kept only the final
494+
// write — the earlier nodes' lanes survived as orphans, and with ids minted
495+
// lowest-free the next effect added inherited one, arriving "Automated" with
496+
// an envelope nobody drew and baked into the render.
497+
const chain = {
498+
version: 1,
499+
nodes: [
500+
{
501+
type: "highpass",
502+
id: "n1",
503+
fromPreset: "telephone",
504+
params: { frequency: 300, q: 0.707, poles: "2" },
505+
},
506+
{
507+
type: "peaking",
508+
id: "n2",
509+
fromPreset: "telephone",
510+
params: { frequency: 1200, gain: 6, q: 1.2 },
511+
},
512+
],
513+
};
514+
const automation = {
515+
version: 1,
516+
lanes: [
517+
{ target: "fx.n1.frequency", points: [{ t: 0, v: 300 }] },
518+
{ target: "fx.n2.gain", points: [{ t: 0, v: 6 }] },
519+
{ target: "fx.preset.telephone", points: [{ t: 0, v: 1 }] },
520+
{ target: "volume", points: [{ t: 0, v: 0.5 }] },
521+
],
522+
};
523+
const { host, onSetAttributeQuiet } = mount({
524+
"fx-chain": JSON.stringify(chain),
525+
automation: JSON.stringify(automation),
526+
});
527+
act(() => byTextButton(host, "Audio FX")?.click());
528+
act(() => host.querySelector<HTMLElement>(".hf-fx-preset-run-remove")?.click());
529+
530+
const write = onSetAttributeQuiet.mock.calls.filter((c) => c[0] === "data-automation").at(-1);
531+
const lanes = JSON.parse(String(write?.[1] ?? '{"lanes":[]}')).lanes as { target: string }[];
532+
const targets = lanes.map((l) => l.target);
533+
// Both nodes gone, and the whole-preset lane with them.
534+
expect(targets).not.toContain("fx.n1.frequency");
535+
expect(targets).not.toContain("fx.n2.gain");
536+
expect(targets).not.toContain("fx.preset.telephone");
537+
// The track's own volume lane is untouched.
538+
expect(targets).toContain("volume");
539+
});
540+
438541
describe("auditioning starts the transport when it has to", () => {
439542
const store = () => usePlayerStore.getState();
440543

0 commit comments

Comments
 (0)