Keep the audio thread free of ended voices and too many vocoders (#356)
game-maker / gate (push) Successful in 3m45s
game-maker / suite (push) Successful in 14m44s

A song that had been played a few times in a long session stuttered
at regular intervals until the page was reloaded. A quick run up the
keys of a sampled instrument made every sound drop out for a second
or two. Both came from work the audio thread should not have had.

- Every worklet node of a voice listened on its port. Chrome never
  collects a port that is listened to, and through its listener not
  the voice either. Every node of the voice stayed on its bus and was
  worked on in every block. A way or stage on the worklet now closes
  its port once it ends or is stopped, and the processor closes its
  own side when it is told to stop.
- A voice without echoes now leaves its bus as soon as its sound and
  what its stages hold back are through. Before, only a voice with
  echoes did.
- At most 12 ways play through the pitch worklet at once. One more
  cuts the oldest in 10 ms, a note already let go before one still
  held. Each runs a phase vocoder, whose first block alone took
  2.7 ms of the 2.67 ms a block has on stein2.
- Play for the game ends everything the tabs play: the piece holding
  the transport and every key or preview (`stopPreviews`).
- Closing a Sound, Sound FX, Instrument, Pattern or Song tab stops
  what it plays of that asset (`AssetTabEditor.closed`).
- A preset in the Instrument tab plays nothing any more. With
  Audition on it is heard like any other change.
- Test hooks: `__gm.instrument.play`, `__gm.sound.playing`, and
  `vocoders` in the stats of the sound library.

Measured:
- A copy of the project, its Song 2 played for 12 s and stopped, six
  times (Chrome on stein2, CDP WebAudio): before, 746 nodes were left
  after the first time and 4469 after the sixth, the load at rest
  grew from 0.01 to 0.06. After, 7 nodes are left every time (the
  master and one bus) and the load at rest stays at 0.01 to 0.02.
- Pitch-player voices live in Chrome on melektaus-atx: 12 take 0.50
  of the audio thread, 16 take 0.71, 24 take 0.99. 30 struck 40 ms
  apart held it at 0.99 from the first second on.

Tests:
- Unit sound-library: 110 green (3 new), 4 counter-probes each red
- e2e 34 (melektaus-atx): ALL PASSED, 86 checks (3 new, 2 changed)
- e2e counter-probe with game start and preset undone: their 3
  checks red, and the old check of Play note in the game as well,
  which now counts on the preview being gone
- e2e counter-probe with tab close undone: exactly its check red. A
  first form of it held a key and stayed green, since the keys let go
  of a held key by themselves once the tab shows no asset
- Gate (melektaus-atx): 270 files, 4181 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-08 17:57:18 +02:00
1 parent 8c0d1cfd3f
commit 25c1d02b06
14 files changed
+269 -88

No files matched your search

+59 -1
View File
@@ -95,7 +95,12 @@ interface FakeWorklet {
pitch: FakeParam;
parameters: Map<string, FakeParam>;
posted: unknown[];
port: { postMessage: (message: unknown) => void; onmessage: ((event: { data: unknown }) => void) | null };
port: {
postMessage: (message: unknown) => void;
onmessage: ((event: { data: unknown }) => void) | null;
close: () => void;
closed: boolean;
};
disconnected: boolean;
connect: (target: unknown) => unknown;
disconnect: () => void;
@@ -362,6 +367,10 @@ beforeEach(() => {
port: {
postMessage: (message: unknown) => node.posted.push(message),
onmessage: null,
close: () => {
node.port.closed = true;
},
closed: false,
},
disconnected: false,
disconnect: () => {
@@ -2551,3 +2560,52 @@ describe('Wet (#354)', () => {
]);
});
});
describe('what has ended is let go', () => {
it('closes the port of a pitched way once it ends or is stopped, so its voice can be collected', async () => {
const library = new SoundLibrary();
await play(library, FILE, {}, { pitch: 3, stages: [] });
await play(library, FILE, {}, { pitch: 5, stages: [] });
const [ended, stopped] = worklets;
ended!.port.onmessage!({ data: 'ended' });
expect(ended!.port).toMatchObject({ closed: true, onmessage: null });
expect(stopped!.port.closed).toBe(false);
library.stop(FILE);
clockTo();
expect(stopped!.posted).toEqual(['stop']);
expect(stopped!.port).toMatchObject({ closed: true, onmessage: null });
});
it('takes a voice without echoes off its bus as soon as its sound has ended', async () => {
const library = new SoundLibrary();
await play(library);
const before = disconnects.length;
sources[0]!.onended!();
expect(library.stats().playing).toBe(0);
expect(disconnects.slice(before)).toEqual(['gain']);
});
it('cuts the oldest pitched voices past twelve, one already let go before one still held', async () => {
const library = new SoundLibrary();
const oneShot: FxPlan = {
pitch: [{ value: 3 }],
stages: [],
source: { file: FILE, volume: 1, loop: false, loopStart: 0, loopEnd: 0, gate: false },
};
const voices: number[] = [];
const strike = async () => {
voices.push(library.playNote('instrument:i', [oneShot], {}, 1));
await settle();
await arrive();
};
for (let i = 0; i < 12; i++) await strike();
expect(voices.every((voice) => library.playing(voice))).toBe(true);
library.release(voices[4]!);
await strike();
expect(voices.map((voice) => library.playing(voice))).toEqual(voices.map((_, i) => i !== 4));
await strike();
expect(voices.map((voice) => library.playing(voice))).toEqual(voices.map((_, i) => i !== 0 && i !== 4));
expect(library.stats().playing).toBe(12);
expect(worklets).toHaveLength(14);
});
});