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 16:39:46 -07:00
parent a3ab011e22
commit 77dd736fe6
6 changed files with 85 additions and 5 deletions
@@ -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)}
/>
)}