From f2be5e4f5ed7fad7952f5ad0e6633dc35f9c23d0 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 16 Aug 2026 22:15:39 +0200 Subject: [PATCH] refactor: new clockbridge class to fix midi and osc timing --- packages/midi/midi.mjs | 29 ++++++-------------- packages/osc/osc.mjs | 19 +++++++++---- packages/osc/package.json | 1 + packages/superdough/audioContext.mjs | 11 +++++++- packages/superdough/clockbridge.mjs | 41 ++++++++++++++++++++++++++++ packages/superdough/index.mjs | 1 + pnpm-lock.yaml | 3 ++ 7 files changed, 79 insertions(+), 26 deletions(-) create mode 100644 packages/superdough/clockbridge.mjs diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index fa5e69ec8..68a0d744f 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -21,7 +21,7 @@ import { } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core'; import { Note } from 'webmidi'; -import { getAudioContext } from '@strudel/webaudio'; +import { getAudioContext, getClockBridge } from '@strudel/webaudio'; import { scheduleAtTime, ensureMinimalOutput } from '../superdough/helpers.mjs'; import { getMidiDeviceNamesString, getDevice } from './util.mjs'; import { MidiInput } from './input.mjs'; @@ -177,7 +177,11 @@ const isFirefox = navigator?.userAgent?.includes('Firefox'); // firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=2062997 function timedSend(timeMs, fn) { if (isFirefox) { - const audioTime = getAudioContext().currentTime + (timeMs - performance.now()) / 1000; + const audioTime = getClockBridge().getAudioContextTime(timeMs); + if (!audioTime) { + logger('[midi]: skip event, not ready'); + return; + } scheduleAtTime(() => fn(undefined), audioTime); } else { fn(timeMs); @@ -281,11 +285,6 @@ function sendNote(note, velocity, duration, device, midichan, timeMs) { timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs })); } -// thanks freya https://youtu.be/LSNQuFEDOyQ?si=ukZI2IGgWV_NDZzP&t=2979 -function expDecay(a, b, decay, dt) { - return b + (a - b) * Math.exp(-decay * dt); -} - /** * MIDI output: Opens a MIDI output port. * @tags external_io @@ -343,26 +342,16 @@ Pattern.prototype.midi = function (midiport, options = {}) { ensureMinimalOutput(); - let p; // filtered clock offset - let lastTime; - return this.sortHapsByPart().onTrigger((hap, _currentTime, cps, targetTime) => { if (!WebMidi.enabled) { logger('Midi not enabled'); return; } - const { contextTime, performanceTime } = getAudioContext().getOutputTimestamp(); - if (!contextTime || !performanceTime) { - logger('[midi] skip midi event: not ready yet?'); + const timeMs = getClockBridge().getPerformanceTime(targetTime); + if (!timeMs) { + logger('[midi] clockbridge not ready'); return; } - // time conversion from audio context time (targetTime) to performance time (what midi needs) - const offset = performanceTime - contextTime * 1000; // clock offset in ms - const dt = performanceTime - (lastTime ?? performanceTime); // delta time since last midi hap - const decay = 1 / 10000; // how fast offset changes have an effect - p = expDecay(p ?? offset, offset, decay, dt); // smooth clock offset - lastTime = performanceTime; - const timeMs = targetTime * 1000 + p; // this is now correct in performance time hap.ensureObjectValue(); diff --git a/packages/osc/osc.mjs b/packages/osc/osc.mjs index b94be0d9d..2c0619b4a 100644 --- a/packages/osc/osc.mjs +++ b/packages/osc/osc.mjs @@ -4,7 +4,8 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core'; +import { logger, parseNumeral, register, isNote, noteToMidi } from '@strudel/core'; +import { ensureMinimalOutput, getClockBridge } from '@strudel/webaudio'; let connection; // Promise function connect() { @@ -54,13 +55,18 @@ export function parseControlsFromHap(hap, cps) { return controls; } -const collator = new ClockCollator({}); - export async function oscTrigger(hap, currentTime, cps = 1, targetTime) { const ws = await connect(); + + const timeMs = getClockBridge().getPerformanceTime(targetTime); + if (!timeMs) { + logger('[osc] clockbridge not ready'); + return; + } + const ts = performance.timeOrigin + timeMs; + const controls = parseControlsFromHap(hap, cps); const keyvals = Object.entries(controls).flat(); - const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000; const msg = { address: '/dirt/play', args: keyvals, timestamp: ts }; if ('oschost' in hap.value) { @@ -82,4 +88,7 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) { * @memberof Pattern * @returns Pattern */ -export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger)); +export const osc = register('osc', (pat) => { + ensureMinimalOutput(); + return pat.onTrigger(oscTrigger); +}); diff --git a/packages/osc/package.json b/packages/osc/package.json index 569242252..de4d2c96c 100644 --- a/packages/osc/package.json +++ b/packages/osc/package.json @@ -38,6 +38,7 @@ "homepage": "https://codeberg.org/uzu/strudel#readme", "dependencies": { "@strudel/core": "workspace:*", + "@strudel/webaudio": "workspace:*", "osc": "^2.4.5", "ws": "^8.18.3" }, diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 6a701d9ad..27a1b25d1 100644 --- a/packages/superdough/audioContext.mjs +++ b/packages/superdough/audioContext.mjs @@ -8,8 +8,9 @@ This program is free software: you can redistribute it and/or modify it under th */ import { clearNodePool } from './nodePools.mjs'; +import { ClockBridge } from './clockbridge.mjs'; -let audioContext; +let audioContext, clockBridge; export const setDefaultAudioContext = () => { return setAudioContext(new AudioContext()); @@ -23,6 +24,7 @@ export const setAudioContext = (context) => { audioContext.close(); } audioContext = context; + clockBridge = new ClockBridge(audioContext); return audioContext; }; @@ -34,6 +36,13 @@ export const getAudioContext = () => { return audioContext; }; +export function getClockBridge() { + if (!clockBridge) { + getAudioContext(); // creates clockBridge + } + return clockBridge; +} + export function getAudioContextCurrentTime() { return getAudioContext().currentTime; } diff --git a/packages/superdough/clockbridge.mjs b/packages/superdough/clockbridge.mjs new file mode 100644 index 000000000..b2bbedb62 --- /dev/null +++ b/packages/superdough/clockbridge.mjs @@ -0,0 +1,41 @@ +/* +clockbridge.mjs +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 . +*/ + +// thanks freya https://youtu.be/LSNQuFEDOyQ?si=ukZI2IGgWV_NDZzP&t=2979 +export function expDecay(a, b, decay, dt) { + return b + (a - b) * Math.exp(-decay * dt); +} + +// translates between audio and performance clock +export class ClockBridge { + p; // smoothed clock offset + lastTime; + audioContext; + constructor(audioContext) { + this.audioContext = audioContext; + } + // delta between audio and performance time in ms + getOffset() { + const { contextTime, performanceTime } = this.audioContext.getOutputTimestamp(); + if (!contextTime || !performanceTime) { + return; + } + const offset = performanceTime - contextTime * 1000; // clock offset in ms + const dt = performanceTime - (this.lastTime ?? performanceTime); // delta time since last hap + const decay = 1 / 10000; // how fast offset changes have an effect + this.p = expDecay(this.p ?? offset, offset, decay, dt); // smooth clock offset + this.lastTime = performanceTime; + return this.p; + } + getPerformanceTime(audioContextTime) { + const offset = this.getOffset(); + return audioContextTime * 1000 + offset; // this is now correct in performance time (ms) + } + getAudioContextTime(performanceTime) { + const offset = this.getOffset(); + return (performanceTime - offset) / 1000; // this is now correct in audio context time (seconds) + } +} diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs index c3d2f6c52..1a3923c58 100644 --- a/packages/superdough/index.mjs +++ b/packages/superdough/index.mjs @@ -14,3 +14,4 @@ export * from './modulators.mjs'; export * from './dspworklet.mjs'; export * from './audioContext.mjs'; export * from './wavetable.mjs'; +export * from './clockbridge.mjs'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b086a3d0..faadf8c2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -470,6 +470,9 @@ importers: '@strudel/core': specifier: workspace:* version: link:../core + '@strudel/webaudio': + specifier: workspace:* + version: link:../webaudio osc: specifier: ^2.4.5 version: 2.4.5