fix(studio): stop the carve measuring the bed, reciprocating, and firing twice

Review findings 9, 10, 11 plus the prune-sentinel tail item. All four are the
group-first carve shape catching up with code written when `sources` held clip
ids.

**9 — the far-end guard was defeated by the shape lint asks for.**
`carvesAgainst` matched a carve's `sources` against raw clip ids and never
expanded a group. A plural carve now names a GROUP (that is what
`audio_carve_ungrouped_sources` exists to push authors toward), so
`.includes(memberId)` stopped matching: `carverAgainst` returned null, the carve
module was offered on a voice clip a bed is already ducking against, and
switching it on wrote a reciprocal carve — each side measuring audio the other
is already attenuating. Sources are expanded through `resolveCarveSourceIds`
first now, which `useFxCarve` already imported for exactly this.

**10 — the bed was measured as one of its own voices.** A group expands to its
CURRENT members, so once the bed joins the group its own carve names — one
timeline drag — `resolveCarveVoices` accepted it: peaking notches at the bed's
own spectral peaks and a duck envelope that dips whenever the bed is loud,
written to `data-fx-chain` / `data-automation` and baked into the export. The
bed's id is excluded at the analysis entry, not inside core's generic resolver,
because the exclusion is a fact about this analysis and not about resolution.
`excludedFor` only guards the picker while it is offering options.

**11 — the await opened a double-fire window the `<= 1` / `!== 1` split cannot
see across.** `setCarve` awaits group creation, which live-patches
`data-audio-group` and calls `updateElement` per member — a store notification.
React re-renders mid-flight and before the carve attribute is written, so the
candidate count collapses 2 → 1 while `carve` is still null and the sibling
single-candidate effect fires a SECOND concurrent `setCarve`: two
read-modify-write saves of `data-fx-carve` against one file (lost update) and
two analyse() runs — two decodes, two FFT passes, two competing chain/automation
writes. One in-flight latch now guards both effects, applied only to AUTO
decisions: a manual change from the panel stays interruptible.

**Tail — the prune never ran for a group bed.** Its "is the timeline loaded"
sentinel was `present.has(element.id)`, and a group is not a timeline element,
so a bus carrying its own carve always bailed. It now accepts either proof.

`useFxCarve.ts` would have gone to 619 lines, so the compile half —
`mintCarveNodes`, `measureCarve`, `carveLanes`, `carveLaneFor` — moved to
`useFxCarveNodes.ts` first. 580 -> 442 + 153, both under the cap, and the split
is the natural one: that half is pure, the hook keeps effects and persistence.

