Play and stop a song from a behavior in the game #336
A song could only be heard in its tab. Now a game plays it through Play song and ends it through Stop song, with its tracks as they are set. A playing pattern or song also no longer undoes the pause of the toolbar. - Play song plays from the start of the song and then round the loop of its ruler, so an intro is heard once. Without loop it plays once to its end. Inputs are song, volume, fade in, loop (on by default) and detached (off by default). It gives out playback. - Stop song stops one song, every song for none, or one playback by its number, over its fade out. Stop sound with no sound named stops songs too. Stop pattern with no pattern named stops only patterns. - Songs play side by side, as patterns do. They are owned like a sound, so leaving the scene ends one unless it is detached. Pause game and the time scale leave the music alone. - Each playing is heard through a master of its own, silent until its first note. The fades move it, and a song's tracks sit inside it on buses of their own. They go once the playing and its voices have. - Notes and clips are struck the same way in the editor and the game (strikeNote, playClip). What a sequence needs comes from one place (core/music/needs). Findings: - Reading the audio clock resumed a suspended context. A playing pattern read it every 25 ms and so undid the toolbar's pause. The library now leaves a context alone while the toolbar holds it. - A pattern played once to its end asked the list of playings about its voices, and that list asked the pattern again. Once its last voice was gone this ran until the stack overflowed. The scheduler of a playing now asks only after voices. - e2e 64 reads the playings through __gm.sound.playbacks() now. Tests: - song-play.test.ts (new): 8 tests, node catalog +8 cases - sound-library.test.ts: 66 tests, 4 new - e2e 64 (melektaus-atx): 23 of 23 checks passed - e2e 65 (melektaus-atx): ALL PASSED, 22 checks (+4) - Gate (melektaus-atx): CHECK EXIT 0, 251 files, 3903 tests - Counter-checks: the old way of asking after voices, a clock read that wakes a held context, and a start after a fade out began each fail 1 test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8f9c250eb6
commit
f97961c8ff
23 files changed
+1134
-194
No files matched your search
@@ -277,6 +277,17 @@ export interface NodeContext {
|
||||
playPattern(patternId: string, volume: number, loop: boolean, detached: boolean): number;
|
||||
/** Stops every playing of that pattern, `''` every pattern; `letRing` lets its notes fall away in their release. */
|
||||
stopPattern(patternId: string, letRing: boolean): void;
|
||||
/**
|
||||
* Plays a song asset (#336) at its tempo, its tracks as they are set: from its start, then round
|
||||
* the loop of its ruler with `loop`, else once to its end. `volume` (0 to 1) is the level of the
|
||||
* whole of it, rising there from silence over `fadeIn` seconds. Owned like a `playSound` voice.
|
||||
* Returns its number; 0 where there is nothing to play.
|
||||
*/
|
||||
playSong(songId: string, volume: number, fadeIn: number, loop: boolean, detached: boolean): number;
|
||||
/** Stops every playing of that song, `''` every song, falling silent over `fadeOut` seconds. */
|
||||
stopSong(songId: string, fadeOut: number): void;
|
||||
/** Stops one playing of a song or pattern by its number, falling silent over `fadeOut` seconds. */
|
||||
stopPlayback(playback: number, fadeOut: number): void;
|
||||
/**
|
||||
* How many seconds one run of that sound asset lasts, so a change can span it; 0 where it does not
|
||||
* exist or has not been loaded yet.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asNumber, asString } from '../../values';
|
||||
import { exec, num, withDefault } from '../shared';
|
||||
|
||||
export const songPort = (): PortDef => ({
|
||||
key: 'song',
|
||||
label: 'song',
|
||||
type: 'string',
|
||||
field: { key: 'song', label: 'Song', kind: 'asset', default: '', assetTypes: ['song'] },
|
||||
});
|
||||
|
||||
export const PlaySong: NodeDef = {
|
||||
type: 'PlaySong',
|
||||
label: 'Play song',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays a song asset at its tempo, its tracks with the volume, pan, mute and solo they have. With ' +
|
||||
'"loop" it plays from its start and then round and round the loop set on its ruler – an intro ' +
|
||||
'once, then the loop – until a Stop song ends it; without, once to its end. "volume" is the ' +
|
||||
'level of the whole song, "fade in" the seconds it rises there from silence. Each play is a ' +
|
||||
'playing of its own: firing this twice plays it twice, side by side. It belongs to this object ' +
|
||||
'like a sound and stops when the object is destroyed or the scene is left, unless "detached" ' +
|
||||
'is on. "playback" is the number of this playing, for Stop song; 0 where the song has nothing ' +
|
||||
'to play.',
|
||||
inputs: [
|
||||
exec(),
|
||||
songPort(),
|
||||
withDefault(num('volume'), 1),
|
||||
withDefault(num('fadeIn', 'fade in'), 0),
|
||||
{
|
||||
key: 'loop',
|
||||
label: 'loop',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
field: { key: 'loop', label: 'Loop', kind: 'boolean', default: true },
|
||||
},
|
||||
{
|
||||
key: 'detached',
|
||||
label: 'detached',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
|
||||
},
|
||||
],
|
||||
outputs: [exec(), num('playback')],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const song = asString(inputs.song);
|
||||
const playback = song
|
||||
? ctx.playSong(
|
||||
song,
|
||||
asNumber(inputs.volume),
|
||||
asNumber(inputs.fadeIn),
|
||||
asBoolean(inputs.loop),
|
||||
asBoolean(inputs.detached),
|
||||
)
|
||||
: 0;
|
||||
return { next: 'exec', outputs: { playback } };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber, asString } from '../../values';
|
||||
import { exec, num, withDefault } from '../shared';
|
||||
import { songPort } from './PlaySong';
|
||||
|
||||
export const StopSong: NodeDef = {
|
||||
type: 'StopSong',
|
||||
label: 'Stop song',
|
||||
category: 'action',
|
||||
description:
|
||||
'Stops every playing of a song, however many Play song started. With no song picked it stops ' +
|
||||
'every song, and leaves patterns and sounds alone. With "playback" connected it stops only that ' +
|
||||
'one playing, the number Play song handed out, and nothing where it is 0. "fade out" is the ' +
|
||||
'seconds it falls silent over before it ends; 0 ends it at once.',
|
||||
inputs: [
|
||||
exec(),
|
||||
songPort(),
|
||||
// Not typed in: unconnected it is null, and only then does the song decide.
|
||||
{ ...num('playback'), inline: false },
|
||||
withDefault(num('fadeOut', 'fade out'), 0),
|
||||
],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const fade = asNumber(inputs.fadeOut);
|
||||
if (inputs.playback === null || inputs.playback === undefined) ctx.stopSong(asString(inputs.song), fade);
|
||||
else {
|
||||
const playback = asNumber(inputs.playback);
|
||||
if (playback > 0) ctx.stopPlayback(playback, fade);
|
||||
}
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -27,6 +27,8 @@ import { SetSoundFxParameters } from './action/SetSoundFxParameters';
|
||||
import { PlayNote } from './action/PlayNote';
|
||||
import { PlayPattern } from './action/PlayPattern';
|
||||
import { StopPattern } from './action/StopPattern';
|
||||
import { PlaySong } from './action/PlaySong';
|
||||
import { StopSong } from './action/StopSong';
|
||||
import { SetZ } from './action/SetZ';
|
||||
import { Spawn } from './action/Spawn';
|
||||
import { StopGame } from './action/StopGame';
|
||||
@@ -312,6 +314,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
InstrumentParameterValues,
|
||||
PlayPattern,
|
||||
StopPattern,
|
||||
PlaySong,
|
||||
StopSong,
|
||||
LoadScene,
|
||||
Log,
|
||||
StopGame,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { instrumentBuildFor, instrumentSourcesOf } from '../instrument/compile';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { soundFxBuildFor } from '../soundfx/compile';
|
||||
import { DRY_VOICE, type FxPlan, type FxVoice } from '../soundfx/nodes';
|
||||
import type { SequenceNeeds } from './playbacks';
|
||||
import type { Sequence } from './sequence';
|
||||
|
||||
/** The sound FX a clip goes through; one that does not compile plays it dry, as `Play sound` does. */
|
||||
export function clipFx(project: Project, fxId: string, tempo: number): FxVoice {
|
||||
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
|
||||
const plan = fx && soundFxBuildFor(fx).compiled;
|
||||
return plan ? { plan, params: {}, tempo } : DRY_VOICE;
|
||||
}
|
||||
|
||||
/** Every layer of every note and every file and FX of every clip a sequence plays. */
|
||||
export function sequenceNeeds(project: Project, sequence: Sequence): SequenceNeeds {
|
||||
const sources = instrumentSourcesOf(project);
|
||||
const seen = new Set<string>();
|
||||
const layers: FxPlan[] = [];
|
||||
for (const { instrument, note } of sequence.notes) {
|
||||
const key = `${instrument}\u0000${note}`;
|
||||
const asset = findAssetOf(project, instrument, 'instrument');
|
||||
if (seen.has(key) || !asset) continue;
|
||||
seen.add(key);
|
||||
layers.push(...instrumentBuildFor(asset, note, sources).layers);
|
||||
}
|
||||
const files = new Set<string>();
|
||||
for (const clip of sequence.clips ?? []) {
|
||||
const file = findAssetOf(project, clip.sound, 'sound')?.file;
|
||||
if (file) files.add(file);
|
||||
const fx = clipFx(project, clip.fx, 120);
|
||||
if (fx !== DRY_VOICE) layers.push(fx.plan);
|
||||
}
|
||||
return { layers, files: [...files] };
|
||||
}
|
||||
+99
-36
@@ -1,37 +1,72 @@
|
||||
import type { FxPlan } from '../soundfx/nodes';
|
||||
import { type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
||||
import type { Sequence } from './sequence';
|
||||
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
||||
import type { LoopRange, Sequence } from './sequence';
|
||||
|
||||
/** What a game asks to hear of a pattern: its notes, how fast, how long, and whether round and round. */
|
||||
/** What a sequence is made of: loaded before the first of its notes and clips is planned. */
|
||||
export interface SequenceNeeds {
|
||||
layers: readonly FxPlan[];
|
||||
files: readonly string[];
|
||||
}
|
||||
|
||||
/** What a track of a song is set to: its bus, its level and its pan (#334). */
|
||||
export interface BusLevel {
|
||||
name: string;
|
||||
gain: number;
|
||||
pan: number;
|
||||
}
|
||||
|
||||
/** What a game asks to hear of a pattern or a song: its notes and clips, how fast, how long, how round. */
|
||||
export interface SequencePlay {
|
||||
sequence: Sequence;
|
||||
tempo: number;
|
||||
/** In ticks: where it ends, and with `loop` where it starts again from 0. */
|
||||
/** In ticks: where it ends when it does not loop. */
|
||||
length: number;
|
||||
loop: boolean;
|
||||
/** What its notes are made of, loaded before the first one is planned. */
|
||||
layers: readonly FxPlan[];
|
||||
/** What it runs round once the playhead gets there, from the start on; null plays it once to `length`. */
|
||||
loop: LoopRange | null;
|
||||
needs: SequenceNeeds;
|
||||
/** The level of the whole playing, 1 where left out, and the seconds it rises to it from silence. */
|
||||
volume?: number;
|
||||
fadeIn?: number;
|
||||
/** The tracks of a song (#336): the buses its notes and clips name, each set to its level and pan. */
|
||||
buses?: readonly BusLevel[];
|
||||
/** Starts one note as the player was asked to, and gives back the number of its voice. */
|
||||
strike(note: ScheduledNote): number;
|
||||
strikeClip?(clip: ScheduledClip): number;
|
||||
}
|
||||
|
||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play'> {
|
||||
prepare(layers: readonly FxPlan[]): Promise<void>;
|
||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip'> {
|
||||
prepare(needs: SequenceNeeds): Promise<void>;
|
||||
/** The level of a playing, from where it is at `at` to `gain`, reached `seconds` later. */
|
||||
level(id: number, gain: number, at: number, seconds: number): void;
|
||||
/** Calls `then` once the audio clock reaches `at`. */
|
||||
after(at: number, then: () => void): void;
|
||||
/** A playing is gone: stopped, or played to its end. */
|
||||
closed?(id: number): void;
|
||||
}
|
||||
|
||||
export interface Playback {
|
||||
id: number;
|
||||
/** What stands for the pattern, as a file stands for a sound. */
|
||||
/** What stands for the pattern or song, as a file stands for a sound: `pattern:<id>`, `song:<id>`. */
|
||||
name: string;
|
||||
owner: string;
|
||||
scheduler: Scheduler;
|
||||
/** The last notes planned, newest last, for a check to hold against the clock. */
|
||||
/** The last notes and clips planned, newest last, for a check to hold against the clock. */
|
||||
planned: ScheduledNote[];
|
||||
clips: ScheduledClip[];
|
||||
/** The second of the audio clock a fade out ends it at; null while it is not fading. */
|
||||
fading: number | null;
|
||||
}
|
||||
|
||||
/** Whether a playing goes by `name`: its own, every one of a kind for `kind:`, every one for `''`. */
|
||||
export function goesBy(playing: string, name: string): boolean {
|
||||
return name === '' || playing === name || (name.endsWith(':') && playing.startsWith(name));
|
||||
}
|
||||
|
||||
const KEPT = 64;
|
||||
|
||||
/**
|
||||
* The patterns a game plays (#336 ahead of songs): each on a scheduler of its own, numbered like a
|
||||
* voice, owned like one and stopped like one. One played to its end goes by itself.
|
||||
* The patterns and songs a game plays (#336): each on a scheduler of its own, numbered like a voice,
|
||||
* owned like one and stopped like one. One played to its end goes by itself.
|
||||
*/
|
||||
export class Playbacks {
|
||||
private list: Playback[] = [];
|
||||
@@ -41,50 +76,78 @@ export class Playbacks {
|
||||
/** Starts once its files are there; a stop that came first wins. */
|
||||
start(id: number, name: string, play: SequencePlay, owner: string): void {
|
||||
const planned: ScheduledNote[] = [];
|
||||
const strike = (note: ScheduledNote) => {
|
||||
planned.push(note);
|
||||
if (planned.length > 64) planned.shift();
|
||||
return play.strike(note);
|
||||
const clips: ScheduledClip[] = [];
|
||||
const keep = <T>(list: T[], item: T) => {
|
||||
list.push(item);
|
||||
if (list.length > KEPT) list.shift();
|
||||
};
|
||||
const scheduler = new Scheduler({ ...this.host, play: strike }, () => play.sequence, {
|
||||
tempo: play.tempo,
|
||||
loop: play.loop ? { start: 0, end: play.length } : null,
|
||||
end: play.length,
|
||||
});
|
||||
const playback = { id, name, owner, scheduler, planned };
|
||||
const strikeClip = play.strikeClip;
|
||||
const scheduler = new Scheduler(
|
||||
{
|
||||
...this.host,
|
||||
play: (note) => (keep(planned, note), play.strike(note)),
|
||||
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
|
||||
},
|
||||
() => play.sequence,
|
||||
{ tempo: play.tempo, loop: play.loop, end: play.length },
|
||||
);
|
||||
const playback: Playback = { id, name, owner, scheduler, planned, clips, fading: null };
|
||||
this.list.push(playback);
|
||||
void this.host.prepare(play.layers).then(() => {
|
||||
if (this.list.includes(playback)) scheduler.start(0);
|
||||
void this.host.prepare(play.needs).then(() => {
|
||||
if (!this.list.includes(playback) || playback.fading !== null) return;
|
||||
scheduler.start(0);
|
||||
this.host.level(id, play.volume ?? 1, this.host.now() + LEAD, Math.max(0, play.fadeIn ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether that one is still to be heard: waiting on its files, playing, or its last notes ringing. */
|
||||
/** Whether that one is still to be heard: waiting on its files, playing, fading, or its last notes ringing. */
|
||||
has(id: number): boolean {
|
||||
return this.current().some((playback) => playback.id === id);
|
||||
}
|
||||
|
||||
/** Those whose name is `name`, every one where it is `''`. */
|
||||
stopNamed(name: string, letRing = false): void {
|
||||
this.stopWhere((playback) => name === '' || playback.name === name, letRing);
|
||||
/** Those that go by `name` (see `goesBy`); with `fade` they fall silent over that many seconds first. */
|
||||
stopNamed(name: string, letRing = false, fade = 0): void {
|
||||
this.stopWhere((playback) => goesBy(playback.name, name), letRing, fade);
|
||||
}
|
||||
|
||||
stopId(id: number, letRing = false): void {
|
||||
this.stopWhere((playback) => playback.id === id, letRing);
|
||||
stopId(id: number, letRing = false, fade = 0): void {
|
||||
this.stopWhere((playback) => playback.id === id, letRing, fade);
|
||||
}
|
||||
|
||||
/** An empty owner is no one, as for a voice: those were started to outlive whatever started them. */
|
||||
stopOwner(owner: string): void {
|
||||
if (owner !== '') this.stopWhere((playback) => playback.owner === owner, false);
|
||||
if (owner !== '') this.stopWhere((playback) => playback.owner === owner, false, 0);
|
||||
}
|
||||
|
||||
/** What is playing, oldest first (test hook). */
|
||||
current(): readonly Readonly<Playback>[] {
|
||||
this.list = this.list.filter((playback) => !playback.scheduler.done);
|
||||
const gone = this.list.filter((playback) => playback.scheduler.done);
|
||||
if (gone.length > 0) this.close(gone, false);
|
||||
return this.list;
|
||||
}
|
||||
|
||||
private stopWhere(going: (playback: Playback) => boolean, letRing: boolean): void {
|
||||
for (const playback of this.list.filter(going)) playback.scheduler.stop(letRing);
|
||||
this.list = this.list.filter((playback) => !going(playback));
|
||||
private stopWhere(going: (playback: Playback) => boolean, letRing: boolean, fade: number): void {
|
||||
const stopping = this.list.filter(going);
|
||||
if (!(fade > 0)) return this.close(stopping, letRing);
|
||||
const now = this.host.now();
|
||||
const end = now + fade;
|
||||
for (const playback of stopping) {
|
||||
if (playback.fading !== null && playback.fading <= end) continue;
|
||||
playback.fading = end;
|
||||
this.host.level(playback.id, 0, now, fade);
|
||||
this.host.after(end, () => {
|
||||
if (this.list.includes(playback) && playback.fading === end) this.close([playback], false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Out of the list before anyone hears of it, so whoever is told finds it gone. */
|
||||
private close(going: readonly Playback[], letRing: boolean): void {
|
||||
if (going.length === 0) return;
|
||||
this.list = this.list.filter((playback) => !going.includes(playback));
|
||||
for (const playback of going) {
|
||||
playback.scheduler.stop(letRing);
|
||||
this.host.closed?.(playback.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { SongAsset } from '../model/Asset';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
|
||||
import type { BusLevel } from './playbacks';
|
||||
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
||||
import { heldMeter, heldTempo, type Meter, secondsOfTicks, ticksPerBar } from './time';
|
||||
|
||||
@@ -126,6 +127,14 @@ export function trackLevel(track: SongTrack, tracks: readonly SongTrack[]): { ga
|
||||
return { gain: heard ? dbToGain(track.volume) : 0, pan: track.pan };
|
||||
}
|
||||
|
||||
/** The level and pan each track's bus is set to. */
|
||||
export function songBuses(song: { id: string; tracks: readonly SongTrack[] }): BusLevel[] {
|
||||
return song.tracks.map((track) => ({
|
||||
name: trackBus(song.id, track.id),
|
||||
...trackLevel(track, song.tracks),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of an item from `tick` on, which is where it is cut by a split or by its left edge: what
|
||||
* it plays starts as far in as the cut is from its start. Null where `tick` is not inside it.
|
||||
|
||||
@@ -67,8 +67,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
stopAll: () => soundLibrary.stopAll(),
|
||||
/** What Stop sound asks for: every voice of that file, its echoes cut or left to ring (#324). */
|
||||
stop: (file: string, letRing: boolean) => soundLibrary.stop(file, letRing),
|
||||
/** The patterns a game plays, with the notes last planned of each. */
|
||||
patterns: () => soundLibrary.patterns(),
|
||||
/** The patterns and songs a game plays, with the notes and clips last planned of each. */
|
||||
playbacks: () => soundLibrary.playbacks(),
|
||||
/** Whether the audio clock is held by the pause of the toolbar or runs. */
|
||||
clockState: () => soundLibrary.clockState(),
|
||||
/** The nodes a stage of a voice becomes, in any context – an offline one renders what it does. */
|
||||
stageNodes: (context: BaseAudioContext, stage: FxStage) => stageNodes(context, stage),
|
||||
/** What drives the two shares of a Mix "Both at center", in any context (unstarted). */
|
||||
|
||||
@@ -2,18 +2,16 @@ import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import {
|
||||
SONG_GRID_KEY,
|
||||
SONG_GRID_OPTIONS,
|
||||
songBuses,
|
||||
songLoop,
|
||||
songMeter,
|
||||
songSequence,
|
||||
songTempo,
|
||||
type SongTrack,
|
||||
trackBus,
|
||||
trackLevel,
|
||||
} from '../../core/music/song';
|
||||
import type { Asset, SongAsset } from '../../core/model/Asset';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import { Button, Dropdown, Label, Menu } from '../../ui';
|
||||
import { type BusLevel, Music } from '../../viewport/music';
|
||||
import { Music } from '../../viewport/music';
|
||||
import { activateAsset } from '../actions';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { claimTransport, releaseTransport } from '../music/transport';
|
||||
@@ -56,7 +54,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
};
|
||||
timeline.onTracksPreview = (tracks) => {
|
||||
const asset = this.asset();
|
||||
if (asset && this.isPlaying) this.music.setBuses(busesOf(asset.id, tracks));
|
||||
if (asset && this.isPlaying) this.music.setBuses(songBuses({ id: asset.id, tracks }));
|
||||
};
|
||||
timeline.onOpenPattern = (patternId) => activateAsset(state, patternId);
|
||||
this.mount(timeline, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]);
|
||||
@@ -87,7 +85,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
claimTransport(this.halt);
|
||||
this.playingId = asset.id;
|
||||
this.told = this.settingsOf(asset);
|
||||
this.music.setBuses(busesOf(asset.id, asset.tracks));
|
||||
this.music.setBuses(songBuses(asset));
|
||||
void this.music.play(songSequence(this.state.project, asset), {
|
||||
tempo: songTempo(asset.props),
|
||||
meter: songMeter(asset.props),
|
||||
@@ -144,7 +142,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
return;
|
||||
}
|
||||
this.music.edit(songSequence(this.state.project, asset));
|
||||
this.music.setBuses(busesOf(asset.id, asset.tracks));
|
||||
this.music.setBuses(songBuses(asset));
|
||||
const settings = this.settingsOf(asset);
|
||||
if (settings === this.told) return;
|
||||
this.told = settings;
|
||||
@@ -174,8 +172,3 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
this.follow(asset);
|
||||
}
|
||||
}
|
||||
|
||||
/** The level and pan each track's bus is set to. */
|
||||
export function busesOf(songId: string, tracks: readonly SongTrack[]): BusLevel[] {
|
||||
return tracks.map((track) => ({ name: trackBus(songId, track.id), ...trackLevel(track, tracks) }));
|
||||
}
|
||||
@@ -36,14 +36,25 @@ export interface AudioSink {
|
||||
timing?: NoteTiming,
|
||||
): number;
|
||||
/**
|
||||
* Plays the notes of a pattern on the audio clock, each struck through `play.strike`. `name` stands
|
||||
* for the pattern as a file does for a sound, and the number it gives back is one no voice has:
|
||||
* `stopVoice` and `playing` take it like a voice's, `stopOwner` and stopping everything reach it.
|
||||
* Returns 0 where nothing is going to play.
|
||||
* A sound as a clip of a song (#334): from `clip.at` on the audio clock, `clip.offset` seconds into
|
||||
* it, round and round for `clip.duration` seconds, on the bus `clip.bus`.
|
||||
*/
|
||||
playClip(file: string, clip: ClipTiming, fx?: FxVoice): number;
|
||||
/**
|
||||
* Plays the notes and clips of a pattern or a song on the audio clock, each struck through
|
||||
* `play.strike`. `name` stands for it as a file does for a sound, and the number it gives back is
|
||||
* one no voice has: `stopVoice` and `playing` take it like a voice's, `stopOwner` and stopping
|
||||
* everything reach it. Returns 0 where nothing is going to play.
|
||||
*/
|
||||
playSequence(name: string, play: SequencePlay, owner: string): number;
|
||||
/** Stops every pattern of that name, `''` every pattern; with `letRing` its notes fall away in their release. */
|
||||
stopSequence(name: string, letRing?: boolean): void;
|
||||
/**
|
||||
* Stops every playing of that name, every one of a kind for `pattern:` or `song:`, every one for
|
||||
* `''`. With `letRing` its notes fall away in their release, with `fade` it falls silent over that
|
||||
* many seconds first.
|
||||
*/
|
||||
stopSequence(name: string, letRing?: boolean, fade?: number): void;
|
||||
/** One playing by its number, falling silent over `fade` seconds; a voice by its number is cut. */
|
||||
stopPlayback(id: number, fade: number): void;
|
||||
/**
|
||||
* The end of a note (#331): its ways that end with the note fade out, those that play through
|
||||
* go on. Nothing happens for a voice that is no note or not there.
|
||||
@@ -90,11 +101,22 @@ export interface NoteTiming {
|
||||
bus?: string;
|
||||
}
|
||||
|
||||
/** When and how a clip of a song plays (#334); see `playClip`. */
|
||||
export interface ClipTiming {
|
||||
at: number;
|
||||
offset: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
bus: string;
|
||||
}
|
||||
|
||||
export const SILENT_AUDIO: AudioSink = {
|
||||
play: () => 0,
|
||||
playNote: () => 0,
|
||||
playClip: () => 0,
|
||||
playSequence: () => 0,
|
||||
stopSequence: () => {},
|
||||
stopPlayback: () => {},
|
||||
release: () => {},
|
||||
tune: () => {},
|
||||
length: () => 0,
|
||||
|
||||
@@ -19,7 +19,10 @@ import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||
import { instrumentBuildFor, instrumentSourcesOf } from '../../core/instrument/compile';
|
||||
import { VELOCITY_PARAM } from '../../core/instrument/nodes';
|
||||
import { heldNote } from '../../core/instrument/notes';
|
||||
import { clipFx, sequenceNeeds } from '../../core/music/needs';
|
||||
import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern';
|
||||
import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler';
|
||||
import { songBuses, songLength, songLoop, songSequence, songTempo } from '../../core/music/song';
|
||||
import { DRY_VOICE, type FxParams } from '../../core/soundfx/nodes';
|
||||
import type { Entity } from '../Entity';
|
||||
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
||||
@@ -176,8 +179,11 @@ export function behaviorContext(
|
||||
}),
|
||||
playPattern: (patternId, volume, loop, detached) =>
|
||||
playPattern(run, patternId, volume, loop, detached ? '' : entity.id),
|
||||
stopPattern: (patternId, letRing) =>
|
||||
run.audio.stopSequence(patternId && patternVoiceName(patternId), letRing),
|
||||
stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing),
|
||||
playSong: (songId, volume, fadeIn, loop, detached) =>
|
||||
playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id),
|
||||
stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)),
|
||||
stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)),
|
||||
soundLength: (assetId) => {
|
||||
const file = soundFileOf(project, assetId);
|
||||
return file ? run.audio.length(file) : 0;
|
||||
@@ -311,35 +317,106 @@ function playPattern(
|
||||
const sequence = patternSequence(asset);
|
||||
const instrument = findAssetOf(project, patternInstrument(asset.props), 'instrument');
|
||||
if (!instrument || sequence.notes.length === 0) return 0;
|
||||
const sources = instrumentSourcesOf(project);
|
||||
const pitches = [...new Set(sequence.notes.map((n) => n.note))];
|
||||
const level = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
|
||||
const level = heldLevel(volume);
|
||||
const length = patternLength(asset);
|
||||
return audio.playSequence(
|
||||
patternVoiceName(patternId),
|
||||
{
|
||||
sequence,
|
||||
tempo: patternTempo(asset.props),
|
||||
length: patternLength(asset),
|
||||
loop,
|
||||
layers: pitches.flatMap((note) => instrumentBuildFor(instrument, note, sources).layers),
|
||||
strike: ({ instrument: id, note, velocity, duration, at, tempo }) =>
|
||||
playNote(
|
||||
{ project, audio },
|
||||
id,
|
||||
{ note, velocity, volume: level, duration },
|
||||
owner,
|
||||
{},
|
||||
{ at, tempo },
|
||||
),
|
||||
length,
|
||||
loop: loop ? { start: 0, end: length } : null,
|
||||
needs: sequenceNeeds(project, sequence),
|
||||
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
|
||||
},
|
||||
owner,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A song played by a game (#336): its tracks at their level, pan, mute and solo, at its tempo. With
|
||||
* `loop` it plays from its start and then runs round the loop of its ruler, else once to its end.
|
||||
* `volume` and `fadeIn` are the level of the whole playing. Returns the number of the playing, 0
|
||||
* where there is no song or nothing in it to play.
|
||||
*/
|
||||
function playSong(
|
||||
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
|
||||
songId: string,
|
||||
volume: number,
|
||||
fadeIn: number,
|
||||
loop: boolean,
|
||||
owner: string,
|
||||
): number {
|
||||
const song = findAssetOf(project, songId, 'song');
|
||||
if (!song) return 0;
|
||||
const sequence = songSequence(project, song);
|
||||
return audio.playSequence(
|
||||
songVoiceName(songId),
|
||||
{
|
||||
sequence,
|
||||
tempo: songTempo(song.props),
|
||||
length: songLength(song),
|
||||
loop: loop ? songLoop(song) : null,
|
||||
needs: sequenceNeeds(project, sequence),
|
||||
volume: heldLevel(volume),
|
||||
fadeIn: heldFade(fadeIn),
|
||||
buses: songBuses(song),
|
||||
strike: (note) => strikeNote({ project, audio }, note, owner),
|
||||
strikeClip: (clip) => playClip({ project, audio }, clip),
|
||||
},
|
||||
owner,
|
||||
);
|
||||
}
|
||||
|
||||
/** A note the scheduler planned (#332), struck through its instrument at its volume, on its bus. */
|
||||
export function strikeNote(
|
||||
sources: Pick<ContextSources, 'project' | 'audio'>,
|
||||
note: ScheduledNote,
|
||||
owner: string,
|
||||
): number {
|
||||
const struck = {
|
||||
note: note.note,
|
||||
velocity: note.velocity,
|
||||
volume: note.volume ?? 1,
|
||||
duration: note.duration,
|
||||
};
|
||||
const timing = { at: note.at, tempo: note.tempo, ...(note.bus && { bus: note.bus }) };
|
||||
return playNote(sources, note.instrument, struck, owner, {}, timing);
|
||||
}
|
||||
|
||||
/** A clip of a song (#334): its sound at its own volume times the item's, through its sound FX. */
|
||||
export function playClip(
|
||||
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
|
||||
clip: ScheduledClip,
|
||||
): number {
|
||||
const asset = findAssetOf(project, clip.sound, 'sound');
|
||||
if (!asset?.file) return 0;
|
||||
const volume = Math.max(0, Math.min(1, volumeOf(resolveAssetProps(project, asset)) * clip.volume));
|
||||
return audio.playClip(
|
||||
asset.file,
|
||||
{ at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus },
|
||||
clipFx(project, clip.fx, clip.tempo),
|
||||
);
|
||||
}
|
||||
|
||||
function heldLevel(volume: number): number {
|
||||
return Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
|
||||
}
|
||||
|
||||
function heldFade(seconds: number): number {
|
||||
return Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
|
||||
}
|
||||
|
||||
/** `pattern:` alone stands for every pattern (see `goesBy`). */
|
||||
export function patternVoiceName(patternId: string): string {
|
||||
return `pattern:${patternId}`;
|
||||
}
|
||||
|
||||
/** `song:` alone stands for every song. */
|
||||
export function songVoiceName(songId: string): string {
|
||||
return `song:${songId}`;
|
||||
}
|
||||
|
||||
/** Where a ray of that length ends; where it has no direction, where it starts. */
|
||||
function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 {
|
||||
const d = Math.hypot(direction.x, direction.y);
|
||||
|
||||
+139
-36
@@ -17,10 +17,10 @@ import {
|
||||
} from '../core/soundfx/nodes';
|
||||
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
|
||||
import type { EaseShare } from '../core/model/animation';
|
||||
import { Playbacks, type SequencePlay } from '../core/music/playbacks';
|
||||
import type { ScheduledNote } from '../core/music/Scheduler';
|
||||
import { type BusLevel, Playbacks, type SequencePlay } from '../core/music/playbacks';
|
||||
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
|
||||
import { DEFAULT_TEMPO } from '../core/music/time';
|
||||
import type { AudioSink, NoteTiming } from '../runtime/systems/Audio';
|
||||
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
|
||||
import type { PitchOptions } from './pitchWorklet';
|
||||
import pitchWorklet from './pitchWorklet.ts?worker&url';
|
||||
import { wake } from './wake';
|
||||
@@ -96,6 +96,8 @@ interface Voice {
|
||||
file: string;
|
||||
/** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */
|
||||
owner: string;
|
||||
/** The bus it is heard on, `''` straight out. */
|
||||
bus: string;
|
||||
/** When it started on the audio clock, what its Envelopes count from. */
|
||||
at: number;
|
||||
sources: Source[];
|
||||
@@ -184,6 +186,7 @@ interface Start {
|
||||
*/
|
||||
interface Pending {
|
||||
id: number;
|
||||
bus: string;
|
||||
fx: Layered;
|
||||
timed: { params: FxParams; time: number; ease: EaseShare }[];
|
||||
/** Stopped by its number before it started. */
|
||||
@@ -247,17 +250,27 @@ export class SoundLibrary implements AudioSink {
|
||||
/** The tracks of the songs played so far (#334), by name. */
|
||||
private readonly buses = new Map<string, Bus>();
|
||||
private readonly peaked = new Map<string, Float32Array>();
|
||||
/** The patterns a game plays, each on a scheduler of its own. */
|
||||
/**
|
||||
* The patterns and songs a game plays, each on a scheduler of its own. What it asks after are its
|
||||
* own voices – asking `playing`, which counts the playings too, would ask the list it is in.
|
||||
*/
|
||||
private readonly sequences = new Playbacks({
|
||||
now: () => this.time(),
|
||||
latency: () => this.latency(),
|
||||
playing: (id) => this.playing(id),
|
||||
playing: (id) => this.sounding(id),
|
||||
stop: (voices, letRing) => {
|
||||
for (const id of voices) this.stopVoice(id, letRing);
|
||||
},
|
||||
wake,
|
||||
prepare: (layers) => this.prepare(layers),
|
||||
prepare: ({ layers, files }) => this.prepare(layers, files),
|
||||
level: (id, gain, at, seconds) => this.level(id, gain, at, seconds),
|
||||
after: (at, then) => void this.after(at, then),
|
||||
closed: () => this.sweep(),
|
||||
});
|
||||
/** The playings whose buses are still there: a master each, and the tracks of a song into it. */
|
||||
private readonly groups = new Set<number>();
|
||||
/** Held by the pause of the toolbar: nothing that reads the clock may wake it then. */
|
||||
private held = false;
|
||||
|
||||
/** Where the files are served from. Set once at start-up, before anything plays. */
|
||||
configure(base: string): void {
|
||||
@@ -345,39 +358,119 @@ export class SoundLibrary implements AudioSink {
|
||||
return name === '' ? this.audio().destination : this.bus(name).gain;
|
||||
}
|
||||
|
||||
private bus(name: string): Bus {
|
||||
private bus(name: string, out?: AudioNode): Bus {
|
||||
const known = this.buses.get(name);
|
||||
if (known) return known;
|
||||
const context = this.audio();
|
||||
const made = { gain: context.createGain(), pan: context.createStereoPanner(), set: { gain: 1, pan: 0 } };
|
||||
made.gain.connect(made.pan).connect(context.destination);
|
||||
made.gain.connect(made.pan).connect(out ?? context.destination);
|
||||
this.buses.set(name, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
/** A sequence without notes plays nothing, and is given no number. */
|
||||
/**
|
||||
* A sequence without notes or clips plays nothing, and is given no number. Each playing is heard
|
||||
* through a master of its own, silent until it starts (#336): its level and its fades. The tracks
|
||||
* of a song go into it, each set as it was given; a note or clip naming no track goes straight in.
|
||||
*/
|
||||
playSequence(name: string, play: SequencePlay, owner = ''): number {
|
||||
if (play.sequence.notes.length === 0) return 0;
|
||||
if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0;
|
||||
const id = this.nextId++;
|
||||
this.sequences.start(id, name, play, owner);
|
||||
const buses = play.buses ?? [];
|
||||
this.open(id, buses);
|
||||
const tracks = new Set(buses.map((bus) => bus.name));
|
||||
const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : '');
|
||||
const { strike, strikeClip } = play;
|
||||
const routed: SequencePlay = {
|
||||
...play,
|
||||
strike: (note) => strike({ ...note, bus: into(note.bus) }),
|
||||
...(strikeClip && {
|
||||
strikeClip: (clip: ScheduledClip) => strikeClip({ ...clip, bus: into(clip.bus) }),
|
||||
}),
|
||||
};
|
||||
this.sequences.start(id, name, routed, owner);
|
||||
return id;
|
||||
}
|
||||
|
||||
stopSequence(name: string, letRing = false): void {
|
||||
this.sequences.stopNamed(name, letRing);
|
||||
stopSequence(name: string, letRing = false, fade = 0): void {
|
||||
this.sequences.stopNamed(name, letRing, fade);
|
||||
}
|
||||
|
||||
/** The patterns playing, oldest first, with the notes last planned of each (test hook). */
|
||||
patterns(): { id: number; name: string; owner: string; playing: boolean; planned: ScheduledNote[] }[] {
|
||||
return this.sequences.current().map(({ id, name, owner, scheduler, planned }) => ({
|
||||
stopPlayback(id: number, fade: number): void {
|
||||
if (this.sequences.has(id)) this.sequences.stopId(id, false, fade);
|
||||
else this.stopVoice(id);
|
||||
}
|
||||
|
||||
/** The patterns and songs playing, oldest first, with the notes and clips last planned of each (test hook). */
|
||||
playbacks(): {
|
||||
id: number;
|
||||
name: string;
|
||||
owner: string;
|
||||
playing: boolean;
|
||||
fading: number | null;
|
||||
planned: ScheduledNote[];
|
||||
clips: ScheduledClip[];
|
||||
}[] {
|
||||
return this.sequences.current().map(({ id, name, owner, scheduler, fading, planned, clips }) => ({
|
||||
id,
|
||||
name,
|
||||
owner,
|
||||
playing: scheduler.playing,
|
||||
fading,
|
||||
planned: [...planned],
|
||||
clips: [...clips],
|
||||
}));
|
||||
}
|
||||
|
||||
private open(id: number, tracks: readonly BusLevel[]): void {
|
||||
const master = this.bus(groupBus(id, ''));
|
||||
master.gain.gain.value = 0;
|
||||
master.set = { gain: 0, pan: 0 };
|
||||
for (const { name, gain, pan } of tracks) {
|
||||
const track = this.bus(groupBus(id, name), master.gain);
|
||||
track.gain.gain.value = Math.max(0, gain);
|
||||
track.pan.pan.value = Math.max(-1, Math.min(1, pan));
|
||||
track.set = { gain: track.gain.gain.value, pan: track.pan.pan.value };
|
||||
}
|
||||
this.groups.add(id);
|
||||
}
|
||||
|
||||
/** The master of a playing, from where it stands at `at` to `gain`, there `seconds` later. */
|
||||
private level(id: number, gain: number, at: number, seconds: number): void {
|
||||
const master = this.buses.get(groupBus(id, ''));
|
||||
if (!master) return;
|
||||
const param = master.gain.gain;
|
||||
const from = param.value;
|
||||
param.cancelScheduledValues(at);
|
||||
param.setValueAtTime(seconds > 0 ? from : gain, at);
|
||||
if (seconds > 0) param.linearRampToValueAtTime(gain, at + seconds);
|
||||
master.set = { ...master.set, gain };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets go of the buses of every playing that is gone and has nothing left on them – a fade later,
|
||||
* so a voice cut a moment ago is not cut off mid-fade.
|
||||
*/
|
||||
private sweep(): void {
|
||||
if (this.groups.size === 0) return;
|
||||
for (const id of [...this.groups]) {
|
||||
if (!this.groups.has(id) || this.sequences.has(id)) continue;
|
||||
const on = (bus: string) => bus === groupBus(id, '') || bus.startsWith(`${groupBus(id, '')}:`);
|
||||
if (this.voices.some((voice) => on(voice.bus))) continue;
|
||||
if (this.pending.some((waiting) => !waiting.stopped && on(waiting.bus))) continue;
|
||||
this.groups.delete(id);
|
||||
const names = [...this.buses.keys()].filter(on);
|
||||
this.after(this.audio().currentTime + FADE, () => {
|
||||
for (const name of names) {
|
||||
const bus = this.buses.get(name);
|
||||
bus?.gain.disconnect();
|
||||
bus?.pan.disconnect();
|
||||
this.buses.delete(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a note (#331): the ways that end with it fade out, a looping one that does not leaves
|
||||
* its loop, the others play on, and the voice goes once they have all ended – or rings on first
|
||||
@@ -449,6 +542,7 @@ export class SoundLibrary implements AudioSink {
|
||||
const askedAt = this.cancelsFor(start.file, start.owner);
|
||||
const waiting: Pending = {
|
||||
id,
|
||||
bus: start.bus ?? '',
|
||||
fx: { ...start.fx, params: { ...start.fx.params } },
|
||||
timed: [],
|
||||
stopped: false,
|
||||
@@ -500,13 +594,13 @@ export class SoundLibrary implements AudioSink {
|
||||
return voice ? this.tailOf(voice.fx) : 0;
|
||||
}
|
||||
|
||||
/** Whether a voice is still heard – or waiting on its bytes to be. A pattern counts as one. */
|
||||
/** Whether a voice is still heard – or waiting on its bytes to be. A pattern or song counts as one. */
|
||||
playing(id: number): boolean {
|
||||
return (
|
||||
this.voices.some((voice) => voice.id === id) ||
|
||||
this.pending.some((other) => other.id === id) ||
|
||||
this.sequences.has(id)
|
||||
);
|
||||
return this.sounding(id) || this.sequences.has(id);
|
||||
}
|
||||
|
||||
private sounding(id: number): boolean {
|
||||
return this.voices.some((voice) => voice.id === id) || this.pending.some((other) => other.id === id);
|
||||
}
|
||||
|
||||
/** `letRing` stops the sound itself and lets its echoes fade as they would (#324). */
|
||||
@@ -544,8 +638,12 @@ export class SoundLibrary implements AudioSink {
|
||||
this.stop('');
|
||||
}
|
||||
|
||||
/** A pause holds the sounds where they are; resuming carries them on mid-note. */
|
||||
/**
|
||||
* A pause holds the sounds where they are; resuming carries them on mid-note. Until then nothing
|
||||
* wakes the clock – a pattern or song reading it every few milliseconds would undo the pause.
|
||||
*/
|
||||
setSuspended(suspended: boolean): void {
|
||||
this.held = suspended;
|
||||
const context = this.context;
|
||||
if (!context) return;
|
||||
if (suspended) void context.suspend();
|
||||
@@ -596,10 +694,15 @@ export class SoundLibrary implements AudioSink {
|
||||
return this.context?.currentTime ?? 0;
|
||||
}
|
||||
|
||||
/** `running`, `suspended` by a pause, or `none` before there is a context (test hook). */
|
||||
clockState(): string {
|
||||
return this.context?.state ?? 'none';
|
||||
}
|
||||
|
||||
/** Seconds on the audio clock, opening the context where there is none yet: what the scheduler plans by (#332). */
|
||||
time(): number {
|
||||
const context = this.audio();
|
||||
if (context.state === 'suspended') void context.resume();
|
||||
if (context.state === 'suspended' && !this.held) void context.resume();
|
||||
return context.currentTime;
|
||||
}
|
||||
|
||||
@@ -617,9 +720,11 @@ export class SoundLibrary implements AudioSink {
|
||||
* Loads every file and the worklet these notes need, so the first note the scheduler plans is
|
||||
* not late waiting on its bytes (#332).
|
||||
*/
|
||||
async prepare(layers: readonly FxPlan[]): Promise<void> {
|
||||
async prepare(layers: readonly FxPlan[], files: readonly string[] = []): Promise<void> {
|
||||
await Promise.all([
|
||||
...[...new Set(layers.flatMap(filesOf))].filter((file) => file !== '').map((file) => this.load(file)),
|
||||
...[...new Set([...layers.flatMap(filesOf), ...files])]
|
||||
.filter((file) => file !== '')
|
||||
.map((file) => this.load(file)),
|
||||
layers.some(pitches) ? this.pitchWorklet() : null,
|
||||
]);
|
||||
}
|
||||
@@ -772,6 +877,7 @@ export class SoundLibrary implements AudioSink {
|
||||
voice.quiet?.();
|
||||
voice.quiet = null;
|
||||
this.voices = this.voices.filter((other) => other !== voice);
|
||||
if (voice.bus !== '') this.sweep();
|
||||
}
|
||||
|
||||
/** The longest tail of its layers: they are heard side by side. */
|
||||
@@ -809,7 +915,7 @@ export class SoundLibrary implements AudioSink {
|
||||
const context = this.audio();
|
||||
// A context starts suspended where the page has not been interacted with; without this the
|
||||
// voice would run silently and end as if it had played.
|
||||
if (context.state === 'suspended') void context.resume();
|
||||
if (context.state === 'suspended' && !this.held) void context.resume();
|
||||
const when = Math.max(at, context.currentTime);
|
||||
// A clip whose bytes came too late is over already, or starts as far in as it would be by now.
|
||||
if (clip && clip.end <= when) return;
|
||||
@@ -850,6 +956,7 @@ export class SoundLibrary implements AudioSink {
|
||||
id,
|
||||
file,
|
||||
owner,
|
||||
bus,
|
||||
at: when,
|
||||
sources,
|
||||
ending: new Set(),
|
||||
@@ -1082,6 +1189,11 @@ export class SoundLibrary implements AudioSink {
|
||||
}
|
||||
}
|
||||
|
||||
/** The bus of a game's playing (#336): its master for `''`, else the track of that name inside it. */
|
||||
function groupBus(id: number, track: string): string {
|
||||
return track === '' ? `play:${id}` : `play:${id}:${track}`;
|
||||
}
|
||||
|
||||
/** A track of a song (#334): its level, then its pan, then out; `set` is what they glide to. */
|
||||
interface Bus {
|
||||
gain: GainNode;
|
||||
@@ -1089,15 +1201,6 @@ interface Bus {
|
||||
set: { gain: number; pan: number };
|
||||
}
|
||||
|
||||
/** When and how a clip of a song plays (#334); see `playClip`. */
|
||||
export interface ClipTiming {
|
||||
at: number;
|
||||
offset: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
bus: string;
|
||||
}
|
||||
|
||||
/** What `stats` says about the last voice: enough for a check to tell which effects it went through. */
|
||||
interface LastVoice {
|
||||
file: string;
|
||||
|
||||
+8
-76
@@ -4,14 +4,11 @@ import {
|
||||
Scheduler,
|
||||
type SchedulerOptions,
|
||||
} from '../core/music/Scheduler';
|
||||
import { sequenceNeeds } from '../core/music/needs';
|
||||
import type { BusLevel } from '../core/music/playbacks';
|
||||
import type { Sequence } from '../core/music/sequence';
|
||||
import { instrumentBuildFor, instrumentSourcesOf } from '../core/instrument/compile';
|
||||
import { findAssetOf, type Project } from '../core/model/Project';
|
||||
import { resolveAssetProps } from '../core/properties/resolve';
|
||||
import { volumeOf } from '../core/properties/sounds';
|
||||
import { soundFxBuildFor } from '../core/soundfx/compile';
|
||||
import { DRY_VOICE, type FxPlan, type FxVoice } from '../core/soundfx/nodes';
|
||||
import { playNote } from '../runtime/systems/behaviorContext';
|
||||
import type { Project } from '../core/model/Project';
|
||||
import { playClip, strikeNote } from '../runtime/systems/behaviorContext';
|
||||
import { soundLibrary } from './SoundLibrary';
|
||||
import { wake } from './wake';
|
||||
|
||||
@@ -27,13 +24,6 @@ export interface PlannedClip extends ScheduledClip {
|
||||
planned: number;
|
||||
}
|
||||
|
||||
/** What a track of a song is set to: its bus, its level and its pan (#334). */
|
||||
export interface BusLevel {
|
||||
name: string;
|
||||
gain: number;
|
||||
pan: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scheduler on the sound library's clock (#332), its notes played by the instruments of the
|
||||
* project: what the Pattern tab (#333) and the Song tab (#334) play, and what the check scripts drive
|
||||
@@ -99,80 +89,22 @@ export class Music {
|
||||
}
|
||||
|
||||
private playOne(note: ScheduledNote): number {
|
||||
const struck = {
|
||||
note: note.note,
|
||||
velocity: note.velocity,
|
||||
volume: note.volume ?? 1,
|
||||
duration: note.duration,
|
||||
};
|
||||
const voice = playNote(
|
||||
{ project: this.project(), audio: soundLibrary },
|
||||
note.instrument,
|
||||
struck,
|
||||
'',
|
||||
{},
|
||||
{
|
||||
at: note.at,
|
||||
tempo: note.tempo,
|
||||
...(note.bus && { bus: note.bus }),
|
||||
},
|
||||
);
|
||||
const voice = strikeNote({ project: this.project(), audio: soundLibrary }, note, '');
|
||||
this.planned.push({ ...note, voice, planned: soundLibrary.clock() });
|
||||
if (this.planned.length > 256) this.planned.shift();
|
||||
return voice;
|
||||
}
|
||||
|
||||
/** A sound at its own volume times the item's, through its sound FX where it has one. */
|
||||
private playClip(clip: ScheduledClip): number {
|
||||
const project = this.project();
|
||||
const asset = findAssetOf(project, clip.sound, 'sound');
|
||||
const volume = asset
|
||||
? Math.max(0, Math.min(1, volumeOf(resolveAssetProps(project, asset)) * clip.volume))
|
||||
: 0;
|
||||
const voice = asset?.file
|
||||
? soundLibrary.playClip(
|
||||
asset.file,
|
||||
{ at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus },
|
||||
this.fxOf(clip.fx, clip.tempo),
|
||||
)
|
||||
: 0;
|
||||
const voice = playClip({ project: this.project(), audio: soundLibrary }, clip);
|
||||
this.clips.push({ ...clip, voice, planned: soundLibrary.clock() });
|
||||
if (this.clips.length > 256) this.clips.shift();
|
||||
return voice;
|
||||
}
|
||||
|
||||
/** A sound FX that does not compile plays the sound dry, as `Play sound` does. */
|
||||
private fxOf(fxId: string, tempo: number): FxVoice {
|
||||
const fx = fxId ? findAssetOf(this.project(), fxId, 'soundfx') : undefined;
|
||||
const plan = fx && soundFxBuildFor(fx).compiled;
|
||||
return plan ? { plan, params: {}, tempo } : DRY_VOICE;
|
||||
}
|
||||
|
||||
/** Every file and worklet the sequence needs, so they are there before the first note is due. */
|
||||
private async prepare(sequence: Sequence): Promise<void> {
|
||||
const project = this.project();
|
||||
const plans: FxPlan[] = [...this.layersOf(sequence)];
|
||||
const files = new Set<string>();
|
||||
for (const clip of sequence.clips ?? []) {
|
||||
const file = findAssetOf(project, clip.sound, 'sound')?.file;
|
||||
if (file) files.add(file);
|
||||
const fx = this.fxOf(clip.fx, 120);
|
||||
if (fx !== DRY_VOICE) plans.push(fx.plan);
|
||||
}
|
||||
await Promise.all([soundLibrary.prepare(plans), ...[...files].map((file) => soundLibrary.load(file))]);
|
||||
}
|
||||
|
||||
/** The layers of every note the sequence has. */
|
||||
private layersOf(sequence: Sequence): FxPlan[] {
|
||||
const project = this.project();
|
||||
const sources = instrumentSourcesOf(project);
|
||||
const seen = new Set<string>();
|
||||
return sequence.notes.flatMap(({ instrument, note }) => {
|
||||
const key = `${instrument}\u0000${note}`;
|
||||
const asset = findAssetOf(project, instrument, 'instrument');
|
||||
if (seen.has(key) || !asset) return [];
|
||||
seen.add(key);
|
||||
return instrumentBuildFor(asset, note, sources).layers;
|
||||
});
|
||||
const { layers, files } = sequenceNeeds(this.project(), sequence);
|
||||
await soundLibrary.prepare(layers, files);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ function start(f: Fixture, audio?: AudioSink) {
|
||||
playNote: () => 0,
|
||||
playSequence: () => 0,
|
||||
stopSequence: () => {},
|
||||
playClip: () => 0,
|
||||
stopPlayback: () => {},
|
||||
release: () => {},
|
||||
tune: () => {},
|
||||
length: () => 0,
|
||||
|
||||
@@ -2133,6 +2133,57 @@ const CASES: Record<string, Case[]> = {
|
||||
after: (ctx) => expect(ctx.silenced).toEqual([]),
|
||||
},
|
||||
],
|
||||
PlaySong: [
|
||||
{
|
||||
name: 'no song is a no-op that still continues the flow',
|
||||
inputs: { song: '' },
|
||||
next: 'exec',
|
||||
outputs: { playback: 0 },
|
||||
after: (ctx) => expect(ctx.songs).toEqual([]),
|
||||
},
|
||||
{
|
||||
name: 'hands the song on with its volume and fade in, looping and owned unless asked otherwise',
|
||||
inputs: { song: 'song-1', volume: 0.5, fadeIn: 2, loop: true },
|
||||
next: 'exec',
|
||||
outputs: { playback: 1 },
|
||||
after: (ctx) =>
|
||||
expect(ctx.songs).toEqual([
|
||||
{ songId: 'song-1', volume: 0.5, fadeIn: 2, loop: true, detached: false },
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'plays once and detached when asked to',
|
||||
inputs: { song: 'song-1', volume: 1, fadeIn: 0, loop: false, detached: true },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.songs[0]).toMatchObject({ loop: false, detached: true }),
|
||||
},
|
||||
],
|
||||
StopSong: [
|
||||
{
|
||||
name: 'stops the song it names over its fade out',
|
||||
inputs: { song: 'song-1', fadeOut: 1.5 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.silenced).toEqual(['song song-1 (fade 1.5)']),
|
||||
},
|
||||
{
|
||||
name: 'naming none stops every song',
|
||||
inputs: { song: '', fadeOut: 0 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.silenced).toEqual(['song (fade 0)']),
|
||||
},
|
||||
{
|
||||
name: 'with a playback connected stops only that one, whatever song is named',
|
||||
inputs: { song: 'song-1', playback: 4, fadeOut: 2 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.silenced).toEqual(['playback 4 (fade 2)']),
|
||||
},
|
||||
{
|
||||
name: 'a connected playback of 0 – a Play song that played nothing – stops nothing',
|
||||
inputs: { song: 'song-1', playback: 0 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.silenced).toEqual([]),
|
||||
},
|
||||
],
|
||||
SoundFxParameters: [
|
||||
{
|
||||
name: 'bundles its picked parameters as numbers',
|
||||
|
||||
@@ -27,6 +27,9 @@ function fakeHost() {
|
||||
stopped: [] as { voices: number[]; letRing: boolean }[],
|
||||
pumps: new Set<() => void>(),
|
||||
loading: [] as (() => void)[],
|
||||
levels: [] as { id: number; gain: number; at: number; seconds: number }[],
|
||||
timers: [] as { at: number; then: () => void }[],
|
||||
closed: [] as number[],
|
||||
};
|
||||
const playbacks = new Playbacks({
|
||||
now: () => host.time,
|
||||
@@ -41,12 +44,19 @@ function fakeHost() {
|
||||
return () => host.pumps.delete(pump);
|
||||
},
|
||||
prepare: () => new Promise<void>((resolve) => host.loading.push(resolve)),
|
||||
level: (id, gain, at, seconds) => host.levels.push({ id, gain, at, seconds }),
|
||||
after: (at, then) => host.timers.push({ at, then }),
|
||||
closed: (id) => host.closed.push(id),
|
||||
});
|
||||
const run = (seconds: number) => {
|
||||
const end = host.time + seconds;
|
||||
while (host.time + INTERVAL <= end + 1e-9) {
|
||||
host.time += INTERVAL;
|
||||
for (const pump of [...host.pumps]) pump();
|
||||
for (const timer of host.timers.filter((t) => t.at <= host.time + 1e-9)) {
|
||||
host.timers.splice(host.timers.indexOf(timer), 1);
|
||||
timer.then();
|
||||
}
|
||||
}
|
||||
};
|
||||
const loaded = async () => {
|
||||
@@ -71,8 +81,8 @@ function bar(struck: ScheduledNote[], loop: boolean, sounding = new Set<number>(
|
||||
},
|
||||
tempo: 120,
|
||||
length: 4 * Q,
|
||||
loop,
|
||||
layers: [],
|
||||
loop: loop ? { start: 0, end: 4 * Q } : null,
|
||||
needs: { layers: [], files: [] },
|
||||
strike: (note) => {
|
||||
struck.push(note);
|
||||
sounding.add(next);
|
||||
@@ -210,12 +220,12 @@ describe('Play pattern', () => {
|
||||
const [asked] = audio.sequences;
|
||||
expect(asked?.name).toBe(`pattern:${f.patternId}`);
|
||||
expect(asked?.owner).toBe(logic.id);
|
||||
expect(asked?.play).toMatchObject({ tempo: 90, length: 2 * 4 * Q, loop: true });
|
||||
expect(asked?.play).toMatchObject({ tempo: 90, length: 2 * 4 * Q, loop: { start: 0, end: 2 * 4 * Q } });
|
||||
expect(asked?.play.sequence.notes.map((n) => [n.tick, n.note, n.instrument])).toEqual([
|
||||
[0, 60, f.lead.id],
|
||||
[Q, 64, f.lead.id],
|
||||
]);
|
||||
expect(asked?.play.layers.length).toBe(2);
|
||||
expect(asked?.play.needs.layers.length).toBe(2);
|
||||
});
|
||||
|
||||
it('strikes each note through the instrument at the node’s volume, owned like the playing', () => {
|
||||
@@ -223,7 +233,7 @@ describe('Play pattern', () => {
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
const [asked] = audio.sequences;
|
||||
expect([asked?.owner, asked?.play.loop]).toEqual(['', false]);
|
||||
expect([asked?.owner, asked?.play.loop]).toEqual(['', null]);
|
||||
asked!.play.strike({ at: 3, duration: 0.25, note: 60, velocity: 0.5, instrument: f.lead.id, tempo: 90 });
|
||||
expect(audio.notes).toEqual([
|
||||
{ name: `instrument:${f.lead.id}`, volume: 0.25, owner: '', duration: 0.25, at: 3, tempo: 90 },
|
||||
@@ -250,7 +260,7 @@ describe('Stop pattern', () => {
|
||||
f.edit.connect(f.play, 'playback', byNumber, 'playback');
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.stoppedSequences).toEqual([`pattern:${f.patternId} (let ring)`, '']);
|
||||
expect(audio.stoppedSequences).toEqual([`pattern:${f.patternId} (let ring)`, 'pattern:']);
|
||||
expect(audio.stoppedVoices).toEqual([1001]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* A game plays a song through Play song and ends it through Stop song (#336): from its start, then
|
||||
* round the loop of its ruler, its tracks on buses of their own, the whole of it faded in and out.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
CreateSoundCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
|
||||
import { SetSongCommand, SetSongLoopCommand } from '../../src/core/commands/songCommands';
|
||||
import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset';
|
||||
import { goesBy, Playbacks, type SequencePlay } from '../../src/core/music/playbacks';
|
||||
import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler';
|
||||
import { newItem, trackBus } from '../../src/core/music/song';
|
||||
import { type AudioSink, type ClipTiming, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
|
||||
|
||||
const FILE = `${'b2'.repeat(32)}.wav`;
|
||||
const Q = 480;
|
||||
const BAR = 4 * Q;
|
||||
|
||||
/** An audio clock moved by hand, its timers run as it passes them. */
|
||||
function fakeHost() {
|
||||
const host = {
|
||||
time: 0,
|
||||
sounding: new Set<number>(),
|
||||
pumps: new Set<() => void>(),
|
||||
loading: [] as (() => void)[],
|
||||
levels: [] as { id: number; gain: number; at: number; seconds: number }[],
|
||||
timers: [] as { at: number; then: () => void }[],
|
||||
closed: [] as number[],
|
||||
};
|
||||
const playbacks = new Playbacks({
|
||||
now: () => host.time,
|
||||
latency: () => 0,
|
||||
playing: (voice) => host.sounding.has(voice),
|
||||
stop: (voices) => {
|
||||
for (const voice of voices) host.sounding.delete(voice);
|
||||
},
|
||||
wake: (pump) => {
|
||||
host.pumps.add(pump);
|
||||
return () => host.pumps.delete(pump);
|
||||
},
|
||||
prepare: () => new Promise<void>((resolve) => host.loading.push(resolve)),
|
||||
level: (id, gain, at, seconds) => host.levels.push({ id, gain, at, seconds }),
|
||||
after: (at, then) => host.timers.push({ at, then }),
|
||||
closed: (id) => host.closed.push(id),
|
||||
});
|
||||
const run = (seconds: number) => {
|
||||
const end = host.time + seconds;
|
||||
while (host.time + INTERVAL <= end + 1e-9) {
|
||||
host.time += INTERVAL;
|
||||
for (const pump of [...host.pumps]) pump();
|
||||
for (const timer of host.timers.filter((t) => t.at <= host.time + 1e-9)) {
|
||||
host.timers.splice(host.timers.indexOf(timer), 1);
|
||||
timer.then();
|
||||
}
|
||||
}
|
||||
};
|
||||
const loaded = async () => {
|
||||
for (const resolve of host.loading.splice(0)) resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
return { host, playbacks, run, loaded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Two bars at 120 BPM, two seconds each: a note on the first beat of each and a clip on the second
|
||||
* bar. Looped, the second bar is its loop.
|
||||
*/
|
||||
function song(struck: (ScheduledNote | ScheduledClip)[], options: Partial<SequencePlay> = {}): SequencePlay {
|
||||
let next = 100;
|
||||
const voice = () => next++;
|
||||
return {
|
||||
sequence: {
|
||||
notes: [0, BAR].map((tick, i) => ({ tick, length: Q, note: 60 + i, velocity: 1, instrument: 'piano' })),
|
||||
clips: [{ tick: BAR, length: Q, sound: 'drum', fx: '', offset: 0, volume: 1, bus: 'track' }],
|
||||
},
|
||||
tempo: 120,
|
||||
length: 2 * BAR,
|
||||
loop: { start: BAR, end: 2 * BAR },
|
||||
needs: { layers: [], files: [] },
|
||||
strike: (note) => (struck.push(note), voice()),
|
||||
strikeClip: (clip) => (struck.push(clip), voice()),
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
const what = (struck: (ScheduledNote | ScheduledClip)[]) =>
|
||||
struck.map((s) => ('note' in s ? `note ${s.note}` : `clip ${s.sound}`));
|
||||
|
||||
describe('the songs a game plays', () => {
|
||||
it('plays the intro once, then runs round the loop, clips with the notes', async () => {
|
||||
const { playbacks, run, loaded } = fakeHost();
|
||||
const struck: (ScheduledNote | ScheduledClip)[] = [];
|
||||
playbacks.start(5, 'song:a', song(struck), '');
|
||||
await loaded();
|
||||
run(9);
|
||||
expect(what(struck).slice(0, 6)).toEqual([
|
||||
'note 60',
|
||||
'note 61',
|
||||
'clip drum',
|
||||
'note 61',
|
||||
'clip drum',
|
||||
'note 61',
|
||||
]);
|
||||
const notes = struck.filter((s): s is ScheduledNote => 'note' in s);
|
||||
expect(notes[1]!.at - notes[0]!.at).toBeCloseTo(2, 9);
|
||||
expect(notes[2]!.at - notes[1]!.at).toBeCloseTo(2, 9);
|
||||
expect(playbacks.current()[0]?.clips[0]).toMatchObject({ sound: 'drum', bus: 'track' });
|
||||
});
|
||||
|
||||
it('rises from silence to its volume over its fade in, from its first note on', async () => {
|
||||
const { host, playbacks, loaded } = fakeHost();
|
||||
playbacks.start(5, 'song:a', song([], { volume: 0.5, fadeIn: 3 }), '');
|
||||
host.time = 1;
|
||||
await loaded();
|
||||
expect(host.levels).toEqual([{ id: 5, gain: 0.5, at: 1 + LEAD, seconds: 3 }]);
|
||||
});
|
||||
|
||||
it('fades out over its fade, playing on meanwhile, then is gone; a hard stop then ends it at once', async () => {
|
||||
const { host, playbacks, run, loaded } = fakeHost();
|
||||
const struck: (ScheduledNote | ScheduledClip)[] = [];
|
||||
playbacks.start(5, 'song:a', song(struck), '');
|
||||
playbacks.start(6, 'song:a', song([]), '');
|
||||
await loaded();
|
||||
run(1);
|
||||
playbacks.stopNamed('song:a', false, 4);
|
||||
expect(
|
||||
host.levels.slice(-2).map(({ id, gain, at, seconds }) => [id, gain, +at.toFixed(9), seconds]),
|
||||
).toEqual([
|
||||
[5, 0, 1, 4],
|
||||
[6, 0, 1, 4],
|
||||
]);
|
||||
run(2);
|
||||
expect([playbacks.has(5), playbacks.current()[0]?.fading]).toEqual([true, expect.closeTo(5, 9)]);
|
||||
expect(struck.length).toBeGreaterThan(1);
|
||||
playbacks.stopId(6);
|
||||
expect(playbacks.current().map((p) => p.id)).toEqual([5]);
|
||||
run(2.1);
|
||||
expect([playbacks.has(5), host.pumps.size, host.closed]).toEqual([false, 0, [6, 5]]);
|
||||
});
|
||||
|
||||
it('never starts when a fade out began while its files were loading', async () => {
|
||||
const { host, playbacks, run, loaded } = fakeHost();
|
||||
const struck: (ScheduledNote | ScheduledClip)[] = [];
|
||||
playbacks.start(5, 'song:a', song(struck), '');
|
||||
playbacks.stopId(5, false, 1);
|
||||
await loaded();
|
||||
run(2);
|
||||
expect([struck.length, host.pumps.size, playbacks.has(5)]).toEqual([0, 0, false]);
|
||||
});
|
||||
|
||||
it('goes by its own name, by its kind and by none – a kind never reaches the other', async () => {
|
||||
expect(['song:a', 'song:', '', 'pattern:', 'song:b'].map((name) => goesBy('song:a', name))).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
const { playbacks, loaded } = fakeHost();
|
||||
playbacks.start(1, 'song:a', song([]), '');
|
||||
playbacks.start(2, 'pattern:p', song([]), '');
|
||||
await loaded();
|
||||
playbacks.stopNamed('song:');
|
||||
expect(playbacks.current().map((p) => p.id)).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A scene whose behavior plays a song on start: on its first track a two-note pattern at bar 1, on
|
||||
* its second a sound at bar 2, the second track at -6 dB and panned left, the third soloed.
|
||||
*/
|
||||
function playingSong(params: Record<string, string | number | boolean> = {}) {
|
||||
const f = makeSpriteScene('Box');
|
||||
const sound = new CreateSoundCommand('Blip', FILE);
|
||||
f.stack.execute(sound);
|
||||
const ins = new CreateAssetCommand('instrument', 'Lead');
|
||||
f.stack.execute(ins);
|
||||
const lead = ins.asset as InstrumentAsset;
|
||||
lead.graph.nodes.find((n) => n.type === 'InstrumentSample')!.params.sound = sound.asset.id;
|
||||
const pattern = new CreateAssetCommand('pattern', 'Riff');
|
||||
f.stack.execute(pattern);
|
||||
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', lead.id));
|
||||
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
|
||||
f.stack.execute(
|
||||
new SetPatternNotesCommand(pattern.asset.id, [
|
||||
{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 1 },
|
||||
{ id: 'n2', tick: Q, length: Q, note: 64, velocity: 1 },
|
||||
]),
|
||||
);
|
||||
const create = new CreateAssetCommand('song', 'Theme');
|
||||
f.stack.execute(create);
|
||||
const id = create.asset.id;
|
||||
const asset = () => f.project.assets.find((a) => a.id === id) as SongAsset;
|
||||
f.stack.execute(new SetAssetPropertyCommand(id, 'tempo', 90));
|
||||
const [one, two, three] = asset().tracks;
|
||||
f.stack.execute(
|
||||
new SetSongCommand(id, {
|
||||
tracks: asset().tracks.map((t, i) => (i === 1 ? { ...t, volume: -6, pan: -1 } : t)),
|
||||
items: [
|
||||
newItem({ track: one!.id, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }),
|
||||
newItem({ track: two!.id, kind: 'sound', asset: sound.asset.id, tick: BAR, length: BAR }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
f.stack.execute(new SetSongLoopCommand(id, { start: BAR, end: 2 * BAR }));
|
||||
const behavior = makeBehavior(f, 'Logic');
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||||
const edit = graphEditor(f, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const play = edit.addNode('PlaySong', { song: id, volume: 0.8, fadeIn: 2, ...params });
|
||||
edit.connect(start, 'exec', play, 'exec');
|
||||
return { ...f, songId: id, tracks: [one!.id, two!.id, three!.id], lead, edit, play };
|
||||
}
|
||||
|
||||
function recordingAudio() {
|
||||
const sink = {
|
||||
...SILENT_AUDIO,
|
||||
sequences: [] as { name: string; play: SequencePlay; owner: string }[],
|
||||
notes: [] as { name: string; volume: number; owner: string; bus?: string; at?: number }[],
|
||||
clips: [] as { file: string; clip: ClipTiming }[],
|
||||
stopped: [] as string[],
|
||||
playSequence: (name: string, play: SequencePlay, owner: string) => {
|
||||
sink.sequences.push({ name, play, owner });
|
||||
return 1000 + sink.sequences.length;
|
||||
},
|
||||
playNote: ((name, _layers, _params, volume, owner, _duration, timing = {}) => {
|
||||
sink.notes.push({ name, volume, owner, ...timing });
|
||||
return 1;
|
||||
}) satisfies AudioSink['playNote'],
|
||||
playClip: (file: string, clip: ClipTiming) => (sink.clips.push({ file, clip }), 2),
|
||||
stopSequence: (name: string, _letRing = false, fade = 0) => sink.stopped.push(`${name} (fade ${fade})`),
|
||||
stopPlayback: (id: number, fade: number) => sink.stopped.push(`playback ${id} (fade ${fade})`),
|
||||
};
|
||||
return sink;
|
||||
}
|
||||
|
||||
describe('Play song', () => {
|
||||
it('hands the song to the player: its tempo, intro then loop, its tracks and the level of the whole', () => {
|
||||
const f = playingSong();
|
||||
const audio = recordingAudio();
|
||||
const { world, frame } = startWorld(f, { behaviors: true, audio });
|
||||
frame();
|
||||
const logic = world.entities.find((e) => e.name === 'Logic')!;
|
||||
const [asked] = audio.sequences;
|
||||
expect([asked?.name, asked?.owner]).toEqual([`song:${f.songId}`, logic.id]);
|
||||
expect(asked?.play).toMatchObject({
|
||||
tempo: 90,
|
||||
length: 4 * BAR,
|
||||
loop: { start: BAR, end: 2 * BAR },
|
||||
volume: 0.8,
|
||||
fadeIn: 2,
|
||||
});
|
||||
expect(asked?.play.sequence.notes.map((n) => [n.tick, n.note])).toEqual([
|
||||
[0, 60],
|
||||
[Q, 64],
|
||||
]);
|
||||
expect(asked?.play.needs.files).toEqual([FILE]);
|
||||
expect(asked?.play.buses?.map((b) => [b.name, +b.gain.toFixed(3), b.pan])).toEqual([
|
||||
[trackBus(f.songId, f.tracks[0]!), 1, 0],
|
||||
[trackBus(f.songId, f.tracks[1]!), 0.501, -1],
|
||||
[trackBus(f.songId, f.tracks[2]!), 1, 0],
|
||||
[expect.any(String), 1, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('strikes notes on their track, owned like the playing, and clips at their volume on theirs', () => {
|
||||
const f = playingSong({ detached: true, loop: false, volume: 3 });
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
const [asked] = audio.sequences;
|
||||
expect([asked?.owner, asked?.play.loop, asked?.play.volume]).toEqual(['', null, 1]);
|
||||
const bus = trackBus(f.songId, f.tracks[1]!);
|
||||
asked!.play.strike({
|
||||
at: 3,
|
||||
duration: 0.25,
|
||||
note: 60,
|
||||
velocity: 1,
|
||||
instrument: f.lead.id,
|
||||
tempo: 90,
|
||||
bus,
|
||||
});
|
||||
asked!.play.strikeClip!({
|
||||
at: 4,
|
||||
duration: 2,
|
||||
offset: 0.5,
|
||||
sound: f.project.assets.find((a) => a.type === 'sound')!.id,
|
||||
fx: '',
|
||||
volume: 0.5,
|
||||
bus,
|
||||
tempo: 90,
|
||||
});
|
||||
expect(audio.notes).toEqual([
|
||||
{ name: `instrument:${f.lead.id}`, volume: 1, owner: '', at: 3, tempo: 90, bus },
|
||||
]);
|
||||
expect(audio.clips).toEqual([
|
||||
{ file: FILE, clip: { at: 4, offset: 0.5, duration: 2, volume: 0.5, bus } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stop song', () => {
|
||||
it('stops one song over its fade, every song for none, and one playing by its number', () => {
|
||||
const f = playingSong();
|
||||
const one = f.edit.addNode('StopSong', { song: f.songId, fadeOut: 1.5 });
|
||||
const all = f.edit.addNode('StopSong', { song: '' });
|
||||
const byNumber = f.edit.addNode('StopSong', { song: f.songId, fadeOut: 2 });
|
||||
f.edit.connect(f.play, 'exec', one, 'exec');
|
||||
f.edit.connect(one, 'exec', all, 'exec');
|
||||
f.edit.connect(all, 'exec', byNumber, 'exec');
|
||||
f.edit.connect(f.play, 'playback', byNumber, 'playback');
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.stopped).toEqual([
|
||||
`song:${f.songId} (fade 1.5)`,
|
||||
'song: (fade 0)',
|
||||
'playback 1001 (fade 2)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -409,6 +409,8 @@ function fakeAudio(): AudioSink & {
|
||||
playNote: () => 0,
|
||||
playSequence: () => 0,
|
||||
stopSequence: () => {},
|
||||
playClip: () => 0,
|
||||
stopPlayback: () => {},
|
||||
release: () => {},
|
||||
length: () => 0.25,
|
||||
stop: () => {},
|
||||
|
||||
@@ -45,6 +45,8 @@ function fakeAudio(): AudioSink & {
|
||||
playNote: () => 0,
|
||||
playSequence: () => 0,
|
||||
stopSequence: () => {},
|
||||
playClip: () => 0,
|
||||
stopPlayback: () => {},
|
||||
release: () => {},
|
||||
tune: () => {},
|
||||
length: () => 0,
|
||||
|
||||
@@ -228,6 +228,8 @@ export type FakeContext = NodeContext & {
|
||||
}[];
|
||||
/** The patterns the context was asked to play; a playing's number is how many have been played. */
|
||||
patterns: { patternId: string; volume: number; loop: boolean; detached: boolean }[];
|
||||
/** The songs the context was asked to play, numbered the same way. */
|
||||
songs: { songId: string; volume: number; fadeIn: number; loop: boolean; detached: boolean }[];
|
||||
silenced: string[];
|
||||
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number; ease: EaseShare }[];
|
||||
/** The scenes the context was asked to load, in order. */
|
||||
@@ -292,6 +294,11 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
ctx.patterns.push({ patternId, volume, loop, detached }),
|
||||
stopPattern: (patternId, letRing) =>
|
||||
ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`),
|
||||
songs: [],
|
||||
playSong: (songId, volume, fadeIn, loop, detached) =>
|
||||
ctx.songs.push({ songId, volume, fadeIn, loop, detached }),
|
||||
stopSong: (songId, fadeOut) => ctx.silenced.push(`song ${songId} (fade ${fadeOut})`),
|
||||
stopPlayback: (playback, fadeOut) => ctx.silenced.push(`playback ${playback} (fade ${fadeOut})`),
|
||||
// Every sound lasts a quarter of a second, as if it had been loaded.
|
||||
soundLength: () => 0.25,
|
||||
// The tail of a voice is its number in tenths of a second, so a test can tell one from another.
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { FxEnvelopePlan, FxPlan, FxStage, FxStagePlan, FxVoice } from '../../src/core/soundfx/nodes';
|
||||
import type { SequencePlay } from '../../src/core/music/playbacks';
|
||||
import type { ScheduledNote } from '../../src/core/music/Scheduler';
|
||||
import type { PitchOptions } from '../../src/viewport/pitchWorklet';
|
||||
import { SoundLibrary } from '../../src/viewport/SoundLibrary';
|
||||
|
||||
@@ -1413,3 +1415,95 @@ describe('clips and tracks of a song (#334)', () => {
|
||||
expect(library.peaks(FILE)).toBe(peaks);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the patterns and songs a game plays (#336)', () => {
|
||||
/** A sequence of a note on a track and one on a track the song does not have, both at its start. */
|
||||
function sequence(
|
||||
strike: (note: ScheduledNote) => number,
|
||||
fields: Partial<SequencePlay> = {},
|
||||
): SequencePlay {
|
||||
const note = { tick: 0, length: 480, velocity: 1, instrument: 'i' };
|
||||
return {
|
||||
sequence: {
|
||||
notes: [
|
||||
{ ...note, note: 60, bus: 'song:s:t' },
|
||||
{ ...note, note: 62, bus: 'song:s:gone' },
|
||||
],
|
||||
},
|
||||
tempo: 120,
|
||||
length: 1920,
|
||||
loop: null,
|
||||
needs: { layers: [], files: [] },
|
||||
volume: 0.5,
|
||||
fadeIn: 2,
|
||||
buses: [{ name: 'song:s:t', gain: 0.25, pan: -1 }],
|
||||
strike,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
it('hears each playing through a master of its own, silent until it starts, its tracks into it', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const struck: ScheduledNote[] = [];
|
||||
const id = library.playSequence(
|
||||
'song:s',
|
||||
sequence((note) => (struck.push(note), 0)),
|
||||
'logic',
|
||||
);
|
||||
expect(library.busLevels()).toEqual([
|
||||
{ name: `play:${id}`, gain: 0, pan: 0 },
|
||||
{ name: `play:${id}:song:s:t`, gain: 0.25, pan: -1 },
|
||||
]);
|
||||
expect(wires).toEqual(['gain > panner', 'panner > destination', 'gain > panner', 'panner > gain']);
|
||||
await settle();
|
||||
expect(struck.map((note) => note.bus)).toEqual([`play:${id}:song:s:t`, `play:${id}`]);
|
||||
// It rises from silence with its first note, 50 ms ahead, to its volume over its fade in.
|
||||
expect(gains[0]!.ramps).toEqual([
|
||||
['set', 0, 0.05],
|
||||
['linear', 0.5, 2.05],
|
||||
]);
|
||||
library.stopSequence('song:', false, 0);
|
||||
expect(library.playbacks()).toEqual([]);
|
||||
clockTo();
|
||||
expect(library.busLevels()).toEqual([]);
|
||||
expect(disconnects.filter((kind) => kind !== 'clock')).toEqual(['gain', 'panner', 'gain', 'panner']);
|
||||
});
|
||||
|
||||
it('is gone once played to its end, its last voice gone too – asking after it does not ask itself', async () => {
|
||||
const library = new SoundLibrary();
|
||||
library.playSequence(
|
||||
'pattern:p',
|
||||
sequence(() => 77, { length: 10, buses: [] }),
|
||||
'',
|
||||
);
|
||||
await settle();
|
||||
expect(() => library.playbacks()).not.toThrow();
|
||||
expect(library.playbacks()).toEqual([]);
|
||||
clockTo();
|
||||
expect(library.busLevels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('a playing without notes or clips is none', () => {
|
||||
const library = new SoundLibrary();
|
||||
const empty = sequence(() => 1, { sequence: { notes: [], clips: [] } });
|
||||
expect(library.playSequence('song:s', empty, '')).toBe(0);
|
||||
expect(library.busLevels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('nothing that reads the clock wakes it while the toolbar holds it', () => {
|
||||
const library = new SoundLibrary();
|
||||
library.time();
|
||||
let resumes = 0;
|
||||
(library as unknown as { context: { resume: () => Promise<void> } }).context.resume = () => {
|
||||
resumes += 1;
|
||||
return Promise.resolve();
|
||||
};
|
||||
contextState = 'suspended';
|
||||
library.setSuspended(true);
|
||||
library.time();
|
||||
expect(resumes).toBe(0);
|
||||
library.setSuspended(false);
|
||||
library.time();
|
||||
expect(resumes).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -361,7 +361,7 @@ export async function pattern(t) {
|
||||
edges: [edge('pt64-e3', 'pt64-start', 'exec', 'pt64-play', 'exec')],
|
||||
},
|
||||
});
|
||||
const patterns = () => json('__gm.sound.patterns()');
|
||||
const patterns = () => json('__gm.sound.playbacks()');
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
await play();
|
||||
const game = await waitFor(patterns, (list) => list[0]?.planned.length >= 6, 40);
|
||||
|
||||
+105
-2
@@ -1,5 +1,5 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
import { pickAssetType } from './lib/scaffold.mjs';
|
||||
import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Songs and their tracks (#334). "+ → Song" opens the tab on four empty tracks. A pattern and a
|
||||
@@ -34,6 +34,9 @@ export async function song(t) {
|
||||
drag,
|
||||
shot,
|
||||
showTab,
|
||||
play,
|
||||
stop,
|
||||
clickToolbar,
|
||||
} = t;
|
||||
const waitFor = async (read, done, tries = 25) => {
|
||||
let value = await read();
|
||||
@@ -346,6 +349,103 @@ export async function song(t) {
|
||||
JSON.stringify(opened),
|
||||
);
|
||||
|
||||
// 65k. A game plays the song through Play song (#336): the clip once as the intro, then round the
|
||||
// loop of its ruler, each track on a bus inside a master of the playing. The pause of the
|
||||
// toolbar holds the clock; S fires Stop song, which fades it out, then it is gone.
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
await mutate(`
|
||||
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
|
||||
song.tracks.forEach((track, i) => Object.assign(track, { mute: false, solo: false, volume: i === 1 ? -6 : 0, pan: i === 1 ? -0.5 : 0 }));
|
||||
const base = song.items[0];
|
||||
song.items = [
|
||||
{ ...base, id: 'sg65-i1', track: song.tracks[0].id, kind: 'sound', asset: ${JSON.stringify(SOUND)}, tick: 0, length: ${BAR}, offset: 0 },
|
||||
{ ...base, id: 'sg65-i2', track: song.tracks[1].id, kind: 'pattern', asset: ${JSON.stringify(PATTERN)}, tick: ${BAR}, length: ${BAR}, offset: 0 },
|
||||
];
|
||||
song.loop = { start: ${BAR}, end: ${2 * BAR} };`);
|
||||
await showTab('scene');
|
||||
const rig = await scaffoldBehavior(t, {
|
||||
assetId: 'sg65-logic',
|
||||
name: 'Music 65',
|
||||
boxId: 'sg65-box',
|
||||
logicId: 'sg65-brain',
|
||||
y: LANES.sound,
|
||||
graph: {
|
||||
nodes: [
|
||||
node('sg65-start', 'OnStart', 0, 0),
|
||||
node('sg65-play', 'PlaySong', 260, 0, { song: id, volume: 0.8, fadeIn: 1, loop: true }),
|
||||
node('sg65-s', 'OnKeyDown', 0, 200, { key: 's' }),
|
||||
node('sg65-stop', 'StopSong', 260, 200, { song: id, fadeOut: 1 }),
|
||||
],
|
||||
edges: [
|
||||
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
|
||||
edge('sg65-e4', 'sg65-s', 'exec', 'sg65-stop', 'exec'),
|
||||
],
|
||||
},
|
||||
});
|
||||
const playbacks = () => json('__gm.sound.playbacks()');
|
||||
await play();
|
||||
const game = await waitFor(playbacks, (list) => list[0]?.planned.length >= 4, 60);
|
||||
const [run] = game;
|
||||
const master = `play:${run?.id}`;
|
||||
const gameNotes = run?.planned ?? [];
|
||||
const gameClip = run?.clips[0];
|
||||
check(
|
||||
'Play song plays the clip once as the intro, then the pattern round the loop, 2 s a round, on their tracks',
|
||||
game.length === 1 &&
|
||||
run.name === `song:${id}` &&
|
||||
run.owner !== '' &&
|
||||
run.clips.length === 1 &&
|
||||
gameClip?.sound === SOUND &&
|
||||
gameClip.bus === busOf(tracks[0]) &&
|
||||
gameNotes.every((n) => n.bus === busOf(tracks[1]) && n.tempo === 120) &&
|
||||
Math.abs(gameNotes[0].at - gameClip.at - 2) < 1e-6 &&
|
||||
Math.abs(gameNotes[1].at - gameNotes[0].at - 1) < 1e-6 &&
|
||||
Math.abs(gameNotes[2].at - gameNotes[0].at - 2) < 1e-6,
|
||||
JSON.stringify({ game: game.map((g) => ({ ...g, planned: g.planned.length })), gameNotes }),
|
||||
);
|
||||
const gameBuses = await json('__gm.song.buses()');
|
||||
const gameBus = (name) => gameBuses.find((b) => b.name === name);
|
||||
check(
|
||||
'the playing has a master at its volume, the tracks inside it as they are set',
|
||||
gameBus(master)?.gain === 0.8 &&
|
||||
gameBus(`${master}:${busOf(tracks[0])}`)?.gain === 1 &&
|
||||
Math.abs(gameBus(`${master}:${busOf(tracks[1])}`)?.gain - 10 ** (-6 / 20)) < 1e-6 &&
|
||||
gameBus(`${master}:${busOf(tracks[1])}`)?.pan === -0.5,
|
||||
JSON.stringify(gameBuses),
|
||||
);
|
||||
await clickToolbar('pause');
|
||||
await sleep(400);
|
||||
const heldClock = await json('{ state: __gm.sound.clockState(), at: __gm.sound.clock() }');
|
||||
await sleep(300);
|
||||
const heldLater = await json('{ state: __gm.sound.clockState(), at: __gm.sound.clock() }');
|
||||
await clickToolbar('pause');
|
||||
await sleep(200);
|
||||
const resumed = await json('__gm.sound.clockState()');
|
||||
check(
|
||||
'the pause of the toolbar holds the clock while the song plays, and resuming lets it run',
|
||||
heldClock.state === 'suspended' &&
|
||||
heldLater.state === 'suspended' &&
|
||||
heldLater.at === heldClock.at &&
|
||||
resumed === 'running',
|
||||
JSON.stringify({ heldClock, heldLater, resumed }),
|
||||
);
|
||||
await press('s');
|
||||
const fading = await waitFor(playbacks, (list) => list[0]?.fading !== null);
|
||||
const fadedOut = await waitFor(playbacks, (list) => list.length === 0, 40);
|
||||
await sleep(100);
|
||||
const leftBuses = (await json('__gm.song.buses()')).filter((b) => b.name.startsWith(master));
|
||||
await stop();
|
||||
check(
|
||||
'Stop song fades it out over its second, playing on meanwhile, then it and its buses are gone',
|
||||
fading.length === 1 &&
|
||||
fading[0].fading !== null &&
|
||||
fading[0].playing &&
|
||||
fadedOut.length === 0 &&
|
||||
leftBuses.length === 0,
|
||||
JSON.stringify({ fading: fading.map((g) => g.fading), fadedOut, leftBuses }),
|
||||
);
|
||||
const left = await rig.remove();
|
||||
|
||||
// 65j. The step takes back everything it made.
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
await mutate(`
|
||||
@@ -358,6 +458,9 @@ export async function song(t) {
|
||||
'the step leaves nothing of its own behind',
|
||||
(await json(
|
||||
`__gm.state.project.assets.filter((a) => a.id.startsWith('sg65') || a.type === 'song').length`,
|
||||
)) === 0,
|
||||
)) === 0 &&
|
||||
left.objects === 0 &&
|
||||
left.assets === 0,
|
||||
JSON.stringify(left),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user