diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 7cd004b6..edffd30e 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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. diff --git a/src/core/graph/nodes/action/songSync.ts b/src/core/graph/nodes/action/songSync.ts index 3ab8cd81..793f5561 100644 --- a/src/core/graph/nodes/action/songSync.ts +++ b/src/core/graph/nodes/action/songSync.ts @@ -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', diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 30a53048..5a0c1329 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/music/Scheduler.ts b/src/core/music/Scheduler.ts index a808003e..beb72fe7 100644 --- a/src/core/music/Scheduler.ts +++ b/src/core/music/Scheduler.ts @@ -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(); 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?.(); diff --git a/src/core/music/automation.ts b/src/core/music/automation.ts index b92ab1e5..131252e5 100644 --- a/src/core/music/automation.ts +++ b/src/core/music/automation.ts @@ -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. */ diff --git a/src/core/music/laneHolds.ts b/src/core/music/laneHolds.ts new file mode 100644 index 00000000..a8e654da --- /dev/null +++ b/src/core/music/laneHolds.ts @@ -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, + 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 = { ...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; +} diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts index f489fa07..da1c4aa2 100644 --- a/src/core/music/playbacks.ts +++ b/src/core/music/playbacks.ts @@ -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[] { return this.current().filter((p) => p.id === id); diff --git a/src/runtime/audioSwitch.ts b/src/runtime/audioSwitch.ts index b9f3423d..c205d4f1 100644 --- a/src/runtime/audioSwitch.ts +++ b/src/runtime/audioSwitch.ts @@ -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'; + } } diff --git a/src/runtime/systems/Audio.ts b/src/runtime/systems/Audio.ts index 0efc227d..e0fcb840 100644 --- a/src/runtime/systems/Audio.ts +++ b/src/runtime/systems/Audio.ts @@ -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'; } /** diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 1ecfaa0f..6da3c12a 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -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, + 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, + 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, + 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 diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 46f667b0..51299c03 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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. diff --git a/tests/core/lane-holds.test.ts b/tests/core/lane-holds.test.ts new file mode 100644 index 00000000..77d11e11 --- /dev/null +++ b/tests/core/lane-holds.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index d4da4360..64c441d6 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2562,6 +2562,28 @@ const CASES: Record = { 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)', diff --git a/tests/core/song-play.test.ts b/tests/core/song-play.test.ts index f823350c..b6746437 100644 --- a/tests/core/song-play.test.ts +++ b/tests/core/song-play.test.ts @@ -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 = {}) { + 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) => { + 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 { + 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; diff --git a/tests/helpers.ts b/tests/helpers.ts index 6d492bbc..791f9e66 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -422,6 +422,10 @@ export function fakeContext(overrides: Partial = {}): 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}`, diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index f4e5dc9d..4950bc28 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -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 = {}): FxVoice => ({ plan: { diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 4eed6222..06af30c0 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -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); diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 2aae5600..5c326aed 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -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 },