Let a game take a song's automation lane over and give it back (#338)
A game should be able to steer a lane of a song, the filter of the
pads in a fight, the pan of a track when the player turns, and later
hand it back to the curve the composer drew. Set song lane holds a
lane of a track at a value, Release song lane glides it back.
- Set song lane takes playback, track ("Song / Track"), lane, value,
fade and quantize (Next bar by default). lane is volume, pan or a
parameter name, with or without "param:".
- From where the lane stands it goes over fade to value and stays.
The value is clamped to the lane's range. It replaces the lane and
is not mixed with it (decision 10).
- Release song lane glides over fade back to the curve, to where the
curve stands at the end of the fade. Then the lane follows the
curve again.
- The override sits in the Scheduler, where the lane marks are made.
Volume, pan and parameters go one way. The maths is pure functions
in the new module laneHolds.ts.
- Notes that start during a parameter hold get the held value. A
Switch song to the same song keeps the holds.
- The holds are a list per lane. The running one holds until the next
one lands. A key held down fires the node on every beat, and with a
single hold per lane the new call replaced the running one before it
landed, so the lane fell back to the curve. e2e 65 found this.
- A track without that lane and a baked song write a line to the log
(with a hint at "live tracks").
- The test harness knows the keys l and r.
Tests:
- Unit: 24 new (lane-holds 14, song-play 5, sound-library 2, node
catalog 3), affected groups 748/748.
- Counter-probes: 12 bends, all red.
- e2e 65 on atx: ALL PASSED, 76 checks. New 65k5: the hold landed on
the bar line, curve until 0.02 s before, -40 dB 0.14 s after, no
values between. It stood 1.8 s. The release began at -39.8 dB and
followed the curve again from 0.55 s on. With one hold per lane
65k5 is red.
- Gate on atx: CHECK EXIT 0, 335 files, 5176 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e390d39314
commit
f9428c5bc2
18 files changed
+1170
-18
No files matched your search
@@ -338,6 +338,21 @@ export interface NodeContext {
|
||||
* 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;
|
||||
/**
|
||||
* Holds the lane `lane` of one track of a playing song (#338) on `value`, in the lane's units,
|
||||
* reached over `fade` seconds from where `quantize` lands; its points are not heard until it is
|
||||
* let go. A track without such a lane, or a playing of its bake, is logged.
|
||||
*/
|
||||
setSongLane(
|
||||
playback: number,
|
||||
track: string,
|
||||
lane: string,
|
||||
value: number,
|
||||
fade: number,
|
||||
quantize: Quantize,
|
||||
): void;
|
||||
/** Lets such a held lane go (#338): back to its curve over `fade` seconds, as soon as can be heard. */
|
||||
releaseSongLane(playback: number, track: string, lane: string, fade: number): 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.
|
||||
|
||||
@@ -74,6 +74,77 @@ export const SetSongTrack: NodeDef = {
|
||||
},
|
||||
};
|
||||
|
||||
const lanePort = (): PortDef => ({
|
||||
key: 'lane',
|
||||
label: 'lane',
|
||||
type: 'string',
|
||||
default: 'volume',
|
||||
field: { key: 'lane', label: 'Lane', kind: 'text', default: 'volume' },
|
||||
});
|
||||
|
||||
const LANE =
|
||||
'The track is picked as "Song / Track"; "lane" names one of its automation lanes: "volume", ' +
|
||||
'"pan" or a parameter by its name. A track the playing song does not have is left alone; one ' +
|
||||
'without that lane is logged, and so is a song played from its bake ("live tracks" in Play song).';
|
||||
|
||||
export const SetSongLane: NodeDef = {
|
||||
type: 'SetSongLane',
|
||||
label: 'Set song lane',
|
||||
category: 'action',
|
||||
description:
|
||||
'Takes an automation lane of one track of a playing song over, by the "playback" number Play ' +
|
||||
'song handed out: from where the lane is, it goes to "value" over "fade" seconds and stays there, ' +
|
||||
'the points of the lane no longer heard, until Release song lane lets it go. "value" is in the ' +
|
||||
'units of the lane – decibels for the volume, -1 (left) to 1 (right) for the pan, a parameter in ' +
|
||||
`the range of its lane – and held to that range. ${QUANTIZE} ${LANE} A Switch song to the same ` +
|
||||
'song keeps what is held.',
|
||||
inputs: [
|
||||
exec(),
|
||||
playbackPort(),
|
||||
trackPort(),
|
||||
lanePort(),
|
||||
withDefault(num('value'), 0),
|
||||
withDefault(num('fade'), 1),
|
||||
quantizePort(),
|
||||
],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
ctx.setSongLane(
|
||||
asNumber(inputs.playback),
|
||||
asString(inputs.track),
|
||||
asString(inputs.lane),
|
||||
asNumber(inputs.value),
|
||||
asNumber(inputs.fade),
|
||||
heldQuantize(inputs.quantize),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const ReleaseSongLane: NodeDef = {
|
||||
type: 'ReleaseSongLane',
|
||||
label: 'Release song lane',
|
||||
category: 'action',
|
||||
description:
|
||||
'Lets an automation lane go that Set song lane took over, by the "playback" number Play song ' +
|
||||
'handed out: as soon as it can be heard, it goes back over "fade" seconds to where the curve of ' +
|
||||
'the lane is by then, and follows it from there. A lane that is not held stays as it is. ' +
|
||||
LANE,
|
||||
inputs: [exec(), playbackPort(), trackPort(), lanePort(), withDefault(num('fade'), 1)],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
ctx.releaseSongLane(
|
||||
asNumber(inputs.playback),
|
||||
asString(inputs.track),
|
||||
asString(inputs.lane),
|
||||
asNumber(inputs.fade),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const SwitchSong: NodeDef = {
|
||||
type: 'SwitchSong',
|
||||
label: 'Switch song',
|
||||
|
||||
@@ -31,7 +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 { ReleaseSongLane, SetSongLane, SetSongTrack, SwitchSong } from './action/songSync';
|
||||
import { SetZ } from './action/SetZ';
|
||||
import { Spawn } from './action/Spawn';
|
||||
import { StopGame } from './action/StopGame';
|
||||
@@ -330,6 +330,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
PlaySong,
|
||||
StopSong,
|
||||
SetSongTrack,
|
||||
SetSongLane,
|
||||
ReleaseSongLane,
|
||||
SwitchSong,
|
||||
SongPosition,
|
||||
SetBusVolume,
|
||||
|
||||
+112
-16
@@ -1,6 +1,15 @@
|
||||
import type { FadeCurve } from '../model/sound';
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
import { AUTOMATION_STEP, type AutomationTarget, type LaneMark, laneMarks, paramsAt } from './automation';
|
||||
import { AUTOMATION_STEP, type AutomationTarget, type LaneMark, type SequenceLane } from './automation';
|
||||
import {
|
||||
heldMarks,
|
||||
heldParamsAt,
|
||||
heldValueAt,
|
||||
holdKey,
|
||||
holdsAfter,
|
||||
type LaneHold,
|
||||
type LanePlaying,
|
||||
} from './laneHolds';
|
||||
import { clipsIn, heldLoop, levelsIn, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
|
||||
import {
|
||||
COMMON_TIME,
|
||||
@@ -137,6 +146,8 @@ export class Scheduler {
|
||||
private before: { anchor: number; from: number; tempo: number; loop: LoopRange | null } | null = null;
|
||||
private voices: number[] = [];
|
||||
private sleep: (() => void) | null = null;
|
||||
/** The lanes a game holds on a value of its own (#338), by `holdKey`, each's holds in the order they land. */
|
||||
private holds = new Map<string, LaneHold[]>();
|
||||
|
||||
constructor(
|
||||
private readonly host: SchedulerHost,
|
||||
@@ -211,6 +222,96 @@ export class Scheduler {
|
||||
this.meter = heldMeter(meter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the lane of `target` on the bus `bus` on `value`, in its units (#338): from the second `at`
|
||||
* of the audio clock it goes there from where it is over `fade` seconds and stays, its own points
|
||||
* no longer heard. A hold that lands before it rules until then; one that would land later, or a
|
||||
* release still to come, gives way. What is planned for it past `at` is planned again. False where
|
||||
* the sequence has no such lane.
|
||||
*/
|
||||
holdLane(bus: string, target: AutomationTarget, value: number, at: number, fade: number): boolean {
|
||||
const lane = this.laneOf(bus, target);
|
||||
if (!lane) return false;
|
||||
const key = holdKey(bus, target);
|
||||
const playing = this.lanePlaying();
|
||||
const kept = (this.holds.get(key) ?? []).filter((hold) => hold.at < at);
|
||||
const start = this.anchor === null ? value : heldValueAt(lane, kept, playing, playing.elapsed(at));
|
||||
const last = kept.at(-1);
|
||||
if (last?.release && last.release.at >= at) last.release = null;
|
||||
this.holds.set(key, [...kept, { at, fade: Math.max(0, fade), start, value, release: null }]);
|
||||
this.replan(lane, at);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a held lane go (#338): from the second `at` it goes back over `fade` seconds to where its
|
||||
* curve is by then, and follows it from there. A hold that would land later is never heard. False
|
||||
* where the lane is not held.
|
||||
*/
|
||||
releaseLane(bus: string, target: AutomationTarget, at: number, fade: number): boolean {
|
||||
const lane = this.laneOf(bus, target);
|
||||
const key = holdKey(bus, target);
|
||||
const holds = this.holds.get(key);
|
||||
if (!lane || !holds?.length) return false;
|
||||
const kept = holds.filter((hold) => hold.at < at);
|
||||
const dropped = holds[kept.length]?.at ?? Infinity;
|
||||
const last = kept.at(-1);
|
||||
if (last && !(last.release && last.release.at <= at)) last.release = { at, fade: Math.max(0, fade) };
|
||||
if (kept.length > 0) this.holds.set(key, kept);
|
||||
else this.holds.delete(key);
|
||||
this.replan(lane, Math.min(at, dropped));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Holds the lanes `other` holds (#338): a playing that takes over from it keeps them. */
|
||||
keepHolds(other: Scheduler): void {
|
||||
for (const [key, holds] of other.holds)
|
||||
this.holds.set(
|
||||
key,
|
||||
holds.map((hold) => ({ ...hold, release: hold.release && { ...hold.release } })),
|
||||
);
|
||||
}
|
||||
|
||||
/** The lane of the sequence on that bus for that target; undefined for none. */
|
||||
private laneOf(bus: string, target: AutomationTarget): SequenceLane | undefined {
|
||||
return this.sequence().lanes?.find((lane) => lane.bus === bus && lane.target === target);
|
||||
}
|
||||
|
||||
/** How the sequence plays for its lanes: the playing time is counted from the anchor. */
|
||||
private lanePlaying(): LanePlaying {
|
||||
const anchor = this.anchor ?? 0;
|
||||
return {
|
||||
from: this.from,
|
||||
loop: this.loop,
|
||||
elapsed: (at) => ticksOfSeconds(at - anchor, this.tempo),
|
||||
};
|
||||
}
|
||||
|
||||
/** What is planned for a lane from the second `at` on is planned again, from there to where planning got. */
|
||||
private replan(lane: SequenceLane, at: number): void {
|
||||
if (this.anchor === null) return;
|
||||
const start = Math.max(0, this.lanePlaying().elapsed(at));
|
||||
if (start < this.until) this.automate(lane, start, this.until, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the bus of a lane along it over the playing time from `start` up to `end` (#337), along its
|
||||
* holds where they rule (#338). With `restart` what was planned for it from the first mark on goes.
|
||||
*/
|
||||
private automate(lane: SequenceLane, start: number, end: number, fresh: boolean, restart: boolean): void {
|
||||
if (!this.host.automate || this.anchor === null) return;
|
||||
const anchor = this.anchor;
|
||||
const step = ticksOfSeconds(AUTOMATION_STEP, this.tempo);
|
||||
const holds = this.holds.get(holdKey(lane.bus, lane.target)) ?? [];
|
||||
const marks = heldMarks(lane, holds, this.lanePlaying(), start, end, fresh, step);
|
||||
this.host.automate({
|
||||
bus: lane.bus,
|
||||
target: lane.target,
|
||||
marks: marks.map((mark) => ({ ...mark, at: anchor + secondsOfTicks(mark.at, this.tempo) })),
|
||||
fresh: restart,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where what is heard right now stands: the audio clock less the output latency, so a playhead
|
||||
* drawn from it is not ahead of the sound. Before the first note it is where it starts.
|
||||
@@ -230,8 +331,9 @@ export class Scheduler {
|
||||
this.voices = this.voices.filter((voice) => this.host.playing(voice));
|
||||
const sequence = this.sequence();
|
||||
const lanes = sequence.lanes ?? [];
|
||||
const playing = this.lanePlaying();
|
||||
for (const due of notesIn(sequence, this.from, this.loop, this.until, to)) {
|
||||
const params = paramsAt(lanes, due.bus, due.tick);
|
||||
const params = heldParamsAt(lanes, this.holds, playing, due.bus, due.tick, due.at);
|
||||
const voice = this.host.play({
|
||||
at: this.anchor + secondsOfTicks(due.at, this.tempo),
|
||||
duration: secondsOfTicks(due.length, this.tempo),
|
||||
@@ -247,7 +349,7 @@ export class Scheduler {
|
||||
}
|
||||
if (this.host.playClip)
|
||||
for (const due of clipsIn(sequence, this.from, this.loop, this.until, to, this.fresh)) {
|
||||
const params = paramsAt(lanes, due.bus, due.tick + due.skip);
|
||||
const params = heldParamsAt(lanes, this.holds, playing, due.bus, due.tick + due.skip, due.at);
|
||||
const voice = this.host.playClip({
|
||||
at: this.anchor + secondsOfTicks(due.at, this.tempo),
|
||||
duration: secondsOfTicks(due.length, this.tempo),
|
||||
@@ -274,21 +376,15 @@ export class Scheduler {
|
||||
done: whole > 0 ? due.skip / whole : 0,
|
||||
});
|
||||
}
|
||||
if (this.host.automate) {
|
||||
const anchor = this.anchor;
|
||||
const step = ticksOfSeconds(AUTOMATION_STEP, this.tempo);
|
||||
for (const lane of lanes) {
|
||||
const marks = laneMarks(lane, this.from, this.loop, this.until, to, this.fresh, step);
|
||||
this.host.automate({
|
||||
bus: lane.bus,
|
||||
target: lane.target,
|
||||
marks: marks.map((mark) => ({ ...mark, at: anchor + secondsOfTicks(mark.at, this.tempo) })),
|
||||
fresh: this.fresh,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const lane of lanes) this.automate(lane, this.until, to, this.fresh, this.fresh);
|
||||
this.fresh = false;
|
||||
this.until = to;
|
||||
// A hold let go and planned back to its curve is over.
|
||||
for (const [key, holds] of this.holds) {
|
||||
const left = holdsAfter(holds, playing, to);
|
||||
if (left.length > 0) this.holds.set(key, left);
|
||||
else this.holds.delete(key);
|
||||
}
|
||||
if (!this.loop && playheadAt(to, this.from, null) >= this.end) {
|
||||
this.ended = true;
|
||||
this.sleep?.();
|
||||
|
||||
@@ -49,6 +49,17 @@ export function isAutomationTarget(value: unknown): value is AutomationTarget {
|
||||
return name !== '' && !name.includes(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* The target a lane is named by in a game (#338): `volume`, `pan`, or a parameter by its name, with
|
||||
* or without `param:` in front; null for a name no lane can have.
|
||||
*/
|
||||
export function targetNamed(name: string): AutomationTarget | null {
|
||||
const text = name.trim();
|
||||
if (isAutomationTarget(text)) return text;
|
||||
const target = paramTarget(text);
|
||||
return isAutomationTarget(target) ? target : null;
|
||||
}
|
||||
|
||||
export interface AutomationPoint {
|
||||
tick: number;
|
||||
/** In the units of its target: decibels for the volume, -1 (left) to 1 (right) for the pan. */
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
import {
|
||||
type AutomationTarget,
|
||||
type LaneMark,
|
||||
laneMarks,
|
||||
laneRange,
|
||||
laneValueAt,
|
||||
paramOf,
|
||||
paramsAt,
|
||||
reaches,
|
||||
type SequenceLane,
|
||||
type SequenceLanePoint,
|
||||
} from './automation';
|
||||
import { type LoopRange, playheadAt } from './sequence';
|
||||
|
||||
/**
|
||||
* A lane a game holds on a value of its own (#338): from `at` it goes from `start`, where the lane
|
||||
* is then, to `value` over `fade` and stays there, its own points no longer heard – until `release`
|
||||
* takes it back over its own `fade` to where its curve is by then, or a later hold lands. Seconds of
|
||||
* the audio clock, values in the units of the lane.
|
||||
*/
|
||||
export interface LaneHold {
|
||||
at: number;
|
||||
fade: number;
|
||||
start: number;
|
||||
value: number;
|
||||
release: { at: number; fade: number } | null;
|
||||
}
|
||||
|
||||
/** How a sequence is played: from where, round what, and its playing time in ticks at a second of the audio clock. */
|
||||
export interface LanePlaying {
|
||||
from: number;
|
||||
loop: LoopRange | null;
|
||||
elapsed(at: number): number;
|
||||
}
|
||||
|
||||
/** What the holds of a lane are kept by: the bus of the lane and its target. */
|
||||
export function holdKey(bus: string, target: AutomationTarget): string {
|
||||
return `${bus}\n${target}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The playing time each of the holds of a lane rules, in ticks, in the order they land: from where
|
||||
* it lands to where its release is through or the next one lands; open while it holds.
|
||||
*/
|
||||
export function holdSpans(
|
||||
holds: readonly LaneHold[],
|
||||
playing: LanePlaying,
|
||||
): { hold: LaneHold; start: number; end: number }[] {
|
||||
return holds
|
||||
.map((hold, i) => {
|
||||
const { release } = hold;
|
||||
const next = holds[i + 1];
|
||||
const own = release ? playing.elapsed(release.at + release.fade) : Infinity;
|
||||
return {
|
||||
hold,
|
||||
start: playing.elapsed(hold.at),
|
||||
end: next ? Math.min(own, playing.elapsed(next.at)) : own,
|
||||
};
|
||||
})
|
||||
.filter((span) => span.end > span.start);
|
||||
}
|
||||
|
||||
/** The holds of a lane still to be planned past `until`, in ticks of the playing time. */
|
||||
export function holdsAfter(holds: readonly LaneHold[], playing: LanePlaying, until: number): LaneHold[] {
|
||||
const going = new Set(
|
||||
holdSpans(holds, playing)
|
||||
.filter((span) => span.end > until)
|
||||
.map((s) => s.hold),
|
||||
);
|
||||
return holds.filter((hold) => going.has(hold));
|
||||
}
|
||||
|
||||
/**
|
||||
* The way of a hold as the points of a lane in ticks of the playing time: from where the lane was to
|
||||
* the value, and from wherever that got to by the release back to the curve where the release ends.
|
||||
*/
|
||||
export function holdPoints(hold: LaneHold, lane: SequenceLane, playing: LanePlaying): SequenceLanePoint[] {
|
||||
const held: SequenceLanePoint[] = [
|
||||
{ tick: playing.elapsed(hold.at), value: hold.start },
|
||||
{ tick: playing.elapsed(hold.at + hold.fade), value: hold.value },
|
||||
];
|
||||
const { release } = hold;
|
||||
if (!release) return held;
|
||||
const from = playing.elapsed(release.at);
|
||||
const to = playing.elapsed(release.at + release.fade);
|
||||
const back = laneValueAt(lane.points, playheadAt(Math.max(0, to), playing.from, playing.loop));
|
||||
return [
|
||||
...held.filter((point) => point.tick <= from),
|
||||
{ tick: from, value: laneValueAt(held, from) },
|
||||
{ tick: to, value: back },
|
||||
];
|
||||
}
|
||||
|
||||
/** Where a lane is at the playing time `elapsed`, in its units: along the hold that rules then, if one does. */
|
||||
export function heldValueAt(
|
||||
lane: SequenceLane,
|
||||
holds: readonly LaneHold[],
|
||||
playing: LanePlaying,
|
||||
elapsed: number,
|
||||
): number {
|
||||
const span = holdSpans(holds, playing).find((s) => elapsed >= s.start && elapsed < s.end);
|
||||
if (span) return laneValueAt(holdPoints(span.hold, lane, playing), elapsed);
|
||||
return laneValueAt(lane.points, playheadAt(Math.max(0, elapsed), playing.from, playing.loop));
|
||||
}
|
||||
|
||||
/**
|
||||
* The marks of a lane over the playing time from `start` up to `end`, as `laneMarks` gives them, but
|
||||
* along its holds where they rule: its own points between them, the way of each hold while it rules.
|
||||
* Where a hold or the way back to the lane begins is a jump, so a bus is set there and does not ramp
|
||||
* from whatever was planned for it last.
|
||||
*/
|
||||
export function heldMarks(
|
||||
lane: SequenceLane,
|
||||
holds: readonly LaneHold[],
|
||||
playing: LanePlaying,
|
||||
start: number,
|
||||
end: number,
|
||||
fresh: boolean,
|
||||
step: number,
|
||||
): LaneMark[] {
|
||||
const { from, loop } = playing;
|
||||
const marks: LaneMark[] = [];
|
||||
let cursor = start;
|
||||
let landed = fresh;
|
||||
for (const span of holdSpans(holds, playing)) {
|
||||
if (span.end <= cursor) continue;
|
||||
if (span.start >= end) break;
|
||||
if (cursor < span.start) marks.push(...laneMarks(lane, from, loop, cursor, span.start, landed, step));
|
||||
const to = Math.min(end, span.end);
|
||||
const way: SequenceLane = { ...lane, points: holdPoints(span.hold, lane, playing) };
|
||||
marks.push(
|
||||
...laneMarks(way, 0, null, Math.max(cursor, span.start), to, fresh || span.start >= start, step),
|
||||
);
|
||||
cursor = to;
|
||||
landed = true;
|
||||
}
|
||||
if (cursor < end) marks.push(...laneMarks(lane, from, loop, cursor, end, landed, step));
|
||||
return marks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The values the parameter lanes have for what starts on `bus` at `tick` of the sequence, `elapsed`
|
||||
* of the playing time, as `paramsAt` gives them: a held one along its hold.
|
||||
*/
|
||||
export function heldParamsAt(
|
||||
lanes: readonly SequenceLane[],
|
||||
holds: ReadonlyMap<string, readonly LaneHold[]>,
|
||||
playing: LanePlaying,
|
||||
bus: string | undefined,
|
||||
tick: number,
|
||||
elapsed: number,
|
||||
): FxParams | undefined {
|
||||
const params = paramsAt(lanes, bus, tick);
|
||||
if (!params || bus === undefined || holds.size === 0) return params;
|
||||
const held: Record<string, number> = { ...params };
|
||||
for (const lane of lanes) {
|
||||
const name = paramOf(lane.target);
|
||||
const laneHolds = holds.get(holdKey(lane.bus, lane.target));
|
||||
if (name === null || !laneHolds || !reaches(lane.bus, bus)) continue;
|
||||
const { min, max } = laneRange(lane);
|
||||
held[name] = Math.max(min, Math.min(max, heldValueAt(lane, laneHolds, playing, elapsed)));
|
||||
}
|
||||
return held;
|
||||
}
|
||||
@@ -246,6 +246,10 @@ export class Playbacks {
|
||||
this.host.level(p.key, p.volume, now, 0);
|
||||
}
|
||||
const playback = this.make(id, key, name, play, owner, true);
|
||||
// What a game holds of the lanes stays held: a lane of the same song is there again.
|
||||
const heard = this.list.find((p) => p.id === id && p !== playback && p.until === null);
|
||||
if (heard?.runner instanceof Scheduler && playback.runner instanceof Scheduler)
|
||||
playback.runner.keepHolds(heard.runner);
|
||||
void this.host.prepare(play.needs, key).then(() => {
|
||||
if (!this.list.includes(playback) || playback.fading !== null) return;
|
||||
const from = this.heardOne(id, playback);
|
||||
@@ -299,6 +303,33 @@ export class Playbacks {
|
||||
return Math.max(soonest, at({ ...clock, soonest }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds a lane of the playing of that number on `value` from the second `at` (#338), as
|
||||
* `Scheduler.holdLane` does: the one heard now and one waiting to take over from it alike. Whether
|
||||
* any of them has that lane; a bake has none.
|
||||
*/
|
||||
holdLane(
|
||||
id: number,
|
||||
bus: string,
|
||||
target: AutomationTarget,
|
||||
value: number,
|
||||
at: number,
|
||||
fade: number,
|
||||
): boolean {
|
||||
let found = false;
|
||||
for (const p of this.named(id))
|
||||
if (p.runner instanceof Scheduler && p.runner.holdLane(bus, target, value, at, fade)) found = true;
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Lets a held lane of the playing of that number go from the second `at` (#338), see `Scheduler.releaseLane`. */
|
||||
releaseLane(id: number, bus: string, target: AutomationTarget, at: number, fade: number): boolean {
|
||||
let found = false;
|
||||
for (const p of this.named(id))
|
||||
if (p.runner instanceof Scheduler && p.runner.releaseLane(bus, target, at, fade)) found = true;
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EaseShare } from '../core/model/animation';
|
||||
import type { AutomationTarget } from '../core/music/automation';
|
||||
import type { MixBus } from '../core/music/mixer';
|
||||
import type { HeardTime, LandingAt, SequencePlay } from '../core/music/playbacks';
|
||||
import type { FxParams, FxPlan, FxVoice } from '../core/soundfx/nodes';
|
||||
@@ -166,4 +167,24 @@ export class AudioSwitch implements AudioSink {
|
||||
): 'set' | 'baked' | 'none' {
|
||||
return this.of(id)?.setSongTrack?.(id, track, gain, fade, at) ?? 'none';
|
||||
}
|
||||
|
||||
holdSongLane(
|
||||
id: number,
|
||||
track: string,
|
||||
target: AutomationTarget,
|
||||
value: number,
|
||||
fade: number,
|
||||
at: LandingAt,
|
||||
): 'set' | 'baked' | 'none' {
|
||||
return this.of(id)?.holdSongLane?.(id, track, target, value, fade, at) ?? 'none';
|
||||
}
|
||||
|
||||
releaseSongLane(
|
||||
id: number,
|
||||
track: string,
|
||||
target: AutomationTarget,
|
||||
fade: number,
|
||||
): 'set' | 'baked' | 'none' {
|
||||
return this.of(id)?.releaseSongLane?.(id, track, target, fade) ?? 'none';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EaseShare } from '../../core/model/animation';
|
||||
import type { AutomationTarget } from '../../core/music/automation';
|
||||
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';
|
||||
@@ -146,6 +147,27 @@ export interface AudioSink {
|
||||
fade: number,
|
||||
at: LandingAt,
|
||||
): 'set' | 'baked' | 'none';
|
||||
/**
|
||||
* Holds the lane of `target` of the track bus `track` of the playing numbered `id` on `value`, in
|
||||
* the lane's units (#338), reached over `fade` seconds from the second `at` names: its own points
|
||||
* are not heard until it is let go. `baked` where it plays its bake, `none` where there is no such
|
||||
* playing or lane.
|
||||
*/
|
||||
holdSongLane?(
|
||||
id: number,
|
||||
track: string,
|
||||
target: AutomationTarget,
|
||||
value: number,
|
||||
fade: number,
|
||||
at: LandingAt,
|
||||
): 'set' | 'baked' | 'none';
|
||||
/** Lets such a held lane go as soon as can be heard (#338), back to its curve over `fade` seconds. */
|
||||
releaseSongLane?(
|
||||
id: number,
|
||||
track: string,
|
||||
target: AutomationTarget,
|
||||
fade: number,
|
||||
): 'set' | 'baked' | 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ 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 { type AutomationLane, laneOf, laneRange, targetNamed } from '../../core/music/automation';
|
||||
import { nextSpot, type Quantize, quantizeSpots } from '../../core/music/beats';
|
||||
import { heldSongMarkers } from '../../core/music/markers';
|
||||
import {
|
||||
@@ -262,6 +263,10 @@ export function behaviorContext(
|
||||
}),
|
||||
setSongTrack: (playback, track, volumeDb, fade, quantize) =>
|
||||
setSongTrack(run, world, playback, track, volumeDb, heldFade(fade), quantize),
|
||||
setSongLane: (playback, track, lane, value, fade, quantize) =>
|
||||
setSongLane(run, world, playback, track, lane, value, heldFade(fade), quantize),
|
||||
releaseSongLane: (playback, track, lane, fade) =>
|
||||
releaseSongLane(run, world, playback, track, lane, heldFade(fade)),
|
||||
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)),
|
||||
@@ -635,6 +640,78 @@ function setSongTrack(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane `lane` names of a track of the song playing numbered `playback` (#338), with what its bus
|
||||
* goes by; null where nothing of it plays or the song has no such track. A track without that lane
|
||||
* is logged by `node`.
|
||||
*/
|
||||
function songLaneOf(
|
||||
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
|
||||
world: World,
|
||||
node: string,
|
||||
playback: number,
|
||||
track: string,
|
||||
lane: string,
|
||||
): { playing: HeardPlaying; song: string; bus: string; lane: AutomationLane } | null {
|
||||
const playing = heardSong(project, audio, playback);
|
||||
const song = playing && findAssetOf(project, playing.asset, 'song');
|
||||
const held = song?.tracks.find((t) => t.id === track);
|
||||
if (!playing || !song || !held) return null;
|
||||
const target = targetNamed(lane);
|
||||
const found = target && laneOf(held, target);
|
||||
if (!found) {
|
||||
world.log.push(`${node}: track "${held.name}" of song "${song.name}" has no lane "${lane}"`);
|
||||
return null;
|
||||
}
|
||||
return { playing, song: song.name, bus: trackBus(song.id, track), lane: found };
|
||||
}
|
||||
|
||||
/** The log of a song played from its bake, which has no lanes to steer (#338). */
|
||||
function bakedLane(world: World, node: string, song: string): void {
|
||||
world.log.push(`${node}: song "${song}" plays its bake; switch on "live tracks" in Play song`);
|
||||
}
|
||||
|
||||
/** Holds a lane of a track of a playing song on `value` (#338), held to its range. */
|
||||
function setSongLane(
|
||||
sources: Pick<ContextSources, 'project' | 'audio'>,
|
||||
world: World,
|
||||
playback: number,
|
||||
track: string,
|
||||
lane: string,
|
||||
value: number,
|
||||
fade: number,
|
||||
quantize: Quantize,
|
||||
): void {
|
||||
const found = songLaneOf(sources, world, 'Set song lane', playback, track, lane);
|
||||
if (!found) return;
|
||||
const { min, max } = laneRange(found.lane);
|
||||
const held = Math.max(min, Math.min(max, Number.isFinite(value) ? value : 0));
|
||||
const done = sources.audio.holdSongLane?.(
|
||||
playback,
|
||||
found.bus,
|
||||
found.lane.target,
|
||||
held,
|
||||
fade,
|
||||
landing(quantize, found.playing),
|
||||
);
|
||||
if (done === 'baked') bakedLane(world, 'Set song lane', found.song);
|
||||
}
|
||||
|
||||
/** Lets a held lane of a track of a playing song go (#338), back to its curve over `fade` seconds. */
|
||||
function releaseSongLane(
|
||||
sources: Pick<ContextSources, 'project' | 'audio'>,
|
||||
world: World,
|
||||
playback: number,
|
||||
track: string,
|
||||
lane: string,
|
||||
fade: number,
|
||||
): void {
|
||||
const found = songLaneOf(sources, world, 'Release song lane', playback, track, lane);
|
||||
if (!found) return;
|
||||
const done = sources.audio.releaseSongLane?.(playback, found.bus, found.lane.target, fade);
|
||||
if (done === 'baked') bakedLane(world, 'Release song lane', found.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
|
||||
|
||||
@@ -1425,6 +1425,38 @@ export class SoundLibrary implements AudioSink {
|
||||
return found ? 'set' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the lane of `target` of one track of a playing (#338) on `value`, in its units, from the
|
||||
* second `at` names on, reached over `fade` seconds, see `Playbacks.holdLane`. A bake has no lanes.
|
||||
*/
|
||||
holdSongLane(
|
||||
id: number,
|
||||
track: string,
|
||||
target: AutomationTarget,
|
||||
value: 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));
|
||||
return this.sequences.holdLane(id, track, target, value, when, fade) ? 'set' : 'none';
|
||||
}
|
||||
|
||||
/** Lets a held lane of one track of a playing go as soon as can be heard (#338), back to its curve over `fade` seconds. */
|
||||
releaseSongLane(
|
||||
id: number,
|
||||
track: string,
|
||||
target: AutomationTarget,
|
||||
fade: number,
|
||||
): 'set' | 'baked' | 'none' {
|
||||
const clock = this.sequences.clockOf(id);
|
||||
if (!clock) return 'none';
|
||||
if (clock.baked) return 'baked';
|
||||
return this.sequences.releaseLane(id, track, target, clock.soonest, fade) ? '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.
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* A game takes a lane of a song over (#338): Set song lane holds it on a value of its own, Release
|
||||
* song lane takes it back to its curve. The way of a hold in playing time, and how the scheduler
|
||||
* plans it in place of the lane's own points.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SequenceLane } from '../../src/core/music/automation';
|
||||
import {
|
||||
heldMarks,
|
||||
heldParamsAt,
|
||||
heldValueAt,
|
||||
holdSpans,
|
||||
type LaneHold,
|
||||
type LanePlaying,
|
||||
} from '../../src/core/music/laneHolds';
|
||||
import {
|
||||
LEAD,
|
||||
type ScheduledAutomation,
|
||||
type ScheduledNote,
|
||||
Scheduler,
|
||||
type SchedulerHost,
|
||||
} from '../../src/core/music/Scheduler';
|
||||
import type { Sequence } from '../../src/core/music/sequence';
|
||||
|
||||
const Q = 480;
|
||||
const BAR = 4 * Q;
|
||||
|
||||
/** 120 BPM: 960 ticks a second, played from the start, its clock at 0 where playing time is. */
|
||||
const PLAYING: LanePlaying = { from: 0, loop: null, elapsed: (at) => at * 960 };
|
||||
|
||||
/** From -1 at the start to 1 a bar (two seconds) later: a second of playing time moves it by 1. */
|
||||
const PAN: SequenceLane = {
|
||||
bus: 'song:s:t',
|
||||
target: 'pan',
|
||||
points: [
|
||||
{ tick: 0, value: -1 },
|
||||
{ tick: BAR, value: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const hold = (fields: Partial<LaneHold> = {}): LaneHold => ({
|
||||
at: 1,
|
||||
fade: 0.5,
|
||||
start: 0,
|
||||
value: 0.5,
|
||||
release: null,
|
||||
...fields,
|
||||
});
|
||||
|
||||
const rounded = (marks: { at: number; value: number; jump: boolean }[]) =>
|
||||
marks.map(({ at, value, jump }) => [Math.round(at * 1e6) / 1e6, Math.round(value * 1e6) / 1e6, jump]);
|
||||
|
||||
describe('the way of a held lane (#338)', () => {
|
||||
it('goes from where the lane was to the value over its fade and stays; before it the lane is its own', () => {
|
||||
const held = hold();
|
||||
expect(holdSpans([held], PLAYING)).toEqual([{ hold: held, start: 960, end: Infinity }]);
|
||||
expect([480, 960, 1200, 1440, 5000].map((t) => heldValueAt(PAN, [held], PLAYING, t))).toEqual([
|
||||
-0.5, 0, 0.25, 0.5, 0.5,
|
||||
]);
|
||||
});
|
||||
|
||||
it('let go, goes back over its fade to where the curve is by then, and follows the curve from there', () => {
|
||||
// Let go at 2 s over 1 s: back to the curve at 3 s, which is past the last point, at 1.
|
||||
const held = hold({ release: { at: 2, fade: 1 } });
|
||||
expect(holdSpans([held], PLAYING)).toEqual([{ hold: held, start: 960, end: 2880 }]);
|
||||
expect([1920, 2400, 2880, 3000].map((t) => heldValueAt(PAN, [held], PLAYING, t))).toEqual([
|
||||
0.5, 0.75, 1, 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('let go in the middle of its fade, goes back from where the fade had got to', () => {
|
||||
const held = hold({ fade: 1, release: { at: 1.5, fade: 0 } });
|
||||
expect(heldValueAt(PAN, [held], PLAYING, 1439)).toBeCloseTo(479 / 1920, 9);
|
||||
// At 1.5 s the curve is at 0.5 itself.
|
||||
expect(heldValueAt(PAN, [held], PLAYING, 1440)).toBe(0.5);
|
||||
expect(heldValueAt(PAN, [held], PLAYING, 1500)).toBeCloseTo(0.5625, 9);
|
||||
});
|
||||
|
||||
it('is marked in place of the lane: its own points before, the way of the hold from a jump on', () => {
|
||||
expect(rounded(heldMarks(PAN, [hold()], PLAYING, 0, 1920, true, 9.6))).toEqual([
|
||||
[0, -1, true],
|
||||
[960, 0, false],
|
||||
[960, 0, true],
|
||||
[1440, 0.5, false],
|
||||
[1920, 0.5, false],
|
||||
]);
|
||||
// A window going on from the last one jumps where the hold begins, all the same.
|
||||
expect(rounded(heldMarks(PAN, [hold()], PLAYING, 900, 1000, false, 9.6))).toEqual([
|
||||
[900, -0.0625, false],
|
||||
[960, 0, false],
|
||||
[960, 0, true],
|
||||
[1000, 0.041667, false],
|
||||
]);
|
||||
// A window that starts inside the hold goes on from where the last one ended, without a jump.
|
||||
expect(rounded(heldMarks(PAN, [hold()], PLAYING, 1200, 1300, false, 9.6))).toEqual([
|
||||
[1200, 0.25, false],
|
||||
[1300, 0.354167, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('let go, the lane is its own again from a jump where the way back ends', () => {
|
||||
const held = hold({ release: { at: 1.5, fade: 0.25 } });
|
||||
expect(rounded(heldMarks(PAN, [held], PLAYING, 1400, 2000, false, 9.6))).toEqual([
|
||||
[1400, 0.458333, false],
|
||||
[1440, 0.5, false],
|
||||
[1680, 0.75, false],
|
||||
[1680, 0.75, true],
|
||||
[1920, 1, false],
|
||||
[2000, 1, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a later hold takes over where it lands, the one before ruling until then; one let go leaves the lane between', () => {
|
||||
const first = hold({ fade: 0, release: { at: 2, fade: 0 } });
|
||||
const second = hold({ at: 1.5, fade: 0, start: 0.5, value: -0.5 });
|
||||
expect(holdSpans([first, second], PLAYING).map((s) => [s.start, s.end])).toEqual([
|
||||
[960, 1440],
|
||||
[1440, Infinity],
|
||||
]);
|
||||
expect([1200, 1440, 2400].map((t) => heldValueAt(PAN, [first, second], PLAYING, t))).toEqual([
|
||||
0.5, -0.5, -0.5,
|
||||
]);
|
||||
const gap = hold({ at: 3, fade: 0, start: 1, value: 0 });
|
||||
expect([1200, 2400, 3000].map((t) => heldValueAt(PAN, [first, gap], PLAYING, t))).toEqual([0.5, 1, 0]);
|
||||
});
|
||||
|
||||
it('a held parameter gives what starts inside its track the held value, the others are the lanes’', () => {
|
||||
const cutoff: SequenceLane = {
|
||||
bus: 'song:s:t',
|
||||
target: 'param:cutoff',
|
||||
points: [
|
||||
{ tick: 0, value: 0 },
|
||||
{ tick: BAR, value: 1 },
|
||||
],
|
||||
};
|
||||
const drive: SequenceLane = { ...cutoff, target: 'param:drive' };
|
||||
const holds = new Map([[`song:s:t\nparam:cutoff`, [hold({ fade: 0, value: 0.9 })]]]);
|
||||
expect(heldParamsAt([cutoff, drive], holds, PLAYING, 'song:s:t:item', 1200, 1200)).toEqual({
|
||||
cutoff: 0.9,
|
||||
drive: 0.625,
|
||||
});
|
||||
// Before the hold lands, and on a bus outside the track, it is the lane's.
|
||||
expect(heldParamsAt([cutoff], holds, PLAYING, 'song:s:t', 480, 480)).toEqual({ cutoff: 0.25 });
|
||||
expect(heldParamsAt([cutoff], holds, PLAYING, 'song:s:other', 1200, 1200)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the scheduler and a held lane (#338)', () => {
|
||||
/** A clock at 10 s moved by hand, every lane window the host is handed, every note struck. */
|
||||
function playing(sequence: Sequence) {
|
||||
const automated: ScheduledAutomation[] = [];
|
||||
const struck: ScheduledNote[] = [];
|
||||
let pump: (() => void) | null = null;
|
||||
const clock = { time: 10 };
|
||||
const host: SchedulerHost = {
|
||||
now: () => clock.time,
|
||||
latency: () => 0,
|
||||
play: (note) => (struck.push(note), 0),
|
||||
automate: (automation) => void automated.push(automation),
|
||||
playing: () => false,
|
||||
stop: () => {},
|
||||
wake: (wake) => ((pump = wake), () => (pump = null)),
|
||||
};
|
||||
const scheduler = new Scheduler(host, () => sequence, { tempo: 120 });
|
||||
const run = (seconds: number) => {
|
||||
for (let t = 0; t < seconds - 1e-9; t += 0.025) {
|
||||
clock.time += 0.025;
|
||||
pump?.();
|
||||
}
|
||||
};
|
||||
return { scheduler, automated, struck, run, clock, host };
|
||||
}
|
||||
/** Where the lane is at a second of the clock, played from 10 s + LEAD on. */
|
||||
const panAt = (at: number) => -1 + (at - 10 - LEAD);
|
||||
const onSpot = (at: number, spot: number) => Math.abs(at - spot) < 1e-9;
|
||||
const marksFrom = (automated: ScheduledAutomation[], from = 0) =>
|
||||
automated.slice(from).flatMap((a) => a.marks);
|
||||
|
||||
it('holds from where it lands, what was planned past it planned again; let go, it follows its curve again', () => {
|
||||
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
run(0.1);
|
||||
const before = automated.length;
|
||||
expect(scheduler.holdLane('song:s:t', 'pan', 0.5, 10.2, 0)).toBe(true);
|
||||
// Planned up to 10.2 s and further already: planned again from there, a fresh window.
|
||||
const replanned = automated[before]!;
|
||||
expect([replanned.fresh, replanned.marks[0]]).toEqual([true, { at: 10.2, value: 0.5, jump: true }]);
|
||||
run(0.5);
|
||||
for (const mark of marksFrom(automated, before)) expect(mark.value).toBe(0.5);
|
||||
const held = automated.length;
|
||||
expect(scheduler.releaseLane('song:s:t', 'pan', 10.7, 0)).toBe(true);
|
||||
expect(automated[held]!.fresh).toBe(true);
|
||||
expect(automated[held]!.marks[0]).toMatchObject({ at: 10.7, jump: true });
|
||||
run(0.5);
|
||||
for (const mark of marksFrom(automated, held)) expect(mark.value).toBeCloseTo(panAt(mark.at), 9);
|
||||
});
|
||||
|
||||
it('held again for a later spot, stays held on the old value until then', () => {
|
||||
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
scheduler.holdLane('song:s:t', 'pan', 0.5, 10.1, 0);
|
||||
run(0.2);
|
||||
// As a node does on every beat while a key is held: each time for the next bar.
|
||||
scheduler.holdLane('song:s:t', 'pan', -0.5, 10.61, 0);
|
||||
const before = automated.length;
|
||||
run(0.8);
|
||||
// On a spot itself a stretch ends at the old value and the next jumps from there: both are right.
|
||||
const marks = marksFrom(automated, before - 1).filter(
|
||||
(mark) => mark.at > 10.1 && !onSpot(mark.at, 10.61),
|
||||
);
|
||||
for (const mark of marks) expect(mark.value).toBe(mark.at < 10.61 ? 0.5 : -0.5);
|
||||
expect(marks.some((mark) => mark.at < 10.61) && marks.some((mark) => mark.at > 10.61)).toBe(true);
|
||||
});
|
||||
|
||||
it('let go before a later hold lands, that one is never heard and the one before goes back', () => {
|
||||
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
scheduler.holdLane('song:s:t', 'pan', 0.5, 10.1, 0);
|
||||
scheduler.holdLane('song:s:t', 'pan', -0.5, 10.61, 0);
|
||||
scheduler.releaseLane('song:s:t', 'pan', 10.31, 0);
|
||||
run(1);
|
||||
for (const mark of marksFrom(automated).filter((m) => m.at > 10.1 && !onSpot(m.at, 10.31)))
|
||||
expect(mark.value).toBeCloseTo(mark.at < 10.31 ? 0.5 : panAt(mark.at), 9);
|
||||
});
|
||||
|
||||
it('says where it has no such lane, and a release where none is held', () => {
|
||||
const { scheduler } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
expect(scheduler.holdLane('song:s:t', 'volume', 0, 10.2, 0)).toBe(false);
|
||||
expect(scheduler.holdLane('song:s:other', 'pan', 0, 10.2, 0)).toBe(false);
|
||||
expect(scheduler.releaseLane('song:s:t', 'pan', 10.2, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('let go before it lands, is never heard', () => {
|
||||
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
scheduler.holdLane('song:s:t', 'pan', 0.5, 11, 0);
|
||||
// Not on the edge of a window: one starts at 10.3 s.
|
||||
scheduler.releaseLane('song:s:t', 'pan', 10.31, 0);
|
||||
run(1.5);
|
||||
for (const mark of marksFrom(automated)) expect(mark.value).toBeCloseTo(panAt(mark.at), 9);
|
||||
// Each window once, in the order of the clock.
|
||||
for (const { marks } of automated)
|
||||
marks.forEach((mark, i) => i > 0 && expect(mark.at).toBeGreaterThanOrEqual(marks[i - 1]!.at));
|
||||
});
|
||||
|
||||
it('a note that starts while a parameter is held starts at the held value', () => {
|
||||
const cutoff: SequenceLane = { ...PAN, target: 'param:cutoff', points: [{ tick: 0, value: 0.25 }] };
|
||||
const note = { tick: Q, length: Q, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t' };
|
||||
const { scheduler, struck, run } = playing({ notes: [note], lanes: [cutoff] });
|
||||
scheduler.start();
|
||||
scheduler.holdLane('song:s:t', 'param:cutoff', 0.9, 10.1, 0);
|
||||
run(1);
|
||||
expect(struck.map((n) => n.params)).toEqual([{ cutoff: 0.9 }]);
|
||||
});
|
||||
|
||||
it('one taking over keeps what the other holds, from its first window on', () => {
|
||||
const { scheduler } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
scheduler.holdLane('song:s:t', 'pan', 0.5, 10.1, 0);
|
||||
const next = playing({ notes: [], lanes: [PAN] });
|
||||
next.clock.time = 12;
|
||||
next.scheduler.keepHolds(scheduler);
|
||||
next.scheduler.start();
|
||||
const [first] = next.automated;
|
||||
expect([first?.fresh, first?.marks[0]]).toEqual([true, { at: 12 + LEAD, value: 0.5, jump: true }]);
|
||||
for (const mark of first!.marks) expect(mark.value).toBe(0.5);
|
||||
});
|
||||
});
|
||||
@@ -2562,6 +2562,28 @@ const CASES: Record<string, Case[]> = {
|
||||
after: (ctx) => expect(ctx.songChanges).toEqual(['track 4 t1 -60 dB fade 0 bar']),
|
||||
},
|
||||
],
|
||||
SetSongLane: [
|
||||
{
|
||||
name: 'holds the lane of the track of the playing it is given, on the next bar where nothing else is picked (#338)',
|
||||
inputs: { playback: 4, track: 't1', lane: 'cutoff', value: 0.5, fade: 2 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.songChanges).toEqual(['lane 4 t1 cutoff 0.5 fade 2 bar']),
|
||||
},
|
||||
{
|
||||
name: 'where its quantize says',
|
||||
inputs: { playback: 4, track: 't1', lane: 'volume', value: -6, fade: 0, quantize: 'beat' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.songChanges).toEqual(['lane 4 t1 volume -6 fade 0 beat']),
|
||||
},
|
||||
],
|
||||
ReleaseSongLane: [
|
||||
{
|
||||
name: 'lets the lane of the track of the playing it is given go (#338)',
|
||||
inputs: { playback: 4, track: 't1', lane: 'pan', fade: 1.5 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.songChanges).toEqual(['release 4 t1 pan fade 1.5']),
|
||||
},
|
||||
],
|
||||
SwitchSong: [
|
||||
{
|
||||
name: 'switches the playing it is given, in a cut on the next bar by default (#338)',
|
||||
|
||||
@@ -20,7 +20,13 @@ import {
|
||||
Playbacks,
|
||||
type SequencePlay,
|
||||
} from '../../src/core/music/playbacks';
|
||||
import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler';
|
||||
import {
|
||||
INTERVAL,
|
||||
LEAD,
|
||||
type ScheduledAutomation,
|
||||
type ScheduledClip,
|
||||
type ScheduledNote,
|
||||
} from '../../src/core/music/Scheduler';
|
||||
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';
|
||||
@@ -486,6 +492,20 @@ function recordingAudio() {
|
||||
sink.turned.push({ id, track, gain, fade, at: at(sink.clockOf(playing)) });
|
||||
return 'set' as const;
|
||||
},
|
||||
lanes: [] as string[],
|
||||
holdSongLane: (id: number, track: string, target: string, value: number, fade: number, at: LandingAt) => {
|
||||
const playing = sink.sequences[id - 1001]!.play;
|
||||
if (playing.bake) return 'baked' as const;
|
||||
sink.lanes.push(
|
||||
`hold ${id} ${track} ${target} ${value} fade ${fade} at ${+at(sink.clockOf(playing)).toFixed(6)}`,
|
||||
);
|
||||
return 'set' as const;
|
||||
},
|
||||
releaseSongLane: (id: number, track: string, target: string, fade: number) => {
|
||||
if (sink.sequences[id - 1001]!.play.bake) return 'baked' as const;
|
||||
sink.lanes.push(`release ${id} ${track} ${target} fade ${fade}`);
|
||||
return 'set' as const;
|
||||
},
|
||||
};
|
||||
return sink;
|
||||
}
|
||||
@@ -685,6 +705,148 @@ describe('Set song track and Switch song (#338)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Set song lane and Release song lane (#338)', () => {
|
||||
/** The song of `playingSong`, its first track with a lane of its volume and one of a parameter. */
|
||||
function withLanes(params: Record<string, string | number | boolean> = {}) {
|
||||
const f = playingSong(params);
|
||||
const song = f.project.assets.find((a) => a.id === f.songId) as SongAsset;
|
||||
const tracks = song.tracks.map((track, i) =>
|
||||
i === 0
|
||||
? {
|
||||
...track,
|
||||
automation: [
|
||||
{
|
||||
target: 'volume' as const,
|
||||
points: [
|
||||
{ tick: 0, value: 0 },
|
||||
{ tick: BAR, value: -12 },
|
||||
],
|
||||
},
|
||||
{ target: 'param:cutoff' as const, min: 100, max: 8000, points: [{ tick: 0, value: 400 }] },
|
||||
],
|
||||
}
|
||||
: track,
|
||||
);
|
||||
f.stack.execute(new SetSongCommand(f.songId, { tracks }));
|
||||
const steer = (type: string, fields: Record<string, string | number>) => {
|
||||
const node = f.edit.addNode(type, { track: f.tracks[0]!, ...fields });
|
||||
f.edit.connect(f.play, 'playback', node, 'playback');
|
||||
return node;
|
||||
};
|
||||
return { ...f, song, steer };
|
||||
}
|
||||
|
||||
it('holds a lane of a track on the next bar, held to its range, and lets it go', () => {
|
||||
const f = withLanes();
|
||||
const hold = f.steer('SetSongLane', { lane: 'volume', value: -80, fade: 0.5 });
|
||||
const release = f.steer('ReleaseSongLane', { lane: 'volume', fade: 2 });
|
||||
f.edit.connect(f.play, 'exec', hold, 'exec');
|
||||
f.edit.connect(hold, 'exec', release, 'exec');
|
||||
const audio = recordingAudio();
|
||||
startWorld(f, { behaviors: true, audio }).frame();
|
||||
// 90 BPM: the next bar is 8/3 s after the start at 10 s.
|
||||
const bus = trackBus(f.songId, f.tracks[0]!);
|
||||
expect(audio.lanes).toEqual([
|
||||
`hold 1001 ${bus} volume -60 fade 0.5 at ${+(10 + 8 / 3).toFixed(6)}`,
|
||||
`release 1001 ${bus} volume fade 2`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('names a parameter with or without "param:"; a lane the track does not have is logged', () => {
|
||||
const f = withLanes();
|
||||
const bare = f.steer('SetSongLane', { lane: 'cutoff', value: 20000, quantize: 'now' });
|
||||
const named = f.steer('SetSongLane', { lane: 'param:cutoff', value: 1000, quantize: 'now' });
|
||||
const none = f.steer('SetSongLane', { lane: 'pan', value: 1 });
|
||||
const gone = f.steer('ReleaseSongLane', { lane: 'drive' });
|
||||
f.edit.connect(f.play, 'exec', bare, 'exec');
|
||||
f.edit.connect(bare, 'exec', named, 'exec');
|
||||
f.edit.connect(named, 'exec', none, 'exec');
|
||||
f.edit.connect(none, 'exec', gone, 'exec');
|
||||
const audio = recordingAudio();
|
||||
const { world, frame } = startWorld(f, { behaviors: true, audio });
|
||||
frame();
|
||||
const bus = trackBus(f.songId, f.tracks[0]!);
|
||||
const track = f.song.tracks[0]!.name;
|
||||
expect([audio.lanes, world.log]).toEqual([
|
||||
[
|
||||
`hold 1001 ${bus} param:cutoff 8000 fade 1 at 10.31`,
|
||||
`hold 1001 ${bus} param:cutoff 1000 fade 1 at 10.31`,
|
||||
],
|
||||
[
|
||||
`Set song lane: track "${track}" of song "Theme" has no lane "pan"`,
|
||||
`Release song lane: track "${track}" of song "Theme" has no lane "drive"`,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a playing of its bake has no lanes: the log says to switch on "live tracks"', () => {
|
||||
const f = withLanes();
|
||||
const spans = bakeSpans({ tempo: 90, length: 2 * BAR, loop: { start: BAR, end: 2 * BAR } });
|
||||
f.song.bake = { file: 'theme.ogg', print: bakePrint(f.project, f.song), peak: 0.5, bytes: 1, ...spans };
|
||||
const hold = f.steer('SetSongLane', { lane: 'volume', value: -6 });
|
||||
const release = f.steer('ReleaseSongLane', { lane: 'volume' });
|
||||
f.edit.connect(f.play, 'exec', hold, 'exec');
|
||||
f.edit.connect(hold, 'exec', release, 'exec');
|
||||
const audio = recordingAudio();
|
||||
const { world, frame } = startWorld(f, { behaviors: true, audio });
|
||||
frame();
|
||||
expect([audio.lanes, world.log]).toEqual([
|
||||
[],
|
||||
[
|
||||
'Set song lane: song "Theme" plays its bake; switch on "live tracks" in Play song',
|
||||
'Release song lane: song "Theme" plays its bake; switch on "live tracks" in Play song',
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a held lane of a playing (#338)', () => {
|
||||
/** A pan lane from -1 to 1 over the first bar, each window it is planned in kept. */
|
||||
function panned(windows: ScheduledAutomation[]): Partial<SequencePlay> {
|
||||
const lane = {
|
||||
bus: 'song:a:t',
|
||||
target: 'pan' as const,
|
||||
points: [
|
||||
{ tick: 0, value: -1 },
|
||||
{ tick: BAR, value: 1 },
|
||||
],
|
||||
};
|
||||
return {
|
||||
sequence: {
|
||||
notes: [{ tick: 0, length: Q, note: 60, velocity: 1, instrument: 'piano' }],
|
||||
lanes: [lane],
|
||||
},
|
||||
automate: (automation) => void windows.push(automation),
|
||||
};
|
||||
}
|
||||
|
||||
it('is held under the number of the playing, and kept by one that takes over from it', async () => {
|
||||
const { playbacks, run, loaded } = fakeHost();
|
||||
const old: ScheduledAutomation[] = [];
|
||||
playbacks.start(5, 'song:a', song([], panned(old)), '');
|
||||
await loaded();
|
||||
run(0.5);
|
||||
expect(playbacks.holdLane(5, 'song:a:t', 'pan', 0.5, 0.6, 0)).toBe(true);
|
||||
expect(playbacks.holdLane(5, 'song:a:other', 'pan', 0.5, 0.6, 0)).toBe(false);
|
||||
expect(playbacks.holdLane(6, 'song:a:t', 'pan', 0.5, 0.6, 0)).toBe(false);
|
||||
const fresh: ScheduledAutomation[] = [];
|
||||
playbacks.switchTo(5, 9, 'song:a', song([], panned(fresh)), '', (clock) => clock.startedAt! + 2, 0);
|
||||
await loaded();
|
||||
run(2);
|
||||
expect(fresh[0]?.marks[0]).toEqual({ at: 2 + LEAD, value: 0.5, jump: true });
|
||||
for (const mark of fresh.flatMap((w) => w.marks)) expect(mark.value).toBe(0.5);
|
||||
expect(playbacks.releaseLane(5, 'song:a:t', 'pan', 3, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('a bake has no lanes to hold', async () => {
|
||||
const { playbacks, loaded } = fakeHost(true);
|
||||
const bake = { file: FILE, gain: 1, playback: { end: null, until: 2 * BAR, round: null } };
|
||||
playbacks.start(5, 'song:a', song([], { ...panned([]), bake }), '');
|
||||
await loaded();
|
||||
expect(playbacks.holdLane(5, 'song:a:t', 'pan', 0.5, 1, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -422,6 +422,10 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
songChanges: [],
|
||||
setSongTrack: (playback, track, volumeDb, fade, quantize) =>
|
||||
ctx.songChanges.push(`track ${playback} ${track} ${volumeDb} dB fade ${fade} ${quantize}`),
|
||||
setSongLane: (playback, track, lane, value, fade, quantize) =>
|
||||
ctx.songChanges.push(`lane ${playback} ${track} ${lane} ${value} fade ${fade} ${quantize}`),
|
||||
releaseSongLane: (playback, track, lane, fade) =>
|
||||
ctx.songChanges.push(`release ${playback} ${track} ${lane} fade ${fade}`),
|
||||
switchSong: (playback, songId, marker, quantize, fade) =>
|
||||
ctx.songChanges.push(
|
||||
`switch ${playback} to ${songId || '(same)'} at ${marker || '(start)'} ${quantize} fade ${fade}`,
|
||||
|
||||
@@ -2181,6 +2181,73 @@ describe('the automation of the tracks of a song (#337)', () => {
|
||||
expect(library.busLevels().some((bus) => bus.name.includes('gone'))).toBe(false);
|
||||
});
|
||||
|
||||
it('a game holds a lane of a track from where it lands, and lets it go back to the curve (#338)', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
const play: SequencePlay = {
|
||||
sequence: {
|
||||
notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t' }],
|
||||
lanes: [
|
||||
{
|
||||
bus: 'song:s:t',
|
||||
target: 'pan',
|
||||
points: [
|
||||
{ tick: 0, value: -1 },
|
||||
{ tick: 1920, value: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
tempo: 120,
|
||||
length: 1920,
|
||||
loop: null,
|
||||
needs: { layers: [], files: [] },
|
||||
buses: [{ name: 'song:s:t', gain: 1, pan: -1, automated: ['pan'] }],
|
||||
strike: () => 0,
|
||||
};
|
||||
const id = library.playSequence('song:s', play, '');
|
||||
await settle();
|
||||
const [panner] = panners.filter((p) => p.ramps.length > 0);
|
||||
const planned = panner!.ramps.length;
|
||||
const at: LandingAt = (clock) => clock.startedAt! + 0.03;
|
||||
expect(library.holdSongLane(id, 'song:s:t', 'pan', 0.5, 0, at)).toBe('set');
|
||||
const ramps = () =>
|
||||
panner!.ramps
|
||||
.slice(planned)
|
||||
.map(([kind, value, when]) => [kind, value, Math.round(Number(when) * 1e6) / 1e6]);
|
||||
// Held by hand where it lands (the fake holds its value), set there, and held to where planning got.
|
||||
expect(ramps()).toEqual([
|
||||
['set', -1, 0.08],
|
||||
['set', 0.5, 0.08],
|
||||
['linear', 0.5, 0.1],
|
||||
]);
|
||||
expect(library.holdSongLane(id, 'song:s:t', 'volume', 0, 0, at)).toBe('none');
|
||||
expect(library.holdSongLane(id + 99, 'song:s:t', 'pan', 0, 0, at)).toBe('none');
|
||||
expect(library.releaseSongLane(id, 'song:s:t', 'pan', 0)).toBe('set');
|
||||
expect(library.releaseSongLane(id, 'song:s:t', 'volume', 0)).toBe('none');
|
||||
});
|
||||
|
||||
it('a playing of its bake has no lanes to hold (#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: { notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i' }] },
|
||||
tempo: 120,
|
||||
length: 1920,
|
||||
loop: null,
|
||||
needs: { layers: [], files: [] },
|
||||
strike: () => 0,
|
||||
bake,
|
||||
},
|
||||
'',
|
||||
);
|
||||
expect([
|
||||
library.holdSongLane(id, 'song:s:t', 'pan', 0.5, 0, () => 0),
|
||||
library.releaseSongLane(id, 'song:s:t', 'pan', 0),
|
||||
]).toEqual(['baked', 'baked']);
|
||||
});
|
||||
|
||||
/** A gain whose decibels are the parameter `loud`; with `source` it plays FILE, as a clip's FX does. */
|
||||
const loud = (fields: Partial<FxPlan> = {}): FxVoice => ({
|
||||
plan: {
|
||||
|
||||
@@ -941,6 +941,9 @@ export async function song(t) {
|
||||
{ id: 'sg65-mark', name: 'Round', tick: ${BAR} },
|
||||
{ id: 'sg65-half', name: 'Half', tick: ${BAR + BAR / 2} },
|
||||
];
|
||||
// 65k5: the volume of the sound's track rises from -24 to 0 dB over each round of the loop. It
|
||||
// starts at 0 dB, as the track is set.
|
||||
song.tracks[0].automation = [{ target: 'volume', points: [{ tick: 0, value: 0 }, { tick: ${BAR}, value: -24 }, { tick: ${2 * BAR}, value: 0 }] }];
|
||||
// 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');
|
||||
@@ -992,6 +995,20 @@ export async function song(t) {
|
||||
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' }),
|
||||
// 65k5: with L held, the volume lane of the sound's track held at -40 dB on the next bar; with
|
||||
// R held, let go back to its curve over half a second.
|
||||
node('sg65-l', 'KeyIsDown', 0, 1800, { key: 'l' }),
|
||||
node('sg65-lif', 'Branch', 1040, 1600),
|
||||
node('sg65-hold', 'SetSongLane', 1300, 1600, {
|
||||
track: tracks[0],
|
||||
lane: 'volume',
|
||||
value: -40,
|
||||
fade: 0,
|
||||
quantize: 'bar',
|
||||
}),
|
||||
node('sg65-r', 'KeyIsDown', 0, 2000, { key: 'r' }),
|
||||
node('sg65-rif', 'Branch', 1300, 1800),
|
||||
node('sg65-let', 'ReleaseSongLane', 1560, 1800, { track: tracks[0], lane: 'volume', fade: 0.5 }),
|
||||
],
|
||||
edges: [
|
||||
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
|
||||
@@ -1023,6 +1040,14 @@ export async function song(t) {
|
||||
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'),
|
||||
edge('sg65-c13', 'sg65-pif', 'else', 'sg65-lif', 'exec'),
|
||||
edge('sg65-c14', 'sg65-l', 'down', 'sg65-lif', 'condition'),
|
||||
edge('sg65-c15', 'sg65-lif', 'then', 'sg65-hold', 'exec'),
|
||||
edge('sg65-c16', 'sg65-cue', 'playback', 'sg65-hold', 'playback'),
|
||||
edge('sg65-c17', 'sg65-lif', 'else', 'sg65-rif', 'exec'),
|
||||
edge('sg65-c18', 'sg65-r', 'down', 'sg65-rif', 'condition'),
|
||||
edge('sg65-c19', 'sg65-rif', 'then', 'sg65-let', 'exec'),
|
||||
edge('sg65-c20', 'sg65-cue', 'playback', 'sg65-let', 'playback'),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1210,6 +1235,64 @@ export async function song(t) {
|
||||
alone[0].name === `song:${id}`,
|
||||
JSON.stringify({ pressedAt, song: song?.startedAt, stinger, beatsIn, alone: alone.map((p) => p.name) }),
|
||||
);
|
||||
// 65k5. A game takes a lane over (#338): L held over a beat holds the volume lane of the sound's
|
||||
// track at -40 dB from the next bar line on, its curve (-24 to 0 dB each round) no longer
|
||||
// heard; R held lets it go, back to the curve over half a second. Read from the level of the
|
||||
// track's bus, against the bar lines 65k3 worked out.
|
||||
const swell = `${newMaster}:${busOf(tracks[0])}`;
|
||||
const levelOf = async () => {
|
||||
const at = await clockNow();
|
||||
const bus = (await json('__gm.song.buses()')).find((b) => b.name === swell);
|
||||
return { at, db: bus ? 20 * Math.log10(Math.max(bus.level, 1e-6)) : NaN };
|
||||
};
|
||||
const onCurve = (r) => r.db >= -24.5 && r.db <= 0.5;
|
||||
const held = (r) => Math.abs(r.db + 40) < 0.1;
|
||||
const holding = [await levelOf()];
|
||||
await t.keyDown('l');
|
||||
const heldFrom = await waitFor(async () => (holding.push(await levelOf()), holding.at(-1)), held, 40);
|
||||
await t.keyUp('l');
|
||||
const lastFree = holding.findLast((r) => !held(r));
|
||||
const holdBar = lastFree && firstBar + Math.max(0, Math.ceil((lastFree.at - 0.05 - firstBar) / 2)) * 2;
|
||||
const heldRound = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
heldRound.push(await levelOf());
|
||||
await sleep(200);
|
||||
}
|
||||
check(
|
||||
'Set song lane holds the volume lane at -40 dB from the next bar line on, in a cut, and stays there as the curve would rise',
|
||||
held(heldFrom) &&
|
||||
holding.every((r) => held(r) || onCurve(r)) &&
|
||||
holdBar !== undefined &&
|
||||
heldFrom.at >= holdBar - 0.05 &&
|
||||
heldFrom.at < holdBar + 0.5 &&
|
||||
heldRound.every(held),
|
||||
JSON.stringify({ swell, holdBar, holding, heldRound }),
|
||||
);
|
||||
// Read as fast as the page answers, so the half second of the way back is seen.
|
||||
const letting = [];
|
||||
await t.keyDown('r');
|
||||
for (const begun = Date.now(); Date.now() - begun < 6000;) {
|
||||
letting.push(await levelOf());
|
||||
const laneFreed = letting.find((r) => !held(r));
|
||||
if (laneFreed && letting.at(-1).at - laneFreed.at > 0.8) break;
|
||||
}
|
||||
await t.keyUp('r');
|
||||
const laneFreed = letting.find((r) => !held(r));
|
||||
const freeRound = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
freeRound.push(await levelOf());
|
||||
await sleep(200);
|
||||
}
|
||||
const spread = Math.max(...freeRound.map((r) => r.db)) - Math.min(...freeRound.map((r) => r.db));
|
||||
check(
|
||||
'Release song lane glides back to the curve over half a second, then it follows the curve round the loop again',
|
||||
laneFreed !== undefined &&
|
||||
laneFreed.db < -30 &&
|
||||
letting.filter((r) => r.at >= laneFreed.at + 0.55).every(onCurve) &&
|
||||
freeRound.every(onCurve) &&
|
||||
spread > 6,
|
||||
JSON.stringify({ laneFreed, letting: letting.filter((r, i) => i % 3 === 0), freeRound, spread }),
|
||||
);
|
||||
await press('s');
|
||||
const fading = await waitFor(playbacks, (list) => list[0]?.fading !== null);
|
||||
const fadedOut = await waitFor(playbacks, (list) => list.length === 0, 40);
|
||||
|
||||
@@ -51,7 +51,9 @@ const KEYS = {
|
||||
d: { key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 },
|
||||
e: { key: 'e', code: 'KeyE', windowsVirtualKeyCode: 69 },
|
||||
g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 },
|
||||
l: { key: 'l', code: 'KeyL', windowsVirtualKeyCode: 76 },
|
||||
p: { key: 'p', code: 'KeyP', windowsVirtualKeyCode: 80 },
|
||||
r: { key: 'r', code: 'KeyR', windowsVirtualKeyCode: 82 },
|
||||
s: { key: 's', code: 'KeyS', windowsVirtualKeyCode: 83 },
|
||||
v: { key: 'v', code: 'KeyV', windowsVirtualKeyCode: 86 },
|
||||
w: { key: 'w', code: 'KeyW', windowsVirtualKeyCode: 87 },
|
||||
|
||||
Reference in new issue
Block a user