Skip to content

Commit f6fcf18

Browse files
vanceingallsclaude
andcommitted
feat: wire the leveller into the rack, in front of the ceiling
Two fixes and the wiring that makes the script reachable. CORRECTNESS: the levelling stage now goes in FRONT of a trailing limiter instead of being appended after it. The likely sequence is "apply Clean Voice, then even out the levels", and Clean Voice ends in a Peak Ceiling — so appending put up to 12 dB of lift after the ceiling that exists to bound the chain. Every quiet-to-loud transition would leave residual lift on material already at -1 dBFS, and the render shears that flat. A ceiling with something added after it is not a ceiling. Falsified by reverting to append. WIRING: "Even Out Levels" sits beside "Tone (EQ)" in the add menu, decoding in an OfflineAudioContext and locking the rack while it works, exactly as the carve does. The same control removes it once present, because pressing it twice never means "add a second levelling stage". The stage itself needs no bespoke module — it is a gain node carrying a label and a lane, and the rack already renders that correctly. THE TRAP, now covered: a script hands back a whole HfAutomation describing only its OWN lane, so writing it to the attribute would take the carve's per-band lanes and the track's volume lane with it — silently, totally, and noticed only later when the mix has lost its ducking. `withLane` merges by target instead, and removal takes the orphaned lane with the node. This had no test until the mutation survived; propertyPanelAutomation.test.ts now covers it and fails when withLane replaces the set. Core 1720 -> 1721, studio 3668 -> 3674. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6b3cc9e commit f6fcf18

7 files changed

Lines changed: 220 additions & 1 deletion

File tree

