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); + }} + > +
+ + Tone + + {onLevel ? ( + + ) : null} + +
+ {GROUPED.map(({ group, defs, jobs }) => ( +
+ + {GROUP_LABEL[group]} + + {jobs.map((job) => ( + + ))} + {defs.map((d) => ( + + ))} +
+ ))} +
+ ); +} 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 ( + <> +
+ onPreview(index, applyAudioFxProfile(node.type, Number(v), params))} + onCommit={(_k, v) => { + trackProfileCommitted(node.type, Number(v), { trackKind }); + onUpdate(index, { params: applyAudioFxProfile(node.type, Number(v), params) }); + }} + /> +
+ {profile ? ( +

+ {profile.ends.low} + {profile.ends.high} +

+ ) : 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 ? ( +

+ {copy.primaryEnds.low} + {copy.primaryEnds.high} +

+ ) : 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. */} -
- - onPreview(index, applyAudioFxProfile(node.type, Number(v), params)) - } - onCommit={(_k, v) => - onUpdate(index, { params: applyAudioFxProfile(node.type, Number(v), params) }) - } - /> -
- {profile ? ( -

- {profile.ends.low} - {profile.ends.high} -

- ) : 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 ? ( -

- {copy.primaryEnds.low} - {copy.primaryEnds.high} -

- ) : 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} -
- ); + setCarveOpen((was) => !was)} + onCarveChange={onCarveChange} + onCarvePreview={previewCarve} + eqIds={eqIds} + openEq={openEq} + onToggleEq={(eqId) => setOpenEq((was) => (was === eqId ? null : eqId))} + onPreviewEqBand={previewEqBand} + onCommitEqBand={commitEqBand} + onRemoveEq={removeEq} + handBuiltCount={handBuilt.length} + runs={runs} + positions={positions} + openNode={openNode} + onToggleOpenNode={(i) => setOpenNode(openNode === i ? null : i)} + onUpdateNode={updateNode} + onMoveNode={moveNode} + onRemoveNode={removeNode} + onPreviewNode={previewNode} + trackKind={trackKind} + collapsedRuns={collapsedRuns} + onToggleCollapse={(runKey) => + setCollapsedRuns((was) => { + const next = new Set(was); + if (was.has(runKey)) next.delete(runKey); + else next.add(runKey); + return next; }) - )} -

- Out - to mix -

-
+ } + onSetRunAmount={setRunAmount} + onRemoveRun={removeRun} + onAutomateParam={onAutomateParam} + onRemoveParamAutomation={onRemoveParamAutomation} + presetAutomated={presetAutomated} + presetAutomateHandler={presetAutomateHandler} + presetRemoveAutomationHandler={presetRemoveAutomationHandler} + /> {adding ? ( -
{ - 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); - }} - > -
- - Tone - - {onLevel ? ( - - ) : null} - -
- {grouped.map(({ group, defs, jobs }) => ( -
- - {GROUP_LABEL[group]} - - {jobs.map((job) => ( - - ))} - {defs.map((d) => ( - - ))} -
- ))} -
+ 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.