Skip to content

Signal Chain

@vessel-dsp/chain is the headless audio signal chain engine. A SignalChain holds an InputProfileNode first, user nodes in the middle, and a MasterNode last. It does not open an AudioContext, touch the DOM, or own a device; the host drives it block by block.

It composes the other simulation packages: RuntimeNode wraps a compiled .vdsp pedal Program from @vessel-dsp/compiler running on @vessel-dsp/runtime, and NamNode plays .nam captures through a vendored WebAssembly engine.

@vessel-dsp/chain 0.1.0 is not yet on npm, and neither are its @vessel-dsp/compiler and @vessel-dsp/runtime 0.1.0 dependencies. Until those releases are published, use the package from this repository’s workspace rather than an install command that will 404.

input -> input-profile -> [user nodes] -> master -> output

The diagram shows the same order. The three user nodes in it, a pedal, an amp and a cabinet, are one example set. A chain can hold any number of user nodes in any order, and the input profile and the master are the only fixed ones.

SignalChain node order An input block passes through the input profile node, then the user nodes (pedal, amp, cabinet), then the master node, and comes out as an output block. The input profile is always first and the master is always last. Input Float64Array Input profile input-profile pickup type, input impedance, input trim guitar cable length and capacitance set the resonance Pedal circuit-runtime RuntimeNode: a compiled .vdsp pedal the compiler's Program, solved by the runtime Amp amp-shaper or nam AmpShaperNode: tanh waveshaper + tone stack NamNode: a .nam capture on the WASM engine the host passes the engine bytes Cabinet cabinet-ir CabinetIrNode: zero-latency FFT convolution low/high cut, optional IR resampling Master master MasterNode: volume, mute, soft limiter user nodes (example) Output Float64Array SignalChain.process(block) takes and returns a Float64Array. The host drives it block by block; there is no AudioContext and no DOM.
Node order in a SignalChain. The highlighted nodes are fixed: the input profile is always first and the master is always last. The pedal, amp and cabinet between them are an example: you add, insert, move, or remove user nodes, and they run in the order you set.

getNodes() returns [inputProfile, ...userNodes, master]. User nodes run in the order they were added or reordered. The chain-level edit API is:

  • addNode(node) appends, or replaces a node with the same id (and calls dispose() on the node it drops);
  • insertNode(node, index), moveNode(id, targetIndex), reorderNodes(orderedIds), removeNode(id), clearNodes();
  • getNode(id), getNodes(), getEffectNodes(), and length;
  • prepare(sampleRate), reset(), process(input);
  • getPreset(name), loadPreset(preset, nodeFactory?), toJson(name), and SignalChain.fromJson(json, options?).

Nodes may implement dispose() to release resources that outlive a block, such as a WASM instance. The chain calls it when it drops the node.

import {
AmpShaperNode,
CabinetIrNode,
SignalChain,
} from "@vessel-dsp/chain";
const chain = new SignalChain({
sampleRate: 48000,
inputProfile: { pickupType: "single-coil", guitarCableLengthMeters: 3 },
});
chain.addNode(new AmpShaperNode("amp", "Amp", { gain: 1.5 }));
chain.addNode(new CabinetIrNode("cab", "Cabinet"));
chain.master.setVolumeDb(-12);
const block = new Float64Array(128);
for (let i = 0; i < block.length; i++) {
block[i] = 0.2 * Math.sin((2 * Math.PI * 220 * i) / 48000);
}
const output = chain.process(block);
let peak = 0;
for (const sample of output) peak = Math.max(peak, Math.abs(sample));
console.log("nodes:", chain.getNodes().map((n) => n.kind).join(" -> "));
console.log("block length:", output.length);
console.log("output peak:", peak.toFixed(4));
console.log(
"first three samples:",
Array.from(output.slice(0, 3)).map((v) => v.toFixed(6)).join(", "),
);
nodes: input-profile -> amp-shaper -> cabinet-ir -> master
block length: 128
output peak: 0.7515
first three samples: 0.000000, 0.000000, 0.000070

process() accepts any block length and returns a Float64Array. The default MasterNode has a soft-knee limiter enabled; it is a safety limiter, not a loudness maximizer. MasterNode also has setMuted() and setVolumeDb(). GainNode (gain) is a flat trim.