packages/core/src/audioLeveller.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type HfAudioFxChain,
77
} from "./audioFx.js";
88
import { sampleAutomationLane } from "./audioAutomation.js";
9+
import { applyAudioFxPreset, getAudioFxPreset } from "./audioFxPresets.js";
910
import {
1011
analyseLevelling,
1112
levellerProfile,
@@ -182,6 +183,28 @@ describe("what it writes", () => {
182183
expect(twice.chain.nodes[0]!.id).toBe(once.chain.nodes[0]!.id);
183184
});
184185

186+
it("goes in FRONT of a trailing limiter, never after it", () => {
187+
// The likely sequence: apply Clean Voice, then even out the levels. Clean
188+
// Voice ends in a Peak Ceiling, and up to 12 dB of lift landing after that
189+
// ceiling means loud material at -1 dBFS goes over full scale and the
190+
// render shears it flat. A ceiling with something after it is not a ceiling.
191+
const voiced = applyAudioFxPreset(empty(), getAudioFxPreset("voice-clean")!);
192+
expect(voiced.nodes[voiced.nodes.length - 1]!.type).toBe("limiter");
193+
194+
const result = levellingResult(
195+
voiced,
196+
uneven([
197+
{ seconds: 3, amp: 0.5 },
198+
{ seconds: 3, amp: 0.06 },
199+
]),
200+
SR,
201+
)!;
202+
const types = result.chain.nodes.map((n) => n.type);
203+
expect(types[types.length - 1]).toBe("limiter");
204+
expect(types[types.length - 2]).toBe("gain");
205+
expect(result.chain.nodes.find((n) => n.fromLeveller)).toBeTruthy();
206+
});
207+
185208
it("leaves hand-added effects alone", () => {
186209
const chain: HfAudioFxChain = {
187210
version: HF_AUDIO_FX_CHAIN_VERSION,

packages/core/src/audioLeveller.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,12 +193,27 @@ export function levellingResult(
193193
params: normalizeAudioFxParams("gain", { gain: 0 }),
194194
};
195195

196+
/**
197+
* In FRONT of a trailing limiter, not after it.
198+
*
199+
* The likely sequence is "apply Clean Voice, then even out the levels", and
200+
* Clean Voice ends in a Peak Ceiling. Appending would put up to 12 dB of lift
201+
* AFTER the ceiling that exists to bound the chain — so every quiet-to-loud
202+
* transition leaves residual lift on loud material sitting at -1 dBFS, and
203+
* the render shears it flat. A ceiling that something is added after is not
204+
* a ceiling.
205+
*/
206+
const insertAt =
207+
!existing && chain.nodes[chain.nodes.length - 1]?.type === "limiter"
208+
? chain.nodes.length - 1
209+
: chain.nodes.length;
210+
196211
return {
197212
chain: {
198213
version: HF_AUDIO_FX_CHAIN_VERSION,
199214
nodes: existing
200215
? chain.nodes.map((n) => (n.fromLeveller ? node : n))
201-
: [...chain.nodes, node],
216+
: [...chain.nodes.slice(0, insertAt), node, ...chain.nodes.slice(insertAt)],
202217
},
203218
automation: {
204219
version: 1,

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,15 @@ import {
4141
import {
4242
automatedTargetsOf,
4343
automationAttrValue,
44+
withLane,
4445
HF_AUDIO_AUTOMATION_ATTR,
4546
HF_AUDIO_AUTOMATION_DATA_KEY,
4647
readPanelAutomation,
4748
resolveAutomationRange,
4849
withoutLane,
4950
withSeededLane,
5051
} from "./propertyPanelAutomation";
52+
import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
5153
import type { DomEditSelection } from "./domEditingTypes";
5254
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
5355
import { usePlayerStore } from "../../player";
@@ -483,6 +485,63 @@ export function AudioFxGroup({
483485
* on this one. The bands replace any previous carve output but leave
484486
* hand-added effects alone, so re-analysing does not discard other work.
485487
*/
488+
/**
489+
* Measure THIS track and write the levelling lane.
490+
*
491+
* Same shape as the carve below it — decode offline, lock the rack while it
492+
* works, write once — but it listens to the track it is on rather than to a
493+
* voice above it, so it needs no source picker.
494+
*/
495+
const runLeveller = async (): Promise<void> => {
496+
const el = element.element;
497+
const src = el?.getAttribute("src");
498+
const doc = el?.ownerDocument;
499+
if (!src || !doc) return;
500+
setAnalysing(true);
501+
try {
502+
const Ctor =
503+
window.OfflineAudioContext ??
504+
(window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
505+
.webkitOfflineAudioContext;
506+
if (!Ctor) return;
507+
const res = await fetch(new URL(src, doc.baseURI).href);
508+
const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
509+
await res.arrayBuffer(),
510+
);
511+
const result = levellingResult(chain, buffer.getChannelData(0), buffer.sampleRate);
512+
if (!result) return;
513+
await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
514+
// Merged by target, never written wholesale: the script describes its own
515+
// lane only, and replacing the attribute would take the carve's lanes and
516+
// the volume lane with it.
517+
const lane = result.automation.lanes[0];
518+
if (lane) {
519+
void onSetAttributeQuiet(
520+
HF_AUDIO_AUTOMATION_ATTR,
521+
automationAttrValue(withLane(automation, lane)) || null,
522+
);
523+
}
524+
} catch {
525+
// A track whose audio cannot be fetched or decoded simply gets no
526+
// levelling, the same way an unreadable carve source is skipped.
527+
} finally {
528+
setAnalysing(false);
529+
}
530+
};
531+
532+
const removeLeveller = (): void => {
533+
const { chain: next, removedTarget } = removeLevelling(chain);
534+
void onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
535+
// The lane goes with the node. An orphan keeps driving a parameter that is
536+
// no longer in the graph.
537+
if (removedTarget) {
538+
void onSetAttributeQuiet(
539+
HF_AUDIO_AUTOMATION_ATTR,
540+
automationAttrValue(withoutLane(automation, removedTarget)) || null,
541+
);
542+
}
543+
};
544+
486545
const analyse = async (active: HfCarveSettings | null = carve): Promise<void> => {
487546
if (!active?.sources.length) return;
488547
const doc = element.element?.ownerDocument;
@@ -660,6 +719,9 @@ export function AudioFxGroup({
660719
onCarveChange={(next) => void setCarve(next)}
661720
onCarvePreview={(next) => onSetAttributeLive(HF_AUDIO_CARVE_ATTR, JSON.stringify(next))}
662721
sourceOptions={sourceOptions}
722+
onLevel={() => void runLeveller()}
723+
onRemoveLevel={removeLeveller}
724+
levelled={chain.nodes.some((n) => n.fromLeveller)}
663725
carvedAgainstBy={carvedAgainstBy}
664726
analysing={analysing}
665727
/>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { HfAutomation } from "@hyperframes/core/audio-automation";
3+
import { automationAttrValue, withLane, withoutLane } from "./propertyPanelAutomation.js";
4+
5+
const carved = (): HfAutomation => ({
6+
version: 1,
7+
lanes: [
8+
{ target: "fx.n1.gain", points: [{ t: 0, v: -6 }] },
9+
{ target: "fx.n2.gain", points: [{ t: 0, v: -9 }] },
10+
{ target: "volume", points: [{ t: 0, v: 0.8 }] },
11+
],
12+
});
13+
14+
/**
15+
* A script hands back a whole `HfAutomation` that describes only its OWN lane.
16+
* Writing that to the attribute would take everything else with it — the
17+
* carve's per-band lanes and the track's volume lane — which is silent, total,
18+
* and only noticed later when the mix has quietly lost its ducking.
19+
*/
20+
describe("withLane", () => {
21+
it("keeps every lane it was not asked about", () => {
22+
const next = withLane(carved(), { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] });
23+
expect(next.lanes.map((l) => l.target).sort()).toEqual([
24+
"fx.n1.gain",
25+
"fx.n2.gain",
26+
"fx.n9.gain",
27+
"volume",
28+
]);
29+
expect(next.lanes.find((l) => l.target === "volume")?.points).toEqual([{ t: 0, v: 0.8 }]);
30+
});
31+
32+
it("replaces a lane rather than adding a second one for the same target", () => {
33+
// Re-running a script must not leave two lanes fighting over one parameter.
34+
const once = withLane(carved(), { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] });
35+
const twice = withLane(once, { target: "fx.n9.gain", points: [{ t: 0, v: 5 }] });
36+
expect(twice.lanes.filter((l) => l.target === "fx.n9.gain")).toHaveLength(1);
37+
expect(twice.lanes.find((l) => l.target === "fx.n9.gain")?.points).toEqual([{ t: 0, v: 5 }]);
38+
expect(twice.lanes).toHaveLength(4);
39+
});
40+
41+
it("does not mutate what it was given", () => {
42+
const before = carved();
43+
withLane(before, { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] });
44+
expect(before.lanes).toHaveLength(3);
45+
});
46+
});
47+
48+
describe("withoutLane", () => {
49+
it("takes one lane and leaves the rest", () => {
50+
// A node removed without its lane leaves an orphan driving a parameter that
51+
// is no longer in the graph.
52+
const next = withoutLane(carved(), "fx.n1.gain");
53+
expect(next.lanes.map((l) => l.target)).toEqual(["fx.n2.gain", "volume"]);
54+
});
55+
56+
it("empties the attribute when the last lane goes", () => {
57+
const one: HfAutomation = { version: 1, lanes: [{ target: "fx.n1.gain", points: [] }] };
58+
expect(automationAttrValue(withoutLane(one, "fx.n1.gain"))).toBe("");
59+
});
60+
});

packages/studio/src/components/editor/propertyPanelAutomation.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* attribute the same way.
77
*/
88

9+
import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
910
import {
1011
HF_AUDIO_AUTOMATION_ATTR,
1112
HF_AUDIO_AUTOMATION_DATA_KEY,
@@ -73,6 +74,21 @@ export function withoutLane(automation: HfAutomation, target: string): HfAutomat
7374
return { version: 1, lanes: automation.lanes.filter((lane) => lane.target !== target) };
7475
}
7576

77+
/**
78+
* Replace one lane, leaving every other lane alone.
79+
*
80+
* A script that hands back a whole `HfAutomation` describes only its OWN lane.
81+
* Writing that wholesale would take the carve's lanes and the volume lane with
82+
* it, so what the script produces has to be merged in by target rather than
83+
* swapped for what is already there.
84+
*/
85+
export function withLane(automation: HfAutomation, lane: HfAutomationLane): HfAutomation {
86+
return {
87+
version: 1,
88+
lanes: [...automation.lanes.filter((l) => l.target !== lane.target), lane],
89+
};
90+
}
91+
7692
/** The attribute value for an automation set; empty when nothing is automated. */
7793
export function automationAttrValue(automation: HfAutomation): string {
7894
return automation.lanes.length > 0 ? serializeAutomation(automation) : "";

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ function mount(overrides: Partial<Parameters<typeof FxSection>[0]> = {}) {
6969
automatedTargets={overrides.automatedTargets}
7070
onAutomateParam={overrides.onAutomateParam}
7171
onRemoveParamAutomation={overrides.onRemoveParamAutomation}
72+
onLevel={overrides.onLevel}
73+
onRemoveLevel={overrides.onRemoveLevel}
74+
levelled={overrides.levelled}
7275
/>,
7376
);
7477
return { host, onChainChange, onChainPreview, onCarveChange };
@@ -386,6 +389,22 @@ describe("FxSection chain", () => {
386389
expect(next.nodes.find((n) => n.label === "Middle")!.params!.gain).toBe(0);
387390
});
388391

392+
it("offers levelling, and offers to take it away once it is there", () => {
393+
const onLevel = vi.fn();
394+
const onRemoveLevel = vi.fn();
395+
const { host } = mount({ onLevel, onRemoveLevel });
396+
click(host.querySelector(".hf-fx-add"));
397+
click(byText(host, ".hf-fx-add-composite", "Even Out Levels"));
398+
expect(onLevel).toHaveBeenCalledTimes(1);
399+
400+
const already = mount({ onLevel, onRemoveLevel, levelled: true });
401+
click(already.host.querySelector(".hf-fx-add"));
402+
// The same control, because adding a second levelling stage is never what
403+
// an author means by pressing it twice.
404+
click(byText(already.host, ".hf-fx-add-composite", "Remove levelling"));
405+
expect(onRemoveLevel).toHaveBeenCalledTimes(1);
406+
});
407+
389408
it("cannot move the ends past themselves", () => {
390409
const { host } = mount({ chain: chainOf("peaking", "reverb") });
391410
const ups = host.querySelectorAll('.hf-fx-move[title="Move up"]');

packages/studio/src/components/editor/propertyPanelFxSection.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,12 @@ export interface FxSectionProps {
649649
onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
650650
/** Delete every lane belonging to a node that is being removed. */
651651
onRemoveNodeAutomation?(nodeId: string): void;
652+
/** Measure this track and write the levelling lane. Absent when unavailable. */
653+
onLevel?(): void;
654+
/** Take the levelling stage and its lane back out. */
655+
onRemoveLevel?(): void;
656+
/** Whether a levelling stage is already on the track. */
657+
levelled?: boolean;
652658
/** Structural edits and gesture-end writes; this is the one that persists. */
653659
onChainChange(chain: HfAudioFxChain): void;
654660
/** Continuous updates while a control is being dragged. */
@@ -686,6 +692,9 @@ export function FxSection({
686692
sourceOptions,
687693
analysing,
688694
disabled,
695+
onLevel,
696+
onRemoveLevel,
697+
levelled,
689698
}: FxSectionProps) {
690699
// Falls back to the persisting write when no preview handler is supplied, which
691700
// keeps the control working rather than going dead.
@@ -908,6 +917,21 @@ export function FxSection({
908917
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
909918
Tone
910919
</span>
920+
{onLevel ? (
921+
<button
922+
type="button"
923+
className="hf-fx-add-composite rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
924+
title="Listen to this track and even out its loud and quiet parts."
925+
disabled={disabled || analysing}
926+
onClick={() => {
927+
if (levelled) onRemoveLevel?.();
928+
else onLevel();
929+
setAdding(false);
930+
}}
931+
>
932+
{levelled ? "Remove levelling" : "Even Out Levels"}
933+
</button>
934+
) : null}
911935
<button
912936
type="button"
913937
// Not hf-fx-add-item: Tone is a composite over several filters,

0 commit comments

Comments
 (0)