/** * What the scheduler plays (#332), before there are patterns and songs to make it of (#333, #334): * notes of instruments at ticks, and a loop the playhead runs round. Everything here is counted in * ticks of playing time – `elapsed` since the start, which only ever grows – and of the sequence – * where the playhead stands, which jumps back at the end of a loop. */ import type { FadeCurve } from '../model/sound'; import type { SequenceLane } from './automation'; export interface SequenceNote { /** Where it starts in the sequence, and how long it is held, in ticks. */ tick: number; length: number; /** As MIDI counts it: 60 is C4. */ note: number; /** 0 to 1. */ velocity: number; /** The instrument asset that plays it. */ instrument: string; /** A factor on its loudness, 1 where left out: the volume of a song's item (#334). */ volume?: number; /** The track of a song it is heard on (#334); straight out where left out. */ bus?: string; /** What a Delay synced to beats counts by, where it is not the sequence's: a pattern in a composition (#418). */ tempo?: number; } /** * A sound played as a clip of a song (#334): from `offset` seconds into it, round and round until * `length` ticks have passed. It keeps its own speed whatever the tempo. */ export interface SequenceClip { tick: number; length: number; sound: string; /** The sound FX it goes through, `''` for none. */ fx: string; offset: number; volume: number; bus: string; /** As a note's. */ tempo?: number; } /** * A ramp of the level of a bus the sequence plays on: from `tick` for `length` ticks it rises from * silence to `gain` along `curve`, or falls from there to silence – the fades of a composition's * item. One 0 ticks long sets the bus to `gain` there. */ export interface SequenceLevel { tick: number; length: number; bus: string; gain: number; rise: boolean; curve: FadeCurve; } export interface Sequence { notes: readonly SequenceNote[]; clips?: readonly SequenceClip[]; levels?: readonly SequenceLevel[]; /** The automation of a song's tracks (#337), each lane on the bus it acts on. */ lanes?: readonly SequenceLane[]; } /** From `start` up to `end`, in ticks of the sequence; `end` is where the playhead jumps back. */ export interface LoopRange { start: number; end: number; } /** A loop with nothing in it is none. */ export function heldLoop(loop: LoopRange | null): LoopRange | null { if (!loop || !(loop.end > loop.start) || !(loop.start >= 0)) return null; return loop; } /** * Where the playhead stands after `elapsed` ticks of playing from `from`. Started before the end of * the loop, it runs round it once it gets there; started behind it, it plays on. */ export function playheadAt(elapsed: number, from: number, loop: LoopRange | null): number { if (!loop || from >= loop.end) return from + elapsed; const toEnd = loop.end - from; if (elapsed < toEnd) return from + elapsed; return loop.start + ((elapsed - toEnd) % (loop.end - loop.start)); } /** A note of the sequence where it falls in playing time, held no longer than the loop lets it. */ export interface DueNote extends SequenceNote { /** Ticks of playing time from the start. */ at: number; } /** * The notes starting in the playing time from `start` up to `end`, both whole ticks. Across the end * of a loop the window goes on at its start, so the next round is planned ahead like any other * stretch and the seam has no gap. A note reaching past the end of a loop ends there: the playhead * has jumped back. */ export function notesIn( sequence: Sequence, from: number, loop: LoopRange | null, start: number, end: number, ): DueNote[] { const due: DueNote[] = []; const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity; let elapsed = start; let head = playheadAt(start, from, loop); while (elapsed < end) { const span = Math.min(end - elapsed, stretchEnd - head); if (!(span > 0)) break; for (const note of sequence.notes) if (note.tick >= head && note.tick < head + span) due.push({ ...note, at: elapsed + (note.tick - head), length: Math.min(note.length, stretchEnd - note.tick), }); elapsed += span; head = head + span >= stretchEnd ? loop!.start : head + span; } return due.sort((a, b) => a.at - b.at || a.note - b.note); } /** A clip of the sequence where it falls in playing time: `skip` ticks of it lie behind the playhead. */ export interface DueClip extends SequenceClip { at: number; skip: number; } /** * The clips starting in the playing time from `start` up to `end`, as `notesIn` finds notes. Where * the playhead lands – at the start with `fresh`, and after every jump back – a clip it lands in the * middle of is taken up there, `skip` ticks into it. A clip ends where its item or the loop does. */ export function clipsIn( sequence: Sequence, from: number, loop: LoopRange | null, start: number, end: number, fresh: boolean, ): DueClip[] { return spansIn(sequence.clips ?? [], from, loop, start, end, fresh); } /** The ramps of the sequence's levels as `clipsIn` finds clips: one the playhead lands inside is taken up there. */ export function levelsIn( sequence: Sequence, from: number, loop: LoopRange | null, start: number, end: number, fresh: boolean, ): (SequenceLevel & { at: number; skip: number })[] { return spansIn(sequence.levels ?? [], from, loop, start, end, fresh); } function spansIn( clips: readonly T[], from: number, loop: LoopRange | null, start: number, end: number, fresh: boolean, ): (T & { at: number; skip: number })[] { const due: (T & { at: number; skip: number })[] = []; if (clips.length === 0) return due; const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity; let elapsed = start; let head = playheadAt(start, from, loop); let landed = fresh; while (elapsed < end) { const span = Math.min(end - elapsed, stretchEnd - head); if (!(span > 0)) break; for (const clip of clips) { const clipEnd = Math.min(clip.tick + clip.length, stretchEnd); if (clip.tick >= head && clip.tick < head + span) due.push({ ...clip, at: elapsed + (clip.tick - head), length: clipEnd - clip.tick, skip: 0 }); else if (landed && clip.tick < head && clipEnd > head) due.push({ ...clip, at: elapsed, length: clipEnd - head, skip: head - clip.tick }); } elapsed += span; head = head + span >= stretchEnd ? loop!.start : head + span; landed = true; } return due.sort((a, b) => a.at - b.at); }