1import { NeoCyclist } from './neocyclist.mjs'; 2import { Cyclist } from './cyclist.mjs'; 3import { evaluate as _evaluate } from './evaluate.mjs'; 4import { errorLogger, logger } from './logger.mjs'; 5import { 6 setCpsFunc, 7 setIsStarted, 8 setPattern as exposeSchedulerPattern, 9 setTime, 10 setTriggerFunc, 11} from './schedulerState.mjs'; 12import { evalScope } from './evaluate.mjs'; 13import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; 14import { reset_state } from './impure.mjs'; 15 16export function repl({ 17 defaultOutput, 18 onEvalError, 19 beforeEval, 20 beforeStart, 21 afterEval, 22 getTime, 23 transpiler, 24 onToggle, 25 editPattern, 26 onUpdateState, 27 sync = false, 28 setInterval, 29 clearInterval, 30 id, 31 mondo = false, 32}) { 33 const state = { 34 schedulerError: undefined, 35 evalError: undefined, 36 code: '// LOADING', 37 activeCode: '// LOADING', 38 pattern: undefined, 39 miniLocations: [], 40 widgets: [], 41 sliders: [], 42 pending: false, 43 started: false, 44 }; 45 46 const transpilerOptions = { 47 id, 48 }; 49 50 const updateState = (update) => { 51 Object.assign(state, update); 52 state.isDirty = state.code !== state.activeCode; 53 state.error = state.evalError || state.schedulerError; 54 onUpdateState?.(state); 55 }; 56 57 const schedulerOptions = { 58 onTrigger: getTrigger({ defaultOutput, getTime }), 59 getTime, 60 onToggle: (started) => { 61 updateState({ started }); 62 setIsStarted(started); 63 onToggle?.(started); 64 if (!started) { 65 reset_state(); 66 } 67 }, 68 setInterval, 69 clearInterval, 70 beforeStart, 71 };
NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
74 const scheduler = 75 sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions); 76 setTriggerFunc(schedulerOptions.onTrigger); 77 setCpsFunc(() => scheduler.cps); 78 let pPatterns = {}; 79 let anonymousIndex = 0; 80 let allTransform; 81 let eachTransform;
Block-based evaluation state
Helper function to collect properties from all code blocks (handles both labeled and anonymous blocks)
90 function collectFromBlocks(property) { 91 return Object.entries(codeBlocks).flatMap(([key, block]) => { 92 if (key === '$') { 93 // Anonymous blocks are stored as an array of block objects 94 return Array.isArray(block) ? block.flatMap((b) => b[property] || []) : []; 95 } 96 // Labeled blocks are stored as single block objects 97 return block[property] || []; 98 }); 99 }
Helper function to process a single labeled block
102 function processLabeledBlock(labels, i, code, options, meta) { 103 const label = labels[i]; 104 const nextLabel = labels[i + 1] || { index: code.length, end: code.length }; 105 106 const labelCode = code.slice(label.index, nextLabel.index); 107 const labelRange = [label.index + options.range[0], label.end + options.range[0]];
Calculate the full block range (from label start to next label start)
113 const blockWidgets = (meta?.widgets || []).filter((widget) => { 114 const widgetPos = widget.from ?? widget.index ?? 0; 115 return widgetPos >= blockStart && widgetPos < blockEnd; 116 }); 117 118 const blockSliders = (meta?.sliders || []).filter((slider) => { 119 const sliderPos = slider.from ?? slider.index ?? 0; 120 return sliderPos >= blockStart && sliderPos < blockEnd; 121 }); 122 123 const blockMiniLocations = (meta?.miniLocations || []).filter((loc) => { 124 // const locStart = loc.start ?? loc.from ?? 0; 125 // mini locations can be either [start, end] arrays or objects with start/from 126 const locStart = Array.isArray(loc) ? loc[0] : (loc.start ?? loc.from ?? 0); 127 return locStart >= blockStart && locStart < blockEnd; 128 }); 129 130 handleSingleLabelBlock( 131 label, 132 labelCode, 133 { ...options, range: labelRange }, 134 { widgets: blockWidgets, sliders: blockSliders, miniLocations: blockMiniLocations }, 135 ); 136 }
helper
139 function cleanupConflictingRanges(codeBlocks, currentKey, newRange) { 140 for (const [existingKey, existingBlock] of Object.entries(codeBlocks)) { 141 if (existingKey === currentKey) continue; 142 if (!existingBlock.range) continue; 143 144 const [existingStart, existingEnd] = existingBlock.range; 145 const [newStart, newEnd] = newRange;
If ranges overlap (not just touch), remove the stale block
helper
155 function handleSingleLabelBlock(label, code, options, meta) { 156 // Detect if this block contains a non-inline widget 157 // The activeVisualizer is now provided by the transpiler for all labels 158 const activeVisualizer = label.activeVisualizer || null; 159 160 if (activeVisualizer !== null) { 161 lastActiveVisualizerLabel = label.name; 162 }
Store the entire code block under the label name
165 codeBlocks[label.name] = { 166 code: code, 167 range: options.range, 168 labels: [label.name], 169 miniLocations: meta?.miniLocations || [], 170 widgets: meta?.widgets || [], 171 sliders: meta?.sliders || [], 172 activeVisualizer: activeVisualizer, // Store the widget type if present, null otherwise 173 };
Clean up any blocks with conflicting ranges (including declaration blocks)
helper These blocks return silence but may contain mini notation strings that need highlighting
181 function handleDeclarationBlock(code, options, meta) { 182 const range = options.range || []; 183 if (range.length < 2) return; 184 185 const blockKey = `_decl:${range[0]}:${range[1]}`; 186 187 codeBlocks[blockKey] = { 188 code: code, 189 range: range, 190 labels: [], 191 miniLocations: meta?.miniLocations || [], 192 widgets: meta?.widgets || [], 193 sliders: meta?.sliders || [], 194 activeVisualizer: null, 195 };
Clean up any overlapping declaration blocks
helper to get a patternified pure value out
Helper function to apply pattern transformations (solo, each, all) this should be abstracted more
230 function applyPatternTransforms(pattern) { 231 const allPatterns = Object.values(pPatterns); 232 233 if (allPatterns.length) { 234 let patterns = []; 235 let soloActive = false; 236 for (const [key, value] of Object.entries(pPatterns)) { 237 // handle soloed patterns ex: S$: s("bd!4") 238 const isSolod = key.length > 1 && key.startsWith('S'); 239 if (isSolod && soloActive === false) { 240 // first time we see a soloed pattern, clear existing patterns 241 patterns = []; 242 soloActive = true; 243 } 244 if (!soloActive || (soloActive && isSolod)) { 245 const valWithState = value.withState((state) => state.setControls({ id: key })); 246 patterns.push(valWithState); 247 } 248 } 249 if (eachTransform) { 250 // Explicit lambda so only element (not index and array) are passed 251 patterns = patterns.map((x) => eachTransform(x)); 252 } 253 pattern = stack(...patterns); 254 } else if (eachTransform) { 255 pattern = eachTransform(pattern); 256 } 257 if (allTransforms.length) { 258 for (const transform of allTransforms) { 259 pattern = transform(pattern); 260 } 261 } 262 263 if (!isPattern(pattern)) { 264 pattern = silence; 265 } 266 267 return pattern; 268 }
270 const stop = () => { 271 codeBlocks = {}; 272 blockPatterns.clear(); 273 pPatterns = {}; 274 lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking 275 updateState({ 276 miniLocations: [], 277 widgets: [], 278 sliders: [], 279 }); 280 scheduler.stop(); 281 }; 282 const start = () => scheduler.start(); 283 const pause = () => scheduler.pause(); 284 const toggle = () => scheduler.toggle(); 285 const setCps = (cps) => { 286 scheduler.setCps(unpure(cps)); 287 return silence; 288 };
Changes the global tempo to the given cycles per minute
@name setcpm @tags temporal @alias setCpm @param {number} cpm cycles per minute @example setcpm(140/4) // =140 bpm in 4/4 $: s("bd*4,[- sd]*2").bank('tr707')
TODO - not documented as jsdoc examples as the test framework doesn't simulate enough context for each and all..
308 let allTransforms = []; 309 /** 310 * Applies a function to all the running patterns. Note that the patterns are grouped together into a single `stack` before the function is applied. This is probably what you want, but see `each` for 311 * a version that applies the function to each pattern separately. 312 * 313 * **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `all`. An unlabeled 314 * pattern such as `note("c4")` is not registered and will produce no audio when `all` is present. 315 * Use `$: note("c4")` instead. 316 * ``` 317 * $: sound("bd - cp sd") 318 * $: sound("hh*8") 319 * all(fast("<2 3>")) 320 * ``` 321 * ``` 322 * $: sound("bd - cp sd") 323 * $: sound("hh*8") 324 * all(x => x.pianoroll()) 325 * ``` 326 * 327 * @tags combiners 328 */ 329 const all = function (transform) { 330 allTransforms.push(transform); 331 return silence; 332 }; 333 /** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern. 334 * 335 * **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `each`. An unlabeled 336 * pattern such as `note("c4")` is not registered and will produce no audio when `each` is present. 337 * Use `$: note("c4")` instead. 338 * ``` 339 * $: sound("bd - cp sd") 340 * $: sound("hh*8") 341 * each(fast("<2 3>")) 342 * ``` 343 * @tags combiners 344 */ 345 const each = function (transform) { 346 eachTransform = transform; 347 return silence; 348 };
set pattern methods that use this repl via closure
351 const injectPatternMethods = () => { 352 Pattern.prototype.p = function (id) { 353 if (typeof id === 'string' && (id.startsWith('_') || id.endsWith('_'))) { 354 // allows muting a pattern x with x_ or _x 355 return silence; 356 } 357 if (id.includes('$')) { 358 // allows adding anonymous patterns with $: 359 id = `${id}${anonymousIndex}`; 360 anonymousIndex++; 361 } 362 pPatterns[id] = this; 363 return this; 364 }; 365 Pattern.prototype.q = function (id) { 366 return silence; 367 }; 368 try { 369 for (let i = 1; i < 10; ++i) { 370 Object.defineProperty(Pattern.prototype, `d${i}`, { 371 get() { 372 return this.p(i); 373 }, 374 configurable: true, 375 }); 376 Object.defineProperty(Pattern.prototype, `p${i}`, { 377 get() { 378 return this.p(i); 379 }, 380 configurable: true, 381 }); 382 Pattern.prototype[`q${i}`] = silence; 383 } 384 } catch (err) { 385 console.warn('injectPatternMethods: error:', err); 386 } 387 const cpm = register('cpm', function (cpm, pat) { 388 return pat._fast(cpm / 60 / scheduler.cps); 389 }); 390 return evalScope({ 391 all, 392 each, 393 hush, 394 cpm, 395 setCps, 396 setcps: setCps, 397 setCpm, 398 setcpm: setCpm, 399 }); 400 };
jevstrudel: evaluations run one at a time, and the newest wins. Each waits for the one before it to settle; one superseded while it waits (in beforeEval, e.g. for the first click, or in _evaluate) stops there, without setting its pattern or calling afterEval. Overlapping, two evaluations interleaved their beforeEval/afterEval and whichever set its pattern last played (2026-09-27).
408 let evalLatest = 0; 409 let evalChain = Promise.resolve(); 410 const serialized = (run) => { 411 const id = ++evalLatest; 412 const superseded = () => id !== evalLatest; 413 const result = evalChain.then(() => (superseded() ? undefined : run(superseded))); 414 evalChain = result.catch(() => {}); 415 return result; 416 };
418 const evaluate = async (code, autostart = true) => { 419 if (!code) { 420 throw new Error('no code to evaluate'); 421 } 422 return serialized((superseded) => evaluateNow(code, autostart, superseded)); 423 }; 424 const evaluateNow = async (code, autostart, superseded) => { 425 try { 426 updateState({ code, pending: true }); 427 await injectPatternMethods(); 428 setTime(() => scheduler.now()); // TODO: refactor? 429 await beforeEval?.({ code, blockBased: false }); 430 if (superseded()) return updateState({ pending: false }); 431 allTransforms = []; // reset all transforms 432 433 codeBlocks = {}; 434 hush(); 435 436 if (mondo) { 437 code = `mondolang\`${code}\``; 438 } 439 440 let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions); 441 if (superseded()) return updateState({ pending: false }); 442 443 pattern = applyPatternTransforms(pattern); 444 445 logger(`[eval] code updated`); 446 pattern = await setPattern(pattern, autostart); 447 updateState({ 448 miniLocations: meta?.miniLocations || [], 449 widgets: meta?.widgets || [], 450 sliders: meta?.sliders || [], 451 activeCode: code, 452 pattern, 453 evalError: undefined, 454 schedulerError: undefined, 455 pending: false, 456 }); 457 458 afterEval?.({ code, pattern, meta, range: undefined, widgetRemoved: false }); 459 return pattern; 460 } catch (err) { 461 logger(`[eval] error: ${err.message}`, 'error'); 462 console.error(err); 463 updateState({ evalError: err, pending: false }); 464 onEvalError?.(err); 465 } 466 }; 467 468 const evaluateBlock = async (code, autostart = true, options = {}) => { 469 if (!code) { 470 throw new Error('no code to evaluate'); 471 } 472 return serialized((superseded) => evaluateBlockNow(code, autostart, options, superseded)); 473 }; 474 const evaluateBlockNow = async (code, autostart, options, superseded) => { 475 try { 476 updateState({ code, pending: true }); 477 await injectPatternMethods(); 478 setTime(() => scheduler.now()); // TODO: refactor? 479 await beforeEval?.({ code, blockBased: true }); 480 if (superseded()) return updateState({ pending: false }); 481 allTransforms = []; // reset all transforms 482 483 const transpilerOptionsWithBlock = { 484 ...transpilerOptions, 485 blockBased: true, 486 range: options.range || [], 487 }; 488 489 if (mondo) { 490 code = `mondolang\`${code}\``; 491 } 492 493 let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptionsWithBlock); 494 if (superseded()) return updateState({ pending: false });
Track activeVisualizer cleanup: check if any block's visualizer was removed
497 let widgetRemoved = false;
499 const labels = meta.labels || [];
Check for anonymous labels (labels starting with '$')
502 const hasAnonymousLabel = labels.some((label) => label.name.startsWith('$'));
Store code blocks in dictionary using labels as keys
it's very common for users to write code prefixed with '$' but to modify and override existing patterns, the patterns must be labeled, otherwise we'll have no idea of which pattern is being overridden
(we probably need to update the docs on this) we could easily enable it, but it would confuse a lot of people
517 throw new Error( 518 'anonymous labels disabled for block based evaluation (see https://strudel.cc/blog/#label-notation)', 519 ); 520 } else if (labels.length > 0) { 521 for (let i = 0; i < labels.length; i++) { 522 // processing transpiler output instead of code is simply to avoid 523 // extra regex in detecting whether or not an inline widget has been commented out 524 processLabeledBlock(labels, i, meta.output, options, meta); 525 } 526 } else { 527 // Declaration block (variable/function that returns silence) 528 // Store it so its miniLocations are preserved for highlighting patterns stored in variables 529 handleDeclarationBlock(code, options, meta); 530 }
Track activeVisualizer cleanup: check if any block's visualizer was removed
537 const blocksToUpdate = labels.map((label) => label.name);
this is the hackiest bit
540 for (const [key, block] of Object.entries(codeBlocks)) { 541 if (blocksToUpdate.includes(key)) { 542 // This block was just updated 543 if (block.activeVisualizer !== null) { 544 // Block now has a visualizer, update tracking 545 lastActiveVisualizerLabel = key; 546 } else if (lastActiveVisualizerLabel === key) { 547 // This block lost its visualizer, trigger cleanup 548 widgetRemoved = true; 549 lastActiveVisualizerLabel = null; 550 } 551 } 552 }
554 pPatterns = Object.fromEntries( 555 Object.entries(pPatterns).filter(([key]) => { 556 return Object.keys(codeBlocks).includes(key); 557 }), 558 ); 559 560 pattern = applyPatternTransforms(pattern); 561 562 logger(`[eval] code updated`); 563 pattern = await setPattern(pattern, autostart); 564 updateState({ 565 miniLocations: meta?.miniLocations || [], 566 widgets: meta?.widgets || [], 567 sliders: meta?.sliders || [], 568 activeCode: code, 569 pattern, 570 evalError: undefined, 571 schedulerError: undefined, 572 pending: false, 573 }); 574 575 afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved }); 576 return pattern; 577 } catch (err) { 578 logger(`[eval] error: ${err.message}`, 'error'); 579 console.error(err); 580 updateState({ evalError: err, pending: false }); 581 onEvalError?.(err); 582 } 583 }; 584 585 const setCode = (code) => updateState({ code }); 586 return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state }; 587} 588 589export const getTrigger = 590 ({ getTime, defaultOutput }) => 591 async (hap, deadline, duration, cps, t) => { 592 // ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger) 593 // TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004 594 try { 595 if (!hap.context.onTrigger || !hap.context.dominantTrigger) { 596 await defaultOutput(hap, deadline, duration, cps, t); 597 } 598 if (hap.context.onTrigger) { 599 // call signature of output / onTrigger is different... 600 await hap.context.onTrigger(hap, getTime(), cps, t); 601 } 602 } catch (err) { 603 errorLogger(err, 'getTrigger'); 604 } 605 };