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:
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.
readonly mixer: MixerThe three buses and master. Survives unlock — levels set before a device apply after it.
readonly available: booleanWhether a real device exists. False in Node, in a locked-down browser, and before unlock.
readonly voices: numberOne-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 | nullThe 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(): numberThe engine's clock, in audio-clock seconds — AudioOptions.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: numberThe 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): voidMove 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.
ThrowsRangeError 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: numberThe 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): voidMove 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(): booleanCreate 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): booleanPlay 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): DisposerObserve 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(): voidStop 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.