fix(studio): harden carve and FX rack behavior (#3452)

* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state

* fix(studio): make audio-group edits transactional

* fix(studio): keep preview state synchronized

* fix(studio): align audio rows, automation lanes and headers

* fix(studio): stabilize timeline audio derivations

* refactor(studio): simplify group metadata memoization

* style(studio): keep timeline layout within size gate

* fix(studio): keep timeline preset apply off auditions

* fix(studio): harden carve and FX rack behavior

* fix(studio): repeat audio FX reveal requests
This commit is contained in:
Vance Ingalls
2026-08-23 18:51:07 -07:00
committed by GitHub
parent 4ea018a4a7
commit f575bdadcb
21 changed files with 1169 additions and 193 deletions
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { audioFxRevealTarget } from "./audioFxRevealTarget";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
const chain = (nodes: HfAudioFxChain["nodes"]): HfAudioFxChain => ({ version: 1, nodes });
describe("audioFxRevealTarget", () => {
it("points a hand-built effect's lane at its own row, by chain index", () => {
const c = chain([
{ type: "highpass", id: "n1", params: {} },
{ type: "peaking", id: "n2", params: {} },
]);
expect(audioFxRevealTarget("fx.n2.gain", c)).toEqual({ kind: "node", index: 1, nodeId: "n2" });
});
// The case that made this a resolver rather than an index lookup: a carve's
// bands are filtered out of the rack's node list, so opening `openNode` on
// one opens nothing. The carve module is what renders them.
it("points a carve band's lane at the carve module", () => {
const c = chain([{ type: "peaking", id: "n1", fromCarve: true, params: {} }]);
expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "carve" });
});
it("points an EQ band's lane at its EQ module", () => {
const c = chain([{ type: "peaking", id: "n1", fromEq: "eq1", params: {} }]);
expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "eq", eqId: "eq1" });
});
it("points a preset node's lane at its run, keyed like collapsedRuns", () => {
const c = chain([
{ type: "highpass", id: "n1", params: {} },
{ type: "peaking", id: "n2", fromPreset: "clean-voice", params: {} },
{ type: "gain", id: "n3", fromPreset: "clean-voice", params: {} },
]);
// Keyed by the run's FIRST node, not the automated one.
expect(audioFxRevealTarget("fx.n3.gain", c)).toEqual({
kind: "preset",
runKey: "clean-voice-1",
});
});
it("resolves a preset-level lane through the preset it names", () => {
const c = chain([{ type: "peaking", id: "n1", fromPreset: "clean-voice", params: {} }]);
expect(audioFxRevealTarget("fx.preset.clean-voice", c)).toEqual({
kind: "preset",
runKey: "clean-voice-0",
});
});
it("treats the track's own volume as the rack itself", () => {
expect(audioFxRevealTarget("volume", chain([]))).toEqual({ kind: "volume" });
});
it("resolves nothing for a lane whose effect is gone, or an unparseable target", () => {
expect(audioFxRevealTarget("fx.gone.gain", chain([]))).toBeNull();
expect(audioFxRevealTarget("nonsense", chain([]))).toBeNull();
expect(audioFxRevealTarget("fx.n1.gain", null)).toBeNull();
});
});
@@ -0,0 +1,113 @@
/**
* Where in the rack an automation lane's parameter actually lives.
*
* A lane names `fx.<nodeId>.<param>`, but the rack does not show one flat list
* of nodes: the carve is one module standing for the filters it compiled, EQ
* bands are folded into their own module, and preset runs are collapsible
* groups. A node id therefore resolves to one of several surfaces, and the
* caller has to open the RIGHT one — opening `openNode` on a carve band, whose
* row is filtered out of `handBuilt`, would open nothing at all and read as the
* click doing nothing.
*/
import { parseAutomationTarget } from "@hyperframes/core/audio-automation";
import { escapeCssString } from "./domEditingDom";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
export type AudioFxRevealTarget =
/** A hand-built effect, addressed by its index in the chain. */
| { kind: "node"; index: number; nodeId: string }
/** An EQ module, addressed by the id its bands share. */
| { kind: "eq"; eqId: string }
/** A preset run, addressed the way `collapsedRuns` keys it. */
| { kind: "preset"; runKey: string }
/** The carve module, which owns every `fromCarve` node collectively. */
| { kind: "carve" }
/** The track's own volume — no rack row; the reveal is the rack itself. */
| { kind: "volume" };
/**
* Resolve a lane target to the surface that shows it, or null when the chain
* does not contain it (a stale lane, or one whose effect was removed).
*/
export function audioFxRevealTarget(
target: string,
chain: HfAudioFxChain | null,
): AudioFxRevealTarget | null {
const parsed = parseAutomationTarget(target);
if (!parsed) return null;
if (parsed.kind === "volume") return { kind: "volume" };
if (parsed.kind === "preset") {
// A preset-level lane names the preset, not a node inside it: find its run
// by the first node that belongs to it, which is how `runKey` is built.
const index = (chain?.nodes ?? []).findIndex((node) => node.fromPreset === parsed.presetId);
return index >= 0 ? { kind: "preset", runKey: `${parsed.presetId}-${index}` } : null;
}
const index = (chain?.nodes ?? []).findIndex((node) => node.id === parsed.nodeId);
const node = index >= 0 ? chain?.nodes[index] : undefined;
if (!node) return null;
// Order matters: a carve band can also carry `fromEq`/`fromPreset` tags, and
// the carve module is the one that actually renders it.
if (node.fromCarve) return { kind: "carve" };
if (node.fromEq) return { kind: "eq", eqId: node.fromEq };
if (node.fromPreset) {
const first = (chain?.nodes ?? []).findIndex((n) => n.fromPreset === node.fromPreset);
return { kind: "preset", runKey: `${node.fromPreset}-${first}` };
}
return { kind: "node", index, nodeId: parsed.nodeId };
}
/**
* The DOM selector for the row a reveal target lives in, or null when the target
* names no surface this panel renders.
*
* A preset run is keyed by the preset id the run element actually exposes, not by
* `runKey`, which is the collapse map's key and carries an index suffix.
*/
function revealRowSelector(where: AudioFxRevealTarget | null): string | null {
if (!where) return null;
switch (where.kind) {
case "node":
return where.nodeId ? `[data-fx-node-id="${escapeCssString(where.nodeId)}"]` : null;
case "eq":
return `[data-fx-eq="${escapeCssString(where.eqId)}"]`;
case "carve":
return ".hf-fx-carve-module";
case "preset":
return `[data-fx-preset="${escapeCssString(where.runKey.replace(/-\d+$/, ""))}"]`;
default:
return null;
}
}
/**
* Scroll the row that owns `target` into view, and report whether it was found.
*
* The target is resolved against the chain again rather than remembered: which
* surface owns a parameter is a fact about the chain, and the chain may have
* been edited between the reveal request and the pass that can act on it. A
* false return means the row has not mounted yet, so the caller keeps the
* request pending.
*/
export function scrollRevealedRowIntoView(
root: HTMLElement | null,
target: string,
chain: HfAudioFxChain,
): boolean {
const selector = revealRowSelector(audioFxRevealTarget(target, chain));
// `querySelector` throws SyntaxError on a malformed selector, and this runs
// inside a render-phase effect — an unescapable id would take the whole
// property panel down instead of leaving the request pending, which is what
// returning false means. `parseAudioFxNode` accepts any non-empty string as
// an id and `parseAutomationTarget` only splits on `.`, so a hand- or
// LLM-authored chain can carry one.
let row: HTMLElement | null = null;
try {
row = selector ? (root?.querySelector<HTMLElement>(selector) ?? null) : null;
} catch {
return false;
}
if (!row) return false;
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
return true;
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import type { HfAudioGroup } from "@hyperframes/core/audio-groups";
import { audioFxSignalPath } from "./audioFxSignalPath";
const group = (over: Partial<HfAudioGroup> = {}): HfAudioGroup => ({
id: "voiceover",
label: "Voiceover",
memberIds: ["vo-1", "vo-2"],
volume: 1,
hidden: false,
...over,
});
describe("audioFxSignalPath", () => {
// The design doc's §5 mockup, both columns.
// Copy taken from the rendered designs, which are more specific than the
// ASCII stand-ins in the markdown: "vo-1 and vo-2, together" / "into
// Voiceover", not "vo-1, vo-2" / "to Voiceover".
it("names what a group sums, and sends it to the mix", () => {
expect(audioFxSignalPath("hf-audio-group", "voiceover", [group()])).toEqual({
inLabel: "vo-1 and vo-2, together",
outLabel: "to mix",
subject: "group",
});
});
it("names the group a member feeds, so routing reads from either end", () => {
expect(audioFxSignalPath("audio", "vo-1", [group()])).toEqual({
inLabel: "this track",
outLabel: "into Voiceover",
subject: "track",
});
});
it("leaves an ungrouped clip on the shipped clip labels", () => {
expect(audioFxSignalPath("audio", "music-bed", [group()])).toEqual({
inLabel: "this track",
outLabel: "to mix",
subject: "track",
});
});
// The state an author is in the instant after making a group. It must not
// read as a failure to resolve.
it("says a memberless group holds nothing yet", () => {
expect(
audioFxSignalPath("hf-audio-group", "empty", [group({ id: "empty", memberIds: [] })]),
).toMatchObject({ inLabel: "nothing yet", subject: "group" });
});
});
@@ -0,0 +1,71 @@
/**
* What the rack's `IN` and `OUT` lines say, per selected element.
*
* The rack brackets its chain with the signal path because the ORDER is the
* point (see `propertyPanelFxRackChain`). Those two lines were hardcoded to a
* clip's answer — "in this track", "out to mix" — which is wrong at both ends
* once groups exist, and the design doc's §5 mockup spells out both:
*
* GROUP: Voiceover CLIP: vo-1
* IN vo-1, vo-2 IN this track
* OUT to mix OUT to Voiceover
*
* A group's IN names what it sums, which is the only thing on screen that says
* a bus is a sum rather than a copy of the chain on each member. A member's OUT
* names the group it feeds, "so the routing is readable from either end".
*/
import type { HfAudioGroup } from "@hyperframes/core/audio-groups";
export interface AudioFxSignalPath {
/** After the word "In". */
inLabel: string;
/** After the word "Out". */
outLabel: string;
/** The thing the empty-state sentence is about: "No effects on this …". */
subject: string;
}
/** What a plain, ungrouped clip has always said, and the default everywhere. */
export const CLIP_SIGNAL_PATH: AudioFxSignalPath = {
inLabel: "this track",
outLabel: "to mix",
subject: "track",
};
/**
* `groups` is the resolved set from the composition; `elementId` and `tag` come
* from the selection. Pure so the labels can be asserted without a DOM.
*/
/** "a", "a and b", "a, b and c" — how the designs read a member list aloud. */
function joinNatural(items: readonly string[]): string {
if (items.length <= 1) return items[0] ?? "";
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
}
export function audioFxSignalPath(
tag: string | undefined,
elementId: string | undefined,
groups: readonly HfAudioGroup[],
): AudioFxSignalPath {
if (tag === "hf-audio-group") {
const group = groups.find((g) => g.id === elementId);
// A group with no members yet still reads as a group — "nothing yet" is the
// honest answer, and it is also the state the author is in right after
// making one, so it must not look like a bug.
const members = group?.memberIds ?? [];
return {
// "vo-1 and vo-2, together" — the rendered design's exact phrasing, not a
// comma list. The trailing "together" is the point: it says the group is
// ONE signal hearing both, which is the thing two separate copies of a
// chain cannot do, and it says it without "sum" or "bus".
inLabel: members.length > 0 ? `${joinNatural(members)}, together` : "nothing yet",
outLabel: "to mix",
subject: "group",
};
}
const owner = elementId ? groups.find((g) => g.memberIds.includes(elementId)) : undefined;
// "into Voiceover", not "to" — a member feeds the group, and the design uses
// the preposition that says so.
return owner ? { ...CLIP_SIGNAL_PATH, outLabel: `into ${owner.label}` } : CLIP_SIGNAL_PATH;
}
@@ -8,6 +8,14 @@ const el = (dataAttributes: Record<string, string>): DomEditSelection =>
const chain = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
describe("audioFxSummary", () => {
// The designs give a member's rack the summary "in Voiceover" — it answers
// "where does this go?" before anything is opened, the same job the rack's
// OUT does from the other end, and it outranks the effect count because a
// member with no effects of its own is still in the group.
it("names the group a clip belongs to, ahead of any effect count", () => {
expect(audioFxSummary(el({}), "Voiceover")).toBe("in Voiceover");
});
it("counts a carve as one module, not as the filters behind it", () => {
// Six bands and a level stage reading "7 effects" is the misreading the
// grouping exists to prevent.
@@ -10,27 +10,39 @@
import { HF_AUDIO_FX_DATA_KEY, parseAudioFxChain } from "@hyperframes/core/audio-fx";
import type { DomEditSelection } from "./domEditingTypes";
export function audioFxSummary(element: DomEditSelection): string {
const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY];
const carveAttr = element.dataAttributes?.["fx-carve"];
let handBuilt = 0;
let carveNodes = 0;
if (raw) {
try {
for (const node of parseAudioFxChain(raw).nodes) {
if (node.enabled === false) continue;
if (node.fromCarve) carveNodes += 1;
else handBuilt += 1;
}
} catch {
return "unreadable";
/** Enabled nodes split by who authored them, or null when the chain won't parse. */
function countEnabledNodes(raw: string | undefined): { handBuilt: number; carve: number } | null {
if (!raw) return { handBuilt: 0, carve: 0 };
try {
let handBuilt = 0;
let carve = 0;
for (const node of parseAudioFxChain(raw).nodes) {
if (node.enabled === false) continue;
if (node.fromCarve) carve += 1;
else handBuilt += 1;
}
return { handBuilt, carve };
} catch {
return null;
}
}
export function audioFxSummary(element: DomEditSelection, groupLabel?: string): string {
// A clip inside a group reads "in Voiceover" — the designs use this line to
// answer "where does this go?" before the author opens anything, which is
// the same job the rack's OUT does from the other end. It outranks the effect
// count: a member with no effects of its own is still IN the group, and that
// is the more useful thing to say about it.
if (groupLabel) return `in ${groupLabel}`;
const counts = countEnabledNodes(element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY]);
if (!counts) return "unreadable";
const parts: string[] = [];
if (handBuilt > 0) parts.push(`${handBuilt} effect${handBuilt === 1 ? "" : "s"}`);
if (counts.handBuilt > 0) {
parts.push(`${counts.handBuilt} effect${counts.handBuilt === 1 ? "" : "s"}`);
}
// One name for the module however many filters are behind it. Named when the
// carve is switched on at all, because the control is in this section whether or
// not it has compiled to anything yet.
if (carveNodes > 0 || carveAttr) parts.push("carve");
if (counts.carve > 0 || element.dataAttributes?.["fx-carve"]) parts.push("carve");
return parts.length > 0 ? parts.join(" + ") : "none";
}
@@ -164,43 +164,9 @@ export function FxCarveModule({
onCarveChange(carve: HfCarveSettings): void;
onCarvePreview(carve: HfCarveSettings): void;
}) {
const bands = nodes.filter((n) => n.type === "peaking").length;
const hasLevel = nodes.some((n) => n.type === "gain");
const on = carve.enabled;
/**
* The only track this bed could be listening to, when there is exactly one.
*
* A picker with one entry is a question with one answer: it asks the author to
* confirm something already decided. So the voice reads out instead.
*
* Not when the stored source is some OTHER track, though — a name that no longer
* classifies as a voice, or a track since renamed. Reading out the one remaining
* candidate there would quietly claim the carve listens to something it does not,
* so the picker comes back and shows the mismatch.
*/
const soleVoice =
sourceOptions.length === 1 &&
(carve.sources.length === 0 ||
(carve.sources.length === 1 && carve.sources[0] === sourceOptions[0]?.id))
? sourceOptions[0]
: null;
// What the module is worth right now, in the head, so a collapsed card still
// says whether it is doing anything: the analysis it produced, or why not.
const summary = !on
? "off"
: analysing
? "analysing…"
: bands > 0
? [
`${bands} band${bands === 1 ? "" : "s"}`,
...(hasLevel ? ["level"] : []),
// Worth saying when it is more than one: the cuts follow whoever is
// speaking, and that is not obvious from a band count.
...(carve.sources.length > 1 ? [`${carve.sources.length} voices`] : []),
].join(" + ")
: carve.sources.length > 0
? "no analysis yet"
: "pick a voice";
const soleVoice = soleCarveVoice(sourceOptions, carve.sources);
const summary = carveSummary({ nodes, carve, analysing });
// The carve's own colour, used three ways: the module's left edge, the title,
// and the wash behind it. A preset gets a title treatment because it is a
// character; the carve gets one because it is the only module in the rack
@@ -230,6 +196,8 @@ export function FxCarveModule({
// sets `tracking-normal`, and two Tailwind tracking utilities on one
// element resolve by stylesheet order, not by the order written.
style={{ color: tint, letterSpacing: "0.16em" }}
// Truncates in a narrow panel like every other name in the rack.
title="Voiceover carve"
aria-expanded={open}
onClick={onToggleOpen}
>
@@ -255,50 +223,13 @@ export function FxCarveModule({
{open && on ? (
<div className="hf-fx-carve-body border-t border-panel-border-input">
<div className="hf-fx-carve-controls space-y-0.5 px-1.5 py-1.5">
<div className="hf-fx-row flex min-h-6 items-center gap-2">
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2">
Listen to
</span>
{soleVoice ? (
<span
className="hf-fx-carve-source min-w-0 flex-1 truncate font-mono text-[10px] text-panel-text-1"
data-carve-source={soleVoice.id}
>
{soleVoice.label}
</span>
) : (
/* Every voice, not one of them. A bed usually runs under a whole
sequence — a narrator, an answer, a second presenter — and they are
analysed together, so the cuts follow whoever is speaking. Which
makes this a set of things to include, not a choice between them. */
<div className="hf-fx-carve-sources flex min-w-0 flex-1 flex-wrap gap-x-2.5 gap-y-0.5">
{sourceOptions.map((o) => (
<label
key={o.id}
className="flex min-w-0 items-center gap-1 font-mono text-[9px] text-panel-text-1"
title={`Make room for ${o.label}`}
>
<input
type="checkbox"
className="hf-fx-carve-source h-2.5 w-2.5 accent-panel-accent"
data-carve-source={o.id}
checked={carve.sources.includes(o.id)}
disabled={disabled}
onChange={(e) =>
onCarveChange({
...carve,
sources: e.target.checked
? [...carve.sources, o.id]
: carve.sources.filter((id) => id !== o.id),
})
}
/>
<span className="truncate">{o.label}</span>
</label>
))}
</div>
)}
</div>
<CarveSourceRow
carve={carve}
sourceOptions={sourceOptions}
soleVoice={soleVoice}
disabled={disabled}
onCarveChange={onCarveChange}
/>
{/* One knob for the whole effect. Depth, band count, width, the
intelligibility weighting and both level-match numbers move together
anyway — a gentle carve is shallow in few bands with little ducking, a
@@ -330,53 +261,200 @@ export function FxCarveModule({
change re-derives all of them — so leaving them up reads as the
settings that are in force when they are already history, and the one
honest thing to say is that the work is happening. */}
{analysing ? (
<p className="hf-fx-carve-working flex items-center justify-center gap-1.5 border-t border-panel-border-input py-2 text-[10px] text-panel-text-2">
<svg
className="hf-fx-carve-spinner h-3 w-3 animate-spin motion-reduce:animate-none"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Analysing…
</p>
) : nodes.length > 0 ? (
<div className="hf-fx-carve-members divide-y divide-panel-border-input/60 border-t border-panel-border-input">
<div className="hf-fx-carve-members-label px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
analysed
</div>
{nodes.map((node, i) => (
<FxCarveMember
key={node.id ?? `${node.type}-${i}`}
node={node}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
/>
))}
</div>
) : (
<p className="hf-fx-carve-working border-t border-panel-border-input py-1.5 text-center text-[10px] text-panel-text-2">
{carve.sources.length > 0
? "Nothing analysed yet."
: "Pick the voices this bed should make room for."}
</p>
)}
<CarveAnalysis
nodes={nodes}
analysing={analysing}
hasSources={carve.sources.length > 0}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
/>
</div>
) : null}
</div>
);
}
/**
* The only track this bed could be listening to, when there is exactly one.
*
* A picker with one entry is a question with one answer: it asks the author to
* confirm something already decided. So the voice reads out instead.
*
* Not when the stored source is some OTHER track, though — a name that no longer
* classifies as a voice, or a track since renamed. Reading out the one remaining
* candidate there would quietly claim the carve listens to something it does not,
* so the picker comes back and shows the mismatch.
*/
function soleCarveVoice(
sourceOptions: AudioTrackOption[],
sources: readonly string[],
): AudioTrackOption | null {
if (sourceOptions.length !== 1) return null;
const only = sourceOptions[0];
if (!only) return null;
if (sources.length === 0) return only;
return sources.length === 1 && sources[0] === only.id ? only : null;
}
/**
* What the module is worth right now, for its head, so a collapsed card still
* says whether it is doing anything: the analysis it produced, or why not.
*/
function carveSummary(input: {
nodes: HfAudioFxNode[];
carve: HfCarveSettings;
analysing?: boolean;
}): string {
const { nodes, carve, analysing } = input;
if (!carve.enabled) return "off";
if (analysing) return "analysing…";
const bands = nodes.filter((n) => n.type === "peaking").length;
if (bands === 0) return carve.sources.length > 0 ? "no analysis yet" : "pick a voice";
return [
`${bands} band${bands === 1 ? "" : "s"}`,
...(nodes.some((n) => n.type === "gain") ? ["level"] : []),
// Worth saying when it is more than one: the cuts follow whoever is
// speaking, and that is not obvious from a band count.
...(carve.sources.length > 1 ? [`${carve.sources.length} voices`] : []),
].join(" + ");
}
/** Which voices the bed makes room for: a readout when there is only one to
* choose, otherwise a set of checkboxes. */
function CarveSourceRow({
carve,
sourceOptions,
soleVoice,
disabled,
onCarveChange,
}: {
carve: HfCarveSettings;
sourceOptions: AudioTrackOption[];
soleVoice: AudioTrackOption | null;
disabled?: boolean;
onCarveChange(carve: HfCarveSettings): void;
}) {
return (
<div className="hf-fx-row flex min-h-6 items-center gap-2">
{/* Wraps like every other name in this column (see FxParamRow) — one
truncating row beside wrapping ones reads as a rendering bug. */}
<span className="hf-fx-label w-[86px] flex-shrink-0 break-words text-[10px] leading-tight text-panel-text-2">
Listen to
</span>
{soleVoice ? (
<span
className="hf-fx-carve-source min-w-0 flex-1 truncate font-mono text-[10px] text-panel-text-1"
data-carve-source={soleVoice.id}
>
{soleVoice.label}
</span>
) : (
/* Every voice, not one of them. A bed usually runs under a whole
sequence — a narrator, an answer, a second presenter — and they are
analysed together, so the cuts follow whoever is speaking. Which
makes this a set of things to include, not a choice between them. */
<div className="hf-fx-carve-sources flex min-w-0 flex-1 flex-wrap gap-x-2.5 gap-y-0.5">
{sourceOptions.map((o) => (
<label
key={o.id}
className="flex min-w-0 items-center gap-1 font-mono text-[9px] text-panel-text-1"
title={`Make room for ${o.label}`}
>
<input
type="checkbox"
className="hf-fx-carve-source h-2.5 w-2.5 accent-panel-accent"
data-carve-source={o.id}
checked={carve.sources.includes(o.id)}
disabled={disabled}
onChange={(e) =>
onCarveChange({
...carve,
sources: e.target.checked
? [...carve.sources, o.id]
: carve.sources.filter((id) => id !== o.id),
})
}
/>
<span className="truncate">{o.label}</span>
</label>
))}
</div>
)}
</div>
);
}
/**
* What the analysis made of all that. Divided rather than boxed: these are parts
* of one module, and a border around each would read as the separate effects
* this replaced.
*
* While the analysis runs the previous filters are gone rather than stale. Every
* number in that list is about to be replaced — a strength change re-derives all
* of them — so leaving them up reads as the settings that are in force when they
* are already history, and the one honest thing to say is that the work is
* happening.
*/
function CarveAnalysis({
nodes,
analysing,
hasSources,
automatedTargets,
liveAutomationValues,
}: {
nodes: HfAudioFxNode[];
analysing?: boolean;
hasSources: boolean;
automatedTargets?: ReadonlySet<string>;
liveAutomationValues?: ReadonlyMap<string, number>;
}) {
if (analysing) {
return (
<p className="hf-fx-carve-working flex items-center justify-center gap-1.5 border-t border-panel-border-input py-2 text-[10px] text-panel-text-2">
<svg
className="hf-fx-carve-spinner h-3 w-3 animate-spin motion-reduce:animate-none"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Analysing…
</p>
);
}
if (nodes.length === 0) {
return (
<p className="hf-fx-carve-working border-t border-panel-border-input py-1.5 text-center text-[10px] text-panel-text-2">
{hasSources ? "Nothing analysed yet." : "Pick the voices this bed should make room for."}
</p>
);
}
return (
<div className="hf-fx-carve-members divide-y divide-panel-border-input/60 border-t border-panel-border-input">
<div className="hf-fx-carve-members-label px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
analysed
</div>
{nodes.map((node, i) => (
<FxCarveMember
key={node.id ?? `${node.type}-${i}`}
node={node}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
/>
))}
</div>
);
}
@@ -113,3 +113,53 @@ describe("FxParamRow commit", () => {
expect(onCommit).not.toHaveBeenCalled();
});
});
describe("FxParamRow label tooltip", () => {
// The label column is a fixed 86px and truncates: "Gap between repeats" reads
// as "Gap between re…", "How many repeats" as "How many repe…". The row
// already carried a `title`, but that is the HINT — what the knob does — so a
// truncated name had no way to be read in full.
const LONG: HfAudioFxNumberParam = {
kind: "number",
key: "time",
label: "Gap between repeats",
min: 0,
max: 1000,
step: 1,
default: 250,
unit: "ms",
hint: "How long before the echo comes back.",
};
it("wraps the full name rather than truncating it, and leaves the hint on the row", () => {
const host = document.createElement("div");
document.body.append(host);
act(() => {
createRoot(host).render(<FxParamRow param={LONG} value={250} onChange={vi.fn()} />);
});
const label = host.querySelector<HTMLElement>(".hf-fx-label");
// The whole name is present and allowed to wrap. No `title`: a tooltip
// answers one row on hover, wrapping answers the column at rest.
expect(label?.textContent).toBe("Gap between repeats");
expect(label?.className).toContain("break-words");
expect(label?.className).not.toContain("truncate");
expect(label?.getAttribute("title")).toBeNull();
// The hint stays where it was: the two are different questions, and the
// name is not a substitute for the explanation either.
expect(host.querySelector<HTMLElement>(".hf-fx-row")?.getAttribute("title")).toBe(
"How long before the echo comes back.",
);
});
it("wraps it the same way with no hint to fall back on", () => {
const host = document.createElement("div");
document.body.append(host);
const { hint: _hint, ...noHint } = LONG;
act(() => {
createRoot(host).render(<FxParamRow param={noHint} value={250} onChange={vi.fn()} />);
});
const label = host.querySelector<HTMLElement>(".hf-fx-label");
expect(label?.textContent).toBe("Gap between repeats");
expect(label?.getAttribute("title")).toBeNull();
});
});
@@ -188,7 +188,14 @@ export function FxParamRow({
if (param.kind === "enum") {
return (
<label className="hf-fx-row flex min-h-6 items-center gap-2" title={param.hint}>
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2">
{/* Wraps rather than truncating. These names are whole questions — "How
big the space is" — so 86px of truncation left "How big the sp…", and
three rows of that read as the same word four times. A title only
answers it on hover, one row at a time; wrapping answers it for the
whole column at rest. `break-words` so a long single token breaks
instead of widening the column. The row keeps `title={param.hint}`:
the name and the explanation are different questions. */}
<span className="hf-fx-label w-[86px] flex-shrink-0 break-words text-[10px] leading-tight text-panel-text-2">
{param.label}
</span>
<select
@@ -227,8 +234,9 @@ export function FxParamRow({
title={param.hint}
data-automated={automated ? "" : undefined}
>
{/* See the enum row above for why the name wraps instead of truncating. */}
<span
className={`hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] ${
className={`hf-fx-label w-[86px] flex-shrink-0 break-words text-[10px] leading-tight ${
automated ? "text-panel-accent" : "text-panel-text-2"
}`}
>
@@ -125,6 +125,7 @@ function Fader({
}
export function FxEqModule({
eqId,
bands,
open,
disabled,
@@ -141,6 +142,8 @@ export function FxEqModule({
className="hf-fx-node hf-fx-eq-module rounded-[4px] border border-l-2 border-panel-border-input"
data-fx-node="eq"
data-fx-family="smart"
// Scroll anchor for a revealed EQ-band automation lane.
data-fx-eq={eqId}
// Smart, with the carve and the leveller: three bands the author sets by
// ear on a control surface, not three filters they configured.
style={{ borderLeftColor: fxFamilyTint({ type: "eq", fromEq: "eq" }) }}
@@ -184,6 +184,9 @@ function FxNodeHeader({
<button
type="button"
className={`hf-fx-node-name flex-1 truncate text-left text-[11px] text-panel-text-1 hover:text-panel-text-0 ${family}`}
// Truncated in the same narrow column as the param labels below, so it
// needs the same fallback to the full text on hover.
title={label}
aria-expanded={open}
onClick={onToggleOpen}
>
@@ -277,6 +280,10 @@ export function FxNodeRow({
className={`hf-fx-node rounded-[4px] border border-l-2 border-panel-border-input${bypassed ? " opacity-50" : ""}`}
data-fx-node={node.type}
data-fx-family={fxFamilyOf(node)}
// The scroll anchor a revealed automation lane lands on. Keyed by node id
// rather than by parameter: every param of one effect lives in this row,
// so the row is the smallest thing worth scrolling to.
data-fx-node-id={node.id}
// The tint is on the edge rather than the text: the name already carries
// the family in its lettering, and colouring it too would fight the
// panel's own tokens for automated and bypassed.
@@ -304,7 +311,10 @@ export function FxNodeRow({
onRemove={() => onRemove(index)}
/>
{summary ? (
<p className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-2">
<p
className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-2"
title={summary}
>
{summary}
</p>
) : null}
@@ -133,8 +133,18 @@ export function FxPresetMenu({
<span className="hf-fx-preset-problem block truncate text-[10px]">
{PRESET_PROBLEM[preset.id] ?? preset.description}
</span>
<span className="hf-fx-preset-name block truncate font-mono text-[9px] text-panel-text-2">
{preset.label}
{/* "Clean Voice · 5 effects" — the count is on the row in the
designs, not hidden in a tooltip. It is doing real work there:
it tells the author a preset IS a chain they can open and edit,
rather than an opaque setting they cannot follow. The count is
its own span so `.hf-fx-preset-name` stays the NAME — several
tests read it as the preset's identity. */}
<span className="block truncate font-mono text-[9px] text-panel-text-2">
<span className="hf-fx-preset-name">{preset.label}</span>
<span className="hf-fx-preset-count">
{" · "}
{preset.nodes.length} effect{preset.nodes.length === 1 ? "" : "s"}
</span>
</span>
{/* Hovering a preset plays it, and playing is otherwise invisible:
the panel looks identical whether the audition is sounding or
@@ -1,7 +1,8 @@
/**
* The rack's own signal path: the carve, the Tone EQ modules, and every
* hand-built effect or preset run in between — bracketed by the "In this
* track" / "Out to mix" labels that say the order is the point.
* hand-built effect or preset run in between — bracketed by the `In` / `Out`
* labels that say the order is the point. What those two name depends on what
* is selected; see `audioFxSignalPath`.
*
* Split out of `propertyPanelFxSection.tsx`, which owned this whole chain
* before the file grew past a size where the chain and the add/pick menus
@@ -15,8 +16,11 @@ import { trackEqChanged, trackPresetAmount } from "./audioFxTelemetry.js";
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
import { FxEqModule } from "./propertyPanelFxEqModule.js";
import { FxPresetRun } from "./propertyPanelFxPresetRun.js";
import type { AudioFxSignalPath } from "./audioFxSignalPath.js";
export interface FxRackChainProps {
/** What the `In`/`Out` lines name — a clip's answer differs from a bus's. */
signalPath: AudioFxSignalPath;
chain: HfAudioFxChain;
showCarve: boolean;
carveNodes: HfAudioFxNode[];
@@ -103,6 +107,7 @@ export function FxRackChain({
presetAutomated,
presetAutomateHandler,
presetRemoveAutomationHandler,
signalPath,
}: FxRackChainProps) {
return (
<div className="hf-fx-chain space-y-1">
@@ -111,7 +116,7 @@ export function FxRackChain({
"move up" look cosmetic — it is the most consequential control here. */}
<p className="hf-fx-term flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
<span className="hf-fx-term-cap text-panel-text-1">In</span>
<span>this track</span>
<span>{signalPath.inLabel}</span>
</p>
{/* Carve leads the rack, which is also where its effects sit in the signal
path — corrective work before anything the author added. Present
@@ -151,7 +156,9 @@ export function FxRackChain({
))}
{handBuiltCount === 0 && eqIds.length === 0 ? (
<p className="hf-fx-empty py-1 text-[11px] text-panel-text-2">
{showCarve ? "No other effects on this track." : "No effects on this track."}
{showCarve
? `No other effects on this ${signalPath.subject}.`
: `No effects on this ${signalPath.subject}.`}
</p>
) : (
runs.map((run) => {
@@ -195,7 +202,7 @@ export function FxRackChain({
)}
<p className="hf-fx-term hf-fx-term-out flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
<span className="hf-fx-term-cap text-panel-text-1">Out</span>
<span>to mix</span>
<span>{signalPath.outLabel}</span>
</p>
</div>
);
@@ -162,6 +162,48 @@ afterEach(() => {
});
describe("FxSection chain", () => {
it("scrolls again when the same open parameter is revealed twice", () => {
const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollIntoView");
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});
const chain: HfAudioFxChain = {
version: 1,
nodes: [
{
type: "lowpass",
id: "filter-1",
enabled: true,
params: defaultAudioFxParams("lowpass"),
},
],
};
const renderFxSection = (revealNonce: number) => (
<FxSection
chain={chain}
onChainChange={vi.fn()}
onCarveChange={vi.fn()}
carve={null}
sourceOptions={[]}
revealTarget="fx.filter-1.frequency"
revealNonce={revealNonce}
/>
);
try {
const { root } = renderInto(renderFxSection(1));
expect(scrollIntoView).toHaveBeenCalledTimes(1);
act(() => root.render(renderFxSection(2)));
expect(scrollIntoView).toHaveBeenCalledTimes(2);
} finally {
if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor);
else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}
});
it("says so when the track has no effects", () => {
const { host } = mount();
// "other", because the carve module is in the rack whenever a voice exists.
@@ -5,7 +5,8 @@
* is not an entry in the chain.
*/
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { audioFxRevealTarget, scrollRevealedRowIntoView } from "./audioFxRevealTarget.js";
import {
defaultAudioFxParams,
mintAudioFxNodeId,
@@ -25,6 +26,7 @@ import { applyAudioFxProfile, getAudioFxProfile } from "@hyperframes/core/audio-
import { audioFxJobNode, type HfAudioFxJob } from "@hyperframes/core/audio-fx-jobs";
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
import { FxRackChain } from "./propertyPanelFxRackChain.js";
import { CLIP_SIGNAL_PATH } from "./audioFxSignalPath.js";
import { FxAddMenu } from "./propertyPanelFxAddMenu.js";
import { useFxAudition } from "./useFxAudition.js";
import {
@@ -103,6 +105,9 @@ export function FxSection({
onRemovePresetAutomation,
automatedPresets,
onAuditionTransport,
signalPath,
revealTarget,
revealNonce,
}: FxSectionProps) {
const presetAutomated = automatedPresets ?? new Set<string>();
// Falls back to the persisting write when no preview handler is supplied, which
@@ -134,11 +139,18 @@ export function FxSection({
[chain, onChainPreview],
);
const { audition, clearAudition } = useFxAudition(chain, onChainPreview, onAuditionTransport);
const { audition, clearAudition, storedChain } = useFxAudition(
chain,
onChainPreview,
onAuditionTransport,
);
const applyPreset = useCallback(
(id: string) => {
const next = applyPresetToChain(chain, id, trackKind);
// The stored chain, not whatever is being auditioned on top of it — see
// `storedChain`. Clicking preset B while hovering preset A used to save
// both, which is heard as the effect running twice.
const next = applyPresetToChain(storedChain(), id, trackKind);
if (!next) return;
// The audition WAS this, so there is nothing to put back — and putting the
// old chain back over the write that just landed is a race the author
@@ -150,7 +162,7 @@ export function FxSection({
setOpenNode(next.nodes.findIndex((n) => n.fromPreset === id));
setPicking(false);
},
[chain, mutate, clearAudition, trackKind],
[storedChain, mutate, clearAudition, trackKind],
);
const addJob = useCallback(
@@ -311,6 +323,51 @@ export function FxSection({
}, [handBuilt, eqIds.length, showCarve]);
const [openEq, setOpenEq] = useState<string | null>(null);
/** The reveal request held until its row is mounted and scrolled. */
const [consumedRevealNonce, setConsumedRevealNonce] = useState<number | null>(null);
const [pendingReveal, setPendingReveal] = useState<{
nonce: number;
target: string;
} | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
if (revealNonce != null && revealNonce !== consumedRevealNonce) {
setConsumedRevealNonce(revealNonce);
const where = revealTarget ? audioFxRevealTarget(revealTarget, chain) : null;
if (where) {
// Each surface has its own open-state; the resolver says which one owns
// this parameter. Opening the wrong one leaves the click looking dead.
if (where.kind === "node") setOpenNode(where.index);
if (where.kind === "eq") setOpenEq(where.eqId);
if (where.kind === "carve") setCarveOpen(true);
if (where.kind === "preset") {
setCollapsedRuns((was) => {
if (!was.has(where.runKey)) return was;
const next = new Set(was);
next.delete(where.runKey);
return next;
});
}
}
setPendingReveal(where && revealTarget ? { nonce: revealNonce, target: revealTarget } : null);
}
/**
* Scroll the revealed parameter into view once its row has actually mounted.
*
* The request itself is a dependency so a second click on an already-open
* surface still scrolls. It is cleared once used, so a later unrelated
* re-render does not yank the panel back to an old parameter.
*/
useEffect(() => {
const target = pendingReveal?.target;
if (target && scrollRevealedRowIntoView(rootRef.current, target, chain)) {
setPendingReveal(null);
}
// `chain` is deliberately not a dependency: it changes on every knob edit,
// and re-running then would scroll the panel while the author is dragging.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openNode, openEq, carveOpen, collapsedRuns, pendingReveal]);
const addEq = useCallback(() => {
clearAudition();
const { chain: next, eqId } = addAudioEq(chain);
@@ -404,12 +461,14 @@ export function FxSection({
return (
<div
ref={rootRef}
className="hf-fx-section space-y-2"
// Focus lives on the buttons and menu items inside, so the keystroke
// bubbles to here without the section needing focus of its own.
onKeyDown={closeMenus}
>
<FxRackChain
signalPath={signalPath ?? CLIP_SIGNAL_PATH}
chain={chain}
showCarve={showCarve}
carveNodes={carveNodes}
@@ -6,8 +6,27 @@
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import type { HfAudioNameKind, HfCarveSettings } from "@hyperframes/core/audio-carve";
import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
import type { AudioFxSignalPath } from "./audioFxSignalPath.js";
export interface FxSectionProps {
/**
* An automation lane asked to be shown: its `fx.<node>.<param>` / `volume`
* target. The section opens whichever surface owns that parameter — a node
* row, an EQ module, a preset run, or the carve — and scrolls to it.
*/
revealTarget?: string | null;
/**
* The reveal request's nonce. Consumption keys on THIS, not on
* `revealTarget`: clicking a lane selects the clip first, which remounts this
* section, so a `!==` against the previous VALUE initialises to the
* already-set request and never fires — and a second click on the same lane
* would be byte-identical and inert. Same reason `PropertyPanelFlat` keys its
* own consumption on the nonce.
*/
revealNonce?: number | null;
/** What the rack's `In`/`Out` lines name — see `audioFxSignalPath`. Absent
* means an ungrouped clip, which is what those lines said before groups. */
signalPath?: AudioFxSignalPath;
chain: HfAudioFxChain;
/** Targets this track already automates, as `fx.<nodeId>.<param>` strings. */
automatedTargets?: ReadonlySet<string>;
Binary file not shown.
@@ -0,0 +1,118 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { carveBedRoles, carverAgainst, collectCarveCandidates } from "./useFxCarveGrouping";
function previewDoc(html: string): Document {
const doc = document.implementation.createHTMLDocument("preview");
doc.body.innerHTML = html;
return doc;
}
/** What the panel would offer as sources for `bedId`, the way useFxCarve asks. */
function candidatesFor(doc: Document, bedId: string) {
const others = Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]")).filter(
(a) => a.id !== bedId,
);
return collectCarveCandidates(doc, others, () => true, bedId).map((c) => c.id);
}
const GROUPED_VOICES = `
<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
<audio id="vo-1" data-audio-group="voiceover"></audio>
<audio id="vo-2" data-audio-group="voiceover"></audio>
<audio id="music-bed"></audio>
`;
describe("collectCarveCandidates", () => {
// The observed bug: selecting vo-2 offered "Voiceover (2)" — the group vo-2 is
// itself a member of. The caller filters out the bed element, but vo-1 survives
// that filter and rolls up into exactly that group. Being the only candidate, it
// was then applied without the author asking: a member ducking the bus it feeds.
it("never offers a member the group it belongs to", () => {
expect(candidatesFor(previewDoc(GROUPED_VOICES), "vo-2")).toEqual(["music-bed"]);
});
// And the mirror: a group bed's own id matches no <audio> id, so nothing
// excluded it. Its members rolled up and handed the group back to itself.
it("never offers a group itself", () => {
const doc = previewDoc(GROUPED_VOICES);
expect(candidatesFor(doc, "voiceover")).toEqual(["music-bed"]);
});
it("still offers a group the bed has nothing to do with", () => {
const doc = previewDoc(`
${GROUPED_VOICES}
<hf-audio-group id="sfx" data-label="SFX"></hf-audio-group>
<audio id="sfx-click" data-audio-group="sfx"></audio>
`);
expect(candidatesFor(doc, "music-bed")).toEqual(["voiceover", "sfx"]);
});
it("leaves an ungrouped bed's candidates alone", () => {
const doc = previewDoc(`<audio id="music-bed"></audio><audio id="vo-1"></audio>`);
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();
});
});
describe("carveBedRoles", () => {
const roles = (html: string, id: string) => {
const doc = previewDoc(html);
return carveBedRoles(id, doc.getElementById(id));
};
// The observed bug: the bus labelled "Music bed" classified as music, so it
// auto-carved against the same voiceover its own member clip had already
// carved against — the bed ran through both chains. A bus is never a bed, and
// `autoBed` matters as much as `couldBeBed`: that is the half that wrote one
// without being asked.
it("never makes a bus a bed, however it is labelled", () => {
expect(
roles(`<hf-audio-group id="music" data-label="Music bed"></hf-audio-group>`, "music"),
).toEqual({ couldBeBed: false, autoBed: false });
expect(
roles(`<hf-audio-group id="sfx" data-label="Sound FX"></hf-audio-group>`, "sfx"),
).toEqual({ couldBeBed: false, autoBed: false });
});
it("still reads a clip's label, id and src", () => {
expect(roles(`<audio id="a1" data-label="Music bed"></audio>`, "a1")).toEqual({
couldBeBed: true,
autoBed: true,
});
// A name that says nothing may be offered the control but never carves itself.
expect(roles(`<audio id="a1"></audio>`, "a1")).toEqual({ couldBeBed: true, autoBed: false });
expect(roles(`<audio id="vo-2"></audio>`, "vo-2")).toEqual({
couldBeBed: false,
autoBed: false,
});
});
});
@@ -7,8 +7,19 @@
* `propertyPanelAudioFxGroup.tsx`.
*/
import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
import {
classifyAudioName,
HF_AUDIO_CARVE_ATTR,
normalizeCarveSettings,
couldBeCarveBed,
isNamedCarveBed,
type HfCarveSettings,
} from "@hyperframes/core/audio-carve";
import {
HF_AUDIO_GROUP_TAG,
resolveAudioGroups,
resolveCarveSourceIds,
} from "@hyperframes/core/audio-groups";
/**
* An id for a new voiceover group, de-duped against every id already in the
@@ -30,6 +41,12 @@ export function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
return typeof (value as { then?: unknown })?.then === "function";
}
/**
* "The auto-group failed, do not persist this carve" — distinct from a
* legitimate `null`, which means the carve was deliberately cleared.
*/
export const CARVE_ABORTED = Symbol("carve-aborted");
/**
* Plural voiceover carve, always against a group — normative, not a
* suggestion (groups doc §1.6). Picking a second ungrouped voice clip mints a
@@ -71,8 +88,16 @@ export function resolveNextCarveSettings(
nextRaw: HfCarveSettings | null,
doc: Document | undefined,
assignGroup: ((clipIds: readonly string[], groupId: string) => Promise<void>) | undefined,
): HfCarveSettings | Promise<HfCarveSettings> | null {
return nextRaw && doc ? withAutoGroupedSources(doc, nextRaw, assignGroup) : nextRaw;
): HfCarveSettings | Promise<HfCarveSettings | typeof CARVE_ABORTED> | null {
const resolved = nextRaw && doc ? withAutoGroupedSources(doc, nextRaw, assignGroup) : nextRaw;
// A failed auto-group resolves to the sentinel rather than rejecting: the
// write has already toasted, and the caller's job is simply not to persist a
// carve whose `sources` name a group that was never written — which reads, at
// playback, as a carve that silently stops ducking. Caught here rather than
// in the caller so the synchronous branch above stays synchronous.
return isPromiseLike(resolved)
? resolved.catch((): typeof CARVE_ABORTED => CARVE_ABORTED)
: resolved;
}
export interface CarveCandidate {
@@ -87,18 +112,38 @@ export interface CarveCandidate {
* the whole timeline is relevant even if each of its segments only overlaps
* part of the bed). Grouped members never appear individually.
*/
/**
* The ids a carve may never name, for a bed of this id.
*
* A carve names what the bed ducks UNDER, so the bed can never be on that
* list — directly or through the bus it plays into. The caller filters the bed
* element out of `others`, but that only removes the bed itself: its siblings
* survive and roll up into the very group the bed is a member of, which came
* back as a candidate and, being the only one, was applied unprompted. And a
* group bed's own id never matches any <audio> id at all, so nothing stopped a
* group carving against itself.
*/
function excludedFor(groupByMember: Map<string, { id: string }>, bedId?: string): Set<string> {
const selfGroupId = bedId ? groupByMember.get(bedId)?.id : undefined;
return new Set([bedId, selfGroupId].filter((id): id is string => !!id));
}
export function collectCarveCandidates(
doc: Document,
others: readonly HTMLAudioElement[],
overlapsBed: (a: Element) => boolean,
/** The bed's own id, so neither it nor the group it belongs to is offered. */
bedId?: string,
): CarveCandidate[] {
const groupByMember = new Map(
resolveAudioGroups(doc).flatMap((group) => group.memberIds.map((id) => [id, group] as const)),
);
const excluded = excludedFor(groupByMember, bedId);
const offeredGroupIds = new Set<string>();
const described: CarveCandidate[] = [];
for (const a of others) {
const group = groupByMember.get(a.id);
if (excluded.has(a.id) || (group && excluded.has(group.id))) continue;
if (!group) {
if (overlapsBed(a)) {
described.push({
@@ -130,3 +175,93 @@ export function collectCarveCandidates(
}
return described;
}
/**
* The two near-end questions about a track, asked of its name.
*
* `couldBeBed` — may a carve be written onto this at all? A carve makes room in
* a bed for a voice, so a voice track is the one thing that can never be the
* bed. The far-end rule (`couldBeCarveSource` — music and sfx are out) has
* existed since it was written and had no caller; this is the half nothing
* asked, and without it a narration clip was offered the control and, finding
* exactly one candidate, had a carve applied against the group it belonged to.
*
* `autoBed` — may one be applied WITHOUT the author asking? Stricter. Showing
* the module on a track named `a1` is a suggestion; writing `data-fx-carve`
* onto it is a decision, and a decision taken off a name that said nothing is
* how a carve turns up that nobody remembers configuring. The same split the
* source side already makes between what the picker may show (`sourceOptions`)
* and what it may choose unprompted (`autoSourceIds`).
*
* Reads the element's `data-label` as well as its id and `src`, because a clip's
* display name is a hint its filename may not carry. A BUS is excluded outright
* — see the first branch.
*/
export function carveBedRoles(
id: string | null | undefined,
node: Element | null | undefined,
): { couldBeBed: boolean; autoBed: boolean } {
// A BUS is never a bed, whatever it is called. Its rack reaches `useFxCarve`
// too, and reading `data-label` — which is what lets a group be classified at
// all — made a bus labelled "Music bed" read as music: it then auto-carved
// against the same voice its own member clip had already auto-carved against,
// and the bed ran through both sets of filters. Nothing caught that, because
// the only guard (`carverAgainst`) asks "is somebody naming ME as a source",
// never "is my own bus, or my own member, already carved".
//
// A bus could not do the whole job anyway: the level half of the carve reads
// the bed's own `src` to measure how far over the voice it sits, and a bus has
// no `src` — so a bus carve was always the spectral half alone, filters with
// no level match. `data-fx-carve` is a clip attribute; the skill has said so
// ("A carve stays on the clip") since the bus was documented.
//
// Not `couldBeBed: false` alone: that leaves `autoBed` free to fire from a
// name, which is the half that wrote these unasked.
if (node?.tagName?.toLowerCase() === HF_AUDIO_GROUP_TAG) {
return { couldBeBed: false, autoBed: false };
}
const parts = [id, node?.getAttribute("src"), node?.getAttribute("data-label")];
return { couldBeBed: couldBeCarveBed(...parts), autoBed: isNamedCarveBed(...parts) };
}
/**
* 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);
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;
}
}
/**
* Is some other track carving against this one, and which?
*
* A carve is a relationship — a bed is carved against a voice — and the voice is
* the far end of it. Offering the same control there offers to carve a track
* against itself by proxy, and switching it on left a setting with no source it
* could legally name. Read off the other elements' own carve attributes, because
* that is where the relationship is recorded.
*/
export function carverAgainst(
doc: Document | undefined,
id: string | null | undefined,
): 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(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 }] : [];
}
@@ -23,7 +23,7 @@ import {
} from "./propertyPanelAutomation";
import { trackLeveller } from "./audioFxTelemetry.js";
import type { DomEditSelection } from "./domEditingTypes";
import { usePlayerStore } from "../../player";
import { useAuditionTransport } from "./useAuditionTransport.js";
/**
* Rate the track is decoded at. Analysis is self-consistent because it reads
@@ -125,39 +125,10 @@ export function useFxLevelling(
}
};
/**
* Where the playhead was when an audition started the transport, so leaving
* can put it back. Null means this audition did not start playback — the
* transport was already running and must be left alone.
*/
const auditionReturn = useRef<number | null>(null);
/**
* Start playback for an audition, and stop it again on the way out.
*
* An audition writes the preset to the running graph, which is silent while
* the transport is paused — so a paused author hovering a preset heard
* nothing at all, and the whole affordance only worked mid-playback. Hovering
* now plays from the playhead, and leaving stops and rewinds to exactly where
* it started: browsing the shelf must not cost the author their place.
*
* Already playing, this does nothing in either direction. The author started
* that, and stopping their transport because they passed over a preset would
* be the panel taking a decision that was not offered to it.
*/
const auditionTransport = (on: boolean): void => {
const store = usePlayerStore.getState();
if (on) {
if (store.isPlaying || auditionReturn.current !== null) return;
auditionReturn.current = store.currentTime;
store.requestPlayback(true);
return;
}
const returnTo = auditionReturn.current;
if (returnTo === null) return;
auditionReturn.current = null;
store.requestPlayback(false, returnTo);
};
// Hovering plays from the playhead and leaving rewinds to exactly where it
// started: browsing the shelf must not cost the author their place. Shared
// with the timeline's FX popover — see `useAuditionTransport`.
const auditionTransport = useAuditionTransport();
const [auditioningLevel, setAuditioningLevel] = useState(false);
/**