A live coding audio engine where every sound is a function.
play('hello', s => Math.sin(2 * Math.PI * 440 * s.t) * 0.3)That's a 440 Hz sine wave. s is the state of the world — time, sample rate, persistent memory. Your function runs 48,000 times per second. Whatever it returns hits the speakers.
// Kick drum
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)
// Hi-hats
const hiss = pipe(noise(), signal => highpass(signal, 6000))
play('hats', s => hiss(s) * decay(phasor(130/30), 80)(s) * 0.3)
// Acid bass — saw wave with resonant filter sweep
const bpm = 130/60
const acidEnv = share(decay(phasor(bpm), 25))
const acidOsc = saw(wave(bpm, [55, 55, 73, 55, 82, 55, 65, 55]))
play('acid', pipe(
s => acidOsc(s) * acidEnv(s) * 0.4,
signal => lpf(signal, s => 200 + acidEnv(s) * 3000, 0.8)
))Change the code, re-send it. The sound updates instantly — no click, no restart. Phase accumulators and effect buffers survive the swap.
Requires Bun and a working audio output.
git clone https://github.com/rolandnsharp/aither.git
cd aither
bun install
bun link
# Terminal 1: start the engine
aither start
# Terminal 2: send code to it
aither send snippet.jsOr open a REPL and type live:
aither replBind a key to send the current file on save. Add this to your keybindings.json (Ctrl+Shift+P → "Open Keyboard Shortcuts (JSON)"):
{
"key": "ctrl+enter",
"command": "workbench.action.terminal.sendSequence",
"args": { "text": "aither send \"${file}\"\u000D" },
"when": "editorTextFocus"
}Now Ctrl+Enter sends whatever file you're editing to the running engine. Edit, hit Ctrl+Enter, hear it.
Every signal is a function f(s) => sample where s carries:
| Property | What it is |
|---|---|
s.t |
Time in seconds |
s.sr |
Sample rate (48000) |
s.dt |
1 / s.sr |
s.state |
128-slot Float64Array, persistent across hot-swaps |
s.name |
Signal name |
s.position |
{ x, y, z } spatial position |
Return a number for mono, or [left, right] for stereo. The engine soft-clips the mix through Math.tanh.
play('name', fn) // Start a signal (or hot-swap if already playing)
play('name', fn, 4) // Start with 4s fade-in
stop('name') // Stop immediately
stop('name', 4) // Fade out over 4s
mute('name') // Silence (state keeps running)
unmute('name') // Resume
solo('name', 4) // Fade out everything else
list() // Print what's playing
clear() // Hard reset — stop all, zero all memory
clear(4) // Fade everything out over 4sAll DSP functions return signal functions. Compose them freely.
sin(440) // Sine wave
saw(110) // Sawtooth
tri(220) // Triangle
square(440) // Square wave
pulse(440, 0.25) // Pulse with 25% duty cycle
phasor(2) // 0-to-1 ramp at 2 Hz
wave(440, [0, 0.5, 1, -0.5]) // Wavetable oscillator
noise() // White noise
// All accept modulation functions
sin(s => 440 + sin(6)(s) * 50) // VibratoEffects wrap a signal and process it. They handle stereo automatically.
lpf(signal, 800, 0.5) // Resonant lowpass (SVF) — cutoff, resonance 0–1
hpf(signal, 200, 0.3) // Resonant highpass
bpf(signal, 800, 0.9) // Bandpass
notch(signal, 400, 0.7) // Notch (band-reject)
lowpass(signal, 800) // One-pole lowpass (no resonance — cheap, good for smoothing)
highpass(signal, 200) // One-pole highpass
delay(signal, 0.5, 0.25) // Delay: max 0.5s, tap at 0.25s
feedback(signal, 2.0, 0.375, 0.6) // Feedback delay
reverb(signal, 2.0, 0.4, 0.3) // Reverb: 2s RT60, damping, 30% wet
tremolo(signal, 6, 0.4) // Tremolo: 6 Hz, 40% depth
slew(signal, 0.1) // Smooth over 100ms (portamento)
// Cutoff and resonance can be modulated
lpf(signal, s => 200 + env(s) * 3000, 0.8)gain(signal, 0.5) // Scale amplitude
pan(signal, -0.3) // Stereo placement (-1 left, +1 right)
fold(signal, 3) // Wavefold — drive past [-1,1] and reflect back
decay(phasor, 40) // Exponential decay envelope
share(signal) // Cache a signal so it's only computed once per sample// pipe: chain a source through effects
pipe(
saw(110),
signal => lowpass(signal, 600),
signal => reverb(signal, 2.0, 0.5, 0.4)
)
// mix: sum signals together
mix(
s => sin(220)(s) * 0.2,
s => sin(330)(s) * 0.2,
s => sin(440)(s) * 0.2
)const mod = sin(180)
const carrier = sin(s => 340 + mod(s) * 100)
play('fm', carrier)const bellMod = sin(563)
const bell = sin(s => 440 + bellMod(s) * 800)
const bellEnv = decay(phasor(2), 15)
play('bell', s => bell(s) * bellEnv(s) * 0.2)play('pad', pipe(
s => (sin(220)(s) + sin(220.5)(s) + sin(330)(s) + sin(329.3)(s)) * 0.1,
signal => lowpass(signal, s => 600 + Math.sin(s.t * 0.2) * 400)
), 6)const shimL = pipe(sin(879), signal => delay(signal, 0.32, 0.13))
const shimR = pipe(sin(880), signal => delay(signal, 0.52, 0.17))
play('shimmer', s => [shimL(s) * 0.1, shimR(s) * 0.1])play('haunt', pipe(
tri(55),
signal => lowpass(signal, 300),
signal => feedback(signal, 2.0, 1.5, 0.7)
))wave at beat rate is a sequencer. At audio rate it's a custom waveform. Same oscillator.
const bpm = 130/60
const notes = wave(bpm * 2, [220, 330, 440, 330])
const env = decay(phasor(bpm * 2), 30)
play('arp', pipe(
sin(notes),
signal => gain(signal, env),
signal => feedback(signal, 1.0, 0.375, 0.4)
))play('nasty', pipe(
saw(55),
signal => fold(signal, s => 2 + sin(0.5)(s) * 1.5),
signal => lowpass(signal, 800)
))No DSP helpers needed. Just math and s.state.
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
})There's no graph, no scheduler, no distinction between "control rate" and "audio rate." A clock is a phasor. An envelope is decay(phasor, rate). A sequencer is wave(bpm, notes) — the same wavetable oscillator that produces custom waveforms at audio rate. Rhythm, melody, timbre — they're all the same thing: functions of s composed together.
Traditional environments (SuperCollider, Max/MSP, PureData) use dataflow graphs. Aither uses plain function composition. The JIT compiles your entire signal chain into a single tight loop. No message passing, no scheduling overhead, no garbage collection on the hot path.
The tradeoff: everything runs at audio rate. A step sequencer that only changes notes twice per second still gets evaluated 48,000 times per second. This is deliberate — it means any signal can modulate any parameter without special plumbing. The CPU cost of redundant table lookups and modulos is negligible. If a complex patch ever hits CPU limits, a hold(signal, rate) primitive that decimates to a lower update rate would solve it without breaking the model.
MIT