declare class ZvukError extends Error { constructor(message: string, options?: ErrorOptions); } declare class EngineClosedError extends ZvukError { constructor(); } declare class BusNotFoundError extends ZvukError { constructor(name: string, hint?: string | null); } declare class SoundNotFoundError extends ZvukError { constructor(name: string, hint?: string | null); } /** * 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. */ declare class DecodeError extends ZvukError { constructor(url: string, cause: unknown, message?: string); } interface DecodeAttempt { readonly url: string; readonly cause: unknown; } interface PreloadFailure { readonly name: string; readonly cause: unknown; } /** * 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. */ declare class PreloadError extends ZvukError { readonly failures: readonly PreloadFailure[]; constructor(failures: readonly PreloadFailure[]); } /** * 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. */ declare class AggregateDecodeError extends DecodeError { readonly attempts: readonly DecodeAttempt[]; constructor(urls: readonly string[], attempts: readonly DecodeAttempt[]); } /** * 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. */ interface FxInsert { readonly input: AudioNode; readonly output: AudioNode; bypassed: boolean; dispose(): void; } interface CompressorConfig { /** dB at which compression begins. Default -24. */ threshold?: number; /** dB range over which the curve smoothly transitions into compression. Default 30. */ knee?: number; /** Compression ratio. Default 12. */ ratio?: number; /** Attack time in seconds. Default 0.003. */ attack?: number; /** Release time in seconds. Default 0.25. */ release?: number; /** Make-up gain in dB applied after compression. Default 0. */ makeupGain?: number; } /** * 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). */ declare class Compressor implements FxInsert { readonly input: GainNode; readonly output: GainNode; private compressor; private makeup; private ctx; private _bypassed; constructor(ctx: AudioContext, config?: CompressorConfig); applyConfig(c: CompressorConfig): void; /** Live gain reduction in dB (read-only, negative when compressing). */ get reduction(): number; get bypassed(): boolean; set bypassed(v: boolean); dispose(): void; private wire; } type FadeCurve = 'linear' | 'equal-power' | 'easeIn' | 'easeOut' | 'easeInOut'; /** * 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. */ interface AudioLevel { rms: number; peak: number; } interface ConcurrencyConfig { /** Max simultaneous voices on this bus. */ max: number; /** Voice-stealing strategy when max is reached. Default: 'oldest'. */ steal?: 'oldest' | 'lowest-priority' | 'quietest' | 'none'; } interface SidechainConfig { /** Bus name to listen to. When that bus is loud, this bus is ducked. */ from: string; /** Amount of duck (0..1, where 1 = fully muted at peak source). */ amount: number; /** Attack in seconds. */ attack?: number; /** Release in seconds. */ release?: number; } interface BusConfig { level?: number; mute?: boolean; concurrency?: ConcurrencyConfig; sidechain?: SidechainConfig; } interface SendOptions { /** Send level (0..1). Default 1. */ amount?: number; /** * Tap the source bus's output (post-fader, post-FX) when `true`, * or input (pre-fader, pre-FX) when `false`. Default `true`, which is almost * always what you want. Pre-fader sends are mainly useful for * monitoring buses that should hear the dry signal regardless of how * the user has faded the source bus down. */ post?: boolean; } interface MasterLimiterConfig { /** Threshold in dB. Default -1 (just below 0 dBFS). */ threshold?: number; /** Compression ratio. Default 20, high enough to be limiter-like without being a brick wall. */ ratio?: number; /** Attack in seconds. Default 0.001, fast enough to catch the transient. */ attack?: number; /** Release in seconds. Default 0.05. */ release?: number; } interface MasterConfig { /** Headroom in dB applied to the master gain (negative). Default: 0. */ headroom?: number; /** Optional fast-attack soft limiter on the master output (best-effort peak control). */ limiter?: MasterLimiterConfig; } /** * 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). */ type ResolvedAsset = AudioBuffer | ArrayBuffer | string; interface ResolveAssetContext { /** The name passed to `engine.loadSound(name, ...)`. */ readonly name: string; /** The URL or URL list passed to `engine.loadSound(name, urls)`. */ readonly url: string | readonly string[]; /** Forwarded from `LoadSoundOptions.signal`. */ readonly signal?: AbortSignal; } /** * 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. */ type AssetResolver = (ctx: ResolveAssetContext) => ResolvedAsset | undefined | null | Promise; /** * 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. * * @example Pixi v8 * ```ts * const tickSource: TickSource = { * subscribe(handler) { * app.ticker.add(handler); * return () => app.ticker.remove(handler); * }, * }; * createEngine({ tickSource }); * ``` * * @example GSAP * ```ts * const tickSource: TickSource = { * subscribe(handler) { * gsap.ticker.add(handler); * return () => gsap.ticker.remove(handler); * }, * }; * ``` */ interface TickSource { /** Register a tick handler. Returns an unsubscribe function. */ subscribe(handler: () => void): () => void; } interface VoiceDefaults { /** * Default click-free fade-out duration applied by `voice.stop()`, in * seconds. Web Audio cuts source nodes mid-waveform, which produces a * digital click on non-zero crossings. A tiny linear ramp on the gain * stage before the source actually stops eliminates that. Default 0.008 * (8 ms): inaudible as fade, sufficient as click suppressor. * * Override per-call with `voice.stop({ fade: 0 })` for hard cuts (sample- * accurate timing, intentional staccato), or `{ fade: 0.05 }` for longer * tails. Set 0 here to opt the whole engine out of click-free behaviour. */ stopFade?: number; } interface EngineConfig { buses?: Record; master?: MasterConfig; voice?: VoiceDefaults; /** * Hint to the underlying `AudioContext` about the desired latency / * battery trade-off. Maps to `AudioContextOptions.latencyHint`: * * - `'interactive'` — the default Web Audio behaviour. Right call for * game audio, slot machines, anything where 60 fps reactive sound * matters more than power consumption. * - `'playback'` — longer buffers, lower CPU, higher latency. Right * call for music players, podcasting tools, anything where the user * isn't expecting input-to-sound to feel "instant." * - `'balanced'` — the browser picks. * - A number — explicit latency in seconds. Browsers honour it on a * best-effort basis. * * Default: undefined (the browser picks; usually `'interactive'`). */ latencyHint?: AudioContextLatencyCategory | number; /** * External ticker (e.g. Pixi `app.ticker`, GSAP `gsap.ticker`) that drives * scheduler dispatch. Without one, the scheduler uses `setTimeout`, * which browsers throttle to ~1 Hz on hidden tabs. Inject a host ticker * to align dispatch to your render loop and avoid spawning a parallel rAF * loop. See the "Runtime timing" guide for the full trade-off. */ tickSource?: TickSource; /** * Adopt buffers from an external asset system instead of (or in addition * to) zvuk's URL fetcher. Called once per `loadSound` / `loadSprite` call * before any fetch. Return: * * - `AudioBuffer` — used as-is. * - `ArrayBuffer` — decoded via the engine's AudioContext. * - `string` — treated as a URL; fetched and decoded normally. * - `undefined` / `null` — miss; falls through to the URL list passed to * `loadSound`. Resolvers can mix cached and uncached sounds without * branching at the call site. * * Useful for: Pixi `Assets.cache`, IndexedDB persistence, manifest-driven * loading, custom CDN proxies. See the "Asset resolution" guide for * complete recipes. */ resolveAsset?: AssetResolver; /** * When `true` (default), the engine listens for `visibilitychange` and * suspends the AudioContext while the tab/window is hidden, then resumes * automatically on return. This is also the iOS Safari workaround for * suspension-on-blur. * * Set to `false` if you want music to keep playing across tab switches: * e.g. background music players where a brief navigation away from the * page shouldn't pause playback. Audio continues running on the Web Audio * thread regardless of tab visibility. */ autoPauseOnHidden?: boolean; /** * 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. */ cache?: CacheConfig; } interface CacheConfig { /** Ceiling on decoded bytes held in the cache. Default 64 MiB. */ maxBytes?: number; /** Ceiling on cached entries, applied alongside `maxBytes`. Default 128. */ maxEntries?: number; } interface FadeOptions { to: number; /** Fade duration in seconds. */ duration: number; curve?: FadeCurve; } interface StopOptions { /** * Click-free fade-out duration (seconds) to apply before the source node * actually stops. Default: the engine's `voice.stopFade` (0.008 if not * configured). Pass `0` for an immediate hard cut. */ fade?: number; } interface VoiceJitter { /** * Center value the jitter varies around. Defaults to 1 (the neutral * volume / playback rate). Set it to combine a base with jitter, e.g. * `{ base: 1.5, jitter: 0.1 }` plays at 1.5 ± 0.1 per voice. */ base?: number; /** Maximum ± random deviation from `base`, drawn once per voice. */ jitter?: number; } /** * 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. */ type DistanceModel = 'linear' | 'inverse' | 'exponential'; interface SpatialOptions { /** [-1, 1] stereo pan (2D). Mutually exclusive with `position`. */ pan?: number; /** [x, y, z] world-space position (3D). Mutually exclusive with `pan`. */ position?: [number, number, number]; /** * Distance below which the source is at full volume. Default 1. * 3D only; ignored when `pan` is used. */ refDistance?: number; /** * Distance beyond which `'linear'` attenuation reaches zero. The * `'inverse'` and `'exponential'` models keep falling off but use this * as the curve's anchor. Default 10000. * 3D only; ignored when `pan` is used. */ maxDistance?: number; /** * How aggressively distance attenuates the sound. 1 is the natural * rolloff for the chosen `distanceModel`; higher values produce a * more dramatic falloff. Default 1. * 3D only; ignored when `pan` is used. */ rolloffFactor?: number; /** Distance-attenuation curve. Default `'inverse'`. 3D only. */ distanceModel?: DistanceModel; /** * 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 * `Parameter` if you want one knob to drive both. * 3D only; ignored when `pan` is used. */ occlusion?: number; } interface PlayOptions { /** Initial volume (0..1). Default 1. */ volume?: number | VoiceJitter; /** * 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 `volume: 0` * and immediately calling `voice.fade({ to: volume, duration: fadeIn })`. */ fadeIn?: number; /** Playback rate. 1 = source speed. Random jitter optional. */ pitch?: number | VoiceJitter; /** Loop the voice. Default false. */ loop?: boolean; /** Override the default bus for this voice. */ bus?: string; /** Voice priority. Higher survives stealing for longer. Default 0. */ priority?: number; /** AbortSignal. The voice stops when it aborts. */ signal?: AbortSignal; /** 2D pan or 3D position. Inserts a Spatializer between the voice and its bus. */ spatializer?: SpatialOptions; /** Offset into the buffer (seconds) to start at. Default 0. Used by Sprite. */ offset?: number; /** If set, voice auto-stops after this many seconds. Used by Sprite. */ duration?: number; /** When loop=true, start of the loop region (seconds). */ loopStart?: number; /** When loop=true, end of the loop region (seconds). */ loopEnd?: number; /** * Equal-power crossfade duration (seconds) at the loop boundary. When * `loop` is true and this is non-zero, zvuk spawns a parallel buffer * source at every loop point and ramps between them, masking the click * that AudioBufferSourceNode's native loop produces when the region * doesn't end on a zero crossing. * * Default `0`, meaning off, which leaves the native hard-cut loop. Cost is one extra * AudioBufferSourceNode + GainNode per loop iteration; with default * Web Audio dispatch this is well under 1% CPU per voice. * * Ignored if `loop` is false, or if the loop region is shorter than * twice the crossfade window (silent fallback to native loop). */ loopCrossfade?: number; } interface LoudnessOptions$1 { /** Target RMS (linear, 0..1). Default 0.1 (~ -20 dBFS). */ targetRms?: number; /** Hard ceiling for the resulting peak; gain is reduced to stay below it. Default 0.99. */ peakCeiling?: number; } interface LoadSoundOptions { /** Default bus for voices spawned by this sound. Default: first declared bus. */ bus?: string; /** AbortSignal for the fetch. */ signal?: AbortSignal; /** * 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 `true` for defaults or an options object to tune * the target. */ normalize?: boolean | LoudnessOptions$1; } /** * 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. */ interface MusicParts { intro?: string | readonly string[]; loop: string | readonly string[]; outro?: string | readonly string[]; } interface MusicLoadOptions extends LoadSoundOptions { /** * Equal-power crossfade (seconds) at the loop boundary. Mirrors * `PlayOptions.loopCrossfade`. Masks the click that would otherwise * fire if the loop region doesn't end on a zero crossing. Default `0`. */ loopCrossfade?: number; } interface MusicPlayOptions { /** Initial volume (0..1). Default 1. */ volume?: number; /** * Fade-in duration (seconds) applied to the music's gain stage at start. * Default 0 (instant). Convenience for "drop into the menu" style intros. */ fadeIn?: number; } interface SkipToOutroOptions { /** * - `'loop-end'` (default) — finish the current loop iteration, then play * the outro at the natural loop boundary. Musical, no click. * - `'now'` — fade out whatever's playing right now (~50 ms equal-power) * and start the outro immediately. Right call for "user pressed Stop" * where waiting feels unresponsive. */ at?: 'loop-end' | 'now'; } type MusicState = 'intro' | 'loop' | 'outro' | 'ended'; /** * One entry in a `engine.preload(...)` batch. Mirrors `engine.loadSound`'s * signature so callers can hand the same data to either path. */ interface PreloadItem { /** Sound name, registered on `engine.sound(name)` after preload completes. */ name: string; /** URL or codec ladder, the same shape as `engine.loadSound`. */ url: string | readonly string[]; /** Per-item options (bus, normalize, etc.). Forwarded to `loadSound`. */ options?: LoadSoundOptions; } interface PreloadProgressEvent { /** Name of the item that just completed (success or failure). */ readonly name: string; /** Outcome. `'loaded'` on decode success, `'failed'` otherwise. */ readonly status: 'loaded' | 'failed'; /** Error attached when `status === 'failed'`. */ readonly error?: unknown; /** Items settled so far (loaded + failed). */ readonly completed: number; /** Total items in the batch. */ readonly total: number; } interface PreloadOptions { /** * Cancels the whole preload. In-flight fetches receive the abort; pending * items aren't started. The promise rejects with the signal's abort reason. */ signal?: AbortSignal; /** * 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. */ concurrency?: number; /** * Fires once per item as it settles. Use this to drive a loading-screen * progress bar. The event is cumulative, so `event.completed / event.total` * is a fraction in [0..1]. */ onProgress?: (event: PreloadProgressEvent) => void; } /** * 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. */ declare class Spatializer { private ctx; private kind; private input; private output; private stereoPan; private panner; private occlusionFilter; private occlusionGain; constructor(ctx: AudioContext, opts: SpatialOptions); setPan(pan: number): void; setPosition(x: number, y: number, z: number): void; setRefDistance(d: number): void; setMaxDistance(d: number): void; setRolloffFactor(f: number): void; setDistanceModel(m: DistanceModel): void; /** * Set occlusion amount (0..1). Drives the internal lowpass cutoff * and a small gain dip — independent of distance attenuation. * No-op on 2D spatializers. */ setOcclusion(amount: number): void; /** Connects the spatializer's output into `dest` and returns the input the Voice should use. */ connectInto(dest: AudioNode): AudioNode; dispose(): void; private setPanner3D; } type VoiceCue = 'started' | 'paused' | 'resumed' | 'ended'; interface VoiceDeps { ctx: AudioContext; buffer: AudioBuffer; destination: AudioNode; options: PlayOptions; onEnded: (v: Voice) => void; spatializer?: Spatializer; sourceName?: string; /** Engine-level default click-free fade for stop() (seconds). */ defaultStopFade?: number; } /** * 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(). */ declare class Voice { readonly id: number; readonly priority: number; readonly bus: string | undefined; readonly startedAt: number; /** Name of the Sound that spawned this voice (if any). Used for crossfade. */ readonly sourceName: string | undefined; /** Resolves when playback finishes (natural end, stop(), or abort). */ readonly ended: Promise; private ctx; private gain; private source; private buffer; private loop; private basePitch; private done; private stopping; private paused; private resolveEnded; private cueListeners; private startCtxTime; private pausedOffset; private currentOffset; private _spatializer; private startOffset; private regionDuration; private loopStart; private loopEnd; private regionTimer; private stopFade; private crossfadeSec; private crossfadeRegionStart; private crossfadeRegionLen; private crossfadeChain; private crossfadeArmTimer; private levelAnalyser; private levelBuf; static nextId: number; constructor(deps: VoiceDeps); private shouldUseLoopCrossfade; fade(opts: FadeOptions): Promise; /** * Stop the voice. Applies a short linear gain ramp (default ~8 ms) before * the source node actually stops, to suppress the digital click that * would otherwise fire if the source is cut mid-waveform on a non-zero * crossing. Pass `{ fade: 0 }` for an instant hard cut, or a longer * duration for an explicit tail. * * Re-entrant calls during an in-progress stop are no-ops — the first * stop wins. The voice's `.ended` promise resolves when the audio * actually stops (i.e. after the ramp completes). */ stop(opts?: StopOptions): void; private activeSources; private stopAllSources; /** * Pause this voice. Tracks the playback offset so resume() picks up * where it left off. No-op on a finished voice. */ pause(): void; /** Resume from the offset captured on pause(). No-op when not paused. */ resume(): void; /** Whether the voice is currently paused (and thus retaining its offset). */ get isPaused(): boolean; /** * Live playback-rate setter. Optional ramp via curve. * Setting while paused only updates the value used on the next start. */ setPlaybackRate(rate: number, opts?: { duration?: number; curve?: FadeOptions['curve']; }): void; get playbackRate(): number; /** The Spatializer attached to this voice (if any). Use for live setPan/setPosition. */ get spatializer(): Spatializer | undefined; /** * Live amplitude readout. Returns `{ rms, peak }` as linear values in * [0..1]. The first call lazily attaches an AnalyserNode to the voice's * gain stage; subsequent calls reuse it. Returns zeros once the voice has * finished. */ level(): AudioLevel; /** * Cheap loudness proxy for voice stealing, in [0..1]. * * `level()` allocates an AnalyserNode and keeps it for the voice's * lifetime, so using it to rank 64 candidates left 64 permanent analysers * on the graph — and a freshly created analyser reads silence anyway, * because its buffer hasn't filled yet. Reuse a tap that already exists; * otherwise read the voice's own gain, which costs nothing. * * @internal */ levelHint(): number; /** Async iterator of lifecycle cues — yields started, optional paused, ended. */ cues(): AsyncIterableIterator; private finish; private emit; private createSourceNode; private armRegionTimer; private bindSourceLifecycle; /** * Begin the loop-crossfade chain. Spawns the first segment immediately at * full level, then arms a setTimeout to spawn the next one shortly before * the boundary. Each subsequent spawn re-arms its own follow-up. * * Audio scheduling uses absolute `ctx.currentTime` values so drift between * setTimeout (wall clock) and the audio thread doesn't cause click-y * misalignment — setTimeout only needs to wake us in time to call * `source.start(when, ...)` before `when`. */ private startCrossfadeChain; private spawnCrossfadeSegment; private armNextCrossfadeSegment; private releaseCrossfadeSegment; private get inCrossfadeMode(); private computeOffset; } /** * 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. */ declare class Send { readonly source: Bus; readonly target: Bus; readonly post: boolean; private gainNode; private ctx; private disposed; constructor(source: Bus, target: Bus, options: SendOptions, ctx: AudioContext); /** Live send level (0..1). Setter ramps over 10 ms to avoid clicks. */ get amount(): number; set amount(v: number); /** Fade the send to `target` over `duration` seconds. */ fadeTo(target: number, duration: number, curve?: FadeCurve): Promise; dispose(): void; } /** * 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. */ declare class Bus { readonly name: string; readonly input: GainNode; /** Sub-bus the FX chain splices into: input → fxInput → (fx…) → output. */ readonly fxInput: GainNode; /** Output node — connect to master.input or another bus.input for sends. */ readonly output: GainNode; private _level; private _muted; private _soloed; /** True while the engine's solo rule is silencing this (non-soloed) bus. */ private _soloVeiled; private readonly ctx; private _concurrency; private _voices; private _fxChain; private _sends; private _meterAnalyser; private _meterBuf; /** * 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. */ notifySoloChange?: (bus: Bus, soloed: boolean) => void; constructor(ctx: AudioContext, name: string, config?: BusConfig); get level(): number; set level(v: number); get muted(): boolean; set muted(v: boolean); /** Fade the bus output to `target` over `duration` seconds. */ fadeTo(target: number, duration: number, curve?: FadeCurve): Promise; /** Active voice count on this bus. */ get voiceCount(): number; /** Read-only iterator over active voices. */ voices(): readonly Voice[]; /** Concurrency config — read it for UI, mutate via setConcurrency. */ get concurrency(): ConcurrencyConfig | null; setConcurrency(c: ConcurrencyConfig | null): void; /** * Called by Engine right before adding a new Voice. Returns a Voice that * was stolen to make room (so the engine can release it from its tracking), * or null if no steal was needed (or the new voice itself was rejected, * in which case onReject is invoked instead). */ applyConcurrencyOnSpawn(newVoice: Voice, onReject: () => void): Voice | null; /** Engine-only: register an active voice. */ trackVoice(v: Voice): void; /** Engine-only: deregister a finished voice. */ releaseVoice(v: Voice): void; /** Add an FX insert to the bus's FX chain (post-input, pre-output). */ addFx(fx: FxInsert): void; removeFx(fx: FxInsert): void; fx(): readonly FxInsert[]; /** * Live amplitude readout on the bus output. Returns `{ rms, peak }` as * linear values in [0..1]. The first call lazily attaches an AnalyserNode * as a passive sibling of `bus.output → master.input`; subsequent calls * reuse it. * * Use this to drive a mixer-dashboard VU meter, drive automation that * reacts to overall bus level, or implement custom voice-stealing rules. */ meter(): AudioLevel; /** * Route a copy of this bus's signal into another bus at the configured * level. `target.input` mixes the send naturally with whatever else is * already feeding it, so the typical pattern — "send 30 % of music to a * dedicated reverb bus" — is just two lines: * * ```ts * const verbSend = engine.bus('music').send(engine.bus('reverb'), { amount: 0.3 }); * verbSend.amount = 0.5; // turn it up live * verbSend.dispose(); // remove the send entirely * ``` * * Default tap is post-fader / post-FX (`post: true`); set `post: false` * if you want a monitor send that hears the dry pre-fader signal. */ send(target: Bus, options?: SendOptions): Send; /** Remove a previously-created send. Idempotent. */ removeSend(send: Send): void; /** Read-only snapshot of every active send originating on this bus. */ sends(): readonly Send[]; /** * Solo this bus. The engine coordinates the global rule: while any bus * is soloed, every non-soloed bus is muted. Soloing multiple buses is * additive — they all stay audible. Calling `unsolo()` (or `solo(false)`) * removes this bus from the soloed set. * * Solo state is independent of `muted`; un-soloing returns the bus to * whatever its `muted` setting was. Useful in mixer UIs where the user * wants to A/B a single channel without disturbing the rest of the mix. */ solo(on?: boolean): void; /** Turn solo off on this bus. Equivalent to `solo(false)`. */ unsolo(): void; /** True when this bus is in the engine's solo set. */ get soloed(): boolean; /** * Engine-only. Apply a transient mute (or restore) driven by the global * solo state. Distinct from `muted` so un-soloing returns the bus to its * user-visible mute state without re-rendering. */ applySoloVeil(soloMute: boolean): void; private rewireFxChain; private rampOutput; private ramp; dispose(): void; } interface DuckerConfig { /** How much to attenuate when fully ducking (0..1). Default 0.5 = -6 dB. */ amount?: number; /** Attack in seconds. Default 0.08. */ attack?: number; /** Release in seconds. Default 0.4. */ release?: number; /** Threshold (linear amplitude) on the source bus's RMS to trigger ducking. Default 0.05. */ threshold?: number; } /** * 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). */ declare class Ducker implements FxInsert { readonly input: GainNode; readonly output: GainNode; private gain; private analyser; private buf; private rafId; /** Timestamp of the previous tick (ms), for measuring the real frame delta. */ private lastTickMs; /** * Envelope-follower state, 1 = not ducking. It starts at unity: starting * at 0 made every freshly-inserted ducker drop its target bus to silence * and swell it back over the release time. */ private envelope; private targetGain; private ctx; private cfg; private _bypassed; private sourceBus; constructor(ctx: AudioContext, sourceBus: Bus, config?: DuckerConfig); setAmount(a: number): void; setThreshold(t: number): void; get bypassed(): boolean; set bypassed(v: boolean); dispose(): void; private tick; /** * A hidden tab stops firing rAF, so the envelope freezes wherever it was. * Come back to a ducker that was mid-duck and the music stays quiet with * nothing driving it back up. Reset to unity on return and let the next * few frames re-duck if the source really is still loud. */ private handleVisibility; private resetEnvelope; private releaseToUnity; private startLoop; private stopLoop; } type FilterKind = 'lowpass' | 'highpass' | 'bandpass' | 'notch' | 'peaking' | 'allpass'; interface FilterConfig { type?: FilterKind; /** Cutoff/center frequency in Hz. Default 1000. */ frequency?: number; /** Q factor (resonance). Default 1. */ q?: number; /** Gain (dB) — only meaningful for `peaking`. Default 0. */ gain?: number; } /** * 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. */ declare class Filter implements FxInsert { readonly input: GainNode; readonly output: GainNode; private filter; private ctx; private _bypassed; constructor(ctx: AudioContext, config?: FilterConfig); setFrequency(hz: number): void; setQ(q: number): void; setType(t: FilterKind): void; /** Set the filter gain in dB. Only affects `peaking` (the one shelf-like mode exposed). */ setGain(db: number): void; get bypassed(): boolean; set bypassed(v: boolean); dispose(): void; private wire; } interface ReverbConfig { /** Wet/dry mix (0 = dry, 1 = full wet). Default 0.3. */ wet?: number; /** A loaded impulse-response buffer. If omitted, a synthetic decay is generated. */ impulse?: AudioBuffer; /** Synthetic decay parameters (used when `impulse` is omitted). */ decay?: { /** RT60-style decay in seconds. Default 1.5. */ seconds?: number; /** Pre-delay in seconds. Default 0. */ preDelay?: number; }; } /** * 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. */ declare class Reverb implements FxInsert { readonly input: GainNode; readonly output: GainNode; private dry; private wet; private convolver; private preDelay; private ctx; private _bypassed; /** Active wet mix (0..1), so bypass can restore it instead of guessing. */ private _wet; constructor(ctx: AudioContext, config?: ReverbConfig); setWet(mix: number): void; setImpulse(buffer: AudioBuffer): void; get bypassed(): boolean; set bypassed(v: boolean); dispose(): void; /** Generate a synthetic exponential-decay impulse response. */ private synthIR; } /** * 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). */ declare class StretchProcessor { private factor; constructor(stretchFactor?: number); process(input: Float32Array): Float32Array; /** Convenience: process an AudioBuffer in place to a new buffer. */ static stretchBuffer(ctx: BaseAudioContext, src: AudioBuffer, factor: number): AudioBuffer; } /** * Realtime *varispeed* via AudioWorklet. * * The realtime companion to the offline `StretchProcessor`. Loads a worklet * processor into the AudioContext and exposes a node whose `stretch` * AudioParam can be automated live — boss intros that bend in real time, * slow-mo stings, etc. * * IMPORTANT: unlike the offline `StretchProcessor`, this node is NOT * pitch-preserving. It resamples a ring buffer at a variable read rate via * linear interpolation, so changing `stretch` shifts pitch *and* tempo * together — classic varispeed / tape-style speed change. It is cheap and * sounds fine for live ramps across the 0.25×–4× range when you don't need * pitch held constant. For pitch-preserving (offline) stretching, use * `StretchProcessor`. */ interface StretchWorkletOptions { /** Initial stretch factor. 1 = play at source rate. > 1 = faster (higher pitch). */ stretchFactor?: number; /** Ring-buffer sizing hint in samples (ring = 8×). Default 1024. */ grainSize?: number; } interface StretchWorkletNode extends AudioNode { /** Live stretch factor — automate via setValueAtTime, linearRampToValueAtTime, etc. */ readonly stretch: AudioParam; /** Disconnect and release. */ dispose(): void; } /** * Ensure the realtime stretch worklet is registered on `ctx`. Idempotent — * the processor module is registered at most once per context. */ declare function ensureStretchWorklet(ctx: AudioContext): Promise; /** * Construct a realtime stretch node. Call after `ensureStretchWorklet(ctx)`. */ declare function createStretchWorkletNode(ctx: AudioContext, options?: StretchWorkletOptions): StretchWorkletNode; /** * 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. */ declare class BusGroup { readonly name: string; readonly members: readonly Bus[]; constructor(name: string, members: readonly Bus[]); /** Set the level on every member. Same 10 ms ramp as direct `bus.level =`. */ set level(v: number); /** * Read the current level. Returns the average across members — useful * when every member shares a level (the common case) and informational * otherwise. */ get level(): number; set muted(v: boolean); /** True if every member is currently muted. */ get muted(): boolean; /** * Fade every member to `target` over `duration` seconds. Returns when * the slowest leg completes — in practice they're all equal because * each member uses the same duration. */ fadeTo(target: number, duration: number, curve?: FadeCurve): Promise; /** Solo every member of the group. Engine handles the global mute-the-rest rule. */ solo(on?: boolean): void; /** Un-solo every member. */ unsolo(): void; } type ParameterCurve = FadeCurve; type Subscriber = (value: number) => void; /** * 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. */ declare class Parameter { readonly name: string; private _value; private subs; constructor(name: string, initial: number); get value(): number; set(v: number): void; subscribe(fn: Subscriber): () => void; /** * Bind a setter to this parameter. The parameter's [0..1] value (clamped) * is mapped to [from..to] via `curve`, then the setter is called with the * mapped value. Returns an unbind function. */ bindTo(setter: (mapped: number) => void, opts?: { from?: number; to?: number; curve?: ParameterCurve; }): () => void; } /** * 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. */ type EngineState = 'cold' | 'unlocking' | 'live' | 'suspended' | 'interrupted' | 'closed'; interface MusicBuffers { intro?: AudioBuffer; loop: AudioBuffer; outro?: AudioBuffer; } interface MusicDeps { ctx: AudioContext; buffers: MusicBuffers; destination: AudioNode; loopCrossfade: number; defaultStopFade?: number; } /** * 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. */ declare class Music { readonly name: string; private deps; private live; constructor(name: string, deps: MusicDeps); get loopDuration(): number; get hasIntro(): boolean; get hasOutro(): boolean; play(options?: MusicPlayOptions): MusicVoice; /** Live playback instances spawned from this asset. */ voices(): readonly MusicVoice[]; /** * Stop every live instance. `engine.close()` uses this — without it a * music voice kept its source nodes running past the engine that owned * them, the way streams used to. */ stopAll(opts?: StopOptions): void; } interface MusicVoiceDeps { ctx: AudioContext; buffers: MusicBuffers; destination: AudioNode; loopCrossfade: number; defaultStopFade?: number; options: MusicPlayOptions; } /** * 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. */ declare class MusicVoice { readonly ended: Promise; private ctx; private buffers; private destination; private gain; private state; private done; private resolveEnded; private stopFade; private loopCrossfadeSec; private introSource; private outroSource; private loopChain; private loopArmTimer; private outroTimer; private nextLoopBoundaryAt; private loopAnchorAt; private introEndsAt; constructor(deps: MusicVoiceDeps); /** Currently-sounding part. Transitions automatically as parts hand off. */ get currentPart(): MusicState; fade(opts: FadeOptions): Promise; /** * Stop the music with the same click-free fade-out semantics as * `voice.stop()`. Skips the outro — call `skipToOutro` first if you * want the music to end musically. */ stop(opts?: StopOptions): void; /** * Schedule the outro. With `at: 'loop-end'` (default) the outro starts * at the next natural loop boundary so the music ends musically. With * `at: 'now'` the loop fades out (~50 ms) and the outro starts * immediately — useful when responsiveness matters more than musicality * (e.g. user pressed Stop). * * No-op if there is no outro buffer, or if the music is already past * the loop part. Calling `skipToOutro` more than once is a no-op too — * the first call wins. */ skipToOutro(opts?: SkipToOutroOptions): void; private start; private scheduleIntro; private scheduleLoopStart; private spawnLoopSegment; private armNextLoopSegment; private crossfadeViable; private releaseLoopSegment; /** * Audio time of the next natural loop boundary. * * Under crossfade the chain re-anchors `nextLoopBoundaryAt` every time it * spawns a segment, so that marker is current by construction. The * native-loop path has no chain — one source loops itself for the whole * playback — so its boundaries are a grid off the loop's start, and the * answer has to be computed against the clock each time it's asked for. * Reading a marker that was written once, at the first boundary, is how * `skipToOutro({ at: 'loop-end' })` came to fire instantly on every * iteration but the first. */ private nextLoopBoundary; private skipToOutroAtLoopEnd; private skipToOutroNow; private cancelLoopArm; private stopLoopSourcesAt; private scheduleOutro; private cancelTimers; private stopAllSources; private finish; } interface SoundDeps { ctx: AudioContext; buffer: AudioBuffer; defaultBus: string; resolveBusInput: (name: string) => AudioNode; resolveSpatializer?: (busName: string, options: PlayOptions['spatializer']) => Spatializer | undefined; trackVoice: (v: Voice, bus: string) => void; releaseVoice: (v: Voice, bus: string) => void; applyConcurrency?: (v: Voice, bus: string) => boolean; /** Engine-level default click-free fade for voice.stop() (seconds). */ defaultStopFade?: number; /** * Name reported on spawned voices, when it differs from the registry key. * Sprites and variant bundles register under internal keys; their voices * should still say which public asset they came from so `engine.crossfade` * can find them. */ sourceName?: string; } /** * A loaded sample — owns one decoded AudioBuffer, spawns Voices on play(). * * Sound is created via engine.loadSound() / bank load; constructor is * package-private. */ declare class Sound { readonly name: string; private deps; constructor(name: string, deps: SoundDeps); get duration(): number; play(options?: PlayOptions): Voice; } interface SpriteRegion { /** Start offset within the buffer, in seconds. */ start: number; /** Region duration in seconds. */ duration: number; /** If true, looping plays this region back-to-back. Default false. */ loop?: boolean; } interface SpriteMap { [name: string]: SpriteRegion; } /** * 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. */ declare class Sprite { readonly name: string; private regions; private sound; constructor(name: string, sound: Sound, regions: SpriteMap); /** Region names defined on this sprite. */ list(): readonly string[]; has(region: string): boolean; region(name: string): SpriteRegion; /** * Spawn a Voice playing the named region. The voice stops itself after * `region.duration` seconds via an internal scheduleAt — the underlying * AudioBufferSourceNode is one big buffer, so we can't rely on its * natural-end event for region timing. */ play(region: string, options?: SpriteRegionPlayOptions): Voice; } type SpriteRegionPlayOptions = Omit & { loop?: boolean; }; /** * 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. */ declare class StreamSound { readonly name: string; private ctx; private url; private destination; private el; private node; private gain; private disposed; constructor(name: string, ctx: AudioContext, url: string, destination: AudioNode); /** Lazily construct the