repl.mjsannotatedrepl.mjssource605 lines · 19.6 KB · raw
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

84  let codeBlocks = {};
85  let lastActiveVisualizerLabel = null;
86  // Track which patterns belong to which blocks: { blockRange: [patternKeys] }
87  let blockPatterns = new Map();

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)

110    const blockStart = label.index + options.range[0];
111    const blockEnd = nextLabel.index + options.range[0];
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

148      if (!(newEnd <= existingStart || newStart >= existingEnd)) {
149        delete codeBlocks[existingKey];
150      }
151    }
152  }

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)

176    cleanupConflictingRanges(codeBlocks, label.name, options.range);
177  }

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

198    cleanupConflictingRanges(codeBlocks, blockKey, range);
199  }
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  };

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  }
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?

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')

301  const setCpm = (cpm) => {
302    scheduler.setCps(unpure(cpm) / 60);
303    return silence;
304  };

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

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

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      }
532      meta.miniLocations = collectFromBlocks('miniLocations');
533      meta.widgets = collectFromBlocks('widgets');
534      meta.sliders = collectFromBlocks('sliders');

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  };