fix(studio): restore the audition's seek, in the place both racks share

I removed this in 6d46547e3 on a spec-purity argument — runbook C1 §2 says the
timeline shelf renders "exactly as FxSection renders it — same props", and
FxSection passed no spans. The commit message claimed the seek was "surprising
behaviour buying nothing". That was wrong, and I did not re-check the browser
before asserting it.

Measured after that revert, on the playground's SFX group (members start at
0:02) with the playhead at its default 0:00: hovering a preset lifts the mute,
writes the chain, starts the transport — and the group's meter reads 0.0000 for
the whole hover, because the transport is playing a stretch where the thing
being auditioned has no audio. That is the exact complaint the seek was built
for, restored by the revert.

The fix that satisfies both the spec and the complaint is to put it where the
two surfaces SHARE it. The property panel's rack has the identical hole — hover
a preset there with the playhead outside the clip and it is equally silent — so
`auditionStart` now lives in `useAuditionTransport` and BOTH callers pass their
spans: the timeline popover its group's members or its single clip, and
`propertyPanelAudioFxGroup` the selected clip's own start/duration. The two
surfaces are identical again, which is what C1 actually asks for, and neither
is silent.

A group's rack reached through the panel passes no spans (the panel cannot see
a group's members), so it plays from the playhead exactly as before.

