mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
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:
@@ -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);
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user