InputProfileNode (input-profile) is always first. It models the guitar, the cable, and the input load, nothing else.

  • Pickup types: single-coil, humbucker, active, piezo, custom.
  • Input impedance, clamped to 10 kOhm to 10 MOhm.
  • Input trim gain, clamped to -24 dB to +24 dB.
  • A guitar-to-first-pedal cable model: guitarCableLengthMeters (default 3) and cableCapacitancePfPerM (default 100, clamped to 20 to 300) set the pickup and cable resonance f0 = 1 / (2 * pi * sqrt(L * C)), where C is the pickup’s internal capacitance plus the cable capacitance. Patch-cable and amp-cable lengths are not modelled.
import { InputProfileNode } from "@vessel-dsp/chain";
for (const meters of [0, 1, 3, 6, 10]) {
const node = new InputProfileNode({ pickupType: "single-coil" });
node.setGuitarCableLength(meters);
node.prepare(48000);
console.log(`${meters} m -> ${node.getParam("resonantFreqHz")} Hz`);
}
0 m -> 9597 Hz
1 m -> 6946 Hz
3 m -> 4971 Hz
6 m -> 3778 Hz
10 m -> 3021 Hz

For a single-coil, 3 m of cable lands at about 5.0 kHz and 10 m at about 3.0 kHz. Active pickups bypass the resonant filter, and piezos use a fixed resonance.

CabinetIrNode (cabinet-ir) is a zero-latency uniform-partitioned FFT convolution: a direct time-domain head for the first 128 taps plus FFT partitions for the tail. Output is sample-aligned with the input.

  • lowCutHz and highCutHz are 2nd-order Butterworth filters on the wet signal. The defaults, 20 Hz and 20 kHz, are transparent; the filters engage only when the cutoffs move inward.
  • Optional irSampleRate resamples the IR at prepare() with a windowed sinc when it differs from the prepared rate.
  • The built-in default IR is a 128-tap synthetic placeholder, not a real cabinet capture. Pass ir to use your own mono Float64Array.
import { CabinetIrNode } from "@vessel-dsp/chain";
const ir = [1, 0.5, 0.25, 0.125];
const impulse = new Float64Array(4);
impulse[0] = 1;
const transparent = new CabinetIrNode("cab", "Cabinet", { ir });
transparent.prepare(48000);
console.log(
"default (transparent):",
Array.from(transparent.process(impulse)).map((v) => v.toFixed(4)).join(", "),
);
const shaped = new CabinetIrNode("cab-eq", "Cabinet EQ", {
ir,
lowCutHz: 80,
highCutHz: 8000,
});
shaped.prepare(48000);
console.log(
"lowCut 80 Hz / highCut 8 kHz:",
Array.from(shaped.process(impulse)).map((v) => v.toFixed(4)).join(", "),
);
default (transparent): 1.0000, 0.5000, 0.2500, 0.1250
lowCut 80 Hz / highCut 8 kHz: 0.1539, 0.4779, 0.5978, 0.4159

The direct head starts at sample 0, so an impulse comes out as the IR with no leading delay. A 48000-tap IR in 128-sample blocks runs at about 27x real time under Bun in the package’s test harness.

NamNode (nam) plays Neural Amp Modeler .nam captures through NeuralAmpModelerCore compiled to plain WebAssembly. The engine ships in the package under nam-engine/ and is exposed as the subpaths @vessel-dsp/chain/nam-engine.wasm, @vessel-dsp/chain/nam-engine.js, and @vessel-dsp/chain/nam-engine.NOTICE.md.

The host passes the wasm bytes to instantiateNamEngine because an AudioWorkletGlobalScope has no fetch. Instantiation is asynchronous and belongs to the caller; the node itself is synchronous.

Supported architectures are whatever the pinned engine parses: Linear, ConvNet, WaveNet, LSTM, and the A2/slimmable variants. A synthetic ConvNet model (one channel, dilations [1], no batchnorm, ReLU activation) loads through NamNode and processes audio; the node does not inspect the model beyond the sample rate and loudness the engine reports.

  • The engine owns loudness normalisation. It applies NAM’s Normalized output mode with a target of -18 dB inside nam_process; the node adds no loudness gain of its own. getInfo().loudness reports what the engine did.
  • A model whose stated sample rate differs from the chain rate by more than 0.5 Hz is refused with both rates in the error. A model that states no rate is accepted. Nothing resamples a model to fit the chain.
  • Model files are not bundled. .nam captures are third-party artifacts, and the caller supplies the model’s JSON text.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { instantiateNamEngine, NamNode } from "@vessel-dsp/chain";
