Compare commits

..

23 Commits

Author SHA1 Message Date
Jade (Rose) Rowland 5bc3b5e9a7 Merge branch 'main' into show12 2026-10-15 14:56:24 -04:00
froos 173a1654c8 Merge pull request 'fix hs2js + tidal package' (#2149) from fix-tidal-package into main
Reviewed-on: https://codeberg.org/uzu/strudel/pulls/2149
2026-10-15 17:32:01 +02:00
Felix Roos dedd687a1f Merge remote-tracking branch 'origin/main' into fix-tidal-package 2026-10-15 16:12:59 +02:00
Jade (Rose) Rowland 2d7f918876 Merge branch 'main' into show12 2026-10-14 20:34:22 -04:00
Felix Roos d385ceba39 fix tidal package wasm url for vite build 2026-10-12 18:28:41 +02:00
Jade (Rose) Rowland 9258857e36 Merge branch 'main' into show12
Resolves conflicts in packages/superdough/sampler.mjs (merged imports and
getSampleInfo/getDuration additions from main) and website/src/settings.mjs
(non-overlapping additions auto-merged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01559pA7fMAMyY9H91PaewXz
2026-09-14 02:35:56 -04:00
Jade (Rose) Rowland ad7e301394 repeat charactar 2026-07-30 12:13:47 +01:00
Jade (Rose) Rowland 7acb9a3ac7 patterntaborder 2026-04-09 17:26:37 -07:00
Jade (Rose) Rowland 9665b233a1 sort by pattern string 2026-04-03 23:41:46 -04:00
Jade (Rose) Rowland 6c67851909 fix merge conflict 2026-02-16 01:42:51 -05:00
Jade (Rose) Rowland 109ea3093b Merge branch 'soloshortcut' into show5 2026-02-11 19:56:37 -05:00
Jade (Rose) Rowland 8d991ad8ae Merge branch 'sample_note' into show5 2026-02-11 19:53:20 -05:00
Jade (Rose) Rowland 0b69ce21c7 jsdoc 2026-02-08 21:11:10 -05:00
Jade (Rose) Rowland 4f05674cb3 working 2026-02-08 20:32:59 -05:00
Jade (Rose) Rowland feab3f5c86 freq 2026-02-08 19:25:10 -05:00
Jade (Rose) Rowland 90c05ec38a fix edge cases 2026-02-08 14:12:21 -05:00
Jade (Rose) Rowland 7e0eed87cb normalize shortcuts 2026-01-27 17:21:36 -05:00
Jade (Rose) Rowland 1a1197f67a Merge branch 'main' into soloshortcut 2026-01-22 21:11:31 -05:00
Jade (Rose) Rowland 3c5afb32f3 scroll to center 2026-01-22 02:04:27 -05:00
Jade (Rose) Rowland 76cbd23859 Merge branch 'soloshortcut' of ssh://codeberg.org/uzu/strudel into soloshortcut 2026-01-22 00:25:41 -05:00
Jade (Rose) Rowland b436ae789c rm dead code 2026-01-22 00:25:28 -05:00
Switch Angel AKA Jade Rose 275731afc7 Merge branch 'main' into soloshortcut 2026-01-22 06:22:17 +01:00
Jade (Rose) Rowland 5cb2214d88 working 2026-01-22 00:20:44 -05:00
21 changed files with 502 additions and 286 deletions
+79 -6
View File
@@ -11,6 +11,7 @@ import {
keymap,
lineNumbers,
} from '@codemirror/view';
import {repeatCharKeymap} from './repeatcharacter.mjs';
import { persistentAtom } from '@nanostores/persistent';
import { registerControl, repl } from '@strudel/core';
import { cleanupDraw, Drawer } from '@strudel/draw';
@@ -24,7 +25,12 @@ import { sliderPlugin, updateSliderWidgets } from './slider.mjs';
import { activateTheme, initTheme, theme } from './themes.mjs';
import { isTooltipEnabled } from './tooltip.mjs';
import { updateWidgets, widgetPlugin } from './widget.mjs';
import { jumpToCharacter } from './labelJump.mjs';
import {
deleteAllInlineBeforeCharacter,
InsertCharBeforeChar,
jumpToCharacter,
jumpToNextCharacter,
} from './labelJump.mjs';
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
@@ -44,9 +50,9 @@ export const extensions = {
isMultiCursorEnabled: (on) =>
on
? [
EditorState.allowMultipleSelections.of(true),
EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey),
]
EditorState.allowMultipleSelections.of(true),
EditorView.clickAddsSelectionRange.of((ev) => ev.metaKey || ev.ctrlKey),
]
: [],
};
export const compartments = Object.fromEntries(Object.keys(extensions).map((key) => [key, new Compartment()]));
@@ -74,6 +80,10 @@ export const codemirrorSettings = persistentAtom('codemirror-settings', defaultS
decode: JSON.parse,
});
const ANON_LABEL = '$';
const SOLO_LABEL = 'S';
const MUTE_LABEL = '_';
// https://codemirror.net/docs/guide/
export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, root, mondo }) {
const settings = codemirrorSettings.get();
@@ -101,6 +111,7 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((v) => onChange(v)),
drawSelection({ cursorBlinkRate: 0 }),
repeatCharKeymap,
Prec.highest(
keymap.of([
{
@@ -122,12 +133,74 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, roo
},
{
key: 'Alt-w',
run: (view) => jumpToCharacter(view, '$', 1),
run: (view) => jumpToNextCharacter(view, ANON_LABEL, 1),
},
{
key: 'Alt-q',
run: (view) => jumpToCharacter(view, '$', -1),
run: (view) => {
return jumpToNextCharacter(view, ANON_LABEL, -1);
},
},
// clear all muted
{
key: `Alt-Ctrl-0`,
run: (view) => {
return deleteAllInlineBeforeCharacter(view, MUTE_LABEL + ANON_LABEL);
},
},
// clear all solod
{
key: `Alt-Shift-0`,
run: (view) => {
return deleteAllInlineBeforeCharacter(view, SOLO_LABEL + ANON_LABEL);
},
},
// clear all solo and mute
{
key: `Ctrl-Shift-0`,
run: (view) => {
return deleteAllInlineBeforeCharacter(view, ANON_LABEL);
},
},
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Alt-${num}`,
run: (view) => {
return jumpToCharacter(view, ANON_LABEL, i);
},
};
}),
// handle solo toggles 1-9
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Alt-Shift-${num}`,
run: (view) => {
return InsertCharBeforeChar(view, ANON_LABEL, SOLO_LABEL, i);
},
};
}),
// handle mute toggles 1-9
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Alt-Ctrl-${num}`,
run: (view) => {
return InsertCharBeforeChar(view, ANON_LABEL, MUTE_LABEL, i);
},
};
}),
// Handle clearing mutes and solos 1-9
...Array.from({ length: 9 }).map((_, i) => {
let num = i + 1;
return {
key: `Ctrl-Shift-${num}`,
run: (view) => {
return InsertCharBeforeChar(view, ANON_LABEL, '', i);
},
};
}),
/* {
key: 'Ctrl-Shift-.',
run: () => (onPanic ? onPanic() : onStop?.()),
+133 -11
View File
@@ -1,18 +1,54 @@
import { EditorSelection } from '@codemirror/state';
import { SearchCursor } from '@codemirror/search';
import { EditorView } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
export function jumpToCharacter(view, character, direction = 1) {
/**
* gets all of the positions of a character in a document, excluding commented out lines
* @param { EditorState} state
* @param {String} character
* @returns {number[]}
*/
function getCharacterPositions(state, character) {
const cursor = new SearchCursor(state.doc, character);
const characterPositions = [];
while (!cursor.next().done) {
const linestartpos = state.doc.lineAt(cursor.value.to).from
if (!isLineCommentedOut(state, linestartpos)) {
characterPositions.push(cursor.value.to);
}
}
return characterPositions;
}
function isLineCommentedOut(state, pos) {
const line = state.doc.lineAt(pos);
// remove white space
pos = line.from + line.text.search(/\S/)
const tree = syntaxTree(state);
const node = tree.resolveInner(pos, 1)
return node.name.includes("Comment")
}
/**
* jump to the next character in a document
* @param {EditorView} view
* @param {String} character
* @param {number} direction 0 or 1
* @returns {boolean}
*/
export function jumpToNextCharacter(view, character, direction = 1) {
const { state, dispatch } = view;
const pos = state.selection.main.head;
const cursor = new SearchCursor(state.doc, character);
let characterPositions = [];
let jumpPos;
while (!cursor.next().done) {
characterPositions.push(cursor.value.to);
}
const characterPositions = getCharacterPositions(state, character);
if (!characterPositions.length) {
return false;
return true;
}
if (direction > 0) {
jumpPos = characterPositions.find((x) => x > pos + 1) ?? characterPositions.at(0); // Loop back around for convenience
@@ -21,11 +57,97 @@ export function jumpToCharacter(view, character, direction = 1) {
}
if (jumpPos == null) {
return false;
return true;
}
const selection = EditorSelection.cursor(jumpPos - 1);
dispatch({
selection: EditorSelection.cursor(jumpPos - 1),
scrollIntoView: true,
selection,
effects: EditorView.scrollIntoView(
selection.head,
{ y: "start" }
)
});
return true;
}
/**
*
* @param {EditorView} view
* @param {String} character
* @param {number} index the instance of the character
* @returns {true}
*/
export function jumpToCharacter(view, character, index) {
const { state, dispatch } = view;
const characterPositions = getCharacterPositions(state, character);
const pos = characterPositions.at(index) ?? characterPositions.at(-1);
if (pos == null) {
return true;
}
const selection = EditorSelection.cursor(pos - 1);
dispatch({
selection,
effects: EditorView.scrollIntoView(
selection.head,
{ y: "start" }
)
});
return true;
}
/**
*
* @param {EditorView} view
* @param {String} character
* @returns {true}
*/
export function deleteAllInlineBeforeCharacter(view, character) {
const { state, dispatch } = view;
const characterPositions = getCharacterPositions(state, character);
const changes = [];
characterPositions.forEach((pos) => {
const line = state.doc.lineAt(pos);
if (state.doc.sliceString(line.from, line.from + 2) === COMMENT_STRING) {
return;
}
changes.push({
from: line.from,
to: pos - 1,
insert: '',
});
});
dispatch({ changes });
return true;
}
/**
*
* @param {EditorView} view
* @param {String} character
* @param {String} character2
* @param {number} index
* @returns {true}
*/
export function InsertCharBeforeChar(view, character, character2, index) {
const { state, dispatch } = view;
const changes = [];
const characterPositions = getCharacterPositions(state, character);
const labelpos = characterPositions.at(index) ?? characterPositions.at(-1);
const line = state.doc.lineAt(labelpos);
//delete preceeding characters
changes.push({
from: line.from,
to: labelpos - 1,
insert: '',
});
changes.push({
insert: character2,
from: line.from,
});
dispatch({ changes });
return true;
}
+55
View File
@@ -0,0 +1,55 @@
import { Compartment } from "@codemirror/state";
import { keymap } from "@codemirror/view";
const repeatMode = new Compartment();
function repeatPreviousChar(times) {
return ({ state, dispatch }) => {
const { from, empty } = state.selection.main;
if (!empty || from === 0) return false;
const prevChar = state.doc.sliceString(from - 1, from);
const text = Array(times).fill(prevChar).join(" ");
dispatch(state.update({
changes: {
from,
insert: " " + text
}
}));
return true;
};
}
function exitRepeatMode(view) {
view.dispatch({
effects: repeatMode.reconfigure([])
});
}
const digitBindings = Array.from({ length: 9 }, (_, i) => ({
key: String(i + 1),
run(view) {
repeatPreviousChar(i + 1)(view);
exitRepeatMode(view);
return true;
}
}));
export const repeatCharKeymap = [
repeatMode.of([]),
keymap.of([
{
key: "Alt-r",
run(view) {
view.dispatch({
effects: repeatMode.reconfigure(keymap.of(digitBindings))
});
return true;
}
}
])
];
+2 -2
View File
@@ -29,10 +29,10 @@
},
"homepage": "https://codeberg.org/uzu/strudel/",
"dependencies": {
"web-tree-sitter": "^0.24.7"
"web-tree-sitter": "^0.24.7",
"tree-sitter-haskell": "^0.23.1"
},
"devDependencies": {
"tree-sitter-haskell": "^0.23.1",
"vite": "^6.0.11"
},
"engines": {
+9 -3
View File
@@ -1,6 +1,8 @@
import Parser from 'web-tree-sitter';
import treeSitterWasm from 'web-tree-sitter/tree-sitter.wasm?url';
import grammarWasm from 'tree-sitter-haskell/tree-sitter-haskell.wasm?url';
let base = '/';
let base;
export function setBase(path) {
base = path;
}
@@ -10,11 +12,15 @@ let isReady = false,
async function _loadParser() {
await Parser.init({
locateFile(scriptName, scriptDirectory) {
return `${base}${scriptName}`;
if (base) {
return `${base}${scriptName}`;
}
return treeSitterWasm;
},
});
parser = new Parser();
const Lang = await Parser.Language.load(`${base}tree-sitter-haskell.wasm`);
let grammarUrl = base ? `${base}tree-sitter-haskell.wasm` : grammarWasm;
const Lang = await Parser.Language.load(grammarUrl);
parser.setLanguage(Lang);
isReady = true;
return parser;
-41
View File
@@ -1,41 +0,0 @@
import { registerChainEffect, getFrequencyFromValue, getWorklet, logger, registerWorklet } from 'superdough';
import workletUrl from './worklet.mjs?audioworklet';
let kabel;
async function initKabelsalat() {
const { SalatRepl } = await import('@kabelsalat/web');
logger('[kabelsalat] ready');
kabel = new SalatRepl({ localScope: true });
return kabel;
}
initKabelsalat().catch((err) => {
logger(`kabelsalat init failed: ${err}`);
});
const compileKabel = (code) => {
if (!kabel) {
throw new Error('kabelsalat still loading');
}
const node = kabel.evaluate(code);
return node.compile({ log: false });
};
registerWorklet(workletUrl);
registerChainEffect('workletSrc', ({ audioContext, fx, value, chainID, start, gateEnd, releaseEnd }) => {
const workletSrc = fx.workletSrc
.replace(/\bpat\[(\d+)\]/g, (_, i) => fx.workletInputs[i])
.replaceAll('sFreq', getFrequencyFromValue(value))
.replaceAll('sGate', `cc('strudel-gate-${chainID}')`);
const { src, ugens, registers } = compileKabel(workletSrc);
const workletNode = getWorklet(audioContext, 'generic-processor', {}, { outputChannelCount: [2] });
workletNode.port.postMessage({
src,
schema: { ugens, registers },
start,
gateEnd,
end: releaseEnd,
});
return workletNode;
});
-43
View File
@@ -1,43 +0,0 @@
{
"name": "@strudel/kabelsalat",
"version": "1.2.6",
"description": "kabelsalat integration for superdough",
"main": "kabelsalat.mjs",
"type": "module",
"publishConfig": {
"main": "dist/kabelsalat.mjs"
},
"scripts": {
"build": "vite build",
"prepublishOnly": "npm run build"
},
"repository": {
"type": "git",
"url": "git+https://codeberg.org/uzu/strudel.git"
},
"keywords": [
"titdalcycles",
"strudel",
"pattern",
"livecoding",
"algorave"
],
"author": "Felix Roos <flix91@gmail.com>",
"license": "AGPL-3.0-or-later",
"bugs": {
"url": "https://codeberg.org/uzu/strudel/issues"
},
"homepage": "https://codeberg.org/uzu/strudel#readme",
"dependencies": {
"superdough": "workspace:*",
"@kabelsalat/lib": "^0.4.1",
"@kabelsalat/web": "^0.4.1"
},
"devDependencies": {
"vite": "^6.0.11",
"vite-plugin-bundle-audioworklet":"workspace:*"
},
"engines": {
"node": ">=18.0.0"
}
}
-20
View File
@@ -1,20 +0,0 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
import { dependencies } from './package.json';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [bundleAudioWorkletPlugin()],
build: {
lib: {
entry: resolve(__dirname, 'kabelsalat.mjs'),
formats: ['es'],
fileName: (ext) => ({ es: 'kabelsalat.mjs' })[ext],
},
rollupOptions: {
external: [...Object.keys(dependencies)],
},
target: 'esnext',
},
});
-83
View File
@@ -1,83 +0,0 @@
import * as ugens from '@kabelsalat/lib/src/ugens.js';
const blockSize = 128;
const UGENS = new Map(Object.entries(ugens));
class GenericProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.playPos = 0;
const channels = 16;
this.outputs = new Array(channels).fill(0);
this.sources = new Array(channels).fill(0);
this.gateEnded = false;
this.started = false;
this.port.onmessage = (event) => {
let {
src,
schema: { ugens, registers },
start,
gateEnd,
end,
} = event.data;
this.start = start;
this.gateEnd = gateEnd;
this.end = end;
this.registers = new Array(registers).fill(0);
this.src = `o.fill(0); // reset outputs\n${src}`;
this.nodes = [];
for (let i = 0; i < ugens.length; i++) {
const ugen = ugens[i];
const nodeClass = UGENS.get(ugen.type);
const node = new nodeClass(i, ugen, sampleRate);
if (node.type === 'cc' && ugen.inputs?.[0]?.includes('strudel-gate')) {
node.setValue(1);
this.gateNode = node;
}
this.nodes[i] = node;
}
this.genSample = new Function(
'time',
'nodes',
'input',
'r', // registers
'o', // outputs
's', // sources
this.src,
);
};
}
process(inputs, outputs) {
const input = inputs[0]?.[0];
if (currentTime >= this.end) {
return false;
} else if (this.genSample === undefined || currentTime < this.start) {
// pending
return true;
}
this.started = true;
if (!this.gateEnded && currentTime > this.gateEnd) {
this.gateNode?.setValue(0);
this.gateEnded = true;
}
const output = outputs[0];
const outL = output[0];
const outR = output[1];
for (let n = 0; n < blockSize; n++) {
this.genSample(this.playPos, this.nodes, input ? input[n] : 0, this.registers, this.outputs, this.sources);
const left = this.outputs[0];
const right = this.outputs[1];
// Spread to stereo if possible; else mixdown to mono
if (outR) {
outL[n] = left;
outR[n] = right;
} else {
outL[n] = 0.5 * (left + right);
}
this.playPos += 1 / sampleRate;
}
return true;
}
}
registerProcessor('generic-processor', GenericProcessor);
+2
View File
@@ -36,6 +36,8 @@
"vite-plugin-bundle-audioworklet": "workspace:*"
},
"dependencies": {
"@kabelsalat/lib": "^0.4.1",
"@kabelsalat/web": "^0.4.1",
"nanostores": "^0.11.3"
},
"engines": {
+30 -4
View File
@@ -1,4 +1,4 @@
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
import { BASE_MIDI_NOTE, getBaseURL, getCommonSampleInfo, noteToFreq, noteToMidi } from './util.mjs';
import { registerSound, registerWaveTable, soundMap } from './index.mjs';
import { getAudioContext } from './audioContext.mjs';
import {
@@ -14,6 +14,13 @@ import { logger } from './logger.mjs';
const bufferCache = {}; // string: Promise<ArrayBuffer>
const loadCache = {}; // string: Promise<ArrayBuffer>
/**
*
* @typedef {Object} SampleMetaData
* @property {string} url
* @property {midi} number
*/
export const getCachedBuffer = (url) => bufferCache[url];
function humanFileSize(bytes, si) {
@@ -56,8 +63,9 @@ export const getDur = getDuration;
export function getSampleInfo(hapValue, bank) {
const { speed = 1.0 } = hapValue;
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
const { transpose, url, index, midi, label, baseFrequency } = getCommonSampleInfo(hapValue, bank);
const playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
return { transpose, url, index, midi, label, playbackRate };
}
@@ -180,7 +188,12 @@ export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '')
if (baseUrl.startsWith('github:')) {
baseUrl = githubPath(baseUrl, '');
}
const fullUrl = (v) => baseUrl + v;
/**
*
* @param {string} v
* @returns {SampleMetaData}
*/
const fullUrl = (v) => ({ url: baseUrl + v, midi: extractMidiNoteFromString(v) });
if (Array.isArray(value)) {
//return [key, value.map(replaceUrl)];
value = value.map(fullUrl);
@@ -391,3 +404,16 @@ export function registerSampleSource(key, bank, params) {
registerSample(key, bank, params);
}
}
export function extractMidiNoteFromString(str) {
const regex = /_([a-gA-G])([#b])?([1-9])?\b/;
const match = str.match(regex);
if (match == null) {
return BASE_MIDI_NOTE;
}
const base = match[1].toUpperCase();
const accidental = match[2] ?? '';
const octave_str = match[3] ?? '';
const parsedVal = base + accidental + octave_str;
return noteToMidi(parsedVal);
}
+29 -20
View File
@@ -16,6 +16,7 @@ import {
gainNode,
getCompressor,
getDistortion,
getFrequencyFromValue,
getLfo,
getWorklet,
releaseAudioNode,
@@ -62,11 +63,6 @@ export function registerSound(key, onTrigger, data = {}) {
soundMap.setKey(key, { onTrigger, data });
}
const chainEffects = {};
export function registerChainEffect(name, effect) {
chainEffects[name] = effect;
}
let gainCurveFunc = (val) => val;
export function applyGainCurve(val) {
@@ -264,6 +260,14 @@ export function loadWorklets() {
return workletsLoading;
}
let kabel;
async function initKabelsalat() {
const { SalatRepl } = await import('@kabelsalat/web');
logger('[kabelsalat] ready');
kabel = new SalatRepl({ localScope: true });
return kabel;
}
// this function should be called on first user interaction (to avoid console warning)
export async function initAudio(options = {}) {
const {
@@ -310,6 +314,7 @@ export async function initAudio(options = {}) {
} catch (err) {
console.warn('could not load AudioWorklet effects', err);
}
await initKabelsalat();
logger('[superdough] ready');
}
let audioReady;
@@ -445,6 +450,14 @@ class Chain {
}
}
const compileKabel = (code) => {
if (!kabel) {
throw new Error('kabelsalat not loaded');
}
const node = kabel.evaluate(code);
return node.compile({ log: false });
};
export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
// mapping from main FX and numbered FX chains to nodes
const nodes = { main: {} };
@@ -620,21 +633,17 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
// custom chain effect sink (used for kabelsalat)
for (let key in chainEffects) {
const effect = chainEffects[key];
if (fx[key] !== undefined) {
const node = effect({
audioContext: ac,
fx,
chainID,
value,
start: t,
gateEnd: end,
releaseEnd: endWithRelease,
});
node && chain.connect(node);
}
// Kabelsalat
if (fx.workletSrc !== undefined) {
const workletNode = getWorklet(ac, 'generic-processor', {}, { outputChannelCount: [2] });
chain.connect(workletNode);
const workletSrc = fx.workletSrc
.replace(/\bpat\[(\d+)\]/g, (_, i) => fx.workletInputs[i])
.replaceAll('sFreq', getFrequencyFromValue(value))
.replaceAll('sGate', `cc('strudel-gate-${chainID}')`);
/* global compileKabel */
const { src, ugens, registers } = compileKabel(workletSrc);
workletNode.port.postMessage({ src, schema: { ugens, registers }, start: t, gateEnd: end, end: endWithRelease });
}
if (fx.stretch !== undefined) {
+42 -10
View File
@@ -19,7 +19,12 @@ const accs = { '#': 1, b: -1, s: 1, f: -1 };
export const getAccidentalsOffset = (accidentals) => {
return accidentals?.split('').reduce((o, char) => o + accs[char], 0) || 0;
};
/**
*
* @param {string} note
* @param {number} defaultOctave
* @returns {number}
*/
export const noteToMidi = (note, defaultOctave = 3) => {
const [pc, acc, oct = defaultOctave] = tokenizeNote(note);
if (!pc) {
@@ -29,6 +34,11 @@ export const noteToMidi = (note, defaultOctave = 3) => {
const offset = getAccidentalsOffset(acc);
return (Number(oct) + 1) * 12 + chroma + offset;
};
/**
*
* @param {number} n
* @returns {number}
*/
export const midiToFreq = (n) => {
return Math.pow(2, (n - 69) / 12) * 440;
};
@@ -38,7 +48,17 @@ export const freqToMidi = (freq) => {
return (12 * Math.log(freq / 440)) / Math.LN2 + 69;
};
export const valueToMidi = (value, fallbackValue) => {
/**
*
* @param {string} note
* @param {number} defaultOctave
* @returns {number}
*/
export const noteToFreq = (note, defaultOctave = 3) => {
return midiToFreq(noteToMidi(note, defaultOctave));
};
function __valueToMidi(value) {
if (typeof value !== 'object') {
throw new Error('valueToMidi: expected object value');
}
@@ -52,10 +72,15 @@ export const valueToMidi = (value, fallbackValue) => {
if (typeof note === 'number') {
return note;
}
if (!fallbackValue) {
return;
}
export const valueToMidi = (value, fallbackValue) => {
const parsedValue = __valueToMidi(value) ?? fallbackValue;
if (parsedValue == null) {
throw new Error('valueToMidi: expected freq or note to be set');
}
return fallbackValue;
return parsedValue;
};
export function nanFallback(value, fallback = 0, silent) {
@@ -84,15 +109,18 @@ export function secondsToCycle(t, cps) {
// deduces relevant info for sample loading from hap.value and sample definition
// it encapsulates the core sampler logic into a pure and synchronous function
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
export const BASE_MIDI_NOTE = 36;
export function getCommonSampleInfo(hapValue, bank) {
const { s, n = 0 } = hapValue;
let midi = valueToMidi(hapValue, 36);
let transpose = midi - 36; // C3 is middle C;
let url;
const maybeMidiNote = __valueToMidi(hapValue);
const midi = maybeMidiNote ?? BASE_MIDI_NOTE;
let transpose = midi - BASE_MIDI_NOTE; // C3 is middle C;
let index = 0;
let samplemeta;
if (Array.isArray(bank)) {
index = getSoundIndex(n, bank.length);
url = bank[index];
samplemeta = bank[index];
} else {
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
// object format will expect keys as notes
@@ -104,10 +132,14 @@ export function getCommonSampleInfo(hapValue, bank) {
);
transpose = -midiDiff(closest); // semitones to repitch
index = getSoundIndex(n, bank[closest].length);
url = bank[closest][index];
samplemeta = bank[closest][index];
}
const label = `${s}:${index}`;
return { transpose, url, index, midi, label };
if (maybeMidiNote != null) {
transpose = transpose + (BASE_MIDI_NOTE - samplemeta.midi);
}
return { transpose, index, midi, label, url: samplemeta.url };
}
/** Selects entries from `source` and renames them via `map`
+81
View File
@@ -5,6 +5,9 @@
import OLAProcessor from './ola-processor';
import FFT from './fft.js';
import { getDistortionAlgorithm } from './helpers.mjs';
import * as ugens from '@kabelsalat/lib/src/ugens.js';
const UGENS = new Map(Object.entries(ugens));
const blockSize = 128;
const PI = Math.PI;
@@ -1489,3 +1492,81 @@ class TransientProcessor extends AudioWorkletProcessor {
}
registerProcessor('transient-processor', TransientProcessor);
class GenericProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.playPos = 0;
const channels = 16;
this.outputs = new Array(channels).fill(0);
this.sources = new Array(channels).fill(0);
this.gateEnded = false;
this.started = false;
this.port.onmessage = (event) => {
let {
src,
schema: { ugens, registers },
start,
gateEnd,
end,
} = event.data;
this.start = start;
this.gateEnd = gateEnd;
this.end = end;
this.registers = new Array(registers).fill(0);
this.src = `o.fill(0); // reset outputs\n${src}`;
this.nodes = [];
for (let i = 0; i < ugens.length; i++) {
const ugen = ugens[i];
const nodeClass = UGENS.get(ugen.type);
const node = new nodeClass(i, ugen, sampleRate);
if (node.type === 'cc' && ugen.inputs?.[0]?.includes('strudel-gate')) {
node.setValue(1);
this.gateNode = node;
}
this.nodes[i] = node;
}
this.genSample = new Function(
'time',
'nodes',
'input',
'r', // registers
'o', // outputs
's', // sources
this.src,
);
};
}
process(inputs, outputs) {
const input = inputs[0]?.[0];
if (currentTime >= this.end) {
return false;
} else if (this.genSample === undefined || currentTime < this.start) {
// pending
return true;
}
this.started = true;
if (!this.gateEnded && currentTime > this.gateEnd) {
this.gateNode?.setValue(0);
this.gateEnded = true;
}
const output = outputs[0];
const outL = output[0];
const outR = output[1];
for (let n = 0; n < blockSize; n++) {
this.genSample(this.playPos, this.nodes, input ? input[n] : 0, this.registers, this.outputs, this.sources);
const left = this.outputs[0];
const right = this.outputs[1];
// Spread to stereo if possible; else mixdown to mono
if (outR) {
outL[n] = left;
outR[n] = right;
} else {
outL[n] = 0.5 * (left + right);
}
this.playPos += 1 / sampleRate;
}
return true;
}
}
registerProcessor('generic-processor', GenericProcessor);
+1
View File
@@ -3,6 +3,7 @@
"version": "0.2.0",
"private": true,
"description": "Experimental Tidal Code interpreter",
"main": "tidal.mjs",
"module": "tidal.mjs",
"repository": {
"type": "git",
+9 -25
View File
@@ -359,13 +359,13 @@ importers:
packages/hs2js:
dependencies:
tree-sitter-haskell:
specifier: ^0.23.1
version: 0.23.1(tree-sitter@0.21.1)
web-tree-sitter:
specifier: ^0.24.7
version: 0.24.7
devDependencies:
tree-sitter-haskell:
specifier: ^0.23.1
version: 0.23.1(tree-sitter@0.21.1)
vite:
specifier: ^6.0.11
version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)
@@ -389,25 +389,6 @@ importers:
specifier: ^6.0.11
version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)
packages/kabelsalat:
dependencies:
'@kabelsalat/lib':
specifier: ^0.4.1
version: 0.4.1
'@kabelsalat/web':
specifier: ^0.4.1
version: 0.4.1
superdough:
specifier: workspace:*
version: link:../superdough
devDependencies:
vite:
specifier: ^6.0.11
version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)
vite-plugin-bundle-audioworklet:
specifier: workspace:*
version: link:../vite-plugin-bundle-audioworklet
packages/midi:
dependencies:
'@strudel/core':
@@ -608,6 +589,12 @@ importers:
packages/superdough:
dependencies:
'@kabelsalat/lib':
specifier: ^0.4.1
version: 0.4.1
'@kabelsalat/web':
specifier: ^0.4.1
version: 0.4.1
nanostores:
specifier: ^0.11.3
version: 0.11.3
@@ -834,9 +821,6 @@ importers:
'@strudel/hydra':
specifier: workspace:*
version: link:../packages/hydra
'@strudel/kabelsalat':
specifier: workspace:*
version: link:../packages/kabelsalat
'@strudel/midi':
specifier: workspace:*
version: link:../packages/midi
-1
View File
@@ -50,7 +50,6 @@
"@strudel/webaudio": "workspace:*",
"@strudel/mondo": "workspace:*",
"@strudel/xen": "workspace:*",
"@strudel/kabelsalat": "workspace:*",
"@supabase/supabase-js": "^2.48.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.0.0",
@@ -9,9 +9,7 @@ import {
userPattern,
} from '../../../user_pattern_utils.mjs';
import { useMemo, useRef } from 'react';
import { getMetadata } from '../../../metadata_parser.js';
import { useExamplePatterns } from '../../useExamplePatterns.jsx';
import { parseJSON, isUdels } from '../../util.mjs';
import { useSettings } from '../../../settings.mjs';
import { ActionButton } from '../button/action-button.jsx';
import { Pagination } from '../pagination/Pagination.jsx';
@@ -20,8 +18,13 @@ import { useDebounce } from '../usedebounce.jsx';
import cx from '@src/cx.mjs';
import { Textbox } from '@src/repl/components/panel/SettingsTab.jsx';
const PATTERN_SORT = {
"NEWEST": "most recent",
"A-Z": "A-Z",
}
export function PatternLabel({ pattern } /* : { pattern: Tables<'code'> } */) {
const meta = useMemo(() => getMetadata(pattern.code), [pattern]);
const meta = pattern.meta
let title = meta.title;
if (title == null) {
@@ -58,6 +61,10 @@ function PatternButtons({ patterns, activePattern, onClick, started }) {
return (
<div className="p-2">
{Object.values(patterns)
.sort((a, b) => {
return (b.meta.title ?? "").localeCompare(a.meta.title ?? "")
})
.reverse()
.map((pattern) => {
const id = pattern.id;
@@ -93,7 +100,7 @@ export function PatternsTab({ context }) {
}
return Object.fromEntries(
Object.entries(userPatterns).filter(([_key, pattern]) => {
const meta = getMetadata(pattern.code);
const meta = pattern.meta;
// Search for specific meta keys
const searchLowercaseTrimmed = search.trim().toLowerCase();
+13 -12
View File
@@ -1,4 +1,4 @@
import { registerSampleSource } from '@strudel/webaudio';
import { extractMidiNoteFromString, registerSampleSource } from '@strudel/webaudio';
import { isAudioFile } from './files.mjs';
import { logger } from '@strudel/core';
@@ -47,7 +47,7 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
Promise.all(
[...soundFiles]
.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }))
.map((soundFile, i) => {
.map((soundFile) => {
const title = soundFile.title;
if (!isAudioFile(title)) {
return;
@@ -58,25 +58,26 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
splitRelativePath[splitRelativePath.length - 2] ?? soundFile.id.split(/\W+/)[0] ?? 'user';
const blob = soundFile.blob;
return blobToDataUrl(blob).then((soundPath) => {
const titlePathMap = sounds.get(parentDirectory) ?? new Map();
return blobToDataUrl(blob).then((path) => {
const sampleInfoMap = sounds.get(parentDirectory) ?? new Map();
const midi = extractMidiNoteFromString(title);
/** @type {import('@strudel/webaudio').SampleMetaData} */
const samplemetadata = { url: path, midi };
sampleInfoMap.set(title, samplemetadata);
titlePathMap.set(title, soundPath);
sounds.set(parentDirectory, titlePathMap);
sounds.set(parentDirectory, sampleInfoMap);
return;
});
}),
)
.then(() => {
sounds.forEach((titlePathMap, key) => {
const value = Array.from(titlePathMap.keys())
sounds.forEach((sampleInfoMap, key) => {
const bank = Array.from(sampleInfoMap.keys())
.sort((a, b) => {
return a.localeCompare(b);
})
.map((title) => titlePathMap.get(title));
registerSampleSource(key, value, { prebake: false });
.map((title) => sampleInfoMap.get(title));
registerSampleSource(key, bank, { prebake: false });
});
logger('imported sounds registered!', 'success');
-1
View File
@@ -89,7 +89,6 @@ export function loadModules() {
import('@strudel/mqtt'),
import('@strudel/mondo'),
import('@strudel/dough'),
import('@strudel/kabelsalat'),
];
if (isTauri()) {
modules = modules.concat([
+6
View File
@@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react';
import { register } from '@strudel/core';
import { isUdels } from './repl/util.mjs';
import { computed } from 'nanostores';
import { getMetadata } from './metadata_parser';
export const audioEngineTargets = {
webaudio: 'webaudio',
@@ -18,6 +19,10 @@ export const soundFilterType = {
ALL: 'all',
};
export const PATTERN_SORT = {
"NEWEST": "most recent",
"A-Z": "A-Z",
}
const initialPrebakeScript = `// Prebake script
//
// This is code that is loaded before your pattern is run.
@@ -85,6 +90,7 @@ export const $settings = computed(settingsMap, (state) => {
Object.keys(userPatterns).forEach((key) => {
const data = userPatterns[key];
data.id = data.id ?? key;
data.meta = getMetadata(data.code)
userPatterns[key] = data;
});
return {