1/* 2neocyclist.mjs - event scheduler like cyclist, except recieves clock pulses from clockworker in order to sync across multiple instances. 3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/neocyclist.mjs> 4This 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 <https://www.gnu.org/licenses/>. 5*/ 6 7import { logger } from './logger.mjs'; 8import { ClockCollator, cycleToSeconds } from './util.mjs'; 9 10export class NeoCyclist { 11 constructor({ onTrigger, onToggle, getTime }) { 12 this.started = false; 13 this.cps = 0.5; 14 this.getTime = getTime; // get absolute time 15 this.time_at_last_tick_message = 0; 16 // the clock of the worker and the audio context clock can drift apart over time 17 // aditionally, the message time of the worker pinging the callback to process haps can be inconsistent. 18 // we need to keep a rolling average of the time difference between the worker clock and audio context clock 19 // in order to schedule events consistently. 20 this.collator = new ClockCollator({ getTargetClockTime: getTime }); 21 this.onToggle = onToggle; 22 this.latency = 0.1; // fixed trigger time offset 23 this.cycle = 0; 24 this.id = Math.round(Date.now() * Math.random()); 25 this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url)); 26 this.worker.port.start(); 27 this.channel = new BroadcastChannel('strudeltick'); 28 const tickCallback = (payload) => { 29 const { cps, begin, end, cycle, time } = payload; 30 this.cps = cps; 31 this.cycle = cycle; 32 const currentTime = this.collator.calculateOffset(time) + time; 33 processHaps(begin, end, currentTime); 34 this.time_at_last_tick_message = currentTime; 35 }; 36 37 const processHaps = (begin, end, currentTime) => { 38 if (this.started === false) { 39 return; 40 } 41 const haps = this.pattern.queryArc(begin, end, { _cps: this.cps, cyclist: 'neocyclist' }); 42 haps.forEach((hap) => { 43 if (hap.hasOnset()) { 44 const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps); 45 const targetTime = timeUntilTrigger + currentTime + this.latency; 46 const duration = cycleToSeconds(hap.duration, this.cps); 47 onTrigger?.(hap, 0, duration, this.cps, targetTime); 48 } 49 }); 50 }; 51 52 // receive messages from worker clock and process them 53 this.channel.onmessage = (message) => { 54 if (!this.started) { 55 return; 56 } 57 const { payload, type } = message.data; 58 59 switch (type) { 60 case 'tick': { 61 tickCallback(payload); 62 } 63 } 64 }; 65 } 66 sendMessage(type, payload) { 67 this.worker.port.postMessage({ type, payload, id: this.id }); 68 } 69 70 now() { 71 const gap = (this.getTime() - this.time_at_last_tick_message) * this.cps; 72 return this.cycle + gap; 73 } 74 setCps(cps = 1) { 75 this.sendMessage('cpschange', { cps }); 76 } 77 setCycle(cycle) { 78 this.sendMessage('setcycle', { cycle }); 79 } 80 setStarted(started) { 81 this.sendMessage('toggle', { started }); 82 this.started = started; 83 this.onToggle?.(started); 84 } 85 start() { 86 logger('[cyclist] start'); 87 this.setStarted(true); 88 } 89 stop() { 90 logger('[cyclist] stop'); 91 this.collator.reset(); 92 this.setStarted(false); 93 } 94 setPattern(pat, autostart = false) { 95 this.pattern = pat; 96 if (autostart && !this.started) { 97 this.start(); 98 } 99 } 100 101 log(begin, end, haps) { 102 const onsets = haps.filter((h) => h.hasOnset()); 103 console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`); 104 } 105}