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 }; 72 73 // 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; 82 83 // Block-based evaluation state 84 let codeBlocks = {}; 85 let lastActiveVisualizerLabel = null; 86 // Track which patterns belong to which blocks: { blockRange: [patternKeys] } 87 let blockPatterns = new Map(); 88 89 // 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 } 100 101 // 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]]; 108 109 // Calculate the full block range (from label start to next label start) 110 const blockStart = label.index + options.range[0]; 111 const blockEnd = nextLabel.index + options.range[0]; 112 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 } 137 138 // 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; 146 147 // If ranges overlap (not just touch), remove the stale block 148 if (!(newEnd <= existingStart || newStart >= existingEnd)) { 149 delete codeBlocks[existingKey]; 150 } 151 } 152 } 153 154 // 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 } 163 164 // 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 }; 174 175 // Clean up any blocks with conflicting ranges (including declaration blocks) 176 cleanupConflictingRanges(codeBlocks, label.name, options.range); 177 } 178 179 // helper 180 // 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 }; 196 197 // Clean up any overlapping declaration blocks 198 cleanupConflictingRanges(codeBlocks, blockKey, range); 199 } 200 201 const hush = function () { 202 pPatterns = {}; 203 anonymousIndex = 0; 204 allTransform = undefined; 205 eachTransform = undefined; 206 codeBlocks = {}; 207 blockPatterns.clear(); 208 lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking 209 return silence; 210 }; 211 212 // helper to get a patternified pure value out 213 function unpure(pat) { 214 if (pat._Pattern) { 215 return pat.__pure; 216 } 217 return pat; 218 } 219 220 const setPattern = async (pattern, autostart = true) => { 221 pattern = editPattern?.(pattern) || pattern; 222 await scheduler.setPattern(pattern, autostart); 223 exposeSchedulerPattern(pattern); 224 return pattern; 225 }; 226 setTime(() => scheduler.now()); // TODO: refactor? 227 228 // Helper function to apply pattern transformations (solo, each, all) 229 // 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 } 269 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 }; 289 290 /** 291 * Changes the global tempo to the given cycles per minute 292 * 293 * @name setcpm 294 * @tags temporal 295 * @alias setCpm 296 * @param {number} cpm cycles per minute 297 * @example 298 * setcpm(140/4) // =140 bpm in 4/4 299 * $: s("bd*4,[- sd]*2").bank('tr707') 300 */ 301 const setCpm = (cpm) => { 302 scheduler.setCps(unpure(cpm) / 60); 303 return silence; 304 }; 305 306 // TODO - not documented as jsdoc examples as the test framework doesn't simulate enough context for `each` and `all`.. 307 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 }; 349 350 // 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 }; 401 402 // jevstrudel: evaluations run one at a time, and the newest wins. Each 403 // waits for the one before it to settle; one superseded while it waits 404 // (in beforeEval, e.g. for the first click, or in _evaluate) stops there, 405 // without setting its pattern or calling afterEval. Overlapping, two 406 // evaluations interleaved their beforeEval/afterEval and whichever set 407 // 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 }; 417 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 }); 495 496 // Track activeVisualizer cleanup: check if any block's visualizer was removed 497 let widgetRemoved = false; 498 499 const labels = meta.labels || []; 500 501 // Check for anonymous labels (labels starting with '$') 502 const hasAnonymousLabel = labels.some((label) => label.name.startsWith('$')); 503 504 // Store code blocks in dictionary using labels as keys 505 if (hasAnonymousLabel) { 506 // variable/function declarations that don't return patterns are allowed, 507 // but anonymous pattern blocks pose an issue for block-based evaluation 508 // if an anonymous pattern is evaluated multiple times it will just stack and get louder and louder 509 510 // it's very common for users to write code prefixed with '$' 511 // but to modify and override existing patterns, the patterns must be labeled, 512 // otherwise we'll have no idea of which pattern is being overridden 513 514 // (we probably need to update the docs on this) 515 // we could easily enable it, but it would confuse a lot of people 516 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 } 531 532 meta.miniLocations = collectFromBlocks('miniLocations'); 533 meta.widgets = collectFromBlocks('widgets'); 534 meta.sliders = collectFromBlocks('sliders'); 535 536 // Track activeVisualizer cleanup: check if any block's visualizer was removed 537 const blocksToUpdate = labels.map((label) => label.name); 538 539 // 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 } 553 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 };