Compare commits

..

8 Commits

Author SHA1 Message Date
Jade (Rose) Rowland 90c05ec38a fix edge cases 2026-02-08 14:12:21 -05:00
Jade (Rose) Rowland 7e0eed87cb normalize shortcuts 2026-01-27 17:21:36 -05:00
Jade (Rose) Rowland 1a1197f67a Merge branch 'main' into soloshortcut 2026-01-22 21:11:31 -05:00
Jade (Rose) Rowland 3c5afb32f3 scroll to center 2026-01-22 02:04:27 -05:00
Jade (Rose) Rowland 76cbd23859 Merge branch 'soloshortcut' of ssh://codeberg.org/uzu/strudel into soloshortcut 2026-01-22 00:25:41 -05:00
Jade (Rose) Rowland b436ae789c rm dead code 2026-01-22 00:25:28 -05:00
Switch Angel AKA Jade Rose 275731afc7 Merge branch 'main' into soloshortcut 2026-01-22 06:22:17 +01:00
Jade (Rose) Rowland 5cb2214d88 working 2026-01-22 00:20:44 -05:00
43 changed files with 941 additions and 2916 deletions
-51
View File
@@ -1,51 +0,0 @@
// 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;
};
+91 -71
View File
@@ -13,18 +13,23 @@ import {
} from '@codemirror/view';
import { persistentAtom } from '@nanostores/persistent';
import { logger, registerControl, repl } from '@strudel/core';
import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw';
import { cleanupDraw, 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 { jumpToCharacter } from './labelJump.mjs';
import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs';
import { isTooltipEnabled } from './tooltip.mjs';
import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs';
import { updateWidgets, widgetPlugin } from './widget.mjs';
import {
deleteAllInlineBeforeCharacter,
InsertCharBeforeChar,
jumpToCharacter,
jumpToNextCharacter,
} from './labelJump.mjs';
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
@@ -64,7 +69,6 @@ export const defaultSettings = {
isLineWrappingEnabled: false,
isTabIndentationEnabled: false,
isMultiCursorEnabled: false,
isBlockBasedEvalEnabled: false,
theme: 'strudelTheme',
fontFamily: 'monospace',
fontSize: 18,
@@ -75,8 +79,12 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
decode: JSON.parse,
});
const ANON_LABEL = '$';
const SOLO_LABEL = 'S';
const MUTE_LABEL = '_';
// https://codemirror.net/docs/guide/
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) {
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) {
const settings = codemirrorSettings.get();
const initialSettings = Object.keys(compartments).map((key) =>
compartments[key].of(extensions[key](parseBooleans(settings[key]))),
@@ -106,26 +114,11 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
keymap.of([
{
key: 'Ctrl-Enter',
run: () => {
// issue with referencing settings, this works more reliably
if (strudelMirror?.isBlockBasedEvalEnabled) {
evalBlock(strudelMirror);
return true;
} else {
return onEvaluate?.();
}
},
run: () => onEvaluate?.(),
},
{
key: 'Alt-Enter',
run: () => {
if (strudelMirror?.isBlockBasedEvalEnabled) {
evalBlock(strudelMirror);
return true;
} else {
return onEvaluate?.();
}
},
run: () => onEvaluate?.(),
},
{
key: 'Ctrl-.',
@@ -138,12 +131,75 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
},
{
key: 'Alt-w',
run: (view) => jumpToCharacter(view, '$', 1),
run: (view) => jumpToNextCharacter(view, ANON_LABEL, 1),
},
{
key: 'Alt-q',
run: (view) => jumpToCharacter(view, '$', -1),
run: (view) => {
return jumpToNextCharacter(view, ANON_LABEL, -1);
},
},
// clear all muted
{
key: `Alt-Ctrl-0`,
run: (view) => {
return deleteAllInlineBeforeCharacter(view, MUTE_LABEL + ANON_LABEL);
},
},
// clear all solod
{
key: `Alt-Shift-0`,
run: (view) => {
return deleteAllInlineBeforeCharacter(view, SOLO_LABEL + ANON_LABEL);
},
},
// clear all solo and mute
{
key: `Ctrl-Shift-0`,
run: (view) => {
return deleteAllInlineBeforeCharacter(view, ANON_LABEL);
},
},
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Alt-${num}`,
run: (view) => {
return jumpToCharacter(view, ANON_LABEL, i);
},
};
}),
// handle solo toggles 1-9
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Alt-Shift-${num}`,
run: (view) => {
return InsertCharBeforeChar(view, ANON_LABEL, SOLO_LABEL, i);
},
};
}),
// handle mute toggles 1-9
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Alt-Ctrl-${num}`,
run: (view) => {
return InsertCharBeforeChar(view, ANON_LABEL, MUTE_LABEL, i);
},
};
}),
// Handle clearing mutes and solos 1-9
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Ctrl-Shift-${num}`,
run: (view) => {
return InsertCharBeforeChar(view, ANON_LABEL,'', i);
},
};
}),
/* {
key: 'Ctrl-Shift-.',
run: () => (onPanic ? onPanic() : onStop?.()),
@@ -187,7 +243,6 @@ 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));
@@ -218,28 +273,20 @@ export class StrudelMirror {
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);
}
beforeEval: async () => {
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');
// 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);
updateWidgets(this.editor, widgets);
updateMiniLocations(this.editor, this.miniLocations);
replOptions?.afterEval?.(options);
// if no painters are set (.onPaint was not called), then we only need
// the present moment (for highlighting)
@@ -247,14 +294,8 @@ 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,
@@ -267,9 +308,7 @@ 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';
@@ -339,9 +378,8 @@ export class StrudelMirror {
this.flash();
await this.repl.evaluate(this.code, autostart);
}
async stop() {
this.repl.stop();
this.repl.scheduler.stop();
}
// Listen for global stop requests (e.g., from Vim :q)
@@ -360,8 +398,8 @@ export class StrudelMirror {
this.evaluate();
}
}
flash(ms, range) {
flash(this.editor, ms, range);
flash(ms) {
flash(this.editor, ms);
}
highlight(haps, time) {
highlightMiniLocations(this.editor, time, haps);
@@ -393,10 +431,6 @@ export class StrudelMirror {
setLineWrappingEnabled(enabled) {
this.reconfigureExtension('isLineWrappingEnabled', enabled);
}
setBlockBasedEvalEnabled(enabled) {
this.reconfigureExtension('isBlockBasedEvalEnabled', enabled);
}
setBracketMatchingEnabled(enabled) {
this.reconfigureExtension('isBracketMatchingEnabled', enabled);
}
@@ -418,10 +452,6 @@ 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);
}
@@ -443,16 +473,6 @@ 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);
+3 -5
View File
@@ -14,8 +14,7 @@ export const flashField = StateField.define({
const mark = Decoration.mark({
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` },
});
const range = e.value.range || { from: 0, to: tr.newDoc.length };
flash = Decoration.set([mark.range(range.from, range.to)]);
flash = Decoration.set([mark.range(0, tr.newDoc.length)]);
} else {
flash = Decoration.set([]);
}
@@ -30,9 +29,8 @@ export const flashField = StateField.define({
provide: (f) => EditorView.decorations.from(f),
});
export const flash = (view, ms = 200, range) => {
const flashData = range ? { range } : true;
view.dispatch({ effects: setFlash.of(flashData) });
export const flash = (view, ms = 200) => {
view.dispatch({ effects: setFlash.of(true) });
setTimeout(() => {
view.dispatch({ effects: setFlash.of(false) });
}, ms);
+60 -161
View File
@@ -3,9 +3,8 @@ import { Decoration, EditorView } from '@codemirror/view';
export const setMiniLocations = StateEffect.define();
export const showMiniLocations = StateEffect.define();
export const displayMiniLocations = StateEffect.define();
export const updateMiniLocations = (view, locations, range = null) => {
view.dispatch({ effects: setMiniLocations.of({ locations, range }) });
export const updateMiniLocations = (view, locations) => {
view.dispatch({ effects: setMiniLocations.of(locations) });
};
export const highlightMiniLocations = (view, atTime, haps) => {
view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
@@ -22,54 +21,23 @@ const miniLocations = StateField.define({
for (let e of tr.effects) {
if (e.is(setMiniLocations)) {
//block-based eval case
if (e.value.range) {
const stateMiniLocations = getMiniLocationsFromDecorations(locations);
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 }) =>
// 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,
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(from, to),
);
}).range(...range), // -> Decoration
);
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>
}
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
}
}
@@ -107,83 +75,12 @@ const visibleMiniLocations = StateField.define({
},
});
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 miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => {
const iterator = state.field(miniLocations).iter();
const { haps } = state.field(visibleMiniLocations);
const builder = new RangeSetBuilder();
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,
@@ -192,48 +89,50 @@ const getMiniLocationsFromDecorations = (decorations) => {
spec: { id },
},
} = iterator;
miniLocationsArray.push({
from,
to,
id,
});
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 miniLocationsArray;
};
export const getMiniLocations = (state) => {
const decorations = state.field(miniLocations);
return getMiniLocationsFromDecorations(decorations);
};
return builder.finish();
});
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 highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights];
export const isPatternHighlightingEnabled = (on, config) => {
// 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);
on &&
config &&
setTimeout(() => {
updateMiniLocations(config.editor, config.miniLocations);
}, 100);
return on ? Prec.highest(highlightExtension) : [];
};
+55 -106
View File
@@ -6,7 +6,6 @@ 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(
@@ -21,70 +20,6 @@ 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
@@ -112,8 +47,29 @@ try {
// :q to pause/stop
Vim.defineEx('quit', 'q', (cm) => {
const view = cm?.view || cm;
replStop(view);
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);
}
});
// :w to evaluate
@@ -127,7 +83,38 @@ try {
} catch (e) {
console.error('Error logging Vim :w evaluation', e);
}
replEval(view);
// 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 :w evaluation event', e);
}
@@ -137,49 +124,11 @@ 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';
+133 -11
View File
@@ -1,18 +1,54 @@
import { EditorSelection } from '@codemirror/state';
import { SearchCursor } from '@codemirror/search';
import { EditorView } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
export function jumpToCharacter(view, character, direction = 1) {
/**
* gets all of the positions of a character in a document, excluding commented out lines
* @param { EditorState} state
* @param {String} character
* @returns {number[]}
*/
function getCharacterPositions(state, character) {
const cursor = new SearchCursor(state.doc, character);
const characterPositions = [];
while (!cursor.next().done) {
const linestartpos = state.doc.lineAt(cursor.value.to).from
if (!isLineCommentedOut(state, linestartpos)) {
characterPositions.push(cursor.value.to);
}
}
return characterPositions;
}
function isLineCommentedOut(state, pos) {
const line = state.doc.lineAt(pos);
// remove white space
pos = line.from + line.text.search(/\S/)
const tree = syntaxTree(state);
const node = tree.resolveInner(pos, 1)
return node.name.includes("Comment")
}
/**
* jump to the next character in a document
* @param {EditorView} view
* @param {String} character
* @param {number} direction 0 or 1
* @returns {boolean}
*/
export function jumpToNextCharacter(view, character, direction = 1) {
const { state, dispatch } = view;
const pos = state.selection.main.head;
const cursor = new SearchCursor(state.doc, character);
let characterPositions = [];
let jumpPos;
while (!cursor.next().done) {
characterPositions.push(cursor.value.to);
}
const characterPositions = getCharacterPositions(state, character);
if (!characterPositions.length) {
return false;
return true;
}
if (direction > 0) {
jumpPos = characterPositions.find((x) => x > pos + 1) ?? characterPositions.at(0); // Loop back around for convenience
@@ -21,11 +57,97 @@ export function jumpToCharacter(view, character, direction = 1) {
}
if (jumpPos == null) {
return false;
return true;
}
const selection = EditorSelection.cursor(jumpPos - 1);
dispatch({
selection: EditorSelection.cursor(jumpPos - 1),
scrollIntoView: true,
selection,
effects: EditorView.scrollIntoView(
selection.head,
{ y: "start" }
)
});
return true;
}
/**
*
* @param {EditorView} view
* @param {String} character
* @param {number} index the instance of the character
* @returns
*/
export function jumpToCharacter(view, character, index) {
const { state, dispatch } = view;
const characterPositions = getCharacterPositions(state, character);
const pos = characterPositions.at(index) ?? characterPositions.at(-1);
if (pos == null) {
return true;
}
const selection = EditorSelection.cursor(pos - 1);
dispatch({
selection,
effects: EditorView.scrollIntoView(
selection.head,
{ y: "start" }
)
});
return true;
}
/**
*
* @param {EditorView} view
* @param {String} character
* @returns {true}
*/
export function deleteAllInlineBeforeCharacter(view, character) {
const { state, dispatch } = view;
const characterPositions = getCharacterPositions(state, character);
const changes = [];
characterPositions.forEach((pos) => {
const line = state.doc.lineAt(pos);
if (state.doc.sliceString(line.from, line.from + 2) === COMMENT_STRING) {
return;
}
changes.push({
from: line.from,
to: pos - 1,
insert: '',
});
});
dispatch({ changes });
return true;
}
/**
*
* @param {EditorView} view
* @param {String} character
* @param {String} character2
* @param {number} index
* @returns
*/
export function InsertCharBeforeChar(view, character, character2, index) {
const { state, dispatch } = view;
const changes = [];
const characterPositions = getCharacterPositions(state, character);
const labelpos = characterPositions.at(index) ?? characterPositions.at(-1);
const line = state.doc.lineAt(labelpos);
//delete preceeding characters
changes.push({
from: line.from,
to: labelpos - 1,
insert: '',
});
changes.push({
insert: character2,
from: line.from,
});
dispatch({ changes });
return true;
}
-1
View File
@@ -44,7 +44,6 @@
"@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:*",
+17 -106
View File
@@ -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, id) {
constructor(value, min, max, from, to, step, view) {
super();
this.value = value;
this.min = min;
@@ -16,21 +16,10 @@ 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(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
);
eq() {
return false;
}
toDOM() {
@@ -49,7 +38,6 @@ 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) => {
@@ -61,7 +49,7 @@ export class SliderWidget extends WidgetType {
slider.originalValue = insert;
slider.value = insert;
this.view.dispatch({ changes: change });
const id = slider.id; // Use range-based ID
const id = getSliderID(slider.originalFrom); // matches id generated in transpiler
window.postMessage({ type: 'cm-slider', value: Number(next), id });
});
return wrap;
@@ -74,60 +62,19 @@ export class SliderWidget extends WidgetType {
export const setSliderWidgets = StateEffect.define();
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) });
}
export const updateSliderWidgets = (view, widgets) => {
view.dispatch({ effects: setSliderWidgets.of(widgets) });
};
function getSliders(widgetConfigs, view) {
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;
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 */);
});
}
export const sliderPlugin = ViewPlugin.fromClass(
@@ -154,42 +101,7 @@ export const sliderPlugin = ViewPlugin.fromClass(
}
}
for (let e of tr.effects) {
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)) {
if (e.is(setSliderWidgets)) {
this.decorations = Decoration.set(getSliders(e.value, update.view));
}
}
@@ -220,7 +132,6 @@ 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) => {
@@ -229,7 +140,7 @@ if (typeof window !== 'undefined') {
// update state when slider is moved
sliderValues[e.data.id] = e.data.value;
} else {
console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
}
}
});
+45 -193
View File
@@ -1,111 +1,55 @@
import { StateEffect, StateField } from '@codemirror/state';
import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
import { Pattern } from '@strudel/core';
export const setWidgets = StateEffect.define();
export const addWidget = StateEffect.define({
map: ({ from, to }, change) => {
return { from: change.mapPos(from), to: change.mapPos(to) };
},
});
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) });
}
export const updateWidgets = (view, widgets) => {
view.dispatch({ effects: addWidget.of(widgets) });
};
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 {
function getWidgets(widgetConfigs) {
return (
widgetConfigs
// codemirror throws an error if we don't sort
.sort((a, b) => a.to - b.to)
.map((widgetConfig) => {
return Decoration.widget({
widget: new BlockWidget(widgetConfig, view),
widget: new BlockWidget(widgetConfig),
side: 0,
}).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
block: true,
}).range(widgetConfig.to);
})
);
}
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();
}
}
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,
});
}
}
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),
});
// 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));
} catch (error) {
console.log('err', error);
}
}
});
}
},
{
decorations: (v) => v.decorations,
}
return widgets;
},
provide: (f) => EditorView.decorations.from(f),
},
);
@@ -116,116 +60,24 @@ export function setWidget(id, el) {
}
export class BlockWidget extends WidgetType {
constructor(widgetConfig, view) {
constructor(widgetConfig) {
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(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
);
eq() {
return true;
}
toDOM() {
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;
const id = getWidgetID(this.widgetConfig);
const el = widgetElements[id];
return el;
}
ignoreEvent(e) {
return true;
}
}
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;
}
export const widgetPlugin = [widgetField];
// widget implementer API to create a new widget type
export function registerWidget(type, fn) {
+226 -247
View File
File diff suppressed because it is too large Load Diff
-28
View File
@@ -5,34 +5,6 @@ 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);
+59 -55
View File
@@ -818,6 +818,7 @@ export class Pattern {
* @name layer
* @tags combiners
* @memberof Pattern
* @synonyms apply
* @returns Pattern
* @example
* "<0 2 4 6 ~ 4 ~ 2 0!3 ~!5>*8"
@@ -1264,7 +1265,6 @@ 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 fastcat
* @synonyms seq, fastcat
* @example
* seq("e5", "b4", ["d5", "c5"]).note()
* // "e5 b4 [d5 c5]".note()
@@ -2137,12 +2137,13 @@ export const { firstOf, every } = register(['firstOf', 'every'], function (n, fu
});
/**
* Applies the given function to the pattern. Like layer, but with a single function:
* @tags combiners
* Like layer, but with a single function:
* @tags temporal
* @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);
});
@@ -2499,7 +2500,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, superdough
* @tags temporal
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").jux(rev)
* @example
@@ -2513,7 +2514,7 @@ export const jux = register('jux', function (func, pat) {
/**
* Superimpose and offset multiple times, applying the given function each time.
* @tags temporal, functional
* @tags temporal
* @name echoWith
* @synonyms echowith, stutWith, stutwith
* @param {number} times how many times to repeat
@@ -2687,7 +2688,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, functional
* @tags temporal
* @name chunk
* @synonyms slowChunk, slowchunk
* @memberof Pattern
@@ -2851,7 +2852,7 @@ Pattern.prototype.tag = function (tag) {
/**
* Filters haps using the given function
* @name filter
* @tags temporal, functional
* @tags temporal
* @param {Function} test function to test Hap
* @example
* s("hh!7 oh").filter(hap => hap.value.s === 'hh')
@@ -2861,7 +2862,7 @@ export const filter = register('filter', (test, pat) => pat.withHaps((haps) => h
/**
* Filters haps by their begin time
* @name filterWhen
* @tags temporal, functional
* @tags temporal
* @param {Function} test function to test Hap.whole.begin
* @example
* oneCycle: s("bd*4").filterWhen((t) => t < 1)
@@ -2871,7 +2872,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, functional
* @tags temporal
* @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
@@ -2946,7 +2947,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 stepwise
* @tags temporal
* @example
* sound("bd sd cp").pace(4)
* // The same as sound("{bd sd cp}%4") or sound("<bd sd cp>*4")
@@ -2988,7 +2989,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 stepwise
* @tags temporal
* @synonyms pm
* @example
* // The same as note("{c eb g, c2 g2}%6")
@@ -3021,7 +3022,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 stepwise
* @tags combiners
* @synonyms timeCat, timecat
* @return {Pattern}
* @example
@@ -3079,7 +3080,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 stepwise
* @tags combiners
* @return {Pattern}
* @example
* stepalt(["bd cp", "mt"], "bd").sound()
@@ -3106,7 +3107,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 stepwise
* @tags temporal
* @return {Pattern}
* @example
* "bd cp ht mt".take("2").sound()
@@ -3151,7 +3152,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 stepwise
* @tags temporal
* @return {Pattern}
* @example
* "tha dhi thom nam".drop("1").sound().bank("mridangam")
@@ -3180,7 +3181,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 stepwise
* @tags temporal
* @example
* stepcat(
* sound("bd bd - cp").extend(2),
@@ -3199,7 +3200,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 stepwise
* @tags temporal
* @example
* stepcat(
* sound("bd bd - cp").replicate(2),
@@ -3214,7 +3215,7 @@ export const replicate = stepRegister('replicate', function (factor, pat) {
* *Experimental*
*
* Expands the step size of the pattern by the given factor.
* @tags stepwise
* @tags temporal
* @example
* sound("tha dhi thom nam").bank("mridangam").expand("3 2 1 1 2 3").pace(8)
*/
@@ -3226,7 +3227,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 stepwise
* @tags temporal
* @example
* sound("tha dhi thom nam").bank("mridangam").contract("3 2 1 1 2 3").pace(8)
*/
@@ -3281,7 +3282,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 stepwise
* @tags temporal
* @return {Pattern}
* @example
* "tha dhi thom nam".shrink("1").sound()
@@ -3321,7 +3322,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 stepwise
* @tags temporal
* @return {Pattern}
* @example
* "tha dhi thom nam".grow("1").sound()
@@ -3362,7 +3363,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 stepwise
* @tags combiners
* @return {Pattern}
* @example
* "[c g]".tour("e f", "e f g", "g f e c").note()
@@ -3389,7 +3390,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 stepwise
* @tags combiners
* @returns {Pattern}
* @example
* zip("e f", "e f g", "g [f e] a f4 c").note()
@@ -3441,7 +3442,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 samples
* @tags temporal
* @memberof Pattern
* @returns Pattern
* @example
@@ -3472,7 +3473,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 samples
* @tags temporal
* @memberof Pattern
* @returns Pattern
* @example
@@ -3491,13 +3492,14 @@ export const striate = register('striate', function (n, pat) {
/**
* Makes the sample fit the given number of cycles by changing the speed.
* @name loopAt
* @tags samples, pitch
* @tags temporal
* @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)
@@ -3505,16 +3507,11 @@ 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 samples
* @tags temporal
* @memberof Pattern
* @returns Pattern
* @example
@@ -3568,7 +3565,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 samples, pitch
* @tags temporal
* @example
* samples('github:tidalcycles/dirt-samples')
* s("breaks165")
@@ -3597,11 +3594,16 @@ 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 samples, pitch
* @tags temporal
* @example
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
* s("rhodes/2").fit()
@@ -3623,16 +3625,18 @@ export const fit = register('fit', (pat) =>
/**
* Makes the sample fit the given number of cycles and cps value, by
* changing the speed. deprecated: use loopAt or fit instead, together with setCps / setCpm.
* changing the speed. Please note that at some point cps will be
* given by a global clock and this function will be
* deprecated/removed.
* @name loopAtCps
* @tags samples, pitch
* @tags temporal
* @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);
});
@@ -3654,7 +3658,7 @@ let fadeGain = (p) => (p < 0.5 ? 1 : 1 - (p - 0.5) / 0.5);
* - 1 = (no left, full right)
*
* @name xfade
* @tags amplitude
* @tags combiners
* @example
* xfade(s("bd*2"), "<0 .25 .5 .75 1>", s("hh*8"))
*/
@@ -3780,7 +3784,7 @@ const _distortWithAlg = function (name) {
* Soft-clipping distortion
*
* @name soft
* @tags distortion, superdough
* @tags fx
* @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion
*
@@ -3791,7 +3795,7 @@ export const soft = _distortWithAlg('soft');
* Hard-clipping distortion
*
* @name hard
* @tags distortion, superdough
* @tags fx
* @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion
*
@@ -3802,7 +3806,7 @@ export const hard = _distortWithAlg('hard');
* Cubic polynomial distortion
*
* @name cubic
* @tags distortion, superdough
* @tags fx
* @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion
*
@@ -3813,7 +3817,7 @@ export const cubic = _distortWithAlg('cubic');
* Diode-emulating distortion
*
* @name diode
* @tags distortion, superdough
* @tags fx
* @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion
*
@@ -3824,7 +3828,7 @@ export const diode = _distortWithAlg('diode');
* Asymmetrical diode distortion
*
* @name asym
* @tags distortion, superdough
* @tags fx
* @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion
*
@@ -3835,7 +3839,7 @@ export const asym = _distortWithAlg('asym');
* Wavefolding distortion
*
* @name fold
* @tags distortion, superdough
* @tags fx
* @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion
*
@@ -3846,7 +3850,7 @@ export const fold = _distortWithAlg('fold');
* Wavefolding distortion composed with sinusoid
*
* @name sinefold
* @tags distortion, superdough
* @tags fx
* @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion
*
@@ -3857,7 +3861,7 @@ export const sinefold = _distortWithAlg('sinefold');
* Distortion via Chebyshev polynomials
*
* @name chebyshev
* @tags distortion, superdough
* @tags fx
* @param {number | Pattern} distortion amount of distortion to apply
* @param {number | Pattern} volume linear postgain of the distortion
*
@@ -3891,7 +3895,7 @@ const _ensureListPattern = (list) => {
* Can also be used to create a new synth via `s('user').partials(...)`
*
* @name partials
* @tags superdough
* @tags fx, 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")
@@ -3913,7 +3917,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 superdough
* @tags fx, 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
@@ -3935,7 +3939,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 superdough
* @tags fx, superdough
* @memberof Pattern
* @returns Pattern
* @example
@@ -3986,7 +3990,7 @@ const _asArrayPattern = (pats) => {
* by wrapping them inside a function in K (see example).
*
* @name K
* @tags generators, superdough
* @tags generators, fx, superdough
* @param {KabelsalatExpression | Function} expr Kabelsalat graph definition
* @memberof Pattern
* @returns Pattern
+23 -28
View File
@@ -71,32 +71,29 @@ 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 or names
* @param {function[] | object} lookup the array or lookup object of functions from which to pull
* @param {Pattern} lookup a pattern of indices
* @param {function[]} funcs the array 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)])
* @example
* note("<c2 d2>(3,8)").s("square")
* .pickF("<jr l> f", { jr:jux(rev), f:fast(2), l:x=>x.lpf(800) })
* .pickF("<0 2> 1", [jux(rev),fast(2),x=>x.lpf(800)])
*/
export const pickF = register('pickF', function (pickPattern, lookup, pat) {
return pat.apply(pick(lookup, pickPattern));
export const pickF = register('pickF', function (lookup, funcs, pat) {
return pat.apply(pick(lookup, funcs));
});
/** * 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 or names
* @param {function[] | object} lookup the array or lookup object of functions from which to pull
* @param {Pattern} lookup a pattern of indices
* @param {function[]} funcs the array of functions from which to pull
* @returns {Pattern}
*/
export const pickmodF = register('pickmodF', function (pickPattern, lookup, pat) {
return pat.apply(pickmod(lookup, pickPattern));
export const pickmodF = register('pickmodF', function (lookup, funcs, pat) {
return pat.apply(pickmod(lookup, funcs));
});
/** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin.
@@ -170,22 +167,20 @@ 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
* 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)
*/
* 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)
*/
export const { inhabit, pickSqueeze } = register(['inhabit', 'pickSqueeze'], function (lookup, pat) {
return _pick(lookup, pat, false).squeezeJoin();
});
+35 -298
View File
@@ -38,7 +38,6 @@ export function repl({
pattern: undefined,
miniLocations: [],
widgets: [],
sliders: [],
pending: false,
started: false,
};
@@ -80,132 +79,11 @@ 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;
};
@@ -225,60 +103,7 @@ export function repl({
};
setTime(() => scheduler.now()); // TODO: refactor?
// 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 stop = () => scheduler.stop();
const start = () => scheduler.start();
const pause = () => scheduler.pause();
const toggle = () => scheduler.toggle();
@@ -392,7 +217,7 @@ export function repl({
});
};
const evaluate = async (code, autostart = true) => {
const evaluate = async (code, autostart = true, shouldHush = true) => {
if (!code) {
throw new Error('no code to evaluate');
}
@@ -400,146 +225,59 @@ export function repl({
updateState({ code, pending: true });
await injectPatternMethods();
setTime(() => scheduler.now()); // TODO: refactor?
await beforeEval?.({ code, blockBased: false });
await beforeEval?.({ code });
allTransforms = []; // reset all transforms
codeBlocks = {};
hush();
shouldHush && hush();
if (mondo) {
code = `mondolang\`${code}\``;
}
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, range: undefined, widgetRemoved: false });
return pattern;
} catch (err) {
logger(`[eval] error: ${err.message}`, 'error');
console.error(err);
updateState({ evalError: err, pending: false });
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;
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);
}
}
pPatterns = Object.fromEntries(
Object.entries(pPatterns).filter(([key]) => {
return Object.keys(codeBlocks).includes(key);
}),
);
pattern = applyPatternTransforms(pattern);
if (!isPattern(pattern)) {
pattern = silence;
}
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 });
afterEval?.({ code, pattern, meta });
return pattern;
} catch (err) {
logger(`[eval] error: ${err.message}`, 'error');
@@ -548,9 +286,8 @@ export function repl({
onEvalError?.(err);
}
};
const setCode = (code) => updateState({ code });
return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state };
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
}
export const getTrigger =
+2 -7
View File
@@ -339,13 +339,8 @@ export function uniqsortr(a) {
export function unicodeToBase64(text) {
const utf8Bytes = new TextEncoder().encode(text);
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);
const base64String = btoa(String.fromCharCode(...utf8Bytes));
return base64String;
}
export function base64ToUnicode(base64String) {
-10
View File
@@ -78,16 +78,6 @@ 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) {
-193
View File
@@ -1,193 +0,0 @@
/*
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);
}
}
}
}
+70 -40
View File
@@ -23,8 +23,6 @@ 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;
@@ -33,6 +31,10 @@ 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) {
@@ -70,6 +72,29 @@ 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) => {
@@ -113,7 +138,7 @@ function githubPath(base, subpath = '') {
/**
* configures the default midimap, which is used when no "midimap" port is set
* @tags external_io, midi
* @tags external_io
* @example
* defaultmidimap({ lpf: 74 })
* $: note("c a f e").midi();
@@ -127,7 +152,7 @@ let loadCache = {};
/**
* Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers.
* @tags external_io, midi
* @tags external_io
* @example
* midimaps({ mymap: { lpf: 74 } })
* $: note("c a f e")
@@ -470,9 +495,9 @@ Pattern.prototype.midi = function (midiport, options = {}) {
};
/**
* Initialize a midi input device
* Initialize a midi device
*/
async function _initializeInput(input) {
async function _initialize(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('${
@@ -480,38 +505,31 @@ async function _initializeInput(input) {
}')`,
);
}
const initial = await enableWebMidi(); // only returns on first init
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(
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)}`,
const device = getDevice(input, WebMidi.inputs);
if (!device) {
throw new Error(
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
);
}
return instance;
if (initial) {
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
logger(
`[midi] Midi enabled! Using "${device.name}". ${
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
}`,
);
}
return device;
}
// 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, midi
* @tags external_io
* @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)
@@ -525,10 +543,31 @@ const midiInputs = {};
* 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 instance = await _initializeInput(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);
};
return instance.createCC.bind(instance);
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;
}
/**
@@ -538,7 +577,7 @@ export async function midin(input) {
* note durations
*
* @name midikeys
* @tags external_io, midi
* @tags external_io
* @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,
@@ -583,16 +622,7 @@ function _triggerKeyboard(input, cps, now, latencyCycles) {
return true;
}
export async function midikeys(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)}`,
);
}
const device = await _initialize(input);
if (!kHaps[input]) {
kHaps[input] = [];
}
-44
View File
@@ -1,44 +0,0 @@
/*
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;
}
+5 -30
View File
@@ -1,4 +1,4 @@
import { BASE_MIDI_NOTE, getBaseURL, getCommonSampleInfo, noteToFreq, noteToMidi } from './util.mjs';
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import { registerSound, registerWaveTable } from './index.mjs';
import { getAudioContext } from './audioContext.mjs';
import {
@@ -14,13 +14,6 @@ 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) {
@@ -40,7 +33,7 @@ function humanFileSize(bytes, si) {
export function getSampleInfo(hapValue, bank) {
const { speed = 1.0 } = hapValue;
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
const playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
return { transpose, url, index, midi, label, playbackRate };
}
@@ -163,20 +156,15 @@ export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '')
if (baseUrl.startsWith('github:')) {
baseUrl = githubPath(baseUrl, '');
}
/**
*
* @param {string} v
* @returns {SampleMetaData}
*/
const getMetaData = (v) => ({ url: baseUrl + v, midi: extractMidiNoteFromString(v) });
const fullUrl = (v) => baseUrl + v;
if (Array.isArray(value)) {
//return [key, value.map(replaceUrl)];
value = value.map(getMetaData);
value = value.map(fullUrl);
} else {
// must be object
value = Object.fromEntries(
Object.entries(value).map(([note, samples]) => {
return [note, (typeof samples === 'string' ? [samples] : samples).map(getMetaData)];
return [note, (typeof samples === 'string' ? [samples] : samples).map(fullUrl)];
}),
);
}
@@ -379,16 +367,3 @@ 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);
}
+2 -2
View File
@@ -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 superdough
* @tags fx, 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 amplitude, superdough
* @tags fx, superdough
* @param {Function} function to apply to all gain values
* @example
* setGainCurve((x) => x * x) // quadratic gain
+10 -42
View File
@@ -19,12 +19,7 @@ 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) {
@@ -34,11 +29,6 @@ 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;
};
@@ -48,17 +38,7 @@ export const freqToMidi = (freq) => {
return (12 * Math.log(freq / 440)) / Math.LN2 + 69;
};
/**
*
* @param {string} note
* @param {number} defaultOctave
* @returns {number}
*/
export const noteToFreq = (note, defaultOctave = 3) => {
return midiToFreq(noteToMidi(note, defaultOctave));
};
function __valueToMidi(value) {
export const valueToMidi = (value, fallbackValue) => {
if (typeof value !== 'object') {
throw new Error('valueToMidi: expected object value');
}
@@ -72,15 +52,10 @@ function __valueToMidi(value) {
if (typeof note === 'number') {
return note;
}
return;
}
export const valueToMidi = (value, fallbackValue) => {
const parsedValue = __valueToMidi(value) ?? fallbackValue;
if (parsedValue == null) {
if (!fallbackValue) {
throw new Error('valueToMidi: expected freq or note to be set');
}
return parsedValue;
return fallbackValue;
};
export function nanFallback(value, fallback = 0, silent) {
@@ -109,18 +84,15 @@ 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;
const maybeMidiNote = __valueToMidi(hapValue);
const midi = maybeMidiNote ?? BASE_MIDI_NOTE;
let transpose = midi - BASE_MIDI_NOTE; // C3 is middle C;
let midi = valueToMidi(hapValue, 36);
let transpose = midi - 36; // C3 is middle C;
let url;
let index = 0;
let samplemeta;
if (Array.isArray(bank)) {
index = getSoundIndex(n, bank.length);
samplemeta = bank[index];
url = bank[index];
} else {
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
// object format will expect keys as notes
@@ -132,14 +104,10 @@ export function getCommonSampleInfo(hapValue, bank) {
);
transpose = -midiDiff(closest); // semitones to repitch
index = getSoundIndex(n, bank[closest].length);
samplemeta = bank[closest][index];
url = bank[closest][index];
}
const label = `${s}:${index}`;
if (maybeMidiNote != null) {
transpose = transpose + (BASE_MIDI_NOTE - samplemeta.midi);
}
return { transpose, index, midi, label, url: samplemeta.url };
return { transpose, url, index, midi, label };
}
/** Selects entries from `source` and renames them via `map`
+1 -1
View File
@@ -186,7 +186,7 @@ export function registerWaveTable(key, tables, params) {
* Loads a collection of wavetables to use with `s`
*
* @name tables
* @tags wavetable
* @tags fx
*/
export const tables = async (url, frameLen, json, options = {}) => {
if (json !== undefined) return _processTables(json, url, frameLen);
+1 -1
View File
@@ -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 < 1 ? input[i][n] : output[i][n - 1];
output[i][n] = n % coarse === 0 ? input[i][n] : output[i][n - 1];
}
}
return true;
+3 -3
View File
@@ -100,7 +100,7 @@ function scaleOffset(scale, offset, note) {
* - 5P = perfect fifth
* - 5d = diminished fifth
*
* @tags tonal
* @tags music_theory
* @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 tonal
* @tags music_theory
* @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 tonal
* @tags music_theory
* @param {string} scale Name of scale
* @returns Pattern
* @example
+4 -4
View File
@@ -90,7 +90,7 @@ export const setVoicingRange = (name, range) => addVoicings(name, voicingRegistr
* Adds a new custom voicing dictionary.
*
* @name addVoicings
* @tags tonal
* @tags music_theory
* @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 tonal
* @tags music_theory
* @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 tonal
* @tags music_theory
* @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 tonal
* @tags music_theory
* @returns Pattern
* @example
* n("0 1 2 3").chord("<C Am F G>").voicing()
+27 -249
View File
@@ -19,14 +19,7 @@ export function registerLanguage(type, config) {
}
export function transpiler(input, options = {}) {
const {
wrapAsync = false,
addReturn = true,
emitMiniLocations = true,
emitWidgets = true,
blockBased = false,
range = [],
} = options;
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options;
const comments = [];
let ast = parse(input, {
@@ -38,13 +31,6 @@ 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) {
@@ -57,28 +43,9 @@ 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);
@@ -121,29 +88,22 @@ export function transpiler(input, options = {}) {
return this.replace(miniWithLocation(value, node));
}
if (isSliderFunction(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));
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));
}
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,
@@ -156,30 +116,8 @@ 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) {
@@ -243,33 +181,17 @@ 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(silenceExpression);
body.push({
type: 'ExpressionStatement',
expression: {
type: 'Identifier',
name: 'silence',
},
});
} else if (!body?.[body.length - 1]?.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);
throw new Error('unexpected ast format without body expression');
}
// add return to last statement
@@ -287,7 +209,7 @@ export function transpiler(input, options = {}) {
if (!emitMiniLocations) {
return { output };
}
return { output, miniLocations, widgets, sliders, labels };
return { output, miniLocations, widgets };
}
function isKabelCall(node) {
@@ -494,14 +416,8 @@ function isWidgetMethod(node) {
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
}
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}`;
function sliderWithLocation(node) {
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
// add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({
@@ -516,26 +432,14 @@ function sliderWithLocation(node, nodeOffset = 0) {
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
// 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}`;
// 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
}
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({
@@ -550,10 +454,6 @@ 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',
@@ -654,128 +554,6 @@ 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
+1 -38
View File
@@ -7,51 +7,14 @@ 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) => {
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));
return hap.withValue(() => tune.note(hap.value));
});
});
+4 -14
View File
@@ -78,21 +78,14 @@ 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
@@ -144,13 +137,10 @@ 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++) {
@@ -158,10 +148,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] = " ";
+9 -176
View File
@@ -4,10 +4,8 @@ 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, removeUndefineds } from '@strudel/core';
import Tune from './tunejs.js';
import { register, _mod, parseNumeral } from '@strudel/core';
// 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 + '"');
@@ -20,33 +18,23 @@ 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],
};
// 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) {
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 (isEdo(scale)) {
if (/^[1-9]+[0-9]*edo$/.test(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;
}
@@ -59,171 +47,16 @@ 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.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);
return pat.withHap((hap) => {
const scale = getXenScale(scaleNameOrRatios);
const frequency = xenOffset(scale, parseNumeral(hap.value));
return hap.withValue(() => frequency);
});
});
/**
* 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) {
export const tuning = register('tuning', function (ratios, pat) {
return pat.withHap((hap) => {
const frequency = xenOffset(ratios, parseNumeral(hap.value));
return hap.withValue(() => frequency);
-20
View File
@@ -221,9 +221,6 @@ 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
@@ -3554,15 +3551,6 @@ 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==}
@@ -11292,14 +11280,6 @@ 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:
-497
View File
@@ -1037,43 +1037,6 @@ 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 ]",
@@ -4230,27 +4193,6 @@ 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 ]",
@@ -4950,78 +4892,6 @@ 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 ]",
@@ -5706,43 +5576,6 @@ 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 ]",
@@ -8266,48 +8099,6 @@ 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] ]",
@@ -8932,33 +8723,6 @@ 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 ]",
@@ -13324,97 +13088,6 @@ 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 ]",
@@ -14188,76 +13861,6 @@ 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 ]",
@@ -14397,106 +14000,6 @@ 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 ]",
-1
View File
@@ -20,7 +20,6 @@ const skippedExamples = [
'accelerationX',
'defaultmidimap',
'midimaps',
'clearScope',
'bmod',
];
+2 -5
View File
@@ -10,11 +10,8 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
import tailwind from '@astrojs/tailwind';
import AstroPWA from '@vite-pwa/astro';
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 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
const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
// this rehype plugin fixes relative links
+1 -1
View File
@@ -104,7 +104,7 @@ export const SIDEBAR: Sidebar = {
Understand: [
{ text: 'Coding syntax', link: 'learn/code' },
{ text: 'Pitch', link: 'understand/pitch' },
{ text: 'Xenharmonic Functions', link: 'learn/xen' },
{ text: 'Xen Harmonic Functions', link: 'learn/xen' },
{ text: 'Cycles', link: 'understand/cycles' },
{ text: 'Voicings', link: 'understand/voicings' },
{ text: 'Pattern Alignment', link: 'technical-manual/alignment' },
+12 -35
View File
@@ -1,26 +1,22 @@
---
title: Xenharmonic Functions
title: Xen Harmonic Functions
layout: ../../layouts/MainLayout.astro
---
import { MiniRepl } from '../../docs/MiniRepl';
import { JsDoc } from '../../docs/JsDoc';
# Xenharmonic Functions (experimental)
{/* TODO expand explanation of xenharmony */}
# Xen Harmonic Functions
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={`i("0 1 2 3 4 5").tune("hexany15").mul("220").freq()`} />
<MiniRepl client:idle tune={`"0 1 2 3 4 5".tune("hexany15").mul("220").freq()`} />
Try other scales like `hexany1`, `iraq`, `gumbeng`, `gunkali`, or `tranh3`
@@ -30,7 +26,7 @@ You can set your root to be a particular note with `getFreq`
<MiniRepl
client:idle
tune={`i("4 8 9 10 - - 5 7 9 11 - -").tune("tranh3")
tune={`"4 8 9 10 - - 5 7 9 11 - -".tune("tranh3")
.mul(getFreq('c3'))
.freq().clip(.5).room(1)`}
/>
@@ -39,7 +35,7 @@ Some tunings become more pronounced with a longer reverb decay:
<MiniRepl
client:idle
tune={`i("<[5 6 8 10] - [5 7 9 12] -> -").tune("gumbeng")
tune={`"<[5 6 8 10] - [5 7 9 12] -> -".tune("gumbeng")
.mul(getFreq('c3'))
.freq().clip(.8).room("3:10").rdim(10000).rfade(5)`}
/>
@@ -48,7 +44,7 @@ Additionally, you can combo this with `fmap` so that the base note changes:
<MiniRepl
client:idle
tune={`i("9 11 12 10 - - -").tune("gunkali")
tune={`"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)`}
/>
@@ -57,7 +53,8 @@ Combining this with various polyrhythm tricks can become very evocative:
<MiniRepl
client:idle
tune={`i("<[0 3 1 -] [-1 4 2 8]> ~ ~,<-4 -5>".add(4))
tune={`"<[0 3 1 -] [-1 4 2 8]> ~ ~,<-4 -5>"
.transpose(4)
.tune("iraq")
.mul("<c3 d3 c#3>".fmap(getFreq))
.freq().clip(.5).room(1).rfade(9)`}
@@ -70,7 +67,7 @@ Take the `sanza` tuning:
<MiniRepl
client:idle
tune={`i("4 5 6 7 8 9").tune("sanza")
tune={`"4 5 6 7 8 9".tune("sanza")
.mul(getFreq('c3'))
.freq()`}
/>
@@ -78,15 +75,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={`i("[7 9]!3").tune("sanza").mul(getFreq('c3')).freq()._pitchwheel()`} />
<MiniRepl client:idle tune={`"[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={`i("[0 1 2 3 4 5 6]@0.3 -"
.add("<2 5 8 1>"))
tune={`"[0 1 2 3 4 5 6]@0.3 -"
.transpose("<2 5 8 1>")
.tune("sanza")
.mul(getFreq('c3')).freq()
.legato("3").room(1).rfade(5)`}
@@ -96,23 +93,3 @@ 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} />
@@ -1,85 +0,0 @@
---
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.
+17 -17
View File
@@ -4,11 +4,7 @@ import jsdocJson from '../../../../../doc.json';
import { Textbox } from '@src/repl/components/panel/SettingsTab';
import { settingsMap, useSettings } from '@src/settings.mjs';
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 isValid = ({ name, description }) => name && !name.startsWith('_') && !!description;
const availableFunctions = (() => {
const seen = new Set(); // avoid repetition
@@ -18,32 +14,36 @@ 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 superdough` here.
// @something is. We only want data from comments like `@tags fx, 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 && !ignoredTags.includes(t)) {
if (typeof t === 'string' && 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.allNames || entry.name).toLowerCase().includes(lowerCaseSearch) ||
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={
'whitespace-nowrap cursor-pointer hover:opacity-50 text-ellipsis block' +
'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.synonyms && <small className="opacity-50">{entry.synonyms?.join(', ')}</small>}
{entry.name}
</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.filter((t) => !ignoredTags.includes(t)).join(', ')}
{entry.tags.join(', ')}
</span>
)}
</div>
@@ -150,7 +150,6 @@ export function SettingsTab({ started }) {
isMultiCursorEnabled,
patternAutoStart,
includePrebakeScriptInShare,
isBlockBasedEvalEnabled,
} = useSettings();
const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
@@ -246,7 +245,7 @@ export function SettingsTab({ started }) {
<ButtonGroup
value={keybindings}
onChange={(keybindings) => settingsMap.setKey('keybindings', keybindings)}
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', helix: 'Helix', vscode: 'VSCode' }}
items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
></ButtonGroup>
</FormItem>
<FormItem label="Panel Position">
@@ -307,11 +306,6 @@ 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)}
+12 -13
View File
@@ -1,4 +1,4 @@
import { extractMidiNoteFromString, registerSampleSource } from '@strudel/webaudio';
import { 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) => {
.map((soundFile, i) => {
const title = soundFile.title;
if (!isAudioFile(title)) {
return;
@@ -58,26 +58,25 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
splitRelativePath[splitRelativePath.length - 2] ?? soundFile.id.split(/\W+/)[0] ?? 'user';
const blob = soundFile.blob;
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);
return blobToDataUrl(blob).then((soundPath) => {
const titlePathMap = sounds.get(parentDirectory) ?? new Map();
sounds.set(parentDirectory, sampleInfoMap);
titlePathMap.set(title, soundPath);
sounds.set(parentDirectory, titlePathMap);
return;
});
}),
)
.then(() => {
sounds.forEach((sampleInfoMap, key) => {
const bank = Array.from(sampleInfoMap.keys())
sounds.forEach((titlePathMap, key) => {
const value = Array.from(titlePathMap.keys())
.sort((a, b) => {
return a.localeCompare(b);
})
.map((title) => sampleInfoMap.get(title));
registerSampleSource(key, bank, { prebake: false });
.map((title) => titlePathMap.get(title));
registerSampleSource(key, value, { prebake: false });
});
logger('imported sounds registered!', 'success');
+9 -16
View File
@@ -71,7 +71,6 @@ 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({
@@ -107,24 +106,17 @@ export function useReplContext() {
//post to iframe parent (like Udels) if it exists...
window.parent?.postMessage(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);
setLatestCode(code);
window.location.hash = '#' + code2hash(code);
setDocumentTitle(code);
const viewingPatternData = getViewingPatternData();
setVersionDefaultsFrom(fullBufferCode);
const data = { ...viewingPatternData, code: fullBufferCode };
setVersionDefaultsFrom(code);
const data = { ...viewingPatternData, code };
let id = data.id;
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
if (isExamplePattern) {
const codeHasChanged = fullBufferCode !== viewingPatternData.code;
const codeHasChanged = code !== viewingPatternData.code;
if (codeHasChanged) {
// fork example
const newPattern = userPattern.duplicate(data);
@@ -176,8 +168,9 @@ export function useReplContext() {
useEffect(() => {
let editorSettings = {};
Object.keys(defaultSettings).forEach((key) => {
// Don't use hasOwnProperty - nanostore uses proxies so values may not be own properties
editorSettings[key] = _settings[key];
if (Object.prototype.hasOwnProperty.call(_settings, key)) {
editorSettings[key] = _settings[key];
}
});
editorRef.current?.updateSettings(editorSettings);
}, [_settings]);
-2
View File
@@ -33,7 +33,6 @@ export const defaultSettings = {
isPatternHighlightingEnabled: true,
isTabIndentationEnabled: false,
isMultiCursorEnabled: false,
isBlockBasedEvalEnabled: false,
theme: 'strudelTheme',
fontFamily: 'monospace',
fontSize: 18,
@@ -90,7 +89,6 @@ 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),
+1 -2
View File
@@ -207,10 +207,9 @@ 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 = `${prefix}_patterns_${date}.json`;
downloadLink.download = `strudel_patterns_${date}.json`;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);