diff --git a/packages/core/util.mjs b/packages/core/util.mjs index 798870f0..0a07e5d4 100644 --- a/packages/core/util.mjs +++ b/packages/core/util.mjs @@ -364,6 +364,46 @@ export function objectMap(obj, fn) { return Object.fromEntries(Object.entries(obj).map(([k, v], i) => [k, fn(v, k, i)])); } + +// utility for averaging two clocks together to account for drift +export class ClockCollator { + constructor({getTargetClockTime = () => Date.now() / 1000, weight = 16, offsetDelta = .005, checkAfterTime = 2}) { + this.offsetTime; + this.timeAtPrevOffsetSample; + this.prevOffsetTimes = []; + this.getTargetClockTime = getTargetClockTime + this.weight = weight; + this.offsetDelta = offsetDelta + this.checkAfterTime = checkAfterTime + } + + calculateTimestamp(currentTime, targetTime) { + const targetClockTime = this.getTargetClockTime(); + // const unixTimeSecs = Date.now() / 1000; + const newOffsetTime = targetClockTime - currentTime; + 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 || targetClockTime - this.timeAtPrevOffsetSample > 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; + } + } + + const timestamp = this.offsetTime + targetTime; + return timestamp; + } +} + // Floating point versions, see Fraction for rational versions // // greatest common divisor // export const gcd = function (x, y, ...z) { diff --git a/packages/desktopbridge/oscbridge.mjs b/packages/desktopbridge/oscbridge.mjs index a789cca8..a8ebf082 100644 --- a/packages/desktopbridge/oscbridge.mjs +++ b/packages/desktopbridge/oscbridge.mjs @@ -1,21 +1,10 @@ -import { parseNumeral, Pattern, averageArray } from '@strudel/core'; - +import { parseNumeral, Pattern, ClockCollator } from '@strudel/core'; import { Invoke } from './utils.mjs'; -import { getAudioContext } from '../superdough/superdough.mjs'; -let offsetTime; -let timeAtPrevOffsetSample; -let prevOffsetTimes = []; +const collator = new ClockCollator({}) - -export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => { - const ctx = getAudioContext(); - const currentTime = ctx.currentTime; - return oscTrigger(null, hap, currentTime, cps, targetTime) -} - -async function oscTrigger(t_deprecate, hap, currentTime, cps = 1, targetTime) { +export async function oscTriggerTauri(t_deprecate, hap, currentTime, cps = 1, targetTime) { hap.ensureObjectValue(); const cycle = hap.wholeOrPart().begin.valueOf(); const delta = hap.duration.valueOf(); @@ -26,27 +15,7 @@ async function oscTrigger(t_deprecate, hap, currentTime, cps = 1, targetTime) { const params = []; - const unixTimeSecs = Date.now() / 1000; - const newOffsetTime = unixTimeSecs - currentTime; - if (offsetTime == null) { - offsetTime = newOffsetTime; - } - prevOffsetTimes.push(newOffsetTime); - if (prevOffsetTimes.length > 8) { - prevOffsetTimes.shift(); - } - // every two seconds, the average of the previous 8 offset times is calculated and used as a stable reference - // for calculating the timestamp that will be sent to the backend - if (timeAtPrevOffsetSample == null || unixTimeSecs - timeAtPrevOffsetSample > 2) { - timeAtPrevOffsetSample = unixTimeSecs; - const rollingOffsetTime = averageArray(prevOffsetTimes); - //account for the js clock freezing or resets set the new offset - if (Math.abs(rollingOffsetTime - offsetTime) > 0.01) { - offsetTime = rollingOffsetTime; - } - } - - const timestamp = offsetTime + targetTime; + const timestamp = collator.calculateTimestamp(currentTime, targetTime) Object.keys(controls).forEach((key) => { const val = controls[key]; @@ -71,5 +40,5 @@ async function oscTrigger(t_deprecate, hap, currentTime, cps = 1, targetTime) { }); } Pattern.prototype.osc = function () { - return this.onTrigger(oscTrigger); + return this.onTrigger(oscTriggerTauri); }; diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs index 422e56da..40869d7c 100644 --- a/packages/osc/osc.mjs +++ b/packages/osc/osc.mjs @@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th import OSC from 'osc-js'; -import { logger, parseNumeral, Pattern, getEventOffsetMs, isNote, noteToMidi } from '@strudel/core'; +import { logger, parseNumeral, Pattern, getEventOffsetMs, isNote, noteToMidi, ClockCollator } from '@strudel/core'; let connection; // Promise function connect() { @@ -34,6 +34,34 @@ function connect() { return connection; } +const collator = new ClockCollator({}) + +export async function oscTrigger(t_deprecate, hap, currentTime, cps = 1, targetTime) { + hap.ensureObjectValue(); + const osc = await connect(); + const cycle = hap.wholeOrPart().begin.valueOf(); + const delta = hap.duration.valueOf(); + const controls = Object.assign({}, { cps, cycle, delta }, hap.value); + // make sure n and note are numbers + controls.n && (controls.n = parseNumeral(controls.n)); + if (typeof controls.note !== 'undefined') { + if (isNote(controls.note)) { + controls.midinote = noteToMidi(controls.note, controls.octave || 3); + } else { + controls.note = parseNumeral(controls.note); + } + } + controls.bank && (controls.s = controls.bank + controls.s); + controls.roomsize && (controls.size = parseNumeral(controls.roomsize)); + const keyvals = Object.entries(controls).flat(); + const ts = Math.round(collator.calculateTimestamp(currentTime, targetTime) * 1000) + + const message = new OSC.Message('/dirt/play', ...keyvals); + const bundle = new OSC.Bundle([message], ts); + bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60 + osc.send(bundle); +} + /** * * Sends each hap as an OSC message, which can be picked up by SuperCollider or any other OSC-enabled software. @@ -44,33 +72,5 @@ function connect() { * @returns Pattern */ Pattern.prototype.osc = function () { - return this.onTrigger(async (time, hap, currentTime, cps = 1, targetTime) => { - hap.ensureObjectValue(); - const osc = await connect(); - const cycle = hap.wholeOrPart().begin.valueOf(); - const delta = hap.duration.valueOf(); - const controls = Object.assign({}, { cps, cycle, delta }, hap.value); - // make sure n and note are numbers - controls.n && (controls.n = parseNumeral(controls.n)); - if (typeof controls.note !== 'undefined') { - if (isNote(controls.note)) { - controls.midinote = noteToMidi(controls.note, controls.octave || 3); - } else { - controls.note = parseNumeral(controls.note); - } - } - controls.bank && (controls.s = controls.bank + controls.s); - controls.roomsize && (controls.size = parseNumeral(controls.roomsize)); - const keyvals = Object.entries(controls).flat(); - // time should be audio time of onset - // currentTime should be current time of audio context (slightly before time) - const offset = getEventOffsetMs(targetTime, currentTime); - - // timestamp in milliseconds used to trigger the osc bundle at a precise moment - const ts = Math.floor(Date.now() + offset); - const message = new OSC.Message('/dirt/play', ...keyvals); - const bundle = new OSC.Bundle([message], ts); - bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60 - osc.send(bundle); - }); + return this.onTrigger(oscTrigger); }; diff --git a/packages/osc/superdirtoutput.js b/packages/osc/superdirtoutput.js new file mode 100644 index 00000000..ee9de65c --- /dev/null +++ b/packages/osc/superdirtoutput.js @@ -0,0 +1,12 @@ +import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs'; +import { isTauri } from '../desktopbridge/utils.mjs'; +import { getAudioContext } from '../superdough/superdough.mjs'; +import { oscTrigger } from './osc.mjs'; + +const trigger = isTauri() ? oscTriggerTauri : oscTrigger; + +export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => { + const ctx = getAudioContext(); + const currentTime = ctx.currentTime; + return trigger(null, hap, currentTime, cps, targetTime) +}; diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index b01f1d79..974040cd 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -14,7 +14,7 @@ import { resetLoadedSounds, initAudioOnFirstClick, } from '@strudel/webaudio'; -import { superdirtOutput } from '@strudel/desktopbridge/oscbridge.mjs'; +import { superdirtOutput } from '@strudel/osc/superdirtoutput'; import { audioEngineTargets, defaultAudioDeviceName } from '../settings.mjs'; import { getAudioDevices, setAudioDevice, setVersionDefaultsFrom } from './util.mjs'; import { StrudelMirror, defaultSettings } from '@strudel/codemirror';