Technical Documentation

How It Works

A deep technical reference to the physics simulation, signal processing, and computational philosophy behind InertiaPhysics. Every equation in this document is running in your browser right now.

Signal Flow Overview

The engine is a classic subtractive synthesizer architecture: three parallel oscillators (VCO) feed through a state-variable filter (SVF), then a voltage-controlled amplifier (VCA) shapes the output. Two ADSR envelope generators and two LFOs provide modulation, routed through a matrix that connects any source to any destination.

The signal chain is fully sample-accurate: every parameter change, envelope update, and LFO tick is computed per-sample, not per-buffer.

use-synth-engine.tssignal chain
// Audio processing callback — VCO → VCF → VCA → OUT
scriptProcessor.onaudioprocess = (event) => {
  const output = event.outputBuffer.getChannelData(0)

  // 1. Generate per-sample frequency buffer (with glide/portamento)
  glide.processBuffer(freqBuffer)

  // 2. Generate oscillator output with per-sample frequencies (VCO)
  oscBank.processWithFrequencyBuffer(oscBuffer, freqBuffer,
    ctx.sampleRate, oversampleFactor)

  // 3. Process through shaping (VCF → VCA)
  shaping.process(oscBuffer, output, gate)
}
Every sample is a physics step. 48,000 steps per second.

Phase Accumulation with Stochastic Drift

Each oscillator maintains a phase accumulator v that ramps from 0 to 1 at the target frequency. Instead of a clean increment, two Ornstein-Uhlenbeck (O-U) noise processes inject continuous micro-perturbations into the timing.

dε = -θₐ · ε · dt + σₐ · √(dt) · N(0,1)
O-U process (Euler-Maruyama)

The epsilon process (θ=50, σ=0.0004) modulates the effective time step, creating micro-jitter in phase accumulation. A second delta process (θ=20, σ=0.003) feeds pulse-width jitter for PWM territory.

oscillator-engine.tsphase step
// O-U noise update
state.epsilon += -EPSILON_THETA * state.epsilon * dt
                + EPSILON_SIGMA * sqrtDt * noise.epsilon()
state.delta   += -DELTA_THETA * state.delta * dt
                + DELTA_SIGMA * sqrtDt * noise.delta()

// Phase accumulation with time-step jitter
const dtEff = dt * (1 + state.epsilon)
state.v += frequency * dtEff

Two identical oscillators. Same code. Same frequency. Left alone long enough, they diverge forever.

Unified Waveform Shaper

The oscillator does not select between discrete waveform types. Instead, a continuous shaping pipeline morphs a single phase ramp through triangle, saw, and pulse territory using three controls: Edge, Slope, and Width.

First, the raw phase is read as both a saw wave and a triangle wave. Slope controls a smoothstep crossfade from triangle (slope=0) to saw (slope>=0.25), then continues to increase steepness:

y = tanh(k · (x - threshold)) / tanh(k)
unified shaper

Where k is the steepness parameter mapped exponentially from slope:

oscillator-engine.tsshape math
// Steepness: exponential mapping
// slope=0 → k=0.8 (soft), slope=1 → k=80 (hard square)
function computeSteepness(slope: number): number {
  return K_MIN * Math.pow(K_MAX / K_MIN, slope)
}

// Duty cycle: quadratic curve for pulse width
// width=0 → 50% duty (square), width=1 → 2% duty (narrow pulse)
function computeDutyCycle(width: number): number {
  const curve = width * width
  return 0.5 - curve * (0.5 - MIN_DUTY)
}

// Threshold determines the zero-crossing point
// PWM modulates this threshold with hysteresis
function computeThreshold(width: number): number {
  const duty = computeDutyCycle(width)
  return 1 - 2 * duty
}

At high slope, the tanh saturator acts as a soft comparator — the waveform is driven into a square/pulse shape. Hysteresis in the threshold prevents chattering at the zero-crossing, and the delta O-U process injects analog-like jitter into the pulse width.

Triangle. Saw. Square. Pulse. It is one continuous gesture, not a mode switch.

Phase Reset and Impulse Energy

When phase v exceeds 1.0, the oscillator resets. In real hardware, this reset is never clean — capacitors do not fully discharge, and the transition injects a tiny transient into the signal. Edge controls two aspects of this behavior simultaneously.

Bleed controls how much phase energy survives the reset. At edge=0, bleed=1.0 (all energy preserved — a clean mathematical reset). At edge=1, bleed=0.5 (half the overshoot energy is lost, creating a softer, more damped oscillation).

