API reference · layer 1

@latticekit/audio

Sound without assets: WebAudio synthesis from declarative sound definitions, with voice limiting, buses and a music sequencer.

exports35 symbols in 6 modules — start with createAudio, createBed, createDeck
depends on@latticekit/core
environmentbrowser
gzipped7.76 kB against a 12 kB budget
sourcepackages/audio · README · index.d.ts

@latticekit/audio — sound without assets. Layer 1, depends only on @latticekit/core.

A table of oscillator recipes becomes the sound of a game: no files, no AudioContext until the player touches something, a hard ceiling on how loud a burst can get, and one continuous bed that follows a number the game already has.

const audio = createAudio({ sounds: {
  tap:     { bus: 'ui',  minGapMs: 40, layers: [{ wave: 'sine', hz: 1180, gain: 0.05, hold: 0.03, cutoff: 2400 }] },
  collect: { bus: 'sfx', minGapMs: 45, ladder: { steps: 5, windowMs: 900 },
             layers: [{ wave: 'triangle', hz: 660, toHz: 880, gain: 0.16, hold: 0.1, cutoff: 3200 }] },
} });

addEventListener('pointerdown', () => audio.unlock()); // nothing exists before this line runs
audio.play('collect');                                 // the argument type is 'tap' | 'collect'

The four rules, and the fourth is what makes the rest testable

  1. No AudioContext until a user gesture unlocks it. Not at module load, not at construction. A context created at boot is a console warning on every refresh and a suspended object in every unit test.
  2. Silent, never throwing, where there is no WebAudio. Importing this in a Node test is free, and play() in a headless run produces no sound and does not throw.
  3. Bursts must not stack. A per-sound minimum gap and a hard voice ceiling: summed gains above 1 clip into a click, and twenty simultaneous voices never sound twenty times better.
  4. Policy above, rendering below. Throttling, the ladder, the voice ceiling, bus resolution, sequencer step times and bed targets are all pure and clock-injected, emitting a reused VoicePlan through Audio.onScheduled. Almost everything is assertable with no mock at all. The price is stated openly rather than hidden: play() returns accepted, not a speaker movedAudio.available answers that.

What this package deliberately does not have

Audio files and decodeAudioData; a modular routing graph, LFOs or an effects rack — the moment routing is author-defined the clipping ceiling can no longer be validated statically; PannerNode and HRTF, which is 3D machinery for a 2D game priced per voice; AnalyserNode, because a visualiser needs a real device and onScheduled gives a HUD the beat without one; a module-level singleton, which would make two games on one page impossible; any use of localStorageMixer.snapshot returns a value and the game hands it to @latticekit/persist; and an auto-unlock listener of its own, because @latticekit/input owns the DOM event surface and the game calls Audio.unlock.

What it promises

  • No AudioContext exists until a user gesture unlocks it.
  • Silent, not throwing, where there is no WebAudio. play() in a headless run produces no sound and still reports acceptance — the policy above is pure and testable, the rendering below is not.
  • A hard voice ceiling. Summed gains above 1 clip into a click, and twenty voices never sound twenty times better.
  • A layer is one fixed chain of ten numbers, not author-defined routing. The moment routing is author-defined the clipping ceiling can no longer be validated statically.
  • This package stores nothing. The mixer returns a versioned snapshot and the game hands it to persist — there is no edge between two layer-1 packages.

Verbatim from .lattice/kit.json, which npm run lint keeps in step with the code.

sounds14 symbols

The shared vocabulary, the shape of a sound, and the table validator.

Everything else in this package is written in the words defined here, so this module imports nothing from the rest of it and can be read first.

Why a sound is a table row rather than a node graph

A layer is one fixed signal chain with the knobs exposed as numbers: source → [highpass] → [lowpass] → gain envelope → [pan] → bus, always, in that order, with no way to say otherwise. Ten numbers an author may vary; nothing about the routing.

That is not a simplification for its own sake. The moment routing is author-defined, the clipping ceiling stops being checkable: a feedback delay at 0.9 turns a 0.16 chord into a runaway and no static validator can see it. A fixed chain is what lets validateSounds say, before a note has sounded, that this table cannot clip.

Tier A throughout — no clock, no randomness, no platform. Safe to import in Node.

Wave type ↳ src/sounds.ts:33

type Wave = 'sine' | 'triangle' | 'square' | 'sawtooth' | 'noise'

The five sources.

noise is a shared, deterministically-filled white-noise buffer, looped; it is what makes thunks, air and hi-hats possible without a sample. A noise layer ignores hz and toHz, so a filter is the only thing shaping it — give one a cutoff or a highpass or it is a full-spectrum hiss.

There is deliberately no custom wave. A PeriodicWave is a Fourier table, which is an asset in the shape of an array, and the moment one is possible somebody pastes a 512-partial one into a config file and the package's zero-asset promise is gone.

BusId type ↳ src/sounds.ts:44

type BusId = 'music' | 'sfx' | 'ui'

The three buses, fixed and closed.

Fixed because the reason buses exist at all is a player who wants the music off and the alerts on, and that player needs the same three switches in every Lattice game. An open bus registry gives every game a different settings panel and gives this package a graph it can prove nothing about. Widening this union is a minor release with the evidence attached, not a config option.

BusName type ↳ src/sounds.ts:47

type BusName = 'master' | BusId

The three buses plus the one they all feed. Nothing connects to the device except master.

BUS_NAMES const ↳ src/sounds.ts:53

const BUS_NAMES: readonly BusName[]

Every bus name, in the order the mixer walks them. Master is first because it is the one whose gain multiplies all the others, and a reader should meet it first.

