diff --git a/packages/studio/src/player/components/AutomationValueInput.tsx b/packages/studio/src/player/components/AutomationValueInput.tsx new file mode 100644 index 0000000000..d37559c6a0 --- /dev/null +++ b/packages/studio/src/player/components/AutomationValueInput.tsx @@ -0,0 +1,49 @@ +/** + * Typing an exact value into a breakpoint. + * + * Dragging is how an envelope is shaped, but it cannot land on a number: -6.0 dB + * is not a pixel you can find. This is the lane's keyboard route to one value, + * kept in its own file so the lane component stays about the pointer. + */ + +export interface AutomationValueInputProps { + text: string; + /** Left edge in the lane's own coordinates. */ + leftPx: number; + label: string; + onChange(text: string): void; + /** Enter or blur: apply what was typed. */ + onCommit(): void; + /** Escape: leave the point where it was. */ + onCancel(): void; +} + +export function AutomationValueInput({ + text, + leftPx, + label, + onChange, + onCommit, + onCancel, +}: AutomationValueInputProps) { + return ( + e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") onCommit(); + if (e.key === "Escape") onCancel(); + }} + onChange={(e) => onChange(e.target.value)} + onBlur={onCommit} + value={text} + aria-label={`${label} value`} + autoFocus + /> + ); +} diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx index a905a30c49..e4d23529c0 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { createRoot } from "react-dom/client"; import { TimelineAutomationLane } from "./TimelineAutomationLane"; import { PAD_X } from "./automationLaneGeometry"; +import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; import { resolveAutomationRange, @@ -80,10 +81,24 @@ function renderNested(node: React.ReactElement): { function fire( el: Element, type: string, - init: { clientX?: number; clientY?: number; button?: number } = {}, + init: { + clientX?: number; + clientY?: number; + button?: number; + altKey?: boolean; + shiftKey?: boolean; + } = {}, ): void { const event = new Event(type, { bubbles: true, cancelable: true }); - Object.assign(event, { clientX: 0, clientY: 0, button: 0, pointerId: 1, ...init }); + Object.assign(event, { + clientX: 0, + clientY: 0, + button: 0, + pointerId: 1, + altKey: false, + shiftKey: false, + ...init, + }); act(() => { el.dispatchEvent(event); }); @@ -443,3 +458,153 @@ describe("TimelineAutomationLane", () => { expect(Math.max(...ys)).toBeGreaterThan(Math.min(...ys) + 30); }); }); + +describe("TimelineAutomationLane modifiers", () => { + /** The lane's own box, so pointer coordinates map to clip time and value. */ + const BOX = { left: 100, top: 0, width: 400 + PAD * 2, height: AUTOMATION_LANE_H }; + + /** x for a clip time, y for a 0..1 unit height, in client coordinates. The + * 6px inset and the height have to match the lane's own, or a point sits + * outside the grab radius and a press silently does nothing. */ + const at = (t: number, unit: number) => ({ + clientX: BOX.left + PAD + (t / 4) * 400, + clientY: BOX.top + 6 + (1 - unit) * (AUTOMATION_LANE_H - 12), + }); + + const mount = (automation: HfAutomation, over: Record = {}) => { + const base = laneProps({ automation, ...over }); + // Narrowed once here: laneProps types these as the prop signature, and every + // assertion below reads the calls the lane made. + const props = { + ...base, + onPreview: base.onPreview as ReturnType, + onCommit: base.onCommit as ReturnType, + }; + const { container } = render(); + const svg = container.querySelector("svg")!; + stubBox(svg, BOX); + return { container, svg, props }; + }; + + it("bends a segment when it is Alt-dragged, and leaves the points where they were", () => { + // `curve` was honoured everywhere it is read — drawn, sampled in preview, + // baked into the render — with no gesture that could set it. + const { svg, props } = mount(ramp); + fire(svg, "pointerdown", { ...at(2, 0.5), altKey: true }); + fire(svg, "pointermove", { ...at(2, 0.85), altKey: true }); + const previewed = props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined; + const points = previewed?.lanes[0]?.points ?? []; + expect(points[0]?.curve).toBeDefined(); + expect(points[0]?.curve).not.toBe(0); + // The breakpoints themselves are untouched: only the shape between them moved. + expect(points.map((p) => [p.t, p.v])).toEqual([ + [0, 1], + [4, 0], + ]); + }); + + it("straightens a segment on Alt-double-click", () => { + const curved: HfAutomation = { + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 1, curve: 0.6 }, + { t: 4, v: 0 }, + ], + }, + ], + }; + const { svg, props } = mount(curved); + fire(svg, "dblclick", { ...at(2, 0.5), altKey: true }); + const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined; + expect(committed?.lanes[0]?.points[0]?.curve).toBeUndefined(); + }); + + it("locks a Shift-drag to one axis", () => { + const { svg, props } = mount(ramp); + // Grab the point at t=0, v=1 (top left) and pull mostly sideways. + fire(svg, "pointerdown", at(0, 1)); + fire(svg, "pointermove", { ...at(2, 0.9), shiftKey: true }); + const points = + (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? []; + const moved = points.find((p) => p.t > 0.5); + expect(moved).toBeDefined(); + // Value held at exactly where the drag started, despite the vertical travel. + expect(moved?.v).toBe(1); + }); + + it("snaps a dragged point to the beat grid", () => { + // By eye, "on the beat" and "20 ms off the beat" look identical. + const { svg, props } = mount(ramp, { snapTimes: [2] }); + fire(svg, "pointerdown", at(0, 1)); + fire(svg, "pointermove", at(2.02, 1)); + const points = + (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? []; + expect(points.find((p) => p.t > 0.5)?.t).toBe(2); + }); + + it("ignores the grid while Alt is held", () => { + const { svg, props } = mount(ramp, { snapTimes: [2] }); + fire(svg, "pointerdown", at(0, 1)); + fire(svg, "pointermove", { ...at(2.02, 1), altKey: true }); + const points = + (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? []; + expect(points.find((p) => p.t > 0.5)?.t).toBeCloseTo(2.02, 2); + }); + + it("takes a second gesture in the same lane, not just the first", () => { + // The panel had exactly this bug: one edit worked and every later one was + // swallowed, because the live write skips the resync the next edit reads. + const { svg, props } = mount(ramp); + fire(svg, "pointerdown", at(0, 1)); + fire(svg, "pointermove", at(1, 0.8)); + fire(svg, "pointerup", at(1, 0.8)); + fire(svg, "pointerdown", at(4, 0)); + fire(svg, "pointermove", at(3, 0.4)); + const points = + (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? []; + // Both ends moved: the first gesture's point is off t=0, the second's off t=4. + expect(points.map((p) => Number(p.t.toFixed(2)))).toEqual([1, 3]); + }); + + it("types an exact value into a point", () => { + // -6.0 dB is not a pixel you can find by dragging. + const { container, svg, props } = mount(ramp); + fire(svg, "dblclick", at(0, 1)); + const input = container.querySelector(".hf-automation-value"); + expect(input).not.toBeNull(); + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call( + input, + "0.25", + ); + input?.dispatchEvent(new Event("input", { bubbles: true })); + }); + fire(input!, "keydown", {}); + const key = new Event("keydown", { bubbles: true, cancelable: true }); + Object.assign(key, { key: "Enter" }); + act(() => { + input?.dispatchEvent(key); + }); + const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined; + expect(committed?.lanes[0]?.points[0]?.v).toBe(0.25); + }); + + it("clamps a typed value to the parameter's range", () => { + const { container, svg, props } = mount(ramp); + fire(svg, "dblclick", at(0, 1)); + const input = container.querySelector(".hf-automation-value"); + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, "99"); + input?.dispatchEvent(new Event("input", { bubbles: true })); + }); + // React listens for focusout, not blur — blur does not bubble. + act(() => { + input?.dispatchEvent(new Event("focusout", { bubbles: true })); + }); + const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined; + expect(committed?.lanes[0]?.points[0]?.v).toBe(VOLUME_RANGE.max); + }); +}); diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index 37ffb4dc23..fb5fca3130 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -1,7 +1,12 @@ /** * Breakpoint automation over an audio clip, edited the way a DAW edits it: * double-click the line to add a point, drag one to shape it, right-click a - * point to remove it. + * point to remove it, Alt-drag the line between two points to bend it, and + * double-click a point to type an exact value. + * + * Modifiers follow Ableton's, because that is the muscle memory an automation + * lane inherits: Shift locks a drag to one axis and fines the value down, Alt + * over a segment curves it, and Alt during a point drag ignores the grid. * * The lane knows nothing about any particular effect. Which parameters it can * offer, their ranges, units and whether they read logarithmically all come @@ -9,14 +14,7 @@ * same principle the property panel's controls follow. */ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type PointerEvent as ReactPointerEvent, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { resolveAutomationRange, sampleAutomationLane, @@ -26,21 +24,27 @@ import { type HfAutomationPoint, } from "@hyperframes/core/audio-automation"; import { - DRAW_SAMPLES, - formatValue, + envelopePath, fromUnit, GRAB_PX, laneFor, PAD_X, - POINT_MERGE_SEC, toUnit, withLane, } from "./automationLaneGeometry"; +import { useAutomationLaneGestures } from "./useAutomationLaneGestures"; +import { AutomationValueInput } from "./AutomationValueInput"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { getTimelineLaneTop } from "./timelineLayout"; import type { TimelineElement } from "../store/playerStore"; import type { UseAutomationLanesResult } from "./useAutomationLanes"; +/** Pointer shape: a read-only lane can only be selected, a live one edited. */ +function laneCursor(readOnly: boolean | undefined, dragging: boolean): string { + if (readOnly) return "pointer"; + return dragging ? "grabbing" : "crosshair"; +} + export interface TimelineAutomationLaneProps { /** Clip-local duration the lane spans. */ duration: number; @@ -59,6 +63,11 @@ export interface TimelineAutomationLaneProps { onPreview(automation: HfAutomation): void; /** Gesture-end write; this is the one that persists and lands in undo. */ onCommit(automation: HfAutomation): void; + /** + * Clip-local times a dragged point snaps to — the beat grid, shifted into this + * clip's frame. Its own neighbouring points are added on top. + */ + snapTimes?: readonly number[]; /** Editing writes to the selected element, so an unselected clip is read-only. */ readOnly?: boolean; /** Called when a read-only lane is pressed: selects the clip so it goes live. */ @@ -77,14 +86,13 @@ export function TimelineAutomationLane({ playheadSec, onPreview, onCommit, + snapTimes, readOnly, onSelect, }: TimelineAutomationLaneProps) { const stored = laneFor(automation, target); const svgRef = useRef(null); - const [dragIndex, setDragIndex] = useState(null); - const [hint, setHint] = useState(null); /** * Points as the user is shaping them, before the edit has come back around. @@ -144,37 +152,10 @@ export function TimelineAutomationLane({ [duration, inner, range, widthPx], ); - /** - * The line the lane draws. A flat line at the current value stands in for a - * lane with no points, so the first click has something to land on. - */ - const path = useMemo(() => { - if (lane.points.length === 0) { - const y = yOf(range.default ?? (range.min + range.max) / 2); - return `M ${PAD_X} ${y} L ${PAD_X + widthPx} ${y}`; - } - const pts: string[] = []; - const first = lane.points[0]!; - pts.push(`M ${PAD_X} ${yOf(first.v)}`); - pts.push(`L ${xOf(first.t)} ${yOf(first.v)}`); - for (let i = 0; i + 1 < lane.points.length; i += 1) { - const a = lane.points[i]!; - const b = lane.points[i + 1]!; - if (!a.curve && range.scale === "linear") { - pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`); - continue; - } - // Curved or log-read: sample it, or the drawing would lie about the - // envelope the audio thread is going to play. - for (let k = 1; k <= DRAW_SAMPLES; k += 1) { - const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES; - pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`); - } - } - const last = lane.points[lane.points.length - 1]!; - pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`); - return pts.join(" "); - }, [lane, range, widthPx, xOf, yOf]); + const path = useMemo( + () => envelopePath({ lane, range, widthPx, xOf, yOf }), + [lane, range, widthPx, xOf, yOf], + ); const commitPoints = useCallback( (points: HfAutomationLane["points"], persist: boolean): void => { @@ -187,90 +168,23 @@ export function TimelineAutomationLane({ [automation, target, onCommit, onPreview], ); - /** Index of a point under the pointer, or null. */ - const hitIndex = useCallback( - (clientX: number, clientY: number): number | null => { - const box = svgRef.current?.getBoundingClientRect(); - if (!box) return null; - const px = clientX - box.left; - const py = clientY - box.top; - for (let i = 0; i < lane.points.length; i += 1) { - const p = lane.points[i]!; - if (Math.hypot(xOf(p.t) - px, yOf(p.v) - py) <= GRAB_PX * 1.6) return i; - } - return null; - }, - [lane, xOf, yOf], - ); - - const onPointerDown = useCallback( - (e: ReactPointerEvent): void => { - if (e.button !== 0) return; - // The lane owns this region either way. Letting a press through starts the - // timeline's own gesture (scrub / marquee / clip drag), which then eats the - // rest of the sequence — including the second half of a double-click. - e.stopPropagation(); - if (readOnly) { - // The lane sits below the clip bar, so the timeline's selection handler - // never sees this press; selecting here is the only way in. - onSelect?.(); - return; - } - const index = hitIndex(e.clientX, e.clientY); - if (index === null) return; - e.preventDefault(); - (e.target as Element).setPointerCapture?.(e.pointerId); - setDragIndex(index); - }, - [hitIndex, readOnly, onSelect], - ); - - const onPointerMove = useCallback( - (e: ReactPointerEvent): void => { - if (dragIndex === null) return; - e.stopPropagation(); - const { t, v } = pointAt(e.clientX, e.clientY); - const next = lane.points.map((p, i) => (i === dragIndex ? { ...p, t, v } : p)); - // Re-sort so dragging a point past a neighbour behaves, and keep the - // dragged one addressable by following where it landed. - const moved = next[dragIndex]!; - next.sort((a, b) => a.t - b.t); - setDragIndex(next.indexOf(moved)); - setHint(`${formatValue(range, v)} @ ${t.toFixed(2)}s`); - commitPoints(next, false); - }, - [dragIndex, lane, pointAt, range, commitPoints], - ); - - const endDrag = useCallback( - (e: ReactPointerEvent): void => { - if (dragIndex === null) return; - e.stopPropagation(); - setDragIndex(null); - setHint(null); - commitPoints(lane.points, true); - }, - [dragIndex, lane, commitPoints], - ); - - const onDoubleClick = useCallback( - (e: ReactPointerEvent): void => { - if (readOnly) return; - e.stopPropagation(); - e.preventDefault(); - const { t, v } = pointAt(e.clientX, e.clientY); - const kept = lane.points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC); - // A lane's first point alone would be a constant, which is not what - // clicking an empty lane means: seed the far end at the same value so the - // envelope has somewhere to go. - const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : []; - commitPoints( - [...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t), - true, - ); - }, - [lane, pointAt, commitPoints, readOnly], + const getBox = useCallback( + (): DOMRect | null => svgRef.current?.getBoundingClientRect() ?? null, + [], ); + const gestures = useAutomationLaneGestures({ + getBox, + lane, + range, + pointAt, + xOf, + yOf, + commitPoints, + snapTimes, + readOnly, + onSelect, + }); + const { dragIndex, curveIndex, hint, editing } = gestures; const removeAt = useCallback( (index: number): void => { @@ -312,24 +226,24 @@ export function TimelineAutomationLane({ top: 0, width: widthPx + PAD_X * 2, height: h, - cursor: readOnly ? "pointer" : dragIndex !== null ? "grabbing" : "crosshair", + cursor: laneCursor(readOnly, dragIndex !== null || curveIndex !== null), opacity: readOnly ? 0.55 : 1, touchAction: "none", }} width={widthPx + PAD_X * 2} height={h} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={endDrag} - onPointerCancel={endDrag} - onDoubleClick={onDoubleClick} + onPointerDown={gestures.onPointerDown} + onPointerMove={gestures.onPointerMove} + onPointerUp={gestures.endDrag} + onPointerCancel={gestures.endDrag} + onDoubleClick={gestures.onDoubleClick} role="group" aria-label={`${range.label} automation`} > {readOnly ? "Click to select this clip, then double-click to add a point" - : "Double-click to add a point, drag to shape, right-click a point to remove"} + : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click to remove. Alt-drag the line to curve it. Shift locks an axis; Alt ignores the grid."} {/* Mid rail, so a value reads against something. */} @@ -379,6 +293,17 @@ export function TimelineAutomationLane({ ) : null} + {editing ? ( + + ) : null} + {hint ? (
+ (beatTimes ?? []) + .filter((t) => t >= element.start && t <= element.start + element.duration) + .map((t) => t - element.start), + [beatTimes, element.start, element.duration], + ); const bound = lanes.bind(element, isSelected); if (bound.lanes.length === 0) return null; const inClip = currentTime >= element.start && currentTime <= element.start + element.duration; @@ -443,6 +380,7 @@ export function TimelineAutomationLaneSlot({ onPreview={bound.onPreview} onCommit={bound.onCommit} onSelect={bound.onSelect} + snapTimes={snapTimes} readOnly={bound.readOnly} /> ); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index d354d08a12..c04ef3119e 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -6,7 +6,7 @@ import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; import { useAutomationLanes } from "./useAutomationLanes"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; -import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; +import { resolveTrackKeyframeClip, trackShowsBeatStrip } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; import { clipTimingStart } from "../../hooks/gsapShared"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; @@ -162,11 +162,10 @@ export function TimelineLanes({ // The beat-dot strip occupies the top of this track's lane (active track, // or the music track when nothing is selected). When shown, keyframe // diamonds shrink + drop to the bottom half so they don't collide with it. - const beatStripOnTrack = - (beatAnalysis?.beatTimes?.length ?? 0) >= 2 && - (selectedElementId - ? els.some((e) => (e.key ?? e.id) === selectedElementId) - : els.some(isMusicTrack)); + const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, { + selectedElementId, + isMusicTrack, + }); const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true); const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement); // The one keyframed element this track shows lanes for (selected, else @@ -555,6 +554,7 @@ export function TimelineLanes({ laneCount={laneCounts.get(elementKey) ?? 0} accentColor={clipStyle.accent} currentTime={currentTime} + beatTimes={beatAnalysis?.beatTimes} /> ) : null } diff --git a/packages/studio/src/player/components/automationLaneGeometry.test.ts b/packages/studio/src/player/components/automationLaneGeometry.test.ts index 8bf266bb27..2b087bd9b0 100644 --- a/packages/studio/src/player/components/automationLaneGeometry.test.ts +++ b/packages/studio/src/player/components/automationLaneGeometry.test.ts @@ -1,6 +1,14 @@ // @vitest-environment happy-dom import { describe, expect, it } from "vitest"; -import { automationTargets, fromUnit, toUnit } from "./automationLaneGeometry"; +import { + applyShiftConstraint, + automationTargets, + curveForDrag, + fromUnit, + snapLaneTime, + toUnit, +} from "./automationLaneGeometry"; +import { applyCurve, sampleAutomationLane } from "@hyperframes/core/audio-automation"; import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation"; import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; @@ -66,3 +74,98 @@ describe("value ↔ lane position", () => { expect(toUnit({ ...VOLUME_RANGE, min: 1, max: 1 }, 1)).toBe(0); }); }); + +describe("curveForDrag", () => { + const a = { t: 0, v: 1 }; + const b = { t: 4, v: 0 }; + + it("puts the curved segment through the point that was dragged", () => { + // The whole contract: whatever curve comes back, sampling the segment at the + // dragged time has to give the dragged value back — otherwise the line runs + // away from the pointer. + for (const [t, v] of [ + [1, 0.9], + [2, 0.8], + [3, 0.15], + ] as const) { + const curve = curveForDrag({ range: VOLUME_RANGE, a, b, t, v }); + expect(curve).not.toBeNull(); + const lane = { target: "volume", points: [{ ...a, curve: curve ?? 0 }, b] }; + expect(sampleAutomationLane(lane, t, "linear")).toBeCloseTo(v, 2); + } + }); + + it("stays inside the range the model will accept", () => { + // Anything outside ±1 is clamped on parse, so a drag past the limit has to + // saturate rather than round-trip to something else. + const curve = curveForDrag({ range: VOLUME_RANGE, a, b, t: 0.05, v: 0.02 }); + expect(curve).not.toBeNull(); + expect(Math.abs(curve ?? 0)).toBeLessThanOrEqual(1); + expect(applyCurve(0.5, curve ?? 0)).toBeGreaterThan(0); + }); + + it("declines a segment with no room to bend", () => { + // Flat: every curve draws the same line, so there is nothing to solve. + expect(curveForDrag({ range: VOLUME_RANGE, a, b: { t: 4, v: 1 }, t: 2, v: 0.5 })).toBeNull(); + // At the very ends the exponent divides by zero. + expect(curveForDrag({ range: VOLUME_RANGE, a, b, t: 0, v: 1 })).toBeNull(); + expect(curveForDrag({ range: VOLUME_RANGE, a, b, t: 4, v: 0 })).toBeNull(); + }); +}); + +describe("applyShiftConstraint", () => { + const origin = { t: 1, v: 0.5 }; + const xOf = (t: number) => t * 100; + const yOf = (v: number) => (1 - v) * 40; + + it("holds the value when the gesture is mostly sideways", () => { + const out = applyShiftConstraint({ + range: VOLUME_RANGE, + origin, + raw: { t: 3, v: 0.55 }, + xOf, + yOf, + }); + expect(out).toEqual({ t: 3, v: 0.5 }); + }); + + it("holds the time and fines the value when it is mostly vertical", () => { + const out = applyShiftConstraint({ + range: VOLUME_RANGE, + origin, + raw: { t: 1.05, v: 0.9 }, + xOf, + yOf, + }); + expect(out.t).toBe(1); + // A quarter of the travel: 0.5 + (0.9 - 0.5) / 4. + expect(out.v).toBeCloseTo(0.6, 5); + }); + + it("decides which axis won in pixels, not in units", () => { + // 0.2 s against 0.2 of a fader are not comparable numbers; at this zoom the + // horizontal move is 20px and the vertical one is 8px. + const out = applyShiftConstraint({ + range: VOLUME_RANGE, + origin, + raw: { t: 1.2, v: 0.7 }, + xOf, + yOf, + }); + expect(out.v).toBe(0.5); + }); +}); + +describe("snapLaneTime", () => { + it("takes the nearest target inside the threshold", () => { + expect(snapLaneTime(2.02, [1, 2, 3], 0.04)).toBe(2); + }); + + it("leaves a time alone when nothing is close enough", () => { + expect(snapLaneTime(2.5, [1, 2, 3], 0.04)).toBe(2.5); + }); + + it("has nothing to snap to on an empty grid", () => { + expect(snapLaneTime(2.5, [], 0.04)).toBe(2.5); + }); +}); diff --git a/packages/studio/src/player/components/automationLaneGeometry.ts b/packages/studio/src/player/components/automationLaneGeometry.ts index dae7151cd3..a0da602c82 100644 --- a/packages/studio/src/player/components/automationLaneGeometry.ts +++ b/packages/studio/src/player/components/automationLaneGeometry.ts @@ -10,6 +10,7 @@ import { fxAutomationTarget, resolveAutomationRange, + sampleAutomationLane, VOLUME_RANGE, VOLUME_TARGET, type AutomationRange, @@ -96,6 +97,124 @@ export function formatValue(range: AutomationRange, value: number): string { return range.unit ? `${shown} ${range.unit}` : shown; } +/** + * The `curve` that bends a segment through a dragged point. + * + * `applyCurve` raises normalised progress to `2^(2*curve)`, so a point the + * pointer holds at progress `x` and unit height `f` fixes the exponent: + * `x^e = f`, hence `e = ln f / ln x` and `curve = log2(e) / 2`. Solving rather + * than accumulating a delta means the segment passes through the pointer + * instead of drifting away from it over a long drag. + * + * Null when the segment cannot express the shape: a flat segment has no room to + * bend, and progress or height at the very ends divides by zero. + */ +export function curveForDrag(input: { + range: AutomationRange; + a: { t: number; v: number }; + b: { t: number; v: number }; + t: number; + v: number; +}): number | null { + const { range, a, b, t, v } = input; + const span = b.t - a.t; + if (span <= 0) return null; + const x = (t - a.t) / span; + if (x <= 0.001 || x >= 0.999) return null; + const ua = toUnit(range, a.v); + const ub = toUnit(range, b.v); + if (Math.abs(ub - ua) < 0.001) return null; + const f = (toUnit(range, v) - ua) / (ub - ua); + if (f <= 0.001 || f >= 0.999) return null; + return Math.max(-1, Math.min(1, Math.log2(Math.log(f) / Math.log(x)) / 2)); +} + +/** + * A point drag with Shift held: one axis at a time, whichever the gesture + * committed to, and a quarter of the vertical travel for a value that has to + * land on a number. + * + * Which axis "won" is decided in pixels, not in seconds and dB — those are + * different units and comparing them would make the lock depend on the zoom. + */ +export function applyShiftConstraint(input: { + range: AutomationRange; + origin: { t: number; v: number }; + raw: { t: number; v: number }; + /** Same projections the lane draws with, so the comparison is on screen. */ + xOf(t: number): number; + yOf(v: number): number; +}): { t: number; v: number } { + const { range, origin, raw, xOf, yOf } = input; + if (Math.abs(xOf(raw.t) - xOf(origin.t)) > Math.abs(yOf(raw.v) - yOf(origin.v))) { + return { t: raw.t, v: origin.v }; + } + const from = toUnit(range, origin.v); + return { t: origin.t, v: fromUnit(range, from + (toUnit(range, raw.v) - from) * 0.25) }; +} + +/** + * Nearest snap target within the threshold, else the time unchanged. + * + * A breakpoint is placed by eye, and by eye "on the beat" and "three + * milliseconds off the beat" look identical — so the lane snaps to the beat grid + * and to its own neighbouring points, the two things an envelope is usually + * aligned against. + */ +export function snapLaneTime(t: number, targets: readonly number[], thresholdSec: number): number { + let best = t; + let bestDist = thresholdSec; + for (const target of targets) { + const d = Math.abs(target - t); + if (d < bestDist) { + bestDist = d; + best = target; + } + } + return best; +} + +/** + * The svg path for one lane's envelope. + * + * A flat line at the parameter's own default stands in for a lane with no + * points, so the first double-click has something to land on. Straight segments + * are drawn as one line each; a curved or log-read segment is sampled, because + * drawing it straight would lie about the envelope the audio thread is going to + * play. + */ +export function envelopePath(input: { + lane: HfAutomationLane; + range: AutomationRange; + widthPx: number; + xOf(t: number): number; + yOf(v: number): number; +}): string { + const { lane, range, widthPx, xOf, yOf } = input; + const first = lane.points[0]; + const last = lane.points[lane.points.length - 1]; + if (!first || !last) { + const y = yOf(range.default ?? (range.min + range.max) / 2); + return `M ${PAD_X} ${y} L ${PAD_X + widthPx} ${y}`; + } + const pts = [`M ${PAD_X} ${yOf(first.v)}`, `L ${xOf(first.t)} ${yOf(first.v)}`]; + for (let i = 0; i + 1 < lane.points.length; i += 1) { + const a = lane.points[i]; + const b = lane.points[i + 1]; + if (!a || !b) continue; + if (!a.curve && range.scale === "linear") { + pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`); + continue; + } + for (let k = 1; k <= DRAW_SAMPLES; k += 1) { + const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES; + pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`); + } + } + pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`); + return pts.join(" "); +} + export function laneFor(automation: HfAutomation, target: string): HfAutomationLane { return automation.lanes.find((l) => l.target === target) ?? { target, points: [] }; } diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts new file mode 100644 index 0000000000..24c12db9d9 --- /dev/null +++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts @@ -0,0 +1,310 @@ +/** + * The pointer gestures over an automation lane. + * + * Its own hook because the lane component sits at the studio's file ceiling and + * because these are the parts worth testing on their own: which of a press, + * a drag and a modifier resolves to moving a point, bending a segment, or + * nothing at all. + * + * Modifiers follow Ableton's, since that is the muscle memory an automation lane + * inherits: Shift locks a drag to one axis and fines the value down, Alt over a + * segment curves it, and Alt during a point drag ignores the grid. + */ + +import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation"; +import { + applyShiftConstraint, + curveForDrag, + formatValue, + GRAB_PX, + POINT_MERGE_SEC, + snapLaneTime, +} from "./automationLaneGeometry"; + +/** Snap radius in clip seconds. Tight on purpose: a lane is often a few seconds + * wide, where a generous radius makes a point unplaceable between two beats. */ +const SNAP_SEC = 0.04; + +/** A point's position, or the origin when the index no longer resolves. */ +function originOf(point: HfAutomationLane["points"][number] | undefined): { t: number; v: number } { + return point ? { t: point.t, v: point.v } : { t: 0, v: 0 }; +} + +/** + * Keep the rest of the gesture even if the pointer leaves the lane. Without it a + * drag that strays outside the svg stops sending moves and the point sticks. + */ +function capturePointer(e: ReactPointerEvent): void { + const target = e.target; + if (target instanceof Element) target.setPointerCapture?.(e.pointerId); +} + +export interface UseAutomationLaneGesturesInput { + /** The lane's box on screen. A getter, not the ref: the hook only ever needs + * the rectangle, and a ref read inside a callback is a lint the rule is right + * about — the value is not a dependency it can track. */ + getBox(): DOMRect | null; + lane: HfAutomationLane; + range: AutomationRange; + /** Pointer position as a clip-local time and a parameter value. */ + pointAt(clientX: number, clientY: number): { t: number; v: number }; + xOf(t: number): number; + yOf(v: number): number; + commitPoints(points: HfAutomationLane["points"], persist: boolean): void; + /** Clip-local times a dragged point snaps to, on top of its own neighbours. */ + snapTimes?: readonly number[] | undefined; + readOnly?: boolean | undefined; + onSelect?: (() => void) | undefined; +} + +export interface UseAutomationLaneGesturesResult { + /** Point being dragged, for the cursor and the grab circle's size. */ + dragIndex: number | null; + /** Segment being bent, identified by the point that owns its curve. */ + curveIndex: number | null; + /** Value readout to show while a gesture is live. */ + hint: string | null; + hitIndex(clientX: number, clientY: number): number | null; + segmentIndex(clientX: number, clientY: number): number | null; + onPointerDown(e: ReactPointerEvent): void; + onPointerMove(e: ReactPointerEvent): void; + endDrag(e: ReactPointerEvent): void; + /** Adds a point, opens the value field on one, or straightens a segment. */ + onDoubleClick(e: ReactPointerEvent): void; + /** The point whose value is being typed, and the text so far. */ + editing: { index: number; text: string } | null; + setEditingText(text: string): void; + commitEdit(): void; + cancelEdit(): void; +} + +export function useAutomationLaneGestures({ + getBox, + lane, + range, + pointAt, + xOf, + yOf, + commitPoints, + snapTimes, + readOnly, + onSelect, +}: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult { + const [dragIndex, setDragIndex] = useState(null); + const [curveIndex, setCurveIndex] = useState(null); + const [hint, setHint] = useState(null); + /** Where a point drag began, so Shift can lock an axis and fine the value. */ + const dragOrigin = useRef<{ t: number; v: number } | null>(null); + /** Point whose value is being typed, and the text so far. */ + const [editing, setEditing] = useState<{ index: number; text: string } | null>(null); + + /** Index of a point under the pointer, or null. */ + const hitIndex = useCallback( + (clientX: number, clientY: number): number | null => { + const box = getBox(); + if (!box) return null; + const px = clientX - box.left; + const py = clientY - box.top; + for (let i = 0; i < lane.points.length; i += 1) { + const p = lane.points[i]; + if (p && Math.hypot(xOf(p.t) - px, yOf(p.v) - py) <= GRAB_PX * 1.6) return i; + } + return null; + }, + [getBox, lane, xOf, yOf], + ); + + /** Index of the point owning the segment under the pointer, or null. */ + const segmentIndex = useCallback( + (clientX: number, clientY: number): number | null => { + const { t } = pointAt(clientX, clientY); + for (let i = 0; i + 1 < lane.points.length; i += 1) { + const a = lane.points[i]; + const b = lane.points[i + 1]; + if (a && b && t > a.t && t < b.t) return i; + } + return null; + }, + [lane, pointAt], + ); + + /** What a press starts: moving a point, or — with Alt on the line — bending it. */ + const gestureAt = useCallback( + (e: ReactPointerEvent): { curve: boolean; index: number } | null => { + const index = hitIndex(e.clientX, e.clientY); + if (index !== null) return { curve: false, index }; + if (!e.altKey) return null; + const segment = segmentIndex(e.clientX, e.clientY); + return segment === null ? null : { curve: true, index: segment }; + }, + [hitIndex, segmentIndex], + ); + + const onPointerDown = useCallback( + (e: ReactPointerEvent): void => { + if (e.button !== 0) return; + // The lane owns this region either way. Letting a press through starts the + // timeline's own gesture (scrub / marquee / clip drag), which then eats the + // rest of the sequence — including the second half of a double-click. + e.stopPropagation(); + if (readOnly) { + // The lane sits below the clip bar, so the timeline's selection handler + // never sees this press; selecting here is the only way in. + onSelect?.(); + return; + } + const gesture = gestureAt(e); + if (!gesture) return; + e.preventDefault(); + capturePointer(e); + if (gesture.curve) { + setCurveIndex(gesture.index); + return; + } + dragOrigin.current = originOf(lane.points[gesture.index]); + setDragIndex(gesture.index); + }, + [gestureAt, lane, readOnly, onSelect], + ); + + /** Bend the segment under the pointer, which is what Alt-dragging the line does. */ + const bendSegment = useCallback( + (clientX: number, clientY: number): void => { + if (curveIndex === null) return; + const a = lane.points[curveIndex]; + const b = lane.points[curveIndex + 1]; + if (!a || !b) return; + const { t, v } = pointAt(clientX, clientY); + const curve = curveForDrag({ range, a, b, t, v }); + if (curve === null) return; + setHint(`curve ${curve.toFixed(2)}`); + commitPoints( + lane.points.map((p, i) => (i === curveIndex ? { ...p, curve } : p)), + false, + ); + }, + [curveIndex, lane, pointAt, range, commitPoints], + ); + + /** Move the point being dragged, honouring the modifiers held with it. */ + const movePoint = useCallback( + (e: ReactPointerEvent): void => { + if (dragIndex === null) return; + const raw = pointAt(e.clientX, e.clientY); + const origin = dragOrigin.current; + let { t, v } = + e.shiftKey && origin ? applyShiftConstraint({ range, origin, raw, xOf, yOf }) : raw; + // Shift is a deliberate free-hand move as much as Alt is, so neither snaps. + if (!e.altKey && !e.shiftKey) { + const neighbours = lane.points.filter((_, i) => i !== dragIndex).map((p) => p.t); + t = snapLaneTime(t, [...(snapTimes ?? []), ...neighbours], SNAP_SEC); + } + const next = lane.points.map((p, i) => (i === dragIndex ? { ...p, t, v } : p)); + // Re-sort so dragging a point past a neighbour behaves, and keep the + // dragged one addressable by following where it landed. + const moved = next[dragIndex]; + next.sort((a, b) => a.t - b.t); + if (moved) setDragIndex(next.indexOf(moved)); + setHint(`${formatValue(range, v)} @ ${t.toFixed(2)}s`); + commitPoints(next, false); + }, + [dragIndex, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf], + ); + + const onPointerMove = useCallback( + (e: ReactPointerEvent): void => { + if (curveIndex === null && dragIndex === null) return; + e.stopPropagation(); + if (curveIndex !== null) bendSegment(e.clientX, e.clientY); + else movePoint(e); + }, + [bendSegment, curveIndex, dragIndex, movePoint], + ); + + const endDrag = useCallback( + (e: ReactPointerEvent): void => { + if (dragIndex === null && curveIndex === null) return; + e.stopPropagation(); + setDragIndex(null); + setCurveIndex(null); + dragOrigin.current = null; + setHint(null); + commitPoints(lane.points, true); + }, + [curveIndex, dragIndex, lane, commitPoints], + ); + + const onDoubleClick = useCallback( + (e: ReactPointerEvent): void => { + if (readOnly) return; + e.stopPropagation(); + e.preventDefault(); + const onPoint = hitIndex(e.clientX, e.clientY); + if (e.altKey) { + // Straighten the segment back out — the counterpart to Alt-dragging it. + const segment = onPoint ?? segmentIndex(e.clientX, e.clientY); + if (segment === null) return; + commitPoints( + lane.points.map((p, i) => (i === segment ? { t: p.t, v: p.v } : p)), + true, + ); + return; + } + if (onPoint !== null) { + // Typing beats dragging when the value has to be exact — -6.0 dB is not + // a pixel you can find. + const p = lane.points[onPoint]; + if (p) setEditing({ index: onPoint, text: String(Number(p.v.toFixed(3))) }); + return; + } + const { t, v } = pointAt(e.clientX, e.clientY); + const kept = lane.points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC); + // A lane's first point alone would be a constant, which is not what + // clicking an empty lane means: seed the far end at the same value so the + // envelope has somewhere to go. + const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : []; + commitPoints( + [...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t), + true, + ); + }, + [lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex], + ); + + const setEditingText = useCallback((text: string): void => { + setEditing((current) => (current ? { index: current.index, text } : null)); + }, []); + + const cancelEdit = useCallback((): void => setEditing(null), []); + + /** Apply a typed value, or drop the edit when it is not a number. */ + const commitEdit = useCallback((): void => { + const active = editing; + setEditing(null); + if (!active) return; + const typed = Number(active.text); + if (!Number.isFinite(typed)) return; + const clamped = Math.min(range.max, Math.max(range.min, typed)); + commitPoints( + lane.points.map((p, i) => (i === active.index ? { ...p, v: clamped } : p)), + true, + ); + }, [editing, lane, range, commitPoints]); + + return { + dragIndex, + curveIndex, + hint, + hitIndex, + segmentIndex, + onPointerDown, + onPointerMove, + endDrag, + onDoubleClick, + editing, + setEditingText, + commitEdit, + cancelEdit, + }; +} diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index 990884ee72..76c63ddffa 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -16,6 +16,25 @@ import { export { getTrackStyle } from "./timelineIcons"; +/** + * Whether this track draws the beat-dot strip: only where there are beats to + * draw, and only on the track the user is working in — the selected clip's, or + * the music track's when nothing is selected. + */ +export function trackShowsBeatStrip( + els: readonly TimelineElement[], + beatTimes: readonly number[] | undefined, + ctx: { + selectedElementId: string | null; + isMusicTrack(element: TimelineElement): boolean; + }, +): boolean { + if ((beatTimes?.length ?? 0) < 2) return false; + return ctx.selectedElementId + ? els.some((e) => (e.key ?? e.id) === ctx.selectedElementId) + : els.some((e) => ctx.isMusicTrack(e)); +} + /** * Automation lanes on one clip, or 0 for anything that is not audio. *