const model = JSON.stringify({
version: "0.5.0",
architecture: "Linear",
config: {
receptive_field: 4,
bias: false,
in_channels: 1,
out_channels: 1,
implementation: "direct",
},
weights: [0.5, -0.25, 0.125, 0.0625],
metadata: { loudness: -14.0 },
sample_rate: 48000,
});
const factory = (await import("@vessel-dsp/chain/nam-engine.js")).default;
const wasmUrl = import.meta.resolve("@vessel-dsp/chain/nam-engine.wasm");
const engine = await instantiateNamEngine(
readFileSync(fileURLToPath(wasmUrl)),
factory,
);
const node = new NamNode("nam-amp", "NAM Amp", { engine, model });
node.prepare(48000);
const info = node.getInfo();
console.log("expectedSampleRate:", info.expectedSampleRate);
console.log("modelLoudnessDb:", info.loudness.modelLoudnessDb);
console.log("appliedGain:", info.loudness.appliedGain.toFixed(4));
const impulse = new Float64Array(16);
impulse[0] = 1;
const out = node.process(impulse);
console.log(
"first six outputs:",
Array.from(out.slice(0, 6)).map((v) => v.toFixed(6)).join(", "),
);
expectedSampleRate: 48000
modelLoudnessDb: -14
appliedGain: 0.6310
first six outputs: 0.315479, -0.158152, 0.078664, 0.039126, -0.000360, -0.000360

Measured under Bun on a desktop CPU, not in a browser, with 128-sample blocks: a 90 KB WaveNet capture ran at about 18x real time and a 420 KB capture at about 3.9x. Cost depends on the model, so measure the capture you intend to ship.

AmpShaperNode (amp-shaper) is a separate node: a tanh waveshaper plus a tone stack. It is not a NAM model loader and must not be called NAM.

RuntimeNode (circuit-runtime) is the only node that solves a circuit. It wraps a @vessel-dsp/compiler Program running on @vessel-dsp/runtime. It needs an already-compiled program and cannot compile .vdsp by itself.

RuntimeNode can apply a SupplyProfile to the circuit external supply. Pass the .vdsp text as the fourth constructor argument, then call setSupplyProfile. The node resolves once with resolveSupplyStamps and retargets each resolved dc-source stamp through ReferenceRuntime.setSupply. There is no audio-domain supply processing: the old audio-domain waveshaper node was rejected and is not present. Sag is the circuit own current draw through the profile series resistance.

A null openCircuitVolts keeps the pedal own declared supply voltage. The sign always follows the stamp own compiled volts sign read from getSupplies, so a positive-ground germanium rail stays negative.

