diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 83ae2ba1d..6ec8fb2c1 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -104,6 +104,12 @@ "types": "./dist/audioFxEq.d.ts", "environments": ["browser", "bun", "node"] }, + "./audio-leveller": { + "source": "./src/audioLeveller.ts", + "runtime": "./dist/audioLeveller.js", + "types": "./dist/audioLeveller.d.ts", + "environments": ["browser", "bun", "node"] + }, "./audio-fx-presets": { "source": "./src/audioFxPresets.ts", "runtime": "./dist/audioFxPresets.js", diff --git a/packages/core/package.json b/packages/core/package.json index b9f8f5ecd..f4f4d3628 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -118,6 +118,12 @@ "import": "./src/audioFxEq.ts", "types": "./src/audioFxEq.ts" }, + "./audio-leveller": { + "bun": "./src/audioLeveller.ts", + "node": "./dist/audioLeveller.js", + "import": "./src/audioLeveller.ts", + "types": "./src/audioLeveller.ts" + }, "./audio-fx-presets": { "bun": "./src/audioFxPresets.ts", "node": "./dist/audioFxPresets.js", @@ -418,6 +424,10 @@ "import": "./dist/audioFxEq.js", "types": "./dist/audioFxEq.d.ts" }, + "./audio-leveller": { + "import": "./dist/audioLeveller.js", + "types": "./dist/audioLeveller.d.ts" + }, "./audio-fx-presets": { "import": "./dist/audioFxPresets.js", "types": "./dist/audioFxPresets.d.ts" diff --git a/packages/core/src/audioFx.ts b/packages/core/src/audioFx.ts index 87531da23..1c9f08c0b 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -832,6 +832,11 @@ export interface HfAudioFxNode { * effect type and its bands stay ordinary filters underneath. */ fromEq?: string; + /** + * Set on the gain stage the leveller writes, so re-running replaces it rather + * than stacking a second one — the same contract `fromCarve` has. + */ + fromLeveller?: boolean; /** Absent means enabled — chain files written before the field existed still load. */ enabled?: boolean; params?: HfAudioFxParamValues; @@ -884,6 +889,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain { fromPreset?: unknown; label?: unknown; fromEq?: unknown; + fromLeveller?: unknown; }; if (typeof node.type !== "string" || !BY_ID.has(node.type)) { throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`); @@ -900,6 +906,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain { : {}), ...(typeof node.label === "string" && node.label ? { label: node.label } : {}), ...(typeof node.fromEq === "string" && node.fromEq ? { fromEq: node.fromEq } : {}), + ...(node.fromLeveller === true ? { fromLeveller: true as const } : {}), enabled: node.enabled !== false, params: normalizeAudioFxParams( node.type, @@ -926,6 +933,7 @@ export function serializeAudioFxChain(chain: HfAudioFxChain): string { ...(node.fromPreset ? { fromPreset: node.fromPreset } : {}), ...(node.label ? { label: node.label } : {}), ...(node.fromEq ? { fromEq: node.fromEq } : {}), + ...(node.fromLeveller === true ? { fromLeveller: true } : {}), ...(node.enabled === false ? { enabled: false } : {}), params: normalizeAudioFxParams(node.type, node.params), })), diff --git a/packages/core/src/audioLeveller.test.ts b/packages/core/src/audioLeveller.test.ts new file mode 100644 index 000000000..dc85736b3 --- /dev/null +++ b/packages/core/src/audioLeveller.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "vitest"; +import { + HF_AUDIO_FX_CHAIN_VERSION, + parseAudioFxChain, + serializeAudioFxChain, + type HfAudioFxChain, +} from "./audioFx.js"; +import { sampleAutomationLane } from "./audioAutomation.js"; +import { applyAudioFxPreset, getAudioFxPreset } from "./audioFxPresets.js"; +import { + analyseLevelling, + levellerProfile, + levellingResult, + levellingSummary, + removeLevelling, +} from "./audioLeveller.js"; + +const SR = 48000; +const empty = (): HfAudioFxChain => ({ version: HF_AUDIO_FX_CHAIN_VERSION, nodes: [] }); + +/** A tone whose amplitude changes per section, so the levelling has real work. */ +function uneven(sections: { seconds: number; amp: number }[]): Float32Array { + const total = sections.reduce((n, s) => n + Math.floor(SR * s.seconds), 0); + const out = new Float32Array(total); + let at = 0; + for (const section of sections) { + const n = Math.floor(SR * section.seconds); + for (let i = 0; i < n; i += 1) { + out[at + i] = section.amp * Math.sin((2 * Math.PI * 300 * (at + i)) / SR); + } + at += n; + } + return out; +} + +const at = (points: { t: number; v: number }[], t: number) => + sampleAutomationLane({ target: "fx.n1.gain", points }, t); + +describe("measuring", () => { + it("lifts a quiet passage and leaves the loud one alone", () => { + // Loud, then 18 dB down, then loud again. + const points = analyseLevelling( + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + { seconds: 3, amp: 0.5 }, + ]), + SR, + ); + expect(points.length).toBeGreaterThan(0); + // Mid-quiet-section, well past the attack. + expect(at(points, 5.5)).toBeGreaterThan(3); + // Mid-loud-section, well past the release. + expect(Math.abs(at(points, 2.5))).toBeLessThan(2); + }); + + it("finds nothing to do on a track that is already even", () => { + // A script that always writes something teaches an author it is doing + // nothing; saying "already even" is the useful answer. + expect(analyseLevelling(uneven([{ seconds: 6, amp: 0.4 }]), SR)).toEqual([]); + }); + + it("leaves room tone alone rather than lifting it into the mix", () => { + // Deliberately NOT digital silence: a real pause is quiet but finite, and + // that is the case the floor exists for. Absolute zero would be skipped by + // the isFinite check alone and prove nothing. + const points = analyseLevelling( + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.0008 }, + { seconds: 3, amp: 0.5 }, + ]), + SR, + 1, + ); + // ~56 dB down: a pause with a noise floor, not a quiet passage. Gain + // applied here is gain applied to the room. + expect(Math.abs(at(points, 5.5))).toBeLessThan(1.5); + }); + + it("targets a level the track reaches, not its loudest instant", () => { + // Mostly quiet with one loud burst. Against the PEAK the whole body of the + // track reads as "quiet" and gets hauled up; against a level the track + // actually sustains, the body is already the target and barely moves. + const points = analyseLevelling( + uneven([ + { seconds: 6, amp: 0.08 }, + { seconds: 1, amp: 0.8 }, + { seconds: 5, amp: 0.08 }, + ]), + SR, + 1, + ); + expect(Math.abs(at(points, 3))).toBeLessThan(3); + }); + + it("never asks for more correction than it can justify", () => { + // ~30 dB below the loud section: still well above the silence floor, so it + // IS a passage to lift — and at full strength the raw ask is over 25 dB, + // which is more gain than any quiet passage should be given. + const points = analyseLevelling( + uneven([ + { seconds: 3, amp: 0.6 }, + { seconds: 4, amp: 0.019 }, + ]), + SR, + 1, + ); + expect(points.some((p) => p.v > 6)).toBe(true); + for (const p of points) expect(Math.abs(p.v)).toBeLessThanOrEqual(12); + }); + + it("starts at the clip's start, so the lane does not slide in from nowhere", () => { + const points = analyseLevelling( + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + ); + expect(points[0]?.t).toBe(0); + }); + + it("handles an empty track without inventing a lane", () => { + expect(analyseLevelling(new Float32Array(0), SR)).toEqual([]); + expect(analyseLevelling(uneven([{ seconds: 1, amp: 0.4 }]), 0)).toEqual([]); + }); +}); + +describe("strength", () => { + it("corrects more the further it is turned up", () => { + const track = uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]); + const gentle = at(analyseLevelling(track, SR, 0.1), 5.5); + const strong = at(analyseLevelling(track, SR, 1), 5.5); + expect(strong).toBeGreaterThan(gentle); + }); + + it("still leaves some of the performance in at full strength", () => { + // Driving a track to a flat line removes the performance along with the + // inconsistency, so even 1.0 corrects most rather than all of it. + expect(levellerProfile(1).correction).toBeLessThan(1); + expect(levellerProfile(0).correction).toBeGreaterThan(0); + }); +}); + +describe("what it writes", () => { + it("rides a gain node, because a volume lane can only attenuate", () => { + // VOLUME_RANGE is 0..1 and normaliseEnvelope clamps into it, so a volume + // lane cannot lift a quiet passage at all. This is the whole reason the + // script writes a node instead of only a lane. + const result = levellingResult( + empty(), + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + ); + expect(result).not.toBeNull(); + const node = result!.chain.nodes[0]!; + expect(node.type).toBe("gain"); + expect(node.label).toBe("Even Out Levels"); + expect(node.params?.gain).toBe(0); + expect(result!.automation.lanes[0]!.target).toBe(`fx.${node.id}.gain`); + }); + + it("returns nothing when there is nothing to correct", () => { + expect(levellingResult(empty(), uneven([{ seconds: 6, amp: 0.4 }]), SR)).toBeNull(); + }); + + it("replaces its own stage instead of stacking a second one", () => { + const track = uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]); + const once = levellingResult(empty(), track, SR)!; + const twice = levellingResult(once.chain, track, SR)!; + expect(twice.chain.nodes.filter((n) => n.fromLeveller)).toHaveLength(1); + // Same node id, so the lane it already wrote still addresses the right stage. + expect(twice.chain.nodes[0]!.id).toBe(once.chain.nodes[0]!.id); + }); + + it("goes in FRONT of a trailing limiter, never after it", () => { + // The likely sequence: apply Clean Voice, then even out the levels. Clean + // Voice ends in a Peak Ceiling, and up to 12 dB of lift landing after that + // ceiling means loud material at -1 dBFS goes over full scale and the + // render shears it flat. A ceiling with something after it is not a ceiling. + const voiced = applyAudioFxPreset(empty(), getAudioFxPreset("voice-clean")!); + expect(voiced.nodes[voiced.nodes.length - 1]!.type).toBe("limiter"); + + const result = levellingResult( + voiced, + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + )!; + const types = result.chain.nodes.map((n) => n.type); + expect(types[types.length - 1]).toBe("limiter"); + expect(types[types.length - 2]).toBe("gain"); + expect(result.chain.nodes.find((n) => n.fromLeveller)).toBeTruthy(); + }); + + it("leaves hand-added effects alone", () => { + const chain: HfAudioFxChain = { + version: HF_AUDIO_FX_CHAIN_VERSION, + nodes: [{ type: "reverb", id: "mine", enabled: true }], + }; + const result = levellingResult( + chain, + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + )!; + expect(result.chain.nodes.map((n) => n.id)).toContain("mine"); + expect(result.chain.nodes).toHaveLength(2); + }); + + it("survives the attribute round trip", () => { + const result = levellingResult( + empty(), + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + )!; + const back = parseAudioFxChain(serializeAudioFxChain(result.chain)); + // Without fromLeveller surviving, re-running stacks a second gain stage. + expect(back.nodes.filter((n) => n.fromLeveller)).toHaveLength(1); + expect(back.nodes[0]!.label).toBe("Even Out Levels"); + }); + + it("names the lane it takes away with it", () => { + const result = levellingResult( + empty(), + uneven([ + { seconds: 3, amp: 0.5 }, + { seconds: 3, amp: 0.06 }, + ]), + SR, + )!; + const removed = removeLevelling(result.chain); + expect(removed.chain.nodes).toHaveLength(0); + // An orphaned lane keeps driving a parameter that is no longer there. + expect(removed.removedTarget).toBe(result.automation.lanes[0]!.target); + }); +}); + +describe("what it says", () => { + it("describes the moves rather than listing numbers", () => { + expect( + levellingSummary([ + { t: 0, v: 0 }, + { t: 1, v: 4.2 }, + ]), + ).toMatch(/lifting quiet parts/); + expect(levellingSummary([])).toMatch(/Already even/); + }); +}); diff --git a/packages/core/src/audioLeveller.ts b/packages/core/src/audioLeveller.ts new file mode 100644 index 000000000..8133e03ff --- /dev/null +++ b/packages/core/src/audioLeveller.ts @@ -0,0 +1,247 @@ +/** + * "Even Out Levels" — the first of the adaptive scripts. + * + * A preset cannot fix inconsistent loudness, because the right correction + * depends on the recording. So this measures the track and writes an automation + * lane that lifts the quiet passages toward the loud ones, exactly as the carve + * measures a voice and writes its bands. + * + * It is NOT loudness normalisation. Platform targets (-14 LUFS and friends) are + * ITU-R BS.1770 — K-weighted and gated — and this is plain windowed RMS, so + * calling it LUFS would be a claim the measurement does not support. It is + * named for what it does: evening out. + * + * ## Why the lane rides a `gain` node + * + * The obvious home is the track's volume lane, and that cannot work: volume is + * 0..1 and `normaliseEnvelope` clamps every keyframe into it, so a volume lane + * can only ever attenuate. Lifting a quiet passage needs a `gain` node, which + * spans -60..+12 dB — which is what the audio skill means when it calls `gain` + * "what an automation lane rides when a track has to move". + */ + +import { + fxAutomationTarget, + type HfAutomation, + type HfAutomationPoint, +} from "./audioAutomation.js"; +import { + HF_AUDIO_FX_CHAIN_VERSION, + mintAudioFxNodeId, + normalizeAudioFxParams, + type HfAudioFxChain, + type HfAudioFxNode, +} from "./audioFx.js"; + +/** One window per emitted point; the hop keeps the lane inside its budget. */ +const FRAME = 4096; +const POINT_BUDGET = 400; +/** Below this, a window is silence rather than a quiet passage worth lifting. */ +const FLOOR_BELOW_PEAK_DB = 42; +/** How far a correction may go. Beyond this, lifting a whisper only lifts the room. */ +const MAX_LIFT_DB = 12; +const MAX_CUT_DB = 12; +/** Ignore differences smaller than this — they are not audible and they add points. */ +const SNAP_DB = 0.4; +/** Attack/release in seconds, so a correction rides the phrase and not the syllable. */ +const ATTACK_S = 0.35; +const RELEASE_S = 0.9; + +export interface HfLevellerSettings { + /** 0 = leave it alone, 1 = as even as this can make it. */ + strength: number; +} + +export const DEFAULT_LEVELLER: HfLevellerSettings = { strength: 0.5 }; + +export interface HfLevellerProfile { + /** How much of the measured difference to correct. */ + correction: number; +} + +/** + * One knob to a profile, the shape `carveProfile` established. + * + * At full strength this still corrects only most of the difference: driving a + * track to a flat line removes the performance along with the inconsistency. + */ +export function levellerProfile(strength: number): HfLevellerProfile { + const s = Number.isFinite(strength) ? Math.min(1, Math.max(0, strength)) : 0.5; + return { correction: Number((0.25 + s * 0.6).toFixed(3)) }; +} + +/** Level in dB of one window, or -Infinity for silence. */ +function windowDb(samples: Float32Array, from: number, count: number): number { + let sum = 0; + let n = 0; + for (let i = from; i < from + count; i += 1) { + const s = samples[i]; + if (s === undefined) break; + sum += s * s; + n += 1; + } + if (n === 0) return Number.NEGATIVE_INFINITY; + const rms = Math.sqrt(sum / n); + return rms > 0 ? 20 * Math.log10(rms) : Number.NEGATIVE_INFINITY; +} + +/** + * Measure a track and return the gain moves that even it out, in dB against + * clip-local seconds. Empty when there is nothing worth correcting. + */ +export function analyseLevelling( + samples: Float32Array, + sampleRate: number, + strength = DEFAULT_LEVELLER.strength, +): HfAutomationPoint[] { + if (samples.length === 0 || sampleRate <= 0) return []; + const profile = levellerProfile(strength); + const hop = Math.max(FRAME, Math.ceil(samples.length / POINT_BUDGET)); + + const levels: number[] = []; + const times: number[] = []; + for (let start = 0; start < samples.length; start += hop) { + levels.push(windowDb(samples, start, FRAME)); + times.push((start + FRAME / 2) / sampleRate); + } + const speaking = levels.filter((d) => Number.isFinite(d)); + if (speaking.length === 0) return []; + + const peak = Math.max(...speaking); + const floor = peak - FLOOR_BELOW_PEAK_DB; + /** + * The target is a level the track ALREADY REACHES — the 80th percentile of + * its speaking windows — not an absolute one. + * + * Anchoring it to an absolute figure means an already-even track gets pulled + * bodily up or down to meet it, which is a volume change wearing a + * levelling label. Against a level the track reaches, its loud passages + * correct to roughly nothing and only the quiet ones move, which is what + * evening out means. It is a percentile rather than the peak so one loud + * word cannot set the target for the whole track. + */ + const sorted = [...speaking].sort((a, b) => a - b); + const target = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.8))] ?? peak; + + const attack = 1 - Math.exp(-(hop / sampleRate) / ATTACK_S); + const release = 1 - Math.exp(-(hop / sampleRate) / RELEASE_S); + + let applied = 0; + const raw: HfAutomationPoint[] = []; + levels.forEach((db, i) => { + // Silence is left alone. Lifting a pause only lifts the room with it. + const wanted = + Number.isFinite(db) && db > floor + ? Math.max(-MAX_CUT_DB, Math.min(MAX_LIFT_DB, (target - db) * profile.correction)) + : 0; + applied += (wanted > applied ? attack : release) * (wanted - applied); + const v = Math.abs(applied) < SNAP_DB ? 0 : Number(applied.toFixed(1)); + raw.push({ t: Number((times[i] ?? 0).toFixed(3)), v }); + }); + + // Keep only the moves. A run of equal values is held by keeping the last of + // the run, or interpolation would slide across a passage that is steady. + const points: HfAutomationPoint[] = []; + let lastKept = 0; + let keptIndex = -1; + raw.forEach((pt, i) => { + if (i !== raw.length - 1 && Math.abs(pt.v - lastKept) < SNAP_DB) return; + if (i > 0 && keptIndex !== i - 1) { + const prev = raw[i - 1]; + if (prev) points.push(prev); + } + points.push(pt); + lastKept = pt.v; + keptIndex = i; + }); + + // A lane of zeroes is not a correction, it is a lane that says nothing. An + // author who runs this on an even track should be told there was nothing to + // do, not handed an inert envelope to wonder about. + if (points.length === 0 || points.every((p) => p.v === 0)) return []; + // A lane's first point has to sit at the clip's start, or everything before + // it is drawn from wherever the first move happens to be. + if ((points[0]?.t ?? 0) > 0) points.unshift({ t: 0, v: points[0]?.v ?? 0 }); + return points; +} + +/** + * The chain and lane for "Even Out Levels". + * + * Returns nothing when the track needs no correcting — a script that always + * writes something teaches an author that it is doing nothing. + */ +export function levellingResult( + chain: HfAudioFxChain, + samples: Float32Array, + sampleRate: number, + strength = DEFAULT_LEVELLER.strength, +): { chain: HfAudioFxChain; automation: HfAutomation } | null { + const points = analyseLevelling(samples, sampleRate, strength); + if (points.length === 0) return null; + + const existing = chain.nodes.find((n) => n.fromLeveller); + const id = existing?.id ?? mintAudioFxNodeId(chain); + const node: HfAudioFxNode = { + type: "gain", + id, + fromLeveller: true, + label: "Even Out Levels", + enabled: true, + // Seeded at 0 dB: the lane is what moves it, and a non-zero seed would be + // heard for the instant before the first ramp is scheduled. + params: normalizeAudioFxParams("gain", { gain: 0 }), + }; + + /** + * In FRONT of a trailing limiter, not after it. + * + * The likely sequence is "apply Clean Voice, then even out the levels", and + * Clean Voice ends in a Peak Ceiling. Appending would put up to 12 dB of lift + * AFTER the ceiling that exists to bound the chain — so every quiet-to-loud + * transition leaves residual lift on loud material sitting at -1 dBFS, and + * the render shears it flat. A ceiling that something is added after is not + * a ceiling. + */ + const insertAt = + !existing && chain.nodes[chain.nodes.length - 1]?.type === "limiter" + ? chain.nodes.length - 1 + : chain.nodes.length; + + return { + chain: { + version: HF_AUDIO_FX_CHAIN_VERSION, + nodes: existing + ? chain.nodes.map((n) => (n.fromLeveller ? node : n)) + : [...chain.nodes.slice(0, insertAt), node, ...chain.nodes.slice(insertAt)], + }, + automation: { + version: 1, + lanes: [{ target: fxAutomationTarget(id, "gain"), points }], + }, + }; +} + +/** Drop the leveller and say which lane went with it. */ +export function removeLevelling(chain: HfAudioFxChain): { + chain: HfAudioFxChain; + removedTarget: string | null; +} { + const node = chain.nodes.find((n) => n.fromLeveller); + return { + chain: { ...chain, nodes: chain.nodes.filter((n) => !n.fromLeveller) }, + removedTarget: node?.id ? fxAutomationTarget(node.id, "gain") : null, + }; +} + +/** What the module says when it is closed. */ +export function levellingSummary(points: readonly HfAutomationPoint[]): string { + const moves = points.filter((p) => p.v !== 0); + if (moves.length === 0) return "Already even — nothing to do"; + const lift = Math.max(...moves.map((p) => p.v)); + const cut = Math.min(...moves.map((p) => p.v)); + const parts: string[] = []; + if (lift > 0) parts.push(`lifting quiet parts up to ${lift.toFixed(1)} dB`); + if (cut < 0) parts.push(`holding loud parts down ${Math.abs(cut).toFixed(1)} dB`); + return parts.join(", "); +} diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index 287cd519a..eb5a6669a 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -41,6 +41,7 @@ import { import { automatedTargetsOf, automationAttrValue, + withLane, HF_AUDIO_AUTOMATION_ATTR, HF_AUDIO_AUTOMATION_DATA_KEY, readPanelAutomation, @@ -48,6 +49,7 @@ 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"; @@ -487,6 +489,63 @@ export function AudioFxGroup({ * 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. + */ + const runLeveller = async (): Promise => { + const el = element.element; + const src = el?.getAttribute("src"); + const doc = el?.ownerDocument; + if (!src || !doc) return; + setAnalysing(true); + try { + const Ctor = + window.OfflineAudioContext ?? + (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext }) + .webkitOfflineAudioContext; + if (!Ctor) return; + 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 result = levellingResult(chain, buffer.getChannelData(0), buffer.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); + } + }; + + const removeLeveller = (): void => { + const { chain: next, removedTarget } = removeLevelling(chain); + void onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next)); + // The lane goes with the node. An orphan keeps driving a parameter that is + // no longer in the graph. + if (removedTarget) { + void onSetAttributeQuiet( + HF_AUDIO_AUTOMATION_ATTR, + automationAttrValue(withoutLane(automation, removedTarget)) || null, + ); + } + }; + const analyse = async (active: HfCarveSettings | null = carve): Promise => { if (!active?.sources.length) return; const doc = element.element?.ownerDocument; @@ -664,6 +723,9 @@ export function AudioFxGroup({ onCarveChange={(next) => void setCarve(next)} onCarvePreview={(next) => onSetAttributeLive(HF_AUDIO_CARVE_ATTR, JSON.stringify(next))} sourceOptions={sourceOptions} + onLevel={() => void runLeveller()} + onRemoveLevel={removeLeveller} + levelled={chain.nodes.some((n) => n.fromLeveller)} carvedAgainstBy={carvedAgainstBy} analysing={analysing} /> diff --git a/packages/studio/src/components/editor/propertyPanelAutomation.test.ts b/packages/studio/src/components/editor/propertyPanelAutomation.test.ts new file mode 100644 index 000000000..f88bbbdcd --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAutomation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import type { HfAutomation } from "@hyperframes/core/audio-automation"; +import { automationAttrValue, withLane, withoutLane } from "./propertyPanelAutomation.js"; + +const carved = (): HfAutomation => ({ + version: 1, + lanes: [ + { target: "fx.n1.gain", points: [{ t: 0, v: -6 }] }, + { target: "fx.n2.gain", points: [{ t: 0, v: -9 }] }, + { target: "volume", points: [{ t: 0, v: 0.8 }] }, + ], +}); + +/** + * A script hands back a whole `HfAutomation` that describes only its OWN lane. + * Writing that to the attribute would take everything else with it — the + * carve's per-band lanes and the track's volume lane — which is silent, total, + * and only noticed later when the mix has quietly lost its ducking. + */ +describe("withLane", () => { + it("keeps every lane it was not asked about", () => { + const next = withLane(carved(), { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] }); + expect(next.lanes.map((l) => l.target).sort()).toEqual([ + "fx.n1.gain", + "fx.n2.gain", + "fx.n9.gain", + "volume", + ]); + expect(next.lanes.find((l) => l.target === "volume")?.points).toEqual([{ t: 0, v: 0.8 }]); + }); + + it("replaces a lane rather than adding a second one for the same target", () => { + // Re-running a script must not leave two lanes fighting over one parameter. + const once = withLane(carved(), { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] }); + const twice = withLane(once, { target: "fx.n9.gain", points: [{ t: 0, v: 5 }] }); + expect(twice.lanes.filter((l) => l.target === "fx.n9.gain")).toHaveLength(1); + expect(twice.lanes.find((l) => l.target === "fx.n9.gain")?.points).toEqual([{ t: 0, v: 5 }]); + expect(twice.lanes).toHaveLength(4); + }); + + it("does not mutate what it was given", () => { + const before = carved(); + withLane(before, { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] }); + expect(before.lanes).toHaveLength(3); + }); +}); + +describe("withoutLane", () => { + it("takes one lane and leaves the rest", () => { + // A node removed without its lane leaves an orphan driving a parameter that + // is no longer in the graph. + const next = withoutLane(carved(), "fx.n1.gain"); + expect(next.lanes.map((l) => l.target)).toEqual(["fx.n2.gain", "volume"]); + }); + + it("empties the attribute when the last lane goes", () => { + const one: HfAutomation = { version: 1, lanes: [{ target: "fx.n1.gain", points: [] }] }; + expect(automationAttrValue(withoutLane(one, "fx.n1.gain"))).toBe(""); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelAutomation.ts b/packages/studio/src/components/editor/propertyPanelAutomation.ts index 3469ce29f..a16376684 100644 --- a/packages/studio/src/components/editor/propertyPanelAutomation.ts +++ b/packages/studio/src/components/editor/propertyPanelAutomation.ts @@ -6,6 +6,7 @@ * attribute the same way. */ +import type { HfAutomationLane } from "@hyperframes/core/audio-automation"; import { HF_AUDIO_AUTOMATION_ATTR, HF_AUDIO_AUTOMATION_DATA_KEY, @@ -73,6 +74,21 @@ export function withoutLane(automation: HfAutomation, target: string): HfAutomat return { version: 1, lanes: automation.lanes.filter((lane) => lane.target !== target) }; } +/** + * Replace one lane, leaving every other lane alone. + * + * A script that hands back a whole `HfAutomation` describes only its OWN lane. + * Writing that wholesale would take the carve's lanes and the volume lane with + * it, so what the script produces has to be merged in by target rather than + * swapped for what is already there. + */ +export function withLane(automation: HfAutomation, lane: HfAutomationLane): HfAutomation { + return { + version: 1, + lanes: [...automation.lanes.filter((l) => l.target !== lane.target), lane], + }; +} + /** The attribute value for an automation set; empty when nothing is automated. */ export function automationAttrValue(automation: HfAutomation): string { return automation.lanes.length > 0 ? serializeAutomation(automation) : ""; diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index 77e2133fb..fef0fdc95 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -69,6 +69,9 @@ function mount(overrides: Partial[0]> = {}) { automatedTargets={overrides.automatedTargets} onAutomateParam={overrides.onAutomateParam} onRemoveParamAutomation={overrides.onRemoveParamAutomation} + onLevel={overrides.onLevel} + onRemoveLevel={overrides.onRemoveLevel} + levelled={overrides.levelled} />, ); return { host, onChainChange, onChainPreview, onCarveChange }; @@ -388,6 +391,22 @@ describe("FxSection chain", () => { expect(next.nodes.find((n) => n.label === "Middle")!.params!.gain).toBe(0); }); + it("offers levelling, and offers to take it away once it is there", () => { + const onLevel = vi.fn(); + const onRemoveLevel = vi.fn(); + const { host } = mount({ onLevel, onRemoveLevel }); + click(host.querySelector(".hf-fx-add")); + click(byText(host, ".hf-fx-add-composite", "Even Out Levels")); + expect(onLevel).toHaveBeenCalledTimes(1); + + const already = mount({ onLevel, onRemoveLevel, levelled: true }); + click(already.host.querySelector(".hf-fx-add")); + // The same control, because adding a second levelling stage is never what + // an author means by pressing it twice. + click(byText(already.host, ".hf-fx-add-composite", "Remove levelling")); + expect(onRemoveLevel).toHaveBeenCalledTimes(1); + }); + it("cannot move the ends past themselves", () => { const { host } = mount({ chain: chainOf("peaking", "reverb") }); const ups = host.querySelectorAll('.hf-fx-move[title="Move up"]'); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index d60f552ac..0c7c4fe13 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -649,6 +649,12 @@ export interface FxSectionProps { onRemoveParamAutomation?(nodeId: string, paramKey: string): void; /** Delete every lane belonging to a node that is being removed. */ onRemoveNodeAutomation?(nodeId: string): void; + /** 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; /** Structural edits and gesture-end writes; this is the one that persists. */ onChainChange(chain: HfAudioFxChain): void; /** Continuous updates while a control is being dragged. */ @@ -686,6 +692,9 @@ export function FxSection({ sourceOptions, analysing, disabled, + onLevel, + onRemoveLevel, + levelled, }: FxSectionProps) { // Falls back to the persisting write when no preview handler is supplied, which // keeps the control working rather than going dead. @@ -908,6 +917,21 @@ export function FxSection({ Tone + {onLevel ? ( + + ) : null}