MAX_VOICES const ↳ src/sounds.ts:64

const MAX_VOICES = 24

Hard ceiling on one-shot voices in flight. Past it, SoundDef plays are dropped rather than queued — a queued burst arrives after the moment that caused it and reads as lag, which is worse than the sound not happening.

One layer is one voice, so a three-layer sound costs three. A sound with more layers than the ceiling can therefore never play at all; that is a table to fix, not a case to special case, because admitting it would mean emitting more voices than the ceiling names.

ATTACK_SEC const ↳ src/sounds.ts:73

const ATTACK_SEC = 0.006

Fixed attack, in seconds. Long enough to kill the leading-edge click that a gain stepping from 0 to 0.3 in one sample produces, short enough that the sound still lands on the tap.

Raise it per layer with Layer.attack for a swell; a longer attack is precisely how a sound stops being a hit.

SEMITONE const ↳ src/sounds.ts:84

const SEMITONE = 1.0594630943592953

Equal temperament: the twelfth root of two, written out rather than computed.

A literal and not Math.pow(2, 1 / 12) because pow is Tier B — not required by ECMA-262 to be correctly rounded — and a constant that differs in the last bit between two engines is a constant that cannot be compared in a test with toBe. A detune that is not a semitone sounds like a fault rather than like variation, which is why the ladder walks in these and not in cents.

RAMP_SEC const ↳ src/sounds.ts:94

const RAMP_SEC = 0.015

Time constant for every gain change made to an already-running node, in seconds.

Assigning gain.value on a live node is an audible click; a volume slider that assigns directly produces one click per pixel of travel. Every live parameter change in this package goes through an exponential approach with this time constant instead. Below about 10 ms the click comes back; above about 50 ms a settings panel feels broken.

Layer interface ↳ src/sounds.ts:102

