Zero-dep, tree-shakeable Web Audio library with synthesised UI presets, buses + ducking, polyphony cap, throttle, jitter, fades, pan, sprites, OfflineAudioContext render-to-WAV, AnalyserNode tap, prefers-reduced-motion + SSR-safe. Strict TypeScript.
High-DX Web Audio. A small, ergonomic API on top of AudioContext with built-in, synthesised UI sound presets.
π§ Live playground: seslen.productdevbook.com β preview every preset, build patterns in the composer, and copy the exact code.
[!IMPORTANT]
Got a sound in your head? Send it our way.seslenis community-built β every preset starts as a one-file PR. The biggest contribution you can make right now is a new preset: opensrc/presets/, copy_template.ts, and ship it. Weβll help land it.
AudioContext is powerful but low-level: context unlock, decode, cache, gain, source lifetime, polyphony, ducking β all manual. seslen wraps that in a one-line API and ships with synthesised UI presets (no audio files, no network, no decode):
import { createSeslen } from "seslen"
import { presets } from "seslen/presets"
const ses = createSeslen({ sources: presets })
await ses.play("victory") // play a preset
await ses.play("tick", { gain: 0.4 }) // gain / rate / detune / pan / fades / jitter
const handle = await ses.play("ambient", { loop: true })
handle?.fadeTo(0, 0.4) // ramp gain β 0 over 400 ms
handle?.stop()
AudioContextplay()prefers-reduced-motion β auto-mutes by defaultlocalStorage persistence for master volume + mutegain, rate, detune, loop, pan, fadeIn, fadeOut, when, sprite, interruptrateJitter / gainJitter / detuneJitter so 100 ticks donβt sound like 1 tick repeatedvoices + steal: "oldest" | "newest" | "drop"volume / mute and ducking (sidechain)play(name, { when: ses.now() + 0.25 })AudioBuffer, or your own SoundFactoryOfflineAudioContext β await ses.render("victory")ses.analyser({ fftSize }) for waveforms / spectraseslen/serververbatimModuleSyntax, isolatedModulesnpm install seslen
# pnpm add seslen
# yarn add seslen
# bun add seslen
import { createSeslen } from "seslen"
import { presets, presetDefaults } from "seslen/presets"
const ses = createSeslen({
sources: presets,
defaults: presetDefaults, // per-preset jitter, throttle, voices
volume: 0.8,
persist: "seslen:master", // round-trip volume/mute through localStorage
})
button.addEventListener("click", () => ses.play("tick"))
form.addEventListener("submit", () => ses.play("success"))
const ses = createSeslen({
sources: { ui: "/sounds/ui-pack.mp3" },
})
await ses.play("ui", { sprite: [0, 0.08], gain: 0.6, rate: 1.2 })
SoundFactoryimport { createSeslen, type SoundFactory } from "seslen"
const blip: SoundFactory = (ctx, master, opts) => {
const t = ctx.currentTime
const o = ctx.createOscillator()
const g = ctx.createGain()
o.frequency.setValueAtTime(880, t)
g.gain.setValueAtTime(0.0001, t)
g.gain.linearRampToValueAtTime(0.1 * (opts.gain ?? 1), t + 0.005)
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.12)
o.connect(g).connect(master)
o.start(t)
o.stop(t + 0.14)
let stopped = false
return {
stop() {
stopped = true
try {
o.stop()
} catch {}
},
get done() {
return stopped
},
get duration() {
return 0.14
},
onEnded() {},
}
}
const ses = createSeslen({ sources: { blip } })
await ses.play("blip")
const ses = createSeslen({
sources: presets,
buses: { ui: {}, music: { volume: 0.6 } },
})
const music = await ses.play("ambient", { bus: "music", loop: true })
// Sidechain music down to 20% for 500 ms whenever the UI fires.
ses.on("play", (e) => {
if (e.name !== "@pattern") ses.bus("music").duck({ target: 0.2, holdSeconds: 0.5 })
})
// Hover sound: cap repeats, vary pitch slightly, never overlap with itself.
await ses.play("hover", {
throttle: 40, // drop calls inside 40 ms
rateJitter: 0.05, // Β±5% pitch variation
detuneJitter: 30, // Β±30 cents
interrupt: true, // stop any prior hover instance
})
await ses.playPattern([
{ id: "tick" },
{ at: 80, id: "tick", options: { gain: 0.5 } },
{ at: 160, id: "success" },
])
// Sample-accurate one-off:
await ses.play("notify", { when: ses.now() + 0.25 })
const wav = await ses.render("victory", { durationSeconds: 1.5 })
const url = URL.createObjectURL(wav)
// download / share / preview
const tap = ses.analyser({ fftSize: 256 })
const data = new Uint8Array(tap.fftSize / 2)
function frame() {
tap.getSpectrum(data) // 0..255 per bin
// draw bars β¦
requestAnimationFrame(frame)
}
frame()
createSeslen(opts?: SeslenOptions): SeslenInstance| option | type | default | description |
|---|---|---|---|
sources |
Record<string, SoundSource> |
{} |
Name β URL, AudioBuffer, or SoundFactory |
defaults |
Partial<Record<string, SourceDefaults>> |
{} |
Per-source jitter / throttle / voices / steal / bus defaults |
volume |
number (0β¦1) |
1 |
Master gain |
buses |
Record<string, { volume?: number; muted?: boolean }> |
{} |
Pre-declared named buses |
maxVoices |
number |
β | Global voice cap across all sources |
respectReducedMotion |
boolean |
true |
Auto-mute when prefers-reduced-motion: reduce is set |
persist |
string |
β | localStorage key for master volume + mute persistence |
preload |
boolean |
false |
Preload every URL source on first user gesture |
SeslenInstance| method | returns | description |
|---|---|---|
play(name, opts?) |
Promise<PlayHandle | null> |
Play a sound. Returns null if throttled or dropped |
playPattern(steps) |
Promise<PlayHandle> |
Schedule a timed sequence; combined handle stops every step |
preload(name) |
Promise<void> |
Fetch + decode (URL sources only) |
stop(name) |
number |
Stop every active handle for one preset; returns the count |
stopAll() |
void |
Stop every active PlayHandle |
register(name, src, defs?) |
void |
Add or replace a source (with optional defaults) |
unregister(name) |
boolean |
Remove a source; stops live handles for that name |
has(name) / names() |
boolean / string[] |
Registry introspection |
getVolume() / setVolume() |
number / void |
Master gain accessors (clamped 0β¦1) |
mute() / unmute() / isMuted() |
β | Master mute toggle |
bus(name) |
BusHandle |
Get or create a named sub-mixer (getVolume, mute, duck, β¦) |
now() / latency() |
number / number |
AudioContext.currentTime / baseLatency + outputLatency |
render(name, opts?) |
Promise<Blob> |
Render a sound to a 16-bit PCM WAV via OfflineAudioContext |
analyser(opts?) |
AnalyserTap |
Tap an AnalyserNode for waveform / spectrum data |
on(type, fn) / off() |
() => void / void |
Subscribe to play / ended / throttled / statechange |
pause() / resume() |
Promise<void> |
Suspend / resume the underlying context |
close() |
Promise<void> |
Close the AudioContext, clear caches, drop analyser |
isReady() / state() |
boolean / "idle" | "running" | "suspended" | "closed" |
Lifecycle inspection |
PlayOptions| field | type | default | description |
|---|---|---|---|
gain |
number |
1 |
Linear gain (0β¦1) |
rate |
number |
1 |
Playback rate (URL/AudioBuffer sources) |
detune |
number |
0 |
Detune in cents |
loop |
boolean |
false |
Loop until stop() is called |
pan |
number (-1β¦1) |
0 |
Stereo pan via StereoPannerNode |
fadeIn |
number (seconds) |
0 |
Linear ramp in from silence |
fadeOut |
number (seconds) |
0 |
Linear ramp to silence on stop() |
when |
number (seconds) |
0 |
Schedule start at currentTime + when (use ses.now()) |
sprite |
[offset, duration] |
β | Slice of a buffer source |
interrupt |
boolean |
false |
Stop every prior instance of the same sound first |
throttle |
number (ms) |
0 |
Drop the call if the same sound was triggered inside this window |
rateJitter |
number (0β¦1) |
0 |
Β± random multiplier applied to rate |
gainJitter |
number (0β¦1) |
0 |
Β± random multiplier applied to gain |
detuneJitter |
number (cents) |
0 |
Β± random offset applied to detune |
bus |
string |
β | Route through a named bus instead of master |
PlayHandleinterface PlayHandle {
stop(): void
readonly done: boolean
readonly duration: number | null
onEnded(cb: () => void): void
fadeTo?(value: number, seconds: number): void
setGain?(value: number): void
rampRate?(value: number, seconds: number): void
}
SourceDefaultsSet per-preset defaults via createSeslen({ defaults }) or register(name, source, defaults). Any per-call PlayOptions override these.
interface SourceDefaults {
gain?: number
rate?: number
detune?: number
pan?: number
rateJitter?: number
gainJitter?: number
detuneJitter?: number
minInterval?: number // throttle ms
voices?: number // polyphony cap
steal?: "oldest" | "newest" | "drop"
bus?: string
}
BusHandleinterface BusHandle {
readonly name: string
getVolume(): number
setVolume(value: number): void
mute(): void
unmute(): void
isMuted(): boolean
duck(opts: {
target: number
holdSeconds: number
attackSeconds?: number
releaseSeconds?: number
}): void
}
SoundFactorytype SoundFactory = (ctx: AudioContext, destination: AudioNode, opts: PlayOptions) => PlayHandle
The destination is a bus or the master gain β connect your last node to it.
import { presets, presetEntries, presetDefaults, presetTags } from "seslen/presets"
presets is the plug-and-play factory map for createSeslen. presetEntries carries the same factories with metadata (label, description, tags, recipe, motion hint, accent colour, author, defaults). presetDefaults is the per-preset jitter/throttle/voices map β pass it to createSeslen({ defaults }) for sensible baselines. presetTags is the deduplicated tag union.
| name | tags | recipe |
|---|---|---|
tick |
ui feedback click |
sine 4 kHz Β· 3 ms |
success |
feedback success chirp |
triangle 660β1320 Hz Β· 320 ms |
error |
feedback error |
square 220β150 Hz Β· 260 ms |
warning |
feedback warning |
square 880β660 Hz Β· 500 ms |
message |
notification bell |
sine 880 + 1320 Hz Β· 420 ms |
add |
ui feedback chirp |
sine 880β1480 Hz Β· 140 ms |
delete |
ui noise sweep |
noise sweep 4 kHzβ400 Hz Β· 200 ms |
victory |
game success arpeggio |
C-E-G-C arpeggio Β· 360 ms |
| name | tags | recipe |
|---|---|---|
hover |
ui hover |
sine 2.4 kHz Β· 25 ms |
pop |
ui feedback |
triangle 1200β320 Hz Β· 90 ms |
swoosh |
ui noise sweep |
noise bandpass 400β4000 Hz Β· 240 ms |
toggle-on |
ui feedback toggle |
sine 700 + 1100 Hz Β· 110 ms |
toggle-off |
ui feedback toggle |
sine 1100 + 700 Hz Β· 110 ms |
notify |
notification chirp |
sine 660-880-1320 Hz Β· 360 ms |
keypress |
ui click keyboard |
square 1.8 kHz Β· 12 ms |
scroll-tick |
ui click |
triangle 3 kHz Β· 6 ms |
drag |
ui drag |
sine 440β660 Hz Β· 120 ms |
drop |
ui drag |
sine 220β110 Hz Β· 120 ms |
expand |
ui transition |
sine 330β990 Hz Β· 200 ms |
collapse |
ui transition |
sine 990β330 Hz Β· 200 ms |
undo |
ui feedback |
triangle 880β520 Hz Β· 180 ms |
redo |
ui feedback |
triangle 520β880 Hz Β· 180 ms |
send |
ui noise sweep |
noise highpass 600β4000 Hz Β· 220 ms |
receive |
notification chirp |
sine 1320β880 Hz Β· 220 ms |
copy |
ui feedback |
sine 1480 + 1480 Hz Β· 90 ms |
paste |
ui feedback |
sine 880 Hz Β· 80 ms |
| name | tags | recipe |
|---|---|---|
level-up |
game success arpeggio |
C-D-E-G-C arpeggio Β· 480 ms |
coin |
game pickup |
square 988 + 1320 Hz Β· 180 ms |
jump |
game |
square 220β880 Hz Β· 100 ms |
shoot |
game noise |
noise bandpass 5 kHzβ500 Hz Β· 130 ms |
explosion |
game noise |
noise lowpass 2 kHzβ100 Hz Β· 600 ms |
| name | tags | recipe |
|---|---|---|
heartbeat |
ambient rhythm |
sine 60 Hz double-thump Β· 600 ms |
alarm |
feedback warning |
square 880β660 Hz Β· 4 cycles Β· 800 ms |
typewriter |
ui click |
triangle 2.6 kHz Β· 8 ms |
lock |
ui feedback click |
square 320 + 220 Hz Β· 140 ms |
unlock |
ui feedback click |
triangle 220 + 440 Hz Β· 140 ms |
PRs that add new presets are very welcome. Every preset is one self-contained file under src/presets/ with a metadata header β see src/presets/CONTRIBUTING.md for a 30-line template and review checklist.
The Vite/Tailwind playground in web/ auto-detects every preset, with search + tag filters β your contribution shows up the moment itβs wired into presets/index.ts.
// server.ts
import { createSeslen } from "seslen/server"
const ses = createSeslen() // every method is a typed no-op
import { SeslenError, ContextNotReadyError, DecodeError, LoadError } from "seslen"
SeslenError is the base. Use instanceof for targeted recovery.
Browsers keep AudioContext suspended until a user gesture. seslen calls resume() on the first pointerdown / keydown / touchstart, then detaches the listeners β you never deal with it.
When the user has prefers-reduced-motion: reduce, seslen auto-mutes the master bus (this is the default β opt out with respectReducedMotion: false). The setting is re-evaluated live.
If seslen saves you engineering time, consider sponsoring on GitHub.