diff --git a/packages/cli/src/server/telemetryIdentity.test.ts b/packages/cli/src/server/telemetryIdentity.test.ts
index 5fb1b432d..e95f30582 100644
--- a/packages/cli/src/server/telemetryIdentity.test.ts
+++ b/packages/cli/src/server/telemetryIdentity.test.ts
@@ -17,6 +17,11 @@ const canaryDecisions = vi.fn<() => Record string | null>();
vi.mock("../telemetry/client.js", () => ({
shouldTrack: (...args: unknown[]) => shouldTrack(...args),
@@ -26,6 +31,9 @@ vi.mock("../telemetry/config.js", () => ({
readConfig: (...args: unknown[]) => readConfig(...args),
readConfigFresh: () => readConfigFresh(),
}));
+vi.mock("../telemetry/agent_runtime.js", () => ({
+ detectAgentRuntime: () => detectAgent(),
+}));
vi.mock("../telemetry/canary.js", () => ({
canaryDecisionsForStudio: () => canaryDecisions(),
}));
@@ -46,6 +54,10 @@ describe("resolveCliTelemetryDistinctId", () => {
readConfig.mockReset();
canaryDecisions.mockReset();
canaryDecisions.mockReturnValue({});
+ detectAgent.mockReset();
+ // No agent is the default so the existing assertions keep describing the
+ // ordinary case: a person at a terminal.
+ detectAgent.mockReturnValue(null);
});
it("returns the CLI anonymousId when telemetry is enabled", () => {
@@ -393,3 +405,61 @@ describe("cross-process opt-out refresh", () => {
expect(after).not.toContain("__HF_CLI_BUCKET_SEED");
});
});
+
+/**
+ * Publishing which agent, if any, drives the CLI.
+ *
+ * Studio has no way to detect this — the signal is in the CLI process
+ * environment, which the browser never sees — so this injection is the only
+ * path by which an agent-driven session can ever be labelled as one.
+ */
+describe("buildCliIdentityScript agent runtime", () => {
+ beforeEach(() => {
+ shouldTrack.mockReset();
+ readConfig.mockReset();
+ readConfig.mockReturnValue({});
+ canaryDecisions.mockReset();
+ canaryDecisions.mockReturnValue({});
+ detectAgent.mockReset();
+ detectAgent.mockReturnValue(null);
+ });
+
+ it("publishes the agent when one is driving the CLI", () => {
+ shouldTrack.mockReturnValue(true);
+ detectAgent.mockReturnValue("claude_code");
+ expect(buildCliIdentityScript()).toContain('window.__HF_CLI_AGENT_RUNTIME="claude_code";');
+ });
+
+ it("publishes nothing when a person is driving it", () => {
+ shouldTrack.mockReturnValue(true);
+ expect(buildCliIdentityScript()).not.toContain("__HF_CLI_AGENT_RUNTIME");
+ });
+
+ it("stays silent when telemetry is off, even under an agent", () => {
+ // Unlike the canary decisions, which Studio needs in order NOT to enrol,
+ // this is only ever read to label an event. With telemetry off there is no
+ // event, so publishing it would leave a marker in the page of someone who
+ // asked not to be measured.
+ shouldTrack.mockReturnValue(false);
+ detectAgent.mockReturnValue("claude_code");
+ expect(buildCliIdentityScript()).not.toContain("__HF_CLI_AGENT_RUNTIME");
+ });
+
+ it("publishes it without an identity when the Host is not trusted", () => {
+ // The value is a category, not an id — a LAN Studio should still be able to
+ // say an agent opened it, the same way it still receives canary decisions.
+ shouldTrack.mockReturnValue(true);
+ detectAgent.mockReturnValue("codex");
+ const script = buildCliIdentityScript({ includeIdentity: false });
+ expect(script).toContain('window.__HF_CLI_AGENT_RUNTIME="codex";');
+ expect(script).not.toContain("__HF_CLI_DISTINCT_ID");
+ });
+
+ it("escapes a value that tries to close the script tag", () => {
+ shouldTrack.mockReturnValue(true);
+ detectAgent.mockReturnValue("");
+ const script = buildCliIdentityScript();
+ expect(script).not.toContain("`;
}
diff --git a/packages/studio/src/components/editor/audioFxTelemetry.test.ts b/packages/studio/src/components/editor/audioFxTelemetry.test.ts
new file mode 100644
index 000000000..9a8e4d001
--- /dev/null
+++ b/packages/studio/src/components/editor/audioFxTelemetry.test.ts
@@ -0,0 +1,129 @@
+// @vitest-environment happy-dom
+
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
+
+const trackStudioEvent = vi.fn();
+vi.mock("../../utils/studioTelemetry", () => ({
+ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
+}));
+
+const { chainShape, nodeOrigin, trackChainObserved, trackParamCommitted, trackPresetApplied } =
+ await import("./audioFxTelemetry");
+
+beforeEach(() => trackStudioEvent.mockReset());
+
+const chainOf = (nodes: HfAudioFxChain["nodes"]): HfAudioFxChain => ({ version: 1, nodes });
+
+describe("what the rack reports", () => {
+ it("namespaces every event so the feature can be found as one thing", () => {
+ trackPresetApplied("voice-clean", "voice", 5, "append", { trackKind: "voice" });
+ expect(trackStudioEvent).toHaveBeenCalledWith("audio_fx_preset_applied", expect.anything());
+ });
+
+ it("carries what the track is, so a preset's use can be read per material", () => {
+ trackPresetApplied("telephone", "character", 7, "append", { trackKind: "music" });
+ expect(trackStudioEvent.mock.calls[0]?.[1]).toMatchObject({
+ preset: "telephone",
+ family: "character",
+ track_kind: "music",
+ });
+ });
+
+ it("says 'unknown' rather than nothing when the track is unclassified", () => {
+ // Absent and "unclassified" are different facts; encoding them the same way
+ // is what makes a breakdown read as a gap that is not there.
+ trackPresetApplied("hall", "space", 1, "append", {});
+ expect(trackStudioEvent.mock.calls[0]?.[1]).toMatchObject({ track_kind: "unknown" });
+ });
+
+ it("separates the two surfaces a parameter can be set from", () => {
+ // The whole one-knob design rests on which of these people use.
+ trackParamCommitted("compressor", "ratio", 4, "details", {});
+ expect(trackStudioEvent.mock.calls[0]?.[1]).toMatchObject({
+ effect: "compressor",
+ param: "ratio",
+ value: 4,
+ surface: "details",
+ });
+ });
+});
+
+describe("a chain reduced to counts", () => {
+ it("names where each node came from", () => {
+ expect(nodeOrigin({ type: "peaking", fromPreset: "voice-clean" })).toBe("preset");
+ expect(nodeOrigin({ type: "peaking", fromCarve: true })).toBe("carve");
+ expect(nodeOrigin({ type: "gain", fromLeveller: true })).toBe("leveller");
+ expect(nodeOrigin({ type: "highpass" })).toBe("hand");
+ });
+
+ it("counts by origin, which is what separates hand-built from generated", () => {
+ const shape = chainShape(
+ chainOf([
+ { type: "highpass", fromPreset: "voice-clean" },
+ { type: "peaking", fromPreset: "voice-clean" },
+ { type: "peaking", fromCarve: true },
+ { type: "lowpass" },
+ ]),
+ );
+ expect(shape).toMatchObject({
+ node_count: 4,
+ nodes_from_preset: 2,
+ nodes_from_carve: 1,
+ nodes_by_hand: 1,
+ preset_count: 1,
+ });
+ });
+
+ it("counts distinct presets, not preset nodes — stacking is the question", () => {
+ const shape = chainShape(
+ chainOf([
+ { type: "highpass", fromPreset: "voice-clean" },
+ { type: "lowpass", fromPreset: "telephone" },
+ { type: "peaking", fromPreset: "telephone" },
+ ]),
+ );
+ expect(shape.preset_count).toBe(2);
+ });
+
+ it("survives an empty or absent chain", () => {
+ expect(chainShape(null)).toMatchObject({ node_count: 0, preset_count: 0 });
+ });
+
+ it("ships no chain contents — only counts and flags", () => {
+ // The standing rule: nothing user-authored leaves the browser. A parameter
+ // value or an element id in here would be the author's own data.
+ const shape = chainShape(chainOf([{ type: "peaking", id: "n1", params: { frequency: 3000 } }]));
+ const serialized = JSON.stringify(shape);
+ expect(serialized).not.toContain("3000");
+ expect(serialized).not.toContain("n1");
+ });
+});
+
+describe("effects this session did not apply", () => {
+ it("calls a chain with no panel edits behind it authored outside", () => {
+ // This is the agent signal. An agent edits the composition and the panel
+ // simply finds the work done — none of its own events ever fire.
+ trackChainObserved(
+ chainOf([{ type: "highpass", fromPreset: "voice-clean" }]),
+ { firstSight: true, panelEdits: 0, hasCarve: false, hasAutomation: false },
+ { trackKind: "voice" },
+ );
+ expect(trackStudioEvent).toHaveBeenCalledWith(
+ "audio_fx_chain_observed",
+ expect.objectContaining({ authored_outside: true, first_sight: true }),
+ );
+ });
+
+ it("does not, when this session's own edits explain it", () => {
+ trackChainObserved(
+ chainOf([{ type: "highpass" }]),
+ { firstSight: false, panelEdits: 3, hasCarve: false, hasAutomation: false },
+ {},
+ );
+ expect(trackStudioEvent.mock.calls[0]?.[1]).toMatchObject({
+ authored_outside: false,
+ panel_edits: 3,
+ });
+ });
+});
diff --git a/packages/studio/src/components/editor/audioFxTelemetry.ts b/packages/studio/src/components/editor/audioFxTelemetry.ts
new file mode 100644
index 000000000..2ec5b9a16
--- /dev/null
+++ b/packages/studio/src/components/editor/audioFxTelemetry.ts
@@ -0,0 +1,230 @@
+/**
+ * What the audio FX rack reports about itself.
+ *
+ * One module rather than `trackStudioEvent` calls scattered through six
+ * components, for two reasons. The event names and property shapes have to
+ * agree across the panel or a dashboard cannot join them — and everything here
+ * is subject to one rule that is easy to break a callsite at a time:
+ *
+ * **Nothing user-authored leaves the browser.** Not element ids, not media
+ * filenames, not composition paths, not chain JSON. Every property below is
+ * either a fixed identifier from our own catalogue (a preset id, an effect
+ * type, a parameter key), a number, or a boolean. `studioTelemetry.ts` already
+ * strips the query string off `url_hash` for exactly this reason — the ids in
+ * it are the author's own. The rack sees the same class of data and must hold
+ * the same line.
+ *
+ * The second rule is about volume: **commit, not preview.** Every control in
+ * the rack has a preview path that fires continuously while a slider moves and
+ * a commit path that fires once when it is released. Only the commit path
+ * belongs here. Wiring a param event to the preview would emit tens of events
+ * per drag, which is both a cost and a lie — an author who nudges a knob and
+ * puts it back did not make thirty decisions.
+ */
+
+import { trackStudioEvent } from "../../utils/studioTelemetry";
+import type { HfAudioFxChain, HfAudioFxNode } from "@hyperframes/core/audio-fx";
+
+/** Kept narrow deliberately — see the "nothing user-authored" rule above. */
+type FxEventProperties = Record;
+
+function track(event: string, properties: FxEventProperties = {}): void {
+ trackStudioEvent(`audio_fx_${event}`, properties);
+}
+
+/** Where a node in the chain came from, as one word. */
+export function nodeOrigin(node: HfAudioFxNode): string {
+ if (node.fromPreset) return "preset";
+ if (node.fromCarve) return "carve";
+ if (node.fromEq) return "eq";
+ if (node.fromLeveller) return "leveller";
+ return "hand";
+}
+
+/**
+ * A chain reduced to counts.
+ *
+ * Counts rather than contents: "four nodes, two of them from a preset" answers
+ * how the rack is used without shipping what the author built. The origin mix
+ * is the interesting half — it separates a chain somebody assembled by hand
+ * from one a preset wrote from one an analysis wrote.
+ */
+export function chainShape(chain: HfAudioFxChain | null): FxEventProperties {
+ const nodes = chain?.nodes ?? [];
+ const byOrigin = new Map();
+ for (const node of nodes) {
+ const origin = nodeOrigin(node);
+ byOrigin.set(origin, (byOrigin.get(origin) ?? 0) + 1);
+ }
+ return {
+ node_count: nodes.length,
+ nodes_from_preset: byOrigin.get("preset") ?? 0,
+ nodes_from_carve: byOrigin.get("carve") ?? 0,
+ nodes_from_eq: byOrigin.get("eq") ?? 0,
+ nodes_from_leveller: byOrigin.get("leveller") ?? 0,
+ nodes_by_hand: byOrigin.get("hand") ?? 0,
+ // How many distinct presets are live on this track. Stacking a character
+ // preset onto a cleaned voice is a supported thing to want, and this is the
+ // only way to find out whether anybody does it.
+ preset_count: new Set(nodes.map((n) => n.fromPreset).filter(Boolean)).size,
+ bypassed_count: nodes.filter((n) => n.enabled === false).length,
+ };
+}
+
+export interface FxTrackContext {
+ /** What the track reads as — `voice` / `music` / `sfx` / `unknown`. */
+ trackKind?: string;
+}
+
+const ctx = (c: FxTrackContext): FxEventProperties => ({ track_kind: c.trackKind ?? "unknown" });
+
+// --- presets ---------------------------------------------------------------
+
+export function trackPresetApplied(
+ presetId: string,
+ family: string,
+ nodeCount: number,
+ mode: "append" | "replace" | "reapply",
+ c: FxTrackContext,
+): void {
+ track("preset_applied", { preset: presetId, family, node_count: nodeCount, mode, ...ctx(c) });
+}
+
+export function trackPresetRemoved(presetId: string, c: FxTrackContext): void {
+ track("preset_removed", { preset: presetId, ...ctx(c) });
+}
+
+/** The whole-preset wet/dry knob, on release. `amount` is 0..1. */
+export function trackPresetAmount(presetId: string, amount: number, c: FxTrackContext): void {
+ track("preset_amount", { preset: presetId, amount, ...ctx(c) });
+}
+
+export function trackPresetAutomated(presetId: string, on: boolean, c: FxTrackContext): void {
+ track("preset_automated", { preset: presetId, enabled: on, ...ctx(c) });
+}
+
+/**
+ * Hover-auditioning a preset from the shelf.
+ *
+ * Fired once per preset per time the shelf is opened, not once per hover: a
+ * pointer crossing the shelf passes over a dozen items in a second, and
+ * counting those would drown every other event in the feature and describe
+ * mouse travel rather than interest.
+ */
+export function trackPresetAuditioned(presetId: string, c: FxTrackContext): void {
+ track("preset_auditioned", { preset: presetId, ...ctx(c) });
+}
+
+// --- nodes -----------------------------------------------------------------
+
+/**
+ * `via` separates the two doors onto the same effect: a named job arrives
+ * already aimed at a frequency, a bare effect does not. Which one authors
+ * actually reach for is the question the jobs were built to answer.
+ */
+export function trackNodeAdded(
+ type: string,
+ via: "job" | "effect" | "eq" | "leveller",
+ jobId: string | null,
+ c: FxTrackContext,
+): void {
+ track("node_added", { effect: type, via, job: jobId ?? "none", ...ctx(c) });
+}
+
+export function trackNodeRemoved(type: string, origin: string, c: FxTrackContext): void {
+ track("node_removed", { effect: type, origin, ...ctx(c) });
+}
+
+export function trackNodeBypassed(type: string, bypassed: boolean, c: FxTrackContext): void {
+ track("node_bypassed", { effect: type, bypassed, ...ctx(c) });
+}
+
+export function trackNodeMoved(type: string, direction: "up" | "down", c: FxTrackContext): void {
+ track("node_moved", { effect: type, direction, ...ctx(c) });
+}
+
+// --- parameters ------------------------------------------------------------
+
+/**
+ * A committed parameter edit.
+ *
+ * `surface` is the point of the event. Every effect with a one-knob profile can
+ * be driven either by that derived knob or by opening Details and setting the
+ * mechanism directly, and the whole one-knob design rests on a claim about
+ * which one people use. Without this property the two are indistinguishable.
+ *
+ * The value goes too — a parameter key with no value tells you somebody touched
+ * "frequency" and not that every author lands on 80 Hz.
+ */
+export function trackParamCommitted(
+ type: string,
+ param: string,
+ value: number | string,
+ surface: "knob" | "details",
+ c: FxTrackContext,
+): void {
+ track("param_committed", { effect: type, param, value, surface, ...ctx(c) });
+}
+
+/** The derived one-knob control, on release. `strength` is 0..1. */
+export function trackProfileCommitted(type: string, strength: number, c: FxTrackContext): void {
+ track("profile_committed", { effect: type, strength, ...ctx(c) });
+}
+
+// --- the measuring modules -------------------------------------------------
+
+export function trackCarveChanged(
+ action: "enabled" | "disabled" | "strength" | "sources",
+ props: { strength?: number; sourceCount?: number },
+): void {
+ track("carve_changed", {
+ action,
+ strength: props.strength,
+ source_count: props.sourceCount,
+ });
+}
+
+export function trackLeveller(action: "run" | "removed" | "auditioned"): void {
+ track("leveller", { action });
+}
+
+export function trackEqChanged(band: string, value: number): void {
+ track("eq_changed", { band, value });
+}
+
+// --- provenance ------------------------------------------------------------
+
+/**
+ * What arrived on a composition that this session did not put there.
+ *
+ * This is how agent-applied effects become visible at all. An agent asked to
+ * fix a mix does not drive the panel — it edits the composition HTML, or runs
+ * `scripts/carve.mjs`, and the rack simply finds the result already present.
+ * None of the events above will ever fire for that work.
+ *
+ * So the panel reports the shape of what it was handed, and how many edits this
+ * session had made when it appeared. Fx that changed while the studio was open
+ * with `panel_edits` unmoved was written by something else — which, combined
+ * with `agent_runtime` on the same event, is as close to "an agent did this" as
+ * the browser can honestly get.
+ *
+ * `first_sight` distinguishes opening a composition that already had effects
+ * from watching effects appear in one that did not.
+ */
+export function trackChainObserved(
+ chain: HfAudioFxChain | null,
+ props: { firstSight: boolean; panelEdits: number; hasCarve: boolean; hasAutomation: boolean },
+ c: FxTrackContext,
+): void {
+ track("chain_observed", {
+ ...chainShape(chain),
+ first_sight: props.firstSight,
+ panel_edits: props.panelEdits,
+ // The whole point: no panel edits behind a chain that is present or that
+ // just changed means the chain came from outside the studio.
+ authored_outside: props.panelEdits === 0,
+ has_carve: props.hasCarve,
+ has_automation: props.hasAutomation,
+ ...ctx(c),
+ });
+}
diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx
index 986d6973c..ada8f6382 100644
--- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx
+++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx
@@ -7,27 +7,16 @@
* budget, and self-contained enough to test on its own.
*/
-import { useEffect, useRef, useState } from "react";
+import { useState } from "react";
import {
- defaultAudioFxParams,
HF_AUDIO_FX_ATTR,
HF_AUDIO_FX_DATA_KEY,
- mintAudioFxNodeId,
parseAudioFxChain,
serializeAudioFxChain,
type HfAudioFxChain,
- type HfAudioFxNode,
} from "@hyperframes/core/audio-fx";
import {
- analyseCarveBands,
- analyseCarveDuck,
- analyseCarveDynamics,
- carveBandsToChain,
- carveProfile,
classifyAudioName,
- clipsOverlap,
- DEFAULT_CARVE,
- mixCarveSources,
HF_AUDIO_CARVE_ATTR,
normalizeCarveSettings,
type HfCarveSettings,
@@ -38,12 +27,10 @@ import {
presetAutomationTarget,
sampleAutomationLane,
type HfAutomation,
- type HfAutomationLane,
} from "@hyperframes/core/audio-automation";
import {
automatedTargetsOf,
automationAttrValue,
- withLane,
HF_AUDIO_AUTOMATION_ATTR,
HF_AUDIO_AUTOMATION_DATA_KEY,
readPanelAutomation,
@@ -51,43 +38,13 @@ import {
withoutLane,
withSeededLane,
} from "./propertyPanelAutomation";
-import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
import type { DomEditSelection } from "./domEditingTypes";
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
-import { usePlayerStore } from "../../player";
-
-/**
- * Rate the carve source is decoded at. Analysis is self-consistent because it
- * reads the decoded buffer's own rate, so this only has to be a sane audio rate.
- */
-const DECODE_SAMPLE_RATE = 48000;
-import { FxSection, type AudioTrackOption } from "./propertyPanelFxSection.js";
-
-/** A clip's span, with an unwritten duration left unbounded rather than zero. */
-function spanOf(
- start: string | null | undefined,
- duration: string | null | undefined,
-): { start: number; duration: number | null } {
- const n =
- duration === null || duration === undefined || duration === "" ? Number.NaN : Number(duration);
- return { start: clipStart(start), duration: Number.isFinite(n) ? n : null };
-}
-
-/** Where a clip starts on the timeline, in seconds. */
-function clipStart(value: string | null | undefined): number {
- const n = Number(value);
- return Number.isFinite(n) ? n : 0;
-}
-
-/** Lanes belonging to nodes the carve generated, which a re-run replaces. */
-function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfAutomation {
- const prefixes = chain.nodes.filter((n) => n.fromCarve && n.id).map((n) => `fx.${n.id}.`);
- if (prefixes.length === 0) return automation;
- return {
- version: automation.version,
- lanes: automation.lanes.filter((lane) => !prefixes.some((p) => lane.target.startsWith(p))),
- };
-}
+import { FxSection } from "./propertyPanelFxSection.js";
+import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
+import { useFxChainObserved } from "./useFxChainObserved.js";
+import { useFxCarve } from "./useFxCarve.js";
+import { useFxLevelling } from "./useFxLevelling.js";
/**
* Bridges the FX panel to the element/attribute world. Chain and carve are
@@ -96,7 +53,7 @@ function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfA
*/
export function AudioFxGroup({
element,
- onSetAttributeQuiet,
+ onSetAttributeQuiet: onSetAttributeQuietRaw,
onSetAttributeLive,
}: {
element: DomEditSelection;
@@ -163,6 +120,29 @@ export function AudioFxGroup({
return values;
})();
+ const carve = ((): HfCarveSettings | null => {
+ const raw = element.dataAttributes?.["fx-carve"];
+ if (!raw) return null;
+ try {
+ return normalizeCarveSettings(JSON.parse(raw));
+ } catch {
+ return null;
+ }
+ })();
+
+ /**
+ * Every persisting write this panel makes, counted — see `useFxChainObserved`,
+ * which reports a chain that changed without one of these behind it as work
+ * something outside the studio did.
+ */
+ const onSetAttributeQuiet = useFxChainObserved(
+ element,
+ chain,
+ carve,
+ automation,
+ onSetAttributeQuietRaw,
+ );
+
// Written through the live path on purpose. It persists to the source just
// like the refreshing one, but skips the preview reload — and a reload
// restarts every playing track, which is heard as the audio chopping. The
@@ -217,66 +197,6 @@ export function AudioFxGroup({
.map((t) => t.presetId),
);
- /**
- * Turn carve on or off.
- *
- * Switching off drops the filters it generated — left behind they keep dipping
- * the bed with nothing in the panel to explain them — but that is a second
- * attribute, and each write is a read-modify-write against the same source
- * file. Fired together, both read the same content and the later one drops the
- * earlier: either the carve settings went and the filters stayed, or the
- * reverse. Awaiting the first means the second reads the file it produced.
- *
- * One commit carrying both would also close the window where a failure of just
- * the second leaves them half-applied; that needs a multi-attribute quiet
- * commit, which does not exist yet.
- */
- const setCarve = async (next: HfCarveSettings | null): Promise => {
- // What the carve generated is only justified by the voices it was measured
- // from: switched off, or left naming none — every source deleted, say —
- // there is nothing those filters are making room for. Left behind they keep
- // dipping the bed with nothing in the panel to explain them.
- const generatedOutputStands = Boolean(next?.enabled) && (next?.sources.length ?? 0) > 0;
- if (!generatedOutputStands) {
- const carriedOver = withoutCarveLanes(automation, chain);
- if (carriedOver.lanes.length !== automation.lanes.length) {
- await onSetAttributeQuiet(
- HF_AUDIO_AUTOMATION_ATTR,
- automationAttrValue(carriedOver) || null,
- );
- }
- }
- if (!generatedOutputStands) {
- const kept = chain.nodes.filter((n) => !n.fromCarve);
- if (kept.length !== chain.nodes.length) {
- await onSetAttributeQuiet(
- HF_AUDIO_FX_ATTR,
- kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null,
- );
- }
- }
- await onSetAttributeQuiet(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : null);
-
- // Every setting here describes the filters, so changing one rebuilds them.
- // There is no apply button: a carve naming a voice with no filters behind it
- // is a setting nobody applied, and the panel already knows everything it needs
- // to. Picking the voice is what starts it; strength and dynamic re-derive what
- // is already there. A carve with no source yet has nothing to analyse.
- const changed =
- next &&
- next.enabled &&
- next.sources.length > 0 &&
- (!carve ||
- // Switching it back on is a change like any other: the filters went with
- // the switch, so there is nothing left to hear until they are rebuilt.
- // Without this, On restored the setting and left the bed uncarved.
- !carve.enabled ||
- next.sources.join("\u0000") !== carve.sources.join("\u0000") ||
- next.strength !== carve.strength);
- if (next && changed) await analyse(next);
- };
-
- /** Every lane belonging to a node that is going away. */
/**
* Drop every lane belonging to these nodes, and optionally a whole-preset one.
*
@@ -301,566 +221,27 @@ export function AudioFxGroup({
const removeNodeAutomation = (nodeId: string): void => removeNodesAutomation([nodeId]);
- const carve = ((): HfCarveSettings | null => {
- const raw = element.dataAttributes?.["fx-carve"];
- if (!raw) return null;
- try {
- return normalizeCarveSettings(JSON.parse(raw));
- } catch {
- return null;
- }
- })();
-
- /**
- * Is some other track carving against this one?
- *
- * A carve is a relationship — a bed is carved against a voice — and the voice is
- * the far end of it. Offering the same control there offers to carve a track
- * against itself by proxy, and switching it on left a setting with no source it
- * could legally name. Read off the other elements' own carve attributes, because
- * that is where the relationship is recorded.
- */
- const carvedAgainstBy = ((): string | null => {
- const doc = element.element?.ownerDocument;
- if (!doc || !element.id) return null;
- for (const other of Array.from(doc.querySelectorAll(`[${HF_AUDIO_CARVE_ATTR}]`))) {
- if (other.id === element.id) continue;
- try {
- const raw = other.getAttribute(HF_AUDIO_CARVE_ATTR);
- if (raw && normalizeCarveSettings(JSON.parse(raw)).sources.includes(element.id ?? "")) {
- return other.id || "another track";
- }
- } catch {
- // An unreadable carve on some other element says nothing about this one.
- }
- }
- return null;
- })();
-
- /**
- * The tracks worth offering as the voice.
- *
- * Not every audio element is a plausible answer: a music bed is the thing being
- * carved, and a 200 ms whoosh has no speech to make room for. Offering them made
- * the picker a list of everything and the "exactly one candidate" rule — which is
- * what lets an obvious pairing carve itself — almost never true, because a
- * composition with a voice, a bed and two stings looked like four options.
- *
- * Classified by name, which is a hint and not a fact, so the rule is loose in the
- * safe direction: a name that says nothing stays in, voice-shaped names sort
- * first, and if filtering would leave nothing at all every track comes back. A
- * picker that hides the track somebody needs is worse than a long one.
- */
- const { sourceOptions, autoSourceIds } = ((): {
- sourceOptions: AudioTrackOption[];
- autoSourceIds: string[];
- } => {
- const doc = element.element?.ownerDocument;
- if (!doc) return { sourceOptions: [], autoSourceIds: [] };
- const others = Array.from(doc.querySelectorAll("audio[id]")).filter(
- (a) => a.id !== element.id,
- );
- // Only tracks that are actually playing while this bed is. A voice somewhere
- // else on the timeline cannot mask it, so including it would contribute silence
- // to the analysis and leave the author wondering why it changed nothing.
- const bedSpan = spanOf(element.dataAttributes?.["start"], element.dataAttributes?.["duration"]);
- const described = others
- .filter((a) =>
- clipsOverlap(
- bedSpan,
- spanOf(a.getAttribute("data-start"), a.getAttribute("data-duration")),
- ),
- )
- .map((a) => ({
- id: a.id,
- label: a.id,
- kind: classifyAudioName(a.id, a.getAttribute("src")),
- }));
- const plausible = described.filter((t) => t.kind === "voice" || t.kind === "unknown");
- const offered = plausible.length > 0 ? plausible : described;
- const byVoiceFirst = (list: typeof described) =>
- [...list].sort((a, b) => (a.kind === "voice" ? 0 : 1) - (b.kind === "voice" ? 0 : 1));
- return {
- sourceOptions: byVoiceFirst(offered).map(({ id, label }) => ({ id, label })),
- // What the panel may pick WITHOUT being asked — never the fallback. The
- // fallback exists so the picker can still show a track whose name reads as
- // music or as an effect, because a name is a hint and the author may know
- // better. Choosing off that list is a different act: it is the panel
- // deciding, and "the only audio left is a 200 ms explosion" is not a voice
- // to make room for. A bed surrounded by nothing plausible waits instead.
- autoSourceIds: byVoiceFirst(plausible).map((t) => t.id),
- };
- })();
-
- /**
- * The voices this carve names that are still in the composition.
- *
- * Existence, not the candidate list: a voice can stop being offered without
- * being gone (it stopped overlapping the bed), and dropping it then would
- * quietly rewrite a relationship the author set. Deleted is the case that has
- * to be noticed, because what the carve produced was measured from that track.
- *
- * Asked of the timeline rather than of `element.element.ownerDocument`, which
- * is the preview's DOM and outlives a delete: measured in the studio, a bed
- * selected right after its voice was deleted still found that voice through
- * the document, so the carve sat on a measurement of a track the timeline had
- * already dropped. The store is what the delete actually edited.
- */
- const timelineElements = usePlayerStore((s) => s.elements);
- const survivingSources = ((): string[] => {
- if (!carve) return [];
- const present = new Set(timelineElements.map((el) => el.domId ?? el.id));
- // Absence only means deletion once the timeline is known to describe THIS
- // composition, and the bed being in it is the proof. Without that check a
- // store that is empty — not loaded yet, or a panel mounted outside the
- // player — reads as "every voice was deleted" and throws away a carve that
- // is perfectly fine. Unchanged sources are what the prune treats as nothing
- // to do.
- if (!element.id || !present.has(element.id)) return carve.sources;
- return carve.sources.filter((id) => present.has(id));
- })();
-
- /**
- * A deleted voice re-analyses the bed.
- *
- * The filters and envelopes are a measurement of specific tracks, so losing one
- * makes them a measurement of something that is no longer there — the bed keeps
- * ducking for a voice nobody can hear. `analyse` already skips a source it
- * cannot find, but nothing asked it to run again.
- *
- * Pruning is the whole trigger: `setCarve` re-analyses when the source list
- * changes, so the surviving voices are re-measured together. Losing the LAST
- * one leaves an empty list, which the effects below repoint at whatever
- * candidates remain — and if there are none, `setCarve` drops what the carve
- * generated, since there is nothing left it could be making room for.
- *
- * Keyed on the survivors rather than on the candidates: a voice that had
- * stopped overlapping was never in the candidate list, so its deletion would
- * not change that identity and this would never fire.
- */
- useEffect(() => {
- if (carvedAgainstBy || !carve?.enabled) return;
- if (survivingSources.length === carve.sources.length) return;
- void setCarve({ ...carve, sources: survivingSources });
- // Keyed on the identity of the decision, not on setCarve — which is rebuilt
- // every render and would re-fire this.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [carve, carvedAgainstBy, survivingSources.join(" ")]);
-
- /**
- * A bed with voices above it carves itself.
- *
- * Carving is what a bed under speech wants, and making the author find the
- * control, name the voices and set a strength before hearing the thing they
- * already wanted is ceremony.
- *
- * Every candidate, not one of them. This used to refuse when there were several,
- * because picking one of three was a guess — but they are analysed together now,
- * so "all of them" is the answer rather than a guess: a bed running under a
- * narrator, an answer and a second presenter should make room for all three.
- *
- * Runs once per state. The write lands in `data-fx-carve`, which is what `carve`
- * is read from, so the condition is false on every later render — and switching it
- * off stores `enabled: false`, which is also a configured carve. That is the whole
- * reason the flag exists rather than "off" being an absent attribute.
- */
- const candidateIds = autoSourceIds.join("\u0000");
- useEffect(() => {
- // Exactly one candidate is the sibling effect's case below, not this one's:
- // both guards passing for a single candidate fired two setCarve calls with
- // the same result — two decodes, two FFT runs, two concurrent attribute
- // writes.
- if (carvedAgainstBy || autoSourceIds.length <= 1) return;
- const all = autoSourceIds;
- // Nothing configured: the default carve, pointed at everything it could hear.
- if (carve === null) {
- void setCarve({ ...DEFAULT_CARVE, sources: all });
- return;
- }
- // Configured but naming no voice — switched on before there was anything to
- // listen to, or a source list emptied. The card reads the candidates out, so
- // they have to be the stored ones too.
- if (carve.enabled && carve.sources.length === 0) void setCarve({ ...carve, sources: all });
- // Keyed on the identity of the decision, not on setCarve — which is rebuilt
- // every render and would re-fire this.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [carve, carvedAgainstBy, candidateIds]);
-
- /**
- * A bed with one obvious voice above it carves itself.
- *
- * Carving is what a bed under narration wants, and making the author find the
- * control, pick the voice and set a strength before hearing the thing they
- * already wanted is ceremony. So an unconfigured track with exactly ONE
- * candidate voice gets the default carve applied for it.
- *
- * Exactly one, not the first of several: picking for the author when the answer
- * is ambiguous is how the wrong track gets carved, and a carve against the wrong
- * voice is silent and confusing. With several candidates the module still appears,
- * with the picker waiting.
- *
- * Runs once. The write lands in `data-fx-carve`, which is what `carve` is read
- * from, so the condition is false on every later render — and switching it off
- * stores `enabled: false`, which is also a configured carve. That is the whole
- * reason the flag exists rather than "off" being an absent attribute.
- */
- useEffect(() => {
- if (carvedAgainstBy || autoSourceIds.length !== 1) return;
- const only = autoSourceIds[0];
- if (!only) return;
- // Nothing configured: the default carve, pointed at the one candidate.
- if (carve === null) {
- void setCarve({ ...DEFAULT_CARVE, sources: [only] });
- return;
- }
- // Configured but with no voice yet — a carve switched on before there was
- // anything to listen to, or one whose source was cleared. The panel reads the
- // sole candidate out as the source, so it has to be the stored one too;
- // otherwise the card claims a relationship the attribute does not record.
- if (carve.enabled && carve.sources.length === 0) void setCarve({ ...carve, sources: [only] });
- // Deliberately keyed on the identity of the decision, not on setCarve — which
- // is rebuilt every render and would re-fire this.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [carve, carvedAgainstBy, autoSourceIds.length, autoSourceIds[0]]);
-
const [analysing, setAnalysing] = useState(false);
- /**
- * Decodes the chosen voice track and turns its spectrum into peaking filters
- * on this one. The bands replace any previous carve output but leave
- * hand-added effects alone, so re-analysing does not discard other work.
- */
- /**
- * Measure THIS track and write the levelling lane.
- *
- * Same shape as the carve below it — decode offline, lock the rack while it
- * works, write once — but it listens to the track it is on rather than to a
- * voice above it, so it needs no source picker.
- */
- /**
- * This track's audio, decoded once and kept.
- *
- * Levelling is measured from it, and hover-auditioning means measuring on every
- * pass over the button — fetching and decoding a several-minute voiceover each
- * time would make the audition slower than the thing it is previewing. Keyed by
- * `src` so a track pointed at a different file re-decodes.
- */
- const decoded = useRef<{ src: string; samples: Float32Array; sampleRate: number } | null>(null);
+ const { carvedAgainstBy, sourceOptions, setCarve } = useFxCarve(
+ element,
+ chain,
+ carve,
+ automation,
+ onSetAttributeQuiet,
+ writeAutomation,
+ setAnalysing,
+ );
- const decodeTrack = async (): Promise<{ samples: Float32Array; sampleRate: number } | null> => {
- const el = element.element;
- const src = el?.getAttribute("src");
- const doc = el?.ownerDocument;
- if (!src || !doc) return null;
- const cached = decoded.current;
- if (cached?.src === src) return cached;
- const Ctor =
- window.OfflineAudioContext ??
- (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
- .webkitOfflineAudioContext;
- if (!Ctor) return null;
- const res = await fetch(new URL(src, doc.baseURI).href);
- const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
- await res.arrayBuffer(),
+ const { runLeveller, auditionTransport, auditioningLevel, auditionLevel, removeLeveller } =
+ useFxLevelling(
+ element,
+ chain,
+ automation,
+ onSetAttributeQuiet,
+ onSetAttributeLive,
+ setAnalysing,
);
- const next = { src, samples: buffer.getChannelData(0), sampleRate: buffer.sampleRate };
- decoded.current = next;
- return next;
- };
-
- /**
- * The part of the decoded file this clip actually plays.
- *
- * A lane's `t` is seconds from the start of the CLIP, but the decode is the
- * whole file from its first sample — so measuring a trimmed clip produced an
- * envelope offset by the trim, and every correction landed early by exactly
- * `media-start`. Slicing here is what puts the two clocks back on the same
- * zero.
- */
- const clipWindow = (audio: { samples: Float32Array; sampleRate: number }) => {
- const mediaStart = Number(element.dataAttributes?.["media-start"] ?? 0);
- const duration = Number(element.dataAttributes?.["duration"] ?? Number.NaN);
- const from =
- Number.isFinite(mediaStart) && mediaStart > 0
- ? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
- : 0;
- const to =
- Number.isFinite(duration) && duration > 0
- ? Math.min(audio.samples.length, from + Math.ceil(duration * audio.sampleRate))
- : audio.samples.length;
- return from === 0 && to === audio.samples.length
- ? audio.samples
- : audio.samples.subarray(from, to);
- };
-
- const runLeveller = async (): Promise => {
- setAnalysing(true);
- try {
- const audio = await decodeTrack();
- if (!audio) return;
- const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
- if (!result) return;
- await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
- // Merged by target, never written wholesale: the script describes its own
- // lane only, and replacing the attribute would take the carve's lanes and
- // the volume lane with it.
- const lane = result.automation.lanes[0];
- if (lane) {
- void onSetAttributeQuiet(
- HF_AUDIO_AUTOMATION_ATTR,
- automationAttrValue(withLane(automation, lane)) || null,
- );
- }
- } catch {
- // A track whose audio cannot be fetched or decoded simply gets no
- // levelling, the same way an unreadable carve source is skipped.
- } finally {
- setAnalysing(false);
- }
- };
-
- /**
- * Where the playhead was when an audition started the transport, so leaving
- * can put it back. Null means this audition did not start playback — the
- * transport was already running and must be left alone.
- */
- const auditionReturn = useRef(null);
-
- /**
- * Start playback for an audition, and stop it again on the way out.
- *
- * An audition writes the preset to the running graph, which is silent while
- * the transport is paused — so a paused author hovering a preset heard
- * nothing at all, and the whole affordance only worked mid-playback. Hovering
- * now plays from the playhead, and leaving stops and rewinds to exactly where
- * it started: browsing the shelf must not cost the author their place.
- *
- * Already playing, this does nothing in either direction. The author started
- * that, and stopping their transport because they passed over a preset would
- * be the panel taking a decision that was not offered to it.
- */
- const auditionTransport = (on: boolean): void => {
- const store = usePlayerStore.getState();
- if (on) {
- if (store.isPlaying || auditionReturn.current !== null) return;
- auditionReturn.current = store.currentTime;
- store.requestPlayback(true);
- return;
- }
- const returnTo = auditionReturn.current;
- if (returnTo === null) return;
- auditionReturn.current = null;
- store.requestPlayback(false, returnTo);
- };
-
- const [auditioningLevel, setAuditioningLevel] = useState(false);
- /**
- * Bumped on every enter and leave, so a measurement can tell whether the
- * pointer is still on the button when it finishes.
- *
- * Decoding a long voiceover takes seconds, and a hover that takes seconds is
- * one the author has usually already left. Applying the result then would put
- * levelling on a track nobody asked to level, through a channel that does not
- * persist — so it would be audible, invisible in the document, and gone on the
- * next reload. This counter is what makes a late result a no-op.
- */
- const auditionRun = useRef(0);
-
- /**
- * Measure this track and play the levelling without persisting it.
- *
- * `false` puts the stored chain and automation back. Both attributes, because
- * levelling is a node AND the lane that drives it: reverting only the chain
- * would leave an envelope writing to a gain stage that is no longer there.
- */
- const auditionLevel = async (on: boolean): Promise => {
- const run = ++auditionRun.current;
- if (!on) {
- setAuditioningLevel(false);
- void onSetAttributeLive(
- HF_AUDIO_FX_ATTR,
- chain.nodes.length ? serializeAudioFxChain(chain) : null,
- );
- void onSetAttributeLive(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(automation) || null);
- return;
- }
- setAuditioningLevel(true);
- try {
- const audio = await decodeTrack();
- // Gone, or superseded by a later hover. Either way this result is stale.
- if (!audio || run !== auditionRun.current) return;
- const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
- if (!result || run !== auditionRun.current) return;
- void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
- const lane = result.automation.lanes[0];
- if (lane) {
- void onSetAttributeLive(
- HF_AUDIO_AUTOMATION_ATTR,
- automationAttrValue(withLane(automation, lane)) || null,
- );
- }
- } catch {
- // Same as the real run: a track that cannot be decoded simply does not
- // audition, rather than failing the panel.
- } finally {
- if (run === auditionRun.current) setAuditioningLevel(false);
- }
- };
-
- const removeLeveller = (): void => {
- const { chain: next, removedTarget } = removeLevelling(chain);
- void onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
- // The lane goes with the node. An orphan keeps driving a parameter that is
- // no longer in the graph.
- if (removedTarget) {
- void onSetAttributeQuiet(
- HF_AUDIO_AUTOMATION_ATTR,
- automationAttrValue(withoutLane(automation, removedTarget)) || null,
- );
- }
- };
-
- const analyse = async (active: HfCarveSettings | null = carve): Promise => {
- if (!active?.sources.length) return;
- const doc = element.element?.ownerDocument;
- if (!doc) return;
- // Every named voice that is actually there with something to decode. A source
- // naming a deleted track is skipped rather than failing the whole analysis.
- //
- // Read out to plain values here rather than carrying elements around: it is
- // what lets the src and the start be non-null by construction downstream
- // instead of by assertion.
- const voices: { src: string; start: string | null }[] = [];
- for (const id of active.sources) {
- const el = doc.getElementById(id);
- // By tag name, not `instanceof HTMLAudioElement`: these elements belong to
- // the composition's iframe document, so the constructor they were made
- // from is not this realm's and the instanceof is false for every one.
- if (el?.tagName !== "AUDIO") continue;
- const src = el.getAttribute("src");
- if (!src) continue;
- voices.push({ src, start: el.getAttribute("data-start") });
- }
- if (voices.length === 0) return;
- setAnalysing(true);
- try {
- // Decoded in an OfflineAudioContext, not a live one. Opening a second
- // output device mid-playback makes the running track glitch while the
- // hardware is reconfigured; an offline context touches no device.
- const Ctor =
- window.OfflineAudioContext ??
- (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
- .webkitOfflineAudioContext;
- if (!Ctor) return;
- const decode = async (relative: string): Promise => {
- const res = await fetch(new URL(relative, doc.baseURI).href);
- return new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(await res.arrayBuffer());
- };
- const bedStart = clipStart(element.dataAttributes?.["start"]);
- // Every voice, summed onto the bed's own clock. One question — where and when
- // is speech masking this bed — with one answer, even when the answer comes
- // from three people talking at different times. Doing this before the analysis
- // is also what lets the bands and the envelopes stay a single set: the chain is
- // fixed, so there is no per-voice filter to switch between.
- const decoded = await Promise.all(
- voices.map(async (voice) => ({
- samples: (await decode(voice.src)).getChannelData(0),
- offsetSeconds: clipStart(voice.start) - bedStart,
- })),
- );
- const voiceMix = mixCarveSources(decoded, DECODE_SAMPLE_RATE);
- if (voiceMix.length === 0) return;
- // Strength is what the author set; these are the numbers it means.
- const profile = carveProfile(active.strength);
- // The bed as well as the voice, when the carve is asked to match levels:
- // "how far over the voice is this bed" cannot be answered by listening to
- // one of them.
- const bedSrc = profile.duckDb > 0 ? element.element?.getAttribute("src") : null;
- const bedBuffer = bedSrc ? await decode(bedSrc).catch(() => null) : null;
- const bands = analyseCarveBands(voiceMix, DECODE_SAMPLE_RATE, profile);
- const carved = carveBandsToChain(bands);
-
- // The level half of the carve, measured against the speech it has to sit
- // under. No offset to apply: the mix is already on the bed's clock.
- const duck = bedBuffer
- ? analyseCarveDuck(voiceMix, bedBuffer.getChannelData(0), DECODE_SAMPLE_RATE, profile, 0)
- : [];
-
- // Carve output is tagged so a re-run replaces it instead of stacking.
- const kept = chain.nodes.filter((n) => !n.fromCarve);
- // Ids, minted against the nodes already claiming one, because a dynamic
- // carve automates these filters and a lane addresses its node by id.
- let claimed: HfAudioFxChain = { version: 1, nodes: kept };
- const mint = (node: HfAudioFxNode): HfAudioFxNode => {
- const withId = { ...node, id: mintAudioFxNodeId(claimed), fromCarve: true };
- claimed = { version: 1, nodes: [...claimed.nodes, withId] };
- return withId;
- };
- const carvedNodes: HfAudioFxNode[] = carved.nodes.map(mint);
- // The gain stage sits after the filters, and only exists when the carve was
- // asked to make level room. It sits at 0 and is driven by the envelope below.
- const duckNode =
- duck.length > 0
- ? mint({
- type: "gain",
- enabled: true,
- params: { ...defaultAudioFxParams("gain"), gain: 0 },
- })
- : null;
- const next = {
- version: 1,
- nodes: [...carvedNodes, ...(duckNode ? [duckNode] : []), ...kept],
- };
- // Live, like every other chain write: the runtime swaps the graph in
- // place, so a reload would only interrupt the audio to reach the same
- // filters.
- //
- // Awaited, because the automation write below is a second read-modify-write
- // against the same file — fired together the later one would drop the
- // earlier — and because a lane naming a node the chain does not have yet is
- // pruned when it is read back.
- await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
-
- /**
- * One carve envelope as a lane on this bed's clock.
- *
- * No shifting: the voices were summed onto the bed's clock before the analysis
- * ran, so what comes back is already in the bed's own time. A lane does hold
- * its first value backwards to the start of its clip, so an envelope that
- * begins later needs an explicit "no cut" at zero or the bed starts out ducked.
- */
- const laneFor = (id: string, points: { t: number; v: number }[]): HfAutomationLane[] => {
- const timed = points
- .map((p) => ({ t: Number(p.t.toFixed(3)), v: p.v }))
- .filter((p) => p.t >= 0);
- if ((timed[0]?.t ?? 0) > 0) timed.unshift({ t: 0, v: 0 });
- return timed.length > 1 ? [{ target: fxAutomationTarget(id, "gain"), points: timed }] : [];
- };
-
- // Each filter's depth becomes an envelope of the speech's level in that band,
- // so pauses leave the bed alone and whoever is talking sets the depth.
- const lanes: HfAutomationLane[] = analyseCarveDynamics(
- voiceMix,
- DECODE_SAMPLE_RATE,
- bands,
- ).flatMap((dyn, i) => {
- const id = carvedNodes[i]?.id;
- return id ? laneFor(id, dyn.points) : [];
- });
- // The level envelope rides the gain stage, on the same clock as the bands.
- if (duckNode?.id && duck.length > 0) {
- lanes.push(...laneFor(duckNode.id, duck));
- }
- const carriedOver = withoutCarveLanes(automation, chain);
- if (lanes.length > 0 || carriedOver.lanes.length !== automation.lanes.length) {
- writeAutomation({ version: 1, lanes: [...carriedOver.lanes, ...lanes] });
- }
- } catch {
- // Leave the chain as it was; the button simply re-enables.
- } finally {
- setAnalysing(false);
- }
- };
return (
= {
+ filter: "Filters",
+ dynamics: "Dynamics",
+ nonlinear: "Non-linear",
+ time: "Time",
+};
+
+/**
+ * The add menu, with the jobs standing in for the effect they are made of.
+ *
+ * `peaking` is not offered as itself: picking it is picking a machine and
+ * leaving the real decision — which range — for afterwards. The jobs are that
+ * decision, already made. See `audioFxJobs.ts`.
+ *
+ * Computed once at module scope, not per render: it has no dependency on props
+ * or state, just the static effect registry.
+ */
+const GROUPED = GROUP_ORDER.map((g) => ({
+ group: g,
+ defs: HF_AUDIO_FX.filter((d) => d.group === g && !HF_AUDIO_FX_JOB_TYPES.has(d.id)),
+ jobs: HF_AUDIO_FX_JOBS.filter((job) => getAudioFxDef(job.type)?.group === g),
+}));
+
+export interface FxAddMenuProps {
+ disabled?: boolean;
+ analysing?: boolean;
+ levelled?: boolean;
+ auditioningLevel?: boolean;
+ /** Measure this track and write the levelling lane. Absent when unavailable. */
+ onLevel?(): void;
+ /** Take the levelling stage and its lane back out. */
+ onRemoveLevel?(): void;
+ onEq(): void;
+ onJob(job: HfAudioFxJob): void;
+ onEffect(type: string): void;
+ /** The shelf just picked something, or the levelling button did its own close. */
+ onClose(): void;
+ /** Play a hypothetical chain without committing to it, or `null` to stop. */
+ audition(make: ((base: HfAudioFxChain) => HfAudioFxChain) | null): void;
+ onAuditionLevel?(on: boolean): void;
+ withJob(base: HfAudioFxChain, job: HfAudioFxJob): HfAudioFxChain;
+ withEffect(base: HfAudioFxChain, type: string): HfAudioFxChain;
+}
+
+/** The shelf `adding` opens: Tone, the named jobs, and the raw effect registry. */
+export function FxAddMenu({
+ disabled,
+ analysing,
+ levelled,
+ auditioningLevel,
+ onLevel,
+ onRemoveLevel,
+ onEq,
+ onJob,
+ onEffect,
+ onClose,
+ audition,
+ onAuditionLevel,
+ withJob,
+ withEffect,
+}: FxAddMenuProps) {
+ return (
+
{
+ audition(null);
+ onAuditionLevel?.(false);
+ }}
+ // The keyboard's version of leaving. Tabbing between two entries fires
+ // this and then the next one's focus, so it reverts and re-auditions.
+ onBlur={() => {
+ audition(null);
+ onAuditionLevel?.(false);
+ }}
+ >
+
+ );
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxNodeOpenBody.tsx b/packages/studio/src/components/editor/propertyPanelFxNodeOpenBody.tsx
new file mode 100644
index 000000000..6a5183212
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFxNodeOpenBody.tsx
@@ -0,0 +1,269 @@
+/**
+ * An effect row's open face: the derived or primary one-knob control, and
+ * Details underneath it.
+ *
+ * Split out of `propertyPanelFxNodeRow.tsx`, which owned all of this before
+ * the file grew past a size where the header and the open face were still one
+ * thing to read.
+ */
+
+import {
+ applyAudioFxProfile,
+ audioFxProfileStrength,
+ getAudioFxProfile,
+} from "@hyperframes/core/audio-fx-profiles";
+import type {
+ HfAudioFxDef,
+ HfAudioFxNode,
+ HfAudioFxParam,
+ HfAudioFxParamValues,
+} from "@hyperframes/core/audio-fx";
+import type { EFFECT_COPY } from "@hyperframes/core/audio-fx-copy";
+import { trackProfileCommitted } from "./audioFxTelemetry.js";
+import { FxParamRow } from "./propertyPanelFxControls.js";
+import { FxBandRuler } from "./propertyPanelFxBandRuler.js";
+import { FxNodeParams, type FxNodeControlHandlers } from "./propertyPanelFxNodeParams.js";
+
+/**
+ * The derived one-knob face, for a module with no real parameter that can be
+ * its own. Not routed through `FxNodeParams`: this knob is not in the
+ * registry, so it has no AudioParam behind it and nothing to automate. What
+ * automation there is belongs to the parameters it sets, under Details, where
+ * they can be aimed at individually.
+ */
+function FxNodeDerivedKnob({
+ node,
+ derived,
+ profile,
+ disabled,
+ bypassed,
+ params,
+ index,
+ onPreview,
+ onUpdate,
+ trackKind,
+}: {
+ node: HfAudioFxNode;
+ derived: HfAudioFxParam | null;
+ profile: ReturnType;
+ disabled?: boolean;
+ bypassed: boolean;
+ params: HfAudioFxParamValues;
+ index: number;
+ onPreview(index: number, params: HfAudioFxParamValues): void;
+ onUpdate(index: number, patch: Partial): void;
+ trackKind?: string;
+}) {
+ if (!derived) return null;
+ return (
+ <>
+
+ ) : null}
+ >
+ );
+}
+
+/**
+ * The primary knob's face: the one control, what its two ends sound like, and
+ * — for a spectral module — the ruler that teaches where it is working.
+ */
+function FxNodePrimaryKnob({
+ node,
+ onlyPrimary,
+ primary,
+ copy,
+ params,
+ index,
+ disabled,
+ bypassed,
+ automatedTargets,
+ liveAutomationValues,
+ onUpdate,
+ onPreview,
+ onAutomateParam,
+ onRemoveParamAutomation,
+ trackKind,
+}: FxNodeControlHandlers & {
+ node: HfAudioFxNode;
+ onlyPrimary: HfAudioFxDef;
+ primary: string | null;
+ copy: (typeof EFFECT_COPY)[string] | undefined;
+ params: HfAudioFxParamValues;
+ index: number;
+ disabled?: boolean;
+ bypassed: boolean;
+}) {
+ if (!primary) return null;
+ return (
+ <>
+
+ {/* What the two ends of that knob sound like. A number tells an author
+ where the control is; this tells them which way to move it, which is
+ the question they actually have. */}
+ {copy?.primaryEnds ? (
+
+ ) : null}
+ {/* Where it is working, in the words the rack shares. Only for a module
+ that acts on a range at all — there is nothing spectral about a
+ limiter, and a ruler under one would be noise. */}
+ {copy?.band && typeof params.frequency === "number" ? (
+
+ ) : null}
+ >
+ );
+}
+
+/**
+ * Everything below the header: does-copy, the one-knob face, and Details. The
+ * derived and primary knobs are already their own components — what is left
+ * is five independent conditionals deciding which pieces of the open face to
+ * show, which is the section's actual job rather than an avoidable branch.
+ */
+// fallow-ignore-next-line complexity
+export function FxNodeOpenBody({
+ node,
+ registryDef,
+ def,
+ onlyPrimary,
+ primary,
+ derived,
+ profile,
+ oneKnob,
+ details,
+ onToggleDetails,
+ copy,
+ params,
+ index,
+ disabled,
+ bypassed,
+ automatedTargets,
+ liveAutomationValues,
+ onUpdate,
+ onPreview,
+ onAutomateParam,
+ onRemoveParamAutomation,
+ trackKind,
+}: FxNodeControlHandlers & {
+ node: HfAudioFxNode;
+ registryDef: HfAudioFxDef;
+ def: HfAudioFxDef;
+ onlyPrimary: HfAudioFxDef;
+ primary: string | null;
+ derived: HfAudioFxParam | null;
+ profile: ReturnType;
+ oneKnob: boolean;
+ details: boolean;
+ onToggleDetails(): void;
+ copy: (typeof EFFECT_COPY)[string] | undefined;
+ params: HfAudioFxParamValues;
+ index: number;
+ disabled?: boolean;
+ bypassed: boolean;
+}) {
+ return (
+ <>
+ {/* What it is for, before what it is made of. */}
+ {copy?.does ? (
+
+ {copy.does}
+
+ ) : null}
+ {!details ? (
+
+ ) : null}
+ {!details ? (
+
+ ) : null}
+ {/* The DSP name lives on the disclosure, so it is read at the moment
+ the author asks what this really is — and never before. */}
+ {oneKnob ? (
+
+ ) : (
+
+ Details — {registryDef.label}
+
+ )}
+ {details || !oneKnob ? (
+
+ ) : null}
+ >
+ );
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxNodeParams.tsx b/packages/studio/src/components/editor/propertyPanelFxNodeParams.tsx
new file mode 100644
index 000000000..4e73130e3
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFxNodeParams.tsx
@@ -0,0 +1,112 @@
+/**
+ * An open effect's knobs, with whatever automation surface applies to them.
+ *
+ * Split into its own file — rather than living in `propertyPanelFxNodeRow.tsx`
+ * or `propertyPanelFxNodeOpenBody.tsx` — because both of those import it: it
+ * backs the row's own Details disclosure AND the open face's derived/primary
+ * knobs, and either owning file would have made the other import a cycle.
+ */
+
+import {
+ defaultAudioFxParams,
+ type HfAudioFxDef,
+ type HfAudioFxNode,
+ type HfAudioFxParamValues,
+} from "@hyperframes/core/audio-fx";
+import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
+import { trackParamCommitted } from "./audioFxTelemetry.js";
+import { FxParams } from "./propertyPanelFxControls.js";
+
+/**
+ * Which of an effect's knobs already have a lane.
+ *
+ * A lane addresses a node by id, so a node the panel has not yet given one
+ * cannot be automated at all. Adding an effect mints the id, so this only
+ * affects chains written before ids existed.
+ */
+function automatedKeysOf(
+ node: HfAudioFxNode,
+ params: readonly { key: string }[],
+ automatedTargets: ReadonlySet | undefined,
+): Set {
+ if (!node.id || !automatedTargets) return new Set();
+ const nodeId = node.id;
+ return new Set(
+ params.filter((p) => automatedTargets.has(fxAutomationTarget(nodeId, p.key))).map((p) => p.key),
+ );
+}
+
+/**
+ * Wiring a knob row hands down to whatever renders it — shared between
+ * `FxNodeParams` and `FxNodeOpenBody`, which both sit between the row and the
+ * controls.
+ */
+export interface FxNodeControlHandlers {
+ automatedTargets?: ReadonlySet;
+ liveAutomationValues?: ReadonlyMap;
+ onUpdate(index: number, patch: Partial): void;
+ onPreview(index: number, params: HfAudioFxParamValues): void;
+ onAutomateParam?(nodeId: string, paramKey: string): void;
+ onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
+ trackKind?: string;
+}
+
+export function FxNodeParams({
+ node,
+ def,
+ index,
+ disabled,
+ automatedTargets,
+ liveAutomationValues,
+ onUpdate,
+ onPreview,
+ onAutomateParam,
+ onRemoveParamAutomation,
+ trackKind,
+}: FxNodeControlHandlers & {
+ node: HfAudioFxNode;
+ def: HfAudioFxDef;
+ index: number;
+ disabled: boolean;
+}) {
+ const nodeId = node.id;
+ // Lanes address a node by id; the controls know their own parameter keys. This
+ // is the one place that translation belongs.
+ const liveValues = ((): Map | undefined => {
+ if (!nodeId || !liveAutomationValues?.size) return undefined;
+ const byKey = new Map();
+ for (const param of def.params) {
+ const live = liveAutomationValues.get(fxAutomationTarget(nodeId, param.key));
+ if (live !== undefined) byKey.set(param.key, live);
+ }
+ return byKey;
+ })();
+ return (
+ onPreview(index, params)}
+ onCommit={(next: HfAudioFxParamValues) => {
+ // Which knob actually moved. `onCommit` hands over the whole parameter
+ // set, so without the diff every commit would report the first key and
+ // the numbers would say authors only ever touch "frequency".
+ const before = node.params ?? defaultAudioFxParams(node.type);
+ for (const [key, value] of Object.entries(next)) {
+ if (before[key] === value) continue;
+ if (typeof value !== "number" && typeof value !== "string") continue;
+ trackParamCommitted(node.type, key, value, "details", { trackKind });
+ }
+ onUpdate(index, { params: next });
+ }}
+ automatedKeys={automatedKeysOf(node, def.params, automatedTargets)}
+ onAutomate={nodeId && onAutomateParam ? (key) => onAutomateParam(nodeId, key) : undefined}
+ onRemoveAutomation={
+ nodeId && onRemoveParamAutomation
+ ? (key) => onRemoveParamAutomation(nodeId, key)
+ : undefined
+ }
+ />
+ );
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx b/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
index 8b178611b..265712519 100644
--- a/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx
@@ -20,15 +20,10 @@ import {
type HfAudioFxParamValues,
} from "@hyperframes/core/audio-fx";
import { EFFECT_COPY, SUMMARY } from "@hyperframes/core/audio-fx-copy";
-import {
- applyAudioFxProfile,
- audioFxProfileStrength,
- getAudioFxProfile,
-} from "@hyperframes/core/audio-fx-profiles";
-import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
-import { FxParams, FxParamRow } from "./propertyPanelFxControls.js";
-import { FxBandRuler } from "./propertyPanelFxBandRuler.js";
+import { trackNodeBypassed } from "./audioFxTelemetry.js";
+import { getAudioFxProfile } from "@hyperframes/core/audio-fx-profiles";
import { FX_FAMILY_TYPE, fxFamilyOf, fxFamilyTint } from "./propertyPanelFxFamily.js";
+import { FxNodeOpenBody } from "./propertyPanelFxNodeOpenBody.js";
/**
* The one control that carries the module, if it has one.
@@ -118,6 +113,8 @@ interface FxNodeRowProps {
onMove(index: number, delta: number): void;
onRemove(index: number): void;
onPreview(index: number, params: HfAudioFxParamValues): void;
+ /** What the track reads as, carried onto this row own events. */
+ trackKind?: string;
}
/** Reorder arrow. Disabled at the end of the chain it would move past. */
@@ -227,80 +224,6 @@ function FxNodeHeader({
);
}
-/**
- * Which of an effect's knobs already have a lane.
- *
- * A lane addresses a node by id, so a node the panel has not yet given one
- * cannot be automated at all. Adding an effect mints the id, so this only
- * affects chains written before ids existed.
- */
-function automatedKeysOf(
- node: HfAudioFxNode,
- params: readonly { key: string }[],
- automatedTargets: ReadonlySet | undefined,
-): Set {
- if (!node.id || !automatedTargets) return new Set();
- const nodeId = node.id;
- return new Set(
- params.filter((p) => automatedTargets.has(fxAutomationTarget(nodeId, p.key))).map((p) => p.key),
- );
-}
-
-/** An open effect's knobs, with whatever automation surface applies to them. */
-function FxNodeParams({
- node,
- def,
- index,
- disabled,
- automatedTargets,
- liveAutomationValues,
- onUpdate,
- onPreview,
- onAutomateParam,
- onRemoveParamAutomation,
-}: {
- node: HfAudioFxNode;
- def: HfAudioFxDef;
- index: number;
- disabled: boolean;
- automatedTargets?: ReadonlySet;
- liveAutomationValues?: ReadonlyMap;
- onUpdate(index: number, patch: Partial): void;
- onPreview(index: number, params: HfAudioFxParamValues): void;
- onAutomateParam?(nodeId: string, paramKey: string): void;
- onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
-}) {
- const nodeId = node.id;
- // Lanes address a node by id; the controls know their own parameter keys. This
- // is the one place that translation belongs.
- const liveValues = ((): Map | undefined => {
- if (!nodeId || !liveAutomationValues?.size) return undefined;
- const byKey = new Map();
- for (const param of def.params) {
- const live = liveAutomationValues.get(fxAutomationTarget(nodeId, param.key));
- if (live !== undefined) byKey.set(param.key, live);
- }
- return byKey;
- })();
- return (
- onPreview(index, params)}
- onCommit={(params: HfAudioFxParamValues) => onUpdate(index, { params })}
- automatedKeys={automatedKeysOf(node, def.params, automatedTargets)}
- onAutomate={nodeId && onAutomateParam ? (key) => onAutomateParam(nodeId, key) : undefined}
- onRemoveAutomation={
- nodeId && onRemoveParamAutomation
- ? (key) => onRemoveParamAutomation(nodeId, key)
- : undefined
- }
- />
- );
-}
-
/** One effect in the chain: its header controls, and its knobs when open. */
export function FxNodeRow({
node,
@@ -318,6 +241,7 @@ export function FxNodeRow({
onMove,
onRemove,
onPreview,
+ trackKind,
}: FxNodeRowProps) {
const registryDef = getAudioFxDef(node.type);
const def = useMemo(() => (registryDef ? plainDef(registryDef) : null), [registryDef]);
@@ -372,7 +296,10 @@ export function FxNodeRow({
last={last}
disabled={disabled}
onToggleOpen={onToggleOpen}
- onToggleBypass={() => onUpdate(index, { enabled: bypassed })}
+ onToggleBypass={() => {
+ trackNodeBypassed(node.type, !bypassed, { trackKind });
+ onUpdate(index, { enabled: bypassed });
+ }}
onMove={(delta) => onMove(index, delta)}
onRemove={() => onRemove(index)}
/>
@@ -382,103 +309,30 @@ export function FxNodeRow({
) : null}
{open ? (
- <>
- {/* What it is for, before what it is made of. */}
- {copy?.does ? (
-
- {copy.does}
-
- ) : null}
- {derived && !details ? (
- <>
- {/* Not routed through FxNodeParams: this knob is not in the
- registry, so it has no AudioParam behind it and nothing to
- automate. What automation there is belongs to the parameters it
- sets, under Details, where they can be aimed at individually. */}
-
- ) : null}
- >
- ) : null}
- {primary && !details ? (
- <>
-
- {/* What the two ends of that knob sound like. A number tells an
- author where the control is; this tells them which way to move
- it, which is the question they actually have. */}
- {copy?.primaryEnds ? (
-
- ) : null}
- {/* Where it is working, in the words the rack shares. Only for a
- module that acts on a range at all — there is nothing spectral
- about a limiter, and a ruler under one would be noise. */}
- {copy?.band && typeof params.frequency === "number" ? (
-
- ) : null}
- >
- ) : null}
- {/* The DSP name lives on the disclosure, so it is read at the moment
- the author asks what this really is — and never before. */}
- {oneKnob ? (
-
- ) : (
-
- Details — {registryDef.label}
-
- )}
- {details || !oneKnob ? (
-
- ) : null}
- >
+ setDetails((was) => !was)}
+ copy={copy}
+ params={params}
+ index={index}
+ disabled={disabled}
+ bypassed={bypassed}
+ automatedTargets={automatedTargets}
+ liveAutomationValues={liveAutomationValues}
+ onUpdate={onUpdate}
+ onPreview={onPreview}
+ onAutomateParam={onAutomateParam}
+ onRemoveParamAutomation={onRemoveParamAutomation}
+ trackKind={trackKind}
+ />
) : null}
);
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx b/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx
index c39dfab6d..d31308ed2 100644
--- a/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx
@@ -13,6 +13,7 @@ import {
type HfAudioFxPresetFamily,
} from "@hyperframes/core/audio-fx-presets";
import { PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
+import { useRef } from "react";
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
/**
@@ -41,6 +42,13 @@ export interface FxPresetMenuProps {
*/
trackKind?: HfAudioNameKind;
onPick(id: string): void;
+ /**
+ * Report that a preset was auditioned. Called at most once per preset for as
+ * long as this shelf stays mounted — a pointer crossing the column passes a
+ * dozen items in a second, and counting every crossing would describe mouse
+ * travel rather than interest, while drowning every other event in the rack.
+ */
+ onAuditionTracked?(id: string): void;
/**
* Play this preset on the running audio without persisting it, and revert on
* `null`. Absent when there is no preview channel to hear it through.
@@ -58,7 +66,21 @@ export interface FxPresetMenuProps {
* in a column is a wall, and they are already the author's grouping rather than
* the registry's. See `plans/audio-fx-ux/README.md` §Decided.
*/
-export function FxPresetMenu({ trackKind, onPick, onAudition }: FxPresetMenuProps) {
+export function FxPresetMenu({
+ trackKind,
+ onPick,
+ onAudition,
+ onAuditionTracked,
+}: FxPresetMenuProps) {
+ // Mounted when the shelf opens and thrown away when it closes, so "once per
+ // preset" resets each time the author comes back — a second visit is a second
+ // look, not a duplicate of the first.
+ const auditioned = useRef(new Set());
+ const reportAudition = (id: string) => {
+ if (auditioned.current.has(id)) return;
+ auditioned.current.add(id);
+ onAuditionTracked?.(id);
+ };
// The voice presets all begin by cutting rumble out of a human voice and end
// in a compressor set for speech. On a music bed that is not a mild mismatch,
// it is the wrong instrument — and the shelf leads with the complaint, so it
@@ -97,10 +119,16 @@ export function FxPresetMenu({ trackKind, onPick, onAudition }: FxPresetMenuProp
preset.nodes.length === 1 ? "" : "s"
})`}
onClick={() => onPick(preset.id)}
- onMouseEnter={onAudition ? () => onAudition(preset.id) : undefined}
+ onMouseEnter={() => {
+ reportAudition(preset.id);
+ onAudition?.(preset.id);
+ }}
// Keyboard reaches this too: arrowing down the shelf auditions the
// same way hovering does, or the whole affordance is mouse-only.
- onFocus={onAudition ? () => onAudition(preset.id) : undefined}
+ onFocus={() => {
+ reportAudition(preset.id);
+ onAudition?.(preset.id);
+ }}
>
{PRESET_PROBLEM[preset.id] ?? preset.description}
diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetRun.tsx b/packages/studio/src/components/editor/propertyPanelFxPresetRun.tsx
new file mode 100644
index 000000000..5687f8d84
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFxPresetRun.tsx
@@ -0,0 +1,215 @@
+/**
+ * One preset's bracket in the rack: its own header, on/off switch, amount lane
+ * and the rows it wraps — or, for a run with no preset, just the rows.
+ *
+ * Split out of `propertyPanelFxSection.tsx`, whose `runs.map()` callback this
+ * body used to be — one card per run, hand-built nodes included as runs with no
+ * preset attached.
+ */
+
+import type {
+ HfAudioFxNode,
+ HfAudioFxParam,
+ HfAudioFxParamValues,
+} from "@hyperframes/core/audio-fx";
+import { getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
+import { FxParamRow } from "./propertyPanelFxControls.js";
+import { fxPresetBackground, fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
+import { FxNodeRow } from "./propertyPanelFxNodeRow.js";
+
+/**
+ * The one control over a whole preset: how much of it is applied.
+ *
+ * Not in the effect registry — a preset is not an effect — so the row is
+ * fabricated the same way the derived one-knob control is, and rendered by the
+ * ordinary controls.
+ */
+const PRESET_AMOUNT_PARAM: HfAudioFxParam = {
+ kind: "number",
+ key: "amount",
+ label: "Amount",
+ unit: "",
+ min: 0,
+ max: 1,
+ step: 0.01,
+ default: 1,
+ hint: "How much of this preset is applied. Automate it to bring the whole preset in or out over time.",
+};
+
+export interface FxPresetRunProps {
+ run: { preset?: string; items: { node: HfAudioFxNode; i: number }[] };
+ /** The number each row wears, counted over the whole rack. */
+ positions: ReadonlyMap;
+ /** So the last row in the WHOLE chain knows it cannot move further down. */
+ totalNodes: number;
+ automatedTargets?: ReadonlySet;
+ liveAutomationValues?: ReadonlyMap;
+ onAutomateParam?(nodeId: string, paramKey: string): void;
+ onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
+ openNode: number | null;
+ onToggleOpenNode(index: number): void;
+ disabled?: boolean;
+ onUpdateNode(index: number, patch: Partial): void;
+ onMoveNode(index: number, delta: number): void;
+ onRemoveNode(index: number): void;
+ onPreviewNode(index: number, params: HfAudioFxParamValues): void;
+ /** What the track reads as, carried onto each row's own telemetry events. */
+ trackKind?: string;
+ /** Whether this run's card is folded shut. Meaningless when there is no preset. */
+ collapsed: boolean;
+ onToggleCollapse(): void;
+ /** How much of the preset is applied, 0..1 — the switch and the lane read the same value. */
+ amount: number;
+ onSetAmount(amount: number, persist?: boolean): void;
+ onRemoveRun(): void;
+ automated: boolean;
+ onAutomate?(): void;
+ onRemoveAutomation?(): void;
+}
+
+/** One run: a preset's bracket around its nodes, or a bare hand-built node. */
+export function FxPresetRun({
+ run,
+ positions,
+ totalNodes,
+ automatedTargets,
+ liveAutomationValues,
+ onAutomateParam,
+ onRemoveParamAutomation,
+ openNode,
+ onToggleOpenNode,
+ disabled,
+ onUpdateNode,
+ onMoveNode,
+ onRemoveNode,
+ onPreviewNode,
+ trackKind,
+ collapsed,
+ onToggleCollapse,
+ amount,
+ onSetAmount,
+ onRemoveRun,
+ automated,
+ onAutomate,
+ onRemoveAutomation,
+}: FxPresetRunProps) {
+ const rows = run.items.map(({ node, i }) => (
+ onToggleOpenNode(i)}
+ onUpdate={onUpdateNode}
+ onMove={onMoveNode}
+ onRemove={onRemoveNode}
+ onPreview={onPreviewNode}
+ trackKind={trackKind}
+ />
+ ));
+
+ const preset = run.preset ? getAudioFxPreset(run.preset) : null;
+ if (!preset) return rows;
+
+ // On unless every node in it is bypassed: one switched back on means the
+ // preset is doing something, and the switch has to offer to stop it rather
+ // than claiming it has already stopped.
+ const runOn = amount > 0;
+ const style = fxPresetStyle(run.preset ?? "");
+ const background = fxPresetBackground(run.preset ?? "");
+
+ return (
+
+
+
+ {/* The whole preset, on or off. Partly-bypassed reads as off, because
+ "some of it is running" is not a state an author set — it is one
+ they arrived at, and the switch is how they get back out of it. */}
+
+
+
+ {/* The same value the switch sets, so an author can put the preset half
+ in — and the lane below ramps it continuously. */}
+ onSetAmount(Number(v), false)}
+ onCommit={(_k, v) => onSetAmount(Number(v))}
+ onAutomate={onAutomate}
+ onRemoveAutomation={onRemoveAutomation}
+ />
+ {collapsed ? null : rows}
+
+ );
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxRackChain.tsx b/packages/studio/src/components/editor/propertyPanelFxRackChain.tsx
new file mode 100644
index 000000000..69a2e00b3
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFxRackChain.tsx
@@ -0,0 +1,202 @@
+/**
+ * The rack's own signal path: the carve, the Tone EQ modules, and every
+ * hand-built effect or preset run in between — bracketed by the "In this
+ * track" / "Out to mix" labels that say the order is the point.
+ *
+ * Split out of `propertyPanelFxSection.tsx`, which owned this whole chain
+ * before the file grew past a size where the chain and the add/pick menus
+ * were still one thing to read.
+ */
+
+import type { HfAudioFxChain, HfAudioFxNode } from "@hyperframes/core/audio-fx";
+import { readAudioEqBands } from "@hyperframes/core/audio-fx-eq";
+import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve";
+import { trackEqChanged, trackPresetAmount } from "./audioFxTelemetry.js";
+import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
+import { FxEqModule } from "./propertyPanelFxEqModule.js";
+import { FxPresetRun } from "./propertyPanelFxPresetRun.js";
+
+export interface FxRackChainProps {
+ chain: HfAudioFxChain;
+ showCarve: boolean;
+ carveNodes: HfAudioFxNode[];
+ carve: HfCarveSettings | null;
+ sourceOptions: AudioTrackOption[];
+ automatedTargets?: ReadonlySet;
+ liveAutomationValues?: ReadonlyMap;
+ carveOpen: boolean;
+ disabled?: boolean;
+ analysing?: boolean;
+ onToggleCarveOpen(): void;
+ onCarveChange(carve: HfCarveSettings | null): void;
+ onCarvePreview(carve: HfCarveSettings): void;
+ eqIds: string[];
+ openEq: string | null;
+ onToggleEq(eqId: string): void;
+ onPreviewEqBand(eqId: string, band: string, gain: number): void;
+ onCommitEqBand(eqId: string, band: string, gain: number): void;
+ onRemoveEq(eqId: string): void;
+ handBuiltCount: number;
+ runs: { preset?: string; items: { node: HfAudioFxNode; i: number }[] }[];
+ positions: ReadonlyMap;
+ openNode: number | null;
+ onToggleOpenNode(index: number): void;
+ onUpdateNode(index: number, patch: Partial): void;
+ onMoveNode(index: number, delta: number): void;
+ onRemoveNode(index: number): void;
+ onPreviewNode(
+ index: number,
+ params: import("@hyperframes/core/audio-fx").HfAudioFxParamValues,
+ ): void;
+ trackKind?: string;
+ collapsedRuns: ReadonlySet;
+ onToggleCollapse(runKey: string): void;
+ onSetRunAmount(
+ items: { node: HfAudioFxNode; i: number }[],
+ amount: number,
+ persist?: boolean,
+ ): void;
+ onRemoveRun(items: { node: HfAudioFxNode; i: number }[], presetId?: string): void;
+ onAutomateParam?(nodeId: string, paramKey: string): void;
+ onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
+ presetAutomated: ReadonlySet;
+ presetAutomateHandler(presetId: string | undefined, amount: number): (() => void) | undefined;
+ presetRemoveAutomationHandler(presetId: string | undefined): (() => void) | undefined;
+}
+
+export function FxRackChain({
+ chain,
+ showCarve,
+ carveNodes,
+ carve,
+ sourceOptions,
+ automatedTargets,
+ liveAutomationValues,
+ carveOpen,
+ disabled,
+ analysing,
+ onToggleCarveOpen,
+ onCarveChange,
+ onCarvePreview,
+ eqIds,
+ openEq,
+ onToggleEq,
+ onPreviewEqBand,
+ onCommitEqBand,
+ onRemoveEq,
+ handBuiltCount,
+ runs,
+ positions,
+ openNode,
+ onToggleOpenNode,
+ onUpdateNode,
+ onMoveNode,
+ onRemoveNode,
+ onPreviewNode,
+ trackKind,
+ collapsedRuns,
+ onToggleCollapse,
+ onSetRunAmount,
+ onRemoveRun,
+ onAutomateParam,
+ onRemoveParamAutomation,
+ presetAutomated,
+ presetAutomateHandler,
+ presetRemoveAutomationHandler,
+}: FxRackChainProps) {
+ return (
+
+ {/* The rack IS the signal path, and saying so costs two lines. Without
+ them the order reads as a list, which is the one reading that makes
+ "move up" look cosmetic — it is the most consequential control here. */}
+
+ In
+ this track
+
+ {/* Carve leads the rack, which is also where its effects sit in the signal
+ path — corrective work before anything the author added. Present
+ whenever there is a voice for it to listen to, rather than appearing
+ only once it has already produced something: a control that materialises
+ after the fact cannot be the thing you reach for to start. */}
+ {showCarve ? (
+
+ ) : null}
+ {eqIds.map((eqId) => (
+ onToggleEq(eqId)}
+ onPreview={(band, gain) => onPreviewEqBand(eqId, band, gain)}
+ onCommit={(band, gain) => {
+ trackEqChanged(band, gain);
+ onCommitEqBand(eqId, band, gain);
+ }}
+ onRemove={() => onRemoveEq(eqId)}
+ />
+ ))}
+ {handBuiltCount === 0 && eqIds.length === 0 ? (
+
+ {showCarve ? "No other effects on this track." : "No effects on this track."}
+
+ ) : (
+ runs.map((run) => {
+ // How much of the preset is applied. Any node of the run carries
+ // it, and the first is the one the graph reads.
+ const rawAmount = run.items[0]?.node.presetAmount;
+ const amount = typeof rawAmount === "number" ? rawAmount : 1;
+ const runKey = `${run.preset}-${run.items[0]?.i ?? 0}`;
+ return (
+ onToggleCollapse(runKey)}
+ amount={amount}
+ onSetAmount={(v, persist = true) => {
+ if (persist && run.preset) trackPresetAmount(run.preset, v, { trackKind });
+ onSetRunAmount(run.items, v, persist);
+ }}
+ onRemoveRun={() => onRemoveRun(run.items, run.preset)}
+ automated={presetAutomated.has(run.preset ?? "")}
+ onAutomate={presetAutomateHandler(run.preset, amount)}
+ onRemoveAutomation={presetRemoveAutomationHandler(run.preset)}
+ />
+ );
+ })
+ )}
+
+ Out
+ to mix
+
+
+ );
+}
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index eb156c3c2..b262b257c 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -5,158 +5,77 @@
* is not an entry in the chain.
*/
-import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
+import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
import {
defaultAudioFxParams,
- getAudioFxDef,
- HF_AUDIO_FX,
mintAudioFxNodeId,
type HfAudioFxChain,
- type HfAudioFxGroup,
type HfAudioFxNode,
- type HfAudioFxParam,
type HfAudioFxParamValues,
} from "@hyperframes/core/audio-fx";
-import {
- DEFAULT_CARVE,
- type HfAudioNameKind,
- type HfCarveSettings,
-} from "@hyperframes/core/audio-carve";
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
import {
addAudioEq,
audioEqIds,
- readAudioEqBands,
removeAudioEq,
setAudioEqBandGain,
} from "@hyperframes/core/audio-fx-eq";
-import { EFFECT_COPY } from "@hyperframes/core/audio-fx-copy";
import { applyAudioFxProfile, getAudioFxProfile } from "@hyperframes/core/audio-fx-profiles";
-import {
- audioFxJobNode,
- HF_AUDIO_FX_JOBS,
- HF_AUDIO_FX_JOB_TYPES,
- type HfAudioFxJob,
-} from "@hyperframes/core/audio-fx-jobs";
-import { FxParamRow } from "./propertyPanelFxControls.js";
-import { fxPresetBackground, fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
+import { audioFxJobNode, type HfAudioFxJob } from "@hyperframes/core/audio-fx-jobs";
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
-import { FxEqModule } from "./propertyPanelFxEqModule.js";
-import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
-import { FxNodeRow } from "./propertyPanelFxNodeRow.js";
+import { FxRackChain } from "./propertyPanelFxRackChain.js";
+import { FxAddMenu } from "./propertyPanelFxAddMenu.js";
+import { useFxAudition } from "./useFxAudition.js";
+import {
+ nodeOrigin,
+ trackNodeAdded,
+ trackNodeMoved,
+ trackNodeRemoved,
+ trackPresetApplied,
+ trackPresetAuditioned,
+ trackPresetAutomated,
+ trackPresetRemoved,
+} from "./audioFxTelemetry.js";
+import type { FxSectionProps } from "./propertyPanelFxSectionTypes.js";
-export type { AudioTrackOption };
-
-const GROUP_ORDER: HfAudioFxGroup[] = ["filter", "dynamics", "nonlinear", "time"];
-const GROUP_LABEL: Record = {
- filter: "Filters",
- dynamics: "Dynamics",
- nonlinear: "Non-linear",
- time: "Time",
-};
+export type { FxSectionProps } from "./propertyPanelFxSectionTypes.js";
/**
- * The one control over a whole preset: how much of it is applied.
+ * One effect appended, at the values its module opens on.
*
- * Not in the effect registry — a preset is not an effect — so the row is
- * fabricated the same way the derived one-knob control is, and rendered by the
- * ordinary controls.
+ * For most effects that is the registry's defaults. For the five with a
+ * derived knob it is NOT: the registry defaults are not a point on the
+ * profile's curve, so the module opened reading a strength it was not set to —
+ * a compressor arrived showing Evenness 0.67 with its make-up gain at 0 dB,
+ * which is the "quieter as you turn it up" bug the profiles exist to prevent,
+ * on the very first frame. Seeding through the profile puts the knob and the
+ * mechanism in agreement from the start.
*/
-const PRESET_AMOUNT_PARAM: HfAudioFxParam = {
- kind: "number",
- key: "amount",
- label: "Amount",
- unit: "",
- min: 0,
- max: 1,
- step: 0.01,
- default: 1,
- hint: "How much of this preset is applied. Automate it to bring the whole preset in or out over time.",
-};
-
-export interface FxSectionProps {
- chain: HfAudioFxChain;
- /** Targets this track already automates, as `fx..` strings. */
- automatedTargets?: ReadonlySet;
- /**
- * What each automated target is worth at the playhead, by the same key.
- *
- * An automated parameter's stored number is only the seed the lane replaced, so
- * a rack that shows it stands still while the carve is audibly working. Absent,
- * or missing a key, means there is no playhead over this clip and the stored
- * value is the honest one.
- */
- liveAutomationValues?: ReadonlyMap;
- /** Add a lane for one effect parameter, seeded at its current value. */
- onAutomateParam?(nodeId: string, paramKey: string): void;
- /** Delete one effect parameter's lane. */
- onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
- /** Delete every lane belonging to a node that is being removed. */
- onRemoveNodeAutomation?(nodeId: string): void;
- /**
- * Delete the lanes of SEVERAL nodes at once, plus the whole-preset lane when
- * a preset id is given. One call, because each write is computed from the same
- * snapshot and replaces the whole attribute — a loop keeps only its last write.
- */
- onRemoveNodesAutomation?(nodeIds: readonly string[], presetId?: string): void;
- /** Add a lane for a whole preset's amount, seeded where it sits now. */
- onAutomatePreset?(presetId: string, amount: number): void;
- /** Delete that lane. */
- onRemovePresetAutomation?(presetId: string): void;
- /** Presets whose amount a lane already drives. */
- automatedPresets?: ReadonlySet;
- /** Measure this track and write the levelling lane. Absent when unavailable. */
- onLevel?(): void;
- /** Take the levelling stage and its lane back out. */
- onRemoveLevel?(): void;
- /** Whether a levelling stage is already on the track. */
- levelled?: boolean;
- /**
- * Hover-audition of the levelling script: measure this track and play the
- * result without persisting it, and put it back on `false`.
- *
- * Separate from `onChainPreview` because it is the one audition that cannot be
- * synthesised from the chain in hand — the numbers do not exist until the
- * audio has been decoded and measured.
- */
- onAuditionLevel?(on: boolean): void;
- /** Whether that measurement is running, so the button can say so. */
- auditioningLevel?: boolean;
- /**
- * Start the transport for an audition, and stop it on the way out.
- *
- * An audition is written to the running graph, which is silent while the
- * transport is paused — so without this, hovering a preset does nothing at all
- * for a paused author.
- */
- onAuditionTransport?(on: boolean): void;
- /** Structural edits and gesture-end writes; this is the one that persists. */
- onChainChange(chain: HfAudioFxChain): void;
- /** Continuous updates while a control is being dragged. */
- onChainPreview?(chain: HfAudioFxChain): void;
- carve: HfCarveSettings | null;
- /** Gesture-end write; this is the one that persists. */
- onCarveChange(carve: HfCarveSettings | null): void;
- /** Continuous updates while a carve slider is dragged. Without this every
- * pointermove patched the source file and resynced the selection. */
- onCarvePreview?(carve: HfCarveSettings): void;
- /**
- * Set when another track's carve listens to this one, naming it. The carve block
- * is then not offered here at all: this track is the voice, not the bed.
- */
- carvedAgainstBy?: string | null;
- /** Other audio elements that could act as the carve source. */
- sourceOptions: AudioTrackOption[];
- /**
- * What this track reads as, from its id and filename. Passed through to the
- * preset shelf, which hides the Voice family on a track that is plainly music
- * or an effect. Absent means unknown, and unknown keeps everything.
- */
- trackKind?: HfAudioNameKind;
- analysing?: boolean;
- disabled?: boolean;
+function withEffect(base: HfAudioFxChain, type: string): HfAudioFxChain {
+ return {
+ ...base,
+ nodes: [
+ ...base.nodes,
+ {
+ type,
+ id: mintAudioFxNodeId(base),
+ enabled: true,
+ params: getAudioFxProfile(type)
+ ? applyAudioFxProfile(type, 0.5, defaultAudioFxParams(type))
+ : defaultAudioFxParams(type),
+ },
+ ],
+ };
}
+/** The same, for a job — an ordinary node that arrives already named and aimed. */
+function withJob(base: HfAudioFxChain, job: HfAudioFxJob): HfAudioFxChain {
+ return { ...base, nodes: [...base.nodes, audioFxJobNode(job, base)] };
+}
+
+// The preset-run card, the add shelf and the audition machinery are already
+// their own files; what is left is the section deciding which of them to show.
+// fallow-ignore-next-line complexity
export function FxSection({
chain,
automatedTargets,
@@ -200,23 +119,6 @@ export function FxSection({
const [picking, setPicking] = useState(false);
const [openNode, setOpenNode] = useState(0);
- /**
- * The add menu, with the jobs standing in for the effect they are made of.
- *
- * `peaking` is not offered as itself: picking it is picking a machine and
- * leaving the real decision — which range — for afterwards. The jobs are that
- * decision, already made. See `audioFxJobs.ts`.
- */
- const grouped = useMemo(
- () =>
- GROUP_ORDER.map((g) => ({
- group: g,
- defs: HF_AUDIO_FX.filter((d) => d.group === g && !HF_AUDIO_FX_JOB_TYPES.has(d.id)),
- jobs: HF_AUDIO_FX_JOBS.filter((job) => getAudioFxDef(job.type)?.group === g),
- })),
- [],
- );
-
const mutate = useCallback(
(nodes: HfAudioFxNode[]) => onChainChange({ ...chain, nodes }),
[chain, onChainChange],
@@ -232,72 +134,7 @@ export function FxSection({
[chain, onChainPreview],
);
- /**
- * The chain as it is really stored, captured when an audition starts.
- *
- * Auditioning writes through the preview channel, which does not persist and
- * does not come back as a new `chain` prop — so reverting has to remember what
- * was there rather than read it back. Null means nothing is being auditioned,
- * which is also what makes a stray leave a no-op instead of a write.
- */
- const auditionBase = useRef(null);
-
- /**
- * Play something without committing to it, and put it back on the way out.
- *
- * Hearing a preset before choosing it is the strongest affordance in this
- * panel — see `plans/audio-fx-ux/README.md` §Decided. It costs nothing new:
- * the preview channel a slider drag already uses rebuilds the running graph
- * without touching the document.
- */
- const audition = useCallback(
- (make: ((base: HfAudioFxChain) => HfAudioFxChain) | null) => {
- if (!onChainPreview) return;
- if (make) {
- auditionBase.current ??= chain;
- onChainPreview(make(auditionBase.current));
- // After the chain is in the graph, not before: starting the transport
- // first plays a moment of the un-auditioned mix.
- onAuditionTransport?.(true);
- } else if (auditionBase.current) {
- // Stop before reverting, for the mirror of that reason — the last thing
- // heard should be the preset, not a frame of the chain coming back.
- onAuditionTransport?.(false);
- onChainPreview(auditionBase.current);
- auditionBase.current = null;
- }
- },
- [chain, onChainPreview, onAuditionTransport],
- );
-
- /**
- * The preview handler as of the last render, held rather than closed over.
- *
- * The teardown below must run on teardown and at no other time, so its deps
- * have to be empty — and `onChainPreview` is an inline arrow in the group,
- * which re-renders on every playhead tick to move the automation readouts. A
- * dep on it made React tear down and re-run the effect on every one of those
- * ticks, so an audition reverted itself about 30 times a second while the
- * pointer was still on the button: the preset was heard for a frame during
- * playback, which is the exact case the whole affordance exists for.
- */
- const previewRef = useRef(onChainPreview);
- previewRef.current = onChainPreview;
-
- // Leaving by any route other than the pointer — the element deselected, the
- // panel closed — would otherwise leave the audition playing over a chain the
- // document does not have.
- const transportRef = useRef(onAuditionTransport);
- transportRef.current = onAuditionTransport;
- useEffect(
- () => () => {
- if (auditionBase.current) {
- transportRef.current?.(false);
- previewRef.current?.(auditionBase.current);
- }
- },
- [],
- );
+ const { audition, clearAudition } = useFxAudition(chain, onChainPreview, onAuditionTransport);
const applyPreset = useCallback(
(id: string) => {
@@ -307,78 +144,50 @@ export function FxSection({
// real thing to want, and replacing silently would throw work away — so
// the destructive option is a separate gesture, not the default one.
const next = applyAudioFxPreset(chain, preset);
+ // Re-applying replaces this preset's own nodes in place rather than
+ // appending a second copy, and the two are different decisions — worth
+ // telling apart in the numbers.
+ const reapply = chain.nodes.some((n) => n.fromPreset === preset.id);
+ trackPresetApplied(
+ preset.id,
+ preset.family,
+ preset.nodes.length,
+ reapply ? "reapply" : "append",
+ { trackKind },
+ );
// The audition WAS this, so there is nothing to put back — and putting the
// old chain back over the write that just landed is a race the author
// hears as the preset arriving and then leaving again.
- auditionBase.current = null;
- onAuditionTransport?.(false);
+ clearAudition();
mutate(next.nodes);
// Land on the first node the preset wrote, so the author can hear what
// arrived and immediately see what it is made of.
setOpenNode(next.nodes.findIndex((n) => n.fromPreset === preset.id));
setPicking(false);
},
- [chain, mutate, onAuditionTransport],
- );
-
- /**
- * One effect appended, at the values its module opens on.
- *
- * For most effects that is the registry's defaults. For the five with a
- * derived knob it is NOT: the registry defaults are not a point on the
- * profile's curve, so the module opened reading a strength it was not set to —
- * a compressor arrived showing Evenness 0.67 with its make-up gain at 0 dB,
- * which is the "quieter as you turn it up" bug the profiles exist to prevent,
- * on the very first frame. Seeding through the profile puts the knob and the
- * mechanism in agreement from the start.
- */
- const withEffect = useCallback(
- (base: HfAudioFxChain, type: string): HfAudioFxChain => ({
- ...base,
- nodes: [
- ...base.nodes,
- {
- type,
- id: mintAudioFxNodeId(base),
- enabled: true,
- params: getAudioFxProfile(type)
- ? applyAudioFxProfile(type, 0.5, defaultAudioFxParams(type))
- : defaultAudioFxParams(type),
- },
- ],
- }),
- [],
- );
-
- /** The same, for a job — an ordinary node that arrives already named and aimed. */
- const withJob = useCallback(
- (base: HfAudioFxChain, job: HfAudioFxJob): HfAudioFxChain => ({
- ...base,
- nodes: [...base.nodes, audioFxJobNode(job, base)],
- }),
- [],
+ [chain, mutate, clearAudition, trackKind],
);
const addJob = useCallback(
(job: HfAudioFxJob) => {
- auditionBase.current = null;
- onAuditionTransport?.(false);
+ clearAudition();
+ trackNodeAdded(job.type, "job", job.id, { trackKind });
mutate(withJob(chain, job).nodes);
setOpenNode(chain.nodes.length);
setAdding(false);
},
- [chain, mutate, withJob, onAuditionTransport],
+ [chain, mutate, clearAudition, trackKind],
);
const addEffect = useCallback(
(type: string) => {
- auditionBase.current = null;
- onAuditionTransport?.(false);
+ clearAudition();
+ trackNodeAdded(type, "effect", null, { trackKind });
mutate(withEffect(chain, type).nodes);
setOpenNode(chain.nodes.length);
setAdding(false);
},
- [chain, mutate, withEffect, onAuditionTransport],
+ [chain, mutate, clearAudition, trackKind],
);
const updateNode = useCallback(
@@ -429,11 +238,12 @@ export function FxSection({
// resurrected the old ramp.
const ids = items.map(({ node }) => node.id).filter((id): id is string => Boolean(id));
if (ids.length > 0 || presetId) onRemoveNodesAutomation?.(ids, presetId);
+ if (presetId) trackPresetRemoved(presetId, { trackKind });
const slots = new Set(items.map((item) => item.i));
mutate(chain.nodes.filter((_, i) => !slots.has(i)));
setOpenNode(null);
},
- [chain.nodes, mutate, onRemoveNodesAutomation],
+ [chain.nodes, mutate, onRemoveNodesAutomation, trackKind],
);
const removeNode = useCallback(
@@ -443,12 +253,14 @@ export function FxSection({
// next effect added takes the same id and inherits the dead envelope —
// arriving with its control disabled and "Automated" without the author
// ever automating it, and baked into the render.
- const removedId = chain.nodes[index]?.id;
+ const removed = chain.nodes[index];
+ const removedId = removed?.id;
if (removedId) onRemoveNodeAutomation?.(removedId);
+ if (removed) trackNodeRemoved(removed.type, nodeOrigin(removed), { trackKind });
mutate(chain.nodes.filter((_, i) => i !== index));
setOpenNode(null);
},
- [chain.nodes, mutate, onRemoveNodeAutomation],
+ [chain.nodes, mutate, onRemoveNodeAutomation, trackKind],
);
// Open by default: the module is the carve's whole control surface now, and a
@@ -515,13 +327,13 @@ export function FxSection({
const [openEq, setOpenEq] = useState(null);
const addEq = useCallback(() => {
- auditionBase.current = null;
- onAuditionTransport?.(false);
+ clearAudition();
const { chain: next, eqId } = addAudioEq(chain);
+ trackNodeAdded("eq", "eq", null, { trackKind });
mutate(next.nodes);
setOpenEq(eqId);
setAdding(false);
- }, [chain, mutate, onAuditionTransport]);
+ }, [chain, mutate, clearAudition, trackKind]);
// Dragging a fader is heard immediately and written once on release, the same
// split every other control in the rack uses.
@@ -557,10 +369,11 @@ export function FxSection({
const next = [...chain.nodes];
const [moved] = next.splice(index, 1);
next.splice(target, 0, moved!);
+ if (moved) trackNodeMoved(moved.type, delta < 0 ? "up" : "down", { trackKind });
mutate(next);
setOpenNode(target);
},
- [chain.nodes, mutate],
+ [chain.nodes, mutate, trackKind],
);
/**
@@ -584,6 +397,26 @@ export function FxSection({
[adding, picking, audition, onAuditionLevel],
);
+ /** Seed a lane for a run's preset amount, or omit the control when one already exists. */
+ const presetAutomateHandler = (
+ presetId: string | undefined,
+ amount: number,
+ ): (() => void) | undefined => {
+ if (!presetId || !onAutomatePreset || presetAutomated.has(presetId)) return undefined;
+ return () => {
+ trackPresetAutomated(presetId, true, { trackKind });
+ onAutomatePreset(presetId, amount);
+ };
+ };
+
+ /** Delete a run's preset-amount lane, or omit the control when there is none. */
+ const presetRemoveAutomationHandler = (
+ presetId: string | undefined,
+ ): (() => void) | undefined => {
+ if (!presetId || !onRemovePresetAutomation || !presetAutomated.has(presetId)) return undefined;
+ return () => onRemovePresetAutomation(presetId);
+ };
+
return (
-
- {/* The rack IS the signal path, and saying so costs two lines. Without
- them the order reads as a list, which is the one reading that makes
- "move up" look cosmetic — it is the most consequential control here. */}
-
- In
- this track
-
- {/* Carve leads the rack, which is also where its effects sit in the signal
- path — corrective work before anything the author added. Present
- whenever there is a voice for it to listen to, rather than appearing
- only once it has already produced something: a control that materialises
- after the fact cannot be the thing you reach for to start. */}
- {showCarve ? (
- setCarveOpen((was) => !was)}
- onCarveChange={onCarveChange}
- onCarvePreview={previewCarve}
- />
- ) : null}
- {eqIds.map((eqId) => (
- setOpenEq((was) => (was === eqId ? null : eqId))}
- onPreview={(band, gain) => previewEqBand(eqId, band, gain)}
- onCommit={(band, gain) => commitEqBand(eqId, band, gain)}
- onRemove={() => removeEq(eqId)}
- />
- ))}
- {handBuilt.length === 0 && eqIds.length === 0 ? (
-
- {showCarve ? "No other effects on this track." : "No effects on this track."}
-
- ) : (
- runs.map((run) => {
- const rows = run.items.map(({ node, i }) => (
- setOpenNode(openNode === i ? null : i)}
- onUpdate={updateNode}
- onMove={moveNode}
- onRemove={removeNode}
- onPreview={previewNode}
- />
- ));
- const preset = run.preset ? getAudioFxPreset(run.preset) : null;
- if (!preset) return rows;
- // On unless every node in it is bypassed: one switched back on means
- // the preset is doing something, and the switch has to offer to stop
- // it rather than claiming it has already stopped.
- // How much of it is applied. Any node of the run carries it, and the
- // first is the one the graph reads.
- const amount = run.items[0]?.node.presetAmount;
- const runAmount = typeof amount === "number" ? amount : 1;
- const runOn = runAmount > 0;
- const runKey = `${run.preset}-${run.items[0]?.i ?? 0}`;
- const collapsed = collapsedRuns.has(runKey);
- const style = fxPresetStyle(run.preset ?? "");
- const background = fxPresetBackground(run.preset ?? "");
- return (
-
-
-
- {/* The whole preset, on or off. Partly-bypassed reads as off,
- because "some of it is running" is not a state an author
- set — it is one they arrived at, and the switch is how they
- get back out of it. */}
-
-
-
- {/* The same value the switch sets, so an author can put the
- preset half in — and the lane below ramps it continuously. */}
- setRunAmount(run.items, Number(v), false)}
- onCommit={(_k, v) => setRunAmount(run.items, Number(v))}
- onAutomate={
- run.preset && onAutomatePreset && !presetAutomated.has(run.preset)
- ? () => onAutomatePreset(run.preset ?? "", runAmount)
- : undefined
- }
- onRemoveAutomation={
- run.preset && onRemovePresetAutomation && presetAutomated.has(run.preset)
- ? () => onRemovePresetAutomation(run.preset ?? "")
- : undefined
- }
- />
- {collapsed ? null : rows}
-
{
- audition(null);
- onAuditionLevel?.(false);
- }}
- // The keyboard's version of leaving. Tabbing between two entries fires
- // this and then the next one's focus, so it reverts and re-auditions.
- onBlur={() => {
- audition(null);
- onAuditionLevel?.(false);
- }}
- >
-
+ setAdding(false)}
+ audition={audition}
+ onAuditionLevel={onAuditionLevel}
+ withJob={withJob}
+ withEffect={withEffect}
+ />
) : null}
{picking ? (
trackPresetAuditioned(id, { trackKind })}
onAudition={
onChainPreview
? (id) => {
diff --git a/packages/studio/src/components/editor/propertyPanelFxSectionTypes.ts b/packages/studio/src/components/editor/propertyPanelFxSectionTypes.ts
new file mode 100644
index 000000000..b6be8f3e6
--- /dev/null
+++ b/packages/studio/src/components/editor/propertyPanelFxSectionTypes.ts
@@ -0,0 +1,91 @@
+/**
+ * `FxSection`'s prop contract, split out of `propertyPanelFxSection.tsx` so the
+ * component file is mostly logic and JSX rather than documentation.
+ */
+
+import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
+import type { HfAudioNameKind, HfCarveSettings } from "@hyperframes/core/audio-carve";
+import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
+
+export interface FxSectionProps {
+ chain: HfAudioFxChain;
+ /** Targets this track already automates, as `fx..` strings. */
+ automatedTargets?: ReadonlySet;
+ /**
+ * What each automated target is worth at the playhead, by the same key.
+ *
+ * An automated parameter's stored number is only the seed the lane replaced, so
+ * a rack that shows it stands still while the carve is audibly working. Absent,
+ * or missing a key, means there is no playhead over this clip and the stored
+ * value is the honest one.
+ */
+ liveAutomationValues?: ReadonlyMap;
+ /** Add a lane for one effect parameter, seeded at its current value. */
+ onAutomateParam?(nodeId: string, paramKey: string): void;
+ /** Delete one effect parameter's lane. */
+ onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
+ /** Delete every lane belonging to a node that is being removed. */
+ onRemoveNodeAutomation?(nodeId: string): void;
+ /**
+ * Delete the lanes of SEVERAL nodes at once, plus the whole-preset lane when
+ * a preset id is given. One call, because each write is computed from the same
+ * snapshot and replaces the whole attribute — a loop keeps only its last write.
+ */
+ onRemoveNodesAutomation?(nodeIds: readonly string[], presetId?: string): void;
+ /** Add a lane for a whole preset's amount, seeded where it sits now. */
+ onAutomatePreset?(presetId: string, amount: number): void;
+ /** Delete that lane. */
+ onRemovePresetAutomation?(presetId: string): void;
+ /** Presets whose amount a lane already drives. */
+ automatedPresets?: ReadonlySet;
+ /** Measure this track and write the levelling lane. Absent when unavailable. */
+ onLevel?(): void;
+ /** Take the levelling stage and its lane back out. */
+ onRemoveLevel?(): void;
+ /** Whether a levelling stage is already on the track. */
+ levelled?: boolean;
+ /**
+ * Hover-audition of the levelling script: measure this track and play the
+ * result without persisting it, and put it back on `false`.
+ *
+ * Separate from `onChainPreview` because it is the one audition that cannot be
+ * synthesised from the chain in hand — the numbers do not exist until the
+ * audio has been decoded and measured.
+ */
+ onAuditionLevel?(on: boolean): void;
+ /** Whether that measurement is running, so the button can say so. */
+ auditioningLevel?: boolean;
+ /**
+ * Start the transport for an audition, and stop it on the way out.
+ *
+ * An audition is written to the running graph, which is silent while the
+ * transport is paused — so without this, hovering a preset does nothing at all
+ * for a paused author.
+ */
+ onAuditionTransport?(on: boolean): void;
+ /** Structural edits and gesture-end writes; this is the one that persists. */
+ onChainChange(chain: HfAudioFxChain): void;
+ /** Continuous updates while a control is being dragged. */
+ onChainPreview?(chain: HfAudioFxChain): void;
+ carve: HfCarveSettings | null;
+ /** Gesture-end write; this is the one that persists. */
+ onCarveChange(carve: HfCarveSettings | null): void;
+ /** Continuous updates while a carve slider is dragged. Without this every
+ * pointermove patched the source file and resynced the selection. */
+ onCarvePreview?(carve: HfCarveSettings): void;
+ /**
+ * Set when another track's carve listens to this one, naming it. The carve block
+ * is then not offered here at all: this track is the voice, not the bed.
+ */
+ carvedAgainstBy?: string | null;
+ /** Other audio elements that could act as the carve source. */
+ sourceOptions: AudioTrackOption[];
+ /**
+ * What this track reads as, from its id and filename. Passed through to the
+ * preset shelf, which hides the Voice family on a track that is plainly music
+ * or an effect. Absent means unknown, and unknown keeps everything.
+ */
+ trackKind?: HfAudioNameKind;
+ analysing?: boolean;
+ disabled?: boolean;
+}
diff --git a/packages/studio/src/components/editor/useFxAudition.ts b/packages/studio/src/components/editor/useFxAudition.ts
new file mode 100644
index 000000000..6fff72d65
--- /dev/null
+++ b/packages/studio/src/components/editor/useFxAudition.ts
@@ -0,0 +1,95 @@
+/**
+ * Preview a hypothetical chain without committing to it, and put it back on
+ * the way out — the machinery behind hovering a preset or an add-menu item.
+ *
+ * Split out of `propertyPanelFxSection.tsx`, whose `audition` callback and its
+ * teardown effect this was.
+ */
+
+import { useCallback, useEffect, useRef } from "react";
+import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
+
+export function useFxAudition(
+ chain: HfAudioFxChain,
+ onChainPreview: ((chain: HfAudioFxChain) => void) | undefined,
+ onAuditionTransport: ((on: boolean) => void) | undefined,
+) {
+ /**
+ * The chain as it is really stored, captured when an audition starts.
+ *
+ * Auditioning writes through the preview channel, which does not persist and
+ * does not come back as a new `chain` prop — so reverting has to remember what
+ * was there rather than read it back. Null means nothing is being auditioned,
+ * which is also what makes a stray leave a no-op instead of a write.
+ */
+ const auditionBase = useRef(null);
+
+ /**
+ * Play something without committing to it, and put it back on the way out.
+ *
+ * Hearing a preset before choosing it is the strongest affordance in this
+ * panel — see `plans/audio-fx-ux/README.md` §Decided. It costs nothing new:
+ * the preview channel a slider drag already uses rebuilds the running graph
+ * without touching the document.
+ */
+ const audition = useCallback(
+ (make: ((base: HfAudioFxChain) => HfAudioFxChain) | null) => {
+ if (!onChainPreview) return;
+ if (make) {
+ auditionBase.current ??= chain;
+ onChainPreview(make(auditionBase.current));
+ // After the chain is in the graph, not before: starting the transport
+ // first plays a moment of the un-auditioned mix.
+ onAuditionTransport?.(true);
+ } else if (auditionBase.current) {
+ // Stop before reverting, for the mirror of that reason — the last thing
+ // heard should be the preset, not a frame of the chain coming back.
+ onAuditionTransport?.(false);
+ onChainPreview(auditionBase.current);
+ auditionBase.current = null;
+ }
+ },
+ [chain, onChainPreview, onAuditionTransport],
+ );
+
+ /**
+ * Drop whatever is being auditioned WITHOUT reverting the preview, for a
+ * caller that is about to mutate the real chain anyway — reverting first
+ * would be a chain the document never sees, immediately overwritten.
+ */
+ const clearAudition = useCallback(() => {
+ auditionBase.current = null;
+ onAuditionTransport?.(false);
+ }, [onAuditionTransport]);
+
+ /**
+ * The preview handler as of the last render, held rather than closed over.
+ *
+ * The teardown below must run on teardown and at no other time, so its deps
+ * have to be empty — and `onChainPreview` is an inline arrow in the group,
+ * which re-renders on every playhead tick to move the automation readouts. A
+ * dep on it made React tear down and re-run the effect on every one of those
+ * ticks, so an audition reverted itself about 30 times a second while the
+ * pointer was still on the button: the preset was heard for a frame during
+ * playback, which is the exact case the whole affordance exists for.
+ */
+ const previewRef = useRef(onChainPreview);
+ previewRef.current = onChainPreview;
+
+ // Leaving by any route other than the pointer — the element deselected, the
+ // panel closed — would otherwise leave the audition playing over a chain the
+ // document does not have.
+ const transportRef = useRef(onAuditionTransport);
+ transportRef.current = onAuditionTransport;
+ useEffect(
+ () => () => {
+ if (auditionBase.current) {
+ transportRef.current?.(false);
+ previewRef.current?.(auditionBase.current);
+ }
+ },
+ [],
+ );
+
+ return { audition, clearAudition };
+}
diff --git a/packages/studio/src/components/editor/useFxCarve.ts b/packages/studio/src/components/editor/useFxCarve.ts
new file mode 100644
index 000000000..430a20bd9
Binary files /dev/null and b/packages/studio/src/components/editor/useFxCarve.ts differ
diff --git a/packages/studio/src/components/editor/useFxChainObserved.ts b/packages/studio/src/components/editor/useFxChainObserved.ts
new file mode 100644
index 000000000..169dfba22
--- /dev/null
+++ b/packages/studio/src/components/editor/useFxChainObserved.ts
@@ -0,0 +1,83 @@
+/**
+ * Report the shape of a chain this panel did not write.
+ *
+ * Split out of `propertyPanelAudioFxGroup.tsx`, which owned this whole
+ * provenance mechanism before the file grew past a size where it was still
+ * one thing to read alongside the carve and the leveller.
+ */
+
+import { useEffect, useRef } from "react";
+import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
+import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
+import type { HfAutomation } from "@hyperframes/core/audio-automation";
+import { trackChainObserved } from "./audioFxTelemetry.js";
+import type { DomEditSelection } from "./domEditingTypes";
+
+/**
+ * This is the only way agent-applied effects become visible. An agent asked to
+ * fix a mix does not drive this panel — it edits the composition HTML, or runs
+ * `scripts/carve.mjs`, and the rack simply finds the work already done. Not
+ * one of the panel's own events fires for any of it.
+ *
+ * So: watch the chain's shape, and report it when it changes without a panel
+ * edit behind it. `panelEdits` is the discriminator — this session's own
+ * writes bump it, so a chain that moved while the counter stood still moved
+ * because something outside the studio moved it.
+ *
+ * Keyed on the shape rather than fired once per mount: a soft reload after an
+ * agent edits the file re-mounts this component, and a mount-only event would
+ * either miss the change or double-count every HMR. Comparing the fingerprint
+ * reports real changes and stays quiet through both.
+ *
+ * Returns a wrapped `onSetAttributeQuiet` that every other write in the panel
+ * must go through instead of the raw prop: the count is only meaningful if it
+ * is exhaustive, and a write added later that forgot to route through here
+ * would silently start reporting the author's own edits as having come from
+ * outside.
+ */
+export function useFxChainObserved(
+ element: DomEditSelection,
+ chain: HfAudioFxChain,
+ carve: HfCarveSettings | null,
+ automation: HfAutomation,
+ onSetAttributeQuietRaw: (attr: string, value: string | null) => void | Promise,
+): (attr: string, value: string | null) => void | Promise {
+ const lastShape = useRef(null);
+ const panelEdits = useRef(0);
+
+ const onSetAttributeQuiet = (attr: string, value: string | null): void | Promise => {
+ panelEdits.current += 1;
+ return onSetAttributeQuietRaw(attr, value);
+ };
+
+ useEffect(() => {
+ const shape = JSON.stringify([
+ chain.nodes.map((n) => `${n.type}:${n.fromPreset ?? ""}:${n.fromCarve ? 1 : 0}`),
+ carve?.enabled ?? false,
+ automation.lanes.length,
+ ]);
+ if (lastShape.current === shape) return;
+ const firstSight = lastShape.current === null;
+ lastShape.current = shape;
+ // An empty chain on first sight is the ordinary case — nothing to report.
+ if (firstSight && chain.nodes.length === 0 && !carve) return;
+ trackChainObserved(
+ chain,
+ {
+ firstSight,
+ panelEdits: panelEdits.current,
+ hasCarve: Boolean(carve?.enabled),
+ hasAutomation: automation.lanes.length > 0,
+ },
+ {
+ trackKind: classifyAudioName(element.id, element.element?.getAttribute("src")) ?? undefined,
+ },
+ );
+ // Each observation is measured against the edits made SINCE the last one, so
+ // a session that edits, then receives an outside change, still reports that
+ // second change as unattributed.
+ panelEdits.current = 0;
+ });
+
+ return onSetAttributeQuiet;
+}
diff --git a/packages/studio/src/components/editor/useFxLevelling.ts b/packages/studio/src/components/editor/useFxLevelling.ts
new file mode 100644
index 000000000..26d9cd86a
--- /dev/null
+++ b/packages/studio/src/components/editor/useFxLevelling.ts
@@ -0,0 +1,235 @@
+/**
+ * The levelling script and its playhead-transport audition: measure this
+ * track, write the "Even Out Levels" node and lane, and preview the result
+ * before committing to it.
+ *
+ * Split out of `propertyPanelAudioFxGroup.tsx`, which owned all of this before
+ * the file grew past a size where "levelling" was still one thing to read.
+ */
+
+import { useRef, useState } from "react";
+import {
+ HF_AUDIO_FX_ATTR,
+ serializeAudioFxChain,
+ type HfAudioFxChain,
+} from "@hyperframes/core/audio-fx";
+import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
+import type { HfAutomation } from "@hyperframes/core/audio-automation";
+import {
+ automationAttrValue,
+ HF_AUDIO_AUTOMATION_ATTR,
+ withLane,
+ withoutLane,
+} from "./propertyPanelAutomation";
+import { trackLeveller } from "./audioFxTelemetry.js";
+import type { DomEditSelection } from "./domEditingTypes";
+import { usePlayerStore } from "../../player";
+
+/**
+ * Rate the track is decoded at. Analysis is self-consistent because it reads
+ * the decoded buffer's own rate, so this only has to be a sane audio rate.
+ */
+const DECODE_SAMPLE_RATE = 48000;
+
+/** A parsed attribute, kept only when it is a usable positive number. */
+function positiveFinite(n: number): number | null {
+ return Number.isFinite(n) && n > 0 ? n : null;
+}
+
+export function useFxLevelling(
+ element: DomEditSelection,
+ chain: HfAudioFxChain,
+ automation: HfAutomation,
+ onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise,
+ onSetAttributeLive: (attr: string, value: string | null) => void | Promise,
+ setAnalysing: (value: boolean) => void,
+) {
+ /**
+ * This track's audio, decoded once and kept.
+ *
+ * Levelling is measured from it, and hover-auditioning means measuring on every
+ * pass over the button — fetching and decoding a several-minute voiceover each
+ * time would make the audition slower than the thing it is previewing. Keyed by
+ * `src` so a track pointed at a different file re-decodes.
+ */
+ const decoded = useRef<{ src: string; samples: Float32Array; sampleRate: number } | null>(null);
+
+ const decodeTrack = async (): Promise<{ samples: Float32Array; sampleRate: number } | null> => {
+ const el = element.element;
+ const src = el?.getAttribute("src");
+ const doc = el?.ownerDocument;
+ if (!src || !doc) return null;
+ const cached = decoded.current;
+ if (cached?.src === src) return cached;
+ const Ctor =
+ window.OfflineAudioContext ??
+ (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
+ .webkitOfflineAudioContext;
+ if (!Ctor) return null;
+ const res = await fetch(new URL(src, doc.baseURI).href);
+ const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
+ await res.arrayBuffer(),
+ );
+ const next = { src, samples: buffer.getChannelData(0), sampleRate: buffer.sampleRate };
+ decoded.current = next;
+ return next;
+ };
+
+ /**
+ * The part of the decoded file this clip actually plays.
+ *
+ * A lane's `t` is seconds from the start of the CLIP, but the decode is the
+ * whole file from its first sample — so measuring a trimmed clip produced an
+ * envelope offset by the trim, and every correction landed early by exactly
+ * `media-start`. Slicing here is what puts the two clocks back on the same
+ * zero.
+ */
+ const clipWindow = (audio: { samples: Float32Array; sampleRate: number }) => {
+ const mediaStart = positiveFinite(Number(element.dataAttributes?.["media-start"] ?? 0));
+ const duration = positiveFinite(Number(element.dataAttributes?.["duration"] ?? Number.NaN));
+ const from = mediaStart
+ ? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
+ : 0;
+ const to = duration
+ ? Math.min(audio.samples.length, from + Math.ceil(duration * audio.sampleRate))
+ : audio.samples.length;
+ return from === 0 && to === audio.samples.length
+ ? audio.samples
+ : audio.samples.subarray(from, to);
+ };
+
+ const runLeveller = async (): Promise => {
+ setAnalysing(true);
+ try {
+ const audio = await decodeTrack();
+ if (!audio) return;
+ const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
+ if (!result) return;
+ trackLeveller("run");
+ await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
+ // Merged by target, never written wholesale: the script describes its own
+ // lane only, and replacing the attribute would take the carve's lanes and
+ // the volume lane with it.
+ const lane = result.automation.lanes[0];
+ if (lane) {
+ void onSetAttributeQuiet(
+ HF_AUDIO_AUTOMATION_ATTR,
+ automationAttrValue(withLane(automation, lane)) || null,
+ );
+ }
+ } catch {
+ // A track whose audio cannot be fetched or decoded simply gets no
+ // levelling, the same way an unreadable carve source is skipped.
+ } finally {
+ setAnalysing(false);
+ }
+ };
+
+ /**
+ * Where the playhead was when an audition started the transport, so leaving
+ * can put it back. Null means this audition did not start playback — the
+ * transport was already running and must be left alone.
+ */
+ const auditionReturn = useRef(null);
+
+ /**
+ * Start playback for an audition, and stop it again on the way out.
+ *
+ * An audition writes the preset to the running graph, which is silent while
+ * the transport is paused — so a paused author hovering a preset heard
+ * nothing at all, and the whole affordance only worked mid-playback. Hovering
+ * now plays from the playhead, and leaving stops and rewinds to exactly where
+ * it started: browsing the shelf must not cost the author their place.
+ *
+ * Already playing, this does nothing in either direction. The author started
+ * that, and stopping their transport because they passed over a preset would
+ * be the panel taking a decision that was not offered to it.
+ */
+ const auditionTransport = (on: boolean): void => {
+ const store = usePlayerStore.getState();
+ if (on) {
+ if (store.isPlaying || auditionReturn.current !== null) return;
+ auditionReturn.current = store.currentTime;
+ store.requestPlayback(true);
+ return;
+ }
+ const returnTo = auditionReturn.current;
+ if (returnTo === null) return;
+ auditionReturn.current = null;
+ store.requestPlayback(false, returnTo);
+ };
+
+ const [auditioningLevel, setAuditioningLevel] = useState(false);
+ /**
+ * Bumped on every enter and leave, so a measurement can tell whether the
+ * pointer is still on the button when it finishes.
+ *
+ * Decoding a long voiceover takes seconds, and a hover that takes seconds is
+ * one the author has usually already left. Applying the result then would put
+ * levelling on a track nobody asked to level, through a channel that does not
+ * persist — so it would be audible, invisible in the document, and gone on the
+ * next reload. This counter is what makes a late result a no-op.
+ */
+ const auditionRun = useRef(0);
+
+ /** Both attributes back to the stored chain, because levelling is a node AND the lane that drives it. */
+ const stopLevelAudition = (): void => {
+ setAuditioningLevel(false);
+ void onSetAttributeLive(
+ HF_AUDIO_FX_ATTR,
+ chain.nodes.length ? serializeAudioFxChain(chain) : null,
+ );
+ void onSetAttributeLive(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(automation) || null);
+ };
+
+ /** Measure this track and play the levelling without persisting it. */
+ const startLevelAudition = async (run: number): Promise => {
+ setAuditioningLevel(true);
+ try {
+ const audio = await decodeTrack();
+ // Gone, or superseded by a later hover. Either way this result is stale.
+ if (!audio || run !== auditionRun.current) return;
+ const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
+ if (!result || run !== auditionRun.current) return;
+ void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
+ const lane = result.automation.lanes[0];
+ if (lane) {
+ void onSetAttributeLive(
+ HF_AUDIO_AUTOMATION_ATTR,
+ automationAttrValue(withLane(automation, lane)) || null,
+ );
+ }
+ } catch {
+ // Same as the real run: a track that cannot be decoded simply does not
+ // audition, rather than failing the panel.
+ } finally {
+ if (run === auditionRun.current) setAuditioningLevel(false);
+ }
+ };
+
+ /**
+ * `false` puts the stored chain and automation back; `true` measures and
+ * plays the result without persisting it.
+ */
+ const auditionLevel = async (on: boolean): Promise => {
+ const run = ++auditionRun.current;
+ if (!on) return stopLevelAudition();
+ await startLevelAudition(run);
+ };
+
+ const removeLeveller = (): void => {
+ trackLeveller("removed");
+ const { chain: next, removedTarget } = removeLevelling(chain);
+ void onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
+ // The lane goes with the node. An orphan keeps driving a parameter that is
+ // no longer in the graph.
+ if (removedTarget) {
+ void onSetAttributeQuiet(
+ HF_AUDIO_AUTOMATION_ATTR,
+ automationAttrValue(withoutLane(automation, removedTarget)) || null,
+ );
+ }
+ };
+
+ return { runLeveller, auditionTransport, auditioningLevel, auditionLevel, removeLeveller };
+}
diff --git a/packages/studio/src/telemetry/agentRuntime.test.ts b/packages/studio/src/telemetry/agentRuntime.test.ts
new file mode 100644
index 000000000..8a727b2fe
--- /dev/null
+++ b/packages/studio/src/telemetry/agentRuntime.test.ts
@@ -0,0 +1,45 @@
+// @vitest-environment happy-dom
+
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ agentRuntimeProperty,
+ resetAgentRuntimeForTests,
+ resolveAgentRuntime,
+} from "./agentRuntime";
+
+afterEach(() => {
+ resetAgentRuntimeForTests();
+ delete window.__HF_CLI_AGENT_RUNTIME;
+});
+
+describe("the agent driving this studio", () => {
+ it("reads what the CLI published", () => {
+ window.__HF_CLI_AGENT_RUNTIME = "claude_code";
+ expect(resolveAgentRuntime()).toBe("claude_code");
+ expect(agentRuntimeProperty()).toBe("claude_code");
+ });
+
+ it("is null when nothing published one — a studio opened by hand", () => {
+ expect(resolveAgentRuntime()).toBeNull();
+ });
+
+ it("treats an empty string as nothing, not as a value", () => {
+ // Two encodings of the same fact is what turns a breakdown into a phantom
+ // gap; "" and absent must not be separate rows.
+ window.__HF_CLI_AGENT_RUNTIME = "";
+ expect(resolveAgentRuntime()).toBeNull();
+ });
+
+ it("reports 'none' rather than nothing as a property", () => {
+ // Most sessions are people, and that is a finding. An omitted property
+ // could not tell it apart from an event that predates the property.
+ expect(agentRuntimeProperty()).toBe("none");
+ });
+
+ it("memoizes, so a later overwrite cannot split one session in two", () => {
+ window.__HF_CLI_AGENT_RUNTIME = "codex";
+ expect(resolveAgentRuntime()).toBe("codex");
+ window.__HF_CLI_AGENT_RUNTIME = "cursor";
+ expect(resolveAgentRuntime()).toBe("codex");
+ });
+});
diff --git a/packages/studio/src/telemetry/agentRuntime.ts b/packages/studio/src/telemetry/agentRuntime.ts
new file mode 100644
index 000000000..d6810daf3
--- /dev/null
+++ b/packages/studio/src/telemetry/agentRuntime.ts
@@ -0,0 +1,62 @@
+/**
+ * Which coding agent, if any, is driving this Studio.
+ *
+ * Studio cannot detect this. The signal lives entirely in the environment of
+ * the CLI process — `CLAUDECODE`, `CURSOR_TRACE_ID` and friends — which the
+ * browser has no access to. So the CLI classifies it once
+ * (`cli/src/telemetry/agent_runtime.ts`) and publishes the resulting category
+ * into the served page as `window.__HF_CLI_AGENT_RUNTIME`, alongside the
+ * distinct id and the canary decisions.
+ *
+ * Null is the common, correct answer: a Studio opened by hand, or served by
+ * Vite in development, has no CLI to ask. Reading it as "no agent" rather than
+ * "unknown" is the honest default — every agent we can name sets a marker, and
+ * an unnamed one is indistinguishable from a person either way.
+ *
+ * Memoized, matching `distinctId.ts`: the value is fixed for the life of the
+ * page, and a caller that reads it per event should not pay for a global lookup
+ * and a type check every time.
+ */
+
+declare global {
+ interface Window {
+ __HF_CLI_AGENT_RUNTIME?: string;
+ }
+}
+
+let resolved: string | null | undefined;
+
+export function resolveAgentRuntime(): string | null {
+ if (resolved !== undefined) return resolved;
+ if (typeof window === "undefined") {
+ resolved = null;
+ return resolved;
+ }
+ const raw = window.__HF_CLI_AGENT_RUNTIME;
+ // A non-empty string only. An empty string means the CLI had nothing to say,
+ // and sending `""` would land in PostHog as a distinct value from absent —
+ // two encodings of the same fact, which is exactly what makes a breakdown
+ // read as a gap that is not there.
+ resolved = typeof raw === "string" && raw.length > 0 ? raw : null;
+ return resolved;
+}
+
+/**
+ * The same answer as a telemetry property: never null, never absent.
+ *
+ * "No agent" is a real, common finding — most sessions are people — so it needs
+ * a value that survives a breakdown. Encoding it as an omitted property would
+ * make "a person used this" indistinguishable from "this event predates the
+ * property", and the two studio transports type their payloads differently
+ * (one permits null, one does not), so left alone they would encode the same
+ * fact two ways. That mismatch has already produced one wrong conclusion in
+ * this project's telemetry; a shared sentinel is what stops it.
+ */
+export function agentRuntimeProperty(): string {
+ return resolveAgentRuntime() ?? "none";
+}
+
+/** Test seam: the memo would otherwise leak between cases in one module load. */
+export function resetAgentRuntimeForTests(): void {
+ resolved = undefined;
+}
diff --git a/packages/studio/src/telemetry/system.ts b/packages/studio/src/telemetry/system.ts
index 73d5fc1f1..3cbf83429 100644
--- a/packages/studio/src/telemetry/system.ts
+++ b/packages/studio/src/telemetry/system.ts
@@ -4,6 +4,8 @@
// No PII — only environment characteristics useful for product analytics.
// ---------------------------------------------------------------------------
+import { agentRuntimeProperty } from "./agentRuntime";
+
export interface BrowserSystemMeta {
user_agent: string;
language: string;
@@ -13,6 +15,8 @@ export interface BrowserSystemMeta {
timezone_offset_minutes: number;
is_mobile: boolean;
studio_version: string;
+ /** Which coding agent is driving this Studio, or null when a person is. */
+ agent_runtime: string;
}
const EMPTY_META: BrowserSystemMeta = {
@@ -24,6 +28,7 @@ const EMPTY_META: BrowserSystemMeta = {
timezone_offset_minutes: 0,
is_mobile: false,
studio_version: "dev",
+ agent_runtime: "none",
};
let cached: BrowserSystemMeta | null = null;
@@ -46,6 +51,9 @@ export function getBrowserSystemMeta(): BrowserSystemMeta {
timezone_offset_minutes: new Date().getTimezoneOffset(),
is_mobile: /Android|iPhone|iPad/i.test(ua),
studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
+ // Same value the `studio:*` transport attaches, from the same accessor —
+ // two families that disagreed here would split every agent breakdown.
+ agent_runtime: agentRuntimeProperty(),
};
return cached;
}
diff --git a/packages/studio/src/utils/studioTelemetry.ts b/packages/studio/src/utils/studioTelemetry.ts
index 373127d0c..1e8dd9608 100644
--- a/packages/studio/src/utils/studioTelemetry.ts
+++ b/packages/studio/src/utils/studioTelemetry.ts
@@ -1,6 +1,7 @@
import { resolveStudioDistinctId } from "../telemetry/distinctId";
import { browserTelemetryAllowed } from "../telemetry/policy";
import { canaryEventProperties } from "../telemetry/canary";
+import { agentRuntimeProperty } from "../telemetry/agentRuntime";
// PostHog public ingest key — write-only, safe to ship in the client bundle
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -42,6 +43,11 @@ function isEnabled(): boolean {
function getSessionProperties(): EventProperties {
return {
studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
+ // On EVERY event, not just the audio ones. "Which of these sessions was a
+ // person and which was an agent" is a question worth asking of any feature,
+ // and a property that only some events carry cannot answer it — the
+ // breakdown silently reads as though the agent never used the rest.
+ agent_runtime: agentRuntimeProperty(),
screen_width: window.screen?.width,
screen_height: window.screen?.height,
viewport_width: window.innerWidth,
@@ -73,9 +79,8 @@ export function trackStudioEvent(event: string, properties: EventProperties = {}
}
}
-async function flushEvents(): Promise {
- if (queue.length === 0) return;
-
+/** The queue, shaped for PostHog's batch endpoint — shared by both drain paths. */
+function drainBatch() {
const batch = queue.map((e) => ({
event: e.event,
properties: { ...e.properties, $ip: null },
@@ -83,6 +88,13 @@ async function flushEvents(): Promise {
timestamp: e.timestamp,
}));
queue = [];
+ return batch;
+}
+
+async function flushEvents(): Promise {
+ if (queue.length === 0) return;
+
+ const batch = drainBatch();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
@@ -112,13 +124,7 @@ export function flushViaBeacon(): void {
flushTimer = null;
}
if (queue.length === 0) return;
- const batch = queue.map((e) => ({
- event: e.event,
- properties: { ...e.properties, $ip: null },
- distinct_id: getDistinctId(),
- timestamp: e.timestamp,
- }));
- queue = [];
+ const batch = drainBatch();
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
try {
navigator.sendBeacon(`${POSTHOG_HOST}/batch/`, body);
diff --git a/plans/webaudio-stack-handoff.md b/plans/webaudio-stack-handoff.md
new file mode 100644
index 000000000..682a078c0
--- /dev/null
+++ b/plans/webaudio-stack-handoff.md
@@ -0,0 +1,227 @@
+# Web Audio FX stack — session handoff
+
+Written 2026-08-12. Worktree `~/src/wt/hyperframes/webaudio-fx`, branch
+`wa-20d7-fx-telemetry` (the stack tip). Everything below is pushed; local and
+origin are in sync across all 47 branches.
+
+---
+
+## 1. What this is
+
+47 open PRs, one continuous chain from `main` to `wa-20d7-fx-telemetry`,
+grouped as **GitHub stack #3237**. ~32k added lines, of which **15,374 are
+production code** — the rest is tests (14,277, a 0.93:1 ratio) and docs.
+
+The feature: audio effects for HyperFrames compositions. A registry of effects,
+one Web Audio graph shared by preview and offline render, a studio rack panel,
+19 presets / 5 named jobs / 5 one-knob profiles, voiceover carve, a levelling
+script, automation lanes, and telemetry.
+
+**Scope observation worth acting on:** automation lanes are 9,175 lines — 28% of
+everything, `wa-9` through `wa-17`. That is a general timeline-envelope editor
+that audio FX is merely the first consumer of. It reviews as a separate feature
+and arguably should have been one.
+
+---
+
+## 2. Current state
+
+| | |
+| ------------------------------ | -------------------------------------- |
+| Open `wa-*` PRs | 47 |
+| Drafts | 0 |
+| Approved | 1 (#3210) |
+| Changes requested | 1 (#3209 — **fixed, needs re-review**) |
+| Awaiting a verdict | 45 |
+| Branches over the 600-line cap | 0 |
+| Studio tests at the tip | 3,748 pass |
+| Chain | continuous, bases verified unchanged |
+
+Tip commits (newest first):
+
+```
+631f6d9e3 style: run oxfmt over the markdown this stack added
+ec86035b3 docs(skills): correct the claim that a pause spectrum reveals a filter
+2a1708c43 docs(skills): fix two things a retest showed the diagnosis guidance got wrong
+dc0933279 docs(skills): teach the audio skill to diagnose a file nobody described
+e0b36f46b feat(studio): instrument the audio FX rack, including work an agent did
+eca192716 fix(core): stop the rack telling a music bed it will thin the voice out
+1cc4781e0 feat(studio): title the carve, hide voice presets off voice tracks, fix panel contrast
+```
+
+---
+
+## 3. Traps — read before running anything
+
+**Rebuild core after any rebase or branch switch.** `packages/core/src/generated/`
+is gitignored and derived from core sources. Stale, it fails **~157 studio tests**
+with `Cannot read properties of undefined`. This looked exactly like a real
+regression twice this session and was not.
+
+```bash
+cd packages/core && bun run build
+```
+
+**Mid-stack commits do not individually typecheck.** Several branches have real
+`tsc` errors that only resolve further up. Verified byte-identical to the
+original history — do not "fix" them. Check the branch tip, not each commit.
+
+**`bun run format:check` covers markdown.** Running `oxfmt` on `.ts/.tsx` only
+is how 51 Preflight failures happened. Format the whole repo before committing
+docs.
+
+**`gh stack link` tries to re-base the bottom PR onto `main`.** It attempted this
+twice and GitHub's validation blocked it both times. Always snapshot every base
+before and diff after:
+
+```bash
+gh pr list --state open --limit 400 --json number,baseRefName > /tmp/before.json
+# ... operation ...
+# diff number->baseRefName; expect zero changes
+```
+
+**`while read` drops a final line without a trailing newline.** Silently skipped
+`#3019` when marking drafts ready. Verify counts after batch loops.
+
+**Cascading a fix down-stack:** `git rebase --update-refs --onto
+` rewrites all 47 refs in one pass. Git 2.50 supports it. Back up
+first: `git for-each-ref --format='%(refname:short) %(objectname)' 'refs/heads/wa-*' > /tmp/backup.txt`.
+
+---
+
+## 4. Open work, highest value first
+
+### 4.1 #3209 needs re-review (blocker was fixed)
+
+`wa-18c-box-select` deleted #3207's edge-stretch feature — 248-line hook, its
+test, `retimeRange`, and the lane wiring. All four reviewer claims verified true.
+Restored and folded into the consolidated hook.
+
+**One arbitration call needs your ruling.** #3207's rule: a selection's edge
+outranks a point sitting on it (because every range op leaves a breakpoint on the
+edge it created, so point-first broke the second stretch). #3209 changed the
+selection to a **box**, where that contradicts its own test — which presses at
+t=0/v=1, simultaneously the t0 edge and a selected point.
+
+I inverted it: **selected content wins, the edge stretches everywhere it is not
+also selected content.** Defensible under a box, but it is a product decision
+between two deliberate designs. Confirm or reverse.
+
+### 4.2 CI failures not yet investigated
+
+`regression`, `preview-regression`, `player-perf` across many PRs. `main` is
+green, so these are ours. Needs per-PR log analysis; may be flaky. Not touched.
+
+### 4.3 Two files over the 600-line cap at the tip
+
+`propertyPanelFxSection.tsx` (1017) and `propertyPanelAudioFxGroup.tsx` (1003).
+Pre-existing, grown by the telemetry PR. **CI's path filter skipped the check on
+#3229**, so nothing catches it. Real violation hiding behind a filter.
+
+### 4.4 Three pre-existing test failures
+
+`FxSection carve` "toFixed" on `wa-2-fx-preview`. Confirmed pre-existing by
+stashing. Separate from anything done this session.
+
+### 4.5 Review throughput
+
+45 of 47 have no verdict. 13 were drafts until this session. The stack merges
+bottom-up, so #3019 → #3020 → … is the order. Landing the bottom few unblocks
+everything.
+
+---
+
+## 5. Telemetry and dashboard
+
+**[PostHog dashboard 1986431](https://us.posthog.com/project/356858/dashboard/1986431)**
+"Audio FX rack — usage", project **356858**. 12 HogQL tiles, every query verified
+to execute. **Tiles stay empty until a build carrying `studio:audio_fx_*` ships** —
+that is expected, not broken.
+
+Two things to know:
+
+- **`agent_runtime` is on EVERY `studio:*` event**, not just audio. The CLI
+ detects the driving agent from its own env (12 vendors,
+ `cli/src/telemetry/agent_runtime.ts`) and publishes it as
+ `window.__HF_CLI_AGENT_RUNTIME`; studio reads it via
+ `telemetry/agentRuntime.ts`. Encoded as the string `"none"`, never omitted —
+ this project has already produced one wrong conclusion from comparing a
+ populated sentinel against an absence.
+- **Agent-applied effects are only visible via `audio_fx_chain_observed`.** An
+ agent edits the composition HTML or runs `carve.mjs`, so no panel event fires.
+ That event carries `authored_outside` (no panel edits behind the change).
+ `carve.mjs` is deliberately NOT instrumented — its output is already
+ identifiable by the `fromCarve` tag.
+
+**You cannot verify telemetry locally.** `browserTelemetryAllowed()` is false
+under Vite dev, and the CLI's `isDevMode()` is true whenever it runs from `.ts`
+source. Both guards exist to stop developers polluting production. Do not defeat
+them; first real data arrives from a released build.
+
+Read the vault page `posthog-cli-telemetry-query-traps` before writing any HogQL —
+it carries the `is_ci` denominator and clock-boundary traps.
+
+---
+
+## 6. The `/hyperframes-audio` skill
+
+Extended this session with `references/presets.md` (the catalogue) and
+`references/diagnosis.md` (how to diagnose a file you cannot hear).
+
+**Evaluated, not assumed.** Blind runs on damaged audio, agent holding the skill,
+no labels. **2 of 4 correct** — and three rounds of doc improvements did not move
+that number, which is the finding.
+
+The structural result:
+
+> **Additive defects are solvable. Filter defects are not, from the file alone.**
+
+Measured, gap spectrum vs the clean take: rumble (noise added) shows **+44.7 dB**
+in the pause — unmissable. Boomy (+7 dB @ 200 Hz), sibilant (+10 dB @ 7 kHz) and
+dull (−9 dB shelf) show **nothing**. A filter multiplies; applied to a take whose
+gaps sit at the quantisation floor it leaves them there.
+
+So the pause answers _"was something added?"_ and cannot answer _"was something
+filtered?"_. The doc now says never to rule out EQ on a null pause result — one
+run did exactly that and shipped a high-pass for an inaudible −72 dBFS rumble on
+a file whose real problem was no top end.
+
+**Implication:** the fix is a better _reference_, not better prose. An agent that
+applies effects knows the before state; one handed someone else's finished audio
+is in the genuinely under-determined case.
+
+### Test bench
+
+`packages/studio/data/projects/fx-test-bench` — one clean 7 s narration damaged
+11 ways, one per shipped fix. Lint and browser gate pass. `GROUND-TRUTH.md` is
+the answer key and is **marked keep-away-from-anything-being-evaluated**.
+Untracked scratch; will not land in a PR.
+
+---
+
+## 7. Corrections made this session — do not re-derive
+
+- **"44 unreviewed" was wrong.** A _commented_ review leaves `reviewDecision` as
+ `REVIEW_REQUIRED`. 30 of those had been reviewed. Count reviews, not verdicts.
+- **"47/47 have descriptions" was wrong.** I tested body _length_; the unfilled
+ PR template passes it. 13 PRs — the whole lower half — carried the template.
+ All 13 now have real bodies written from their own commits, and 6 had
+ placeholder titles (`"wa 1 fx registry"`) which were rewritten.
+- **PR #3058 is closed**, superseded by the #3207–#3215 split. `wa-18-lane-stretch`
+ and `wa-20d-rack-design` are dead local branches backing zero PRs. Do not push
+ them — `wa-20d-rack-design` would re-add ~2,153 lines of stripped handoff docs.
+- **Case B's sibilance diagnosis was right for unverifiable reasons.** It claimed
+ to measure the room-tone gaps; the gap shows nothing at 7 kHz. Right answer,
+ reasoning I could not reproduce.
+
+---
+
+## 8. Conventions
+
+- `bun`, not pnpm/npm. `oxlint` / `oxfmt`, not eslint/prettier/biome.
+- Commits need `--no-verify` (pre-commit hooks are slow and sometimes fight
+ mid-rebase state).
+- Do not push or update PRs unless asked.
+- Signed commits are required; `filter-branch` strips signatures and the push is
+ rejected with GH013.
+- Composition changes: `npx hyperframes lint` then `npx hyperframes check`.
diff --git a/skills-manifest.json b/skills-manifest.json
index 2d5324cef..f12b4ae85 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -26,8 +26,8 @@
"files": 121
},
"hyperframes-audio": {
- "hash": "8eccbc04ced1dab1",
- "files": 5
+ "hash": "534cea75fe0f2bc6",
+ "files": 6
},
"hyperframes-cli": {
"hash": "e042fcaaa3f9767f",
diff --git a/skills/hyperframes-audio/SKILL.md b/skills/hyperframes-audio/SKILL.md
index e6d8d5947..95ccc63a4 100644
--- a/skills/hyperframes-audio/SKILL.md
+++ b/skills/hyperframes-audio/SKILL.md
@@ -33,6 +33,8 @@ Three attributes carry everything, all on the audio/video element itself:
Exact JSON for each, and the rules a lane must satisfy: `references/attributes.md`.
Every effect with its parameters, ranges and units: `references/fx-registry.md`.
+How to work out what is wrong with a file you cannot hear:
+`references/diagnosis.md`.
**Presets, named jobs and one-knob profiles, plus a symptom-to-fix table:
`references/presets.md`** — read that before hand-building a chain, because one
of the presets or named jobs usually already names the problem.
@@ -112,9 +114,34 @@ flowchart LR
A static carve is the same graph with fixed values and no lanes at all.
+## First, work out what is wrong
+
+The table below starts from "it sounds boomy" — which presumes somebody already
+listened and said so. Handed a file and "fix this", you have no such sentence
+and you cannot listen, so you have to measure. One rule governs all of it:
+
+> **The absolute spectrum of a single unknown voice cannot be diagnosed.**
+> Formants are ±10 dB, fundamentals run 85–255 Hz, and sentences decline 5–6 dB
+> as they end. Every one of those reads as a defect on its own, and every one of
+> them is the speaker.
+
+So compare, and compare against something **inside the same file**: the clean
+original if it exists, otherwise the pauses — whatever is audible in a gap is
+additive, and the gap's spectrum is the channel rather than the voice. Comparing
+against a published average spectrum or a synthesised control voice does not
+work: two speakers differ by more than most defects, and both wrong answers in
+the evaluation behind this guidance came from exactly that.
+
+When there is no original and no usable silence, a static tonal defect is
+genuinely under-determined. Say so and offer the readings that fit, rather than
+picking one and building a chain on it.
+
+Commands, traps and worked recipes: **`references/diagnosis.md`**. Read it
+before diagnosing a file nobody has described.
+
## Start from the symptom
-Before choosing an effect, name what is wrong with the audio. Most bad audio is
+Once you know the band and the kind, name what is wrong with the audio. Most bad audio is
one or two of these, and each has a shipped answer:
| It sounds like | Reach for |
diff --git a/skills/hyperframes-audio/references/diagnosis.md b/skills/hyperframes-audio/references/diagnosis.md
new file mode 100644
index 000000000..e92e0166f
--- /dev/null
+++ b/skills/hyperframes-audio/references/diagnosis.md
@@ -0,0 +1,241 @@
+# Diagnosing audio you cannot hear
+
+The symptom table in `SKILL.md` starts from "it sounds boomy". That presumes
+somebody already listened and said so. Handed a file and "fix this", you have
+no such sentence — and you cannot listen. This is how to get one.
+
+It is worth being blunt about the difficulty first, because the failure mode is
+not "no answer", it is **a confident wrong answer**:
+
+> **The absolute spectrum of a single unknown voice cannot be diagnosed.**
+
+Every voice has peaks and dips of exactly the size an injected filter has.
+Formants are ±10 dB. A speaker's fundamental sits anywhere from 85 to 255 Hz.
+Sentences decline 5–6 dB from start to end as a matter of ordinary prosody. Look
+at one spectrum on its own and you will find "defects" in all of it, and the
+ones you find will be the speaker.
+
+So diagnosis is always **comparison**. The whole method is choosing the right
+thing to compare against.
+
+---
+
+## Compare against something inside the same file
+
+Ranked by how much they can tell you. Prefer the highest one available.
+
+### 1. The clean original, if it exists
+
+If the undamaged take is on disk, this is the whole job — measure both, subtract,
+and the difference _is_ the defect. Nothing below is as good. Look for it before
+anything else.
+
+### 2. The pauses
+
+The strongest reference that lives inside a single file. Speech stops; whatever
+is still there in the gap is not the voice.
+
+**What it answers: "was something added?"**
+
+Anything audible in the pauses is additive — hum, rumble, hiss, room tone. It was
+laid on top, so it can be subtracted, and this is a reliable positive finding.
+
+**What it does NOT answer: "was something filtered?"** — and getting this
+backwards is how the method produces a confident wrong answer.
+
+A filter multiplies. Applied to a file whose gaps already sit at the
+quantisation floor, it leaves them at the quantisation floor: near-silence times
+anything is still near-silence. So the pause carries no trace of it. Measured on
+one take with a −9 dB shelf above 2.5 kHz applied to the whole file:
+
+| | 1 kHz | 5 kHz | tilt |
+| ----------------- | ----- | ----- | --------- |
+| pause, undamaged | −91.0 | −91.0 | +0.0 |
+| pause, shelved | −91.0 | −91.0 | **+0.0** |
+| speech, undamaged | −34.7 | −42.8 | −8.1 |
+| speech, shelved | −35.4 | −48.5 | **−13.1** |
+
+The defect is a clear 5 dB in the speech and **exactly zero** in the pause.
+
+So: **never use a null result from the pause spectrum to rule out EQ.** A run
+that did exactly that — measured the pause, found it smooth, and concluded
+"static EQ of any type or Q is ruled out" — went on to treat an inaudible
+−72 dBFS rumble as the defect and shipped a high-pass for a file whose actual
+problem was that it had no top end.
+
+The pause spectrum _is_ a transfer function only when the gaps carry a real
+recorded noise floor that passed through the same filter. A room-tone bed does;
+a digitally clean take does not. Check which you have before trusting it: if the
+gaps are within a few dB of the quantisation floor, this reference can find
+additive content and nothing else.
+
+### 3. The speech's own tilt, for a suspected filter
+
+When the pause cannot see a filter (above), the only thing left carrying it is
+the speech. Read the tilt across a few 1/3-octave bands rather than any single
+one — `1k / 3.2k / 5k / 7k` is enough to see a shelf:
+
+```bash
+for f in 1000 3200 5000 7000; do third voice.wav $f; done
+```
+
+Speech falls away steadily above about 1 kHz, so a downward slope is expected;
+what you are looking for is a slope that keeps steepening, or a step. In the
+table above, −8.1 dB from 1 k to 5 k is an ordinary voice and −13.1 dB is the
+same voice with 9 dB taken off the top.
+
+**This is a candidate, not a verdict.** Where the ordinary slope ends and a
+defect begins is speaker-dependent, and you have no baseline for this speaker.
+Say what you measured and what it would mean, and let somebody hear it.
+
+### 4. The file against itself over time
+
+For anything level-related, compare each passage to the track's own median rather
+than to a target. That is what `levellingResult` does, and it is why an already
+even track comes back untouched.
+
+---
+
+## Do not compare against a different voice
+
+Both wrong answers in the evaluation that produced this page came from an
+external reference, and both were argued rigorously from bad ground:
+
+- **A published average spectrum** (LTASS and friends). One run concluded
+ "+10 dB above 7 kHz, split-half stable, gating-independent" on a file whose
+ actual defect was +6.6 dB at 200 Hz. Its supporting claim — 10 kHz sitting
+ 6.2 dB above 6.3 kHz — measured 0.6 dB on re-check, and measured the same in
+ the clean original. Published curves are mixed-sex, mixed-corpus, and
+ mixed-microphone; the gap between them and any one speaker is larger than most
+ defects.
+- **A synthesised control voice** (`say`, a TTS take, another narrator). One run
+ generated a control this way, found the spectrum "normal", and missed a −6.9 dB
+ shelf. Two speakers differ by more than 7 dB across the top octaves as a matter
+ of course, so a cross-voice comparison cannot resolve a defect that size.
+
+If neither the original nor usable pauses exist — continuous speech, or gaps that
+are digital silence and so carry no channel — then a static tonal defect is
+**genuinely under-determined**.
+
+Report that. It is a finding, not a failure to find one, and it is the correct
+answer rather than the fallback when the better methods are unavailable. Give
+the author the two or three readings that fit and ask which they hear; they can
+listen, and that one sentence from them collapses the whole problem.
+
+**This is the point where a capable agent goes wrong.** Told a thing is
+under-determined, the instinct is to invent a cleverer measurement and escape
+it — and something will always be found, because a single voice's spectrum is
+full of peaks and valleys that survive any amount of statistical rigour. An
+elaborate novel method reaching a confident conclusion, on a file where the two
+reliable references were both unavailable, is the _signature_ of this failure,
+not evidence against it. If you notice yourself building one, stop and report
+the ambiguity instead.
+
+---
+
+## Recipes
+
+All verified with ffmpeg 8.1.1. `-hide_banner` keeps the output readable;
+`volumedetect` prints to stderr, so do not silence it with `-v error`.
+
+### Band energy, in proportional bands
+
+**Use proportional bandwidths or the numbers lie.** A fixed 2000 Hz-wide band at
+10 kHz collects more energy than a 1200 Hz-wide band at 6.3 kHz for no reason but
+its width, which manufactures a high-frequency excess that is not there. One
+third of an octave is `f × 0.2316`.
+
+```bash
+third() {
+ w=$(python3 -c "print(round($2*0.2316))")
+ ffmpeg -hide_banner -i "$1" -af "bandpass=f=$2:width_type=h:w=$w,volumedetect" \
+ -f null - 2>&1 | grep -m1 mean_volume
+}
+third voice.wav 200 # weight / boom
+third voice.wav 3200 # presence / harshness
+```
+
+Read them as a shape across 100 / 200 / 400 / 1k / 3.2k / 7k, and read the shape
+against a reference from the list above — never on its own.
+
+### The noise floor, and what is in it
+
+```bash
+ffmpeg -hide_banner -i voice.wav -af astats=metadata=1 -f null - 2>&1 | grep -i 'noise floor'
+```
+
+`-inf` means digital silence in the gaps: no additive noise, so rumble, hiss and
+room tone are all ruled out in one command. A real number is the level of
+whatever is sitting under the voice. To see its _shape_, cut a pause out with
+`-ss`/`-t` and run the band recipe on that slice alone.
+
+### Level over time
+
+```bash
+ffmpeg -hide_banner -i voice.wav -af ebur128=framelog=quiet -f null - 2>&1 | tail -6
+```
+
+LRA under ~3 LU is even. Then window it, because LRA hides a single sagging
+passage:
+
+```bash
+for s in 0 1.2 2.4 3.6 4.8 6.0; do
+ ffmpeg -hide_banner -ss $s -t 1.2 -i voice.wav -af volumedetect -f null - 2>&1 |
+ grep -m1 mean_volume
+done
+```
+
+**A 4–6 dB spread across windows is normal speech**, not a defect — sentences
+decline as they end. Injected unevenness looks like 12 dB or more. Levelling a
+track that only has declination flattens the prosody and is heard as robotic.
+
+### Pitch, before blaming the low end
+
+```bash
+ffmpeg -hide_banner -i voice.wav -af "lowpass=f=400,astats=metadata=1" -f null - 2>&1 | grep -i 'peak level'
+```
+
+A voice has no energy below its own fundamental, so a "missing" 100 Hz on a
+speaker whose F0 is 210 Hz is the speaker, not a rolloff.
+
+The same fact runs the other way, and that direction is the trap: **a boost near
+the fundamental is indistinguishable from that voice being naturally chesty.**
+Both look like energy at F0, because both are.
+
+So the rule is symmetric, and the dangerous half is the second one:
+
+- Do not call a peak at F0 a defect on its own evidence.
+- **Do not dismiss one either.** "The peak is at 200 Hz, F0 is 185 Hz, therefore
+ it is the fundamental" is not a diagnosis — it is the same observation
+ restated, and it discards the one candidate most likely to be real. Boominess
+ _is_ excess energy at the bottom of a voice; that is what the word means.
+
+What you can do is measure how much, against the same file's midrange:
+
+```bash
+third voice.wav 200 # or the nearest 1/3-octave band to F0
+third voice.wav 1000
+```
+
+In an ordinary take these land within a couple of dB of each other. A low band
+sitting **more than about 4 dB above the 1 kHz band** is a strong boom or mud
+candidate. Measured across one voice damaged several ways: undamaged +0.9,
+harsh +0.6, dull +2.0; boomy +6.7, muddy +5.8. Treat the figure as indicative
+rather than a threshold — it is one speaker — but the separation is wide, and a
+reading up at +6 is worth raising even when you cannot explain it.
+
+It still cannot tell you whether a filter did that or the speaker did, so report
+it as a candidate. That is the whole answer here: measure it, name it, hand the
+choice to somebody who can hear it.
+
+---
+
+## Then, and only then, the symptom table
+
+Measurement gives you the band and the kind. `SKILL.md`'s table and
+`presets.md`'s fuller one turn that into a fix. Going the other way round —
+picking a plausible fix and finding evidence for it — is how both wrong answers
+in the evaluation happened, and both were long, careful and confident.
+
+One habit that catches it: before applying anything, state what you would expect
+to measure **if you are wrong**, and check that too.