@hyperframes/studio 0.7.107 → 0.7.108
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-QqavTWRK.js} +1 -1
- package/dist/assets/{index-BA9yhzfR.js → index-BQjXA1pz.js} +1 -1
- package/dist/assets/{index-BSBk5srs.js → index-BhHHMprP.js} +1 -1
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/assets/index-GqONLcOl.js +428 -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,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview a hypothetical chain without committing to it, and put it back on
|
|
3
|
+
* the way out — the machinery behind hovering a preset or an add-menu item.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `propertyPanelFxSection.tsx`, whose `audition` callback and its
|
|
6
|
+
* teardown effect this was.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
10
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
11
|
+
|
|
12
|
+
export function useFxAudition(
|
|
13
|
+
chain: HfAudioFxChain,
|
|
14
|
+
onChainPreview: ((chain: HfAudioFxChain) => void) | undefined,
|
|
15
|
+
onAuditionTransport: ((on: boolean) => void) | undefined,
|
|
16
|
+
) {
|
|
17
|
+
/**
|
|
18
|
+
* The chain as it is really stored, captured when an audition starts.
|
|
19
|
+
*
|
|
20
|
+
* Auditioning writes through the preview channel, which does not persist and
|
|
21
|
+
* does not come back as a new `chain` prop — so reverting has to remember what
|
|
22
|
+
* was there rather than read it back. Null means nothing is being auditioned,
|
|
23
|
+
* which is also what makes a stray leave a no-op instead of a write.
|
|
24
|
+
*/
|
|
25
|
+
const auditionBase = useRef<HfAudioFxChain | null>(null);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Play something without committing to it, and put it back on the way out.
|
|
29
|
+
*
|
|
30
|
+
* Hearing a preset before choosing it is the strongest affordance in this
|
|
31
|
+
* panel — see `plans/audio-fx-ux/README.md` §Decided. It costs nothing new:
|
|
32
|
+
* the preview channel a slider drag already uses rebuilds the running graph
|
|
33
|
+
* without touching the document.
|
|
34
|
+
*/
|
|
35
|
+
const audition = useCallback(
|
|
36
|
+
(make: ((base: HfAudioFxChain) => HfAudioFxChain) | null) => {
|
|
37
|
+
if (!onChainPreview) return;
|
|
38
|
+
if (make) {
|
|
39
|
+
auditionBase.current ??= chain;
|
|
40
|
+
onChainPreview(make(auditionBase.current));
|
|
41
|
+
// After the chain is in the graph, not before: starting the transport
|
|
42
|
+
// first plays a moment of the un-auditioned mix.
|
|
43
|
+
onAuditionTransport?.(true);
|
|
44
|
+
} else if (auditionBase.current) {
|
|
45
|
+
// Stop before reverting, for the mirror of that reason — the last thing
|
|
46
|
+
// heard should be the preset, not a frame of the chain coming back.
|
|
47
|
+
onAuditionTransport?.(false);
|
|
48
|
+
onChainPreview(auditionBase.current);
|
|
49
|
+
auditionBase.current = null;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
[chain, onChainPreview, onAuditionTransport],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Drop whatever is being auditioned WITHOUT reverting the preview, for a
|
|
57
|
+
* caller that is about to mutate the real chain anyway — reverting first
|
|
58
|
+
* would be a chain the document never sees, immediately overwritten.
|
|
59
|
+
*/
|
|
60
|
+
const clearAudition = useCallback(() => {
|
|
61
|
+
auditionBase.current = null;
|
|
62
|
+
onAuditionTransport?.(false);
|
|
63
|
+
}, [onAuditionTransport]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The preview handler as of the last render, held rather than closed over.
|
|
67
|
+
*
|
|
68
|
+
* The teardown below must run on teardown and at no other time, so its deps
|
|
69
|
+
* have to be empty — and `onChainPreview` is an inline arrow in the group,
|
|
70
|
+
* which re-renders on every playhead tick to move the automation readouts. A
|
|
71
|
+
* dep on it made React tear down and re-run the effect on every one of those
|
|
72
|
+
* ticks, so an audition reverted itself about 30 times a second while the
|
|
73
|
+
* pointer was still on the button: the preset was heard for a frame during
|
|
74
|
+
* playback, which is the exact case the whole affordance exists for.
|
|
75
|
+
*/
|
|
76
|
+
const previewRef = useRef(onChainPreview);
|
|
77
|
+
previewRef.current = onChainPreview;
|
|
78
|
+
|
|
79
|
+
// Leaving by any route other than the pointer — the element deselected, the
|
|
80
|
+
// panel closed — would otherwise leave the audition playing over a chain the
|
|
81
|
+
// document does not have.
|
|
82
|
+
const transportRef = useRef(onAuditionTransport);
|
|
83
|
+
transportRef.current = onAuditionTransport;
|
|
84
|
+
useEffect(
|
|
85
|
+
() => () => {
|
|
86
|
+
if (auditionBase.current) {
|
|
87
|
+
transportRef.current?.(false);
|
|
88
|
+
previewRef.current?.(auditionBase.current);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { audition, clearAudition };
|
|
95
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report the shape of a chain this panel did not write.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `propertyPanelAudioFxGroup.tsx`, which owned this whole
|
|
5
|
+
* provenance mechanism before the file grew past a size where it was still
|
|
6
|
+
* one thing to read alongside the carve and the leveller.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef } from "react";
|
|
10
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
11
|
+
import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
|
|
12
|
+
import type { HfAutomation } from "@hyperframes/core/audio-automation";
|
|
13
|
+
import { trackChainObserved } from "./audioFxTelemetry.js";
|
|
14
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* This is the only way agent-applied effects become visible. An agent asked to
|
|
18
|
+
* fix a mix does not drive this panel — it edits the composition HTML, or runs
|
|
19
|
+
* `scripts/carve.mjs`, and the rack simply finds the work already done. Not
|
|
20
|
+
* one of the panel's own events fires for any of it.
|
|
21
|
+
*
|
|
22
|
+
* So: watch the chain's shape, and report it when it changes without a panel
|
|
23
|
+
* edit behind it. `panelEdits` is the discriminator — this session's own
|
|
24
|
+
* writes bump it, so a chain that moved while the counter stood still moved
|
|
25
|
+
* because something outside the studio moved it.
|
|
26
|
+
*
|
|
27
|
+
* Keyed on the shape rather than fired once per mount: a soft reload after an
|
|
28
|
+
* agent edits the file re-mounts this component, and a mount-only event would
|
|
29
|
+
* either miss the change or double-count every HMR. Comparing the fingerprint
|
|
30
|
+
* reports real changes and stays quiet through both.
|
|
31
|
+
*
|
|
32
|
+
* Returns a wrapped `onSetAttributeQuiet` that every other write in the panel
|
|
33
|
+
* must go through instead of the raw prop: the count is only meaningful if it
|
|
34
|
+
* is exhaustive, and a write added later that forgot to route through here
|
|
35
|
+
* would silently start reporting the author's own edits as having come from
|
|
36
|
+
* outside.
|
|
37
|
+
*/
|
|
38
|
+
export function useFxChainObserved(
|
|
39
|
+
element: DomEditSelection,
|
|
40
|
+
chain: HfAudioFxChain,
|
|
41
|
+
carve: HfCarveSettings | null,
|
|
42
|
+
automation: HfAutomation,
|
|
43
|
+
onSetAttributeQuietRaw: (attr: string, value: string | null) => void | Promise<void>,
|
|
44
|
+
): (attr: string, value: string | null) => void | Promise<void> {
|
|
45
|
+
const lastShape = useRef<string | null>(null);
|
|
46
|
+
const panelEdits = useRef(0);
|
|
47
|
+
|
|
48
|
+
const onSetAttributeQuiet = (attr: string, value: string | null): void | Promise<void> => {
|
|
49
|
+
panelEdits.current += 1;
|
|
50
|
+
return onSetAttributeQuietRaw(attr, value);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const shape = JSON.stringify([
|
|
55
|
+
chain.nodes.map((n) => `${n.type}:${n.fromPreset ?? ""}:${n.fromCarve ? 1 : 0}`),
|
|
56
|
+
carve?.enabled ?? false,
|
|
57
|
+
automation.lanes.length,
|
|
58
|
+
]);
|
|
59
|
+
if (lastShape.current === shape) return;
|
|
60
|
+
const firstSight = lastShape.current === null;
|
|
61
|
+
lastShape.current = shape;
|
|
62
|
+
// An empty chain on first sight is the ordinary case — nothing to report.
|
|
63
|
+
if (firstSight && chain.nodes.length === 0 && !carve) return;
|
|
64
|
+
trackChainObserved(
|
|
65
|
+
chain,
|
|
66
|
+
{
|
|
67
|
+
firstSight,
|
|
68
|
+
panelEdits: panelEdits.current,
|
|
69
|
+
hasCarve: Boolean(carve?.enabled),
|
|
70
|
+
hasAutomation: automation.lanes.length > 0,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
trackKind: classifyAudioName(element.id, element.element?.getAttribute("src")) ?? undefined,
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
// Each observation is measured against the edits made SINCE the last one, so
|
|
77
|
+
// a session that edits, then receives an outside change, still reports that
|
|
78
|
+
// second change as unattributed.
|
|
79
|
+
panelEdits.current = 0;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return onSetAttributeQuiet;
|
|
83
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The levelling script and its playhead-transport audition: measure this
|
|
3
|
+
* track, write the "Even Out Levels" node and lane, and preview the result
|
|
4
|
+
* before committing to it.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `propertyPanelAudioFxGroup.tsx`, which owned all of this before
|
|
7
|
+
* the file grew past a size where "levelling" was still one thing to read.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useRef, useState } from "react";
|
|
11
|
+
import {
|
|
12
|
+
HF_AUDIO_FX_ATTR,
|
|
13
|
+
serializeAudioFxChain,
|
|
14
|
+
type HfAudioFxChain,
|
|
15
|
+
} from "@hyperframes/core/audio-fx";
|
|
16
|
+
import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
|
|
17
|
+
import type { HfAutomation } from "@hyperframes/core/audio-automation";
|
|
18
|
+
import {
|
|
19
|
+
automationAttrValue,
|
|
20
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
21
|
+
withLane,
|
|
22
|
+
withoutLane,
|
|
23
|
+
} from "./propertyPanelAutomation";
|
|
24
|
+
import { trackLeveller } from "./audioFxTelemetry.js";
|
|
25
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
26
|
+
import { usePlayerStore } from "../../player";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rate the track is decoded at. Analysis is self-consistent because it reads
|
|
30
|
+
* the decoded buffer's own rate, so this only has to be a sane audio rate.
|
|
31
|
+
*/
|
|
32
|
+
const DECODE_SAMPLE_RATE = 48000;
|
|
33
|
+
|
|
34
|
+
/** A parsed attribute, kept only when it is a usable positive number. */
|
|
35
|
+
function positiveFinite(n: number): number | null {
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useFxLevelling(
|
|
40
|
+
element: DomEditSelection,
|
|
41
|
+
chain: HfAudioFxChain,
|
|
42
|
+
automation: HfAutomation,
|
|
43
|
+
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
|
|
44
|
+
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>,
|
|
45
|
+
setAnalysing: (value: boolean) => void,
|
|
46
|
+
) {
|
|
47
|
+
/**
|
|
48
|
+
* This track's audio, decoded once and kept.
|
|
49
|
+
*
|
|
50
|
+
* Levelling is measured from it, and hover-auditioning means measuring on every
|
|
51
|
+
* pass over the button — fetching and decoding a several-minute voiceover each
|
|
52
|
+
* time would make the audition slower than the thing it is previewing. Keyed by
|
|
53
|
+
* `src` so a track pointed at a different file re-decodes.
|
|
54
|
+
*/
|
|
55
|
+
const decoded = useRef<{ src: string; samples: Float32Array; sampleRate: number } | null>(null);
|
|
56
|
+
|
|
57
|
+
const decodeTrack = async (): Promise<{ samples: Float32Array; sampleRate: number } | null> => {
|
|
58
|
+
const el = element.element;
|
|
59
|
+
const src = el?.getAttribute("src");
|
|
60
|
+
const doc = el?.ownerDocument;
|
|
61
|
+
if (!src || !doc) return null;
|
|
62
|
+
const cached = decoded.current;
|
|
63
|
+
if (cached?.src === src) return cached;
|
|
64
|
+
const Ctor =
|
|
65
|
+
window.OfflineAudioContext ??
|
|
66
|
+
(window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
|
|
67
|
+
.webkitOfflineAudioContext;
|
|
68
|
+
if (!Ctor) return null;
|
|
69
|
+
const res = await fetch(new URL(src, doc.baseURI).href);
|
|
70
|
+
const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
|
|
71
|
+
await res.arrayBuffer(),
|
|
72
|
+
);
|
|
73
|
+
const next = { src, samples: buffer.getChannelData(0), sampleRate: buffer.sampleRate };
|
|
74
|
+
decoded.current = next;
|
|
75
|
+
return next;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The part of the decoded file this clip actually plays.
|
|
80
|
+
*
|
|
81
|
+
* A lane's `t` is seconds from the start of the CLIP, but the decode is the
|
|
82
|
+
* whole file from its first sample — so measuring a trimmed clip produced an
|
|
83
|
+
* envelope offset by the trim, and every correction landed early by exactly
|
|
84
|
+
* `media-start`. Slicing here is what puts the two clocks back on the same
|
|
85
|
+
* zero.
|
|
86
|
+
*/
|
|
87
|
+
const clipWindow = (audio: { samples: Float32Array; sampleRate: number }) => {
|
|
88
|
+
const mediaStart = positiveFinite(Number(element.dataAttributes?.["media-start"] ?? 0));
|
|
89
|
+
const duration = positiveFinite(Number(element.dataAttributes?.["duration"] ?? Number.NaN));
|
|
90
|
+
const from = mediaStart
|
|
91
|
+
? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
|
|
92
|
+
: 0;
|
|
93
|
+
const to = duration
|
|
94
|
+
? Math.min(audio.samples.length, from + Math.ceil(duration * audio.sampleRate))
|
|
95
|
+
: audio.samples.length;
|
|
96
|
+
return from === 0 && to === audio.samples.length
|
|
97
|
+
? audio.samples
|
|
98
|
+
: audio.samples.subarray(from, to);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const runLeveller = async (): Promise<void> => {
|
|
102
|
+
setAnalysing(true);
|
|
103
|
+
try {
|
|
104
|
+
const audio = await decodeTrack();
|
|
105
|
+
if (!audio) return;
|
|
106
|
+
const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
|
|
107
|
+
if (!result) return;
|
|
108
|
+
trackLeveller("run");
|
|
109
|
+
await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
|
|
110
|
+
// Merged by target, never written wholesale: the script describes its own
|
|
111
|
+
// lane only, and replacing the attribute would take the carve's lanes and
|
|
112
|
+
// the volume lane with it.
|
|
113
|
+
const lane = result.automation.lanes[0];
|
|
114
|
+
if (lane) {
|
|
115
|
+
void onSetAttributeQuiet(
|
|
116
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
117
|
+
automationAttrValue(withLane(automation, lane)) || null,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// A track whose audio cannot be fetched or decoded simply gets no
|
|
122
|
+
// levelling, the same way an unreadable carve source is skipped.
|
|
123
|
+
} finally {
|
|
124
|
+
setAnalysing(false);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where the playhead was when an audition started the transport, so leaving
|
|
130
|
+
* can put it back. Null means this audition did not start playback — the
|
|
131
|
+
* transport was already running and must be left alone.
|
|
132
|
+
*/
|
|
133
|
+
const auditionReturn = useRef<number | null>(null);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Start playback for an audition, and stop it again on the way out.
|
|
137
|
+
*
|
|
138
|
+
* An audition writes the preset to the running graph, which is silent while
|
|
139
|
+
* the transport is paused — so a paused author hovering a preset heard
|
|
140
|
+
* nothing at all, and the whole affordance only worked mid-playback. Hovering
|
|
141
|
+
* now plays from the playhead, and leaving stops and rewinds to exactly where
|
|
142
|
+
* it started: browsing the shelf must not cost the author their place.
|
|
143
|
+
*
|
|
144
|
+
* Already playing, this does nothing in either direction. The author started
|
|
145
|
+
* that, and stopping their transport because they passed over a preset would
|
|
146
|
+
* be the panel taking a decision that was not offered to it.
|
|
147
|
+
*/
|
|
148
|
+
const auditionTransport = (on: boolean): void => {
|
|
149
|
+
const store = usePlayerStore.getState();
|
|
150
|
+
if (on) {
|
|
151
|
+
if (store.isPlaying || auditionReturn.current !== null) return;
|
|
152
|
+
auditionReturn.current = store.currentTime;
|
|
153
|
+
store.requestPlayback(true);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const returnTo = auditionReturn.current;
|
|
157
|
+
if (returnTo === null) return;
|
|
158
|
+
auditionReturn.current = null;
|
|
159
|
+
store.requestPlayback(false, returnTo);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const [auditioningLevel, setAuditioningLevel] = useState(false);
|
|
163
|
+
/**
|
|
164
|
+
* Bumped on every enter and leave, so a measurement can tell whether the
|
|
165
|
+
* pointer is still on the button when it finishes.
|
|
166
|
+
*
|
|
167
|
+
* Decoding a long voiceover takes seconds, and a hover that takes seconds is
|
|
168
|
+
* one the author has usually already left. Applying the result then would put
|
|
169
|
+
* levelling on a track nobody asked to level, through a channel that does not
|
|
170
|
+
* persist — so it would be audible, invisible in the document, and gone on the
|
|
171
|
+
* next reload. This counter is what makes a late result a no-op.
|
|
172
|
+
*/
|
|
173
|
+
const auditionRun = useRef(0);
|
|
174
|
+
|
|
175
|
+
/** Both attributes back to the stored chain, because levelling is a node AND the lane that drives it. */
|
|
176
|
+
const stopLevelAudition = (): void => {
|
|
177
|
+
setAuditioningLevel(false);
|
|
178
|
+
void onSetAttributeLive(
|
|
179
|
+
HF_AUDIO_FX_ATTR,
|
|
180
|
+
chain.nodes.length ? serializeAudioFxChain(chain) : null,
|
|
181
|
+
);
|
|
182
|
+
void onSetAttributeLive(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(automation) || null);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Measure this track and play the levelling without persisting it. */
|
|
186
|
+
const startLevelAudition = async (run: number): Promise<void> => {
|
|
187
|
+
setAuditioningLevel(true);
|
|
188
|
+
try {
|
|
189
|
+
const audio = await decodeTrack();
|
|
190
|
+
// Gone, or superseded by a later hover. Either way this result is stale.
|
|
191
|
+
if (!audio || run !== auditionRun.current) return;
|
|
192
|
+
const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
|
|
193
|
+
if (!result || run !== auditionRun.current) return;
|
|
194
|
+
void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
|
|
195
|
+
const lane = result.automation.lanes[0];
|
|
196
|
+
if (lane) {
|
|
197
|
+
void onSetAttributeLive(
|
|
198
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
199
|
+
automationAttrValue(withLane(automation, lane)) || null,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// Same as the real run: a track that cannot be decoded simply does not
|
|
204
|
+
// audition, rather than failing the panel.
|
|
205
|
+
} finally {
|
|
206
|
+
if (run === auditionRun.current) setAuditioningLevel(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `false` puts the stored chain and automation back; `true` measures and
|
|
212
|
+
* plays the result without persisting it.
|
|
213
|
+
*/
|
|
214
|
+
const auditionLevel = async (on: boolean): Promise<void> => {
|
|
215
|
+
const run = ++auditionRun.current;
|
|
216
|
+
if (!on) return stopLevelAudition();
|
|
217
|
+
await startLevelAudition(run);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const removeLeveller = (): void => {
|
|
221
|
+
trackLeveller("removed");
|
|
222
|
+
const { chain: next, removedTarget } = removeLevelling(chain);
|
|
223
|
+
void onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
|
|
224
|
+
// The lane goes with the node. An orphan keeps driving a parameter that is
|
|
225
|
+
// no longer in the graph.
|
|
226
|
+
if (removedTarget) {
|
|
227
|
+
void onSetAttributeQuiet(
|
|
228
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
229
|
+
automationAttrValue(withoutLane(automation, removedTarget)) || null,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return { runLeveller, auditionTransport, auditioningLevel, auditionLevel, removeLeveller };
|
|
235
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { useVolumeAutomation, type VolumeAutomationBinding } from "./useVolumeAutomation";
|
|
6
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
function bind(dataAttributes: Record<string, string>) {
|
|
11
|
+
const onSetAttributeQuiet = vi.fn();
|
|
12
|
+
const captured: { current: VolumeAutomationBinding | null } = { current: null };
|
|
13
|
+
function Probe() {
|
|
14
|
+
captured.current = useVolumeAutomation(
|
|
15
|
+
{ dataAttributes } as unknown as DomEditSelection,
|
|
16
|
+
onSetAttributeQuiet,
|
|
17
|
+
);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const host = document.createElement("div");
|
|
21
|
+
document.body.append(host);
|
|
22
|
+
act(() => {
|
|
23
|
+
createRoot(host).render(<Probe />);
|
|
24
|
+
});
|
|
25
|
+
if (!captured.current) throw new Error("hook never ran");
|
|
26
|
+
return { binding: captured.current, onSetAttributeQuiet };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const volumeLane = (v: number) =>
|
|
30
|
+
JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v }] }] });
|
|
31
|
+
|
|
32
|
+
describe("useVolumeAutomation", () => {
|
|
33
|
+
it("reports an unautomated track", () => {
|
|
34
|
+
expect(bind({ volume: "0.55" }).binding.volumeAutomated).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reports a track with a volume lane", () => {
|
|
38
|
+
expect(bind({ volume: "0.55", automation: volumeLane(0.2) }).binding.volumeAutomated).toBe(
|
|
39
|
+
true,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not count an FX lane as automating the volume", () => {
|
|
44
|
+
const automation = JSON.stringify({
|
|
45
|
+
version: 1,
|
|
46
|
+
lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
|
|
47
|
+
});
|
|
48
|
+
expect(bind({ volume: "0.55", automation }).binding.volumeAutomated).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("seeds a new lane at the level the slider already shows", () => {
|
|
52
|
+
// Automating a track must not change how loud it is.
|
|
53
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "0.55" });
|
|
54
|
+
act(() => binding.onAutomateVolume());
|
|
55
|
+
expect(onSetAttributeQuiet).toHaveBeenCalledWith(
|
|
56
|
+
"data-automation",
|
|
57
|
+
JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v: 0.55 }] }] }),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("treats a missing data-volume as unity", () => {
|
|
62
|
+
const { binding, onSetAttributeQuiet } = bind({});
|
|
63
|
+
act(() => binding.onAutomateVolume());
|
|
64
|
+
expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("keeps FX lanes when adding the volume one", () => {
|
|
68
|
+
const automation = JSON.stringify({
|
|
69
|
+
version: 1,
|
|
70
|
+
lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
|
|
71
|
+
});
|
|
72
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation });
|
|
73
|
+
act(() => binding.onAutomateVolume());
|
|
74
|
+
expect(
|
|
75
|
+
JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes.map(
|
|
76
|
+
(l: { target: string }) => l.target,
|
|
77
|
+
),
|
|
78
|
+
).toEqual(["fx.n1.frequency", "volume"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("deletes only the volume lane", () => {
|
|
82
|
+
const automation = JSON.stringify({
|
|
83
|
+
version: 1,
|
|
84
|
+
lanes: [
|
|
85
|
+
{ target: "volume", points: [{ t: 0, v: 0.2 }] },
|
|
86
|
+
{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation });
|
|
90
|
+
act(() => binding.onRemoveVolumeAutomation());
|
|
91
|
+
expect(
|
|
92
|
+
JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes.map(
|
|
93
|
+
(l: { target: string }) => l.target,
|
|
94
|
+
),
|
|
95
|
+
).toEqual(["fx.n1.frequency"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("clears the attribute when the volume lane was the only one", () => {
|
|
99
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation: volumeLane(0.2) });
|
|
100
|
+
act(() => binding.onRemoveVolumeAutomation());
|
|
101
|
+
// Null, not "": the quiet path removes an attribute it is given null for.
|
|
102
|
+
expect(onSetAttributeQuiet).toHaveBeenCalledWith("data-automation", null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("reads an unreadable attribute as no automation", () => {
|
|
106
|
+
expect(bind({ volume: "0.55", automation: "{not json" }).binding.volumeAutomated).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("seeds at unity when data-volume is present but empty", () => {
|
|
110
|
+
// Number("") is 0, so `?? "1"` alone seeded the lane at silence while the
|
|
111
|
+
// engine read the same empty attribute as unity.
|
|
112
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "" });
|
|
113
|
+
act(() => binding.onAutomateVolume());
|
|
114
|
+
expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("seeds at unity when data-volume is not a number", () => {
|
|
118
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "loud" });
|
|
119
|
+
act(() => binding.onAutomateVolume());
|
|
120
|
+
expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The volume lane's state and edits for the media section.
|
|
3
|
+
*
|
|
4
|
+
* Volume lives in a different panel section from the FX chain, but is automated
|
|
5
|
+
* the same way, so it reads and writes through the same helper the FX group uses
|
|
6
|
+
* rather than a second interpretation of the attribute.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { HF_AUDIO_AUTOMATION_DATA_KEY, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
|
|
10
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
11
|
+
import {
|
|
12
|
+
automationAttrValue,
|
|
13
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
14
|
+
readPanelAutomation,
|
|
15
|
+
withoutLane,
|
|
16
|
+
withSeededLane,
|
|
17
|
+
} from "./propertyPanelAutomation";
|
|
18
|
+
|
|
19
|
+
export interface VolumeAutomationBinding {
|
|
20
|
+
volumeAutomated: boolean;
|
|
21
|
+
onAutomateVolume: () => void;
|
|
22
|
+
onRemoveVolumeAutomation: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useVolumeAutomation(
|
|
26
|
+
element: DomEditSelection,
|
|
27
|
+
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
|
|
28
|
+
): VolumeAutomationBinding {
|
|
29
|
+
// The chain is not needed to resolve a volume lane — volume is always a valid
|
|
30
|
+
// target — so this deliberately does not parse it.
|
|
31
|
+
const automation = readPanelAutomation(
|
|
32
|
+
element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY],
|
|
33
|
+
undefined,
|
|
34
|
+
);
|
|
35
|
+
const write = (next: Parameters<typeof automationAttrValue>[0]): void => {
|
|
36
|
+
// Quiet: clicking the toggle used to reload the preview and restart every
|
|
37
|
+
// playing track, while the same click on an effect parameter did not.
|
|
38
|
+
void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null);
|
|
39
|
+
};
|
|
40
|
+
// `??` alone would let an empty `data-volume` through as Number("") === 0, so
|
|
41
|
+
// automating the track would seed its lane at silence. The engine reads the same
|
|
42
|
+
// empty value as unity.
|
|
43
|
+
const raw = element.dataAttributes?.["volume"];
|
|
44
|
+
const parsed = raw ? Number(raw) : 1;
|
|
45
|
+
const current = Number.isFinite(parsed) ? parsed : 1;
|
|
46
|
+
return {
|
|
47
|
+
volumeAutomated: automation.lanes.some((lane) => lane.target === VOLUME_TARGET),
|
|
48
|
+
// Seeded at the level the slider already shows, so automating the track does
|
|
49
|
+
// not change how loud it is.
|
|
50
|
+
onAutomateVolume: () =>
|
|
51
|
+
write(withSeededLane(automation, VOLUME_TARGET, Number.isFinite(current) ? current : 1)),
|
|
52
|
+
onRemoveVolumeAutomation: () => write(withoutLane(automation, VOLUME_TARGET)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -15,6 +15,7 @@ export interface DomEditActionsValue extends Pick<
|
|
|
15
15
|
| "handleDomStyleCommit"
|
|
16
16
|
| "handleDomAttributeCommit"
|
|
17
17
|
| "handleDomAttributeLiveCommit"
|
|
18
|
+
| "handleDomAttributeQuietCommit"
|
|
18
19
|
| "handleDomHtmlAttributeCommit"
|
|
19
20
|
| "handleDomAttributesCommit"
|
|
20
21
|
| "handleDomPathOffsetCommit"
|
|
@@ -116,6 +117,13 @@ export function useDomEditSelectionContext(): DomEditSelectionValue {
|
|
|
116
117
|
return ctx;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/** Optional counterpart to useDomEditActionsContextOptional — same reason: the
|
|
121
|
+
* player package's own components mount outside a provider in standalone and
|
|
122
|
+
* test trees, where "no dom-edit selection" is the correct answer. */
|
|
123
|
+
export function useDomEditSelectionContextOptional(): DomEditSelectionValue | null {
|
|
124
|
+
return useContext(DomEditSelectionContext);
|
|
125
|
+
}
|
|
126
|
+
|
|
119
127
|
/** @deprecated Prefer useDomEditActionsContext or useDomEditSelectionContext. */
|
|
120
128
|
export function useDomEditContext(): DomEditValue {
|
|
121
129
|
return { ...useDomEditActionsContext(), ...useDomEditSelectionContext() };
|
|
@@ -140,6 +148,7 @@ export function DomEditProvider({
|
|
|
140
148
|
handleDomStyleCommit,
|
|
141
149
|
handleDomAttributeCommit,
|
|
142
150
|
handleDomAttributeLiveCommit,
|
|
151
|
+
handleDomAttributeQuietCommit,
|
|
143
152
|
handleDomHtmlAttributeCommit,
|
|
144
153
|
handleDomAttributesCommit,
|
|
145
154
|
handleDomPathOffsetCommit,
|
|
@@ -229,6 +238,7 @@ export function DomEditProvider({
|
|
|
229
238
|
handleDomStyleCommit,
|
|
230
239
|
handleDomAttributeCommit,
|
|
231
240
|
handleDomAttributeLiveCommit,
|
|
241
|
+
handleDomAttributeQuietCommit,
|
|
232
242
|
handleDomHtmlAttributeCommit,
|
|
233
243
|
handleDomAttributesCommit,
|
|
234
244
|
handleDomPathOffsetCommit,
|
|
@@ -299,6 +309,7 @@ export function DomEditProvider({
|
|
|
299
309
|
handleDomStyleCommit,
|
|
300
310
|
handleDomAttributeCommit,
|
|
301
311
|
handleDomAttributeLiveCommit,
|
|
312
|
+
handleDomAttributeQuietCommit,
|
|
302
313
|
handleDomHtmlAttributeCommit,
|
|
303
314
|
handleDomAttributesCommit,
|
|
304
315
|
handleDomPathOffsetCommit,
|