diff --git a/packages/core/clockworker.js b/packages/core/clockworker.js index 4c33de29..bcaf2872 100644 --- a/packages/core/clockworker.js +++ b/packages/core/clockworker.js @@ -4,9 +4,9 @@ // import createClock from './zyklus.mjs'; function getTime() { - const precision = 10 ** 4; - const seconds = performance.now() / 1000; - return Math.round(seconds * precision) / precision; + const seconds = performance.now() * 0.001; + return seconds; + // return Math.round(seconds * precision) / precision; } let num_cycles_at_cps_change = 0; @@ -24,27 +24,20 @@ const sendMessage = (type, payload) => { const sendTick = (phase, duration, tick, time) => { const num_seconds_since_cps_change = num_ticks_since_cps_change * duration; - const tickdeadline = phase - time; const lastTick = time + tickdeadline; const num_cycles_since_cps_change = num_seconds_since_cps_change * cps; - const begin = num_cycles_at_cps_change + num_cycles_since_cps_change; const secondsSinceLastTick = time - lastTick - duration; - const eventLength = duration * cps; const end = begin + eventLength; - const cycle = begin + secondsSinceLastTick * cps; sendMessage('tick', { begin, end, cps, - tickdeadline, - num_cycles_at_cps_change, - num_seconds_at_cps_change, - num_seconds_since_cps_change, + time, cycle, }); num_ticks_since_cps_change++; diff --git a/packages/core/neocyclist.mjs b/packages/core/neocyclist.mjs index 4600d0ef..ad22cf00 100644 --- a/packages/core/neocyclist.mjs +++ b/packages/core/neocyclist.mjs @@ -5,6 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import { logger } from './logger.mjs'; +import { ClockCollator, cycleToSeconds } from './util.mjs'; export class NeoCyclist { constructor({ onTrigger, onToggle, getTime }) { @@ -13,79 +14,38 @@ export class NeoCyclist { this.lastTick = 0; // absolute time when last tick (clock callback) happened this.getTime = getTime; // get absolute time this.time_at_last_tick_message = 0; - - this.num_cycles_at_cps_change = 0; + // the clock of the worker and the audio context clock can drift apart over time + // aditionally, the message time of the worker pinging the callback to process haps can be inconsistent. + // we need to keep a rolling average of the time difference between the worker clock and audio context clock + // in order to schedule events consistently. + this.collator = new ClockCollator({ getTargetClockTime: getTime }); this.onToggle = onToggle; this.latency = 0.1; // fixed trigger time offset this.cycle = 0; this.id = Math.round(Date.now() * Math.random()); - this.worker_time_dif; this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url)); this.worker.port.start(); - this.channel = new BroadcastChannel('strudeltick'); - let weight = 0; // the amount of weight that is applied to the current average when averaging a new time dif - const maxWeight = 20; - const precision = 10 ** 3; //round off time diff to prevent accumulating outliers - - // the clock of the worker and the audio context clock can drift apart over time - // aditionally, the message time of the worker pinging the callback to process haps can be inconsistent. - // we need to keep a rolling weighted average of the time difference between the worker clock and audio context clock - // in order to schedule events consistently. - const setTimeReference = (num_seconds_at_cps_change, num_seconds_since_cps_change, tickdeadline) => { - const time_dif = getTime() - (num_seconds_at_cps_change + num_seconds_since_cps_change) + tickdeadline; - if (this.worker_time_dif == null) { - this.worker_time_dif = time_dif; - } else { - const w = 1; //weight of new time diff; - const new_dif = - Math.round(((this.worker_time_dif * weight + time_dif * w) / (weight + w)) * precision) / precision; - - if (new_dif != this.worker_time_dif) { - // reset the weight so the clock recovers faster from an audio context freeze/dropout if it happens - weight = 4; - } - this.worker_time_dif = new_dif; - } - weight = Math.min(weight + 1, maxWeight); - }; - const tickCallback = (payload) => { - const { - num_cycles_at_cps_change, - cps, - num_seconds_at_cps_change, - num_seconds_since_cps_change, - begin, - end, - tickdeadline, - cycle, - } = payload; + const { cps, begin, end, cycle, time } = payload; this.cps = cps; this.cycle = cycle; - - setTimeReference(num_seconds_at_cps_change, num_seconds_since_cps_change, tickdeadline); - - processHaps(begin, end, num_cycles_at_cps_change, num_seconds_at_cps_change); - - this.time_at_last_tick_message = this.getTime(); + const currentTime = this.collator.calculateOffset(time) + time; + processHaps(begin, end, currentTime); + this.time_at_last_tick_message = currentTime; }; - const processHaps = (begin, end, num_cycles_at_cps_change, seconds_at_cps_change) => { + const processHaps = (begin, end, currentTime) => { if (this.started === false) { return; } const haps = this.pattern.queryArc(begin, end, { _cps: this.cps }); - haps.forEach((hap) => { if (hap.hasOnset()) { - const targetTime = - (hap.whole.begin - num_cycles_at_cps_change) / this.cps + - seconds_at_cps_change + - this.latency + - this.worker_time_dif; - const duration = hap.duration / this.cps; + const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps); + const targetTime = timeUntilTrigger + currentTime + this.latency; + const duration = cycleToSeconds(hap.duration, this.cps); onTrigger?.(hap, 0, duration, this.cps, targetTime); } }); @@ -129,8 +89,8 @@ export class NeoCyclist { this.setStarted(true); } stop() { - this.worker_time_dif = null; logger('[cyclist] stop'); + this.collator.reset(); this.setStarted(false); } setPattern(pat, autostart = false) { diff --git a/packages/core/util.mjs b/packages/core/util.mjs index 798870f0..66f8d0db 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -363,6 +363,68 @@ export function objectMap(obj, fn) { } return Object.fromEntries(Object.entries(obj).map(([k, v], i) => [k, fn(v, k, i)])); } +export function cycleToSeconds(cycle, cps) { + return cycle / cps; +} + +// utility for averaging two clocks together to account for drift +export class ClockCollator { + constructor({ + getTargetClockTime = () => Date.now() * 0.001, + weight = 16, + offsetDelta = 0.005, + checkAfterTime = 2, + resetAfterTime = 8, + }) { + this.offsetTime; + this.timeAtPrevOffsetSample; + this.prevOffsetTimes = []; + this.getTargetClockTime = getTargetClockTime; + this.weight = weight; + this.offsetDelta = offsetDelta; + this.checkAfterTime = checkAfterTime; + this.resetAfterTime = resetAfterTime; + this.reset = () => { + this.prevOffsetTimes = []; + this.offsetTime = null; + this.timeAtPrevOffsetSample = null; + }; + } + calculateOffset(currentTime) { + const targetClockTime = this.getTargetClockTime(); + const diffBetweenTimeSamples = targetClockTime - this.timeAtPrevOffsetSample; + const newOffsetTime = targetClockTime - currentTime; + // recalcuate the diff from scratch if the clock has been paused for some time. + if (diffBetweenTimeSamples > this.resetAfterTime) { + this.reset(); + } + + if (this.offsetTime == null) { + this.offsetTime = newOffsetTime; + } + this.prevOffsetTimes.push(newOffsetTime); + if (this.prevOffsetTimes.length > this.weight) { + this.prevOffsetTimes.shift(); + } + + // after X time has passed, the average of the previous weight offset times is calculated and used as a stable reference + // for calculating the timestamp + if (this.timeAtPrevOffsetSample == null || diffBetweenTimeSamples > this.checkAfterTime) { + this.timeAtPrevOffsetSample = targetClockTime; + const rollingOffsetTime = averageArray(this.prevOffsetTimes); + //when the clock offsets surpass the delta, set the new reference time + if (Math.abs(rollingOffsetTime - this.offsetTime) > this.offsetDelta) { + this.offsetTime = rollingOffsetTime; + } + } + + return this.offsetTime; + } + + calculateTimestamp(currentTime, targetTime) { + return this.calculateOffset(currentTime) + targetTime; + } +} // Floating point versions, see Fraction for rational versions // // greatest common divisor