# zvuk v1.14.0 — full documentation > Every page of https://zvuk.schmooky.dev as one Markdown document. > Generated at build time; the per-page copies live alongside each route > (for example /concepts/bus.md). --- # AggregateDecodeError · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/AggregateDecodeError/ Thrown when every URL in a fallback list fails to load. Subclass of DecodeError so existing `catch (e instanceof DecodeError)` paths still fire — `attempts` exposes the per-URL causes for diagnostics. [← API](/api/) Class # AggregateDecodeError Thrown when every URL in a fallback list fails to load. Subclass of DecodeError so existing \`catch (e instanceof DecodeError)\` paths still fire — \`attempts\` exposes the per-URL causes for diagnostics. ## Members - constructor - attempts --- # ApplyOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/ApplyOptions/ Reference for ApplyOptions. [← API](/api/) Interface # ApplyOptions ## Members - fade Crossfade duration in seconds. Default 0 (snap). --- # AssetResolver · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/AssetResolver/ Hook for adopting buffers from an external asset system (Pixi assetpack, IndexedDB cache, custom manifest) instead of (or in addition to) zvuk's URL fetcher. Returning `undefined` / `null` falls through to the URL fetch, so resolvers can mix cached and uncached sounds without branching at the call site. The resolver runs before any fetch. If it returns a buffer or URL, the URL list passed to `loadSound` is only used as the resolution key. [← API](/api/) TypeAlias # AssetResolver Hook for adopting buffers from an external asset system (Pixi assetpack, IndexedDB cache, custom manifest) instead of (or in addition to) zvuk's URL fetcher. Returning \`undefined\` / \`null\` falls through to the URL fetch, so resolvers can mix cached and uncached sounds without branching at the call site. The resolver runs before any fetch. If it returns a buffer or URL, the URL list passed to \`loadSound\` is only used as the resolution key. --- # AudioLevel · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/AudioLevel/ Live amplitude readout, returned by `voice.level()` and `bus.meter()` . Values are linear (0..1). Convert to dB with `20 * log10(value)` . - `rms` — root-mean-square over the most recent ~10 ms window. Smooth, matches what a VU meter shows. - `peak` — maximum absolute sample in the same window. Fast-moving, matches what a peak meter / clip indicator shows. Both numbers come from a single AnalyserNode read; calling `level()` more than ~60 Hz is wasted work because the underlying time-domain buffer doesn't refresh faster than the audio thread fills it. [← API](/api/) Interface # AudioLevel Live amplitude readout, returned by \`voice.level()\` and \`bus.meter()\` . Values are linear (0..1). Convert to dB with \`20 \* log10(value)\` . - \`rms\` — root-mean-square over the most recent ~10 ms window. Smooth, matches what a VU meter shows. - \`peak\` — maximum absolute sample in the same window. Fast-moving, matches what a peak meter / clip indicator shows. Both numbers come from a single AnalyserNode read; calling \`level()\` more than ~60 Hz is wasted work because the underlying time-domain buffer doesn't refresh faster than the audio thread fills it. ## Members - peak - rms --- # AudioMimeType · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/AudioMimeType/ Codec capability + asset-source picking. Recommended encoding pipeline: - Primary: WebM/Opus — smallest, best quality/byte, supported in Chrome, Firefox, Edge, and Safari 14.1+ on macOS / iOS 17+. - Fallback: AAC in M4A — required for older iOS Safari (≤16) and older macOS Safari without Opus support. Ship both; pickSource() returns the first URL the browser can decode. canPlay() uses HTMLAudioElement.canPlayType — it gives a sound (no pun intended) prediction without actually fetching anything. The Web Audio decoder will accept anything HTMLAudioElement says it can play, plus a few extras (uncompressed WAV always works), so canPlay is conservative. [← API](/api/) TypeAlias # AudioMimeType Codec capability + asset-source picking. Recommended encoding pipeline: - Primary: WebM/Opus — smallest, best quality/byte, supported in Chrome, Firefox, Edge, and Safari 14.1+ on macOS / iOS 17+. - Fallback: AAC in M4A — required for older iOS Safari (≤16) and older macOS Safari without Opus support. Ship both; pickSource() returns the first URL the browser can decode. canPlay() uses HTMLAudioElement.canPlayType — it gives a sound (no pun intended) prediction without actually fetching anything. The Web Audio decoder will accept anything HTMLAudioElement says it can play, plus a few extras (uncompressed WAV always works), so canPlay is conservative. --- # Bus · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Bus/ A Bus is a named mix bucket with its own gain stage, optional FX inserts, a voice-concurrency limit, and an optional sidechain key. Voices are connected to bus.input. The master receives bus.output. level/mute use 10ms ramps to avoid clicks; raw gain.value writes would pop audibly on browsers that don't smooth the parameter. Public time arguments (fadeTo) are seconds. [← API](/api/) Class # Bus A Bus is a named mix bucket with its own gain stage, optional FX inserts, a voice-concurrency limit, and an optional sidechain key. Voices are connected to bus.input. The master receives bus.output. level/mute use 10ms ramps to avoid clicks; raw gain.value writes would pop audibly on browsers that don't smooth the parameter. Public time arguments (fadeTo) are seconds. ## Members - constructor - fxInput Sub-bus the FX chain splices into: input → fxInput → (fx…) → output. - input - name - notifySoloChange Engine-injected callback fired when this bus's solo state changes. The Engine uses it to coordinate the global "any soloed → mute the rest" rule across every bus in the graph. Bus does not depend on Engine; the callback is the only escape hatch. - output Output node — connect to master.input or another bus.input for sends. - concurrency - level - muted - soloed - voiceCount - addFx - applyConcurrencyOnSpawn - applySoloVeil - dispose - fadeTo - fx - meter - releaseVoice - removeFx - removeSend - send - sends - setConcurrency - solo - trackVoice - unsolo - voices --- # BusConfig · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/BusConfig/ Reference for BusConfig. [← API](/api/) Interface # BusConfig ## Members - concurrency - level - mute - sidechain --- # BusGroup · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/BusGroup/ A logical handle that addresses several buses at once. Doesn't change the audio graph — applies operations (level, fade, mute, solo) to every member in parallel. Useful when several buses form a sub-mix that should always be controlled together: combat = weapons + enemies + environment; voice = dialogue + effort sounds; etc. Construct via `engine.busGroup(name, members)` ; look up via `engine.busGroup(name)` . Snapshots and parameters can target a group instead of repeating bus names. [← API](/api/) Class # BusGroup A logical handle that addresses several buses at once. Doesn't change the audio graph — applies operations (level, fade, mute, solo) to every member in parallel. Useful when several buses form a sub-mix that should always be controlled together: combat = weapons + enemies + environment; voice = dialogue + effort sounds; etc. Construct via \`engine.busGroup(name, members)\` ; look up via \`engine.busGroup(name)\` . Snapshots and parameters can target a group instead of repeating bus names. ## Members - constructor - members - name - level - muted - fadeTo - solo - unsolo --- # BusNotFoundError · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/BusNotFoundError/ Reference for BusNotFoundError. [← API](/api/) Class # BusNotFoundError ## Members - constructor --- # Compressor · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Compressor/ Dynamics compressor as a bus FX insert. Wraps DynamicsCompressorNode and a make-up gain node, and exposes `input` / `output` so the Bus can wire it into its FX chain. Bypass is a graph swap, not a parameter — when bypassed, the input is connected directly to the output (avoids the compressor's non-zero look-ahead latency leaking into the dry signal). [← API](/api/) Class # Compressor Dynamics compressor as a bus FX insert. Wraps DynamicsCompressorNode and a make-up gain node, and exposes \`input\` / \`output\` so the Bus can wire it into its FX chain. Bypass is a graph swap, not a parameter — when bypassed, the input is connected directly to the output (avoids the compressor's non-zero look-ahead latency leaking into the dry signal). ## Members - constructor - input - output - bypassed - reduction - applyConfig - dispose --- # CompressorConfig · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/CompressorConfig/ Reference for CompressorConfig. [← API](/api/) Interface # CompressorConfig ## Members - attack Attack time in seconds. Default 0.003. - knee dB range over which the curve smoothly transitions into compression. Default 30. - makeupGain Make-up gain in dB applied after compression. Default 0. - ratio Compression ratio. Default 12. - release Release time in seconds. Default 0.25. - threshold dB at which compression begins. Default -24. --- # ConcurrencyConfig · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/ConcurrencyConfig/ Reference for ConcurrencyConfig. [← API](/api/) Interface # ConcurrencyConfig ## Members - max Max simultaneous voices on this bus. - steal Voice-stealing strategy when max is reached. Default: 'oldest'. --- # CrossfadeOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/CrossfadeOptions/ Reference for CrossfadeOptions. [← API](/api/) Interface # CrossfadeOptions ## Members - bus Bus to play - curve Curve. Default 'equal-power' so the sum stays at constant power. - duration Crossfade duration in seconds. Default 1.5. - loop Loop the new voice. Default true (music swap is the canonical use case). - toVolume Override target volume of the incoming voice (0..1). Default 1. --- # DecodeAttempt · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/DecodeAttempt/ Reference for DecodeAttempt. [← API](/api/) Interface # DecodeAttempt ## Members - cause - url --- # DecodeError · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/DecodeError/ A fetch or decode failure for one URL. The underlying failure is kept on `cause` as well as summarised into the message, so a logger that walks the cause chain gets the original stack. [← API](/api/) Class # DecodeError A fetch or decode failure for one URL. The underlying failure is kept on \`cause\` as well as summarised into the message, so a logger that walks the cause chain gets the original stack. ## Members - constructor --- # DistanceModel · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/DistanceModel/ Distance-attenuation curve. Mirrors `PannerNode.distanceModel` : - `'inverse'` (default) — natural-sounding rolloff, matches outdoor sound. - `'linear'` — straight-line attenuation between `refDistance` and `maxDistance` . - `'exponential'` — steeper than inverse; useful for tight environments. [← API](/api/) TypeAlias # DistanceModel Distance-attenuation curve. Mirrors \`PannerNode.distanceModel\` : - \`'inverse'\` (default) — natural-sounding rolloff, matches outdoor sound. - \`'linear'\` — straight-line attenuation between \`refDistance\` and \`maxDistance\` . - \`'exponential'\` — steeper than inverse; useful for tight environments. --- # Ducker · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Ducker/ Sidechain ducker. Inserts on the *target* bus (e.g. music) and listens to the level of a source bus (e.g. voice). When the source is loud, the target's gain drops; when quiet, it returns. Implementation: an envelope follower running on the source bus's RMS, driving an additional gain node spliced into the target's FX chain. The envelope follower runs on the main thread at ~60 Hz — fine for a speech ducker, not for sample-accurate audio-rate sidechaining. For that, use a custom AudioWorklet (planned). [← API](/api/) Class # Ducker Sidechain ducker. Inserts on the \*target\* bus (e.g. music) and listens to the level of a source bus (e.g. voice). When the source is loud, the target's gain drops; when quiet, it returns. Implementation: an envelope follower running on the source bus's RMS, driving an additional gain node spliced into the target's FX chain. The envelope follower runs on the main thread at ~60 Hz — fine for a speech ducker, not for sample-accurate audio-rate sidechaining. For that, use a custom AudioWorklet (planned). ## Members - constructor - input - output - bypassed - dispose - setAmount - setThreshold --- # DuckerConfig · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/DuckerConfig/ Reference for DuckerConfig. [← API](/api/) Interface # DuckerConfig ## Members - amount How much to attenuate when fully ducking (0..1). Default 0.5 = -6 dB. - attack Attack in seconds. Default 0.08. - release Release in seconds. Default 0.4. - threshold Threshold (linear amplitude) on the source bus's RMS to trigger ducking. Default 0.05. --- # Engine · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Engine/ The audio engine. Generic in `TBusName` so that `engine.bus(name)` type-checks against the bus names you declared in `createEngine({ buses })` . When the buses map is inferred from a literal, typos like `engine.bus('sxf')` fail at compile time. [← API](/api/) Interface # Engine The audio engine. Generic in \`TBusName\` so that \`engine.bus(name)\` type-checks against the bus names you declared in \`createEngine({ buses })\` . When the buses map is inferred from a literal, typos like \`engine.bus('sxf')\` fail at compile time. ## Members - context Live AudioContext. Constructs one on first read. Throws if engine is closed. - now - state - activeVoices - blendSnapshots - bus - busGroup - captureSnapshot - close - createSound - crossfade - hasMusic - hasSound - hasSprite - hasStream - hasVariants - loadMusic - loadSound - loadSprite - loadStream - loadVariants - masterMeter - music - onStateChange - parameter - preload - removeSound - scheduleAt - snapshot - sound - sprite - stream - unloadSound - unlock - variants --- # EngineClosedError · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/EngineClosedError/ Reference for EngineClosedError. [← API](/api/) Class # EngineClosedError ## Members - constructor --- # EngineConfig · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/EngineConfig/ Reference for EngineConfig. [← API](/api/) Interface # EngineConfig ## Members - autoPauseOnHidden When - buses - cache Decoded-buffer cache limits. Decoded audio costs 4 bytes per sample per channel, so a few minutes of stereo 48 kHz is tens of megabytes and an entry count says nothing useful about memory. Defaults: 64 MiB, 128 entries. The cache is LRU on both. - latencyHint Hint to the underlying - master - resolveAsset Adopt buffers from an external asset system instead of (or in addition to) zvuk's URL fetcher. Called once per - tickSource External ticker (e.g. Pixi - voice --- # EngineState · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/EngineState/ Engine lifecycle, mirroring the underlying AudioContext: - `cold` — no context yet, or a failed unlock. - `unlocking` — a resume() is in flight. - `live` — the context is running. - `suspended` — the context was paused, normally by `autoPauseOnHidden` on tab hide. Recoverable with `unlock()` . - `interrupted` — iOS took the audio session (phone call, Siri). `resume()` does not recover from this; the OS has to hand it back. - `closed` — terminal. [← API](/api/) TypeAlias # EngineState Engine lifecycle, mirroring the underlying AudioContext: - \`cold\` — no context yet, or a failed unlock. - \`unlocking\` — a resume() is in flight. - \`live\` — the context is running. - \`suspended\` — the context was paused, normally by \`autoPauseOnHidden\` on tab hide. Recoverable with \`unlock()\` . - \`interrupted\` — iOS took the audio session (phone call, Siri). \`resume()\` does not recover from this; the OS has to hand it back. - \`closed\` — terminal. --- # FadeCurve · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/FadeCurve/ Reference for FadeCurve. [← API](/api/) TypeAlias # FadeCurve --- # FadeOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/FadeOptions/ Reference for FadeOptions. [← API](/api/) Interface # FadeOptions ## Members - curve - duration Fade duration in seconds. - to --- # Filter · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Filter/ Biquad filter as a bus FX insert. Bypass is a graph swap, not a parameter trick — when bypassed, input is connected directly to output, leaving the biquad fully detached so its delay-line state can't bleed into the dry signal. [← API](/api/) Class # Filter Biquad filter as a bus FX insert. Bypass is a graph swap, not a parameter trick — when bypassed, input is connected directly to output, leaving the biquad fully detached so its delay-line state can't bleed into the dry signal. ## Members - constructor - input - output - bypassed - dispose - setFrequency - setGain - setQ - setType --- # FilterConfig · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/FilterConfig/ Reference for FilterConfig. [← API](/api/) Interface # FilterConfig ## Members - frequency Cutoff/center frequency in Hz. Default 1000. - gain Gain (dB) — only meaningful for - q Q factor (resonance). Default 1. - type --- # FilterKind · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/FilterKind/ Reference for FilterKind. [← API](/api/) TypeAlias # FilterKind --- # FxInsert · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/FxInsert/ Common FX insert contract. Every FX exposes a single input + a single output node so the bus can splice it into the (fxInput → output) hop. [← API](/api/) Interface # FxInsert Common FX insert contract. Every FX exposes a single input + a single output node so the bus can splice it into the (fxInput → output) hop. ## Members - bypassed - input - output - dispose --- # LoadSoundOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/LoadSoundOptions/ Reference for LoadSoundOptions. [← API](/api/) Interface # LoadSoundOptions ## Members - bus Default bus for voices spawned by this sound. Default: first declared bus. - normalize Run RMS-based loudness normalization on the decoded buffer so it sits at the same RMS level as other normalized sounds. (Full-band RMS, not perceptual/LUFS.) Pass - signal AbortSignal for the fetch. --- # LoudnessOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/LoudnessOptions/ RMS-based loudness normalization on a decoded AudioBuffer. `engine.loadSound(..., { normalize: true })` runs this at decode-time and produces a buffer pre-scaled so all sounds sit at the same RMS level — removing a workflow tax that game-audio teams currently pay in their DAW. Pass an object to override the target RMS or peak ceiling. NOTE: this is full-band RMS, not perceptual (K-weighted / LUFS) loudness — two spectrally different clips at equal RMS may still differ in perceived loudness. [← API](/api/) Interface # LoudnessOptions RMS-based loudness normalization on a decoded AudioBuffer. \`engine.loadSound(..., { normalize: true })\` runs this at decode-time and produces a buffer pre-scaled so all sounds sit at the same RMS level — removing a workflow tax that game-audio teams currently pay in their DAW. Pass an object to override the target RMS or peak ceiling. NOTE: this is full-band RMS, not perceptual (K-weighted / LUFS) loudness — two spectrally different clips at equal RMS may still differ in perceived loudness. ## Members - peakCeiling Hard ceiling for the resulting peak; gain is reduced if it would clip. Default 0.99. - targetRms Target RMS (linear, 0..1). Default 0.1 (~ -20 dBFS). --- # Master · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Master/ The Master stage. Headroom-aware gain into an optional soft limiter, then to destination. Buses connect their output to master.input. Headroom is a static gain offset; the limiter is a fast-attack DynamicsCompressorNode that catches most peaks the headroom can't tame when heavy FX stack on top of busy mixes. Note it is best-effort, not a true brick-wall limiter — with a finite attack and no lookahead, transient peaks above the threshold can still pass, so it does not guarantee a hard ceiling. [← API](/api/) Class # Master The Master stage. Headroom-aware gain into an optional soft limiter, then to destination. Buses connect their output to master.input. Headroom is a static gain offset; the limiter is a fast-attack DynamicsCompressorNode that catches most peaks the headroom can't tame when heavy FX stack on top of busy mixes. Note it is best-effort, not a true brick-wall limiter — with a finite attack and no lookahead, transient peaks above the threshold can still pass, so it does not guarantee a hard ceiling. ## Members - constructor - input - headroom - reduction - dispose - meter - setHeadroom - setLimiter --- # MasterConfig · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/MasterConfig/ Reference for MasterConfig. [← API](/api/) Interface # MasterConfig ## Members - headroom Headroom in dB applied to the master gain (negative). Default: 0. - limiter Optional fast-attack soft limiter on the master output (best-effort peak control). --- # MasterLimiterConfig · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/MasterLimiterConfig/ Reference for MasterLimiterConfig. [← API](/api/) Interface # MasterLimiterConfig ## Members - attack Attack in seconds. Default 0.001, fast enough to catch the transient. - ratio Compression ratio. Default 20, high enough to be limiter-like without being a brick wall. - release Release in seconds. Default 0.05. - threshold Threshold in dB. Default -1 (just below 0 dBFS). --- # Music · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Music/ Stinger → loop → outro music asset. The pattern every casino slot, action game, and rhythm game uses for combat/win/menu music: an intro that plays once, a body that loops cleanly until you ask it to stop, and an outro tail that plays once at the natural loop boundary so the music ends musically instead of cutting off mid-bar. Construct via `engine.loadMusic(name, parts)` ; spawn live instances via `music.play()` . Each instance is a `MusicVoice` you can fade, pause, resume, stop, or `skipToOutro()` independently. [← API](/api/) Class # Music Stinger → loop → outro music asset. The pattern every casino slot, action game, and rhythm game uses for combat/win/menu music: an intro that plays once, a body that loops cleanly until you ask it to stop, and an outro tail that plays once at the natural loop boundary so the music ends musically instead of cutting off mid-bar. Construct via \`engine.loadMusic(name, parts)\` ; spawn live instances via \`music.play()\` . Each instance is a \`MusicVoice\` you can fade, pause, resume, stop, or \`skipToOutro()\` independently. ## Members - constructor - name - hasIntro - hasOutro - loopDuration - play - stopAll - voices --- # MusicLoadOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/MusicLoadOptions/ Reference for MusicLoadOptions. [← API](/api/) Interface # MusicLoadOptions ## Members - bus Default bus for voices spawned by this sound. Default: first declared bus. - loopCrossfade Equal-power crossfade (seconds) at the loop boundary. Mirrors - normalize Run RMS-based loudness normalization on the decoded buffer so it sits at the same RMS level as other normalized sounds. (Full-band RMS, not perceptual/LUFS.) Pass - signal AbortSignal for the fetch. --- # MusicParts · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/MusicParts/ A three-part music asset: optional intro stinger, mandatory loop body, optional outro tail. Modelled on the Wwise / FMOD pattern every casino slot, action game, and rhythm game uses for combat/win/menu music. Each part accepts a single URL or a codec ladder, the same shape as `engine.loadSound` 's second argument. [← API](/api/) Interface # MusicParts A three-part music asset: optional intro stinger, mandatory loop body, optional outro tail. Modelled on the Wwise / FMOD pattern every casino slot, action game, and rhythm game uses for combat/win/menu music. Each part accepts a single URL or a codec ladder, the same shape as \`engine.loadSound\` 's second argument. ## Members - intro - loop - outro --- # MusicPlayOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/MusicPlayOptions/ Reference for MusicPlayOptions. [← API](/api/) Interface # MusicPlayOptions ## Members - fadeIn Fade-in duration (seconds) applied to the music's gain stage at start. Default 0 (instant). Convenience for "drop into the menu" style intros. - volume Initial volume (0..1). Default 1. --- # MusicState · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/MusicState/ Reference for MusicState. [← API](/api/) TypeAlias # MusicState --- # MusicVoice · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/MusicVoice/ One live playback instance of a `Music` asset. Tracks which part is currently sounding ( `'intro' | 'loop' | 'outro' | 'ended'` ), exposes `fade` / `pause` / `resume` / `stop` , and adds two music-specific operations: - `skipToOutro({ at: 'loop-end' })` — wait for the current loop iteration to complete, then play the outro at the natural loop boundary. - `skipToOutro({ at: 'now' })` — fade the loop out (~50 ms) and start the outro immediately. Right call for "user pressed Stop." `stop()` is the click-free cut — no outro. Use `skipToOutro` if you want the music to end musically. [← API](/api/) Class # MusicVoice One live playback instance of a \`Music\` asset. Tracks which part is currently sounding ( \`'intro' | 'loop' | 'outro' | 'ended'\` ), exposes \`fade\` / \`pause\` / \`resume\` / \`stop\` , and adds two music-specific operations: - \`skipToOutro({ at: 'loop-end' })\` — wait for the current loop iteration to complete, then play the outro at the natural loop boundary. - \`skipToOutro({ at: 'now' })\` — fade the loop out (~50 ms) and start the outro immediately. Right call for "user pressed Stop." \`stop()\` is the click-free cut — no outro. Use \`skipToOutro\` if you want the music to end musically. ## Members - constructor - ended - currentPart - fade - skipToOutro - stop --- # NormalizeFlag · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/NormalizeFlag/ Reference for NormalizeFlag. [← API](/api/) TypeAlias # NormalizeFlag --- # Parameter · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Parameter/ A named float you can drive at runtime. Set it from anywhere; subscribers (bus levels, FX wet, voice gain) update immediately. Bind a target to a parameter with `bindTo` — when the parameter changes, the curve maps [0..1] to a target range and applies the value. Repeated `set` calls override each other (no queue), making parameters ideal for "intensity"/"distance"/"tension" knobs that change continuously. [← API](/api/) Class # Parameter A named float you can drive at runtime. Set it from anywhere; subscribers (bus levels, FX wet, voice gain) update immediately. Bind a target to a parameter with \`bindTo\` — when the parameter changes, the curve maps \[0..1\] to a target range and applies the value. Repeated \`set\` calls override each other (no queue), making parameters ideal for "intensity"/"distance"/"tension" knobs that change continuously. ## Members - constructor - name - value - bindTo - set - subscribe --- # ParameterCurve · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/ParameterCurve/ Reference for ParameterCurve. [← API](/api/) TypeAlias # ParameterCurve --- # PlayOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/PlayOptions/ Reference for PlayOptions. [← API](/api/) Interface # PlayOptions ## Members - bus Override the default bus for this voice. - duration If set, voice auto-stops after this many seconds. Used by Sprite. - fadeIn Fade-in duration (seconds) applied to the voice's gain at start. Default 0 (instant). Convenience for "drop in smoothly", the mirror of the click-free stop fade. Equivalent to playing at - loop Loop the voice. Default false. - loopCrossfade Equal-power crossfade duration (seconds) at the loop boundary. When - loopEnd When loop=true, end of the loop region (seconds). - loopStart When loop=true, start of the loop region (seconds). - offset Offset into the buffer (seconds) to start at. Default 0. Used by Sprite. - pitch Playback rate. 1 = source speed. Random jitter optional. - priority Voice priority. Higher survives stealing for longer. Default 0. - signal AbortSignal. The voice stops when it aborts. - spatializer 2D pan or 3D position. Inserts a Spatializer between the voice and its bus. - volume Initial volume (0..1). Default 1. --- # PreloadError · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/PreloadError/ Thrown by `engine.preload(...)` when one or more items in the batch fail. Other items in the batch still complete; this only fires after every item has settled, so a single broken asset doesn't short-circuit the rest of a loading screen. [← API](/api/) Class # PreloadError Thrown by \`engine.preload(...)\` when one or more items in the batch fail. Other items in the batch still complete; this only fires after every item has settled, so a single broken asset doesn't short-circuit the rest of a loading screen. ## Members - constructor - failures --- # PreloadFailure · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/PreloadFailure/ Reference for PreloadFailure. [← API](/api/) Interface # PreloadFailure ## Members - cause - name --- # PreloadItem · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/PreloadItem/ One entry in a `engine.preload(...)` batch. Mirrors `engine.loadSound` 's signature so callers can hand the same data to either path. [← API](/api/) Interface # PreloadItem One entry in a \`engine.preload(...)\` batch. Mirrors \`engine.loadSound\` 's signature so callers can hand the same data to either path. ## Members - name Sound name, registered on - options Per-item options (bus, normalize, etc.). Forwarded to - url URL or codec ladder, the same shape as --- # PreloadOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/PreloadOptions/ Reference for PreloadOptions. [← API](/api/) Interface # PreloadOptions ## Members - concurrency Maximum concurrent loads. Default 4, which balances against browser connection limits (typically 6 per host) so the rest of the page's fetches don't starve. Lower for cheap mobile data plans, higher when you control the host and want to saturate. - onProgress Fires once per item as it settles. Use this to drive a loading-screen progress bar. The event is cumulative, so - signal Cancels the whole preload. In-flight fetches receive the abort; pending items aren't started. The promise rejects with the signal's abort reason. --- # PreloadProgressEvent · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/PreloadProgressEvent/ Reference for PreloadProgressEvent. [← API](/api/) Interface # PreloadProgressEvent ## Members - completed Items settled so far (loaded + failed). - error Error attached when - name Name of the item that just completed (success or failure). - status Outcome. - total Total items in the batch. --- # ResolveAssetContext · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/ResolveAssetContext/ Reference for ResolveAssetContext. [← API](/api/) Interface # ResolveAssetContext ## Members - name The name passed to - signal Forwarded from - url The URL or URL list passed to --- # ResolvedAsset · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/ResolvedAsset/ Anything an `AssetResolver` is allowed to return: - `AudioBuffer` — already decoded, used as-is. - `ArrayBuffer` — encoded bytes; zvuk decodes via the engine's AudioContext. - `string` — a URL; zvuk fetches and decodes via its normal loader (and the cache). - `undefined` / `null` — explicit "I don't have this". Falls through to the URL list passed to `loadSound` (or throws if none was given). [← API](/api/) TypeAlias # ResolvedAsset Anything an \`AssetResolver\` is allowed to return: - \`AudioBuffer\` — already decoded, used as-is. - \`ArrayBuffer\` — encoded bytes; zvuk decodes via the engine's AudioContext. - \`string\` — a URL; zvuk fetches and decodes via its normal loader (and the cache). - \`undefined\` / \`null\` — explicit "I don't have this". Falls through to the URL list passed to \`loadSound\` (or throws if none was given). --- # Reverb · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Reverb/ Convolution reverb. Mixes a dry signal with a wet path that runs through a ConvolverNode. If no impulse response is provided, a synthetic noise decay is generated — quick and free, but not as nice as a real IR. [← API](/api/) Class # Reverb Convolution reverb. Mixes a dry signal with a wet path that runs through a ConvolverNode. If no impulse response is provided, a synthetic noise decay is generated — quick and free, but not as nice as a real IR. ## Members - constructor - input - output - bypassed - dispose - setImpulse - setWet --- # ReverbConfig · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/ReverbConfig/ Reference for ReverbConfig. [← API](/api/) Interface # ReverbConfig ## Members - decay Synthetic decay parameters (used when - impulse A loaded impulse-response buffer. If omitted, a synthetic decay is generated. - wet Wet/dry mix (0 = dry, 1 = full wet). Default 0.3. --- # Send · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Send/ One configured send from a source bus to a target bus. Returned by `bus.send(target, ...)` ; held by callers so the level can be adjusted (or the send removed) at runtime. [← API](/api/) Class # Send One configured send from a source bus to a target bus. Returned by \`bus.send(target, ...)\` ; held by callers so the level can be adjusted (or the send removed) at runtime. ## Members - constructor - post - source - target - amount - dispose - fadeTo --- # SendOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/SendOptions/ Reference for SendOptions. [← API](/api/) Interface # SendOptions ## Members - amount Send level (0..1). Default 1. - post Tap the source bus's output (post-fader, post-FX) when --- # SidechainConfig · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/SidechainConfig/ Reference for SidechainConfig. [← API](/api/) Interface # SidechainConfig ## Members - amount Amount of duck (0..1, where 1 = fully muted at peak source). - attack Attack in seconds. - from Bus name to listen to. When that bus is loud, this bus is ducked. - release Release in seconds. --- # SkipToOutroOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/SkipToOutroOptions/ Reference for SkipToOutroOptions. [← API](/api/) Interface # SkipToOutroOptions ## Members - at \- --- # Snapshot · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Snapshot/ A captured mix-state preset. Capture it once with the engine in a known good state ("menu mood"), then `apply({ fade: 0.25 })` to crossfade the entire mix back to that snapshot — bus levels, mutes, parameter values, everything in one call. Snapshots are immutable; mutate the engine and re-capture if you need a new one. [← API](/api/) Class # Snapshot A captured mix-state preset. Capture it once with the engine in a known good state ("menu mood"), then \`apply({ fade: 0.25 })\` to crossfade the entire mix back to that snapshot — bus levels, mutes, parameter values, everything in one call. Snapshots are immutable; mutate the engine and re-capture if you need a new one. ## Members - constructor - name - state - apply - blendWith --- # SnapshotState · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/SnapshotState/ Reference for SnapshotState. [← API](/api/) Interface # SnapshotState ## Members - buses - parameters --- # Sound · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Sound/ A loaded sample — owns one decoded AudioBuffer, spawns Voices on play(). Sound is created via engine.loadSound() / bank load; constructor is package-private. [← API](/api/) Class # Sound A loaded sample — owns one decoded AudioBuffer, spawns Voices on play(). Sound is created via engine.loadSound() / bank load; constructor is package-private. ## Members - constructor - name - duration - play --- # SoundNotFoundError · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/SoundNotFoundError/ Reference for SoundNotFoundError. [← API](/api/) Class # SoundNotFoundError ## Members - constructor --- # SpatialOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/SpatialOptions/ Reference for SpatialOptions. [← API](/api/) Interface # SpatialOptions ## Members - distanceModel Distance-attenuation curve. Default - maxDistance Distance beyond which - occlusion Occlusion amount (0..1). Drives an internal low-pass filter that sweeps cutoff from 22050 Hz (transparent) down to ~500 Hz (heavily muffled), plus a small gain dip. That is the standard "behind a wall" effect. Independent of distance attenuation; combine via a - pan \[-1, 1\] stereo pan (2D). Mutually exclusive with - position \[x, y, z\] world-space position (3D). Mutually exclusive with - refDistance Distance below which the source is at full volume. Default 1. 3D only; ignored when - rolloffFactor How aggressively distance attenuates the sound. 1 is the natural rolloff for the chosen --- # Spatializer · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Spatializer/ PannerNode + occlusion biquad wrapper. Inserted between a Voice and its Bus when a play call passes a `spatializer` option. - 2D pan uses `StereoPannerNode` (cheap, just left/right shift). - 3D uses `PannerNode` in HRTF mode (one node per voice — fine for the dozens of simultaneous voices a typical game holds, expensive past hundreds), followed by a biquad lowpass for occlusion plus a gain stage so occlusion can also drop level slightly. 3D config ( `refDistance` , `maxDistance` , `rolloffFactor` , `distanceModel` ) is configurable per voice via `SpatialOptions` and tunable live via the `set*` methods. Occlusion is a single 0..1 knob that drives the lowpass cutoff plus a small gain dip. [← API](/api/) Class # Spatializer PannerNode + occlusion biquad wrapper. Inserted between a Voice and its Bus when a play call passes a \`spatializer\` option. - 2D pan uses \`StereoPannerNode\` (cheap, just left/right shift). - 3D uses \`PannerNode\` in HRTF mode (one node per voice — fine for the dozens of simultaneous voices a typical game holds, expensive past hundreds), followed by a biquad lowpass for occlusion plus a gain stage so occlusion can also drop level slightly. 3D config ( \`refDistance\` , \`maxDistance\` , \`rolloffFactor\` , \`distanceModel\` ) is configurable per voice via \`SpatialOptions\` and tunable live via the \`set\*\` methods. Occlusion is a single 0..1 knob that drives the lowpass cutoff plus a small gain dip. ## Members - constructor - connectInto - dispose - setDistanceModel - setMaxDistance - setOcclusion - setPan - setPosition - setRefDistance - setRolloffFactor --- # Sprite · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Sprite/ One buffer, many named regions, one fetch. Use for cascades, UI variants, low-latency one-shots — anything where the cost of N separate decodes outweighs the cost of N region offsets into a single buffer. Built on top of an underlying Sound (the buffer); regions are cooperative — overlapping regions just produce overlapping voices. [← API](/api/) Class # Sprite One buffer, many named regions, one fetch. Use for cascades, UI variants, low-latency one-shots — anything where the cost of N separate decodes outweighs the cost of N region offsets into a single buffer. Built on top of an underlying Sound (the buffer); regions are cooperative — overlapping regions just produce overlapping voices. ## Members - constructor - name - has - list - play - region --- # SpriteMap · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/SpriteMap/ Reference for SpriteMap. [← API](/api/) Interface # SpriteMap --- # SpriteRegion · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/SpriteRegion/ Reference for SpriteRegion. [← API](/api/) Interface # SpriteRegion ## Members - duration Region duration in seconds. - loop If true, looping plays this region back-to-back. Default false. - start Start offset within the buffer, in seconds. --- # SpriteRegionPlayOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/SpriteRegionPlayOptions/ Reference for SpriteRegionPlayOptions. [← API](/api/) TypeAlias # SpriteRegionPlayOptions --- # StopOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/StopOptions/ Reference for StopOptions. [← API](/api/) Interface # StopOptions ## Members - fade Click-free fade-out duration (seconds) to apply before the source node actually stops. Default: the engine's --- # StreamSound · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/StreamSound/ Stream a long media file through HTMLAudioElement → MediaElementAudioSource. Use for music tracks > 30s where decoding the whole file into RAM (the loadSound path) would waste memory and stall on iOS. The element handles progressive download and seek; we just route its output into the engine's bus graph so it picks up the same FX/sidechain as buffer-based voices. Created lazily — the underlying MediaElementAudioSource is only built on first play(), since it can't be reattached to a different bus once created. [← API](/api/) Class # StreamSound Stream a long media file through HTMLAudioElement → MediaElementAudioSource. Use for music tracks > 30s where decoding the whole file into RAM (the loadSound path) would waste memory and stall on iOS. The element handles progressive download and seek; we just route its output into the engine's bus graph so it picks up the same FX/sidechain as buffer-based voices. Created lazily — the underlying MediaElementAudioSource is only built on first play(), since it can't be reattached to a different bus once created. ## Members - constructor - name - currentTime - duration - paused - dispose - fade - pause - play - seek - setVolume - stop --- # StretchProcessor · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/StretchProcessor/ Pitch-preserving time-stretch via overlap-add granular synthesis with cross-correlation alignment (a SOLA-style approximation). Used to render an offline stretched copy of an AudioBuffer at load time — not realtime. For realtime tempo control, see the (planned) AudioWorklet implementation. stretchFactor > 1 = play faster (shorter buffer). stretchFactor < 1 = not currently supported (use rate via PlaybackRate). [← API](/api/) Class # StretchProcessor Pitch-preserving time-stretch via overlap-add granular synthesis with cross-correlation alignment (a SOLA-style approximation). Used to render an offline stretched copy of an AudioBuffer at load time — not realtime. For realtime tempo control, see the (planned) AudioWorklet implementation. stretchFactor > 1 = play faster (shorter buffer). stretchFactor < 1 = not currently supported (use rate via PlaybackRate). ## Members - constructor - process - stretchBuffer --- # StretchWorkletNode · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/StretchWorkletNode/ Reference for StretchWorkletNode. [← API](/api/) Interface # StretchWorkletNode ## Members - stretch Live stretch factor — automate via setValueAtTime, linearRampToValueAtTime, etc. - dispose --- # StretchWorkletOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/StretchWorkletOptions/ Reference for StretchWorkletOptions. [← API](/api/) Interface # StretchWorkletOptions ## Members - grainSize Ring-buffer sizing hint in samples (ring = 8×). Default 1024. - stretchFactor Initial stretch factor. 1 = play at source rate. > 1 = faster (higher pitch). --- # TickSource · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/TickSource/ External ticker for driving the scheduler's task dispatch. Typically a host's existing render loop (Pixi `app.ticker` , GSAP `gsap.ticker` ). The scheduler subscribes lazily: only while there are pending tasks. When the queue drains, it unsubscribes so a 60 Hz host loop isn't waking the scheduler 60 times per second to do nothing. Without a `TickSource` , the scheduler dispatches via `setTimeout` . See the "Runtime timing" guide for trade-offs around browser timer throttling and tab visibility. [← API](/api/) Interface # TickSource External ticker for driving the scheduler's task dispatch. Typically a host's existing render loop (Pixi \`app.ticker\` , GSAP \`gsap.ticker\` ). The scheduler subscribes lazily: only while there are pending tasks. When the queue drains, it unsubscribes so a 60 Hz host loop isn't waking the scheduler 60 times per second to do nothing. Without a \`TickSource\` , the scheduler dispatches via \`setTimeout\` . See the "Runtime timing" guide for trade-offs around browser timer throttling and tab visibility. ## Members - subscribe --- # VariantStrategy · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/VariantStrategy/ Strategy for picking which variant fires on each `play()` : - `'random'` — uniform random pick. Cheap; can play the same one twice in a row, which sounds robotic. - `'no-repeat'` (default) — uniform random, but never the same as the previous pick. The fix every casino slot uses for SFX variants. - `'shuffle-bag'` — Tetris-style: cycle through every variant in a random shuffle, then reshuffle once the bag empties. Guarantees every variant gets played roughly equally. [← API](/api/) TypeAlias # VariantStrategy Strategy for picking which variant fires on each \`play()\` : - \`'random'\` — uniform random pick. Cheap; can play the same one twice in a row, which sounds robotic. - \`'no-repeat'\` (default) — uniform random, but never the same as the previous pick. The fix every casino slot uses for SFX variants. - \`'shuffle-bag'\` — Tetris-style: cycle through every variant in a random shuffle, then reshuffle once the bag empties. Guarantees every variant gets played roughly equally. --- # Variants · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Variants/ A bundle of N alternate sounds that play one at a time. The classic casino-slot pattern: every coin / win / reel-stop trigger plays a randomised variant so stacked SFX don't sound robotic. Construct via `engine.loadVariants(name, urlSets)` ; spawn voices via `engine.variants(name).play()` . The picker strategy is configurable ( `'random'` | `'no-repeat'` | `'shuffle-bag'` ). [← API](/api/) Class # Variants A bundle of N alternate sounds that play one at a time. The classic casino-slot pattern: every coin / win / reel-stop trigger plays a randomised variant so stacked SFX don't sound robotic. Construct via \`engine.loadVariants(name, urlSets)\` ; spawn voices via \`engine.variants(name).play()\` . The picker strategy is configurable ( \`'random'\` | \`'no-repeat'\` | \`'shuffle-bag'\` ). ## Members - constructor - count - name - lastPick - play - playIndex --- # VariantsOptions · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/VariantsOptions/ Reference for VariantsOptions. [← API](/api/) Interface # VariantsOptions ## Members - strategy --- # Voice · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/Voice/ One playback instance, returned from sound.play(). Owns its source node and gain stage; disposes itself on natural end, signal abort, or stop(). Voice constructor is package-private — callers obtain instances via Sound.play(). [← API](/api/) Class # Voice One playback instance, returned from sound.play(). Owns its source node and gain stage; disposes itself on natural end, signal abort, or stop(). Voice constructor is package-private — callers obtain instances via Sound.play(). ## Members - constructor - bus - ended Resolves when playback finishes (natural end, stop(), or abort). - id - priority - sourceName Name of the Sound that spawned this voice (if any). Used for crossfade. - startedAt - nextId - isPaused - playbackRate - spatializer - cues - fade - level - pause - resume - setPlaybackRate - stop --- # VoiceDefaults · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/VoiceDefaults/ Reference for VoiceDefaults. [← API](/api/) Interface # VoiceDefaults ## Members - stopFade Default click-free fade-out duration applied by --- # VoiceJitter · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/VoiceJitter/ Reference for VoiceJitter. [← API](/api/) Interface # VoiceJitter ## Members - base Center value the jitter varies around. Defaults to 1 (the neutral volume / playback rate). Set it to combine a base with jitter, e.g. - jitter Maximum ± random deviation from --- # ZvukError · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/ZvukError/ Reference for ZvukError. [← API](/api/) Class # ZvukError ## Members - constructor --- # applyLoudnessNormalization · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/applyLoudnessNormalization/ Reference for applyLoudnessNormalization. [← API](/api/) Function # applyLoudnessNormalization ## Signatures - applyLoudnessNormalization ( buffer: AudioBuffer, flag: NormalizeFlag, ctx: BaseAudioContext ): AudioBuffer --- # canPlay · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/canPlay/ Reference for canPlay. [← API](/api/) Function # canPlay ## Signatures - canPlay ( mime: AudioMimeType ): boolean --- # computeNormalizationGain · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/computeNormalizationGain/ Reference for computeNormalizationGain. [← API](/api/) Function # computeNormalizationGain ## Signatures - computeNormalizationGain ( buffer: AudioBuffer, opts: Required ): number --- # createEngine · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/createEngine/ Reference for createEngine. [← API](/api/) Function # createEngine ## Signatures - createEngine ( config: EngineConfig ): Engine Construct a new engine. Does NOT touch the AudioContext — that happens on the first unlock() or play() call. Safe to call before any user gesture. The bus-name union is inferred from the --- # createStretchWorkletNode · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/createStretchWorkletNode/ Reference for createStretchWorkletNode. [← API](/api/) Function # createStretchWorkletNode ## Signatures - createStretchWorkletNode ( ctx: AudioContext, options: StretchWorkletOptions ): StretchWorkletNode Construct a realtime stretch node. Call after --- # ensureStretchWorklet · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/ensureStretchWorklet/ Reference for ensureStretchWorklet. [← API](/api/) Function # ensureStretchWorklet ## Signatures - ensureStretchWorklet ( ctx: AudioContext ): Promise Ensure the realtime stretch worklet is registered on --- # API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/ Auto-generated TypeDoc reference for every public symbol. Reference # API Generated by TypeDoc on every build from `src/index.ts`. The narrative concept pages are still the best way to learn — this index is for "I know the name, link me to it." ## Class - [ AggregateDecodeError Thrown when every URL in a fallback list fails to load. Subclass of DecodeError so existing ](/api/AggregateDecodeError/) - [ Bus A Bus is a named mix bucket with its own gain stage, optional FX inserts, a voice-concurrency limit, and an optional sidechain key. Voices are connected to bus.input. The master receives bus.output. level/mute use 10ms ramps to avoid clicks; raw gain.value writes would pop audibly on browsers that don't smooth the parameter. Public time arguments (fadeTo) are seconds. ](/api/Bus/) - [ BusGroup A logical handle that addresses several buses at once. Doesn't change the audio graph — applies operations (level, fade, mute, solo) to every member in parallel. Useful when several buses form a sub-mix that should always be controlled together: combat = weapons + enemies + environment; voice = dialogue + effort sounds; etc. Construct via ](/api/BusGroup/) - [ BusNotFoundError ](/api/BusNotFoundError/) - [ Compressor Dynamics compressor as a bus FX insert. Wraps DynamicsCompressorNode and a make-up gain node, and exposes ](/api/Compressor/) - [ DecodeError A fetch or decode failure for one URL. The underlying failure is kept on ](/api/DecodeError/) - [ Ducker Sidechain ducker. Inserts on the \*target\* bus (e.g. music) and listens to the level of a source bus (e.g. voice). When the source is loud, the target's gain drops; when quiet, it returns. Implementation: an envelope follower running on the source bus's RMS, driving an additional gain node spliced into the target's FX chain. The envelope follower runs on the main thread at ~60 Hz — fine for a speech ducker, not for sample-accurate audio-rate sidechaining. For that, use a custom AudioWorklet (planned). ](/api/Ducker/) - [ EngineClosedError ](/api/EngineClosedError/) - [ Filter Biquad filter as a bus FX insert. Bypass is a graph swap, not a parameter trick — when bypassed, input is connected directly to output, leaving the biquad fully detached so its delay-line state can't bleed into the dry signal. ](/api/Filter/) - [ Master The Master stage. Headroom-aware gain into an optional soft limiter, then to destination. Buses connect their output to master.input. Headroom is a static gain offset; the limiter is a fast-attack DynamicsCompressorNode that catches most peaks the headroom can't tame when heavy FX stack on top of busy mixes. Note it is best-effort, not a true brick-wall limiter — with a finite attack and no lookahead, transient peaks above the threshold can still pass, so it does not guarantee a hard ceiling. ](/api/Master/) - [ Music Stinger → loop → outro music asset. The pattern every casino slot, action game, and rhythm game uses for combat/win/menu music: an intro that plays once, a body that loops cleanly until you ask it to stop, and an outro tail that plays once at the natural loop boundary so the music ends musically instead of cutting off mid-bar. Construct via ](/api/Music/) - [ MusicVoice One live playback instance of a ](/api/MusicVoice/) - [ Parameter A named float you can drive at runtime. Set it from anywhere; subscribers (bus levels, FX wet, voice gain) update immediately. Bind a target to a parameter with ](/api/Parameter/) - [ PreloadError Thrown by ](/api/PreloadError/) - [ Reverb Convolution reverb. Mixes a dry signal with a wet path that runs through a ConvolverNode. If no impulse response is provided, a synthetic noise decay is generated — quick and free, but not as nice as a real IR. ](/api/Reverb/) - [ Send One configured send from a source bus to a target bus. Returned by ](/api/Send/) - [ Snapshot A captured mix-state preset. Capture it once with the engine in a known good state ("menu mood"), then ](/api/Snapshot/) - [ Sound A loaded sample — owns one decoded AudioBuffer, spawns Voices on play(). Sound is created via engine.loadSound() / bank load; constructor is package-private. ](/api/Sound/) - [ SoundNotFoundError ](/api/SoundNotFoundError/) - [ Spatializer PannerNode + occlusion biquad wrapper. Inserted between a Voice and its Bus when a play call passes a ](/api/Spatializer/) - [ Sprite One buffer, many named regions, one fetch. Use for cascades, UI variants, low-latency one-shots — anything where the cost of N separate decodes outweighs the cost of N region offsets into a single buffer. Built on top of an underlying Sound (the buffer); regions are cooperative — overlapping regions just produce overlapping voices. ](/api/Sprite/) - [ StreamSound Stream a long media file through HTMLAudioElement → MediaElementAudioSource. Use for music tracks > 30s where decoding the whole file into RAM (the loadSound path) would waste memory and stall on iOS. The element handles progressive download and seek; we just route its output into the engine's bus graph so it picks up the same FX/sidechain as buffer-based voices. Created lazily — the underlying MediaElementAudioSource is only built on first play(), since it can't be reattached to a different bus once created. ](/api/StreamSound/) - [ StretchProcessor Pitch-preserving time-stretch via overlap-add granular synthesis with cross-correlation alignment (a SOLA-style approximation). Used to render an offline stretched copy of an AudioBuffer at load time — not realtime. For realtime tempo control, see the (planned) AudioWorklet implementation. stretchFactor > 1 = play faster (shorter buffer). stretchFactor < 1 = not currently supported (use rate via PlaybackRate). ](/api/StretchProcessor/) - [ Variants A bundle of N alternate sounds that play one at a time. The classic casino-slot pattern: every coin / win / reel-stop trigger plays a randomised variant so stacked SFX don't sound robotic. Construct via ](/api/Variants/) - [ Voice One playback instance, returned from sound.play(). Owns its source node and gain stage; disposes itself on natural end, signal abort, or stop(). Voice constructor is package-private — callers obtain instances via Sound.play(). ](/api/Voice/) - [ ZvukError ](/api/ZvukError/) ## Function - [ applyLoudnessNormalization ](/api/applyLoudnessNormalization/) - [ canPlay ](/api/canPlay/) - [ computeNormalizationGain ](/api/computeNormalizationGain/) - [ createEngine ](/api/createEngine/) - [ createStretchWorkletNode ](/api/createStretchWorkletNode/) - [ ensureStretchWorklet ](/api/ensureStretchWorklet/) - [ mimeForUrl ](/api/mimeForUrl/) - [ pickSource ](/api/pickSource/) - [ pickSourceOrder ](/api/pickSourceOrder/) ## Interface - [ ApplyOptions ](/api/ApplyOptions/) - [ AudioLevel Live amplitude readout, returned by ](/api/AudioLevel/) - [ BusConfig ](/api/BusConfig/) - [ CompressorConfig ](/api/CompressorConfig/) - [ ConcurrencyConfig ](/api/ConcurrencyConfig/) - [ CrossfadeOptions ](/api/CrossfadeOptions/) - [ DecodeAttempt ](/api/DecodeAttempt/) - [ DuckerConfig ](/api/DuckerConfig/) - [ Engine The audio engine. Generic in ](/api/Engine/) - [ EngineConfig ](/api/EngineConfig/) - [ FadeOptions ](/api/FadeOptions/) - [ FilterConfig ](/api/FilterConfig/) - [ FxInsert Common FX insert contract. Every FX exposes a single input + a single output node so the bus can splice it into the (fxInput → output) hop. ](/api/FxInsert/) - [ LoadSoundOptions ](/api/LoadSoundOptions/) - [ LoudnessOptions RMS-based loudness normalization on a decoded AudioBuffer. ](/api/LoudnessOptions/) - [ MasterConfig ](/api/MasterConfig/) - [ MasterLimiterConfig ](/api/MasterLimiterConfig/) - [ MusicLoadOptions ](/api/MusicLoadOptions/) - [ MusicParts A three-part music asset: optional intro stinger, mandatory loop body, optional outro tail. Modelled on the Wwise / FMOD pattern every casino slot, action game, and rhythm game uses for combat/win/menu music. Each part accepts a single URL or a codec ladder, the same shape as ](/api/MusicParts/) - [ MusicPlayOptions ](/api/MusicPlayOptions/) - [ PlayOptions ](/api/PlayOptions/) - [ PreloadFailure ](/api/PreloadFailure/) - [ PreloadItem One entry in a ](/api/PreloadItem/) - [ PreloadOptions ](/api/PreloadOptions/) - [ PreloadProgressEvent ](/api/PreloadProgressEvent/) - [ ResolveAssetContext ](/api/ResolveAssetContext/) - [ ReverbConfig ](/api/ReverbConfig/) - [ SendOptions ](/api/SendOptions/) - [ SidechainConfig ](/api/SidechainConfig/) - [ SkipToOutroOptions ](/api/SkipToOutroOptions/) - [ SnapshotState ](/api/SnapshotState/) - [ SpatialOptions ](/api/SpatialOptions/) - [ SpriteMap ](/api/SpriteMap/) - [ SpriteRegion ](/api/SpriteRegion/) - [ StopOptions ](/api/StopOptions/) - [ StretchWorkletNode ](/api/StretchWorkletNode/) - [ StretchWorkletOptions ](/api/StretchWorkletOptions/) - [ TickSource External ticker for driving the scheduler's task dispatch. Typically a host's existing render loop (Pixi ](/api/TickSource/) - [ VariantsOptions ](/api/VariantsOptions/) - [ VoiceDefaults ](/api/VoiceDefaults/) - [ VoiceJitter ](/api/VoiceJitter/) ## TypeAlias - [ AssetResolver Hook for adopting buffers from an external asset system (Pixi assetpack, IndexedDB cache, custom manifest) instead of (or in addition to) zvuk's URL fetcher. Returning ](/api/AssetResolver/) - [ AudioMimeType Codec capability + asset-source picking. Recommended encoding pipeline: - Primary: WebM/Opus — smallest, best quality/byte, supported in Chrome, Firefox, Edge, and Safari 14.1+ on macOS / iOS 17+. - Fallback: AAC in M4A — required for older iOS Safari (≤16) and older macOS Safari without Opus support. Ship both; pickSource() returns the first URL the browser can decode. canPlay() uses HTMLAudioElement.canPlayType — it gives a sound (no pun intended) prediction without actually fetching anything. The Web Audio decoder will accept anything HTMLAudioElement says it can play, plus a few extras (uncompressed WAV always works), so canPlay is conservative. ](/api/AudioMimeType/) - [ DistanceModel Distance-attenuation curve. Mirrors ](/api/DistanceModel/) - [ EngineState Engine lifecycle, mirroring the underlying AudioContext: - ](/api/EngineState/) - [ FadeCurve ](/api/FadeCurve/) - [ FilterKind ](/api/FilterKind/) - [ MusicState ](/api/MusicState/) - [ NormalizeFlag ](/api/NormalizeFlag/) - [ ParameterCurve ](/api/ParameterCurve/) - [ ResolvedAsset Anything an ](/api/ResolvedAsset/) - [ SpriteRegionPlayOptions ](/api/SpriteRegionPlayOptions/) - [ VariantStrategy Strategy for picking which variant fires on each ](/api/VariantStrategy/) --- # mimeForUrl · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/mimeForUrl/ Reference for mimeForUrl. [← API](/api/) Function # mimeForUrl ## Signatures - mimeForUrl ( url: string ): void --- # pickSource · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/pickSource/ Reference for pickSource. [← API](/api/) Function # pickSource ## Signatures - pickSource ( urls: unknown ): string Given a list of URLs (e.g. \['sfx.webm', 'sfx.m4a'\]), return the first one the browser claims it can play. If none match — or there's no DOM, e.g. during SSR — return the first URL and let decodeAudioData decide. --- # pickSourceOrder · API · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/api/pickSourceOrder/ Reference for pickSourceOrder. [← API](/api/) Function # pickSourceOrder ## Signatures - pickSourceOrder ( urls: unknown ): void Return the URL list reordered for fallback loading: codecs the browser claims it can play first (in user-given order), unknowns/unsupported last. canPlayType is a hint, not a hard filter — some browsers under-report support, and decodeAudioData accepts a few extras. So we keep all URLs in the result; ordering merely biases the first attempts toward what's most likely to succeed. Pair with Decoder.loadFirst() to walk the list and fall through on per-URL fetch/decode failures. --- # Changelog · zvuk zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/changelog/ Every released version of @schmooky/zvuk, sourced from the root CHANGELOG.md. Releases # Changelog Generated by [Changesets](https://github.com/changesets/changesets) from [CHANGELOG.md](https://github.com/schmooky/zvuk/blob/main/CHANGELOG.md). Install any version with `pnpm add @schmooky/zvuk@`. 1. ## [v1.14.0](#v1.14.0) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.14.0) minor 1 change - [#95](https://github.com/schmooky/zvuk/pull/95) [`85d52d3`](https://github.com/schmooky/zvuk/commit/85d52d3c7f8264341dab6325c8f18d9f53806390) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Loading and cache improvements. - Concurrent loads of the same URL now share one fetch and one decode. The shared request is only aborted once every caller has aborted, so one caller pulling out doesn't cancel the others. - The decoded-buffer cache is budgeted in bytes, not entries. Configure it with `createEngine({ cache: { maxBytes, maxEntries } })`; defaults are 64 MiB and 128 entries. - `DecodeOptions.onProgress` is implemented — byte-level download progress, read off `response.body`. - `loadMusic` fetches intro, loop and outro in parallel; `loadVariants` runs through a worker pool at the same concurrency cap as `preload`, instead of awaiting each in a loop. - `Music` tracks the voices it spawns: `music.voices()` and `music.stopAll()`. - `EngineState` gains `'suspended'`. Code that switches exhaustively over it needs a new branch. - `variants.lastPick` reports which take the most recent `play()` chose, or `-1` before the first. The bundle already tracked it internally; it is read-only now so a subtitle, a telemetry event or an animation can follow whichever alternate actually fired. patch 1 change - [#95](https://github.com/schmooky/zvuk/pull/95) [`85d52d3`](https://github.com/schmooky/zvuk/commit/85d52d3c7f8264341dab6325c8f18d9f53806390) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Fix a set of audio-scheduling and lifecycle bugs. - `applyRamp` now interrupts a running `setValueCurveAtTime` with `cancelAndHoldAtTime` where the engine has it, and no longer lets a refused scheduling call escape to the caller — `voice.fade()` and `bus.fadeTo()` had no handler of their own. - A looping sprite region stopped after one pass, which made `SpriteRegion.loop` documented but non-functional. - `voice.setPlaybackRate` never re-armed the region stop-timer, so a `duration`\-bounded voice at half speed still ended at the original wall time. - `resolveAsset` returning an `ArrayBuffer` had it detached by `decodeAudioData`, so a resolver backed by a byte cache broke on its second hit. - `Ducker` started its envelope at 0 (dropping the target bus to silence on insertion), wrote `gain.value = 1` on bypass (a click), left stale envelope state that could disable ducking after un-bypass, ran its rAF loop while bypassed, froze mid-duck in a hidden tab, and threw under SSR. - `Master.rewire()` dropped the meter analyser, so `masterMeter()` read zero forever after any `setLimiter()` call. `setHeadroom` now ramps instead of writing `gain.value`. - `Bus.muted` had no equality guard, so `Snapshot.blendWith` fired a redundant ramp on every bus on every frame. - The engine reported `'live'` over a suspended context, so `unlock()` early-returned and manual recovery was impossible. `EngineState` gains `'suspended'`. - `new AudioContext()` had no `webkitAudioContext` fallback. - `close()` left bus groups and the solo set populated and never stopped live `MusicVoice` instances. - Detached fades in `crossfade()` had no `.catch`, and `preload` used `Promise.all`, so an abort rejected the batch while siblings rejected into the void. - Errors stringified their cause into the message and discarded it; they now pass it through as `cause`. - Fade promises resolved on `setTimeout` rather than the audio clock, so a voice stopped mid-fade still reported at the full duration and a frozen ramp resolved anyway. - `steal: 'quietest'` allocated a permanent `AnalyserNode` per candidate voice. - Sprite and variant parts leaked their internal registry names into `hasSound`, into did-you-mean suggestions, and onto `voice.sourceName`, which broke `engine.crossfade` for variants. - `Scheduler.scheduleAt` full-sorted on every insert and never evicted cancelled tasks. - Errors built their did-you-mean text by splicing quote characters into the caller's own template, producing \`Bus "sxf"; did you mean "sfx" is - `tsup` ships a minified bundle. The tarball was published unminified at 25 kB gzipped while the README advertised a min+gzip figure, so the number on the page described a build nobody was installing. It is 16.9 kB now, gated in CI at 18 kB, with a second gate at 16 kB on a `createEngine`\-only import (14.6 kB today). No API change here, but worth knowing if you picked zvuk partly for the "fully tree-shakable" line in the README: it wasn't true, and the README now says what is. The FX classes drop out when unused. The engine core does not, because `createEngine` statically reaches every source type, so importing only `createEngine` still costs 14.6 kB of the 16.9 kB whole. Loosening that is tracked in [#94](https://github.com/schmooky/zvuk/issues/94). 2. ## [v1.13.0](#v1.13.0) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.13.0) minor 1 change - [#87](https://github.com/schmooky/zvuk/pull/87) [`50bb39f`](https://github.com/schmooky/zvuk/commit/50bb39f70cc6c9bf2298b721f120189d93b99ec8) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Add `Filter.setGain(db)` to adjust the peaking-filter gain live (previously `gain` could only be set at construction). Also fixed concept-page docs: the Engine state union now lists `interrupted` (5 states, not 4), the Filter API surface shows `input`/`output` as `GainNode` (not `BiquadFilterNode`) plus the new `setGain`, and the manual Spatializer recipe now routes a source into the node `connectInto()` returns (the previous snippet produced silence). patch 12 changes - [#89](https://github.com/schmooky/zvuk/pull/89) [`7037d0b`](https://github.com/schmooky/zvuk/commit/7037d0b71d11a41a6b6b3c68f2678f91e3c7162e) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Minor cleanups: use `Math.SQRT1_2` for the occlusion filter's Butterworth Q (clears the last Biome warning) and fix a stale `Bus.fxInput` doc comment that claimed it equals `input` (it's a distinct node the FX chain splices into). - [#86](https://github.com/schmooky/zvuk/pull/86) [`176408c`](https://github.com/schmooky/zvuk/commit/176408c35f5b0e462ab4e7e0ce07d6084619dadf) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Stop labelling the scheduler "sample-accurate". It dispatches JS callbacks from a tick source (`setTimeout` or an injected ticker), so it is tick-bounded (ms-level), as its own docstring already noted. Reworded the README, `Scheduler` docs, and docs pages to "audio-clock scheduler", and corrected the claim that the `scheduleAt` callback receives an `audioTime` argument (it does not — close over the value you scheduled against). - [#85](https://github.com/schmooky/zvuk/pull/85) [`8fb3747`](https://github.com/schmooky/zvuk/commit/8fb37470d372a3131e947b3b3ec77c920b6aa4bf) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Stop calling the master limiter a "brick-wall" limiter. It's a fast-attack `DynamicsCompressorNode` (ratio ~20) — limiter-like but, with a finite attack and no lookahead, it does not guarantee a hard 0 dBFS ceiling. Reworded the README, `Master` docs, and `MasterLimiterConfig`/`MasterConfig` to "soft limiter / best-effort peak control", and fixed the concepts/mixer snippet that used a non-existent `engine.bus('master')` / `master.setLimiter` runtime API (the limiter is configured at construction). - [#75](https://github.com/schmooky/zvuk/pull/75) [`69cae54`](https://github.com/schmooky/zvuk/commit/69cae5480a7073e5a154a20d33186a07dfbcc10d) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Clarify that the realtime stretch worklet (`createStretchWorkletNode`) is **varispeed** — it shifts pitch and tempo together (tape-style), not pitch-preserving time-stretch like the offline `StretchProcessor`. Updated the module docs, README feature, and the pitch FX page to say so, and removed dead Hann-window code from the worklet processor that implied an overlap-add it never performed. No runtime behavior change. - [#73](https://github.com/schmooky/zvuk/pull/73) [`1ef40e9`](https://github.com/schmooky/zvuk/commit/1ef40e9407bfcd1c315dd2b05d649b8f46715c70) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - `VoiceJitter` now accepts a `base` so `volume`/`pitch` jitter can be combined with a chosen center value, e.g. `play({ pitch: { base: 1.5, jitter: 0.1 } })`. Previously jitter always centered on `1.0`, so a base playback rate or volume could not be combined with jitter. Plain numbers and `{ jitter }`\-only forms are unchanged. - [#74](https://github.com/schmooky/zvuk/pull/74) [`4424bee`](https://github.com/schmooky/zvuk/commit/4424beeda56adb426b471cf2a27d4ee9d7c62d8a) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - `Bus.dispose` now tears down the bus's sends and FX inserts. Previously a disposed bus left its `Send` GainNodes connected to their target buses (a node leak), since `engine.close` never iterated bus sends. `Bus.fx()` and `Bus.sends()` now return copies so callers can't mutate the live chain/send list. - [#80](https://github.com/schmooky/zvuk/pull/80) [`1806f89`](https://github.com/schmooky/zvuk/commit/1806f89703f68f33f27427c4ff1156fdd0848566) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - `engine.crossfade` now only fades out instances of `from` on the **same bus** the incoming voice plays on — previously it faded out every voice of `from` across all buses, so crossfading on the music bus could stop the same sound playing on an ambience bus. Also corrected the docs: a fresh voice for `to` is always started (it never reuses an already-playing `to`, despite the previous wording). - [#79](https://github.com/schmooky/zvuk/pull/79) [`6bff9b7`](https://github.com/schmooky/zvuk/commit/6bff9b7bc50191d84a0b40cafa80fe3605f5d419) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - `Ducker`'s envelope follower now measures the real frame delta (from the rAF timestamp, clamped to 1–100 ms) instead of assuming a fixed `1/60 s`. The attack/release time constants were previously ~2× too fast on 120 Hz displays and far too slow in throttled/background tabs. - [#82](https://github.com/schmooky/zvuk/pull/82) [`33d8d57`](https://github.com/schmooky/zvuk/commit/33d8d57f5f38d1df67e428ea9e486dc142d84f88) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Loop crossfades now use equal-power (sin/cos) ramps instead of linear ones, for both `PlayOptions.loopCrossfade` (Voice) and `MusicLoadOptions.loopCrossfade` (Music). Two overlapping linear ramps summed to a ~3 dB power dip at every loop boundary — the exact seam the feature is meant to hide, and contrary to the "equal-power" the docs already claimed. - [#77](https://github.com/schmooky/zvuk/pull/77) [`1e99f97`](https://github.com/schmooky/zvuk/commit/1e99f975ecba2263fa9f6f11d0f82f36ca05013c) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Loudness normalization no longer mutates the source buffer in place — it scales into a fresh `AudioBuffer` when run through the engine, so a buffer adopted from a `resolveAsset` cache is left untouched. Also clarified that normalization matches **RMS level**, not perceptual/LUFS loudness, in the docs and `LoadSoundOptions.normalize`. - [#78](https://github.com/schmooky/zvuk/pull/78) [`ba9ec8a`](https://github.com/schmooky/zvuk/commit/ba9ec8aeaf1ede1d875dce73dc0b1c3031bf74d3) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - `preload` abort hardening: `combineSignals` now removes both abort listeners as soon as either fires, so a batch-wide signal reused across every item no longer accumulates a stale listener per item. (In-flight fetches were already cancelled on abort via signal propagation; this also adds explicit mid-batch abort test coverage.) - [#81](https://github.com/schmooky/zvuk/pull/81) [`f37be23`](https://github.com/schmooky/zvuk/commit/f37be23a893f492591c6f9715e4bde28f497cb66) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - `Voice.cues()` now always yields a terminal `ended` cue. If the iterator was attached after the voice had already finished, it previously returned an empty stream — so a consumer awaiting `ended` from `cues()` never observed completion. 3. ## [v1.12.1](#v1.12.1) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.12.1) patch 4 changes - [#69](https://github.com/schmooky/zvuk/pull/69) [`9a95045`](https://github.com/schmooky/zvuk/commit/9a95045de2c7b85df4fd7ae40c8794026d1d63a2) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Fix `Bus.fadeTo` so it no longer overrides mute or solo. It previously wrote the output gain unconditionally, so fading a muted (or solo-veiled) bus audibly un-muted it. As a consequence, `Snapshot.apply` could not keep a bus captured as muted silent — the level fade un-muted it right after the mute was applied. `fadeTo` now stores the target level while silenced and applies it when the bus is unmuted/unveiled. - [#68](https://github.com/schmooky/zvuk/pull/68) [`13a3a94`](https://github.com/schmooky/zvuk/commit/13a3a941c897b09c06c125bdb2c3d6dbb82d004c) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Fix the `equal-power` fade curve so crossfades stay at constant power. It previously applied a `sin²` gain symmetrically, dipping ~3 dB at the crossfade midpoint — the exact loudness dip equal-power is meant to remove. The curve is now direction-aware (rising legs follow `sin(t·π/2)`, falling legs `cos(t·π/2)`), so two opposing legs sum to unity power. Affects `engine.crossfade`, `Bus`/`Send` fades, `Snapshot.apply`, and `Parameter` bindings using `curve: 'equal-power'`. - [#71](https://github.com/schmooky/zvuk/pull/71) [`f4580f1`](https://github.com/schmooky/zvuk/commit/f4580f1757e5addbe130294175e7cd74c284290a) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Fix the README sidechain-ducking example. It constructed `new Ducker(engine.context, { source, target, ... })`, but the real signature is `new Ducker(ctx, sourceBus, config)` — the source bus is the second positional argument and `DuckerConfig` has no `source`/`target` keys, so the snippet did not compile or run. It now matches the actual API (and the ducking guide). - [#70](https://github.com/schmooky/zvuk/pull/70) [`98235d6`](https://github.com/schmooky/zvuk/commit/98235d63cd2ab57f93ebee30f3c9cb32bb8e7ba4) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Fix `Reverb` bypass. Bypassing now passes the dry signal at unity gain and silences the wet path — it previously left dry at `1 - wet`, so a "bypassed" reverb attenuated the signal by up to ~3 dB. Un-bypassing restores the configured/last-set wet mix instead of snapping to a hardcoded `0.3`, and `setWet` called while bypassed is remembered and applied when the effect is re-enabled. 4. ## [v1.12.0](#v1.12.0) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.12.0) minor 1 change - [#44](https://github.com/schmooky/zvuk/pull/44) [`7b3a2ab`](https://github.com/schmooky/zvuk/commit/7b3a2abda739febec4047a9075df2ebc48fa562e) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Snapshot blend — interpolate the live mix between two captured snapshots. ```ts const calm = engine.captureSnapshot("calm"); // ...set the mix to its combat shape, then capture again. const combat = engine.captureSnapshot("combat"); // Snap the live mix to lerp(calm, combat, t). engine.blendSnapshots(calm, combat, 0.4); // Drive it from a Parameter — bus levels and parameter values follow per-frame. const tension = engine.parameter("tension", 0); tension.subscribe((t) => engine.blendSnapshots(calm, combat, t)); tension.set(0.75); ``` - **`engine.blendSnapshots(a, b, t)`** — snaps every bus level and parameter value to `lerp(a, b, t)`. `t` is clamped to `[0, 1]`. Each call is instant (the 10 ms anti-click ramp on `bus.level` still applies), so calling it on every frame is cheap. - **`snapshot.blendWith(other, t)`** — same operation as a method on `Snapshot`, mirroring `apply()`. - Buses or parameters present in only one of the two snapshots are skipped. Mute flips at `t = 0.5` rather than interpolating, since the flag is binary. - For one-shot crossfades with a fade duration, `snapshot.apply({ fade })` is unchanged — `blendSnapshots` is the continuous-knob sibling. New `examples/snapshot-blend/` shows the pattern end-to-end: two looping layers, a slider drives a `tension` parameter that interpolates between a `calm` and `combat` snapshot. 5. ## [v1.11.1](#v1.11.1) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.11.1) patch 1 change - [#42](https://github.com/schmooky/zvuk/pull/42) [`818bbd9`](https://github.com/schmooky/zvuk/commit/818bbd9235c1c80543ce7b8dd2122c1b9eb613be) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Drop `eventemitter3` runtime dependency. It was declared in `dependencies` but never imported anywhere in the package — a leftover that slipped in by mistake. `Voice` cue listeners use a plain `Set` walker, not an `EventEmitter`. zvuk now has zero runtime dependencies, matching the pitch in the README. 6. ## [v1.11.0](#v1.11.0) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.11.0) minor 1 change - [#40](https://github.com/schmooky/zvuk/pull/40) [`7279123`](https://github.com/schmooky/zvuk/commit/7279123a19f2510f65a524067022b3b10672cfa1) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - DX wins — five small, mutually-independent additions. ```ts // 1) Variants — bundle N alternates, picker keeps SFX from sounding robotic. await engine.loadVariants( "coin", [ ["/sfx/coin-1.webm", "/sfx/coin-1.m4a"], ["/sfx/coin-2.webm"], ["/sfx/coin-3.webm"], ], { bus: "sfx", strategy: "no-repeat" } // 'random' | 'no-repeat' | 'shuffle-bag' ); engine.variants("coin").play(); // 2) Fade-in on play — dual of the click-free stop fade. engine.sound("ambience").play({ loop: true, volume: 0.7, fadeIn: 0.5 }); // 3) Explicit unload — drops the sound AND evicts its buffer from the LRU. engine.unloadSound("coin"); // evictBuffer: true (default) engine.unloadSound("coin", { evictBuffer: false }); // registry only // 4) Latency hint — maps to AudioContext.latencyHint. const engine = createEngine({ buses: { music: {}, sfx: {} }, latencyHint: "interactive", // | 'playback' | 'balanced' | number }); // 5) Branded BusName — engine.bus(name) types against your declared buses. const engine = createEngine({ buses: { music: {}, sfx: {} } }); engine.bus("music"); // ✓ engine.bus("sxf"); // ✗ Type Error: Argument of type '"sxf"' is not assignable // to parameter of type '"music" | "sfx"'. ``` - **`engine.loadVariants(name, urls, options)` + `Variants`** — picker strategies are `'random'`, `'no-repeat'` (default), `'shuffle-bag'`. The `'no-repeat'` and `'shuffle-bag'` paths handle the spam-feel-robotic problem every casino slot hits without users rolling their own shufflers. - **`PlayOptions.fadeIn`** — voice ramps from 0 → volume over the configured window. Eliminates the `play({ volume: 0 }) + voice.fade({ to, duration })` two-step that ambient layers needed. - **`engine.unloadSound(name, { evictBuffer? })`** — explicit eviction sibling to `removeSound`. Active voices keep playing until they end naturally; only future `play()` calls are affected. `evictBuffer` defaults to `true`. - **`createEngine({ latencyHint })`** — forwards to `AudioContextOptions.latencyHint`. Slot games doing 60 fps reactive audio want `'interactive'`; long music players want `'playback'`. Browsers honour numeric values on a best-effort basis. - **Branded `BusName` types.** `Engine` and `EngineConfig` are generic in `TBusName`. Pass a literal `buses` map and `engine.bus(name)` type-checks against the keys you declared. Fully backwards-compatible — pass an `EngineConfig` typed as `string` (the default) and `engine.bus()` accepts any string. Voice and Loading concept pages picked up the new sections. 7. ## [v1.10.0](#v1.10.0) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.10.0) minor 1 change - [#38](https://github.com/schmooky/zvuk/pull/38) [`ba8865a`](https://github.com/schmooky/zvuk/commit/ba8865a4310d994b4abeb639a2e8591e988418f7) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Spatializer 3D config exposed, plus a single-knob occlusion parameter. ```ts // 3D config — previously hard-coded, now configurable per voice. engine.sound("engine").play({ spatializer: { position: [10, 0, 0], refDistance: 5, // full volume within 5 units maxDistance: 250, rolloffFactor: 1.5, distanceModel: "inverse", // 'linear' | 'inverse' | 'exponential' occlusion: 0, // 0..1 — "behind a wall" knob }, }); // Live setters for moving sources / dynamic environments. v.spatializer?.setRefDistance(8); v.spatializer?.setMaxDistance(500); v.spatializer?.setRolloffFactor(2); v.spatializer?.setDistanceModel("linear"); v.spatializer?.setOcclusion(0.7); ``` Two related changes: - **3D config exposed.** `refDistance`, `maxDistance`, `rolloffFactor`, and `distanceModel` are now `SpatialOptions` fields and have matching live setters on `Spatializer`. Previously these were hard-coded to `(1, 1000, 1, 'inverse')`. Defaults match Web Audio sensible values (`(1, 10000, 1, 'inverse')`); existing code is unaffected unless it relied on the slightly tighter `maxDistance` of 1000. - **Occlusion knob.** A new `occlusion: 0..1` field on `SpatialOptions`, plus `setOcclusion(amount)` on `Spatializer`. Drives an internal `BiquadFilterNode` lowpass (cutoff sweeps log-style from 22050 Hz to ~500 Hz) plus a gain stage (-6 dB at amount = 1). Independent of distance attenuation; bind a single `Parameter` to both `setOcclusion` and a position update if you want one knob to drive both. 3D Spatializers gain one extra `BiquadFilterNode` + `GainNode` per voice for the always-on (but transparent at occlusion = 0) occlusion chain. 2D StereoPanner spatializers are unchanged. Documented on the Spatializer concept page. 8. ## [v1.9.0](#v1.9.0) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.9.0) minor 1 change - [#36](https://github.com/schmooky/zvuk/pull/36) [`102a8fb`](https://github.com/schmooky/zvuk/commit/102a8fb2b6bf226505a37c96021af57972cb2481) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Routing primitives — bus sends, solo, bus groups, and a master meter. ```ts // Send a configurable share of one bus into another. const verbSend = engine .bus("music") .send(engine.bus("reverb"), { amount: 0.3 }); verbSend.amount = 0.5; // setter ramps over 10 ms await verbSend.fadeTo(0, 1.2); // smooth fade-out verbSend.dispose(); // remove // Solo any subset of buses; engine coordinates the global mute-the-rest rule. engine.bus("voice").solo(); engine.bus("music").solo(); // additive — both still audible engine.bus("voice").unsolo(); // music still soloed; everyone else still muted // Address several buses with a single handle. const combat = engine.busGroup("combat", [ engine.bus("weapons"), engine.bus("enemies"), engine.bus("environment"), ]); combat.level = 0.5; // applied to every member await combat.fadeTo(0, 0.8); // fades every member in parallel combat.solo(); // solos every member at once // Live amplitude readout on the master output — same shape as bus.meter(). const m = engine.masterMeter(); // → { rms, peak } ``` Four additions, all sharing the routing/mixing theme: - **`bus.send(target, { amount, post })`** — the Wwise primitive the README has been claiming. Each `send` returns a `Send` handle with live `amount`, `fadeTo()`, and `dispose()`. Default tap is post-fader / post-FX; pass `post: false` for monitor-style pre-fader sends. Sends route into the target's `input`, so the target's FX chain and concurrency rules apply naturally. - **`bus.solo()` + `bus.unsolo()`** — engine maintains the global solo set. While any bus is soloed, every non-soloed bus is muted via a 10 ms ramp; when the set drains, every bus is restored. Solo state is independent of `muted` — un-soloing returns each bus to its own user-visible mute state, not unconditionally to "audible". Multiple solos are additive. - **`engine.busGroup(name, members)` / `engine.busGroup(name)`** — a `BusGroup` is a logical handle, not an audio node. Setting `group.level`, calling `group.fadeTo()`, `group.muted = true`, or `group.solo()` applies to every member in parallel. Doesn't change the audio graph; pure convenience for sub-mixes that always move together. - **`engine.masterMeter()`** — same `{ rms, peak }` readout as `bus.meter()` and `voice.level()`, just at the top of the chain. Lazy AnalyserNode tap on `master.input`. The Bus concept page on the docs site picks up four new sections covering each. 9. ## [v1.8.0](#v1.8.0) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.8.0) minor 1 change - [#34](https://github.com/schmooky/zvuk/pull/34) [`d224167`](https://github.com/schmooky/zvuk/commit/d2241673f5947cf5b6032a6eaa053f0a1719e3be) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Add the `Music` source — stinger → loop → outro, the pattern every casino slot, action game, and rhythm game uses for combat/win/menu music. ```ts await engine.loadMusic( "boss-theme", { intro: ["/music/boss-intro.webm", "/music/boss-intro.m4a"], loop: ["/music/boss-loop.webm", "/music/boss-loop.m4a"], outro: ["/music/boss-outro.webm", "/music/boss-outro.m4a"], }, { bus: "music", loopCrossfade: 0.05 } ); const m = engine.music("boss-theme").play({ volume: 0.7, fadeIn: 0.2 }); // → intro plays once, then the loop runs forever. m.skipToOutro(); // → finishes current loop iteration, plays outro, ends m.skipToOutro({ at: "now" }); // → fades loop (~50 ms), starts outro immediately m.stop(); // → click-free fade-out, no outro await m.ended; ``` Each part accepts a single URL or a codec ladder, loaded through the same decoder cache and `resolveAsset` hook as `loadSound`. The intro and outro are both optional — a loop-only manifest works the way a regular looping sound does today, and `skipToOutro()` on a loop-only asset falls through to a clean stop so calling code doesn't have to branch on `music.hasOutro`. `loopCrossfade` carries through to the loop body (same equal-power-at-the-boundary trick from v1.5's `PlayOptions.loopCrossfade`), so non-zero-crossing loop regions don't click on takeover. A new vanilla example `examples/music-stinger-loop-tail/` wires it up end-to-end with start/skip-to-outro · loop-end/skip-to-outro · now/hard-stop buttons and a part-state indicator. The Music concept page on the docs site walks through the API surface and the two skip-to-outro modes. Drive-by: deleted three pre-existing lint warnings (`_baseLevel` unused field on Bus, template-literal nit in CLI transcode, optional-chain nit in codecs). 10. ## [v1.7.3](#v1.7.3) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.7.3) patch 1 change - [#32](https://github.com/schmooky/zvuk/pull/32) [`fdd04e6`](https://github.com/schmooky/zvuk/commit/fdd04e66b65ef3d76dbc6ab85ac7189fed472901) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Spectrum bars on every interactive docs demo (was: time-domain oscilloscope), plus a `bars-stereo` variant for the SpatialPanner. Bars read better than the oscilloscope across the whole site — pulses with the music, makes filter sweeps and pitch shifts visibly obvious, looks consistent. The 13 non-spatial demos (`BusFader`, `CompressorPlayground`, `CrossfadeDemo`, `MixerDashboard`, `ParameterModulator`, `PitchStretch`, `ReverbWet`, `SlotReel`, `SnapshotCrossfade`, `SoundCard`, `VoiceJitter`, `VoiceLimit`, `CrossfadeDemo`) all use the standard mono spectrum. `SpatialPanner` is the one demo where the mono sum hides what's happening — pan all the way left and the summed spectrum is identical to centred. So the panner now uses a new `bars-stereo` variant: `` splits the source through a `ChannelSplitterNode` and runs an analyser per channel, rendering L and R spectra side-by-side with a hairline divider. As you drag the puck, you watch the L bars grow while the R bars shrink, which is the actual demo. No public API change; docs-site polish only. 11. ## [v1.7.2](#v1.7.2) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.7.2) patch 1 change - [#30](https://github.com/schmooky/zvuk/pull/30) [`5449941`](https://github.com/schmooky/zvuk/commit/5449941ae3cd4d99a924eaf61551ec2903d60d91) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Live waveform overlays on every interactive docs demo. Each playable component on the docs site now renders a real-time oscilloscope (or, for the FilterSweep demo, a frequency-domain spectrum) on the bus the demo routes audio through. As you drag a slider, toggle bypass, fire a voice, or pan a sound, you see the signal change immediately — not just hear it. Wired into all 14 docs demos: `BusFader`, `CompressorPlayground`, `CrossfadeDemo`, `FilterSweep`, `MixerDashboard` (per-bus mini-meters), `ParameterModulator`, `PitchStretch`, `ReverbWet`, `SlotReel`, `SnapshotCrossfade`, `SoundCard`, `SpatialPanner`, `VoiceJitter`, `VoiceLimit`. Implementation is a small `` React component that lazily attaches its own AnalyserNode as a passive sibling of the source node — no engine change, no audio-path change. Cleans up on unmount or when the source changes. No public API change; docs-site polish only. 12. ## [v1.7.1](#v1.7.1) [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.7.1) patch 1 change - [#28](https://github.com/schmooky/zvuk/pull/28) [`595a8eb`](https://github.com/schmooky/zvuk/commit/595a8eb1b65f0144894ac7007ff5800ecd76dea9) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Render changelog markdown on the docs site. The `/changelog/` page was dumping each entry's body inside a `
` tag, so bullets, bold, fenced code blocks, and links rendered as raw markdown noise (`- **Foo**` instead of a styled list). Two fixes:
         
         -   The build script (`docs/scripts/build-changelog.mjs`) now passes each bullet body through `marked` and stores the rendered HTML alongside the original markdown. Build-time only — no markdown parser ships to the browser. The parser also keeps blank lines between indented continuation lines so paragraphs in long entries don't get squished into a single block.
         -   The `/changelog/` Astro page injects the pre-rendered HTML through `set:html` and styles it with a scoped `.changelog-prose` block so paragraphs, lists, code spans, and fenced code all render properly.
         
         No public API change — purely a docs-site fix.
         
     
13.  ## [v1.7.0](#v1.7.0)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.7.0)
     
     minor 1 change
     
     -   [#26](https://github.com/schmooky/zvuk/pull/26) [`2a3f6a9`](https://github.com/schmooky/zvuk/commit/2a3f6a968755a18e6512e0aae63e2f623ff1e3fb) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Live amplitude meters on Voice and Bus, plus a `rhythm-metronome` example.
         
         ```ts
         // Per-voice readout — lazy AnalyserNode tap on the voice's gain stage.
         const v = engine.sound("hit").play();
         v.level(); // → { rms: 0.18, peak: 0.42 }   linear, in [0..1]
         
         // Per-bus readout — same shape, on bus output.
         engine.bus("music").meter();
         ```
         
         Both methods return `{ rms, peak }` as linear values in `[0..1]`. The first call on each instance lazily attaches an AnalyserNode as a passive sibling of the existing audio path — no cost until you read, no signal flow change.
         
         Three things ship together because they share the same primitive:
         
         -   **`voice.level()`** — drives per-voice clip indicators, custom voice-stealing rules, or "loudest voice in the mix" UI.
         -   **`bus.meter()`** — drives mixer-dashboard VU meters and automation that reacts to bus level.
         -   **`'quietest'` voice steal works for real now.** Previously it logged a console warning and silently fell back to `'oldest'` (per the v1.4.0 changelog). With per-voice levels available it now does what the docs said all along: when the bus hits its concurrency limit, the voice with the lowest live RMS is stolen. The fallback warning is gone.
         
         A new vanilla example, `examples/rhythm-metronome/`, ties it together: sample-accurate clicks via `engine.scheduleAt`, a live VU bar driven by `bus.meter()`, and a per-voice peak meter showing `voice.level()` on the most-recently-fired voice. BPM control with drift-free re-anchoring.
         
         Documented on the Voice and Bus concept pages.
         
     
14.  ## [v1.6.0](#v1.6.0)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.6.0)
     
     minor 1 change
     
     -   [#24](https://github.com/schmooky/zvuk/pull/24) [`d7b94de`](https://github.com/schmooky/zvuk/commit/d7b94de82378808ecaee18f9fdefc6f679a38828) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Add `engine.preload(items, options)` — a first-class bulk loader for loading screens.
         
         ```ts
         await engine.preload(
           [
             {
               name: "coin",
               url: ["/sfx/coin.webm", "/sfx/coin.m4a"],
               options: { bus: "sfx" },
             },
             {
               name: "win",
               url: ["/sfx/win.webm", "/sfx/win.m4a"],
               options: { bus: "sfx" },
             },
             // ... 100 more items
           ],
           {
             concurrency: 4,
             onProgress: ({ name, status, completed, total }) => {
               bar.value = completed / total;
             },
           }
         );
         ```
         
         The DIY `Promise.all(items.map(loadSound))` pattern works fine for small batches, but breaks down once you ship a real loading screen: every adopter writes the same boilerplate for per-item progress, a concurrency cap so the rest of the page's network isn't starved, and aggregated failure reporting. `engine.preload` provides all three:
         
         -   **Per-item progress** via `onProgress({ name, status, completed, total })`. `completed / total` is your loading-bar fraction.
         -   **Concurrency cap** (default `4`) — caps in-flight fetches so the browser's per-host connection budget (typically 6) isn't fully consumed by audio.
         -   **Aggregated failures** — the promise rejects with `PreloadError` only after every item has settled, exposing `.failures: { name, cause }[]`. A single broken asset doesn't short-circuit the rest of the screen.
         -   **Cancellable** via `options.signal` — pending items aren't started, in-flight fetches receive the abort.
         
         Item shape mirrors `loadSound` one-for-one (`{ name, url, options? }`), so existing manifests can be passed through without massaging the data first.
         
         Documented in the "Loading sounds" guide.
         
     
15.  ## [v1.5.0](#v1.5.0)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.5.0)
     
     minor 1 change
     
     -   [#22](https://github.com/schmooky/zvuk/pull/22) [`cfb8ade`](https://github.com/schmooky/zvuk/commit/cfb8ade7ac075ed81a924ce1ccdc20a1cc393536) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Add `loopCrossfade` play option for click-free music loops.
         
         ```ts
         engine.sound("music-bed").play({
           loop: true,
           loopStart: 0.04,
           loopEnd: 31.96,
           loopCrossfade: 0.05, // 50 ms equal-power overlap at the loop boundary
         });
         ```
         
         AudioBufferSourceNode's native loop is a hard cut from `loopEnd` back to `loopStart`. If those points don't land on a zero crossing, every loop iteration produces an audible click — the kind of thing a sample editor would normally have you fix at edit time. `loopCrossfade` does it at runtime instead: zvuk spawns a parallel buffer source one crossfade-window before each boundary and equal-power-ramps between them.
         
         **Off by default.** Existing `loop: true` voices keep using AudioBufferSourceNode's native single-source loop — no behaviour or cost change unless you opt in. When opted in:
         
         -   Each loop iteration costs one extra `AudioBufferSourceNode` + `GainNode`. With default Web Audio dispatch this is well under 1% CPU per voice on commodity hardware.
         -   Silently falls back to native loop if `loop` is false, or if the loop region is shorter than 2× the crossfade window.
         -   Works alongside everything else on Voice — `pause`/`resume` re-enters a fresh chain, `setPlaybackRate` fans out across every live segment, `stop()` tears the chain down with the usual click-free fade.
         
         Documented on the Voice concept page.
         
     
16.  ## [v1.4.0](#v1.4.0)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.4.0)
     
     minor 1 change
     
     -   [#20](https://github.com/schmooky/zvuk/pull/20) [`419b817`](https://github.com/schmooky/zvuk/commit/419b817b018566257f88575cd27a13899c7446fb) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Three small cleanups surfaced in an audit pass.
         
         -   **`Filter.bypassed` actually bypasses now.** Previously, toggling `bypassed = true` set the biquad's frequency to 22050 Hz — which still ran the filter and let its delay-line state bleed into the dry signal. Bypass now mirrors `Compressor.bypassed`: a real graph rewire that detaches the biquad and connects `input → output` directly. Two unused `GainNode`s (`bypassPath`, `direct`) were also removed. Public shape is unchanged: `Filter` still implements `FxInsert` with `input` / `output` / `bypassed` / `dispose`, but `input !== output` now (separate gain nodes spliced around the biquad).
         -   **`'quietest'` voice-stealing logs an honest fallback warning.** The strategy advertised in `ConcurrencyConfig['steal']` was silently behaving like `'oldest'` because Voice doesn't expose a level meter yet. It still falls back, but now logs a one-shot `console.warn` explaining that real metering ships in a follow-up release. Use `'lowest-priority'` if you need explicit control today; `'quietest'` becomes a no-warn, real implementation when per-voice meters land.
         -   **`BankNotLoadedError` removed.** The class was exported from the public API but never thrown — the CLI's generated `loadBank()` is just a loop over `engine.loadSound`, so a dedicated bank error wasn't doing any work. If you were `import`\-ing it, you can drop the import.
         
     
17.  ## [v1.3.0](#v1.3.0)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.3.0)
     
     minor 1 change
     
     -   [#18](https://github.com/schmooky/zvuk/pull/18) [`c672522`](https://github.com/schmooky/zvuk/commit/c672522052e5a7b4a02a5e001e7dd9ac081d21a3) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Add `createEngine({ resolveAsset })` — a generic hook for adopting buffers from an external asset system (Pixi `Assets.cache`, IndexedDB, manifest, custom loader) instead of (or alongside) zvuk's URL fetcher. Plus a "Asset resolution" guide with full recipes.
         
         ### Why
         
         Most apps already have an asset system. Forcing zvuk to also fetch and decode the same audio file means double the download, double the RAM, and weird race conditions on the loading screen. The new resolver hook lets you point zvuk at whatever you already use, without zvuk depending on any of it.
         
         ### Shape
         
         ```ts
         import { createEngine, type AssetResolver } from "@schmooky/zvuk";
         
         const resolveAsset: AssetResolver = ({ name, url, signal }) => {
           // Return one of:
           //   AudioBuffer    — used as-is, no decode
           //   ArrayBuffer    — decoded via the engine's AudioContext
           //   string         — treated as a URL, fetched + decoded normally
           //   undefined/null — explicit miss; falls through to the URL list
         };
         
         const engine = createEngine({ buses: { sfx: {} }, resolveAsset });
         ```
         
         The resolver runs before any fetch on every `loadSound` / `loadSprite` call. Returning `undefined`/`null` falls through to the URL list passed to `loadSound`, so resolvers can mix cached and uncached sounds without branching at the call site.
         
         ### Recipes covered in the guide
         
         -   **Pixi v8 + assetpack** — pull buffers straight out of `Assets.cache`, so the existing Pixi loading-screen progress bar drives audio downloads too. (A real example app will ship separately with slotplate.)
         -   **IndexedDB persistent cache** — fetch the first time, hydrate from the DB on returning users. Useful for slot machines and kiosk apps that load the same audio set repeatedly.
         -   **In-memory `Map` cache** — full control over eviction, useful for service-worker / build-time-inlined buffers.
         -   **Manifest-driven URLs** — ship one JSON mapping logical names to hash-busted URLs.
         
         ### Scope
         
         Applies to `loadSound` and (transitively) `loadSprite`. `loadStream` is HTMLAudioElement-backed and doesn't decode buffers, so it stays on direct URL consumption — covered by a pitfall callout in the guide.
         
     
18.  ## [v1.2.0](#v1.2.0)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.2.0)
     
     minor 1 change
     
     -   [#16](https://github.com/schmooky/zvuk/pull/16) [`c63f9f5`](https://github.com/schmooky/zvuk/commit/c63f9f5e7d66481fb3d0c28db92781a8b055c601) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Add opt-in `tickSource` injection so the scheduler can dispatch JS callbacks from a host's existing render loop (Pixi `app.ticker`, GSAP `gsap.ticker`, custom rAF) instead of `setTimeout`. Also expose the existing visibility-driven AudioContext suspend as a configurable `autoPauseOnHidden` flag, and add a "Runtime timing" guide that documents the full timing model.
         
         ### Why
         
         `engine.scheduleAt(audioTime, fn)` and voice region timers previously used `setTimeout` exclusively. Browsers throttle `setTimeout` to ~1 Hz on hidden tabs, so callbacks scheduled to fire while the tab is hidden land late. Audio playback itself is unaffected — Web Audio runs on its own thread and zvuk stamps fade ramps and source starts directly with audio time — but the JS-side confirmation callbacks lag.
         
         Spinning up a parallel `requestAnimationFrame` loop inside the library would be the wrong fix: it doesn't help on hidden tabs (rAF pauses entirely there, worse than `setTimeout`'s 1 Hz throttle), and it burns frames in hosts that already have a render loop. Better to let consumers wire zvuk into the loop they already run.
         
         ### Ticker injection
         
         ```ts
         import { Application } from "pixi.js";
         import { createEngine, type TickSource } from "@schmooky/zvuk";
         
         const app = new Application();
         await app.init({
           /* ... */
         });
         
         const tickSource: TickSource = {
           subscribe(handler) {
             app.ticker.add(handler);
             return () => app.ticker.remove(handler);
           },
         };
         
         const engine = createEngine({ buses: { sfx: {} }, tickSource });
         ```
         
         `TickSource` is a minimal `subscribe(handler) → unsubscribe` shape — anything you can `add(handler)` and later `remove(handler)` from is a valid source. The scheduler subscribes lazily (only while there are pending tasks) so a 60 Hz host loop isn't waking it 60 times a second to do nothing. Without a `tickSource`, the scheduler keeps using `setTimeout`.
         
         ### `autoPauseOnHidden`
         
         The engine has always suspended the AudioContext on `visibilitychange === 'hidden'` and resumed on return — primarily as the iOS Safari reliability workaround for suspension-on-blur. That behaviour is now exposed as `createEngine({ autoPauseOnHidden: false })` for music players and background-audio apps that want playback to continue across tab switches. Default remains `true`, so existing code is unaffected.
         
         ### Docs
         
         New `/guides/runtime-timing/` page covers the JS-vs-audio timing split, why we don't run an internal rAF, the Pixi / GSAP / custom-rAF recipes, and how to pick a strategy per use case.
         
     
19.  ## [v1.1.0](#v1.1.0)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.1.0)
     
     minor 1 change
     
     -   [#14](https://github.com/schmooky/zvuk/pull/14) [`6587a92`](https://github.com/schmooky/zvuk/commit/6587a9252da2332ea024ebc07a53e82c520a10f0) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Apply a short click-free fade-out before `voice.stop()` actually cuts the source node, eliminating the digital click that fires when Web Audio stops a buffer mid-waveform on a non-zero crossing.
         
         `Voice.stop()` previously called `source.stop()` directly, which on the audio thread translates to "discontinue this sample stream right now." If the waveform happened to be at e.g. 0.7 amplitude when the stop landed, the abrupt jump to zero produces a broadband click — most audible on bass-heavy material, looped sustains, and any voice cut by stealing or a region timer.
         
         The new path schedules a tiny linear gain ramp to 0 (default 8 ms) on the voice's gain stage, then schedules `source.stop(stopAt)` so the source actually stops once the ramp lands. The voice's `.ended` promise resolves after the ramp completes; the engine's voice tracking sees the same termination it always did. Re-entrant `stop()` calls during an in-progress fade are no-ops — the first stop wins.
         
         ### Configuration
         
         -   **Engine default:** `createEngine({ voice: { stopFade: 0.008 } })`. Set to `0` to disable globally and restore the old hard-stop behaviour.
         -   **Per-call override:** `voice.stop({ fade: 0.05 })` for a longer tail, or `voice.stop({ fade: 0 })` for an immediate hard cut (sample-accurate timing, intentional staccato).
         
         The same fade applies to all stop paths: explicit `stop()`, `AbortSignal` abort, region-timer expiry, and concurrency-driven voice stealing.
         
         `pause()` is intentionally untouched in this release — the maintainer is reworking pause semantics in a follow-up. Pause/resume continues to do a hard-stop on the source node as before.
         
     
20.  ## [v1.0.1](#v1.0.1)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.0.1)
     
     patch 1 change
     
     -   [#12](https://github.com/schmooky/zvuk/pull/12) [`40ea9af`](https://github.com/schmooky/zvuk/commit/40ea9af1b1195f43c2b2413754f853e4995fb8f4) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Fall through to the next URL on fetch / decode failure when loading an audio asset with a fallback list (codec ladder).
         
         `engine.loadSound('coin', ['coin.webm', 'coin.m4a'])` previously selected one URL upfront via `pickSource()` and threw `DecodeError` immediately if that single URL 404'd, hit a network error, or failed to decode — even if the other URL would have worked. The codec ladder only protected against codec capability, not transport failures, so a stale CDN entry or under-reported `canPlayType` could brick a sound that had a perfectly good fallback sitting next to it in the array.
         
         `Decoder` now exposes `loadFirst(urls, opts)` which walks the list in order (codecs the browser claims it can play float to the front via the new `pickSourceOrder()`), and falls through on per-URL fetch/decode failures. The first URL that successfully fetches AND decodes wins. `AbortError` from `opts.signal` is fatal and propagates verbatim — once the caller pulled the plug we don't keep trying. A cache fast-path scans every URL in the list before any fetch, so a previously-resolved fallback short-circuits without re-hitting the network.
         
         When every URL fails, a new `AggregateDecodeError` is thrown with per-URL causes attached on `attempts`. It's a subclass of `DecodeError`, so existing `catch (e instanceof DecodeError)` paths still fire. Single-URL failures rethrow the underlying `DecodeError` verbatim — no behavioural change for callers that don't pass an array.
         
     
21.  ## [v1.0.0](#v1.0.0)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v1.0.0)
     
     major 1 change
     
     -   [#9](https://github.com/schmooky/zvuk/pull/9) [`b5a82cb`](https://github.com/schmooky/zvuk/commit/b5a82cbc6c289e7df1c85f471ecdc8c22e76ee27) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Normalize all time-valued options to **seconds** to match the Web Audio API. This is a breaking change.
         
         `PlayOptions`, `CompressorConfig`, `MasterLimiterConfig`, and `ReverbConfig` already used seconds. `FadeOptions`, `CrossfadeOptions`, `SnapshotOptions`, `DuckerConfig`, and `SidechainConfig` previously mixed milliseconds in. They now all take seconds, and a few field names changed to remove the `ms` suffix.
         
         ### Migration
         
         Old call → new call:
         
         Before
         
         After
         
         `voice.fade({ to, ms: 800 })`
         
         `voice.fade({ to, duration: 0.8 })`
         
         `stream.fade({ to, ms: 800 })`
         
         `stream.fade({ to, duration: 0.8 })`
         
         `voice.setPlaybackRate(r, { ms: 800 })`
         
         `voice.setPlaybackRate(r, { duration: 0.8 })`
         
         `bus.fadeTo(target, 800)`
         
         `bus.fadeTo(target, 0.8)`
         
         `engine.crossfade(from, to, { ms: 1500 })`
         
         `engine.crossfade(from, to, { duration: 1.5 })`
         
         `snapshot.apply({ fadeMs: 250 })`
         
         `snapshot.apply({ fade: 0.25 })`
         
         `new Ducker(ctx, src, { attack: 80, release: 400 })`
         
         `new Ducker(ctx, src, { attack: 0.08, release: 0.4 })`
         
         `sidechain: { attack: 80, release: 400 }`
         
         `sidechain: { attack: 0.08, release: 0.4 }`
         
         Mechanical fix in most call sites: divide every existing time value by 1000 and rename `ms` → `duration` (or `fadeMs` → `fade`).
         
         ### Why
         
         Web Audio is the underlying runtime, and it speaks seconds everywhere — `AudioContext.currentTime`, every `AudioParam` schedule call, `setValueAtTime`, `linearRampToValueAtTime`, `setValueCurveAtTime`. Mixing milliseconds in user-facing options forced a `* 1000` / `/ 1000` conversion at every boundary and made it easy to pass the wrong unit when copy-pasting across APIs (e.g. `Compressor` vs `Ducker` both have `attack`/`release`, but they were in different units). Aligning on seconds removes that whole class of bug.
         
     
22.  ## [v0.2.0](#v0.2.0)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v0.2.0)
     
     minor 1 change
     
     -   [#6](https://github.com/schmooky/zvuk/pull/6) [`490d822`](https://github.com/schmooky/zvuk/commit/490d822dd75fc343e9b544b802aad9d321049a87) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Handle iOS Safari `AudioContext` interruptions (phone calls, Siri, system audio takeovers).
         
         iOS Safari moves the `AudioContext` into a non-standard `'interrupted'` state during these events; `resume()` does not recover from it. Without explicit handling, voices hang silently until the page is reloaded.
         
         The `AudioContextHost` now subscribes to the context's `statechange` event:
         
         -   On transition into `'interrupted'`, a new `'interrupted'` engine state is emitted via `onStateChange`, so apps can render an "audio paused" indicator.
         -   When the OS releases the interruption (`'interrupted'` → `'suspended'`), the host auto-resumes after a 200 ms beat — the same idiom used for visibility-driven suspends.
         -   Once the context returns to `'running'`, the engine state goes back to `'live'`.
         
         **Breaking:** `EngineState` adds an `'interrupted'` arm. Code doing exhaustive `switch` on engine state needs an additional case (TypeScript will surface this).
         
     
     patch 3 changes
     
     -   [#7](https://github.com/schmooky/zvuk/pull/7) [`12b6e46`](https://github.com/schmooky/zvuk/commit/12b6e46e1c32713dfe9d560cb8d2b8416862e0bf) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Fix `Ducker.dispose()` leaking the analyser node into its source bus.
         
         The Ducker constructor wires `sourceBus.output → analyser` to read RMS off the source bus, but the previous `dispose()` only disconnected the analyser's _outgoing_ side. The source bus retained its outbound edge to the analyser, so the analyser (and its 1024-sample `Float32Array` envelope buffer) stayed alive for the entire lifetime of the bus — long-running games (slot machines, music apps) would accumulate one of these per Ducker swap.
         
         `dispose()` now stores the source bus on the instance and tears down the inbound edge first via `sourceBus.output.disconnect(this.analyser)`.
         
         Also extends the happy-dom Web Audio mock so `AudioNode.disconnect(target)` honours its target argument (it previously cleared all outgoing edges regardless), and adds `setTargetAtTime` to `FakeAudioParam` so Ducker's envelope follower can run under tests.
         
     -   [#8](https://github.com/schmooky/zvuk/pull/8) [`e29b8da`](https://github.com/schmooky/zvuk/commit/e29b8da94956f4efb1f2a48ba3db410064bf4d41) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Add unit-test coverage for `Snapshot` — capture / apply / mute restore / parameter behaviour / missing-bus tolerance / re-capture. No source changes; pins the existing behaviour of `engine.captureSnapshot()` and `Snapshot.apply()` so future edits don't silently regress the documented contract.
         
         Notable behaviours now pinned:
         
         -   `apply({ fadeMs: 0 })` snaps and resolves immediately; `apply({ fadeMs: N })` takes ≥ N ms.
         -   Missing buses on the engine are silently skipped (no throw) — preserved intentionally so snapshots can be ported across configs.
         -   Parameter values snap discretely even when `fadeMs > 0` — confirmed as the documented behaviour.
         -   `captureSnapshot()` returns a frozen copy of the state at capture time; later mutations to the engine don't affect prior snapshots.
         
     -   [#4](https://github.com/schmooky/zvuk/pull/4) [`48e4155`](https://github.com/schmooky/zvuk/commit/48e41554cc28ad5b5de64d8c3f3c4ae3e3c2e068) Thanks [@igaming-bulochka](https://github.com/igaming-bulochka)! - Fix `Voice` invoking the engine's internal `onEnded` callback twice on natural end of non-looped sources.
         
         The voice constructor wired the engine cleanup hook both through `bindSourceLifecycle` (sync, when `AudioBufferSourceNode.onended` fires) and through `this.ended.then(...)` (microtask, when `finish()` resolves the `ended` promise). `stop()`, abort signals, and the region timer all flowed through only the promise path, so natural end was the lone asymmetric case.
         
         Engine and Bus voice tracking use `Set.delete` so the duplicate was idempotent in practice — but it was a real correctness bug waiting to bite any callback that wasn't safe to call twice. All termination paths now fire exactly once via the promise.
         
     
23.  ## [v0.1.1](#v0.1.1)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v0.1.1)
     
     patch 1 change
     
     -   Fix `homepage` in package.json to point at the actual docs deploy (`https://zvuk.schmooky.dev`). v0.1.0 shipped with the wrong URL, so the npmjs.com page links to a non-existent domain. No code changes — this republishes the manifest with the correct metadata.
         
         Bundled doc fixes that came in alongside the rename (carried so the release notes describe what landed on the docs site):
         
         -   Navbar version pill is now dynamic — reads `package.json#version` via SITE.version, so it stays in sync with whatever changesets publishes.
         -   Navbar adds an npm icon-button linking to the package page.
         -   Footer npm link uses the scoped name (`@schmooky/zvuk`) instead of the rejected unscoped one.
         -   `/changelog/` page now sources from the root `CHANGELOG.md` (one card per published version, grouped by bump, with commit SHA and `@author` per bullet, deep-linkable `#v` anchors). Replaces the previous "list pending changesets" view, which only showed unreleased work.
         -   Hero badge and "What's in v…" heading also bind to SITE.version.
         -   docs/index "What's coming" list rewritten to point at the roadmap (it had been listing items that already shipped).
         
         [d252e30](https://github.com/schmooky/zvuk/commit/d252e30e1023c667b36aa107298fb77e43bfd9fe) [@igaming-bulochka](https://github.com/igaming-bulochka)
         
     
24.  ## [v0.1.0](#v0.1.0)
     
     [GitHub release →](https://github.com/schmooky/zvuk/releases/tag/v0.1.0)
     
     minor 2 changes
     
     -   Initial Sprint 1 release: lazy AudioContext runtime, Master + named Buses, Sound + Voice with abort signals, codec-aware multi-source loading (`['sfx.webm', 'sfx.m4a']`), iOS-Safari resume dance, sample-accurate scheduler. Docs site with landing, quickstart, Engine concept page, asset-format guide, and a live Mixer Dashboard demo running on real assets.
         
         [4ed17e1](https://github.com/schmooky/zvuk/commit/4ed17e1b2e645b2432d334ae865e0418592873a3) [@schmooky](https://github.com/schmooky)
         
     -   Sweeps the public roadmap (Tiers 1–4) end-to-end:
         
         -   Voice live control: `pause()`, `resume()`, `setPlaybackRate()`, exposed `voice.spatializer` for live `setPan` / `setPosition` while playing.
         -   Audio sprites — one buffer, many named regions, one fetch — via `engine.loadSprite()` + `engine.sprite('cascade').play('match-3')`.
         -   Stream source for long media via `engine.loadStream()` (HTMLAudioElement + MediaElementAudioSource), so multi-minute music tracks don't decode into RAM.
         -   Loudness normalization: `loadSound(..., { normalize: true })` runs an RMS pass at decode and applies makeup gain (with peak ceiling).
         -   Better error messages: BusNotFoundError / SoundNotFoundError now include a Levenshtein "did you mean?" suggestion when a close name exists.
         -   Realtime time-stretch via AudioWorklet: `ensureStretchWorklet(ctx)` + `createStretchWorkletNode(ctx)` for live tempo automation.
         -   Master limiter: `master.limiter` config (or `master.setLimiter(...)`) wires a fast-attack DynamicsCompressor on master out.
         -   Crossfade helper: `engine.crossfade('intro', 'main', { ms })` — equal-power by default, picks up sourceName off the outgoing voices.
         -   CLI: `npx zvuk transcode ` (ffmpeg ladder) and `npx zvuk gen bank.json` (typed sound-name module).
         -   Bench suite under `bench/` (vitest bench): voice spawn, decode + cache, fade drift.
         -   Docs: TypeDoc-driven `/api/`, Pagefind ⌘K search, auto-built `/changelog/`, and per-page OG cards via astro-og-canvas.
         -   Vanilla `examples/` (slot-machine, match-3, fps-footsteps) — no React/Vue.
         
         [3b6e032](https://github.com/schmooky/zvuk/commit/3b6e03287cdb71ccab29f179f972b7885f2af7cb) [@igaming-bulochka](https://github.com/igaming-bulochka)
         
     
     patch 1 change
     
     -   Docs polish + agent-readable index:
         
         -   `/llms.txt` route added (slotplate-style: H1, tagline, bulleted page index with descriptions). Linked from the top nav so both humans and crawlers hit it. Built from a single manifest in `docs/src/pages/llms.txt.ts` — keep it in sync when adding new docs pages.
         -   Roadmap page rewritten: every Tier 1–4 item moved into a green "Recently shipped in v0.0.2" callout.
         -   Concept and FX pages updated to describe the new APIs surfaced in the v0.0.2 sweep — sprite, stream, crossfade, master limiter, normalize, did-you-mean, pause/resume, setPlaybackRate, voice.spatializer live binding, realtime stretch worklet.
         -   Loading guide expanded with stream/sprite/normalize/typed-banks sections.
         -   SpatialPanner demo polished — pointer events + pointer capture (one path for mouse/touch/stylus), and now drives panning via the new `voice.spatializer.setPan()` ref instead of the v0 placeholder.
         -   New `CrossfadeDemo` React island (Engine concept page) running a real `engine.crossfade()` between two music beds (`/audio/music-{a,b}.mp3`).
         -   `examples/` (slot-machine, match-3, fps-footsteps) now use the casino SFX shipped under `docs/public/audio/` so the examples run with no extra setup. slot-machine streams the bed via `engine.loadStream`.
         -   Kenney's "Digital Audio" pack (CC0) curated in under `docs/public/audio/` (laser/powerUp/phaseJump/zap, ×2 each), with attribution in the root README, examples README, and docs footer.
         -   Build pipeline fixes: Search component switched to inline raw JS so Vite stops choking on `/pagefind/pagefind.js` at build time; OG route renamed `[slug].png.ts` → `[slug].ts` to fix the `*.png.png` filenames.
         -   Tests: stream, crossfade source-filter precision, voice cues paused/resumed, stretch worklet (mocked). 40 tests pass.
         
         [7fe93b9](https://github.com/schmooky/zvuk/commit/7fe93b9854160f9b4aaa4f51e5182a3157b0afc3) [@igaming-bulochka](https://github.com/igaming-bulochka)


---



# Bus · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/bus/

A named mix bucket with its own gain stage, FX chain, voice limit, and optional sidechain key.

Concept

# Bus

A named mix bucket with its own gain stage, FX chain, voice limit, and optional sidechain key.

Twenty sounds are playing and the player drags the "music" slider. Without somewhere to put that instruction, you are iterating live voices and hoping you catch the ones that started half a frame ago.

A bus is a named mix bucket. It owns a gain stage, an FX chain, a voice limit, and an optional sidechain key. Sounds are routed to it by name at play time, and the level you set on the bus applies to everything on it, including voices that start later. See [`Bus`](/api/Bus/) for the full reference.

## What does a bus actually do?

You declare it once in `createEngine` and then talk to it by name. Level changes and mutes ramp over 10 ms internally, so a slider drag doesn't click. `fadeTo` takes a curve when you want something longer than a slider.

A bus has three nodes: input → fxInput (FX chain head) → output. Voices target input; master takes output.

## API surface

The Bus interface ts

```
class Bus {
  readonly name: string;
  readonly input: GainNode;          // connect voices/sources here
  readonly output: GainNode;         // connected to master

  level: number;                     // setter ramps over 10ms (click-free)
  muted: boolean;                    // setter ramps over 10ms

  fadeTo(target: number, duration: number, curve?: FadeCurve): Promise;  // duration in seconds

  voiceCount: number;
  voices(): readonly Voice[];

  concurrency: ConcurrencyConfig | null;
  setConcurrency(c: ConcurrencyConfig | null): void;

  addFx(fx: FxInsert): void;
  removeFx(fx: FxInsert): void;
  fx(): readonly FxInsert[];

  meter(): { rms: number; peak: number };  // live amplitude readout (lazy analyser tap)

  send(target: Bus, options?: { amount?: number; post?: boolean }): Send;
  removeSend(send: Send): void;
  sends(): readonly Send[];

  solo(on?: boolean): void;                // engine coordinates "any soloed → mute the rest"
  unsolo(): void;
  readonly soloed: boolean;
}
```

## Live demo

Slam the slider. No click. The fade buttons run the same gain through a curve.

Use your own sound

music bus\-inf dB

bus outputidle

music.level0.60

write output.gain.value directly (no 10 ms ramp)

fadeTo(1, 0.6)fadeTo(0.1, 0.8)fadeTo(0, 1.2)stop voice

Unlock & start loop

## Recipes

### Direct level vs. fadeTo

ts ts

```
// Smooth fade — won't pop because of the 10ms internal ramp on direct writes,
// or use fadeTo() with a curve for longer transitions.
engine.bus('music').level = 0.5;
await engine.bus('music').fadeTo(0, 1.2, 'equal-power');
```

### Enumerate active voices on a bus

ts ts

```
const bus = engine.bus('sfx');
console.log('voices on this bus:', bus.voiceCount);
for (const v of bus.voices()) v.fade({ to: 0, duration: 0.2 });
```

### Live VU meter via `bus.meter()`

Returns `{ rms, peak }` as linear values in `[0..1]`. The first call attaches a passive AnalyserNode tap. Nothing is allocated until you read, and the audio path is unchanged. The [`rhythm-metronome`](https://github.com/schmooky/zvuk/tree/main/examples/rhythm-metronome) example drives a VU bar and a per-voice peak meter from it.

ts ts

```
// Drive a VU bar from bus.meter() inside your render loop.
function tick() {
  const m = engine.bus('music').meter();
  vuBar.style.width = (m.rms * 200) + '%';
  peakDot.style.left = (m.peak * 100) + '%';
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

// First call lazily attaches an AnalyserNode as a sibling of bus.output —
// no cost until you read. Subsequent calls reuse the same analyser.
```

### Sends: route a copy of one bus into another

A send routes a configurable share of one bus's signal into another, instead of inserting an effect directly on the source. It is the Wwise primitive for shared reverb. "Send 30 % of music to a verb-only bus" is two lines, and the amount is adjustable while audio is playing.

ts ts

```
// Send 30% of music to a dedicated reverb bus.
const verbSend = engine.bus('music').send(engine.bus('reverb'), { amount: 0.3 });

// Adjust live; the setter ramps over 10ms to avoid clicks.
verbSend.amount = 0.5;
await verbSend.fadeTo(0, 1.2);   // smooth fade-out

// Remove the send entirely.
verbSend.dispose();
// or
engine.bus('music').removeSend(verbSend);

// Pre-fader / pre-FX tap (rare — useful for monitor sends that should
// hear the dry signal regardless of how the source bus is faded).
engine.bus('music').send(engine.bus('monitor'), { post: false });
```

### Solo: A/B one bus without disturbing the rest

The engine coordinates the global rule. While any bus is in the solo set, every non-soloed bus is muted through the same 10 ms ramp, and when the set drains, every bus is restored. Solo is tracked separately from `muted`, so un-soloing returns each bus to whatever the user had set.

ts ts

```
// Solo this bus — every other bus is muted while the solo set is non-empty.
engine.bus('voice').solo();
engine.bus('music').solo();      // additive — both still audible

engine.bus('voice').unsolo();    // music is still soloed; everyone else still muted
engine.bus('music').unsolo();    // solo set drains — every bus restored

// Solo state is independent of muted: un-soloing returns each bus to its
// own .muted setting, not unconditionally to "audible".
```

### Bus groups: address several buses at once

A [`BusGroup`](/api/BusGroup/) is a logical handle rather than an audio node. It applies `level`, `fadeTo`, `muted` and `solo` to every member in parallel. Reach for it when several buses always move together. Combat is usually weapons plus enemies plus environment; a voice group is usually dialogue plus effort sounds.

ts ts

```
// Group several buses so a single call addresses all of them.
const combat = engine.busGroup('combat', [
  engine.bus('weapons'),
  engine.bus('enemies'),
  engine.bus('environment'),
]);

combat.level = 0.5;              // sets every member's level
await combat.fadeTo(0, 0.8);     // fades every member in parallel
combat.muted = true;             // mutes the whole group
combat.solo();                   // solos every member at once

// Look up later by name.
engine.busGroup('combat').level = 1;
```

## Pitfalls

Don't write to `output.gain.value` directly.

The Bus class wraps that with a 10 ms ramp. Bypassing it produces audible clicks in Chrome and Firefox.

Don't share an FxInsert between buses.

Each FX node has its own internal connections. Construct one per bus, dispose when removed.

## Related

-   [Concurrency](/concepts/concurrency/) sets the voice-limit policy on a bus.
-   [Mixer](/concepts/mixer/) is the wider bus graph this sits in.
-   [Ducking](/guides/ducking/) sidechains one bus from another.


---



# Concurrency · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/concurrency/

Voice limits with a steal strategy. Keeps polyphony bounded, and decides which voice dies when the limit is hit.

Concept

# Concurrency

Voice limits with a steal strategy. Keeps polyphony bounded, and decides which voice dies when the limit is hit.

Hold a button that fires a 50 ms click every frame. Thirty seconds later you have eighteen hundred live voices, a stalled audio thread, and a fan at full tilt. Nothing in the Web Audio API stops you.

Concurrency is a per-bus cap on how many voices may sound at once, together with a rule for what happens when a new voice arrives at a full bus. That rule is the _steal strategy_. It picks an existing voice to end so the new one can play, and it runs synchronously, before `play()` returns.

## How many voices can play at once?

As many as you allow. The cap is declared per bus, so a UI bus can hold four while an SFX bus holds thirty-two. A bus with no `concurrency` block is unbounded, which is the right default for music and the wrong one for anything a player can spam.

Slots are bounded. When full, the steal strategy picks a victim, synchronously, before play() returns.

## Strategies

-   **oldest** kills the voice that started first. This is the default, and the right call for SFX rain.
-   **lowest-priority** kills the voice with the smallest `priority`. Use it when you have a hierarchy: a hero attack outranks a footstep, which outranks an ambience tick.
-   **quietest** kills the least audible voice. It ranks candidates by an existing meter tap where one exists and by the voice's own gain otherwise, so it costs nothing per candidate.
-   **none** rejects the new voice. The returned [`Voice`](/api/Voice/) is already ended.

## API

At construction ts

```
createEngine({
  buses: {
    sfx: { concurrency: { max: 32, steal: 'oldest' } },
    voice: { concurrency: { max: 1, steal: 'lowest-priority' } },
  },
});
```

Live tuning ts

```
engine.bus('sfx').setConcurrency({ max: 8, steal: 'lowest-priority' });
```

## Live demo

Change `max` and `steal`, then mash "Fire voice". Slots fill, and once you pass the cap you can watch which voice the strategy picks.

Use your own sound

sfx busidle

max4steal strategyoldestlowest-priorityquietestnone (reject)

active: 0 / 4spawned: 0

fire voicestop all

Unlock & load

## Recipes

### Protect important voices

ts ts

```
// Hero hit — protected from stealing.
engine.sound('player-hit').play({ priority: 10 });

// Footstep — cheap, expendable.
engine.sound('footstep').play({ priority: 0 });
```

With `steal: 'lowest-priority'`, the player-hit voice survives until it ends naturally or another priority-10 voice arrives.

## Pitfalls

Don't use `steal: 'none'` for SFX.

Rejecting voices makes a player notice missing sounds in exactly the dense moments where they are paying attention. Stealing one is far less obvious. Reserve `'none'` for a voice or alarm bus, where you want the announcement that is already playing to finish.

Don't set max too low for sprites.

A cascading match-3 can fire twelve or more one-shots in a single frame. At `max: 4` that sounds chopped. Profile real gameplay before clamping.

## Related

-   [Voice](/concepts/voice/) carries the `priority` you set on play.
-   [Bus](/concepts/bus/) is where the concurrency block lives.


---



# Engine · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/engine/

The root object. Owns the AudioContext, the bus graph, the scheduler, and every loaded sound.

Concept

# Engine

The root object. Owns the AudioContext, the bus graph, the scheduler, and every loaded sound.

Constructing an `AudioContext` at module load used to be normal. Then browsers made autoplay a permission, and now the context you built before the user touched anything starts suspended, stays suspended, and swallows every sound you play through it.

The engine is the root object and the only one you construct directly. Buses, sounds, voices, parameters and snapshots are all reached through it. It holds the `AudioContext`, the bus graph, the scheduler, and every loaded asset. The context is created on first use rather than in the constructor, so `createEngine()` is safe to call before any user interaction. See [`Engine`](/api/Engine/) for the full surface.

## How do I unlock audio on iOS Safari?

Call `await engine.unlock()` from inside a real user gesture: a click, a tap, a keydown. It is idempotent, and repeated calls return the same in-flight promise, so wiring it to several handlers is fine. Visibility and focus changes suspend and resume the context on their own afterwards.

## State machine

Six states, one terminal transition. `suspended` covers a tab that went to the background. `interrupted` is the iOS state for a phone call or Siri taking the audio session, which `resume()` cannot recover from on its own.

Engine lifecycle. unlock() is idempotent and returns the same in-flight promise.

## Signal flow

Each `play()` call attaches a voice to its bus's input node. Voices feed through the bus FX chain if there is one, out of the bus output, into master, and on to `ctx.destination`. Everything else in the library is a variation on this graph.

signal flow

## API surface

The Engine interface ts

```
interface Engine {
  readonly state: 'cold' | 'unlocking' | 'live' | 'interrupted' | 'closed';
  readonly now: number;
  readonly context: AudioContext;

  unlock(): Promise;
  close(): Promise;

  loadSound(name: string, url: string | readonly string[], options?: LoadSoundOptions): Promise;
  loadSprite(name: string, url, regions: SpriteMap, options?): Promise;
  loadStream(name: string, url: string | readonly string[], options?): StreamSound;
  hasSound(name: string): boolean;
  hasSprite(name: string): boolean;
  hasStream(name: string): boolean;

  sound(name: string): Sound;            // throws SoundNotFoundError ("did you mean…")
  sprite(name: string): Sprite;
  stream(name: string): StreamSound;
  bus(name: string): Bus;                // throws BusNotFoundError ("did you mean…")

  crossfade(from: string, to: string, opts?: CrossfadeOptions): Voice;
  scheduleAt(audioTime: number, fn: () => void): () => void;

  parameter(name: string, initial?: number): Parameter;
  captureSnapshot(name: string): Snapshot;

  activeVoices(): readonly Voice[];
  onStateChange(fn: (s: EngineState) => void): () => void;
}
```

## Live demo

The dashboard below runs three buses, six samples in `.webm`/`.m4a` pairs, and a voice counter that updates as voices start and end.

engine.state = cold

0 voicesPop out

music

mute

level: 0.80

sfx

mute

level: 1.00

ui

mute

level: 0.70

chimemusicbellsmusicdice-rollsfxdice-shakesfxgemuiheartui

Each sample is shipped as `.webm` (Opus) +`.m4a` (AAC). The engine picks whichever your browser supports.

Unlock audio & load samples

## Recipes

### Build at module load, unlock on click

ts ts

```
import { createEngine } from '@schmooky/zvuk';

const engine = createEngine({
  buses: {
    music: { level: 0.8 },
    sfx:   { level: 1.0 },
    voice: { level: 1.0 },
  },
  master: { headroom: -3 },
});
```

ts ts

```
await engine.unlock();          // call from a user gesture
engine.state;                    // 'cold' | 'unlocking' | 'live' | 'interrupted' | 'closed'

await engine.loadSound('coin', '/sfx/coin.webm', { bus: 'sfx' });
engine.sound('coin').play();

await engine.close();            // terminal — construct a new engine if needed
```

### Watch state transitions

ts ts

```
const off = engine.onStateChange((s) => {
  if (s === 'live') console.log('audio is live; ctx time:', engine.now);
});
// later: off();
```

### Audio-clock scheduling

`scheduleAt` dispatches a callback against the audio clock. It runs on the main thread, so the drift is bounded by one tick, a few milliseconds. It is not sample-accurate. When you need that, stamp the Web Audio parameter itself with the audio time and let the audio thread do the work: `source.start(t)`, or `gain.linearRampToValueAtTime(v, t)`.

ts ts

```
const beat = engine.now + 0.25;          // 250 ms ahead in audio time
engine.scheduleAt(beat, () => engine.sound('downbeat').play());
```

### Audio sprites: one buffer, many regions

Sprites suit low-latency one-shots that can share a buffer. Cascades, UI variants, dialogue chunks. One fetch, one decode, and voices bound to a named region of the result.

ts ts

```
// One buffer, three regions — the cascade SFX share a single fetch + decode.
await engine.loadSprite('cascade', '/sfx/cascade.webm', {
  small:  { start: 0,    duration: 0.2 },
  medium: { start: 0.25, duration: 0.4 },
  big:    { start: 0.7,  duration: 0.6 },
}, { bus: 'sfx' });

engine.sprite('cascade').play('medium', { volume: { jitter: 0.05 } });
```

### Stream long media instead of decoding it

Past about thirty seconds, prefer `loadStream` over `loadSound`. Decoded audio costs four bytes per sample per channel, so three minutes of stereo at 48 kHz is roughly 70 MB of resident memory. A stream reads progressively through an `HTMLAudioElement`, which is also the path iOS Safari is happiest with.

ts ts

```
// Multi-minute music — don't decode a 4-min track into RAM.
const music = engine.loadStream('intro', '/music/intro.m4a', { bus: 'music' });
await music.play({ loop: true, volume: 0.6 });
await music.fade({ to: 0, duration: 1.5 });
music.stop();
```

### Crossfade music tracks in one call

Equal-power by default, so perceived loudness stays flat across the swap instead of dipping in the middle. Outgoing voices are matched by `sourceName`.

ts ts

```
// 1.5-second equal-power crossfade between two pre-loaded music tracks.
await engine.loadSound('intro', '/music/intro.webm', { bus: 'music', normalize: true });
await engine.loadSound('main',  '/music/main.webm',  { bus: 'music', normalize: true });

engine.sound('intro').play({ loop: true });
// Later — at the boss reveal.
engine.crossfade('intro', 'main', { duration: 1.5, loop: true });
```

bus outputidle

rainbirdsong

1500 ms · equal-power · streamed

Crossfade to rain

`engine.loadStream(...)` keeps both beds out of RAM; two `stream.fade({ curve: 'equal-power' })` legs sum to constant power, so the swap doesn't dip in the middle.

Unlock & start rain

### "Did you mean…" lookups

A misspelled bus or sound name produces an error carrying the closest declared name, by Levenshtein distance. It is a small thing that saves an afternoon roughly once per project.

ts ts

```
// Typo? The error tells you what you meant.
try {
  engine.bus('sxf');
} catch (e) {
  // BusNotFoundError: Bus "sxf"; did you mean "sfx" is not configured. ...
}
```

### Tear down on route change

ts ts

```
// React example
useEffect(() => () => { void engine.close(); }, []);

// Vue example
onBeforeUnmount(() => { void engine.close(); });

// Plain SPA
router.beforeEach(async () => { await engine.close(); });
```

## Pitfalls

Don't construct the engine inside a click handler.

Construct it at module load. `createEngine` is cheap; only `unlock()` needs a user gesture.

Don't call play() before unlock.

On iOS Safari the AudioContext starts `suspended`. Sounds will be silently dropped. Always `await engine.unlock()` first.

Don't reuse a closed engine.

`close()` is terminal. Construct a fresh one if you need audio again.

## Related

-   [Mixer](/concepts/mixer/) is the bus graph rooted at the engine.
-   [Bus](/concepts/bus/) covers routing, level, mute and fade.
-   [Voice](/concepts/voice/) is what `play()` returns.


---



# Concepts · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/

The vocabulary you need to think in zvuk.

Reference

# Concepts

The vocabulary you need to think in zvuk.

These ten pages are the vocabulary. Read them in any order; they cross-link. If you would rather click than read, the [recipes](/recipes/) are short and each one runs.

[

/concepts/engine/

Engine

The root object. Lifecycle, unlock, scheduler.

](/concepts/engine/)[

/concepts/mixer/

Mixer

The named bus graph. Routing made declarative.

](/concepts/mixer/)[

/concepts/bus/

Bus

A mix bucket with level, fade, FX and a voice limit.

](/concepts/bus/)[

/concepts/sound/

Sound

A loaded sample. Spawns Voices on play.

](/concepts/sound/)[

/concepts/music/

Music

Stinger, loop, outro. Skip to the outro at the next loop boundary.

](/concepts/music/)[

/concepts/voice/

Voice

One playback instance. Cues, fades, abort.

](/concepts/voice/)[

/concepts/concurrency/

Concurrency

Voice limits and steal strategies.

](/concepts/concurrency/)[

/concepts/snapshot/

Snapshot

Capture the mix; crossfade between presets.

](/concepts/snapshot/)[

/concepts/parameter/

Parameter

A named knob. Bind it to anything.

](/concepts/parameter/)[

/concepts/spatializer/

Spatializer

Stereo pan or 3D position per voice.

](/concepts/spatializer/)


---



# Mixer · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/mixer/

The named bus graph that routes every voice from source to speakers.

Concept

# Mixer

The named bus graph that routes every voice from source to speakers.

Every project ships a volume slider. Most ship three. The interesting part is never the slider; it is having somewhere for it to point that survives the twentieth sound you add.

The mixer is the declarative routing model. It is a set of [named buses](/api/Bus/), each with a level, optional FX inserts, an optional voice cap, and an optional sidechain key. You name them at construction and the engine builds the audio graph. Bus outputs sum at the master, which applies headroom and sends to the destination.

## Why declare buses up front?

Because the alternative is deciding routing at every call site, and call sites multiply. With named buses, "duck the music while dialogue plays" is one rule written once, and it applies to dialogue lines that don't exist yet.

bus graph

Three buses, FX inserts, sidechain key (voice → music). Master sums and applies headroom.

## API surface

Declare buses up front ts

```
const engine = createEngine({
  buses: {
    music: { level: 0.8, concurrency: { max: 4 } },
    sfx:   { level: 1.0, concurrency: { max: 32, steal: 'oldest' } },
    voice: { level: 1.0, sidechain: { from: 'music', amount: 0.5, attack: 0.08, release: 0.4 } },
  },
  master: {
    headroom: -3,
    // Fast-attack soft limiter on master out — best-effort peak control for
    // when FX stack on busy mixes. Disable by omitting the field.
    limiter: { threshold: -1, ratio: 20, attack: 0.001, release: 0.05 },
  },
});
```

Talk to a bus by name ts

```
engine.bus('music').level = 0.5;
engine.bus('sfx').fadeTo(0, 0.8);
engine.bus('voice').muted = true;
```

## Live demo

Three buses, six samples, a live voice counter. Sliders and mute drive the running engine.

engine.state = cold

0 voicesPop out

music

mute

level: 0.80

sfx

mute

level: 1.00

ui

mute

level: 0.70

chimemusicbellsmusicdice-rollsfxdice-shakesfxgemuiheartui

Each sample is shipped as `.webm` (Opus) +`.m4a` (AAC). The engine picks whichever your browser supports.

Unlock audio & load samples

## Recipes

### Insert an FX chain on a bus

ts ts

```
import { Compressor, Reverb } from '@schmooky/zvuk';

const comp = new Compressor(engine.context, { threshold: -18, ratio: 4 });
const reverb = new Reverb(engine.context, { wet: 0.3, decay: { seconds: 1.4 } });

engine.bus('music').addFx(reverb);
engine.bus('sfx').addFx(comp);   // FX run between bus.input and bus.output
```

### Master limiter

Headroom is a static gain offset. The optional limiter is a fast-attack `DynamicsCompressor` at ratio 20 with a 1 ms attack, used as a soft limiter on the master output. It catches most of the transients headroom alone can't tame. It is not a brick wall: with a finite attack and no lookahead, peaks above the threshold still get through, so don't treat it as a guaranteed 0 dBFS ceiling.

ts ts

```
// The master limiter is configured at construction (see above). There is
// no public runtime accessor yet — to change or disable it, recreate the
// engine with a different master.limiter (or omit the field).
createEngine({ master: { limiter: { threshold: -0.5, ratio: 20 } } });
```

## Pitfalls

Don't add buses dynamically after createEngine.

Bus topology is declared once. If you need a temporary effect, route into an existing bus and bypass when not in use, or add a fresh FX insert.

Don't write directly to ctx.destination.

Going around the master skips headroom and breaks snapshots. Always route through a declared bus.

## Related

-   [Bus](/concepts/bus/) covers a single bus and its FX chain.
-   [Snapshot](/concepts/snapshot/) captures and crossfades the whole mix.
-   [Building your mix](/guides/mix/) is the practical version of this page.


---



# Music · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/music/

Stinger, loop, outro, with skip-to-outro at the natural loop boundary.

Concept

# Music

Stinger, loop, outro, with skip-to-outro at the natural loop boundary.

The player wins, you stop the combat track, and it cuts off half a bar into a phrase. Nobody can say what is wrong, but everyone can hear it.

A [`Music`](/api/Music/) source is a three-part asset. There is an optional intro, a mandatory loop, and an optional outro. The intro plays once, the loop body runs until told otherwise, and the outro fires at the next loop boundary so the track ends on the beat instead of wherever the stop happened to land. It is the shape Wwise and FMOD have used for interactive music for twenty years.

## API surface

The Music and MusicVoice interface ts

```
class Music {
  readonly name: string;
  readonly loopDuration: number;
  readonly hasIntro: boolean;
  readonly hasOutro: boolean;

  play(options?: { volume?: number; fadeIn?: number }): MusicVoice;
}

class MusicVoice {
  readonly currentPart: 'intro' | 'loop' | 'outro' | 'ended';
  readonly ended: Promise;

  fade(opts: { to: number; duration: number; curve?: FadeCurve }): Promise;
  stop(opts?: { fade?: number }): void;
  skipToOutro(opts?: { at?: 'loop-end' | 'now' }): void;
}
```

## Load a three-part asset

Each part takes the same shape as `loadSound`'s second argument, either a single URL or a codec ladder. All three go through the same decoder cache and the same [`resolveAsset`](/guides/asset-resolution/) hook, and they are fetched in parallel.

ts ts

```
// engine.loadMusic — three parts; codec ladders allowed per part.
await engine.loadMusic('boss-theme', {
  intro: ['/music/boss-intro.webm', '/music/boss-intro.m4a'],
  loop:  ['/music/boss-loop.webm',  '/music/boss-loop.m4a'],
  outro: ['/music/boss-outro.webm', '/music/boss-outro.m4a'],
}, { bus: 'music', loopCrossfade: 0.05 });
```

## Play it

ts ts

```
// Start the music. The intro plays once, then the loop runs forever.
const m = engine.music('boss-theme').play({ volume: 0.7, fadeIn: 0.2 });

// Later, when the user clears the room:
m.skipToOutro();    // → finishes current loop iteration, then plays outro,
                    // then resolves m.ended.

await m.ended;
```

## Why is my music cutting off mid-phrase?

Because `stop()` is a click-free cut, not a musical one. It fades over a few milliseconds and ends. When you want the track to finish a phrase, call `skipToOutro({ at: 'loop-end' })` and let the loop reach its boundary first.

ts ts

```
// Two ways to end the music.
//
// 'loop-end' (default) — wait for the current loop iteration to complete,
// then play the outro at the natural loop boundary. The musical answer.
m.skipToOutro({ at: 'loop-end' });

// 'now' — fade the loop out (~50 ms) and start the outro immediately.
// Less musical, more responsive — right call for "user pressed Stop."
m.skipToOutro({ at: 'now' });

// Hard cut, no outro.
m.stop();           // ~8 ms click-free fade
m.stop({ fade: 0 }); // immediate
m.stop({ fade: 1 }); // 1-second fade-out
```

## Outro is optional

Without an `outro`, `skipToOutro()` falls through to a clean stop, so calling code never has to branch on `music.hasOutro`.

ts ts

```
// Outro is optional. Loop-only assets work fine.
await engine.loadMusic('menu', { loop: '/music/menu.webm' }, { bus: 'music' });
const m = engine.music('menu').play({ fadeIn: 0.3 });
m.skipToOutro(); // no outro buffer → falls through to a clean fade-out
```

## Click-free loops

`loopCrossfade` on `loadMusic` behaves the same way as [`PlayOptions.loopCrossfade`](/concepts/voice/) on a regular voice. One crossfade window before each loop boundary it spawns a parallel buffer source and equal-power ramps between them. Off by default; set a non-zero value to opt in.

ts ts

```
// Click-free loop boundaries with loopCrossfade.
await engine.loadMusic('combat', {
  loop: '/music/combat-loop.webm',
}, { bus: 'music', loopCrossfade: 0.05 });
// Same trick as PlayOptions.loopCrossfade on a Voice — a parallel buffer
// source spawns one crossfade-window before each boundary and equal-power
// ramps between them. Off by default; set > 0 to opt in.
```

## Pitfalls

Don't use `Music` for short SFX.

Each `play()` spawns a fresh chain of buffer sources and a couple of timers. For one-shot clicks and stingers, [Sound](/concepts/sound/) or [Sprite](/concepts/sound/) is cheaper and the right shape.

Don't use `Music` for tracks over 30 s.

All three parts decode into RAM, at four bytes per sample per channel. For long tracks, use [`loadStream`](/guides/loading/), which routes through `HTMLAudioElement` and `MediaElementAudioSource` so memory stays flat.


---



# Parameter · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/parameter/

A named float you drive at runtime. Subscribers update immediately. Bind it to bus levels, FX values, anything.

Concept

# Parameter

A named float you drive at runtime. Subscribers update immediately. Bind it to bus levels, FX values, anything.

Tension rises. You want the music bed up, the ambience down, the low-pass on the drone opening, and the reverb wet going with it. Written by hand that is four setters that have to stay in step, plus a fifth the day someone adds another layer.

A [`Parameter`](/api/Parameter/) is a named float you set at runtime, and a set of bindings that map it onto other values. You set the parameter; everything bound to it updates through its own range and curve. It is the seam between an idea a designer has and the ten numbers that idea turns into.

## Mental model

One parameter feeds many bound targets. Each binding has its own range + curve.

## API surface

Parameter ts

```
engine.parameter(name, initial?): Parameter

class Parameter {
  readonly name: string;
  readonly value: number;
  set(v: number): void;
  subscribe(fn: (v: number) => void): () => void;
  bindTo(setter: (mapped: number) => void, opts?: {
    from?: number;        // default 0
    to?:   number;        // default 1
    curve?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'equal-power';
  }): () => void;
}
```

## Live demo

One slider driving a bus level and a derived value, each through its own range and curve.

Use your own sound

bus outputidle

parameter("intensity")0.30

bus("music").level\[0.3 → 1.0\]

0.600

reverb.wet (preview)\[0.85 → 1.15\]

1.000

Unlock & start

## Recipes

### Get / set

ts ts

```
const intensity = engine.parameter('intensity', 0.3);
intensity.set(0.7);                       // updates all bound targets
```

### Bind to one or more targets

ts ts

```
intensity.bindTo((v) => engine.bus('music').level = v, {
  from: 0.4, to: 1.0, curve: 'easeInOut',
});

intensity.bindTo((v) => filter.setFrequency(v), {
  from: 800, to: 12000, curve: 'easeIn',
});
```

### Subscribe for UI updates

ts ts

```
const off = intensity.subscribe((v) => console.log('intensity:', v));
// later: off();
```

## Pitfalls

Parameters aren't ramps.

A parameter is a discrete value. Subscribers turn it into a ramp on their own, usually by writing to a bus level, which has its own 10 ms ramp inside. When you need a guaranteed N-millisecond crossfade, use a [Snapshot](/concepts/snapshot/).

Don't `set` in a tight loop.

Each set runs every subscriber synchronously. Driving a parameter from `requestAnimationFrame` means 60 Hz, which is fine. Anything denser than that, throttle it.

## Related

-   [Snapshot](/concepts/snapshot/) handles discrete mix states, crossfaded.
-   [Bus](/concepts/bus/) is the most common bind target.


---



# Snapshot · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/snapshot/

Capture a mix state. Apply it later with a smooth crossfade. Your menu/gameplay/boss switcher in one call.

Concept

# Snapshot

Capture a mix state. Apply it later with a smooth crossfade. Your menu/gameplay/boss switcher in one call.

Getting a mix right takes an afternoon of nudging eight sliders. Getting back to it from the menu screen takes eight `fadeTo` calls that all have to start together and land together, and one of them will drift the day someone adds a ninth bus.

A [`Snapshot`](/api/Snapshot/) is an immutable record of bus levels, mutes and parameter values. You capture one when the mix sounds right and apply it later with a fade, in seconds, and the whole mix crossfades in one call.

## Mental model

Three named snapshots. apply() ramps every bus level (and parameter) in lockstep.

## API surface

Snapshot ts

```
engine.captureSnapshot(name): Snapshot
engine.snapshot(name, state): Snapshot
engine.blendSnapshots(a, b, t): void   // snap mix to lerp(a, b, t)

class Snapshot {
  readonly name: string;
  readonly state: SnapshotState;
  apply(options?: { fade?: number }): Promise;
  blendWith(other: Snapshot, t: number): void;
}
```

## Live demo

Three presets, 800 ms crossfade between mixes.

Use your own sound

music busidle

snapshotmenusnapshotgameplaysnapshotboss

music level: 0.80

sfx level: 0.30

Unlock & start

## Recipes

### Capture from the live engine

ts ts

```
// Set the mix to a known-good "menu" state, then capture it.
engine.bus('music').level = 0.8;
engine.bus('sfx').level   = 0.3;
const menu = engine.captureSnapshot('menu');
```

### Build from explicit state

ts ts

```
// Build a snapshot from explicit state — no need to mutate the live engine.
const boss = engine.snapshot('boss', {
  buses: {
    music: { level: 1.0, muted: false },
    sfx:   { level: 0.8, muted: false },
  },
  parameters: { intensity: 1.0 },
});
```

### Switch with crossfade

ts ts

```
await menu.apply({ fade: 0.25 });        // crossfade everything in 250ms
await boss.apply({ fade: 0.8 });
```

### Blend continuously between two snapshots

`apply` is a one-shot crossfade. When you want a mix that follows a knob instead of a switch, capture two snapshots and let a [Parameter](/concepts/parameter/) drive `blendSnapshots`. Game tension, distance to a boss room and time of day all read naturally that way.

ts ts

```
const calm   = engine.captureSnapshot('calm');
// ...switch the mix to its combat shape, then re-capture.
const combat = engine.captureSnapshot('combat');

// Snap the mix to lerp(calm, combat, t). Drive a Parameter to animate.
engine.blendSnapshots(calm, combat, 0.4);   // 40% of the way to combat

const tension = engine.parameter('tension', 0);
tension.subscribe((t) => engine.blendSnapshots(calm, combat, t));
tension.set(0.75);                          // per-frame friendly
```

Each call snaps the mix instantly, so calling it every frame is cheap. Every bus-level setter still rides its own 10 ms anti-click ramp underneath. Buses and parameters missing from either snapshot are skipped, and mute flips at `t = 0.5` rather than interpolating. The [snapshot-blend example](/examples/snapshot-blend/) wires a slider through a Parameter end to end.

## Pitfalls

Snapshots don't include voices.

They restore the _mix_, not what is playing on it. For a "boss music starts" moment, trigger the boss music yourself, then apply the snapshot.

Capturing twice does not snapshot motion.

A snapshot is a single moment. To animate a fade-in over time, drive a [Parameter](/concepts/parameter/) instead.

## Related

-   [Parameter](/concepts/parameter/) handles continuous values.
-   [Mixer](/concepts/mixer/) holds the bus state being captured.


---



# Sound · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/sound/

A loaded sample. Owns one decoded AudioBuffer and spawns Voices on play().

Concept

# Sound

A loaded sample. Owns one decoded AudioBuffer and spawns Voices on play().

Fire the same coin sound eight times in one cascade and you want eight independent playbacks, each with its own volume, pitch and stop time, from one download.

A [`Sound`](/api/Sound/) is the decoded, immutable representation of an audio asset. Every call to `sound.play()` spawns a fresh [Voice](/concepts/voice/) on the sound's default bus, or on one you name. The buffer is shared across all of them; the voices are not.

## API surface

Sound + PlayOptions ts

```
class Sound {
  readonly name: string;
  readonly duration: number;          // seconds

  play(options?: PlayOptions): Voice;
}

interface PlayOptions {
  volume?: number | { jitter?: number };
  pitch?:  number | { jitter?: number };
  loop?:   boolean;
  bus?:    string;
  priority?: number;
  signal?: AbortSignal;
  spatializer?: { pan?: number; position?: [number, number, number] };
}
```

## Live demo

Use your own sound

Play sound

Unlock & load

engine.sound("hit").play()

## Recipes

### Load + play

ts ts

```
const sword = await engine.loadSound('sword', '/sfx/sword.webm', { bus: 'sfx' });
console.log(sword.duration);   // seconds
```

### Codec ladder for cross-browser

ts ts

```
// Codec ladder — first decodable wins. Opus everywhere except old iOS, AAC there.
await engine.loadSound('coin', [
  '/sfx/coin.webm',
  '/sfx/coin.m4a',
], { bus: 'sfx' });
```

See the [asset-formats guide](/guides/asset-formats/) for the encoding pipeline.

### Spawn many voices from one sound

ts ts

```
// One Sound, many Voices — buffer is shared, each play() is independent.
const coin = engine.sound('coin');
for (let i = 0; i < 8; i++) coin.play({ pitch: { jitter: 0.05 } });
```

### Tie playback to an AbortSignal

ts ts

```
const ac = new AbortController();
const v = engine.sound('coin').play({ signal: ac.signal });
// later, e.g. on unmount:
ac.abort();
```

### Loudness-normalize on load

RMS-based normalization runs once per buffer at decode time. It takes the edge off a library where every asset was mastered by a different person at a different level, and it never touches the file on disk.

ts ts

```
// Pass { normalize: true } to apply RMS-target loudness at decode time.
// All normalized sounds will sit at the same perceived loudness.
await engine.loadSound('crowd', '/sfx/crowd.webm', {
  bus: 'sfx',
  normalize: true,
});

// Or tune the target — defaults are RMS 0.1 (~ -20 dBFS), peak ceiling 0.99.
await engine.loadSound('alert', '/sfx/alert.webm', {
  normalize: { targetRms: 0.15, peakCeiling: 0.95 },
});
```

### Audio sprites

[Engine](/concepts/engine/) has the full sprite story. Briefly, `loadSprite` shares the same fetch, decode and cache path as `loadSound`, then maps named regions onto the result.

ts ts

```
// One buffer, many regions — drop in a sprite when you'd otherwise load
// 5+ tiny one-shots that share a session/scene.
await engine.loadSprite('ui', '/sfx/ui-strip.webm', {
  click:   { start: 0,    duration: 0.04 },
  hover:   { start: 0.1,  duration: 0.05 },
  error:   { start: 0.2,  duration: 0.18 },
  success: { start: 0.5,  duration: 0.3 },
}, { bus: 'sfx' });

engine.sprite('ui').play('click');
```

## Pitfalls

Don't store the AudioBuffer yourself.

The Sound owns it. If you read raw bytes for visualization, copy them out and let the Sound stay the source of truth.

Don't await sound.play().

`play()` is synchronous and returns the Voice. Awaiting waits forever (it's not a Promise). Use `v.ended` to await completion.

## Related

-   [Voice](/concepts/voice/) is what `play()` returns.
-   [Loading sounds](/guides/loading/) covers bulk load patterns.
-   [Asset formats](/guides/asset-formats/) covers the codec ladder.


---



# Spatializer · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/spatializer/

Stereo pan or full 3D positional audio. PannerNode wrapped, HRTF on by default.

Concept

# Spatializer

Stereo pan or full 3D positional audio. PannerNode wrapped, HRTF on by default.

A footstep behind you and a footstep in front of you are the same sample. What tells them apart is where the engine puts it, and the browser will do that for you if you hand it a position.

A [`Spatializer`](/api/Spatializer/) sits between one voice and its bus, as either a `StereoPannerNode` for a flat left-right pan or a `PannerNode` in HRTF mode for a position in 3D. Pass `spatializer` to `play()` for a fixed placement. For a source that moves, hold the object and update it per frame.

## Mental model

2D pan is cheap (StereoPannerNode). 3D uses PannerNode in HRTF mode, one node per voice.

## API surface

Spatializer + SpatialOptions ts

```
interface SpatialOptions {
  pan?: number;                       // [-1, 1] — 2D
  position?: [number, number, number]; // x, y, z  — 3D
}

class Spatializer {
  setPan(pan: number): void;          // 2D only
  setPosition(x: number, y: number, z: number): void;  // 3D only
  connectInto(dest: AudioNode): AudioNode;
  dispose(): void;
}
```

## Live demo

Drag the puck to pan a looping sound. Best with headphones.

Use your own sound

sfx bus · L | Ridle

spatializer.pan0.00

L

R

Drag with mouse, touch, or stylus — pointer events + pointer capture handle all three. The Voice is held in a ref and panned via `voice.spatializer.setPan(next)`.

Unlock & start

## Recipes

### 2D pan from play()

ts ts

```
engine.sound('footstep').play({
  spatializer: { pan: -0.6 },              // [-1, 1] stereo
});
```

### 3D position from play()

ts ts

```
engine.sound('footstep').play({
  spatializer: { position: [x, y, z] },    // world-space coords
});
```

### Dynamic position — moving source

ts ts

```
import { Spatializer } from '@schmooky/zvuk';

const sp = new Spatializer(engine.context, { position: [0, 0, 0] });
// connectInto() wires the spatializer to the bus and RETURNS the node your
// source should feed into — route your source there, or you'll get silence.
const input = sp.connectInto(engine.bus('sfx').input);
mySourceNode.connect(input);
sp.setPosition(playerX, playerY, playerZ);    // each frame

// when done:
sp.dispose();
```

### Live binding from the spawned Voice

Spawning with `spatializer` stores the node on the returned voice, so there is nothing to re-attach and no second reference to keep in sync.

ts ts

```
// The spawned voice exposes its Spatializer for live steering.
const v = engine.sound('engine').play({
  loop: true,
  spatializer: { position: [0, 0, 0] },
});

requestAnimationFrame(function tick() {
  if (v.spatializer) v.spatializer.setPosition(player.x, 0, player.z);
  requestAnimationFrame(tick);
});

// 2D — same shape with setPan.
const swarm = engine.sound('bee').play({ spatializer: { pan: 0 } });
swarm.spatializer?.setPan(-0.6);
```

### 3D distance config

Four values, available both as construction options and as live setters. `refDistance` is the radius inside which nothing attenuates. `maxDistance` anchors the far end of the rolloff curve. `rolloffFactor` scales how hard distance bites. `distanceModel` picks the curve shape, one of `'inverse'`, `'linear'` or `'exponential'`.

ts ts

```
// 3D config exposed: tune the distance-attenuation curve to your scene.
engine.sound('engine').play({
  spatializer: {
    position: [10, 0, 0],
    refDistance: 5,            // full volume within 5 units of the listener
    maxDistance: 250,          // 'linear' model reaches zero here
    rolloffFactor: 1.5,        // steeper than the natural rolloff
    distanceModel: 'inverse',  // 'linear' | 'inverse' | 'exponential'
  },
});

// Live setters for moving sources / dynamic environments:
v.spatializer?.setRefDistance(8);
v.spatializer?.setMaxDistance(500);
v.spatializer?.setRolloffFactor(2);
v.spatializer?.setDistanceModel('linear');
```

### Occlusion — "behind a wall"

One knob from 0 to 1, driving an internal lowpass whose cutoff sweeps logarithmically from 22050 Hz down to about 500 Hz, plus a gain dip reaching -6 dB at full amount. That is roughly what a wall does to a sound. Bind it to a [Parameter](/concepts/parameter/) and one handle moves the whole room.

ts ts

```
// Occlusion — single 0..1 knob driving an internal lowpass + small gain dip.
// 0 = clear; 1 = behind a wall (cutoff sweeps to ~500 Hz, level drops -6 dB).
engine.sound('boss-roar').play({
  spatializer: { position: [50, 0, 0], occlusion: 0 },
});

// Drive it from a Parameter so a single knob can occlude every relevant voice.
const occlusion = engine.parameter('boss-occlusion', 0);
v.spatializer && occlusion.bindTo((amount) => v.spatializer!.setOcclusion(amount));
occlusion.set(0.7);          // boss is now mostly muffled

// Combine with distance for a "behind a wall, far away" feel — bind the
// same parameter to both setOcclusion() and a position update.
```

## Pitfalls

Don't 3D-spatialize music.

Music wants to feel wide, not located. HRTF on a stereo music bus is an audible downgrade. Pan it manually if you want a side bias.

Don't construct a Spatializer per frame.

HRTF nodes are cheap but not free. Hold one per emitter and call `setPosition` on it.

## Related

-   [Voice](/concepts/voice/) is what gets spatialized.
-   [Bus](/concepts/bus/) is what a spatializer routes into.


---



# Voice · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/concepts/voice/

One playback instance. What sound.play() returns.

Concept

# Voice

One playback instance. What sound.play() returns.

A sound is a file. A voice is one time you played it. The distinction only starts to matter when the eighth copy is still ringing and you need to stop the third.

A [`Voice`](/api/Voice/) is a single live playback. It owns its own gain stage and source node, emits lifecycle cues, can be faded or stopped at any point, and tears itself down when it ends. You never construct one; `sound.play()` hands it to you.

## API surface

The Voice interface ts

```
class Voice {
  readonly id: number;
  readonly priority: number;
  readonly bus: string | undefined;
  readonly sourceName: string | undefined; // name of the Sound that spawned it
  readonly startedAt: number;              // engine.now at spawn
  readonly playbackRate: number;           // current rate
  readonly isPaused: boolean;
  readonly spatializer: Spatializer | undefined;

  readonly ended: Promise;           // resolves on natural end, stop, or abort

  fade(opts: { to: number; duration: number; curve?: FadeCurve }): Promise;
  pause(): void;                           // captures offset
  resume(): void;                          // resumes from captured offset
  setPlaybackRate(rate: number, opts?: { duration?: number; curve?: FadeCurve }): void;
  stop(opts?: { fade?: number }): void;     // ~8 ms click-free fade by default
  cues(): AsyncIterableIterator<'started' | 'paused' | 'resumed' | 'ended'>;
  level(): { rms: number; peak: number };   // live amplitude readout (lazy analyser tap)
}
```

## Live demo

Hit the button repeatedly. Random pitch + volume jitter on every voice.

Use your own sound

sfx busidle

pitch jitter±0.08volume jitter±0.10

Hit me

0 voices spawned

Unlock & load

## Recipes

### Random pitch + volume jitter

ts ts

```
// Stacked SFX with variation so they don't sound robotic.
for (let i = 0; i < 6; i++) {
  engine.sound('hit').play({
    pitch:  { jitter: 0.08 },        // ±8% playback rate
    volume: { jitter: 0.05 },        // ±5% gain
  });
}
```

### Fade-in on start

`play({ fadeIn })` is the mirror of the click-free stop fade. The voice ramps from `0` up to `volume` across the window you give it. Reach for it on ambient layers that should drift in rather than appear.

ts ts

```
// "Drop in smoothly" — voice ramps from 0 → volume over fadeIn seconds.
// The dual of the click-free stop fade.
engine.sound('ambience').play({ loop: true, volume: 0.7, fadeIn: 0.5 });
```

### Async iterator for cues

ts ts

```
const v = engine.sound('intro').play();
for await (const cue of v.cues()) {
  if (cue === 'started') analytics.send('intro:start');
  if (cue === 'ended')   ui.advance();
}
```

### AbortSignal cancellation

ts ts

```
const ac = new AbortController();
const v = engine.sound('alert').play({ signal: ac.signal });

// Anywhere — close a modal, route change, etc.
ac.abort();
await v.ended;                       // resolves immediately
```

### Pause / resume on blur

`pause()` tears down the current source node and remembers the sample offset. `resume()` rebuilds the source and starts again from there. A looping voice round-trips through its loop region on the way.

ts ts

```
// Pause-on-blur: keep the voice alive across menu/modal transitions.
const v = engine.sound('intro').play({ loop: true });

window.addEventListener('blur', () => v.pause());
window.addEventListener('focus', () => v.resume());

// resume() picks up at the offset captured on pause().
```

### Loop crossfade (off by default)

AudioBufferSourceNode's native loop is a hard cut from `loopEnd` back to `loopStart`. When those points don't sit on a zero crossing you get a click every single loop. Setting `loopCrossfade` spawns a parallel buffer source one crossfade window before the boundary and equal-power ramps between them. It is the same trick a sample editor offers at edit time, done at runtime instead.

ts ts

```
// Off by default. Set loopCrossfade > 0 to splice an equal-power
// crossfade at the loop boundary — masks the click from non-zero-crossing
// loop regions without needing to re-edit the asset.
const v = engine.sound('music-bed').play({
  loop: true,
  loopStart: 0.04,        // skip a fade-in
  loopEnd: 31.96,         // and fade-out
  loopCrossfade: 0.05,    // 50 ms equal-power overlap
});

// Cost: one extra AudioBufferSourceNode + GainNode per loop iteration.
// Ignored when loop is false, or when the region is shorter than 2× the
// crossfade window (silent fallback to native hard-cut loop).
```

### Live playback-rate ramp

`setPlaybackRate` automates the underlying `playbackRate` AudioParam, so pitch and tempo move together. When you need one without the other, see [pitch and time-stretch](/fx/pitch/).

ts ts

```
// Slow-mo sting on a boss intro — ramp the rate over 800 ms.
const v = engine.sound('boss-stinger').play();
v.setPlaybackRate(0.6, { duration: 0.8, curve: 'easeOut' });
```

### Per-voice level readout

`voice.level()` returns `{ rms, peak }` as linear values in `[0..1]`. The first call lazily attaches an AnalyserNode to the voice's gain stage, so nothing is allocated until something reads. Use it for per-voice meters and clip indicators. The [`rhythm-metronome` example](https://github.com/schmooky/zvuk/tree/main/examples/rhythm-metronome) drives one.

ts ts

```
// Per-voice peak meter — useful for "show the loudest voice" UI,
// or for driving custom voice-stealing rules beyond the built-in strategies.
function tick() {
  for (const v of engine.activeVoices()) {
    const lv = v.level();
    if (lv.peak > 0.95) flashClipIndicator(v.id);
  }
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
```

### Live spatial control

The spatializer is exposed on the voice. Hold the reference and call `setPan` or `setPosition` from your render loop.

ts ts

```
// Hold the voice and steer the spatializer as the source moves.
const v = engine.sound('engine').play({
  loop: true,
  spatializer: { position: [0, 0, 0] },
});

requestAnimationFrame(function tick() {
  if (v.spatializer) v.spatializer.setPosition(player.x, 0, player.z);
  requestAnimationFrame(tick);
});
```

## Stop semantics

`source.stop()` cuts a buffer wherever it happens to be. If that lands off a zero crossing, the speaker gets a step function and you hear a click. `voice.stop()` ramps the gain stage down over about 8 ms first, which is short enough that nobody reads it as a fade and long enough that nobody hears the cut. Pass `{ fade: 0 }` for a sample-accurate hard cut, or set `voice.stopFade` on the engine to change the default. Every stop path goes through it: an explicit `stop()`, an `AbortSignal` abort, a region timer expiring, and a voice stolen by a concurrency cap.

## Pitfalls

Don't hold a Voice ref past `ended`.

Once `ended` resolves, the source/gain are disconnected. Calling `stop()` is a no-op; `fade()` resolves immediately.

`pause()` rebuilds the source on `resume()`.

The Web Audio API doesn't expose pause on a buffer source, so we stop + re-spawn at the captured offset. For very tight cue chains, prefer `fade({ to: 0 })` over `pause()`.

## Related

-   [Sound](/concepts/sound/) is what spawned this voice.
-   [Concurrency](/concepts/concurrency/) covers voice limits and stealing.
-   [Spatializer](/concepts/spatializer/) covers pan and 3D position.


---



# What is zvuk? · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/docs/

An audio engine for the web. Tiny, ESM, type-safe. Built from a real shipping slot-game stack.

Overview

# What is zvuk?

An audio engine for the web. Tiny, ESM, type-safe. Built from a real shipping slot-game stack.

**zvuk** is a Web Audio API library built around the vocabulary a game audio team already uses. There is an Engine you talk to, a Mixer made of named Buses, Sounds that spawn Voices, Snapshots that crossfade the whole mix, and Parameters that drive any value through a curve. Wwise, FMOD and Unreal all name these things the same way.

It is designed around the parts the Web Audio API leaves to you. Autoplay restrictions. iOS Safari's `AudioContext` lifecycle. Click-free parameter changes. Polyphony at scale. Disposing everything you constructed. Each of those is a named concern here, which is what keeps the code at the call site short.

## Conventions

**All time-valued options are in seconds**, matching the Web Audio API (`AudioContext.currentTime`, every `AudioParam` schedule). That includes fades (`voice.fade({ duration: 0.8 })`), crossfades (`engine.crossfade(a, b, { duration: 1.5 })`), snapshot applies (`snap.apply({ fade: 0.25 })`), and every dynamics processor's `attack`/`release`. No millisecond fields, no per-API conversions to remember.

## What's in v1.14.0

-   **Runtime:** lazy AudioContext, audio-clock scheduler, decode cache, codec ladder.
-   **Mixer:** Master with headroom + optional soft limiter, named Buses with click-free fades, FX chain inserts.
-   **Sources:** Sound + Voice with jitter, fades, pause / resume, live `setPlaybackRate`, abort signals, lifecycle promises; `createSound` for procedural buffers.
-   **Sprites:** one buffer, many named regions, one fetch. For cascades, UI variants and dialogue chunks.
-   **Streams:** `loadStream` for music tracks > 30s via `HTMLAudioElement` + `MediaElementAudioSource`.
-   **Crossfade:** `engine.crossfade(from, to, { duration })` for an equal-power music swap in one call.
-   **Concurrency:** per-bus voice limit with steal strategies (`oldest` / `lowest-priority` / `quietest` / `none`).
-   **FX:** Compressor, Reverb (synthetic IR or your own), Filter (six biquad types), Ducker (sidechain), pitch-preserving StretchProcessor (offline) plus a realtime AudioWorklet variant.
-   **Spatializer:** 2D StereoPanner + 3D HRTF PannerNode per voice, with `voice.spatializer` exposed for live `setPan` / `setPosition`.
-   **Loudness:** RMS-based normalization on load (`loadSound({ normalize: true })`) so sounds sit at uniform perceived loudness.
-   **Parameters & Snapshots:** named knobs with `bindTo` mapping; whole-mix capture and crossfade.
-   **DX:** errors carry a Levenshtein suggestion, so a mistyped bus name says `Did you mean "sfx"?`; a CLI with `zvuk transcode` and `zvuk gen bank.json`; an auto-built TypeDoc reference, Pagefind search, generated OG cards, and machine-readable [`llms.txt`](/llms.txt) plus [`llms-full.txt`](/llms-full.txt).

## What's next

The [roadmap](/roadmap/) lists sized, prioritised items and the [changelog](/changelog/) covers what has already landed. Pull requests are welcome; [CONTRIBUTING.md](https://github.com/schmooky/zvuk/blob/main/CONTRIBUTING.md) says what the gates are.

## Read next

-   [Quickstart](/docs/quickstart/) gets sound playing in two functions.
-   [The Engine](/concepts/engine/) covers lifecycle, unlock and the state machine.
-   [Asset formats](/guides/asset-formats/) is the encoding pipeline you should be using.


---



# Quickstart · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/docs/quickstart/

Two function calls and a user gesture.

Start here

# Quickstart

Two function calls and a user gesture.

## 1\. Install

bash bash

```
pnpm add @schmooky/zvuk
```

## 2\. Construct the engine

Declare your buses up front. `createEngine` does not touch the AudioContext. It records the shape of the mix and nothing else, so calling it at module load is safe.

ts ts

```
import { createEngine } from '@schmooky/zvuk';

export const engine = createEngine({
  buses: {
    music: { level: 0.8 },
    sfx:   { level: 1.0 },
    ui:    { level: 0.7 },
  },
  master: { headroom: -3 },  // dB
});
```

## 3\. Unlock from a user gesture

Browsers block audio until the user does something. Call `engine.unlock()` from a click, tap or keypress handler. It is idempotent, so calling it from several handlers is fine.

ts ts

```
document.querySelector('button')!.addEventListener('click', async () => {
  await engine.unlock();
  await engine.loadSound('coin', ['/sfx/coin.webm', '/sfx/coin.m4a'], { bus: 'sfx' });
  engine.sound('coin').play();
});
```

Pass an array of URLs to ship a codec ladder. The first one your browser can decode wins. [Asset formats](/guides/asset-formats/) explains why that matters more than it sounds.

## 4\. Play, fade, mix

Every time-valued option in zvuk is in **seconds**. Fades, crossfades, attack and release, snapshot applies, all of them. That matches the Web Audio API, so there is one convention to remember rather than two.

ts ts

```
const v = engine.sound('coin').play({
  volume: { jitter: 0.05 },
  pitch:  { jitter: 0.04 },
});

// Bus-level fade — click-free (duration in seconds)
engine.bus('music').fadeTo(0.1, 0.8);

// Voice-level fade with curve
await v.fade({ to: 0, duration: 0.8, curve: 'equal-power' });
await v.ended;
```

## 5\. Clean up

On an SPA route change or a game teardown, call `engine.close()`. Every voice stops, the AudioContext closes, and listeners detach. Close is terminal. Construct a new engine if you need audio again.

ts ts

```
await engine.close();
```

## Where to go next

-   [The Engine](/concepts/engine/) covers the full lifecycle, state machine and scheduler.
-   [Bus](/concepts/bus/) covers routing, concurrency and sidechain.
-   [Recipes](/recipes/) has six patterns you can click and copy.
-   [Asset formats](/guides/asset-formats/) walks through webm/Opus and m4a/AAC.


---



# Why another audio lib? · zvuk

zvuk v1.14.0 · MIT · https://zvuk.schmooky.dev/docs/why/

Howler exists. Tone.js exists. Raw Web Audio exists. Here is the gap zvuk fills.

Background

# Why another audio lib?

Howler exists. Tone.js exists. Raw Web Audio exists. Here is the gap zvuk fills.

## The problem

Game audio on the web has three failure modes.

1.  **Autoplay and iOS Safari.** Roughly half of the audio bug reports I have seen on shipped web games are some version of "no sound on iPhone".
2.  **Polyphony.** Spam a reel-stop sound thirty times and the second one cuts off the first, because the same `HTMLAudioElement` is being recycled underneath.
3.  **Mix discipline.** Ducking music under dialogue. Crossfading menu states. Reverb on ambience but not on UI. Web Audio gives you nodes. Nobody gives you the routing model.

## Should I use zvuk or Howler?

Use Howler if you want to play clips and stop them, you need the widest possible browser support, and your mix is a volume slider. It is battle-tested and it will outlive us all.

Use zvuk if your audio has structure. Named buses, ducking, snapshots, voice limits, per-voice control. That is the part Howler does not model, and the part you end up writing yourself.

 

zvuk

Howler

Tone.js

pixi-sound

Named mixer buses

yes

no

channels

no

Sidechain ducking

built in

no

build it

no

Mix snapshots

yes

no

no

no

Voice limits and stealing

per bus

no

no

no

Synthesis and transport

no

no

yes

no

Codec ladder

yes

yes

manual

yes

Renderer coupling

none

none

none

PixiJS

Use it if

your mix has structure

you just need clips

you are writing music

you already ship Pixi

## Should I use zvuk or Tone.js?

Tone is a music framework. It has a transport, a synthesis graph, and scheduling built around musical time. If you are sequencing notes or building an instrument, use Tone and don't look back. zvuk plays and mixes recorded assets. There is no oscillator in it.

## What zvuk adds

-   An [Engine](/concepts/engine/) with an explicit state machine, running `cold`, `unlocking`, `live`, `suspended`, `interrupted`, `closed`.
-   A [Mixer](/concepts/mixer/) declared in `createEngine` config, with named buses and click-free fades.
-   A [Voice](/concepts/voice/) per `play()` call, carrying abort signals, lifecycle promises and an async iterator of cues.
-   A codec ladder, so `['sfx.webm', 'sfx.m4a']` ships Opus to the world and AAC only to the devices that need it.
-   An iOS Safari resume sequence that has survived production slot games. On iOS 15 an immediate `resume()` after a visibility change failed often enough to be a bug report; a 200 ms delay stopped it.

## What zvuk is not

-   A synthesis framework. There is no oscillator graph and no transport.
-   A streaming music player. `Stream` wraps `