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