Skip to content

Commit b991761

Browse files
committed
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.
1 parent 8bb7ac8 commit b991761

8 files changed

Lines changed: 847 additions & 144 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Typing an exact value into a breakpoint.
3+
*
4+
* Dragging is how an envelope is shaped, but it cannot land on a number: -6.0 dB
5+
* is not a pixel you can find. This is the lane's keyboard route to one value,
6+
* kept in its own file so the lane component stays about the pointer.
7+
*/
8+
9+
export interface AutomationValueInputProps {
10+
text: string;
11+
/** Left edge in the lane's own coordinates. */
12+
leftPx: number;
13+
label: string;
14+
onChange(text: string): void;
15+
/** Enter or blur: apply what was typed. */
16+
onCommit(): void;
17+
/** Escape: leave the point where it was. */
18+
onCancel(): void;
19+
}
20+
21+
export function AutomationValueInput({
22+
text,
23+
leftPx,
24+
label,
25+
onChange,
26+
onCommit,
27+
onCancel,
28+
}: AutomationValueInputProps) {
29+
return (
30+
<input
31+
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"
32+
style={{ left: leftPx, top: 1, width: 44, zIndex: 4 }}
33+
// The lane is a pointer surface, and the timeline above it owns single-key
34+
// shortcuts. Without stopping both, a press lands on the lane instead of
35+
// the field and a typed "5" scrubs the transport.
36+
onPointerDown={(e) => e.stopPropagation()}
37+
onKeyDown={(e) => {
38+
e.stopPropagation();
39+
if (e.key === "Enter") onCommit();
40+
if (e.key === "Escape") onCancel();
41+
}}
42+
onChange={(e) => onChange(e.target.value)}
43+
onBlur={onCommit}
44+
value={text}
45+
aria-label={`${label} value`}
46+
autoFocus
47+
/>
48+
);
49+
}

‎packages/studio/src/player/components/TimelineAutomationLane.test.tsx‎

