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.
// 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)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.
// 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 * dtEffTwo 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)Where k is the steepness parameter mapped exponentially from slope:
// 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).
// 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 48kHzEach 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]// 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))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:
// 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 > 13. Frequency pre-warping. The bilinear transform causes pitch warping at high frequencies. Pre-warp compensation ensures the self-oscillation pitch matches the cutoff frequency:
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.
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])// 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.
// 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.smoothingFactorNothing 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:
// 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:
// 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.
// 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) * aSample-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:
// 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.
// 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.
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.