basement.js

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()
}
mash it — it won't click

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.

wind

three wanderers on filtered air

full code
pulse

sixteen steps and a sub-rumble

full code
bassline

a saw through a resonant lowpass, jumping the root around

full code
echoes

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:

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 wakea swipe can claim the speaker icon but not the engine — a clean tap wakes both.
refresh popa small click on refresh mid-sound is the platform's teardown.
one open buga rare click under very fast mashing — parked with a measurement plan.
not yetcustom 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.