mirror of
https://codeberg.org/uzu/strudel
synced 2026-10-15 12:33:14 -04:00
Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be3422c5e6 | |||
| 382d5df933 | |||
| 2e47716d23 | |||
| 0ffeb14c58 | |||
| 73b3982b69 | |||
| a18c2b6b80 | |||
| c9b32ec38b | |||
| d3325a2f82 | |||
| a2af0cd5cc | |||
| 56b279d723 | |||
| 32cccda215 | |||
| d7b4c6e498 | |||
| dd8d04dc5c | |||
| 1a52eb4515 | |||
| 196115a9da | |||
| 3d6f4cf32b | |||
| 2ebf9f7103 | |||
| a3302cf167 | |||
| cd6d364067 | |||
| ff80bae275 | |||
| 8247179a4d | |||
| c398620439 | |||
| 2cc9bf2568 | |||
| 39b6466a91 | |||
| d5dc506dc4 | |||
| 1f2202e6a1 | |||
| 63e2810375 | |||
| 262a6d85db | |||
| e66723230b | |||
| cb06042d3b | |||
| 88fdb424c4 | |||
| 0d4816804e | |||
| ce4ef629bf | |||
| 6448bd0956 | |||
| 884409310f | |||
| 126e45af9a | |||
| a6943f1eeb | |||
| 4a0d9de664 | |||
| 7f486556c6 | |||
| 257787d38a | |||
| 9eebf0ad21 | |||
| d97e91e928 | |||
| e78cc85ad7 | |||
| ee9138b514 | |||
| 0b786700d0 | |||
| 076bb8a152 | |||
| 357af69052 | |||
| fd2626d11d | |||
| 2b5f31f605 | |||
| c57c5a7c4d | |||
| c7d0f15826 | |||
| 60ac02905d | |||
| 5148066b01 | |||
| 29a4e30042 | |||
| 0a6d61e14a | |||
| 74de75644f | |||
| 6e50217a53 | |||
| c57320a2de | |||
| a1be663b15 | |||
| 9c05f8ebc6 | |||
| b2c2dfb0b4 | |||
| 38956e4d16 | |||
| c5fd80902b | |||
| 58dfe9a55c | |||
| ecc7bc078c | |||
| 078aa60779 | |||
| d7384dfc3d | |||
| 4671d343c4 | |||
| a3a38cfe33 | |||
| 98e99e8166 | |||
| 8f81463b9c | |||
| 8120fc72ab | |||
| d7fb9fe992 | |||
| 37d524e639 | |||
| d65b24db8f | |||
| f1dd209749 | |||
| d6f0b863f0 | |||
| 5dbbc7b9e1 | |||
| beb16f4040 | |||
| f2be5e4f5e | |||
| c7db8eaad6 | |||
| 723cd5da04 | |||
| 98d19a6643 | |||
| ef106b356f | |||
| ee460dc980 | |||
| d5d6bc6f34 | |||
| d9336aba3a | |||
| 263c9c0253 | |||
| 6ecdc739b4 | |||
| f67e81a03f | |||
| 5cf7571b77 | |||
| b2aeaa56c1 | |||
| 9e3792f6a6 | |||
| f8ed9c3a7c | |||
| c5b42b5555 | |||
| 75f58bc875 | |||
| 3b037deb10 | |||
| 362b2df390 | |||
| 20d925c10c | |||
| 91f37accdd | |||
| 07f01c237f | |||
| 76fa8264cd | |||
| 08dc5e2c5c | |||
| bd54c8003a | |||
| 9e946f1d14 | |||
| f1a541caec | |||
| 47cdf4561d | |||
| 6c8b707d2e | |||
| adcbf9240e | |||
| f4b3fdaacd | |||
| 9bf3249e82 | |||
| 0a0afcf73f | |||
| 6ebf506070 | |||
| eafa2f17ec | |||
| 429682735d | |||
| 04232370c2 | |||
| 4484ee4e80 | |||
| 57598f2aa1 | |||
| ebc25467c0 | |||
| a866cb2189 | |||
| 890d69b23e | |||
| 8e187abf24 | |||
| 2d014b47c8 | |||
| daafee7527 | |||
| 692170e117 | |||
| c9522a4e49 | |||
| 14ac26fa45 | |||
| 704018f774 | |||
| 25d1179985 | |||
| c150752372 | |||
| e248bf85f3 | |||
| 6c51c0261a | |||
| deefde7b50 | |||
| d4f63e8de3 | |||
| 19cb3dedc2 | |||
| a7c3407da7 | |||
| 6870b04fb2 | |||
| 2b2646a768 | |||
| 97ef5bc335 | |||
| 85e6d436ef | |||
| d3e2b7c7b4 | |||
| def1738259 | |||
| 0feeb1e701 | |||
| 49a1e11cd8 | |||
| 941c97da0d | |||
| e38811d325 | |||
| 475f17ddfd | |||
| bd68c6a0a7 | |||
| f21aeb55bd | |||
| 422b03699a | |||
| cb046a3548 | |||
| d83139980b | |||
| fe0be6bc53 | |||
| 4747fab4ed | |||
| d5171e2ab7 | |||
| 81257b5f06 | |||
| 896ec4c643 | |||
| 1723125099 | |||
| 0d73c02314 | |||
| 3dff15eab0 | |||
| 8f1a0840c0 | |||
| 948f78da11 | |||
| 08eaa8a578 | |||
| aeeaa50071 | |||
| 91681983e2 | |||
| 6df34625c3 | |||
| 8ad767363f | |||
| 581bbc05fc | |||
| a58711a311 | |||
| 4596926ac2 | |||
| f2acdc580f | |||
| beee2bc921 | |||
| b586d8c4c4 | |||
| c901ffb37f | |||
| c0a43a20dc | |||
| fa4d060ed2 | |||
| 25bf6856ae | |||
| f769b9c7a5 | |||
| 7c7da3e783 | |||
| 3e5c945e12 | |||
| a1ccb9c0bb | |||
| 7510cb715f | |||
| 3554a544d6 | |||
| a161d5a5d3 | |||
| 02e0c43f77 | |||
| 814510f637 | |||
| e502a3c3e5 | |||
| 1ef39a2ea7 | |||
| ee4b8fa315 | |||
| 36a456eeb4 | |||
| 37d052a655 | |||
| 44ec79182e | |||
| e5a08091f1 | |||
| 70213a26ae | |||
| aedd7de2df | |||
| 4d0a4902ab | |||
| 58e6b0543f | |||
| dfb2335b1c | |||
| 53cce1d97b | |||
| a76a23a43d | |||
| 787adfb20f | |||
| d4ec7e9a55 | |||
| 10b0e30057 | |||
| c2572f4fad | |||
| 4f7a0f76d3 | |||
| 88d7868e12 |
@@ -13,16 +13,12 @@ jobs:
|
||||
build:
|
||||
if: ${{ github.event.label.name == 'serve-hot' }}
|
||||
runs-on: docker
|
||||
container: node:22-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
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
|
||||
@@ -41,4 +37,4 @@ jobs:
|
||||
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
|
||||
strudel@matrix.toplap.org:/home/strudel/deploy/pr-${{ github.event.pull_request.number }}.hot.strudel.cc
|
||||
|
||||
@@ -13,17 +13,13 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container: node:22-bookworm
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
# cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -37,4 +33,4 @@ jobs:
|
||||
apt update && apt install -y rsync
|
||||
mkdir ~/.ssh
|
||||
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/strudel.cc
|
||||
|
||||
@@ -10,17 +10,13 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container: node:22-bookworm
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
# cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -34,4 +30,4 @@ jobs:
|
||||
apt update && apt install -y rsync
|
||||
mkdir ~/.ssh
|
||||
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
|
||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/cold.strudel.cc
|
||||
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/cold.strudel.cc
|
||||
|
||||
@@ -5,21 +5,11 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
container: node:22-bookworm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: apt install ztd
|
||||
run: apt update && apt install -y zstd
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
- run: pnpm install
|
||||
- run: pnpm run format-check
|
||||
- run: pnpm run lint
|
||||
|
||||
-14
@@ -45,20 +45,6 @@ tidal-drum-machines
|
||||
webaudiofontdata
|
||||
src-tauri/target
|
||||
|
||||
|
||||
|
||||
### BEGIN Visual Studio Code ###
|
||||
# Blanket, recursive exclude for .vscode directory and files
|
||||
.vscode/**/*
|
||||
# Unexclude specific files and directories within .vscode
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
### END Visual Studio Code ###
|
||||
|
||||
|
||||
|
||||
# BEGIN JetBrains -> END JetBrains
|
||||
|
||||
# for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
|
||||
+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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const drawContext = canvas.getContext('2d');
|
||||
const drawTime = [-2, 2]; // time window of drawn haps
|
||||
|
||||
const editor = new StrudelMirror({
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
transpiler,
|
||||
root: document.getElementById('editor'),
|
||||
|
||||
@@ -12,7 +12,7 @@ registerSynthSounds();
|
||||
evalScope(import('@strudel/core'), import('@strudel/mini'), import('@strudel/webaudio'), import('@strudel/tonal'));
|
||||
|
||||
const { evaluate } = repl({
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
getTime: () => ctx.currentTime,
|
||||
transpiler,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"description": "Port of tidalcycles to javascript",
|
||||
"packageManager": "pnpm@10.15.0",
|
||||
"scripts": {
|
||||
"setup": "pnpm i",
|
||||
"pretest": "npm run jsdoc-json",
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import jsdoc from '../../doc.json';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import { h } from './html';
|
||||
//TODO: fix tonal scale import
|
||||
// import { Scale } from '@tonaljs/tonal';
|
||||
// import { soundMap } from '@strudel/webaudio';
|
||||
let soundMap = undefined;
|
||||
import { complex } from '@strudel/tonal';
|
||||
|
||||
const escapeHtml = (str) => {
|
||||
const div = document.createElement('div');
|
||||
@@ -80,53 +75,6 @@ const isValidDoc = (doc) => {
|
||||
const hasExcludedTags = (doc) =>
|
||||
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag));
|
||||
|
||||
export function bankCompletions() {
|
||||
// TODO: FIX IMPORT
|
||||
const soundDict = soundMap?.get() ?? {};
|
||||
|
||||
const banks = new Set();
|
||||
for (const key of Object.keys(soundDict)) {
|
||||
const [bank, suffix] = key.split('_');
|
||||
if (suffix && bank) banks.add(bank);
|
||||
}
|
||||
return Array.from(banks)
|
||||
.sort()
|
||||
.map((name) => ({ label: name, type: 'bank' }));
|
||||
}
|
||||
|
||||
// Attempt to get all scale names from Tonal TODO: FIX IMPORT
|
||||
let scaleCompletions = [];
|
||||
// try {
|
||||
// scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
|
||||
// } catch (e) {
|
||||
// console.warn('[autocomplete] Could not load scale names from Tonal:', e);
|
||||
// }
|
||||
|
||||
// Valid mode values for voicing
|
||||
const modeCompletions = [
|
||||
{ label: 'below', type: 'mode' },
|
||||
{ label: 'above', type: 'mode' },
|
||||
{ label: 'duck', type: 'mode' },
|
||||
{ label: 'root', type: 'mode' },
|
||||
];
|
||||
|
||||
// Valid chord symbols from ireal dictionary plus empty string for major triads
|
||||
const chordSymbols = ['', ...Object.keys(complex)].sort();
|
||||
const chordSymbolCompletions = chordSymbols.map((symbol) => {
|
||||
if (symbol === '') {
|
||||
return {
|
||||
label: 'major',
|
||||
apply: '',
|
||||
type: 'chord-symbol',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: symbol,
|
||||
apply: symbol,
|
||||
type: 'chord-symbol',
|
||||
};
|
||||
});
|
||||
|
||||
export const getSynonymDoc = (doc, synonym) => {
|
||||
const synonyms = doc.synonyms || [];
|
||||
const docLabel = getDocLabel(doc);
|
||||
@@ -165,299 +113,19 @@ const jsdocCompletions = (() => {
|
||||
return completions;
|
||||
})();
|
||||
|
||||
// --- Handler functions for each context ---
|
||||
const pitchNames = [
|
||||
'C',
|
||||
'C#',
|
||||
'Db',
|
||||
'D',
|
||||
'D#',
|
||||
'Eb',
|
||||
'E',
|
||||
'E#',
|
||||
'Fb',
|
||||
'F',
|
||||
'F#',
|
||||
'Gb',
|
||||
'G',
|
||||
'G#',
|
||||
'Ab',
|
||||
'A',
|
||||
'A#',
|
||||
'Bb',
|
||||
'B',
|
||||
'B#',
|
||||
'Cb',
|
||||
];
|
||||
|
||||
// Cached regex patterns for scaleHandler
|
||||
const SCALE_NO_QUOTES_REGEX = /scale\(\s*$/;
|
||||
const SCALE_AFTER_COLON_REGEX = /scale\(\s*['"][^'"]*:[^'"]*$/;
|
||||
const SCALE_PRE_COLON_REGEX = /scale\(\s*['"][^'"]*$/;
|
||||
const SCALE_PITCH_MATCH_REGEX = /([A-Ga-g][#b]*)?$/;
|
||||
const SCALE_SPACES_TO_COLON_REGEX = /\s+/g;
|
||||
|
||||
function scaleHandler(context) {
|
||||
// First check for scale context without quotes - block with empty completions
|
||||
let scaleNoQuotesContext = context.matchBefore(SCALE_NO_QUOTES_REGEX);
|
||||
if (scaleNoQuotesContext) {
|
||||
return {
|
||||
from: scaleNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for after-colon context first (more specific)
|
||||
let scaleAfterColonContext = context.matchBefore(SCALE_AFTER_COLON_REGEX);
|
||||
if (scaleAfterColonContext) {
|
||||
const text = scaleAfterColonContext.text;
|
||||
const colonIdx = text.lastIndexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const fragment = text.slice(colonIdx + 1);
|
||||
const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment));
|
||||
const options = filteredScales.map((s) => ({
|
||||
...s,
|
||||
apply: s.label.replace(SCALE_SPACES_TO_COLON_REGEX, ':'),
|
||||
}));
|
||||
const from = scaleAfterColonContext.from + colonIdx + 1;
|
||||
return {
|
||||
from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for pre-colon context
|
||||
let scalePreColonContext = context.matchBefore(SCALE_PRE_COLON_REGEX);
|
||||
if (scalePreColonContext) {
|
||||
if (!scalePreColonContext.text.includes(':')) {
|
||||
if (context.explicit) {
|
||||
const text = scalePreColonContext.text;
|
||||
const match = text.match(SCALE_PITCH_MATCH_REGEX);
|
||||
const fragment = match ? match[0] : '';
|
||||
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
||||
const from = scalePreColonContext.to - fragment.length;
|
||||
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
|
||||
return { from, options };
|
||||
} else {
|
||||
return { from: scalePreColonContext.to, options: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cached regex patterns for soundHandler
|
||||
const SOUND_NO_QUOTES_REGEX = /(s|sound)\(\s*$/;
|
||||
const SOUND_WITH_QUOTES_REGEX = /(s|sound)\(\s*['"][^'"]*$/;
|
||||
const SOUND_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w]*)$/;
|
||||
|
||||
function soundHandler(context) {
|
||||
// First check for sound context without quotes - block with empty completions
|
||||
let soundNoQuotesContext = context.matchBefore(SOUND_NO_QUOTES_REGEX);
|
||||
if (soundNoQuotesContext) {
|
||||
return {
|
||||
from: soundNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Then check for sound context with quotes - provide completions
|
||||
let soundContext = context.matchBefore(SOUND_WITH_QUOTES_REGEX);
|
||||
if (!soundContext) return null;
|
||||
|
||||
const text = soundContext.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
|
||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
||||
const soundNames = Object.keys(soundMap?.get() ?? {}).sort();
|
||||
const filteredSounds = soundNames.filter((name) => name.includes(fragment));
|
||||
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
|
||||
const from = soundContext.to - fragment.length;
|
||||
return {
|
||||
from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
// Cached regex patterns for bankHandler
|
||||
const BANK_NO_QUOTES_REGEX = /bank\(\s*$/;
|
||||
const BANK_WITH_QUOTES_REGEX = /bank\(\s*['"][^'"]*$/;
|
||||
|
||||
function bankHandler(context) {
|
||||
// First check for bank context without quotes - block with empty completions
|
||||
let bankNoQuotesContext = context.matchBefore(BANK_NO_QUOTES_REGEX);
|
||||
if (bankNoQuotesContext) {
|
||||
return {
|
||||
from: bankNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Then check for bank context with quotes - provide completions
|
||||
let bankMatch = context.matchBefore(BANK_WITH_QUOTES_REGEX);
|
||||
if (!bankMatch) return null;
|
||||
|
||||
const text = bankMatch.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
const fragment = inside;
|
||||
let banks = bankCompletions();
|
||||
const filteredBanks = banks.filter((b) => b.label.startsWith(fragment));
|
||||
const from = bankMatch.to - fragment.length;
|
||||
return {
|
||||
from,
|
||||
options: filteredBanks,
|
||||
};
|
||||
}
|
||||
|
||||
// Cached regex patterns for modeHandler
|
||||
const MODE_NO_QUOTES_REGEX = /mode\(\s*$/;
|
||||
const MODE_AFTER_COLON_REGEX = /mode\(\s*['"][^'"]*:[^'"]*$/;
|
||||
const MODE_PRE_COLON_REGEX = /mode\(\s*['"][^'"]*$/;
|
||||
const MODE_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w:]*)$/;
|
||||
|
||||
function modeHandler(context) {
|
||||
// First check for mode context without quotes - block with empty completions
|
||||
let modeNoQuotesContext = context.matchBefore(MODE_NO_QUOTES_REGEX);
|
||||
if (modeNoQuotesContext) {
|
||||
return {
|
||||
from: modeNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for after-colon context first (more specific)
|
||||
let modeAfterColonContext = context.matchBefore(MODE_AFTER_COLON_REGEX);
|
||||
if (modeAfterColonContext) {
|
||||
const text = modeAfterColonContext.text;
|
||||
const colonIdx = text.lastIndexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const fragment = text.slice(colonIdx + 1);
|
||||
// For anchor after colon, we can suggest pitch names
|
||||
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
||||
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
|
||||
const from = modeAfterColonContext.from + colonIdx + 1;
|
||||
return {
|
||||
from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for pre-colon context
|
||||
let modeContext = context.matchBefore(MODE_PRE_COLON_REGEX);
|
||||
if (!modeContext) return null;
|
||||
|
||||
const text = modeContext.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
const fragMatch = inside.match(MODE_FRAGMENT_MATCH_REGEX);
|
||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
||||
const filteredModes = modeCompletions.filter((m) => m.label.startsWith(fragment));
|
||||
const from = modeContext.to - fragment.length;
|
||||
return {
|
||||
from,
|
||||
options: filteredModes,
|
||||
};
|
||||
}
|
||||
|
||||
// Cached regex patterns for chordHandler
|
||||
const CHORD_NO_QUOTES_REGEX = /chord\(\s*$/;
|
||||
const CHORD_WITH_QUOTES_REGEX = /chord\(\s*['"][^'"]*$/;
|
||||
const CHORD_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w#b+^:-]*)$/;
|
||||
|
||||
function chordHandler(context) {
|
||||
// First check for chord context without quotes - block with empty completions
|
||||
let chordNoQuotesContext = context.matchBefore(CHORD_NO_QUOTES_REGEX);
|
||||
if (chordNoQuotesContext) {
|
||||
return {
|
||||
from: chordNoQuotesContext.to,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Then check for chord context with quotes - provide completions
|
||||
let chordContext = context.matchBefore(CHORD_WITH_QUOTES_REGEX);
|
||||
if (!chordContext) return null;
|
||||
|
||||
const text = chordContext.text;
|
||||
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
|
||||
if (quoteIdx === -1) return null;
|
||||
const inside = text.slice(quoteIdx + 1);
|
||||
|
||||
// Use same fragment matching as sound/mode for expressions like "<G Am>"
|
||||
const fragMatch = inside.match(CHORD_FRAGMENT_MATCH_REGEX);
|
||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
||||
|
||||
// Check if fragment contains any pitch name at start (for root + symbol)
|
||||
let rootMatch = null;
|
||||
let symbolFragment = fragment;
|
||||
for (const pitch of pitchNames) {
|
||||
if (fragment.toLowerCase().startsWith(pitch.toLowerCase())) {
|
||||
rootMatch = pitch;
|
||||
symbolFragment = fragment.slice(pitch.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (rootMatch) {
|
||||
// We have a root, now complete chord symbols
|
||||
const filteredSymbols = chordSymbolCompletions.filter((s) =>
|
||||
s.label.toLowerCase().startsWith(symbolFragment.toLowerCase()),
|
||||
);
|
||||
|
||||
// Create completions that replace the entire chord, not just the symbol part
|
||||
const options = filteredSymbols;
|
||||
|
||||
const from = chordContext.to - symbolFragment.length;
|
||||
return { from, options };
|
||||
} else {
|
||||
// No root yet, complete with pitch names
|
||||
const filteredPitches = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
|
||||
const options = filteredPitches.map((p) => ({ label: p, type: 'pitch' }));
|
||||
const from = chordContext.to - fragment.length;
|
||||
return { from, options };
|
||||
}
|
||||
}
|
||||
|
||||
// Cached regex patterns for fallbackHandler
|
||||
const FALLBACK_WORD_REGEX = /\w*/;
|
||||
|
||||
function fallbackHandler(context) {
|
||||
const word = context.matchBefore(FALLBACK_WORD_REGEX);
|
||||
if (word && word.from === word.to && !context.explicit) return null;
|
||||
if (word) {
|
||||
return {
|
||||
from: word.from,
|
||||
options: jsdocCompletions,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlers = [
|
||||
soundHandler,
|
||||
bankHandler,
|
||||
chordHandler,
|
||||
scaleHandler,
|
||||
modeHandler,
|
||||
// this handler *must* be last
|
||||
fallbackHandler,
|
||||
];
|
||||
|
||||
export const strudelAutocomplete = (context) => {
|
||||
for (const handler of handlers) {
|
||||
const result = handler(context);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const word = context.matchBefore(/\w*/);
|
||||
if (word.from === word.to && !context.explicit) return null;
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options: jsdocCompletions,
|
||||
/* options: [
|
||||
{ label: 'match', type: 'keyword' },
|
||||
{ label: 'hello', type: 'variable', info: '(World)' },
|
||||
{ label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
|
||||
], */
|
||||
};
|
||||
};
|
||||
|
||||
export const isAutoCompletionEnabled = (enabled) =>
|
||||
|
||||
@@ -2,23 +2,13 @@ import {
|
||||
keymap,
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
dropCursor,
|
||||
rectangularSelection,
|
||||
crosshairCursor,
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
} from '@codemirror/view';
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
} from '@codemirror/language';
|
||||
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { completionKeymap, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
import { closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
|
||||
// Taken + slightly modified from https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// Block-based evaluation utilities
|
||||
|
||||
export function getBlockRegions(code) {
|
||||
const chars = code.split('');
|
||||
let i = 0,
|
||||
blanks = [],
|
||||
blockStart = 0,
|
||||
regions = [];
|
||||
while (i < chars.length) {
|
||||
const isBlank = chars[i] === '\n';
|
||||
if (isBlank) {
|
||||
blanks.push(i);
|
||||
} else if (chars[i].trim() !== '') {
|
||||
if (blanks.length > 1) {
|
||||
regions.push([blockStart, blanks[0]]);
|
||||
blockStart = i;
|
||||
}
|
||||
blanks = [];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
regions.push([blockStart, blanks.length ? blanks[0] : i]);
|
||||
return regions;
|
||||
}
|
||||
|
||||
export function getBlockAt(code, cursor) {
|
||||
const regions = getBlockRegions(code);
|
||||
for (const [start, end] of regions) {
|
||||
if (cursor >= start && cursor <= end) {
|
||||
return [start, end];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const evalBlock = (strudelMirror) => {
|
||||
const { state } = strudelMirror.editor;
|
||||
const code = state.doc.toString();
|
||||
const cursor = state.selection.main.head;
|
||||
const range = getBlockAt(code, cursor);
|
||||
if (range) {
|
||||
const [a, b] = range;
|
||||
const block = code.slice(a, b);
|
||||
if (block) {
|
||||
// Flash the block being evaluated
|
||||
strudelMirror.flash(200, { from: a, to: b });
|
||||
strudelMirror.repl.evaluateBlock(block, true, { range });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
import { indentWithTab, toggleLineComment } from '@codemirror/commands';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||
@@ -12,19 +12,19 @@ import {
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
import { logger, registerControl, repl } from '@strudel/core';
|
||||
import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw';
|
||||
import { registerControl, repl } from '@strudel/core';
|
||||
import { cleanupDraw, Drawer } from '@strudel/draw';
|
||||
|
||||
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
||||
import { basicSetup } from './basicSetup.mjs';
|
||||
import { evalBlock } from './block_utilities.mjs';
|
||||
import { flash, isFlashEnabled } from './flash.mjs';
|
||||
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
|
||||
import { keybindings } from './keybindings.mjs';
|
||||
import { jumpToCharacter } from './labelJump.mjs';
|
||||
import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
||||
import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
|
||||
import { activateTheme, initTheme, theme } from './themes.mjs';
|
||||
import { isTooltipEnabled } from './tooltip.mjs';
|
||||
import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs';
|
||||
import { updateWidgets, widgetPlugin } from './widget.mjs';
|
||||
import { jumpToCharacter } from './labelJump.mjs';
|
||||
|
||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||
|
||||
@@ -64,7 +64,6 @@ export const defaultSettings = {
|
||||
isLineWrappingEnabled: false,
|
||||
isTabIndentationEnabled: false,
|
||||
isMultiCursorEnabled: false,
|
||||
isBlockBasedEvalEnabled: false,
|
||||
theme: 'strudelTheme',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 18,
|
||||
@@ -76,7 +75,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
|
||||
});
|
||||
|
||||
// https://codemirror.net/docs/guide/
|
||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) {
|
||||
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) {
|
||||
const settings = codemirrorSettings.get();
|
||||
const initialSettings = Object.keys(compartments).map((key) =>
|
||||
compartments[key].of(extensions[key](parseBooleans(settings[key]))),
|
||||
@@ -106,26 +105,11 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Ctrl-Enter',
|
||||
run: () => {
|
||||
// issue with referencing settings, this works more reliably
|
||||
if (strudelMirror?.isBlockBasedEvalEnabled) {
|
||||
evalBlock(strudelMirror);
|
||||
return true;
|
||||
} else {
|
||||
return onEvaluate?.();
|
||||
}
|
||||
},
|
||||
run: () => onEvaluate?.(),
|
||||
},
|
||||
{
|
||||
key: 'Alt-Enter',
|
||||
run: () => {
|
||||
if (strudelMirror?.isBlockBasedEvalEnabled) {
|
||||
evalBlock(strudelMirror);
|
||||
return true;
|
||||
} else {
|
||||
return onEvaluate?.();
|
||||
}
|
||||
},
|
||||
run: () => onEvaluate?.(),
|
||||
},
|
||||
{
|
||||
key: 'Ctrl-.',
|
||||
@@ -187,7 +171,6 @@ export class StrudelMirror {
|
||||
this.onDraw = onDraw || this.draw;
|
||||
this.id = id || s4();
|
||||
this.solo = solo;
|
||||
this.isBlockBasedEvalEnabled = false; // Will be updated via updateSettings()
|
||||
|
||||
this.drawer = new Drawer((haps, time, _, painters) => {
|
||||
const currentFrame = haps.filter((hap) => hap.isActive(time));
|
||||
@@ -218,12 +201,8 @@ export class StrudelMirror {
|
||||
cleanupDraw(true, id);
|
||||
}
|
||||
},
|
||||
beforeEval: async ({ blockBased } = {}) => {
|
||||
// Only clean up all drawings for full evaluation
|
||||
// Block-based eval should preserve animations (like .scope()) from other blocks
|
||||
if (!blockBased) {
|
||||
cleanupDraw(true, id);
|
||||
}
|
||||
beforeEval: async () => {
|
||||
cleanupDraw(true, id);
|
||||
await this.prebaked;
|
||||
await replOptions?.beforeEval?.();
|
||||
},
|
||||
@@ -233,13 +212,10 @@ export class StrudelMirror {
|
||||
this.widgets = options.meta?.widgets || [];
|
||||
|
||||
const sliders = this.widgets.filter((w) => w.type === 'slider');
|
||||
updateSliderWidgets(this.editor, sliders);
|
||||
const widgets = this.widgets.filter((w) => w.type !== 'slider');
|
||||
// range-aware update for block-based evaluation
|
||||
const range = options.range && options.range.length >= 2 ? options.range : null;
|
||||
|
||||
updateSliderWidgets(this.editor, sliders, range);
|
||||
updateWidgets(this.editor, widgets, range);
|
||||
updateMiniLocations(this.editor, this.miniLocations, range);
|
||||
updateWidgets(this.editor, widgets);
|
||||
updateMiniLocations(this.editor, this.miniLocations);
|
||||
replOptions?.afterEval?.(options);
|
||||
// if no painters are set (.onPaint was not called), then we only need
|
||||
// the present moment (for highlighting)
|
||||
@@ -247,14 +223,8 @@ export class StrudelMirror {
|
||||
this.drawer.setDrawTime(drawTime);
|
||||
// invalidate drawer after we've set the appropriate drawTime
|
||||
this.drawer.invalidate(this.repl.scheduler);
|
||||
|
||||
// Clean up draw context if a non-inline widget was removed
|
||||
if (options.widgetRemoved) {
|
||||
cleanupDrawContext(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.cleanupDrawContext = () => cleanupDrawContext(id);
|
||||
this.editor = initEditor({
|
||||
root,
|
||||
initialCode,
|
||||
@@ -267,9 +237,7 @@ export class StrudelMirror {
|
||||
onEvaluate: () => this.evaluate(),
|
||||
onStop: () => this.stop(),
|
||||
mondo: replOptions.mondo,
|
||||
strudelMirror: this,
|
||||
});
|
||||
|
||||
const cmEditor = this.root.querySelector('.cm-editor');
|
||||
if (cmEditor) {
|
||||
this.root.style.display = 'block';
|
||||
@@ -289,39 +257,6 @@ export class StrudelMirror {
|
||||
}
|
||||
};
|
||||
document.addEventListener('start-repl', this.onStartRepl);
|
||||
|
||||
// Handle global evaluation requests (e.g., from Vim :w)
|
||||
this.onEvaluateRequest = (e) => {
|
||||
try {
|
||||
if (e.detail.view !== this.editor) {
|
||||
return; // ignore events from other editors
|
||||
}
|
||||
// Evaluate current editor on repl-evaluate
|
||||
logger('[repl] evaluate via event');
|
||||
this.evaluate();
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-evaluate event', err);
|
||||
}
|
||||
};
|
||||
document.addEventListener('repl-evaluate', this.onEvaluateRequest);
|
||||
document.addEventListener('repl-stop', this.onStopRequest);
|
||||
|
||||
// Toggle comments requested from Vim (gc)
|
||||
this.onToggleComment = (e) => {
|
||||
try {
|
||||
if (e.detail.view !== this.editor) {
|
||||
return; // ignore events from other editors
|
||||
}
|
||||
// Honor selections; toggleLineComment handles both selections and
|
||||
// single line
|
||||
toggleLineComment(this.editor);
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-toggle-comment event', err);
|
||||
}
|
||||
};
|
||||
document.addEventListener('repl-toggle-comment', this.onToggleComment);
|
||||
}
|
||||
draw(haps, time, painters) {
|
||||
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
|
||||
@@ -345,23 +280,10 @@ export class StrudelMirror {
|
||||
this.flash();
|
||||
await this.repl.evaluate(this.code, autostart);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.repl.stop();
|
||||
this.repl.scheduler.stop();
|
||||
}
|
||||
|
||||
// Listen for global stop requests (e.g., from Vim :q)
|
||||
onStopRequest = (e) => {
|
||||
try {
|
||||
if (e.detail.view !== this.editor) {
|
||||
return; // ignore events from other editors
|
||||
}
|
||||
this.stop();
|
||||
e?.cancelable && e.preventDefault?.();
|
||||
} catch (err) {
|
||||
console.error('Error handling repl-stop event', err);
|
||||
}
|
||||
};
|
||||
async toggle() {
|
||||
if (this.repl.scheduler.started) {
|
||||
this.repl.stop();
|
||||
@@ -369,8 +291,8 @@ export class StrudelMirror {
|
||||
this.evaluate();
|
||||
}
|
||||
}
|
||||
flash(ms, range) {
|
||||
flash(this.editor, ms, range);
|
||||
flash(ms) {
|
||||
flash(this.editor, ms);
|
||||
}
|
||||
highlight(haps, time) {
|
||||
highlightMiniLocations(this.editor, time, haps);
|
||||
@@ -402,10 +324,6 @@ export class StrudelMirror {
|
||||
setLineWrappingEnabled(enabled) {
|
||||
this.reconfigureExtension('isLineWrappingEnabled', enabled);
|
||||
}
|
||||
|
||||
setBlockBasedEvalEnabled(enabled) {
|
||||
this.reconfigureExtension('isBlockBasedEvalEnabled', enabled);
|
||||
}
|
||||
setBracketMatchingEnabled(enabled) {
|
||||
this.reconfigureExtension('isBracketMatchingEnabled', enabled);
|
||||
}
|
||||
@@ -427,10 +345,6 @@ export class StrudelMirror {
|
||||
for (let key in extensions) {
|
||||
this.reconfigureExtension(key, settings[key]);
|
||||
}
|
||||
// Update block-based eval setting on the instance
|
||||
if (settings.isBlockBasedEvalEnabled !== undefined) {
|
||||
this.isBlockBasedEvalEnabled = parseBooleans(settings.isBlockBasedEvalEnabled);
|
||||
}
|
||||
const updated = { ...codemirrorSettings.get(), ...settings };
|
||||
codemirrorSettings.set(updated);
|
||||
}
|
||||
@@ -454,21 +368,8 @@ export class StrudelMirror {
|
||||
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() {
|
||||
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
|
||||
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
|
||||
this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest);
|
||||
this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment);
|
||||
}
|
||||
getCursorLocation() {
|
||||
return this.editor.state.selection.main.head;
|
||||
|
||||
@@ -14,8 +14,7 @@ export const flashField = StateField.define({
|
||||
const mark = Decoration.mark({
|
||||
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` },
|
||||
});
|
||||
const range = e.value.range || { from: 0, to: tr.newDoc.length };
|
||||
flash = Decoration.set([mark.range(range.from, range.to)]);
|
||||
flash = Decoration.set([mark.range(0, tr.newDoc.length)]);
|
||||
} else {
|
||||
flash = Decoration.set([]);
|
||||
}
|
||||
@@ -30,9 +29,8 @@ export const flashField = StateField.define({
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
export const flash = (view, ms = 200, range) => {
|
||||
const flashData = range ? { range } : true;
|
||||
view.dispatch({ effects: setFlash.of(flashData) });
|
||||
export const flash = (view, ms = 200) => {
|
||||
view.dispatch({ effects: setFlash.of(true) });
|
||||
setTimeout(() => {
|
||||
view.dispatch({ effects: setFlash.of(false) });
|
||||
}, ms);
|
||||
|
||||
@@ -3,9 +3,8 @@ import { Decoration, EditorView } from '@codemirror/view';
|
||||
|
||||
export const setMiniLocations = StateEffect.define();
|
||||
export const showMiniLocations = StateEffect.define();
|
||||
export const displayMiniLocations = StateEffect.define();
|
||||
export const updateMiniLocations = (view, locations, range = null) => {
|
||||
view.dispatch({ effects: setMiniLocations.of({ locations, range }) });
|
||||
export const updateMiniLocations = (view, locations) => {
|
||||
view.dispatch({ effects: setMiniLocations.of(locations) });
|
||||
};
|
||||
export const highlightMiniLocations = (view, atTime, haps) => {
|
||||
view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
|
||||
@@ -22,54 +21,23 @@ const miniLocations = StateField.define({
|
||||
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(setMiniLocations)) {
|
||||
//block-based eval case
|
||||
if (e.value.range) {
|
||||
const stateMiniLocations = getMiniLocationsFromDecorations(locations);
|
||||
|
||||
const normalized = e.value.locations
|
||||
.filter(([from]) => from < tr.newDoc.length)
|
||||
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]);
|
||||
|
||||
const newIds = new Set(normalized.map((r) => r.join(':')));
|
||||
|
||||
const marks = normalized.map((range) => {
|
||||
const id = range.join(':');
|
||||
return Decoration.mark({
|
||||
id,
|
||||
// this green is only to verify that the decoration moves when the document is edited
|
||||
// it will be removed later, so the mark is not visible by default
|
||||
attributes: { style: `background-color: #00CA2880` },
|
||||
}).range(...range); // -> Decoration
|
||||
});
|
||||
|
||||
const previousMarks = stateMiniLocations
|
||||
.filter(({ id }) => !newIds.has(id))
|
||||
.map(({ from, to, id }) =>
|
||||
// this is called on eval, with the mini locations obtained from the transpiler
|
||||
// codemirror will automatically remap the marks when the document is edited
|
||||
// create a mark for each mini location, adding the range to the spec to find it later
|
||||
const marks = e.value
|
||||
.filter(([from]) => from < tr.newDoc.length)
|
||||
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
|
||||
.map(
|
||||
(range) =>
|
||||
Decoration.mark({
|
||||
id,
|
||||
id: range.join(':'),
|
||||
// this green is only to verify that the decoration moves when the document is edited
|
||||
// it will be removed later, so the mark is not visible by default
|
||||
attributes: { style: `background-color: #00CA2880` },
|
||||
}).range(from, to),
|
||||
);
|
||||
}).range(...range), // -> Decoration
|
||||
);
|
||||
|
||||
locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet<Decoration>
|
||||
} else {
|
||||
// this is called on eval, with the mini locations obtained from the transpiler
|
||||
// codemirror will automatically remap the marks when the document is edited
|
||||
// create a mark for each mini location, adding the range to the spec to find it later
|
||||
const marks = e.value.locations
|
||||
.filter(([from]) => from < tr.newDoc.length)
|
||||
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
|
||||
.map(
|
||||
(range) =>
|
||||
Decoration.mark({
|
||||
id: range.join(':'),
|
||||
// this green is only to verify that the decoration moves when the document is edited
|
||||
// it will be removed later, so the mark is not visible by default
|
||||
attributes: { style: `background-color: #00CA2880` },
|
||||
}).range(...range), // -> Decoration
|
||||
);
|
||||
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
|
||||
}
|
||||
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,83 +75,12 @@ const visibleMiniLocations = StateField.define({
|
||||
},
|
||||
});
|
||||
|
||||
const displayMiniLocationsState = StateField.define({
|
||||
create() {
|
||||
return true; // default to showing miniLocations
|
||||
},
|
||||
update(display, tr) {
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(displayMiniLocations)) {
|
||||
display = e.value;
|
||||
}
|
||||
}
|
||||
return display;
|
||||
},
|
||||
});
|
||||
|
||||
// // Derive the set of decorations from the miniLocations and visibleLocations
|
||||
const miniLocationHighlights = EditorView.decorations.compute(
|
||||
[miniLocations, visibleMiniLocations, displayMiniLocationsState],
|
||||
(state) => {
|
||||
// Check if miniLocations display is disabled
|
||||
const shouldDisplay = state.field(displayMiniLocationsState);
|
||||
if (!shouldDisplay) {
|
||||
return Decoration.none; // Return empty decorations if display is disabled
|
||||
}
|
||||
const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => {
|
||||
const iterator = state.field(miniLocations).iter();
|
||||
const { haps } = state.field(visibleMiniLocations);
|
||||
const builder = new RangeSetBuilder();
|
||||
|
||||
const iterator = state.field(miniLocations).iter();
|
||||
const { haps } = state.field(visibleMiniLocations);
|
||||
const builder = new RangeSetBuilder();
|
||||
|
||||
while (iterator.value) {
|
||||
const {
|
||||
from,
|
||||
to,
|
||||
value: {
|
||||
spec: { id },
|
||||
},
|
||||
} = iterator;
|
||||
|
||||
if (haps.has(id)) {
|
||||
const hap = haps.get(id);
|
||||
const color = hap.value?.color ?? 'var(--foreground)';
|
||||
const style = hap.value?.markcss || `outline: solid 2px ${color}`;
|
||||
// Get explicit channels for color values
|
||||
/*
|
||||
const swatch = document.createElement('div');
|
||||
swatch.style.color = color;
|
||||
document.body.appendChild(swatch);
|
||||
let channels = getComputedStyle(swatch)
|
||||
.color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
|
||||
.slice(1)
|
||||
.map((c) => parseFloat(c || 1));
|
||||
document.body.removeChild(swatch);
|
||||
|
||||
// Get percentage of event
|
||||
const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
|
||||
channels[3] *= percent;
|
||||
*/
|
||||
|
||||
builder.add(
|
||||
from,
|
||||
to,
|
||||
Decoration.mark({
|
||||
// attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
|
||||
attributes: { style },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
iterator.next();
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
},
|
||||
);
|
||||
|
||||
const getMiniLocationsFromDecorations = (decorations) => {
|
||||
const iterator = decorations.iter();
|
||||
const miniLocationsArray = [];
|
||||
while (iterator.value) {
|
||||
const {
|
||||
from,
|
||||
@@ -192,48 +89,50 @@ const getMiniLocationsFromDecorations = (decorations) => {
|
||||
spec: { id },
|
||||
},
|
||||
} = iterator;
|
||||
miniLocationsArray.push({
|
||||
from,
|
||||
to,
|
||||
id,
|
||||
});
|
||||
|
||||
if (haps.has(id)) {
|
||||
const hap = haps.get(id);
|
||||
const color = hap.value?.color ?? 'var(--foreground)';
|
||||
const style = hap.value?.markcss || `outline: solid 2px ${color}`;
|
||||
// Get explicit channels for color values
|
||||
/*
|
||||
const swatch = document.createElement('div');
|
||||
swatch.style.color = color;
|
||||
document.body.appendChild(swatch);
|
||||
let channels = getComputedStyle(swatch)
|
||||
.color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
|
||||
.slice(1)
|
||||
.map((c) => parseFloat(c || 1));
|
||||
document.body.removeChild(swatch);
|
||||
|
||||
// Get percentage of event
|
||||
const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
|
||||
channels[3] *= percent;
|
||||
*/
|
||||
|
||||
builder.add(
|
||||
from,
|
||||
to,
|
||||
Decoration.mark({
|
||||
// attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
|
||||
attributes: { style },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
iterator.next();
|
||||
}
|
||||
return miniLocationsArray;
|
||||
};
|
||||
|
||||
export const getMiniLocations = (state) => {
|
||||
const decorations = state.field(miniLocations);
|
||||
return getMiniLocationsFromDecorations(decorations);
|
||||
};
|
||||
return builder.finish();
|
||||
});
|
||||
|
||||
export const getActiveMiniLocations = (state) => {
|
||||
const miniLocations = getMiniLocations(state);
|
||||
const { haps } = state.field(visibleMiniLocations);
|
||||
|
||||
const activeMiniLocations = miniLocations.filter((location) => haps.has(location.id));
|
||||
return activeMiniLocations;
|
||||
};
|
||||
|
||||
export const highlightExtension = [
|
||||
miniLocations,
|
||||
visibleMiniLocations,
|
||||
displayMiniLocationsState,
|
||||
miniLocationHighlights,
|
||||
];
|
||||
export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights];
|
||||
|
||||
export const isPatternHighlightingEnabled = (on, config) => {
|
||||
// NOTE:
|
||||
// Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed.
|
||||
// This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly.
|
||||
// The setTimeout was also removed because it conflicted with the range-specific updates required by
|
||||
// block based evaluation.
|
||||
// Not sure if this is the best approach, but for block based eval I can't think of a better way to do it.
|
||||
|
||||
if (config) {
|
||||
// Toggle the display state for miniLocations
|
||||
config.editor.dispatch({ effects: displayMiniLocations.of(on) });
|
||||
}
|
||||
|
||||
return Prec.highest(highlightExtension);
|
||||
on &&
|
||||
config &&
|
||||
setTimeout(() => {
|
||||
updateMiniLocations(config.editor, config.miniLocations);
|
||||
}, 100);
|
||||
return on ? Prec.highest(highlightExtension) : [];
|
||||
};
|
||||
|
||||
@@ -3,11 +3,9 @@ import { Prec, EditorState } from '@codemirror/state';
|
||||
import { keymap, ViewPlugin } from '@codemirror/view';
|
||||
// import { searchKeymap } from '@codemirror/search';
|
||||
import { emacs } from '@replit/codemirror-emacs';
|
||||
import { vim, Vim } from '@replit/codemirror-vim';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
// import { vim } from './vim_test.mjs';
|
||||
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
||||
import { helix, commands } from 'codemirror-helix';
|
||||
import { logger } from '@strudel/core';
|
||||
|
||||
const vscodePlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
@@ -21,166 +19,11 @@ const vscodePlugin = ViewPlugin.fromClass(
|
||||
);
|
||||
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
|
||||
|
||||
function replEval(view) {
|
||||
try {
|
||||
// Dispatch a dedicated evaluate event first
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-evaluate event', e);
|
||||
}
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
// Try Ctrl+Enter first if not handled by custom event
|
||||
const ctrlEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(ctrlEnter);
|
||||
// If not handled (no handler called preventDefault), try Alt+Enter as
|
||||
// fallback
|
||||
if (!ctrlEnter.defaultPrevented) {
|
||||
const altEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altEnter);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl evaluation event', e);
|
||||
}
|
||||
}
|
||||
|
||||
function replStop(view) {
|
||||
try {
|
||||
// First try dispatching our custom stop event, then fallback to Alt+.
|
||||
let handled = false;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
|
||||
handled = document.dispatchEvent(ev) === false;
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl-stop event', e);
|
||||
}
|
||||
if (!handled) {
|
||||
const altDot = new KeyboardEvent('keydown', {
|
||||
key: '.',
|
||||
code: 'Period',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
view?.dom?.dispatchEvent?.(altDot);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error dispatching repl stop event', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom
|
||||
// event 'repl-evaluate' that the editor listens for, and also simulate
|
||||
// Ctrl+Enter/Alt+Enter as a fallback. We log to the Strudel logger so it
|
||||
// appears in the Console panel.
|
||||
try {
|
||||
if (Vim && typeof Vim.defineEx === 'function') {
|
||||
// Map gc to toggle line comments by dispatching a custom event that our
|
||||
// CodeMirror integration listens to. This avoids depending on Vim's
|
||||
// internal actions and works with current selections/visual mode.
|
||||
try {
|
||||
Vim.defineAction('strudelToggleComment', (cm) => {
|
||||
const view = cm.cm6;
|
||||
try {
|
||||
const ev = new CustomEvent('repl-toggle-comment', { detail: { source: 'vim', view }, cancelable: true });
|
||||
document.dispatchEvent(ev);
|
||||
} catch (e) {
|
||||
console.error('strudelToggleComment dispatch failed', e);
|
||||
}
|
||||
});
|
||||
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'normal' });
|
||||
Vim.mapCommand('gc', 'action', 'strudelToggleComment', {}, { context: 'visual' });
|
||||
} catch (e) {
|
||||
console.error('Vim gc mapping failed', e);
|
||||
}
|
||||
|
||||
// :q to pause/stop
|
||||
Vim.defineEx('quit', 'q', (cm) => {
|
||||
const view = cm.cm6;
|
||||
logger('[vim] :q — stopping repl');
|
||||
replStop(view);
|
||||
});
|
||||
|
||||
// :w to evaluate
|
||||
Vim.defineEx('write', 'w', (cm) => {
|
||||
const view = cm.cm6;
|
||||
try {
|
||||
view.focus?.();
|
||||
// Let the app know this came from Vim :w
|
||||
try {
|
||||
logger('[vim] :w — evaluating code');
|
||||
} catch (e) {
|
||||
console.error('Error logging Vim :w evaluation', e);
|
||||
}
|
||||
replEval(view);
|
||||
} catch (e) {
|
||||
console.error('Error dispatching :w evaluation event', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (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 = {
|
||||
vim,
|
||||
emacs,
|
||||
codemirror: () => keymap.of(defaultKeymap),
|
||||
vscode: vscodeExtension,
|
||||
helix: () => [helix(), helixCommands],
|
||||
};
|
||||
|
||||
export { Vim } from '@replit/codemirror-vim';
|
||||
|
||||
@@ -44,14 +44,10 @@
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"codemirror-helix": "^0.5.0",
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/draw": "workspace:*",
|
||||
"@strudel/tonal": "workspace:*",
|
||||
"@strudel/transpiler": "workspace:*",
|
||||
"@tonaljs/tonal": "^4.10.0",
|
||||
"nanostores": "^0.11.3",
|
||||
"superdough": "workspace:*"
|
||||
"nanostores": "^0.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
|
||||
+17
-106
@@ -2,11 +2,11 @@ import { ref, pure } from '@strudel/core';
|
||||
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
|
||||
import { StateEffect } from '@codemirror/state';
|
||||
|
||||
// Global state storage for all widget types
|
||||
export let sliderValues = {};
|
||||
const getSliderID = (from) => `slider_${from}`;
|
||||
|
||||
export class SliderWidget extends WidgetType {
|
||||
constructor(value, min, max, from, to, step, view, id) {
|
||||
constructor(value, min, max, from, to, step, view) {
|
||||
super();
|
||||
this.value = value;
|
||||
this.min = min;
|
||||
@@ -16,21 +16,10 @@ export class SliderWidget extends WidgetType {
|
||||
this.to = to;
|
||||
this.step = step;
|
||||
this.view = view;
|
||||
this.id = id || `${from}:${to}`; // Range-based ID for stability
|
||||
}
|
||||
|
||||
eq(other) {
|
||||
if (!(other instanceof SliderWidget)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.id === other.id &&
|
||||
this.from === other.from &&
|
||||
this.to === other.to &&
|
||||
this.value === other.value &&
|
||||
this.min === other.min &&
|
||||
this.max === other.max
|
||||
);
|
||||
eq() {
|
||||
return false;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
@@ -49,7 +38,6 @@ export class SliderWidget extends WidgetType {
|
||||
slider.from = this.from;
|
||||
slider.originalFrom = this.originalFrom;
|
||||
slider.to = this.to;
|
||||
slider.id = this.id; // Store range-based ID in DOM element
|
||||
slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
|
||||
this.slider = slider;
|
||||
slider.addEventListener('input', (e) => {
|
||||
@@ -61,7 +49,7 @@ export class SliderWidget extends WidgetType {
|
||||
slider.originalValue = insert;
|
||||
slider.value = insert;
|
||||
this.view.dispatch({ changes: change });
|
||||
const id = slider.id; // Use range-based ID
|
||||
const id = getSliderID(slider.originalFrom); // matches id generated in transpiler
|
||||
window.postMessage({ type: 'cm-slider', value: Number(next), id });
|
||||
});
|
||||
return wrap;
|
||||
@@ -74,60 +62,19 @@ export class SliderWidget extends WidgetType {
|
||||
|
||||
export const setSliderWidgets = StateEffect.define();
|
||||
|
||||
export const setSliderWidgetsInRange = StateEffect.define();
|
||||
|
||||
export const updateSliderWidgets = (view, widgets, range = null) => {
|
||||
if (range) {
|
||||
// range argument passed for block-based evaluation
|
||||
view.dispatch({ effects: setSliderWidgetsInRange.of({ widgets, range }) });
|
||||
} else {
|
||||
view.dispatch({ effects: setSliderWidgets.of(widgets) });
|
||||
}
|
||||
export const updateSliderWidgets = (view, widgets) => {
|
||||
view.dispatch({ effects: setSliderWidgets.of(widgets) });
|
||||
};
|
||||
|
||||
function getSliders(widgetConfigs, view) {
|
||||
return (
|
||||
widgetConfigs
|
||||
.filter((w) => w.type === 'slider')
|
||||
// Deduplicate sliders that might appear multiple times (e.g., during paste operations)
|
||||
.filter((slider, index, self) => index === self.findIndex((s) => s.from === slider.from && s.to === slider.to))
|
||||
.sort((a, b) => a.from - b.from)
|
||||
.map(({ from, to, value, min, max, step, id }) => {
|
||||
return Decoration.widget({
|
||||
widget: new SliderWidget(value, min, max, from, to, step, view, id),
|
||||
side: 0,
|
||||
}).range(from /* , to */);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function getSliderWidgets(view) {
|
||||
if (!view || !view.state) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sliderPluginInstance = view.plugin(sliderPlugin);
|
||||
if (!sliderPluginInstance || !sliderPluginInstance.decorations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sliderWidgets = [];
|
||||
|
||||
sliderPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||
if (decoration.widget instanceof SliderWidget) {
|
||||
sliderWidgets.push({
|
||||
type: 'slider',
|
||||
from: decoration.widget.from,
|
||||
to: decoration.widget.to,
|
||||
value: decoration.widget.value,
|
||||
min: decoration.widget.min,
|
||||
max: decoration.widget.max,
|
||||
step: decoration.widget.step,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return sliderWidgets;
|
||||
return widgetConfigs
|
||||
.filter((w) => w.type === 'slider')
|
||||
.map(({ from, to, value, min, max, step }) => {
|
||||
return Decoration.widget({
|
||||
widget: new SliderWidget(value, min, max, from, to, step, view),
|
||||
side: 0,
|
||||
}).range(from /* , to */);
|
||||
});
|
||||
}
|
||||
|
||||
export const sliderPlugin = ViewPlugin.fromClass(
|
||||
@@ -154,42 +101,7 @@ export const sliderPlugin = ViewPlugin.fromClass(
|
||||
}
|
||||
}
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(setSliderWidgetsInRange)) {
|
||||
// Block-aware slider update logic
|
||||
const { widgets, range } = e.value;
|
||||
const [rangeStart, rangeEnd] = range;
|
||||
|
||||
// Get existing slider widgets that should be preserved
|
||||
const existingSliders = [];
|
||||
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
|
||||
if (decoration.widget instanceof SliderWidget) {
|
||||
// Preserve sliders outside the evaluation range
|
||||
// Use strict > for rangeEnd because when code is deleted, slider positions
|
||||
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
|
||||
if (from < rangeStart || from > rangeEnd) {
|
||||
existingSliders.push({
|
||||
from,
|
||||
to,
|
||||
value: decoration.widget.value,
|
||||
min: decoration.widget.min,
|
||||
max: decoration.widget.max,
|
||||
step: decoration.widget.step,
|
||||
id: decoration.widget.id || `${from}:${to}`,
|
||||
type: 'slider',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Merge preserved sliders with new widgets
|
||||
const mergedWidgets = [...existingSliders, ...widgets]
|
||||
.filter(
|
||||
(slider, index, self) => index === self.findIndex((s) => s.type === 'slider' && s.id === slider.id),
|
||||
)
|
||||
.sort((a, b) => a.from - b.from);
|
||||
|
||||
this.decorations = Decoration.set(getSliders(mergedWidgets, update.view));
|
||||
} else if (e.is(setSliderWidgets)) {
|
||||
if (e.is(setSliderWidgets)) {
|
||||
this.decorations = Decoration.set(getSliders(e.value, update.view));
|
||||
}
|
||||
}
|
||||
@@ -220,7 +132,6 @@ export let sliderWithID = (id, value, min, max) => {
|
||||
sliderValues[id] = value; // sync state at eval time (code -> state)
|
||||
return ref(() => sliderValues[id]); // use state at query time
|
||||
};
|
||||
|
||||
// update state when sliders are moved
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', (e) => {
|
||||
@@ -229,7 +140,7 @@ if (typeof window !== 'undefined') {
|
||||
// update state when slider is moved
|
||||
sliderValues[e.data.id] = e.data.value;
|
||||
} else {
|
||||
console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
||||
console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+45
-193
@@ -1,111 +1,55 @@
|
||||
import { StateEffect, StateField } from '@codemirror/state';
|
||||
import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
|
||||
import { Pattern } from '@strudel/core';
|
||||
|
||||
export const setWidgets = StateEffect.define();
|
||||
export const addWidget = StateEffect.define({
|
||||
map: ({ from, to }, change) => {
|
||||
return { from: change.mapPos(from), to: change.mapPos(to) };
|
||||
},
|
||||
});
|
||||
|
||||
export const setWidgetsInRange = StateEffect.define();
|
||||
|
||||
export const updateWidgets = (view, widgets, range = null) => {
|
||||
if (range) {
|
||||
// range argument passed for block-based evaluation
|
||||
view.dispatch({ effects: setWidgetsInRange.of({ widgets, range }) });
|
||||
} else {
|
||||
view.dispatch({ effects: setWidgets.of(widgets) });
|
||||
}
|
||||
export const updateWidgets = (view, widgets) => {
|
||||
view.dispatch({ effects: addWidget.of(widgets) });
|
||||
};
|
||||
|
||||
function getWidgets(widgetConfigs, view) {
|
||||
const filtered = widgetConfigs
|
||||
// Filter to widget configs only (exclude sliders)
|
||||
.filter((w) => w && w.type && w.type !== 'slider')
|
||||
// Deduplicate widgets by ID, matching slider behavior for stable widget identity
|
||||
.filter((widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id));
|
||||
|
||||
// Filter out widgets whose range is encompassed by another widget
|
||||
// const nonEncompassed = filterEncompassedWidgets(filtered);
|
||||
|
||||
return filtered
|
||||
.sort((a, b) => (a.to || 0) - (b.to || 0))
|
||||
.map((widgetConfig) => {
|
||||
try {
|
||||
function getWidgets(widgetConfigs) {
|
||||
return (
|
||||
widgetConfigs
|
||||
// codemirror throws an error if we don't sort
|
||||
.sort((a, b) => a.to - b.to)
|
||||
.map((widgetConfig) => {
|
||||
return Decoration.widget({
|
||||
widget: new BlockWidget(widgetConfig, view),
|
||||
widget: new BlockWidget(widgetConfig),
|
||||
side: 0,
|
||||
}).range(widgetConfig.to || widgetConfig.from || 0);
|
||||
} catch (error) {
|
||||
console.error('error creating widget', error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean); // Remove any null results from failed creations
|
||||
block: true,
|
||||
}).range(widgetConfig.to);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export const widgetPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations; //: DecorationSet
|
||||
|
||||
constructor(view /* : EditorView */) {
|
||||
this.decorations = Decoration.set([]);
|
||||
}
|
||||
|
||||
update(update /* : ViewUpdate */) {
|
||||
update.transactions.forEach((tr) => {
|
||||
if (tr.docChanged) {
|
||||
this.decorations = this.decorations.map(tr.changes);
|
||||
const iterator = this.decorations.iter();
|
||||
// Apply changes to iterator.from and iterator.to if docChanged
|
||||
while (iterator.value) {
|
||||
// when the widgets are moved, we need to tell the dom node the current position
|
||||
// this is important because the widget functions have to work with the dom node
|
||||
if (iterator.value?.widget instanceof BlockWidget) {
|
||||
iterator.value.widget.from = iterator.from;
|
||||
iterator.value.widget.to = iterator.to;
|
||||
}
|
||||
iterator.next();
|
||||
}
|
||||
}
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(setWidgetsInRange)) {
|
||||
// Block-aware widget update logic
|
||||
const { widgets, range } = e.value;
|
||||
const [rangeStart, rangeEnd] = range;
|
||||
|
||||
// Get existing widget widgets that should be preserved
|
||||
const existingWidgets = [];
|
||||
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
|
||||
if (decoration.widget instanceof BlockWidget) {
|
||||
// Preserve widgets outside the evaluation range
|
||||
// Use strict > for rangeEnd because when code is deleted, widget positions
|
||||
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
|
||||
if (from < rangeStart || from > rangeEnd) {
|
||||
existingWidgets.push({
|
||||
from: decoration.widget.from,
|
||||
to: decoration.widget.to,
|
||||
type: decoration.widget.type,
|
||||
index: decoration.widget.index,
|
||||
id: decoration.widget.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
const widgetField = StateField.define(
|
||||
/* <DecorationSet> */ {
|
||||
create() {
|
||||
return Decoration.none;
|
||||
},
|
||||
update(widgets, tr) {
|
||||
widgets = widgets.map(tr.changes);
|
||||
for (let e of tr.effects) {
|
||||
if (e.is(addWidget)) {
|
||||
try {
|
||||
widgets = widgets.update({
|
||||
filter: () => false,
|
||||
add: getWidgets(e.value),
|
||||
});
|
||||
|
||||
// Merge preserved widgets with new widgets, deduplicating by ID
|
||||
const mergedWidgets = [...existingWidgets, ...widgets].filter(
|
||||
(widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id),
|
||||
);
|
||||
|
||||
this.decorations = Decoration.set(getWidgets(mergedWidgets, update.view));
|
||||
} else if (e.is(setWidgets)) {
|
||||
this.decorations = Decoration.set(getWidgets(e.value, update.view));
|
||||
} catch (error) {
|
||||
console.log('err', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
}
|
||||
return widgets;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -116,116 +60,24 @@ export function setWidget(id, el) {
|
||||
}
|
||||
|
||||
export class BlockWidget extends WidgetType {
|
||||
constructor(widgetConfig, view) {
|
||||
constructor(widgetConfig) {
|
||||
super();
|
||||
|
||||
// Graceful handling of invalid configs like sliders
|
||||
if (!widgetConfig || typeof widgetConfig !== 'object') {
|
||||
widgetConfig = { type: 'unknown', from: 0, to: 0 };
|
||||
}
|
||||
|
||||
this.from = widgetConfig.from || 0;
|
||||
this.originalFrom = widgetConfig.from || 0;
|
||||
this.to = widgetConfig.to || this.from;
|
||||
this.originalTo = widgetConfig.to || this.from;
|
||||
this.type = widgetConfig.type || 'unknown';
|
||||
this.index = widgetConfig.index || 0;
|
||||
this.view = view;
|
||||
|
||||
// Use range-based ID for stability, similar to sliders
|
||||
this.id = widgetConfig.id || getWidgetID?.(widgetConfig);
|
||||
this.widgetConfig = widgetConfig;
|
||||
}
|
||||
|
||||
eq(other) {
|
||||
if (!(other instanceof BlockWidget)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.id === other.id &&
|
||||
this.from === other.from &&
|
||||
this.to === other.to &&
|
||||
this.type === other.type &&
|
||||
this.index === other.index
|
||||
);
|
||||
eq() {
|
||||
return true;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
let wrap = document.createElement('span');
|
||||
wrap.setAttribute('aria-hidden', 'true');
|
||||
wrap.className = 'cm-widget-container';
|
||||
|
||||
let el = widgetElements[this.id];
|
||||
if (el) {
|
||||
// Ensure the element has the correct ID
|
||||
el.id = this.id;
|
||||
wrap.appendChild(el);
|
||||
} else {
|
||||
// Create a placeholder element if the widget element doesn't exist
|
||||
// This prevents CodeMirror errors when widget is missing
|
||||
const placeholder = document.createElement('span');
|
||||
placeholder.setAttribute('aria-hidden', 'true');
|
||||
placeholder.className = 'cm-widget-placeholder';
|
||||
placeholder.style.cssText = 'display: none;'; // Hide placeholder
|
||||
placeholder.id = this.id;
|
||||
wrap.appendChild(placeholder);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
const id = getWidgetID(this.widgetConfig);
|
||||
const el = widgetElements[id];
|
||||
return el;
|
||||
}
|
||||
|
||||
ignoreEvent(e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveWidgets(view) {
|
||||
if (!view || !view.state) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const widgetPluginInstance = view.plugin(widgetPlugin);
|
||||
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const widgets = [];
|
||||
|
||||
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||
if (decoration.widget instanceof BlockWidget) {
|
||||
widgets.push({
|
||||
type: decoration.widget.type,
|
||||
from: decoration.widget.from,
|
||||
to: decoration.widget.to,
|
||||
index: decoration.widget.index,
|
||||
id: decoration.widget.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
export function getAllWidgetIds(view) {
|
||||
if (!view || !view.state) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const widgetPluginInstance = view.plugin(widgetPlugin);
|
||||
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const widgetIds = [];
|
||||
|
||||
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
|
||||
if (decoration.widget instanceof BlockWidget) {
|
||||
widgetIds.push(decoration.widget.id);
|
||||
}
|
||||
});
|
||||
|
||||
return widgetIds;
|
||||
}
|
||||
export const widgetPlugin = [widgetField];
|
||||
|
||||
// widget implementer API to create a new widget type
|
||||
export function registerWidget(type, fn) {
|
||||
|
||||
+16
-10
@@ -118,7 +118,7 @@ export const { s, sound } = registerControl(['s', 'n', 'gain'], 'sound');
|
||||
* @param {number | Pattern} position Position in the wavetable from 0 to 1
|
||||
* @synonyms wavetablePosition
|
||||
* @example
|
||||
* s("squelch").bank("wt_digital").seg(8).note("F1").wt("0 0.25 0.5 0.75 1")
|
||||
* s("wt_digital").seg(8).note("F1").wt("0 0.25 0.5 0.75 1")
|
||||
*/
|
||||
export const { wt, wavetablePosition } = registerControl('wt', 'wavetablePosition');
|
||||
|
||||
@@ -519,10 +519,10 @@ export const { fmh, fmh1, fmh2, fmh3, fmh4, fmh5, fmh6, fmh7, fmh8 } = registerM
|
||||
* each other with matrix commands like `fm13`, which would send `fm1` back into
|
||||
* `fm3`
|
||||
*
|
||||
* @name fmi
|
||||
* @name fm
|
||||
* @tags fm, superdough, supradough
|
||||
* @param {number | Pattern} brightness modulation index
|
||||
* @synonyms fm
|
||||
* @synonyms fmi
|
||||
* @example
|
||||
* note("c e g b g e")
|
||||
* .fm("<0 1 2 8 32>")
|
||||
@@ -1988,11 +1988,10 @@ export const { spread } = registerControl('spread');
|
||||
* Set dryness of reverb. See `room` and `size` for more information about reverb.
|
||||
*
|
||||
* @name dry
|
||||
* @tags superdirt
|
||||
* @tags superdirt, superdough
|
||||
* @param {number | Pattern} dry 0 = wet, 1 = dry
|
||||
* @example
|
||||
* n("[0,3,7](3,8)").s("superpiano").room(.7).dry("<0 .5 .75 1>").osc()
|
||||
* @superdirtOnly
|
||||
*
|
||||
*/
|
||||
export const { dry } = registerControl('dry');
|
||||
@@ -2226,7 +2225,7 @@ export const { orbit } = registerControl('orbit', 'o');
|
||||
* otherPat.bmod(..) (to modulate another pattern with the bus)
|
||||
*
|
||||
* @name bus
|
||||
* @tags superdirt
|
||||
* @tags superdough
|
||||
* @param {number | Pattern} number
|
||||
*/
|
||||
export const { bus } = registerControl('bus');
|
||||
@@ -2235,7 +2234,7 @@ export const { bus } = registerControl('bus');
|
||||
* Postgain multiplier prior to sending the signal to the audio bus.
|
||||
*
|
||||
* @name busgain
|
||||
* @tags superdirt
|
||||
* @tags superdough
|
||||
* @synonyms bgain
|
||||
* @param {number | Pattern} number
|
||||
*/
|
||||
@@ -2352,11 +2351,18 @@ export const { offset } = registerControl('offset');
|
||||
**/
|
||||
export const { octaves } = registerControl('octaves');
|
||||
/**
|
||||
* Remove anchor note from the voicing. Useful for melody harmonization
|
||||
* How the voicing is aligned to the anchor
|
||||
* - `below`: top note <= anchor
|
||||
* - `duck`: top note <= anchor, anchor excluded
|
||||
* - `above`: bottom note >= anchor
|
||||
* - `root`: bottom note is the lowest root of the chord >= anchor
|
||||
*
|
||||
* - `oldabove` : old (buggy) behavior of above, kept for legacy reason
|
||||
* - `oldroot` : old (buggy) behavior of root, kept for legacy reason
|
||||
*
|
||||
* @name mode
|
||||
* @tags tonal
|
||||
* @param {string | Pattern} modeName one of {below | above | duck | root}
|
||||
* @param {string | Pattern} modeName one of {below | above | duck | root | oldabove | oldroot}
|
||||
* @example
|
||||
* mode("<below above duck root>").chord("C").voicing()
|
||||
*
|
||||
@@ -2370,7 +2376,7 @@ export const { mode } = registerControl(['mode', 'anchor']);
|
||||
*
|
||||
* @name room
|
||||
* @tags orbit, superdough
|
||||
* @param {number | Pattern} level between 0 and 1
|
||||
* @param {number | Pattern} level
|
||||
* @example
|
||||
* s("bd sd [~ bd] sd").room("<0 .2 .4 .6 .8 1>")
|
||||
* @example
|
||||
|
||||
@@ -10,6 +10,7 @@ import { errorLogger, logger } from './logger.mjs';
|
||||
export class Cyclist {
|
||||
constructor({
|
||||
interval,
|
||||
onSchedule,
|
||||
onTrigger,
|
||||
onToggle,
|
||||
onError,
|
||||
@@ -19,6 +20,11 @@ export class Cyclist {
|
||||
clearInterval,
|
||||
beforeStart,
|
||||
}) {
|
||||
if (onTrigger) {
|
||||
throw new Error(
|
||||
'onTrigger has been dropped. use onSchedule(hap, time, cps) instead, see https://codeberg.org/uzu/strudel/pulls/2138',
|
||||
);
|
||||
}
|
||||
this.started = false;
|
||||
this.beforeStart = beforeStart;
|
||||
this.cps = 0.5;
|
||||
@@ -63,12 +69,7 @@ export class Cyclist {
|
||||
if (hap.hasOnset()) {
|
||||
const targetTime =
|
||||
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
|
||||
const duration = hap.duration / this.cps;
|
||||
// the following line is dumb and only here for backwards compatibility
|
||||
// see https://codeberg.org/uzu/strudel/pulls/1004
|
||||
const deadline = targetTime - phase;
|
||||
// this onTrigger has another signature
|
||||
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
|
||||
onSchedule?.(hap, targetTime, this.cps);
|
||||
if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
|
||||
this.cps = hap.value.cps;
|
||||
this.num_ticks_since_cps_change = 0;
|
||||
|
||||
+45
-19
@@ -59,6 +59,11 @@ export const bjorklund = function (ons, steps) {
|
||||
* really useful for computer/algorithmic music because they can
|
||||
* describe a large number of rhythms with a couple of numbers.
|
||||
*
|
||||
* A negative number of pulses inverts the pattern
|
||||
* so pulses happen in the gaps of the normal euclidean rhythm.
|
||||
*
|
||||
* A negative number of steps changes it into `euclidLegato`.
|
||||
*
|
||||
* @memberof Pattern
|
||||
* @name euclid
|
||||
* @tags temporal
|
||||
@@ -137,8 +142,38 @@ const _euclidRot = function (pulses, steps, rotation) {
|
||||
return b;
|
||||
};
|
||||
|
||||
const _euclidLegato = function (pulses, steps, rotation, pat) {
|
||||
var bin_pat = _euclidRot(pulses, steps, 0);
|
||||
// Because the `gapless` algorithm logic assumes the first entry in bin_pat is a 1,
|
||||
// we rotate the pattern so the first onset is at the start.
|
||||
const firstOnset = bin_pat.indexOf(1);
|
||||
bin_pat = rotate(bin_pat, firstOnset);
|
||||
steps = Math.abs(steps);
|
||||
const gapless = bin_pat
|
||||
.join('')
|
||||
.split('1')
|
||||
.slice(1)
|
||||
.map((s) => [s.length + 1, true]);
|
||||
return pat.struct(timeCat(...gapless).late(Fraction(rotation + firstOnset).div(steps)));
|
||||
};
|
||||
|
||||
const _euclid = function (pulses, steps, rotation, pat) {
|
||||
if (steps === 0 || pulses === 0) {
|
||||
return silence;
|
||||
}
|
||||
const cycles = Math.ceil(Math.abs(pulses) / Math.abs(steps));
|
||||
const totalSteps = cycles * steps;
|
||||
if (totalSteps < 0) {
|
||||
return _euclidLegato(pulses, -totalSteps, rotation, pat.fast(cycles)).slow(cycles);
|
||||
}
|
||||
return pat
|
||||
.fast(cycles)
|
||||
.struct(_euclidRot(pulses, totalSteps, rotation))
|
||||
.slow(cycles);
|
||||
};
|
||||
|
||||
export const euclid = register('euclid', function (pulses, steps, pat) {
|
||||
return pat.struct(_euclidRot(pulses, steps, 0));
|
||||
return _euclid(pulses, steps, 0, pat);
|
||||
});
|
||||
|
||||
export const bjork = register('bjork', function (euc, pat) {
|
||||
@@ -146,16 +181,18 @@ export const bjork = register('bjork', function (euc, pat) {
|
||||
euc = [euc];
|
||||
}
|
||||
const [pulses, steps = pulses, rot = 0] = euc;
|
||||
return pat.struct(_euclidRot(pulses, steps, rot));
|
||||
return _euclid(pulses, steps, rot, pat);
|
||||
});
|
||||
|
||||
export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], function (pulses, steps, rotation, pat) {
|
||||
return pat.struct(_euclidRot(pulses, steps, rotation));
|
||||
return _euclid(pulses, steps, rotation, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* Similar to `euclid`, but each pulse is held until the next pulse,
|
||||
* so there will be no gaps.
|
||||
*
|
||||
* A negative number of steps changes it into `euclid`.
|
||||
* @name euclidLegato
|
||||
* @memberof Pattern
|
||||
* @tags temporal
|
||||
@@ -167,27 +204,16 @@ export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], fun
|
||||
* note("c3").euclidLegato(3,8)
|
||||
*/
|
||||
|
||||
const _euclidLegato = function (pulses, steps, rotation, pat) {
|
||||
if (pulses < 1) {
|
||||
return silence;
|
||||
}
|
||||
const bin_pat = _euclidRot(pulses, steps, 0);
|
||||
const gapless = bin_pat
|
||||
.join('')
|
||||
.split('1')
|
||||
.slice(1)
|
||||
.map((s) => [s.length + 1, true]);
|
||||
return pat.struct(timeCat(...gapless)).late(Fraction(rotation).div(steps));
|
||||
};
|
||||
|
||||
export const euclidLegato = register(['euclidLegato'], function (pulses, steps, pat) {
|
||||
return _euclidLegato(pulses, steps, 0, pat);
|
||||
return _euclid(pulses, -steps, 0, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
* Similar to `euclid`, but each pulse is held until the next pulse,
|
||||
* so there will be no gaps, and has an additional parameter for 'rotating'
|
||||
* the resulting sequence
|
||||
* the resulting sequence.
|
||||
*
|
||||
* A negative number of steps changes it into `euclidRot`.
|
||||
* @name euclidLegatoRot
|
||||
* @memberof Pattern
|
||||
* @tags temporal
|
||||
@@ -198,7 +224,7 @@ export const euclidLegato = register(['euclidLegato'], function (pulses, steps,
|
||||
* note("c3").euclidLegatoRot(3,5,2)
|
||||
*/
|
||||
export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, steps, rotation, pat) {
|
||||
return _euclidLegato(pulses, steps, rotation, pat);
|
||||
return _euclid(pulses, -steps, rotation, pat);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,34 +5,6 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
export const strudelScope = {};
|
||||
// Make strudelScope available globally so transpiled code can access it
|
||||
globalThis.strudelScope = strudelScope;
|
||||
|
||||
// Track user-defined keys (from block-based eval) so we can clear them without removing strudel functions
|
||||
export const userDefinedKeys = new Set();
|
||||
globalThis.userDefinedKeys = userDefinedKeys;
|
||||
|
||||
/**
|
||||
* Clears all user-defined variables and functions from the scope.
|
||||
* This removes variables created during block-based evaluation.
|
||||
* @name clearScope
|
||||
* @example
|
||||
* // After defining variables in blocks:
|
||||
* // let myVar = 5
|
||||
* // function myFunc() { return 10; }
|
||||
* clearScope() // removes myVar and myFunc from scope
|
||||
*/
|
||||
export const clearScope = () => {
|
||||
for (const key of userDefinedKeys) {
|
||||
delete strudelScope[key];
|
||||
delete globalThis[key];
|
||||
}
|
||||
userDefinedKeys.clear();
|
||||
// Return silence if available (for use in pattern expressions), otherwise undefined
|
||||
return globalThis.silence;
|
||||
};
|
||||
// Make clearScope available globally
|
||||
globalThis.clearScope = clearScope;
|
||||
|
||||
export const evalScope = async (...args) => {
|
||||
const results = await Promise.allSettled(args);
|
||||
|
||||
@@ -8,7 +8,12 @@ import { logger } from './logger.mjs';
|
||||
import { ClockCollator, cycleToSeconds } from './util.mjs';
|
||||
|
||||
export class NeoCyclist {
|
||||
constructor({ onTrigger, onToggle, getTime }) {
|
||||
constructor({ onSchedule, onToggle, getTime, onTrigger }) {
|
||||
if (onTrigger) {
|
||||
throw new Error(
|
||||
'onTrigger has been dropped. use onSchedule(hap, time, cps) instead, see https://codeberg.org/uzu/strudel/pulls/2138',
|
||||
);
|
||||
}
|
||||
this.started = false;
|
||||
this.cps = 0.5;
|
||||
this.getTime = getTime; // get absolute time
|
||||
@@ -43,8 +48,7 @@ export class NeoCyclist {
|
||||
if (hap.hasOnset()) {
|
||||
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
|
||||
const targetTime = timeUntilTrigger + currentTime + this.latency;
|
||||
const duration = cycleToSeconds(hap.duration, this.cps);
|
||||
onTrigger?.(hap, 0, duration, this.cps, targetTime);
|
||||
onSchedule?.(hap, targetTime, this.cps);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
+469
-51
@@ -27,6 +27,7 @@ import {
|
||||
import drawLine from './drawLine.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
import { strudelScope } from './evaluate.mjs';
|
||||
import { getClockTime } from './schedulerState.mjs';
|
||||
|
||||
let stringParser;
|
||||
|
||||
@@ -504,10 +505,25 @@ export class Pattern {
|
||||
|
||||
/**
|
||||
* Returns a new pattern with the given function applied to the list of haps returned by every query.
|
||||
*
|
||||
* As an end user, you will most likely not need to use this; look at `withContext`, `withHapSpan`, etc. instead.
|
||||
* @tags internals
|
||||
* @param {Function} func
|
||||
* @returns Pattern
|
||||
* @noAutocomplete
|
||||
* @example
|
||||
* note("[a b, c d] a b c d").s("gm_piano").withHaps(x=>{
|
||||
* if (x.length > 1) {
|
||||
* x = x.map(hap => hap.withValue(val => {
|
||||
* return {
|
||||
* n: 1,
|
||||
* // select gm_piano:1 sample
|
||||
* ...val
|
||||
* }
|
||||
* }));
|
||||
* }
|
||||
* return x
|
||||
* })
|
||||
*/
|
||||
withHaps(func) {
|
||||
const result = new Pattern((state) => func(this.query(state), state));
|
||||
@@ -517,10 +533,23 @@ export class Pattern {
|
||||
|
||||
/**
|
||||
* As with `withHaps`, but applies the function to every hap, rather than every list of haps.
|
||||
*
|
||||
* As an end user, you will most likely not need to use this; look at `withContext`, `withHapSpan`, etc. instead.
|
||||
* @tags internals
|
||||
* @param {Function} func
|
||||
* @returns Pattern
|
||||
* @noAutocomplete
|
||||
* @example
|
||||
* note("c a f e").withHap(x=>{
|
||||
* if (x.value.note == 'c') {
|
||||
* x.value.s = 'gm_piano'
|
||||
* }
|
||||
* else {
|
||||
* x.value.s = 'gm_piano'
|
||||
* x.value.n = 2
|
||||
* }
|
||||
* return x
|
||||
* })
|
||||
*/
|
||||
withHap(func) {
|
||||
return this.withHaps((haps) => haps.map(func));
|
||||
@@ -850,6 +879,26 @@ export class Pattern {
|
||||
return stack(this, ...pats);
|
||||
}
|
||||
|
||||
stackLeft(...pats) {
|
||||
return stackLeft(this, ...pats);
|
||||
}
|
||||
|
||||
stackRight(...pats) {
|
||||
return stackRight(this, ...pats);
|
||||
}
|
||||
|
||||
stackCenter(...pats) {
|
||||
return stackCenter(this, ...pats);
|
||||
}
|
||||
|
||||
stackCentre(...pats) {
|
||||
return stackCentre(this, ...pats);
|
||||
}
|
||||
|
||||
stackRepeat(...pats) {
|
||||
return stackRepeat(this, ...pats);
|
||||
}
|
||||
|
||||
sequence(...pats) {
|
||||
return sequence(this, ...pats);
|
||||
}
|
||||
@@ -872,23 +921,41 @@ export class Pattern {
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Context methods - ones that deal with metadata
|
||||
|
||||
onTrigger(onTrigger, dominant = true) {
|
||||
/**
|
||||
* Attaches a function that is called for each hap onset.
|
||||
* @name onSchedule
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* s("bd sd").onSchedule((hap, time, cps) => {
|
||||
* console.log('hap scheduled', time, cps);
|
||||
* })
|
||||
*/
|
||||
onSchedule(onSchedule, dominant = true) {
|
||||
return this.withHap((hap) =>
|
||||
hap.setContext({
|
||||
...hap.context,
|
||||
onTrigger: (...args) => {
|
||||
onSchedule: (scheduleHap, t, cps) => {
|
||||
// run previously set trigger, if it exists
|
||||
hap.context.onTrigger?.(...args);
|
||||
onTrigger(...args);
|
||||
hap.context.onSchedule?.(scheduleHap, t, cps);
|
||||
onSchedule(scheduleHap, t, cps);
|
||||
},
|
||||
// if dominantTrigger is set to true, the default output (webaudio) will be disabled
|
||||
// if dominantSchedule is set to true, the default output (webaudio) will be disabled
|
||||
// when using multiple triggers, you cannot flip this flag to false again!
|
||||
// example: x.csound('CooLSynth').log() as well as x.log().csound('CooLSynth') should work the same
|
||||
dominantTrigger: hap.context.dominantTrigger || dominant,
|
||||
dominantSchedule: hap.context.dominantSchedule || dominant,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// legacy:
|
||||
onTrigger(onTrigger, dominant = true) {
|
||||
console.warn('onTrigger is deprecated.. use onSchedule(hap, time, cps) instead');
|
||||
return this.onSchedule((hap, t, cps) => {
|
||||
const currentTime = getClockTime();
|
||||
return onTrigger(hap, currentTime, cps, t);
|
||||
}, dominant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the content of the current event to the console (visible in the side menu).
|
||||
* @tags visualization
|
||||
@@ -898,7 +965,7 @@ export class Pattern {
|
||||
* s("bd sd").log()
|
||||
*/
|
||||
log(func = (hap) => `[hap] ${hap.showWhole(true)}`, getData = (hap) => ({ hap })) {
|
||||
return this.onTrigger((...args) => {
|
||||
return this.onSchedule((...args) => {
|
||||
logger(func(...args), undefined, getData(...args));
|
||||
}, false);
|
||||
}
|
||||
@@ -1002,7 +1069,7 @@ export const arpWith = register('arpWith', (func, pat) => {
|
||||
* */
|
||||
export const arp = register(
|
||||
'arp',
|
||||
(indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[i % haps.length])),
|
||||
(indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[_mod(i, haps.length)])),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -1074,7 +1141,7 @@ const COMPOSERS = {
|
||||
* @param {Pattern} pat
|
||||
* @returns {Pattern}
|
||||
* @memberof Pattern
|
||||
* @tags internal, combiners
|
||||
* @tags internals, combiners
|
||||
* @example
|
||||
* // because input pattern has `s` set,
|
||||
* // it overrides the "sine" declared earlier
|
||||
@@ -1099,7 +1166,7 @@ const COMPOSERS = {
|
||||
* @param {Pattern} pat
|
||||
* @memberof Pattern
|
||||
* @returns {Pattern}
|
||||
* @tags internal, combiners
|
||||
* @tags internals, combiners
|
||||
* @example
|
||||
* // notes, already defined, will stay "c a f e",
|
||||
* // while "s", not defined, will be set to "piano"
|
||||
@@ -1156,24 +1223,163 @@ const COMPOSERS = {
|
||||
* @tags math
|
||||
*/
|
||||
div: [numeralArgs((a, b) => a / b)],
|
||||
/**
|
||||
*
|
||||
* Divides each number by the given factor and returns the remainder
|
||||
* @name mod
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
*/
|
||||
mod: [numeralArgs(_mod)],
|
||||
/**
|
||||
*
|
||||
* Raises each number to the power of the given factor
|
||||
* @name pow
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
*/
|
||||
pow: [numeralArgs(Math.pow)],
|
||||
/**
|
||||
*
|
||||
* Performs a bitwise `and` on each number and the given factor
|
||||
* @name band
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
*/
|
||||
band: [numeralArgs((a, b) => a & b)],
|
||||
/**
|
||||
*
|
||||
* Performs a bitwise `or` on each number and the given factor
|
||||
* @name bor
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
*/
|
||||
bor: [numeralArgs((a, b) => a | b)],
|
||||
/**
|
||||
*
|
||||
* Performs a bitwise `xor` on each number and the given factor
|
||||
* @name bxor
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
*/
|
||||
bxor: [numeralArgs((a, b) => a ^ b)],
|
||||
/**
|
||||
*
|
||||
* Performs a bitwise `left shift` on each number by the given factor
|
||||
* @name blshift
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
*/
|
||||
blshift: [numeralArgs((a, b) => a << b)],
|
||||
/**
|
||||
*
|
||||
* Performs a bitwise `right shift` on each number by the given factor
|
||||
* @name brshift
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
*/
|
||||
brshift: [numeralArgs((a, b) => a >> b)],
|
||||
|
||||
// TODO - force numerical comparison if both look like numbers?
|
||||
/**
|
||||
* performs a < b
|
||||
* @name lt
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @returns Pattern with boolean value(s)
|
||||
*/
|
||||
lt: [(a, b) => a < b],
|
||||
/**
|
||||
* performs a > b
|
||||
* @name gt
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @returns Pattern with boolean value(s)
|
||||
*/
|
||||
gt: [(a, b) => a > b],
|
||||
/**
|
||||
* performs a <= b
|
||||
* @name lte
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @returns Pattern with boolean value(s)
|
||||
*/
|
||||
lte: [(a, b) => a <= b],
|
||||
/**
|
||||
* performs a >= b
|
||||
* @name gte
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @returns Pattern with boolean value(s)
|
||||
*/
|
||||
gte: [(a, b) => a >= b],
|
||||
/**
|
||||
* performs a == b
|
||||
* @name eq
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @returns Pattern with boolean value(s)
|
||||
*/
|
||||
eq: [(a, b) => a == b],
|
||||
/**
|
||||
* performs a === b (strictly equal)
|
||||
* @name eqt
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @returns Pattern with boolean value(s)
|
||||
*/
|
||||
eqt: [(a, b) => a === b],
|
||||
/**
|
||||
* performs a != b
|
||||
* @name ne
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @returns Pattern with boolean value(s)
|
||||
*/
|
||||
ne: [(a, b) => a != b],
|
||||
/**
|
||||
* performs a !== b (strictly not equal)
|
||||
* @name net
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @returns Pattern with boolean value(s)
|
||||
*/
|
||||
net: [(a, b) => a !== b],
|
||||
/**
|
||||
* performs a && b
|
||||
* @name and
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @returns Pattern with boolean value(s)
|
||||
*/
|
||||
and: [(a, b) => a && b],
|
||||
/**
|
||||
* performs a || b
|
||||
* @name or
|
||||
* @memberof Pattern
|
||||
* @tags math
|
||||
* @param {any} a
|
||||
* @param {any} b
|
||||
* @returns Pattern with boolean value(s)
|
||||
*/
|
||||
or: [(a, b) => a || b],
|
||||
|
||||
// bitwise ops
|
||||
@@ -1449,7 +1655,19 @@ export function sequenceP(pats) {
|
||||
export function stack(...pats) {
|
||||
// Array test here is to avoid infinite recursions..
|
||||
pats = pats.map((pat) => (Array.isArray(pat) ? sequence(...pat) : reify(pat)));
|
||||
const query = (state) => flatten(pats.map((pat) => pat.query(state)));
|
||||
const query = (state) =>
|
||||
flatten(
|
||||
pats.map((pat) => {
|
||||
try {
|
||||
// An error occurring in one part of the stack should not break
|
||||
// playback of the other parts of the stack.
|
||||
return pat.query(state);
|
||||
} catch (e) {
|
||||
errorLogger(e);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
const result = new Pattern(query);
|
||||
if (__steps) {
|
||||
result._steps = lcm(...pats.map((pat) => pat._steps));
|
||||
@@ -1470,6 +1688,18 @@ function _stackWith(func, pats) {
|
||||
return stack(...func(steps, pats));
|
||||
}
|
||||
|
||||
/**
|
||||
* The input patterns are aligned to the left, with silence padded to the shorter pattern(s) in the final steps if there's a stepcount disparity. See also `stackRight` and `stackCenter/stackCentre`
|
||||
*
|
||||
* @name stackLeft
|
||||
* @tags temporal, stepwise, combiners
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* stackLeft("0 1 2 3", "3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
|
||||
* @example
|
||||
* // chainable
|
||||
* "0 1 2 3".stackLeft("3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
|
||||
*/
|
||||
export function stackLeft(...pats) {
|
||||
return _stackWith(
|
||||
(steps, pats) => pats.map((pat) => (pat._steps.eq(steps) ? pat : stepcat(pat, gap(steps.sub(pat._steps))))),
|
||||
@@ -1477,6 +1707,18 @@ export function stackLeft(...pats) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The input patterns are aligned to the right, with silence padded to the shorter pattern(s) in the initial steps if there's a stepcount disparity. See also `stackLeft` and `stackCenter/stackCentre`
|
||||
*
|
||||
* @name stackRight
|
||||
* @tags temporal, stepwise, combiners
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* stackRight("0 1 2 3", "3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
|
||||
* @example
|
||||
* // chainable
|
||||
* "0 1 2 3".stackRight("3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
|
||||
*/
|
||||
export function stackRight(...pats) {
|
||||
return _stackWith(
|
||||
(steps, pats) => pats.map((pat) => (pat._steps.eq(steps) ? pat : stepcat(gap(steps.sub(pat._steps)), pat))),
|
||||
@@ -1484,7 +1726,19 @@ export function stackRight(...pats) {
|
||||
);
|
||||
}
|
||||
|
||||
export function stackCentre(...pats) {
|
||||
/**
|
||||
* The input patterns are aligned to the right, with silence padded to the shorter pattern(s) in the initial steps if there's a stepcount disparity. See also `stackLeft` and `stackRight`
|
||||
* @name stackCenter
|
||||
* @tags temporal, stepwise, combiners
|
||||
* @synonyms stackCentre
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* stackCenter("0 1 2 3", "3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
|
||||
* @example
|
||||
* // chainable
|
||||
* "0 1 2 3".stackCenter("3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
|
||||
*/
|
||||
export function stackCenter(...pats) {
|
||||
return _stackWith(
|
||||
(steps, pats) =>
|
||||
pats.map((pat) => {
|
||||
@@ -1498,6 +1752,25 @@ export function stackCentre(...pats) {
|
||||
);
|
||||
}
|
||||
|
||||
export function stackCentre(...pats) {
|
||||
return stackCenter(...pats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for `pm(patterns).pace(m)` where m is the max step count in the input patterns
|
||||
* @name stackRepeat
|
||||
* @tags temporal, stepwise, combiners
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* stackRepeat("0 1 2 3", "3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
|
||||
* @example
|
||||
* // chainable
|
||||
* "0 1 2 3".stackRepeat("3 4", "[4 5 6] 7 8 9 10").scale("c:minor").note()
|
||||
*/
|
||||
export function stackRepeat(...pats) {
|
||||
return stackBy(reify('repeat'), ...pats);
|
||||
}
|
||||
|
||||
export function stackBy(by, ...pats) {
|
||||
const [left, ...right] = pats.map((pat) => pat._steps);
|
||||
const steps = left.maximum(...right);
|
||||
@@ -1529,7 +1802,9 @@ export function slowcat(...pats) {
|
||||
// Array test here is to avoid infinite recursions..
|
||||
pats = pats.map((pat) => (Array.isArray(pat) ? fastcat(...pat) : reify(pat)));
|
||||
|
||||
if (pats.length == 1) {
|
||||
if (!pats.length) {
|
||||
return silence;
|
||||
} else if (pats.length == 1) {
|
||||
return pats[0];
|
||||
}
|
||||
|
||||
@@ -1537,10 +1812,6 @@ export function slowcat(...pats) {
|
||||
const span = state.span;
|
||||
const pat_n = _mod(span.begin.sam(), pats.length);
|
||||
const pat = pats[pat_n];
|
||||
if (!pat) {
|
||||
// pat_n can be negative, if the span is in the past..
|
||||
return [];
|
||||
}
|
||||
// A bit of maths to make sure that cycles from constituent patterns aren't skipped.
|
||||
// For example if three patterns are slowcat-ed, the fourth cycle of the result should
|
||||
// be the second (rather than fourth) cycle from the first pattern.
|
||||
@@ -1557,11 +1828,14 @@ export function slowcat(...pats) {
|
||||
* @return {Pattern}
|
||||
*/
|
||||
export function slowcatPrime(...pats) {
|
||||
if (!pats.length) {
|
||||
return silence;
|
||||
}
|
||||
pats = pats.map(reify);
|
||||
const query = function (state) {
|
||||
const pat_n = Math.floor(state.span.begin) % pats.length;
|
||||
const pat = pats[pat_n]; // can be undefined for same cases e.g. /#cHVyZSg0MikKICAuZXZlcnkoMyxhZGQoNykpCiAgLmxhdGUoLjUp
|
||||
return pat?.query(state) || [];
|
||||
const pat_n = _mod(Math.floor(state.span.begin), pats.length);
|
||||
const pat = pats[pat_n];
|
||||
return pat.query(state);
|
||||
};
|
||||
return new Pattern(query).splitQueries();
|
||||
}
|
||||
@@ -1611,10 +1885,10 @@ export function arrange(...sections) {
|
||||
* @tags combiners
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
seqPLoop(
|
||||
[0, 2, "bd(3,8)"],
|
||||
[1, 3, "cp(3,8)"]
|
||||
).sound()
|
||||
* seqPLoop(
|
||||
* [0, 2, "bd(3,8)"],
|
||||
* [1, 3, "cp(3,8)"]
|
||||
* ).sound()
|
||||
*/
|
||||
export function seqPLoop(...parts) {
|
||||
let total = Fraction(0);
|
||||
@@ -2080,8 +2354,8 @@ export const ply = register('ply', function (factor, pat) {
|
||||
* @example
|
||||
* s("bd hh sd hh").fast(2) // s("[bd hh sd hh]*2")
|
||||
*/
|
||||
export const { fast, density } = register(
|
||||
['fast', 'density'],
|
||||
export const fast = register(
|
||||
'fast',
|
||||
function (factor, pat) {
|
||||
if (factor === 0) {
|
||||
return silence;
|
||||
@@ -2291,11 +2565,11 @@ export const { zoomArc, zoomarc } = register(['zoomArc', 'zoomarc'], function (a
|
||||
* @param {number} slices to play
|
||||
* @example
|
||||
* note("0 1 2 3 4 5 6 7".scale('c:mixolydian'))
|
||||
*.bite(4, "3 2 1 0")
|
||||
* .bite(4, "3 2 1 0")
|
||||
* @example
|
||||
* sound("bd - bd bd*2, - sd:6 - sd:5 sd:1 - [- sd:2] -, hh [- cp:7]")
|
||||
.bank("RolandTR909").speed(1.2)
|
||||
.bite(4, "0 0 [1 2] <3 2> 0 0 [2 1] 3")
|
||||
* .bank("RolandTR909").speed(1.2)
|
||||
* .bite(4, "0 0 [1 2] <3 2> 0 0 [2 1] 3")
|
||||
*/
|
||||
export const bite = register(
|
||||
'bite',
|
||||
@@ -2312,6 +2586,74 @@ export const bite = register(
|
||||
false,
|
||||
);
|
||||
|
||||
/**
|
||||
* Similar to `struct`, but every hap in the structure pattern plays the next
|
||||
* cycle of the main pattern.
|
||||
* With a different number of cycles in the main pattern and haps in the
|
||||
* structure pattern, this creates an isorhythm.
|
||||
*
|
||||
* Note: This function works on the assumption that the structure pattern
|
||||
* repeats every cycle. If the structure pattern only repeats after multiple
|
||||
* cycles, it may skip or repeat certain parts of the melody.
|
||||
* @tags temporal
|
||||
* @name isobite
|
||||
* @synonyms cycleStruct, cyclestruct
|
||||
* @memberof Pattern
|
||||
* @param {Pattern} structure structure of the pattern
|
||||
* @example
|
||||
* "<0 1 2 3 4 5>".isobite("[x - x*2]*2").n().scale("c:major")
|
||||
* // the same as "0 - [1 2] 3 - [4 5]".n().scale("c:major")
|
||||
*
|
||||
* @example
|
||||
* "<0 1 2 3 4>".isobite("x - x").n().scale("c:major")
|
||||
* // the same as "<[0 - 1] [2 - 3] [4 - 0] [1 - 2] [3 - 4]>".n().scale("c:major")
|
||||
*
|
||||
* @example
|
||||
* // Here the structure pattern repeats after 2 cycles,
|
||||
* // so certain haps from the melody are skipped.
|
||||
* "<0 1 2 3>".isobite("x x*2".slow(2)).n().scale("c:major")
|
||||
* // the same as "<[0] [2 3] [2] [2 3]>".n().scale("c:major")
|
||||
*
|
||||
* @example
|
||||
* // This can be avoided by making the struct pattern 1 cycle,
|
||||
* // and slowing the whole pattern down as follows:
|
||||
* "<0 1 2 3>".isobite("x x*2").slow(2).n().scale("c:major")
|
||||
* // the same as "<[0] [1 2] [3] [0 1] [2] [3 0] [1] [2 3]>".n().scale("c:major")
|
||||
*/
|
||||
export const { isobite, cyclestruct, cycleStruct } = register(
|
||||
['isobite', 'cyclestruct', 'cycleStruct'],
|
||||
(structure, pat) => {
|
||||
const structPat = new Pattern((state) => {
|
||||
const haps_cycle = structure
|
||||
.withQuerySpan((span) => span.begin.wholeCycle())
|
||||
.query(state)
|
||||
.filter((hap) => hap.value != 0)
|
||||
.sort((a, b) => a.whole.begin - b.whole.begin)
|
||||
.map((hap, i) => hap.withValue((_) => i));
|
||||
|
||||
const haps = [];
|
||||
for (const hap of haps_cycle) {
|
||||
const new_part = state.span.intersection(hap.part);
|
||||
if (new_part) {
|
||||
haps.push(new Hap(hap.whole, new_part, hap.value, hap.context));
|
||||
}
|
||||
}
|
||||
return haps;
|
||||
}).splitQueries();
|
||||
|
||||
const stepsPattern = new Pattern((state) => {
|
||||
const nrHaps = structure
|
||||
.withQuerySpan((span) => span.begin.wholeCycle())
|
||||
.query(state)
|
||||
.filter((hap) => hap.value != 0).length;
|
||||
return [new Hap(state.span.begin.wholeCycle(), state.span, nrHaps, {})];
|
||||
}).splitQueries();
|
||||
|
||||
return pat.fast(stepsPattern).bite(stepsPattern, structPat);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
/**
|
||||
* Selects the given fraction of the pattern and repeats that part to fill the remainder of the cycle.
|
||||
* @tags temporal
|
||||
@@ -2418,8 +2760,12 @@ export const off = register('off', function (time_pat, func, pat) {
|
||||
* Returns a new pattern where every other cycle is played once, twice as
|
||||
* fast, and offset in time by one quarter of a cycle. Creates a kind of
|
||||
* breakbeat feel.
|
||||
* @name brak
|
||||
* @tags temporal
|
||||
* @returns Pattern
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* s("bd hh!2 rim").brak()
|
||||
*/
|
||||
export const brak = register('brak', function (pat) {
|
||||
return pat.when(slowcat(false, true), (x) => fastcat(x, silence)._late(0.25));
|
||||
@@ -2908,10 +3254,29 @@ export const { ribbon, rib } = register(['ribbon', 'rib'], (offset, cycles, pat)
|
||||
pat.early(offset).restart(pure(1).slow(cycles)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the pattern's color to the given [hsla](https://www.w3schools.com/htmL/html_colors_hsl.asp)
|
||||
*
|
||||
* @name hsla
|
||||
* @param {Number} hue (0 - 360)
|
||||
* @param {Number} saturation percentage (0 - 100)
|
||||
* @param {Number} lightness percentage (0 - 100)
|
||||
* @param {Number} alpha (transparency) (0.0 - 1.0)
|
||||
* @tags visualization
|
||||
*/
|
||||
export const hsla = register('hsla', (h, s, l, a, pat) => {
|
||||
return pat.color(`hsla(${h}turn,${s * 100}%,${l * 100}%,${a})`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets the pattern's color to the given [hsl](https://www.w3schools.com/htmL/html_colors_hsl.asp)
|
||||
*
|
||||
* @name hsl
|
||||
* @param {Number} hue (0 - 360)
|
||||
* @param {Number} saturation percentage (0 - 100)
|
||||
* @param {Number} lightness percentage (0 - 100)
|
||||
* @tags visualization
|
||||
*/
|
||||
export const hsl = register('hsl', (h, s, l, pat) => {
|
||||
return pat.color(`hsl(${h}turn,${s * 100}%,${l * 100}%)`);
|
||||
});
|
||||
@@ -3267,7 +3632,7 @@ export const drop = stepRegister('drop', function (i, pat) {
|
||||
* @tags stepwise
|
||||
* @example
|
||||
* stepcat(
|
||||
* sound("bd bd - cp").extend(2),
|
||||
* sound("bd bd - <cp hh>").extend(2),
|
||||
* sound("bd - sd -")
|
||||
* ).pace(8)
|
||||
*/
|
||||
@@ -3278,15 +3643,12 @@ export const extend = stepRegister('extend', function (factor, pat) {
|
||||
/**
|
||||
* *Experimental*
|
||||
*
|
||||
* `replicate` is similar to `fast` in that it increases its density, but it also increases the step count
|
||||
* accordingly. So `stepcat("a b".replicate(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"`.
|
||||
* `replicate` is similar to `extend`, except that `replicate` will "replicate" the thing being extended. This will only differ from `extend` when the input pattern differs from cycle to cycle.
|
||||
*
|
||||
* TODO: find out how this function differs from extend
|
||||
* @tags stepwise
|
||||
* @example
|
||||
* stepcat(
|
||||
* sound("bd bd - cp").replicate(2),
|
||||
* sound("bd bd - <cp hh>").replicate(2),
|
||||
* sound("bd - sd -")
|
||||
* ).pace(8)
|
||||
*/
|
||||
@@ -3382,8 +3744,8 @@ export const growlist = (amount, pat) => pat.growlist(amount);
|
||||
* "tha dhi thom nam".shrink("1 -1").sound().bank("mridangam").pace(4)
|
||||
* @example
|
||||
* note("0 1 2 3 4 5 6 7".scale("C:ritusen")).sound("folkharp")
|
||||
.shrink("1 -1").pace(8)
|
||||
|
||||
* .shrink("1 -1").pace(8)
|
||||
*
|
||||
*/
|
||||
|
||||
export const shrink = register(
|
||||
@@ -3422,7 +3784,7 @@ export const shrink = register(
|
||||
* "tha dhi thom nam".grow("1 -1").sound().bank("mridangam").pace(4)
|
||||
* @example
|
||||
* note("0 1 2 3 4 5 6 7".scale("C:ritusen")).sound("folkharp")
|
||||
.grow("1 -1").pace(8)
|
||||
* .grow("1 -1").pace(8)
|
||||
*/
|
||||
|
||||
export const grow = register(
|
||||
@@ -3446,17 +3808,17 @@ export const grow = register(
|
||||
|
||||
/**
|
||||
* *Experimental*
|
||||
*
|
||||
* Inserts a pattern into a list of patterns. On the first repetition it will be inserted at the end of the list, then moved backwards through the list
|
||||
* on successive repetitions. The patterns are added together stepwise, with all repetitions taking place over a single cycle. Using `pace` to set the
|
||||
*
|
||||
* Inserts a pattern into a list of patterns. On the first repetition it will be inserted at the end of the list, then moved backwards through the list
|
||||
* on successive repetitions. The patterns are added together stepwise, with all repetitions taking place over a single cycle. Using `pace` to set the
|
||||
* number of steps per cycle is therefore usually recommended.
|
||||
*
|
||||
* @tags stepwise
|
||||
* @return {Pattern}
|
||||
* @example
|
||||
* "[c g]".tour("e f", "e f g", "g f e c").note()
|
||||
.sound("folkharp")
|
||||
.pace(8)
|
||||
* .sound("folkharp")
|
||||
* .pace(8)
|
||||
*/
|
||||
export const tour = function (pat, ...many) {
|
||||
return pat.tour(...many);
|
||||
@@ -3474,16 +3836,16 @@ Pattern.prototype.tour = function (...many) {
|
||||
|
||||
/**
|
||||
* *Experimental*
|
||||
*
|
||||
* '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.
|
||||
*
|
||||
*
|
||||
* @tags stepwise
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* zip("e f", "e f g", "g [f e] a f4 c").note()
|
||||
.sound("folkharp")
|
||||
.pace(8)
|
||||
* .sound("folkharp")
|
||||
* .pace(8)
|
||||
*/
|
||||
export const zip = function (...pats) {
|
||||
pats = pats.filter((pat) => pat.hasSteps);
|
||||
@@ -3646,9 +4008,9 @@ export const slice = register(
|
||||
* s("bd!8").onTriggerTime((hap) => {console.log(hap)})
|
||||
*/
|
||||
Pattern.prototype.onTriggerTime = function (func) {
|
||||
return this.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
const diff = targetTime - currentTime;
|
||||
window.setTimeout(() => {
|
||||
return this.onSchedule((hap, targetTime) => {
|
||||
const diff = targetTime - getClockTime();
|
||||
setTimeout(() => {
|
||||
func(hap);
|
||||
}, diff * 1000);
|
||||
}, false);
|
||||
@@ -4132,3 +4494,59 @@ Pattern.prototype.worklet = function (src, ...inputs) {
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -104,6 +104,8 @@ export const pickmodF = register('pickmodF', function (pickPattern, lookup, pat)
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* note("<0*4 1 2*2 3*3>".pickOut(["g a", "e f", "f g f g" , "g c d"]))
|
||||
*/
|
||||
export const pickOut = register('pickOut', function (lookup, pat) {
|
||||
return _pick(lookup, pat, false).outerJoin();
|
||||
|
||||
+55
-308
@@ -7,13 +7,15 @@ import {
|
||||
setIsStarted,
|
||||
setPattern as exposeSchedulerPattern,
|
||||
setTime,
|
||||
setTriggerFunc,
|
||||
setScheduleFunc,
|
||||
setClockTime,
|
||||
} from './schedulerState.mjs';
|
||||
import { evalScope } from './evaluate.mjs';
|
||||
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
|
||||
import { reset_state } from './impure.mjs';
|
||||
|
||||
export function repl({
|
||||
onSchedule,
|
||||
defaultOutput,
|
||||
onEvalError,
|
||||
beforeEval,
|
||||
@@ -30,6 +32,11 @@ export function repl({
|
||||
id,
|
||||
mondo = false,
|
||||
}) {
|
||||
if (defaultOutput) {
|
||||
throw new Error(
|
||||
'defaultOutput has been removed. use onSchedule(hap, time, cps) instead. see https://codeberg.org/uzu/strudel/pulls/2138/',
|
||||
);
|
||||
}
|
||||
const state = {
|
||||
schedulerError: undefined,
|
||||
evalError: undefined,
|
||||
@@ -38,7 +45,6 @@ export function repl({
|
||||
pattern: undefined,
|
||||
miniLocations: [],
|
||||
widgets: [],
|
||||
sliders: [],
|
||||
pending: false,
|
||||
started: false,
|
||||
};
|
||||
@@ -55,7 +61,7 @@ export function repl({
|
||||
};
|
||||
|
||||
const schedulerOptions = {
|
||||
onTrigger: getTrigger({ defaultOutput, getTime }),
|
||||
onSchedule: getSchedule({ onSchedule }),
|
||||
getTime,
|
||||
onToggle: (started) => {
|
||||
updateState({ started });
|
||||
@@ -73,139 +79,18 @@ export function repl({
|
||||
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
|
||||
const scheduler =
|
||||
sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions);
|
||||
setTriggerFunc(schedulerOptions.onTrigger);
|
||||
setScheduleFunc(schedulerOptions.onSchedule);
|
||||
setCpsFunc(() => scheduler.cps);
|
||||
let pPatterns = {};
|
||||
let anonymousIndex = 0;
|
||||
let allTransform;
|
||||
let eachTransform;
|
||||
|
||||
// Block-based evaluation state
|
||||
let codeBlocks = {};
|
||||
let lastActiveVisualizerLabel = null;
|
||||
// Track which patterns belong to which blocks: { blockRange: [patternKeys] }
|
||||
let blockPatterns = new Map();
|
||||
|
||||
// Helper function to collect properties from all code blocks (handles both labeled and anonymous blocks)
|
||||
function collectFromBlocks(property) {
|
||||
return Object.entries(codeBlocks).flatMap(([key, block]) => {
|
||||
if (key === '$') {
|
||||
// Anonymous blocks are stored as an array of block objects
|
||||
return Array.isArray(block) ? block.flatMap((b) => b[property] || []) : [];
|
||||
}
|
||||
// Labeled blocks are stored as single block objects
|
||||
return block[property] || [];
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to process a single labeled block
|
||||
function processLabeledBlock(labels, i, code, options, meta) {
|
||||
const label = labels[i];
|
||||
const nextLabel = labels[i + 1] || { index: code.length, end: code.length };
|
||||
|
||||
const labelCode = code.slice(label.index, nextLabel.index);
|
||||
const labelRange = [label.index + options.range[0], label.end + options.range[0]];
|
||||
|
||||
// Calculate the full block range (from label start to next label start)
|
||||
const blockStart = label.index + options.range[0];
|
||||
const blockEnd = nextLabel.index + options.range[0];
|
||||
|
||||
const blockWidgets = (meta?.widgets || []).filter((widget) => {
|
||||
const widgetPos = widget.from ?? widget.index ?? 0;
|
||||
return widgetPos >= blockStart && widgetPos < blockEnd;
|
||||
});
|
||||
|
||||
const blockSliders = (meta?.sliders || []).filter((slider) => {
|
||||
const sliderPos = slider.from ?? slider.index ?? 0;
|
||||
return sliderPos >= blockStart && sliderPos < blockEnd;
|
||||
});
|
||||
|
||||
const blockMiniLocations = (meta?.miniLocations || []).filter((loc) => {
|
||||
// const locStart = loc.start ?? loc.from ?? 0;
|
||||
// mini locations can be either [start, end] arrays or objects with start/from
|
||||
const locStart = Array.isArray(loc) ? loc[0] : (loc.start ?? loc.from ?? 0);
|
||||
return locStart >= blockStart && locStart < blockEnd;
|
||||
});
|
||||
|
||||
handleSingleLabelBlock(
|
||||
label,
|
||||
labelCode,
|
||||
{ ...options, range: labelRange },
|
||||
{ widgets: blockWidgets, sliders: blockSliders, miniLocations: blockMiniLocations },
|
||||
);
|
||||
}
|
||||
|
||||
// helper
|
||||
function cleanupConflictingRanges(codeBlocks, currentKey, newRange) {
|
||||
for (const [existingKey, existingBlock] of Object.entries(codeBlocks)) {
|
||||
if (existingKey === currentKey) continue;
|
||||
if (!existingBlock.range) continue;
|
||||
|
||||
const [existingStart, existingEnd] = existingBlock.range;
|
||||
const [newStart, newEnd] = newRange;
|
||||
|
||||
// If ranges overlap (not just touch), remove the stale block
|
||||
if (!(newEnd <= existingStart || newStart >= existingEnd)) {
|
||||
delete codeBlocks[existingKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// helper
|
||||
function handleSingleLabelBlock(label, code, options, meta) {
|
||||
// Detect if this block contains a non-inline widget
|
||||
// The activeVisualizer is now provided by the transpiler for all labels
|
||||
const activeVisualizer = label.activeVisualizer || null;
|
||||
|
||||
if (activeVisualizer !== null) {
|
||||
lastActiveVisualizerLabel = label.name;
|
||||
}
|
||||
|
||||
// Store the entire code block under the label name
|
||||
codeBlocks[label.name] = {
|
||||
code: code,
|
||||
range: options.range,
|
||||
labels: [label.name],
|
||||
miniLocations: meta?.miniLocations || [],
|
||||
widgets: meta?.widgets || [],
|
||||
sliders: meta?.sliders || [],
|
||||
activeVisualizer: activeVisualizer, // Store the widget type if present, null otherwise
|
||||
};
|
||||
|
||||
// Clean up any blocks with conflicting ranges (including declaration blocks)
|
||||
cleanupConflictingRanges(codeBlocks, label.name, options.range);
|
||||
}
|
||||
|
||||
// helper
|
||||
// These blocks return silence but may contain mini notation strings that need highlighting
|
||||
function handleDeclarationBlock(code, options, meta) {
|
||||
const range = options.range || [];
|
||||
if (range.length < 2) return;
|
||||
|
||||
const blockKey = `_decl:${range[0]}:${range[1]}`;
|
||||
|
||||
codeBlocks[blockKey] = {
|
||||
code: code,
|
||||
range: range,
|
||||
labels: [],
|
||||
miniLocations: meta?.miniLocations || [],
|
||||
widgets: meta?.widgets || [],
|
||||
sliders: meta?.sliders || [],
|
||||
activeVisualizer: null,
|
||||
};
|
||||
|
||||
// Clean up any overlapping declaration blocks
|
||||
cleanupConflictingRanges(codeBlocks, blockKey, range);
|
||||
}
|
||||
|
||||
const hush = function () {
|
||||
pPatterns = {};
|
||||
anonymousIndex = 0;
|
||||
allTransform = undefined;
|
||||
eachTransform = undefined;
|
||||
codeBlocks = {};
|
||||
blockPatterns.clear();
|
||||
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
|
||||
return silence;
|
||||
};
|
||||
|
||||
@@ -224,61 +109,9 @@ export function repl({
|
||||
return pattern;
|
||||
};
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
setClockTime(() => scheduler.getTime()); // TODO: refactor?
|
||||
|
||||
// Helper function to apply pattern transformations (solo, each, all)
|
||||
// this should be abstracted more
|
||||
function applyPatternTransforms(pattern) {
|
||||
const allPatterns = Object.values(pPatterns);
|
||||
|
||||
if (allPatterns.length) {
|
||||
let patterns = [];
|
||||
let soloActive = false;
|
||||
for (const [key, value] of Object.entries(pPatterns)) {
|
||||
// handle soloed patterns ex: S$: s("bd!4")
|
||||
const isSolod = key.length > 1 && key.startsWith('S');
|
||||
if (isSolod && soloActive === false) {
|
||||
// first time we see a soloed pattern, clear existing patterns
|
||||
patterns = [];
|
||||
soloActive = true;
|
||||
}
|
||||
if (!soloActive || (soloActive && isSolod)) {
|
||||
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
||||
patterns.push(valWithState);
|
||||
}
|
||||
}
|
||||
if (eachTransform) {
|
||||
// Explicit lambda so only element (not index and array) are passed
|
||||
patterns = patterns.map((x) => eachTransform(x));
|
||||
}
|
||||
pattern = stack(...patterns);
|
||||
} else if (eachTransform) {
|
||||
pattern = eachTransform(pattern);
|
||||
}
|
||||
if (allTransforms.length) {
|
||||
for (const transform of allTransforms) {
|
||||
pattern = transform(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPattern(pattern)) {
|
||||
pattern = silence;
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
codeBlocks = {};
|
||||
blockPatterns.clear();
|
||||
pPatterns = {};
|
||||
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
|
||||
updateState({
|
||||
miniLocations: [],
|
||||
widgets: [],
|
||||
sliders: [],
|
||||
});
|
||||
scheduler.stop();
|
||||
};
|
||||
const stop = () => scheduler.stop();
|
||||
const start = () => scheduler.start();
|
||||
const pause = () => scheduler.pause();
|
||||
const toggle = () => scheduler.toggle();
|
||||
@@ -399,7 +232,7 @@ export function repl({
|
||||
});
|
||||
};
|
||||
|
||||
const evaluate = async (code, autostart = true) => {
|
||||
const evaluate = async (code, autostart = true, shouldHush = true) => {
|
||||
if (!code) {
|
||||
throw new Error('no code to evaluate');
|
||||
}
|
||||
@@ -407,131 +240,48 @@ export function repl({
|
||||
updateState({ code, pending: true });
|
||||
await injectPatternMethods();
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
await beforeEval?.({ code, blockBased: false });
|
||||
setClockTime(() => scheduler.getTime()); // TODO: refactor?
|
||||
await beforeEval?.({ code });
|
||||
allTransforms = []; // reset all transforms
|
||||
|
||||
codeBlocks = {};
|
||||
hush();
|
||||
shouldHush && hush();
|
||||
|
||||
if (mondo) {
|
||||
code = `mondolang\`${code}\``;
|
||||
}
|
||||
|
||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
||||
|
||||
pattern = applyPatternTransforms(pattern);
|
||||
|
||||
logger(`[eval] code updated`);
|
||||
pattern = await setPattern(pattern, autostart);
|
||||
updateState({
|
||||
miniLocations: meta?.miniLocations || [],
|
||||
widgets: meta?.widgets || [],
|
||||
sliders: meta?.sliders || [],
|
||||
activeCode: code,
|
||||
pattern,
|
||||
evalError: undefined,
|
||||
schedulerError: undefined,
|
||||
pending: false,
|
||||
});
|
||||
|
||||
afterEval?.({ code, pattern, meta, range: undefined, widgetRemoved: false });
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
logger(`[eval] error: ${err.message}`, 'error');
|
||||
console.error(err);
|
||||
updateState({ evalError: err, pending: false });
|
||||
onEvalError?.(err);
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateBlock = async (code, autostart = true, options = {}) => {
|
||||
if (!code) {
|
||||
throw new Error('no code to evaluate');
|
||||
}
|
||||
try {
|
||||
updateState({ code, pending: true });
|
||||
await injectPatternMethods();
|
||||
setTime(() => scheduler.now()); // TODO: refactor?
|
||||
await beforeEval?.({ code, blockBased: true });
|
||||
allTransforms = []; // reset all transforms
|
||||
|
||||
const transpilerOptionsWithBlock = {
|
||||
...transpilerOptions,
|
||||
blockBased: true,
|
||||
range: options.range || [],
|
||||
};
|
||||
|
||||
if (mondo) {
|
||||
code = `mondolang\`${code}\``;
|
||||
}
|
||||
|
||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptionsWithBlock);
|
||||
|
||||
// Track activeVisualizer cleanup: check if any block's visualizer was removed
|
||||
let widgetRemoved = false;
|
||||
|
||||
const labels = meta.labels || [];
|
||||
|
||||
// Check for anonymous labels (labels starting with '$')
|
||||
const hasAnonymousLabel = labels.some((label) => label.name.startsWith('$'));
|
||||
|
||||
// Store code blocks in dictionary using labels as keys
|
||||
if (hasAnonymousLabel) {
|
||||
// variable/function declarations that don't return patterns are allowed,
|
||||
// but anonymous pattern blocks pose an issue for block-based evaluation
|
||||
// if an anonymous pattern is evaluated multiple times it will just stack and get louder and louder
|
||||
|
||||
// it's very common for users to write code prefixed with '$'
|
||||
// but to modify and override existing patterns, the patterns must be labeled,
|
||||
// otherwise we'll have no idea of which pattern is being overridden
|
||||
|
||||
// (we probably need to update the docs on this)
|
||||
// we could easily enable it, but it would confuse a lot of people
|
||||
|
||||
throw new Error(
|
||||
'anonymous labels disabled for block based evaluation (see https://strudel.cc/blog/#label-notation)',
|
||||
);
|
||||
} else if (labels.length > 0) {
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
// processing transpiler output instead of code is simply to avoid
|
||||
// extra regex in detecting whether or not an inline widget has been commented out
|
||||
processLabeledBlock(labels, i, meta.output, options, meta);
|
||||
}
|
||||
} else {
|
||||
// Declaration block (variable/function that returns silence)
|
||||
// Store it so its miniLocations are preserved for highlighting patterns stored in variables
|
||||
handleDeclarationBlock(code, options, meta);
|
||||
}
|
||||
|
||||
meta.miniLocations = collectFromBlocks('miniLocations');
|
||||
meta.widgets = collectFromBlocks('widgets');
|
||||
meta.sliders = collectFromBlocks('sliders');
|
||||
|
||||
// Track activeVisualizer cleanup: check if any block's visualizer was removed
|
||||
const blocksToUpdate = labels.map((label) => label.name);
|
||||
|
||||
// this is the hackiest bit
|
||||
for (const [key, block] of Object.entries(codeBlocks)) {
|
||||
if (blocksToUpdate.includes(key)) {
|
||||
// This block was just updated
|
||||
if (block.activeVisualizer !== null) {
|
||||
// Block now has a visualizer, update tracking
|
||||
lastActiveVisualizerLabel = key;
|
||||
} else if (lastActiveVisualizerLabel === key) {
|
||||
// This block lost its visualizer, trigger cleanup
|
||||
widgetRemoved = true;
|
||||
lastActiveVisualizerLabel = null;
|
||||
if (Object.keys(pPatterns).length) {
|
||||
let patterns = [];
|
||||
let soloActive = false;
|
||||
for (const [key, value] of Object.entries(pPatterns)) {
|
||||
// handle soloed patterns ex: S$: s("bd!4")
|
||||
const isSolod = key.length > 1 && key.startsWith('S');
|
||||
if (isSolod && soloActive === false) {
|
||||
// first time we see a soloed pattern, clear existing patterns
|
||||
patterns = [];
|
||||
soloActive = true;
|
||||
}
|
||||
if (!soloActive || (soloActive && isSolod)) {
|
||||
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
||||
patterns.push(valWithState);
|
||||
}
|
||||
}
|
||||
if (eachTransform) {
|
||||
// Explicit lambda so only element (not index and array) are passed
|
||||
patterns = patterns.map((x) => eachTransform(x));
|
||||
}
|
||||
pattern = stack(...patterns);
|
||||
} else if (eachTransform) {
|
||||
pattern = eachTransform(pattern);
|
||||
}
|
||||
if (allTransforms.length) {
|
||||
for (const transform of allTransforms) {
|
||||
pattern = transform(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
pPatterns = Object.fromEntries(
|
||||
Object.entries(pPatterns).filter(([key]) => {
|
||||
return Object.keys(codeBlocks).includes(key);
|
||||
}),
|
||||
);
|
||||
|
||||
pattern = applyPatternTransforms(pattern);
|
||||
if (!isPattern(pattern)) {
|
||||
pattern = silence;
|
||||
}
|
||||
|
||||
logger(`[eval] code updated`);
|
||||
pattern = await setPattern(pattern, autostart);
|
||||
@@ -546,7 +296,7 @@ export function repl({
|
||||
pending: false,
|
||||
});
|
||||
|
||||
afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved });
|
||||
afterEval?.({ code, pattern, meta });
|
||||
return pattern;
|
||||
} catch (err) {
|
||||
logger(`[eval] error: ${err.message}`, 'error');
|
||||
@@ -557,23 +307,20 @@ export function repl({
|
||||
};
|
||||
|
||||
const setCode = (code) => updateState({ code });
|
||||
return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state };
|
||||
}
|
||||
|
||||
export const getTrigger =
|
||||
({ getTime, defaultOutput }) =>
|
||||
async (hap, deadline, duration, cps, t) => {
|
||||
// ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger)
|
||||
// TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/1004
|
||||
export const getSchedule =
|
||||
({ onSchedule }) =>
|
||||
async (hap, t, cps) => {
|
||||
try {
|
||||
if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
|
||||
await defaultOutput(hap, deadline, duration, cps, t);
|
||||
if (!hap.context.onSchedule || !hap.context.dominantSchedule) {
|
||||
await onSchedule(hap, t, cps);
|
||||
}
|
||||
if (hap.context.onTrigger) {
|
||||
// call signature of output / onTrigger is different...
|
||||
await hap.context.onTrigger(hap, getTime(), cps, t);
|
||||
if (hap.context.onSchedule) {
|
||||
await hap.context.onSchedule(hap, t, cps);
|
||||
}
|
||||
} catch (err) {
|
||||
errorLogger(err, 'getTrigger');
|
||||
errorLogger(err, 'getSchedule');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,8 +7,14 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
let time;
|
||||
let cpsFunc;
|
||||
let pattern;
|
||||
let triggerFunc;
|
||||
let scheduleFunc;
|
||||
let isStarted;
|
||||
|
||||
/**
|
||||
* @name getTime
|
||||
* @tags internals
|
||||
* @returns {Number} the current cycle time as a number
|
||||
*/
|
||||
export function getTime() {
|
||||
if (!time) {
|
||||
throw new Error('no time set! use setTime to define a time source');
|
||||
@@ -20,10 +26,32 @@ export function setTime(func) {
|
||||
time = func;
|
||||
}
|
||||
|
||||
let clockTime;
|
||||
/**
|
||||
* @name getClockTime
|
||||
* @tags internals
|
||||
* @returns {Number} the current clock time in seconds (onSchedule time)
|
||||
*/
|
||||
export function getClockTime() {
|
||||
if (!clockTime) {
|
||||
throw new Error('no clockTime set! use setClockTime to define a time source');
|
||||
}
|
||||
return clockTime();
|
||||
}
|
||||
|
||||
export function setClockTime(func) {
|
||||
clockTime = func;
|
||||
}
|
||||
|
||||
export function setCpsFunc(func) {
|
||||
cpsFunc = func;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getCps
|
||||
* @tags internals
|
||||
* @returns {Number} the current cycles per second
|
||||
*/
|
||||
export function getCps() {
|
||||
return cpsFunc?.();
|
||||
}
|
||||
@@ -36,12 +64,12 @@ export function getPattern() {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
export function setTriggerFunc(func) {
|
||||
triggerFunc = func;
|
||||
export function setScheduleFunc(func) {
|
||||
scheduleFunc = func;
|
||||
}
|
||||
|
||||
export function getTriggerFunc() {
|
||||
return triggerFunc;
|
||||
export function getScheduleFunc() {
|
||||
return scheduleFunc;
|
||||
}
|
||||
|
||||
export function setIsStarted(val) {
|
||||
|
||||
+99
-13
@@ -5,7 +5,8 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import { Hap } from './hap.mjs';
|
||||
import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP } from './pattern.mjs';
|
||||
import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP, isPattern } from './pattern.mjs';
|
||||
import { _mod } from './util.mjs';
|
||||
import Fraction from './fraction.mjs';
|
||||
|
||||
import { id, keyAlias, getCurrentKeyboardState } from './util.mjs';
|
||||
@@ -15,6 +16,24 @@ export function steady(value) {
|
||||
return new Pattern((state) => [new Hap(undefined, state.span, value)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signal given by the input function
|
||||
*
|
||||
* @name signal
|
||||
* @param {*} func
|
||||
* @returns {Pattern} signal
|
||||
* @tags generators
|
||||
* @example
|
||||
* // sawtooth signal definition
|
||||
* signal(t=>t%1).segment(16).range(30,40).note()
|
||||
* @example
|
||||
* signal(t=>Math.pow(t,2)%1).range(40, 60).add(5).segment(16)
|
||||
* .off(0, x=>x.add(7))
|
||||
* .note()
|
||||
* .s("gm_electric_guitar_jazz,gm_epiano")
|
||||
* .gain(1.7)
|
||||
* .pianoroll()
|
||||
*/
|
||||
export const signal = (func) => {
|
||||
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin, state.controls))];
|
||||
return new Pattern(query);
|
||||
@@ -33,7 +52,7 @@ export const signal = (func) => {
|
||||
* .scale('C major')
|
||||
*
|
||||
*/
|
||||
export const saw = signal((t) => t % 1);
|
||||
export const saw = signal((t) => _mod(t, 1));
|
||||
|
||||
/**
|
||||
* A sawtooth signal between -1 and 1 (like `saw`, but bipolar).
|
||||
@@ -56,7 +75,7 @@ export const saw2 = saw.toBipolar();
|
||||
* .scale('C major')
|
||||
*
|
||||
*/
|
||||
export const isaw = signal((t) => 1 - (t % 1));
|
||||
export const isaw = signal((t) => 1 - _mod(t, 1));
|
||||
|
||||
/**
|
||||
* A sawtooth signal between 1 and -1 (like `saw2`, but flipped).
|
||||
@@ -113,7 +132,7 @@ export const cosine2 = sine2._early(Fraction(1).div(4));
|
||||
* n(square.segment(4).range(0,7)).scale("C:minor")
|
||||
*
|
||||
*/
|
||||
export const square = signal((t) => Math.floor((t * 2) % 2));
|
||||
export const square = signal((t) => Math.floor(_mod(t * 2, 2)));
|
||||
|
||||
/**
|
||||
* A square signal between -1 and 1 (like `square`, but bipolar).
|
||||
@@ -123,6 +142,22 @@ export const square = signal((t) => Math.floor((t * 2) % 2));
|
||||
*/
|
||||
export const square2 = square.toBipolar();
|
||||
|
||||
/**
|
||||
* A square signal between 1 and 0 (like `square` but flipped).
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @tags generators
|
||||
*/
|
||||
export const isquare = signal((t) => 1 - Math.floor(_mod(t * 2, 2)));
|
||||
|
||||
/**
|
||||
* A square signal between 1 and -1 (like `isquare`, but bipolar).
|
||||
*
|
||||
* @return {Pattern}
|
||||
* @tags generators
|
||||
*/
|
||||
export const isquare2 = isquare.toBipolar();
|
||||
|
||||
/**
|
||||
* A triangle signal between 0 and 1.
|
||||
*
|
||||
@@ -172,6 +207,7 @@ export const time = signal(id);
|
||||
/**
|
||||
* The mouse's x position value ranges from 0 to 1.
|
||||
* @name mousex
|
||||
* @synonyms mouseX
|
||||
* @return {Pattern}
|
||||
* @tags external_io
|
||||
* @example
|
||||
@@ -182,6 +218,7 @@ export const time = signal(id);
|
||||
/**
|
||||
* The mouse's y position value ranges from 0 to 1.
|
||||
* @name mousey
|
||||
* @synonyms mouseY
|
||||
* @return {Pattern}
|
||||
* @tags external_io
|
||||
* @example
|
||||
@@ -396,7 +433,7 @@ export const randrun = (n) => {
|
||||
.map((n, i) => [n, i])
|
||||
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
|
||||
.map((x) => x[1]);
|
||||
const i = t.cyclePos().mul(n).floor() % n;
|
||||
const i = _mod(t.cyclePos().mul(n).floor(), n);
|
||||
return nums[i];
|
||||
})._segment(n);
|
||||
};
|
||||
@@ -542,6 +579,9 @@ export const __chooseWith = (pat, xs) => {
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* note("c2 g2!2 d2 f1").s(chooseWith(sine.fast(2), ["sawtooth", "triangle", "bd:6"]))
|
||||
* @example
|
||||
* // Compare with chooseInWith
|
||||
* note(chooseWith("<0 0.5 1>*3", ["c3", "b3", "d3"]))
|
||||
*/
|
||||
export const chooseWith = (pat, xs) => {
|
||||
return __chooseWith(pat, xs).outerJoin();
|
||||
@@ -553,6 +593,13 @@ export const chooseWith = (pat, xs) => {
|
||||
* @tags temporal
|
||||
* @param {Pattern} pat
|
||||
* @param {*} xs
|
||||
* @example
|
||||
* // Because the structure comes from the chosen values (the notes, here)
|
||||
* // The choosing pattern will only advance once per cycle (not 3 times per cycle)
|
||||
* note(chooseInWith("<0 0.5 1>*3", ["c3", "b3", "d3"]))
|
||||
* @example
|
||||
* // Here, the chosen values have a structure such that the choosing pattern will advance 3 times per cycle
|
||||
* note(chooseInWith("<0 0.5 1>*3", ["c3*3", "b3*3", "d3*3"]))
|
||||
* @returns {Pattern}
|
||||
*/
|
||||
export const chooseInWith = (pat, xs) => {
|
||||
@@ -569,9 +616,7 @@ export const chooseInWith = (pat, xs) => {
|
||||
*/
|
||||
export const choose = (...xs) => chooseWith(rand, xs);
|
||||
|
||||
// todo: doc
|
||||
export const chooseIn = (...xs) => chooseInWith(rand, xs);
|
||||
export const chooseOut = choose;
|
||||
|
||||
/**
|
||||
* Chooses from the given list of values (or patterns of values), according
|
||||
@@ -717,12 +762,30 @@ export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
|
||||
*/
|
||||
export const berlin = signal((t, controls) => _berlin(t, controls.randSeed));
|
||||
|
||||
export const degradeByWith = register(
|
||||
'degradeByWith',
|
||||
(withPat, x, pat) => pat.fmap((a) => (_) => a).appLeft(withPat.filterValues((v) => v > x)),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
/**
|
||||
* `degradeByWith` is like `degradeBy`, but with control over the random or other signal used to filter out the events.
|
||||
*
|
||||
* `degradeBy(x)` is essentially `degradeByWith(rand, x)`
|
||||
*
|
||||
* This allows you to do things other than with `rand`, see examples below
|
||||
*
|
||||
* @name degradeByWith
|
||||
* @param {Pattern|number} test
|
||||
* @param {Pattern|number} testAgainst
|
||||
* @memberof Pattern
|
||||
* @tags temporal
|
||||
* @returns {Pattern}
|
||||
* @example
|
||||
* note("c a f e").degradeByWith(sine, .1)
|
||||
* @example
|
||||
* note("c a f e").degradeByWith(perlin, .5)
|
||||
* @example
|
||||
* note("c a f e").degradeByWith(sine, cosine)
|
||||
*/
|
||||
export const degradeByWith = register('degradeByWith', (withPat, x, pat) => {
|
||||
if (!isPattern(withPat)) withPat = pure(withPat);
|
||||
return pat.fmap((a) => (_) => a).appLeft(withPat.filterValues((v) => v > x));
|
||||
});
|
||||
|
||||
/**
|
||||
* Randomly removes events from the pattern by a given amount.
|
||||
@@ -1080,3 +1143,26 @@ export const perx = new Pattern(function (state) {
|
||||
const n = Fraction(1).div(state.span.duration);
|
||||
return [new Hap(undefined, state.span, Math.log(n) / Math.log(2) + 1)];
|
||||
});
|
||||
|
||||
/**
|
||||
* A signal that goes from `0` to `1` over one cycle, and then stays at `1` forever.
|
||||
* Mainly useful in combination with other functions that reset the cycle. See also
|
||||
* `close`.
|
||||
* @tags temporal
|
||||
* @example
|
||||
* sound("bd sd [- bd] sd".fast(2))
|
||||
* .lpf(open.slow(4).range(300,3000)) // close envelope over 4 cycles
|
||||
* .restart("x/4") // restart every 4 cycles
|
||||
*/
|
||||
export const open = signal((t) => t.min(1));
|
||||
|
||||
/**
|
||||
* The same as `open`, but goes from `1` to `0` over one cycle, and then
|
||||
* stays on `0` forever.
|
||||
* @tags temporal
|
||||
* @example
|
||||
* sound("bd sd [- bd] sd".fast(2))
|
||||
* .lpf(close.slow(4).range(300,3000)) // close envelope over 4 cycles
|
||||
* .restart("x/4") // restart every 4 cycles
|
||||
*/
|
||||
export const close = signal((t) => Fraction(1).sub(t).max(0));
|
||||
|
||||
@@ -32,7 +32,7 @@ function triggerSpeech(words, lang, voice) {
|
||||
}
|
||||
|
||||
export const speak = register('speak', function (lang, voice, pat) {
|
||||
return pat.onTrigger((hap) => {
|
||||
return pat.onSchedule((hap) => {
|
||||
triggerSpeech(hap.value, lang, voice);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,44 @@ describe('euclid', () => {
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(['0/1 → 1/8: a', '1/4 → 3/8: a', '3/8 → 1/2: a', '5/8 → 3/4: a', '3/4 → 7/8: a']);
|
||||
});
|
||||
it('Can create euclidLegato using negative steps', () => {
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclid(3, -8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(
|
||||
fastcat('a')
|
||||
.euclidLegato(3, 8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
);
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclid(5, -8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(
|
||||
fastcat('a')
|
||||
.euclidLegato(5, 8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
);
|
||||
});
|
||||
it('stretches over multiple cycles if pulses is greater than steps', () => {
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclid(5, 4)
|
||||
.fast(2)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(['0/1 → 1/8: a', '1/4 → 3/8: a', '3/8 → 1/2: a', '5/8 → 3/4: a', '3/4 → 7/8: a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('euclidRot', () => {
|
||||
@@ -67,6 +105,45 @@ describe('euclidLegato', () => {
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(['0/1 → 1/4: a', '1/4 → 3/8: a', '3/8 → 5/8: a', '5/8 → 3/4: a', '3/4 → 1/1: a']);
|
||||
});
|
||||
it('Can create euclid using negative steps', () => {
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclidLegato(3, -8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(
|
||||
fastcat('a')
|
||||
.euclid(3, 8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
);
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclidLegato(5, -8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(
|
||||
fastcat('a')
|
||||
.euclid(5, 8)
|
||||
.firstCycle()
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
);
|
||||
});
|
||||
it('stretches over multiple cycles if pulses is greater than steps', () => {
|
||||
expect(
|
||||
fastcat('a')
|
||||
.euclidLegato(5, 4)
|
||||
.fast(2)
|
||||
.firstCycle()
|
||||
.filter((hap) => hap.hasOnset())
|
||||
.sort((a, b) => a.part.begin.sub(b.part.begin))
|
||||
.map((a) => a.showWhole(true)),
|
||||
).toStrictEqual(['0/1 → 1/4: a', '1/4 → 3/8: a', '3/8 → 5/8: a', '5/8 → 3/4: a', '3/4 → 1/1: a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('euclidLegatoRot', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
fastcat,
|
||||
firstOf,
|
||||
slowcat,
|
||||
slowcatPrime,
|
||||
cat,
|
||||
sequence,
|
||||
palindrome,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
rev,
|
||||
time,
|
||||
run,
|
||||
randrun,
|
||||
binaryN,
|
||||
pick,
|
||||
stackLeft,
|
||||
@@ -53,9 +55,10 @@ import {
|
||||
stepcat,
|
||||
sometimes,
|
||||
expand,
|
||||
arp,
|
||||
} from '../index.mjs';
|
||||
|
||||
import { log, logValues } from '../pattern.mjs';
|
||||
import { log, logValues, stackCenter } from '../pattern.mjs';
|
||||
|
||||
import { steady } from '../signal.mjs';
|
||||
|
||||
@@ -72,6 +75,10 @@ const sameFirst = (a, b) => {
|
||||
return expect(a.sortHapsByPart().firstCycle()).toStrictEqual(b.sortHapsByPart().firstCycle());
|
||||
};
|
||||
|
||||
const sameFirstX = (x, a, b) => {
|
||||
return expect(a.fast(x).sortHapsByPart().firstCycle()).toStrictEqual(b.fast(x).sortHapsByPart().firstCycle());
|
||||
};
|
||||
|
||||
describe('TimeSpan', () => {
|
||||
describe('equals()', () => {
|
||||
it('Should be equal to the same value', () => {
|
||||
@@ -544,6 +551,9 @@ describe('Pattern', () => {
|
||||
});
|
||||
});
|
||||
describe('slowcat()', () => {
|
||||
it('Can be empty', () => {
|
||||
expect(slowcat().firstCycle()).toStrictEqual([]);
|
||||
});
|
||||
it('Can concatenate things slowly', () => {
|
||||
expect(
|
||||
slowcat('a', 'b')
|
||||
@@ -576,6 +586,38 @@ describe('Pattern', () => {
|
||||
sameFirst(slowcat('a', ['b', 'c']).fast(4), sequence('a', ['b', 'c']).fast(2));
|
||||
});
|
||||
});
|
||||
describe('slowcatPrime()', () => {
|
||||
it('Can be empty', () => {
|
||||
expect(slowcatPrime().firstCycle()).toStrictEqual([]);
|
||||
});
|
||||
it('Can slowcat patterns swapping back and forth skipping the expected notes', () => {
|
||||
expect(
|
||||
slowcatPrime(fastcat(0, 1, 2, 3).slow(2), fastcat(4, 5, 6, 7).slow(2))
|
||||
.fast(4)
|
||||
.firstCycle()
|
||||
.map((a) => a.value),
|
||||
).toStrictEqual([0, 1, 6, 7, 0, 1, 6, 7]);
|
||||
});
|
||||
it('Can go into negative time', () => {
|
||||
expect(
|
||||
slowcatPrime(fastcat(0, 1, 2, 3).slow(2), fastcat(4, 5, 6, 7).slow(2))
|
||||
.fast(4)
|
||||
.late(8)
|
||||
.firstCycle()
|
||||
.map((a) => a.value),
|
||||
).toStrictEqual([0, 1, 6, 7, 0, 1, 6, 7]);
|
||||
});
|
||||
});
|
||||
describe('arp()', () => {
|
||||
it('It wraps around with both positive and negative indices', () => {
|
||||
expect(
|
||||
stack('a', 'b', 'c')
|
||||
.arp(fastcat(-3, -2, -1, 0, 1, 2, 3, 4))
|
||||
.firstCycle()
|
||||
.map((a) => a.value),
|
||||
).toStrictEqual(['a', 'b', 'c', 'a', 'b', 'c', 'a', 'b']);
|
||||
});
|
||||
});
|
||||
describe('rev()', () => {
|
||||
it('Can reverse things', () => {
|
||||
expect(
|
||||
@@ -945,6 +987,17 @@ describe('Pattern', () => {
|
||||
it('Can run', () => {
|
||||
expect(run(4).firstCycle()).toStrictEqual(sequence(0, 1, 2, 3).firstCycle());
|
||||
});
|
||||
it('Can go into negative time', () => {
|
||||
expect(run(4).late(1).firstCycle()).toStrictEqual(sequence(0, 1, 2, 3).firstCycle());
|
||||
});
|
||||
});
|
||||
describe('randrun', () => {
|
||||
it('Can randrun', () => {
|
||||
expect(randrun(4).firstCycle()).toStrictEqual(sequence(2, 1, 3, 0).firstCycle());
|
||||
});
|
||||
it('Can go into negative time', () => {
|
||||
expect(randrun(4).late(1).firstCycle()).toStrictEqual(sequence(1, 2, 0, 3).firstCycle());
|
||||
});
|
||||
});
|
||||
describe('binaryN', () => {
|
||||
it('Can make a binary pattern from a decimal', () => {
|
||||
@@ -1166,6 +1219,7 @@ describe('Pattern', () => {
|
||||
expect(stackRight(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3));
|
||||
// maybe this should double when they are either all even or all odd
|
||||
expect(stackCentre(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3));
|
||||
expect(stackCenter(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3));
|
||||
expect(fastcat(0, 1).ply(3)._steps).toStrictEqual(Fraction(6));
|
||||
expect(fastcat(0, 1).setSteps(undefined).ply(3)._steps).toStrictEqual(undefined);
|
||||
expect(fastcat(0, 1).fast(3)._steps).toStrictEqual(Fraction(2));
|
||||
@@ -1271,6 +1325,21 @@ describe('Pattern', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('isobite', () => {
|
||||
it('can play a melody with a structure', () => {
|
||||
sameFirst(
|
||||
slowcat(0, 1, 2, 3, 4, 5).isobite(fastcat(true, silence, [true, true]).fast(2)),
|
||||
fastcat(0, silence, [1, 2], 3, silence, [4, 5]),
|
||||
);
|
||||
});
|
||||
it('syncopates over multiple cycles if the number of haps in the patterns differ', () => {
|
||||
sameFirstX(
|
||||
4,
|
||||
slowcat(0, 1, 2, 3).isobite(fastcat(true, [true, true])),
|
||||
slowcat(0, [1, 2], 3, [0, 1], 2, [3, 0], 1, [2, 3]).fast(2),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('unjoin', () => {
|
||||
it('destructures a pattern into subcycles', () => {
|
||||
sameFirst(
|
||||
@@ -1314,7 +1383,7 @@ describe('Pattern', () => {
|
||||
|
||||
// Force a trigger
|
||||
haps.forEach((hap) => {
|
||||
hap.context?.onTrigger?.(hap);
|
||||
hap.context?.onSchedule?.(hap);
|
||||
});
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
@@ -1332,7 +1401,7 @@ describe('Pattern', () => {
|
||||
|
||||
// Force a trigger
|
||||
haps.forEach((hap) => {
|
||||
hap.context?.onTrigger?.(hap);
|
||||
hap.context?.onSchedule?.(hap);
|
||||
});
|
||||
|
||||
expect(mockConsoleLog).toHaveBeenCalledWith(
|
||||
|
||||
@@ -8,7 +8,23 @@ import Fraction from 'fraction.js';
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
|
||||
import {
|
||||
saw,
|
||||
saw2,
|
||||
isaw,
|
||||
isaw2,
|
||||
tri,
|
||||
tri2,
|
||||
itri,
|
||||
itri2,
|
||||
square,
|
||||
square2,
|
||||
isquare,
|
||||
isquare2,
|
||||
per,
|
||||
perx,
|
||||
cyclesPer,
|
||||
} from '../signal.mjs';
|
||||
import { fastcat, sequence, State, TimeSpan, Hap, note } from '../index.mjs';
|
||||
|
||||
const st = (begin, end) => new State(ts(begin, end));
|
||||
@@ -24,17 +40,61 @@ const sameFirst = (a, b) => {
|
||||
|
||||
describe('signal()', () => {
|
||||
it('Can make saw/saw2', () => {
|
||||
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
|
||||
);
|
||||
|
||||
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.25, 0.5, 0.75).firstCycle());
|
||||
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
|
||||
});
|
||||
it('Can make isaw/isaw2', () => {
|
||||
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle());
|
||||
|
||||
expect(isaw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, -0.5).firstCycle());
|
||||
});
|
||||
it('Can make tri/tri2', () => {
|
||||
expect(tri.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.5, 1, 0.5).firstCycle());
|
||||
expect(tri2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, 0, 1, 0).firstCycle());
|
||||
});
|
||||
it('Can make itri/itri2', () => {
|
||||
expect(itri.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.5, 0, 0.5).firstCycle());
|
||||
expect(itri2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0, -1, 0).firstCycle());
|
||||
});
|
||||
it('Can make square/square2', () => {
|
||||
expect(square.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0, 1, 1).firstCycle());
|
||||
expect(square2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -1, 1, 1).firstCycle());
|
||||
});
|
||||
it('Can make isquare/isquare2', () => {
|
||||
expect(isquare.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 1, 0, 0).firstCycle());
|
||||
expect(isquare2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 1, -1, -1).firstCycle());
|
||||
});
|
||||
it('Can go into negative time', () => {
|
||||
expect(saw.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(0, 0.25, 0.5, 0.75).firstCycle(),
|
||||
);
|
||||
expect(saw2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(-1, -0.5, 0, 0.5).firstCycle(),
|
||||
);
|
||||
expect(isaw.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(1, 0.75, 0.5, 0.25).firstCycle(),
|
||||
);
|
||||
expect(isaw2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(1, 0.5, 0, -0.5).firstCycle(),
|
||||
);
|
||||
expect(tri.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(0, 0.5, 1, 0.5).firstCycle(),
|
||||
);
|
||||
expect(tri2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, 0, 1, 0).firstCycle());
|
||||
expect(itri.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(1, 0.5, 0, 0.5).firstCycle(),
|
||||
);
|
||||
expect(itri2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0, -1, 0).firstCycle());
|
||||
expect(square.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0, 1, 1).firstCycle());
|
||||
expect(square2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(-1, -1, 1, 1).firstCycle(),
|
||||
);
|
||||
expect(isquare.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(1, 1, 0, 0).firstCycle(),
|
||||
);
|
||||
expect(isquare2.late(1).struct(true, true, true, true).firstCycle()).toStrictEqual(
|
||||
sequence(1, 1, -1, -1).firstCycle(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cyclesPer', () => {
|
||||
|
||||
+29
-15
@@ -70,17 +70,6 @@ export const getEventOffsetMs = (targetTimeSeconds, currentTimeSeconds) => {
|
||||
return (targetTimeSeconds - currentTimeSeconds) * 1000;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated does not appear to be referenced or invoked anywhere in the codebase
|
||||
* @noAutocomplete
|
||||
*/
|
||||
export const getFreq = (noteOrMidi) => {
|
||||
if (typeof noteOrMidi === 'number') {
|
||||
return midiToFreq(noteOrMidi);
|
||||
}
|
||||
return midiToFreq(noteToMidi(noteOrMidi));
|
||||
};
|
||||
|
||||
const pcs = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
/**
|
||||
* @deprecated only used in workshop (first-notes)
|
||||
@@ -130,25 +119,50 @@ export const getPlayableNoteValue = (hap) => {
|
||||
return note;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a hap, returns the frequency of the note or freq, if defined
|
||||
*
|
||||
* Given a note, returns the frequency of that note
|
||||
* `getFreq('c3')`
|
||||
*
|
||||
* Given a midi note number, returns the frequency of that midi note
|
||||
* `getFreq('c3')`
|
||||
*
|
||||
* @name getFrequency
|
||||
* @tags pitch
|
||||
* @synonyms getFreq
|
||||
* @param {Hap | note | number} input
|
||||
* @returns {Number}
|
||||
* @example
|
||||
* i("4 8 9 10 - - 5 7 9 11 - -").tune("tranh3")
|
||||
* .mul(getFreq('c3'))
|
||||
* .freq().clip(.5).room(1)
|
||||
*/
|
||||
export const getFrequency = (hap) => {
|
||||
let { value, context } = hap;
|
||||
let { value, context } =
|
||||
typeof hap == 'object'
|
||||
? hap
|
||||
: // default: interpret value as midi note.
|
||||
{ value: hap, context: { type: 'note' } };
|
||||
// if value is number => interpret as midi number as long as its not marked as frequency
|
||||
if (typeof value === 'object') {
|
||||
if (value.freq) {
|
||||
return value.freq;
|
||||
}
|
||||
return getFreq(value.note || value.n || value.value);
|
||||
return getFreq(value.note ?? value.n ?? value.value);
|
||||
}
|
||||
if (typeof value === 'number' && context.type !== 'frequency') {
|
||||
value = midiToFreq(hap.value);
|
||||
value = midiToFreq(value);
|
||||
} else if (typeof value === 'string' && isNote(value)) {
|
||||
value = midiToFreq(noteToMidi(hap.value));
|
||||
value = midiToFreq(noteToMidi(value));
|
||||
} else if (typeof value !== 'number') {
|
||||
throw new Error('not a note or frequency: ' + value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const getFreq = (hap) => getFrequency(hap);
|
||||
|
||||
// rotate array by n steps (to the left)
|
||||
export const rotate = (arr, n) => arr.slice(n).concat(arr.slice(0, n));
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const csound = register('csound', (instrument, pat) => {
|
||||
instrument = instrument || 'triangle';
|
||||
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
||||
// TODO: find a alternative way to wait for csound to load (to wait with first time playback)
|
||||
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
return pat.onSchedule((hap, targetTime) => {
|
||||
if (!_csound) {
|
||||
logger('[csound] not loaded yet', 'warning');
|
||||
return;
|
||||
@@ -36,9 +36,10 @@ export const csound = register('csound', (instrument, pat) => {
|
||||
const controls = Object.entries({ ...hap.value, freq })
|
||||
.flat()
|
||||
.join('/');
|
||||
|
||||
// TODO: find out how to send a precise ctx based time
|
||||
// http://www.csounds.com/manual/html/i.html
|
||||
const timeOffset = targetTime - currentTime; // latency ?
|
||||
const timeOffset = targetTime - getAudioContext().currentTime; // latency ?
|
||||
//const timeOffset = time_deprecate - getAudioContext().currentTime
|
||||
const params = [
|
||||
`"${instrument}"`, // p1: instrument name
|
||||
@@ -144,7 +145,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
|
||||
p1 = `"${instrument}"`;
|
||||
}
|
||||
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
|
||||
return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
|
||||
return pat.onSchedule((hap, targetTime) => {
|
||||
if (!_csound) {
|
||||
logger('[csound] not loaded yet', 'warning');
|
||||
return;
|
||||
@@ -152,6 +153,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
|
||||
if (typeof hap.value !== 'object') {
|
||||
throw new Error('csound only support objects as hap values');
|
||||
}
|
||||
const currentTime = getAudioContext().currentTime;
|
||||
// Time in seconds counting from now.
|
||||
const p2 = targetTime - currentTime;
|
||||
const p3 = hap.duration.valueOf() + 0;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAudioContext } from '@strudel/webaudio/index.mjs';
|
||||
import { Invoke } from './utils.mjs';
|
||||
import { Pattern, getEventOffsetMs, noteToMidi } from '@strudel/core';
|
||||
|
||||
@@ -6,7 +7,8 @@ const OFF_MESSAGE = 0x80;
|
||||
const CC_MESSAGE = 0xb0;
|
||||
|
||||
Pattern.prototype.midi = function (output) {
|
||||
return this.onTrigger((hap, currentTime, cps, targetTime) => {
|
||||
return this.onSchedule((hap, targetTime, cps) => {
|
||||
const currentTime = getAudioContext().currentTime;
|
||||
let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value;
|
||||
//magic number to get audio engine to line up, can probably be calculated somehow
|
||||
const latencyMs = 34;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Pattern, ClockCollator } from '@strudel/core';
|
||||
import { parseControlsFromHap } from 'node_modules/@strudel/osc/osc.mjs';
|
||||
import { parseControlsFromHap } from '@strudel/osc/osc.mjs';
|
||||
import { Invoke } from './utils.mjs';
|
||||
import { getAudioContext } from '@strudel/webaudio/index.mjs';
|
||||
|
||||
const collator = new ClockCollator({});
|
||||
|
||||
export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
|
||||
export async function tauriOscOutput(hap, targetTime, cps = 1) {
|
||||
const controls = parseControlsFromHap(hap, cps);
|
||||
const params = [];
|
||||
const currentTime = getAudioContext().currentTime;
|
||||
const timestamp = collator.calculateTimestamp(currentTime, targetTime);
|
||||
|
||||
Object.keys(controls).forEach((key) => {
|
||||
@@ -32,5 +34,5 @@ export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
|
||||
});
|
||||
}
|
||||
Pattern.prototype.osc = function () {
|
||||
return this.onTrigger(oscTriggerTauri);
|
||||
return this.onSchedule(tauriOscOutput);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/osc": "workspace:*",
|
||||
"@strudel/webaudio": "workspace:*",
|
||||
"@tauri-apps/api": "^2.2.0"
|
||||
},
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
dough.mjs
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/osc/osc.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 } from '@strudel/core';
|
||||
import { Dough, doughsamples } from 'dough-synth';
|
||||
import { getAudioContext, ensureMinimalOutput } from '@strudel/webaudio';
|
||||
import wasm from 'dough-synth/dough.wasm?url';
|
||||
import workletCode from 'dough-synth/dough.js?raw';
|
||||
import { hap2dough } from './lib.mjs';
|
||||
export * from './lib.mjs';
|
||||
|
||||
Object.assign(globalThis, { doughsamples });
|
||||
|
||||
let D;
|
||||
/**
|
||||
*
|
||||
* initializes dough ahead of time. you don't need to use this, but if you do, you can await it, so dough is ready before the pattern starts.
|
||||
*
|
||||
* @name initDough
|
||||
* @tags external_io
|
||||
* @memberof Pattern
|
||||
* @example
|
||||
* await initDough()
|
||||
* $: chord("<Dm9 Dm11 Dm7>").offset("<-1 0 1 0>").voicing()
|
||||
* .phaser(3).fm(1.4).fmh(1.01)
|
||||
* .dough()
|
||||
*/
|
||||
export function initDough() {
|
||||
if (!D || D.audioContext !== getAudioContext()) {
|
||||
D = new Dough({
|
||||
wasm,
|
||||
workletCode,
|
||||
audioContext: getAudioContext(),
|
||||
});
|
||||
}
|
||||
return D.ready;
|
||||
}
|
||||
|
||||
export async function doughOutput(hap, targetTime, cps = 1) {
|
||||
const offset = D.context_offset?.[0];
|
||||
if (!offset) {
|
||||
return; // not ready
|
||||
}
|
||||
const t = targetTime - offset;
|
||||
const event = hap2dough(hap, t, cps);
|
||||
D.evaluate(event);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Uses dough as the audio engine. more info at https://dough.strudel.cc
|
||||
*
|
||||
* @name dough
|
||||
* @tags external_io
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
* @example
|
||||
* $: chord("<Dm9 Dm11 Dm7>").offset("<-1 0 1 0>").voicing()
|
||||
* .phaser(3).fm(1.4).fmh(1.01)
|
||||
* .dough()
|
||||
*/
|
||||
export const dough = register('dough', (pat) => {
|
||||
initDough();
|
||||
ensureMinimalOutput();
|
||||
return pat.onSchedule(doughOutput);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { register, registerControl, stack, Pattern, noteToMidi } from '@strudel/core';
|
||||
|
||||
export const { group } = registerControl('group');
|
||||
export const { voices } = registerControl('voices');
|
||||
export const { glide } = registerControl('glide');
|
||||
|
||||
export const groupmod = register('groupmod', function (modulator, pat) {
|
||||
const modpat = modulator
|
||||
.withValue((vm) => pat.withValue(({ group }) => ({ ...vm, group })))
|
||||
.outerJoin()
|
||||
.set({ dough: 'update_group' });
|
||||
return stack(pat.set(modulator), modpat);
|
||||
});
|
||||
Pattern.prototype.gmod = Pattern.prototype.groupmod;
|
||||
|
||||
export function hap2dough(hap, time, cps) {
|
||||
hap.ensureObjectValue();
|
||||
const event = {
|
||||
dough: 'play',
|
||||
...hap.value,
|
||||
time,
|
||||
duration: hap.duration / cps,
|
||||
};
|
||||
if (typeof event.note === 'string') {
|
||||
event.note = noteToMidi(event.note);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@strudel/dough",
|
||||
"version": "1.3.2",
|
||||
"description": "dough synth integration for strudel",
|
||||
"main": "dough.mjs",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"main": "dist/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://codeberg.org/uzu/strudel.git"
|
||||
},
|
||||
"keywords": [
|
||||
"tidalcycles",
|
||||
"strudel",
|
||||
"pattern",
|
||||
"livecoding",
|
||||
"algorave"
|
||||
],
|
||||
"author": "Felix Roos <flix91@gmail.com>",
|
||||
"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:*",
|
||||
"@strudel/webaudio": "workspace:*",
|
||||
"dough-synth": "0.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -78,16 +78,6 @@ export const cleanupDraw = (clearScreen = true, id) => {
|
||||
stopAllAnimations(id);
|
||||
};
|
||||
|
||||
export const cleanupDrawContext = (replID) => {
|
||||
const ctx = getDrawContext();
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
// clear the big canvas context, ignore inline widgets
|
||||
Object.keys(animationFrames).forEach(
|
||||
(id) => (!replID || id.startsWith(replID)) && !id.startsWith('_') && stopAnimationFrame(id),
|
||||
);
|
||||
};
|
||||
|
||||
Pattern.prototype.onPaint = function (painter) {
|
||||
return this.withState((state) => {
|
||||
if (!state.controls.painters) {
|
||||
|
||||
+74
-70
@@ -13,7 +13,7 @@ import {
|
||||
getIsStarted,
|
||||
getPattern,
|
||||
getTime,
|
||||
getTriggerFunc,
|
||||
getScheduleFunc,
|
||||
isPattern,
|
||||
logger,
|
||||
ref,
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
} from '@strudel/core';
|
||||
import { noteToMidi, getControlName } from '@strudel/core';
|
||||
import { Note } from 'webmidi';
|
||||
import { getAudioContext } from '@strudel/webaudio';
|
||||
import { scheduleAtTime } from '../superdough/helpers.mjs';
|
||||
import { getAudioContext, getClockBridge } from '@strudel/webaudio';
|
||||
import { scheduleAtTime, ensureMinimalOutput } from '../superdough/helpers.mjs';
|
||||
import { getMidiDeviceNamesString, getDevice } from './util.mjs';
|
||||
import { MidiInput } from './input.mjs';
|
||||
|
||||
@@ -171,6 +171,23 @@ function normalize(value = 0, min = 0, max = 1, exp = 1) {
|
||||
return Math.pow(normalized, exp);
|
||||
}
|
||||
|
||||
const isFirefox = navigator?.userAgent?.includes('Firefox');
|
||||
// call fn either directly with given time (non-firefox) or after scheduleAtTime with undefined (firefox)
|
||||
// the scheduleAtTime approach is still jittery, but the best we can be on firefox
|
||||
// firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=2062997
|
||||
function timedSend(timeMs, fn) {
|
||||
if (isFirefox) {
|
||||
const audioTime = getClockBridge().getAudioContextTime(timeMs);
|
||||
if (!audioTime) {
|
||||
logger('[midi]: skip event, not ready');
|
||||
return;
|
||||
}
|
||||
scheduleAtTime(() => fn(undefined), audioTime);
|
||||
} else {
|
||||
fn(timeMs);
|
||||
}
|
||||
}
|
||||
|
||||
function mapCC(mapping, value) {
|
||||
return Object.keys(value)
|
||||
.filter((key) => !!mapping[getControlName(key)])
|
||||
@@ -182,7 +199,7 @@ function mapCC(mapping, value) {
|
||||
}
|
||||
|
||||
// sends a cc message to the given device on the given channel
|
||||
function sendCC(ccn, ccv, device, midichan, targetTime) {
|
||||
function sendCC(ccn, ccv, device, midichan, timeMs) {
|
||||
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
||||
throw new Error('expected ccv to be a number between 0 and 1');
|
||||
}
|
||||
@@ -190,23 +207,19 @@ function sendCC(ccn, ccv, device, midichan, targetTime) {
|
||||
throw new Error('expected ccn to be a number or a string');
|
||||
}
|
||||
const scaled = Math.round(ccv * 127);
|
||||
scheduleAtTime(() => {
|
||||
device.sendControlChange(ccn, scaled, midichan);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendControlChange(ccn, scaled, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
// sends a program change message to the given device on the given channel
|
||||
function sendProgramChange(progNum, device, midichan, targetTime) {
|
||||
function sendProgramChange(progNum, device, midichan, timeMs) {
|
||||
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
||||
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendProgramChange(progNum, midichan);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendProgramChange(progNum, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
// sends a sysex message to the given device on the given channel
|
||||
function sendSysex(sysexid, sysexdata, device, targetTime) {
|
||||
function sendSysex(sysexid, sysexdata, device, timeMs) {
|
||||
if (Array.isArray(sysexid)) {
|
||||
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all sysexid bytes must be integers between 0 and 255');
|
||||
@@ -221,13 +234,11 @@ function sendSysex(sysexid, sysexdata, device, targetTime) {
|
||||
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all sysex bytes must be integers between 0 and 255');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendSysex(sysexid, sysexdata);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendSysex(sysexid, sysexdata, { time: timeMs }));
|
||||
}
|
||||
|
||||
// sends a NRPN message to the given device on the given channel
|
||||
function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
|
||||
function sendNRPN(nrpnn, nrpv, device, midichan, timeMs) {
|
||||
if (Array.isArray(nrpnn)) {
|
||||
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('all nrpnn bytes must be integers between 0 and 255');
|
||||
@@ -235,34 +246,29 @@ function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
|
||||
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) {
|
||||
throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendNRPN(nrpnn, nrpv, midichan);
|
||||
}, targetTime);
|
||||
|
||||
timedSend(timeMs, (timeMs) => device.sendNrpnValue(nrpnn, nrpv, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
// sends a pitch bend message to the given device on the given channel
|
||||
function sendPitchBend(midibend, device, midichan, targetTime) {
|
||||
function sendPitchBend(midibend, device, midichan, timeMs) {
|
||||
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
||||
throw new Error('expected midibend to be a number between -1 and 1');
|
||||
}
|
||||
scheduleAtTime(() => {
|
||||
device.sendPitchBend(midibend, midichan);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendPitchBend(midibend, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
// sends a channel aftertouch message to the given device on the given channel
|
||||
function sendAftertouch(miditouch, device, midichan, targetTime) {
|
||||
function sendAftertouch(miditouch, device, midichan, timeMs) {
|
||||
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
||||
throw new Error('expected miditouch to be a number between 0 and 1');
|
||||
}
|
||||
|
||||
scheduleAtTime(() => {
|
||||
device.sendChannelAftertouch(miditouch, midichan);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendChannelAftertouch(miditouch, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
// sends a note message to the given device on the given channel
|
||||
function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
||||
function sendNote(note, velocity, duration, device, midichan, timeMs) {
|
||||
if (note == null || note === '') {
|
||||
throw new Error('note cannot be null or empty');
|
||||
}
|
||||
@@ -273,11 +279,10 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
||||
throw new Error('duration must be a positive number');
|
||||
}
|
||||
const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
|
||||
const midiNote = new Note(midiNumber, { attack: velocity, duration });
|
||||
const midiNote = new Note(midiNumber, { attack: velocity });
|
||||
|
||||
scheduleAtTime(() => {
|
||||
device.playNote(midiNote, midichan);
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendNoteOn(midiNote, { channels: midichan, time: timeMs }));
|
||||
timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,8 +292,6 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
||||
* @param {object} options Additional MIDI configuration options
|
||||
* @example
|
||||
* note("c4").midichan(1).midi('IAC Driver Bus 1')
|
||||
* @example
|
||||
* note("c4").midichan(1).midi('IAC Driver Bus 1', { controller: true, latency: 50 })
|
||||
*/
|
||||
|
||||
Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
@@ -314,7 +317,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
let midiConfig = {
|
||||
// Default configuration values
|
||||
isController: false, // Disable sending notes for midi controllers
|
||||
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
|
||||
noteOffsetMs: isFirefox ? 10 : 1, // Default note-off offset to prevent glitching in ms. firefox needs more slack
|
||||
midichannel: 1, // Default MIDI channel
|
||||
velocity: 0.9, // Default velocity
|
||||
gain: 1, // Default gain
|
||||
@@ -337,11 +340,19 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
|
||||
});
|
||||
|
||||
return this.onTrigger((hap, _currentTime, cps, targetTime) => {
|
||||
ensureMinimalOutput();
|
||||
|
||||
return this.sortHapsByPart().onSchedule((hap, targetTime, cps) => {
|
||||
if (!WebMidi.enabled) {
|
||||
logger('Midi not enabled');
|
||||
return;
|
||||
}
|
||||
const timeMs = getClockBridge().getPerformanceTime(targetTime);
|
||||
if (!timeMs) {
|
||||
logger('[midi] clockbridge not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
hap.ensureObjectValue();
|
||||
|
||||
// midi event values from hap with configurable defaults
|
||||
@@ -379,7 +390,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
// if midimap is set, send a cc messages from defined controls
|
||||
if (midicontrolMap.has(midimap)) {
|
||||
const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
|
||||
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
|
||||
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeMs));
|
||||
} else if (midimap !== 'default') {
|
||||
// Add warning when a non-existent midimap is specified
|
||||
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
||||
@@ -387,16 +398,20 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
|
||||
// Handle note
|
||||
if (note !== undefined && !midiConfig.isController) {
|
||||
// note off messages will often a few ms arrive late,
|
||||
// try to prevent glitching by subtracting noteOffsetMs from the duration length
|
||||
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
|
||||
// note off time is calculated early, together with note on time
|
||||
// when the note off is due, the clock might have drifted, and the next note on message might happen before the note off
|
||||
// this would lead to the next note being cut off
|
||||
// this is why we make notes shorter by noteOffsetMs, so note offs happen earlier than the note ons after
|
||||
const hapDuration = (hap.duration.valueOf() / cps) * 1000;
|
||||
const offset = Math.min(midiConfig.noteOffsetMs, hapDuration / 2);
|
||||
const duration = hapDuration - offset;
|
||||
|
||||
sendNote(note, velocity, duration, device, midichan, targetTime);
|
||||
sendNote(note, velocity, duration, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle program change
|
||||
if (progNum !== undefined) {
|
||||
sendProgramChange(progNum, device, midichan, targetTime);
|
||||
sendProgramChange(progNum, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle sysex
|
||||
@@ -406,63 +421,53 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
// if sysexid is an array the first byte is 0x00
|
||||
|
||||
if (sysexid !== undefined && sysexdata !== undefined) {
|
||||
sendSysex(sysexid, sysexdata, device, targetTime);
|
||||
sendSysex(sysexid, sysexdata, device, timeMs);
|
||||
}
|
||||
|
||||
// Handle control change
|
||||
if (ccv !== undefined && ccn !== undefined) {
|
||||
sendCC(ccn, ccv, device, midichan, targetTime);
|
||||
sendCC(ccn, ccv, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle NRPN non-registered parameter number
|
||||
if (nrpnn !== undefined && nrpv !== undefined) {
|
||||
sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
|
||||
sendNRPN(nrpnn, nrpv, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle midibend
|
||||
if (midibend !== undefined) {
|
||||
sendPitchBend(midibend, device, midichan, targetTime);
|
||||
sendPitchBend(midibend, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle miditouch
|
||||
if (miditouch !== undefined) {
|
||||
sendAftertouch(miditouch, device, midichan, targetTime);
|
||||
sendAftertouch(miditouch, device, midichan, timeMs);
|
||||
}
|
||||
|
||||
// Handle midicmd
|
||||
if (hap.whole.begin + 0 === 0) {
|
||||
// we need to start here because we have the timing info
|
||||
scheduleAtTime(() => {
|
||||
device.sendStart();
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
|
||||
}
|
||||
if (['clock', 'midiClock'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendClock();
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendClock({ time: timeMs }));
|
||||
} else if (['start'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendStart();
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs }));
|
||||
} else if (['stop'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendStop();
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendStop({ time: timeMs }));
|
||||
} else if (['continue'].includes(midicmd)) {
|
||||
scheduleAtTime(() => {
|
||||
device.sendContinue();
|
||||
}, targetTime);
|
||||
timedSend(timeMs, (timeMs) => device.sendContinue({ time: timeMs }));
|
||||
} else if (Array.isArray(midicmd)) {
|
||||
if (midicmd[0] === 'progNum') {
|
||||
sendProgramChange(midicmd[1], device, midichan, targetTime);
|
||||
sendProgramChange(midicmd[1], device, midichan, timeMs);
|
||||
} else if (midicmd[0] === 'cc') {
|
||||
if (midicmd.length === 2) {
|
||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
|
||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeMs);
|
||||
}
|
||||
} else if (midicmd[0] === 'sysex') {
|
||||
if (midicmd.length === 3) {
|
||||
const [_, id, data] = midicmd;
|
||||
sendSysex(id, data, device, targetTime);
|
||||
sendSysex(id, data, device, timeMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,8 +570,8 @@ const kListeners = {};
|
||||
|
||||
function _triggerKeyboard(input, cps, now, latencyCycles) {
|
||||
const pattern = getPattern();
|
||||
const trigger = getTriggerFunc();
|
||||
if (!pattern || !trigger) {
|
||||
const schedule = getScheduleFunc();
|
||||
if (!pattern || !schedule) {
|
||||
return false;
|
||||
}
|
||||
const t = now + latencyCycles;
|
||||
@@ -583,8 +588,7 @@ function _triggerKeyboard(input, cps, now, latencyCycles) {
|
||||
return;
|
||||
}
|
||||
const t = ctxNow + (hap.whole.begin - now) / cps;
|
||||
const duration = hap.duration / cps;
|
||||
trigger(hap, t - ctxNow, duration, cps, t);
|
||||
schedule(hap, t, cps);
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -619,7 +623,7 @@ export async function midikeys(input) {
|
||||
noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff
|
||||
const key = `${input}_${note}`;
|
||||
const cps = getCps() ?? 0.5;
|
||||
const triggerAvailable = !!(getPattern() && getTriggerFunc());
|
||||
const triggerAvailable = !!(getPattern() && getScheduleFunc());
|
||||
const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency
|
||||
const now = getTime();
|
||||
const t = now + latencySeconds * cps;
|
||||
|
||||
@@ -84,6 +84,22 @@ describe('mini', () => {
|
||||
it('supports patterning euclidean rhythms', () => {
|
||||
expect(minS('[a(<3 5>, <8 16>)]*2')).toEqual(minS('a(3,8) a(5,16)'));
|
||||
});
|
||||
it('supports inverted euclidean rhythms', () => {
|
||||
expect(minS('a(-3, 8)')).toEqual(['a: 1/8 - 1/4', 'a: 1/4 - 3/8', 'a: 1/2 - 5/8', 'a: 5/8 - 3/4', 'a: 7/8 - 1']);
|
||||
});
|
||||
it('supports euclidean legato rhythms', () => {
|
||||
expect(minS('a(3, -8)')).toEqual(['a: 0 - 3/8', 'a: 3/8 - 3/4', 'a: 3/4 - 1']);
|
||||
});
|
||||
it('supports inverted euclidean legato rhythms', () => {
|
||||
expect(minS('a(-3, -8)')).toEqual([
|
||||
'a: -1/8 - 1/8',
|
||||
'a: 1/8 - 1/4',
|
||||
'a: 1/4 - 1/2',
|
||||
'a: 1/2 - 5/8',
|
||||
'a: 5/8 - 7/8',
|
||||
'a: 7/8 - 9/8',
|
||||
]);
|
||||
});
|
||||
it("reproduces Toussaint's example euclidean algorithms", () => {
|
||||
const checkEuclid = function (spec, target) {
|
||||
expect(minS(`x(${spec[0]},${spec[1]})`)).toEqual(minS(target));
|
||||
@@ -118,6 +134,40 @@ describe('mini', () => {
|
||||
checkEuclid([11, 24], 'x ~ ~ x ~ x ~ x ~ x ~ x ~ ~ x ~ x ~ x ~ x ~ x ~');
|
||||
checkEuclid([13, 24], 'x ~ x x ~ x ~ x ~ x ~ x ~ x x ~ x ~ x ~ x ~ x ~');
|
||||
});
|
||||
it("reproduces Toussaint's example euclidean legato algorithms", () => {
|
||||
const checkEuclid = function (spec, target) {
|
||||
expect(minS(`x(${spec[0]},${spec[1]})`)).toEqual(minS(target));
|
||||
};
|
||||
checkEuclid([1, -2], 'x _');
|
||||
checkEuclid([1, -3], 'x _ _');
|
||||
checkEuclid([1, -4], 'x _ _ _');
|
||||
checkEuclid([4, -12], 'x _ _ x _ _ x _ _ x _ _');
|
||||
checkEuclid([2, -5], 'x _ x _ _');
|
||||
// checkEuclid([3, 4], "x _ x x"); // Toussaint is wrong..
|
||||
checkEuclid([3, -4], 'x x x _'); // correction
|
||||
checkEuclid([3, -5], 'x _ x _ x');
|
||||
checkEuclid([3, -7], 'x _ x _ x _ _');
|
||||
checkEuclid([3, -8], 'x _ _ x _ _ x _');
|
||||
checkEuclid([4, -7], 'x _ x _ x _ x');
|
||||
checkEuclid([4, -9], 'x _ x _ x _ x _ _');
|
||||
checkEuclid([4, -11], 'x _ _ x _ _ x _ _ x _');
|
||||
// checkEuclid([5, 6], "x _ x x x x"); // Toussaint is wrong..
|
||||
checkEuclid([5, -6], 'x x x x x _'); // correction
|
||||
checkEuclid([5, -7], 'x _ x x _ x x');
|
||||
checkEuclid([5, -8], 'x _ x x _ x x _');
|
||||
checkEuclid([5, -9], 'x _ x _ x _ x _ x');
|
||||
checkEuclid([5, -11], 'x _ x _ x _ x _ x _ _');
|
||||
checkEuclid([5, -12], 'x _ _ x _ x _ _ x _ x _');
|
||||
// checkEuclid([5, 16], "x _ _ x _ _ x _ _ x _ _ x _ _ _ _"); // Toussaint is wrong..
|
||||
checkEuclid([5, -16], 'x _ _ x _ _ x _ _ x _ _ x _ _ _'); // correction
|
||||
// checkEuclid([7, 8], "x _ x x x x x x"); // Toussaint is wrong..
|
||||
checkEuclid([7, -8], 'x x x x x x x _'); // Correction
|
||||
checkEuclid([7, -12], 'x _ x x _ x _ x x _ x _');
|
||||
checkEuclid([7, -16], 'x _ _ x _ x _ x _ _ x _ x _ x _');
|
||||
checkEuclid([9, -16], 'x _ x x _ x _ x _ x x _ x _ x _');
|
||||
checkEuclid([11, -24], 'x _ _ x _ x _ x _ x _ x _ _ x _ x _ x _ x _ x _');
|
||||
checkEuclid([13, -24], 'x _ x x _ x _ x _ x _ x _ x x _ x _ x _ x _ x _');
|
||||
});
|
||||
it('supports the - alias for ~', () => {
|
||||
expect(minS('a - b [- c]')).toEqual(minS('a ~ b [~ c]'));
|
||||
});
|
||||
|
||||
@@ -46,7 +46,6 @@ lib[':'] = tail;
|
||||
lib['..'] = range;
|
||||
lib['def'] = () => silence;
|
||||
lib['or'] = (...children) => chooseIn(...children); // always has structure but is cyclewise.. e.g. "s oh*8.dec[.04 | .5]"
|
||||
//lib['or'] = (...children) => chooseOut(...children); // "s oh*8.dec[.04 | .5]" is better but "dec[.04 | .5].s oh*8" has no struct
|
||||
|
||||
function evaluator(node, scope) {
|
||||
const { type } = node;
|
||||
|
||||
+38
-32
@@ -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/>.
|
||||
*/
|
||||
|
||||
import { Pattern, isPattern } from '@strudel/core';
|
||||
import { Pattern, getClockTime, createParams } from '@strudel/core';
|
||||
import Paho from 'paho-mqtt';
|
||||
|
||||
const connections = {};
|
||||
@@ -80,41 +80,47 @@ Pattern.prototype.mqtt = function (
|
||||
}
|
||||
cx.connect(props);
|
||||
}
|
||||
return this.withHap((hap) => {
|
||||
const onTrigger = (hap, currentTime, cps, targetTime) => {
|
||||
let msg_topic = topic;
|
||||
if (!cx || !cx.isConnected()) {
|
||||
return;
|
||||
}
|
||||
let message = '';
|
||||
if (typeof hap.value === 'object') {
|
||||
let value = hap.value;
|
||||
return this.onSchedule((hap, targetTime, cps) => {
|
||||
let msg_topic = topic;
|
||||
if (!cx || !cx.isConnected()) {
|
||||
return;
|
||||
}
|
||||
let message = '';
|
||||
if (typeof hap.value === 'object') {
|
||||
let value = hap.value;
|
||||
|
||||
// Try to take topic from pattern if it's not set
|
||||
if (typeof msg_topic === 'undefined' && 'topic' in value) {
|
||||
msg_topic = value.topic;
|
||||
if (Array.isArray(msg_topic)) {
|
||||
msg_topic = msg_topic.join('/');
|
||||
}
|
||||
msg_topic = '/' + msg_topic;
|
||||
// Try to take topic from pattern if it's not set
|
||||
if (typeof msg_topic === 'undefined' && 'topic' in value) {
|
||||
msg_topic = value.topic;
|
||||
if (Array.isArray(msg_topic)) {
|
||||
msg_topic = msg_topic.join('/');
|
||||
}
|
||||
if (add_meta) {
|
||||
const duration = hap.duration.div(cps);
|
||||
value = { ...value, duration: duration.valueOf(), cps: cps };
|
||||
}
|
||||
message = JSON.stringify(value);
|
||||
} else {
|
||||
message = hap.value;
|
||||
msg_topic = '/' + msg_topic;
|
||||
}
|
||||
message = new Paho.Message(message);
|
||||
message.destinationName = msg_topic;
|
||||
if (add_meta) {
|
||||
const duration = hap.duration.div(cps);
|
||||
value = { ...value, duration: duration.valueOf(), cps: cps };
|
||||
}
|
||||
message = JSON.stringify(value);
|
||||
} else {
|
||||
message = hap.value;
|
||||
}
|
||||
message = new Paho.Message(message);
|
||||
message.destinationName = msg_topic;
|
||||
|
||||
const offset = (targetTime - currentTime + latency) * 1000;
|
||||
const offset = (targetTime - getClockTime() + latency) * 1000;
|
||||
|
||||
window.setTimeout(function () {
|
||||
cx.send(message);
|
||||
}, offset);
|
||||
};
|
||||
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
|
||||
window.setTimeout(function () {
|
||||
cx.send(message);
|
||||
}, offset);
|
||||
});
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
+24
-7
@@ -4,7 +4,8 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core';
|
||||
import { logger, parseNumeral, register, isNote, noteToMidi } from '@strudel/core';
|
||||
import { ensureMinimalOutput, getClockBridge } from '@strudel/webaudio';
|
||||
|
||||
let connection; // Promise<OSC>
|
||||
function connect() {
|
||||
@@ -45,7 +46,6 @@ export function parseControlsFromHap(hap, cps) {
|
||||
controls.midinote = parseNumeral(controls.note);
|
||||
}
|
||||
}
|
||||
controls.bank && (controls.s = controls.bank + controls.s);
|
||||
controls.roomsize && (controls.size = parseNumeral(controls.roomsize));
|
||||
// speed adjustment for CPS is handled on the DSP side in superdirt and pattern side in Strudel,
|
||||
// so we need to undo the adjustment before sending the message to superdirt.
|
||||
@@ -55,13 +55,27 @@ export function parseControlsFromHap(hap, cps) {
|
||||
return controls;
|
||||
}
|
||||
|
||||
const collator = new ClockCollator({});
|
||||
|
||||
export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
|
||||
let timeOrigin = performance.timeOrigin;
|
||||
export async function oscOutput(hap, targetTime, cps = 1) {
|
||||
const ws = await connect();
|
||||
|
||||
const timeMs = getClockBridge().getPerformanceTime(targetTime);
|
||||
if (!timeMs) {
|
||||
logger('[osc] clockbridge not ready');
|
||||
return;
|
||||
}
|
||||
// when the system sleeps while the browser is open, performance.now() doesnt tick
|
||||
// .. which means performance.timeOrigin + performance.now() doesnt land in the present
|
||||
const deprivation = timeOrigin + performance.now() - Date.now();
|
||||
if (Math.abs(deprivation) > 20) {
|
||||
console.log(`[osc] snap with ${(deprivation / 1000).toFixed(2)}s deprivation`);
|
||||
// shift time after microsleep
|
||||
timeOrigin -= deprivation;
|
||||
}
|
||||
const ts = timeOrigin + timeMs;
|
||||
|
||||
const controls = parseControlsFromHap(hap, cps);
|
||||
const keyvals = Object.entries(controls).flat();
|
||||
const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
|
||||
const msg = { address: '/dirt/play', args: keyvals, timestamp: ts };
|
||||
|
||||
if ('oschost' in hap.value) {
|
||||
@@ -83,4 +97,7 @@ export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
|
||||
* @memberof Pattern
|
||||
* @returns Pattern
|
||||
*/
|
||||
export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger));
|
||||
export const osc = register('osc', (pat) => {
|
||||
ensureMinimalOutput();
|
||||
return pat.onSchedule(oscOutput);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"homepage": "https://codeberg.org/uzu/strudel#readme",
|
||||
"dependencies": {
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/webaudio": "workspace:*",
|
||||
"osc": "^2.4.5",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
|
||||
/* import { tauriOscOutput } from '../desktopbridge/oscbridge.mjs';
|
||||
import { isTauri } from '../desktopbridge/utils.mjs'; */
|
||||
import { oscTrigger } from './osc.mjs';
|
||||
import { oscOutput } from './osc.mjs';
|
||||
|
||||
const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger;
|
||||
const output = /* isTauri() ? tauriOscOutput : */ oscOutput;
|
||||
|
||||
export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
|
||||
const currentTime = performance.now() / 1000;
|
||||
return trigger(hap, currentTime, cps, targetTime);
|
||||
};
|
||||
export const superdirtOutput = (hap, targetTime, cps) => output(hap, targetTime, cps);
|
||||
|
||||
@@ -37,7 +37,7 @@ if (typeof HTMLElement !== 'undefined') {
|
||||
const drawContext = getDrawContext();
|
||||
const drawTime = [-2, 2];
|
||||
this.editor = new StrudelMirror({
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
transpiler,
|
||||
root: container,
|
||||
|
||||
@@ -98,6 +98,7 @@ if (args.includes('--json')) {
|
||||
}
|
||||
writeFileSync(outFile, json, 'utf8');
|
||||
console.log(`Wrote json to ${outFile}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
|
||||
+40
-43
@@ -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/>.
|
||||
*/
|
||||
|
||||
import { Pattern, isPattern } from '@strudel/core';
|
||||
import { Pattern, getClockTime } from '@strudel/core';
|
||||
|
||||
var writeMessagers = {};
|
||||
var choosing = false;
|
||||
@@ -64,53 +64,50 @@ function crc16(data) {
|
||||
}
|
||||
|
||||
Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids = false, name = 'default') {
|
||||
return this.withHap((hap) => {
|
||||
if (!(name in writeMessagers)) {
|
||||
getWriter(name, br);
|
||||
}
|
||||
const onTrigger = (hap, currentTime, _cps, targetTime) => {
|
||||
var message = '';
|
||||
var chk = 0;
|
||||
if (typeof hap.value === 'object') {
|
||||
if ('action' in hap.value) {
|
||||
var action = hap.value['action'];
|
||||
if (!(name in writeMessagers)) {
|
||||
getWriter(name, br);
|
||||
}
|
||||
return this.onSchedule((hap, targetTime) => {
|
||||
var message = '';
|
||||
var chk = 0;
|
||||
if (typeof hap.value === 'object') {
|
||||
if ('action' in hap.value) {
|
||||
var action = hap.value['action'];
|
||||
if (singlecharids) {
|
||||
action = action.charAt(0);
|
||||
}
|
||||
message += action + '(';
|
||||
var first = true;
|
||||
for (var [key, val] of Object.entries(hap.value)) {
|
||||
if (key === 'action') {
|
||||
continue;
|
||||
}
|
||||
if (first) {
|
||||
first = false;
|
||||
} else {
|
||||
message += ',';
|
||||
}
|
||||
if (singlecharids) {
|
||||
action = action.charAt(0);
|
||||
}
|
||||
message += action + '(';
|
||||
var first = true;
|
||||
for (var [key, val] of Object.entries(hap.value)) {
|
||||
if (key === 'action') {
|
||||
continue;
|
||||
}
|
||||
if (first) {
|
||||
first = false;
|
||||
} else {
|
||||
message += ',';
|
||||
}
|
||||
if (singlecharids) {
|
||||
key = key.charAt(0);
|
||||
}
|
||||
message += key + ':' + val;
|
||||
}
|
||||
message += ')';
|
||||
if (sendcrc) {
|
||||
chk = crc16(message);
|
||||
}
|
||||
} else {
|
||||
for (const [key, val] of Object.entries(hap.value)) {
|
||||
message += `${key}:${val}`;
|
||||
key = key.charAt(0);
|
||||
}
|
||||
message += key + ':' + val;
|
||||
}
|
||||
message += ')';
|
||||
if (sendcrc) {
|
||||
chk = crc16(message);
|
||||
}
|
||||
} else {
|
||||
message = hap.value;
|
||||
for (const [key, val] of Object.entries(hap.value)) {
|
||||
message += `${key}:${val}`;
|
||||
}
|
||||
}
|
||||
const offset = (targetTime - currentTime + latency) * 1000;
|
||||
} else {
|
||||
message = hap.value;
|
||||
}
|
||||
const offset = (targetTime - getClockTime() + latency) * 1000;
|
||||
|
||||
window.setTimeout(function () {
|
||||
writeMessagers[name](message, chk);
|
||||
}, offset);
|
||||
};
|
||||
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
|
||||
window.setTimeout(function () {
|
||||
writeMessagers[name](message, chk);
|
||||
}, offset);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ async function loadFont(name) {
|
||||
const url = `${soundfontUrl}/${name}.js`;
|
||||
const preset = await fetch(url).then((res) => res.text());
|
||||
let [_, data] = preset.split('={');
|
||||
return eval('{' + data);
|
||||
return new Function('return {' + data)().zones;
|
||||
};
|
||||
loadCache[name] = load();
|
||||
return loadCache[name];
|
||||
|
||||
@@ -479,7 +479,7 @@ export default {
|
||||
gm_electric_bass_finger: [
|
||||
// Electric Bass (finger): Bass
|
||||
'0330_JCLive_sf2_file',
|
||||
'0330_FluidR3_GM_sf2_fible',
|
||||
'0330_FluidR3_GM_sf2_file',
|
||||
'0330_Aspirin_sf2_file',
|
||||
//0330_Chaos_sf2_file // same as last
|
||||
'0330_GeneralUserGS_sf2_file',
|
||||
@@ -527,7 +527,7 @@ export default {
|
||||
'0370_Aspirin_sf2_file',
|
||||
// 0370_Chaos_sf2_file // same as last
|
||||
'0370_FluidR3_GM_sf2_file',
|
||||
'0370_GeneralUserGS_sf2_fil e',
|
||||
'0370_GeneralUserGS_sf2_file',
|
||||
'0370_JCLive_sf2_file',
|
||||
//0370_SBLive_sf2
|
||||
//0370_SoundBlasterOld_sf2
|
||||
@@ -1782,6 +1782,5 @@ export default {
|
||||
'1272_GeneralUserGS_sf2_file',
|
||||
'1273_GeneralUserGS_sf2_file',
|
||||
'1274_GeneralUserGS_sf2_file',
|
||||
'',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getAudioContext, registerSound } from '@strudel/webaudio';
|
||||
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
|
||||
|
||||
Pattern.prototype.soundfont = function (sf, n = 0) {
|
||||
return this.onTrigger((h, ct, cps, targetTime) => {
|
||||
return this.onSchedule((h, targetTime) => {
|
||||
const ctx = getAudioContext();
|
||||
const note = getPlayableNoteValue(h);
|
||||
const preset = sf.presets[n % sf.presets.length];
|
||||
|
||||
@@ -7,15 +7,24 @@ Copyright (C) 2025 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/>.
|
||||
*/
|
||||
|
||||
let audioContext;
|
||||
import { clearNodePool } from './nodePools.mjs';
|
||||
import { ClockBridge } from './clockbridge.mjs';
|
||||
|
||||
let audioContext, clockBridge;
|
||||
|
||||
export const setDefaultAudioContext = () => {
|
||||
audioContext = new AudioContext();
|
||||
return audioContext;
|
||||
return setAudioContext(new AudioContext());
|
||||
};
|
||||
|
||||
export const setAudioContext = (context) => {
|
||||
// Existing nodes in the node pool contain references to the previous AudioContext,
|
||||
// so all the nodes in the pool must be cleared when we set a new AudioContext.
|
||||
clearNodePool();
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close();
|
||||
}
|
||||
audioContext = context;
|
||||
clockBridge = new ClockBridge(audioContext);
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
@@ -27,6 +36,13 @@ export const getAudioContext = () => {
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export function getClockBridge() {
|
||||
if (!clockBridge) {
|
||||
getAudioContext(); // creates clockBridge
|
||||
}
|
||||
return clockBridge;
|
||||
}
|
||||
|
||||
export function getAudioContextCurrentTime() {
|
||||
return getAudioContext().currentTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
clockbridge.mjs
|
||||
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/index.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/>.
|
||||
*/
|
||||
|
||||
// thanks freya https://youtu.be/LSNQuFEDOyQ?si=ukZI2IGgWV_NDZzP&t=2979
|
||||
export function expDecay(a, b, decay, dt) {
|
||||
return b + (a - b) * Math.exp(-decay * dt);
|
||||
}
|
||||
|
||||
// translates between audio and performance clock
|
||||
export class ClockBridge {
|
||||
p; // smoothed clock offset
|
||||
lastTime;
|
||||
audioContext;
|
||||
constructor(audioContext) {
|
||||
this.audioContext = audioContext;
|
||||
}
|
||||
// delta between audio and performance time in ms
|
||||
getOffset() {
|
||||
const { contextTime, performanceTime } = this.audioContext.getOutputTimestamp();
|
||||
if (!contextTime || !performanceTime) {
|
||||
return;
|
||||
}
|
||||
const offset = performanceTime - contextTime * 1000; // clock offset in ms
|
||||
const dt = performanceTime - (this.lastTime ?? performanceTime); // delta time since last hap
|
||||
const deprivation = offset - this.p;
|
||||
// when the system sleeps, the audio and system clock get jumbled
|
||||
// so we dont smooth if the jump is too high, to snap the offset
|
||||
if (Math.abs(deprivation) > 20) {
|
||||
console.log(`[ClockBridge] snap with ${(deprivation / 1000).toFixed(2)}s deprivation`);
|
||||
this.p = offset;
|
||||
} else {
|
||||
const decay = 1 / 10000; // how fast offset changes have an effect
|
||||
this.p = expDecay(this.p ?? offset, offset, decay, dt); // smooth clock offset
|
||||
}
|
||||
this.lastTime = performanceTime;
|
||||
return this.p;
|
||||
}
|
||||
getPerformanceTime(audioContextTime) {
|
||||
const offset = this.getOffset();
|
||||
return audioContextTime * 1000 + offset; // this is now correct in performance time (ms)
|
||||
}
|
||||
getAudioContextTime(performanceTime) {
|
||||
const offset = this.getOffset();
|
||||
return (performanceTime - offset) / 1000; // this is now correct in audio context time (seconds)
|
||||
}
|
||||
}
|
||||
@@ -67,13 +67,14 @@ if (typeof window !== 'undefined') {
|
||||
});
|
||||
}
|
||||
|
||||
export const dough = async (code) => {
|
||||
export const rawdsp = async (code) => {
|
||||
const ac = getAudioContext();
|
||||
stop();
|
||||
worklet = await dspWorklet(ac, code);
|
||||
worklet.node.connect(ac.destination);
|
||||
};
|
||||
|
||||
export function doughTrigger(hap, currentTime, cps, targetTime) {
|
||||
window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps });
|
||||
export function rawdspOutput(hap, time, cps) {
|
||||
const currentTime = getAudioContext().currentTime;
|
||||
window.postMessage({ time, dough: hap.value, currentTime, duration: hap.duration, cps });
|
||||
}
|
||||
|
||||
@@ -6,12 +6,27 @@ import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
|
||||
|
||||
export const noises = ['pink', 'white', 'brown', 'crackle'];
|
||||
|
||||
export function gainNode(value) {
|
||||
const node = getAudioContext().createGain();
|
||||
export function gainNode(value, audioContext = getAudioContext()) {
|
||||
const node = audioContext.createGain();
|
||||
node.gain.value = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
// this helper makes sure the audio context is "used", meaning it outputs something
|
||||
// this prevents the browser from throttling timing accuracy
|
||||
// it happened when only midi was running, the clock got more drifty without this
|
||||
let constantNode, constantNodeAudioContext;
|
||||
export function ensureMinimalOutput() {
|
||||
if (constantNode && constantNodeAudioContext === getAudioContext()) {
|
||||
return;
|
||||
}
|
||||
constantNodeAudioContext = getAudioContext();
|
||||
constantNode = new ConstantSourceNode(constantNodeAudioContext);
|
||||
constantNode.offset.value = 1e-7;
|
||||
constantNode.connect(constantNodeAudioContext.destination);
|
||||
constantNode.start();
|
||||
}
|
||||
|
||||
export function effectSend(input, effect, wet) {
|
||||
const send = gainNode(wet);
|
||||
input.connect(send);
|
||||
@@ -374,7 +389,7 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
||||
|
||||
// Certain browsers requires audio nodes to be connected in order for their onended events
|
||||
// to fire, so we _mute it_ and then connect it to the destination
|
||||
const zeroGain = gainNode(0);
|
||||
const zeroGain = gainNode(0, audioContext);
|
||||
zeroGain.connect(audioContext.destination);
|
||||
constantNode.connect(zeroGain);
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from './modulators.mjs';
|
||||
export * from './dspworklet.mjs';
|
||||
export * from './audioContext.mjs';
|
||||
export * from './wavetable.mjs';
|
||||
export * from './clockbridge.mjs';
|
||||
|
||||
@@ -5,11 +5,24 @@ Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { releaseAudioNode } from './helpers.mjs';
|
||||
|
||||
const nodePools = new Map();
|
||||
const POOL_KEY = Symbol('nodePoolKey');
|
||||
|
||||
export const isPoolable = (node) => !!node[POOL_KEY];
|
||||
|
||||
export const clearNodePool = () => {
|
||||
for (const pool of nodePools) {
|
||||
for (const node of pool) {
|
||||
if (node instanceof AudioNode) {
|
||||
releaseAudioNode(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
nodePools.clear();
|
||||
};
|
||||
|
||||
const getNodeTime = (node) => {
|
||||
return node.context?.currentTime ?? 0;
|
||||
};
|
||||
|
||||
@@ -36,8 +36,9 @@ function humanFileSize(bytes, si) {
|
||||
*
|
||||
* Note: `must` be called with await, otherwise you'll get a pending Promise object.
|
||||
*
|
||||
* @name getDuration,getDur
|
||||
* @tag samples
|
||||
* @name getDuration
|
||||
* @synonym getDur
|
||||
* @tags samples
|
||||
* @param {string} sampleName
|
||||
* @param {number} (optional) n
|
||||
*
|
||||
|
||||
@@ -1048,7 +1048,3 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const superdoughTrigger = (t, hap, ct, cps) => {
|
||||
superdough(hap, t - ct, hap.duration / cps, cps);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import './tonal.mjs';
|
||||
import './voicings.mjs';
|
||||
import './ireal.mjs';
|
||||
|
||||
export * from './tonal.mjs';
|
||||
export * from './voicings.mjs';
|
||||
export * from './ireal.mjs';
|
||||
|
||||
import './ireal.mjs';
|
||||
|
||||
export const packageName = '@strudel/tonal';
|
||||
|
||||
@@ -137,7 +137,14 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr
|
||||
const interval = !isNaN(Number(intervalOrSemitones))
|
||||
? Interval.fromSemitones(intervalOrSemitones)
|
||||
: String(intervalOrSemitones);
|
||||
const targetNote = Note.transpose(note, interval);
|
||||
|
||||
let n = Note.get(note);
|
||||
if (n.oct == undefined) {
|
||||
n.oct = 3;
|
||||
let n1 = Note.get(Note.transpose(n, interval));
|
||||
n.oct = n1.oct == 3 ? undefined : 3;
|
||||
}
|
||||
const targetNote = Note.transpose(n, interval);
|
||||
if (typeof hap.value === 'object') {
|
||||
return hap.withValue(() => ({ ...hap.value, note: targetNote }));
|
||||
}
|
||||
|
||||
@@ -134,6 +134,16 @@ let modeTarget = {
|
||||
duck: (v) => v.slice(-1)[0],
|
||||
above: (v) => v[0],
|
||||
root: (v) => v[0],
|
||||
oldabove: (v) => v[0],
|
||||
oldroot: (v) => v[0],
|
||||
};
|
||||
let modeMult = {
|
||||
below: 1,
|
||||
duck: 1,
|
||||
above: -1,
|
||||
root: -1,
|
||||
oldabove: 1,
|
||||
oldroot: 1,
|
||||
};
|
||||
|
||||
export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below', anchor = 'c5', octaves = 1 }) {
|
||||
@@ -144,19 +154,20 @@ export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below'
|
||||
const voicings = dictionary[symbol].map((voicing) =>
|
||||
(typeof voicing === 'string' ? voicing.split(' ') : voicing).map(step2semitones),
|
||||
);
|
||||
const mult = modeMult[mode];
|
||||
|
||||
let minDistance, bestIndex;
|
||||
// calculate distances up from voicing top notes
|
||||
let chromaDiffs = voicings.map((v, i) => {
|
||||
const targetStep = modeTarget[mode](v);
|
||||
const diff = _mod(anchorChroma - targetStep - rootChroma, 12);
|
||||
const diff = _mod((anchorChroma - targetStep - rootChroma) * mult, 12);
|
||||
if (minDistance === undefined || diff < minDistance) {
|
||||
minDistance = diff;
|
||||
bestIndex = i;
|
||||
}
|
||||
return diff;
|
||||
return diff * mult;
|
||||
});
|
||||
if (mode === 'root') {
|
||||
if (mode === 'root' || mode === 'oldroot') {
|
||||
bestIndex = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,9 @@ export const rootNotes = register('rootNotes', function (octave, pat) {
|
||||
* - `below`: top note <= anchor
|
||||
* - `duck`: top note <= anchor, anchor excluded
|
||||
* - `above`: bottom note >= anchor
|
||||
* - `root`: bottom note is the lowest root of the chord >= anchor
|
||||
* - `oldabove` : old (buggy) behavior of above, kept for legacy reason
|
||||
* - `oldroot` : old (buggy) behavior of root, kept for legacy reason
|
||||
* - `offset`: whole number that shifts the voicing up or down to the next voicing
|
||||
* - `n`: if set, the voicing is played like a scale. Overshooting numbers will be octaved
|
||||
*
|
||||
|
||||
@@ -39,15 +39,11 @@ const sliderTranspilerPlugin = {
|
||||
if (!isSliderFunction(node)) return;
|
||||
context.widgets ??= [];
|
||||
context.sliders ??= [];
|
||||
const { options, widgets, sliders, nodeOffset } = context;
|
||||
const { options, widgets, sliders } = context;
|
||||
const { emitWidgets } = options;
|
||||
const from = node.arguments[0].start + nodeOffset;
|
||||
const to = node.arguments[0].end + nodeOffset;
|
||||
const id = `${from}:${to}`; // Range-based ID for stability
|
||||
const sliderConfig = {
|
||||
from,
|
||||
to,
|
||||
id,
|
||||
from: node.arguments[0].start,
|
||||
to: node.arguments[0].end,
|
||||
value: node.arguments[0].raw, // don't use value!
|
||||
min: node.arguments[1]?.value ?? 0,
|
||||
max: node.arguments[2]?.value ?? 1,
|
||||
@@ -56,7 +52,7 @@ const sliderTranspilerPlugin = {
|
||||
};
|
||||
emitWidgets && widgets.push(sliderConfig);
|
||||
sliders.push(sliderConfig);
|
||||
return this.replace(sliderWithLocation(node, nodeOffset));
|
||||
return this.replace(sliderWithLocation(node));
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -73,13 +69,8 @@ function isWidgetMethod(node) {
|
||||
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
|
||||
}
|
||||
|
||||
function sliderWithLocation(node, nodeOffset = 0) {
|
||||
// Apply nodeOffset for block-based evaluation to generate correct range
|
||||
const from = node.arguments[0].start + nodeOffset;
|
||||
const to = node.arguments[0].end + nodeOffset;
|
||||
|
||||
// Use range-based ID for stability during block evaluation
|
||||
const id = `${from}:${to}`;
|
||||
function sliderWithLocation(node) {
|
||||
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
|
||||
|
||||
// add loc as identifier to first argument
|
||||
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
||||
@@ -95,19 +86,10 @@ function sliderWithLocation(node, nodeOffset = 0) {
|
||||
export function getWidgetID(widgetConfig) {
|
||||
// the widget id is used as id for the dom element + as key for eventual resources
|
||||
// for example, for each scope widget, a new analyser + buffer (large) is created
|
||||
// Update: use range-based ID generation for better stability during block evaluation
|
||||
// When we have both from and to, use them together for stability
|
||||
// Otherwise fall back to position-based ID for backward compatibility
|
||||
let uniqueIdentifier;
|
||||
if (widgetConfig.from !== undefined && widgetConfig.to !== undefined) {
|
||||
// Use range for more stable identification
|
||||
uniqueIdentifier = `${widgetConfig.from}-${widgetConfig.to}`;
|
||||
} else {
|
||||
// Fallback to single position (for backward compatibility)
|
||||
uniqueIdentifier = widgetConfig.to || widgetConfig.from || 0;
|
||||
}
|
||||
const baseId = `${widgetConfig.id || ''}_widget_${widgetConfig.type}`;
|
||||
return `${baseId}_${widgetConfig.index}_${uniqueIdentifier}`;
|
||||
// that means, if we use the index index of line position as id, less garbage is generated
|
||||
// return `widget_${widgetConfig.to}`; // more gargabe
|
||||
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage
|
||||
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
|
||||
}
|
||||
|
||||
function widgetWithLocation(node, widgetConfig) {
|
||||
|
||||
@@ -35,12 +35,10 @@ export function transpiler(input, options = {}) {
|
||||
addReturn: true,
|
||||
emitMiniLocations: true,
|
||||
emitWidgets: true,
|
||||
blockBased: false,
|
||||
range: [],
|
||||
...options,
|
||||
};
|
||||
|
||||
const { wrapAsync, addReturn, emitMiniLocations, emitWidgets, blockBased, range } = options;
|
||||
const { wrapAsync, addReturn, emitMiniLocations, emitWidgets } = options;
|
||||
|
||||
const comments = [];
|
||||
let ast = parse(input, {
|
||||
@@ -52,67 +50,21 @@ export function transpiler(input, options = {}) {
|
||||
|
||||
let miniDisableRanges = findMiniDisableRanges(comments, input.length);
|
||||
|
||||
// Position offset for block-based evaluation
|
||||
let nodeOffset = range && range.length > 0 ? range[0] : 0;
|
||||
|
||||
// Track declarations to add to strudelScope for block-based eval
|
||||
let scopeDeclarations = [];
|
||||
|
||||
let labels = [];
|
||||
|
||||
const context = { options, input, nodeOffset, miniDisableRanges, labels };
|
||||
const context = { options, input, miniDisableRanges, labels };
|
||||
const plugins = getPlugins().map((plugin) => plugin.walk?.(context));
|
||||
|
||||
walk(ast, {
|
||||
enter(node, parent, prop, index) {
|
||||
// Apply position offset for block-based evaluation
|
||||
if (blockBased && node.start !== undefined) {
|
||||
node.start = node.start + nodeOffset;
|
||||
node.end = node.end + nodeOffset;
|
||||
}
|
||||
// Collect variable and function declarations for strudelScope (block-based eval)
|
||||
if (blockBased && parent?.type === 'Program') {
|
||||
if (node.type === 'VariableDeclaration') {
|
||||
for (const declarator of node.declarations) {
|
||||
if (declarator.id?.name) {
|
||||
scopeDeclarations.push(declarator.id.name);
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'FunctionDeclaration' && node.id?.name) {
|
||||
scopeDeclarations.push(node.id.name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin?.enter?.call(this, node, parent, prop, index)) continue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLabelStatement(node)) {
|
||||
// Collect label info for block-based evaluation
|
||||
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
|
||||
if (blockBased) {
|
||||
labels.push({
|
||||
name: node.label.name,
|
||||
index: node.start - nodeOffset,
|
||||
end: node.label.end - nodeOffset,
|
||||
fullMatch: input.slice(node.start - nodeOffset, node.label.end - nodeOffset),
|
||||
activeVisualizer: findVisualizerInSubtree(node.body),
|
||||
});
|
||||
}
|
||||
return this.replace(labelToP(node));
|
||||
}
|
||||
// Detect all() calls as special labels for block management
|
||||
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
|
||||
if (blockBased && isAllCall(node)) {
|
||||
labels.push({
|
||||
name: 'all',
|
||||
index: node.start - nodeOffset,
|
||||
end: node.end - nodeOffset,
|
||||
fullMatch: input.slice(node.start - nodeOffset, node.end - nodeOffset),
|
||||
activeVisualizer: node.arguments[0] ? findVisualizerInSubtree(node.arguments[0]) : null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
leave(node, parent, prop, index) {
|
||||
@@ -141,14 +93,6 @@ export function transpiler(input, options = {}) {
|
||||
body.push(silenceExpression);
|
||||
}
|
||||
|
||||
// For block-based eval, add scope assignments before the return statement
|
||||
// This allows variables/functions defined in one block to be used in other blocks
|
||||
if (blockBased && scopeDeclarations.length > 0) {
|
||||
const scopeAssignments = scopeDeclarations.flatMap((name) => createScopeAssignment(name));
|
||||
// Insert scope assignments before the last statement (which will become the return)
|
||||
body.splice(body.length - 1, 0, ...scopeAssignments);
|
||||
}
|
||||
|
||||
// add return to last statement
|
||||
if (addReturn) {
|
||||
const { expression } = body[body.length - 1];
|
||||
@@ -166,7 +110,7 @@ export function transpiler(input, options = {}) {
|
||||
}
|
||||
|
||||
let pluginContext;
|
||||
({ options, input, miniDisableRanges, nodeOffset, ...pluginContext } = context);
|
||||
({ options, input, miniDisableRanges, ...pluginContext } = context);
|
||||
|
||||
return { output, ...pluginContext };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export * from '@strudel/mini';
|
||||
export * from '@strudel/edo';
|
||||
export * from '@strudel/tonal';
|
||||
export * from '@strudel/webaudio';
|
||||
import { Pattern, evalScope, setTime } from '@strudel/core';
|
||||
import { Pattern, evalScope, setTime, setClockTime } from '@strudel/core';
|
||||
import { initAudioOnFirstClick, registerSynthSounds, webaudioRepl } from '@strudel/webaudio';
|
||||
// import { registerSoundfonts } from '@strudel/soundfonts';
|
||||
import { evaluate as _evaluate, transpiler } from '@strudel/transpiler';
|
||||
@@ -41,6 +41,7 @@ export function initStrudel(options = {}) {
|
||||
return repl;
|
||||
})();
|
||||
setTime(() => repl.scheduler.now());
|
||||
setClockTime(() => repl.scheduler.getTime()); // TODO: refactor?
|
||||
return initDone;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ initAudioOnFirstClick();
|
||||
const ctx = getAudioContext();
|
||||
|
||||
const { scheduler } = repl({
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
getTime: () => ctx.currentTime
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const soundMap = new Map();
|
||||
const loadedSounds = new Map();
|
||||
|
||||
Pattern.prototype.supradough = function () {
|
||||
return this.onTrigger((hap, __, cps, begin) => {
|
||||
return this.onSchedule((hap, begin, cps) => {
|
||||
hap.value._begin = begin;
|
||||
hap.value._duration = hap.duration / cps;
|
||||
!doughWorklet && initDoughWorklet();
|
||||
@@ -61,7 +61,7 @@ function githubPath(base, subpath = '') {
|
||||
}
|
||||
return `https://raw.githubusercontent.com/${path}/${subpath}`;
|
||||
}
|
||||
export async function fetchSampleMap(url) {
|
||||
export async function _fetchSampleMap(url) {
|
||||
if (url.startsWith('github:')) {
|
||||
url = githubPath(url, 'strudel.json');
|
||||
}
|
||||
@@ -114,11 +114,11 @@ async function fetchSample(url) {
|
||||
return { channels, sampleRate: buffer.sampleRate };
|
||||
}
|
||||
|
||||
export async function doughsamples(sampleMap, baseUrl) {
|
||||
export async function supradoughsamples(sampleMap, baseUrl) {
|
||||
if (typeof sampleMap === 'string') {
|
||||
const [json, base] = await fetchSampleMap(sampleMap);
|
||||
const [json, base] = await _fetchSampleMap(sampleMap);
|
||||
// console.log('json', json, 'base', base);
|
||||
return doughsamples(json, base);
|
||||
return supradoughsamples(json, base);
|
||||
}
|
||||
Object.entries(sampleMap).map(async ([key, urls]) => {
|
||||
if (key !== '_base') {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
superdough,
|
||||
getAudioContext,
|
||||
setLogger,
|
||||
doughTrigger,
|
||||
rawdspOutput,
|
||||
registerWorklet,
|
||||
setAudioContext,
|
||||
initAudio,
|
||||
@@ -32,8 +32,8 @@ const hap2value = (hap) => {
|
||||
};
|
||||
|
||||
// uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004
|
||||
// TODO: refactor output callbacks to eliminate deadline
|
||||
export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
|
||||
export const webaudioOutput = (hap, t, cps) => {
|
||||
const hapDuration = hap.duration / cps;
|
||||
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
|
||||
};
|
||||
|
||||
@@ -56,32 +56,14 @@ export async function renderPatternAudio(
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
});
|
||||
logger('[webaudio] preloading');
|
||||
|
||||
// Calling superdough(...) in ascending onset time order is important
|
||||
// for controls that depend on the audio graph state like `cut`
|
||||
let haps = pattern
|
||||
.queryArc(begin, end, { _cps: cps })
|
||||
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
|
||||
for (const hap of haps) {
|
||||
if (hap.hasOnset()) {
|
||||
try {
|
||||
await superdough(
|
||||
hap2value(hap),
|
||||
(hap.whole.begin.valueOf() - begin) / cps,
|
||||
hap.duration / cps,
|
||||
cps,
|
||||
(hap.whole?.begin.valueOf() - begin) / cps,
|
||||
);
|
||||
} catch (err) {
|
||||
errorLogger(err, 'webaudio');
|
||||
}
|
||||
}
|
||||
}
|
||||
logger('[webaudio] start rendering');
|
||||
|
||||
return audioContext
|
||||
.startRendering()
|
||||
// Firefox currently doesn't support suspending an OfflineAudioContext,
|
||||
// so no chunked rendering. Bad performance, but at least it works.
|
||||
return (
|
||||
audioContext.suspend === undefined
|
||||
? renderPatternAudioWhole(audioContext, pattern, cps, begin, end)
|
||||
: renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, 1)
|
||||
)
|
||||
.then((renderedBuffer) => {
|
||||
const wavBuffer = audioBufferToWav(renderedBuffer);
|
||||
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
|
||||
@@ -102,19 +84,97 @@ export async function renderPatternAudio(
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPatternAudioWhole(audioContext, pattern, cps, begin, end) {
|
||||
logger(`[webaudio] preloading`);
|
||||
|
||||
await scheduleHapsChunk(pattern, cps, begin, begin, end);
|
||||
|
||||
logger('[webaudio] start rendering');
|
||||
|
||||
return audioContext.startRendering();
|
||||
}
|
||||
|
||||
async function renderPatternAudioInChunks(audioContext, pattern, cps, begin, end, chunkSizeInCycles) {
|
||||
let currentCycle = begin;
|
||||
let renderPromise = null;
|
||||
|
||||
logger('[webaudio] start rendering');
|
||||
|
||||
while (currentCycle <= end) {
|
||||
const chunkStart = currentCycle;
|
||||
const chunkEnd = Math.min(currentCycle + chunkSizeInCycles, end);
|
||||
|
||||
logger(`[webaudio] preloading cycles ${chunkStart} - ${chunkEnd}`);
|
||||
|
||||
await scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd);
|
||||
|
||||
logger(`[webaudio] rendering cycles ${chunkStart} - ${chunkEnd}`);
|
||||
|
||||
currentCycle += chunkSizeInCycles;
|
||||
|
||||
// According to the MDN docs, suspends should be scheduled while
|
||||
// the audioContext is not currently running for better precision.
|
||||
// So we schedule the suspend first, and await after resuming.
|
||||
var suspendPromise;
|
||||
if (currentCycle < end) {
|
||||
// Make sure to suspend one cycle before the next currentCycle
|
||||
// so the next haps can be scheduled on time.
|
||||
suspendPromise = audioContext.suspend((currentCycle - begin - 1) / cps);
|
||||
}
|
||||
|
||||
if (renderPromise === null) {
|
||||
renderPromise = audioContext.startRendering();
|
||||
} else {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
if (currentCycle < end) {
|
||||
await suspendPromise;
|
||||
}
|
||||
}
|
||||
|
||||
logger('[webaudio] finish rendering');
|
||||
|
||||
return renderPromise;
|
||||
}
|
||||
|
||||
async function scheduleHapsChunk(pattern, cps, begin, chunkStart, chunkEnd) {
|
||||
// Calling superdough(...) in ascending onset time order is important
|
||||
// for controls that depend on the audio graph state like `cut`
|
||||
let haps = pattern
|
||||
.queryArc(chunkStart, chunkEnd, { _cps: cps })
|
||||
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
|
||||
|
||||
for (const hap of haps) {
|
||||
if (hap.hasOnset()) {
|
||||
try {
|
||||
await superdough(
|
||||
hap2value(hap),
|
||||
(hap.whole.begin.valueOf() - begin) / cps,
|
||||
hap.duration / cps,
|
||||
cps,
|
||||
(hap.whole?.begin.valueOf() - begin) / cps,
|
||||
);
|
||||
} catch (err) {
|
||||
errorLogger(err, 'webaudio');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function webaudioRepl(options = {}) {
|
||||
const audioContext = options.audioContext ?? getAudioContext();
|
||||
setAudioContext(audioContext);
|
||||
options = {
|
||||
getTime: () => audioContext.currentTime,
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
...options,
|
||||
};
|
||||
return repl(options);
|
||||
}
|
||||
|
||||
Pattern.prototype.dough = function () {
|
||||
return this.onTrigger(doughTrigger, 1);
|
||||
Pattern.prototype.rawdsp = function () {
|
||||
return this.onSchedule(rawdspOutput, 1);
|
||||
};
|
||||
|
||||
function audioBufferToWav(buffer, opt) {
|
||||
|
||||
@@ -8,11 +8,14 @@ import Tune from './tunejs.js';
|
||||
import { register } from '@strudel/core';
|
||||
|
||||
/**
|
||||
* Deprecated.
|
||||
*
|
||||
* Assumes pattern contains numerical scale degrees on the `i` control (see examples below). Accepts a scale name or list of frequencies (see all available names at the link on the reference). Returns a new pattern with all values mapped to a frequency ratio. Similar to `xen`.
|
||||
* @name tune
|
||||
* @returns Pattern
|
||||
* @memberof Pattern
|
||||
* @param {(string | number[] )} scale
|
||||
* @deprecated
|
||||
* @example
|
||||
* i("0 1 2 3 4 5").tune("hexany15").mul("220").freq()
|
||||
* @example
|
||||
|
||||
+105
-29
@@ -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/>.
|
||||
*/
|
||||
|
||||
import { register, _mod, parseNumeral, removeUndefineds } from '@strudel/core';
|
||||
import { register, _mod, parseNumeral, removeUndefineds, Pattern, parray, State } from '@strudel/core';
|
||||
import Tune from './tunejs.js';
|
||||
|
||||
// returns a list of frequency ratios for given edo scale
|
||||
@@ -61,14 +61,8 @@ function xenOffset(xenScale, offset, index = 0) {
|
||||
|
||||
const trimFreq = (freq) => parseFloat(freq.toPrecision(10));
|
||||
|
||||
// accepts a scale name such as 31edo, and a pattern
|
||||
// pattern expected to follow format such that a value can be mapped
|
||||
// to an edostep within the scale. Returns the pattern with
|
||||
// values mapped to the frequencies associated with the given edosteps
|
||||
// scaleNameOrRatios: string || number[], steps?: number
|
||||
|
||||
/**
|
||||
* Assumes a numerical pattern of scale steps, and a scale. Scales accepted are all preset scale names of `tune`, arbitrary edos such as 31edo, or an array of frequency ratios. Assumes scales repeat at octave (2/1). Returns a new pattern with all values mapped to their associated frequency, assuming a base frequency of 220hz.
|
||||
* Assumes a numerical pattern of scale steps, and a scale. Scales accepted are all preset scale names of `tune`, arbitrary edos such as 31edo, or an array of frequency ratios. Assumes scales repeat at octave (2/1). Returns a new pattern with all values mapped to their associated frequency, assuming a base frequency of 220hz. See first 4 examples for the different ways you can input a scale into `xen`
|
||||
*
|
||||
* @name xen
|
||||
* @returns Pattern
|
||||
@@ -87,6 +81,15 @@ const trimFreq = (freq) => parseFloat(freq.toPrecision(10));
|
||||
* Math.pow(2, 18/31),
|
||||
* ]).piano()
|
||||
* @example
|
||||
* // However, with the above you won't get highlighting of the scale indices,
|
||||
* // instead you could do something like:
|
||||
* i("[<2 ->, 0 1 <- 2> 3]*2").xen([
|
||||
* "1:1", // tonic
|
||||
* "5:4", // Major 3rd
|
||||
* "[3:2 4:3]", // Perfect 5th, 4th
|
||||
* "5:3" // Major 6th
|
||||
* ]).s("tri")
|
||||
* @example
|
||||
* // xen also supports all scale names that
|
||||
* // tune does:
|
||||
* i("0 1 2 3 4 5").xen("hexany15")
|
||||
@@ -96,27 +99,100 @@ const trimFreq = (freq) => parseFloat(freq.toPrecision(10));
|
||||
* i("0 1 2 3 4 5 6 7").xen("<5edo 10edo 15edo hexany15>")
|
||||
*/
|
||||
|
||||
export const xen = register('xen', function (scaleNameOrRatios, pat) {
|
||||
return pat.withHaps((haps) => {
|
||||
haps = haps.map((hap) => {
|
||||
let hVal = hap.value;
|
||||
const isObject = typeof hVal === 'object';
|
||||
if (!isObject) {
|
||||
throw new Error(`Expected hap to have control 'i' set, but received ${hap.value.i}, try wrapping input in i()`);
|
||||
}
|
||||
const { i, ...otherValues } = hVal;
|
||||
const scale = getXenScale(scaleNameOrRatios);
|
||||
let freq = xenOffset(scale, parseNumeral(hVal.i));
|
||||
// 10 is somewhat arbitrary
|
||||
freq = trimFreq(freq);
|
||||
hap.value = { ...otherValues, freq };
|
||||
return isEdo(scaleNameOrRatios)
|
||||
? hap.setContext({ ...hap.context, edoSize: scaleNameOrRatios.match(/^([1-9]+[0-9]*)edo$/)[1] })
|
||||
: hap;
|
||||
});
|
||||
return removeUndefineds(haps);
|
||||
});
|
||||
});
|
||||
export const xen = register(
|
||||
'xen',
|
||||
function (scaleNameOrRatios, pat) {
|
||||
const JIsc2 = (input) => {
|
||||
let k = parray(input);
|
||||
|
||||
return new Pattern((state) => {
|
||||
let res = k.withValue((x) => x.map((y) => y.reduce((acc, v) => acc / v))).query(state);
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
const query = (state) => {
|
||||
let haps = pat.query(state);
|
||||
let resHaps = [];
|
||||
|
||||
haps.forEach((hap) => {
|
||||
let hVal = hap.value;
|
||||
const isObject = typeof hVal === 'object';
|
||||
if (!isObject) {
|
||||
throw new Error(
|
||||
`Expected hap to have control 'i' set, but received ${hap.value.i}, try wrapping input in i()`,
|
||||
);
|
||||
}
|
||||
const { i, ...otherValues } = hVal;
|
||||
let edo = false;
|
||||
let st = new State(hap.wholeOrPart().intersection(state.span), state.controls);
|
||||
|
||||
// Format: An array of values
|
||||
// Each value is a pattern, representing an index on the scale
|
||||
let sc = scaleNameOrRatios.query(st);
|
||||
// Check to see if input is attempting to be a named scale or a raw scale
|
||||
let isStr = typeof sc[0].value == 'string';
|
||||
let isRawScale = Array.isArray(sc[0].value) && sc[0].value.every((x) => typeof x == 'number');
|
||||
if (!isStr && !isRawScale) {
|
||||
sc = sc[0].value;
|
||||
} else if (isRawScale) {
|
||||
sc = getXenScale(sc[0].value);
|
||||
}
|
||||
|
||||
if (!isStr && !isRawScale) {
|
||||
let scaleByIndices = [];
|
||||
for (let j = 0; j < sc.length; j++) {
|
||||
scaleByIndices[j] = sc[j].query(st);
|
||||
}
|
||||
/*
|
||||
Format: An array of 1 or more Haps, each Hap has an array of values of
|
||||
one or more scale index definitions (contains whole scale)
|
||||
Assumption: inner value array should be same length between each
|
||||
*/
|
||||
let sc2 = JIsc2(sc).query(st);
|
||||
|
||||
let index = _mod(i, sc.length);
|
||||
for (let j = 0; j < scaleByIndices[index].length; j++) {
|
||||
let scale = getXenScale(sc2[j].value);
|
||||
let freq = xenOffset(scale, parseNumeral(i));
|
||||
let newHap = hap;
|
||||
freq = trimFreq(freq);
|
||||
newHap.value = { ...otherValues, freq };
|
||||
newHap = newHap.setContext(newHap.combineContext(scaleByIndices[index][j]));
|
||||
resHaps.push(newHap);
|
||||
}
|
||||
} else if (isRawScale) {
|
||||
let freq = xenOffset(sc, parseNumeral(i));
|
||||
let newHap = hap;
|
||||
freq = trimFreq(freq);
|
||||
newHap.value = { ...otherValues, freq };
|
||||
resHaps.push(newHap);
|
||||
} else if (isStr) {
|
||||
// scale is named scale(s)
|
||||
for (let j = 0; j < sc.length; j++) {
|
||||
let scaleName = sc[j].value;
|
||||
let scale = getXenScale(scaleName);
|
||||
let freq = xenOffset(scale, parseNumeral(i));
|
||||
let newHap = hap;
|
||||
freq = trimFreq(freq);
|
||||
newHap.value = { ...otherValues, freq };
|
||||
newHap = newHap.setContext(newHap.combineContext(sc[j]));
|
||||
if (isEdo(scaleName)) {
|
||||
edo = scaleName.match(/^([1-9]+[0-9]*)edo$/)[1];
|
||||
newHap = newHap.setContext({ ...newHap.context, edoSize: edo });
|
||||
}
|
||||
resHaps.push(newHap);
|
||||
}
|
||||
} else {
|
||||
throw new Error('failed to parse scale');
|
||||
}
|
||||
});
|
||||
return resHaps;
|
||||
};
|
||||
return new Pattern(query);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
/**
|
||||
* Assumes pattern of frequencies tuned to some `base` frequency, such as the output of `xen`
|
||||
|
||||
Generated
+65
-30
@@ -242,24 +242,12 @@ importers:
|
||||
'@strudel/draw':
|
||||
specifier: workspace:*
|
||||
version: link:../draw
|
||||
'@strudel/tonal':
|
||||
specifier: workspace:*
|
||||
version: link:../tonal
|
||||
'@strudel/transpiler':
|
||||
specifier: workspace:*
|
||||
version: link:../transpiler
|
||||
'@tonaljs/tonal':
|
||||
specifier: ^4.10.0
|
||||
version: 4.10.0
|
||||
codemirror-helix:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
|
||||
nanostores:
|
||||
specifier: ^0.11.3
|
||||
version: 0.11.3
|
||||
superdough:
|
||||
specifier: workspace:*
|
||||
version: link:../superdough
|
||||
devDependencies:
|
||||
vite:
|
||||
specifier: ^6.0.11
|
||||
@@ -299,10 +287,32 @@ importers:
|
||||
'@strudel/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@strudel/osc':
|
||||
specifier: workspace:*
|
||||
version: link:../osc
|
||||
'@strudel/webaudio':
|
||||
specifier: workspace:*
|
||||
version: link:../webaudio
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
|
||||
packages/dough:
|
||||
dependencies:
|
||||
'@strudel/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@strudel/webaudio':
|
||||
specifier: workspace:*
|
||||
version: link:../webaudio
|
||||
dough-synth:
|
||||
specifier: 0.4.1
|
||||
version: 0.4.1
|
||||
devDependencies:
|
||||
vite:
|
||||
specifier: ^6.0.11
|
||||
version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)
|
||||
|
||||
packages/draw:
|
||||
dependencies:
|
||||
'@strudel/core':
|
||||
@@ -470,6 +480,9 @@ importers:
|
||||
'@strudel/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@strudel/webaudio':
|
||||
specifier: workspace:*
|
||||
version: link:../webaudio
|
||||
osc:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
@@ -808,6 +821,9 @@ importers:
|
||||
'@strudel/desktopbridge':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/desktopbridge
|
||||
'@strudel/dough':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/dough
|
||||
'@strudel/draw':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/draw
|
||||
@@ -2066,6 +2082,7 @@ packages:
|
||||
'@lerna/create@8.1.9':
|
||||
resolution: {integrity: sha512-DPnl5lPX4v49eVxEbJnAizrpMdMTBz1qykZrAbBul9rfgk531v8oAt+Pm6O/rpAleRombNM7FJb5rYGzBJatOQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
deprecated: This package is an implementation detail of Lerna and is no longer published separately.
|
||||
|
||||
'@lezer/common@1.2.3':
|
||||
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
|
||||
@@ -3065,6 +3082,7 @@ packages:
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
||||
'@vite-pwa/astro@0.5.0':
|
||||
resolution: {integrity: sha512-Yd3Pug/c1EUQJXWvzYh6eTtoqzmSKcdCqWCcNquZeaD13tLWpBb2FIPJ4HMULVY6+GfxMvrT+OBuMrbHQCvftw==}
|
||||
@@ -3630,15 +3648,6 @@ packages:
|
||||
resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
codemirror-helix@0.5.0:
|
||||
resolution: {integrity: sha512-hI56hf9VGz53H1YvL6H1GC7HtP6te8vX+MsIHaE9J7Q3PQ6KFapKtIRg6lqSH898ikHWpMCPu42r6HJN0IfVLA==}
|
||||
peerDependencies:
|
||||
'@codemirror/commands': ^6.0.0
|
||||
'@codemirror/language': ^6.0.0
|
||||
'@codemirror/search': ^6.0.0
|
||||
'@codemirror/state': ^6.0.0
|
||||
'@codemirror/view': ^6.0.0
|
||||
|
||||
collapse-white-space@2.1.0:
|
||||
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
|
||||
|
||||
@@ -3716,6 +3725,7 @@ packages:
|
||||
conventional-changelog-core@5.0.1:
|
||||
resolution: {integrity: sha512-Rvi5pH+LvgsqGwZPZ3Cq/tz4ty7mjijhr3qR4m9IBXNbxGGYgTVVO+duXzz9aArmHxFtwZ+LRkrNIMDQzgoY4A==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead.
|
||||
|
||||
conventional-changelog-preset-loader@3.0.0:
|
||||
resolution: {integrity: sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA==}
|
||||
@@ -4047,6 +4057,10 @@ packages:
|
||||
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dough-synth@0.4.1:
|
||||
resolution: {integrity: sha512-hxBsfOBwTIlJMhSB/Pk2xNNc7YTAVTgexn5Y7GRv/Kn2qBkMxwLxHJNvhsUhklQhVvzny/aCihxb0EOq4tv5Bw==}
|
||||
hasBin: true
|
||||
|
||||
dset@3.1.4:
|
||||
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4579,6 +4593,7 @@ packages:
|
||||
git-raw-commits@3.0.0:
|
||||
resolution: {integrity: sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
|
||||
hasBin: true
|
||||
|
||||
git-remote-origin-url@2.0.0:
|
||||
@@ -4588,6 +4603,7 @@ packages:
|
||||
git-semver-tags@5.0.1:
|
||||
resolution: {integrity: sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
|
||||
hasBin: true
|
||||
|
||||
git-up@7.0.0:
|
||||
@@ -4615,15 +4631,17 @@ packages:
|
||||
|
||||
glob@10.4.5:
|
||||
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
glob@9.3.5:
|
||||
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
globalize@0.1.1:
|
||||
resolution: {integrity: sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA==}
|
||||
@@ -6406,6 +6424,7 @@ packages:
|
||||
prebuild-install@7.1.1:
|
||||
resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
|
||||
hasBin: true
|
||||
|
||||
precinct@12.1.2:
|
||||
@@ -7230,6 +7249,7 @@ packages:
|
||||
tar@6.2.1:
|
||||
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
temp-dir@1.0.0:
|
||||
resolution: {integrity: sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==}
|
||||
@@ -7357,6 +7377,7 @@ packages:
|
||||
tsconfck@3.1.4:
|
||||
resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==}
|
||||
engines: {node: ^18 || >=20}
|
||||
deprecated: unmaintained
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^5.0.0
|
||||
@@ -7636,6 +7657,7 @@ packages:
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
@@ -7987,6 +8009,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.22.0:
|
||||
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xmlcreate@2.0.4:
|
||||
resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==}
|
||||
|
||||
@@ -11368,14 +11402,6 @@ snapshots:
|
||||
|
||||
cmd-shim@6.0.3: {}
|
||||
|
||||
codemirror-helix@0.5.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2):
|
||||
dependencies:
|
||||
'@codemirror/commands': 6.8.0
|
||||
'@codemirror/language': 6.10.8
|
||||
'@codemirror/search': 6.5.8
|
||||
'@codemirror/state': 6.5.1
|
||||
'@codemirror/view': 6.36.2
|
||||
|
||||
collapse-white-space@2.1.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
@@ -11757,6 +11783,13 @@ snapshots:
|
||||
|
||||
dotenv@16.4.7: {}
|
||||
|
||||
dough-synth@0.4.1:
|
||||
dependencies:
|
||||
ws: 8.22.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
dset@3.1.4: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -16653,6 +16686,8 @@ snapshots:
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
ws@8.22.0: {}
|
||||
|
||||
xmlcreate@2.0.4: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
+13
-12
@@ -4,18 +4,19 @@ packages:
|
||||
- tools/dbpatch
|
||||
- website/
|
||||
|
||||
onlyBuiltDependencies:
|
||||
catalog:
|
||||
'@codemirror/autocomplete': ^6.18.4
|
||||
'@codemirror/commands': ^6.8.0
|
||||
'@codemirror/lang-javascript': ^6.2.2
|
||||
'@codemirror/language': ^6.10.8
|
||||
'@codemirror/search': ^6.5.8
|
||||
'@codemirror/state': ^6.5.1
|
||||
'@codemirror/view': ^6.36.2
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- '@serialport/bindings-cpp'
|
||||
- tree-sitter-haskell
|
||||
- tree-sitter
|
||||
- esbuild
|
||||
- nx
|
||||
- sharp
|
||||
- tree-sitter
|
||||
- tree-sitter-haskell
|
||||
|
||||
catalog:
|
||||
"@codemirror/commands": "^6.8.0"
|
||||
"@codemirror/language": "^6.10.8"
|
||||
"@codemirror/lang-javascript": "^6.2.2"
|
||||
"@codemirror/state": "^6.5.1"
|
||||
"@codemirror/view": "^6.36.2"
|
||||
"@codemirror/autocomplete": "^6.18.4"
|
||||
"@codemirror/search": "^6.5.8"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7203,8 +7203,12 @@ exports[`renders tunes > tune: holyflute 1`] = `
|
||||
|
||||
exports[`renders tunes > tune: juxUndTollerei 1`] = `
|
||||
[
|
||||
"[ -99/200 ⇜ (0/1 → 1/200) | note:63 s:triangle pan:0 cutoff:758.852817928549 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
|
||||
"[ -99/200 ⇜ (0/1 → 1/200) | note:67 s:triangle pan:1 color:green cutoff:758.852817928549 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
|
||||
"[ 0/1 → 1/4 | note:c3 s:sawtooth pan:0 cutoff:1100 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
|
||||
"[ 0/1 → 1/4 | note:bb3 s:sawtooth pan:1 color:green cutoff:1100 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
|
||||
"[ 1/200 → 101/200 | note:55 s:triangle pan:0 cutoff:1103.534282651425 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
|
||||
"[ 1/200 → 101/200 | note:65 s:triangle pan:1 color:green cutoff:1103.534282651425 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
|
||||
"[ 1/4 → 1/2 | note:eb3 s:sawtooth pan:0 cutoff:1275.581289814515 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
|
||||
"[ 1/4 → 1/2 | note:g3 s:sawtooth pan:1 color:green cutoff:1275.581289814515 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
|
||||
"[ 1/2 → 3/4 | note:g3 s:sawtooth pan:0 cutoff:1444.415089128581 lpattack:0.2 lpenv:-2 decay:0.05 sustain:0 room:0.6 delay:0.5 delaytime:0.1 delayfeedback:0.4 ]",
|
||||
@@ -7318,12 +7322,12 @@ exports[`renders tunes > tune: randomBells 1`] = `
|
||||
[
|
||||
"[ -9/8 ⇜ (0/1 → 3/8) | gain:0.6 note:A3 velocity:0.598990320228 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ -3/4 ⇜ (0/1 → 3/4) | gain:0.6 note:C5 velocity:0.836992966942 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 0/1 → 3/2 | note:F2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 0/1 → 3/2 | note:D2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 0/1 → 9/4 | gain:0.6 note:D3 velocity:0.5 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 3/8 → 21/8 | gain:0.6 note:F5 velocity:0.921303892508 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 3/4 → 3/1 | gain:0.6 note:C5 velocity:0.842607785016 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 3/2 → 9/4 | note:F2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 9/4 → 3/1 | note:F2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 3/2 → 9/4 | note:D2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 9/4 → 3/1 | note:D2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 9/4 → 9/2 | gain:0.6 note:D4 velocity:0.700696257874 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 21/8 → 39/8 | gain:0.6 note:C4 velocity:0.650794394314 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 3/1 → 9/2 | note:D2 s:bass clip:1 gain:0.8 ]",
|
||||
@@ -7335,12 +7339,12 @@ exports[`renders tunes > tune: randomBells 1`] = `
|
||||
"[ (21/4 → 6/1) ⇝ 27/4 | gain:0.6 note:D4 velocity:0.698815540411 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 39/8 ⇜ (6/1 → 51/8) | gain:0.6 note:D5 velocity:0.875811336562 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 21/4 ⇜ (6/1 → 27/4) | gain:0.6 note:D4 velocity:0.698815540411 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 6/1 → 15/2 | note:D2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 6/1 → 15/2 | note:A2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 6/1 → 33/4 | gain:0.6 note:G4 velocity:0.75977108255 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 51/8 → 69/8 | gain:0.6 note:G4 velocity:0.774316444062 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 27/4 → 9/1 | gain:0.6 note:C5 velocity:0.836244757287 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 15/2 → 33/4 | note:D2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 33/4 → 9/1 | note:D2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 15/2 → 33/4 | note:A2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 33/4 → 9/1 | note:A2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 33/4 → 21/2 | gain:0.6 note:A3 velocity:0.59140187595 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 69/8 → 87/8 | gain:0.6 note:G4 velocity:0.754063542932 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 9/1 → 21/2 | note:A2 s:bass clip:1 gain:0.8 ]",
|
||||
@@ -7352,12 +7356,12 @@ exports[`renders tunes > tune: randomBells 1`] = `
|
||||
"[ (45/4 → 12/1) ⇝ 51/4 | gain:0.6 note:A4 velocity:0.797278589569 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 87/8 ⇜ (12/1 → 99/8) | gain:0.6 note:F4 velocity:0.734787144698 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 45/4 ⇜ (12/1 → 51/4) | gain:0.6 note:A4 velocity:0.797278589569 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 12/1 → 27/2 | note:A2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 12/1 → 27/2 | note:G2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 12/1 → 57/4 | gain:0.6 note:G5 velocity:0.979763559997 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 99/8 → 117/8 | gain:0.6 note:C4 velocity:0.666239210404 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 51/4 → 15/1 | gain:0.6 note:F5 velocity:0.95169514697 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 27/2 → 57/4 | note:A2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 57/4 → 15/1 | note:A2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 27/2 → 57/4 | note:G2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 57/4 → 15/1 | note:G2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 57/4 → 33/2 | gain:0.6 note:F5 velocity:0.918253320269 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 117/8 → 135/8 | gain:0.6 note:G3 velocity:0.571157105267 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 15/1 → 33/2 | note:G2 s:bass clip:1 gain:0.8 ]",
|
||||
@@ -7369,12 +7373,12 @@ exports[`renders tunes > tune: randomBells 1`] = `
|
||||
"[ (69/4 → 18/1) ⇝ 75/4 | gain:0.6 note:F3 velocity:0.508127085865 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 135/8 ⇜ (18/1 → 147/8) | gain:0.6 note:F5 velocity:0.945647025481 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 69/4 ⇜ (18/1 → 75/4) | gain:0.6 note:F3 velocity:0.508127085865 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 18/1 → 39/2 | note:G2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 18/1 → 39/2 | note:F2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 18/1 → 81/4 | gain:0.6 note:A3 velocity:0.608644555323 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 147/8 → 165/8 | gain:0.6 note:F3 velocity:0.506259439513 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 75/4 → 21/1 | gain:0.6 note:D4 velocity:0.671621939167 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 39/2 → 81/4 | note:G2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 81/4 → 21/1 | note:G2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 39/2 → 81/4 | note:F2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 81/4 → 21/1 | note:F2 s:bass clip:1 gain:0.8 ]",
|
||||
"[ 81/4 → 45/2 | gain:0.6 note:D4 velocity:0.704345900565 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 165/8 → 183/8 | gain:0.6 note:D5 velocity:0.887838857248 s:bell delay:0.2 delaytime:0.333333333333 delayfeedback:0.8 ]",
|
||||
"[ 21/1 → 45/2 | note:F2 s:bass clip:1 gain:0.8 ]",
|
||||
|
||||
@@ -20,7 +20,6 @@ const skippedExamples = [
|
||||
'accelerationX',
|
||||
'defaultmidimap',
|
||||
'midimaps',
|
||||
'clearScope',
|
||||
'bmod',
|
||||
];
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ const toneHelpersMocked = {
|
||||
'_spectrum',
|
||||
'markcss',
|
||||
'p',
|
||||
'dough',
|
||||
].forEach((mock) => {
|
||||
strudel.Pattern.prototype[mock] = function () {
|
||||
return this;
|
||||
@@ -174,6 +175,7 @@ evalScope(
|
||||
getDuration,
|
||||
setcps: id,
|
||||
setcpm: id,
|
||||
initDough: id,
|
||||
Clock: {}, // whatever
|
||||
},
|
||||
);
|
||||
|
||||
+2
-2
@@ -597,8 +597,8 @@
|
||||
],
|
||||
"/packages/superdough/dspworklet.mjs": [
|
||||
"dspWorklet",
|
||||
"dough",
|
||||
"doughTrigger"
|
||||
"rawdsp",
|
||||
"rawdspTrigger"
|
||||
],
|
||||
"/packages/superdough/index.mjs": [],
|
||||
"/packages/tonal/tonleiter.mjs": [
|
||||
|
||||
@@ -10,11 +10,8 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import AstroPWA from '@vite-pwa/astro';
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
const site = process.env.SITE_URL || `https://strudel.cc/`; // root url without a path
|
||||
const base = process.env.BASE_PATH || ''; // base path of the strudel site
|
||||
|
||||
const site = process.env.STRUDEL_SITE || `https://strudel.cc/`; // root url without a path
|
||||
const base = process.env.STRUDEL_BASE || '/'; // base path of the strudel site
|
||||
const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||
|
||||
// this rehype plugin fixes relative links
|
||||
@@ -73,11 +70,13 @@ export default defineConfig({
|
||||
workbox: {
|
||||
maximumFileSizeToCacheInBytes: 4194304, // 4MB
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,wav,mp3,ogg,ttf,woff2,TTF,otf}'],
|
||||
globIgnores: ['img/**', '_astro/csound*.js'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }) =>
|
||||
[
|
||||
/^https:\/\/raw\.githubusercontent\.com\/.*/i,
|
||||
/^https:\/\/strudel\.b-cdn\.net\/.*/i,
|
||||
/^https:\/\/freesound\.org\/.*/i,
|
||||
/^https:\/\/cdn\.freesound\.org\/.*/i,
|
||||
/^https:\/\/shabda\.ndre\.gr\/.*/i,
|
||||
@@ -87,7 +86,7 @@ export default defineConfig({
|
||||
cacheName: 'external-samples',
|
||||
expiration: {
|
||||
maxEntries: 5000,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 14 days
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200],
|
||||
@@ -144,4 +143,13 @@ export default defineConfig({
|
||||
// external: ['fraction.js'], // https://github.com/infusion/Fraction.js/issues/51
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// these are needed for dough, which uses shared memory
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements
|
||||
// this is only for the dev server, so the server strudel runs on, also needs those
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'credentialless',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@strudel/motion": "workspace:*",
|
||||
"@strudel/mqtt": "workspace:*",
|
||||
"@strudel/osc": "workspace:*",
|
||||
"@strudel/dough": "workspace:*",
|
||||
"@strudel/serial": "workspace:*",
|
||||
"@strudel/soundfonts": "workspace:*",
|
||||
"@strudel/tidal": "workspace:*",
|
||||
|
||||
@@ -24,7 +24,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
|
||||
|
||||
<base href={BASE_URL} />
|
||||
|
||||
<!-- Scrollable a11y code helper -->
|
||||
<!-- Scrollable accessibility code helper -->
|
||||
<script src={`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
|
||||
|
||||
<script src="/src/pwa.ts"></script>
|
||||
|
||||
@@ -23,7 +23,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
|
||||
|
||||
<base href={BASE_URL} />
|
||||
|
||||
<!-- Scrollable a11y code helper -->
|
||||
<!-- Scrollable accessibility code helper -->
|
||||
<script src={`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
|
||||
|
||||
<script src="/src/pwa.ts"></script>
|
||||
|
||||
@@ -16,7 +16,7 @@ const imageAlt = frontmatter.image?.alt ?? OPEN_GRAPH.image.alt;
|
||||
<!-- Page Metadata -->
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
<!-- OpenGraph Tags -->
|
||||
<!-- og Tags -->
|
||||
<meta property="og:title" content={formattedContentTitle} />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
|
||||
@@ -22,7 +22,7 @@ export function MiniRepl({
|
||||
tunes,
|
||||
hideHeader = false,
|
||||
canvasHeight = 100,
|
||||
onTrigger,
|
||||
onSchedule,
|
||||
punchcard,
|
||||
punchcardLabels = true,
|
||||
claviature,
|
||||
@@ -48,7 +48,7 @@ export function MiniRepl({
|
||||
|
||||
const editor = new StrudelMirror({
|
||||
id,
|
||||
defaultOutput: webaudioOutput,
|
||||
onSchedule: webaudioOutput,
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
transpiler,
|
||||
autodraw,
|
||||
@@ -58,8 +58,8 @@ export function MiniRepl({
|
||||
drawTime,
|
||||
drawContext,
|
||||
editPattern: (pat, id) => {
|
||||
if (onTrigger) {
|
||||
pat = pat.onTrigger(onTrigger, false);
|
||||
if (onSchedule) {
|
||||
pat = pat.onSchedule(onSchedule, false);
|
||||
}
|
||||
if (claviature) {
|
||||
pat = pat.onPaint((ctx, time, haps, drawTime) => {
|
||||
|
||||
Vendored
+3
@@ -2,3 +2,6 @@
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
declare module 'date-fns';
|
||||
declare module '@strudel/*';
|
||||
declare module 'dough-synth';
|
||||
declare module 'paho-mqtt';
|
||||
|
||||
@@ -36,7 +36,7 @@ const githubEditUrl = `${CONFIG.GITHUB_EDIT_URL}/${currentFile}`;
|
||||
<Header currentPage={currentPage} />
|
||||
</header>
|
||||
<main class="relative pt-16">
|
||||
<div class="h-full top-0 overflow-auto min-w-[300px] flex xl:justify-center pr-4 pl-4 md:pl-[300px] xl:pl-0">
|
||||
<div class="h-full top-0 overflow-auto min-w-[300px] flex xl:justify-center pr-4 pl-4 md:pl-[300px] xl:pr-[300px]">
|
||||
<aside title="Site Navigation" class="w-[300px] px-6 left-0 hidden md:block fixed h-full">
|
||||
<LeftSidebar currentPage={currentPage} />
|
||||
</aside>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getMetadata } from './metadata_parser';
|
||||
|
||||
export function getMyPatterns() {
|
||||
const my = import.meta.glob('../../my-patterns/**', { as: 'raw', eager: true });
|
||||
const my = import.meta.glob('../../my-patterns/**', { query: '?raw', eager: true });
|
||||
return Object.fromEntries(
|
||||
Object.entries(my)
|
||||
.filter(([name]) => name.endsWith('.txt'))
|
||||
|
||||
@@ -150,5 +150,3 @@ In the future, the integration could be improved by passing all patterned contro
|
||||
This could work by a unique [channel](https://kunstmusik.github.io/icsc2022-csound-web/tutorial2-interacting-with-csound/#step-4---writing-continuous-data-channels)
|
||||
for each value. Channels could be read [like this](https://github.com/csound/csound/blob/master/Android/CsoundForAndroid/CsoundAndroidExamples/src/main/res/raw/multitouch_xy.csd).
|
||||
Also, it might make sense to have a standard library of csound instruments for strudel's effects.
|
||||
|
||||
Now, let's dive into the [Functional JavaScript API](/functions/intro)
|
||||
|
||||
@@ -33,7 +33,7 @@ The signal chain in Strudel is as follows:
|
||||
- This is where the main (volume) ADSR happens
|
||||
- A lowpass filter (`lpf`)
|
||||
- A highpass filter (`hpf`)
|
||||
- A bandpass filter (`bandpass`)
|
||||
- A bandpass filter (`bpf`)
|
||||
- A vowel filter (`vowel`)
|
||||
- Sample rate reduction (`coarse`)
|
||||
- Bit crushing (`crush`)
|
||||
@@ -184,9 +184,9 @@ Each filter has 2 parameters:
|
||||
|
||||
Amplitude modulation changes the amplitude (gain) periodically over time.
|
||||
|
||||
## am
|
||||
## tremolo
|
||||
|
||||
<JsDoc client:idle name="am" h={0} />
|
||||
<JsDoc client:idle name="tremolo" h={0} />
|
||||
|
||||
## tremolosync
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ These are the equivalents used by the Mini Notation:
|
||||
| `stack(x, y)` | `"x,y"` |
|
||||
| `stepcat([3,x],[2,y])` | `"x@3 y@2"` |
|
||||
| `polymeter([a, b, c], [x, y])` | `"{a b c, x y}"` |
|
||||
| `polymeterSteps(2, x, y, z)` | `"{x y z}%2"` |
|
||||
| `silence` | `"~"` |
|
||||
|
||||
## cat
|
||||
@@ -45,10 +44,6 @@ These are the equivalents used by the Mini Notation:
|
||||
|
||||
<JsDoc client:idle name="polymeter" h={0} />
|
||||
|
||||
## polymeterSteps
|
||||
|
||||
<JsDoc client:idle name="polymeterSteps" h={0} />
|
||||
|
||||
## silence
|
||||
|
||||
<JsDoc client:idle name="silence" h={0} />
|
||||
|
||||
@@ -52,6 +52,15 @@ There are multiple ways to load your sample collection. Some methods are good fo
|
||||
- Serve a folder of samples locally using the [strudel 'sampler' commandline tool](https://strudel.cc/learn/samples/#from-disk-via-strudelsampler). This can be most reliable method, but requires [nodejs](https://nodejs.org) to be installed.
|
||||
- Host your sound library online on the web and [load them from an URL](/learn/samples/#loading-custom-samples)
|
||||
|
||||
## Can I create a new project based on Strudel?
|
||||
|
||||
Strudel is free/open source software, and we are always happy to see people making use of it within the following terms:
|
||||
|
||||
- Please don't use 'strudel' in the name of your project (e.g. strudel2000, foo-strudel), 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 chat](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](https://discord.com/invite/HGEdXmRkzT) to understand our aims and values.
|
||||
|
||||
## Can I use Strudel with AI/LLM tools?
|
||||
|
||||
You are free to do what you like with Strudel, within the terms of the free/open source AGPLv3 license.
|
||||
@@ -337,3 +346,11 @@ s increase by one semitone, i.e. sharp, works for steps of scales, note names
|
||||
$: at the start of a line, defines a member of the stack. is the only stack name that should occur multiple names
|
||||
_ before a stack name: mutes the stack, i.e. hush(), for example _$: s("bd"), see above for a different usage.
|
||||
```
|
||||
|
||||
## What is a 'hap'?
|
||||
|
||||
A 'hap' is a name for a thing that happens, e.g. `note("c3 -")` playing a note and then stopping after a while. Haps have a start and an end. Every separate sound that is produced by strudel is a hap on its own.
|
||||
|
||||
We would have called it 'event' if this particular name wasn't used by JavaScript to describe things that happen on html pages, like clicking on a button or hovering over a line of text.
|
||||
|
||||
Since strudel is written in JavaScript, this confusion of having the same name for different things is avoided by calling events 'haps'.
|
||||
|
||||
@@ -45,7 +45,7 @@ Here, the LFO will modulate the low pass filter `.lpf`.
|
||||
|
||||
## Moving away from the default
|
||||
|
||||
The following sections explain how pass parameters to `.lfo`. Similar to `._spectrum` above, almost all the configuration of `lfo` lives inside a json object, starting with `{` and ending with `}`.
|
||||
The following sections explain how to pass parameters to `.lfo`. Similar to `._spectrum` above, almost all the configuration of `lfo` lives inside a json object, starting with `{` and ending with `}`.
|
||||
All the parameters (except `id`) are written as `key: value` inside and separated by `,`.
|
||||
|
||||
The reference refers to them as `config.key`, i.e. for the following one as `config.control` but you use them like below.
|
||||
|
||||
@@ -329,7 +329,7 @@ Sampler effects are functions that can be used to change the behaviour of sample
|
||||
|
||||
### begin
|
||||
|
||||
<JsDoc client:idle name="Pattern.begin" h={0} />
|
||||
<JsDoc client:idle name="begin" h={0} />
|
||||
|
||||
### end
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ There is also `saw2`, `sine2`, `cosine2`, `tri2`, `square2` and `rand2` which ha
|
||||
|
||||
<JsDoc client:idle name="brandBy" h={0} />
|
||||
|
||||
## time
|
||||
|
||||
<JsDoc client:idle name="time" h={0} />
|
||||
|
||||
## creating your own signal
|
||||
|
||||
<JsDoc client:idle name="signal" h={0} />
|
||||
|
||||
## mouseX
|
||||
|
||||
<JsDoc client:idle name="mousex" h={0} />
|
||||
|
||||
@@ -12,11 +12,17 @@ import { JsDoc } from '../../docs/JsDoc';
|
||||
|
||||
These functions allow the use of scales other than your typical chromatic 12 based ones.
|
||||
|
||||
### xen(scaleOrRatios)
|
||||
|
||||
{/* TODO add explanation of EDO to documentation */}
|
||||
|
||||
<JsDoc client:idle name="Pattern.xen" h={0} />
|
||||
|
||||
### tune(scale)
|
||||
|
||||
{/* TODO (maybe): combine jsdoc things in tune.mjs with here */}
|
||||
|
||||
<JsDoc client:idle name="tune" h={0} />
|
||||
{/* <JsDoc client:idle name="tune" h={0} /> */}
|
||||
|
||||
Here's an example of how to configure a basic hexany scale:
|
||||
|
||||
@@ -110,9 +116,3 @@ You can also give tune a list of frequencies to use as the scale:
|
||||
523.2511306012
|
||||
]).mul(220).freq();`}
|
||||
/>
|
||||
|
||||
### xen(scaleOrRatios)
|
||||
|
||||
{/* TODO add explanation of EDO to documentation */}
|
||||
|
||||
<JsDoc client:idle name="Pattern.xen" h={0} />
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
title: Helix Keybindings
|
||||
layout: ../../layouts/MainLayout.astro
|
||||
---
|
||||
|
||||
# Helix Keybindings in the REPL
|
||||
|
||||
The Strudel REPL supports [Helix editor](https://helix-editor.com/) keybindings through the `codemirror-helix` extension. Helix is a post-modern modal text editor with a focus on selection-first editing and multiple cursors.
|
||||
|
||||
## Enabling Helix Mode
|
||||
|
||||
1. Open the Settings panel in the REPL (click the settings icon or use the keyboard shortcut)
|
||||
2. Find the "Keybindings" section
|
||||
3. Select "Helix" from the available options
|
||||
|
||||
## Key Differences from Vim
|
||||
|
||||
Helix uses a selection-first approach, which means:
|
||||
|
||||
- **Selection → Action** (Helix) vs **Action → Motion** (Vim)
|
||||
- For example, to delete a word in Helix: `w` (select word) → `d` (delete)
|
||||
- In Vim, it would be: `dw` (delete word)
|
||||
|
||||
## Common Helix Commands
|
||||
|
||||
### Normal Mode
|
||||
|
||||
- `i` — Enter insert mode before selection
|
||||
- `a` — Enter insert mode after selection
|
||||
- `v` — Enter visual/select mode
|
||||
- `w` — Select next word
|
||||
- `e` — Select to end of word
|
||||
- `b` — Select previous word
|
||||
- `x` — Select/extend line
|
||||
- `d` — Delete selection
|
||||
- `c` — Change selection (delete and enter insert mode)
|
||||
- `y` — Yank (copy) selection
|
||||
- `p` — Paste after selection
|
||||
- `u` — Undo
|
||||
- `U` — Redo
|
||||
- `/` — Search
|
||||
- `n` — Select next search match
|
||||
- `N` — Select previous search match
|
||||
|
||||
### Movement
|
||||
|
||||
- `h, j, k, l` — Move left, down, up, right
|
||||
- `gg` — Go to start of document
|
||||
- `ge` — Go to end of document
|
||||
- `{` / `}` — Move to previous/next paragraph
|
||||
- `%` — Match bracket
|
||||
|
||||
### Multi-cursor
|
||||
|
||||
- `C` — Duplicate cursor to line below
|
||||
- `Alt-C` — Duplicate cursor to line above
|
||||
- `,` — Remove primary cursor
|
||||
- `Alt-,` — Remove all secondary cursors
|
||||
|
||||
## Strudel-Specific Features
|
||||
|
||||
Currently, Helix mode in Strudel provides standard Helix keybindings. Unlike Vim mode, there are no custom Strudel-specific commands (like `:w` to evaluate) yet.
|
||||
|
||||
To evaluate code while in Helix mode, use the standard shortcuts:
|
||||
|
||||
- `Ctrl+Enter` or `Alt+Enter` — Evaluate code
|
||||
- `Alt+.` — Stop playback
|
||||
|
||||
## Resources
|
||||
|
||||
- [Helix Documentation](https://docs.helix-editor.com/)
|
||||
- [Helix Keymap](https://docs.helix-editor.com/keymap.html)
|
||||
- [codemirror-helix on npm](https://www.npmjs.com/package/codemirror-helix)
|
||||
- [codemirror-helix on GitLab](https://gitlab.com/_rvidal/codemirror-helix)
|
||||
|
||||
## Notes
|
||||
|
||||
- The `codemirror-helix` extension is described as an "initial version" and may not implement all Helix features
|
||||
- Behavior may differ slightly from the native Helix editor
|
||||
- If you encounter issues, you can switch back to other keybinding modes in Settings
|
||||
- The keybinding preference is saved in your browser's local storage
|
||||
|
||||
## Contributing
|
||||
|
||||
If you'd like to add Strudel-specific Helix commands (similar to Vim's `:w` for evaluate), contributions are welcome! See the implementation in `/packages/codemirror/keybindings.mjs` for reference.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user