Synth Framework — Overview

File Layout

static/js/synth/
  core.js             DSP utilities (encodeWAV, ADSR, normalize, math helpers)
  registry.js         register(id, engine) / get(id) for sequencer integration
  distortion.js       DistortionPipeline, curve generation, chain builder, UI
  ui-builder.js       Builds per-module param sections, envelope/waveform canvas, lock sync
  preset-manager.js   Generic preset CRUD with localStorage, manifest.json, list UI
  playback.js         AudioContext lifecycle, playBuffer with VU meter, WAV download
  engine.js           initSynth() orchestrator + renderToBuffer() headless render
  midi-player.js      SMF parser + AudioContext-timed MIDI scheduler
  sequencer.js        Step sequencer: strips, patterns, scheduler, effect chain, Turing Machine
  sequencer-ui.js     Sequencer DOM rendering: sound cards, step grid, effect controls

  modules/
    filter.js         Low-pass BiquadFilter
    eq.js             3-band shelving/peaking EQ
    compressor.js     Dynamics compressor
    limiter.js        Brickwall limiter (-6 dB, 20:1)
    ott.js            Multiband upward/downward compressor (OTT)
    clarity.js        Dynamic mud + air reduction
    chorus.js         BBD stereo chorus
    reverb.js         Convolution reverb (async OfflineAudioContext tail)
    delay.js          Stereo ping-pong tempo-synced delay (sequencer-ready)
    osc.js            Per-trigger oscillator bank
    noise.js          Cached noise buffer source
    sub.js            Sub oscillator (sine at freq/2)
    pitch.js          Frequency ramp (pitch decay)
    terrain.js        Wave terrain shaper network (BIA-specific)
    tear.js           LFO for terrain modulation (BIA-specific)
    volume.js         AD gain envelope

  engines/
    kick.js           SynthKick — bass drum
    snare.js          SynthSnare — snare drum
    hihat.js          SynthHihat — hi-hat
    bia-clone.js      SynthMono — BIA Clone terrain synth
    juno.js           SynthJuno — polyphonic Juno-inspired
    neuro.js          SynthNeuro — sub/neurofunk bass

  init-*.js           Page bootstrap files (load engine, init UI, wire keyboard/MIDI)

Unified Engine Contract

Every engine follows the same pattern:

export var SynthX = {
  id: 'x',
  modules: [ /* module definitions */ ],
  defaults: { params: { ... }, stages: [] },
  presetsKey: 'x_synth_presets',

  getDuration(params) { ... },

  buildLiveChain(ctx, params, stages) {
    var c = {};
    c.masterGain = ctx.createGain();
    c.masterGain.gain.value = 0;
    var chainInput = c.masterGain;
    for (var i = this.modules.length - 1; i >= 0; i--) {
      var mod = this.modules[i];
      if (mod.type === 'processor' && mod.build) {
        var result = mod.build(ctx, params, chainInput, 1);
        chainInput = result && result.input ? result.input : result;
      } else if (mod.type === 'distortion') {
        chainInput = buildDistortionChain(ctx, stages || [], chainInput);
      }
    }
    c.input = chainInput;  // per-note sources connect here
    return c;
  },

  trigger(ctx, chain, params, time, velocity) {
    // Create per-note sources connected to chain.input
    var noteGain = ctx.createGain();
    noteGain.gain.setValueAtTime(0, time);
    ...
    noteGain.connect(chain.input);
  },

  updateChain(chain, params) { /* update baked-in params */ },
};

The backward loop iterates the modules array from last to first. Each processor module's build() wraps the previous chainInput — its output connects to chainInput, and it returns its input node (or { input, output, nodes }).

Module Types

Type Loop? Purpose
source Skipped Oscillators, noise, terrain — handled by trigger()
processor Wrapped Filter, compressor, chorus, clarity, limiter — wired by backward loop
distortion Wrapped buildDistortionChain — inserted between processors
master Skipped Volume envelope — baked into buildLiveChain (type master, not processor)
builtin Skipped Post-render normalisation (applied in renderToBuffer Promise)

Legacy render() vs Unified buildLiveChain + trigger

The old pattern had a standalone render() function that built sources and connected to chainInput. This duplicated the processor wiring logic and led to subtle differences between live playback (sequencer/midi) and WAV export.

The unified pattern uses buildLiveChain + trigger for everything: - Live playback (sequencer audition, keyboard, MIDI) calls trigger() at audioCtx.currentTime - WAV export calls trigger() at time 0 on an OfflineAudioContext

The engine.js renderToBufferInternal falls back to the unified path when engine.render is falsy and engine.buildLiveChain + engine.trigger exist.

Lifecycle

  1. Bootstrap: initSynth(engine) fetches presets/{id}/default.json, builds UI
  2. Live chain: trigger() called by keyboard, sequencer, or MIDI player
  3. Export: renderToBuffer(engine, params, stages) builds offline chain, renders, applies builtins, returns buffer
  4. Presets: PresetManager manages save/load via localStorage + built-in manifest.json