Lines changed: 167 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
44
import { createRoot } from "react-dom/client";
55
import { TimelineAutomationLane } from "./TimelineAutomationLane";
66
import { PAD_X } from "./automationLaneGeometry";
7+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
78
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
89
import {
910
resolveAutomationRange,
@@ -80,10 +81,24 @@ function renderNested(node: React.ReactElement): {
8081
function fire(
8182
el: Element,
8283
type: string,
83-
init: { clientX?: number; clientY?: number; button?: number } = {},
84+
init: {
85+
clientX?: number;
86+
clientY?: number;
87+
button?: number;
88+
altKey?: boolean;
89+
shiftKey?: boolean;
90+
} = {},
8491
): void {
8592
const event = new Event(type, { bubbles: true, cancelable: true });
86-
Object.assign(event, { clientX: 0, clientY: 0, button: 0, pointerId: 1, ...init });
93+
Object.assign(event, {
94+
clientX: 0,
95+
clientY: 0,
96+
button: 0,
97+
pointerId: 1,
98+
altKey: false,
99+
shiftKey: false,
100+
...init,
101+
});
87102
act(() => {
88103
el.dispatchEvent(event);
89104
});
@@ -443,3 +458,153 @@ describe("TimelineAutomationLane", () => {
443458
expect(Math.max(...ys)).toBeGreaterThan(Math.min(...ys) + 30);
444459
});
445460
});
461+
462+
describe("TimelineAutomationLane modifiers", () => {
463+
/** The lane's own box, so pointer coordinates map to clip time and value. */
464+
const BOX = { left: 100, top: 0, width: 400 + PAD * 2, height: AUTOMATION_LANE_H };
465+
466+
/** x for a clip time, y for a 0..1 unit height, in client coordinates. The
467+
* 6px inset and the height have to match the lane's own, or a point sits
468+
* outside the grab radius and a press silently does nothing. */
469+
const at = (t: number, unit: number) => ({
470+
clientX: BOX.left + PAD + (t / 4) * 400,
471+
clientY: BOX.top + 6 + (1 - unit) * (AUTOMATION_LANE_H - 12),
472+
});
473+
474+
const mount = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
475+
const base = laneProps({ automation, ...over });
476+
// Narrowed once here: laneProps types these as the prop signature, and every
477+
// assertion below reads the calls the lane made.
478+
const props = {
479+
...base,
480+
onPreview: base.onPreview as ReturnType<typeof vi.fn>,
481+
onCommit: base.onCommit as ReturnType<typeof vi.fn>,
482+
};
483+
const { container } = render(<TimelineAutomationLane {...props} />);
484+
const svg = container.querySelector("svg")!;
485+
stubBox(svg, BOX);
486+
return { container, svg, props };
487+
};
488+
489+
it("bends a segment when it is Alt-dragged, and leaves the points where they were", () => {
490+
// `curve` was honoured everywhere it is read — drawn, sampled in preview,
491+
// baked into the render — with no gesture that could set it.
492+
const { svg, props } = mount(ramp);
493+
fire(svg, "pointerdown", { ...at(2, 0.5), altKey: true });
494+
fire(svg, "pointermove", { ...at(2, 0.85), altKey: true });
495+
const previewed = props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
496+
const points = previewed?.lanes[0]?.points ?? [];
497+
expect(points[0]?.curve).toBeDefined();
498+
expect(points[0]?.curve).not.toBe(0);
499+
// The breakpoints themselves are untouched: only the shape between them moved.
500+
expect(points.map((p) => [p.t, p.v])).toEqual([
501+
[0, 1],
502+
[4, 0],
503+
]);
504+
});
505+
506+
it("straightens a segment on Alt-double-click", () => {
507+
const curved: HfAutomation = {
508+
version: 1,
509+
lanes: [
510+
{
511+
target: "volume",
512+
points: [
513+
{ t: 0, v: 1, curve: 0.6 },
514+
{ t: 4, v: 0 },
515+
],
516+
},
517+
],
518+
};
519+
const { svg, props } = mount(curved);
520+
fire(svg, "dblclick", { ...at(2, 0.5), altKey: true });
521+
const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
522+
expect(committed?.lanes[0]?.points[0]?.curve).toBeUndefined();
523+
});
524+
525+
it("locks a Shift-drag to one axis", () => {
526+
const { svg, props } = mount(ramp);
527+
// Grab the point at t=0, v=1 (top left) and pull mostly sideways.
528+
fire(svg, "pointerdown", at(0, 1));
529+
fire(svg, "pointermove", { ...at(2, 0.9), shiftKey: true });
530+
const points =
531+
(props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
532+
const moved = points.find((p) => p.t > 0.5);
533+
expect(moved).toBeDefined();
534+
// Value held at exactly where the drag started, despite the vertical travel.
535+
expect(moved?.v).toBe(1);
536+
});
537+
538+
it("snaps a dragged point to the beat grid", () => {
539+
// By eye, "on the beat" and "20 ms off the beat" look identical.
540+
const { svg, props } = mount(ramp, { snapTimes: [2] });
541+
fire(svg, "pointerdown", at(0, 1));
542+
fire(svg, "pointermove", at(2.02, 1));
543+
const points =
544+
(props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
545+
expect(points.find((p) => p.t > 0.5)?.t).toBe(2);
546+
});
547+
548+
it("ignores the grid while Alt is held", () => {
549+
const { svg, props } = mount(ramp, { snapTimes: [2] });
550+
fire(svg, "pointerdown", at(0, 1));
551+
fire(svg, "pointermove", { ...at(2.02, 1), altKey: true });
552+
const points =
553+
(props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
554+
expect(points.find((p) => p.t > 0.5)?.t).toBeCloseTo(2.02, 2);
555+
});
556+
557+
it("takes a second gesture in the same lane, not just the first", () => {
558+
// The panel had exactly this bug: one edit worked and every later one was
559+
// swallowed, because the live write skips the resync the next edit reads.
560+
const { svg, props } = mount(ramp);
561+
fire(svg, "pointerdown", at(0, 1));
562+
fire(svg, "pointermove", at(1, 0.8));
563+
fire(svg, "pointerup", at(1, 0.8));
564+
fire(svg, "pointerdown", at(4, 0));
565+
fire(svg, "pointermove", at(3, 0.4));
566+
const points =
567+
(props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
568+
// Both ends moved: the first gesture's point is off t=0, the second's off t=4.
569+
expect(points.map((p) => Number(p.t.toFixed(2)))).toEqual([1, 3]);
570+
});
571+
572+
it("types an exact value into a point", () => {
573+
// -6.0 dB is not a pixel you can find by dragging.
574+
const { container, svg, props } = mount(ramp);
575+
fire(svg, "dblclick", at(0, 1));
576+
const input = container.querySelector<HTMLInputElement>(".hf-automation-value");
577+
expect(input).not.toBeNull();
578+
act(() => {
579+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(
580+
input,
581+
"0.25",
582+
);
583+
input?.dispatchEvent(new Event("input", { bubbles: true }));
584+
});
585+
fire(input!, "keydown", {});
586+
const key = new Event("keydown", { bubbles: true, cancelable: true });
587+
Object.assign(key, { key: "Enter" });
588+
act(() => {
589+
input?.dispatchEvent(key);
590+
});
591+
const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
592+
expect(committed?.lanes[0]?.points[0]?.v).toBe(0.25);
593+
});
594+
595+
it("clamps a typed value to the parameter's range", () => {
596+
const { container, svg, props } = mount(ramp);
597+
fire(svg, "dblclick", at(0, 1));
598+
const input = container.querySelector<HTMLInputElement>(".hf-automation-value");
599+
act(() => {
600+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, "99");
601+
input?.dispatchEvent(new Event("input", { bubbles: true }));
602+
});
603+
// React listens for focusout, not blur — blur does not bubble.
604+
act(() => {
605+
input?.dispatchEvent(new Event("focusout", { bubbles: true }));
606+
});
607+
const committed = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined;
608+
expect(committed?.lanes[0]?.points[0]?.v).toBe(VOLUME_RANGE.max);
609+
});
610+
});

0 commit comments

Comments
 (0)