feat(studio): instrument the audio FX rack, including work an agent did (#3229)

* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened

useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a
preview-only commit that useDomEditAttributeCommits.ts never grew — backported
that option support from its own later commit so the two sides of the API
agree. The paste path and its tests were missing the box selection's v0/v1
bounds a sibling commit added to AutomationSelection. The FX panel's carve
controls still edited the six mechanism numbers (maxCutDb, bands,
intelligibilityBias) after carveProfile() collapsed authoring to one Strength
knob, so those fields no longer existed on HfCarveSettings; UI now edits
strength, and analyseCarveBands is called with carveProfile(strength).

Also closes fallow's complexity, dead-code and duplication findings on this
PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math)
and useAutomationRangeDrag.ts (the marquee-select gesture) out of
useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a
resolver into named functions, dropped an export nothing outside its file
used, and shared a step-simplifier between audioCarve's two envelope
builders.

The edge-stretch vs. box-select priority test in TimelineAutomationLane.test
was still pinning the pre-box-select rule (edge wins over a point sitting on
it) that a sibling commit deliberately reversed — a point inside the box is
now selected content, so grabbing it drags the group instead. Updated the
test to the shipped rule instead of the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(core): cap the via conic's weight so an edge-clamped via point can't NaN

A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to
(0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0.
viaConic divided by that zero to get an infinite weight, and shapeVia turned
Infinity into NaN a few steps later (Infinity - Infinity in the quadratic
coefficient). NaN reaching setValueCurveAtTime silences the automated
parameter for the rest of the render.

Capped the weight at 1e6 instead of leaving it unbounded — past that point
the arc already reads as touching the via point, so nothing visible is lost.
Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`,
since NaN fails the original comparison and fell through it.

Review by Miga (PR #3208).

* fix(studio-server): fingerprint the proactive waveform cache key too

The route already keys the waveform cache on the asset's size and mtime as
well as its path, so a rebuilt-in-place file gets fresh peaks instead of
stale ones. generateWaveformCache — the proactive path that runs on upload —
still called buildWaveformCacheKey with the path alone, so it wrote to a
different key than the route reads from (making the pre-generated cache
never found) and kept the exact collision bug this fingerprint exists to fix
on its own path.

Review by Miga (PR #3211).

* style(docs): run oxfmt on the /hyperframes-audio skill docs

Table column widths had drifted out of alignment with oxfmt's own rules,
failing format:check and blocking the Preflight gate every downstream
branch inherits. Whitespace only, no content change.

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

* fix(studio): widen PropertyPanel's resetModules render timeout again

The 20s margin (already once widened for the same reason) is timing out in
CI's full-monorepo Test run — the resetModules()+fresh-import render this
test needs is uncached and competes with every other package's test suite
for the same worker pool, and the same test passes in well under 2s
standalone. Went to 45s rather than re-tuning to whatever number happens to
clear the current CI load, since that number moves every time CI gains a
package.

* fix(studio): stop the single-candidate auto-apply carve firing twice

Two auto-apply effects both fire when sourceOptions.length === 1: the
multi-candidate effect only guards length === 0, so a single candidate
passes it too, and the single-candidate effect passes its own guard right
after — both compute the same sources list and both call setCarve, so the
common case (one narrator, one bed) triggered two decodes, two FFT runs, and
two concurrent attribute writes for one decision.

The multi-candidate effect now defers to its sibling for exactly one
candidate, which already has its own detailed handling for that case.

Review by Miga (PR #3213).

* feat(core): carve against every voice over a bed, always (#3212)

* feat(core): carve against every voice over a bed, always dynamically

A bed usually runs under a whole sequence — a narrator, an interview answer, a
second presenter — and carving against one of them left the others fighting it.
`source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's
clock before anything is measured. That is what keeps one analysis sufficient: the
chain is fixed, so there is no per-voice filter to switch between, and bands drawn
from all the speech there is with envelopes that rise wherever any of it happens
answer the actual question — where and when is speech masking this bed.

Summed rather than averaged: two people talking at once mask more than either
alone. Audio before the bed starts is dropped rather than folded in at zero, since
it plays over nothing and shifting it would put a cut where there is no voice.

`dynamic` is gone. A fixed depth thins the bed through every pause, and once both
have been heard there is no reason to want it, so every carve follows the speech.

Two helpers the panel and the headless script now share instead of each carrying a
copy — two definitions of "what does this name suggest" drift, and then the two
disagree about which track is the voice:

- `classifyAudioName` reads a track's kind from its id and filename together.
  `unknown` is deliberately common: treating an unrecognised name as "not a voice"
  would hide the one track somebody needs to pick.
- `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten
  duration counts as unbounded, not zero — refusing a clip whose length the
  composition leaves to the media would drop the commonest case there is.

Files written before this still load: a single `source` reads as a one-voice list,
a stored `dynamic` is ignored, and an absent attribute means the defaults whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(studio): port the carve UI off the removed source/dynamic fields

#3212 (accidentally squash-merged into this branch instead of main) changed
HfCarveSettings from a single `source` + `dynamic` toggle to a `sources`
list with dynamic mode removed outright — the multi-voice UI consumer that
goes with that shape lands in the very next PR, so this branch was left with
a type that no longer matched its own code.

Minimal port, not the multi-voice redesign that PR does properly: the
"Listen to" picker and analyse() treat sources[0] as the one voice this UI
still understands, and every dynamic-mode branch (the automated envelope
lanes, the toggle, the checkbox) is gone along with the field — a carve is
now always the static value the analysis computes, matching what the type
change made permanent. Test suite trimmed the same way: the automation-lane
and toggle tests covered behavior that no longer exists.

* refactor(studio): break up the FX rack's largest functions and files

Fallow flagged 9 complexity findings and 2 file-size violations after the
telemetry stack landed. Extracts FxPresetRun, FxAddMenu, FxRackChain,
FxNodeOpenBody, FxNodeParams, and useFxAudition/useFxCarve/useFxLevelling/
useFxChainObserved out of propertyPanelFxSection.tsx and
propertyPanelAudioFxGroup.tsx, splits propertyPanelFxNodeRow.tsx's open-face
rendering into its own component, and dedupes a clone in studioTelemetry.ts.
Pure structural move — no behavior change; full test suite still green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-13 15:05:51 -07:00
committed by GitHub
co-authored by Claude Sonnet 5
parent e3ec48adce
commit d6c4774ef4
27 changed files with 2886 additions and 1450 deletions
@@ -17,6 +17,11 @@ const canaryDecisions = vi.fn<() => Record<string, { enabled: boolean; forced: b
// assertion below ran against a refresh that silently did nothing.
const resetPostureCache = vi.fn();
const readConfigFresh = vi.fn();
// Mocked for the same reason as the canary registry above: the real detector
// reads THIS process env, so every string assertion here would pass on a
// developer laptop and fail the moment an agent ran the suite (CLAUDECODE=1),
// or vice versa. The agent-specific behaviour gets its own cases below.
const detectAgent = vi.fn<() => 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("</script><script>alert(1)</script>");
const script = buildCliIdentityScript();
expect(script).not.toContain("</script><script>");
expect(script.match(/<\/script>/g)).toHaveLength(1);
});
});
@@ -23,6 +23,7 @@ import {
shouldTrack as telemetryShouldTrack,
} from "../telemetry/client.js";
import { canaryDecisionsForStudio, type CliCanaryDecision } from "../telemetry/canary.js";
import { detectAgentRuntime } from "../telemetry/agent_runtime.js";
/**
* The CLI's anonymous distinct id to hand to Studio, or null when CLI telemetry
@@ -156,6 +157,27 @@ export function buildCliIdentityScript(options: { includeIdentity?: boolean } =
parts.push(`window.__HF_CLI_CANARY_DECISIONS=${encodeInlineScriptJson(decisions)};`);
}
// Which agent, if any, is driving this CLI — and therefore the Studio it just
// opened. Studio cannot work this out for itself: the signal is entirely in
// the CLI process's environment, which the browser never sees.
//
// Published on the same terms as the decisions above, and for the same
// reason: it is a category derived from the EXISTENCE of well-known vendor
// env vars, never their values (`agent_runtime.ts` is explicit that it never
// reads a value, because some are API keys). One of a dozen fixed strings, or
// absent. Nothing identifying, so it does not belong behind the trusted-Host
// gate that gates the distinct id.
//
// Gated on telemetry being ON, unlike the decisions above. Those exist so a
// Studio whose CLI opted out does not enrol itself, so they have to survive
// the opt-out. This is only ever read to label an event, so with telemetry off
// there is nothing for it to label — publishing it anyway would leave a marker
// in the page of a user who asked not to be measured.
const agent = telemetryShouldTrack() ? detectAgentRuntime() : null;
if (agent) {
parts.push(`window.__HF_CLI_AGENT_RUNTIME=${encodeInlineScriptValue(agent)};`);
}
return parts.length === 0 ? "" : `<script>${parts.join("")}</script>`;
}
@@ -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,
});
});
});
@@ -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<string, string | number | boolean | null | undefined>;
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<string, number>();
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),
});
}
@@ -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<void> => {
// 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<HTMLElement>(`[${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<HTMLAudioElement>("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<void> => {
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<number | null>(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<void> => {
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<void> => {
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<AudioBuffer> => {
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 (
<FxSection
@@ -0,0 +1,21 @@
/**
* Small clip-timing helpers shared by the FX group and its carve/levelling
* hooks — split out of `propertyPanelAudioFxGroup.tsx` so each file that needs
* "where does this clip sit" does not have to redefine it.
*/
/** Where a clip starts on the timeline, in seconds. */
export function clipStart(value: string | null | undefined): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
/** A clip's span, with an unwritten duration left unbounded rather than zero. */
export 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 };
}
@@ -0,0 +1,209 @@
/**
* The add-effect shelf: Tone, the composite jobs, and the raw registry.
*
* Split out of `propertyPanelFxSection.tsx`'s `{adding ? (...) : null}` block —
* the section owns the audition/chain plumbing this shelf drives, and passes it
* straight through.
*/
import {
getAudioFxDef,
HF_AUDIO_FX,
type HfAudioFxChain,
type HfAudioFxGroup,
} from "@hyperframes/core/audio-fx";
import {
HF_AUDIO_FX_JOBS,
HF_AUDIO_FX_JOB_TYPES,
type HfAudioFxJob,
} from "@hyperframes/core/audio-fx-jobs";
import { EFFECT_COPY } from "@hyperframes/core/audio-fx-copy";
const GROUP_ORDER: HfAudioFxGroup[] = ["filter", "dynamics", "nonlinear", "time"];
const GROUP_LABEL: Record<HfAudioFxGroup, string> = {
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 (
<div
className="hf-fx-add-menu space-y-1.5 rounded-[4px] border border-panel-border-input p-1.5"
// On the shelf, not on each button: moving between two of them passes
// through the gap, and a per-button leave would revert on the way.
onMouseLeave={() => {
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);
}}
>
<div className="hf-fx-add-group flex flex-wrap items-center gap-1">
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
Tone
</span>
{onLevel ? (
<button
type="button"
className="hf-fx-add-composite rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
title="Listen to this track and even out its loud and quiet parts."
disabled={disabled || analysing}
onClick={() => {
if (levelled) onRemoveLevel?.();
else onLevel();
onClose();
}}
// The one module here that cannot answer instantly: it has to
// decode the track and measure it before there is anything to
// hear. So it says it is working rather than doing nothing
// visible, and whoever handles this must drop a result that
// arrives after the pointer has gone.
onMouseEnter={
levelled
? undefined
: () => {
audition(null);
onAuditionLevel?.(true);
}
}
onFocus={levelled ? undefined : () => onAuditionLevel?.(true)}
>
{levelled ? "Remove levelling" : "Even Out Levels"}
{auditioningLevel ? <span className="hf-fx-add-working"> measuring</span> : null}
</button>
) : null}
<button
type="button"
// Not hf-fx-add-item: Tone is a composite over several filters, not
// an entry in the effect registry, and a count of the registry must
// not include it.
className="hf-fx-add-composite rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
title="Bass, middle and treble on one set of faders."
// No audition of its own: a Tone module arrives with every band at
// 0 dB, so there is nothing to hear until a fader moves, and a hover
// that changes nothing teaches that hovering does nothing. It still
// has to call the neighbours' auditions off.
onMouseEnter={() => {
audition(null);
onAuditionLevel?.(false);
}}
onClick={onEq}
>
Tone (EQ)
</button>
</div>
{GROUPED.map(({ group, defs, jobs }) => (
<div key={group} className="hf-fx-add-group flex flex-wrap items-center gap-1">
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
{GROUP_LABEL[group]}
</span>
{jobs.map((job) => (
<button
key={job.id}
type="button"
// Same class as any other entry: a job IS an effect, and one
// that looked special would read as a preset rather than as the
// thing the author is about to add.
className="hf-fx-add-item rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
title={job.does}
onClick={() => onJob(job)}
onMouseEnter={() => {
onAuditionLevel?.(false);
audition((base) => withJob(base, job));
}}
onFocus={() => audition((base) => withJob(base, job))}
>
{job.label}
</button>
))}
{defs.map((d) => (
<button
key={d.id}
type="button"
className="hf-fx-add-item rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
// The menu that adds it has to call it what the rack will call
// it, or the author picks "High-pass" and a module named
// "Remove Rumble" appears. The registry's own description stays
// as the tooltip beside the plain one: the mechanism is taught
// here rather than withheld.
title={EFFECT_COPY[d.id] ? `${EFFECT_COPY[d.id]?.does} (${d.label})` : d.description}
onClick={() => onEffect(d.id)}
// Cancels the levelling audition as well as starting its own.
// The shelf's leave handler only fires on the way OUT of the
// menu, so sliding from Even Out Levels straight to here left a
// measurement in flight — and it landed on top of this one, a
// levelled version of the chain as it was, written through a
// channel the document never sees.
onMouseEnter={() => {
onAuditionLevel?.(false);
audition((base) => withEffect(base, d.id));
}}
onFocus={() => audition((base) => withEffect(base, d.id))}
>
{EFFECT_COPY[d.id]?.title ?? d.label}
</button>
))}
</div>
))}
</div>
);
}
@@ -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<typeof getAudioFxProfile>;
disabled?: boolean;
bypassed: boolean;
params: HfAudioFxParamValues;
index: number;
onPreview(index: number, params: HfAudioFxParamValues): void;
onUpdate(index: number, patch: Partial<HfAudioFxNode>): void;
trackKind?: string;
}) {
if (!derived) return null;
return (
<>
<div className="hf-fx-params space-y-0.5 border-t border-panel-border-input px-1.5 py-1.5">
<FxParamRow
param={derived}
value={audioFxProfileStrength(node.type, params)}
disabled={Boolean(disabled) || bypassed}
onChange={(_k, v) => 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) });
}}
/>
</div>
{profile ? (
<p className="hf-fx-node-ends flex justify-between gap-2 px-1.5 pb-1 text-[9px] text-panel-text-2">
<span className="truncate">{profile.ends.low}</span>
<span className="truncate text-right">{profile.ends.high}</span>
</p>
) : 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 (
<>
<FxNodeParams
node={node}
def={onlyPrimary}
index={index}
disabled={Boolean(disabled) || bypassed}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
onUpdate={onUpdate}
onPreview={onPreview}
onAutomateParam={onAutomateParam}
onRemoveParamAutomation={onRemoveParamAutomation}
trackKind={trackKind}
/>
{/* 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 ? (
<p className="hf-fx-node-ends flex justify-between gap-2 px-1.5 pb-1 text-[9px] text-panel-text-2">
<span className="truncate">{copy.primaryEnds.low}</span>
<span className="truncate text-right">{copy.primaryEnds.high}</span>
</p>
) : 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" ? (
<FxBandRuler band={copy.band} at={params.frequency} />
) : 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<typeof getAudioFxProfile>;
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 ? (
<p className="hf-fx-node-does border-t border-panel-border-input px-1.5 py-1 text-[10px] text-panel-text-2">
{copy.does}
</p>
) : null}
{!details ? (
<FxNodeDerivedKnob
node={node}
derived={derived}
profile={profile}
disabled={disabled}
bypassed={bypassed}
params={params}
index={index}
onPreview={onPreview}
onUpdate={onUpdate}
trackKind={trackKind}
/>
) : null}
{!details ? (
<FxNodePrimaryKnob
node={node}
onlyPrimary={onlyPrimary}
primary={primary}
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}
{/* 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 ? (
<button
type="button"
className="hf-fx-node-details flex w-full items-center gap-1 border-t border-panel-border-input px-1.5 py-1 text-left font-mono text-[9px] uppercase tracking-wide text-panel-text-2 hover:text-panel-text-0"
aria-expanded={details}
onClick={onToggleDetails}
>
<span aria-hidden="true">{details ? "▾" : "▸"}</span>
Details {registryDef.label}
</button>
) : (
<p className="hf-fx-node-mechanism border-t border-panel-border-input px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
Details {registryDef.label}
</p>
)}
{details || !oneKnob ? (
<FxNodeParams
node={node}
def={def}
index={index}
disabled={Boolean(disabled) || bypassed}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
onUpdate={onUpdate}
onPreview={onPreview}
onAutomateParam={onAutomateParam}
onRemoveParamAutomation={onRemoveParamAutomation}
trackKind={trackKind}
/>
) : null}
</>
);
}
@@ -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<string> | undefined,
): Set<string> {
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<string>;
liveAutomationValues?: ReadonlyMap<string, number>;
onUpdate(index: number, patch: Partial<HfAudioFxNode>): 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<string, number> | undefined => {
if (!nodeId || !liveAutomationValues?.size) return undefined;
const byKey = new Map<string, number>();
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 (
<FxParams
def={def}
params={node.params ?? defaultAudioFxParams(node.type)}
liveValues={liveValues}
disabled={disabled}
onChange={(params: HfAudioFxParamValues) => 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
}
/>
);
}
@@ -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<string> | undefined,
): Set<string> {
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<string>;
liveAutomationValues?: ReadonlyMap<string, number>;
onUpdate(index: number, patch: Partial<HfAudioFxNode>): 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<string, number> | undefined => {
if (!nodeId || !liveAutomationValues?.size) return undefined;
const byKey = new Map<string, number>();
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 (
<FxParams
def={def}
params={node.params ?? defaultAudioFxParams(node.type)}
liveValues={liveValues}
disabled={disabled}
onChange={(params: HfAudioFxParamValues) => 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({
</p>
) : null}
{open ? (
<>
{/* What it is for, before what it is made of. */}
{copy?.does ? (
<p className="hf-fx-node-does border-t border-panel-border-input px-1.5 py-1 text-[10px] text-panel-text-2">
{copy.does}
</p>
) : 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. */}
<div className="hf-fx-params space-y-0.5 border-t border-panel-border-input px-1.5 py-1.5">
<FxParamRow
param={derived}
value={audioFxProfileStrength(node.type, params)}
disabled={Boolean(disabled) || bypassed}
onChange={(_k, v) =>
onPreview(index, applyAudioFxProfile(node.type, Number(v), params))
}
onCommit={(_k, v) =>
onUpdate(index, { params: applyAudioFxProfile(node.type, Number(v), params) })
}
/>
</div>
{profile ? (
<p className="hf-fx-node-ends flex justify-between gap-2 px-1.5 pb-1 text-[9px] text-panel-text-2">
<span className="truncate">{profile.ends.low}</span>
<span className="truncate text-right">{profile.ends.high}</span>
</p>
) : null}
</>
) : null}
{primary && !details ? (
<>
<FxNodeParams
node={node}
def={onlyPrimary}
index={index}
disabled={Boolean(disabled) || bypassed}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
onUpdate={onUpdate}
onPreview={onPreview}
onAutomateParam={onAutomateParam}
onRemoveParamAutomation={onRemoveParamAutomation}
/>
{/* 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 ? (
<p className="hf-fx-node-ends flex justify-between gap-2 px-1.5 pb-1 text-[9px] text-panel-text-2">
<span className="truncate">{copy.primaryEnds.low}</span>
<span className="truncate text-right">{copy.primaryEnds.high}</span>
</p>
) : 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" ? (
<FxBandRuler band={copy.band} at={params.frequency} />
) : 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 ? (
<button
type="button"
className="hf-fx-node-details flex w-full items-center gap-1 border-t border-panel-border-input px-1.5 py-1 text-left font-mono text-[9px] uppercase tracking-wide text-panel-text-2 hover:text-panel-text-0"
aria-expanded={details}
onClick={() => setDetails((was) => !was)}
>
<span aria-hidden="true">{details ? "\u25BE" : "\u25B8"}</span>
Details {registryDef.label}
</button>
) : (
<p className="hf-fx-node-mechanism border-t border-panel-border-input px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
Details {registryDef.label}
</p>
)}
{details || !oneKnob ? (
<FxNodeParams
node={node}
def={def}
index={index}
disabled={Boolean(disabled) || bypassed}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
onUpdate={onUpdate}
onPreview={onPreview}
onAutomateParam={onAutomateParam}
onRemoveParamAutomation={onRemoveParamAutomation}
/>
) : null}
</>
<FxNodeOpenBody
node={node}
registryDef={registryDef}
def={def}
onlyPrimary={onlyPrimary}
primary={primary}
derived={derived}
profile={profile}
oneKnob={oneKnob}
details={details}
onToggleDetails={() => 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}
</div>
);
@@ -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<string>());
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);
}}
>
<span className="hf-fx-preset-problem block truncate text-[10px]">
{PRESET_PROBLEM[preset.id] ?? preset.description}
@@ -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<number, number>;
/** So the last row in the WHOLE chain knows it cannot move further down. */
totalNodes: number;
automatedTargets?: ReadonlySet<string>;
liveAutomationValues?: ReadonlyMap<string, number>;
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<HfAudioFxNode>): 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 }) => (
<FxNodeRow
// Keyed by id, as the carve module's list above already is. On
// `${type}-${index}` two effects of the same type keep their keys through
// a reorder, so React reuses each row where it stands — and the controls
// hold real state (a half-typed number, an in-flight drag), which then
// lands on whichever effect moved into that slot.
key={node.id ?? `${node.type}-${i}`}
node={node}
index={i}
position={positions.get(i)}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
onAutomateParam={onAutomateParam}
onRemoveParamAutomation={onRemoveParamAutomation}
open={openNode === i}
last={i === totalNodes - 1}
disabled={disabled}
onToggleOpen={() => 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 (
<div
className="hf-fx-preset-run space-y-1 rounded-[4px] border border-l-2 border-dashed border-panel-border-input p-1"
data-fx-preset={run.preset}
data-collapsed={collapsed ? "" : undefined}
// The bracket's edge carries the preset's own colour, the way a module's
// carries its family's — and the wash behind it is the same hue taken to
// near-black, so a rack with three presets in it reads as three regions
// rather than one long list.
style={{
borderLeftColor: style.color,
...(background ? { backgroundColor: background } : {}),
}}
>
<div className="hf-fx-preset-run-head flex min-h-6 items-center gap-1 px-0.5">
<button
type="button"
className={`hf-fx-preset-run-label min-w-0 flex-1 truncate text-left leading-tight hover:opacity-80 ${style.type}`}
// The face and the colour are data, not classes: a Tailwind class
// cannot name a font stack the config does not know, and adding eight
// to the config to style one panel would put them in every
// autocomplete in the studio.
style={{
color: style.color,
...(style.family ? { fontFamily: style.family } : {}),
}}
aria-expanded={!collapsed}
title={
collapsed ? `Show what ${preset.label} contains` : `Hide ${preset.label}'s effects`
}
onClick={onToggleCollapse}
>
<span className="hf-fx-preset-run-caret pr-1 font-mono opacity-60" aria-hidden="true">
{collapsed ? "▸" : "▾"}
</span>
{preset.label}
{/* Collapsed, the count is what says the preset is still a chain
rather than one opaque effect. */}
{collapsed ? (
<span className="hf-fx-preset-run-count pl-1.5 font-mono text-[9px] opacity-60">
{run.items.length}
</span>
) : null}
</button>
{/* 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. */}
<button
type="button"
className="hf-fx-preset-run-toggle rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
aria-pressed={runOn}
title={runOn ? `Switch ${preset.label} off` : `Switch ${preset.label} back on`}
disabled={disabled}
onClick={() => onSetAmount(runOn ? 0 : 1)}
>
{runOn ? "On" : "Off"}
</button>
<button
type="button"
className="hf-fx-preset-run-remove px-1 font-mono text-[11px] text-panel-text-2 hover:text-red-400 disabled:opacity-40"
title={`Remove ${preset.label}`}
disabled={disabled}
onClick={onRemoveRun}
>
&times;
</button>
</div>
{/* The same value the switch sets, so an author can put the preset half
in — and the lane below ramps it continuously. */}
<FxParamRow
param={PRESET_AMOUNT_PARAM}
value={amount}
disabled={disabled || automated}
automated={automated}
onChange={(_k, v) => onSetAmount(Number(v), false)}
onCommit={(_k, v) => onSetAmount(Number(v))}
onAutomate={onAutomate}
onRemoveAutomation={onRemoveAutomation}
/>
{collapsed ? null : rows}
</div>
);
}
@@ -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<string>;
liveAutomationValues?: ReadonlyMap<string, number>;
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<number, number>;
openNode: number | null;
onToggleOpenNode(index: number): void;
onUpdateNode(index: number, patch: Partial<HfAudioFxNode>): 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<string>;
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<string>;
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 (
<div className="hf-fx-chain space-y-1">
{/* 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. */}
<p className="hf-fx-term flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
<span className="hf-fx-term-cap text-panel-text-1">In</span>
<span>this track</span>
</p>
{/* 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 ? (
<FxCarveModule
nodes={carveNodes}
carve={carve ?? { ...DEFAULT_CARVE }}
sourceOptions={sourceOptions}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
open={carveOpen}
disabled={disabled}
analysing={analysing}
onToggleOpen={onToggleCarveOpen}
onCarveChange={onCarveChange}
onCarvePreview={onCarvePreview}
/>
) : null}
{eqIds.map((eqId) => (
<FxEqModule
key={eqId}
eqId={eqId}
bands={readAudioEqBands(chain, eqId)}
open={openEq === eqId}
disabled={disabled}
onToggleOpen={() => 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 ? (
<p className="hf-fx-empty py-1 text-[11px] text-panel-text-2">
{showCarve ? "No other effects on this track." : "No effects on this track."}
</p>
) : (
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 (
<FxPresetRun
key={`preset-${run.preset}-${run.items[0]?.i}`}
run={run}
positions={positions}
totalNodes={chain.nodes.length}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
onAutomateParam={onAutomateParam}
onRemoveParamAutomation={onRemoveParamAutomation}
openNode={openNode}
onToggleOpenNode={onToggleOpenNode}
disabled={disabled}
onUpdateNode={onUpdateNode}
onMoveNode={onMoveNode}
onRemoveNode={onRemoveNode}
onPreviewNode={onPreviewNode}
trackKind={trackKind}
collapsed={collapsedRuns.has(runKey)}
onToggleCollapse={() => 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)}
/>
);
})
)}
<p className="hf-fx-term hf-fx-term-out flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
<span className="hf-fx-term-cap text-panel-text-1">Out</span>
<span>to mix</span>
</p>
</div>
);
}
@@ -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<HfAudioFxGroup, string> = {
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.<nodeId>.<param>` strings. */
automatedTargets?: ReadonlySet<string>;
/**
* 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<string, number>;
/** 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<string>;
/** 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<number | null>(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<HfAudioFxChain | null>(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<string | null>(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 (
<div
className="hf-fx-section space-y-2"
@@ -591,337 +424,78 @@ export function FxSection({
// bubbles to here without the section needing focus of its own.
onKeyDown={closeMenus}
>
<div className="hf-fx-chain space-y-1">
{/* 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. */}
<p className="hf-fx-term flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
<span className="hf-fx-term-cap text-panel-text-1">In</span>
<span>this track</span>
</p>
{/* 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 ? (
<FxCarveModule
nodes={carveNodes}
carve={carve ?? { ...DEFAULT_CARVE }}
sourceOptions={sourceOptions}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
open={carveOpen}
disabled={disabled}
analysing={analysing}
onToggleOpen={() => setCarveOpen((was) => !was)}
onCarveChange={onCarveChange}
onCarvePreview={previewCarve}
/>
) : null}
{eqIds.map((eqId) => (
<FxEqModule
key={eqId}
eqId={eqId}
bands={readAudioEqBands(chain, eqId)}
open={openEq === eqId}
disabled={disabled}
onToggleOpen={() => 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 ? (
<p className="hf-fx-empty py-1 text-[11px] text-panel-text-2">
{showCarve ? "No other effects on this track." : "No effects on this track."}
</p>
) : (
runs.map((run) => {
const rows = run.items.map(({ node, i }) => (
<FxNodeRow
// Keyed by id, as the carve module's list above already is.
// On `${type}-${index}` two effects of the same type keep their
// keys through a reorder, so React reuses each row where it
// stands — and the controls hold real state (a half-typed
// number, an in-flight drag), which then lands on whichever
// effect moved into that slot.
key={node.id ?? `${node.type}-${i}`}
node={node}
index={i}
position={positions.get(i)}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
onAutomateParam={onAutomateParam}
onRemoveParamAutomation={onRemoveParamAutomation}
open={openNode === i}
last={i === chain.nodes.length - 1}
disabled={disabled}
onToggleOpen={() => 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 (
<div
key={`preset-${run.preset}-${run.items[0]?.i}`}
className="hf-fx-preset-run space-y-1 rounded-[4px] border border-l-2 border-dashed border-panel-border-input p-1"
data-fx-preset={run.preset}
data-collapsed={collapsed ? "" : undefined}
// The bracket's edge carries the preset's own colour, the way a
// module's carries its family's — and the wash behind it is the
// same hue taken to near-black, so a rack with three presets in
// it reads as three regions rather than one long list.
style={{
borderLeftColor: style.color,
...(background ? { backgroundColor: background } : {}),
}}
>
<div className="hf-fx-preset-run-head flex min-h-6 items-center gap-1 px-0.5">
<button
type="button"
className={`hf-fx-preset-run-label min-w-0 flex-1 truncate text-left leading-tight hover:opacity-80 ${style.type}`}
// The face and the colour are data, not classes: a Tailwind
// class cannot name a font stack the config does not know,
// and adding eight to the config to style one panel would
// put them in every autocomplete in the studio.
style={{
color: style.color,
...(style.family ? { fontFamily: style.family } : {}),
}}
aria-expanded={!collapsed}
title={
collapsed
? `Show what ${preset.label} contains`
: `Hide ${preset.label}'s effects`
}
onClick={() =>
setCollapsedRuns((was) => {
const next = new Set(was);
if (collapsed) next.delete(runKey);
else next.add(runKey);
return next;
})
}
>
<span
className="hf-fx-preset-run-caret pr-1 font-mono opacity-60"
aria-hidden="true"
>
{collapsed ? "\u25B8" : "\u25BE"}
</span>
{preset.label}
{/* Collapsed, the count is what says the preset is still a
chain rather than one opaque effect. */}
{collapsed ? (
<span className="hf-fx-preset-run-count pl-1.5 font-mono text-[9px] opacity-60">
{run.items.length}
</span>
) : null}
</button>
{/* 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. */}
<button
type="button"
className="hf-fx-preset-run-toggle rounded-[3px] border border-panel-border-input px-1.5 py-0.5 font-mono text-[9px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
aria-pressed={runOn}
title={runOn ? `Switch ${preset.label} off` : `Switch ${preset.label} back on`}
disabled={disabled}
onClick={() => setRunAmount(run.items, runOn ? 0 : 1)}
>
{runOn ? "On" : "Off"}
</button>
<button
type="button"
className="hf-fx-preset-run-remove px-1 font-mono text-[11px] text-panel-text-2 hover:text-red-400 disabled:opacity-40"
title={`Remove ${preset.label}`}
disabled={disabled}
onClick={() => removeRun(run.items, run.preset)}
>
&times;
</button>
</div>
{/* The same value the switch sets, so an author can put the
preset half in — and the lane below ramps it continuously. */}
<FxParamRow
param={PRESET_AMOUNT_PARAM}
value={runAmount}
disabled={disabled || presetAutomated.has(run.preset ?? "")}
automated={presetAutomated.has(run.preset ?? "")}
onChange={(_k, v) => 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}
</div>
);
<FxRackChain
chain={chain}
showCarve={showCarve}
carveNodes={carveNodes}
carve={carve}
sourceOptions={sourceOptions}
automatedTargets={automatedTargets}
liveAutomationValues={liveAutomationValues}
carveOpen={carveOpen}
disabled={disabled}
analysing={analysing}
onToggleCarveOpen={() => 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;
})
)}
<p className="hf-fx-term hf-fx-term-out flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
<span className="hf-fx-term-cap text-panel-text-1">Out</span>
<span>to mix</span>
</p>
</div>
}
onSetRunAmount={setRunAmount}
onRemoveRun={removeRun}
onAutomateParam={onAutomateParam}
onRemoveParamAutomation={onRemoveParamAutomation}
presetAutomated={presetAutomated}
presetAutomateHandler={presetAutomateHandler}
presetRemoveAutomationHandler={presetRemoveAutomationHandler}
/>
{adding ? (
<div
className="hf-fx-add-menu space-y-1.5 rounded-[4px] border border-panel-border-input p-1.5"
// On the shelf, not on each button: moving between two of them passes
// through the gap, and a per-button leave would revert on the way.
onMouseLeave={() => {
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);
}}
>
<div className="hf-fx-add-group flex flex-wrap items-center gap-1">
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
Tone
</span>
{onLevel ? (
<button
type="button"
className="hf-fx-add-composite rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
title="Listen to this track and even out its loud and quiet parts."
disabled={disabled || analysing}
onClick={() => {
if (levelled) onRemoveLevel?.();
else onLevel();
setAdding(false);
}}
// The one module here that cannot answer instantly: it has to
// decode the track and measure it before there is anything to
// hear. So it says it is working rather than doing nothing
// visible, and whoever handles this must drop a result that
// arrives after the pointer has gone.
onMouseEnter={
levelled
? undefined
: () => {
audition(null);
onAuditionLevel?.(true);
}
}
onFocus={levelled ? undefined : () => onAuditionLevel?.(true)}
>
{levelled ? "Remove levelling" : "Even Out Levels"}
{auditioningLevel ? <span className="hf-fx-add-working"> measuring</span> : null}
</button>
) : null}
<button
type="button"
// Not hf-fx-add-item: Tone is a composite over several filters,
// not an entry in the effect registry, and a count of the registry
// must not include it.
className="hf-fx-add-composite rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
title="Bass, middle and treble on one set of faders."
// No audition of its own: a Tone module arrives with every band at
// 0 dB, so there is nothing to hear until a fader moves, and a
// hover that changes nothing teaches that hovering does nothing.
// It still has to call the neighbours' auditions off.
onMouseEnter={() => {
audition(null);
onAuditionLevel?.(false);
}}
onClick={addEq}
>
Tone (EQ)
</button>
</div>
{grouped.map(({ group, defs, jobs }) => (
<div key={group} className="hf-fx-add-group flex flex-wrap items-center gap-1">
<span className="hf-fx-add-group-label w-full font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
{GROUP_LABEL[group]}
</span>
{jobs.map((job) => (
<button
key={job.id}
type="button"
// Same class as any other entry: a job IS an effect, and one
// that looked special would read as a preset rather than as
// the thing the author is about to add.
className="hf-fx-add-item rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
title={job.does}
onClick={() => addJob(job)}
onMouseEnter={() => {
onAuditionLevel?.(false);
audition((base) => withJob(base, job));
}}
onFocus={() => audition((base) => withJob(base, job))}
>
{job.label}
</button>
))}
{defs.map((d) => (
<button
key={d.id}
type="button"
className="hf-fx-add-item rounded-[3px] bg-panel-surface px-1.5 py-0.5 text-[10px] text-panel-text-1 hover:text-panel-text-0"
// The menu that adds it has to call it what the rack will call
// it, or the author picks "High-pass" and a module named
// "Remove Rumble" appears. The registry's own description
// stays as the tooltip beside the plain one: the mechanism is
// taught here rather than withheld.
title={
EFFECT_COPY[d.id] ? `${EFFECT_COPY[d.id]?.does} (${d.label})` : d.description
}
onClick={() => addEffect(d.id)}
// Cancels the levelling audition as well as starting its own.
// The shelf's leave handler only fires on the way OUT of the
// menu, so sliding from Even Out Levels straight to here left a
// measurement in flight — and it landed on top of this one, a
// levelled version of the chain as it was, written through a
// channel the document never sees.
onMouseEnter={() => {
onAuditionLevel?.(false);
audition((base) => withEffect(base, d.id));
}}
onFocus={() => audition((base) => withEffect(base, d.id))}
>
{EFFECT_COPY[d.id]?.title ?? d.label}
</button>
))}
</div>
))}
</div>
<FxAddMenu
disabled={disabled}
analysing={analysing}
levelled={levelled}
auditioningLevel={auditioningLevel}
onLevel={onLevel}
onRemoveLevel={onRemoveLevel}
onEq={addEq}
onJob={addJob}
onEffect={addEffect}
onClose={() => setAdding(false)}
audition={audition}
onAuditionLevel={onAuditionLevel}
withJob={withJob}
withEffect={withEffect}
/>
) : null}
{picking ? (
<FxPresetMenu
trackKind={trackKind}
onPick={applyPreset}
onAuditionTracked={(id) => trackPresetAuditioned(id, { trackKind })}
onAudition={
onChainPreview
? (id) => {
@@ -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.<nodeId>.<param>` strings. */
automatedTargets?: ReadonlySet<string>;
/**
* 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<string, number>;
/** 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<string>;
/** 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;
}
@@ -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<HfAudioFxChain | null>(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 };
}
Binary file not shown.
@@ -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<void>,
): (attr: string, value: string | null) => void | Promise<void> {
const lastShape = useRef<string | null>(null);
const panelEdits = useRef(0);
const onSetAttributeQuiet = (attr: string, value: string | null): void | Promise<void> => {
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;
}
@@ -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<void>,
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>,
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<void> => {
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<number | null>(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<void> => {
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<void> => {
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 };
}
@@ -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");
});
});
@@ -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;
}
+8
View File
@@ -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;
}
+16 -10
View File
@@ -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<void> {
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<void> {
timestamp: e.timestamp,
}));
queue = [];
return batch;
}
async function flushEvents(): Promise<void> {
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);
+227
View File
@@ -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 <fixed-branch>
<old-sha> <tip>` 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`.
+2 -2
View File
@@ -26,8 +26,8 @@
"files": 121
},
"hyperframes-audio": {
"hash": "8eccbc04ced1dab1",
"files": 5
"hash": "534cea75fe0f2bc6",
"files": 6
},
"hyperframes-cli": {
"hash": "e042fcaaa3f9767f",
+28 -1
View File
@@ -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 85255 Hz, and sentences decline 56 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 |
@@ -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 56 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 46 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.