Import kabelsalat, update transpiler to handle K and S, add worklet control

This commit is contained in:
Aria
2026-01-07 12:18:57 -06:00
parent fd472a64d9
commit 5caaf423ff
8 changed files with 311 additions and 8 deletions
+1
View File
@@ -31,6 +31,7 @@
},
"homepage": "https://strudel.cc",
"dependencies": {
"@kabelsalat/web": "^0.4.0",
"fraction.js": "^5.2.1"
},
"gitHead": "0e26d4e741500f5bae35b023608f062a794905c2",
+29
View File
@@ -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);
+8
View File
@@ -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,
});
};
+1
View File
@@ -36,6 +36,7 @@
"vite-plugin-bundle-audioworklet": "workspace:*"
},
"dependencies": {
"@kabelsalat/lib": "^0.4.0",
"nanostores": "^0.11.3"
},
"engines": {
+21 -7
View File
@@ -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);
+73
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;
@@ -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);
+152 -1
View File
@@ -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;
+26
View File
@@ -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