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

+5 -1
View File
@@ -92,11 +92,15 @@ export abstract class AssetTabEditor<
this.refresh(); this.refresh();
} }
/** A closed tab: opened again, the document starts from a fitted view. */ /** A closed tab: opened again, the document starts from a fitted view. What it played stops. */
forgetView(assetId: string): void { forgetView(assetId: string): void {
this.views.delete(assetId); this.views.delete(assetId);
this.closed(assetId);
} }
/** Stops what the tab plays of that asset; a tab that plays nothing has nothing to stop. */
protected closed(_assetId: string): void {}
/** The selection's id for this kind of asset, `''` when the selection is about something else. */ /** The selection's id for this kind of asset, `''` when the selection is about something else. */
abstract selectedId(): string; abstract selectedId(): string;
+2
View File
@@ -24,6 +24,7 @@ import type { GraphEditor } from './graph/GraphEditor';
import type { LiveMachineState } from './graph/GraphCanvas'; import type { LiveMachineState } from './graph/GraphCanvas';
import type { StateMachineEditor } from './statemachine/StateMachineEditor'; import type { StateMachineEditor } from './statemachine/StateMachineEditor';
import type { EditorState } from './state'; import type { EditorState } from './state';
import { stopPreviews } from './music/transport';
/** Facts about the running world for the check scripts. */ /** Facts about the running world for the check scripts. */
export interface WorldSnapshot { export interface WorldSnapshot {
@@ -150,6 +151,7 @@ export class PlaySession {
return; return;
} }
ui().focus.blur(); ui().focus.blur();
stopPreviews();
// A run starts at second zero, whatever the editor's previews have been counting up to. // A run starts at second zero, whatever the editor's previews have been counting up to.
gameClock.reset(); gameClock.reset();
const runtime = this.state.enterPlay(); const runtime = this.state.enterPlay();
+2
View File
@@ -146,6 +146,8 @@ export function graphHooks({ state, editor }: HookContext) {
}), }),
params: () => editor.instrumentEditor.previewValues, params: () => editor.instrumentEditor.previewValues,
setParam: (key: string, value: number) => editor.instrumentEditor.setPreviewValue(key, value), setParam: (key: string, value: number) => editor.instrumentEditor.setPreviewValue(key, value),
/** Starts a note as a key does, held until stopped; gives its voice. */
play: (note: number) => editor.instrumentEditor.playNote(note),
}, },
particles: { particles: {
current: () => editor.particlesEditor.currentAssetId, current: () => editor.particlesEditor.currentAssetId,
+1
View File
@@ -79,6 +79,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
clockState: () => soundLibrary.clockState(), clockState: () => soundLibrary.clockState(),
/** The second of the audio clock a voice started at, and the rate that clock runs at (#346). */ /** The second of the audio clock a voice started at, and the rate that clock runs at (#346). */
startedAt: (voice: number) => soundLibrary.startedAt(voice), startedAt: (voice: number) => soundLibrary.startedAt(voice),
playing: (voice: number) => soundLibrary.playing(voice),
sampleRate: () => soundLibrary.liveContext().sampleRate, sampleRate: () => soundLibrary.liveContext().sampleRate,
/** /**
* The nodes a stage of a voice becomes, in any context – an offline one renders what it does. * The nodes a stage of a voice becomes, in any context – an offline one renders what it does.
+12 -6
View File
@@ -48,7 +48,7 @@ const INFO_WIDTH = 140;
const PARAM_WIDTH = 48; const PARAM_WIDTH = 48;
/** Whose voices the tab's Stop silences: its own, never a running game's. */ /** Whose voices the tab's Stop silences: its own, never a running game's. */
const PREVIEW_OWNER = 'instrument-preview'; const PREVIEW_OWNER = 'instrument-preview';
/** How long a note is heard after a Preset, Randomize, Mutate or a change with Audition on: a held one is let go then. */ /** How long a note is heard after Randomize, Mutate or a change with Audition on: a held one is let go then. */
const AUDITION_SECONDS = 2; const AUDITION_SECONDS = 2;
/** How long a change waits to be heard with Audition on, so dragging a number plays once it rests. */ /** How long a change waits to be heard with Audition on, so dragging a number plays once it rests. */
const AUDITION_DELAY_MS = 150; const AUDITION_DELAY_MS = 150;
@@ -57,7 +57,8 @@ const AUDITION_DELAY_MS = 150;
* The nodes of one instrument (#331), and below them its keys: a note is heard while a key is held, * The nodes of one instrument (#331), and below them its keys: a note is heard while a key is held,
* by the pointer or by the computer keyboard. The header holds a value for each of its parameters – * by the pointer or by the computer keyboard. The header holds a value for each of its parameters –
* the tab's own, never written into the asset – and a Stop for everything the tab still plays. Its * the tab's own, never written into the asset – and a Stop for everything the tab still plays. Its
* Preset menu, Randomize and Mutate make 8-bit sounds the way sfxr does (#329) and play them at once. * Preset menu, Randomize and Mutate make 8-bit sounds the way sfxr does (#329); the last two play
* them at once, a preset is heard on the keys.
*/ */
export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanvas> { export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanvas> {
readonly snap = new SnapToggle(() => this.canvas); readonly snap = new SnapToggle(() => this.canvas);
@@ -69,7 +70,7 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
readonly mutateButton: Button; readonly mutateButton: Button;
/** Whether a change to the graph is heard; off by default, as in the Pattern tab. */ /** Whether a change to the graph is heard; off by default, as in the Pattern tab. */
readonly auditionButton: Button; readonly auditionButton: Button;
/** The note a Preset, Randomize, Mutate or Audition plays: the one last played. */ /** The note Randomize, Mutate or Audition plays: the one last played. */
lastNote = MIDDLE_C; lastNote = MIDDLE_C;
private readonly octaveLabel = new Label('', { style: 'small' }); private readonly octaveLabel = new Label('', { style: 'small' });
private readonly paramBar = new HStack({ gap: Theme.gap, align: 'center' }); private readonly paramBar = new HStack({ gap: Theme.gap, align: 'center' });
@@ -78,8 +79,9 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
private defaults: Record<string, number> = {}; private defaults: Record<string, number> = {};
private paramsKey = ''; private paramsKey = '';
private valuesFor = ''; private valuesFor = '';
/** The voices the tab started that may still be playing – what a moved field reaches. */ /** The voices the tab started that may still be playing – what a moved field reaches – and whose. */
private voices: number[] = []; private voices: number[] = [];
private playedFor = '';
/** The graph as last heard or shown, by `soundPrint`, and whose it was. */ /** The graph as last heard or shown, by `soundPrint`, and whose it was. */
private heard = ''; private heard = '';
private heardFor = ''; private heardFor = '';
@@ -170,10 +172,11 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
fxVoiceOf(this.state.project, instrumentFx(this.state.project, asset.id, ''), DEFAULT_TEMPO), fxVoiceOf(this.state.project, instrumentFx(this.state.project, asset.id, ''), DEFAULT_TEMPO),
); );
if (voice) this.voices.push(voice); if (voice) this.voices.push(voice);
this.playedFor = asset.id;
return voice; return voice;
} }
/** Plays the note last played on its own, for a while: what Preset, Randomize, Mutate and Audition do. */ /** Plays the note last played on its own, for a while: what Randomize, Mutate and Audition do. */
audition(): number { audition(): number {
clearTimeout(this.auditionTimer); clearTimeout(this.auditionTimer);
this.stop(); this.stop();
@@ -187,7 +190,6 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
if (!this.state.execute(new ReplaceGraphCommand(asset.id, chipPresetGraph(preset), `Preset ${label}`))) if (!this.state.execute(new ReplaceGraphCommand(asset.id, chipPresetGraph(preset), `Preset ${label}`)))
return; return;
this.canvas.fit(); this.canvas.fit();
this.audition();
} }
randomize(): void { randomize(): void {
@@ -224,6 +226,10 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
this.keyboard.syncRinging(); this.keyboard.syncRinging();
} }
protected override closed(assetId: string): void {
if (assetId === this.playedFor) this.stop();
}
/** The notes the keys show as making a sound (test hook). */ /** The notes the keys show as making a sound (test hook). */
soundingNotes(): number[] { soundingNotes(): number[] {
const asset = this.asset(); const asset = this.asset();
+9
View File
@@ -1,3 +1,5 @@
import { soundLibrary } from '../../viewport/SoundLibrary';
/** /**
* The editor plays one piece at a time (#334): a song or a pattern. Whoever starts playing claims the * The editor plays one piece at a time (#334): a song or a pattern. Whoever starts playing claims the
* transport with its own stop, and whoever had it before is stopped. * transport with its own stop, and whoever had it before is stopped.
@@ -13,3 +15,10 @@ export function claimTransport(stop: () => void): void {
export function releaseTransport(stop: () => void): void { export function releaseTransport(stop: () => void): void {
if (holder === stop) holder = null; if (holder === stop) holder = null;
} }
/** Ends everything the editor's tabs play: the piece holding the transport and every key or preview. */
export function stopPreviews(): void {
holder?.();
holder = null;
soundLibrary.stopAll();
}
+5
View File
@@ -165,6 +165,11 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
this.canvas.invalidate(); this.canvas.invalidate();
} }
protected override closed(assetId: string): void {
if (assetId === this.playingId) this.stop();
if (assetId === this.currentAssetId) soundLibrary.stopOwner(PREVIEW_OWNER);
}
override destroy(options?: Parameters<AssetTabEditor<PatternAsset, PianoRoll>['destroy']>[0]): void { override destroy(options?: Parameters<AssetTabEditor<PatternAsset, PianoRoll>['destroy']>[0]): void {
this.music.stop(); this.music.stop();
super.destroy(options); super.destroy(options);
+4
View File
@@ -338,6 +338,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
this.canvas.invalidate(); this.canvas.invalidate();
} }
protected override closed(assetId: string): void {
if (assetId === this.playingId) this.stop();
}
override destroy(options?: Parameters<AssetTabEditor<SongAsset, SongTimeline>['destroy']>[0]): void { override destroy(options?: Parameters<AssetTabEditor<SongAsset, SongTimeline>['destroy']>[0]): void {
this.music.stop(); this.music.stop();
this.take?.recorder.cancel(); this.take?.recorder.cancel();
+4
View File
@@ -173,6 +173,10 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
this.syncPlayButton(); this.syncPlayButton();
} }
protected override closed(assetId: string): void {
if (assetId === this.playingId) this.stop();
}
/** Only called while the tab is visible: the playhead follows what is heard, and stops with it. */ /** Only called while the tab is visible: the playhead follows what is heard, and stops with it. */
tick(): void { tick(): void {
if (!this.voice) return; if (!this.voice) return;
+7 -1
View File
@@ -42,8 +42,9 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
private defaults: Record<string, number> = {}; private defaults: Record<string, number> = {};
private paramsKey = ''; private paramsKey = '';
private valuesFor = ''; private valuesFor = '';
/** The voices the tab started that may still be playing – what a moved field reaches. */ /** The voices the tab started that may still be playing – what a moved field reaches – and whose. */
private voices: number[] = []; private voices: number[] = [];
private playedFor = '';
constructor(state: EditorState) { constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH }); super(state, { infoWidth: INFO_WIDTH });
@@ -110,6 +111,7 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
const file = playedSoundOf(this.state.project, sound); const file = playedSoundOf(this.state.project, sound);
const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus); const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
if (voice) this.voices.push(voice); if (voice) this.voices.push(voice);
this.playedFor = asset.id;
} }
/** A moved field is heard at once on what the tab is playing (#315); an ended voice is let go. */ /** A moved field is heard at once on what the tab is playing (#315); an ended voice is let go. */
@@ -123,6 +125,10 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
this.voices = []; this.voices = [];
} }
protected override closed(assetId: string): void {
if (assetId === this.playedFor) this.stop();
}
protected override infoText(asset: SoundFxAsset): string { protected override infoText(asset: SoundFxAsset): string {
const build = soundFxBuildFor(asset, this.state.project); const build = soundFxBuildFor(asset, this.state.project);
if (!build.compiled) return build.reason; if (!build.compiled) return build.reason;
+75 -29
View File
@@ -63,6 +63,12 @@ const MAX_SEND_DELAY = 1;
/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */ /** Seconds a stopped voice takes to fall silent, so it does not click (#324). */
const FADE = 0.01; const FADE = 0.01;
/**
* The most ways that play through the pitch worklet at once, a phase vocoder each (#317). Measured in
* Chrome on the Windows runner: 12 take half of the audio thread, 24 all of it, and past that every
* sound drops out until enough of them have ended.
*/
const MAX_VOCODERS = 12;
/** How finely `peaks` cuts a sound up. */ /** How finely `peaks` cuts a sound up. */
const PEAK_BUCKETS = 1024; const PEAK_BUCKETS = 1024;
/** Samples the cuts made so far may hold together before the oldest are let go; one is always kept. */ /** Samples the cuts made so far may hold together before the oldest are let go; one is always kept. */
@@ -107,6 +113,9 @@ interface Driver {
stop(): void; stop(): void;
} }
/** The ways of a voice on the pitch worklet still running: none once only its echoes are left. */
const vocodersOf = (voice: Voice) => (voice.ringing ? 0 : voice.vocoders);
/** Whether a note's end leaves a source as it is: a one-shot, which plays to the end of its sound. */ /** Whether a note's end leaves a source as it is: a one-shot, which plays to the end of its sound. */
const playsThrough = (source: Source) => !source.gate && !source.leave; const playsThrough = (source: Source) => !source.gate && !source.leave;
@@ -157,6 +166,9 @@ interface Voice {
ringing: boolean; ringing: boolean;
/** Calls off the clock that ends its ringing. */ /** Calls off the clock that ends its ringing. */
quiet: (() => void) | null; quiet: (() => void) | null;
/** How many of its ways play through the pitch worklet, and whether its note has been let go. */
vocoders: number;
released: boolean;
} }
/** /**
@@ -193,6 +205,7 @@ interface Wiring {
pitches: number[]; pitches: number[];
knobs: Knob[]; knobs: Knob[];
stages: number; stages: number;
vocoders: number;
} }
/** Everything a voice is started with, the same whether it waits on its bytes first or not. */ /** Everything a voice is started with, the same whether it waits on its bytes first or not. */
@@ -810,7 +823,7 @@ export class SoundLibrary implements AudioSink {
} }
input.connect(node); input.connect(node);
made.nodes.push(node); made.nodes.push(node);
made.stops.push(() => node.port.postMessage('stop')); made.stops.push(() => stopProcessor(node));
made.knobs.push({ param: node.parameters.get('pitch')!, target: pitch, even: 'value' }); made.knobs.push({ param: node.parameters.get('pitch')!, target: pitch, even: 'value' });
return { out: node, latency: shifterLatency(context.sampleRate) }; return { out: node, latency: shifterLatency(context.sampleRate) };
} }
@@ -1000,6 +1013,7 @@ export class SoundLibrary implements AudioSink {
* holds, unless `all`. Of the rest the gated ones fade out at once, with `all` every one. * holds, unless `all`. Of the rest the gated ones fade out at once, with `all` every one.
*/ */
private letGo(voice: Voice, when: number, all: boolean): void { private letGo(voice: Voice, when: number, all: boolean): void {
voice.released = true;
for (const run of voice.envelopes) { for (const run of voice.envelopes) {
if (run.released) continue; if (run.released) continue;
if (!all && run.sources.length > 0 && run.sources.every(playsThrough)) continue; if (!all && run.sources.length > 0 && run.sources.every(playsThrough)) continue;
@@ -1200,6 +1214,8 @@ export class SoundLibrary implements AudioSink {
failed: number; failed: number;
/** Whether the pitch worklet is there; null while nothing has asked for it or it is loading. */ /** Whether the pitch worklet is there; null while nothing has asked for it or it is loading. */
worklet: boolean | null; worklet: boolean | null;
/** Ways playing through the pitch worklet now, at most `MAX_VOCODERS`. */
vocoders: number;
last: LastVoice | null; last: LastVoice | null;
} { } {
return { return {
@@ -1211,6 +1227,7 @@ export class SoundLibrary implements AudioSink {
decoded: this.buffers.size, decoded: this.buffers.size,
failed: this.failed.size, failed: this.failed.size,
worklet: this.hasWorklet, worklet: this.hasWorklet,
vocoders: this.voices.reduce((sum, voice) => sum + vocodersOf(voice), 0),
last: this.last, last: this.last,
}; };
} }
@@ -1388,6 +1405,22 @@ export class SoundLibrary implements AudioSink {
} }
} }
/**
* Cuts the oldest pitched voices, those let go first, while more ways than `MAX_VOCODERS` would run
* a phase vocoder: a quick run up the keys of a sampled instrument started more than the audio
* thread keeps up with, and it fell silent until they had played out.
*/
private makeRoom(newest: Voice): void {
let count = this.voices.reduce((sum, voice) => sum + vocodersOf(voice), 0);
if (count <= MAX_VOCODERS) return;
const older = this.voices.filter((voice) => voice !== newest && vocodersOf(voice) > 0);
for (const voice of [...older.filter((v) => v.released), ...older.filter((v) => !v.released)]) {
if (count <= MAX_VOCODERS) return;
count -= vocodersOf(voice);
this.cut(voice);
}
}
/** Fades the whole voice out and lets it go. */ /** Fades the whole voice out and lets it go. */
private cut(voice: Voice): void { private cut(voice: Voice): void {
this.forget(voice); this.forget(voice);
@@ -1436,8 +1469,17 @@ export class SoundLibrary implements AudioSink {
private done(voice: Voice): void { private done(voice: Voice): void {
this.forget(voice); this.forget(voice);
for (const driver of voice.drivers) driver.stop(); for (const driver of voice.drivers) driver.stop();
// An echo is a loop of nodes; left connected it would keep the audio thread busy for nothing. // Left on its bus, every node of it would be worked on in every block until it happened to be
if (voice.rings) voice.gain.disconnect(); // collected – an echo, a loop of nodes, for good. What its stages hold back is heard first.
const late = voice.rings ? 0 : this.lateOf(voice.fx);
if (late > 0) this.after(this.audio().currentTime + late + FADE, () => voice.gain.disconnect());
else voice.gain.disconnect();
}
/** Seconds the latest layer of a voice is late by its own stages (#326). */
private lateOf(fx: Layered): number {
const rate = this.audio().sampleRate;
return Math.max(0, ...fx.layers.map((plan) => fxLatencyOf(fxChainOf(plan, fx.params, fx.tempo), rate)));
} }
private forget(voice: Voice): void { private forget(voice: Voice): void {
@@ -1534,6 +1576,7 @@ export class SoundLibrary implements AudioSink {
pitches: [], pitches: [],
knobs: [], knobs: [],
stages: 0, stages: 0,
vocoders: 0,
}; };
chains.forEach((chain, i) => heldBack(context, this.wire(wiring, chain), late - lates[i]!).connect(gain)); chains.forEach((chain, i) => heldBack(context, this.wire(wiring, chain), late - lates[i]!).connect(gain));
gain.connect(this.busInput(bus)); gain.connect(this.busInput(bus));
@@ -1567,6 +1610,8 @@ export class SoundLibrary implements AudioSink {
rings, rings,
ringing: false, ringing: false,
quiet: null, quiet: null,
vocoders: wiring.vocoders,
released: false,
}; };
const going = new Set(sources); const going = new Set(sources);
voice.finish = (source) => { voice.finish = (source) => {
@@ -1575,6 +1620,7 @@ export class SoundLibrary implements AudioSink {
for (const source of sources) source.ended(() => voice.finish?.(source)); for (const source of sources) source.ended(() => voice.finish?.(source));
this.voices.push(voice); this.voices.push(voice);
this.started += 1; this.started += 1;
this.makeRoom(voice);
for (const driver of wiring.drivers) driver.start(); for (const driver of wiring.drivers) driver.start();
for (const source of sources) source.start(when, wiring.offset); for (const source of sources) source.start(when, wiring.offset);
// Silent once its decay is over, however long the note is held (#329). // Silent once its decay is over, however long the note is held (#329).
@@ -1699,17 +1745,11 @@ export class SoundLibrary implements AudioSink {
parameterData: { pitch }, parameterData: { pitch },
processorOptions: options, processorOptions: options,
}); });
wiring.vocoders += 1;
wiring.sources.push({ wiring.sources.push({
start: () => {}, start: () => {},
stop: () => { stop: () => stopProcessor(node),
node.port.postMessage('stop'); ended: (then) => endedProcessor(node, then),
node.disconnect();
},
ended: (then) => {
node.port.onmessage = (event: MessageEvent) => {
if (event.data === 'ended') then();
};
},
fade: null, fade: null,
gate, gate,
leave: own && loop ? () => node.port.postMessage('leave') : null, leave: own && loop ? () => node.port.postMessage('leave') : null,
@@ -1786,15 +1826,8 @@ export class SoundLibrary implements AudioSink {
}); });
wiring.sources.push({ wiring.sources.push({
start: () => {}, start: () => {},
stop: () => { stop: () => stopProcessor(node),
node.port.postMessage('stop'); ended: (then) => endedProcessor(node, then),
node.disconnect();
},
ended: (then) => {
node.port.onmessage = (event: MessageEvent) => {
if (event.data === 'ended') then();
};
},
fade: null, fade: null,
gate: true, gate: true,
leave: null, leave: null,
@@ -2478,14 +2511,27 @@ function workletNodes(
if (mod) follow?.(mod, param, even === 'ratio'); if (mod) follow?.(mod, param, even === 'ratio');
return { param: mod ? null : param, target: value, even }; return { param: mod ? null : param, target: value, even };
}); });
return { return { input: node, output: node, knobs, stop: () => stopProcessor(node) };
input: node, }
output: node,
knobs, /**
stop: () => { * Stops a processor and lets go of its port. A port that is listened to is never collected, and
node.port.postMessage('stop'); * with it everything its listener reaches: the voice and every node of it, still on its bus.
node.disconnect(); */
}, function stopProcessor(node: AudioWorkletNode): void {
node.port.postMessage('stop');
node.port.onmessage = null;
node.port.close();
node.disconnect();
}
/** Calls `then` once a source on the worklet has played to its end, and lets go of its port. */
function endedProcessor(node: AudioWorkletNode, then: () => void): void {
node.port.onmessage = (event: MessageEvent) => {
if (event.data !== 'ended') return;
node.port.onmessage = null;
node.port.close();
then();
}; };
} }
+26 -38
View File
@@ -24,6 +24,16 @@ declare function registerProcessor<T>(
processor: new (options: { processorOptions: T }) => object, processor: new (options: { processorOptions: T }) => object,
): void; ): void;
/** A processor that runs until it is told to stop, then lets go of its port so it can be collected. */
abstract class Stoppable extends AudioWorkletProcessor {
protected stopped = false;
protected stop(): void {
this.stopped = true;
this.port.close();
}
}
export interface PitchOptions { export interface PitchOptions {
/** The file the samples are of: they are sent with the first voice of it only and kept here. */ /** The file the samples are of: they are sent with the first voice of it only and kept here. */
file: string; file: string;
@@ -43,9 +53,8 @@ export interface PitchOptions {
/** Every sound a voice has been played from, for the next voice of it – processors share this scope. */ /** Every sound a voice has been played from, for the next voice of it – processors share this scope. */
const sounds = new Map<string, Float32Array[]>(); const sounds = new Map<string, Float32Array[]>();
class PitchProcessor extends AudioWorkletProcessor { class PitchProcessor extends Stoppable {
private readonly player: PitchPlayer; private readonly player: PitchPlayer;
private stopped = false;
private startAt: number; private startAt: number;
constructor({ processorOptions }: { processorOptions: PitchOptions }) { constructor({ processorOptions }: { processorOptions: PitchOptions }) {
@@ -57,7 +66,7 @@ class PitchProcessor extends AudioWorkletProcessor {
this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region, skip); this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region, skip);
this.port.onmessage = (event: MessageEvent) => { this.port.onmessage = (event: MessageEvent) => {
if (event.data === 'leave') this.player.leaveLoop(); if (event.data === 'leave') this.player.leaveLoop();
else this.stopped = true; else this.stop();
}; };
} }
@@ -93,15 +102,12 @@ registerProcessor('pitch-player', PitchProcessor);
* The pitch of a bus (#346): what comes in goes out moved by `pitch`, a frame late. It runs until * The pitch of a bus (#346): what comes in goes out moved by `pitch`, a frame late. It runs until
* the bus tells it to stop. * the bus tells it to stop.
*/ */
class ShiftProcessor extends AudioWorkletProcessor { class ShiftProcessor extends Stoppable {
private readonly shifter = new PitchShifter(2, sampleRate); private readonly shifter = new PitchShifter(2, sampleRate);
private stopped = false;
constructor() { constructor() {
super(); super();
this.port.onmessage = () => { this.port.onmessage = () => this.stop();
this.stopped = true;
};
} }
process( process(
@@ -122,15 +128,12 @@ class ShiftProcessor extends AudioWorkletProcessor {
registerProcessor('pitch-shifter', ShiftProcessor); registerProcessor('pitch-shifter', ShiftProcessor);
/** A Bitcrusher (#322) between two stages of a voice; it runs until the voice tells it to stop. */ /** A Bitcrusher (#322) between two stages of a voice; it runs until the voice tells it to stop. */
class CrushProcessor extends AudioWorkletProcessor { class CrushProcessor extends Stoppable {
private readonly crusher = new Crusher(sampleRate); private readonly crusher = new Crusher(sampleRate);
private stopped = false;
constructor() { constructor() {
super(); super();
this.port.onmessage = () => { this.port.onmessage = () => this.stop();
this.stopped = true;
};
} }
process( process(
@@ -161,16 +164,13 @@ export interface PhaserOptions {
const PHASER_PARAMS = ['frequency', 'rate', 'depth', 'feedback', 'mix'] as const; const PHASER_PARAMS = ['frequency', 'rate', 'depth', 'feedback', 'mix'] as const;
/** A Phaser (#341) between two stages of a voice; it runs until the voice tells it to stop. */ /** A Phaser (#341) between two stages of a voice; it runs until the voice tells it to stop. */
class PhaserProcessor extends AudioWorkletProcessor { class PhaserProcessor extends Stoppable {
private readonly phaser: Phaser; private readonly phaser: Phaser;
private stopped = false;
constructor({ processorOptions }: { processorOptions: PhaserOptions }) { constructor({ processorOptions }: { processorOptions: PhaserOptions }) {
super(); super();
this.phaser = new Phaser(sampleRate, processorOptions.stages); this.phaser = new Phaser(sampleRate, processorOptions.stages);
this.port.onmessage = () => { this.port.onmessage = () => this.stop();
this.stopped = true;
};
} }
process( process(
@@ -198,17 +198,14 @@ export interface FlangerOptions {
const FLANGER_PARAMS = ['offset', 'sweep', 'rate', 'depth', 'feedback', 'mix'] as const; const FLANGER_PARAMS = ['offset', 'sweep', 'rate', 'depth', 'feedback', 'mix'] as const;
/** A Flanger (#341) between two stages of a voice; its sweep starts with the voice. */ /** A Flanger (#341) between two stages of a voice; its sweep starts with the voice. */
class FlangerProcessor extends AudioWorkletProcessor { class FlangerProcessor extends Stoppable {
private readonly flanger = new Flanger(sampleRate); private readonly flanger = new Flanger(sampleRate);
private stopped = false;
private startAt: number; private startAt: number;
constructor({ processorOptions }: { processorOptions: FlangerOptions }) { constructor({ processorOptions }: { processorOptions: FlangerOptions }) {
super(); super();
this.startAt = processorOptions.startAt; this.startAt = processorOptions.startAt;
this.port.onmessage = () => { this.port.onmessage = () => this.stop();
this.stopped = true;
};
} }
process( process(
@@ -247,15 +244,12 @@ registerProcessor('flanger', FlangerProcessor);
const LIMITER_PARAMS = ['threshold', 'ceiling', 'release', 'autoGain'] as const; const LIMITER_PARAMS = ['threshold', 'ceiling', 'release', 'autoGain'] as const;
/** A Limiter (#326) between two stages of a voice or a bus; it runs until it is told to stop. */ /** A Limiter (#326) between two stages of a voice or a bus; it runs until it is told to stop. */
class LimitProcessor extends AudioWorkletProcessor { class LimitProcessor extends Stoppable {
private readonly limiter = new Limiter(sampleRate); private readonly limiter = new Limiter(sampleRate);
private stopped = false;
constructor() { constructor() {
super(); super();
this.port.onmessage = () => { this.port.onmessage = () => this.stop();
this.stopped = true;
};
} }
process( process(
@@ -285,9 +279,8 @@ export interface LfoOptions {
} }
/** A Tremolo or a Vibrato (#320) between two stages; its LFO starts with its voice. */ /** A Tremolo or a Vibrato (#320) between two stages; its LFO starts with its voice. */
class LfoProcessor extends AudioWorkletProcessor { class LfoProcessor extends Stoppable {
private readonly effect: Tremolo | Vibrato; private readonly effect: Tremolo | Vibrato;
private stopped = false;
private startAt: number; private startAt: number;
constructor({ processorOptions }: { processorOptions: LfoOptions & { vibrato: boolean } }) { constructor({ processorOptions }: { processorOptions: LfoOptions & { vibrato: boolean } }) {
@@ -297,9 +290,7 @@ class LfoProcessor extends AudioWorkletProcessor {
? new Vibrato(shape, sampleRate, processorOptions.center) ? new Vibrato(shape, sampleRate, processorOptions.center)
: new Tremolo(shape, sampleRate); : new Tremolo(shape, sampleRate);
this.startAt = processorOptions.startAt; this.startAt = processorOptions.startAt;
this.port.onmessage = () => { this.port.onmessage = () => this.stop();
this.stopped = true;
};
} }
process( process(
@@ -342,18 +333,15 @@ export interface ChipOptions extends ChipShape {
} }
/** The Chip of a note (#329): a source of its own, its frequency, duty and pitch AudioParams. */ /** The Chip of a note (#329): a source of its own, its frequency, duty and pitch AudioParams. */
class ChipProcessor extends AudioWorkletProcessor { class ChipProcessor extends Stoppable {
private readonly player: ChipPlayer; private readonly player: ChipPlayer;
private stopped = false;
private startAt: number; private startAt: number;
constructor({ processorOptions }: { processorOptions: ChipOptions }) { constructor({ processorOptions }: { processorOptions: ChipOptions }) {
super(); super();
this.player = new ChipPlayer(processorOptions, sampleRate); this.player = new ChipPlayer(processorOptions, sampleRate);
this.startAt = processorOptions.startAt; this.startAt = processorOptions.startAt;
this.port.onmessage = () => { this.port.onmessage = () => this.stop();
this.stopped = true;
};
} }
process( process(
+59 -1
View File
@@ -95,7 +95,12 @@ interface FakeWorklet {
pitch: FakeParam; pitch: FakeParam;
parameters: Map<string, FakeParam>; parameters: Map<string, FakeParam>;
posted: unknown[]; 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; disconnected: boolean;
connect: (target: unknown) => unknown; connect: (target: unknown) => unknown;
disconnect: () => void; disconnect: () => void;
@@ -362,6 +367,10 @@ beforeEach(() => {
port: { port: {
postMessage: (message: unknown) => node.posted.push(message), postMessage: (message: unknown) => node.posted.push(message),
onmessage: null, onmessage: null,
close: () => {
node.port.closed = true;
},
closed: false,
}, },
disconnected: false, disconnected: false,
disconnect: () => { 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);
});
});
+58 -12
View File
@@ -1525,9 +1525,39 @@ export async function sound(t) {
); );
await press('ArrowLeft'); await press('ArrowLeft');
// Twice the eleven keys off the root, all held at once: each plays through the pitch worklet, and
// the ten oldest are cut, so no more than twelve phase vocoders run and the audio thread keeps up.
await evaluate('__gm.sound.stopAll()');
const crowd = await json(`(() => {
const voices = [...Array(22)].map((_, i) => __gm.instrument.play(61 + (i % 11)));
return { started: voices.filter((v) => v > 0).length, playing: voices.map((v) => __gm.sound.playing(v)), stats: __gm.sound.stats() };
})()`);
check(
'held at once, 22 notes off the root keep only the 12 newest on the pitch worklet',
crowd.started === 22 &&
crowd.playing.join() === [...Array(22)].map((_, i) => i >= 10).join() &&
crowd.stats.vocoders === 12 &&
crowd.stats.playing === 12,
JSON.stringify({ started: crowd.started, playing: crowd.playing, vocoders: crowd.stats.vocoders }),
);
// Closing the tab ends what it plays: E4, looped and held by no key, falls silent with it.
await evaluate('__gm.sound.stopAll()');
await json('__gm.instrument.play(64)');
const heldBeforeClose = await stats();
await evaluate(`__gm.closeDocument('asset:${insId}')`);
await sleep(300);
const afterClose = await stats();
await evaluate(`__gm.openDocument('asset:${insId}')`);
check(
'closing the Instrument tab ends a note it still plays',
heldBeforeClose.playing === 1 && afterClose.playing === 0,
JSON.stringify({ held: heldBeforeClose.playing, closed: afterClose.playing }),
);
// 34g-6. Presets (#329): Preset → Laser makes the instrument one Chip through Envelope, Filter and // 34g-6. Presets (#329): Preset → Laser makes the instrument one Chip through Envelope, Filter and
// Bitcrusher and plays the note last played at once – C4, typed just now. Randomize and // Bitcrusher and plays nothing. Randomize and Mutate play the note last played; a change
// Mutate play anew; a change only plays with Audition on. Three undos bring the blip back. // only plays with Audition on. Three undos bring the blip back.
const insGraph = () => const insGraph = () =>
json(`(() => { json(`(() => {
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph; const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
@@ -1543,11 +1573,12 @@ export async function sound(t) {
await clickHook('presetAt'); await clickHook('presetAt');
const presetMenu = await json('__gm.overlayTexts()'); const presetMenu = await json('__gm.overlayTexts()');
await clickText('Laser'); await clickText('Laser');
const preset = await waitFor(stats, (s) => s.started > beforePreset.started); await sleep(400);
const preset = await stats();
const laser = await insGraph(); const laser = await insGraph();
const presetInfo = await json('__gm.instrument.info()'); const presetInfo = await json('__gm.instrument.info()');
check( check(
'Preset → Laser makes Notes → Chip → Envelope → Filter → Bitcrusher → Output and plays at once', 'Preset → Laser makes Notes → Chip → Envelope → Filter → Bitcrusher → Output and plays nothing',
['Pickup', 'Laser', 'Explosion', 'Powerup', 'Hit', 'Jump', 'Blip'].every((label) => ['Pickup', 'Laser', 'Explosion', 'Powerup', 'Hit', 'Jump', 'Blip'].every((label) =>
presetMenu.includes(label), presetMenu.includes(label),
) && ) &&
@@ -1556,9 +1587,9 @@ export async function sound(t) {
laser.chip?.followNote === false && laser.chip?.followNote === false &&
laser.chip.slide < 0 && laser.chip.slide < 0 &&
laser.envelope?.sustain === 0 && laser.envelope?.sustain === 0 &&
preset.last?.file === `instrument:${insId}` && preset.started === beforePreset.started &&
presetInfo === '128 notes', presetInfo === '128 notes',
JSON.stringify({ presetMenu, laser, last: preset.last, presetInfo }), JSON.stringify({ presetMenu, laser, started: [beforePreset.started, preset.started], presetInfo }),
); );
await shot('34-instrument-preset'); await shot('34-instrument-preset');
@@ -1612,25 +1643,32 @@ export async function sound(t) {
); );
// 34g-7. An instrument to play tunes on: Preset → Square Lead follows the note and holds it, and // 34g-7. An instrument to play tunes on: Preset → Square Lead follows the note and holds it, and
// plays C4 at once. One undo takes it back. // plays nothing. One undo takes it back.
await evaluate('__gm.sound.stopAll()'); await evaluate('__gm.sound.stopAll()');
const beforeLead = await stats(); const beforeLead = await stats();
await clickHook('presetAt'); await clickHook('presetAt');
await clickText('Square Lead'); await clickText('Square Lead');
const lead = await waitFor(stats, (s) => s.started > beforeLead.started); await sleep(400);
const lead = await stats();
const leadGraph = await insGraph(); const leadGraph = await insGraph();
const leadInfo = await json('__gm.instrument.info()'); const leadInfo = await json('__gm.instrument.info()');
await t.clickToolbar('undo'); await t.clickToolbar('undo');
const unled = await insGraph(); const unled = await insGraph();
check( check(
'Preset → Square Lead plays every note at its own pitch and holds it; one undo takes it back', 'Preset → Square Lead plays every note at its own pitch, holds it and plays nothing; one undo takes it back',
['Square Lead', 'Phased Pad', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) && ['Square Lead', 'Phased Pad', 'Organ', 'Bell', 'Drum Kit'].every((label) => presetMenu.includes(label)) &&
leadGraph.chip?.followNote === true && leadGraph.chip?.followNote === true &&
leadGraph.envelope?.sustain > 0 && leadGraph.envelope?.sustain > 0 &&
lead.last?.file === `instrument:${insId}` && lead.started === beforeLead.started &&
leadInfo === '128 notes' && leadInfo === '128 notes' &&
unled.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput', unled.types.join() === 'InstrumentNotes,InstrumentSample,InstrumentOutput',
JSON.stringify({ chip: leadGraph.chip, envelope: leadGraph.envelope, last: lead.last, leadInfo, unled }), JSON.stringify({
chip: leadGraph.chip,
envelope: leadGraph.envelope,
started: [beforeLead.started, lead.started],
leadInfo,
unled,
}),
); );
await evaluate('__gm.sound.stopAll()'); await evaluate('__gm.sound.stopAll()');
@@ -1640,10 +1678,18 @@ export async function sound(t) {
node('sn-note', 'PlayNote', 260, 220, { instrument: insId, note: 67, duration: 1.5 }), node('sn-note', 'PlayNote', 260, 220, { instrument: insId, note: 67, duration: 1.5 }),
)}); )});
graph.edges.find((e) => e.id === 'sn-e1').to = { node: 'sn-note', port: 'exec' };`); graph.edges.find((e) => e.id === 'sn-e1').to = { node: 'sn-note', port: 'exec' };`);
// A key still held in the Instrument tab is not part of the game: Play ends it.
const preview = await json('__gm.instrument.play(64)');
const previewHeld = await json(`__gm.sound.playing(${preview})`);
await showTab('scene'); await showTab('scene');
await evaluate('__gm.sound.stopAll()');
const beforeNote = await stats(); const beforeNote = await stats();
await play(); await play();
const previewAfterPlay = await json(`__gm.sound.playing(${preview})`);
check(
'Play ends what the tabs play: a note held in the Instrument tab is gone',
preview > 0 && previewHeld && !previewAfterPlay,
JSON.stringify({ preview, previewHeld, previewAfterPlay }),
);
// The sample loops, so only the release after 1.5 s on the audio clock ends the note: it is heard // The sample loops, so only the release after 1.5 s on the audio clock ends the note: it is heard
// well past the 0.25 s of the blip, never past its duration, and is gone soon after. // well past the 0.25 s of the blip, never past its duration, and is gone soon after.
const noteNow = () => json('({ stats: __gm.sound.stats(), clock: __gm.sound.clock() })'); const noteNow = () => json('({ stats: __gm.sound.stats(), clock: __gm.sound.clock() })');