From 91e1cc136e1169b1864b784cdbb02b8e29e14cb5 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Sat, 1 Nov 2025 23:34:10 -0400 Subject: [PATCH] working --- packages/midi/midi.mjs | 101 +++++++++++++++++++------------- packages/superdough/helpers.mjs | 5 ++ 2 files changed, 64 insertions(+), 42 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index ae983d200..32f030125 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -5,9 +5,10 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as _WebMidi from 'webmidi'; -import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core'; +import { Pattern, isPattern, logger, ref } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core'; import { Note } from 'webmidi'; +import { scheduleAtTime } from '../superdough/helpers.mjs'; // if you use WebMidi from outside of this package, make sure to import that instance: export const { WebMidi } = _WebMidi; @@ -190,7 +191,7 @@ function mapCC(mapping, value) { } // sends a cc message to the given device on the given channel -function sendCC(ccn, ccv, device, midichan, timeOffsetString) { +function sendCC(ccn, ccv, device, midichan, targetTime) { if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) { throw new Error('expected ccv to be a number between 0 and 1'); } @@ -198,19 +199,23 @@ function sendCC(ccn, ccv, device, midichan, timeOffsetString) { throw new Error('expected ccn to be a number or a string'); } const scaled = Math.round(ccv * 127); - device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString }); + scheduleAtTime(() => { + device.sendControlChange(ccn, scaled, midichan); + }, targetTime); } // sends a program change message to the given device on the given channel -function sendProgramChange(progNum, device, midichan, timeOffsetString) { +function sendProgramChange(progNum, device, midichan, targetTime) { if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) { throw new Error('expected progNum (program change) to be a number between 0 and 127'); } - device.sendProgramChange(progNum, midichan, { time: timeOffsetString }); + scheduleAtTime(() => { + device.sendProgramChange(progNum, midichan); + }, targetTime); } // sends a sysex message to the given device on the given channel -function sendSysex(sysexid, sysexdata, device, timeOffsetString) { +function sendSysex(sysexid, sysexdata, device, targetTime) { if (Array.isArray(sysexid)) { if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { throw new Error('all sysexid bytes must be integers between 0 and 255'); @@ -225,11 +230,13 @@ function sendSysex(sysexid, sysexdata, device, timeOffsetString) { if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { throw new Error('all sysex bytes must be integers between 0 and 255'); } - device.sendSysex(sysexid, sysexdata, { time: timeOffsetString }); + scheduleAtTime(() => { + device.sendSysex(sysexid, sysexdata); + }, targetTime); } // sends a NRPN message to the given device on the given channel -function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) { +function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) { if (Array.isArray(nrpnn)) { if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { throw new Error('all nrpnn bytes must be integers between 0 and 255'); @@ -237,28 +244,34 @@ function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) { } else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) { throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers'); } - - device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString }); + scheduleAtTime(() => { + device.sendNRPN(nrpnn, nrpv, midichan); + }, targetTime); } // sends a pitch bend message to the given device on the given channel -function sendPitchBend(midibend, device, midichan, timeOffsetString) { +function sendPitchBend(midibend, device, midichan, targetTime) { if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) { throw new Error('expected midibend to be a number between -1 and 1'); } - device.sendPitchBend(midibend, midichan, { time: timeOffsetString }); + scheduleAtTime(() => { + device.sendPitchBend(midibend, midichan); + }, targetTime); } // sends a channel aftertouch message to the given device on the given channel -function sendAftertouch(miditouch, device, midichan, timeOffsetString) { +function sendAftertouch(miditouch, device, midichan, targetTime) { if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) { throw new Error('expected miditouch to be a number between 0 and 1'); } - device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString }); + + scheduleAtTime(() => { + device.sendChannelAftertouch(miditouch, midichan); + }, targetTime); } // sends a note message to the given device on the given channel -function sendNote(note, velocity, duration, device, midichan, timeOffsetString) { +function sendNote(note, velocity, duration, device, midichan, targetTime) { if (note == null || note === '') { throw new Error('note cannot be null or empty'); } @@ -268,12 +281,12 @@ function sendNote(note, velocity, duration, device, midichan, timeOffsetString) if (duration != null && (typeof duration !== 'number' || duration < 0)) { throw new Error('duration must be a positive number'); } - const midiNumber = typeof note === 'number' ? note : noteToMidi(note); const midiNote = new Note(midiNumber, { attack: velocity, duration }); - device.playNote(midiNote, midichan, { - time: timeOffsetString, - }); + + scheduleAtTime(() => { + device.playNote(midiNote, midichan); + }, targetTime); } /** @@ -309,7 +322,6 @@ Pattern.prototype.midi = function (midiport, options = {}) { let midiConfig = { // Default configuration values isController: false, // Disable sending notes for midi controllers - latencyMs: 34, // Default latency to get audio engine to line up in ms noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms midichannel: 1, // Default MIDI channel velocity: 0.9, // Default velocity @@ -333,18 +345,13 @@ Pattern.prototype.midi = function (midiport, options = {}) { logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`), }); - return this.onTrigger((hap, currentTime, cps, targetTime) => { + return this.onTrigger((hap, _currentTime, cps, targetTime) => { if (!WebMidi.enabled) { logger('Midi not enabled'); return; } hap.ensureObjectValue(); - //magic number to get audio engine to line up, can probably be calculated somehow - const latencyMs = midiConfig.latencyMs; - // passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output - const timeOffsetString = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`; - // midi event values from hap with configurable defaults let { note, @@ -380,7 +387,7 @@ Pattern.prototype.midi = function (midiport, options = {}) { // if midimap is set, send a cc messages from defined controls if (midicontrolMap.has(midimap)) { const ccs = mapCC(midicontrolMap.get(midimap), hap.value); - ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString)); + ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime)); } else if (midimap !== 'default') { // Add warning when a non-existent midimap is specified logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`); @@ -392,12 +399,12 @@ Pattern.prototype.midi = function (midiport, options = {}) { // try to prevent glitching by subtracting noteOffsetMs from the duration length const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs; - sendNote(note, velocity, duration, device, midichan, timeOffsetString); + sendNote(note, velocity, duration, device, midichan, targetTime); } // Handle program change if (progNum !== undefined) { - sendProgramChange(progNum, device, midichan, timeOffsetString); + sendProgramChange(progNum, device, midichan, targetTime); } // Handle sysex @@ -407,53 +414,63 @@ Pattern.prototype.midi = function (midiport, options = {}) { // if sysexid is an array the first byte is 0x00 if (sysexid !== undefined && sysexdata !== undefined) { - sendSysex(sysexid, sysexdata, device, timeOffsetString); + sendSysex(sysexid, sysexdata, device, targetTime); } // Handle control change if (ccv !== undefined && ccn !== undefined) { - sendCC(ccn, ccv, device, midichan, timeOffsetString); + sendCC(ccn, ccv, device, midichan, targetTime); } // Handle NRPN non-registered parameter number if (nrpnn !== undefined && nrpv !== undefined) { - sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString); + sendNRPN(nrpnn, nrpv, device, midichan, targetTime); } // Handle midibend if (midibend !== undefined) { - sendPitchBend(midibend, device, midichan, timeOffsetString); + sendPitchBend(midibend, device, midichan, targetTime); } // Handle miditouch if (miditouch !== undefined) { - sendAftertouch(miditouch, device, midichan, timeOffsetString); + sendAftertouch(miditouch, device, midichan, targetTime); } // Handle midicmd if (hap.whole.begin + 0 === 0) { // we need to start here because we have the timing info - device.sendStart({ time: timeOffsetString }); + scheduleAtTime(() => { + device.sendStart(); + }, targetTime); } if (['clock', 'midiClock'].includes(midicmd)) { - device.sendClock({ time: timeOffsetString }); + scheduleAtTime(() => { + device.sendClock(); + }, targetTime); } else if (['start'].includes(midicmd)) { - device.sendStart({ time: timeOffsetString }); + scheduleAtTime(() => { + device.sendStart(); + }, targetTime); } else if (['stop'].includes(midicmd)) { - device.sendStop({ time: timeOffsetString }); + scheduleAtTime(() => { + device.sendStop(); + }, targetTime); } else if (['continue'].includes(midicmd)) { - device.sendContinue({ time: timeOffsetString }); + scheduleAtTime(() => { + device.sendContinue(); + }, targetTime); } else if (Array.isArray(midicmd)) { if (midicmd[0] === 'progNum') { - sendProgramChange(midicmd[1], device, midichan, timeOffsetString); + sendProgramChange(midicmd[1], device, midichan, targetTime); } else if (midicmd[0] === 'cc') { if (midicmd.length === 2) { - sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString); + sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime); } } else if (midicmd[0] === 'sysex') { if (midicmd.length === 3) { const [_, id, data] = midicmd; - sendSysex(id, data, device, timeOffsetString); + sendSysex(id, data, device, targetTime); } } } diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index a471da9dd..b06f584a0 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -280,6 +280,11 @@ export function getVibratoOscillator(param, value, t) { return vibratoOscillator; } } + +export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) { + const currentTime = audioContext.currentTime; + webAudioTimeout(audioContext, callback, currentTime, targetTime); +} // ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities // a bit of a hack, but it works very well :) export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {