fix(studio,core,engine): close the defects a max-effort review found in the fixes

A review of the five fix commits found eleven real defects, including a
regression one of them introduced. Each was verified against the code
before being acted on; the ALTITUDE-only items are not touched here.

REGRESSION, from "group rows survive a collapse". Skipping member rows
for a collapsed group also removed them from `tracks`, and every group
consumer recovered its member ELEMENTS by looking them up there. Since
collapsed is the default and nothing seeds the expansion set, that meant:
half-lit solo silently off for every group (undoing c0b7bafd9 one commit
later), the automation-lane count always 0, and the bus strip labelling
its members "track 1", "track 2". Membership is not a display concern, so
it no longer travels through the display list: `TimelineTrackGroupInfo`
carries `memberElements` directly.

Group bus. `reanchor` wrote `fader.gain.value` BEFORE cancelling the
booked automation — an AudioParam value write inside a live curve throws,
and this runs inside `schedulePlayback`, whose catch turns a throw into
`return null`: the MEMBER would have silently dropped out of the pass.
Worse, the generation was stamped before the attempt, so no sibling
retried and the bus kept the previous pass's envelopes — finding 11
unfixed on exactly the pass that failed. Now: clear first, stamp only on
success, and isolate the call. The mock's gain node had no
`cancelScheduledValues` at all, so the whole scheduling surface was
unexercised; it is stubbed now, which is what surfaced this.

`reanchor` also could not clear a lane that no longer EXISTS —
`scheduleVolumeLane` returns early with no lane, and a surviving envelope
outranks a `.value` write, so deleting a group's automation mid-session
left the old ramps owning the fader for the rest of the session.

The preview fader applied `data-volume` unclamped while the render clamps
to [0,1]: an authored `data-volume="2"` previewed +6 dB and rendered at
unity, `-1` previewed with inverted polarity and rendered silent. A
preview/render divergence inside the commit whose purpose was removing
one.

Pitch shift. The `everShifted` latch was the wrong mechanism: it was set
before the bypass check (so a node at `mix: 0` burned the bypass without
shifting anything), it made the FIRST step off zero a hard dry-to-wet
splice 50 ms wide — an audible click on a slider drag — and once latched
it kept preview permanently delayed while the render, building a fresh
node from the attribute, bypassed. Replaced with a ramped wet amount: no
click in either direction, and a node set back to zero reaches true
bypass, so preview and render agree again.

Silent no-ops. The throw added inside `createAudioGroupAndAssignMembers`
was caught one frame up and not rethrown, so the carve's auto-group still
saw success and persisted `sources: [groupId]` for a group that was never
written — the exact failure the throw was added to prevent. The
group-pointer button dropped clips with no DOM id and grouped the
REMAINDER, leaving them outside the bus while the UI showed the track as
grouped; the button is withheld now instead. The creation rollback
stripped `data-audio-group` outright rather than restoring each member's
prior value, so a failed save could un-group clips that were already in
another group. `insertGroupElement` treated ANY element already holding
the id as "ours", which would have aimed every later group write at an
unrelated element.

`setAudioMuteHidden` rescheduled Web Audio mid-play without `stopAll()`.
Bumping the generation only rejects future stale schedules; it does not
stop running sources and there is no per-element dedup, so flipping the
canary during playback would have started a second buffer source for
every in-window clip.

`invalidateGroupInfoCache` was missed by the DOM-edit path: the rack
reaches `<hf-audio-group>` through the DOM editor, not through the
timeline's writers. Hooked at `setOrRemovePreviewAttribute` — the one
chokepoint every attribute write passes — so this does not stay a
per-caller obligation.

Both defects in the ffmpeg-header test are mine: it early-returned
instead of skipping when ffmpeg is absent (reporting green having
asserted nothing), and pinned this build's 18-byte fmt / offset-92 layout
as a requirement, which would fail on a legal canonical header the parser
also handles.

