Software Eurorack modular synthesizer. Modules pass voltages; sound only at output module.
- Audio: ±5V | Gates: 0/10V | Triggers: 5-10ms pulse at 5-10V
- Pitch CV: 1V/octave (0V = base, +1V = +1 octave)
- Thresholds: Gates ≥1V, Clock >2.5V, Arp >0.4V, Pause >2V
┌─────────────────────────────────────────────────────────────┐
│ index.html │
│ (App initialization) │
└─────────────────────┬───────────────────────────────────────┘
│
┌───────────┴───────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────────────────────────┐
│ audio/engine │ │ rack/rack.js │
│ (DSP loop) │◄───►│ (Orchestrates UI + routing) │
└────────┬────────┘ └──────────────┬──────────────────────┘
│ │
│ ┌──────────────┼──────────────┐
│ ▼ ▼ ▼
│ ┌───────────┐ ┌───────────┐ ┌───────────┐
└─────►│ modules/ │ │ modules/ │ │ modules/ │
│ vco/ │ │ vcf/ │ │ ... │
│ index.js │ │ index.js │ │ index.js │
└───────────┘ └───────────┘ └───────────┘
│ │ │
└──────────────┴──────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│rack/registry│ │ ui/renderer │
│ (lookup) │ │ (UI gen) │
└─────────────┘ └──────┬──────┘
│
┌──────┴──────┐
▼ ▼
┌────────────┐ ┌─────────────┐
│ui/toolkit/ │ │ ui/toolkit/ │
│ components │ │ layout │
└────────────┘ └─────────────┘
Self-contained modules: Each module folder contains DSP + UI definition in one file. Modules export metadata, createDSP() factory, and declarative ui config.
Available modules: midi-cv (mono MIDI-CV) · midi-4 (4-voice poly MIDI) · midi-cc (CC to CV) · midi-clk (MIDI clock) · midi-drum (drum pads to triggers) · clk (clock) · div (divider) · lfo · nse (noise) · sh (sample&hold) · quant (quantizer) · arp (arpeggiator) · seq (sequencer) · euclid (euclidean rhythm) · logic (AND/OR gates) · mult (signal splitter) · vco · vcf · fold (wavefolder) · ring (ring mod) · rnd (random) · envf (envelope follower) · func (function generator) · adsr · vca · atten (attenuverter) · slew · mix · dly (delay) · verb (reverb) · chorus · phaser · flanger · crush (bit crusher) · granulita (granular chord) · db (VU meter) · pwm (pulse width mod) · turing (random looping seq) · ochd (8x LFO) · cmp2 (window comparator) · kick · snare · hat · scope · spectrum (FFT analyzer) · plot (waveform plotter) · spectrogram (freq over time) · out
src/js/
├── index.js # Main entry point
├── rack/ # Rack infrastructure
│ ├── rack.js # Rack orchestration
│ └── registry.js # Module lookup & validation
├── ui/
│ ├── renderer.js # Declarative UI → DOM
│ └── toolkit/ # UI component factories
│ ├── components.js # Knobs, jacks, switches, LEDs
│ ├── layout.js # Panels, rows, sections
│ └── interactions.js # Drag handling
├── modules/ # Self-contained modules
│ └── {moduleId}/
│ └── index.js # DSP + UI definition
├── audio/engine.js # DSP processing loop
├── config/factory-patches.js
└── patches/ # Patch serialization
tests/dsp/{module}.test.js # Module tests
Processing order is computed dynamically from cable connections using computeProcessOrder():
- Sources process before destinations (topological sort)
- Ties broken by
MODULE_ORDER:clk → div → lfo → nse → sh → quant → arp → seq → euclid → logic → mult → vco → vcf → fold → ring → rnd → envf → func → adsr → vca → atten → slew → dly → verb → chorus → phaser → flanger → crush → granulita → db → pwm → turing → ochd → cmp2 → kick → snare → hat → mix → scope → out - Cycles (feedback patches) fall back to
MODULE_ORDER - Recomputed when cables or modules change
- Find official manual/PDF from manufacturer website
- Check ModularGrid for specs and panel image
- Document ALL: knobs, CV inputs, audio inputs, trigger inputs, outputs, switches, LEDs
- Note voltage ranges, thresholds, and hidden modes
- Cross-reference: manufacturer site, ModularGrid, retailer pages, demo videos
Tests go in tests/dsp/{module}.test.js. Test these categories:
- Initialization — default params, buffers created, correct sizes
- Output ranges — audio ±5V, gates 0/10V
- Each knob — min/max behavior, effect on output
- Each CV input — modulation response, voltage scaling
- Each trigger input — edge detection, correct threshold
- Each switch/mode — behavior in each position
- LEDs — reflect module state
- Reset — clears all internal state
- Buffer integrity — no NaN, fills entire buffer
- Spec compliance — matches manufacturer documentation
// src/js/modules/{moduleId}/index.js
export default {
// Metadata
id: 'moduleId',
name: 'Module Name',
hp: 4, // Panel width
color: '#8b4513', // Header color
category: 'source', // source | modulator | utility | effect
// DSP factory
createDSP({ sampleRate = 44100, bufferSize = 512 } = {}) {
const out = new Float32Array(bufferSize);
return {
params: { knob: 0.5, switch: 0 },
inputs: { cv: new Float32Array(bufferSize) },
outputs: { out },
leds: { active: 0 },
process() { /* fill outputs */ },
reset() { /* clear state */ }
};
},
// Declarative UI
ui: {
leds: ['active'],
knobs: [{ id: 'knob', label: 'Knob', param: 'knob', min: 0, max: 1, default: 0.5 }],
switches: [{ id: 'switch', label: 'Mode', param: 'switch', default: 0 }],
inputs: [{ id: 'cv', label: 'CV', port: 'cv', type: 'cv' }],
outputs: [{ id: 'out', label: 'Out', port: 'out', type: 'audio' }]
}
};Port types: audio | cv | gate | trigger | buffer
After creating src/js/modules/{moduleId}/index.js, register it in two places:
- Add import to
src/js/rack/registry.jsinloadModules():
import('../modules/{moduleId}/index.js'),-
Add to
src/js/index.jsinDEFAULT_MODULE_ORDERarray (determines processing order and sidebar position) -
Update documentation:
- Add to
CLAUDE.mdavailable modules list - Add to
CLAUDE.mdport reference table - Add to
README.mdmodule table
- Add to
-
Create test patch in
src/js/config/patches/test-{moduleId}.js
Edge detection:
const rising = trig[i] >= 1 && lastTrig < 1;
lastTrig = trig[i]; // Update AFTER checkV/Oct to frequency:
freq = baseFreq * Math.pow(2, vOct) * Math.pow(2, fine / 12);Slew limiting: Use createSlew() from src/js/utils/slew.js
PolyBLEP: Apply at saw/pulse discontinuities for anti-aliasing
Unpatched audio silence: Two-part pattern ensures silence when cables are removed:
- Module-level: Audio-path modules (output, VCA, VCF) reset inputs after
process()IF replaced by routing:if (this.inputs.x !== ownX) { ownX.fill(0); this.inputs.x = ownX; } - Engine-level:
setCables()zeroes disconnected audio inputs immediately when cables change
Patches are stored in src/js/config/patches/. Each patch file exports:
export default {
name: 'Patch Name',
factory: true,
state: {
modules: [
// type = module id (e.g., 'lfo', 'vco')
// instanceId = your chosen name for THIS instance (used in knobs/cables)
{ type: 'lfo', instanceId: 'lfo1', row: 1 },
{ type: 'lfo', instanceId: 'lfo2', row: 1 }, // Can have multiple of same type
{ type: 'vco', instanceId: 'vco', row: 1 },
],
knobs: {
// Keys match instanceId, not type
lfo1: { rateKnob: 0.5 },
lfo2: { rateKnob: 0.2 },
vco: { coarse: 0.35, fine: 0 }
},
switches: {
lfo1: { range: 0 }
},
cables: [
// fromModule/toModule = instanceId
// fromPort/toPort = port name from module definition
{ fromModule: 'lfo1', fromPort: 'primary', toModule: 'vco', toPort: 'vOct' }
]
}
};Key distinctions:
type= Module id (from module'sidfield, e.g.,'lfo','vco')instanceId= Your name for this instance (used inknobs,switches,cables)fromPort/toPort= Port name from module'sui.inputs[]orui.outputs[]
IMPORTANT: Cable port names must match the port field in each module's ui.inputs[] and ui.outputs[]. Always check the module's index.js for exact port names.
| Module | Inputs | Outputs |
|---|---|---|
| midi-cv | — | pitch, gate, velocity, mod |
| midi-4 | — | pitch1, gate1, pitch2, gate2, pitch3, gate3, pitch4, gate4 |
| midi-cc | — | cv1, cv2, cv3, cv4 |
| midi-clk | — | clock, reset, run |
| midi-drum | — | trig1, trig2, trig3, trig4, trig5, trig6, trig7, trig8, velocity |
| clk | — | clock |
| div | clock, rate1CV, rate2CV | out1, out2 |
| lfo | rateCV, waveCV, reset | primary, secondary |
| nse | trigger | noise |
| sh | in1, in2, trig1, trig2 | out1, out2 |
| quant | cv | cv, trigger |
| arp | clock, cvIn, gateIn, hold, pause | cvOut, gateOut |
| seq | clock, reset | cv, gate |
| euclid | clock, reset, lenCV, hitsCV | trig |
| logic | in1, in2 | and, or |
| mult | in1, in2 | out1a, out1b, out1c, out2a, out2b, out2c |
| vco | vOct, fm, pwm, sync | triangle, ramp, pulse |
| vcf | audio, cutoffCV, resCV | lpf, bpf, hpf |
| fold | audio, foldCV, symCV | out |
| ring | x, y | out |
| rnd | clock | step, smooth, gate |
| envf | audio | env, inv |
| func | in, trig, riseCV, fallCV, cycleCV | out, inv, eor, eoc |
| adsr | gate, retrig | env |
| vca | ch1In, ch2In, ch1CV, ch2CV | ch1Out, ch2Out |
| mix | in1, in2, in3, in4 | out |
| atten | in1, in2 | out1, out2 |
| slew | in1, cv1, in2, cv2 | out1, out2 |
| dly | inL, inR, timeCV, feedbackCV | outL, outR |
| verb | audioL, audioR, mixCV | outL, outR |
| chorus | inL, inR, rateCV, depthCV | outL, outR |
| phaser | inL, inR, rateCV, depthCV | outL, outR |
| flanger | inL, inR, rateCV, depthCV | outL, outR |
| crush | inL, inR, bitsCV, rateCV | outL, outR |
| granulita | inL, inR, hit, blendCV, pitchCV, chordCV, voiceCV, verbCV, countCV, lengthCV | outL, outR |
| db | L, R | outL, outR |
| pwm | in, pwmCV | out, inv |
| turing | clock, lockCV | cv, pulse |
| ochd | rateCV | out1, out2, out3, out4, out5, out6, out7, out8 |
| cmp2 | in1, in2, shiftCV1, sizeCV1, shiftCV2, sizeCV2 | out1, not1, out2, not2, and, or, xor, ff |
| kick | trigger, pitchCV, decayCV, toneCV | out |
| snare | trigger, toneCV, decayCV | out |
| hat | trigger, decayCV | out |
| scope | ch1, ch2 | — |
| spectrum | audio | out |
| plot | audio, trig | out |
| spectrogram | audio | out |
| out | L, R | — |
- Create file:
src/js/config/patches/{name}.js - Import in
src/js/config/patches/index.js - Add to
FACTORY_PATCHESexport - Run tests:
npm test -- tests/config/factory-patches.test.js
When module specs change (renamed/removed params, inputs, outputs, switches):
- Search:
grep -n "moduleName" src/js/config/factory-patches.js - Update all
knobs,switches, andcablesreferences
- NaN — Guard division by zero, clamp inputs, test with
.every(v => !isNaN(v)) - Clicks — Use slew for params, ensure envelopes end at zero, apply PolyBLEP
- DC offset — Test mean ≈ 0 for audio outputs
- Trigger not firing — Check threshold, ensure lastTrig updated AFTER edge check
- CV delayed — Check
engine.processOrderto verify sources process before destinations
- All knobs with correct ranges
- All CV inputs with proper voltage scaling
- All audio/gate/trigger inputs
- All outputs producing correct voltage ranges
- All switches/modes
- LED indicators reflect state
- Matches manufacturer manual
- Tests passing
- Factory patches updated if specs changed
- ModularGrid — Module database
- 2hp — Official manuals
- Mutable Instruments GitHub — Open source DSP
Module documentation and DSP references are maintained in /research/:
research/modules/— Per-module specs, manuals, and implementation referencesresearch/topics/— Cross-cutting DSP topics (filters, anti-aliasing, etc.)research/sound-engineering-review.md— Sound quality improvements and recommendations
When implementing or modifying modules, consult and update the relevant research docs to maintain a trail of source material.
We balance hardware authenticity with optimal sound quality. When making DSP decisions:
- Document the options — Record what approaches exist (e.g., linear vs allpass interpolation)
- Explain the trade-offs — Note CPU cost, sound quality, complexity
- Reference the research — Link to papers, code, or forum discussions that informed the choice
- Make the decision visible — Comment in code why a particular approach was chosen
Before implementing improvements, check research/sound-engineering-review.md for prioritized recommendations. After implementing, update the review document with results and any new findings.
- No audible artifacts — Eliminate aliasing, clicks, zipper noise
- Musical parameter ranges — Knobs should feel useful across their full range
- Analog character — Capture warmth, nonlinearity, and behavior of hardware
- Modulation response — CV inputs should respond musically at audio rates
- CPU efficiency — Good enough quality at acceptable performance cost