Audio modules for the browser, implemented as AudioWorklets. Each one is a node
you connect() into a graph you already have, with AudioParams you automate
the way you automate everything else — filling the gaps Web Audio leaves rather
than replacing it.
import { registerAdsrWorklet, AdsrAmp } from "synthlet";
const ac = new AudioContext();
await registerAdsrWorklet(ac);
// Web Audio has no envelope generator. This is one.
const amp = AdsrAmp(ac, {
attack: 0.01,
decay: 0.1,
sustain: 0.7,
release: 0.3,
});
const osc = new OscillatorNode(ac, { frequency: 440 });
osc.start();
osc.connect(amp).connect(ac.destination);
amp.gate.value = 1; // note on
// ...later
amp.gate.value = 0; // note offModules are functions, not classes, so there's no new. They start themselves,
so there's no start(). Everything else is a normal Web Audio node.
Install synthlet for everything:
npm i synthletOr install a single module — each one is a standalone package with no dependencies:
npm i @synthlet/adsrSynthlet nodes connect to each other and to native nodes with the ordinary
connect(). registerAllWorklets registers every module at once and returns
the context, so it chains:
import {
registerAllWorklets,
PolyblepOscillator,
Svf,
SvfType,
AdsrAmp,
} from "synthlet";
const ac = await registerAllWorklets(new AudioContext());
const osc = PolyblepOscillator(ac, { frequency: 110 });
const filter = Svf(ac, { type: SvfType.LowPass, frequency: 1200, Q: 4 });
const amp = AdsrAmp(ac, {
attack: 0.01,
decay: 0.2,
sustain: 0.6,
release: 0.4,
});
osc.connect(filter).connect(amp).connect(ac.destination);
amp.gate.setValueAtTime(1, ac.currentTime);
amp.gate.setValueAtTime(0, ac.currentTime + 0.5);Registration is asynchronous and has to happen before you create anything — an
AudioWorkletProcessor can't fetch its own code, so it must be installed on the
context first. Everything after that is synchronous.
A parameter accepts a node wherever it accepts a number, which is how you modulate:
import {
registerAllWorklets,
Lfo,
LfoType,
PolyblepOscillator,
} from "synthlet";
const ac = await registerAllWorklets(new AudioContext());
const vibrato = Lfo(ac, { type: LfoType.Sine, frequency: 5, gain: 6 });
const osc = PolyblepOscillator(ac, { frequency: 220, detune: vibrato });
osc.connect(ac.destination);MonoSynth and the drums are whole instruments built out of those modules. They
expose their own parameters, and the modules they're made of:
import {
registerDrums,
registerMonoSynth,
MonoSynth,
KickDrum,
} from "synthlet";
// Each compound registers only what it's made of, and they compose
const ac = new AudioContext();
await Promise.all([registerMonoSynth(ac), registerDrums(ac)]);
const synth = MonoSynth(ac, { frequency: 220 });
synth.connect(ac.destination);
synth.gate.setValueAtTime(1, ac.currentTime);
synth.gate.setValueAtTime(0, ac.currentTime + 0.5);
// The modules it's made of are on the synth
synth.osc.frequency.value = 330;
synth.filter.frequency.value = 900;
const kick = KickDrum(ac, { tone: 0.4, decay: 0.6 });
kick.connect(ac.destination);
kick.trigger.setValueAtTime(1, ac.currentTime);
kick.trigger.setValueAtTime(0, ac.currentTime + 0.01);
// dispose() tears down the whole internal graph
synth.dispose();
kick.dispose();trigger and gate are AudioParams, and every gate and trigger in the
library is a-rate: a note lands on the sample it was scheduled for rather than
at the top of the next render block, and two triggers inside one block both
fire. For a one-shot, schedule both edges with setValueAtTime as above —
setting .value to 1 and back to 0 in the same tick leaves nothing for the
worklet to see, because only the last write survives.
MonoSynth and the drums aren't special. Compound is the declaration they're
built on, and it makes a group of modules behave as one: a single node to
connect from, a public surface you choose, and one dispose() that tears down
everything inside.
import { Compound, Param, PolyblepOscillator, Svf, SvfType } from "synthlet";
function Voice(ac: AudioContext) {
const osc = PolyblepOscillator(ac, { frequency: 110 });
const cutoff = Param(ac, { input: 1200 });
const filter = Svf(ac, { type: SvfType.LowPass, frequency: cutoff });
osc.connect(filter);
return Compound({
output: filter,
owns: [osc, cutoff],
exposes: { cutoff: cutoff.input, osc, filter },
});
}
const voice = Voice(ac);
voice.connect(ac.destination);
voice.cutoff.value = 400; // an inlet the compound chose to expose
voice.dispose(); // tears down osc, cutoff and filterowns is what dispose() tears down — the nodes you connected by hand.
Anything passed to a factory is already owned by the module it was passed to.
exposes is the public surface: an AudioParam, a Param node's .input
where an inlet needs scaling or fan-out, or the modules themselves.
Every factory also carries the parameters its processor declares, so a UI can be built from the module rather than from a hard-coded table:
Svf.descriptors;
// [{ name: "type", defaultValue: 1, minValue: 0, maxValue: 6, automationRate: "k-rate" }, …]Sources — PolyblepOscillator, WavetableOscillator, KarplusStrong,
Noise, Impulse, TimestretchAudioSource (a buffer player with independent
time and pitch)
Modifiers — Svf (state variable filter), VirtualAnalogFilter (Moog
ladder, Korg 35, diode ladder, Oberheim), ClipAmp, AdsrAmp, AdAmp,
RingMod (ring and amplitude modulation), ModalResonator (tuned resonator
bank: drums, bells), Decimator (sample-rate and bit-depth reduction),
LookaheadLimiter (true-peak brickwall), LevelMeter
Modulators — AdsrEnv, AdEnv, Lfo, Param, SampleHold,
EnvelopeFollower, SlewLimiter (portamento), Quantizer (snap a note number
to a scale)
Sequencers — Clock, Euclid, Arp
Effects — DigitalDelay, AnalogDelay (tape and bucket-brigade), Chorus,
ReverbDelay, DattorroReverb, Granite (granular delay)
Instruments — MonoSynth, and eleven drums: KickDrum, SnareDrum,
HiHatDrum, ClaveDrum, CowBellDrum, CymbalDrum, MaracasDrum,
HandclapDrum, TomDrum, CongaDrum, MembraneDrum
Every module has a matching register<Name>Worklet function if you'd rather not
register all of them.
Documentation and live examples are here.
Web Audio gives you oscillators, filters and gains, and then stops. There's no
noise node, no envelope generator, no reverb that doesn't need you to source an
impulse response, no level meter without an AnalyserNode and a
requestAnimationFrame loop, and a DynamicsCompressorNode that most people
consider unusable as a limiter. Synthlet is a module for each of those, shaped
like the nodes you already use.
Why TypeScript? Because a module needs no build step to read, hack or debug, and JS engines optimise this kind of code well enough. It's a tradeoff, not a principle — WASM is open where the DSP warrants it, and packaging is designed to hide which one you're using.
Why one package per module? So npm i @synthlet/adsr gets you an envelope
generator and nothing else. The shared runtime is copied into each package
rather than extracted into a dependency, deliberately.
It's not a music framework. No transport, no bar/beat scheduling, no note names. If you want those, Tone.js has them, and a synthlet module connects into a Tone.js graph like any other node.
This library wouldn't be possible with all the people writing books, blog posts and awesome libraries... and making music! Thanks to all 💚
- Designing Synth Plugins 2nd Edition
- Developing Virtual Synthesizers with VCV Rack
- BasicSynth: Creating a Music Synthesizer in Software
- Generating Sound and Organizing Time
- Designing Audio FX Plugins 2nd Edition
- https://github.com/BillyDM/awesome-audio-dsp
- https://paulbatchelor.github.io/sndkit/algos/
- https://www.musicdsp.org/
- Signalsmith Audio blog
- Valhalla DSP Blog
- http://synthworks.eu/ - DIY Synthetizers
- Karplus-Strong original paper
Projects worth reading. None of synthlet's code derives from them — see THIRD-PARTY-LICENSES.md for what actually does.
- Faust
- Cmajor
- VCVRack
- The Synthesis ToolKit
- Surge synth
- Surge Rust
- https://github.com/jd-13/WE-Core
- https://github.com/mhetrick/nonlinearcircuits
- https://github.com/timowest/analogue
- https://github.com/pichenettes/stmlib/tree/master/dsp
MIT License. See LICENSE.md.
Some modules contain DSP derived from third-party work. Those derivations, their authors and the notices they require are collected in THIRD-PARTY-LICENSES.md.