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();
}
/** 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 {
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. */
abstract selectedId(): string;
+2
View File
@@ -24,6 +24,7 @@ import type { GraphEditor } from './graph/GraphEditor';
import type { LiveMachineState } from './graph/GraphCanvas';
import type { StateMachineEditor } from './statemachine/StateMachineEditor';
import type { EditorState } from './state';
import { stopPreviews } from './music/transport';
/** Facts about the running world for the check scripts. */
export interface WorldSnapshot {
@@ -150,6 +151,7 @@ export class PlaySession {
return;
}
ui().focus.blur();
stopPreviews();
// A run starts at second zero, whatever the editor's previews have been counting up to.
gameClock.reset();
const runtime = this.state.enterPlay();
+2
View File
@@ -146,6 +146,8 @@ export function graphHooks({ state, editor }: HookContext) {
}),
params: () => editor.instrumentEditor.previewValues,
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: {
current: () => editor.particlesEditor.currentAssetId,
+1
View File
@@ -79,6 +79,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
clockState: () => soundLibrary.clockState(),
/** The second of the audio clock a voice started at, and the rate that clock runs at (#346). */
startedAt: (voice: number) => soundLibrary.startedAt(voice),
playing: (voice: number) => soundLibrary.playing(voice),
sampleRate: () => soundLibrary.liveContext().sampleRate,
/**
* 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;
/** Whose voices the tab's Stop silences: its own, never a running game's. */
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;
/** How long a change waits to be heard with Audition on, so dragging a number plays once it rests. */
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,
* 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
* 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> {
readonly snap = new SnapToggle(() => this.canvas);
@@ -69,7 +70,7 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
readonly mutateButton: Button;
/** Whether a change to the graph is heard; off by default, as in the Pattern tab. */
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;
private readonly octaveLabel = new Label('', { style: 'small' });
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 paramsKey = '';
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 playedFor = '';
/** The graph as last heard or shown, by `soundPrint`, and whose it was. */
private heard = '';
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),
);
if (voice) this.voices.push(voice);
this.playedFor = asset.id;
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 {
clearTimeout(this.auditionTimer);
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}`)))
return;
this.canvas.fit();
this.audition();
}
randomize(): void {
@@ -224,6 +226,10 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
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). */
soundingNotes(): number[] {
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
* 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 {
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();
}
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 {
this.music.stop();
super.destroy(options);
+4
View File
@@ -338,6 +338,10 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
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 {
this.music.stop();
this.take?.recorder.cancel();
+4
View File
@@ -173,6 +173,10 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
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. */
tick(): void {
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 paramsKey = '';
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 playedFor = '';
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
@@ -110,6 +111,7 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
const file = playedSoundOf(this.state.project, sound);
const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
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. */
@@ -123,6 +125,10 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
this.voices = [];
}
protected override closed(assetId: string): void {
if (assetId === this.playedFor) this.stop();
}
protected override infoText(asset: SoundFxAsset): string {
const build = soundFxBuildFor(asset, this.state.project);
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). */
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. */
const PEAK_BUCKETS = 1024;
/** 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;
}
/** 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. */
const playsThrough = (source: Source) => !source.gate && !source.leave;
@@ -157,6 +166,9 @@ interface Voice {
ringing: boolean;
/** Calls off the clock that ends its ringing. */
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[];
knobs: Knob[];
stages: number;
vocoders: number;
}
/** 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);
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' });
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.
*/
private letGo(voice: Voice, when: number, all: boolean): void {
voice.released = true;
for (const run of voice.envelopes) {
if (run.released) continue;
if (!all && run.sources.length > 0 && run.sources.every(playsThrough)) continue;
@@ -1200,6 +1214,8 @@ export class SoundLibrary implements AudioSink {
failed: number;
/** Whether the pitch worklet is there; null while nothing has asked for it or it is loading. */
worklet: boolean | null;
/** Ways playing through the pitch worklet now, at most `MAX_VOCODERS`. */
vocoders: number;
last: LastVoice | null;
} {
return {
@@ -1211,6 +1227,7 @@ export class SoundLibrary implements AudioSink {
decoded: this.buffers.size,
failed: this.failed.size,
worklet: this.hasWorklet,
vocoders: this.voices.reduce((sum, voice) => sum + vocodersOf(voice), 0),
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. */
private cut(voice: Voice): void {
this.forget(voice);
@@ -1436,8 +1469,17 @@ export class SoundLibrary implements AudioSink {
private done(voice: Voice): void {
this.forget(voice);
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.
if (voice.rings) voice.gain.disconnect();
// Left on its bus, every node of it would be worked on in every block until it happened to be
// 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 {
@@ -1534,6 +1576,7 @@ export class SoundLibrary implements AudioSink {
pitches: [],
knobs: [],
stages: 0,
vocoders: 0,
};
chains.forEach((chain, i) => heldBack(context, this.wire(wiring, chain), late - lates[i]!).connect(gain));
gain.connect(this.busInput(bus));
@@ -1567,6 +1610,8 @@ export class SoundLibrary implements AudioSink {
rings,
ringing: false,
quiet: null,
vocoders: wiring.vocoders,
released: false,
};
const going = new Set(sources);
voice.finish = (source) => {
@@ -1575,6 +1620,7 @@ export class SoundLibrary implements AudioSink {
for (const source of sources) source.ended(() => voice.finish?.(source));
this.voices.push(voice);
this.started += 1;
this.makeRoom(voice);
for (const driver of wiring.drivers) driver.start();
for (const source of sources) source.start(when, wiring.offset);
// Silent once its decay is over, however long the note is held (#329).
@@ -1699,17 +1745,11 @@ export class SoundLibrary implements AudioSink {
parameterData: { pitch },
processorOptions: options,
});
wiring.vocoders += 1;
wiring.sources.push({
start: () => {},
stop: () => {
node.port.postMessage('stop');
node.disconnect();
},
ended: (then) => {
node.port.onmessage = (event: MessageEvent) => {
if (event.data === 'ended') then();
};
},
stop: () => stopProcessor(node),
ended: (then) => endedProcessor(node, then),
fade: null,
gate,
leave: own && loop ? () => node.port.postMessage('leave') : null,
@@ -1786,15 +1826,8 @@ export class SoundLibrary implements AudioSink {
});
wiring.sources.push({
start: () => {},
stop: () => {
node.port.postMessage('stop');
node.disconnect();
},
ended: (then) => {
node.port.onmessage = (event: MessageEvent) => {
if (event.data === 'ended') then();
};
},
stop: () => stopProcessor(node),
ended: (then) => endedProcessor(node, then),
fade: null,
gate: true,
leave: null,
@@ -2478,14 +2511,27 @@ function workletNodes(
if (mod) follow?.(mod, param, even === 'ratio');
return { param: mod ? null : param, target: value, even };
});
return {
input: node,
output: node,
knobs,
stop: () => {
node.port.postMessage('stop');
node.disconnect();
},
return { input: node, output: node, knobs, stop: () => stopProcessor(node) };
}
/**
* Stops a processor and lets go of its port. A port that is listened to is never collected, and
* with it everything its listener reaches: the voice and every node of it, still on its bus.
*/
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,
): 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 {
/** The file the samples are of: they are sent with the first voice of it only and kept here. */
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. */
const sounds = new Map<string, Float32Array[]>();
class PitchProcessor extends AudioWorkletProcessor {
class PitchProcessor extends Stoppable {
private readonly player: PitchPlayer;
private stopped = false;
private startAt: number;
constructor({ processorOptions }: { processorOptions: PitchOptions }) {
@@ -57,7 +66,7 @@ class PitchProcessor extends AudioWorkletProcessor {
this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region, skip);
this.port.onmessage = (event: MessageEvent) => {
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 bus tells it to stop.
*/
class ShiftProcessor extends AudioWorkletProcessor {
class ShiftProcessor extends Stoppable {
private readonly shifter = new PitchShifter(2, sampleRate);
private stopped = false;
constructor() {
super();
this.port.onmessage = () => {
this.stopped = true;
};
this.port.onmessage = () => this.stop();
}
process(
@@ -122,15 +128,12 @@ class ShiftProcessor extends AudioWorkletProcessor {
registerProcessor('pitch-shifter', ShiftProcessor);
/** 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 stopped = false;
constructor() {
super();
this.port.onmessage = () => {
this.stopped = true;
};
this.port.onmessage = () => this.stop();
}
process(
@@ -161,16 +164,13 @@ export interface PhaserOptions {
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. */
class PhaserProcessor extends AudioWorkletProcessor {
class PhaserProcessor extends Stoppable {
private readonly phaser: Phaser;
private stopped = false;
constructor({ processorOptions }: { processorOptions: PhaserOptions }) {
super();
this.phaser = new Phaser(sampleRate, processorOptions.stages);
this.port.onmessage = () => {
this.stopped = true;
};
this.port.onmessage = () => this.stop();
}
process(
@@ -198,17 +198,14 @@ export interface FlangerOptions {
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. */
class FlangerProcessor extends AudioWorkletProcessor {
class FlangerProcessor extends Stoppable {
private readonly flanger = new Flanger(sampleRate);
private stopped = false;
private startAt: number;
constructor({ processorOptions }: { processorOptions: FlangerOptions }) {
super();
this.startAt = processorOptions.startAt;
this.port.onmessage = () => {
this.stopped = true;
};
this.port.onmessage = () => this.stop();
}
process(
@@ -247,15 +244,12 @@ registerProcessor('flanger', FlangerProcessor);
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. */
class LimitProcessor extends AudioWorkletProcessor {
class LimitProcessor extends Stoppable {
private readonly limiter = new Limiter(sampleRate);
private stopped = false;
constructor() {
super();
this.port.onmessage = () => {
this.stopped = true;
};
this.port.onmessage = () => this.stop();
}
process(
@@ -285,9 +279,8 @@ export interface LfoOptions {
}
/** 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 stopped = false;
private startAt: number;
constructor({ processorOptions }: { processorOptions: LfoOptions & { vibrato: boolean } }) {
@@ -297,9 +290,7 @@ class LfoProcessor extends AudioWorkletProcessor {
? new Vibrato(shape, sampleRate, processorOptions.center)
: new Tremolo(shape, sampleRate);
this.startAt = processorOptions.startAt;
this.port.onmessage = () => {
this.stopped = true;
};
this.port.onmessage = () => this.stop();
}
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. */
class ChipProcessor extends AudioWorkletProcessor {
class ChipProcessor extends Stoppable {
private readonly player: ChipPlayer;
private stopped = false;
private startAt: number;
constructor({ processorOptions }: { processorOptions: ChipOptions }) {
super();
this.player = new ChipPlayer(processorOptions, sampleRate);
this.startAt = processorOptions.startAt;
this.port.onmessage = () => {
this.stopped = true;
};
this.port.onmessage = () => this.stop();
}
process(
+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);
});
});
+58 -12
View File
@@ -1525,9 +1525,39 @@ export async function sound(t) {
);
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
// Bitcrusher and plays the note last played at once – C4, typed just now. Randomize and
// Mutate play anew; a change only plays with Audition on. Three undos bring the blip back.
// Bitcrusher and plays nothing. Randomize and Mutate play the note last played; a change
// only plays with Audition on. Three undos bring the blip back.
const insGraph = () =>
json(`(() => {
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');
const presetMenu = await json('__gm.overlayTexts()');
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 presetInfo = await json('__gm.instrument.info()');
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) =>
presetMenu.includes(label),
) &&
@@ -1556,9 +1587,9 @@ export async function sound(t) {
laser.chip?.followNote === false &&
laser.chip.slide < 0 &&
laser.envelope?.sustain === 0 &&
preset.last?.file === `instrument:${insId}` &&
preset.started === beforePreset.started &&
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');
@@ -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
// plays C4 at once. One undo takes it back.
// plays nothing. One undo takes it back.
await evaluate('__gm.sound.stopAll()');
const beforeLead = await stats();
await clickHook('presetAt');
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 leadInfo = await json('__gm.instrument.info()');
await t.clickToolbar('undo');
const unled = await insGraph();
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)) &&
leadGraph.chip?.followNote === true &&
leadGraph.envelope?.sustain > 0 &&
lead.last?.file === `instrument:${insId}` &&
lead.started === beforeLead.started &&
leadInfo === '128 notes' &&
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()');
@@ -1640,10 +1678,18 @@ export async function sound(t) {
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' };`);
// 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 evaluate('__gm.sound.stopAll()');
const beforeNote = await stats();
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
// 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() })');