feat(studio): curve, snap and type a value in an automation lane

Four gestures from Ableton's envelope editor, which is the muscle memory
an automation lane inherits.

Alt-drag the line between two breakpoints to bend it, Alt-double-click to
straighten. `curve` was already honoured everywhere it is read — drawn in
the lane, sampled in preview, baked into the render through
setValueCurveAtTime — with no gesture anywhere that could set it, so every
envelope anyone could draw was linear in practice. The curve is solved,
not accumulated (x^e = f, so e = ln f / ln x), which keeps the segment
under the pointer instead of drifting away over a long drag; the test
asserts that by sampling with the renderer's own sampler.

Shift locks a drag to one axis and fines the vertical travel to a quarter.
Which axis won is decided in pixels — seconds and dB are not comparable
numbers, and comparing them would make the lock depend on the zoom.

A dragged point snaps to the beat grid and to its neighbouring points,
with Alt to ignore it. The radius is tight on purpose: a lane is often a
few seconds wide, where a generous radius makes a point unplaceable
between two beats.

Double-click a point to type its value. -6.0 dB is not a pixel you can
find, and there was no way to enter one.

The gesture layer moves to useAutomationLaneGestures and the path builder
to envelopePath: the component was at the studio's 600-line ceiling, and
both are worth testing without a render. trackShowsBeatStrip comes out of
TimelineLanes for the same reason.
This commit is contained in:
Vance Ingalls
2026-08-06 10:59:26 -07:00
parent b75c5f56f4
commit 0d09f0c3a7
8 changed files with 847 additions and 144 deletions
@@ -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 (
<input
className="hf-automation-value absolute rounded-[3px] border border-panel-border-input bg-panel-bg-2 px-1 font-mono text-[9px] text-panel-text-1"
style={{ left: leftPx, top: 1, width: 44, zIndex: 4 }}
// The lane is a pointer surface, and the timeline above it owns single-key
// shortcuts. Without stopping both, a press lands on the lane instead of
// the field and a typed "5" scrubs the transport.
onPointerDown={(e) => 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
/>
);
}
@@ -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<string, unknown> = {}) => {
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<typeof vi.fn>,
onCommit: base.onCommit as ReturnType<typeof vi.fn>,
};
const { container } = render(<TimelineAutomationLane {...props} />);
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<HTMLInputElement>(".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<HTMLInputElement>(".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);
});
});
@@ -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<SVGSVGElement | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [hint, setHint] = useState<string | null>(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<SVGSVGElement>): 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<SVGSVGElement>): 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<SVGSVGElement>): void => {
if (dragIndex === null) return;
e.stopPropagation();
setDragIndex(null);
setHint(null);
commitPoints(lane.points, true);
},
[dragIndex, lane, commitPoints],
);
const onDoubleClick = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): 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`}
>
<title>
{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."}
</title>
<rect x={PAD_X} y={0} width={widthPx} height={h} fill="rgba(0,0,0,0.18)" rx={3} />
{/* Mid rail, so a value reads against something. */}
@@ -379,6 +293,17 @@ export function TimelineAutomationLane({
) : null}
</svg>
{editing ? (
<AutomationValueInput
text={editing.text}
leftPx={leftPx + xOf(lane.points[editing.index]?.t ?? 0) - PAD_X - 18}
label={range.label}
onChange={gestures.setEditingText}
onCommit={gestures.commitEdit}
onCancel={gestures.cancelEdit}
/>
) : null}
{hint ? (
<div
className="hf-automation-hint pointer-events-none absolute rounded-[3px] bg-black/80 px-1 py-0.5 font-mono text-[9px] text-white"
@@ -401,6 +326,8 @@ export interface TimelineAutomationLaneSlotProps {
accentColor: string;
/** Composition-time playhead; the slot converts it to clip-local. */
currentTime: number;
/** Composition-time beat grid; the slot converts it to clip-local too. */
beatTimes?: readonly number[];
}
/**
@@ -416,7 +343,17 @@ export function TimelineAutomationLaneSlot({
laneCount,
accentColor,
currentTime,
beatTimes,
}: TimelineAutomationLaneSlotProps) {
// Beats inside this clip, in the clip's own frame — the lane's times are
// clip-local, and a beat outside the clip can never be snapped to anyway.
const snapTimes = useMemo(
() =>
(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}
/>
);
@@ -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
}
@@ -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);
});
});
@@ -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: [] };
}
@@ -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<SVGSVGElement>): 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<SVGSVGElement>): void;
onPointerMove(e: ReactPointerEvent<SVGSVGElement>): void;
endDrag(e: ReactPointerEvent<SVGSVGElement>): void;
/** Adds a point, opens the value field on one, or straightens a segment. */
onDoubleClick(e: ReactPointerEvent<SVGSVGElement>): 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<number | null>(null);
const [curveIndex, setCurveIndex] = useState<number | null>(null);
const [hint, setHint] = useState<string | null>(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<SVGSVGElement>): { 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<SVGSVGElement>): 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<SVGSVGElement>): 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<SVGSVGElement>): 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<SVGSVGElement>): 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<SVGSVGElement>): 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,
};
}
@@ -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.
*