Aither: Live Coding Audio Synthesis in JavaScript

Try Aither in your browser

Every sound in Aither is a function. A sine wave at 440 Hz:

play('hello', s => Math.sin(2 * Math.PI * 440 * s.t) * 0.3)

That function runs 48,000 times per second. Each call returns a number between -1 and 1 — one audio sample. The engine collects these samples and sends them to the speakers. That's the entire model.

No graph. No scheduler. No distinction between "control rate" and "audio rate." A signal is a function f(s) => sample. Everything else — oscillators, filters, effects, composition — is functions calling functions.

The Architecture

The state object s carries everything a signal needs:

Return a number for mono, [left, right] for stereo. The engine soft-clips the final mix through Math.tanh.

Zero-GC Hot Path

The audio thread cannot tolerate garbage collection pauses. A 1ms GC pause is an audible click. Aither solves this with a sidecar architecture:

JavaScript's GC can pause the producer. The ring buffer absorbs the jitter. The consumer never calls back into JavaScript. Result: no clicks, no dropouts, no GC artifacts in the audio output.

All per-signal state lives in pre-allocated Float64Arrays. No objects created, no arrays allocated, no strings concatenated in the hot path. The JIT compiles each signal chain into a tight numerical loop.

Hot-Swapping

Edit your code, send it to the engine (Ctrl+Enter from VSCode), and the signal crossfades to the new version. No click. No restart. The s.state array persists across swaps — a running oscillator keeps its phase, a delay buffer keeps its contents.

This is the core interaction model: write a function, hear it immediately, modify it, hear the change. The feedback loop is sub-second.

Functional Composition

Oscillators are functions that return functions:

sin(440)        // sine wave at 440 Hz
saw(110)        // sawtooth
tri(220)        // triangle
square(440)     // square wave
pulse(440, 0.3) // pulse with 30% duty cycle
noise()         // white noise
phasor(2)       // 0-to-1 ramp at 2 Hz — the universal clock

Effects take a signal and return a signal:

lowpass(signal, 800)                  // one-pole lowpass at 800 Hz
highpass(signal, 6000)                // one-pole highpass
delay(signal, 2.0, 0.5)              // delay line, 0.5s tap
feedback(signal, 2.0, 1.5, 0.7)      // feedback delay
reverb(signal, 2.0, 0.4, 0.3)        // Schroeder reverb, 30% wet

Composition is pipe and mix:

// Chain: sawtooth → lowpass → reverb
pipe(saw(110), signal => lowpass(signal, 800), signal => reverb(signal, 2.0, 0.4, 0.3))

// Sum: three detuned sines
mix(sin(220), sin(220.5), sin(330))

Every helper — lowpass(), delay(), tremolo() — works on any signal regardless of how it was built. A filter doesn't care if it's processing a pure sine wave, a physics simulation, or a chaotic attractor. It's all f(s) => sample. The helpers manage their own state implicitly — no manual wiring, no bus routing, no explicit state allocation.

What It Sounds Like

A kick drum, hi-hats, and acid bass in 15 lines:

const beat = phasor(130/60)
const envelope = share(decay(beat, 40))
const kick = sin(s => 60 + envelope(s) * 200)
play('kick', s => kick(s) * envelope(s) * 0.8)

const hiss = pipe(noise(), signal => highpass(signal, 6000))
play('hats', s => hiss(s) * decay(phasor(130/30), 80)(s) * 0.3)

const bpm = 130/60
const acidBeat = phasor(bpm)
const acidEnv = share(decay(acidBeat, 25))
const notes = [55, 55, 73, 55, 82, 55, 65, 55]
const acidOsc = saw(s => notes[Math.floor(s.t * bpm) % notes.length])
play('acid', pipe(
  s => acidOsc(s) * acidEnv(s) * 0.4,
  signal => lowpass(signal, s => 200 + acidEnv(s) * 3000)
))

A clock is a phasor. An envelope is decay(phasor, rate). A sequencer is array indexing by time. These aren't abstractions bolted on — they fall out of the model. When everything is a function, musical structure is just function composition.

FM synthesis — a modulator feeding a carrier:

const mod = sin(180)
const carrier = sin(s => 340 + mod(s) * 100)
play('fm', carrier)

Cross-signal modulation is just one function calling another. No buses, no routing matrix, no global state. The modulator is a function, the carrier calls it. That's it.

A logistic map oscillator — chaos theory as sound:

play('chaos', s => {
  s.state[0] = s.state[0] || 0.5
  s.state[2] = (s.state[2] || 0) + 1
  if (s.state[2] >= 2000) {
    s.state[2] = 0
    s.state[0] = 3.59 * s.state[0] * (1 - s.state[0])
  }
  const freq = 200 + s.state[0] * 400
  s.state[1] = (s.state[1] + freq / s.sr) % 1.0
  return Math.sin(s.state[1] * 2 * Math.PI) * 0.3
})

The state array is the escape hatch. When the functional DSP toolkit doesn't cover what you need, drop to raw sample-by-sample computation. Write your own oscillator, your own filter, your own effect — it's just math at 48kHz.

Why Not SuperCollider

SuperCollider separates UGens, DemandUGens, and Patterns into different APIs with different semantics. Control rate and audio rate are separate worlds. Cross-signal modulation requires explicit buses. State management is manual.

Aither has one interface: f(s) => sample. The same helpers work on everything. Cross-signal modulation is a function call. State is implicit. The JIT compiles the entire signal chain into a single tight loop — no message passing, no scheduling overhead, no garbage collection on the hot path.

Max/MSP and PureData give you visual patching. Aither gives you JavaScript — the language you already know, your editor, your debugger, your workflow. The tradeoff is no GUI and no 30 years of UGen libraries. What you get is the full npm ecosystem, sub-second feedback, and a synthesis model where composition is just how functions work.


Aither runs on Bun. Source at github.com/rolandnsharp/aither.

Co-authored with Claude.