From 597c94199118adcc597e3e437b0f7174192c8bb5 Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 10 Dec 2025 12:01:04 -0600 Subject: [PATCH 1/8] Add keyboard function --- packages/midi/midi.mjs | 72 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index cd38d3587..477da51cd 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -543,3 +543,75 @@ export async function midin(input) { device.addListener('midimessage', listeners[input]); return cc; } + +/** + * MIDI keyboard: Opens a MIDI input port to receive MIDI keyboard messages. + * + * The note length is fixed as Superdough is not currently set up for undetermined + * note durations + * + * @param {string | number} input MIDI device name or index defaulting to 0 + * @returns {function(): Pattern} A function that produces a pattern. + * When queried, the pattern will produces the most recently played midi notes and velocities, + * lasting for the specified duration + * @example + * const cc = await midin('IAC Driver Bus 1') + * note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth") + * @example + * const allCC = await midin('IAC Driver Bus 1') + * const cc = (ccNum) => allCC(ccNum, 2) // just channel 2 + * note("c a f e").s("saw") + * .when(cc(0).gt(0), x => x.postgain(0)) + */ +const kHaps = {}; +const kListeners = {}; +export async function keyboard(input, noteLength = 0.5) { + const device = await _initialize(input); + if (!kHaps[input]) { + kHaps[input] = {}; + } + kListeners[input] && device.removeListener('midimessage', kListeners[input]); + kListeners[input] = (e) => { + const { dataBytes, message } = e; + const [cc, v] = dataBytes; + const noteoff = message.command === 8; + const key = `${input}_${cc}`; + const t = getTime() + 0.1; // slight delay so it's not too late for cyclist to catch + const span = new TimeSpan(t, t + noteLength); + let value = { midikey: key }; + if (noteoff) { + /* TODO: It's a big effort, but we could modify superdough to allow for situations where + we don't know the hap duration in advance. This would mean, for example, that if the hap + is flagged as such a special note-on event, we have all effects be persistent & all ADSR + envelopes stop at the S stage [and store references to them by note+pattern] + If this is implemented, then getting full keyboard functionality should be as simple + as sending the corresponding note-off event below and triggering `release` on each of those + referenced effects/envelopes + + value = { ...value, noteoff: true }; + + If this is achieved, we can remove the noteLength parameter + */ + return; + } else { + value = { ...value, note: Math.round(cc), velocity: v / 127 }; + } + kHaps[input][key] = new Hap(span, span, value, {}); + }; + device.addListener('midimessage', kListeners[input]); + const kb = () => { + const query = (state) => { + const haps = []; + for (const [key, hap] of Object.entries(kHaps[input])) { + haps.push(hap); + } + if (state.controls.cyclist) { + // Notes have been sent; clear them + kHaps[input] = {}; + } + return haps; + }; + return new Pattern(query); + }; + return kb; +} From 103b27c21ffa957684bbcd28b72e4ffe080ee6f6 Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 10 Dec 2025 13:27:11 -0600 Subject: [PATCH 2/8] Working version of keyboard with variable lengths; tests and cleanup --- packages/midi/midi.mjs | 115 +++++++++++++--------- test/__snapshots__/examples.test.mjs.snap | 4 + test/runtime.mjs | 5 + 3 files changed, 75 insertions(+), 49 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 477da51cd..bfc42207a 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as _WebMidi from 'webmidi'; -import { Pattern, isPattern, logger, ref } from '@strudel/core'; +import { Hap, Pattern, TimeSpan, getTime, isPattern, logger, ref, reify } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core'; import { Note } from 'webmidi'; import { scheduleAtTime } from '../superdough/helpers.mjs'; @@ -477,14 +477,41 @@ Pattern.prototype.midi = function (midiport, options = {}) { }); }; -let listeners = {}; -const refs = {}; -const refsByChan = {}; +/** + * Initialize a midi device + */ +async function _initialize(input) { + if (isPattern(input)) { + throw new Error( + `[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${ + WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1' + }')`, + ); + } + const initial = await enableWebMidi(); // only returns on first init + const device = getDevice(input, WebMidi.inputs); + if (!device) { + throw new Error( + `[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, + ); + } + if (initial) { + const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name); + logger( + `[midi] Midi enabled! Using "${device.name}". ${ + otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : '' + }`, + ); + } + return device; +} /** * MIDI input: Opens a MIDI input port to receive MIDI control change messages. * * The output is a function that accepts a midi cc value to query as well as (optionally) a midi channel + * + * @name midin * @param {string | number} input MIDI device name or index defaulting to 0 * @returns {function(number, number=): Pattern} A function from (cc, channel?) to a pattern. * When queried, the pattern will produces the most recently received midi value (normalized to 0 to 1) @@ -498,29 +525,11 @@ const refsByChan = {}; * note("c a f e").s("saw") * .when(cc(0).gt(0), x => x.postgain(0)) */ +let listeners = {}; +const refs = {}; +const refsByChan = {}; export async function midin(input) { - if (isPattern(input)) { - throw new Error( - `midin: does not accept Pattern as input. Make sure to pass device name with single quotes. Example: midin('${ - WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1' - }')`, - ); - } - const initial = await enableWebMidi(); // only returns on first init - const device = getDevice(input, WebMidi.inputs); - if (!device) { - throw new Error( - `midiin: device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, - ); - } - if (initial) { - const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name); - logger( - `Midi enabled! Using "${device.name}". ${ - otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : '' - }`, - ); - } + const device = await _initialize(input); refs[input] ??= {}; refsByChan[input] ??= {}; const cc = (cc, chan) => { @@ -532,8 +541,7 @@ export async function midin(input) { listeners[input] && device.removeListener('midimessage', listeners[input]); listeners[input] = (e) => { - const ccNum = e.dataBytes[0]; - const v = e.dataBytes[1]; + const [ccNum, v] = e.dataBytes; const chan = e.message.channel; const scaled = v / 127; refsByChan[input][ccNum] ??= {}; @@ -550,40 +558,42 @@ export async function midin(input) { * The note length is fixed as Superdough is not currently set up for undetermined * note durations * + * @name keyboard * @param {string | number} input MIDI device name or index defaulting to 0 - * @returns {function(): Pattern} A function that produces a pattern. + * @returns {function((number | Pattern)=): Pattern} A function that produces a pattern. * When queried, the pattern will produces the most recently played midi notes and velocities, * lasting for the specified duration * @example - * const cc = await midin('IAC Driver Bus 1') - * note("c a f e").lpf(cc(0).range(0, 1000)).lpq(cc(1).range(0, 10)).sound("sawtooth") + * const kb = await keyboard('Arturia KeyStep 32') + * kb().s("tri").lpf(80).lpe(6).lpd(0.1).room(2).delay(0.35) * @example - * const allCC = await midin('IAC Driver Bus 1') - * const cc = (ccNum) => allCC(ccNum, 2) // just channel 2 - * note("c a f e").s("saw") - * .when(cc(0).gt(0), x => x.postgain(0)) + * const kb = await keyboard('Arturia KeyStep 32') + * kb("0.5 1") + * .s("saw") + * .add(note(rand.mul(0.3))) + * .lpf(1000).lpe(2).room(0.5) */ const kHaps = {}; const kListeners = {}; -export async function keyboard(input, noteLength = 0.5) { +export async function keyboard(input) { const device = await _initialize(input); if (!kHaps[input]) { - kHaps[input] = {}; + kHaps[input] = []; } kListeners[input] && device.removeListener('midimessage', kListeners[input]); kListeners[input] = (e) => { const { dataBytes, message } = e; - const [cc, v] = dataBytes; + const [note, velocity] = dataBytes; const noteoff = message.command === 8; - const key = `${input}_${cc}`; + const key = `${input}_${note}`; const t = getTime() + 0.1; // slight delay so it's not too late for cyclist to catch - const span = new TimeSpan(t, t + noteLength); + const span = new TimeSpan(t, t); let value = { midikey: key }; if (noteoff) { /* TODO: It's a big effort, but we could modify superdough to allow for situations where we don't know the hap duration in advance. This would mean, for example, that if the hap is flagged as such a special note-on event, we have all effects be persistent & all ADSR - envelopes stop at the S stage [and store references to them by note+pattern] + envelopes stop at the S stage [and store references to them by `midikey`] If this is implemented, then getting full keyboard functionality should be as simple as sending the corresponding note-off event below and triggering `release` on each of those referenced effects/envelopes @@ -594,20 +604,27 @@ export async function keyboard(input, noteLength = 0.5) { */ return; } else { - value = { ...value, note: Math.round(cc), velocity: v / 127 }; + value = { ...value, note: Math.round(note), velocity: velocity / 127 }; } - kHaps[input][key] = new Hap(span, span, value, {}); + kHaps[input].push(new Hap(span, span, value, {})); }; device.addListener('midimessage', kListeners[input]); - const kb = () => { + const kb = (noteLength = 0.5) => { + const nlPat = reify(noteLength); const query = (state) => { - const haps = []; - for (const [key, hap] of Object.entries(kHaps[input])) { - haps.push(hap); - } + const haps = kHaps[input].flatMap((hap) => { + const lenHaps = nlPat.query(state.setSpan(hap.wholeOrPart())); + return lenHaps.map((lenHap) => { + const nl = lenHap.value ?? 0.5; + const whole = new TimeSpan(hap.whole.begin, hap.whole.begin.add(nl)); + const part = new TimeSpan(hap.part.begin, hap.part.begin.add(nl)); + const context = hap.combineContext(lenHap); + return new Hap(whole, part, hap.value, context); + }); + }); if (state.controls.cyclist) { // Notes have been sent; clear them - kHaps[input] = {}; + kHaps[input] = []; } return haps; }; diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 09d285b5d..62e2de914 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -5943,6 +5943,10 @@ exports[`runs examples > example "juxBy" example index 0 1`] = ` exports[`runs examples > example "keyDown" example index 0 1`] = `[]`; +exports[`runs examples > example "keyboard" example index 0 1`] = `[]`; + +exports[`runs examples > example "keyboard" example index 1 1`] = `[]`; + exports[`runs examples > example "lastOf" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:c3 ]", diff --git a/test/runtime.mjs b/test/runtime.mjs index 2a29de3f5..51a713a60 100644 --- a/test/runtime.mjs +++ b/test/runtime.mjs @@ -131,6 +131,10 @@ const midin = () => { return (ccNum) => strudel.ref(() => 0); // returns ref with default value 0 }; +const keyboard = async () => { + return () => strudel.silence; +}; + const sysex = ([id, data]) => {}; // TODO: refactor to evalScope @@ -150,6 +154,7 @@ evalScope( */ { midin, + keyboard, sysex, // gist, // euclid, From 4743334a17388c58993b84fbb6bf57d604d7829e Mon Sep 17 00:00:00 2001 From: Aria Date: Fri, 9 Jan 2026 22:50:16 -0600 Subject: [PATCH 3/8] Reduce latency; change name to midikeys; include in i/o learn page --- packages/midi/midi.mjs | 10 +++++----- test/__snapshots__/examples.test.mjs.snap | 8 ++++---- test/runtime.mjs | 4 ++-- website/src/pages/learn/input-output.mdx | 6 +++++- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index bfc42207a..d4f6093a0 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -558,16 +558,16 @@ export async function midin(input) { * The note length is fixed as Superdough is not currently set up for undetermined * note durations * - * @name keyboard + * @name midikeys * @param {string | number} input MIDI device name or index defaulting to 0 * @returns {function((number | Pattern)=): Pattern} A function that produces a pattern. * When queried, the pattern will produces the most recently played midi notes and velocities, * lasting for the specified duration * @example - * const kb = await keyboard('Arturia KeyStep 32') + * const kb = await midikeys('Arturia KeyStep 32') * kb().s("tri").lpf(80).lpe(6).lpd(0.1).room(2).delay(0.35) * @example - * const kb = await keyboard('Arturia KeyStep 32') + * const kb = await midikeys('Arturia KeyStep 32') * kb("0.5 1") * .s("saw") * .add(note(rand.mul(0.3))) @@ -575,7 +575,7 @@ export async function midin(input) { */ const kHaps = {}; const kListeners = {}; -export async function keyboard(input) { +export async function midikeys(input) { const device = await _initialize(input); if (!kHaps[input]) { kHaps[input] = []; @@ -586,7 +586,7 @@ export async function keyboard(input) { const [note, velocity] = dataBytes; const noteoff = message.command === 8; const key = `${input}_${note}`; - const t = getTime() + 0.1; // slight delay so it's not too late for cyclist to catch + const t = getTime() + 0.03; // slight delay so it's not too late for cyclist to catch const span = new TimeSpan(t, t); let value = { midikey: key }; if (noteoff) { diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 62e2de914..2aa20a0eb 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -5943,10 +5943,6 @@ exports[`runs examples > example "juxBy" example index 0 1`] = ` exports[`runs examples > example "keyDown" example index 0 1`] = `[]`; -exports[`runs examples > example "keyboard" example index 0 1`] = `[]`; - -exports[`runs examples > example "keyboard" example index 1 1`] = `[]`; - exports[`runs examples > example "lastOf" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:c3 ]", @@ -7031,6 +7027,10 @@ exports[`runs examples > example "midicmd" example index 0 1`] = ` ] `; +exports[`runs examples > example "midikeys" example index 0 1`] = `[]`; + +exports[`runs examples > example "midikeys" example index 1 1`] = `[]`; + exports[`runs examples > example "midin" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:c cutoff:0 resonance:0 s:sawtooth ]", diff --git a/test/runtime.mjs b/test/runtime.mjs index 51a713a60..5a64913cf 100644 --- a/test/runtime.mjs +++ b/test/runtime.mjs @@ -131,7 +131,7 @@ const midin = () => { return (ccNum) => strudel.ref(() => 0); // returns ref with default value 0 }; -const keyboard = async () => { +const midikeys = async () => { return () => strudel.silence; }; @@ -154,7 +154,7 @@ evalScope( */ { midin, - keyboard, + midikeys, sysex, // gist, // euclid, diff --git a/website/src/pages/learn/input-output.mdx b/website/src/pages/learn/input-output.mdx index 0dd48b7de..1d4bf4c39 100644 --- a/website/src/pages/learn/input-output.mdx +++ b/website/src/pages/learn/input-output.mdx @@ -16,10 +16,14 @@ It is also possible to pattern other things with Strudel, such as software and h Strudel supports MIDI without any additional software (thanks to [webmidi](https://npmjs.com/package/webmidi)), just by adding methods to your pattern: -## midiin(inputName?) +## midin(inputName?) +## midikeys(inputName?) + + + ## midi(outputName?,options?) Either connect a midi device or use the IAC Driver (Mac) or Midi Through Port (Linux) for internal midi messages. From d753eedb901c1ead239f8f288d887af292f3ce66 Mon Sep 17 00:00:00 2001 From: Aria Date: Sat, 10 Jan 2026 11:20:17 -0600 Subject: [PATCH 4/8] Make latency consistent across all cps --- packages/core/repl.mjs | 3 ++- packages/core/time.mjs | 9 +++++++++ packages/midi/midi.mjs | 6 ++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 4d59f5990..77ceac8c5 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -2,7 +2,7 @@ import { NeoCyclist } from './neocyclist.mjs'; import { Cyclist } from './cyclist.mjs'; import { evaluate as _evaluate } from './evaluate.mjs'; import { errorLogger, logger } from './logger.mjs'; -import { setTime } from './time.mjs'; +import { setCpsFunc, setTime } from './time.mjs'; import { evalScope } from './evaluate.mjs'; import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; @@ -61,6 +61,7 @@ export function repl({ // 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); + setCpsFunc(() => scheduler.cps); let pPatterns = {}; let anonymousIndex = 0; let allTransform; diff --git a/packages/core/time.mjs b/packages/core/time.mjs index 80daaf53c..3d94a694c 100644 --- a/packages/core/time.mjs +++ b/packages/core/time.mjs @@ -1,4 +1,5 @@ let time; +let cpsFunc; export function getTime() { if (!time) { throw new Error('no time set! use setTime to define a time source'); @@ -9,3 +10,11 @@ export function getTime() { export function setTime(func) { time = func; } + +export function setCpsFunc(func) { + cpsFunc = func; +} + +export function getCps() { + return cpsFunc?.(); +} diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index d4f6093a0..6233bf60d 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as _WebMidi from 'webmidi'; -import { Hap, Pattern, TimeSpan, getTime, isPattern, logger, ref, reify } from '@strudel/core'; +import { Hap, Pattern, TimeSpan, getCps, getTime, isPattern, logger, ref, reify } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core'; import { Note } from 'webmidi'; import { scheduleAtTime } from '../superdough/helpers.mjs'; @@ -586,7 +586,9 @@ export async function midikeys(input) { const [note, velocity] = dataBytes; const noteoff = message.command === 8; const key = `${input}_${note}`; - const t = getTime() + 0.03; // slight delay so it's not too late for cyclist to catch + const cps = getCps() ?? 0.5; + const latencySeconds = 0.06; // slight delay so it's not too late for cyclist to catch + const t = getTime() + latencySeconds * cps; const span = new TimeSpan(t, t); let value = { midikey: key }; if (noteoff) { From dab16767612e88e2337c46e938d387f361fdceda Mon Sep 17 00:00:00 2001 From: Aria Date: Sat, 10 Jan 2026 11:26:30 -0600 Subject: [PATCH 5/8] Add license for time file --- packages/core/time.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/time.mjs b/packages/core/time.mjs index 3d94a694c..2d4caecf5 100644 --- a/packages/core/time.mjs +++ b/packages/core/time.mjs @@ -1,3 +1,9 @@ +/* +time.mjs - Core time module. Used to expose parameters from the Scheduler like `time` and `cps` +Copyright (C) 2026 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 . +*/ + let time; let cpsFunc; export function getTime() { From 12cc1e9b5ea22017bacfce974e2c5aad0ca7eaa6 Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 11 Jan 2026 14:51:47 -0600 Subject: [PATCH 6/8] Add immediate triggering; move time to schedulerState --- packages/core/index.mjs | 2 +- packages/core/repl.mjs | 4 +- .../core/{time.mjs => schedulerState.mjs} | 22 ++++++- packages/midi/midi.mjs | 59 +++++++++++++++++-- 4 files changed, 79 insertions(+), 8 deletions(-) rename packages/core/{time.mjs => schedulerState.mjs} (70%) diff --git a/packages/core/index.mjs b/packages/core/index.mjs index 9260e3671..542a54243 100644 --- a/packages/core/index.mjs +++ b/packages/core/index.mjs @@ -22,7 +22,7 @@ export * from './repl.mjs'; export * from './signal.mjs'; export * from './speak.mjs'; export * from './state.mjs'; -export * from './time.mjs'; +export * from './schedulerState.mjs'; export * from './timespan.mjs'; export * from './ui.mjs'; export * from './util.mjs'; diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 71f68b809..e39b57de3 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -2,7 +2,7 @@ import { NeoCyclist } from './neocyclist.mjs'; import { Cyclist } from './cyclist.mjs'; import { evaluate as _evaluate } from './evaluate.mjs'; import { errorLogger, logger } from './logger.mjs'; -import { setCpsFunc, setTime } from './time.mjs'; +import { setCpsFunc, setPattern as exposeSchedulerPattern, setTime, setTriggerFunc } from './schedulerState.mjs'; import { evalScope } from './evaluate.mjs'; import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; import { reset_state } from './impure.mjs'; @@ -65,6 +65,7 @@ export function repl({ // 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); + setTriggerFunc(schedulerOptions.onTrigger); setCpsFunc(() => scheduler.cps); let pPatterns = {}; let anonymousIndex = 0; @@ -90,6 +91,7 @@ export function repl({ const setPattern = async (pattern, autostart = true) => { pattern = editPattern?.(pattern) || pattern; await scheduler.setPattern(pattern, autostart); + exposeSchedulerPattern(pattern); return pattern; }; setTime(() => scheduler.now()); // TODO: refactor? diff --git a/packages/core/time.mjs b/packages/core/schedulerState.mjs similarity index 70% rename from packages/core/time.mjs rename to packages/core/schedulerState.mjs index 2d4caecf5..97a4f56dc 100644 --- a/packages/core/time.mjs +++ b/packages/core/schedulerState.mjs @@ -1,11 +1,13 @@ /* -time.mjs - Core time module. Used to expose parameters from the Scheduler like `time` and `cps` -Copyright (C) 2026 Strudel contributors - see +schedulerState.mjs - Module to pipe out various parameters from the scheduler for global consumption +Copyright (C) 2026 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 . */ let time; let cpsFunc; +let pattern; +let triggerFunc; export function getTime() { if (!time) { throw new Error('no time set! use setTime to define a time source'); @@ -24,3 +26,19 @@ export function setCpsFunc(func) { export function getCps() { return cpsFunc?.(); } + +export function setPattern(pat) { + pattern = pat; +} + +export function getPattern() { + return pattern; +} + +export function setTriggerFunc(func) { + triggerFunc = func; +} + +export function getTriggerFunc() { + return triggerFunc; +} diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 6233bf60d..ad6b799dc 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -5,9 +5,22 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as _WebMidi from 'webmidi'; -import { Hap, Pattern, TimeSpan, getCps, getTime, isPattern, logger, ref, reify } from '@strudel/core'; +import { + Hap, + Pattern, + TimeSpan, + getCps, + getPattern, + getTime, + getTriggerFunc, + isPattern, + logger, + ref, + reify, +} from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core'; import { Note } from 'webmidi'; +import { getAudioContext } from '@strudel/webaudio'; import { scheduleAtTime } from '../superdough/helpers.mjs'; // if you use WebMidi from outside of this package, make sure to import that instance: @@ -575,6 +588,33 @@ export async function midin(input) { */ const kHaps = {}; const kListeners = {}; + +function triggerKbNow(input, cps, now, latencyCycles) { + const pattern = getPattern(); + const trigger = getTriggerFunc(); + if (!pattern || !trigger) { + return false; + } + const t = now + latencyCycles; + const eps = 1e-6; + const haps = pattern.queryArc(t - eps, t + eps, { _cps: cps }); + // Only keep haps coming from `midikeys` + const kbHaps = haps.filter((hap) => hap.value?.midikey?.startsWith(`${input}_`)); + const ctxNow = getAudioContext().currentTime; + if (!kbHaps.length) { + return false; + } + kbHaps.forEach((hap) => { + if (!hap.hasOnset()) { + return; + } + const t = ctxNow + (hap.whole.begin - now) / cps; + const duration = hap.duration / cps; + trigger(hap, t - ctxNow, duration, cps, t); + }); + + return true; +} export async function midikeys(input) { const device = await _initialize(input); if (!kHaps[input]) { @@ -584,11 +624,14 @@ export async function midikeys(input) { kListeners[input] = (e) => { const { dataBytes, message } = e; const [note, velocity] = dataBytes; - const noteoff = message.command === 8; + const noteon = message.command === 9; + const noteoff = message.command === 8 || (noteon && velocity === 0); const key = `${input}_${note}`; const cps = getCps() ?? 0.5; - const latencySeconds = 0.06; // slight delay so it's not too late for cyclist to catch - const t = getTime() + latencySeconds * cps; + const triggerAvailable = !!(getPattern() && getTriggerFunc()); + const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency + const now = getTime(); + const t = now + latencySeconds * cps; const span = new TimeSpan(t, t); let value = { midikey: key }; if (noteoff) { @@ -609,6 +652,14 @@ export async function midikeys(input) { value = { ...value, note: Math.round(note), velocity: velocity / 127 }; } kHaps[input].push(new Hap(span, span, value, {})); + if (!noteoff && triggerAvailable) { + // If we have access to a trigger function, we call it to immediately + // dispatch to the audio engine, rather than waiting for cyclist to catch these haps + const triggered = triggerKbNow(input, cps, now, latencySeconds * cps); + if (triggered) { + kHaps[input] = []; + } + } }; device.addListener('midimessage', kListeners[input]); const kb = (noteLength = 0.5) => { From 5355a0c563bea7b0e98dbfbfbff65c7a60f00b01 Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 11 Jan 2026 23:34:52 -0600 Subject: [PATCH 7/8] Ignore non-note messages --- packages/midi/midi.mjs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index ad6b799dc..50a58b059 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -589,7 +589,7 @@ export async function midin(input) { const kHaps = {}; const kListeners = {}; -function triggerKbNow(input, cps, now, latencyCycles) { +function _triggerKeyboard(input, cps, now, latencyCycles) { const pattern = getPattern(); const trigger = getTriggerFunc(); if (!pattern || !trigger) { @@ -623,9 +623,14 @@ export async function midikeys(input) { kListeners[input] && device.removeListener('midimessage', kListeners[input]); kListeners[input] = (e) => { const { dataBytes, message } = e; - const [note, velocity] = dataBytes; const noteon = message.command === 9; - const noteoff = message.command === 8 || (noteon && velocity === 0); + let noteoff = message.command === 8; + if (!noteon && !noteoff) { + // Ignore non-note messages (e.g. CC, pitchbend, modwheel, etc.) + return; + } + const [note, velocity] = dataBytes; + noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff const key = `${input}_${note}`; const cps = getCps() ?? 0.5; const triggerAvailable = !!(getPattern() && getTriggerFunc()); @@ -655,7 +660,7 @@ export async function midikeys(input) { if (!noteoff && triggerAvailable) { // If we have access to a trigger function, we call it to immediately // dispatch to the audio engine, rather than waiting for cyclist to catch these haps - const triggered = triggerKbNow(input, cps, now, latencySeconds * cps); + const triggered = _triggerKeyboard(input, cps, now, latencySeconds * cps); if (triggered) { kHaps[input] = []; } From 80875cca956024e017281108fcd2fee673e6f1f3 Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 14 Jan 2026 11:44:48 -0600 Subject: [PATCH 8/8] Expose scheduler state and don't enqueue/trigger midi keys when repl stopped --- packages/core/repl.mjs | 9 ++++++++- packages/core/schedulerState.mjs | 9 +++++++++ packages/midi/midi.mjs | 8 ++++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index e39b57de3..a056cac53 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -2,7 +2,13 @@ import { NeoCyclist } from './neocyclist.mjs'; import { Cyclist } from './cyclist.mjs'; import { evaluate as _evaluate } from './evaluate.mjs'; import { errorLogger, logger } from './logger.mjs'; -import { setCpsFunc, setPattern as exposeSchedulerPattern, setTime, setTriggerFunc } from './schedulerState.mjs'; +import { + setCpsFunc, + setIsStarted, + setPattern as exposeSchedulerPattern, + setTime, + setTriggerFunc, +} from './schedulerState.mjs'; import { evalScope } from './evaluate.mjs'; import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; import { reset_state } from './impure.mjs'; @@ -52,6 +58,7 @@ export function repl({ getTime, onToggle: (started) => { updateState({ started }); + setIsStarted(started); onToggle?.(started); if (!started) { reset_state(); diff --git a/packages/core/schedulerState.mjs b/packages/core/schedulerState.mjs index 97a4f56dc..14b4c9054 100644 --- a/packages/core/schedulerState.mjs +++ b/packages/core/schedulerState.mjs @@ -8,6 +8,7 @@ let time; let cpsFunc; let pattern; let triggerFunc; +let isStarted; export function getTime() { if (!time) { throw new Error('no time set! use setTime to define a time source'); @@ -42,3 +43,11 @@ export function setTriggerFunc(func) { export function getTriggerFunc() { return triggerFunc; } + +export function setIsStarted(val) { + isStarted = !!val; +} + +export function getIsStarted() { + return isStarted; +} diff --git a/packages/midi/midi.mjs b/packages/midi/midi.mjs index 50a58b059..d0a1ce18d 100644 --- a/packages/midi/midi.mjs +++ b/packages/midi/midi.mjs @@ -10,6 +10,7 @@ import { Pattern, TimeSpan, getCps, + getIsStarted, getPattern, getTime, getTriggerFunc, @@ -625,8 +626,11 @@ export async function midikeys(input) { const { dataBytes, message } = e; const noteon = message.command === 9; let noteoff = message.command === 8; - if (!noteon && !noteoff) { - // Ignore non-note messages (e.g. CC, pitchbend, modwheel, etc.) + // Don't enqueue or trigger midi notes if scheduler is not started + const notStarted = !getIsStarted(); + // Ignore non-note messages (e.g. CC, pitchbend, modwheel, etc.) + const notANote = !noteon && !noteoff; + if (notStarted || notANote) { return; } const [note, velocity] = dataBytes;