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} />