@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.
Files changed (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
  2. package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
  3. package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
  4. package/dist/assets/index-CfBGQdrX.js +428 -0
  5. package/dist/assets/index-DNkh9mbV.css +1 -0
  6. package/dist/index.d.ts +85 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +17696 -12512
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +18 -1
  12. package/src/components/TimelineToolbar.test.tsx +46 -0
  13. package/src/components/TimelineToolbar.tsx +17 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  15. package/src/components/editor/PropertyPanel.tsx +7 -23
  16. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  17. package/src/components/editor/audioFxSummary.test.ts +75 -0
  18. package/src/components/editor/audioFxSummary.ts +36 -0
  19. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  20. package/src/components/editor/audioFxTelemetry.ts +230 -0
  21. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  23. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  24. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  25. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  26. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  27. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  28. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  29. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  30. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  31. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  32. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  33. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  34. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  35. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  36. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  37. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  38. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  39. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  40. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  41. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  42. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  43. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  44. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  45. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  46. package/src/components/editor/propertyPanelTypes.ts +5 -0
  47. package/src/components/editor/useFxAudition.ts +95 -0
  48. package/src/components/editor/useFxCarve.ts +0 -0
  49. package/src/components/editor/useFxChainObserved.ts +83 -0
  50. package/src/components/editor/useFxLevelling.ts +235 -0
  51. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  52. package/src/components/editor/useVolumeAutomation.ts +54 -0
  53. package/src/contexts/DomEditContext.tsx +11 -0
  54. package/src/hooks/useAppHotkeys.test.ts +198 -0
  55. package/src/hooks/useAppHotkeys.ts +30 -3
  56. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  57. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  58. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  59. package/src/hooks/useDomEditCommits.test.tsx +38 -0
  60. package/src/hooks/useDomEditCommits.ts +2 -0
  61. package/src/hooks/useDomEditSession.ts +2 -0
  62. package/src/hooks/useDomEditTextCommits.ts +2 -0
  63. package/src/hooks/useElementLifecycleOps.ts +3 -0
  64. package/src/hooks/useLivePlayheadTime.ts +49 -0
  65. package/src/hooks/usePreviewPersistence.ts +7 -0
  66. package/src/hooks/useTimelineEditing.ts +3 -0
  67. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  68. package/src/player/components/AutomationValueInput.tsx +51 -0
  69. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  70. package/src/player/components/Timeline.test.ts +84 -0
  71. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  72. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  73. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  74. package/src/player/components/TimelineLanes.test.tsx +24 -5
  75. package/src/player/components/TimelineLanes.tsx +142 -154
  76. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  77. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  78. package/src/player/components/TimelineTrackRow.tsx +8 -1
  79. package/src/player/components/automationClipboard.test.ts +94 -0
  80. package/src/player/components/automationClipboard.ts +136 -0
  81. package/src/player/components/automationGestureKeys.test.ts +49 -0
  82. package/src/player/components/automationGestureKeys.ts +49 -0
  83. package/src/player/components/automationLaneData.test.ts +270 -0
  84. package/src/player/components/automationLaneData.ts +258 -0
  85. package/src/player/components/automationLaneDragMath.ts +194 -0
  86. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  87. package/src/player/components/automationLaneGeometry.ts +287 -0
  88. package/src/player/components/automationLaneHeight.ts +15 -0
  89. package/src/player/components/automationLanePointer.ts +20 -0
  90. package/src/player/components/automationLaneSelection.test.ts +183 -0
  91. package/src/player/components/automationLaneSelection.ts +135 -0
  92. package/src/player/components/automationShapes.test.ts +82 -0
  93. package/src/player/components/automationShapes.ts +70 -0
  94. package/src/player/components/automationSimplify.test.ts +48 -0
  95. package/src/player/components/automationSimplify.ts +51 -0
  96. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  97. package/src/player/components/timelineLaneProps.ts +19 -0
  98. package/src/player/components/timelineLayout.ts +11 -5
  99. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  100. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  101. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  102. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  103. package/src/player/components/useAutomationLanes.ts +170 -0
  104. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  105. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  106. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  107. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  108. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  109. package/src/player/lib/automationStoreSync.test.ts +83 -0
  110. package/src/player/lib/automationStoreSync.ts +56 -0
  111. package/src/player/lib/timelineDOM.test.ts +33 -0
  112. package/src/player/lib/timelineDOM.ts +12 -0
  113. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  114. package/src/player/store/automationSelectionSlice.ts +54 -0
  115. package/src/player/store/playerStore.test.ts +15 -0
  116. package/src/player/store/playerStore.ts +54 -65
  117. package/src/player/store/timelineElement.ts +78 -0
  118. package/src/styles/studio.css +89 -0
  119. package/src/telemetry/agentRuntime.test.ts +45 -0
  120. package/src/telemetry/agentRuntime.ts +62 -0
  121. package/src/telemetry/system.ts +8 -0
  122. package/src/utils/studioFileHistory.test.ts +49 -0
  123. package/src/utils/studioFileHistory.ts +17 -1
  124. package/src/utils/studioTelemetry.ts +16 -10
  125. package/dist/assets/index-dF-CmLZu.js +0 -428
  126. 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
+ }