move kabelsalat to separate package + add registerChainEffect in

superdough to load it
This commit is contained in:
Felix Roos
2026-10-12 18:11:00 +02:00
parent 6448bd0956
commit c492e21e1d
10 changed files with 231 additions and 118 deletions
+41
View File
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,43 @@
{
"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
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,83 @@
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,8 +36,6 @@
"vite-plugin-bundle-audioworklet": "workspace:*"
},
"dependencies": {
"@kabelsalat/lib": "^0.4.1",
"@kabelsalat/web": "^0.4.1",
"nanostores": "^0.11.3"
},
"engines": {
+20 -29
View File
@@ -16,7 +16,6 @@ import {
gainNode,
getCompressor,
getDistortion,
getFrequencyFromValue,
getLfo,
getWorklet,
releaseAudioNode,
@@ -63,6 +62,11 @@ 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) {
@@ -260,14 +264,6 @@ 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 {
@@ -314,7 +310,6 @@ export async function initAudio(options = {}) {
} catch (err) {
console.warn('could not load AudioWorklet effects', err);
}
await initKabelsalat();
logger('[superdough] ready');
}
let audioReady;
@@ -450,14 +445,6 @@ 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: {} };
@@ -633,17 +620,21 @@ 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);
// 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 });
// 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);
}
}
if (fx.stretch !== undefined) {
-81
View File
@@ -5,9 +5,6 @@
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;
@@ -1492,81 +1489,3 @@ 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);
+22 -6
View File
@@ -401,6 +401,25 @@ 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':
@@ -601,12 +620,6 @@ 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
@@ -848,6 +861,9 @@ 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,6 +50,7 @@
"@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",
+1
View File
@@ -86,6 +86,7 @@ export function loadModules() {
import('@strudel/mqtt'),
import('@strudel/mondo'),
import('@strudel/dough'),
import('@strudel/kabelsalat'),
];
if (isTauri()) {
modules = modules.concat([