From b9fce1504276215bd9b280b69ab1214f6d341162 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Wed, 3 Dec 2025 15:18:32 -0500 Subject: [PATCH 01/19] working --- packages/codemirror/codemirror.mjs | 9 +++++++++ packages/codemirror/labelJump.mjs | 31 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 packages/codemirror/labelJump.mjs diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 69fce4b50..6643e400d 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -24,6 +24,7 @@ import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { activateTheme, initTheme, theme } from './themes.mjs'; import { isTooltipEnabled } from './tooltip.mjs'; import { updateWidgets, widgetPlugin } from './widget.mjs'; +import { jumpToCharacter } from './labelJump.mjs'; export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; @@ -119,6 +120,14 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo preventDefault: true, run: () => onStop?.(), }, + { + key: 'Alt-]', + run: (view) => jumpToCharacter(view, '$', 1), + }, + { + key: 'Alt-[', + run: (view) => jumpToCharacter(view, '$', -1), + }, /* { key: 'Ctrl-Shift-.', run: () => (onPanic ? onPanic() : onStop?.()), diff --git a/packages/codemirror/labelJump.mjs b/packages/codemirror/labelJump.mjs new file mode 100644 index 000000000..fcdb437f5 --- /dev/null +++ b/packages/codemirror/labelJump.mjs @@ -0,0 +1,31 @@ +import { EditorSelection } from '@codemirror/state'; +import { SearchCursor } from '@codemirror/search'; + +export function jumpToCharacter(view, character, direction = 1) { + const { state, dispatch } = view; + const pos = state.selection.main.head; + const cursor = new SearchCursor(state.doc, character); + + let characterPositions = []; + let jumpPos; + while (!cursor.next().done) { + characterPositions.push(cursor.value.to); + } + if (!characterPositions.length) { + return false; + } + if (direction > 0) { + jumpPos = characterPositions.find((x) => x > pos + 1) ?? characterPositions.at(0); // Loop back around for convenience + } else { + jumpPos = characterPositions.reverse().find((x) => x < pos + 1) ?? characterPositions.at(0); + } + + if (!jumpPos) { + return false; + } + dispatch({ + selection: EditorSelection.cursor(jumpPos - 1), + scrollIntoView: true, + }); + return true; +} From c1a185e852433d4469e25e6b07a2eec64b36d08d Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Wed, 3 Dec 2025 15:25:05 -0500 Subject: [PATCH 02/19] fix null case --- packages/codemirror/labelJump.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/codemirror/labelJump.mjs b/packages/codemirror/labelJump.mjs index fcdb437f5..0aced4eb5 100644 --- a/packages/codemirror/labelJump.mjs +++ b/packages/codemirror/labelJump.mjs @@ -20,7 +20,7 @@ export function jumpToCharacter(view, character, direction = 1) { jumpPos = characterPositions.reverse().find((x) => x < pos + 1) ?? characterPositions.at(0); } - if (!jumpPos) { + if (jumpPos == null) { return false; } dispatch({ From f0aac2098afba2ba10d1f8d88db70c988c6b3496 Mon Sep 17 00:00:00 2001 From: "Jade (Rose) Rowland" Date: Wed, 3 Dec 2025 22:59:20 -0500 Subject: [PATCH 03/19] update_shortcut --- packages/codemirror/codemirror.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index 6643e400d..ba481d9ac 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -121,11 +121,11 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo run: () => onStop?.(), }, { - key: 'Alt-]', + key: 'Alt-w', run: (view) => jumpToCharacter(view, '$', 1), }, { - key: 'Alt-[', + key: 'Alt-q', run: (view) => jumpToCharacter(view, '$', -1), }, /* { From 597c94199118adcc597e3e437b0f7174192c8bb5 Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 10 Dec 2025 12:01:04 -0600 Subject: [PATCH 04/19] 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 05/19] 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 6908ea38e43f4efeb8537c18cdb534e144f0ed48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Bj=C3=B6rk?= Date: Sat, 20 Dec 2025 17:26:23 +0100 Subject: [PATCH 06/19] Added search/filter in patterns tab Added pattern as dep to filter memo Format --- website/src/metadata_parser.js | 2 +- .../src/repl/components/panel/PatternsTab.jsx | 190 ++++++++++-------- 2 files changed, 106 insertions(+), 86 deletions(-) diff --git a/website/src/metadata_parser.js b/website/src/metadata_parser.js index fab943d66..f0a70babb 100644 --- a/website/src/metadata_parser.js +++ b/website/src/metadata_parser.js @@ -1,4 +1,4 @@ -const ALLOW_MANY = ['by', 'url', 'genre', 'license']; +const ALLOW_MANY = ['by', 'url', 'genre', 'license', 'tag']; export function getMetadata(raw_code) { if (raw_code == null) { diff --git a/website/src/repl/components/panel/PatternsTab.jsx b/website/src/repl/components/panel/PatternsTab.jsx index 5e0d50e72..588f8f39e 100644 --- a/website/src/repl/components/panel/PatternsTab.jsx +++ b/website/src/repl/components/panel/PatternsTab.jsx @@ -18,6 +18,7 @@ import { Pagination } from '../pagination/Pagination.jsx'; import { useState } from 'react'; import { useDebounce } from '../usedebounce.jsx'; import cx from '@src/cx.mjs'; +import { Textbox } from '../textbox/Textbox.jsx'; export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) { const meta = useMemo(() => getMetadata(pattern.code), [pattern]); @@ -33,7 +34,7 @@ export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) { } const author = Array.isArray(meta.by) ? meta.by.join(',') : 'Anonymous'; - return <>{`${pattern.id}: ${title} by ${author.slice(0, 100)}`.slice(0, 60)}; + return <>{`${title} by ${author.slice(0, 100)}`.slice(0, 60)}; } function PatternButton({ showOutline, onClick, pattern, showHiglight }) { @@ -79,73 +80,117 @@ const updateCodeWindow = (context, patternData, reset = false) => { context.handleUpdate(patternData, reset); }; -function UserPatterns({ context }) { +export function PatternsTab({ context }) { + const [search, setSearch] = useState(''); const activePattern = useActivePattern(); const viewingPatternStore = useViewingPatternData(); const viewingPatternData = parseJSON(viewingPatternStore); - const { userPatterns, patternFilter, patternAutoStart } = useSettings(); + const { userPatterns, patternAutoStart } = useSettings(); const viewingPatternID = viewingPatternData?.id; - return ( -
-
- { - const { data } = userPattern.createAndAddToDB(); - updateCodeWindow(context, data); - }} - /> - { - const { data } = userPattern.duplicate(viewingPatternData); - updateCodeWindow(context, data); - }} - /> - { - const { data } = userPattern.delete(viewingPatternID); - updateCodeWindow(context, { ...data, collection: userPattern.collection }); - }} - /> - - - { - const { data } = userPattern.clearAll(); - updateCodeWindow(context, data); - }} - /> -
+ const visiblePatterns = useMemo(() => { + return Object.fromEntries( + Object.entries(userPatterns).filter(([_key, pattern]) => { + if (!search) { + return true; + } -
- {/* {patternFilter === patternFilterName.user && ( */} - { - updateCodeWindow(context, { ...userPatterns[id], collection: userPattern.collection }, patternAutoStart); + const meta = getMetadata(pattern.code); - if (context.started && activePattern === id) { - context.handleEvaluate(); + // Search for specific meta keys + const searchTrimmed = search.trim(); + if (searchTrimmed.includes(':')) { + const [metaKey, metaSearch] = searchTrimmed.split(':'); + if (metaKey !== undefined && metaSearch !== undefined && metaKey in meta) { + const metaValues = meta[metaKey]; + if (Array.isArray(metaValues)) { + return metaValues.some((metaValue) => metaValue.toLowerCase().includes(metaSearch.trim().toLowerCase())); + } else if (typeof metaValues === 'string') { + return metaValues.toLowerCase().includes(metaSearch.trim().toLowerCase()); + } else { + return false; } - }} - patterns={userPatterns} - started={context.started} - activePattern={activePattern} - viewingPatternID={viewingPatternID} - /> - {/* )} */} + } + } + const title = meta.title ? meta.title : 'unnamed'; + const authors = meta.by ? meta.by : ['anonymous']; + const tags = meta.tag ? meta.tag : []; + const lowerCaseSearch = search.toLowerCase(); + return ( + title.toLowerCase().includes(lowerCaseSearch) || + authors.some((author) => author.toLowerCase().includes(lowerCaseSearch)) || + tags.some((tag) => tag.toLowerCase().includes(lowerCaseSearch)) + ); + }), + ); + }, [search, viewingPatternStore]); + + return ( +
+
+ +
+
+
+ { + const { data } = userPattern.createAndAddToDB(); + updateCodeWindow(context, data); + }} + /> + { + const { data } = userPattern.duplicate(viewingPatternData); + updateCodeWindow(context, data); + }} + /> + { + const { data } = userPattern.delete(viewingPatternID); + updateCodeWindow(context, { ...data, collection: userPattern.collection }); + }} + /> + + + + { + const { data } = userPattern.clearAll(); + updateCodeWindow(context, data); + }} + /> +
+ +
+ {/* {patternFilter === patternFilterName.user && ( */} + { + updateCodeWindow(context, { ...userPatterns[id], collection: userPattern.collection }, patternAutoStart); + + if (context.started && activePattern === id) { + context.handleEvaluate(); + } + }} + patterns={visiblePatterns} + started={context.started} + activePattern={activePattern} + viewingPatternID={viewingPatternID} + /> + {/* )} */} +
); @@ -232,28 +277,3 @@ function PublicPatterns({ context }) { } return ; } - -export function PatternsTab({ context }) { - const { patternFilter } = useSettings(); - - return ( -
- -
- ); - /* return ( -
- settingsMap.setKey('patternFilter', value)} - items={patternFilterName} - > - - {patternFilter === patternFilterName.user ? ( - - ) : ( - - )} -
- ); */ -} From aa8209ad4c44e0d17a5185d3d3213269ef509229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Bj=C3=B6rk?= Date: Thu, 1 Jan 2026 12:22:52 +0100 Subject: [PATCH 07/19] Refactored handling of trim/lowercase based on PR comments --- .../src/repl/components/panel/PatternsTab.jsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/website/src/repl/components/panel/PatternsTab.jsx b/website/src/repl/components/panel/PatternsTab.jsx index 588f8f39e..56f0b4cb8 100644 --- a/website/src/repl/components/panel/PatternsTab.jsx +++ b/website/src/repl/components/panel/PatternsTab.jsx @@ -98,15 +98,15 @@ export function PatternsTab({ context }) { const meta = getMetadata(pattern.code); // Search for specific meta keys - const searchTrimmed = search.trim(); - if (searchTrimmed.includes(':')) { - const [metaKey, metaSearch] = searchTrimmed.split(':'); + const searchLowercaseTrimmed = search.trim().toLowerCase(); + if (searchLowercaseTrimmed.includes(':')) { + const [metaKey, metaSearch] = searchLowercaseTrimmed.split(/:\s*/); if (metaKey !== undefined && metaSearch !== undefined && metaKey in meta) { const metaValues = meta[metaKey]; if (Array.isArray(metaValues)) { - return metaValues.some((metaValue) => metaValue.toLowerCase().includes(metaSearch.trim().toLowerCase())); + return metaValues.some((metaValue) => metaValue.toLowerCase().includes(metaSearch)); } else if (typeof metaValues === 'string') { - return metaValues.toLowerCase().includes(metaSearch.trim().toLowerCase()); + return metaValues.toLowerCase().includes(metaSearch); } else { return false; } @@ -115,11 +115,10 @@ export function PatternsTab({ context }) { const title = meta.title ? meta.title : 'unnamed'; const authors = meta.by ? meta.by : ['anonymous']; const tags = meta.tag ? meta.tag : []; - const lowerCaseSearch = search.toLowerCase(); return ( - title.toLowerCase().includes(lowerCaseSearch) || - authors.some((author) => author.toLowerCase().includes(lowerCaseSearch)) || - tags.some((tag) => tag.toLowerCase().includes(lowerCaseSearch)) + title.toLowerCase().includes(searchLowercaseTrimmed) || + authors.some((author) => author.toLowerCase().includes(searchLowercaseTrimmed)) || + tags.some((tag) => tag.toLowerCase().includes(searchLowercaseTrimmed)) ); }), ); From 4743334a17388c58993b84fbb6bf57d604d7829e Mon Sep 17 00:00:00 2001 From: Aria Date: Fri, 9 Jan 2026 22:50:16 -0600 Subject: [PATCH 08/19] 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 09/19] 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 10/19] 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 31cacc3c29a18e59761cda66e912c8ee8e00d8a3 Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 11 Jan 2026 16:23:17 +0000 Subject: [PATCH 11/19] add warm to faq --- website/src/pages/learn/faq.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/website/src/pages/learn/faq.mdx b/website/src/pages/learn/faq.mdx index e43839dc3..fc3bcdb2d 100644 --- a/website/src/pages/learn/faq.mdx +++ b/website/src/pages/learn/faq.mdx @@ -19,6 +19,14 @@ While there is no charge there are some caveats, e.g.: - the source code must stay free, i.e. you cannot distribute strudel or tidal as part of projects with incompatible licenses - see the [license](https://www.gnu.org/licenses/agpl-3.0.en.html) for details. - the contributed examples and tracks are also separately licensed, and must not e.g. be used to train AI models without permission. +## How do I try out the latest features? + +The main, stable strudel website is [strudel.cc](https://strudel.cc/). There is also [warm.strudel.cc](https://warm.strudel.cc), known as "warm strudel", which has the latest development features. You might find warm strudel has bug fixes and features that the main website doesn't, but it will often be less stable and probably not suitable for important performances. + +Alternatively, you can run strudel locally to try out the latest features. You can find development-oriented [instructions for that here](https://codeberg.org/uzu/strudel/src/branch/main/CONTRIBUTING.md#project-setup). + +You can see the [latest changes here](https://codeberg.org/uzu/strudel/pulls?q=&type=all&sort=recentupdate&state=closed&labels=&milestone=0&project=0&assignee=0&poster=0), as 'pull requests'. + ## How to record or export audio? Strudel is not a digital audio workstation and does not operate following the same principles shared by most traditional audio softwares. However, there are multiple ways to record the audio -- and video -- output of Strudel: From 12cc1e9b5ea22017bacfce974e2c5aad0ca7eaa6 Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 11 Jan 2026 14:51:47 -0600 Subject: [PATCH 12/19] 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 13/19] 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 7c5f53f9ae8b85507300164be00852eccf6d0bf6 Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 12 Jan 2026 13:51:21 -0600 Subject: [PATCH 14/19] Add ack message for worklet death; remove max pool size --- packages/superdough/nodePools.mjs | 7 ++----- packages/superdough/synth.mjs | 5 ++++- packages/superdough/wavetable.mjs | 5 ++++- packages/superdough/worklets.mjs | 26 ++++++++++++++++++++++++-- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/superdough/nodePools.mjs b/packages/superdough/nodePools.mjs index 205352247..000099dc6 100644 --- a/packages/superdough/nodePools.mjs +++ b/packages/superdough/nodePools.mjs @@ -8,7 +8,6 @@ This program is free software: you can redistribute it and/or modify it under th const nodePools = new Map(); const POOL_KEY = Symbol('nodePoolKey'); const IS_WORKLET_DEAD = Symbol('nodePoolIsWorkletDead'); -const MAX_POOL_SIZE = 64; export const isPoolable = (node) => !!node[POOL_KEY]; @@ -47,10 +46,8 @@ export const releaseNodeToPool = (node) => { const now = node.context?.currentTime ?? 0; getParams(node).forEach((param) => param.cancelScheduledValues(now)); const pool = nodePools.get(key) ?? []; - if (pool.length < MAX_POOL_SIZE) { - pool.push(new WeakRef(node)); - nodePools.set(key, pool); - } + pool.push(new WeakRef(node)); + nodePools.set(key, pool); }; export const markWorkletAsDead = (worklet) => (worklet[IS_WORKLET_DEAD] = true); diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index f4bdd749f..ec1724da0 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -186,7 +186,10 @@ export function registerSynthSounds() { }); o.port.postMessage({ type: 'initialize' }); o.port.onmessage = (e) => { - if (e.data.type === 'died') markWorkletAsDead(o); + if (e.data.type === 'died') { + markWorkletAsDead(o); + o.port.postMessage({ type: 'diedACK' }); + } o.port.onmessage = null; }; const gainAdjustment = 1 / Math.sqrt(voices); diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index adf8b0c44..a228bc1d7 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -252,7 +252,10 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { }); source.port.postMessage({ type: 'initialize', payload }); source.port.onmessage = (e) => { - if (e.data.type === 'died') markWorkletAsDead(source); + if (e.data.type === 'died') { + markWorkletAsDead(source); + source.port.postMessage({ type: 'diedACK' }); + } source.port.onmessage = null; }; if (ac.currentTime > t) { diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 52c13e836..61c620878 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -464,11 +464,19 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { constructor() { super(); this.isAlive = true; // used internally to prevent multiple death messages + // diedACK is used for the main thread to acknowledge that the worklet has died + // This is so that we don't risk simultaneously killing the worklet (`return false`) + // and pooling the worklet (main thread) before the async `died` message hits + // the main thread + this.diedACK = false; this.port.onmessage = (e) => { const { type, payload } = e.data || {}; if (type === 'initialize') { this.initialize(payload); } + if (type === 'diedACK') { + this.diedACK = true; + } }; this.initialize(); } @@ -529,7 +537,10 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { this.port.postMessage({ type: 'died' }); this.isAlive = false; } - return false; + if (this.diedACK || currentTime >= params.end[1] + 1) { + return false; + } + return true; } if (currentTime >= params.end[0] || currentTime <= params.begin[0]) { // Inside of grace period or not yet started @@ -1165,11 +1176,19 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { constructor(options) { super(options); this.isAlive = true; // used internally to prevent multiple death messages + // diedACK is used for the main thread to acknowledge that the worklet has died + // This is so that we don't risk simultaneously killing the worklet (`return false`) + // and pooling the worklet (main thread) before the async `died` message hits + // the main thread + this.diedACK = false; this.port.onmessage = (e) => { const { type, payload } = e.data || {}; if (type === 'initialize') { this.initialize(payload); } + if (type === 'diedACK') { + this.diedACK = true; + } }; this.initialize(); } @@ -1338,7 +1357,10 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { this.port.postMessage({ type: 'died' }); this.isAlive = false; } - return false; + if (this.diedACK || currentTime >= parameters.end[1] + 1) { + return false; + } + return true; } if (currentTime >= parameters.end[0] || currentTime <= parameters.begin[0]) { // Inside of grace period or not yet started From 1f1f3288a6fe0d9f1803f1ceb00cd12bb746ba05 Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 12 Jan 2026 17:58:20 -0600 Subject: [PATCH 15/19] Switch to tracking grace period in node pool; add negative begin and ends --- packages/superdough/nodePools.mjs | 32 +++++++++---- packages/superdough/synth.mjs | 9 +--- packages/superdough/wavetable.mjs | 9 +--- packages/superdough/worklets.mjs | 78 +++++++++++-------------------- 4 files changed, 53 insertions(+), 75 deletions(-) diff --git a/packages/superdough/nodePools.mjs b/packages/superdough/nodePools.mjs index 000099dc6..90b4c0ae7 100644 --- a/packages/superdough/nodePools.mjs +++ b/packages/superdough/nodePools.mjs @@ -7,10 +7,13 @@ This program is free software: you can redistribute it and/or modify it under th const nodePools = new Map(); const POOL_KEY = Symbol('nodePoolKey'); -const IS_WORKLET_DEAD = Symbol('nodePoolIsWorkletDead'); export const isPoolable = (node) => !!node[POOL_KEY]; +const getNodeTime = (node) => { + return node.context?.currentTime ?? 0; +}; + const getParams = (node) => { const params = new Set(); node.parameters?.forEach((param) => params.add(param)); @@ -37,32 +40,43 @@ export const releaseNodeToPool = (node) => { // not reusable return; } - if (node[IS_WORKLET_DEAD]) { - // Worklet already terminated, don't pool it - return; - } const key = node[POOL_KEY]; if (key == null) return; - const now = node.context?.currentTime ?? 0; + const now = getNodeTime(node); getParams(node).forEach((param) => param.cancelScheduledValues(now)); const pool = nodePools.get(key) ?? []; pool.push(new WeakRef(node)); nodePools.set(key, pool); }; -export const markWorkletAsDead = (worklet) => (worklet[IS_WORKLET_DEAD] = true); +// Audio worklets are given a grace period to survive (`return true`) after +// being released. This concludes at time `end + 0.5`. We test here whether we are +// within some safe distance of that (`end + 0.45`) and if so, permit the node to be +// released. This helps to prevent race conditions between node termination and node +// re-use +const isNodeAlive = (node) => { + // Skip check if node is not a worklet + if (!(node instanceof AudioWorkletNode)) return true; + const now = getNodeTime(node); + const end = node?.parameters?.get('end').value ?? 0; + return now < end + 0.45; +}; // Attempt to get node from the pool. If this fails, fall back // to building it with the factory export const getNodeFromPool = (key, factory) => { const pool = nodePools.get(key) ?? []; let node; + let found = false; while (pool.length) { const ref = pool.pop(); node = ref?.deref(); - if (node != null && !node[IS_WORKLET_DEAD]) break; + if (node != null && isNodeAlive(node)) { + found = true; + break; + } } - if (node == null || node[IS_WORKLET_DEAD]) { + if (!found) { node = factory(); } node[POOL_KEY] = key; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index ec1724da0..9561ab363 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -18,7 +18,7 @@ import { } from './helpers.mjs'; import { logger } from './logger.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; -import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs'; +import { getNodeFromPool, releaseNodeToPool } from './nodePools.mjs'; const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one']; const waveformAliases = [ @@ -185,13 +185,6 @@ export function registerSynthSounds() { param.setValueAtTime(target, now); }); o.port.postMessage({ type: 'initialize' }); - o.port.onmessage = (e) => { - if (e.data.type === 'died') { - markWorkletAsDead(o); - o.port.postMessage({ type: 'diedACK' }); - } - o.port.onmessage = null; - }; const gainAdjustment = 1 / Math.sqrt(voices); getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin); diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index a228bc1d7..1de42f643 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -11,7 +11,7 @@ import { webAudioTimeout, releaseAudioNode, } from './helpers.mjs'; -import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs'; +import { getNodeFromPool, releaseNodeToPool } from './nodePools.mjs'; import { logger } from './logger.mjs'; export const Warpmode = Object.freeze({ @@ -251,13 +251,6 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { param.setValueAtTime(target, now); }); source.port.postMessage({ type: 'initialize', payload }); - source.port.onmessage = (e) => { - if (e.data.type === 'died') { - markWorkletAsDead(source); - source.port.postMessage({ type: 'diedACK' }); - } - source.port.onmessage = null; - }; if (ac.currentTime > t) { logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); return; diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 61c620878..6689278e3 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -463,20 +463,11 @@ registerProcessor('distort-processor', DistortProcessor); class SuperSawOscillatorProcessor extends AudioWorkletProcessor { constructor() { super(); - this.isAlive = true; // used internally to prevent multiple death messages - // diedACK is used for the main thread to acknowledge that the worklet has died - // This is so that we don't risk simultaneously killing the worklet (`return false`) - // and pooling the worklet (main thread) before the async `died` message hits - // the main thread - this.diedACK = false; this.port.onmessage = (e) => { const { type, payload } = e.data || {}; if (type === 'initialize') { this.initialize(payload); } - if (type === 'diedACK') { - this.diedACK = true; - } }; this.initialize(); } @@ -487,16 +478,16 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { return [ { name: 'begin', - defaultValue: 0, + defaultValue: -1, max: Number.POSITIVE_INFINITY, - min: 0, + min: -1, }, { name: 'end', - defaultValue: 0, + defaultValue: -1, max: Number.POSITIVE_INFINITY, - min: 0, + min: -1, }, { @@ -531,19 +522,17 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { ]; } process(_input, outputs, params) { - if (currentTime >= params.end[0] + 0.5) { - // Outside of grace period - should terminate - if (this.isAlive) { - this.port.postMessage({ type: 'died' }); - this.isAlive = false; - } - if (this.diedACK || currentTime >= params.end[1] + 1) { - return false; - } - return true; - } - if (currentTime >= params.end[0] || currentTime <= params.begin[0]) { - // Inside of grace period or not yet started + const begin = params.begin[0]; + const end = params.end[0]; + const beginDefined = begin >= 0; + const endDefined = end >= 0; + // We give a 0.5s grace period (for node pooling) before termination + const shouldTerminate = endDefined && currentTime >= end + 0.5; + const ended = endDefined && currentTime >= end; + const notStarted = currentTime <= begin; + if (shouldTerminate) { + return false; + } else if (ended || notStarted || !beginDefined) { return true; } const output = outputs[0]; @@ -1159,8 +1148,8 @@ const tablesCache = {}; class WavetableOscillatorProcessor extends AudioWorkletProcessor { static get parameterDescriptors() { return [ - { name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, - { name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, + { name: 'begin', defaultValue: -1, min: -1, max: Number.POSITIVE_INFINITY }, + { name: 'end', defaultValue: -1, min: -1, max: Number.POSITIVE_INFINITY }, { name: 'frequency', defaultValue: 440, min: Number.EPSILON }, { name: 'detune', defaultValue: 0 }, { name: 'freqspread', defaultValue: 0.18, min: 0 }, @@ -1175,20 +1164,11 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { constructor(options) { super(options); - this.isAlive = true; // used internally to prevent multiple death messages - // diedACK is used for the main thread to acknowledge that the worklet has died - // This is so that we don't risk simultaneously killing the worklet (`return false`) - // and pooling the worklet (main thread) before the async `died` message hits - // the main thread - this.diedACK = false; this.port.onmessage = (e) => { const { type, payload } = e.data || {}; if (type === 'initialize') { this.initialize(payload); } - if (type === 'diedACK') { - this.diedACK = true; - } }; this.initialize(); } @@ -1351,19 +1331,17 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { } process(_inputs, outputs, parameters) { - if (currentTime >= parameters.end[0] + 0.5) { - // Outside of grace period - should terminate - if (this.isAlive) { - this.port.postMessage({ type: 'died' }); - this.isAlive = false; - } - if (this.diedACK || currentTime >= parameters.end[1] + 1) { - return false; - } - return true; - } - if (currentTime >= parameters.end[0] || currentTime <= parameters.begin[0]) { - // Inside of grace period or not yet started + const begin = parameters.begin[0]; + const end = parameters.end[0]; + const beginDefined = begin >= 0; + const endDefined = end >= 0; + // We give a 0.5s grace period (for node pooling) before termination + const shouldTerminate = endDefined && currentTime >= end + 0.5; + const ended = endDefined && currentTime >= end; + const notStarted = currentTime <= begin; + if (shouldTerminate) { + return false; + } else if (ended || notStarted || !beginDefined) { return true; } const outL = outputs[0][0]; From 8b1fb12388aa523224f8e7a93e5bca0e3368fbd0 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 13 Jan 2026 03:32:19 +0100 Subject: [PATCH 16/19] fix: class -> className --- website/src/repl/components/panel/PatternsTab.jsx | 2 +- website/src/repl/components/panel/Reference.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/repl/components/panel/PatternsTab.jsx b/website/src/repl/components/panel/PatternsTab.jsx index 56f0b4cb8..04f6452e5 100644 --- a/website/src/repl/components/panel/PatternsTab.jsx +++ b/website/src/repl/components/panel/PatternsTab.jsx @@ -126,7 +126,7 @@ export function PatternsTab({ context }) { return (
-
+
diff --git a/website/src/repl/components/panel/Reference.jsx b/website/src/repl/components/panel/Reference.jsx index 505cf50d2..c03ac0cf9 100644 --- a/website/src/repl/components/panel/Reference.jsx +++ b/website/src/repl/components/panel/Reference.jsx @@ -55,7 +55,7 @@ export function Reference() { return (
-
+
From e82bb6c4100e58b4a9f99e617745a74d1fb74b1c Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 13 Jan 2026 03:38:09 +0100 Subject: [PATCH 17/19] fix: use pattern.id as title fallback --- website/src/repl/components/panel/PatternsTab.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/repl/components/panel/PatternsTab.jsx b/website/src/repl/components/panel/PatternsTab.jsx index 04f6452e5..75c019438 100644 --- a/website/src/repl/components/panel/PatternsTab.jsx +++ b/website/src/repl/components/panel/PatternsTab.jsx @@ -29,7 +29,7 @@ export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) { if (!isNaN(date)) { title = date.toLocaleDateString(); } else { - title = 'unnamed'; + title = pattern.id || 'unnamed'; } } From e5ab6b3c93b35973e46607c45bb071b323177bbf Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 13 Jan 2026 03:47:33 +0100 Subject: [PATCH 18/19] fix: short circuit --- website/src/repl/components/panel/PatternsTab.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/website/src/repl/components/panel/PatternsTab.jsx b/website/src/repl/components/panel/PatternsTab.jsx index 75c019438..c72ebaa30 100644 --- a/website/src/repl/components/panel/PatternsTab.jsx +++ b/website/src/repl/components/panel/PatternsTab.jsx @@ -89,12 +89,11 @@ export function PatternsTab({ context }) { const viewingPatternID = viewingPatternData?.id; const visiblePatterns = useMemo(() => { + if (!search) { + return userPatterns; + } return Object.fromEntries( Object.entries(userPatterns).filter(([_key, pattern]) => { - if (!search) { - return true; - } - const meta = getMetadata(pattern.code); // Search for specific meta keys From 80875cca956024e017281108fcd2fee673e6f1f3 Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 14 Jan 2026 11:44:48 -0600 Subject: [PATCH 19/19] 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;