Let a behavior hear a song, pattern or sound come to its end (#373)
A game should be able to start the next piece of music when one is over, or wait for a sound before it goes on. Until now a behavior could start and stop music and sounds, but never learn that they had ended. - Two new events: On music finished with the fields Song and Pattern and the input playback, and On sound finished with the field Sound and the input voice. Both give out what ended and stopped. - Two new data nodes, Music playing and Sound playing, ask whether something is still heard. - Over means nothing is heard any more: no tail of the effects, and a fade-out has run down. - stopped is on for Stop song, pattern or sound, when the behavior that owns it ends and on a scene change. For a sound also when a newer voice needs its place or the file does not load. - Only what the Play nodes of this run started is heard, across scene changes too. Previews in the tabs, compositions and notes of instruments do not count. - Switch song ends nothing. The number goes on, and the end is told under the song heard last. - Playbacks and the SoundLibrary tell each end once to a new PlayedEnds per run. The AudioSwitch passes them on from both sinks. Found and fixed: a pattern whose last bar ends in silence counted as done up to one planning window before its end. In e2e 65 the next pattern started after 1.962 s instead of 2 s. Scheduler.done now also wants the playhead to have reached the end. Cleaning up done playbacks therefore comes a few tenths of a second later. One test that expected the early end is adjusted. Tests: - Unit: 23 new (played-ends 12, sound-library 5, node catalog 5, audio-switch 1), all green. - Counter-probes: 19 bends, all red. One was green at first for want of a case, which is added now. - e2e 65 on atx: ALL PASSED, 78 checks. New 65k6: the event came once with stopped off, and the second pattern started 2.051 s after the stinger. Stop song gave exactly one end with stopped on, and the switch from 65k3 gave none. - Gate on atx: CHECK EXIT 0, 337 files, 5222 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2333f66ba2
commit
e53121a0bb
22 files changed
+932
-29
No files matched your search
@@ -408,6 +408,11 @@ export interface NodeContext {
|
||||
* the one started last of `song` or `pattern`, of every one where both are `''`; null for none.
|
||||
*/
|
||||
songPosition(playback: number | null, song: string, pattern: string): HeardPosition | null;
|
||||
/**
|
||||
* Whether the voice or playing numbered `id` is still to be heard (#373): waiting on its files,
|
||||
* playing, fading or ringing on; false for 0.
|
||||
*/
|
||||
playing(id: number): boolean;
|
||||
/**
|
||||
* Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be
|
||||
* able to finish the flow it is in, and half a frame in the old world and half in the new one is
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { PLAYBACK_INPUT, playbackPort } from '../events/musicEvents';
|
||||
import { bool, num } from '../shared';
|
||||
|
||||
export const MusicPlaying: NodeDef = {
|
||||
type: 'MusicPlaying',
|
||||
label: 'Music playing',
|
||||
category: 'data',
|
||||
description:
|
||||
'Whether the song or pattern numbered "playback" is still to be heard: from Play song or Play ' +
|
||||
'pattern on, while its files load, while it plays and fades, until its last echoes are gone – ' +
|
||||
'the moment On music finished runs. Unlike "playing" of Song position it is on before the first ' +
|
||||
'note is heard. Off for 0 and for a number nothing plays under.',
|
||||
inputs: [playbackPort()],
|
||||
outputs: [bool('playing')],
|
||||
params: [],
|
||||
read: (ctx, _params, input) => ({ playing: ctx.playing(asNumber(input(PLAYBACK_INPUT))) }),
|
||||
};
|
||||
|
||||
export const SoundPlaying: NodeDef = {
|
||||
type: 'SoundPlaying',
|
||||
label: 'Sound playing',
|
||||
category: 'data',
|
||||
description:
|
||||
'Whether the voice numbered "voice", the number Play sound handed out, is still to be heard: ' +
|
||||
'while its file loads, while it plays, until the echoes of its FX are gone – the moment On sound ' +
|
||||
'finished runs. Off for 0 and for a voice that has ended.',
|
||||
inputs: [{ ...num('voice'), inline: false }],
|
||||
outputs: [bool('playing')],
|
||||
params: [],
|
||||
read: (ctx, _params, input) => ({ playing: ctx.playing(asNumber(input('voice'))) }),
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { textOf } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||
import { bool, exec, num } from '../shared';
|
||||
|
||||
/** The input that picks one play (#373): not typed in, so unconnected it is null and the Sound decides. */
|
||||
export const VOICE_INPUT = 'voice';
|
||||
|
||||
export const OnSoundFinished: NodeDef = {
|
||||
type: 'OnSoundFinished',
|
||||
label: 'On sound finished',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a sound a Play sound of this game started is over: once nothing of it is heard ' +
|
||||
'any more, the echoes of its FX included. "stopped" is on where it did not play to its end but ' +
|
||||
'was stopped – by Stop sound, by the end of the behavior that owns it, or to make room for ' +
|
||||
'newer voices – and where it never started because its file would not load. A looping sound ' +
|
||||
'ends only so. Pick a Sound to hear only that one, or connect "voice" to hear only that play, ' +
|
||||
'the number Play sound handed out. A note of an instrument is none of them. It comes in the ' +
|
||||
'first frame after the end, so up to a frame late.',
|
||||
inputs: [{ ...num(VOICE_INPUT), inline: false }],
|
||||
outputs: [exec(), num('voice'), bool('stopped')],
|
||||
params: [{ key: 'sound', label: 'Sound', kind: 'sound', default: '' }, orderParam],
|
||||
hiddenParams: (params) => [...(textOf(params.sound ?? '') ? [] : ['sound']), ...hiddenOrder(params)],
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { textOf } from '../../../model/Properties';
|
||||
import type { PropertyDef } from '../../../properties/PropertySchema';
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||
import { exec, num, str } from '../shared';
|
||||
import { bool, exec, num, str } from '../shared';
|
||||
|
||||
/** The input that picks one playing (#338): not typed in, so unconnected it is null and hears every one. */
|
||||
export const PLAYBACK_INPUT = 'playback';
|
||||
@@ -76,6 +76,24 @@ export const OnBar: NodeDef = {
|
||||
hiddenParams: hiddenEmpty('song', 'pattern'),
|
||||
};
|
||||
|
||||
export const OnMusicFinished: NodeDef = {
|
||||
type: 'OnMusicFinished',
|
||||
label: 'On music finished',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a song or pattern a Play song or Play pattern of this game started is over: once ' +
|
||||
'nothing of it is heard any more, its last echoes and a fade out included. "stopped" is on where ' +
|
||||
'it did not play to its end but was stopped – by Stop song, Stop pattern or Stop sound, or by ' +
|
||||
'the end of the behavior that owns it. One with a loop ends only so. After a Switch song it ' +
|
||||
'counts as the song heard last. Pick a Song or a Pattern to hear only that one (both picked, ' +
|
||||
'either), or connect "playback" to hear only that playing. It comes in the first frame after ' +
|
||||
'the end, so up to a frame late.',
|
||||
inputs: [playbackPort()],
|
||||
outputs: [exec(), num('playback'), bool('stopped')],
|
||||
params: [songParam, patternParam, orderParam],
|
||||
hiddenParams: hiddenEmpty('song', 'pattern'),
|
||||
};
|
||||
|
||||
export const OnSongMarker: NodeDef = {
|
||||
type: 'OnSongMarker',
|
||||
label: 'On song marker',
|
||||
|
||||
@@ -128,7 +128,9 @@ import { TextureSize } from './data/TextureSize';
|
||||
import { OnAnimationFinished } from './events/OnAnimationFinished';
|
||||
import { OnCompositionFinished } from './events/OnCompositionFinished';
|
||||
import { OnVideoFinished } from './events/OnVideoFinished';
|
||||
import { OnBar, OnBeat, OnSongMarker } from './events/musicEvents';
|
||||
import { OnBar, OnBeat, OnMusicFinished, OnSongMarker } from './events/musicEvents';
|
||||
import { OnSoundFinished } from './events/OnSoundFinished';
|
||||
import { MusicPlaying, SoundPlaying } from './data/MusicPlaying';
|
||||
import { SongPosition } from './data/SongPosition';
|
||||
import { VideoTime } from './data/VideoTime';
|
||||
import { OnBehavior } from './events/OnBehavior';
|
||||
@@ -196,6 +198,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
OnBeat,
|
||||
OnBar,
|
||||
OnSongMarker,
|
||||
OnMusicFinished,
|
||||
OnSoundFinished,
|
||||
OnBehavior,
|
||||
OnStateEntered,
|
||||
OnStateExited,
|
||||
@@ -341,6 +345,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
ReleaseSongLane,
|
||||
SwitchSong,
|
||||
SongPosition,
|
||||
MusicPlaying,
|
||||
SoundPlaying,
|
||||
VideoTime,
|
||||
SetBusVolume,
|
||||
BusVolume,
|
||||
|
||||
@@ -169,9 +169,16 @@ export class Scheduler {
|
||||
return this.anchor;
|
||||
}
|
||||
|
||||
/** Played to its end, and none of its notes is heard any more. */
|
||||
/**
|
||||
* Played to its end, and none of its notes is heard any more. Planned to its end is not yet played
|
||||
* to it: its notes are planned ahead, and a last bar may end in silence (#373).
|
||||
*/
|
||||
get done(): boolean {
|
||||
return this.ended && this.voices.every((voice) => !this.host.playing(voice));
|
||||
return (
|
||||
this.ended &&
|
||||
this.headAt(this.host.now()) >= this.end &&
|
||||
this.voices.every((voice) => !this.host.playing(voice))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -140,6 +140,11 @@ export interface PlaybackHost extends Omit<
|
||||
after(at: number, then: () => void): void;
|
||||
/** A playing is gone: stopped, or played to its end. */
|
||||
closed?(key: number): void;
|
||||
/**
|
||||
* Nothing of the playings numbered `id` is left (#373): played to their end, or `stopped`. `name`
|
||||
* is that of the one heard last, a Switch song's in place of the one it took over from.
|
||||
*/
|
||||
ended?(id: number, name: string, stopped: boolean): void;
|
||||
/** Starts the bake of that playing to be heard from `at`, as one voice; 0 where nothing plays. */
|
||||
playBaked?(key: number, bake: BakePlay, at: number): number;
|
||||
}
|
||||
@@ -167,6 +172,8 @@ export interface Playback {
|
||||
baked: boolean;
|
||||
/** How fast, round what and to where it plays, and from where it started (#338). */
|
||||
time: PlayingTime;
|
||||
/** A stop reached it (#373), rather than it playing to its end. */
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
/** The time of a playing (#338): its tempo, where it started, its loop and its length, in ticks. */
|
||||
@@ -393,6 +400,7 @@ export class Playbacks {
|
||||
until: null,
|
||||
successor,
|
||||
time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length },
|
||||
stopped: false,
|
||||
};
|
||||
this.list.push(playback);
|
||||
return playback;
|
||||
@@ -447,6 +455,7 @@ export class Playbacks {
|
||||
|
||||
private stopWhere(going: (playback: Playback) => boolean, letRing: boolean, fade: number): void {
|
||||
const stopping = this.list.filter(going);
|
||||
for (const playback of stopping) playback.stopped = true;
|
||||
if (!(fade > 0)) return this.close(stopping, letRing);
|
||||
const now = this.host.now();
|
||||
const end = now + fade;
|
||||
@@ -464,10 +473,27 @@ export class Playbacks {
|
||||
private close(going: readonly Playback[], letRing: boolean): void {
|
||||
if (going.length === 0) return;
|
||||
this.list = this.list.filter((playback) => !going.includes(playback));
|
||||
const ended = this.endedOf(going);
|
||||
for (const playback of going) {
|
||||
playback.runner.stop(letRing);
|
||||
this.host.closed?.(playback.key);
|
||||
}
|
||||
for (const { id, name, stopped } of ended) this.host.ended?.(id, name, stopped);
|
||||
}
|
||||
|
||||
/**
|
||||
* The numbers nothing is left of once `going` goes (#373). Of a switch (#338) the one taken over
|
||||
* from goes first and the number lives on in the other; where both go, it is named by the newer
|
||||
* one if that had started.
|
||||
*/
|
||||
private endedOf(going: readonly Playback[]): { id: number; name: string; stopped: boolean }[] {
|
||||
return [...new Set(going.map((playback) => playback.id))].flatMap((id) => {
|
||||
if (this.list.some((playback) => playback.id === id)) return [];
|
||||
const ones = going.filter((playback) => playback.id === id);
|
||||
const started = ones.filter((playback) => !playback.successor || playback.runner.startedAt !== null);
|
||||
const last = started[started.length - 1] ?? ones[0]!;
|
||||
return [{ id, name: last.name, stopped: ones.some((playback) => playback.stopped) }];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import type { Input } from './systems/Input';
|
||||
import { bringObjects, keptEntities, objectsToBring } from './keptObjects';
|
||||
import type { Rapier } from './physics/engine';
|
||||
import { PlayedEnds } from './systems/playedEnds';
|
||||
import { World } from './World';
|
||||
|
||||
export interface RunServices {
|
||||
@@ -49,6 +50,8 @@ export class GameRun {
|
||||
private readonly services: RunServices,
|
||||
public world: World,
|
||||
public behaviors: BehaviorSystem,
|
||||
/** What the run played, heard ending in whatever scene it is in by then (#373). */
|
||||
private readonly ends: PlayedEnds,
|
||||
) {}
|
||||
|
||||
static start(project: Project, sceneId: string, input: Input, services: RunServices): GameRun {
|
||||
@@ -58,7 +61,9 @@ export class GameRun {
|
||||
services.audio.resetMix();
|
||||
services.audio.setMix(mixOf(project), videoBusesOf(project));
|
||||
const world = World.instantiate(project, sceneId);
|
||||
const run = new GameRun(project, input, services, world, addGameSystems(world, project, input, services));
|
||||
const ends = new PlayedEnds();
|
||||
const behaviors = addGameSystems(world, project, input, { ...services, ends });
|
||||
const run = new GameRun(project, input, services, world, behaviors, ends);
|
||||
if (before) run.stored.set(sceneId, before);
|
||||
return run;
|
||||
}
|
||||
@@ -106,6 +111,7 @@ export class GameRun {
|
||||
...this.services,
|
||||
carried,
|
||||
kept,
|
||||
ends: this.ends,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AutomationTarget } from '../core/music/automation';
|
||||
import type { MixBus } from '../core/music/mixer';
|
||||
import type { HeardTime, LandingAt, SequencePlay } from '../core/music/playbacks';
|
||||
import type { FxParams, FxPlan, FxVoice } from '../core/soundfx/nodes';
|
||||
import type { AudioSink, ClipTiming, NoteTiming } from './systems/Audio';
|
||||
import type { AudioSink, ClipTiming, EndedPlay, NoteTiming } from './systems/Audio';
|
||||
|
||||
/** The first number a sink diverted to gives out: whatever it numbers is told apart from the live one's. */
|
||||
export const DIVERTED_IDS = 1_000_000_000;
|
||||
@@ -143,6 +143,15 @@ export class AudioSwitch implements AudioSink {
|
||||
return this.now.clock?.() ?? 0;
|
||||
}
|
||||
|
||||
/** What ended in either sink (#373): their numbers are told apart. */
|
||||
endings(): EndedPlay[] {
|
||||
return this.both.flatMap((sink) => sink.endings?.() ?? []);
|
||||
}
|
||||
|
||||
playing(id: number): boolean {
|
||||
return this.of(id)?.playing?.(id) ?? false;
|
||||
}
|
||||
|
||||
/** Both sinks' playings: each counts its own on its own clock. */
|
||||
playingTimes(): HeardTime[] {
|
||||
return this.both.flatMap((sink) => sink.playingTimes?.() ?? []);
|
||||
|
||||
@@ -118,6 +118,17 @@ export interface AudioSink {
|
||||
* picture by. A sink without it keeps no time, and nothing is held to it.
|
||||
*/
|
||||
clock?(): number;
|
||||
/**
|
||||
* The voices of `play` and the playings of `playSequence` that ended lately, oldest first (#373):
|
||||
* once nothing of them is heard any more, after their echoes and a fade out. The last
|
||||
* `ENDED_KEPT` are kept, so one followed every frame misses none. A sink without it tells of none.
|
||||
*/
|
||||
endings?(): readonly EndedPlay[];
|
||||
/**
|
||||
* Whether the voice or playing numbered `id` is still to be heard (#373): waiting on its bytes,
|
||||
* playing, fading, or its echoes ringing. A sink without it plays nothing.
|
||||
*/
|
||||
playing?(id: number): boolean;
|
||||
/**
|
||||
* The patterns and songs playing, each with how far into its playing time it is heard now (#338):
|
||||
* what beats, bars and `Song position` go by. A sink without it plays none to be followed.
|
||||
@@ -183,6 +194,19 @@ export interface NoteTiming {
|
||||
note?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A voice or a playing that ended (#373), by its number: `name` is its file, or what stands for the
|
||||
* pattern or song. `stopped` where a stop ended it rather than it playing to its end.
|
||||
*/
|
||||
export interface EndedPlay {
|
||||
id: number;
|
||||
name: string;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
/** So many ends a sink keeps to be told (see `AudioSink.endings`). */
|
||||
export const ENDED_KEPT = 256;
|
||||
|
||||
/** When and how a clip of a song plays (#334); see `playClip`. */
|
||||
export interface ClipTiming {
|
||||
at: number;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../../core/graph';
|
||||
import { GraphCache } from '../../core/graph/graphIndex';
|
||||
import { PLAYBACK_INPUT } from '../../core/graph/nodes/events/musicEvents';
|
||||
import { VOICE_INPUT } from '../../core/graph/nodes/events/OnSoundFinished';
|
||||
import { PER_CELL_INPUT } from '../../core/graph/nodes/shared';
|
||||
import { keyName } from '../../core/keys';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
MusicFollower,
|
||||
type PlaybackOf,
|
||||
} from './musicPulses';
|
||||
import { musicFinishedPulses, PlayedEnds, soundFinishedPulses } from './playedEnds';
|
||||
import type { CollisionSource } from './solids';
|
||||
import { AnimationSystem } from './AnimationSystem';
|
||||
import { CompositionSystem, type VideoPictures } from './CompositionSystem';
|
||||
@@ -139,6 +141,8 @@ export interface GameSystemOptions {
|
||||
pictures?: VideoPictures | null;
|
||||
/** Where a `Break` node pauses: only the editor's Play has one. */
|
||||
breakpoint?: Breakpoint | null;
|
||||
/** What the run played, kept through its scenes (#373); a world without one keeps its own. */
|
||||
ends?: PlayedEnds | null;
|
||||
}
|
||||
|
||||
/** Collisions, compositions, animations and videos are ticked first, so behaviors see this frame's events. */
|
||||
@@ -159,13 +163,25 @@ export function addGameSystems(
|
||||
steersVideos = true,
|
||||
pictures = null,
|
||||
breakpoint = null,
|
||||
ends = null,
|
||||
}: GameSystemOptions = {},
|
||||
): BehaviorSystem {
|
||||
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
|
||||
const compositions = new CompositionSystem(playsCompositions, pictures, audio);
|
||||
const animations = new AnimationSystem();
|
||||
const videos = new VideoSystem(steersVideos);
|
||||
const sources = { collisions, animations, compositions, videos, audio, clock, saves, recorder, breakpoint };
|
||||
const sources = {
|
||||
collisions,
|
||||
animations,
|
||||
compositions,
|
||||
videos,
|
||||
audio,
|
||||
clock,
|
||||
saves,
|
||||
recorder,
|
||||
breakpoint,
|
||||
ends,
|
||||
};
|
||||
const behaviors = new BehaviorSystem(project, input, sources, shouldRun);
|
||||
if (carried) behaviors.carryOver(carried);
|
||||
if (kept) {
|
||||
@@ -192,6 +208,8 @@ export interface EventSources {
|
||||
saves?: SaveArea | null;
|
||||
recorder?: RecordingSink | null;
|
||||
breakpoint?: Breakpoint | null;
|
||||
/** What the run played, to hear of its end (#373). */
|
||||
ends?: PlayedEnds | null;
|
||||
}
|
||||
|
||||
/** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */
|
||||
@@ -239,6 +257,7 @@ export class BehaviorSystem implements System {
|
||||
private readonly saves: SaveArea | null;
|
||||
private readonly recorder: RecordingSink | null;
|
||||
private readonly breakpoint: Breakpoint | null;
|
||||
private readonly ends: PlayedEnds;
|
||||
/** What was last written to `saves`, so a frame that changed nothing writes nothing. */
|
||||
private lastSaved = '';
|
||||
/** Where each playing of a song or pattern was heard last frame (#338). */
|
||||
@@ -263,6 +282,7 @@ export class BehaviorSystem implements System {
|
||||
this.saves = sources.saves ?? null;
|
||||
this.recorder = sources.recorder ?? null;
|
||||
this.breakpoint = sources.breakpoint ?? null;
|
||||
this.ends = sources.ends ?? new PlayedEnds();
|
||||
}
|
||||
|
||||
/** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */
|
||||
@@ -660,6 +680,8 @@ export class BehaviorSystem implements System {
|
||||
// Every frame, paused or not: the music goes on, and what a paused one passed is not told after.
|
||||
const passed = this.music.follow(heardPlayings(this.project, this.audio));
|
||||
const playbackOf: PlaybackOf = (i, node) => i.interpreter.eventInput(node, PLAYBACK_INPUT, contextOf(i));
|
||||
const voiceOf: PlaybackOf = (i, node) => i.interpreter.eventInput(node, VOICE_INPUT, contextOf(i));
|
||||
const finished = this.ends.follow(this.audio.endings?.() ?? []);
|
||||
const kinds: (() => Pulse[])[] = [
|
||||
() =>
|
||||
ticking.flatMap((i) =>
|
||||
@@ -678,6 +700,8 @@ export class BehaviorSystem implements System {
|
||||
() => ticking.flatMap((i) => beatPulses(i, passed, playbackOf)),
|
||||
() => ticking.flatMap((i) => barPulses(i, passed, playbackOf)),
|
||||
() => ticking.flatMap((i) => markerPulses(i, passed, playbackOf)),
|
||||
() => ticking.flatMap((i) => musicFinishedPulses(i, finished, playbackOf)),
|
||||
() => ticking.flatMap((i) => soundFinishedPulses(i, finished, voiceOf)),
|
||||
() =>
|
||||
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
|
||||
() =>
|
||||
@@ -811,6 +835,7 @@ export class BehaviorSystem implements System {
|
||||
clock: this.clock,
|
||||
recorder: this.recorder,
|
||||
breakpoint: this.breakpoint,
|
||||
ends: this.ends,
|
||||
shared: this.sharedState,
|
||||
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ import type { AudioSink, NoteTiming } from './Audio';
|
||||
import type { Instance } from './behaviorPulses';
|
||||
import type { Input } from './Input';
|
||||
import { type HeardPlaying, heardPlayings, positionOf } from './musicPulses';
|
||||
import type { PlayedEnds } from './playedEnds';
|
||||
import type { RecordingSink } from '../../core/recording/recording';
|
||||
import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids';
|
||||
import type { VideoSystem } from './VideoSystem';
|
||||
@@ -74,6 +75,8 @@ export interface ContextSources {
|
||||
recorder: RecordingSink | null;
|
||||
/** Where `Break` pauses; only the editor's Play has one. */
|
||||
breakpoint: Breakpoint | null;
|
||||
/** What the run played, to be told when it ends (#373); absent, nothing is. */
|
||||
ends?: PlayedEnds;
|
||||
shared: SharedState;
|
||||
/** A graph moved one of this instance's machines; its events follow between exec flows. */
|
||||
stateChanged(machineId: string, from: string, to: string): void;
|
||||
@@ -211,8 +214,8 @@ export function behaviorContext(
|
||||
const found = resolve(target);
|
||||
return (found && run.videos?.reading(world, found)) ?? null;
|
||||
},
|
||||
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) =>
|
||||
playSound(
|
||||
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) => {
|
||||
const voice = playSound(
|
||||
run,
|
||||
assetId,
|
||||
volume,
|
||||
@@ -223,7 +226,10 @@ export function behaviorContext(
|
||||
tempo,
|
||||
bus,
|
||||
syncedStart(run, sync)?.at,
|
||||
),
|
||||
);
|
||||
run.ends?.sound(voice, assetId);
|
||||
return voice;
|
||||
},
|
||||
playNote: (
|
||||
instrumentId,
|
||||
note,
|
||||
@@ -249,8 +255,8 @@ export function behaviorContext(
|
||||
fxVoiceOf(project, instrumentFx(project, instrumentId, fx), tempo, fxParams),
|
||||
);
|
||||
},
|
||||
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus, sync) =>
|
||||
playPattern(
|
||||
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus, sync) => {
|
||||
const id = playPattern(
|
||||
run,
|
||||
patternId,
|
||||
volume,
|
||||
@@ -260,10 +266,13 @@ export function behaviorContext(
|
||||
fxParams,
|
||||
bus,
|
||||
syncedStart(run, sync),
|
||||
),
|
||||
);
|
||||
run.ends?.playing(id);
|
||||
return id;
|
||||
},
|
||||
stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing),
|
||||
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) =>
|
||||
playSong(run, songId, {
|
||||
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) => {
|
||||
const id = playSong(run, songId, {
|
||||
volume,
|
||||
fadeIn,
|
||||
loop,
|
||||
@@ -272,7 +281,10 @@ export function behaviorContext(
|
||||
fxParams,
|
||||
bus,
|
||||
live,
|
||||
}),
|
||||
});
|
||||
run.ends?.playing(id);
|
||||
return id;
|
||||
},
|
||||
setSongTrack: (playback, track, volumeDb, fade, quantize) =>
|
||||
setSongTrack(run, world, playback, track, volumeDb, heldFade(fade), quantize),
|
||||
setSongLane: (playback, track, lane, value, fade, quantize) =>
|
||||
@@ -310,6 +322,7 @@ export function behaviorContext(
|
||||
},
|
||||
songPosition: (playback, song, pattern) =>
|
||||
positionOf(heardPlayings(project, run.audio), playback, song, pattern),
|
||||
playing: (id) => id > 0 && (run.audio.playing?.(id) ?? false),
|
||||
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
||||
addJoint: (assetId, owner, connected, at) => {
|
||||
const on = resolve(owner);
|
||||
|
||||
@@ -38,13 +38,19 @@ export interface HeardPlaying {
|
||||
markers: readonly SongMarker[];
|
||||
}
|
||||
|
||||
/** The song or pattern a playing stands for, by its name (`song:<id>`); null for anything else. */
|
||||
export function playedAsset(name: string): { kind: 'song' | 'pattern'; asset: string } | null {
|
||||
const split = name.indexOf(':');
|
||||
const kind = name.slice(0, split);
|
||||
return kind === 'song' || kind === 'pattern' ? { kind, asset: name.slice(split + 1) } : null;
|
||||
}
|
||||
|
||||
/** The playings of songs and patterns, oldest first. */
|
||||
export function heardPlayings(project: Project, audio: AudioSink): HeardPlaying[] {
|
||||
return (audio.playingTimes?.() ?? []).flatMap((time): HeardPlaying[] => {
|
||||
const split = time.name.indexOf(':');
|
||||
const kind = time.name.slice(0, split);
|
||||
const id = time.name.slice(split + 1);
|
||||
if (kind !== 'song' && kind !== 'pattern') return [];
|
||||
const played = playedAsset(time.name);
|
||||
if (!played) return [];
|
||||
const { kind, asset: id } = played;
|
||||
const song = kind === 'song' ? findAssetOf(project, id, 'song') : undefined;
|
||||
const pattern = kind === 'pattern' ? findAssetOf(project, id, 'pattern') : undefined;
|
||||
const meter = song ? songMeter(song.props) : pattern ? patternMeter(pattern.props) : COMMON_TIME;
|
||||
@@ -102,7 +108,12 @@ export class MusicFollower {
|
||||
export type PlaybackOf = (instance: Instance, node: GraphNode) => Value;
|
||||
|
||||
/** Whether a node hears that playing: by its number, else by the song and the pattern picked. */
|
||||
function hears(node: GraphNode, wanted: Value, playing: HeardPlaying, keys: readonly string[]): boolean {
|
||||
export function hears(
|
||||
node: GraphNode,
|
||||
wanted: Value,
|
||||
playing: Pick<HeardPlaying, 'id' | 'kind' | 'asset'>,
|
||||
keys: readonly string[],
|
||||
): boolean {
|
||||
if (wanted !== null) return asNumber(wanted) === playing.id;
|
||||
const picked = keys.map((key) => ({ key, id: textOf(node.params[key] ?? '') })).filter((p) => p.id !== '');
|
||||
return picked.length === 0 || picked.some((p) => p.key === playing.kind && p.id === playing.asset);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* What a run played and heard the end of (#373): the sounds of `Play sound` and the playings of
|
||||
* `Play pattern` and `Play song`, each told once – what `On sound finished` and `On music finished`
|
||||
* go by. A run keeps one through its scenes, so a song that outlives a scene is heard ending in the
|
||||
* next; what another run or a tab of the editor played is none of its business.
|
||||
*/
|
||||
import { asNumber, type GraphNode } from '../../core/graph';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import type { EndedPlay } from './Audio';
|
||||
import type { Instance, Pulse } from './behaviorPulses';
|
||||
import { hears, type PlaybackOf, playedAsset } from './musicPulses';
|
||||
|
||||
/** A sound or a playing of this run that ended, and whether a stop ended it. */
|
||||
export interface Finished {
|
||||
id: number;
|
||||
kind: 'sound' | 'song' | 'pattern';
|
||||
/** The sound, song or pattern: of a playing the one heard last, after a Switch song too. */
|
||||
asset: string;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
export class PlayedEnds {
|
||||
/** What each number this run played stands for: its sound, `''` for a playing. */
|
||||
private readonly played = new Map<number, string>();
|
||||
|
||||
sound(voice: number, soundId: string): void {
|
||||
if (voice > 0) this.played.set(voice, soundId);
|
||||
}
|
||||
|
||||
playing(id: number): void {
|
||||
if (id > 0) this.played.set(id, '');
|
||||
}
|
||||
|
||||
/** Of `ended`, those this run played and not told yet, oldest first. */
|
||||
follow(ended: readonly EndedPlay[]): Finished[] {
|
||||
return ended.flatMap(({ id, name, stopped }): Finished[] => {
|
||||
const sound = this.played.get(id);
|
||||
if (sound === undefined) return [];
|
||||
this.played.delete(id);
|
||||
if (sound !== '') return [{ id, kind: 'sound', asset: sound, stopped }];
|
||||
const played = playedAsset(name);
|
||||
return played ? [{ id, ...played, stopped }] : [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function musicFinishedPulses(
|
||||
instance: Instance,
|
||||
finished: readonly Finished[],
|
||||
playbackOf: PlaybackOf,
|
||||
): Pulse[] {
|
||||
return instance.interpreter.eventNodes('OnMusicFinished').flatMap((node) => {
|
||||
const wanted = playbackOf(instance, node);
|
||||
return finished.flatMap(({ id, kind, asset, stopped }) =>
|
||||
kind !== 'sound' && hears(node, wanted, { id, kind, asset }, ['song', 'pattern'])
|
||||
? [{ instance, node, outputs: { playback: id, stopped } }]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** By its number where `voice` is connected, else by the Sound picked, every one where none is. */
|
||||
export function soundFinishedPulses(
|
||||
instance: Instance,
|
||||
finished: readonly Finished[],
|
||||
voiceOf: PlaybackOf,
|
||||
): Pulse[] {
|
||||
return instance.interpreter.eventNodes('OnSoundFinished').flatMap((node: GraphNode) => {
|
||||
const wanted = voiceOf(instance, node);
|
||||
const sound = textOf(node.params.sound ?? '');
|
||||
return finished.flatMap(({ id, kind, asset, stopped }) =>
|
||||
kind === 'sound' && (wanted !== null ? asNumber(wanted) === id : sound === '' || sound === asset)
|
||||
? [{ instance, node, outputs: { voice: id, stopped } }]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -47,7 +47,13 @@ import { STREAM_RATE } from '../core/music/streamQueue';
|
||||
import { type CutRange, cutChannels, cutRange, fadeGain, parseCutName } from '../core/model/sound';
|
||||
import { shifterLatency } from '../core/soundfx/pitchPlayer';
|
||||
import { REVERB_MAX_DECAY } from '../core/soundfx/reverb';
|
||||
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
|
||||
import {
|
||||
type AudioSink,
|
||||
type ClipTiming,
|
||||
ENDED_KEPT,
|
||||
type EndedPlay,
|
||||
type NoteTiming,
|
||||
} from '../runtime/systems/Audio';
|
||||
import type { AcidOptions, ChipOptions, PitchOptions, StreamOptions } from './fxWorklet';
|
||||
import fxWorkletUrl from './fxWorklet.ts?worker&url';
|
||||
import {
|
||||
@@ -201,6 +207,8 @@ interface Voice {
|
||||
released: boolean;
|
||||
/** Seconds a stop that lets it ring fades it out over: a bake (#340), whose notes are one sound. */
|
||||
ringFade: number;
|
||||
/** A stop reached it, or another took its place (#373), rather than it ending by itself. */
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,8 +429,13 @@ export class SoundLibrary implements AudioSink {
|
||||
level: (id, gain, at, seconds) => this.level(id, gain, at, seconds),
|
||||
after: (at, then) => void this.after(at, then),
|
||||
closed: () => this.sweep(),
|
||||
ended: (id, name, stopped) => this.keepEnd({ id, name, stopped }),
|
||||
playBaked: (id, bake, at) => this.playBaked(id, bake, at),
|
||||
});
|
||||
/** The voices of `play` and the playings that ended lately, oldest first (#373). */
|
||||
private readonly ends: EndedPlay[] = [];
|
||||
/** The voices of `play` whose end is still to be told. */
|
||||
private readonly told = new Set<number>();
|
||||
/** 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. */
|
||||
@@ -516,7 +529,7 @@ export class SoundLibrary implements AudioSink {
|
||||
if (file === '' || this.hasFailed(file)) return 0;
|
||||
const layered = { layers: [fx.plan], params: fx.params, tempo: fx.tempo ?? DEFAULT_TEMPO };
|
||||
const stream = this.streamed(file);
|
||||
return this.begin({
|
||||
const id = this.begin({
|
||||
id: 0,
|
||||
file,
|
||||
files: [file],
|
||||
@@ -529,6 +542,8 @@ export class SoundLibrary implements AudioSink {
|
||||
bus,
|
||||
...(stream && { stream }),
|
||||
});
|
||||
this.told.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1585,6 +1600,7 @@ export class SoundLibrary implements AudioSink {
|
||||
vocoders: 0,
|
||||
released: false,
|
||||
ringFade: BAKE_LET_RING,
|
||||
stopped: false,
|
||||
};
|
||||
const going = new Set(voice.sources);
|
||||
voice.finish = (source) => {
|
||||
@@ -1904,8 +1920,8 @@ export class SoundLibrary implements AudioSink {
|
||||
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the
|
||||
// bytes were in flight has to win, or a sound the run already ended would start afterwards.
|
||||
const silent = buffers.every((buffer) => !buffer) && !start.fx.layers.some(hasChip);
|
||||
if (silent || waiting.stopped) return;
|
||||
if (this.cancelsFor(start.file, start.owner) !== askedAt) return;
|
||||
const cancelled = waiting.stopped || this.cancelsFor(start.file, start.owner) !== askedAt;
|
||||
if (silent || cancelled) return this.voiceEnded(id, start.file, true);
|
||||
const started: Start = { ...asked, fx: waiting.fx };
|
||||
if (stream?.failed) delete started.stream;
|
||||
this.start(started);
|
||||
@@ -1999,7 +2015,10 @@ export class SoundLibrary implements AudioSink {
|
||||
stopVoice(id: number, letRing = false): void {
|
||||
this.sequences.stopId(id, letRing);
|
||||
const waiting = this.pending.find((other) => other.id === id);
|
||||
if (waiting) waiting.stopped = true;
|
||||
if (waiting) {
|
||||
waiting.stopped = true;
|
||||
this.voiceEnded(id, waiting.file, true);
|
||||
}
|
||||
this.silence(
|
||||
this.voices.filter((voice) => voice.id === id),
|
||||
letRing,
|
||||
@@ -2381,6 +2400,7 @@ export class SoundLibrary implements AudioSink {
|
||||
private silence(going: Voice[], letRing = false): void {
|
||||
this.stopped += going.filter((voice) => !(letRing && voice.ringing)).length;
|
||||
for (const voice of going) {
|
||||
voice.stopped = true;
|
||||
if (letRing && voice.ringFade > 0) {
|
||||
this.cut(voice, voice.ringFade);
|
||||
continue;
|
||||
@@ -2409,6 +2429,7 @@ export class SoundLibrary implements AudioSink {
|
||||
for (const voice of [...older.filter((v) => v.released), ...older.filter((v) => !v.released)]) {
|
||||
if (count <= MAX_VOCODERS) return;
|
||||
count -= vocodersOf(voice);
|
||||
voice.stopped = true;
|
||||
this.cut(voice);
|
||||
}
|
||||
}
|
||||
@@ -2492,6 +2513,24 @@ export class SoundLibrary implements AudioSink {
|
||||
voice.quiet = null;
|
||||
this.voices = this.voices.filter((other) => other !== voice);
|
||||
if (voice.bus !== '') this.sweep();
|
||||
this.voiceEnded(voice.id, voice.file, voice.stopped);
|
||||
}
|
||||
|
||||
/** Keeps an end for `endings` (#373), letting the oldest go past `ENDED_KEPT`. */
|
||||
private keepEnd(end: EndedPlay): void {
|
||||
this.ends.push(end);
|
||||
if (this.ends.length > ENDED_KEPT) this.ends.shift();
|
||||
}
|
||||
|
||||
/** The end of a voice: kept where `play` started it, and only once. */
|
||||
private voiceEnded(id: number, name: string, stopped: boolean): void {
|
||||
if (this.told.delete(id)) this.keepEnd({ id, name, stopped });
|
||||
}
|
||||
|
||||
endings(): readonly EndedPlay[] {
|
||||
// A playing heard to its end goes once it is looked for.
|
||||
this.sequences.current();
|
||||
return this.ends;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2626,6 +2665,7 @@ export class SoundLibrary implements AudioSink {
|
||||
vocoders: wiring.vocoders,
|
||||
released: false,
|
||||
ringFade: 0,
|
||||
stopped: false,
|
||||
};
|
||||
const going = new Set(sources);
|
||||
voice.finish = (source) => {
|
||||
|
||||
@@ -90,4 +90,28 @@ describe('AudioSwitch (#423)', () => {
|
||||
const there = audio.playSequence('song:s', {} as never, '');
|
||||
expect(audio.landOn(there, () => 0)).toBe(0.5);
|
||||
});
|
||||
it('tells what ended in both sinks, and asks each voice of the one that numbered it (#373)', () => {
|
||||
const live = {
|
||||
...sinkFrom(1),
|
||||
endings: () => [{ id: 1, name: 'music', stopped: false }],
|
||||
playing: (id: number) => id === 2,
|
||||
};
|
||||
const aside = {
|
||||
...sinkFrom(DIVERTED_IDS),
|
||||
endings: () => [{ id: DIVERTED_IDS, name: 'hit', stopped: true }],
|
||||
playing: (id: number) => id === DIVERTED_IDS + 1,
|
||||
};
|
||||
const audio = new AudioSwitch(live);
|
||||
expect(audio.endings().map((end) => end.id)).toEqual([1]);
|
||||
audio.divert(aside);
|
||||
expect(audio.endings().map((end) => end.id)).toEqual([1, DIVERTED_IDS]);
|
||||
expect([2, 3, DIVERTED_IDS, DIVERTED_IDS + 1].map((id) => audio.playing(id))).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
audio.restore();
|
||||
expect(audio.playing(DIVERTED_IDS + 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -104,7 +104,7 @@ describe('the order rule', () => {
|
||||
const events = BUILT_IN_NODES.filter(
|
||||
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
|
||||
);
|
||||
expect(events).toHaveLength(23);
|
||||
expect(events).toHaveLength(25);
|
||||
for (const def of events) {
|
||||
expect(
|
||||
def.params.map((p) => p.key),
|
||||
|
||||
@@ -2586,6 +2586,27 @@ const CASES: Record<string, Case[]> = {
|
||||
outputs: { bar: 0, beat: 0, fraction: 0, seconds: 0, playing: false },
|
||||
},
|
||||
],
|
||||
MusicPlaying: [
|
||||
{
|
||||
name: 'answers whether the playing is still to be heard (#373)',
|
||||
inputs: { playback: 4 },
|
||||
outputs: { playing: true },
|
||||
},
|
||||
{
|
||||
name: 'off for one that has ended, and where "playback" is not connected',
|
||||
inputs: { playback: 5 },
|
||||
outputs: { playing: false },
|
||||
},
|
||||
{ name: 'unconnected is 0, which nothing plays under', inputs: {}, outputs: { playing: false } },
|
||||
],
|
||||
SoundPlaying: [
|
||||
{
|
||||
name: 'answers whether the voice is still to be heard (#373)',
|
||||
inputs: { voice: 6 },
|
||||
outputs: { playing: true },
|
||||
},
|
||||
{ name: 'off for a voice that has ended', inputs: { voice: 7 }, outputs: { playing: false } },
|
||||
],
|
||||
VideoTime: [
|
||||
{
|
||||
name: 'answers where the video of the object stands (#410)',
|
||||
@@ -2688,6 +2709,8 @@ const EVENT_NODES: Record<string, string> = {
|
||||
OnBeat: 'music-events.test.ts',
|
||||
OnBar: 'music-events.test.ts',
|
||||
OnSongMarker: 'music-events.test.ts',
|
||||
OnMusicFinished: 'played-ends.test.ts',
|
||||
OnSoundFinished: 'played-ends.test.ts',
|
||||
OnStateEntered: 'state-machine.test.ts',
|
||||
OnStateExited: 'state-machine.test.ts',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* A game hears of the end of what it played (#373): a playing of a pattern or song tells its sink
|
||||
* once nothing of it is left, the sink keeps it, and On music finished and On sound finished run for
|
||||
* what the run itself played – once, with "stopped" where a stop ended it.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, CreateSoundCommand } from '../../src/core/commands/assetCommands';
|
||||
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { Playbacks, type SequencePlay } from '../../src/core/music/playbacks';
|
||||
import { INTERVAL } from '../../src/core/music/Scheduler';
|
||||
import { GameRun } from '../../src/runtime/GameRun';
|
||||
import { loadPhysics, readyPhysics } from '../../src/runtime/physics/engine';
|
||||
import { type AudioSink, type EndedPlay, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
||||
import { Input } from '../../src/runtime/systems/Input';
|
||||
import { PlayedEnds } from '../../src/runtime/systems/playedEnds';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
|
||||
|
||||
const Q = 480;
|
||||
|
||||
/** An audio clock moved by hand, its notes over as soon as they are struck; what ends is kept. */
|
||||
function fakeHost() {
|
||||
const host = {
|
||||
time: 0,
|
||||
pumps: new Set<() => void>(),
|
||||
loading: [] as (() => void)[],
|
||||
timers: [] as { at: number; then: () => void }[],
|
||||
ended: [] as EndedPlay[],
|
||||
};
|
||||
const playbacks = new Playbacks({
|
||||
now: () => host.time,
|
||||
latency: () => 0,
|
||||
playing: () => false,
|
||||
stop: () => {},
|
||||
wake: (pump) => {
|
||||
host.pumps.add(pump);
|
||||
return () => host.pumps.delete(pump);
|
||||
},
|
||||
prepare: () => new Promise<void>((resolve) => host.loading.push(resolve)),
|
||||
level: () => {},
|
||||
after: (at, then) => host.timers.push({ at, then }),
|
||||
ended: (id, name, stopped) => host.ended.push({ id, name, stopped }),
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
/** A bar of four quarters at 120 BPM: two seconds long. */
|
||||
function bar(loop: boolean): SequencePlay {
|
||||
let next = 100;
|
||||
return {
|
||||
sequence: {
|
||||
notes: [0, 1, 2, 3].map((i) => ({
|
||||
tick: i * Q,
|
||||
length: Q / 2,
|
||||
note: 60 + i,
|
||||
velocity: 1,
|
||||
instrument: 'p',
|
||||
})),
|
||||
},
|
||||
tempo: 120,
|
||||
length: 4 * Q,
|
||||
loop: loop ? { start: 0, end: 4 * Q } : null,
|
||||
needs: { layers: [], files: [] },
|
||||
strike: () => next++,
|
||||
};
|
||||
}
|
||||
|
||||
describe('the end of a playing', () => {
|
||||
it('is told once it is played to its end, once, not stopped', async () => {
|
||||
const { host, playbacks, run, loaded } = fakeHost();
|
||||
playbacks.start(7, 'pattern:a', bar(false), '');
|
||||
await loaded();
|
||||
// Heard from 0.05 s on, so over at 2.05 s; its last note is long gone and the rest planned by then.
|
||||
run(2);
|
||||
playbacks.current();
|
||||
expect(host.ended).toEqual([]);
|
||||
run(0.1);
|
||||
playbacks.current();
|
||||
playbacks.current();
|
||||
expect(host.ended).toEqual([{ id: 7, name: 'pattern:a', stopped: false }]);
|
||||
});
|
||||
|
||||
it('with a loop never by itself, only by a stop – after its fade out', async () => {
|
||||
const { host, playbacks, run, loaded } = fakeHost();
|
||||
playbacks.start(7, 'song:a', bar(true), '');
|
||||
await loaded();
|
||||
run(9);
|
||||
playbacks.current();
|
||||
expect(host.ended).toEqual([]);
|
||||
playbacks.stopNamed('song:a', false, 1);
|
||||
run(0.9);
|
||||
expect(host.ended).toEqual([]);
|
||||
run(0.2);
|
||||
expect(host.ended).toEqual([{ id: 7, name: 'song:a', stopped: true }]);
|
||||
});
|
||||
|
||||
it('stopped while its files load, by its owner, it is told as stopped', () => {
|
||||
const { host, playbacks } = fakeHost();
|
||||
playbacks.start(7, 'pattern:a', bar(false), 'logic');
|
||||
playbacks.stopOwner('logic');
|
||||
expect(host.ended).toEqual([{ id: 7, name: 'pattern:a', stopped: true }]);
|
||||
});
|
||||
|
||||
it('of a switch only once the number is over, by the song heard last (#338)', async () => {
|
||||
const { host, playbacks, run, loaded } = fakeHost();
|
||||
playbacks.start(7, 'song:a', bar(true), '');
|
||||
await loaded();
|
||||
run(1);
|
||||
playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 0);
|
||||
await loaded();
|
||||
run(1);
|
||||
expect(playbacks.named(7).map((p) => p.name)).toEqual(['song:b']);
|
||||
expect(host.ended).toEqual([]);
|
||||
playbacks.stopId(7);
|
||||
expect(host.ended).toEqual([{ id: 7, name: 'song:b', stopped: true }]);
|
||||
});
|
||||
|
||||
it('of a switch stopped while the two cross, by the one it switched to', async () => {
|
||||
const { host, playbacks, run, loaded } = fakeHost();
|
||||
playbacks.start(7, 'song:a', bar(true), '');
|
||||
await loaded();
|
||||
run(1);
|
||||
playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 2);
|
||||
await loaded();
|
||||
run(0.5);
|
||||
expect(playbacks.named(7).map((p) => p.name)).toEqual(['song:a', 'song:b']);
|
||||
playbacks.stopId(7);
|
||||
expect(host.ended).toEqual([{ id: 7, name: 'song:b', stopped: true }]);
|
||||
});
|
||||
|
||||
it('of a switch stopped before it is heard, by the song heard then', async () => {
|
||||
const { host, playbacks, run, loaded } = fakeHost();
|
||||
playbacks.start(7, 'song:a', bar(true), '');
|
||||
await loaded();
|
||||
run(1);
|
||||
playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 0);
|
||||
playbacks.stopId(7);
|
||||
expect(host.ended).toEqual([{ id: 7, name: 'song:a', stopped: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayedEnds', () => {
|
||||
it('tells only what the run played, each once, a playing by the song or pattern it ended as', () => {
|
||||
const ends = new PlayedEnds();
|
||||
ends.sound(3, 'blip');
|
||||
ends.playing(4);
|
||||
ends.playing(5);
|
||||
ends.playing(0);
|
||||
const log: EndedPlay[] = [
|
||||
{ id: 2, name: 'x.ogg', stopped: false },
|
||||
{ id: 3, name: 'b.ogg', stopped: true },
|
||||
{ id: 4, name: 'song:boss', stopped: false },
|
||||
{ id: 5, name: 'pattern:riff', stopped: true },
|
||||
];
|
||||
expect(ends.follow(log)).toEqual([
|
||||
{ id: 3, kind: 'sound', asset: 'blip', stopped: true },
|
||||
{ id: 4, kind: 'song', asset: 'boss', stopped: false },
|
||||
{ id: 5, kind: 'pattern', asset: 'riff', stopped: true },
|
||||
]);
|
||||
expect(ends.follow(log)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A sink that plays its patterns and songs on real playings, on the hand-moved clock, and makes a
|
||||
* voice of each sound that lasts until `endVoice` or a stop.
|
||||
*/
|
||||
function playingSink() {
|
||||
const fake = fakeHost();
|
||||
let next = 1;
|
||||
const voices = new Map<number, string>();
|
||||
const endVoice = (id: number, stopped: boolean) => {
|
||||
const file = voices.get(id);
|
||||
if (file === undefined) return;
|
||||
voices.delete(id);
|
||||
fake.host.ended.push({ id, name: file, stopped });
|
||||
};
|
||||
const audio: AudioSink = {
|
||||
...SILENT_AUDIO,
|
||||
play: (file) => {
|
||||
voices.set(next, file);
|
||||
return next++;
|
||||
},
|
||||
playSequence: (name, play, owner) => {
|
||||
const id = next++;
|
||||
fake.playbacks.start(id, name, play, owner);
|
||||
return id;
|
||||
},
|
||||
stopSequence: (name, letRing, fade) => fake.playbacks.stopNamed(name, letRing, fade),
|
||||
stopPlayback: (id, fade) => fake.playbacks.stopId(id, false, fade),
|
||||
stop: (file) => {
|
||||
if (file === '') fake.playbacks.stopNamed('');
|
||||
for (const [id, of] of voices) if (file === '' || of === file) endVoice(id, true);
|
||||
},
|
||||
stopVoice: (id) => endVoice(id, true),
|
||||
stopOwner: (owner) => fake.playbacks.stopOwner(owner),
|
||||
playing: (id) => voices.has(id) || fake.playbacks.has(id),
|
||||
endings: () => (fake.playbacks.current(), fake.host.ended),
|
||||
};
|
||||
return { ...fake, audio, endVoice };
|
||||
}
|
||||
|
||||
/** A song and a pattern, each played without a loop on start, and a behavior that logs what ends. */
|
||||
function listening() {
|
||||
const f = makeSpriteScene('Box');
|
||||
const create = (type: 'song' | 'pattern', name: string) => {
|
||||
const command = new CreateAssetCommand(type, name);
|
||||
f.stack.execute(command);
|
||||
return command.asset.id;
|
||||
};
|
||||
const song = create('song', 'Theme');
|
||||
const pattern = create('pattern', 'Riff');
|
||||
const blip = new CreateSoundCommand('Blip', `${'a1'.repeat(32)}.wav`);
|
||||
f.stack.execute(blip);
|
||||
const behavior = makeBehavior(f, 'Logic');
|
||||
place(f, behavior.id, { x: 0, y: 0 });
|
||||
const edit = graphEditor(f, behavior);
|
||||
/** An event that logs `prefix`, its number and "stopped", run together. */
|
||||
const logs = (type: string, params: Record<string, string>, prefix: string, number: string) => {
|
||||
const event = edit.addNode(type, params);
|
||||
const join = edit.addNode('Join');
|
||||
const first = edit.addNode('Join', { a: prefix });
|
||||
edit.connect(event, number, first, 'b');
|
||||
edit.connect(first, 'text', join, 'a');
|
||||
edit.connect(event, 'stopped', join, 'b');
|
||||
const log = edit.addNode('Log');
|
||||
edit.connect(event, 'exec', log, 'exec');
|
||||
edit.connect(join, 'text', log, 'message');
|
||||
return event;
|
||||
};
|
||||
return { ...f, song, pattern, sound: blip.asset.id, edit, logs };
|
||||
}
|
||||
|
||||
describe('On music finished', () => {
|
||||
it('runs once when a song without a loop is over, not stopped; one with a loop never by itself', async () => {
|
||||
const f = listening();
|
||||
const start = f.edit.addNode('OnStart');
|
||||
const once = f.edit.addNode('PlaySong', { song: f.song, loop: false });
|
||||
const round = f.edit.addNode('PlaySong', { song: f.song, loop: true });
|
||||
f.edit.connect(start, 'exec', once, 'exec');
|
||||
f.edit.connect(once, 'exec', round, 'exec');
|
||||
f.logs('OnMusicFinished', {}, 'end ', 'playback');
|
||||
const { audio, playbacks, run, loaded } = playingSink();
|
||||
const { world, frame } = startWorld(f, { behaviors: true, audio });
|
||||
frame();
|
||||
const [first, second] = playbacks.current().map((p) => p.id);
|
||||
await loaded();
|
||||
const length = playbacks.current()[0]!.time.length / (2 * Q);
|
||||
run(length - 0.5);
|
||||
frame();
|
||||
expect(world.log).toEqual([]);
|
||||
run(1);
|
||||
frame();
|
||||
frame();
|
||||
expect(world.log).toEqual([`end ${first}false`]);
|
||||
run(3 * length);
|
||||
frame();
|
||||
expect(world.log).toHaveLength(1);
|
||||
expect(playbacks.has(second!)).toBe(true);
|
||||
});
|
||||
|
||||
it('runs with "stopped" after Stop song, and hears only what its Song, Pattern or "playback" picks', async () => {
|
||||
const f = listening();
|
||||
const start = f.edit.addNode('OnStart');
|
||||
const song = f.edit.addNode('PlaySong', { song: f.song, loop: true });
|
||||
const again = f.edit.addNode('PlaySong', { song: f.song, loop: true });
|
||||
f.edit.connect(start, 'exec', song, 'exec');
|
||||
f.edit.connect(song, 'exec', again, 'exec');
|
||||
f.logs('OnMusicFinished', { pattern: f.pattern }, 'pattern ', 'playback');
|
||||
f.logs('OnMusicFinished', { song: f.song }, 'song ', 'playback');
|
||||
// The sink numbers the two playings 1 and 2.
|
||||
const picked = f.logs('OnMusicFinished', {}, 'picked ', 'playback');
|
||||
f.edit.connect(f.edit.addNode('Number', { value: 1 }), 'value', picked, 'playback');
|
||||
const other = f.logs('OnMusicFinished', {}, 'other ', 'playback');
|
||||
f.edit.connect(f.edit.addNode('Number', { value: 2 }), 'value', other, 'playback');
|
||||
const key = f.edit.addNode('OnKeyDown', { key: 's' });
|
||||
const stop = f.edit.addNode('StopSong', { song: f.song });
|
||||
f.edit.connect(key, 'exec', stop, 'exec');
|
||||
const { audio, playbacks, loaded } = playingSink();
|
||||
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
|
||||
frame();
|
||||
expect(playbacks.current().map((p) => p.id)).toEqual([1, 2]);
|
||||
await loaded();
|
||||
input.press('s');
|
||||
frame();
|
||||
frame();
|
||||
expect(world.log).toEqual(['song 1true', 'song 2true', 'picked 1true', 'other 2true']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('On sound finished', () => {
|
||||
it('runs once for a sound this game played, by its Sound or its "voice", with "stopped" where stopped', () => {
|
||||
const f = listening();
|
||||
const start = f.edit.addNode('OnStart');
|
||||
const first = f.edit.addNode('PlaySound', { sound: f.sound });
|
||||
const second = f.edit.addNode('PlaySound', { sound: f.sound });
|
||||
f.edit.connect(start, 'exec', first, 'exec');
|
||||
f.edit.connect(first, 'exec', second, 'exec');
|
||||
f.logs('OnSoundFinished', {}, 'any ', 'voice');
|
||||
f.logs('OnSoundFinished', { sound: f.sound }, 'blip ', 'voice');
|
||||
f.logs('OnSoundFinished', { sound: 'other' }, 'other ', 'voice');
|
||||
const picked = f.logs('OnSoundFinished', {}, 'second ', 'voice');
|
||||
f.edit.connect(f.edit.addNode('Number', { value: 2 }), 'value', picked, 'voice');
|
||||
f.logs('OnMusicFinished', {}, 'music ', 'playback');
|
||||
const key = f.edit.addNode('OnKeyDown', { key: 's' });
|
||||
const stop = f.edit.addNode('StopSound', { sound: f.sound });
|
||||
f.edit.connect(key, 'exec', stop, 'exec');
|
||||
const { audio, endVoice } = playingSink();
|
||||
const { world, frame, input } = startWorld(f, { behaviors: true, audio });
|
||||
frame();
|
||||
expect([audio.playing!(1), audio.playing!(2)]).toEqual([true, true]);
|
||||
endVoice(1, false);
|
||||
frame();
|
||||
frame();
|
||||
expect(world.log).toEqual(['any 1false', 'blip 1false']);
|
||||
input.press('s');
|
||||
frame();
|
||||
frame();
|
||||
expect(world.log.slice(2)).toEqual(['any 2true', 'blip 2true', 'second 2true']);
|
||||
});
|
||||
|
||||
it('hears nothing a run did not play itself', () => {
|
||||
const f = listening();
|
||||
f.logs('OnSoundFinished', {}, 'any ', 'voice');
|
||||
f.logs('OnMusicFinished', {}, 'music ', 'playback');
|
||||
const { audio, playbacks } = playingSink();
|
||||
audio.play('a.wav', 1, false, 'sound-preview');
|
||||
playbacks.start(9, `song:${f.song}`, bar(false), 'pattern-preview');
|
||||
playbacks.stopId(9);
|
||||
audio.stopVoice(1);
|
||||
const { world, frame } = startWorld(f, { behaviors: true, audio });
|
||||
frame();
|
||||
frame();
|
||||
expect(world.log).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a run', () => {
|
||||
beforeAll(async () => {
|
||||
await loadPhysics();
|
||||
});
|
||||
|
||||
it('hears a sound it played in one scene end in the next', () => {
|
||||
const f = listening();
|
||||
const start = f.edit.addNode('OnStart');
|
||||
const play = f.edit.addNode('PlaySound', { sound: f.sound, detached: true });
|
||||
const load = f.edit.addNode('LoadScene');
|
||||
f.edit.connect(start, 'exec', play, 'exec');
|
||||
f.edit.connect(play, 'exec', load, 'exec');
|
||||
const next = new CreateAssetCommand('scene', 'Level 2');
|
||||
f.stack.execute(next);
|
||||
load.params.scene = next.asset.id;
|
||||
const hearing = makeBehavior(f, 'Ears');
|
||||
f.stack.execute(new PlaceAssetCommand(next.asset.id, hearing.id, { x: 0, y: 0 }, ''));
|
||||
const ears = graphEditor(f, hearing);
|
||||
const log = ears.addNode('Log', { message: 'heard' });
|
||||
ears.connect(ears.addNode('OnSoundFinished', { sound: f.sound }), 'exec', log, 'exec');
|
||||
const { audio, endVoice } = playingSink();
|
||||
const run = GameRun.start(f.project, f.sceneId, new Input(), {
|
||||
audio,
|
||||
physics: readyPhysics()!,
|
||||
clock: { scale: 1 },
|
||||
saves: { read: () => ({}), write: () => {} },
|
||||
});
|
||||
expect(run.tick(1 / 60, 1 / 60)).toBe('scene');
|
||||
run.tick(1 / 60, 1 / 60);
|
||||
endVoice(1, false);
|
||||
run.tick(1 / 60, 1 / 60);
|
||||
expect(run.world.log).toEqual(['heard']);
|
||||
});
|
||||
});
|
||||
@@ -455,6 +455,8 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
[...ctx.buses].reverse().find((change) => change.bus === bus && change.volume !== undefined)?.volume ??
|
||||
1,
|
||||
songPosition: () => null,
|
||||
// An even number plays, an odd one has ended, so a test can ask for either.
|
||||
playing: (id) => id > 0 && id % 2 === 0,
|
||||
loadScene: (sceneId) => ctx.loaded.push(sceneId),
|
||||
spawn: () => null,
|
||||
addJoint: () => null,
|
||||
|
||||
@@ -1963,6 +1963,9 @@ describe('the patterns and songs a game plays (#336)', () => {
|
||||
'',
|
||||
);
|
||||
await settle();
|
||||
// Planned to its end, but not yet played to it (#373): 10 ticks from 50 ms on.
|
||||
expect(library.playbacks()).toHaveLength(1);
|
||||
(library as unknown as { context: { currentTime: number } }).context.currentTime = 0.1;
|
||||
expect(() => library.playbacks()).not.toThrow();
|
||||
expect(library.playbacks()).toEqual([]);
|
||||
clockTo();
|
||||
@@ -3472,3 +3475,79 @@ describe('what has ended is let go', () => {
|
||||
expect(worklets).toHaveLength(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what ended, for a game to hear (#373)', () => {
|
||||
it('keeps the end of a voice of play once its sound is over, once, not stopped', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
const voice = await play(library);
|
||||
expect(library.endings()).toEqual([]);
|
||||
sources[0]!.onended!();
|
||||
sources[0]!.onended!();
|
||||
expect(library.endings()).toEqual([{ id: voice, name: FILE, stopped: false }]);
|
||||
});
|
||||
|
||||
it('with an echo only once the echo has faded', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
const echo: FxStage = {
|
||||
kind: 'delay',
|
||||
time: 0.25,
|
||||
feedback: 0,
|
||||
damping: 20000,
|
||||
mix: 0.5,
|
||||
pingPong: false,
|
||||
};
|
||||
const voice = await play(library, FILE, {}, { pitch: 0, stages: [echo] });
|
||||
sources[0]!.onended!();
|
||||
clockTo(0.24);
|
||||
expect(library.endings()).toEqual([]);
|
||||
clockTo(0.25);
|
||||
expect(library.endings()).toEqual([{ id: voice, name: FILE, stopped: false }]);
|
||||
});
|
||||
|
||||
it('a stopped one at once, stopped – also one still waiting on its bytes, told only once', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
const first = await play(library);
|
||||
const waiting = await play(library, OTHER, { hold: true });
|
||||
library.stopVoice(first);
|
||||
library.stopVoice(waiting);
|
||||
const told = [
|
||||
{ id: first, name: FILE, stopped: true },
|
||||
{ id: waiting, name: OTHER, stopped: true },
|
||||
];
|
||||
expect(library.endings()).toEqual(told);
|
||||
await arrive();
|
||||
clockTo();
|
||||
expect(library.endings()).toEqual(told);
|
||||
});
|
||||
|
||||
it('one whose owner went while its bytes were on the way once they come, stopped', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
const voice = await play(library, FILE, { hold: true, owner: 'logic' });
|
||||
library.stopOwner('logic');
|
||||
expect(library.endings()).toEqual([]);
|
||||
await arrive();
|
||||
expect(library.endings()).toEqual([{ id: voice, name: FILE, stopped: true }]);
|
||||
expect(library.stats().playing).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps no end of a note, and the end of a playing by the name it plays as', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
library.playNote('instrument:i', [{ pitch: [], stages: [] }], {}, 1, '', 0);
|
||||
const id = library.playSequence(
|
||||
'song:s',
|
||||
{
|
||||
sequence: { notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i' }] },
|
||||
tempo: 120,
|
||||
length: 1920,
|
||||
loop: { start: 0, end: 1920 },
|
||||
needs: { layers: [], files: [] },
|
||||
strike: () => 0,
|
||||
},
|
||||
'',
|
||||
);
|
||||
await settle();
|
||||
library.stop('');
|
||||
clockTo();
|
||||
expect(library.endings()).toEqual([{ id, name: 'song:s', stopped: true }]);
|
||||
});
|
||||
});
|
||||
+67
-2
@@ -1,4 +1,4 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
import { edge, node, port as readPort, variable, written } from '../lib/builders.mjs';
|
||||
import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||
import { sampleZoom } from './lib/zoomTransition.mjs';
|
||||
|
||||
@@ -1009,6 +1009,18 @@ export async function song(t) {
|
||||
node('sg65-r', 'KeyIsDown', 0, 2000, { key: 'r' }),
|
||||
node('sg65-rif', 'Branch', 1300, 1800),
|
||||
node('sg65-let', 'ReleaseSongLane', 1560, 1800, { track: tracks[0], lane: 'volume', fade: 0.5 }),
|
||||
// 65k6 (#373): once the stinger is over, the pattern once more, at its own tempo. The
|
||||
// stinger's number is kept in a variable, as no event reads an output another one left; heard
|
||||
// by it, the one started after is not heard. And the end of the song, logged.
|
||||
node('sg65-keep', 'Variables', 1300, 1600, {}, [written('sting', 'number')]),
|
||||
node('sg65-kept', 'Variables', 1040, 1800, {}, [readPort('sting', 'number')]),
|
||||
node('sg65-fin', 'OnMusicFinished', 1300, 1400),
|
||||
node('sg65-again', 'PlayPattern', 1560, 1400, { pattern: PATTERN, loop: false }),
|
||||
node('sg65-jf', 'Join', 1560, 1600, { a: 'finished stinger ' }),
|
||||
node('sg65-logf', 'Log', 1820, 1400),
|
||||
node('sg65-fins', 'OnMusicFinished', 0, 2200, { song: id }),
|
||||
node('sg65-js', 'Join', 260, 2200, { a: 'finished song ' }),
|
||||
node('sg65-logs', 'Log', 520, 2200),
|
||||
],
|
||||
edges: [
|
||||
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
|
||||
@@ -1048,9 +1060,24 @@ export async function song(t) {
|
||||
edge('sg65-c18', 'sg65-r', 'down', 'sg65-rif', 'condition'),
|
||||
edge('sg65-c19', 'sg65-rif', 'then', 'sg65-let', 'exec'),
|
||||
edge('sg65-c20', 'sg65-cue', 'playback', 'sg65-let', 'playback'),
|
||||
edge('sg65-f0', 'sg65-sting', 'exec', 'sg65-keep', 'exec'),
|
||||
edge('sg65-f00', 'sg65-sting', 'playback', 'sg65-keep', 'sting'),
|
||||
edge('sg65-f1', 'sg65-kept', 'sting', 'sg65-fin', 'playback'),
|
||||
edge('sg65-f2', 'sg65-fin', 'exec', 'sg65-again', 'exec'),
|
||||
edge('sg65-f3', 'sg65-again', 'exec', 'sg65-logf', 'exec'),
|
||||
edge('sg65-f4', 'sg65-fin', 'stopped', 'sg65-jf', 'b'),
|
||||
edge('sg65-f5', 'sg65-jf', 'text', 'sg65-logf', 'message'),
|
||||
edge('sg65-f6', 'sg65-fins', 'exec', 'sg65-logs', 'exec'),
|
||||
edge('sg65-f7', 'sg65-fins', 'stopped', 'sg65-js', 'b'),
|
||||
edge('sg65-f8', 'sg65-js', 'text', 'sg65-logs', 'message'),
|
||||
],
|
||||
},
|
||||
});
|
||||
await mutate(
|
||||
`__gm.state.project.assets.find((a) => a.id === 'sg65-logic').variables = ${JSON.stringify([
|
||||
variable('sting', 'number', 0),
|
||||
])};`,
|
||||
);
|
||||
const playbacks = () => json('__gm.sound.playbacks()');
|
||||
const heardFrom = t.consoleLog.length;
|
||||
await play();
|
||||
@@ -1233,7 +1260,18 @@ export async function song(t) {
|
||||
const song = stung.find((p) => p.name === `song:${id}`);
|
||||
const stinger = stung.find((p) => p.name === `pattern:${PATTERN}`);
|
||||
const beatsIn = stinger && song ? (stinger.startedAt - song.startedAt) / 0.5 : NaN;
|
||||
const alone = await waitFor(playbacks, (list) => list.length === 1, 60);
|
||||
// Every pattern playing seen on the way, the one 65k6 starts after the stinger among them.
|
||||
const patterns = new Map();
|
||||
const alone = await waitFor(
|
||||
async () => {
|
||||
const list = await playbacks();
|
||||
for (const p of list)
|
||||
if (p.name === `pattern:${PATTERN}` && p.startedAt !== null) patterns.set(p.id, p);
|
||||
return list;
|
||||
},
|
||||
(list) => list.length === 1 && patterns.size >= 2,
|
||||
80,
|
||||
);
|
||||
check(
|
||||
'a pattern synced to the song plays once on its next beat, to the sample, at the tempo of the song',
|
||||
Math.abs(beatsIn - Math.round(beatsIn)) < 1e-6 &&
|
||||
@@ -1244,6 +1282,28 @@ export async function song(t) {
|
||||
alone[0].name === `song:${id}`,
|
||||
JSON.stringify({ pressedAt, song: song?.startedAt, stinger, beatsIn, alone: alone.map((p) => p.name) }),
|
||||
);
|
||||
// 65k6. The end of a playing (#373): On music finished, hearing the stinger by its number, runs
|
||||
// once it is over, its "stopped" off, and Play pattern starts the pattern once more – after
|
||||
// the stinger's 2 s, within a frame and the lead of the scheduler. That one it does not hear.
|
||||
const finishedLines = (what) =>
|
||||
t.consoleLog.slice(heardFrom).filter((text) => text.includes(`finished ${what}`));
|
||||
// P held over two beats plays two stingers; the listener hears the last, by the number it left.
|
||||
const lastStinger = [...patterns.values()].filter((p) => p.startedAt < stinger.startedAt + 1.5).at(-1);
|
||||
const chained = [...patterns.values()].filter((p) => p.startedAt > lastStinger.startedAt + 1);
|
||||
const chainedAfter = chained[0] ? chained[0].startedAt - lastStinger.startedAt : NaN;
|
||||
check(
|
||||
'On music finished runs once the stinger is over, not stopped, and starts the pattern again right after it',
|
||||
finishedLines('stinger').length === 1 &&
|
||||
finishedLines('stinger')[0].endsWith('finished stinger false') &&
|
||||
chained.length === 1 &&
|
||||
chainedAfter >= 2 &&
|
||||
chainedAfter < 2.4,
|
||||
JSON.stringify({
|
||||
lines: finishedLines('stinger'),
|
||||
patterns: [...patterns.values()].map((p) => ({ id: p.id, startedAt: p.startedAt })),
|
||||
chainedAfter,
|
||||
}),
|
||||
);
|
||||
// 65k5. A game takes a lane over (#338): L held over a beat holds the volume lane of the sound's
|
||||
// track at -40 dB from the next bar line on, its curve (-24 to 0 dB each round) no longer
|
||||
// heard; R held lets it go, back to the curve over half a second. Read from the level of the
|
||||
@@ -1318,6 +1378,11 @@ export async function song(t) {
|
||||
leftBuses.length === 0,
|
||||
JSON.stringify({ fading: fading.map((g) => g.fading), fadedOut, leftBuses }),
|
||||
);
|
||||
check(
|
||||
'On music finished runs once for the song after its fade out, "stopped" on – not for the switch 65k3 made',
|
||||
finishedLines('song').length === 1 && finishedLines('song')[0].endsWith('finished song true'),
|
||||
JSON.stringify(finishedLines('song')),
|
||||
);
|
||||
await mutate(
|
||||
`delete __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(PATTERN)}).props.tempo;`,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user