diff --git a/packages/core/src/audio/audioFxWorklets.test.ts b/packages/core/src/audio/audioFxWorklets.test.ts index e7089d7ce0..2f342f6669 100644 --- a/packages/core/src/audio/audioFxWorklets.test.ts +++ b/packages/core/src/audio/audioFxWorklets.test.ts @@ -136,25 +136,127 @@ describe("the worklet processors themselves", () => { return crossings / ((s.length - start) / SR); } - it("at semitones: 0, mix: 1 reproduces the input, delayed by exactly one grain/2", async () => { + // This assertion used to be that the output equalled the input DELAYED by + // grain/2 — the measurement was right and was written down as the contract. + // But the grain delay is there to shift pitch, and at semitones: 0 nothing + // is being shifted: the node degenerated into a pure 50 ms delay of the + // signal, plus a head of silence while the ring filled, under a label that + // reads "Unchanged pitch". + it("at semitones: 0, mix: 1 passes the input through untouched", async () => { const HfPitchshift = (await loadProcessors()).get("hf-pitchshift"); if (!HfPitchshift) throw new Error("hf-pitchshift not registered"); const p = new HfPitchshift({ processorOptions: { semitones: 0, mix: 1 } }); const input = sine(440, 0.5); const output = run(p, input); - const grain = Math.round(SR * 0.1); - // readTap reads from `write - 1`, i.e. one sample behind the one just - // written in this same iteration — so the effective delay is one sample - // more than the nominal grain/2. - const delay = grain / 2 + 1; - // Skip the first grain while the ring buffer is still filling. let maxErr = 0; - for (let i = grain * 2; i < input.length; i++) { - maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i - delay] ?? 0))); + for (let i = 0; i < input.length; i++) { + maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i] ?? 0))); } expect(maxErr).toBeLessThan(1e-6); }); + it("mix: 0 passes the input through untouched too", async () => { + const HfPitchshift = (await loadProcessors()).get("hf-pitchshift"); + if (!HfPitchshift) throw new Error("hf-pitchshift not registered"); + const p = new HfPitchshift({ processorOptions: { semitones: 7, mix: 0 } }); + const input = sine(440, 0.25); + const output = run(p, input); + let maxErr = 0; + for (let i = 0; i < input.length; i++) { + maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i] ?? 0))); + } + expect(maxErr).toBeLessThan(1e-6); + }); + + /** Largest sample-to-sample step — a splice between the dry and the + * ~50 ms-delayed wet path shows up here as a discontinuity. */ + function maxStep(s: Float32Array, from: number, to: number): number { + let worst = 0; + for (let i = from + 1; i < to; i++) { + worst = Math.max(worst, Math.abs((s[i] ?? 0) - (s[i - 1] ?? 0))); + } + return worst; + } + + // Dragging the semitones slider off zero mid-playback swaps the output from + // x[t] to x[t-50ms]. Switched hard that is an audible click; the wet amount + // is ramped instead. A 440 Hz sine steps ~0.057 per sample at its steepest, + // so anything near the signal's own peak is a splice, not the waveform. + it("does not click when the shift moves off zero mid-signal", async () => { + const HfPitchshift = (await loadProcessors()).get("hf-pitchshift"); + if (!HfPitchshift) throw new Error("hf-pitchshift not registered"); + const p = new HfPitchshift({ processorOptions: { semitones: 0, mix: 1 } }); + run(p, sine(440, 0.3)); // settled dry, ring warm + p.p = { ...p.p, semitones: 7 }; + const output = run(p, sine(440, 0.3)); + expect(maxStep(output, 0, output.length)).toBeLessThan(0.2); + }); + + it("does not click on the way back to zero either", async () => { + const HfPitchshift = (await loadProcessors()).get("hf-pitchshift"); + if (!HfPitchshift) throw new Error("hf-pitchshift not registered"); + const p = new HfPitchshift({ processorOptions: { semitones: 7, mix: 1 } }); + run(p, sine(440, 0.3)); + p.p = { ...p.p, semitones: 0 }; + const output = run(p, sine(440, 0.3)); + expect(maxStep(output, 0, output.length)).toBeLessThan(0.2); + }); + + // ...and having ramped back down it must reach TRUE bypass, not sit on a + // permanently latched wet path. The render builds a fresh node from the + // saved attribute and bypasses at semitones 0; a preview that stayed wet + // would carry a 50 ms delay the export does not have. + it("returns to true bypass after being shifted and set back to zero", async () => { + const HfPitchshift = (await loadProcessors()).get("hf-pitchshift"); + if (!HfPitchshift) throw new Error("hf-pitchshift not registered"); + const p = new HfPitchshift({ processorOptions: { semitones: 7, mix: 1 } }); + run(p, sine(440, 0.3)); + p.p = { ...p.p, semitones: 0 }; + run(p, sine(440, 0.3)); // ramp down settles here + + const input = sine(440, 0.3); + const output = run(p, input); + let maxErr = 0; + for (let i = 0; i < input.length; i++) { + maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i] ?? 0))); + } + expect(maxErr).toBeLessThan(1e-6); + }); + + // A node parked at mix 0 has shifted nothing, so it must not have spent + // anything that stops the zero-shift bypass engaging later. + it("is transparent at zero after sitting mixed fully out", async () => { + const HfPitchshift = (await loadProcessors()).get("hf-pitchshift"); + if (!HfPitchshift) throw new Error("hf-pitchshift not registered"); + const p = new HfPitchshift({ processorOptions: { semitones: 7, mix: 0 } }); + run(p, sine(440, 0.3)); + + p.p = { ...p.p, semitones: 0, mix: 1 }; + const input = sine(440, 0.3); + const output = run(p, input); + let maxErr = 0; + for (let i = 0; i < input.length; i++) { + maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i] ?? 0))); + } + expect(maxErr).toBeLessThan(1e-6); + }); + + // The ring starts empty, so the taps read zeros for the first grain. That + // used to come out of the head of every clip as silence; it ramps the wet + // path in instead, which is unshifted audio rather than no audio. + it("does not open with silence while the grain buffer fills", async () => { + const HfPitchshift = (await loadProcessors()).get("hf-pitchshift"); + if (!HfPitchshift) throw new Error("hf-pitchshift not registered"); + const p = new HfPitchshift({ processorOptions: { semitones: 7, mix: 1 } }); + const input = sine(440, 0.5); + const output = run(p, input); + // Peak over the first 20 ms — well inside the old dead zone. + let peak = 0; + for (let i = 0; i < Math.round(SR * 0.02); i++) + peak = Math.max(peak, Math.abs(output[i] ?? 0)); + expect(peak).toBeGreaterThan(0.5); + }); + it("at semitones: 12, doubles the fundamental (one octave up)", async () => { const HfPitchshift = (await loadProcessors()).get("hf-pitchshift"); if (!HfPitchshift) throw new Error("hf-pitchshift not registered"); diff --git a/packages/core/src/audio/audioFxWorklets.ts b/packages/core/src/audio/audioFxWorklets.ts index d0dd8dded5..5aa3d9fc16 100644 --- a/packages/core/src/audio/audioFxWorklets.ts +++ b/packages/core/src/audio/audioFxWorklets.ts @@ -253,6 +253,21 @@ class HfPitchshift extends AudioWorkletProcessor { this.buf = []; this.write = 0; this.phase = 0; + // Samples written so far, capped at one grain. The taps read up to a grain + // behind the write head, so until this fills they would read the ring's + // zeros — the head of every clip came out attenuated or silent. + this.filled = 0; + // How much of the wet (pitch-shifted) path is currently in the output, and + // where it is heading. Crossing between dry and wet is a ~50 ms jump in the + // signal, so it is RAMPED rather than switched: a hard swap either way is a + // click. Ramping in both directions is also what lets a node return to true + // bypass at semitones 0 — a one-way latch left preview stuck with the delay + // that the render, building a fresh node from the attribute, does not have. + this.wet = 0; + this.wetTarget = 0; + // ~15 ms one-pole, short enough to feel immediate on a slider drag and long + // enough that the splice is inaudible. + this.wetCoef = Math.exp(-1 / (sampleRate * 0.015)); this.port.onmessage = (e) => { if (e.data && e.data.__hfDispose) { this.dead = true; return; } this.p = { ...this.p, ...e.data }; @@ -265,20 +280,51 @@ class HfPitchshift extends AudioWorkletProcessor { const p = this.p; const semitones = Math.max(-12, Math.min(12, p.semitones ?? 0)); const mix = Math.max(0, Math.min(1, p.mix ?? 1)); - const ratio = Math.pow(2, semitones / 12); const grain = this.grain; const ringLen = grain * 2; - const inc = (1 - ratio) / grain; const n = i[0] ? i[0].length : 0; for (let ch = 0; ch < i.length; ch++) { if (!this.buf[ch]) this.buf[ch] = new Float32Array(ringLen); } - let write = this.write, phase = this.phase; + + // Nothing to shift, or mixed fully out. The grain delay is ~grain/2 + // whatever the ratio, so at semitones=0 this degenerated into a pure 50 ms + // delay of the signal — while the copy for that exact setting reads + // "Unchanged pitch". + this.wetTarget = semitones === 0 ? 0 : mix; + + // Fully dry AND settled: take the cheap transparent path. The ring keeps + // filling, so a later shift does not start cold. + if (this.wetTarget === 0 && this.wet < 1e-4) { + this.wet = 0; + let w = this.write; + for (let s = 0; s < n; s++) { + for (let ch = 0; ch < i.length; ch++) { + const x = i[ch][s]; + this.buf[ch][w] = x; + o[ch][s] = x; + } + w = (w + 1) % ringLen; + } + this.write = w; + this.filled = Math.min(grain, this.filled + n); + return true; + } + + const ratio = Math.pow(2, semitones / 12); + const inc = (1 - ratio) / grain; + let write = this.write, phase = this.phase, filled = this.filled, wetNow = this.wet; + const target = this.wetTarget, coef = this.wetCoef; for (let s = 0; s < n; s++) { phase += inc; phase -= Math.floor(phase); const phaseB = (phase + 0.5) % 1; const gA = xfade(phase), gB = xfade(phaseB); + // Ramp the wet path in as the ring fills rather than reading zeros: + // 100 ms of unshifted audio at the head of a clip beats 50 ms of silence. + const warm = filled >= grain ? 1 : filled / grain; + wetNow = target + coef * (wetNow - target); + const wetMix = wetNow * warm; for (let ch = 0; ch < i.length; ch++) { const ring = this.buf[ch]; const inp = i[ch], out = o[ch]; @@ -286,12 +332,15 @@ class HfPitchshift extends AudioWorkletProcessor { ring[write] = x; const wet = readTap(ring, write, phase * grain) * gA + readTap(ring, write, phaseB * grain) * gB; - out[s] = x * (1 - mix) + wet * mix; + out[s] = x * (1 - wetMix) + wet * wetMix; } write = (write + 1) % ringLen; + if (filled < grain) filled++; } this.write = write; this.phase = phase; + this.filled = filled; + this.wet = wetNow; return true; } } diff --git a/packages/core/src/audioCarve.test.ts b/packages/core/src/audioCarve.test.ts index be4dcfb14d..60fdbec2b4 100644 --- a/packages/core/src/audioCarve.test.ts +++ b/packages/core/src/audioCarve.test.ts @@ -10,6 +10,8 @@ import { clipsOverlap, mixCarveSources, couldBeCarveSource, + couldBeCarveBed, + isNamedCarveBed, DEFAULT_CARVE, normalizeCarveSettings, } from "./audioCarve.js"; @@ -538,6 +540,30 @@ describe("classifyAudioName", () => { expect(couldBeCarveSource("sfx-explosion")).toBe(false); }); + // The near-end rule, which nothing used to ask. `couldBeCarveSource` shipped + // with its own doc comment ("music and sfx are out") and no caller; the bed + // side had no predicate at all, so a narration clip was offered the carve and + // — finding one candidate — had one applied for it, against the group it was + // a member of. + it("never offers a voice track as the bed, but keeps an unnamed one eligible", () => { + expect(couldBeCarveBed("music-bed")).toBe(true); + expect(couldBeCarveBed("sfx-riser")).toBe(true); + expect(couldBeCarveBed("a1")).toBe(true); + expect(couldBeCarveBed("vo-2")).toBe(false); + expect(couldBeCarveBed("voiceover")).toBe(false); + expect(couldBeCarveBed("narration-3")).toBe(false); + }); + + // Showing the control is a suggestion; writing the attribute is a decision. + // A decision taken off a name that said nothing is how a carve appears that + // nobody remembers configuring — so `a1` may be offered but never chosen. + it("only self-applies to a name that positively reads as a bed", () => { + expect(isNamedCarveBed("music-bed")).toBe(true); + expect(isNamedCarveBed("sfx-riser")).toBe(true); + expect(isNamedCarveBed("a1")).toBe(false); + expect(isNamedCarveBed("vo-2")).toBe(false); + }); + it("treats underscores as separators, not word characters, for short hints", () => { // `\b` treats `_` as a word character, so `\bbed\b` used to miss `bed_01` — // an underscore-separated bed classified as "unknown" and could end up diff --git a/packages/core/src/audioCarve.ts b/packages/core/src/audioCarve.ts index 265b48352d..c43500c5db 100644 --- a/packages/core/src/audioCarve.ts +++ b/packages/core/src/audioCarve.ts @@ -188,6 +188,40 @@ export function couldBeCarveSource(...parts: readonly (string | null | undefined return kind === "voice" || kind === "unknown"; } +/** + * Could this track be the BED a carve is written onto? + * + * The other half of `couldBeCarveSource`, and the half nothing used to ask. A + * carve makes room in a bed for a voice; a voice track has no room to make for + * itself, and offering it the control is offering a track to duck against its + * own kind. Observed: a narration clip in a Voiceover group carved against that + * group — a member ducking the bus it feeds. + * + * Loose in the same direction as its sibling: a name that says nothing stays + * eligible, because a name is a hint and an author may know better. Only a name + * that positively reads as speech is refused. + */ +export function couldBeCarveBed(...parts: readonly (string | null | undefined)[]): boolean { + return classifyAudioName(...parts) !== "voice"; +} + +/** + * Does this track's name positively say "bed"? + * + * Stricter than `couldBeCarveBed`, for the one act the author did not ask for: + * applying a carve on their behalf. Offering the control on a track named `a1` + * is a suggestion they can ignore; writing `data-fx-carve` onto it is a decision, + * and a decision taken off a name that said nothing is how a carve appears that + * nobody remembers configuring. + * + * The same split the source side already makes between what the picker may show + * and what `autoSourceIds` may choose unprompted. + */ +export function isNamedCarveBed(...parts: readonly (string | null | undefined)[]): boolean { + const kind = classifyAudioName(...parts); + return kind === "music" || kind === "sfx"; +} + export const DEFAULT_CARVE: HfCarveSettings = { enabled: true, sources: [], diff --git a/packages/core/src/audioFx.ts b/packages/core/src/audioFx.ts index fd31285b89..181ddb67c6 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -509,7 +509,12 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [ id: "pitchshift", label: "Pitch shift", group: "time", - description: "Shifts pitch up or down without changing playback speed.", + // The granular algorithm reads from a 100 ms grain, so its output runs a + // constant ~50 ms behind its input and nothing in the graph subtracts that + // — there is no latency/pre-roll concept here yet. It is inaudible on its + // own and audible against picture or against an unshifted track, so it is + // stated rather than hidden. `semitones: 0` bypasses the node entirely. + description: "Shifts pitch up or down without changing playback speed. Adds ~50 ms of latency.", params: [ { kind: "number", @@ -917,50 +922,7 @@ export function parseAudioFxChain(json: string): HfAudioFxChain { if (!Array.isArray(obj.nodes)) { throw new AudioFxChainError("Chain file is missing a `nodes` array."); } - const nodes: HfAudioFxNode[] = obj.nodes.map((n, i) => { - if (typeof n !== "object" || n === null) { - throw new AudioFxChainError(`Node ${i} is not an object.`); - } - const node = n as { - type?: unknown; - id?: unknown; - enabled?: unknown; - params?: unknown; - fromCarve?: unknown; - fromPreset?: unknown; - label?: unknown; - fromEq?: unknown; - fromLeveller?: unknown; - presetAmount?: unknown; - }; - if (typeof node.type !== "string" || !BY_ID.has(node.type)) { - throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`); - } - return { - type: node.type, - ...(typeof node.id === "string" && node.id ? { id: node.id } : {}), - ...(node.fromCarve === true ? { fromCarve: true as const } : {}), - // Both survive the round trip or a preset stops being able to find its - // own nodes after a reload: re-applying would stack a second copy and - // the rack would lose the grouping it braces them with. - ...(typeof node.fromPreset === "string" && node.fromPreset - ? { fromPreset: node.fromPreset } - : {}), - ...(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 } : {}), - // Clamped on the way in: the blend is two gains in opposition, and a value - // outside 0..1 makes the dry leg negative rather than simply loud. - ...(typeof node.presetAmount === "number" && Number.isFinite(node.presetAmount) - ? { presetAmount: Math.min(1, Math.max(0, node.presetAmount)) } - : {}), - enabled: node.enabled !== false, - params: normalizeAudioFxParams( - node.type, - (node.params ?? undefined) as HfAudioFxParamValues | undefined, - ), - }; - }); + const nodes = obj.nodes.map(parseAudioFxNode); return { version: HF_AUDIO_FX_CHAIN_VERSION, nodes }; } @@ -973,22 +935,7 @@ export function enabledAudioFxNodes(chain: HfAudioFxChain): HfAudioFxNode[] { export function serializeAudioFxChain(chain: HfAudioFxChain): string { return JSON.stringify({ version: HF_AUDIO_FX_CHAIN_VERSION, - nodes: chain.nodes.map((node) => ({ - type: node.type, - ...(node.id ? { id: node.id } : {}), - ...(node.fromCarve === true ? { fromCarve: true } : {}), - ...(node.fromPreset ? { fromPreset: node.fromPreset } : {}), - ...(node.label ? { label: node.label } : {}), - ...(node.fromEq ? { fromEq: node.fromEq } : {}), - ...(node.fromLeveller === true ? { fromLeveller: true } : {}), - // Omitted when fully applied, so an untouched preset does not grow a field - // in every chain that carries one. - ...(typeof node.presetAmount === "number" && node.presetAmount !== 1 - ? { presetAmount: node.presetAmount } - : {}), - ...(node.enabled === false ? { enabled: false } : {}), - params: normalizeAudioFxParams(node.type, node.params), - })), + nodes: chain.nodes.map(serializeAudioFxNode), }); } @@ -1004,3 +951,102 @@ export function mintAudioFxNodeId(chain: HfAudioFxChain): string { if (!taken.has(id)) return id; } } + +/** The shape a node is READ as: everything unknown until checked. */ +interface RawAudioFxNode { + type?: unknown; + id?: unknown; + enabled?: unknown; + params?: unknown; + fromCarve?: unknown; + fromPreset?: unknown; + label?: unknown; + fromEq?: unknown; + fromLeveller?: unknown; + presetAmount?: unknown; +} + +/** + * One node out of a chain file, validated. Throws rather than dropping: a chain + * that silently loses a node would render differently from the project the + * author saved. + */ +function parseAudioFxNode(n: unknown, i: number): HfAudioFxNode { + if (typeof n !== "object" || n === null) { + throw new AudioFxChainError(`Node ${i} is not an object.`); + } + const node = n as RawAudioFxNode; + if (typeof node.type !== "string" || !BY_ID.has(node.type)) { + throw new AudioFxChainError(`Node ${i} has unknown effect type: ${String(node.type)}`); + } + return withoutUndefined({ + type: node.type, + id: nonEmptyString(node.id), + fromCarve: onlyTrue(node.fromCarve), + // fromPreset and label both survive the round trip or a preset stops being + // able to find its own nodes after a reload: re-applying would stack a + // second copy and the rack would lose the grouping it braces them with. + fromPreset: nonEmptyString(node.fromPreset), + label: nonEmptyString(node.label), + fromEq: nonEmptyString(node.fromEq), + fromLeveller: onlyTrue(node.fromLeveller), + presetAmount: clampedPresetAmount(node.presetAmount), + enabled: node.enabled !== false, + params: normalizeAudioFxParams( + node.type, + (node.params ?? undefined) as HfAudioFxParamValues | undefined, + ), + }); +} + +/** One node as the `data-fx-chain` attribute carries it. Every optional field is + * omitted when it holds its default, so a plain chain stays plain. */ +function serializeAudioFxNode(node: HfAudioFxNode) { + return withoutUndefined({ + type: node.type, + id: nonEmptyString(node.id), + fromCarve: onlyTrue(node.fromCarve), + fromPreset: nonEmptyString(node.fromPreset), + label: nonEmptyString(node.label), + fromEq: nonEmptyString(node.fromEq), + fromLeveller: onlyTrue(node.fromLeveller), + // Omitted when fully applied, so an untouched preset does not grow a field + // in every chain that carries one. + presetAmount: node.presetAmount === 1 ? undefined : clampedPresetAmount(node.presetAmount), + // Only the non-default is written: a chain of enabled nodes stays plain. + enabled: node.enabled === false ? (false as const) : undefined, + params: normalizeAudioFxParams(node.type, node.params), + }); +} + +/** + * The field readers the two node codecs share. + * + * Written as readers rather than as conditional spreads inline: nine + * `...(cond ? { x } : {})` clauses in one object literal is nine branches in a + * function whose actual job is "copy the fields that are set", and it read as + * complex because it was measured as complex. + */ +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} + +function onlyTrue(value: unknown): true | undefined { + return value === true ? true : undefined; +} + +/** Clamped on the way in: the blend is two gains in opposition, and a value + * outside 0..1 makes the dry leg negative rather than simply loud. */ +function clampedPresetAmount(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return Math.min(1, Math.max(0, value)); +} + +/** Drop the keys whose reader returned undefined, so an absent field stays + * absent rather than becoming an explicit `undefined` in the document. */ +function withoutUndefined(obj: T): T { + for (const key of Object.keys(obj) as Array) { + if (obj[key] === undefined) delete obj[key]; + } + return obj; +} diff --git a/packages/core/src/audioGroups.test.ts b/packages/core/src/audioGroups.test.ts index edc16d16a7..606112d2de 100644 --- a/packages/core/src/audioGroups.test.ts +++ b/packages/core/src/audioGroups.test.ts @@ -3,15 +3,15 @@ import { audioGroupOf, ensureAudioGroupInertStyle, HF_AUDIO_GROUP_ATTR, + isMemberGroupHidden, resolveAudioGroups, resolveCarveSourceIds, + resolveGroupElement, } from "./audioGroups.js"; +import { AUDIO_GROUP_RENDER_ID_ATTR, MEDIA_RENDER_ID_ATTR } from "./compiler/mediaRenderIds.js"; beforeEach(() => { document.body.innerHTML = ""; - // The inert stylesheet is injected once per document, so a leftover from an - // earlier test would carry the assertion for the one after it. - document.getElementById("__hf-audio-group-inert")?.remove(); }); describe("resolveAudioGroups", () => { @@ -115,18 +115,14 @@ describe("audioGroupOf", () => { expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBeNull(); }); - // The mirror of resolveAudioGroups' own video case. These two readers used to - // disagree here: the resolver saw no group, this one answered "voiceover", so - // preview routed a track through a bus the export would never build (the - // render enforces audio-only in audioMixer). - it("returns null for a video, matching resolveAudioGroups", () => { + it("ignores video membership so preview matches the audio-only render", () => { document.body.innerHTML = ``; const el = document.getElementById("v-1") as Element; expect(audioGroupOf(el)).toBeNull(); expect(resolveAudioGroups(document)).toEqual([]); }); - it("returns null for an empty attribute, not an empty string", () => { + it("normalizes an empty membership attribute to null", () => { document.body.innerHTML = ``; expect(audioGroupOf(document.getElementById("vo-1") as Element)).toBeNull(); expect(resolveAudioGroups(document)).toEqual([]); @@ -139,40 +135,6 @@ describe("audioGroupOf", () => { }); }); -describe("ensureAudioGroupInertStyle", () => { - it("takes the group element out of layout", () => { - document.body.innerHTML = ``; - const el = document.getElementById("voiceover") as HTMLElement; - ensureAudioGroupInertStyle(document); - expect(getComputedStyle(el).display).toBe("none"); - }); - - // An unknown custom element is an ordinary inline box, so in a flex or grid - // root it takes a slot: a gap, a justify-content share, and every - // :nth-child after it shifts. An author rule must not be able to put it - // back — and an id selector outranks this rule's type selector no matter - // which stylesheet came last, so `!important` is the only thing holding the - // contract. Dropping it makes this case fail. - it("beats an author rule that outranks it on specificity", () => { - document.head.insertAdjacentHTML( - "beforeend", - ``, - ); - document.body.innerHTML = ``; - ensureAudioGroupInertStyle(document); - expect(getComputedStyle(document.getElementById("voiceover") as HTMLElement).display).toBe( - "none", - ); - document.getElementById("author")?.remove(); - }); - - it("injects once, however many times it is called", () => { - ensureAudioGroupInertStyle(document); - ensureAudioGroupInertStyle(document); - expect(document.querySelectorAll("#__hf-audio-group-inert")).toHaveLength(1); - }); -}); - describe("resolveCarveSourceIds", () => { it("expands a group id to its current members", () => { document.body.innerHTML = ` @@ -219,3 +181,123 @@ describe(HF_AUDIO_GROUP_ATTR, () => { expect(HF_AUDIO_GROUP_ATTR).toBe("data-audio-group"); }); }); + +describe("resolveGroupElement", () => { + const doc = (html: string): Document => { + const d = document.implementation.createHTMLDocument("t"); + d.body.innerHTML = html; + return d; + }; + + it("returns the bus for a real ", () => { + const d = doc(``); + expect(resolveGroupElement(d, "vo")?.tagName.toLowerCase()).toBe("hf-audio-group"); + }); + + // The trap: a bare getElementById read a member's OWN fader and chain as the + // bus's, applying both a second time on the sub-mix. + it("refuses an