diff --git a/packages/kabelsalat/kabelsalat.mjs b/packages/kabelsalat/kabelsalat.mjs new file mode 100644 index 000000000..51cecdf59 --- /dev/null +++ b/packages/kabelsalat/kabelsalat.mjs @@ -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; +}); diff --git a/packages/kabelsalat/package.json b/packages/kabelsalat/package.json new file mode 100644 index 000000000..12e0e4338 --- /dev/null +++ b/packages/kabelsalat/package.json @@ -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 ", + "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" + } +} diff --git a/packages/kabelsalat/vite.config.js b/packages/kabelsalat/vite.config.js new file mode 100644 index 000000000..9cc0536b1 --- /dev/null +++ b/packages/kabelsalat/vite.config.js @@ -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', + }, +}); diff --git a/packages/kabelsalat/worklet.mjs b/packages/kabelsalat/worklet.mjs new file mode 100644 index 000000000..741af79f5 --- /dev/null +++ b/packages/kabelsalat/worklet.mjs @@ -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); diff --git a/packages/superdough/package.json b/packages/superdough/package.json index ce17ab5e5..535afbece 100644 --- a/packages/superdough/package.json +++ b/packages/superdough/package.json @@ -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": { diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 3209e7098..24ae2f83b 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -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) { diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 6afd7a0a3..345949c89 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9930ef857..26fcd59a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/website/package.json b/website/package.json index 3bc8b7bce..c12e33d0a 100644 --- a/website/package.json +++ b/website/package.json @@ -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", diff --git a/website/src/repl/util.mjs b/website/src/repl/util.mjs index bd1a85f24..45ca89056 100644 --- a/website/src/repl/util.mjs +++ b/website/src/repl/util.mjs @@ -86,6 +86,7 @@ export function loadModules() { import('@strudel/mqtt'), import('@strudel/mondo'), import('@strudel/dough'), + import('@strudel/kabelsalat'), ]; if (isTauri()) { modules = modules.concat([