From 77dd736fe60294aff9d78b66e5af12d28200e46f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 18 Aug 2026 13:55:10 -0700 Subject: [PATCH] fix(studio): audition a preset where the thing it applies to actually sounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../editor/useAuditionTransport.test.ts | 36 +++++++++++++++++++ .../components/editor/useAuditionTransport.ts | 34 ++++++++++++++++-- .../player/components/TimelineFxButton.tsx | 13 +++++-- .../player/components/TimelineGroupHeader.tsx | 5 +++ .../player/components/TimelineGroupRow.tsx | 1 + .../player/components/TimelineTrackHeader.tsx | 1 + 6 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 packages/studio/src/components/editor/useAuditionTransport.test.ts diff --git a/packages/studio/src/components/editor/useAuditionTransport.test.ts b/packages/studio/src/components/editor/useAuditionTransport.test.ts new file mode 100644 index 000000000..9e9b87fa8 --- /dev/null +++ b/packages/studio/src/components/editor/useAuditionTransport.test.ts @@ -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); + }); +}); diff --git a/packages/studio/src/components/editor/useAuditionTransport.ts b/packages/studio/src/components/editor/useAuditionTransport.ts index 0022f5af5..150768af7 100644 --- a/packages/studio/src/components/editor/useAuditionTransport.ts +++ b/packages/studio/src/components/editor/useAuditionTransport.ts @@ -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; } diff --git a/packages/studio/src/player/components/TimelineFxButton.tsx b/packages/studio/src/player/components/TimelineFxButton.tsx index 24e2318b8..52ff192ff 100644 --- a/packages/studio/src/player/components/TimelineFxButton.tsx +++ b/packages/studio/src/player/components/TimelineFxButton.tsx @@ -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, diff --git a/packages/studio/src/player/components/TimelineGroupHeader.tsx b/packages/studio/src/player/components/TimelineGroupHeader.tsx index 2c54a8e72..0695233d4 100644 --- a/packages/studio/src/player/components/TimelineGroupHeader.tsx +++ b/packages/studio/src/player/components/TimelineGroupHeader.tsx @@ -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} />