Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 147 additions & 10 deletions packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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);
});

Expand All @@ -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(
Expand All @@ -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();
Expand All @@ -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);
});
});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
102 changes: 89 additions & 13 deletions packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -492,23 +492,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
Expand All @@ -529,6 +549,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));
Expand Down Expand Up @@ -722,6 +796,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}
/>
Expand Down
Loading
Loading