From 5caaf423ffcfc881391143c763e92cb2ccebb5ed Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 7 Jan 2026 12:18:57 -0600 Subject: [PATCH] Import kabelsalat, update transpiler to handle K and S, add worklet control --- packages/core/package.json | 1 + packages/core/pattern.mjs | 29 ++++++ packages/core/repl.mjs | 8 ++ packages/superdough/package.json | 1 + packages/superdough/superdough.mjs | 28 ++++-- packages/superdough/worklets.mjs | 73 ++++++++++++++ packages/transpiler/transpiler.mjs | 153 ++++++++++++++++++++++++++++- pnpm-lock.yaml | 26 +++++ 8 files changed, 311 insertions(+), 8 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index fa0761039..64253b081 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,6 +31,7 @@ }, "homepage": "https://strudel.cc", "dependencies": { + "@kabelsalat/web": "^0.4.0", "fraction.js": "^5.2.1" }, "gitHead": "0e26d4e741500f5bae35b023608f062a794905c2", diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 02cf91881..162de400c 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -3753,3 +3753,32 @@ Pattern.prototype.FX = function (...effects) { return { ...v, FX: currFX.concat(vEff) }; }).appLeft(parray(effects)); }; + +const _asArrayPattern = (pats) => { + const pack = (...xs) => xs; + let acc = pure(curry(pack, null, pats.length)); + for (const p of pats) acc = acc.appLeft(p); + return acc; +}; + +/** + * Creates a worklet effect. Typically derived by writing K(...) in the REPL which will parse + * Kabelsalat code. + * + * @name worklet + * @param {string} src Source code of the worklet update function + * @param {...number | ...Pattern} inputs Worklet inputs + * @memberof Pattern + * @returns Pattern + */ +Pattern.prototype.worklet = function (src, ...inputs) { + inputs = inputs.map(reify); + return this.outerBind((v) => { + return _asArrayPattern(inputs).withValue((vInput) => { + const currInputs = v.workletInputs ?? []; + return { ...v, workletSrc: src, workletInputs: currInputs.concat(vInput) }; + }); + }); +}; + +export const worklet = (...args) => pure(0).worklet(...args); diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 470be47bc..76f89f392 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -6,6 +6,7 @@ import { setTime } from './time.mjs'; import { evalScope } from './evaluate.mjs'; import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; import { reset_state } from './impure.mjs'; +import { SalatRepl } from '@kabelsalat/web'; export function repl({ defaultOutput, @@ -24,6 +25,7 @@ export function repl({ id, mondo = false, }) { + const kabel = new SalatRepl({ localScope: true }); const state = { schedulerError: undefined, evalError: undefined, @@ -78,6 +80,11 @@ export function repl({ return silence; }; + const compileKabel = (code) => { + const node = kabel.evaluate(code); + return node.compile({ log: false }); + }; + // helper to get a patternified pure value out function unpure(pat) { if (pat._Pattern) { @@ -198,6 +205,7 @@ export function repl({ setcps: setCps, setCpm, setcpm: setCpm, + compileKabel, }); }; diff --git a/packages/superdough/package.json b/packages/superdough/package.json index 36201297a..2125b8d98 100644 --- a/packages/superdough/package.json +++ b/packages/superdough/package.json @@ -36,6 +36,7 @@ "vite-plugin-bundle-audioworklet": "workspace:*" }, "dependencies": { + "@kabelsalat/lib": "^0.4.0", "nanostores": "^0.11.3" }, "engines": { diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 33c1e404f..d271c7830 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -15,6 +15,7 @@ import { gainNode, getCompressor, getDistortion, + getFrequencyFromValue, getLfo, getWorklet, releaseAudioNode, @@ -407,9 +408,9 @@ function mapChannelNumbers(channels) { } class Chain { - constructor(head) { - this.audioNodes = [head]; - this.tails = [head]; + constructor() { + this.audioNodes = []; + this.tails = []; } connect(...nodes) { nodes.forEach((node) => { @@ -538,6 +539,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) value.s = s; } + const chain = new Chain(); // connection manager which tracks audio nodes for releasing + // get source AudioNode let sourceNode; if (source) { @@ -578,7 +581,8 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) return; } - const chain = new Chain(sourceNode); // connection manager which tracks audio nodes for releasing + chain.connect(sourceNode); + FX = [...FX, value]; // run through the FX chain and then run through all FX outside of it as well for (let [idx, fx] of Object.entries(FX)) { const key = idx == FX.length - 1 ? 'main' : idx; @@ -596,7 +600,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) delayfeedback = getDefaultValue('delayfeedback'), delaysync = getDefaultValue('delaysync'), delaytime, - stretch = getDefaultValue('stretch'), i = getDefaultValue('i'), } = fx; gain = applyGainCurve(nanFallback(gain, 1)); @@ -607,8 +610,8 @@ 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); - if (stretch !== undefined) { - const phaseVocoder = getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch }); + if (fx.stretch !== undefined) { + const phaseVocoder = getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: fx.stretch }); chain.connect(phaseVocoder); fxNodes['stretch'] = [phaseVocoder]; } @@ -844,6 +847,17 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) filterChain.forEach((f) => chain.connect(f)); chain.audioNodes.push(lfo); } + if (fx.workletSrc !== undefined) { + const workletNode = getWorklet(ac, 'generic-processor', {}); + 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, end }); + } // delay if (key !== 'main' && delay > 0 && delaytime > 0 && delayfeedback > 0) { const dry = gainNode(1); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 8001c7da8..40428db03 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -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; @@ -1478,3 +1481,73 @@ 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, + end, + } = event.data; + this.start = start; + 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 (this.genSample === undefined || currentTime < this.start) { + // pending + return true; + } else if (input === undefined) { + // if no input and started, return false and close worklet + // else just keep waiting (true) + return !this.started; + } + this.started = true; + if (!this.gateEnded && currentTime > this.end) { + this.gateNode?.setValue(0); + this.gateEnded = true; + } + const outL = outputs[0][0]; + const outR = outputs[0][1] ?? outputs[0][0]; + for (let n = 0; n < blockSize; n++) { + this.genSample(this.playPos, this.nodes, input ? input[n] : 0, this.registers, this.outputs, this.sources); + outL[n] = this.outputs[0]; + outR[n] = this.outputs[1]; + this.playPos += 1 / sampleRate; + } + return true; + } +} +registerProcessor('generic-processor', GenericProcessor); diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index fea6bad4d..2aac676dc 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -110,7 +110,55 @@ export function transpiler(input, options = {}) { return this.replace(labelToP(node)); } }, - leave(node, parent, prop, index) {}, + + leave(node, parent, prop, index) { + if (!isKabelCall(node)) return; + + const [expr, ...rest] = node.arguments; + if (!expr) throw new Error('K(...) requires an expression'); + + const { template, patternExprs } = extractPatternPlaceholders(expr); + if (patternExprs.length) { + const workletArgs = [{ type: 'Literal', value: template }, ...patternExprs, ...rest]; + + let callee = node.callee; + if (callee.type === 'ChainExpression') callee = callee.expression; + if (callee.type === 'MemberExpression') { + return this.replace({ + type: 'CallExpression', + callee: workletMemberAst(callee.object), + arguments: workletArgs, + optional: false, + }); + } + return this.replace({ + type: 'CallExpression', + callee: { type: 'Identifier', name: 'worklet' }, + arguments: workletArgs, + optional: false, + }); + } + + const kabelSrc = genExprSource(expr); + const workletArgs = [{ type: 'Literal', value: kabelSrc }, ...rest]; + + let callee = node.callee; + if (callee.type === 'ChainExpression') callee = callee.expression; + if (callee.type === 'MemberExpression') { + return this.replace({ + type: 'CallExpression', + callee: workletMemberAst(callee.object), + arguments: workletArgs, + optional: false, + }); + } + return this.replace({ + type: 'CallExpression', + callee: { type: 'Identifier', name: 'worklet' }, + arguments: workletArgs, + optional: false, + }); + }, }); let { body } = ast; @@ -146,6 +194,109 @@ export function transpiler(input, options = {}) { return { output, miniLocations, widgets }; } +function isKabelCall(node) { + if (node.type !== 'CallExpression') return false; + let callee = node.callee; + if (callee.type === 'ChainExpression') callee = callee.expression; + if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K'; + return callee.type === 'Identifier' && callee.name === 'K'; +} + +function genExprSource(expr) { + return escodegen.generate(expr, { format: { semicolons: false } }); +} + +function extractPatternPlaceholders(expr) { + const templateExpr = cloneNode(expr); + const parents = new Map(); + const targets = []; + + walk(templateExpr, { + enter(node, parent, prop, index) { + parents.set(node, { parent, prop, index }); + if (isStrudelPatternCall(node)) { + targets.push(node); + } + }, + }); + + if (!targets.length) { + return { template: genExprSource(templateExpr), patternExprs: [] }; + } + + targets.sort((a, b) => (a.start ?? 0) - (b.start ?? 0)); + + const patternExprs = targets.map((node) => { + const arg = node.arguments?.[0]; + if (!arg) { + throw new Error('S(...) requires an argument'); + } + return cloneNode(arg); + }); + + let currentExpr = templateExpr; + targets.forEach((node, index) => { + currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr); + }); + + const template = genExprSource(currentExpr); + return { template, patternExprs }; +} + +function isStrudelPatternCall(node) { + if (node.type !== 'CallExpression') { + return false; + } + const callee = node.callee; + if (callee.type === 'Identifier') { + return callee.name === 'S'; + } + if (callee.type === 'MemberExpression' && !callee.computed) { + return callee.property?.name === 'S'; + } + return false; +} + +function placeholderAst(index) { + return { + type: 'MemberExpression', + object: { type: 'Identifier', name: 'pat' }, + property: { type: 'Literal', value: index }, + computed: true, + optional: false, + }; +} + +function replaceNode(node, replacement, parents, currentRoot) { + const info = parents.get(node); + if (!info || !info.parent) { + return replacement; + } + + const { parent, prop, index } = info; + if (Array.isArray(parent[prop])) { + parent[prop][index] = replacement; + } else { + parent[prop] = replacement; + } + parents.set(replacement, { parent, prop, index }); + return currentRoot; +} + +function cloneNode(node) { + return JSON.parse(JSON.stringify(node)); +} + +function workletMemberAst(objectExpr) { + return { + type: 'MemberExpression', + object: objectExpr, + property: { type: 'Identifier', name: 'worklet' }, + computed: false, + optional: false, + }; +} + function isStringWithDoubleQuotes(node, locations, code) { if (node.type !== 'Literal') { return false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23903fcac..2684ce3bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,9 @@ importers: packages/core: dependencies: + '@kabelsalat/web': + specifier: ^0.4.0 + version: 0.4.0 fraction.js: specifier: ^5.2.1 version: 5.2.1 @@ -518,6 +521,9 @@ importers: packages/superdough: dependencies: + '@kabelsalat/lib': + specifier: ^0.4.0 + version: 0.4.0 nanostores: specifier: ^0.11.3 version: 0.11.3 @@ -1969,6 +1975,15 @@ packages: resolution: {integrity: sha512-yYxMVH7Dqw6nO0d5NIV8OQWnitU8k6vXH8NtgqAfIa/IUqRMxRv/NUJJ08VEKbAakwxlgBl5PJdrU0dMPStsnw==} engines: {node: '>=v12.0.0'} + '@kabelsalat/core@0.3.1': + resolution: {integrity: sha512-y2wHZyKnwbhJdGuwXvkaIb5vqbNTW6rMDnBslHGxQHTFX5xYyxJLOcuF2/EOB698t09kOVhmmpLXKbyokf3pOA==} + + '@kabelsalat/lib@0.4.0': + resolution: {integrity: sha512-UoOUhYOFShDjYCTne1edevCHHxSC07FsxG9tQ5GGOOzNoKMUpGVY8Ecq9X1GVyseQFvQBgz3aqAC6K8FPZp4ZQ==} + + '@kabelsalat/web@0.4.0': + resolution: {integrity: sha512-uvcOJjpKf8PG6KrgY0QEpoV4IAbIPDXh73bPtFSdDcXApbkSyEEyeNE8IoqIlqfcjIX926IOCAx9nM6FsFp6DA==} + '@lerna/create@8.1.9': resolution: {integrity: sha512-DPnl5lPX4v49eVxEbJnAizrpMdMTBz1qykZrAbBul9rfgk531v8oAt+Pm6O/rpAleRombNM7FJb5rYGzBJatOQ==} engines: {node: '>=18.0.0'} @@ -9261,6 +9276,17 @@ snapshots: dependencies: lodash: 4.17.21 + '@kabelsalat/core@0.3.1': {} + + '@kabelsalat/lib@0.4.0': + dependencies: + '@kabelsalat/core': 0.3.1 + + '@kabelsalat/web@0.4.0': + dependencies: + '@kabelsalat/core': 0.3.1 + '@kabelsalat/lib': 0.4.0 + '@lerna/create@8.1.9(encoding@0.1.13)(typescript@5.7.3)': dependencies: '@npmcli/arborist': 7.5.4