Skip to content

Commit 32bd0ed

Browse files
committed
fix(player): stop the pitch worklet running twelve idle chains at zero transpose
Both engines wired all thirteen semitone buses into the SoundTouch worklet at construction. The processor decides whether a semitone is in use by whether its input has channels, and takes its bypass path only when none do. A bus wired with nothing playing into it still reaches the processor as one channel of silence in Chrome, so every pitch input read as live, twelve WSOLA chains were built and ran on silence for the whole track, and the bypass never engaged even with nothing transposed. Measured in headless Chromium, offline render of 60 s, seven stems, no transpose: 13% of real time with every bus wired, 0.6% with only the unpitched bus wired, 0.5% with no worklet at all. On a laptop that load lands on the audio thread's 128-sample deadline and is heard as pops. It also meant the playhead ran ahead of the audio by the worklet's priming latency, since the engine assumes none when no lane is transposed. A pitch bus is now wired into the worklet only while a lane is on it and unwired once the last lane leaves, so the processor sees the empty input its contract describes. The unpitched bus stays wired: the click is scheduled onto it directly. The routing test now walks the graph for which inputs are wired as lanes arrive, share a key, and leave.
1 parent 37a2c5f commit 32bd0ed

3 files changed

Lines changed: 102 additions & 2 deletions

File tree

‎static/js/audioEngine.js‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
3434
// as which bus it is connected to, so several lanes can sit in different
3535
// keys at once while still sharing the worklet's single tempo stage.
3636
const buses = Array.from({ length: INPUT_COUNT }, () => ctx.createGain());
37+
// How many tracks sit on each bus. A pitch bus is wired into the worklet only
38+
// while this is non-zero; the unpitched bus is wired for good. See the same
39+
// field in chunkedAudioEngine.js for why: a bus wired with nothing playing
40+
// into it still reaches the processor as a channel of silence, which it took
41+
// as a lane to transpose, so twelve pitch chains ran on silence for every
42+
// track at zero transpose.
43+
const busLanes = new Array(INPUT_COUNT).fill(0);
3744
master.connect(ctx.destination);
3845