interface Layer {

One oscillator or noise burst inside a sound.

Layers within a sound play together; Layer.delay is what turns a chord into an arpeggio. Their gains sum, which is why validateSounds exists.

10 members
readonly wave: Wave

Which of the five sources. noise ignores hz and toHz.

readonly hz: number

Starting frequency in Hz. Ignored when wave is noise.

readonly toHz?: number

Sweep to this frequency across the layer's life.

The ramp is exponential, always. Pitch is heard logarithmically, so a linear sweep from 880 down to 190 spends most of its duration in the bottom octave and reads as a fault rather than as a fall. Omit to hold hz.

readonly gain: number

Peak gain, 0–1, before the sound's per-play gain, the bus and master.

These sum across layers and WebAudio hard-clips above 1.0: a chord adding to 1.4 does not play 40% louder, it plays distorted, and it distorts differently depending on what else happens to overlap it — which is why it is miserable to diagnose by ear rather than with validateSounds.

readonly hold: number

Seconds of decay after the attack. This, and not toHz, is what makes a sound feel heavy or brief; a hold of 2 s on a menu blip is what makes an interface feel slow.

readonly attack?: number

Override ATTACK_SEC, in seconds. Raise it for a swell.

readonly delay?: number

Seconds before this layer starts, measured from the play. This is how a chord arpeggiates.

readonly cutoff?: number

Low-pass corner in Hz. The single most useful knob in the table: it is the difference between "a square wave" and "a distant announcement in a car park". Omit for no filter.

readonly highpass?: number

High-pass corner in Hz. Rare and specific — everything below about 6 kHz is what makes a hi-hat sound like a cough. Giving both this and cutoff is a band-pass and costs one extra node per voice.

readonly pan?: number

Static pan, −1…1.

Almost always the wrong place for it: pan belongs to the event — where the thing was on screen — not to the recipe. Set PlayOptions.pan instead. This exists for the genuinely fixed case, a layer that is meant to sit off-center in every play.

SoundDef interface ↳ src/sounds.ts:160

interface SoundDef {

A sound, as a game author writes it.

The keys of the table passed to createAudio become the id union, so there is no SoundId type to keep in sync by hand and a typo at a call site is a compile error.

5 members
readonly layers: readonly Layer[]

Played together. At least one, and their overlapping gains must sum under full scale — see validateSounds.

readonly bus?: BusId

Which bus this sound is mixed on. Default 'sfx'.

Put anything a player might reasonably want silenced separately on its own bus: the interface clicks on ui, the world on sfx. A sound on the wrong bus is a player muting more than they meant to.

readonly minGapMs: number

Minimum milliseconds between two plays of this sound.

Why this is required rather than optional. A COLLECT ALL button banks twenty buildings in one tap: twenty play('collect') calls in the same millisecond. Twenty stacked oscillators is not twenty times as satisfying — the gains sum past 1 and the output clips into a click. Making the field optional means the author who most needs it is exactly the author who omits it, so it is required and validateSounds rejects a zero.

readonly ladder?: { readonly steps: number; readonly windowMs: number; }

Successive plays inside windowMs step up the scale rather than repeating, wrapping after steps and resetting once the player stops.

This is what makes four taps in a row feel like a run rather than four identical blips. A ladder rather than a random detune on purpose, and not only because Math.random is banned in this kit: a repeat that moves unpredictably sounds broken, and one that moves up a scale sounds alive.

readonly spatial?: boolean

Whether PlayOptions.pan is honoured for this sound.

Defaults to bus === 'sfx', which is the useful default: world events pan, and the interface does not follow the camera. A menu click that moves in the stereo field as the player drags the map is the most disorienting thing this package can do.

PlayOptions interface ↳ src/sounds.ts:213

interface PlayOptions {

Per-event modulation. Everything here is about this play, never about the recipe.

Every field is clamped rather than validated: these carry player- and camera-derived numbers, and a NaN written to an AudioParam poisons that parameter for the life of the node. A clamp at this boundary is the difference between one quiet sound and a voice that is silent forever.

4 members
readonly gain?: number

0–1 multiplier on the whole sound. Distance falloff lives here, and it carries more than pan does: an off-screen sound made quieter is far more legible than one made left.

readonly pan?: number

−1…1, clamped to ±maxPan. Ignored unless the sound is SoundDef.spatial.

readonly detune?: number

Semitones, applied on top of the ladder step. For pitching a sound by size or by tier.

readonly at?: number

Audio-clock seconds to start at. Omit for now. Use it to place a sound inside a beat — it is the same clock onScheduled reports and the same clock the sequencer pins to.

VoicePlan interface ↳ src/sounds.ts:239

interface VoicePlan {

What the engine decided to build, handed to every onScheduled listener — one call per layer. Emitted by one-shots, by the deck and by a bed's layers alike; source is the sound id, the track id, or 'bed'.

This object is reused between calls. It is emitted once per layer per play, which the sequencer alone does eight times a second; a fresh object each time is a garbage collector pause with a pleasant signature. Read it, or copy the fields you keep — do not retain it.

10 members
readonly source: string

The sound id, the track id, or 'bed'.

readonly bus: BusName
readonly layer: number

Index into the sound's layers, the song's tracks, or the bed's layers.

readonly wave: Wave
readonly hz: number

Hz at start, after the ladder and any per-play detune.

readonly toHz: number

Hz at end. Equals hz when the layer does not sweep.

readonly gain: number

Final gain after the sound's per-play gain and the ladder, and before bus and master.

readonly pan: number

−1…1, already clamped to the engine's maxPan.

readonly start: number

Audio-clock seconds.

readonly end: number

Audio-clock seconds, including the release tail — the decay is the release here, so this is start + attack + hold. This is what the voice ceiling counts, which is why it is on the plan rather than inside the renderer: a ceiling driven by onended can never come back down for a voice that never ends.

SoundProblem interface ↳ src/sounds.ts:272

interface SoundProblem {

One thing wrong with one sound, named with the numbers in it.

Returned rather than thrown: a shipped game must not refuse to start because a sound is 0.03 too loud. The game's own test asserts the array is empty, which is where a table fault should stop a build.

3 members
readonly sound: string

The table key.

readonly code: 'no-layers' | 'clips' | 'no-throttle' | 'ladder-shorter-than-gap' | 'ladder-too-short' | 'inaudible' | 'sub-audio-frequency' | 'zero-hold'
readonly message: string

Names the author's mistake with the numbers in it: collect peaks at 1.24, ceiling is 0.95.

validateSounds function ↳ src/sounds.ts:356

function validateSounds<Ids extends string>(sounds: Readonly<Record<Ids, SoundDef>>): readonly SoundProblem[]

Check a sound table for the faults that produce a worse game and no error.

The source game this kit came from spent five hand-written tests asserting its table was sane, and every game built on Lattice would otherwise write the same five. They ship here instead, as one call.

What it catches: a chord that sums past full scale, a burst-capable sound with no throttle, a ladder shorter than its own gap or shorter than two steps, a layer at 8 Hz that is a rumble rather than a tone, a layer at a gain nobody can hear, a layer with no hold at all.

What it cannot catch is a sound that is declared and never played. That defect needs the game's own source: grep src for every key of the table, as the README shows. It is the one failure in this class that has actually shipped, repeatedly.

Returns problems rather than throwing, in table order, then in the order listed above.

bus3 symbols

The mixer: three buses, one master, and a snapshot the game hands to @latticekit/persist.

Why gain and mute are two values and not one

A mute implemented as "set the gain to 0" is the bug where turning the music back on returns it at full volume, because the level the player chose was overwritten by the act of silencing it. Here every bus carries an independent gain and an independent mute flag, and the value sent to the device is their product. setMuted('music', false) therefore restores the exact level that was there before, with no bookkeeping anywhere else.

Why this module stores nothing

snapshot() returns a small plain value and restore() takes one back; the game hands that value to @latticekit/persist. Three reasons, in order of weight. Layering: audio and persist are both layer 1, so an edge between them is a design error rather than a convenience. A device preference is not save state: a player who hits START OVER must not get their sound turned back on, and a mute must not ride along in an export — that is a device-scoped store, which belongs to persist. Testability: a mixer that writes to storage cannot be tested without a storage shim.

Tier A, no clock, no platform. The device is reached only through the apply callback.

MixerState interface ↳ src/bus.ts:36

interface MixerState {

The mixer as a value, versioned because it goes in a save and a save that cannot say what it is cannot be migrated.

Both maps are complete — every bus, every time — so a reader never has to guess whether a missing key means "default" or "the writer had a different bus list".

3 members
readonly version: 1
readonly gain: Readonly<Record<BusName, number>>
readonly muted: Readonly<Record<BusName, boolean>>

Mixer interface ↳ src/bus.ts:49

interface Mixer {

The three buses and master. Gain and mute are separate on purpose — see the module header.

The reason the bus list is closed rather than a registry: a player who wants the music off and the alerts on needs the same three switches in every Lattice game, and an open registry gives every game a different settings panel.

6 members
gain(bus: BusName): number

The player's chosen level, 0–1. Unaffected by muting — that is the whole point.

setGain(bus: BusName, gain: number): void

Set a level, 0–1, clamped. A non-finite value is ignored rather than stored: NaN written to an AudioParam poisons it for the life of the node, and a bus node lives as long as the context does, so one bad slider frame would silence that bus for the session.

Ramped over RAMP_SEC rather than assigned: a step change on a running oscillator is an audible click, and dragging a slider would produce one per pixel of travel.

muted(bus: BusName): boolean
setMuted(bus: BusName, muted: boolean): void

Independent of gain. Muting master silences everything and preserves every bus's level, so a settings panel can offer one switch and four sliders without them fighting.

snapshot(): MixerState

The whole mixer as a value to hand to @latticekit/persist.

Not an output parameter and not on any hot path: this is called when a settings panel closes, not per frame.

restore(state: Readonly<MixerState>): void

Apply a snapshot.

Unknown, missing and out-of-range fields are clamped or ignored rather than thrown. A save written by an older build — or a truncated one, or {} — must not be able to silence a game permanently or stop it booting. The rule is: a value that parses as a number is clamped into range; anything else leaves that bus exactly as it was.

effectiveGain function ↳ src/bus.ts:93

function effectiveGain(mixer: Mixer, bus: BusName): number

What the device is actually set to for a bus: the player's level, or zero when muted.

Exported because it is the one piece of arithmetic a caller has to repeat otherwise — a HUD meter, or a test asserting that master 0.5 under music 0.5 renders a plan's gain at a quarter. The plan itself carries the gain before bus and master, so the multiplication belongs to whoever is asking.

engine3 symbols

The engine: a table of recipes, a gesture, and a play that is honest about what it did.

The four rules this module exists to keep

1. Nothing is created before a gesture. No AudioContext at module load and none at construction. Browsers block audio before a gesture anyway; the stronger reason is that a context created at boot is a console warning on every refresh and a suspended object in every unit test. Audio.unlock is called from the game's own interaction handler and everything before it is a silent no-op.

2. Silent where there is no audio, never an exception. There is no AudioContext in Node. Every entry point checks and returns rather than throwing, so importing this in a test is free and play() in a headless run produces no sound.

3. Bursts must not stack. Two independent defences, because they fail differently: a per-sound minGapMs and a hard voice ceiling. See voice.ts.

4. Policy above, rendering below. Everything in this file that decides anything is pure and driven by an injected clock, and it emits a reused VoicePlan through Audio.onScheduled. The device is reached through one interface with five methods. The price is stated openly rather than hidden: play() returns "accepted", not "a speaker moved", so the same branch runs everywhere and Audio.available answers the other question. A policy that only runs when a device exists is a policy nobody can test.

The fifth thing, which is a property of this file rather than a rule about sound

Every option reads back off the engine, and the two that are policy also move. For maxVoices that is not a convenience: dispose() closes the AudioContext, a document gets about six of them ever, so a ceiling that can only change by rebuilding is a ceiling whose slider silences the page after six drags. It is one integer in one comparison — nothing allocated from it, no handle derived from it, nothing recorded carrying it — so nothing downstream has a correctness claim that it did not change, and it is live. The table and the context are the opposite case: the id union and the node graph are already built from them, so they are readable and the setter for either is new. docs/rfc/live-options.md is the test both answers come from.

AudioOptions interface ↳ src/engine.ts:61

interface AudioOptions<Ids extends string> {

Options for createAudio. Every field has a default that is right for a game.

5 members
readonly sounds: Readonly<Record<Ids, SoundDef>>

The table. Its keys become the type of Audio.play's first argument, so there is no id union to maintain by hand and play('colect') is a compile error.

readonly context?: () => AudioContext | null

How to obtain a context. Defaults to AudioContext ?? webkitAudioContext, returning null when neither exists or when construction throws.

This is the seam the whole test suite hangs from — context: () => null is a headless run, and a spy proves nothing is constructed before unlock. It is also how a host that already owns a context, an app embedding two Lattice games, passes one in.

readonly now?: () => number

The clock, in audio-clock seconds. Defaults to the context's currentTime, and to a constant zero when there is no context.

Two consequences worth stating. First, performance.now() never appears in this package, so the determinism lint passes with no exemption. Second, throttles are measured in the same time base as scheduling, so a throttle can never disagree with the notes it is throttling. Author-facing fields stay in milliseconds (minGapMs, windowMs) because that is the unit a human reasons about; the conversion is this package's problem.

Supplying one overrides the device clock, which is what a test wants and what a game almost never does: a clock that disagrees with currentTime schedules notes in the past.

readonly maxVoices?: number

Override MAX_VOICES. Lower it on a game with a busy bed; raising it is almost always wrong, because twenty simultaneous voices never sound twenty times better and their gains sum into a clip.

Policy, not identity: read it back with Audio.maxVoices and move it with Audio.setMaxVoices. It is one integer in one comparison — nothing is allocated from it and nothing recorded carries it — so the honest way to offer a player a voice slider is that setter and never a rebuild. dispose() closes the AudioContext and a document gets about six of them; a ceiling that could only be changed by rebuilding is a slider that permanently silences the page after six drags.

readonly maxPan?: number

Absolute pan limit, default 0.6. Read it back with Audio.maxPan and move it with Audio.setMaxPansetMaxPan(0) is a mono switch for a settings screen, and it costs no nodes and no rebuild.

Audio interface ↳ src/engine.ts:132

interface Audio<Ids extends string> {

The engine. One per game; there is deliberately no module-level singleton.

Every field of AudioOptions is readable off this object, because a value a caller handed over and cannot read back is a value they must store twice, and two copies drift with no error when they do. Two of them also move:

optionread it backmove it
soundsAudio.soundsthe id union is the table's keys — a different table is a different engine
contextAudio.context — the device it producedunlock builds it once; there is no second
nowAudio.nowit is the clock; a game that wants a different one builds a different engine
maxVoicesAudio.maxVoicesAudio.setMaxVoices
maxPanAudio.maxPanAudio.setMaxPan

The line between the two halves is docs/rfc/live-options.md's single question — *does anything downstream have a correctness claim that this value did not change?* The table and the context are identity: the id union, the node graph and the device are already built from them. The ceiling and the pan limit are policy: two numbers read inside a comparison and a clamp, with nothing allocated, handed out or written down that depends on either.

14 members
readonly mixer: Mixer

The three buses and master. Survives unlock — levels set before a device apply after it.

readonly available: boolean

Whether a real device exists. False in Node, in a locked-down browser, and before unlock.

readonly voices: number

One-shot voices whose scheduled end is still in the future. A bed's layers and a deck's notes are not counted: a bed never ends, so counting it would eat the ceiling forever.

May read above Audio.maxVoices for one release tail after the ceiling is lowered — see Audio.setMaxVoices. It is what is sounding, not what is allowed.

readonly sounds: Readonly<Record<Ids, SoundDef>>

The engine's own frozen copy of AudioOptions.sounds.

Frozen, and a copy, for one reason: the engine looks its recipes up in a map taken at construction, so a getter that handed back the caller's object would happily report an id that play refuses the moment they added one to it. What you read here is what the engine will actually play.

For a debug overlay listing every sound, a test asserting the table it was built from, and validateSounds(audio.sounds) on a table assembled at runtime. The SoundDefs inside are the caller's own objects and are not deep-frozen — mutating one still changes what plays, which is a thing to avoid rather than a thing this copy can prevent.

readonly context: AudioContext | null

The device AudioOptions.context produced, or null before the first successful unlock and after dispose.

The factory is not handed back, and that is the point: calling it again is how a page ends up with two contexts out of the six or so it will ever get. This is the readback for that option in the only form that is useful — a settings screen showing sampleRate or state, or a host embedding two Lattice games that needs to prove they share one device.

Do not close() it. That is dispose's job, and closing it behind the engine's back leaves an engine that reports available and renders silence.

now(): number

The engine's clock, in audio-clock secondsAudioOptions.now, or the device's currentTime, or a constant zero when there is neither.

The readback for now, and the only honest source for PlayOptions.at: a caller placing a sound inside a beat has to name a time in this clock, and reading context.currentTime themselves is wrong before unlock and wrong again whenever a clock was injected. A non-finite reading is coerced to 0 here rather than passed on, because NaN reaching a scheduled time silently stops the throttle throttling.

readonly maxVoices: number

The hard ceiling on one-shot voices in flight — AudioOptions.maxVoices, or MAX_VOICES, or whatever Audio.setMaxVoices last set.

It exists so nothing keeps a second copy. The slider that moves the ceiling needs its own current value to render; a HUD showing "17 / 24" needs the denominator; a diagnostic reporting a refused burst needs to name the number that refused it. Given no reader, each of those keeps its own copy, and they agree until the first setMaxVoices and never after.

setMaxVoices(maxVoices: number): void

Move the voice ceiling. Takes effect on the next play.

A setter rather than a rebuild, because rebuilding is not renewable here: dispose() closes the AudioContext, browsers cap live contexts per document at about six, and a ceiling slider that rebuilt the engine on every drag would permanently silence the page in roughly a second. Nothing downstream has a correctness claim on this number — it is one integer in one comparison, no buffer is sized from it, no handle derived from it, and no save or log records it.

Lowering it below what is already sounding refuses new plays; it does not cut live ones short. Those voices are scheduled on the device already and stopping them early is an audible chop, so Audio.voices may exceed the new ceiling until their release tails pass. Raising it admits again immediately — there is nothing to rebuild on the way back up.

Throws

RangeError if maxVoices is not an integer >= 1 — the same refusal, in the same words, that createAudio gives, because this number is author-facing at both entrances and a ceiling of 0 is silence nobody can debug. A rejected call changes nothing.

readonly maxPan: number

The absolute pan limit in force, 0–1 — AudioOptions.maxPan, or 0.6, or whatever Audio.setMaxPan last set. VoicePlan.pan is already clamped to ±this.

Read it to render a "stereo width" control without keeping a copy of the number it moves, and to explain why a sound asked for pan: 1 and landed at 0.6.

setMaxPan(maxPan: number): void

Move the pan limit. Applies to the next play; voices already scheduled keep the pan they were built with, because a panner's value is set once at construction of that voice.

setMaxPan(0) is a mono switch, which is a real accessibility setting and costs no nodes. Clamped into [0, 1] and a non-finite value is ignored rather than stored, which is the same rule createAudio applies to the same field: this one can reach a settings slider, and a NaN written to an AudioParam poisons that node for its whole life. A setter inherits its value's policy; it does not get a stricter or a softer one for being a setter.

unlock(): boolean

Create the context, or resume one the browser suspended. Idempotent and cheap; call it from every interaction handler you have.

Resuming matters as much as creating. A tab backgrounded long enough gets its context suspended, and without the resume, sound works for one session and then silently stops.

Returns available, so a settings panel can say "audio unavailable" truthfully.

play(id: Ids, options?: PlayOptions): boolean

Play a sound if policy allows it right now. Returns whether it was accepted — not whether a speaker moved.

Acceptance is decided by the throttle, the ladder and the voice ceiling, all of which run identically with or without a device. A rejection means one of those three said no: the same sound played again inside its minGapMs, or the ceiling is full. Use available to ask about the device.

onScheduled(listener: (plan: Readonly<VoicePlan>) => void): Disposer

Observe every voice the engine schedules, one call per layer. Returns a disposer.

Two customers, which is why it earns an export where a test-only hook would not: a test asserts on plans with no device at all, and a HUD flashes a meter on the beat without an AnalyserNode or a real context. The plan object is reused — copy what you keep.

dispose(): void

Stop everything, disconnect, close the context, and tear down every bed and deck built on this engine.

Not optional politeness: browsers cap live contexts per document — six, historically — and a test file that creates one per case exhausts that cap and fails in a way that looks like a broken assertion. Every method is a silent no-op afterwards.

createAudio functionstart here ↳ src/engine.ts:336

function createAudio<Ids extends string>(options: AudioOptions<Ids>): Audio<Ids>

Build an engine. The only constructor, and it creates nothing until Audio.unlock.

There is deliberately no module-level singleton. The source game this kit came from has one and it is right for a game; it is wrong for a kit, because it makes two games on one page impossible, makes test order matter, and creates state at import time in a package whose first rule is that nothing exists until a gesture.

Throws

RangeError if maxVoices is not a positive integer — a programmer error, and the one thing here worth refusing loudly, because a ceiling of 0 is silence nobody can debug. Audio.setMaxVoices refuses the same values in the same words.

bed4 symbols

The bed: the continuous half, and the real answer to "what survives twenty minutes".

Why the drone comes before the sequencer

A loop is annoying at twenty minutes because it is *the same twenty minutes regardless of what the player did*. What wears out is melody — the thing the ear learns, predicts and then resents. Texture does not wear out; nobody has ever been annoyed by rain.

A bed has nothing to remember, and when it is driven by game state it stops being decoration and becomes a readout: it thickens as the world grows, so scale is something you hear before you look at a number, and it sags in pitch when the power goes, because plant losing power winds down. A drop in level alone reads as a mixing change; a drop in pitch reads as machinery stopping. Information does not become tedious.

And without one, a Lattice game is silent between taps — which for an idle game is 95% of a session. Every game built on this kit would hand-roll a bed, badly, with Math.random.

A note is an event; a drone is a state

That is why this is not the sequencer with a 0 bpm mode. What the two share, and all they share, is a vocabulary: both take a 0–1 level with the same meaning, both write to the same buses, and a game drives both from the same number in one line.

Everything here is pure and clock-injected except the two lines that hand a target to a ToneHandle. Targets are emitted through onScheduled as they change, so what a bed is doing is assertable in Node with no device at all.

BedLayer interface ↳ src/bed.ts:64

interface BedLayer {

One continuous layer of a bed. Every layer runs forever; only its gain, filter and pitch move.

There is deliberately no pan. Panning a transient by screen position makes a world a place rather than a picture; panning anything continuous means it sweeps across the stereo field every time the camera moves, which reads as a fault.

7 members
readonly wave: Wave
readonly hz: number

Base frequency in Hz, before the sag. Ignored when wave is noise.

readonly gain: number

Gain at level 1, scaled toward silence as the level falls — linearly, so level and loudness are the same number and a test can assert an exact product. An empty world is silent, not quiet.

readonly cutoff: number

Low-pass corner in Hz at level 0. Filtered noise is the only honest way to do moving air.

readonly cutoffAtFull?: number

Multiple the cutoff opens to at full level. This, and not gain, is what makes a busy hall sound busy: volume alone reads as "the same hum, nearer", while the top end arriving reads as "there is a lot of it".

readonly beat?: number

Detune in Hz, added to hz. Write two layers with the same hz and give the second a beat of a fraction of a hertz: two near-identical sources beat, audibly, and that beat is what stops a bed sounding like a synthesiser pad. Real plant is never in phase.

readonly band?: readonly [number, number]

The range of tone this layer speaks over, at full weight in the middle and fading to nothing at each edge. Omit for "always".

This is what makes the bed a soundscape rather than a hum with a knob. Crickets on a low band and coil whine on a high one, and the valley crossfades between them as the same number that lerps the palette moves: one filter sweep sounds like a filter sweep, two layers trading places sounds like evening.

A band that touches 0 or 1 does not fade at that end — a layer banded [0, 0.5] is at full weight at tone = 0, not silent there. Overlap adjacent bands ([0, 0.5] and [0.4, 1], not [0, 0.4] and [0.55, 1]), or keep one unbanded layer: a bed that is completely silent at some middle value of tone is a hole the player walks into.

BedOptions interface ↳ src/bed.ts:106

interface BedOptions {

How a bed is wired, as opposed to what it sounds like. Every field has a working default.

3 members
readonly bus?: BusId

Default 'sfx', so a player muting music does not silence the world. The bed is not a soundtrack — it is the room.

readonly sagTo?: number

Pitch multiplier at tone = 0. Default 0.55. Clamped to (0, 1].

readonly glideSec?: number

Seconds for a change to arrive. Default ~1. Clamped to at least a millisecond.

Bed interface ↳ src/bed.ts:128

interface Bed {

A running bed. Build it with createBed; drive it every frame; stop it once.

Every field of BedOptions reads back off this object — bus, sagTo, glideSec — so a panel driving a bed never keeps a second copy of a number it already handed over. None of the three has a setter today: bus cannot have one, because every layer's node is already connected to that bus and moving it is a teardown wearing a setter's signature; the other two are policy and could, which is docs/rfc/live-options.md §10's business rather than this interface's.

7 members
set(level: number, tone?: number): void

Drive the bed. Safe to call every frame — it ramps toward the figures rather than resetting anything, so nothing clicks, nothing restarts, and nothing is allocated. A layer whose targets have not moved is not re-issued at all: setTargetAtTime with an unchanged target re-anchors the curve, so a bed nudged every frame would never actually arrive.

A non-finite argument leaves that value as it was, rather than clamping to an edge: NaN reaching an AudioParam poisons it for the life of the node, and a bed layer's node lives as long as the session.

readonly level: number

The last level given, clamped. For a HUD, and so a game need not keep its own copy.

readonly tone: number

The last tone given, clamped.

readonly bus: BusId

The bus every layer is mixed on — BedOptions.bus, or 'sfx'.

Read it to answer the question a muted world always raises: whether this bed rides on the switch the player just moved. It is fixed for the bed's life, because the layers' nodes are connected to that bus the moment they stand up.

readonly sagTo: number

The pitch multiplier at tone = 0 in force — BedOptions.sagTo, clamped as it was at construction. Read it to label a "power sag" control with the depth it actually has.

readonly glideSec: number

Seconds a change takes to arrive — BedOptions.glideSec, clamped as it was at construction. It is also the end - start of every plan this bed emits, which is why a test asserting the crossfade needs to read it rather than assume the default.

stop(fadeSec?: number): void

Fade out and tear the layers down. A stopped bed cannot restart — build another. Safe to call twice, and safe to call with no device.

createBed functionstart here ↳ src/bed.ts:184

function createBed<Ids extends string>(audio: Audio<Ids>, layers: readonly BedLayer[], options?: BedOptions): Bed

Stand up a bed on an engine.

A free function rather than a method on Audio so that a game wanting sounds and nothing else does not carry it, and so that two beds — a valley and an interior — are the obvious thing rather than a special case.

A bed built before unlock() stands its nodes up on the first unlock, at whatever level it has been driven to in the meantime, so a game may create it during boot and drive it from frame one. Bed layers are not counted against the voice ceiling: they never end, so a counter driven by onended could never decrement them, and five layers must not eat a fifth of the ceiling forever. They are bounded by construction instead — the layer count is fixed here and cannot grow.

music10 symbols

The music deck — opt-in, and the minimum that survives an hour.

A sequencer is the difference between a game with sound and a game with a soundtrack, so it stays in the package; but it is behind its own factory rather than a member of Audio, so a game that never imports createDeck never ships it. That matters against a 12 kB budget, and it makes the ranking honest: the bed is what a small game reaches for first.

The five properties that make a loop survive an hour behind a spreadsheet

