Compare commits

..

5 Commits

Author SHA1 Message Date
Felix Roos 12fac86012 rename 2026-02-15 00:42:21 +01:00
Felix Roos cea6301ffd restore export 2026-02-15 00:40:13 +01:00
Jade (Rose) Rowland 0b69ce21c7 jsdoc 2026-02-08 21:11:10 -05:00
Jade (Rose) Rowland 4f05674cb3 working 2026-02-08 20:32:59 -05:00
Jade (Rose) Rowland feab3f5c86 freq 2026-02-08 19:25:10 -05:00
150 changed files with 3154 additions and 6473 deletions
-11
View File
@@ -1,11 +0,0 @@
**/node_modules
**/.angular
**/dist
.astro/
.forgejo/
.idea/
.vscode/
test/
.gitignore
Dockerfile
.dockerignore
-40
View File
@@ -1,40 +0,0 @@
name: Build and Deploy hot PRs
on:
pull_request_target:
types: [labeled]
# Allow one concurrent deployment
concurrency:
group: "warm-pages"
cancel-in-progress: false
jobs:
build:
if: ${{ github.event.label.name == 'serve-hot' }}
runs-on: docker
container: node:22-bookworm
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: pnpm/action-setup@v4
- name: Install Dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
eval $(ssh-agent -s)
echo "$SSH_PRIVATE_KEY" | ssh-add -
apt update && apt install -y rsync
mkdir -p ~/.ssh
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
rsync -atv --delete --delete-after --progress \
./website/dist/ \
strudel@matrix.toplap.org:/home/strudel/deploy/pr-${{ github.event.pull_request.number }}.hot.strudel.cc
+8 -4
View File
@@ -1,4 +1,4 @@
name: Build and Deploy to warm (strudel.cc) name: Build and Deploy to beta (warm.strudel.cc)
on: on:
push: push:
@@ -13,13 +13,17 @@ concurrency:
jobs: jobs:
build: build:
runs-on: docker runs-on: docker
container: node:22-bookworm
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@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 - name: Install Dependencies
run: pnpm install run: pnpm install
@@ -33,4 +37,4 @@ jobs:
apt update && apt install -y rsync apt update && apt install -y rsync
mkdir ~/.ssh mkdir ~/.ssh
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/warm.strudel.cc
+8 -4
View File
@@ -1,4 +1,4 @@
name: Build and Deploy to cold (cold.strudel.cc) name: Build and Deploy to live (strudel.cc)
on: [workflow_dispatch] on: [workflow_dispatch]
@@ -10,13 +10,17 @@ concurrency:
jobs: jobs:
build: build:
runs-on: docker runs-on: docker
container: node:22-bookworm
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@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 - name: Install Dependencies
run: pnpm install run: pnpm install
@@ -30,4 +34,4 @@ jobs:
apt update && apt install -y rsync apt update && apt install -y rsync
mkdir ~/.ssh mkdir ~/.ssh
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/cold.strudel.cc rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc
+11 -1
View File
@@ -5,11 +5,21 @@ on: [push, pull_request]
jobs: jobs:
build: build:
runs-on: docker runs-on: docker
container: node:22-bookworm strategy:
matrix:
node-version: [20]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: apt install ztd
run: apt update && apt install -y zstd
- uses: pnpm/action-setup@v4 - 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 install
- run: pnpm run format-check - run: pnpm run format-check
- run: pnpm run lint - run: pnpm run lint
+14
View File
@@ -45,6 +45,20 @@ tidal-drum-machines
webaudiofontdata webaudiofontdata
src-tauri/target 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 # BEGIN JetBrains -> END JetBrains
# for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # for JetBrains IDE users, e.g. WebStorm. Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+1 -11
View File
@@ -52,17 +52,7 @@ Strudel is a project handmade by humans, with thought and nuance.
If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself. If you have used LLMs (so called 'AI'), please detail that in the pull request. We are still developing our response to the onslaught of LLM technology, but for practical and legal reasons are currently not accepting wholly LLM-generated code. We are also not accepting PRs that add LLM features to strudel itself.
There are #llm-chat and #llm-share channels on [our discord](https://discord.com/invite/HGEdXmRkzT). Please do not discuss or share LLM-related things outside of those channels. There are #llm-chat and #llm-share channels on our discord. Please do not discuss or share LLM-related things outside of those channels.
## Creating and sharing a new project using strudel
Strudel is free/open source software, and we are also happy to see people making use of it within the following terms.
Please don't use 'strudel' in the name of your project, so people don't assume it's official strudel project. (If you'd like it to be an official strudel project, please check in with the community, e.g. on [the discord](https://discord.com/invite/HGEdXmRkzT).)
Please respect our AGPL license, which e.g. requires you to share/link to the source code of strudel, any modifications you've made to it, and the source code for the rest of your project if it integrates with strudel. You are also required to maintain Strudel's copyright notices in the source code, and include Strudel's copyright notice in your user interface. This is an ad-hoc summary - please [refer to the license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) for full details.
You are also encouraged to connect with the community and understand our aims and values.
## Report a Bug ## Report a Bug
+1 -2
View File
@@ -16,7 +16,7 @@ const drawContext = canvas.getContext('2d');
const drawTime = [-2, 2]; // time window of drawn haps const drawTime = [-2, 2]; // time window of drawn haps
const editor = new StrudelMirror({ const editor = new StrudelMirror({
onSchedule: webaudioOutput, defaultOutput: webaudioOutput,
getTime: () => getAudioContext().currentTime, getTime: () => getAudioContext().currentTime,
transpiler, transpiler,
root: document.getElementById('editor'), root: document.getElementById('editor'),
@@ -29,7 +29,6 @@ const editor = new StrudelMirror({
import('@strudel/core'), import('@strudel/core'),
import('@strudel/draw'), import('@strudel/draw'),
import('@strudel/mini'), import('@strudel/mini'),
import('@strudel/edo'),
import('@strudel/tonal'), import('@strudel/tonal'),
import('@strudel/webaudio'), import('@strudel/webaudio'),
); );
-1
View File
@@ -15,7 +15,6 @@
"@strudel/codemirror": "workspace:*", "@strudel/codemirror": "workspace:*",
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*", "@strudel/draw": "workspace:*",
"@strudel/edo": "workspace:*",
"@strudel/mini": "workspace:*", "@strudel/mini": "workspace:*",
"@strudel/soundfonts": "workspace:*", "@strudel/soundfonts": "workspace:*",
"@strudel/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
+1 -1
View File
@@ -12,7 +12,7 @@ registerSynthSounds();
evalScope(import('@strudel/core'), import('@strudel/mini'), import('@strudel/webaudio'), import('@strudel/tonal')); evalScope(import('@strudel/core'), import('@strudel/mini'), import('@strudel/webaudio'), import('@strudel/tonal'));
const { evaluate } = repl({ const { evaluate } = repl({
onSchedule: webaudioOutput, defaultOutput: webaudioOutput,
getTime: () => ctx.currentTime, getTime: () => ctx.currentTime,
transpiler, transpiler,
}); });
-1
View File
@@ -4,7 +4,6 @@ export * from './packages/core/index.mjs';
export * from './packages/csound/index.mjs'; export * from './packages/csound/index.mjs';
export * from './packages/desktopbridge/index.mjs'; export * from './packages/desktopbridge/index.mjs';
export * from './packages/draw/index.mjs'; export * from './packages/draw/index.mjs';
export * from './packages/edo/index.mjs';
export * from './packages/embed/index.mjs'; export * from './packages/embed/index.mjs';
export * from './packages/hydra/index.mjs'; export * from './packages/hydra/index.mjs';
export * from './packages/midi/index.mjs'; export * from './packages/midi/index.mjs';
-2
View File
@@ -3,7 +3,6 @@
"version": "0.5.0", "version": "0.5.0",
"private": true, "private": true,
"description": "Port of tidalcycles to javascript", "description": "Port of tidalcycles to javascript",
"packageManager": "pnpm@10.15.0",
"scripts": { "scripts": {
"setup": "pnpm i", "setup": "pnpm i",
"pretest": "npm run jsdoc-json", "pretest": "npm run jsdoc-json",
@@ -49,7 +48,6 @@
"homepage": "https://strudel.cc", "homepage": "https://strudel.cc",
"dependencies": { "dependencies": {
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/edo": "workspace:*",
"@strudel/mini": "workspace:*", "@strudel/mini": "workspace:*",
"@strudel/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
"@strudel/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
+342 -10
View File
@@ -1,6 +1,11 @@
import jsdoc from '../../doc.json'; import jsdoc from '../../doc.json';
import { autocompletion } from '@codemirror/autocomplete'; import { autocompletion } from '@codemirror/autocomplete';
import { h } from './html'; 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 escapeHtml = (str) => {
const div = document.createElement('div'); const div = document.createElement('div');
@@ -75,6 +80,53 @@ const isValidDoc = (doc) => {
const hasExcludedTags = (doc) => const hasExcludedTags = (doc) =>
['superdirtOnly', 'noAutocomplete'].some((tag) => doc.tags?.find((t) => t.originalTitle === tag)); ['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) => { export const getSynonymDoc = (doc, synonym) => {
const synonyms = doc.synonyms || []; const synonyms = doc.synonyms || [];
const docLabel = getDocLabel(doc); const docLabel = getDocLabel(doc);
@@ -113,19 +165,299 @@ const jsdocCompletions = (() => {
return completions; return completions;
})(); })();
export const strudelAutocomplete = (context) => { // --- Handler functions for each context ---
const word = context.matchBefore(/\w*/); const pitchNames = [
if (word.from === word.to && !context.explicit) return null; '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 { return {
from: word.from, from,
options: jsdocCompletions, options,
/* options: [
{ label: 'match', type: 'keyword' },
{ label: 'hello', type: 'variable', info: '(World)' },
{ label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
], */
}; };
}
// 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;
}; };
export const isAutoCompletionEnabled = (enabled) => export const isAutoCompletionEnabled = (enabled) =>
+12 -2
View File
@@ -2,13 +2,23 @@ import {
keymap, keymap,
highlightSpecialChars, highlightSpecialChars,
drawSelection, drawSelection,
highlightActiveLine,
dropCursor, dropCursor,
rectangularSelection, rectangularSelection,
crosshairCursor, crosshairCursor,
lineNumbers,
highlightActiveLineGutter,
} from '@codemirror/view'; } from '@codemirror/view';
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; import {
defaultHighlightStyle,
syntaxHighlighting,
bracketMatching,
foldGutter,
foldKeymap,
} from '@codemirror/language';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { closeBracketsKeymap } from '@codemirror/autocomplete'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { completionKeymap, closeBracketsKeymap } from '@codemirror/autocomplete';
// Taken + slightly modified from https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts // Taken + slightly modified from https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
+51
View File
@@ -0,0 +1,51 @@
// Block-based evaluation utilities
export function getBlockRegions(code) {
const chars = code.split('');
let i = 0,
blanks = [],
blockStart = 0,
regions = [];
while (i < chars.length) {
const isBlank = chars[i] === '\n';
if (isBlank) {
blanks.push(i);
} else if (chars[i].trim() !== '') {
if (blanks.length > 1) {
regions.push([blockStart, blanks[0]]);
blockStart = i;
}
blanks = [];
}
i++;
}
regions.push([blockStart, blanks.length ? blanks[0] : i]);
return regions;
}
export function getBlockAt(code, cursor) {
const regions = getBlockRegions(code);
for (const [start, end] of regions) {
if (cursor >= start && cursor <= end) {
return [start, end];
}
}
return null;
}
export const evalBlock = (strudelMirror) => {
const { state } = strudelMirror.editor;
const code = state.doc.toString();
const cursor = state.selection.main.head;
const range = getBlockAt(code, cursor);
if (range) {
const [a, b] = range;
const block = code.slice(a, b);
if (block) {
// Flash the block being evaluated
strudelMirror.flash(200, { from: a, to: b });
strudelMirror.repl.evaluateBlock(block, true, { range });
}
}
return true;
};
+113 -25
View File
@@ -1,5 +1,5 @@
import { closeBrackets } from '@codemirror/autocomplete'; import { closeBrackets } from '@codemirror/autocomplete';
import { indentWithTab } from '@codemirror/commands'; import { indentWithTab, toggleLineComment } from '@codemirror/commands';
import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState, Prec } from '@codemirror/state'; import { Compartment, EditorState, Prec } from '@codemirror/state';
@@ -12,19 +12,19 @@ import {
lineNumbers, lineNumbers,
} from '@codemirror/view'; } from '@codemirror/view';
import { persistentAtom } from '@nanostores/persistent'; import { persistentAtom } from '@nanostores/persistent';
import { registerControl, repl } from '@strudel/core'; import { logger, registerControl, repl } from '@strudel/core';
import { cleanupDraw, Drawer } from '@strudel/draw'; import { cleanupDraw, cleanupDrawContext, Drawer } from '@strudel/draw';
import { isAutoCompletionEnabled } from './autocomplete.mjs'; import { isAutoCompletionEnabled } from './autocomplete.mjs';
import { basicSetup } from './basicSetup.mjs'; import { basicSetup } from './basicSetup.mjs';
import { evalBlock } from './block_utilities.mjs';
import { flash, isFlashEnabled } from './flash.mjs'; import { flash, isFlashEnabled } from './flash.mjs';
import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs'; import { highlightMiniLocations, isPatternHighlightingEnabled, updateMiniLocations } from './highlight.mjs';
import { keybindings } from './keybindings.mjs'; import { keybindings } from './keybindings.mjs';
import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { jumpToCharacter } from './labelJump.mjs';
import { getSliderWidgets, sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs'; import { activateTheme, initTheme, theme } from './themes.mjs';
import { isTooltipEnabled } from './tooltip.mjs'; import { isTooltipEnabled } from './tooltip.mjs';
import { updateWidgets, widgetPlugin } from './widget.mjs'; import { getActiveWidgets, updateWidgets, widgetPlugin } from './widget.mjs';
import { jumpToCharacter } from './labelJump.mjs';
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands'; export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
@@ -64,6 +64,7 @@ export const defaultSettings = {
isLineWrappingEnabled: false, isLineWrappingEnabled: false,
isTabIndentationEnabled: false, isTabIndentationEnabled: false,
isMultiCursorEnabled: false, isMultiCursorEnabled: false,
isBlockBasedEvalEnabled: false,
theme: 'strudelTheme', theme: 'strudelTheme',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 18, fontSize: 18,
@@ -75,7 +76,7 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
}); });
// https://codemirror.net/docs/guide/ // https://codemirror.net/docs/guide/
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) { export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo, strudelMirror }) {
const settings = codemirrorSettings.get(); const settings = codemirrorSettings.get();
const initialSettings = Object.keys(compartments).map((key) => const initialSettings = Object.keys(compartments).map((key) =>
compartments[key].of(extensions[key](parseBooleans(settings[key]))), compartments[key].of(extensions[key](parseBooleans(settings[key]))),
@@ -105,11 +106,26 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
keymap.of([ keymap.of([
{ {
key: 'Ctrl-Enter', key: 'Ctrl-Enter',
run: () => onEvaluate?.(), run: () => {
// issue with referencing settings, this works more reliably
if (strudelMirror?.isBlockBasedEvalEnabled) {
evalBlock(strudelMirror);
return true;
} else {
return onEvaluate?.();
}
},
}, },
{ {
key: 'Alt-Enter', key: 'Alt-Enter',
run: () => onEvaluate?.(), run: () => {
if (strudelMirror?.isBlockBasedEvalEnabled) {
evalBlock(strudelMirror);
return true;
} else {
return onEvaluate?.();
}
},
}, },
{ {
key: 'Ctrl-.', key: 'Ctrl-.',
@@ -171,6 +187,7 @@ export class StrudelMirror {
this.onDraw = onDraw || this.draw; this.onDraw = onDraw || this.draw;
this.id = id || s4(); this.id = id || s4();
this.solo = solo; this.solo = solo;
this.isBlockBasedEvalEnabled = false; // Will be updated via updateSettings()
this.drawer = new Drawer((haps, time, _, painters) => { this.drawer = new Drawer((haps, time, _, painters) => {
const currentFrame = haps.filter((hap) => hap.isActive(time)); const currentFrame = haps.filter((hap) => hap.isActive(time));
@@ -201,21 +218,28 @@ export class StrudelMirror {
cleanupDraw(true, id); cleanupDraw(true, id);
} }
}, },
beforeEval: async () => { beforeEval: async ({ blockBased } = {}) => {
cleanupDraw(true, id); // Only clean up all drawings for full evaluation
// Block-based eval should preserve animations (like .scope()) from other blocks
if (!blockBased) {
cleanupDraw(true, id);
}
await this.prebaked; await this.prebaked;
await replOptions?.beforeEval?.(); await replOptions?.beforeEval?.();
}, },
afterEval: (options) => { afterEval: (options) => {
// remember for when highlighting is toggled on // remember for when highlighting is toggled on
this.miniLocations = options.meta?.miniLocations || []; this.miniLocations = options.meta?.miniLocations || [];
this.widgets = options.meta?.widgets || []; this.widgets = options.meta?.widgets;
const sliders = this.widgets.filter((w) => w.type === 'slider'); const sliders = this.widgets.filter((w) => w.type === 'slider');
updateSliderWidgets(this.editor, sliders);
const widgets = this.widgets.filter((w) => w.type !== 'slider'); const widgets = this.widgets.filter((w) => w.type !== 'slider');
updateWidgets(this.editor, widgets); // range-aware update for block-based evaluation
updateMiniLocations(this.editor, this.miniLocations); const range = options.range && options.range.length >= 2 ? options.range : null;
updateSliderWidgets(this.editor, sliders, range);
updateWidgets(this.editor, widgets, range);
updateMiniLocations(this.editor, this.miniLocations, range);
replOptions?.afterEval?.(options); replOptions?.afterEval?.(options);
// if no painters are set (.onPaint was not called), then we only need // if no painters are set (.onPaint was not called), then we only need
// the present moment (for highlighting) // the present moment (for highlighting)
@@ -223,8 +247,14 @@ export class StrudelMirror {
this.drawer.setDrawTime(drawTime); this.drawer.setDrawTime(drawTime);
// invalidate drawer after we've set the appropriate drawTime // invalidate drawer after we've set the appropriate drawTime
this.drawer.invalidate(this.repl.scheduler); this.drawer.invalidate(this.repl.scheduler);
// Clean up draw context if a non-inline widget was removed
if (options.widgetRemoved) {
cleanupDrawContext(id);
}
}, },
}); });
this.cleanupDrawContext = () => cleanupDrawContext(id);
this.editor = initEditor({ this.editor = initEditor({
root, root,
initialCode, initialCode,
@@ -237,7 +267,9 @@ export class StrudelMirror {
onEvaluate: () => this.evaluate(), onEvaluate: () => this.evaluate(),
onStop: () => this.stop(), onStop: () => this.stop(),
mondo: replOptions.mondo, mondo: replOptions.mondo,
strudelMirror: this,
}); });
const cmEditor = this.root.querySelector('.cm-editor'); const cmEditor = this.root.querySelector('.cm-editor');
if (cmEditor) { if (cmEditor) {
this.root.style.display = 'block'; this.root.style.display = 'block';
@@ -257,6 +289,33 @@ export class StrudelMirror {
} }
}; };
document.addEventListener('start-repl', this.onStartRepl); document.addEventListener('start-repl', this.onStartRepl);
// Handle global evaluation requests (e.g., from Vim :w)
this.onEvaluateRequest = (e) => {
try {
// 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 {
// 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) { draw(haps, time, painters) {
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime)); painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
@@ -280,10 +339,20 @@ export class StrudelMirror {
this.flash(); this.flash();
await this.repl.evaluate(this.code, autostart); await this.repl.evaluate(this.code, autostart);
} }
async stop() { async stop() {
this.repl.scheduler.stop(); this.repl.stop();
} }
// Listen for global stop requests (e.g., from Vim :q)
onStopRequest = (e) => {
try {
this.stop();
e?.cancelable && e.preventDefault?.();
} catch (err) {
console.error('Error handling repl-stop event', err);
}
};
async toggle() { async toggle() {
if (this.repl.scheduler.started) { if (this.repl.scheduler.started) {
this.repl.stop(); this.repl.stop();
@@ -291,8 +360,8 @@ export class StrudelMirror {
this.evaluate(); this.evaluate();
} }
} }
flash(ms) { flash(ms, range) {
flash(this.editor, ms); flash(this.editor, ms, range);
} }
highlight(haps, time) { highlight(haps, time) {
highlightMiniLocations(this.editor, time, haps); highlightMiniLocations(this.editor, time, haps);
@@ -324,6 +393,10 @@ export class StrudelMirror {
setLineWrappingEnabled(enabled) { setLineWrappingEnabled(enabled) {
this.reconfigureExtension('isLineWrappingEnabled', enabled); this.reconfigureExtension('isLineWrappingEnabled', enabled);
} }
setBlockBasedEvalEnabled(enabled) {
this.reconfigureExtension('isBlockBasedEvalEnabled', enabled);
}
setBracketMatchingEnabled(enabled) { setBracketMatchingEnabled(enabled) {
this.reconfigureExtension('isBracketMatchingEnabled', enabled); this.reconfigureExtension('isBracketMatchingEnabled', enabled);
} }
@@ -345,6 +418,10 @@ export class StrudelMirror {
for (let key in extensions) { for (let key in extensions) {
this.reconfigureExtension(key, settings[key]); this.reconfigureExtension(key, settings[key]);
} }
// Update block-based eval setting on the instance
if (settings.isBlockBasedEvalEnabled !== undefined) {
this.isBlockBasedEvalEnabled = parseBooleans(settings.isBlockBasedEvalEnabled);
}
const updated = { ...codemirrorSettings.get(), ...settings }; const updated = { ...codemirrorSettings.get(), ...settings };
codemirrorSettings.set(updated); codemirrorSettings.set(updated);
} }
@@ -358,18 +435,29 @@ export class StrudelMirror {
this.setFontSize(value); this.setFontSize(value);
} }
} }
replaceCode(code, from, to) { setCode(code) {
const changes = { from, to, insert: code }; const changes = {
from: 0,
to: this.editor.state.doc.length,
insert: code,
};
this.editor.dispatch({ changes }); this.editor.dispatch({ changes });
} }
insertCode(code, position) { // used for debugging but could serve other purposes
this.replaceCode(code, position, position); getActiveWidgets() {
return getActiveWidgets(this.editor);
} }
setCode(code) { getSliderWidgets() {
this.replaceCode(code, 0, this.editor.state.doc.length); return getSliderWidgets(this.editor);
}
getMiniLocations() {
return this.miniLocations;
} }
clear() { clear() {
this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl); this.onStartRepl && document.removeEventListener('start-repl', this.onStartRepl);
this.onEvaluateRequest && document.removeEventListener('repl-evaluate', this.onEvaluateRequest);
this.onStopRequest && document.removeEventListener('repl-stop', this.onStopRequest);
this.onToggleComment && document.removeEventListener('repl-toggle-comment', this.onToggleComment);
} }
getCursorLocation() { getCursorLocation() {
return this.editor.state.selection.main.head; return this.editor.state.selection.main.head;
+5 -3
View File
@@ -14,7 +14,8 @@ export const flashField = StateField.define({
const mark = Decoration.mark({ const mark = Decoration.mark({
attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` }, attributes: { style: `background-color: rgba(255,255,255, .4); filter: invert(10%)` },
}); });
flash = Decoration.set([mark.range(0, tr.newDoc.length)]); const range = e.value.range || { from: 0, to: tr.newDoc.length };
flash = Decoration.set([mark.range(range.from, range.to)]);
} else { } else {
flash = Decoration.set([]); flash = Decoration.set([]);
} }
@@ -29,8 +30,9 @@ export const flashField = StateField.define({
provide: (f) => EditorView.decorations.from(f), provide: (f) => EditorView.decorations.from(f),
}); });
export const flash = (view, ms = 200) => { export const flash = (view, ms = 200, range) => {
view.dispatch({ effects: setFlash.of(true) }); const flashData = range ? { range } : true;
view.dispatch({ effects: setFlash.of(flashData) });
setTimeout(() => { setTimeout(() => {
view.dispatch({ effects: setFlash.of(false) }); view.dispatch({ effects: setFlash.of(false) });
}, ms); }, ms);
+164 -63
View File
@@ -3,8 +3,9 @@ import { Decoration, EditorView } from '@codemirror/view';
export const setMiniLocations = StateEffect.define(); export const setMiniLocations = StateEffect.define();
export const showMiniLocations = StateEffect.define(); export const showMiniLocations = StateEffect.define();
export const updateMiniLocations = (view, locations) => { export const displayMiniLocations = StateEffect.define();
view.dispatch({ effects: setMiniLocations.of(locations) }); export const updateMiniLocations = (view, locations, range = null) => {
view.dispatch({ effects: setMiniLocations.of({ locations, range }) });
}; };
export const highlightMiniLocations = (view, atTime, haps) => { export const highlightMiniLocations = (view, atTime, haps) => {
view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) }); view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) });
@@ -21,23 +22,54 @@ const miniLocations = StateField.define({
for (let e of tr.effects) { for (let e of tr.effects) {
if (e.is(setMiniLocations)) { if (e.is(setMiniLocations)) {
// this is called on eval, with the mini locations obtained from the transpiler //block-based eval case
// codemirror will automatically remap the marks when the document is edited if (e.value.range) {
// create a mark for each mini location, adding the range to the spec to find it later const stateMiniLocations = getMiniLocationsFromDecorations(locations);
const marks = e.value
.filter(([from]) => from < tr.newDoc.length)
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
.map(
(range) =>
Decoration.mark({
id: range.join(':'),
// this green is only to verify that the decoration moves when the document is edited
// it will be removed later, so the mark is not visible by default
attributes: { style: `background-color: #00CA2880` },
}).range(...range), // -> Decoration
);
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration> const normalized = e.value.locations
.filter(([from]) => from < tr.newDoc.length)
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]);
const newIds = new Set(normalized.map((r) => r.join(':')));
const marks = normalized.map((range) => {
const id = range.join(':');
return Decoration.mark({
id,
// this green is only to verify that the decoration moves when the document is edited
// it will be removed later, so the mark is not visible by default
attributes: { style: `background-color: #00CA2880` },
}).range(...range); // -> Decoration
});
const previousMarks = stateMiniLocations
.filter(({ id }) => !newIds.has(id))
.map(({ from, to, id }) =>
Decoration.mark({
id,
attributes: { style: `background-color: #00CA2880` },
}).range(from, to),
);
locations = Decoration.set(previousMarks.concat(marks), true); // -> DecorationSet === RangeSet<Decoration>
} else {
// this is called on eval, with the mini locations obtained from the transpiler
// codemirror will automatically remap the marks when the document is edited
// create a mark for each mini location, adding the range to the spec to find it later
const marks = e.value.locations
.filter(([from]) => from < tr.newDoc.length)
.map(([from, to]) => [from, Math.min(to, tr.newDoc.length)])
.map(
(range) =>
Decoration.mark({
id: range.join(':'),
// this green is only to verify that the decoration moves when the document is edited
// it will be removed later, so the mark is not visible by default
attributes: { style: `background-color: #00CA2880` },
}).range(...range), // -> Decoration
);
locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet<Decoration>
}
} }
} }
@@ -75,12 +107,83 @@ const visibleMiniLocations = StateField.define({
}, },
}); });
// // Derive the set of decorations from the miniLocations and visibleLocations const displayMiniLocationsState = StateField.define({
const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => { create() {
const iterator = state.field(miniLocations).iter(); return true; // default to showing miniLocations
const { haps } = state.field(visibleMiniLocations); },
const builder = new RangeSetBuilder(); update(display, tr) {
for (let e of tr.effects) {
if (e.is(displayMiniLocations)) {
display = e.value;
}
}
return display;
},
});
// // Derive the set of decorations from the miniLocations and visibleLocations
const miniLocationHighlights = EditorView.decorations.compute(
[miniLocations, visibleMiniLocations, displayMiniLocationsState],
(state) => {
// Check if miniLocations display is disabled
const shouldDisplay = state.field(displayMiniLocationsState);
if (!shouldDisplay) {
return Decoration.none; // Return empty decorations if display is disabled
}
const iterator = state.field(miniLocations).iter();
const { haps } = state.field(visibleMiniLocations);
const builder = new RangeSetBuilder();
while (iterator.value) {
const {
from,
to,
value: {
spec: { id },
},
} = iterator;
if (haps.has(id)) {
const hap = haps.get(id);
const color = hap.value?.color ?? 'var(--foreground)';
const style = hap.value?.markcss || `outline: solid 2px ${color}`;
// Get explicit channels for color values
/*
const swatch = document.createElement('div');
swatch.style.color = color;
document.body.appendChild(swatch);
let channels = getComputedStyle(swatch)
.color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
.slice(1)
.map((c) => parseFloat(c || 1));
document.body.removeChild(swatch);
// Get percentage of event
const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
channels[3] *= percent;
*/
builder.add(
from,
to,
Decoration.mark({
// attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
attributes: { style },
}),
);
}
iterator.next();
}
return builder.finish();
},
);
const getMiniLocationsFromDecorations = (decorations) => {
const iterator = decorations.iter();
const miniLocationsArray = [];
while (iterator.value) { while (iterator.value) {
const { const {
from, from,
@@ -89,50 +192,48 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi
spec: { id }, spec: { id },
}, },
} = iterator; } = iterator;
miniLocationsArray.push({
if (haps.has(id)) { from,
const hap = haps.get(id); to,
const color = hap.value?.color ?? 'var(--foreground)'; id,
const style = hap.value?.markcss || `outline: solid 2px ${color}`; });
// Get explicit channels for color values
/*
const swatch = document.createElement('div');
swatch.style.color = color;
document.body.appendChild(swatch);
let channels = getComputedStyle(swatch)
.color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/)
.slice(1)
.map((c) => parseFloat(c || 1));
document.body.removeChild(swatch);
// Get percentage of event
const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration;
channels[3] *= percent;
*/
builder.add(
from,
to,
Decoration.mark({
// attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` },
attributes: { style },
}),
);
}
iterator.next(); iterator.next();
} }
return miniLocationsArray;
};
return builder.finish(); export const getMiniLocations = (state) => {
}); const decorations = state.field(miniLocations);
return getMiniLocationsFromDecorations(decorations);
};
export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights]; export const getActiveMiniLocations = (state) => {
const miniLocations = getMiniLocations(state);
const { haps } = state.field(visibleMiniLocations);
const activeMiniLocations = miniLocations.filter((location) => haps.has(location.id));
return activeMiniLocations;
};
export const highlightExtension = [
miniLocations,
visibleMiniLocations,
displayMiniLocationsState,
miniLocationHighlights,
];
export const isPatternHighlightingEnabled = (on, config) => { export const isPatternHighlightingEnabled = (on, config) => {
on && // NOTE:
config && // Modified this function to always return the highlightExtension, and instead just toggle whether or not the miniLocations are displayed.
setTimeout(() => { // This is because block based evaluation only updates regions of miniLocations, and those updates need to be kept track of constantly.
updateMiniLocations(config.editor, config.miniLocations); // The setTimeout was also removed because it conflicted with the range-specific updates required by
}, 100); // block based evaluation.
return on ? Prec.highest(highlightExtension) : []; // Not sure if this is the best approach, but for block based eval I can't think of a better way to do it.
if (config) {
// Toggle the display state for miniLocations
config.editor.dispatch({ effects: displayMiniLocations.of(on) });
}
return Prec.highest(highlightExtension);
}; };
-1
View File
@@ -5,4 +5,3 @@ export * from './slider.mjs';
export * from './themes.mjs'; export * from './themes.mjs';
export * from './widget.mjs'; export * from './widget.mjs';
export { Vim } from './keybindings.mjs'; export { Vim } from './keybindings.mjs';
export * from './basicSetup.mjs';
+157 -1
View File
@@ -3,9 +3,11 @@ import { Prec, EditorState } from '@codemirror/state';
import { keymap, ViewPlugin } from '@codemirror/view'; import { keymap, ViewPlugin } from '@codemirror/view';
// import { searchKeymap } from '@codemirror/search'; // import { searchKeymap } from '@codemirror/search';
import { emacs } from '@replit/codemirror-emacs'; import { emacs } from '@replit/codemirror-emacs';
import { vim } from '@replit/codemirror-vim'; import { vim, Vim } from '@replit/codemirror-vim';
// import { vim } from './vim_test.mjs'; // import { vim } from './vim_test.mjs';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import { helix, commands } from 'codemirror-helix';
import { logger } from '@strudel/core';
const vscodePlugin = ViewPlugin.fromClass( const vscodePlugin = ViewPlugin.fromClass(
class { class {
@@ -19,11 +21,165 @@ const vscodePlugin = ViewPlugin.fromClass(
); );
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []); const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
function replEval(view) {
try {
// Dispatch a dedicated evaluate event first
let handled = false;
try {
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
} catch (e) {
console.error('Error dispatching repl-evaluate event', e);
}
if (handled) {
return;
}
// Try Ctrl+Enter first if not handled by custom event
const ctrlEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(ctrlEnter);
// If not handled (no handler called preventDefault), try Alt+Enter as
// fallback
if (!ctrlEnter.defaultPrevented) {
const altEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altEnter);
}
} catch (e) {
console.error('Error dispatching repl evaluation event', e);
}
}
function replStop(view) {
try {
// First try dispatching our custom stop event, then fallback to Alt+.
let handled = false;
try {
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false;
} catch (e) {
console.error('Error dispatching repl-stop event', e);
}
if (!handled) {
const altDot = new KeyboardEvent('keydown', {
key: '.',
code: 'Period',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altDot);
}
} catch (e) {
console.error('Error dispatching repl stop event', e);
}
}
// Map Vim :w to trigger the same action as evaluation. We dispatch a custom
// 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?.view || cm;
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?.view || cm;
replStop(view);
});
// :w to evaluate
Vim.defineEx('write', 'w', (cm) => {
const view = cm?.view || cm; // CM6 Vim passes either an object with view or the view itself
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 = { const keymaps = {
vim, vim,
emacs, emacs,
codemirror: () => keymap.of(defaultKeymap), codemirror: () => keymap.of(defaultKeymap),
vscode: vscodeExtension, vscode: vscodeExtension,
helix: () => [helix(), helixCommands],
}; };
export { Vim } from '@replit/codemirror-vim'; export { Vim } from '@replit/codemirror-vim';
+12 -8
View File
@@ -32,22 +32,26 @@
}, },
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "catalog:", "@codemirror/autocomplete": "^6.18.4",
"@codemirror/commands": "catalog:", "@codemirror/commands": "^6.8.0",
"@codemirror/lang-javascript": "catalog:", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "catalog:", "@codemirror/language": "^6.10.8",
"@codemirror/search": "catalog:", "@codemirror/search": "^6.5.8",
"@codemirror/state": "catalog:", "@codemirror/state": "^6.5.1",
"@codemirror/view": "catalog:", "@codemirror/view": "^6.36.2",
"@lezer/highlight": "^1.2.1", "@lezer/highlight": "^1.2.1",
"@nanostores/persistent": "^0.10.2", "@nanostores/persistent": "^0.10.2",
"@replit/codemirror-emacs": "^6.1.0", "@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2", "@replit/codemirror-vscode-keymap": "^6.0.2",
"codemirror-helix": "^0.5.0",
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*", "@strudel/draw": "workspace:*",
"@strudel/tonal": "workspace:*",
"@strudel/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
"nanostores": "^0.11.3" "@tonaljs/tonal": "^4.10.0",
"nanostores": "^0.11.3",
"superdough": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
+106 -17
View File
@@ -2,11 +2,11 @@ import { ref, pure } from '@strudel/core';
import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view'; import { WidgetType, ViewPlugin, Decoration } from '@codemirror/view';
import { StateEffect } from '@codemirror/state'; import { StateEffect } from '@codemirror/state';
// Global state storage for all widget types
export let sliderValues = {}; export let sliderValues = {};
const getSliderID = (from) => `slider_${from}`;
export class SliderWidget extends WidgetType { export class SliderWidget extends WidgetType {
constructor(value, min, max, from, to, step, view) { constructor(value, min, max, from, to, step, view, id) {
super(); super();
this.value = value; this.value = value;
this.min = min; this.min = min;
@@ -16,10 +16,21 @@ export class SliderWidget extends WidgetType {
this.to = to; this.to = to;
this.step = step; this.step = step;
this.view = view; this.view = view;
this.id = id || `${from}:${to}`; // Range-based ID for stability
} }
eq() { eq(other) {
return false; if (!(other instanceof SliderWidget)) {
return false;
}
return (
this.id === other.id &&
this.from === other.from &&
this.to === other.to &&
this.value === other.value &&
this.min === other.min &&
this.max === other.max
);
} }
toDOM() { toDOM() {
@@ -38,6 +49,7 @@ export class SliderWidget extends WidgetType {
slider.from = this.from; slider.from = this.from;
slider.originalFrom = this.originalFrom; slider.originalFrom = this.originalFrom;
slider.to = this.to; slider.to = this.to;
slider.id = this.id; // Store range-based ID in DOM element
slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)'; slider.style = 'width:64px;margin-right:4px;transform:translateY(4px)';
this.slider = slider; this.slider = slider;
slider.addEventListener('input', (e) => { slider.addEventListener('input', (e) => {
@@ -49,7 +61,7 @@ export class SliderWidget extends WidgetType {
slider.originalValue = insert; slider.originalValue = insert;
slider.value = insert; slider.value = insert;
this.view.dispatch({ changes: change }); this.view.dispatch({ changes: change });
const id = getSliderID(slider.originalFrom); // matches id generated in transpiler const id = slider.id; // Use range-based ID
window.postMessage({ type: 'cm-slider', value: Number(next), id }); window.postMessage({ type: 'cm-slider', value: Number(next), id });
}); });
return wrap; return wrap;
@@ -62,19 +74,60 @@ export class SliderWidget extends WidgetType {
export const setSliderWidgets = StateEffect.define(); export const setSliderWidgets = StateEffect.define();
export const updateSliderWidgets = (view, widgets) => { export const setSliderWidgetsInRange = StateEffect.define();
view.dispatch({ effects: setSliderWidgets.of(widgets) });
export const updateSliderWidgets = (view, widgets, range = null) => {
if (range) {
// range argument passed for block-based evaluation
view.dispatch({ effects: setSliderWidgetsInRange.of({ widgets, range }) });
} else {
view.dispatch({ effects: setSliderWidgets.of(widgets) });
}
}; };
function getSliders(widgetConfigs, view) { function getSliders(widgetConfigs, view) {
return widgetConfigs return (
.filter((w) => w.type === 'slider') widgetConfigs
.map(({ from, to, value, min, max, step }) => { .filter((w) => w.type === 'slider')
return Decoration.widget({ // Deduplicate sliders that might appear multiple times (e.g., during paste operations)
widget: new SliderWidget(value, min, max, from, to, step, view), .filter((slider, index, self) => index === self.findIndex((s) => s.from === slider.from && s.to === slider.to))
side: 0, .sort((a, b) => a.from - b.from)
}).range(from /* , to */); .map(({ from, to, value, min, max, step, id }) => {
}); return Decoration.widget({
widget: new SliderWidget(value, min, max, from, to, step, view, id),
side: 0,
}).range(from /* , to */);
})
);
}
export function getSliderWidgets(view) {
if (!view || !view.state) {
return [];
}
const sliderPluginInstance = view.plugin(sliderPlugin);
if (!sliderPluginInstance || !sliderPluginInstance.decorations) {
return [];
}
const sliderWidgets = [];
sliderPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof SliderWidget) {
sliderWidgets.push({
type: 'slider',
from: decoration.widget.from,
to: decoration.widget.to,
value: decoration.widget.value,
min: decoration.widget.min,
max: decoration.widget.max,
step: decoration.widget.step,
});
}
});
return sliderWidgets;
} }
export const sliderPlugin = ViewPlugin.fromClass( export const sliderPlugin = ViewPlugin.fromClass(
@@ -101,7 +154,42 @@ export const sliderPlugin = ViewPlugin.fromClass(
} }
} }
for (let e of tr.effects) { for (let e of tr.effects) {
if (e.is(setSliderWidgets)) { if (e.is(setSliderWidgetsInRange)) {
// Block-aware slider update logic
const { widgets, range } = e.value;
const [rangeStart, rangeEnd] = range;
// Get existing slider widgets that should be preserved
const existingSliders = [];
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof SliderWidget) {
// Preserve sliders outside the evaluation range
// Use strict > for rangeEnd because when code is deleted, slider positions
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
if (from < rangeStart || from > rangeEnd) {
existingSliders.push({
from,
to,
value: decoration.widget.value,
min: decoration.widget.min,
max: decoration.widget.max,
step: decoration.widget.step,
id: decoration.widget.id || `${from}:${to}`,
type: 'slider',
});
}
}
});
// Merge preserved sliders with new widgets
const mergedWidgets = [...existingSliders, ...widgets]
.filter(
(slider, index, self) => index === self.findIndex((s) => s.type === 'slider' && s.id === slider.id),
)
.sort((a, b) => a.from - b.from);
this.decorations = Decoration.set(getSliders(mergedWidgets, update.view));
} else if (e.is(setSliderWidgets)) {
this.decorations = Decoration.set(getSliders(e.value, update.view)); this.decorations = Decoration.set(getSliders(e.value, update.view));
} }
} }
@@ -132,6 +220,7 @@ export let sliderWithID = (id, value, min, max) => {
sliderValues[id] = value; // sync state at eval time (code -> state) sliderValues[id] = value; // sync state at eval time (code -> state)
return ref(() => sliderValues[id]); // use state at query time return ref(() => sliderValues[id]); // use state at query time
}; };
// update state when sliders are moved // update state when sliders are moved
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
@@ -140,7 +229,7 @@ if (typeof window !== 'undefined') {
// update state when slider is moved // update state when slider is moved
sliderValues[e.data.id] = e.data.value; sliderValues[e.data.id] = e.data.value;
} else { } else {
console.warn(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`); console.error(`slider with id "${e.data.id}" is not registered. Only ${Object.keys(sliderValues)}`);
} }
} }
}); });
+194 -46
View File
@@ -1,55 +1,111 @@
import { StateEffect, StateField } from '@codemirror/state'; import { StateEffect, StateField } from '@codemirror/state';
import { Decoration, EditorView, WidgetType } from '@codemirror/view'; import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
import { getWidgetID, registerWidgetType } from '@strudel/transpiler'; import { getWidgetID, registerWidgetType } from '@strudel/transpiler';
import { Pattern } from '@strudel/core'; import { Pattern } from '@strudel/core';
export const addWidget = StateEffect.define({ export const setWidgets = StateEffect.define();
map: ({ from, to }, change) => {
return { from: change.mapPos(from), to: change.mapPos(to) };
},
});
export const updateWidgets = (view, widgets) => { export const setWidgetsInRange = StateEffect.define();
view.dispatch({ effects: addWidget.of(widgets) });
export const updateWidgets = (view, widgets, range = null) => {
if (range) {
// range argument passed for block-based evaluation
view.dispatch({ effects: setWidgetsInRange.of({ widgets, range }) });
} else {
view.dispatch({ effects: setWidgets.of(widgets) });
}
}; };
function getWidgets(widgetConfigs) { function getWidgets(widgetConfigs, view) {
return ( const filtered = widgetConfigs
widgetConfigs // Filter to widget configs only (exclude sliders)
// codemirror throws an error if we don't sort .filter((w) => w && w.type && w.type !== 'slider')
.sort((a, b) => a.to - b.to) // Deduplicate widgets by ID, matching slider behavior for stable widget identity
.map((widgetConfig) => { .filter((widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id));
// Filter out widgets whose range is encompassed by another widget
// const nonEncompassed = filterEncompassedWidgets(filtered);
return filtered
.sort((a, b) => (a.to || 0) - (b.to || 0))
.map((widgetConfig) => {
try {
return Decoration.widget({ return Decoration.widget({
widget: new BlockWidget(widgetConfig), widget: new BlockWidget(widgetConfig, view),
side: 0, side: 0,
block: true, }).range(widgetConfig.to || widgetConfig.from || 0);
}).range(widgetConfig.to); } catch (error) {
}) console.error('error creating widget', error);
); return null;
}
})
.filter(Boolean); // Remove any null results from failed creations
} }
const widgetField = StateField.define( export const widgetPlugin = ViewPlugin.fromClass(
/* <DecorationSet> */ { class {
create() { decorations; //: DecorationSet
return Decoration.none;
}, constructor(view /* : EditorView */) {
update(widgets, tr) { this.decorations = Decoration.set([]);
widgets = widgets.map(tr.changes); }
for (let e of tr.effects) {
if (e.is(addWidget)) { update(update /* : ViewUpdate */) {
try { update.transactions.forEach((tr) => {
widgets = widgets.update({ if (tr.docChanged) {
filter: () => false, this.decorations = this.decorations.map(tr.changes);
add: getWidgets(e.value), const iterator = this.decorations.iter();
}); // Apply changes to iterator.from and iterator.to if docChanged
} catch (error) { while (iterator.value) {
console.log('err', error); // when the widgets are moved, we need to tell the dom node the current position
// this is important because the widget functions have to work with the dom node
if (iterator.value?.widget instanceof BlockWidget) {
iterator.value.widget.from = iterator.from;
iterator.value.widget.to = iterator.to;
}
iterator.next();
} }
} }
} for (let e of tr.effects) {
return widgets; if (e.is(setWidgetsInRange)) {
}, // Block-aware widget update logic
provide: (f) => EditorView.decorations.from(f), const { widgets, range } = e.value;
const [rangeStart, rangeEnd] = range;
// Get existing widget widgets that should be preserved
const existingWidgets = [];
this.decorations.between(0, update.view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof BlockWidget) {
// Preserve widgets outside the evaluation range
// Use strict > for rangeEnd because when code is deleted, widget positions
// map to the deletion boundary (rangeEnd), and those should be removed, not preserved
if (from < rangeStart || from > rangeEnd) {
existingWidgets.push({
from: decoration.widget.from,
to: decoration.widget.to,
type: decoration.widget.type,
index: decoration.widget.index,
id: decoration.widget.id,
});
}
}
});
// Merge preserved widgets with new widgets, deduplicating by ID
const mergedWidgets = [...existingWidgets, ...widgets].filter(
(widget, index, self) => index === self.findIndex((w) => w.type === widget.type && w.id === widget.id),
);
this.decorations = Decoration.set(getWidgets(mergedWidgets, update.view));
} else if (e.is(setWidgets)) {
this.decorations = Decoration.set(getWidgets(e.value, update.view));
}
}
});
}
},
{
decorations: (v) => v.decorations,
}, },
); );
@@ -60,24 +116,116 @@ export function setWidget(id, el) {
} }
export class BlockWidget extends WidgetType { export class BlockWidget extends WidgetType {
constructor(widgetConfig) { constructor(widgetConfig, view) {
super(); super();
// Graceful handling of invalid configs like sliders
if (!widgetConfig || typeof widgetConfig !== 'object') {
widgetConfig = { type: 'unknown', from: 0, to: 0 };
}
this.from = widgetConfig.from || 0;
this.originalFrom = widgetConfig.from || 0;
this.to = widgetConfig.to || this.from;
this.originalTo = widgetConfig.to || this.from;
this.type = widgetConfig.type || 'unknown';
this.index = widgetConfig.index || 0;
this.view = view;
// Use range-based ID for stability, similar to sliders
this.id = widgetConfig.id || getWidgetID?.(widgetConfig);
this.widgetConfig = widgetConfig; this.widgetConfig = widgetConfig;
} }
eq() {
return true; eq(other) {
if (!(other instanceof BlockWidget)) {
return false;
}
return (
this.id === other.id &&
this.from === other.from &&
this.to === other.to &&
this.type === other.type &&
this.index === other.index
);
} }
toDOM() { toDOM() {
const id = getWidgetID(this.widgetConfig); let wrap = document.createElement('span');
const el = widgetElements[id]; wrap.setAttribute('aria-hidden', 'true');
return el; wrap.className = 'cm-widget-container';
let el = widgetElements[this.id];
if (el) {
// Ensure the element has the correct ID
el.id = this.id;
wrap.appendChild(el);
} else {
// Create a placeholder element if the widget element doesn't exist
// This prevents CodeMirror errors when widget is missing
const placeholder = document.createElement('span');
placeholder.setAttribute('aria-hidden', 'true');
placeholder.className = 'cm-widget-placeholder';
placeholder.style.cssText = 'display: none;'; // Hide placeholder
placeholder.id = this.id;
wrap.appendChild(placeholder);
}
return wrap;
} }
ignoreEvent(e) { ignoreEvent(e) {
return true; return true;
} }
} }
export const widgetPlugin = [widgetField]; export function getActiveWidgets(view) {
if (!view || !view.state) {
return [];
}
const widgetPluginInstance = view.plugin(widgetPlugin);
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
return [];
}
const widgets = [];
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof BlockWidget) {
widgets.push({
type: decoration.widget.type,
from: decoration.widget.from,
to: decoration.widget.to,
index: decoration.widget.index,
id: decoration.widget.id,
});
}
});
return widgets;
}
export function getAllWidgetIds(view) {
if (!view || !view.state) {
return [];
}
const widgetPluginInstance = view.plugin(widgetPlugin);
if (!widgetPluginInstance || !widgetPluginInstance.decorations) {
return [];
}
const widgetIds = [];
widgetPluginInstance.decorations.between(0, view.state.doc.length, (from, to, decoration) => {
if (decoration.widget instanceof BlockWidget) {
widgetIds.push(decoration.widget.id);
}
});
return widgetIds;
}
// widget implementer API to create a new widget type // widget implementer API to create a new widget type
export function registerWidget(type, fn) { export function registerWidget(type, fn) {
+26 -39
View File
@@ -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 * @param {number | Pattern} position Position in the wavetable from 0 to 1
* @synonyms wavetablePosition * @synonyms wavetablePosition
* @example * @example
* s("wt_digital").seg(8).note("F1").wt("0 0.25 0.5 0.75 1") * s("squelch").bank("wt_digital").seg(8).note("F1").wt("0 0.25 0.5 0.75 1")
*/ */
export const { wt, wavetablePosition } = registerControl('wt', 'wavetablePosition'); 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 * each other with matrix commands like `fm13`, which would send `fm1` back into
* `fm3` * `fm3`
* *
* @name fm * @name fmi
* @tags fm, superdough, supradough * @tags fm, superdough, supradough
* @param {number | Pattern} brightness modulation index * @param {number | Pattern} brightness modulation index
* @synonyms fmi * @synonyms fm
* @example * @example
* note("c e g b g e") * note("c e g b g e")
* .fm("<0 1 2 8 32>") * .fm("<0 1 2 8 32>")
@@ -1893,6 +1893,19 @@ export const { delay } = registerControl(['delay', 'delaytime', 'delayfeedback']
*/ */
export const { delayfeedback, delayfb, dfb } = registerControl('delayfeedback', 'delayfb', 'dfb'); export const { delayfeedback, delayfb, dfb } = registerControl('delayfeedback', 'delayfb', 'dfb');
/**
* Sets the level of the signal that is fed back into the delay.
* Caution: Values >= 1 will result in a signal that gets louder and louder! Don't do it
*
* @name delayfeedback
* @tags orbit, superdough, supradough
* @param {number | Pattern} feedback between 0 and 1
* @synonyms delayfb, dfb
* @example
* s("bd").delay(.25).delayfeedback("<.25 .5 .75 1>")
*
*/
export const { delayspeed } = registerControl('delayspeed');
/** /**
* Sets the time of the delay effect. * Sets the time of the delay effect.
* *
@@ -1904,22 +1917,6 @@ export const { delayfeedback, delayfb, dfb } = registerControl('delayfeedback',
* note("d d a# a".fast(2)).s("sawtooth").delay(.8).delaytime(1/2).delayspeed("<2 .5 -1 -2>") * note("d d a# a".fast(2)).s("sawtooth").delay(.8).delaytime(1/2).delayspeed("<2 .5 -1 -2>")
* *
*/ */
export const { delayspeed } = registerControl('delayspeed');
/**
* Sets the time of the delay effect in seconds.
*
* @name delaytime
* @tags orbit, superdough, supradough
* @param {number | Pattern} delay in seconds
* @synonyms delayt, dt
* @example
* note("d d a# a".fast(2))
* .s("sawtooth")
* .delay(.8)
* .delaytime(1/2)
* .delayspeed("<2 .5 -1 -2>")
*/
export const { delaytime, delayt, dt } = registerControl('delaytime', 'delayt', 'dt'); export const { delaytime, delayt, dt } = registerControl('delaytime', 'delayt', 'dt');
/** /**
@@ -1988,10 +1985,11 @@ export const { spread } = registerControl('spread');
* Set dryness of reverb. See `room` and `size` for more information about reverb. * Set dryness of reverb. See `room` and `size` for more information about reverb.
* *
* @name dry * @name dry
* @tags superdirt, superdough * @tags superdirt
* @param {number | Pattern} dry 0 = wet, 1 = dry * @param {number | Pattern} dry 0 = wet, 1 = dry
* @example * @example
* n("[0,3,7](3,8)").s("superpiano").room(.7).dry("<0 .5 .75 1>").osc() * n("[0,3,7](3,8)").s("superpiano").room(.7).dry("<0 .5 .75 1>").osc()
* @superdirtOnly
* *
*/ */
export const { dry } = registerControl('dry'); export const { dry } = registerControl('dry');
@@ -2225,7 +2223,7 @@ export const { orbit } = registerControl('orbit', 'o');
* otherPat.bmod(..) (to modulate another pattern with the bus) * otherPat.bmod(..) (to modulate another pattern with the bus)
* *
* @name bus * @name bus
* @tags superdough * @tags superdirt
* @param {number | Pattern} number * @param {number | Pattern} number
*/ */
export const { bus } = registerControl('bus'); export const { bus } = registerControl('bus');
@@ -2234,7 +2232,7 @@ export const { bus } = registerControl('bus');
* Postgain multiplier prior to sending the signal to the audio bus. * Postgain multiplier prior to sending the signal to the audio bus.
* *
* @name busgain * @name busgain
* @tags superdough * @tags superdirt
* @synonyms bgain * @synonyms bgain
* @param {number | Pattern} number * @param {number | Pattern} number
*/ */
@@ -2351,18 +2349,11 @@ export const { offset } = registerControl('offset');
**/ **/
export const { octaves } = registerControl('octaves'); export const { octaves } = registerControl('octaves');
/** /**
* How the voicing is aligned to the anchor * Remove anchor note from the voicing. Useful for melody harmonization
* - `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 * @name mode
* @tags tonal * @tags tonal
* @param {string | Pattern} modeName one of {below | above | duck | root | oldabove | oldroot} * @param {string | Pattern} modeName one of {below | above | duck | root}
* @example * @example
* mode("<below above duck root>").chord("C").voicing() * mode("<below above duck root>").chord("C").voicing()
* *
@@ -2376,7 +2367,7 @@ export const { mode } = registerControl(['mode', 'anchor']);
* *
* @name room * @name room
* @tags orbit, superdough * @tags orbit, superdough
* @param {number | Pattern} level * @param {number | Pattern} level between 0 and 1
* @example * @example
* s("bd sd [~ bd] sd").room("<0 .2 .4 .6 .8 1>") * s("bd sd [~ bd] sd").room("<0 .2 .4 .6 .8 1>")
* @example * @example
@@ -2588,17 +2579,13 @@ export const { compressorRelease } = registerControl('compressorRelease');
export const { speed } = registerControl('speed'); export const { speed } = registerControl('speed');
/** /**
* Changes the pitch of the sample without changing its speed. * Changes the speed of sample playback, i.e. a cheap way of changing pitch.
* The frequencies are multiplied by (factor + 1) for positive numbers
* and by max(factor / 4 + 1, 0) for negative numbers.
* So tuning up by octaves can be done with 1, 3, 7, ...
* and tuning down by octaves with -2, -3, -3.5...
* *
* @name stretch * @name stretch
* @tags pitch, samples * @tags pitch, samples
* @param {number | Pattern} factor between `-4` and `inf`. Positive increases pitch, 0 does nothing, negative decreases the pitch. * @param {number | Pattern} factor -inf to inf, negative numbers play the sample backwards.
* @example * @example
* s("gm_flute").stretch("<2 1 0 -2>") * s("gm_flute").stretch("1 2 .5")
* *
*/ */
export const { stretch } = registerControl('stretch'); export const { stretch } = registerControl('stretch');
+6 -7
View File
@@ -10,7 +10,6 @@ import { errorLogger, logger } from './logger.mjs';
export class Cyclist { export class Cyclist {
constructor({ constructor({
interval, interval,
onSchedule,
onTrigger, onTrigger,
onToggle, onToggle,
onError, onError,
@@ -20,11 +19,6 @@ export class Cyclist {
clearInterval, clearInterval,
beforeStart, 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.started = false;
this.beforeStart = beforeStart; this.beforeStart = beforeStart;
this.cps = 0.5; this.cps = 0.5;
@@ -69,7 +63,12 @@ export class Cyclist {
if (hap.hasOnset()) { if (hap.hasOnset()) {
const targetTime = const targetTime =
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency; (hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
onSchedule?.(hap, targetTime, this.cps); 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);
if (hap.value.cps !== undefined && this.cps != hap.value.cps) { if (hap.value.cps !== undefined && this.cps != hap.value.cps) {
this.cps = hap.value.cps; this.cps = hap.value.cps;
this.num_ticks_since_cps_change = 0; this.num_ticks_since_cps_change = 0;
+19 -45
View File
@@ -59,11 +59,6 @@ export const bjorklund = function (ons, steps) {
* really useful for computer/algorithmic music because they can * really useful for computer/algorithmic music because they can
* describe a large number of rhythms with a couple of numbers. * 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 * @memberof Pattern
* @name euclid * @name euclid
* @tags temporal * @tags temporal
@@ -142,38 +137,8 @@ const _euclidRot = function (pulses, steps, rotation) {
return b; 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) { export const euclid = register('euclid', function (pulses, steps, pat) {
return _euclid(pulses, steps, 0, pat); return pat.struct(_euclidRot(pulses, steps, 0));
}); });
export const bjork = register('bjork', function (euc, pat) { export const bjork = register('bjork', function (euc, pat) {
@@ -181,18 +146,16 @@ export const bjork = register('bjork', function (euc, pat) {
euc = [euc]; euc = [euc];
} }
const [pulses, steps = pulses, rot = 0] = euc; const [pulses, steps = pulses, rot = 0] = euc;
return _euclid(pulses, steps, rot, pat); return pat.struct(_euclidRot(pulses, steps, rot));
}); });
export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], function (pulses, steps, rotation, pat) { export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], function (pulses, steps, rotation, pat) {
return _euclid(pulses, steps, rotation, pat); return pat.struct(_euclidRot(pulses, steps, rotation));
}); });
/** /**
* Similar to `euclid`, but each pulse is held until the next pulse, * Similar to `euclid`, but each pulse is held until the next pulse,
* so there will be no gaps. * so there will be no gaps.
*
* A negative number of steps changes it into `euclid`.
* @name euclidLegato * @name euclidLegato
* @memberof Pattern * @memberof Pattern
* @tags temporal * @tags temporal
@@ -204,16 +167,27 @@ export const { euclidrot, euclidRot } = register(['euclidrot', 'euclidRot'], fun
* note("c3").euclidLegato(3,8) * 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) { export const euclidLegato = register(['euclidLegato'], function (pulses, steps, pat) {
return _euclid(pulses, -steps, 0, pat); return _euclidLegato(pulses, steps, 0, pat);
}); });
/** /**
* Similar to `euclid`, but each pulse is held until the next pulse, * 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' * 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 * @name euclidLegatoRot
* @memberof Pattern * @memberof Pattern
* @tags temporal * @tags temporal
@@ -224,7 +198,7 @@ export const euclidLegato = register(['euclidLegato'], function (pulses, steps,
* note("c3").euclidLegatoRot(3,5,2) * note("c3").euclidLegatoRot(3,5,2)
*/ */
export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, steps, rotation, pat) { export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, steps, rotation, pat) {
return _euclid(pulses, -steps, rotation, pat); return _euclidLegato(pulses, steps, rotation, pat);
}); });
/** /**
+28
View File
@@ -5,6 +5,34 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
export const strudelScope = {}; export const strudelScope = {};
// Make strudelScope available globally so transpiled code can access it
globalThis.strudelScope = strudelScope;
// Track user-defined keys (from block-based eval) so we can clear them without removing strudel functions
export const userDefinedKeys = new Set();
globalThis.userDefinedKeys = userDefinedKeys;
/**
* Clears all user-defined variables and functions from the scope.
* This removes variables created during block-based evaluation.
* @name clearScope
* @example
* // After defining variables in blocks:
* // let myVar = 5
* // function myFunc() { return 10; }
* clearScope() // removes myVar and myFunc from scope
*/
export const clearScope = () => {
for (const key of userDefinedKeys) {
delete strudelScope[key];
delete globalThis[key];
}
userDefinedKeys.clear();
// Return silence if available (for use in pattern expressions), otherwise undefined
return globalThis.silence;
};
// Make clearScope available globally
globalThis.clearScope = clearScope;
export const evalScope = async (...args) => { export const evalScope = async (...args) => {
const results = await Promise.allSettled(args); const results = await Promise.allSettled(args);
+3 -7
View File
@@ -8,12 +8,7 @@ import { logger } from './logger.mjs';
import { ClockCollator, cycleToSeconds } from './util.mjs'; import { ClockCollator, cycleToSeconds } from './util.mjs';
export class NeoCyclist { export class NeoCyclist {
constructor({ onSchedule, onToggle, getTime, onTrigger }) { constructor({ onTrigger, onToggle, getTime }) {
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.started = false;
this.cps = 0.5; this.cps = 0.5;
this.getTime = getTime; // get absolute time this.getTime = getTime; // get absolute time
@@ -48,7 +43,8 @@ export class NeoCyclist {
if (hap.hasOnset()) { if (hap.hasOnset()) {
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps); const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
const targetTime = timeUntilTrigger + currentTime + this.latency; const targetTime = timeUntilTrigger + currentTime + this.latency;
onSchedule?.(hap, targetTime, this.cps); const duration = cycleToSeconds(hap.duration, this.cps);
onTrigger?.(hap, 0, duration, this.cps, targetTime);
} }
}); });
}; };
+50 -557
View File
@@ -27,7 +27,6 @@ import {
import drawLine from './drawLine.mjs'; import drawLine from './drawLine.mjs';
import { errorLogger, logger } from './logger.mjs'; import { errorLogger, logger } from './logger.mjs';
import { strudelScope } from './evaluate.mjs'; import { strudelScope } from './evaluate.mjs';
import { getClockTime } from './schedulerState.mjs';
let stringParser; let stringParser;
@@ -505,25 +504,10 @@ export class Pattern {
/** /**
* Returns a new pattern with the given function applied to the list of haps returned by every query. * 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 * @tags internals
* @param {Function} func * @param {Function} func
* @returns Pattern * @returns Pattern
* @noAutocomplete * @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) { withHaps(func) {
const result = new Pattern((state) => func(this.query(state), state)); const result = new Pattern((state) => func(this.query(state), state));
@@ -533,23 +517,10 @@ export class Pattern {
/** /**
* As with `withHaps`, but applies the function to every hap, rather than every list of haps. * 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 * @tags internals
* @param {Function} func * @param {Function} func
* @returns Pattern * @returns Pattern
* @noAutocomplete * @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) { withHap(func) {
return this.withHaps((haps) => haps.map(func)); return this.withHaps((haps) => haps.map(func));
@@ -879,26 +850,6 @@ export class Pattern {
return stack(this, ...pats); 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) { sequence(...pats) {
return sequence(this, ...pats); return sequence(this, ...pats);
} }
@@ -921,41 +872,23 @@ export class Pattern {
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// Context methods - ones that deal with metadata // 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) => return this.withHap((hap) =>
hap.setContext({ hap.setContext({
...hap.context, ...hap.context,
onSchedule: (scheduleHap, t, cps) => { onTrigger: (...args) => {
// run previously set trigger, if it exists // run previously set trigger, if it exists
hap.context.onSchedule?.(scheduleHap, t, cps); hap.context.onTrigger?.(...args);
onSchedule(scheduleHap, t, cps); onTrigger(...args);
}, },
// if dominantSchedule is set to true, the default output (webaudio) will be disabled // if dominantTrigger is set to true, the default output (webaudio) will be disabled
// when using multiple triggers, you cannot flip this flag to false again! // 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 // example: x.csound('CooLSynth').log() as well as x.log().csound('CooLSynth') should work the same
dominantSchedule: hap.context.dominantSchedule || dominant, dominantTrigger: hap.context.dominantTrigger || 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). * Writes the content of the current event to the console (visible in the side menu).
* @tags visualization * @tags visualization
@@ -965,7 +898,7 @@ export class Pattern {
* s("bd sd").log() * s("bd sd").log()
*/ */
log(func = (hap) => `[hap] ${hap.showWhole(true)}`, getData = (hap) => ({ hap })) { log(func = (hap) => `[hap] ${hap.showWhole(true)}`, getData = (hap) => ({ hap })) {
return this.onSchedule((...args) => { return this.onTrigger((...args) => {
logger(func(...args), undefined, getData(...args)); logger(func(...args), undefined, getData(...args));
}, false); }, false);
} }
@@ -1069,7 +1002,7 @@ export const arpWith = register('arpWith', (func, pat) => {
* */ * */
export const arp = register( export const arp = register(
'arp', 'arp',
(indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[_mod(i, haps.length)])), (indices, pat) => pat.arpWith((haps) => reify(indices).fmap((i) => haps[i % haps.length])),
false, false,
); );
@@ -1123,55 +1056,7 @@ function _composeOp(a, b, func) {
// pattern composers // pattern composers
const COMPOSERS = { const COMPOSERS = {
/**
* When called on a pattern `a`, with a input pattern `b` (`a.set(b)`),
* combines `a` and `b` such that anything defined in `b`
* and anything defined in `a` that is *not* defined in `b`
* will be in the resulting pattern.
*
* The structure is maintained from `a`,
* because the default pattern alignment is `in`,
* see the section on `Pattern Alignment`
* in the technical manual in the docs
*
* This is the inverse of `keep`
*
* See examples below
* @name set
* @param {Pattern} pat
* @returns {Pattern}
* @memberof Pattern
* @tags internals, combiners
* @example
* // because input pattern has `s` set,
* // it overrides the "sine" declared earlier
* note("c a f e").s("sine").set(s("triangle"))
*/
set: [(a, b) => b], set: [(a, b) => b],
/**
* When called on a pattern `a`, with a input pattern `b` (`a.keep(b)`),
* combines `a` and `b` such that anything defined in `a`,
* and anything defined in `b` that is *not* defined in `a`
* will be in the resulting pattern
*
* The structure is maintained from `a`,
* because the default pattern alignment is `in`,
* see the section on `Pattern Alignment`
* in the technical manual in the docs
*
* This is the inverse of `set`
*
* See examples below
* @name keep
* @param {Pattern} pat
* @memberof Pattern
* @returns {Pattern}
* @tags internals, combiners
* @example
* // notes, already defined, will stay "c a f e",
* // while "s", not defined, will be set to "piano"
* note("c a f e").keep(note("e f a c").s("piano"))
*/
keep: [(a) => a], keep: [(a) => a],
keepif: [(a, b) => (b ? a : undefined)], keepif: [(a, b) => (b ? a : undefined)],
@@ -1223,163 +1108,25 @@ const COMPOSERS = {
* @tags math * @tags math
*/ */
div: [numeralArgs((a, b) => a / b)], 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)], mod: [numeralArgs(_mod)],
/**
*
* Raises each number to the power of the given factor
* @name pow
* @memberof Pattern
* @tags math
*/
pow: [numeralArgs(Math.pow)], pow: [numeralArgs(Math.pow)],
/** log2: [numeralArgs(Math.log2)],
*
* Performs a bitwise `and` on each number and the given factor
* @name band
* @memberof Pattern
* @tags math
*/
band: [numeralArgs((a, b) => a & b)], 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)], 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)], 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)], 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)], brshift: [numeralArgs((a, b) => a >> b)],
// TODO - force numerical comparison if both look like numbers? // 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], 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], 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], 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], 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], 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], 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], 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], 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], 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], or: [(a, b) => a || b],
// bitwise ops // bitwise ops
@@ -1655,19 +1402,7 @@ export function sequenceP(pats) {
export function stack(...pats) { export function stack(...pats) {
// Array test here is to avoid infinite recursions.. // Array test here is to avoid infinite recursions..
pats = pats.map((pat) => (Array.isArray(pat) ? sequence(...pat) : reify(pat))); pats = pats.map((pat) => (Array.isArray(pat) ? sequence(...pat) : reify(pat)));
const query = (state) => const query = (state) => flatten(pats.map((pat) => pat.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); const result = new Pattern(query);
if (__steps) { if (__steps) {
result._steps = lcm(...pats.map((pat) => pat._steps)); result._steps = lcm(...pats.map((pat) => pat._steps));
@@ -1688,18 +1423,6 @@ function _stackWith(func, pats) {
return stack(...func(steps, 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) { export function stackLeft(...pats) {
return _stackWith( return _stackWith(
(steps, pats) => pats.map((pat) => (pat._steps.eq(steps) ? pat : stepcat(pat, gap(steps.sub(pat._steps))))), (steps, pats) => pats.map((pat) => (pat._steps.eq(steps) ? pat : stepcat(pat, gap(steps.sub(pat._steps))))),
@@ -1707,18 +1430,6 @@ 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) { export function stackRight(...pats) {
return _stackWith( return _stackWith(
(steps, pats) => pats.map((pat) => (pat._steps.eq(steps) ? pat : stepcat(gap(steps.sub(pat._steps)), pat))), (steps, pats) => pats.map((pat) => (pat._steps.eq(steps) ? pat : stepcat(gap(steps.sub(pat._steps)), pat))),
@@ -1726,19 +1437,7 @@ 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( return _stackWith(
(steps, pats) => (steps, pats) =>
pats.map((pat) => { pats.map((pat) => {
@@ -1752,25 +1451,6 @@ export function stackCenter(...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) { export function stackBy(by, ...pats) {
const [left, ...right] = pats.map((pat) => pat._steps); const [left, ...right] = pats.map((pat) => pat._steps);
const steps = left.maximum(...right); const steps = left.maximum(...right);
@@ -1802,9 +1482,7 @@ export function slowcat(...pats) {
// Array test here is to avoid infinite recursions.. // Array test here is to avoid infinite recursions..
pats = pats.map((pat) => (Array.isArray(pat) ? fastcat(...pat) : reify(pat))); pats = pats.map((pat) => (Array.isArray(pat) ? fastcat(...pat) : reify(pat)));
if (!pats.length) { if (pats.length == 1) {
return silence;
} else if (pats.length == 1) {
return pats[0]; return pats[0];
} }
@@ -1812,6 +1490,10 @@ export function slowcat(...pats) {
const span = state.span; const span = state.span;
const pat_n = _mod(span.begin.sam(), pats.length); const pat_n = _mod(span.begin.sam(), pats.length);
const pat = pats[pat_n]; 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. // 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 // 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. // be the second (rather than fourth) cycle from the first pattern.
@@ -1828,14 +1510,11 @@ export function slowcat(...pats) {
* @return {Pattern} * @return {Pattern}
*/ */
export function slowcatPrime(...pats) { export function slowcatPrime(...pats) {
if (!pats.length) {
return silence;
}
pats = pats.map(reify); pats = pats.map(reify);
const query = function (state) { const query = function (state) {
const pat_n = _mod(Math.floor(state.span.begin), pats.length); const pat_n = Math.floor(state.span.begin) % pats.length;
const pat = pats[pat_n]; const pat = pats[pat_n]; // can be undefined for same cases e.g. /#cHVyZSg0MikKICAuZXZlcnkoMyxhZGQoNykpCiAgLmxhdGUoLjUp
return pat.query(state); return pat?.query(state) || [];
}; };
return new Pattern(query).splitQueries(); return new Pattern(query).splitQueries();
} }
@@ -1885,10 +1564,10 @@ export function arrange(...sections) {
* @tags combiners * @tags combiners
* @return {Pattern} * @return {Pattern}
* @example * @example
* seqPLoop( seqPLoop(
* [0, 2, "bd(3,8)"], [0, 2, "bd(3,8)"],
* [1, 3, "cp(3,8)"] [1, 3, "cp(3,8)"]
* ).sound() ).sound()
*/ */
export function seqPLoop(...parts) { export function seqPLoop(...parts) {
let total = Fraction(0); let total = Fraction(0);
@@ -2015,12 +1694,6 @@ export const func = curry((a, b) => reify(b).func(a));
* *
*/ */
export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) { export function register(name, func, patternify = true, preserveSteps = false, join = (x) => x.innerJoin()) {
if (isPattern(name)) {
throw new Error(
'Name argument for register is a pattern, try using single quotes (\'name\') instead of double quotes ("name")',
);
}
if (Array.isArray(name)) { if (Array.isArray(name)) {
const result = {}; const result = {};
for (const name_item of name) { for (const name_item of name) {
@@ -2143,8 +1816,6 @@ export const floor = register('floor', function (pat) {
return pat.asNumber().fmap((v) => Math.floor(v)); return pat.asNumber().fmap((v) => Math.floor(v));
}); });
export const log2 = register('log2', (pat) => pat.asNumber().fmap((v) => Math.log2(v)));
/** /**
* Assumes a numerical pattern. Returns a new pattern with all values set to * Assumes a numerical pattern. Returns a new pattern with all values set to
* their mathematical ceiling. E.g. `3.2` replaced with `4`, and `-4.2` * their mathematical ceiling. E.g. `3.2` replaced with `4`, and `-4.2`
@@ -2354,8 +2025,8 @@ export const ply = register('ply', function (factor, pat) {
* @example * @example
* s("bd hh sd hh").fast(2) // s("[bd hh sd hh]*2") * s("bd hh sd hh").fast(2) // s("[bd hh sd hh]*2")
*/ */
export const fast = register( export const { fast, density } = register(
'fast', ['fast', 'density'],
function (factor, pat) { function (factor, pat) {
if (factor === 0) { if (factor === 0) {
return silence; return silence;
@@ -2565,11 +2236,11 @@ export const { zoomArc, zoomarc } = register(['zoomArc', 'zoomarc'], function (a
* @param {number} slices to play * @param {number} slices to play
* @example * @example
* note("0 1 2 3 4 5 6 7".scale('c:mixolydian')) * 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 * @example
* sound("bd - bd bd*2, - sd:6 - sd:5 sd:1 - [- sd:2] -, hh [- cp:7]") * sound("bd - bd bd*2, - sd:6 - sd:5 sd:1 - [- sd:2] -, hh [- cp:7]")
* .bank("RolandTR909").speed(1.2) .bank("RolandTR909").speed(1.2)
* .bite(4, "0 0 [1 2] <3 2> 0 0 [2 1] 3") .bite(4, "0 0 [1 2] <3 2> 0 0 [2 1] 3")
*/ */
export const bite = register( export const bite = register(
'bite', 'bite',
@@ -2586,74 +2257,6 @@ export const bite = register(
false, 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. * Selects the given fraction of the pattern and repeats that part to fill the remainder of the cycle.
* @tags temporal * @tags temporal
@@ -2760,12 +2363,8 @@ export const off = register('off', function (time_pat, func, pat) {
* Returns a new pattern where every other cycle is played once, twice as * 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 * fast, and offset in time by one quarter of a cycle. Creates a kind of
* breakbeat feel. * breakbeat feel.
* @name brak
* @tags temporal * @tags temporal
* @returns Pattern * @returns Pattern
* @memberof Pattern
* @example
* s("bd hh!2 rim").brak()
*/ */
export const brak = register('brak', function (pat) { export const brak = register('brak', function (pat) {
return pat.when(slowcat(false, true), (x) => fastcat(x, silence)._late(0.25)); return pat.when(slowcat(false, true), (x) => fastcat(x, silence)._late(0.25));
@@ -2898,20 +2497,6 @@ export const { juxBy, juxby } = register(['juxBy', 'juxby'], function (by, func,
return stack(left, right).setSteps(__steps ? lcm(left._steps, right._steps) : undefined); return stack(left, right).setSteps(__steps ? lcm(left._steps, right._steps) : undefined);
}); });
/**
* Like juxBy, except it flips the ears each cycle.
* @name juxFlipBy
* @synonyms juxflipby, fluxBy, fluxby
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlipBy(".8", rev)
*/
export const { juxFlipBy, juxflipby, fluxBy, fluxby } = register(
['juxFlipBy', 'juxflipby', 'fluxBy', 'fluxby'],
function (by, func, pat) {
return pat.juxBy(slowcat(by, -by), func);
},
);
/** /**
* The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel. * The jux function creates strange stereo effects, by applying a function to a pattern, but only in the right-hand channel.
* @tags temporal, superdough * @tags temporal, superdough
@@ -2926,21 +2511,6 @@ export const jux = register('jux', function (func, pat) {
return pat._juxBy(1, func, pat); return pat._juxBy(1, func, pat);
}); });
/**
* Like jux, but flips the ears each cycle.
* @name juxFlip
* @synonyms juxflip, flux
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(rev)
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(press)
* @example
* s("bd lt [~ ht] mt cp ~ bd hh").juxFlip(iter(4))
*/
export const { juxFlip, flux } = register(['juxFlip', 'juxflip', 'flux'], function (func, pat) {
return pat._juxFlipBy(1, func, pat);
});
/** /**
* Superimpose and offset multiple times, applying the given function each time. * Superimpose and offset multiple times, applying the given function each time.
* @tags temporal, functional * @tags temporal, functional
@@ -3254,29 +2824,10 @@ export const { ribbon, rib } = register(['ribbon', 'rib'], (offset, cycles, pat)
pat.early(offset).restart(pure(1).slow(cycles)), 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) => { export const hsla = register('hsla', (h, s, l, a, pat) => {
return pat.color(`hsla(${h}turn,${s * 100}%,${l * 100}%,${a})`); 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) => { export const hsl = register('hsl', (h, s, l, pat) => {
return pat.color(`hsl(${h}turn,${s * 100}%,${l * 100}%)`); return pat.color(`hsl(${h}turn,${s * 100}%,${l * 100}%)`);
}); });
@@ -3632,7 +3183,7 @@ export const drop = stepRegister('drop', function (i, pat) {
* @tags stepwise * @tags stepwise
* @example * @example
* stepcat( * stepcat(
* sound("bd bd - <cp hh>").extend(2), * sound("bd bd - cp").extend(2),
* sound("bd - sd -") * sound("bd - sd -")
* ).pace(8) * ).pace(8)
*/ */
@@ -3643,12 +3194,15 @@ export const extend = stepRegister('extend', function (factor, pat) {
/** /**
* *Experimental* * *Experimental*
* *
* `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. * `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"`.
* *
* TODO: find out how this function differs from extend
* @tags stepwise * @tags stepwise
* @example * @example
* stepcat( * stepcat(
* sound("bd bd - <cp hh>").replicate(2), * sound("bd bd - cp").replicate(2),
* sound("bd - sd -") * sound("bd - sd -")
* ).pace(8) * ).pace(8)
*/ */
@@ -3721,11 +3275,6 @@ Pattern.prototype.shrinklist = function (amount) {
export const shrinklist = (amount, pat) => pat.shrinklist(amount); export const shrinklist = (amount, pat) => pat.shrinklist(amount);
Pattern.prototype.growlist = function (amount) {
return this.shrinklist(amount).reverse();
};
export const growlist = (amount, pat) => pat.growlist(amount);
/** /**
* *Experimental* * *Experimental*
* *
@@ -3744,8 +3293,8 @@ export const growlist = (amount, pat) => pat.growlist(amount);
* "tha dhi thom nam".shrink("1 -1").sound().bank("mridangam").pace(4) * "tha dhi thom nam".shrink("1 -1").sound().bank("mridangam").pace(4)
* @example * @example
* note("0 1 2 3 4 5 6 7".scale("C:ritusen")).sound("folkharp") * 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( export const shrink = register(
@@ -3784,7 +3333,7 @@ export const shrink = register(
* "tha dhi thom nam".grow("1 -1").sound().bank("mridangam").pace(4) * "tha dhi thom nam".grow("1 -1").sound().bank("mridangam").pace(4)
* @example * @example
* note("0 1 2 3 4 5 6 7".scale("C:ritusen")).sound("folkharp") * 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( export const grow = register(
@@ -3808,17 +3357,17 @@ export const grow = register(
/** /**
* *Experimental* * *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 * 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 * on successive repetitions. The patterns are added together stepwise, with all repetitions taking place over a single cycle. Using `pace` to set the
* number of steps per cycle is therefore usually recommended. * number of steps per cycle is therefore usually recommended.
* *
* @tags stepwise * @tags stepwise
* @return {Pattern} * @return {Pattern}
* @example * @example
* "[c g]".tour("e f", "e f g", "g f e c").note() * "[c g]".tour("e f", "e f g", "g f e c").note()
* .sound("folkharp") .sound("folkharp")
* .pace(8) .pace(8)
*/ */
export const tour = function (pat, ...many) { export const tour = function (pat, ...many) {
return pat.tour(...many); return pat.tour(...many);
@@ -3836,16 +3385,16 @@ Pattern.prototype.tour = function (...many) {
/** /**
* *Experimental* * *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. * Using `pace` to set the number of steps per cycle is therefore usually recommended.
* *
* @tags stepwise * @tags stepwise
* @returns {Pattern} * @returns {Pattern}
* @example * @example
* zip("e f", "e f g", "g [f e] a f4 c").note() * zip("e f", "e f g", "g [f e] a f4 c").note()
* .sound("folkharp") .sound("folkharp")
* .pace(8) .pace(8)
*/ */
export const zip = function (...pats) { export const zip = function (...pats) {
pats = pats.filter((pat) => pat.hasSteps); pats = pats.filter((pat) => pat.hasSteps);
@@ -4008,9 +3557,9 @@ export const slice = register(
* s("bd!8").onTriggerTime((hap) => {console.log(hap)}) * s("bd!8").onTriggerTime((hap) => {console.log(hap)})
*/ */
Pattern.prototype.onTriggerTime = function (func) { Pattern.prototype.onTriggerTime = function (func) {
return this.onSchedule((hap, targetTime) => { return this.onTrigger((hap, currentTime, _cps, targetTime) => {
const diff = targetTime - getClockTime(); const diff = targetTime - currentTime;
setTimeout(() => { window.setTimeout(() => {
func(hap); func(hap);
}, diff * 1000); }, diff * 1000);
}, false); }, false);
@@ -4494,59 +4043,3 @@ Pattern.prototype.worklet = function (src, ...inputs) {
}; };
export const worklet = (...args) => pure({}).worklet(...args); export const worklet = (...args) => pure({}).worklet(...args);
/**
* Creates a pattern of numbers in base b from a number or pattern of numbers
* limited to d digits long from the right
*
* @name base
* @tags generators
* @param {number} n - number to convert (can be a pattern or array)
* @param {number} b - base to convert to (defaults to 10) (can be a pattern)
* @param {number} d - max number of digits to produce for each n (defaults to 0 for all) (can be a pattern)
* @example
* $: note(base("7175 543", 10, 3)).scale("c:major").s("saw")
* // $: note("1 7 5 5 4 3").scale("c:major").s("saw")
*/
export const base = (n, b = 10, d = 0) => {
if (Array.isArray(n)) {
n = sequence(n);
}
n = reify(n);
b = reify(b);
d = reify(d);
return d
.withValue((e) => {
return b
.withValue((c) => {
return n
.withValue((v) => {
let digits = [];
let value = v;
while (value > 0) {
digits.unshift(value % c);
value = Math.floor(value / c);
}
if (e) {
const l = digits.length;
if (l > e) {
digits = digits.slice(-1 * e);
}
/*
if (l < e){
for (let i = l; i < e; i++) {
digits.unshift("~");//0); //Would like to be padding this but ~- doesn't work
}
console.log("digits", digits);
}
*/
}
return sequence(digits);
})
.squeezeJoin();
})
.squeezeJoin();
})
.squeezeJoin();
};
-2
View File
@@ -104,8 +104,6 @@ export const pickmodF = register('pickmodF', function (pickPattern, lookup, pat)
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @param {*} xs
* @returns {Pattern} * @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) { export const pickOut = register('pickOut', function (lookup, pat) {
return _pick(lookup, pat, false).outerJoin(); return _pick(lookup, pat, false).outerJoin();
+313 -67
View File
@@ -7,15 +7,13 @@ import {
setIsStarted, setIsStarted,
setPattern as exposeSchedulerPattern, setPattern as exposeSchedulerPattern,
setTime, setTime,
setScheduleFunc, setTriggerFunc,
setClockTime,
} from './schedulerState.mjs'; } from './schedulerState.mjs';
import { evalScope } from './evaluate.mjs'; import { evalScope } from './evaluate.mjs';
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
import { reset_state } from './impure.mjs'; import { reset_state } from './impure.mjs';
export function repl({ export function repl({
onSchedule,
defaultOutput, defaultOutput,
onEvalError, onEvalError,
beforeEval, beforeEval,
@@ -32,11 +30,6 @@ export function repl({
id, id,
mondo = false, 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 = { const state = {
schedulerError: undefined, schedulerError: undefined,
evalError: undefined, evalError: undefined,
@@ -45,6 +38,7 @@ export function repl({
pattern: undefined, pattern: undefined,
miniLocations: [], miniLocations: [],
widgets: [], widgets: [],
sliders: [],
pending: false, pending: false,
started: false, started: false,
}; };
@@ -61,7 +55,7 @@ export function repl({
}; };
const schedulerOptions = { const schedulerOptions = {
onSchedule: getSchedule({ onSchedule }), onTrigger: getTrigger({ defaultOutput, getTime }),
getTime, getTime,
onToggle: (started) => { onToggle: (started) => {
updateState({ started }); updateState({ started });
@@ -79,18 +73,139 @@ export function repl({
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome // NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
const scheduler = const scheduler =
sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions); sync && typeof SharedWorker != 'undefined' ? new NeoCyclist(schedulerOptions) : new Cyclist(schedulerOptions);
setScheduleFunc(schedulerOptions.onSchedule); setTriggerFunc(schedulerOptions.onTrigger);
setCpsFunc(() => scheduler.cps); setCpsFunc(() => scheduler.cps);
let pPatterns = {}; let pPatterns = {};
let anonymousIndex = 0; let anonymousIndex = 0;
let allTransform; let allTransform;
let eachTransform; let eachTransform;
// Block-based evaluation state
let codeBlocks = {};
let lastActiveVisualizerLabel = null;
// Track which patterns belong to which blocks: { blockRange: [patternKeys] }
let blockPatterns = new Map();
// Helper function to collect properties from all code blocks (handles both labeled and anonymous blocks)
function collectFromBlocks(property) {
return Object.entries(codeBlocks).flatMap(([key, block]) => {
if (key === '$') {
// Anonymous blocks are stored as an array of block objects
return Array.isArray(block) ? block.flatMap((b) => b[property] || []) : [];
}
// Labeled blocks are stored as single block objects
return block[property] || [];
});
}
// Helper function to process a single labeled block
function processLabeledBlock(labels, i, code, options, meta) {
const label = labels[i];
const nextLabel = labels[i + 1] || { index: code.length, end: code.length };
const labelCode = code.slice(label.index, nextLabel.index);
const labelRange = [label.index + options.range[0], label.end + options.range[0]];
// Calculate the full block range (from label start to next label start)
const blockStart = label.index + options.range[0];
const blockEnd = nextLabel.index + options.range[0];
const blockWidgets = (meta?.widgets || []).filter((widget) => {
const widgetPos = widget.from ?? widget.index ?? 0;
return widgetPos >= blockStart && widgetPos < blockEnd;
});
const blockSliders = (meta?.sliders || []).filter((slider) => {
const sliderPos = slider.from ?? slider.index ?? 0;
return sliderPos >= blockStart && sliderPos < blockEnd;
});
const blockMiniLocations = (meta?.miniLocations || []).filter((loc) => {
// const locStart = loc.start ?? loc.from ?? 0;
// mini locations can be either [start, end] arrays or objects with start/from
const locStart = Array.isArray(loc) ? loc[0] : (loc.start ?? loc.from ?? 0);
return locStart >= blockStart && locStart < blockEnd;
});
handleSingleLabelBlock(
label,
labelCode,
{ ...options, range: labelRange },
{ widgets: blockWidgets, sliders: blockSliders, miniLocations: blockMiniLocations },
);
}
// helper
function cleanupConflictingRanges(codeBlocks, currentKey, newRange) {
for (const [existingKey, existingBlock] of Object.entries(codeBlocks)) {
if (existingKey === currentKey) continue;
if (!existingBlock.range) continue;
const [existingStart, existingEnd] = existingBlock.range;
const [newStart, newEnd] = newRange;
// If ranges overlap (not just touch), remove the stale block
if (!(newEnd <= existingStart || newStart >= existingEnd)) {
delete codeBlocks[existingKey];
}
}
}
// helper
function handleSingleLabelBlock(label, code, options, meta) {
// Detect if this block contains a non-inline widget
// The activeVisualizer is now provided by the transpiler for all labels
const activeVisualizer = label.activeVisualizer || null;
if (activeVisualizer !== null) {
lastActiveVisualizerLabel = label.name;
}
// Store the entire code block under the label name
codeBlocks[label.name] = {
code: code,
range: options.range,
labels: [label.name],
miniLocations: meta?.miniLocations || [],
widgets: meta?.widgets || [],
sliders: meta?.sliders || [],
activeVisualizer: activeVisualizer, // Store the widget type if present, null otherwise
};
// Clean up any blocks with conflicting ranges (including declaration blocks)
cleanupConflictingRanges(codeBlocks, label.name, options.range);
}
// helper
// These blocks return silence but may contain mini notation strings that need highlighting
function handleDeclarationBlock(code, options, meta) {
const range = options.range || [];
if (range.length < 2) return;
const blockKey = `_decl:${range[0]}:${range[1]}`;
codeBlocks[blockKey] = {
code: code,
range: range,
labels: [],
miniLocations: meta?.miniLocations || [],
widgets: meta?.widgets || [],
sliders: meta?.sliders || [],
activeVisualizer: null,
};
// Clean up any overlapping declaration blocks
cleanupConflictingRanges(codeBlocks, blockKey, range);
}
const hush = function () { const hush = function () {
pPatterns = {}; pPatterns = {};
anonymousIndex = 0; anonymousIndex = 0;
allTransform = undefined; allTransform = undefined;
eachTransform = undefined; eachTransform = undefined;
codeBlocks = {};
blockPatterns.clear();
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
return silence; return silence;
}; };
@@ -109,9 +224,61 @@ export function repl({
return pattern; return pattern;
}; };
setTime(() => scheduler.now()); // TODO: refactor? setTime(() => scheduler.now()); // TODO: refactor?
setClockTime(() => scheduler.getTime()); // TODO: refactor?
const stop = () => scheduler.stop(); // Helper function to apply pattern transformations (solo, each, all)
// this should be abstracted more
function applyPatternTransforms(pattern) {
const allPatterns = Object.values(pPatterns);
if (allPatterns.length) {
let patterns = [];
let soloActive = false;
for (const [key, value] of Object.entries(pPatterns)) {
// handle soloed patterns ex: S$: s("bd!4")
const isSolod = key.length > 1 && key.startsWith('S');
if (isSolod && soloActive === false) {
// first time we see a soloed pattern, clear existing patterns
patterns = [];
soloActive = true;
}
if (!soloActive || (soloActive && isSolod)) {
const valWithState = value.withState((state) => state.setControls({ id: key }));
patterns.push(valWithState);
}
}
if (eachTransform) {
// Explicit lambda so only element (not index and array) are passed
patterns = patterns.map((x) => eachTransform(x));
}
pattern = stack(...patterns);
} else if (eachTransform) {
pattern = eachTransform(pattern);
}
if (allTransforms.length) {
for (const transform of allTransforms) {
pattern = transform(pattern);
}
}
if (!isPattern(pattern)) {
pattern = silence;
}
return pattern;
}
const stop = () => {
codeBlocks = {};
blockPatterns.clear();
pPatterns = {};
lastActiveVisualizerLabel = null; // Reset 'all' visualizer tracking
updateState({
miniLocations: [],
widgets: [],
sliders: [],
});
scheduler.stop();
};
const start = () => scheduler.start(); const start = () => scheduler.start();
const pause = () => scheduler.pause(); const pause = () => scheduler.pause();
const toggle = () => scheduler.toggle(); const toggle = () => scheduler.toggle();
@@ -140,12 +307,8 @@ export function repl({
let allTransforms = []; let allTransforms = [];
/** /**
* Applies a function to all the running patterns. Note that the patterns are grouped together into a single `stack` before the function is applied. This is probably what you want, but see `each` for * Applies a function to all the running patterns. Note that the patterns are groups together into a single `stack` before the function is applied. This is probably what you want, but see `each` for
* a version that applies the function to each pattern separately. * a version that applies the function to each pattern separately.
*
* **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `all`. An unlabeled
* pattern such as `note("c4")` is not registered and will produce no audio when `all` is present.
* Use `$: note("c4")` instead.
* ``` * ```
* $: sound("bd - cp sd") * $: sound("bd - cp sd")
* $: sound("hh*8") * $: sound("hh*8")
@@ -165,9 +328,6 @@ export function repl({
}; };
/** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern. /** Applies a function to each of the running patterns separately. This is intended for future use with upcoming 'stepwise' features. See `all` for a version that applies the function to all the patterns stacked together into a single pattern.
* *
* **Note:** Patterns must be labeled (e.g. with `$:`) to be picked up by `each`. An unlabeled
* pattern such as `note("c4")` is not registered and will produce no audio when `each` is present.
* Use `$: note("c4")` instead.
* ``` * ```
* $: sound("bd - cp sd") * $: sound("bd - cp sd")
* $: sound("hh*8") * $: sound("hh*8")
@@ -232,7 +392,7 @@ export function repl({
}); });
}; };
const evaluate = async (code, autostart = true, shouldHush = true) => { const evaluate = async (code, autostart = true) => {
if (!code) { if (!code) {
throw new Error('no code to evaluate'); throw new Error('no code to evaluate');
} }
@@ -240,48 +400,19 @@ export function repl({
updateState({ code, pending: true }); updateState({ code, pending: true });
await injectPatternMethods(); await injectPatternMethods();
setTime(() => scheduler.now()); // TODO: refactor? setTime(() => scheduler.now()); // TODO: refactor?
setClockTime(() => scheduler.getTime()); // TODO: refactor? await beforeEval?.({ code, blockBased: false });
await beforeEval?.({ code });
allTransforms = []; // reset all transforms allTransforms = []; // reset all transforms
shouldHush && hush();
codeBlocks = {};
hush();
if (mondo) { if (mondo) {
code = `mondolang\`${code}\``; code = `mondolang\`${code}\``;
} }
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
if (Object.keys(pPatterns).length) {
let patterns = [];
let soloActive = false;
for (const [key, value] of Object.entries(pPatterns)) {
// handle soloed patterns ex: S$: s("bd!4")
const isSolod = key.length > 1 && key.startsWith('S');
if (isSolod && soloActive === false) {
// first time we see a soloed pattern, clear existing patterns
patterns = [];
soloActive = true;
}
if (!soloActive || (soloActive && isSolod)) {
const valWithState = value.withState((state) => state.setControls({ id: key }));
patterns.push(valWithState);
}
}
if (eachTransform) {
// Explicit lambda so only element (not index and array) are passed
patterns = patterns.map((x) => eachTransform(x));
}
pattern = stack(...patterns);
} else if (eachTransform) {
pattern = eachTransform(pattern);
}
if (allTransforms.length) {
for (const transform of allTransforms) {
pattern = transform(pattern);
}
}
if (!isPattern(pattern)) { let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
pattern = silence;
} pattern = applyPatternTransforms(pattern);
logger(`[eval] code updated`); logger(`[eval] code updated`);
pattern = await setPattern(pattern, autostart); pattern = await setPattern(pattern, autostart);
@@ -296,7 +427,119 @@ export function repl({
pending: false, pending: false,
}); });
afterEval?.({ code, pattern, meta }); 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;
}
}
}
pPatterns = Object.fromEntries(
Object.entries(pPatterns).filter(([key]) => {
return Object.keys(codeBlocks).includes(key);
}),
);
pattern = applyPatternTransforms(pattern);
logger(`[eval] code updated`);
pattern = await setPattern(pattern, autostart);
updateState({
miniLocations: meta?.miniLocations || [],
widgets: meta?.widgets || [],
sliders: meta?.sliders || [],
activeCode: code,
pattern,
evalError: undefined,
schedulerError: undefined,
pending: false,
});
afterEval?.({ code, pattern, meta, range: options.range, widgetRemoved });
return pattern; return pattern;
} catch (err) { } catch (err) {
logger(`[eval] error: ${err.message}`, 'error'); logger(`[eval] error: ${err.message}`, 'error');
@@ -307,20 +550,23 @@ export function repl({
}; };
const setCode = (code) => updateState({ code }); const setCode = (code) => updateState({ code });
return { scheduler, evaluate, start, stop, pause, setCps, setPattern, setCode, toggle, state }; return { scheduler, evaluate, evaluateBlock, start, stop, pause, setCps, setPattern, setCode, toggle, state };
} }
export const getSchedule = export const getTrigger =
({ onSchedule }) => ({ getTime, defaultOutput }) =>
async (hap, t, cps) => { 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
try { try {
if (!hap.context.onSchedule || !hap.context.dominantSchedule) { if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
await onSchedule(hap, t, cps); await defaultOutput(hap, deadline, duration, cps, t);
} }
if (hap.context.onSchedule) { if (hap.context.onTrigger) {
await hap.context.onSchedule(hap, t, cps); // call signature of output / onTrigger is different...
await hap.context.onTrigger(hap, getTime(), cps, t);
} }
} catch (err) { } catch (err) {
errorLogger(err, 'getSchedule'); errorLogger(err, 'getTrigger');
} }
}; };
+5 -33
View File
@@ -7,14 +7,8 @@ This program is free software: you can redistribute it and/or modify it under th
let time; let time;
let cpsFunc; let cpsFunc;
let pattern; let pattern;
let scheduleFunc; let triggerFunc;
let isStarted; let isStarted;
/**
* @name getTime
* @tags internals
* @returns {Number} the current cycle time as a number
*/
export function getTime() { export function getTime() {
if (!time) { if (!time) {
throw new Error('no time set! use setTime to define a time source'); throw new Error('no time set! use setTime to define a time source');
@@ -26,32 +20,10 @@ export function setTime(func) {
time = 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) { export function setCpsFunc(func) {
cpsFunc = func; cpsFunc = func;
} }
/**
* @name getCps
* @tags internals
* @returns {Number} the current cycles per second
*/
export function getCps() { export function getCps() {
return cpsFunc?.(); return cpsFunc?.();
} }
@@ -64,12 +36,12 @@ export function getPattern() {
return pattern; return pattern;
} }
export function setScheduleFunc(func) { export function setTriggerFunc(func) {
scheduleFunc = func; triggerFunc = func;
} }
export function getScheduleFunc() { export function getTriggerFunc() {
return scheduleFunc; return triggerFunc;
} }
export function setIsStarted(val) { export function setIsStarted(val) {
+16 -104
View File
@@ -5,8 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import { Hap } from './hap.mjs'; import { Hap } from './hap.mjs';
import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP, isPattern } from './pattern.mjs'; import { Pattern, fastcat, pure, register, reify, silence, stack, sequenceP } from './pattern.mjs';
import { _mod } from './util.mjs';
import Fraction from './fraction.mjs'; import Fraction from './fraction.mjs';
import { id, keyAlias, getCurrentKeyboardState } from './util.mjs'; import { id, keyAlias, getCurrentKeyboardState } from './util.mjs';
@@ -16,24 +15,6 @@ export function steady(value) {
return new Pattern((state) => [new Hap(undefined, state.span, 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) => { export const signal = (func) => {
const query = (state) => [new Hap(undefined, state.span, func(state.span.begin, state.controls))]; const query = (state) => [new Hap(undefined, state.span, func(state.span.begin, state.controls))];
return new Pattern(query); return new Pattern(query);
@@ -52,7 +33,7 @@ export const signal = (func) => {
* .scale('C major') * .scale('C major')
* *
*/ */
export const saw = signal((t) => _mod(t, 1)); export const saw = signal((t) => t % 1);
/** /**
* A sawtooth signal between -1 and 1 (like `saw`, but bipolar). * A sawtooth signal between -1 and 1 (like `saw`, but bipolar).
@@ -75,7 +56,7 @@ export const saw2 = saw.toBipolar();
* .scale('C major') * .scale('C major')
* *
*/ */
export const isaw = signal((t) => 1 - _mod(t, 1)); export const isaw = signal((t) => 1 - (t % 1));
/** /**
* A sawtooth signal between 1 and -1 (like `saw2`, but flipped). * A sawtooth signal between 1 and -1 (like `saw2`, but flipped).
@@ -132,7 +113,7 @@ export const cosine2 = sine2._early(Fraction(1).div(4));
* n(square.segment(4).range(0,7)).scale("C:minor") * n(square.segment(4).range(0,7)).scale("C:minor")
* *
*/ */
export const square = signal((t) => Math.floor(_mod(t * 2, 2))); export const square = signal((t) => Math.floor((t * 2) % 2));
/** /**
* A square signal between -1 and 1 (like `square`, but bipolar). * A square signal between -1 and 1 (like `square`, but bipolar).
@@ -142,22 +123,6 @@ export const square = signal((t) => Math.floor(_mod(t * 2, 2)));
*/ */
export const square2 = square.toBipolar(); 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. * A triangle signal between 0 and 1.
* *
@@ -207,7 +172,6 @@ export const time = signal(id);
/** /**
* The mouse's x position value ranges from 0 to 1. * The mouse's x position value ranges from 0 to 1.
* @name mousex * @name mousex
* @synonyms mouseX
* @return {Pattern} * @return {Pattern}
* @tags external_io * @tags external_io
* @example * @example
@@ -218,7 +182,6 @@ export const time = signal(id);
/** /**
* The mouse's y position value ranges from 0 to 1. * The mouse's y position value ranges from 0 to 1.
* @name mousey * @name mousey
* @synonyms mouseY
* @return {Pattern} * @return {Pattern}
* @tags external_io * @tags external_io
* @example * @example
@@ -353,7 +316,7 @@ export const run = (n) => saw.range(0, n).round().segment(n);
* // "hh".s().struct("1 0 1") * // "hh".s().struct("1 0 1")
*/ */
export const binary = (n) => { export const binary = (n) => {
const nBits = reify(n).log2().floor().add(1); const nBits = reify(n).log2(0).floor().add(1);
return binaryN(n, nBits); return binaryN(n, nBits);
}; };
@@ -385,7 +348,7 @@ export const binaryN = (n, nBits = 16) => {
* .partials(binaryL(irand(4096).add(1))) * .partials(binaryL(irand(4096).add(1)))
*/ */
export const binaryL = (n) => { export const binaryL = (n) => {
const nBits = reify(n).log2().floor().add(1); const nBits = reify(n).log2(0).floor().add(1);
return binaryNL(n, nBits); return binaryNL(n, nBits);
}; };
@@ -426,14 +389,12 @@ export const randL = (n) => {
export const randrun = (n) => { export const randrun = (n) => {
return signal((t, controls) => { return signal((t, controls) => {
// Without adding 0.5, the first cycle is always 0,1,2,3,... // Without adding 0.5, the first cycle is always 0,1,2,3,...
let rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed); const rands = getRandsAtTime(t.floor().add(0.5), n, controls.randSeed);
// Support n = 1
if (!Array.isArray(rands)) rands = [rands];
const nums = rands const nums = rands
.map((n, i) => [n, i]) .map((n, i) => [n, i])
.sort((a, b) => (a[0] > b[0]) - (a[0] < b[0])) .sort((a, b) => (a[0] > b[0]) - (a[0] < b[0]))
.map((x) => x[1]); .map((x) => x[1]);
const i = _mod(t.cyclePos().mul(n).floor(), n); const i = t.cyclePos().mul(n).floor() % n;
return nums[i]; return nums[i];
})._segment(n); })._segment(n);
}; };
@@ -579,9 +540,6 @@ export const __chooseWith = (pat, xs) => {
* @returns {Pattern} * @returns {Pattern}
* @example * @example
* note("c2 g2!2 d2 f1").s(chooseWith(sine.fast(2), ["sawtooth", "triangle", "bd:6"])) * 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) => { export const chooseWith = (pat, xs) => {
return __chooseWith(pat, xs).outerJoin(); return __chooseWith(pat, xs).outerJoin();
@@ -593,13 +551,6 @@ export const chooseWith = (pat, xs) => {
* @tags temporal * @tags temporal
* @param {Pattern} pat * @param {Pattern} pat
* @param {*} xs * @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} * @returns {Pattern}
*/ */
export const chooseInWith = (pat, xs) => { export const chooseInWith = (pat, xs) => {
@@ -616,7 +567,9 @@ export const chooseInWith = (pat, xs) => {
*/ */
export const choose = (...xs) => chooseWith(rand, xs); export const choose = (...xs) => chooseWith(rand, xs);
// todo: doc
export const chooseIn = (...xs) => chooseInWith(rand, xs); export const chooseIn = (...xs) => chooseInWith(rand, xs);
export const chooseOut = choose;
/** /**
* Chooses from the given list of values (or patterns of values), according * Chooses from the given list of values (or patterns of values), according
@@ -762,30 +715,12 @@ export const perlin = signal((t, controls) => _perlin(t, controls.randSeed));
*/ */
export const berlin = signal((t, controls) => _berlin(t, controls.randSeed)); export const berlin = signal((t, controls) => _berlin(t, controls.randSeed));
/** export const degradeByWith = register(
* `degradeByWith` is like `degradeBy`, but with control over the random or other signal used to filter out the events. 'degradeByWith',
* (withPat, x, pat) => pat.fmap((a) => (_) => a).appLeft(withPat.filterValues((v) => v > x)),
* `degradeBy(x)` is essentially `degradeByWith(rand, x)` true,
* true,
* 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. * Randomly removes events from the pattern by a given amount.
@@ -1143,26 +1078,3 @@ export const perx = new Pattern(function (state) {
const n = Fraction(1).div(state.span.duration); const n = Fraction(1).div(state.span.duration);
return [new Hap(undefined, state.span, Math.log(n) / Math.log(2) + 1)]; 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));
+1 -1
View File
@@ -32,7 +32,7 @@ function triggerSpeech(words, lang, voice) {
} }
export const speak = register('speak', function (lang, voice, pat) { export const speak = register('speak', function (lang, voice, pat) {
return pat.onSchedule((hap) => { return pat.onTrigger((hap) => {
triggerSpeech(hap.value, lang, voice); triggerSpeech(hap.value, lang, voice);
}); });
}); });
-77
View File
@@ -29,44 +29,6 @@ describe('euclid', () => {
.map((a) => a.showWhole(true)), .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']); ).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', () => { describe('euclidRot', () => {
@@ -105,45 +67,6 @@ describe('euclidLegato', () => {
.map((a) => a.showWhole(true)), .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']); ).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', () => { describe('euclidLegatoRot', () => {
+3 -72
View File
@@ -18,7 +18,6 @@ import {
fastcat, fastcat,
firstOf, firstOf,
slowcat, slowcat,
slowcatPrime,
cat, cat,
sequence, sequence,
palindrome, palindrome,
@@ -46,7 +45,6 @@ import {
rev, rev,
time, time,
run, run,
randrun,
binaryN, binaryN,
pick, pick,
stackLeft, stackLeft,
@@ -55,10 +53,9 @@ import {
stepcat, stepcat,
sometimes, sometimes,
expand, expand,
arp,
} from '../index.mjs'; } from '../index.mjs';
import { log, logValues, stackCenter } from '../pattern.mjs'; import { log, logValues } from '../pattern.mjs';
import { steady } from '../signal.mjs'; import { steady } from '../signal.mjs';
@@ -75,10 +72,6 @@ const sameFirst = (a, b) => {
return expect(a.sortHapsByPart().firstCycle()).toStrictEqual(b.sortHapsByPart().firstCycle()); 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('TimeSpan', () => {
describe('equals()', () => { describe('equals()', () => {
it('Should be equal to the same value', () => { it('Should be equal to the same value', () => {
@@ -551,9 +544,6 @@ describe('Pattern', () => {
}); });
}); });
describe('slowcat()', () => { describe('slowcat()', () => {
it('Can be empty', () => {
expect(slowcat().firstCycle()).toStrictEqual([]);
});
it('Can concatenate things slowly', () => { it('Can concatenate things slowly', () => {
expect( expect(
slowcat('a', 'b') slowcat('a', 'b')
@@ -586,38 +576,6 @@ describe('Pattern', () => {
sameFirst(slowcat('a', ['b', 'c']).fast(4), sequence('a', ['b', 'c']).fast(2)); 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()', () => { describe('rev()', () => {
it('Can reverse things', () => { it('Can reverse things', () => {
expect( expect(
@@ -987,17 +945,6 @@ describe('Pattern', () => {
it('Can run', () => { it('Can run', () => {
expect(run(4).firstCycle()).toStrictEqual(sequence(0, 1, 2, 3).firstCycle()); 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', () => { describe('binaryN', () => {
it('Can make a binary pattern from a decimal', () => { it('Can make a binary pattern from a decimal', () => {
@@ -1219,7 +1166,6 @@ describe('Pattern', () => {
expect(stackRight(fastcat(0, 1, 2), fastcat(3, 4))._steps).toStrictEqual(Fraction(3)); 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 // 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(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).ply(3)._steps).toStrictEqual(Fraction(6));
expect(fastcat(0, 1).setSteps(undefined).ply(3)._steps).toStrictEqual(undefined); expect(fastcat(0, 1).setSteps(undefined).ply(3)._steps).toStrictEqual(undefined);
expect(fastcat(0, 1).fast(3)._steps).toStrictEqual(Fraction(2)); expect(fastcat(0, 1).fast(3)._steps).toStrictEqual(Fraction(2));
@@ -1325,21 +1271,6 @@ 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', () => { describe('unjoin', () => {
it('destructures a pattern into subcycles', () => { it('destructures a pattern into subcycles', () => {
sameFirst( sameFirst(
@@ -1383,7 +1314,7 @@ describe('Pattern', () => {
// Force a trigger // Force a trigger
haps.forEach((hap) => { haps.forEach((hap) => {
hap.context?.onSchedule?.(hap); hap.context?.onTrigger?.(hap);
}); });
expect(mockConsoleLog).toHaveBeenCalledWith( expect(mockConsoleLog).toHaveBeenCalledWith(
@@ -1401,7 +1332,7 @@ describe('Pattern', () => {
// Force a trigger // Force a trigger
haps.forEach((hap) => { haps.forEach((hap) => {
hap.context?.onSchedule?.(hap); hap.context?.onTrigger?.(hap);
}); });
expect(mockConsoleLog).toHaveBeenCalledWith( expect(mockConsoleLog).toHaveBeenCalledWith(
+7 -73
View File
@@ -8,24 +8,8 @@ import Fraction from 'fraction.js';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { import { saw, saw2, isaw, isaw2, per, perx, cyclesPer } from '../signal.mjs';
saw, import { fastcat, sequence, State, TimeSpan, Hap } from '../index.mjs';
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)); const st = (begin, end) => new State(ts(begin, end));
const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end)); const ts = (begin, end) => new TimeSpan(Fraction(begin), Fraction(end));
@@ -40,61 +24,17 @@ const sameFirst = (a, b) => {
describe('signal()', () => { describe('signal()', () => {
it('Can make saw/saw2', () => { it('Can make saw/saw2', () => {
expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(0, 0.25, 0.5, 0.75).firstCycle()); expect(saw.struct(true, true, true, true).firstCycle()).toStrictEqual(
sequence(0, 1 / 4, 1 / 2, 3 / 4).firstCycle(),
);
expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle()); expect(saw2.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(-1, -0.5, 0, 0.5).firstCycle());
}); });
it('Can make isaw/isaw2', () => { it('Can make isaw/isaw2', () => {
expect(isaw.struct(true, true, true, true).firstCycle()).toStrictEqual(sequence(1, 0.75, 0.5, 0.25).firstCycle()); 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()); 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', () => { describe('cyclesPer', () => {
@@ -119,9 +59,3 @@ describe('perx', () => {
); );
}); });
}); });
describe('shuffle', () => {
it('returns original pattern if input is 1', () => {
expect(note('c d e f').sound('piano').shuffle(1).firstCycle()).toEqual(note('c d e f').sound('piano').firstCycle());
});
});
+15 -29
View File
@@ -70,6 +70,17 @@ export const getEventOffsetMs = (targetTimeSeconds, currentTimeSeconds) => {
return (targetTimeSeconds - currentTimeSeconds) * 1000; 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']; const pcs = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
/** /**
* @deprecated only used in workshop (first-notes) * @deprecated only used in workshop (first-notes)
@@ -119,50 +130,25 @@ export const getPlayableNoteValue = (hap) => {
return note; 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) => { export const getFrequency = (hap) => {
let { value, context } = let { value, context } = hap;
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 value is number => interpret as midi number as long as its not marked as frequency
if (typeof value === 'object') { if (typeof value === 'object') {
if (value.freq) { if (value.freq) {
return 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') { if (typeof value === 'number' && context.type !== 'frequency') {
value = midiToFreq(value); value = midiToFreq(hap.value);
} else if (typeof value === 'string' && isNote(value)) { } else if (typeof value === 'string' && isNote(value)) {
value = midiToFreq(noteToMidi(value)); value = midiToFreq(noteToMidi(hap.value));
} else if (typeof value !== 'number') { } else if (typeof value !== 'number') {
throw new Error('not a note or frequency: ' + value); throw new Error('not a note or frequency: ' + value);
} }
return value; return value;
}; };
export const getFreq = (hap) => getFrequency(hap);
// rotate array by n steps (to the left) // rotate array by n steps (to the left)
export const rotate = (arr, n) => arr.slice(n).concat(arr.slice(0, n)); export const rotate = (arr, n) => arr.slice(n).concat(arr.slice(0, n));
+3 -5
View File
@@ -23,7 +23,7 @@ export const csound = register('csound', (instrument, pat) => {
instrument = instrument || 'triangle'; instrument = instrument || 'triangle';
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it 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) // TODO: find a alternative way to wait for csound to load (to wait with first time playback)
return pat.onSchedule((hap, targetTime) => { return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
if (!_csound) { if (!_csound) {
logger('[csound] not loaded yet', 'warning'); logger('[csound] not loaded yet', 'warning');
return; return;
@@ -36,10 +36,9 @@ export const csound = register('csound', (instrument, pat) => {
const controls = Object.entries({ ...hap.value, freq }) const controls = Object.entries({ ...hap.value, freq })
.flat() .flat()
.join('/'); .join('/');
// TODO: find out how to send a precise ctx based time // TODO: find out how to send a precise ctx based time
// http://www.csounds.com/manual/html/i.html // http://www.csounds.com/manual/html/i.html
const timeOffset = targetTime - getAudioContext().currentTime; // latency ? const timeOffset = targetTime - currentTime; // latency ?
//const timeOffset = time_deprecate - getAudioContext().currentTime //const timeOffset = time_deprecate - getAudioContext().currentTime
const params = [ const params = [
`"${instrument}"`, // p1: instrument name `"${instrument}"`, // p1: instrument name
@@ -145,7 +144,7 @@ export const csoundm = register('csoundm', (instrument, pat) => {
p1 = `"${instrument}"`; p1 = `"${instrument}"`;
} }
init(); // not async to support csound inside other patterns + to be able to call pattern methods after it init(); // not async to support csound inside other patterns + to be able to call pattern methods after it
return pat.onSchedule((hap, targetTime) => { return pat.onTrigger((hap, currentTime, _cps, targetTime) => {
if (!_csound) { if (!_csound) {
logger('[csound] not loaded yet', 'warning'); logger('[csound] not loaded yet', 'warning');
return; return;
@@ -153,7 +152,6 @@ export const csoundm = register('csoundm', (instrument, pat) => {
if (typeof hap.value !== 'object') { if (typeof hap.value !== 'object') {
throw new Error('csound only support objects as hap values'); throw new Error('csound only support objects as hap values');
} }
const currentTime = getAudioContext().currentTime;
// Time in seconds counting from now. // Time in seconds counting from now.
const p2 = targetTime - currentTime; const p2 = targetTime - currentTime;
const p3 = hap.duration.valueOf() + 0; const p3 = hap.duration.valueOf() + 0;
+1 -3
View File
@@ -1,4 +1,3 @@
import { getAudioContext } from '@strudel/webaudio/index.mjs';
import { Invoke } from './utils.mjs'; import { Invoke } from './utils.mjs';
import { Pattern, getEventOffsetMs, noteToMidi } from '@strudel/core'; import { Pattern, getEventOffsetMs, noteToMidi } from '@strudel/core';
@@ -7,8 +6,7 @@ const OFF_MESSAGE = 0x80;
const CC_MESSAGE = 0xb0; const CC_MESSAGE = 0xb0;
Pattern.prototype.midi = function (output) { Pattern.prototype.midi = function (output) {
return this.onSchedule((hap, targetTime, cps) => { return this.onTrigger((hap, currentTime, cps, targetTime) => {
const currentTime = getAudioContext().currentTime;
let { note, nrpnn, nrpv, ccn, ccv, velocity = 0.9, gain = 1 } = hap.value; 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 //magic number to get audio engine to line up, can probably be calculated somehow
const latencyMs = 34; const latencyMs = 34;
+3 -5
View File
@@ -1,14 +1,12 @@
import { Pattern, ClockCollator } from '@strudel/core'; import { Pattern, ClockCollator } from '@strudel/core';
import { parseControlsFromHap } from '@strudel/osc/osc.mjs'; import { parseControlsFromHap } from 'node_modules/@strudel/osc/osc.mjs';
import { Invoke } from './utils.mjs'; import { Invoke } from './utils.mjs';
import { getAudioContext } from '@strudel/webaudio/index.mjs';
const collator = new ClockCollator({}); const collator = new ClockCollator({});
export async function tauriOscOutput(hap, targetTime, cps = 1) { export async function oscTriggerTauri(hap, currentTime, cps = 1, targetTime) {
const controls = parseControlsFromHap(hap, cps); const controls = parseControlsFromHap(hap, cps);
const params = []; const params = [];
const currentTime = getAudioContext().currentTime;
const timestamp = collator.calculateTimestamp(currentTime, targetTime); const timestamp = collator.calculateTimestamp(currentTime, targetTime);
Object.keys(controls).forEach((key) => { Object.keys(controls).forEach((key) => {
@@ -34,5 +32,5 @@ export async function tauriOscOutput(hap, targetTime, cps = 1) {
}); });
} }
Pattern.prototype.osc = function () { Pattern.prototype.osc = function () {
return this.onSchedule(tauriOscOutput); return this.onTrigger(oscTriggerTauri);
}; };
-2
View File
@@ -23,8 +23,6 @@
}, },
"dependencies": { "dependencies": {
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/osc": "workspace:*",
"@strudel/webaudio": "workspace:*",
"@tauri-apps/api": "^2.2.0" "@tauri-apps/api": "^2.2.0"
}, },
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
-69
View File
@@ -1,69 +0,0 @@
/*
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);
});
-28
View File
@@ -1,28 +0,0 @@
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;
}
-42
View File
@@ -1,42 +0,0 @@
{
"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"
}
}
+10
View File
@@ -78,6 +78,16 @@ export const cleanupDraw = (clearScreen = true, id) => {
stopAllAnimations(id); stopAllAnimations(id);
}; };
export const cleanupDrawContext = (replID) => {
const ctx = getDrawContext();
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// clear the big canvas context, ignore inline widgets
Object.keys(animationFrames).forEach(
(id) => (!replID || id.startsWith(replID)) && !id.startsWith('_') && stopAnimationFrame(id),
);
};
Pattern.prototype.onPaint = function (painter) { Pattern.prototype.onPaint = function (painter) {
return this.withState((state) => { return this.withState((state) => {
if (!state.controls.painters) { if (!state.controls.painters) {
+1 -32
View File
@@ -54,42 +54,11 @@ export function pitchwheel({
} }
if (edo) { if (edo) {
edo = haps.length >= 1 && haps[0].value && haps[0].value.edo ? haps[0].value.edo : edo;
root = haps.length >= 1 && haps[0].value && haps[0].value.root ? haps[0].value.root : root;
const degreeIndexes =
haps.length >= 1 && haps[0].value && haps[0].value.degreeIndexes ? haps[0].value.degreeIndexes : null;
const intLabels = haps.length >= 1 && haps[0].value && haps[0].value.intLabels ? haps[0].value.intLabels : null;
ctx.font = '20px sans-serif';
const label = `${edo} EDO`;
// Draw EDO label:
ctx.fillText(label, centerX + radius - ctx.measureText(label).width + 15, centerY + radius);
Array.from({ length: edo }, (_, i) => { Array.from({ length: edo }, (_, i) => {
const angle = freq2angle(root * Math.pow(2, i / edo), root); const angle = freq2angle(root * Math.pow(2, i / edo), root);
const [x, y] = circlePos(centerX, centerY, radius, angle); const [x, y] = circlePos(centerX, centerY, radius, angle);
ctx.beginPath(); ctx.beginPath();
// Draw interval label for degree i when it exists: ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
if (degreeIndexes === null || degreeIndexes.includes(i)) {
ctx.globalAlpha = 1;
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
if (intLabels !== null) {
const degree = degreeIndexes.indexOf(i);
if (intLabels[degree]) {
if (angle < 0.32 && angle > 0.125) {
ctx.fillText(intLabels[degree], x - 34, y);
} else {
if (angle < 0.1 && angle > -1.125) {
ctx.fillText(intLabels[degree], x - 7, y - 12);
} else {
ctx.fillText(intLabels[degree], x + 9, y);
}
}
}
}
} else {
ctx.globalAlpha = 0.15;
ctx.arc(x, y, hapRadius, 0, 2 * Math.PI);
}
ctx.fill(); ctx.fill();
}); });
ctx.stroke(); ctx.stroke();
-41
View File
@@ -1,41 +0,0 @@
# @strudel/edo
This package adds EDO scale functions to strudel Patterns.
## Install
```sh
npm i @strudel/edo --save
```
## Example
```js
import { n } from '@strudel/core';
import '@strudel/edo';
// E.g. edoScale for Gorgo-6 scale, 16 EDO, LLsLLLs
// base note C3, large step size 3, small step size 1:
// C3:LLsLLL:3:1
const [baseNote, sequence, largeStep, smallStep] = ['C3', 'LLsLLL', 3, 1]
const pattern = n("0 2 4 6 4 2").edoScale([baseNote, sequence, largeStep, smallStep]);
const events = pattern.firstCycle().map((e) => e.show());
console.log(events);
```
yields:
```
[
"[ 0/1 → 1/1 |
{
\"degree\":1,
\"degreeIndexes\":[0,3,6,7,10,13],
\"intLabels\":[null,\"S2\",\"d4\",\"N4\",\"s6\",\"s7\",\"P8\"],
\"root\":\"130.8128\",
\"freq\":130.813,
\"edo\":16
}
]"
]
```
-114
View File
@@ -1,114 +0,0 @@
/*
edo.mjs - Equal division of the octave (EDO) scale functions for strudel
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/edo.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { register, pure, noteToMidi, isNote, tokenizeNote } from '@strudel/core';
import { EdoScale } from './edoscale.mjs';
import { Intervals } from './intervals.mjs';
import { Pitches } from './pitches.mjs';
const pitchesCache = new Map();
/**
* Turns numbers into notes in the given EDO scale (zero indexed).
*
* An EDO scale definition looks like this:
*
* e.g. C:LLsLLLs:2:1 <- this is the C major scale, 12 EDO
*
* e.g. C:LLsLLL:3:1 <- this is the Gorgo 6 note scale, 16 EDO
*
* An EDO scale, e.g. C:LLsLLLs:2:1, consists of a root note (e.g. C)
* followed by semicolon (':')
* and then a [Large/small step notation sequence](https://en.xen.wiki/w/MOS_scale)
* (e.g. LLsLLLs)
* followed by semicolon, then the large step size (e.g. 2)
* followed by semicolon, then the small step size (e.g. 1).
*
* The number of divisions of the octave is calculated as the sum
* of the steps in the EDO scale definition.
*
* e.g. C:LLsLLLs:2:1 is 2+2+1+2+2+2+1 = 12 EDO, 7 note scale
*
* e.g. C:LLsLLL:3:1 is 3+3+1+3+3+3 = 16 EDO, 6 note scale
*
* The root note defaults to octave 3, if no octave number is given.
*
* @name edoScale
* @param {string} scale Definition of EDO scale.
* @returns Pattern
* @example
* n("0 2 4 6 4 2").edoScale("C:LLsLLLs:2:1")
* @example
* n("[0,7] 4 [2,7] 4")
* .edoScale("G2:<LLsLLL LLLLsL>:3:1")
* .s("piano")._pitchwheel()
* @example
* n(rand.range(0,5).segment(6))
* .edoScale("<G2 C3>:LLsLL:3:1")
* .s("piano")._pitchwheel()
*/
export const edoScale = register(
'edoScale',
function (scaleDefinition, pat) {
// console.log(scaleDefinition);
// if (Array.isArray(scale)) {
const key = scaleDefinition.flat().join(':');
// }
// console.log(scaleDefinition);
let pitches;
if (pitchesCache.has(key)) {
pitches = pitchesCache.get(key);
} else {
// console.log({ key });
const [base_note, sequence, large, small] = scaleDefinition;
const root_octave = tokenizeNote(base_note)[2] || 3;
// console.log({ root_octave });
const scale = new EdoScale(large, small, sequence);
const intervals = new Intervals(scale);
// console.log({ intervals });
pitches = new Pitches(scale, intervals, 440, noteToMidi(base_note), root_octave);
pitchesCache.set(key, pitches);
// console.log({ base_note: noteToMidi(base_note) });
// console.log({ sequence });
// console.log({ edivisions: scale.edivisions });
// console.log({ intervals });
// console.log({ pat });
// console.log({ pitches: pitches });
}
return pat
.fmap((value) => {
const isObject = typeof value === 'object';
const n = isObject ? value.n : value;
if (isObject) {
delete value.n; // remove n so it won't cause trouble
}
if (isNote(n)) {
// legacy..
return pure(n);
}
const deg = (typeof n === 'string' ? parseInt(n, 10) : Number.isInteger(n) ? n : Math.round(n)) + 1;
const [oct, degree] = pitches.octdeg(deg);
const freq = pitches.octdegfreq(oct, degree);
const note = pitches.octdegmidi(oct, degree);
const edo = pitches.scale.edivisions;
const root = pitches.base_freq;
const degreeIndexes = pitches.scale.divisions;
const intLabels = pitches.intervals.intLabels;
// const color = 'red';
value = pure(isObject ? { ...value, degree, degreeIndexes, intLabels, root, freq, edo } : note);
// value = pure(isObject ? { ...value, key } : note);
// value = pure(isObject ? { ...value, edo } : note);
// console.log({ value });
return value;
})
.outerJoin()
.withHap((hap) => hap.setContext({ ...hap.context, scaleDefinition }));
},
true,
true, // preserve tactus
);
-213
View File
@@ -1,213 +0,0 @@
/*
edoscale.mjs - EdoScale defines equal division of the octave (EDO) scale in Ls notation
- Port of pitfalls/lib/Scale.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Scale.lua>
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/edoscale.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const M = 2;
const L = 1;
const S = 0;
const LABELS = ['s', 'L', 'M'];
export class EdoScale {
constructor(large, small, sequence, medium) {
this.stepbackup = [L, L, S, L, L, L, S, L, L, L, S, L, L, L, S, L];
this.large = large;
this.medium = medium || large;
this.small = small;
this.divisions = [];
this.edivisions = null;
this.sequence = null;
this.tonic = 1;
this.mode = 1;
this.max_steps = 12;
this.min_steps = 3;
this.setSequence(sequence);
}
hasMedium() {
return this.step.some((_, i) => this.step[this.offset(i)] === M);
}
stepSize(i) {
return LABELS[this.step[this.offset(i)]];
}
sequence() {
return this.step.map((_, i) => this.stepSize(i)).join('');
}
stepValue(i) {
const step = this.step[this.offset(i)];
return step === L ? this.large : step === M ? this.medium : this.small;
}
offset(i) {
if (this.mode === 1) {
return i;
} else {
const offset = (this.mode - 1 + i) % this.length;
return offset === 0 ? this.length : offset;
}
}
static setMaxSteps(max) {
this.max_steps = max;
}
static setMinSteps(min) {
this.min_steps = min;
}
setSequence(sequence) {
if (this.sequence !== sequence) {
this.sequence = sequence;
this.length = sequence.length;
this.step = [];
for (let i = 0; i < sequence.length; i++) {
const char = sequence[i];
this.step[i] = char === 'L' ? L : char === 'M' ? M : S;
}
this.updateEdo();
} else {
return false;
}
}
setLarge(l) {
if (this.large !== l) {
this.large = l;
this.updateEdo();
} else {
return false;
}
}
setMedium(m) {
if (this.medium !== m) {
this.medium = m;
this.updateEdo();
} else {
return false;
}
}
setSmall(s) {
if (this.small !== s) {
this.small = s;
this.updateEdo();
} else {
return false;
}
}
setMode(mode) {
this.mode = mode;
}
setTonic(tonic) {
this.tonic = tonic;
}
changeMode(d) {
const orig = this.mode;
this.mode = Math.max(1, Math.min(this.mode + d, this.length));
return orig !== this.mode;
}
changeTonic(d) {
const orig = this.tonic;
this.tonic = Math.max(1, Math.min(this.tonic + d, this.edivisions));
return orig !== this.tonic;
}
updateEdo() {
const orig = this.edivisions;
this.edivisions = this.step.reduce((sum, _, i) => {
this.divisions[i] = sum;
return sum + this.stepValue(i);
}, 0);
// console.log(this.divisions);
const changed = orig !== this.edivisions;
if (changed) {
this.tonic = Math.max(1, Math.min(this.tonic, this.edivisions));
}
return changed;
}
changeStep(d, i) {
const index = this.offset(i);
const orig = this.step[index];
this.step[index] = Math.max(S, Math.min(this.step[index] + d, M));
this.stepbackup[index] = this.step[index];
const changed = orig !== this.step[index];
if (changed) {
this.updateEdo();
}
return changed;
}
changeLarge(d) {
const orig = this.large;
this.setLarge(Math.max(this.small + 1, Math.min(this.large + d, this.large + 1)));
const changed = this.large !== orig;
if (changed) {
if (this.large <= this.medium) {
this.setMedium(Math.max(this.small + 1, Math.min(this.large - 1, this.large)));
}
this.updateEdo();
}
return changed;
}
changeMedium(d) {
const orig = this.medium;
this.setMedium(Math.max(this.small + 1, Math.min(this.medium + d, this.large - 1)));
const changed = this.medium !== orig;
if (changed) {
this.updateEdo();
}
return changed;
}
changeSmall(d) {
const orig = this.small;
const value = this.small + d;
if (this.hasMedium()) {
this.setSmall(Math.max(1, Math.min(value, this.medium - 1)));
} else {
this.setSmall(Math.max(1, Math.min(value, this.large - 1)));
}
const changed = this.small !== orig;
if (changed) {
if (this.small >= this.medium) {
this.setMedium(Math.max(this.small + 1, Math.min(this.large)));
}
this.updateEdo();
}
return changed;
}
changeLength(d) {
const orig = this.length;
if (d === 1) {
this.length = Math.min(this.length + 1, this.max_steps);
} else if (d === -1) {
this.length = Math.max(this.length - 1, this.min_steps);
}
const changed = this.length !== orig;
if (changed) {
this.mode = 1;
if (d === 1) {
this.step[this.length] = this.stepbackup[this.length] || L;
} else if (d === -1 && this.length >= this.min_steps) {
this.step.pop();
}
this.updateEdo();
}
return changed;
}
}
-5
View File
@@ -1,5 +0,0 @@
import './edo.mjs';
export * from './edo.mjs';
export const packageName = '@strudel/edo';
-103
View File
@@ -1,103 +0,0 @@
/*
intervals.mjs - defines Intervals for equal division of the octave (EDO) scale
- Port of pitfalls/lib/Intervals.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Intervals.lua>
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/intervals.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import ratiointervals from './ratios.mjs';
function ratio(division, edivisions) {
return division === 0 ? 1 : Math.pow(2, division / edivisions);
}
export class Intervals {
constructor(scale) {
this.scale = scale;
this.intLabels = [];
this.intNoms = [];
this.intRatios = [];
this.uniqLabels = [];
this.intErrors = [];
this.ratios = [];
const BLANK = '';
let division = 0;
const labToErr = {};
const labToInd = {};
this.ratios[0] = 1;
for (let i = 0; i < scale.length; i++) {
division += scale.stepValue(i);
this.ratios[i + 1] = ratio(division, scale.edivisions);
if (i < scale.length) {
const nearest = ratiointervals.nearestInterval(this.ratios[i + 1]);
const closeness = nearest[0];
const ratio = nearest[1];
const intLabel = ratiointervals.key(ratio);
this.intLabels[i + 1] = intLabel;
this.intErrors[i + 1] = closeness;
this.intNoms[i + 1] = ratio ? ratiointervals.nom(ratio) : 0;
this.intRatios[i + 1] = ratio ? `${ratiointervals.nom(ratio)}/${ratiointervals.denom(ratio)}` : '';
this.uniqLabels[i + 1] = BLANK;
if (intLabel && intLabel !== 'P1' && intLabel !== 'P8') {
if (!labToErr[intLabel]) {
this.uniqLabels[i + 1] = intLabel;
labToInd[intLabel] = i + 1;
labToErr[intLabel] = closeness;
} else if (closeness < labToErr[intLabel]) {
this.uniqLabels[labToInd[intLabel]] = BLANK;
this.uniqLabels[i + 1] = intLabel;
labToInd[intLabel] = i + 1;
labToErr[intLabel] = closeness;
}
}
}
}
}
ratio(i) {
return this.ratios[i];
}
intervalLabel(i) {
return this.intLabels[i];
}
intervalNominator(i) {
return this.intNoms[i];
}
intervalRatio(i) {
return this.intRatios[i];
}
uniqIntervalLabel(i) {
return this.uniqLabels[i];
}
intervalError(i) {
return this.intErrors[i];
}
nearestDegreeTo(r, threshold) {
let min = 1;
let degree = null;
for (const [i, v] of Object.entries(this.ratios)) {
const diff = Math.abs((r - v) / r);
if (diff < min) {
min = diff;
degree = parseInt(i, 10);
}
}
if (threshold == null) {
return degree;
} else {
return min < threshold ? degree : 1;
}
}
}
-42
View File
@@ -1,42 +0,0 @@
{
"name": "@strudel/edo",
"version": "0.1.0",
"description": "Equal division of the octave (EDO) scale functions for strudel",
"main": "index.mjs",
"publishConfig": {
"main": "dist/index.mjs"
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"type": "module",
"repository": {
"type": "git",
"url": "git+https://codeberg.org/uzu/strudel.git"
},
"keywords": [
"tidalcycles",
"strudel",
"pattern",
"livecoding",
"algorave"
],
"author": "Rob McKinnon <rob@movingflow>",
"license": "AGPL-3.0-or-later",
"bugs": {
"url": "https://codeberg.org/uzu/strudel/issues"
},
"homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": {
"@strudel/core": "workspace:*",
"@tonaljs/tonal": "^4.10.0",
"chord-voicings": "^0.0.1",
"webmidi": "^3.1.12"
},
"devDependencies": {
"vite": "^6.0.11",
"vitest": "^3.0.4"
}
}
-98
View File
@@ -1,98 +0,0 @@
/*
pitches.mjs - defines Pitches for equal division of the octave (EDO) scale
- Port of pitfalls/lib/Pitches.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/Pitches.lua>
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/pitches.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
function ratio(division, edivisions) {
return division === 0 ? 1 : Math.pow(2, division / edivisions);
}
// This function gets frequency based on index, edo, octave, and base frequency
function get_freq(base_freq, edo, index, oct, base_octave) {
let f = base_freq * ratio(index - 1, edo);
if (oct < base_octave) {
f /= Math.pow(2, base_octave - oct);
} else if (oct > base_octave) {
f *= Math.pow(2, oct - base_octave);
}
return f;
}
// Convert MIDI number to Hz using a given tuning multiplier
function midi_to_hz(n, tuning) {
return tuning * Math.pow(2, (n - 69) / 12.0);
}
const denom = Math.log(2);
function hz_to_midi(freq, tuning) {
return 12.0 * (Math.log(freq / tuning) / denom) + 69;
}
export class Pitches {
constructor(scale, intervals, tuning, midi_start, root_octave) {
this.scale = scale;
this.intervals = intervals;
this.base_freq = midi_to_hz(midi_start, tuning);
this.root_octave = root_octave;
this.freqs = {};
this.midis = {};
this.degrees = {};
this.octdegfreqs = {};
this.octdegmidis = {};
let index = 0;
let f = null;
for (let oct = 0; oct <= 8; oct++) {
this.octdegfreqs[oct] = {};
this.octdegmidis[oct] = {};
f = get_freq(this.base_freq, scale.edivisions, scale.tonic, oct, this.root_octave);
for (let deg = 0; deg < scale.length; deg++) {
index = index + 1;
this.freqs[index] = parseFloat((f * intervals.ratio(deg)).toFixed(3));
this.midis[index] = parseFloat(hz_to_midi(f * intervals.ratio(deg), tuning).toFixed(4));
this.degrees[index] = deg;
this.octdegfreqs[oct][deg + 1] = this.freqs[index];
this.octdegmidis[oct][deg + 1] = this.midis[index];
}
}
this.base_freq = parseFloat(this.base_freq).toFixed(4);
}
base_freq() {
return this.base_freq;
}
degree(index) {
return this.degrees[index];
}
freq(index) {
return this.freqs[index];
}
octdeg(deg) {
const higherOcatve = deg > this.scale.length;
const octave = this.root_octave + (higherOcatve ? Math.floor((deg - 1) / this.scale.length) : 0);
const degree = higherOcatve ? (deg % this.scale.length === 0 ? this.scale.length : deg % this.scale.length) : deg;
// console.log([octave, degree]);
return [octave, degree];
}
octdegfreq(oct, deg) {
if (this.octdegfreqs[oct]) {
return this.octdegfreqs[oct][deg];
} else {
return null;
}
}
octdegmidi(oct, deg) {
if (this.octdegmidis[oct]) {
return this.octdegmidis[oct][deg];
} else {
return null;
}
}
}
-95
View File
@@ -1,95 +0,0 @@
/*
ratios.mjs - lists whole number ratios for pitch intervals
- Port of pitfalls/lib/ratios.lua - see <https://github.com/robmckinnon/pitfalls/blob/main/lib/ratios.lua>
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/ratios.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const ratiointervals = {};
// FJS Calculators - https://misotanni.github.io/fjs/en/calc.html
// List of pitch intervals - https://en.wikipedia.org/wiki/List_of_pitch_intervals
// Gallery of just intervals - https://en.xen.wiki/w/Gallery_of_just_intervals
// Two letter codes changed to make different interval labels more unique.
ratiointervals.list = new Map([
[1, ['P1', 'P1', 'P1', 1, 1]], // unison P1
[16 / 15, ['m2', 'm2', 'm2_5', 16, 15]], // minor second m2
[15 / 14, ['A1', 'A1', 'A1^5_7', 15, 14]], // augmented unison
[13 / 12, ['t2', 'm2', 'm2^13', 13, 12]], // tridecimal neutral second
[12 / 11, ['N2', 'M2', 'M2_11', 12, 11]], // undecimal neutral second
[11 / 10, ['n2', 'm2', 'm2^11_5', 11, 10]], // undecimal submajor second
[10 / 9, ['T2', 'M2', 'M2^5', 10, 9]], // classic (whole) tone
[9 / 8, ['M2', 'M2', 'M2', 9, 8]], // major second M2
[8 / 7, ['S2', 'M2', 'M2_7', 8, 7]], // septimal major second
[7 / 6, ['s3', 'm3', 'm3^7', 7, 6]], // septimal minor third
[19 / 16, ['o3', 'm3', 'm3^19', 19, 16]], // otonal minor third
[6 / 5, ['m3', 'm3', 'm3_5', 6, 5]], // minor third m3
[17 / 14, ['t3', 'm3', 'm3^17_7', 17, 14]], // septendecimal supraminor third
[11 / 9, ['n3', 'm3', 'm3^11', 11, 9]], // undecimal neutral third
[5 / 4, ['M3', 'M3', 'M3^5', 5, 4]], // major third M3
[9 / 7, ['S3', 'M3', 'M3_7', 9, 7]], // septimal major third SM3
[13 / 10, ['d4', 'd4', 'd4^13_5', 13, 10]], // Barbados third
[4 / 3, ['P4', 'P4', 'P4', 4, 3]], // perfect fourth P4
[19 / 14, ['N4', 'P4', 'P4^19_7', 19, 14]], // undevicesimal wide fourth
[11 / 8, ['n4', 'P4', 'P4^11', 11, 8]], // super-fourth
[25 / 18, ['a4', 'A4', 'A4^5,5', 25, 18]], // classic augmented fourth
[7 / 5, ['sT', 'd5', 'd5^7_5', 7, 5]], // lesser septimal tritone
[45 / 32, ['A4', 'A4', 'A4^5', 45, 32]], // just augmented fourth
[17 / 12, ['d5', 'd5', 'd5^17', 17, 12]], // larger septendecimal tritone
[10 / 7, ['ST', 'A4', 'A4^5_7', 10, 7]], // greater septimal tritone
[13 / 9, ['t5', 'd5', 'd5^13', 13, 9]], // tridecimal diminished fifth
[3 / 2, ['P5', 'P5', 'P5', 3, 2]], // perfect fifth P5
[14 / 9, ['s6', 'M6', 'm6^7', 14, 9]], // subminor sixth or septimal sixth
[25 / 16, ['a5', 'A5', 'A5^5,5', 25, 16]], // classic augmented fifth
[11 / 7, ['A5', 'P5', 'P5^11_7', 11, 7]], // undecimal minor sixth
[8 / 5, ['m6', 'm6', 'm6_5', 8, 5]], // minor sixth m6
[13 / 8, ['N6', 'm6', 'm6^13', 13, 8]], // tridecimal neutral sixth
[18 / 11, ['n6', 'M6', 'M6_11', 18, 11]], // undecimal neutral sixth
[5 / 3, ['M6', 'M6', 'M6^5', 5, 3]], // just major sixth M6
[128 / 75, ['d7', 'd7', 'd7_5,5', 128, 75]], // diminished seventh
[17 / 10, ['T6', 'd7', 'd7^17_5', 17, 10]], // septendecimal diminished seventh
[12 / 7, ['S6', 'M6', 'M6_7', 12, 7]], // septimal major sixth
[7 / 4, ['s7', 'm7', 'm7^7', 7, 4]], // septimal minor seventh
[16 / 9, ['m7', 'm7', 'm7', 16, 9]], // lesser minor seventh
[9 / 5, ['g7', 'm7', 'm7_5', 9, 5]], // greater just minor seventh
[11 / 6, ['n7', 'm7', 'm7^11', 11, 6]], // undecimal neutral seventh
[13 / 7, ['N7', 'm7', 'm7^13_7', 13, 7]], // tridecimal neutral seventh
[15 / 8, ['M7', 'M7', 'M7^5', 15, 8]], // major seventh
[17 / 9, ['T7', 'd8', 'd8^17', 17, 9]], // large septendecimal major seventh
[19 / 10, ['d8', 'd8', 'd8^19_5', 19, 10]], // large undevicesimal major seventh
[2, ['P8', 'P8', 'P8', 2, 1]], // octave P8
]);
ratiointervals.key = function (ratio) {
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[0] || '';
};
ratiointervals.label = function (ratio) {
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[1] || '';
};
ratiointervals.fjs = function (ratio) {
return ratio == null ? '' : ratiointervals.list.get(ratio)?.[2] || '';
};
ratiointervals.nom = function (ratio) {
return ratio == null ? null : ratiointervals.list.get(ratio)?.[3] || null;
};
ratiointervals.denom = function (ratio) {
return ratio == null ? null : ratiointervals.list.get(ratio)?.[4] || null;
};
ratiointervals.nearestInterval = function (v) {
let min = 1;
let match = null;
for (const [ratio, _labels] of ratiointervals.list) {
const diff = Math.abs((ratio - v) / ratio);
if (diff < min) {
min = diff;
match = ratio;
}
}
return min < 0.01 ? [min, match] : [null, null];
};
export default ratiointervals;
-34
View File
@@ -1,34 +0,0 @@
/*
edo.test.mjs - tests of edo.mjs
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/tonal/test/tonal.test.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import '../edo.mjs';
import { n } from '@strudel/core';
import { describe, it, expect } from 'vitest';
describe('edoScale', () => {
it('Should run tonal functions ', () => {
// base note A3 = 220Hz
let baseNote = 'A3';
let root = 220;
let freq = 220;
let pattern = n('0').edoScale([baseNote, 'LLsLLLs', 2, 1]);
let cycle = pattern.firstCycleValues[0];
expect(cycle.freq).toEqual(freq);
expect(cycle.edo).toEqual(12);
expect(cycle.degree).toEqual(1);
expect(parseFloat(cycle.root).toFixed(0)).toEqual(root.toFixed(0));
// base note A4 = 440Hz
baseNote = 'A4';
root = 440;
freq = 880;
pattern = n('7').edoScale([baseNote, 'LLsLLLs', 2, 1]);
cycle = pattern.firstCycleValues[0];
expect(cycle.freq).toEqual(freq);
expect(cycle.edo).toEqual(12);
expect(cycle.degree).toEqual(1);
expect(parseFloat(cycle.root).toFixed(0)).toEqual(root.toFixed(0));
});
});
-21
View File
@@ -1,21 +0,0 @@
/*
edoscale.test.mjs - tests of EdoScale from edoscale.mjs
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/test/edoscale.test.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import '../edoscale.mjs'; // need to import this to add prototypes
import { EdoScale } from '../edoscale.mjs';
import { describe, it, expect } from 'vitest';
describe('EdoScale', () => {
it('updates edivisions', () => {
let scale = new EdoScale(2, 1, ['L', 'L', 's', 'L', 'L', 'L', 's']);
expect(scale.stepSize(0)).toEqual('L');
expect(scale.stepValue(0)).toEqual(2);
expect(scale.stepSize(2)).toEqual('s');
expect(scale.stepValue(2)).toEqual(1);
expect(scale.edivisions).toEqual(12);
});
});
-18
View File
@@ -1,18 +0,0 @@
/*
ratios.test.mjs - tests of ratios.mjs
Copyright (C) 2025 Rob McKinnon and Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/edo/test/ratios.test.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import ratiointervals from '../ratios.mjs';
import { describe, it, expect } from 'vitest';
describe('ratiointervals', () => {
it('updates edivisions', () => {
let ratio = 9 / 7;
expect(ratiointervals.key(ratio)).toEqual('S3');
expect(ratiointervals.label(ratio)).toEqual('M3');
expect(ratiointervals.fjs(ratio)).toEqual('M3_7');
expect(ratiointervals.nom(ratio)).toEqual(9);
expect(ratiointervals.denom(ratio)).toEqual(7);
});
});
-19
View File
@@ -1,19 +0,0 @@
import { defineConfig } from 'vite';
import { dependencies } from './package.json';
import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [],
build: {
lib: {
entry: resolve(__dirname, 'index.mjs'),
formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs' })[ext],
},
rollupOptions: {
external: [...Object.keys(dependencies)],
},
target: 'esnext',
},
});
+71 -82
View File
@@ -13,7 +13,7 @@ import {
getIsStarted, getIsStarted,
getPattern, getPattern,
getTime, getTime,
getScheduleFunc, getTriggerFunc,
isPattern, isPattern,
logger, logger,
ref, ref,
@@ -21,8 +21,8 @@ import {
} from '@strudel/core'; } from '@strudel/core';
import { noteToMidi, getControlName } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi'; import { Note } from 'webmidi';
import { getAudioContext, getClockBridge } from '@strudel/webaudio'; import { getAudioContext } from '@strudel/webaudio';
import { scheduleAtTime, ensureMinimalOutput } from '../superdough/helpers.mjs'; import { scheduleAtTime } from '../superdough/helpers.mjs';
import { getMidiDeviceNamesString, getDevice } from './util.mjs'; import { getMidiDeviceNamesString, getDevice } from './util.mjs';
import { MidiInput } from './input.mjs'; import { MidiInput } from './input.mjs';
@@ -171,23 +171,6 @@ function normalize(value = 0, min = 0, max = 1, exp = 1) {
return Math.pow(normalized, exp); 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) { function mapCC(mapping, value) {
return Object.keys(value) return Object.keys(value)
.filter((key) => !!mapping[getControlName(key)]) .filter((key) => !!mapping[getControlName(key)])
@@ -199,7 +182,7 @@ function mapCC(mapping, value) {
} }
// sends a cc message to the given device on the given channel // sends a cc message to the given device on the given channel
function sendCC(ccn, ccv, device, midichan, timeMs) { function sendCC(ccn, ccv, device, midichan, targetTime) {
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) { if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
throw new Error('expected ccv to be a number between 0 and 1'); throw new Error('expected ccv to be a number between 0 and 1');
} }
@@ -207,19 +190,23 @@ function sendCC(ccn, ccv, device, midichan, timeMs) {
throw new Error('expected ccn to be a number or a string'); throw new Error('expected ccn to be a number or a string');
} }
const scaled = Math.round(ccv * 127); const scaled = Math.round(ccv * 127);
timedSend(timeMs, (timeMs) => device.sendControlChange(ccn, scaled, { channels: midichan, time: timeMs })); scheduleAtTime(() => {
device.sendControlChange(ccn, scaled, midichan);
}, targetTime);
} }
// sends a program change message to the given device on the given channel // sends a program change message to the given device on the given channel
function sendProgramChange(progNum, device, midichan, timeMs) { function sendProgramChange(progNum, device, midichan, targetTime) {
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) { if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
throw new Error('expected progNum (program change) to be a number between 0 and 127'); throw new Error('expected progNum (program change) to be a number between 0 and 127');
} }
timedSend(timeMs, (timeMs) => device.sendProgramChange(progNum, { channels: midichan, time: timeMs })); scheduleAtTime(() => {
device.sendProgramChange(progNum, midichan);
}, targetTime);
} }
// sends a sysex message to the given device on the given channel // sends a sysex message to the given device on the given channel
function sendSysex(sysexid, sysexdata, device, timeMs) { function sendSysex(sysexid, sysexdata, device, targetTime) {
if (Array.isArray(sysexid)) { if (Array.isArray(sysexid)) {
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysexid bytes must be integers between 0 and 255'); throw new Error('all sysexid bytes must be integers between 0 and 255');
@@ -234,11 +221,13 @@ function sendSysex(sysexid, sysexdata, device, timeMs) {
if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { if (!sysexdata.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all sysex bytes must be integers between 0 and 255'); throw new Error('all sysex bytes must be integers between 0 and 255');
} }
timedSend(timeMs, (timeMs) => device.sendSysex(sysexid, sysexdata, { time: timeMs })); scheduleAtTime(() => {
device.sendSysex(sysexid, sysexdata);
}, targetTime);
} }
// sends a NRPN message to the given device on the given channel // sends a NRPN message to the given device on the given channel
function sendNRPN(nrpnn, nrpv, device, midichan, timeMs) { function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
if (Array.isArray(nrpnn)) { if (Array.isArray(nrpnn)) {
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) { if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
throw new Error('all nrpnn bytes must be integers between 0 and 255'); throw new Error('all nrpnn bytes must be integers between 0 and 255');
@@ -246,29 +235,34 @@ function sendNRPN(nrpnn, nrpv, device, midichan, timeMs) {
} else if (!Number.isInteger(nrpv) || nrpv < 0 || nrpv > 255) { } 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'); throw new Error('A:sysexid must be an number between 0 and 255 or an array of such integers');
} }
scheduleAtTime(() => {
timedSend(timeMs, (timeMs) => device.sendNrpnValue(nrpnn, nrpv, { channels: midichan, time: timeMs })); device.sendNRPN(nrpnn, nrpv, midichan);
}, targetTime);
} }
// sends a pitch bend message to the given device on the given channel // sends a pitch bend message to the given device on the given channel
function sendPitchBend(midibend, device, midichan, timeMs) { function sendPitchBend(midibend, device, midichan, targetTime) {
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) { if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
throw new Error('expected midibend to be a number between -1 and 1'); throw new Error('expected midibend to be a number between -1 and 1');
} }
timedSend(timeMs, (timeMs) => device.sendPitchBend(midibend, { channels: midichan, time: timeMs })); scheduleAtTime(() => {
device.sendPitchBend(midibend, midichan);
}, targetTime);
} }
// sends a channel aftertouch message to the given device on the given channel // sends a channel aftertouch message to the given device on the given channel
function sendAftertouch(miditouch, device, midichan, timeMs) { function sendAftertouch(miditouch, device, midichan, targetTime) {
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) { if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
throw new Error('expected miditouch to be a number between 0 and 1'); throw new Error('expected miditouch to be a number between 0 and 1');
} }
timedSend(timeMs, (timeMs) => device.sendChannelAftertouch(miditouch, { channels: midichan, time: timeMs })); scheduleAtTime(() => {
device.sendChannelAftertouch(miditouch, midichan);
}, targetTime);
} }
// sends a note message to the given device on the given channel // sends a note message to the given device on the given channel
function sendNote(note, velocity, duration, device, midichan, timeMs) { function sendNote(note, velocity, duration, device, midichan, targetTime) {
if (note == null || note === '') { if (note == null || note === '') {
throw new Error('note cannot be null or empty'); throw new Error('note cannot be null or empty');
} }
@@ -279,10 +273,11 @@ function sendNote(note, velocity, duration, device, midichan, timeMs) {
throw new Error('duration must be a positive number'); throw new Error('duration must be a positive number');
} }
const midiNumber = typeof note === 'number' ? note : noteToMidi(note); const midiNumber = typeof note === 'number' ? note : noteToMidi(note);
const midiNote = new Note(midiNumber, { attack: velocity }); const midiNote = new Note(midiNumber, { attack: velocity, duration });
timedSend(timeMs, (timeMs) => device.sendNoteOn(midiNote, { channels: midichan, time: timeMs })); scheduleAtTime(() => {
timedSend(timeMs + duration, (timeMs) => device.sendNoteOff(midiNote, { channels: midichan, time: timeMs })); device.playNote(midiNote, midichan);
}, targetTime);
} }
/** /**
@@ -292,6 +287,8 @@ function sendNote(note, velocity, duration, device, midichan, timeMs) {
* @param {object} options Additional MIDI configuration options * @param {object} options Additional MIDI configuration options
* @example * @example
* note("c4").midichan(1).midi('IAC Driver Bus 1') * 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 = {}) { Pattern.prototype.midi = function (midiport, options = {}) {
@@ -317,7 +314,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
let midiConfig = { let midiConfig = {
// Default configuration values // Default configuration values
isController: false, // Disable sending notes for midi controllers isController: false, // Disable sending notes for midi controllers
noteOffsetMs: isFirefox ? 10 : 1, // Default note-off offset to prevent glitching in ms. firefox needs more slack noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms
midichannel: 1, // Default MIDI channel midichannel: 1, // Default MIDI channel
velocity: 0.9, // Default velocity velocity: 0.9, // Default velocity
gain: 1, // Default gain gain: 1, // Default gain
@@ -340,19 +337,11 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`), logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
}); });
ensureMinimalOutput(); return this.onTrigger((hap, _currentTime, cps, targetTime) => {
return this.sortHapsByPart().onSchedule((hap, targetTime, cps) => {
if (!WebMidi.enabled) { if (!WebMidi.enabled) {
logger('Midi not enabled'); logger('Midi not enabled');
return; return;
} }
const timeMs = getClockBridge().getPerformanceTime(targetTime);
if (!timeMs) {
logger('[midi] clockbridge not ready');
return;
}
hap.ensureObjectValue(); hap.ensureObjectValue();
// midi event values from hap with configurable defaults // midi event values from hap with configurable defaults
@@ -390,7 +379,7 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if midimap is set, send a cc messages from defined controls // if midimap is set, send a cc messages from defined controls
if (midicontrolMap.has(midimap)) { if (midicontrolMap.has(midimap)) {
const ccs = mapCC(midicontrolMap.get(midimap), hap.value); const ccs = mapCC(midicontrolMap.get(midimap), hap.value);
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeMs)); ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
} else if (midimap !== 'default') { } else if (midimap !== 'default') {
// Add warning when a non-existent midimap is specified // Add warning when a non-existent midimap is specified
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`); logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
@@ -398,20 +387,16 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// Handle note // Handle note
if (note !== undefined && !midiConfig.isController) { if (note !== undefined && !midiConfig.isController) {
// note off time is calculated early, together with note on time // note off messages will often a few ms arrive late,
// when the note off is due, the clock might have drifted, and the next note on message might happen before the note off // try to prevent glitching by subtracting noteOffsetMs from the duration length
// this would lead to the next note being cut off const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
// 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, timeMs); sendNote(note, velocity, duration, device, midichan, targetTime);
} }
// Handle program change // Handle program change
if (progNum !== undefined) { if (progNum !== undefined) {
sendProgramChange(progNum, device, midichan, timeMs); sendProgramChange(progNum, device, midichan, targetTime);
} }
// Handle sysex // Handle sysex
@@ -421,53 +406,63 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// if sysexid is an array the first byte is 0x00 // if sysexid is an array the first byte is 0x00
if (sysexid !== undefined && sysexdata !== undefined) { if (sysexid !== undefined && sysexdata !== undefined) {
sendSysex(sysexid, sysexdata, device, timeMs); sendSysex(sysexid, sysexdata, device, targetTime);
} }
// Handle control change // Handle control change
if (ccv !== undefined && ccn !== undefined) { if (ccv !== undefined && ccn !== undefined) {
sendCC(ccn, ccv, device, midichan, timeMs); sendCC(ccn, ccv, device, midichan, targetTime);
} }
// Handle NRPN non-registered parameter number // Handle NRPN non-registered parameter number
if (nrpnn !== undefined && nrpv !== undefined) { if (nrpnn !== undefined && nrpv !== undefined) {
sendNRPN(nrpnn, nrpv, device, midichan, timeMs); sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
} }
// Handle midibend // Handle midibend
if (midibend !== undefined) { if (midibend !== undefined) {
sendPitchBend(midibend, device, midichan, timeMs); sendPitchBend(midibend, device, midichan, targetTime);
} }
// Handle miditouch // Handle miditouch
if (miditouch !== undefined) { if (miditouch !== undefined) {
sendAftertouch(miditouch, device, midichan, timeMs); sendAftertouch(miditouch, device, midichan, targetTime);
} }
// Handle midicmd // Handle midicmd
if (hap.whole.begin + 0 === 0) { if (hap.whole.begin + 0 === 0) {
// we need to start here because we have the timing info // we need to start here because we have the timing info
timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs })); scheduleAtTime(() => {
device.sendStart();
}, targetTime);
} }
if (['clock', 'midiClock'].includes(midicmd)) { if (['clock', 'midiClock'].includes(midicmd)) {
timedSend(timeMs, (timeMs) => device.sendClock({ time: timeMs })); scheduleAtTime(() => {
device.sendClock();
}, targetTime);
} else if (['start'].includes(midicmd)) { } else if (['start'].includes(midicmd)) {
timedSend(timeMs, (timeMs) => device.sendStart({ time: timeMs })); scheduleAtTime(() => {
device.sendStart();
}, targetTime);
} else if (['stop'].includes(midicmd)) { } else if (['stop'].includes(midicmd)) {
timedSend(timeMs, (timeMs) => device.sendStop({ time: timeMs })); scheduleAtTime(() => {
device.sendStop();
}, targetTime);
} else if (['continue'].includes(midicmd)) { } else if (['continue'].includes(midicmd)) {
timedSend(timeMs, (timeMs) => device.sendContinue({ time: timeMs })); scheduleAtTime(() => {
device.sendContinue();
}, targetTime);
} else if (Array.isArray(midicmd)) { } else if (Array.isArray(midicmd)) {
if (midicmd[0] === 'progNum') { if (midicmd[0] === 'progNum') {
sendProgramChange(midicmd[1], device, midichan, timeMs); sendProgramChange(midicmd[1], device, midichan, targetTime);
} else if (midicmd[0] === 'cc') { } else if (midicmd[0] === 'cc') {
if (midicmd.length === 2) { if (midicmd.length === 2) {
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeMs); sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
} }
} else if (midicmd[0] === 'sysex') { } else if (midicmd[0] === 'sysex') {
if (midicmd.length === 3) { if (midicmd.length === 3) {
const [_, id, data] = midicmd; const [_, id, data] = midicmd;
sendSysex(id, data, device, timeMs); sendSysex(id, data, device, targetTime);
} }
} }
} }
@@ -542,9 +537,6 @@ export async function midin(input) {
* The note length is fixed as Superdough is not currently set up for undetermined * The note length is fixed as Superdough is not currently set up for undetermined
* note durations * note durations
* *
* The 'midichan' control value contains the number of the channel the note is coming from
* so it could be filtered or manipulated further in the chain.
*
* @name midikeys * @name midikeys
* @tags external_io, midi * @tags external_io, midi
* @param {string | number} input MIDI device name or index defaulting to 0 * @param {string | number} input MIDI device name or index defaulting to 0
@@ -560,18 +552,14 @@ export async function midin(input) {
* .s("saw") * .s("saw")
* .add(note(rand.mul(0.3))) * .add(note(rand.mul(0.3)))
* .lpf(1000).lpe(2).room(0.5) * .lpf(1000).lpe(2).room(0.5)
* @example
* // discard all notes not coming out from midi channel 2
* const kb = await midikeys('Arturia KeyStep 32')
* kb().filterValues(v=>v.midichan==2).s("tri")
*/ */
const kHaps = {}; const kHaps = {};
const kListeners = {}; const kListeners = {};
function _triggerKeyboard(input, cps, now, latencyCycles) { function _triggerKeyboard(input, cps, now, latencyCycles) {
const pattern = getPattern(); const pattern = getPattern();
const schedule = getScheduleFunc(); const trigger = getTriggerFunc();
if (!pattern || !schedule) { if (!pattern || !trigger) {
return false; return false;
} }
const t = now + latencyCycles; const t = now + latencyCycles;
@@ -588,7 +576,8 @@ function _triggerKeyboard(input, cps, now, latencyCycles) {
return; return;
} }
const t = ctxNow + (hap.whole.begin - now) / cps; const t = ctxNow + (hap.whole.begin - now) / cps;
schedule(hap, t, cps); const duration = hap.duration / cps;
trigger(hap, t - ctxNow, duration, cps, t);
}); });
return true; return true;
@@ -623,7 +612,7 @@ export async function midikeys(input) {
noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff noteoff ||= noteon && velocity === 0; // handle devices which may use velocity = 0 to signal noteoff
const key = `${input}_${note}`; const key = `${input}_${note}`;
const cps = getCps() ?? 0.5; const cps = getCps() ?? 0.5;
const triggerAvailable = !!(getPattern() && getScheduleFunc()); const triggerAvailable = !!(getPattern() && getTriggerFunc());
const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency const latencySeconds = triggerAvailable ? 0.01 : 0.06; // avoid missing notes due to cyclist / trigger latency
const now = getTime(); const now = getTime();
const t = now + latencySeconds * cps; const t = now + latencySeconds * cps;
@@ -644,7 +633,7 @@ export async function midikeys(input) {
*/ */
return; return;
} else { } else {
value = { ...value, note: Math.round(note), velocity: velocity / 127, midichan: message.channel }; value = { ...value, note: Math.round(note), velocity: velocity / 127 };
} }
kHaps[input].push(new Hap(span, span, value, {})); kHaps[input].push(new Hap(span, span, value, {}));
if (!noteoff && triggerAvailable) { if (!noteoff && triggerAvailable) {
-50
View File
@@ -84,22 +84,6 @@ describe('mini', () => {
it('supports patterning euclidean rhythms', () => { it('supports patterning euclidean rhythms', () => {
expect(minS('[a(<3 5>, <8 16>)]*2')).toEqual(minS('a(3,8) a(5,16)')); 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", () => { it("reproduces Toussaint's example euclidean algorithms", () => {
const checkEuclid = function (spec, target) { const checkEuclid = function (spec, target) {
expect(minS(`x(${spec[0]},${spec[1]})`)).toEqual(minS(target)); expect(minS(`x(${spec[0]},${spec[1]})`)).toEqual(minS(target));
@@ -134,40 +118,6 @@ describe('mini', () => {
checkEuclid([11, 24], 'x ~ ~ x ~ 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 ~'); 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 ~', () => { it('supports the - alias for ~', () => {
expect(minS('a - b [- c]')).toEqual(minS('a ~ b [~ c]')); expect(minS('a - b [- c]')).toEqual(minS('a ~ b [~ c]'));
}); });
+1
View File
@@ -46,6 +46,7 @@ lib[':'] = tail;
lib['..'] = range; lib['..'] = range;
lib['def'] = () => silence; 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) => 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) { function evaluator(node, scope) {
const { type } = node; const { type } = node;
+33 -38
View File
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Pattern, getClockTime, createParams } from '@strudel/core'; import { Pattern, isPattern } from '@strudel/core';
import Paho from 'paho-mqtt'; import Paho from 'paho-mqtt';
const connections = {}; const connections = {};
@@ -61,6 +61,7 @@ Pattern.prototype.mqtt = function (
const props = { const props = {
onSuccess: onConnect, onSuccess: onConnect,
onFailure: onFailure, onFailure: onFailure,
useSSL: true,
}; };
if (username) { if (username) {
@@ -80,47 +81,41 @@ Pattern.prototype.mqtt = function (
} }
cx.connect(props); cx.connect(props);
} }
return this.onSchedule((hap, targetTime, cps) => { return this.withHap((hap) => {
let msg_topic = topic; const onTrigger = (hap, currentTime, cps, targetTime) => {
if (!cx || !cx.isConnected()) { let msg_topic = topic;
return; if (!cx || !cx.isConnected()) {
} return;
let message = ''; }
if (typeof hap.value === 'object') { let message = '';
let value = hap.value; if (typeof hap.value === 'object') {
let value = hap.value;
// Try to take topic from pattern if it's not set // Try to take topic from pattern if it's not set
if (typeof msg_topic === 'undefined' && 'topic' in value) { if (typeof msg_topic === 'undefined' && 'topic' in value) {
msg_topic = value.topic; msg_topic = value.topic;
if (Array.isArray(msg_topic)) { if (Array.isArray(msg_topic)) {
msg_topic = msg_topic.join('/'); msg_topic = msg_topic.join('/');
}
msg_topic = '/' + msg_topic;
} }
msg_topic = '/' + 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;
} }
if (add_meta) { message = new Paho.Message(message);
const duration = hap.duration.div(cps); message.destinationName = msg_topic;
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 - getClockTime() + latency) * 1000; const offset = (targetTime - currentTime + latency) * 1000;
window.setTimeout(function () { window.setTimeout(function () {
cx.send(message); cx.send(message);
}, offset); }, offset);
};
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
}); });
}; };
// This adds the 'move' and 'motor' commands to strudel
export const { move, motor } = createParams('move', 'motor');
window.move = move;
window.motor = motor;
// This adds the 'robot' command
Pattern.prototype.robot = function (robot_id, address = 'ws://192.168.8.248:9001/mqtt') {
return this.mqtt(undefined, undefined, '/move/' + robot_id, address);
};
+8 -25
View File
@@ -4,8 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { logger, parseNumeral, register, isNote, noteToMidi } from '@strudel/core'; import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core';
import { ensureMinimalOutput, getClockBridge } from '@strudel/webaudio';
let connection; // Promise<OSC> let connection; // Promise<OSC>
function connect() { function connect() {
@@ -43,9 +42,10 @@ export function parseControlsFromHap(hap, cps) {
if (isNote(controls.note)) { if (isNote(controls.note)) {
controls.midinote = noteToMidi(controls.note, controls.octave || 3); controls.midinote = noteToMidi(controls.note, controls.octave || 3);
} else { } else {
controls.midinote = parseNumeral(controls.note); controls.note = parseNumeral(controls.note);
} }
} }
controls.bank && (controls.s = controls.bank + controls.s);
controls.roomsize && (controls.size = parseNumeral(controls.roomsize)); controls.roomsize && (controls.size = parseNumeral(controls.roomsize));
// speed adjustment for CPS is handled on the DSP side in superdirt and pattern side in Strudel, // 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. // so we need to undo the adjustment before sending the message to superdirt.
@@ -55,27 +55,13 @@ export function parseControlsFromHap(hap, cps) {
return controls; return controls;
} }
let timeOrigin = performance.timeOrigin; const collator = new ClockCollator({});
export async function oscOutput(hap, targetTime, cps = 1) {
export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
const ws = await connect(); 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 controls = parseControlsFromHap(hap, cps);
const keyvals = Object.entries(controls).flat(); const keyvals = Object.entries(controls).flat();
const ts = collator.calculateTimestamp(currentTime, targetTime) * 1000;
const msg = { address: '/dirt/play', args: keyvals, timestamp: ts }; const msg = { address: '/dirt/play', args: keyvals, timestamp: ts };
if ('oschost' in hap.value) { if ('oschost' in hap.value) {
@@ -97,7 +83,4 @@ export async function oscOutput(hap, targetTime, cps = 1) {
* @memberof Pattern * @memberof Pattern
* @returns Pattern * @returns Pattern
*/ */
export const osc = register('osc', (pat) => { export const osc = register('osc', (pat) => pat.onTrigger(oscTrigger));
ensureMinimalOutput();
return pat.onSchedule(oscOutput);
});
-1
View File
@@ -38,7 +38,6 @@
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": { "dependencies": {
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/webaudio": "workspace:*",
"osc": "^2.4.5", "osc": "^2.4.5",
"ws": "^8.18.3" "ws": "^8.18.3"
}, },
+7 -4
View File
@@ -1,7 +1,10 @@
/* import { tauriOscOutput } from '../desktopbridge/oscbridge.mjs'; /* import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
import { isTauri } from '../desktopbridge/utils.mjs'; */ import { isTauri } from '../desktopbridge/utils.mjs'; */
import { oscOutput } from './osc.mjs'; import { oscTrigger } from './osc.mjs';
const output = /* isTauri() ? tauriOscOutput : */ oscOutput; const trigger = /* isTauri() ? oscTriggerTauri : */ oscTrigger;
export const superdirtOutput = (hap, targetTime, cps) => output(hap, targetTime, cps); export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
const currentTime = performance.now() / 1000;
return trigger(hap, currentTime, cps, targetTime);
};
-1
View File
@@ -36,7 +36,6 @@
"@strudel/codemirror": "workspace:*", "@strudel/codemirror": "workspace:*",
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*", "@strudel/draw": "workspace:*",
"@strudel/edo": "workspace:*",
"@strudel/hydra": "workspace:*", "@strudel/hydra": "workspace:*",
"@strudel/midi": "workspace:*", "@strudel/midi": "workspace:*",
"@strudel/mini": "workspace:*", "@strudel/mini": "workspace:*",
-1
View File
@@ -8,7 +8,6 @@ export async function prebake() {
core, core,
import('@strudel/draw'), import('@strudel/draw'),
import('@strudel/mini'), import('@strudel/mini'),
import('@strudel/edo'),
import('@strudel/tonal'), import('@strudel/tonal'),
import('@strudel/webaudio'), import('@strudel/webaudio'),
import('@strudel/codemirror'), import('@strudel/codemirror'),
+1 -1
View File
@@ -37,7 +37,7 @@ if (typeof HTMLElement !== 'undefined') {
const drawContext = getDrawContext(); const drawContext = getDrawContext();
const drawTime = [-2, 2]; const drawTime = [-2, 2];
this.editor = new StrudelMirror({ this.editor = new StrudelMirror({
onSchedule: webaudioOutput, defaultOutput: webaudioOutput,
getTime: () => getAudioContext().currentTime, getTime: () => getAudioContext().currentTime,
transpiler, transpiler,
root: container, root: container,
-1
View File
@@ -98,7 +98,6 @@ if (args.includes('--json')) {
} }
writeFileSync(outFile, json, 'utf8'); writeFileSync(outFile, json, 'utf8');
console.log(`Wrote json to ${outFile}`); console.log(`Wrote json to ${outFile}`);
process.exit(0);
} }
console.log( console.log(
+43 -40
View File
@@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Pattern, getClockTime } from '@strudel/core'; import { Pattern, isPattern } from '@strudel/core';
var writeMessagers = {}; var writeMessagers = {};
var choosing = false; var choosing = false;
@@ -64,50 +64,53 @@ function crc16(data) {
} }
Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids = false, name = 'default') { Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids = false, name = 'default') {
if (!(name in writeMessagers)) { return this.withHap((hap) => {
getWriter(name, br); if (!(name in writeMessagers)) {
} getWriter(name, br);
return this.onSchedule((hap, targetTime) => { }
var message = ''; const onTrigger = (hap, currentTime, _cps, targetTime) => {
var chk = 0; var message = '';
if (typeof hap.value === 'object') { var chk = 0;
if ('action' in hap.value) { if (typeof hap.value === 'object') {
var action = hap.value['action']; if ('action' in hap.value) {
if (singlecharids) { var action = hap.value['action'];
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) { if (singlecharids) {
key = key.charAt(0); 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}`;
} }
message += key + ':' + val;
}
message += ')';
if (sendcrc) {
chk = crc16(message);
} }
} else { } else {
for (const [key, val] of Object.entries(hap.value)) { message = hap.value;
message += `${key}:${val}`;
}
} }
} else { const offset = (targetTime - currentTime + latency) * 1000;
message = hap.value;
}
const offset = (targetTime - getClockTime() + latency) * 1000;
window.setTimeout(function () { window.setTimeout(function () {
writeMessagers[name](message, chk); writeMessagers[name](message, chk);
}, offset); }, offset);
};
return hap.setContext({ ...hap.context, onTrigger, dominantTrigger: true });
}); });
}; };
+1 -1
View File
@@ -28,7 +28,7 @@ async function loadFont(name) {
const url = `${soundfontUrl}/${name}.js`; const url = `${soundfontUrl}/${name}.js`;
const preset = await fetch(url).then((res) => res.text()); const preset = await fetch(url).then((res) => res.text());
let [_, data] = preset.split('={'); let [_, data] = preset.split('={');
return new Function('return {' + data)().zones; return eval('{' + data);
}; };
loadCache[name] = load(); loadCache[name] = load();
return loadCache[name]; return loadCache[name];
+3 -2
View File
@@ -479,7 +479,7 @@ export default {
gm_electric_bass_finger: [ gm_electric_bass_finger: [
// Electric Bass (finger): Bass // Electric Bass (finger): Bass
'0330_JCLive_sf2_file', '0330_JCLive_sf2_file',
'0330_FluidR3_GM_sf2_file', '0330_FluidR3_GM_sf2_fible',
'0330_Aspirin_sf2_file', '0330_Aspirin_sf2_file',
//0330_Chaos_sf2_file // same as last //0330_Chaos_sf2_file // same as last
'0330_GeneralUserGS_sf2_file', '0330_GeneralUserGS_sf2_file',
@@ -527,7 +527,7 @@ export default {
'0370_Aspirin_sf2_file', '0370_Aspirin_sf2_file',
// 0370_Chaos_sf2_file // same as last // 0370_Chaos_sf2_file // same as last
'0370_FluidR3_GM_sf2_file', '0370_FluidR3_GM_sf2_file',
'0370_GeneralUserGS_sf2_file', '0370_GeneralUserGS_sf2_fil e',
'0370_JCLive_sf2_file', '0370_JCLive_sf2_file',
//0370_SBLive_sf2 //0370_SBLive_sf2
//0370_SoundBlasterOld_sf2 //0370_SoundBlasterOld_sf2
@@ -1782,5 +1782,6 @@ export default {
'1272_GeneralUserGS_sf2_file', '1272_GeneralUserGS_sf2_file',
'1273_GeneralUserGS_sf2_file', '1273_GeneralUserGS_sf2_file',
'1274_GeneralUserGS_sf2_file', '1274_GeneralUserGS_sf2_file',
'',
], ],
}; };
+1 -1
View File
@@ -3,7 +3,7 @@ import { getAudioContext, registerSound } from '@strudel/webaudio';
import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato'; import { loadSoundfont as _loadSoundfont, startPresetNote } from 'sfumato';
Pattern.prototype.soundfont = function (sf, n = 0) { Pattern.prototype.soundfont = function (sf, n = 0) {
return this.onSchedule((h, targetTime) => { return this.onTrigger((h, ct, cps, targetTime) => {
const ctx = getAudioContext(); const ctx = getAudioContext();
const note = getPlayableNoteValue(h); const note = getPlayableNoteValue(h);
const preset = sf.presets[n % sf.presets.length]; const preset = sf.presets[n % sf.presets.length];
+3 -19
View File
@@ -7,24 +7,15 @@ 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/>. 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 { clearNodePool } from './nodePools.mjs'; let audioContext;
import { ClockBridge } from './clockbridge.mjs';
let audioContext, clockBridge;
export const setDefaultAudioContext = () => { export const setDefaultAudioContext = () => {
return setAudioContext(new AudioContext()); audioContext = new AudioContext();
return audioContext;
}; };
export const setAudioContext = (context) => { 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; audioContext = context;
clockBridge = new ClockBridge(audioContext);
return audioContext; return audioContext;
}; };
@@ -36,13 +27,6 @@ export const getAudioContext = () => {
return audioContext; return audioContext;
}; };
export function getClockBridge() {
if (!clockBridge) {
getAudioContext(); // creates clockBridge
}
return clockBridge;
}
export function getAudioContextCurrentTime() { export function getAudioContextCurrentTime() {
return getAudioContext().currentTime; return getAudioContext().currentTime;
} }
-49
View File
@@ -1,49 +0,0 @@
/*
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)
}
}
+3 -4
View File
@@ -67,14 +67,13 @@ if (typeof window !== 'undefined') {
}); });
} }
export const rawdsp = async (code) => { export const dough = async (code) => {
const ac = getAudioContext(); const ac = getAudioContext();
stop(); stop();
worklet = await dspWorklet(ac, code); worklet = await dspWorklet(ac, code);
worklet.node.connect(ac.destination); worklet.node.connect(ac.destination);
}; };
export function rawdspOutput(hap, time, cps) { export function doughTrigger(hap, currentTime, cps, targetTime) {
const currentTime = getAudioContext().currentTime; window.postMessage({ time: targetTime, dough: hap.value, currentTime, duration: hap.duration, cps });
window.postMessage({ time, dough: hap.value, currentTime, duration: hap.duration, cps });
} }
+3 -18
View File
@@ -6,27 +6,12 @@ import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
export const noises = ['pink', 'white', 'brown', 'crackle']; export const noises = ['pink', 'white', 'brown', 'crackle'];
export function gainNode(value, audioContext = getAudioContext()) { export function gainNode(value) {
const node = audioContext.createGain(); const node = getAudioContext().createGain();
node.gain.value = value; node.gain.value = value;
return node; 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) { export function effectSend(input, effect, wet) {
const send = gainNode(wet); const send = gainNode(wet);
input.connect(send); input.connect(send);
@@ -389,7 +374,7 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
// Certain browsers requires audio nodes to be connected in order for their onended events // 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 // to fire, so we _mute it_ and then connect it to the destination
const zeroGain = gainNode(0, audioContext); const zeroGain = gainNode(0);
zeroGain.connect(audioContext.destination); zeroGain.connect(audioContext.destination);
constantNode.connect(zeroGain); constantNode.connect(zeroGain);
-1
View File
@@ -14,4 +14,3 @@ export * from './modulators.mjs';
export * from './dspworklet.mjs'; export * from './dspworklet.mjs';
export * from './audioContext.mjs'; export * from './audioContext.mjs';
export * from './wavetable.mjs'; export * from './wavetable.mjs';
export * from './clockbridge.mjs';
-13
View File
@@ -5,24 +5,11 @@ 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/>. 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 nodePools = new Map();
const POOL_KEY = Symbol('nodePoolKey'); const POOL_KEY = Symbol('nodePoolKey');
export const isPoolable = (node) => !!node[POOL_KEY]; 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) => { const getNodeTime = (node) => {
return node.context?.currentTime ?? 0; return node.context?.currentTime ?? 0;
}; };
+31 -30
View File
@@ -1,5 +1,5 @@
import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { BASE_MIDI_NOTE, getBaseURL, getCommonSampleInfo, noteToFreq, noteToMidi } from './util.mjs';
import { registerSound, registerWaveTable, soundMap } from './index.mjs'; import { registerSound, registerWaveTable } from './index.mjs';
import { getAudioContext } from './audioContext.mjs'; import { getAudioContext } from './audioContext.mjs';
import { import {
getADSRValues, getADSRValues,
@@ -14,6 +14,13 @@ import { logger } from './logger.mjs';
const bufferCache = {}; // string: Promise<ArrayBuffer> const bufferCache = {}; // string: Promise<ArrayBuffer>
const loadCache = {}; // string: Promise<ArrayBuffer> const loadCache = {}; // string: Promise<ArrayBuffer>
/**
*
* @typedef {Object} SampleMetaData
* @property {string} url
* @property {midi} number
*/
export const getCachedBuffer = (url) => bufferCache[url]; export const getCachedBuffer = (url) => bufferCache[url];
function humanFileSize(bytes, si) { function humanFileSize(bytes, si) {
@@ -30,34 +37,10 @@ function humanFileSize(bytes, si) {
return bytes.toFixed(1) + ' ' + units[u]; return bytes.toFixed(1) + ' ' + units[u];
} }
/**
* Returns the duration, in seconds, of the given sample.
* Has optional param `n` (for instance, the `2` in `s("casio:2")`)
*
* Note: `must` be called with await, otherwise you'll get a pending Promise object.
*
* @name getDuration
* @synonym getDur
* @tags samples
* @param {string} sampleName
* @param {number} (optional) n
*
* @example
* // Set a patterns cycle length to exactly the length of the sample
* samples('github:tidalcycles/dirt-samples')
* let k = await getDuration('sax')
* s("sax").cps(1/k)
*/
export const getDuration = (s, n = 0) => {
return getSampleBufferSource({ s, n }, soundMap.get(s)[s].data.samples).then((x) => x.bufferDuration);
};
export const getDur = getDuration;
export function getSampleInfo(hapValue, bank) { export function getSampleInfo(hapValue, bank) {
const { speed = 1.0 } = hapValue; const { speed = 1.0 } = hapValue;
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank); const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12); const playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
return { transpose, url, index, midi, label, playbackRate }; return { transpose, url, index, midi, label, playbackRate };
} }
@@ -180,15 +163,20 @@ export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '')
if (baseUrl.startsWith('github:')) { if (baseUrl.startsWith('github:')) {
baseUrl = githubPath(baseUrl, ''); baseUrl = githubPath(baseUrl, '');
} }
const fullUrl = (v) => baseUrl + v; /**
*
* @param {string} v
* @returns {SampleMetaData}
*/
const getMetaData = (v) => ({ url: baseUrl + v, midi: extractMidiNoteFromString(v) });
if (Array.isArray(value)) { if (Array.isArray(value)) {
//return [key, value.map(replaceUrl)]; //return [key, value.map(replaceUrl)];
value = value.map(fullUrl); value = value.map(getMetaData);
} else { } else {
// must be object // must be object
value = Object.fromEntries( value = Object.fromEntries(
Object.entries(value).map(([note, samples]) => { Object.entries(value).map(([note, samples]) => {
return [note, (typeof samples === 'string' ? [samples] : samples).map(fullUrl)]; return [note, (typeof samples === 'string' ? [samples] : samples).map(getMetaData)];
}), }),
); );
} }
@@ -391,3 +379,16 @@ export function registerSampleSource(key, bank, params) {
registerSample(key, bank, params); registerSample(key, bank, params);
} }
} }
export function extractMidiNoteFromString(str) {
const regex = /_([a-gA-G])([#b])?([1-9])?\b/;
const match = str.match(regex);
if (match == null) {
return BASE_MIDI_NOTE;
}
const base = match[1].toUpperCase();
const accidental = match[2] ?? '';
const octave_str = match[3] ?? '';
const parsedVal = base + accidental + octave_str;
return noteToMidi(parsedVal);
}
+5 -1
View File
@@ -25,7 +25,7 @@ import {
import { map } from 'nanostores'; import { map } from 'nanostores';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs'; import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs';
import { getSampleBufferSource, loadBuffer } from './sampler.mjs'; import { loadBuffer } from './sampler.mjs';
import { getAudioContext } from './audioContext.mjs'; import { getAudioContext } from './audioContext.mjs';
import { SuperdoughAudioController } from './superdoughoutput.mjs'; import { SuperdoughAudioController } from './superdoughoutput.mjs';
import { resetSeenKeys } from './wavetable.mjs'; import { resetSeenKeys } from './wavetable.mjs';
@@ -1048,3 +1048,7 @@ 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);
};
+2 -4
View File
@@ -485,10 +485,8 @@ export function waveformN(partials, phases, type) {
if (phase !== 0) { if (phase !== 0) {
const c = Math.cos(PI2 * phase); const c = Math.cos(PI2 * phase);
const s = Math.sin(PI2 * phase); const s = Math.sin(PI2 * phase);
const R0 = R; R = c * R - s * I;
const I0 = I; I = s * R + c * I;
R = c * R0 - s * I0;
I = s * R0 + c * I0;
} }
real[n + 1] = R; real[n + 1] = R;
imag[n + 1] = I; imag[n + 1] = I;
+42 -10
View File
@@ -19,7 +19,12 @@ const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => { export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0; return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
}; };
/**
*
* @param {string} note
* @param {number} defaultOctave
* @returns {number}
*/
export const noteToMidi = (note, defaultOctave = 3) => { export const noteToMidi = (note, defaultOctave = 3) => {
const [pc, acc, oct = defaultOctave] = tokenizeNote(note); const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
if (!pc) { if (!pc) {
@@ -29,6 +34,11 @@ export const noteToMidi = (note, defaultOctave = 3) => {
const offset = getAccidentalsOffset(acc); const offset = getAccidentalsOffset(acc);
return (Number(oct) + 1) * 12 + chroma + offset; return (Number(oct) + 1) * 12 + chroma + offset;
}; };
/**
*
* @param {number} n
* @returns {number}
*/
export const midiToFreq = (n) => { export const midiToFreq = (n) => {
return Math.pow(2, (n - 69) / 12) * 440; return Math.pow(2, (n - 69) / 12) * 440;
}; };
@@ -38,7 +48,17 @@ export const freqToMidi = (freq) => {
return (12 * Math.log(freq / 440)) / Math.LN2 + 69; return (12 * Math.log(freq / 440)) / Math.LN2 + 69;
}; };
export const valueToMidi = (value, fallbackValue) => { /**
*
* @param {string} note
* @param {number} defaultOctave
* @returns {number}
*/
export const noteToFreq = (note, defaultOctave = 3) => {
return midiToFreq(noteToMidi(note, defaultOctave));
};
function __valueToMidi(value) {
if (typeof value !== 'object') { if (typeof value !== 'object') {
throw new Error('valueToMidi: expected object value'); throw new Error('valueToMidi: expected object value');
} }
@@ -52,10 +72,15 @@ export const valueToMidi = (value, fallbackValue) => {
if (typeof note === 'number') { if (typeof note === 'number') {
return note; return note;
} }
if (!fallbackValue) { return;
}
export const valueToMidi = (value, fallbackValue) => {
const parsedValue = __valueToMidi(value) ?? fallbackValue;
if (parsedValue == null) {
throw new Error('valueToMidi: expected freq or note to be set'); throw new Error('valueToMidi: expected freq or note to be set');
} }
return fallbackValue; return parsedValue;
}; };
export function nanFallback(value, fallback = 0, silent) { export function nanFallback(value, fallback = 0, silent) {
@@ -84,15 +109,18 @@ export function secondsToCycle(t, cps) {
// deduces relevant info for sample loading from hap.value and sample definition // deduces relevant info for sample loading from hap.value and sample definition
// it encapsulates the core sampler logic into a pure and synchronous function // it encapsulates the core sampler logic into a pure and synchronous function
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format) // hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
export const BASE_MIDI_NOTE = 36;
export function getCommonSampleInfo(hapValue, bank) { export function getCommonSampleInfo(hapValue, bank) {
const { s, n = 0 } = hapValue; const { s, n = 0 } = hapValue;
let midi = valueToMidi(hapValue, 36); const maybeMidiNote = __valueToMidi(hapValue);
let transpose = midi - 36; // C3 is middle C; const midi = maybeMidiNote ?? BASE_MIDI_NOTE;
let url; let transpose = midi - BASE_MIDI_NOTE; // C3 is middle C;
let index = 0; let index = 0;
let samplemeta;
if (Array.isArray(bank)) { if (Array.isArray(bank)) {
index = getSoundIndex(n, bank.length); index = getSoundIndex(n, bank.length);
url = bank[index]; samplemeta = bank[index];
} else { } else {
const midiDiff = (noteA) => noteToMidi(noteA) - midi; const midiDiff = (noteA) => noteToMidi(noteA) - midi;
// object format will expect keys as notes // object format will expect keys as notes
@@ -104,10 +132,14 @@ export function getCommonSampleInfo(hapValue, bank) {
); );
transpose = -midiDiff(closest); // semitones to repitch transpose = -midiDiff(closest); // semitones to repitch
index = getSoundIndex(n, bank[closest].length); index = getSoundIndex(n, bank[closest].length);
url = bank[closest][index]; samplemeta = bank[closest][index];
} }
const label = `${s}:${index}`; const label = `${s}:${index}`;
return { transpose, url, index, midi, label }; if (maybeMidiNote != null) {
transpose = transpose + (BASE_MIDI_NOTE - samplemeta.midi);
}
return { transpose, index, midi, label, url: samplemeta.url };
} }
/** Selects entries from `source` and renames them via `map` /** Selects entries from `source` and renames them via `map`
+2 -2
View File
@@ -1,9 +1,9 @@
import './tonal.mjs'; import './tonal.mjs';
import './voicings.mjs'; import './voicings.mjs';
import './ireal.mjs';
export * from './tonal.mjs'; export * from './tonal.mjs';
export * from './voicings.mjs'; export * from './voicings.mjs';
export * from './ireal.mjs';
import './ireal.mjs';
export const packageName = '@strudel/tonal'; export const packageName = '@strudel/tonal';
+1 -8
View File
@@ -137,14 +137,7 @@ export const { transpose, trans } = register(['transpose', 'trans'], function tr
const interval = !isNaN(Number(intervalOrSemitones)) const interval = !isNaN(Number(intervalOrSemitones))
? Interval.fromSemitones(intervalOrSemitones) ? Interval.fromSemitones(intervalOrSemitones)
: String(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') { if (typeof hap.value === 'object') {
return hap.withValue(() => ({ ...hap.value, note: targetNote })); return hap.withValue(() => ({ ...hap.value, note: targetNote }));
} }
+3 -14
View File
@@ -134,16 +134,6 @@ let modeTarget = {
duck: (v) => v.slice(-1)[0], duck: (v) => v.slice(-1)[0],
above: (v) => v[0], above: (v) => v[0],
root: (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 }) { export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below', anchor = 'c5', octaves = 1 }) {
@@ -154,20 +144,19 @@ export function renderVoicing({ chord, dictionary, offset = 0, n, mode = 'below'
const voicings = dictionary[symbol].map((voicing) => const voicings = dictionary[symbol].map((voicing) =>
(typeof voicing === 'string' ? voicing.split(' ') : voicing).map(step2semitones), (typeof voicing === 'string' ? voicing.split(' ') : voicing).map(step2semitones),
); );
const mult = modeMult[mode];
let minDistance, bestIndex; let minDistance, bestIndex;
// calculate distances up from voicing top notes // calculate distances up from voicing top notes
let chromaDiffs = voicings.map((v, i) => { let chromaDiffs = voicings.map((v, i) => {
const targetStep = modeTarget[mode](v); const targetStep = modeTarget[mode](v);
const diff = _mod((anchorChroma - targetStep - rootChroma) * mult, 12); const diff = _mod(anchorChroma - targetStep - rootChroma, 12);
if (minDistance === undefined || diff < minDistance) { if (minDistance === undefined || diff < minDistance) {
minDistance = diff; minDistance = diff;
bestIndex = i; bestIndex = i;
} }
return diff * mult; return diff;
}); });
if (mode === 'root' || mode === 'oldroot') { if (mode === 'root') {
bestIndex = 0; bestIndex = 0;
} }
-3
View File
@@ -185,9 +185,6 @@ export const rootNotes = register('rootNotes', function (octave, pat) {
* - `below`: top note <= anchor * - `below`: top note <= anchor
* - `duck`: top note <= anchor, anchor excluded * - `duck`: top note <= anchor, anchor excluded
* - `above`: bottom note >= anchor * - `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 * - `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 * - `n`: if set, the voicing is played like a scale. Overshooting numbers will be octaved
* *
+3 -24
View File
@@ -1,31 +1,10 @@
# @strudel/transpiler # @strudel/transpiler
This package contains a JS code transpiler with a plugin system that can be used to transform the code. This package contains a JS code transpiler with the following features:
A plugin can be registered via a `registerTranspilerPlugin` call :
```js
registerTranspilerPlugin({
walk: (context) => ({
enter: function(node, parent, prop, index) {},
leave: function(node, parent, prop, index) {}
})
})
```
where the enter/leave functions (both optional) follow the `estree-walker` walk API.
4 plugins are currently hosted inside `@strudel/transpiler`
- mini: add locations of mini notation strings (double quoted or backticked) for highlighting
- widgets: add handling of sliders & draw widgets
- sample: make it possible to call a sample without await
- kabelsalat: transform the code to handle the `K(..)` kabelsalat notation
it also
- add locations of mini notation strings (double quoted or backticked) for highlighting
- converts pseudo note variables to note strings
- adds return statement to the last expression - adds return statement to the last expression
- handles label capturing for block-based eval
## Install ## Install
+1 -13
View File
@@ -1,17 +1,5 @@
/*
index.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { evaluate as _evaluate } from '@strudel/core'; import { evaluate as _evaluate } from '@strudel/core';
import { transpiler } from './transpiler.mjs'; import { transpiler } from './transpiler.mjs';
export * from './transpiler.mjs'; export * from './transpiler.mjs';
import './plugin-kabelsalat.mjs'; export const evaluate = (code) => _evaluate(code, transpiler);
import './plugin-mini.mjs';
import './plugin-sample.mjs';
import './plugin-widgets.mjs';
export { registerWidgetType, getWidgetID } from './plugin-widgets.mjs';
export const evaluate = (code, transpilerOptions) => _evaluate(code, transpiler, transpilerOptions);
-235
View File
@@ -1,235 +0,0 @@
/*
plugin-kabelsalat.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { walk } from 'estree-walker';
import escodegen from 'escodegen';
import { getLanguages, registerTranspilerPlugin } from './transpiler.mjs';
export function genExprSource(expr) {
return escodegen.generate(expr, { format: { semicolons: false } });
}
function isStrudelPatternWrap(node) {
if (node.type !== 'CallExpression') {
return false;
}
const callee = node.callee;
if (callee.type === 'Identifier') {
return callee.name === 'S';
}
if (callee.type === 'MemberExpression' && !callee.computed) {
return callee.property?.name === 'S';
}
return false;
}
// Used to identify transpiled `m(...)` calls for proper conversion
// to, say, kabelsalat placeholders
function isMiniCall(node) {
if (node.type !== 'CallExpression') {
return false;
}
const callee = node.callee;
if (callee.type !== 'Identifier') {
return false;
}
if (callee.name !== getMinilangName()) {
return false;
}
const firstArg = node.arguments?.[0];
return firstArg?.type === 'Literal' && typeof firstArg.value === 'string';
}
function getMinilangName() {
const minilang = getLanguages().get('minilang');
return minilang?.name || 'm';
}
function replaceNode(node, replacement, parents, currentRoot) {
const info = parents.get(node);
if (!info || !info.parent) {
return replacement;
}
const { parent, prop, index } = info;
if (Array.isArray(parent[prop])) {
parent[prop][index] = replacement;
} else {
parent[prop] = replacement;
}
parents.set(replacement, { parent, prop, index });
return currentRoot;
}
// If `start` is available, we use it. If it's already been transpiled
// to `m(...)`, use the provided offset
function getPatternNodeOrder(node) {
if (typeof node.start === 'number') {
return node.start;
}
if (isMiniCall(node)) {
const offsetArg = node.arguments?.[1];
if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') {
return offsetArg.value;
}
}
return 0;
}
function placeholderAst(index) {
return {
type: 'MemberExpression',
object: { type: 'Identifier', name: 'pat' },
property: { type: 'Literal', value: index },
computed: true,
optional: false,
};
}
function getStrudelPatternExpr(node) {
if (isStrudelPatternWrap(node)) {
const arg = node.arguments?.[0];
if (!arg) {
throw new Error('S(...) requires an argument');
}
return arg;
}
if (isMiniCall(node)) {
return node;
}
return null;
}
function cloneNode(node) {
return JSON.parse(JSON.stringify(node));
}
export function extractPatternPlaceholders(expr) {
const templateExpr = cloneNode(expr);
const parents = new Map();
const targets = [];
walk(templateExpr, {
enter(node, parent, prop, index) {
parents.set(node, { parent, prop, index });
const patternExpr = getStrudelPatternExpr(node);
if (patternExpr) {
targets.push({ node, patternExpr });
this.skip();
}
},
});
if (!targets.length) {
return { template: genExprSource(templateExpr), patternExprs: [] };
}
targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node));
const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr));
let currentExpr = templateExpr;
targets.forEach(({ node }, index) => {
currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr);
});
const template = genExprSource(currentExpr);
return { template, patternExprs };
}
const transpilerPlugin = {
walk: (context) => ({
leave: function (node, parent, prop, index) {
if (!isKabelCall(node)) return;
let [expr, ...rest] = node.arguments;
if (!expr) throw new Error('K(...) requires an expression');
if (shouldCallKabelExpression(expr)) {
expr = {
type: 'CallExpression',
callee: expr,
arguments: [],
optional: false,
};
}
const language = 'kabelsalat';
const { template, patternExprs } = extractPatternPlaceholders(expr);
if (patternExprs.length) {
const workletArgs = [
/*{ type: 'Literal', value: language },*/
{ type: 'Literal', value: template },
...patternExprs,
...rest,
];
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') {
return this.replace({
type: 'CallExpression',
callee: workletMemberAst(callee.object),
arguments: workletArgs,
optional: false,
});
}
return this.replace({
type: 'CallExpression',
callee: { type: 'Identifier', name: 'worklet' },
arguments: workletArgs,
optional: false,
});
}
const kabelSrc = genExprSource(expr);
const workletArgs = [/*{ type: 'Literal', value: language },*/ { type: 'Literal', value: kabelSrc }, ...rest];
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') {
return this.replace({
type: 'CallExpression',
callee: workletMemberAst(callee.object),
arguments: workletArgs,
optional: false,
});
}
return this.replace({
type: 'CallExpression',
callee: { type: 'Identifier', name: 'worklet' },
arguments: workletArgs,
optional: false,
});
},
}),
};
registerTranspilerPlugin(transpilerPlugin);
function isKabelCall(node) {
if (node.type !== 'CallExpression') return false;
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K';
return callee.type === 'Identifier' && callee.name === 'K';
}
function shouldCallKabelExpression(expr) {
if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
return false;
}
if (expr.params.length) {
return false;
}
return expr.body?.type === 'BlockStatement';
}
function workletMemberAst(objectExpr) {
return {
type: 'MemberExpression',
object: objectExpr,
property: { type: 'Identifier', name: 'worklet' },
computed: false,
optional: false,
};
}
-200
View File
@@ -1,200 +0,0 @@
/*
plugin-mini.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { getLeafLocations } from '@strudel/mini';
import { getLanguages, registerTranspilerPlugin } from './transpiler.mjs';
const languageLiteral = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isLanguageLiteral(node)) return;
context.miniLocations ??= [];
const { options, miniLocations } = context;
const { emitMiniLocations } = options;
const { name } = node.tag;
const language = getLanguages().get(name);
const code = node.quasi.quasis[0].value.raw;
const offset = node.quasi.start + 1;
if (emitMiniLocations) {
const locs = language.getLocations(code, offset);
miniLocations.push(...locs);
}
this.skip();
return this.replace(languageWithLocation(name, code, offset));
},
}),
};
const tidal = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isTemplateLiteral(node, 'tidal')) return;
context.miniLocations ??= [];
const { options, miniLocations } = context;
const { emitMiniLocations } = options;
const raw = node.quasi.quasis[0].value.raw;
const offset = node.quasi.start + 1;
if (emitMiniLocations) {
const stringLocs = collectHaskellMiniLocations(raw, offset);
miniLocations.push(...stringLocs);
}
this.skip();
return this.replace(tidalWithLocation(raw, offset));
},
}),
};
const backtick = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isBackTickString(node, parent)) return;
context.miniLocations ??= [];
const { options, input, miniDisableRanges, miniLocations } = context;
const { emitMiniLocations } = options;
if (isMiniDisabled(node.start, miniDisableRanges)) {
return;
}
const { quasis } = node;
const { raw } = quasis[0].value;
this.skip();
emitMiniLocations && collectMiniLocations(raw, node, miniLocations, input);
return this.replace(miniWithLocation(raw, node));
},
}),
};
const doublequotes = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isStringWithDoubleQuotes(node)) return;
context.miniLocations ??= [];
const { options, input, miniDisableRanges, miniLocations } = context;
const { emitMiniLocations } = options;
if (isMiniDisabled(node.start, miniDisableRanges)) {
return;
}
const { value } = node;
this.skip();
emitMiniLocations && collectMiniLocations(value, node, miniLocations, input);
return this.replace(miniWithLocation(value, node));
},
}),
};
function isLanguageLiteral(node) {
return node.type === 'TaggedTemplateExpression' && getLanguages().has(node.tag.name);
}
function languageWithLocation(name, value, offset) {
return {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: name,
},
arguments: [
{ type: 'Literal', value },
{ type: 'Literal', value: offset },
],
optional: false,
};
}
function collectHaskellMiniLocations(haskellCode, offset) {
return haskellCode
.split('')
.reduce((acc, char, i) => {
if (char !== '"') {
return acc;
}
if (!acc.length || acc[acc.length - 1].length > 1) {
acc.push([i + 1]);
} else {
acc[acc.length - 1].push(i);
}
return acc;
}, [])
.map(([start, end]) => {
const miniString = haskellCode.slice(start, end);
return getLeafLocations(`"${miniString}"`, offset + start - 1);
})
.flat();
}
function isTemplateLiteral(node, value) {
return node.type === 'TaggedTemplateExpression' && node.tag.name === value;
}
function tidalWithLocation(value, offset) {
return {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: 'tidal',
},
arguments: [
{ type: 'Literal', value },
{ type: 'Literal', value: offset },
],
optional: false,
};
}
function isBackTickString(node, parent) {
return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression';
}
function isMiniDisabled(offset, miniDisableRanges) {
for (const [start, end] of miniDisableRanges) {
if (offset >= start && offset < end) {
return true;
}
}
return false;
}
function miniWithLocation(value, node) {
const { start: fromOffset } = node;
const minilang = getLanguages().get('minilang');
let name = 'm';
if (minilang && minilang.name) {
name = minilang.name; // name is expected to be exported from the package of the minilang
}
return {
type: 'CallExpression',
callee: {
type: 'Identifier',
name,
},
arguments: [
{ type: 'Literal', value },
{ type: 'Literal', value: fromOffset },
],
optional: false,
};
}
const collectMiniLocations = (value, node, miniLocations, input) => {
const minilang = getLanguages().get('minilang');
if (minilang) {
const code = `[${value}]`;
const locs = minilang.getLocations(code, node.start);
miniLocations.push(...locs);
} else {
const leafLocs = getLeafLocations(`"${value}"`, node.start, input);
miniLocations.push(...leafLocs);
}
};
function isStringWithDoubleQuotes(node, locations, code) {
if (node.type !== 'Literal') {
return false;
}
return node.raw[0] === '"';
}
registerTranspilerPlugin([languageLiteral, tidal, backtick, doublequotes]);
-29
View File
@@ -1,29 +0,0 @@
/*
plugin-sample.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { registerTranspilerPlugin } from './transpiler.mjs';
export function withAwait(node) {
return {
type: 'AwaitExpression',
argument: node,
};
}
const bareSample = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isBareSamplesCall(node, parent)) return;
this.replace(withAwait(node));
},
}),
};
function isBareSamplesCall(node, parent) {
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
}
registerTranspilerPlugin(bareSample);
-110
View File
@@ -1,110 +0,0 @@
/*
plugin-widgets.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { registerTranspilerPlugin } from './transpiler.mjs';
let widgetMethods = [];
export function registerWidgetType(type) {
widgetMethods.push(type);
}
const widgetTranspilerPlugin = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isWidgetMethod(node)) return;
context.widgets ??= [];
const { widgets, options } = context;
const { emitWidgets } = options;
const type = node.callee.property.name;
const idx = widgets.filter((w) => w.type === type).length;
const widgetConfig = {
from: node.start,
to: node.end,
index: idx,
type,
id: options.id,
};
emitWidgets && widgets.push(widgetConfig);
return this.replace(widgetWithLocation(node, widgetConfig));
},
}),
};
const sliderTranspilerPlugin = {
walk: (context) => ({
enter: function (node, parent, prop, index) {
if (!isSliderFunction(node)) return;
context.widgets ??= [];
context.sliders ??= [];
const { options, widgets, sliders } = context;
const { emitWidgets } = options;
const sliderConfig = {
from: node.arguments[0].start,
to: node.arguments[0].end,
value: node.arguments[0].raw, // don't use value!
min: node.arguments[1]?.value ?? 0,
max: node.arguments[2]?.value ?? 1,
step: node.arguments[3]?.value,
type: 'slider',
};
emitWidgets && widgets.push(sliderConfig);
sliders.push(sliderConfig);
return this.replace(sliderWithLocation(node));
},
}),
};
export const widgetTranspilerPlugins = [sliderTranspilerPlugin, widgetTranspilerPlugin];
// these functions are connected to @strudel/codemirror -> slider.mjs
// maybe someday there will be pluggable transpiler functions, then move this there
function isSliderFunction(node) {
return node.type === 'CallExpression' && node.callee.name === 'slider';
}
function isWidgetMethod(node) {
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
}
function sliderWithLocation(node) {
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
// add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({
type: 'Literal',
value: id,
raw: id,
});
node.callee.name = 'sliderWithID';
return node;
}
export function getWidgetID(widgetConfig) {
// the widget id is used as id for the dom element + as key for eventual resources
// for example, for each scope widget, a new analyser + buffer (large) is created
// that means, if we use the index index of line position as id, less garbage is generated
// return `widget_${widgetConfig.to}`; // more gargabe
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
}
function widgetWithLocation(node, widgetConfig) {
const id = getWidgetID(widgetConfig);
// Store the unique ID back into the config so it's available for widget management
// This is critical for block-based evaluation to match existing widgets with new ones
widgetConfig.id = id;
// add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({
type: 'Literal',
value: id,
raw: id,
});
return node;
}
registerTranspilerPlugin(widgetTranspilerPlugins);
+1 -1
View File
@@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { transpiler } from '../index.mjs'; import { transpiler } from '../transpiler.mjs';
const simple = { wrapAsync: false, addReturn: false, simpleLocs: true }; const simple = { wrapAsync: false, addReturn: false, simpleLocs: true };
+555 -41
View File
@@ -1,12 +1,13 @@
/* import { getLeafLocations } from '@strudel/mini';
transpiler.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { parse } from 'acorn'; import { parse } from 'acorn';
import escodegen from 'escodegen'; import escodegen from 'escodegen';
import { walk } from 'estree-walker'; import { walk } from 'estree-walker';
let widgetMethods = [];
export function registerWidgetType(type) {
widgetMethods.push(type);
}
let languages = new Map(); let languages = new Map();
// config = { getLocations: (code: string, offset?: number) => number[][] } // config = { getLocations: (code: string, offset?: number) => number[][] }
// see mondough.mjs for example use // see mondough.mjs for example use
@@ -16,29 +17,16 @@ let languages = new Map();
export function registerLanguage(type, config) { export function registerLanguage(type, config) {
languages.set(type, config); languages.set(type, config);
} }
export function getLanguages() {
return languages;
}
const plugins = [];
export function registerTranspilerPlugin(plugin) {
plugins.push(plugin);
}
export function getPlugins() {
return plugins.flat(Infinity);
}
export function transpiler(input, options = {}) { export function transpiler(input, options = {}) {
options = { const {
wrapAsync: false, wrapAsync = false,
addReturn: true, addReturn = true,
emitMiniLocations: true, emitMiniLocations = true,
emitWidgets: true, emitWidgets = true,
...options, blockBased = false,
}; range = [],
} = options;
const { wrapAsync, addReturn, emitMiniLocations, emitWidgets } = options;
const comments = []; const comments = [];
let ast = parse(input, { let ast = parse(input, {
@@ -48,30 +36,208 @@ export function transpiler(input, options = {}) {
onComment: comments, onComment: comments,
}); });
let miniDisableRanges = findMiniDisableRanges(comments, input.length); const miniDisableRanges = findMiniDisableRanges(comments, input.length);
let miniLocations = [];
// Position offset for block-based evaluation
let nodeOffset = range && range.length > 0 ? range[0] : 0;
// Track declarations to add to strudelScope for block-based eval
let scopeDeclarations = [];
const collectMiniLocations = (value, node) => {
const minilang = languages.get('minilang');
if (minilang) {
const code = `[${value}]`;
const locs = minilang.getLocations(code, node.start);
miniLocations = miniLocations.concat(locs);
} else {
const leafLocs = getLeafLocations(`"${value}"`, node.start, input);
miniLocations = miniLocations.concat(leafLocs);
}
};
let widgets = [];
let sliders = [];
let labels = []; let labels = [];
const context = { options, input, miniDisableRanges, labels };
const plugins = getPlugins().map((plugin) => plugin.walk?.(context));
walk(ast, { walk(ast, {
enter(node, parent, prop, index) { enter(node, parent /* , prop, index */) {
for (const plugin of plugins) { // Apply position offset for block-based evaluation
if (!plugin?.enter?.call(this, node, parent, prop, index)) continue; if (blockBased && node.start !== undefined) {
return; node.start = node.start + nodeOffset;
node.end = node.end + nodeOffset;
} }
// Collect variable and function declarations for strudelScope (block-based eval)
if (blockBased && parent?.type === 'Program') {
if (node.type === 'VariableDeclaration') {
for (const declarator of node.declarations) {
if (declarator.id?.name) {
scopeDeclarations.push(declarator.id.name);
}
}
} else if (node.type === 'FunctionDeclaration' && node.id?.name) {
scopeDeclarations.push(node.id.name);
}
}
if (isLanguageLiteral(node)) {
const { name } = node.tag;
const language = languages.get(name);
const code = node.quasi.quasis[0].value.raw;
const offset = node.quasi.start + 1;
if (emitMiniLocations) {
const locs = language.getLocations(code, offset);
miniLocations = miniLocations.concat(locs);
}
this.skip();
return this.replace(languageWithLocation(name, code, offset));
}
if (isTemplateLiteral(node, 'tidal')) {
const raw = node.quasi.quasis[0].value.raw;
const offset = node.quasi.start + 1;
if (emitMiniLocations) {
const stringLocs = collectHaskellMiniLocations(raw, offset);
miniLocations = miniLocations.concat(stringLocs);
}
this.skip();
return this.replace(tidalWithLocation(raw, offset));
}
if (isBackTickString(node, parent)) {
if (isMiniDisabled(node.start, miniDisableRanges)) {
return;
}
const { quasis } = node;
const { raw } = quasis[0].value;
this.skip();
emitMiniLocations && collectMiniLocations(raw, node);
return this.replace(miniWithLocation(raw, node));
}
if (isStringWithDoubleQuotes(node)) {
if (isMiniDisabled(node.start, miniDisableRanges)) {
return;
}
const { value } = node;
this.skip();
emitMiniLocations && collectMiniLocations(value, node);
return this.replace(miniWithLocation(value, node));
}
if (isSliderFunction(node)) {
const from = node.arguments[0].start + nodeOffset;
const to = node.arguments[0].end + nodeOffset;
const id = `${from}:${to}`; // Range-based ID for stability
const sliderConfig = {
from,
to,
id,
value: node.arguments[0].raw, // don't use value!
min: node.arguments[1]?.value ?? 0,
max: node.arguments[2]?.value ?? 1,
step: node.arguments[3]?.value,
type: 'slider',
};
emitWidgets && widgets.push(sliderConfig);
sliders.push(sliderConfig);
return this.replace(sliderWithLocation(node, nodeOffset));
}
if (isWidgetMethod(node)) {
const type = node.callee.property.name;
const index = widgets.filter((w) => w.type === type).length;
const widgetConfig = {
from: node.start,
to: node.end,
index,
type,
id: options.id,
};
emitWidgets && widgets.push(widgetConfig);
return this.replace(widgetWithLocation(node, widgetConfig));
}
if (isBareSamplesCall(node, parent)) {
return this.replace(withAwait(node));
}
if (isLabelStatement(node)) { if (isLabelStatement(node)) {
// Collect label info for block-based evaluation
// Store positions WITHOUT offset so repl can slice the transpiler output correctly
if (blockBased) {
labels.push({
name: node.label.name,
index: node.start - nodeOffset,
end: node.label.end - nodeOffset,
fullMatch: input.slice(node.start - nodeOffset, node.label.end - nodeOffset),
activeVisualizer: findVisualizerInSubtree(node.body),
});
}
return this.replace(labelToP(node)); 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) { leave(node, parent, prop, index) {
for (const plugin of plugins) { if (!isKabelCall(node)) return;
if (!plugin?.leave?.call(this, node, parent, prop, index)) continue;
return; let [expr, ...rest] = node.arguments;
if (!expr) throw new Error('K(...) requires an expression');
if (shouldCallKabelExpression(expr)) {
expr = {
type: 'CallExpression',
callee: expr,
arguments: [],
optional: false,
};
} }
const { template, patternExprs } = extractPatternPlaceholders(expr);
if (patternExprs.length) {
const workletArgs = [{ type: 'Literal', value: template }, ...patternExprs, ...rest];
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') {
return this.replace({
type: 'CallExpression',
callee: workletMemberAst(callee.object),
arguments: workletArgs,
optional: false,
});
}
return this.replace({
type: 'CallExpression',
callee: { type: 'Identifier', name: 'worklet' },
arguments: workletArgs,
optional: false,
});
}
const kabelSrc = genExprSource(expr);
const workletArgs = [{ type: 'Literal', value: kabelSrc }, ...rest];
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') {
return this.replace({
type: 'CallExpression',
callee: workletMemberAst(callee.object),
arguments: workletArgs,
optional: false,
});
}
return this.replace({
type: 'CallExpression',
callee: { type: 'Identifier', name: 'worklet' },
arguments: workletArgs,
optional: false,
});
}, },
}); });
@@ -90,7 +256,20 @@ export function transpiler(input, options = {}) {
body.push(silenceExpression); body.push(silenceExpression);
} else if (!body?.[body.length - 1]?.expression) { } else if (!body?.[body.length - 1]?.expression) {
// Last statement is not an expression (e.g., VariableDeclaration, FunctionDeclaration) // Last statement is not an expression (e.g., VariableDeclaration, FunctionDeclaration)
body.push(silenceExpression); if (blockBased) {
// For block-based eval, add silence as the return value when block ends with declaration
body.push(silenceExpression);
} else {
throw new Error('unexpected ast format without body expression');
}
}
// For block-based eval, add scope assignments before the return statement
// This allows variables/functions defined in one block to be used in other blocks
if (blockBased && scopeDeclarations.length > 0) {
const scopeAssignments = scopeDeclarations.flatMap((name) => createScopeAssignment(name));
// Insert scope assignments before the last statement (which will become the return)
body.splice(body.length - 1, 0, ...scopeAssignments);
} }
// add return to last statement // add return to last statement
@@ -108,17 +287,280 @@ export function transpiler(input, options = {}) {
if (!emitMiniLocations) { if (!emitMiniLocations) {
return { output }; return { output };
} }
return { output, miniLocations, widgets, sliders, labels };
}
let pluginContext; function isKabelCall(node) {
({ options, input, miniDisableRanges, ...pluginContext } = context); if (node.type !== 'CallExpression') return false;
let callee = node.callee;
if (callee.type === 'ChainExpression') callee = callee.expression;
if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K';
return callee.type === 'Identifier' && callee.name === 'K';
}
return { output, ...pluginContext }; function shouldCallKabelExpression(expr) {
if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
return false;
}
if (expr.params.length) {
return false;
}
return expr.body?.type === 'BlockStatement';
}
function genExprSource(expr) {
return escodegen.generate(expr, { format: { semicolons: false } });
}
function extractPatternPlaceholders(expr) {
const templateExpr = cloneNode(expr);
const parents = new Map();
const targets = [];
walk(templateExpr, {
enter(node, parent, prop, index) {
parents.set(node, { parent, prop, index });
const patternExpr = getStrudelPatternExpr(node);
if (patternExpr) {
targets.push({ node, patternExpr });
this.skip();
}
},
});
if (!targets.length) {
return { template: genExprSource(templateExpr), patternExprs: [] };
}
targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node));
const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr));
let currentExpr = templateExpr;
targets.forEach(({ node }, index) => {
currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr);
});
const template = genExprSource(currentExpr);
return { template, patternExprs };
}
function getStrudelPatternExpr(node) {
if (isStrudelPatternWrap(node)) {
const arg = node.arguments?.[0];
if (!arg) {
throw new Error('S(...) requires an argument');
}
return arg;
}
if (isMiniCall(node)) {
return node;
}
return null;
}
function isStrudelPatternWrap(node) {
if (node.type !== 'CallExpression') {
return false;
}
const callee = node.callee;
if (callee.type === 'Identifier') {
return callee.name === 'S';
}
if (callee.type === 'MemberExpression' && !callee.computed) {
return callee.property?.name === 'S';
}
return false;
}
function getMinilangName() {
const minilang = languages.get('minilang');
return minilang?.name || 'm';
}
// Used to identify transpiled `m(...)` calls for proper conversion
// to, say, kabelsalat placeholders
function isMiniCall(node) {
if (node.type !== 'CallExpression') {
return false;
}
const callee = node.callee;
if (callee.type !== 'Identifier') {
return false;
}
if (callee.name !== getMinilangName()) {
return false;
}
const firstArg = node.arguments?.[0];
return firstArg?.type === 'Literal' && typeof firstArg.value === 'string';
}
// If `start` is available, we use it. If it's already been transpiled
// to `m(...)`, use the provided offset
function getPatternNodeOrder(node) {
if (typeof node.start === 'number') {
return node.start;
}
if (isMiniCall(node)) {
const offsetArg = node.arguments?.[1];
if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') {
return offsetArg.value;
}
}
return 0;
}
function placeholderAst(index) {
return {
type: 'MemberExpression',
object: { type: 'Identifier', name: 'pat' },
property: { type: 'Literal', value: index },
computed: true,
optional: false,
};
}
function replaceNode(node, replacement, parents, currentRoot) {
const info = parents.get(node);
if (!info || !info.parent) {
return replacement;
}
const { parent, prop, index } = info;
if (Array.isArray(parent[prop])) {
parent[prop][index] = replacement;
} else {
parent[prop] = replacement;
}
parents.set(replacement, { parent, prop, index });
return currentRoot;
}
function cloneNode(node) {
return JSON.parse(JSON.stringify(node));
}
function workletMemberAst(objectExpr) {
return {
type: 'MemberExpression',
object: objectExpr,
property: { type: 'Identifier', name: 'worklet' },
computed: false,
optional: false,
};
}
function isStringWithDoubleQuotes(node, locations, code) {
if (node.type !== 'Literal') {
return false;
}
return node.raw[0] === '"';
}
function isBackTickString(node, parent) {
return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression';
}
function miniWithLocation(value, node) {
const { start: fromOffset } = node;
const minilang = languages.get('minilang');
let name = 'm';
if (minilang && minilang.name) {
name = minilang.name; // name is expected to be exported from the package of the minilang
}
return {
type: 'CallExpression',
callee: {
type: 'Identifier',
name,
},
arguments: [
{ type: 'Literal', value },
{ type: 'Literal', value: fromOffset },
],
optional: false,
};
}
// these functions are connected to @strudel/codemirror -> slider.mjs
// maybe someday there will be pluggable transpiler functions, then move this there
function isSliderFunction(node) {
return node.type === 'CallExpression' && node.callee.name === 'slider';
}
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}`;
// add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({
type: 'Literal',
value: id,
raw: id,
});
node.callee.name = 'sliderWithID';
return node;
}
export function getWidgetID(widgetConfig) {
// the widget id is used as id for the dom element + as key for eventual resources
// for example, for each scope widget, a new analyser + buffer (large) is created
// Update: use range-based ID generation for better stability during block evaluation
// When we have both from and to, use them together for stability
// Otherwise fall back to position-based ID for backward compatibility
let uniqueIdentifier;
if (widgetConfig.from !== undefined && widgetConfig.to !== undefined) {
// Use range for more stable identification
uniqueIdentifier = `${widgetConfig.from}-${widgetConfig.to}`;
} else {
// Fallback to single position (for backward compatibility)
uniqueIdentifier = widgetConfig.to || widgetConfig.from || 0;
}
const baseId = `${widgetConfig.id || ''}_widget_${widgetConfig.type}`;
return `${baseId}_${widgetConfig.index}_${uniqueIdentifier}`;
}
function widgetWithLocation(node, widgetConfig) {
const id = getWidgetID(widgetConfig);
// Store the unique ID back into the config so it's available for widget management
// This is critical for block-based evaluation to match existing widgets with new ones
widgetConfig.id = id;
// add loc as identifier to first argument
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
node.arguments.unshift({
type: 'Literal',
value: id,
raw: id,
});
return node;
}
function isBareSamplesCall(node, parent) {
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
} }
function isAllCall(node) { function isAllCall(node) {
return node.type === 'CallExpression' && node.callee.name === 'all'; return node.type === 'CallExpression' && node.callee.name === 'all';
} }
function withAwait(node) {
return {
type: 'AwaitExpression',
argument: node,
};
}
function isLabelStatement(node) { function isLabelStatement(node) {
return node.type === 'LabeledStatement'; return node.type === 'LabeledStatement';
} }
@@ -149,6 +591,69 @@ function labelToP(node) {
}; };
} }
function isLanguageLiteral(node) {
return node.type === 'TaggedTemplateExpression' && languages.has(node.tag.name);
}
// tidal highlighting
// this feels kind of stupid, when we also know the location inside the string op (tidal.mjs)
// but maybe it's the only way
function isTemplateLiteral(node, value) {
return node.type === 'TaggedTemplateExpression' && node.tag.name === value;
}
function collectHaskellMiniLocations(haskellCode, offset) {
return haskellCode
.split('')
.reduce((acc, char, i) => {
if (char !== '"') {
return acc;
}
if (!acc.length || acc[acc.length - 1].length > 1) {
acc.push([i + 1]);
} else {
acc[acc.length - 1].push(i);
}
return acc;
}, [])
.map(([start, end]) => {
const miniString = haskellCode.slice(start, end);
return getLeafLocations(`"${miniString}"`, offset + start - 1);
})
.flat();
}
function tidalWithLocation(value, offset) {
return {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: 'tidal',
},
arguments: [
{ type: 'Literal', value },
{ type: 'Literal', value: offset },
],
optional: false,
};
}
function languageWithLocation(name, value, offset) {
return {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: name,
},
arguments: [
{ type: 'Literal', value },
{ type: 'Literal', value: offset },
],
optional: false,
};
}
// List of non-inline widgets that need cleanup // List of non-inline widgets that need cleanup
// These are Pattern.prototype methods that create persistent visualizations // These are Pattern.prototype methods that create persistent visualizations
// (should be repalced by a function call producing an actual list of registered widgets) // (should be repalced by a function call producing an actual list of registered widgets)
@@ -290,3 +795,12 @@ function findMiniDisableRanges(comments, codeEnd) {
} }
return ranges; return ranges;
} }
function isMiniDisabled(offset, miniDisableRanges) {
for (const [start, end] of miniDisableRanges) {
if (offset >= start && offset < end) {
return true;
}
}
return false;
}
-1
View File
@@ -34,7 +34,6 @@
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": { "dependencies": {
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/edo": "workspace:*",
"@strudel/mini": "workspace:*", "@strudel/mini": "workspace:*",
"@strudel/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
"@strudel/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
+1 -4
View File
@@ -3,10 +3,9 @@ export * from '@strudel/webaudio';
//export * from '@strudel/soundfonts'; //export * from '@strudel/soundfonts';
export * from '@strudel/transpiler'; export * from '@strudel/transpiler';
export * from '@strudel/mini'; export * from '@strudel/mini';
export * from '@strudel/edo';
export * from '@strudel/tonal'; export * from '@strudel/tonal';
export * from '@strudel/webaudio'; export * from '@strudel/webaudio';
import { Pattern, evalScope, setTime, setClockTime } from '@strudel/core'; import { Pattern, evalScope, setTime } from '@strudel/core';
import { initAudioOnFirstClick, registerSynthSounds, webaudioRepl } from '@strudel/webaudio'; import { initAudioOnFirstClick, registerSynthSounds, webaudioRepl } from '@strudel/webaudio';
// import { registerSoundfonts } from '@strudel/soundfonts'; // import { registerSoundfonts } from '@strudel/soundfonts';
import { evaluate as _evaluate, transpiler } from '@strudel/transpiler'; import { evaluate as _evaluate, transpiler } from '@strudel/transpiler';
@@ -18,7 +17,6 @@ export async function defaultPrebake() {
evalScope, evalScope,
import('@strudel/core'), import('@strudel/core'),
import('@strudel/mini'), import('@strudel/mini'),
import('@strudel/edo'),
import('@strudel/tonal'), import('@strudel/tonal'),
import('@strudel/webaudio'), import('@strudel/webaudio'),
{ hush, evaluate }, { hush, evaluate },
@@ -41,7 +39,6 @@ export function initStrudel(options = {}) {
return repl; return repl;
})(); })();
setTime(() => repl.scheduler.now()); setTime(() => repl.scheduler.now());
setClockTime(() => repl.scheduler.getTime()); // TODO: refactor?
return initDone; return initDone;
} }
+1 -1
View File
@@ -19,7 +19,7 @@ initAudioOnFirstClick();
const ctx = getAudioContext(); const ctx = getAudioContext();
const { scheduler } = repl({ const { scheduler } = repl({
onSchedule: webaudioOutput, defaultOutput: webaudioOutput,
getTime: () => ctx.currentTime getTime: () => ctx.currentTime
}); });
+5 -5
View File
@@ -20,7 +20,7 @@ const soundMap = new Map();
const loadedSounds = new Map(); const loadedSounds = new Map();
Pattern.prototype.supradough = function () { Pattern.prototype.supradough = function () {
return this.onSchedule((hap, begin, cps) => { return this.onTrigger((hap, __, cps, begin) => {
hap.value._begin = begin; hap.value._begin = begin;
hap.value._duration = hap.duration / cps; hap.value._duration = hap.duration / cps;
!doughWorklet && initDoughWorklet(); !doughWorklet && initDoughWorklet();
@@ -61,7 +61,7 @@ function githubPath(base, subpath = '') {
} }
return `https://raw.githubusercontent.com/${path}/${subpath}`; return `https://raw.githubusercontent.com/${path}/${subpath}`;
} }
export async function _fetchSampleMap(url) { export async function fetchSampleMap(url) {
if (url.startsWith('github:')) { if (url.startsWith('github:')) {
url = githubPath(url, 'strudel.json'); url = githubPath(url, 'strudel.json');
} }
@@ -114,11 +114,11 @@ async function fetchSample(url) {
return { channels, sampleRate: buffer.sampleRate }; return { channels, sampleRate: buffer.sampleRate };
} }
export async function supradoughsamples(sampleMap, baseUrl) { export async function doughsamples(sampleMap, baseUrl) {
if (typeof sampleMap === 'string') { if (typeof sampleMap === 'string') {
const [json, base] = await _fetchSampleMap(sampleMap); const [json, base] = await fetchSampleMap(sampleMap);
// console.log('json', json, 'base', base); // console.log('json', json, 'base', base);
return supradoughsamples(json, base); return doughsamples(json, base);
} }
Object.entries(sampleMap).map(async ([key, urls]) => { Object.entries(sampleMap).map(async ([key, urls]) => {
if (key !== '_base') { if (key !== '_base') {

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