Keep the audio thread free of ended voices and too many vocoders (#356)
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:
1 parent
8c0d1cfd3f
commit
25c1d02b06
14 files changed
+269
-88
No files matched your search
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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() })');
|
||||||
|
|||||||
Reference in new issue
Block a user