mirror of
https://codeberg.org/uzu/strudel
synced 2026-08-01 13:29:25 -04:00
Compare commits
283 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a866cb2189 | |||
| 890d69b23e | |||
| 8e187abf24 | |||
| 2d014b47c8 | |||
| daafee7527 | |||
| c150752372 | |||
| e248bf85f3 | |||
| 6c51c0261a | |||
| deefde7b50 | |||
| d4f63e8de3 | |||
| 19cb3dedc2 | |||
| a7c3407da7 | |||
| 6870b04fb2 | |||
| 2b2646a768 | |||
| 97ef5bc335 | |||
| 85e6d436ef | |||
| d3e2b7c7b4 | |||
| def1738259 | |||
| 0feeb1e701 | |||
| 813cc6c190 | |||
| 62d4f84e69 | |||
| 49a1e11cd8 | |||
| 941c97da0d | |||
| 95a9d301a1 | |||
| 475f17ddfd | |||
| bd68c6a0a7 | |||
| 2635716c87 | |||
| 58f956b57e | |||
| 0ae2c120b3 | |||
| 05a43ef687 | |||
| f21aeb55bd | |||
| d83139980b | |||
| 0c61cd7670 | |||
| 13b11efa15 | |||
| f73b395648 | |||
| 9c47efd3cf | |||
| f4066089f4 | |||
| 0e81987c4e | |||
| 23dbe53013 | |||
| 0e4d0484a3 | |||
| 37bf2e4dc7 | |||
| e69761397e | |||
| 10c33f24f5 | |||
| ea0ec7edb9 | |||
| f900642359 | |||
| 4cc63b8b01 | |||
| 26832c0fe7 | |||
| 01abc8ae79 | |||
| 84df683794 | |||
| ca1aca0c00 | |||
| e8668a1552 | |||
| 86d65c72f0 | |||
| 9f19e4616a | |||
| e15ce7d402 | |||
| ef5ddc90c2 | |||
| ae32743f12 | |||
| 7294cd5443 | |||
| cdae661b24 | |||
| 28af99914f | |||
| 63f42faca8 | |||
| 2624b97500 | |||
| 633b7287c0 | |||
| 3805ba8f76 | |||
| 82e88d4a28 | |||
| 5cd45d5ea1 | |||
| d930057000 | |||
| daf27ed971 | |||
| 8520044f3e | |||
| 4a801ab668 | |||
| 8f61a0aa90 | |||
| 1e2705c07f | |||
| 3cefbf80bf | |||
| 81342fb469 | |||
| 761e2e347e | |||
| 3911142443 | |||
| bc8517ade7 | |||
| a926346d89 | |||
| 0f4e6af49d | |||
| c20a6673af | |||
| 6b00837a4d | |||
| 807cce521c | |||
| aa49d9dcc2 | |||
| 60da901952 | |||
| 30cc92e863 | |||
| 91203233c9 | |||
| c52e8f3b38 | |||
| d5dbc5c428 | |||
| 161d2ce056 | |||
| 7cb8a151a4 | |||
| 6f108f7f28 | |||
| 7c607c8d06 | |||
| be0b3c0d8b | |||
| d3e9b9c020 | |||
| c16b300144 | |||
| 29a3543918 | |||
| 1e502c7e8c | |||
| 27a22d7609 | |||
| c515ba89a0 | |||
| 7e3063038e | |||
| 9bc560b17f | |||
| bdeafbaf75 | |||
| 8984055ea6 | |||
| 04dcbda9a3 | |||
| d7067f3562 | |||
| a39c9e6096 | |||
| 9690f78c30 | |||
| cf845ddbab | |||
| 3f72b9b97a | |||
| a347bd607f | |||
| 3966659f18 | |||
| 6d9b899ae4 | |||
| d802907dac | |||
| 2bea102dc1 | |||
| d9e5c5b0c8 | |||
| 7c1304690f | |||
| 3c366b37ae | |||
| 52a206ceca | |||
| 23f6ff9bc4 | |||
| 3e1d02cb3d | |||
| 8a72f21257 | |||
| 2755a7eebf | |||
| deb69ced9f | |||
| d5a9019220 | |||
| d0d2459e14 | |||
| a60a54147c | |||
| 6707c7893b | |||
| 1d0e2bd0c7 | |||
| b5ef12a2b1 | |||
| e3dd4ab126 | |||
| 751f924a4d | |||
| 7b58bbf12b | |||
| b2cfd3dc3d | |||
| 9f2237e11d | |||
| 6d99a7f399 | |||
| 5921bec5f4 | |||
| b4e171f5b4 | |||
| 90ad12300a | |||
| ffc6d12d87 | |||
| 1334276ec0 | |||
| cbf472fe0a | |||
| 4439599117 | |||
| 32754ef97a | |||
| 67bce82a62 | |||
| 5da3ae8df4 | |||
| eb3818eebb | |||
| e6b824ebff | |||
| c74dd95bd0 | |||
| 897a750f70 | |||
| 81d02cf5ae | |||
| a6dc6bb831 | |||
| 00a3c1acc0 | |||
| 8ff51d4468 | |||
| 2e041e8647 | |||
| 55095ba4e8 | |||
| 530f45a7bb | |||
| 99295275ec | |||
| c2e45eb36c | |||
| 35e32a32e8 | |||
| 8def3f6af6 | |||
| aa3698c4f4 | |||
| 6b525b4819 | |||
| 77ced19144 | |||
| 14cc5d989b | |||
| 47ad4bb171 | |||
| e3ad6b1f0a | |||
| 1e771d6150 | |||
| 3ac4649536 | |||
| 14be1ddf7f | |||
| 207ce9cbec | |||
| 3abcc59874 | |||
| 7fc8f1c5c9 | |||
| 016315afbc | |||
| b3c0d9179f | |||
| 993dc53e92 | |||
| 60345db1bd | |||
| a09349e457 | |||
| 71f538b115 | |||
| 70be560a7a | |||
| 269758ebdb | |||
| 98d613fbdf | |||
| f5e21a220b | |||
| f6e6df78a8 | |||
| 05ae80b0ca | |||
| 3bbaf77b20 | |||
| a9975a2b41 | |||
| 2b3f719484 | |||
| 4051fd5241 | |||
| 55ffb59b9c | |||
| 93cf9fcb6a | |||
| a5b867f1b1 | |||
| d8645e9a71 | |||
| b824ad16ae | |||
| 03b221a2a2 | |||
| 28db6d1fd4 | |||
| dc9cadc979 | |||
| f99b13efc2 | |||
| 0001d9c27b | |||
| d79d122f68 | |||
| 6e61f3cdb6 | |||
| 4993ec4cbf | |||
| 8e5ca57edd | |||
| 107a3acd3a | |||
| 1d887e81ef | |||
| 7f2860eb14 | |||
| 3364b9f05f | |||
| f9ed71fbf8 | |||
| e9ea0bc7cc | |||
| a87daf3d21 | |||
| 784b9c08ec | |||
| 139b5376fc | |||
| a052def314 | |||
| bcce1fa68a | |||
| 9a10cac8e9 | |||
| cf999462b7 | |||
| 9fc084b291 | |||
| cb46dd7840 | |||
| b684e183a0 | |||
| 84e4dde3d7 | |||
| 5f8d028d4f | |||
| e3abdd41c0 | |||
| bce6ffde08 | |||
| 35a3e32238 | |||
| 60b5024f63 | |||
| 4f14ff00d5 | |||
| 3d3130e880 | |||
| 83d542d06a | |||
| 6105d5027d | |||
| 7e34069021 | |||
| b75ca193a2 | |||
| afac1f62ca | |||
| de83805fd9 | |||
| 38b7bcb3d2 | |||
| c96b1c2faf | |||
| 87e723649e | |||
| 62bf9abed3 | |||
| e35a2000de | |||
| 4b8d80016b | |||
| d2f2c6df02 | |||
| 8ec1d4d034 | |||
| 9bcaaa3ac4 | |||
| 2e7ec9ea27 | |||
| bc9e97b9ac | |||
| 00f0ef56bc | |||
| 44ad05fb20 | |||
| 18cc1e4466 | |||
| 055f009357 | |||
| 2fe1712dae | |||
| 7c22b29493 | |||
| 0ef30d5e8d | |||
| 160ef84b47 | |||
| 1a7f464998 | |||
| ee326c709e | |||
| a0291e8a42 | |||
| ecada58edc | |||
| bb7f587025 | |||
| dab1a018e4 | |||
| 0c01a997ac | |||
| b8dc50267e | |||
| 1aaa04ab50 | |||
| 1860d31624 | |||
| 47557cf06f | |||
| 213edfb211 | |||
| 84e59645fd | |||
| 4df19f9ff8 | |||
| ba7721f53c | |||
| fd1e3d248a | |||
| bad81e3850 | |||
| a409149dae | |||
| a6b3b9ef5e | |||
| ec73c48e2e | |||
| c77675c492 | |||
| 5197af1aa4 | |||
| 65dd79e374 | |||
| 85f6472894 | |||
| 54fd877be1 | |||
| 7d948fd1a7 | |||
| 3373fa39a6 | |||
| 1de8123437 | |||
| 781c477078 | |||
| e2cdce5ac9 | |||
| ad331c8c2e | |||
| 43293dc80f | |||
| 73965a3a39 |
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devToolbar": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.angular
|
||||||
|
**/dist
|
||||||
|
.astro/
|
||||||
|
.forgejo/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
test/
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Deploy to beta (warm.strudel.cc)
|
name: Build and Deploy to warm (strudel.cc)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -37,4 +37,4 @@ jobs:
|
|||||||
apt update && apt install -y rsync
|
apt update && apt install -y rsync
|
||||||
mkdir ~/.ssh
|
mkdir ~/.ssh
|
||||||
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
||||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/warm.strudel.cc
|
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Deploy to live (strudel.cc)
|
name: Build and Deploy to cold (cold.strudel.cc)
|
||||||
|
|
||||||
on: [workflow_dispatch]
|
on: [workflow_dispatch]
|
||||||
|
|
||||||
@@ -34,4 +34,4 @@ jobs:
|
|||||||
apt update && apt install -y rsync
|
apt update && apt install -y rsync
|
||||||
mkdir ~/.ssh
|
mkdir ~/.ssh
|
||||||
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
||||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc
|
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/cold.strudel.cc
|
||||||
+11
-1
@@ -52,7 +52,17 @@ Strudel is a project handmade by humans, with thought and nuance.
|
|||||||
|
|
||||||
If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself.
|
If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself.
|
||||||
|
|
||||||
There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels.
|
There are #llm-chat and #llm-share channels on [our discord](https://discord.com/invite/HGEdXmRkzT). Please do not discuss or share LLM-related things outside of those channels.
|
||||||
|
|
||||||
|
## Creating and sharing a new project using strudel
|
||||||
|
|
||||||
|
Strudel is free/open source software, and we are also happy to see people making use of it within the following terms.
|
||||||
|
|
||||||
|
Please don't use 'strudel' in the name of your project, so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on [the discord](https://discord.com/invite/HGEdXmRkzT).)
|
||||||
|
|
||||||
|
Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details.
|
||||||
|
|
||||||
|
You are also encouraged to connect with the community and understand our aims and values.
|
||||||
|
|
||||||
## Report a Bug
|
## Report a Bug
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const editor = new StrudelMirror({
|
|||||||
import('@strudel/core'),
|
import('@strudel/core'),
|
||||||
import('@strudel/draw'),
|
import('@strudel/draw'),
|
||||||
import('@strudel/mini'),
|
import('@strudel/mini'),
|
||||||
|
import('@strudel/edo'),
|
||||||
import('@strudel/tonal'),
|
import('@strudel/tonal'),
|
||||||
import('@strudel/webaudio'),
|
import('@strudel/webaudio'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@strudel/codemirror": "workspace:*",
|
"@strudel/codemirror": "workspace:*",
|
||||||
"@strudel/core": "workspace:*",
|
"@strudel/core": "workspace:*",
|
||||||
"@strudel/draw": "workspace:*",
|
"@strudel/draw": "workspace:*",
|
||||||
|
"@strudel/edo": "workspace:*",
|
||||||
"@strudel/mini": "workspace:*",
|
"@strudel/mini": "workspace:*",
|
||||||
"@strudel/soundfonts": "workspace:*",
|
"@strudel/soundfonts": "workspace:*",
|
||||||
"@strudel/tonal": "workspace:*",
|
"@strudel/tonal": "workspace:*",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export * from './packages/core/index.mjs';
|
|||||||
export * from './packages/csound/index.mjs';
|
export * from './packages/csound/index.mjs';
|
||||||
export * from './packages/desktopbridge/index.mjs';
|
export * from './packages/desktopbridge/index.mjs';
|
||||||
export * from './packages/draw/index.mjs';
|
export * from './packages/draw/index.mjs';
|
||||||
|
export * from './packages/edo/index.mjs';
|
||||||
export * from './packages/embed/index.mjs';
|
export * from './packages/embed/index.mjs';
|
||||||
export * from './packages/hydra/index.mjs';
|
export * from './packages/hydra/index.mjs';
|
||||||
export * from './packages/midi/index.mjs';
|
export * from './packages/midi/index.mjs';
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"homepage": "https://strudel.cc",
|
"homepage": "https://strudel.cc",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@strudel/core": "workspace:*",
|
"@strudel/core": "workspace:*",
|
||||||
|
"@strudel/edo": "workspace:*",
|
||||||
"@strudel/mini": "workspace:*",
|
"@strudel/mini": "workspace:*",
|
||||||
"@strudel/tonal": "workspace:*",
|
"@strudel/tonal": "workspace:*",
|
||||||
"@strudel/transpiler": "workspace:*",
|
"@strudel/transpiler": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Block-based evaluation utilities
|
||||||
|
|
||||||
|
export function getBlockRegions(code) {
|
||||||
|
const chars = code.split('');
|
||||||
|
let i = 0,
|
||||||
|
blanks = [],
|
||||||
|
blockStart = 0,
|
||||||
|
regions = [];
|
||||||
|
while (i < chars.length) {
|
||||||
|
const isBlank = chars[i] === '\n';
|
||||||
|
if (isBlank) {
|
||||||
|
blanks.push(i);
|
||||||
|
} else if (chars[i].trim() !== '') {
|
||||||
|
if (blanks.length > 1) {
|
||||||
|
regions.push([blockStart, blanks[0]]);
|
||||||
|
blockStart = i;
|
||||||
|
}
|
||||||
|
blanks = [];
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
regions.push([blockStart, blanks.length ? blanks[0] : i]);
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlockAt(code, cursor) {
|
||||||
|
const regions = getBlockRegions(code);
|
||||||
|
for (const [start, end] of regions) {
|
||||||
|
if (cursor >= start && cursor <= end) {
|
||||||
|
return [start, end];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const evalBlock = (strudelMirror) => {
|
||||||
|
const { state } = strudelMirror.editor;
|
||||||
|
const code = state.doc.toString();
|
||||||
|
const cursor = state.selection.main.head;
|
||||||
|
const range = getBlockAt(code, cursor);
|
||||||
|
if (range) {
|
||||||
|
const [a, b] = range;
|
||||||
|
const block = code.slice(a, b);
|
||||||
|
if (block) {
|
||||||
|
// Flash the block being evaluated
|
||||||
|
strudelMirror.flash(200, { from: a, to: b });
|
||||||
|
strudelMirror.repl.evaluateBlock(block, true, { range });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -13,18 +13,18 @@ import {
|
|||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
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, Drawer } from '@strudel/draw';
|
import { cleanupDraw, cleanupDrawContext, 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 { sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
import { jumpToCharacter } from './labelJump.mjs';
|
||||||
|
import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
||||||
import { activateTheme, initTheme, theme } from './themes.mjs';
|
import { activateTheme, initTheme, theme } from './themes.mjs';
|
||||||
import { isTooltipEnabled } from './tooltip.mjs';
|
import { isTooltipEnabled } from './tooltip.mjs';
|
||||||
import { updateWidgets, widgetPlugin } from './widget.mjs';
|
import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs';
|
||||||
import { jumpToCharacter } from './labelJump.mjs';
|
|
||||||
|
|
||||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ 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,
|
||||||
@@ -75,7 +76,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
|
|||||||
});
|
});
|
||||||
|
|
||||||
// https://codemirror.net/docs/guide/
|
// https://codemirror.net/docs/guide/
|
||||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) {
|
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) {
|
||||||
const settings = codemirrorSettings.get();
|
const 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]))),
|
||||||
@@ -105,11 +106,26 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
|||||||
keymap.of([
|
keymap.of([
|
||||||
{
|
{
|
||||||
key: 'Ctrl-Enter',
|
key: 'Ctrl-Enter',
|
||||||
run: () => onEvaluate?.(),
|
run: () => {
|
||||||
|
// issue with referencing settings, this works more reliably
|
||||||
|
if (strudelMirror?.isBlockBasedEvalEnabled) {
|
||||||
|
evalBlock(strudelMirror);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return onEvaluate?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Alt-Enter',
|
key: 'Alt-Enter',
|
||||||
run: () => onEvaluate?.(),
|
run: () => {
|
||||||
|
if (strudelMirror?.isBlockBasedEvalEnabled) {
|
||||||
|
evalBlock(strudelMirror);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return onEvaluate?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Ctrl-.',
|
key: 'Ctrl-.',
|
||||||
@@ -171,6 +187,7 @@ 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));
|
||||||
@@ -201,20 +218,28 @@ export class StrudelMirror {
|
|||||||
cleanupDraw(true, id);
|
cleanupDraw(true, id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeEval: async () => {
|
beforeEval: async ({ blockBased } = {}) => {
|
||||||
cleanupDraw(true, id);
|
// Only clean up all drawings for full evaluation
|
||||||
|
// Block-based eval should preserve animations (like .scope()) from other blocks
|
||||||
|
if (!blockBased) {
|
||||||
|
cleanupDraw(true, id);
|
||||||
|
}
|
||||||
await this.prebaked;
|
await 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');
|
||||||
updateWidgets(this.editor, widgets);
|
// range-aware update for block-based evaluation
|
||||||
updateMiniLocations(this.editor, this.miniLocations);
|
const range = options.range && options.range.length >= 2 ? options.range : null;
|
||||||
|
|
||||||
|
updateSliderWidgets(this.editor, sliders, range);
|
||||||
|
updateWidgets(this.editor, widgets, range);
|
||||||
|
updateMiniLocations(this.editor, this.miniLocations, range);
|
||||||
replOptions?.afterEval?.(options);
|
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)
|
||||||
@@ -222,8 +247,14 @@ 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,
|
||||||
@@ -236,7 +267,9 @@ 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';
|
||||||
@@ -260,6 +293,9 @@ export class StrudelMirror {
|
|||||||
// Handle global evaluation requests (e.g., from Vim :w)
|
// Handle global evaluation requests (e.g., from Vim :w)
|
||||||
this.onEvaluateRequest = (e) => {
|
this.onEvaluateRequest = (e) => {
|
||||||
try {
|
try {
|
||||||
|
if (e.detail.view !== this.editor) {
|
||||||
|
return; // ignore events from other editors
|
||||||
|
}
|
||||||
// Evaluate current editor on repl-evaluate
|
// Evaluate current editor on repl-evaluate
|
||||||
logger('[repl] evaluate via event');
|
logger('[repl] evaluate via event');
|
||||||
this.evaluate();
|
this.evaluate();
|
||||||
@@ -274,6 +310,9 @@ export class StrudelMirror {
|
|||||||
// Toggle comments requested from Vim (gc)
|
// Toggle comments requested from Vim (gc)
|
||||||
this.onToggleComment = (e) => {
|
this.onToggleComment = (e) => {
|
||||||
try {
|
try {
|
||||||
|
if (e.detail.view !== this.editor) {
|
||||||
|
return; // ignore events from other editors
|
||||||
|
}
|
||||||
// Honor selections; toggleLineComment handles both selections and
|
// Honor selections; toggleLineComment handles both selections and
|
||||||
// single line
|
// single line
|
||||||
toggleLineComment(this.editor);
|
toggleLineComment(this.editor);
|
||||||
@@ -306,13 +345,17 @@ 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.scheduler.stop();
|
this.repl.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for global stop requests (e.g., from Vim :q)
|
// Listen for global stop requests (e.g., from Vim :q)
|
||||||
onStopRequest = (e) => {
|
onStopRequest = (e) => {
|
||||||
try {
|
try {
|
||||||
|
if (e.detail.view !== this.editor) {
|
||||||
|
return; // ignore events from other editors
|
||||||
|
}
|
||||||
this.stop();
|
this.stop();
|
||||||
e?.cancelable && e.preventDefault?.();
|
e?.cancelable && e.preventDefault?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -326,8 +369,8 @@ export class StrudelMirror {
|
|||||||
this.evaluate();
|
this.evaluate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flash(ms) {
|
flash(ms, range) {
|
||||||
flash(this.editor, ms);
|
flash(this.editor, ms, range);
|
||||||
}
|
}
|
||||||
highlight(haps, time) {
|
highlight(haps, time) {
|
||||||
highlightMiniLocations(this.editor, time, haps);
|
highlightMiniLocations(this.editor, time, haps);
|
||||||
@@ -359,6 +402,10 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -380,6 +427,10 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -393,14 +444,26 @@ export class StrudelMirror {
|
|||||||
this.setFontSize(value);
|
this.setFontSize(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCode(code) {
|
replaceCode(code, from, to) {
|
||||||
const changes = {
|
const changes = { from, to, insert: code };
|
||||||
from: 0,
|
|
||||||
to: this.editor.state.doc.length,
|
|
||||||
insert: code,
|
|
||||||
};
|
|
||||||
this.editor.dispatch({ changes });
|
this.editor.dispatch({ changes });
|
||||||
}
|
}
|
||||||
|
insertCode(code, position) {
|
||||||
|
this.replaceCode(code, position, position);
|
||||||
|
}
|
||||||
|
setCode(code) {
|
||||||
|
this.replaceCode(code, 0, this.editor.state.doc.length);
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ 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%)` },
|
||||||
});
|
});
|
||||||
flash = Decoration.set([mark.range(0, tr.newDoc.length)]);
|
const range = e.value.range || { from: 0, to: tr.newDoc.length };
|
||||||
|
flash = Decoration.set([mark.range(range.from, range.to)]);
|
||||||
} else {
|
} else {
|
||||||
flash = Decoration.set([]);
|
flash = Decoration.set([]);
|
||||||
}
|
}
|
||||||
@@ -29,8 +30,9 @@ export const flashField = StateField.define({
|
|||||||
provide: (f) => EditorView.decorations.from(f),
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const flash = (view, ms = 200) => {
|
export const flash = (view, ms = 200, range) => {
|
||||||
view.dispatch({ effects: setFlash.of(true) });
|
const flashData = range ? { range } : true;
|
||||||
|
view.dispatch({ effects: setFlash.of(flashData) });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
view.dispatch({ effects: setFlash.of(false) });
|
view.dispatch({ effects: setFlash.of(false) });
|
||||||
}, ms);
|
}, ms);
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ 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 updateMiniLocations = (view, locations) => {
|
export const displayMiniLocations = StateEffect.define();
|
||||||
view.dispatch({ effects: setMiniLocations.of(locations) });
|
export const updateMiniLocations = (view, locations, range = null) => {
|
||||||
|
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 }) });
|
||||||
@@ -21,23 +22,54 @@ const miniLocations = StateField.define({
|
|||||||
|
|
||||||
for (let e of tr.effects) {
|
for (let e of tr.effects) {
|
||||||
if (e.is(setMiniLocations)) {
|
if (e.is(setMiniLocations)) {
|
||||||
// this is called on eval, with the mini locations obtained from the transpiler
|
//block-based eval case
|
||||||
// codemirror will automatically remap the marks when the document is edited
|
if (e.value.range) {
|
||||||
// create a mark for each mini location, adding the range to the spec to find it later
|
const stateMiniLocations = getMiniLocationsFromDecorations(locations);
|
||||||
const marks = e.value
|
|
||||||
.filter(([from]) => from < tr.newDoc.length)
|
|
||||||
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
|
|
||||||
.map(
|
|
||||||
(range) =>
|
|
||||||
Decoration.mark({
|
|
||||||
id: range.join(':'),
|
|
||||||
// this green is only to verify that the decoration moves when the document is edited
|
|
||||||
// it will be removed later, so the mark is not visible by default
|
|
||||||
attributes: { style: `background-color: #00CA2880` },
|
|
||||||
}).range(...range), // -> Decoration
|
|
||||||
);
|
|
||||||
|
|
||||||
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
|
const normalized = e.value.locations
|
||||||
|
.filter(([from]) => from < tr.newDoc.length)
|
||||||
|
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]);
|
||||||
|
|
||||||
|
const newIds = new Set(normalized.map((r) => r.join(':')));
|
||||||
|
|
||||||
|
const marks = normalized.map((range) => {
|
||||||
|
const id = range.join(':');
|
||||||
|
return Decoration.mark({
|
||||||
|
id,
|
||||||
|
// this green is only to verify that the decoration moves when the document is edited
|
||||||
|
// it will be removed later, so the mark is not visible by default
|
||||||
|
attributes: { style: `background-color: #00CA2880` },
|
||||||
|
}).range(...range); // -> Decoration
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousMarks = stateMiniLocations
|
||||||
|
.filter(({ id }) => !newIds.has(id))
|
||||||
|
.map(({ from, to, id }) =>
|
||||||
|
Decoration.mark({
|
||||||
|
id,
|
||||||
|
attributes: { style: `background-color: #00CA2880` },
|
||||||
|
}).range(from, to),
|
||||||
|
);
|
||||||
|
|
||||||
|
locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet<Decoration>
|
||||||
|
} else {
|
||||||
|
// this is called on eval, with the mini locations obtained from the transpiler
|
||||||
|
// codemirror will automatically remap the marks when the document is edited
|
||||||
|
// create a mark for each mini location, adding the range to the spec to find it later
|
||||||
|
const marks = e.value.locations
|
||||||
|
.filter(([from]) => from < tr.newDoc.length)
|
||||||
|
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
|
||||||
|
.map(
|
||||||
|
(range) =>
|
||||||
|
Decoration.mark({
|
||||||
|
id: range.join(':'),
|
||||||
|
// this green is only to verify that the decoration moves when the document is edited
|
||||||
|
// it will be removed later, so the mark is not visible by default
|
||||||
|
attributes: { style: `background-color: #00CA2880` },
|
||||||
|
}).range(...range), // -> Decoration
|
||||||
|
);
|
||||||
|
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,12 +107,83 @@ const visibleMiniLocations = StateField.define({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// // Derive the set of decorations from the miniLocations and visibleLocations
|
const displayMiniLocationsState = StateField.define({
|
||||||
const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => {
|
create() {
|
||||||
const iterator = state.field(miniLocations).iter();
|
return true; // default to showing miniLocations
|
||||||
const { haps } = state.field(visibleMiniLocations);
|
},
|
||||||
const builder = new RangeSetBuilder();
|
update(display, tr) {
|
||||||
|
for (let e of tr.effects) {
|
||||||
|
if (e.is(displayMiniLocations)) {
|
||||||
|
display = e.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return display;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// // Derive the set of decorations from the miniLocations and visibleLocations
|
||||||
|
const miniLocationHighlights = EditorView.decorations.compute(
|
||||||
|
[miniLocations, visibleMiniLocations, displayMiniLocationsState],
|
||||||
|
(state) => {
|
||||||
|
// Check if miniLocations display is disabled
|
||||||
|
const shouldDisplay = state.field(displayMiniLocationsState);
|
||||||
|
if (!shouldDisplay) {
|
||||||
|
return Decoration.none; // Return empty decorations if display is disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterator = state.field(miniLocations).iter();
|
||||||
|
const { haps } = state.field(visibleMiniLocations);
|
||||||
|
const builder = new RangeSetBuilder();
|
||||||
|
|
||||||
|
while (iterator.value) {
|
||||||
|
const {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
value: {
|
||||||
|
spec: { id },
|
||||||
|
},
|
||||||
|
} = iterator;
|
||||||
|
|
||||||
|
if (haps.has(id)) {
|
||||||
|
const hap = haps.get(id);
|
||||||
|
const color = hap.value?.color ?? 'var(--foreground)';
|
||||||
|
const style = hap.value?.markcss || `outline: solid 2px ${color}`;
|
||||||
|
// Get explicit channels for color values
|
||||||
|
/*
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.style.color = color;
|
||||||
|
document.body.appendChild(swatch);
|
||||||
|
let channels = getComputedStyle(swatch)
|
||||||
|
.color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
|
||||||
|
.slice(1)
|
||||||
|
.map((c) => parseFloat(c || 1));
|
||||||
|
document.body.removeChild(swatch);
|
||||||
|
|
||||||
|
// Get percentage of event
|
||||||
|
const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
|
||||||
|
channels[3] *= percent;
|
||||||
|
*/
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
Decoration.mark({
|
||||||
|
// attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
|
||||||
|
attributes: { style },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
iterator.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMiniLocationsFromDecorations = (decorations) => {
|
||||||
|
const iterator = decorations.iter();
|
||||||
|
const miniLocationsArray = [];
|
||||||
while (iterator.value) {
|
while (iterator.value) {
|
||||||
const {
|
const {
|
||||||
from,
|
from,
|
||||||
@@ -89,50 +192,48 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi
|
|||||||
spec: { id },
|
spec: { id },
|
||||||
},
|
},
|
||||||
} = iterator;
|
} = iterator;
|
||||||
|
miniLocationsArray.push({
|
||||||
if (haps.has(id)) {
|
from,
|
||||||
const hap = haps.get(id);
|
to,
|
||||||
const color = hap.value?.color ?? 'var(--foreground)';
|
id,
|
||||||
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;
|
||||||
|
};
|
||||||
|
|
||||||
return builder.finish();
|
export const getMiniLocations = (state) => {
|
||||||
});
|
const decorations = state.field(miniLocations);
|
||||||
|
return getMiniLocationsFromDecorations(decorations);
|
||||||
|
};
|
||||||
|
|
||||||
export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights];
|
export const getActiveMiniLocations = (state) => {
|
||||||
|
const miniLocations = getMiniLocations(state);
|
||||||
|
const { haps } = state.field(visibleMiniLocations);
|
||||||
|
|
||||||
|
const activeMiniLocations = miniLocations.filter((location) => haps.has(location.id));
|
||||||
|
return activeMiniLocations;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const highlightExtension = [
|
||||||
|
miniLocations,
|
||||||
|
visibleMiniLocations,
|
||||||
|
displayMiniLocationsState,
|
||||||
|
miniLocationHighlights,
|
||||||
|
];
|
||||||
|
|
||||||
export const isPatternHighlightingEnabled = (on, config) => {
|
export const isPatternHighlightingEnabled = (on, config) => {
|
||||||
on &&
|
// NOTE:
|
||||||
config &&
|
// Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed.
|
||||||
setTimeout(() => {
|
// This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly.
|
||||||
updateMiniLocations(config.editor, config.miniLocations);
|
// The setTimeout was also removed because it conflicted with the range-specific updates required by
|
||||||
}, 100);
|
// block based evaluation.
|
||||||
return on ? Prec.highest(highlightExtension) : [];
|
// Not sure if this is the best approach, but for block based eval I can't think of a better way to do it.
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
// Toggle the display state for miniLocations
|
||||||
|
config.editor.dispatch({ effects: displayMiniLocations.of(on) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Prec.highest(highlightExtension);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from './slider.mjs';
|
|||||||
export * from './themes.mjs';
|
export * from './themes.mjs';
|
||||||
export * from './widget.mjs';
|
export * from './widget.mjs';
|
||||||
export { Vim } from './keybindings.mjs';
|
export { Vim } from './keybindings.mjs';
|
||||||
|
export * from './basicSetup.mjs';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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(
|
||||||
@@ -20,6 +21,70 @@ 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
|
||||||
@@ -31,7 +96,7 @@ try {
|
|||||||
// internal actions and works with current selections/visual mode.
|
// internal actions and works with current selections/visual mode.
|
||||||
try {
|
try {
|
||||||
Vim.defineAction('strudelToggleComment', (cm) => {
|
Vim.defineAction('strudelToggleComment', (cm) => {
|
||||||
const view = cm?.view || cm;
|
const view = cm.cm6;
|
||||||
try {
|
try {
|
||||||
const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true });
|
const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true });
|
||||||
document.dispatchEvent(ev);
|
document.dispatchEvent(ev);
|
||||||
@@ -47,74 +112,23 @@ try {
|
|||||||
|
|
||||||
// :q to pause/stop
|
// :q to pause/stop
|
||||||
Vim.defineEx('quit', 'q', (cm) => {
|
Vim.defineEx('quit', 'q', (cm) => {
|
||||||
try {
|
const view = cm.cm6;
|
||||||
const view = cm?.view || cm;
|
logger('[vim] :q — stopping repl');
|
||||||
// First try dispatching our custom stop event, then fallback to Alt+.
|
replStop(view);
|
||||||
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
|
||||||
Vim.defineEx('write', 'w', (cm) => {
|
Vim.defineEx('write', 'w', (cm) => {
|
||||||
const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself
|
const view = cm.cm6;
|
||||||
try {
|
try {
|
||||||
view?.focus?.();
|
view.focus?.();
|
||||||
// Let the app know this came from Vim :w
|
// Let the app know this came from Vim :w
|
||||||
try {
|
try {
|
||||||
logger('[vim] :w — evaluating code');
|
logger('[vim] :w — evaluating code');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error logging Vim :w evaluation', e);
|
console.error('Error logging Vim :w evaluation', e);
|
||||||
}
|
}
|
||||||
// Dispatch a dedicated evaluate event first
|
replEval(view);
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -124,11 +138,49 @@ 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';
|
||||||
|
|||||||
@@ -32,18 +32,19 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.18.4",
|
"@codemirror/autocomplete": "catalog:",
|
||||||
"@codemirror/commands": "^6.8.0",
|
"@codemirror/commands": "catalog:",
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@codemirror/lang-javascript": "catalog:",
|
||||||
"@codemirror/language": "^6.10.8",
|
"@codemirror/language": "catalog:",
|
||||||
"@codemirror/search": "^6.5.8",
|
"@codemirror/search": "catalog:",
|
||||||
"@codemirror/state": "^6.5.1",
|
"@codemirror/state": "catalog:",
|
||||||
"@codemirror/view": "^6.36.2",
|
"@codemirror/view": "catalog:",
|
||||||
"@lezer/highlight": "^1.2.1",
|
"@lezer/highlight": "^1.2.1",
|
||||||
"@nanostores/persistent": "^0.10.2",
|
"@nanostores/persistent": "^0.10.2",
|
||||||
"@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:*",
|
||||||
|
|||||||
+106
-17
@@ -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) {
|
constructor(value, min, max, from, to, step, view, id) {
|
||||||
super();
|
super();
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.min = min;
|
this.min = min;
|
||||||
@@ -16,10 +16,21 @@ 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() {
|
eq(other) {
|
||||||
return false;
|
if (!(other instanceof SliderWidget)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.id === other.id &&
|
||||||
|
this.from === other.from &&
|
||||||
|
this.to === other.to &&
|
||||||
|
this.value === other.value &&
|
||||||
|
this.min === other.min &&
|
||||||
|
this.max === other.max
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOM() {
|
toDOM() {
|
||||||
@@ -38,6 +49,7 @@ 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) => {
|
||||||
@@ -49,7 +61,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 = getSliderID(slider.originalFrom); // matches id generated in transpiler
|
const id = slider.id; // Use range-based ID
|
||||||
window.postMessage({ type: 'cm-slider', value: Number(next), id });
|
window.postMessage({ type: 'cm-slider', value: Number(next), id });
|
||||||
});
|
});
|
||||||
return wrap;
|
return wrap;
|
||||||
@@ -62,19 +74,60 @@ export class SliderWidget extends WidgetType {
|
|||||||
|
|
||||||
export const setSliderWidgets = StateEffect.define();
|
export const setSliderWidgets = StateEffect.define();
|
||||||
|
|
||||||
export const updateSliderWidgets = (view, widgets) => {
|
export const setSliderWidgetsInRange = StateEffect.define();
|
||||||
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 widgetConfigs
|
return (
|
||||||
.filter((w) => w.type === 'slider')
|
widgetConfigs
|
||||||
.map(({ from, to, value, min, max, step }) => {
|
.filter((w) => w.type === 'slider')
|
||||||
return Decoration.widget({
|
// Deduplicate sliders that might appear multiple times (e.g., during paste operations)
|
||||||
widget: new SliderWidget(value, min, max, from, to, step, view),
|
.filter((slider, index, self) => index === self.findIndex((s) => s.from === slider.from && s.to === slider.to))
|
||||||
side: 0,
|
.sort((a, b) => a.from - b.from)
|
||||||
}).range(from /* , to */);
|
.map(({ from, to, value, min, max, step, id }) => {
|
||||||
});
|
return Decoration.widget({
|
||||||
|
widget: new SliderWidget(value, min, max, from, to, step, view, id),
|
||||||
|
side: 0,
|
||||||
|
}).range(from /* , to */);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSliderWidgets(view) {
|
||||||
|
if (!view || !view.state) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliderPluginInstance = view.plugin(sliderPlugin);
|
||||||
|
if (!sliderPluginInstance || !sliderPluginInstance.decorations) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliderWidgets = [];
|
||||||
|
|
||||||
|
sliderPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||||
|
if (decoration.widget instanceof SliderWidget) {
|
||||||
|
sliderWidgets.push({
|
||||||
|
type: 'slider',
|
||||||
|
from: decoration.widget.from,
|
||||||
|
to: decoration.widget.to,
|
||||||
|
value: decoration.widget.value,
|
||||||
|
min: decoration.widget.min,
|
||||||
|
max: decoration.widget.max,
|
||||||
|
step: decoration.widget.step,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return sliderWidgets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sliderPlugin = ViewPlugin.fromClass(
|
export const sliderPlugin = ViewPlugin.fromClass(
|
||||||
@@ -101,7 +154,42 @@ export const sliderPlugin = ViewPlugin.fromClass(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let e of tr.effects) {
|
for (let e of tr.effects) {
|
||||||
if (e.is(setSliderWidgets)) {
|
if (e.is(setSliderWidgetsInRange)) {
|
||||||
|
// Block-aware slider update logic
|
||||||
|
const { widgets, range } = e.value;
|
||||||
|
const [rangeStart, rangeEnd] = range;
|
||||||
|
|
||||||
|
// Get existing slider widgets that should be preserved
|
||||||
|
const existingSliders = [];
|
||||||
|
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
|
||||||
|
if (decoration.widget instanceof SliderWidget) {
|
||||||
|
// Preserve sliders outside the evaluation range
|
||||||
|
// Use strict > for rangeEnd because when code is deleted, slider positions
|
||||||
|
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
|
||||||
|
if (from < rangeStart || from > rangeEnd) {
|
||||||
|
existingSliders.push({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
value: decoration.widget.value,
|
||||||
|
min: decoration.widget.min,
|
||||||
|
max: decoration.widget.max,
|
||||||
|
step: decoration.widget.step,
|
||||||
|
id: decoration.widget.id || `${from}:${to}`,
|
||||||
|
type: 'slider',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge preserved sliders with new widgets
|
||||||
|
const mergedWidgets = [...existingSliders, ...widgets]
|
||||||
|
.filter(
|
||||||
|
(slider, index, self) => index === self.findIndex((s) => s.type === 'slider' && s.id === slider.id),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
|
this.decorations = Decoration.set(getSliders(mergedWidgets, update.view));
|
||||||
|
} else if (e.is(setSliderWidgets)) {
|
||||||
this.decorations = Decoration.set(getSliders(e.value, update.view));
|
this.decorations = Decoration.set(getSliders(e.value, update.view));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +220,7 @@ export let sliderWithID = (id, value, min, max) => {
|
|||||||
sliderValues[id] = value; // sync state at eval time (code -> state)
|
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) => {
|
||||||
@@ -140,7 +229,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.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+1
@@ -198,6 +198,7 @@ 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;`)
|
||||||
|
|||||||
+3
-1
@@ -17,13 +17,15 @@ 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: grey,
|
gutterBackground: pinkAccent,
|
||||||
gutterForeground: lightGrey,
|
gutterForeground: lightGrey,
|
||||||
gutterBorder: 'transparent',
|
gutterBorder: 'transparent',
|
||||||
lineHighlight: pinkAccent,
|
lineHighlight: pinkAccent,
|
||||||
|
light: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default createTheme({
|
export default createTheme({
|
||||||
|
|||||||
+1
@@ -31,6 +31,7 @@ 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],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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',
|
||||||
@@ -19,6 +20,7 @@ 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
@@ -12,6 +12,7 @@ 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
@@ -11,6 +11,7 @@ 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',
|
||||||
@@ -25,6 +26,7 @@ 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',
|
||||||
|
|||||||
Vendored
+2
@@ -5,6 +5,7 @@ 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',
|
||||||
@@ -18,6 +19,7 @@ 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
@@ -6,6 +6,7 @@ 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',
|
||||||
@@ -20,6 +21,7 @@ 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',
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@ 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', // whats that?
|
foreground: 'white',
|
||||||
|
muted: '#ffffff50',
|
||||||
caret: 'white',
|
caret: 'white',
|
||||||
selection: '#ffffff20',
|
selection: '#ffffff20',
|
||||||
selectionMatch: '#036dd626',
|
selectionMatch: '#036dd626',
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,8 @@ import { createTheme } from './theme-helper.mjs';
|
|||||||
export const settings = {
|
export const settings = {
|
||||||
background: '#051DB5',
|
background: '#051DB5',
|
||||||
lineBackground: '#051DB550',
|
lineBackground: '#051DB550',
|
||||||
foreground: 'white', // whats that?
|
foreground: 'white',
|
||||||
|
muted: '#ffffff50',
|
||||||
caret: 'white',
|
caret: 'white',
|
||||||
selection: 'rgba(128, 203, 196, 0.5)',
|
selection: 'rgba(128, 203, 196, 0.5)',
|
||||||
selectionMatch: '#036dd626',
|
selectionMatch: '#036dd626',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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
@@ -9,6 +9,7 @@ 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)',
|
||||||
@@ -23,6 +24,7 @@ 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
@@ -11,6 +11,7 @@ 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)',
|
||||||
@@ -27,6 +28,7 @@ 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
@@ -10,6 +10,7 @@ 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',
|
||||||
@@ -22,6 +23,7 @@ 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
@@ -6,6 +6,7 @@ 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',
|
||||||
@@ -20,6 +21,7 @@ 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
@@ -23,6 +23,7 @@ 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
@@ -5,6 +5,7 @@ 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',
|
||||||
@@ -16,6 +17,7 @@ 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
@@ -6,6 +6,7 @@ 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',
|
||||||
@@ -17,6 +18,7 @@ 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
@@ -13,6 +13,7 @@ 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
@@ -10,6 +10,7 @@ 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',
|
||||||
@@ -23,6 +24,7 @@ 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
@@ -11,6 +11,7 @@ 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',
|
||||||
@@ -25,6 +26,7 @@ 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
@@ -5,6 +5,7 @@ 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',
|
||||||
@@ -19,6 +20,7 @@ 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',
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,8 @@ export const settings = {
|
|||||||
light: true,
|
light: true,
|
||||||
background: '#FAFAFA',
|
background: '#FAFAFA',
|
||||||
lineBackground: '#FAFAFA99',
|
lineBackground: '#FAFAFA99',
|
||||||
foreground: '#90A4AE',
|
foreground: '#6182B8',
|
||||||
|
muted: '#6182B850',
|
||||||
caret: '#272727',
|
caret: '#272727',
|
||||||
selection: '#80CBC440',
|
selection: '#80CBC440',
|
||||||
selectionMatch: '#FAFAFA',
|
selectionMatch: '#FAFAFA',
|
||||||
@@ -19,6 +20,7 @@ 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
@@ -5,6 +5,7 @@ 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',
|
||||||
@@ -18,6 +19,7 @@ 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
@@ -6,6 +6,7 @@ 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',
|
||||||
@@ -19,6 +20,7 @@ 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',
|
||||||
|
|||||||
Vendored
+2
@@ -5,6 +5,7 @@ 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',
|
||||||
@@ -20,6 +21,7 @@ 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
@@ -13,6 +13,7 @@ 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],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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',
|
||||||
@@ -45,6 +46,7 @@ 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,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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',
|
||||||
@@ -19,6 +20,7 @@ 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
@@ -13,6 +13,7 @@ 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],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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
@@ -5,6 +5,7 @@ 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',
|
||||||
@@ -18,6 +19,7 @@ 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',
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@ let colorD = '#f8fc55';
|
|||||||
|
|
||||||
export const settings = {
|
export const settings = {
|
||||||
background: '#000000',
|
background: '#000000',
|
||||||
foreground: colorA, // whats that?
|
foreground: colorA,
|
||||||
|
muted: colorA + '50',
|
||||||
caret: colorC,
|
caret: colorC,
|
||||||
selection: colorD,
|
selection: colorD,
|
||||||
selectionMatch: colorA,
|
selectionMatch: colorA,
|
||||||
|
|||||||
+4
-2
@@ -4,7 +4,8 @@ import { createTheme } from './theme-helper.mjs';
|
|||||||
export const settings = {
|
export const settings = {
|
||||||
background: '#24283b',
|
background: '#24283b',
|
||||||
lineBackground: '#24283b99',
|
lineBackground: '#24283b99',
|
||||||
foreground: '#7982a9',
|
foreground: '#f9f2f9',
|
||||||
|
muted: '#f9f2f950',
|
||||||
caret: '#c0caf5',
|
caret: '#c0caf5',
|
||||||
selection: '#6f7bb630',
|
selection: '#6f7bb630',
|
||||||
selectionMatch: '#1f2335',
|
selectionMatch: '#1f2335',
|
||||||
@@ -18,7 +19,8 @@ export default createTheme({
|
|||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
settings: {
|
settings: {
|
||||||
background: '#24283b',
|
background: '#24283b',
|
||||||
foreground: '#7982a9',
|
foreground: '#f9f2f9',
|
||||||
|
muted: '#f9f2f950',
|
||||||
caret: '#c0caf5',
|
caret: '#c0caf5',
|
||||||
selection: '#6f7bb630',
|
selection: '#6f7bb630',
|
||||||
selectionMatch: '#343b5f',
|
selectionMatch: '#343b5f',
|
||||||
|
|||||||
+4
-2
@@ -4,7 +4,8 @@ import { createTheme } from './theme-helper.mjs';
|
|||||||
export const settings = {
|
export const settings = {
|
||||||
background: '#1a1b26',
|
background: '#1a1b26',
|
||||||
lineBackground: '#1a1b2699',
|
lineBackground: '#1a1b2699',
|
||||||
foreground: '#787c99',
|
foreground: '#f8fcf9',
|
||||||
|
muted: '#f8fcf950',
|
||||||
caret: '#c0caf5',
|
caret: '#c0caf5',
|
||||||
selection: '#515c7e40',
|
selection: '#515c7e40',
|
||||||
selectionMatch: '#16161e',
|
selectionMatch: '#16161e',
|
||||||
@@ -18,7 +19,8 @@ export default createTheme({
|
|||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
settings: {
|
settings: {
|
||||||
background: '#1a1b26',
|
background: '#1a1b26',
|
||||||
foreground: '#787c99',
|
foreground: '#f8fcf9',
|
||||||
|
muted: '#f8fcf950',
|
||||||
caret: '#c0caf5',
|
caret: '#c0caf5',
|
||||||
selection: '#515c7e40',
|
selection: '#515c7e40',
|
||||||
selectionMatch: '#16161e',
|
selectionMatch: '#16161e',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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',
|
||||||
@@ -20,6 +21,7 @@ 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
@@ -5,6 +5,7 @@ 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',
|
||||||
@@ -19,6 +20,7 @@ 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
@@ -5,6 +5,7 @@ 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',
|
||||||
@@ -20,6 +21,7 @@ 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',
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@ 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', // whats that?
|
foreground: 'black',
|
||||||
|
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
@@ -6,6 +6,7 @@ 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',
|
||||||
@@ -18,6 +19,7 @@ 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',
|
||||||
|
|||||||
+194
-46
@@ -1,55 +1,111 @@
|
|||||||
import { StateEffect, StateField } from '@codemirror/state';
|
import { StateEffect, StateField } from '@codemirror/state';
|
||||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
|
||||||
import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
|
import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
|
||||||
import { Pattern } from '@strudel/core';
|
import { Pattern } from '@strudel/core';
|
||||||
|
|
||||||
export const addWidget = StateEffect.define({
|
export const setWidgets = StateEffect.define();
|
||||||
map: ({ from, to }, change) => {
|
|
||||||
return { from: change.mapPos(from), to: change.mapPos(to) };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateWidgets = (view, widgets) => {
|
export const setWidgetsInRange = StateEffect.define();
|
||||||
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) {
|
function getWidgets(widgetConfigs, view) {
|
||||||
return (
|
const filtered = widgetConfigs
|
||||||
widgetConfigs
|
// Filter to widget configs only (exclude sliders)
|
||||||
// codemirror throws an error if we don't sort
|
.filter((w) => w && w.type && w.type !== 'slider')
|
||||||
.sort((a, b) => a.to - b.to)
|
// Deduplicate widgets by ID, matching slider behavior for stable widget identity
|
||||||
.map((widgetConfig) => {
|
.filter((widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id));
|
||||||
|
|
||||||
|
// Filter out widgets whose range is encompassed by another widget
|
||||||
|
// const nonEncompassed = filterEncompassedWidgets(filtered);
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
.sort((a, b) => (a.to || 0) - (b.to || 0))
|
||||||
|
.map((widgetConfig) => {
|
||||||
|
try {
|
||||||
return Decoration.widget({
|
return Decoration.widget({
|
||||||
widget: new BlockWidget(widgetConfig),
|
widget: new BlockWidget(widgetConfig, view),
|
||||||
side: 0,
|
side: 0,
|
||||||
block: true,
|
}).range(widgetConfig.to || widgetConfig.from || 0);
|
||||||
}).range(widgetConfig.to);
|
} catch (error) {
|
||||||
})
|
console.error('error creating widget', error);
|
||||||
);
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean); // Remove any null results from failed creations
|
||||||
}
|
}
|
||||||
|
|
||||||
const widgetField = StateField.define(
|
export const widgetPlugin = ViewPlugin.fromClass(
|
||||||
/* <DecorationSet> */ {
|
class {
|
||||||
create() {
|
decorations; //: DecorationSet
|
||||||
return Decoration.none;
|
|
||||||
},
|
constructor(view /* : EditorView */) {
|
||||||
update(widgets, tr) {
|
this.decorations = Decoration.set([]);
|
||||||
widgets = widgets.map(tr.changes);
|
}
|
||||||
for (let e of tr.effects) {
|
|
||||||
if (e.is(addWidget)) {
|
update(update /* : ViewUpdate */) {
|
||||||
try {
|
update.transactions.forEach((tr) => {
|
||||||
widgets = widgets.update({
|
if (tr.docChanged) {
|
||||||
filter: () => false,
|
this.decorations = this.decorations.map(tr.changes);
|
||||||
add: getWidgets(e.value),
|
const iterator = this.decorations.iter();
|
||||||
});
|
// Apply changes to iterator.from and iterator.to if docChanged
|
||||||
} catch (error) {
|
while (iterator.value) {
|
||||||
console.log('err', error);
|
// 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) {
|
||||||
return widgets;
|
if (e.is(setWidgetsInRange)) {
|
||||||
},
|
// Block-aware widget update logic
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
const { widgets, range } = e.value;
|
||||||
|
const [rangeStart, rangeEnd] = range;
|
||||||
|
|
||||||
|
// Get existing widget widgets that should be preserved
|
||||||
|
const existingWidgets = [];
|
||||||
|
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
|
||||||
|
if (decoration.widget instanceof BlockWidget) {
|
||||||
|
// Preserve widgets outside the evaluation range
|
||||||
|
// Use strict > for rangeEnd because when code is deleted, widget positions
|
||||||
|
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
|
||||||
|
if (from < rangeStart || from > rangeEnd) {
|
||||||
|
existingWidgets.push({
|
||||||
|
from: decoration.widget.from,
|
||||||
|
to: decoration.widget.to,
|
||||||
|
type: decoration.widget.type,
|
||||||
|
index: decoration.widget.index,
|
||||||
|
id: decoration.widget.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge preserved widgets with new widgets, deduplicating by ID
|
||||||
|
const mergedWidgets = [...existingWidgets, ...widgets].filter(
|
||||||
|
(widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.decorations = Decoration.set(getWidgets(mergedWidgets, update.view));
|
||||||
|
} else if (e.is(setWidgets)) {
|
||||||
|
this.decorations = Decoration.set(getWidgets(e.value, update.view));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,24 +116,116 @@ export function setWidget(id, el) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BlockWidget extends WidgetType {
|
export class BlockWidget extends WidgetType {
|
||||||
constructor(widgetConfig) {
|
constructor(widgetConfig, view) {
|
||||||
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() {
|
|
||||||
return true;
|
eq(other) {
|
||||||
|
if (!(other instanceof BlockWidget)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.id === other.id &&
|
||||||
|
this.from === other.from &&
|
||||||
|
this.to === other.to &&
|
||||||
|
this.type === other.type &&
|
||||||
|
this.index === other.index
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOM() {
|
toDOM() {
|
||||||
const id = getWidgetID(this.widgetConfig);
|
let wrap = document.createElement('span');
|
||||||
const el = widgetElements[id];
|
wrap.setAttribute('aria-hidden', 'true');
|
||||||
return el;
|
wrap.className = 'cm-widget-container';
|
||||||
|
|
||||||
|
let el = widgetElements[this.id];
|
||||||
|
if (el) {
|
||||||
|
// Ensure the element has the correct ID
|
||||||
|
el.id = this.id;
|
||||||
|
wrap.appendChild(el);
|
||||||
|
} else {
|
||||||
|
// Create a placeholder element if the widget element doesn't exist
|
||||||
|
// This prevents CodeMirror errors when widget is missing
|
||||||
|
const placeholder = document.createElement('span');
|
||||||
|
placeholder.setAttribute('aria-hidden', 'true');
|
||||||
|
placeholder.className = 'cm-widget-placeholder';
|
||||||
|
placeholder.style.cssText = 'display: none;'; // Hide placeholder
|
||||||
|
placeholder.id = this.id;
|
||||||
|
wrap.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
ignoreEvent(e) {
|
ignoreEvent(e) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const widgetPlugin = [widgetField];
|
export function getActiveWidgets(view) {
|
||||||
|
if (!view || !view.state) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetPluginInstance = view.plugin(widgetPlugin);
|
||||||
|
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgets = [];
|
||||||
|
|
||||||
|
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||||
|
if (decoration.widget instanceof BlockWidget) {
|
||||||
|
widgets.push({
|
||||||
|
type: decoration.widget.type,
|
||||||
|
from: decoration.widget.from,
|
||||||
|
to: decoration.widget.to,
|
||||||
|
index: decoration.widget.index,
|
||||||
|
id: decoration.widget.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllWidgetIds(view) {
|
||||||
|
if (!view || !view.state) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetPluginInstance = view.plugin(widgetPlugin);
|
||||||
|
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetIds = [];
|
||||||
|
|
||||||
|
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||||
|
if (decoration.widget instanceof BlockWidget) {
|
||||||
|
widgetIds.push(decoration.widget.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return widgetIds;
|
||||||
|
}
|
||||||
|
|
||||||
// widget implementer API to create a new widget type
|
// widget implementer API to create a new widget type
|
||||||
export function registerWidget(type, fn) {
|
export function registerWidget(type, fn) {
|
||||||
|
|||||||
+272
-242
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,34 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const strudelScope = {};
|
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);
|
||||||
|
|||||||
+317
-148
@@ -818,7 +818,6 @@ export class Pattern {
|
|||||||
* @name layer
|
* @name layer
|
||||||
* @tags combiners
|
* @tags combiners
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @synonyms apply
|
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
* "<0 2 4 6 ~ 4 ~ 2 0!3 ~!5>*8"
|
* "<0 2 4 6 ~ 4 ~ 2 0!3 ~!5>*8"
|
||||||
@@ -1055,91 +1054,135 @@ function _composeOp(a, b, func) {
|
|||||||
return func(a, b);
|
return func(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make composers
|
// pattern composers
|
||||||
(function () {
|
const COMPOSERS = {
|
||||||
// pattern composers
|
/**
|
||||||
const composers = {
|
* When called on a pattern `a`, with a input pattern `b` (`a.set(b)`),
|
||||||
set: [(a, b) => b],
|
* combines `a` and `b` such that anything defined in `b`
|
||||||
keep: [(a) => a],
|
* and anything defined in `a` that is *not* defined in `b`
|
||||||
keepif: [(a, b) => (b ? a : undefined)],
|
* will be in the resulting pattern.
|
||||||
|
*
|
||||||
|
* The structure is maintained from `a`,
|
||||||
|
* because the default pattern alignment is `in`,
|
||||||
|
* see the section on `Pattern Alignment`
|
||||||
|
* in the technical manual in the docs
|
||||||
|
*
|
||||||
|
* This is the inverse of `keep`
|
||||||
|
*
|
||||||
|
* See examples below
|
||||||
|
* @name set
|
||||||
|
* @param {Pattern} pat
|
||||||
|
* @returns {Pattern}
|
||||||
|
* @memberof Pattern
|
||||||
|
* @tags internal, combiners
|
||||||
|
* @example
|
||||||
|
* // because input pattern has `s` set,
|
||||||
|
* // it overrides the "sine" declared earlier
|
||||||
|
* note("c a f e").s("sine").set(s("triangle"))
|
||||||
|
*/
|
||||||
|
set: [(a, b) => b],
|
||||||
|
/**
|
||||||
|
* When called on a pattern `a`, with a input pattern `b` (`a.keep(b)`),
|
||||||
|
* combines `a` and `b` such that anything defined in `a`,
|
||||||
|
* and anything defined in `b` that is *not* defined in `a`
|
||||||
|
* will be in the resulting pattern
|
||||||
|
*
|
||||||
|
* The structure is maintained from `a`,
|
||||||
|
* because the default pattern alignment is `in`,
|
||||||
|
* see the section on `Pattern Alignment`
|
||||||
|
* in the technical manual in the docs
|
||||||
|
*
|
||||||
|
* This is the inverse of `set`
|
||||||
|
*
|
||||||
|
* See examples below
|
||||||
|
* @name keep
|
||||||
|
* @param {Pattern} pat
|
||||||
|
* @memberof Pattern
|
||||||
|
* @returns {Pattern}
|
||||||
|
* @tags internal, combiners
|
||||||
|
* @example
|
||||||
|
* // notes, already defined, will stay "c a f e",
|
||||||
|
* // while "s", not defined, will be set to "piano"
|
||||||
|
* note("c a f e").keep(note("e f a c").s("piano"))
|
||||||
|
*/
|
||||||
|
keep: [(a) => a],
|
||||||
|
keepif: [(a, b) => (b ? a : undefined)],
|
||||||
|
|
||||||
// numerical functions
|
// numerical functions
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Assumes a pattern of numbers. Adds the given number to each item in the pattern.
|
* Assumes a pattern of numbers. Adds the given number to each item in the pattern.
|
||||||
* @name add
|
* @name add
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @tags math
|
* @tags math
|
||||||
* @example
|
* @example
|
||||||
* // Here, the triad 0, 2, 4 is shifted by different amounts
|
* // Here, the triad 0, 2, 4 is shifted by different amounts
|
||||||
* n("0 2 4".add("<0 3 4 0>")).scale("C:major")
|
* n("0 2 4".add("<0 3 4 0>")).scale("C:major")
|
||||||
* // Without add, the equivalent would be:
|
* // Without add, the equivalent would be:
|
||||||
* // n("<[0 2 4] [3 5 7] [4 6 8] [0 2 4]>").scale("C:major")
|
* // n("<[0 2 4] [3 5 7] [4 6 8] [0 2 4]>").scale("C:major")
|
||||||
* @example
|
* @example
|
||||||
* // You can also use add with notes:
|
* // You can also use add with notes:
|
||||||
* note("c3 e3 g3".add("<0 5 7 0>"))
|
* note("c3 e3 g3".add("<0 5 7 0>"))
|
||||||
* // Behind the scenes, the notes are converted to midi numbers:
|
* // Behind the scenes, the notes are converted to midi numbers:
|
||||||
* // note("48 52 55".add("<0 5 7 0>"))
|
* // note("48 52 55".add("<0 5 7 0>"))
|
||||||
*/
|
*/
|
||||||
add: [numeralArgs((a, b) => a + b)], // support string concatenation
|
add: [numeralArgs((a, b) => a + b)], // support string concatenation
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Like add, but the given numbers are subtracted.
|
* Like add, but the given numbers are subtracted.
|
||||||
* @name sub
|
* @name sub
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @tags math
|
* @tags math
|
||||||
* @example
|
* @example
|
||||||
* n("0 2 4".sub("<0 1 2 3>")).scale("C4:minor")
|
* n("0 2 4".sub("<0 1 2 3>")).scale("C4:minor")
|
||||||
* // See add for more information.
|
* // See add for more information.
|
||||||
*/
|
*/
|
||||||
sub: [numeralArgs((a, b) => a - b)],
|
sub: [numeralArgs((a, b) => a - b)],
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Multiplies each number by the given factor.
|
* Multiplies each number by the given factor.
|
||||||
* @name mul
|
* @name mul
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @tags math
|
* @tags math
|
||||||
* @example
|
* @example
|
||||||
* "<1 1.5 [1.66, <2 2.33>]>*4".mul(150).freq()
|
* "<1 1.5 [1.66, <2 2.33>]>*4".mul(150).freq()
|
||||||
*/
|
*/
|
||||||
mul: [numeralArgs((a, b) => a * b)],
|
mul: [numeralArgs((a, b) => a * b)],
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Divides each number by the given factor.
|
* Divides each number by the given factor.
|
||||||
* @name div
|
* @name div
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @tags math
|
* @tags math
|
||||||
*/
|
*/
|
||||||
div: [numeralArgs((a, b) => a / b)],
|
div: [numeralArgs((a, b) => a / b)],
|
||||||
mod: [numeralArgs(_mod)],
|
mod: [numeralArgs(_mod)],
|
||||||
pow: [numeralArgs(Math.pow)],
|
pow: [numeralArgs(Math.pow)],
|
||||||
log2: [numeralArgs(Math.log2)],
|
band: [numeralArgs((a, b) => a & b)],
|
||||||
band: [numeralArgs((a, b) => a & b)],
|
bor: [numeralArgs((a, b) => a | b)],
|
||||||
bor: [numeralArgs((a, b) => a | b)],
|
bxor: [numeralArgs((a, b) => a ^ b)],
|
||||||
bxor: [numeralArgs((a, b) => a ^ b)],
|
blshift: [numeralArgs((a, b) => a << b)],
|
||||||
blshift: [numeralArgs((a, b) => a << b)],
|
brshift: [numeralArgs((a, b) => a >> b)],
|
||||||
brshift: [numeralArgs((a, b) => a >> b)],
|
|
||||||
|
|
||||||
// TODO - force numerical comparison if both look like numbers?
|
// TODO - force numerical comparison if both look like numbers?
|
||||||
lt: [(a, b) => a < b],
|
lt: [(a, b) => a < b],
|
||||||
gt: [(a, b) => a > b],
|
gt: [(a, b) => a > b],
|
||||||
lte: [(a, b) => a <= b],
|
lte: [(a, b) => a <= b],
|
||||||
gte: [(a, b) => a >= b],
|
gte: [(a, b) => a >= b],
|
||||||
eq: [(a, b) => a == b],
|
eq: [(a, b) => a == b],
|
||||||
eqt: [(a, b) => a === b],
|
eqt: [(a, b) => a === b],
|
||||||
ne: [(a, b) => a != b],
|
ne: [(a, b) => a != b],
|
||||||
net: [(a, b) => a !== b],
|
net: [(a, b) => a !== b],
|
||||||
and: [(a, b) => a && b],
|
and: [(a, b) => a && b],
|
||||||
or: [(a, b) => a || b],
|
or: [(a, b) => a || b],
|
||||||
|
|
||||||
// bitwise ops
|
// bitwise ops
|
||||||
func: [(a, b) => b(a)],
|
func: [(a, b) => b(a)],
|
||||||
};
|
};
|
||||||
|
|
||||||
const hows = ['In', 'Out', 'Mix', 'Squeeze', 'SqueezeOut', 'Reset', 'Restart', 'Poly'];
|
|
||||||
|
|
||||||
|
const _setupAlignments = () => {
|
||||||
// generate methods to do what and how
|
// generate methods to do what and how
|
||||||
for (const [what, [op, preprocess]] of Object.entries(composers)) {
|
for (const [what, [op, preprocess]] of Object.entries(COMPOSERS)) {
|
||||||
// make plain version, e.g. pat._add(value) adds that plain value
|
// make plain version, e.g. pat._add(value) adds that plain value
|
||||||
// to all the values in pat
|
// to all the values in pat
|
||||||
Pattern.prototype['_' + what] = function (value) {
|
Pattern.prototype['_' + what] = function (value) {
|
||||||
@@ -1148,16 +1191,18 @@ function _composeOp(a, b, func) {
|
|||||||
|
|
||||||
// make patternified monster version
|
// make patternified monster version
|
||||||
Object.defineProperty(Pattern.prototype, what, {
|
Object.defineProperty(Pattern.prototype, what, {
|
||||||
|
// Set to configurable so we can update if the default alignment changes
|
||||||
|
configurable: true,
|
||||||
// a getter that returns a function, so 'pat' can be
|
// a getter that returns a function, so 'pat' can be
|
||||||
// accessed by closures that are methods of that function..
|
// accessed by closures that are methods of that function..
|
||||||
get: function () {
|
get: function () {
|
||||||
const pat = this;
|
const pat = this;
|
||||||
|
|
||||||
// wrap the 'in' function as default behaviour
|
// wrap the 'in' function as default behaviour
|
||||||
const wrapper = (...other) => pat[what]['in'](...other);
|
const wrapper = (...other) => pat[what][DEFAULT_ALIGNMENT](...other);
|
||||||
|
|
||||||
// add methods to that function for each behaviour
|
// add methods to that function for each behaviour
|
||||||
for (const how of hows) {
|
for (const how of ALIGNMENTS) {
|
||||||
wrapper[how.toLowerCase()] = function (...other) {
|
wrapper[how.toLowerCase()] = function (...other) {
|
||||||
var howpat = pat;
|
var howpat = pat;
|
||||||
other = sequence(other);
|
other = sequence(other);
|
||||||
@@ -1182,15 +1227,23 @@ function _composeOp(a, b, func) {
|
|||||||
return wrapper;
|
return wrapper;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default op to 'set', e.g. pat.squeeze(pat2) = pat.set.squeeze(pat2)
|
|
||||||
for (const how of hows) {
|
|
||||||
Pattern.prototype[how.toLowerCase()] = function (...args) {
|
|
||||||
return this.set[how.toLowerCase()](args);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let DEFAULT_ALIGNMENT = 'in';
|
||||||
|
const ALIGNMENTS = ['In', 'Out', 'Mix', 'Squeeze', 'SqueezeOut', 'Reset', 'Restart', 'Poly'];
|
||||||
|
const ALIGNMENT_KEYS = ALIGNMENTS.map((how) => how.toLowerCase());
|
||||||
|
|
||||||
|
// Make composers
|
||||||
|
(function () {
|
||||||
|
_setupAlignments();
|
||||||
|
|
||||||
|
// Default op to 'set', e.g. pat.squeeze(pat2) = pat.set.squeeze(pat2)
|
||||||
|
for (const how of ALIGNMENTS) {
|
||||||
|
Pattern.prototype[how.toLowerCase()] = function (...args) {
|
||||||
|
return this.set[how.toLowerCase()](args);
|
||||||
|
};
|
||||||
|
}
|
||||||
// binary composers
|
// binary composers
|
||||||
/**
|
/**
|
||||||
* Applies the given structure to the pattern:
|
* Applies the given structure to the pattern:
|
||||||
@@ -1249,6 +1302,28 @@ function _composeOp(a, b, func) {
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the default method of combining events from two patterns (aka [alignment](https://strudel.cc/technical-manual/alignment/)) in Strudel.
|
||||||
|
* The default method is 'in', meaning that patterns to the left will (typically) dictate the event timings when combined with patterns to the right.
|
||||||
|
* By changing alignment to 'out', the opposite will happen. With 'mix', they will combine their event timings.
|
||||||
|
*
|
||||||
|
* Note that we say the _default_ method, because alignments can also be set explicitly with calls like
|
||||||
|
* 'add.mix', 'set.squeeze', etc.
|
||||||
|
*
|
||||||
|
* @param {string} method Default join method to use. Options: 'in', 'out', 'mix', 'squeeze', 'squeezeout', 'reset', 'restart', 'poly'
|
||||||
|
* @tags combiners
|
||||||
|
* @example
|
||||||
|
* setDefaultJoin('mix') // also try 'in', 'out', 'squeeze', etc.
|
||||||
|
* s("saw").vel("1 0.5").note("F A C E").delay("0 0.2 0.3")
|
||||||
|
*/
|
||||||
|
export const setDefaultJoin = (alignment) => {
|
||||||
|
alignment = alignment?.toLowerCase();
|
||||||
|
if (DEFAULT_ALIGNMENT !== alignment && ALIGNMENT_KEYS.includes(alignment)) {
|
||||||
|
DEFAULT_ALIGNMENT = alignment;
|
||||||
|
_setupAlignments();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// aliases
|
// aliases
|
||||||
export const polyrhythm = stack;
|
export const polyrhythm = stack;
|
||||||
export const pr = stack;
|
export const pr = stack;
|
||||||
@@ -1536,10 +1611,10 @@ export function arrange(...sections) {
|
|||||||
* @tags combiners
|
* @tags combiners
|
||||||
* @return {Pattern}
|
* @return {Pattern}
|
||||||
* @example
|
* @example
|
||||||
seqPLoop([0, 2, "bd(3,8)"],
|
seqPLoop(
|
||||||
[1, 3, "cp(3,8)"]
|
[0, 2, "bd(3,8)"],
|
||||||
)
|
[1, 3, "cp(3,8)"]
|
||||||
.sound()
|
).sound()
|
||||||
*/
|
*/
|
||||||
export function seqPLoop(...parts) {
|
export function seqPLoop(...parts) {
|
||||||
let total = Fraction(0);
|
let total = Fraction(0);
|
||||||
@@ -1582,7 +1657,7 @@ export function sequence(...pats) {
|
|||||||
|
|
||||||
/** Like **cat**, but the items are crammed into one cycle.
|
/** Like **cat**, but the items are crammed into one cycle.
|
||||||
* @tags combiners
|
* @tags combiners
|
||||||
* @synonyms seq, fastcat
|
* @synonyms fastcat
|
||||||
* @example
|
* @example
|
||||||
* seq("e5", "b4", ["d5", "c5"]).note()
|
* seq("e5", "b4", ["d5", "c5"]).note()
|
||||||
* // "e5 b4 [d5 c5]".note()
|
* // "e5 b4 [d5 c5]".note()
|
||||||
@@ -1666,6 +1741,12 @@ export const func = curry((a, b) => reify(b).func(a));
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) {
|
export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) {
|
||||||
|
if (isPattern(name)) {
|
||||||
|
throw new Error(
|
||||||
|
'Name argument for register is a pattern, try using single quotes (\'name\') instead of double quotes ("name")',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(name)) {
|
if (Array.isArray(name)) {
|
||||||
const result = {};
|
const result = {};
|
||||||
for (const name_item of name) {
|
for (const name_item of name) {
|
||||||
@@ -1788,6 +1869,8 @@ export const floor = register('floor', function (pat) {
|
|||||||
return pat.asNumber().fmap((v) => Math.floor(v));
|
return pat.asNumber().fmap((v) => Math.floor(v));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const log2 = register('log2', (pat) => pat.asNumber().fmap((v) => Math.log2(v)));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assumes a numerical pattern. Returns a new pattern with all values set to
|
* Assumes a numerical pattern. Returns a new pattern with all values set to
|
||||||
* their mathematical ceiling. E.g. `3.2` replaced with `4`, and `-4.2`
|
* their mathematical ceiling. E.g. `3.2` replaced with `4`, and `-4.2`
|
||||||
@@ -2109,13 +2192,12 @@ export const { firstOf, every } = register(['firstOf', 'every'], function (n, fu
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like layer, but with a single function:
|
* Applies the given function to the pattern. Like layer, but with a single function:
|
||||||
* @tags temporal
|
* @tags combiners
|
||||||
* @name apply
|
* @name apply
|
||||||
* @example
|
* @example
|
||||||
* "<c3 eb3 g3>".scale('C minor').apply(scaleTranspose("0,2,4")).note()
|
* "<c3 eb3 g3>".scale('C minor').apply(scaleTranspose("0,2,4")).note()
|
||||||
*/
|
*/
|
||||||
// TODO: remove or dedupe with layer?
|
|
||||||
export const apply = register('apply', function (func, pat) {
|
export const apply = register('apply', function (func, pat) {
|
||||||
return func(pat);
|
return func(pat);
|
||||||
});
|
});
|
||||||
@@ -2470,9 +2552,23 @@ export const { juxBy, juxby } = register(['juxBy', 'juxby'], function (by, func,
|
|||||||
return stack(left, right).setSteps(__steps ? lcm(left._steps, right._steps) : undefined);
|
return stack(left, right).setSteps(__steps ? lcm(left._steps, right._steps) : undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like juxBy, except it flips the ears each cycle.
|
||||||
|
* @name juxFlipBy
|
||||||
|
* @synonyms juxflipby, fluxBy, fluxby
|
||||||
|
* @example
|
||||||
|
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlipBy(".8", rev)
|
||||||
|
*/
|
||||||
|
export const { juxFlipBy, juxflipby, fluxBy, fluxby } = register(
|
||||||
|
['juxFlipBy', 'juxflipby', 'fluxBy', 'fluxby'],
|
||||||
|
function (by, func, pat) {
|
||||||
|
return pat.juxBy(slowcat(by, -by), func);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel.
|
* The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel.
|
||||||
* @tags temporal
|
* @tags temporal, superdough
|
||||||
* @example
|
* @example
|
||||||
* s("bd lt [~ ht] mt cp ~ bd hh").jux(rev)
|
* s("bd lt [~ ht] mt cp ~ bd hh").jux(rev)
|
||||||
* @example
|
* @example
|
||||||
@@ -2484,9 +2580,24 @@ export const jux = register('jux', function (func, pat) {
|
|||||||
return pat._juxBy(1, func, pat);
|
return pat._juxBy(1, func, pat);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like jux, but flips the ears each cycle.
|
||||||
|
* @name juxFlip
|
||||||
|
* @synonyms juxflip, flux
|
||||||
|
* @example
|
||||||
|
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(rev)
|
||||||
|
* @example
|
||||||
|
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(press)
|
||||||
|
* @example
|
||||||
|
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(iter(4))
|
||||||
|
*/
|
||||||
|
export const { juxFlip, flux } = register(['juxFlip', 'juxflip', 'flux'], function (func, pat) {
|
||||||
|
return pat._juxFlipBy(1, func, pat);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Superimpose and offset multiple times, applying the given function each time.
|
* Superimpose and offset multiple times, applying the given function each time.
|
||||||
* @tags temporal
|
* @tags temporal, functional
|
||||||
* @name echoWith
|
* @name echoWith
|
||||||
* @synonyms echowith, stutWith, stutwith
|
* @synonyms echowith, stutWith, stutwith
|
||||||
* @param {number} times how many times to repeat
|
* @param {number} times how many times to repeat
|
||||||
@@ -2660,7 +2771,7 @@ export const { repeatCycles } = register(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Divides a pattern into a given number of parts, then cycles through those parts in turn, applying the given function to each part in turn (one part per cycle).
|
* Divides a pattern into a given number of parts, then cycles through those parts in turn, applying the given function to each part in turn (one part per cycle).
|
||||||
* @tags temporal
|
* @tags temporal, functional
|
||||||
* @name chunk
|
* @name chunk
|
||||||
* @synonyms slowChunk, slowchunk
|
* @synonyms slowChunk, slowchunk
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
@@ -2824,7 +2935,7 @@ Pattern.prototype.tag = function (tag) {
|
|||||||
/**
|
/**
|
||||||
* Filters haps using the given function
|
* Filters haps using the given function
|
||||||
* @name filter
|
* @name filter
|
||||||
* @tags temporal
|
* @tags temporal, functional
|
||||||
* @param {Function} test function to test Hap
|
* @param {Function} test function to test Hap
|
||||||
* @example
|
* @example
|
||||||
* s("hh!7 oh").filter(hap => hap.value.s === 'hh')
|
* s("hh!7 oh").filter(hap => hap.value.s === 'hh')
|
||||||
@@ -2834,7 +2945,7 @@ export const filter = register('filter', (test, pat) => pat.withHaps((haps) => h
|
|||||||
/**
|
/**
|
||||||
* Filters haps by their begin time
|
* Filters haps by their begin time
|
||||||
* @name filterWhen
|
* @name filterWhen
|
||||||
* @tags temporal
|
* @tags temporal, functional
|
||||||
* @param {Function} test function to test Hap.whole.begin
|
* @param {Function} test function to test Hap.whole.begin
|
||||||
* @example
|
* @example
|
||||||
* oneCycle: s("bd*4").filterWhen((t) => t < 1)
|
* oneCycle: s("bd*4").filterWhen((t) => t < 1)
|
||||||
@@ -2844,7 +2955,7 @@ export const filterWhen = register('filterWhen', (test, pat) => pat.filter((h) =
|
|||||||
/**
|
/**
|
||||||
* Use within to apply a function to only a part of a pattern.
|
* Use within to apply a function to only a part of a pattern.
|
||||||
* @name within
|
* @name within
|
||||||
* @tags temporal
|
* @tags temporal, functional
|
||||||
* @param {number} start start within cycle (0 - 1)
|
* @param {number} start start within cycle (0 - 1)
|
||||||
* @param {number} end end within cycle (0 - 1). Must be > start
|
* @param {number} end end within cycle (0 - 1). Must be > start
|
||||||
* @param {Function} func function to be applied to the sub-pattern
|
* @param {Function} func function to be applied to the sub-pattern
|
||||||
@@ -2919,7 +3030,7 @@ export function _match(span, hap_p) {
|
|||||||
* *Experimental*
|
* *Experimental*
|
||||||
*
|
*
|
||||||
* Speeds a pattern up or down, to fit to the given number of steps per cycle.
|
* Speeds a pattern up or down, to fit to the given number of steps per cycle.
|
||||||
* @tags temporal
|
* @tags stepwise
|
||||||
* @example
|
* @example
|
||||||
* sound("bd sd cp").pace(4)
|
* sound("bd sd cp").pace(4)
|
||||||
* // The same as sound("{bd sd cp}%4") or sound("<bd sd cp>*4")
|
* // The same as sound("{bd sd cp}%4") or sound("<bd sd cp>*4")
|
||||||
@@ -2961,7 +3072,7 @@ export function _polymeterListSteps(steps, ...args) {
|
|||||||
* *Experimental*
|
* *Experimental*
|
||||||
*
|
*
|
||||||
* Aligns the steps of the patterns, creating polymeters. The patterns are repeated until they all fit the cycle. For example, in the below the first pattern is repeated twice, and the second is repeated three times, to fit the lowest common multiple of six steps.
|
* Aligns the steps of the patterns, creating polymeters. The patterns are repeated until they all fit the cycle. For example, in the below the first pattern is repeated twice, and the second is repeated three times, to fit the lowest common multiple of six steps.
|
||||||
* @tags temporal
|
* @tags stepwise
|
||||||
* @synonyms pm
|
* @synonyms pm
|
||||||
* @example
|
* @example
|
||||||
* // The same as note("{c eb g, c2 g2}%6")
|
* // The same as note("{c eb g, c2 g2}%6")
|
||||||
@@ -2994,7 +3105,7 @@ export function polymeter(...args) {
|
|||||||
* The steps can either be inferred from the pattern, or provided as a [length, pattern] pair.
|
* The steps can either be inferred from the pattern, or provided as a [length, pattern] pair.
|
||||||
* Has the alias `timecat`.
|
* Has the alias `timecat`.
|
||||||
* @name stepcat
|
* @name stepcat
|
||||||
* @tags combiners
|
* @tags stepwise
|
||||||
* @synonyms timeCat, timecat
|
* @synonyms timeCat, timecat
|
||||||
* @return {Pattern}
|
* @return {Pattern}
|
||||||
* @example
|
* @example
|
||||||
@@ -3052,7 +3163,7 @@ export function stepcat(...timepats) {
|
|||||||
* Concatenates patterns stepwise, according to an inferred 'steps per cycle'.
|
* Concatenates patterns stepwise, according to an inferred 'steps per cycle'.
|
||||||
* Similar to `stepcat`, but if an argument is a list, the whole pattern will alternate between the elements in the list.
|
* Similar to `stepcat`, but if an argument is a list, the whole pattern will alternate between the elements in the list.
|
||||||
*
|
*
|
||||||
* @tags combiners
|
* @tags stepwise
|
||||||
* @return {Pattern}
|
* @return {Pattern}
|
||||||
* @example
|
* @example
|
||||||
* stepalt(["bd cp", "mt"], "bd").sound()
|
* stepalt(["bd cp", "mt"], "bd").sound()
|
||||||
@@ -3079,7 +3190,7 @@ export function stepalt(...groups) {
|
|||||||
*
|
*
|
||||||
* Takes the given number of steps from a pattern (dropping the rest).
|
* Takes the given number of steps from a pattern (dropping the rest).
|
||||||
* A positive number will take steps from the start of a pattern, and a negative number from the end.
|
* A positive number will take steps from the start of a pattern, and a negative number from the end.
|
||||||
* @tags temporal
|
* @tags stepwise
|
||||||
* @return {Pattern}
|
* @return {Pattern}
|
||||||
* @example
|
* @example
|
||||||
* "bd cp ht mt".take("2").sound()
|
* "bd cp ht mt".take("2").sound()
|
||||||
@@ -3124,7 +3235,7 @@ export const take = stepRegister('take', function (i, pat) {
|
|||||||
*
|
*
|
||||||
* Drops the given number of steps from a pattern.
|
* Drops the given number of steps from a pattern.
|
||||||
* A positive number will drop steps from the start of a pattern, and a negative number from the end.
|
* A positive number will drop steps from the start of a pattern, and a negative number from the end.
|
||||||
* @tags temporal
|
* @tags stepwise
|
||||||
* @return {Pattern}
|
* @return {Pattern}
|
||||||
* @example
|
* @example
|
||||||
* "tha dhi thom nam".drop("1").sound().bank("mridangam")
|
* "tha dhi thom nam".drop("1").sound().bank("mridangam")
|
||||||
@@ -3153,7 +3264,7 @@ export const drop = stepRegister('drop', function (i, pat) {
|
|||||||
* `extend` is similar to `fast` in that it increases its density, but it also increases the step count
|
* `extend` is similar to `fast` in that it increases its density, but it also increases the step count
|
||||||
* accordingly. So `stepcat("a b".extend(2), "c d")` would be the same as `"a b a b c d"`, whereas
|
* accordingly. So `stepcat("a b".extend(2), "c d")` would be the same as `"a b a b c d"`, whereas
|
||||||
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
|
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
|
||||||
* @tags temporal
|
* @tags stepwise
|
||||||
* @example
|
* @example
|
||||||
* stepcat(
|
* stepcat(
|
||||||
* sound("bd bd - cp").extend(2),
|
* sound("bd bd - cp").extend(2),
|
||||||
@@ -3172,7 +3283,7 @@ export const extend = stepRegister('extend', function (factor, pat) {
|
|||||||
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
|
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
|
||||||
*
|
*
|
||||||
* TODO: find out how this function differs from extend
|
* TODO: find out how this function differs from extend
|
||||||
* @tags temporal
|
* @tags stepwise
|
||||||
* @example
|
* @example
|
||||||
* stepcat(
|
* stepcat(
|
||||||
* sound("bd bd - cp").replicate(2),
|
* sound("bd bd - cp").replicate(2),
|
||||||
@@ -3187,7 +3298,7 @@ export const replicate = stepRegister('replicate', function (factor, pat) {
|
|||||||
* *Experimental*
|
* *Experimental*
|
||||||
*
|
*
|
||||||
* Expands the step size of the pattern by the given factor.
|
* Expands the step size of the pattern by the given factor.
|
||||||
* @tags temporal
|
* @tags stepwise
|
||||||
* @example
|
* @example
|
||||||
* sound("tha dhi thom nam").bank("mridangam").expand("3 2 1 1 2 3").pace(8)
|
* sound("tha dhi thom nam").bank("mridangam").expand("3 2 1 1 2 3").pace(8)
|
||||||
*/
|
*/
|
||||||
@@ -3199,7 +3310,7 @@ export const expand = stepRegister('expand', function (factor, pat) {
|
|||||||
* *Experimental*
|
* *Experimental*
|
||||||
*
|
*
|
||||||
* Contracts the step size of the pattern by the given factor. See also `expand`.
|
* Contracts the step size of the pattern by the given factor. See also `expand`.
|
||||||
* @tags temporal
|
* @tags stepwise
|
||||||
* @example
|
* @example
|
||||||
* sound("tha dhi thom nam").bank("mridangam").contract("3 2 1 1 2 3").pace(8)
|
* sound("tha dhi thom nam").bank("mridangam").contract("3 2 1 1 2 3").pace(8)
|
||||||
*/
|
*/
|
||||||
@@ -3248,13 +3359,18 @@ Pattern.prototype.shrinklist = function (amount) {
|
|||||||
|
|
||||||
export const shrinklist = (amount, pat) => pat.shrinklist(amount);
|
export const shrinklist = (amount, pat) => pat.shrinklist(amount);
|
||||||
|
|
||||||
|
Pattern.prototype.growlist = function (amount) {
|
||||||
|
return this.shrinklist(amount).reverse();
|
||||||
|
};
|
||||||
|
export const growlist = (amount, pat) => pat.growlist(amount);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* *Experimental*
|
* *Experimental*
|
||||||
*
|
*
|
||||||
* Progressively shrinks the pattern by 'n' steps until there's nothing left, or if a second value is given (using mininotation list syntax with `:`),
|
* Progressively shrinks the pattern by 'n' steps until there's nothing left, or if a second value is given (using mininotation list syntax with `:`),
|
||||||
* that number of times.
|
* that number of times.
|
||||||
* A positive number will progressively drop steps from the start of a pattern, and a negative number from the end.
|
* A positive number will progressively drop steps from the start of a pattern, and a negative number from the end.
|
||||||
* @tags temporal
|
* @tags stepwise
|
||||||
* @return {Pattern}
|
* @return {Pattern}
|
||||||
* @example
|
* @example
|
||||||
* "tha dhi thom nam".shrink("1").sound()
|
* "tha dhi thom nam".shrink("1").sound()
|
||||||
@@ -3294,7 +3410,7 @@ export const shrink = register(
|
|||||||
* Progressively grows the pattern by 'n' steps until the full pattern is played, or if a second value is given (using mininotation list syntax with `:`),
|
* Progressively grows the pattern by 'n' steps until the full pattern is played, or if a second value is given (using mininotation list syntax with `:`),
|
||||||
* that number of times.
|
* that number of times.
|
||||||
* A positive number will progressively grow steps from the start of a pattern, and a negative number from the end.
|
* A positive number will progressively grow steps from the start of a pattern, and a negative number from the end.
|
||||||
* @tags temporal
|
* @tags stepwise
|
||||||
* @return {Pattern}
|
* @return {Pattern}
|
||||||
* @example
|
* @example
|
||||||
* "tha dhi thom nam".grow("1").sound()
|
* "tha dhi thom nam".grow("1").sound()
|
||||||
@@ -3335,7 +3451,7 @@ export const grow = register(
|
|||||||
* on successive repetitions. The patterns are added together stepwise, with all repetitions taking place over a single cycle. Using `pace` to set the
|
* on successive repetitions. The patterns are added together stepwise, with all repetitions taking place over a single cycle. Using `pace` to set the
|
||||||
* number of steps per cycle is therefore usually recommended.
|
* number of steps per cycle is therefore usually recommended.
|
||||||
*
|
*
|
||||||
* @tags combiners
|
* @tags stepwise
|
||||||
* @return {Pattern}
|
* @return {Pattern}
|
||||||
* @example
|
* @example
|
||||||
* "[c g]".tour("e f", "e f g", "g f e c").note()
|
* "[c g]".tour("e f", "e f g", "g f e c").note()
|
||||||
@@ -3362,7 +3478,7 @@ Pattern.prototype.tour = function (...many) {
|
|||||||
* 'zips' together the steps of the provided patterns. This can create a long repetition, taking place over a single, dense cycle.
|
* 'zips' together the steps of the provided patterns. This can create a long repetition, taking place over a single, dense cycle.
|
||||||
* Using `pace` to set the number of steps per cycle is therefore usually recommended.
|
* Using `pace` to set the number of steps per cycle is therefore usually recommended.
|
||||||
*
|
*
|
||||||
* @tags combiners
|
* @tags stepwise
|
||||||
* @returns {Pattern}
|
* @returns {Pattern}
|
||||||
* @example
|
* @example
|
||||||
* zip("e f", "e f g", "g [f e] a f4 c").note()
|
* zip("e f", "e f g", "g [f e] a f4 c").note()
|
||||||
@@ -3414,7 +3530,7 @@ Pattern.prototype.steps = Pattern.prototype.pace;
|
|||||||
* Cuts each sample into the given number of parts, allowing you to explore a technique known as 'granular synthesis'.
|
* Cuts each sample into the given number of parts, allowing you to explore a technique known as 'granular synthesis'.
|
||||||
* It turns a pattern of samples into a pattern of parts of samples.
|
* It turns a pattern of samples into a pattern of parts of samples.
|
||||||
* @name chop
|
* @name chop
|
||||||
* @tags temporal
|
* @tags samples
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
@@ -3445,7 +3561,7 @@ export const chop = register('chop', function (n, pat) {
|
|||||||
/**
|
/**
|
||||||
* Cuts each sample into the given number of parts, triggering progressive portions of each sample at each loop.
|
* Cuts each sample into the given number of parts, triggering progressive portions of each sample at each loop.
|
||||||
* @name striate
|
* @name striate
|
||||||
* @tags temporal
|
* @tags samples
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
@@ -3464,14 +3580,13 @@ export const striate = register('striate', function (n, pat) {
|
|||||||
/**
|
/**
|
||||||
* Makes the sample fit the given number of cycles by changing the speed.
|
* Makes the sample fit the given number of cycles by changing the speed.
|
||||||
* @name loopAt
|
* @name loopAt
|
||||||
* @tags temporal
|
* @tags samples, pitch
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
|
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
|
||||||
* s("rhodes").loopAt(2)
|
* s("rhodes").loopAt(2)
|
||||||
*/
|
*/
|
||||||
// TODO - global cps clock
|
|
||||||
const _loopAt = function (factor, pat, cps = 0.5) {
|
const _loopAt = function (factor, pat, cps = 0.5) {
|
||||||
return pat
|
return pat
|
||||||
.speed((1 / factor) * cps)
|
.speed((1 / factor) * cps)
|
||||||
@@ -3479,11 +3594,16 @@ const _loopAt = function (factor, pat, cps = 0.5) {
|
|||||||
.slow(factor);
|
.slow(factor);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const { loopAt, loopat } = register(['loopAt', 'loopat'], function (factor, pat) {
|
||||||
|
const steps = pat._steps ? pat._steps.div(factor) : undefined;
|
||||||
|
return new Pattern((state) => _loopAt(factor, pat, state.controls._cps).query(state), steps);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chops samples into the given number of slices, triggering those slices with a given pattern of slice numbers.
|
* Chops samples into the given number of slices, triggering those slices with a given pattern of slice numbers.
|
||||||
* Instead of a number, it also accepts a list of numbers from 0 to 1 to slice at specific points.
|
* Instead of a number, it also accepts a list of numbers from 0 to 1 to slice at specific points.
|
||||||
* @name slice
|
* @name slice
|
||||||
* @tags temporal
|
* @tags samples
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
@@ -3537,7 +3657,7 @@ Pattern.prototype.onTriggerTime = function (func) {
|
|||||||
/**
|
/**
|
||||||
* Works the same as slice, but changes the playback speed of each slice to match the duration of its step.
|
* Works the same as slice, but changes the playback speed of each slice to match the duration of its step.
|
||||||
* @name splice
|
* @name splice
|
||||||
* @tags temporal
|
* @tags samples, pitch
|
||||||
* @example
|
* @example
|
||||||
* samples('github:tidalcycles/dirt-samples')
|
* samples('github:tidalcycles/dirt-samples')
|
||||||
* s("breaks165")
|
* s("breaks165")
|
||||||
@@ -3566,16 +3686,11 @@ export const splice = register(
|
|||||||
false, // turns off auto-patternification
|
false, // turns off auto-patternification
|
||||||
);
|
);
|
||||||
|
|
||||||
export const { loopAt, loopat } = register(['loopAt', 'loopat'], function (factor, pat) {
|
|
||||||
const steps = pat._steps ? pat._steps.div(factor) : undefined;
|
|
||||||
return new Pattern((state) => _loopAt(factor, pat, state.controls._cps).query(state), steps);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes the sample fit its event duration. Good for rhythmical loops like drum breaks.
|
* Makes the sample fit its event duration. Good for rhythmical loops like drum breaks.
|
||||||
* Similar to `loopAt`.
|
* Similar to `loopAt`.
|
||||||
* @name fit
|
* @name fit
|
||||||
* @tags temporal
|
* @tags samples, pitch
|
||||||
* @example
|
* @example
|
||||||
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
|
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
|
||||||
* s("rhodes/2").fit()
|
* s("rhodes/2").fit()
|
||||||
@@ -3597,18 +3712,16 @@ export const fit = register('fit', (pat) =>
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes the sample fit the given number of cycles and cps value, by
|
* Makes the sample fit the given number of cycles and cps value, by
|
||||||
* changing the speed. Please note that at some point cps will be
|
* changing the speed. deprecated: use loopAt or fit instead, together with setCps / setCpm.
|
||||||
* given by a global clock and this function will be
|
|
||||||
* deprecated/removed.
|
|
||||||
* @name loopAtCps
|
* @name loopAtCps
|
||||||
* @tags temporal
|
* @tags samples, pitch
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
|
* @deprecated
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
|
* samples({ rhodes: 'https://cdn.freesound.org/previews/132/132051_316502-lq.mp3' })
|
||||||
* s("rhodes").loopAtCps(4,1.5).cps(1.5)
|
* s("rhodes").loopAtCps(4,1.5).cps(1.5)
|
||||||
*/
|
*/
|
||||||
// TODO - global cps clock
|
|
||||||
export const { loopAtCps, loopatcps } = register(['loopAtCps', 'loopatcps'], function (factor, cps, pat) {
|
export const { loopAtCps, loopatcps } = register(['loopAtCps', 'loopatcps'], function (factor, cps, pat) {
|
||||||
return _loopAt(factor, pat, cps);
|
return _loopAt(factor, pat, cps);
|
||||||
});
|
});
|
||||||
@@ -3630,7 +3743,7 @@ let fadeGain = (p) => (p < 0.5 ? 1 : 1 - (p - 0.5) / 0.5);
|
|||||||
* - 1 = (no left, full right)
|
* - 1 = (no left, full right)
|
||||||
*
|
*
|
||||||
* @name xfade
|
* @name xfade
|
||||||
* @tags combiners
|
* @tags amplitude
|
||||||
* @example
|
* @example
|
||||||
* xfade(s("bd*2"), "<0 .25 .5 .75 1>", s("hh*8"))
|
* xfade(s("bd*2"), "<0 .25 .5 .75 1>", s("hh*8"))
|
||||||
*/
|
*/
|
||||||
@@ -3756,7 +3869,7 @@ const _distortWithAlg = function (name) {
|
|||||||
* Soft-clipping distortion
|
* Soft-clipping distortion
|
||||||
*
|
*
|
||||||
* @name soft
|
* @name soft
|
||||||
* @tags fx
|
* @tags distortion, superdough
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
* @param {number | Pattern} distortion amount of distortion to apply
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
* @param {number | Pattern} volume linear postgain of the distortion
|
||||||
*
|
*
|
||||||
@@ -3767,7 +3880,7 @@ export const soft = _distortWithAlg('soft');
|
|||||||
* Hard-clipping distortion
|
* Hard-clipping distortion
|
||||||
*
|
*
|
||||||
* @name hard
|
* @name hard
|
||||||
* @tags fx
|
* @tags distortion, superdough
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
* @param {number | Pattern} distortion amount of distortion to apply
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
* @param {number | Pattern} volume linear postgain of the distortion
|
||||||
*
|
*
|
||||||
@@ -3778,7 +3891,7 @@ export const hard = _distortWithAlg('hard');
|
|||||||
* Cubic polynomial distortion
|
* Cubic polynomial distortion
|
||||||
*
|
*
|
||||||
* @name cubic
|
* @name cubic
|
||||||
* @tags fx
|
* @tags distortion, superdough
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
* @param {number | Pattern} distortion amount of distortion to apply
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
* @param {number | Pattern} volume linear postgain of the distortion
|
||||||
*
|
*
|
||||||
@@ -3789,7 +3902,7 @@ export const cubic = _distortWithAlg('cubic');
|
|||||||
* Diode-emulating distortion
|
* Diode-emulating distortion
|
||||||
*
|
*
|
||||||
* @name diode
|
* @name diode
|
||||||
* @tags fx
|
* @tags distortion, superdough
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
* @param {number | Pattern} distortion amount of distortion to apply
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
* @param {number | Pattern} volume linear postgain of the distortion
|
||||||
*
|
*
|
||||||
@@ -3800,7 +3913,7 @@ export const diode = _distortWithAlg('diode');
|
|||||||
* Asymmetrical diode distortion
|
* Asymmetrical diode distortion
|
||||||
*
|
*
|
||||||
* @name asym
|
* @name asym
|
||||||
* @tags fx
|
* @tags distortion, superdough
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
* @param {number | Pattern} distortion amount of distortion to apply
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
* @param {number | Pattern} volume linear postgain of the distortion
|
||||||
*
|
*
|
||||||
@@ -3811,7 +3924,7 @@ export const asym = _distortWithAlg('asym');
|
|||||||
* Wavefolding distortion
|
* Wavefolding distortion
|
||||||
*
|
*
|
||||||
* @name fold
|
* @name fold
|
||||||
* @tags fx
|
* @tags distortion, superdough
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
* @param {number | Pattern} distortion amount of distortion to apply
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
* @param {number | Pattern} volume linear postgain of the distortion
|
||||||
*
|
*
|
||||||
@@ -3822,7 +3935,7 @@ export const fold = _distortWithAlg('fold');
|
|||||||
* Wavefolding distortion composed with sinusoid
|
* Wavefolding distortion composed with sinusoid
|
||||||
*
|
*
|
||||||
* @name sinefold
|
* @name sinefold
|
||||||
* @tags fx
|
* @tags distortion, superdough
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
* @param {number | Pattern} distortion amount of distortion to apply
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
* @param {number | Pattern} volume linear postgain of the distortion
|
||||||
*
|
*
|
||||||
@@ -3833,7 +3946,7 @@ export const sinefold = _distortWithAlg('sinefold');
|
|||||||
* Distortion via Chebyshev polynomials
|
* Distortion via Chebyshev polynomials
|
||||||
*
|
*
|
||||||
* @name chebyshev
|
* @name chebyshev
|
||||||
* @tags fx
|
* @tags distortion, superdough
|
||||||
* @param {number | Pattern} distortion amount of distortion to apply
|
* @param {number | Pattern} distortion amount of distortion to apply
|
||||||
* @param {number | Pattern} volume linear postgain of the distortion
|
* @param {number | Pattern} volume linear postgain of the distortion
|
||||||
*
|
*
|
||||||
@@ -3867,7 +3980,7 @@ const _ensureListPattern = (list) => {
|
|||||||
* Can also be used to create a new synth via `s('user').partials(...)`
|
* Can also be used to create a new synth via `s('user').partials(...)`
|
||||||
*
|
*
|
||||||
* @name partials
|
* @name partials
|
||||||
* @tags fx, superdough
|
* @tags superdough
|
||||||
* @param {number[] | Pattern} magnitudes List of [0, 1] magnitudes for partials. 0th entry is the fundamental harmonic (i.e. DC offset is skipped)
|
* @param {number[] | Pattern} magnitudes List of [0, 1] magnitudes for partials. 0th entry is the fundamental harmonic (i.e. DC offset is skipped)
|
||||||
* @example
|
* @example
|
||||||
* s("user").seg(16).n(irand(8)).scale("A:major")
|
* s("user").seg(16).n(irand(8)).scale("A:major")
|
||||||
@@ -3889,7 +4002,7 @@ export const partials = (list) => {
|
|||||||
* Rotates the harmonics of one of the core synths ('sine', 'tri', 'saw', 'user', ..) by a list of phases
|
* Rotates the harmonics of one of the core synths ('sine', 'tri', 'saw', 'user', ..) by a list of phases
|
||||||
*
|
*
|
||||||
* @name phases
|
* @name phases
|
||||||
* @tags fx, superdough
|
* @tags superdough
|
||||||
* @param {number[] | Pattern} phases List of [0, 1) phases for partials. 0th entry is the fundamental phase (i.e. DC offset is skipped)
|
* @param {number[] | Pattern} phases List of [0, 1) phases for partials. 0th entry is the fundamental phase (i.e. DC offset is skipped)
|
||||||
* @example
|
* @example
|
||||||
* // Phase cancellation
|
* // Phase cancellation
|
||||||
@@ -3911,7 +4024,7 @@ export const phases = (list) => {
|
|||||||
* calls and/or in a single .FX(fx1, fx2, ..) call. The fx1, .. are _patterns_ which
|
* calls and/or in a single .FX(fx1, fx2, ..) call. The fx1, .. are _patterns_ which
|
||||||
* establish the controls of the given effect. See examples.
|
* establish the controls of the given effect. See examples.
|
||||||
* @name FX
|
* @name FX
|
||||||
* @tags fx, superdough
|
* @tags superdough
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
@@ -3962,7 +4075,7 @@ const _asArrayPattern = (pats) => {
|
|||||||
* by wrapping them inside a function in K (see example).
|
* by wrapping them inside a function in K (see example).
|
||||||
*
|
*
|
||||||
* @name K
|
* @name K
|
||||||
* @tags generators, fx, superdough
|
* @tags generators, superdough
|
||||||
* @param {KabelsalatExpression | Function} expr Kabelsalat graph definition
|
* @param {KabelsalatExpression | Function} expr Kabelsalat graph definition
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
@@ -4019,3 +4132,59 @@ Pattern.prototype.worklet = function (src, ...inputs) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const worklet = (...args) => pure({}).worklet(...args);
|
export const worklet = (...args) => pure({}).worklet(...args);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a pattern of numbers in base b from a number or pattern of numbers
|
||||||
|
* limited to d digits long from the right
|
||||||
|
*
|
||||||
|
* @name base
|
||||||
|
* @tags generators
|
||||||
|
* @param {number} n - number to convert (can be a pattern or array)
|
||||||
|
* @param {number} b - base to convert to (defaults to 10) (can be a pattern)
|
||||||
|
* @param {number} d - max number of digits to produce for each n (defaults to 0 for all) (can be a pattern)
|
||||||
|
* @example
|
||||||
|
* $: note(base("7175 543", 10, 3)).scale("c:major").s("saw")
|
||||||
|
* // $: note("1 7 5 5 4 3").scale("c:major").s("saw")
|
||||||
|
*/
|
||||||
|
export const base = (n, b = 10, d = 0) => {
|
||||||
|
if (Array.isArray(n)) {
|
||||||
|
n = sequence(n);
|
||||||
|
}
|
||||||
|
n = reify(n);
|
||||||
|
b = reify(b);
|
||||||
|
d = reify(d);
|
||||||
|
|
||||||
|
return d
|
||||||
|
.withValue((e) => {
|
||||||
|
return b
|
||||||
|
.withValue((c) => {
|
||||||
|
return n
|
||||||
|
.withValue((v) => {
|
||||||
|
let digits = [];
|
||||||
|
let value = v;
|
||||||
|
while (value > 0) {
|
||||||
|
digits.unshift(value % c);
|
||||||
|
value = Math.floor(value / c);
|
||||||
|
}
|
||||||
|
if (e) {
|
||||||
|
const l = digits.length;
|
||||||
|
if (l > e) {
|
||||||
|
digits = digits.slice(-1 * e);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
if (l < e){
|
||||||
|
for (let i = l; i < e; i++) {
|
||||||
|
digits.unshift("~");//0); //Would like to be padding this but ~- doesn't work
|
||||||
|
}
|
||||||
|
console.log("digits", digits);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
return sequence(digits);
|
||||||
|
})
|
||||||
|
.squeezeJoin();
|
||||||
|
})
|
||||||
|
.squeezeJoin();
|
||||||
|
})
|
||||||
|
.squeezeJoin();
|
||||||
|
};
|
||||||
|
|||||||
+28
-23
@@ -71,29 +71,32 @@ export const pickmod = register('pickmod', function (lookup, pat) {
|
|||||||
/** * pickF lets you use a pattern of numbers to pick which function to apply to another pattern.
|
/** * pickF lets you use a pattern of numbers to pick which function to apply to another pattern.
|
||||||
* @tags combiners, functional
|
* @tags combiners, functional
|
||||||
* @param {Pattern} pat
|
* @param {Pattern} pat
|
||||||
* @param {Pattern} lookup a pattern of indices
|
* @param {Pattern} lookup a pattern of indices or names
|
||||||
* @param {function[]} funcs the array of functions from which to pull
|
* @param {function[] | object} lookup the array or lookup object 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 (lookup, funcs, pat) {
|
export const pickF = register('pickF', function (pickPattern, lookup, pat) {
|
||||||
return pat.apply(pick(lookup, funcs));
|
return pat.apply(pick(lookup, pickPattern));
|
||||||
});
|
});
|
||||||
|
|
||||||
/** * 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
|
* @tags combiners
|
||||||
* @param {Pattern} pat
|
* @param {Pattern} pat
|
||||||
* @param {Pattern} lookup a pattern of indices
|
* @param {Pattern} lookup a pattern of indices or names
|
||||||
* @param {function[]} funcs the array of functions from which to pull
|
* @param {function[] | object} lookup the array or lookup object of functions from which to pull
|
||||||
* @returns {Pattern}
|
* @returns {Pattern}
|
||||||
*/
|
*/
|
||||||
export const pickmodF = register('pickmodF', function (lookup, funcs, pat) {
|
export const pickmodF = register('pickmodF', function (pickPattern, lookup, pat) {
|
||||||
return pat.apply(pickmod(lookup, funcs));
|
return pat.apply(pickmod(lookup, pickPattern));
|
||||||
});
|
});
|
||||||
|
|
||||||
/** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin.
|
/** * Similar to `pick`, but it applies an outerJoin instead of an innerJoin.
|
||||||
@@ -167,20 +170,22 @@ export const pickmodReset = register('pickmodReset', function (lookup, pat) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Picks patterns (or plain values) either from a list (by index) or a lookup table (by name).
|
/** 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
|
* @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();
|
||||||
});
|
});
|
||||||
|
|||||||
+310
-40
@@ -38,6 +38,7 @@ export function repl({
|
|||||||
pattern: undefined,
|
pattern: undefined,
|
||||||
miniLocations: [],
|
miniLocations: [],
|
||||||
widgets: [],
|
widgets: [],
|
||||||
|
sliders: [],
|
||||||
pending: false,
|
pending: false,
|
||||||
started: false,
|
started: false,
|
||||||
};
|
};
|
||||||
@@ -79,11 +80,132 @@ export function repl({
|
|||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,7 +225,60 @@ export function repl({
|
|||||||
};
|
};
|
||||||
setTime(() => scheduler.now()); // TODO: refactor?
|
setTime(() => scheduler.now()); // TODO: refactor?
|
||||||
|
|
||||||
const stop = () => scheduler.stop();
|
// Helper function to apply pattern transformations (solo, each, all)
|
||||||
|
// this should be abstracted more
|
||||||
|
function applyPatternTransforms(pattern) {
|
||||||
|
const allPatterns = Object.values(pPatterns);
|
||||||
|
|
||||||
|
if (allPatterns.length) {
|
||||||
|
let patterns = [];
|
||||||
|
let soloActive = false;
|
||||||
|
for (const [key, value] of Object.entries(pPatterns)) {
|
||||||
|
// handle soloed patterns ex: S$: s("bd!4")
|
||||||
|
const isSolod = key.length > 1 && key.startsWith('S');
|
||||||
|
if (isSolod && soloActive === false) {
|
||||||
|
// first time we see a soloed pattern, clear existing patterns
|
||||||
|
patterns = [];
|
||||||
|
soloActive = true;
|
||||||
|
}
|
||||||
|
if (!soloActive || (soloActive && isSolod)) {
|
||||||
|
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
||||||
|
patterns.push(valWithState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eachTransform) {
|
||||||
|
// Explicit lambda so only element (not index and array) are passed
|
||||||
|
patterns = patterns.map((x) => eachTransform(x));
|
||||||
|
}
|
||||||
|
pattern = stack(...patterns);
|
||||||
|
} else if (eachTransform) {
|
||||||
|
pattern = eachTransform(pattern);
|
||||||
|
}
|
||||||
|
if (allTransforms.length) {
|
||||||
|
for (const transform of allTransforms) {
|
||||||
|
pattern = transform(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPattern(pattern)) {
|
||||||
|
pattern = silence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
codeBlocks = {};
|
||||||
|
blockPatterns.clear();
|
||||||
|
pPatterns = {};
|
||||||
|
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
|
||||||
|
updateState({
|
||||||
|
miniLocations: [],
|
||||||
|
widgets: [],
|
||||||
|
sliders: [],
|
||||||
|
});
|
||||||
|
scheduler.stop();
|
||||||
|
};
|
||||||
const start = () => scheduler.start();
|
const start = () => scheduler.start();
|
||||||
const pause = () => scheduler.pause();
|
const pause = () => scheduler.pause();
|
||||||
const toggle = () => scheduler.toggle();
|
const toggle = () => scheduler.toggle();
|
||||||
@@ -132,8 +307,12 @@ export function repl({
|
|||||||
|
|
||||||
let allTransforms = [];
|
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 grouped 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.
|
||||||
|
*
|
||||||
|
* **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `all`. An unlabeled
|
||||||
|
* pattern such as `note("c4")` is not registered and will produce no audio when `all` is present.
|
||||||
|
* Use `$: note("c4")` instead.
|
||||||
* ```
|
* ```
|
||||||
* $: sound("bd - cp sd")
|
* $: sound("bd - cp sd")
|
||||||
* $: sound("hh*8")
|
* $: sound("hh*8")
|
||||||
@@ -153,6 +332,9 @@ export function repl({
|
|||||||
};
|
};
|
||||||
/** 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.
|
||||||
*
|
*
|
||||||
|
* **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `each`. An unlabeled
|
||||||
|
* pattern such as `note("c4")` is not registered and will produce no audio when `each` is present.
|
||||||
|
* Use `$: note("c4")` instead.
|
||||||
* ```
|
* ```
|
||||||
* $: sound("bd - cp sd")
|
* $: sound("bd - cp sd")
|
||||||
* $: sound("hh*8")
|
* $: sound("hh*8")
|
||||||
@@ -217,7 +399,7 @@ export function repl({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const evaluate = async (code, autostart = true, shouldHush = true) => {
|
const evaluate = async (code, autostart = true) => {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new Error('no code to evaluate');
|
throw new Error('no code to evaluate');
|
||||||
}
|
}
|
||||||
@@ -225,59 +407,34 @@ 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 });
|
await beforeEval?.({ code, blockBased: false });
|
||||||
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);
|
|
||||||
if (Object.keys(pPatterns).length) {
|
|
||||||
let patterns = [];
|
|
||||||
let soloActive = false;
|
|
||||||
for (const [key, value] of Object.entries(pPatterns)) {
|
|
||||||
// handle soloed patterns ex: S$: s("bd!4")
|
|
||||||
const isSolod = key.length > 1 && key.startsWith('S');
|
|
||||||
if (isSolod && soloActive === false) {
|
|
||||||
// first time we see a soloed pattern, clear existing patterns
|
|
||||||
patterns = [];
|
|
||||||
soloActive = true;
|
|
||||||
}
|
|
||||||
if (!soloActive || (soloActive && isSolod)) {
|
|
||||||
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
|
||||||
patterns.push(valWithState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (eachTransform) {
|
|
||||||
// Explicit lambda so only element (not index and array) are passed
|
|
||||||
patterns = patterns.map((x) => eachTransform(x));
|
|
||||||
}
|
|
||||||
pattern = stack(...patterns);
|
|
||||||
} else if (eachTransform) {
|
|
||||||
pattern = eachTransform(pattern);
|
|
||||||
}
|
|
||||||
if (allTransforms.length) {
|
|
||||||
for (const transform of allTransforms) {
|
|
||||||
pattern = transform(pattern);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPattern(pattern)) {
|
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
||||||
pattern = silence;
|
|
||||||
}
|
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: undefined, widgetRemoved: false });
|
||||||
return pattern;
|
return pattern;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger(`[eval] error: ${err.message}`, 'error');
|
logger(`[eval] error: ${err.message}`, 'error');
|
||||||
@@ -286,8 +443,121 @@ export function repl({
|
|||||||
onEvalError?.(err);
|
onEvalError?.(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const evaluateBlock = async (code, autostart = true, options = {}) => {
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('no code to evaluate');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
updateState({ code, pending: true });
|
||||||
|
await injectPatternMethods();
|
||||||
|
setTime(() => scheduler.now()); // TODO: refactor?
|
||||||
|
await beforeEval?.({ code, blockBased: true });
|
||||||
|
allTransforms = []; // reset all transforms
|
||||||
|
|
||||||
|
const transpilerOptionsWithBlock = {
|
||||||
|
...transpilerOptions,
|
||||||
|
blockBased: true,
|
||||||
|
range: options.range || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mondo) {
|
||||||
|
code = `mondolang\`${code}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptionsWithBlock);
|
||||||
|
|
||||||
|
// Track activeVisualizer cleanup: check if any block's visualizer was removed
|
||||||
|
let widgetRemoved = false;
|
||||||
|
|
||||||
|
const labels = meta.labels || [];
|
||||||
|
|
||||||
|
// Check for anonymous labels (labels starting with '$')
|
||||||
|
const hasAnonymousLabel = labels.some((label) => label.name.startsWith('$'));
|
||||||
|
|
||||||
|
// Store code blocks in dictionary using labels as keys
|
||||||
|
if (hasAnonymousLabel) {
|
||||||
|
// variable/function declarations that don't return patterns are allowed,
|
||||||
|
// but anonymous pattern blocks pose an issue for block-based evaluation
|
||||||
|
// if an anonymous pattern is evaluated multiple times it will just stack and get louder and louder
|
||||||
|
|
||||||
|
// it's very common for users to write code prefixed with '$'
|
||||||
|
// but to modify and override existing patterns, the patterns must be labeled,
|
||||||
|
// otherwise we'll have no idea of which pattern is being overridden
|
||||||
|
|
||||||
|
// (we probably need to update the docs on this)
|
||||||
|
// we could easily enable it, but it would confuse a lot of people
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'anonymous labels disabled for block based evaluation (see https://strudel.cc/blog/#label-notation)',
|
||||||
|
);
|
||||||
|
} else if (labels.length > 0) {
|
||||||
|
for (let i = 0; i < labels.length; i++) {
|
||||||
|
// processing transpiler output instead of code is simply to avoid
|
||||||
|
// extra regex in detecting whether or not an inline widget has been commented out
|
||||||
|
processLabeledBlock(labels, i, meta.output, options, meta);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Declaration block (variable/function that returns silence)
|
||||||
|
// Store it so its miniLocations are preserved for highlighting patterns stored in variables
|
||||||
|
handleDeclarationBlock(code, options, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.miniLocations = collectFromBlocks('miniLocations');
|
||||||
|
meta.widgets = collectFromBlocks('widgets');
|
||||||
|
meta.sliders = collectFromBlocks('sliders');
|
||||||
|
|
||||||
|
// Track activeVisualizer cleanup: check if any block's visualizer was removed
|
||||||
|
const blocksToUpdate = labels.map((label) => label.name);
|
||||||
|
|
||||||
|
// this is the hackiest bit
|
||||||
|
for (const [key, block] of Object.entries(codeBlocks)) {
|
||||||
|
if (blocksToUpdate.includes(key)) {
|
||||||
|
// This block was just updated
|
||||||
|
if (block.activeVisualizer !== null) {
|
||||||
|
// Block now has a visualizer, update tracking
|
||||||
|
lastActiveVisualizerLabel = key;
|
||||||
|
} else if (lastActiveVisualizerLabel === key) {
|
||||||
|
// This block lost its visualizer, trigger cleanup
|
||||||
|
widgetRemoved = true;
|
||||||
|
lastActiveVisualizerLabel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pPatterns = Object.fromEntries(
|
||||||
|
Object.entries(pPatterns).filter(([key]) => {
|
||||||
|
return Object.keys(codeBlocks).includes(key);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
pattern = applyPatternTransforms(pattern);
|
||||||
|
|
||||||
|
logger(`[eval] code updated`);
|
||||||
|
pattern = await setPattern(pattern, autostart);
|
||||||
|
updateState({
|
||||||
|
miniLocations: meta?.miniLocations || [],
|
||||||
|
widgets: meta?.widgets || [],
|
||||||
|
sliders: meta?.sliders || [],
|
||||||
|
activeCode: code,
|
||||||
|
pattern,
|
||||||
|
evalError: undefined,
|
||||||
|
schedulerError: undefined,
|
||||||
|
pending: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved });
|
||||||
|
return pattern;
|
||||||
|
} catch (err) {
|
||||||
|
logger(`[eval] error: ${err.message}`, 'error');
|
||||||
|
console.error(err);
|
||||||
|
updateState({ evalError: err, pending: false });
|
||||||
|
onEvalError?.(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setCode = (code) => updateState({ code });
|
const setCode = (code) => updateState({ code });
|
||||||
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTrigger =
|
export const getTrigger =
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export const run = (n) => saw.range(0, n).round().segment(n);
|
|||||||
* // "hh".s().struct("1 0 1")
|
* // "hh".s().struct("1 0 1")
|
||||||
*/
|
*/
|
||||||
export const binary = (n) => {
|
export const binary = (n) => {
|
||||||
const nBits = reify(n).log2(0).floor().add(1);
|
const nBits = reify(n).log2().floor().add(1);
|
||||||
return binaryN(n, nBits);
|
return binaryN(n, nBits);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -348,7 +348,7 @@ export const binaryN = (n, nBits = 16) => {
|
|||||||
* .partials(binaryL(irand(4096).add(1)))
|
* .partials(binaryL(irand(4096).add(1)))
|
||||||
*/
|
*/
|
||||||
export const binaryL = (n) => {
|
export const binaryL = (n) => {
|
||||||
const nBits = reify(n).log2(0).floor().add(1);
|
const nBits = reify(n).log2().floor().add(1);
|
||||||
return binaryNL(n, nBits);
|
return binaryNL(n, nBits);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -389,7 +389,9 @@ export const randL = (n) => {
|
|||||||
export const randrun = (n) => {
|
export const randrun = (n) => {
|
||||||
return signal((t, controls) => {
|
return signal((t, controls) => {
|
||||||
// Without adding 0.5, the first cycle is always 0,1,2,3,...
|
// Without adding 0.5, the first cycle is always 0,1,2,3,...
|
||||||
const rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
|
let rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
|
||||||
|
// Support n = 1
|
||||||
|
if (!Array.isArray(rands)) rands = [rands];
|
||||||
const nums = rands
|
const nums = rands
|
||||||
.map((n, i) => [n, i])
|
.map((n, i) => [n, i])
|
||||||
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
|
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import Fraction from 'fraction.js';
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
|
import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
|
||||||
import { fastcat, sequence, State, TimeSpan, Hap } from '../index.mjs';
|
import { fastcat, sequence, State, TimeSpan, Hap, note } from '../index.mjs';
|
||||||
|
|
||||||
const st = (begin, end) => new State(ts(begin, end));
|
const st = (begin, end) => new State(ts(begin, end));
|
||||||
const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end));
|
const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end));
|
||||||
@@ -59,3 +59,9 @@ describe('perx', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('shuffle', () => {
|
||||||
|
it('returns original pattern if input is 1', () => {
|
||||||
|
expect(note('c d e f').sound('piano').shuffle(1).firstCycle()).toEqual(note('c d e f').sound('piano').firstCycle());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -339,8 +339,13 @@ export function uniqsortr(a) {
|
|||||||
|
|
||||||
export function unicodeToBase64(text) {
|
export function unicodeToBase64(text) {
|
||||||
const utf8Bytes = new TextEncoder().encode(text);
|
const utf8Bytes = new TextEncoder().encode(text);
|
||||||
const base64String = btoa(String.fromCharCode(...utf8Bytes));
|
let binaryString = '';
|
||||||
return base64String;
|
const chunkSize = 0x8000;
|
||||||
|
for (let i = 0; i < utf8Bytes.length; i += chunkSize) {
|
||||||
|
const chunk = utf8Bytes.subarray(i, i + chunkSize);
|
||||||
|
binaryString += String.fromCharCode.apply(null, chunk);
|
||||||
|
}
|
||||||
|
return btoa(binaryString);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function base64ToUnicode(base64String) {
|
export function base64ToUnicode(base64String) {
|
||||||
|
|||||||
@@ -78,6 +78,16 @@ 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) {
|
||||||
|
|||||||
@@ -54,11 +54,42 @@ export function pitchwheel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (edo) {
|
if (edo) {
|
||||||
|
edo = haps.length >= 1 && haps[0].value && haps[0].value.edo ? haps[0].value.edo : edo;
|
||||||
|
root = haps.length >= 1 && haps[0].value && haps[0].value.root ? haps[0].value.root : root;
|
||||||
|
const degreeIndexes =
|
||||||
|
haps.length >= 1 && haps[0].value && haps[0].value.degreeIndexes ? haps[0].value.degreeIndexes : null;
|
||||||
|
const intLabels = haps.length >= 1 && haps[0].value && haps[0].value.intLabels ? haps[0].value.intLabels : null;
|
||||||
|
ctx.font = '20px sans-serif';
|
||||||
|
const label = `${edo} EDO`;
|
||||||
|
// Draw EDO label:
|
||||||
|
ctx.fillText(label, centerX + radius - ctx.measureText(label).width + 15, centerY + radius);
|
||||||
Array.from({ length: edo }, (_, i) => {
|
Array.from({ length: edo }, (_, i) => {
|
||||||
const angle = freq2angle(root * Math.pow(2, i / edo), root);
|
const angle = freq2angle(root * Math.pow(2, i / edo), root);
|
||||||
|
|
||||||
const [x, y] = circlePos(centerX, centerY, radius, angle);
|
const [x, y] = circlePos(centerX, centerY, radius, angle);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
|
// Draw interval label for degree i when it exists:
|
||||||
|
if (degreeIndexes === null || degreeIndexes.includes(i)) {
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
|
||||||
|
if (intLabels !== null) {
|
||||||
|
const degree = degreeIndexes.indexOf(i);
|
||||||
|
if (intLabels[degree]) {
|
||||||
|
if (angle < 0.32 && angle > 0.125) {
|
||||||
|
ctx.fillText(intLabels[degree], x - 34, y);
|
||||||
|
} else {
|
||||||
|
if (angle < 0.1 && angle > -1.125) {
|
||||||
|
ctx.fillText(intLabels[degree], x - 7, y - 12);
|
||||||
|
} else {
|
||||||
|
ctx.fillText(intLabels[degree], x + 9, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.globalAlpha = 0.15;
|
||||||
|
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
|
||||||
|
}
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
});
|
});
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# @strudel/edo
|
||||||
|
|
||||||
|
This package adds EDO scale functions to strudel Patterns.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i @strudel/edo --save
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { n } from '@strudel/core';
|
||||||
|
import '@strudel/edo';
|
||||||
|
|
||||||
|
// E.g. edoScale for Gorgo-6 scale, 16 EDO, LLsLLLs
|
||||||
|
// base note C3, large step size 3, small step size 1:
|
||||||
|
// C3:LLsLLL:3:1
|
||||||
|
const [baseNote, sequence, largeStep, smallStep] = ['C3', 'LLsLLL', 3, 1]
|
||||||
|
const pattern = n("0 2 4 6 4 2").edoScale([baseNote, sequence, largeStep, smallStep]);
|
||||||
|
|
||||||
|
const events = pattern.firstCycle().map((e) => e.show());
|
||||||
|
console.log(events);
|
||||||
|
```
|
||||||
|
|
||||||
|
yields:
|
||||||
|
```
|
||||||
|
[
|
||||||
|
"[ 0/1 → 1/1 |
|
||||||
|
{
|
||||||
|
\"degree\":1,
|
||||||
|
\"degreeIndexes\":[0,3,6,7,10,13],
|
||||||
|
\"intLabels\":[null,\"S2\",\"d4\",\"N4\",\"s6\",\"s7\",\"P8\"],
|
||||||
|
\"root\":\"130.8128\",
|
||||||
|
\"freq\":130.813,
|
||||||
|
\"edo\":16
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
]
|
||||||
|
```
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
edo.mjs - Equal division of the octave (EDO) scale functions for strudel
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/edo.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 { register, pure, noteToMidi, isNote, tokenizeNote } from '@strudel/core';
|
||||||
|
import { EdoScale } from './edoscale.mjs';
|
||||||
|
import { Intervals } from './intervals.mjs';
|
||||||
|
import { Pitches } from './pitches.mjs';
|
||||||
|
|
||||||
|
const pitchesCache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns numbers into notes in the given EDO scale (zero indexed).
|
||||||
|
*
|
||||||
|
* An EDO scale definition looks like this:
|
||||||
|
*
|
||||||
|
* e.g. C:LLsLLLs:2:1 <- this is the C major scale, 12 EDO
|
||||||
|
*
|
||||||
|
* e.g. C:LLsLLL:3:1 <- this is the Gorgo 6 note scale, 16 EDO
|
||||||
|
*
|
||||||
|
* An EDO scale, e.g. C:LLsLLLs:2:1, consists of a root note (e.g. C)
|
||||||
|
* followed by semicolon (':')
|
||||||
|
* and then a [Large/small step notation sequence](https://en.xen.wiki/w/MOS_scale)
|
||||||
|
* (e.g. LLsLLLs)
|
||||||
|
* followed by semicolon, then the large step size (e.g. 2)
|
||||||
|
* followed by semicolon, then the small step size (e.g. 1).
|
||||||
|
*
|
||||||
|
* The number of divisions of the octave is calculated as the sum
|
||||||
|
* of the steps in the EDO scale definition.
|
||||||
|
*
|
||||||
|
* e.g. C:LLsLLLs:2:1 is 2+2+1+2+2+2+1 = 12 EDO, 7 note scale
|
||||||
|
*
|
||||||
|
* e.g. C:LLsLLL:3:1 is 3+3+1+3+3+3 = 16 EDO, 6 note scale
|
||||||
|
*
|
||||||
|
* The root note defaults to octave 3, if no octave number is given.
|
||||||
|
*
|
||||||
|
* @name edoScale
|
||||||
|
* @param {string} scale Definition of EDO scale.
|
||||||
|
* @returns Pattern
|
||||||
|
* @example
|
||||||
|
* n("0 2 4 6 4 2").edoScale("C:LLsLLLs:2:1")
|
||||||
|
* @example
|
||||||
|
* n("[0,7] 4 [2,7] 4")
|
||||||
|
* .edoScale("G2:<LLsLLL LLLLsL>:3:1")
|
||||||
|
* .s("piano")._pitchwheel()
|
||||||
|
* @example
|
||||||
|
* n(rand.range(0,5).segment(6))
|
||||||
|
* .edoScale("<G2 C3>:LLsLL:3:1")
|
||||||
|
* .s("piano")._pitchwheel()
|
||||||
|
*/
|
||||||
|
export const edoScale = register(
|
||||||
|
'edoScale',
|
||||||
|
function (scaleDefinition, pat) {
|
||||||
|
// console.log(scaleDefinition);
|
||||||
|
|
||||||
|
// if (Array.isArray(scale)) {
|
||||||
|
const key = scaleDefinition.flat().join(':');
|
||||||
|
// }
|
||||||
|
// console.log(scaleDefinition);
|
||||||
|
let pitches;
|
||||||
|
if (pitchesCache.has(key)) {
|
||||||
|
pitches = pitchesCache.get(key);
|
||||||
|
} else {
|
||||||
|
// console.log({ key });
|
||||||
|
const [base_note, sequence, large, small] = scaleDefinition;
|
||||||
|
const root_octave = tokenizeNote(base_note)[2] || 3;
|
||||||
|
// console.log({ root_octave });
|
||||||
|
const scale = new EdoScale(large, small, sequence);
|
||||||
|
const intervals = new Intervals(scale);
|
||||||
|
// console.log({ intervals });
|
||||||
|
pitches = new Pitches(scale, intervals, 440, noteToMidi(base_note), root_octave);
|
||||||
|
pitchesCache.set(key, pitches);
|
||||||
|
// console.log({ base_note: noteToMidi(base_note) });
|
||||||
|
// console.log({ sequence });
|
||||||
|
// console.log({ edivisions: scale.edivisions });
|
||||||
|
// console.log({ intervals });
|
||||||
|
// console.log({ pat });
|
||||||
|
// console.log({ pitches: pitches });
|
||||||
|
}
|
||||||
|
return pat
|
||||||
|
.fmap((value) => {
|
||||||
|
const isObject = typeof value === 'object';
|
||||||
|
const n = isObject ? value.n : value;
|
||||||
|
if (isObject) {
|
||||||
|
delete value.n; // remove n so it won't cause trouble
|
||||||
|
}
|
||||||
|
if (isNote(n)) {
|
||||||
|
// legacy..
|
||||||
|
return pure(n);
|
||||||
|
}
|
||||||
|
const deg = (typeof n === 'string' ? parseInt(n, 10) : Number.isInteger(n) ? n : Math.round(n)) + 1;
|
||||||
|
|
||||||
|
const [oct, degree] = pitches.octdeg(deg);
|
||||||
|
const freq = pitches.octdegfreq(oct, degree);
|
||||||
|
const note = pitches.octdegmidi(oct, degree);
|
||||||
|
const edo = pitches.scale.edivisions;
|
||||||
|
const root = pitches.base_freq;
|
||||||
|
const degreeIndexes = pitches.scale.divisions;
|
||||||
|
const intLabels = pitches.intervals.intLabels;
|
||||||
|
// const color = 'red';
|
||||||
|
value = pure(isObject ? { ...value, degree, degreeIndexes, intLabels, root, freq, edo } : note);
|
||||||
|
// value = pure(isObject ? { ...value, key } : note);
|
||||||
|
// value = pure(isObject ? { ...value, edo } : note);
|
||||||
|
// console.log({ value });
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
.outerJoin()
|
||||||
|
.withHap((hap) => hap.setContext({ ...hap.context, scaleDefinition }));
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
true, // preserve tactus
|
||||||
|
);
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/*
|
||||||
|
edoscale.mjs - EdoScale defines equal division of the octave (EDO) scale in Ls notation
|
||||||
|
- Port of pitfalls/lib/Scale.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Scale.lua>
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/edoscale.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/>.
|
||||||
|
*/
|
||||||
|
const M = 2;
|
||||||
|
const L = 1;
|
||||||
|
const S = 0;
|
||||||
|
const LABELS = ['s', 'L', 'M'];
|
||||||
|
|
||||||
|
export class EdoScale {
|
||||||
|
constructor(large, small, sequence, medium) {
|
||||||
|
this.stepbackup = [L, L, S, L, L, L, S, L, L, L, S, L, L, L, S, L];
|
||||||
|
this.large = large;
|
||||||
|
this.medium = medium || large;
|
||||||
|
this.small = small;
|
||||||
|
this.divisions = [];
|
||||||
|
this.edivisions = null;
|
||||||
|
this.sequence = null;
|
||||||
|
this.tonic = 1;
|
||||||
|
this.mode = 1;
|
||||||
|
this.max_steps = 12;
|
||||||
|
this.min_steps = 3;
|
||||||
|
this.setSequence(sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMedium() {
|
||||||
|
return this.step.some((_, i) => this.step[this.offset(i)] === M);
|
||||||
|
}
|
||||||
|
|
||||||
|
stepSize(i) {
|
||||||
|
return LABELS[this.step[this.offset(i)]];
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence() {
|
||||||
|
return this.step.map((_, i) => this.stepSize(i)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
stepValue(i) {
|
||||||
|
const step = this.step[this.offset(i)];
|
||||||
|
return step === L ? this.large : step === M ? this.medium : this.small;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset(i) {
|
||||||
|
if (this.mode === 1) {
|
||||||
|
return i;
|
||||||
|
} else {
|
||||||
|
const offset = (this.mode - 1 + i) % this.length;
|
||||||
|
return offset === 0 ? this.length : offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static setMaxSteps(max) {
|
||||||
|
this.max_steps = max;
|
||||||
|
}
|
||||||
|
|
||||||
|
static setMinSteps(min) {
|
||||||
|
this.min_steps = min;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSequence(sequence) {
|
||||||
|
if (this.sequence !== sequence) {
|
||||||
|
this.sequence = sequence;
|
||||||
|
this.length = sequence.length;
|
||||||
|
this.step = [];
|
||||||
|
for (let i = 0; i < sequence.length; i++) {
|
||||||
|
const char = sequence[i];
|
||||||
|
this.step[i] = char === 'L' ? L : char === 'M' ? M : S;
|
||||||
|
}
|
||||||
|
this.updateEdo();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLarge(l) {
|
||||||
|
if (this.large !== l) {
|
||||||
|
this.large = l;
|
||||||
|
this.updateEdo();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMedium(m) {
|
||||||
|
if (this.medium !== m) {
|
||||||
|
this.medium = m;
|
||||||
|
this.updateEdo();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSmall(s) {
|
||||||
|
if (this.small !== s) {
|
||||||
|
this.small = s;
|
||||||
|
this.updateEdo();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode) {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTonic(tonic) {
|
||||||
|
this.tonic = tonic;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeMode(d) {
|
||||||
|
const orig = this.mode;
|
||||||
|
this.mode = Math.max(1, Math.min(this.mode + d, this.length));
|
||||||
|
return orig !== this.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeTonic(d) {
|
||||||
|
const orig = this.tonic;
|
||||||
|
this.tonic = Math.max(1, Math.min(this.tonic + d, this.edivisions));
|
||||||
|
return orig !== this.tonic;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEdo() {
|
||||||
|
const orig = this.edivisions;
|
||||||
|
this.edivisions = this.step.reduce((sum, _, i) => {
|
||||||
|
this.divisions[i] = sum;
|
||||||
|
return sum + this.stepValue(i);
|
||||||
|
}, 0);
|
||||||
|
// console.log(this.divisions);
|
||||||
|
const changed = orig !== this.edivisions;
|
||||||
|
if (changed) {
|
||||||
|
this.tonic = Math.max(1, Math.min(this.tonic, this.edivisions));
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeStep(d, i) {
|
||||||
|
const index = this.offset(i);
|
||||||
|
const orig = this.step[index];
|
||||||
|
this.step[index] = Math.max(S, Math.min(this.step[index] + d, M));
|
||||||
|
this.stepbackup[index] = this.step[index];
|
||||||
|
const changed = orig !== this.step[index];
|
||||||
|
if (changed) {
|
||||||
|
this.updateEdo();
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeLarge(d) {
|
||||||
|
const orig = this.large;
|
||||||
|
this.setLarge(Math.max(this.small + 1, Math.min(this.large + d, this.large + 1)));
|
||||||
|
const changed = this.large !== orig;
|
||||||
|
if (changed) {
|
||||||
|
if (this.large <= this.medium) {
|
||||||
|
this.setMedium(Math.max(this.small + 1, Math.min(this.large - 1, this.large)));
|
||||||
|
}
|
||||||
|
this.updateEdo();
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeMedium(d) {
|
||||||
|
const orig = this.medium;
|
||||||
|
this.setMedium(Math.max(this.small + 1, Math.min(this.medium + d, this.large - 1)));
|
||||||
|
const changed = this.medium !== orig;
|
||||||
|
if (changed) {
|
||||||
|
this.updateEdo();
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeSmall(d) {
|
||||||
|
const orig = this.small;
|
||||||
|
const value = this.small + d;
|
||||||
|
|
||||||
|
if (this.hasMedium()) {
|
||||||
|
this.setSmall(Math.max(1, Math.min(value, this.medium - 1)));
|
||||||
|
} else {
|
||||||
|
this.setSmall(Math.max(1, Math.min(value, this.large - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = this.small !== orig;
|
||||||
|
if (changed) {
|
||||||
|
if (this.small >= this.medium) {
|
||||||
|
this.setMedium(Math.max(this.small + 1, Math.min(this.large)));
|
||||||
|
}
|
||||||
|
this.updateEdo();
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeLength(d) {
|
||||||
|
const orig = this.length;
|
||||||
|
if (d === 1) {
|
||||||
|
this.length = Math.min(this.length + 1, this.max_steps);
|
||||||
|
} else if (d === -1) {
|
||||||
|
this.length = Math.max(this.length - 1, this.min_steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = this.length !== orig;
|
||||||
|
if (changed) {
|
||||||
|
this.mode = 1;
|
||||||
|
if (d === 1) {
|
||||||
|
this.step[this.length] = this.stepbackup[this.length] || L;
|
||||||
|
} else if (d === -1 && this.length >= this.min_steps) {
|
||||||
|
this.step.pop();
|
||||||
|
}
|
||||||
|
this.updateEdo();
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import './edo.mjs';
|
||||||
|
|
||||||
|
export * from './edo.mjs';
|
||||||
|
|
||||||
|
export const packageName = '@strudel/edo';
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
intervals.mjs - defines Intervals for equal division of the octave (EDO) scale
|
||||||
|
- Port of pitfalls/lib/Intervals.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Intervals.lua>
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/intervals.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 ratiointervals from './ratios.mjs';
|
||||||
|
|
||||||
|
function ratio(division, edivisions) {
|
||||||
|
return division === 0 ? 1 : Math.pow(2, division / edivisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Intervals {
|
||||||
|
constructor(scale) {
|
||||||
|
this.scale = scale;
|
||||||
|
this.intLabels = [];
|
||||||
|
this.intNoms = [];
|
||||||
|
this.intRatios = [];
|
||||||
|
this.uniqLabels = [];
|
||||||
|
this.intErrors = [];
|
||||||
|
this.ratios = [];
|
||||||
|
|
||||||
|
const BLANK = '';
|
||||||
|
let division = 0;
|
||||||
|
const labToErr = {};
|
||||||
|
const labToInd = {};
|
||||||
|
|
||||||
|
this.ratios[0] = 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < scale.length; i++) {
|
||||||
|
division += scale.stepValue(i);
|
||||||
|
this.ratios[i + 1] = ratio(division, scale.edivisions);
|
||||||
|
|
||||||
|
if (i < scale.length) {
|
||||||
|
const nearest = ratiointervals.nearestInterval(this.ratios[i + 1]);
|
||||||
|
const closeness = nearest[0];
|
||||||
|
const ratio = nearest[1];
|
||||||
|
const intLabel = ratiointervals.key(ratio);
|
||||||
|
this.intLabels[i + 1] = intLabel;
|
||||||
|
this.intErrors[i + 1] = closeness;
|
||||||
|
this.intNoms[i + 1] = ratio ? ratiointervals.nom(ratio) : 0;
|
||||||
|
this.intRatios[i + 1] = ratio ? `${ratiointervals.nom(ratio)}/${ratiointervals.denom(ratio)}` : '';
|
||||||
|
this.uniqLabels[i + 1] = BLANK;
|
||||||
|
|
||||||
|
if (intLabel && intLabel !== 'P1' && intLabel !== 'P8') {
|
||||||
|
if (!labToErr[intLabel]) {
|
||||||
|
this.uniqLabels[i + 1] = intLabel;
|
||||||
|
labToInd[intLabel] = i + 1;
|
||||||
|
labToErr[intLabel] = closeness;
|
||||||
|
} else if (closeness < labToErr[intLabel]) {
|
||||||
|
this.uniqLabels[labToInd[intLabel]] = BLANK;
|
||||||
|
this.uniqLabels[i + 1] = intLabel;
|
||||||
|
labToInd[intLabel] = i + 1;
|
||||||
|
labToErr[intLabel] = closeness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ratio(i) {
|
||||||
|
return this.ratios[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalLabel(i) {
|
||||||
|
return this.intLabels[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalNominator(i) {
|
||||||
|
return this.intNoms[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalRatio(i) {
|
||||||
|
return this.intRatios[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqIntervalLabel(i) {
|
||||||
|
return this.uniqLabels[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalError(i) {
|
||||||
|
return this.intErrors[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
nearestDegreeTo(r, threshold) {
|
||||||
|
let min = 1;
|
||||||
|
let degree = null;
|
||||||
|
|
||||||
|
for (const [i, v] of Object.entries(this.ratios)) {
|
||||||
|
const diff = Math.abs((r - v) / r);
|
||||||
|
if (diff < min) {
|
||||||
|
min = diff;
|
||||||
|
degree = parseInt(i, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (threshold == null) {
|
||||||
|
return degree;
|
||||||
|
} else {
|
||||||
|
return min < threshold ? degree : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@strudel/edo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Equal division of the octave (EDO) scale functions for strudel",
|
||||||
|
"main": "index.mjs",
|
||||||
|
"publishConfig": {
|
||||||
|
"main": "dist/index.mjs"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://codeberg.org/uzu/strudel.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"tidalcycles",
|
||||||
|
"strudel",
|
||||||
|
"pattern",
|
||||||
|
"livecoding",
|
||||||
|
"algorave"
|
||||||
|
],
|
||||||
|
"author": "Rob McKinnon <rob@movingflow>",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://codeberg.org/uzu/strudel/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"@strudel/core": "workspace:*",
|
||||||
|
"@tonaljs/tonal": "^4.10.0",
|
||||||
|
"chord-voicings": "^0.0.1",
|
||||||
|
"webmidi": "^3.1.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.0.11",
|
||||||
|
"vitest": "^3.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
pitches.mjs - defines Pitches for equal division of the octave (EDO) scale
|
||||||
|
- Port of pitfalls/lib/Pitches.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Pitches.lua>
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/pitches.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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function ratio(division, edivisions) {
|
||||||
|
return division === 0 ? 1 : Math.pow(2, division / edivisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function gets frequency based on index, edo, octave, and base frequency
|
||||||
|
function get_freq(base_freq, edo, index, oct, base_octave) {
|
||||||
|
let f = base_freq * ratio(index - 1, edo);
|
||||||
|
if (oct < base_octave) {
|
||||||
|
f /= Math.pow(2, base_octave - oct);
|
||||||
|
} else if (oct > base_octave) {
|
||||||
|
f *= Math.pow(2, oct - base_octave);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert MIDI number to Hz using a given tuning multiplier
|
||||||
|
function midi_to_hz(n, tuning) {
|
||||||
|
return tuning * Math.pow(2, (n - 69) / 12.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const denom = Math.log(2);
|
||||||
|
function hz_to_midi(freq, tuning) {
|
||||||
|
return 12.0 * (Math.log(freq / tuning) / denom) + 69;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Pitches {
|
||||||
|
constructor(scale, intervals, tuning, midi_start, root_octave) {
|
||||||
|
this.scale = scale;
|
||||||
|
this.intervals = intervals;
|
||||||
|
this.base_freq = midi_to_hz(midi_start, tuning);
|
||||||
|
this.root_octave = root_octave;
|
||||||
|
this.freqs = {};
|
||||||
|
this.midis = {};
|
||||||
|
this.degrees = {};
|
||||||
|
this.octdegfreqs = {};
|
||||||
|
this.octdegmidis = {};
|
||||||
|
let index = 0;
|
||||||
|
let f = null;
|
||||||
|
|
||||||
|
for (let oct = 0; oct <= 8; oct++) {
|
||||||
|
this.octdegfreqs[oct] = {};
|
||||||
|
this.octdegmidis[oct] = {};
|
||||||
|
f = get_freq(this.base_freq, scale.edivisions, scale.tonic, oct, this.root_octave);
|
||||||
|
for (let deg = 0; deg < scale.length; deg++) {
|
||||||
|
index = index + 1;
|
||||||
|
this.freqs[index] = parseFloat((f * intervals.ratio(deg)).toFixed(3));
|
||||||
|
this.midis[index] = parseFloat(hz_to_midi(f * intervals.ratio(deg), tuning).toFixed(4));
|
||||||
|
this.degrees[index] = deg;
|
||||||
|
this.octdegfreqs[oct][deg + 1] = this.freqs[index];
|
||||||
|
this.octdegmidis[oct][deg + 1] = this.midis[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.base_freq = parseFloat(this.base_freq).toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
base_freq() {
|
||||||
|
return this.base_freq;
|
||||||
|
}
|
||||||
|
|
||||||
|
degree(index) {
|
||||||
|
return this.degrees[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
freq(index) {
|
||||||
|
return this.freqs[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
octdeg(deg) {
|
||||||
|
const higherOcatve = deg > this.scale.length;
|
||||||
|
const octave = this.root_octave + (higherOcatve ? Math.floor((deg - 1) / this.scale.length) : 0);
|
||||||
|
const degree = higherOcatve ? (deg % this.scale.length === 0 ? this.scale.length : deg % this.scale.length) : deg;
|
||||||
|
// console.log([octave, degree]);
|
||||||
|
return [octave, degree];
|
||||||
|
}
|
||||||
|
|
||||||
|
octdegfreq(oct, deg) {
|
||||||
|
if (this.octdegfreqs[oct]) {
|
||||||
|
return this.octdegfreqs[oct][deg];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
octdegmidi(oct, deg) {
|
||||||
|
if (this.octdegmidis[oct]) {
|
||||||
|
return this.octdegmidis[oct][deg];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
ratios.mjs - lists whole number ratios for pitch intervals
|
||||||
|
- Port of pitfalls/lib/ratios.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/ratios.lua>
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/ratios.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/>.
|
||||||
|
*/
|
||||||
|
const ratiointervals = {};
|
||||||
|
|
||||||
|
// FJS Calculators - https://misotanni.github.io/fjs/en/calc.html
|
||||||
|
// List of pitch intervals - https://en.wikipedia.org/wiki/List_of_pitch_intervals
|
||||||
|
// Gallery of just intervals - https://en.xen.wiki/w/Gallery_of_just_intervals
|
||||||
|
// Two letter codes changed to make different interval labels more unique.
|
||||||
|
ratiointervals.list = new Map([
|
||||||
|
[1, ['P1', 'P1', 'P1', 1, 1]], // unison P1
|
||||||
|
[16 / 15, ['m2', 'm2', 'm2_5', 16, 15]], // minor second m2
|
||||||
|
[15 / 14, ['A1', 'A1', 'A1^5_7', 15, 14]], // augmented unison
|
||||||
|
[13 / 12, ['t2', 'm2', 'm2^13', 13, 12]], // tridecimal neutral second
|
||||||
|
[12 / 11, ['N2', 'M2', 'M2_11', 12, 11]], // undecimal neutral second
|
||||||
|
[11 / 10, ['n2', 'm2', 'm2^11_5', 11, 10]], // undecimal submajor second
|
||||||
|
[10 / 9, ['T2', 'M2', 'M2^5', 10, 9]], // classic (whole) tone
|
||||||
|
[9 / 8, ['M2', 'M2', 'M2', 9, 8]], // major second M2
|
||||||
|
[8 / 7, ['S2', 'M2', 'M2_7', 8, 7]], // septimal major second
|
||||||
|
[7 / 6, ['s3', 'm3', 'm3^7', 7, 6]], // septimal minor third
|
||||||
|
[19 / 16, ['o3', 'm3', 'm3^19', 19, 16]], // otonal minor third
|
||||||
|
[6 / 5, ['m3', 'm3', 'm3_5', 6, 5]], // minor third m3
|
||||||
|
[17 / 14, ['t3', 'm3', 'm3^17_7', 17, 14]], // septendecimal supraminor third
|
||||||
|
[11 / 9, ['n3', 'm3', 'm3^11', 11, 9]], // undecimal neutral third
|
||||||
|
[5 / 4, ['M3', 'M3', 'M3^5', 5, 4]], // major third M3
|
||||||
|
[9 / 7, ['S3', 'M3', 'M3_7', 9, 7]], // septimal major third SM3
|
||||||
|
[13 / 10, ['d4', 'd4', 'd4^13_5', 13, 10]], // Barbados third
|
||||||
|
[4 / 3, ['P4', 'P4', 'P4', 4, 3]], // perfect fourth P4
|
||||||
|
[19 / 14, ['N4', 'P4', 'P4^19_7', 19, 14]], // undevicesimal wide fourth
|
||||||
|
[11 / 8, ['n4', 'P4', 'P4^11', 11, 8]], // super-fourth
|
||||||
|
[25 / 18, ['a4', 'A4', 'A4^5,5', 25, 18]], // classic augmented fourth
|
||||||
|
[7 / 5, ['sT', 'd5', 'd5^7_5', 7, 5]], // lesser septimal tritone
|
||||||
|
[45 / 32, ['A4', 'A4', 'A4^5', 45, 32]], // just augmented fourth
|
||||||
|
[17 / 12, ['d5', 'd5', 'd5^17', 17, 12]], // larger septendecimal tritone
|
||||||
|
[10 / 7, ['ST', 'A4', 'A4^5_7', 10, 7]], // greater septimal tritone
|
||||||
|
[13 / 9, ['t5', 'd5', 'd5^13', 13, 9]], // tridecimal diminished fifth
|
||||||
|
[3 / 2, ['P5', 'P5', 'P5', 3, 2]], // perfect fifth P5
|
||||||
|
[14 / 9, ['s6', 'M6', 'm6^7', 14, 9]], // subminor sixth or septimal sixth
|
||||||
|
[25 / 16, ['a5', 'A5', 'A5^5,5', 25, 16]], // classic augmented fifth
|
||||||
|
[11 / 7, ['A5', 'P5', 'P5^11_7', 11, 7]], // undecimal minor sixth
|
||||||
|
[8 / 5, ['m6', 'm6', 'm6_5', 8, 5]], // minor sixth m6
|
||||||
|
[13 / 8, ['N6', 'm6', 'm6^13', 13, 8]], // tridecimal neutral sixth
|
||||||
|
[18 / 11, ['n6', 'M6', 'M6_11', 18, 11]], // undecimal neutral sixth
|
||||||
|
[5 / 3, ['M6', 'M6', 'M6^5', 5, 3]], // just major sixth M6
|
||||||
|
[128 / 75, ['d7', 'd7', 'd7_5,5', 128, 75]], // diminished seventh
|
||||||
|
[17 / 10, ['T6', 'd7', 'd7^17_5', 17, 10]], // septendecimal diminished seventh
|
||||||
|
[12 / 7, ['S6', 'M6', 'M6_7', 12, 7]], // septimal major sixth
|
||||||
|
[7 / 4, ['s7', 'm7', 'm7^7', 7, 4]], // septimal minor seventh
|
||||||
|
[16 / 9, ['m7', 'm7', 'm7', 16, 9]], // lesser minor seventh
|
||||||
|
[9 / 5, ['g7', 'm7', 'm7_5', 9, 5]], // greater just minor seventh
|
||||||
|
[11 / 6, ['n7', 'm7', 'm7^11', 11, 6]], // undecimal neutral seventh
|
||||||
|
[13 / 7, ['N7', 'm7', 'm7^13_7', 13, 7]], // tridecimal neutral seventh
|
||||||
|
[15 / 8, ['M7', 'M7', 'M7^5', 15, 8]], // major seventh
|
||||||
|
[17 / 9, ['T7', 'd8', 'd8^17', 17, 9]], // large septendecimal major seventh
|
||||||
|
[19 / 10, ['d8', 'd8', 'd8^19_5', 19, 10]], // large undevicesimal major seventh
|
||||||
|
[2, ['P8', 'P8', 'P8', 2, 1]], // octave P8
|
||||||
|
]);
|
||||||
|
|
||||||
|
ratiointervals.key = function (ratio) {
|
||||||
|
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[0] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
ratiointervals.label = function (ratio) {
|
||||||
|
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[1] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
ratiointervals.fjs = function (ratio) {
|
||||||
|
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[2] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
ratiointervals.nom = function (ratio) {
|
||||||
|
return ratio == null ? null : ratiointervals.list.get(ratio)?.[3] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
ratiointervals.denom = function (ratio) {
|
||||||
|
return ratio == null ? null : ratiointervals.list.get(ratio)?.[4] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
ratiointervals.nearestInterval = function (v) {
|
||||||
|
let min = 1;
|
||||||
|
let match = null;
|
||||||
|
for (const [ratio, _labels] of ratiointervals.list) {
|
||||||
|
const diff = Math.abs((ratio - v) / ratio);
|
||||||
|
if (diff < min) {
|
||||||
|
min = diff;
|
||||||
|
match = ratio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return min < 0.01 ? [min, match] : [null, null];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ratiointervals;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
edo.test.mjs - tests of edo.mjs
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/tonal/test/tonal.test.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 '../edo.mjs';
|
||||||
|
import { n } from '@strudel/core';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('edoScale', () => {
|
||||||
|
it('Should run tonal functions ', () => {
|
||||||
|
// base note A3 = 220Hz
|
||||||
|
let baseNote = 'A3';
|
||||||
|
let root = 220;
|
||||||
|
let freq = 220;
|
||||||
|
let pattern = n('0').edoScale([baseNote, 'LLsLLLs', 2, 1]);
|
||||||
|
let cycle = pattern.firstCycleValues[0];
|
||||||
|
expect(cycle.freq).toEqual(freq);
|
||||||
|
expect(cycle.edo).toEqual(12);
|
||||||
|
expect(cycle.degree).toEqual(1);
|
||||||
|
expect(parseFloat(cycle.root).toFixed(0)).toEqual(root.toFixed(0));
|
||||||
|
|
||||||
|
// base note A4 = 440Hz
|
||||||
|
baseNote = 'A4';
|
||||||
|
root = 440;
|
||||||
|
freq = 880;
|
||||||
|
pattern = n('7').edoScale([baseNote, 'LLsLLLs', 2, 1]);
|
||||||
|
cycle = pattern.firstCycleValues[0];
|
||||||
|
expect(cycle.freq).toEqual(freq);
|
||||||
|
expect(cycle.edo).toEqual(12);
|
||||||
|
expect(cycle.degree).toEqual(1);
|
||||||
|
expect(parseFloat(cycle.root).toFixed(0)).toEqual(root.toFixed(0));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
edoscale.test.mjs - tests of EdoScale from edoscale.mjs
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/test/edoscale.test.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 '../edoscale.mjs'; // need to import this to add prototypes
|
||||||
|
import { EdoScale } from '../edoscale.mjs';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('EdoScale', () => {
|
||||||
|
it('updates edivisions', () => {
|
||||||
|
let scale = new EdoScale(2, 1, ['L', 'L', 's', 'L', 'L', 'L', 's']);
|
||||||
|
expect(scale.stepSize(0)).toEqual('L');
|
||||||
|
expect(scale.stepValue(0)).toEqual(2);
|
||||||
|
|
||||||
|
expect(scale.stepSize(2)).toEqual('s');
|
||||||
|
expect(scale.stepValue(2)).toEqual(1);
|
||||||
|
|
||||||
|
expect(scale.edivisions).toEqual(12);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
ratios.test.mjs - tests of ratios.mjs
|
||||||
|
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/test/ratios.test.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 ratiointervals from '../ratios.mjs';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('ratiointervals', () => {
|
||||||
|
it('updates edivisions', () => {
|
||||||
|
let ratio = 9 / 7;
|
||||||
|
expect(ratiointervals.key(ratio)).toEqual('S3');
|
||||||
|
expect(ratiointervals.label(ratio)).toEqual('M3');
|
||||||
|
expect(ratiointervals.fjs(ratio)).toEqual('M3_7');
|
||||||
|
expect(ratiointervals.nom(ratio)).toEqual(9);
|
||||||
|
expect(ratiointervals.denom(ratio)).toEqual(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { dependencies } from './package.json';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'index.mjs'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: (ext) => ({ es: 'index.mjs' })[ext],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: [...Object.keys(dependencies)],
|
||||||
|
},
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/*
|
||||||
|
input.mjs - MIDI input wrapper
|
||||||
|
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.mjs>
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WebMidi } from 'webmidi';
|
||||||
|
import { logger, ref } from '@strudel/core';
|
||||||
|
import { getDevice } from './util.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIDI input device wrapper that manages connection and reconnection, tracks
|
||||||
|
* persisted CC states, etc. These instances are long-lived and are maintained as singletons
|
||||||
|
* (keyed globally by input string/number).
|
||||||
|
*/
|
||||||
|
export class MidiInput {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string | number} input MIDI device name or index defaulting to 0
|
||||||
|
*/
|
||||||
|
constructor(input) {
|
||||||
|
this.input = input;
|
||||||
|
this.stateKey = typeof input === 'string' ? input : undefined; // Saved state is not tracked for numeric index inputs
|
||||||
|
|
||||||
|
this._refs = {};
|
||||||
|
this._refsByChan = {};
|
||||||
|
|
||||||
|
this._loadAllStates();
|
||||||
|
|
||||||
|
this.initialDevice = this._startDeviceListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation for the cc() factory function tied to this specific input.
|
||||||
|
* @param {number} cc MIDI CC number
|
||||||
|
* @param {number | undefined} chan MIDI channel (1-16) or undefined for all channels
|
||||||
|
*/
|
||||||
|
createCC(cc, chan) {
|
||||||
|
const lookupMap = chan === undefined ? this._refs : this._refsByChan[chan];
|
||||||
|
if (!(cc in lookupMap)) {
|
||||||
|
const initialState = this._loadState(chan);
|
||||||
|
lookupMap[cc] = initialState[cc] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref(() => lookupMap[cc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_startDeviceListener() {
|
||||||
|
const initialDevice = getDevice(this.input, WebMidi.inputs);
|
||||||
|
|
||||||
|
// Background connection loop
|
||||||
|
(async () => {
|
||||||
|
const midiListener = this._onMidiMessage.bind(this);
|
||||||
|
let device = initialDevice;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (!device) {
|
||||||
|
device = await this._waitForDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit for device to be ready to receive last state
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Still continue if sending did not work
|
||||||
|
this._sendAllStates(device);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('midiin: failed to send last state on connect:', device.name, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for incoming MIDI messages and for disconnection
|
||||||
|
device.addListener('midimessage', midiListener);
|
||||||
|
|
||||||
|
await this._waitForDeviceDisconnect(device);
|
||||||
|
|
||||||
|
device.removeListener('midimessage', midiListener);
|
||||||
|
device = null; // Clear var to trigger wait for connection
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return initialDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a promise that resolves when the specified device is connected
|
||||||
|
_waitForDevice() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const connListener = () => {
|
||||||
|
const device = getDevice(this.input, WebMidi.inputs);
|
||||||
|
if (device) {
|
||||||
|
logger(`[midi] device reconnected: ${device.name}`);
|
||||||
|
|
||||||
|
WebMidi.removeListener('connected', connListener);
|
||||||
|
resolve(device);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WebMidi.addListener('connected', connListener);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a promise that resolves when the specified device is disconnected
|
||||||
|
_waitForDeviceDisconnect(device) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const disconnListener = (e) => {
|
||||||
|
if (e.port.name === device.name) {
|
||||||
|
logger(`[midi] device disconnected: ${device.name}`);
|
||||||
|
|
||||||
|
WebMidi.removeListener('disconnected', disconnListener);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WebMidi.addListener('disconnected', disconnListener);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMidiMessage(e) {
|
||||||
|
const ccNum = e.dataBytes[0];
|
||||||
|
const v = e.dataBytes[1];
|
||||||
|
const chan = e.message.channel;
|
||||||
|
const scaled = v / 127;
|
||||||
|
|
||||||
|
this._refs[ccNum] = scaled;
|
||||||
|
this._refsByChan[chan] ??= {};
|
||||||
|
this._refsByChan[chan][ccNum] = scaled;
|
||||||
|
|
||||||
|
this._saveState(undefined, ccNum, scaled);
|
||||||
|
this._saveState(chan, ccNum, scaled);
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadAllStates() {
|
||||||
|
Object.assign(this._refs, this._loadState(undefined));
|
||||||
|
|
||||||
|
for (let chan = 1; chan <= 16; chan++) {
|
||||||
|
this._refsByChan[chan] ??= {};
|
||||||
|
Object.assign(this._refsByChan[chan], this._loadState(chan));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadState(chan) {
|
||||||
|
if (!this.stateKey) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialDataRaw = localStorage.getItem(
|
||||||
|
`strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`,
|
||||||
|
);
|
||||||
|
if (!initialDataRaw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(initialDataRaw);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to parse MIDI state from localStorage for input "${this.stateKey}" and channel "${chan}"`,
|
||||||
|
initialDataRaw,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveState(chan, cc, value) {
|
||||||
|
if (!this.stateKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this._loadState(chan);
|
||||||
|
state[cc] = value;
|
||||||
|
localStorage.setItem(
|
||||||
|
`strudel-midin-${this.stateKey}-chan${chan !== undefined ? chan : 'all'}`,
|
||||||
|
JSON.stringify(state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send CC values back to device to restore encoders and motorized sliders
|
||||||
|
_sendAllStates(device) {
|
||||||
|
const output = WebMidi.outputs.find((o) => o.name === device.name);
|
||||||
|
if (!output) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [chan, refs] of Object.entries(this._refsByChan)) {
|
||||||
|
const channel = Number(chan);
|
||||||
|
for (const [cc, value] of Object.entries(refs)) {
|
||||||
|
const ccn = Number(cc);
|
||||||
|
const scaled = Math.round(value * 127);
|
||||||
|
output.sendControlChange(ccn, scaled, channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-68
@@ -23,6 +23,8 @@ import { noteToMidi, getControlName } from '@strudel/core';
|
|||||||
import { Note } from 'webmidi';
|
import { Note } from 'webmidi';
|
||||||
import { getAudioContext } from '@strudel/webaudio';
|
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;
|
||||||
@@ -31,10 +33,6 @@ 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) {
|
||||||
@@ -72,29 +70,6 @@ export function enableWebMidi(options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDevice(indexOrName, devices) {
|
|
||||||
if (!devices.length) {
|
|
||||||
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
|
|
||||||
}
|
|
||||||
if (typeof indexOrName === 'number') {
|
|
||||||
return devices[indexOrName];
|
|
||||||
}
|
|
||||||
const byName = (name) => devices.find((output) => output.name.includes(name));
|
|
||||||
if (typeof indexOrName === 'string') {
|
|
||||||
return byName(indexOrName);
|
|
||||||
}
|
|
||||||
// attempt to default to first IAC device if none is specified
|
|
||||||
const IACOutput = byName('IAC');
|
|
||||||
const device = IACOutput ?? devices[0];
|
|
||||||
if (!device) {
|
|
||||||
throw new Error(
|
|
||||||
`🔌 MIDI device '${device ? device : ''}' not found. Use one of ${getMidiDeviceNamesString(devices)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return IACOutput ?? devices[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// send start/stop messages to outputs when repl starts/stops
|
// 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) => {
|
||||||
@@ -138,7 +113,7 @@ 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
|
* @tags external_io, midi
|
||||||
* @example
|
* @example
|
||||||
* defaultmidimap({ lpf: 74 })
|
* defaultmidimap({ lpf: 74 })
|
||||||
* $: note("c a f e").midi();
|
* $: note("c a f e").midi();
|
||||||
@@ -152,7 +127,7 @@ let loadCache = {};
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers.
|
* Adds midimaps to the registry. Inside each midimap, control names (e.g. lpf) are mapped to cc numbers.
|
||||||
* @tags external_io
|
* @tags external_io, midi
|
||||||
* @example
|
* @example
|
||||||
* midimaps({ mymap: { lpf: 74 } })
|
* midimaps({ mymap: { lpf: 74 } })
|
||||||
* $: note("c a f e")
|
* $: note("c a f e")
|
||||||
@@ -495,9 +470,9 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize a midi device
|
* Initialize a midi input device
|
||||||
*/
|
*/
|
||||||
async function _initialize(input) {
|
async function _initializeInput(input) {
|
||||||
if (isPattern(input)) {
|
if (isPattern(input)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${
|
`[midi] Midi input cannot be a pattern. Make sure to pass device name with single quotes. Example: midin('${
|
||||||
@@ -505,31 +480,38 @@ async function _initialize(input) {
|
|||||||
}')`,
|
}')`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const initial = await enableWebMidi(); // only returns on first init
|
const initial = await enableWebMidi(); // only returns on first init
|
||||||
const device = getDevice(input, WebMidi.inputs);
|
|
||||||
if (!device) {
|
const instance = midiInputs[input] || new MidiInput(input);
|
||||||
throw new Error(
|
midiInputs[input] = instance;
|
||||||
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (initial) {
|
if (initial) {
|
||||||
|
const device = instance.initialDevice;
|
||||||
|
|
||||||
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
|
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
|
||||||
logger(
|
logger(
|
||||||
`[midi] Midi enabled! Using "${device.name}". ${
|
device
|
||||||
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
? `[midi] Midi enabled! Using "${device.name}". ${
|
||||||
}`,
|
otherInputs?.length ? `Also available: ${getMidiDeviceNamesString(otherInputs)}` : ''
|
||||||
|
}`
|
||||||
|
: `[midi] Midi enabled! Waiting for device "${input}"... Currently connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return device;
|
|
||||||
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MIDI input wrappers, by specified input string/index
|
||||||
|
const midiInputs = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MIDI input: Opens a MIDI input port to receive MIDI control change messages.
|
* 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
|
* @name midin
|
||||||
* @tags external_io
|
* @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)
|
||||||
@@ -543,31 +525,10 @@ async function _initialize(input) {
|
|||||||
* note("c a f e").s("saw")
|
* note("c a f e").s("saw")
|
||||||
* .when(cc(0).gt(0), x => x.postgain(0))
|
* .when(cc(0).gt(0), x => x.postgain(0))
|
||||||
*/
|
*/
|
||||||
let listeners = {};
|
|
||||||
const refs = {};
|
|
||||||
const refsByChan = {};
|
|
||||||
export async function midin(input) {
|
export async function midin(input) {
|
||||||
const device = await _initialize(input);
|
const instance = await _initializeInput(input);
|
||||||
refs[input] ??= {};
|
|
||||||
refsByChan[input] ??= {};
|
|
||||||
const cc = (cc, chan) => {
|
|
||||||
if (chan !== undefined) {
|
|
||||||
return ref(() => refsByChan[input][cc]?.[chan] || 0);
|
|
||||||
}
|
|
||||||
return ref(() => refs[input][cc] || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
listeners[input] && device.removeListener('midimessage', listeners[input]);
|
return instance.createCC.bind(instance);
|
||||||
listeners[input] = (e) => {
|
|
||||||
const [ccNum, v] = e.dataBytes;
|
|
||||||
const chan = e.message.channel;
|
|
||||||
const scaled = v / 127;
|
|
||||||
refsByChan[input][ccNum] ??= {};
|
|
||||||
refsByChan[input][ccNum][chan] = scaled;
|
|
||||||
refs[input][ccNum] = scaled;
|
|
||||||
};
|
|
||||||
device.addListener('midimessage', listeners[input]);
|
|
||||||
return cc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -576,8 +537,11 @@ export async function midin(input) {
|
|||||||
* The note length is fixed as Superdough is not currently set up for undetermined
|
* The note length is fixed as Superdough is not currently set up for undetermined
|
||||||
* note durations
|
* note durations
|
||||||
*
|
*
|
||||||
|
* The 'midichan' control value contains the number of the channel the note is coming from
|
||||||
|
* so it could be filtered or manipulated further in the chain.
|
||||||
|
*
|
||||||
* @name midikeys
|
* @name midikeys
|
||||||
* @tags external_io
|
* @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 | Pattern)=): Pattern} A function that produces a pattern.
|
* @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,
|
* When queried, the pattern will produces the most recently played midi notes and velocities,
|
||||||
@@ -591,6 +555,10 @@ export async function midin(input) {
|
|||||||
* .s("saw")
|
* .s("saw")
|
||||||
* .add(note(rand.mul(0.3)))
|
* .add(note(rand.mul(0.3)))
|
||||||
* .lpf(1000).lpe(2).room(0.5)
|
* .lpf(1000).lpe(2).room(0.5)
|
||||||
|
* @example
|
||||||
|
* // discard all notes not coming out from midi channel 2
|
||||||
|
* const kb = await midikeys('Arturia KeyStep 32')
|
||||||
|
* kb().filterValues(v=>v.midichan==2).s("tri")
|
||||||
*/
|
*/
|
||||||
const kHaps = {};
|
const kHaps = {};
|
||||||
const kListeners = {};
|
const kListeners = {};
|
||||||
@@ -622,7 +590,16 @@ function _triggerKeyboard(input, cps, now, latencyCycles) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
export async function midikeys(input) {
|
export async function midikeys(input) {
|
||||||
const device = await _initialize(input);
|
const instance = await _initializeInput(input);
|
||||||
|
|
||||||
|
// TODO: support unpluggable device usage
|
||||||
|
const device = instance.initialDevice;
|
||||||
|
if (!device) {
|
||||||
|
throw new Error(
|
||||||
|
`[midi] Midi device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!kHaps[input]) {
|
if (!kHaps[input]) {
|
||||||
kHaps[input] = [];
|
kHaps[input] = [];
|
||||||
}
|
}
|
||||||
@@ -663,7 +640,7 @@ export async function midikeys(input) {
|
|||||||
*/
|
*/
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
value = { ...value, note: Math.round(note), velocity: velocity / 127 };
|
value = { ...value, note: Math.round(note), velocity: velocity / 127, midichan: message.channel };
|
||||||
}
|
}
|
||||||
kHaps[input].push(new Hap(span, span, value, {}));
|
kHaps[input].push(new Hap(span, span, value, {}));
|
||||||
if (!noteoff && triggerAvailable) {
|
if (!noteoff && triggerAvailable) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
util.mjs - MIDI utility functions
|
||||||
|
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/midi/midi.mjs>
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Input, Output } from 'webmidi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a string listing device names for error messages.
|
||||||
|
* @param {Input[] | Output[]} devices
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getMidiDeviceNamesString(devices) {
|
||||||
|
return devices.map((o) => `'${o.name}'`).join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a device by index or name. Otherwise return a default device, or fail if none are connected.
|
||||||
|
*
|
||||||
|
* @param {string | number} indexOrName
|
||||||
|
* @param {Input[] | Output[]} devices
|
||||||
|
* @returns {Input | Output | undefined}
|
||||||
|
*/
|
||||||
|
export function getDevice(indexOrName, devices) {
|
||||||
|
if (typeof indexOrName === 'number') {
|
||||||
|
return devices[indexOrName];
|
||||||
|
}
|
||||||
|
const byName = (name) => devices.find((output) => output.name.includes(name));
|
||||||
|
if (typeof indexOrName === 'string') {
|
||||||
|
return byName(indexOrName);
|
||||||
|
}
|
||||||
|
// attempt to default to first IAC device if none is specified
|
||||||
|
const IACOutput = byName('IAC');
|
||||||
|
const device = IACOutput ?? devices[0];
|
||||||
|
if (!device) {
|
||||||
|
if (!devices.length) {
|
||||||
|
throw new Error(`🔌 No MIDI devices found. Connect a device or enable IAC Driver.`);
|
||||||
|
}
|
||||||
|
throw new Error(`🔌 Default MIDI device not found. Use one of ${getMidiDeviceNamesString(devices)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return device;
|
||||||
|
}
|
||||||
+10
-2
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
|||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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 { Pattern, isPattern } from '@strudel/core';
|
import { Pattern, isPattern, createParams } from '@strudel/core';
|
||||||
import Paho from 'paho-mqtt';
|
import Paho from 'paho-mqtt';
|
||||||
|
|
||||||
const connections = {};
|
const connections = {};
|
||||||
@@ -61,7 +61,6 @@ Pattern.prototype.mqtt = function (
|
|||||||
const props = {
|
const props = {
|
||||||
onSuccess: onConnect,
|
onSuccess: onConnect,
|
||||||
onFailure: onFailure,
|
onFailure: onFailure,
|
||||||
useSSL: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (username) {
|
if (username) {
|
||||||
@@ -119,3 +118,12 @@ Pattern.prototype.mqtt = function (
|
|||||||
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
|
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// This adds the 'move' and 'motor' commands to strudel
|
||||||
|
export const { move, motor } = createParams('move', 'motor');
|
||||||
|
window.move = move;
|
||||||
|
window.motor = motor;
|
||||||
|
// This adds the 'robot' command
|
||||||
|
Pattern.prototype.robot = function (robot_id, address = 'ws://192.168.8.248:9001/mqtt') {
|
||||||
|
return this.mqtt(undefined, undefined, '/move/' + robot_id, address);
|
||||||
|
};
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function parseControlsFromHap(hap, cps) {
|
|||||||
if (isNote(controls.note)) {
|
if (isNote(controls.note)) {
|
||||||
controls.midinote = noteToMidi(controls.note, controls.octave || 3);
|
controls.midinote = noteToMidi(controls.note, controls.octave || 3);
|
||||||
} else {
|
} else {
|
||||||
controls.note = parseNumeral(controls.note);
|
controls.midinote = parseNumeral(controls.note);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
controls.bank && (controls.s = controls.bank + controls.s);
|
controls.bank && (controls.s = controls.bank + controls.s);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@strudel/codemirror": "workspace:*",
|
"@strudel/codemirror": "workspace:*",
|
||||||
"@strudel/core": "workspace:*",
|
"@strudel/core": "workspace:*",
|
||||||
"@strudel/draw": "workspace:*",
|
"@strudel/draw": "workspace:*",
|
||||||
|
"@strudel/edo": "workspace:*",
|
||||||
"@strudel/hydra": "workspace:*",
|
"@strudel/hydra": "workspace:*",
|
||||||
"@strudel/midi": "workspace:*",
|
"@strudel/midi": "workspace:*",
|
||||||
"@strudel/mini": "workspace:*",
|
"@strudel/mini": "workspace:*",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export async function prebake() {
|
|||||||
core,
|
core,
|
||||||
import('@strudel/draw'),
|
import('@strudel/draw'),
|
||||||
import('@strudel/mini'),
|
import('@strudel/mini'),
|
||||||
|
import('@strudel/edo'),
|
||||||
import('@strudel/tonal'),
|
import('@strudel/tonal'),
|
||||||
import('@strudel/webaudio'),
|
import('@strudel/webaudio'),
|
||||||
import('@strudel/codemirror'),
|
import('@strudel/codemirror'),
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ 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);
|
||||||
@@ -109,7 +110,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: cycle / cps,
|
time: retrig > 0.5 ? 0 : cycle / cps,
|
||||||
depth: depthValue,
|
depth: depthValue,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
|
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
|
||||||
import { registerSound, registerWaveTable } from './index.mjs';
|
import { registerSound, registerWaveTable, soundMap } from './index.mjs';
|
||||||
import { getAudioContext } from './audioContext.mjs';
|
import { getAudioContext } from './audioContext.mjs';
|
||||||
import {
|
import {
|
||||||
getADSRValues,
|
getADSRValues,
|
||||||
@@ -30,6 +30,29 @@ function humanFileSize(bytes, si) {
|
|||||||
return bytes.toFixed(1) + ' ' + units[u];
|
return bytes.toFixed(1) + ' ' + units[u];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the duration, in seconds, of the given sample.
|
||||||
|
* Has optional param `n` (for instance, the `2` in `s("casio:2")`)
|
||||||
|
*
|
||||||
|
* Note: `must` be called with await, otherwise you'll get a pending Promise object.
|
||||||
|
*
|
||||||
|
* @name getDuration,getDur
|
||||||
|
* @tag samples
|
||||||
|
* @param {string} sampleName
|
||||||
|
* @param {number} (optional) n
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Set a patterns cycle length to exactly the length of the sample
|
||||||
|
* samples('github:tidalcycles/dirt-samples')
|
||||||
|
* let k = await getDuration('sax')
|
||||||
|
* s("sax").cps(1/k)
|
||||||
|
*/
|
||||||
|
export const getDuration = (s, n = 0) => {
|
||||||
|
return getSampleBufferSource({ s, n }, soundMap.get(s)[s].data.samples).then((x) => x.bufferDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDur = getDuration;
|
||||||
|
|
||||||
export function getSampleInfo(hapValue, bank) {
|
export function getSampleInfo(hapValue, bank) {
|
||||||
const { speed = 1.0 } = hapValue;
|
const { speed = 1.0 } = hapValue;
|
||||||
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
|
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
import { map } from 'nanostores';
|
import { map } from 'nanostores';
|
||||||
import { logger } from './logger.mjs';
|
import { logger } from './logger.mjs';
|
||||||
import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs';
|
import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs';
|
||||||
import { loadBuffer } from './sampler.mjs';
|
import { getSampleBufferSource, loadBuffer } from './sampler.mjs';
|
||||||
import { getAudioContext } from './audioContext.mjs';
|
import { getAudioContext } from './audioContext.mjs';
|
||||||
import { SuperdoughAudioController } from './superdoughoutput.mjs';
|
import { SuperdoughAudioController } from './superdoughoutput.mjs';
|
||||||
import { resetSeenKeys } from './wavetable.mjs';
|
import { resetSeenKeys } from './wavetable.mjs';
|
||||||
@@ -40,7 +40,7 @@ export let maxPolyphony = DEFAULT_MAX_POLYPHONY;
|
|||||||
* start to die out in first-in-first-out order once the max polyphony has been hit
|
* start to die out in first-in-first-out order once the max polyphony has been hit
|
||||||
*
|
*
|
||||||
* @name setMaxPolyphony
|
* @name setMaxPolyphony
|
||||||
* @tags fx, superdough
|
* @tags superdough
|
||||||
* @param {number} Max polyphony. Defaults to 128
|
* @param {number} Max polyphony. Defaults to 128
|
||||||
* @example
|
* @example
|
||||||
* setMaxPolyphony(4)
|
* setMaxPolyphony(4)
|
||||||
@@ -74,7 +74,7 @@ export function applyGainCurve(val) {
|
|||||||
* quadratic, exponential, etc. rather than linear
|
* quadratic, exponential, etc. rather than linear
|
||||||
*
|
*
|
||||||
* @name setGainCurve
|
* @name setGainCurve
|
||||||
* @tags fx, superdough
|
* @tags amplitude, superdough
|
||||||
* @param {Function} function to apply to all gain values
|
* @param {Function} function to apply to all gain values
|
||||||
* @example
|
* @example
|
||||||
* setGainCurve((x) => x * x) // quadratic gain
|
* setGainCurve((x) => x * x) // quadratic gain
|
||||||
|
|||||||
@@ -485,8 +485,10 @@ export function waveformN(partials, phases, type) {
|
|||||||
if (phase !== 0) {
|
if (phase !== 0) {
|
||||||
const c = Math.cos(PI2 * phase);
|
const c = Math.cos(PI2 * phase);
|
||||||
const s = Math.sin(PI2 * phase);
|
const s = Math.sin(PI2 * phase);
|
||||||
R = c * R - s * I;
|
const R0 = R;
|
||||||
I = s * R + c * I;
|
const I0 = I;
|
||||||
|
R = c * R0 - s * I0;
|
||||||
|
I = s * R0 + c * I0;
|
||||||
}
|
}
|
||||||
real[n + 1] = R;
|
real[n + 1] = R;
|
||||||
imag[n + 1] = I;
|
imag[n + 1] = I;
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function registerWaveTable(key, tables, params) {
|
|||||||
* Loads a collection of wavetables to use with `s`
|
* Loads a collection of wavetables to use with `s`
|
||||||
*
|
*
|
||||||
* @name tables
|
* @name tables
|
||||||
* @tags fx
|
* @tags wavetable
|
||||||
*/
|
*/
|
||||||
export const tables = async (url, frameLen, json, options = {}) => {
|
export const tables = async (url, frameLen, json, options = {}) => {
|
||||||
if (json !== undefined) return _processTables(json, url, frameLen);
|
if (json !== undefined) return _processTables(json, url, frameLen);
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ class CoarseProcessor extends AudioWorkletProcessor {
|
|||||||
coarse = Math.max(1, coarse);
|
coarse = Math.max(1, coarse);
|
||||||
for (let n = 0; n < blockSize; n++) {
|
for (let n = 0; n < blockSize; n++) {
|
||||||
for (let i = 0; i < input.length; i++) {
|
for (let i = 0; i < input.length; i++) {
|
||||||
output[i][n] = n % coarse === 0 ? input[i][n] : output[i][n - 1];
|
output[i][n] = n % coarse < 1 ? input[i][n] : output[i][n - 1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function scaleOffset(scale, offset, note) {
|
|||||||
* - 5P = perfect fifth
|
* - 5P = perfect fifth
|
||||||
* - 5d = diminished fifth
|
* - 5d = diminished fifth
|
||||||
*
|
*
|
||||||
* @tags music_theory
|
* @tags tonal
|
||||||
* @param {string | number} amount Either number of semitones or interval string.
|
* @param {string | number} amount Either number of semitones or interval string.
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
@@ -155,7 +155,7 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr
|
|||||||
*
|
*
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @name scaleTranspose
|
* @name scaleTranspose
|
||||||
* @tags music_theory
|
* @tags tonal
|
||||||
* @param {offset} offset number of steps inside the scale
|
* @param {offset} offset number of steps inside the scale
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @synonyms scaleTrans, strans
|
* @synonyms scaleTrans, strans
|
||||||
@@ -246,7 +246,7 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
|
|||||||
* The root note defaults to octave 3, if no octave number is given.
|
* The root note defaults to octave 3, if no octave number is given.
|
||||||
*
|
*
|
||||||
* @name scale
|
* @name scale
|
||||||
* @tags music_theory
|
* @tags tonal
|
||||||
* @param {string} scale Name of scale
|
* @param {string} scale Name of scale
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const setVoicingRange = (name, range) => addVoicings(name, voicingRegistr
|
|||||||
* Adds a new custom voicing dictionary.
|
* Adds a new custom voicing dictionary.
|
||||||
*
|
*
|
||||||
* @name addVoicings
|
* @name addVoicings
|
||||||
* @tags music_theory
|
* @tags tonal
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @param {string} name identifier for the voicing dictionary
|
* @param {string} name identifier for the voicing dictionary
|
||||||
* @param {Object} dictionary maps chord symbol to possible voicings
|
* @param {Object} dictionary maps chord symbol to possible voicings
|
||||||
@@ -134,7 +134,7 @@ const getVoicing = (chord, dictionaryName, lastVoicing) => {
|
|||||||
* Uses [chord-voicings package](https://github.com/felixroos/chord-voicings#chord-voicings).
|
* Uses [chord-voicings package](https://github.com/felixroos/chord-voicings#chord-voicings).
|
||||||
*
|
*
|
||||||
* @name voicings
|
* @name voicings
|
||||||
* @tags music_theory
|
* @tags tonal
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @param {string} dictionary which voicing dictionary to use.
|
* @param {string} dictionary which voicing dictionary to use.
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
@@ -159,7 +159,7 @@ export const voicings = register('voicings', function (dictionary, pat) {
|
|||||||
* Maps the chords of the incoming pattern to root notes in the given octave.
|
* Maps the chords of the incoming pattern to root notes in the given octave.
|
||||||
*
|
*
|
||||||
* @name rootNotes
|
* @name rootNotes
|
||||||
* @tags music_theory
|
* @tags tonal
|
||||||
* @memberof Pattern
|
* @memberof Pattern
|
||||||
* @param {octave} octave octave to use
|
* @param {octave} octave octave to use
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
@@ -192,7 +192,7 @@ export const rootNotes = register('rootNotes', function (octave, pat) {
|
|||||||
* If you pass a pattern of strings to voicing, they will be interpreted as chords.
|
* If you pass a pattern of strings to voicing, they will be interpreted as chords.
|
||||||
*
|
*
|
||||||
* @name voicing
|
* @name voicing
|
||||||
* @tags music_theory
|
* @tags tonal
|
||||||
* @returns Pattern
|
* @returns Pattern
|
||||||
* @example
|
* @example
|
||||||
* n("0 1 2 3").chord("<C Am F G>").voicing()
|
* n("0 1 2 3").chord("<C Am F G>").voicing()
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
# @strudel/transpiler
|
# @strudel/transpiler
|
||||||
|
|
||||||
This package contains a JS code transpiler with the following features:
|
This package contains a JS code transpiler with a plugin system that can be used to transform the code.
|
||||||
|
|
||||||
|
A plugin can be registered via a `registerTranspilerPlugin` call :
|
||||||
|
|
||||||
|
```js
|
||||||
|
registerTranspilerPlugin({
|
||||||
|
walk: (context) => ({
|
||||||
|
enter: function(node, parent, prop, index) {},
|
||||||
|
leave: function(node, parent, prop, index) {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
where the enter/leave functions (both optional) follow the `estree-walker` walk API.
|
||||||
|
|
||||||
|
4 plugins are currently hosted inside `@strudel/transpiler`
|
||||||
|
|
||||||
|
- mini: add locations of mini notation strings (double quoted or backticked) for highlighting
|
||||||
|
- widgets: add handling of sliders & draw widgets
|
||||||
|
- sample: make it possible to call a sample without await
|
||||||
|
- kabelsalat: transform the code to handle the `K(..)` kabelsalat notation
|
||||||
|
|
||||||
|
it also
|
||||||
|
|
||||||
- add locations of mini notation strings (double quoted or backticked) for highlighting
|
|
||||||
- converts pseudo note variables to note strings
|
|
||||||
- adds return statement to the last expression
|
- adds return statement to the last expression
|
||||||
|
- handles label capturing for block-based eval
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
|
/*
|
||||||
|
index.mjs - <short description TODO>
|
||||||
|
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.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 { evaluate as _evaluate } from '@strudel/core';
|
import { evaluate as _evaluate } from '@strudel/core';
|
||||||
import { transpiler } from './transpiler.mjs';
|
import { transpiler } from './transpiler.mjs';
|
||||||
|
|
||||||
export * from './transpiler.mjs';
|
export * from './transpiler.mjs';
|
||||||
|
|
||||||
export const evaluate = (code) => _evaluate(code, transpiler);
|
import './plugin-kabelsalat.mjs';
|
||||||
|
import './plugin-mini.mjs';
|
||||||
|
import './plugin-sample.mjs';
|
||||||
|
import './plugin-widgets.mjs';
|
||||||
|
|
||||||
|
export { registerWidgetType, getWidgetID } from './plugin-widgets.mjs';
|
||||||
|
export const evaluate = (code, transpilerOptions) => _evaluate(code, transpiler, transpilerOptions);
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/*
|
||||||
|
plugin-kabelsalat.mjs - <short description TODO>
|
||||||
|
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.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 { walk } from 'estree-walker';
|
||||||
|
import escodegen from 'escodegen';
|
||||||
|
import { getLanguages, registerTranspilerPlugin } from './transpiler.mjs';
|
||||||
|
|
||||||
|
export function genExprSource(expr) {
|
||||||
|
return escodegen.generate(expr, { format: { semicolons: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrudelPatternWrap(node) {
|
||||||
|
if (node.type !== 'CallExpression') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const callee = node.callee;
|
||||||
|
if (callee.type === 'Identifier') {
|
||||||
|
return callee.name === 'S';
|
||||||
|
}
|
||||||
|
if (callee.type === 'MemberExpression' && !callee.computed) {
|
||||||
|
return callee.property?.name === 'S';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to identify transpiled `m(...)` calls for proper conversion
|
||||||
|
// to, say, kabelsalat placeholders
|
||||||
|
function isMiniCall(node) {
|
||||||
|
if (node.type !== 'CallExpression') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const callee = node.callee;
|
||||||
|
if (callee.type !== 'Identifier') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (callee.name !== getMinilangName()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const firstArg = node.arguments?.[0];
|
||||||
|
return firstArg?.type === 'Literal' && typeof firstArg.value === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMinilangName() {
|
||||||
|
const minilang = getLanguages().get('minilang');
|
||||||
|
return minilang?.name || 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceNode(node, replacement, parents, currentRoot) {
|
||||||
|
const info = parents.get(node);
|
||||||
|
if (!info || !info.parent) {
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { parent, prop, index } = info;
|
||||||
|
if (Array.isArray(parent[prop])) {
|
||||||
|
parent[prop][index] = replacement;
|
||||||
|
} else {
|
||||||
|
parent[prop] = replacement;
|
||||||
|
}
|
||||||
|
parents.set(replacement, { parent, prop, index });
|
||||||
|
return currentRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If `start` is available, we use it. If it's already been transpiled
|
||||||
|
// to `m(...)`, use the provided offset
|
||||||
|
function getPatternNodeOrder(node) {
|
||||||
|
if (typeof node.start === 'number') {
|
||||||
|
return node.start;
|
||||||
|
}
|
||||||
|
if (isMiniCall(node)) {
|
||||||
|
const offsetArg = node.arguments?.[1];
|
||||||
|
if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') {
|
||||||
|
return offsetArg.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholderAst(index) {
|
||||||
|
return {
|
||||||
|
type: 'MemberExpression',
|
||||||
|
object: { type: 'Identifier', name: 'pat' },
|
||||||
|
property: { type: 'Literal', value: index },
|
||||||
|
computed: true,
|
||||||
|
optional: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStrudelPatternExpr(node) {
|
||||||
|
if (isStrudelPatternWrap(node)) {
|
||||||
|
const arg = node.arguments?.[0];
|
||||||
|
if (!arg) {
|
||||||
|
throw new Error('S(...) requires an argument');
|
||||||
|
}
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
if (isMiniCall(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneNode(node) {
|
||||||
|
return JSON.parse(JSON.stringify(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPatternPlaceholders(expr) {
|
||||||
|
const templateExpr = cloneNode(expr);
|
||||||
|
const parents = new Map();
|
||||||
|
const targets = [];
|
||||||
|
|
||||||
|
walk(templateExpr, {
|
||||||
|
enter(node, parent, prop, index) {
|
||||||
|
parents.set(node, { parent, prop, index });
|
||||||
|
const patternExpr = getStrudelPatternExpr(node);
|
||||||
|
if (patternExpr) {
|
||||||
|
targets.push({ node, patternExpr });
|
||||||
|
this.skip();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targets.length) {
|
||||||
|
return { template: genExprSource(templateExpr), patternExprs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node));
|
||||||
|
|
||||||
|
const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr));
|
||||||
|
|
||||||
|
let currentExpr = templateExpr;
|
||||||
|
targets.forEach(({ node }, index) => {
|
||||||
|
currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr);
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = genExprSource(currentExpr);
|
||||||
|
return { template, patternExprs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const transpilerPlugin = {
|
||||||
|
walk: (context) => ({
|
||||||
|
leave: function (node, parent, prop, index) {
|
||||||
|
if (!isKabelCall(node)) return;
|
||||||
|
let [expr, ...rest] = node.arguments;
|
||||||
|
if (!expr) throw new Error('K(...) requires an expression');
|
||||||
|
if (shouldCallKabelExpression(expr)) {
|
||||||
|
expr = {
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: expr,
|
||||||
|
arguments: [],
|
||||||
|
optional: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const language = 'kabelsalat';
|
||||||
|
const { template, patternExprs } = extractPatternPlaceholders(expr);
|
||||||
|
if (patternExprs.length) {
|
||||||
|
const workletArgs = [
|
||||||
|
/*{ type: 'Literal', value: language },*/
|
||||||
|
{ type: 'Literal', value: template },
|
||||||
|
...patternExprs,
|
||||||
|
...rest,
|
||||||
|
];
|
||||||
|
let callee = node.callee;
|
||||||
|
if (callee.type === 'ChainExpression') callee = callee.expression;
|
||||||
|
if (callee.type === 'MemberExpression') {
|
||||||
|
return this.replace({
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: workletMemberAst(callee.object),
|
||||||
|
arguments: workletArgs,
|
||||||
|
optional: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.replace({
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: { type: 'Identifier', name: 'worklet' },
|
||||||
|
arguments: workletArgs,
|
||||||
|
optional: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const kabelSrc = genExprSource(expr);
|
||||||
|
const workletArgs = [/*{ type: 'Literal', value: language },*/ { type: 'Literal', value: kabelSrc }, ...rest];
|
||||||
|
|
||||||
|
let callee = node.callee;
|
||||||
|
if (callee.type === 'ChainExpression') callee = callee.expression;
|
||||||
|
if (callee.type === 'MemberExpression') {
|
||||||
|
return this.replace({
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: workletMemberAst(callee.object),
|
||||||
|
arguments: workletArgs,
|
||||||
|
optional: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.replace({
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: { type: 'Identifier', name: 'worklet' },
|
||||||
|
arguments: workletArgs,
|
||||||
|
optional: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
registerTranspilerPlugin(transpilerPlugin);
|
||||||
|
|
||||||
|
function isKabelCall(node) {
|
||||||
|
if (node.type !== 'CallExpression') return false;
|
||||||
|
let callee = node.callee;
|
||||||
|
if (callee.type === 'ChainExpression') callee = callee.expression;
|
||||||
|
if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K';
|
||||||
|
return callee.type === 'Identifier' && callee.name === 'K';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCallKabelExpression(expr) {
|
||||||
|
if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (expr.params.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return expr.body?.type === 'BlockStatement';
|
||||||
|
}
|
||||||
|
|
||||||
|
function workletMemberAst(objectExpr) {
|
||||||
|
return {
|
||||||
|
type: 'MemberExpression',
|
||||||
|
object: objectExpr,
|
||||||
|
property: { type: 'Identifier', name: 'worklet' },
|
||||||
|
computed: false,
|
||||||
|
optional: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/*
|
||||||
|
plugin-mini.mjs - <short description TODO>
|
||||||
|
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.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 { getLeafLocations } from '@strudel/mini';
|
||||||
|
import { getLanguages, registerTranspilerPlugin } from './transpiler.mjs';
|
||||||
|
|
||||||
|
const languageLiteral = {
|
||||||
|
walk: (context) => ({
|
||||||
|
enter: function (node, parent, prop, index) {
|
||||||
|
if (!isLanguageLiteral(node)) return;
|
||||||
|
context.miniLocations ??= [];
|
||||||
|
const { options, miniLocations } = context;
|
||||||
|
const { emitMiniLocations } = options;
|
||||||
|
const { name } = node.tag;
|
||||||
|
const language = getLanguages().get(name);
|
||||||
|
const code = node.quasi.quasis[0].value.raw;
|
||||||
|
const offset = node.quasi.start + 1;
|
||||||
|
if (emitMiniLocations) {
|
||||||
|
const locs = language.getLocations(code, offset);
|
||||||
|
miniLocations.push(...locs);
|
||||||
|
}
|
||||||
|
this.skip();
|
||||||
|
return this.replace(languageWithLocation(name, code, offset));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const tidal = {
|
||||||
|
walk: (context) => ({
|
||||||
|
enter: function (node, parent, prop, index) {
|
||||||
|
if (!isTemplateLiteral(node, 'tidal')) return;
|
||||||
|
context.miniLocations ??= [];
|
||||||
|
const { options, miniLocations } = context;
|
||||||
|
const { emitMiniLocations } = options;
|
||||||
|
const raw = node.quasi.quasis[0].value.raw;
|
||||||
|
const offset = node.quasi.start + 1;
|
||||||
|
if (emitMiniLocations) {
|
||||||
|
const stringLocs = collectHaskellMiniLocations(raw, offset);
|
||||||
|
miniLocations.push(...stringLocs);
|
||||||
|
}
|
||||||
|
this.skip();
|
||||||
|
return this.replace(tidalWithLocation(raw, offset));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const backtick = {
|
||||||
|
walk: (context) => ({
|
||||||
|
enter: function (node, parent, prop, index) {
|
||||||
|
if (!isBackTickString(node, parent)) return;
|
||||||
|
context.miniLocations ??= [];
|
||||||
|
const { options, input, miniDisableRanges, miniLocations } = context;
|
||||||
|
const { emitMiniLocations } = options;
|
||||||
|
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { quasis } = node;
|
||||||
|
const { raw } = quasis[0].value;
|
||||||
|
this.skip();
|
||||||
|
emitMiniLocations && collectMiniLocations(raw, node, miniLocations, input);
|
||||||
|
return this.replace(miniWithLocation(raw, node));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const doublequotes = {
|
||||||
|
walk: (context) => ({
|
||||||
|
enter: function (node, parent, prop, index) {
|
||||||
|
if (!isStringWithDoubleQuotes(node)) return;
|
||||||
|
context.miniLocations ??= [];
|
||||||
|
const { options, input, miniDisableRanges, miniLocations } = context;
|
||||||
|
const { emitMiniLocations } = options;
|
||||||
|
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { value } = node;
|
||||||
|
this.skip();
|
||||||
|
emitMiniLocations && collectMiniLocations(value, node, miniLocations, input);
|
||||||
|
return this.replace(miniWithLocation(value, node));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function isLanguageLiteral(node) {
|
||||||
|
return node.type === 'TaggedTemplateExpression' && getLanguages().has(node.tag.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageWithLocation(name, value, offset) {
|
||||||
|
return {
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name: name,
|
||||||
|
},
|
||||||
|
arguments: [
|
||||||
|
{ type: 'Literal', value },
|
||||||
|
{ type: 'Literal', value: offset },
|
||||||
|
],
|
||||||
|
optional: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectHaskellMiniLocations(haskellCode, offset) {
|
||||||
|
return haskellCode
|
||||||
|
.split('')
|
||||||
|
.reduce((acc, char, i) => {
|
||||||
|
if (char !== '"') {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
if (!acc.length || acc[acc.length - 1].length > 1) {
|
||||||
|
acc.push([i + 1]);
|
||||||
|
} else {
|
||||||
|
acc[acc.length - 1].push(i);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.map(([start, end]) => {
|
||||||
|
const miniString = haskellCode.slice(start, end);
|
||||||
|
return getLeafLocations(`"${miniString}"`, offset + start - 1);
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTemplateLiteral(node, value) {
|
||||||
|
return node.type === 'TaggedTemplateExpression' && node.tag.name === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tidalWithLocation(value, offset) {
|
||||||
|
return {
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name: 'tidal',
|
||||||
|
},
|
||||||
|
arguments: [
|
||||||
|
{ type: 'Literal', value },
|
||||||
|
{ type: 'Literal', value: offset },
|
||||||
|
],
|
||||||
|
optional: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBackTickString(node, parent) {
|
||||||
|
return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMiniDisabled(offset, miniDisableRanges) {
|
||||||
|
for (const [start, end] of miniDisableRanges) {
|
||||||
|
if (offset >= start && offset < end) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function miniWithLocation(value, node) {
|
||||||
|
const { start: fromOffset } = node;
|
||||||
|
|
||||||
|
const minilang = getLanguages().get('minilang');
|
||||||
|
let name = 'm';
|
||||||
|
if (minilang && minilang.name) {
|
||||||
|
name = minilang.name; // name is expected to be exported from the package of the minilang
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'CallExpression',
|
||||||
|
callee: {
|
||||||
|
type: 'Identifier',
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
arguments: [
|
||||||
|
{ type: 'Literal', value },
|
||||||
|
{ type: 'Literal', value: fromOffset },
|
||||||
|
],
|
||||||
|
optional: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectMiniLocations = (value, node, miniLocations, input) => {
|
||||||
|
const minilang = getLanguages().get('minilang');
|
||||||
|
if (minilang) {
|
||||||
|
const code = `[${value}]`;
|
||||||
|
const locs = minilang.getLocations(code, node.start);
|
||||||
|
miniLocations.push(...locs);
|
||||||
|
} else {
|
||||||
|
const leafLocs = getLeafLocations(`"${value}"`, node.start, input);
|
||||||
|
miniLocations.push(...leafLocs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function isStringWithDoubleQuotes(node, locations, code) {
|
||||||
|
if (node.type !== 'Literal') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return node.raw[0] === '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
registerTranspilerPlugin([languageLiteral, tidal, backtick, doublequotes]);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user