3946
let _playbackRate = 1.0;
@@ -57,7 +64,12 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
5764
// The worklet loads asynchronously, so anything set before it arrived
5865
// would otherwise be dropped. Re-apply the current value now.
5966
stNode.parameters.get('tempo').value = _playbackRate;
60-
for (let k = 0; k < INPUT_COUNT; k++) buses[k].connect(stNode, 0, k);
67+
// Tracks decoded before the worklet arrived are already routed, so
68+
// wire whichever pitch buses they occupy.
69+
buses[ZERO_INPUT].connect(stNode, 0, ZERO_INPUT);
70+
for (let k = 0; k < INPUT_COUNT; k++) {
71+
if (k !== ZERO_INPUT && busLanes[k] > 0) buses[k].connect(stNode, 0, k);
72+
}
6173
stNode.connect(master);
6274
}).catch((err) => {
6375
console.warn('[audioEngine] SoundTouch worklet load failed, using tape-effect fallback:', err);
@@ -314,16 +326,37 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
314326
const ROUTE_FADE = 0.006;
315327
const ROUTE_HOLD = 0.02;
316328

329+
// A track arriving on, or leaving, a bus. Mirrors chunkedAudioEngine.js:
330+
// only the first arrival and the last departure touch the worklet, the
331+
// unpitched bus stays wired whatever its count because the click is
332+
// scheduled onto it directly, and arrival is announced before the track
333+
// connects so the bus is live by the time anything reaches it. No worklet
334+
// means the tape-effect fallback, where every bus already feeds master.
335+
function _laneArriving(bus) {
336+
const k = buses.indexOf(bus);
337+
if (busLanes[k]++ === 0 && k !== ZERO_INPUT && stNode) buses[k].connect(stNode, 0, k);
338+
}
339+
function _laneLeft(bus) {
340+
if (!bus) return;
341+
const k = buses.indexOf(bus);
342+
if (--busLanes[k] === 0 && k !== ZERO_INPUT && stNode) {
343+
try { buses[k].disconnect(stNode, 0, k); } catch { /* worklet already gone */ }
344+
}
345+
}
346+
317347
/** Connect a track to the bus for its current transpose. */
318348
function routeTrack(t, immediate = false) {
319349
if (t.visualOnly) return;
320350
const target = buses[inputForPitch(effectivePitch(t.name, t.pitch, t.pitchable))];
321351
if (t.bus === target) return;
322352
const swap = () => {
323353
if (destroyed) return;
354+
const previous = t.bus;
355+
_laneArriving(target);
324356
try { t.analyser.disconnect(); } catch { /* was not connected yet */ }
325357
t.analyser.connect(target);
326358
t.bus = target;
359+
_laneLeft(previous);
327360
};
328361
if (immediate || !playing) { swap(); return; }
329362
const g = t.gain.gain;

‎static/js/chunkedAudioEngine.js‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,20 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
198198
// as which bus it is connected to, so several lanes can sit in different
199199
// keys at once while still sharing the worklet's single tempo stage.
200200
const buses = Array.from({ length: INPUT_COUNT }, () => ctx.createGain());
201+
// How many lanes sit on each bus. A pitch bus is wired into the worklet only
202+
// while this is non-zero; the unpitched bus is wired for good.
203+
//
204+
// The processor decides whether a semitone is in use by whether its input
205+
// has any channels, and it takes its bypass path (a straight copy) only when
206+
// none do. Wiring every bus up front defeated that: a bus with nothing
207+
// playing into it still arrives as one channel of silence in Chrome, so the
208+
// processor built a pitch chain for all twelve semitones and ran WSOLA on
209+
// silence for the whole track, at zero transpose. Measured at 13% of real
210+
// time for seven stems on a desktop, against 0.6% with only the unpitched
211+
// input wired -- enough to starve the audio thread on a laptop and be heard
212+
// as pops. Connecting a bus only while a lane is on it hands the processor
213+
// the empty input its contract describes.
214+
const busLanes = new Array(INPUT_COUNT).fill(0);
201215
master.connect(ctx.destination);
202216

203217
let stNode = null;
@@ -218,7 +232,12 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
218232
// The worklet loads asynchronously, so anything set before it arrived
219233
// would otherwise be dropped. Re-apply the current value now.
220234
stNode.parameters.get('tempo').value = _playbackRate;
221-
for (let k = 0; k < INPUT_COUNT; k++) buses[k].connect(stNode, 0, k);
235+
// Lanes were routed before the worklet arrived (see the stem loop
236+
// below), so wire whichever pitch buses they already occupy.
237+
buses[ZERO_INPUT].connect(stNode, 0, ZERO_INPUT);
238+
for (let k = 0; k < INPUT_COUNT; k++) {
239+
if (k !== ZERO_INPUT && busLanes[k] > 0) buses[k].connect(stNode, 0, k);
240+
}
222241
stNode.connect(master);
223242
}).catch((err) => {
224243
console.warn('[chunkedEngine] SoundTouch worklet failed, tape-effect fallback:', err);
@@ -320,15 +339,40 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
320339
const ROUTE_FADE = 0.006;
321340
const ROUTE_HOLD = 0.02;
322341

342+
// A lane arriving on, or leaving, a bus. Only the first arrival and the last
343+
// departure touch the worklet: the bus is wired in while occupied and taken
344+
// out again once empty, so the processor sees exactly the inputs that carry
345+
// a lane. The unpitched bus stays wired whatever its count, because the
346+
// click is scheduled onto it directly. In the tape-effect fallback there is
347+
// no worklet and every bus already feeds master, so there is nothing to do.
348+
//
349+
// Arrival is announced before the lane connects, so a bus is live in the
350+
// worklet by the time anything reaches it, and nothing watching the graph
351+
// sees a lane land on a bus that leads nowhere.
352+
function _laneArriving(bus) {
353+
const k = buses.indexOf(bus);
354+
if (busLanes[k]++ === 0 && k !== ZERO_INPUT && stNode) buses[k].connect(stNode, 0, k);
355+
}
356+
function _laneLeft(bus) {
357+
if (!bus) return;
358+
const k = buses.indexOf(bus);
359+
if (--busLanes[k] === 0 && k !== ZERO_INPUT && stNode) {
360+
try { buses[k].disconnect(stNode, 0, k); } catch { /* worklet already gone */ }
361+
}
362+
}
363+
323364
/** Connect a stem to the bus for its current transpose. */
324365
function routeStem(stem, immediate = false) {
325366
const target = buses[inputForPitch(effectivePitch(stem.name, stem.pitch, stem.pitchable))];
326367
if (stem.bus === target) return;
327368
const swap = () => {
328369
if (destroyed) return;
370+
const previous = stem.bus;
371+
_laneArriving(target);
329372
try { stem.analyser.disconnect(); } catch { /* was not connected yet */ }
330373
stem.analyser.connect(target);
331374
stem.bus = target;
375+
_laneLeft(previous);
332376
};
333377
if (immediate || !playing) { swap(); return; }
334378
const g = stem.gain.gain;

‎tests/js/audio-routing.test.mjs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,17 @@ async function verifyEngine(name, create) {
243243
};
244244
check(`${name} exposes the unpitched bus for the metronome`, engine.getMasterNode() === bus(ZERO_INPUT));
245245

246+
// Wiring a bus into the worklet is what tells the processor that semitone is
247+
// in use, and a wired bus with nothing on it still reaches the processor as a
248+
// channel of silence. So at start only the unpitched bus may be wired, and
249+
// the checks below watch pitch buses come and go with the lanes on them.
250+
const wired = () => {
251+
const out = [];
252+
for (let k = 0; k < INPUT_COUNT; k++) if (bus(k)) out.push(k - ZERO_INPUT);
253+
return out.join(',');
254+
};
255+
check(`${name} wires only the unpitched bus while nothing is transposed`, wired() === '0', `wired ${wired()}`);
256+
246257
const originalAnalysers = engine.getAnalysers('original');
247258
check(`${name} groups all complement analysers under original`, originalAnalysers.length === 2);
248259
const drumAnalyser = originalAnalysers[0];
@@ -266,6 +277,7 @@ async function verifyEngine(name, create) {
266277
check(`${name} puts a lane in the key it was given`, vocalBus === -2, `landed on ${vocalBus}`);
267278
check(`${name} reports the key it was given`, engine.getStemPitch('vocals') === -2);
268279
check(`${name} leaves other lanes where they were`, busOf(melodicAnalyser) === 0);
280+
check(`${name} wires a bus when a lane arrives on it`, wired() === '-2,0', `wired ${wired()}`);
269281

270282
// A key past the range the DSP is measured over stops at the edge rather
271283
// than landing somewhere unusable.
@@ -274,6 +286,7 @@ async function verifyEngine(name, create) {
274286
`${name} clamps a lane to the offered range`,
275287
busOf(engine.getAnalysers('vocals')[0]) === 6,
276288
);
289+
check(`${name} unwires a bus once its last lane has left`, wired() === '0,6', `wired ${wired()}`);
277290

278291
// One control drives both sources in the `original` group, and only one of
279292
// them is allowed to move. This is the whole reason the unpitched input
@@ -291,6 +304,16 @@ async function verifyEngine(name, create) {
291304
);
292305
check(`${name} reports drums as not pitchable`, engine.isStemPitchable('drums') === false);
293306

307+
// Two lanes share the +3 bus, so it must survive one of them leaving and
308+
// go only when the other does too. The unpitched bus is never unwired: the
309+
// click is scheduled onto it whether or not any lane sits there.
310+
engine.setStemPitch('vocals', 3);
311+
check(`${name} shares one bus between lanes in the same key`, wired() === '0,3', `wired ${wired()}`);
312+
engine.setStemPitch('vocals', 0);
313+
check(`${name} keeps a bus wired while another lane is still on it`, wired() === '0,3', `wired ${wired()}`);
314+
engine.setStemPitch('original', 0);
315+
check(`${name} unwires a shared bus only when it empties`, wired() === '0', `wired ${wired()}`);
316+
engine.setStemPitch('original', 3);
294317
engine.setStemPitch('vocals', 3);
295318

296319
if (name === 'full-decode engine') {

0 commit comments

Comments
 (0)