oscillator-engine.tsreset
// Phase reset with energy bleed
while (state.v >= 1) {
  const overshoot = state.v - 1
  const bleed = computeBleed(params.edge) // 1.0 → 0.5
  state.v = overshoot * bleed
  state.lastOvershoot = overshoot

  // Impulse energy from reset transient
  const gain = computeImpulseGain(params.edge)
  const denom = Math.max(increment, 1e-9)
  const normalizedOvershoot = Math.min(overshoot / denom, 2.0)
  const variation = 1 + 0.1 * noise.impulse()
  const kick = gain * normalizedOvershoot * variation
  state.impulseEnergy += kick
}

// Impulse decays exponentially and subtracts from output
const impulseContrib = Math.min(state.impulseEnergy, 0.35)
output -= impulseContrib
state.impulseEnergy *= 0.82 // ~0.4ms decay at 48kHz

Each reset is unique. The impulse energy is frequency-dependent, noise-modulated, and decays at its own rate. This is not modeled — it is emergent.

Anti-Aliasing via Oversampled Simulation

The phase accumulator runs at 2x the native sample rate by default (configurable). At each output sample, the oscillator is stepped twice through the physics simulation, and the result is decimated through a 2nd-order Butterworth lowpass filter.

y[n] = b₀x[n] + b₁x[n-1] + b₂x[n-2] - a₁y[n-1] - a₂y[n-2]
biquad lowpass (decimation)
oscillator-engine.tsoversampling
// Decimation filter: Butterworth LP at 0.45 / oversampleFactor
function computeBiquadLowpass(cutoffNorm: number, Q = 0.707) {
  const omega = Math.PI * cutoffNorm
  const sinOmega = Math.sin(omega)
  const cosOmega = Math.cos(omega)
  const alpha = sinOmega / (2 * Q)
  const a0 = 1 + alpha
  return {
    b0: ((1 - cosOmega) / 2) / a0,
    b1: (1 - cosOmega) / a0,
    b2: ((1 - cosOmega) / 2) / a0,
    a1: (-2 * cosOmega) / a0,
    a2: (1 - alpha) / a0,
  }
}

// Per-sample: run N oversampled steps, take last
for (let os = 0; os < oversampleFactor; os++) {
  const raw = processOscillatorSample(state, freq, oversampledRate, noise)
  const filtered = applyDecimationFilter(raw, state, coeffs)
  if (os === oversampleFactor - 1) {
    sample += filtered * state.gain * 0.3
  }
}

This approach allows the physics simulation to run at higher temporal resolution without aliasing artifacts, while the Butterworth filter prevents spectral images from folding back into the audible range.

The simulation runs faster than time. Then we slow it back down.

State-Variable Filter (SVF)

The filter is a topology-preserving digital state-variable filter (TPT SVF). It produces lowpass, highpass, bandpass, and notch outputs simultaneously from a single processing step.

g = tan(π · fₙ)   k = 1/Q   a₁ = 1/(1 + g(g + k))
SVF coefficient computation
filter-engine.tsSVF core
function processSVFSample(input, state, g, k, a1, a2, a3) {
  const v3 = input - state.ic2eq
  const v1 = a1 * state.ic1eq + a2 * v3
  const v2 = state.ic2eq + a2 * state.ic1eq + a3 * v3

  // Update integrator states (trapezoidal integration)
  state.ic1eq = 2 * v1 - state.ic1eq
  state.ic2eq = 2 * v2 - state.ic2eq

  return {
    lp: v2,                    // lowpass
    hp: input - k * v1 - v2,   // highpass
    bp: v1,                    // bandpass
    notch: input - k * v1,     // notch
  }
}

Emphasis (resonance) maps from 0-1 to Q via exponential curve: Q = 0.5 · (20/0.5)^emphasis. At high Q, the filter approaches self-oscillation — a pure sine generated from feedback alone.

Feedback Saturation and Pitch Tracking

When Q exceeds 5, the filter enters the self-oscillation regime. Three stabilization mechanisms prevent runaway amplitude while preserving musical behavior:

1. Feedback saturation. The integrator states are soft-clipped to prevent exponential growth:

filter-engine.tsfeedback limiter
// Soft clip at ~1.5 amplitude — gentler than tanh for musical behavior
function feedbackSaturate(x: number): number {
  const threshold = 1.5
  if (Math.abs(x) < threshold) return x
  const sign = x > 0 ? 1 : -1
  return sign * (threshold +
    (1 - Math.exp(-(Math.abs(x) - threshold) * 0.5)) * 0.5)
}

