Skip to content

Commit 6faf4d6

Browse files
authored
fix(core): harden audio FX and group identity (#3444)
* fix(core): harden audio FX and group identity * fix(core): address audio group review feedback
1 parent 32d58a7 commit 6faf4d6

14 files changed

Lines changed: 818 additions & 173 deletions

packages/core/src/audio/audioFxWorklets.test.ts

Lines changed: 111 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,25 +136,127 @@ describe("the worklet processors themselves", () => {
136136
return crossings / ((s.length - start) / SR);
137137
}
138138

139-
it("at semitones: 0, mix: 1 reproduces the input, delayed by exactly one grain/2", async () => {
139+
// This assertion used to be that the output equalled the input DELAYED by
140+
// grain/2 — the measurement was right and was written down as the contract.
141+
// But the grain delay is there to shift pitch, and at semitones: 0 nothing
142+
// is being shifted: the node degenerated into a pure 50 ms delay of the
143+
// signal, plus a head of silence while the ring filled, under a label that
144+
// reads "Unchanged pitch".
145+
it("at semitones: 0, mix: 1 passes the input through untouched", async () => {
140146
const HfPitchshift = (await loadProcessors()).get("hf-pitchshift");
141147
if (!HfPitchshift) throw new Error("hf-pitchshift not registered");
142148
const p = new HfPitchshift({ processorOptions: { semitones: 0, mix: 1 } });
143149
const input = sine(440, 0.5);
144150
const output = run(p, input);
145-
const grain = Math.round(SR * 0.1);
146-
// readTap reads from `write - 1`, i.e. one sample behind the one just
147-
// written in this same iteration — so the effective delay is one sample
148-
// more than the nominal grain/2.
149-
const delay = grain / 2 + 1;
150-
// Skip the first grain while the ring buffer is still filling.
151151
let maxErr = 0;
152-
for (let i = grain * 2; i < input.length; i++) {
153-
maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i - delay] ?? 0)));
152+
for (let i = 0; i < input.length; i++) {
153+
maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i] ?? 0)));
154154
}
155155
expect(maxErr).toBeLessThan(1e-6);
156156
});
157157

