diff --git a/src/editor/AssetTabEditor.ts b/src/editor/AssetTabEditor.ts index cc871e3c..fe288c3c 100644 --- a/src/editor/AssetTabEditor.ts +++ b/src/editor/AssetTabEditor.ts @@ -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; diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index d1d2061f..0ff2684d 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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(); diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 827da512..6e79b028 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -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, diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index d3ea5dfb..d5b3c065 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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. diff --git a/src/editor/instrument/InstrumentEditor.ts b/src/editor/instrument/InstrumentEditor.ts index 17d3ff94..ac704b94 100644 --- a/src/editor/instrument/InstrumentEditor.ts +++ b/src/editor/instrument/InstrumentEditor.ts @@ -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 { readonly snap = new SnapToggle(() => this.canvas); @@ -69,7 +70,7 @@ export class InstrumentEditor extends AssetTabEditor = {}; 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 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(); +} diff --git a/src/editor/pattern/PatternEditor.ts b/src/editor/pattern/PatternEditor.ts index c796fbeb..8d66752d 100644 --- a/src/editor/pattern/PatternEditor.ts +++ b/src/editor/pattern/PatternEditor.ts @@ -165,6 +165,11 @@ export class PatternEditor extends AssetTabEditor { 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['destroy']>[0]): void { this.music.stop(); super.destroy(options); diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index 04b67f9a..cf92ef3b 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -338,6 +338,10 @@ export class SongEditor extends AssetTabEditor { this.canvas.invalidate(); } + protected override closed(assetId: string): void { + if (assetId === this.playingId) this.stop(); + } + override destroy(options?: Parameters['destroy']>[0]): void { this.music.stop(); this.take?.recorder.cancel(); diff --git a/src/editor/sound/SoundEditor.ts b/src/editor/sound/SoundEditor.ts index e9a4a285..b08a18fa 100644 --- a/src/editor/sound/SoundEditor.ts +++ b/src/editor/sound/SoundEditor.ts @@ -173,6 +173,10 @@ export class SoundEditor extends AssetTabEditor { 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; diff --git a/src/editor/soundfx/SoundFxEditor.ts b/src/editor/soundfx/SoundFxEditor.ts index 75c2df20..962d6be1 100644 --- a/src/editor/soundfx/SoundFxEditor.ts +++ b/src/editor/soundfx/SoundFxEditor.ts @@ -42,8 +42,9 @@ export class SoundFxEditor extends AssetTabEditor { private defaults: Record = {}; 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 { 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 { 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; diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 1b168f20..8257140b 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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(); }; } diff --git a/src/viewport/fxWorklet.ts b/src/viewport/fxWorklet.ts index 1f4cf3dc..38256d57 100644 --- a/src/viewport/fxWorklet.ts +++ b/src/viewport/fxWorklet.ts @@ -24,6 +24,16 @@ declare function registerProcessor( 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(); -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( diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 19eeffb7..96cb0b2c 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -95,7 +95,12 @@ interface FakeWorklet { pitch: FakeParam; parameters: Map; 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); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 78769889..7dba9e6b 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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() })');