Also: the group-degradation note is no longer dropped when the outer mix
degrades too, and a malformed doc comment (two stacked openers) is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:39 -07:00
co-authored by Claude Opus 5
parent 07203f59d1
commit 7393095be8
19 changed files with 325 additions and 66 deletions
+64 -10
View File
@@ -168,23 +168,77 @@ describe("the worklet processors themselves", () => {
expect(maxErr).toBeLessThan(1e-6);
});
// A track whose semitones are automated THROUGH zero must not jump between
// the delayed and the undelayed path — that discontinuity is a click, which
// is worse than the delay the bypass would save.
it("keeps processing at zero once it has shifted, rather than clicking to dry", async () => {
/** 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 } });
const input = sine(440, 0.4);
run(p, input);
run(p, sine(440, 0.3));
p.p = { ...p.p, semitones: 0 };
const output = run(p, sine(440, 0.4));
// Still the wet path (grain-delayed), so it does NOT equal the input.
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 < 4000; i++) {
for (let i = 0; i < input.length; i++) {
maxErr = Math.max(maxErr, Math.abs((output[i] ?? 0) - (input[i] ?? 0)));
}
expect(maxErr).toBeGreaterThan(1e-3);
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
+26 -14
View File
@@ -257,11 +257,17 @@ class HfPitchshift extends AudioWorkletProcessor {
// 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;
// Latched the first time a non-zero shift is asked for. The no-op bypass
// below must not engage for a track whose semitones are AUTOMATED through
// zero: switching between a delayed and an undelayed path mid-signal is a
// click, which is worse than the delay it would save.
this.everShifted = false;
// 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 };
@@ -274,7 +280,6 @@ 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));
if (semitones !== 0) this.everShifted = true;
const grain = this.grain;
const ringLen = grain * 2;
const n = i[0] ? i[0].length : 0;
@@ -282,12 +287,16 @@ class HfPitchshift extends AudioWorkletProcessor {
if (!this.buf[ch]) this.buf[ch] = new Float32Array(ringLen);
}
// A node asking for no shift at all, or mixed fully out, is transparent.
// The grain delay is ~grain/2 whatever the ratio, so at semitones=0 this
// used to degrade into a pure 50 ms delay of the signal — while the copy
// for that exact setting reads "Unchanged pitch". The ring keeps filling
// so a later shift does not start cold.
if (mix === 0 || (semitones === 0 && !this.everShifted)) {
// 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++) {
@@ -304,7 +313,8 @@ class HfPitchshift extends AudioWorkletProcessor {
const ratio = Math.pow(2, semitones / 12);
const inc = (1 - ratio) / grain;
let write = this.write, phase = this.phase, filled = this.filled;
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);
@@ -313,7 +323,8 @@ class HfPitchshift extends AudioWorkletProcessor {
// 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;
const wetMix = mix * warm;
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];
@@ -329,6 +340,7 @@ class HfPitchshift extends AudioWorkletProcessor {
this.write = write;
this.phase = phase;
this.filled = filled;
this.wet = wetNow;
return true;
}
}
+9 -2
View File
@@ -205,8 +205,15 @@ export function initSandboxRuntimeModular(): void {
if (silenceHiddenAudio === enabled) return;
silenceHiddenAudio = enabled;
// The active-clip set is built with this predicate baked in, so a flip
// mid-session has to rebuild it — same reason a `data-hidden` toggle does.
if (clock.isPlaying()) scheduleWebAudioForActiveClips();
// mid-session has to rebuild it. `stopAll()` first: bumping the generation
// only rejects future STALE schedules, it does not stop sources already
// started, and there is no per-element dedup — so rescheduling on its own
// starts a second buffer source for every in-window clip on top of the ones
// still playing. `applyWebAudioRate` pairs the two for the same reason.
if (clock.isPlaying()) {
webAudio.stopAll();
scheduleWebAudioForActiveClips();
}
};
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
@@ -656,7 +656,24 @@ describe("WebAudioTransport", () => {
addEventListener: vi.fn(),
})),
createGain: vi.fn(() => {
const node = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
// The AudioParam scheduling surface is part of the contract the group
// bus uses (`clearParamLane` cancels before re-seeding a reused bus).
// A bare `{ value }` made any such call throw, and `schedulePlayback`
// swallows throws into `return null` — so the mock's own gap read as
// "the member did not play" rather than as a missing stub.
const node = {
gain: {
value: 1,
cancelScheduledValues: vi.fn(),
cancelAndHoldAtTime: vi.fn(),
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
setValueCurveAtTime: vi.fn(),
},
connect: vi.fn(),
disconnect: vi.fn(),
};
gainNodes.push(node);
return node;
}),
@@ -866,6 +883,29 @@ describe("WebAudioTransport", () => {
expect(fader.gain.value).toBe(0);
});
// reanchor runs inside schedulePlayback, whose catch turns any throw into
// `return null` — so a bus that fails to re-anchor would silently take the
// MEMBER out of the pass, and a generation stamped before the attempt would
// stop every later member retrying.
it("keeps the member playing when re-anchoring the bus throws", async () => {
document.body.innerHTML = `<hf-audio-group id="vo" data-volume="0.5"></hf-audio-group>`;
const { transport, mock, gen } = setupGroupTransport();
await scheduleGrouped(transport, gen, "a", "vo");
const fader = mock.gainNodes[5]!;
fader.gain.cancelScheduledValues = vi.fn(() => {
throw new Error("param is not schedulable");
});
transport.stopAll();
const gen2 = transport.startGeneration();
await expect(scheduleGrouped(transport, gen2, "a", "vo")).resolves.not.toBeNull();
// Generation not consumed by the failed attempt, so a sibling still tries.
fader.gain.cancelScheduledValues = vi.fn();
await scheduleGrouped(transport, gen2, "b", "vo");
expect(fader.gain.value).toBeCloseTo(0.5, 6);
});
describe('solo — "Hear only this" (B5)', () => {
it("silences a non-soloed member via its own solo gain, without touching the group bus", async () => {
const { transport, mock, gen } = setupGroupTransport();
+34 -4
View File
@@ -1,5 +1,6 @@
import { attachElementFxChain, readElementAutomation, type ElementFxHandle } from "./audioFx.js";
import {
clearParamLane,
scheduleParamLane,
volumeLane,
type AutomationTiming,
@@ -16,6 +17,18 @@ function normalizeRate(rate: number): number {
return rate;
}
/**
* The render puts every track volume through its own `clampVolume` before
* building the filter, so an authored `<hf-audio-group data-volume="2">`
* renders at unity. Preview has to agree or the two diverge on exactly the
* attribute this bus exists to honour and a negative value would invert
* polarity in preview while rendering silent. Compositions are hand-authorable,
* so out-of-range values do not need the studio slider to be reachable.
*/
function clampGroupVolume(volume: number): number {
return Math.max(0, Math.min(1, volume));
}
/**
* Breadcrumb for the per-element-mute handoff: the transport just claimed a track
* that was audibly playing through the HTMLMedia fallback. Quiet unless
@@ -323,8 +336,18 @@ export class WebAudioTransport {
// which for a fade-out is silence for the rest of the session. Re-anchor
// once per play generation, not once per member scheduled.
if (existing.generation !== this._playGeneration) {
existing.generation = this._playGeneration;
existing.reanchor(timing);
// Stamped only on success, and isolated: this runs inside
// `schedulePlayback`, whose catch turns any throw into `return null` —
// i.e. a bus problem would silently drop the MEMBER from the pass. And
// stamping first would consume the generation, so no later member of
// the same group would retry and the bus would keep the previous pass's
// envelopes: finding 11 unfixed on exactly the pass that failed.
try {
existing.reanchor(timing);
existing.generation = this._playGeneration;
} catch (err) {
swallow("webAudioTransport.groupReanchor", err);
}
}
return existing.input;
}
@@ -355,7 +378,7 @@ export class WebAudioTransport {
// group effect — a compressor, the Giant preset — previewed differently
// than it rendered.
const fader = this._ctx.createGain();
fader.gain.value = readAudioGroupVolume(groupEl);
fader.gain.value = clampGroupVolume(readAudioGroupVolume(groupEl));
fader.connect(muteGain);
const fx = attachElementFxChain(
this._ctx,
@@ -375,7 +398,14 @@ export class WebAudioTransport {
fx,
generation: this._playGeneration,
reanchor: (at: AutomationTiming) => {
fader.gain.value = readAudioGroupVolume(groupEl);
// Cleared BEFORE the value write, and unconditionally. `scheduleVolumeLane`
// clears as part of scheduling, but returns early when the group no
// longer has a lane — and a scheduled envelope outranks a `.value`
// write, so deleting a group's automation mid-session otherwise left
// the previous pass's ramps still owning the param (for a fade-out,
// silence) for the rest of the session.
clearParamLane([{ param: fader.gain }]);
fader.gain.value = clampGroupVolume(readAudioGroupVolume(groupEl));
fx?.reanchor(at);
if (groupEl) scheduleVolumeLane(groupEl, fader, at);
},