Let a sound, note or pattern land on the beat of the music (#338)
A stinger has to start on the next beat or bar of the song that is playing, not whenever the frame happens to run. Play sound, Play note and Play pattern now take the number of a song or pattern playback and plan their start on the audio clock, as the music is heard. - New inputs sync (a playback number) and quantize (Next bar by default, decision 6) on Play sound, Play note and Play pattern. - The landing is worked out in Playbacks (landOn), on the next beat, bar or marker of the playback as it is heard. SoundLibrary and the audio switch only pass it on, and only on the clock the stinger plays on. - A pattern with sync plays in the tempo of the playback. It takes its bake only when both tempos match. - Without a link, or when the playback is not heard, the node starts at once as before. - The sync ports live in their own module musicSync.ts. Putting them in songSync.ts would have made an import cycle through Play song. Tests: - Unit: 8 new (6 in song-play, 1 each in sound-library and audio-switch), all green. - Counter-probes: 12 bends, all red. - e2e 65 on atx: ALL PASSED, 74 checks. New 65k4: the pattern (own tempo 100) started exactly 8 beats after the song and played at the song's 120 BPM. Without sync it is red (6.2 beats, 100 BPM). - Gate on atx: CHECK EXIT 0, 334 files, 5152 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
53ec1094f4
commit
e390d39314
16 files changed
+413
-42
No files matched your search
@@ -20,6 +20,15 @@ export const ENTITY_HANDLE: unique symbol = Symbol('entity handle');
|
||||
* What a graph sees of a running entity; the runtime's Entity satisfies this shape. Mutating
|
||||
* `x`/`y`/`props` is how actions take effect.
|
||||
*/
|
||||
/**
|
||||
* A sound, note or pattern started in time with a playing song or pattern (#338), a stinger: on the
|
||||
* spot `quantize` names of the playing numbered `playback`; 0 for none, which starts it at once.
|
||||
*/
|
||||
export interface MusicSync {
|
||||
playback: number;
|
||||
quantize: Quantize;
|
||||
}
|
||||
|
||||
export interface EntityHandle {
|
||||
readonly [ENTITY_HANDLE]: true;
|
||||
id: string;
|
||||
@@ -247,7 +256,8 @@ export interface NodeContext {
|
||||
* names (#352), `NO_FX` none.
|
||||
* `fxParams` are values for its parameters; one not given is the default the FX declares.
|
||||
* `tempo` is what a Delay synced to beats counts by (#332). `bus` is the bus it is heard on
|
||||
* (#325): a bus asset or `MASTER_BUS`, `''` for the one the sound asset names.
|
||||
* (#325): a bus asset or `MASTER_BUS`, `''` for the one the sound asset names. `sync` starts it in
|
||||
* time with a playing (#338).
|
||||
* Returns the number of the voice for `tuneSound`, or 0 where nothing is played.
|
||||
*/
|
||||
playSound(
|
||||
@@ -259,6 +269,7 @@ export interface NodeContext {
|
||||
fxParams: FxParams,
|
||||
tempo: number,
|
||||
bus: string,
|
||||
sync: MusicSync,
|
||||
): number;
|
||||
/**
|
||||
* Plays one note of an instrument asset (#331): `note` as MIDI counts it (60 is C4), struck with
|
||||
@@ -267,7 +278,7 @@ export interface NodeContext {
|
||||
* values for the instrument's parameters, `tempo` as for `playSound`. `fx` sends the note through a
|
||||
* sound FX of its own (#346), `fxParams` with values for its parameters, apart from the instrument's;
|
||||
* `''` the one the instrument names, `NO_FX` none (#352).
|
||||
* `bus` as for `playSound`, `''` for the one the instrument names.
|
||||
* `bus` as for `playSound`, `''` for the one the instrument names, and `sync` too.
|
||||
* Returns the number of the voice, 0 where nothing plays.
|
||||
*/
|
||||
playNote(
|
||||
@@ -282,13 +293,14 @@ export interface NodeContext {
|
||||
fx: string,
|
||||
fxParams: FxParams,
|
||||
bus: string,
|
||||
sync: MusicSync,
|
||||
): number;
|
||||
/**
|
||||
* Plays a pattern asset through its instrument, at its own tempo, once or round and round, every
|
||||
* note at `volume`. Owned like a `playSound` voice. `fx` is a sound FX all of it goes through as
|
||||
* one chain (#346), `''` the one the pattern names, `NO_FX` none (#352); `fxParams` values for its
|
||||
* parameters, `bus` as for `playSound`. Returns its
|
||||
* number, for `stopVoice` and `tuneSound`; 0 where there is nothing to play.
|
||||
* parameters, `bus` as for `playSound`. Synced to a playing (#338), it starts on its spot and plays
|
||||
* at its tempo. Returns its number, for `stopVoice` and `tuneSound`; 0 where there is nothing to play.
|
||||
*/
|
||||
playPattern(
|
||||
patternId: string,
|
||||
@@ -298,6 +310,7 @@ export interface NodeContext {
|
||||
fx: string,
|
||||
fxParams: FxParams,
|
||||
bus: string,
|
||||
sync: MusicSync,
|
||||
): number;
|
||||
/** Stops every playing of that pattern, `''` every pattern; `letRing` lets its notes fall away in their release. */
|
||||
stopPattern(patternId: string, letRing: boolean): void;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { heldTempo } from '../../../music/time';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asNumber, asString } from '../../values';
|
||||
import { detachedPort, exec, fxParamsPort, num, playedBusPort, soundFxPort, withDefault } from '../shared';
|
||||
import { quantizePort, SYNC, syncOf, syncPort } from './musicSync';
|
||||
import { fxParamsOf, tempoPort } from './PlaySound';
|
||||
|
||||
export const PlayNote: NodeDef = {
|
||||
@@ -10,7 +11,8 @@ export const PlayNote: NodeDef = {
|
||||
label: 'Play note',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does; left empty, the one the instrument names, and None through none. "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute. "bus" is the bus it is heard on; left empty, the one the instrument names.',
|
||||
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does; left empty, the one the instrument names, and None through none. "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute. "bus" is the bus it is heard on; left empty, the one the instrument names. ' +
|
||||
SYNC,
|
||||
inputs: [
|
||||
exec(),
|
||||
{
|
||||
@@ -41,6 +43,8 @@ export const PlayNote: NodeDef = {
|
||||
{ ...soundFxPort(), key: 'soundFx' },
|
||||
fxParamsPort('soundFxParams'),
|
||||
playedBusPort(),
|
||||
syncPort(),
|
||||
quantizePort(),
|
||||
],
|
||||
outputs: [exec(), num('voice')],
|
||||
params: [],
|
||||
@@ -59,6 +63,7 @@ export const PlayNote: NodeDef = {
|
||||
asString(inputs.soundFx),
|
||||
fxParamsOf(inputs.soundFxParams),
|
||||
asString(inputs.bus),
|
||||
syncOf(inputs),
|
||||
)
|
||||
: 0;
|
||||
return { next: 'exec', outputs: { voice } };
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
soundFxPort,
|
||||
withDefault,
|
||||
} from '../shared';
|
||||
import { quantizePort, SYNC, syncOf, syncPort } from './musicSync';
|
||||
import { fxParamsOf } from './PlaySound';
|
||||
|
||||
export const patternPort = (): PortDef => ({
|
||||
@@ -34,7 +35,8 @@ export const PlayPattern: NodeDef = {
|
||||
'none. "fx params" takes values for its parameters from a Sound ' +
|
||||
'FX parameters node. "playback" is the number of this playing, for Stop pattern and for Set sound ' +
|
||||
'FX parameters; 0 where the pattern has no instrument or no notes. "bus" is the bus all of it is ' +
|
||||
'heard on; left empty, the one the pattern names.',
|
||||
`heard on; left empty, the one the pattern names. ${SYNC} Synced, it plays at the tempo of that ` +
|
||||
'playing.',
|
||||
inputs: [
|
||||
exec(),
|
||||
patternPort(),
|
||||
@@ -44,6 +46,8 @@ export const PlayPattern: NodeDef = {
|
||||
soundFxPort(),
|
||||
fxParamsPort(),
|
||||
playedBusPort(),
|
||||
syncPort(),
|
||||
quantizePort(),
|
||||
],
|
||||
outputs: [exec(), num('playback')],
|
||||
params: [],
|
||||
@@ -58,6 +62,7 @@ export const PlayPattern: NodeDef = {
|
||||
asString(inputs.fx),
|
||||
fxParamsOf(inputs.fxParams),
|
||||
asString(inputs.bus),
|
||||
syncOf(inputs),
|
||||
)
|
||||
: 0;
|
||||
return { next: 'exec', outputs: { playback } };
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
soundPort,
|
||||
withDefault,
|
||||
} from '../shared';
|
||||
import { quantizePort, SYNC, syncOf, syncPort } from './musicSync';
|
||||
|
||||
/**
|
||||
* Three answers, not two: a checkbox cannot say "leave it to the asset", and without that a sound
|
||||
@@ -64,7 +65,7 @@ export const PlaySound: NodeDef = {
|
||||
'the sound has not been loaded yet. A pitch never changes it. "tail" is that length and the ' +
|
||||
'echoes of its FX after it, as they are when it starts – how long until the voice is quiet. ' +
|
||||
'"tempo" is what a Delay of its FX synced to beats counts by, in quarter notes a minute. "bus" is ' +
|
||||
'the bus it is heard on; left empty, the one the sound names.',
|
||||
`the bus it is heard on; left empty, the one the sound names. ${SYNC}`,
|
||||
inputs: [
|
||||
exec(),
|
||||
soundPort(),
|
||||
@@ -75,6 +76,8 @@ export const PlaySound: NodeDef = {
|
||||
fxParamsPort(),
|
||||
tempoPort(),
|
||||
playedBusPort(),
|
||||
syncPort(),
|
||||
quantizePort(),
|
||||
],
|
||||
outputs: [exec(), num('voice'), num('length'), num('tail')],
|
||||
params: [],
|
||||
@@ -90,6 +93,7 @@ export const PlaySound: NodeDef = {
|
||||
fxParamsOf(inputs.fxParams),
|
||||
heldTempo(asNumber(inputs.tempo)),
|
||||
asString(inputs.bus),
|
||||
syncOf(inputs),
|
||||
)
|
||||
: 0;
|
||||
const length = voice ? ctx.soundLength(sound) : 0;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { heldQuantize, QUANTIZE_OPTIONS } from '../../../music/beats';
|
||||
import type { MusicSync } from '../../NodeContext';
|
||||
import type { PortDef } from '../../NodeRegistry';
|
||||
import { asNumber, type Value } from '../../values';
|
||||
import { num } from '../shared';
|
||||
|
||||
/** Where a change to the music lands (#338): at once, or on the next beat, bar or marker of the playing. */
|
||||
export 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 sound, note or pattern starts in time with (#338), a stinger: not typed in either. */
|
||||
export const syncPort = (): PortDef => ({ ...num('sync'), inline: false });
|
||||
|
||||
/** What `syncPort` and `quantizePort` ask for. */
|
||||
export function syncOf(inputs: Record<string, Value>): MusicSync {
|
||||
return { playback: asNumber(inputs.sync), quantize: heldQuantize(inputs.quantize) };
|
||||
}
|
||||
|
||||
/** How the nodes that play a sound, note or pattern tell of `sync` (#338). */
|
||||
export const SYNC =
|
||||
'"sync" takes the playback number of a playing song or pattern to start in time with, as a ' +
|
||||
'stinger: on the next beat, bar or marker of it as it is heard, as "quantize" says, planned on ' +
|
||||
'the audio clock to the sample. Unconnected, or where that playing is not heard, it starts at once.';
|
||||
@@ -1,19 +1,11 @@
|
||||
import { heldQuantize, QUANTIZE_OPTIONS } from '../../../music/beats';
|
||||
import { heldQuantize } 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 { quantizePort } from './musicSync';
|
||||
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 });
|
||||
|
||||
|
||||
@@ -80,6 +80,11 @@ export interface SequencePlay {
|
||||
* waits for its files, it starts as much further in, to keep to the clock it was asked for by.
|
||||
*/
|
||||
from?: number;
|
||||
/**
|
||||
* The second of the audio clock it is heard from (#338), where that is still to come once its
|
||||
* files are there: a stinger in time with another playing (`Playbacks.landOn`).
|
||||
*/
|
||||
at?: number;
|
||||
}
|
||||
|
||||
/** A bake as it is played: its file, the level it is heard at, and which stretches of it. */
|
||||
@@ -207,7 +212,7 @@ export class Playbacks {
|
||||
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);
|
||||
playback.runner.start(playback.time.from, play.at);
|
||||
// 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));
|
||||
@@ -282,6 +287,18 @@ export class Playbacks {
|
||||
return { time, startedAt: playing.runner.startedAt, now, soonest: now + CUT, baked };
|
||||
}
|
||||
|
||||
/**
|
||||
* The second of the audio clock `at` names on the clock of the playing of that number heard now
|
||||
* (#338), no sooner than something started now can be heard: where a stinger in time with it
|
||||
* starts. Null where none is heard.
|
||||
*/
|
||||
landOn(id: number, at: LandingAt): number | null {
|
||||
const clock = this.clockOf(id);
|
||||
if (!clock) return null;
|
||||
const soonest = clock.now + LEAD;
|
||||
return Math.max(soonest, at({ ...clock, soonest }));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -46,8 +46,16 @@ export class AudioSwitch implements AudioSink {
|
||||
return id >= DIVERTED_IDS ? this.aside : this.live;
|
||||
}
|
||||
|
||||
play(file: string, volume: number, loop: boolean, owner: string, fx?: FxVoice, bus?: string): number {
|
||||
return this.now.play(file, volume, loop, owner, fx, bus);
|
||||
play(
|
||||
file: string,
|
||||
volume: number,
|
||||
loop: boolean,
|
||||
owner: string,
|
||||
fx?: FxVoice,
|
||||
bus?: string,
|
||||
at?: number,
|
||||
): number {
|
||||
return this.now.play(file, volume, loop, owner, fx, bus, at);
|
||||
}
|
||||
|
||||
playNote(
|
||||
@@ -139,6 +147,12 @@ export class AudioSwitch implements AudioSink {
|
||||
return this.both.flatMap((sink) => sink.playingTimes?.() ?? []);
|
||||
}
|
||||
|
||||
/** Only on the clock of the sink a stinger is played in: one diverted since counts on another. */
|
||||
landOn(id: number, at: LandingAt): number | null {
|
||||
const sink = this.of(id);
|
||||
return sink === this.now ? (sink.landOn?.(id, at) ?? null) : null;
|
||||
}
|
||||
|
||||
switchSequence(id: number, name: string, play: SequencePlay, at: LandingAt, fade: number): boolean {
|
||||
return this.of(id)?.switchSequence?.(id, name, play, at, fade) ?? false;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,18 @@ export interface AudioSink {
|
||||
* `''` means the voice has no owner and outlives whatever started it. `fx` is the sound FX the
|
||||
* voice passes on its way out (#311), with the values of its parameters; `DRY_VOICE` passes none.
|
||||
* Returns the number of the voice, never used twice, or 0 where nothing is going to play. `bus` is
|
||||
* the bus of the mix it is heard on (#325), the master where left out.
|
||||
* the bus of the mix it is heard on (#325), the master where left out. `at` is the second of the
|
||||
* audio clock it is heard from (#338), now where it is left out or already past.
|
||||
*/
|
||||
play(file: string, volume: number, loop: boolean, owner: string, fx?: FxVoice, bus?: string): number;
|
||||
play(
|
||||
file: string,
|
||||
volume: number,
|
||||
loop: boolean,
|
||||
owner: string,
|
||||
fx?: FxVoice,
|
||||
bus?: string,
|
||||
at?: number,
|
||||
): number;
|
||||
/**
|
||||
* Starts one note of an instrument (#331), as `play` starts a sound: `layers` are what the note is
|
||||
* made of, played side by side as one voice, each way reading the file of its own source. `name`
|
||||
@@ -113,6 +122,12 @@ export interface AudioSink {
|
||||
* what beats, bars and `Song position` go by. A sink without it plays none to be followed.
|
||||
*/
|
||||
playingTimes?(): readonly HeardTime[];
|
||||
/**
|
||||
* The second of the audio clock `at` names on the clock of the playing numbered `id` (#338), no
|
||||
* sooner than something started now is heard: where a stinger in time with it starts. Null where
|
||||
* no such playing is heard; a sink without it syncs nothing.
|
||||
*/
|
||||
landOn?(id: number, at: LandingAt): number | null;
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* the world, the keyboard, collisions and rays, physics, sound, the clock and the shared values.
|
||||
* Handles arriving from a graph are resolved to the world's entities here, so no node holds one.
|
||||
*/
|
||||
import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../../core/graph';
|
||||
import type { EntityHandle, MusicSync, NodeContext, PhysicsControl, SharedState } from '../../core/graph';
|
||||
import { keyName } from '../../core/keys';
|
||||
import { collisionFilterOf } from '../../core/model/collisionLayers';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
@@ -198,20 +198,56 @@ export function behaviorContext(
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[behavior ${entity.name}]`, message);
|
||||
},
|
||||
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus) =>
|
||||
playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams, tempo, bus),
|
||||
playNote: (instrumentId, note, velocity, volume, duration, detached, params, tempo, fx, fxParams, bus) =>
|
||||
playNote(
|
||||
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) =>
|
||||
playSound(
|
||||
run,
|
||||
assetId,
|
||||
volume,
|
||||
loop,
|
||||
detached ? '' : entity.id,
|
||||
fx,
|
||||
fxParams,
|
||||
tempo,
|
||||
bus,
|
||||
syncedStart(run, sync)?.at,
|
||||
),
|
||||
playNote: (
|
||||
instrumentId,
|
||||
note,
|
||||
velocity,
|
||||
volume,
|
||||
duration,
|
||||
detached,
|
||||
params,
|
||||
tempo,
|
||||
fx,
|
||||
fxParams,
|
||||
bus,
|
||||
sync,
|
||||
) => {
|
||||
const synced = syncedStart(run, sync);
|
||||
return playNote(
|
||||
run,
|
||||
instrumentId,
|
||||
{ note, velocity, volume, duration },
|
||||
detached ? '' : entity.id,
|
||||
params,
|
||||
{ tempo, bus: instrumentBus(project, instrumentId, bus) },
|
||||
{ tempo, bus: instrumentBus(project, instrumentId, bus), ...(synced && { at: synced.at }) },
|
||||
fxVoiceOf(project, instrumentFx(project, instrumentId, fx), tempo, fxParams),
|
||||
);
|
||||
},
|
||||
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus, sync) =>
|
||||
playPattern(
|
||||
run,
|
||||
patternId,
|
||||
volume,
|
||||
loop,
|
||||
detached ? '' : entity.id,
|
||||
fx,
|
||||
fxParams,
|
||||
bus,
|
||||
syncedStart(run, sync),
|
||||
),
|
||||
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, live) =>
|
||||
playSong(run, songId, {
|
||||
@@ -327,6 +363,7 @@ function playSound(
|
||||
fxParams: FxParams,
|
||||
tempo: number,
|
||||
bus: string,
|
||||
at?: number,
|
||||
): number {
|
||||
const ref = findSound(project, assetId);
|
||||
if (!ref?.asset.file) return 0;
|
||||
@@ -340,9 +377,31 @@ function playSound(
|
||||
owner,
|
||||
fx ?? DRY_VOICE,
|
||||
playedBus(project, bus, ref.asset.id),
|
||||
at,
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a sound, note or pattern synced to a playing starts (#338), and the tempo that one plays at. */
|
||||
interface SyncedStart {
|
||||
at: number;
|
||||
tempo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The start of a stinger (#338): on the spot `sync.quantize` names of the playing it is synced to,
|
||||
* as that is heard. Null where it is not synced, or nothing of that number is heard: it starts at once.
|
||||
*/
|
||||
function syncedStart(
|
||||
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
|
||||
sync: MusicSync,
|
||||
): SyncedStart | null {
|
||||
if (!(sync.playback > 0)) return null;
|
||||
const playing = heardPlayings(project, audio).find((p) => p.id === sync.playback);
|
||||
if (!playing) return null;
|
||||
const at = audio.landOn?.(sync.playback, landing(sync.quantize, playing)) ?? null;
|
||||
return at === null ? null : { at, tempo: playing.timeline.tempo };
|
||||
}
|
||||
|
||||
/** The sound FX a note of that instrument goes through, by the pick of a node (#352); '' for none. */
|
||||
export function instrumentFx(project: Project, instrumentId: string, picked: string): string {
|
||||
return pickedFx(picked, findAssetOf(project, instrumentId, 'instrument')?.props ?? {});
|
||||
@@ -414,7 +473,9 @@ function heardOn(route: MixRoute): Pick<SequencePlay, 'bus' | 'sends'> {
|
||||
* A pattern played by a game: its notes through its instrument at its tempo, the whole of it once or
|
||||
* round and round – the loop set on its ruler is for editing. Its notes take the volume and the owner
|
||||
* of the playing; its bake (#340) takes the volume where it still holds, and is played instead.
|
||||
* Returns the number of the playing, 0 where there is no pattern or nothing to hear.
|
||||
* `synced` starts it in time with another playing (#338), at that one's tempo: its bake is of its
|
||||
* own tempo, so only that plays it. Returns the number of the playing, 0 where there is no pattern
|
||||
* or nothing to hear.
|
||||
*/
|
||||
function playPattern(
|
||||
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
|
||||
@@ -425,6 +486,7 @@ function playPattern(
|
||||
fxId: string,
|
||||
fxParams: FxParams,
|
||||
bus: string,
|
||||
synced: SyncedStart | null = null,
|
||||
): number {
|
||||
const asset = findAssetOf(project, patternId, 'pattern');
|
||||
if (!asset) return 0;
|
||||
@@ -433,9 +495,10 @@ function playPattern(
|
||||
if (!instrument || sequence.notes.length === 0) return 0;
|
||||
const level = heldLevel(volume);
|
||||
const length = patternLength(asset);
|
||||
const tempo = patternTempo(asset.props);
|
||||
const own = patternTempo(asset.props);
|
||||
const tempo = synced?.tempo ?? own;
|
||||
const fx = fxVoiceOf(project, pickedFx(fxId, asset.props), tempo, fxParams);
|
||||
const bake = validBake(project, asset);
|
||||
const bake = tempo === own ? validBake(project, asset) : null;
|
||||
return audio.playSequence(
|
||||
patternVoiceName(patternId),
|
||||
{
|
||||
@@ -448,6 +511,7 @@ function playPattern(
|
||||
...heardOn(playedRoute(project, bus, patternId)),
|
||||
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
|
||||
...(bake && { bake: { file: bake.file, gain: bake.peak * level, playback: bakePlayback(bake, loop) } }),
|
||||
...(synced && { at: synced.at }),
|
||||
},
|
||||
owner,
|
||||
);
|
||||
|
||||
@@ -501,9 +501,18 @@ export class SoundLibrary implements AudioSink {
|
||||
|
||||
/**
|
||||
* The number is handed out at once, before the bytes are there: a graph can tune a voice in the
|
||||
* same frame it asked for it, and the voice then starts with those values.
|
||||
* same frame it asked for it, and the voice then starts with those values. `at` starts it at that
|
||||
* second of the audio clock (#338); bytes that come later start it then.
|
||||
*/
|
||||
play(file: string, volume: number, loop: boolean, owner = '', fx: FxVoice = DRY_VOICE, bus = ''): number {
|
||||
play(
|
||||
file: string,
|
||||
volume: number,
|
||||
loop: boolean,
|
||||
owner = '',
|
||||
fx: FxVoice = DRY_VOICE,
|
||||
bus = '',
|
||||
at = 0,
|
||||
): number {
|
||||
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);
|
||||
@@ -516,7 +525,7 @@ export class SoundLibrary implements AudioSink {
|
||||
owner,
|
||||
fx: layered,
|
||||
duration: 0,
|
||||
at: 0,
|
||||
at,
|
||||
bus,
|
||||
...(stream && { stream }),
|
||||
});
|
||||
@@ -1378,6 +1387,11 @@ export class SoundLibrary implements AudioSink {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Where a stinger in time with the playing numbered `id` starts (#338), see `Playbacks.landOn`. */
|
||||
landOn(id: number, at: LandingAt): number | null {
|
||||
return this.sequences.landOn(id, at);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -77,4 +77,17 @@ describe('AudioSwitch (#423)', () => {
|
||||
expect(live.calls).toEqual(['sequence composition:c', 'stopPlayback 1']);
|
||||
expect(aside.calls).toEqual(['sequence composition:c']);
|
||||
});
|
||||
|
||||
it('lands a stinger only on the clock of the sink it plays in (#338)', () => {
|
||||
const live = { ...sinkFrom(1, 5), landOn: () => 6 };
|
||||
const aside = { ...sinkFrom(DIVERTED_IDS, 0.25), landOn: () => 0.5 };
|
||||
const audio = new AudioSwitch(live);
|
||||
const song = audio.playSequence('song:s', {} as never, '');
|
||||
expect(audio.landOn(song, () => 0)).toBe(6);
|
||||
audio.divert(aside);
|
||||
// The song plays on live, the stinger would play diverted: the two clocks do not meet.
|
||||
expect(audio.landOn(song, () => 0)).toBeNull();
|
||||
const there = audio.playSequence('song:s', {} as never, '');
|
||||
expect(audio.landOn(there, () => 0)).toBe(0.5);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
|
||||
import { SetSongCommand, SetSongMarkersCommand } from '../../src/core/commands/songCommands';
|
||||
import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset';
|
||||
import type { InstrumentAsset, PatternAsset, SongAsset } from '../../src/core/model/Asset';
|
||||
import { bakePlayback, bakePrint, bakeSpans } from '../../src/core/music/bake';
|
||||
import {
|
||||
CUT,
|
||||
@@ -348,6 +348,35 @@ describe('a song switched to in place of another (#338)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stinger in time with a playing (#338)', () => {
|
||||
it('lands where the playing heard now says, no sooner than something started now is heard', async () => {
|
||||
const { playbacks, loaded, run } = fakeHost();
|
||||
playbacks.start(5, 'song:a', song([]), '');
|
||||
await loaded();
|
||||
run(1);
|
||||
const asked: PlayingClock[] = [];
|
||||
expect(playbacks.landOn(5, (clock) => (asked.push(clock), clock.startedAt! + 2))).toBeCloseTo(
|
||||
2 + LEAD,
|
||||
9,
|
||||
);
|
||||
expect(asked.map((c) => [c.startedAt, +c.soonest.toFixed(9)])).toEqual([[LEAD, +(1 + LEAD).toFixed(9)]]);
|
||||
expect(playbacks.landOn(5, () => 0.5)).toBeCloseTo(1 + LEAD, 9);
|
||||
expect(playbacks.landOn(6, () => 2)).toBeNull();
|
||||
});
|
||||
|
||||
it('a pattern asked to start there plays its first note there, its level rising from there', async () => {
|
||||
const { host, playbacks, loaded, run } = fakeHost();
|
||||
const notes: (ScheduledNote | ScheduledClip)[] = [];
|
||||
playbacks.start(7, 'pattern:p', song(notes, { at: 2 }), '');
|
||||
await loaded();
|
||||
expect(host.levels.map(({ id, at }) => [id, at])).toEqual([[7, 2]]);
|
||||
run(1);
|
||||
expect(notes).toEqual([]);
|
||||
run(1);
|
||||
expect((notes as ScheduledNote[])[0]?.at).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A scene whose behavior plays a song on start: on its first track a two-note pattern at bar 1, on
|
||||
* its second a sound at bar 2, the second track at -6 dB and panned left, the third soloed.
|
||||
@@ -403,7 +432,17 @@ function recordingAudio() {
|
||||
sequences: [] as { name: string; play: SequencePlay; owner: string }[],
|
||||
notes: [] as { name: string; volume: number; owner: string; bus?: string; at?: number }[],
|
||||
clips: [] as { file: string; clip: ClipTiming }[],
|
||||
sounds: [] as { file: string; at?: number }[],
|
||||
stopped: [] as string[],
|
||||
play: ((file, _volume, _loop, _owner, _fx, _bus, at) => {
|
||||
sink.sounds.push({ file, ...(at !== undefined && { at }) });
|
||||
return 3;
|
||||
}) satisfies AudioSink['play'],
|
||||
/** A stinger lands on the clock of the playing it is synced to (#338), as a switch does. */
|
||||
landOn: (id: number, at: LandingAt) => {
|
||||
const playing = sink.sequences[id - 1001];
|
||||
return playing ? at(sink.clockOf(playing.play)) : null;
|
||||
},
|
||||
playSequence: (name: string, play: SequencePlay, owner: string) => {
|
||||
sink.sequences.push({ name, play, owner });
|
||||
return 1000 + sink.sequences.length;
|
||||
@@ -645,3 +684,77 @@ describe('Set song track and Switch song (#338)', () => {
|
||||
expect([audio.switched, world.log]).toEqual([[], ['Switch song: song "Theme" has no marker "Rage"']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stingers: a sound, note or pattern synced to a playing (#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;
|
||||
const assetOf = (f: ReturnType<typeof playingSong>, type: string) =>
|
||||
f.project.assets.find((a) => a.type === type)!.id;
|
||||
|
||||
it('a pattern starts on the next beat of the song, at its tempo', () => {
|
||||
const f = playingSong();
|
||||
const sting = f.edit.addNode('PlayPattern', {
|
||||
pattern: assetOf(f, 'pattern'),
|
||||
loop: false,
|
||||
quantize: 'beat',
|
||||
});
|
||||
f.edit.connect(f.play, 'exec', sting, 'exec');
|
||||
f.edit.connect(f.play, 'playback', sting, 'sync');
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
const [, stinger] = audio.sequences;
|
||||
expect([stinger?.name, stinger?.play.tempo, stinger?.play.at]).toEqual([
|
||||
`pattern:${assetOf(f, 'pattern')}`,
|
||||
90,
|
||||
expect.closeTo(10 + BEAT, 9),
|
||||
]);
|
||||
});
|
||||
|
||||
it('its bake is of its own tempo: at the song’s it plays its notes, at its own its bake', () => {
|
||||
const bakedStinger = (tempo: number) => {
|
||||
const f = playingSong();
|
||||
const pattern = f.project.assets.find((a) => a.type === 'pattern') as PatternAsset;
|
||||
f.stack.execute(new SetAssetPropertyCommand(pattern.id, 'tempo', tempo));
|
||||
const spans = bakeSpans({ tempo, length: BAR, loop: { start: 0, end: BAR } });
|
||||
pattern.bake = { file: 'riff.ogg', print: bakePrint(f.project, pattern), peak: 1, bytes: 1, ...spans };
|
||||
const sting = f.edit.addNode('PlayPattern', { pattern: pattern.id, loop: false, quantize: 'beat' });
|
||||
f.edit.connect(f.play, 'exec', sting, 'exec');
|
||||
f.edit.connect(f.play, 'playback', sting, 'sync');
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
return audio.sequences[1]?.play.bake?.file;
|
||||
};
|
||||
expect([bakedStinger(120), bakedStinger(90)]).toEqual([undefined, 'riff.ogg']);
|
||||
});
|
||||
|
||||
it('a sound on the next bar, a note at once: as soon as it can be heard', () => {
|
||||
const f = playingSong();
|
||||
const sound = f.edit.addNode('PlaySound', { sound: assetOf(f, 'sound') });
|
||||
const note = f.edit.addNode('PlayNote', { instrument: f.lead.id, duration: 0.5, quantize: 'now' });
|
||||
f.edit.connect(f.play, 'exec', sound, 'exec');
|
||||
f.edit.connect(sound, 'exec', note, 'exec');
|
||||
f.edit.connect(f.play, 'playback', sound, 'sync');
|
||||
f.edit.connect(f.play, 'playback', note, 'sync');
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.sounds.map((s) => [s.file, s.at])).toEqual([[FILE, expect.closeTo(10 + 4 * BEAT, 9)]]);
|
||||
expect(audio.notes.map((n) => n.at)).toEqual([10.31]);
|
||||
});
|
||||
|
||||
it('unsynced, or synced to nothing heard, it starts at once, a pattern at its own tempo', () => {
|
||||
const f = playingSong();
|
||||
const own = f.edit.addNode('PlayPattern', { pattern: assetOf(f, 'pattern'), loop: false });
|
||||
const lost = f.edit.addNode('PlayPattern', { pattern: assetOf(f, 'pattern'), loop: false, sync: 77 });
|
||||
const sound = f.edit.addNode('PlaySound', { sound: assetOf(f, 'sound'), sync: 77 });
|
||||
f.edit.connect(f.play, 'exec', own, 'exec');
|
||||
f.edit.connect(own, 'exec', lost, 'exec');
|
||||
f.edit.connect(lost, 'exec', sound, 'exec');
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
expect(audio.sequences.slice(1).map((s) => [s.play.tempo, s.play.at])).toEqual([
|
||||
[120, undefined],
|
||||
[120, undefined],
|
||||
]);
|
||||
expect(audio.sounds).toEqual([{ file: FILE }]);
|
||||
});
|
||||
});
|
||||
+30
-4
@@ -13,6 +13,7 @@ import {
|
||||
type GraphLookup,
|
||||
type GraphNode,
|
||||
NO_LOOKUP,
|
||||
type MusicSync,
|
||||
type NodeContext,
|
||||
type PointerState,
|
||||
type Value,
|
||||
@@ -241,6 +242,8 @@ export type FakeContext = NodeContext & {
|
||||
tempo: number;
|
||||
/** The bus picked (#325); left out for none. */
|
||||
bus?: string;
|
||||
/** The playing it starts in time with (#338); left out for none. */
|
||||
sync?: MusicSync;
|
||||
}[];
|
||||
/** The notes the context was asked to play (#331); a voice's number is how many have been played. */
|
||||
notes: {
|
||||
@@ -255,6 +258,7 @@ export type FakeContext = NodeContext & {
|
||||
fx: string;
|
||||
fxParams: Readonly<Record<string, number>>;
|
||||
bus?: string;
|
||||
sync?: MusicSync;
|
||||
}[];
|
||||
/** The patterns the context was asked to play; a playing's number is how many have been played. */
|
||||
patterns: {
|
||||
@@ -265,6 +269,7 @@ export type FakeContext = NodeContext & {
|
||||
fx: string;
|
||||
fxParams: Readonly<Record<string, number>>;
|
||||
bus?: string;
|
||||
sync?: MusicSync;
|
||||
}[];
|
||||
/** The songs the context was asked to play, numbered the same way. */
|
||||
songs: {
|
||||
@@ -346,8 +351,18 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
silenced: [],
|
||||
loaded: [],
|
||||
// The number of a voice is how many have been played: unique, and never 0.
|
||||
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus) =>
|
||||
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams, tempo, ...(bus && { bus }) }),
|
||||
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) =>
|
||||
ctx.played.push({
|
||||
assetId,
|
||||
volume,
|
||||
loop,
|
||||
detached,
|
||||
fx,
|
||||
fxParams,
|
||||
tempo,
|
||||
...(bus && { bus }),
|
||||
...(sync.playback > 0 && { sync }),
|
||||
}),
|
||||
notes: [],
|
||||
playNote: (
|
||||
instrumentId,
|
||||
@@ -361,6 +376,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
fx,
|
||||
fxParams,
|
||||
bus,
|
||||
sync,
|
||||
) =>
|
||||
ctx.notes.push({
|
||||
instrumentId,
|
||||
@@ -374,10 +390,20 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
fx,
|
||||
fxParams,
|
||||
...(bus && { bus }),
|
||||
...(sync.playback > 0 && { sync }),
|
||||
}),
|
||||
patterns: [],
|
||||
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus) =>
|
||||
ctx.patterns.push({ patternId, volume, loop, detached, fx, fxParams, ...(bus && { bus }) }),
|
||||
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus, sync) =>
|
||||
ctx.patterns.push({
|
||||
patternId,
|
||||
volume,
|
||||
loop,
|
||||
detached,
|
||||
fx,
|
||||
fxParams,
|
||||
...(bus && { bus }),
|
||||
...(sync.playback > 0 && { sync }),
|
||||
}),
|
||||
stopPattern: (patternId, letRing) =>
|
||||
ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`),
|
||||
songs: [],
|
||||
|
||||
@@ -1402,6 +1402,16 @@ describe('notes of an instrument (#331)', () => {
|
||||
expect(sources[1]!.startedAt).toBe(0);
|
||||
});
|
||||
|
||||
it('a sound asked for a second of the audio clock starts there: a stinger (#338)', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
library.play(FILE, 1, false, '', DRY_VOICE, '', 3);
|
||||
await settle();
|
||||
await arrive();
|
||||
expect(sources[0]!.startedAt).toBe(3);
|
||||
library.play(FILE, 1, false);
|
||||
expect(sources[1]!.startedAt).toBe(0);
|
||||
});
|
||||
|
||||
it('starts a note with a Limiter as much earlier as it is late, its other layer held back to it (#326)', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
const limit: FxStagePlan = {
|
||||
|
||||
+39
-1
@@ -940,7 +940,9 @@ export async function song(t) {
|
||||
song.markers = [
|
||||
{ id: 'sg65-mark', name: 'Round', tick: ${BAR} },
|
||||
{ id: 'sg65-half', name: 'Half', tick: ${BAR + BAR / 2} },
|
||||
];`);
|
||||
];
|
||||
// 65k4: a tempo of its own, for the song's to show when it plays in time with it.
|
||||
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(PATTERN)}).props.tempo = 100;`);
|
||||
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.
|
||||
@@ -986,6 +988,10 @@ export async function song(t) {
|
||||
fade: 0,
|
||||
quantize: 'bar',
|
||||
}),
|
||||
// 65k4: with P held, the pattern once as a stinger on the next beat.
|
||||
node('sg65-p', 'KeyIsDown', 0, 1600, { key: 'p' }),
|
||||
node('sg65-pif', 'Branch', 780, 1400),
|
||||
node('sg65-sting', 'PlayPattern', 1040, 1400, { pattern: PATTERN, loop: false, quantize: 'beat' }),
|
||||
],
|
||||
edges: [
|
||||
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
|
||||
@@ -1013,6 +1019,10 @@ export async function song(t) {
|
||||
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'),
|
||||
edge('sg65-c9', 'sg65-vif', 'else', 'sg65-pif', 'exec'),
|
||||
edge('sg65-c10', 'sg65-p', 'down', 'sg65-pif', 'condition'),
|
||||
edge('sg65-c11', 'sg65-pif', 'then', 'sg65-sting', 'exec'),
|
||||
edge('sg65-c12', 'sg65-cue', 'playback', 'sg65-sting', 'sync'),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1175,6 +1185,31 @@ export async function song(t) {
|
||||
Math.abs(silent.gain - 10 ** (-6 / 20)) < 1e-6,
|
||||
JSON.stringify({ newMaster, barLine, turns }),
|
||||
);
|
||||
// 65k4. A stinger (#338): P held over a beat plays the pattern once, synced to the song on the
|
||||
// next beat. It starts on a beat of the song to the sample, at the song's 120 BPM rather
|
||||
// than its own 100, and goes once it has played.
|
||||
const pressedAt = await clockNow();
|
||||
await t.keyDown('p');
|
||||
const stung = await waitFor(
|
||||
playbacks,
|
||||
(list) => list.some((p) => p.name === `pattern:${PATTERN}` && p.planned.length > 0),
|
||||
40,
|
||||
);
|
||||
await t.keyUp('p');
|
||||
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);
|
||||
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 &&
|
||||
stinger.startedAt > pressedAt &&
|
||||
stinger.startedAt < pressedAt + 1.5 &&
|
||||
stinger.planned.every((n) => n.tempo === 120) &&
|
||||
alone.length === 1 &&
|
||||
alone[0].name === `song:${id}`,
|
||||
JSON.stringify({ pressedAt, song: song?.startedAt, stinger, beatsIn, alone: alone.map((p) => p.name) }),
|
||||
);
|
||||
await press('s');
|
||||
const fading = await waitFor(playbacks, (list) => list[0]?.fading !== null);
|
||||
const fadedOut = await waitFor(playbacks, (list) => list.length === 0, 40);
|
||||
@@ -1191,6 +1226,9 @@ export async function song(t) {
|
||||
leftBuses.length === 0,
|
||||
JSON.stringify({ fading: fading.map((g) => g.fading), fadedOut, leftBuses }),
|
||||
);
|
||||
await mutate(
|
||||
`delete __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(PATTERN)}).props.tempo;`,
|
||||
);
|
||||
const left = await rig.remove();
|
||||
|
||||
// 65l. Record (#342) plays from the cursor and takes chromium's test tone: the take grows red on a
|
||||
|
||||
Reference in new issue
Block a user