Let a game switch songs and turn their tracks on the beat (#338)

A game could start and stop a song, but not steer one while it plays.
The battle theme could not take over on the next bar, a song could not
jump to a marker, and a track could not fall away when the player
hides. This is stage 3 of 5 in #338.

- Quantize: now, next beat, next bar (the default) or next marker. The
  spot is the next one as the song is heard, and the change is planned
  on the audio clock to the sample, round the loop as the ruler counts.
- Set song track turns one track in dB on top of the song's level, over
  a fade, on the bus input before its FX and sends.
- Switch song plays another song, or the same one from a marker, in
  place of the playing one. It keeps the playback number, so Stop song,
  Set song track and On beat go on reaching it.
- A playing now has a key for its buses apart from its public number.
  During a switch the old and the new song each have buses of their own.
- Play song has a "live tracks" switch. With it off a baked song plays
  its bake, and Set song track on it writes a line to the log.
- A Switch song to a marker plays the notes, as a bake only plays from
  its start.

Found on the way:

- A cut on a bar line faded over seconds in Chrome. cancelAndHoldAtTime
  holds nothing where nothing is planned later, so the ramp ran from the
  bus's last change. The turn now sets its start value on the spot. It
  had been measured at 0.52 of full level 0.2 s past the bar line.

Tests:
- Unit: 23 new cases in beats, song-play, sound-library and the node
  catalog. Counter-checks: 17 bends, all compile, all red.
- e2e 65 (atx): ALL PASSED, 73 checks. 65k3 is new. The switch landed
  three bars after the start under the same number, and the track cut
  on the next bar line with nothing in between. With the old ramp 65k3
  is red (0.53 and 0.51 between).
- Gate (atx): CHECK EXIT 0, 334 files, 5144 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-29 21:17:52 +01:00
1 parent a6a41b6b69
commit 53ec1094f4
23 files changed
+1259 -101

No files matched your search

+15 -3
View File
@@ -3,7 +3,7 @@ import type { CompositionPlayer } from '../model/composition';
import type { Mat } from '../model/matrix';
import type { Position, PropertyBag } from '../model/Properties';
import type { FxParams } from '../soundfx/nodes';
import type { HeardPosition } from '../music/beats';
import type { HeardPosition, Quantize } from '../music/beats';
import type { RecordingSettings } from '../recording/recording';
import { type ChainTransform, composeAlongChain } from '../model/walk';
import { enabledAlongChain } from '../properties/enabled';
@@ -305,8 +305,9 @@ export interface NodeContext {
* 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.
* `fx`, `fxParams` and `bus` as for `playPattern`, after its tracks. Returns its number; 0 where
* there is nothing to play.
* `fx`, `fxParams` and `bus` as for `playPattern`, after its tracks. With `live` it plays its notes
* even where it has a bake, so its tracks can be turned (#338). Returns its number; 0 where there is
* nothing to play.
*/
playSong(
songId: string,
@@ -317,7 +318,18 @@ export interface NodeContext {
fx: string,
fxParams: FxParams,
bus: string,
live: boolean,
): number;
/**
* Turns one track of a playing song (#338) to `volumeDb` on top of its own level, over `fade`
* seconds from where `quantize` lands; a playing of its bake has no tracks, which is logged.
*/
setSongTrack(playback: number, track: string, volumeDb: number, fade: number, quantize: Quantize): void;
/**
* Plays `songId` in place of a playing song (#338), `''` the same one, from the marker `marker`
* (`''` its start), where `quantize` lands: over `fade` seconds, else in a cut. It keeps its number.
*/
switchSong(playback: number, songId: string, marker: string, quantize: Quantize, fade: number): void;
/** 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. */
+6 -2
View File
@@ -33,8 +33,10 @@ export const PlaySong: NodeDef = {
'is on. "fx" sends the whole song through one sound FX, after its tracks and before "volume"; ' +
'left empty, the one the song names, and None is none. ' +
'"fx params" takes values for its parameters from a Sound FX parameters node. "playback" is the ' +
'number of this playing, for Stop song and for Set sound FX parameters; 0 where the song has ' +
'nothing to play. "bus" is the bus all of it is heard on; left empty, the one the song names.',
'number of this playing, for Stop song, Set song track, Switch song and Set sound FX parameters; ' +
'0 where the song has nothing to play. "bus" is the bus all of it is heard on; left empty, the ' +
'one the song names. A song with a bake plays the bake, one sound for all its tracks; with "live ' +
'tracks" it plays its notes instead, as the Song tab does, so Set song track can turn its tracks.',
inputs: [
exec(),
songPort(),
@@ -45,6 +47,7 @@ export const PlaySong: NodeDef = {
soundFxPort(),
fxParamsPort(),
playedBusPort(),
checkPort('live', 'live tracks', 'Live tracks', false),
],
outputs: [exec(), num('playback')],
params: [],
@@ -60,6 +63,7 @@ export const PlaySong: NodeDef = {
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
asString(inputs.bus),
asBoolean(inputs.live),
)
: 0;
return { next: 'exec', outputs: { playback } };
+110
View File
@@ -0,0 +1,110 @@
import { heldQuantize, QUANTIZE_OPTIONS } from '../../../music/beats';
import { TRACK_MAX_DB, TRACK_MIN_DB } from '../../../music/level';
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asNumber, asString } from '../../values';
import { exec, num, withDefault } from '../shared';
import { songPort } from './PlaySong';
/** Where a change to the music lands (#338): at once, or on the next beat, bar or marker of the playing. */
const quantizePort = (): PortDef => ({
key: 'quantize',
label: 'quantize',
type: 'string',
default: 'bar',
field: { key: 'quantize', label: 'Quantize', kind: 'enum', default: 'bar', options: [...QUANTIZE_OPTIONS] },
});
/** The playing a node changes: not typed in, as it is a number Play song hands out. */
const playbackPort = (): PortDef => ({ ...num('playback'), inline: false });
const trackPort = (): PortDef => ({
key: 'track',
label: 'track',
type: 'string',
field: { key: 'track', label: 'Track', kind: 'asset', default: '', assetTypes: ['song'], tracks: true },
});
const markerPort = (): PortDef => ({
key: 'marker',
label: 'marker',
type: 'string',
field: { key: 'marker', label: 'Marker', kind: 'asset', default: '', assetTypes: ['song'], markers: true },
});
const QUANTIZE =
'"quantize" says where it lands: Now, or on the next beat, bar or marker of the song as it is ' +
'heard, planned on the audio clock to the sample; Next marker in a song without markers is the ' +
'next bar.';
export const SetSongTrack: NodeDef = {
type: 'SetSongTrack',
label: 'Set song track',
category: 'action',
description:
'Turns one track of a playing song up or down, by the "playback" number Play song handed out: ' +
'"volume" in decibels on top of the level the song gives it, 0 as the song has it, -60 silent – ' +
'what goes into the track, its sound FX and its sends alike. It gets there over "fade" seconds. ' +
`${QUANTIZE} The track is picked as "Song / Track"; a track the playing song does not have is ` +
'left alone. A song played from its bake has no tracks to turn: switch on "live tracks" in Play ' +
'song, else the log says so. A Switch song to the same song keeps what was turned.',
inputs: [
exec(),
playbackPort(),
trackPort(),
withDefault(
{
...num('volume', 'volume (dB)'),
field: {
key: 'volume',
label: 'Volume (dB)',
kind: 'number',
default: 0,
min: TRACK_MIN_DB,
max: TRACK_MAX_DB,
},
},
0,
),
withDefault(num('fade'), 1),
quantizePort(),
],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
ctx.setSongTrack(
asNumber(inputs.playback),
asString(inputs.track),
asNumber(inputs.volume),
asNumber(inputs.fade),
heldQuantize(inputs.quantize),
);
return { next: 'exec' };
},
};
export const SwitchSong: NodeDef = {
type: 'SwitchSong',
label: 'Switch song',
category: 'action',
description:
'Plays another song, or the same one from a marker, in place of a playing song, by the ' +
'"playback" number Play song handed out; the playing keeps its number, so Stop song and Set ' +
'song track go on reaching it. "song" left empty is the same song, "marker" left empty its ' +
`start. ${QUANTIZE} With "fade" 0 it is a cut on that spot; else the old song falls silent over ` +
'that many seconds as the new one rises from it. The new song plays with the volume, loop, ' +
'bus, sound FX and "live tracks" of its Play song. A marker the song does not have is logged, ' +
'and nothing changes.',
inputs: [exec(), playbackPort(), songPort(), markerPort(), quantizePort(), withDefault(num('fade'), 0)],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
ctx.switchSong(
asNumber(inputs.playback),
asString(inputs.song),
asString(inputs.marker),
heldQuantize(inputs.quantize),
asNumber(inputs.fade),
);
return { next: 'exec' };
},
};
+3
View File
@@ -31,6 +31,7 @@ import { PlayPattern } from './action/PlayPattern';
import { StopPattern } from './action/StopPattern';
import { PlaySong } from './action/PlaySong';
import { StopSong } from './action/StopSong';
import { SetSongTrack, SwitchSong } from './action/songSync';
import { SetZ } from './action/SetZ';
import { Spawn } from './action/Spawn';
import { StopGame } from './action/StopGame';
@@ -328,6 +329,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
StopPattern,
PlaySong,
StopSong,
SetSongTrack,
SwitchSong,
SongPosition,
SetBusVolume,
BusVolume,
+6 -3
View File
@@ -163,11 +163,14 @@ export class Scheduler {
return this.ended && this.voices.every((voice) => !this.host.playing(voice));
}
/** Plays from `from` ticks of the sequence, or from where it was last started or sent. */
start(from = this.from): void {
/**
* Plays from `from` ticks of the sequence, or from where it was last started or sent – heard from
* `at` on the audio clock where that is later than it can be (#338).
*/
start(from = this.from, at = -Infinity): void {
this.stop();
this.from = Math.max(0, Math.round(from));
this.anchor = this.host.now() + LEAD + this.ahead();
this.anchor = Math.max(this.host.now() + LEAD + this.ahead(), at);
this.until = 0;
this.before = null;
this.ended = false;
+57
View File
@@ -210,6 +210,63 @@ export function newestPassed<T extends Passed>(list: readonly T[]): T[] {
return first ? list.filter((p) => p.late === first.late) : [];
}
/** Where a change a game asks of its music lands (#338): at once, or on the next of these. */
export type Quantize = 'now' | 'beat' | 'bar' | 'marker';
export const QUANTIZE_OPTIONS: readonly { value: Quantize; label: string }[] = [
{ value: 'now', label: 'Now' },
{ value: 'beat', label: 'Next beat' },
{ value: 'bar', label: 'Next bar' },
{ value: 'marker', label: 'Next marker' },
];
export function heldQuantize(value: unknown): Quantize {
return QUANTIZE_OPTIONS.some((option) => option.value === value) ? (value as Quantize) : 'bar';
}
/** The spots a change waits for; a song without markers, or a pattern, waits for the next bar. */
export function quantizeSpots(
quantize: Exclude<Quantize, 'now'>,
meter: Meter,
markers: readonly SongMarker[],
): SpotsIn {
if (quantize === 'beat') return gridSpots(ticksPerBeat(meter));
if (quantize === 'marker' && markers.length > 0) return tickSpots(markers.map((m) => m.tick));
return gridSpots(ticksPerBar(meter));
}
/** The furthest ahead a spot is looked for on a stretch without end. */
const LOOK_AHEAD = 600;
/** The stretch that follows `stretch`; null after the last one of a playing that ends. */
function stretchAfter(timeline: Timeline, stretch: Stretch): Stretch | null {
return stretch.end === Infinity ? null : stretchAt(timeline, stretch.end);
}
/**
* The first spot at or after `elapsed` seconds of playing time, in seconds of playing time; null where
* none comes before a playing that ends does – a change then lands at once.
*/
export function nextSpot(timeline: Timeline, elapsed: number, spots: SpotsIn): number | null {
const { tempo, length } = timeline;
const low = ticksOfSeconds(Math.max(0, elapsed), tempo);
let stretch: Stretch | null = stretchAt(timeline, low);
for (let n = 0; stretch && n < MOST_STRETCHES; n++) {
const headLow = stretch.head + (Math.max(low, stretch.start) - stretch.start);
// One that runs on without end is looked through for `LOOK_AHEAD` seconds, not for ever.
let headHigh =
stretch.head + (Math.min(stretch.end, low + ticksOfSeconds(LOOK_AHEAD, tempo)) - stretch.start);
if (ends(timeline)) headHigh = Math.min(headHigh, length);
if (headHigh > headLow) {
const [first] = spots(headLow, headHigh, false, true).sort((a, b) => a - b);
if (first !== undefined) return secondsOfTicks(stretch.start + (first - stretch.head), tempo);
}
if (ends(timeline) && headHigh >= length) return null;
stretch = stretchAfter(timeline, stretch);
}
return null;
}
/** The newest bar line passed, if one is still to be told. */
export function passedBar(timeline: Timeline, before: number | null, now: number): PassedBeat | null {
const [newest] = passed(timeline, before, now, gridSpots(ticksPerBar(timeline.meter)));
+164 -25
View File
@@ -96,30 +96,59 @@ export interface Runner {
readonly startedAt: number | null;
/** Played to its end, and nothing of it is heard any more. */
readonly done: boolean;
start(from?: number): void;
/** Plays from `from` ticks, heard from `at` on the audio clock where that is still to come (#338). */
start(from?: number, at?: number): void;
stop(letRing?: boolean): void;
}
/** What a change a game asks of a playing goes by (#338): its time, where it started, the clock now. */
export interface PlayingClock {
time: PlayingTime;
/** The second of the audio clock its playing time 0 is heard at; null before it starts. */
startedAt: number | null;
now: number;
/** The soonest second of the audio clock the change can be heard from. */
soonest: number;
}
/** The second of the audio clock a change to a playing lands at, from its clock (#338). */
export type LandingAt = (clock: PlayingClock) => number;
/** Seconds a hard cut between two playings takes, so it does not click. */
export const CUT = 0.005;
/**
* The audio side of the playings. Each is reached here by its `key`, the number its buses go by: the
* same as its `id` but where it took over another one's (#338).
*/
export interface PlaybackHost extends Omit<
SchedulerHost,
'play' | 'playClip' | 'ahead' | 'ramp' | 'automate'
> {
/** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */
prepare(needs: SequenceNeeds, id: number): Promise<void>;
prepare(needs: SequenceNeeds, key: number): Promise<void>;
/** How far ahead of its time a note of that playing may be started (see `SchedulerHost.ahead`). */
ahead?(id: number): number;
ahead?(key: number): number;
/** 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;
level(key: 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;
closed?(key: number): void;
/** Starts the bake of that playing to be heard from `at`, as one voice; 0 where nothing plays. */
playBaked?(id: number, bake: BakePlay, at: number): number;
playBaked?(key: number, bake: BakePlay, at: number): number;
}
export interface Playback {
id: number;
/** What its buses go by; its `id` but where it took over from another playing (#338). */
key: number;
/** The level of the whole of it, as it was asked for. */
volume: number;
/** The second of the audio clock another playing takes over from it at (#338); null while it is the one. */
until: number | null;
/** It takes over from another playing of its number, once it is heard (#338). */
successor: boolean;
/** What stands for the pattern or song, as a file stands for a sound: `pattern:<id>`, `song:<id>`. */
name: string;
owner: string;
@@ -146,8 +175,11 @@ export interface PlayingTime {
/** A playing and how far into its playing time it is heard now, null before it starts (#338). */
export interface HeardTime extends PlayingTime {
id: number;
/** Told apart from the playing of its number it took over from (`Playback.key`). */
key: number;
name: string;
heard: number | null;
baked: boolean;
}
/** Whether a playing goes by `name`: its own, every one of a kind for `kind:`, every one for `''`. */
@@ -168,6 +200,112 @@ 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 playback = this.make(id, id, name, play, owner, false);
const asked = this.host.now();
void this.host.prepare(play.needs, id).then(() => {
if (!this.list.includes(playback) || playback.fading !== null) return;
const from = play.from;
playback.time.from =
from === undefined ? 0 : Math.round(from + ticksOfSeconds(this.host.now() - asked, play.tempo));
playback.runner.start(playback.time.from);
// From the first note heard, which is later where the buses are (#346).
const at = playback.runner.startedAt ?? this.host.now() + LEAD;
this.host.level(id, playback.volume, at, Math.max(0, play.fadeIn ?? 0));
});
}
/**
* Plays `play` in place of the playing numbered `id` (#338), on its buses `key`, from `play.from`
* ticks: once its files are there, it is heard from the second `at` names for the playing it takes
* over from, which falls silent there – over `fade` seconds as this one rises over them, else in a
* cut. It keeps the number, so whatever knows the playing goes on reaching it. Asked again before
* that, the one still waiting is dropped and the playing heard now is the one to go. Where nothing
* of that number plays any more, it starts as soon as it can.
*/
switchTo(
id: number,
key: number,
name: string,
play: SequencePlay,
owner: string,
at: LandingAt,
fade: number,
): void {
const now = this.host.now();
const waiting = this.list.filter((p) => p.id === id && this.waits(p, now));
this.close(waiting, false);
for (const p of this.list)
if (p.id === id && p.until !== null && p.until > now) {
p.until = null;
p.fading = null;
this.host.level(p.key, p.volume, now, 0);
}
const playback = this.make(id, key, name, play, owner, true);
void this.host.prepare(play.needs, key).then(() => {
if (!this.list.includes(playback) || playback.fading !== null) return;
const from = this.heardOne(id, playback);
const now = this.host.now();
const ahead = this.host.ahead?.(key) ?? 0;
const soonest = now + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0);
const clock = from && { time: from.time, startedAt: from.runner.startedAt, now, soonest };
playback.time.from = Math.max(0, Math.round(play.from ?? 0));
playback.runner.start(playback.time.from, clock ? at(clock) : undefined);
const start = playback.runner.startedAt ?? soonest;
const over = fade > 0 ? fade : 0;
if (from) this.handOver(from, start, Math.max(over, CUT));
this.host.level(key, playback.volume, start, over);
});
}
/** The playing of that number heard now, but `besides`: the one a switch takes over from. */
private heardOne(id: number, besides: Playback): Playback | null {
return this.current().find((p) => p.id === id && p !== besides && p.until === null) ?? null;
}
/** Falls silent from `at` over `fade` seconds and goes then: the one taken over from (#338). */
private handOver(playback: Playback, at: number, fade: number): void {
const end = at + fade;
playback.until = at;
playback.fading = end;
this.host.level(playback.key, 0, at, fade);
this.host.after(end, () => {
if (this.list.includes(playback) && playback.fading === end) this.close([playback], false);
});
}
/** The clock of the playing of that number heard now (#338), and whether it is a bake; null where none is. */
clockOf(id: number): (PlayingClock & { baked: boolean }) | null {
const playing = this.current().find((p) => p.id === id && p.until === null && !this.waits(p));
if (!playing) return null;
const now = this.host.now();
const { time, baked } = playing;
return { time, startedAt: playing.runner.startedAt, now, soonest: now + CUT, baked };
}
/** The playings of that number, the one heard now and one waiting to take over from it (#338). */
named(id: number): readonly Readonly<Playback>[] {
return this.current().filter((p) => p.id === id);
}
/** Whether the buses `key` belong to a playing still to be heard. */
holds(key: number): boolean {
return this.current().some((playback) => playback.key === key);
}
/** A successor not heard yet: waiting on its files, or on the second it takes over at. */
private waits(playback: Playback, now = this.host.now()): boolean {
const at = playback.runner.startedAt;
return playback.successor && (at === null || at > now);
}
private make(
id: number,
key: number,
name: string,
play: SequencePlay,
owner: string,
successor: boolean,
): Playback {
const planned: ScheduledNote[] = [];
const clips: ScheduledClip[] = [];
const keep = <T>(list: T[], item: T) => {
@@ -175,12 +313,12 @@ export class Playbacks {
if (list.length > KEPT) list.shift();
};
const strikeClip = play.strikeClip;
const ahead = () => this.host.ahead?.(id) ?? 0;
const ahead = () => this.host.ahead?.(key) ?? 0;
const { bake } = play;
const playBaked = this.host.playBaked?.bind(this.host);
const runner: Runner =
bake && playBaked
? new BakedRun(this.host, ahead, (at) => playBaked(id, bake, at), bake.playback.end !== null)
? new BakedRun(this.host, ahead, (at) => playBaked(key, bake, at), bake.playback.end !== null)
: new Scheduler(
{
...this.host,
@@ -195,6 +333,7 @@ export class Playbacks {
);
const playback: Playback = {
id,
key,
name,
owner,
runner,
@@ -202,20 +341,13 @@ export class Playbacks {
clips,
fading: null,
baked: runner instanceof BakedRun,
volume: play.volume ?? 1,
until: null,
successor,
time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length },
};
this.list.push(playback);
const asked = this.host.now();
void this.host.prepare(play.needs, id).then(() => {
if (!this.list.includes(playback) || playback.fading !== null) return;
const from = play.from;
playback.time.from =
from === undefined ? 0 : Math.round(from + ticksOfSeconds(this.host.now() - asked, play.tempo));
runner.start(playback.time.from);
// From the first note heard, which is later where the buses are (#346).
const at = runner.startedAt ?? this.host.now() + LEAD;
this.host.level(id, play.volume ?? 1, at, Math.max(0, play.fadeIn ?? 0));
});
return playback;
}
/** Whether that one is still to be heard: waiting on its files, playing, fading, or its last notes ringing. */
@@ -232,9 +364,15 @@ export class Playbacks {
const list = this.current();
if (list.length === 0) return [];
const heard = this.host.now() - this.host.latency();
return list.map(({ id, name, runner, time }) => {
// Of a switch (#338) one is heard at a time: the old one up to where the new one is.
const told = list.filter(
({ until, successor, runner }) =>
(until === null || heard < until) &&
(!successor || (runner.startedAt !== null && heard >= runner.startedAt)),
);
return told.map(({ id, key, name, runner, time, baked }) => {
const at = runner.startedAt;
return { id, name, ...time, heard: at === null ? null : heard - at };
return { id, key, name, ...time, heard: at === null ? null : heard - at, baked };
});
}
@@ -267,7 +405,7 @@ export class Playbacks {
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.level(playback.key, 0, now, fade);
this.host.after(end, () => {
if (this.list.includes(playback) && playback.fading === end) this.close([playback], false);
});
@@ -280,7 +418,7 @@ export class Playbacks {
this.list = this.list.filter((playback) => !going.includes(playback));
for (const playback of going) {
playback.runner.stop(letRing);
this.host.closed?.(playback.id);
this.host.closed?.(playback.key);
}
}
}
@@ -312,10 +450,11 @@ class BakedRun implements Runner {
return this.anchor !== null && this.ends && !this.host.playing(this.voice);
}
start(): void {
/** From its start: a bake plays from nowhere else. Heard from `at` where that is still to come. */
start(_from?: number, at = -Infinity): void {
this.stop();
const ahead = this.ahead();
this.anchor = this.host.now() + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0);
this.anchor = Math.max(this.host.now() + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0), at);
this.voice = this.play(this.anchor);
}
+2
View File
@@ -161,6 +161,8 @@ type Extras<K extends FlatKind> = K extends 'number'
noFx?: boolean;
/** Offers the markers of the songs, each as "Song / Marker", instead of the songs (#338). */
markers?: boolean;
/** Offers the tracks of the songs, each as "Song / Track", instead of the songs (#338). */
tracks?: boolean;
}
: K extends 'texture'
? { uploadedOnly?: boolean; moving?: boolean }
+24 -1
View File
@@ -33,7 +33,30 @@ export function assetRefLabel(project: Project | null, id: string): string {
if (clip) return soundRefName(clip, markedName(clip.asset));
// A marker of a song (#338) reads "Song / Marker", as a clip does.
const marker = project ? findSongMarker(project, id) : null;
return marker ? clipRefName(markedName(marker.song), marker.name) : `${id}?`;
if (marker) return clipRefName(markedName(marker.song), marker.name);
// So does a track of a song (#338).
const track = project ? findSongTrack(project, id) : null;
return track ? clipRefName(markedName(track.song), track.name) : `${id}?`;
}
/** The first song with a track of that id (#338), with the track's name; null where none has one. */
export function findSongTrack(project: Project, id: string): { song: Asset; name: string } | null {
if (id === '') return null;
for (const song of project.assets) {
if (song.type !== 'song') continue;
const track = song.tracks.find((t) => t.id === id);
if (track) return { song, name: track.name };
}
return null;
}
/** What a track field offers (#338): the tracks of every song, "Theme / Drums", in their order. */
export function trackChoices(project: Project): { id: string; label: string }[] {
return assetRefChoices(project, ['song']).flatMap((song) =>
song.type === 'song'
? song.tracks.map((t) => ({ id: t.id, label: clipRefName(markedName(song), t.name) }))
: [],
);
}
/** The marker of a song an id names (#338), with its song; null where none does. */
+3 -2
View File
@@ -426,12 +426,13 @@ function nodeEntry({ id, def, params }: PaletteEntry): object {
/**
* A property's row without what only the inspector's drawing needs. `markers` says the field takes
* the id of a marker of a song (#338), as `get_asset` lists them, not the song's.
* the id of a marker of a song (#338), as `get_asset` lists them, not the song's; `tracks` the id of
* a track of one.
*/
function propertyRow(def: PropertyDef): object {
const { key, label, kind } = def;
const row: Record<string, unknown> = { key, label, kind, default: def.default };
for (const extra of ['options', 'min', 'max', 'assetTypes', 'markers'] as const) {
for (const extra of ['options', 'min', 'max', 'assetTypes', 'markers', 'tracks'] as const) {
if (extra in def) row[extra] = (def as Record<string, unknown>)[extra];
}
return row;
+2
View File
@@ -19,6 +19,7 @@ import {
NO_ASSET_LABEL,
soundChoices,
soundEntries,
trackChoices,
} from '../../core/properties/assetRef';
import { NO_FX } from '../../core/properties/assetFx';
import {
@@ -417,6 +418,7 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
choices: (project, def) => {
const extras = def.kind === 'asset' ? def : undefined;
if (extras?.markers) return markerChoices(project);
if (extras?.tracks) return trackChoices(project);
return [
...(extras?.fromAsset ? [{ id: FROM_ASSET_BUS, label: FROM_ASSET_LABEL }] : []),
...(extras?.toComposition ? [{ id: TO_COMPOSITION_BUS, label: TO_COMPOSITION_LABEL }] : []),
+15 -1
View File
@@ -1,6 +1,6 @@
import type { EaseShare } from '../core/model/animation';
import type { MixBus } from '../core/music/mixer';
import type { HeardTime, SequencePlay } from '../core/music/playbacks';
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';
@@ -138,4 +138,18 @@ export class AudioSwitch implements AudioSink {
playingTimes(): HeardTime[] {
return this.both.flatMap((sink) => sink.playingTimes?.() ?? []);
}
switchSequence(id: number, name: string, play: SequencePlay, at: LandingAt, fade: number): boolean {
return this.of(id)?.switchSequence?.(id, name, play, at, fade) ?? false;
}
setSongTrack(
id: number,
track: string,
gain: number,
fade: number,
at: LandingAt,
): 'set' | 'baked' | 'none' {
return this.of(id)?.setSongTrack?.(id, track, gain, fade, at) ?? 'none';
}
}
+19 -1
View File
@@ -1,5 +1,5 @@
import type { EaseShare } from '../../core/model/animation';
import type { HeardTime, SequencePlay } from '../../core/music/playbacks';
import type { HeardTime, LandingAt, SequencePlay } from '../../core/music/playbacks';
import type { MixBus } from '../../core/music/mixer';
import type { FxParams, FxPlan, FxVoice } from '../../core/soundfx/nodes';
@@ -113,6 +113,24 @@ export interface AudioSink {
* what beats, bars and `Song position` go by. A sink without it plays none to be followed.
*/
playingTimes?(): readonly HeardTime[];
/**
* Plays `play` in place of the playing numbered `id` (#338), heard from the second `at` names and
* taking its number; the old one falls silent there, over `fade` seconds or in a cut. Says whether
* there was such a playing. A sink without it switches nothing.
*/
switchSequence?(id: number, name: string, play: SequencePlay, at: LandingAt, fade: number): boolean;
/**
* Turns the track bus `track` of the playing numbered `id` to `gain` (#338), on top of its own
* level, over `fade` seconds from the second `at` names: `baked` where it plays its bake, `none`
* where there is no such playing or track.
*/
setSongTrack?(
id: number,
track: string,
gain: number,
fade: number,
at: LandingAt,
): 'set' | 'baked' | 'none';
}
/**
+168 -35
View File
@@ -24,6 +24,8 @@ import { bakePlayback, validBake } from '../../core/music/bake';
import { clipFx, fxVoiceOf, sequenceNeeds } from '../../core/music/needs';
import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern';
import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler';
import { nextSpot, type Quantize, quantizeSpots } from '../../core/music/beats';
import { heldSongMarkers } from '../../core/music/markers';
import {
dbToGain,
songLength,
@@ -31,8 +33,12 @@ import {
songSequence,
songTempo,
SOUND_ITEM_MAX_DB,
TRACK_MAX_DB,
TRACK_MIN_DB,
trackBus,
} from '../../core/music/song';
import type { SequencePlay } from '../../core/music/playbacks';
import type { LandingAt, SequencePlay } from '../../core/music/playbacks';
import { findSongMarker } from '../../core/properties/assetRef';
import { songBuses } from '../../core/music/songBuses';
import { type MixRoute, playedBus, playedRoute, reachedBus } from '../../core/music/mixer';
import { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes';
@@ -42,7 +48,7 @@ import type { World } from '../World';
import type { AudioSink, NoteTiming } from './Audio';
import type { Instance } from './behaviorPulses';
import type { Input } from './Input';
import { heardPlayings, positionOf } from './musicPulses';
import { type HeardPlaying, heardPlayings, positionOf } from './musicPulses';
import type { RecordingSink } from '../../core/recording/recording';
import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids';
@@ -207,8 +213,21 @@ export function behaviorContext(
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus) =>
playPattern(run, patternId, volume, loop, detached ? '' : entity.id, fx, fxParams, bus),
stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing),
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus) =>
playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id, fx, fxParams, bus),
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) =>
playSong(run, songId, {
volume,
fadeIn,
loop,
owner: detached ? '' : entity.id,
fx,
fxParams,
bus,
live,
}),
setSongTrack: (playback, track, volumeDb, fade, quantize) =>
setSongTrack(run, world, playback, track, volumeDb, heldFade(fade), quantize),
switchSong: (playback, songId, marker, quantize, fade) =>
switchSong(run, world, playback, songId, marker, quantize, heldFade(fade)),
stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)),
stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)),
soundLength: (assetId) => {
@@ -434,48 +453,162 @@ function playPattern(
);
}
/** How a Play song asked for a song (#338): what a Switch song plays the next one by. */
interface SongAsk {
volume: number;
fadeIn: number;
loop: boolean;
owner: string;
fx: string;
fxParams: FxParams;
bus: string;
/** Its notes, even where it has a bake: its tracks can be turned then. */
live: boolean;
}
/** What each playing of a song was asked for, by its number, in the sink it plays in. */
const songAsks = new WeakMap<AudioSink, Map<number, SongAsk>>();
/** So many asks are kept before those of playings gone are let go. */
const ASKS_KEPT = 64;
function keepAsk(audio: AudioSink, id: number, ask: SongAsk): void {
let asks = songAsks.get(audio);
if (!asks) songAsks.set(audio, (asks = new Map<number, SongAsk>()));
if (asks.size >= ASKS_KEPT) {
const playing = new Set((audio.playingTimes?.() ?? []).map((time) => time.id));
for (const known of asks.keys()) if (!playing.has(known)) asks.delete(known);
}
asks.set(id, ask);
}
/**
* 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. Its bake (#340) is played instead while
* it holds. Returns the number of the playing, 0 where there is no song or nothing in it to play.
* it holds, unless `live` (#338). Returns the number of the playing, 0 where there is no song or
* nothing in it to play.
*/
function playSong(
function playSong(sources: Pick<ContextSources, 'project' | 'audio'>, songId: string, ask: SongAsk): number {
const play = songPlay(sources, songId, ask, 0);
const id = play ? sources.audio.playSequence(songVoiceName(songId), play, ask.owner) : 0;
if (id !== 0) keepAsk(sources.audio, id, ask);
return id;
}
/**
* What a song plays as, asked for so (see `playSong`), from `from` ticks on (#338): a bake plays only
* from the start, so one asked to start further in plays its notes.
*/
function songPlay(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
songId: string,
volume: number,
fadeIn: number,
loop: boolean,
owner: string,
fxId: string,
fxParams: FxParams,
bus: string,
): number {
ask: SongAsk,
from: number,
): SequencePlay | null {
const song = findAssetOf(project, songId, 'song');
if (!song) return 0;
if (!song) return null;
const sequence = songSequence(project, song);
const tempo = songTempo(song.props);
const fx = fxVoiceOf(project, pickedFx(fxId, song.props), tempo, fxParams);
const bake = validBake(project, song);
return audio.playSequence(
songVoiceName(songId),
{
sequence,
tempo,
length: songLength(song),
loop: loop ? songLoop(song) : null,
needs: sequenceNeeds(project, sequence),
volume: heldLevel(volume),
fadeIn: heldFade(fadeIn),
buses: songBuses(project, song),
...(fx && { fx }),
...heardOn(playedRoute(project, bus, songId)),
strike: (note) => strikeNote({ project, audio }, note, owner),
strikeClip: (clip) => playClip({ project, audio }, clip),
...(bake && { bake: { file: bake.file, gain: bake.peak, playback: bakePlayback(bake, loop) } }),
},
owner,
const fx = fxVoiceOf(project, pickedFx(ask.fx, song.props), tempo, ask.fxParams);
const bake = ask.live || from > 0 ? null : validBake(project, song);
return {
sequence,
tempo,
length: songLength(song),
loop: ask.loop ? songLoop(song) : null,
needs: sequenceNeeds(project, sequence),
volume: heldLevel(ask.volume),
fadeIn: heldFade(ask.fadeIn),
buses: songBuses(project, song),
...(fx && { fx }),
...heardOn(playedRoute(project, ask.bus, songId)),
strike: (note) => strikeNote({ project, audio }, note, ask.owner),
strikeClip: (clip) => playClip({ project, audio }, clip),
...(bake && { bake: { file: bake.file, gain: bake.peak, playback: bakePlayback(bake, ask.loop) } }),
...(from > 0 && { from }),
};
}
/** Where a change to a playing lands (#338): as soon as it can be heard, or on the next spot `quantize` names. */
function landing(quantize: Quantize, playing: HeardPlaying): LandingAt {
return ({ time, startedAt, soonest }) => {
if (quantize === 'now' || startedAt === null) return soonest;
const spots = quantizeSpots(quantize, playing.timeline.meter, playing.markers);
const next = nextSpot({ ...time, meter: playing.timeline.meter }, soonest - startedAt, spots);
return next === null ? soonest : startedAt + next;
};
}
/** The song playing numbered `playback` as it is heard now; null for none, or a pattern. */
function heardSong(project: Project, audio: AudioSink, playback: number): HeardPlaying | null {
if (!(playback > 0)) return null;
return heardPlayings(project, audio).find((p) => p.id === playback && p.kind === 'song') ?? null;
}
/** Turns a track of a playing song (#338); where it plays its bake there is nothing to turn, and the log says so. */
function setSongTrack(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
world: World,
playback: number,
track: string,
volumeDb: number,
fade: number,
quantize: Quantize,
): void {
const playing = heardSong(project, audio, playback);
if (!playing || track === '') return;
const db = Number.isFinite(volumeDb) ? Math.max(TRACK_MIN_DB, Math.min(TRACK_MAX_DB, volumeDb)) : 0;
const done = audio.setSongTrack?.(
playback,
trackBus(playing.asset, track),
dbToGain(db),
fade,
landing(quantize, playing),
);
if (done === 'baked') {
const name = findAssetOf(project, playing.asset, 'song')?.name ?? playing.asset;
world.log.push(`Set song track: song "${name}" plays its bake; switch on "live tracks" in Play song`);
}
}
/**
* Plays another song, or the same one from a marker (#338), in place of a playing song: asked for
* as that one was, where `quantize` lands for the one heard now. A marker the song does not have is
* logged, and nothing changes.
*/
function switchSong(
sources: Pick<ContextSources, 'project' | 'audio'>,
world: World,
playback: number,
songId: string,
markerId: string,
quantize: Quantize,
fade: number,
): void {
const { project, audio } = sources;
const playing = heardSong(project, audio, playback);
if (!playing) return;
const target = songId || playing.asset;
const song = findAssetOf(project, target, 'song');
if (!song) return;
const marker = markerId ? heldSongMarkers(song.markers).find((m) => m.id === markerId) : undefined;
if (markerId && !marker) {
const named = findSongMarker(project, markerId)?.name ?? markerId;
world.log.push(`Switch song: song "${song.name}" has no marker "${named}"`);
return;
}
const ask = songAsks.get(audio)?.get(playback) ?? {
volume: 1,
fadeIn: 0,
loop: true,
owner: '',
fx: '',
fxParams: {},
bus: '',
live: false,
};
const play = songPlay(sources, target, ask, marker?.tick ?? 0);
if (play) audio.switchSequence?.(playback, songVoiceName(target), play, landing(quantize, playing), fade);
}
/**
+8 -4
View File
@@ -27,6 +27,8 @@ import type { Instance, Pulse } from './behaviorPulses';
/** A playing of a song or a pattern, as a game follows it. */
export interface HeardPlaying {
id: number;
/** Told apart from the playing of its number a Switch song took over from (#338). */
key: number;
kind: 'song' | 'pattern';
/** The id of the song or pattern. */
asset: string;
@@ -50,6 +52,7 @@ export function heardPlayings(project: Project, audio: AudioSink): HeardPlaying[
return [
{
id: time.id,
key: time.key,
kind,
asset: id,
timeline: { tempo, meter, from, loop, length },
@@ -70,7 +73,8 @@ export interface MusicPassed {
/**
* Follows the playings from frame to frame: each frame what each passed since the last one. One
* first seen is told what it passed no more than the limit ago, its first beat among it.
* first seen is told what it passed no more than the limit ago, its first beat among it – so is one
* a Switch song started in place of another, under the same number.
*/
export class MusicFollower {
private before = new Map<number, number>();
@@ -78,10 +82,10 @@ export class MusicFollower {
follow(playings: readonly HeardPlaying[]): MusicPassed[] {
const next = new Map<number, number>();
const passed = playings.map((playing) => {
const { id, heard, timeline } = playing;
const before = this.before.get(id) ?? null;
const { key, heard, timeline } = playing;
const before = this.before.get(key) ?? null;
if (heard === null) return { playing, beat: null, bar: null, markers: [] };
next.set(id, heard);
next.set(key, heard);
return {
playing,
beat: passedBeat(timeline, before, heard),
+100 -15
View File
@@ -22,7 +22,9 @@ import { BAKE_RATE } from '../core/music/bake';
import {
type BakePlay,
type BusLevel,
CUT,
type HeardTime,
type LandingAt,
Playbacks,
type SequenceNeeds,
type SequencePlay,
@@ -745,7 +747,8 @@ export class SoundLibrary implements AudioSink {
/**
* What each track's bus was last set to, with whether its sound FX is there and how late, and the
* level it has now, ramps and fades included (test hook).
* level it has now, ramps and fades included – `turned` what its input is at now, where a game turned
* it (#338) (test hook).
*/
busLevels(): {
name: string;
@@ -756,11 +759,13 @@ export class SoundLibrary implements AudioSink {
fx: boolean;
latency: number;
sends?: { to: string; tap: SendTap; gain: number; delay: number }[];
turned?: number;
}[] {
return [...this.buses].map(([name, bus]) => ({
name,
...bus.set,
level: bus.gain.gain.value,
...((bus.steer !== 1 || bus.input.gain.value !== 1) && { turned: bus.input.gain.value }),
into: bus.sends ? null : bus.into,
fx: bus.chain !== null,
latency: bus.chain?.latency ?? 0,
@@ -962,6 +967,8 @@ export class SoundLibrary implements AudioSink {
gain: context.createGain(),
pan: context.createStereoPanner(),
set: { gain: 1, pan: 0 },
steer: 1,
steerEnd: 0,
automated: new Set(),
into: target,
sends: null,
@@ -1340,23 +1347,86 @@ export class SoundLibrary implements AudioSink {
/**
* 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.
* through a master of its own, silent until it starts (#336): its level and its fades.
*/
playSequence(name: string, play: SequencePlay, owner = ''): number {
if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0;
const id = this.nextId++;
this.sequences.start(id, name, this.routed(id, play), owner);
return id;
}
/**
* Plays `play` in place of the playing numbered `id` (#338), from the second `at` names, as
* `Playbacks.switchTo` does: on buses of its own, its tracks turned as a game turned those of the
* same name, and owned by whoever owns the playing. Says whether there was such a playing.
*/
switchSequence(id: number, name: string, play: SequencePlay, at: LandingAt, fade: number): boolean {
const playings = this.sequences.named(id);
const from = playings.find((playing) => playing.until === null) ?? playings[0];
if (!from || (play.sequence.notes.length === 0 && !play.sequence.clips?.length)) return false;
const key = this.nextId++;
const routed = this.routed(key, play);
for (const [busName, bus] of this.buses) {
const track = trackOf(from.key, busName);
const into = track === null ? undefined : this.buses.get(groupBus(key, track));
if (!into) continue;
into.steer = bus.steer;
into.input.gain.value = bus.steer;
}
this.sequences.switchTo(id, key, name, routed, from.owner, at, fade);
return true;
}
/**
* Turns one track of a playing (#338) to `gain` over `fade` seconds from the second `at` names, on
* top of its own level: the one heard now, and one waiting to take over from it alike. A bake has
* no tracks to turn.
*/
setSongTrack(
id: number,
track: string,
gain: number,
fade: number,
at: LandingAt,
): 'set' | 'baked' | 'none' {
const clock = this.sequences.clockOf(id);
if (!clock) return 'none';
if (clock.baked) return 'baked';
const when = Math.max(this.audio().currentTime, at(clock));
let found = false;
for (const playing of this.sequences.named(id)) {
const bus = this.buses.get(groupBus(playing.key, track));
if (!bus) continue;
found = true;
const from = bus.steer;
bus.steer = Math.max(0, gain);
// Where the last turn is through by then, nothing is planned past `when`, and Chrome's
// cancelAndHoldAtTime holds nothing: the ramp would run from that turn on, seconds early.
if (when < bus.steerEnd) holdParam(bus.input.gain, when);
else bus.input.gain.setValueAtTime(from, when);
bus.steerEnd = when + Math.max(CUT, fade);
bus.input.gain.linearRampToValueAtTime(bus.steer, bus.steerEnd);
}
return found ? 'set' : 'none';
}
/**
* The buses a playing is heard through (#336), `key` its master's: its tracks into it, each set as
* it was given; a note or clip naming no track goes straight in. Silent until it starts.
*/
private routed(key: number, play: SequencePlay): SequencePlay {
// A bake whose file could not be had is no bake: the notes are played instead (#340).
const { bake: asked, ...rest } = play;
const bake = asked && !this.hasFailed(asked.file) ? asked : null;
const buses = bake ? [] : (play.buses ?? []);
this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX, play.sends);
this.open(key, buses, play.fx ?? null, play.bus ?? MASTER_MIX, play.sends);
const tracks = new Set(buses.map((bus) => bus.name));
const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : '');
const into = (bus: string | undefined) => groupBus(key, bus !== undefined && tracks.has(bus) ? bus : '');
const { strike, strikeClip } = play;
const chains = [...buses.flatMap((bus) => bus.fx ?? []), ...(play.fx ? [play.fx] : [])];
const needs = bake ? { layers: [], files: [bake.file] } : play.needs;
const routed: SequencePlay = {
return {
...rest,
...(bake && { bake }),
needs: { ...needs, layers: [...needs.layers, ...chains.map((fx) => fx.plan)] },
@@ -1366,14 +1436,12 @@ export class SoundLibrary implements AudioSink {
}),
// Only a track's: the master of the playing is its level and its fades.
ramp: (ramp) => {
if (tracks.has(ramp.bus)) this.rampBus(groupBus(id, ramp.bus), ramp);
if (tracks.has(ramp.bus)) this.rampBus(groupBus(key, ramp.bus), ramp);
},
automate: (automation) => {
if (tracks.has(automation.bus)) this.automateBus(groupBus(id, automation.bus), automation);
if (tracks.has(automation.bus)) this.automateBus(groupBus(key, automation.bus), automation);
},
};
this.sequences.start(id, name, routed, owner);
return id;
}
stopSequence(name: string, letRing = false, fade = 0): void {
@@ -1671,7 +1739,7 @@ export class SoundLibrary implements AudioSink {
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;
if (!this.groups.has(id) || this.sequences.holds(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;
@@ -1815,7 +1883,12 @@ export class SoundLibrary implements AudioSink {
return true;
}
const voice = this.voices.find((other) => other.id === id);
if (!voice) return this.sequences.has(id) && this.tuneMaster(id, params, time, ease);
// Every playing of that number (#338): one taking over from another is tuned alike.
if (!voice) {
const playings = this.sequences.named(id);
for (const playing of playings) this.tuneMaster(playing.key, params, time, ease);
return playings.length > 0;
}
voice.fx = { ...voice.fx, params: { ...voice.fx.params, ...params } };
if (this.glide(voice, time, ease)) this.tunedAt = this.audio().currentTime;
// A longer feedback makes for a longer tail: it is counted again from where the change ends.
@@ -1828,10 +1901,9 @@ export class SoundLibrary implements AudioSink {
* The sound FX of a pattern or song playing (#346), on its master; one still loading starts with
* the values. Says it was reached, whether or not it has a sound FX.
*/
private tuneMaster(id: number, params: FxParams, time: number, ease: EaseShare): boolean {
const master = this.buses.get(groupBus(id, ''));
private tuneMaster(key: number, params: FxParams, time: number, ease: EaseShare): void {
const master = this.buses.get(groupBus(key, ''));
if (master) this.tuneBus(master, params, time, ease);
return true;
}
private tuneBus(bus: Bus, params: FxParams, time: number, ease: EaseShare): void {
@@ -3140,6 +3212,12 @@ function groupBus(id: number, track: string): string {
return track === '' ? `play:${id}` : `play:${id}:${track}`;
}
/** The track a bus of the playing `key` is (#338); null for its master and any other bus. */
function trackOf(key: number, bus: string): string | null {
const head = `${groupBus(key, '')}:`;
return bus.startsWith(head) ? bus.slice(head.length) : null;
}
/**
* A track of a song (#334): what goes in passes its sound FX (#346), then its level, then its pan,
* then the bus it goes into or straight out – or each of its sends (#350); `set` is what level and
@@ -3150,6 +3228,13 @@ interface Bus {
gain: GainNode;
pan: StereoPannerNode;
set: { gain: number; pan: number };
/**
* What a game turns a track of its playing to (#338), on its input: on top of its level, before its
* sound FX and every send, what goes into it from other tracks too.
*/
steer: number;
/** The second of the audio clock the last turn of `steer` is through at; 0 for none. */
steerEnd: number;
/** What a lane of automation sets instead (#337), where `setBus` leaves it alone. */
automated: Set<AutomationTarget>;
/** Where it goes as a whole while it has no sends; null straight out. */
+47
View File
@@ -5,11 +5,14 @@
import { describe, expect, it } from 'vitest';
import {
heardPosition,
heldQuantize,
lateLimit,
newestPassed,
nextSpot,
passedBar,
passedBeat,
passedMarkers,
quantizeSpots,
type Timeline,
} from '../../src/core/music/beats';
import { COMMON_TIME } from '../../src/core/music/time';
@@ -133,3 +136,47 @@ describe('the markers it passed', () => {
expect(newestPassed(passed.filter((m) => m.id === 'c')).map((m) => m.id)).toEqual(['c']);
});
});
describe('where a change to the music lands (#338)', () => {
const markers = [
{ id: 'a', name: 'Verse', tick: BAR + Q },
{ id: 'b', name: 'Late', tick: 2 * BAR - Q },
];
const at = (t: Timeline, elapsed: number, quantize: 'beat' | 'bar' | 'marker', list = markers) =>
nextSpot(t, elapsed, quantizeSpots(quantize, t.meter, list));
it('on the next beat or bar, one it stands on being the one', () => {
expect(at(LOOPED, 0.2, 'beat')).toBeCloseTo(0.5, 9);
expect(at(LOOPED, 0.5, 'beat')).toBeCloseTo(0.5, 9);
expect(at(LOOPED, 0.2, 'bar')).toBeCloseTo(2, 9);
});
it('round the loop: past its last bar the next one is its start come round again', () => {
// Played time 3.9 s is the end of bar 2; the loop's start follows at 4 s.
expect(at(LOOPED, 3.9, 'bar')).toBeCloseTo(4, 9);
expect(at(LOOPED, 5.9, 'bar')).toBeCloseTo(6, 9);
});
it('on the next marker, round the loop too, and on the next bar in a song without markers', () => {
expect(at(LOOPED, 0, 'marker')).toBeCloseTo(2.5, 9);
expect(at(LOOPED, 2.6, 'marker')).toBeCloseTo(3.5, 9);
expect(at(LOOPED, 3.6, 'marker')).toBeCloseTo(4.5, 9);
expect(at(LOOPED, 0.2, 'marker', [])).toBeCloseTo(2, 9);
});
it('from where a playing started, a marker its start lies past is not come to again', () => {
expect(at({ ...LOOPED, from: BAR + 2 * Q }, 0, 'marker')).toBeCloseTo(0.5, 9);
expect(at({ ...ONCE, from: BAR + 2 * Q }, 0, 'marker', [markers[0]!])).toBeNull();
});
it('nowhere at or past the end of one that ends: the change then lands at once', () => {
expect(at(ONCE, 3.2, 'bar')).toBeNull();
expect(at(ONCE, 3.2, 'beat')).toBeCloseTo(3.5, 9);
});
it('takes what it does not know for the next bar', () => {
expect(heldQuantize('now')).toBe('now');
expect(heldQuantize('soon')).toBe('bar');
expect(heldQuantize(undefined)).toBe('bar');
});
});
+31 -1
View File
@@ -55,8 +55,10 @@ function heardAudio(song: string, pattern: string) {
const times: HeardTime[] = [
{
id: 7,
key: 7,
name: `song:${song}`,
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: { start: BAR, end: 2 * BAR },
@@ -64,14 +66,26 @@ function heardAudio(song: string, pattern: string) {
},
{
id: 8,
key: 8,
name: `pattern:${pattern}`,
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: { start: 0, end: 3 * Q },
length: 3 * Q,
},
{ id: 9, name: 'composition:c', heard: null, tempo: 120, from: 0, loop: null, length: 2 * BAR },
{
id: 9,
key: 9,
name: 'composition:c',
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: null,
length: 2 * BAR,
},
];
return {
audio: { ...SILENT_AUDIO, playingTimes: () => times },
@@ -124,6 +138,22 @@ describe('On beat and On bar', () => {
expect(world.log.slice(1)).toEqual(['beat 14']);
});
it('start over with a song a Switch song plays under the same number (#338)', () => {
const f = listening();
f.logs('OnBeat', {}, 'beat ', 'bar', 'beat');
const { audio, at } = heardAudio(f.song, f.pattern);
const { world, frame } = startWorld(f, { behaviors: true, audio });
at(3.01, 7);
frame();
expect(world.log).toEqual(['beat 23']);
// Switched to: the same number on buses of its own, its first beat heard a moment ago.
const switched = audio.playingTimes().find((time) => time.id === 7)!;
switched.key = 70;
at(0.01, 7);
frame();
expect(world.log.slice(1)).toEqual(['beat 11']);
});
it('hear the song or pattern picked, every one where none is, a composition never', () => {
const f = listening();
f.logs('OnBeat', {}, 'any ', 'playback', 'beat');
+28
View File
@@ -2548,6 +2548,34 @@ const CASES: Record<string, Case[]> = {
outputs: { bar: 0, beat: 0, fraction: 0, seconds: 0, playing: false },
},
],
SetSongTrack: [
{
name: 'turns the track of the playing it is given, on the next bar where nothing else is picked (#338)',
inputs: { playback: 4, track: 't1', volume: -12, fade: 1 },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['track 4 t1 -12 dB fade 1 bar']),
},
{
name: 'a quantize it does not know is the next bar',
inputs: { playback: 4, track: 't1', volume: -60, fade: 0, quantize: 'soon' },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['track 4 t1 -60 dB fade 0 bar']),
},
],
SwitchSong: [
{
name: 'switches the playing it is given, in a cut on the next bar by default (#338)',
inputs: { playback: 4 },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['switch 4 to (same) at (start) bar fade 0']),
},
{
name: 'to the song and marker it picks, where its quantize says',
inputs: { playback: 4, song: 'boss', marker: 'm2', quantize: 'marker', fade: 2 },
next: 'exec',
after: (ctx) => expect(ctx.songChanges).toEqual(['switch 4 to boss at m2 marker fade 2']),
},
],
};
/** Two distinct handles that share a name – `Equals` must still tell them apart. */
+221 -3
View File
@@ -9,12 +9,19 @@ import {
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import { SetSongCommand, SetSongMarkersCommand } from '../../src/core/commands/songCommands';
import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset';
import { bakePlayback, bakePrint, bakeSpans } from '../../src/core/music/bake';
import { goesBy, Playbacks, type SequencePlay } from '../../src/core/music/playbacks';
import {
CUT,
goesBy,
type LandingAt,
type PlayingClock,
Playbacks,
type SequencePlay,
} from '../../src/core/music/playbacks';
import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler';
import { newItem, newTrack, trackBus } from '../../src/core/music/song';
import { dbToGain, newItem, newTrack, 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';
@@ -122,8 +129,10 @@ describe('the songs a game plays', () => {
expect(playbacks.times()).toEqual([
{
id: 5,
key: 5,
name: 'song:a',
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: { start: BAR, end: 2 * BAR },
@@ -252,6 +261,93 @@ describe('the songs a game plays', () => {
});
});
describe('a song switched to in place of another (#338)', () => {
/** Song a playing from 0.05 s on, as heard, and a clock it was asked for at 1 s. */
async function playingA() {
const fake = fakeHost();
const old: (ScheduledNote | ScheduledClip)[] = [];
fake.playbacks.start(5, 'song:a', song(old), '');
await fake.loaded();
fake.run(1);
return { ...fake, old };
}
const levelsOf = (host: ReturnType<typeof fakeHost>['host']) =>
host.levels.map(({ id, gain, at, seconds }) => [id, gain, +at.toFixed(6), seconds]);
it('is heard from where it lands, under the same number; the old one falls silent there in a cut and goes', async () => {
const { host, playbacks, run, loaded } = await playingA();
const fresh: (ScheduledNote | ScheduledClip)[] = [];
const asked: PlayingClock[] = [];
const twoBarsIn: LandingAt = (clock) => (asked.push(clock), clock.startedAt! + 2);
playbacks.switchTo(5, 9, 'song:b', song(fresh, { from: BAR }), '', twoBarsIn, 0);
await loaded();
expect(asked.map((c) => [c.startedAt, +c.now.toFixed(9), +c.soonest.toFixed(9)])).toEqual([
[LEAD, 1, 1 + LEAD],
]);
expect(levelsOf(host).slice(-2)).toEqual([
[5, 0, 2 + LEAD, CUT],
[9, 1, 2 + LEAD, 0],
]);
// Until the clock gets there the old one is the one heard, from there on the new one.
expect(playbacks.times().map((t) => [t.id, t.key])).toEqual([[5, 5]]);
run(1.1);
expect(playbacks.times().map((t) => [t.id, t.key, t.from, +t.heard!.toFixed(6)])).toEqual([
[5, 9, BAR, +(host.time - 2 - LEAD).toFixed(6)],
]);
expect([playbacks.has(5), host.closed]).toEqual([true, [5]]);
const [first] = fresh as ScheduledNote[];
expect([first?.note, first?.at]).toEqual([61, 2 + LEAD]);
});
it('over its fade the old one falls as the new one rises, and goes once it is silent', async () => {
const { host, playbacks, run, loaded } = await playingA();
playbacks.switchTo(5, 9, 'song:a', song([]), '', (clock) => clock.startedAt! + 2, 1.5);
await loaded();
expect(levelsOf(host).slice(-2)).toEqual([
[5, 0, 2 + LEAD, 1.5],
[9, 1, 2 + LEAD, 1.5],
]);
// While the old one fades, only the new one is told: beats and the position go by it.
run(1.2);
expect(playbacks.times().map((t) => [t.id, t.key])).toEqual([[5, 9]]);
run(1.2);
expect(host.closed).toEqual([]);
run(0.3);
expect(host.closed).toEqual([5]);
});
it('asked again before it lands, drops the one waiting, and the old one is heard on at its level', async () => {
const { host, playbacks, loaded } = await playingA();
playbacks.switchTo(5, 9, 'song:b', song([]), '', (clock) => clock.startedAt! + 2, 0);
await loaded();
playbacks.switchTo(5, 10, 'song:c', song([]), '', (clock) => clock.startedAt! + 4, 0);
expect(host.closed).toEqual([9]);
expect(levelsOf(host).at(-1)).toEqual([5, 1, 1, 0]);
await loaded();
expect(levelsOf(host).slice(-2)).toEqual([
[5, 0, 4 + LEAD, CUT],
[10, 1, 4 + LEAD, 0],
]);
expect(playbacks.named(5).map((p) => p.key)).toEqual([5, 10]);
});
it('a stop of its number stops the old one and the one waiting alike', async () => {
const { host, playbacks, loaded } = await playingA();
playbacks.switchTo(5, 9, 'song:b', song([]), '', (clock) => clock.startedAt! + 2, 0);
await loaded();
playbacks.stopId(5);
expect([playbacks.has(5), host.closed.sort()]).toEqual([false, [5, 9]]);
});
it('where nothing of that number plays any more, starts as soon as it can', async () => {
const { host, playbacks, loaded } = fakeHost();
let asked = 0;
playbacks.switchTo(5, 9, 'song:b', song([]), '', () => (asked++, 99), 0);
await loaded();
expect([asked, levelsOf(host)]).toEqual([0, [[9, 1, LEAD, 0]]]);
});
});
/**
* 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.
@@ -319,6 +415,38 @@ function recordingAudio() {
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})`),
/** Every playing heard 0.3 s into a clock that started it at 10 s (#338). */
clockOf: (play: SequencePlay): PlayingClock => ({
time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length },
startedAt: 10,
now: 10.3,
soonest: 10.31,
}),
playingTimes: () =>
sink.sequences.map(({ name, play }, i) => ({
id: 1001 + i,
key: 1001 + i,
name,
heard: 0.3,
baked: play.bake !== undefined,
tempo: play.tempo,
from: 0,
loop: play.loop,
length: play.length,
})),
switched: [] as { id: number; name: string; play: SequencePlay; at: number; fade: number }[],
switchSequence: (id: number, name: string, play: SequencePlay, at: LandingAt, fade: number) => {
const from = sink.sequences[id - 1001]!.play;
sink.switched.push({ id, name, play, at: at(sink.clockOf(from)), fade });
return true;
},
turned: [] as { id: number; track: string; gain: number; fade: number; at: number }[],
setSongTrack: (id: number, track: string, gain: number, fade: number, at: LandingAt) => {
const playing = sink.sequences[id - 1001]!.play;
if (playing.bake) return 'baked' as const;
sink.turned.push({ id, track, gain, fade, at: at(sink.clockOf(playing)) });
return 'set' as const;
},
};
return sink;
}
@@ -427,3 +555,93 @@ describe('Stop song', () => {
]);
});
});
describe('Set song track and Switch song (#338)', () => {
/** 90 BPM: a beat is 2/3 s, a bar 8/3 s, played from 10 s on and heard 0.3 s in. */
const BEAT = 2 / 3;
it('turns a track of the playing on the next beat, in decibels on top of its level', () => {
const f = playingSong();
const turn = f.edit.addNode('SetSongTrack', {
track: f.tracks[1]!,
volume: -6,
fade: 0.5,
quantize: 'beat',
});
f.edit.connect(f.play, 'exec', turn, 'exec');
f.edit.connect(f.play, 'playback', turn, 'playback');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.turned.map((t) => ({ ...t, at: +t.at.toFixed(9) }))).toEqual([
{
id: 1001,
track: trackBus(f.songId, f.tracks[1]!),
gain: dbToGain(-6),
fade: 0.5,
at: +(10 + BEAT).toFixed(9),
},
]);
});
it('a playing of its bake has no tracks: the log says to switch on "live tracks", which plays its notes', () => {
const baked = (live: boolean) => {
const f = playingSong({ live });
const song = f.project.assets.find((a) => a.id === f.songId) as SongAsset;
const spans = bakeSpans({ tempo: 90, length: 2 * BAR, loop: { start: BAR, end: 2 * BAR } });
const file = `${'c3'.repeat(6)}/${'d4'.repeat(32)}.ogg`;
song.bake = { file, print: bakePrint(f.project, song), peak: 0.5, bytes: 1, ...spans };
const turn = f.edit.addNode('SetSongTrack', { track: f.tracks[0]!, volume: -60, quantize: 'now' });
f.edit.connect(f.play, 'exec', turn, 'exec');
f.edit.connect(f.play, 'playback', turn, 'playback');
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
return { audio, log: world.log };
};
const off = baked(false);
expect([off.audio.turned, off.log]).toEqual([
[],
['Set song track: song "Theme" plays its bake; switch on "live tracks" in Play song'],
]);
const on = baked(true);
expect([on.audio.sequences[0]?.play.bake, on.audio.turned[0], on.log]).toEqual([
undefined,
expect.objectContaining({ gain: 0, at: 10.31 }),
[],
]);
});
it('switches to a marker of the same song on the next marker, asked for as its Play song was', () => {
const f = playingSong();
f.stack.execute(new SetSongMarkersCommand(f.songId, [{ id: 'm1', name: 'Chorus', tick: BAR + Q }]));
const go = f.edit.addNode('SwitchSong', { marker: 'm1', quantize: 'marker', fade: 1.5 });
f.edit.connect(f.play, 'exec', go, 'exec');
f.edit.connect(f.play, 'playback', go, 'playback');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
const [switched] = audio.switched;
// Bar 2 beat 2 is 2400 ticks in, 3 1/3 s at 90 BPM.
expect([switched?.id, switched?.name, switched?.fade, switched?.at]).toEqual([
1001,
`song:${f.songId}`,
1.5,
expect.closeTo(10 + 10 / 3, 9),
]);
expect(switched?.play).toMatchObject({ from: BAR + Q, volume: 0.8, loop: { start: BAR, end: 2 * BAR } });
expect(switched?.play.bake).toBeUndefined();
});
it('logs a marker the song does not have, and switches nothing', () => {
const f = playingSong();
const other = new CreateAssetCommand('song', 'Boss');
f.stack.execute(other);
f.stack.execute(new SetSongMarkersCommand(other.asset.id, [{ id: 'm9', name: 'Rage', tick: 0 }]));
const go = f.edit.addNode('SwitchSong', { marker: 'm9' });
f.edit.connect(f.play, 'exec', go, 'exec');
f.edit.connect(f.play, 'playback', go, 'playback');
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
expect([audio.switched, world.log]).toEqual([[], ['Switch song: song "Theme" has no marker "Rage"']]);
});
});
+22 -2
View File
@@ -276,7 +276,10 @@ export type FakeContext = NodeContext & {
fx: string;
fxParams: Readonly<Record<string, number>>;
bus?: string;
live?: boolean;
}[];
/** What Set song track and Switch song asked of a playing (#338), one line each. */
songChanges: string[];
silenced: string[];
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number; ease: EaseShare }[];
/** What buses the context was told to change (#325): a volume or FX parameters, in order. */
@@ -378,8 +381,25 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
stopPattern: (patternId, letRing) =>
ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`),
songs: [],
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus) =>
ctx.songs.push({ songId, volume, fadeIn, loop, detached, fx, fxParams, ...(bus && { bus }) }),
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) =>
ctx.songs.push({
songId,
volume,
fadeIn,
loop,
detached,
fx,
fxParams,
...(bus && { bus }),
...(live && { live }),
}),
songChanges: [],
setSongTrack: (playback, track, volumeDb, fade, quantize) =>
ctx.songChanges.push(`track ${playback} ${track} ${volumeDb} dB fade ${fade} ${quantize}`),
switchSong: (playback, songId, marker, quantize, fade) =>
ctx.songChanges.push(
`switch ${playback} to ${songId || '(same)'} at ${marker || '(start)'} ${quantize} fade ${fade}`,
),
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.
+96 -1
View File
@@ -18,7 +18,7 @@ import {
import type { AutomationTarget } from '../../src/core/music/automation';
import { dbToGain } from '../../src/core/music/level';
import type { MixBus } from '../../src/core/music/mixer';
import type { BakePlay, SequencePlay } from '../../src/core/music/playbacks';
import { type BakePlay, CUT, type LandingAt, type SequencePlay } from '../../src/core/music/playbacks';
import type { ScheduledAutomation, ScheduledNote } from '../../src/core/music/Scheduler';
import type { LfoOptions, PitchOptions, StreamOptions } from '../../src/viewport/fxWorklet';
import { saturationCurve } from '../../src/core/soundfx/saturation';
@@ -1982,6 +1982,101 @@ describe('the patterns and songs a game plays (#336)', () => {
library.time();
expect(resumes).toBe(2);
});
/** A bus of the library by its name, with the params a test reads. */
const busOf = (library: SoundLibrary, name: string) =>
(
library as unknown as { buses: Map<string, { input: { gain: FakeParam }; gain: { gain: FakeParam } }> }
).buses.get(name);
it('turns a track of a playing on its input from where it lands, on top of its level (#338)', async () => {
const library = new SoundLibrary(false);
const id = library.playSequence(
'song:s',
sequence(() => 0),
'',
);
await settle();
const landed: (number | null)[] = [];
const at: LandingAt = (clock) => (landed.push(clock.startedAt), clock.startedAt! + 2);
expect(library.setSongTrack(id, 'song:s:t', 0.25, 1, at)).toBe('set');
const track = busOf(library, `play:${id}:song:s:t`)!;
expect([landed, track.input.gain.ramps]).toEqual([
[0.05],
[
['set', 1, 2.05],
['linear', 0.25, 3.05],
],
]);
// Its own level is the song's, left alone.
expect(track.gain.gain.value).toBe(0.25);
// A turn after that one is through starts where it left the track, not where it is now.
library.setSongTrack(id, 'song:s:t', 0.5, 0, (clock) => clock.startedAt! + 4);
expect(track.input.gain.ramps.slice(2)).toEqual([
['set', 0.25, 4.05],
['linear', 0.5, expect.closeTo(4.055, 9)],
]);
expect(library.setSongTrack(id, 'song:s:gone', 0.5, 0, at)).toBe('none');
expect(library.setSongTrack(id + 99, 'song:s:t', 0.5, 0, at)).toBe('none');
});
it('a playing of its bake has no tracks to turn (#338)', () => {
const library = new SoundLibrary(false);
const bake = { file: 'baked.ogg', gain: 0.5, playback: { end: 2, until: 2, round: null } };
const id = library.playSequence(
'song:s',
sequence(() => 0, { bake }),
'',
);
expect(library.setSongTrack(id, 'song:s:t', 0.5, 0, () => 0)).toBe('baked');
});
it('switches a playing on buses of its own, its tracks turned as they were, the old one gone once silent (#338)', async () => {
const library = new SoundLibrary(false);
const id = library.playSequence(
'song:s',
sequence(() => 0),
'logic',
);
await settle();
library.setSongTrack(id, 'song:s:t', 0.25, 0, () => 0.5);
const at: LandingAt = (clock) => clock.startedAt! + 2;
expect(
library.switchSequence(
id,
'song:s',
sequence(() => 0, { from: 480 }),
at,
0,
),
).toBe(true);
const fresh = library
.busLevels()
.find((bus) => bus.name.endsWith(':song:s:t') && bus.name !== `play:${id}:song:s:t`);
expect(busOf(library, fresh!.name)!.input.gain.value).toBe(0.25);
await settle();
expect(library.playbacks().map((p) => [p.id, p.owner])).toEqual([
[id, 'logic'],
[id, 'logic'],
]);
expect(busOf(library, `play:${id}`)!.gain.gain.ramps.slice(-2)).toEqual([
['set', 0, 2.05],
['linear', 0, 2.05 + CUT],
]);
clockTo();
expect(library.playbacks().map((p) => p.id)).toEqual([id]);
// The old one's buses are let go, though a playing of its number plays on.
expect(library.busLevels().filter((bus) => bus.name.startsWith(`play:${id}`))).toEqual([]);
expect(
library.switchSequence(
id + 99,
'song:s',
sequence(() => 0),
at,
0,
),
).toBe(false);
});
});
describe('the automation of the tracks of a song (#337)', () => {
+112 -2
View File
@@ -937,7 +937,10 @@ export async function song(t) {
{ ...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} };
song.markers = [{ id: 'sg65-mark', name: 'Round', tick: ${BAR} }];`);
song.markers = [
{ id: 'sg65-mark', name: 'Round', tick: ${BAR} },
{ id: 'sg65-half', name: 'Half', tick: ${BAR + BAR / 2} },
];`);
await showTab('scene');
// 65k2 hears it through these (#338): each beat logs bar, beat, how late it is told and where
// Song position stands then, each marker its name.
@@ -969,6 +972,20 @@ export async function song(t) {
node('sg65-marker', 'OnSongMarker', 0, 800, { song: id }),
node('sg65-jm', 'Join', 200, 800, { a: 'marker ' }),
node('sg65-logm', 'Log', 400, 800),
// 65k3 (#338): on a beat with W held, Switch song to "Half" on the next bar; with V held, the
// track of the pattern turned down to silence there.
node('sg65-cue', 'OnBeat', 0, 1000, { song: id }),
node('sg65-w', 'KeyIsDown', 0, 1200, { key: 'w' }),
node('sg65-wif', 'Branch', 260, 1000),
node('sg65-switch', 'SwitchSong', 520, 1000, { marker: 'sg65-half', quantize: 'bar', fade: 0 }),
node('sg65-v', 'KeyIsDown', 0, 1400, { key: 'v' }),
node('sg65-vif', 'Branch', 520, 1200),
node('sg65-turn', 'SetSongTrack', 780, 1200, {
track: tracks[1],
volume: -60,
fade: 0,
quantize: 'bar',
}),
],
edges: [
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
@@ -988,6 +1005,14 @@ export async function song(t) {
edge('sg65-m1', 'sg65-marker', 'name', 'sg65-jm', 'b'),
edge('sg65-m2', 'sg65-marker', 'exec', 'sg65-logm', 'exec'),
edge('sg65-m3', 'sg65-jm', 'text', 'sg65-logm', 'message'),
edge('sg65-c1', 'sg65-cue', 'exec', 'sg65-wif', 'exec'),
edge('sg65-c2', 'sg65-w', 'down', 'sg65-wif', 'condition'),
edge('sg65-c3', 'sg65-wif', 'then', 'sg65-switch', 'exec'),
edge('sg65-c4', 'sg65-cue', 'playback', 'sg65-switch', 'playback'),
edge('sg65-c5', 'sg65-wif', 'else', 'sg65-vif', 'exec'),
edge('sg65-c6', 'sg65-v', 'down', 'sg65-vif', 'condition'),
edge('sg65-c7', 'sg65-vif', 'then', 'sg65-turn', 'exec'),
edge('sg65-c8', 'sg65-cue', 'playback', 'sg65-turn', 'playback'),
],
},
});
@@ -1066,11 +1091,96 @@ export async function song(t) {
beats.every((b) => b.late >= 0 && b.late <= 0.15 && Math.abs(b.fraction * 0.5 - b.late) < 0.02),
JSON.stringify({ beats, markersHeard: markersHeard.length }),
);
// 65k3. Music follows the game (#338): W held over a beat asks Switch song for "Half", bar 2 beat 3,
// on the next bar. The playing keeps its number on buses of its own, heard from that bar line
// to the sample; On beat starts over from the marker. V held turns the pattern's track down to
// silence on the bar after, on its input, its level as the song has it.
const clockNow = () => json('__gm.sound.clock()');
const firstPlaying = (await playbacks())[0];
const linesBefore = (await heardLines()).length;
await t.holdKey('w', 600);
const pair = await waitFor(playbacks, (list) => list.length === 2, 20);
const landed = await waitFor(
playbacks,
(list) => list.length === 1 && list[0].startedAt !== firstPlaying?.startedAt,
40,
);
const switchedTo = landed[0];
const beatsSince = (lines) =>
lines.slice(linesBefore).flatMap((text) => {
const m = /\bbeat (\d+)\.(\d+) /.exec(text);
return m ? [`${m[1]}.${m[2]}`] : [];
});
// Only the jump to the marker hears 2.3 right after 2.4: the loop goes from 2.4 to 2.1.
const jumpIn = (beats) => beats.findIndex((b, i) => b === '2.4' && beats[i + 1] === '2.3') + 1;
const sinceSwitch = beatsSince(
await waitFor(
heardLines,
(lines) => {
const beats = beatsSince(lines);
const at = jumpIn(beats);
return at > 0 && beats.length > at + 1;
},
40,
),
);
const fromHalf = jumpIn(sinceSwitch) || -1;
const bars = switchedTo && firstPlaying ? (switchedTo.startedAt - firstPlaying.startedAt) / 2 : NaN;
check(
'Switch song plays the song from its marker on the next bar line, to the sample, under the same number; On beat goes on from the marker',
pair.length === 2 &&
pair.every((p) => p.id === firstPlaying?.id) &&
switchedTo?.id === firstPlaying?.id &&
Math.abs(bars - Math.round(bars)) < 1e-6 &&
Math.round(bars) >= 1 &&
fromHalf >= 0 &&
sinceSwitch[fromHalf + 1] === '2.4',
JSON.stringify({
firstPlaying,
pair: pair.map((p) => [p.id, p.startedAt]),
switchedTo,
bars,
sinceSwitch,
}),
);
const newMaster = (await json('__gm.song.buses()')).find((b) => /^play:\d+$/.test(b.name))?.name;
const pattern = `${newMaster}:${busOf(tracks[1])}`;
const turnedOf = async () => {
const at = await clockNow();
const bus = (await json('__gm.song.buses()')).find((b) => b.name === pattern);
return { at, turned: bus?.turned ?? 1, gain: bus?.gain };
};
// Read from before V on until it is silent: a cut, so 1 up to a bar line and 0 from it, nothing
// between. The switched song started on beat 3 of bar 2, so its bar lines lie 1 s after it, then
// every 2 s.
const turns = [await turnedOf()];
await t.keyDown('v');
const silent = await waitFor(
async () => (turns.push(await turnedOf()), turns.at(-1)),
(r) => r.turned === 0,
40,
);
await t.keyUp('v');
const lastOn = turns.findLast((r) => r.turned === 1);
const firstBar = (switchedTo?.startedAt ?? 0) + 1;
const barLine = lastOn && firstBar + Math.max(0, Math.ceil((lastOn.at - 0.05 - firstBar) / 2)) * 2;
check(
'Set song track turns the pattern’s track to silence on the next bar line, its own level left as the song has it',
newMaster !== `play:${firstPlaying?.id}` &&
silent.turned === 0 &&
turns.every((r) => r.turned === 1 || r.turned === 0) &&
barLine !== undefined &&
silent.at >= barLine - 0.05 &&
silent.at < barLine + 0.5 &&
Math.abs(silent.gain - 10 ** (-6 / 20)) < 1e-6,
JSON.stringify({ newMaster, barLine, turns }),
);
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));
// Every playing's, the one 65k3 switched to among them.
const leftBuses = (await json('__gm.song.buses()')).filter((b) => /^play:/.test(b.name));
await stop();
check(
'Stop song fades it out over its second, playing on meanwhile, then it and its buses are gone',