Built-in profiles in SUPPLY_PROFILES (values from Jack Orman, 9v Battery Impedance, AMZ-FX Lab Notebook, 2015, http://www.muzique.com/lab/batteryz.htm, method: unloaded reading, then loaded with a 560 ohm resistor, Rint by Ohm law):

  • ideal: null volts, 0 ohm, source definition.
  • alkaline-fresh: null volts, 5.405 ohm. Source: Orman 2015 fresh table, AC-Delco alkaline 5.99 and 4.82 ohm; the value is their mean.
  • zinc-carbon-fresh: null volts, 25.925 ohm. Source: Orman 2015 fresh table, Sunbeam Heavy Duty 25.28 and 26.57 ohm; the value is their mean.
  • alkaline-depleted-specimen: 7.73 V, 195.00 ohm. Source: Orman 2015 used table, Duracell alkaline 6, unloaded 7.73 V, loaded 6.43 V, 195.00 ohm. A single specimen, not a type rating.
  • zinc-carbon-used-specimen: 9.02 V, 78.47 ohm. Source: Orman 2015 used table, Golden Power Heavy Duty 7, unloaded 9.02 V, loaded 7.91 V, 78.47 ohm. A single specimen.

Adapter profiles are deliberately absent. No source was found for regulated 9 V, regulated 18 V, or unregulated AC/DC resistance or voltage.

A measured profile follows the same method:

import { profileFromMeasurement } from "@vessel-dsp/chain";
const cell = profileFromMeasurement({
openCircuitVolts: 9.0,
loadedVolts: 8.0,
loadOhms: 560,
});
console.log(cell.internalResistanceOhms);
70

Rint = (Vopen - Vloaded) / (Vloaded / Rload). Here (9 - 8) / (8 / 560) is 560 / 8, which is 70 ohm.

customSupplyProfile builds a profile from caller-supplied numbers with source caller-supplied. getParams adds supplyOpenCircuitVolts and supplyInternalResistanceOhms once a profile has been applied, and setParam on either re-applies the pair as a custom profile, so a chain preset round-trips the supply setting. reset and prepare keep the applied profile in force. Missing source text or zero resolved supplies throws with the refusal reason codes and rail ids.

Example with big-muff-pi.vdsp from the corpus, compiled with pedalPartCatalog (read-only, not a committed test):

import { readFileSync } from "node:fs";
import { compile, pedalPartCatalog } from "@vessel-dsp/compiler";
import { RuntimeNode, SUPPLY_PROFILES } from "@vessel-dsp/chain";
const source = readFileSync("big-muff-pi.vdsp", "utf8");
const program = (() => {
const result = compile(source, { registry: pedalPartCatalog });
if (result.status !== "ok") throw new Error("compile failed");
return result.program;
})();
function rms(values: Float64Array): number {
let sum = 0;
for (const v of values) sum += v * v;
return Math.sqrt(sum / values.length);
}
const input = new Float64Array(4800);
for (let i = 0; i < input.length; i++) {
input[i] = 0.3 * Math.sin((2 * Math.PI * 440 * i) / 48000);
}
for (const id of ["ideal", "alkaline-depleted-specimen"] as const) {
const profile = SUPPLY_PROFILES.find((p) => p.id === id)!;
const node = new RuntimeNode("muff", "Big Muff", program, { source });
node.prepare(48000);
node.setSupplyProfile(profile);
node.process(input);
console.log(`${id} rms: ${rms(node.process(input)).toFixed(5)}`);
}
ideal rms: 0.20087
alkaline-depleted-specimen rms: 0.19471

Limits, stated plainly:

  • It uses the TypeScript reference runtime. The WebAssembly console needs the same setter in its own repo and is not done.
  • Op-amp stages draw almost no supply current in the model, so sag on op-amp-heavy pedals is under-reported.
  • The model source can absorb current a real cell cannot, so a hard clipper can push the rail up.
  • A single series resistance is a DC approximation (Orman’s own AC table shows impedance falls with frequency).
  • Mains-fed amps are refused by design.

getPreset() captures the input profile, each user node’s params, and the master config. toJson() serialises it, and SignalChain.fromJson() restores it. A nam snapshot is deliberately not recreated by the default factory, because a preset carries neither the engine nor the model; pass a nodeFactory when the host can supply both.

import {
AmpShaperNode,
CabinetIrNode,
SignalChain,
} from "@vessel-dsp/chain";
const chain = new SignalChain({ sampleRate: 48000 });
chain.inputProfile.setGuitarCableLength(6);
chain.addNode(new AmpShaperNode("amp", "Amp", { gain: 2 }));
chain.addNode(new CabinetIrNode("cab", "Cabinet", { highCutHz: 8000 }));
chain.master.setVolumeDb(-3);
const preset = chain.getPreset("My Rig");
console.log("preset nodes:", preset.nodes.map((n) => n.id).join(", "));
console.log("preset cable length:", preset.inputProfile.guitarCableLengthMeters);
const json = chain.toJson("My Rig");
const restored = SignalChain.fromJson(json, { sampleRate: 48000 });
console.log(
"restored nodes:",
restored.getNodes().map((n) => n.kind).join(" -> "),
);
console.log(
"restored cable length:",
restored.inputProfile.getParam("guitarCableLengthMeters"),
);
preset nodes: amp, cab
preset cable length: 6
restored nodes: input-profile -> amp-shaper -> cabinet-ir -> master
restored cable length: 6
  • The old audio-domain waveshaper supply node was rejected and is not present. There is no audio-domain supply processing. Supply profiles move the rail inside the solver; see Power supply above.
  • NAM calibrated mode is not implemented. It needs an input calibration level that .nam models do not carry.
  • NAM models are not resampled. A rate mismatch is refused instead.
  • Patch-cable and amp-cable lengths are not modelled. Only the guitar-to-first- pedal cable is.
  • AmpShaperNode is a single static waveshaper with no oversampling.
  • The built-in cabinet IR is a synthetic placeholder, not a real cabinet.