mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio): make the FX rack speak the author's language (#3192)
* fix(studio): drop the unused DomEditSelection import in PropertyPanelFlat CI caught it on PR #3026 (wa-12-panel-params); a later refactor in the stack removed the last use of the type here without removing the import. * fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a preview-only commit that useDomEditAttributeCommits.ts never grew — backported that option support from its own later commit so the two sides of the API agree. The paste path and its tests were missing the box selection's v0/v1 bounds a sibling commit added to AutomationSelection. The FX panel's carve controls still edited the six mechanism numbers (maxCutDb, bands, intelligibilityBias) after carveProfile() collapsed authoring to one Strength knob, so those fields no longer existed on HfCarveSettings; UI now edits strength, and analyseCarveBands is called with carveProfile(strength). Also closes fallow's complexity, dead-code and duplication findings on this PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math) and useAutomationRangeDrag.ts (the marquee-select gesture) out of useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a resolver into named functions, dropped an export nothing outside its file used, and shared a step-simplifier between audioCarve's two envelope builders. The edge-stretch vs. box-select priority test in TimelineAutomationLane.test was still pinning the pre-box-select rule (edge wins over a point sitting on it) that a sibling commit deliberately reversed — a point inside the box is now selected content, so grabbing it drags the group instead. Updated the test to the shipped rule instead of the old one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): cap the via conic's weight so an edge-clamped via point can't NaN A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to (0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0. viaConic divided by that zero to get an infinite weight, and shapeVia turned Infinity into NaN a few steps later (Infinity - Infinity in the quadratic coefficient). NaN reaching setValueCurveAtTime silences the automated parameter for the rest of the render. Capped the weight at 1e6 instead of leaving it unbounded — past that point the arc already reads as touching the via point, so nothing visible is lost. Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`, since NaN fails the original comparison and fell through it. Review by Miga (PR #3208). * fix(studio-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). * fix(studio): widen PropertyPanel's resetModules render timeout again The 20s margin (already once widened for the same reason) is timing out in CI's full-monorepo Test run — the resetModules()+fresh-import render this test needs is uncached and competes with every other package's test suite for the same worker pool, and the same test passes in well under 2s standalone. Went to 45s rather than re-tuning to whatever number happens to clear the current CI load, since that number moves every time CI gains a package. * fix(studio): stop the single-candidate auto-apply carve firing twice Two auto-apply effects both fire when sourceOptions.length === 1: the multi-candidate effect only guards length === 0, so a single candidate passes it too, and the single-candidate effect passes its own guard right after — both compute the same sources list and both call setCarve, so the common case (one narrator, one bed) triggered two decodes, two FFT runs, and two concurrent attribute writes for one decision. The multi-candidate effect now defers to its sibling for exactly one candidate, which already has its own detailed handling for that case. Review by Miga (PR #3213). * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
27cdd4d5b1
commit
0e4da52c82
@@ -4,8 +4,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
|
||||
import type { DomEditSelection } from "./domEditingTypes";
|
||||
import { EFFECT_COPY } from "@hyperframes/core/audio-fx-copy";
|
||||
import { liveTime, usePlayerStore } from "../../player";
|
||||
|
||||
/**
|
||||
* What a knob is CALLED in the panel, looked up rather than spelled out.
|
||||
*
|
||||
* The rack speaks the plain-language layer now, so a row is addressed by the
|
||||
* parameter it belongs to and the copy decides the words. Hard-coding them here
|
||||
* would make every copy edit a test edit, and these tests are about which row
|
||||
* carries the automate button — not about how it reads.
|
||||
*/
|
||||
function plainLabel(effectId: string, key: string): string {
|
||||
const label = EFFECT_COPY[effectId]?.params[key]?.label;
|
||||
if (!label) throw new Error(`no copy for ${effectId}.${key}`);
|
||||
return label;
|
||||
}
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const CHAIN = JSON.stringify({
|
||||
@@ -49,6 +64,11 @@ function audioSelection(
|
||||
return { dataAttributes, id: "bed", element: bed } as unknown as DomEditSelection;
|
||||
}
|
||||
|
||||
/** A button found by the text it contains, since several now read as sentences. */
|
||||
function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
||||
}
|
||||
|
||||
function mount(dataAttributes: Record<string, string>, alone = false, voices = 2) {
|
||||
// Every write is quiet: persisted without the preview reload that would
|
||||
// restart every playing track, but with a selection resync so the panel sees
|
||||
@@ -91,15 +111,17 @@ const writeTo = (calls: unknown[][], attr: string): unknown[] | undefined =>
|
||||
describe("AudioFxGroup automation", () => {
|
||||
it("renders the chain's parameters", () => {
|
||||
const { host } = mount({ "fx-chain": CHAIN });
|
||||
expect(rowFor(host, "Cutoff")).toBeTruthy();
|
||||
expect(rowFor(host, "Q")).toBeTruthy();
|
||||
expect(rowFor(host, plainLabel("lowpass", "frequency"))).toBeTruthy();
|
||||
expect(rowFor(host, plainLabel("lowpass", "q"))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("seeds a new lane at the value the control already holds", () => {
|
||||
// Switching to an envelope must not change the sound — only where the value
|
||||
// comes from. The chain has frequency at 900, not the registry default.
|
||||
const { host, onSetAttributeQuiet } = mount({ "fx-chain": CHAIN });
|
||||
const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement;
|
||||
const button = rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
|
||||
".hf-fx-automate",
|
||||
) as HTMLButtonElement;
|
||||
act(() => button.click());
|
||||
const write = writeTo(onSetAttributeQuiet.mock.calls, "data-automation");
|
||||
expect(write).toBeTruthy();
|
||||
@@ -117,7 +139,13 @@ describe("AudioFxGroup automation", () => {
|
||||
lanes: [{ target: "volume", points: [{ t: 0, v: 0.5 }] }],
|
||||
}),
|
||||
});
|
||||
act(() => (rowFor(host, "Q")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click());
|
||||
act(() =>
|
||||
(
|
||||
rowFor(host, plainLabel("lowpass", "q"))!.querySelector(
|
||||
".hf-fx-automate",
|
||||
) as HTMLButtonElement
|
||||
).click(),
|
||||
);
|
||||
expect(
|
||||
parseWrite(writeTo(onSetAttributeQuiet.mock.calls, "data-automation")!).lanes.map(
|
||||
(l: { target: string }) => l.target,
|
||||
@@ -133,11 +161,13 @@ describe("AudioFxGroup automation", () => {
|
||||
lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
|
||||
}),
|
||||
});
|
||||
const cutoff = rowFor(host, "Cutoff")!;
|
||||
const cutoff = rowFor(host, plainLabel("lowpass", "frequency"))!;
|
||||
expect(cutoff.querySelector<HTMLInputElement>('input[type="range"]')?.disabled).toBe(true);
|
||||
expect(cutoff.hasAttribute("data-automated")).toBe(true);
|
||||
expect(
|
||||
rowFor(host, "Q")!.querySelector<HTMLInputElement>('input[type="range"]')?.disabled,
|
||||
rowFor(host, plainLabel("lowpass", "q"))!.querySelector<HTMLInputElement>(
|
||||
'input[type="range"]',
|
||||
)?.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -153,7 +183,11 @@ describe("AudioFxGroup automation", () => {
|
||||
}),
|
||||
});
|
||||
act(() =>
|
||||
(rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click(),
|
||||
(
|
||||
rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
|
||||
".hf-fx-automate",
|
||||
) as HTMLButtonElement
|
||||
).click(),
|
||||
);
|
||||
expect(
|
||||
parseWrite(writeTo(onSetAttributeQuiet.mock.calls, "data-automation")!).lanes.map(
|
||||
@@ -171,7 +205,11 @@ describe("AudioFxGroup automation", () => {
|
||||
}),
|
||||
});
|
||||
act(() =>
|
||||
(rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click(),
|
||||
(
|
||||
rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
|
||||
".hf-fx-automate",
|
||||
) as HTMLButtonElement
|
||||
).click(),
|
||||
);
|
||||
// Null rather than "": the live path removes an attribute it is given null for.
|
||||
expect(writeTo(onSetAttributeQuiet.mock.calls, "data-automation")![1]).toBeNull();
|
||||
@@ -187,7 +225,9 @@ describe("AudioFxGroup automation", () => {
|
||||
});
|
||||
// Nothing is automated, so every control stays live.
|
||||
expect(
|
||||
rowFor(host, "Cutoff")!.querySelector<HTMLInputElement>('input[type="range"]')?.disabled,
|
||||
rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector<HTMLInputElement>(
|
||||
'input[type="range"]',
|
||||
)?.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -326,8 +366,105 @@ describe("AudioFxGroup dynamic carve", () => {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same voice, but the decode does not finish until it is let go.
|
||||
*
|
||||
* Hover-auditioning the leveller is the one path where the result can arrive
|
||||
* after the author has moved on, so the tests that cover that need to hold the
|
||||
* decode open across a second gesture.
|
||||
*/
|
||||
function stubGatedDecode(): { release: () => void; decoded: Promise<void> } {
|
||||
const sampleRate = 48000;
|
||||
const data = new Float32Array(sampleRate * 4);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const t = i / sampleRate;
|
||||
data[i] = t > 1 && t < 3 ? 0.7 * Math.sin(2 * Math.PI * 1000 * t) : 0;
|
||||
}
|
||||
let release = (): void => {};
|
||||
const decoded = new Promise<void>((r) => {
|
||||
release = r;
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"OfflineAudioContext",
|
||||
class {
|
||||
async decodeAudioData() {
|
||||
await decoded;
|
||||
return { sampleRate, getChannelData: () => data };
|
||||
}
|
||||
},
|
||||
);
|
||||
return { release: () => release(), decoded };
|
||||
}
|
||||
|
||||
/** Let the held decode finish, and the measurement it feeds after it. */
|
||||
async function settleDecode(release: () => void, decoded: Promise<void>): Promise<void> {
|
||||
await act(async () => {
|
||||
release();
|
||||
await decoded;
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
/**
|
||||
* Hover-auditioning the leveller has to measure before there is anything to
|
||||
* hear, and measuring a long voiceover takes seconds — by which time the
|
||||
* pointer has usually moved on. Applying then would put levelling on a track
|
||||
* nobody asked to level, through a channel that does not persist: audible,
|
||||
* absent from the document, and gone on the next reload.
|
||||
*/
|
||||
it("drops a levelling measurement that lands after the pointer has gone", async () => {
|
||||
const { release, decoded } = stubGatedDecode();
|
||||
const { host, onSetAttributeLive } = mount({ "fx-chain": CHAIN });
|
||||
document.getElementById("bed")?.setAttribute("src", "bed.wav");
|
||||
act(() => byTextButton(host, "Add effect")?.click());
|
||||
const level = byTextButton(host, "Even Out Levels");
|
||||
expect(level, "the levelling button was not offered").toBeTruthy();
|
||||
act(() => level?.focus());
|
||||
// Gone again before the decode finishes.
|
||||
act(() => {
|
||||
host
|
||||
.querySelector(".hf-fx-add-menu")
|
||||
?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await settleDecode(release, decoded);
|
||||
|
||||
// The revert on the way out is allowed to write; a levelling stage is not.
|
||||
const levelled = onSetAttributeLive.mock.calls.filter((c) =>
|
||||
String(c[1] ?? "").includes("fromLeveller"),
|
||||
);
|
||||
expect(levelled).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Sliding from the leveller to the effect beside it is not leaving the menu,
|
||||
* so the shelf's own leave never fires — and the measurement already in flight
|
||||
* used to land on top of whatever was being auditioned next, writing a
|
||||
* levelled version of the chain as it was through a channel the document never
|
||||
* sees. Every entry in the shelf calls its neighbours' auditions off.
|
||||
*/
|
||||
it("calls the levelling measurement off when the pointer moves to the effect beside it", async () => {
|
||||
const { release, decoded } = stubGatedDecode();
|
||||
const { host, onSetAttributeLive } = mount({ "fx-chain": CHAIN });
|
||||
document.getElementById("bed")?.setAttribute("src", "bed.wav");
|
||||
act(() => byTextButton(host, "Add effect")?.click());
|
||||
act(() => byTextButton(host, "Even Out Levels")?.focus());
|
||||
// Straight to a neighbour, without ever leaving the shelf.
|
||||
act(() =>
|
||||
byTextButton(host, "Reverb")?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })),
|
||||
);
|
||||
await settleDecode(release, decoded);
|
||||
|
||||
expect(
|
||||
onSetAttributeLive.mock.calls.filter((c) => String(c[1] ?? "").includes("fromLeveller")),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("automates the carve filters' gain from the voice, in the bed's own time", async () => {
|
||||
stubDecode();
|
||||
// Voice starts 10s into the composition, bed at 0: the envelope is measured
|
||||
@@ -933,7 +1070,7 @@ describe("AudioFxGroup carve module readouts", () => {
|
||||
],
|
||||
}),
|
||||
});
|
||||
const mixRow = rowFor(host, "Mix");
|
||||
const mixRow = rowFor(host, plainLabel("delay", "mix"));
|
||||
const number = mixRow?.querySelector<HTMLInputElement>(".hf-fx-number");
|
||||
const slider = mixRow?.querySelector<HTMLInputElement>(".hf-fx-slider");
|
||||
expect(number?.disabled).toBe(true); // the lane owns it
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* budget, and self-contained enough to test on its own.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
defaultAudioFxParams,
|
||||
HF_AUDIO_FX_ATTR,
|
||||
@@ -496,23 +496,43 @@ export function AudioFxGroup({
|
||||
* works, write once — but it listens to the track it is on rather than to a
|
||||
* voice above it, so it needs no source picker.
|
||||
*/
|
||||
const runLeveller = async (): Promise<void> => {
|
||||
/**
|
||||
* This track's audio, decoded once and kept.
|
||||
*
|
||||
* Levelling is measured from it, and hover-auditioning means measuring on every
|
||||
* pass over the button — fetching and decoding a several-minute voiceover each
|
||||
* time would make the audition slower than the thing it is previewing. Keyed by
|
||||
* `src` so a track pointed at a different file re-decodes.
|
||||
*/
|
||||
const decoded = useRef<{ src: string; samples: Float32Array; sampleRate: number } | null>(null);
|
||||
|
||||
const decodeTrack = async (): Promise<{ samples: Float32Array; sampleRate: number } | null> => {
|
||||
const el = element.element;
|
||||
const src = el?.getAttribute("src");
|
||||
const doc = el?.ownerDocument;
|
||||
if (!src || !doc) return;
|
||||
if (!src || !doc) return null;
|
||||
const cached = decoded.current;
|
||||
if (cached?.src === src) return cached;
|
||||
const Ctor =
|
||||
window.OfflineAudioContext ??
|
||||
(window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
|
||||
.webkitOfflineAudioContext;
|
||||
if (!Ctor) return null;
|
||||
const res = await fetch(new URL(src, doc.baseURI).href);
|
||||
const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
|
||||
await res.arrayBuffer(),
|
||||
);
|
||||
const next = { src, samples: buffer.getChannelData(0), sampleRate: buffer.sampleRate };
|
||||
decoded.current = next;
|
||||
return next;
|
||||
};
|
||||
|
||||
const runLeveller = async (): Promise<void> => {
|
||||
setAnalysing(true);
|
||||
try {
|
||||
const Ctor =
|
||||
window.OfflineAudioContext ??
|
||||
(window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
|
||||
.webkitOfflineAudioContext;
|
||||
if (!Ctor) return;
|
||||
const res = await fetch(new URL(src, doc.baseURI).href);
|
||||
const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
|
||||
await res.arrayBuffer(),
|
||||
);
|
||||
const result = levellingResult(chain, buffer.getChannelData(0), buffer.sampleRate);
|
||||
const audio = await decodeTrack();
|
||||
if (!audio) return;
|
||||
const result = levellingResult(chain, audio.samples, audio.sampleRate);
|
||||
if (!result) return;
|
||||
await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
|
||||
// Merged by target, never written wholesale: the script describes its own
|
||||
@@ -533,6 +553,60 @@ export function AudioFxGroup({
|
||||
}
|
||||
};
|
||||
|
||||
const [auditioningLevel, setAuditioningLevel] = useState(false);
|
||||
/**
|
||||
* Bumped on every enter and leave, so a measurement can tell whether the
|
||||
* pointer is still on the button when it finishes.
|
||||
*
|
||||
* Decoding a long voiceover takes seconds, and a hover that takes seconds is
|
||||
* one the author has usually already left. Applying the result then would put
|
||||
* levelling on a track nobody asked to level, through a channel that does not
|
||||
* persist — so it would be audible, invisible in the document, and gone on the
|
||||
* next reload. This counter is what makes a late result a no-op.
|
||||
*/
|
||||
const auditionRun = useRef(0);
|
||||
|
||||
/**
|
||||
* Measure this track and play the levelling without persisting it.
|
||||
*
|
||||
* `false` puts the stored chain and automation back. Both attributes, because
|
||||
* levelling is a node AND the lane that drives it: reverting only the chain
|
||||
* would leave an envelope writing to a gain stage that is no longer there.
|
||||
*/
|
||||
const auditionLevel = async (on: boolean): Promise<void> => {
|
||||
const run = ++auditionRun.current;
|
||||
if (!on) {
|
||||
setAuditioningLevel(false);
|
||||
void onSetAttributeLive(
|
||||
HF_AUDIO_FX_ATTR,
|
||||
chain.nodes.length ? serializeAudioFxChain(chain) : null,
|
||||
);
|
||||
void onSetAttributeLive(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(automation) || null);
|
||||
return;
|
||||
}
|
||||
setAuditioningLevel(true);
|
||||
try {
|
||||
const audio = await decodeTrack();
|
||||
// Gone, or superseded by a later hover. Either way this result is stale.
|
||||
if (!audio || run !== auditionRun.current) return;
|
||||
const result = levellingResult(chain, audio.samples, audio.sampleRate);
|
||||
if (!result || run !== auditionRun.current) return;
|
||||
void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
|
||||
const lane = result.automation.lanes[0];
|
||||
if (lane) {
|
||||
void onSetAttributeLive(
|
||||
HF_AUDIO_AUTOMATION_ATTR,
|
||||
automationAttrValue(withLane(automation, lane)) || null,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Same as the real run: a track that cannot be decoded simply does not
|
||||
// audition, rather than failing the panel.
|
||||
} finally {
|
||||
if (run === auditionRun.current) setAuditioningLevel(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeLeveller = (): void => {
|
||||
const { chain: next, removedTarget } = removeLevelling(chain);
|
||||
void onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
|
||||
@@ -726,6 +800,8 @@ export function AudioFxGroup({
|
||||
onLevel={() => void runLeveller()}
|
||||
onRemoveLevel={removeLeveller}
|
||||
levelled={chain.nodes.some((n) => n.fromLeveller)}
|
||||
onAuditionLevel={(on) => void auditionLevel(on)}
|
||||
auditioningLevel={auditioningLevel}
|
||||
carvedAgainstBy={carvedAgainstBy}
|
||||
analysing={analysing}
|
||||
/>
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
*
|
||||
* An entry in the chain, as opposed to a composite module — the carve and the
|
||||
* Tone EQ own several nodes each and have their own files.
|
||||
*
|
||||
* The row speaks the author's language, not the registry's. `EFFECT_COPY`
|
||||
* supplies the name and every knob's name, `SUMMARY` the sentence under it, and
|
||||
* the DSP name moves inside — it is a fact about the mechanism, so it belongs
|
||||
* with the mechanism. See `plans/audio-fx-ux/README.md` §Decided.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
defaultAudioFxParams,
|
||||
getAudioFxDef,
|
||||
@@ -12,9 +18,33 @@ import {
|
||||
type HfAudioFxNode,
|
||||
type HfAudioFxParamValues,
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import { EFFECT_COPY, SUMMARY } from "@hyperframes/core/audio-fx-copy";
|
||||
import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
|
||||
import { FxParams } from "./propertyPanelFxControls.js";
|
||||
|
||||
/**
|
||||
* The registry's definition with the plain names written over it.
|
||||
*
|
||||
* Over rather than instead of: the registry stays the authority on range, step,
|
||||
* unit and what is automatable, and only the words change. A parameter with no
|
||||
* copy keeps its own label rather than disappearing — `audioFxCopy.test.ts` is
|
||||
* what makes sure there is never one.
|
||||
*/
|
||||
function plainDef(def: HfAudioFxDef): HfAudioFxDef {
|
||||
const copy = EFFECT_COPY[def.id];
|
||||
if (!copy) return def;
|
||||
return {
|
||||
...def,
|
||||
params: def.params.map((param) => {
|
||||
const plain = copy.params[param.key];
|
||||
if (!plain) return param;
|
||||
// The registry's hint explains the mechanism, which is still the better
|
||||
// tooltip than none — but the plain one wins where it exists.
|
||||
return { ...param, label: plain.label, ...(plain.hint ? { hint: plain.hint } : {}) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
interface FxNodeRowProps {
|
||||
node: HfAudioFxNode;
|
||||
index: number;
|
||||
@@ -218,18 +248,26 @@ export function FxNodeRow({
|
||||
onRemove,
|
||||
onPreview,
|
||||
}: FxNodeRowProps) {
|
||||
const def = getAudioFxDef(node.type);
|
||||
if (!def) return null;
|
||||
const registryDef = getAudioFxDef(node.type);
|
||||
const def = useMemo(() => (registryDef ? plainDef(registryDef) : null), [registryDef]);
|
||||
if (!registryDef || !def) return null;
|
||||
const bypassed = node.enabled === false;
|
||||
const params = node.params ?? defaultAudioFxParams(node.type);
|
||||
// What this effect is doing to the sound, as a sentence. The rack is read top
|
||||
// to bottom far more often than any one module is opened, so this is the line
|
||||
// that decides whether an author can follow their own mix.
|
||||
const summary = SUMMARY[node.type]?.(params);
|
||||
return (
|
||||
<div
|
||||
className={`hf-fx-node rounded-[4px] border border-panel-border-input${bypassed ? " opacity-50" : ""}`}
|
||||
data-fx-node={node.type}
|
||||
>
|
||||
<FxNodeHeader
|
||||
// The node's own job name when a preset gave it one: a chain that cuts
|
||||
// mud and then lifts clarity must not show "Peaking EQ" twice.
|
||||
label={node.label ?? def.label}
|
||||
// The node's own job name when a preset gave it one, because that is the
|
||||
// most specific truth available: a chain that cuts mud and then lifts
|
||||
// clarity must not show the same name twice. Then the plain name, and
|
||||
// the registry's only if an effect somehow has no copy.
|
||||
label={node.label ?? EFFECT_COPY[node.type]?.title ?? registryDef.label}
|
||||
open={open}
|
||||
bypassed={bypassed}
|
||||
first={index === 0}
|
||||
@@ -240,19 +278,32 @@ export function FxNodeRow({
|
||||
onMove={(delta) => onMove(index, delta)}
|
||||
onRemove={() => onRemove(index)}
|
||||
/>
|
||||
{summary ? (
|
||||
<p className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-4">
|
||||
{summary}
|
||||
</p>
|
||||
) : null}
|
||||
{open ? (
|
||||
<FxNodeParams
|
||||
node={node}
|
||||
def={def}
|
||||
index={index}
|
||||
disabled={Boolean(disabled) || bypassed}
|
||||
automatedTargets={automatedTargets}
|
||||
liveAutomationValues={liveAutomationValues}
|
||||
onUpdate={onUpdate}
|
||||
onPreview={onPreview}
|
||||
onAutomateParam={onAutomateParam}
|
||||
onRemoveParamAutomation={onRemoveParamAutomation}
|
||||
/>
|
||||
<>
|
||||
{/* The DSP name, once, where the mechanism is. An author who wants to
|
||||
know what "Remove Rumble" really is finds out by opening it; one who
|
||||
does not never has to meet the word. */}
|
||||
<p className="hf-fx-node-mechanism border-t border-panel-border-input px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
|
||||
Details — {registryDef.label}
|
||||
</p>
|
||||
<FxNodeParams
|
||||
node={node}
|
||||
def={def}
|
||||
index={index}
|
||||
disabled={Boolean(disabled) || bypassed}
|
||||
automatedTargets={automatedTargets}
|
||||
liveAutomationValues={liveAutomationValues}
|
||||
onUpdate={onUpdate}
|
||||
onPreview={onPreview}
|
||||
onAutomateParam={onAutomateParam}
|
||||
onRemoveParamAutomation={onRemoveParamAutomation}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
HF_AUDIO_FX_PRESET_FAMILIES,
|
||||
type HfAudioFxPresetFamily,
|
||||
} from "@hyperframes/core/audio-fx-presets";
|
||||
import { PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
|
||||
|
||||
/**
|
||||
* Shelf names in the author's language, which is deliberately not the effect
|
||||
@@ -28,21 +29,46 @@ const FAMILY_LABEL: Record<HfAudioFxPresetFamily, string> = {
|
||||
|
||||
export interface FxPresetMenuProps {
|
||||
onPick(id: string): void;
|
||||
/**
|
||||
* Play this preset on the running audio without persisting it, and revert on
|
||||
* `null`. Absent when there is no preview channel to hear it through.
|
||||
*/
|
||||
onAudition?(id: string | null): void;
|
||||
}
|
||||
|
||||
export function FxPresetMenu({ onPick }: FxPresetMenuProps) {
|
||||
/**
|
||||
* Presets read as the complaint they answer, not as their own names.
|
||||
*
|
||||
* The name is a thing you have to already know — "Telephone", "Broadcast" — and
|
||||
* the author arriving here does not know it; they know their voice sounds
|
||||
* boomy. So the sentence leads and the name follows underneath, which is also
|
||||
* how the name gets learned. The four shelves stay because eighteen sentences
|
||||
* in a column is a wall, and they are already the author's grouping rather than
|
||||
* the registry's. See `plans/audio-fx-ux/README.md` §Decided.
|
||||
*/
|
||||
export function FxPresetMenu({ onPick, onAudition }: FxPresetMenuProps) {
|
||||
return (
|
||||
<div className="hf-fx-preset-menu space-y-1.5 rounded-[4px] border border-panel-border-input p-1.5">
|
||||
<div
|
||||
className="hf-fx-preset-menu space-y-1.5 rounded-[4px] border border-panel-border-input p-1.5"
|
||||
// One handler for the shelf rather than one per button: leaving any preset
|
||||
// for the gap between two of them has to revert, and a per-button leave
|
||||
// fires that on the way to the next one.
|
||||
onMouseLeave={onAudition ? () => onAudition(null) : undefined}
|
||||
// Focus leaving the shelf is the keyboard's version of the pointer leaving
|
||||
// it. Moving between two buttons inside fires this and then the next
|
||||
// button's focus, so it reverts and re-auditions rather than sticking.
|
||||
onBlur={onAudition ? () => onAudition(null) : undefined}
|
||||
>
|
||||
{HF_AUDIO_FX_PRESET_FAMILIES.map((family) => (
|
||||
<div key={family} className="hf-fx-preset-group flex flex-wrap items-center gap-1">
|
||||
<span className="hf-fx-preset-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
|
||||
<div key={family} className="hf-fx-preset-group space-y-0.5">
|
||||
<span className="hf-fx-preset-group-label block font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
|
||||
{FAMILY_LABEL[family]}
|
||||
</span>
|
||||
{audioFxPresetsByFamily(family).map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className="hf-fx-preset-item rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
|
||||
className="hf-fx-preset-item block w-full rounded-[3px] bg-panel-surface px-1.5 py-1 text-left text-panel-text-1 hover:text-panel-text-0"
|
||||
// The description says what it does; the count is doing real work
|
||||
// — it tells the author a preset IS a chain they can open and
|
||||
// edit, rather than an opaque setting they cannot follow.
|
||||
@@ -50,8 +76,17 @@ export function FxPresetMenu({ onPick }: FxPresetMenuProps) {
|
||||
preset.nodes.length === 1 ? "" : "s"
|
||||
})`}
|
||||
onClick={() => onPick(preset.id)}
|
||||
onMouseEnter={onAudition ? () => onAudition(preset.id) : undefined}
|
||||
// Keyboard reaches this too: arrowing down the shelf auditions the
|
||||
// same way hovering does, or the whole affordance is mouse-only.
|
||||
onFocus={onAudition ? () => onAudition(preset.id) : undefined}
|
||||
>
|
||||
{preset.label}
|
||||
<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-4">
|
||||
{preset.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
type HfAudioFxChain,
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve";
|
||||
import { EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
|
||||
import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
|
||||
|
||||
/**
|
||||
* What a knob is CALLED in the panel, looked up rather than spelled out.
|
||||
*
|
||||
* The rack speaks the plain-language layer now, so a row is addressed by the
|
||||
* parameter it belongs to and the copy decides the words. Hard-coding them here
|
||||
* would make every copy edit a test edit, and these tests are about which row
|
||||
* carries the automate button — not about how it reads.
|
||||
*/
|
||||
function plainLabel(effectId: string, key: string): string {
|
||||
const label = EFFECT_COPY[effectId]?.params[key]?.label;
|
||||
if (!label) throw new Error(`no copy for ${effectId}.${key}`);
|
||||
return label;
|
||||
}
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { FxSection } from "./propertyPanelFxSection.js";
|
||||
|
||||
@@ -56,7 +72,7 @@ function mount(overrides: Partial<Parameters<typeof FxSection>[0]> = {}) {
|
||||
const onChainChange = vi.fn();
|
||||
const onChainPreview = vi.fn();
|
||||
const onCarveChange = vi.fn();
|
||||
const { host } = renderInto(
|
||||
const { host, root } = renderInto(
|
||||
<FxSection
|
||||
chain={overrides.chain ?? { version: 1, nodes: [] }}
|
||||
onChainChange={overrides.onChainChange ?? onChainChange}
|
||||
@@ -72,9 +88,11 @@ function mount(overrides: Partial<Parameters<typeof FxSection>[0]> = {}) {
|
||||
onLevel={overrides.onLevel}
|
||||
onRemoveLevel={overrides.onRemoveLevel}
|
||||
levelled={overrides.levelled}
|
||||
onAuditionLevel={overrides.onAuditionLevel}
|
||||
auditioningLevel={overrides.auditioningLevel}
|
||||
/>,
|
||||
);
|
||||
return { host, onChainChange, onChainPreview, onCarveChange };
|
||||
return { host, root, onChainChange, onChainPreview, onCarveChange };
|
||||
}
|
||||
|
||||
const click = (el: Element | null | undefined) => {
|
||||
@@ -91,6 +109,19 @@ const openAddMenuItems = (host: HTMLElement) => {
|
||||
return Array.from(host.querySelectorAll(".hf-fx-add-item")).map((e) => e.textContent?.trim());
|
||||
};
|
||||
|
||||
/**
|
||||
* A preset button, found by the preset it applies rather than by its words.
|
||||
*
|
||||
* The shelf leads with the complaint and follows with the name, so a button's
|
||||
* text is two sentences and neither of them alone is what an author would call
|
||||
* it. Addressing it by id keeps the test about what applying it does.
|
||||
*/
|
||||
const presetButton = (host: HTMLElement, id: string): Element | undefined =>
|
||||
Array.from(host.querySelectorAll(".hf-fx-preset-item")).find(
|
||||
(e) =>
|
||||
e.querySelector(".hf-fx-preset-name")?.textContent?.trim() === getAudioFxPreset(id)?.label,
|
||||
);
|
||||
|
||||
/**
|
||||
* React tracks an input's value on the DOM node, so assigning `.value` and
|
||||
* dispatching looks like a no-op change and the handler never fires. Going
|
||||
@@ -142,7 +173,7 @@ describe("FxSection chain", () => {
|
||||
const labels = Array.from(host.querySelectorAll(".hf-fx-label")).map((e) =>
|
||||
e.textContent?.trim(),
|
||||
);
|
||||
for (const p of def.params) expect(labels).toContain(p.label);
|
||||
for (const p of def.params) expect(labels).toContain(plainLabel("compressor", p.key));
|
||||
});
|
||||
|
||||
it("uses a select for an enum parameter and a slider for a number", () => {
|
||||
@@ -220,7 +251,7 @@ describe("FxSection chain", () => {
|
||||
it("applies a preset as ordinary nodes, tagged with where they came from", () => {
|
||||
const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
|
||||
click(byText(host, "button", "Presets"));
|
||||
click(byText(host, "button", "Telephone"));
|
||||
click(presetButton(host, "telephone"));
|
||||
|
||||
const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
|
||||
// The band, its honk and de-mud shaping, and the soft clip — a chain the
|
||||
@@ -239,6 +270,153 @@ describe("FxSection chain", () => {
|
||||
expect(new Set(next.nodes.map((n) => n.id)).size).toBe(next.nodes.length);
|
||||
});
|
||||
|
||||
it("names an effect for the job it does, and keeps the DSP name for inside", () => {
|
||||
// The rack is read by somebody who has never opened a mixer. "Remove Rumble"
|
||||
// is what they came here for; "High-pass" is a fact about the mechanism, so
|
||||
// it waits until they open the module and ask.
|
||||
const { host } = mount({ chain: chainOf("highpass") });
|
||||
const node = fxCard(host);
|
||||
const name = node.querySelector(".hf-fx-node-name")?.textContent?.trim();
|
||||
expect(name).toBe(EFFECT_COPY.highpass?.title);
|
||||
expect(name).not.toBe(getAudioFxDef("highpass")?.label);
|
||||
// And a sentence under it, so the rack reads top to bottom.
|
||||
expect(node.querySelector(".hf-fx-node-summary")?.textContent).toContain("Cutting everything");
|
||||
// The first node is open by default, which is where the DSP name lives.
|
||||
expect(node.querySelector(".hf-fx-node-mechanism")?.textContent).toContain(
|
||||
getAudioFxDef("highpass")?.label,
|
||||
);
|
||||
});
|
||||
|
||||
it("offers presets as the complaint they answer", () => {
|
||||
const { host } = mount({ chain: { version: 1, nodes: [] } });
|
||||
click(byText(host, "button", "Presets"));
|
||||
const item = presetButton(host, "telephone");
|
||||
expect(item?.querySelector(".hf-fx-preset-problem")?.textContent).toBe(
|
||||
PRESET_PROBLEM.telephone,
|
||||
);
|
||||
// The name is still there, under it — which is how it gets learned.
|
||||
expect(item?.querySelector(".hf-fx-preset-name")?.textContent).toBe("Telephone");
|
||||
});
|
||||
|
||||
describe("hover-audition", () => {
|
||||
/** Focus is the keyboard's hover, and both go through the same handler. */
|
||||
const enter = (el: Element | null | undefined) => {
|
||||
if (!el) throw new Error("element not found");
|
||||
act(() => (el as HTMLElement).focus());
|
||||
};
|
||||
const leave = (host: HTMLElement, sel: string) =>
|
||||
act(() => {
|
||||
host.querySelector(sel)?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
|
||||
it("plays a preset without committing to it", () => {
|
||||
const { host, onChainPreview, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
|
||||
click(byText(host, "button", "Presets"));
|
||||
enter(presetButton(host, "telephone"));
|
||||
|
||||
const heard = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
|
||||
expect(heard.nodes.length).toBeGreaterThan(0);
|
||||
expect(heard.nodes.every((n) => n.fromPreset === "telephone")).toBe(true);
|
||||
// Heard, not written. Hovering is not a decision.
|
||||
expect(onChainChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("puts the chain back on the way out", () => {
|
||||
const { host, onChainPreview } = mount({ chain: chainOf("peaking") });
|
||||
click(byText(host, "button", "Presets"));
|
||||
enter(presetButton(host, "telephone"));
|
||||
leave(host, ".hf-fx-preset-menu");
|
||||
|
||||
const back = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
|
||||
expect(back.nodes.map((n) => n.type)).toEqual(["peaking"]);
|
||||
});
|
||||
|
||||
it("does not put the old chain back over the preset it just applied", () => {
|
||||
// The audition WAS the preset, so reverting after the write is a race the
|
||||
// author hears as it arriving and then leaving again.
|
||||
//
|
||||
// Applying closes the shelf, so the pointer never leaves it — the revert
|
||||
// that would fire is the panel's own teardown, which is what unmounting
|
||||
// here exercises. It is also the real route: applying a preset and then
|
||||
// clicking another clip does exactly this.
|
||||
const { host, root, onChainPreview } = mount({ chain: { version: 1, nodes: [] } });
|
||||
click(byText(host, "button", "Presets"));
|
||||
enter(presetButton(host, "telephone"));
|
||||
const auditions = onChainPreview.mock.calls.length;
|
||||
click(presetButton(host, "telephone"));
|
||||
act(() => root.unmount());
|
||||
|
||||
expect(onChainPreview.mock.calls.length).toBe(auditions);
|
||||
});
|
||||
|
||||
it("puts the chain back if the panel goes away mid-audition", () => {
|
||||
// Deselecting the clip while hovering is not a decision either, and the
|
||||
// preview channel does not persist — so without this the author hears a
|
||||
// chain the document does not have until something else writes.
|
||||
const { host, root, onChainPreview } = mount({ chain: chainOf("peaking") });
|
||||
click(byText(host, "button", "Presets"));
|
||||
enter(presetButton(host, "telephone"));
|
||||
act(() => root.unmount());
|
||||
|
||||
const back = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
|
||||
expect(back.nodes.map((n) => n.type)).toEqual(["peaking"]);
|
||||
});
|
||||
|
||||
it("survives the panel re-rendering under it, which playback does constantly", () => {
|
||||
// The group re-renders on every playhead tick to move the automation
|
||||
// readouts, handing down a fresh preview callback each time. A teardown
|
||||
// keyed on that callback ran on every tick, so an audition reverted itself
|
||||
// about thirty times a second — during playback, which is the only time
|
||||
// there is anything to audition.
|
||||
const { host, root, onChainPreview } = mount({ chain: chainOf("peaking") });
|
||||
click(byText(host, "button", "Presets"));
|
||||
enter(presetButton(host, "telephone"));
|
||||
const auditions = onChainPreview.mock.calls.length;
|
||||
|
||||
// Same behaviour, new identity — exactly what a tick hands down.
|
||||
act(() =>
|
||||
root.render(
|
||||
<FxSection
|
||||
chain={chainOf("peaking")}
|
||||
onChainChange={vi.fn()}
|
||||
onChainPreview={(next) => onChainPreview(next)}
|
||||
carve={null}
|
||||
onCarveChange={vi.fn()}
|
||||
sourceOptions={[{ id: "vo", label: "Voiceover" }]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(onChainPreview.mock.calls.length).toBe(auditions);
|
||||
});
|
||||
|
||||
it("auditions an effect the add menu is offering", () => {
|
||||
const { host, onChainPreview, onChainChange } = mount({ chain: chainOf("peaking") });
|
||||
click(byText(host, "button", "Add effect"));
|
||||
enter(byText(host, "button", "Reverb"));
|
||||
|
||||
const heard = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
|
||||
expect(heard.nodes.map((n) => n.type)).toEqual(["peaking", "reverb"]);
|
||||
expect(onChainChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for a levelling measurement on hover, and calls it off on the way out", () => {
|
||||
// The one module that cannot answer instantly: there is nothing to hear
|
||||
// until the track has been decoded and measured.
|
||||
const onAuditionLevel = vi.fn();
|
||||
const { host } = mount({
|
||||
chain: { version: 1, nodes: [] },
|
||||
onLevel: vi.fn(),
|
||||
onAuditionLevel,
|
||||
});
|
||||
click(byText(host, "button", "Add effect"));
|
||||
enter(byText(host, "button", "Even Out Levels"));
|
||||
expect(onAuditionLevel).toHaveBeenLastCalledWith(true);
|
||||
leave(host, ".hf-fx-add-menu");
|
||||
expect(onAuditionLevel).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a preset to what is already there rather than replacing it", () => {
|
||||
const existing: HfAudioFxChain = {
|
||||
version: 1,
|
||||
@@ -248,7 +426,7 @@ describe("FxSection chain", () => {
|
||||
};
|
||||
const { host, onChainChange } = mount({ chain: existing });
|
||||
click(byText(host, "button", "Presets"));
|
||||
click(byText(host, "button", "Cut Rumble"));
|
||||
click(presetButton(host, "rumble-cut"));
|
||||
|
||||
const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
|
||||
expect(next.nodes.map((n) => n.id)).toContain("mine");
|
||||
@@ -761,8 +939,12 @@ describe("automation in the panel", () => {
|
||||
// Saturate: `output` is a make-up gain, but the curve's type and threshold
|
||||
// are rebuilt wholesale and cannot be scheduled.
|
||||
const { host } = automatable(idChain("saturate"));
|
||||
expect(rowFor(host, "Output")?.querySelector(".hf-fx-automate")).toBeTruthy();
|
||||
expect(rowFor(host, "Threshold")?.querySelector(".hf-fx-automate")).toBeNull();
|
||||
expect(
|
||||
rowFor(host, plainLabel("saturate", "output"))?.querySelector(".hf-fx-automate"),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
rowFor(host, plainLabel("saturate", "threshold"))?.querySelector(".hf-fx-automate"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("offers nothing for a worklet effect, which exposes no AudioParams", () => {
|
||||
@@ -773,7 +955,9 @@ describe("automation in the panel", () => {
|
||||
it("asks to automate a parameter by node id and key", () => {
|
||||
const onAutomateParam = vi.fn();
|
||||
const { host } = automatable(idChain("lowpass", "n7"), { onAutomateParam });
|
||||
const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement;
|
||||
const button = rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
|
||||
".hf-fx-automate",
|
||||
) as HTMLButtonElement;
|
||||
expect(button.hasAttribute("title")).toBe(false);
|
||||
act(() => button.click());
|
||||
expect(onAutomateParam).toHaveBeenCalledWith("n7", "frequency");
|
||||
@@ -783,12 +967,12 @@ describe("automation in the panel", () => {
|
||||
const { host } = automatable(idChain("lowpass"), {
|
||||
automatedTargets: new Set(["fx.n1.frequency"]),
|
||||
});
|
||||
const row = rowFor(host, "Cutoff")!;
|
||||
const row = rowFor(host, plainLabel("lowpass", "frequency"))!;
|
||||
expect(row.querySelector<HTMLInputElement>('input[type="range"]')?.disabled).toBe(true);
|
||||
expect(row.querySelector<HTMLInputElement>('input[type="number"]')?.disabled).toBe(true);
|
||||
expect(row.hasAttribute("data-automated")).toBe(true);
|
||||
// A sibling parameter on the same effect stays editable.
|
||||
const q = rowFor(host, "Q")!;
|
||||
const q = rowFor(host, plainLabel("lowpass", "q"))!;
|
||||
expect(q.querySelector<HTMLInputElement>('input[type="range"]')?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -798,7 +982,9 @@ describe("automation in the panel", () => {
|
||||
automatedTargets: new Set(["fx.n1.frequency"]),
|
||||
onRemoveParamAutomation,
|
||||
});
|
||||
const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement;
|
||||
const button = rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
|
||||
".hf-fx-automate",
|
||||
) as HTMLButtonElement;
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(button.getAttribute("aria-label")).toMatch(/remove/i);
|
||||
// The wording lives in the Tooltip component, which only renders its bubble
|
||||
@@ -814,7 +1000,9 @@ describe("automation in the panel", () => {
|
||||
const { host } = automatable(idChain("lowpass"), {
|
||||
automatedTargets: new Set(["fx.n1.frequency"]),
|
||||
});
|
||||
const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement;
|
||||
const button = rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
|
||||
".hf-fx-automate",
|
||||
) as HTMLButtonElement;
|
||||
// Tooltip positions itself from the trigger's box and gives up on a 0x0
|
||||
// one, which is every element in happy-dom.
|
||||
vi.spyOn(button, "getBoundingClientRect").mockReturnValue({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* is not an entry in the chain.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
defaultAudioFxParams,
|
||||
HF_AUDIO_FX,
|
||||
@@ -64,6 +64,17 @@ export interface FxSectionProps {
|
||||
onRemoveLevel?(): void;
|
||||
/** Whether a levelling stage is already on the track. */
|
||||
levelled?: boolean;
|
||||
/**
|
||||
* Hover-audition of the levelling script: measure this track and play the
|
||||
* result without persisting it, and put it back on `false`.
|
||||
*
|
||||
* Separate from `onChainPreview` because it is the one audition that cannot be
|
||||
* synthesised from the chain in hand — the numbers do not exist until the
|
||||
* audio has been decoded and measured.
|
||||
*/
|
||||
onAuditionLevel?(on: boolean): void;
|
||||
/** Whether that measurement is running, so the button can say so. */
|
||||
auditioningLevel?: boolean;
|
||||
/** Structural edits and gesture-end writes; this is the one that persists. */
|
||||
onChainChange(chain: HfAudioFxChain): void;
|
||||
/** Continuous updates while a control is being dragged. */
|
||||
@@ -104,6 +115,8 @@ export function FxSection({
|
||||
onLevel,
|
||||
onRemoveLevel,
|
||||
levelled,
|
||||
onAuditionLevel,
|
||||
auditioningLevel,
|
||||
}: FxSectionProps) {
|
||||
// Falls back to the persisting write when no preview handler is supplied, which
|
||||
// keeps the control working rather than going dead.
|
||||
@@ -139,6 +152,61 @@ export function FxSection({
|
||||
[chain, onChainPreview],
|
||||
);
|
||||
|
||||
/**
|
||||
* The chain as it is really stored, captured when an audition starts.
|
||||
*
|
||||
* Auditioning writes through the preview channel, which does not persist and
|
||||
* does not come back as a new `chain` prop — so reverting has to remember what
|
||||
* was there rather than read it back. Null means nothing is being auditioned,
|
||||
* which is also what makes a stray leave a no-op instead of a write.
|
||||
*/
|
||||
const auditionBase = useRef<HfAudioFxChain | null>(null);
|
||||
|
||||
/**
|
||||
* Play something without committing to it, and put it back on the way out.
|
||||
*
|
||||
* Hearing a preset before choosing it is the strongest affordance in this
|
||||
* panel — see `plans/audio-fx-ux/README.md` §Decided. It costs nothing new:
|
||||
* the preview channel a slider drag already uses rebuilds the running graph
|
||||
* without touching the document.
|
||||
*/
|
||||
const audition = useCallback(
|
||||
(make: ((base: HfAudioFxChain) => HfAudioFxChain) | null) => {
|
||||
if (!onChainPreview) return;
|
||||
if (make) {
|
||||
auditionBase.current ??= chain;
|
||||
onChainPreview(make(auditionBase.current));
|
||||
} else if (auditionBase.current) {
|
||||
onChainPreview(auditionBase.current);
|
||||
auditionBase.current = null;
|
||||
}
|
||||
},
|
||||
[chain, onChainPreview],
|
||||
);
|
||||
|
||||
/**
|
||||
* The preview handler as of the last render, held rather than closed over.
|
||||
*
|
||||
* The teardown below must run on teardown and at no other time, so its deps
|
||||
* have to be empty — and `onChainPreview` is an inline arrow in the group,
|
||||
* which re-renders on every playhead tick to move the automation readouts. A
|
||||
* dep on it made React tear down and re-run the effect on every one of those
|
||||
* ticks, so an audition reverted itself about 30 times a second while the
|
||||
* pointer was still on the button: the preset was heard for a frame during
|
||||
* playback, which is the exact case the whole affordance exists for.
|
||||
*/
|
||||
const previewRef = useRef(onChainPreview);
|
||||
previewRef.current = onChainPreview;
|
||||
|
||||
// Leaving by any route other than the pointer — the element deselected, the
|
||||
// panel closed — would otherwise leave the audition playing over a chain the
|
||||
// document does not have.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (auditionBase.current) previewRef.current?.(auditionBase.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const applyPreset = useCallback(
|
||||
(id: string) => {
|
||||
const preset = getAudioFxPreset(id);
|
||||
@@ -147,6 +215,10 @@ export function FxSection({
|
||||
// real thing to want, and replacing silently would throw work away — so
|
||||
// the destructive option is a separate gesture, not the default one.
|
||||
const next = applyAudioFxPreset(chain, preset);
|
||||
// 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
|
||||
// hears as the preset arriving and then leaving again.
|
||||
auditionBase.current = null;
|
||||
mutate(next.nodes);
|
||||
// Land on the first node the preset wrote, so the author can hear what
|
||||
// arrived and immediately see what it is made of.
|
||||
@@ -156,16 +228,25 @@ export function FxSection({
|
||||
[chain, mutate],
|
||||
);
|
||||
|
||||
/** One effect at its defaults, appended — what both adding and auditioning do. */
|
||||
const withEffect = useCallback(
|
||||
(base: HfAudioFxChain, type: string): HfAudioFxChain => ({
|
||||
...base,
|
||||
nodes: [
|
||||
...base.nodes,
|
||||
{ type, id: mintAudioFxNodeId(base), enabled: true, params: defaultAudioFxParams(type) },
|
||||
],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const addEffect = useCallback(
|
||||
(type: string) => {
|
||||
mutate([
|
||||
...chain.nodes,
|
||||
{ type, id: mintAudioFxNodeId(chain), enabled: true, params: defaultAudioFxParams(type) },
|
||||
]);
|
||||
auditionBase.current = null;
|
||||
mutate(withEffect(chain, type).nodes);
|
||||
setOpenNode(chain.nodes.length);
|
||||
setAdding(false);
|
||||
},
|
||||
[chain, mutate],
|
||||
[chain, mutate, withEffect],
|
||||
);
|
||||
|
||||
const updateNode = useCallback(
|
||||
@@ -208,6 +289,7 @@ export function FxSection({
|
||||
const [openEq, setOpenEq] = useState<string | null>(null);
|
||||
|
||||
const addEq = useCallback(() => {
|
||||
auditionBase.current = null;
|
||||
const { chain: next, eqId } = addAudioEq(chain);
|
||||
mutate(next.nodes);
|
||||
setOpenEq(eqId);
|
||||
@@ -321,7 +403,21 @@ export function FxSection({
|
||||
</div>
|
||||
|
||||
{adding ? (
|
||||
<div className="hf-fx-add-menu space-y-1.5 rounded-[4px] border border-panel-border-input p-1.5">
|
||||
<div
|
||||
className="hf-fx-add-menu space-y-1.5 rounded-[4px] border border-panel-border-input p-1.5"
|
||||
// On the shelf, not on each button: moving between two of them passes
|
||||
// through the gap, and a per-button leave would revert on the way.
|
||||
onMouseLeave={() => {
|
||||
audition(null);
|
||||
onAuditionLevel?.(false);
|
||||
}}
|
||||
// The keyboard's version of leaving. Tabbing between two entries fires
|
||||
// this and then the next one's focus, so it reverts and re-auditions.
|
||||
onBlur={() => {
|
||||
audition(null);
|
||||
onAuditionLevel?.(false);
|
||||
}}
|
||||
>
|
||||
<div className="hf-fx-add-group flex flex-wrap items-center gap-1">
|
||||
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-4">
|
||||
Tone
|
||||
@@ -337,8 +433,23 @@ export function FxSection({
|
||||
else onLevel();
|
||||
setAdding(false);
|
||||
}}
|
||||
// The one module here that cannot answer instantly: it has to
|
||||
// decode the track and measure it before there is anything to
|
||||
// hear. So it says it is working rather than doing nothing
|
||||
// visible, and whoever handles this must drop a result that
|
||||
// arrives after the pointer has gone.
|
||||
onMouseEnter={
|
||||
levelled
|
||||
? undefined
|
||||
: () => {
|
||||
audition(null);
|
||||
onAuditionLevel?.(true);
|
||||
}
|
||||
}
|
||||
onFocus={levelled ? undefined : () => onAuditionLevel?.(true)}
|
||||
>
|
||||
{levelled ? "Remove levelling" : "Even Out Levels"}
|
||||
{auditioningLevel ? <span className="hf-fx-add-working"> measuring…</span> : null}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
@@ -348,6 +459,14 @@ export function FxSection({
|
||||
// must not include it.
|
||||
className="hf-fx-add-composite rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
|
||||
title="Bass, middle and treble on one set of faders."
|
||||
// No audition of its own: a Tone module arrives with every band at
|
||||
// 0 dB, so there is nothing to hear until a fader moves, and a
|
||||
// hover that changes nothing teaches that hovering does nothing.
|
||||
// It still has to call the neighbours' auditions off.
|
||||
onMouseEnter={() => {
|
||||
audition(null);
|
||||
onAuditionLevel?.(false);
|
||||
}}
|
||||
onClick={addEq}
|
||||
>
|
||||
Tone (EQ)
|
||||
@@ -365,6 +484,17 @@ export function FxSection({
|
||||
className="hf-fx-add-item rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
|
||||
title={d.description}
|
||||
onClick={() => addEffect(d.id)}
|
||||
// Cancels the levelling audition as well as starting its own.
|
||||
// The shelf's leave handler only fires on the way OUT of the
|
||||
// menu, so sliding from Even Out Levels straight to here left a
|
||||
// measurement in flight — and it landed on top of this one, a
|
||||
// levelled version of the chain as it was, written through a
|
||||
// channel the document never sees.
|
||||
onMouseEnter={() => {
|
||||
onAuditionLevel?.(false);
|
||||
audition((base) => withEffect(base, d.id));
|
||||
}}
|
||||
onFocus={() => audition((base) => withEffect(base, d.id))}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
@@ -374,7 +504,19 @@ export function FxSection({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{picking ? <FxPresetMenu onPick={applyPreset} /> : null}
|
||||
{picking ? (
|
||||
<FxPresetMenu
|
||||
onPick={applyPreset}
|
||||
onAudition={
|
||||
onChainPreview
|
||||
? (id) => {
|
||||
const preset = id ? getAudioFxPreset(id) : null;
|
||||
audition(preset ? (base) => applyAudioFxPreset(base, preset) : null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{adding || picking ? null : (
|
||||
<div className="flex gap-1">
|
||||
|
||||
Reference in New Issue
Block a user