a web audio library that behaves · it sounds better down here
This is the code. The button runs exactly it.
const voice = audio.unison(220)
const strike = audio.env([[0.01, 0.3], [1.3, 0]])
voice.gain = strike
btn.onclick = () => {
audio.start()
voice.align()
strike.trigger()
}
No lifecycle code, no cleanup, no timestamps. The hard parts live below your code. prototype v0.9.7
three programs, one world
Each runs on its own. Together they're a song — try all three.
three wanderers on filtered air
full code
sixteen steps and a sub-rumble
full code
a saw through a resonant lowpass, jumping the root around
full code
a driven lead into a dub delay whose time drifts — the melody the others lack
full code
playground
audio and dB are in scope. Edit, run, break it.
why
Tone.js made browser music real and taught us what the problems were. If it serves you, keep it. We wanted different promises:
- The naive code is the correct code. The tutorial one-liner and the synth-per-touch handler are supported usage.
- Nothing clicks or zippers. Stops fade, retriggers dip, knobs glide. An explicit zero is respected.
- Sound survives the pocket. Background tabs, mute switches, lock screens — tested on real phones.
- Silence is never the only symptom. Bad values are rejected with an explanation, not swallowed.
- Defaults are tuned, parameters are obeyed.
how it's built
Every bug becomes a law, every law becomes a test. A few, with scars: own your schedule (cancelAndHold snaps — measured), never schedule in the past, never await an iOS resume() that may pend forever, and the 10 ms declick floor applies to everything — a 3 ms gate is a click the sample tests can't even see.
CI renders are seeded, diffed against goldens, and tortured: stop/start cycles, 30 ms mash storms, mid-decay retriggers. Zero discontinuities is the passing grade.
honest gaps
| iOS wake | a swipe can claim the speaker icon but not the engine — a clean tap wakes both. |
| refresh pop | a small click on refresh mid-sound is the platform's teardown. |
| one open bug | a rare click under very fast mashing — parked with a measurement plan. |
| not yet | custom DSP, samples, MIDI. |
api
Every example is complete and playable.
audio.osc(frequency, opts?)
An oscillator — the basic tone. One frequency, one shape, out the speaker.
frequency in Hz · opts.shape: "sine" (default) | "saw" | "tri" | "square". Properties: frequency, gain, pan — all glide, all accept signals, all have .jump() and .rampTo().
const o = audio.osc(220)
o.gain = dB(-12)
o.pan = -0.3
audio.every(2, () => {
o.frequency = 180 + Math.random() * 300
})audio.unison(frequency, opts?)
Several detuned copies of one tone, spread across the stereo field — the thick version of osc.
opts.voices (default 3) · opts.spread in cents (default 4) · opts.width 0..1 (default 0.6). align() resets the voices' phases so a percussive hit lands full.
const u = audio.unison(220, { voices: 3, spread: 5, width: 0.6 })
const e = audio.env([[0.01, 0.3], [1.2, 0]])
u.gain = e
audio.every(1.5, () => {
u.align()
e.trigger()
})audio.noise(opts?)
Noise — the raw material for wind, hats, texture.
opts.color: "air" (default, ear-tuned) | "white" | "pink" | "brown". Properties: gain, pan.
const bed = audio.noise() bed.gain = 0.15
audio.filter(opts?)
A filter shapes what frequencies pass — the main tool for turning raw sound into a voice.
opts.type: "lp" (lowpass) | "hp" (highpass) | "bp" (bandpass) · opts.cutoff in Hz · opts.q resonance. Wire with source.to = filter. The cutoff is a param — sweep it with .jump() and .rampTo(). (An envelope-to-range wrapper, env.range(min, max), is on the roadmap.)
const saw = audio.osc(110, { shape: "saw" })
const f = audio.filter({ type: "lp", cutoff: 300, q: 6 })
saw.to = f
const e = audio.env([[0.004, 0.35], [0.4, 0]])
saw.gain = e
audio.every(0.6, () => {
f.cutoff.jump(2400)
f.cutoff.rampTo(220, 0.35)
e.trigger()
})audio.env(segments)
An envelope — a shape for loudness (or anything) over time. Attack, decay, done.
segments: [[duration, target], ...] from 0. trigger() plays them, release() fades out — for held notes. Retriggers dip at the declick floor so every note punches.
const o = audio.osc(262)
const e = audio.env([[0.005, 0.3], [0.25, 0.1], [0.6, 0]])
o.gain = e
audio.every(1, () => {
e.trigger()
})audio.wander(min, max, opts?)
A slow random drift — holds a value, then glides somewhere new. How things stay alive for hours.
opts.hold: [min, max] seconds between moves · opts.glide seconds per move. Assign to any property. Assigning a number later takes over and glides home.
const n = audio.noise({ color: "pink" })
n.gain = 0.3
const f = audio.filter({ type: "bp", cutoff: 800, q: 6 })
n.to = f
f.cutoff = audio.wander(300, 2400, { hold: [1, 3], glide: 1 })audio.reverb(opts?)
A room. Sound fed into it rings and fades, darkening as it goes — like real air.
opts.decay seconds · opts.damp 0..1 · opts.wet 0..1 dry/wet mix.
const ping = audio.osc(660)
const e = audio.env([[0.003, 0.25], [0.15, 0]])
ping.gain = e
const room = audio.reverb({ decay: 4, damp: 0.65, wet: 0.5 })
ping.to = room
audio.every({ poisson: 1.2 }, () => {
ping.frequency.jump(440 + Math.random() * 660)
e.trigger()
})audio.delay(opts?)
An echo — the signal repeats, quieter and darker each time. The dub machine.
opts.time seconds · opts.feedback 0..1 · opts.damp 0..1 (darker repeats) · opts.wet mix. time is a live param — slow changes warble like tape.
const stab = audio.osc(330, { shape: "tri" })
const e = audio.env([[0.004, 0.3], [0.12, 0]])
stab.gain = e
const echo = audio.delay({ time: 0.375, feedback: 0.5, damp: 0.5 })
stab.to = echo
audio.every(1.5, () => {
e.trigger()
})audio.drive(opts?)
Saturation — pushes a signal into a soft ceiling until it growls. Warmth to grit.
opts.amount 1..50 pre-gain into a fixed tanh curve. amount is a live param — glide it, wander it.
const o = audio.osc(110, { shape: "saw" })
o.gain = 0.25
const d = audio.drive({ amount: 2 })
o.to = d
d.amount = audio.wander(1, 12, { hold: [2, 5], glide: 2 })audio.every(interval, fn)
A repeating clock for triggering things — the sequencer.
interval: seconds, or { poisson: mean } for organic timing. Inside fn, "now" is the beat's exact time. The handle's .rate retunes live. Keeps going in background tabs.
const k = audio.osc(80, { shape: "sine" })
const e = audio.env([[0.004, 0.8], [0.2, 0]])
k.gain = e
const beat = audio.every(0.5, () => {
k.frequency.jump(90)
k.frequency.rampTo(50, 0.07)
e.trigger()
})audio.start()
Wakes the world — call it from any click. Repeated calls are safe. If a gesture can't grant audio (iOS), the library finishes the job on your next tap. That's the whole lifecycle.
audio.stop()
Fades everything out and sleeps the engine. No clicks, nothing to dispose. start() brings it all back.
audio.state
One honest string: "idle", "starting", "running" or "stopped". If something is silent, this tells you why — silence is never the only symptom.
dB(x)
Decibels to linear gain — write dB(-12) instead of guessing 0.25. And the setters teach: a negative or NaN gain is rejected with an explanation in the console, not swallowed.
start
One file, zero dependencies. npm soon, as basement-js.
⬇ basement.js · ~37 KB · ES module · MIT
<!doctype html>
<button id="go">play</button>
<script type="module">
import { audio } from "./basement.js"
const voice = audio.unison(220)
const strike = audio.env([[0.01, 0.3], [1.3, 0]])
voice.gain = strike
go.onclick = () => {
audio.start()
strike.trigger()
}
</script>
Or just use the playground. You're early — break it and tell us how.