seslen

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.

21
0
21
TypeScript
public

seslen β€” High-DX Web Audio. Built-in UI sounds, one line away.

seslen

High-DX Web Audio. A small, ergonomic API on top of AudioContext with built-in, synthesised UI sound presets.

npm bundle size license playground

🎧 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. seslen is community-built β€” every preset starts as a one-file PR. The biggest contribution you can make right now is a new preset: open src/presets/, copy _template.ts, and ship it. We’ll help land it.

Why seslen?

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()

Features

  • πŸͺΆ Zero dependencies, pure ESM, tree-shakeable
  • 🎹 Synthesised UI presets β€” every play generated fresh on AudioContext
  • ⚑ Lazy AudioContext β€” created only on the first play()
  • πŸ”“ Auto-unlock β€” resumes the context on the first user gesture
  • ♿️ Respects prefers-reduced-motion β€” auto-mutes by default
  • πŸ’Ύ localStorage persistence for master volume + mute
  • πŸŽ› Per-call options β€” gain, rate, detune, loop, pan, fadeIn, fadeOut, when, sprite, interrupt
  • πŸŒ€ Jitter β€” rateJitter / gainJitter / detuneJitter so 100 ticks don’t sound like 1 tick repeated
  • 🚦 Throttle per call β€” drop rapid-fire repeats inside a window
  • 🎚 Polyphony cap β€” per-source voices + steal: "oldest" | "newest" | "drop"
  • 🚌 Buses β€” named sub-mixers with their own volume / mute and ducking (sidechain)
  • ⏱ Sample-accurate scheduling β€” play(name, { when: ses.now() + 0.25 })
  • πŸͺ„ Single-flight cache for URL sources β€” decoded only once
  • 🧱 Three source types β€” URL, AudioBuffer, or your own SoundFactory
  • πŸ“Ό Render to WAV via OfflineAudioContext β€” await ses.render("victory")
  • πŸ“ˆ Analyser tap β€” ses.analyser({ fftSize }) for waveforms / spectra
  • πŸ›‘ SSR-safe β€” every method is a typed no-op via seslen/server
  • πŸ”‘ Strict TypeScript β€” verbatimModuleSyntax, isolatedModules

Install

npm install seslen
# pnpm add seslen
# yarn add seslen
# bun add seslen

Quick start

1) Use the built-in presets

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"))

2) Register a remote URL (with a sprite)

const ses = createSeslen({
  sources: { ui: "/sounds/ui-pack.mp3" },
})
await ses.play("ui", { sprite: [0, 0.08], gain: 0.6, rate: 1.2 })

3) Register your own SoundFactory

import { 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")

4) Buses + ducking

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 })
})

5) Throttle, jitter, interrupt β€” for sounds that fire often

// 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
})

6) Schedule a sequence

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 })

7) Render a preset to a WAV file

const wav = await ses.render("victory", { durationSeconds: 1.5 })
const url = URL.createObjectURL(wav)
// download / share / preview

8) Visualise the master signal

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()

API

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

PlayHandle

interface 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
}

SourceDefaults

Set 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
}

BusHandle

interface 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
}

SoundFactory

type SoundFactory = (ctx: AudioContext, destination: AudioNode, opts: PlayOptions) => PlayHandle

The destination is a bus or the master gain β€” connect your last node to it.

Built-in presets

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.

Original eight

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

UI feedback

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

Game / playful

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

Ambient / state

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

Contributing presets

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.

SSR

// server.ts
import { createSeslen } from "seslen/server"
const ses = createSeslen() // every method is a typed no-op

Errors

import { SeslenError, ContextNotReadyError, DecodeError, LoadError } from "seslen"

SeslenError is the base. Use instanceof for targeted recovery.

Auto-unlock + accessibility

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.

License

MIT Β© productdevbook


πŸ’– Support

If seslen saves you engineering time, consider sponsoring on GitHub.

v0.3.3[beta]