158+
it("mix: 0 passes the input through untouched too", async () => {
159+
const HfPitchshift = (await loadProcessors()).get("hf-pitchshift");
160+
if (!HfPitchshift) throw new Error("hf-pitchshift not registered");
161+
const p = new HfPitchshift({ processorOptions: { semitones: 7, mix: 0 } });
162+
const input = sine(440, 0.25);
163+
const output = run(p, input);
164+
let maxErr = 0;
165+
for (let i = 0; i < input.length; i++) {
166+
maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i] ?? 0)));
167+
}
168+
expect(maxErr).toBeLessThan(1e-6);
169+
});
170+
171+
/** Largest sample-to-sample step — a splice between the dry and the
172+
* ~50 ms-delayed wet path shows up here as a discontinuity. */
173+
function maxStep(s: Float32Array, from: number, to: number): number {
174+
let worst = 0;
175+
for (let i = from + 1; i < to; i++) {
176+
worst = Math.max(worst, Math.abs((s[i] ?? 0) - (s[i - 1] ?? 0)));
177+
}
178+
return worst;
179+
}
180+
181+
// Dragging the semitones slider off zero mid-playback swaps the output from
182+
// x[t] to x[t-50ms]. Switched hard that is an audible click; the wet amount
183+
// is ramped instead. A 440 Hz sine steps ~0.057 per sample at its steepest,
184+
// so anything near the signal's own peak is a splice, not the waveform.
185+
it("does not click when the shift moves off zero mid-signal", async () => {
186+
const HfPitchshift = (await loadProcessors()).get("hf-pitchshift");
187+
if (!HfPitchshift) throw new Error("hf-pitchshift not registered");
188+
const p = new HfPitchshift({ processorOptions: { semitones: 0, mix: 1 } });
189+
run(p, sine(440, 0.3)); // settled dry, ring warm
190+
p.p = { ...p.p, semitones: 7 };
191+
const output = run(p, sine(440, 0.3));
192+
expect(maxStep(output, 0, output.length)).toBeLessThan(0.2);
193+
});
194+
195+
it("does not click on the way back to zero either", async () => {
196+
const HfPitchshift = (await loadProcessors()).get("hf-pitchshift");
197+
if (!HfPitchshift) throw new Error("hf-pitchshift not registered");
198+
const p = new HfPitchshift({ processorOptions: { semitones: 7, mix: 1 } });
199+
run(p, sine(440, 0.3));
200+
p.p = { ...p.p, semitones: 0 };
201+
const output = run(p, sine(440, 0.3));
202+
expect(maxStep(output, 0, output.length)).toBeLessThan(0.2);
203+
});
204+
205+
// ...and having ramped back down it must reach TRUE bypass, not sit on a
206+
// permanently latched wet path. The render builds a fresh node from the
207+
// saved attribute and bypasses at semitones 0; a preview that stayed wet
208+
// would carry a 50 ms delay the export does not have.
209+
it("returns to true bypass after being shifted and set back to zero", async () => {
210+
const HfPitchshift = (await loadProcessors()).get("hf-pitchshift");
211+
if (!HfPitchshift) throw new Error("hf-pitchshift not registered");
212+
const p = new HfPitchshift({ processorOptions: { semitones: 7, mix: 1 } });
213+
run(p, sine(440, 0.3));
214+
p.p = { ...p.p, semitones: 0 };
215+
run(p, sine(440, 0.3)); // ramp down settles here
216+
217+
const input = sine(440, 0.3);
218+
const output = run(p, input);
219+
let maxErr = 0;
220+
for (let i = 0; i < input.length; i++) {
221+
maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i] ?? 0)));
222+
}
223+
expect(maxErr).toBeLessThan(1e-6);
224+
});
225+
226+
// A node parked at mix 0 has shifted nothing, so it must not have spent
227+
// anything that stops the zero-shift bypass engaging later.
228+
it("is transparent at zero after sitting mixed fully out", async () => {
229+
const HfPitchshift = (await loadProcessors()).get("hf-pitchshift");
230+
if (!HfPitchshift) throw new Error("hf-pitchshift not registered");
231+
const p = new HfPitchshift({ processorOptions: { semitones: 7, mix: 0 } });
232+
run(p, sine(440, 0.3));
233+
234+
p.p = { ...p.p, semitones: 0, mix: 1 };
235+
const input = sine(440, 0.3);
236+
const output = run(p, input);
237+
let maxErr = 0;
238+
for (let i = 0; i < input.length; i++) {
239+
maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i] ?? 0)));
240+
}
241+
expect(maxErr).toBeLessThan(1e-6);
242+
});
243+
244+
// The ring starts empty, so the taps read zeros for the first grain. That
245+
// used to come out of the head of every clip as silence; it ramps the wet
246+
// path in instead, which is unshifted audio rather than no audio.
247+
it("does not open with silence while the grain buffer fills", async () => {
248+
const HfPitchshift = (await loadProcessors()).get("hf-pitchshift");
249+
if (!HfPitchshift) throw new Error("hf-pitchshift not registered");
250+
const p = new HfPitchshift({ processorOptions: { semitones: 7, mix: 1 } });
251+
const input = sine(440, 0.5);
252+
const output = run(p, input);
253+
// Peak over the first 20 ms — well inside the old dead zone.
254+
let peak = 0;
255+
for (let i = 0; i < Math.round(SR * 0.02); i++)
256+
peak = Math.max(peak, Math.abs(output[i] ?? 0));
257+
expect(peak).toBeGreaterThan(0.5);
258+
});
259+
158260
it("at semitones: 12, doubles the fundamental (one octave up)", async () => {
159261
const HfPitchshift = (await loadProcessors()).get("hf-pitchshift");
160262
if (!HfPitchshift) throw new Error("hf-pitchshift not registered");

packages/core/src/audio/audioFxWorklets.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,21 @@ class HfPitchshift extends AudioWorkletProcessor {
253253
this.buf = [];
254254
this.write = 0;
255255
this.phase = 0;
256+
// Samples written so far, capped at one grain. The taps read up to a grain
257+
// behind the write head, so until this fills they would read the ring's
258+
// zeros — the head of every clip came out attenuated or silent.
259+
this.filled = 0;
260+
// How much of the wet (pitch-shifted) path is currently in the output, and
261+
// where it is heading. Crossing between dry and wet is a ~50 ms jump in the
262+
// signal, so it is RAMPED rather than switched: a hard swap either way is a
263+
// click. Ramping in both directions is also what lets a node return to true
264+
// bypass at semitones 0 — a one-way latch left preview stuck with the delay
265+
// that the render, building a fresh node from the attribute, does not have.
266+
this.wet = 0;
267+
this.wetTarget = 0;
268+
// ~15 ms one-pole, short enough to feel immediate on a slider drag and long
269+
// enough that the splice is inaudible.
270+
this.wetCoef = Math.exp(-1 / (sampleRate * 0.015));
256271
this.port.onmessage = (e) => {
257272
if (e.data && e.data.__hfDispose) { this.dead = true; return; }
258273
this.p = { ...this.p, ...e.data };
@@ -265,33 +280,67 @@ class HfPitchshift extends AudioWorkletProcessor {
265280
const p = this.p;
266281
const semitones = Math.max(-12, Math.min(12, p.semitones ?? 0));
267282
const mix = Math.max(0, Math.min(1, p.mix ?? 1));
268-
const ratio = Math.pow(2, semitones / 12);
269283
const grain = this.grain;
270284
const ringLen = grain * 2;
271-
const inc = (1 - ratio) / grain;
272285
const n = i[0] ? i[0].length : 0;
273286
for (let ch = 0; ch < i.length; ch++) {
274287
if (!this.buf[ch]) this.buf[ch] = new Float32Array(ringLen);
275288
}
276-
let write = this.write, phase = this.phase;
289+
290+
// Nothing to shift, or mixed fully out. The grain delay is ~grain/2
291+
// whatever the ratio, so at semitones=0 this degenerated into a pure 50 ms
292+
// delay of the signal — while the copy for that exact setting reads
293+
// "Unchanged pitch".
294+
this.wetTarget = semitones === 0 ? 0 : mix;
295+
296+
// Fully dry AND settled: take the cheap transparent path. The ring keeps
297+
// filling, so a later shift does not start cold.
298+
if (this.wetTarget === 0 && this.wet < 1e-4) {
299+
this.wet = 0;
300+
let w = this.write;
301+
for (let s = 0; s < n; s++) {
302+
for (let ch = 0; ch < i.length; ch++) {
303+
const x = i[ch][s];
304+
this.buf[ch][w] = x;
305+
o[ch][s] = x;
306+
}
307+
w = (w + 1) % ringLen;
308+
}
309+
this.write = w;
310+
this.filled = Math.min(grain, this.filled + n);
311+
return true;
312+
}
313+
314+
const ratio = Math.pow(2, semitones / 12);
315+
const inc = (1 - ratio) / grain;
316+
let write = this.write, phase = this.phase, filled = this.filled, wetNow = this.wet;
317+
const target = this.wetTarget, coef = this.wetCoef;
277318
for (let s = 0; s < n; s++) {
278319
phase += inc;
279320
phase -= Math.floor(phase);
280321
const phaseB = (phase + 0.5) % 1;
281322
const gA = xfade(phase), gB = xfade(phaseB);
323+
// Ramp the wet path in as the ring fills rather than reading zeros:
324+
// 100 ms of unshifted audio at the head of a clip beats 50 ms of silence.
325+
const warm = filled >= grain ? 1 : filled / grain;
326+
wetNow = target + coef * (wetNow - target);
327+
const wetMix = wetNow * warm;
282328
for (let ch = 0; ch < i.length; ch++) {
283329
const ring = this.buf[ch];
284330
const inp = i[ch], out = o[ch];
285331
const x = inp[s];
286332
ring[write] = x;
287333
const wet =
288334
readTap(ring, write, phase * grain) * gA + readTap(ring, write, phaseB * grain) * gB;
289-
out[s] = x * (1 - mix) + wet * mix;
335+
out[s] = x * (1 - wetMix) + wet * wetMix;
290336
}
291337
write = (write + 1) % ringLen;
338+
if (filled < grain) filled++;
292339
}
293340
this.write = write;
294341
this.phase = phase;
342+
this.filled = filled;
343+
this.wet = wetNow;
295344
return true;
296345
}
297346
}

packages/core/src/audioCarve.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
clipsOverlap,
1111
mixCarveSources,
1212
couldBeCarveSource,
13+
couldBeCarveBed,
14+
isNamedCarveBed,
1315
DEFAULT_CARVE,
1416
normalizeCarveSettings,
1517
} from "./audioCarve.js";
@@ -538,6 +540,30 @@ describe("classifyAudioName", () => {
538540
expect(couldBeCarveSource("sfx-explosion")).toBe(false);
539541
});
540542

543+
// The near-end rule, which nothing used to ask. `couldBeCarveSource` shipped
544+
// with its own doc comment ("music and sfx are out") and no caller; the bed
545+
// side had no predicate at all, so a narration clip was offered the carve and
546+
// — finding one candidate — had one applied for it, against the group it was
547+
// a member of.
548+
it("never offers a voice track as the bed, but keeps an unnamed one eligible", () => {
549+
expect(couldBeCarveBed("music-bed")).toBe(true);
550+
expect(couldBeCarveBed("sfx-riser")).toBe(true);
551+
expect(couldBeCarveBed("a1")).toBe(true);
552+
expect(couldBeCarveBed("vo-2")).toBe(false);
553+
expect(couldBeCarveBed("voiceover")).toBe(false);
554+
expect(couldBeCarveBed("narration-3")).toBe(false);
555+
});
556+
557+
// Showing the control is a suggestion; writing the attribute is a decision.
558+
// A decision taken off a name that said nothing is how a carve appears that
559+
// nobody remembers configuring — so `a1` may be offered but never chosen.
560+
it("only self-applies to a name that positively reads as a bed", () => {
561+
expect(isNamedCarveBed("music-bed")).toBe(true);
562+
expect(isNamedCarveBed("sfx-riser")).toBe(true);
563+
expect(isNamedCarveBed("a1")).toBe(false);
564+
expect(isNamedCarveBed("vo-2")).toBe(false);
565+
});
566+
541567
it("treats underscores as separators, not word characters, for short hints", () => {
542568
// `\b` treats `_` as a word character, so `\bbed\b` used to miss `bed_01` —
543569
// an underscore-separated bed classified as "unknown" and could end up

packages/core/src/audioCarve.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,40 @@ export function couldBeCarveSource(...parts: readonly (string | null | undefined
188188
return kind === "voice" || kind === "unknown";
189189
}
190190

191+
/**
192+
* Could this track be the BED a carve is written onto?
193+
*
194+
* The other half of `couldBeCarveSource`, and the half nothing used to ask. A
195+
* carve makes room in a bed for a voice; a voice track has no room to make for
196+
* itself, and offering it the control is offering a track to duck against its
197+
* own kind. Observed: a narration clip in a Voiceover group carved against that
198+
* group — a member ducking the bus it feeds.
199+
*
200+
* Loose in the same direction as its sibling: a name that says nothing stays
201+
* eligible, because a name is a hint and an author may know better. Only a name
202+
* that positively reads as speech is refused.
203+
*/
204+
export function couldBeCarveBed(...parts: readonly (string | null | undefined)[]): boolean {
205+
return classifyAudioName(...parts) !== "voice";
206+
}
207+
208+
/**
209+
* Does this track's name positively say "bed"?
210+
*
211+
* Stricter than `couldBeCarveBed`, for the one act the author did not ask for:
212+
* applying a carve on their behalf. Offering the control on a track named `a1`
213+
* is a suggestion they can ignore; writing `data-fx-carve` onto it is a decision,
214+
* and a decision taken off a name that said nothing is how a carve appears that
215+
* nobody remembers configuring.
216+
*
217+
* The same split the source side already makes between what the picker may show
218+
* and what `autoSourceIds` may choose unprompted.
219+
*/
220+
export function isNamedCarveBed(...parts: readonly (string | null | undefined)[]): boolean {
221+
const kind = classifyAudioName(...parts);
222+
return kind === "music" || kind === "sfx";
223+
}
224+
191225
export const DEFAULT_CARVE: HfCarveSettings = {
192226
enabled: true,
193227
sources: [],

0 commit comments

Comments
 (0)