Skip to content

Commit acdb112

Browse files
vanceingallsclaude
andauthored
feat: switch a preset off, or ramp it, as one thing (#3189)
* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a preview-only commit that useDomEditAttributeCommits.ts never grew — backported that option support from its own later commit so the two sides of the API agree. The paste path and its tests were missing the box selection's v0/v1 bounds a sibling commit added to AutomationSelection. The FX panel's carve controls still edited the six mechanism numbers (maxCutDb, bands, intelligibilityBias) after carveProfile() collapsed authoring to one Strength knob, so those fields no longer existed on HfCarveSettings; UI now edits strength, and analyseCarveBands is called with carveProfile(strength). Also closes fallow's complexity, dead-code and duplication findings on this PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math) and useAutomationRangeDrag.ts (the marquee-select gesture) out of useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a resolver into named functions, dropped an export nothing outside its file used, and shared a step-simplifier between audioCarve's two envelope builders. The edge-stretch vs. box-select priority test in TimelineAutomationLane.test was still pinning the pre-box-select rule (edge wins over a point sitting on it) that a sibling commit deliberately reversed — a point inside the box is now selected content, so grabbing it drags the group instead. Updated the test to the shipped rule instead of the old one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): cap the via conic's weight so an edge-clamped via point can't NaN A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to (0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0. viaConic divided by that zero to get an infinite weight, and shapeVia turned Infinity into NaN a few steps later (Infinity - Infinity in the quadratic coefficient). NaN reaching setValueCurveAtTime silences the automated parameter for the rest of the render. Capped the weight at 1e6 instead of leaving it unbounded — past that point the arc already reads as touching the via point, so nothing visible is lost. Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`, since NaN fails the original comparison and fell through it. Review by Miga (PR #3208). * fix(studio-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * fix(studio): widen PropertyPanel's resetModules render timeout again The 20s margin (already once widened for the same reason) is timing out in CI's full-monorepo Test run — the resetModules()+fresh-import render this test needs is uncached and competes with every other package's test suite for the same worker pool, and the same test passes in well under 2s standalone. Went to 45s rather than re-tuning to whatever number happens to clear the current CI load, since that number moves every time CI gains a package. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 29f516d commit acdb112

11 files changed

Lines changed: 616 additions & 20 deletions

packages/core/src/audio/audioFxAutomation.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,24 @@ export function scheduleChainAutomation(
245245
chain: HfAudioFxChain,
246246
nodes: readonly AutomatableNode[],
247247
timing: AutomationTiming,
248+
/** The wet/dry blend around each preset run, from `FxChainHandle.presets`. */
249+
presets?: Record<string, FxParamTarget[]>,
248250
): FxParamTarget[] {
249251
const byId = new Map(nodes.filter((n) => n.id).map((n) => [n.id as string, n.handle]));
250252
const scheduled: FxParamTarget[] = [];
251253
for (const lane of automation.lanes) {
252254
const parsed = parseAutomationTarget(lane.target);
253-
if (!parsed || parsed.kind !== "fx") continue;
254-
const targets = byId.get(parsed.nodeId)?.automation?.[parsed.param];
255+
if (!parsed) continue;
256+
// A whole-preset lane drives the wet/dry blend the graph wrapped its run in,
257+
// rather than any node's parameter — which is the point of it: a preset's
258+
// nodes share no automatable parameter, and its worklet effects expose none
259+
// at all.
260+
const targets =
261+
parsed.kind === "preset"
262+
? presets?.[parsed.presetId]
263+
: parsed.kind === "fx"
264+
? byId.get(parsed.nodeId)?.automation?.[parsed.param]
265+
: undefined;
255266
if (!targets || targets.length === 0) continue;
256267
const range = resolveAutomationRange(lane.target, chain);
257268
if (!range) continue;

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,3 +624,77 @@ describe("chain update keeps ids with their effects", () => {
624624
expect(handle.nodes.map((n) => n.id)).toEqual(["n2", "n1"]);
625625
});
626626
});
627+
628+
describe("a preset's run is wrapped in a wet/dry blend", () => {
629+
/** Two nodes from one preset, with an ordinary effect after them. */
630+
const chainWith = (amount?: number): HfAudioFxChain => ({
631+
version: 1,
632+
nodes: [
633+
{
634+
type: "highpass",
635+
id: "p1",
636+
fromPreset: "telephone",
637+
enabled: true,
638+
...(amount === undefined ? {} : { presetAmount: amount }),
639+
params: defaultAudioFxParams("highpass"),
640+
},
641+
{
642+
type: "lowpass",
643+
id: "p2",
644+
fromPreset: "telephone",
645+
enabled: true,
646+
params: defaultAudioFxParams("lowpass"),
647+
},
648+
{ type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
649+
],
650+
});
651+
652+
it("exposes one blend for the whole preset, not one per node", () => {
653+
// The reason this exists: a preset's nodes share no automatable parameter,
654+
// and its worklet effects expose no AudioParams at all, so there is nothing
655+
// to aim a lane at node-by-node.
656+
const built = buildFxChain(asCtx(ctx()), chainWith());
657+
expect(Object.keys(built.presets)).toEqual(["telephone"]);
658+
// Two gains in opposition, the same shape an effect's own mix knob has.
659+
expect(built.presets.telephone).toHaveLength(2);
660+
});
661+
662+
it("blends dry against wet at the stored amount", () => {
663+
const built = buildFxChain(asCtx(ctx()), chainWith(0.25));
664+
const [wet, dry] = built.presets.telephone ?? [];
665+
expect(wet?.param.value).toBeCloseTo(0.25, 6);
666+
expect(dry?.param.value).toBeCloseTo(0.75, 6);
667+
});
668+
669+
it("is fully applied when nothing says otherwise", () => {
670+
// Every chain written before this shipped means "all of it".
671+
const [wet, dry] = buildFxChain(asCtx(ctx()), chainWith()).presets.telephone ?? [];
672+
expect(wet?.param.value).toBe(1);
673+
expect(dry?.param.value).toBe(0);
674+
});
675+
676+
it("pushes a changed amount into the running graph rather than rebuilding", () => {
677+
// Switching a preset off is a value change, and a rebuild would restart the
678+
// audio underneath it.
679+
const built = buildFxChain(asCtx(ctx()), chainWith(1));
680+
expect(built.update(chainWith(0))).toBe(true);
681+
const [wet, dry] = built.presets.telephone ?? [];
682+
expect(wet?.param.value).toBe(0);
683+
expect(dry?.param.value).toBe(1);
684+
});
685+
686+
it("wraps nothing around effects the author placed themselves", () => {
687+
const built = buildFxChain(asCtx(ctx()), chain("peaking", "reverb"));
688+
expect(Object.keys(built.presets)).toEqual([]);
689+
});
690+
691+
it("unwires the blend on dispose", () => {
692+
// The wrap belongs to the chain rather than to any effect, so it is not in
693+
// `handles` — without this a rebuild leaves a crossfade connected to the
694+
// graph it used to bridge.
695+
const c = ctx();
696+
buildFxChain(asCtx(c), chainWith(0.5)).dispose();
697+
const live = c.created.filter((n) => n.kind === "gain" && !n.disconnected);
698+
expect(live).toEqual([]);
699+
});
700+
});

packages/core/src/audio/audioFxGraph.ts

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getAudioFxDef,
1414
normalizeAudioFxParams,
1515
type HfAudioFxChain,
16+
type HfAudioFxNode,
1617
type HfAudioFxParamValues,
1718
} from "../audioFx.js";
1819
import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js";
@@ -539,11 +540,45 @@ export interface FxChainHandle {
539540
output: AudioNode;
540541
/** Built effects in chain order, carrying the node ids lanes address. */
541542
nodes: { id?: string; type: string; handle: FxNodeHandle }[];
543+
/**
544+
* The wet/dry blend around each preset run, by preset id — where a
545+
* whole-preset lane writes. Two gains in opposition, the same shape
546+
* `mixTargets` builds for an effect's own mix knob.
547+
*/
548+
presets: Record<string, FxParamTarget[]>;
542549
/** Re-parameterise in place when the shape is unchanged; false if a rebuild is needed. */
543550
update(chain: HfAudioFxChain): boolean;
544551
dispose(): void;
545552
}
546553

554+
/**
555+
* Consecutive nodes grouped by the preset that wrote them.
556+
*
557+
* `amount` comes off the nodes themselves — a preset is bypassed by setting its
558+
* members' `enabled` to false everywhere else in the codebase, and the wrap has
559+
* to agree with that or the switch and the lane would fight. Absent means fully
560+
* applied, which is what every chain written before this shipped means.
561+
*/
562+
function presetRuns(
563+
nodes: readonly HfAudioFxNode[],
564+
): { preset?: string; amount: number; nodes: HfAudioFxNode[] }[] {
565+
const out: { preset?: string; amount: number; nodes: HfAudioFxNode[] }[] = [];
566+
for (const node of nodes) {
567+
const preset = node.fromPreset;
568+
const last = out.at(-1);
569+
if (last && last.preset === preset) last.nodes.push(node);
570+
else {
571+
const amount = typeof node.presetAmount === "number" ? node.presetAmount : 1;
572+
out.push({
573+
...(preset ? { preset } : {}),
574+
amount: Math.min(1, Math.max(0, amount)),
575+
nodes: [node],
576+
});
577+
}
578+
}
579+
return out;
580+
}
581+
547582
/**
548583
* A signature of everything that changes the graph's *shape* rather than its
549584
* parameter values. When this is unchanged an update can just push new values
@@ -585,21 +620,66 @@ export function buildFxChain(
585620
const input = ctx.createGain();
586621
const output = ctx.createGain();
587622
const handles: { id?: string; type: string; handle: FxNodeHandle }[] = [];
623+
const presets: { id: string; entry: GainNode; wet: GainNode; dry: GainNode; join: GainNode }[] =
624+
[];
625+
626+
/**
627+
* A preset's consecutive nodes, wrapped in a wet/dry pair.
628+
*
629+
* The rest of the chain is a strict series, which is right for an effect the
630+
* author placed: it is either in the path or it is not. A preset is not one
631+
* effect, though — it is several the author added as a unit, and "how much of
632+
* it is applied" is a question about the unit. Its nodes share no automatable
633+
* parameter, and the worklet ones expose no AudioParams at all, so there is
634+
* nothing to aim a lane at node-by-node. One crossfade around the run is the
635+
* whole answer, and it cannot go half-wrong the way seven lanes can.
636+
*
637+
* Consecutive only, matching what the rack brackets: a preset pulled apart by
638+
* a reorder is no longer a unit, and wrapping across the gap would route the
639+
* effect between its members through the dry leg too.
640+
*/
641+
const runs = presetRuns(enabledAudioFxNodes(chain));
588642

589643
let tail: AudioNode = input;
590-
for (const node of enabledAudioFxNodes(chain)) {
591-
const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed);
592-
tail.connect(handle.input);
593-
tail = handle.output;
594-
handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle });
644+
for (const run of runs) {
645+
let wrap: { entry: GainNode; wet: GainNode; dry: GainNode; join: GainNode } | null = null;
646+
if (run.preset) {
647+
const entry = ctx.createGain();
648+
const dry = ctx.createGain();
649+
const wet = ctx.createGain();
650+
const join = ctx.createGain();
651+
wet.gain.value = run.amount;
652+
dry.gain.value = 1 - run.amount;
653+
tail.connect(entry);
654+
// The dry leg bridges the whole run: it leaves before the first effect and
655+
// rejoins after the last, which is what makes amount 0 the untouched
656+
// signal rather than a quieter version of the processed one.
657+
entry.connect(dry).connect(join);
658+
wrap = { entry, wet, dry, join };
659+
tail = entry;
660+
}
661+
for (const node of run.nodes) {
662+
const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed);
663+
tail.connect(handle.input);
664+
tail = handle.output;
665+
handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle });
666+
}
667+
if (wrap && run.preset) {
668+
tail.connect(wrap.wet).connect(wrap.join);
669+
presets.push({ id: run.preset, ...wrap });
670+
tail = wrap.join;
671+
}
595672
}
596673
tail.connect(output);
597674

598675
const shape = shapeOf(chain);
599676

677+
const presetTargets: Record<string, FxParamTarget[]> = {};
678+
for (const p of presets) presetTargets[p.id] = mixTargets(p.wet.gain, p.dry.gain);
600679
return {
601680
input,
602681
output,
682+
presets: presetTargets,
603683
nodes: handles,
604684
update(next) {
605685
if (shapeOf(next) !== shape) return false;
@@ -616,13 +696,31 @@ export function buildFxChain(
616696
if (node.id === undefined) delete held.id;
617697
else held.id = node.id;
618698
});
619-
// `shape` is not reassigned: the early return above already established
699+
// The blend is a value like any other: switching a preset off writes
700+
// `presetAmount`, and pushing it into the running graph is what keeps that
701+
// from being a rebuild — and from restarting the audio underneath it.
702+
for (const run of presetRuns(enabledAudioFxNodes(next))) {
703+
if (!run.preset) continue;
704+
const wrap = presets.find((p) => p.id === run.preset);
705+
if (!wrap) continue;
706+
wrap.wet.gain.value = run.amount;
707+
wrap.dry.gain.value = 1 - run.amount;
708+
} // `shape` is not reassigned: the early return above already established
620709
// that `shapeOf(next)` equals it, so recomputing was a whole normalise +
621710
// join per observer tick to write back the string that was already there.
622711
return true;
623712
},
624713
dispose() {
625714
for (const { handle } of handles) handle.dispose();
715+
// The wrap is not one of `handles` — it belongs to the chain rather than
716+
// to any effect — so it has to be unwired here or a rebuild leaves a
717+
// crossfade still connected to the graph it used to bridge.
718+
for (const { entry, wet, dry, join } of presets) {
719+
entry.disconnect();
720+
wet.disconnect();
721+
dry.disconnect();
722+
join.disconnect();
723+
}
626724
input.disconnect();
627725
output.disconnect();
628726
},

packages/core/src/audioAutomation.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,22 @@ export class AudioAutomationError extends Error {
7979

8080
export const VOLUME_TARGET = "volume";
8181

82-
export type HfAutomationTarget = { kind: "volume" } | { kind: "fx"; nodeId: string; param: string };
82+
export type HfAutomationTarget =
83+
| { kind: "volume" }
84+
| { kind: "fx"; nodeId: string; param: string }
85+
| { kind: "preset"; presetId: string };
8386

8487
/** Split a target string. Returns null for anything unrecognised. */
8588
export function parseAutomationTarget(target: string): HfAutomationTarget | null {
8689
if (target === VOLUME_TARGET) return { kind: "volume" };
8790
const parts = target.split(".");
91+
// `fx.preset.<id>` before the 3-part fx form, because it IS a 3-part fx form
92+
// with a reserved node id — an effect can never be called "preset", since ids
93+
// are minted `n1`, `n2`, ….
94+
if (parts.length === 3 && parts[0] === "fx" && parts[1] === PRESET_TARGET_KEY) {
95+
const presetId = parts[2];
96+
return presetId ? { kind: "preset", presetId } : null;
97+
}
8898
if (parts.length !== 3 || parts[0] !== "fx") return null;
8999
const [, nodeId, param] = parts;
90100
if (!nodeId || !param) return null;
@@ -95,6 +105,33 @@ export function fxAutomationTarget(nodeId: string, param: string): string {
95105
return `fx.${nodeId}.${param}`;
96106
}
97107

108+
/** The reserved node-id slot that marks a whole-preset target. */
109+
const PRESET_TARGET_KEY = "preset";
110+
111+
/**
112+
* How much of a preset is applied, 0..1.
113+
*
114+
* A preset's nodes share no automatable parameter — and its worklet effects
115+
* expose no AudioParams at all — so there is nothing to aim a lane at
116+
* node-by-node. The graph wraps a preset's run in a wet/dry pair instead, and
117+
* this drives the blend: 0 is the dry signal untouched, 1 is the preset fully
118+
* applied, and between them it crossfades.
119+
*/
120+
export function presetAutomationTarget(presetId: string): string {
121+
return `fx.${PRESET_TARGET_KEY}.${presetId}`;
122+
}
123+
124+
/** 0..1 blend, the same shape as a wet/dry mix knob. */
125+
export const PRESET_RANGE: AutomationRange = {
126+
min: 0,
127+
max: 1,
128+
step: 0.01,
129+
unit: "",
130+
label: "Amount",
131+
scale: "linear",
132+
default: 1,
133+
};
134+
98135
/**
99136
* The value range a lane is drawn and clamped against.
100137
*
@@ -136,6 +173,13 @@ export function resolveAutomationRange(
136173
const parsed = parseAutomationTarget(target);
137174
if (!parsed) return null;
138175
if (parsed.kind === "volume") return VOLUME_RANGE;
176+
if (parsed.kind === "preset") {
177+
// Only for a preset the chain actually carries, so a lane left behind by a
178+
// removed preset resolves to nothing and is dropped at read time — the same
179+
// contract an orphaned node lane has.
180+
const present = chain?.nodes.some((n) => n.fromPreset === parsed.presetId);
181+
return present ? { ...PRESET_RANGE, label: `${parsed.presetId} · Amount` } : null;
182+
}
139183
const node = chain?.nodes.find((n) => n.id === parsed.nodeId);
140184
if (!node) return null;
141185
const def = getAudioFxDef(node.type);

packages/core/src/audioFx.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -832,10 +832,21 @@ export interface HfAudioFxNode {
832832
* effect type and its bands stay ordinary filters underneath.
833833
*/
834834
fromEq?: string;
835+
835836
/**
837+
* How much of this node's preset is applied, 0..1 — the wet/dry blend the
838+
* graph wraps its run in.
839+
*
840+
* On every node of the run rather than beside the chain, because the chain has
841+
* nowhere else to put it: `HfAudioFxChain` is a version and a list of nodes,
842+
* and a preset is defined by which nodes carry its tag. The graph reads it off
843+
* the first node of each run. Absent means fully applied, which is what every
844+
* chain written before this means.
845+
*/
846+
presetAmount?: number /**
836847
* Set on the gain stage the leveller writes, so re-running replaces it rather
837848
* than stacking a second one — the same contract `fromCarve` has.
838-
*/
849+
*/;
839850
fromLeveller?: boolean;
840851
/** Absent means enabled — chain files written before the field existed still load. */
841852
enabled?: boolean;
@@ -890,6 +901,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
890901
label?: unknown;
891902
fromEq?: unknown;
892903
fromLeveller?: unknown;
904+
presetAmount?: unknown;
893905
};
894906
if (typeof node.type !== "string" || !BY_ID.has(node.type)) {
895907
throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`);
@@ -907,6 +919,11 @@ export function parseAudioFxChain(json: string): HfAudioFxChain {
907919
...(typeof node.label === "string" && node.label ? { label: node.label } : {}),
908920
...(typeof node.fromEq === "string" && node.fromEq ? { fromEq: node.fromEq } : {}),
909921
...(node.fromLeveller === true ? { fromLeveller: true as const } : {}),
922+
// Clamped on the way in: the blend is two gains in opposition, and a value
923+
// outside 0..1 makes the dry leg negative rather than simply loud.
924+
...(typeof node.presetAmount === "number" && Number.isFinite(node.presetAmount)
925+
? { presetAmount: Math.min(1, Math.max(0, node.presetAmount)) }
926+
: {}),
910927
enabled: node.enabled !== false,
911928
params: normalizeAudioFxParams(
912929
node.type,
@@ -934,6 +951,11 @@ export function serializeAudioFxChain(chain: HfAudioFxChain): string {
934951
...(node.label ? { label: node.label } : {}),
935952
...(node.fromEq ? { fromEq: node.fromEq } : {}),
936953
...(node.fromLeveller === true ? { fromLeveller: true } : {}),
954+
// Omitted when fully applied, so an untouched preset does not grow a field
955+
// in every chain that carries one.
956+
...(typeof node.presetAmount === "number" && node.presetAmount !== 1
957+
? { presetAmount: node.presetAmount }
958+
: {}),
937959
...(node.enabled === false ? { enabled: false } : {}),
938960
params: normalizeAudioFxParams(node.type, node.params),
939961
})),

0 commit comments

Comments
 (0)