fix(audio): harden group routing and audition

This commit is contained in:
Vance Ingalls
2026-08-23 12:11:14 -07:00
parent 0126db7712
commit 44c5ac559b
11 changed files with 220 additions and 26 deletions
@@ -101,6 +101,37 @@ function mount(dataAttributes: Record<string, string>, alone = false, voices = 2
return { host, onSetAttributeQuiet, onSetAttributeLive };
}
function mountGroup(memberStart: number) {
const bus = document.createElement("hf-audio-group");
bus.id = "voiceover";
document.body.append(bus);
const member = document.createElement("audio");
member.id = "vo-1";
member.setAttribute("data-audio-group", "voiceover");
member.setAttribute("data-start", String(memberStart));
member.setAttribute("data-duration", "5");
document.body.append(member);
const host = document.createElement("div");
document.body.append(host);
const selection = {
dataAttributes: { "fx-chain": CHAIN },
id: "voiceover",
element: bus,
tagName: "hf-audio-group",
} as unknown as DomEditSelection;
act(() => {
createRoot(host).render(
<AudioFxGroup
element={selection}
onSetAttributeQuiet={vi.fn()}
onSetAttributeLive={vi.fn()}
/>,
);
});
return host;
}
const rowFor = (host: HTMLElement, label: string): HTMLElement | null => {
for (const row of Array.from(host.querySelectorAll<HTMLElement>(".hf-fx-row"))) {
if (row.querySelector(".hf-fx-label")?.textContent === label) return row;
@@ -565,6 +596,20 @@ describe("AudioFxGroup dynamic carve", () => {
expect(store().playbackRequest?.returnTo).toBe(42);
});
it("seeks a group audition to the next member span", () => {
act(() =>
usePlayerStore.setState({
isPlaying: false,
currentTime: 2,
requestedSeekTime: null,
}),
);
const host = mountGroup(10);
hoverPreset(host);
expect(store().requestedSeekTime).toBe(10);
leaveShelf(host);
});
it("leaves a transport the author started alone", () => {
// Stopping their playback because they passed over a preset would be the
// panel taking a decision nobody offered it.
@@ -48,9 +48,39 @@ import { useFxChainObserved } from "./useFxChainObserved.js";
import { useFxCarve } from "./useFxCarve.js";
import { audioFxSignalPath } from "./audioFxSignalPath.js";
import type { AuditionSpan } from "./useAuditionTransport.js";
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
import {
HF_AUDIO_GROUP_ATTR,
HF_AUDIO_GROUP_TAG,
resolveAudioGroups,
} from "@hyperframes/core/audio-groups";
import { useFxLevelling } from "./useFxLevelling.js";
function auditionSpan(startRaw: string | undefined, durationRaw: string | undefined) {
const start = Number.parseFloat(startRaw ?? "");
const duration = Number.parseFloat(durationRaw ?? "");
return Number.isFinite(start) && Number.isFinite(duration) && duration > 0
? { start, duration }
: null;
}
/** The selected clip, or every current member when the selected rack is a bus. */
function auditionSpansFor(element: DomEditSelection): AuditionSpan[] {
const own = auditionSpan(element.dataAttributes?.["start"], element.dataAttributes?.["duration"]);
if (own) return [own];
if (element.tagName?.toLowerCase() !== HF_AUDIO_GROUP_TAG || !element.id) return [];
const doc = element.element?.ownerDocument;
if (!doc) return [];
return [...doc.querySelectorAll(`audio[${HF_AUDIO_GROUP_ATTR}]`)]
.filter((member) => member.getAttribute(HF_AUDIO_GROUP_ATTR) === element.id)
.flatMap((member) => {
const span = auditionSpan(
member.getAttribute("data-start") ?? undefined,
member.getAttribute("data-duration") ?? undefined,
);
return span ? [span] : [];
});
}
/**
* Bridges the FX panel to the element/attribute world. Chain and carve are
* serialised onto the element the way colour grading carries its config, so
@@ -254,18 +284,10 @@ export function AudioFxGroup({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [element, storeElements]);
/**
* The clip this rack belongs to, so hovering a preset auditions where it
* sounds. A group's rack reaches this file too, but a group has no span of
* its own — its members carry the audio, and this panel does not see them,
* so it passes none and the transport plays from the playhead as before.
*/
const auditionSpans = useMemo((): AuditionSpan[] => {
const start = Number.parseFloat(element.dataAttributes?.["start"] ?? "");
const duration = Number.parseFloat(element.dataAttributes?.["duration"] ?? "");
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) return [];
return [{ start, duration }];
}, [element]);
// A bus has no span of its own, so resolve the live members. The
// `storeElements` subscription above rerenders this panel when membership
// changes without changing the selection.
const auditionSpans = auditionSpansFor(element);
const { carvedAgainstBy, sourceOptions, setCarve } = useFxCarve(
element,