Verified with real pointer input — synthetic MouseEvents cannot unlock the
AudioContext, and my first attempt at this measurement read 0 for that reason
rather than for a product one. Playhead 0:00, group muted: hover jumps to 0:02,
meter reads 0.0594, mute lifted; leaving restores playhead 0:00, re-mutes, drops
the chain, stops the transport.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean.
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:54 -07:00
parent 6f0cbb0392
commit 926496f6b6
7 changed files with 103 additions and 5 deletions
@@ -45,6 +45,7 @@ import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
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 { useFxLevelling } from "./useFxLevelling.js";
@@ -240,6 +241,19 @@ export function AudioFxGroup({
);
}, [element]);
/**
* 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]);
const { carvedAgainstBy, sourceOptions, setCarve } = useFxCarve(
element,
chain,
@@ -288,7 +302,7 @@ export function AudioFxGroup({
)
}
signalPath={signalPath}
onAuditionTransport={auditionTransport}
onAuditionTransport={(on) => auditionTransport(on, auditionSpans)}
onChainPreview={(next) =>
// Live writes skip the preview refresh entirely, so dragging a knob no
// longer reloads the composition and restarts playback on every pixel.
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { auditionStart } from "./useAuditionTransport";
const SPANS = [
{ start: 2, duration: 7 },
{ start: 18, duration: 7 },
];
describe("auditionStart", () => {
// Nothing to aim at — a caller with no spans keeps the old behaviour: play
// from wherever the author left the playhead.
it("stays put when there are no spans", () => {
expect(auditionStart(undefined, 0)).toBeNull();
expect(auditionStart([], 0)).toBeNull();
});
// Already inside the clip: moving the playhead here would be the UI taking a
// decision it was not asked for, and it would cost the author their place for
// no gain.
it("stays put when the playhead is already inside a span", () => {
expect(auditionStart(SPANS, 2)).toBeNull();
expect(auditionStart(SPANS, 8.9)).toBeNull();
});
// The bug this exists for: hovering a preset at 0:00 on a group whose members
// start at 0:02 played silence under the effect.
it("jumps to the next span when the playhead is before or between them", () => {
expect(auditionStart(SPANS, 0)).toBe(2);
expect(auditionStart(SPANS, 9)).toBe(18);
});
// Past everything, wrap to the first rather than play out the tail in silence.
it("wraps to the first span when the playhead is past them all", () => {
expect(auditionStart(SPANS, 40)).toBe(2);
});
});
@@ -14,7 +14,38 @@ import { useRef } from "react";
// timeline in, and the timeline's FX button imports this hook — a cycle.
import { usePlayerStore } from "../../player/store/playerStore";
export function useAuditionTransport(): (on: boolean) => void {
/** A clip the audition is meant to be heard through. */
export interface AuditionSpan {
start: number;
duration: number;
}
/**
* Where to start playing so the audition is actually audible, or null to stay.
*
* Playing "from the playhead" only works when the thing being auditioned is
* sounding there. A group whose members start at 0:02, hovered with the
* playhead at 0:00, plays the rest of the mix unchanged — the transport runs,
* the chain is in the graph, and the author hears nothing of the preset. So:
* inside a span, stay; otherwise jump to the next one, wrapping to the first
* when the playhead is past them all.
*
* Shared rather than the popover's own, which is where it went wrong the first
* time: the property panel's rack has the identical hole, and giving the two
* surfaces different audition behaviour is exactly what runbook C1 §2 forbids
* when it says the shelf renders "exactly as FxSection renders it".
*/
export function auditionStart(
spans: readonly AuditionSpan[] | undefined,
at: number,
): number | null {
if (!spans || spans.length === 0) return null;
if (spans.some((span) => at >= span.start && at < span.start + span.duration)) return null;
const starts = spans.map((span) => span.start).sort((a, b) => a - b);
return starts.find((start) => start > at) ?? starts[0] ?? null;
}
export function useAuditionTransport(): (on: boolean, spans?: readonly AuditionSpan[]) => void {
/**
* 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
@@ -27,11 +58,15 @@ export function useAuditionTransport(): (on: boolean) => void {
* that, and stopping their transport because they passed over a preset would
* be the UI taking a decision that was not offered to it.
*/
return (on: boolean): void => {
return (on: boolean, spans?: readonly AuditionSpan[]): void => {
const store = usePlayerStore.getState();
if (on) {
if (store.isPlaying || auditionReturn.current !== null) return;
// Recorded BEFORE the seek, so leaving returns the author to where they
// actually were rather than to the clip this jumped to.
auditionReturn.current = store.currentTime;
const from = auditionStart(spans, store.currentTime);
if (from !== null) store.requestSeek(from);
store.requestPlayback(true);
return;
}
@@ -18,7 +18,10 @@ import {
} from "@hyperframes/core/audio-fx";
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
import { useAuditionTransport } from "../../components/editor/useAuditionTransport.js";
import {
useAuditionTransport,
type AuditionSpan,
} from "../../components/editor/useAuditionTransport.js";
function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
if (!raw) return { version: 1, nodes: [] };
@@ -36,6 +39,9 @@ interface TimelineFxButtonChainProps {
fxChainRaw: string | undefined;
onChainChange: (next: HfAudioFxChain) => void;
onChainPreview?: (next: HfAudioFxChain) => void;
/** The clips this chain is heard through, so an audition starts where they
* actually sound rather than from a playhead parked before the first. */
auditionSpans?: readonly AuditionSpan[];
/** Whether this target is muted right now. */
isMuted?: boolean;
/** Set this target's mute on the running graph WITHOUT touching the document,
@@ -152,7 +158,7 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
if (on) borrowedMute.current = props.isMuted === true;
if (borrowedMute.current) props.onSetMutedLive?.(!on);
if (!on) borrowedMute.current = false;
transport(on);
transport(on, props.auditionSpans);
}}
onOpenRack={props.onOpenRack}
/>,
@@ -3,6 +3,7 @@ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import { TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
import { TimelineFxButton } from "./TimelineFxButton";
import type { AuditionSpan } from "../../components/editor/useAuditionTransport.js";
interface TimelineGroupHeaderProps {
label: string;
@@ -27,6 +28,8 @@ interface TimelineGroupHeaderProps {
fxChain?: string;
onFxChainChange: (next: HfAudioFxChain) => void;
onFxChainPreview?: (next: HfAudioFxChain) => void;
/** Member clips, so hovering a preset auditions where the group sounds. */
auditionSpans?: readonly AuditionSpan[];
/** Set the group mute on the running graph only, so an audition can lift it. */
onSetMutedLive?: (muted: boolean) => void;
onOpenFxRack: () => void;
@@ -54,6 +57,7 @@ export function TimelineGroupHeader({
fxChain,
onFxChainChange,
onFxChainPreview,
auditionSpans,
onSetMutedLive,
onOpenFxRack,
columnWidth,
@@ -167,6 +171,7 @@ export function TimelineGroupHeader({
fxChainRaw={fxChain}
onChainChange={onFxChainChange}
onChainPreview={onFxChainPreview}
auditionSpans={auditionSpans}
isMuted={hidden}
onSetMutedLive={onSetMutedLive}
onOpenRack={onOpenFxRack}
@@ -133,6 +133,7 @@ export function TimelineGroupRow({
fxChain={group.fxChain}
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
auditionSpans={memberElements}
onSetMutedLive={setGroupMutedLive}
onOpenFxRack={openGroupFxRack}
// Same width as every other row's header. The group row needs a real
@@ -493,6 +493,7 @@ export function TimelineTrackHeader({
// Muted, an audition is silent — so the hover lifts the mute on
// the running graph and puts it back on the way out, the same
// borrow-and-return it already does with the playhead.
auditionSpans={[singleAudioClip]}
isMuted={isTrackHidden}
onSetMutedLive={(muted) =>
onSetElementAttributeLive?.(singleAudioClip, "data-hidden", muted ? "" : null)