// Applied when Q > 5 (self-oscillation territory)
if (selfOscTracking && Q > 5) {
  state.ic1eq = feedbackSaturate(state.ic1eq)
  state.ic2eq = feedbackSaturate(state.ic2eq)
}

2. Resonance gain compensation. As Q increases, passband gain rises dramatically. A compensation curve normalizes output:

Gₘ = 1 / (1 + (Q - 1) · 0.4)   for Q > 1
gain compensation

3. Frequency pre-warping. The bilinear transform causes pitch warping at high frequencies. Pre-warp compensation ensures the self-oscillation pitch matches the cutoff frequency:

filter-engine.tspre-warp
function prewarpCutoff(cutoffHz, sampleRate) {
  const normalizedFreq = cutoffHz / sampleRate
  if (normalizedFreq < 0.1) return cutoffHz

  // Inverse of tan warping compensation
  const compensated = Math.atan(
    Math.tan(Math.PI * normalizedFreq)
  ) / Math.PI
  return compensated * sampleRate
}
Push it hard enough, and the filter becomes an oscillator. Not by design — by physics.

ADSR with Exponential Curve Shaping

Each envelope generator produces a 0-1 control signal through four stages: Attack, Decay, Sustain, Release. Curve shaping per segment uses cubic polynomials rather than true exponentials for computational efficiency while maintaining musical feel.

envelope-generator.tscurve shaping
function applyCurve(progress, curveType, isRising) {
  const t = clamp(progress, 0, 1)

  switch (curveType) {
    case 'exponential':
      if (isRising) {
        return t * t * t           // slow start, fast end (punch)
      } else {
        const inv = 1 - t
        return 1 - inv * inv * inv  // fast start, slow tail (natural)
      }

    case 'logarithmic':
      // Opposite feel: fast attack, slow tail (or vice versa)
      if (isRising) {
        const inv = 1 - t
        return 1 - inv * inv * inv
      } else {
        return t * t * t
      }

    default: // linear
      return t
  }
}

Soft retrigger mode attacks from the current envelope value (not zero), preventing clicks on fast retriggering. Legato mode only fires attack on non-overlapping notes. A 1ms minimum segment time prevents clicks on all segments.

One-Pole Smoothing as Physical Mass

Every parameter in the engine has mass. Cutoff does not jump — it travels. Pitch does not teleport — it glides. This is implemented through one-pole smoothing filters, where the time constant acts as the parameter's inertia:

α = 1 - e^(-1 / (fₛ · τ))   y[n] = y[n-1] + α · (target - y[n-1])
one-pole smoothing
glide-engine.tsportamento
// Per-sample one-pole smoothing for glide
processSample(): number {
  const a = 1 - Math.exp(-1 / (this.sampleRate * this.params.time))
  this.currentHz += (this.targetHz - this.currentHz) * a

  // Avoid denormals — snap when close enough
  if (Math.abs(this.targetHz - this.currentHz) < 1e-9) {
    this.currentHz = this.targetHz
  }
  return this.currentHz
}

The same smoothing principle applies everywhere: 5ms for filter cutoff changes, 10ms for VCA gain, 7ms for oscillator detune. Each time constant is chosen to balance responsiveness against click-free operation.

filter-engine.tsparameter smoothing
// 5ms smoothing for click-free filter changes
this.smoothingFactor = 1 - Math.exp(-1 / (sampleRate * 0.005))

// Per-sample: smooth cutoff and emphasis
this.smoothedCutoff += (targetCutoff - this.smoothedCutoff)
                     * this.smoothingFactor
this.smoothedEmphasis += (targetEmphasis - this.smoothedEmphasis)
                       * this.smoothingFactor
Nothing is instant. Everything has mass.

Modulation Matrix: Sources, Destinations, Semantics

The routing matrix connects four modulation sources (amp envelope, filter envelope, LFO 1, LFO 2) to three destinations (VCA gain, filter cutoff, filter emphasis). Each route has a depth (-1 to +1) and an enable flag. The semantics differ by destination type:

Absolute destinations (VCA gain): the routed envelope value IS the gain. Multiple envelope sources multiply. LFO sources apply as tremolo on top:

routing-bus.tsVCA routing
// Envelope → VCA: base gain (multiply policy for stacking)
for (let i = 0; i < length; i++) {
  this.vcaGainModBuffer[i] = envToGain(src[i], depth)
}

// LFO → VCA: tremolo multiplier (layered on top)
for (let i = 0; i < length; i++) {
  const tremolo = 1 + src[i] * sign * tremoloDepth
  tremoloBuffer[i] *= Math.max(0, tremolo)
}