  1. It rests. The melody speaks on well under three quarters of the steps — a note on every step is a drill. Percussion is exempt and obeys the opposite rule: a steady hat is the thing a listener stops hearing and starts moving to. validateSong enforces the first and knows about the second, which is what Track.melodic is for.
  2. It is mixed under the information. Every note is quieter than the quietest sound that means something. A theme that buries the alarm gets the whole game muted — and muting to escape the music also loses the alarm.
  3. The harmony is one loop, rotated. C-G-Am-F is Am-F-C-G started elsewhere; the same four chords read bright or wistful depending only on which one lands first.
  4. Nothing is bright. Every note goes through a low-pass. Brightness is what makes a loop nag.
  5. Bars are not identical. Two cheap deterministic mechanisms remove the seam without a composer: a per-track bar mask (Track.bars) and a seeded per-note Track.drop, rolled from hash3(seed, bar, step, track) — stateless, so a muted track cannot shift what every other track plays, and the same song is the same twenty minutes on every machine.

Plus intensity, the deck's version of the bed's level and the same 0–1 number. Tempo never changes; a tempo change mid-loop is a mistake you cannot un-hear.

LOOKAHEAD_SEC const ↳ src/music.ts:47

const LOOKAHEAD_SEC = 1.5

How far ahead the deck schedules, in seconds.

This number is about background tabs and nothing else. setInterval is throttled to a second or more in a hidden tab, so a horizon shorter than that leaves audible gaps the moment a player changes tab — the source game's 250 ms horizon is fine in the foreground and stutters in the background. Notes inside the horizon are already pinned to the audio clock and sound whatever the timer does.

TrackVoice interface ↳ src/music.ts:81

interface TrackVoice {

The instrument a track plays. It has no pitch of its own: the sequencer supplies that from the chord, which is what lets one progression change every track's notes at once.

8 members
readonly wave: Wave
readonly gain: number

Peak gain of one note. See validateSong — these sum when tracks land on a step together.

readonly hold: number

Seconds of decay. A note longer than a step is a legato line; longer than two is a drone.

readonly attack?: number

Override the fixed attack. Raise it and a lead becomes a pad.

readonly cutoff?: number

Low-pass corner in Hz. Give every track one: brightness is what makes a loop nag.

readonly highpass?: number

High-pass corner in Hz. For a hat — everything below about 6 kHz makes one sound like a cough.

readonly sweepTo?: number

Sweep to this multiple of the note's pitch. A kick drum is a sine at 125 Hz swept to 0.35 in a tenth of a second; there is no other way to get a kick out of an oscillator, and a sample would mean shipping a binary for the sake of one thud.

readonly fixedHz?: number

Fixed Hz, ignoring the chord entirely. Percussion does not follow the harmony.

Note interface ↳ src/music.ts:104

interface Note {

A step within the bar, and how far above the bar's root it speaks. Omit semis for the root.

2 members
readonly step: number

0-based, inside [0, song.steps).

readonly semis?: number

Semitones above the bar's root.

Track interface ↳ src/music.ts:112

interface Track {

One instrument's part. Tracks are independent: muting one cannot move another's notes.

7 members
readonly id: string

Stable id, for MusicDeck.setTrackMuted, for VoicePlan.source, and for reporting.

readonly voice: TrackVoice
readonly notes: readonly Note[]
readonly bars?: readonly number[]

Which bars of the progression this track speaks on, by index. Omit for every bar.

The cheap anti-seam: bars: [0, 2] sits the arpeggio out of half the progression, so the loop stops announcing where it begins.

readonly minIntensity?: number

Silent below this intensity, 0–1. Default 0 — always speaking.

readonly drop?: number

0–1 chance a note is dropped, decided by the song's seeded hash rather than by chance. Default 0; keep it under about 0.2, past which the part stops being recognisable.

readonly melodic?: boolean

Whether this track carries melody. Only melodic tracks are held to the rest rule in validateSong: a hat that fills every offbeat is correct and a lead that does is not.

Song interface ↳ src/music.ts:139

interface Song {

A whole piece, as data. Everything a deck needs and nothing about how it is played.

6 members
readonly bpm: number

Beats per minute. Never changes while playing — a tempo change mid-loop cannot be un-heard.

readonly steps: number

Steps per bar. 16 is sixteenth notes.

readonly rootHz: number

Root of the whole piece in Hz. Low — 55 is A1.

readonly progression: readonly number[]

Semitone offset of each bar's root. Its length is the loop length in bars.

readonly tracks: readonly Track[]
readonly seed?: number

Seed for Track.drop. Same seed, same twenty minutes, on every machine. Default 0.

SongProblem interface ↳ src/music.ts:154

interface SongProblem {

One thing wrong with a song. Same class of check as validateSounds, same reasons.

3 members
readonly track: string | null

null when the fault is the song's rather than one track's.

readonly code: 'tempo' | 'no-rests' | 'clips' | 'step-out-of-bar' | 'bar-out-of-progression' | 'no-tracks'
readonly message: string

Names the mistake with the numbers in it.

MusicDeck interface ↳ src/music.ts:170

interface MusicDeck {

A running deck. One song at a time; build it with createDeck.

Everything the deck was told reads back off it: autoPump from the options bag, the song it was handed, the intensity it was set to, and whether a given track is muted. A settings panel or a "now playing" readout that cannot ask keeps its own copy of each, and the copies agree until the first call that moves one.

10 members
readonly playing: boolean

Whether a song is scheduling. False during a fade-out, which is still audible.

readonly intensity: number

0–1. Gates tracks by Track.minIntensity.

readonly autoPump: boolean

Whether the deck runs its own PUMP_INTERVAL_MS timer — createDeck's autoPump, default true.

Read it before writing a pump() of your own: two pumps are harmless, and no pump is a deck that schedules exactly one horizon of music and then stops, which sounds like the song ended rather than like a missing call.

readonly song: Song | null

The song MusicDeck.play was last handed, or null before the first one and once a stop has fully faded. Non-null through a fade-out, when playing is already false — that gap is the fade, and a readout that showed nothing there would blank the title while the music was still audible.

The caller's own object, not a copy: it is domain data on loan, exactly as it was passed.

play(song: Song, options?: { readonly fadeSec?: number; }): void

Fade in over fadeSec (default 0.6) and replace whatever was playing.

Throws

RangeError if the song cannot be played at all — a bpm of 0, a step count that is not a positive integer, an empty progression. Those are programmer errors and the caller wants the line number; a bad-sounding song is validateSong's business and never throws.

stop(options?: { readonly fadeSec?: number; }): void

Fade out and stop scheduling. Notes already inside the horizon still sound.

setIntensity(intensity: number): void

0–1, clamped. Gates tracks by minIntensity. Never changes tempo.

setTrackMuted(trackId: string, muted: boolean): void

For a settings panel that offers "no drums", and for a test that wants one track's plans.

trackMuted(trackId: string): boolean

Whether a track is muted. The reader half of MusicDeck.setTrackMuted, so the "no drums" checkbox can render its own state instead of keeping a second set that drifts the first time anything else mutes a track.

A track id the song does not have reads false — the same answer as an unmuted one, because mute is a set of ids and not a claim about the song.

pump(): void

Schedule everything due inside LOOKAHEAD_SEC. Idempotent, safe to over-call, and safe to call at an irregular rate — notes are pinned to the audio clock, not to whoever called this.

The deck runs its own PUMP_INTERVAL_MS timer unless autoPump: false, so a game never has to call this. It is public because a test needs to drive time by hand and because a host with its own scheduler should be able to. Never drive it from requestAnimationFrame alone: rAF is 0 Hz in a hidden tab, so the music would stop the moment the player changed tabs.

validateSong function ↳ src/music.ts:237

function validateSong(song: Song): readonly SongProblem[]

Check a song for the faults that make it unlistenable rather than unplayable.

Returns problems rather than throwing, in song order: the song's own faults first, then each track's. A shipped game must not refuse to start because a hat is 0.02 too loud.

createDeck functionstart here ↳ src/music.ts:322

function createDeck<Ids extends string>(audio: Audio<Ids>, options?: {
    readonly autoPump?: boolean;
}): MusicDeck

Stand up a deck on an engine. Opt-in: a game that does not call this does not ship the sequencer.

Music is off until something calls MusicDeck.play, and "muted" is not "not playing". Music nobody asked for is the fastest route to a permanently muted game, and muting to escape music also loses the alarms, which are the sounds doing actual work — so the engine starts with the music bus unmuted and no deck running. A game that restores a saved mixer with music muted and then calls play() would otherwise never work out why nothing happened.

index1 symbol