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) => {