@hyperframes/studio 0.7.107 → 0.7.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
- package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
- package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
- package/dist/assets/index-CfBGQdrX.js +428 -0
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/index.d.ts +85 -2
- package/dist/index.html +2 -2
- package/dist/index.js +17696 -12512
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/contexts/DomEditContext.tsx +11 -0
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +30 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +38 -0
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +2 -0
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/system.ts +8 -0
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioTelemetry.ts +16 -10
- package/dist/assets/index-dF-CmLZu.js +0 -428
- package/dist/assets/index-zQ4JFwwB.css +0 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { FxParamRow } from "./propertyPanelFxControls";
|
|
6
|
+
import type { HfAudioFxNumberParam } from "@hyperframes/core/audio-fx";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** A frequency knob: wide range, so a clamp to the minimum is unmistakable. */
|
|
15
|
+
const FREQUENCY: HfAudioFxNumberParam = {
|
|
16
|
+
kind: "number",
|
|
17
|
+
key: "frequency",
|
|
18
|
+
label: "Frequency",
|
|
19
|
+
min: 20,
|
|
20
|
+
max: 20000,
|
|
21
|
+
step: 1,
|
|
22
|
+
default: 1000,
|
|
23
|
+
unit: "Hz",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** React tracks its own value on the node, so a plain assignment is ignored. */
|
|
27
|
+
function setInputValue(input: HTMLInputElement, text: string): void {
|
|
28
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
29
|
+
setter?.call(input, text);
|
|
30
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mount(value: number) {
|
|
34
|
+
const onChange = vi.fn();
|
|
35
|
+
const onCommit = vi.fn();
|
|
36
|
+
const host = document.createElement("div");
|
|
37
|
+
document.body.append(host);
|
|
38
|
+
let root!: Root;
|
|
39
|
+
act(() => {
|
|
40
|
+
root = createRoot(host);
|
|
41
|
+
root.render(
|
|
42
|
+
<FxParamRow param={FREQUENCY} value={value} onChange={onChange} onCommit={onCommit} />,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
const number = () => host.querySelector<HTMLInputElement>(".hf-fx-number")!;
|
|
46
|
+
const slider = () => host.querySelector<HTMLInputElement>(".hf-fx-slider")!;
|
|
47
|
+
const type = (text: string) => {
|
|
48
|
+
act(() => {
|
|
49
|
+
number().focus();
|
|
50
|
+
setInputValue(number(), text);
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
/** A new value arrives through the prop — a carve, or an undo. */
|
|
54
|
+
const receive = (next: number) => {
|
|
55
|
+
act(() => {
|
|
56
|
+
root.render(
|
|
57
|
+
<FxParamRow param={FREQUENCY} value={next} onChange={onChange} onCommit={onCommit} />,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
return { host, number, slider, onChange, onCommit, type, receive };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("FxParamRow number field", () => {
|
|
65
|
+
it("shows what is being typed instead of snapping back to the stored value", () => {
|
|
66
|
+
// Every keystroke is clamped and written live, and the live write does not
|
|
67
|
+
// refresh the prop — so a field bound to the committed value put the old
|
|
68
|
+
// number straight back. Typing 5000 wrote 20 on the first keystroke and the
|
|
69
|
+
// knob could not be typed into at all, only dragged.
|
|
70
|
+
const { number, type } = mount(1000);
|
|
71
|
+
type("5");
|
|
72
|
+
expect(number().value).toBe("5");
|
|
73
|
+
type("5000");
|
|
74
|
+
expect(number().value).toBe("5000");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not write the parameter minimum while the field is empty", () => {
|
|
78
|
+
// `Number("") === 0` passes Number.isFinite, so select-all + Delete before
|
|
79
|
+
// retyping used to clamp to the minimum and live-write it — 20 Hz here.
|
|
80
|
+
const { onChange, type } = mount(1000);
|
|
81
|
+
type("");
|
|
82
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
83
|
+
type("800");
|
|
84
|
+
expect(onChange).toHaveBeenLastCalledWith("frequency", 800);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("FxParamRow commit", () => {
|
|
89
|
+
it("stays quiet when a gesture changed nothing", () => {
|
|
90
|
+
// commit() hangs off pointerup, keyup and blur, all reachable without an
|
|
91
|
+
// edit. Firing then costs a source patch, a selection resync, a preview
|
|
92
|
+
// reload and an audio restart for a gesture that moved nothing.
|
|
93
|
+
const { slider, number, onCommit } = mount(1000);
|
|
94
|
+
act(() => slider().dispatchEvent(new Event("pointerup", { bubbles: true })));
|
|
95
|
+
act(() => number().dispatchEvent(new Event("blur", { bubbles: true })));
|
|
96
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does not re-persist a stale value over a change that arrived meanwhile", () => {
|
|
100
|
+
// The scenario: open an EQ row, run the carve (or press undo), which
|
|
101
|
+
// rewrites the chain so this row's value becomes 400. Then click the slider
|
|
102
|
+
// thumb and release without moving it. `latest` was seeded at mount and only
|
|
103
|
+
// written by an edit, so it still held 1000 — and the release wrote it back,
|
|
104
|
+
// undoing the carve with no gesture that looks like an edit.
|
|
105
|
+
const { slider, onCommit, type, receive } = mount(1000);
|
|
106
|
+
// An edit happened earlier in this row's life, so `latest` holds 1000.
|
|
107
|
+
type("1000");
|
|
108
|
+
act(() => slider().dispatchEvent(new Event("pointerup", { bubbles: true })));
|
|
109
|
+
onCommit.mockClear();
|
|
110
|
+
|
|
111
|
+
receive(400);
|
|
112
|
+
act(() => slider().dispatchEvent(new Event("pointerup", { bubbles: true })));
|
|
113
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controls for one effect, generated from its registry entry.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here knows what a compressor is. The registry declares each
|
|
5
|
+
* parameter's range, step, unit and scale, and this renders whatever it finds —
|
|
6
|
+
* so adding an effect or a knob upstream needs no change in the panel, and the
|
|
7
|
+
* panel cannot offer a value the renderer would reject.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
11
|
+
import { Tooltip } from "../ui/Tooltip";
|
|
12
|
+
import type {
|
|
13
|
+
HfAudioFxDef,
|
|
14
|
+
HfAudioFxNumberParam,
|
|
15
|
+
HfAudioFxParam,
|
|
16
|
+
HfAudioFxParamValues,
|
|
17
|
+
} from "@hyperframes/core/audio-fx";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Frequency and time controls span three or four decades, so a linear slider
|
|
21
|
+
* spends most of its travel somewhere useless. Those declare `scale: "log"` and
|
|
22
|
+
* the slider position maps exponentially instead.
|
|
23
|
+
*/
|
|
24
|
+
function toSlider(p: HfAudioFxNumberParam, value: number): number {
|
|
25
|
+
if (p.scale !== "log" || p.min <= 0) return value;
|
|
26
|
+
const t = (Math.log(value) - Math.log(p.min)) / (Math.log(p.max) - Math.log(p.min));
|
|
27
|
+
return p.min + t * (p.max - p.min);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fromSlider(p: HfAudioFxNumberParam, position: number): number {
|
|
31
|
+
if (p.scale !== "log" || p.min <= 0) return position;
|
|
32
|
+
const t = (position - p.min) / (p.max - p.min);
|
|
33
|
+
return Math.exp(Math.log(p.min) + t * (Math.log(p.max) - Math.log(p.min)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Enough precision to be honest without showing float noise. */
|
|
37
|
+
function display(p: HfAudioFxNumberParam, value: number): string {
|
|
38
|
+
const decimals = p.step >= 1 ? 0 : p.step >= 0.1 ? 1 : 2;
|
|
39
|
+
return value.toFixed(decimals);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ParamRowProps {
|
|
43
|
+
param: HfAudioFxParam;
|
|
44
|
+
value: number | string;
|
|
45
|
+
/**
|
|
46
|
+
* The envelope's value at the playhead, when a lane drives this parameter and
|
|
47
|
+
* the playhead is over the clip. Shown in place of `value`, which by then is
|
|
48
|
+
* only the seed the lane replaced.
|
|
49
|
+
*/
|
|
50
|
+
liveValue?: number;
|
|
51
|
+
/** Fires continuously while dragging — cheap, not persisted. */
|
|
52
|
+
onChange(key: string, value: number | string): void;
|
|
53
|
+
/** Fires once when the gesture ends — this is the write that persists. */
|
|
54
|
+
onCommit?(key: string, value: number | string): void;
|
|
55
|
+
disabled?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* A lane in the timeline drives this parameter. The control is disabled
|
|
58
|
+
* because a value typed here would be overwritten by the envelope on the next
|
|
59
|
+
* tick — the lane is the value now.
|
|
60
|
+
*/
|
|
61
|
+
automated?: boolean;
|
|
62
|
+
/** Add a lane for this parameter, seeded at its current value. */
|
|
63
|
+
onAutomate?(key: string): void;
|
|
64
|
+
/** Delete this parameter's lane, handing the value back to the control. */
|
|
65
|
+
onRemoveAutomation?(key: string): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The automation toggle for one parameter: adds a lane, or deletes the one that
|
|
70
|
+
* already owns the value. Absent for parameters no envelope can drive — a
|
|
71
|
+
* WaveShaper curve, a convolution impulse, or a worklet's options.
|
|
72
|
+
*/
|
|
73
|
+
export function AutomationToggle({
|
|
74
|
+
paramKey,
|
|
75
|
+
label,
|
|
76
|
+
automated,
|
|
77
|
+
onAutomate,
|
|
78
|
+
onRemoveAutomation,
|
|
79
|
+
}: {
|
|
80
|
+
paramKey: string;
|
|
81
|
+
label: string;
|
|
82
|
+
automated: boolean;
|
|
83
|
+
onAutomate?(key: string): void;
|
|
84
|
+
onRemoveAutomation?(key: string): void;
|
|
85
|
+
}) {
|
|
86
|
+
if (!onAutomate && !onRemoveAutomation) return null;
|
|
87
|
+
return (
|
|
88
|
+
<Tooltip label={automated ? "Automated" : "Automate"}>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
className={`hf-fx-automate w-[16px] flex-shrink-0 rounded-[3px] border font-mono text-[9px] leading-none ${
|
|
92
|
+
automated
|
|
93
|
+
? "border-panel-accent text-panel-accent"
|
|
94
|
+
: "border-panel-border-input text-panel-text-2 hover:text-panel-text-0"
|
|
95
|
+
}`}
|
|
96
|
+
aria-pressed={automated}
|
|
97
|
+
aria-label={automated ? `Remove ${label} automation` : `Automate ${label}`}
|
|
98
|
+
onClick={() => (automated ? onRemoveAutomation?.(paramKey) : onAutomate?.(paramKey))}
|
|
99
|
+
>
|
|
100
|
+
A
|
|
101
|
+
</button>
|
|
102
|
+
</Tooltip>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function FxParamRow({
|
|
107
|
+
param,
|
|
108
|
+
value,
|
|
109
|
+
liveValue,
|
|
110
|
+
onChange,
|
|
111
|
+
onCommit,
|
|
112
|
+
disabled,
|
|
113
|
+
automated,
|
|
114
|
+
onAutomate,
|
|
115
|
+
onRemoveAutomation,
|
|
116
|
+
}: ParamRowProps) {
|
|
117
|
+
// While dragging, the slider is driven locally. Waiting for the value to come
|
|
118
|
+
// back through the element attribute makes the control feel laggy and fights
|
|
119
|
+
// the pointer.
|
|
120
|
+
const [dragging, setDragging] = useState(false);
|
|
121
|
+
const [local, setLocal] = useState(value);
|
|
122
|
+
const latest = useRef(value);
|
|
123
|
+
/**
|
|
124
|
+
* What the gesture last asked for, held until the world agrees.
|
|
125
|
+
*
|
|
126
|
+
* Releasing ends the drag, but the value only comes back after the attribute is
|
|
127
|
+
* written and the selection resynced — and for a carve, after the analysis it
|
|
128
|
+
* kicks off. In that gap the prop still holds the pre-drag number, so dropping
|
|
129
|
+
* straight back to it made the control snap to where it started and then jump to
|
|
130
|
+
* where it was dropped. Keeping the gesture's own number until a NEW one arrives
|
|
131
|
+
* is honest either way: it is what the audio is already doing, since the live
|
|
132
|
+
* write applied on the way down.
|
|
133
|
+
*/
|
|
134
|
+
const [pending, setPending] = useState<number | null>(null);
|
|
135
|
+
/**
|
|
136
|
+
* What the number field is showing while it has focus.
|
|
137
|
+
*
|
|
138
|
+
* The field cannot be bound to the committed value: every keystroke is
|
|
139
|
+
* clamped into range and written live, and the live write does not refresh
|
|
140
|
+
* the prop — so React put the old number straight back and typing `5000` into
|
|
141
|
+
* a 20..20000 knob wrote 20 on the first keystroke and never got further. Held
|
|
142
|
+
* as TEXT, so a half-typed "-" or "" is a state the field can be in rather
|
|
143
|
+
* than a number to clamp and persist.
|
|
144
|
+
*/
|
|
145
|
+
const [typing, setTyping] = useState<string | null>(null);
|
|
146
|
+
/**
|
|
147
|
+
* Did this gesture actually change anything?
|
|
148
|
+
*
|
|
149
|
+
* `commit()` hangs off pointerup, keyup and blur, all of which fire without
|
|
150
|
+
* an edit — clicking a slider thumb without moving it, or tabbing through the
|
|
151
|
+
* field. Committing then re-persisted whatever `latest` happened to hold and
|
|
152
|
+
* silently reverted any change that had arrived meanwhile.
|
|
153
|
+
*/
|
|
154
|
+
const edited = useRef(false);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!dragging) setLocal(value);
|
|
157
|
+
}, [value, dragging]);
|
|
158
|
+
// Any inbound value is newer information than the gesture's guess — including a
|
|
159
|
+
// value that came back different from what was asked for, or an undo.
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
setPending(null);
|
|
162
|
+
}, [value]);
|
|
163
|
+
|
|
164
|
+
const handleNumber = useCallback(
|
|
165
|
+
(raw: number) => {
|
|
166
|
+
const p = param as HfAudioFxNumberParam;
|
|
167
|
+
const next = Math.min(p.max, Math.max(p.min, raw));
|
|
168
|
+
latest.current = next;
|
|
169
|
+
edited.current = true;
|
|
170
|
+
setLocal(next);
|
|
171
|
+
onChange(param.key, next);
|
|
172
|
+
},
|
|
173
|
+
[param, onChange],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const commit = useCallback(() => {
|
|
177
|
+
setDragging(false);
|
|
178
|
+
// Nothing was edited, so there is nothing to persist. Without this a bare
|
|
179
|
+
// focus/blur — or a click on the slider thumb that never moved — fired a
|
|
180
|
+
// full persisting write: source patch, selection resync, preview reload and
|
|
181
|
+
// an audio restart, for a gesture that changed no value.
|
|
182
|
+
if (!edited.current) return;
|
|
183
|
+
edited.current = false;
|
|
184
|
+
if (typeof latest.current === "number") setPending(latest.current);
|
|
185
|
+
onCommit?.(param.key, latest.current);
|
|
186
|
+
}, [onCommit, param.key]);
|
|
187
|
+
|
|
188
|
+
if (param.kind === "enum") {
|
|
189
|
+
return (
|
|
190
|
+
<label className="hf-fx-row flex min-h-6 items-center gap-2" title={param.hint}>
|
|
191
|
+
<span className="hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] text-panel-text-2">
|
|
192
|
+
{param.label}
|
|
193
|
+
</span>
|
|
194
|
+
<select
|
|
195
|
+
className="hf-fx-select min-w-0 flex-1 rounded-[3px] bg-panel-surface px-1 py-0.5 font-mono text-[10px] text-panel-text-0"
|
|
196
|
+
value={String(value)}
|
|
197
|
+
disabled={disabled}
|
|
198
|
+
onChange={(e) => {
|
|
199
|
+
// A select has no drag; the change is already the commit.
|
|
200
|
+
onChange(param.key, e.target.value);
|
|
201
|
+
onCommit?.(param.key, e.target.value);
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
{param.options.map((o) => (
|
|
205
|
+
<option key={o.value} value={o.value}>
|
|
206
|
+
{o.label}
|
|
207
|
+
</option>
|
|
208
|
+
))}
|
|
209
|
+
</select>
|
|
210
|
+
</label>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// An envelope's value at the playhead outranks the one stored in the chain,
|
|
215
|
+
// because it is the one the audio is using — the stored number is only the seed
|
|
216
|
+
// the lane replaced. Safe against the pointer: an automated control is locked
|
|
217
|
+
// (see `locked` below), so there is no drag for this to fight.
|
|
218
|
+
const shown = dragging ? local : (liveValue ?? pending ?? value);
|
|
219
|
+
const numeric = typeof shown === "number" ? shown : Number(shown);
|
|
220
|
+
const current = Number.isFinite(numeric) ? numeric : param.default;
|
|
221
|
+
|
|
222
|
+
const locked = Boolean(disabled) || Boolean(automated);
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<label
|
|
226
|
+
className={`hf-fx-row flex min-h-6 items-center gap-2${automated ? " hf-fx-row-automated" : ""}`}
|
|
227
|
+
title={param.hint}
|
|
228
|
+
data-automated={automated ? "" : undefined}
|
|
229
|
+
>
|
|
230
|
+
<span
|
|
231
|
+
className={`hf-fx-label w-[86px] flex-shrink-0 truncate text-[10px] ${
|
|
232
|
+
automated ? "text-panel-accent" : "text-panel-text-2"
|
|
233
|
+
}`}
|
|
234
|
+
>
|
|
235
|
+
{param.label}
|
|
236
|
+
</span>
|
|
237
|
+
<input
|
|
238
|
+
className="hf-fx-slider h-1 min-w-0 flex-1 accent-panel-accent"
|
|
239
|
+
type="range"
|
|
240
|
+
min={param.min}
|
|
241
|
+
max={param.max}
|
|
242
|
+
step={(param.max - param.min) / 1000}
|
|
243
|
+
value={toSlider(param, current)}
|
|
244
|
+
disabled={locked}
|
|
245
|
+
aria-label={param.label}
|
|
246
|
+
onPointerDown={() => setDragging(true)}
|
|
247
|
+
onChange={(e) => handleNumber(fromSlider(param, Number(e.target.value)))}
|
|
248
|
+
onPointerUp={commit}
|
|
249
|
+
onKeyUp={commit}
|
|
250
|
+
onBlur={commit}
|
|
251
|
+
/>
|
|
252
|
+
<input
|
|
253
|
+
className="hf-fx-number w-[54px] flex-shrink-0 rounded-[3px] bg-panel-surface px-1 py-0.5 text-right font-mono text-[10px] text-panel-text-0"
|
|
254
|
+
type="number"
|
|
255
|
+
min={param.min}
|
|
256
|
+
max={param.max}
|
|
257
|
+
step={param.step}
|
|
258
|
+
value={typing ?? display(param, current)}
|
|
259
|
+
disabled={locked}
|
|
260
|
+
onFocus={() => setTyping(display(param, current))}
|
|
261
|
+
onChange={(e) => {
|
|
262
|
+
const text = e.target.value;
|
|
263
|
+
setTyping(text);
|
|
264
|
+
// An empty field, a lone "-", or a trailing "." are all states on the
|
|
265
|
+
// way to a number, not numbers. `Number("")` is 0, which passes
|
|
266
|
+
// Number.isFinite — so clearing the field to retype used to clamp to
|
|
267
|
+
// the parameter MINIMUM and write it live: 20 Hz on a cutoff, -40 dB
|
|
268
|
+
// on a gain.
|
|
269
|
+
if (text.trim() === "") return;
|
|
270
|
+
const next = Number(text);
|
|
271
|
+
if (Number.isFinite(next)) handleNumber(next);
|
|
272
|
+
}}
|
|
273
|
+
onBlur={() => {
|
|
274
|
+
commit();
|
|
275
|
+
setTyping(null);
|
|
276
|
+
}}
|
|
277
|
+
onKeyDown={(e) => {
|
|
278
|
+
if (e.key === "Enter") commit();
|
|
279
|
+
}}
|
|
280
|
+
/>
|
|
281
|
+
{param.unit ? (
|
|
282
|
+
<span className="hf-fx-unit w-[22px] flex-shrink-0 font-mono text-[9px] text-panel-text-2">
|
|
283
|
+
{param.unit}
|
|
284
|
+
</span>
|
|
285
|
+
) : null}
|
|
286
|
+
<AutomationToggle
|
|
287
|
+
paramKey={param.key}
|
|
288
|
+
label={param.label}
|
|
289
|
+
automated={Boolean(automated)}
|
|
290
|
+
onAutomate={onAutomate}
|
|
291
|
+
onRemoveAutomation={onRemoveAutomation}
|
|
292
|
+
/>
|
|
293
|
+
</label>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
interface FxParamsProps {
|
|
298
|
+
def: HfAudioFxDef;
|
|
299
|
+
params: HfAudioFxParamValues;
|
|
300
|
+
/** What automated knobs are worth at the playhead, by parameter key. */
|
|
301
|
+
liveValues?: ReadonlyMap<string, number>;
|
|
302
|
+
onChange(params: HfAudioFxParamValues): void;
|
|
303
|
+
onCommit?(params: HfAudioFxParamValues): void;
|
|
304
|
+
disabled?: boolean;
|
|
305
|
+
/** Parameter keys this effect currently has a lane for. */
|
|
306
|
+
automatedKeys?: ReadonlySet<string>;
|
|
307
|
+
/** Absent when the effect cannot be automated at all, or nothing can write. */
|
|
308
|
+
onAutomate?(key: string): void;
|
|
309
|
+
onRemoveAutomation?(key: string): void;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Every knob the effect declares, in registry order. */
|
|
313
|
+
export function FxParams({
|
|
314
|
+
def,
|
|
315
|
+
params,
|
|
316
|
+
liveValues,
|
|
317
|
+
onChange,
|
|
318
|
+
onCommit,
|
|
319
|
+
disabled,
|
|
320
|
+
automatedKeys,
|
|
321
|
+
onAutomate,
|
|
322
|
+
onRemoveAutomation,
|
|
323
|
+
}: FxParamsProps) {
|
|
324
|
+
const set = useCallback(
|
|
325
|
+
(key: string, value: number | string) => onChange({ ...params, [key]: value }),
|
|
326
|
+
[params, onChange],
|
|
327
|
+
);
|
|
328
|
+
const commit = useCallback(
|
|
329
|
+
(key: string, value: number | string) => onCommit?.({ ...params, [key]: value }),
|
|
330
|
+
[params, onCommit],
|
|
331
|
+
);
|
|
332
|
+
return (
|
|
333
|
+
<div className="hf-fx-params space-y-0.5 border-t border-panel-border-input px-1.5 py-1.5">
|
|
334
|
+
{def.params.map((p) => {
|
|
335
|
+
// Only a parameter the registry marks automatable has an AudioParam
|
|
336
|
+
// behind it for an envelope to write to.
|
|
337
|
+
const canAutomate = p.kind === "number" && p.automatable === true;
|
|
338
|
+
const automated = automatedKeys?.has(p.key) ?? false;
|
|
339
|
+
return (
|
|
340
|
+
<FxParamRow
|
|
341
|
+
key={p.key}
|
|
342
|
+
param={p}
|
|
343
|
+
value={params[p.key] ?? p.default}
|
|
344
|
+
liveValue={liveValues?.get(p.key)}
|
|
345
|
+
onChange={set}
|
|
346
|
+
onCommit={commit}
|
|
347
|
+
disabled={disabled}
|
|
348
|
+
automated={automated}
|
|
349
|
+
onAutomate={canAutomate && !automated ? onAutomate : undefined}
|
|
350
|
+
onRemoveAutomation={canAutomate && automated ? onRemoveAutomation : undefined}
|
|
351
|
+
/>
|
|
352
|
+
);
|
|
353
|
+
})}
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Tone module: a multi-band EQ as one control surface over several nodes.
|
|
3
|
+
*
|
|
4
|
+
* Faders rather than the rack's usual horizontal sliders, because a row of them
|
|
5
|
+
* around a centre detent is what an equaliser looks like to everybody who has
|
|
6
|
+
* met one. Recognising the control is most of the value — an author who has
|
|
7
|
+
* never opened a mixer has still used bass, middle and treble.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useState } from "react";
|
|
11
|
+
import {
|
|
12
|
+
audioEqSummary,
|
|
13
|
+
HF_AUDIO_EQ_RANGE_DB,
|
|
14
|
+
type HfAudioEqBand,
|
|
15
|
+
} from "@hyperframes/core/audio-fx-eq";
|
|
16
|
+
import { FX_FAMILY_TYPE, fxFamilyTint } from "./propertyPanelFxFamily.js";
|
|
17
|
+
|
|
18
|
+
export interface FxEqModuleProps {
|
|
19
|
+
eqId: string;
|
|
20
|
+
bands: HfAudioEqBand[];
|
|
21
|
+
open: boolean;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
onToggleOpen(): void;
|
|
24
|
+
/** Dragging: heard immediately, not persisted. */
|
|
25
|
+
onPreview(bandName: string, gain: number): void;
|
|
26
|
+
/** Release: the write that persists. */
|
|
27
|
+
onCommit(bandName: string, gain: number): void;
|
|
28
|
+
onRemove(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fader travel as a percentage from the top, with 0 dB at the centre. */
|
|
32
|
+
function offsetFor(gain: number): number {
|
|
33
|
+
const clamped = Math.max(-HF_AUDIO_EQ_RANGE_DB, Math.min(HF_AUDIO_EQ_RANGE_DB, gain));
|
|
34
|
+
return 50 - (clamped / (HF_AUDIO_EQ_RANGE_DB * 2)) * 100;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const shown = (gain: number): string => {
|
|
38
|
+
const v = Number(gain.toFixed(1));
|
|
39
|
+
return v > 0 ? `+${v}` : String(v);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function Fader({
|
|
43
|
+
band,
|
|
44
|
+
disabled,
|
|
45
|
+
onPreview,
|
|
46
|
+
onCommit,
|
|
47
|
+
}: {
|
|
48
|
+
band: HfAudioEqBand;
|
|
49
|
+
disabled?: boolean;
|
|
50
|
+
onPreview(gain: number): void;
|
|
51
|
+
onCommit(gain: number): void;
|
|
52
|
+
}) {
|
|
53
|
+
/**
|
|
54
|
+
* Held locally for the length of the gesture.
|
|
55
|
+
*
|
|
56
|
+
* The module is driven by the chain, and dragging only PREVIEWS — it does
|
|
57
|
+
* not write — so a purely controlled input re-renders back to the old value
|
|
58
|
+
* on the first move and the fader snaps out from under the pointer. Same
|
|
59
|
+
* split the rack's other controls already make.
|
|
60
|
+
*/
|
|
61
|
+
const [local, setLocal] = useState(band.gain);
|
|
62
|
+
const [dragging, setDragging] = useState(false);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!dragging) setLocal(band.gain);
|
|
65
|
+
}, [band.gain, dragging]);
|
|
66
|
+
|
|
67
|
+
const value = dragging ? local : band.gain;
|
|
68
|
+
const pct = offsetFor(value);
|
|
69
|
+
const moved = Math.abs(value) >= 0.05;
|
|
70
|
+
|
|
71
|
+
const move = (next: number) => {
|
|
72
|
+
setDragging(true);
|
|
73
|
+
setLocal(next);
|
|
74
|
+
onPreview(next);
|
|
75
|
+
};
|
|
76
|
+
const settle = () => {
|
|
77
|
+
if (!dragging) return;
|
|
78
|
+
setDragging(false);
|
|
79
|
+
onCommit(local);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// A range input rotated into a fader: it keeps keyboard control, focus and
|
|
83
|
+
// the platform's own pointer handling, which a div with pointer events would
|
|
84
|
+
// all have to reimplement badly.
|
|
85
|
+
return (
|
|
86
|
+
<div className="hf-fx-eq-band flex min-w-0 flex-1 flex-col items-center gap-1">
|
|
87
|
+
<div className="relative h-[74px] w-full">
|
|
88
|
+
<span className="pointer-events-none absolute inset-x-0 top-1/2 h-px bg-panel-border-input" />
|
|
89
|
+
<span
|
|
90
|
+
className="pointer-events-none absolute left-1/2 w-[3px] -translate-x-1/2 rounded-sm bg-panel-accent"
|
|
91
|
+
style={
|
|
92
|
+
// `value`, not `band.gain`: mid-drag across zero the fill would
|
|
93
|
+
// otherwise keep pointing the way it started.
|
|
94
|
+
value >= 0 ? { top: `${pct}%`, bottom: "50%" } : { top: "50%", bottom: `${100 - pct}%` }
|
|
95
|
+
}
|
|
96
|
+
/>
|
|
97
|
+
<input
|
|
98
|
+
className="hf-fx-eq-fader absolute left-1/2 h-[19px] w-[74px] -translate-x-1/2 -translate-y-1/2 rotate-[-90deg] cursor-ns-resize appearance-none bg-transparent"
|
|
99
|
+
style={{ top: "50%" }}
|
|
100
|
+
type="range"
|
|
101
|
+
min={-HF_AUDIO_EQ_RANGE_DB}
|
|
102
|
+
max={HF_AUDIO_EQ_RANGE_DB}
|
|
103
|
+
step={0.5}
|
|
104
|
+
value={value}
|
|
105
|
+
disabled={disabled}
|
|
106
|
+
aria-label={`${band.name} ${shown(value)} dB`}
|
|
107
|
+
onChange={(e) => move(Number(e.target.value))}
|
|
108
|
+
onPointerUp={settle}
|
|
109
|
+
onKeyUp={settle}
|
|
110
|
+
onBlur={settle}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
<span className="hf-fx-eq-name w-full truncate text-center font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
|
|
114
|
+
{band.name}
|
|
115
|
+
</span>
|
|
116
|
+
<span
|
|
117
|
+
className={`hf-fx-eq-value font-mono text-[9px] tabular-nums ${
|
|
118
|
+
moved ? "text-panel-accent" : "text-panel-text-2"
|
|
119
|
+
}`}
|
|
120
|
+
>
|
|
121
|
+
{moved ? shown(value) : "0"}
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function FxEqModule({
|
|
128
|
+
bands,
|
|
129
|
+
open,
|
|
130
|
+
disabled,
|
|
131
|
+
onToggleOpen,
|
|
132
|
+
onPreview,
|
|
133
|
+
onCommit,
|
|
134
|
+
onRemove,
|
|
135
|
+
}: FxEqModuleProps) {
|
|
136
|
+
const preview = useCallback((name: string, gain: number) => onPreview(name, gain), [onPreview]);
|
|
137
|
+
const commit = useCallback((name: string, gain: number) => onCommit(name, gain), [onCommit]);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
className="hf-fx-node hf-fx-eq-module rounded-[4px] border border-l-2 border-panel-border-input"
|
|
142
|
+
data-fx-node="eq"
|
|
143
|
+
data-fx-family="smart"
|
|
144
|
+
// Smart, with the carve and the leveller: three bands the author sets by
|
|
145
|
+
// ear on a control surface, not three filters they configured.
|
|
146
|
+
style={{ borderLeftColor: fxFamilyTint({ type: "eq", fromEq: "eq" }) }}
|
|
147
|
+
>
|
|
148
|
+
<div className="hf-fx-node-head flex items-center gap-1.5 px-1.5 py-1">
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
className={`hf-fx-node-name flex-1 text-left text-[11px] text-panel-text-0 ${FX_FAMILY_TYPE.smart}`}
|
|
152
|
+
aria-expanded={open}
|
|
153
|
+
onClick={onToggleOpen}
|
|
154
|
+
>
|
|
155
|
+
Tone
|
|
156
|
+
</button>
|
|
157
|
+
<span className="font-mono text-[9px] text-panel-text-2">{bands.length}-band</span>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
className="hf-fx-remove px-1 text-[11px] text-panel-text-2 hover:text-panel-danger"
|
|
161
|
+
aria-label="Remove Tone"
|
|
162
|
+
disabled={disabled}
|
|
163
|
+
onClick={onRemove}
|
|
164
|
+
>
|
|
165
|
+
×
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{open ? (
|
|
170
|
+
<div className="hf-fx-eq-body px-2 pb-2">
|
|
171
|
+
<div className="flex gap-1.5">
|
|
172
|
+
{bands.map((band) => (
|
|
173
|
+
<Fader
|
|
174
|
+
key={band.name}
|
|
175
|
+
band={band}
|
|
176
|
+
disabled={disabled}
|
|
177
|
+
onPreview={(g) => preview(band.name, g)}
|
|
178
|
+
onCommit={(g) => commit(band.name, g)}
|
|
179
|
+
/>
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
<div className="mt-1.5 flex justify-between font-mono text-[8px] tracking-wide text-panel-text-2">
|
|
183
|
+
<span>CUT</span>
|
|
184
|
+
<span>BOOST</span>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
) : (
|
|
188
|
+
// Closed, it reads like every other module: a sentence about the sound
|
|
189
|
+
// rather than a list of values.
|
|
190
|
+
<p className="hf-fx-eq-summary px-2 pb-1.5 text-[11px] text-panel-text-2">
|
|
191
|
+
{audioEqSummary(bands)}
|
|
192
|
+
</p>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|