Compare commits

..

2 Commits

Author SHA1 Message Date
Aria c3018a29a0 Update docstring and add an example 2026-01-11 19:10:02 -06:00
Aria 6bd1c877aa Working version of inputs as sound sources 2026-01-11 19:03:20 -06:00
169 changed files with 1451 additions and 6626 deletions
-5
View File
@@ -1,5 +0,0 @@
{
"devToolbar": {
"enabled": false
}
}
-44
View File
@@ -1,44 +0,0 @@
name: Build and Deploy hot PRs
on:
pull_request_target:
types: [labeled]
# Allow one concurrent deployment
concurrency:
group: "warm-pages"
cancel-in-progress: false
jobs:
build:
if: ${{ github.event.label.name == 'serve-hot' }}
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: pnpm/action-setup@v4
with:
version: 9.12.2
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
eval $(ssh-agent -s)
echo "$SSH_PRIVATE_KEY" | ssh-add -
apt update && apt install -y rsync
mkdir -p ~/.ssh
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
rsync -atv --delete --delete-after --progress \
./website/dist/ \
strudel@matrix.toplap.org:/home/strudel/deploy/pr-${{ github.event.pull_request.number }}.hot.strudel.cc
+125 -1063
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -12,13 +12,6 @@ function defineTags(dictionary) {
doclet.synonyms = doclet.synonyms_text.split(/[ ,]+/); doclet.synonyms = doclet.synonyms_text.split(/[ ,]+/);
}, },
}); });
dictionary.defineTag('tags', {
mustHaveValue: true,
onTagged: function (doclet, tag) {
doclet.tags = tag.value.split(/[ ,]+/);
},
});
} }
module.exports = { defineTags: defineTags }; module.exports = { defineTags: defineTags };
-211
View File
@@ -1,211 +0,0 @@
0 silly argv {
0 silly argv _: [ 'version' ],
0 silly argv private: false,
0 silly argv lernaVersion: '8.1.9',
0 silly argv '$0': 'node_modules/lerna/dist/cli.js'
0 silly argv }
1 notice cli v8.1.9
2 verbose packageConfigs Explicit "packages" configuration found in lerna.json. Resolving packages using the configured glob(s): ["packages/*"]
3 verbose rootPath /Users/jaderose/Documents/Github/cstrudel/strudel
4 info versioning independent
5 silly isAnythingCommitted
6 verbose isAnythingCommitted 1
7 silly getCurrentBranch
8 verbose currentBranch main
9 silly remoteBranchExists
10 silly isBehindUpstream
11 silly isBehindUpstream main is behind origin/main by 0 commit(s) and ahead by 4
12 silly hasTags
13 verbose hasTags true
14 silly git-describe.sync "@strudel/codemirror@1.2.7-4-ga3ac9646" => {"lastTagName":"@strudel/codemirror@1.2.7","lastVersion":"1.2.7","refCount":"4","sha":"a3ac9646","isDirty":false}
15 info Looking for changed packages since @strudel/codemirror@1.2.7
16 silly checking diff packages/codemirror
17 silly no diff found in @strudel/codemirror
18 silly checking diff packages/core
19 silly no diff found in @strudel/core
20 silly checking diff packages/csound
21 silly no diff found in @strudel/csound
22 silly checking diff packages/desktopbridge
23 silly no diff found in @strudel/desktopbridge
24 silly checking diff packages/draw
25 silly no diff found in @strudel/draw
26 silly checking diff packages/embed
27 silly no diff found in @strudel/embed
28 silly checking diff packages/gamepad
29 silly no diff found in @strudel/gamepad
30 silly checking diff packages/hs2js
31 silly no diff found in hs2js
32 silly checking diff packages/hydra
33 silly no diff found in @strudel/hydra
34 silly checking diff packages/midi
35 silly no diff found in @strudel/midi
36 silly checking diff packages/mini
37 silly no diff found in @strudel/mini
38 silly checking diff packages/mondo
39 silly no diff found in mondolang
40 silly checking diff packages/mondough
41 silly no diff found in @strudel/mondo
42 silly checking diff packages/motion
43 silly no diff found in @strudel/motion
44 silly checking diff packages/mqtt
45 silly no diff found in @strudel/mqtt
46 silly checking diff packages/osc
47 silly found diff in packages/osc/server.js
48 verbose filtered diff [ 'packages/osc/server.js' ]
49 silly checking diff packages/reference
50 silly no diff found in @strudel/reference
51 silly checking diff packages/repl
52 silly no diff found in @strudel/repl
53 silly checking diff packages/sampler
54 silly no diff found in @strudel/sampler
55 silly checking diff packages/serial
56 silly no diff found in @strudel/serial
57 silly checking diff packages/soundfonts
58 silly no diff found in @strudel/soundfonts
59 silly checking diff packages/superdough
60 silly found diff in packages/superdough/package.json
61 verbose filtered diff [ 'packages/superdough/package.json' ]
62 silly checking diff packages/supradough
63 silly found diff in packages/supradough/package.json
64 verbose filtered diff [ 'packages/supradough/package.json' ]
65 silly checking diff packages/tidal
66 silly no diff found in @strudel/tidal
67 silly checking diff packages/tonal
68 silly no diff found in @strudel/tonal
69 silly checking diff packages/transpiler
70 silly no diff found in @strudel/transpiler
71 silly checking diff packages/vite-plugin-bundle-audioworklet
72 silly no diff found in vite-plugin-bundle-audioworklet
73 silly checking diff packages/web
74 silly no diff found in @strudel/web
75 silly checking diff packages/webaudio
76 silly found diff in packages/webaudio/package.json
77 verbose filtered diff [ 'packages/webaudio/package.json' ]
78 silly checking diff packages/xen
79 silly no diff found in @strudel/xen
80 verbose updated @strudel/codemirror
81 verbose updated @strudel/csound
82 verbose updated @strudel/midi
83 verbose updated @strudel/osc
84 verbose updated @strudel/repl
85 verbose updated @strudel/soundfonts
86 verbose updated superdough
87 verbose updated supradough
88 verbose updated @strudel/web
89 verbose updated @strudel/webaudio
90 verbose git-describe undefined => "@strudel/codemirror@1.2.7-4-ga3ac9646"
91 silly git-describe parsed => {"lastTagName":"@strudel/codemirror@1.2.7","lastVersion":"1.2.7","refCount":"4","sha":"a3ac9646","isDirty":false}
92 info execute Skipping releases
93 silly lifecycle No script for "preversion" in "@strudel/monorepo", continuing
94 silly lifecycle No script for "preversion" in "@strudel/osc", continuing
95 silly lifecycle No script for "preversion" in "superdough", continuing
96 silly lifecycle No script for "preversion" in "supradough", continuing
97 verbose version supradough has no lockfile. Skipping lockfile update.
98 verbose version @strudel/osc has no lockfile. Skipping lockfile update.
99 verbose version superdough has no lockfile. Skipping lockfile update.
100 silly lifecycle No script for "version" in "supradough", continuing
101 silly lifecycle No script for "version" in "superdough", continuing
102 silly lifecycle No script for "preversion" in "@strudel/codemirror", continuing
103 silly lifecycle No script for "preversion" in "@strudel/webaudio", continuing
104 silly lifecycle No script for "version" in "@strudel/osc", continuing
105 verbose version @strudel/webaudio has no lockfile. Skipping lockfile update.
106 verbose version @strudel/codemirror has no lockfile. Skipping lockfile update.
107 silly lifecycle No script for "version" in "@strudel/codemirror", continuing
108 silly lifecycle No script for "version" in "@strudel/webaudio", continuing
109 silly lifecycle No script for "preversion" in "@strudel/csound", continuing
110 silly lifecycle No script for "preversion" in "@strudel/midi", continuing
111 silly lifecycle No script for "preversion" in "@strudel/soundfonts", continuing
112 silly lifecycle No script for "preversion" in "@strudel/web", continuing
113 verbose version @strudel/csound has no lockfile. Skipping lockfile update.
114 verbose version @strudel/midi has no lockfile. Skipping lockfile update.
115 verbose version @strudel/soundfonts has no lockfile. Skipping lockfile update.
116 verbose version @strudel/web has no lockfile. Skipping lockfile update.
117 silly lifecycle No script for "version" in "@strudel/midi", continuing
118 silly lifecycle No script for "version" in "@strudel/csound", continuing
119 silly lifecycle No script for "version" in "@strudel/soundfonts", continuing
120 silly lifecycle No script for "preversion" in "@strudel/repl", continuing
121 verbose version @strudel/repl has no lockfile. Skipping lockfile update.
122 silly lifecycle No script for "version" in "@strudel/web", continuing
123 silly lifecycle No script for "version" in "@strudel/repl", continuing
124 silly lifecycle No script for "version" in "@strudel/monorepo", continuing
125 verbose version Updating root pnpm-lock.yaml
126 silly version Skipped applying prettier to ignored file: packages/supradough/package.json
127 silly version Skipped applying prettier to ignored file: packages/superdough/package.json
128 silly version Skipped applying prettier to ignored file: packages/osc/package.json
129 silly version Skipped applying prettier to ignored file: packages/codemirror/package.json
130 silly version Skipped applying prettier to ignored file: packages/webaudio/package.json
131 silly version Skipped applying prettier to ignored file: packages/midi/package.json
132 silly version Skipped applying prettier to ignored file: packages/csound/package.json
133 silly version Skipped applying prettier to ignored file: packages/soundfonts/package.json
134 silly version Skipped applying prettier to ignored file: packages/web/package.json
135 silly version Skipped applying prettier to ignored file: packages/repl/package.json
136 silly version Skipped applying prettier to ignored file: pnpm-lock.yaml
137 silly gitAdd [
137 silly gitAdd 'packages/supradough/package.json',
137 silly gitAdd 'packages/superdough/package.json',
137 silly gitAdd 'packages/osc/package.json',
137 silly gitAdd 'packages/codemirror/package.json',
137 silly gitAdd 'packages/webaudio/package.json',
137 silly gitAdd 'packages/midi/package.json',
137 silly gitAdd 'packages/csound/package.json',
137 silly gitAdd 'packages/soundfonts/package.json',
137 silly gitAdd 'packages/web/package.json',
137 silly gitAdd 'packages/repl/package.json',
137 silly gitAdd 'pnpm-lock.yaml'
137 silly gitAdd ]
138 silly gitCommit Publish
138 silly gitCommit
138 silly gitCommit - @strudel/codemirror@1.2.8
138 silly gitCommit - @strudel/csound@1.2.8
138 silly gitCommit - @strudel/midi@1.2.8
138 silly gitCommit - @strudel/osc@1.3.2
138 silly gitCommit - @strudel/repl@1.2.9
138 silly gitCommit - @strudel/soundfonts@1.2.8
138 silly gitCommit - superdough@1.2.5
138 silly gitCommit - supradough@1.2.4
138 silly gitCommit - @strudel/web@1.2.8
138 silly gitCommit - @strudel/webaudio@1.2.9
139 verbose git [
139 verbose git 'commit',
139 verbose git '-F',
139 verbose git '/private/var/folders/hc/yf_zr55547sbcpz5rj7q85sc0000gn/T/3c85b5d4-56ae-40e9-8df7-db2bc1048402/lerna-commit.txt'
139 verbose git ]
140 silly gitTag @strudel/codemirror@1.2.8 git tag %s -m %s
141 verbose git [
141 verbose git 'tag',
141 verbose git '@strudel/codemirror@1.2.8',
141 verbose git '-m',
141 verbose git '@strudel/codemirror@1.2.8'
141 verbose git ]
142 silly gitTag @strudel/csound@1.2.8 git tag %s -m %s
143 verbose git [ 'tag', '@strudel/csound@1.2.8', '-m', '@strudel/csound@1.2.8' ]
144 silly gitTag @strudel/midi@1.2.8 git tag %s -m %s
145 verbose git [ 'tag', '@strudel/midi@1.2.8', '-m', '@strudel/midi@1.2.8' ]
146 silly gitTag @strudel/osc@1.3.2 git tag %s -m %s
147 verbose git [ 'tag', '@strudel/osc@1.3.2', '-m', '@strudel/osc@1.3.2' ]
148 silly gitTag @strudel/repl@1.2.9 git tag %s -m %s
149 verbose git [ 'tag', '@strudel/repl@1.2.9', '-m', '@strudel/repl@1.2.9' ]
150 silly gitTag @strudel/soundfonts@1.2.8 git tag %s -m %s
151 verbose git [
151 verbose git 'tag',
151 verbose git '@strudel/soundfonts@1.2.8',
151 verbose git '-m',
151 verbose git '@strudel/soundfonts@1.2.8'
151 verbose git ]
152 silly gitTag superdough@1.2.5 git tag %s -m %s
153 verbose git [ 'tag', 'superdough@1.2.5', '-m', 'superdough@1.2.5' ]
154 silly gitTag supradough@1.2.4 git tag %s -m %s
155 verbose git [ 'tag', 'supradough@1.2.4', '-m', 'supradough@1.2.4' ]
156 silly gitTag @strudel/web@1.2.8 git tag %s -m %s
157 verbose git [ 'tag', '@strudel/web@1.2.8', '-m', '@strudel/web@1.2.8' ]
158 silly gitTag @strudel/webaudio@1.2.9 git tag %s -m %s
159 verbose git [ 'tag', '@strudel/webaudio@1.2.9', '-m', '@strudel/webaudio@1.2.9' ]
160 error Error: Command failed with exit code 128: git tag superdough@1.2.5 -m superdough@1.2.5
160 error fatal: tag 'superdough@1.2.5' already exists
160 error at makeError (/Users/jaderose/Documents/Github/cstrudel/strudel/node_modules/.pnpm/execa@5.0.0/node_modules/execa/lib/error.js:59:11)
160 error at handlePromise (/Users/jaderose/Documents/Github/cstrudel/strudel/node_modules/.pnpm/execa@5.0.0/node_modules/execa/index.js:114:26)
160 error at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
160 error at async Promise.all (index 6)
160 error at async VersionCommand.gitCommitAndTagVersionForUpdates (/Users/jaderose/Documents/Github/cstrudel/strudel/node_modules/.pnpm/lerna@8.1.9_encoding@0.1.13/node_modules/lerna/dist/index.js:9957:11)
160 error at async VersionCommand.commitAndTagUpdates (/Users/jaderose/Documents/Github/cstrudel/strudel/node_modules/.pnpm/lerna@8.1.9_encoding@0.1.13/node_modules/lerna/dist/index.js:9933:18)
160 error at async Promise.all (index 0)
-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;
};
+19 -154
View File
@@ -11,26 +11,19 @@ import {
keymap, keymap,
lineNumbers, lineNumbers,
} from '@codemirror/view'; } from '@codemirror/view';
import {repeatCharKeymap} from './repeatcharacter.mjs';
import { persistentAtom } from '@nanostores/persistent'; import { persistentAtom } from '@nanostores/persistent';
import { logger, registerControl, repl } from '@strudel/core'; 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 { isAutoCompletionEnabled } from './autocomplete.mjs';
import { basicSetup } from './basicSetup.mjs'; import { basicSetup } from './basicSetup.mjs';
import { evalBlock } from './block_utilities.mjs';
import { flash, isFlashEnabled } from './flash.mjs'; import { flash, isFlashEnabled } from './flash.mjs';
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
import { keybindings } from './keybindings.mjs'; import { keybindings } from './keybindings.mjs';
import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs'; import { activateTheme, initTheme, theme } from './themes.mjs';
import { isTooltipEnabled } from './tooltip.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'; export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
@@ -50,9 +43,9 @@ export const extensions = {
isMultiCursorEnabled: (on) => isMultiCursorEnabled: (on) =>
on on
? [ ? [
EditorState.allowMultipleSelections.of(true), EditorState.allowMultipleSelections.of(true),
EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey), EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey),
] ]
: [], : [],
}; };
export const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()])); export const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
@@ -70,7 +63,6 @@ export const defaultSettings = {
isLineWrappingEnabled: false, isLineWrappingEnabled: false,
isTabIndentationEnabled: false, isTabIndentationEnabled: false,
isMultiCursorEnabled: false, isMultiCursorEnabled: false,
isBlockBasedEvalEnabled: false,
theme: 'strudelTheme', theme: 'strudelTheme',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 18, fontSize: 18,
@@ -81,12 +73,8 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
decode: JSON.parse, decode: JSON.parse,
}); });
const ANON_LABEL = '$';
const SOLO_LABEL = 'S';
const MUTE_LABEL = '_';
// https://codemirror.net/docs/guide/ // 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 settings = codemirrorSettings.get();
const initialSettings = Object.keys(compartments).map((key) => const initialSettings = Object.keys(compartments).map((key) =>
compartments[key].of(extensions[key](parseBooleans(settings[key]))), compartments[key].of(extensions[key](parseBooleans(settings[key]))),
@@ -112,31 +100,15 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
syntaxHighlighting(defaultHighlightStyle), syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((v) => onChange(v)), EditorView.updateListener.of((v) => onChange(v)),
drawSelection({ cursorBlinkRate: 0 }), drawSelection({ cursorBlinkRate: 0 }),
repeatCharKeymap,
Prec.highest( Prec.highest(
keymap.of([ keymap.of([
{ {
key: 'Ctrl-Enter', key: 'Ctrl-Enter',
run: () => { run: () => onEvaluate?.(),
// issue with referencing settings, this works more reliably
if (strudelMirror?.isBlockBasedEvalEnabled) {
evalBlock(strudelMirror);
return true;
} else {
return onEvaluate?.();
}
},
}, },
{ {
key: 'Alt-Enter', key: 'Alt-Enter',
run: () => { run: () => onEvaluate?.(),
if (strudelMirror?.isBlockBasedEvalEnabled) {
evalBlock(strudelMirror);
return true;
} else {
return onEvaluate?.();
}
},
}, },
{ {
key: 'Ctrl-.', key: 'Ctrl-.',
@@ -147,76 +119,6 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
preventDefault: true, preventDefault: true,
run: () => onStop?.(), run: () => onStop?.(),
}, },
{
key: 'Alt-w',
run: (view) => jumpToNextCharacter(view, ANON_LABEL, 1),
},
{
key: 'Alt-q',
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-.', key: 'Ctrl-Shift-.',
run: () => (onPanic ? onPanic() : onStop?.()), run: () => (onPanic ? onPanic() : onStop?.()),
@@ -260,7 +162,6 @@ export class StrudelMirror {
this.onDraw = onDraw || this.draw; this.onDraw = onDraw || this.draw;
this.id = id || s4(); this.id = id || s4();
this.solo = solo; this.solo = solo;
this.isBlockBasedEvalEnabled = false; // Will be updated via updateSettings()
this.drawer = new Drawer((haps, time, _, painters) => { this.drawer = new Drawer((haps, time, _, painters) => {
const currentFrame = haps.filter((hap) => hap.isActive(time)); const currentFrame = haps.filter((hap) => hap.isActive(time));
@@ -291,28 +192,20 @@ export class StrudelMirror {
cleanupDraw(true, id); cleanupDraw(true, id);
} }
}, },
beforeEval: async ({ blockBased } = {}) => { beforeEval: async () => {
// Only clean up all drawings for full evaluation cleanupDraw(true, id);
// Block-based eval should preserve animations (like .scope()) from other blocks
if (!blockBased) {
cleanupDraw(true, id);
}
await this.prebaked; await this.prebaked;
await replOptions?.beforeEval?.(); await replOptions?.beforeEval?.();
}, },
afterEval: (options) => { afterEval: (options) => {
// remember for when highlighting is toggled on // remember for when highlighting is toggled on
this.miniLocations = options.meta?.miniLocations || []; this.miniLocations = options.meta?.miniLocations;
this.widgets = options.meta?.widgets; this.widgets = options.meta?.widgets;
const sliders = this.widgets.filter((w) => w.type === 'slider'); const sliders = this.widgets.filter((w) => w.type === 'slider');
updateSliderWidgets(this.editor, sliders);
const widgets = this.widgets.filter((w) => w.type !== 'slider'); const widgets = this.widgets.filter((w) => w.type !== 'slider');
// range-aware update for block-based evaluation updateWidgets(this.editor, widgets);
const range = options.range && options.range.length >= 2 ? options.range : null; updateMiniLocations(this.editor, this.miniLocations);
updateSliderWidgets(this.editor, sliders, range);
updateWidgets(this.editor, widgets, range);
updateMiniLocations(this.editor, this.miniLocations, range);
replOptions?.afterEval?.(options); replOptions?.afterEval?.(options);
// if no painters are set (.onPaint was not called), then we only need // if no painters are set (.onPaint was not called), then we only need
// the present moment (for highlighting) // the present moment (for highlighting)
@@ -320,14 +213,8 @@ export class StrudelMirror {
this.drawer.setDrawTime(drawTime); this.drawer.setDrawTime(drawTime);
// invalidate drawer after we've set the appropriate drawTime // invalidate drawer after we've set the appropriate drawTime
this.drawer.invalidate(this.repl.scheduler); 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({ this.editor = initEditor({
root, root,
initialCode, initialCode,
@@ -340,9 +227,7 @@ export class StrudelMirror {
onEvaluate: () => this.evaluate(), onEvaluate: () => this.evaluate(),
onStop: () => this.stop(), onStop: () => this.stop(),
mondo: replOptions.mondo, mondo: replOptions.mondo,
strudelMirror: this,
}); });
const cmEditor = this.root.querySelector('.cm-editor'); const cmEditor = this.root.querySelector('.cm-editor');
if (cmEditor) { if (cmEditor) {
this.root.style.display = 'block'; this.root.style.display = 'block';
@@ -412,9 +297,8 @@ export class StrudelMirror {
this.flash(); this.flash();
await this.repl.evaluate(this.code, autostart); await this.repl.evaluate(this.code, autostart);
} }
async stop() { async stop() {
this.repl.stop(); this.repl.scheduler.stop();
} }
// Listen for global stop requests (e.g., from Vim :q) // Listen for global stop requests (e.g., from Vim :q)
@@ -433,8 +317,8 @@ export class StrudelMirror {
this.evaluate(); this.evaluate();
} }
} }
flash(ms, range) { flash(ms) {
flash(this.editor, ms, range); flash(this.editor, ms);
} }
highlight(haps, time) { highlight(haps, time) {
highlightMiniLocations(this.editor, time, haps); highlightMiniLocations(this.editor, time, haps);
@@ -466,10 +350,6 @@ export class StrudelMirror {
setLineWrappingEnabled(enabled) { setLineWrappingEnabled(enabled) {
this.reconfigureExtension('isLineWrappingEnabled', enabled); this.reconfigureExtension('isLineWrappingEnabled', enabled);
} }
setBlockBasedEvalEnabled(enabled) {
this.reconfigureExtension('isBlockBasedEvalEnabled', enabled);
}
setBracketMatchingEnabled(enabled) { setBracketMatchingEnabled(enabled) {
this.reconfigureExtension('isBracketMatchingEnabled', enabled); this.reconfigureExtension('isBracketMatchingEnabled', enabled);
} }
@@ -491,10 +371,6 @@ export class StrudelMirror {
for (let key in extensions) { for (let key in extensions) {
this.reconfigureExtension(key, settings[key]); 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 }; const updated = { ...codemirrorSettings.get(), ...settings };
codemirrorSettings.set(updated); codemirrorSettings.set(updated);
} }
@@ -516,16 +392,6 @@ export class StrudelMirror {
}; };
this.editor.dispatch({ changes }); 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() { clear() {
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest); this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
@@ -559,7 +425,6 @@ function s4() {
/** /**
* Overrides the css of highlighted events. Make sure to use single quotes! * Overrides the css of highlighted events. Make sure to use single quotes!
* @name markcss * @name markcss
* @tag visualization
* @example * @example
* note("c a f e") * note("c a f e")
* .markcss('text-decoration:underline') * .markcss('text-decoration:underline')
+3 -5
View File
@@ -14,8 +14,7 @@ export const flashField = StateField.define({
const mark = Decoration.mark({ const mark = Decoration.mark({
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` }, 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(0, tr.newDoc.length)]);
flash = Decoration.set([mark.range(range.from, range.to)]);
} else { } else {
flash = Decoration.set([]); flash = Decoration.set([]);
} }
@@ -30,9 +29,8 @@ export const flashField = StateField.define({
provide: (f) => EditorView.decorations.from(f), provide: (f) => EditorView.decorations.from(f),
}); });
export const flash = (view, ms = 200, range) => { export const flash = (view, ms = 200) => {
const flashData = range ? { range } : true; view.dispatch({ effects: setFlash.of(true) });
view.dispatch({ effects: setFlash.of(flashData) });
setTimeout(() => { setTimeout(() => {
view.dispatch({ effects: setFlash.of(false) }); view.dispatch({ effects: setFlash.of(false) });
}, ms); }, ms);
+60 -161
View File
@@ -3,9 +3,8 @@ import { Decoration, EditorView } from '@codemirror/view';
export const setMiniLocations = StateEffect.define(); export const setMiniLocations = StateEffect.define();
export const showMiniLocations = StateEffect.define(); export const showMiniLocations = StateEffect.define();
export const displayMiniLocations = StateEffect.define(); export const updateMiniLocations = (view, locations) => {
export const updateMiniLocations = (view, locations, range = null) => { view.dispatch({ effects: setMiniLocations.of(locations) });
view.dispatch({ effects: setMiniLocations.of({ locations, range }) });
}; };
export const highlightMiniLocations = (view, atTime, haps) => { export const highlightMiniLocations = (view, atTime, haps) => {
view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) }); view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
@@ -22,54 +21,23 @@ const miniLocations = StateField.define({
for (let e of tr.effects) { for (let e of tr.effects) {
if (e.is(setMiniLocations)) { if (e.is(setMiniLocations)) {
//block-based eval case // this is called on eval, with the mini locations obtained from the transpiler
if (e.value.range) { // codemirror will automatically remap the marks when the document is edited
const stateMiniLocations = getMiniLocationsFromDecorations(locations); // create a mark for each mini location, adding the range to the spec to find it later
const marks = e.value
const normalized = e.value.locations .filter(([from]) => from < tr.newDoc.length)
.filter(([from]) => from < tr.newDoc.length) .map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]); .map(
(range) =>
const newIds = new Set(normalized.map((r) => r.join(':')));
const marks = normalized.map((range) => {
const id = range.join(':');
return Decoration.mark({
id,
// this green is only to verify that the decoration moves when the document is edited
// it will be removed later, so the mark is not visible by default
attributes: { style: `background-color: #00CA2880` },
}).range(...range); // -> Decoration
});
const previousMarks = stateMiniLocations
.filter(({ id }) => !newIds.has(id))
.map(({ from, to, id }) =>
Decoration.mark({ 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` }, attributes: { style: `background-color: #00CA2880` },
}).range(from, to), }).range(...range), // -> Decoration
); );
locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet<Decoration> locations = Decoration.set(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>
}
} }
} }
@@ -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 // // Derive the set of decorations from the miniLocations and visibleLocations
const miniLocationHighlights = EditorView.decorations.compute( const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => {
[miniLocations, visibleMiniLocations, displayMiniLocationsState], const iterator = state.field(miniLocations).iter();
(state) => { const { haps } = state.field(visibleMiniLocations);
// Check if miniLocations display is disabled const builder = new RangeSetBuilder();
const shouldDisplay = state.field(displayMiniLocationsState);
if (!shouldDisplay) {
return Decoration.none; // Return empty decorations if display is disabled
}
const iterator = state.field(miniLocations).iter();
const { haps } = state.field(visibleMiniLocations);
const builder = new RangeSetBuilder();
while (iterator.value) {
const {
from,
to,
value: {
spec: { id },
},
} = iterator;
if (haps.has(id)) {
const hap = haps.get(id);
const color = hap.value?.color ?? 'var(--foreground)';
const style = hap.value?.markcss || `outline: solid 2px ${color}`;
// Get explicit channels for color values
/*
const swatch = document.createElement('div');
swatch.style.color = color;
document.body.appendChild(swatch);
let channels = getComputedStyle(swatch)
.color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
.slice(1)
.map((c) => parseFloat(c || 1));
document.body.removeChild(swatch);
// Get percentage of event
const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
channels[3] *= percent;
*/
builder.add(
from,
to,
Decoration.mark({
// attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
attributes: { style },
}),
);
}
iterator.next();
}
return builder.finish();
},
);
const getMiniLocationsFromDecorations = (decorations) => {
const iterator = decorations.iter();
const miniLocationsArray = [];
while (iterator.value) { while (iterator.value) {
const { const {
from, from,
@@ -192,48 +89,50 @@ const getMiniLocationsFromDecorations = (decorations) => {
spec: { id }, spec: { id },
}, },
} = iterator; } = iterator;
miniLocationsArray.push({
from, if (haps.has(id)) {
to, const hap = haps.get(id);
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(); iterator.next();
} }
return miniLocationsArray;
};
export const getMiniLocations = (state) => { return builder.finish();
const decorations = state.field(miniLocations); });
return getMiniLocationsFromDecorations(decorations);
};
export const getActiveMiniLocations = (state) => { export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights];
const miniLocations = getMiniLocations(state);
const { haps } = state.field(visibleMiniLocations);
const activeMiniLocations = miniLocations.filter((location) => haps.has(location.id));
return activeMiniLocations;
};
export const highlightExtension = [
miniLocations,
visibleMiniLocations,
displayMiniLocationsState,
miniLocationHighlights,
];
export const isPatternHighlightingEnabled = (on, config) => { export const isPatternHighlightingEnabled = (on, config) => {
// NOTE: on &&
// Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed. config &&
// This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly. setTimeout(() => {
// The setTimeout was also removed because it conflicted with the range-specific updates required by updateMiniLocations(config.editor, config.miniLocations);
// block based evaluation. }, 100);
// Not sure if this is the best approach, but for block based eval I can't think of a better way to do it. return on ? Prec.highest(highlightExtension) : [];
if (config) {
// Toggle the display state for miniLocations
config.editor.dispatch({ effects: displayMiniLocations.of(on) });
}
return 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, Vim } from '@replit/codemirror-vim';
// import { vim } from './vim_test.mjs'; // import { vim } from './vim_test.mjs';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import { helix, commands } from 'codemirror-helix';
import { logger } from '@strudel/core'; import { logger } from '@strudel/core';
const vscodePlugin = ViewPlugin.fromClass( const vscodePlugin = ViewPlugin.fromClass(
@@ -21,70 +20,6 @@ const vscodePlugin = ViewPlugin.fromClass(
); );
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []); 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 // 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 // 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 // Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
@@ -112,8 +47,29 @@ try {
// :q to pause/stop // :q to pause/stop
Vim.defineEx('quit', 'q', (cm) => { Vim.defineEx('quit', 'q', (cm) => {
const view = cm?.view || cm; try {
replStop(view); 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 // :w to evaluate
@@ -127,7 +83,38 @@ try {
} catch (e) { } catch (e) {
console.error('Error logging Vim :w evaluation', 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) { } catch (e) {
console.error('Error dispatching :w evaluation event', 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); 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 = { const keymaps = {
vim, vim,
emacs, emacs,
codemirror: () => keymap.of(defaultKeymap), codemirror: () => keymap.of(defaultKeymap),
vscode: vscodeExtension, vscode: vscodeExtension,
helix: () => [helix(), helixCommands],
}; };
export { Vim } from '@replit/codemirror-vim'; export { Vim } from '@replit/codemirror-vim';
-153
View File
@@ -1,153 +0,0 @@
import { EditorSelection } from '@codemirror/state';
import { SearchCursor } from '@codemirror/search';
import { EditorView } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
/**
* 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;
let jumpPos;
const characterPositions = getCharacterPositions(state, character);
if (!characterPositions.length) {
return true;
}
if (direction > 0) {
jumpPos = characterPositions.find((x) => x > pos + 1) ?? characterPositions.at(0); // Loop back around for convenience
} else {
jumpPos = characterPositions.reverse().find((x) => x < pos + 1) ?? characterPositions.at(0);
}
if (jumpPos == null) {
return true;
}
const selection = EditorSelection.cursor(jumpPos - 1);
dispatch({
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 {true}
*/
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 {true}
*/
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 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/codemirror", "name": "@strudel/codemirror",
"version": "1.3.0", "version": "1.2.6",
"description": "Codemirror Extensions for Strudel", "description": "Codemirror Extensions for Strudel",
"main": "index.mjs", "main": "index.mjs",
"publishConfig": { "publishConfig": {
@@ -44,7 +44,6 @@
"@replit/codemirror-emacs": "^6.1.0", "@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2", "@replit/codemirror-vscode-keymap": "^6.0.2",
"codemirror-helix": "^0.5.0",
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*", "@strudel/draw": "workspace:*",
"@strudel/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
-55
View File
@@ -1,55 +0,0 @@
import { Compartment } from "@codemirror/state";
import { keymap } from "@codemirror/view";
const repeatMode = new Compartment();
function repeatPreviousChar(times) {
return ({ state, dispatch }) => {
const { from, empty } = state.selection.main;
if (!empty || from === 0) return false;
const prevChar = state.doc.sliceString(from - 1, from);
const text = Array(times).fill(prevChar).join(" ");
dispatch(state.update({
changes: {
from,
insert: " " + text
}
}));
return true;
};
}
function exitRepeatMode(view) {
view.dispatch({
effects: repeatMode.reconfigure([])
});
}
const digitBindings = Array.from({ length: 9 }, (_, i) => ({
key: String(i + 1),
run(view) {
repeatPreviousChar(i + 1)(view);
exitRepeatMode(view);
return true;
}
}));
export const repeatCharKeymap = [
repeatMode.of([]),
keymap.of([
{
key: "Alt-r",
run(view) {
view.dispatch({
effects: repeatMode.reconfigure(keymap.of(digitBindings))
});
return true;
}
}
])
];
+17 -107
View File
@@ -2,11 +2,11 @@ import { ref, pure } from '@strudel/core';
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view'; import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
import { StateEffect } from '@codemirror/state'; import { StateEffect } from '@codemirror/state';
// Global state storage for all widget types
export let sliderValues = {}; export let sliderValues = {};
const getSliderID = (from) => `slider_${from}`;
export class SliderWidget extends WidgetType { export class SliderWidget extends WidgetType {
constructor(value, min, max, from, to, step, view, id) { constructor(value, min, max, from, to, step, view) {
super(); super();
this.value = value; this.value = value;
this.min = min; this.min = min;
@@ -16,21 +16,10 @@ export class SliderWidget extends WidgetType {
this.to = to; this.to = to;
this.step = step; this.step = step;
this.view = view; this.view = view;
this.id = id || `${from}:${to}`; // Range-based ID for stability
} }
eq(other) { eq() {
if (!(other instanceof SliderWidget)) { return false;
return false;
}
return (
this.id === other.id &&
this.from === other.from &&
this.to === other.to &&
this.value === other.value &&
this.min === other.min &&
this.max === other.max
);
} }
toDOM() { toDOM() {
@@ -49,7 +38,6 @@ export class SliderWidget extends WidgetType {
slider.from = this.from; slider.from = this.from;
slider.originalFrom = this.originalFrom; slider.originalFrom = this.originalFrom;
slider.to = this.to; 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)'; slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
this.slider = slider; this.slider = slider;
slider.addEventListener('input', (e) => { slider.addEventListener('input', (e) => {
@@ -61,7 +49,7 @@ export class SliderWidget extends WidgetType {
slider.originalValue = insert; slider.originalValue = insert;
slider.value = insert; slider.value = insert;
this.view.dispatch({ changes: change }); 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 }); window.postMessage({ type: 'cm-slider', value: Number(next), id });
}); });
return wrap; return wrap;
@@ -74,60 +62,19 @@ export class SliderWidget extends WidgetType {
export const setSliderWidgets = StateEffect.define(); export const setSliderWidgets = StateEffect.define();
export const setSliderWidgetsInRange = StateEffect.define(); export const updateSliderWidgets = (view, widgets) => {
view.dispatch({ effects: setSliderWidgets.of(widgets) });
export const updateSliderWidgets = (view, widgets, range = null) => {
if (range) {
// range argument passed for block-based evaluation
view.dispatch({ effects: setSliderWidgetsInRange.of({ widgets, range }) });
} else {
view.dispatch({ effects: setSliderWidgets.of(widgets) });
}
}; };
function getSliders(widgetConfigs, view) { function getSliders(widgetConfigs, view) {
return ( return widgetConfigs
widgetConfigs .filter((w) => w.type === 'slider')
.filter((w) => w.type === 'slider') .map(({ from, to, value, min, max, step }) => {
// Deduplicate sliders that might appear multiple times (e.g., during paste operations) return Decoration.widget({
.filter((slider, index, self) => index === self.findIndex((s) => s.from === slider.from && s.to === slider.to)) widget: new SliderWidget(value, min, max, from, to, step, view),
.sort((a, b) => a.from - b.from) side: 0,
.map(({ from, to, value, min, max, step, id }) => { }).range(from /* , to */);
return Decoration.widget({ });
widget: new SliderWidget(value, min, max, from, to, step, view, id),
side: 0,
}).range(from /* , to */);
})
);
}
export function getSliderWidgets(view) {
if (!view || !view.state) {
return [];
}
const sliderPluginInstance = view.plugin(sliderPlugin);
if (!sliderPluginInstance || !sliderPluginInstance.decorations) {
return [];
}
const sliderWidgets = [];
sliderPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof SliderWidget) {
sliderWidgets.push({
type: 'slider',
from: decoration.widget.from,
to: decoration.widget.to,
value: decoration.widget.value,
min: decoration.widget.min,
max: decoration.widget.max,
step: decoration.widget.step,
});
}
});
return sliderWidgets;
} }
export const sliderPlugin = ViewPlugin.fromClass( export const sliderPlugin = ViewPlugin.fromClass(
@@ -154,42 +101,7 @@ export const sliderPlugin = ViewPlugin.fromClass(
} }
} }
for (let e of tr.effects) { for (let e of tr.effects) {
if (e.is(setSliderWidgetsInRange)) { if (e.is(setSliderWidgets)) {
// Block-aware slider update logic
const { widgets, range } = e.value;
const [rangeStart, rangeEnd] = range;
// Get existing slider widgets that should be preserved
const existingSliders = [];
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof SliderWidget) {
// Preserve sliders outside the evaluation range
// Use strict > for rangeEnd because when code is deleted, slider positions
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
if (from < rangeStart || from > rangeEnd) {
existingSliders.push({
from,
to,
value: decoration.widget.value,
min: decoration.widget.min,
max: decoration.widget.max,
step: decoration.widget.step,
id: decoration.widget.id || `${from}:${to}`,
type: 'slider',
});
}
}
});
// Merge preserved sliders with new widgets
const mergedWidgets = [...existingSliders, ...widgets]
.filter(
(slider, index, self) => index === self.findIndex((s) => s.type === 'slider' && s.id === slider.id),
)
.sort((a, b) => a.from - b.from);
this.decorations = Decoration.set(getSliders(mergedWidgets, update.view));
} else if (e.is(setSliderWidgets)) {
this.decorations = Decoration.set(getSliders(e.value, update.view)); this.decorations = Decoration.set(getSliders(e.value, update.view));
} }
} }
@@ -205,7 +117,6 @@ export const sliderPlugin = ViewPlugin.fromClass(
* Displays a slider widget to allow the user manipulate a value * Displays a slider widget to allow the user manipulate a value
* *
* @name slider * @name slider
* @tags external_io, visualization
* @param {number} value Initial value * @param {number} value Initial value
* @param {number} min Minimum value - optional, defaults to 0 * @param {number} min Minimum value - optional, defaults to 0
* @param {number} max Maximum value - optional, defaults to 1 * @param {number} max Maximum value - optional, defaults to 1
@@ -220,7 +131,6 @@ export let sliderWithID = (id, value, min, max) => {
sliderValues[id] = value; // sync state at eval time (code -> state) sliderValues[id] = value; // sync state at eval time (code -> state)
return ref(() => sliderValues[id]); // use state at query time return ref(() => sliderValues[id]); // use state at query time
}; };
// update state when sliders are moved // update state when sliders are moved
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
@@ -229,7 +139,7 @@ if (typeof window !== 'undefined') {
// update state when slider is moved // update state when slider is moved
sliderValues[e.data.id] = e.data.value; sliderValues[e.data.id] = e.data.value;
} else { } 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)}`);
} }
} }
}); });
-1
View File
@@ -198,7 +198,6 @@ export function activateTheme(name) {
const themeSettings = settings[name] || settings.strudelTheme; const themeSettings = settings[name] || settings.strudelTheme;
// set css variables // set css variables
themeStyle.innerHTML = `:root { themeStyle.innerHTML = `:root {
color-scheme: ${themeSettings.light ? 'light' : 'dark'};
${Object.entries(themeSettings) ${Object.entries(themeSettings)
// important to override fallback // important to override fallback
.map(([key, value]) => `--${key}: ${value} !important;`) .map(([key, value]) => `--${key}: ${value} !important;`)
+1 -3
View File
@@ -17,15 +17,13 @@ export const settings = {
background: 'white', background: 'white',
lineBackground: 'transparent', lineBackground: 'transparent',
foreground: deepPurple, foreground: deepPurple,
muted: deepPurple + '50',
caret: '#797977', caret: '#797977',
selection: yellowPink, selection: yellowPink,
selectionMatch: '#2B323D', selectionMatch: '#2B323D',
gutterBackground: pinkAccent, gutterBackground: grey,
gutterForeground: lightGrey, gutterForeground: lightGrey,
gutterBorder: 'transparent', gutterBorder: 'transparent',
lineHighlight: pinkAccent, lineHighlight: pinkAccent,
light: true,
}; };
export default createTheme({ export default createTheme({
-1
View File
@@ -31,7 +31,6 @@ const palette = palettes['Sour Watermelon B'];
export const settings = { export const settings = {
background: palette[3], background: palette[3],
foreground: palette[1], foreground: palette[1],
muted: palette[1] + '50',
caret: palette[0], caret: palette[0],
selection: palette[0], selection: palette[0],
selectionMatch: palette[1], selectionMatch: palette[1],
-2
View File
@@ -8,7 +8,6 @@ export const settings = {
background: '#282b2e', background: '#282b2e',
lineBackground: '#282b2e99', lineBackground: '#282b2e99',
foreground: '#a9b7c6', foreground: '#a9b7c6',
muted: '#a9b7c650',
caret: '#00FF00', caret: '#00FF00',
selection: '#343739', selection: '#343739',
selectionMatch: '#343739', selectionMatch: '#343739',
@@ -20,7 +19,6 @@ export default createTheme({
settings: { settings: {
background: '#282b2e', background: '#282b2e',
foreground: '#a9b7c6', foreground: '#a9b7c6',
muted: '#a9b7c650',
caret: '#00FF00', caret: '#00FF00',
selection: '#4e5254', selection: '#4e5254',
selectionMatch: '#4e5254', selectionMatch: '#4e5254',
-1
View File
@@ -12,7 +12,6 @@ export const settings = {
background: hex[0], background: hex[0],
lineBackground: 'transparent', lineBackground: 'transparent',
foreground: hex[1], foreground: hex[1],
muted: hex[2],
selection: hex[2], selection: hex[2],
selectionMatch: hex[0], selectionMatch: hex[0],
gutterBackground: hex[0], gutterBackground: hex[0],
-2
View File
@@ -11,7 +11,6 @@ export const settings = {
background: '#272C35', background: '#272C35',
lineBackground: '#272C3599', lineBackground: '#272C3599',
foreground: 'hsl(220, 14%, 71%)', foreground: 'hsl(220, 14%, 71%)',
muted: 'hsl(220, 14%, 41%)',
caret: '#797977', caret: '#797977',
selection: '#ffffff30', selection: '#ffffff30',
selectionMatch: '#2B323D', selectionMatch: '#2B323D',
@@ -26,7 +25,6 @@ export default createTheme({
settings: { settings: {
background: '#272C35', background: '#272C35',
foreground: '#9d9b97', foreground: '#9d9b97',
muted: 'hsl(220, 14%, 41%)',
caret: '#797977', caret: '#797977',
selection: '#3d4c64', selection: '#3d4c64',
selectionMatch: '#3d4c64', selectionMatch: '#3d4c64',
-2
View File
@@ -5,7 +5,6 @@ export const settings = {
background: '#21202e', background: '#21202e',
lineBackground: '#21202e99', lineBackground: '#21202e99',
foreground: '#edecee', foreground: '#edecee',
muted: '#edecee50',
caret: '#a277ff', caret: '#a277ff',
selection: '#3d375e7f', selection: '#3d375e7f',
selectionMatch: '#3d375e7f', selectionMatch: '#3d375e7f',
@@ -19,7 +18,6 @@ export default createTheme({
settings: { settings: {
background: '#21202e', background: '#21202e',
foreground: '#edecee', foreground: '#edecee',
muted: '#edecee50',
caret: '#a277ff', caret: '#a277ff',
selection: '#5a51898f', selection: '#5a51898f',
selectionMatch: '#5a51898f', selectionMatch: '#5a51898f',
-2
View File
@@ -6,7 +6,6 @@ export const settings = {
background: '#FFFFFF', background: '#FFFFFF',
lineBackground: '#FFFFFF99', lineBackground: '#FFFFFF99',
foreground: '#000000', foreground: '#000000',
muted: '#00000050',
caret: '#FBAC52', caret: '#FBAC52',
selection: '#FFD420', selection: '#FFD420',
selectionMatch: '#FFD420', selectionMatch: '#FFD420',
@@ -21,7 +20,6 @@ export default createTheme({
settings: { settings: {
background: '#FFFFFF', background: '#FFFFFF',
foreground: '#000000', foreground: '#000000',
muted: '#00000050',
caret: '#FBAC52', caret: '#FBAC52',
selection: '#FFD420', selection: '#FFD420',
selectionMatch: '#FFD420', selectionMatch: '#FFD420',
+1 -2
View File
@@ -2,8 +2,7 @@ import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs'; import { createTheme } from './theme-helper.mjs';
export const settings = { export const settings = {
background: 'black', background: 'black',
foreground: 'white', foreground: 'white', // whats that?
muted: '#ffffff50',
caret: 'white', caret: 'white',
selection: '#ffffff20', selection: '#ffffff20',
selectionMatch: '#036dd626', selectionMatch: '#036dd626',
+1 -2
View File
@@ -3,8 +3,7 @@ import { createTheme } from './theme-helper.mjs';
export const settings = { export const settings = {
background: '#051DB5', background: '#051DB5',
lineBackground: '#051DB550', lineBackground: '#051DB550',
foreground: 'white', foreground: 'white', // whats that?
muted: '#ffffff50',
caret: 'white', caret: 'white',
selection: 'rgba(128, 203, 196, 0.5)', selection: 'rgba(128, 203, 196, 0.5)',
selectionMatch: '#036dd626', selectionMatch: '#036dd626',
-1
View File
@@ -11,7 +11,6 @@ export const settings = {
background: hex[0], background: hex[0],
lineBackground: 'transparent', lineBackground: 'transparent',
foreground: hex[2], foreground: hex[2],
muted: hex[3],
selection: hex[3], selection: hex[3],
selectionMatch: hex[0], selectionMatch: hex[0],
gutterBackground: hex[0], gutterBackground: hex[0],
-2
View File
@@ -9,7 +9,6 @@ export const settings = {
background: '#242424', background: '#242424',
lineBackground: '#24242499', lineBackground: '#24242499',
foreground: '#f8f8f2', foreground: '#f8f8f2',
muted: '#f8f8f250',
caret: '#FFFFFF', caret: '#FFFFFF',
selection: 'rgba(255, 255, 255, 0.1)', selection: 'rgba(255, 255, 255, 0.1)',
selectionMatch: 'rgba(255, 255, 255, 0.2)', selectionMatch: 'rgba(255, 255, 255, 0.2)',
@@ -24,7 +23,6 @@ export default createTheme({
settings: { settings: {
background: '#242424', background: '#242424',
foreground: '#f8f8f2', foreground: '#f8f8f2',
muted: '#f8f8f250',
caret: '#FFFFFF', caret: '#FFFFFF',
selection: 'rgba(255, 255, 255, 0.1)', selection: 'rgba(255, 255, 255, 0.1)',
selectionMatch: 'rgba(255, 255, 255, 0.2)', selectionMatch: 'rgba(255, 255, 255, 0.2)',
-2
View File
@@ -11,7 +11,6 @@ export const settings = {
background: '#282a36', background: '#282a36',
lineBackground: '#282a3699', lineBackground: '#282a3699',
foreground: '#f8f8f2', foreground: '#f8f8f2',
muted: '#f8f8f250',
caret: '#f8f8f0', caret: '#f8f8f0',
selection: 'rgba(255, 255, 255, 0.1)', selection: 'rgba(255, 255, 255, 0.1)',
selectionMatch: 'rgba(255, 255, 255, 0.2)', selectionMatch: 'rgba(255, 255, 255, 0.2)',
@@ -28,7 +27,6 @@ export default createTheme({
settings: { settings: {
background: '#282a36', background: '#282a36',
foreground: '#f8f8f2', foreground: '#f8f8f2',
muted: '#f8f8f250',
caret: '#f8f8f0', caret: '#f8f8f0',
selection: 'rgba(255, 255, 255, 0.1)', selection: 'rgba(255, 255, 255, 0.1)',
selectionMatch: 'rgba(255, 255, 255, 0.2)', selectionMatch: 'rgba(255, 255, 255, 0.2)',
-2
View File
@@ -10,7 +10,6 @@ export const settings = {
background: '#2a2734', background: '#2a2734',
lineBackground: '#2a273499', lineBackground: '#2a273499',
foreground: '#eeebff', foreground: '#eeebff',
muted: '#eeebff50',
caret: '#ffad5c', caret: '#ffad5c',
selection: 'rgba(255, 255, 255, 0.1)', selection: 'rgba(255, 255, 255, 0.1)',
gutterBackground: '#2a2734', gutterBackground: '#2a2734',
@@ -23,7 +22,6 @@ export default createTheme({
settings: { settings: {
background: '#2a2734', background: '#2a2734',
foreground: '#6c6783', foreground: '#6c6783',
muted: '#eeebff50',
caret: '#ffad5c', caret: '#ffad5c',
selection: '#9a86fd', selection: '#9a86fd',
selectionMatch: '#9a86fd', selectionMatch: '#9a86fd',
-2
View File
@@ -6,7 +6,6 @@ export const settings = {
background: '#fff', background: '#fff',
lineBackground: '#ffffff99', lineBackground: '#ffffff99',
foreground: '#000', foreground: '#000',
muted: '#00000050',
caret: '#FFFFFF', caret: '#FFFFFF',
selection: '#d7d4f0', selection: '#d7d4f0',
selectionMatch: '#d7d4f0', selectionMatch: '#d7d4f0',
@@ -21,7 +20,6 @@ export default createTheme({
settings: { settings: {
background: '#fff', background: '#fff',
foreground: '#000', foreground: '#000',
muted: '#00000050',
caret: '#FFFFFF', caret: '#FFFFFF',
selection: '#d7d4f0', selection: '#d7d4f0',
selectionMatch: '#d7d4f0', selectionMatch: '#d7d4f0',
-1
View File
@@ -23,7 +23,6 @@ export const settings = {
background: hex[0], background: hex[0],
lineBackground: 'transparent', lineBackground: 'transparent',
foreground: hex[10], foreground: hex[10],
muted: hex[7],
selection: hex[8], selection: hex[8],
selectionMatch: hex[0], selectionMatch: hex[0],
gutterBackground: hex[3], gutterBackground: hex[3],
-2
View File
@@ -5,7 +5,6 @@ export const settings = {
background: '#0d1117', background: '#0d1117',
lineBackground: '#0d111799', lineBackground: '#0d111799',
foreground: '#c9d1d9', foreground: '#c9d1d9',
muted: '#c9d1d950',
caret: '#c9d1d9', caret: '#c9d1d9',
selection: '#003d73', selection: '#003d73',
selectionMatch: '#003d73', selectionMatch: '#003d73',
@@ -17,7 +16,6 @@ export default createTheme({
settings: { settings: {
background: '#0d1117', background: '#0d1117',
foreground: '#c9d1d9', foreground: '#c9d1d9',
muted: '#c9d1d950',
caret: '#c9d1d9', caret: '#c9d1d9',
selection: '#003d73', selection: '#003d73',
selectionMatch: '#003d73', selectionMatch: '#003d73',
-2
View File
@@ -6,7 +6,6 @@ export const settings = {
background: '#fff', background: '#fff',
lineBackground: '#ffffff99', lineBackground: '#ffffff99',
foreground: '#24292e', foreground: '#24292e',
muted: '#24292e50',
selection: '#BBDFFF', selection: '#BBDFFF',
selectionMatch: '#BBDFFF', selectionMatch: '#BBDFFF',
gutterBackground: '#fff', gutterBackground: '#fff',
@@ -18,7 +17,6 @@ export default createTheme({
settings: { settings: {
background: '#fff', background: '#fff',
foreground: '#24292e', foreground: '#24292e',
muted: '#24292e50',
selection: '#BBDFFF', selection: '#BBDFFF',
selectionMatch: '#BBDFFF', selectionMatch: '#BBDFFF',
gutterBackground: '#fff', gutterBackground: '#fff',
-1
View File
@@ -13,7 +13,6 @@ export const settings = {
background: hex[0], background: hex[0],
lineBackground: 'transparent', lineBackground: 'transparent',
foreground: hex[2], foreground: hex[2],
muted: hex[2] + '50',
selection: hex[4], selection: hex[4],
selectionMatch: hex[0], selectionMatch: hex[0],
gutterBackground: hex[0], gutterBackground: hex[0],
-2
View File
@@ -10,7 +10,6 @@ export const settings = {
background: '#282828', background: '#282828',
lineBackground: '#28282899', lineBackground: '#28282899',
foreground: '#ebdbb2', foreground: '#ebdbb2',
muted: '#ebdbb250',
caret: '#ebdbb2', caret: '#ebdbb2',
selection: '#bdae93', selection: '#bdae93',
selectionMatch: '#bdae93', selectionMatch: '#bdae93',
@@ -24,7 +23,6 @@ export default createTheme({
settings: { settings: {
background: '#282828', background: '#282828',
foreground: '#ebdbb2', foreground: '#ebdbb2',
muted: '#ebdbb250',
caret: '#ebdbb2', caret: '#ebdbb2',
selection: '#b99d555c', selection: '#b99d555c',
selectionMatch: '#b99d555c', selectionMatch: '#b99d555c',
-2
View File
@@ -11,7 +11,6 @@ export const settings = {
background: '#fbf1c7', background: '#fbf1c7',
lineBackground: '#fbf1c799', lineBackground: '#fbf1c799',
foreground: '#3c3836', foreground: '#3c3836',
muted: '#3c383650',
caret: '#af3a03', caret: '#af3a03',
selection: '#ebdbb2', selection: '#ebdbb2',
selectionMatch: '#bdae93', selectionMatch: '#bdae93',
@@ -26,7 +25,6 @@ export default createTheme({
settings: { settings: {
background: '#fbf1c7', background: '#fbf1c7',
foreground: '#3c3836', foreground: '#3c3836',
muted: '#3c383650',
caret: '#af3a03', caret: '#af3a03',
selection: '#bdae9391', selection: '#bdae9391',
selectionMatch: '#bdae9391', selectionMatch: '#bdae9391',
-2
View File
@@ -5,7 +5,6 @@ export const settings = {
background: '#212121', background: '#212121',
lineBackground: '#21212199', lineBackground: '#21212199',
foreground: '#bdbdbd', foreground: '#bdbdbd',
muted: '#bdbdbd50',
caret: '#a0a4ae', caret: '#a0a4ae',
selection: '#d7d4f0', selection: '#d7d4f0',
selectionMatch: '#d7d4f0', selectionMatch: '#d7d4f0',
@@ -20,7 +19,6 @@ export default createTheme({
settings: { settings: {
background: '#212121', background: '#212121',
foreground: '#bdbdbd', foreground: '#bdbdbd',
muted: '#bdbdbd50',
caret: '#a0a4ae', caret: '#a0a4ae',
selection: '#d7d4f063', selection: '#d7d4f063',
selectionMatch: '#d7d4f063', selectionMatch: '#d7d4f063',
+1 -3
View File
@@ -5,8 +5,7 @@ export const settings = {
light: true, light: true,
background: '#FAFAFA', background: '#FAFAFA',
lineBackground: '#FAFAFA99', lineBackground: '#FAFAFA99',
foreground: '#6182B8', foreground: '#90A4AE',
muted: '#6182B850',
caret: '#272727', caret: '#272727',
selection: '#80CBC440', selection: '#80CBC440',
selectionMatch: '#FAFAFA', selectionMatch: '#FAFAFA',
@@ -20,7 +19,6 @@ export default createTheme({
settings: { settings: {
background: '#FAFAFA', background: '#FAFAFA',
foreground: '#90A4AE', foreground: '#90A4AE',
muted: '#6182B850',
caret: '#272727', caret: '#272727',
selection: '#80CBC440', selection: '#80CBC440',
selectionMatch: '#80CBC440', selectionMatch: '#80CBC440',
-2
View File
@@ -5,7 +5,6 @@ export const settings = {
background: '#272822', background: '#272822',
lineBackground: '#27282299', lineBackground: '#27282299',
foreground: '#FFFFFF', foreground: '#FFFFFF',
muted: '#FFFFFF50',
caret: '#FFFFFF', caret: '#FFFFFF',
selection: '#49483E', selection: '#49483E',
selectionMatch: '#49483E', selectionMatch: '#49483E',
@@ -19,7 +18,6 @@ export default createTheme({
settings: { settings: {
background: '#272822', background: '#272822',
foreground: '#FFFFFF', foreground: '#FFFFFF',
muted: '#FFFFFF50',
caret: '#FFFFFF', caret: '#FFFFFF',
selection: '#49483E', selection: '#49483E',
selectionMatch: '#49483E', selectionMatch: '#49483E',
-2
View File
@@ -6,7 +6,6 @@ export const settings = {
background: '#f2f1f8', background: '#f2f1f8',
lineBackground: '#f2f1f899', lineBackground: '#f2f1f899',
foreground: '#0c006b', foreground: '#0c006b',
muted: '#0c006b50',
caret: '#5c49e9', caret: '#5c49e9',
selection: '#d5d1f2', selection: '#d5d1f2',
selectionMatch: '#d5d1f2', selectionMatch: '#d5d1f2',
@@ -20,7 +19,6 @@ export default createTheme({
settings: { settings: {
background: '#f2f1f8', background: '#f2f1f8',
foreground: '#0c006b', foreground: '#0c006b',
muted: '#0c006b50',
caret: '#5c49e9', caret: '#5c49e9',
selection: '#d5d1f2', selection: '#d5d1f2',
selectionMatch: '#d5d1f2', selectionMatch: '#d5d1f2',
-2
View File
@@ -5,7 +5,6 @@ export const settings = {
background: '#2e3440', background: '#2e3440',
lineBackground: '#2e344099', lineBackground: '#2e344099',
foreground: '#FFFFFF', foreground: '#FFFFFF',
muted: '#FFFFFF50',
caret: '#FFFFFF', caret: '#FFFFFF',
selection: '#3b4252', selection: '#3b4252',
selectionMatch: '#e5e9f0', selectionMatch: '#e5e9f0',
@@ -21,7 +20,6 @@ export default createTheme({
settings: { settings: {
background: '#2e3440', background: '#2e3440',
foreground: '#FFFFFF', foreground: '#FFFFFF',
muted: '#FFFFFF50',
caret: '#FFFFFF', caret: '#FFFFFF',
selection: '#00000073', selection: '#00000073',
selectionMatch: '#00000073', selectionMatch: '#00000073',
-1
View File
@@ -13,7 +13,6 @@ export const settings = {
background: hex[0], background: hex[0],
lineBackground: 'transparent', lineBackground: 'transparent',
foreground: hex[2], foreground: hex[2],
muted: hex[2] + '50',
selection: hex[4], selection: hex[4],
selectionMatch: hex[0], selectionMatch: hex[0],
gutterBackground: hex[0], gutterBackground: hex[0],
-2
View File
@@ -5,7 +5,6 @@ export const settings = {
background: '#002b36', background: '#002b36',
lineBackground: '#002b3699', lineBackground: '#002b3699',
foreground: '#93a1a1', foreground: '#93a1a1',
muted: '#93a1a150',
caret: '#839496', caret: '#839496',
selection: '#173541', selection: '#173541',
selectionMatch: '#aafe661a', selectionMatch: '#aafe661a',
@@ -46,7 +45,6 @@ export default createTheme({
settings: { settings: {
background: c.background, background: c.background,
foreground: c.foreground, foreground: c.foreground,
muted: c.muted,
caret: c.cursor, caret: c.cursor,
selection: c.selection, selection: c.selection,
selectionMatch: c.selection, selectionMatch: c.selection,
-2
View File
@@ -8,7 +8,6 @@ export const settings = {
background: '#fdf6e3', background: '#fdf6e3',
lineBackground: '#fdf6e399', lineBackground: '#fdf6e399',
foreground: '#657b83', foreground: '#657b83',
muted: '#657b8350',
caret: '#586e75', caret: '#586e75',
selection: '#dfd9c8', selection: '#dfd9c8',
selectionMatch: '#dfd9c8', selectionMatch: '#dfd9c8',
@@ -20,7 +19,6 @@ export const settings = {
const c = { const c = {
background: '#FDF6E3', background: '#FDF6E3',
foreground: '#657B83', foreground: '#657B83',
muted: '#657b8350',
selection: '#EEE8D5', selection: '#EEE8D5',
selectionMatch: '#EEE8D5', selectionMatch: '#EEE8D5',
cursor: '#657B83', cursor: '#657B83',
-1
View File
@@ -13,7 +13,6 @@ export const settings = {
background: '#000000', background: '#000000',
lineBackground: 'transparent', lineBackground: 'transparent',
foreground: hex[4], foreground: hex[4],
muted: hex[6],
selection: hex[6], selection: hex[6],
gutterBackground: hex[0], gutterBackground: hex[0],
gutterForeground: hex[5], gutterForeground: hex[5],
-1
View File
@@ -5,7 +5,6 @@ export const settings = {
background: '#222', background: '#222',
lineBackground: '#22222299', lineBackground: '#22222299',
foreground: '#fff', foreground: '#fff',
muted: '#8a919966',
caret: '#ffcc00', caret: '#ffcc00',
selection: 'rgba(128, 203, 196, 0.5)', selection: 'rgba(128, 203, 196, 0.5)',
selectionMatch: '#036dd626', selectionMatch: '#036dd626',
-2
View File
@@ -5,7 +5,6 @@ export const settings = {
background: '#303841', background: '#303841',
lineBackground: '#30384199', lineBackground: '#30384199',
foreground: '#FFFFFF', foreground: '#FFFFFF',
muted: '#FFFFFF50',
caret: '#FBAC52', caret: '#FBAC52',
selection: '#4C5964', selection: '#4C5964',
selectionMatch: '#3A546E', selectionMatch: '#3A546E',
@@ -19,7 +18,6 @@ export default createTheme({
settings: { settings: {
background: '#303841', background: '#303841',
foreground: '#FFFFFF', foreground: '#FFFFFF',
muted: '#FFFFFF50',
caret: '#FBAC52', caret: '#FBAC52',
selection: '#4C5964', selection: '#4C5964',
selectionMatch: '#3A546E', selectionMatch: '#3A546E',
+1 -2
View File
@@ -9,8 +9,7 @@ let colorD = '#f8fc55';
export const settings = { export const settings = {
background: '#000000', background: '#000000',
foreground: colorA, foreground: colorA, // whats that?
muted: colorA + '50',
caret: colorC, caret: colorC,
selection: colorD, selection: colorD,
selectionMatch: colorA, selectionMatch: colorA,
+2 -4
View File
@@ -4,8 +4,7 @@ import { createTheme } from './theme-helper.mjs';
export const settings = { export const settings = {
background: '#24283b', background: '#24283b',
lineBackground: '#24283b99', lineBackground: '#24283b99',
foreground: '#f9f2f9', foreground: '#7982a9',
muted: '#f9f2f950',
caret: '#c0caf5', caret: '#c0caf5',
selection: '#6f7bb630', selection: '#6f7bb630',
selectionMatch: '#1f2335', selectionMatch: '#1f2335',
@@ -19,8 +18,7 @@ export default createTheme({
theme: 'dark', theme: 'dark',
settings: { settings: {
background: '#24283b', background: '#24283b',
foreground: '#f9f2f9', foreground: '#7982a9',
muted: '#f9f2f950',
caret: '#c0caf5', caret: '#c0caf5',
selection: '#6f7bb630', selection: '#6f7bb630',
selectionMatch: '#343b5f', selectionMatch: '#343b5f',
+2 -4
View File
@@ -4,8 +4,7 @@ import { createTheme } from './theme-helper.mjs';
export const settings = { export const settings = {
background: '#1a1b26', background: '#1a1b26',
lineBackground: '#1a1b2699', lineBackground: '#1a1b2699',
foreground: '#f8fcf9', foreground: '#787c99',
muted: '#f8fcf950',
caret: '#c0caf5', caret: '#c0caf5',
selection: '#515c7e40', selection: '#515c7e40',
selectionMatch: '#16161e', selectionMatch: '#16161e',
@@ -19,8 +18,7 @@ export default createTheme({
theme: 'dark', theme: 'dark',
settings: { settings: {
background: '#1a1b26', background: '#1a1b26',
foreground: '#f8fcf9', foreground: '#787c99',
muted: '#f8fcf950',
caret: '#c0caf5', caret: '#c0caf5',
selection: '#515c7e40', selection: '#515c7e40',
selectionMatch: '#16161e', selectionMatch: '#16161e',
-2
View File
@@ -6,7 +6,6 @@ export const settings = {
background: '#e1e2e7', background: '#e1e2e7',
lineBackground: '#e1e2e799', lineBackground: '#e1e2e799',
foreground: '#3760bf', foreground: '#3760bf',
muted: '#3760bf50',
caret: '#3760bf', caret: '#3760bf',
selection: '#99a7df', selection: '#99a7df',
selectionMatch: '#99a7df', selectionMatch: '#99a7df',
@@ -21,7 +20,6 @@ export default createTheme({
settings: { settings: {
background: '#e1e2e7', background: '#e1e2e7',
foreground: '#3760bf', foreground: '#3760bf',
muted: '#3760bf50',
caret: '#3760bf', caret: '#3760bf',
selection: '#99a7df', selection: '#99a7df',
selectionMatch: '#99a7df', selectionMatch: '#99a7df',
-2
View File
@@ -5,7 +5,6 @@ export const settings = {
background: '#1e1e1e', background: '#1e1e1e',
lineBackground: '#1e1e1e99', lineBackground: '#1e1e1e99',
foreground: '#fff', foreground: '#fff',
muted: '#ffffff50',
caret: '#c6c6c6', caret: '#c6c6c6',
selection: '#6199ff2f', selection: '#6199ff2f',
selectionMatch: '#72a1ff59', selectionMatch: '#72a1ff59',
@@ -20,7 +19,6 @@ export default createTheme({
settings: { settings: {
background: '#1e1e1e', background: '#1e1e1e',
foreground: '#fff', foreground: '#fff',
muted: '#ffffff50',
caret: '#c6c6c6', caret: '#c6c6c6',
selection: '#6199ff2f', selection: '#6199ff2f',
selectionMatch: '#72a1ff59', selectionMatch: '#72a1ff59',
-2
View File
@@ -5,7 +5,6 @@ export const settings = {
background: '#ffffff', background: '#ffffff',
lineBackground: '#ffffff50', lineBackground: '#ffffff50',
foreground: '#383a42', foreground: '#383a42',
muted: '#383a4250',
caret: '#000', caret: '#000',
selection: '#add6ff', selection: '#add6ff',
selectionMatch: '#a8ac94', selectionMatch: '#a8ac94',
@@ -21,7 +20,6 @@ export default createTheme({
settings: { settings: {
background: '#ffffff', background: '#ffffff',
foreground: '#383a42', foreground: '#383a42',
muted: '#383a4250',
caret: '#000', caret: '#000',
selection: '#add6ff', selection: '#add6ff',
selectionMatch: '#a8ac94', selectionMatch: '#a8ac94',
+1 -2
View File
@@ -2,8 +2,7 @@ import { tags as t } from '@lezer/highlight';
import { createTheme } from './theme-helper.mjs'; import { createTheme } from './theme-helper.mjs';
export const settings = { export const settings = {
background: 'white', background: 'white',
foreground: 'black', foreground: 'black', // whats that?
muted: '#00000050',
caret: 'black', caret: 'black',
selection: 'rgba(128, 203, 196, 0.5)', selection: 'rgba(128, 203, 196, 0.5)',
selectionMatch: '#ffffff26', selectionMatch: '#ffffff26',
-2
View File
@@ -6,7 +6,6 @@ export const settings = {
background: '#fff', background: '#fff',
lineBackground: '#ffffff99', lineBackground: '#ffffff99',
foreground: '#3D3D3D', foreground: '#3D3D3D',
muted: '#3D3D3D50',
selection: '#BBDFFF', selection: '#BBDFFF',
selectionMatch: '#BBDFFF', selectionMatch: '#BBDFFF',
gutterBackground: '#fff', gutterBackground: '#fff',
@@ -19,7 +18,6 @@ export default createTheme({
settings: { settings: {
background: '#fff', background: '#fff',
foreground: '#3D3D3D', foreground: '#3D3D3D',
muted: '#3D3D3D50',
selection: '#BBDFFF', selection: '#BBDFFF',
selectionMatch: '#BBDFFF', selectionMatch: '#BBDFFF',
gutterBackground: '#fff', gutterBackground: '#fff',
+45 -193
View File
@@ -1,111 +1,55 @@
import { StateEffect, StateField } from '@codemirror/state'; 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 { getWidgetID, registerWidgetType } from '@strudel/transpiler';
import { Pattern } from '@strudel/core'; 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) => {
view.dispatch({ effects: addWidget.of(widgets) });
export const updateWidgets = (view, widgets, range = null) => {
if (range) {
// range argument passed for block-based evaluation
view.dispatch({ effects: setWidgetsInRange.of({ widgets, range }) });
} else {
view.dispatch({ effects: setWidgets.of(widgets) });
}
}; };
function getWidgets(widgetConfigs, view) { function getWidgets(widgetConfigs) {
const filtered = widgetConfigs return (
// Filter to widget configs only (exclude sliders) widgetConfigs
.filter((w) => w && w.type && w.type !== 'slider') // codemirror throws an error if we don't sort
// Deduplicate widgets by ID, matching slider behavior for stable widget identity .sort((a, b) => a.to - b.to)
.filter((widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id)); .map((widgetConfig) => {
// Filter out widgets whose range is encompassed by another widget
// const nonEncompassed = filterEncompassedWidgets(filtered);
return filtered
.sort((a, b) => (a.to || 0) - (b.to || 0))
.map((widgetConfig) => {
try {
return Decoration.widget({ return Decoration.widget({
widget: new BlockWidget(widgetConfig, view), widget: new BlockWidget(widgetConfig),
side: 0, side: 0,
}).range(widgetConfig.to || widgetConfig.from || 0); block: true,
} catch (error) { }).range(widgetConfig.to);
console.error('error creating widget', error); })
return null; );
}
})
.filter(Boolean); // Remove any null results from failed creations
} }
export const widgetPlugin = ViewPlugin.fromClass( const widgetField = StateField.define(
class { /* <DecorationSet> */ {
decorations; //: DecorationSet create() {
return Decoration.none;
constructor(view /* : EditorView */) { },
this.decorations = Decoration.set([]); update(widgets, tr) {
} widgets = widgets.map(tr.changes);
for (let e of tr.effects) {
update(update /* : ViewUpdate */) { if (e.is(addWidget)) {
update.transactions.forEach((tr) => { try {
if (tr.docChanged) { widgets = widgets.update({
this.decorations = this.decorations.map(tr.changes); filter: () => false,
const iterator = this.decorations.iter(); add: getWidgets(e.value),
// 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,
});
}
}
}); });
} catch (error) {
// Merge preserved widgets with new widgets, deduplicating by ID console.log('err', error);
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));
} }
} }
}); }
} return widgets;
}, },
{ provide: (f) => EditorView.decorations.from(f),
decorations: (v) => v.decorations,
}, },
); );
@@ -116,116 +60,24 @@ export function setWidget(id, el) {
} }
export class BlockWidget extends WidgetType { export class BlockWidget extends WidgetType {
constructor(widgetConfig, view) { constructor(widgetConfig) {
super(); 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; this.widgetConfig = widgetConfig;
} }
eq() {
eq(other) { return true;
if (!(other instanceof BlockWidget)) {
return false;
}
return (
this.id === other.id &&
this.from === other.from &&
this.to === other.to &&
this.type === other.type &&
this.index === other.index
);
} }
toDOM() { toDOM() {
let wrap = document.createElement('span'); const id = getWidgetID(this.widgetConfig);
wrap.setAttribute('aria-hidden', 'true'); const el = widgetElements[id];
wrap.className = 'cm-widget-container'; return el;
let el = widgetElements[this.id];
if (el) {
// Ensure the element has the correct ID
el.id = this.id;
wrap.appendChild(el);
} else {
// Create a placeholder element if the widget element doesn't exist
// This prevents CodeMirror errors when widget is missing
const placeholder = document.createElement('span');
placeholder.setAttribute('aria-hidden', 'true');
placeholder.className = 'cm-widget-placeholder';
placeholder.style.cssText = 'display: none;'; // Hide placeholder
placeholder.id = this.id;
wrap.appendChild(placeholder);
}
return wrap;
} }
ignoreEvent(e) { ignoreEvent(e) {
return true; return true;
} }
} }
export function getActiveWidgets(view) { export const widgetPlugin = [widgetField];
if (!view || !view.state) {
return [];
}
const widgetPluginInstance = view.plugin(widgetPlugin);
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
return [];
}
const widgets = [];
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof BlockWidget) {
widgets.push({
type: decoration.widget.type,
from: decoration.widget.from,
to: decoration.widget.to,
index: decoration.widget.index,
id: decoration.widget.id,
});
}
});
return widgets;
}
export function getAllWidgetIds(view) {
if (!view || !view.state) {
return [];
}
const widgetPluginInstance = view.plugin(widgetPlugin);
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
return [];
}
const widgetIds = [];
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof BlockWidget) {
widgetIds.push(decoration.widget.id);
}
});
return widgetIds;
}
// widget implementer API to create a new widget type // widget implementer API to create a new widget type
export function registerWidget(type, fn) { export function registerWidget(type, fn) {
File diff suppressed because it is too large Load Diff
-1
View File
@@ -15,7 +15,6 @@ import Fraction, { gcd } from './fraction.mjs';
* - "-" hold previous value * - "-" hold previous value
* - "." silence * - "." silence
* *
* @tags visualization
* @param {Pattern} pattern the pattern to use * @param {Pattern} pattern the pattern to use
* @param {number} chars max number of characters (approximately) * @param {number} chars max number of characters (approximately)
* @returns string * @returns string
-5
View File
@@ -61,7 +61,6 @@ export const bjorklund = function (ons, steps) {
* *
* @memberof Pattern * @memberof Pattern
* @name euclid * @name euclid
* @tags temporal
* @param {number} pulses the number of onsets/beats * @param {number} pulses the number of onsets/beats
* @param {number} steps the number of steps to fill * @param {number} steps the number of steps to fill
* @returns Pattern * @returns Pattern
@@ -74,7 +73,6 @@ export const bjorklund = function (ons, steps) {
* Like `euclid`, but has an additional parameter for 'rotating' the resulting sequence. * Like `euclid`, but has an additional parameter for 'rotating' the resulting sequence.
* @memberof Pattern * @memberof Pattern
* @name euclidRot * @name euclidRot
* @tags temporal
* @param {number} pulses the number of onsets/beats * @param {number} pulses the number of onsets/beats
* @param {number} steps the number of steps to fill * @param {number} steps the number of steps to fill
* @param {number} rotation offset in steps * @param {number} rotation offset in steps
@@ -158,7 +156,6 @@ export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], fun
* so there will be no gaps. * so there will be no gaps.
* @name euclidLegato * @name euclidLegato
* @memberof Pattern * @memberof Pattern
* @tags temporal
* @param {number} pulses the number of onsets/beats * @param {number} pulses the number of onsets/beats
* @param {number} steps the number of steps to fill * @param {number} steps the number of steps to fill
* @param rotation offset in steps * @param rotation offset in steps
@@ -190,7 +187,6 @@ export const euclidLegato = register(['euclidLegato'], function (pulses, steps,
* the resulting sequence * the resulting sequence
* @name euclidLegatoRot * @name euclidLegatoRot
* @memberof Pattern * @memberof Pattern
* @tags temporal
* @param {number} pulses the number of onsets/beats * @param {number} pulses the number of onsets/beats
* @param {number} steps the number of steps to fill * @param {number} steps the number of steps to fill
* @param {number} rotation offset in steps * @param {number} rotation offset in steps
@@ -212,7 +208,6 @@ export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, s
* @name euclidish * @name euclidish
* @synonyms eish * @synonyms eish
* @memberof Pattern * @memberof Pattern
* @tags temporal
* @param {number} pulses the number of onsets * @param {number} pulses the number of onsets
* @param {number} steps the number of steps to fill * @param {number} steps the number of steps to fill
* @param {number} groove exists between the extremes of 0 (straight euclidian) and 1 (straight pulse) * @param {number} groove exists between the extremes of 0 (straight euclidian) and 1 (straight pulse)
-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 = {}; 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) => { export const evalScope = async (...args) => {
const results = await Promise.allSettled(args); const results = await Promise.allSettled(args);
+1 -1
View File
@@ -22,7 +22,7 @@ export * from './repl.mjs';
export * from './signal.mjs'; export * from './signal.mjs';
export * from './speak.mjs'; export * from './speak.mjs';
export * from './state.mjs'; export * from './state.mjs';
export * from './schedulerState.mjs'; export * from './time.mjs';
export * from './timespan.mjs'; export * from './timespan.mjs';
export * from './ui.mjs'; export * from './ui.mjs';
export * from './util.mjs'; export * from './util.mjs';
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/core", "name": "@strudel/core",
"version": "1.2.6", "version": "1.2.5",
"description": "Port of Tidal Cycles to JavaScript", "description": "Port of Tidal Cycles to JavaScript",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
+109 -378
View File
File diff suppressed because it is too large Load Diff
+22 -40
View File
@@ -28,7 +28,6 @@ const _pick = function (lookup, pat, modulo = true) {
/** * Picks patterns (or plain values) either from a list (by index) or a lookup table (by name). /** * Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
* Similar to `inhabit`, but maintains the structure of the original patterns. * Similar to `inhabit`, but maintains the structure of the original patterns.
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -58,7 +57,6 @@ const __pick = register('pick', function (lookup, pat) {
* it wraps around, rather than sticking at the maximum value. * it wraps around, rather than sticking at the maximum value.
* For example, if you pick the fifth pattern of a list of three, you'll get the * For example, if you pick the fifth pattern of a list of three, you'll get the
* second one. * second one.
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -69,38 +67,32 @@ export const pickmod = register('pickmod', function (lookup, pat) {
}); });
/** * pickF lets you use a pattern of numbers to pick which function to apply to another pattern. /** * 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} pat
* @param {Pattern} lookup a pattern of indices or names * @param {Pattern} lookup a pattern of indices
* @param {function[] | object} lookup the array or lookup object of functions from which to pull * @param {function[]} funcs the array of functions from which to pull
* @returns {Pattern} * @returns {Pattern}
* @example * @example
* s("bd [rim hh]").pickF("<0 1 2>", [rev,jux(rev),fast(2)]) * s("bd [rim hh]").pickF("<0 1 2>", [rev,jux(rev),fast(2)])
* @example * @example
* note("<c2 d2>(3,8)").s("square") * note("<c2 d2>(3,8)").s("square")
* .pickF("<0 2> 1", [jux(rev), fast(2), x=>x.lpf(800)]) * .pickF("<0 2> 1", [jux(rev),fast(2),x=>x.lpf(800)])
* @example
* note("<c2 d2>(3,8)").s("square")
* .pickF("<jr l> f", { jr:jux(rev), f:fast(2), l:x=>x.lpf(800) })
*/ */
export const pickF = register('pickF', function (pickPattern, lookup, pat) { export const pickF = register('pickF', function (lookup, funcs, pat) {
return pat.apply(pick(lookup, pickPattern)); return pat.apply(pick(lookup, funcs));
}); });
/** * The same as `pickF`, but if you pick a number greater than the size of the functions list, /** * 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. * it wraps around, rather than sticking at the maximum value.
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {Pattern} lookup a pattern of indices or names * @param {Pattern} lookup a pattern of indices
* @param {function[] | object} lookup the array or lookup object of functions from which to pull * @param {function[]} funcs the array of functions from which to pull
* @returns {Pattern} * @returns {Pattern}
*/ */
export const pickmodF = register('pickmodF', function (pickPattern, lookup, pat) { export const pickmodF = register('pickmodF', function (lookup, funcs, pat) {
return pat.apply(pickmod(lookup, pickPattern)); return pat.apply(pickmod(lookup, funcs));
}); });
/** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin. /** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin.
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -111,7 +103,6 @@ export const pickOut = register('pickOut', function (lookup, pat) {
/** * The same as `pickOut`, but if you pick a number greater than the size of the list, /** * The same as `pickOut`, but if you pick a number greater than the size of the list,
* it wraps around, rather than sticking at the maximum value. * it wraps around, rather than sticking at the maximum value.
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -121,7 +112,6 @@ export const pickmodOut = register('pickmodOut', function (lookup, pat) {
}); });
/** * Similar to `pick`, but the choosen pattern is restarted when its index is triggered. /** * Similar to `pick`, but the choosen pattern is restarted when its index is triggered.
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -132,7 +122,6 @@ export const pickRestart = register('pickRestart', function (lookup, pat) {
/** * The same as `pickRestart`, but if you pick a number greater than the size of the list, /** * The same as `pickRestart`, but if you pick a number greater than the size of the list,
* it wraps around, rather than sticking at the maximum value. * it wraps around, rather than sticking at the maximum value.
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -149,7 +138,6 @@ export const pickmodRestart = register('pickmodRestart', function (lookup, pat)
}); });
/** * Similar to `pick`, but the choosen pattern is reset when its index is triggered. /** * Similar to `pick`, but the choosen pattern is reset when its index is triggered.
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -160,7 +148,6 @@ export const pickReset = register('pickReset', function (lookup, pat) {
/** * The same as `pickReset`, but if you pick a number greater than the size of the list, /** * The same as `pickReset`, but if you pick a number greater than the size of the list,
* it wraps around, rather than sticking at the maximum value. * it wraps around, rather than sticking at the maximum value.
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -170,22 +157,19 @@ 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). /** 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. * Similar to `pick`, but cycles are squeezed into the target ('inhabited') pattern.
* @name inhabit * @name inhabit
* @tags combiners * @synonyms pickSqueeze
* @synonyms pickSqueeze * @param {Pattern} pat
* @param {Pattern} pat * @param {*} xs
* @param {*} xs * @returns {Pattern}
* @returns {Pattern} * @example
* @example * "<a b [a,b]>".inhabit({a: s("bd(3,8)"),
* let a = s("bd(3,8)") b: s("cp sd")
* let b = s("cp sd") })
* "<a b [a,b]>".inhabit({ a, b }) * @example
* @example * s("a@2 [a b] a".inhabit({a: "bd(3,8)", b: "sd sd"})).slow(4)
* 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) { export const { inhabit, pickSqueeze } = register(['inhabit', 'pickSqueeze'], function (lookup, pat) {
return _pick(lookup, pat, false).squeezeJoin(); return _pick(lookup, pat, false).squeezeJoin();
}); });
@@ -196,7 +180,6 @@ export const { inhabit, pickSqueeze } = register(['inhabit', 'pickSqueeze'], fun
* second one. * second one.
* @name inhabitmod * @name inhabitmod
* @synonyms pickmodSqueeze * @synonyms pickmodSqueeze
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -209,7 +192,6 @@ export const { inhabitmod, pickmodSqueeze } = register(['inhabitmod', 'pickmodSq
/** /**
* Pick from the list of values (or patterns of values) via the index using the given * Pick from the list of values (or patterns of values) via the index using the given
* pattern of integers. The selected pattern will be compressed to fit the duration of the selecting event * pattern of integers. The selected pattern will be compressed to fit the duration of the selecting event
* @tags combiners
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
+38 -317
View File
@@ -2,13 +2,7 @@ import { NeoCyclist } from './neocyclist.mjs';
import { Cyclist } from './cyclist.mjs'; import { Cyclist } from './cyclist.mjs';
import { evaluate as _evaluate } from './evaluate.mjs'; import { evaluate as _evaluate } from './evaluate.mjs';
import { errorLogger, logger } from './logger.mjs'; import { errorLogger, logger } from './logger.mjs';
import { import { setTime } from './time.mjs';
setCpsFunc,
setIsStarted,
setPattern as exposeSchedulerPattern,
setTime,
setTriggerFunc,
} from './schedulerState.mjs';
import { evalScope } from './evaluate.mjs'; import { evalScope } from './evaluate.mjs';
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
import { reset_state } from './impure.mjs'; import { reset_state } from './impure.mjs';
@@ -38,7 +32,6 @@ export function repl({
pattern: undefined, pattern: undefined,
miniLocations: [], miniLocations: [],
widgets: [], widgets: [],
sliders: [],
pending: false, pending: false,
started: false, started: false,
}; };
@@ -59,7 +52,6 @@ export function repl({
getTime, getTime,
onToggle: (started) => { onToggle: (started) => {
updateState({ started }); updateState({ started });
setIsStarted(started);
onToggle?.(started); onToggle?.(started);
if (!started) { if (!started) {
reset_state(); reset_state();
@@ -73,139 +65,16 @@ export function repl({
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome // NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
const scheduler = const scheduler =
sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions); sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions);
setTriggerFunc(schedulerOptions.onTrigger);
setCpsFunc(() => scheduler.cps);
let pPatterns = {}; let pPatterns = {};
let anonymousIndex = 0; let anonymousIndex = 0;
let allTransform; let allTransform;
let eachTransform; 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 () { const hush = function () {
pPatterns = {}; pPatterns = {};
anonymousIndex = 0; anonymousIndex = 0;
allTransform = undefined; allTransform = undefined;
eachTransform = undefined; eachTransform = undefined;
codeBlocks = {};
blockPatterns.clear();
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
return silence; return silence;
}; };
@@ -220,65 +89,11 @@ export function repl({
const setPattern = async (pattern, autostart = true) => { const setPattern = async (pattern, autostart = true) => {
pattern = editPattern?.(pattern) || pattern; pattern = editPattern?.(pattern) || pattern;
await scheduler.setPattern(pattern, autostart); await scheduler.setPattern(pattern, autostart);
exposeSchedulerPattern(pattern);
return pattern; return pattern;
}; };
setTime(() => scheduler.now()); // TODO: refactor? setTime(() => scheduler.now()); // TODO: refactor?
// Helper function to apply pattern transformations (solo, each, all) const stop = () => scheduler.stop();
// this should be abstracted more
function applyPatternTransforms(pattern) {
const allPatterns = Object.values(pPatterns);
if (allPatterns.length) {
let patterns = [];
let soloActive = false;
for (const [key, value] of Object.entries(pPatterns)) {
// handle soloed patterns ex: S$: s("bd!4")
const isSolod = key.length > 1 && key.startsWith('S');
if (isSolod && soloActive === false) {
// first time we see a soloed pattern, clear existing patterns
patterns = [];
soloActive = true;
}
if (!soloActive || (soloActive && isSolod)) {
const valWithState = value.withState((state) => state.setControls({ id: key }));
patterns.push(valWithState);
}
}
if (eachTransform) {
// Explicit lambda so only element (not index and array) are passed
patterns = patterns.map((x) => eachTransform(x));
}
pattern = stack(...patterns);
} else if (eachTransform) {
pattern = eachTransform(pattern);
}
if (allTransforms.length) {
for (const transform of allTransforms) {
pattern = transform(pattern);
}
}
if (!isPattern(pattern)) {
pattern = silence;
}
return pattern;
}
const stop = () => {
codeBlocks = {};
blockPatterns.clear();
pPatterns = {};
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
updateState({
miniLocations: [],
widgets: [],
sliders: [],
});
scheduler.stop();
};
const start = () => scheduler.start(); const start = () => scheduler.start();
const pause = () => scheduler.pause(); const pause = () => scheduler.pause();
const toggle = () => scheduler.toggle(); const toggle = () => scheduler.toggle();
@@ -291,7 +106,6 @@ export function repl({
* Changes the global tempo to the given cycles per minute * Changes the global tempo to the given cycles per minute
* *
* @name setcpm * @name setcpm
* @tags temporal
* @alias setCpm * @alias setCpm
* @param {number} cpm cycles per minute * @param {number} cpm cycles per minute
* @example * @example
@@ -305,9 +119,7 @@ export function repl({
// TODO - not documented as jsdoc examples as the test framework doesn't simulate enough context for `each` and `all`.. // TODO - not documented as jsdoc examples as the test framework doesn't simulate enough context for `each` and `all`..
let allTransforms = []; /** Applies a function to all the running patterns. Note that the patterns are groups together into a single `stack` before the function is applied. This is probably what you want, but see `each` for
/**
* Applies a function to all the running patterns. Note that the patterns are groups together into a single `stack` before the function is applied. This is probably what you want, but see `each` for
* a version that applies the function to each pattern separately. * a version that applies the function to each pattern separately.
* ``` * ```
* $: sound("bd - cp sd") * $: sound("bd - cp sd")
@@ -319,21 +131,18 @@ export function repl({
* $: sound("hh*8") * $: sound("hh*8")
* all(x => x.pianoroll()) * all(x => x.pianoroll())
* ``` * ```
*
* @tags combiners
*/ */
let allTransforms = [];
const all = function (transform) { const all = function (transform) {
allTransforms.push(transform); allTransforms.push(transform);
return silence; return silence;
}; };
/** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern. /** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern.
*
* ``` * ```
* $: sound("bd - cp sd") * $: sound("bd - cp sd")
* $: sound("hh*8") * $: sound("hh*8")
* each(fast("<2 3>")) * each(fast("<2 3>"))
* ``` * ```
* @tags combiners
*/ */
const each = function (transform) { const each = function (transform) {
eachTransform = transform; eachTransform = transform;
@@ -392,7 +201,7 @@ export function repl({
}); });
}; };
const evaluate = async (code, autostart = true) => { const evaluate = async (code, autostart = true, shouldHush = true) => {
if (!code) { if (!code) {
throw new Error('no code to evaluate'); throw new Error('no code to evaluate');
} }
@@ -400,146 +209,59 @@ export function repl({
updateState({ code, pending: true }); updateState({ code, pending: true });
await injectPatternMethods(); await injectPatternMethods();
setTime(() => scheduler.now()); // TODO: refactor? setTime(() => scheduler.now()); // TODO: refactor?
await beforeEval?.({ code, blockBased: false }); await beforeEval?.({ code });
allTransforms = []; // reset all transforms allTransforms = []; // reset all transforms
shouldHush && hush();
codeBlocks = {};
hush();
if (mondo) { if (mondo) {
code = `mondolang\`${code}\``; code = `mondolang\`${code}\``;
} }
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions); let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
if (Object.keys(pPatterns).length) {
pattern = applyPatternTransforms(pattern); let patterns = [];
let soloActive = false;
logger(`[eval] code updated`); for (const [key, value] of Object.entries(pPatterns)) {
pattern = await setPattern(pattern, autostart); // handle soloed patterns ex: S$: s("bd!4")
updateState({ const isSolod = key.length > 1 && key.startsWith('S');
miniLocations: meta?.miniLocations || [], if (isSolod && soloActive === false) {
widgets: meta?.widgets || [], // first time we see a soloed pattern, clear existing patterns
sliders: meta?.sliders || [], patterns = [];
activeCode: code, soloActive = true;
pattern, }
evalError: undefined, if (!soloActive || (soloActive && isSolod)) {
schedulerError: undefined, const valWithState = value.withState((state) => state.setControls({ id: key }));
pending: false, patterns.push(valWithState);
});
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 (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( if (!isPattern(pattern)) {
Object.entries(pPatterns).filter(([key]) => { pattern = silence;
return Object.keys(codeBlocks).includes(key); }
}),
);
pattern = applyPatternTransforms(pattern);
logger(`[eval] code updated`); logger(`[eval] code updated`);
pattern = await setPattern(pattern, autostart); pattern = await setPattern(pattern, autostart);
updateState({ updateState({
miniLocations: meta?.miniLocations || [], miniLocations: meta?.miniLocations || [],
widgets: meta?.widgets || [], widgets: meta?.widgets || [],
sliders: meta?.sliders || [],
activeCode: code, activeCode: code,
pattern, pattern,
evalError: undefined, evalError: undefined,
schedulerError: undefined, schedulerError: undefined,
pending: false, pending: false,
}); });
afterEval?.({ code, pattern, meta });
afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved });
return pattern; return pattern;
} catch (err) { } catch (err) {
logger(`[eval] error: ${err.message}`, 'error'); logger(`[eval] error: ${err.message}`, 'error');
@@ -548,9 +270,8 @@ export function repl({
onEvalError?.(err); onEvalError?.(err);
} }
}; };
const setCode = (code) => updateState({ code }); 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 = export const getTrigger =
-53
View File
@@ -1,53 +0,0 @@
/*
schedulerState.mjs - Module to pipe out various parameters from the scheduler for global consumption
Copyright (C) 2026 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/schedulerState.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/>.
*/
let time;
let cpsFunc;
let pattern;
let triggerFunc;
let isStarted;
export function getTime() {
if (!time) {
throw new Error('no time set! use setTime to define a time source');
}
return time();
}
export function setTime(func) {
time = func;
}
export function setCpsFunc(func) {
cpsFunc = func;
}
export function getCps() {
return cpsFunc?.();
}
export function setPattern(pat) {
pattern = pat;
}
export function getPattern() {
return pattern;
}
export function setTriggerFunc(func) {
triggerFunc = func;
}
export function getTriggerFunc() {
return triggerFunc;
}
export function setIsStarted(val) {
isStarted = !!val;
}
export function getIsStarted() {
return isStarted;
}
-63
View File
@@ -24,7 +24,6 @@ export const signal = (func) => {
* A sawtooth signal between 0 and 1. * A sawtooth signal between 0 and 1.
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
* @example * @example
* note("<c3 [eb3,g3] g2 [g3,bb3]>*8") * note("<c3 [eb3,g3] g2 [g3,bb3]>*8")
* .clip(saw.slow(2)) * .clip(saw.slow(2))
@@ -39,7 +38,6 @@ export const saw = signal((t) => t % 1);
* A sawtooth signal between -1 and 1 (like `saw`, but bipolar). * A sawtooth signal between -1 and 1 (like `saw`, but bipolar).
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
*/ */
export const saw2 = saw.toBipolar(); export const saw2 = saw.toBipolar();
@@ -47,7 +45,6 @@ export const saw2 = saw.toBipolar();
* A sawtooth signal between 1 and 0 (like `saw`, but flipped). * A sawtooth signal between 1 and 0 (like `saw`, but flipped).
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
* @example * @example
* note("<c3 [eb3,g3] g2 [g3,bb3]>*8") * note("<c3 [eb3,g3] g2 [g3,bb3]>*8")
* .clip(isaw.slow(2)) * .clip(isaw.slow(2))
@@ -62,7 +59,6 @@ export const isaw = signal((t) => 1 - (t % 1));
* A sawtooth signal between 1 and -1 (like `saw2`, but flipped). * A sawtooth signal between 1 and -1 (like `saw2`, but flipped).
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
*/ */
export const isaw2 = isaw.toBipolar(); export const isaw2 = isaw.toBipolar();
@@ -70,14 +66,12 @@ export const isaw2 = isaw.toBipolar();
* A sine signal between -1 and 1 (like `sine`, but bipolar). * A sine signal between -1 and 1 (like `sine`, but bipolar).
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
*/ */
export const sine2 = signal((t) => Math.sin(Math.PI * 2 * t)); export const sine2 = signal((t) => Math.sin(Math.PI * 2 * t));
/** /**
* A sine signal between 0 and 1. * A sine signal between 0 and 1.
* @return {Pattern} * @return {Pattern}
* @tags generators
* @example * @example
* n(sine.segment(16).range(0,15)) * n(sine.segment(16).range(0,15))
* .scale("C:minor") * .scale("C:minor")
@@ -89,7 +83,6 @@ export const sine = sine2.fromBipolar();
* A cosine signal between 0 and 1. * A cosine signal between 0 and 1.
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
* @example * @example
* n(stack(sine,cosine).segment(16).range(0,15)) * n(stack(sine,cosine).segment(16).range(0,15))
* .scale("C:minor") * .scale("C:minor")
@@ -101,14 +94,12 @@ export const cosine = sine._early(Fraction(1).div(4));
* A cosine signal between -1 and 1 (like `cosine`, but bipolar). * A cosine signal between -1 and 1 (like `cosine`, but bipolar).
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
*/ */
export const cosine2 = sine2._early(Fraction(1).div(4)); export const cosine2 = sine2._early(Fraction(1).div(4));
/** /**
* A square signal between 0 and 1. * A square signal between 0 and 1.
* @return {Pattern} * @return {Pattern}
* @tags generators
* @example * @example
* n(square.segment(4).range(0,7)).scale("C:minor") * n(square.segment(4).range(0,7)).scale("C:minor")
* *
@@ -119,7 +110,6 @@ export const square = signal((t) => Math.floor((t * 2) % 2));
* A square signal between -1 and 1 (like `square`, but bipolar). * A square signal between -1 and 1 (like `square`, but bipolar).
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
*/ */
export const square2 = square.toBipolar(); export const square2 = square.toBipolar();
@@ -127,7 +117,6 @@ export const square2 = square.toBipolar();
* A triangle signal between 0 and 1. * A triangle signal between 0 and 1.
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
* @example * @example
* n(tri.segment(8).range(0,7)).scale("C:minor") * n(tri.segment(8).range(0,7)).scale("C:minor")
* *
@@ -138,7 +127,6 @@ export const tri = fastcat(saw, isaw);
* A triangle signal between -1 and 1 (like `tri`, but bipolar). * A triangle signal between -1 and 1 (like `tri`, but bipolar).
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
*/ */
export const tri2 = fastcat(saw2, isaw2); export const tri2 = fastcat(saw2, isaw2);
@@ -146,7 +134,6 @@ export const tri2 = fastcat(saw2, isaw2);
* An inverted triangle signal between 1 and 0 (like `tri`, but flipped). * An inverted triangle signal between 1 and 0 (like `tri`, but flipped).
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
* @example * @example
* n(itri.segment(8).range(0,7)).scale("C:minor") * n(itri.segment(8).range(0,7)).scale("C:minor")
* *
@@ -157,7 +144,6 @@ export const itri = fastcat(isaw, saw);
* An inverted triangle signal between -1 and 1 (like `itri`, but bipolar). * An inverted triangle signal between -1 and 1 (like `itri`, but bipolar).
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
*/ */
export const itri2 = fastcat(isaw2, saw2); export const itri2 = fastcat(isaw2, saw2);
@@ -165,7 +151,6 @@ export const itri2 = fastcat(isaw2, saw2);
* A signal representing the cycle time. * A signal representing the cycle time.
* *
* @return {Pattern} * @return {Pattern}
* @tags generators
*/ */
export const time = signal(id); export const time = signal(id);
@@ -173,7 +158,6 @@ export const time = signal(id);
* The mouse's x position value ranges from 0 to 1. * The mouse's x position value ranges from 0 to 1.
* @name mousex * @name mousex
* @return {Pattern} * @return {Pattern}
* @tags external_io
* @example * @example
* n(mousex.segment(4).range(0,7)).scale("C:minor") * n(mousex.segment(4).range(0,7)).scale("C:minor")
* *
@@ -183,7 +167,6 @@ export const time = signal(id);
* The mouse's y position value ranges from 0 to 1. * The mouse's y position value ranges from 0 to 1.
* @name mousey * @name mousey
* @return {Pattern} * @return {Pattern}
* @tags external_io
* @example * @example
* n(mousey.segment(4).range(0,7)).scale("C:minor") * n(mousey.segment(4).range(0,7)).scale("C:minor")
* *
@@ -286,7 +269,6 @@ export const getRandsAtTime = (t, n = 1, seed = 0) => {
* precise RNG, try `useRNG('precise')`. * precise RNG, try `useRNG('precise')`.
* *
* @name useRNG * @name useRNG
* @tags generators, math
* @param {string} mod - Mode. One of 'legacy', 'precise' * @param {string} mod - Mode. One of 'legacy', 'precise'
* @example * @example
* useRNG('legacy') * useRNG('legacy')
@@ -298,7 +280,6 @@ export const useRNG = (mode = 'legacy') => (RNG_MODE = mode);
/** /**
* A discrete pattern of numbers from 0 to n-1 * A discrete pattern of numbers from 0 to n-1
* @tags generators
* @example * @example
* n(run(4)).scale("C4:pentatonic") * n(run(4)).scale("C4:pentatonic")
* // n("0 1 2 3").scale("C4:pentatonic") * // n("0 1 2 3").scale("C4:pentatonic")
@@ -309,7 +290,6 @@ export const run = (n) => saw.range(0, n).round().segment(n);
* Creates a binary pattern from a number. * Creates a binary pattern from a number.
* *
* @name binary * @name binary
* @tags generators
* @param {number} n - input number to convert to binary * @param {number} n - input number to convert to binary
* @example * @example
* "hh".s().struct(binary(5)) * "hh".s().struct(binary(5))
@@ -324,7 +304,6 @@ export const binary = (n) => {
* Creates a binary pattern from a number, padded to n bits long. * Creates a binary pattern from a number, padded to n bits long.
* *
* @name binaryN * @name binaryN
* @tags generators
* @param {number} n - input number to convert to binary * @param {number} n - input number to convert to binary
* @param {number} nBits - pattern length, defaults to 16 * @param {number} nBits - pattern length, defaults to 16
* @example * @example
@@ -342,7 +321,6 @@ export const binaryN = (n, nBits = 16) => {
* Creates a binary list pattern from a number. * Creates a binary list pattern from a number.
* *
* @name binaryL * @name binaryL
* @tags generators
* @param {number} n - input number to convert to binary * @param {number} n - input number to convert to binary
* s("saw").seg(8) * s("saw").seg(8)
* .partials(binaryL(irand(4096).add(1))) * .partials(binaryL(irand(4096).add(1)))
@@ -356,7 +334,6 @@ export const binaryL = (n) => {
* Creates a binary list pattern from a number, padded to n bits long. * Creates a binary list pattern from a number, padded to n bits long.
* *
* @name binaryNL * @name binaryNL
* @tags generators
* @param {number} n - input number to convert to binary * @param {number} n - input number to convert to binary
* @param {number} nBits - pattern length, defaults to 16 * @param {number} nBits - pattern length, defaults to 16
*/ */
@@ -376,7 +353,6 @@ export const binaryNL = (n, nBits = 16) => {
* Creates a list of random numbers of the given length * Creates a list of random numbers of the given length
* *
* @name randL * @name randL
* @tags generators
* @param {number} n Number of random numbers to sample * @param {number} n Number of random numbers to sample
* @example * @example
* s("saw").seg(16).n(irand(12)).scale("F1:minor") * s("saw").seg(16).n(irand(12)).scale("F1:minor")
@@ -408,7 +384,6 @@ const _rearrangeWith = (ipat, n, pat) => {
* Slices a pattern into the given number of parts, then plays those parts in random order. * Slices a pattern into the given number of parts, then plays those parts in random order.
* Each part will be played exactly once per cycle. * Each part will be played exactly once per cycle.
* @name shuffle * @name shuffle
* @tags temporal
* @example * @example
* note("c d e f").sound("piano").shuffle(4) * note("c d e f").sound("piano").shuffle(4)
* @example * @example
@@ -422,7 +397,6 @@ export const shuffle = register('shuffle', (n, pat) => {
* Slices a pattern into the given number of parts, then plays those parts at random. Similar to `shuffle`, * Slices a pattern into the given number of parts, then plays those parts at random. Similar to `shuffle`,
* but parts might be played more than once, or not at all, per cycle. * but parts might be played more than once, or not at all, per cycle.
* @name scramble * @name scramble
* @tags temporal
* @example * @example
* note("c d e f").sound("piano").scramble(4) * note("c d e f").sound("piano").scramble(4)
* @example * @example
@@ -435,7 +409,6 @@ export const scramble = register('scramble', (n, pat) => {
/** /**
* Modify a pattern by applying a function to the `randomSeed` control if present * Modify a pattern by applying a function to the `randomSeed` control if present
* *
* @tags math
* @param {Function} func Function from seed (or undefined) to seed (or undefined) * @param {Function} func Function from seed (or undefined) to seed (or undefined)
* @param {Pattern} pat Pattern to update * @param {Pattern} pat Pattern to update
* @returns Pattern * @returns Pattern
@@ -455,7 +428,6 @@ export const withSeed = (func, pat) => {
* that use randomness, like `shuffle` and `sometimes`. * that use randomness, like `shuffle` and `sometimes`.
* *
* @name seed * @name seed
* @tags math
* @param {number} n A new seed. Can be any number. * @param {number} n A new seed. Can be any number.
* @example * @example
* $: s("hh*4").degrade(); * $: s("hh*4").degrade();
@@ -469,7 +441,6 @@ export const seed = register('seed', (n, pat) => {
* A continuous pattern of random numbers, between 0 and 1. * A continuous pattern of random numbers, between 0 and 1.
* *
* @name rand * @name rand
* @tags generators
* @example * @example
* // randomly change the cutoff * // randomly change the cutoff
* s("bd*4,hh*8").cutoff(rand.range(500,8000)) * s("bd*4,hh*8").cutoff(rand.range(500,8000))
@@ -478,7 +449,6 @@ export const seed = register('seed', (n, pat) => {
export const rand = signal((t, controls) => getRandsAtTime(t, 1, controls.randSeed)); export const rand = signal((t, controls) => getRandsAtTime(t, 1, controls.randSeed));
/** /**
* A continuous pattern of random numbers, between -1 and 1 * A continuous pattern of random numbers, between -1 and 1
* @tags generators
*/ */
export const rand2 = rand.toBipolar(); export const rand2 = rand.toBipolar();
@@ -488,7 +458,6 @@ export const _brandBy = (p) => rand.fmap((x) => x < p);
* A continuous pattern of 0 or 1 (binary random), with a probability for the value being 1 * A continuous pattern of 0 or 1 (binary random), with a probability for the value being 1
* *
* @name brandBy * @name brandBy
* @tags generators
* @param {number} probability - a number between 0 and 1 * @param {number} probability - a number between 0 and 1
* @example * @example
* s("hh*10").pan(brandBy(0.2)) * s("hh*10").pan(brandBy(0.2))
@@ -499,7 +468,6 @@ export const brandBy = (pPat) => reify(pPat).fmap(_brandBy).innerJoin();
* A continuous pattern of 0 or 1 (binary random) * A continuous pattern of 0 or 1 (binary random)
* *
* @name brand * @name brand
* @tags generators
* @example * @example
* s("hh*10").pan(brand) * s("hh*10").pan(brand)
*/ */
@@ -511,7 +479,6 @@ export const _irand = (i) => rand.fmap((x) => Math.trunc(x * i));
* A continuous pattern of random integers, between 0 and n-1. * A continuous pattern of random integers, between 0 and n-1.
* *
* @name irand * @name irand
* @tags generators
* @param {number} n max value (exclusive) * @param {number} n max value (exclusive)
* @example * @example
* // randomly select scale notes from 0 - 7 (= C to C) * // randomly select scale notes from 0 - 7 (= C to C)
@@ -534,7 +501,6 @@ export const __chooseWith = (pat, xs) => {
/** /**
* Choose from the list of values (or patterns of values) using the given * Choose from the list of values (or patterns of values) using the given
* pattern of numbers, which should be in the range of 0..1 * pattern of numbers, which should be in the range of 0..1
* @tags temporal
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -548,7 +514,6 @@ export const chooseWith = (pat, xs) => {
/** /**
* As with {chooseWith}, but the structure comes from the chosen values, rather * As with {chooseWith}, but the structure comes from the chosen values, rather
* than the pattern you're using to choose with. * than the pattern you're using to choose with.
* @tags temporal
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @returns {Pattern}
@@ -559,7 +524,6 @@ export const chooseInWith = (pat, xs) => {
/** /**
* Chooses randomly from the given list of elements. * Chooses randomly from the given list of elements.
* @tags temporal
* @param {...any} xs values / patterns to choose from. * @param {...any} xs values / patterns to choose from.
* @returns {Pattern} - a continuous pattern. * @returns {Pattern} - a continuous pattern.
* @example * @example
@@ -575,7 +539,6 @@ export const chooseOut = choose;
* Chooses from the given list of values (or patterns of values), according * Chooses from the given list of values (or patterns of values), according
* to the pattern that the method is called on. The pattern should be in * to the pattern that the method is called on. The pattern should be in
* the range 0 .. 1. * the range 0 .. 1.
* @tags temporal
* @param {...any} xs * @param {...any} xs
* @returns {Pattern} * @returns {Pattern}
*/ */
@@ -586,7 +549,6 @@ Pattern.prototype.choose = function (...xs) {
/** /**
* As with choose, but the pattern that this method is called on should be * As with choose, but the pattern that this method is called on should be
* in the range -1 .. 1 * in the range -1 .. 1
* @tags temporal
* @param {...any} xs * @param {...any} xs
* @returns {Pattern} * @returns {Pattern}
*/ */
@@ -596,7 +558,6 @@ Pattern.prototype.choose2 = function (...xs) {
/** /**
* Picks one of the elements at random each cycle. * Picks one of the elements at random each cycle.
* @tags temporal
* @synonyms randcat * @synonyms randcat
* @returns {Pattern} * @returns {Pattern}
* @example * @example
@@ -639,7 +600,6 @@ const wchooseWith = (...args) => _wchooseWith(...args).outerJoin();
/** /**
* Chooses randomly from the given list of elements by giving a probability to each element * Chooses randomly from the given list of elements by giving a probability to each element
* @tags temporal
* @param {...any} pairs arrays of value and weight * @param {...any} pairs arrays of value and weight
* @returns {Pattern} - a continuous pattern. * @returns {Pattern} - a continuous pattern.
* @example * @example
@@ -649,7 +609,6 @@ export const wchoose = (...pairs) => wchooseWith(rand, ...pairs);
/** /**
* Picks one of the elements at random each cycle by giving a probability to each element * Picks one of the elements at random each cycle by giving a probability to each element
* @tags temporal
* @synonyms wrandcat * @synonyms wrandcat
* @returns {Pattern} * @returns {Pattern}
* @example * @example
@@ -693,7 +652,6 @@ function _berlin(t, seed = 0) {
/** /**
* Generates a continuous pattern of [perlin noise](https://en.wikipedia.org/wiki/Perlin_noise), in the range 0..1. * Generates a continuous pattern of [perlin noise](https://en.wikipedia.org/wiki/Perlin_noise), in the range 0..1.
* *
* @tags generators
* @name perlin * @name perlin
* @example * @example
* // randomly change the cutoff * // randomly change the cutoff
@@ -706,7 +664,6 @@ export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
* Generates a continuous pattern of [berlin noise](conceived by Jame Coyne and Jade Rowland as a joke but turned out to be surprisingly cool and useful, * Generates a continuous pattern of [berlin noise](conceived by Jame Coyne and Jade Rowland as a joke but turned out to be surprisingly cool and useful,
* like perlin noise but with sawtooth waves), in the range 0..1. * like perlin noise but with sawtooth waves), in the range 0..1.
* *
* @tags generators
* @name berlin * @name berlin
* @example * @example
* // ascending arpeggios * // ascending arpeggios
@@ -727,7 +684,6 @@ export const degradeByWith = register(
* 0 = 0% chance of removal * 0 = 0% chance of removal
* 1 = 100% chance of removal * 1 = 100% chance of removal
* *
* @tags temporal
* @name degradeBy * @name degradeBy
* @memberof Pattern * @memberof Pattern
* @param {number} amount - a number between 0 and 1 * @param {number} amount - a number between 0 and 1
@@ -753,7 +709,6 @@ export const degradeBy = register(
* *
* Randomly removes 50% of events from the pattern. Shorthand for `.degradeBy(0.5)` * Randomly removes 50% of events from the pattern. Shorthand for `.degradeBy(0.5)`
* *
* @tags temporal
* @name degrade * @name degrade
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
@@ -770,7 +725,6 @@ export const degrade = register('degrade', (pat) => pat._degradeBy(0.5), true, t
* 1 = 0% chance of removal * 1 = 0% chance of removal
* Events that would be removed by degradeBy are let through by undegradeBy and vice versa (see second example). * Events that would be removed by degradeBy are let through by undegradeBy and vice versa (see second example).
* *
* @tags temporal
* @name undegradeBy * @name undegradeBy
* @memberof Pattern * @memberof Pattern
* @param {number} amount - a number between 0 and 1 * @param {number} amount - a number between 0 and 1
@@ -799,7 +753,6 @@ export const undegradeBy = register(
* Inverse of `degrade`: Randomly removes 50% of events from the pattern. Shorthand for `.undegradeBy(0.5)` * Inverse of `degrade`: Randomly removes 50% of events from the pattern. Shorthand for `.undegradeBy(0.5)`
* Events that would be removed by degrade are let through by undegrade and vice versa (see second example). * Events that would be removed by degrade are let through by undegrade and vice versa (see second example).
* *
* @tags temporal
* @name undegrade * @name undegrade
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
@@ -818,7 +771,6 @@ export const undegrade = register('undegrade', (pat) => pat._undegradeBy(0.5), t
* Randomly applies the given function by the given probability. * Randomly applies the given function by the given probability.
* Similar to `someCyclesBy` * Similar to `someCyclesBy`
* *
* @tags temporal
* @name sometimesBy * @name sometimesBy
* @memberof Pattern * @memberof Pattern
* @param {number | Pattern} probability - a number between 0 and 1 * @param {number | Pattern} probability - a number between 0 and 1
@@ -838,7 +790,6 @@ export const sometimesBy = register('sometimesBy', function (patx, func, pat) {
* *
* Applies the given function with a 50% chance * Applies the given function with a 50% chance
* *
* @tags temporal
* @name sometimes * @name sometimes
* @memberof Pattern * @memberof Pattern
* @param {function} function - the transformation to apply * @param {function} function - the transformation to apply
@@ -860,7 +811,6 @@ export const sometimes = register('sometimes', function (func, pat) {
* @param {number | Pattern} probability - a number between 0 and 1 * @param {number | Pattern} probability - a number between 0 and 1
* @param {function} function - the transformation to apply * @param {function} function - the transformation to apply
* @returns Pattern * @returns Pattern
* @tags temporal
* @example * @example
* s("bd,hh*8").someCyclesBy(.3, x=>x.speed("0.5")) * s("bd,hh*8").someCyclesBy(.3, x=>x.speed("0.5"))
*/ */
@@ -883,7 +833,6 @@ export const someCyclesBy = register('someCyclesBy', function (patx, func, pat)
* @name someCycles * @name someCycles
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
* @tags temporal
* @example * @example
* s("bd,hh*8").someCycles(x=>x.speed("0.5")) * s("bd,hh*8").someCycles(x=>x.speed("0.5"))
*/ */
@@ -898,7 +847,6 @@ export const someCycles = register('someCycles', function (func, pat) {
* @name often * @name often
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
* @tags temporal
* @example * @example
* s("hh*8").often(x=>x.speed("0.5")) * s("hh*8").often(x=>x.speed("0.5"))
*/ */
@@ -913,7 +861,6 @@ export const often = register('often', function (func, pat) {
* @name rarely * @name rarely
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
* @tags temporal
* @example * @example
* s("hh*8").rarely(x=>x.speed("0.5")) * s("hh*8").rarely(x=>x.speed("0.5"))
*/ */
@@ -925,7 +872,6 @@ export const rarely = register('rarely', function (func, pat) {
* *
* Shorthand for `.sometimesBy(0.1, fn)` * Shorthand for `.sometimesBy(0.1, fn)`
* *
* @tags temporal
* @name almostNever * @name almostNever
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
@@ -940,7 +886,6 @@ export const almostNever = register('almostNever', function (func, pat) {
* *
* Shorthand for `.sometimesBy(0.9, fn)` * Shorthand for `.sometimesBy(0.9, fn)`
* *
* @tags temporal
* @name almostAlways * @name almostAlways
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
@@ -955,7 +900,6 @@ export const almostAlways = register('almostAlways', function (func, pat) {
* *
* Shorthand for `.sometimesBy(0, fn)` (never calls fn) * Shorthand for `.sometimesBy(0, fn)` (never calls fn)
* *
* @tags temporal
* @name never * @name never
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
@@ -970,7 +914,6 @@ export const never = register('never', function (_, pat) {
* *
* Shorthand for `.sometimesBy(1, fn)` (always calls fn) * Shorthand for `.sometimesBy(1, fn)` (always calls fn)
* *
* @tags temporal
* @name always * @name always
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
@@ -999,7 +942,6 @@ export function _keyDown(keyname) {
* Do something on a keypress, or array of keypresses * Do something on a keypress, or array of keypresses
* [Key name reference](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values) * [Key name reference](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values)
* *
* @tags external_io
* @name whenKey * @name whenKey
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
@@ -1016,7 +958,6 @@ export const whenKey = register('whenKey', function (input, func, pat) {
* returns true when a key or array of keys is held * returns true when a key or array of keys is held
* [Key name reference](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values) * [Key name reference](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values)
* *
* @tags external_io
* @name keyDown * @name keyDown
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
@@ -1034,8 +975,6 @@ export const keyDown = register('keyDown', function (pat) {
* event durations, from the pattern that it is combined with. * event durations, from the pattern that it is combined with.
* For example `cyclesPer.struct("1 1 [1 1] 1")` would give the same as `"0.25 0.25 [0.125 0.125] 0.25"`. * For example `cyclesPer.struct("1 1 [1 1] 1")` would give the same as `"0.25 0.25 [0.125 0.125] 0.25"`.
* See also its reciprocal, `per`, also known as `perCycle`. * See also its reciprocal, `per`, also known as `perCycle`.
*
* @tags temporal
* @example * @example
* // Shorter events are lower in pitch * // Shorter events are lower in pitch
* sound("saw saw [saw saw] saw") * sound("saw saw [saw saw] saw")
@@ -1054,7 +993,6 @@ export const cyclesPer = new Pattern(function (state) {
* event durations, from the pattern that it is combined with. * event durations, from the pattern that it is combined with.
* For example `per.struct("1 1 [1 1] 1")` would give the same as `"4 4 [8 8] 4"`. * For example `per.struct("1 1 [1 1] 1")` would give the same as `"4 4 [8 8] 4"`.
* See also its reciprocal, `cyclesPer`. * See also its reciprocal, `cyclesPer`.
* @tags temporal
* @synonyms perCycle * @synonyms perCycle
* @example * @example
* // Shorter events are more distorted * // Shorter events are more distorted
@@ -1072,7 +1010,6 @@ export const perCycle = per;
* particular, where the event duration halves, the * particular, where the event duration halves, the
* returned value increases by one. `perx.struct("1 1 [1 [1 1]] 1")` would therefore be * returned value increases by one. `perx.struct("1 1 [1 [1 1]] 1")` would therefore be
* the same as `"3 3 [4 [5 5]] 3"`. * the same as `"3 3 [4 [5 5]] 3"`.
* @tags temporal
*/ */
export const perx = new Pattern(function (state) { export const perx = new Pattern(function (state) {
const n = Fraction(1).div(state.span.duration); const n = Fraction(1).div(state.span.duration);
+11
View File
@@ -0,0 +1,11 @@
let time;
export function getTime() {
if (!time) {
throw new Error('no time set! use setTime to define a time source');
}
return time();
}
export function setTime(func) {
time = func;
}
+2 -7
View File
@@ -339,13 +339,8 @@ export function uniqsortr(a) {
export function unicodeToBase64(text) { export function unicodeToBase64(text) {
const utf8Bytes = new TextEncoder().encode(text); const utf8Bytes = new TextEncoder().encode(text);
let binaryString = ''; const base64String = btoa(String.fromCharCode(...utf8Bytes));
const chunkSize = 0x8000; return base64String;
for (let i = 0; i < utf8Bytes.length; i += chunkSize) {
const chunk = utf8Bytes.subarray(i, i + chunkSize);
binaryString += String.fromCharCode.apply(null, chunk);
}
return btoa(binaryString);
} }
export function base64ToUnicode(base64String) { export function base64ToUnicode(base64String) {
-2
View File
@@ -135,8 +135,6 @@ export async function loadOrc(url) {
* p4 -- MIDI key number (as a real number, not an integer but in [0, 127]. * p4 -- MIDI key number (as a real number, not an integer but in [0, 127].
* p5 -- MIDI velocity (as a real number, not an integer but in [0, 127]. * p5 -- MIDI velocity (as a real number, not an integer but in [0, 127].
* p6 -- Strudel controls, as a string. * p6 -- Strudel controls, as a string.
*
* @tags external_io
*/ */
export const csoundm = register('csoundm', (instrument, pat) => { export const csoundm = register('csoundm', (instrument, pat) => {
let p1 = instrument; let p1 = instrument;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/csound", "name": "@strudel/csound",
"version": "1.3.0", "version": "1.2.6",
"description": "csound bindings for strudel", "description": "csound bindings for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
-10
View File
@@ -78,16 +78,6 @@ export const cleanupDraw = (clearScreen = true, id) => {
stopAllAnimations(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) { Pattern.prototype.onPaint = function (painter) {
return this.withState((state) => { return this.withState((state) => {
if (!state.controls.painters) { if (!state.controls.painters) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/draw", "name": "@strudel/draw",
"version": "1.2.6", "version": "1.2.5",
"description": "Helpers for drawing with Strudel", "description": "Helpers for drawing with Strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
-2
View File
@@ -42,7 +42,6 @@ const getValue = (e) => {
* *
* @name pianoroll * @name pianoroll
* @synonyms punchcard * @synonyms punchcard
* @tags visualization
* @param {Object} options Object containing all the optional following parameters as key value pairs: * @param {Object} options Object containing all the optional following parameters as key value pairs:
* @param {integer} cycles number of cycles to be displayed at the same time - defaults to 4 * @param {integer} cycles number of cycles to be displayed at the same time - defaults to 4
* @param {number} playhead location of the active notes on the time axis - 0 to 1, defaults to 0.5 * @param {number} playhead location of the active notes on the time axis - 0 to 1, defaults to 0.5
@@ -300,7 +299,6 @@ Pattern.prototype.punchcard = function (options) {
* Supports all the same options as pianoroll. * Supports all the same options as pianoroll.
* *
* @name wordfall * @name wordfall
* @tags visualization
*/ */
Pattern.prototype.wordfall = function (options) { Pattern.prototype.wordfall = function (options) {
return this.punchcard({ vertical: 1, labels: 1, stroke: 0, fillActive: 1, active: 'white', ...options }); return this.punchcard({ vertical: 1, labels: 1, stroke: 0, fillActive: 1, active: 'white', ...options });
-1
View File
@@ -116,7 +116,6 @@ export function pitchwheel({
/** /**
* Renders a pitch circle to visualize frequencies within one octave * Renders a pitch circle to visualize frequencies within one octave
* @name pitchwheel * @name pitchwheel
* @tags visualization
* @param {number} hapcircles * @param {number} hapcircles
* @param {number} circle * @param {number} circle
* @param {number} edo * @param {number} edo
-1
View File
@@ -129,7 +129,6 @@ function drawSpiral(options) {
* Displays a spiral visual. * Displays a spiral visual.
* *
* @name spiral * @name spiral
* @tags visualization
* @param {Object} options Object containing all the optional following parameters as key value pairs: * @param {Object} options Object containing all the optional following parameters as key value pairs:
* @param {number} stretch controls the rotations per cycle ratio, where 1 = 1 cycle / 360 degrees * @param {number} stretch controls the rotations per cycle ratio, where 1 = 1 cycle / 360 degrees
* @param {number} size the diameter of the spiral * @param {number} size the diameter of the spiral
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/embed", "name": "@strudel/embed",
"version": "1.1.2", "version": "1.1.1",
"description": "Embeddable Web Component to load a Strudel REPL into an iframe", "description": "Embeddable Web Component to load a Strudel REPL into an iframe",
"main": "embed.js", "main": "embed.js",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/gamepad", "name": "@strudel/gamepad",
"version": "1.2.6", "version": "1.2.5",
"description": "Gamepad Inputs for strudel", "description": "Gamepad Inputs for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/hydra", "name": "@strudel/hydra",
"version": "1.2.6", "version": "1.2.5",
"description": "Hydra integration for strudel", "description": "Hydra integration for strudel",
"main": "hydra.mjs", "main": "hydra.mjs",
"type": "module", "type": "module",
-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);
}
}
}
}
+67 -193
View File
@@ -5,26 +5,10 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import * as _WebMidi from 'webmidi'; import * as _WebMidi from 'webmidi';
import { import { Pattern, isPattern, logger, ref } from '@strudel/core';
Hap,
Pattern,
TimeSpan,
getCps,
getIsStarted,
getPattern,
getTime,
getTriggerFunc,
isPattern,
logger,
ref,
reify,
} from '@strudel/core';
import { noteToMidi, getControlName } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi'; import { Note } from 'webmidi';
import { getAudioContext } from '@strudel/webaudio';
import { scheduleAtTime } from '../superdough/helpers.mjs'; 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: // if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi; export const { WebMidi } = _WebMidi;
@@ -33,6 +17,10 @@ function supportsMidi() {
return typeof navigator.requestMIDIAccess === 'function'; return typeof navigator.requestMIDIAccess === 'function';
} }
function getMidiDeviceNamesString(devices) {
return devices.map((o) => `'${o.name}'`).join(' | ');
}
export function enableWebMidi(options = {}) { export function enableWebMidi(options = {}) {
const { onReady, onConnected, onDisconnected, onEnabled } = options; const { onReady, onConnected, onDisconnected, onEnabled } = options;
if (WebMidi.enabled) { if (WebMidi.enabled) {
@@ -70,6 +58,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 // send start/stop messages to outputs when repl starts/stops
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
@@ -113,7 +124,6 @@ function githubPath(base, subpath = '') {
/** /**
* configures the default midimap, which is used when no "midimap" port is set * configures the default midimap, which is used when no "midimap" port is set
* @tags external_io, midi
* @example * @example
* defaultmidimap({ lpf: 74 }) * defaultmidimap({ lpf: 74 })
* $: note("c a f e").midi(); * $: note("c a f e").midi();
@@ -127,7 +137,6 @@ let loadCache = {};
/** /**
* Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers. * Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers.
* @tags external_io, midi
* @example * @example
* midimaps({ mymap: { lpf: 74 } }) * midimaps({ mymap: { lpf: 74 } })
* $: note("c a f e") * $: note("c a f e")
@@ -282,7 +291,6 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
/** /**
* MIDI output: Opens a MIDI output port. * MIDI output: Opens a MIDI output port.
* @tags external_io
* @param {string | number} midiport MIDI device name or index defaulting to 0 * @param {string | number} midiport MIDI device name or index defaulting to 0
* @param {object} options Additional MIDI configuration options * @param {object} options Additional MIDI configuration options
* @example * @example
@@ -469,49 +477,14 @@ Pattern.prototype.midi = function (midiport, options = {}) {
}); });
}; };
/** let listeners = {};
* Initialize a midi input device const refs = {};
*/ const refsByChan = {};
async function _initializeInput(input) {
if (isPattern(input)) {
throw new Error(
`[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
}')`,
);
}
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)}`,
);
}
return instance;
}
// MIDI input wrappers, by specified input string/index
const midiInputs = {};
/** /**
* MIDI input: Opens a MIDI input port to receive MIDI control change messages. * 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 * 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
* @param {string | number} input MIDI device name or index defaulting to 0 * @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. * @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) * When queried, the pattern will produces the most recently received midi value (normalized to 0 to 1)
@@ -526,146 +499,47 @@ const midiInputs = {};
* .when(cc(0).gt(0), x => x.postgain(0)) * .when(cc(0).gt(0), x => x.postgain(0))
*/ */
export async function midin(input) { export async function midin(input) {
const instance = await _initializeInput(input); if (isPattern(input)) {
return instance.createCC.bind(instance);
}
/**
* MIDI keyboard: Opens a MIDI input port to receive MIDI keyboard messages.
*
* The note length is fixed as Superdough is not currently set up for undetermined
* note durations
*
* @name midikeys
* @tags external_io, midi
* @param {string | number} input MIDI device name or index defaulting to 0
* @returns {function((number | Pattern)=): Pattern} A function that produces a pattern.
* When queried, the pattern will produces the most recently played midi notes and velocities,
* lasting for the specified duration
* @example
* const kb = await midikeys('Arturia KeyStep 32')
* kb().s("tri").lpf(80).lpe(6).lpd(0.1).room(2).delay(0.35)
* @example
* const kb = await midikeys('Arturia KeyStep 32')
* kb("0.5 1")
* .s("saw")
* .add(note(rand.mul(0.3)))
* .lpf(1000).lpe(2).room(0.5)
*/
const kHaps = {};
const kListeners = {};
function _triggerKeyboard(input, cps, now, latencyCycles) {
const pattern = getPattern();
const trigger = getTriggerFunc();
if (!pattern || !trigger) {
return false;
}
const t = now + latencyCycles;
const eps = 1e-6;
const haps = pattern.queryArc(t - eps, t + eps, { _cps: cps });
// Only keep haps coming from `midikeys`
const kbHaps = haps.filter((hap) => hap.value?.midikey?.startsWith(`${input}_`));
const ctxNow = getAudioContext().currentTime;
if (!kbHaps.length) {
return false;
}
kbHaps.forEach((hap) => {
if (!hap.hasOnset()) {
return;
}
const t = ctxNow + (hap.whole.begin - now) / cps;
const duration = hap.duration / cps;
trigger(hap, t - ctxNow, duration, cps, t);
});
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( throw new Error(
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`, `midin: does not accept Pattern as input. Make sure to pass device name with single quotes. Example: midin('${
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
}')`,
); );
} }
const initial = await enableWebMidi(); // only returns on first init
if (!kHaps[input]) { const device = getDevice(input, WebMidi.inputs);
kHaps[input] = []; if (!device) {
throw new Error(
`midiin: device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
);
} }
kListeners[input] && device.removeListener('midimessage', kListeners[input]); if (initial) {
kListeners[input] = (e) => { const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
const { dataBytes, message } = e; logger(
const noteon = message.command === 9; `Midi enabled! Using "${device.name}". ${
let noteoff = message.command === 8; otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
// Don't enqueue or trigger midi notes if scheduler is not started }`,
const notStarted = !getIsStarted(); );
// Ignore non-note messages (e.g. CC, pitchbend, modwheel, etc.) }
const notANote = !noteon && !noteoff; refs[input] ??= {};
if (notStarted || notANote) { refsByChan[input] ??= {};
return; const cc = (cc, chan) => {
} if (chan !== undefined) {
const [note, velocity] = dataBytes; return ref(() => refsByChan[input][cc]?.[chan] || 0);
noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff
const key = `${input}_${note}`;
const cps = getCps() ?? 0.5;
const triggerAvailable = !!(getPattern() && getTriggerFunc());
const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency
const now = getTime();
const t = now + latencySeconds * cps;
const span = new TimeSpan(t, t);
let value = { midikey: key };
if (noteoff) {
/* TODO: It's a big effort, but we could modify superdough to allow for situations where
we don't know the hap duration in advance. This would mean, for example, that if the hap
is flagged as such a special note-on event, we have all effects be persistent & all ADSR
envelopes stop at the S stage [and store references to them by `midikey`]
If this is implemented, then getting full keyboard functionality should be as simple
as sending the corresponding note-off event below and triggering `release` on each of those
referenced effects/envelopes
value = { ...value, noteoff: true };
If this is achieved, we can remove the noteLength parameter
*/
return;
} else {
value = { ...value, note: Math.round(note), velocity: velocity / 127 };
}
kHaps[input].push(new Hap(span, span, value, {}));
if (!noteoff && triggerAvailable) {
// If we have access to a trigger function, we call it to immediately
// dispatch to the audio engine, rather than waiting for cyclist to catch these haps
const triggered = _triggerKeyboard(input, cps, now, latencySeconds * cps);
if (triggered) {
kHaps[input] = [];
}
} }
return ref(() => refs[input][cc] || 0);
}; };
device.addListener('midimessage', kListeners[input]);
const kb = (noteLength = 0.5) => { listeners[input] && device.removeListener('midimessage', listeners[input]);
const nlPat = reify(noteLength); listeners[input] = (e) => {
const query = (state) => { const ccNum = e.dataBytes[0];
const haps = kHaps[input].flatMap((hap) => { const v = e.dataBytes[1];
const lenHaps = nlPat.query(state.setSpan(hap.wholeOrPart())); const chan = e.message.channel;
return lenHaps.map((lenHap) => { const scaled = v / 127;
const nl = lenHap.value ?? 0.5; refsByChan[input][ccNum] ??= {};
const whole = new TimeSpan(hap.whole.begin, hap.whole.begin.add(nl)); refsByChan[input][ccNum][chan] = scaled;
const part = new TimeSpan(hap.part.begin, hap.part.begin.add(nl)); refs[input][ccNum] = scaled;
const context = hap.combineContext(lenHap);
return new Hap(whole, part, hap.value, context);
});
});
if (state.controls.cyclist) {
// Notes have been sent; clear them
kHaps[input] = [];
}
return haps;
};
return new Pattern(query);
}; };
return kb; device.addListener('midimessage', listeners[input]);
return cc;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/midi", "name": "@strudel/midi",
"version": "1.3.0", "version": "1.2.6",
"description": "Midi API for strudel", "description": "Midi API for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
-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;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/mini", "name": "@strudel/mini",
"version": "1.2.6", "version": "1.2.5",
"description": "Mini notation for strudel", "description": "Mini notation for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mondolang", "name": "mondolang",
"version": "1.1.2", "version": "1.1.1",
"description": "a language for functional composition that translates to js", "description": "a language for functional composition that translates to js",
"main": "mondo.mjs", "main": "mondo.mjs",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/mondo", "name": "@strudel/mondo",
"version": "1.1.6", "version": "1.1.5",
"description": "mondo notation for strudel", "description": "mondo notation for strudel",
"main": "mondough.mjs", "main": "mondough.mjs",
"type": "module", "type": "module",
-15
View File
@@ -7,7 +7,6 @@ import { signal } from '../core/signal.mjs';
* @name accelerationX * @name accelerationX
* @return {Pattern} * @return {Pattern}
* @synonyms accX * @synonyms accX
* @tags external_io
* @example * @example
* n(accelerationX.segment(4).range(0,7)).scale("C:minor") * n(accelerationX.segment(4).range(0,7)).scale("C:minor")
* *
@@ -18,7 +17,6 @@ import { signal } from '../core/signal.mjs';
* @name accelerationY * @name accelerationY
* @return {Pattern} * @return {Pattern}
* @synonyms accY * @synonyms accY
* @tags external_io
* @example * @example
* n(accelerationY.segment(4).range(0,7)).scale("C:minor") * n(accelerationY.segment(4).range(0,7)).scale("C:minor")
* *
@@ -29,7 +27,6 @@ import { signal } from '../core/signal.mjs';
* @name accelerationZ * @name accelerationZ
* @return {Pattern} * @return {Pattern}
* @synonyms accZ * @synonyms accZ
* @tags external_io
* @example * @example
* n(accelerationZ.segment(4).range(0,7)).scale("C:minor") * n(accelerationZ.segment(4).range(0,7)).scale("C:minor")
* *
@@ -40,7 +37,6 @@ import { signal } from '../core/signal.mjs';
* @name gravityX * @name gravityX
* @return {Pattern} * @return {Pattern}
* @synonyms gravX * @synonyms gravX
* @tags external_io
* @example * @example
* n(gravityX.segment(4).range(0,7)).scale("C:minor") * n(gravityX.segment(4).range(0,7)).scale("C:minor")
* *
@@ -51,7 +47,6 @@ import { signal } from '../core/signal.mjs';
* @name gravityY * @name gravityY
* @return {Pattern} * @return {Pattern}
* @synonyms gravY * @synonyms gravY
* @tags external_io
* @example * @example
* n(gravityY.segment(4).range(0,7)).scale("C:minor") * n(gravityY.segment(4).range(0,7)).scale("C:minor")
* *
@@ -62,7 +57,6 @@ import { signal } from '../core/signal.mjs';
* @name gravityZ * @name gravityZ
* @return {Pattern} * @return {Pattern}
* @synonyms gravZ * @synonyms gravZ
* @tags external_io
* @example * @example
* n(gravityZ.segment(4).range(0,7)).scale("C:minor") * n(gravityZ.segment(4).range(0,7)).scale("C:minor")
* *
@@ -73,7 +67,6 @@ import { signal } from '../core/signal.mjs';
* @name rotationAlpha * @name rotationAlpha
* @return {Pattern} * @return {Pattern}
* @synonyms rotA, rotZ, rotationZ * @synonyms rotA, rotZ, rotationZ
* @tags external_io
* @example * @example
* n(rotationAlpha.segment(4).range(0,7)).scale("C:minor") * n(rotationAlpha.segment(4).range(0,7)).scale("C:minor")
* *
@@ -84,7 +77,6 @@ import { signal } from '../core/signal.mjs';
* @name rotationBeta * @name rotationBeta
* @return {Pattern} * @return {Pattern}
* @synonyms rotB, rotX, rotationX * @synonyms rotB, rotX, rotationX
* @tags external_io
* @example * @example
* n(rotationBeta.segment(4).range(0,7)).scale("C:minor") * n(rotationBeta.segment(4).range(0,7)).scale("C:minor")
* *
@@ -95,7 +87,6 @@ import { signal } from '../core/signal.mjs';
* @name rotationGamma * @name rotationGamma
* @return {Pattern} * @return {Pattern}
* @synonyms rotG, rotY, rotationY * @synonyms rotG, rotY, rotationY
* @tags external_io
* @example * @example
* n(rotationGamma.segment(4).range(0,7)).scale("C:minor") * n(rotationGamma.segment(4).range(0,7)).scale("C:minor")
* *
@@ -106,7 +97,6 @@ import { signal } from '../core/signal.mjs';
* @name orientationAlpha * @name orientationAlpha
* @return {Pattern} * @return {Pattern}
* @synonyms oriA, oriZ, orientationZ * @synonyms oriA, oriZ, orientationZ
* @tags external_io
* @example * @example
* n(orientationAlpha.segment(4).range(0,7)).scale("C:minor") * n(orientationAlpha.segment(4).range(0,7)).scale("C:minor")
* *
@@ -117,7 +107,6 @@ import { signal } from '../core/signal.mjs';
* @name orientationBeta * @name orientationBeta
* @return {Pattern} * @return {Pattern}
* @synonyms oriB, oriX, orientationX * @synonyms oriB, oriX, orientationX
* @tags external_io
* @example * @example
* n(orientationBeta.segment(4).range(0,7)).scale("C:minor") * n(orientationBeta.segment(4).range(0,7)).scale("C:minor")
* *
@@ -128,7 +117,6 @@ import { signal } from '../core/signal.mjs';
* @name orientationGamma * @name orientationGamma
* @return {Pattern} * @return {Pattern}
* @synonyms oriG, oriY, orientationY * @synonyms oriG, oriY, orientationY
* @tags external_io
* @example * @example
* n(orientationGamma.segment(4).range(0,7)).scale("C:minor") * n(orientationGamma.segment(4).range(0,7)).scale("C:minor")
* *
@@ -139,7 +127,6 @@ import { signal } from '../core/signal.mjs';
* @name absoluteOrientationAlpha * @name absoluteOrientationAlpha
* @return {Pattern} * @return {Pattern}
* @synonyms absOriA, absOriZ, absoluteOrientationZ * @synonyms absOriA, absOriZ, absoluteOrientationZ
* @tags external_io
* @example * @example
* n(absoluteOrientationAlpha.segment(4).range(0,7)).scale("C:minor") * n(absoluteOrientationAlpha.segment(4).range(0,7)).scale("C:minor")
* *
@@ -150,7 +137,6 @@ import { signal } from '../core/signal.mjs';
* @name absoluteOrientationBeta * @name absoluteOrientationBeta
* @return {Pattern} * @return {Pattern}
* @synonyms absOriB, absOriX, absoluteOrientationX * @synonyms absOriB, absOriX, absoluteOrientationX
* @tags external_io
* @example * @example
* n(absoluteOrientationBeta.segment(4).range(0,7)).scale("C:minor") * n(absoluteOrientationBeta.segment(4).range(0,7)).scale("C:minor")
* *
@@ -161,7 +147,6 @@ import { signal } from '../core/signal.mjs';
* @name absoluteOrientationGamma * @name absoluteOrientationGamma
* @return {Pattern} * @return {Pattern}
* @synonyms absOriG, absOriY, absoluteOrientationY * @synonyms absOriG, absOriY, absoluteOrientationY
* @tags external_io
* @example * @example
* n(absoluteOrientationGamma.segment(4).range(0,7)).scale("C:minor") * n(absoluteOrientationGamma.segment(4).range(0,7)).scale("C:minor")
* *
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/motion", "name": "@strudel/motion",
"version": "1.2.6", "version": "1.2.5",
"description": "DeviceMotion API for strudel", "description": "DeviceMotion API for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/mqtt", "name": "@strudel/mqtt",
"version": "1.2.6", "version": "1.2.5",
"description": "MQTT API for strudel", "description": "MQTT API for strudel",
"main": "mqtt.mjs", "main": "mqtt.mjs",
"type": "module", "type": "module",
-1
View File
@@ -79,7 +79,6 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
* For more info, read [MIDI & OSC in the docs](https://strudel.cc/learn/input-output/) * For more info, read [MIDI & OSC in the docs](https://strudel.cc/learn/input-output/)
* *
* @name osc * @name osc
* @tags external_io
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
*/ */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/osc", "name": "@strudel/osc",
"version": "1.3.2", "version": "1.3.0",
"description": "OSC messaging for strudel", "description": "OSC messaging for strudel",
"main": "osc.mjs", "main": "osc.mjs",
"bin": "./server.js", "bin": "./server.js",
Executable → Regular
View File
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/reference", "name": "@strudel/reference",
"version": "1.2.2", "version": "1.2.1",
"description": "Headless reference of all strudel functions", "description": "Headless reference of all strudel functions",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/repl", "name": "@strudel/repl",
"version": "1.3.0", "version": "1.2.7",
"description": "Strudel REPL as a Web Component", "description": "Strudel REPL as a Web Component",
"module": "index.mjs", "module": "index.mjs",
"publishConfig": { "publishConfig": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/sampler", "name": "@strudel/sampler",
"version": "0.2.4", "version": "0.2.3",
"description": "", "description": "",
"keywords": [ "keywords": [
"tidalcycles", "tidalcycles",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/serial", "name": "@strudel/serial",
"version": "1.2.6", "version": "1.2.5",
"description": "Webserial API for strudel", "description": "Webserial API for strudel",
"main": "serial.mjs", "main": "serial.mjs",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/soundfonts", "name": "@strudel/soundfonts",
"version": "1.3.0", "version": "1.2.6",
"description": "Soundsfont support for strudel", "description": "Soundsfont support for strudel",
"main": "index.mjs", "main": "index.mjs",
"publishConfig": { "publishConfig": {
+4 -3
View File
@@ -154,8 +154,9 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) {
attack: attack ?? 0.005, attack: attack ?? 0.005,
release: release ?? 0.05, release: release ?? 0.05,
}; };
const now = ac.currentTime;
Object.entries(options).forEach(([key, value]) => { Object.entries(options).forEach(([key, value]) => {
node[key].value = value; node[key].setValueAtTime(value, now);
}); });
return node; return node;
} }
@@ -172,7 +173,6 @@ export const getADSRValues = (params, curve = 'linear', defaultValues) => {
if (a == null && d == null && s == null && r == null) { if (a == null && d == null && s == null && r == null) {
return defaultValues ?? [envmin, envmin, envmax, releaseMin]; return defaultValues ?? [envmin, envmin, envmax, releaseMin];
} }
const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin; const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin;
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
}; };
@@ -242,8 +242,9 @@ export function createFilter(context, start, end, params, cps, cycle) {
const factory = () => context.createBiquadFilter(); const factory = () => context.createBiquadFilter();
filter = getNodeFromPool('filter', factory); filter = getNodeFromPool('filter', factory);
filter.type = type; filter.type = type;
const now = context.currentTime;
Object.entries({ Q: q, frequency }).forEach(([key, value]) => { Object.entries({ Q: q, frequency }).forEach(([key, value]) => {
filter[key].value = value; filter[key].setValueAtTime(value, now);
}); });
frequencyParam = filter.frequency; frequencyParam = filter.frequency;
} }
+1 -2
View File
@@ -98,7 +98,6 @@ export const connectLFO = (id, params, nodeTracker) => {
fxi = 'main', fxi = 'main',
depth = 1, depth = 1,
depthabs, depthabs,
retrig = 0,
...filteredParams ...filteredParams
} = params; } = params;
const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl); const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
@@ -110,7 +109,7 @@ export const connectLFO = (id, params, nodeTracker) => {
const modParams = { const modParams = {
...filteredParams, ...filteredParams,
frequency: sync !== undefined ? sync * cps : rate, frequency: sync !== undefined ? sync * cps : rate,
time: retrig > 0.5 ? 0 : cycle / cps, time: cycle / cps,
depth: depthValue, depth: depthValue,
min, min,
max, max,
+14 -25
View File
@@ -7,13 +7,11 @@ This program is free software: you can redistribute it and/or modify it under th
const nodePools = new Map(); const nodePools = new Map();
const POOL_KEY = Symbol('nodePoolKey'); const POOL_KEY = Symbol('nodePoolKey');
const IS_WORKLET_DEAD = Symbol('nodePoolIsWorkletDead');
const MAX_POOL_SIZE = 64;
export const isPoolable = (node) => !!node[POOL_KEY]; export const isPoolable = (node) => !!node[POOL_KEY];
const getNodeTime = (node) => {
return node.context?.currentTime ?? 0;
};
const getParams = (node) => { const getParams = (node) => {
const params = new Set(); const params = new Set();
node.parameters?.forEach((param) => params.add(param)); node.parameters?.forEach((param) => params.add(param));
@@ -40,43 +38,34 @@ export const releaseNodeToPool = (node) => {
// not reusable // not reusable
return; return;
} }
if (node[IS_WORKLET_DEAD]) {
// Worklet already terminated, don't pool it
return;
}
const key = node[POOL_KEY]; const key = node[POOL_KEY];
if (key == null) return; if (key == null) return;
const now = getNodeTime(node); const now = node.context?.currentTime ?? 0;
getParams(node).forEach((param) => param.cancelScheduledValues(now)); getParams(node).forEach((param) => param.cancelScheduledValues(now));
const pool = nodePools.get(key) ?? []; const pool = nodePools.get(key) ?? [];
pool.push(new WeakRef(node)); if (pool.length < MAX_POOL_SIZE) {
nodePools.set(key, pool); pool.push(new WeakRef(node));
nodePools.set(key, pool);
}
}; };
// Audio worklets are given a grace period to survive (`return true`) after export const markWorkletAsDead = (worklet) => (worklet[IS_WORKLET_DEAD] = true);
// being released. This concludes at time `end + 0.5`. We test here whether we are
// within some safe distance of that (`end + 0.45`) and if so, permit the node to be
// released. This helps to prevent race conditions between node termination and node
// re-use
const isNodeAlive = (node) => {
// Skip check if node is not a worklet
if (!(node instanceof AudioWorkletNode)) return true;
const now = getNodeTime(node);
const end = node?.parameters?.get('end').value ?? 0;
return now < end + 0.45;
};
// Attempt to get node from the pool. If this fails, fall back // Attempt to get node from the pool. If this fails, fall back
// to building it with the factory // to building it with the factory
export const getNodeFromPool = (key, factory) => { export const getNodeFromPool = (key, factory) => {
const pool = nodePools.get(key) ?? []; const pool = nodePools.get(key) ?? [];
let node; let node;
let found = false;
while (pool.length) { while (pool.length) {
const ref = pool.pop(); const ref = pool.pop();
node = ref?.deref(); node = ref?.deref();
if (node != null && isNodeAlive(node)) { if (node != null && !node[IS_WORKLET_DEAD]) break;
found = true;
break;
}
} }
if (!found) { if (node == null || node[IS_WORKLET_DEAD]) {
node = factory(); node = factory();
} }
node[POOL_KEY] = key; node[POOL_KEY] = key;
+7 -22
View File
@@ -35,9 +35,7 @@ class OLAProcessor extends AudioWorkletProcessor {
} }
/** Handles dynamic reallocation of input/output channels buffer /** Handles dynamic reallocation of input/output channels buffer
* (channel numbers may vary during lifecycle) (channel numbers may lety during lifecycle) **/
* @tags internals
**/
reallocateChannelsIfNeeded(inputs, outputs) { reallocateChannelsIfNeeded(inputs, outputs) {
for (let i = 0; i < this.nbInputs; i++) { for (let i = 0; i < this.nbInputs; i++) {
let nbChannels = inputs[i].length; let nbChannels = inputs[i].length;
@@ -90,10 +88,7 @@ class OLAProcessor extends AudioWorkletProcessor {
} }
} }
/** /** Read next web audio block to input buffers **/
* Read next web audio block to input buffers
* @tags internals
**/
readInputs(inputs) { readInputs(inputs) {
// when playback is paused, we may stop receiving new samples // when playback is paused, we may stop receiving new samples
if (inputs[0].length && inputs[0][0].length == 0) { if (inputs[0].length && inputs[0][0].length == 0) {
@@ -113,9 +108,7 @@ class OLAProcessor extends AudioWorkletProcessor {
} }
} }
/** Write next web audio block from output buffers /** Write next web audio block from output buffers **/
* @tags internals
**/
writeOutputs(outputs) { writeOutputs(outputs) {
for (let i = 0; i < this.nbInputs; i++) { for (let i = 0; i < this.nbInputs; i++) {
for (let j = 0; j < this.inputBuffers[i].length; j++) { for (let j = 0; j < this.inputBuffers[i].length; j++) {
@@ -125,9 +118,7 @@ class OLAProcessor extends AudioWorkletProcessor {
} }
} }
/** Shift left content of input buffers to receive new web audio block /** Shift left content of input buffers to receive new web audio block **/
* @tags internals
**/
shiftInputBuffers() { shiftInputBuffers() {
for (let i = 0; i < this.nbInputs; i++) { for (let i = 0; i < this.nbInputs; i++) {
for (let j = 0; j < this.inputBuffers[i].length; j++) { for (let j = 0; j < this.inputBuffers[i].length; j++) {
@@ -136,9 +127,7 @@ class OLAProcessor extends AudioWorkletProcessor {
} }
} }
/** Shift left content of output buffers to receive new web audio block /** Shift left content of output buffers to receive new web audio block **/
* @tags internals
**/
shiftOutputBuffers() { shiftOutputBuffers() {
for (let i = 0; i < this.nbOutputs; i++) { for (let i = 0; i < this.nbOutputs; i++) {
for (let j = 0; j < this.outputBuffers[i].length; j++) { for (let j = 0; j < this.outputBuffers[i].length; j++) {
@@ -148,9 +137,7 @@ class OLAProcessor extends AudioWorkletProcessor {
} }
} }
/** Copy contents of input buffers to buffer actually sent to process /** Copy contents of input buffers to buffer actually sent to process **/
* @tags internals
**/
prepareInputBuffersToSend() { prepareInputBuffersToSend() {
for (let i = 0; i < this.nbInputs; i++) { for (let i = 0; i < this.nbInputs; i++) {
for (let j = 0; j < this.inputBuffers[i].length; j++) { for (let j = 0; j < this.inputBuffers[i].length; j++) {
@@ -159,9 +146,7 @@ class OLAProcessor extends AudioWorkletProcessor {
} }
} }
/** Add contents of output buffers just processed to output buffers /** Add contents of output buffers just processed to output buffers **/
* @tags internals
**/
handleOutputBuffersToRetrieve() { handleOutputBuffersToRetrieve() {
for (let i = 0; i < this.nbOutputs; i++) { for (let i = 0; i < this.nbOutputs; i++) {
for (let j = 0; j < this.outputBuffers[i].length; j++) { for (let j = 0; j < this.outputBuffers[i].length; j++) {

Some files were not shown because too many files have changed in this diff Show More