Three tests for 9, each verified against a revert. studio: 390 files.
This commit is contained in:
Vance Ingalls
2026-08-20 16:41:19 -07:00
parent 608c3b50ba
commit 730ea1eb06
4 changed files with 284 additions and 176 deletions
@@ -7,24 +7,15 @@
* the file grew past a size where "the carve" was still one thing to read.
*/
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import {
defaultAudioFxParams,
HF_AUDIO_FX_ATTR,
mintAudioFxNodeId,
serializeAudioFxChain,
type HfAudioFxChain,
type HfAudioFxNode,
} from "@hyperframes/core/audio-fx";
import {
analyseCarveBands,
analyseCarveDuck,
analyseCarveDynamics,
carveBandsToChain,
carveProfile,
clipsOverlap,
DEFAULT_CARVE,
mixCarveSources,
HF_AUDIO_CARVE_ATTR,
type HfCarveSettings,
} from "@hyperframes/core/audio-carve";
@@ -37,23 +28,19 @@ import {
isPromiseLike,
resolveNextCarveSettings,
} from "./useFxCarveGrouping.js";
import {
fxAutomationTarget,
type HfAutomation,
type HfAutomationLane,
} from "@hyperframes/core/audio-automation";
import { type HfAutomation } from "@hyperframes/core/audio-automation";
import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAutomation";
import { trackCarveChanged } from "./audioFxTelemetry.js";
import type { DomEditSelection } from "./domEditingTypes";
import { usePlayerStore } from "../../player";
import { clipStart, spanOf } from "./propertyPanelAudioFxGroupUtils.js";
import { carveLanes, measureCarve, mintCarveNodes } from "./useFxCarveNodes.js";
import { spanOf } from "./propertyPanelAudioFxGroupUtils.js";
import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
/**
* Rate the carve source is decoded at. Analysis is self-consistent because it
* reads the decoded buffer's own rate, so this only has to be a sane audio rate.
*/
const DECODE_SAMPLE_RATE = 48000;
/**
* Which carve setting actually moved, by comparing the two snapshots.
@@ -129,131 +116,6 @@ function resolveCarveVoices(
return voices;
}
/**
* Turns the analysed bands (and, if the carve is asked to match levels, a
* ducking envelope) into chain nodes — tagged so a re-run replaces them
* instead of stacking, and minted against the nodes already claiming an id
* because a dynamic carve automates these filters and a lane addresses its
* node by id.
*/
function mintCarveNodes(
chain: HfAudioFxChain,
carved: HfAudioFxChain,
duck: { t: number; v: number }[],
): { next: HfAudioFxChain; carvedNodes: HfAudioFxNode[]; duckNode: HfAudioFxNode | null } {
const kept = chain.nodes.filter((n) => !n.fromCarve);
let claimed: HfAudioFxChain = { version: 1, nodes: kept };
const mint = (node: HfAudioFxNode): HfAudioFxNode => {
const withId = { ...node, id: mintAudioFxNodeId(claimed), fromCarve: true };
claimed = { version: 1, nodes: [...claimed.nodes, withId] };
return withId;
};
const carvedNodes: HfAudioFxNode[] = carved.nodes.map(mint);
// The gain stage sits after the filters, and only exists when the carve was
// asked to make level room. It sits at 0 and is driven by the envelope below.
const duckNode =
duck.length > 0
? mint({ type: "gain", enabled: true, params: { ...defaultAudioFxParams("gain"), gain: 0 } })
: null;
return {
next: { version: 1, nodes: [...carvedNodes, ...(duckNode ? [duckNode] : []), ...kept] },
carvedNodes,
duckNode,
};
}
/**
* Decode every voice, mix them onto the bed's own clock, and measure the
* bands (and, if the profile calls for it, the ducking envelope) from that
* mix. Null on anything that leaves nothing to build a carve from — the
* platform lacking an offline context, or a mix that decoded to silence.
*/
async function measureCarve(
doc: Document,
voices: { src: string; start: string | null }[],
strength: number,
bedStartAttr: string | null | undefined,
bedSrc: string | null | undefined,
): Promise<{
bands: ReturnType<typeof analyseCarveBands>;
carved: HfAudioFxChain;
duck: { t: number; v: number }[];
voiceMix: Float32Array;
} | null> {
// Decoded in an OfflineAudioContext, not a live one. Opening a second output
// device mid-playback makes the running track glitch while the hardware is
// reconfigured; an offline context touches no device.
const Ctor =
window.OfflineAudioContext ??
(window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
.webkitOfflineAudioContext;
if (!Ctor) return null;
const decode = async (relative: string): Promise<AudioBuffer> => {
const res = await fetch(new URL(relative, doc.baseURI).href);
return new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(await res.arrayBuffer());
};
const bedStart = clipStart(bedStartAttr);
// Every voice, summed onto the bed's own clock. One question — where and
// when is speech masking this bed — with one answer, even when the answer
// comes from three people talking at different times. Doing this before the
// analysis is also what lets the bands and the envelopes stay a single set:
// the chain is fixed, so there is no per-voice filter to switch between.
const decoded = await Promise.all(
voices.map(async (voice) => ({
samples: (await decode(voice.src)).getChannelData(0),
offsetSeconds: clipStart(voice.start) - bedStart,
})),
);
const voiceMix = mixCarveSources(decoded, DECODE_SAMPLE_RATE);
if (voiceMix.length === 0) return null;
// Strength is what the author set; these are the numbers it means.
const profile = carveProfile(strength);
// The bed as well as the voice, when the carve is asked to match levels:
// "how far over the voice is this bed" cannot be answered by listening to
// one of them.
const bedBuffer = profile.duckDb > 0 && bedSrc ? await decode(bedSrc).catch(() => null) : null;
const bands = analyseCarveBands(voiceMix, DECODE_SAMPLE_RATE, profile);
// The level half of the carve, measured against the speech it has to sit
// under. No offset to apply: the mix is already on the bed's clock.
const duck = bedBuffer
? analyseCarveDuck(voiceMix, bedBuffer.getChannelData(0), DECODE_SAMPLE_RATE, profile, 0)
: [];
return { bands, carved: carveBandsToChain(bands), duck, voiceMix };
}
/** Each filter's depth as an envelope, plus the level envelope if there is one. */
function carveLanes(
carvedNodes: HfAudioFxNode[],
duckNode: HfAudioFxNode | null,
duck: { t: number; v: number }[],
voiceMix: Float32Array,
bands: ReturnType<typeof analyseCarveBands>,
): HfAutomationLane[] {
// Each filter's depth becomes an envelope of the speech's level in that
// band, so pauses leave the bed alone and whoever is talking sets the depth.
const lanes = analyseCarveDynamics(voiceMix, DECODE_SAMPLE_RATE, bands).flatMap((dyn, i) => {
const id = carvedNodes[i]?.id;
return id ? carveLaneFor(id, dyn.points) : [];
});
// The level envelope rides the gain stage, on the same clock as the bands.
if (duckNode?.id && duck.length > 0) lanes.push(...carveLaneFor(duckNode.id, duck));
return lanes;
}
/**
* One carve envelope as a lane on this bed's clock.
*
* No shifting: the voices were summed onto the bed's clock before the analysis
* ran, so what comes back is already in the bed's own time. A lane does hold
* its first value backwards to the start of its clip, so an envelope that
* begins later needs an explicit "no cut" at zero or the bed starts out ducked.
*/
function carveLaneFor(id: string, points: { t: number; v: number }[]): HfAutomationLane[] {
const timed = points.map((p) => ({ t: Number(p.t.toFixed(3)), v: p.v })).filter((p) => p.t >= 0);
if ((timed[0]?.t ?? 0) > 0) timed.unshift({ t: 0, v: 0 });
return timed.length > 1 ? [{ target: fxAutomationTarget(id, "gain"), points: timed }] : [];
}
/**
* What the carve generated is only justified by the voices it was measured
* from: switched off, or left naming none — every source deleted, say — there
@@ -374,13 +236,18 @@ export function useFxCarve(
// player — reads as "every voice was deleted" and throws away a carve that
// is perfectly fine. Unchanged sources are what the prune treats as nothing
// to do.
if (!element.id || !present.has(element.id)) return carve.sources;
// A group id is never in `present` — it's not a timeline element — so it
// needs its own existence check: a group survives as long as it still has
// at least one member, checked against the live document the same way the
// picker resolves groups above.
const doc = element.element?.ownerDocument;
const groupIds = doc ? new Set(resolveAudioGroups(doc).map((g) => g.id)) : new Set<string>();
// The BED can be a group too — a bus carrying its own carve — and a group id
// is never in `present`, so the sentinel rejected every group bed and the
// prune simply never ran for one. Accept either proof that the timeline
// describes this composition.
if (!element.id || !(present.has(element.id) || groupIds.has(element.id))) {
return carve.sources;
}
// Same reason a group source needs its own check: it is not a timeline
// element. A group survives as long as it still has at least one member,
// read off the live document the way the picker resolves groups above.
return carve.sources.filter((id) => present.has(id) || groupIds.has(id));
})();
@@ -427,6 +294,59 @@ export function useFxCarve(
// carveLanes); what is left is the orchestration between them, including the
// two-attribute write order the comments below explain the reason for.
// fallow-ignore-next-line complexity
/**
* One auto-carve decision at a time.
*
* `setCarve` awaits `resolveNextCarveSettings`, which awaits group creation —
* and that live-patches `data-audio-group` onto each member and calls
* `updateElement` per member, a store notification. So React re-renders while
* the first `setCarve` is still in flight and BEFORE the carve attribute has
* been written: the candidate count legitimately collapses 2 → 1 (two clips
* became one group) while `carve` is still null, and the sibling
* single-candidate effect fires a second concurrent `setCarve`. That is two
* read-modify-write saves of `data-fx-carve` against the same file (a lost
* update) and two `analyse()` runs — two decodes, two FFT passes, two
* competing `data-fx-chain` / `data-automation` writes.
*
* The `<= 1` / `!== 1` split between the two effects prevents the same
* double-fire within one render pass; it cannot see across an await.
*/
const autoCarveInFlight = useRef(false);
/** `setCarve` from an auto-decision: latched, so the sibling effect cannot
* start a second one across the await inside it. A manual change from the
* panel is deliberately NOT latched — the author is allowed to interrupt. */
const setCarveAuto = (next: HfCarveSettings): void => {
autoCarveInFlight.current = true;
void setCarve(next).finally(() => {
autoCarveInFlight.current = false;
});
};
/**
* Write what a measurement compiled to: the chain first, then the lanes.
*
* The chain write is live, like every other one — the runtime swaps the graph
* in place, so a reload would only interrupt the audio to reach the same
* filters. It is AWAITED because the automation write is a second
* read-modify-write against the same file (fired together the later one drops
* the earlier), and because a lane naming a node the chain does not carry yet
* is pruned when it is read back.
*/
const persistMeasuredCarve = async (
measured: NonNullable<Awaited<ReturnType<typeof measureCarve>>>,
): Promise<void> => {
const { bands, carved, duck, voiceMix } = measured;
const { next, carvedNodes, duckNode } = mintCarveNodes(chain, carved, duck);
await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
const lanes = carveLanes(carvedNodes, duckNode, duck, voiceMix, bands);
const carriedOver = withoutCarveLanes(automation, chain);
if (lanes.length > 0 || carriedOver.lanes.length !== automation.lanes.length) {
writeAutomation({ version: 1, lanes: [...carriedOver.lanes, ...lanes] });
}
};
const analyse = async (active: HfCarveSettings | null = carve): Promise<void> => {
if (!active?.sources.length) return;
const doc = element.element?.ownerDocument;
@@ -435,7 +355,16 @@ export function useFxCarve(
// a group named here expands to whoever is in it right now, so a fourth
// voice added to an already-carved group is heard on the next analysis
// without anyone editing the carve itself.
const voices = resolveCarveVoices(doc, resolveCarveSourceIds(doc, active.sources));
// The bed is excluded from its own voice list. A group expands to its
// CURRENT members, so once the bed joins the group its own carve names —
// one timeline drag — it was measured as one of its own voices: peaking
// notches at the bed's own spectral peaks and a duck envelope that dips
// whenever the bed is loud, written into `data-fx-chain` and
// `data-automation` and baked into the export. The candidate-list guard
// (`excludedFor`) only runs while the picker is offering options, never
// against sources already persisted.
const expanded = resolveCarveSourceIds(doc, active.sources).filter((id) => id !== element.id);
const voices = resolveCarveVoices(doc, expanded);
if (voices.length === 0) return;
setAnalysing(true);
try {
@@ -448,24 +377,7 @@ export function useFxCarve(
bedSrc,
);
if (!measured) return;
const { bands, carved, duck, voiceMix } = measured;
const { next, carvedNodes, duckNode } = mintCarveNodes(chain, carved, duck);
// Live, like every other chain write: the runtime swaps the graph in
// place, so a reload would only interrupt the audio to reach the same
// filters.
//
// Awaited, because the automation write below is a second read-modify-write
// against the same file — fired together the later one would drop the
// earlier — and because a lane naming a node the chain does not have yet is
// pruned when it is read back.
await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
const lanes = carveLanes(carvedNodes, duckNode, duck, voiceMix, bands);
const carriedOver = withoutCarveLanes(automation, chain);
if (lanes.length > 0 || carriedOver.lanes.length !== automation.lanes.length) {
writeAutomation({ version: 1, lanes: [...carriedOver.lanes, ...lanes] });
}
await persistMeasuredCarve(measured);
} catch {
// Leave the chain as it was; the button simply re-enables.
} finally {
@@ -524,16 +436,17 @@ export function useFxCarve(
// the same result — two decodes, two FFT runs, two concurrent attribute
// writes.
if (carvedAgainstBy || !autoBed || autoSourceIds.length <= 1) return;
if (autoCarveInFlight.current) return;
const all = autoSourceIds;
// Nothing configured: the default carve, pointed at everything it could hear.
if (carve === null) {
void setCarve({ ...DEFAULT_CARVE, sources: all });
setCarveAuto({ ...DEFAULT_CARVE, sources: all });
return;
}
// Configured but naming no voice — switched on before there was anything to
// listen to, or a source list emptied. The card reads the candidates out, so
// they have to be the stored ones too.
if (carve.enabled && carve.sources.length === 0) void setCarve({ ...carve, sources: all });
if (carve.enabled && carve.sources.length === 0) setCarveAuto({ ...carve, sources: all });
// Keyed on the identity of the decision, not on setCarve — which is rebuilt
// every render and would re-fire this.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -559,18 +472,19 @@ export function useFxCarve(
*/
useEffect(() => {
if (carvedAgainstBy || !autoBed || autoSourceIds.length !== 1) return;
if (autoCarveInFlight.current) return;
const only = autoSourceIds[0];
if (!only) return;
// Nothing configured: the default carve, pointed at the one candidate.
if (carve === null) {
void setCarve({ ...DEFAULT_CARVE, sources: [only] });
setCarveAuto({ ...DEFAULT_CARVE, sources: [only] });
return;
}
// Configured but with no voice yet — a carve switched on before there was
// anything to listen to, or one whose source was cleared. The panel reads the
// sole candidate out as the source, so it has to be the stored one too;
// otherwise the card claims a relationship the attribute does not record.
if (carve.enabled && carve.sources.length === 0) void setCarve({ ...carve, sources: [only] });
if (carve.enabled && carve.sources.length === 0) setCarveAuto({ ...carve, sources: [only] });
// Deliberately keyed on the identity of the decision, not on setCarve — which
// is rebuilt every render and would re-fire this.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { collectCarveCandidates } from "./useFxCarveGrouping";
import { carverAgainst, collectCarveCandidates } from "./useFxCarveGrouping";
function previewDoc(html: string): Document {
const doc = document.implementation.createHTMLDocument("preview");
@@ -53,3 +53,32 @@ describe("collectCarveCandidates", () => {
expect(candidatesFor(doc, "music-bed")).toEqual(["vo-1"]);
});
});
describe("carverAgainst", () => {
// The far-end guard has to see through a GROUP source. A plural carve names a
// group — that is what the lint rule pushes authors toward — so matching raw
// ids never found the member, the carve module was offered on a voice already
// being ducked against, and switching it on wrote a reciprocal carve.
it("finds the bed carving a voice through its group", () => {
const doc = previewDoc(`
${GROUPED_VOICES}
<audio id="bed" data-fx-carve='{"enabled":true,"sources":["voiceover"],"strength":0.3}'></audio>
`);
expect(carverAgainst(doc, "vo-1")).toBe("bed");
expect(carverAgainst(doc, "vo-2")).toBe("bed");
});
it("still finds a carve that names the clip directly", () => {
const doc = previewDoc(`
${GROUPED_VOICES}
<audio id="bed" data-fx-carve='{"enabled":true,"sources":["vo-1"],"strength":0.3}'></audio>
`);
expect(carverAgainst(doc, "vo-1")).toBe("bed");
expect(carverAgainst(doc, "vo-2")).toBeNull();
});
it("is null for a track nobody carves against", () => {
const doc = previewDoc(GROUPED_VOICES);
expect(carverAgainst(doc, "vo-1")).toBeNull();
});
});
@@ -15,7 +15,7 @@ import {
isNamedCarveBed,
type HfCarveSettings,
} from "@hyperframes/core/audio-carve";
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
import { resolveAudioGroups, resolveCarveSourceIds } from "@hyperframes/core/audio-groups";
/**
* An id for a new voiceover group, de-duped against every id already in the
@@ -200,11 +200,23 @@ export function carveBedRoles(
return { couldBeBed: couldBeCarveBed(...parts), autoBed: isNamedCarveBed(...parts) };
}
/** Whether some element's own carve attribute names `targetId` as a source. */
function carvesAgainst(other: HTMLElement, targetId: string): boolean {
/**
* Whether some element's own carve attribute names `targetId` as a source.
*
* Sources are EXPANDED first. A plural carve now names a group rather than a
* clip list — that is what `audio_carve_ungrouped_sources` exists to push
* authors toward — so a raw `.includes(targetId)` never matched a member again,
* and the far-end guard this feeds was silently defeated by the very shape the
* lint rule asks for. The result: the carve module was offered on a voice clip
* a bed is already ducking against, and switching it on wrote a reciprocal
* carve — each side measuring audio the other is already attenuating.
*/
function carvesAgainst(doc: Document, other: HTMLElement, targetId: string): boolean {
try {
const raw = other.getAttribute(HF_AUDIO_CARVE_ATTR);
return Boolean(raw && normalizeCarveSettings(JSON.parse(raw)).sources.includes(targetId));
if (!raw) return false;
const sources = normalizeCarveSettings(JSON.parse(raw)).sources;
return resolveCarveSourceIds(doc, sources).includes(targetId);
} catch {
// An unreadable carve on some other element says nothing about this one.
return false;
@@ -226,6 +238,6 @@ export function carverAgainst(
): string | null {
if (!doc || !id) return null;
const others = Array.from(doc.querySelectorAll<HTMLElement>(`[${HF_AUDIO_CARVE_ATTR}]`));
const carver = others.find((other) => other.id !== id && carvesAgainst(other, id));
const carver = others.find((other) => other.id !== id && carvesAgainst(doc, other, id));
return carver ? carver.id || "another track" : null;
}
@@ -0,0 +1,153 @@
/**
* What a carve analysis COMPILES TO: the filter nodes it mints and the
* automation lanes it writes for them.
*
* Split out of `useFxCarve.ts` to keep it under the studio's 600-line cap. This
* half is pure — it takes measurements and returns nodes and lanes — while the
* hook keeps the effects, the persistence and the auto-carve decisions.
*/
import {
defaultAudioFxParams,
mintAudioFxNodeId,
type HfAudioFxChain,
type HfAudioFxNode,
} from "@hyperframes/core/audio-fx";
import {
analyseCarveBands,
analyseCarveDuck,
analyseCarveDynamics,
carveBandsToChain,
carveProfile,
mixCarveSources,
} from "@hyperframes/core/audio-carve";
import { fxAutomationTarget, type HfAutomationLane } from "@hyperframes/core/audio-automation";
import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
/** Decode rate every carve measurement shares — see `measureCarve`. */
const DECODE_SAMPLE_RATE = 48000;
/**
* Turns the analysed bands (and, if the carve is asked to match levels, a
* ducking envelope) into chain nodes — tagged so a re-run replaces them
* instead of stacking, and minted against the nodes already claiming an id
* because a dynamic carve automates these filters and a lane addresses its
* node by id.
*/
export function mintCarveNodes(
chain: HfAudioFxChain,
carved: HfAudioFxChain,
duck: { t: number; v: number }[],
): { next: HfAudioFxChain; carvedNodes: HfAudioFxNode[]; duckNode: HfAudioFxNode | null } {
const kept = chain.nodes.filter((n) => !n.fromCarve);
let claimed: HfAudioFxChain = { version: 1, nodes: kept };
const mint = (node: HfAudioFxNode): HfAudioFxNode => {
const withId = { ...node, id: mintAudioFxNodeId(claimed), fromCarve: true };
claimed = { version: 1, nodes: [...claimed.nodes, withId] };
return withId;
};
const carvedNodes: HfAudioFxNode[] = carved.nodes.map(mint);
// The gain stage sits after the filters, and only exists when the carve was
// asked to make level room. It sits at 0 and is driven by the envelope below.
const duckNode =
duck.length > 0
? mint({ type: "gain", enabled: true, params: { ...defaultAudioFxParams("gain"), gain: 0 } })
: null;
return {
next: { version: 1, nodes: [...carvedNodes, ...(duckNode ? [duckNode] : []), ...kept] },
carvedNodes,
duckNode,
};
}
/**
* Decode every voice, mix them onto the bed's own clock, and measure the
* bands (and, if the profile calls for it, the ducking envelope) from that
* mix. Null on anything that leaves nothing to build a carve from — the
* platform lacking an offline context, or a mix that decoded to silence.
*/
export async function measureCarve(
doc: Document,
voices: { src: string; start: string | null }[],
strength: number,
bedStartAttr: string | null | undefined,
bedSrc: string | null | undefined,
): Promise<{
bands: ReturnType<typeof analyseCarveBands>;
carved: HfAudioFxChain;
duck: { t: number; v: number }[];
voiceMix: Float32Array;
} | null> {
// Decoded in an OfflineAudioContext, not a live one. Opening a second output
// device mid-playback makes the running track glitch while the hardware is
// reconfigured; an offline context touches no device.
const Ctor =
window.OfflineAudioContext ??
(window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
.webkitOfflineAudioContext;
if (!Ctor) return null;
const decode = async (relative: string): Promise<AudioBuffer> => {
const res = await fetch(new URL(relative, doc.baseURI).href);
return new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(await res.arrayBuffer());
};
const bedStart = clipStart(bedStartAttr);
// Every voice, summed onto the bed's own clock. One question — where and
// when is speech masking this bed — with one answer, even when the answer
// comes from three people talking at different times. Doing this before the
// analysis is also what lets the bands and the envelopes stay a single set:
// the chain is fixed, so there is no per-voice filter to switch between.
const decoded = await Promise.all(
voices.map(async (voice) => ({
samples: (await decode(voice.src)).getChannelData(0),
offsetSeconds: clipStart(voice.start) - bedStart,
})),
);
const voiceMix = mixCarveSources(decoded, DECODE_SAMPLE_RATE);
if (voiceMix.length === 0) return null;
// Strength is what the author set; these are the numbers it means.
const profile = carveProfile(strength);
// The bed as well as the voice, when the carve is asked to match levels:
// "how far over the voice is this bed" cannot be answered by listening to
// one of them.
const bedBuffer = profile.duckDb > 0 && bedSrc ? await decode(bedSrc).catch(() => null) : null;
const bands = analyseCarveBands(voiceMix, DECODE_SAMPLE_RATE, profile);
// The level half of the carve, measured against the speech it has to sit
// under. No offset to apply: the mix is already on the bed's clock.
const duck = bedBuffer
? analyseCarveDuck(voiceMix, bedBuffer.getChannelData(0), DECODE_SAMPLE_RATE, profile, 0)
: [];
return { bands, carved: carveBandsToChain(bands), duck, voiceMix };
}
/** Each filter's depth as an envelope, plus the level envelope if there is one. */
export function carveLanes(
carvedNodes: HfAudioFxNode[],
duckNode: HfAudioFxNode | null,
duck: { t: number; v: number }[],
voiceMix: Float32Array,
bands: ReturnType<typeof analyseCarveBands>,
): HfAutomationLane[] {
// Each filter's depth becomes an envelope of the speech's level in that
// band, so pauses leave the bed alone and whoever is talking sets the depth.
const lanes = analyseCarveDynamics(voiceMix, DECODE_SAMPLE_RATE, bands).flatMap((dyn, i) => {
const id = carvedNodes[i]?.id;
return id ? carveLaneFor(id, dyn.points) : [];
});
// The level envelope rides the gain stage, on the same clock as the bands.
if (duckNode?.id && duck.length > 0) lanes.push(...carveLaneFor(duckNode.id, duck));
return lanes;
}
/**
* One carve envelope as a lane on this bed's clock.
*
* No shifting: the voices were summed onto the bed's clock before the analysis
* ran, so what comes back is already in the bed's own time. A lane does hold
* its first value backwards to the start of its clip, so an envelope that
* begins later needs an explicit "no cut" at zero or the bed starts out ducked.
*/
function carveLaneFor(id: string, points: { t: number; v: number }[]): HfAutomationLane[] {
const timed = points.map((p) => ({ t: Number(p.t.toFixed(3)), v: p.v })).filter((p) => p.t >= 0);
if ((timed[0]?.t ?? 0) > 0) timed.unshift({ t: 0, v: 0 });
return timed.length > 1 ? [{ target: fxAutomationTarget(id, "gain"), points: timed }] : [];
}