Compare commits

..

16 Commits

Author SHA1 Message Date
Alex McLean 70c932a760 Merge branch 'cycler' into textbox 2025-10-14 12:39:26 +02:00
Alex McLean f5631a01ae fixes 2025-10-14 11:02:53 +01:00
Alex McLean 989b5dcc3a thingie to display current pattern value 2025-10-14 10:41:57 +01:00
alex 5de891f31d format 2025-10-14 08:57:25 +01:00
alex 325e348bbf Merge branch 'main' into cycler 2025-10-14 08:56:47 +01:00
alex fe117e23fd start of tap tempo 2025-10-14 08:56:11 +01:00
Alex McLean 69a82ddc5b low latency clock control experimentation 2025-10-08 12:18:32 +01:00
Alex McLean 4209506fd0 Merge branch 'main' into cycler 2025-10-01 13:27:49 +02:00
Alex McLean ff790c803d format 2025-10-01 12:01:19 +01:00
Alex McLean f0efaabe20 Merge branch 'main' into cycler 2025-10-01 11:58:19 +01:00
alex 5097ae8fc0 use encoder disc 2025-03-01 18:07:25 +00:00
alex 6b1e6cc7af try to make more reactish 2025-03-01 16:53:13 +00:00
alex 9c6eca9a59 it works 2025-03-01 15:55:10 +00:00
alex 02bb88bde4 rm 2025-03-01 10:57:39 +00:00
alex c16511dfbd towards spinning a disk matching cycles inside strudel 2025-03-01 10:55:37 +00:00
alex 5054756ca2 experiment 2025-02-28 17:20:28 +00:00
117 changed files with 7501 additions and 9895 deletions
-37
View File
@@ -1,37 +0,0 @@
name: Build and Deploy to beta (warm.strudel.cc)
on: [workflow_dispatch]
# Allow one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
runs-on: docker
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.12.2
- uses: actions/setup-node@v4
with:
node-version: 20
# cache: "pnpm"
- name: Install Dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Deploy
run: |
eval $(ssh-agent -s)
echo "$SSH_PRIVATE_KEY" | ssh-add -
apt update && apt install -y rsync
mkdir ~/.ssh
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/warm.strudel.cc
+2 -2
View File
@@ -1,4 +1,4 @@
name: Build and Deploy to live (strudel.cc) name: Build and Deploy
on: [workflow_dispatch] on: [workflow_dispatch]
@@ -34,4 +34,4 @@ jobs:
apt update && apt install -y rsync apt update && apt install -y rsync
mkdir ~/.ssh mkdir ~/.ssh
ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts ssh-keyscan matrix.toplap.org > ~/.ssh/known_hosts
rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy/strudel.cc rsync -atv --delete --delete-after --progress ./website/dist/ strudel@matrix.toplap.org:/home/strudel/deploy
-14
View File
@@ -45,20 +45,6 @@ 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
View File
@@ -1 +0,0 @@
22
-88
View File
@@ -1,88 +0,0 @@
# changelog
NOTE: you can generate this with `node warm.js`. it might still not be perfectly sorted...
## november 2025
- 2025-11-27T22:03:53+01:00 [perf] in `noise`, let noiseMix do the disconnect when it exists by jeromew in: [#1783](https://codeberg.org/uzu/strudel/pulls/1783)
- 2025-11-27T22:01:01+01:00 Bug Fix: Retries for sounds tab by glossing in: [#1754](https://codeberg.org/uzu/strudel/pulls/1754)
- 2025-11-27T20:36:46+01:00 [hydra] return the hydra object when await initHydra(..) is called by jeromew in: [#1784](https://codeberg.org/uzu/strudel/pulls/1784)
- 2025-11-27T20:36:14+01:00 Use errorLogger for query and tonal errors by glossing in: [#1782](https://codeberg.org/uzu/strudel/pulls/1782)
- 2025-11-27T20:27:10+01:00 [perf] level 5 `connect-leak` on `vowel` by jeromew in: [#1779](https://codeberg.org/uzu/strudel/pulls/1779)
- 2025-11-26T08:23:38+01:00 feat: add prebake script import button for loading .strudel files by daslyfe in: [#1774](https://codeberg.org/uzu/strudel/pulls/1774)
- 2025-11-26T07:39:28+01:00 Fix Sampler port trampling by Dayglo in: [#1717](https://codeberg.org/uzu/strudel/pulls/1717)
- 2025-11-25T20:59:09+01:00 Feat: Hook up octave and fix example by glossing in: [#1773](https://codeberg.org/uzu/strudel/pulls/1773)
- 2025-11-25T20:48:09+01:00 [perf] fix `connect-leak` in `tremolo` param by jeromew in: [#1780](https://codeberg.org/uzu/strudel/pulls/1780)
- 2025-11-24T17:51:20+01:00 filter modulation improvements! by daslyfe in: [#1769](https://codeberg.org/uzu/strudel/pulls/1769)
- 2025-11-24T01:20:53+01:00 Add o as a synonym for orbit by daslyfe in: [#1766](https://codeberg.org/uzu/strudel/pulls/1766)
- 2025-11-23T22:23:26+01:00 [perf] fix `connect-leak` in `fm` modulation by jeromew in: [#1758](https://codeberg.org/uzu/strudel/pulls/1758)
- 2025-11-23T09:47:24+01:00 Fix interoperability issue between `all` and `await initHydra()` by jeromew in: [#1663](https://codeberg.org/uzu/strudel/pulls/1663)
- 2025-11-23T04:23:06+01:00 Bug fix: Swap l/r gains with temp variable by glossing in: [#1768](https://codeberg.org/uzu/strudel/pulls/1768)
- 2025-11-23T00:52:28+01:00 FIX: prevent LFO filter modulation from popping from negative values by daslyfe in: [#1767](https://codeberg.org/uzu/strudel/pulls/1767)
- 2025-11-21T01:56:11+01:00 Bug Fix: Use frac due to negative frequencies from FM by glossing in: [#1759](https://codeberg.org/uzu/strudel/pulls/1759)
- 2025-11-20T19:48:16+01:00 [perf] fix `connect-leak` added by #1742 when noise() is not used by jeromew in: [#1757](https://codeberg.org/uzu/strudel/pulls/1757)
- 2025-11-20T14:51:00+01:00 [perf] fix `connect-leak` in `delay` effect by jeromew in: [#1755](https://codeberg.org/uzu/strudel/pulls/1755)
- 2025-11-20T14:49:41+01:00 [perf] fix `connect leak` when .noise() is in the mix by jeromew in: [#1742](https://codeberg.org/uzu/strudel/pulls/1742)
- 2025-11-18T23:52:45+01:00 wchooseCycles has now notes in an example by scrappy_fiddler in: [#1748](https://codeberg.org/uzu/strudel/pulls/1748)
- 2025-11-17T05:31:54+01:00 Feature: Partials by glossing in: [#1659](https://codeberg.org/uzu/strudel/pulls/1659)
- 2025-11-16T22:06:14+01:00 README: update superdough documentation by TristanMlct in: [#1741](https://codeberg.org/uzu/strudel/pulls/1741)
- 2025-11-16T21:08:54+01:00 Worklet optimizations by glossing in: [#1730](https://codeberg.org/uzu/strudel/pulls/1730)
- 2025-11-15T14:59:30+01:00 [perf] disconnect Offline AudioNode connections in generateReverb by jeromew in: [#1740](https://codeberg.org/uzu/strudel/pulls/1740)
- 2025-11-15T14:57:47+01:00 [perf] disconnect `send` effect AudioNode when `room` is used by jeromew in: [#1736](https://codeberg.org/uzu/strudel/pulls/1736)
- 2025-11-12T21:22:34+01:00 [supradough] fix: unstable filter by froos in: [#1593](https://codeberg.org/uzu/strudel/pulls/1593)
- 2025-11-12T21:12:07+01:00 Fix link syntax in `project-start` by Kissaki in: [#1724](https://codeberg.org/uzu/strudel/pulls/1724)
- 2025-11-12T21:06:13+01:00 Fix web README sample code by Kissaki in: [#1725](https://codeberg.org/uzu/strudel/pulls/1725)
- 2025-11-12T20:59:39+01:00 Fix typo: 'studel' -> 'strudel' by drhayes in: [#1726](https://codeberg.org/uzu/strudel/pulls/1726)
- 2025-11-12T15:23:41+01:00 fix for node 24 support in tests - #1718 by ausav in: [#1719](https://codeberg.org/uzu/strudel/pulls/1719)
- 2025-11-11T09:00:49+01:00 soundAlias example fix by PepsiiMan in: [#1720](https://codeberg.org/uzu/strudel/pulls/1720)
- 2025-11-10T08:18:36+01:00 fix: Make Supradough package build by munshkr in: [#1711](https://codeberg.org/uzu/strudel/pulls/1711)
- 2025-11-10T08:18:00+01:00 added docs for spaces in scale names by ondras in: [#1715](https://codeberg.org/uzu/strudel/pulls/1715)
- 2025-11-10T02:44:01+01:00 Feature: LFOs for Filters by glossing in: [#1636](https://codeberg.org/uzu/strudel/pulls/1636)
- 2025-11-05T17:07:00+01:00 improvement: sync midi messages to audio context clock by daslyfe in: [#1708](https://codeberg.org/uzu/strudel/pulls/1708)
- 2025-11-04T19:36:14+01:00 Add stick button mappings to gamepad implementation and improve docs by kaosuryoko in: [#1696](https://codeberg.org/uzu/strudel/pulls/1696)
- 2025-11-04T19:29:35+01:00 Refactor sound stopping and triggering logic in SoundsTab component by IJOL in: [#1688](https://codeberg.org/uzu/strudel/pulls/1688)
- 2025-11-04T19:13:21+01:00 Add setting to toggle pattern auto-start on pattern change by moumar in: [#1690](https://codeberg.org/uzu/strudel/pulls/1690)
- 2025-11-04T18:58:50+01:00 Voicings JSDoc by sharkeys_lunchbox in: [#1686](https://codeberg.org/uzu/strudel/pulls/1686)
- 2025-11-01T07:50:40+01:00 FIX: Loading local samples uses too much memory by daslyfe in: [#1706](https://codeberg.org/uzu/strudel/pulls/1706)
## october 2025
- 2025-10-28T22:58:24+01:00 Bug Fix: Handle scale-for-notes when n also supplied by glossing in: [#1625](https://codeberg.org/uzu/strudel/pulls/1625)
- 2025-10-28T22:51:16+01:00 Repurpose vim shortcuts for usability by dtricks in: [#1624](https://codeberg.org/uzu/strudel/pulls/1624)
- 2025-10-28T22:35:21+01:00 Add support for euclidian in mondo with `bd&3:8` by TristanCacqueray in: [#1630](https://codeberg.org/uzu/strudel/pulls/1630)
- 2025-10-27T10:42:07+01:00 Use bunny cdn for all samples and json files by yaxu in: [#1701](https://codeberg.org/uzu/strudel/pulls/1701)
- 2025-10-26T22:52:54+01:00 degithub - switch some samples to bunnycdn by yaxu in: [#1697](https://codeberg.org/uzu/strudel/pulls/1697)
- 2025-10-26T17:09:22+01:00 Fix ZZFX example by moumar in: [#1685](https://codeberg.org/uzu/strudel/pulls/1685)
- 2025-10-23T15:56:04+02:00 Make osc port and host configurable. Changes dependencies. by yaxu in: [#1682](https://codeberg.org/uzu/strudel/pulls/1682)
- 2025-10-23T03:47:31+02:00 Adds back shape to superdough by glossing in: [#1672](https://codeberg.org/uzu/strudel/pulls/1672)
- 2025-10-22T22:15:19+02:00 Fix sampler.mjs githubPath by jeromew in: [#1684](https://codeberg.org/uzu/strudel/pulls/1684)
- 2025-10-22T16:20:50+02:00 Fix onPaint for widgets by yaxu in: [#1658](https://codeberg.org/uzu/strudel/pulls/1658)
- 2025-10-22T15:19:12+02:00 Fix a bug introduced by #4e17cfbdd6 by milliganf in: [#1679](https://codeberg.org/uzu/strudel/pulls/1679)
- 2025-10-18T04:29:20+02:00 Bug Fix: Wavetable: phase wrapping at 1 and detune by glossing in: [#1620](https://codeberg.org/uzu/strudel/pulls/1620)
- 2025-10-16T20:26:28+02:00 github samples: default to "samples" if repository is not specified by prezmop in: [#1644](https://codeberg.org/uzu/strudel/pulls/1644)
- 2025-10-16T17:33:39+02:00 Docs: add example of custom chained function by dariusk in: [#1642](https://codeberg.org/uzu/strudel/pulls/1642)
- 2025-10-14T12:39:48+02:00 textbox by yaxu in: [#1650](https://codeberg.org/uzu/strudel/pulls/1650)
- 2025-10-13T07:05:57+02:00 Feature: Wavetable FM by glossing in: [#1623](https://codeberg.org/uzu/strudel/pulls/1623)
- 2025-10-12T20:43:42+02:00 fix: repair REPL sample sources and URL concat (#1640) by erikfox in: [#1646](https://codeberg.org/uzu/strudel/pulls/1646)
- 2025-10-10T11:25:02+02:00 Add control-metadata by yaxu in: [#1634](https://codeberg.org/uzu/strudel/pulls/1634)
- 2025-10-07T22:59:39+02:00 Fix MQTT: change the trigger handler to match new hap by jurrchen in: [#1629](https://codeberg.org/uzu/strudel/pulls/1629)
- 2025-10-05T21:42:19+02:00 Fix case sensitivity for synonym search by vvolhejn in: [#1618](https://codeberg.org/uzu/strudel/pulls/1618)
- 2025-10-01T08:56:35+02:00 Optimize wavetable synth by glossing in: [#1613](https://codeberg.org/uzu/strudel/pulls/1613)
- 2025-10-01T02:02:33+02:00 fix: pattern switching by daslyfe in: [#1616](https://codeberg.org/uzu/strudel/pulls/1616)
## September 2025
- 2025-09-29T02:10:54+02:00 fix wavetable JSON by daslyfe in: [#1611](https://codeberg.org/uzu/strudel/pulls/1611)
- 2025-09-28T21:28:16+02:00 rename wavetable controls to match existing naming conventions by daslyfe in: [#1610](https://codeberg.org/uzu/strudel/pulls/1610)
- 2025-09-27T23:28:13+02:00 Feature: Wavetable synth improvements by glossing in: [#1607](https://codeberg.org/uzu/strudel/pulls/1607)
- 2025-09-27T21:43:10+02:00 wavetable improvements by daslyfe in: [#1608](https://codeberg.org/uzu/strudel/pulls/1608)
- 2025-09-26T08:48:46+02:00 create orbit based DJ filter by daslyfe in: [#1603](https://codeberg.org/uzu/strudel/pulls/1603)
- 2025-09-23T13:28:20+02:00 fix: time signal by daslyfe in: [#1583](https://codeberg.org/uzu/strudel/pulls/1583)
- 2025-09-21T16:16:06+02:00 mondo: fix all + setcpm + setcps by froos in: [#1600](https://codeberg.org/uzu/strudel/pulls/1600)
- 2025-09-19T21:59:43+02:00 fix: osc error message by froos in: [#1597](https://codeberg.org/uzu/strudel/pulls/1597)
- 2025-09-18T21:09:56+02:00 osc: --debug flag to see incoming messages by froos in: [#1595](https://codeberg.org/uzu/strudel/pulls/1595)
- 2025-09-17T14:12:10+02:00 simplify osc usage by froos in: [#1588](https://codeberg.org/uzu/strudel/pulls/1588)
- 2025-09-16T16:59:58+02:00 dev: logger errors to console in dev mode by daslyfe in: [#1585](https://codeberg.org/uzu/strudel/pulls/1585)
- 2025-09-15T22:52:14+02:00 add tic80 font by froos in: [#1579](https://codeberg.org/uzu/strudel/pulls/1579)
- 2025-09-15T00:00:19+02:00 supradough: fix delay + add some jsdoc types by froos in: [#1578](https://codeberg.org/uzu/strudel/pulls/1578)
+1 -1
View File
@@ -13,7 +13,7 @@ https://strudel.cc/
After cloning the project, you can run the REPL locally: After cloning the project, you can run the REPL locally:
1. Install [Node.js](https://nodejs.org/) 18 or newer 1. Install [Node.js](https://nodejs.org/)
2. Install [pnpm](https://pnpm.io/installation) 2. Install [pnpm](https://pnpm.io/installation)
3. Install dependencies by running the following command: 3. Install dependencies by running the following command:
```bash ```bash
-3
View File
@@ -20,8 +20,5 @@
"@strudel/tonal": "workspace:*", "@strudel/tonal": "workspace:*",
"@strudel/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
"@strudel/webaudio": "workspace:*" "@strudel/webaudio": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-3
View File
@@ -14,8 +14,5 @@
}, },
"dependencies": { "dependencies": {
"@strudel/web": "workspace:*" "@strudel/web": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-3
View File
@@ -18,8 +18,5 @@
"@strudel/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
"@strudel/webaudio": "workspace:*", "@strudel/webaudio": "workspace:*",
"@strudel/tonal": "workspace:*" "@strudel/tonal": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-3
View File
@@ -13,8 +13,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-3
View File
@@ -32,8 +32,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-3
View File
@@ -73,8 +73,5 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"vitest": "^3.0.4", "vitest": "^3.0.4",
"vite-plugin-bundle-audioworklet": "workspace:*" "vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+8 -340
View File
@@ -1,11 +1,6 @@
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');
@@ -80,53 +75,6 @@ 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);
@@ -165,299 +113,19 @@ const jsdocCompletions = (() => {
return completions; return completions;
})(); })();
// --- Handler functions for each context --- export const strudelAutocomplete = (context) => {
const pitchNames = [ const word = context.matchBefore(/\w*/);
'C', if (word.from === word.to && !context.explicit) return null;
'C#',
'Db',
'D',
'D#',
'Eb',
'E',
'E#',
'Fb',
'F',
'F#',
'Gb',
'G',
'G#',
'Ab',
'A',
'A#',
'Bb',
'B',
'B#',
'Cb',
];
// Cached regex patterns for scaleHandler
const SCALE_NO_QUOTES_REGEX = /scale\(\s*$/;
const SCALE_AFTER_COLON_REGEX = /scale\(\s*['"][^'"]*:[^'"]*$/;
const SCALE_PRE_COLON_REGEX = /scale\(\s*['"][^'"]*$/;
const SCALE_PITCH_MATCH_REGEX = /([A-Ga-g][#b]*)?$/;
const SCALE_SPACES_TO_COLON_REGEX = /\s+/g;
function scaleHandler(context) {
// First check for scale context without quotes - block with empty completions
let scaleNoQuotesContext = context.matchBefore(SCALE_NO_QUOTES_REGEX);
if (scaleNoQuotesContext) {
return {
from: scaleNoQuotesContext.to,
options: [],
};
}
// Check for after-colon context first (more specific)
let scaleAfterColonContext = context.matchBefore(SCALE_AFTER_COLON_REGEX);
if (scaleAfterColonContext) {
const text = scaleAfterColonContext.text;
const colonIdx = text.lastIndexOf(':');
if (colonIdx !== -1) {
const fragment = text.slice(colonIdx + 1);
const filteredScales = scaleCompletions.filter((s) => s.label.startsWith(fragment));
const options = filteredScales.map((s) => ({
...s,
apply: s.label.replace(SCALE_SPACES_TO_COLON_REGEX, ':'),
}));
const from = scaleAfterColonContext.from + colonIdx + 1;
return {
from,
options,
};
}
}
// Then check for pre-colon context
let scalePreColonContext = context.matchBefore(SCALE_PRE_COLON_REGEX);
if (scalePreColonContext) {
if (!scalePreColonContext.text.includes(':')) {
if (context.explicit) {
const text = scalePreColonContext.text;
const match = text.match(SCALE_PITCH_MATCH_REGEX);
const fragment = match ? match[0] : '';
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
const from = scalePreColonContext.to - fragment.length;
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
return { from, options };
} else {
return { from: scalePreColonContext.to, options: [] };
}
}
}
return null;
}
// Cached regex patterns for soundHandler
const SOUND_NO_QUOTES_REGEX = /(s|sound)\(\s*$/;
const SOUND_WITH_QUOTES_REGEX = /(s|sound)\(\s*['"][^'"]*$/;
const SOUND_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w]*)$/;
function soundHandler(context) {
// First check for sound context without quotes - block with empty completions
let soundNoQuotesContext = context.matchBefore(SOUND_NO_QUOTES_REGEX);
if (soundNoQuotesContext) {
return {
from: soundNoQuotesContext.to,
options: [],
};
}
// Then check for sound context with quotes - provide completions
let soundContext = context.matchBefore(SOUND_WITH_QUOTES_REGEX);
if (!soundContext) return null;
const text = soundContext.text;
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
if (quoteIdx === -1) return null;
const inside = text.slice(quoteIdx + 1);
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
const fragment = fragMatch ? fragMatch[1] : inside;
const soundNames = Object.keys(soundMap?.get() ?? {}).sort();
const filteredSounds = soundNames.filter((name) => name.includes(fragment));
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
const from = soundContext.to - fragment.length;
return {
from,
options,
};
}
// Cached regex patterns for bankHandler
const BANK_NO_QUOTES_REGEX = /bank\(\s*$/;
const BANK_WITH_QUOTES_REGEX = /bank\(\s*['"][^'"]*$/;
function bankHandler(context) {
// First check for bank context without quotes - block with empty completions
let bankNoQuotesContext = context.matchBefore(BANK_NO_QUOTES_REGEX);
if (bankNoQuotesContext) {
return {
from: bankNoQuotesContext.to,
options: [],
};
}
// Then check for bank context with quotes - provide completions
let bankMatch = context.matchBefore(BANK_WITH_QUOTES_REGEX);
if (!bankMatch) return null;
const text = bankMatch.text;
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
if (quoteIdx === -1) return null;
const inside = text.slice(quoteIdx + 1);
const fragment = inside;
let banks = bankCompletions();
const filteredBanks = banks.filter((b) => b.label.startsWith(fragment));
const from = bankMatch.to - fragment.length;
return {
from,
options: filteredBanks,
};
}
// Cached regex patterns for modeHandler
const MODE_NO_QUOTES_REGEX = /mode\(\s*$/;
const MODE_AFTER_COLON_REGEX = /mode\(\s*['"][^'"]*:[^'"]*$/;
const MODE_PRE_COLON_REGEX = /mode\(\s*['"][^'"]*$/;
const MODE_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w:]*)$/;
function modeHandler(context) {
// First check for mode context without quotes - block with empty completions
let modeNoQuotesContext = context.matchBefore(MODE_NO_QUOTES_REGEX);
if (modeNoQuotesContext) {
return {
from: modeNoQuotesContext.to,
options: [],
};
}
// Check for after-colon context first (more specific)
let modeAfterColonContext = context.matchBefore(MODE_AFTER_COLON_REGEX);
if (modeAfterColonContext) {
const text = modeAfterColonContext.text;
const colonIdx = text.lastIndexOf(':');
if (colonIdx !== -1) {
const fragment = text.slice(colonIdx + 1);
// For anchor after colon, we can suggest pitch names
const filtered = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
const options = filtered.map((p) => ({ label: p, type: 'pitch' }));
const from = modeAfterColonContext.from + colonIdx + 1;
return {
from,
options,
};
}
}
// Then check for pre-colon context
let modeContext = context.matchBefore(MODE_PRE_COLON_REGEX);
if (!modeContext) return null;
const text = modeContext.text;
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
if (quoteIdx === -1) return null;
const inside = text.slice(quoteIdx + 1);
const fragMatch = inside.match(MODE_FRAGMENT_MATCH_REGEX);
const fragment = fragMatch ? fragMatch[1] : inside;
const filteredModes = modeCompletions.filter((m) => m.label.startsWith(fragment));
const from = modeContext.to - fragment.length;
return {
from,
options: filteredModes,
};
}
// Cached regex patterns for chordHandler
const CHORD_NO_QUOTES_REGEX = /chord\(\s*$/;
const CHORD_WITH_QUOTES_REGEX = /chord\(\s*['"][^'"]*$/;
const CHORD_FRAGMENT_MATCH_REGEX = /(?:[\s[{(<])([\w#b+^:-]*)$/;
function chordHandler(context) {
// First check for chord context without quotes - block with empty completions
let chordNoQuotesContext = context.matchBefore(CHORD_NO_QUOTES_REGEX);
if (chordNoQuotesContext) {
return {
from: chordNoQuotesContext.to,
options: [],
};
}
// Then check for chord context with quotes - provide completions
let chordContext = context.matchBefore(CHORD_WITH_QUOTES_REGEX);
if (!chordContext) return null;
const text = chordContext.text;
const quoteIdx = Math.max(text.lastIndexOf('"'), text.lastIndexOf("'"));
if (quoteIdx === -1) return null;
const inside = text.slice(quoteIdx + 1);
// Use same fragment matching as sound/mode for expressions like "<G Am>"
const fragMatch = inside.match(CHORD_FRAGMENT_MATCH_REGEX);
const fragment = fragMatch ? fragMatch[1] : inside;
// Check if fragment contains any pitch name at start (for root + symbol)
let rootMatch = null;
let symbolFragment = fragment;
for (const pitch of pitchNames) {
if (fragment.toLowerCase().startsWith(pitch.toLowerCase())) {
rootMatch = pitch;
symbolFragment = fragment.slice(pitch.length);
break;
}
}
if (rootMatch) {
// We have a root, now complete chord symbols
const filteredSymbols = chordSymbolCompletions.filter((s) =>
s.label.toLowerCase().startsWith(symbolFragment.toLowerCase()),
);
// Create completions that replace the entire chord, not just the symbol part
const options = filteredSymbols;
const from = chordContext.to - symbolFragment.length;
return { from, options };
} else {
// No root yet, complete with pitch names
const filteredPitches = pitchNames.filter((p) => p.toLowerCase().startsWith(fragment.toLowerCase()));
const options = filteredPitches.map((p) => ({ label: p, type: 'pitch' }));
const from = chordContext.to - fragment.length;
return { from, options };
}
}
// Cached regex patterns for fallbackHandler
const FALLBACK_WORD_REGEX = /\w*/;
function fallbackHandler(context) {
const word = context.matchBefore(FALLBACK_WORD_REGEX);
if (word && word.from === word.to && !context.explicit) return null;
if (word) {
return { return {
from: word.from, from: word.from,
options: jsdocCompletions, options: jsdocCompletions,
/* options: [
{ label: 'match', type: 'keyword' },
{ label: 'hello', type: 'variable', info: '(World)' },
{ label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
], */
}; };
}
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) =>
+17 -63
View File
@@ -1,31 +1,30 @@
import { closeBrackets } from '@codemirror/autocomplete'; import { closeBrackets } from '@codemirror/autocomplete';
import { indentWithTab, toggleLineComment } from '@codemirror/commands'; export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands';
// import { search, highlightSelectionMatches } from '@codemirror/search';
import { indentWithTab } 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 { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language';
import { Compartment, EditorState, Prec } from '@codemirror/state'; import { Compartment, EditorState, Prec } from '@codemirror/state';
import { import {
drawSelection,
EditorView, EditorView,
highlightActiveLine,
highlightActiveLineGutter, highlightActiveLineGutter,
highlightActiveLine,
keymap, keymap,
lineNumbers, lineNumbers,
drawSelection,
} from '@codemirror/view'; } from '@codemirror/view';
import { persistentAtom } from '@nanostores/persistent'; import { repl, registerControl } from '@strudel/core';
import { logger, registerControl, repl } from '@strudel/core'; import { Drawer, cleanupDraw } from '@strudel/draw';
import { cleanupDraw, Drawer } from '@strudel/draw';
import { isAutoCompletionEnabled } from './autocomplete.mjs'; import { isAutoCompletionEnabled } from './autocomplete.mjs';
import { basicSetup } from './basicSetup.mjs'; import { isTooltipEnabled } from './tooltip.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 { initTheme, activateTheme, theme } from './themes.mjs';
import { sliderPlugin, updateSliderWidgets } from './slider.mjs'; import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs'; import { widgetPlugin, updateWidgets } from './widget.mjs';
import { isTooltipEnabled } from './tooltip.mjs'; import { persistentAtom } from '@nanostores/persistent';
import { updateWidgets, widgetPlugin } from './widget.mjs'; import { basicSetup } from './basicSetup.mjs';
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
const extensions = { const extensions = {
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []), isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
@@ -95,8 +94,8 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
}), }),
sliderPlugin, sliderPlugin,
widgetPlugin, widgetPlugin,
// indentOnInput(), // works without. already brought with javascript // indentOnInput(), // works without. already brought with javascript extension?
// extension? bracketMatching(), // does not do anything // bracketMatching(), // does not do anything
syntaxHighlighting(defaultHighlightStyle), syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((v) => onChange(v)), EditorView.updateListener.of((v) => onChange(v)),
drawSelection({ cursorBlinkRate: 0 }), drawSelection({ cursorBlinkRate: 0 }),
@@ -207,8 +206,7 @@ export class StrudelMirror {
updateWidgets(this.editor, widgets); updateWidgets(this.editor, widgets);
updateMiniLocations(this.editor, this.miniLocations); updateMiniLocations(this.editor, this.miniLocations);
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)
const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0]; const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0];
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
@@ -247,33 +245,6 @@ 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));
@@ -300,16 +271,6 @@ export class StrudelMirror {
async stop() { async stop() {
this.repl.scheduler.stop(); this.repl.scheduler.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();
@@ -385,18 +346,11 @@ export class StrudelMirror {
} }
} }
setCode(code) { setCode(code) {
const changes = { const changes = { from: 0, to: this.editor.state.doc.length, insert: code };
from: 0,
to: this.editor.state.doc.length,
insert: code,
};
this.editor.dispatch({ changes }); this.editor.dispatch({ changes });
} }
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;
+2 -107
View File
@@ -1,12 +1,11 @@
import { defaultKeymap } from '@codemirror/commands';
import { Prec } from '@codemirror/state'; import { Prec } 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, Vim } from '@replit/codemirror-vim'; import { 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 { logger } from '@strudel/core'; import { defaultKeymap } from '@codemirror/commands';
const vscodePlugin = ViewPlugin.fromClass( const vscodePlugin = ViewPlugin.fromClass(
class { class {
@@ -20,110 +19,6 @@ const vscodePlugin = ViewPlugin.fromClass(
); );
const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []); const vscodeExtension = (options) => [vscodePlugin].concat(options ?? []);
// 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) => {
try {
const view = cm?.view || cm;
// First try dispatching our custom stop event, then fallback to Alt+.
let handled = false;
try {
const ev = new CustomEvent('repl-stop', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false;
} catch (e) {
console.error('Error dispatching repl-stop event', e);
}
if (!handled) {
const altDot = new KeyboardEvent('keydown', {
key: '.',
code: 'Period',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altDot);
}
} catch (e) {
console.error('Error dispatching :q stop event', e);
}
});
// :w to evaluate
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);
}
// Dispatch a dedicated evaluate event first
let handled = false;
try {
const ev = new CustomEvent('repl-evaluate', { detail: { source: 'vim', view }, cancelable: true });
handled = document.dispatchEvent(ev) === false; // false means preventDefault was called
} catch (e) {
console.error('Error dispatching repl-evaluate event', e);
}
if (handled) {
return;
}
// Try Ctrl+Enter first if not handled by custom event
const ctrlEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(ctrlEnter);
// If not handled (no handler called preventDefault), try Alt+Enter as
// fallback
if (!ctrlEnter.defaultPrevented) {
const altEnter = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
altKey: true,
bubbles: true,
cancelable: true,
});
view?.dom?.dispatchEvent?.(altEnter);
}
} catch (e) {
console.error('Error dispatching :w evaluation event', e);
}
});
}
} catch (e) {
console.error('Vim ex command setup failed (defineEx missing or Vim unavailable)', e);
}
const keymaps = { const keymaps = {
vim, vim,
emacs, emacs,
+2 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/codemirror", "name": "@strudel/codemirror",
"version": "1.2.6", "version": "1.2.5",
"description": "Codemirror Extensions for Strudel", "description": "Codemirror Extensions for Strudel",
"main": "index.mjs", "main": "index.mjs",
"publishConfig": { "publishConfig": {
@@ -46,16 +46,10 @@
"@replit/codemirror-vscode-keymap": "^6.0.2", "@replit/codemirror-vscode-keymap": "^6.0.2",
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"@strudel/draw": "workspace:*", "@strudel/draw": "workspace:*",
"@strudel/tonal": "workspace:*",
"@strudel/transpiler": "workspace:*", "@strudel/transpiler": "workspace:*",
"@tonaljs/tonal": "^4.10.0", "nanostores": "^0.11.3"
"nanostores": "^0.11.3",
"superdough": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+6
View File
@@ -140,3 +140,9 @@ registerWidget('_spectrum', (id, options = {}, pat) => {
const ctx = getCanvasWidget(id, options).getContext('2d'); const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.spectrum({ ...options, ctx, id }); return pat.spectrum({ ...options, ctx, id });
}); });
registerWidget('_textbox', (id, options = {}, pat) => {
options = { width: 500, height: 30, ...options };
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.textbox({ ...options, ctx, id });
});
+15 -3
View File
@@ -12,6 +12,8 @@ function getTime() {
let num_cycles_at_cps_change = 0; let num_cycles_at_cps_change = 0;
let num_ticks_since_cps_change = 0; let num_ticks_since_cps_change = 0;
let num_seconds_at_cps_change = 0; let num_seconds_at_cps_change = 0;
let max_cycle = null;
let cps = 0.5; let cps = 0.5;
// {id: {started: boolean}} // {id: {started: boolean}}
const clients = new Map(); const clients = new Map();
@@ -39,6 +41,7 @@ const sendTick = (phase, duration, tick, time) => {
cps, cps,
time, time,
cycle, cycle,
max_cycle,
}); });
num_ticks_since_cps_change++; num_ticks_since_cps_change++;
}; };
@@ -60,9 +63,10 @@ const stopClock = async (id) => {
const otherClientStarted = Array.from(clients.values()).some((c) => c.started); const otherClientStarted = Array.from(clients.values()).some((c) => c.started);
//dont stop the clock if other instances are running... //dont stop the clock if other instances are running...
if (!started || otherClientStarted) { // actually do stop it
return; // if (!started || otherClientStarted) {
} // return;
// }
clock.stop(); clock.stop();
setCycle(0); setCycle(0);
@@ -74,6 +78,10 @@ const setCycle = (cycle) => {
num_cycles_at_cps_change = cycle; num_cycles_at_cps_change = cycle;
}; };
const setMaxCycle = (cycle) => {
max_cycle = cycle;
};
const processMessage = (message) => { const processMessage = (message) => {
const { type, payload } = message; const { type, payload } = message;
@@ -92,6 +100,10 @@ const processMessage = (message) => {
setCycle(payload.cycle); setCycle(payload.cycle);
break; break;
} }
case 'setmaxcycle': {
setMaxCycle(payload.maxcycle);
break;
}
case 'toggle': { case 'toggle': {
if (payload.started) { if (payload.started) {
startClock(message.id); startClock(message.id);
+11 -266
View File
@@ -1288,196 +1288,6 @@ export const { fanchor } = registerControl('fanchor');
*/ */
// currently an alias of 'hcutoff' https://codeberg.org/uzu/strudel/issues/496 // currently an alias of 'hcutoff' https://codeberg.org/uzu/strudel/issues/496
// ['hpf'], // ['hpf'],
/**
* Rate of the LFO for the lowpass filter
*
* @name lprate
* @param {number | Pattern} rate rate in hertz
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lprate("<4 8 2 1>")
*/
export const { lprate } = registerControl('lprate');
/**
* Cycle-synced rate of the LFO for the lowpass filter
*
* @name lpsync
* @param {number | Pattern} rate rate in cycles
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpsync("<4 8 2 1>")
*/
export const { lpsync } = registerControl('lpsync');
/**
* Depth of the LFO for the lowpass filter
*
* @name lpdepth
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpdepth("<1 .5 1.8 0>")
*/
export const { lpdepth } = registerControl('lpdepth');
/**
* Depth of the LFO for the lowpass filter, in HZ
*
* @name lpdepthfrequency
* @synonyms
* lpdethfreq
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpdepthfrequency("<200 500 100 0>")
*/
export const { lpdepthfrequency } = registerControl('lpdepthfrequency', 'lpdepthfreq');
/**
* Shape of the LFO for the lowpass filter
*
* @name lpshape
* @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..)
*/
export const { lpshape } = registerControl('lpshape');
/**
* DC offset of the LFO for the lowpass filter
*
* @name lpdc
* @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar
*/
export const { lpdc } = registerControl('lpdc');
/**
* Skew of the LFO for the lowpass filter
*
* @name lpskew
* @param {number | Pattern} skew How much to bend the LFO shape
*/
export const { lpskew } = registerControl('lpskew');
/**
* Rate of the LFO for the bandpass filter
*
* @name bprate
* @param {number | Pattern} rate rate in hertz
*/
export const { bprate } = registerControl('bprate');
/**
* Cycle-synced rate of the LFO for the bandpass filter
*
* @name bpsync
* @param {number | Pattern} rate rate in cycles
*/
export const { bpsync } = registerControl('bpsync');
/**
* Depth of the LFO for the bandpass filter
*
* @name bpdepth
* @param {number | Pattern} depth depth of modulation
*/
export const { bpdepth } = registerControl('bpdepth');
/**
* Depth of the LFO for the bandpass filter, in HZ
*
* @name bpdepthfrequency
* @synonyms
* bpdethfreq
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).bpdepthfrequency("<200 500 100 0>")
*/
export const { bpdepthfrequency } = registerControl('bpdepthfrequency', 'bpdepthfreq');
/**
* Shape of the LFO for the bandpass filter
*
* @name bpshape
* @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..)
*/
export const { bpshape } = registerControl('bpshape');
/**
* DC offset of the LFO for the bandpass filter
*
* @name bpdc
* @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar
*/
export const { bpdc } = registerControl('bpdc');
/**
* Skew of the LFO for the bandpass filter
*
* @name bpskew
* @param {number | Pattern} skew How much to bend the LFO shape
*/
export const { bpskew } = registerControl('bpskew');
/**
* Rate of the LFO for the highpass filter
*
* @name hprate
* @param {number | Pattern} rate rate in hertz
*/
export const { hprate } = registerControl('hprate');
/**
* Cycle-synced rate of the LFO for the highpass filter
*
* @name hpsync
* @param {number | Pattern} rate rate in cycles
*/
export const { hpsync } = registerControl('hpsync');
/**
* Depth of the LFO for the highpass filter
*
* @name hpdepth
* @param {number | Pattern} depth depth of modulation
*/
export const { hpdepth, hpdepthfreq } = registerControl('hpdepth');
/**
* Depth of the LFO for the hipass filter, in hz
*
* @name hpdepthfrequency
* @synonyms
* hpdethfreq
* @param {number | Pattern} depth depth of modulation
* @example
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).hpdepthfrequency("<200 500 100 0>")
*/
export const { hpdepthfrequency } = registerControl('hpdepthfrequency', 'hpdepthfreq');
/**
* Shape of the LFO for the highpass filter
*
* @name hpshape
* @param {number | Pattern} shape Shape of the lfo (0, 1, 2, ..)
*/
export const { hpshape } = registerControl('hpshape');
/**
* DC offset of the LFO for the highpass filter
*
* @name hpdc
* @param {number | Pattern} dcoffset dc offset. set to 0 for unipolar
*/
export const { hpdc } = registerControl('hpdc');
/**
* Skew of the LFO for the highpass filter
*
* @name hpskew
* @param {number | Pattern} skew How much to bend the LFO shape
*/
export const { hpskew } = registerControl('hpskew');
/** /**
* Applies a vibrato to the frequency of the oscillator. * Applies a vibrato to the frequency of the oscillator.
* *
@@ -1865,12 +1675,12 @@ export const { nudge } = registerControl('nudge');
* Sets the default octave of a synth. * Sets the default octave of a synth.
* *
* @name octave * @name octave
* @synonyms oct
* @param {number | Pattern} octave octave number * @param {number | Pattern} octave octave number
* @example * @example
* n("0,4,7").scale("F:minor").s('supersaw').octave("<0 1 2 3>") * n("0,4,7").s('supersquare').octave("<3 4 5 6>").osc()
* @superDirtOnly
*/ */
export const { octave, oct } = registerControl('octave', 'oct'); export const { octave } = registerControl('octave');
// ['ophatdecay'], // ['ophatdecay'],
// TODO: example // TODO: example
@@ -1878,7 +1688,6 @@ export const { octave, oct } = registerControl('octave', 'oct');
* An `orbit` is a global parameter context for patterns. Patterns with the same orbit will share the same global effects. * An `orbit` is a global parameter context for patterns. Patterns with the same orbit will share the same global effects.
* *
* @name orbit * @name orbit
* @synonyms o
* @param {number | Pattern} number * @param {number | Pattern} number
* @example * @example
* stack( * stack(
@@ -1886,7 +1695,7 @@ export const { octave, oct } = registerControl('octave', 'oct');
* s("~ sd ~ sd").delay(.5).delaytime(.125).orbit(2) * s("~ sd ~ sd").delay(.5).delaytime(.125).orbit(2)
* ) * )
*/ */
export const { orbit } = registerControl('orbit', 'o'); export const { orbit } = registerControl('orbit');
// TODO: what is this? not found in tidal doc Answer: gain is limited to maximum of 2. This allows you to go over that // TODO: what is this? not found in tidal doc Answer: gain is limited to maximum of 2. This allows you to go over that
export const { overgain } = registerControl('overgain'); export const { overgain } = registerControl('overgain');
// TODO: what is this? not found in tidal doc. Similar to above, but limited to 1 // TODO: what is this? not found in tidal doc. Similar to above, but limited to 1
@@ -1941,64 +1750,17 @@ export const { semitone } = registerControl('semitone');
// TODO: synth param // TODO: synth param
export const { voice } = registerControl('voice'); export const { voice } = registerControl('voice');
// voicings // https://codeberg.org/uzu/strudel/issues/506 // voicings // https://codeberg.org/uzu/strudel/issues/506
/** // chord to voice, like C Eb Fm7 G7. the symbols can be defined via addVoicings
* The chord to voice
* @name chord
* @param {string | Pattern} symbols chord symbols to voice e.g., C, Eb, Fm7, G7. The symbols can be defined via addVoicings
* @example
* chord("<Am C D F Am E Am E>").voicing()
**/
export const { chord } = registerControl('chord'); export const { chord } = registerControl('chord');
/** // which dictionary to use for the voicings
* Which dictionary to use for the voicings. This falls back to the default dictionary if not provided
*
* @name dictionary
* @param {string} dictionaryName which dictionary (having been defined with `addVoicings`) to use
* @example
* addVoicings('house', {
'': ['7 12 16', '0 7 16', '4 7 12'],
'm': ['0 3 7']
})
chord("<Am C D F Am E Am E>")
.dict('house').anchor(66)
.voicing().room(.5)
**/
export const { dictionary, dict } = registerControl('dictionary', 'dict'); export const { dictionary, dict } = registerControl('dictionary', 'dict');
/** The top note to align the voicing to. Defaults to c5 // the top note to align the voicing to, defaults to c5
*
* @name anchor
* @param {string | Pattern} anchorNote the note to align the voicings to
* @example
* anchor("<c4 g4 c5 g5>").chord("C").voicing()
**/
export const { anchor } = registerControl('anchor'); export const { anchor } = registerControl('anchor');
/** // how the voicing is offset from the anchored position
* Sets how the voicing is offset from the anchored position
*
* @name offset
* @param {number | Pattern} shift the amount to shift the voicing up or down
* @example
* chord("<Am C D F Am E Am E>").offset("<0 1 2 3 4 5>") // alter the voicing each time
**/
export const { offset } = registerControl('offset'); export const { offset } = registerControl('offset');
/** // how many octaves are voicing steps spread apart, defaults to 1
* How many octaves are voicing steps spread apart, defaults to 1
*
* @name octaves
* @param {number | Pattern} count the number of octaves
* @example
* chord("<Am C D F Am E Am E>").octaves("<2 4>").voicing()
**/
export const { octaves } = registerControl('octaves'); export const { octaves } = registerControl('octaves');
/** // below = anchor note will be removed from the voicing, useful for melody harmonization
* Remove anchor note from the voicing. Useful for melody harmonization
*
* @name mode
* @param {string | Pattern} modeName one of {below | above | duck | root}
* @example
* mode("<below above duck root>").chord("C").voicing()
*
**/
export const { mode } = registerControl(['mode', 'anchor']); export const { mode } = registerControl(['mode', 'anchor']);
/** /**
@@ -2310,6 +2072,7 @@ export const { tsdelay } = registerControl('tsdelay');
export const { real } = registerControl('real'); export const { real } = registerControl('real');
export const { imag } = registerControl('imag'); export const { imag } = registerControl('imag');
export const { enhance } = registerControl('enhance'); export const { enhance } = registerControl('enhance');
export const { partials } = registerControl('partials');
export const { comb } = registerControl('comb'); export const { comb } = registerControl('comb');
export const { smear } = registerControl('smear'); export const { smear } = registerControl('smear');
export const { scram } = registerControl('scram'); export const { scram } = registerControl('scram');
@@ -2557,24 +2320,6 @@ export const { miditouch } = registerControl('miditouch');
// TODO: what is this? // TODO: what is this?
export const { polyTouch } = registerControl('polyTouch'); export const { polyTouch } = registerControl('polyTouch');
/**
* The host to send open sound control messages to. Requires running the OSC bridge.
* @name oschost
* @param {string | Pattern} oschost e.g. 'localhost'
* @example
* note("c4").oschost('127.0.0.1').oscport(57120).osc();
*/
export const { oschost } = registerControl('oschost');
/**
* The port to send open sound control messages to. Requires running the OSC bridge.
* @name oscport
* @param {number | Pattern} oscport e.g. 57120
* @example
* note("c4").oschost('127.0.0.1').oscport(57120).osc();
*/
export const { oscport } = registerControl('oscport');
export const getControlName = (alias) => { export const getControlName = (alias) => {
if (controlAlias.has(alias)) { if (controlAlias.has(alias)) {
return controlAlias.get(alias); return controlAlias.get(alias);
+1 -1
View File
@@ -14,7 +14,7 @@ export class Cyclist {
onToggle, onToggle,
onError, onError,
getTime, getTime,
latency = 0.1, latency = 0.03,
setInterval, setInterval,
clearInterval, clearInterval,
beforeStart, beforeStart,
+7 -7
View File
@@ -35,18 +35,18 @@ const right = function (n, x) {
return result; return result;
}; };
const _bjorklund = function (n, x) { const _bjork = function (n, x) {
const [ons, offs] = n; const [ons, offs] = n;
return Math.min(ons, offs) <= 1 ? [n, x] : _bjorklund(...(ons > offs ? left(n, x) : right(n, x))); return Math.min(ons, offs) <= 1 ? [n, x] : _bjork(...(ons > offs ? left(n, x) : right(n, x)));
}; };
export const bjorklund = function (ons, steps) { export const bjork = function (ons, steps) {
const inverted = ons < 0; const inverted = ons < 0;
const absOns = Math.abs(ons); const absOns = Math.abs(ons);
const offs = steps - absOns; const offs = steps - absOns;
const ones = Array(absOns).fill([1]); const ones = Array(absOns).fill([1]);
const zeros = Array(offs).fill([0]); const zeros = Array(offs).fill([0]);
const result = _bjorklund([absOns, offs], [ones, zeros]); const result = _bjork([absOns, offs], [ones, zeros]);
const pattern = flatten(result[1][0]).concat(flatten(result[1][1])); const pattern = flatten(result[1][0]).concat(flatten(result[1][1]));
return inverted ? pattern.map((x) => 1 - x) : pattern; return inverted ? pattern.map((x) => 1 - x) : pattern;
}; };
@@ -128,7 +128,7 @@ export const bjorklund = function (ons, steps) {
*/ */
const _euclidRot = function (pulses, steps, rotation) { const _euclidRot = function (pulses, steps, rotation) {
const b = bjorklund(pulses, steps); const b = bjork(pulses, steps);
if (rotation) { if (rotation) {
return rotate(b, -rotation); return rotate(b, -rotation);
} }
@@ -139,7 +139,7 @@ export const euclid = register('euclid', function (pulses, steps, pat) {
return pat.struct(_euclidRot(pulses, steps, 0)); return pat.struct(_euclidRot(pulses, steps, 0));
}); });
export const bjork = register('bjork', function (euc, pat) { export const e = register('e', function (euc, pat) {
if (!Array.isArray(euc)) { if (!Array.isArray(euc)) {
euc = [euc]; euc = [euc];
} }
@@ -216,6 +216,6 @@ export const euclidLegatoRot = register(['euclidLegatoRot'], function (pulses, s
* .pan(sine.slow(8)) * .pan(sine.slow(8))
*/ */
export const { euclidish, eish } = register(['euclidish', 'eish'], function (pulses, steps, perc, pat) { export const { euclidish, eish } = register(['euclidish', 'eish'], function (pulses, steps, perc, pat) {
const morphed = _morph(bjorklund(pulses, steps), new Array(pulses).fill(1), perc); const morphed = _morph(bjork(pulses, steps), new Array(pulses).fill(1), perc);
return pat.struct(morphed).setSteps(steps); return pat.struct(morphed).setSteps(steps);
}); });
+1 -1
View File
@@ -18,7 +18,7 @@ export function logger(message, type, data = {}) {
} }
lastMessage = message; lastMessage = message;
lastTime = t; lastTime = t;
console.log(`%c${message}`, 'background-color: black;color:white;border-radius:15px'); console.log(`${t} %c${message}`, 'background-color: black;color:white;border-radius:15px');
if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') { if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') {
document.dispatchEvent( document.dispatchEvent(
new CustomEvent(logKey, { new CustomEvent(logKey, {
+6 -3
View File
@@ -1,6 +1,6 @@
/* /*
neocyclist.mjs - event scheduler like cyclist, except recieves clock pulses from clockworker in order to sync across multiple instances. neocyclist.mjs - event scheduler like cyclist, except recieves clock pulses from clockworker in order to sync across multiple instances.
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/neocyclist.mjs> Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/neocyclist.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/>. 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/>.
*/ */
@@ -11,6 +11,7 @@ export class NeoCyclist {
constructor({ onTrigger, onToggle, getTime }) { constructor({ onTrigger, onToggle, getTime }) {
this.started = false; this.started = false;
this.cps = 0.5; this.cps = 0.5;
this.lastTick = 0; // absolute time when last tick (clock callback) happened
this.getTime = getTime; // get absolute time this.getTime = getTime; // get absolute time
this.time_at_last_tick_message = 0; this.time_at_last_tick_message = 0;
// the clock of the worker and the audio context clock can drift apart over time // the clock of the worker and the audio context clock can drift apart over time
@@ -19,16 +20,18 @@ export class NeoCyclist {
// in order to schedule events consistently. // in order to schedule events consistently.
this.collator = new ClockCollator({ getTargetClockTime: getTime }); this.collator = new ClockCollator({ getTargetClockTime: getTime });
this.onToggle = onToggle; this.onToggle = onToggle;
this.latency = 0.1; // fixed trigger time offset this.latency = -0.1; // fixed trigger time offset
this.cycle = 0; this.cycle = 0;
this.maxcycle = null;
this.id = Math.round(Date.now() * Math.random()); this.id = Math.round(Date.now() * Math.random());
this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url)); this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url));
this.worker.port.start(); this.worker.port.start();
this.channel = new BroadcastChannel('strudeltick'); this.channel = new BroadcastChannel('strudeltick');
const tickCallback = (payload) => { const tickCallback = (payload) => {
const { cps, begin, end, cycle, time } = payload; const { cps, begin, end, cycle, maxcycle, time } = payload;
this.cps = cps; this.cps = cps;
this.cycle = cycle; this.cycle = cycle;
this.maxcycle = maxcycle;
const currentTime = this.collator.calculateOffset(time) + time; const currentTime = this.collator.calculateOffset(time) + time;
processHaps(begin, end, currentTime); processHaps(begin, end, currentTime);
this.time_at_last_tick_message = currentTime; this.time_at_last_tick_message = currentTime;
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/core", "name": "@strudel/core",
"version": "1.2.5", "version": "1.2.4",
"description": "Port of Tidal Cycles to JavaScript", "description": "Port of Tidal Cycles to JavaScript",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -37,8 +37,5 @@
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3 -84
View File
@@ -25,7 +25,7 @@ import {
stringifyValues, stringifyValues,
} from './util.mjs'; } from './util.mjs';
import drawLine from './drawLine.mjs'; import drawLine from './drawLine.mjs';
import { errorLogger, logger } from './logger.mjs'; import { logger } from './logger.mjs';
let stringParser; let stringParser;
@@ -414,7 +414,7 @@ export class Pattern {
try { try {
return this.query(new State(new TimeSpan(begin, end), controls)); return this.query(new State(new TimeSpan(begin, end), controls));
} catch (err) { } catch (err) {
errorLogger(err, 'query'); logger(`[query]: ${err.message}`, 'error');
return []; return [];
} }
} }
@@ -2574,8 +2574,8 @@ export const { chunkBack, chunkback } = register(
* @returns Pattern * @returns Pattern
* @example * @example
* "<0 8> 1 2 3 4 5 6 7" * "<0 8> 1 2 3 4 5 6 7"
* .scale("C2:major").note()
* .fastChunk(4, x => x.color('red')).slow(2) * .fastChunk(4, x => x.color('red')).slow(2)
* .scale("C2:major").note()
*/ */
export const { fastchunk, fastChunk } = register( export const { fastchunk, fastChunk } = register(
['fastchunk', 'fastChunk'], ['fastchunk', 'fastChunk'],
@@ -2998,24 +2998,6 @@ export const extend = stepRegister('extend', function (factor, pat) {
return pat.fast(factor).expand(factor); return pat.fast(factor).expand(factor);
}); });
/**
* *Experimental*
*
* `replicate` is similar to `fast` in that it increases its density, but it also increases the step count
* accordingly. So `stepcat("a b".replicate(2), "c d")` would be the same as `"a b a b c d"`, whereas
* `stepcat("a b".fast(2), "c d")` would be the same as `"[a b] [a b] c d"`.
*
* TODO: find out how this function differs from extend
* @example
* stepcat(
* sound("bd bd - cp").replicate(2),
* sound("bd - sd -")
* ).pace(8)
*/
export const replicate = stepRegister('replicate', function (factor, pat) {
return pat.repeatCycles(factor).fast(factor).expand(factor);
});
/** /**
* *Experimental* * *Experimental*
* *
@@ -3624,66 +3606,3 @@ for (const name of distAlgoNames) {
return this.distort(argsPat); return this.distort(argsPat);
}; };
} }
/**
* Turns a list of patterns into a single pattern which outputs list-values
*
* @name parray
* @returns Pattern
*/
export const parray = (pats) => {
const pack = (...xs) => xs;
let acc = pure(curry(pack, null, pats.length));
for (const p of pats) acc = acc.appBoth(reify(p));
return acc;
};
const _ensureListPattern = (list) => {
if (Array.isArray(list)) {
return parray(list);
}
return reify(list);
};
/**
* Scale the magnitude of the harmonics of one of the core synths ('sine', 'tri', 'saw', ..)
*
* Can also be used to create a new synth via `s('user').partials(...)`
*
* @name partials
* @param {number[] | Pattern} magnitudes List of [0, 1] magnitudes for partials. 0th entry is the fundamental harmonic (i.e. DC offset is skipped)
* @example
* s("user").seg(16).n(irand(8)).scale("A:major")
* .partials([1, 0, 1, 0, 0, 1])
* @example
* s("saw").seg(8).n(irand(12)).scale("G#:minor")
* .partials(binaryL(irand(256).add("1")))
*/
Pattern.prototype.partials = function (list) {
return this.withValue((v) => (l) => ({ ...v, partials: l })).appLeft(_ensureListPattern(list));
};
// Also create a top-level function
export const partials = (list) => {
return _ensureListPattern(list).as('partials');
};
/**
* Rotates the harmonics of one of the core synths ('sine', 'tri', 'saw', 'user', ..) by a list of phases
*
* @name phases
* @param {number[] | Pattern} phases List of [0, 1) phases for partials. 0th entry is the fundamental phase (i.e. DC offset is skipped)
* @example
* // Phase cancellation
* s("saw").seg(8).n(irand(12)).scale("G#1:minor")
* .partials(partials([1, 1, 1]))
* .superimpose(x => x.phases([0.5, 0.5, 0.5]))
*/
Pattern.prototype.phases = function (list) {
return this.withValue((v) => (l) => ({ ...v, phases: l })).appLeft(_ensureListPattern(list));
};
// Also create a top-level function
export const phases = (list) => {
return _ensureListPattern(list).as('phases');
};
+7 -17
View File
@@ -152,9 +152,9 @@ export function repl({
// allows muting a pattern x with x_ or _x // allows muting a pattern x with x_ or _x
return silence; return silence;
} }
if (id.includes('$')) { if (id === '$') {
// allows adding anonymous patterns with $: // allows adding anonymous patterns with $:
id = `${id}${anonymousIndex}`; id = `$${anonymousIndex}`;
anonymousIndex++; anonymousIndex++;
} }
pPatterns[id] = this; pPatterns[id] = this;
@@ -215,19 +215,8 @@ export function repl({
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions); let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
if (Object.keys(pPatterns).length) { if (Object.keys(pPatterns).length) {
let patterns = []; let patterns = [];
let soloActive = false;
for (const [key, value] of Object.entries(pPatterns)) { for (const [key, value] of Object.entries(pPatterns)) {
// handle soloed patterns ex: S$: s("bd!4") patterns.push(value.withState((state) => state.setControls({ id: key })));
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) { if (eachTransform) {
// Explicit lambda so only element (not index and array) are passed // Explicit lambda so only element (not index and array) are passed
@@ -238,13 +227,14 @@ export function repl({
pattern = eachTransform(pattern); pattern = eachTransform(pattern);
} }
if (allTransforms.length) { if (allTransforms.length) {
for (const transform of allTransforms) { for (let i in allTransforms) {
pattern = transform(pattern); pattern = allTransforms[i](pattern);
} }
} }
if (!isPattern(pattern)) { if (!isPattern(pattern)) {
pattern = silence; const message = `got "${typeof evaluated}" instead of pattern`;
throw new Error(message + (typeof evaluated === 'function' ? ', did you forget to call a function?' : '.'));
} }
logger(`[eval] code updated`); logger(`[eval] code updated`);
pattern = await setPattern(pattern, autostart); pattern = await setPattern(pattern, autostart);
+3 -48
View File
@@ -228,7 +228,7 @@ const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
export const run = (n) => saw.range(0, n).round().segment(n); export const run = (n) => saw.range(0, n).round().segment(n);
/** /**
* Creates a binary pattern from a number. * Creates a pattern from a binary number.
* *
* @name binary * @name binary
* @param {number} n - input number to convert to binary * @param {number} n - input number to convert to binary
@@ -242,7 +242,7 @@ export const binary = (n) => {
}; };
/** /**
* Creates a binary pattern from a number, padded to n bits long. * Creates a pattern from a binary number, padded to n bits long.
* *
* @name binaryN * @name binaryN
* @param {number} n - input number to convert to binary * @param {number} n - input number to convert to binary
@@ -258,51 +258,6 @@ export const binaryN = (n, nBits = 16) => {
return reify(n).segment(nBits).brshift(bitPos).band(pure(1)); return reify(n).segment(nBits).brshift(bitPos).band(pure(1));
}; };
/**
* Creates a binary list pattern from a number.
*
* @name binaryL
* @param {number} n - input number to convert to binary
* s("saw").seg(8)
* .partials(binaryL(irand(4096).add(1)))
*/
export const binaryL = (n) => {
const nBits = reify(n).log2(0).floor().add(1);
return binaryNL(n, nBits);
};
/**
* Creates a binary list pattern from a number, padded to n bits long.
*
* @name binaryNL
* @param {number} n - input number to convert to binary
* @param {number} nBits - pattern length, defaults to 16
*/
export const binaryNL = (n, nBits = 16) => {
return reify(n)
.withValue((v) => (bits) => {
const bList = [];
for (let i = bits - 1; i >= 0; i--) {
bList.push((v >> i) & 1);
}
return bList;
})
.appLeft(reify(nBits));
};
/**
* Creates a list of random numbers of the given length
*
* @name randL
* @param {number} n Number of random numbers to sample
* @example
* s("saw").seg(16).n(irand(12)).scale("F1:minor")
* .partials(randL(8))
*/
export const randL = (n) => {
return signal((t) => (nVal) => timeToRands(t, nVal).map(Math.abs)).appLeft(reify(n));
};
export const randrun = (n) => { export const randrun = (n) => {
return signal((t) => { return signal((t) => {
// 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,...
@@ -524,7 +479,7 @@ export const wchoose = (...pairs) => wchooseWith(rand, ...pairs);
* @example * @example
* wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8) * wchooseCycles(["bd",10], ["hh",1], ["sd",1]).s().fast(8)
* @example * @example
* wchooseCycles(["c c c",5], ["a a a",3], ["f f f",1]).fast(4).note() * wchooseCycles(["bd bd bd",5], ["hh hh hh",3], ["sd sd sd",1]).fast(4).s()
* @example * @example
* // The probability can itself be a pattern * // The probability can itself be a pattern
* wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s() * wchooseCycles(["bd(3,8)","<5 0>"], ["hh hh hh",3]).fast(4).s()
+8 -8
View File
@@ -1,14 +1,14 @@
import { bjorklund } from '../euclid.mjs'; import { bjork } from '../euclid.mjs';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { fastcat } from '../pattern.mjs'; import { fastcat } from '../pattern.mjs';
describe('bjorklund', () => { describe('bjork', () => {
it('should apply bjorklundlund to ons and steps', () => { it('should apply bjorklund to ons and steps', () => {
expect(bjorklund(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]); expect(bjork(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]);
expect(bjorklund(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]); expect(bjork(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]);
expect(bjorklund(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]); expect(bjork(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]);
expect(bjorklund(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]); expect(bjork(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]);
expect(bjorklund(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]); expect(bjork(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]);
}); });
}); });
+3 -7
View File
@@ -7,8 +7,8 @@ This program is free software: you can redistribute it and/or modify it under th
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
// returns true if the given string is a note // returns true if the given string is a note
export const isNoteWithOctave = (name) => /^[a-gA-G][#bsf]*[0-9]*$/.test(name); export const isNoteWithOctave = (name) => /^[a-gA-G][#bs]*[0-9]$/.test(name);
export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]*$/.test(name); export const isNote = (name) => /^[a-gA-G][#bsf]*-?[0-9]?$/.test(name);
export const tokenizeNote = (note) => { export const tokenizeNote = (note) => {
if (typeof note !== 'string') { if (typeof note !== 'string') {
return []; return [];
@@ -23,10 +23,6 @@ export const tokenizeNote = (note) => {
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const accs = { '#': 1, b: -1, s: 1, f: -1 }; const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
};
// turns the given note into its midi number representation // turns the given note into its midi number representation
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);
@@ -34,7 +30,7 @@ export const noteToMidi = (note, defaultOctave = 3) => {
throw new Error('not a note: "' + note + '"'); throw new Error('not a note: "' + note + '"');
} }
const chroma = chromas[pc.toLowerCase()]; const chroma = chromas[pc.toLowerCase()];
const offset = getAccidentalsOffset(acc); const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
return (Number(oct) + 1) * 12 + chroma + offset; return (Number(oct) + 1) * 12 + chroma + offset;
}; };
export const midiToFreq = (n) => { export const midiToFreq = (n) => {
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/csound", "name": "@strudel/csound",
"version": "1.2.6", "version": "1.2.5",
"description": "csound bindings for strudel", "description": "csound bindings for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -38,8 +38,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -4
View File
@@ -25,8 +25,5 @@
"@strudel/core": "workspace:*", "@strudel/core": "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"
"engines": {
"node": ">=18.0.0"
}
} }
-9
View File
@@ -84,18 +84,9 @@ Pattern.prototype.onPaint = function (painter) {
state.controls.painters = []; state.controls.painters = [];
} }
state.controls.painters.push(painter); state.controls.painters.push(painter);
return state;
}); });
}; };
// TODO - Why isn't this pure deep copy not working?
// Pattern.prototype.onPaint = function (painter) {
// return this.withState((state) => {
// const painters = state.controls.painters ? [...state.controls.painters, painter] : [painter];
// return new State(state.span, { ...state.controls, painters });
// });
// };
Pattern.prototype.getPainters = function () { Pattern.prototype.getPainters = function () {
let painters = []; let painters = [];
this.queryArc(0, 0, { painters }); this.queryArc(0, 0, { painters });
+1
View File
@@ -4,3 +4,4 @@ export * from './draw.mjs';
export * from './pianoroll.mjs'; export * from './pianoroll.mjs';
export * from './spiral.mjs'; export * from './spiral.mjs';
export * from './pitchwheel.mjs'; export * from './pitchwheel.mjs';
export * from './textbox.mjs';
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/draw", "name": "@strudel/draw",
"version": "1.2.5", "version": "1.2.4",
"description": "Helpers for drawing with Strudel", "description": "Helpers for drawing with Strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -33,8 +33,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+50
View File
@@ -0,0 +1,50 @@
import { getTheme, getDrawContext } from './draw.mjs';
import { Pattern } from '@strudel/core';
export function textbox({ haps, ctx, id, margin = 10, fontsize = 24 } = {}) {
const w = ctx.canvas.width;
const h = ctx.canvas.height;
ctx.clearRect(0, 0, w, h);
const color = getTheme().foreground;
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
haps.forEach((hap) => {
if (hap.hasOnset()) {
const hapColor = hap.value.color || color;
ctx.strokeStyle = hapColor;
ctx.fillStyle = hapColor;
const { velocity = 1, gain = 1 } = hap.value || {};
const alpha = velocity * gain;
ctx.globalAlpha = alpha;
ctx.font = `${fontsize}px sans-serif`;
ctx.fillText(hap.value, 0, fontsize);
}
});
return;
}
Pattern.prototype.textbox = function (options = {}) {
let { ctx = getDrawContext(), id = 1 } = options;
this.draw(
(haps, time) => {
textbox({
...options,
time,
ctx,
haps: haps.filter((hap) => hap.isActive(time)),
});
},
{
lookbehind: 0,
lookahead: 0,
id,
},
);
return this;
};
+1 -4
View File
@@ -20,8 +20,5 @@
"bugs": { "bugs": {
"url": "https://codeberg.org/uzu/strudel/issues" "url": "https://codeberg.org/uzu/strudel/issues"
}, },
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme"
"engines": {
"node": ">=18.0.0"
}
} }
-6
View File
@@ -40,12 +40,6 @@ const pattern = sequence([
- D-Pad - D-Pad
- `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) - `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase)
- Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`) - Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight`(or `tglU`, `tglD`, `tglL`, `tglR`)
- Stick Buttons
- `l3`, `r3` (or `ls`, `rs`)
- Toggle versions: `tglL3`, `tglR3` (or `tglLS`, `tglRS`)
- System Buttons
- `start`, `back` (or uppercase `START`, `BACK`)
- Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`)
### Analog Sticks ### Analog Sticks
- Left Stick - Left Stick
-4
View File
@@ -29,10 +29,6 @@ The gamepad module provides access to buttons and analog sticks as normalized si
| | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` | | | Toggle versions: `tglLB`, `tglRB`, `tglLT`, `tglRT` |
| D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) | | D-Pad | `up`, `down`, `left`, `right` (or `u`, `d`, `l`, `r` or uppercase) |
| | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) | | | Toggle versions: `tglUp`, `tglDown`, `tglLeft`, `tglRight` (or `tglU`, `tglD`, `tglL`, `tglR`) |
| Stick Buttons | `l3`, 'r3' (or `ls`, `rs`) |
| | Toggle versions: `tglL3`, 'tglR3' (or `tglLs`, `tglRs`) |
| System Buttons | `start`, `back` (or uppercase `START`, `BACK`) |
| | Toggle versions: `tglStart`, `tglBack` (or `tglSTART`, `tglBACK`) |
### Analog Sticks ### Analog Sticks
-4
View File
@@ -14,10 +14,6 @@ export const buttonMap = {
rt: 7, rt: 7,
back: 8, back: 8,
start: 9, start: 9,
l3: 10,
ls: 10,
r3: 11,
rs: 11,
u: 12, u: 12,
up: 12, up: 12,
d: 13, d: 13,
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/gamepad", "name": "@strudel/gamepad",
"version": "1.2.5", "version": "1.2.4",
"description": "Gamepad Inputs for strudel", "description": "Gamepad Inputs for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -33,8 +33,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-3
View File
@@ -34,8 +34,5 @@
"devDependencies": { "devDependencies": {
"tree-sitter-haskell": "^0.23.1", "tree-sitter-haskell": "^0.23.1",
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-1
View File
@@ -35,7 +35,6 @@ export async function initHydra(options = {}) {
hydra.synth.s0.init({ src: canvas }); hydra.synth.s0.init({ src: canvas });
} }
} }
return hydra;
} }
export function clearHydra() { export function clearHydra() {
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/hydra", "name": "@strudel/hydra",
"version": "1.2.5", "version": "1.2.4",
"description": "Hydra integration for strudel", "description": "Hydra integration for strudel",
"main": "hydra.mjs", "main": "hydra.mjs",
"type": "module", "type": "module",
@@ -40,8 +40,5 @@
"devDependencies": { "devDependencies": {
"pkg": "^5.8.1", "pkg": "^5.8.1",
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+42 -59
View File
@@ -5,10 +5,9 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import * as _WebMidi from 'webmidi'; import * as _WebMidi from 'webmidi';
import { Pattern, isPattern, logger, ref } from '@strudel/core'; import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core';
import { noteToMidi, getControlName } from '@strudel/core'; import { noteToMidi, getControlName } from '@strudel/core';
import { Note } from 'webmidi'; import { Note } from 'webmidi';
import { scheduleAtTime } from '../superdough/helpers.mjs';
// if you use WebMidi from outside of this package, make sure to import that instance: // if you use WebMidi from outside of this package, make sure to import that instance:
export const { WebMidi } = _WebMidi; export const { WebMidi } = _WebMidi;
@@ -191,7 +190,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, targetTime) { function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
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');
} }
@@ -199,23 +198,19 @@ function sendCC(ccn, ccv, device, midichan, targetTime) {
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);
scheduleAtTime(() => { device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
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, targetTime) { function sendProgramChange(progNum, device, midichan, timeOffsetString) {
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');
} }
scheduleAtTime(() => { device.sendProgramChange(progNum, midichan, { time: timeOffsetString });
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, targetTime) { function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
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');
@@ -230,13 +225,11 @@ function sendSysex(sysexid, sysexdata, device, targetTime) {
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');
} }
scheduleAtTime(() => { device.sendSysex(sysexid, sysexdata, { time: timeOffsetString });
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, targetTime) { function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
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');
@@ -244,34 +237,28 @@ function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
} 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(() => {
device.sendNRPN(nrpnn, nrpv, midichan); device.sendNRPN(nrpnn, nrpv, midichan, { time: timeOffsetString });
}, 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, targetTime) { function sendPitchBend(midibend, device, midichan, timeOffsetString) {
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');
} }
scheduleAtTime(() => { device.sendPitchBend(midibend, midichan, { time: timeOffsetString });
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, targetTime) { function sendAftertouch(miditouch, device, midichan, timeOffsetString) {
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');
} }
device.sendChannelAftertouch(miditouch, midichan, { time: timeOffsetString });
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, targetTime) { function sendNote(note, velocity, duration, device, midichan, timeOffsetString) {
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');
} }
@@ -281,12 +268,12 @@ function sendNote(note, velocity, duration, device, midichan, targetTime) {
if (duration != null && (typeof duration !== 'number' || duration < 0)) { if (duration != null && (typeof duration !== 'number' || duration < 0)) {
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, duration }); const midiNote = new Note(midiNumber, { attack: velocity, duration });
device.playNote(midiNote, midichan, {
scheduleAtTime(() => { time: timeOffsetString,
device.playNote(midiNote, midichan); });
}, targetTime);
} }
/** /**
@@ -322,6 +309,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
latencyMs: 34, // Default latency to get audio engine to line up in ms
noteOffsetMs: 10, // Default note-off offset to prevent glitching in ms 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
@@ -345,13 +333,18 @@ Pattern.prototype.midi = function (midiport, options = {}) {
logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`), logger(`Midi device disconnected! Available: ${getMidiDeviceNamesString(outputs)}`),
}); });
return this.onTrigger((hap, _currentTime, cps, targetTime) => { return this.onTrigger((hap, currentTime, cps, targetTime) => {
if (!WebMidi.enabled) { if (!WebMidi.enabled) {
logger('Midi not enabled'); logger('Midi not enabled');
return; return;
} }
hap.ensureObjectValue(); hap.ensureObjectValue();
//magic number to get audio engine to line up, can probably be calculated somehow
const latencyMs = midiConfig.latencyMs;
// passing a string with a +num into the webmidi api adds an offset to the current time https://webmidijs.org/api/classes/Output
const timeOffsetString = `+${getEventOffsetMs(targetTime, currentTime) + latencyMs}`;
// midi event values from hap with configurable defaults // midi event values from hap with configurable defaults
let { let {
note, note,
@@ -387,7 +380,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, targetTime)); ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, timeOffsetString));
} 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(', ')}`);
@@ -399,12 +392,12 @@ Pattern.prototype.midi = function (midiport, options = {}) {
// try to prevent glitching by subtracting noteOffsetMs from the duration length // try to prevent glitching by subtracting noteOffsetMs from the duration length
const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs; const duration = (hap.duration.valueOf() / cps) * 1000 - midiConfig.noteOffsetMs;
sendNote(note, velocity, duration, device, midichan, targetTime); sendNote(note, velocity, duration, device, midichan, timeOffsetString);
} }
// Handle program change // Handle program change
if (progNum !== undefined) { if (progNum !== undefined) {
sendProgramChange(progNum, device, midichan, targetTime); sendProgramChange(progNum, device, midichan, timeOffsetString);
} }
// Handle sysex // Handle sysex
@@ -414,63 +407,53 @@ 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, targetTime); sendSysex(sysexid, sysexdata, device, timeOffsetString);
} }
// Handle control change // Handle control change
if (ccv !== undefined && ccn !== undefined) { if (ccv !== undefined && ccn !== undefined) {
sendCC(ccn, ccv, device, midichan, targetTime); sendCC(ccn, ccv, device, midichan, timeOffsetString);
} }
// 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, targetTime); sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString);
} }
// Handle midibend // Handle midibend
if (midibend !== undefined) { if (midibend !== undefined) {
sendPitchBend(midibend, device, midichan, targetTime); sendPitchBend(midibend, device, midichan, timeOffsetString);
} }
// Handle miditouch // Handle miditouch
if (miditouch !== undefined) { if (miditouch !== undefined) {
sendAftertouch(miditouch, device, midichan, targetTime); sendAftertouch(miditouch, device, midichan, timeOffsetString);
} }
// 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
scheduleAtTime(() => { device.sendStart({ time: timeOffsetString });
device.sendStart();
}, targetTime);
} }
if (['clock', 'midiClock'].includes(midicmd)) { if (['clock', 'midiClock'].includes(midicmd)) {
scheduleAtTime(() => { device.sendClock({ time: timeOffsetString });
device.sendClock();
}, targetTime);
} else if (['start'].includes(midicmd)) { } else if (['start'].includes(midicmd)) {
scheduleAtTime(() => { device.sendStart({ time: timeOffsetString });
device.sendStart();
}, targetTime);
} else if (['stop'].includes(midicmd)) { } else if (['stop'].includes(midicmd)) {
scheduleAtTime(() => { device.sendStop({ time: timeOffsetString });
device.sendStop();
}, targetTime);
} else if (['continue'].includes(midicmd)) { } else if (['continue'].includes(midicmd)) {
scheduleAtTime(() => { device.sendContinue({ time: timeOffsetString });
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, targetTime); sendProgramChange(midicmd[1], device, midichan, timeOffsetString);
} 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, targetTime); sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString);
} }
} 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, targetTime); sendSysex(id, data, device, timeOffsetString);
} }
} }
} }
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/midi", "name": "@strudel/midi",
"version": "1.2.6", "version": "1.2.5",
"description": "Midi API for strudel", "description": "Midi API for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -35,8 +35,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/mini", "name": "@strudel/mini",
"version": "1.2.5", "version": "1.2.4",
"description": "Mini notation for strudel", "description": "Mini notation for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -38,8 +38,5 @@
"peggy": "^4.2.0", "peggy": "^4.2.0",
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+5 -8
View File
@@ -21,14 +21,13 @@ export class MondoParser {
close_curly: /^\}/, close_curly: /^\}/,
number: /^-?[0-9]*\.?[0-9]+/, // before pipe! number: /^-?[0-9]*\.?[0-9]+/, // before pipe!
// TODO: better error handling when "-" is used as rest, e.g "s [- bd]" // TODO: better error handling when "-" is used as rest, e.g "s [- bd]"
op: /^[*/:!@%?+\-&]|^\.{2}/, // * / : ! @ % ? .. op: /^[*/:!@%?+-]|^\.{2}/, // * / : ! @ % ? ..
// dollar: /^\$/, // dollar: /^\$/,
pipe: /^#/, pipe: /^#/,
stack: /^[,$]/, stack: /^[,$]/,
or: /^[|]/, or: /^[|]/,
plain: /^[a-zA-Z0-9-~_^#]+/, plain: /^[a-zA-Z0-9-~_^#]+/,
}; };
op_precedence = [['*', '/', ':', '!', '@', '%', '?', '+', '-', '..'], ['&']];
// matches next token // matches next token
next_token(code, offset = 0) { next_token(code, offset = 0) {
for (let type in this.token_types) { for (let type in this.token_types) {
@@ -151,9 +150,9 @@ export class MondoParser {
} }
return children; return children;
} }
desugar_ops(children, types) { desugar_ops(children) {
while (true) { while (true) {
let opIndex = children.findIndex((child) => child.type === 'op' && types.includes(child.value)); let opIndex = children.findIndex((child) => child.type === 'op');
if (opIndex === -1) break; if (opIndex === -1) break;
const op = { type: 'plain', value: children[opIndex].value }; const op = { type: 'plain', value: children[opIndex].value };
if (opIndex === children.length - 1) { if (opIndex === children.length - 1) {
@@ -264,10 +263,8 @@ export class MondoParser {
// the type we've removed before splitting needs to be added back // the type we've removed before splitting needs to be added back
children = [{ type: 'plain', value: type }, ...children]; children = [{ type: 'plain', value: type }, ...children];
} }
// for each precendence group, call desugar_ops once children = this.desugar_ops(children);
this.op_precedence.forEach((ops) => { // children = this.desugar_pipes(children, (children) => this.desugar_dollars(children));
children = this.desugar_ops(children, ops);
});
children = this.desugar_pipes(children); children = this.desugar_pipes(children);
return children; return children;
}), }),
-3
View File
@@ -33,8 +33,5 @@
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-1
View File
@@ -117,7 +117,6 @@ describe('mondo sugar', () => {
it('should desugar x:y', () => expect(desguar('x:y')).toEqual('(: y x)')); it('should desugar x:y', () => expect(desguar('x:y')).toEqual('(: y x)'));
it('should desugar x:y:z', () => expect(desguar('x:y:z')).toEqual('(: z (: y x))')); it('should desugar x:y:z', () => expect(desguar('x:y:z')).toEqual('(: z (: y x))'));
it('should desugar x:y*x', () => expect(desguar('bd:0*2')).toEqual('(* 2 (: 0 bd))')); it('should desugar x:y*x', () => expect(desguar('bd:0*2')).toEqual('(* 2 (: 0 bd))'));
it('should desugar x&y:z', () => expect(desguar('bd&3:8')).toEqual('(& (: 8 3) bd)'));
it('should desugar a..b', () => expect(desguar('0..2')).toEqual('(.. 2 0)')); it('should desugar a..b', () => expect(desguar('0..2')).toEqual('(.. 2 0)'));
/* it('should desugar x $ y', () => expect(desguar('x $ y')).toEqual('(x y)')); /* it('should desugar x $ y', () => expect(desguar('x $ y')).toEqual('(x y)'));
it('should desugar x $ y z', () => expect(desguar('x $ y z')).toEqual('(x (y z))')); it('should desugar x $ y z', () => expect(desguar('x $ y z')).toEqual('(x (y z))'));
+2 -4
View File
@@ -5,13 +5,12 @@ import {
slow, slow,
seq, seq,
stepcat, stepcat,
replicate, extend,
expand, expand,
pace, pace,
chooseIn, chooseIn,
degradeBy, degradeBy,
silence, silence,
bjork,
} from '@strudel/core'; } from '@strudel/core';
import { registerLanguage } from '@strudel/transpiler'; import { registerLanguage } from '@strudel/transpiler';
import { MondoRunner } from 'mondolang'; import { MondoRunner } from 'mondolang';
@@ -37,11 +36,10 @@ lib.square = (...args) => stepcat(...args).setSteps(1);
lib.angle = (...args) => stepcat(...args).pace(1); lib.angle = (...args) => stepcat(...args).pace(1);
lib['*'] = fast; lib['*'] = fast;
lib['/'] = slow; lib['/'] = slow;
lib['!'] = replicate; lib['!'] = extend;
lib['@'] = expand; lib['@'] = expand;
lib['%'] = pace; lib['%'] = pace;
lib['?'] = degradeBy; // todo: default 0.5 not working.. lib['?'] = degradeBy; // todo: default 0.5 not working..
lib['&'] = bjork;
lib[':'] = tail; lib[':'] = tail;
lib['..'] = range; lib['..'] = range;
lib['def'] = () => silence; lib['def'] = () => silence;
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/mondo", "name": "@strudel/mondo",
"version": "1.1.5", "version": "1.1.4",
"description": "mondo notation for strudel", "description": "mondo notation for strudel",
"main": "mondough.mjs", "main": "mondough.mjs",
"type": "module", "type": "module",
@@ -40,8 +40,5 @@
"mondo": "*", "mondo": "*",
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/motion", "name": "@strudel/motion",
"version": "1.2.5", "version": "1.2.4",
"description": "DeviceMotion API for strudel", "description": "DeviceMotion API for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -33,8 +33,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/mqtt", "name": "@strudel/mqtt",
"version": "1.2.5", "version": "1.2.4",
"description": "MQTT API for strudel", "description": "MQTT API for strudel",
"main": "mqtt.mjs", "main": "mqtt.mjs",
"type": "module", "type": "module",
@@ -34,8 +34,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+16 -17
View File
@@ -4,6 +4,8 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. 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 OSC from 'osc-js';
import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core'; import { logger, parseNumeral, register, isNote, noteToMidi, ClockCollator } from '@strudel/core';
let connection; // Promise<OSC> let connection; // Promise<OSC>
@@ -11,18 +13,19 @@ function connect() {
if (!connection) { if (!connection) {
// make sure this runs only once // make sure this runs only once
connection = new Promise((resolve, reject) => { connection = new Promise((resolve, reject) => {
const ws = new WebSocket('ws://localhost:8080'); const osc = new OSC();
ws.addEventListener('open', (event) => { osc.open();
logger(`[osc] websocket connected`); osc.on('open', () => {
resolve(ws); const url = osc.options?.plugin?.socket?.url;
logger(`[osc] connected${url ? ` to ${url}` : ''}`);
resolve(osc);
}); });
ws.addEventListener('close', (event) => { osc.on('close', () => {
logger(`[osc] websocket closed`);
connection = undefined; // allows new connection afterwards connection = undefined; // allows new connection afterwards
console.log('[osc] disconnected'); console.log('[osc] disconnected');
reject('OSC connection closed'); reject('OSC connection closed');
}); });
ws.addEventListener('error', (err) => reject(err)); osc.on('error', (err) => reject(err));
}).catch((err) => { }).catch((err) => {
connection = undefined; connection = undefined;
throw new Error('Could not connect to OSC server. Is it running?'); throw new Error('Could not connect to OSC server. Is it running?');
@@ -58,19 +61,15 @@ export function parseControlsFromHap(hap, cps) {
const collator = new ClockCollator({}); const collator = new ClockCollator({});
export async function oscTrigger(hap, currentTime, cps = 1, targetTime) { export async function oscTrigger(hap, currentTime, cps = 1, targetTime) {
const ws = await connect(); const osc = await connect();
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 };
if ('oschost' in hap.value) { const ts = Math.round(collator.calculateTimestamp(currentTime, targetTime) * 1000);
msg['host'] = hap.value['oschost']; const message = new OSC.Message('/dirt/play', ...keyvals);
} const bundle = new OSC.Bundle([message], ts);
if ('oscport' in hap.value) { bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60
msg['port'] = hap.value['oscport']; osc.send(bundle);
}
ws.send(JSON.stringify(msg));
} }
/** /**
+2 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/osc", "name": "@strudel/osc",
"version": "1.3.0", "version": "1.2.10",
"description": "OSC messaging for strudel", "description": "OSC messaging for strudel",
"main": "osc.mjs", "main": "osc.mjs",
"bin": "./server.js", "bin": "./server.js",
@@ -38,14 +38,10 @@
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": { "dependencies": {
"@strudel/core": "workspace:*", "@strudel/core": "workspace:*",
"osc": "^2.4.5", "osc-js": "^2.4.1"
"ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"pkg": "^5.8.1", "pkg": "^5.8.1",
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
Executable → Regular
+58 -47
View File
@@ -6,59 +6,70 @@ 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 OSC from 'osc-js'; import OSC from 'osc-js';
import { WebSocketServer } from 'ws'; const args = process.argv.slice(2);
import osc from 'osc'; function getArgValue(flag) {
const i = args.indexOf(flag);
if (i !== -1) {
const nextIsFlag = args[i + 1]?.startsWith('--') ?? true;
if (nextIsFlag) return true;
return args[i + 1];
}
}
const WS_PORT = 8080; // WebSocket server port let udpClientPort = Number(getArgValue('--port')) || 57120;
const OSC_REMOTE_IP = '127.0.0.1'; let debug = Number(getArgValue('--debug')) || 0;
const OSC_REMOTE_PORT = 57120;
const udpPort = new osc.UDPPort({ const config = {
localAddress: '0.0.0.0', receiver: 'ws', // @param {string} Where messages sent via 'send' method will be delivered to, 'ws' for Websocket clients, 'udp' for udp client
localPort: 0, udpServer: {
remoteAddress: OSC_REMOTE_IP, host: 'localhost', // @param {string} Hostname of udp server to bind to
remotePort: OSC_REMOTE_PORT, port: 57121, // @param {number} Port of udp client for messaging
// enabling the following line will receive tidal messages:
// port: 57120, // @param {number} Port of udp client for messaging
exclusive: false, // @param {boolean} Exclusive flag
},
udpClient: {
host: 'localhost', // @param {string} Hostname of udp client for messaging
port: udpClientPort, // @param {number} Port of udp client for messaging
},
wsServer: {
host: 'localhost', // @param {string} Hostname of WebSocket server
port: 8080, // @param {number} Port of WebSocket server
},
};
const osc = new OSC({ plugin: new OSC.BridgePlugin(config) });
if (debug) {
osc.on('*', (message) => {
const { address, args } = message;
let str = '';
for (let i = 0; i < args.length; i += 2) {
str += `${args[i]}: ${args[i + 1]} `;
}
console.log(`${address} ${str}`);
}); });
udpPort.open();
console.log(`[Sending OSC] ${OSC_REMOTE_IP}:${OSC_REMOTE_PORT}`);
udpPort.on('error', (e) => {
console.log('Error: ', e);
});
const wss = new WebSocketServer({ port: WS_PORT });
console.log(`[Listening WS] ws://localhost:${WS_PORT}`);
wss.on('connection', (ws) => {
console.log('New WebSocket connection');
ws.on('message', (message) => {
let osc_host = '127.0.0.1';
let osc_port = 57120;
try {
const data = JSON.parse(message);
if ('host' in data) {
osc_host = data['host'];
}
if ('port' in data) {
osc_port = data['port'];
}
let msg = { address: data['address'], args: data['args'] };
if ('timestamp' in data) {
msg = { timeTag: osc.timeTag(0, data['timestamp']), packets: [msg] };
} }
udpPort.send(msg, osc_host, osc_port); osc.on('error', (message) => {
} catch (err) { if (message.toString().includes('EADDRINUSE')) {
console.error('Error parsing message:', err); console.log(`------ ERROR -------
a server is already running on port 57121! to stop it:
1. run "lsof -ti :57121 | xargs kill -9" (macos / linux)
2. re-run the osc server
`);
} else {
console.log(message);
} }
}); });
ws.on('close', () => { osc.open();
console.log('WebSocket connection closed');
}); console.log('osc client running on port', config.udpClient.port);
}); console.log('osc server running on port', config.udpServer.port);
console.log('websocket server running on port', config.wsServer.port);
if (debug) {
console.log('debug logs enabled. incoming messages will appear below');
}
-3
View File
@@ -33,8 +33,5 @@
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://codeberg.org/uzu/strudel#readme",
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/repl", "name": "@strudel/repl",
"version": "1.2.7", "version": "1.2.6",
"description": "Strudel REPL as a Web Component", "description": "Strudel REPL as a Web Component",
"module": "index.mjs", "module": "index.mjs",
"publishConfig": { "publishConfig": {
@@ -48,8 +48,5 @@
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.2",
"vite": "^6.0.11", "vite": "^6.0.11",
"vite-plugin-bundle-audioworklet": "workspace:*" "vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-3
View File
@@ -15,8 +15,5 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"cowsay": "^1.6.0" "cowsay": "^1.6.0"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+3 -1
View File
@@ -9,7 +9,6 @@ import readline from 'readline';
import os from 'os'; import os from 'os';
const LOG = !!process.env.LOG || false; const LOG = !!process.env.LOG || false;
const PORT = process.env.PORT || 5432;
const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg']; const VALID_AUDIO_EXTENSIONS = ['wav', 'mp3', 'ogg'];
const isAudioFile = (f) => { const isAudioFile = (f) => {
@@ -55,6 +54,7 @@ async function getBanks(directory, flat = false) {
banks[bank].push(subDir); banks[bank].push(subDir);
return subDir; return subDir;
}); });
banks._base = `http://localhost:5432`;
return { banks, files }; return { banks, files };
} }
@@ -134,6 +134,8 @@ const server = http.createServer(async (req, res) => {
readStream.pipe(res); readStream.pipe(res);
}); });
// eslint-disable-next-line
const PORT = process.env.PORT || 5432;
const IP_ADDRESS = '0.0.0.0'; const IP_ADDRESS = '0.0.0.0';
let IP; let IP;
const networkInterfaces = os.networkInterfaces(); const networkInterfaces = os.networkInterfaces();
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/serial", "name": "@strudel/serial",
"version": "1.2.5", "version": "1.2.4",
"description": "Webserial API for strudel", "description": "Webserial API for strudel",
"main": "serial.mjs", "main": "serial.mjs",
"type": "module", "type": "module",
@@ -33,8 +33,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/soundfonts", "name": "@strudel/soundfonts",
"version": "1.2.6", "version": "1.2.5",
"description": "Soundsfont support for strudel", "description": "Soundsfont support for strudel",
"main": "index.mjs", "main": "index.mjs",
"publishConfig": { "publishConfig": {
@@ -37,8 +37,5 @@
"devDependencies": { "devDependencies": {
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -2
View File
@@ -89,7 +89,7 @@ superdough({ s: 'bd', delay: 0.5 }, 0, 1);
- `decay`: seconds of decay phase - `decay`: seconds of decay phase
- `sustain`: gain of sustain phase - `sustain`: gain of sustain phase
- `release`: seconds of release phase - `release`: seconds of release phase
- `deadline`: seconds from audio context initialization before playing the sound (getAudioContextCurrentTime() = immediate) - `deadline`: seconds until the sound should play (0 = immediate)
- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds - `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds
### registerSynthSounds() ### registerSynthSounds()
@@ -153,7 +153,6 @@ samples('github:tidalcycles/dirt-samples')
The format is `github:<user>/<repo>/<branch>`. The format is `github:<user>/<repo>/<branch>`.
If `<repo>` and `<branch>` are not specified, they will default to `samples` and `main` respectively.
It expects a `strudel.json` file to be present at the root of the given repository, which declares the sample paths in the repo. It expects a `strudel.json` file to be present at the root of the given repository, which declares the sample paths in the repo.
The format is also expected to be the same as explained above. The format is also expected to be the same as explained above.
+39 -100
View File
@@ -154,24 +154,6 @@ export const getADSRValues = (params, curve = 'linear', defaultValues) => {
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
}; };
export function getParamLfo(audioContext, param, start, end, lfoValues) {
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
if (depth == null) {
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
depth = hasLFOParams ? defaultDepth : 0;
}
let lfo;
if (depth) {
lfo = getLfo(audioContext, start, end, {
depth,
dcoffset,
...getLfoInputs,
});
lfo.connect(param);
}
return lfo;
}
// helper utility for applying standard modulators to a parameter // helper utility for applying standard modulators to a parameter
export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) { export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) {
let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues; let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues;
@@ -188,76 +170,55 @@ export function applyParameterModulators(audioContext, param, start, end, envelo
const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues); const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues);
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve); getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
} }
const lfo = getParamLfo(audioContext, param, start, end, lfoValues); let lfo;
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
if (depth == null) {
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
depth = hasLFOParams ? defaultDepth : 0;
}
if (depth) {
lfo = getLfo(audioContext, start, end, {
depth,
dcoffset,
...getLfoInputs,
});
lfo.connect(param);
}
return { lfo, disconnect: () => lfo?.disconnect() }; return { lfo, disconnect: () => lfo?.disconnect() };
} }
export function createFilter(context, start, end, params, cps, cycle) {
let {
frequency,
anchor,
env,
type,
model,
q = 1,
drive = 0.69,
depth,
depthfrequency,
dcoffset = -0.5,
skew,
shape,
rate,
sync,
} = params;
let frequencyParam, filter; export function createFilter(context, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor, model, drive) {
const curve = 'exponential';
const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]);
let filter;
let frequencyParam;
if (model === 'ladder') { if (model === 'ladder') {
filter = getWorklet(context, 'ladder-processor', { frequency, q, drive }); filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive });
frequencyParam = filter.parameters.get('frequency'); frequencyParam = filter.parameters.get('frequency');
} else { } else {
filter = context.createBiquadFilter(); filter = context.createBiquadFilter();
filter.type = type; filter.type = type;
filter.Q.value = q; filter.Q.value = Q;
filter.frequency.value = frequency; filter.frequency.value = frequency;
frequencyParam = filter.frequency; frequencyParam = filter.frequency;
} }
const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]);
// envelope is active when any of these values is set // envelope is active when any of these values is set
const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined); const hasEnvelope = att ?? dec ?? sus ?? rel ?? fenv;
// Apply ADSR to filter frequency // Apply ADSR to filter frequency
if (hasEnvelope) { if (hasEnvelope !== undefined) {
env = nanFallback(env, 1, true); fenv = nanFallback(fenv, 1, true);
anchor = nanFallback(anchor, 0, true); fanchor = nanFallback(fanchor, 0, true);
const envAbs = Math.abs(env); const fenvAbs = Math.abs(fenv);
const offset = envAbs * anchor; const offset = fenvAbs * fanchor;
let min = clamp(2 ** -offset * frequency, 0, 20000); let min = clamp(2 ** -offset * frequency, 0, 20000);
let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000); let max = clamp(2 ** (fenvAbs - offset) * frequency, 0, 20000);
if (env < 0) [min, max] = [max, min]; if (fenv < 0) [min, max] = [max, min];
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential'); getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, curve);
return filter;
} }
if (sync != null) {
rate = cps * sync;
}
const hasLFO = [depth, depthfrequency, skew, shape, rate].some((v) => v !== undefined);
if (hasLFO) {
depth = depth ?? 1;
const time = cycle / cps;
const modDepth = depthfrequency ?? (depth ?? 1) * frequency;
const lfoValues = {
depth: modDepth,
dcoffset,
skew,
shape,
frequency: rate ?? cps,
min: -frequency + 30,
max: 20000 - frequency,
time,
curve: 1,
};
getParamLfo(context, frequencyParam, start, end, lfoValues);
}
return filter; return filter;
} }
@@ -280,15 +241,7 @@ export function drywet(dry, wet, wetAmount = 0) {
let mix = ac.createGain(); let mix = ac.createGain();
dry_gain.connect(mix); dry_gain.connect(mix);
wet_gain.connect(mix); wet_gain.connect(mix);
return { return mix;
node: mix,
onended: () => {
dry_gain.disconnect(mix);
wet_gain.disconnect(mix);
dry.disconnect(dry_gain);
wet.disconnect(wet_gain);
},
};
} }
let curves = ['linear', 'exponential']; let curves = ['linear', 'exponential'];
@@ -323,19 +276,10 @@ export function getVibratoOscillator(param, value, t) {
gain.gain.value = vibmod * 100; gain.gain.value = vibmod * 100;
vibratoOscillator.connect(gain); vibratoOscillator.connect(gain);
gain.connect(param); gain.connect(param);
vibratoOscillator.onended = () => {
gain.disconnect(param);
vibratoOscillator.disconnect(gain);
};
vibratoOscillator.start(t); vibratoOscillator.start(t);
return vibratoOscillator; return vibratoOscillator;
} }
} }
export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) {
const currentTime = audioContext.currentTime;
webAudioTimeout(audioContext, callback, currentTime, targetTime);
}
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities // ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
// a bit of a hack, but it works very well :) // a bit of a hack, but it works very well :)
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
@@ -380,9 +324,9 @@ const mod = (freq, range = 1, type = 'sine') => {
} }
osc.start(); osc.start();
const g = gainNode(range); const g = new GainNode(ctx, { gain: range });
osc.connect(g); // -range, range osc.connect(g); // -range, range
return { node: g, stop: (t) => osc.stop(t), osc: osc }; return { node: g, stop: (t) => osc.stop(t) };
}; };
const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => { const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => {
const carrfreq = frequencyparam.value; const carrfreq = frequencyparam.value;
@@ -434,11 +378,6 @@ export function applyFM(param, value, begin) {
modulator.connect(envGain); modulator.connect(envGain);
envGain.connect(param); envGain.connect(param);
} }
fmmod.osc.onended = () => {
envGain.disconnect();
modulator.disconnect();
fmmod.osc.disconnect();
};
} }
return { stop }; return { stop };
} }
@@ -540,7 +479,7 @@ export const getDistortion = (distort, postgain, algorithm) => {
}; };
export const getFrequencyFromValue = (value, defaultNote = 36) => { export const getFrequencyFromValue = (value, defaultNote = 36) => {
let { note, freq, octave = 0 } = value; let { note, freq } = value;
note = note || defaultNote; note = note || defaultNote;
if (typeof note === 'string') { if (typeof note === 'string') {
note = noteToMidi(note); // e.g. c3 => 48 note = noteToMidi(note); // e.g. c3 => 48
@@ -549,7 +488,7 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => {
if (!freq && typeof note === 'number') { if (!freq && typeof note === 'number') {
freq = midiToFreq(note); // + 48); freq = midiToFreq(note); // + 48);
} }
freq *= Math.pow(2, octave);
return Number(freq); return Number(freq);
}; };
+1 -2
View File
@@ -65,9 +65,8 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) {
export function getNoiseMix(inputNode, wet, t) { export function getNoiseMix(inputNode, wet, t) {
const noiseOscillator = getNoiseOscillator('pink', t); const noiseOscillator = getNoiseOscillator('pink', t);
const noiseMix = drywet(inputNode, noiseOscillator.node, wet); const noiseMix = drywet(inputNode, noiseOscillator.node, wet);
noiseOscillator.node.onended = () => noiseMix.onended();
return { return {
node: noiseMix.node, node: noiseMix,
stop: (time) => noiseOscillator?.stop(time), stop: (time) => noiseOscillator?.stop(time),
}; };
} }
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "superdough", "name": "superdough",
"version": "1.2.6", "version": "1.2.5",
"description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.", "description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -37,8 +37,5 @@
}, },
"dependencies": { "dependencies": {
"nanostores": "^0.11.3" "nanostores": "^0.11.3"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-2
View File
@@ -104,8 +104,6 @@ var applyGradualLowpass = function (input, lpFreqStart, lpFreqEnd, lpFreqEndAt,
player.start(); player.start();
context.oncomplete = function (event) { context.oncomplete = function (event) {
callback(event.renderedBuffer); callback(event.renderedBuffer);
filter.disconnect();
player.disconnect();
}; };
context.startRendering(); context.startRendering();
+8 -13
View File
@@ -1,4 +1,4 @@
import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { getCommonSampleInfo } from './util.mjs';
import { registerSound, registerWaveTable } from './index.mjs'; import { registerSound, registerWaveTable } from './index.mjs';
import { getAudioContext } from './audioContext.mjs'; import { getAudioContext } from './audioContext.mjs';
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs'; import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
@@ -121,18 +121,13 @@ function githubPath(base, subpath = '') {
if (!base.startsWith('github:')) { if (!base.startsWith('github:')) {
throw new Error('expected "github:" at the start of pseudoUrl'); throw new Error('expected "github:" at the start of pseudoUrl');
} }
let path = base.slice('github:'.length); let [_, path] = base.split('github:');
path = path.endsWith('/') ? path.slice(0, -1) : path; path = path.endsWith('/') ? path.slice(0, -1) : path;
if (path.split('/').length === 2) {
let components = path.split('/'); // assume main as default branch if none set
let user = components[0]; path += '/main';
let repo = components.length >= 2 ? components[1] : 'samples'; }
let branch = components.length >= 3 ? components[2] : 'main'; return `https://raw.githubusercontent.com/${path}/${subpath}`;
let other = components.slice(3);
other.push(subpath ? subpath : '');
other = other.join('/');
return `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${other}`;
} }
export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => { export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => {
@@ -211,7 +206,7 @@ export async function fetchSampleMap(url) {
// not a browser // not a browser
return; return;
} }
const base = getBaseURL(url); const base = url.split('/').slice(0, -1).join('/');
if (typeof fetch === 'undefined') { if (typeof fetch === 'undefined') {
// skip fetch when in node / testing // skip fetch when in node / testing
return; return;
+74 -81
View File
@@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
import './feedbackdelay.mjs'; import './feedbackdelay.mjs';
import './reverb.mjs'; import './reverb.mjs';
import './vowel.mjs'; import './vowel.mjs';
import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs'; import { nanFallback, _mod, cycleToSeconds } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet'; import workletsUrl from './worklets.mjs?audioworklet';
import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs'; import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs';
import { map } from 'nanostores'; import { map } from 'nanostores';
@@ -146,6 +146,11 @@ let defaultDefaultValues = {
gain: 0.8, gain: 0.8,
postgain: 1, postgain: 1,
density: '.03', density: '.03',
ftype: '12db',
fanchor: 0,
resonance: 1,
hresonance: 1,
bandq: 1,
channels: [1, 2], channels: [1, 2],
phaserdepth: 0.75, phaserdepth: 0.75,
shapevol: 1, shapevol: 1,
@@ -263,8 +268,8 @@ let audioReady;
export async function initAudioOnFirstClick(options) { export async function initAudioOnFirstClick(options) {
if (!audioReady) { if (!audioReady) {
audioReady = new Promise((resolve) => { audioReady = new Promise((resolve) => {
document.addEventListener('mousedown', async function listener() { document.addEventListener('click', async function listener() {
document.removeEventListener('mousedown', listener); document.removeEventListener('click', listener);
await initAudio(options); await initAudio(options);
resolve(); resolve();
}); });
@@ -410,7 +415,32 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
djf, djf,
// filters // filters
fanchor = getDefaultValue('fanchor'), fanchor = getDefaultValue('fanchor'),
drive = 0.69,
release = 0, release = 0,
// low pass
cutoff,
lpenv,
lpattack,
lpdecay,
lpsustain,
lprelease,
resonance = getDefaultValue('resonance'),
// high pass
hpenv,
hcutoff,
hpattack,
hpdecay,
hpsustain,
hprelease,
hresonance = getDefaultValue('hresonance'),
// band pass
bpenv,
bandf,
bpattack,
bpdecay,
bpsustain,
bprelease,
bandq = getDefaultValue('bandq'),
//phaser //phaser
phaserrate: phaser, phaserrate: phaser,
@@ -481,7 +511,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// oldest audio nodes will be destroyed if maximum polyphony is exceeded // oldest audio nodes will be destroyed if maximum polyphony is exceeded
for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) { for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) {
const ch = activeSoundSources.entries().next(); const ch = activeSoundSources.entries().next();
const source = ch.value[1].deref(); const source = ch.value[1];
const chainID = ch.value[0]; const chainID = ch.value[0];
const endTime = t + 0.25; const endTime = t + 0.25;
source?.node?.gain?.linearRampToValueAtTime(0, endTime); source?.node?.gain?.linearRampToValueAtTime(0, endTime);
@@ -513,7 +543,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
if (soundHandle) { if (soundHandle) {
sourceNode = soundHandle.node; sourceNode = soundHandle.node;
activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC activeSoundSources.set(chainID, soundHandle);
} }
} else { } else {
throw new Error(`sound ${s} not found! Is it loaded?`); throw new Error(`sound ${s} not found! Is it loaded?`);
@@ -537,88 +567,55 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
//filter //filter
const ftype = getFilterType(value.ftype); const ftype = getFilterType(value.ftype);
if (cutoff !== undefined) {
if (value.cutoff !== undefined) { let lp = () =>
const lpMap = { createFilter(
frequency: 'cutoff', ac,
q: 'resonance', 'lowpass',
attack: 'lpattack', cutoff,
decay: 'lpdecay', resonance,
sustain: 'lpsustain', lpattack,
release: 'lprelease', lpdecay,
env: 'lpenv', lpsustain,
anchor: 'fanchor', lprelease,
model: 'ftype', lpenv,
drive: 'drive', t,
rate: 'lprate', end,
sync: 'lpsync', fanchor,
depth: 'lpdepth', ftype,
depthfrequency: 'lpdepthfrequency', drive,
shape: 'lpshape', );
dcoffset: 'lpdc',
skew: 'lpskew',
};
const lpParams = pickAndRename(value, lpMap);
lpParams.type = 'lowpass';
let lp = () => createFilter(ac, t, end, lpParams, cps, cycle);
chain.push(lp()); chain.push(lp());
if (ftype === '24db') { if (ftype === '24db') {
chain.push(lp()); chain.push(lp());
} }
} }
if (value.hcutoff !== undefined) { if (hcutoff !== undefined) {
const hpMap = { let hp = () =>
frequency: 'hcutoff', createFilter(
q: 'hresonance', ac,
attack: 'hpattack', 'highpass',
decay: 'hpdecay', hcutoff,
sustain: 'hpsustain', hresonance,
release: 'hprelease', hpattack,
env: 'hpenv', hpdecay,
anchor: 'fanchor', hpsustain,
model: 'ftype', hprelease,
drive: 'drive', hpenv,
rate: 'hprate', t,
sync: 'hpsync', end,
depth: 'hpdepth', fanchor,
depthfrequency: 'hpdepthfrequency', );
shape: 'hpshape',
dcoffset: 'hpdc',
skew: 'hpskew',
};
const hpParams = pickAndRename(value, hpMap);
hpParams.type = 'highpass';
let hp = () => createFilter(ac, t, end, hpParams, cps, cycle);
chain.push(hp()); chain.push(hp());
if (ftype === '24db') { if (ftype === '24db') {
chain.push(hp()); chain.push(hp());
} }
} }
if (value.bandf !== undefined) { if (bandf !== undefined) {
const bpMap = { let bp = () =>
frequency: 'bandf', createFilter(ac, 'bandpass', bandf, bandq, bpattack, bpdecay, bpsustain, bprelease, bpenv, t, end, fanchor);
q: 'bandq',
attack: 'bpattack',
decay: 'bpdecay',
sustain: 'bpsustain',
release: 'bprelease',
env: 'bpenv',
anchor: 'fanchor',
model: 'ftype',
drive: 'drive',
rate: 'bprate',
sync: 'bpsync',
depth: 'bpdepth',
depthfrequency: 'bpdepthfrequency',
shape: 'bpshape',
dcoffset: 'bpdc',
skew: 'bpskew',
};
const bpParams = pickAndRename(value, bpMap);
bpParams.type = 'bandpass';
let bp = () => createFilter(ac, t, end, bpParams, cps, cycle);
chain.push(bp()); chain.push(bp());
if (ftype === '24db') { if (ftype === '24db') {
chain.push(bp()); chain.push(bp());
@@ -633,7 +630,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// effects // effects
coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse })); coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse }));
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush })); crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }));
distort !== undefined && chain.push(getDistortion(distort, distortvol, distorttype)); distort !== undefined && chain.push(getDistortion(distort, distortvol, distorttype));
if (tremolosync != null) { if (tremolosync != null) {
@@ -668,7 +664,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
curve: 1.5, curve: 1.5,
}); });
lfo.connect(amGain.gain); lfo.connect(amGain.gain);
audioNodes.push(lfo);
chain.push(amGain); chain.push(amGain);
} }
@@ -696,8 +691,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
// delay // delay
if (delay > 0 && delaytime > 0 && delayfeedback > 0) { if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
orbitBus.getDelay(delaytime, delayfeedback, t); orbitBus.getDelay(delaytime, delayfeedback, t);
const send = orbitBus.sendDelay(post, delay); orbitBus.sendDelay(post, delay);
audioNodes.push(send);
} }
// reverb // reverb
if (room > 0) { if (room > 0) {
@@ -713,8 +707,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
roomIR = await loadBuffer(url, ac, ir, 0); roomIR = await loadBuffer(url, ac, ir, 0);
} }
orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin); orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
const send = orbitBus.sendReverb(post, room); orbitBus.sendReverb(post, room);
audioNodes.push(send);
} }
if (djf != null) { if (djf != null) {
+2 -2
View File
@@ -78,11 +78,11 @@ export class Orbit {
return this.reverbNode; return this.reverbNode;
} }
sendReverb(node, amount) { sendReverb(node, amount) {
return effectSend(node, this.reverbNode, amount); effectSend(node, this.reverbNode, amount);
} }
sendDelay(node, amount) { sendDelay(node, amount) {
return effectSend(node, this.delayNode, amount); effectSend(node, this.delayNode, amount);
} }
duck(t, onsettime = 0, attacktime = 0.1, depth = 1) { duck(t, onsettime = 0, attacktime = 0.1, depth = 1) {
+25 -51
View File
@@ -15,10 +15,9 @@ import {
noises, noises,
webAudioTimeout, webAudioTimeout,
} from './helpers.mjs'; } from './helpers.mjs';
import { logger } from './logger.mjs';
import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user']; const waveforms = ['triangle', 'square', 'sawtooth', 'sine'];
const waveformAliases = [ const waveformAliases = [
['tri', 'triangle'], ['tri', 'triangle'],
['sqr', 'square'], ['sqr', 'square'],
@@ -48,17 +47,19 @@ export function registerSynthSounds() {
[0.001, 0.05, 0.6, 0.01], [0.001, 0.05, 0.6, 0.01],
); );
let sound = getOscillator(s, t, value);
let { node: o, stop, triggerRelease } = sound;
// turn down // turn down
const g = gainNode(0.3); const g = gainNode(0.3);
let sound = getOscillator(s, t, value, () => { const { duration } = value;
o.onended = () => {
o.disconnect();
g.disconnect(); g.disconnect();
onended(); onended();
}); };
let { node: o, stop, triggerRelease } = sound;
const { duration } = value;
const envGain = gainNode(1); const envGain = gainNode(1);
let node = o.connect(g).connect(envGain); let node = o.connect(g).connect(envGain);
@@ -413,13 +414,9 @@ export function registerSynthSounds() {
waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] })); waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] }));
} }
const PI2 = 2 * Math.PI; export function waveformN(partials, type) {
export function waveformN(partials, phases, type) { const real = new Float32Array(partials + 1);
const isList = typeof partials === 'object'; const imag = new Float32Array(partials + 1);
partials = isList ? partials : new Float32Array(partials).fill(1);
const len = partials.length;
const real = new Float32Array(len + 1);
const imag = new Float32Array(len + 1);
const ac = getAudioContext(); const ac = getAudioContext();
const osc = ac.createOscillator(); const osc = ac.createOscillator();
@@ -427,29 +424,20 @@ export function waveformN(partials, phases, type) {
sawtooth: (n) => [0, -1 / n], sawtooth: (n) => [0, -1 / n],
square: (n) => [0, n % 2 === 0 ? 0 : 1 / n], square: (n) => [0, n % 2 === 0 ? 0 : 1 / n],
triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0], triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0],
user: (_n) => [0, 1],
}; };
if (!terms[type]) { if (!terms[type]) {
throw new Error(`unknown wave type ${type}`); throw new Error(`unknown wave type ${type}`);
} }
for (let n = 0; n < len; n++) { real[0] = 0; // dc offset
const mag = partials[n]; imag[0] = 0;
const [r, i] = terms[type](n + 1); // we skip n === 0 as this is dc offset let n = 1;
const phase = phases?.[n] ?? 0; while (n <= partials) {
// Scale by `partials` const [r, i] = terms[type](n);
let R = r * mag; real[n] = r;
let I = i * mag; imag[n] = i;
// Apply rotation by the phase n++;
if (phase !== 0) {
const c = Math.cos(PI2 * phase);
const s = Math.sin(PI2 * phase);
R = c * R - s * I;
I = s * R + c * I;
}
real[n + 1] = R;
imag[n + 1] = I;
} }
const wave = ac.createPeriodicWave(real, imag); const wave = ac.createPeriodicWave(real, imag);
@@ -458,28 +446,21 @@ export function waveformN(partials, phases, type) {
} }
// expects one of waveforms as s // expects one of waveforms as s
export function getOscillator(s, t, value, onended) { export function getOscillator(s, t, value) {
const { duration, noise = 0 } = value; let { n: partials, duration, noise = 0 } = value;
const partials = value.partials ?? value.n;
let o; let o;
if (s === 'user' && !partials) {
logger(
`[superdough] Synth 'user' was selected, but partials not specified. Defaulting to triangle. Use pat.partials to setup custom waveform`,
);
s = 'triangle';
}
s = s === 'user' && !partials ? 'triangle' : s;
// If no partials are given, use stock waveforms // If no partials are given, use stock waveforms
if (!partials || partials?.length === 0 || s === 'sine') { if (!partials || s === 'sine') {
o = getAudioContext().createOscillator(); o = getAudioContext().createOscillator();
o.type = s || 'triangle'; o.type = s || 'triangle';
} }
// generate custom waveform if partials are given // generate custom waveform if partials are given
else { else {
o = waveformN(partials, value.phases, s); o = waveformN(partials, s);
} }
// set frequency // set frequency
o.frequency.value = getFrequencyFromValue(value); o.frequency.value = getFrequencyFromValue(value);
o.start(t);
let vibratoOscillator = getVibratoOscillator(o.detune, value, t); let vibratoOscillator = getVibratoOscillator(o.detune, value, t);
@@ -492,13 +473,6 @@ export function getOscillator(s, t, value, onended) {
noiseMix = getNoiseMix(o, noise, t); noiseMix = getNoiseMix(o, noise, t);
} }
o.onended = () => {
noiseMix || o.disconnect();
noiseMix?.node.disconnect();
onended();
};
o.start(t);
return { return {
node: noiseMix?.node || o, node: noiseMix?.node || o,
stop: (time) => { stop: (time) => {
+1 -20
View File
@@ -16,17 +16,13 @@ export const tokenizeNote = (note) => {
const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const accs = { '#': 1, b: -1, s: 1, f: -1 }; const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
};
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) {
throw new Error('not a note: "' + note + '"'); throw new Error('not a note: "' + note + '"');
} }
const chroma = chromas[pc.toLowerCase()]; const chroma = chromas[pc.toLowerCase()];
const offset = getAccidentalsOffset(acc); const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
return (Number(oct) + 1) * 12 + chroma + offset; return (Number(oct) + 1) * 12 + chroma + offset;
}; };
export const midiToFreq = (n) => { export const midiToFreq = (n) => {
@@ -109,18 +105,3 @@ export function getCommonSampleInfo(hapValue, bank) {
const label = `${s}:${index}`; const label = `${s}:${index}`;
return { transpose, url, index, midi, label }; return { transpose, url, index, midi, label };
} }
/** Selects entries from `source` and renames them via `map` */
export const pickAndRename = (source, map) => {
return Object.fromEntries(Object.entries(map).map(([newKey, oldKey]) => [newKey, source[oldKey]]));
};
export const getBaseURL = (url) => {
try {
// For real URLs
return new URL('.', new URL(url)).href.replace(/\/$/, ''); // removes trailing slash
} catch {
// For pseudo URLS
return url.split('/').slice(0, -1).join('/');
}
};
+5 -17
View File
@@ -45,8 +45,7 @@ if (typeof GainNode !== 'undefined') {
throw new Error('vowel: unknown vowel ' + letter); throw new Error('vowel: unknown vowel ' + letter);
} }
const { gains, qs, freqs } = vowelFormant[letter]; const { gains, qs, freqs } = vowelFormant[letter];
this.makeupGain = ac.createGain(); const makeupGain = ac.createGain();
this.audioNodes = [];
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const gain = ac.createGain(); const gain = ac.createGain();
gain.gain.value = gains[i]; gain.gain.value = gains[i];
@@ -54,25 +53,14 @@ if (typeof GainNode !== 'undefined') {
filter.type = 'bandpass'; filter.type = 'bandpass';
filter.Q.value = qs[i]; filter.Q.value = qs[i];
filter.frequency.value = freqs[i]; filter.frequency.value = freqs[i];
super.connect(filter); this.connect(filter);
filter.connect(gain); filter.connect(gain);
this.audioNodes.push(filter); gain.connect(makeupGain);
gain.connect(this.makeupGain);
this.audioNodes.push(gain);
} }
this.makeupGain.gain.value = 8; // how much makeup gain to add? makeupGain.gain.value = 8; // how much makeup gain to add?
this.connect = (target) => makeupGain.connect(target);
return this; return this;
} }
connect(target) {
this.makeupGain.connect(target);
}
disconnect() {
this.makeupGain.disconnect();
this.audioNodes.forEach((n) => n.disconnect());
super.disconnect();
this.makeupGain = null;
this.audioNodes = null;
}
} }
AudioContext.prototype.createVowelFilter = function (letter) { AudioContext.prototype.createVowelFilter = function (letter) {
+4 -5
View File
@@ -1,5 +1,5 @@
import { getAudioContext, registerSound } from './index.mjs'; import { getAudioContext, registerSound } from './index.mjs';
import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { getCommonSampleInfo } from './util.mjs';
import { import {
applyFM, applyFM,
applyParameterModulators, applyParameterModulators,
@@ -190,7 +190,6 @@ export const tables = async (url, frameLen, json, options = {}) => {
if (url.startsWith('local:')) { if (url.startsWith('local:')) {
url = `http://localhost:5432`; url = `http://localhost:5432`;
} }
const base = getBaseURL(url);
if (typeof fetch !== 'function') { if (typeof fetch !== 'function') {
// not a browser // not a browser
return; return;
@@ -201,7 +200,7 @@ export const tables = async (url, frameLen, json, options = {}) => {
} }
return fetch(url) return fetch(url)
.then((res) => res.json()) .then((res) => res.json())
.then((json) => _processTables(json, base, frameLen, options)) .then((json) => _processTables(json, url, frameLen, options))
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
throw new Error(`error loading "${url}"`); throw new Error(`error loading "${url}"`);
@@ -232,12 +231,12 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
begin: t, begin: t,
end: envEnd, end: envEnd,
frequency, frequency,
freqspread: value.detune, detune: value.detune,
position: value.wt, position: value.wt,
warp: value.warp, warp: value.warp,
warpMode: warpmode, warpMode: warpmode,
voices: Math.max(value.unison ?? 1, 1), voices: Math.max(value.unison ?? 1, 1),
panspread: value.spread, spread: value.spread,
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0, phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
}, },
{ outputChannelCount: [2] }, { outputChannelCount: [2] },
+182 -250
View File
@@ -6,57 +6,51 @@ import OLAProcessor from './ola-processor';
import FFT from './fft.js'; import FFT from './fft.js';
import { getDistortionAlgorithm } from './helpers.mjs'; import { getDistortionAlgorithm } from './helpers.mjs';
const blockSize = 128;
const PI = Math.PI;
const TWO_PI = 2 * PI;
const INVSR = 1 / sampleRate;
const clamp = (num, min, max) => Math.min(Math.max(num, min), max); const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
const mod = (n, m) => ((n % m) + m) % m; const mod = (n, m) => ((n % m) + m) % m;
const lerp = (a, b, n) => n * (b - a) + a; const lerp = (a, b, n) => n * (b - a) + a;
const pv = (arr, n) => arr[n] ?? arr[0]; const pv = (arr, n) => arr[n] ?? arr[0];
const frac = (x) => x - Math.floor(x); const frac = (x) => x - Math.floor(x);
const ffloor = (x) => x | 0; // fast floor for non-negative
// Fast integer ops for non-negative values const getUnisonDetune = (unison, detune, voiceIndex) => {
const ffloor = (x) => x | 0;
const fround = (x) => ffloor(x + 0.5);
const fceil = (x) => ffloor(x + 1);
const ffrac = (x) => x - ffloor(x);
const fast_tanh = (x) => {
const x2 = x ** 2;
return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
};
// Optimized per-voice detuner which precomputes constants
const getDetuner = (unison, detune) => {
if (unison < 2) { if (unison < 2) {
return (_voiceIdx) => 0; return 0;
} }
const scale = detune / (unison - 1); return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1));
const center = detune * 0.5;
return (voiceIdx) => voiceIdx * scale - center;
}; };
const applySemitoneDetuneToFrequency = (frequency, detune) => { const applySemitoneDetuneToFrequency = (frequency, detune) => {
return frequency * Math.pow(2, detune / 12); return frequency * Math.pow(2, detune / 12);
}; };
// Restrict phase to the range [0, maxPhase) via wrapping
function wrapPhase(phase, maxPhase = 1) {
if (phase >= maxPhase) {
phase -= maxPhase;
} else if (phase < 0) {
phase += maxPhase;
}
return phase;
}
const blockSize = 128;
// Smooth waveshape near discontinuities to remove frequencies above Nyquist and prevent aliasing // Smooth waveshape near discontinuities to remove frequencies above Nyquist and prevent aliasing
// referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517 // referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517
function polyBlep(phase, dt) { function polyBlep(phase, dt) {
dt = Math.min(dt, 1 - dt); dt = Math.min(dt, 1 - dt);
const invdt = 1 / dt;
// Start of cycle // Start of cycle
if (phase < dt) { if (phase < dt) {
phase *= invdt; phase /= dt;
return 2 * phase - phase ** 2 - 1; // 2 * (phase - phase^2/2 - 0.5)
return phase + phase - phase * phase - 1;
} }
// End of cycle // End of cycle
else if (phase > 1 - dt) { else if (phase > 1 - dt) {
phase = (phase - 1) * invdt; phase = (phase - 1) / dt;
return phase ** 2 + 2 * phase + 1; // 2 * (phase^2/2 + phase + 0.5)
return phase * phase + phase + phase + 1;
} }
// 0 otherwise // 0 otherwise
else { else {
return 0; return 0;
@@ -72,7 +66,7 @@ const waveshapes = {
return phase / skew; return phase / skew;
}, },
sine(phase) { sine(phase) {
return Math.sin(TWO_PI * phase) * 0.5 + 0.5; return Math.sin(Math.PI * 2 * phase) * 0.5 + 0.5;
}, },
ramp(phase) { ramp(phase) {
return phase; return phase;
@@ -106,6 +100,12 @@ const waveshapes = {
return v - polyBlep(phase, dt); return v - polyBlep(phase, dt);
}, },
}; };
function getParamValue(block, param) {
if (param.length > 1) {
return param[block];
}
return param[0];
}
const waveShapeNames = Object.keys(waveshapes); const waveShapeNames = Object.keys(waveshapes);
class LFOProcessor extends AudioWorkletProcessor { class LFOProcessor extends AudioWorkletProcessor {
@@ -165,9 +165,9 @@ class LFOProcessor extends AudioWorkletProcessor {
const blockSize = output[0].length ?? 0; const blockSize = output[0].length ?? 0;
if (this.phase == null) { if (this.phase == null) {
this.phase = ffrac(time * frequency + phaseoffset); this.phase = mod(time * frequency + phaseoffset, 1);
} }
const dt = frequency * INVSR; const dt = frequency / sampleRate;
for (let n = 0; n < blockSize; n++) { for (let n = 0; n < blockSize; n++) {
for (let i = 0; i < output.length; i++) { for (let i = 0; i < output.length; i++) {
let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth; let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth;
@@ -293,8 +293,8 @@ class TwoPoleFilter {
// Out of bound values can produce NaNs // Out of bound values can produce NaNs
resonance = clamp(resonance, 0, 1); resonance = clamp(resonance, 0, 1);
cutoff = clamp(cutoff, 0, sampleRate / 2 - 1); cutoff = clamp(cutoff, 0, sampleRate / 2 - 1);
const c = clamp(2 * Math.sin(cutoff * PI * INVSR), 0, 1.14); const c = clamp(2 * Math.sin(cutoff * (_PI / sampleRate)), 0, 1.14);
const r = Math.pow(0.5, 8 * resonance + 1); const r = Math.pow(0.5, (resonance + 0.125) / 0.125);
const mrc = 1 - r * c; const mrc = 1 - r * c;
this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf
this.s1 = mrc * this.s1 + c * this.s0; // lpf this.s1 = mrc * this.s1 + c * this.s0; // lpf
@@ -353,6 +353,11 @@ class DJFProcessor extends AudioWorkletProcessor {
} }
registerProcessor('djf-processor', DJFProcessor); registerProcessor('djf-processor', DJFProcessor);
function fast_tanh(x) {
const x2 = x * x;
return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
}
const _PI = 3.14159265359;
//adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file //adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file
class LadderProcessor extends AudioWorkletProcessor { class LadderProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() { static get parameterDescriptors() {
@@ -390,7 +395,7 @@ class LadderProcessor extends AudioWorkletProcessor {
const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000); const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000);
let cutoff = parameters.frequency[0]; let cutoff = parameters.frequency[0];
cutoff = cutoff * TWO_PI * INVSR; cutoff = (cutoff * 2 * _PI) / sampleRate;
cutoff = cutoff > 1 ? 1 : cutoff; cutoff = cutoff > 1 ? 1 : cutoff;
const k = Math.min(8, resonance * 0.13); const k = Math.min(8, resonance * 0.13);
@@ -503,7 +508,6 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
name: 'voices', name: 'voices',
defaultValue: 5, defaultValue: 5,
min: 1, min: 1,
automationRate: 'k-rate',
}, },
]; ];
} }
@@ -515,37 +519,40 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
// this.port.postMessage({ type: 'onended' }); // this.port.postMessage({ type: 'onended' });
return false; return false;
} }
const output = outputs[0]; const output = outputs[0];
const voices = params.voices[0]; // k-rate
for (let i = 0; i < output[0].length; i++) { for (let i = 0; i < output[0].length; i++) {
const detune = pv(params.detune, i); const detune = pv(params.detune, i);
const voices = pv(params.voices, i);
const freqspread = pv(params.freqspread, i); const freqspread = pv(params.freqspread, i);
const panspread = pv(params.panspread, i) * 0.5 + 0.5; const panspread = pv(params.panspread, i) * 0.5 + 0.5;
let gainL = Math.sqrt(1 - panspread); const gain1 = Math.sqrt(1 - panspread);
let gainR = Math.sqrt(panspread); const gain2 = Math.sqrt(panspread);
let freq = pv(params.frequency, i); let freq = pv(params.frequency, i);
// Main detuning // Main detuning
freq = applySemitoneDetuneToFrequency(freq, detune / 100); freq = applySemitoneDetuneToFrequency(freq, detune / 100);
const detuner = getDetuner(voices, freqspread);
for (let n = 0; n < voices; n++) { for (let n = 0; n < voices; n++) {
const isOdd = (n & 1) == 1;
let gainL = gain1;
let gainR = gain2;
// invert right and left gain
if (isOdd) {
gainL = gain2;
gainR = gain1;
}
// Individual voice detuning // Individual voice detuning
const freqVoice = applySemitoneDetuneToFrequency(freq, detuner(n)); const freqVoice = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n));
// We must wrap this here because it is passed into sawblep below which // We must wrap this here because it is passed into sawblep below which
// has domain [0, 1] // has domain [0, 1]
const dt = frac(freqVoice * INVSR); const dt = mod(freqVoice / sampleRate, 1);
this.phase[n] = this.phase[n] ?? Math.random(); this.phase[n] = this.phase[n] ?? Math.random();
const v = waveshapes.sawblep(this.phase[n], dt); const v = waveshapes.sawblep(this.phase[n], dt);
output[0][i] += v * gainL; output[0][i] = output[0][i] + v * gainL;
output[1][i] += v * gainR; output[1][i] = output[1][i] + v * gainR;
let pn = this.phase[n] + dt; this.phase[n] = wrapPhase(this.phase[n] + dt);
if (pn >= 1.0) pn -= 1.0;
this.phase[n] = pn;
// invert right and left gain
const tmp = gainL;
gainL = gainR;
gainR = tmp;
} }
} }
return true; return true;
@@ -557,16 +564,12 @@ registerProcessor('supersaw-oscillator', SuperSawOscillatorProcessor);
// Phase Vocoder sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file // Phase Vocoder sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file
const BUFFERED_BLOCK_SIZE = 2048; const BUFFERED_BLOCK_SIZE = 2048;
const hannCache = new Map();
function genHannWindow(length) { function genHannWindow(length) {
if (!hannCache.has(length)) { let win = new Float32Array(length);
const win = new Float32Array(length); for (var i = 0; i < length; i++) {
for (let i = 0; i < length; i++) { win[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / length));
win[i] = 0.5 * (1 - Math.cos((TWO_PI * i) / length));
} }
hannCache.set(length, win); return win;
}
return hannCache.get(length);
} }
class PhaseVocoderProcessor extends OLAProcessor { class PhaseVocoderProcessor extends OLAProcessor {
@@ -584,10 +587,11 @@ class PhaseVocoderProcessor extends OLAProcessor {
blockSize: BUFFERED_BLOCK_SIZE, blockSize: BUFFERED_BLOCK_SIZE,
}; };
super(options); super(options);
this.timeCursor = 0;
this.fftSize = this.blockSize; this.fftSize = this.blockSize;
this.invfftSize = 1 / this.fftSize; this.timeCursor = 0;
this.hannWindow = genHannWindow(this.fftSize);
this.hannWindow = genHannWindow(this.blockSize);
// prepare FFT and pre-allocate buffers // prepare FFT and pre-allocate buffers
this.fft = new FFT(this.fftSize); this.fft = new FFT(this.fftSize);
this.freqComplexBuffer = this.fft.createComplexArray(); this.freqComplexBuffer = this.fft.createComplexArray();
@@ -600,43 +604,52 @@ class PhaseVocoderProcessor extends OLAProcessor {
processOLA(inputs, outputs, parameters) { processOLA(inputs, outputs, parameters) {
// no automation, take last value // no automation, take last value
let pitchFactor = parameters.pitchFactor[parameters.pitchFactor.length - 1]; let pitchFactor = parameters.pitchFactor[parameters.pitchFactor.length - 1];
if (pitchFactor < 0) { if (pitchFactor < 0) {
pitchFactor = pitchFactor * 0.25; pitchFactor = pitchFactor * 0.25;
} }
pitchFactor = Math.max(0, pitchFactor + 1); pitchFactor = Math.max(0, pitchFactor + 1);
for (let i = 0; i < this.nbInputs; i++) {
for (let j = 0; j < inputs[i].length; j++) { for (var i = 0; i < this.nbInputs; i++) {
const input = inputs[i][j]; for (var j = 0; j < inputs[i].length; j++) {
const output = outputs[i][j]; // big assumption here: output is symetric to input
var input = inputs[i][j];
var output = outputs[i][j];
this.applyHannWindow(input); this.applyHannWindow(input);
this.fft.realTransform(this.freqComplexBuffer, input); this.fft.realTransform(this.freqComplexBuffer, input);
this.computeMagnitudes(); this.computeMagnitudes();
this.findPeaks(); this.findPeaks();
this.shiftPeaks(pitchFactor); this.shiftPeaks(pitchFactor);
this.fft.completeSpectrum(this.freqComplexBufferShifted); this.fft.completeSpectrum(this.freqComplexBufferShifted);
this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted); this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted);
this.fft.fromComplexArray(this.timeComplexBuffer, output); this.fft.fromComplexArray(this.timeComplexBuffer, output);
this.applyHannWindow(output); this.applyHannWindow(output);
} }
} }
this.timeCursor += this.hopSize; this.timeCursor += this.hopSize;
} }
/** Apply Hann window in-place */ /** Apply Hann window in-place */
applyHannWindow(input) { applyHannWindow(input) {
for (let i = 0; i < this.blockSize; i++) { for (var i = 0; i < this.blockSize; i++) {
input[i] *= this.hannWindow[i] * 1.62; input[i] = input[i] * this.hannWindow[i] * 1.62;
} }
} }
/** Compute squared magnitudes for peak finding **/ /** Compute squared magnitudes for peak finding **/
computeMagnitudes() { computeMagnitudes() {
let i = 0, var i = 0,
j = 0; j = 0;
while (i < this.magnitudes.length) { while (i < this.magnitudes.length) {
const real = this.freqComplexBuffer[j]; let real = this.freqComplexBuffer[j];
const imag = this.freqComplexBuffer[j + 1]; let imag = this.freqComplexBuffer[j + 1];
// no need to sqrt for peak finding // no need to sqrt for peak finding
this.magnitudes[i] = real ** 2 + imag ** 2; this.magnitudes[i] = real ** 2 + imag ** 2;
i += 1; i += 1;
@@ -647,10 +660,12 @@ class PhaseVocoderProcessor extends OLAProcessor {
/** Find peaks in spectrum magnitudes **/ /** Find peaks in spectrum magnitudes **/
findPeaks() { findPeaks() {
this.nbPeaks = 0; this.nbPeaks = 0;
let i = 2; var i = 2;
const end = this.magnitudes.length - 2; let end = this.magnitudes.length - 2;
while (i < end) { while (i < end) {
const mag = this.magnitudes[i]; let mag = this.magnitudes[i];
if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) { if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) {
i++; i++;
continue; continue;
@@ -659,6 +674,7 @@ class PhaseVocoderProcessor extends OLAProcessor {
i++; i++;
continue; continue;
} }
this.peakIndexes[this.nbPeaks] = i; this.peakIndexes[this.nbPeaks] = i;
this.nbPeaks++; this.nbPeaks++;
i += 2; i += 2;
@@ -669,44 +685,53 @@ class PhaseVocoderProcessor extends OLAProcessor {
shiftPeaks(pitchFactor) { shiftPeaks(pitchFactor) {
// zero-fill new spectrum // zero-fill new spectrum
this.freqComplexBufferShifted.fill(0); this.freqComplexBufferShifted.fill(0);
for (let i = 0; i < this.nbPeaks; i++) {
const peakIndex = this.peakIndexes[i]; for (var i = 0; i < this.nbPeaks; i++) {
const peakIndexShifted = fround(peakIndex * pitchFactor); let peakIndex = this.peakIndexes[i];
let peakIndexShifted = Math.round(peakIndex * pitchFactor);
if (peakIndexShifted > this.magnitudes.length) { if (peakIndexShifted > this.magnitudes.length) {
break; break;
} }
// find region of influence // find region of influence
let startIndex = 0; var startIndex = 0;
let endIndex = this.fftSize; var endIndex = this.fftSize;
if (i > 0) { if (i > 0) {
startIndex = peakIndex - fround((peakIndex - this.peakIndexes[i - 1]) / 2); let peakIndexBefore = this.peakIndexes[i - 1];
startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2);
} }
if (i < this.nbPeaks - 1) { if (i < this.nbPeaks - 1) {
endIndex = peakIndex + fceil((this.peakIndexes[i + 1] - peakIndex) / 2); let peakIndexAfter = this.peakIndexes[i + 1];
endIndex = peakIndex + Math.ceil((peakIndexAfter - peakIndex) / 2);
} }
// shift whole region of influence around peak to shifted peak // shift whole region of influence around peak to shifted peak
const startOffset = startIndex - peakIndex; let startOffset = startIndex - peakIndex;
const endOffset = endIndex - peakIndex; let endOffset = endIndex - peakIndex;
const omegaDelta = TWO_PI * this.invfftSize * (peakIndexShifted - peakIndex); for (var j = startOffset; j < endOffset; j++) {
const phaseShiftReal = Math.cos(omegaDelta * this.timeCursor); let binIndex = peakIndex + j;
const phaseShiftImag = Math.sin(omegaDelta * this.timeCursor); let binIndexShifted = peakIndexShifted + j;
for (let j = startOffset; j < endOffset; j++) {
const binIndex = peakIndex + j;
const binIndexShifted = peakIndexShifted + j;
if (binIndexShifted >= this.magnitudes.length) { if (binIndexShifted >= this.magnitudes.length) {
break; break;
} }
// apply phase correction // apply phase correction
const indexReal = 2 * binIndex; let omegaDelta = (2 * Math.PI * (binIndexShifted - binIndex)) / this.fftSize;
const indexImag = indexReal + 1; let phaseShiftReal = Math.cos(omegaDelta * this.timeCursor);
const valueReal = this.freqComplexBuffer[indexReal]; let phaseShiftImag = Math.sin(omegaDelta * this.timeCursor);
const valueImag = this.freqComplexBuffer[indexImag];
const valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag; let indexReal = binIndex * 2;
const valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal; let indexImag = indexReal + 1;
let valueReal = this.freqComplexBuffer[indexReal];
let valueImag = this.freqComplexBuffer[indexImag];
const indexShiftedReal = 2 * binIndexShifted; let valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag;
const indexShiftedImag = indexShiftedReal + 1; let valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal;
let indexShiftedReal = binIndexShifted * 2;
let indexShiftedImag = indexShiftedReal + 1;
this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal; this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal;
this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag; this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag;
} }
@@ -720,10 +745,11 @@ registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor);
class PulseOscillatorProcessor extends AudioWorkletProcessor { class PulseOscillatorProcessor extends AudioWorkletProcessor {
constructor() { constructor() {
super(); super();
this.phi = -PI; // phase this.pi = _PI;
this.phi = -this.pi; // phase
this.Y0 = 0; // feedback memories this.Y0 = 0; // feedback memories
this.Y1 = 0; this.Y1 = 0;
this.PW = PI; // pulse width this.PW = this.pi; // pulse width
this.B = 2.3; // feedback coefficient this.B = 2.3; // feedback coefficient
this.dphif = 0; // filtered phase increment this.dphif = 0; // filtered phase increment
this.envf = 0; // filtered envelope this.envf = 0; // filtered envelope
@@ -780,11 +806,11 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
dphi; dphi;
for (let i = 0; i < (output[0].length ?? 0); i++) { for (let i = 0; i < (output[0].length ?? 0); i++) {
const pw = (1 - clamp(pv(params.pulsewidth, i), -0.99, 0.99)) * PI; const pw = (1 - clamp(getParamValue(i, params.pulsewidth), -0.99, 0.99)) * this.pi;
const detune = pv(params.detune, i); const detune = getParamValue(i, params.detune);
const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100); const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100);
dphi = freq * TWO_PI * INVSR; // phase increment dphi = freq * (this.pi / (sampleRate * 0.5)); // phase increment
this.dphif += 0.1 * (dphi - this.dphif); this.dphif += 0.1 * (dphi - this.dphif);
env *= 0.9998; // exponential decay envelope env *= 0.9998; // exponential decay envelope
@@ -796,7 +822,7 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
// Waveform generation (half-Tomisawa oscillators) // Waveform generation (half-Tomisawa oscillators)
this.phi += this.dphif; // phase increment this.phi += this.dphif; // phase increment
if (this.phi >= PI) this.phi -= TWO_PI; // phase wrapping if (this.phi >= this.pi) this.phi -= 2 * this.pi; // phase wrapping
// First half-Tomisawa generator // First half-Tomisawa generator
let out0 = Math.cos(this.phi + this.B * this.Y0); // self-phase modulation let out0 = Math.cos(this.phi + this.B * this.Y0); // self-phase modulation
@@ -826,23 +852,24 @@ const chyx = {
/*bit reverse*/ br: function (x, size = 8) { /*bit reverse*/ br: function (x, size = 8) {
if (size > 32) { if (size > 32) {
throw new Error('br() Size cannot be greater than 32'); throw new Error('br() Size cannot be greater than 32');
} } else {
let result = 0; let result = 0;
for (let idx = 0; idx < size; idx++) { for (let idx = 0; idx < size - 0; idx++) {
result |= chyx.bitC(x, 1 << idx, 1 << (size - (idx + 1))); result += chyx.bitC(x, 2 ** idx, 2 ** (size - (idx + 1)));
} }
return result; return result;
}
}, },
/*sin that loops every 128 "steps", instead of every pi steps*/ sinf: function (x) { /*sin that loops every 128 "steps", instead of every pi steps*/ sinf: function (x) {
return Math.sin((x * PI) / 128); return Math.sin(x / (128 / Math.PI));
}, },
/*cos that loops every 128 "steps", instead of every pi steps*/ cosf: function (x) { /*cos that loops every 128 "steps", instead of every pi steps*/ cosf: function (x) {
return Math.cos((x * PI) / 128); return Math.cos(x / (128 / Math.PI));
}, },
/*tan that loops every 128 "steps", instead of every pi steps*/ tanf: function (x) { /*tan that loops every 128 "steps", instead of every pi steps*/ tanf: function (x) {
return Math.tan((x * PI) / 128); return Math.tan(x / (128 / Math.PI));
}, },
/*converts t into a string composed of its bits; regexes that*/ regG: function (t, X) { /*converts t into a string composed of it's bits, regex's that*/ regG: function (t, X) {
return X.test(t.toString(2)); return X.test(t.toString(2));
}, },
}; };
@@ -850,7 +877,7 @@ const chyx = {
// Create shortened Math functions // Create shortened Math functions
let mathParams, byteBeatHelperFuncs; let mathParams, byteBeatHelperFuncs;
function getByteBeatFunc(codetext) { function getByteBeatFunc(codetext) {
if (mathParams == null) { if ((mathParams || byteBeatHelperFuncs) == null) {
mathParams = Object.getOwnPropertyNames(Math); mathParams = Object.getOwnPropertyNames(Math);
byteBeatHelperFuncs = mathParams.map((k) => Math[k]); byteBeatHelperFuncs = mathParams.map((k) => Math[k]);
const chyxNames = Object.getOwnPropertyNames(chyx); const chyxNames = Object.getOwnPropertyNames(chyx);
@@ -883,7 +910,7 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
this.func = getByteBeatFunc(codeText); this.func = getByteBeatFunc(codeText);
}; };
this.initialOffset = 0; this.initialOffset = null;
this.t = null; this.t = null;
this.func = null; this.func = null;
} }
@@ -930,19 +957,18 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
this.t = params.begin[0] * sampleRate; this.t = params.begin[0] * sampleRate;
} }
const output = outputs[0]; const output = outputs[0];
const scale = 256 * INVSR;
for (let i = 0; i < output[0].length; i++) { for (let i = 0; i < output[0].length; i++) {
const detune = pv(params.detune, i); const detune = getParamValue(i, params.detune);
const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100); const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100);
const local_t = scale * freq * this.t + this.initialOffset; let local_t = (this.t / (sampleRate / 256)) * freq + this.initialOffset;
const funcValue = this.func(local_t); const funcValue = this.func(local_t);
const signal = (funcValue & 255) / 127.5 - 1; let signal = (funcValue & 255) / 127.5 - 1;
//prevent speaker blowout via clipping if threshold exceeds const out = signal * 0.2;
const out = clamp(signal * 0.2, -0.4, 0.4);
for (let c = 0; c < output.length; c++) { for (let c = 0; c < output.length; c++) {
output[c][i] = out; //prevent speaker blowout via clipping if threshold exceeds
output[c][i] = clamp(out, -0.4, 0.4);
} }
this.t++; this.t = this.t + 1;
} }
return true; // keep the audio processing going return true; // keep the audio processing going
@@ -951,102 +977,6 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
registerProcessor('byte-beat-processor', ByteBeatProcessor); registerProcessor('byte-beat-processor', ByteBeatProcessor);
class EnvelopeProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'begin', defaultValue: 0 },
{ name: 'end', defaultValue: 0 },
{ name: 'attack', defaultValue: 0.005, minValue: 0 },
{ name: 'decay', defaultValue: 0.14, minValue: 0 },
{ name: 'sustain', defaultValue: 0, minValue: 0, maxValue: 1 },
{ name: 'release', defaultValue: 0.1, minValue: 0 },
{ name: 'attackCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
{ name: 'decayCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
{ name: 'releaseCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
{ name: 'peak', defaultValue: 1 },
{ name: 'retrigger', defaultValue: 1, minValue: 0, maxValue: 1 },
];
}
constructor() {
super();
this.val = 0;
this.segIdx = 0;
this.state = 0;
this.beginTime = 0;
this.endTime = 0;
this.attackStart = 0;
}
_warp(phase, curvature, strength = 8) {
if (phase === 0 || phase === 1) return phase; // fast exit
if (curvature > 0) {
// snappier
const exp = 1 + strength * curvature;
return 1 - Math.pow(1 - phase, exp);
} else {
// more calm
const exp = 1 - strength * curvature;
return Math.pow(phase, exp);
}
}
_advance(start, target, time, curvature) {
if (time === 0 || start === target) {
this.val = target;
} else {
// We compute our progress through this section of the envelope in time
// as a `phase` value, which is warped by the curvature, and then used
// to compute the value of the envelope at that time
const phase = Math.min(1, (currentTime - this.beginTime) / time);
const phaseWarped = this._warp(phase, curvature);
this.val = start + (target - start) * phaseWarped;
}
}
process(_inputs, outputs, params) {
const out = outputs[0][0];
if (!out) return true;
const begin = pv(params.begin, 0);
const retrigger = pv(params.retrigger, 0) >= 0.5; // convert to bool
if (begin !== this.beginTime && (this.state === 0 || retrigger)) {
// triggered
this.beginTime = begin;
this.state = 1;
this.endTime = pv(params.end, 0);
this.attackStart = this.val;
}
const susTime = this.endTime - this.beginTime;
for (let i = 0; i < out.length; i++) {
const attack = pv(params.attack, i);
const decay = pv(params.decay, i);
const sustain = pv(params.sustain, i);
const release = pv(params.release, i);
const aCurve = pv(params.attackCurve, i);
const dCurve = pv(params.decayCurve, i);
const rCurve = pv(params.releaseCurve, i);
const peak = pv(params.peak, i);
const states = [
{ time: Number.POSITIVE_INFINITY, start: 0, target: 0 }, // idle
{ time: attack, start: this.attackStart, target: 1, curve: aCurve },
{ time: attack + decay, start: 1, target: sustain, curve: dCurve },
{ time: susTime, start: sustain, target: sustain },
{ time: susTime + release, start: sustain, target: 0, curve: rCurve },
];
let { time, start, target, curve } = states[this.state];
this._advance(start, target, time, curve);
while (currentTime - this.beginTime >= time) {
this.state = (this.state + 1) % states.length;
time = states[this.state].time;
}
out[i] = this.val * peak;
}
return true;
}
}
registerProcessor('envelope-processor', EnvelopeProcessor);
export const WarpMode = Object.freeze({ export const WarpMode = Object.freeze({
NONE: 0, NONE: 0,
ASYM: 1, ASYM: 1,
@@ -1120,15 +1050,14 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
return [ return [
{ name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, { name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, { name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'frequency', defaultValue: 440, min: Number.EPSILON }, { name: 'frequency', defaultValue: 220, minValue: 0.01, maxValue: 20000 },
{ name: 'detune', defaultValue: 0 }, { name: 'detune', defaultValue: 0.18 },
{ name: 'freqspread', defaultValue: 0.18, min: 0 }, { name: 'position', defaultValue: 0, minValue: 0, maxValue: 1 },
{ name: 'position', defaultValue: 0, min: 0, max: 1 }, { name: 'warp', defaultValue: 0, minValue: 0, maxValue: 1 },
{ name: 'warp', defaultValue: 0, min: 0, max: 1 },
{ name: 'warpMode', defaultValue: 0 }, { name: 'warpMode', defaultValue: 0 },
{ name: 'voices', defaultValue: 1, min: 1, automationRate: 'k-rate' }, { name: 'voices', defaultValue: 1, minValue: 1, maxValue: 32 },
{ name: 'panspread', defaultValue: 0.7, min: 0, max: 1 }, { name: 'spread', defaultValue: 0.7, minValue: 0, maxValue: 1 },
{ name: 'phaserand', defaultValue: 0, min: 0, max: 1 }, { name: 'phaserand', defaultValue: 0, minValue: 0, maxValue: 1 },
]; ];
} }
@@ -1137,6 +1066,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
this.frameLen = 0; this.frameLen = 0;
this.numFrames = 0; this.numFrames = 0;
this.phase = []; this.phase = [];
this.invSR = 1 / sampleRate;
this.port.onmessage = (e) => { this.port.onmessage = (e) => {
const { type, payload } = e.data || {}; const { type, payload } = e.data || {};
@@ -1173,7 +1103,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
_toBits(amt, min = 2, max = 12) { _toBits(amt, min = 2, max = 12) {
const b = max + (min - max) * amt; const b = max + (min - max) * amt;
return { b, n: fround(Math.pow(2, b)) }; return { b, n: Math.round(Math.pow(2, b)) };
} }
_warpPhase(phase, amt, mode) { _warpPhase(phase, amt, mode) {
@@ -1199,7 +1129,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
return amt < 0.5 ? this._warpPhase(phase, 1 - 2 * amt, 3) : this._warpPhase(phase, 2 * amt - 1, 2); return amt < 0.5 ? this._warpPhase(phase, 1 - 2 * amt, 3) : this._warpPhase(phase, 2 * amt - 1, 2);
} }
case WarpMode.SYNC: { case WarpMode.SYNC: {
const syncRatio = Math.pow(16, amt ** 2); const syncRatio = Math.pow(16, amt * amt);
return (phase * syncRatio) % 1; return (phase * syncRatio) % 1;
} }
case WarpMode.QUANT: { case WarpMode.QUANT: {
@@ -1208,8 +1138,8 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
} }
case WarpMode.FOLD: { case WarpMode.FOLD: {
const K = 7; const K = 7;
const k = 1 + Math.max(1, fround(K * amt)); const k = 1 + Math.max(1, Math.round(K * amt));
return Math.abs(ffrac(k * phase) - 0.5) * 2; return Math.abs(frac(k * phase) - 0.5) * 2;
} }
case WarpMode.PWM: { case WarpMode.PWM: {
const w = clamp(0.5 + 0.49 * (2 * amt - 1), 0, 1); const w = clamp(0.5 + 0.49 * (2 * amt - 1), 0, 1);
@@ -1219,12 +1149,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
case WarpMode.ORBIT: { case WarpMode.ORBIT: {
const depth = 0.5 * amt; const depth = 0.5 * amt;
const n = 3; const n = 3;
return frac(phase + depth * Math.sin(TWO_PI * n * phase)); return frac(phase + depth * Math.sin(2 * Math.PI * n * phase));
} }
case WarpMode.SPIN: { case WarpMode.SPIN: {
const depth = 0.5 * amt; const depth = 0.5 * amt;
const { n } = this._toBits(amt, 1, 6); const { n } = this._toBits(amt, 1, 6);
return frac(phase + depth * Math.sin(TWO_PI * n * phase)); return frac(phase + depth * Math.sin(2 * Math.PI * n * phase));
} }
case WarpMode.CHAOS: { case WarpMode.CHAOS: {
const r = 3.7 + 0.3 * amt; const r = 3.7 + 0.3 * amt;
@@ -1235,7 +1165,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
const isPrime = (n) => { const isPrime = (n) => {
if (n < 2) return false; if (n < 2) return false;
if (n % 2 === 0) return n === 2; if (n % 2 === 0) return n === 2;
for (let d = 3; d ** 2 <= n; d += 2) if (n % d === 0) return false; for (let d = 3; d * d <= n; d += 2) if (n % d === 0) return false;
return true; return true;
}; };
let { n } = this._toBits(amt, 3); let { n } = this._toBits(amt, 3);
@@ -1244,12 +1174,18 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
} }
case WarpMode.BINARY: { case WarpMode.BINARY: {
let { b } = this._toBits(amt, 3); let { b } = this._toBits(amt, 3);
b = fround(b); b = Math.round(b);
const n = 1 << b; const n = 1 << b;
const idx = ffloor(phase * n); const idx = ffloor(phase * n);
const ridx = bitReverse(idx, b); const ridx = bitReverse(idx, b);
return ridx / n; return ridx / n;
} }
case WarpMode.MODULAR: {
const { n } = this._toBits(amt);
const depth = 0.5 * amt;
const jump = frac(phase * n) / n;
return frac(phase + depth * jump);
}
case WarpMode.BROWNIAN: { case WarpMode.BROWNIAN: {
const disp = 0.25 * amt * brownian(64 * phase, 4); const disp = 0.25 * amt * brownian(64 * phase, 4);
return frac(phase + disp); return frac(phase + disp);
@@ -1272,7 +1208,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
case WarpMode.LOGISTIC: { case WarpMode.LOGISTIC: {
let x = phase; let x = phase;
const r = 3.6 + 0.4 * amt; const r = 3.6 + 0.4 * amt;
const iters = 1 + fround(2 * amt); const iters = 1 + Math.round(2 * amt);
for (let i = 0; i < iters; i++) x = r * x * (1 - x); for (let i = 0; i < iters; i++) x = r * x * (1 - x);
return clamp(x, 0, 1); return clamp(x, 0, 1);
} }
@@ -1285,7 +1221,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
return (y - y0) / (y1 - y0); return (y - y0) / (y1 - y0);
} }
case WarpMode.FRACTAL: { case WarpMode.FRACTAL: {
const d = 0.5 * Math.sin(TWO_PI * phase) * amt; const d = 0.5 * Math.sin(2 * Math.PI * phase) * amt;
return frac(phase + d); return frac(phase + d);
} }
case WarpMode.FLIP: { case WarpMode.FLIP: {
@@ -1299,12 +1235,10 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
_sampleFrame(frame, phase) { _sampleFrame(frame, phase) {
const len = frame.length; const len = frame.length;
const pos = phase * len; const pos = phase * len;
let i = pos | 0; const i = pos | 0;
if (i >= len) i = 0; // fast wrap
const frac = pos - i; const frac = pos - i;
const a = frame[i]; const a = frame[i];
let i1 = i + 1; const i1 = i + 1 < len ? i + 1 : 0; // fast wrap
if (i1 >= len) i1 = 0;
const b = frame[i1]; const b = frame[i1];
return a + (b - a) * frac; return a + (b - a) * frac;
} }
@@ -1332,24 +1266,22 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
if (outR !== outL) outR.set(outL); if (outR !== outL) outR.set(outL);
return true; return true;
} }
const voices = parameters.voices[0]; // k-rate
for (let i = 0; i < outL.length; i++) { for (let i = 0; i < outL.length; i++) {
const detune = pv(parameters.detune, i); const detune = pv(parameters.detune, i);
const freqspread = pv(parameters.freqspread, i);
const tablePos = clamp(pv(parameters.position, i), 0, 1); const tablePos = clamp(pv(parameters.position, i), 0, 1);
const idx = tablePos * (this.numFrames - 1); const idx = tablePos * (this.numFrames - 1);
const fIdx = idx | 0; const fIdx = idx | 0;
const interpT = idx - fIdx; const frac = idx - fIdx;
const warpAmount = clamp(pv(parameters.warp, i), 0, 1); const warpAmount = clamp(pv(parameters.warp, i), 0, 1);
const warpMode = pv(parameters.warpMode, i); const warpMode = pv(parameters.warpMode, i);
const voices = pv(parameters.voices, i);
const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1); const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1);
const panspread = voices > 1 ? clamp(pv(parameters.panspread, i), 0, 1) : 0; const spread = voices > 1 ? clamp(pv(parameters.spread, i), 0, 1) : 0;
const gain1 = Math.sqrt(0.5 - 0.5 * panspread); const gain1 = Math.sqrt(0.5 - 0.5 * spread);
const gain2 = Math.sqrt(0.5 + 0.5 * panspread); const gain2 = Math.sqrt(0.5 + 0.5 * spread);
let f = pv(parameters.frequency, i); let f = pv(parameters.frequency, i);
f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune
const normalizer = 1 / Math.sqrt(voices); const normalizer = 1 / Math.sqrt(voices);
const detuner = getDetuner(voices, freqspread);
for (let n = 0; n < voices; n++) { for (let n = 0; n < voices; n++) {
const isOdd = (n & 1) == 1; const isOdd = (n & 1) == 1;
let gainL = gain1; let gainL = gain1;
@@ -1359,8 +1291,8 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
gainL = gain2; gainL = gain2;
gainR = gain1; gainR = gain1;
} }
const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, detune, n)); // voice detune
const dPhase = fVoice * INVSR; const dPhase = fVoice * this.invSR;
const level = this._chooseMip(dPhase); const level = this._chooseMip(dPhase);
const table = this.tables[level]; const table = this.tables[level];
@@ -1369,13 +1301,13 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
const ph = this._warpPhase(this.phase[n], warpAmount, warpMode); const ph = this._warpPhase(this.phase[n], warpAmount, warpMode);
const s0 = this._sampleFrame(table[fIdx], ph); const s0 = this._sampleFrame(table[fIdx], ph);
const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph); const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
let s = lerp(s0, s1, interpT); let s = s0 + (s1 - s0) * frac;
if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) { if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
s = -s; s = -s;
} }
outL[i] += s * gainL * normalizer; outL[i] += s * gainL * normalizer;
outR[i] += s * gainR * normalizer; outR[i] += s * gainR * normalizer;
this.phase[n] = frac(this.phase[n] + dPhase); this.phase[n] = wrapPhase(this.phase[n] + dPhase);
} }
} }
return true; return true;
+2 -4
View File
@@ -1,12 +1,11 @@
// this is dough, the superdough without dependencies // this is dough, the superdough without dependencies
// @ts-nocheck // @ts-check
// @ts-ignore ignore next line because sampleRate is unknown // @ts-ignore ignore next line because sampleRate is unknown
const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000; const SAMPLE_RATE = typeof sampleRate !== 'undefined' ? sampleRate : 48000;
const PI_DIV_SR = Math.PI / SAMPLE_RATE; const PI_DIV_SR = Math.PI / SAMPLE_RATE;
const ISR = 1 / SAMPLE_RATE; const ISR = 1 / SAMPLE_RATE;
let gainCurveFunc = (val) => Math.pow(val, 2); let gainCurveFunc = (val) => Math.pow(val, 2);
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
function applyGainCurve(val) { function applyGainCurve(val) {
return gainCurveFunc(val); return gainCurveFunc(val);
@@ -152,8 +151,7 @@ export class TwoPoleFilter {
resonance = Math.max(resonance, 0); resonance = Math.max(resonance, 0);
cutoff = Math.min(cutoff, 20000); cutoff = Math.min(cutoff, 20000);
let c = 2 * Math.sin(cutoff * PI_DIV_SR); const c = 2 * Math.sin(cutoff * PI_DIV_SR);
c = clamp(c, 0, 1.14); // this line prevents instability TODO: test
const r = Math.pow(0.5, (resonance + 0.125) / 0.125); const r = Math.pow(0.5, (resonance + 0.125) / 0.125);
const mrc = 1 - r * c; const mrc = 1 - r * c;
+5 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "supradough", "name": "supradough",
"version": "1.2.4", "version": "1.2.3",
"description": "platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.", "description": "platform agnostic synth and sampler intended for live coding. a reimplementation of superdough.",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -13,7 +13,7 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://codeberg.org/uzu/strudel.git" "url": "git+https://github.com/tidalcycles/strudel.git"
}, },
"keywords": [ "keywords": [
"tidalcycles", "tidalcycles",
@@ -25,15 +25,13 @@
"author": "Felix Roos <flix91@gmail.com>", "author": "Felix Roos <flix91@gmail.com>",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"bugs": { "bugs": {
"url": "https://codeberg.org/uzu/strudel/issues" "url": "https://github.com/tidalcycles/strudel/issues"
}, },
"homepage": "https://codeberg.org/uzu/strudel#readme", "homepage": "https://github.com/tidalcycles/strudel#readme",
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vite-plugin-bundle-audioworklet": "workspace:*", "vite-plugin-bundle-audioworklet": "workspace:*",
"wav-encoder": "^1.3.0" "wav-encoder": "^1.3.0"
}, },
"engines": { "dependencies": {}
"node": ">=18.0.0"
}
} }
-16
View File
@@ -1,16 +0,0 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [bundleAudioWorkletPlugin()],
build: {
lib: {
entry: resolve(__dirname, 'index.mjs'),
formats: ['es'],
fileName: (ext) => ({ es: 'index.mjs' })[ext],
},
target: 'esnext',
},
});
-3
View File
@@ -25,8 +25,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+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 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/tonal", "name": "@strudel/tonal",
"version": "1.2.5", "version": "1.2.4",
"description": "Tonal functions for strudel", "description": "Tonal functions for strudel",
"main": "index.mjs", "main": "index.mjs",
"publishConfig": { "publishConfig": {
@@ -38,8 +38,5 @@
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+7
View File
@@ -61,6 +61,13 @@ describe('tonal', () => {
.firstCycleValues.map((h) => h.note), .firstCycleValues.map((h) => h.note),
).toEqual(['B2', 'Eb3', 'A2', 'G3', 'F3']); ).toEqual(['B2', 'Eb3', 'A2', 'G3', 'F3']);
}); });
it('produces silence for mixed sharps and flats', () => {
expect(
n(seq('0b#', '1#b', '2#b#'))
.scale('C major')
.firstCycleValues.map((h) => h.note),
).toEqual([]);
});
it('snaps notes (upwards) to scale', () => { it('snaps notes (upwards) to scale', () => {
const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb']; const inputNotes = ['Cb', 'Eb', 'G', 'A#', 'Bb'];
const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3']; const expectedNotes = ['B2', 'E3', 'G3', 'B3', 'B3'];
+41 -54
View File
@@ -5,17 +5,9 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import { Note, Interval, Scale } from '@tonaljs/tonal'; import { Note, Interval, Scale } from '@tonaljs/tonal';
import { import { register, _mod, silence, logger, pure, isNote } from '@strudel/core';
_mod,
errorLogger,
getAccidentalsOffset,
isNote,
logger,
noteToMidi,
register,
removeUndefineds,
} from '@strudel/core';
import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs'; import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs';
import { noteToMidi } from '../core/util.mjs';
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P'; const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
@@ -193,15 +185,17 @@ function _convertStepToNumberAndOffset(step) {
step = String(step); step = String(step);
// Check to see if the step matches the expected format: // Check to see if the step matches the expected format:
// - A number (possibly negative) // - A number (possibly negative)
// - Some number of sharps or flats // - Some number of sharps or flats (but not both)
const match = /^(-?\d+)([#bsf]*)$/.exec(step); const match = /^(-?\d+)(#+|b+)?$/.exec(step);
if (!match) { if (!match) {
throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`); throw new Error(`invalid scale step "${step}", expected number or integer with optional # b suffixes`);
} }
asNumber = Number(match[1]); asNumber = Number(match[1]);
const accidentals = match[2] || ''; // These decorations will determine the semitone offset based on the number of
offset = getAccidentalsOffset(accidentals); // sharps or flats
const decorations = match[2] || '';
offset = decorations[0] === '#' ? decorations.length : -decorations.length;
} }
return [asNumber, offset]; return [asNumber, offset];
} }
@@ -232,15 +226,12 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
* Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale. * Turns numbers into notes in the scale (zero indexed) or quantizes notes to a scale.
* *
* When describing notes via numbers, note that negative numbers can be used to wrap backwards * When describing notes via numbers, note that negative numbers can be used to wrap backwards
* in the scale as well as sharps or flats to produce notes outside of the scale. * in the scale as well as sharps or flats (but not both) to produce notes outside of the scale.
* *
* Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}. * Also sets scale for other scale operations, like {@link Pattern#scaleTranspose}.
* *
* A scale consists of a root note (e.g. `c4`, `c`, `f#`, `bb4`) followed by semicolon (':') and then a [scale type](https://github.com/tonaljs/tonal/blob/main/packages/scale-type/data.ts). * A scale consists of a root note (e.g. `c4`, `c`, `f#`, `bb4`) followed by semicolon (':') and then a [scale type](https://github.com/tonaljs/tonal/blob/main/packages/scale-type/data.ts).
* *
* The scale name must be written without spaces (because it would be interpreted as a multi-step pattern otherwise).
* If your scale name includes spaces, replace them with colons.
*
* The root note defaults to octave 3, if no octave number is given. * The root note defaults to octave 3, if no octave number is given.
* *
* @name scale * @name scale
@@ -262,9 +253,8 @@ function _getNearestScaleNote(scaleName, note, preferHigher = true) {
* .s("piano") * .s("piano")
* @example * @example
* note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3) * note("C1*16").transpose(irand(36)).scale('Cb2 major').scaleTranspose(3)
* @example
* n("[0 0] [1 2] [3 4] [5 6]").scale("C:major:blues")
*/ */
export const scale = register( export const scale = register(
'scale', 'scale',
function (scale, pat) { function (scale, pat) {
@@ -272,47 +262,44 @@ export const scale = register(
if (Array.isArray(scale)) { if (Array.isArray(scale)) {
scale = scale.flat().join(' '); scale = scale.flat().join(' ');
} }
return pat.withHaps((haps) => { return (
haps = haps.map((hap) => { pat
let hVal = hap.value; .fmap((value) => {
const isObject = typeof hVal === 'object'; const isObject = typeof value === 'object';
// If hVal is a pure value, place it on `n` so that we interpret it as a scale degree // The case where the note has been defined via `n` or `pure`
hVal = isObject ? hVal : { n: hVal }; if (!isObject || (isObject && ('n' in value || 'value' in value))) {
const { note, n, value, ...otherValues } = hVal; const step = isObject ? (value.n ?? value.value) : value;
const noteOrStep = note ?? n ?? value; delete value.n; // remove n so it won't cause trouble
if (noteOrStep === undefined) { if (isNote(step)) {
logger( // legacy..
`[tonal] Invalid value format for 'scale'. Value must contain n, note, or value but received keys [${Object.keys(hVal).join(', ')}]`, return pure(step);
'error',
);
return hap; // pass the value through unchanged
} }
let scaleNote;
if (isNote(noteOrStep)) {
// Note case (quantize to scale)
scaleNote = _getNearestScaleNote(scale, noteOrStep);
hap.value = { ...otherValues, note: scaleNote };
} else {
// Step case (convert to note in scale)
try { try {
const [number, offset] = _convertStepToNumberAndOffset(noteOrStep); const [number, offset] = _convertStepToNumberAndOffset(step);
if (otherValues.anchor) { let note;
scaleNote = stepInNamedScale(number, scale, otherValues.anchor); if (isObject && value.anchor) {
note = stepInNamedScale(number, scale, value.anchor);
} else { } else {
scaleNote = scaleStep(number, scale); note = scaleStep(number, scale);
} }
if (offset != 0) scaleNote = Note.transpose(scaleNote, Interval.fromSemitones(offset)); if (offset != 0) note = Note.transpose(note, Interval.fromSemitones(offset));
value = pure(isObject ? { ...value, note } : note);
} catch (err) { } catch (err) {
errorLogger(err, 'tonal'); logger(`[tonal] ${err.message}`, 'error');
return; // will be removed return silence;
} }
return value;
} }
hap.value = isObject ? { ...otherValues, note: scaleNote } : scaleNote; // The case where the note has been defined via `note`
// Tag with scale for downsteam scale-aware operations else {
return hap.setContext({ ...hap.context, scale }); const note = _getNearestScaleNote(scale, value.note);
}); return pure(isObject ? { ...value, note } : note);
return removeUndefineds(haps); }
}); })
.outerJoin()
// legacy:
.withHap((hap) => hap.setContext({ ...hap.context, scale }))
);
}, },
true, true,
true, // preserve step count true, // preserve step count
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/transpiler", "name": "@strudel/transpiler",
"version": "1.2.5", "version": "1.2.4",
"description": "Transpiler for strudel user code. Converts syntactically correct but semantically meaningless JS into evaluatable strudel code.", "description": "Transpiler for strudel user code. Converts syntactically correct but semantically meaningless JS into evaluatable strudel code.",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -39,8 +39,5 @@
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
@@ -12,8 +12,5 @@
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -1
View File
@@ -62,7 +62,7 @@ initStrudel();
document.getElementById('play').addEventListener('click', document.getElementById('play').addEventListener('click',
() => evaluate('note("c a f e").jux(rev)') () => evaluate('note("c a f e").jux(rev)')
); );
document.getElementById('stop').addEventListener('click', document.getElementById('play').addEventListener('stop',
() => hush() () => hush()
); );
``` ```
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/web", "name": "@strudel/web",
"version": "1.2.6", "version": "1.2.5",
"description": "Easy to setup, opiniated bundle of Strudel for the browser.", "description": "Easy to setup, opiniated bundle of Strudel for the browser.",
"module": "web.mjs", "module": "web.mjs",
"publishConfig": { "publishConfig": {
@@ -43,8 +43,5 @@
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.2",
"vite": "^6.0.11", "vite": "^6.0.11",
"vite-plugin-bundle-audioworklet": "workspace:*" "vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/webaudio", "name": "@strudel/webaudio",
"version": "1.2.6", "version": "1.2.5",
"description": "Web Audio helpers for Strudel", "description": "Web Audio helpers for Strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -40,8 +40,5 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^6.0.11" "vite": "^6.0.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@strudel/xen", "name": "@strudel/xen",
"version": "1.2.5", "version": "1.2.4",
"description": "Xenharmonic API for strudel", "description": "Xenharmonic API for strudel",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -35,8 +35,5 @@
"devDependencies": { "devDependencies": {
"vite": "^6.0.11", "vite": "^6.0.11",
"vitest": "^3.0.4" "vitest": "^3.0.4"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+1
View File
@@ -41,6 +41,7 @@ Tune.prototype.tonicize = function(newTonic) {
this.tonic = newTonic this.tonic = newTonic
} }
/* Return data in the mode you are in (freq, ratio, or midi) */ /* Return data in the mode you are in (freq, ratio, or midi) */
Tune.prototype.note = function(input,octave){ Tune.prototype.note = function(input,octave){
+8 -246
View File
@@ -41,7 +41,7 @@ importers:
version: 2.2.7 version: 2.2.7
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: 3.0.4 specifier: 3.0.4
version: 3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)) version: 3.0.4(vitest@3.0.4)
'@vitest/ui': '@vitest/ui':
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(vitest@3.0.4) version: 3.0.4(vitest@3.0.4)
@@ -212,21 +212,12 @@ importers:
'@strudel/draw': '@strudel/draw':
specifier: workspace:* specifier: workspace:*
version: link:../draw version: link:../draw
'@strudel/tonal':
specifier: workspace:*
version: link:../tonal
'@strudel/transpiler': '@strudel/transpiler':
specifier: workspace:* specifier: workspace:*
version: link:../transpiler version: link:../transpiler
'@tonaljs/tonal':
specifier: ^4.10.0
version: 4.10.0
nanostores: nanostores:
specifier: ^0.11.3 specifier: ^0.11.3
version: 0.11.3 version: 0.11.3
superdough:
specifier: workspace:*
version: link:../superdough
devDependencies: devDependencies:
vite: vite:
specifier: ^6.0.11 specifier: ^6.0.11
@@ -415,12 +406,9 @@ importers:
'@strudel/core': '@strudel/core':
specifier: workspace:* specifier: workspace:*
version: link:../core version: link:../core
osc: osc-js:
specifier: ^2.4.5 specifier: ^2.4.1
version: 2.4.5 version: 2.4.1
ws:
specifier: ^8.18.3
version: 8.18.3
devDependencies: devDependencies:
pkg: pkg:
specifier: ^5.8.1 specifier: ^5.8.1
@@ -2432,70 +2420,6 @@ packages:
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@serialport/binding-mock@10.2.2':
resolution: {integrity: sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==}
engines: {node: '>=12.0.0'}
'@serialport/bindings-cpp@12.0.1':
resolution: {integrity: sha512-r2XOwY2dDvbW7dKqSPIk2gzsr6M6Qpe9+/Ngs94fNaNlcTRCV02PfaoDmRgcubpNVVcLATlxSxPTIDw12dbKOg==}
engines: {node: '>=16.0.0'}
'@serialport/bindings-interface@1.2.2':
resolution: {integrity: sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==}
engines: {node: ^12.22 || ^14.13 || >=16}
'@serialport/parser-byte-length@12.0.0':
resolution: {integrity: sha512-0ei0txFAj+s6FTiCJFBJ1T2hpKkX8Md0Pu6dqMrYoirjPskDLJRgZGLqoy3/lnU1bkvHpnJO+9oJ3PB9v8rNlg==}
engines: {node: '>=12.0.0'}
'@serialport/parser-cctalk@12.0.0':
resolution: {integrity: sha512-0PfLzO9t2X5ufKuBO34DQKLXrCCqS9xz2D0pfuaLNeTkyGUBv426zxoMf3rsMRodDOZNbFblu3Ae84MOQXjnZw==}
engines: {node: '>=12.0.0'}
'@serialport/parser-delimiter@11.0.0':
resolution: {integrity: sha512-aZLJhlRTjSmEwllLG7S4J8s8ctRAS0cbvCpO87smLvl3e4BgzbVgF6Z6zaJd3Aji2uSiYgfedCdNc4L6W+1E2g==}
engines: {node: '>=12.0.0'}
'@serialport/parser-delimiter@12.0.0':
resolution: {integrity: sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==}
engines: {node: '>=12.0.0'}
'@serialport/parser-inter-byte-timeout@12.0.0':
resolution: {integrity: sha512-GnCh8K0NAESfhCuXAt+FfBRz1Cf9CzIgXfp7SdMgXwrtuUnCC/yuRTUFWRvuzhYKoAo1TL0hhUo77SFHUH1T/w==}
engines: {node: '>=12.0.0'}
'@serialport/parser-packet-length@12.0.0':
resolution: {integrity: sha512-p1hiCRqvGHHLCN/8ZiPUY/G0zrxd7gtZs251n+cfNTn+87rwcdUeu9Dps3Aadx30/sOGGFL6brIRGK4l/t7MuQ==}
engines: {node: '>=8.6.0'}
'@serialport/parser-readline@11.0.0':
resolution: {integrity: sha512-rRAivhRkT3YO28WjmmG4FQX6L+KMb5/ikhyylRfzWPw0nSXy97+u07peS9CbHqaNvJkMhH1locp2H36aGMOEIA==}
engines: {node: '>=12.0.0'}
'@serialport/parser-readline@12.0.0':
resolution: {integrity: sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==}
engines: {node: '>=12.0.0'}
'@serialport/parser-ready@12.0.0':
resolution: {integrity: sha512-ygDwj3O4SDpZlbrRUraoXIoIqb8sM7aMKryGjYTIF0JRnKeB1ys8+wIp0RFMdFbO62YriUDextHB5Um5cKFSWg==}
engines: {node: '>=12.0.0'}
'@serialport/parser-regex@12.0.0':
resolution: {integrity: sha512-dCAVh4P/pZrLcPv9NJ2mvPRBg64L5jXuiRxIlyxxdZGH4WubwXVXY/kBTihQmiAMPxbT3yshSX8f2+feqWsxqA==}
engines: {node: '>=12.0.0'}
'@serialport/parser-slip-encoder@12.0.0':
resolution: {integrity: sha512-0APxDGR9YvJXTRfY+uRGhzOhTpU5akSH183RUcwzN7QXh8/1jwFsFLCu0grmAUfi+fItCkR+Xr1TcNJLR13VNA==}
engines: {node: '>=12.0.0'}
'@serialport/parser-spacepacket@12.0.0':
resolution: {integrity: sha512-dozONxhPC/78pntuxpz/NOtVps8qIc/UZzdc/LuPvVsqCoJXiRxOg6ZtCP/W58iibJDKPZPAWPGYeZt9DJxI+Q==}
engines: {node: '>=12.0.0'}
'@serialport/stream@12.0.0':
resolution: {integrity: sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==}
engines: {node: '>=12.0.0'}
'@shikijs/core@1.29.1': '@shikijs/core@1.29.1':
resolution: {integrity: sha512-Mo1gGGkuOYjDu5H8YwzmOuly9vNr8KDVkqj9xiKhhhFS8jisAtDSEWB9hzqRHLVQgFdA310e8XRJcW4tYhRB2A==} resolution: {integrity: sha512-Mo1gGGkuOYjDu5H8YwzmOuly9vNr8KDVkqj9xiKhhhFS8jisAtDSEWB9hzqRHLVQgFdA310e8XRJcW4tYhRB2A==}
@@ -3748,15 +3672,6 @@ packages:
supports-color: supports-color:
optional: true optional: true
debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.0: debug@4.4.0:
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -5366,9 +5281,6 @@ packages:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'} engines: {node: '>=10'}
long@4.0.0:
resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==}
longest-streak@3.1.0: longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -5752,9 +5664,6 @@ packages:
ms@2.0.0: ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -5813,9 +5722,6 @@ packages:
resolution: {integrity: sha512-z8iYzQGBu35ZkTQ9mtR8RqugJZ9RCLn8fv3d7LsgDBzOijGQP3RdKTX4LA7LXw03ZhU5z0l4xfhIMgSES31+cg==} resolution: {integrity: sha512-z8iYzQGBu35ZkTQ9mtR8RqugJZ9RCLn8fv3d7LsgDBzOijGQP3RdKTX4LA7LXw03ZhU5z0l4xfhIMgSES31+cg==}
engines: {node: '>=10'} engines: {node: '>=10'}
node-addon-api@7.0.0:
resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==}
node-addon-api@8.3.0: node-addon-api@8.3.0:
resolution: {integrity: sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==} resolution: {integrity: sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==}
engines: {node: ^18 || ^20 || >= 21} engines: {node: ^18 || ^20 || >= 21}
@@ -5854,10 +5760,6 @@ packages:
resolution: {integrity: sha512-yqkmYrMbK1wPrfz7mgeYvA4tBperLg9FQ4S3Sau3nSAkpOA0x0zC8nQ1siBwozy1f4SE8vq2n1WKv99r+PCa1Q==} resolution: {integrity: sha512-yqkmYrMbK1wPrfz7mgeYvA4tBperLg9FQ4S3Sau3nSAkpOA0x0zC8nQ1siBwozy1f4SE8vq2n1WKv99r+PCa1Q==}
engines: {node: '>= 0.6.0'} engines: {node: '>= 0.6.0'}
node-gyp-build@4.6.0:
resolution: {integrity: sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==}
hasBin: true
node-gyp-build@4.8.4: node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true hasBin: true
@@ -6016,8 +5918,8 @@ packages:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
osc@2.4.5: osc-js@2.4.1:
resolution: {integrity: sha512-Nc4/qcl+vA/CMxiKS1xrYgzjfnyB3W94gZnrkn3eTzihlndbEml6+wj1YQNKBI4r+qrw3obCcEoU7SVH/OxpxA==} resolution: {integrity: sha512-QlSeRKJclL47FNvO1MUCAAp9frmCF9zcYbnf6R9HpcklAst8ZyX3ISsk1v/Vghr/5GmXn0bhVjFXF9h+hfnl4Q==}
own-keys@1.0.1: own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
@@ -6778,10 +6680,6 @@ packages:
serialize-javascript@4.0.0: serialize-javascript@4.0.0:
resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==} resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==}
serialport@12.0.0:
resolution: {integrity: sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==}
engines: {node: '>=16.0.0'}
set-blocking@2.0.0: set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
@@ -6869,9 +6767,6 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
slip@1.0.2:
resolution: {integrity: sha512-XrcHe3NAcyD3wO+O4I13RcS4/3AF+S9RvGNj9JhJeS02HyImwD2E3QWLrmn9hBfL+fB6yapagwxRkeyYzhk98g==}
smart-buffer@4.2.0: smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
@@ -7747,9 +7642,6 @@ packages:
resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==}
engines: {node: '>=18'} engines: {node: '>=18'}
wolfy87-eventemitter@5.2.9:
resolution: {integrity: sha512-P+6vtWyuDw+MB01X7UeF8TaHBvbCovf4HPEMF/SV7BdDc1SMTiBy13SRD71lQh4ExFTG1d/WNzDGDCyOKSMblw==}
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -7872,18 +7764,6 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xmlcreate@2.0.4: xmlcreate@2.0.4:
resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==} resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==}
@@ -9880,76 +9760,6 @@ snapshots:
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
'@serialport/binding-mock@10.2.2':
dependencies:
'@serialport/bindings-interface': 1.2.2
debug: 4.4.0
transitivePeerDependencies:
- supports-color
optional: true
'@serialport/bindings-cpp@12.0.1':
dependencies:
'@serialport/bindings-interface': 1.2.2
'@serialport/parser-readline': 11.0.0
debug: 4.3.4
node-addon-api: 7.0.0
node-gyp-build: 4.6.0
transitivePeerDependencies:
- supports-color
optional: true
'@serialport/bindings-interface@1.2.2':
optional: true
'@serialport/parser-byte-length@12.0.0':
optional: true
'@serialport/parser-cctalk@12.0.0':
optional: true
'@serialport/parser-delimiter@11.0.0':
optional: true
'@serialport/parser-delimiter@12.0.0':
optional: true
'@serialport/parser-inter-byte-timeout@12.0.0':
optional: true
'@serialport/parser-packet-length@12.0.0':
optional: true
'@serialport/parser-readline@11.0.0':
dependencies:
'@serialport/parser-delimiter': 11.0.0
optional: true
'@serialport/parser-readline@12.0.0':
dependencies:
'@serialport/parser-delimiter': 12.0.0
optional: true
'@serialport/parser-ready@12.0.0':
optional: true
'@serialport/parser-regex@12.0.0':
optional: true
'@serialport/parser-slip-encoder@12.0.0':
optional: true
'@serialport/parser-spacepacket@12.0.0':
optional: true
'@serialport/stream@12.0.0':
dependencies:
'@serialport/bindings-interface': 1.2.2
debug: 4.3.4
transitivePeerDependencies:
- supports-color
optional: true
'@shikijs/core@1.29.1': '@shikijs/core@1.29.1':
dependencies: dependencies:
'@shikijs/engine-javascript': 1.29.1 '@shikijs/engine-javascript': 1.29.1
@@ -10554,7 +10364,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/coverage-v8@3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0))': '@vitest/coverage-v8@3.0.4(vitest@3.0.4)':
dependencies: dependencies:
'@ampproject/remapping': 2.3.0 '@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2 '@bcoe/v8-coverage': 1.0.2
@@ -11462,11 +11272,6 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
debug@4.3.4:
dependencies:
ms: 2.1.2
optional: true
debug@4.4.0: debug@4.4.0:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -13336,8 +13141,6 @@ snapshots:
chalk: 4.1.2 chalk: 4.1.2
is-unicode-supported: 0.1.0 is-unicode-supported: 0.1.0
long@4.0.0: {}
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
loupe@3.1.2: {} loupe@3.1.2: {}
@@ -14015,9 +13818,6 @@ snapshots:
ms@2.0.0: {} ms@2.0.0: {}
ms@2.1.2:
optional: true
ms@2.1.3: {} ms@2.1.3: {}
multimatch@5.0.0: multimatch@5.0.0:
@@ -14067,9 +13867,6 @@ snapshots:
dependencies: dependencies:
semver: 7.6.3 semver: 7.6.3
node-addon-api@7.0.0:
optional: true
node-addon-api@8.3.0: {} node-addon-api@8.3.0: {}
node-domexception@1.0.0: {} node-domexception@1.0.0: {}
@@ -14096,9 +13893,6 @@ snapshots:
node-getopt@0.3.2: {} node-getopt@0.3.2: {}
node-gyp-build@4.6.0:
optional: true
node-gyp-build@4.8.4: {} node-gyp-build@4.8.4: {}
node-gyp@10.3.1: node-gyp@10.3.1:
@@ -14350,17 +14144,11 @@ snapshots:
os-tmpdir@1.0.2: {} os-tmpdir@1.0.2: {}
osc@2.4.5: osc-js@2.4.1:
dependencies: dependencies:
long: 4.0.0
slip: 1.0.2
wolfy87-eventemitter: 5.2.9
ws: 8.18.0 ws: 8.18.0
optionalDependencies:
serialport: 12.0.0
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- supports-color
- utf-8-validate - utf-8-validate
own-keys@1.0.1: own-keys@1.0.1:
@@ -15255,26 +15043,6 @@ snapshots:
dependencies: dependencies:
randombytes: 2.1.0 randombytes: 2.1.0
serialport@12.0.0:
dependencies:
'@serialport/binding-mock': 10.2.2
'@serialport/bindings-cpp': 12.0.1
'@serialport/parser-byte-length': 12.0.0
'@serialport/parser-cctalk': 12.0.0
'@serialport/parser-delimiter': 12.0.0
'@serialport/parser-inter-byte-timeout': 12.0.0
'@serialport/parser-packet-length': 12.0.0
'@serialport/parser-readline': 12.0.0
'@serialport/parser-ready': 12.0.0
'@serialport/parser-regex': 12.0.0
'@serialport/parser-slip-encoder': 12.0.0
'@serialport/parser-spacepacket': 12.0.0
'@serialport/stream': 12.0.0
debug: 4.3.4
transitivePeerDependencies:
- supports-color
optional: true
set-blocking@2.0.0: {} set-blocking@2.0.0: {}
set-function-length@1.2.2: set-function-length@1.2.2:
@@ -15417,8 +15185,6 @@ snapshots:
slash@3.0.0: {} slash@3.0.0: {}
slip@1.0.2: {}
smart-buffer@4.2.0: {} smart-buffer@4.2.0: {}
socks-proxy-agent@8.0.5: socks-proxy-agent@8.0.5:
@@ -16328,8 +16094,6 @@ snapshots:
dependencies: dependencies:
string-width: 7.2.0 string-width: 7.2.0
wolfy87-eventemitter@5.2.9: {}
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
wordwrap@0.0.3: {} wordwrap@0.0.3: {}
@@ -16529,8 +16293,6 @@ snapshots:
ws@8.18.0: {} ws@8.18.0: {}
ws@8.18.3: {}
xmlcreate@2.0.4: {} xmlcreate@2.0.4: {}
xtend@4.0.2: {} xtend@4.0.2: {}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -15
View File
@@ -4,25 +4,11 @@ import { describe, it } from 'vitest';
const tuneKeys = Object.keys(tunes); const tuneKeys = Object.keys(tunes);
// Node 24 tightened Number→string rounding; clamp decimals so snapshots stay stable across engines.
const roundFloatStrings = (input, precision = 12) => {
// if matches a decimal number ex: 12.34, -0.5, 0.123, 99.0, 1.932093850293
const regex = /-?\d+\.\d+/g;
return input.replace(regex, (match) => {
// converts the literal to a number, performs round to nearest (ties to even)
// at the requested precision, and returns the rounded decimal string
const rounded = Number(match).toFixed(precision);
// trims trailing zeros (and a dangling dot) after rounding, so the displayed string looks tidy
return rounded.replace(/\.?0+$/, '').replace(/\.$/, '');
});
};
describe('renders tunes', () => { describe('renders tunes', () => {
tuneKeys.forEach((key) => { tuneKeys.forEach((key) => {
it(`tune: ${key}`, async ({ expect }) => { it(`tune: ${key}`, async ({ expect }) => {
const haps = await queryCode(tunes[key], testCycles[key] || 1); const haps = await queryCode(tunes[key], testCycles[key] || 1);
const normalized = haps.map((hap) => roundFloatStrings(hap)); expect(haps).toMatchSnapshot();
expect(normalized).toMatchSnapshot();
}); });
}); });
}); });
-4
View File
@@ -1,9 +1,5 @@
{ {
"name": "dbpatch",
"dependencies": { "dependencies": {
"csv": "^6.3.11" "csv": "^6.3.11"
},
"engines": {
"node": ">=18.0.0"
} }
} }
-14
View File
@@ -1,14 +0,0 @@
fetch('https://codeberg.org/api/v1/repos/uzu/strudel/pulls?state=closed&page=1')
.then((res) => res.json())
.then((pulls) => {
const r = pulls
.filter((pull) => pull.merged)
.sort((a, b) => new Date(b.closed_at) - new Date(a.closed_at))
.map((pull) => `${pull.closed_at} ${pull.title} by ${pull.user.login || '?'} in: [#${pull.number}](${pull.url}) `)
.join('\n');
console.log(r);
});
/*
*/
-3
View File
@@ -75,8 +75,5 @@
"sharp": "^0.33.5", "sharp": "^0.33.5",
"workbox-window": "^7.3.0", "workbox-window": "^7.3.0",
"vite-plugin-bundle-audioworklet": "workspace:*" "vite-plugin-bundle-audioworklet": "workspace:*"
},
"engines": {
"node": ">=18.0.0"
} }
} }
+155
View File
@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 12.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 51448) -->
<svg
version="1.0"
id="Layer_1"
width="284.46"
height="284.46"
viewBox="0 0 284.46 284.46"
overflow="visible"
enable-background="new 0 0 284.46 284.46"
xml:space="preserve"
sodipodi:version="0.32"
inkscape:version="1.4 (1:1.4+202410161351+e7c3feb100)"
sodipodi:docname="encoder_disc.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
id="metadata2068"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs2066">
</defs><sodipodi:namedview
inkscape:window-height="1131"
inkscape:window-width="1920"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base"
inkscape:zoom="1.0549412"
inkscape:cx="195.27154"
inkscape:cy="-101.90141"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:current-layer="Layer_1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:window-maximized="1" />
<path
id="path1"
style="fill:#f9f9f9;stroke:none;stroke-width:1.23072"
d="M 142.22902,0.61574073 A 141.61462,141.61462 0 0 0 0.61574173,142.22901 141.61462,141.61462 0 0 0 142.22902,283.84425 141.61462,141.61462 0 0 0 283.84425,142.22901 141.61462,141.61462 0 0 0 142.22902,0.61574073 Z m 0,106.22069927 a 35.393924,35.393924 0 0 1 35.39453,35.39257 35.393924,35.393924 0 0 1 -35.39453,35.39455 35.393924,35.393924 0 0 1 -35.39258,-35.39455 35.393924,35.393924 0 0 1 35.39258,-35.39257 z" /><g
id="g2"><path
d="M 242.47,41.99 242.441,42.02 217.4,67.06 C 198.17,47.83 171.589,35.93 142.23,35.93 V 0.5 c 39.17,0 74.6,15.85 100.24,41.49 z"
id="path2015" /><path
d="m 142.23,0.5 v 35.43 c -29.37,0 -55.93,11.89 -75.17,31.13 L 42.02,42.02 41.99,41.99 C 67.63,16.35 103.06,0.5 142.23,0.5 Z"
id="path2017" /><path
d="m 142.23,35.93 v 35.43 c -19.59,0 -37.3,7.92 -50.12,20.75 L 67.06,67.06 C 86.3,47.82 112.86,35.93 142.23,35.93 Z"
id="path2019" /><path
d="M 67.06,67.06 92.11,92.11 C 79.28,104.93 71.36,122.64 71.36,142.23 H 35.93 c 0,-29.36 11.89,-55.94 31.13,-75.17 z"
id="path2021" /><path
d="M 92.11,192.35 67.06,217.4 C 47.82,198.171 35.93,171.59 35.93,142.23 h 35.43 c 0,19.589 7.92,37.3 20.75,50.12 z"
id="path2023" /><path
d="m 142.23,213.09 v 35.44 c -29.37,0 -55.93,-11.891 -75.17,-31.131 l 25.05,-25.05 c 12.82,12.821 30.53,20.741 50.12,20.741 z"
id="path2025" /><path
d="m 142.23,248.53 v 35.43 c -39.17,0 -74.6,-15.851 -100.24,-41.49 l 0.03,-0.03 25.04,-25.04 c 19.24,19.24 45.8,31.13 75.17,31.13 z"
id="path2027" /><path
d="m 142.23,283.96 v -35.43 c 29.36,0 55.94,-11.9 75.17,-31.131 l 25.04,25.04 0.029,0.03 C 216.83,268.109 181.4,283.96 142.23,283.96 Z"
id="path2029" /><path
d="m 177.66,142.229 h 35.43 c 0,19.59 -7.92,37.301 -20.74,50.12 L 167.3,167.3 c 6.4,-6.401 10.36,-15.25 10.36,-25.071 z"
id="path2031" /><path
d="m 167.3,167.3 25.05,25.05 c -12.819,12.82 -30.529,20.74 -50.12,20.74 v -35.43 c 9.8,0 18.66,-3.95 25.07,-10.36 z"
id="path2033" /><path
d="m 142.23,177.66 v 35.43 c -19.59,0 -37.3,-7.92 -50.12,-20.74 l 25.05,-25.04 c 6.42,6.41 15.28,10.35 25.07,10.35 z"
id="path2035" /><path
d="m 117.16,167.3 v 0.01 L 92.11,192.35 C 79.28,179.531 71.36,161.82 71.36,142.23 h 35.43 c 0,9.82 3.96,18.669 10.37,25.07 z"
id="path2037" /><path
fill="none"
stroke="#000000"
d="m 142.23,283.96 c -39.17,0 -74.6,-15.851 -100.24,-41.49 C 16.35,216.83 0.5,181.399 0.5,142.229 0.5,103.059 16.35,67.629 41.99,41.989 67.63,16.35 103.06,0.5 142.23,0.5 c 39.17,0 74.6,15.85 100.24,41.49 25.641,25.64 41.49,61.07 41.49,100.24 0,39.17 -15.85,74.601 -41.49,100.24 -25.64,25.639 -61.07,41.49 -100.24,41.49 z"
id="path2041" /><path
fill="none"
stroke="#000000"
d="m 248.53,142.229 c 0,-29.35 -11.891,-55.93 -31.13,-75.17 -19.23,-19.23 -45.811,-31.13 -75.17,-31.13 -29.37,0 -55.93,11.89 -75.17,31.13 -19.24,19.23 -31.13,45.81 -31.13,75.17 0,29.36 11.89,55.94 31.13,75.17 19.24,19.24 45.8,31.131 75.17,31.131 29.36,0 55.94,-11.9 75.17,-31.131 19.24,-19.239 31.13,-45.819 31.13,-75.17 z"
id="path2043" /><path
fill="none"
stroke="#000000"
d="m 142.23,213.09 c -19.59,0 -37.3,-7.92 -50.12,-20.74 -12.83,-12.819 -20.75,-30.53 -20.75,-50.12 0,-19.59 7.92,-37.3 20.75,-50.12 12.82,-12.83 30.53,-20.75 50.12,-20.75 19.59,0 37.3,7.92 50.12,20.75 12.82,12.82 20.74,30.53 20.74,50.12 0,19.59 -7.92,37.301 -20.74,50.12 -12.82,12.82 -30.53,20.74 -50.12,20.74 z"
id="path2045" /><path
fill="none"
stroke="#000000"
d="m 117.16,167.31 c 6.42,6.41 15.28,10.351 25.07,10.351 9.8,0 18.66,-3.95 25.07,-10.36 6.4,-6.4 10.36,-15.25 10.36,-25.07 0,-9.819 -3.95,-18.67 -10.36,-25.07 -6.399,-6.41 -15.27,-10.36 -25.07,-10.36 -9.8,0 -18.66,3.95 -25.08,10.35 -6.4,6.4 -10.36,15.26 -10.36,25.08 0,9.82 3.96,18.67 10.37,25.07"
id="path2047" /><path
id="polyline2049"
style="fill:none;stroke:#000000"
d="m 142.23,177.66 v 35.43 35.44 35.43"
sodipodi:nodetypes="cccc" /><path
id="polyline2051"
style="fill:none;stroke:#000000"
d="m 167.3,167.3 25.05,25.05 25.05,25.049 25.04,25.04"
sodipodi:nodetypes="cccc" /><path
id="polyline2053"
style="fill:none;stroke:#000000"
d="m 177.66,142.229 h 35.43 35.44 35.43"
sodipodi:nodetypes="cccc" /><path
id="polyline2055"
style="fill:none;stroke:#000000"
d="M 167.3,117.16 192.35,92.11 217.4,67.06 242.44,42.02"
sodipodi:nodetypes="cccc" /><path
id="polyline2057"
style="fill:none;stroke:#000000"
d="M 142.23,106.8 V 71.36 35.93 0.5"
sodipodi:nodetypes="cccc" /><path
id="polyline2059"
style="fill:none;stroke:#000000"
d="M 117.15,117.15 92.11,92.11 67.06,67.06 42.02,42.02"
sodipodi:nodetypes="cccc" /><path
id="polyline2061"
style="fill:none;stroke:#000000"
d="M 106.79,142.229 H 71.36 35.93 0.5"
sodipodi:nodetypes="cccc" /><path
id="polyline2063"
style="fill:none;stroke:#000000"
d="m 117.16,167.3 v 0.01 l -25.05,25.04 -25.05,25.049 -25.04,25.04"
sodipodi:nodetypes="ccccc" /></g>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

+68
View File
@@ -0,0 +1,68 @@
import React, { useState, useRef, useEffect } from 'react';
export default function Tap({ initialCps = 0.4, maxSamples = 3 }) {
const [timestamps, setTimestamps] = useState([]);
const [cps, setCps] = useState(initialCps);
function addTap(ts = Date.now()) {
setTimestamps((prev) => {
const next = [...prev, ts].slice(-(maxSamples + 1));
calcCps(next);
return next;
});
}
function calcCps(times) {
if (!times || times.length < 2) return;
const intervals = [];
for (let i = 1; i < times.length; i++) {
intervals.push(times[i] - times[i - 1]);
}
const avgMs = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const newCps = 1000 / avgMs;
if (Number.isFinite(newCps) && newCps > 0 && newCps < 1000) {
setCps(newCps);
}
}
function handleTap(e) {
e && e.preventDefault();
addTap();
}
function handleReset(e) {
e && e.preventDefault();
reset();
}
useEffect(() => {
function onKey(e) {
if (e.code === 'Space') {
e.preventDefault();
addTap();
} else if (e.code === 'Backspace') {
e.preventDefault();
reset();
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
function reset() {
setTimestamps([]);
// setCps(initialCps);
}
return (
<div>
<div class="flex flex-col items-center p-7 font-medium text-white">
<div>{cps.toFixed(2)} cps</div>
<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" onClick={handleTap}>
Tap
</button>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
/*
Vinyl.jsx - <short description TODO>
Copyright (C) 2025 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/repl/src/App.js>
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 React, { useState, useEffect } from 'react';
import { setInterval, clearInterval } from 'worker-timers';
import { NeoCyclist } from '@strudel/core/neocyclist.mjs';
import { getAudioContext } from '@strudel/webaudio';
import { saw } from '@strudel/core';
import encoder from '../../public/encoder_disc.svg';
console.log(encoder);
const schedulerOptions = {
onTrigger: (x) => {},
getTime: () => getAudioContext().currentTime,
onToggle: (started) => console.log('started: ', started),
setInterval,
clearInterval,
// beforeStart,
};
const cyclist = new NeoCyclist(schedulerOptions);
export function Vinyl() {
const [isActive, setIsActive] = useState(false);
const [cyclepos, setCyclepos] = useState(0);
const activate = () => setIsActive(!isActive);
if (isActive) {
if (!cyclist.started) {
cyclist.start();
// cyclist.setPattern(saw.segment(8));
}
} else {
cyclist.stop();
}
useEffect(() => {
const intervalId = setInterval(() => {
setCyclepos(cyclist.cycle);
}, 10);
return () => clearInterval(intervalId);
}, []);
const deg = (cyclepos % 1) * 360;
const style = {
fontSize: '20em',
transform: 'rotate(' + deg + 'deg)',
};
return (
<div onClick={activate} style={style}>
<center>
<img src={encoder.src} />
</center>
</div>
);
}

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