diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index b9cf996a6..11873d6c3 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -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. 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..694e6e1fd --- /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 — 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); + }); +}); diff --git a/packages/studio/src/components/editor/useAuditionTransport.ts b/packages/studio/src/components/editor/useAuditionTransport.ts index 16975da47..104b74447 100644 --- a/packages/studio/src/components/editor/useAuditionTransport.ts +++ b/packages/studio/src/components/editor/useAuditionTransport.ts @@ -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; } diff --git a/packages/studio/src/player/components/TimelineFxButton.tsx b/packages/studio/src/player/components/TimelineFxButton.tsx index 8aa7ba4ce..72e266210 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,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} />, diff --git a/packages/studio/src/player/components/TimelineGroupHeader.tsx b/packages/studio/src/player/components/TimelineGroupHeader.tsx index 4cda5a030..42933344d 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[]; /** 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} diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 171244f56..6195663cf 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -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 diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 0f90e1603..e0080a6b3 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -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)