diff --git a/packages/core/cyclist.mjs b/packages/core/cyclist.mjs index cc68bb102..163ca3232 100644 --- a/packages/core/cyclist.mjs +++ b/packages/core/cyclist.mjs @@ -28,31 +28,34 @@ export class Cyclist { this.started = false; this.beforeStart = beforeStart; this.cps = 0.5; - this.num_ticks_since_cps_change = 0; + this.checkpoint = { + // new checkpoints are created when there's a change in cps + cycles: 0, // number of cycles when cps was changed + seconds: 0, // clock phase when cps was changed + }; + this.num_ticks_since_checkpoint = 0; this.lastTick = 0; // absolute time when last tick (clock callback) happened this.lastBegin = 0; // query begin of last tick this.lastEnd = 0; // query end of last tick this.getTime = getTime; // get absolute time - this.num_cycles_at_cps_change = 0; - this.seconds_at_cps_change; // clock phase when cps was changed this.onToggle = onToggle; this.latency = latency; // fixed trigger time offset this.clock = createClock( getTime, // called slightly before each cycle (phase, duration, _, t) => { - if (this.num_ticks_since_cps_change === 0) { - this.num_cycles_at_cps_change = this.lastEnd; - this.seconds_at_cps_change = phase; + if (this.num_ticks_since_checkpoint === 0) { + this.checkpoint = { + cycles: this.lastEnd, + seconds: phase, + }; } - this.num_ticks_since_cps_change++; - const seconds_since_cps_change = this.num_ticks_since_cps_change * duration; - const num_cycles_since_cps_change = seconds_since_cps_change * this.cps; + this.num_ticks_since_checkpoint++; try { - const begin = this.lastEnd; + var begin = this.lastEnd; this.lastBegin = begin; - const end = this.num_cycles_at_cps_change + num_cycles_since_cps_change; + var end = this.checkpoint.cycles + this.num_ticks_since_checkpoint * duration * this.cps; this.lastEnd = end; this.lastTick = phase; @@ -62,20 +65,80 @@ export class Cyclist { return; } - // query the pattern for events - const haps = this.pattern.queryArc(begin, end, { _cps: this.cps, cyclist: 'cyclist' }); + const tickEndTime = phase + duration; - haps.forEach((hap) => { - if (hap.hasOnset()) { - const targetTime = - (hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency; - onSchedule?.(hap, targetTime, this.cps); - if (hap.value.cps !== undefined && this.cps != hap.value.cps) { - this.cps = hap.value.cps; - this.num_ticks_since_cps_change = 0; + var cpsChanged = false; + do { + cpsChanged = false; + + // query the pattern for events + const haps = this.pattern + .queryArc(begin, end, { _cps: this.cps, cyclist: 'cyclist' }) + // Sort the haps based on their onset, so cps changes within the + // tick duration happen in the correct order. + .sort((a, b) => { + const aBegin = a.whole.begin.valueOf(); + const bBegin = b.whole.begin.valueOf(); + // If haps happen at the same time, trigger the haps that cause + // cps changes first, so the durations of the simultaneous haps + // are adjusted accordingly. + if (aBegin === bBegin) { + if (a.value.cps !== undefined) { + return b.value.cps !== undefined ? 0 : -1; + } + if (b.value.cps !== undefined) { + return 1; + } + return 0; + } + return aBegin - bBegin; + }); + + haps.forEach((hap) => { + if (hap.hasOnset()) { + const targetTime = (hap.whole.begin - this.checkpoint.cycles) / this.cps + this.checkpoint.seconds; + if (tickEndTime < targetTime) { + // If the targetTime exceeds the tickEndTime, this means + // the cps has slowed down, and the latter haps from the query + // no longer fall within the window of the current tick. + // They'll be scheduled during the next tick. + return; + } + onSchedule?.(hap, targetTime, hap.value.cps ?? this.cps); + if (hap.value.cps !== undefined && this.cps != hap.value.cps) { + cpsChanged = true; + this.cps = hap.value.cps; + this.num_ticks_since_checkpoint = 0; + this.checkpoint = { + cycles: hap.whole.begin, + seconds: targetTime, + }; + } + } + }); + + // If there are any haps in the query that trigger changes to the + // cps that result in the pattern speeding up, the current cycle end + // may not capture all the haps that need to be scheduled. + // If this is the case, we repeat the query + scheduling with a + // newly calculated end time. + // This process can repeat multiple times until no additional + // changes to the cps are triggered. + // In practice, this doesn't happen more than once during a tick. + if (cpsChanged) { + begin = end; + const remainingSecondsSinceCheckpoint = tickEndTime - this.checkpoint.seconds; + const remainingCycles = remainingSecondsSinceCheckpoint * this.cps; + end = this.checkpoint.cycles + remainingCycles; + this.lastEnd = end; + if (end < begin) { + // If the new end is before the old end this means the track + // slowed down, so we don't need to schedule additional haps. + // Otherwise, schedule the remaining events. + break; } } - }); + } while (cpsChanged); } catch (e) { errorLogger(e); onError?.(e); @@ -101,8 +164,11 @@ export class Cyclist { } async start() { await this.beforeStart?.(); - this.num_ticks_since_cps_change = 0; - this.num_cycles_at_cps_change = 0; + this.checkpoint = { + cycles: 0, + seconds: 0, + }; + this.num_ticks_since_checkpoint = 0; if (!this.pattern) { throw new Error('Scheduler: no pattern set! call .setPattern first.'); } @@ -132,7 +198,7 @@ export class Cyclist { return; } this.cps = cps; - this.num_ticks_since_cps_change = 0; + this.num_ticks_since_checkpoint = 0; } log(begin, end, haps) { const onsets = haps.filter((h) => h.hasOnset());