diff --git a/packages/core/cyclist.mjs b/packages/core/cyclist.mjs index cf08b328..f024c464 100644 --- a/packages/core/cyclist.mjs +++ b/packages/core/cyclist.mjs @@ -1,136 +1,93 @@ /* -cyclist.mjs - recieves clock pulses from clockworker, and schedules the next events +cyclist.mjs - event scheduler for a single strudel instance. for multi-instance scheduler, see - see Copyright (C) 2022 Strudel contributors - see This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ +import * as createClock from './zyklus.js'; import { logger } from './logger.mjs'; export class Cyclist { - constructor({ onTrigger, onToggle, getTime }) { + constructor({ interval, onTrigger, onToggle, onError, getTime, latency = 0.1 }) { this.started = false; this.cps = 0.5; + this.num_ticks_since_cps_change = 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.onToggle = onToggle; - this.latency = 0.1; // fixed trigger time offset - this.cycle = 0; - - this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url)); - this.worker.port.start(); - - this.channel = new BroadcastChannel('strudeltick'); - let worker_time_dif = 0; // time difference between audio context clock and worker clock - let weight = 0; // the amount of weight that is applied to the current average when averaging a new time dif - const maxWeight = 400; - 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 = (time, workertime) => { - const time_dif = workertime - time; - if (worker_time_dif === 0) { - worker_time_dif = time_dif; - } else { - const w = 1; //weight of new time diff; - const new_dif = Math.round(((worker_time_dif * weight + time_dif * w) / (weight + w)) * precision) / precision; - - if (new_dif != worker_time_dif) { - // reset the weight so the clock recovers faster from an audio context freeze/dropout if it happens - weight = 4; + this.latency = latency; // fixed trigger time offset + this.clock = createClock( + getTime, + // called slightly before each cycle + (phase, duration, tick) => { + if (tick === 0) { + this.origin = phase; } - worker_time_dif = new_dif; - } - }; - - const getTickDeadline = (phase, time) => { - return phase - time - worker_time_dif; - }; - - const tickCallback = (payload) => { - const workertime = payload.time; - const time = this.getTime(); - - const { duration, phase, num_ticks_since_cps_change, num_cycles_at_cps_change, cps } = payload; - setTimeReference(time, workertime); - this.cps = cps; - - //calculate begin and end - const eventLength = duration * cps; - const num_cycles_since_cps_change = num_ticks_since_cps_change * eventLength; - const begin = num_cycles_at_cps_change + num_cycles_since_cps_change; - const tickdeadline = getTickDeadline(phase, time); - const end = begin + eventLength; - - //calculate current cycle - const lastTick = time + tickdeadline; - const secondsSinceLastTick = time - lastTick - duration; - this.cycle = begin + secondsSinceLastTick * cps; - - //set the weight of average time diff and processs haps - weight = Math.min(weight + 1, maxWeight); - processHaps(begin, end, tickdeadline); - this.time_at_last_tick_message = this.getTime(); - }; - - const processHaps = (begin, end, tickdeadline) => { - if (this.started === false) { - return; - } - const haps = this.pattern.queryArc(begin, end, { _cps: this.cps }); - - haps.forEach((hap) => { - if (hap.part.begin.equals(hap.whole.begin)) { - const deadline = (hap.whole.begin - begin) / this.cps + tickdeadline + this.latency; - const duration = hap.duration / this.cps; - onTrigger?.(hap, deadline, duration, this.cps); + if (this.num_ticks_since_cps_change === 0) { + this.num_cycles_at_cps_change = this.lastEnd; } - }); - }; + this.num_ticks_since_cps_change++; + try { + const time = getTime(); + const begin = this.lastEnd; + this.lastBegin = begin; - // receive messages from worker clock and process them - this.channel.onmessage = (message) => { - if (!this.started) { - return; - } - const { payload, type } = message.data; + //convert ticks to cycles, so you can query the pattern for events + const eventLength = duration * this.cps; + const end = this.num_cycles_at_cps_change + this.num_ticks_since_cps_change * eventLength; + this.lastEnd = end; - switch (type) { - case 'tick': { - tickCallback(payload); + // query the pattern for events + const haps = this.pattern.queryArc(begin, end, { _cps: this.cps }); + + const tickdeadline = phase - time; // time left until the phase is a whole number + this.lastTick = time + tickdeadline; + + haps.forEach((hap) => { + if (hap.part.begin.equals(hap.whole.begin)) { + const deadline = (hap.whole.begin - begin) / this.cps + tickdeadline + latency; + const duration = hap.duration / this.cps; + onTrigger?.(hap, deadline, duration, this.cps); + } + }); + } catch (e) { + logger(`[cyclist] error: ${e.message}`); + onError?.(e); } - } - }; + }, + interval, // duration of each cycle + ); } - sendMessage(type, payload) { - this.worker.port.postMessage({ type, payload }); - } - now() { - const gap = (this.getTime() - this.time_at_last_tick_message) * this.cps; - return this.cycle + gap; + const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration; + return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency; } - setCps(cps = 1) { - this.sendMessage('cpschange', { cps }); - } - setCycle(cycle) { - this.sendMessage('setcycle', { cycle }); - } - setStarted(started) { - this.sendMessage('toggle', { started }); - this.started = started; - this.onToggle?.(started); + setStarted(v) { + this.started = v; + this.onToggle?.(v); } start() { + this.num_ticks_since_cps_change = 0; + this.num_cycles_at_cps_change = 0; + if (!this.pattern) { + throw new Error('Scheduler: no pattern set! call .setPattern first.'); + } logger('[cyclist] start'); + this.clock.start(); this.setStarted(true); } + pause() { + logger('[cyclist] pause'); + this.clock.pause(); + this.setStarted(false); + } stop() { logger('[cyclist] stop'); + this.clock.stop(); + this.lastEnd = 0; this.setStarted(false); } setPattern(pat, autostart = false) { @@ -139,141 +96,15 @@ export class Cyclist { this.start(); } } - + setCps(cps = 0.5) { + if (this.cps === cps) { + return; + } + this.cps = cps; + this.num_ticks_since_cps_change = 0; + } log(begin, end, haps) { const onsets = haps.filter((h) => h.hasOnset()); console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`); } } - -function getTime(precision) { - const seconds = performance.now() / 1000; - return Math.round(seconds * precision) / precision; -} -const allPorts = []; -let num_cycles_at_cps_change = 0; -let num_ticks_since_cps_change = 0; -let cps = 0.5; -const duration = 0.1; - -const sendMessage = (type, payload) => { - allPorts.forEach((port) => { - port.postMessage({ type, payload }); - }); -}; - -const sendTick = ({ phase, duration, time }) => { - sendMessage('tick', { - phase, - duration, - time, - cps, - num_cycles_at_cps_change, - num_ticks_since_cps_change, - }); - num_ticks_since_cps_change++; -}; - -const clock = createClock(sendTick, duration); -let started = false; - -const startClock = () => { - if (started) { - return; - } - clock.start(); - started = true; -}; -const stopClock = () => { - //dont stop the clock if mutliple instances are using it... - if (!started || numClientsConnected() > 1) { - return; - } - clock.stop(); - setCycle(0); - started = false; -}; - -const setCycle = (cycle) => { - num_ticks_since_cps_change = 0; - num_cycles_at_cps_change = cycle; -}; - -const numClientsConnected = () => allPorts.length; -const processMessage = (message) => { - const { type, payload } = message; - - switch (type) { - case 'cpschange': { - if (payload.cps !== cps) { - num_cycles_at_cps_change = num_cycles_at_cps_change + num_ticks_since_cps_change * duration * cps; - cps = payload.cps; - num_ticks_since_cps_change = 0; - } - break; - } - case 'setcycle': { - setCycle(payload.cycle); - break; - } - case 'toggle': { - if (payload.started) { - startClock(); - } else { - stopClock(); - } - break; - } - } -}; - -if (typeof self !== 'undefined') { - self.onconnect = function (e) { - // the incoming port - const port = e.ports[0]; - allPorts.push(port); - port.addEventListener('message', function (e) { - processMessage(e.data); - }); - port.start(); // Required when using addEventListener. Otherwise called implicitly by onmessage setter. - }; -} - -function createClock( - callback, // called slightly before each cycle - duration, -) { - const interval = 0.1; - const overlap = interval / 2; - const precision = 10 ** 4; // used to round phase - const minLatency = 0.01; - let phase = 0; // next callback time - - const onTick = () => { - const t = getTime(precision); - const lookahead = t + interval + overlap; // the time window for this tick - if (phase === 0) { - phase = t + minLatency; - } - // callback as long as we're inside the lookahead - while (phase < lookahead) { - phase = Math.round(phase * precision) / precision; - phase >= t && callback({ phase, duration, time: t }); - phase < t && console.log('TOO LATE', phase); // what if latency is added from outside? - phase += duration; // increment phase by duration - } - }; - let intervalID; - const start = () => { - clear(); // just in case start was called more than once - onTick(); - intervalID = setInterval(onTick, interval * 1000); - }; - const clear = () => intervalID !== undefined && clearInterval(intervalID); - const stop = () => { - phase = 0; - clear(); - }; - - return { start, stop }; -} diff --git a/packages/core/neocyclist.mjs b/packages/core/neocyclist.mjs new file mode 100644 index 00000000..ada66cf4 --- /dev/null +++ b/packages/core/neocyclist.mjs @@ -0,0 +1,147 @@ +/* +neocyclist.mjs - event scheduler like cyclist, except recieves clock pulses from clockworker in order to sync across multiple instances. +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { logger } from './logger.mjs'; + +export class NeoCyclist { + constructor({ onTrigger, onToggle, getTime }) { + this.started = false; + this.cps = 0.5; + this.lastTick = 0; // absolute time when last tick (clock callback) happened + this.getTime = getTime; // get absolute time + + this.num_cycles_at_cps_change = 0; + this.onToggle = onToggle; + this.latency = 0.1; // fixed trigger time offset + this.cycle = 0; + + this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url)); + this.worker.port.start(); + + this.channel = new BroadcastChannel('strudeltick'); + let worker_time_dif = 0; // time difference between audio context clock and worker clock + let weight = 0; // the amount of weight that is applied to the current average when averaging a new time dif + const maxWeight = 400; + 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 = (time, workertime) => { + const time_dif = workertime - time; + if (worker_time_dif === 0) { + worker_time_dif = time_dif; + } else { + const w = 1; //weight of new time diff; + const new_dif = Math.round(((worker_time_dif * weight + time_dif * w) / (weight + w)) * precision) / precision; + + if (new_dif != worker_time_dif) { + // reset the weight so the clock recovers faster from an audio context freeze/dropout if it happens + weight = 4; + } + worker_time_dif = new_dif; + } + }; + + const getTickDeadline = (phase, time) => { + return phase - time - worker_time_dif; + }; + + const tickCallback = (payload) => { + const workertime = payload.time; + const time = this.getTime(); + + const { duration, phase, num_ticks_since_cps_change, num_cycles_at_cps_change, cps } = payload; + setTimeReference(time, workertime); + this.cps = cps; + + //calculate begin and end + const eventLength = duration * cps; + const num_cycles_since_cps_change = num_ticks_since_cps_change * eventLength; + const begin = num_cycles_at_cps_change + num_cycles_since_cps_change; + const tickdeadline = getTickDeadline(phase, time); + const end = begin + eventLength; + + //calculate current cycle + const lastTick = time + tickdeadline; + const secondsSinceLastTick = time - lastTick - duration; + this.cycle = begin + secondsSinceLastTick * cps; + + //set the weight of average time diff and processs haps + weight = Math.min(weight + 1, maxWeight); + processHaps(begin, end, tickdeadline); + this.time_at_last_tick_message = this.getTime(); + }; + + const processHaps = (begin, end, tickdeadline) => { + if (this.started === false) { + return; + } + const haps = this.pattern.queryArc(begin, end, { _cps: this.cps }); + + haps.forEach((hap) => { + if (hap.part.begin.equals(hap.whole.begin)) { + const deadline = (hap.whole.begin - begin) / this.cps + tickdeadline + this.latency; + const duration = hap.duration / this.cps; + onTrigger?.(hap, deadline, duration, this.cps); + } + }); + }; + + // receive messages from worker clock and process them + this.channel.onmessage = (message) => { + if (!this.started) { + return; + } + const { payload, type } = message.data; + + switch (type) { + case 'tick': { + tickCallback(payload); + } + } + }; + } + sendMessage(type, payload) { + this.worker.port.postMessage({ type, payload }); + } + + now() { + const gap = (this.getTime() - this.time_at_last_tick_message) * this.cps; + return this.cycle + gap; + } + setCps(cps = 1) { + this.sendMessage('cpschange', { cps }); + } + setCycle(cycle) { + this.sendMessage('setcycle', { cycle }); + } + setStarted(started) { + this.sendMessage('toggle', { started }); + this.started = started; + this.onToggle?.(started); + } + start() { + logger('[cyclist] start'); + this.setStarted(true); + } + stop() { + logger('[cyclist] stop'); + this.setStarted(false); + } + setPattern(pat, autostart = false) { + this.pattern = pat; + if (autostart && !this.started) { + this.start(); + } + } + + log(begin, end, haps) { + const onsets = haps.filter((h) => h.hasOnset()); + console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`); + } +} diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 8cad7141..0ec106af 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -1,4 +1,4 @@ -import { Cyclist } from './cyclist.mjs'; +import { NeoCyclist } from './neocyclist.mjs'; import { evaluate as _evaluate } from './evaluate.mjs'; import { logger } from './logger.mjs'; import { setTime } from './time.mjs'; @@ -35,7 +35,7 @@ export function repl({ onUpdateState?.(state); }; - const scheduler = new Cyclist({ + const scheduler = new NeoCyclist({ onTrigger: getTrigger({ defaultOutput, getTime }), getTime, onToggle: (started) => { diff --git a/packages/core/zyklus.js b/packages/core/zyklus.js index 8d4d43b5..7a9c7fa5 100644 --- a/packages/core/zyklus.js +++ b/packages/core/zyklus.js @@ -1,48 +1,50 @@ // will move to https://github.com/felixroos/zyklus // TODO: started flag - -this.createClock = ( - getTime, - callback, // called slightly before each cycle - duration = 0.05, // duration of each cycle - interval = 0.1, // interval between callbacks - overlap = 0.1, // overlap between callbacks -) => { - let tick = 0; // counts callbacks - let phase = 0; // next callback time - let precision = 10 ** 4; // used to round phase - let minLatency = 0.01; - const setDuration = (setter) => (duration = setter(duration)); - overlap = overlap || interval / 2; - const onTick = () => { - const t = getTime(); - const lookahead = t + interval + overlap; // the time window for this tick - if (phase === 0) { - phase = t + minLatency; - } - // callback as long as we're inside the lookahead - while (phase < lookahead) { - phase = Math.round(phase * precision) / precision; - phase >= t && callback(phase, duration, tick, t); - phase < t && console.log('TOO LATE', phase); // what if latency is added from outside? - phase += duration; // increment phase by duration - tick++; - } +//TODO: fix tests not understanding "self" +if (typeof self !== 'undefined') { + self.createClock = ( + getTime, + callback, // called slightly before each cycle + duration = 0.05, // duration of each cycle + interval = 0.1, // interval between callbacks + overlap = 0.1, // overlap between callbacks + ) => { + let tick = 0; // counts callbacks + let phase = 0; // next callback time + let precision = 10 ** 4; // used to round phase + let minLatency = 0.01; + const setDuration = (setter) => (duration = setter(duration)); + overlap = overlap || interval / 2; + const onTick = () => { + const t = getTime(); + const lookahead = t + interval + overlap; // the time window for this tick + if (phase === 0) { + phase = t + minLatency; + } + // callback as long as we're inside the lookahead + while (phase < lookahead) { + phase = Math.round(phase * precision) / precision; + phase >= t && callback(phase, duration, tick, t); + phase < t && console.log('TOO LATE', phase); // what if latency is added from outside? + phase += duration; // increment phase by duration + tick++; + } + }; + let intervalID; + const start = () => { + clear(); // just in case start was called more than once + onTick(); + intervalID = setInterval(onTick, interval * 1000); + }; + const clear = () => intervalID !== undefined && clearInterval(intervalID); + const pause = () => clear(); + const stop = () => { + tick = 0; + phase = 0; + clear(); + }; + const getPhase = () => phase; + // setCallback + return { setDuration, start, stop, pause, duration, interval, getPhase, minLatency }; }; - let intervalID; - const start = () => { - clear(); // just in case start was called more than once - onTick(); - intervalID = setInterval(onTick, interval * 1000); - }; - const clear = () => intervalID !== undefined && clearInterval(intervalID); - const pause = () => clear(); - const stop = () => { - tick = 0; - phase = 0; - clear(); - }; - const getPhase = () => phase; - // setCallback - return { setDuration, start, stop, pause, duration, interval, getPhase, minLatency }; -}; +}