): 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.
*