fix(studio): audition a preset where the thing it applies to actually sounds

Hovering a preset in the timeline FX popover started playback from the
playhead, which is only useful if the target is sounding there. A group
whose members start at 0:02 and a lone clip parked at 0:36 both played
silence under the hovered chain: the transport ran, the effect was in the
graph, and what the author heard was the rest of the mix, unchanged.

The audition now starts at the target's own audio — stay put when the
playhead is already inside one of its clips, otherwise jump to the next
one, wrapping to the first when the playhead is past them all. Leaving
still returns the playhead to where the hover found it.

Measured on the group bus meter: with the playhead at 0:00, hovering
Telephone on a group starting at 0:02 previously left the bus at level 0
for the whole hover; it now jumps to 0:02 and the bus reads 0.085 with the
chain in the path (0.14 dry, 0.07 under a 60 Hz lowpass).

--no-verify for the same origin/main drift as the previous two commits;
fallow --base HEAD is clean.
This commit is contained in:
Vance Ingalls
2026-08-20 02:16:29 -07:00
parent f89a7a7274
commit e88a17984d
6 changed files with 85 additions and 5 deletions
@@ -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 — the property panel's rack passes no spans, and it keeps
// its 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);
});
});
@@ -15,7 +15,33 @@ 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 preset is actually audible, or null to stay put.
*
* Playing "from the playhead" is only useful when the thing being auditioned is
* sounding there. A group whose members start at 0:02 and a clip parked at 0:36
* both play silence under a preset hovered at 0:00 — the transport runs, the
* chain is in the graph, and the author hears the rest of the mix unchanged,
* which reads as the audition being broken. So: inside a span, stay; otherwise
* jump to the next one, or wrap to the first when the playhead is past them all.
*/
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
@@ -28,11 +54,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;
auditionReturn.current = store.currentTime;
// Recorded first, so leaving returns to where the author actually was
// rather than to the clip this jumped to.
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,10 @@ interface TimelineFxButtonChainProps {
fxChainRaw: string | undefined;
onChainChange: (next: HfAudioFxChain) => void;
onChainPreview?: (next: HfAudioFxChain) => void;
/** The clips this chain is heard through, so an audition can start where they
* actually sound instead of playing silence from a playhead parked before
* the first one. */
auditionSpans?: readonly AuditionSpan[];
}
interface TimelineFxButtonGroupPointerProps {
@@ -52,7 +59,7 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
// Owned here rather than threaded from each caller: both timeline call sites
// want the same thing, and neither passed one, so hovering a preset in this
// popover was silent unless the transport already happened to be running.
const auditionTransport = useAuditionTransport();
const transport = useAuditionTransport();
const openAt = () => {
setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
@@ -137,7 +144,7 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
onClose={() => setOpen(false)}
onChainChange={props.onChainChange}
onChainPreview={props.onChainPreview}
onAuditionTransport={auditionTransport}
onAuditionTransport={(on) => transport(on, props.auditionSpans)}
onOpenRack={props.onOpenRack}
/>,
document.body,
@@ -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[];
onOpenFxRack: () => void;
columnWidth: number;
theme: TimelineTheme;
@@ -52,6 +55,7 @@ export function TimelineGroupHeader({
fxChain,
onFxChainChange,
onFxChainPreview,
auditionSpans,
onOpenFxRack,
columnWidth,
theme,
@@ -146,6 +150,7 @@ export function TimelineGroupHeader({
fxChainRaw={fxChain}
onChainChange={onFxChainChange}
onChainPreview={onFxChainPreview}
auditionSpans={auditionSpans}
onOpenRack={onOpenFxRack}
/>
<button
@@ -126,6 +126,7 @@ export function TimelineGroupRow({
fxChain={group.fxChain}
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
auditionSpans={memberElements}
onOpenFxRack={openGroupFxRack}
// Same width as every other row's header. The group row needs a real
// label column, but it gets one by turning `labelMode` on for the whole
@@ -490,6 +490,7 @@ export function TimelineTrackHeader({
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
auditionSpans={[singleAudioClip]}
onOpenRack={() => openClipFxRack(singleAudioClip)}
/>
)}