// Final: gain = envelope * tremolo * master
vcaGainModBuffer[i] *= tremoloBuffer[i]

Additive destinations (filter cutoff, emphasis): modulation is a bipolar delta around the knob value. The filter interprets cutoff modulation in octaves:

filter-engine.tscutoff modulation
// Envelope modulation sweeps cutoff in octaves
const modOctaves = cutoffModulation * contour * 4  // up to ±4 octaves
cutoff = baseCutoff * Math.pow(2, modOctaves)

// Key tracking: middle C is reference
const semitonesFromMiddleC = baseNote - 60
const octavesOffset = semitonesFromMiddleC / 12
cutoff *= Math.pow(2, octavesOffset * keyTracking)

Negative depth inverts the modulation. An inverted amp envelope creates a ducking effect. An inverted LFO on cutoff sweeps the filter in opposition to the wave. Every connection is a force, not an assignment.

Low-Frequency Oscillators with Smoothing

The LFOs generate bipolar (-1 to +1) control signals at sub-audio rates (0.05-20Hz). Five waveforms are available: sine, triangle, saw, square, and sample-and-hold. Each uses sample-rate-correct phase accumulation — the same fundamental mechanism as the audio oscillators.

lfo-engine.tsLFO core
// Phase accumulation (same as audio oscillators)
const phaseInc = rate / this.sampleRate
this.state.phase = wrap01(this.state.phase + phaseInc)

// Waveform generation
switch (waveform) {
  case 'sine':     x = Math.sin(2 * Math.PI * p)
  case 'triangle': x = 1 - 4 * Math.abs(p - 0.5)
  case 'saw':      x = 2 * p - 1
  case 'square':   x = p < 0.5 ? 1 : -1
  case 'sampleHold': // Deterministic RNG, updates once per cycle
    if (countdown <= 0) {
      shValue = rng()  // seeded PRNG for consistency
      countdown = Math.floor(sampleRate / rate)
    }
    x = shValue
}

// Optional one-pole smoothing (2ms-80ms) — tames harsh edges
const tau = 0.002 + smooth * 0.08
const a = 1 - Math.exp(-1 / (sampleRate * tau))
out = lastOut + (out - lastOut) * a

Sample-and-hold uses a deterministic PRNG (linear congruential generator) seeded per LFO instance. This ensures each LFO's random sequence is unique but reproducible — two LFOs with different seeds produce uncorrelated modulation.

Saturation, Soft Limits, and NaN Guards

Real analog circuits have natural limits — capacitors saturate, transistors clip, power supplies rail. Digital systems do not have these limits. They have infinity and NaN. Every nonlinearity in the engine serves as a physical limit:

filter-engine.tssoft limiting
// Input drive saturation: tanh-based
function saturate(x, amount) {
  const drive = 1 + amount * 3
  return Math.tanh(x * drive) / drive
}

// Cutoff soft limiting: prevents coefficient explosion
// Instead of hard clipping, excess compresses smoothly
function softLimitCutoff(hz, maxHz) {
  if (hz <= maxHz) return hz
  const excess = hz - maxHz
  return maxHz + excess / (1 + excess / maxHz)
}

// SVF stability: cutoff clamped to 45% of Nyquist
// Prevents tan(π·f) from approaching infinity
function getMaxPhysicalCutoff(sampleRate) {
  return sampleRate * 0.5 * 0.45
}

The VCA uses soft clipping with controllable drive — blending linearly between clean and tanh-saturated output. The filter's cutoff is soft-limited before reaching the SVF coefficients, preventing the tan() function from exploding near Nyquist.

The last line of defense: every sample output is checked for NaN/Infinity. If detected, the filter state resets silently and outputs zero. The engine always recovers.

filter-engine.tsNaN guard
// NaN safety guard — reset filter state if output is non-finite
// This ensures the engine always recovers from edge cases
if (!Number.isFinite(output[i])) {
  state.ic1eq = 0
  state.ic2eq = 0
  output[i] = 0
}
The engine cannot crash. It can only saturate.
Conclusion

Computation as Material

This is not analog modeling. We are not simulating capacitors or transistors. We are observing computation and allowing it to behave like matter.

Floating-point rounding accumulates. Phase drifts microscopically. Feedback saturates gently. Time has texture. None of this is programmed as warmth. It emerges from the mathematics running on your machine, right now.

Different browsers. Different CPUs. Different architectures. Same code. Different physics.

Your instrument is shaped by the machine it runs on. Just like hardware always was.