mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-13 22:35:15 -04:00
Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12fac86012 | |||
| cea6301ffd | |||
| 0b69ce21c7 | |||
| 4f05674cb3 | |||
| feab3f5c86 | |||
| 2755a7eebf | |||
| deb69ced9f | |||
| 1d0e2bd0c7 | |||
| b5ef12a2b1 | |||
| e3dd4ab126 | |||
| 751f924a4d | |||
| 6d99a7f399 | |||
| 5921bec5f4 | |||
| b4e171f5b4 | |||
| 90ad12300a | |||
| ffc6d12d87 | |||
| 1334276ec0 | |||
| cbf472fe0a | |||
| 4439599117 | |||
| 32754ef97a | |||
| 67bce82a62 | |||
| 5da3ae8df4 | |||
| eb3818eebb | |||
| e6b824ebff | |||
| c74dd95bd0 | |||
| 897a750f70 | |||
| 81d02cf5ae | |||
| a6dc6bb831 | |||
| 00a3c1acc0 | |||
| 8ff51d4468 | |||
| 2e041e8647 | |||
| 55095ba4e8 | |||
| 530f45a7bb | |||
| 99295275ec | |||
| c2e45eb36c | |||
| 35e32a32e8 | |||
| 8def3f6af6 | |||
| aa3698c4f4 | |||
| 6b525b4819 | |||
| 77ced19144 | |||
| 14cc5d989b | |||
| 47ad4bb171 | |||
| e3ad6b1f0a | |||
| 1e771d6150 | |||
| 3ac4649536 | |||
| 14be1ddf7f | |||
| 207ce9cbec | |||
| 3abcc59874 | |||
| 7fc8f1c5c9 | |||
| 016315afbc | |||
| b3c0d9179f | |||
| 993dc53e92 | |||
| 60345db1bd | |||
| a09349e457 | |||
| 71f538b115 | |||
| 70be560a7a | |||
| 269758ebdb | |||
| 98d613fbdf | |||
| f5e21a220b | |||
| f6e6df78a8 | |||
| 05ae80b0ca | |||
| 3bbaf77b20 | |||
| a9975a2b41 | |||
| 2b3f719484 | |||
| 4051fd5241 | |||
| 55ffb59b9c | |||
| 93cf9fcb6a | |||
| a5b867f1b1 | |||
| d8645e9a71 | |||
| b824ad16ae | |||
| 03b221a2a2 | |||
| 28db6d1fd4 | |||
| dc9cadc979 | |||
| f99b13efc2 | |||
| 4993ec4cbf | |||
| 8e5ca57edd | |||
| 107a3acd3a | |||
| 1d887e81ef | |||
| c96b1c2faf | |||
| 87e723649e | |||
| e35a2000de | |||
| 4b8d80016b | |||
| d2f2c6df02 | |||
| 9bcaaa3ac4 | |||
| 2e7ec9ea27 | |||
| bc9e97b9ac | |||
| 00f0ef56bc | |||
| 44ad05fb20 | |||
| 18cc1e4466 | |||
| 055f009357 | |||
| 2fe1712dae | |||
| 0ef30d5e8d | |||
| 160ef84b47 | |||
| 1a7f464998 | |||
| ee326c709e | |||
| a0291e8a42 | |||
| ecada58edc | |||
| bb7f587025 | |||
| dab1a018e4 | |||
| 0c01a997ac | |||
| b8dc50267e | |||
| 1aaa04ab50 | |||
| 1860d31624 | |||
| 47557cf06f | |||
| 213edfb211 | |||
| 84e59645fd | |||
| 4df19f9ff8 | |||
| ba7721f53c | |||
| fd1e3d248a | |||
| bad81e3850 | |||
| a409149dae | |||
| a6b3b9ef5e | |||
| ec73c48e2e | |||
| 65dd79e374 |
@@ -0,0 +1,51 @@
|
||||
// Block-based evaluation utilities
|
||||
|
||||
export function getBlockRegions(code) {
|
||||
const chars = code.split('');
|
||||
let i = 0,
|
||||
blanks = [],
|
||||
blockStart = 0,
|
||||
regions = [];
|
||||
while (i < chars.length) {
|
||||
const isBlank = chars[i] === '\n';
|
||||
if (isBlank) {
|
||||
blanks.push(i);
|
||||
} else if (chars[i].trim() !== '') {
|
||||
if (blanks.length > 1) {
|
||||
regions.push([blockStart, blanks[0]]);
|
||||
blockStart = i;
|
||||
}
|
||||
blanks = [];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
regions.push([blockStart, blanks.length ? blanks[0] : i]);
|
||||
return regions;
|
||||
}
|
||||
|
||||
export function getBlockAt(code, cursor) {
|
||||
const regions = getBlockRegions(code);
|
||||
for (const [start, end] of regions) {
|
||||
if (cursor >= start && cursor <= end) {
|
||||
return [start, end];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const evalBlock = (strudelMirror) => {
|
||||
const { state } = strudelMirror.editor;
|
||||
const code = state.doc.toString();
|
||||
const cursor = state.selection.main.head;
|
||||
const range = getBlockAt(code, cursor);
|
||||
if (range) {
|
||||
const [a, b] = range;
|
||||
const block = code.slice(a, b);
|
||||
if (block) {
|
||||
// Flash the block being evaluated
|
||||
strudelMirror.flash(200, { from: a, to: b });
|
||||
strudelMirror.repl.evaluateBlock(block, true, { range });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -13,18 +13,18 @@ import {
|
||||
} from '@codemirror/view';
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
import { logger, registerControl, repl } from '@strudel/core';
|
||||
import { cleanupDraw, Drawer } from '@strudel/draw';
|
||||
|
||||
import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw';
|
||||
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
||||
import { basicSetup } from './basicSetup.mjs';
|
||||
import { evalBlock } from './block_utilities.mjs';
|
||||
import { flash, isFlashEnabled } from './flash.mjs';
|
||||
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
|
||||
import { keybindings } from './keybindings.mjs';
|
||||
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
||||
import { jumpToCharacter } from './labelJump.mjs';
|
||||
import { getSliderWidgets, 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';
|
||||
import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs';
|
||||
|
||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||
|
||||
@@ -64,6 +64,7 @@ export const defaultSettings = {
|
||||
isLineWrappingEnabled: false,
|
||||
isTabIndentationEnabled: false,
|
||||
isMultiCursorEnabled: false,
|
||||
isBlockBasedEvalEnabled: false,
|
||||
theme: 'strudelTheme',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 18,
|
||||
@@ -75,7 +76,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
|
||||
});
|
||||
|
||||
// https://codemirror.net/docs/guide/
|
||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) {
|
||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) {
|
||||
const settings = codemirrorSettings.get();
|
||||
const initialSettings = Object.keys(compartments).map((key) =>
|
||||
compartments[key].of(extensions[key](parseBooleans(settings[key]))),
|
||||
@@ -105,11 +106,26 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Ctrl-Enter',
|
||||
run: () => onEvaluate?.(),
|
||||
run: () => {
|
||||
// issue with referencing settings, this works more reliably
|
||||
if (strudelMirror?.isBlockBasedEvalEnabled) {
|
||||
evalBlock(strudelMirror);
|
||||
return true;
|
||||
} else {
|
||||
return onEvaluate?.();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Alt-Enter',
|
||||
run: () => onEvaluate?.(),
|
||||
run: () => {
|
||||
if (strudelMirror?.isBlockBasedEvalEnabled) {
|
||||
evalBlock(strudelMirror);
|
||||
return true;
|
||||
} else {
|
||||
return onEvaluate?.();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Ctrl-.',
|
||||
@@ -171,6 +187,7 @@ export class StrudelMirror {
|
||||
this.onDraw = onDraw || this.draw;
|
||||
this.id = id || s4();
|
||||
this.solo = solo;
|
||||
this.isBlockBasedEvalEnabled = false; // Will be updated via updateSettings()
|
||||
|
||||
this.drawer = new Drawer((haps, time, _, painters) => {
|
||||
const currentFrame = haps.filter((hap) => hap.isActive(time));
|
||||
@@ -201,20 +218,28 @@ export class StrudelMirror {
|
||||
cleanupDraw(true, id);
|
||||
}
|
||||
},
|
||||
beforeEval: async () => {
|
||||
cleanupDraw(true, id);
|
||||
beforeEval: async ({ blockBased } = {}) => {
|
||||
// Only clean up all drawings for full evaluation
|
||||
// Block-based eval should preserve animations (like .scope()) from other blocks
|
||||
if (!blockBased) {
|
||||
cleanupDraw(true, id);
|
||||
}
|
||||
await this.prebaked;
|
||||
await replOptions?.beforeEval?.();
|
||||
},
|
||||
afterEval: (options) => {
|
||||
// remember for when highlighting is toggled on
|
||||
this.miniLocations = options.meta?.miniLocations;
|
||||
this.miniLocations = options.meta?.miniLocations || [];
|
||||
this.widgets = options.meta?.widgets;
|
||||
|
||||
const sliders = this.widgets.filter((w) => w.type === 'slider');
|
||||
updateSliderWidgets(this.editor, sliders);
|
||||
const widgets = this.widgets.filter((w) => w.type !== 'slider');
|
||||
updateWidgets(this.editor, widgets);
|
||||
updateMiniLocations(this.editor, this.miniLocations);
|
||||
// range-aware update for block-based evaluation
|
||||
const range = options.range && options.range.length >= 2 ? options.range : null;
|
||||
|
||||
updateSliderWidgets(this.editor, sliders, range);
|
||||
updateWidgets(this.editor, widgets, range);
|
||||
updateMiniLocations(this.editor, this.miniLocations, range);
|
||||
replOptions?.afterEval?.(options);
|
||||
// if no painters are set (.onPaint was not called), then we only need
|
||||
// the present moment (for highlighting)
|
||||
@@ -222,8 +247,14 @@ export class StrudelMirror {
|
||||
this.drawer.setDrawTime(drawTime);
|
||||
// invalidate drawer after we've set the appropriate drawTime
|
||||
this.drawer.invalidate(this.repl.scheduler);
|
||||
|
||||
// Clean up draw context if a non-inline widget was removed
|
||||
if (options.widgetRemoved) {
|
||||
cleanupDrawContext(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.cleanupDrawContext = () => cleanupDrawContext(id);
|
||||
this.editor = initEditor({
|
||||
root,
|
||||
initialCode,
|
||||
@@ -236,7 +267,9 @@ export class StrudelMirror {
|
||||
onEvaluate: () => this.evaluate(),
|
||||
onStop: () => this.stop(),
|
||||
mondo: replOptions.mondo,
|
||||
strudelMirror: this,
|
||||
});
|
||||
|
||||
const cmEditor = this.root.querySelector('.cm-editor');
|
||||
if (cmEditor) {
|
||||
this.root.style.display = 'block';
|
||||
@@ -306,8 +339,9 @@ export class StrudelMirror {
|
||||
this.flash();
|
||||
await this.repl.evaluate(this.code, autostart);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.repl.scheduler.stop();
|
||||
this.repl.stop();
|
||||
}
|
||||
|
||||
// Listen for global stop requests (e.g., from Vim :q)
|
||||
@@ -326,8 +360,8 @@ export class StrudelMirror {
|
||||
this.evaluate();
|
||||
}
|
||||
}
|
||||
flash(ms) {
|
||||
flash(this.editor, ms);
|
||||
flash(ms, range) {
|
||||
flash(this.editor, ms, range);
|
||||
}
|
||||
highlight(haps, time) {
|
||||
highlightMiniLocations(this.editor, time, haps);
|
||||
@@ -359,6 +393,10 @@ export class StrudelMirror {
|
||||
setLineWrappingEnabled(enabled) {
|
||||
this.reconfigureExtension('isLineWrappingEnabled', enabled);
|
||||
}
|
||||
|
||||
setBlockBasedEvalEnabled(enabled) {
|
||||
this.reconfigureExtension('isBlockBasedEvalEnabled', enabled);
|
||||
}
|
||||
setBracketMatchingEnabled(enabled) {
|
||||
this.reconfigureExtension('isBracketMatchingEnabled', enabled);
|
||||
}
|
||||
@@ -380,6 +418,10 @@ export class StrudelMirror {
|
||||
for (let key in extensions) {
|
||||
this.reconfigureExtension(key, settings[key]);
|
||||
}
|
||||
// Update block-based eval setting on the instance
|
||||
if (settings.isBlockBasedEvalEnabled !== undefined) {
|
||||
this.isBlockBasedEvalEnabled = parseBooleans(settings.isBlockBasedEvalEnabled);
|
||||
}
|
||||
const updated = { ...codemirrorSettings.get(), ...settings };
|
||||
codemirrorSettings.set(updated);
|
||||
}
|
||||
@@ -401,6 +443,16 @@ export class StrudelMirror {
|
||||
};
|
||||
this.editor.dispatch({ changes });
|
||||
}
|
||||
// used for debugging but could serve other purposes
|
||||
getActiveWidgets() {
|
||||
return getActiveWidgets(this.editor);
|
||||
}
|
||||
getSliderWidgets() {
|
||||
return getSliderWidgets(this.editor);
|
||||
}
|
||||
getMiniLocations() {
|
||||
return this.miniLocations;
|
||||
}
|
||||
clear() {
|
||||
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
|
||||
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
|
||||
|
||||
@@ -14,7 +14,8 @@ export const flashField = StateField.define({
|
||||
const mark = Decoration.mark({
|
||||
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` },
|
||||
});
|
||||
flash = Decoration.set([mark.range(0, tr.newDoc.length)]);
|
||||
const range = e.value.range || { from: 0, to: tr.newDoc.length };
|
||||
flash = Decoration.set([mark.range(range.from, range.to)]);
|
||||
} else {
|
||||
flash = Decoration.set([]);
|
||||
}
|
||||
@@ -29,8 +30,9 @@ export const flashField = StateField.define({
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
export const flash = (view, ms = 200) => {
|
||||
view.dispatch({ effects: setFlash.of(true) });
|
||||
export const flash = (view, ms = 200, range) => {
|
||||
const flashData = range ? { range } : true;
|
||||
view.dispatch({ effects: setFlash.of(flashData) });
|
||||
setTimeout(() => {
|
||||
view.dispatch({ effects: setFlash.of(false) });
|
||||
}, ms);
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Decoration, EditorView } from '@codemirror/view';
|
||||
|
||||
export const setMiniLocations = StateEffect.define();
|
||||
export const showMiniLocations = StateEffect.define();
|
||||
export const updateMiniLocations = (view, locations) => {
|
||||
view.dispatch({ effects: setMiniLocations.of(locations) });
|
||||
export const displayMiniLocations = StateEffect.define();
|
||||
export const updateMiniLocations = (view, locations, range = null) => {
|
||||
view.dispatch({ effects: setMiniLocations.of({ locations, range }) });
|
||||
};
|
||||
export const highlightMiniLocations = (view, atTime, haps) => {
|
||||
view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
|
||||
@@ -21,23 +22,54 @@ const miniLocations = StateField.define({
|
||||
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(setMiniLocations)) {
|
||||
// this is called on eval, with the mini locations obtained from the transpiler
|
||||
// codemirror will automatically remap the marks when the document is edited
|
||||
// create a mark for each mini location, adding the range to the spec to find it later
|
||||
const marks = e.value
|
||||
.filter(([from]) => from < tr.newDoc.length)
|
||||
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
|
||||
.map(
|
||||
(range) =>
|
||||
Decoration.mark({
|
||||
id: range.join(':'),
|
||||
// this green is only to verify that the decoration moves when the document is edited
|
||||
// it will be removed later, so the mark is not visible by default
|
||||
attributes: { style: `background-color: #00CA2880` },
|
||||
}).range(...range), // -> Decoration
|
||||
);
|
||||
//block-based eval case
|
||||
if (e.value.range) {
|
||||
const stateMiniLocations = getMiniLocationsFromDecorations(locations);
|
||||
|
||||
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
|
||||
const normalized = e.value.locations
|
||||
.filter(([from]) => from < tr.newDoc.length)
|
||||
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]);
|
||||
|
||||
const newIds = new Set(normalized.map((r) => r.join(':')));
|
||||
|
||||
const marks = normalized.map((range) => {
|
||||
const id = range.join(':');
|
||||
return Decoration.mark({
|
||||
id,
|
||||
// this green is only to verify that the decoration moves when the document is edited
|
||||
// it will be removed later, so the mark is not visible by default
|
||||
attributes: { style: `background-color: #00CA2880` },
|
||||
}).range(...range); // -> Decoration
|
||||
});
|
||||
|
||||
const previousMarks = stateMiniLocations
|
||||
.filter(({ id }) => !newIds.has(id))
|
||||
.map(({ from, to, id }) =>
|
||||
Decoration.mark({
|
||||
id,
|
||||
attributes: { style: `background-color: #00CA2880` },
|
||||
}).range(from, to),
|
||||
);
|
||||
|
||||
locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet<Decoration>
|
||||
} else {
|
||||
// this is called on eval, with the mini locations obtained from the transpiler
|
||||
// codemirror will automatically remap the marks when the document is edited
|
||||
// create a mark for each mini location, adding the range to the spec to find it later
|
||||
const marks = e.value.locations
|
||||
.filter(([from]) => from < tr.newDoc.length)
|
||||
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
|
||||
.map(
|
||||
(range) =>
|
||||
Decoration.mark({
|
||||
id: range.join(':'),
|
||||
// this green is only to verify that the decoration moves when the document is edited
|
||||
// it will be removed later, so the mark is not visible by default
|
||||
attributes: { style: `background-color: #00CA2880` },
|
||||
}).range(...range), // -> Decoration
|
||||
);
|
||||
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +107,83 @@ const visibleMiniLocations = StateField.define({
|
||||
},
|
||||
});
|
||||
|
||||
// // Derive the set of decorations from the miniLocations and visibleLocations
|
||||
const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => {
|
||||
const iterator = state.field(miniLocations).iter();
|
||||
const { haps } = state.field(visibleMiniLocations);
|
||||
const builder = new RangeSetBuilder();
|
||||
const displayMiniLocationsState = StateField.define({
|
||||
create() {
|
||||
return true; // default to showing miniLocations
|
||||
},
|
||||
update(display, tr) {
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(displayMiniLocations)) {
|
||||
display = e.value;
|
||||
}
|
||||
}
|
||||
return display;
|
||||
},
|
||||
});
|
||||
|
||||
// // Derive the set of decorations from the miniLocations and visibleLocations
|
||||
const miniLocationHighlights = EditorView.decorations.compute(
|
||||
[miniLocations, visibleMiniLocations, displayMiniLocationsState],
|
||||
(state) => {
|
||||
// Check if miniLocations display is disabled
|
||||
const shouldDisplay = state.field(displayMiniLocationsState);
|
||||
if (!shouldDisplay) {
|
||||
return Decoration.none; // Return empty decorations if display is disabled
|
||||
}
|
||||
|
||||
const iterator = state.field(miniLocations).iter();
|
||||
const { haps } = state.field(visibleMiniLocations);
|
||||
const builder = new RangeSetBuilder();
|
||||
|
||||
while (iterator.value) {
|
||||
const {
|
||||
from,
|
||||
to,
|
||||
value: {
|
||||
spec: { id },
|
||||
},
|
||||
} = iterator;
|
||||
|
||||
if (haps.has(id)) {
|
||||
const hap = haps.get(id);
|
||||
const color = hap.value?.color ?? 'var(--foreground)';
|
||||
const style = hap.value?.markcss || `outline: solid 2px ${color}`;
|
||||
// Get explicit channels for color values
|
||||
/*
|
||||
const swatch = document.createElement('div');
|
||||
swatch.style.color = color;
|
||||
document.body.appendChild(swatch);
|
||||
let channels = getComputedStyle(swatch)
|
||||
.color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
|
||||
.slice(1)
|
||||
.map((c) => parseFloat(c || 1));
|
||||
document.body.removeChild(swatch);
|
||||
|
||||
// Get percentage of event
|
||||
const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
|
||||
channels[3] *= percent;
|
||||
*/
|
||||
|
||||
builder.add(
|
||||
from,
|
||||
to,
|
||||
Decoration.mark({
|
||||
// attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
|
||||
attributes: { style },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
iterator.next();
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
},
|
||||
);
|
||||
|
||||
const getMiniLocationsFromDecorations = (decorations) => {
|
||||
const iterator = decorations.iter();
|
||||
const miniLocationsArray = [];
|
||||
while (iterator.value) {
|
||||
const {
|
||||
from,
|
||||
@@ -89,50 +192,48 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi
|
||||
spec: { id },
|
||||
},
|
||||
} = iterator;
|
||||
|
||||
if (haps.has(id)) {
|
||||
const hap = haps.get(id);
|
||||
const color = hap.value?.color ?? 'var(--foreground)';
|
||||
const style = hap.value?.markcss || `outline: solid 2px ${color}`;
|
||||
// Get explicit channels for color values
|
||||
/*
|
||||
const swatch = document.createElement('div');
|
||||
swatch.style.color = color;
|
||||
document.body.appendChild(swatch);
|
||||
let channels = getComputedStyle(swatch)
|
||||
.color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
|
||||
.slice(1)
|
||||
.map((c) => parseFloat(c || 1));
|
||||
document.body.removeChild(swatch);
|
||||
|
||||
// Get percentage of event
|
||||
const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
|
||||
channels[3] *= percent;
|
||||
*/
|
||||
|
||||
builder.add(
|
||||
from,
|
||||
to,
|
||||
Decoration.mark({
|
||||
// attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
|
||||
attributes: { style },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
miniLocationsArray.push({
|
||||
from,
|
||||
to,
|
||||
id,
|
||||
});
|
||||
iterator.next();
|
||||
}
|
||||
return miniLocationsArray;
|
||||
};
|
||||
|
||||
return builder.finish();
|
||||
});
|
||||
export const getMiniLocations = (state) => {
|
||||
const decorations = state.field(miniLocations);
|
||||
return getMiniLocationsFromDecorations(decorations);
|
||||
};
|
||||
|
||||
export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights];
|
||||
export const getActiveMiniLocations = (state) => {
|
||||
const miniLocations = getMiniLocations(state);
|
||||
const { haps } = state.field(visibleMiniLocations);
|
||||
|
||||
const activeMiniLocations = miniLocations.filter((location) => haps.has(location.id));
|
||||
return activeMiniLocations;
|
||||
};
|
||||
|
||||
export const highlightExtension = [
|
||||
miniLocations,
|
||||
visibleMiniLocations,
|
||||
displayMiniLocationsState,
|
||||
miniLocationHighlights,
|
||||
];
|
||||
|
||||
export const isPatternHighlightingEnabled = (on, config) => {
|
||||
on &&
|
||||
config &&
|
||||
setTimeout(() => {
|
||||
updateMiniLocations(config.editor, config.miniLocations);
|
||||
}, 100);
|
||||
return on ? Prec.highest(highlightExtension) : [];
|
||||
// NOTE:
|
||||
// Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed.
|
||||
// This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly.
|
||||
// The setTimeout was also removed because it conflicted with the range-specific updates required by
|
||||
// block based evaluation.
|
||||
// Not sure if this is the best approach, but for block based eval I can't think of a better way to do it.
|
||||
|
||||
if (config) {
|
||||
// Toggle the display state for miniLocations
|
||||
config.editor.dispatch({ effects: displayMiniLocations.of(on) });
|
||||
}
|
||||
|
||||
return Prec.highest(highlightExtension);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { emacs } from '@replit/codemirror-emacs';
|
||||
import { vim, Vim } from '@replit/codemirror-vim';
|
||||
// import { vim } from './vim_test.mjs';
|
||||
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
||||
import { helix, commands } from 'codemirror-helix';
|
||||
import { logger } from '@strudel/core';
|
||||
|
||||
const vscodePlugin = ViewPlugin.fromClass(
|
||||
@@ -20,6 +21,70 @@ const vscodePlugin = ViewPlugin.fromClass(
|
||||
);
|
||||
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
|
||||
|
||||
function replEval(view) {
|
||||
try {
|
||||
// Dispatch a dedicated evaluate event first
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-evaluate event', e);
|
||||
}
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
// Try Ctrl+Enter first if not handled by custom event
|
||||
const ctrlEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(ctrlEnter);
|
||||
// If not handled (no handler called preventDefault), try Alt+Enter as
|
||||
// fallback
|
||||
if (!ctrlEnter.defaultPrevented) {
|
||||
const altEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altEnter);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl evaluation event', e);
|
||||
}
|
||||
}
|
||||
|
||||
function replStop(view) {
|
||||
try {
|
||||
// First try dispatching our custom stop event, then fallback to Alt+.
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false;
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-stop event', e);
|
||||
}
|
||||
if (!handled) {
|
||||
const altDot = new KeyboardEvent('keydown', {
|
||||
key: '.',
|
||||
code: 'Period',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altDot);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl stop event', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom
|
||||
// event 'repl-evaluate' that the editor listens for, and also simulate
|
||||
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
|
||||
@@ -47,29 +112,8 @@ try {
|
||||
|
||||
// :q to pause/stop
|
||||
Vim.defineEx('quit', 'q', (cm) => {
|
||||
try {
|
||||
const view = cm?.view || cm;
|
||||
// First try dispatching our custom stop event, then fallback to Alt+.
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false;
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-stop event', e);
|
||||
}
|
||||
if (!handled) {
|
||||
const altDot = new KeyboardEvent('keydown', {
|
||||
key: '.',
|
||||
code: 'Period',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altDot);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching :q stop event', e);
|
||||
}
|
||||
const view = cm?.view || cm;
|
||||
replStop(view);
|
||||
});
|
||||
|
||||
// :w to evaluate
|
||||
@@ -83,38 +127,7 @@ try {
|
||||
} catch (e) {
|
||||
console.error('Error logging Vim :w evaluation', e);
|
||||
}
|
||||
// Dispatch a dedicated evaluate event first
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-evaluate event', e);
|
||||
}
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
// Try Ctrl+Enter first if not handled by custom event
|
||||
const ctrlEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(ctrlEnter);
|
||||
// If not handled (no handler called preventDefault), try Alt+Enter as
|
||||
// fallback
|
||||
if (!ctrlEnter.defaultPrevented) {
|
||||
const altEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altEnter);
|
||||
}
|
||||
replEval(view);
|
||||
} catch (e) {
|
||||
console.error('Error dispatching :w evaluation event', e);
|
||||
}
|
||||
@@ -124,11 +137,49 @@ try {
|
||||
console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e);
|
||||
}
|
||||
|
||||
// Map Helix :w to trigger the same action as evaluation. We dispatch a custom
|
||||
// event 'repl-evaluate' that the editor listens for, and also simulate
|
||||
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
|
||||
// appears in the Console panel.
|
||||
const helixCommands = commands.of([
|
||||
{
|
||||
// :w to evaluate
|
||||
name: 'write',
|
||||
aliases: ['w'],
|
||||
help: 'Repl-eval',
|
||||
handler(view, args) {
|
||||
try {
|
||||
view?.focus?.(); // Let the app know this came from Helix :w
|
||||
logger('[helix] :w — evaluating code');
|
||||
replEval(view);
|
||||
} catch (e) {
|
||||
console.error('Error dispatching helix :w evaluation event', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// :q to pause/stop
|
||||
name: 'quit',
|
||||
aliases: ['q'],
|
||||
help: 'Repl-stop',
|
||||
handler(view, args) {
|
||||
try {
|
||||
view?.focus?.(); // Let the app know this came from Helix :q
|
||||
logger('[helix] :q — stopping repl');
|
||||
replStop(view);
|
||||
} catch (e) {
|
||||
console.error('Error dispatching helix :q stop event', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const keymaps = {
|
||||
vim,
|
||||
emacs,
|
||||
codemirror: () => keymap.of(defaultKeymap),
|
||||
vscode: vscodeExtension,
|
||||
helix: () => [helix(), helixCommands],
|
||||
};
|
||||
|
||||
export { Vim } from '@replit/codemirror-vim';
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"codemirror-helix": "^0.5.0",
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/draw": "workspace:*",
|
||||
"@strudel/tonal": "workspace:*",
|
||||
|
||||
+106
-17
@@ -2,11 +2,11 @@ import { ref, pure } from '@strudel/core';
|
||||
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
|
||||
import { StateEffect } from '@codemirror/state';
|
||||
|
||||
// Global state storage for all widget types
|
||||
export let sliderValues = {};
|
||||
const getSliderID = (from) => `slider_${from}`;
|
||||
|
||||
export class SliderWidget extends WidgetType {
|
||||
constructor(value, min, max, from, to, step, view) {
|
||||
constructor(value, min, max, from, to, step, view, id) {
|
||||
super();
|
||||
this.value = value;
|
||||
this.min = min;
|
||||
@@ -16,10 +16,21 @@ export class SliderWidget extends WidgetType {
|
||||
this.to = to;
|
||||
this.step = step;
|
||||
this.view = view;
|
||||
this.id = id || `${from}:${to}`; // Range-based ID for stability
|
||||
}
|
||||
|
||||
eq() {
|
||||
return false;
|
||||
eq(other) {
|
||||
if (!(other instanceof SliderWidget)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.id === other.id &&
|
||||
this.from === other.from &&
|
||||
this.to === other.to &&
|
||||
this.value === other.value &&
|
||||
this.min === other.min &&
|
||||
this.max === other.max
|
||||
);
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
@@ -38,6 +49,7 @@ export class SliderWidget extends WidgetType {
|
||||
slider.from = this.from;
|
||||
slider.originalFrom = this.originalFrom;
|
||||
slider.to = this.to;
|
||||
slider.id = this.id; // Store range-based ID in DOM element
|
||||
slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
|
||||
this.slider = slider;
|
||||
slider.addEventListener('input', (e) => {
|
||||
@@ -49,7 +61,7 @@ export class SliderWidget extends WidgetType {
|
||||
slider.originalValue = insert;
|
||||
slider.value = insert;
|
||||
this.view.dispatch({ changes: change });
|
||||
const id = getSliderID(slider.originalFrom); // matches id generated in transpiler
|
||||
const id = slider.id; // Use range-based ID
|
||||
window.postMessage({ type: 'cm-slider', value: Number(next), id });
|
||||
});
|
||||
return wrap;
|
||||
@@ -62,19 +74,60 @@ export class SliderWidget extends WidgetType {
|
||||
|
||||
export const setSliderWidgets = StateEffect.define();
|
||||
|
||||
export const updateSliderWidgets = (view, widgets) => {
|
||||
view.dispatch({ effects: setSliderWidgets.of(widgets) });
|
||||
export const setSliderWidgetsInRange = StateEffect.define();
|
||||
|
||||
export const updateSliderWidgets = (view, widgets, range = null) => {
|
||||
if (range) {
|
||||
// range argument passed for block-based evaluation
|
||||
view.dispatch({ effects: setSliderWidgetsInRange.of({ widgets, range }) });
|
||||
} else {
|
||||
view.dispatch({ effects: setSliderWidgets.of(widgets) });
|
||||
}
|
||||
};
|
||||
|
||||
function getSliders(widgetConfigs, view) {
|
||||
return widgetConfigs
|
||||
.filter((w) => w.type === 'slider')
|
||||
.map(({ from, to, value, min, max, step }) => {
|
||||
return Decoration.widget({
|
||||
widget: new SliderWidget(value, min, max, from, to, step, view),
|
||||
side: 0,
|
||||
}).range(from /* , to */);
|
||||
});
|
||||
return (
|
||||
widgetConfigs
|
||||
.filter((w) => w.type === 'slider')
|
||||
// Deduplicate sliders that might appear multiple times (e.g., during paste operations)
|
||||
.filter((slider, index, self) => index === self.findIndex((s) => s.from === slider.from && s.to === slider.to))
|
||||
.sort((a, b) => a.from - b.from)
|
||||
.map(({ from, to, value, min, max, step, id }) => {
|
||||
return Decoration.widget({
|
||||
widget: new SliderWidget(value, min, max, from, to, step, view, id),
|
||||
side: 0,
|
||||
}).range(from /* , to */);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function getSliderWidgets(view) {
|
||||
if (!view || !view.state) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sliderPluginInstance = view.plugin(sliderPlugin);
|
||||
if (!sliderPluginInstance || !sliderPluginInstance.decorations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sliderWidgets = [];
|
||||
|
||||
sliderPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||
if (decoration.widget instanceof SliderWidget) {
|
||||
sliderWidgets.push({
|
||||
type: 'slider',
|
||||
from: decoration.widget.from,
|
||||
to: decoration.widget.to,
|
||||
value: decoration.widget.value,
|
||||
min: decoration.widget.min,
|
||||
max: decoration.widget.max,
|
||||
step: decoration.widget.step,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return sliderWidgets;
|
||||
}
|
||||
|
||||
export const sliderPlugin = ViewPlugin.fromClass(
|
||||
@@ -101,7 +154,42 @@ export const sliderPlugin = ViewPlugin.fromClass(
|
||||
}
|
||||
}
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(setSliderWidgets)) {
|
||||
if (e.is(setSliderWidgetsInRange)) {
|
||||
// Block-aware slider update logic
|
||||
const { widgets, range } = e.value;
|
||||
const [rangeStart, rangeEnd] = range;
|
||||
|
||||
// Get existing slider widgets that should be preserved
|
||||
const existingSliders = [];
|
||||
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
|
||||
if (decoration.widget instanceof SliderWidget) {
|
||||
// Preserve sliders outside the evaluation range
|
||||
// Use strict > for rangeEnd because when code is deleted, slider positions
|
||||
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
|
||||
if (from < rangeStart || from > rangeEnd) {
|
||||
existingSliders.push({
|
||||
from,
|
||||
to,
|
||||
value: decoration.widget.value,
|
||||
min: decoration.widget.min,
|
||||
max: decoration.widget.max,
|
||||
step: decoration.widget.step,
|
||||
id: decoration.widget.id || `${from}:${to}`,
|
||||
type: 'slider',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Merge preserved sliders with new widgets
|
||||
const mergedWidgets = [...existingSliders, ...widgets]
|
||||
.filter(
|
||||
(slider, index, self) => index === self.findIndex((s) => s.type === 'slider' && s.id === slider.id),
|
||||
)
|
||||
.sort((a, b) => a.from - b.from);
|
||||
|
||||
this.decorations = Decoration.set(getSliders(mergedWidgets, update.view));
|
||||
} else if (e.is(setSliderWidgets)) {
|
||||
this.decorations = Decoration.set(getSliders(e.value, update.view));
|
||||
}
|
||||
}
|
||||
@@ -132,6 +220,7 @@ export let sliderWithID = (id, value, min, max) => {
|
||||
sliderValues[id] = value; // sync state at eval time (code -> state)
|
||||
return ref(() => sliderValues[id]); // use state at query time
|
||||
};
|
||||
|
||||
// update state when sliders are moved
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', (e) => {
|
||||
@@ -140,7 +229,7 @@ if (typeof window !== 'undefined') {
|
||||
// update state when slider is moved
|
||||
sliderValues[e.data.id] = e.data.value;
|
||||
} else {
|
||||
console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
||||
console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+194
-46
@@ -1,55 +1,111 @@
|
||||
import { StateEffect, StateField } from '@codemirror/state';
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
|
||||
import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
|
||||
import { Pattern } from '@strudel/core';
|
||||
|
||||
export const addWidget = StateEffect.define({
|
||||
map: ({ from, to }, change) => {
|
||||
return { from: change.mapPos(from), to: change.mapPos(to) };
|
||||
},
|
||||
});
|
||||
export const setWidgets = StateEffect.define();
|
||||
|
||||
export const updateWidgets = (view, widgets) => {
|
||||
view.dispatch({ effects: addWidget.of(widgets) });
|
||||
export const setWidgetsInRange = StateEffect.define();
|
||||
|
||||
export const updateWidgets = (view, widgets, range = null) => {
|
||||
if (range) {
|
||||
// range argument passed for block-based evaluation
|
||||
view.dispatch({ effects: setWidgetsInRange.of({ widgets, range }) });
|
||||
} else {
|
||||
view.dispatch({ effects: setWidgets.of(widgets) });
|
||||
}
|
||||
};
|
||||
|
||||
function getWidgets(widgetConfigs) {
|
||||
return (
|
||||
widgetConfigs
|
||||
// codemirror throws an error if we don't sort
|
||||
.sort((a, b) => a.to - b.to)
|
||||
.map((widgetConfig) => {
|
||||
function getWidgets(widgetConfigs, view) {
|
||||
const filtered = widgetConfigs
|
||||
// Filter to widget configs only (exclude sliders)
|
||||
.filter((w) => w && w.type && w.type !== 'slider')
|
||||
// Deduplicate widgets by ID, matching slider behavior for stable widget identity
|
||||
.filter((widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id));
|
||||
|
||||
// Filter out widgets whose range is encompassed by another widget
|
||||
// const nonEncompassed = filterEncompassedWidgets(filtered);
|
||||
|
||||
return filtered
|
||||
.sort((a, b) => (a.to || 0) - (b.to || 0))
|
||||
.map((widgetConfig) => {
|
||||
try {
|
||||
return Decoration.widget({
|
||||
widget: new BlockWidget(widgetConfig),
|
||||
widget: new BlockWidget(widgetConfig, view),
|
||||
side: 0,
|
||||
block: true,
|
||||
}).range(widgetConfig.to);
|
||||
})
|
||||
);
|
||||
}).range(widgetConfig.to || widgetConfig.from || 0);
|
||||
} catch (error) {
|
||||
console.error('error creating widget', error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean); // Remove any null results from failed creations
|
||||
}
|
||||
|
||||
const widgetField = StateField.define(
|
||||
/* <DecorationSet> */ {
|
||||
create() {
|
||||
return Decoration.none;
|
||||
},
|
||||
update(widgets, tr) {
|
||||
widgets = widgets.map(tr.changes);
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(addWidget)) {
|
||||
try {
|
||||
widgets = widgets.update({
|
||||
filter: () => false,
|
||||
add: getWidgets(e.value),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('err', error);
|
||||
export const widgetPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations; //: DecorationSet
|
||||
|
||||
constructor(view /* : EditorView */) {
|
||||
this.decorations = Decoration.set([]);
|
||||
}
|
||||
|
||||
update(update /* : ViewUpdate */) {
|
||||
update.transactions.forEach((tr) => {
|
||||
if (tr.docChanged) {
|
||||
this.decorations = this.decorations.map(tr.changes);
|
||||
const iterator = this.decorations.iter();
|
||||
// Apply changes to iterator.from and iterator.to if docChanged
|
||||
while (iterator.value) {
|
||||
// when the widgets are moved, we need to tell the dom node the current position
|
||||
// this is important because the widget functions have to work with the dom node
|
||||
if (iterator.value?.widget instanceof BlockWidget) {
|
||||
iterator.value.widget.from = iterator.from;
|
||||
iterator.value.widget.to = iterator.to;
|
||||
}
|
||||
iterator.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
return widgets;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(setWidgetsInRange)) {
|
||||
// Block-aware widget update logic
|
||||
const { widgets, range } = e.value;
|
||||
const [rangeStart, rangeEnd] = range;
|
||||
|
||||
// Get existing widget widgets that should be preserved
|
||||
const existingWidgets = [];
|
||||
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
|
||||
if (decoration.widget instanceof BlockWidget) {
|
||||
// Preserve widgets outside the evaluation range
|
||||
// Use strict > for rangeEnd because when code is deleted, widget positions
|
||||
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
|
||||
if (from < rangeStart || from > rangeEnd) {
|
||||
existingWidgets.push({
|
||||
from: decoration.widget.from,
|
||||
to: decoration.widget.to,
|
||||
type: decoration.widget.type,
|
||||
index: decoration.widget.index,
|
||||
id: decoration.widget.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Merge preserved widgets with new widgets, deduplicating by ID
|
||||
const mergedWidgets = [...existingWidgets, ...widgets].filter(
|
||||
(widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id),
|
||||
);
|
||||
|
||||
this.decorations = Decoration.set(getWidgets(mergedWidgets, update.view));
|
||||
} else if (e.is(setWidgets)) {
|
||||
this.decorations = Decoration.set(getWidgets(e.value, update.view));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -60,24 +116,116 @@ export function setWidget(id, el) {
|
||||
}
|
||||
|
||||
export class BlockWidget extends WidgetType {
|
||||
constructor(widgetConfig) {
|
||||
constructor(widgetConfig, view) {
|
||||
super();
|
||||
|
||||
// Graceful handling of invalid configs like sliders
|
||||
if (!widgetConfig || typeof widgetConfig !== 'object') {
|
||||
widgetConfig = { type: 'unknown', from: 0, to: 0 };
|
||||
}
|
||||
|
||||
this.from = widgetConfig.from || 0;
|
||||
this.originalFrom = widgetConfig.from || 0;
|
||||
this.to = widgetConfig.to || this.from;
|
||||
this.originalTo = widgetConfig.to || this.from;
|
||||
this.type = widgetConfig.type || 'unknown';
|
||||
this.index = widgetConfig.index || 0;
|
||||
this.view = view;
|
||||
|
||||
// Use range-based ID for stability, similar to sliders
|
||||
this.id = widgetConfig.id || getWidgetID?.(widgetConfig);
|
||||
this.widgetConfig = widgetConfig;
|
||||
}
|
||||
eq() {
|
||||
return true;
|
||||
|
||||
eq(other) {
|
||||
if (!(other instanceof BlockWidget)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.id === other.id &&
|
||||
this.from === other.from &&
|
||||
this.to === other.to &&
|
||||
this.type === other.type &&
|
||||
this.index === other.index
|
||||
);
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const id = getWidgetID(this.widgetConfig);
|
||||
const el = widgetElements[id];
|
||||
return el;
|
||||
let wrap = document.createElement('span');
|
||||
wrap.setAttribute('aria-hidden', 'true');
|
||||
wrap.className = 'cm-widget-container';
|
||||
|
||||
let el = widgetElements[this.id];
|
||||
if (el) {
|
||||
// Ensure the element has the correct ID
|
||||
el.id = this.id;
|
||||
wrap.appendChild(el);
|
||||
} else {
|
||||
// Create a placeholder element if the widget element doesn't exist
|
||||
// This prevents CodeMirror errors when widget is missing
|
||||
const placeholder = document.createElement('span');
|
||||
placeholder.setAttribute('aria-hidden', 'true');
|
||||
placeholder.className = 'cm-widget-placeholder';
|
||||
placeholder.style.cssText = 'display: none;'; // Hide placeholder
|
||||
placeholder.id = this.id;
|
||||
wrap.appendChild(placeholder);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
ignoreEvent(e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const widgetPlugin = [widgetField];
|
||||
export function getActiveWidgets(view) {
|
||||
if (!view || !view.state) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const widgetPluginInstance = view.plugin(widgetPlugin);
|
||||
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const widgets = [];
|
||||
|
||||
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||
if (decoration.widget instanceof BlockWidget) {
|
||||
widgets.push({
|
||||
type: decoration.widget.type,
|
||||
from: decoration.widget.from,
|
||||
to: decoration.widget.to,
|
||||
index: decoration.widget.index,
|
||||
id: decoration.widget.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
export function getAllWidgetIds(view) {
|
||||
if (!view || !view.state) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const widgetPluginInstance = view.plugin(widgetPlugin);
|
||||
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const widgetIds = [];
|
||||
|
||||
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||
if (decoration.widget instanceof BlockWidget) {
|
||||
widgetIds.push(decoration.widget.id);
|
||||
}
|
||||
});
|
||||
|
||||
return widgetIds;
|
||||
}
|
||||
|
||||
// widget implementer API to create a new widget type
|
||||
export function registerWidget(type, fn) {
|
||||
|
||||
+247
-226
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,34 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
export const strudelScope = {};
|
||||
// Make strudelScope available globally so transpiled code can access it
|
||||
globalThis.strudelScope = strudelScope;
|
||||
|
||||
// Track user-defined keys (from block-based eval) so we can clear them without removing strudel functions
|
||||
export const userDefinedKeys = new Set();
|
||||
globalThis.userDefinedKeys = userDefinedKeys;
|
||||
|
||||
/**
|
||||
* Clears all user-defined variables and functions from the scope.
|
||||
* This removes variables created during block-based evaluation.
|
||||
* @name clearScope
|
||||
* @example
|
||||
* // After defining variables in blocks:
|
||||
* // let myVar = 5
|
||||
* // function myFunc() { return 10; }
|
||||
* clearScope() // removes myVar and myFunc from scope
|
||||
*/
|
||||
export const clearScope = () => {
|
||||
for (const key of userDefinedKeys) {
|
||||
delete strudelScope[key];
|
||||
delete globalThis[key];
|
||||
}
|
||||
userDefinedKeys.clear();
|
||||
// Return silence if available (for use in pattern expressions), otherwise undefined
|
||||
return globalThis.silence;
|
||||
};
|
||||
// Make clearScope available globally
|
||||
globalThis.clearScope = clearScope;
|
||||
|
||||
export const evalScope = async (...args) => {
|
||||
const results = await Promise.allSettled(args);
|
||||
|
||||
+55
-59
@@ -818,7 +818,6 @@ export class Pattern {
|
||||
* @name layer
|
||||
* @tags combiners
|
||||
* @memberof Pattern
|
||||
* @synonyms apply
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* "<0 2 4 6 ~ 4 ~ 2 0!3 ~!5>*8"
|
||||
@@ -1265,6 +1264,7 @@ const ALIGNMENT_KEYS = ALIGNMENTS.map((how) => how.toLowerCase());
|
||||
* 'add.mix', 'set.squeeze', etc.
|
||||
*
|
||||
* @param {string} method Default join method to use. Options: 'in', 'out', 'mix', 'squeeze', 'squeezeout', 'reset', 'restart', 'poly'
|
||||
* @tags combiners
|
||||
* @example
|
||||
* setDefaultJoin('mix') // also try 'in', 'out', 'squeeze', etc.
|
||||
* s("saw").vel("1 0.5").note("F A C E").delay("0 0.2 0.3")
|
||||
@@ -1564,10 +1564,10 @@ export function arrange(...sections) {
|
||||
* @tags combiners
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
seqPLoop([0, 2, "bd(3,8)"],
|
||||
[1, 3, "cp(3,8)"]
|
||||
)
|
||||
.sound()
|
||||
seqPLoop(
|
||||
[0, 2, "bd(3,8)"],
|
||||
[1, 3, "cp(3,8)"]
|
||||
).sound()
|
||||
*/
|
||||
export function seqPLoop(...parts) {
|
||||
let total = Fraction(0);
|
||||
@@ -1610,7 +1610,7 @@ export function sequence(...pats) {
|
||||
|
||||
/** Like **cat**, but the items are crammed into one cycle.
|
||||
* @tags combiners
|
||||
* @synonyms seq, fastcat
|
||||
* @synonyms fastcat
|
||||
* @example
|
||||
* seq("e5", "b4", ["d5", "c5"]).note()
|
||||
* // "e5 b4 [d5 c5]".note()
|
||||
@@ -2137,13 +2137,12 @@ export const { firstOf, every } = register(['firstOf', 'every'], function (n, fu
|
||||
});
|
||||
|
||||
/**
|
||||
* Like layer, but with a single function:
|
||||
* @tags temporal
|
||||
* Applies the given function to the pattern. Like layer, but with a single function:
|
||||
* @tags combiners
|
||||
* @name apply
|
||||
* @example
|
||||
* "<c3 eb3 g3>".scale('C minor').apply(scaleTranspose("0,2,4")).note()
|
||||
*/
|
||||
// TODO: remove or dedupe with layer?
|
||||
export const apply = register('apply', function (func, pat) {
|
||||
return func(pat);
|
||||
});
|
||||
@@ -2500,7 +2499,7 @@ export const { juxBy, juxby } = register(['juxBy', 'juxby'], function (by, func,
|
||||
|
||||
/**
|
||||
* The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel.
|
||||
* @tags temporal
|
||||
* @tags temporal, superdough
|
||||
* @example
|
||||
* s("bd lt [~ ht] mt cp ~ bd hh").jux(rev)
|
||||
* @example
|
||||
@@ -2514,7 +2513,7 @@ export const jux = register('jux', function (func, pat) {
|
||||
|
||||
/**
|
||||
* Superimpose and offset multiple times, applying the given function each time.
|
||||
* @tags temporal
|
||||
* @tags temporal, functional
|
||||
* @name echoWith
|
||||
* @synonyms echowith, stutWith, stutwith
|
||||
* @param {number} times how many times to repeat
|
||||
@@ -2688,7 +2687,7 @@ export const { repeatCycles } = register(
|
||||
|
||||
/**
|
||||
* Divides a pattern into a given number of parts, then cycles through those parts in turn, applying the given function to each part in turn (one part per cycle).
|
||||
* @tags temporal
|
||||
* @tags temporal, functional
|
||||
* @name chunk
|
||||
* @synonyms slowChunk, slowchunk
|
||||
* @memberof Pattern
|
||||
@@ -2852,7 +2851,7 @@ Pattern.prototype.tag = function (tag) {
|
||||
/**
|
||||
* Filters haps using the given function
|
||||
* @name filter
|
||||
* @tags temporal
|
||||
* @tags temporal, functional
|
||||
* @param {Function} test function to test Hap
|
||||
* @example
|
||||
* s("hh!7 oh").filter(hap => hap.value.s === 'hh')
|
||||
@@ -2862,7 +2861,7 @@ export const filter = register('filter', (test, pat) => pat.withHaps((haps) => h
|
||||
/**
|
||||
* Filters haps by their begin time
|
||||
* @name filterWhen
|
||||
* @tags temporal
|
||||
* @tags temporal, functional
|
||||
* @param {Function} test function to test Hap.whole.begin
|
||||
* @example
|
||||
* oneCycle: s("bd*4").filterWhen((t) => t < 1)
|
||||
@@ -2872,7 +2871,7 @@ export const filterWhen = register('filterWhen', (test, pat) => pat.filter((h) =
|
||||
/**
|
||||
* Use within to apply a function to only a part of a pattern.
|
||||
* @name within
|
||||
* @tags temporal
|
||||
* @tags temporal, functional
|
||||
* @param {number} start start within cycle (0 - 1)
|
||||
* @param {number} end end within cycle (0 - 1). Must be > start
|
||||
* @param {Function} func function to be applied to the sub-pattern
|
||||
@@ -2947,7 +2946,7 @@ export function _match(span, hap_p) {
|
||||
* *Experimental*
|
||||
*
|
||||
* Speeds a pattern up or down, to fit to the given number of steps per cycle.
|
||||
* @tags temporal
|
||||
* @tags stepwise
|
||||
* @example
|
||||
* sound("bd sd cp").pace(4)
|
||||
* // The same as sound("{bd sd cp}%4") or sound("<bd sd cp>*4")
|
||||
@@ -2989,7 +2988,7 @@ export function _polymeterListSteps(steps, ...args) {
|
||||
* *Experimental*
|
||||
*
|
||||
* Aligns the steps of the patterns, creating polymeters. The patterns are repeated until they all fit the cycle. For example, in the below the first pattern is repeated twice, and the second is repeated three times, to fit the lowest common multiple of six steps.
|
||||
* @tags temporal
|
||||
* @tags stepwise
|
||||
* @synonyms pm
|
||||
* @example
|
||||
* // The same as note("{c eb g, c2 g2}%6")
|
||||
@@ -3022,7 +3021,7 @@ export function polymeter(...args) {
|
||||
* The steps can either be inferred from the pattern, or provided as a [length, pattern] pair.
|
||||
* Has the alias `timecat`.
|
||||
* @name stepcat
|
||||
* @tags combiners
|
||||
* @tags stepwise
|
||||
* @synonyms timeCat, timecat
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
@@ -3080,7 +3079,7 @@ export function stepcat(...timepats) {
|
||||
* Concatenates patterns stepwise, according to an inferred 'steps per cycle'.
|
||||
* Similar to `stepcat`, but if an argument is a list, the whole pattern will alternate between the elements in the list.
|
||||
*
|
||||
* @tags combiners
|
||||
* @tags stepwise
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* stepalt(["bd cp", "mt"], "bd").sound()
|
||||
@@ -3107,7 +3106,7 @@ export function stepalt(...groups) {
|
||||
*
|
||||
* Takes the given number of steps from a pattern (dropping the rest).
|
||||
* A positive number will take steps from the start of a pattern, and a negative number from the end.
|
||||
* @tags temporal
|
||||
* @tags stepwise
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* "bd cp ht mt".take("2").sound()
|
||||
@@ -3152,7 +3151,7 @@ export const take = stepRegister('take', function (i, pat) {
|
||||
*
|
||||
* Drops the given number of steps from a pattern.
|
||||
* A positive number will drop steps from the start of a pattern, and a negative number from the end.
|
||||
* @tags temporal
|
||||
* @tags stepwise
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* "tha dhi thom nam".drop("1").sound().bank("mridangam")
|
||||
@@ -3181,7 +3180,7 @@ export const drop = stepRegister('drop', function (i, pat) {
|
||||
* `extend` is similar to `fast` in that it increases its density, but it also increases the step count
|
||||
* accordingly. So `stepcat("a b".extend(2), "c d")` would be the same as `"a b a b c d"`, whereas
|
||||
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
|
||||
* @tags temporal
|
||||
* @tags stepwise
|
||||
* @example
|
||||
* stepcat(
|
||||
* sound("bd bd - cp").extend(2),
|
||||
@@ -3200,7 +3199,7 @@ export const extend = stepRegister('extend', function (factor, pat) {
|
||||
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
|
||||
*
|
||||
* TODO: find out how this function differs from extend
|
||||
* @tags temporal
|
||||
* @tags stepwise
|
||||
* @example
|
||||
* stepcat(
|
||||
* sound("bd bd - cp").replicate(2),
|
||||
@@ -3215,7 +3214,7 @@ export const replicate = stepRegister('replicate', function (factor, pat) {
|
||||
* *Experimental*
|
||||
*
|
||||
* Expands the step size of the pattern by the given factor.
|
||||
* @tags temporal
|
||||
* @tags stepwise
|
||||
* @example
|
||||
* sound("tha dhi thom nam").bank("mridangam").expand("3 2 1 1 2 3").pace(8)
|
||||
*/
|
||||
@@ -3227,7 +3226,7 @@ export const expand = stepRegister('expand', function (factor, pat) {
|
||||
* *Experimental*
|
||||
*
|
||||
* Contracts the step size of the pattern by the given factor. See also `expand`.
|
||||
* @tags temporal
|
||||
* @tags stepwise
|
||||
* @example
|
||||
* sound("tha dhi thom nam").bank("mridangam").contract("3 2 1 1 2 3").pace(8)
|
||||
*/
|
||||
@@ -3282,7 +3281,7 @@ export const shrinklist = (amount, pat) => pat.shrinklist(amount);
|
||||
* Progressively shrinks the pattern by 'n' steps until there's nothing left, or if a second value is given (using mininotation list syntax with `:`),
|
||||
* that number of times.
|
||||
* A positive number will progressively drop steps from the start of a pattern, and a negative number from the end.
|
||||
* @tags temporal
|
||||
* @tags stepwise
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* "tha dhi thom nam".shrink("1").sound()
|
||||
@@ -3322,7 +3321,7 @@ export const shrink = register(
|
||||
* Progressively grows the pattern by 'n' steps until the full pattern is played, or if a second value is given (using mininotation list syntax with `:`),
|
||||
* that number of times.
|
||||
* A positive number will progressively grow steps from the start of a pattern, and a negative number from the end.
|
||||
* @tags temporal
|
||||
* @tags stepwise
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* "tha dhi thom nam".grow("1").sound()
|
||||
@@ -3363,7 +3362,7 @@ export const grow = register(
|
||||
* on successive repetitions. The patterns are added together stepwise, with all repetitions taking place over a single cycle. Using `pace` to set the
|
||||
* number of steps per cycle is therefore usually recommended.
|
||||
*
|
||||
* @tags combiners
|
||||
* @tags stepwise
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* "[c g]".tour("e f", "e f g", "g f e c").note()
|
||||
@@ -3390,7 +3389,7 @@ Pattern.prototype.tour = function (...many) {
|
||||
* 'zips' together the steps of the provided patterns. This can create a long repetition, taking place over a single, dense cycle.
|
||||
* Using `pace` to set the number of steps per cycle is therefore usually recommended.
|
||||
*
|
||||
* @tags combiners
|
||||
* @tags stepwise
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* zip("e f", "e f g", "g [f e] a f4 c").note()
|
||||
@@ -3442,7 +3441,7 @@ Pattern.prototype.steps = Pattern.prototype.pace;
|
||||
* Cuts each sample into the given number of parts, allowing you to explore a technique known as 'granular synthesis'.
|
||||
* It turns a pattern of samples into a pattern of parts of samples.
|
||||
* @name chop
|
||||
* @tags temporal
|
||||
* @tags samples
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
@@ -3473,7 +3472,7 @@ export const chop = register('chop', function (n, pat) {
|
||||
/**
|
||||
* Cuts each sample into the given number of parts, triggering progressive portions of each sample at each loop.
|
||||
* @name striate
|
||||
* @tags temporal
|
||||
* @tags samples
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
@@ -3492,14 +3491,13 @@ export const striate = register('striate', function (n, pat) {
|
||||
/**
|
||||
* Makes the sample fit the given number of cycles by changing the speed.
|
||||
* @name loopAt
|
||||
* @tags temporal
|
||||
* @tags samples, pitch
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
|
||||
* s("rhodes").loopAt(2)
|
||||
*/
|
||||
// TODO - global cps clock
|
||||
const _loopAt = function (factor, pat, cps = 0.5) {
|
||||
return pat
|
||||
.speed((1 / factor) * cps)
|
||||
@@ -3507,11 +3505,16 @@ const _loopAt = function (factor, pat, cps = 0.5) {
|
||||
.slow(factor);
|
||||
};
|
||||
|
||||
export const { loopAt, loopat } = register(['loopAt', 'loopat'], function (factor, pat) {
|
||||
const steps = pat._steps ? pat._steps.div(factor) : undefined;
|
||||
return new Pattern((state) => _loopAt(factor, pat, state.controls._cps).query(state), steps);
|
||||
});
|
||||
|
||||
/**
|
||||
* Chops samples into the given number of slices, triggering those slices with a given pattern of slice numbers.
|
||||
* Instead of a number, it also accepts a list of numbers from 0 to 1 to slice at specific points.
|
||||
* @name slice
|
||||
* @tags temporal
|
||||
* @tags samples
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
@@ -3565,7 +3568,7 @@ Pattern.prototype.onTriggerTime = function (func) {
|
||||
/**
|
||||
* Works the same as slice, but changes the playback speed of each slice to match the duration of its step.
|
||||
* @name splice
|
||||
* @tags temporal
|
||||
* @tags samples, pitch
|
||||
* @example
|
||||
* samples('github:tidalcycles/dirt-samples')
|
||||
* s("breaks165")
|
||||
@@ -3594,16 +3597,11 @@ export const splice = register(
|
||||
false, // turns off auto-patternification
|
||||
);
|
||||
|
||||
export const { loopAt, loopat } = register(['loopAt', 'loopat'], function (factor, pat) {
|
||||
const steps = pat._steps ? pat._steps.div(factor) : undefined;
|
||||
return new Pattern((state) => _loopAt(factor, pat, state.controls._cps).query(state), steps);
|
||||
});
|
||||
|
||||
/**
|
||||
* Makes the sample fit its event duration. Good for rhythmical loops like drum breaks.
|
||||
* Similar to `loopAt`.
|
||||
* @name fit
|
||||
* @tags temporal
|
||||
* @tags samples, pitch
|
||||
* @example
|
||||
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
|
||||
* s("rhodes/2").fit()
|
||||
@@ -3625,18 +3623,16 @@ export const fit = register('fit', (pat) =>
|
||||
|
||||
/**
|
||||
* Makes the sample fit the given number of cycles and cps value, by
|
||||
* changing the speed. Please note that at some point cps will be
|
||||
* given by a global clock and this function will be
|
||||
* deprecated/removed.
|
||||
* changing the speed. deprecated: use loopAt or fit instead, together with setCps / setCpm.
|
||||
* @name loopAtCps
|
||||
* @tags temporal
|
||||
* @tags samples, pitch
|
||||
* @memberof Pattern
|
||||
* @deprecated
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
|
||||
* s("rhodes").loopAtCps(4,1.5).cps(1.5)
|
||||
*/
|
||||
// TODO - global cps clock
|
||||
export const { loopAtCps, loopatcps } = register(['loopAtCps', 'loopatcps'], function (factor, cps, pat) {
|
||||
return _loopAt(factor, pat, cps);
|
||||
});
|
||||
@@ -3658,7 +3654,7 @@ let fadeGain = (p) => (p < 0.5 ? 1 : 1 - (p - 0.5) / 0.5);
|
||||
* - 1 = (no left, full right)
|
||||
*
|
||||
* @name xfade
|
||||
* @tags combiners
|
||||
* @tags amplitude
|
||||
* @example
|
||||
* xfade(s("bd*2"), "<0 .25 .5 .75 1>", s("hh*8"))
|
||||
*/
|
||||
@@ -3784,7 +3780,7 @@ const _distortWithAlg = function (name) {
|
||||
* Soft-clipping distortion
|
||||
*
|
||||
* @name soft
|
||||
* @tags fx
|
||||
* @tags distortion, superdough
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
@@ -3795,7 +3791,7 @@ export const soft = _distortWithAlg('soft');
|
||||
* Hard-clipping distortion
|
||||
*
|
||||
* @name hard
|
||||
* @tags fx
|
||||
* @tags distortion, superdough
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
@@ -3806,7 +3802,7 @@ export const hard = _distortWithAlg('hard');
|
||||
* Cubic polynomial distortion
|
||||
*
|
||||
* @name cubic
|
||||
* @tags fx
|
||||
* @tags distortion, superdough
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
@@ -3817,7 +3813,7 @@ export const cubic = _distortWithAlg('cubic');
|
||||
* Diode-emulating distortion
|
||||
*
|
||||
* @name diode
|
||||
* @tags fx
|
||||
* @tags distortion, superdough
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
@@ -3828,7 +3824,7 @@ export const diode = _distortWithAlg('diode');
|
||||
* Asymmetrical diode distortion
|
||||
*
|
||||
* @name asym
|
||||
* @tags fx
|
||||
* @tags distortion, superdough
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
@@ -3839,7 +3835,7 @@ export const asym = _distortWithAlg('asym');
|
||||
* Wavefolding distortion
|
||||
*
|
||||
* @name fold
|
||||
* @tags fx
|
||||
* @tags distortion, superdough
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
@@ -3850,7 +3846,7 @@ export const fold = _distortWithAlg('fold');
|
||||
* Wavefolding distortion composed with sinusoid
|
||||
*
|
||||
* @name sinefold
|
||||
* @tags fx
|
||||
* @tags distortion, superdough
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
@@ -3861,7 +3857,7 @@ export const sinefold = _distortWithAlg('sinefold');
|
||||
* Distortion via Chebyshev polynomials
|
||||
*
|
||||
* @name chebyshev
|
||||
* @tags fx
|
||||
* @tags distortion, superdough
|
||||
* @param {number | Pattern} distortion amount of distortion to apply
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
@@ -3895,7 +3891,7 @@ const _ensureListPattern = (list) => {
|
||||
* Can also be used to create a new synth via `s('user').partials(...)`
|
||||
*
|
||||
* @name partials
|
||||
* @tags fx, superdough
|
||||
* @tags superdough
|
||||
* @param {number[] | Pattern} magnitudes List of [0, 1] magnitudes for partials. 0th entry is the fundamental harmonic (i.e. DC offset is skipped)
|
||||
* @example
|
||||
* s("user").seg(16).n(irand(8)).scale("A:major")
|
||||
@@ -3917,7 +3913,7 @@ export const partials = (list) => {
|
||||
* Rotates the harmonics of one of the core synths ('sine', 'tri', 'saw', 'user', ..) by a list of phases
|
||||
*
|
||||
* @name phases
|
||||
* @tags fx, superdough
|
||||
* @tags superdough
|
||||
* @param {number[] | Pattern} phases List of [0, 1) phases for partials. 0th entry is the fundamental phase (i.e. DC offset is skipped)
|
||||
* @example
|
||||
* // Phase cancellation
|
||||
@@ -3939,7 +3935,7 @@ export const phases = (list) => {
|
||||
* calls and/or in a single .FX(fx1, fx2, ..) call. The fx1, .. are _patterns_ which
|
||||
* establish the controls of the given effect. See examples.
|
||||
* @name FX
|
||||
* @tags fx, superdough
|
||||
* @tags superdough
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
@@ -3990,7 +3986,7 @@ const _asArrayPattern = (pats) => {
|
||||
* by wrapping them inside a function in K (see example).
|
||||
*
|
||||
* @name K
|
||||
* @tags generators, fx, superdough
|
||||
* @tags generators, superdough
|
||||
* @param {KabelsalatExpression | Function} expr Kabelsalat graph definition
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
|
||||
+28
-23
@@ -71,29 +71,32 @@ export const pickmod = register('pickmod', function (lookup, pat) {
|
||||
/** * pickF lets you use a pattern of numbers to pick which function to apply to another pattern.
|
||||
* @tags combiners, functional
|
||||
* @param {Pattern} pat
|
||||
* @param {Pattern} lookup a pattern of indices
|
||||
* @param {function[]} funcs the array of functions from which to pull
|
||||
* @param {Pattern} lookup a pattern of indices or names
|
||||
* @param {function[] | object} lookup the array or lookup object of functions from which to pull
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* s("bd [rim hh]").pickF("<0 1 2>", [rev,jux(rev),fast(2)])
|
||||
* @example
|
||||
* note("<c2 d2>(3,8)").s("square")
|
||||
* .pickF("<0 2> 1", [jux(rev),fast(2),x=>x.lpf(800)])
|
||||
* .pickF("<0 2> 1", [jux(rev), fast(2), x=>x.lpf(800)])
|
||||
* @example
|
||||
* note("<c2 d2>(3,8)").s("square")
|
||||
* .pickF("<jr l> f", { jr:jux(rev), f:fast(2), l:x=>x.lpf(800) })
|
||||
*/
|
||||
export const pickF = register('pickF', function (lookup, funcs, pat) {
|
||||
return pat.apply(pick(lookup, funcs));
|
||||
export const pickF = register('pickF', function (pickPattern, lookup, pat) {
|
||||
return pat.apply(pick(lookup, pickPattern));
|
||||
});
|
||||
|
||||
/** * The same as `pickF`, but if you pick a number greater than the size of the functions list,
|
||||
* it wraps around, rather than sticking at the maximum value.
|
||||
* @tags combiners
|
||||
* @param {Pattern} pat
|
||||
* @param {Pattern} lookup a pattern of indices
|
||||
* @param {function[]} funcs the array of functions from which to pull
|
||||
* @param {Pattern} lookup a pattern of indices or names
|
||||
* @param {function[] | object} lookup the array or lookup object of functions from which to pull
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const pickmodF = register('pickmodF', function (lookup, funcs, pat) {
|
||||
return pat.apply(pickmod(lookup, funcs));
|
||||
export const pickmodF = register('pickmodF', function (pickPattern, lookup, pat) {
|
||||
return pat.apply(pickmod(lookup, pickPattern));
|
||||
});
|
||||
|
||||
/** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin.
|
||||
@@ -167,20 +170,22 @@ export const pickmodReset = register('pickmodReset', function (lookup, pat) {
|
||||
});
|
||||
|
||||
/** Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
|
||||
* Similar to `pick`, but cycles are squeezed into the target ('inhabited') pattern.
|
||||
* @name inhabit
|
||||
* @tags combiners
|
||||
* @synonyms pickSqueeze
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* "<a b [a,b]>".inhabit({a: s("bd(3,8)"),
|
||||
b: s("cp sd")
|
||||
})
|
||||
* @example
|
||||
* s("a@2 [a b] a".inhabit({a: "bd(3,8)", b: "sd sd"})).slow(4)
|
||||
*/
|
||||
* Similar to `pick`, but cycles are squeezed into the target ('inhabited') pattern.
|
||||
* @name inhabit
|
||||
* @tags combiners
|
||||
* @synonyms pickSqueeze
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* let a = s("bd(3,8)")
|
||||
* let b = s("cp sd")
|
||||
* "<a b [a,b]>".inhabit({ a, b })
|
||||
* @example
|
||||
* s("a@2 [a b] a"
|
||||
* .inhabit({a: "bd(3,8)", b: "sd sd"}))
|
||||
* .slow(4)
|
||||
*/
|
||||
export const { inhabit, pickSqueeze } = register(['inhabit', 'pickSqueeze'], function (lookup, pat) {
|
||||
return _pick(lookup, pat, false).squeezeJoin();
|
||||
});
|
||||
|
||||
+302
-39
@@ -38,6 +38,7 @@ export function repl({
|
||||
pattern: undefined,
|
||||
miniLocations: [],
|
||||
widgets: [],
|
||||
sliders: [],
|
||||
pending: false,
|
||||
started: false,
|
||||
};
|
||||
@@ -79,11 +80,132 @@ export function repl({
|
||||
let allTransform;
|
||||
let eachTransform;
|
||||
|
||||
// Block-based evaluation state
|
||||
let codeBlocks = {};
|
||||
let lastActiveVisualizerLabel = null;
|
||||
// Track which patterns belong to which blocks: { blockRange: [patternKeys] }
|
||||
let blockPatterns = new Map();
|
||||
|
||||
// Helper function to collect properties from all code blocks (handles both labeled and anonymous blocks)
|
||||
function collectFromBlocks(property) {
|
||||
return Object.entries(codeBlocks).flatMap(([key, block]) => {
|
||||
if (key === '$') {
|
||||
// Anonymous blocks are stored as an array of block objects
|
||||
return Array.isArray(block) ? block.flatMap((b) => b[property] || []) : [];
|
||||
}
|
||||
// Labeled blocks are stored as single block objects
|
||||
return block[property] || [];
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to process a single labeled block
|
||||
function processLabeledBlock(labels, i, code, options, meta) {
|
||||
const label = labels[i];
|
||||
const nextLabel = labels[i + 1] || { index: code.length, end: code.length };
|
||||
|
||||
const labelCode = code.slice(label.index, nextLabel.index);
|
||||
const labelRange = [label.index + options.range[0], label.end + options.range[0]];
|
||||
|
||||
// Calculate the full block range (from label start to next label start)
|
||||
const blockStart = label.index + options.range[0];
|
||||
const blockEnd = nextLabel.index + options.range[0];
|
||||
|
||||
const blockWidgets = (meta?.widgets || []).filter((widget) => {
|
||||
const widgetPos = widget.from ?? widget.index ?? 0;
|
||||
return widgetPos >= blockStart && widgetPos < blockEnd;
|
||||
});
|
||||
|
||||
const blockSliders = (meta?.sliders || []).filter((slider) => {
|
||||
const sliderPos = slider.from ?? slider.index ?? 0;
|
||||
return sliderPos >= blockStart && sliderPos < blockEnd;
|
||||
});
|
||||
|
||||
const blockMiniLocations = (meta?.miniLocations || []).filter((loc) => {
|
||||
// const locStart = loc.start ?? loc.from ?? 0;
|
||||
// mini locations can be either [start, end] arrays or objects with start/from
|
||||
const locStart = Array.isArray(loc) ? loc[0] : (loc.start ?? loc.from ?? 0);
|
||||
return locStart >= blockStart && locStart < blockEnd;
|
||||
});
|
||||
|
||||
handleSingleLabelBlock(
|
||||
label,
|
||||
labelCode,
|
||||
{ ...options, range: labelRange },
|
||||
{ widgets: blockWidgets, sliders: blockSliders, miniLocations: blockMiniLocations },
|
||||
);
|
||||
}
|
||||
|
||||
// helper
|
||||
function cleanupConflictingRanges(codeBlocks, currentKey, newRange) {
|
||||
for (const [existingKey, existingBlock] of Object.entries(codeBlocks)) {
|
||||
if (existingKey === currentKey) continue;
|
||||
if (!existingBlock.range) continue;
|
||||
|
||||
const [existingStart, existingEnd] = existingBlock.range;
|
||||
const [newStart, newEnd] = newRange;
|
||||
|
||||
// If ranges overlap (not just touch), remove the stale block
|
||||
if (!(newEnd <= existingStart || newStart >= existingEnd)) {
|
||||
delete codeBlocks[existingKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// helper
|
||||
function handleSingleLabelBlock(label, code, options, meta) {
|
||||
// Detect if this block contains a non-inline widget
|
||||
// The activeVisualizer is now provided by the transpiler for all labels
|
||||
const activeVisualizer = label.activeVisualizer || null;
|
||||
|
||||
if (activeVisualizer !== null) {
|
||||
lastActiveVisualizerLabel = label.name;
|
||||
}
|
||||
|
||||
// Store the entire code block under the label name
|
||||
codeBlocks[label.name] = {
|
||||
code: code,
|
||||
range: options.range,
|
||||
labels: [label.name],
|
||||
miniLocations: meta?.miniLocations || [],
|
||||
widgets: meta?.widgets || [],
|
||||
sliders: meta?.sliders || [],
|
||||
activeVisualizer: activeVisualizer, // Store the widget type if present, null otherwise
|
||||
};
|
||||
|
||||
// Clean up any blocks with conflicting ranges (including declaration blocks)
|
||||
cleanupConflictingRanges(codeBlocks, label.name, options.range);
|
||||
}
|
||||
|
||||
// helper
|
||||
// These blocks return silence but may contain mini notation strings that need highlighting
|
||||
function handleDeclarationBlock(code, options, meta) {
|
||||
const range = options.range || [];
|
||||
if (range.length < 2) return;
|
||||
|
||||
const blockKey = `_decl:${range[0]}:${range[1]}`;
|
||||
|
||||
codeBlocks[blockKey] = {
|
||||
code: code,
|
||||
range: range,
|
||||
labels: [],
|
||||
miniLocations: meta?.miniLocations || [],
|
||||
widgets: meta?.widgets || [],
|
||||
sliders: meta?.sliders || [],
|
||||
activeVisualizer: null,
|
||||
};
|
||||
|
||||
// Clean up any overlapping declaration blocks
|
||||
cleanupConflictingRanges(codeBlocks, blockKey, range);
|
||||
}
|
||||
|
||||
const hush = function () {
|
||||
pPatterns = {};
|
||||
anonymousIndex = 0;
|
||||
allTransform = undefined;
|
||||
eachTransform = undefined;
|
||||
codeBlocks = {};
|
||||
blockPatterns.clear();
|
||||
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
|
||||
return silence;
|
||||
};
|
||||
|
||||
@@ -103,7 +225,60 @@ export function repl({
|
||||
};
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
|
||||
const stop = () => scheduler.stop();
|
||||
// Helper function to apply pattern transformations (solo, each, all)
|
||||
// this should be abstracted more
|
||||
function applyPatternTransforms(pattern) {
|
||||
const allPatterns = Object.values(pPatterns);
|
||||
|
||||
if (allPatterns.length) {
|
||||
let patterns = [];
|
||||
let soloActive = false;
|
||||
for (const [key, value] of Object.entries(pPatterns)) {
|
||||
// handle soloed patterns ex: S$: s("bd!4")
|
||||
const isSolod = key.length > 1 && key.startsWith('S');
|
||||
if (isSolod && soloActive === false) {
|
||||
// first time we see a soloed pattern, clear existing patterns
|
||||
patterns = [];
|
||||
soloActive = true;
|
||||
}
|
||||
if (!soloActive || (soloActive && isSolod)) {
|
||||
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
||||
patterns.push(valWithState);
|
||||
}
|
||||
}
|
||||
if (eachTransform) {
|
||||
// Explicit lambda so only element (not index and array) are passed
|
||||
patterns = patterns.map((x) => eachTransform(x));
|
||||
}
|
||||
pattern = stack(...patterns);
|
||||
} else if (eachTransform) {
|
||||
pattern = eachTransform(pattern);
|
||||
}
|
||||
if (allTransforms.length) {
|
||||
for (const transform of allTransforms) {
|
||||
pattern = transform(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPattern(pattern)) {
|
||||
pattern = silence;
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
codeBlocks = {};
|
||||
blockPatterns.clear();
|
||||
pPatterns = {};
|
||||
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
|
||||
updateState({
|
||||
miniLocations: [],
|
||||
widgets: [],
|
||||
sliders: [],
|
||||
});
|
||||
scheduler.stop();
|
||||
};
|
||||
const start = () => scheduler.start();
|
||||
const pause = () => scheduler.pause();
|
||||
const toggle = () => scheduler.toggle();
|
||||
@@ -217,7 +392,7 @@ export function repl({
|
||||
});
|
||||
};
|
||||
|
||||
const evaluate = async (code, autostart = true, shouldHush = true) => {
|
||||
const evaluate = async (code, autostart = true) => {
|
||||
if (!code) {
|
||||
throw new Error('no code to evaluate');
|
||||
}
|
||||
@@ -225,59 +400,34 @@ export function repl({
|
||||
updateState({ code, pending: true });
|
||||
await injectPatternMethods();
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
await beforeEval?.({ code });
|
||||
await beforeEval?.({ code, blockBased: false });
|
||||
allTransforms = []; // reset all transforms
|
||||
shouldHush && hush();
|
||||
|
||||
codeBlocks = {};
|
||||
hush();
|
||||
|
||||
if (mondo) {
|
||||
code = `mondolang\`${code}\``;
|
||||
}
|
||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
||||
if (Object.keys(pPatterns).length) {
|
||||
let patterns = [];
|
||||
let soloActive = false;
|
||||
for (const [key, value] of Object.entries(pPatterns)) {
|
||||
// handle soloed patterns ex: S$: s("bd!4")
|
||||
const isSolod = key.length > 1 && key.startsWith('S');
|
||||
if (isSolod && soloActive === false) {
|
||||
// first time we see a soloed pattern, clear existing patterns
|
||||
patterns = [];
|
||||
soloActive = true;
|
||||
}
|
||||
if (!soloActive || (soloActive && isSolod)) {
|
||||
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
||||
patterns.push(valWithState);
|
||||
}
|
||||
}
|
||||
if (eachTransform) {
|
||||
// Explicit lambda so only element (not index and array) are passed
|
||||
patterns = patterns.map((x) => eachTransform(x));
|
||||
}
|
||||
pattern = stack(...patterns);
|
||||
} else if (eachTransform) {
|
||||
pattern = eachTransform(pattern);
|
||||
}
|
||||
if (allTransforms.length) {
|
||||
for (const transform of allTransforms) {
|
||||
pattern = transform(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPattern(pattern)) {
|
||||
pattern = silence;
|
||||
}
|
||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
||||
|
||||
pattern = applyPatternTransforms(pattern);
|
||||
|
||||
logger(`[eval] code updated`);
|
||||
pattern = await setPattern(pattern, autostart);
|
||||
updateState({
|
||||
miniLocations: meta?.miniLocations || [],
|
||||
widgets: meta?.widgets || [],
|
||||
sliders: meta?.sliders || [],
|
||||
activeCode: code,
|
||||
pattern,
|
||||
evalError: undefined,
|
||||
schedulerError: undefined,
|
||||
pending: false,
|
||||
});
|
||||
afterEval?.({ code, pattern, meta });
|
||||
|
||||
afterEval?.({ code, pattern, meta, range: undefined, widgetRemoved: false });
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
logger(`[eval] error: ${err.message}`, 'error');
|
||||
@@ -286,8 +436,121 @@ export function repl({
|
||||
onEvalError?.(err);
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateBlock = async (code, autostart = true, options = {}) => {
|
||||
if (!code) {
|
||||
throw new Error('no code to evaluate');
|
||||
}
|
||||
try {
|
||||
updateState({ code, pending: true });
|
||||
await injectPatternMethods();
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
await beforeEval?.({ code, blockBased: true });
|
||||
allTransforms = []; // reset all transforms
|
||||
|
||||
const transpilerOptionsWithBlock = {
|
||||
...transpilerOptions,
|
||||
blockBased: true,
|
||||
range: options.range || [],
|
||||
};
|
||||
|
||||
if (mondo) {
|
||||
code = `mondolang\`${code}\``;
|
||||
}
|
||||
|
||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptionsWithBlock);
|
||||
|
||||
// Track activeVisualizer cleanup: check if any block's visualizer was removed
|
||||
let widgetRemoved = false;
|
||||
|
||||
const labels = meta.labels || [];
|
||||
|
||||
// Check for anonymous labels (labels starting with '$')
|
||||
const hasAnonymousLabel = labels.some((label) => label.name.startsWith('$'));
|
||||
|
||||
// Store code blocks in dictionary using labels as keys
|
||||
if (hasAnonymousLabel) {
|
||||
// variable/function declarations that don't return patterns are allowed,
|
||||
// but anonymous pattern blocks pose an issue for block-based evaluation
|
||||
// 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
|
||||
|
||||
throw new Error(
|
||||
'anonymous labels disabled for block based evaluation (see https://strudel.cc/blog/#label-notation)',
|
||||
);
|
||||
} else if (labels.length > 0) {
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
// processing transpiler output instead of code is simply to avoid
|
||||
// extra regex in detecting whether or not an inline widget has been commented out
|
||||
processLabeledBlock(labels, i, meta.output, options, meta);
|
||||
}
|
||||
} else {
|
||||
// Declaration block (variable/function that returns silence)
|
||||
// Store it so its miniLocations are preserved for highlighting patterns stored in variables
|
||||
handleDeclarationBlock(code, options, meta);
|
||||
}
|
||||
|
||||
meta.miniLocations = collectFromBlocks('miniLocations');
|
||||
meta.widgets = collectFromBlocks('widgets');
|
||||
meta.sliders = collectFromBlocks('sliders');
|
||||
|
||||
// Track activeVisualizer cleanup: check if any block's visualizer was removed
|
||||
const blocksToUpdate = labels.map((label) => label.name);
|
||||
|
||||
// this is the hackiest bit
|
||||
for (const [key, block] of Object.entries(codeBlocks)) {
|
||||
if (blocksToUpdate.includes(key)) {
|
||||
// This block was just updated
|
||||
if (block.activeVisualizer !== null) {
|
||||
// Block now has a visualizer, update tracking
|
||||
lastActiveVisualizerLabel = key;
|
||||
} else if (lastActiveVisualizerLabel === key) {
|
||||
// This block lost its visualizer, trigger cleanup
|
||||
widgetRemoved = true;
|
||||
lastActiveVisualizerLabel = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pPatterns = Object.fromEntries(
|
||||
Object.entries(pPatterns).filter(([key]) => {
|
||||
return Object.keys(codeBlocks).includes(key);
|
||||
}),
|
||||
);
|
||||
|
||||
pattern = applyPatternTransforms(pattern);
|
||||
|
||||
logger(`[eval] code updated`);
|
||||
pattern = await setPattern(pattern, autostart);
|
||||
updateState({
|
||||
miniLocations: meta?.miniLocations || [],
|
||||
widgets: meta?.widgets || [],
|
||||
sliders: meta?.sliders || [],
|
||||
activeCode: code,
|
||||
pattern,
|
||||
evalError: undefined,
|
||||
schedulerError: undefined,
|
||||
pending: false,
|
||||
});
|
||||
|
||||
afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved });
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
logger(`[eval] error: ${err.message}`, 'error');
|
||||
console.error(err);
|
||||
updateState({ evalError: err, pending: false });
|
||||
onEvalError?.(err);
|
||||
}
|
||||
};
|
||||
|
||||
const setCode = (code) => updateState({ code });
|
||||
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||
return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||
}
|
||||
|
||||
export const getTrigger =
|
||||
|
||||
@@ -339,8 +339,13 @@ export function uniqsortr(a) {
|
||||
|
||||
export function unicodeToBase64(text) {
|
||||
const utf8Bytes = new TextEncoder().encode(text);
|
||||
const base64String = btoa(String.fromCharCode(...utf8Bytes));
|
||||
return base64String;
|
||||
let binaryString = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < utf8Bytes.length; i += chunkSize) {
|
||||
const chunk = utf8Bytes.subarray(i, i + chunkSize);
|
||||
binaryString += String.fromCharCode.apply(null, chunk);
|
||||
}
|
||||
return btoa(binaryString);
|
||||
}
|
||||
|
||||
export function base64ToUnicode(base64String) {
|
||||
|
||||
@@ -78,6 +78,16 @@ export const cleanupDraw = (clearScreen = true, id) => {
|
||||
stopAllAnimations(id);
|
||||
};
|
||||
|
||||
export const cleanupDrawContext = (replID) => {
|
||||
const ctx = getDrawContext();
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
// clear the big canvas context, ignore inline widgets
|
||||
Object.keys(animationFrames).forEach(
|
||||
(id) => (!replID || id.startsWith(replID)) && !id.startsWith('_') && stopAnimationFrame(id),
|
||||
);
|
||||
};
|
||||
|
||||
Pattern.prototype.onPaint = function (painter) {
|
||||
return this.withState((state) => {
|
||||
if (!state.controls.painters) {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
input.mjs - MIDI input wrapper
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.mjs>
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { WebMidi } from 'webmidi';
|
||||
import { logger, ref } from '@strudel/core';
|
||||
import { getDevice } from './util.mjs';
|
||||
|
||||
/**
|
||||
* MIDI input device wrapper that manages connection and reconnection, tracks
|
||||
* persisted CC states, etc. These instances are long-lived and are maintained as singletons
|
||||
* (keyed globally by input string/number).
|
||||
*/
|
||||
export class MidiInput {
|
||||
/**
|
||||
*
|
||||
* @param {string | number} input MIDI device name or index defaulting to 0
|
||||
*/
|
||||
constructor(input) {
|
||||
this.input = input;
|
||||
this.stateKey = typeof input === 'string' ? input : undefined; // Saved state is not tracked for numeric index inputs
|
||||
|
||||
this._refs = {};
|
||||
this._refsByChan = {};
|
||||
|
||||
this._loadAllStates();
|
||||
|
||||
this.initialDevice = this._startDeviceListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation for the cc() factory function tied to this specific input.
|
||||
* @param {number} cc MIDI CC number
|
||||
* @param {number | undefined} chan MIDI channel (1-16) or undefined for all channels
|
||||
*/
|
||||
createCC(cc, chan) {
|
||||
const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan];
|
||||
if (!(cc in lookupMap)) {
|
||||
const initialState = this._loadState(chan);
|
||||
lookupMap[cc] = initialState[cc] || 0;
|
||||
}
|
||||
|
||||
return ref(() => lookupMap[cc]);
|
||||
}
|
||||
|
||||
_startDeviceListener() {
|
||||
const initialDevice = getDevice(this.input, WebMidi.inputs);
|
||||
|
||||
// Background connection loop
|
||||
(async () => {
|
||||
const midiListener = this._onMidiMessage.bind(this);
|
||||
let device = initialDevice;
|
||||
|
||||
while (true) {
|
||||
if (!device) {
|
||||
device = await this._waitForDevice();
|
||||
}
|
||||
|
||||
// Wait a bit for device to be ready to receive last state
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
try {
|
||||
// Still continue if sending did not work
|
||||
this._sendAllStates(device);
|
||||
} catch (err) {
|
||||
console.error('midiin: failed to send last state on connect:', device.name, err);
|
||||
}
|
||||
|
||||
// Listen for incoming MIDI messages and for disconnection
|
||||
device.addListener('midimessage', midiListener);
|
||||
|
||||
await this._waitForDeviceDisconnect(device);
|
||||
|
||||
device.removeListener('midimessage', midiListener);
|
||||
device = null; // Clear var to trigger wait for connection
|
||||
}
|
||||
})();
|
||||
|
||||
return initialDevice;
|
||||
}
|
||||
|
||||
// Returns a promise that resolves when the specified device is connected
|
||||
_waitForDevice() {
|
||||
return new Promise((resolve) => {
|
||||
const connListener = () => {
|
||||
const device = getDevice(this.input, WebMidi.inputs);
|
||||
if (device) {
|
||||
logger(`[midi] device reconnected: ${device.name}`);
|
||||
|
||||
WebMidi.removeListener('connected', connListener);
|
||||
resolve(device);
|
||||
}
|
||||
};
|
||||
|
||||
WebMidi.addListener('connected', connListener);
|
||||
});
|
||||
}
|
||||
|
||||
// Returns a promise that resolves when the specified device is disconnected
|
||||
_waitForDeviceDisconnect(device) {
|
||||
return new Promise((resolve) => {
|
||||
const disconnListener = (e) => {
|
||||
if (e.port.name === device.name) {
|
||||
logger(`[midi] device disconnected: ${device.name}`);
|
||||
|
||||
WebMidi.removeListener('disconnected', disconnListener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
WebMidi.addListener('disconnected', disconnListener);
|
||||
});
|
||||
}
|
||||
|
||||
_onMidiMessage(e) {
|
||||
const ccNum = e.dataBytes[0];
|
||||
const v = e.dataBytes[1];
|
||||
const chan = e.message.channel;
|
||||
const scaled = v / 127;
|
||||
|
||||
this._refs[ccNum] = scaled;
|
||||
this._refsByChan[chan] ??= {};
|
||||
this._refsByChan[chan][ccNum] = scaled;
|
||||
|
||||
this._saveState(undefined, ccNum, scaled);
|
||||
this._saveState(chan, ccNum, scaled);
|
||||
}
|
||||
|
||||
_loadAllStates() {
|
||||
Object.assign(this._refs, this._loadState(undefined));
|
||||
|
||||
for (let chan = 1; chan <= 16; chan++) {
|
||||
this._refsByChan[chan] ??= {};
|
||||
Object.assign(this._refsByChan[chan], this._loadState(chan));
|
||||
}
|
||||
}
|
||||
|
||||
_loadState(chan) {
|
||||
if (!this.stateKey) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const initialDataRaw = localStorage.getItem(
|
||||
`strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`,
|
||||
);
|
||||
if (!initialDataRaw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(initialDataRaw);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to parse MIDI state from localStorage for input "${this.stateKey}" and channel "${chan}"`,
|
||||
initialDataRaw,
|
||||
err,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
_saveState(chan, cc, value) {
|
||||
if (!this.stateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this._loadState(chan);
|
||||
state[cc] = value;
|
||||
localStorage.setItem(
|
||||
`strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`,
|
||||
JSON.stringify(state),
|
||||
);
|
||||
}
|
||||
|
||||
// Send CC values back to device to restore encoders and motorized sliders
|
||||
_sendAllStates(device) {
|
||||
const output = WebMidi.outputs.find((o) => o.name === device.name);
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [chan, refs] of Object.entries(this._refsByChan)) {
|
||||
const channel = Number(chan);
|
||||
for (const [cc, value] of Object.entries(refs)) {
|
||||
const ccn = Number(cc);
|
||||
const scaled = Math.round(value * 127);
|
||||
output.sendControlChange(ccn, scaled, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-67
@@ -23,6 +23,8 @@ import { noteToMidi, getControlName } from '@strudel/core';
|
||||
import { Note } from 'webmidi';
|
||||
import { getAudioContext } from '@strudel/webaudio';
|
||||
import { scheduleAtTime } from '../superdough/helpers.mjs';
|
||||
import { getMidiDeviceNamesString, getDevice } from './util.mjs';
|
||||
import { MidiInput } from './input.mjs';
|
||||
|
||||
// if you use WebMidi from outside of this package, make sure to import that instance:
|
||||
export const { WebMidi } = _WebMidi;
|
||||
@@ -31,10 +33,6 @@ function supportsMidi() {
|
||||
return typeof navigator.requestMIDIAccess === 'function';
|
||||
}
|
||||
|
||||
function getMidiDeviceNamesString(devices) {
|
||||
return devices.map((o) => `'${o.name}'`).join(' | ');
|
||||
}
|
||||
|
||||
export function enableWebMidi(options = {}) {
|
||||
const { onReady, onConnected, onDisconnected, onEnabled } = options;
|
||||
if (WebMidi.enabled) {
|
||||
@@ -72,29 +70,6 @@ export function enableWebMidi(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function getDevice(indexOrName, devices) {
|
||||
if (!devices.length) {
|
||||
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
|
||||
}
|
||||
if (typeof indexOrName === 'number') {
|
||||
return devices[indexOrName];
|
||||
}
|
||||
const byName = (name) => devices.find((output) => output.name.includes(name));
|
||||
if (typeof indexOrName === 'string') {
|
||||
return byName(indexOrName);
|
||||
}
|
||||
// attempt to default to first IAC device if none is specified
|
||||
const IACOutput = byName('IAC');
|
||||
const device = IACOutput ?? devices[0];
|
||||
if (!device) {
|
||||
throw new Error(
|
||||
`🔌 MIDI device '${device ? device : ''}' not found. Use one of ${getMidiDeviceNamesString(devices)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return IACOutput ?? devices[0];
|
||||
}
|
||||
|
||||
// send start/stop messages to outputs when repl starts/stops
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', (e) => {
|
||||
@@ -138,7 +113,7 @@ function githubPath(base, subpath = '') {
|
||||
|
||||
/**
|
||||
* configures the default midimap, which is used when no "midimap" port is set
|
||||
* @tags external_io
|
||||
* @tags external_io, midi
|
||||
* @example
|
||||
* defaultmidimap({ lpf: 74 })
|
||||
* $: note("c a f e").midi();
|
||||
@@ -152,7 +127,7 @@ let loadCache = {};
|
||||
|
||||
/**
|
||||
* Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers.
|
||||
* @tags external_io
|
||||
* @tags external_io, midi
|
||||
* @example
|
||||
* midimaps({ mymap: { lpf: 74 } })
|
||||
* $: note("c a f e")
|
||||
@@ -495,9 +470,9 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize a midi device
|
||||
* Initialize a midi input device
|
||||
*/
|
||||
async function _initialize(input) {
|
||||
async function _initializeInput(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('${
|
||||
@@ -505,31 +480,38 @@ async function _initialize(input) {
|
||||
}')`,
|
||||
);
|
||||
}
|
||||
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const instance = midiInputs[input] || new MidiInput(input);
|
||||
midiInputs[input] = instance;
|
||||
|
||||
if (initial) {
|
||||
const device = instance.initialDevice;
|
||||
|
||||
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
|
||||
logger(
|
||||
`[midi] Midi enabled! Using "${device.name}". ${
|
||||
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
||||
}`,
|
||||
device
|
||||
? `[midi] Midi enabled! Using "${device.name}". ${
|
||||
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
||||
}`
|
||||
: `[midi] Midi enabled! Waiting for device "${input}"... Currently connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
||||
);
|
||||
}
|
||||
return device;
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// MIDI input wrappers, by specified input string/index
|
||||
const midiInputs = {};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @tags external_io
|
||||
* @tags external_io, midi
|
||||
* @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)
|
||||
@@ -543,31 +525,10 @@ async function _initialize(input) {
|
||||
* 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) {
|
||||
const device = await _initialize(input);
|
||||
refs[input] ??= {};
|
||||
refsByChan[input] ??= {};
|
||||
const cc = (cc, chan) => {
|
||||
if (chan !== undefined) {
|
||||
return ref(() => refsByChan[input][cc]?.[chan] || 0);
|
||||
}
|
||||
return ref(() => refs[input][cc] || 0);
|
||||
};
|
||||
const instance = await _initializeInput(input);
|
||||
|
||||
listeners[input] && device.removeListener('midimessage', listeners[input]);
|
||||
listeners[input] = (e) => {
|
||||
const [ccNum, v] = e.dataBytes;
|
||||
const chan = e.message.channel;
|
||||
const scaled = v / 127;
|
||||
refsByChan[input][ccNum] ??= {};
|
||||
refsByChan[input][ccNum][chan] = scaled;
|
||||
refs[input][ccNum] = scaled;
|
||||
};
|
||||
device.addListener('midimessage', listeners[input]);
|
||||
return cc;
|
||||
return instance.createCC.bind(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -577,7 +538,7 @@ export async function midin(input) {
|
||||
* note durations
|
||||
*
|
||||
* @name midikeys
|
||||
* @tags external_io
|
||||
* @tags external_io, midi
|
||||
* @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,
|
||||
@@ -622,7 +583,16 @@ function _triggerKeyboard(input, cps, now, latencyCycles) {
|
||||
return true;
|
||||
}
|
||||
export async function midikeys(input) {
|
||||
const device = await _initialize(input);
|
||||
const instance = await _initializeInput(input);
|
||||
|
||||
// TODO: support unpluggable device usage
|
||||
const device = instance.initialDevice;
|
||||
if (!device) {
|
||||
throw new Error(
|
||||
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!kHaps[input]) {
|
||||
kHaps[input] = [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
util.mjs - MIDI utility functions
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.mjs>
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Input, Output } from 'webmidi';
|
||||
|
||||
/**
|
||||
* Get a string listing device names for error messages.
|
||||
* @param {Input[] | Output[]} devices
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getMidiDeviceNamesString(devices) {
|
||||
return devices.map((o) => `'${o.name}'`).join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a device by index or name. Otherwise return a default device, or fail if none are connected.
|
||||
*
|
||||
* @param {string | number} indexOrName
|
||||
* @param {Input[] | Output[]} devices
|
||||
* @returns {Input | Output | undefined}
|
||||
*/
|
||||
export function getDevice(indexOrName, devices) {
|
||||
if (typeof indexOrName === 'number') {
|
||||
return devices[indexOrName];
|
||||
}
|
||||
const byName = (name) => devices.find((output) => output.name.includes(name));
|
||||
if (typeof indexOrName === 'string') {
|
||||
return byName(indexOrName);
|
||||
}
|
||||
// attempt to default to first IAC device if none is specified
|
||||
const IACOutput = byName('IAC');
|
||||
const device = IACOutput ?? devices[0];
|
||||
if (!device) {
|
||||
if (!devices.length) {
|
||||
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
|
||||
}
|
||||
throw new Error(`🔌 Default MIDI device not found. Use one of ${getMidiDeviceNamesString(devices)}`);
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
|
||||
import { BASE_MIDI_NOTE, getBaseURL, getCommonSampleInfo, noteToFreq, noteToMidi } from './util.mjs';
|
||||
import { registerSound, registerWaveTable } from './index.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import {
|
||||
@@ -14,6 +14,13 @@ import { logger } from './logger.mjs';
|
||||
const bufferCache = {}; // string: Promise<ArrayBuffer>
|
||||
const loadCache = {}; // string: Promise<ArrayBuffer>
|
||||
|
||||
/**
|
||||
*
|
||||
* @typedef {Object} SampleMetaData
|
||||
* @property {string} url
|
||||
* @property {midi} number
|
||||
*/
|
||||
|
||||
export const getCachedBuffer = (url) => bufferCache[url];
|
||||
|
||||
function humanFileSize(bytes, si) {
|
||||
@@ -33,7 +40,7 @@ function humanFileSize(bytes, si) {
|
||||
export function getSampleInfo(hapValue, bank) {
|
||||
const { speed = 1.0 } = hapValue;
|
||||
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
|
||||
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
|
||||
const playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
|
||||
return { transpose, url, index, midi, label, playbackRate };
|
||||
}
|
||||
|
||||
@@ -156,15 +163,20 @@ export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '')
|
||||
if (baseUrl.startsWith('github:')) {
|
||||
baseUrl = githubPath(baseUrl, '');
|
||||
}
|
||||
const fullUrl = (v) => baseUrl + v;
|
||||
/**
|
||||
*
|
||||
* @param {string} v
|
||||
* @returns {SampleMetaData}
|
||||
*/
|
||||
const getMetaData = (v) => ({ url: baseUrl + v, midi: extractMidiNoteFromString(v) });
|
||||
if (Array.isArray(value)) {
|
||||
//return [key, value.map(replaceUrl)];
|
||||
value = value.map(fullUrl);
|
||||
value = value.map(getMetaData);
|
||||
} else {
|
||||
// must be object
|
||||
value = Object.fromEntries(
|
||||
Object.entries(value).map(([note, samples]) => {
|
||||
return [note, (typeof samples === 'string' ? [samples] : samples).map(fullUrl)];
|
||||
return [note, (typeof samples === 'string' ? [samples] : samples).map(getMetaData)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -367,3 +379,16 @@ export function registerSampleSource(key, bank, params) {
|
||||
registerSample(key, bank, params);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMidiNoteFromString(str) {
|
||||
const regex = /_([a-gA-G])([#b])?([1-9])?\b/;
|
||||
const match = str.match(regex);
|
||||
if (match == null) {
|
||||
return BASE_MIDI_NOTE;
|
||||
}
|
||||
const base = match[1].toUpperCase();
|
||||
const accidental = match[2] ?? '';
|
||||
const octave_str = match[3] ?? '';
|
||||
const parsedVal = base + accidental + octave_str;
|
||||
return noteToMidi(parsedVal);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export let maxPolyphony = DEFAULT_MAX_POLYPHONY;
|
||||
* start to die out in first-in-first-out order once the max polyphony has been hit
|
||||
*
|
||||
* @name setMaxPolyphony
|
||||
* @tags fx, superdough
|
||||
* @tags superdough
|
||||
* @param {number} Max polyphony. Defaults to 128
|
||||
* @example
|
||||
* setMaxPolyphony(4)
|
||||
@@ -74,7 +74,7 @@ export function applyGainCurve(val) {
|
||||
* quadratic, exponential, etc. rather than linear
|
||||
*
|
||||
* @name setGainCurve
|
||||
* @tags fx, superdough
|
||||
* @tags amplitude, superdough
|
||||
* @param {Function} function to apply to all gain values
|
||||
* @example
|
||||
* setGainCurve((x) => x * x) // quadratic gain
|
||||
|
||||
@@ -19,7 +19,12 @@ const accs = { '#': 1, b: -1, s: 1, f: -1 };
|
||||
export const getAccidentalsOffset = (accidentals) => {
|
||||
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} note
|
||||
* @param {number} defaultOctave
|
||||
* @returns {number}
|
||||
*/
|
||||
export const noteToMidi = (note, defaultOctave = 3) => {
|
||||
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
|
||||
if (!pc) {
|
||||
@@ -29,6 +34,11 @@ export const noteToMidi = (note, defaultOctave = 3) => {
|
||||
const offset = getAccidentalsOffset(acc);
|
||||
return (Number(oct) + 1) * 12 + chroma + offset;
|
||||
};
|
||||
/**
|
||||
*
|
||||
* @param {number} n
|
||||
* @returns {number}
|
||||
*/
|
||||
export const midiToFreq = (n) => {
|
||||
return Math.pow(2, (n - 69) / 12) * 440;
|
||||
};
|
||||
@@ -38,7 +48,17 @@ export const freqToMidi = (freq) => {
|
||||
return (12 * Math.log(freq / 440)) / Math.LN2 + 69;
|
||||
};
|
||||
|
||||
export const valueToMidi = (value, fallbackValue) => {
|
||||
/**
|
||||
*
|
||||
* @param {string} note
|
||||
* @param {number} defaultOctave
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
export const noteToFreq = (note, defaultOctave = 3) => {
|
||||
return midiToFreq(noteToMidi(note, defaultOctave));
|
||||
};
|
||||
function __valueToMidi(value) {
|
||||
if (typeof value !== 'object') {
|
||||
throw new Error('valueToMidi: expected object value');
|
||||
}
|
||||
@@ -52,10 +72,15 @@ export const valueToMidi = (value, fallbackValue) => {
|
||||
if (typeof note === 'number') {
|
||||
return note;
|
||||
}
|
||||
if (!fallbackValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
export const valueToMidi = (value, fallbackValue) => {
|
||||
const parsedValue = __valueToMidi(value) ?? fallbackValue;
|
||||
if (parsedValue == null) {
|
||||
throw new Error('valueToMidi: expected freq or note to be set');
|
||||
}
|
||||
return fallbackValue;
|
||||
return parsedValue;
|
||||
};
|
||||
|
||||
export function nanFallback(value, fallback = 0, silent) {
|
||||
@@ -84,15 +109,18 @@ export function secondsToCycle(t, cps) {
|
||||
// deduces relevant info for sample loading from hap.value and sample definition
|
||||
// it encapsulates the core sampler logic into a pure and synchronous function
|
||||
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
|
||||
export const BASE_MIDI_NOTE = 36;
|
||||
|
||||
export function getCommonSampleInfo(hapValue, bank) {
|
||||
const { s, n = 0 } = hapValue;
|
||||
let midi = valueToMidi(hapValue, 36);
|
||||
let transpose = midi - 36; // C3 is middle C;
|
||||
let url;
|
||||
const maybeMidiNote = __valueToMidi(hapValue);
|
||||
const midi = maybeMidiNote ?? BASE_MIDI_NOTE;
|
||||
let transpose = midi - BASE_MIDI_NOTE; // C3 is middle C;
|
||||
let index = 0;
|
||||
let samplemeta;
|
||||
if (Array.isArray(bank)) {
|
||||
index = getSoundIndex(n, bank.length);
|
||||
url = bank[index];
|
||||
samplemeta = bank[index];
|
||||
} else {
|
||||
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
|
||||
// object format will expect keys as notes
|
||||
@@ -104,10 +132,14 @@ export function getCommonSampleInfo(hapValue, bank) {
|
||||
);
|
||||
transpose = -midiDiff(closest); // semitones to repitch
|
||||
index = getSoundIndex(n, bank[closest].length);
|
||||
url = bank[closest][index];
|
||||
samplemeta = bank[closest][index];
|
||||
}
|
||||
const label = `${s}:${index}`;
|
||||
return { transpose, url, index, midi, label };
|
||||
if (maybeMidiNote != null) {
|
||||
transpose = transpose + (BASE_MIDI_NOTE - samplemeta.midi);
|
||||
}
|
||||
|
||||
return { transpose, index, midi, label, url: samplemeta.url };
|
||||
}
|
||||
|
||||
/** Selects entries from `source` and renames them via `map`
|
||||
|
||||
@@ -186,7 +186,7 @@ export function registerWaveTable(key, tables, params) {
|
||||
* Loads a collection of wavetables to use with `s`
|
||||
*
|
||||
* @name tables
|
||||
* @tags fx
|
||||
* @tags wavetable
|
||||
*/
|
||||
export const tables = async (url, frameLen, json, options = {}) => {
|
||||
if (json !== undefined) return _processTables(json, url, frameLen);
|
||||
|
||||
@@ -214,7 +214,7 @@ class CoarseProcessor extends AudioWorkletProcessor {
|
||||
coarse = Math.max(1, coarse);
|
||||
for (let n = 0; n < blockSize; n++) {
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
output[i][n] = n % coarse === 0 ? input[i][n] : output[i][n - 1];
|
||||
output[i][n] = n % coarse < 1 ? input[i][n] : output[i][n - 1];
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -100,7 +100,7 @@ function scaleOffset(scale, offset, note) {
|
||||
* - 5P = perfect fifth
|
||||
* - 5d = diminished fifth
|
||||
*
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @param {string | number} amount Either number of semitones or interval string.
|
||||
* @returns Pattern
|
||||
* @memberof Pattern
|
||||
@@ -155,7 +155,7 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr
|
||||
*
|
||||
* @memberof Pattern
|
||||
* @name scaleTranspose
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @param {offset} offset number of steps inside the scale
|
||||
* @returns Pattern
|
||||
* @synonyms scaleTrans, strans
|
||||
@@ -246,7 +246,7 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
|
||||
* The root note defaults to octave 3, if no octave number is given.
|
||||
*
|
||||
* @name scale
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @param {string} scale Name of scale
|
||||
* @returns Pattern
|
||||
* @example
|
||||
|
||||
@@ -90,7 +90,7 @@ export const setVoicingRange = (name, range) => addVoicings(name, voicingRegistr
|
||||
* Adds a new custom voicing dictionary.
|
||||
*
|
||||
* @name addVoicings
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @memberof Pattern
|
||||
* @param {string} name identifier for the voicing dictionary
|
||||
* @param {Object} dictionary maps chord symbol to possible voicings
|
||||
@@ -134,7 +134,7 @@ const getVoicing = (chord, dictionaryName, lastVoicing) => {
|
||||
* Uses [chord-voicings package](https://github.com/felixroos/chord-voicings#chord-voicings).
|
||||
*
|
||||
* @name voicings
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @memberof Pattern
|
||||
* @param {string} dictionary which voicing dictionary to use.
|
||||
* @returns Pattern
|
||||
@@ -159,7 +159,7 @@ export const voicings = register('voicings', function (dictionary, pat) {
|
||||
* Maps the chords of the incoming pattern to root notes in the given octave.
|
||||
*
|
||||
* @name rootNotes
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @memberof Pattern
|
||||
* @param {octave} octave octave to use
|
||||
* @returns Pattern
|
||||
@@ -192,7 +192,7 @@ export const rootNotes = register('rootNotes', function (octave, pat) {
|
||||
* If you pass a pattern of strings to voicing, they will be interpreted as chords.
|
||||
*
|
||||
* @name voicing
|
||||
* @tags music_theory
|
||||
* @tags tonal
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* n("0 1 2 3").chord("<C Am F G>").voicing()
|
||||
|
||||
@@ -19,7 +19,14 @@ export function registerLanguage(type, config) {
|
||||
}
|
||||
|
||||
export function transpiler(input, options = {}) {
|
||||
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options;
|
||||
const {
|
||||
wrapAsync = false,
|
||||
addReturn = true,
|
||||
emitMiniLocations = true,
|
||||
emitWidgets = true,
|
||||
blockBased = false,
|
||||
range = [],
|
||||
} = options;
|
||||
|
||||
const comments = [];
|
||||
let ast = parse(input, {
|
||||
@@ -31,6 +38,13 @@ export function transpiler(input, options = {}) {
|
||||
|
||||
const miniDisableRanges = findMiniDisableRanges(comments, input.length);
|
||||
let miniLocations = [];
|
||||
|
||||
// Position offset for block-based evaluation
|
||||
let nodeOffset = range && range.length > 0 ? range[0] : 0;
|
||||
|
||||
// Track declarations to add to strudelScope for block-based eval
|
||||
let scopeDeclarations = [];
|
||||
|
||||
const collectMiniLocations = (value, node) => {
|
||||
const minilang = languages.get('minilang');
|
||||
if (minilang) {
|
||||
@@ -43,9 +57,28 @@ export function transpiler(input, options = {}) {
|
||||
}
|
||||
};
|
||||
let widgets = [];
|
||||
let sliders = [];
|
||||
let labels = [];
|
||||
|
||||
walk(ast, {
|
||||
enter(node, parent /* , prop, index */) {
|
||||
// Apply position offset for block-based evaluation
|
||||
if (blockBased && node.start !== undefined) {
|
||||
node.start = node.start + nodeOffset;
|
||||
node.end = node.end + nodeOffset;
|
||||
}
|
||||
// Collect variable and function declarations for strudelScope (block-based eval)
|
||||
if (blockBased && parent?.type === 'Program') {
|
||||
if (node.type === 'VariableDeclaration') {
|
||||
for (const declarator of node.declarations) {
|
||||
if (declarator.id?.name) {
|
||||
scopeDeclarations.push(declarator.id.name);
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'FunctionDeclaration' && node.id?.name) {
|
||||
scopeDeclarations.push(node.id.name);
|
||||
}
|
||||
}
|
||||
if (isLanguageLiteral(node)) {
|
||||
const { name } = node.tag;
|
||||
const language = languages.get(name);
|
||||
@@ -88,22 +121,29 @@ export function transpiler(input, options = {}) {
|
||||
return this.replace(miniWithLocation(value, node));
|
||||
}
|
||||
if (isSliderFunction(node)) {
|
||||
emitWidgets &&
|
||||
widgets.push({
|
||||
from: node.arguments[0].start,
|
||||
to: node.arguments[0].end,
|
||||
value: node.arguments[0].raw, // don't use value!
|
||||
min: node.arguments[1]?.value ?? 0,
|
||||
max: node.arguments[2]?.value ?? 1,
|
||||
step: node.arguments[3]?.value,
|
||||
type: 'slider',
|
||||
});
|
||||
return this.replace(sliderWithLocation(node));
|
||||
const from = node.arguments[0].start + nodeOffset;
|
||||
const to = node.arguments[0].end + nodeOffset;
|
||||
const id = `${from}:${to}`; // Range-based ID for stability
|
||||
|
||||
const sliderConfig = {
|
||||
from,
|
||||
to,
|
||||
id,
|
||||
value: node.arguments[0].raw, // don't use value!
|
||||
min: node.arguments[1]?.value ?? 0,
|
||||
max: node.arguments[2]?.value ?? 1,
|
||||
step: node.arguments[3]?.value,
|
||||
type: 'slider',
|
||||
};
|
||||
emitWidgets && widgets.push(sliderConfig);
|
||||
sliders.push(sliderConfig);
|
||||
return this.replace(sliderWithLocation(node, nodeOffset));
|
||||
}
|
||||
if (isWidgetMethod(node)) {
|
||||
const type = node.callee.property.name;
|
||||
const index = widgets.filter((w) => w.type === type).length;
|
||||
const widgetConfig = {
|
||||
from: node.start,
|
||||
to: node.end,
|
||||
index,
|
||||
type,
|
||||
@@ -116,8 +156,30 @@ export function transpiler(input, options = {}) {
|
||||
return this.replace(withAwait(node));
|
||||
}
|
||||
if (isLabelStatement(node)) {
|
||||
// Collect label info for block-based evaluation
|
||||
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
|
||||
if (blockBased) {
|
||||
labels.push({
|
||||
name: node.label.name,
|
||||
index: node.start - nodeOffset,
|
||||
end: node.label.end - nodeOffset,
|
||||
fullMatch: input.slice(node.start - nodeOffset, node.label.end - nodeOffset),
|
||||
activeVisualizer: findVisualizerInSubtree(node.body),
|
||||
});
|
||||
}
|
||||
return this.replace(labelToP(node));
|
||||
}
|
||||
// Detect all() calls as special labels for block management
|
||||
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
|
||||
if (blockBased && isAllCall(node)) {
|
||||
labels.push({
|
||||
name: 'all',
|
||||
index: node.start - nodeOffset,
|
||||
end: node.end - nodeOffset,
|
||||
fullMatch: input.slice(node.start - nodeOffset, node.end - nodeOffset),
|
||||
activeVisualizer: node.arguments[0] ? findVisualizerInSubtree(node.arguments[0]) : null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
leave(node, parent, prop, index) {
|
||||
@@ -181,17 +243,33 @@ export function transpiler(input, options = {}) {
|
||||
|
||||
let { body } = ast;
|
||||
|
||||
const silenceExpression = {
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'Identifier',
|
||||
name: 'silence',
|
||||
},
|
||||
};
|
||||
|
||||
if (!body.length) {
|
||||
console.warn('empty body -> fallback to silence');
|
||||
body.push({
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'Identifier',
|
||||
name: 'silence',
|
||||
},
|
||||
});
|
||||
body.push(silenceExpression);
|
||||
} else if (!body?.[body.length - 1]?.expression) {
|
||||
throw new Error('unexpected ast format without body expression');
|
||||
// Last statement is not an expression (e.g., VariableDeclaration, FunctionDeclaration)
|
||||
if (blockBased) {
|
||||
// For block-based eval, add silence as the return value when block ends with declaration
|
||||
body.push(silenceExpression);
|
||||
} else {
|
||||
throw new Error('unexpected ast format without body expression');
|
||||
}
|
||||
}
|
||||
|
||||
// For block-based eval, add scope assignments before the return statement
|
||||
// This allows variables/functions defined in one block to be used in other blocks
|
||||
if (blockBased && scopeDeclarations.length > 0) {
|
||||
const scopeAssignments = scopeDeclarations.flatMap((name) => createScopeAssignment(name));
|
||||
// Insert scope assignments before the last statement (which will become the return)
|
||||
body.splice(body.length - 1, 0, ...scopeAssignments);
|
||||
}
|
||||
|
||||
// add return to last statement
|
||||
@@ -209,7 +287,7 @@ export function transpiler(input, options = {}) {
|
||||
if (!emitMiniLocations) {
|
||||
return { output };
|
||||
}
|
||||
return { output, miniLocations, widgets };
|
||||
return { output, miniLocations, widgets, sliders, labels };
|
||||
}
|
||||
|
||||
function isKabelCall(node) {
|
||||
@@ -416,8 +494,14 @@ function isWidgetMethod(node) {
|
||||
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
|
||||
}
|
||||
|
||||
function sliderWithLocation(node) {
|
||||
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
|
||||
function sliderWithLocation(node, nodeOffset = 0) {
|
||||
// Apply nodeOffset for block-based evaluation to generate correct range
|
||||
const from = node.arguments[0].start + nodeOffset;
|
||||
const to = node.arguments[0].end + nodeOffset;
|
||||
|
||||
// Use range-based ID for stability during block evaluation
|
||||
const id = `${from}:${to}`;
|
||||
|
||||
// add loc as identifier to first argument
|
||||
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||
node.arguments.unshift({
|
||||
@@ -432,14 +516,26 @@ function sliderWithLocation(node) {
|
||||
export function getWidgetID(widgetConfig) {
|
||||
// the widget id is used as id for the dom element + as key for eventual resources
|
||||
// for example, for each scope widget, a new analyser + buffer (large) is created
|
||||
// that means, if we use the index index of line position as id, less garbage is generated
|
||||
// return `widget_${widgetConfig.to}`; // more gargabe
|
||||
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage
|
||||
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
|
||||
// Update: use range-based ID generation for better stability during block evaluation
|
||||
// When we have both from and to, use them together for stability
|
||||
// Otherwise fall back to position-based ID for backward compatibility
|
||||
let uniqueIdentifier;
|
||||
if (widgetConfig.from !== undefined && widgetConfig.to !== undefined) {
|
||||
// Use range for more stable identification
|
||||
uniqueIdentifier = `${widgetConfig.from}-${widgetConfig.to}`;
|
||||
} else {
|
||||
// Fallback to single position (for backward compatibility)
|
||||
uniqueIdentifier = widgetConfig.to || widgetConfig.from || 0;
|
||||
}
|
||||
const baseId = `${widgetConfig.id || ''}_widget_${widgetConfig.type}`;
|
||||
return `${baseId}_${widgetConfig.index}_${uniqueIdentifier}`;
|
||||
}
|
||||
|
||||
function widgetWithLocation(node, widgetConfig) {
|
||||
const id = getWidgetID(widgetConfig);
|
||||
// Store the unique ID back into the config so it's available for widget management
|
||||
// This is critical for block-based evaluation to match existing widgets with new ones
|
||||
widgetConfig.id = id;
|
||||
// add loc as identifier to first argument
|
||||
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||
node.arguments.unshift({
|
||||
@@ -454,6 +550,10 @@ function isBareSamplesCall(node, parent) {
|
||||
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
|
||||
}
|
||||
|
||||
function isAllCall(node) {
|
||||
return node.type === 'CallExpression' && node.callee.name === 'all';
|
||||
}
|
||||
|
||||
function withAwait(node) {
|
||||
return {
|
||||
type: 'AwaitExpression',
|
||||
@@ -554,6 +654,128 @@ function languageWithLocation(name, value, offset) {
|
||||
};
|
||||
}
|
||||
|
||||
// List of non-inline widgets that need cleanup
|
||||
// These are Pattern.prototype methods that create persistent visualizations
|
||||
// (should be repalced by a function call producing an actual list of registered widgets)
|
||||
const nonInlineWidgets = ['punchcard', 'spiral', 'scope', 'pitchwheel', 'spectrum', 'pianoroll', 'wordfall'];
|
||||
|
||||
function isVisualizerCall(node) {
|
||||
if (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
nonInlineWidgets.includes(node.callee.property?.name)
|
||||
) {
|
||||
return node.callee.property.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findVisualizerInSubtree(node) {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
|
||||
// Check if this node is a visualizer call
|
||||
const viz = isVisualizerCall(node);
|
||||
if (viz) return viz;
|
||||
|
||||
// Recursively search children
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === 'parent') continue; // Skip parent references to avoid cycles
|
||||
const child = node[key];
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
const found = findVisualizerInSubtree(item);
|
||||
if (found) return found;
|
||||
}
|
||||
} else if (child && typeof child === 'object' && child.type) {
|
||||
const found = findVisualizerInSubtree(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Creates AST nodes for: userDefinedKeys.add('name'); strudelScope.name = name; globalThis.name = name;
|
||||
// Used in block-based evaluation to persist variables/functions across blocks
|
||||
// We add to both strudelScope (for internal lookups) and globalThis (for direct access)
|
||||
// We also track the key in userDefinedKeys so clearScope() can remove it later
|
||||
function createScopeAssignment(name) {
|
||||
return [
|
||||
// userDefinedKeys.add('name');
|
||||
{
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
object: {
|
||||
type: 'Identifier',
|
||||
name: 'userDefinedKeys',
|
||||
},
|
||||
property: {
|
||||
type: 'Identifier',
|
||||
name: 'add',
|
||||
},
|
||||
computed: false,
|
||||
},
|
||||
arguments: [
|
||||
{
|
||||
type: 'Literal',
|
||||
value: name,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// strudelScope.name = name;
|
||||
{
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'AssignmentExpression',
|
||||
operator: '=',
|
||||
left: {
|
||||
type: 'MemberExpression',
|
||||
object: {
|
||||
type: 'Identifier',
|
||||
name: 'strudelScope',
|
||||
},
|
||||
property: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
},
|
||||
computed: false,
|
||||
},
|
||||
right: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
},
|
||||
},
|
||||
},
|
||||
// globalThis.name = name;
|
||||
{
|
||||
type: 'ExpressionStatement',
|
||||
expression: {
|
||||
type: 'AssignmentExpression',
|
||||
operator: '=',
|
||||
left: {
|
||||
type: 'MemberExpression',
|
||||
object: {
|
||||
type: 'Identifier',
|
||||
name: 'globalThis',
|
||||
},
|
||||
property: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
},
|
||||
computed: false,
|
||||
},
|
||||
right: {
|
||||
type: 'Identifier',
|
||||
name: name,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function findMiniDisableRanges(comments, codeEnd) {
|
||||
const ranges = [];
|
||||
const stack = []; // used to track on/off pairs
|
||||
|
||||
+38
-1
@@ -7,14 +7,51 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
import Tune from './tunejs.js';
|
||||
import { register } from '@strudel/core';
|
||||
|
||||
/**
|
||||
* Assumes pattern contains numerical scale degrees on the `i` control (see examples below). Accepts a scale name or list of frequencies (see all available names at the link on the reference). Returns a new pattern with all values mapped to a frequency ratio. Similar to `xen`.
|
||||
* @name tune
|
||||
* @returns Pattern
|
||||
* @memberof Pattern
|
||||
* @param {(string | number[] )} scale
|
||||
* @example
|
||||
* i("0 1 2 3 4 5").tune("hexany15").mul("220").freq()
|
||||
* @example
|
||||
* // You can set your root to be a
|
||||
* // particular note with getFreq:
|
||||
* i("4 8 9 10 - - 5 7 9 11 - -").tune("tranh3")
|
||||
* .mul(getFreq('c3'))
|
||||
* .freq().clip(.5).room(1)
|
||||
* @example
|
||||
* // You can also give tune a list of
|
||||
* // frequencies to use as the scale:
|
||||
* i("0 1 2 3 4").tune([
|
||||
* 261.6255653006,
|
||||
* 302.72962012827,
|
||||
* 350.29154279212,
|
||||
* 405.32593044476,
|
||||
* 469.00678383895,
|
||||
* 523.2511306012
|
||||
* ]).mul(220).freq();
|
||||
*
|
||||
* @tags tonal
|
||||
*/
|
||||
|
||||
// Tune.scale seems to be in ratio format
|
||||
export const tune = register('tune', (scale, pat) => {
|
||||
const tune = new Tune();
|
||||
if (!tune.isValidScale(scale)) {
|
||||
throw new Error('not a valid tune.js scale name: "' + scale + '". See http://abbernie.github.io/tune/scales.html');
|
||||
}
|
||||
tune.loadScale(scale);
|
||||
// if the tonic is a frequency, why are we putting in "1"
|
||||
tune.tonicize(1);
|
||||
return pat.withHap((hap) => {
|
||||
return hap.withValue(() => tune.note(hap.value));
|
||||
if (typeof hap.value !== 'object') {
|
||||
throw new Error(`Expected hap to have control 'i' set, but received ${hap.value.i}, try wrapping input in i()`);
|
||||
}
|
||||
// const { i, ...otherValues } = hap.value;
|
||||
// hap.value = { ...otherValues, freq: tune.note(i)}
|
||||
// return hap
|
||||
return hap.withValue(() => tune.note(hap.value.i));
|
||||
});
|
||||
});
|
||||
|
||||
+14
-4
@@ -78,14 +78,21 @@ Tune.prototype.frequency = function(stepIn, octaveIn) {
|
||||
}
|
||||
|
||||
// which scale degree (0 - scale length) is our input
|
||||
// 60 % 12 = 0
|
||||
var scaleDegree = stepIn % this.scale.length
|
||||
|
||||
// what's this doing
|
||||
// 0 scaleDegree = 12
|
||||
// seems to simply be for a negative result from above, maybe another way to do it, but ok for now
|
||||
while (scaleDegree < 0) {
|
||||
scaleDegree += this.scale.length
|
||||
}
|
||||
|
||||
// tonic is currently always 1
|
||||
// so this is 1*scale[scaledegree]
|
||||
var freq = this.tonic*this.scale[scaleDegree]
|
||||
|
||||
// map it to octave
|
||||
freq = freq*(Math.pow(2,octave))
|
||||
|
||||
// truncate irrational numbers
|
||||
@@ -137,10 +144,13 @@ Tune.prototype.MIDI = function(stepIn,octaveIn) {
|
||||
}
|
||||
|
||||
/* Load a new scale */
|
||||
// sets .scale to ratios
|
||||
|
||||
Tune.prototype.loadScale = function(scale){
|
||||
|
||||
/* load the scale */
|
||||
let name
|
||||
if (typeof scale === 'string') name = scale;
|
||||
var freqs = isArrayOfNumbers(scale) ? scale : TuningList[scale].frequencies
|
||||
this.scale = []
|
||||
for (var i=0;i<freqs.length-1;i++) {
|
||||
@@ -148,10 +158,10 @@ Tune.prototype.loadScale = function(scale){
|
||||
}
|
||||
|
||||
/* visualize in console */
|
||||
/* console.log(" ");
|
||||
console.log("LOADED "+name);
|
||||
console.log(TuningList[name].description);
|
||||
console.log(this.scale); */
|
||||
// console.log(" ");
|
||||
// console.log("LOADED "+name);
|
||||
// console.log(TuningList[name].description);
|
||||
// console.log(this.scale);
|
||||
var vis = [];
|
||||
for (var i=0;i<100;i++) {
|
||||
vis[i] = " ";
|
||||
|
||||
+176
-9
@@ -4,8 +4,10 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { register, _mod, parseNumeral } from '@strudel/core';
|
||||
import { register, _mod, parseNumeral, removeUndefineds } from '@strudel/core';
|
||||
import Tune from './tunejs.js';
|
||||
|
||||
// returns a list of frequency ratios for given edo scale
|
||||
export function edo(name) {
|
||||
if (!/^[1-9]+[0-9]*edo$/.test(name)) {
|
||||
throw new Error('not an edo scale: "' + name + '"');
|
||||
@@ -18,23 +20,33 @@ const presets = {
|
||||
'12ji': [1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4, 4 / 3, 45 / 32, 3 / 2, 8 / 5, 5 / 3, 16 / 9, 15 / 8],
|
||||
};
|
||||
|
||||
function withBase(freq, scale) {
|
||||
// Given a base frequency such as 220 and an edo scale, returns
|
||||
// an array of frequencies representing the given edo scale in that base
|
||||
function _withBase(freq, scale) {
|
||||
return scale.map((r) => r * freq);
|
||||
}
|
||||
|
||||
const defaultBase = 220;
|
||||
|
||||
const isEdo = (scale) => /^[1-9]+[0-9]*edo$/.test(scale);
|
||||
|
||||
// Assumes a base of 220. Returns a filtered scale based on 'indices'
|
||||
// NOTE: indices functionality is unused
|
||||
function getXenScale(scale, indices) {
|
||||
let tune = new Tune();
|
||||
if (typeof scale === 'string') {
|
||||
if (/^[1-9]+[0-9]*edo$/.test(scale)) {
|
||||
if (isEdo(scale)) {
|
||||
scale = edo(scale);
|
||||
} else if (presets[scale]) {
|
||||
scale = presets[scale];
|
||||
} else if (tune.isValidScale(scale)) {
|
||||
tune.loadScale(scale);
|
||||
scale = tune.scale;
|
||||
} else {
|
||||
throw new Error('unknown scale name: "' + scale + '"');
|
||||
}
|
||||
}
|
||||
scale = withBase(defaultBase, scale);
|
||||
scale = _withBase(defaultBase, scale);
|
||||
if (!indices) {
|
||||
return scale;
|
||||
}
|
||||
@@ -47,16 +59,171 @@ function xenOffset(xenScale, offset, index = 0) {
|
||||
return xenScale[i] * Math.pow(2, oct);
|
||||
}
|
||||
|
||||
const trimFreq = (freq) => parseFloat(freq.toPrecision(10));
|
||||
|
||||
// accepts a scale name such as 31edo, and a pattern
|
||||
// pattern expected to follow format such that a value can be mapped
|
||||
// to an edostep within the scale. Returns the pattern with
|
||||
// values mapped to the frequencies associated with the given edosteps
|
||||
// scaleNameOrRatios: string || number[], steps?: number
|
||||
|
||||
/**
|
||||
* Assumes a numerical pattern of scale steps, and a scale. Scales accepted are all preset scale names of `tune`, arbitrary edos such as 31edo, or an array of frequency ratios. Assumes scales repeat at octave (2/1). Returns a new pattern with all values mapped to their associated frequency, assuming a base frequency of 220hz.
|
||||
*
|
||||
* @name xen
|
||||
* @returns Pattern
|
||||
* @memberof Pattern
|
||||
* @param {(string | number[] )} scaleNameOrRatios
|
||||
* @tags tonal
|
||||
* @example
|
||||
* // A minor triad in 31edo:
|
||||
* i("0 8 18").xen("31edo").piano()
|
||||
* @example
|
||||
* // You can also use xen with frequency ratios.
|
||||
* // This is equivalent to the above:
|
||||
* i("0 1 2").xen([
|
||||
* Math.pow(2, 0/31),
|
||||
* Math.pow(2, 8/31),
|
||||
* Math.pow(2, 18/31),
|
||||
* ]).piano()
|
||||
* @example
|
||||
* // xen also supports all scale names that
|
||||
* // tune does:
|
||||
* i("0 1 2 3 4 5").xen("hexany15")
|
||||
* // equiv to:
|
||||
* // "0 1 2 3 4 5".tune("hexany15").mul("220").freq()
|
||||
* @example
|
||||
* i("0 1 2 3 4 5 6 7").xen("<5edo 10edo 15edo hexany15>")
|
||||
*/
|
||||
|
||||
export const xen = register('xen', function (scaleNameOrRatios, pat) {
|
||||
return pat.withHap((hap) => {
|
||||
const scale = getXenScale(scaleNameOrRatios);
|
||||
const frequency = xenOffset(scale, parseNumeral(hap.value));
|
||||
return hap.withValue(() => frequency);
|
||||
return pat.withHaps((haps) => {
|
||||
haps = haps.map((hap) => {
|
||||
let hVal = hap.value;
|
||||
const isObject = typeof hVal === 'object';
|
||||
if (!isObject) {
|
||||
throw new Error(`Expected hap to have control 'i' set, but received ${hap.value.i}, try wrapping input in i()`);
|
||||
}
|
||||
const { i, ...otherValues } = hVal;
|
||||
const scale = getXenScale(scaleNameOrRatios);
|
||||
let freq = xenOffset(scale, parseNumeral(hVal.i));
|
||||
// 10 is somewhat arbitrary
|
||||
freq = trimFreq(freq);
|
||||
hap.value = { ...otherValues, freq };
|
||||
return isEdo(scaleNameOrRatios)
|
||||
? hap.setContext({ ...hap.context, edoSize: scaleNameOrRatios.match(/^([1-9]+[0-9]*)edo$/)[1] })
|
||||
: hap;
|
||||
});
|
||||
return removeUndefineds(haps);
|
||||
});
|
||||
});
|
||||
|
||||
export const tuning = register('tuning', function (ratios, pat) {
|
||||
/**
|
||||
* Assumes pattern of frequencies tuned to some `base` frequency, such as the output of `xen`
|
||||
* Because `xen` defaults to `220Hz`, so will `withBase`.
|
||||
* but you can specify a different original base with the standard optional array syntax '`:`'
|
||||
* @name withBase
|
||||
* @param {number} base
|
||||
* @param {number} (optional) originalBase
|
||||
* @tags tonal
|
||||
*
|
||||
* @example
|
||||
* i("[0 1 2 3] [3 4] [4 3 2 1]").xen("hexany23").withBase("<220 [300 200]>")
|
||||
* @example
|
||||
* mini([1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4].join(' ')).withBase("220:1")
|
||||
* // mini([1 / 1, 16 / 15, 9 / 8, 6 / 5, 5 / 4].join(' ')).mul(220).freq()
|
||||
*
|
||||
* @returns Pattern
|
||||
*/
|
||||
export const withBase = register('withBase', (b, pat) => {
|
||||
let base;
|
||||
let originalBase = 220;
|
||||
if (Array.isArray(b)) {
|
||||
base = b[0];
|
||||
originalBase = b[1];
|
||||
} else {
|
||||
base = b;
|
||||
}
|
||||
return pat.withHaps((haps) => {
|
||||
haps = haps.map((hap) => {
|
||||
let hVal = hap.value;
|
||||
const isObject = typeof hVal === 'object';
|
||||
let freq = isObject ? hVal.freq : hVal;
|
||||
freq = (freq * base) / originalBase;
|
||||
hap.value = isObject ? { ...hap.value, freq } : { freq };
|
||||
return hap;
|
||||
});
|
||||
return removeUndefineds(haps);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Frequency transpose. Assumes pattern either has `freq` set, or has values that can be interpreted as frequencies
|
||||
* amt has optional `edoSize` param, defaults to 12.
|
||||
* If haps have edoSize param set, such as from the output of `xen("31edo")`,
|
||||
* `ftrans` will fallback to that instead of 12 as the default.
|
||||
*
|
||||
* Transposes the frequency by `amt` edoSteps
|
||||
* @name ftranspose
|
||||
* @synonyms ftrans, fTrans, ftranspose, fTranspose
|
||||
* @tags tonal
|
||||
* @param {number} amt
|
||||
* @param {number} edoSize (optional)
|
||||
* @returns {Pattern}
|
||||
*
|
||||
* @example
|
||||
* i("0 1 2").xen("12edo").ftrans("7")
|
||||
* // n("0 1 2").scale("A:chromatic").trans("7")
|
||||
* @example
|
||||
* i("0 8 18").xen("31edo").ftrans("<8 -8>")
|
||||
* @example
|
||||
* // to transpose by steps of an edo, use "step:edo" :
|
||||
* i("0 7 8 18").xen("31edo").ftrans("<0 1:31 1:12>")
|
||||
* @example
|
||||
* // it can also work with frequency values directly
|
||||
* freq("200 300 400").ftrans("<0 7:31 7>")
|
||||
*/
|
||||
|
||||
/* f = frequency (Hz)
|
||||
n = edo (steps per octave)
|
||||
x = number of steps
|
||||
if 0\n = f, then x\n = f * 2^(x/n)
|
||||
example: 5edo, 0\5 = 220 Hz, then 2\5 = 220*2^(2/5) = 290.29 Hz */
|
||||
|
||||
export const { ftrans, fTrans, ftranspose, fTranspose } = register(
|
||||
['ftrans', 'fTrans', 'ftranspose', 'fTranspose'],
|
||||
(amt, pat) => {
|
||||
let edoSize;
|
||||
let numSteps;
|
||||
if (Array.isArray(amt)) {
|
||||
edoSize = amt[1];
|
||||
numSteps = amt[0];
|
||||
} else {
|
||||
numSteps = amt;
|
||||
}
|
||||
return pat.withHaps((haps) => {
|
||||
haps = haps.map((hap) => {
|
||||
let hVal = hap.value;
|
||||
const isObject = typeof hVal === 'object';
|
||||
hVal = isObject ? hVal : { freq: hVal };
|
||||
let { freq, ...otherValues } = hVal;
|
||||
if (edoSize == undefined && hap.context.edoSize != undefined) {
|
||||
edoSize = hap.context.edoSize;
|
||||
} else if (edoSize == undefined) {
|
||||
edoSize = 12;
|
||||
}
|
||||
freq = freq * Math.pow(2, numSteps / edoSize);
|
||||
freq = trimFreq(freq);
|
||||
hap.value = isObject ? { ...otherValues, freq } : freq;
|
||||
return hap.setContext({ ...hap.context, edoSize });
|
||||
});
|
||||
return removeUndefineds(haps);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// not sure there's a point to having this and the above, seems like a proto version of the above.
|
||||
const tuning = register('tuning', function (ratios, pat) {
|
||||
return pat.withHap((hap) => {
|
||||
const frequency = xenOffset(ratios, parseNumeral(hap.value));
|
||||
return hap.withValue(() => frequency);
|
||||
|
||||
Generated
+20
@@ -221,6 +221,9 @@ importers:
|
||||
'@tonaljs/tonal':
|
||||
specifier: ^4.10.0
|
||||
version: 4.10.0
|
||||
codemirror-helix:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
|
||||
nanostores:
|
||||
specifier: ^0.11.3
|
||||
version: 0.11.3
|
||||
@@ -3551,6 +3554,15 @@ packages:
|
||||
resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
codemirror-helix@0.5.0:
|
||||
resolution: {integrity: sha512-hI56hf9VGz53H1YvL6H1GC7HtP6te8vX+MsIHaE9J7Q3PQ6KFapKtIRg6lqSH898ikHWpMCPu42r6HJN0IfVLA==}
|
||||
peerDependencies:
|
||||
'@codemirror/commands': ^6.0.0
|
||||
'@codemirror/language': ^6.0.0
|
||||
'@codemirror/search': ^6.0.0
|
||||
'@codemirror/state': ^6.0.0
|
||||
'@codemirror/view': ^6.0.0
|
||||
|
||||
collapse-white-space@2.1.0:
|
||||
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
|
||||
|
||||
@@ -11280,6 +11292,14 @@ snapshots:
|
||||
|
||||
cmd-shim@6.0.3: {}
|
||||
|
||||
codemirror-helix@0.5.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2):
|
||||
dependencies:
|
||||
'@codemirror/commands': 6.8.0
|
||||
'@codemirror/language': 6.10.8
|
||||
'@codemirror/search': 6.5.8
|
||||
'@codemirror/state': 6.5.1
|
||||
'@codemirror/view': 6.36.2
|
||||
|
||||
collapse-white-space@2.1.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
|
||||
@@ -1037,6 +1037,43 @@ exports[`runs examples > example "anchor" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "anchor" example index 1 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | anchor:c4 note:60 ]",
|
||||
"[ 1/8 → 1/4 | anchor:c4 note:62 ]",
|
||||
"[ 1/4 → 3/8 | anchor:c4 note:64 ]",
|
||||
"[ 3/8 → 1/2 | anchor:c4 note:65 ]",
|
||||
"[ 1/2 → 5/8 | anchor:c4 note:67 ]",
|
||||
"[ 5/8 → 3/4 | anchor:c4 note:69 ]",
|
||||
"[ 3/4 → 7/8 | anchor:c4 note:71 ]",
|
||||
"[ 7/8 → 1/1 | anchor:c4 note:72 ]",
|
||||
"[ 1/1 → 9/8 | anchor:g4 note:67 ]",
|
||||
"[ 9/8 → 5/4 | anchor:g4 note:68 ]",
|
||||
"[ 5/4 → 11/8 | anchor:g4 note:70 ]",
|
||||
"[ 11/8 → 3/2 | anchor:g4 note:72 ]",
|
||||
"[ 3/2 → 13/8 | anchor:g4 note:73 ]",
|
||||
"[ 13/8 → 7/4 | anchor:g4 note:75 ]",
|
||||
"[ 7/4 → 15/8 | anchor:g4 note:77 ]",
|
||||
"[ 15/8 → 2/1 | anchor:g4 note:79 ]",
|
||||
"[ 2/1 → 17/8 | anchor:c5 note:72 ]",
|
||||
"[ 17/8 → 9/4 | anchor:c5 note:74 ]",
|
||||
"[ 9/4 → 19/8 | anchor:c5 note:76 ]",
|
||||
"[ 19/8 → 5/2 | anchor:c5 note:77 ]",
|
||||
"[ 5/2 → 21/8 | anchor:c5 note:79 ]",
|
||||
"[ 21/8 → 11/4 | anchor:c5 note:81 ]",
|
||||
"[ 11/4 → 23/8 | anchor:c5 note:83 ]",
|
||||
"[ 23/8 → 3/1 | anchor:c5 note:84 ]",
|
||||
"[ 3/1 → 25/8 | anchor:g5 note:79 ]",
|
||||
"[ 25/8 → 13/4 | anchor:g5 note:80 ]",
|
||||
"[ 13/4 → 27/8 | anchor:g5 note:82 ]",
|
||||
"[ 27/8 → 7/2 | anchor:g5 note:84 ]",
|
||||
"[ 7/2 → 29/8 | anchor:g5 note:85 ]",
|
||||
"[ 29/8 → 15/4 | anchor:g5 note:87 ]",
|
||||
"[ 15/4 → 31/8 | anchor:g5 note:89 ]",
|
||||
"[ 31/8 → 4/1 | anchor:g5 note:91 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "apply" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/1 | note:C3 ]",
|
||||
@@ -4193,6 +4230,27 @@ exports[`runs examples > example "extend" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "fadeTime" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/4 | s:oh end:0.1 fadeTime:0 ]",
|
||||
"[ 1/4 → 1/2 | s:oh end:0.1 fadeTime:0 ]",
|
||||
"[ 1/2 → 3/4 | s:oh end:0.1 fadeTime:0 ]",
|
||||
"[ 3/4 → 1/1 | s:oh end:0.1 fadeTime:0 ]",
|
||||
"[ 1/1 → 5/4 | s:oh end:0.1 fadeTime:0.2 ]",
|
||||
"[ 5/4 → 3/2 | s:oh end:0.1 fadeTime:0.2 ]",
|
||||
"[ 3/2 → 7/4 | s:oh end:0.1 fadeTime:0.2 ]",
|
||||
"[ 7/4 → 2/1 | s:oh end:0.1 fadeTime:0.2 ]",
|
||||
"[ 2/1 → 9/4 | s:oh end:0.1 fadeTime:0.4 ]",
|
||||
"[ 9/4 → 5/2 | s:oh end:0.1 fadeTime:0.4 ]",
|
||||
"[ 5/2 → 11/4 | s:oh end:0.1 fadeTime:0.4 ]",
|
||||
"[ 11/4 → 3/1 | s:oh end:0.1 fadeTime:0.4 ]",
|
||||
"[ 3/1 → 13/4 | s:oh end:0.1 fadeTime:0.8 ]",
|
||||
"[ 13/4 → 7/2 | s:oh end:0.1 fadeTime:0.8 ]",
|
||||
"[ 7/2 → 15/4 | s:oh end:0.1 fadeTime:0.8 ]",
|
||||
"[ 15/4 → 4/1 | s:oh end:0.1 fadeTime:0.8 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "fanchor" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | note:f s:sawtooth cutoff:1000 lpenv:8 fanchor:0 ]",
|
||||
@@ -4892,6 +4950,78 @@ exports[`runs examples > example "fscope" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "ftranspose" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/3 | freq:329.6275569 ]",
|
||||
"[ 1/3 → 2/3 | freq:349.2282315 ]",
|
||||
"[ 2/3 → 1/1 | freq:369.9944227 ]",
|
||||
"[ 1/1 → 4/3 | freq:329.6275569 ]",
|
||||
"[ 4/3 → 5/3 | freq:349.2282315 ]",
|
||||
"[ 5/3 → 2/1 | freq:369.9944227 ]",
|
||||
"[ 2/1 → 7/3 | freq:329.6275569 ]",
|
||||
"[ 7/3 → 8/3 | freq:349.2282315 ]",
|
||||
"[ 8/3 → 3/1 | freq:369.9944227 ]",
|
||||
"[ 3/1 → 10/3 | freq:329.6275569 ]",
|
||||
"[ 10/3 → 11/3 | freq:349.2282315 ]",
|
||||
"[ 11/3 → 4/1 | freq:369.9944227 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "ftranspose" example index 1 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/3 | freq:263.0921203 ]",
|
||||
"[ 1/3 → 2/3 | freq:314.6248353 ]",
|
||||
"[ 2/3 → 1/1 | freq:393.4589706 ]",
|
||||
"[ 1/1 → 4/3 | freq:183.965981 ]",
|
||||
"[ 4/3 → 5/3 | freq:220 ]",
|
||||
"[ 5/3 → 2/1 | freq:275.1244143 ]",
|
||||
"[ 2/1 → 7/3 | freq:263.0921203 ]",
|
||||
"[ 7/3 → 8/3 | freq:314.6248353 ]",
|
||||
"[ 8/3 → 3/1 | freq:393.4589706 ]",
|
||||
"[ 3/1 → 10/3 | freq:183.965981 ]",
|
||||
"[ 10/3 → 11/3 | freq:220 ]",
|
||||
"[ 11/3 → 4/1 | freq:275.1244143 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "ftranspose" example index 2 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/4 | freq:220 ]",
|
||||
"[ 1/4 → 1/2 | freq:257.2747684 ]",
|
||||
"[ 1/2 → 3/4 | freq:263.0921203 ]",
|
||||
"[ 3/4 → 1/1 | freq:329.0139341 ]",
|
||||
"[ 1/1 → 5/4 | freq:224.9745158 ]",
|
||||
"[ 5/4 → 3/2 | freq:263.0921203 ]",
|
||||
"[ 3/2 → 7/4 | freq:269.0410108 ]",
|
||||
"[ 7/4 → 2/1 | freq:336.4534115 ]",
|
||||
"[ 2/1 → 9/4 | freq:233.0818808 ]",
|
||||
"[ 9/4 → 5/2 | freq:272.5731222 ]",
|
||||
"[ 5/2 → 11/4 | freq:278.7363919 ]",
|
||||
"[ 11/4 → 3/1 | freq:348.5781207 ]",
|
||||
"[ 3/1 → 13/4 | freq:220 ]",
|
||||
"[ 13/4 → 7/2 | freq:257.2747684 ]",
|
||||
"[ 7/2 → 15/4 | freq:263.0921203 ]",
|
||||
"[ 15/4 → 4/1 | freq:329.0139341 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "ftranspose" example index 3 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/3 | freq:200 ]",
|
||||
"[ 1/3 → 2/3 | freq:300 ]",
|
||||
"[ 2/3 → 1/1 | freq:400 ]",
|
||||
"[ 1/1 → 4/3 | freq:233.8861531 ]",
|
||||
"[ 4/3 → 5/3 | freq:350.8292297 ]",
|
||||
"[ 5/3 → 2/1 | freq:467.7723062 ]",
|
||||
"[ 2/1 → 7/3 | freq:299.6614154 ]",
|
||||
"[ 7/3 → 8/3 | freq:449.4921231 ]",
|
||||
"[ 8/3 → 3/1 | freq:599.3228308 ]",
|
||||
"[ 3/1 → 10/3 | freq:200 ]",
|
||||
"[ 10/3 → 11/3 | freq:300 ]",
|
||||
"[ 11/3 → 4/1 | freq:400 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "ftype" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | note:f s:sawtooth lpenv:4 cutoff:500 ftype:0 resonance:1 ]",
|
||||
@@ -5576,6 +5706,43 @@ exports[`runs examples > example "hush" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "i" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | freq:220 ]",
|
||||
"[ 1/8 → 1/4 | freq:252.7136381 ]",
|
||||
"[ 1/4 → 3/8 | freq:290.2917404 ]",
|
||||
"[ 3/8 → 1/2 | freq:333.4576446 ]",
|
||||
"[ 1/2 → 5/8 | freq:383.0422479 ]",
|
||||
"[ 5/8 → 3/4 | freq:440 ]",
|
||||
"[ 3/4 → 7/8 | freq:505.4272762 ]",
|
||||
"[ 7/8 → 1/1 | freq:580.5834807 ]",
|
||||
"[ 1/1 → 9/8 | freq:220 ]",
|
||||
"[ 9/8 → 5/4 | freq:235.7901618 ]",
|
||||
"[ 5/4 → 11/8 | freq:252.7136381 ]",
|
||||
"[ 11/8 → 3/2 | freq:270.8517709 ]",
|
||||
"[ 3/2 → 13/8 | freq:290.2917404 ]",
|
||||
"[ 13/8 → 7/4 | freq:311.1269837 ]",
|
||||
"[ 7/4 → 15/8 | freq:333.4576446 ]",
|
||||
"[ 15/8 → 2/1 | freq:357.3910544 ]",
|
||||
"[ 2/1 → 17/8 | freq:220 ]",
|
||||
"[ 17/8 → 9/4 | freq:230.404707 ]",
|
||||
"[ 9/4 → 19/8 | freq:241.3014955 ]",
|
||||
"[ 19/8 → 5/2 | freq:252.7136381 ]",
|
||||
"[ 5/2 → 21/8 | freq:264.6655079 ]",
|
||||
"[ 21/8 → 11/4 | freq:277.182631 ]",
|
||||
"[ 11/4 → 23/8 | freq:290.2917404 ]",
|
||||
"[ 23/8 → 3/1 | freq:304.0208336 ]",
|
||||
"[ 3/1 → 25/8 | freq:220 ]",
|
||||
"[ 25/8 → 13/4 | freq:275 ]",
|
||||
"[ 13/4 → 27/8 | freq:293.3333333 ]",
|
||||
"[ 27/8 → 7/2 | freq:330 ]",
|
||||
"[ 7/2 → 29/8 | freq:352 ]",
|
||||
"[ 29/8 → 15/4 | freq:440 ]",
|
||||
"[ 15/4 → 31/8 | freq:550 ]",
|
||||
"[ 31/8 → 4/1 | freq:586.6666667 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "inhabit" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | s:bd ]",
|
||||
@@ -8099,6 +8266,48 @@ exports[`runs examples > example "panchor" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "panspan" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/4 | s:bd pan:0.5 panspan:0 ]",
|
||||
"[ 1/4 → 1/2 | s:hh pan:0.5 panspan:0 ]",
|
||||
"[ 1/2 → 3/4 | s:bd pan:0.5 panspan:0 ]",
|
||||
"[ 3/4 → 1/1 | s:hh pan:0.5 panspan:0 ]",
|
||||
"[ 1/1 → 5/4 | s:bd pan:1 panspan:0.5 ]",
|
||||
"[ 5/4 → 3/2 | s:hh pan:1 panspan:0.5 ]",
|
||||
"[ 3/2 → 7/4 | s:bd pan:1 panspan:0.5 ]",
|
||||
"[ 7/4 → 2/1 | s:hh pan:1 panspan:0.5 ]",
|
||||
"[ 2/1 → 9/4 | s:bd pan:0.5 panspan:1 ]",
|
||||
"[ 9/4 → 5/2 | s:hh pan:0.5 panspan:1 ]",
|
||||
"[ 5/2 → 11/4 | s:bd pan:0.5 panspan:1 ]",
|
||||
"[ 11/4 → 3/1 | s:hh pan:0.5 panspan:1 ]",
|
||||
"[ 3/1 → 13/4 | s:bd pan:0 panspan:0 ]",
|
||||
"[ 13/4 → 7/2 | s:hh pan:0 panspan:0 ]",
|
||||
"[ 7/2 → 15/4 | s:bd pan:0 panspan:0 ]",
|
||||
"[ 15/4 → 4/1 | s:hh pan:0 panspan:0 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "pansplay" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/4 | s:bd pan:0.5 pansplay:0 ]",
|
||||
"[ 1/4 → 1/2 | s:hh pan:0.5 pansplay:0 ]",
|
||||
"[ 1/2 → 3/4 | s:bd pan:0.5 pansplay:0 ]",
|
||||
"[ 3/4 → 1/1 | s:hh pan:0.5 pansplay:0 ]",
|
||||
"[ 1/1 → 5/4 | s:bd pan:1 pansplay:0.5 ]",
|
||||
"[ 5/4 → 3/2 | s:hh pan:1 pansplay:0.5 ]",
|
||||
"[ 3/2 → 7/4 | s:bd pan:1 pansplay:0.5 ]",
|
||||
"[ 7/4 → 2/1 | s:hh pan:1 pansplay:0.5 ]",
|
||||
"[ 2/1 → 9/4 | s:bd pan:0.5 pansplay:1 ]",
|
||||
"[ 9/4 → 5/2 | s:hh pan:0.5 pansplay:1 ]",
|
||||
"[ 5/2 → 11/4 | s:bd pan:0.5 pansplay:1 ]",
|
||||
"[ 11/4 → 3/1 | s:hh pan:0.5 pansplay:1 ]",
|
||||
"[ 3/1 → 13/4 | s:bd pan:0 pansplay:0 ]",
|
||||
"[ 13/4 → 7/2 | s:hh pan:0 pansplay:0 ]",
|
||||
"[ 7/2 → 15/4 | s:bd pan:0 pansplay:0 ]",
|
||||
"[ 15/4 → 4/1 | s:hh pan:0 pansplay:0 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "partials" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/16 | s:user note:A3 partials:[1 0 1 0 0 1] ]",
|
||||
@@ -8723,6 +8932,33 @@ exports[`runs examples > example "pickF" example index 1 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "pickF" example index 2 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | note:c2 s:square pan:0 ]",
|
||||
"[ 1/8 → 1/4 | note:c2 s:square pan:1 ]",
|
||||
"[ 3/8 → 1/2 | note:c2 s:square pan:0 ]",
|
||||
"[ 1/2 → 9/16 | note:d2 s:square ]",
|
||||
"[ 11/16 → 3/4 | note:d2 s:square ]",
|
||||
"[ 7/8 → 15/16 | note:d2 s:square ]",
|
||||
"[ 1/1 → 9/8 | note:d2 s:square cutoff:800 ]",
|
||||
"[ 11/8 → 3/2 | note:d2 s:square cutoff:800 ]",
|
||||
"[ 3/2 → 25/16 | note:d2 s:square ]",
|
||||
"[ 27/16 → 7/4 | note:d2 s:square ]",
|
||||
"[ 15/8 → 31/16 | note:d2 s:square ]",
|
||||
"[ 2/1 → 17/8 | note:c2 s:square pan:0 ]",
|
||||
"[ 17/8 → 9/4 | note:c2 s:square pan:1 ]",
|
||||
"[ 19/8 → 5/2 | note:c2 s:square pan:0 ]",
|
||||
"[ 5/2 → 41/16 | note:d2 s:square ]",
|
||||
"[ 43/16 → 11/4 | note:d2 s:square ]",
|
||||
"[ 23/8 → 47/16 | note:d2 s:square ]",
|
||||
"[ 3/1 → 25/8 | note:d2 s:square cutoff:800 ]",
|
||||
"[ 27/8 → 7/2 | note:d2 s:square cutoff:800 ]",
|
||||
"[ 7/2 → 57/16 | note:d2 s:square ]",
|
||||
"[ 59/16 → 15/4 | note:d2 s:square ]",
|
||||
"[ 31/8 → 63/16 | note:d2 s:square ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "pickmodRestart" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/4 | note:C3 s:piano ]",
|
||||
@@ -13088,6 +13324,97 @@ exports[`runs examples > example "tri" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "tune" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/6 | freq:{i:0} ]",
|
||||
"[ 1/6 → 1/3 | freq:{i:1} ]",
|
||||
"[ 1/3 → 1/2 | freq:{i:2} ]",
|
||||
"[ 1/2 → 2/3 | freq:{i:3} ]",
|
||||
"[ 2/3 → 5/6 | freq:{i:4} ]",
|
||||
"[ 5/6 → 1/1 | freq:{i:5} ]",
|
||||
"[ 1/1 → 7/6 | freq:{i:0} ]",
|
||||
"[ 7/6 → 4/3 | freq:{i:1} ]",
|
||||
"[ 4/3 → 3/2 | freq:{i:2} ]",
|
||||
"[ 3/2 → 5/3 | freq:{i:3} ]",
|
||||
"[ 5/3 → 11/6 | freq:{i:4} ]",
|
||||
"[ 11/6 → 2/1 | freq:{i:5} ]",
|
||||
"[ 2/1 → 13/6 | freq:{i:0} ]",
|
||||
"[ 13/6 → 7/3 | freq:{i:1} ]",
|
||||
"[ 7/3 → 5/2 | freq:{i:2} ]",
|
||||
"[ 5/2 → 8/3 | freq:{i:3} ]",
|
||||
"[ 8/3 → 17/6 | freq:{i:4} ]",
|
||||
"[ 17/6 → 3/1 | freq:{i:5} ]",
|
||||
"[ 3/1 → 19/6 | freq:{i:0} ]",
|
||||
"[ 19/6 → 10/3 | freq:{i:1} ]",
|
||||
"[ 10/3 → 7/2 | freq:{i:2} ]",
|
||||
"[ 7/2 → 11/3 | freq:{i:3} ]",
|
||||
"[ 11/3 → 23/6 | freq:{i:4} ]",
|
||||
"[ 23/6 → 4/1 | freq:{i:5} ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "tune" example index 1 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/12 | freq:{i:4} clip:0.5 room:1 ]",
|
||||
"[ 1/12 → 1/6 | freq:{i:8} clip:0.5 room:1 ]",
|
||||
"[ 1/6 → 1/4 | freq:{i:9} clip:0.5 room:1 ]",
|
||||
"[ 1/4 → 1/3 | freq:{i:10} clip:0.5 room:1 ]",
|
||||
"[ 1/2 → 7/12 | freq:{i:5} clip:0.5 room:1 ]",
|
||||
"[ 7/12 → 2/3 | freq:{i:7} clip:0.5 room:1 ]",
|
||||
"[ 2/3 → 3/4 | freq:{i:9} clip:0.5 room:1 ]",
|
||||
"[ 3/4 → 5/6 | freq:{i:11} clip:0.5 room:1 ]",
|
||||
"[ 1/1 → 13/12 | freq:{i:4} clip:0.5 room:1 ]",
|
||||
"[ 13/12 → 7/6 | freq:{i:8} clip:0.5 room:1 ]",
|
||||
"[ 7/6 → 5/4 | freq:{i:9} clip:0.5 room:1 ]",
|
||||
"[ 5/4 → 4/3 | freq:{i:10} clip:0.5 room:1 ]",
|
||||
"[ 3/2 → 19/12 | freq:{i:5} clip:0.5 room:1 ]",
|
||||
"[ 19/12 → 5/3 | freq:{i:7} clip:0.5 room:1 ]",
|
||||
"[ 5/3 → 7/4 | freq:{i:9} clip:0.5 room:1 ]",
|
||||
"[ 7/4 → 11/6 | freq:{i:11} clip:0.5 room:1 ]",
|
||||
"[ 2/1 → 25/12 | freq:{i:4} clip:0.5 room:1 ]",
|
||||
"[ 25/12 → 13/6 | freq:{i:8} clip:0.5 room:1 ]",
|
||||
"[ 13/6 → 9/4 | freq:{i:9} clip:0.5 room:1 ]",
|
||||
"[ 9/4 → 7/3 | freq:{i:10} clip:0.5 room:1 ]",
|
||||
"[ 5/2 → 31/12 | freq:{i:5} clip:0.5 room:1 ]",
|
||||
"[ 31/12 → 8/3 | freq:{i:7} clip:0.5 room:1 ]",
|
||||
"[ 8/3 → 11/4 | freq:{i:9} clip:0.5 room:1 ]",
|
||||
"[ 11/4 → 17/6 | freq:{i:11} clip:0.5 room:1 ]",
|
||||
"[ 3/1 → 37/12 | freq:{i:4} clip:0.5 room:1 ]",
|
||||
"[ 37/12 → 19/6 | freq:{i:8} clip:0.5 room:1 ]",
|
||||
"[ 19/6 → 13/4 | freq:{i:9} clip:0.5 room:1 ]",
|
||||
"[ 13/4 → 10/3 | freq:{i:10} clip:0.5 room:1 ]",
|
||||
"[ 7/2 → 43/12 | freq:{i:5} clip:0.5 room:1 ]",
|
||||
"[ 43/12 → 11/3 | freq:{i:7} clip:0.5 room:1 ]",
|
||||
"[ 11/3 → 15/4 | freq:{i:9} clip:0.5 room:1 ]",
|
||||
"[ 15/4 → 23/6 | freq:{i:11} clip:0.5 room:1 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "tune" example index 2 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/5 | freq:{i:0} ]",
|
||||
"[ 1/5 → 2/5 | freq:{i:1} ]",
|
||||
"[ 2/5 → 3/5 | freq:{i:2} ]",
|
||||
"[ 3/5 → 4/5 | freq:{i:3} ]",
|
||||
"[ 4/5 → 1/1 | freq:{i:4} ]",
|
||||
"[ 1/1 → 6/5 | freq:{i:0} ]",
|
||||
"[ 6/5 → 7/5 | freq:{i:1} ]",
|
||||
"[ 7/5 → 8/5 | freq:{i:2} ]",
|
||||
"[ 8/5 → 9/5 | freq:{i:3} ]",
|
||||
"[ 9/5 → 2/1 | freq:{i:4} ]",
|
||||
"[ 2/1 → 11/5 | freq:{i:0} ]",
|
||||
"[ 11/5 → 12/5 | freq:{i:1} ]",
|
||||
"[ 12/5 → 13/5 | freq:{i:2} ]",
|
||||
"[ 13/5 → 14/5 | freq:{i:3} ]",
|
||||
"[ 14/5 → 3/1 | freq:{i:4} ]",
|
||||
"[ 3/1 → 16/5 | freq:{i:0} ]",
|
||||
"[ 16/5 → 17/5 | freq:{i:1} ]",
|
||||
"[ 17/5 → 18/5 | freq:{i:2} ]",
|
||||
"[ 18/5 → 19/5 | freq:{i:3} ]",
|
||||
"[ 19/5 → 4/1 | freq:{i:4} ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "undegrade" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | s:hh ]",
|
||||
@@ -13861,6 +14188,76 @@ exports[`runs examples > example "when" example index 0 1`] = `
|
||||
|
||||
exports[`runs examples > example "whenKey" example index 0 1`] = `[]`;
|
||||
|
||||
exports[`runs examples > example "withBase" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/12 | freq:220 ]",
|
||||
"[ 1/12 → 1/6 | freq:293.3333333 ]",
|
||||
"[ 1/6 → 1/4 | freq:302.5 ]",
|
||||
"[ 1/4 → 1/3 | freq:320 ]",
|
||||
"[ 1/3 → 1/2 | freq:320 ]",
|
||||
"[ 1/2 → 2/3 | freq:330 ]",
|
||||
"[ 2/3 → 3/4 | freq:330 ]",
|
||||
"[ 3/4 → 5/6 | freq:320 ]",
|
||||
"[ 5/6 → 11/12 | freq:302.5 ]",
|
||||
"[ 11/12 → 1/1 | freq:293.3333333 ]",
|
||||
"[ 1/1 → 13/12 | freq:300 ]",
|
||||
"[ 13/12 → 7/6 | freq:399.99999995454544 ]",
|
||||
"[ 7/6 → 5/4 | freq:412.5 ]",
|
||||
"[ 5/4 → 4/3 | freq:436.3636363636364 ]",
|
||||
"[ 4/3 → 3/2 | freq:436.3636363636364 ]",
|
||||
"[ 3/2 → 5/3 | freq:300 ]",
|
||||
"[ 5/3 → 7/4 | freq:300 ]",
|
||||
"[ 7/4 → 11/6 | freq:290.90909090909093 ]",
|
||||
"[ 11/6 → 23/12 | freq:275 ]",
|
||||
"[ 23/12 → 2/1 | freq:266.66666663636363 ]",
|
||||
"[ 2/1 → 25/12 | freq:220 ]",
|
||||
"[ 25/12 → 13/6 | freq:293.3333333 ]",
|
||||
"[ 13/6 → 9/4 | freq:302.5 ]",
|
||||
"[ 9/4 → 7/3 | freq:320 ]",
|
||||
"[ 7/3 → 5/2 | freq:320 ]",
|
||||
"[ 5/2 → 8/3 | freq:330 ]",
|
||||
"[ 8/3 → 11/4 | freq:330 ]",
|
||||
"[ 11/4 → 17/6 | freq:320 ]",
|
||||
"[ 17/6 → 35/12 | freq:302.5 ]",
|
||||
"[ 35/12 → 3/1 | freq:293.3333333 ]",
|
||||
"[ 3/1 → 37/12 | freq:300 ]",
|
||||
"[ 37/12 → 19/6 | freq:399.99999995454544 ]",
|
||||
"[ 19/6 → 13/4 | freq:412.5 ]",
|
||||
"[ 13/4 → 10/3 | freq:436.3636363636364 ]",
|
||||
"[ 10/3 → 7/2 | freq:436.3636363636364 ]",
|
||||
"[ 7/2 → 11/3 | freq:300 ]",
|
||||
"[ 11/3 → 15/4 | freq:300 ]",
|
||||
"[ 15/4 → 23/6 | freq:290.90909090909093 ]",
|
||||
"[ 23/6 → 47/12 | freq:275 ]",
|
||||
"[ 47/12 → 4/1 | freq:266.66666663636363 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "withBase" example index 1 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/5 | freq:220 ]",
|
||||
"[ 1/5 → 2/5 | freq:234.66666666666666 ]",
|
||||
"[ 2/5 → 3/5 | freq:247.5 ]",
|
||||
"[ 3/5 → 4/5 | freq:264 ]",
|
||||
"[ 4/5 → 1/1 | freq:275 ]",
|
||||
"[ 1/1 → 6/5 | freq:220 ]",
|
||||
"[ 6/5 → 7/5 | freq:234.66666666666666 ]",
|
||||
"[ 7/5 → 8/5 | freq:247.5 ]",
|
||||
"[ 8/5 → 9/5 | freq:264 ]",
|
||||
"[ 9/5 → 2/1 | freq:275 ]",
|
||||
"[ 2/1 → 11/5 | freq:220 ]",
|
||||
"[ 11/5 → 12/5 | freq:234.66666666666666 ]",
|
||||
"[ 12/5 → 13/5 | freq:247.5 ]",
|
||||
"[ 13/5 → 14/5 | freq:264 ]",
|
||||
"[ 14/5 → 3/1 | freq:275 ]",
|
||||
"[ 3/1 → 16/5 | freq:220 ]",
|
||||
"[ 16/5 → 17/5 | freq:234.66666666666666 ]",
|
||||
"[ 17/5 → 18/5 | freq:247.5 ]",
|
||||
"[ 18/5 → 19/5 | freq:264 ]",
|
||||
"[ 19/5 → 4/1 | freq:275 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "withValue" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/3 | 10 ]",
|
||||
@@ -14000,6 +14397,106 @@ exports[`runs examples > example "wtphaserand" example index 0 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "xen" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
|
||||
"[ 1/3 → 2/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
|
||||
"[ 2/3 → 1/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
|
||||
"[ 1/1 → 4/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
|
||||
"[ 4/3 → 5/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
|
||||
"[ 5/3 → 2/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
|
||||
"[ 2/1 → 7/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
|
||||
"[ 7/3 → 8/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
|
||||
"[ 8/3 → 3/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
|
||||
"[ 3/1 → 10/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
|
||||
"[ 10/3 → 11/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
|
||||
"[ 11/3 → 4/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "xen" example index 1 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
|
||||
"[ 1/3 → 2/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
|
||||
"[ 2/3 → 1/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
|
||||
"[ 1/1 → 4/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
|
||||
"[ 4/3 → 5/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
|
||||
"[ 5/3 → 2/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
|
||||
"[ 2/1 → 7/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
|
||||
"[ 7/3 → 8/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
|
||||
"[ 8/3 → 3/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
|
||||
"[ 3/1 → 10/3 | freq:220 clip:1 s:piano release:0.1 pan:0.5138888888888888 ]",
|
||||
"[ 10/3 → 11/3 | freq:263.0921203 clip:1 s:piano release:0.1 pan:0.5277777777777778 ]",
|
||||
"[ 11/3 → 4/1 | freq:329.0139341 clip:1 s:piano release:0.1 pan:0.5462962962962963 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "xen" example index 2 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/6 | freq:220 ]",
|
||||
"[ 1/6 → 1/3 | freq:275 ]",
|
||||
"[ 1/3 → 1/2 | freq:293.3333333 ]",
|
||||
"[ 1/2 → 2/3 | freq:330 ]",
|
||||
"[ 2/3 → 5/6 | freq:352 ]",
|
||||
"[ 5/6 → 1/1 | freq:440 ]",
|
||||
"[ 1/1 → 7/6 | freq:220 ]",
|
||||
"[ 7/6 → 4/3 | freq:275 ]",
|
||||
"[ 4/3 → 3/2 | freq:293.3333333 ]",
|
||||
"[ 3/2 → 5/3 | freq:330 ]",
|
||||
"[ 5/3 → 11/6 | freq:352 ]",
|
||||
"[ 11/6 → 2/1 | freq:440 ]",
|
||||
"[ 2/1 → 13/6 | freq:220 ]",
|
||||
"[ 13/6 → 7/3 | freq:275 ]",
|
||||
"[ 7/3 → 5/2 | freq:293.3333333 ]",
|
||||
"[ 5/2 → 8/3 | freq:330 ]",
|
||||
"[ 8/3 → 17/6 | freq:352 ]",
|
||||
"[ 17/6 → 3/1 | freq:440 ]",
|
||||
"[ 3/1 → 19/6 | freq:220 ]",
|
||||
"[ 19/6 → 10/3 | freq:275 ]",
|
||||
"[ 10/3 → 7/2 | freq:293.3333333 ]",
|
||||
"[ 7/2 → 11/3 | freq:330 ]",
|
||||
"[ 11/3 → 23/6 | freq:352 ]",
|
||||
"[ 23/6 → 4/1 | freq:440 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "xen" example index 3 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | freq:220 ]",
|
||||
"[ 1/8 → 1/4 | freq:252.7136381 ]",
|
||||
"[ 1/4 → 3/8 | freq:290.2917404 ]",
|
||||
"[ 3/8 → 1/2 | freq:333.4576446 ]",
|
||||
"[ 1/2 → 5/8 | freq:383.0422479 ]",
|
||||
"[ 5/8 → 3/4 | freq:440 ]",
|
||||
"[ 3/4 → 7/8 | freq:505.4272762 ]",
|
||||
"[ 7/8 → 1/1 | freq:580.5834807 ]",
|
||||
"[ 1/1 → 9/8 | freq:220 ]",
|
||||
"[ 9/8 → 5/4 | freq:235.7901618 ]",
|
||||
"[ 5/4 → 11/8 | freq:252.7136381 ]",
|
||||
"[ 11/8 → 3/2 | freq:270.8517709 ]",
|
||||
"[ 3/2 → 13/8 | freq:290.2917404 ]",
|
||||
"[ 13/8 → 7/4 | freq:311.1269837 ]",
|
||||
"[ 7/4 → 15/8 | freq:333.4576446 ]",
|
||||
"[ 15/8 → 2/1 | freq:357.3910544 ]",
|
||||
"[ 2/1 → 17/8 | freq:220 ]",
|
||||
"[ 17/8 → 9/4 | freq:230.404707 ]",
|
||||
"[ 9/4 → 19/8 | freq:241.3014955 ]",
|
||||
"[ 19/8 → 5/2 | freq:252.7136381 ]",
|
||||
"[ 5/2 → 21/8 | freq:264.6655079 ]",
|
||||
"[ 21/8 → 11/4 | freq:277.182631 ]",
|
||||
"[ 11/4 → 23/8 | freq:290.2917404 ]",
|
||||
"[ 23/8 → 3/1 | freq:304.0208336 ]",
|
||||
"[ 3/1 → 25/8 | freq:220 ]",
|
||||
"[ 25/8 → 13/4 | freq:275 ]",
|
||||
"[ 13/4 → 27/8 | freq:293.3333333 ]",
|
||||
"[ 27/8 → 7/2 | freq:330 ]",
|
||||
"[ 7/2 → 29/8 | freq:352 ]",
|
||||
"[ 29/8 → 15/4 | freq:440 ]",
|
||||
"[ 15/4 → 31/8 | freq:550 ]",
|
||||
"[ 31/8 → 4/1 | freq:586.6666667 ]",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`runs examples > example "xfade" example index 0 1`] = `
|
||||
[
|
||||
"[ 0/1 → 1/8 | s:hh gain:0 ]",
|
||||
|
||||
@@ -20,6 +20,7 @@ const skippedExamples = [
|
||||
'accelerationX',
|
||||
'defaultmidimap',
|
||||
'midimaps',
|
||||
'clearScope',
|
||||
'bmod',
|
||||
];
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import AstroPWA from '@vite-pwa/astro';
|
||||
|
||||
const site = process.env.STRUDEL_SITE || `https://strudel.cc/`; // root url without a path
|
||||
const base = process.env.STRUDEL_BASE || '/'; // base path of the strudel site
|
||||
import process from 'node:process';
|
||||
|
||||
const site = process.env.SITE_URL || `https://strudel.cc/`; // root url without a path
|
||||
const base = process.env.BASE_PATH || ''; // base path of the strudel site
|
||||
|
||||
const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||
|
||||
// this rehype plugin fixes relative links
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Loader from '@src/repl/components/Loader';
|
||||
import { HorizontalPanel } from '@src/repl/components/panel/Panel';
|
||||
import { BottomPanel } from '@src/repl/components/panel/Panel';
|
||||
import { Code } from '@src/repl/components/Code';
|
||||
import BigPlayButton from '@src/repl/components/BigPlayButton';
|
||||
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
|
||||
@@ -20,7 +20,7 @@ export default function UdelsEditor(Props) {
|
||||
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
|
||||
</div>
|
||||
<UserFacingErrorMessage error={error} />
|
||||
<HorizontalPanel context={context} />
|
||||
<BottomPanel context={context} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export const SIDEBAR: Sidebar = {
|
||||
Understand: [
|
||||
{ text: 'Coding syntax', link: 'learn/code' },
|
||||
{ text: 'Pitch', link: 'understand/pitch' },
|
||||
{ text: 'Xen Harmonic Functions', link: 'learn/xen' },
|
||||
{ text: 'Xenharmonic Functions', link: 'learn/xen' },
|
||||
{ text: 'Cycles', link: 'understand/cycles' },
|
||||
{ text: 'Voicings', link: 'understand/voicings' },
|
||||
{ text: 'Pattern Alignment', link: 'technical-manual/alignment' },
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
---
|
||||
title: Xen Harmonic Functions
|
||||
title: Xenharmonic Functions
|
||||
layout: ../../layouts/MainLayout.astro
|
||||
---
|
||||
|
||||
import { MiniRepl } from '../../docs/MiniRepl';
|
||||
import { JsDoc } from '../../docs/JsDoc';
|
||||
|
||||
# Xen Harmonic Functions
|
||||
# Xenharmonic Functions (experimental)
|
||||
|
||||
{/* TODO expand explanation of xenharmony */}
|
||||
|
||||
These functions allow the use of scales other than your typical chromatic 12 based ones.
|
||||
|
||||
### tune(scale)
|
||||
|
||||
{/* TODO (maybe): combine jsdoc things in tune.mjs with here */}
|
||||
|
||||
<JsDoc client:idle name="tune" h={0} />
|
||||
|
||||
Here's an example of how to configure a basic hexany scale:
|
||||
|
||||
<MiniRepl client:idle tune={`"0 1 2 3 4 5".tune("hexany15").mul("220").freq()`} />
|
||||
<MiniRepl client:idle tune={`i("0 1 2 3 4 5").tune("hexany15").mul("220").freq()`} />
|
||||
|
||||
Try other scales like `hexany1`, `iraq`, `gumbeng`, `gunkali`, or `tranh3`
|
||||
|
||||
@@ -26,7 +30,7 @@ You can set your root to be a particular note with `getFreq`
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`"4 8 9 10 - - 5 7 9 11 - -".tune("tranh3")
|
||||
tune={`i("4 8 9 10 - - 5 7 9 11 - -").tune("tranh3")
|
||||
.mul(getFreq('c3'))
|
||||
.freq().clip(.5).room(1)`}
|
||||
/>
|
||||
@@ -35,7 +39,7 @@ Some tunings become more pronounced with a longer reverb decay:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`"<[5 6 8 10] - [5 7 9 12] -> -".tune("gumbeng")
|
||||
tune={`i("<[5 6 8 10] - [5 7 9 12] -> -").tune("gumbeng")
|
||||
.mul(getFreq('c3'))
|
||||
.freq().clip(.8).room("3:10").rdim(10000).rfade(5)`}
|
||||
/>
|
||||
@@ -44,7 +48,7 @@ Additionally, you can combo this with `fmap` so that the base note changes:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`"9 11 12 10 - - -".tune("gunkali")
|
||||
tune={`i("9 11 12 10 - - -").tune("gunkali")
|
||||
.mul("<c3 c3 a3 d#3>".fmap(getFreq))
|
||||
.freq().legato("2 .7").room("1:15").rdim(8500).rlp(14000).rfade(8)`}
|
||||
/>
|
||||
@@ -53,8 +57,7 @@ Combining this with various polyrhythm tricks can become very evocative:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`"<[0 3 1 -] [-1 4 2 8]> ~ ~,<-4 -5>"
|
||||
.transpose(4)
|
||||
tune={`i("<[0 3 1 -] [-1 4 2 8]> ~ ~,<-4 -5>".add(4))
|
||||
.tune("iraq")
|
||||
.mul("<c3 d3 c#3>".fmap(getFreq))
|
||||
.freq().clip(.5).room(1).rfade(9)`}
|
||||
@@ -67,7 +70,7 @@ Take the `sanza` tuning:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`"4 5 6 7 8 9".tune("sanza")
|
||||
tune={`i("4 5 6 7 8 9").tune("sanza")
|
||||
.mul(getFreq('c3'))
|
||||
.freq()`}
|
||||
/>
|
||||
@@ -75,15 +78,15 @@ Take the `sanza` tuning:
|
||||
Notes 7 and 9 will clash quite a bit if you arp them normally. Many tunings will have this sort of sound, and it can feel distracting on its own.
|
||||
See how close they are on the pitch wheel?
|
||||
|
||||
<MiniRepl client:idle tune={`"[7 9]!3".tune("sanza").mul(getFreq('c3')).freq()._pitchwheel()`} />
|
||||
<MiniRepl client:idle tune={`i("[7 9]!3").tune("sanza").mul(getFreq('c3')).freq()._pitchwheel()`} />
|
||||
|
||||
This quality is often due to how the tunings were formed with instruments that were played differently than a piano.
|
||||
As such, some tunings are much better strummed, with the subtle clash of the detuned notes actually making the sound much more magical:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`"[0 1 2 3 4 5 6]@0.3 -"
|
||||
.transpose("<2 5 8 1>")
|
||||
tune={`i("[0 1 2 3 4 5 6]@0.3 -"
|
||||
.add("<2 5 8 1>"))
|
||||
.tune("sanza")
|
||||
.mul(getFreq('c3')).freq()
|
||||
.legato("3").room(1).rfade(5)`}
|
||||
@@ -93,3 +96,23 @@ Note the legato and reverb effects make sure the sound of the strumming gets to
|
||||
tones sound even more alive, too.
|
||||
|
||||
The `tranh3` tuning has a similar set of notes, with two clashing. You might trying plugging that in above and see if you find a favorite strumming pattern.
|
||||
|
||||
You can also give tune a list of frequencies to use as the scale:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`i("0 1 2 3 4").tune([
|
||||
261.6255653006,
|
||||
302.72962012827,
|
||||
350.29154279212,
|
||||
405.32593044476,
|
||||
469.00678383895,
|
||||
523.2511306012
|
||||
]).mul(220).freq();`}
|
||||
/>
|
||||
|
||||
### xen(scaleOrRatios)
|
||||
|
||||
{/* TODO add explanation of EDO to documentation */}
|
||||
|
||||
<JsDoc client:idle name="Pattern.xen" h={0} />
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Helix Keybindings
|
||||
layout: ../../layouts/MainLayout.astro
|
||||
---
|
||||
|
||||
# Helix Keybindings in the REPL
|
||||
|
||||
The Strudel REPL supports [Helix editor](https://helix-editor.com/) keybindings through the `codemirror-helix` extension. Helix is a post-modern modal text editor with a focus on selection-first editing and multiple cursors.
|
||||
|
||||
## Enabling Helix Mode
|
||||
|
||||
1. Open the Settings panel in the REPL (click the settings icon or use the keyboard shortcut)
|
||||
2. Find the "Keybindings" section
|
||||
3. Select "Helix" from the available options
|
||||
|
||||
## Key Differences from Vim
|
||||
|
||||
Helix uses a selection-first approach, which means:
|
||||
|
||||
- **Selection → Action** (Helix) vs **Action → Motion** (Vim)
|
||||
- For example, to delete a word in Helix: `w` (select word) → `d` (delete)
|
||||
- In Vim, it would be: `dw` (delete word)
|
||||
|
||||
## Common Helix Commands
|
||||
|
||||
### Normal Mode
|
||||
|
||||
- `i` — Enter insert mode before selection
|
||||
- `a` — Enter insert mode after selection
|
||||
- `v` — Enter visual/select mode
|
||||
- `w` — Select next word
|
||||
- `e` — Select to end of word
|
||||
- `b` — Select previous word
|
||||
- `x` — Select/extend line
|
||||
- `d` — Delete selection
|
||||
- `c` — Change selection (delete and enter insert mode)
|
||||
- `y` — Yank (copy) selection
|
||||
- `p` — Paste after selection
|
||||
- `u` — Undo
|
||||
- `U` — Redo
|
||||
- `/` — Search
|
||||
- `n` — Select next search match
|
||||
- `N` — Select previous search match
|
||||
|
||||
### Movement
|
||||
|
||||
- `h, j, k, l` — Move left, down, up, right
|
||||
- `gg` — Go to start of document
|
||||
- `ge` — Go to end of document
|
||||
- `{` / `}` — Move to previous/next paragraph
|
||||
- `%` — Match bracket
|
||||
|
||||
### Multi-cursor
|
||||
|
||||
- `C` — Duplicate cursor to line below
|
||||
- `Alt-C` — Duplicate cursor to line above
|
||||
- `,` — Remove primary cursor
|
||||
- `Alt-,` — Remove all secondary cursors
|
||||
|
||||
## Strudel-Specific Features
|
||||
|
||||
Currently, Helix mode in Strudel provides standard Helix keybindings. Unlike Vim mode, there are no custom Strudel-specific commands (like `:w` to evaluate) yet.
|
||||
|
||||
To evaluate code while in Helix mode, use the standard shortcuts:
|
||||
|
||||
- `Ctrl+Enter` or `Alt+Enter` — Evaluate code
|
||||
- `Alt+.` — Stop playback
|
||||
|
||||
## Resources
|
||||
|
||||
- [Helix Documentation](https://docs.helix-editor.com/)
|
||||
- [Helix Keymap](https://docs.helix-editor.com/keymap.html)
|
||||
- [codemirror-helix on npm](https://www.npmjs.com/package/codemirror-helix)
|
||||
- [codemirror-helix on GitLab](https://gitlab.com/_rvidal/codemirror-helix)
|
||||
|
||||
## Notes
|
||||
|
||||
- The `codemirror-helix` extension is described as an "initial version" and may not implement all Helix features
|
||||
- Behavior may differ slightly from the native Helix editor
|
||||
- If you encounter issues, you can switch back to other keybinding modes in Settings
|
||||
- The keybinding preference is saved in your browser's local storage
|
||||
|
||||
## Contributing
|
||||
|
||||
If you'd like to add Strudel-specific Helix commands (similar to Vim's `:w` for evaluate), contributions are welcome! See the implementation in `/packages/codemirror/keybindings.mjs` for reference.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Code } from '@src/repl/components/Code';
|
||||
import Loader from '@src/repl/components/Loader';
|
||||
import { HorizontalPanel, MainPanel, VerticalPanel } from '@src/repl/components/panel/Panel';
|
||||
import { BottomPanel, MainPanel, RightPanel } from '@src/repl/components/panel/Panel';
|
||||
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
|
||||
import { useSettings } from '@src/settings.mjs';
|
||||
|
||||
@@ -21,13 +21,13 @@ export default function ReplEditor(Props) {
|
||||
<div className="flex flex-col grow overflow-hidden">
|
||||
{/* <MainPanel context={context} isEmbedded={isEmbedded} className="hidden sm:block" /> */}
|
||||
<MainPanel context={context} isEmbedded={isEmbedded} />
|
||||
<div className="flex overflow-hidden">
|
||||
<div className="flex overflow-hidden h-full">
|
||||
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
|
||||
{!isZen && panelPosition === 'right' && <VerticalPanel context={context} />}
|
||||
{!isZen && panelPosition === 'right' && <RightPanel context={context} />}
|
||||
</div>
|
||||
</div>
|
||||
<UserFacingErrorMessage error={error} />
|
||||
{!isZen && panelPosition === 'bottom' && <HorizontalPanel context={context} />}
|
||||
{!isZen && panelPosition === 'bottom' && <BottomPanel context={context} />}
|
||||
{/* <MainPanel context={context} isEmbedded={isEmbedded} className="block sm:hidden" /> */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,21 @@ import cx from '@src/cx.mjs';
|
||||
import { useSettings } from '../../../settings.mjs';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $strudel_log_history } from '../useLogger';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function ConsoleTab() {
|
||||
const log = useStore($strudel_log_history);
|
||||
const { fontFamily } = useSettings();
|
||||
const scrollRef = useRef();
|
||||
// scroll to bottom when log changes
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [log]);
|
||||
return (
|
||||
<div id="console-tab" className="break-all w-full h-full" style={{ fontFamily }}>
|
||||
<div className="h-full w-full overflow-auto space-y-1 p-2 rounded-md">
|
||||
<div className="h-full w-full overflow-auto space-y-1 p-2 rounded-md" ref={scrollRef}>
|
||||
{' '}
|
||||
{/* bg-background */}
|
||||
{log.map((l, i) => {
|
||||
@@ -18,12 +26,13 @@ export function ConsoleTab() {
|
||||
<div
|
||||
key={l.id}
|
||||
className={cx(
|
||||
'whitespace-nowrap',
|
||||
l.type === 'error' ? 'text-background bg-foreground' : 'text-foreground',
|
||||
l.type === 'highlight' && 'underline',
|
||||
)}
|
||||
style={color ? { color } : {}}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: message }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: message }} className="whitespace-nowrap" />
|
||||
{l.count ? ` (${l.count})` : ''}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,11 +48,8 @@ export function MainPanel({ context, isEmbedded = false, className }) {
|
||||
<nav
|
||||
id="header"
|
||||
className={cx(
|
||||
//'border-t sm:border-b sm:border-t-0 border-muted',
|
||||
'border-b border-muted',
|
||||
'flex-none text-black z-[100] text-sm select-none min-h-10 max-h-10',
|
||||
!isZen && !isEmbedded && 'bg-lineHighlight',
|
||||
// isZen ? 'h-12 w-8 fixed top-0 left-0' : 'h-10 sticky top-0 w-full justify-between',
|
||||
!isZen && !isEmbedded && 'border-b border-muted bg-lineHighlight',
|
||||
isZen ? 'h-12 w-8 fixed top-0 left-0' : '',
|
||||
'flex items-center',
|
||||
className,
|
||||
@@ -60,8 +57,6 @@ export function MainPanel({ context, isEmbedded = false, className }) {
|
||||
style={{ fontFamily }}
|
||||
>
|
||||
<div className={cx('flex w-full justify-between')}>
|
||||
{' '}
|
||||
{/*flex-wrap*/}
|
||||
<div className="px-3 py-1 flex space-x-2 select-none">
|
||||
<h1
|
||||
onClick={() => {
|
||||
@@ -77,11 +72,6 @@ export function MainPanel({ context, isEmbedded = false, className }) {
|
||||
<div className="space-x-2 flex items-baseline">
|
||||
<span className="hidden sm:block">strudel</span>
|
||||
<span className="text-sm font-medium hidden sm:block">REPL</span>
|
||||
{/* !isEmbedded && isButtonRowHidden && (
|
||||
<a href={`${baseNoTrailing}/learn`} className="text-sm opacity-25 font-medium">
|
||||
DOCS
|
||||
</a>
|
||||
) */}
|
||||
</div>
|
||||
)}
|
||||
</h1>
|
||||
@@ -89,7 +79,6 @@ export function MainPanel({ context, isEmbedded = false, className }) {
|
||||
{!isZen && (
|
||||
<div className="flex grow justify-end">
|
||||
{!isButtonRowHidden && <MainMenu isEmbedded={isEmbedded} context={context} />}
|
||||
{/* className="hidden sm:flex" */}
|
||||
<PanelToggle isEmbedded={isEmbedded} isZen={isZen} />
|
||||
</div>
|
||||
)}
|
||||
@@ -101,7 +90,6 @@ export function MainPanel({ context, isEmbedded = false, className }) {
|
||||
export function Footer({ context, isEmbedded = false }) {
|
||||
return (
|
||||
<div className="border-t border-muted bg-lineHighlight block lg:hidden">
|
||||
{/* block lg:hidden */}
|
||||
<MainMenu context={context} isEmbedded={isEmbedded} />
|
||||
</div>
|
||||
);
|
||||
@@ -117,15 +105,10 @@ function MainMenu({ context, isEmbedded = false, className }) {
|
||||
title={started ? 'stop' : 'play'}
|
||||
className={cx('px-2 hover:opacity-50', !started && !isCSSAnimationDisabled && 'animate-pulse')}
|
||||
>
|
||||
{/* {!pending ? ( */}
|
||||
<span className={cx('flex items-center space-x-2')}>
|
||||
{/* {started ? <StopCircleIcon className="w-5 h-5" /> : <PlayCircleIcon className="w-5 h-5" />} */}
|
||||
{started ? <StopIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
|
||||
{!isEmbedded && <span>{started ? 'stop' : 'play'}</span>}
|
||||
{!isEmbedded && <span>{pending ? '...' : started ? 'stop' : 'play'}</span>}
|
||||
</span>
|
||||
{/* ) : (
|
||||
<>loading...</>
|
||||
)} */}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEvaluate}
|
||||
@@ -162,7 +145,7 @@ function PanelCloseButton() {
|
||||
isPanelOpen && (
|
||||
<button
|
||||
onClick={() => setIsPanelOpened(false)}
|
||||
className={cx('border-l border-muted px-2 py-0 text-foreground hover:opacity-50')}
|
||||
className={cx('px-2 py-0 text-foreground hover:opacity-50')}
|
||||
aria-label="Close Menu"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
@@ -171,7 +154,7 @@ function PanelCloseButton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function HorizontalPanel({ context }) {
|
||||
export function BottomPanel({ context }) {
|
||||
const { isPanelOpen, activeFooter: tab } = useSettings();
|
||||
return (
|
||||
<PanelNav
|
||||
@@ -182,7 +165,7 @@ export function HorizontalPanel({ context }) {
|
||||
>
|
||||
<div className="flex justify-between min-h-10 max-h-10 grid-cols-2 items-center border-t border-muted">
|
||||
<PanelCloseButton />
|
||||
<Tabs setTab={setTab} tab={tab} />
|
||||
<Tabs setTab={setTab} tab={tab} className={cx(isPanelOpen && 'border-l border-muted')} />
|
||||
</div>
|
||||
{isPanelOpen && (
|
||||
<div className="w-full h-full overflow-auto border-t border-muted">
|
||||
@@ -193,7 +176,7 @@ export function HorizontalPanel({ context }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function VerticalPanel({ context }) {
|
||||
export function RightPanel({ context }) {
|
||||
const settings = useSettings();
|
||||
const { activeFooter: tab, isPanelOpen } = settings;
|
||||
if (!isPanelOpen) {
|
||||
@@ -203,24 +186,16 @@ export function VerticalPanel({ context }) {
|
||||
<PanelNav
|
||||
settings={settings}
|
||||
className={cx(
|
||||
//'border-l border-muted shrink-0',
|
||||
'border-0 border-muted shrink-0 h-full overflow-hidden',
|
||||
isPanelOpen
|
||||
? //? `min-w-full max-w-full lg:min-w-[min(600px,100vw)] lg:max-w-[min(600px,80vw)]`
|
||||
`min-w-[min(600px,100vw)] max-w-[min(600px,80vw)]`
|
||||
: 'min-w-12 max-w-12',
|
||||
'border-l border-muted shrink-0 h-full overflow-hidden',
|
||||
isPanelOpen ? `min-w-[min(600px,100vw)] max-w-[min(600px,80vw)]` : 'min-w-12 max-w-12',
|
||||
)}
|
||||
>
|
||||
<div className={cx('flex flex-col h-full')}>
|
||||
<div className="flex justify-between w-full overflow-hidden border-b border-muted min-h-10 max-h-10">
|
||||
{/* <div className="block sm:hidden text-foreground px-3 py-2 border-l border-muted">
|
||||
<LogoButton context={context} />
|
||||
</div> */}
|
||||
<PanelCloseButton />
|
||||
<Tabs setTab={setTab} tab={tab} />
|
||||
{/* <PanelCloseButton /> */}
|
||||
<Tabs setTab={setTab} tab={tab} className="border-l border-muted" />
|
||||
</div>
|
||||
<div className="overflow-auto h-full border-l border-muted">
|
||||
<div className="overflow-auto h-full">
|
||||
<PanelContent context={context} tab={tab} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,7 +276,7 @@ function Tabs({ className }) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'px-2 border-l border-muted w-full flex select-none max-w-full h-10 max-h-10 min-h-10 overflow-auto items-center',
|
||||
'px-2 w-full flex select-none max-w-full h-10 max-h-10 min-h-10 overflow-auto items-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -320,14 +295,10 @@ export function PanelToggle({ isEmbedded, isZen }) {
|
||||
!isZen &&
|
||||
panelPosition === 'right' && (
|
||||
<button
|
||||
title="share"
|
||||
className={cx(
|
||||
'border-l border-muted px-2 py-0 text-foreground hover:opacity-50' /* , isPanelOpen && 'hidden' */,
|
||||
)}
|
||||
title="menu"
|
||||
className={cx('border-l border-muted px-2 py-0 text-foreground hover:opacity-50')}
|
||||
onClick={() => setIsPanelOpened(!isPanelOpen)}
|
||||
>
|
||||
{/* <span>menu</span> */}
|
||||
{/* isPanelOpen ? <XMarkIcon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" /> */}
|
||||
<Bars3Icon className="w-6 h-6" />
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,11 @@ import jsdocJson from '../../../../../doc.json';
|
||||
import { Textbox } from '@src/repl/components/panel/SettingsTab';
|
||||
import { settingsMap, useSettings } from '@src/settings.mjs';
|
||||
|
||||
const isValid = ({ name, description }) => name && !name.startsWith('_') && !!description;
|
||||
const isValid = ({ name, description, tags = [] }) => {
|
||||
const isSupradoughOnly = tags.includes('supradough') && !tags.includes('superdough');
|
||||
const isSuperdirtOnly = tags.includes('superdirt') && !tags.includes('superdough');
|
||||
return name && !name.startsWith('_') && !!description && !isSupradoughOnly && !isSuperdirtOnly;
|
||||
};
|
||||
|
||||
const availableFunctions = (() => {
|
||||
const seen = new Set(); // avoid repetition
|
||||
@@ -14,36 +18,32 @@ const availableFunctions = (() => {
|
||||
if (seen.has(doc.name)) continue;
|
||||
|
||||
// jsdoc also uses "tags" for when you use @something in the comments and it doesn't know what
|
||||
// @something is. We only want data from comments like `@tags fx, superdough` here.
|
||||
// @something is. We only want data from comments like `@tags superdough` here.
|
||||
// If nothing is specified, we default to "untagged" for debugging
|
||||
doc.tags = doc.tags?.filter((t) => t && typeof t === 'string') || ['untagged'];
|
||||
functions.push(doc);
|
||||
|
||||
const synonyms = doc.synonyms || [];
|
||||
let names = [doc.name];
|
||||
seen.add(doc.name);
|
||||
for (const s of synonyms) {
|
||||
if (!s || seen.has(s)) continue;
|
||||
names.push(s);
|
||||
seen.add(s);
|
||||
// Swap `doc.name` in for `s` in the list of synonyms
|
||||
const synonymsWithDoc = [doc.name, ...synonyms].filter((x) => x && x !== s);
|
||||
functions.push({
|
||||
...doc,
|
||||
name: s, // update names for the synonym
|
||||
longname: s,
|
||||
synonyms: synonymsWithDoc,
|
||||
synonyms_text: synonymsWithDoc.join(', '),
|
||||
});
|
||||
}
|
||||
doc.allNames = names.join(' ');
|
||||
doc.synonyms = names.slice(1);
|
||||
functions.push(doc);
|
||||
}
|
||||
return functions.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
|
||||
})();
|
||||
|
||||
const tagCounts = {};
|
||||
const ignoredTags = ['supradough', 'superdirt'];
|
||||
// const tagOptions = { all: `all (${availableFunctions.length})` };
|
||||
const tagOptions = { all: `all` };
|
||||
for (const doc of availableFunctions) {
|
||||
(doc.tags || ['untagged']).forEach((t) => {
|
||||
if (typeof t === 'string' && t) {
|
||||
if (typeof t === 'string' && t && !ignoredTags.includes(t)) {
|
||||
tagCounts[t] = (tagCounts[t] || 0) + 1;
|
||||
//tagOptions[t] = `${t} (${tagCounts[t]})`;
|
||||
tagOptions[t] = t;
|
||||
@@ -82,7 +82,7 @@ export const Reference = memo(function Reference() {
|
||||
}
|
||||
const lowerCaseSearch = search.toLowerCase();
|
||||
return (
|
||||
entry.name.toLowerCase().includes(lowerCaseSearch) ||
|
||||
(entry.allNames || entry.name).toLowerCase().includes(lowerCaseSearch) ||
|
||||
(entry.synonyms?.some((s) => s.toLowerCase().includes(lowerCaseSearch)) ?? false)
|
||||
);
|
||||
});
|
||||
@@ -151,7 +151,7 @@ export const Reference = memo(function Reference() {
|
||||
<Fragment key={`entry-${entry.name}`}>
|
||||
<a
|
||||
className={
|
||||
'cursor-pointer hover:opacity-50 text-ellipsis block' +
|
||||
'whitespace-nowrap cursor-pointer hover:opacity-50 text-ellipsis block' +
|
||||
(entry.name === selectedFunction ? 'bg-lineHighlight font-bold' : '')
|
||||
}
|
||||
onClick={() => {
|
||||
@@ -162,7 +162,7 @@ export const Reference = memo(function Reference() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{entry.name}
|
||||
{entry.name} {entry.synonyms && <small className="opacity-50">{entry.synonyms?.join(', ')}</small>}
|
||||
</a>{' '}
|
||||
</Fragment>
|
||||
))}
|
||||
@@ -204,7 +204,7 @@ export const Reference = memo(function Reference() {
|
||||
</h3>
|
||||
{entry.tags && (
|
||||
<span className="ml-2 text-xs text-foreground border border-muted px-1 py-0.5">
|
||||
{entry.tags.join(', ')}
|
||||
{entry.tags.filter((t) => !ignoredTags.includes(t)).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -150,6 +150,7 @@ export function SettingsTab({ started }) {
|
||||
isMultiCursorEnabled,
|
||||
patternAutoStart,
|
||||
includePrebakeScriptInShare,
|
||||
isBlockBasedEvalEnabled,
|
||||
} = useSettings();
|
||||
const shouldAlwaysSync = isUdels();
|
||||
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
||||
@@ -245,7 +246,7 @@ export function SettingsTab({ started }) {
|
||||
<ButtonGroup
|
||||
value={keybindings}
|
||||
onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)}
|
||||
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
|
||||
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', helix: 'Helix', vscode: 'VSCode' }}
|
||||
></ButtonGroup>
|
||||
</FormItem>
|
||||
<FormItem label="Panel Position">
|
||||
@@ -306,6 +307,11 @@ export function SettingsTab({ started }) {
|
||||
onChange={(cbEvent) => settingsMap.setKey('isMultiCursorEnabled', cbEvent.target.checked)}
|
||||
value={isMultiCursorEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Enable Block-based Evaluation (EXPERIMENTAL)"
|
||||
onChange={(cbEvent) => settingsMap.setKey('isBlockBasedEvalEnabled', cbEvent.target.checked)}
|
||||
value={isBlockBasedEvalEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Enable flashing on evaluation"
|
||||
onChange={(cbEvent) => settingsMap.setKey('isFlashEnabled', cbEvent.target.checked)}
|
||||
|
||||
@@ -21,7 +21,7 @@ function getUpdatedLog(log, event) {
|
||||
} else {
|
||||
log = log.concat([{ message, type, id, data }]);
|
||||
}
|
||||
return log.slice(-20);
|
||||
return log.slice(-40);
|
||||
}
|
||||
|
||||
export function useLogger() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { registerSampleSource } from '@strudel/webaudio';
|
||||
import { extractMidiNoteFromString, registerSampleSource } from '@strudel/webaudio';
|
||||
import { isAudioFile } from './files.mjs';
|
||||
import { logger } from '@strudel/core';
|
||||
|
||||
@@ -47,7 +47,7 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
|
||||
Promise.all(
|
||||
[...soundFiles]
|
||||
.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }))
|
||||
.map((soundFile, i) => {
|
||||
.map((soundFile) => {
|
||||
const title = soundFile.title;
|
||||
if (!isAudioFile(title)) {
|
||||
return;
|
||||
@@ -58,25 +58,26 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
|
||||
splitRelativePath[splitRelativePath.length - 2] ?? soundFile.id.split(/\W+/)[0] ?? 'user';
|
||||
const blob = soundFile.blob;
|
||||
|
||||
return blobToDataUrl(blob).then((soundPath) => {
|
||||
const titlePathMap = sounds.get(parentDirectory) ?? new Map();
|
||||
return blobToDataUrl(blob).then((path) => {
|
||||
const sampleInfoMap = sounds.get(parentDirectory) ?? new Map();
|
||||
const midi = extractMidiNoteFromString(title);
|
||||
/** @type {import('@strudel/webaudio').SampleMetaData} */
|
||||
const samplemetadata = { url: path, midi };
|
||||
sampleInfoMap.set(title, samplemetadata);
|
||||
|
||||
titlePathMap.set(title, soundPath);
|
||||
|
||||
sounds.set(parentDirectory, titlePathMap);
|
||||
sounds.set(parentDirectory, sampleInfoMap);
|
||||
return;
|
||||
});
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
sounds.forEach((titlePathMap, key) => {
|
||||
const value = Array.from(titlePathMap.keys())
|
||||
sounds.forEach((sampleInfoMap, key) => {
|
||||
const bank = Array.from(sampleInfoMap.keys())
|
||||
.sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((title) => titlePathMap.get(title));
|
||||
|
||||
registerSampleSource(key, value, { prebake: false });
|
||||
.map((title) => sampleInfoMap.get(title));
|
||||
registerSampleSource(key, bank, { prebake: false });
|
||||
});
|
||||
|
||||
logger('imported sounds registered!', 'success');
|
||||
|
||||
@@ -71,6 +71,7 @@ export function useReplContext() {
|
||||
const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
|
||||
const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds;
|
||||
const init = useCallback(() => {
|
||||
setActivePattern(getViewingPatternData().id);
|
||||
const drawTime = [-2, 2];
|
||||
const drawContext = getDrawContext();
|
||||
const editor = new StrudelMirror({
|
||||
@@ -106,17 +107,24 @@ export function useReplContext() {
|
||||
//post to iframe parent (like Udels) if it exists...
|
||||
window.parent?.postMessage(code);
|
||||
|
||||
setLatestCode(code);
|
||||
window.location.hash = '#' + code2hash(code);
|
||||
setDocumentTitle(code);
|
||||
// Get the full buffer content from the editor instead of just the evaluated block
|
||||
const fullBufferCode = editorRef.current?.code || code;
|
||||
setLatestCode(fullBufferCode);
|
||||
|
||||
try {
|
||||
window.location.hash = '#' + code2hash(fullBufferCode);
|
||||
} catch (e) {
|
||||
console.warn('[useReplContext] Failed to update hash:', e.message);
|
||||
}
|
||||
setDocumentTitle(fullBufferCode);
|
||||
const viewingPatternData = getViewingPatternData();
|
||||
setVersionDefaultsFrom(code);
|
||||
const data = { ...viewingPatternData, code };
|
||||
setVersionDefaultsFrom(fullBufferCode);
|
||||
const data = { ...viewingPatternData, code: fullBufferCode };
|
||||
let id = data.id;
|
||||
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
|
||||
|
||||
if (isExamplePattern) {
|
||||
const codeHasChanged = code !== viewingPatternData.code;
|
||||
const codeHasChanged = fullBufferCode !== viewingPatternData.code;
|
||||
if (codeHasChanged) {
|
||||
// fork example
|
||||
const newPattern = userPattern.duplicate(data);
|
||||
@@ -168,9 +176,8 @@ export function useReplContext() {
|
||||
useEffect(() => {
|
||||
let editorSettings = {};
|
||||
Object.keys(defaultSettings).forEach((key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(_settings, key)) {
|
||||
editorSettings[key] = _settings[key];
|
||||
}
|
||||
// Don't use hasOwnProperty - nanostore uses proxies so values may not be own properties
|
||||
editorSettings[key] = _settings[key];
|
||||
});
|
||||
editorRef.current?.updateSettings(editorSettings);
|
||||
}, [_settings]);
|
||||
|
||||
@@ -33,6 +33,7 @@ export const defaultSettings = {
|
||||
isPatternHighlightingEnabled: true,
|
||||
isTabIndentationEnabled: false,
|
||||
isMultiCursorEnabled: false,
|
||||
isBlockBasedEvalEnabled: false,
|
||||
theme: 'strudelTheme',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 18,
|
||||
@@ -89,6 +90,7 @@ export const $settings = computed(settingsMap, (state) => {
|
||||
isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled),
|
||||
isTabIndentationEnabled: parseBoolean(state.isTabIndentationEnabled),
|
||||
isMultiCursorEnabled: parseBoolean(state.isMultiCursorEnabled),
|
||||
isBlockBasedEvalEnabled: parseBoolean(state.isBlockBasedEvalEnabled),
|
||||
fontSize: Number(state.fontSize),
|
||||
panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is!
|
||||
isPanelPinned: parseBoolean(state.isPanelPinned),
|
||||
|
||||
@@ -207,9 +207,10 @@ export async function exportPatterns() {
|
||||
const userPatterns = userPattern.getAll();
|
||||
const blob = new Blob([JSON.stringify(userPatterns)], { type: 'application/json' });
|
||||
const downloadLink = document.createElement('a');
|
||||
const prefix = window.location.hostname.split('.').join('_');
|
||||
downloadLink.href = window.URL.createObjectURL(blob);
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
downloadLink.download = `strudel_patterns_${date}.json`;
|
||||
downloadLink.download = `${prefix}_patterns_${date}.json`;
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
|
||||
Reference in New Issue
Block a user