diff --git a/packages/core/clockworker.js b/packages/core/clockworker.js new file mode 100644 index 00000000..c5442422 --- /dev/null +++ b/packages/core/clockworker.js @@ -0,0 +1,104 @@ +// eslint-disable-next-line no-undef +importScripts('./neozyklus.js'); +// TODO: swap below line with above one when firefox supports esm imports in service workers +// see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker?retiredLocale=de#browser_compatibility +// import createClock from './zyklus.mjs'; + +function getTime() { + const precision = 10 ** 4; + const seconds = performance.now() / 1000; + return Math.round(seconds * precision) / precision; +} + +let num_cycles_at_cps_change = 0; +let num_ticks_since_cps_change = 0; +let cps = 0.5; +// {id: {started: boolean}} +const clients = new Map(); +const duration = 0.1; +const channel = new BroadcastChannel('strudeltick'); + +const sendMessage = (type, payload) => { + channel.postMessage({ type, payload }); +}; + +const sendTick = (phase, duration, tick, time) => { + sendMessage('tick', { + phase, + duration, + time, + cps, + num_cycles_at_cps_change, + num_ticks_since_cps_change, + }); + num_ticks_since_cps_change++; +}; + +//create clock method from zyklus +const clock = this.createClock(getTime, sendTick, duration); +let started = false; + +const startClock = (id) => { + clients.set(id, { started: true }); + if (started) { + return; + } + clock.start(); + started = true; +}; +const stopClock = async (id) => { + clients.set(id, { started: false }); + + const otherClientStarted = Array.from(clients.values()).some((c) => c.started); + //dont stop the clock if other instances are running... + if (!started || otherClientStarted) { + return; + } + + clock.stop(); + setCycle(0); + started = false; +}; + +const setCycle = (cycle) => { + num_ticks_since_cps_change = 0; + num_cycles_at_cps_change = cycle; +}; + +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(message.id); + } else { + stopClock(message.id); + } + break; + } + } +}; + +this.onconnect = function (e) { + // the incoming port + const port = e.ports[0]; + + port.addEventListener('message', function (e) { + console.log(e.data); + processMessage(e.data); + }); + port.start(); // Required when using addEventListener. Otherwise called implicitly by onmessage setter. +}; diff --git a/packages/core/cyclist.mjs b/packages/core/cyclist.mjs index fef510f8..819c31bc 100644 --- a/packages/core/cyclist.mjs +++ b/packages/core/cyclist.mjs @@ -1,5 +1,5 @@ /* -cyclist.mjs - +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 . */ @@ -16,7 +16,7 @@ export class Cyclist { 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_since_last_cps_change = 0; + this.num_cycles_at_cps_change = 0; this.onToggle = onToggle; this.latency = latency; // fixed trigger time offset this.clock = createClock( @@ -27,7 +27,7 @@ export class Cyclist { this.origin = phase; } if (this.num_ticks_since_cps_change === 0) { - this.num_cycles_since_last_cps_change = this.lastEnd; + this.num_cycles_at_cps_change = this.lastEnd; } this.num_ticks_since_cps_change++; try { @@ -37,7 +37,7 @@ export class Cyclist { //convert ticks to cycles, so you can query the pattern for events const eventLength = duration * this.cps; - const end = this.num_cycles_since_last_cps_change + this.num_ticks_since_cps_change * eventLength; + const end = this.num_cycles_at_cps_change + this.num_ticks_since_cps_change * eventLength; this.lastEnd = end; // query the pattern for events @@ -71,7 +71,7 @@ export class Cyclist { } start() { this.num_ticks_since_cps_change = 0; - this.num_cycles_since_last_cps_change = 0; + this.num_cycles_at_cps_change = 0; if (!this.pattern) { throw new Error('Scheduler: no pattern set! call .setPattern first.'); } diff --git a/packages/core/neocyclist.mjs b/packages/core/neocyclist.mjs new file mode 100644 index 00000000..1ffb6fda --- /dev/null +++ b/packages/core/neocyclist.mjs @@ -0,0 +1,148 @@ +/* +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.id = Math.round(Date.now() * Math.random()); + + 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 = 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 = (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, id: this.id }); + } + + 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/neozyklus.js b/packages/core/neozyklus.js new file mode 100644 index 00000000..9ec4e775 --- /dev/null +++ b/packages/core/neozyklus.js @@ -0,0 +1,46 @@ +// used to consistently schedule events, for use in a service worker - see +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++; + } + }; + 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 }; +}; diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index e6826907..aa8762d8 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -1,3 +1,4 @@ +import { NeoCyclist } from './neocyclist.mjs'; import { Cyclist } from './cyclist.mjs'; import { evaluate as _evaluate } from './evaluate.mjs'; import { logger } from './logger.mjs'; @@ -6,9 +7,7 @@ import { evalScope } from './evaluate.mjs'; import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; export function repl({ - interval, defaultOutput, - onSchedulerError, onEvalError, beforeEval, afterEval, @@ -17,6 +16,7 @@ export function repl({ onToggle, editPattern, onUpdateState, + sync = false, }) { const state = { schedulerError: undefined, @@ -37,16 +37,18 @@ export function repl({ onUpdateState?.(state); }; - const scheduler = new Cyclist({ - interval, + const schedulerOptions = { onTrigger: getTrigger({ defaultOutput, getTime }), - onError: onSchedulerError, getTime, onToggle: (started) => { updateState({ started }); onToggle?.(started); }, - }); + }; + + // NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome + const scheduler = + sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions); let pPatterns = {}; let allTransform; diff --git a/packages/core/zyklus.mjs b/packages/core/zyklus.mjs index 3d25b054..d3c48667 100644 --- a/packages/core/zyklus.mjs +++ b/packages/core/zyklus.mjs @@ -23,7 +23,7 @@ function createClock( // callback as long as we're inside the lookahead while (phase < lookahead) { phase = Math.round(phase * precision) / precision; - phase >= t && callback(phase, duration, tick); + 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++; diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 6c2c307b..f43669f6 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -72,6 +72,7 @@ export function Repl({ embedded = false }) { }); }; const editor = new StrudelMirror({ + sync: true, defaultOutput: webaudioOutput, getTime: () => getAudioContext().currentTime, transpiler,