mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-22 05:05:26 -04:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9c21489f8 | |||
| 3962558c09 | |||
| f6bb3869de | |||
| b9e9c9e9e6 | |||
| 92d87093e0 | |||
| 3e05525dc2 | |||
| 22807b3833 | |||
| 0960741156 | |||
| bbb977afcc | |||
| 81a4981a68 | |||
| 90d82c8988 | |||
| 2875814a36 | |||
| 157b05536a | |||
| f8c9425569 | |||
| be3a88b09c | |||
| aabf122161 | |||
| 3c6de3be6d | |||
| 15f7b7c1a2 | |||
| b6e382508f | |||
| 3e7ae88043 | |||
| 897a2016d0 | |||
| 7a1c1a75b1 | |||
| 7f06f11e96 | |||
| 5acb00795e | |||
| f4b68fa2e6 | |||
| 1b37f1ed1d | |||
| f8943dbc94 | |||
| a3f82f90a9 | |||
| 670f75b7e8 | |||
| 79875ebc50 | |||
| a1bd905c06 | |||
| 247bf9b930 | |||
| 8f24ce223f | |||
| 51648d3e6e | |||
| d4e67e1c20 | |||
| edf4739cc6 | |||
| 3e636bd6f0 | |||
| 4df2cc3086 | |||
| 809eb7132c | |||
| dd6429007e | |||
| e211d5a6bb | |||
| 404d9999f3 | |||
| bc1c83ed64 | |||
| 91e1cc136e | |||
| 92e3a80e65 | |||
| 4f046ae883 | |||
| 2b575d50b9 | |||
| 7b92874909 | |||
| 10bfe32a2d | |||
| a41b07c67a | |||
| 20027d7208 | |||
| f38792c569 | |||
| 2c2bfd7070 | |||
| 484c5e6cdd | |||
| a036c4f921 | |||
| 4c35f6a77b | |||
| e92f4938cd | |||
| 12204c9053 | |||
| 980bf17059 | |||
| f2009a33aa | |||
| a65a68b828 | |||
| c0e7971be7 | |||
| b491c42af5 | |||
| ee43d85f29 | |||
| fcd83cce55 | |||
| fddf2ff2a7 | |||
| 6d4787ee33 | |||
| 1a362bd8ea | |||
| 20bbda30f8 | |||
| a8c613b7eb | |||
| fd0a910bfb | |||
| c02def03b6 | |||
| 0d5dc1f8c1 | |||
| ac7e9c6978 | |||
| c13a726129 | |||
| 99b3c04a76 | |||
| 57fac57677 | |||
| a3b803c3f3 | |||
| 319a2d7289 | |||
| 3a7d50924d | |||
| f33db5f07f | |||
| b7fa440bda | |||
| 3dc9b3524c | |||
| f48f8e46e5 | |||
| a79fc3db5b | |||
| 30a2e14959 | |||
| cfe17fcba5 | |||
| 46ba165c7e | |||
| 73a9834959 | |||
| be9368e8a2 | |||
| 39a73f2499 | |||
| 11dd8a318a | |||
| 3ef6c7c921 | |||
| 4d39f36868 | |||
| c64aa3bf01 | |||
| a0c2edad30 | |||
| 82c9047195 |
@@ -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/)
|
1. Install [Node.js](https://nodejs.org/) 18 or newer
|
||||||
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
|
||||||
|
|||||||
@@ -20,5 +20,8 @@
|
|||||||
"@strudel/tonal": "workspace:*",
|
"@strudel/tonal": "workspace:*",
|
||||||
"@strudel/transpiler": "workspace:*",
|
"@strudel/transpiler": "workspace:*",
|
||||||
"@strudel/webaudio": "workspace:*"
|
"@strudel/webaudio": "workspace:*"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@strudel/web": "workspace:*"
|
"@strudel/web": "workspace:*"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,8 @@
|
|||||||
"@strudel/transpiler": "workspace:*",
|
"@strudel/transpiler": "workspace:*",
|
||||||
"@strudel/webaudio": "workspace:*",
|
"@strudel/webaudio": "workspace:*",
|
||||||
"@strudel/tonal": "workspace:*"
|
"@strudel/tonal": "workspace:*"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,5 +32,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,5 +73,8 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
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';
|
||||||
import { Scale } from '@tonaljs/tonal';
|
//TODO: fix tonal scale import
|
||||||
import { soundMap } from 'superdough';
|
// import { Scale } from '@tonaljs/tonal';
|
||||||
|
// import { soundMap } from '@strudel/webaudio';
|
||||||
|
let soundMap = undefined;
|
||||||
import { complex } from '@strudel/tonal';
|
import { complex } from '@strudel/tonal';
|
||||||
|
|
||||||
const escapeHtml = (str) => {
|
const escapeHtml = (str) => {
|
||||||
@@ -79,7 +81,9 @@ 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() {
|
export function bankCompletions() {
|
||||||
const soundDict = soundMap.get();
|
// TODO: FIX IMPORT
|
||||||
|
const soundDict = soundMap?.get() ?? {};
|
||||||
|
|
||||||
const banks = new Set();
|
const banks = new Set();
|
||||||
for (const key of Object.keys(soundDict)) {
|
for (const key of Object.keys(soundDict)) {
|
||||||
const [bank, suffix] = key.split('_');
|
const [bank, suffix] = key.split('_');
|
||||||
@@ -90,13 +94,13 @@ export function bankCompletions() {
|
|||||||
.map((name) => ({ label: name, type: 'bank' }));
|
.map((name) => ({ label: name, type: 'bank' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to get all scale names from Tonal
|
// Attempt to get all scale names from Tonal TODO: FIX IMPORT
|
||||||
let scaleCompletions = [];
|
let scaleCompletions = [];
|
||||||
try {
|
// try {
|
||||||
scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
|
// scaleCompletions = (Scale.names ? Scale.names() : []).map((name) => ({ label: name, type: 'scale' }));
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
console.warn('[autocomplete] Could not load scale names from Tonal:', e);
|
// console.warn('[autocomplete] Could not load scale names from Tonal:', e);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Valid mode values for voicing
|
// Valid mode values for voicing
|
||||||
const modeCompletions = [
|
const modeCompletions = [
|
||||||
@@ -268,7 +272,7 @@ function soundHandler(context) {
|
|||||||
const inside = text.slice(quoteIdx + 1);
|
const inside = text.slice(quoteIdx + 1);
|
||||||
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
|
const fragMatch = inside.match(SOUND_FRAGMENT_MATCH_REGEX);
|
||||||
const fragment = fragMatch ? fragMatch[1] : inside;
|
const fragment = fragMatch ? fragMatch[1] : inside;
|
||||||
const soundNames = Object.keys(soundMap.get()).sort();
|
const soundNames = Object.keys(soundMap?.get() ?? {}).sort();
|
||||||
const filteredSounds = soundNames.filter((name) => name.includes(fragment));
|
const filteredSounds = soundNames.filter((name) => name.includes(fragment));
|
||||||
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
|
let options = filteredSounds.map((name) => ({ label: name, type: 'sound' }));
|
||||||
const from = soundContext.to - fragment.length;
|
const from = soundContext.to - fragment.length;
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
import { closeBrackets } from '@codemirror/autocomplete';
|
import { closeBrackets } from '@codemirror/autocomplete';
|
||||||
export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands';
|
import { indentWithTab, toggleLineComment } 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 { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from '@codemirror/language';
|
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
|
drawSelection,
|
||||||
EditorView,
|
EditorView,
|
||||||
highlightActiveLineGutter,
|
|
||||||
highlightActiveLine,
|
highlightActiveLine,
|
||||||
|
highlightActiveLineGutter,
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
drawSelection,
|
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { repl, registerControl } from '@strudel/core';
|
import { persistentAtom } from '@nanostores/persistent';
|
||||||
import { Drawer, cleanupDraw } from '@strudel/draw';
|
import { logger, registerControl, repl } from '@strudel/core';
|
||||||
|
import { cleanupDraw, Drawer } from '@strudel/draw';
|
||||||
|
|
||||||
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
import { isAutoCompletionEnabled } from './autocomplete.mjs';
|
||||||
import { isTooltipEnabled } from './tooltip.mjs';
|
import { basicSetup } from './basicSetup.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 { widgetPlugin, updateWidgets } from './widget.mjs';
|
import { activateTheme, initTheme, theme } from './themes.mjs';
|
||||||
import { persistentAtom } from '@nanostores/persistent';
|
import { isTooltipEnabled } from './tooltip.mjs';
|
||||||
import { basicSetup } from './basicSetup.mjs';
|
import { updateWidgets, widgetPlugin } from './widget.mjs';
|
||||||
|
|
||||||
|
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||||
|
|
||||||
const extensions = {
|
const extensions = {
|
||||||
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
||||||
@@ -94,8 +95,8 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
|
|||||||
}),
|
}),
|
||||||
sliderPlugin,
|
sliderPlugin,
|
||||||
widgetPlugin,
|
widgetPlugin,
|
||||||
// indentOnInput(), // works without. already brought with javascript extension?
|
// indentOnInput(), // works without. already brought with javascript
|
||||||
// bracketMatching(), // does not do anything
|
// extension? 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 }),
|
||||||
@@ -206,7 +207,8 @@ 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 the present moment (for highlighting)
|
// if no painters are set (.onPaint was not called), then we only need
|
||||||
|
// 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
|
||||||
@@ -245,6 +247,33 @@ export class StrudelMirror {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('start-repl', this.onStartRepl);
|
document.addEventListener('start-repl', this.onStartRepl);
|
||||||
|
|
||||||
|
// Handle global evaluation requests (e.g., from Vim :w)
|
||||||
|
this.onEvaluateRequest = (e) => {
|
||||||
|
try {
|
||||||
|
// Evaluate current editor on repl-evaluate
|
||||||
|
logger('[repl] evaluate via event');
|
||||||
|
this.evaluate();
|
||||||
|
e?.cancelable && e.preventDefault?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error handling repl-evaluate event', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('repl-evaluate', this.onEvaluateRequest);
|
||||||
|
document.addEventListener('repl-stop', this.onStopRequest);
|
||||||
|
|
||||||
|
// Toggle comments requested from Vim (gc)
|
||||||
|
this.onToggleComment = (e) => {
|
||||||
|
try {
|
||||||
|
// Honor selections; toggleLineComment handles both selections and
|
||||||
|
// single line
|
||||||
|
toggleLineComment(this.editor);
|
||||||
|
e?.cancelable && e.preventDefault?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error handling repl-toggle-comment event', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('repl-toggle-comment', this.onToggleComment);
|
||||||
}
|
}
|
||||||
draw(haps, time, painters) {
|
draw(haps, time, painters) {
|
||||||
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
|
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
|
||||||
@@ -271,6 +300,16 @@ 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();
|
||||||
@@ -346,11 +385,18 @@ export class StrudelMirror {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCode(code) {
|
setCode(code) {
|
||||||
const changes = { from: 0, to: this.editor.state.doc.length, insert: code };
|
const changes = {
|
||||||
|
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;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
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 } from '@replit/codemirror-vim';
|
import { vim, Vim } from '@replit/codemirror-vim';
|
||||||
// import { vim } from './vim_test.mjs';
|
// import { vim } from './vim_test.mjs';
|
||||||
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
|
||||||
import { defaultKeymap } from '@codemirror/commands';
|
import { logger } from '@strudel/core';
|
||||||
|
|
||||||
const vscodePlugin = ViewPlugin.fromClass(
|
const vscodePlugin = ViewPlugin.fromClass(
|
||||||
class {
|
class {
|
||||||
@@ -19,6 +20,110 @@ 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,
|
||||||
|
|||||||
@@ -54,5 +54,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+198
-6
@@ -1288,6 +1288,151 @@ 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
|
||||||
|
*/
|
||||||
|
export const { lprate } = registerControl('lprate');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle-synced rate of the LFO for the lowpass filter
|
||||||
|
*
|
||||||
|
* @name lpsync
|
||||||
|
* @param {number | Pattern} rate rate in cycles
|
||||||
|
*/
|
||||||
|
export const { lpsync } = registerControl('lpsync');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depth of the LFO for the lowpass filter
|
||||||
|
*
|
||||||
|
* @name lpdepth
|
||||||
|
* @param {number | Pattern} depth depth of modulation
|
||||||
|
*/
|
||||||
|
export const { lpdepth } = registerControl('lpdepth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 } = registerControl('hpdepth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
*
|
*
|
||||||
@@ -1750,17 +1895,64 @@ 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']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,18 +35,18 @@ const right = function (n, x) {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _bjork = function (n, x) {
|
const _bjorklund = function (n, x) {
|
||||||
const [ons, offs] = n;
|
const [ons, offs] = n;
|
||||||
return Math.min(ons, offs) <= 1 ? [n, x] : _bjork(...(ons > offs ? left(n, x) : right(n, x)));
|
return Math.min(ons, offs) <= 1 ? [n, x] : _bjorklund(...(ons > offs ? left(n, x) : right(n, x)));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bjork = function (ons, steps) {
|
export const bjorklund = 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 = _bjork([absOns, offs], [ones, zeros]);
|
const result = _bjorklund([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 bjork = function (ons, steps) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const _euclidRot = function (pulses, steps, rotation) {
|
const _euclidRot = function (pulses, steps, rotation) {
|
||||||
const b = bjork(pulses, steps);
|
const b = bjorklund(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 e = register('e', function (euc, pat) {
|
export const bjork = register('bjork', 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(bjork(pulses, steps), new Array(pulses).fill(1), perc);
|
const morphed = _morph(bjorklund(pulses, steps), new Array(pulses).fill(1), perc);
|
||||||
return pat.struct(morphed).setSteps(steps);
|
return pat.struct(morphed).setSteps(steps);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,5 +37,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
* .fastChunk(4, x => x.color('red')).slow(2)
|
|
||||||
* .scale("C2:major").note()
|
* .scale("C2:major").note()
|
||||||
|
* .fastChunk(4, x => x.color('red')).slow(2)
|
||||||
*/
|
*/
|
||||||
export const { fastchunk, fastChunk } = register(
|
export const { fastchunk, fastChunk } = register(
|
||||||
['fastchunk', 'fastChunk'],
|
['fastchunk', 'fastChunk'],
|
||||||
|
|||||||
@@ -201,8 +201,7 @@ const timeToIntSeed = (x) => xorwise(Math.trunc(_frac(x / 300) * 536870912));
|
|||||||
|
|
||||||
const intSeedToRand = (x) => (x % 536870912) / 536870912;
|
const intSeedToRand = (x) => (x % 536870912) / 536870912;
|
||||||
|
|
||||||
// Set first random value to be 0.5, otherwise it would be 0. https://github.com/tidalcycles/strudel/issues/1293
|
const timeToRand = (x) => Math.abs(intSeedToRand(timeToIntSeed(x)));
|
||||||
const timeToRand = (x) => (x === 0 ? 0.5 : Math.abs(intSeedToRand(timeToIntSeed(x))));
|
|
||||||
|
|
||||||
const timeToRandsPrime = (seed, n) => {
|
const timeToRandsPrime = (seed, n) => {
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { bjork } from '../euclid.mjs';
|
import { bjorklund } 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('bjork', () => {
|
describe('bjorklund', () => {
|
||||||
it('should apply bjorklund to ons and steps', () => {
|
it('should apply bjorklundlund to ons and steps', () => {
|
||||||
expect(bjork(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]);
|
expect(bjorklund(3, 8)).toStrictEqual([1, 0, 0, 1, 0, 0, 1, 0]);
|
||||||
expect(bjork(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]);
|
expect(bjorklund(-3, 8)).toStrictEqual([0, 1, 1, 0, 1, 1, 0, 1]);
|
||||||
expect(bjork(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]);
|
expect(bjorklund(8, 8)).toStrictEqual([1, 1, 1, 1, 1, 1, 1, 1]);
|
||||||
expect(bjork(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
expect(bjorklund(-8, 8)).toStrictEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
expect(bjork(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]);
|
expect(bjorklund(5, 8)).toStrictEqual([1, 0, 1, 1, 0, 1, 1, 0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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][#bs]*[0-9]$/.test(name);
|
export const isNoteWithOctave = (name) => /^[a-gA-G][#bsf]*[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,6 +23,10 @@ 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);
|
||||||
@@ -30,7 +34,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 = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
const offset = getAccidentalsOffset(acc);
|
||||||
return (Number(oct) + 1) * 12 + chroma + offset;
|
return (Number(oct) + 1) * 12 + chroma + offset;
|
||||||
};
|
};
|
||||||
export const midiToFreq = (n) => {
|
export const midiToFreq = (n) => {
|
||||||
|
|||||||
@@ -38,5 +38,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,5 +25,8 @@
|
|||||||
"@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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,5 +33,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,5 +20,8 @@
|
|||||||
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ 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
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ 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,
|
||||||
|
|||||||
@@ -33,5 +33,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,5 +34,8 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,5 +40,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"pkg": "^5.8.1",
|
"pkg": "^5.8.1",
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-42
@@ -5,9 +5,10 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as _WebMidi from 'webmidi';
|
import * as _WebMidi from 'webmidi';
|
||||||
import { Pattern, getEventOffsetMs, isPattern, logger, ref } from '@strudel/core';
|
import { Pattern, 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;
|
||||||
@@ -190,7 +191,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, timeOffsetString) {
|
function sendCC(ccn, ccv, device, midichan, targetTime) {
|
||||||
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
if (typeof ccv !== 'number' || ccv < 0 || ccv > 1) {
|
||||||
throw new Error('expected ccv to be a number between 0 and 1');
|
throw new Error('expected ccv to be a number between 0 and 1');
|
||||||
}
|
}
|
||||||
@@ -198,19 +199,23 @@ function sendCC(ccn, ccv, device, midichan, timeOffsetString) {
|
|||||||
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);
|
||||||
device.sendControlChange(ccn, scaled, midichan, { time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendControlChange(ccn, scaled, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a program change message to the given device on the given channel
|
// sends a program change message to the given device on the given channel
|
||||||
function sendProgramChange(progNum, device, midichan, timeOffsetString) {
|
function sendProgramChange(progNum, device, midichan, targetTime) {
|
||||||
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
if (typeof progNum !== 'number' || progNum < 0 || progNum > 127) {
|
||||||
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
throw new Error('expected progNum (program change) to be a number between 0 and 127');
|
||||||
}
|
}
|
||||||
device.sendProgramChange(progNum, midichan, { time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendProgramChange(progNum, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a sysex message to the given device on the given channel
|
// sends a sysex message to the given device on the given channel
|
||||||
function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
|
function sendSysex(sysexid, sysexdata, device, targetTime) {
|
||||||
if (Array.isArray(sysexid)) {
|
if (Array.isArray(sysexid)) {
|
||||||
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
if (!sysexid.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||||
throw new Error('all sysexid bytes must be integers between 0 and 255');
|
throw new Error('all sysexid bytes must be integers between 0 and 255');
|
||||||
@@ -225,11 +230,13 @@ function sendSysex(sysexid, sysexdata, device, timeOffsetString) {
|
|||||||
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');
|
||||||
}
|
}
|
||||||
device.sendSysex(sysexid, sysexdata, { time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendSysex(sysexid, sysexdata);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a NRPN message to the given device on the given channel
|
// sends a NRPN message to the given device on the given channel
|
||||||
function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
|
function sendNRPN(nrpnn, nrpv, device, midichan, targetTime) {
|
||||||
if (Array.isArray(nrpnn)) {
|
if (Array.isArray(nrpnn)) {
|
||||||
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
if (!nrpnn.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||||
throw new Error('all nrpnn bytes must be integers between 0 and 255');
|
throw new Error('all nrpnn bytes must be integers between 0 and 255');
|
||||||
@@ -237,28 +244,34 @@ function sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString) {
|
|||||||
} 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, { time: timeOffsetString });
|
device.sendNRPN(nrpnn, nrpv, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a pitch bend message to the given device on the given channel
|
// sends a pitch bend message to the given device on the given channel
|
||||||
function sendPitchBend(midibend, device, midichan, timeOffsetString) {
|
function sendPitchBend(midibend, device, midichan, targetTime) {
|
||||||
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
if (typeof midibend !== 'number' || midibend < -1 || midibend > 1) {
|
||||||
throw new Error('expected midibend to be a number between -1 and 1');
|
throw new Error('expected midibend to be a number between -1 and 1');
|
||||||
}
|
}
|
||||||
device.sendPitchBend(midibend, midichan, { time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendPitchBend(midibend, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sends a channel aftertouch message to the given device on the given channel
|
// sends a channel aftertouch message to the given device on the given channel
|
||||||
function sendAftertouch(miditouch, device, midichan, timeOffsetString) {
|
function sendAftertouch(miditouch, device, midichan, targetTime) {
|
||||||
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
if (typeof miditouch !== 'number' || miditouch < 0 || miditouch > 1) {
|
||||||
throw new Error('expected miditouch to be a number between 0 and 1');
|
throw new Error('expected miditouch to be a number between 0 and 1');
|
||||||
}
|
}
|
||||||
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, timeOffsetString) {
|
function sendNote(note, velocity, duration, device, midichan, targetTime) {
|
||||||
if (note == null || note === '') {
|
if (note == null || note === '') {
|
||||||
throw new Error('note cannot be null or empty');
|
throw new Error('note cannot be null or empty');
|
||||||
}
|
}
|
||||||
@@ -268,12 +281,12 @@ function sendNote(note, velocity, duration, device, midichan, timeOffsetString)
|
|||||||
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, {
|
|
||||||
time: timeOffsetString,
|
scheduleAtTime(() => {
|
||||||
});
|
device.playNote(midiNote, midichan);
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -309,7 +322,6 @@ 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
|
||||||
@@ -333,18 +345,13 @@ 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,
|
||||||
@@ -380,7 +387,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, timeOffsetString));
|
ccs.forEach(({ ccn, ccv }) => sendCC(ccn, ccv, device, midichan, targetTime));
|
||||||
} else if (midimap !== 'default') {
|
} else if (midimap !== 'default') {
|
||||||
// Add warning when a non-existent midimap is specified
|
// Add warning when a non-existent midimap is specified
|
||||||
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
logger(`[midi] midimap "${midimap}" not found! Available maps: ${[...midicontrolMap.keys()].join(', ')}`);
|
||||||
@@ -392,12 +399,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, timeOffsetString);
|
sendNote(note, velocity, duration, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle program change
|
// Handle program change
|
||||||
if (progNum !== undefined) {
|
if (progNum !== undefined) {
|
||||||
sendProgramChange(progNum, device, midichan, timeOffsetString);
|
sendProgramChange(progNum, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle sysex
|
// Handle sysex
|
||||||
@@ -407,53 +414,63 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
|||||||
// if sysexid is an array the first byte is 0x00
|
// if sysexid is an array the first byte is 0x00
|
||||||
|
|
||||||
if (sysexid !== undefined && sysexdata !== undefined) {
|
if (sysexid !== undefined && sysexdata !== undefined) {
|
||||||
sendSysex(sysexid, sysexdata, device, timeOffsetString);
|
sendSysex(sysexid, sysexdata, device, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle control change
|
// Handle control change
|
||||||
if (ccv !== undefined && ccn !== undefined) {
|
if (ccv !== undefined && ccn !== undefined) {
|
||||||
sendCC(ccn, ccv, device, midichan, timeOffsetString);
|
sendCC(ccn, ccv, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle NRPN non-registered parameter number
|
// Handle NRPN non-registered parameter number
|
||||||
if (nrpnn !== undefined && nrpv !== undefined) {
|
if (nrpnn !== undefined && nrpv !== undefined) {
|
||||||
sendNRPN(nrpnn, nrpv, device, midichan, timeOffsetString);
|
sendNRPN(nrpnn, nrpv, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle midibend
|
// Handle midibend
|
||||||
if (midibend !== undefined) {
|
if (midibend !== undefined) {
|
||||||
sendPitchBend(midibend, device, midichan, timeOffsetString);
|
sendPitchBend(midibend, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle miditouch
|
// Handle miditouch
|
||||||
if (miditouch !== undefined) {
|
if (miditouch !== undefined) {
|
||||||
sendAftertouch(miditouch, device, midichan, timeOffsetString);
|
sendAftertouch(miditouch, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle midicmd
|
// Handle midicmd
|
||||||
if (hap.whole.begin + 0 === 0) {
|
if (hap.whole.begin + 0 === 0) {
|
||||||
// we need to start here because we have the timing info
|
// we need to start here because we have the timing info
|
||||||
device.sendStart({ time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendStart();
|
||||||
|
}, targetTime);
|
||||||
}
|
}
|
||||||
if (['clock', 'midiClock'].includes(midicmd)) {
|
if (['clock', 'midiClock'].includes(midicmd)) {
|
||||||
device.sendClock({ time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendClock();
|
||||||
|
}, targetTime);
|
||||||
} else if (['start'].includes(midicmd)) {
|
} else if (['start'].includes(midicmd)) {
|
||||||
device.sendStart({ time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendStart();
|
||||||
|
}, targetTime);
|
||||||
} else if (['stop'].includes(midicmd)) {
|
} else if (['stop'].includes(midicmd)) {
|
||||||
device.sendStop({ time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendStop();
|
||||||
|
}, targetTime);
|
||||||
} else if (['continue'].includes(midicmd)) {
|
} else if (['continue'].includes(midicmd)) {
|
||||||
device.sendContinue({ time: timeOffsetString });
|
scheduleAtTime(() => {
|
||||||
|
device.sendContinue();
|
||||||
|
}, targetTime);
|
||||||
} else if (Array.isArray(midicmd)) {
|
} else if (Array.isArray(midicmd)) {
|
||||||
if (midicmd[0] === 'progNum') {
|
if (midicmd[0] === 'progNum') {
|
||||||
sendProgramChange(midicmd[1], device, midichan, timeOffsetString);
|
sendProgramChange(midicmd[1], device, midichan, targetTime);
|
||||||
} else if (midicmd[0] === 'cc') {
|
} else if (midicmd[0] === 'cc') {
|
||||||
if (midicmd.length === 2) {
|
if (midicmd.length === 2) {
|
||||||
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, timeOffsetString);
|
sendCC(midicmd[0], midicmd[1] / 127, device, midichan, targetTime);
|
||||||
}
|
}
|
||||||
} else if (midicmd[0] === 'sysex') {
|
} else if (midicmd[0] === 'sysex') {
|
||||||
if (midicmd.length === 3) {
|
if (midicmd.length === 3) {
|
||||||
const [_, id, data] = midicmd;
|
const [_, id, data] = midicmd;
|
||||||
sendSysex(id, data, device, timeOffsetString);
|
sendSysex(id, data, device, targetTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,5 +38,8 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ 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) {
|
||||||
@@ -150,9 +151,9 @@ export class MondoParser {
|
|||||||
}
|
}
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
desugar_ops(children) {
|
desugar_ops(children, types) {
|
||||||
while (true) {
|
while (true) {
|
||||||
let opIndex = children.findIndex((child) => child.type === 'op');
|
let opIndex = children.findIndex((child) => child.type === 'op' && types.includes(child.value));
|
||||||
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) {
|
||||||
@@ -263,8 +264,10 @@ 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];
|
||||||
}
|
}
|
||||||
children = this.desugar_ops(children);
|
// for each precendence group, call desugar_ops once
|
||||||
// children = this.desugar_pipes(children, (children) => this.desugar_dollars(children));
|
this.op_precedence.forEach((ops) => {
|
||||||
|
children = this.desugar_ops(children, ops);
|
||||||
|
});
|
||||||
children = this.desugar_pipes(children);
|
children = this.desugar_pipes(children);
|
||||||
return children;
|
return children;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -33,5 +33,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ 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))'));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
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';
|
||||||
@@ -40,6 +41,7 @@ lib['!'] = replicate;
|
|||||||
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;
|
||||||
|
|||||||
@@ -40,5 +40,8 @@
|
|||||||
"mondo": "*",
|
"mondo": "*",
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,5 +33,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,5 +34,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"pkg": "^5.8.1",
|
"pkg": "^5.8.1",
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,5 +33,8 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,5 +48,8 @@
|
|||||||
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cowsay": "^1.6.0"
|
"cowsay": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,5 +33,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,5 +37,8 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,24 @@ 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;
|
||||||
@@ -170,55 +188,58 @@ 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);
|
||||||
}
|
}
|
||||||
let lfo;
|
const lfo = 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;
|
|
||||||
}
|
|
||||||
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, type, frequency, Q, att, dec, sus, rel, fenv, start, end, fanchor, model, drive) {
|
export function createFilter(context, start, end, params, cps) {
|
||||||
const curve = 'exponential';
|
let {
|
||||||
const [attack, decay, sustain, release] = getADSRValues([att, dec, sus, rel], curve, [0.005, 0.14, 0, 0.1]);
|
frequency,
|
||||||
let filter;
|
anchor,
|
||||||
let frequencyParam;
|
env,
|
||||||
|
type,
|
||||||
|
model,
|
||||||
|
q = 1,
|
||||||
|
drive = 0.69,
|
||||||
|
depth,
|
||||||
|
dcoffset = -0.5,
|
||||||
|
skew,
|
||||||
|
shape,
|
||||||
|
rate,
|
||||||
|
sync,
|
||||||
|
} = params;
|
||||||
|
let frequencyParam, filter;
|
||||||
if (model === 'ladder') {
|
if (model === 'ladder') {
|
||||||
filter = getWorklet(context, 'ladder-processor', { frequency, q: Q, drive });
|
filter = getWorklet(context, 'ladder-processor', { frequency, 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 = att ?? dec ?? sus ?? rel ?? fenv;
|
const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined);
|
||||||
// Apply ADSR to filter frequency
|
// Apply ADSR to filter frequency
|
||||||
if (hasEnvelope !== undefined) {
|
if (hasEnvelope) {
|
||||||
fenv = nanFallback(fenv, 1, true);
|
env = nanFallback(env, 1, true);
|
||||||
fanchor = nanFallback(fanchor, 0, true);
|
anchor = nanFallback(anchor, 0, true);
|
||||||
const fenvAbs = Math.abs(fenv);
|
const envAbs = Math.abs(env);
|
||||||
const offset = fenvAbs * fanchor;
|
const offset = envAbs * anchor;
|
||||||
let min = clamp(2 ** -offset * frequency, 0, 20000);
|
let min = clamp(2 ** -offset * frequency, 0, 20000);
|
||||||
let max = clamp(2 ** (fenvAbs - offset) * frequency, 0, 20000);
|
let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000);
|
||||||
if (fenv < 0) [min, max] = [max, min];
|
if (env < 0) [min, max] = [max, min];
|
||||||
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, curve);
|
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential');
|
||||||
return filter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sync != null) {
|
||||||
|
rate = cps * sync;
|
||||||
|
}
|
||||||
|
const lfoValues = { depth, dcoffset, skew, shape, frequency: rate };
|
||||||
|
getParamLfo(context, frequencyParam, start, end, lfoValues);
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +301,11 @@ export function getVibratoOscillator(param, value, 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) {
|
||||||
|
|||||||
@@ -37,5 +37,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanostores": "^0.11.3"
|
"nanostores": "^0.11.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ 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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 } from './util.mjs';
|
import { nanFallback, _mod, cycleToSeconds, pickAndRename } 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,11 +146,6 @@ 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,
|
||||||
@@ -415,32 +410,7 @@ 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,
|
||||||
@@ -511,7 +481,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];
|
const source = ch.value[1].deref();
|
||||||
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);
|
||||||
@@ -543,7 +513,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, soundHandle);
|
activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`sound ${s} not found! Is it loaded?`);
|
throw new Error(`sound ${s} not found! Is it loaded?`);
|
||||||
@@ -565,57 +535,87 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
|||||||
// gain stage
|
// gain stage
|
||||||
chain.push(gainNode(gain));
|
chain.push(gainNode(gain));
|
||||||
|
|
||||||
//filter
|
// filter
|
||||||
const ftype = getFilterType(value.ftype);
|
const ftype = getFilterType(value.ftype);
|
||||||
if (cutoff !== undefined) {
|
|
||||||
let lp = () =>
|
if (value.cutoff !== undefined) {
|
||||||
createFilter(
|
const lpMap = {
|
||||||
ac,
|
frequency: 'cutoff',
|
||||||
'lowpass',
|
q: 'resonance',
|
||||||
cutoff,
|
attack: 'lpattack',
|
||||||
resonance,
|
decay: 'lpdecay',
|
||||||
lpattack,
|
sustain: 'lpsustain',
|
||||||
lpdecay,
|
release: 'lprelease',
|
||||||
lpsustain,
|
env: 'lpenv',
|
||||||
lprelease,
|
anchor: 'fanchor',
|
||||||
lpenv,
|
model: 'ftype',
|
||||||
t,
|
drive: 'drive',
|
||||||
end,
|
rate: 'lprate',
|
||||||
fanchor,
|
sync: 'lpsync',
|
||||||
ftype,
|
depth: 'lpdepth',
|
||||||
drive,
|
shape: 'lpshape',
|
||||||
);
|
dcoffset: 'lpdc',
|
||||||
|
skew: 'lpskew',
|
||||||
|
};
|
||||||
|
const lpParams = pickAndRename(value, lpMap);
|
||||||
|
lpParams.type = 'lowpass';
|
||||||
|
let lp = () => createFilter(ac, t, end, lpParams, cps);
|
||||||
chain.push(lp());
|
chain.push(lp());
|
||||||
if (ftype === '24db') {
|
if (ftype === '24db') {
|
||||||
chain.push(lp());
|
chain.push(lp());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hcutoff !== undefined) {
|
if (value.hcutoff !== undefined) {
|
||||||
let hp = () =>
|
const hpMap = {
|
||||||
createFilter(
|
frequency: 'hcutoff',
|
||||||
ac,
|
q: 'hresonance',
|
||||||
'highpass',
|
attack: 'hpattack',
|
||||||
hcutoff,
|
decay: 'hpdecay',
|
||||||
hresonance,
|
sustain: 'hpsustain',
|
||||||
hpattack,
|
release: 'hprelease',
|
||||||
hpdecay,
|
env: 'hpenv',
|
||||||
hpsustain,
|
anchor: 'fanchor',
|
||||||
hprelease,
|
model: 'ftype',
|
||||||
hpenv,
|
drive: 'drive',
|
||||||
t,
|
rate: 'hprate',
|
||||||
end,
|
sync: 'hpsync',
|
||||||
fanchor,
|
depth: 'hpdepth',
|
||||||
);
|
shape: 'hpshape',
|
||||||
|
dcoffset: 'hpdc',
|
||||||
|
skew: 'hpskew',
|
||||||
|
};
|
||||||
|
const hpParams = pickAndRename(value, hpMap);
|
||||||
|
hpParams.type = 'highpass';
|
||||||
|
let hp = () => createFilter(ac, t, end, hpParams, cps);
|
||||||
chain.push(hp());
|
chain.push(hp());
|
||||||
if (ftype === '24db') {
|
if (ftype === '24db') {
|
||||||
chain.push(hp());
|
chain.push(hp());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bandf !== undefined) {
|
if (value.bandf !== undefined) {
|
||||||
let bp = () =>
|
const bpMap = {
|
||||||
createFilter(ac, 'bandpass', bandf, bandq, bpattack, bpdecay, bpsustain, bprelease, bpenv, t, end, fanchor);
|
frequency: 'bandf',
|
||||||
|
q: 'bandq',
|
||||||
|
attack: 'bpattack',
|
||||||
|
decay: 'bpdecay',
|
||||||
|
sustain: 'bpsustain',
|
||||||
|
release: 'bprelease',
|
||||||
|
env: 'bpenv',
|
||||||
|
anchor: 'fanchor',
|
||||||
|
model: 'ftype',
|
||||||
|
drive: 'drive',
|
||||||
|
rate: 'bprate',
|
||||||
|
sync: 'bpsync',
|
||||||
|
depth: 'bpdepth',
|
||||||
|
shape: 'bpshape',
|
||||||
|
dcoffset: 'bpdc',
|
||||||
|
skew: 'bpskew',
|
||||||
|
};
|
||||||
|
const bpParams = pickAndRename(value, bpMap);
|
||||||
|
bpParams.type = 'bandpass';
|
||||||
|
let bp = () => createFilter(ac, t, end, bpParams, cps);
|
||||||
chain.push(bp());
|
chain.push(bp());
|
||||||
if (ftype === '24db') {
|
if (ftype === '24db') {
|
||||||
chain.push(bp());
|
chain.push(bp());
|
||||||
@@ -708,7 +708,8 @@ 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);
|
||||||
orbitBus.sendReverb(post, room);
|
const send = orbitBus.sendReverb(post, room);
|
||||||
|
audioNodes.push(send);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (djf != null) {
|
if (djf != null) {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export class Orbit {
|
|||||||
return this.reverbNode;
|
return this.reverbNode;
|
||||||
}
|
}
|
||||||
sendReverb(node, amount) {
|
sendReverb(node, amount) {
|
||||||
effectSend(node, this.reverbNode, amount);
|
return effectSend(node, this.reverbNode, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendDelay(node, amount) {
|
sendDelay(node, amount) {
|
||||||
|
|||||||
@@ -16,13 +16,17 @@ 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 = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0;
|
const offset = getAccidentalsOffset(acc);
|
||||||
return (Number(oct) + 1) * 12 + chroma + offset;
|
return (Number(oct) + 1) * 12 + chroma + offset;
|
||||||
};
|
};
|
||||||
export const midiToFreq = (n) => {
|
export const midiToFreq = (n) => {
|
||||||
@@ -105,3 +109,8 @@ 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]]));
|
||||||
|
};
|
||||||
|
|||||||
+138
-171
@@ -6,51 +6,57 @@ 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
|
|
||||||
|
|
||||||
const getUnisonDetune = (unison, detune, voiceIndex) => {
|
// Fast integer ops for non-negative values
|
||||||
if (unison < 2) {
|
const ffloor = (x) => x | 0;
|
||||||
return 0;
|
const fround = (x) => ffloor(x + 0.5);
|
||||||
}
|
const fceil = (x) => ffloor(x + 1);
|
||||||
return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 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) {
|
||||||
|
return (_voiceIdx) => 0;
|
||||||
|
}
|
||||||
|
const scale = detune / (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 /= dt;
|
phase *= invdt;
|
||||||
// 2 * (phase - phase^2/2 - 0.5)
|
return 2 * phase - phase ** 2 - 1;
|
||||||
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) / dt;
|
phase = (phase - 1) * invdt;
|
||||||
// 2 * (phase^2/2 + phase + 0.5)
|
return phase ** 2 + 2 * phase + 1;
|
||||||
return phase * phase + phase + phase + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0 otherwise
|
// 0 otherwise
|
||||||
else {
|
else {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -66,7 +72,7 @@ const waveshapes = {
|
|||||||
return phase / skew;
|
return phase / skew;
|
||||||
},
|
},
|
||||||
sine(phase) {
|
sine(phase) {
|
||||||
return Math.sin(Math.PI * 2 * phase) * 0.5 + 0.5;
|
return Math.sin(TWO_PI * phase) * 0.5 + 0.5;
|
||||||
},
|
},
|
||||||
ramp(phase) {
|
ramp(phase) {
|
||||||
return phase;
|
return phase;
|
||||||
@@ -100,12 +106,6 @@ 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 = mod(time * frequency + phaseoffset, 1);
|
this.phase = ffrac(time * frequency + phaseoffset);
|
||||||
}
|
}
|
||||||
const dt = frequency / sampleRate;
|
const dt = frequency * INVSR;
|
||||||
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 / sampleRate)), 0, 1.14);
|
const c = clamp(2 * Math.sin(cutoff * PI * INVSR), 0, 1.14);
|
||||||
const r = Math.pow(0.5, (resonance + 0.125) / 0.125);
|
const r = Math.pow(0.5, 8 * resonance + 1);
|
||||||
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,11 +353,6 @@ 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() {
|
||||||
@@ -395,7 +390,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 * 2 * _PI) / sampleRate;
|
cutoff = cutoff * TWO_PI * INVSR;
|
||||||
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);
|
||||||
@@ -508,6 +503,7 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
|||||||
name: 'voices',
|
name: 'voices',
|
||||||
defaultValue: 5,
|
defaultValue: 5,
|
||||||
min: 1,
|
min: 1,
|
||||||
|
automationRate: 'k-rate',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -519,40 +515,36 @@ 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;
|
||||||
const gain1 = Math.sqrt(1 - panspread);
|
let gainL = Math.sqrt(1 - panspread);
|
||||||
const gain2 = Math.sqrt(panspread);
|
let gainR = 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, getUnisonDetune(voices, freqspread, n));
|
const freqVoice = applySemitoneDetuneToFrequency(freq, detuner(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 = mod(freqVoice / sampleRate, 1);
|
const dt = ffrac(freqVoice * INVSR);
|
||||||
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] = output[0][i] + v * gainL;
|
output[0][i] += v * gainL;
|
||||||
output[1][i] = output[1][i] + v * gainR;
|
output[1][i] += v * gainR;
|
||||||
|
|
||||||
this.phase[n] = wrapPhase(this.phase[n] + dt);
|
let pn = this.phase[n] + dt;
|
||||||
|
if (pn >= 1.0) pn -= 1.0;
|
||||||
|
this.phase[n] = pn;
|
||||||
|
// invert right and left gain
|
||||||
|
gainL = gainR;
|
||||||
|
gainR = gainL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -564,12 +556,16 @@ 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) {
|
||||||
let win = new Float32Array(length);
|
if (!hannCache.has(length)) {
|
||||||
for (var i = 0; i < length; i++) {
|
const win = new Float32Array(length);
|
||||||
win[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / length));
|
for (let i = 0; i < length; i++) {
|
||||||
|
win[i] = 0.5 * (1 - Math.cos((TWO_PI * i) / length));
|
||||||
}
|
}
|
||||||
return win;
|
hannCache.set(length, win);
|
||||||
|
}
|
||||||
|
return hannCache.get(length);
|
||||||
}
|
}
|
||||||
|
|
||||||
class PhaseVocoderProcessor extends OLAProcessor {
|
class PhaseVocoderProcessor extends OLAProcessor {
|
||||||
@@ -587,11 +583,10 @@ class PhaseVocoderProcessor extends OLAProcessor {
|
|||||||
blockSize: BUFFERED_BLOCK_SIZE,
|
blockSize: BUFFERED_BLOCK_SIZE,
|
||||||
};
|
};
|
||||||
super(options);
|
super(options);
|
||||||
|
|
||||||
this.fftSize = this.blockSize;
|
|
||||||
this.timeCursor = 0;
|
this.timeCursor = 0;
|
||||||
|
this.fftSize = this.blockSize;
|
||||||
this.hannWindow = genHannWindow(this.blockSize);
|
this.invfftSize = 1 / this.fftSize;
|
||||||
|
this.hannWindow = genHannWindow(this.fftSize);
|
||||||
// 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();
|
||||||
@@ -604,52 +599,43 @@ 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 (var i = 0; i < this.nbInputs; i++) {
|
for (let j = 0; j < inputs[i].length; j++) {
|
||||||
for (var j = 0; j < inputs[i].length; j++) {
|
const input = inputs[i][j];
|
||||||
// big assumption here: output is symetric to input
|
const output = outputs[i][j];
|
||||||
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 (var i = 0; i < this.blockSize; i++) {
|
for (let i = 0; i < this.blockSize; i++) {
|
||||||
input[i] = input[i] * this.hannWindow[i] * 1.62;
|
input[i] *= this.hannWindow[i] * 1.62;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute squared magnitudes for peak finding **/
|
/** Compute squared magnitudes for peak finding **/
|
||||||
computeMagnitudes() {
|
computeMagnitudes() {
|
||||||
var i = 0,
|
let i = 0,
|
||||||
j = 0;
|
j = 0;
|
||||||
while (i < this.magnitudes.length) {
|
while (i < this.magnitudes.length) {
|
||||||
let real = this.freqComplexBuffer[j];
|
const real = this.freqComplexBuffer[j];
|
||||||
let imag = this.freqComplexBuffer[j + 1];
|
const 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;
|
||||||
@@ -660,12 +646,10 @@ class PhaseVocoderProcessor extends OLAProcessor {
|
|||||||
/** Find peaks in spectrum magnitudes **/
|
/** Find peaks in spectrum magnitudes **/
|
||||||
findPeaks() {
|
findPeaks() {
|
||||||
this.nbPeaks = 0;
|
this.nbPeaks = 0;
|
||||||
var i = 2;
|
let i = 2;
|
||||||
let end = this.magnitudes.length - 2;
|
const end = this.magnitudes.length - 2;
|
||||||
|
|
||||||
while (i < end) {
|
while (i < end) {
|
||||||
let mag = this.magnitudes[i];
|
const 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;
|
||||||
@@ -674,7 +658,6 @@ 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;
|
||||||
@@ -685,53 +668,44 @@ 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++) {
|
||||||
for (var i = 0; i < this.nbPeaks; i++) {
|
const peakIndex = this.peakIndexes[i];
|
||||||
let peakIndex = this.peakIndexes[i];
|
const peakIndexShifted = fround(peakIndex * pitchFactor);
|
||||||
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
|
||||||
var startIndex = 0;
|
let startIndex = 0;
|
||||||
var endIndex = this.fftSize;
|
let endIndex = this.fftSize;
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
let peakIndexBefore = this.peakIndexes[i - 1];
|
startIndex = peakIndex - fround((peakIndex - this.peakIndexes[i - 1]) / 2);
|
||||||
startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2);
|
|
||||||
}
|
}
|
||||||
if (i < this.nbPeaks - 1) {
|
if (i < this.nbPeaks - 1) {
|
||||||
let peakIndexAfter = this.peakIndexes[i + 1];
|
endIndex = peakIndex + fceil((this.peakIndexes[i + 1] - peakIndex) / 2);
|
||||||
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
|
||||||
let startOffset = startIndex - peakIndex;
|
const startOffset = startIndex - peakIndex;
|
||||||
let endOffset = endIndex - peakIndex;
|
const endOffset = endIndex - peakIndex;
|
||||||
for (var j = startOffset; j < endOffset; j++) {
|
const omegaDelta = TWO_PI * this.invfftSize * (peakIndexShifted - peakIndex);
|
||||||
let binIndex = peakIndex + j;
|
const phaseShiftReal = Math.cos(omegaDelta * this.timeCursor);
|
||||||
let binIndexShifted = peakIndexShifted + j;
|
const phaseShiftImag = Math.sin(omegaDelta * this.timeCursor);
|
||||||
|
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
|
||||||
let omegaDelta = (2 * Math.PI * (binIndexShifted - binIndex)) / this.fftSize;
|
const indexReal = 2 * binIndex;
|
||||||
let phaseShiftReal = Math.cos(omegaDelta * this.timeCursor);
|
const indexImag = indexReal + 1;
|
||||||
let phaseShiftImag = Math.sin(omegaDelta * this.timeCursor);
|
const valueReal = this.freqComplexBuffer[indexReal];
|
||||||
|
const valueImag = this.freqComplexBuffer[indexImag];
|
||||||
|
|
||||||
let indexReal = binIndex * 2;
|
const valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag;
|
||||||
let indexImag = indexReal + 1;
|
const valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal;
|
||||||
let valueReal = this.freqComplexBuffer[indexReal];
|
|
||||||
let valueImag = this.freqComplexBuffer[indexImag];
|
|
||||||
|
|
||||||
let valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag;
|
const indexShiftedReal = 2 * binIndexShifted;
|
||||||
let valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal;
|
const indexShiftedImag = indexShiftedReal + 1;
|
||||||
|
|
||||||
let indexShiftedReal = binIndexShifted * 2;
|
|
||||||
let indexShiftedImag = indexShiftedReal + 1;
|
|
||||||
this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal;
|
this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal;
|
||||||
this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag;
|
this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag;
|
||||||
}
|
}
|
||||||
@@ -745,11 +719,10 @@ registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor);
|
|||||||
class PulseOscillatorProcessor extends AudioWorkletProcessor {
|
class PulseOscillatorProcessor extends AudioWorkletProcessor {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.pi = _PI;
|
this.phi = -PI; // phase
|
||||||
this.phi = -this.pi; // phase
|
|
||||||
this.Y0 = 0; // feedback memories
|
this.Y0 = 0; // feedback memories
|
||||||
this.Y1 = 0;
|
this.Y1 = 0;
|
||||||
this.PW = this.pi; // pulse width
|
this.PW = 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
|
||||||
@@ -806,11 +779,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(getParamValue(i, params.pulsewidth), -0.99, 0.99)) * this.pi;
|
const pw = (1 - clamp(pv(params.pulsewidth, i), -0.99, 0.99)) * PI;
|
||||||
const detune = getParamValue(i, params.detune);
|
const detune = pv(params.detune, i);
|
||||||
const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100);
|
const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
|
||||||
|
|
||||||
dphi = freq * (this.pi / (sampleRate * 0.5)); // phase increment
|
dphi = freq * TWO_PI * INVSR; // 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
|
||||||
@@ -822,7 +795,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 >= this.pi) this.phi -= 2 * this.pi; // phase wrapping
|
if (this.phi >= PI) this.phi -= TWO_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
|
||||||
@@ -852,24 +825,23 @@ 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 - 0; idx++) {
|
for (let idx = 0; idx < size; idx++) {
|
||||||
result += chyx.bitC(x, 2 ** idx, 2 ** (size - (idx + 1)));
|
result |= chyx.bitC(x, 1 << idx, 1 << (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 / (128 / Math.PI));
|
return Math.sin((x * PI) / 128);
|
||||||
},
|
},
|
||||||
/*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 / (128 / Math.PI));
|
return Math.cos((x * PI) / 128);
|
||||||
},
|
},
|
||||||
/*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 / (128 / Math.PI));
|
return Math.tan((x * PI) / 128);
|
||||||
},
|
},
|
||||||
/*converts t into a string composed of it's bits, regex's that*/ regG: function (t, X) {
|
/*converts t into a string composed of its bits; regexes that*/ regG: function (t, X) {
|
||||||
return X.test(t.toString(2));
|
return X.test(t.toString(2));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -877,7 +849,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 || byteBeatHelperFuncs) == null) {
|
if (mathParams == 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);
|
||||||
@@ -910,7 +882,7 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
|
|||||||
|
|
||||||
this.func = getByteBeatFunc(codeText);
|
this.func = getByteBeatFunc(codeText);
|
||||||
};
|
};
|
||||||
this.initialOffset = null;
|
this.initialOffset = 0;
|
||||||
this.t = null;
|
this.t = null;
|
||||||
this.func = null;
|
this.func = null;
|
||||||
}
|
}
|
||||||
@@ -957,18 +929,19 @@ 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 = getParamValue(i, params.detune);
|
const detune = pv(params.detune, i);
|
||||||
const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100);
|
const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
|
||||||
let local_t = (this.t / (sampleRate / 256)) * freq + this.initialOffset;
|
const local_t = scale * freq * this.t + this.initialOffset;
|
||||||
const funcValue = this.func(local_t);
|
const funcValue = this.func(local_t);
|
||||||
let signal = (funcValue & 255) / 127.5 - 1;
|
const signal = (funcValue & 255) / 127.5 - 1;
|
||||||
const out = signal * 0.2;
|
|
||||||
for (let c = 0; c < output.length; c++) {
|
|
||||||
//prevent speaker blowout via clipping if threshold exceeds
|
//prevent speaker blowout via clipping if threshold exceeds
|
||||||
output[c][i] = clamp(out, -0.4, 0.4);
|
const out = clamp(signal * 0.2, -0.4, 0.4);
|
||||||
|
for (let c = 0; c < output.length; c++) {
|
||||||
|
output[c][i] = out;
|
||||||
}
|
}
|
||||||
this.t = this.t + 1;
|
this.t++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true; // keep the audio processing going
|
return true; // keep the audio processing going
|
||||||
@@ -1056,7 +1029,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
|||||||
{ name: 'position', defaultValue: 0, min: 0, max: 1 },
|
{ name: 'position', defaultValue: 0, min: 0, max: 1 },
|
||||||
{ name: 'warp', defaultValue: 0, min: 0, max: 1 },
|
{ name: 'warp', defaultValue: 0, min: 0, max: 1 },
|
||||||
{ name: 'warpMode', defaultValue: 0 },
|
{ name: 'warpMode', defaultValue: 0 },
|
||||||
{ name: 'voices', defaultValue: 1, min: 1 },
|
{ name: 'voices', defaultValue: 1, min: 1, automationRate: 'k-rate' },
|
||||||
{ name: 'panspread', defaultValue: 0.7, min: 0, max: 1 },
|
{ name: 'panspread', defaultValue: 0.7, min: 0, max: 1 },
|
||||||
{ name: 'phaserand', defaultValue: 0, min: 0, max: 1 },
|
{ name: 'phaserand', defaultValue: 0, min: 0, max: 1 },
|
||||||
];
|
];
|
||||||
@@ -1067,7 +1040,6 @@ 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 || {};
|
||||||
@@ -1104,7 +1076,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: Math.round(Math.pow(2, b)) };
|
return { b, n: fround(Math.pow(2, b)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
_warpPhase(phase, amt, mode) {
|
_warpPhase(phase, amt, mode) {
|
||||||
@@ -1130,7 +1102,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 * amt);
|
const syncRatio = Math.pow(16, amt ** 2);
|
||||||
return (phase * syncRatio) % 1;
|
return (phase * syncRatio) % 1;
|
||||||
}
|
}
|
||||||
case WarpMode.QUANT: {
|
case WarpMode.QUANT: {
|
||||||
@@ -1139,8 +1111,8 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
|||||||
}
|
}
|
||||||
case WarpMode.FOLD: {
|
case WarpMode.FOLD: {
|
||||||
const K = 7;
|
const K = 7;
|
||||||
const k = 1 + Math.max(1, Math.round(K * amt));
|
const k = 1 + Math.max(1, fround(K * amt));
|
||||||
return Math.abs(frac(k * phase) - 0.5) * 2;
|
return Math.abs(ffrac(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);
|
||||||
@@ -1150,12 +1122,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(2 * Math.PI * n * phase));
|
return frac(phase + depth * Math.sin(TWO_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(2 * Math.PI * n * phase));
|
return frac(phase + depth * Math.sin(TWO_PI * n * phase));
|
||||||
}
|
}
|
||||||
case WarpMode.CHAOS: {
|
case WarpMode.CHAOS: {
|
||||||
const r = 3.7 + 0.3 * amt;
|
const r = 3.7 + 0.3 * amt;
|
||||||
@@ -1166,7 +1138,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 * d <= n; d += 2) if (n % d === 0) return false;
|
for (let d = 3; d ** 2 <= 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);
|
||||||
@@ -1175,18 +1147,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
|||||||
}
|
}
|
||||||
case WarpMode.BINARY: {
|
case WarpMode.BINARY: {
|
||||||
let { b } = this._toBits(amt, 3);
|
let { b } = this._toBits(amt, 3);
|
||||||
b = Math.round(b);
|
b = fround(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);
|
||||||
@@ -1209,7 +1175,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 + Math.round(2 * amt);
|
const iters = 1 + fround(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);
|
||||||
}
|
}
|
||||||
@@ -1222,7 +1188,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(2 * Math.PI * phase) * amt;
|
const d = 0.5 * Math.sin(TWO_PI * phase) * amt;
|
||||||
return frac(phase + d);
|
return frac(phase + d);
|
||||||
}
|
}
|
||||||
case WarpMode.FLIP: {
|
case WarpMode.FLIP: {
|
||||||
@@ -1269,6 +1235,7 @@ 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 freqspread = pv(parameters.freqspread, i);
|
||||||
@@ -1278,7 +1245,6 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
|||||||
const frac = 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 panspread = voices > 1 ? clamp(pv(parameters.panspread, i), 0, 1) : 0;
|
||||||
const gain1 = Math.sqrt(0.5 - 0.5 * panspread);
|
const gain1 = Math.sqrt(0.5 - 0.5 * panspread);
|
||||||
@@ -1286,6 +1252,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
|||||||
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;
|
||||||
@@ -1295,8 +1262,8 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
|||||||
gainL = gain2;
|
gainL = gain2;
|
||||||
gainR = gain1;
|
gainR = gain1;
|
||||||
}
|
}
|
||||||
const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, freqspread, n)); // voice detune
|
const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune
|
||||||
const dPhase = fVoice * this.invSR;
|
const dPhase = fVoice * INVSR;
|
||||||
const level = this._chooseMip(dPhase);
|
const level = this._chooseMip(dPhase);
|
||||||
const table = this.tables[level];
|
const table = this.tables[level];
|
||||||
|
|
||||||
@@ -1311,7 +1278,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
|||||||
}
|
}
|
||||||
outL[i] += s * gainL * normalizer;
|
outL[i] += s * gainL * normalizer;
|
||||||
outR[i] += s * gainR * normalizer;
|
outR[i] += s * gainR * normalizer;
|
||||||
this.phase[n] = wrapPhase(this.phase[n] + dPhase);
|
this.phase[n] = ffrac(this.phase[n] + dPhase);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// this is dough, the superdough without dependencies
|
// this is dough, the superdough without dependencies
|
||||||
// @ts-check
|
// @ts-nocheck
|
||||||
// @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);
|
||||||
@@ -151,7 +152,8 @@ export class TwoPoleFilter {
|
|||||||
resonance = Math.max(resonance, 0);
|
resonance = Math.max(resonance, 0);
|
||||||
|
|
||||||
cutoff = Math.min(cutoff, 20000);
|
cutoff = Math.min(cutoff, 20000);
|
||||||
const c = 2 * Math.sin(cutoff * PI_DIV_SR);
|
let 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;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/tidalcycles/strudel.git"
|
"url": "git+https://codeberg.org/uzu/strudel.git"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"tidalcycles",
|
"tidalcycles",
|
||||||
@@ -25,12 +25,15 @@
|
|||||||
"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://github.com/tidalcycles/strudel/issues"
|
"url": "https://codeberg.org/uzu/strudel/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/tidalcycles/strudel#readme",
|
"homepage": "https://codeberg.org/uzu/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": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -25,5 +25,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,5 +38,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,13 +61,6 @@ 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'];
|
||||||
|
|||||||
+45
-41
@@ -5,9 +5,8 @@ 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 { register, _mod, silence, logger, pure, isNote } from '@strudel/core';
|
import { register, _mod, logger, isNote, noteToMidi, removeUndefineds, getAccidentalsOffset } 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';
|
||||||
|
|
||||||
@@ -185,17 +184,15 @@ 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 (but not both)
|
// - Some number of sharps or flats
|
||||||
const match = /^(-?\d+)(#+|b+)?$/.exec(step);
|
const match = /^(-?\d+)([#bsf]*)$/.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]);
|
||||||
// These decorations will determine the semitone offset based on the number of
|
const accidentals = match[2] || '';
|
||||||
// sharps or flats
|
offset = getAccidentalsOffset(accidentals);
|
||||||
const decorations = match[2] || '';
|
|
||||||
offset = decorations[0] === '#' ? decorations.length : -decorations.length;
|
|
||||||
}
|
}
|
||||||
return [asNumber, offset];
|
return [asNumber, offset];
|
||||||
}
|
}
|
||||||
@@ -226,12 +223,15 @@ 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 (but not both) to produce notes outside of the scale.
|
* in the scale as well as sharps or flats 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
|
||||||
@@ -253,8 +253,9 @@ 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) {
|
||||||
@@ -262,44 +263,47 @@ export const scale = register(
|
|||||||
if (Array.isArray(scale)) {
|
if (Array.isArray(scale)) {
|
||||||
scale = scale.flat().join(' ');
|
scale = scale.flat().join(' ');
|
||||||
}
|
}
|
||||||
return (
|
return pat.withHaps((haps) => {
|
||||||
pat
|
haps = haps.map((hap) => {
|
||||||
.fmap((value) => {
|
let hVal = hap.value;
|
||||||
const isObject = typeof value === 'object';
|
const isObject = typeof hVal === 'object';
|
||||||
// The case where the note has been defined via `n` or `pure`
|
// If hVal is a pure value, place it on `n` so that we interpret it as a scale degree
|
||||||
if (!isObject || (isObject && ('n' in value || 'value' in value))) {
|
hVal = isObject ? hVal : { n: hVal };
|
||||||
const step = isObject ? (value.n ?? value.value) : value;
|
const { note, n, value, ...otherValues } = hVal;
|
||||||
delete value.n; // remove n so it won't cause trouble
|
const noteOrStep = note ?? n ?? value;
|
||||||
if (isNote(step)) {
|
if (noteOrStep === undefined) {
|
||||||
// legacy..
|
logger(
|
||||||
return pure(step);
|
`[tonal] Invalid value format for 'scale'. Value must contain n, note, or value but received keys [${Object.keys(hVal).join(', ')}]`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
return hap; // pass the value through unchanged
|
||||||
}
|
}
|
||||||
try {
|
let scaleNote;
|
||||||
const [number, offset] = _convertStepToNumberAndOffset(step);
|
if (isNote(noteOrStep)) {
|
||||||
let note;
|
// Note case (quantize to scale)
|
||||||
if (isObject && value.anchor) {
|
scaleNote = _getNearestScaleNote(scale, noteOrStep);
|
||||||
note = stepInNamedScale(number, scale, value.anchor);
|
hap.value = { ...otherValues, note: scaleNote };
|
||||||
} else {
|
} else {
|
||||||
note = scaleStep(number, scale);
|
// Step case (convert to note in scale)
|
||||||
|
try {
|
||||||
|
const [number, offset] = _convertStepToNumberAndOffset(noteOrStep);
|
||||||
|
if (otherValues.anchor) {
|
||||||
|
scaleNote = stepInNamedScale(number, scale, otherValues.anchor);
|
||||||
|
} else {
|
||||||
|
scaleNote = scaleStep(number, scale);
|
||||||
}
|
}
|
||||||
if (offset != 0) note = Note.transpose(note, Interval.fromSemitones(offset));
|
if (offset != 0) scaleNote = Note.transpose(scaleNote, Interval.fromSemitones(offset));
|
||||||
value = pure(isObject ? { ...value, note } : note);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger(`[tonal] ${err.message}`, 'error');
|
logger(`[tonal] ${err.message}`, 'error');
|
||||||
return silence;
|
return; // will be removed
|
||||||
}
|
}
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
// The case where the note has been defined via `note`
|
hap.value = isObject ? { ...otherValues, note: scaleNote } : scaleNote;
|
||||||
else {
|
// Tag with scale for downsteam scale-aware operations
|
||||||
const note = _getNearestScaleNote(scale, value.note);
|
return hap.setContext({ ...hap.context, scale });
|
||||||
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
|
||||||
|
|||||||
@@ -39,5 +39,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('play').addEventListener('stop',
|
document.getElementById('stop').addEventListener('click',
|
||||||
() => hush()
|
() => hush()
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -43,5 +43,8 @@
|
|||||||
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,5 +40,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ 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){
|
||||||
|
|||||||
@@ -840,6 +840,31 @@ exports[`runs examples > example "amp" example index 0 1`] = `
|
|||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`runs examples > example "anchor" example index 0 1`] = `
|
||||||
|
[
|
||||||
|
"[ 0/1 → 1/1 | note:E2 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:C3 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:E3 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:G3 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:C4 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:C3 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:G3 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:C4 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:E4 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:G4 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:E3 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:C4 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:E4 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:G4 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:C5 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:C4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:G4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:C5 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:E5 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:G5 ]",
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`runs examples > example "apply" example index 0 1`] = `
|
exports[`runs examples > example "apply" example index 0 1`] = `
|
||||||
[
|
[
|
||||||
"[ 0/1 → 1/1 | note:C3 ]",
|
"[ 0/1 → 1/1 | note:C3 ]",
|
||||||
@@ -1053,10 +1078,10 @@ exports[`runs examples > example "begin" example index 0 1`] = `
|
|||||||
|
|
||||||
exports[`runs examples > example "berlin" example index 0 1`] = `
|
exports[`runs examples > example "berlin" example index 0 1`] = `
|
||||||
[
|
[
|
||||||
"[ 0/1 → 1/16 | note:A3 ]",
|
"[ 0/1 → 1/16 | note:D3 ]",
|
||||||
"[ 1/16 → 1/8 | note:Bb3 ]",
|
"[ 1/16 → 1/8 | note:E3 ]",
|
||||||
"[ 1/8 → 3/16 | note:C4 ]",
|
"[ 1/8 → 3/16 | note:F3 ]",
|
||||||
"[ 3/16 → 1/4 | note:D4 ]",
|
"[ 3/16 → 1/4 | note:G3 ]",
|
||||||
"[ 1/4 → 5/16 | note:A3 ]",
|
"[ 1/4 → 5/16 | note:A3 ]",
|
||||||
"[ 5/16 → 3/8 | note:C4 ]",
|
"[ 5/16 → 3/8 | note:C4 ]",
|
||||||
"[ 3/8 → 7/16 | note:D4 ]",
|
"[ 3/8 → 7/16 | note:D4 ]",
|
||||||
@@ -1847,6 +1872,31 @@ exports[`runs examples > example "chop" example index 0 1`] = `
|
|||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`runs examples > example "chord" example index 0 1`] = `
|
||||||
|
[
|
||||||
|
"[ 0/1 → 1/1 | note:A3 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:C4 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:E4 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:A4 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:C5 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:E3 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:C4 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:E4 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:G4 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:C5 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:D3 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:A3 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:D4 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:Gb4 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:A4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:F3 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:C4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:F4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:A4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:C5 ]",
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`runs examples > example "chorus" example index 0 1`] = `
|
exports[`runs examples > example "chorus" example index 0 1`] = `
|
||||||
[
|
[
|
||||||
"[ 0/1 → 1/4 | note:d s:sawtooth chorus:0.5 ]",
|
"[ 0/1 → 1/4 | note:d s:sawtooth chorus:0.5 ]",
|
||||||
@@ -2739,6 +2789,23 @@ exports[`runs examples > example "detune" example index 0 1`] = `
|
|||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`runs examples > example "dictionary" example index 0 1`] = `
|
||||||
|
[
|
||||||
|
"[ 0/1 → 1/1 | note:A3 room:0.5 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:C4 room:0.5 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:E4 room:0.5 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:G3 room:0.5 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:C4 room:0.5 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:E4 room:0.5 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:A3 room:0.5 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:D4 room:0.5 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:Gb4 room:0.5 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:A3 room:0.5 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:C4 room:0.5 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:F4 room:0.5 ]",
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`runs examples > example "distort" example index 0 1`] = `
|
exports[`runs examples > example "distort" example index 0 1`] = `
|
||||||
[
|
[
|
||||||
"[ 0/1 → 1/8 | s:hh distort:0 ]",
|
"[ 0/1 → 1/8 | s:hh distort:0 ]",
|
||||||
@@ -3838,8 +3905,8 @@ exports[`runs examples > example "fast" example index 0 1`] = `
|
|||||||
|
|
||||||
exports[`runs examples > example "fastChunk" example index 0 1`] = `
|
exports[`runs examples > example "fastChunk" example index 0 1`] = `
|
||||||
[
|
[
|
||||||
"[ 0/1 → 1/4 | color:red note:0 ]",
|
"[ 0/1 → 1/4 | note:C2 color:red ]",
|
||||||
"[ 1/4 → 1/2 | color:red note:1 ]",
|
"[ 1/4 → 1/2 | note:D2 color:red ]",
|
||||||
"[ 1/2 → 3/4 | note:E2 ]",
|
"[ 1/2 → 3/4 | note:E2 ]",
|
||||||
"[ 3/4 → 1/1 | note:F2 ]",
|
"[ 3/4 → 1/1 | note:F2 ]",
|
||||||
"[ 1/1 → 5/4 | note:G2 ]",
|
"[ 1/1 → 5/4 | note:G2 ]",
|
||||||
@@ -3848,8 +3915,8 @@ exports[`runs examples > example "fastChunk" example index 0 1`] = `
|
|||||||
"[ 7/4 → 2/1 | note:C3 ]",
|
"[ 7/4 → 2/1 | note:C3 ]",
|
||||||
"[ 2/1 → 9/4 | note:D3 ]",
|
"[ 2/1 → 9/4 | note:D3 ]",
|
||||||
"[ 9/4 → 5/2 | note:D2 ]",
|
"[ 9/4 → 5/2 | note:D2 ]",
|
||||||
"[ 5/2 → 11/4 | color:red note:2 ]",
|
"[ 5/2 → 11/4 | note:E2 color:red ]",
|
||||||
"[ 11/4 → 3/1 | color:red note:3 ]",
|
"[ 11/4 → 3/1 | note:F2 color:red ]",
|
||||||
"[ 3/1 → 13/4 | note:G2 ]",
|
"[ 3/1 → 13/4 | note:G2 ]",
|
||||||
"[ 13/4 → 7/2 | note:A2 ]",
|
"[ 13/4 → 7/2 | note:A2 ]",
|
||||||
"[ 7/2 → 15/4 | note:B2 ]",
|
"[ 7/2 → 15/4 | note:B2 ]",
|
||||||
@@ -6496,6 +6563,28 @@ exports[`runs examples > example "miditouch" example index 0 1`] = `
|
|||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`runs examples > example "mode" example index 0 1`] = `
|
||||||
|
[
|
||||||
|
"[ 0/1 → 1/1 | note:E3 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:C4 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:E4 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:G4 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:C5 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:C5 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:G5 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:C6 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:E6 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:E3 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:C4 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:E4 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:G4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:C5 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:G5 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:C6 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:E6 ]",
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`runs examples > example "morph" example index 0 1`] = `
|
exports[`runs examples > example "morph" example index 0 1`] = `
|
||||||
[
|
[
|
||||||
"[ 0/1 → 1/8 | s:hh ]",
|
"[ 0/1 → 1/8 | s:hh ]",
|
||||||
@@ -6814,6 +6903,31 @@ exports[`runs examples > example "octave" example index 0 1`] = `
|
|||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`runs examples > example "octaves" example index 0 1`] = `
|
||||||
|
[
|
||||||
|
"[ 0/1 → 1/1 | note:A3 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:C4 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:E4 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:A4 ]",
|
||||||
|
"[ 0/1 → 1/1 | note:C5 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:E3 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:C4 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:E4 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:G4 ]",
|
||||||
|
"[ 1/1 → 2/1 | note:C5 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:D3 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:A3 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:D4 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:Gb4 ]",
|
||||||
|
"[ 2/1 → 3/1 | note:A4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:F3 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:C4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:F4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:A4 ]",
|
||||||
|
"[ 3/1 → 4/1 | note:C5 ]",
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`runs examples > example "off" example index 0 1`] = `
|
exports[`runs examples > example "off" example index 0 1`] = `
|
||||||
[
|
[
|
||||||
"[ -5/24 ⇜ (0/1 → 1/8) | note:62 ]",
|
"[ -5/24 ⇜ (0/1 → 1/8) | note:62 ]",
|
||||||
@@ -6847,6 +6961,15 @@ exports[`runs examples > example "off" example index 0 1`] = `
|
|||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`runs examples > example "offset" example index 0 1`] = `
|
||||||
|
[
|
||||||
|
"[ 0/1 → 1/1 | chord:Am offset:0 ]",
|
||||||
|
"[ 1/1 → 2/1 | chord:C offset:1 ]",
|
||||||
|
"[ 2/1 → 3/1 | chord:D offset:2 ]",
|
||||||
|
"[ 3/1 → 4/1 | chord:F offset:3 ]",
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`runs examples > example "often" example index 0 1`] = `
|
exports[`runs examples > example "often" example index 0 1`] = `
|
||||||
[
|
[
|
||||||
"[ 0/1 → 1/8 | s:hh speed:0.5 ]",
|
"[ 0/1 → 1/8 | s:hh speed:0.5 ]",
|
||||||
@@ -7150,18 +7273,18 @@ exports[`runs examples > example "penv" example index 0 1`] = `
|
|||||||
|
|
||||||
exports[`runs examples > example "perlin" example index 0 1`] = `
|
exports[`runs examples > example "perlin" example index 0 1`] = `
|
||||||
[
|
[
|
||||||
"[ 0/1 → 1/8 | s:hh cutoff:4250 ]",
|
"[ 0/1 → 1/8 | s:hh cutoff:500 ]",
|
||||||
"[ 0/1 → 1/4 | s:bd cutoff:4250 ]",
|
"[ 0/1 → 1/4 | s:bd cutoff:500 ]",
|
||||||
"[ 1/8 → 1/4 | s:hh cutoff:4252.352717325493 ]",
|
"[ 1/8 → 1/4 | s:hh cutoff:562.5486401770559 ]",
|
||||||
"[ 1/4 → 3/8 | s:hh cutoff:4265.171895756794 ]",
|
"[ 1/4 → 3/8 | s:hh cutoff:903.3554895067937 ]",
|
||||||
"[ 1/4 → 1/2 | s:bd cutoff:4265.171895756794 ]",
|
"[ 1/4 → 1/2 | s:bd cutoff:903.3554895067937 ]",
|
||||||
"[ 3/8 → 1/2 | s:hh cutoff:4290.3361308769945 ]",
|
"[ 3/8 → 1/2 | s:hh cutoff:1572.364329119182 ]",
|
||||||
"[ 1/2 → 5/8 | s:hh cutoff:4323.283119127154 ]",
|
"[ 1/2 → 5/8 | s:hh cutoff:2448.2831191271544 ]",
|
||||||
"[ 1/2 → 3/4 | s:bd cutoff:4323.283119127154 ]",
|
"[ 1/2 → 3/4 | s:bd cutoff:2448.2831191271544 ]",
|
||||||
"[ 5/8 → 3/4 | s:hh cutoff:4356.230107377314 ]",
|
"[ 5/8 → 3/4 | s:hh cutoff:3324.2019091351267 ]",
|
||||||
"[ 3/4 → 7/8 | s:hh cutoff:4381.394342497515 ]",
|
"[ 3/4 → 7/8 | s:hh cutoff:3993.210748747515 ]",
|
||||||
"[ 3/4 → 1/1 | s:bd cutoff:4381.394342497515 ]",
|
"[ 3/4 → 1/1 | s:bd cutoff:3993.210748747515 ]",
|
||||||
"[ 7/8 → 1/1 | s:hh cutoff:4394.213520928815 ]",
|
"[ 7/8 → 1/1 | s:hh cutoff:4334.017598077253 ]",
|
||||||
"[ 1/1 → 9/8 | s:hh cutoff:4396.566238254309 ]",
|
"[ 1/1 → 9/8 | s:hh cutoff:4396.566238254309 ]",
|
||||||
"[ 1/1 → 5/4 | s:bd cutoff:4396.566238254309 ]",
|
"[ 1/1 → 5/4 | s:bd cutoff:4396.566238254309 ]",
|
||||||
"[ 9/8 → 5/4 | s:hh cutoff:4449.536839055554 ]",
|
"[ 9/8 → 5/4 | s:hh cutoff:4449.536839055554 ]",
|
||||||
@@ -9214,6 +9337,43 @@ exports[`runs examples > example "scale" example index 4 1`] = `
|
|||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`runs examples > example "scale" example index 5 1`] = `
|
||||||
|
[
|
||||||
|
"[ 0/1 → 1/8 | note:C3 ]",
|
||||||
|
"[ 1/8 → 1/4 | note:C3 ]",
|
||||||
|
"[ 1/4 → 3/8 | note:D3 ]",
|
||||||
|
"[ 3/8 → 1/2 | note:Eb3 ]",
|
||||||
|
"[ 1/2 → 5/8 | note:E3 ]",
|
||||||
|
"[ 5/8 → 3/4 | note:G3 ]",
|
||||||
|
"[ 3/4 → 7/8 | note:A3 ]",
|
||||||
|
"[ 7/8 → 1/1 | note:C4 ]",
|
||||||
|
"[ 1/1 → 9/8 | note:C3 ]",
|
||||||
|
"[ 9/8 → 5/4 | note:C3 ]",
|
||||||
|
"[ 5/4 → 11/8 | note:D3 ]",
|
||||||
|
"[ 11/8 → 3/2 | note:Eb3 ]",
|
||||||
|
"[ 3/2 → 13/8 | note:E3 ]",
|
||||||
|
"[ 13/8 → 7/4 | note:G3 ]",
|
||||||
|
"[ 7/4 → 15/8 | note:A3 ]",
|
||||||
|
"[ 15/8 → 2/1 | note:C4 ]",
|
||||||
|
"[ 2/1 → 17/8 | note:C3 ]",
|
||||||
|
"[ 17/8 → 9/4 | note:C3 ]",
|
||||||
|
"[ 9/4 → 19/8 | note:D3 ]",
|
||||||
|
"[ 19/8 → 5/2 | note:Eb3 ]",
|
||||||
|
"[ 5/2 → 21/8 | note:E3 ]",
|
||||||
|
"[ 21/8 → 11/4 | note:G3 ]",
|
||||||
|
"[ 11/4 → 23/8 | note:A3 ]",
|
||||||
|
"[ 23/8 → 3/1 | note:C4 ]",
|
||||||
|
"[ 3/1 → 25/8 | note:C3 ]",
|
||||||
|
"[ 25/8 → 13/4 | note:C3 ]",
|
||||||
|
"[ 13/4 → 27/8 | note:D3 ]",
|
||||||
|
"[ 27/8 → 7/2 | note:Eb3 ]",
|
||||||
|
"[ 7/2 → 29/8 | note:E3 ]",
|
||||||
|
"[ 29/8 → 15/4 | note:G3 ]",
|
||||||
|
"[ 15/4 → 31/8 | note:A3 ]",
|
||||||
|
"[ 31/8 → 4/1 | note:C4 ]",
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`runs examples > example "scaleTranspose" example index 0 1`] = `
|
exports[`runs examples > example "scaleTranspose" example index 0 1`] = `
|
||||||
[
|
[
|
||||||
"[ 0/1 → 1/2 | note:C3 ]",
|
"[ 0/1 → 1/2 | note:C3 ]",
|
||||||
|
|||||||
+6350
-6348
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -4,11 +4,25 @@ 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);
|
||||||
expect(haps).toMatchSnapshot();
|
const normalized = haps.map((hap) => roundFloatStrings(hap));
|
||||||
|
expect(normalized).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csv": "^6.3.11"
|
"csv": "^6.3.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,5 +75,8 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ You can also create custom aliases for existing sounds using the `soundAlias` fu
|
|||||||
|
|
||||||
<MiniRepl
|
<MiniRepl
|
||||||
client:idle
|
client:idle
|
||||||
tune={`soundAlias("RolandTR808_bd", "kick")
|
tune={`soundAlias('RolandTR808_bd', 'kick')
|
||||||
s("kick")`}
|
s("kick")`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ There are 3 quick ways to embed strudel in your website:
|
|||||||
|
|
||||||
### Inside an iframe
|
### Inside an iframe
|
||||||
|
|
||||||
Using an iframe is the most easy way to embed a studel tune.
|
Using an iframe is the most easy way to embed a strudel tune.
|
||||||
You can embed any pattern of your choice via an iframe and the URL of the pattern of your choice:
|
You can embed any pattern of your choice via an iframe and the URL of the pattern of your choice:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
@@ -133,7 +133,7 @@ If you'd rather use your own UI, you can use the `@strudel/web` package:
|
|||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
For more info on this package, see the [@strudel/web README]https://codeberg.org/uzu/strudel/src/branch/main/packages/web#strudel-web).
|
For more info on this package, see the [@strudel/web README](https://codeberg.org/uzu/strudel/src/branch/main/packages/web#strudel-web).
|
||||||
|
|
||||||
## Via npm
|
## Via npm
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ Here's an example AST for `c3 [e3 g3]`
|
|||||||
|
|
||||||
which translates to `seq(c3, seq(e3, g3))`
|
which translates to `seq(c3, seq(e3, g3))`
|
||||||
|
|
||||||
|
## Vim Keybindings
|
||||||
|
|
||||||
|
See the separate page on Vim shortcuts for a quick reference: [/technical-manual/vim](/technical-manual/vim)
|
||||||
|
|
||||||
## Scheduling Events
|
## Scheduling Events
|
||||||
|
|
||||||
After an instance of `Pattern` is obtained from the user code,
|
After an instance of `Pattern` is obtained from the user code,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Vim Shortcuts
|
||||||
|
layout: ../../layouts/MainLayout.astro
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vim Shortcuts in the REPL
|
||||||
|
|
||||||
|
When the REPL editor (CodeMirror) is configured to use Vim keybindings, the following commands are available:
|
||||||
|
|
||||||
|
- :w — Evaluate the current code
|
||||||
|
|
||||||
|
- Triggers the same evaluation as Ctrl+Enter / Alt+Enter
|
||||||
|
- You'll see messages in the Console panel such as:
|
||||||
|
- [vim] :w — evaluating code
|
||||||
|
- [repl] evaluate via event
|
||||||
|
- [eval] code updated
|
||||||
|
|
||||||
|
- :q — Stop/pause playback
|
||||||
|
|
||||||
|
- Triggers the same stop action as Alt+.
|
||||||
|
- Useful to quickly stop scheduling without leaving Vim mode
|
||||||
|
|
||||||
|
- gc — Toggle line comments for the current selection(s)
|
||||||
|
|
||||||
|
- Works in normal and visual mode
|
||||||
|
- If there's a selection, all selected lines are toggled
|
||||||
|
|
||||||
|
Notes
|
||||||
|
|
||||||
|
- Behavior respects the current language mode in the editor for comment syntax.
|
||||||
|
- If multiple REPL editors are open, commands target the active editor. The implementation dispatches custom events handled by the editor.
|
||||||
|
- If you don't see the Console panel, open the right panel in the REPL UI.
|
||||||
|
|
||||||
|
Troubleshooting
|
||||||
|
|
||||||
|
- If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback.
|
||||||
|
- For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again.
|
||||||
@@ -79,13 +79,11 @@ const updateCodeWindow = (context, patternData, reset = false) => {
|
|||||||
context.handleUpdate(patternData, reset);
|
context.handleUpdate(patternData, reset);
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoResetPatternOnChange = !isUdels();
|
|
||||||
|
|
||||||
function UserPatterns({ context }) {
|
function UserPatterns({ context }) {
|
||||||
const activePattern = useActivePattern();
|
const activePattern = useActivePattern();
|
||||||
const viewingPatternStore = useViewingPatternData();
|
const viewingPatternStore = useViewingPatternData();
|
||||||
const viewingPatternData = parseJSON(viewingPatternStore);
|
const viewingPatternData = parseJSON(viewingPatternStore);
|
||||||
const { userPatterns, patternFilter } = useSettings();
|
const { userPatterns, patternFilter, patternAutoStart } = useSettings();
|
||||||
const viewingPatternID = viewingPatternData?.id;
|
const viewingPatternID = viewingPatternData?.id;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 flex-grow overflow-hidden h-full pb-2 ">
|
<div className="flex flex-col gap-2 flex-grow overflow-hidden h-full pb-2 ">
|
||||||
@@ -135,13 +133,13 @@ function UserPatterns({ context }) {
|
|||||||
<div className="overflow-auto h-full bg-background p-2 rounded-md">
|
<div className="overflow-auto h-full bg-background p-2 rounded-md">
|
||||||
{/* {patternFilter === patternFilterName.user && ( */}
|
{/* {patternFilter === patternFilterName.user && ( */}
|
||||||
<PatternButtons
|
<PatternButtons
|
||||||
onClick={(id) =>
|
onClick={(id) => {
|
||||||
updateCodeWindow(
|
updateCodeWindow(context, { ...userPatterns[id], collection: userPattern.collection }, patternAutoStart);
|
||||||
context,
|
|
||||||
{ ...userPatterns[id], collection: userPattern.collection },
|
if (context.started && activePattern === id) {
|
||||||
autoResetPatternOnChange,
|
context.handleEvaluate();
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
patterns={userPatterns}
|
patterns={userPatterns}
|
||||||
started={context.started}
|
started={context.started}
|
||||||
activePattern={activePattern}
|
activePattern={activePattern}
|
||||||
@@ -188,17 +186,14 @@ function FeaturedPatterns({ context }) {
|
|||||||
const examplePatterns = useExamplePatterns();
|
const examplePatterns = useExamplePatterns();
|
||||||
const collections = examplePatterns.collections;
|
const collections = examplePatterns.collections;
|
||||||
const patterns = collections.get(patternFilterName.featured);
|
const patterns = collections.get(patternFilterName.featured);
|
||||||
|
const { patternAutoStart } = useSettings();
|
||||||
return (
|
return (
|
||||||
<PatternPageWithPagination
|
<PatternPageWithPagination
|
||||||
patterns={patterns}
|
patterns={patterns}
|
||||||
context={context}
|
context={context}
|
||||||
initialPage={featuredPageNum}
|
initialPage={featuredPageNum}
|
||||||
patternOnClick={(id) => {
|
patternOnClick={(id) => {
|
||||||
updateCodeWindow(
|
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.featured }, patternAutoStart);
|
||||||
context,
|
|
||||||
{ ...patterns[id], collection: patternFilterName.featured },
|
|
||||||
autoResetPatternOnChange,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
paginationOnChange={async (pageNum) => {
|
paginationOnChange={async (pageNum) => {
|
||||||
await loadAndSetFeaturedPatterns(pageNum - 1);
|
await loadAndSetFeaturedPatterns(pageNum - 1);
|
||||||
@@ -213,13 +208,14 @@ function LatestPatterns({ context }) {
|
|||||||
const examplePatterns = useExamplePatterns();
|
const examplePatterns = useExamplePatterns();
|
||||||
const collections = examplePatterns.collections;
|
const collections = examplePatterns.collections;
|
||||||
const patterns = collections.get(patternFilterName.public);
|
const patterns = collections.get(patternFilterName.public);
|
||||||
|
const { patternAutoStart } = useSettings();
|
||||||
return (
|
return (
|
||||||
<PatternPageWithPagination
|
<PatternPageWithPagination
|
||||||
patterns={patterns}
|
patterns={patterns}
|
||||||
context={context}
|
context={context}
|
||||||
initialPage={latestPageNum}
|
initialPage={latestPageNum}
|
||||||
patternOnClick={(id) => {
|
patternOnClick={(id) => {
|
||||||
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, autoResetPatternOnChange);
|
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, patternAutoStart);
|
||||||
}}
|
}}
|
||||||
paginationOnChange={async (pageNum) => {
|
paginationOnChange={async (pageNum) => {
|
||||||
await loadAndSetPublicPatterns(pageNum - 1);
|
await loadAndSetPublicPatterns(pageNum - 1);
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export function SettingsTab({ started }) {
|
|||||||
multiChannelOrbits,
|
multiChannelOrbits,
|
||||||
isTabIndentationEnabled,
|
isTabIndentationEnabled,
|
||||||
isMultiCursorEnabled,
|
isMultiCursorEnabled,
|
||||||
|
patternAutoStart,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
const shouldAlwaysSync = isUdels();
|
const shouldAlwaysSync = isUdels();
|
||||||
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
||||||
@@ -304,6 +305,11 @@ export function SettingsTab({ started }) {
|
|||||||
onChange={(cbEvent) => settingsMap.setKey('isCSSAnimationDisabled', cbEvent.target.checked)}
|
onChange={(cbEvent) => settingsMap.setKey('isCSSAnimationDisabled', cbEvent.target.checked)}
|
||||||
value={isCSSAnimationDisabled}
|
value={isCSSAnimationDisabled}
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Auto-start pattern on pattern change"
|
||||||
|
onChange={(cbEvent) => settingsMap.setKey('patternAutoStart', cbEvent.target.checked)}
|
||||||
|
value={patternAutoStart}
|
||||||
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
|
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
|
||||||
<FormItem label="Reset Settings">
|
<FormItem label="Reset Settings">
|
||||||
|
|||||||
@@ -62,11 +62,9 @@ export function SoundsTab() {
|
|||||||
|
|
||||||
// stop current sound on mouseup
|
// stop current sound on mouseup
|
||||||
useEvent('mouseup', () => {
|
useEvent('mouseup', () => {
|
||||||
const t = trigRef.current;
|
const ref = trigRef.current;
|
||||||
trigRef.current = undefined;
|
trigRef.current = undefined;
|
||||||
t?.then((ref) => {
|
ref?.stop?.(getAudioContext().currentTime + 0.01);
|
||||||
ref?.stop(getAudioContext().currentTime + 0.01);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div id="sounds-tab" className="px-4 flex gap-2 flex-col w-full h-full text-foreground">
|
<div id="sounds-tab" className="px-4 flex gap-2 flex-col w-full h-full text-foreground">
|
||||||
@@ -124,12 +122,19 @@ export function SoundsTab() {
|
|||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
};
|
};
|
||||||
soundPreviewIdx++;
|
soundPreviewIdx++;
|
||||||
const time = ctx.currentTime + 0.05;
|
|
||||||
const onended = () => trigRef.current?.node?.disconnect();
|
const onended = () => trigRef.current?.node?.disconnect();
|
||||||
trigRef.current = Promise.resolve(onTrigger(time, params, onended));
|
try {
|
||||||
trigRef.current.then((ref) => {
|
// Pre-load the sample by calling onTrigger with a future time
|
||||||
connectToDestination(ref?.node);
|
// This triggers the loading but schedules playback for later
|
||||||
});
|
const time = ctx.currentTime + 0.5; // Give 500ms for loading
|
||||||
|
const ref = await onTrigger(time, params, onended);
|
||||||
|
trigRef.current = ref;
|
||||||
|
if (ref?.node) {
|
||||||
|
connectToDestination(ref.node);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to trigger sound:', err);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{' '}
|
{' '}
|
||||||
|
|||||||
@@ -92,11 +92,7 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
|
|||||||
|
|
||||||
async function blobToDataUrl(blob) {
|
async function blobToDataUrl(blob) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
var reader = new FileReader();
|
resolve(URL.createObjectURL(blob));
|
||||||
reader.onload = function (event) {
|
|
||||||
resolve(event.target.result);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ export function useSettings() {
|
|||||||
isPanelOpen: parseBoolean(state.isPanelOpen),
|
isPanelOpen: parseBoolean(state.isPanelOpen),
|
||||||
userPatterns: userPatterns,
|
userPatterns: userPatterns,
|
||||||
multiChannelOrbits: parseBoolean(state.multiChannelOrbits),
|
multiChannelOrbits: parseBoolean(state.multiChannelOrbits),
|
||||||
|
patternAutoStart: isUdels()
|
||||||
|
? false
|
||||||
|
: state.patternAutoStart === undefined
|
||||||
|
? true
|
||||||
|
: parseBoolean(state.patternAutoStart),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user