Changelog
Generated by Changesets
from CHANGELOG.md.
Install any version with pnpm add @schmooky/zvuk@<version>.
-
v1.13.0
GitHub release →minor 1 change-
#87
50bb39fThanks @igaming-bulochka! - AddFilter.setGain(db)to adjust the peaking-filter gain live (previouslygaincould only be set at construction). Also fixed concept-page docs: the Engine state union now listsinterrupted(5 states, not 4), the Filter API surface showsinput/outputasGainNode(notBiquadFilterNode) plus the newsetGain, and the manual Spatializer recipe now routes a source into the nodeconnectInto()returns (the previous snippet produced silence).
patch 12 changes-
#89
7037d0bThanks @igaming-bulochka! - Minor cleanups: useMath.SQRT1_2for the occlusion filter's Butterworth Q (clears the last Biome warning) and fix a staleBus.fxInputdoc comment that claimed it equalsinput(it's a distinct node the FX chain splices into). -
#86
176408cThanks @igaming-bulochka! - Stop labelling the scheduler "sample-accurate". It dispatches JS callbacks from a tick source (setTimeoutor an injected ticker), so it is tick-bounded (ms-level), as its own docstring already noted. Reworded the README,Schedulerdocs, and docs pages to "audio-clock scheduler", and corrected the claim that thescheduleAtcallback receives anaudioTimeargument (it does not — close over the value you scheduled against). -
#85
8fb3747Thanks @igaming-bulochka! - Stop calling the master limiter a "brick-wall" limiter. It's a fast-attackDynamicsCompressorNode(ratio ~20) — limiter-like but, with a finite attack and no lookahead, it does not guarantee a hard 0 dBFS ceiling. Reworded the README,Masterdocs, andMasterLimiterConfig/MasterConfigto "soft limiter / best-effort peak control", and fixed the concepts/mixer snippet that used a non-existentengine.bus('master')/master.setLimiterruntime API (the limiter is configured at construction). -
#75
69cae54Thanks @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 offlineStretchProcessor. 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
1ef40e9Thanks @igaming-bulochka! -VoiceJitternow accepts abasesovolume/pitchjitter can be combined with a chosen center value, e.g.play({ pitch: { base: 1.5, jitter: 0.1 } }). Previously jitter always centered on1.0, so a base playback rate or volume could not be combined with jitter. Plain numbers and{ jitter }-only forms are unchanged. -
#74
4424beeThanks @igaming-bulochka! -Bus.disposenow tears down the bus's sends and FX inserts. Previously a disposed bus left itsSendGainNodes connected to their target buses (a node leak), sinceengine.closenever iterated bus sends.Bus.fx()andBus.sends()now return copies so callers can't mutate the live chain/send list. -
#80
1806f89Thanks @igaming-bulochka! -engine.crossfadenow only fades out instances offromon the same bus the incoming voice plays on — previously it faded out every voice offromacross 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 fortois always started (it never reuses an already-playingto, despite the previous wording). -
#79
6bff9b7Thanks @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 fixed1/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
33d8d57Thanks @igaming-bulochka! - Loop crossfades now use equal-power (sin/cos) ramps instead of linear ones, for bothPlayOptions.loopCrossfade(Voice) andMusicLoadOptions.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
1e99f97Thanks @igaming-bulochka! - Loudness normalization no longer mutates the source buffer in place — it scales into a freshAudioBufferwhen run through the engine, so a buffer adopted from aresolveAssetcache is left untouched. Also clarified that normalization matches RMS level, not perceptual/LUFS loudness, in the docs andLoadSoundOptions.normalize. -
#78
ba9ec8aThanks @igaming-bulochka! -preloadabort hardening:combineSignalsnow 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
f37be23Thanks @igaming-bulochka! -Voice.cues()now always yields a terminalendedcue. If the iterator was attached after the voice had already finished, it previously returned an empty stream — so a consumer awaitingendedfromcues()never observed completion.
-
-
v1.12.1
GitHub release →patch 4 changes-
#69
9a95045Thanks @igaming-bulochka! - FixBus.fadeToso 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.applycould not keep a bus captured as muted silent — the level fade un-muted it right after the mute was applied.fadeTonow stores the target level while silenced and applies it when the bus is unmuted/unveiled. -
#68
13a3a94Thanks @igaming-bulochka! - Fix theequal-powerfade curve so crossfades stay at constant power. It previously applied asin²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 followsin(t·π/2), falling legscos(t·π/2)), so two opposing legs sum to unity power. Affectsengine.crossfade,Bus/Sendfades,Snapshot.apply, andParameterbindings usingcurve: 'equal-power'. -
#71
f4580f1Thanks @igaming-bulochka! - Fix the README sidechain-ducking example. It constructednew Ducker(engine.context, { source, target, ... }), but the real signature isnew Ducker(ctx, sourceBus, config)— the source bus is the second positional argument andDuckerConfighas nosource/targetkeys, so the snippet did not compile or run. It now matches the actual API (and the ducking guide). -
#70
98235d6Thanks @igaming-bulochka! - FixReverbbypass. Bypassing now passes the dry signal at unity gain and silences the wet path — it previously left dry at1 - 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 hardcoded0.3, andsetWetcalled while bypassed is remembered and applied when the effect is re-enabled.
-
-
v1.12.0
GitHub release →minor 1 change-
#44
7b3a2abThanks @igaming-bulochka! - Snapshot blend — interpolate the live mix between two captured snapshots.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 tolerp(a, b, t).tis clamped to[0, 1]. Each call is instant (the 10 ms anti-click ramp onbus.levelstill applies), so calling it on every frame is cheap.snapshot.blendWith(other, t)— same operation as a method onSnapshot, mirroringapply().- Buses or parameters present in only one of the two snapshots are skipped. Mute flips at
t = 0.5rather than interpolating, since the flag is binary. - For one-shot crossfades with a fade duration,
snapshot.apply({ fade })is unchanged —blendSnapshotsis the continuous-knob sibling.
New
examples/snapshot-blend/shows the pattern end-to-end: two looping layers, a slider drives atensionparameter that interpolates between acalmandcombatsnapshot.
-
-
v1.11.1
GitHub release →patch 1 change-
#42
818bbd9Thanks @igaming-bulochka! - Dropeventemitter3runtime dependency.It was declared in
dependenciesbut never imported anywhere in the package — a leftover that slipped in by mistake.Voicecue listeners use a plainSet<fn>walker, not anEventEmitter. zvuk now has zero runtime dependencies, matching the pitch in the README.
-
-
v1.11.0
GitHub release →minor 1 change-
#40
7279123Thanks @igaming-bulochka! - DX wins — five small, mutually-independent additions.// 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 theplay({ volume: 0 }) + voice.fade({ to, duration })two-step that ambient layers needed.engine.unloadSound(name, { evictBuffer? })— explicit eviction sibling toremoveSound. Active voices keep playing until they end naturally; only futureplay()calls are affected.evictBufferdefaults totrue.createEngine({ latencyHint })— forwards toAudioContextOptions.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
BusNametypes.EngineandEngineConfigare generic inTBusName. Pass a literalbusesmap andengine.bus(name)type-checks against the keys you declared. Fully backwards-compatible — pass anEngineConfigtyped asstring(the default) andengine.bus()accepts any string.
Voice and Loading concept pages picked up the new sections.
-
-
v1.10.0
GitHub release →minor 1 change-
#38
ba8865aThanks @igaming-bulochka! - Spatializer 3D config exposed, plus a single-knob occlusion parameter.// 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, anddistanceModelare nowSpatialOptionsfields and have matching live setters onSpatializer. 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 tightermaxDistanceof 1000. - Occlusion knob. A new
occlusion: 0..1field onSpatialOptions, plussetOcclusion(amount)onSpatializer. Drives an internalBiquadFilterNodelowpass (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 singleParameterto bothsetOcclusionand a position update if you want one knob to drive both.
3D Spatializers gain one extra
BiquadFilterNode+GainNodeper voice for the always-on (but transparent at occlusion = 0) occlusion chain. 2D StereoPanner spatializers are unchanged.Documented on the Spatializer concept page.
- 3D config exposed.
-
-
v1.9.0
GitHub release →minor 1 change-
#36
102a8fbThanks @igaming-bulochka! - Routing primitives — bus sends, solo, bus groups, and a master meter.// 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. Eachsendreturns aSendhandle with liveamount,fadeTo(), anddispose(). Default tap is post-fader / post-FX; passpost: falsefor monitor-style pre-fader sends. Sends route into the target'sinput, 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 ofmuted— 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)— aBusGroupis a logical handle, not an audio node. Settinggroup.level, callinggroup.fadeTo(),group.muted = true, orgroup.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 asbus.meter()andvoice.level(), just at the top of the chain. Lazy AnalyserNode tap onmaster.input.
The Bus concept page on the docs site picks up four new sections covering each.
-
-
v1.8.0
GitHub release →minor 1 change-
#34
d224167Thanks @igaming-bulochka! - Add theMusicsource — stinger → loop → outro, the pattern every casino slot, action game, and rhythm game uses for combat/win/menu music.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
resolveAssethook asloadSound. The intro and outro are both optional — a loop-only manifest works the way a regular looping sound does today, andskipToOutro()on a loop-only asset falls through to a clean stop so calling code doesn't have to branch onmusic.hasOutro.loopCrossfadecarries through to the loop body (same equal-power-at-the-boundary trick from v1.5'sPlayOptions.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 (
_baseLevelunused field on Bus, template-literal nit in CLI transcode, optional-chain nit in codecs).
-
-
v1.7.3
GitHub release →patch 1 change-
#32
fdd04e6Thanks @igaming-bulochka! - Spectrum bars on every interactive docs demo (was: time-domain oscilloscope), plus abars-stereovariant 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.SpatialPanneris 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 newbars-stereovariant:<Waveform>splits the source through aChannelSplitterNodeand 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.
-
-
v1.7.2
GitHub release →patch 1 change-
#30
5449941Thanks @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
<Waveform audioNode={busNode} />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.
-
-
v1.7.1
GitHub release →patch 1 change-
#28
595a8ebThanks @igaming-bulochka! - Render changelog markdown on the docs site.The
/changelog/page was dumping each entry's body inside a<pre>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 throughmarkedand 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 throughset:htmland styles it with a scoped.changelog-proseblock so paragraphs, lists, code spans, and fenced code all render properly.
No public API change — purely a docs-site fix.
- The build script (
-
-
v1.7.0
GitHub release →minor 1 change-
#26
2a3f6a9Thanks @igaming-bulochka! - Live amplitude meters on Voice and Bus, plus arhythm-metronomeexample.// 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 viaengine.scheduleAt, a live VU bar driven bybus.meter(), and a per-voice peak meter showingvoice.level()on the most-recently-fired voice. BPM control with drift-free re-anchoring.Documented on the Voice and Bus concept pages.
-
-
v1.6.0
GitHub release →minor 1 change-
#24
d7b94deThanks @igaming-bulochka! - Addengine.preload(items, options)— a first-class bulk loader for loading screens.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.preloadprovides all three:- Per-item progress via
onProgress({ name, status, completed, total }).completed / totalis 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
PreloadErroronly 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
loadSoundone-for-one ({ name, url, options? }), so existing manifests can be passed through without massaging the data first.Documented in the "Loading sounds" guide.
- Per-item progress via
-
-
v1.5.0
GitHub release →minor 1 change-
#22
cfb8adeThanks @igaming-bulochka! - AddloopCrossfadeplay option for click-free music loops.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
loopEndback toloopStart. 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.loopCrossfadedoes 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: truevoices 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
loopis false, or if the loop region is shorter than 2× the crossfade window. - Works alongside everything else on Voice —
pause/resumere-enters a fresh chain,setPlaybackRatefans out across every live segment,stop()tears the chain down with the usual click-free fade.
Documented on the Voice concept page.
- Each loop iteration costs one extra
-
-
v1.4.0
GitHub release →minor 1 change-
#20
419b817Thanks @igaming-bulochka! - Three small cleanups surfaced in an audit pass.Filter.bypassedactually bypasses now. Previously, togglingbypassed = trueset 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 mirrorsCompressor.bypassed: a real graph rewire that detaches the biquad and connectsinput → outputdirectly. Two unusedGainNodes (bypassPath,direct) were also removed. Public shape is unchanged:Filterstill implementsFxInsertwithinput/output/bypassed/dispose, butinput !== outputnow (separate gain nodes spliced around the biquad).'quietest'voice-stealing logs an honest fallback warning. The strategy advertised inConcurrencyConfig['steal']was silently behaving like'oldest'because Voice doesn't expose a level meter yet. It still falls back, but now logs a one-shotconsole.warnexplaining 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.BankNotLoadedErrorremoved. The class was exported from the public API but never thrown — the CLI's generatedloadBank()is just a loop overengine.loadSound, so a dedicated bank error wasn't doing any work. If you wereimport-ing it, you can drop the import.
-
-
v1.3.0
GitHub release →minor 1 change-
#18
c672522Thanks @igaming-bulochka! - AddcreateEngine({ resolveAsset })— a generic hook for adopting buffers from an external asset system (PixiAssets.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
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/loadSpritecall. Returningundefined/nullfalls through to the URL list passed toloadSound, 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
Mapcache — 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
loadSoundand (transitively)loadSprite.loadStreamis HTMLAudioElement-backed and doesn't decode buffers, so it stays on direct URL consumption — covered by a pitfall callout in the guide. - Pixi v8 + assetpack — pull buffers straight out of
-
-
v1.2.0
GitHub release →minor 1 change-
#16
c63f9f5Thanks @igaming-bulochka! - Add opt-intickSourceinjection so the scheduler can dispatch JS callbacks from a host's existing render loop (Pixiapp.ticker, GSAPgsap.ticker, custom rAF) instead ofsetTimeout. Also expose the existing visibility-driven AudioContext suspend as a configurableautoPauseOnHiddenflag, and add a "Runtime timing" guide that documents the full timing model.Why
engine.scheduleAt(audioTime, fn)and voice region timers previously usedsetTimeoutexclusively. Browsers throttlesetTimeoutto ~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
requestAnimationFrameloop inside the library would be the wrong fix: it doesn't help on hidden tabs (rAF pauses entirely there, worse thansetTimeout'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
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 });TickSourceis a minimalsubscribe(handler) → unsubscribeshape — anything you canadd(handler)and laterremove(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 atickSource, the scheduler keeps usingsetTimeout.autoPauseOnHiddenThe 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 ascreateEngine({ autoPauseOnHidden: false })for music players and background-audio apps that want playback to continue across tab switches. Default remainstrue, 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.
-
-
v1.1.0
GitHub release →minor 1 change-
#14
6587a92Thanks @igaming-bulochka! - Apply a short click-free fade-out beforevoice.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 calledsource.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.endedpromise resolves after the ramp completes; the engine's voice tracking sees the same termination it always did. Re-entrantstop()calls during an in-progress fade are no-ops — the first stop wins.Configuration
- Engine default:
createEngine({ voice: { stopFade: 0.008 } }). Set to0to disable globally and restore the old hard-stop behaviour. - Per-call override:
voice.stop({ fade: 0.05 })for a longer tail, orvoice.stop({ fade: 0 })for an immediate hard cut (sample-accurate timing, intentional staccato).
The same fade applies to all stop paths: explicit
stop(),AbortSignalabort, 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. - Engine default:
-
-
v1.0.1
GitHub release →patch 1 change-
#12
40ea9afThanks @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 viapickSource()and threwDecodeErrorimmediately 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-reportedcanPlayTypecould brick a sound that had a perfectly good fallback sitting next to it in the array.Decodernow exposesloadFirst(urls, opts)which walks the list in order (codecs the browser claims it can play float to the front via the newpickSourceOrder()), and falls through on per-URL fetch/decode failures. The first URL that successfully fetches AND decodes wins.AbortErrorfromopts.signalis 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
AggregateDecodeErroris thrown with per-URL causes attached onattempts. It's a subclass ofDecodeError, so existingcatch (e instanceof DecodeError)paths still fire. Single-URL failures rethrow the underlyingDecodeErrorverbatim — no behavioural change for callers that don't pass an array.
-
-
v1.0.0
GitHub release →major 1 change-
#9
b5a82cbThanks @igaming-bulochka! - Normalize all time-valued options to seconds to match the Web Audio API. This is a breaking change.PlayOptions,CompressorConfig,MasterLimiterConfig, andReverbConfigalready used seconds.FadeOptions,CrossfadeOptions,SnapshotOptions,DuckerConfig, andSidechainConfigpreviously mixed milliseconds in. They now all take seconds, and a few field names changed to remove themssuffix.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(orfadeMs→fade).Why
Web Audio is the underlying runtime, and it speaks seconds everywhere —
AudioContext.currentTime, everyAudioParamschedule call,setValueAtTime,linearRampToValueAtTime,setValueCurveAtTime. Mixing milliseconds in user-facing options forced a* 1000// 1000conversion at every boundary and made it easy to pass the wrong unit when copy-pasting across APIs (e.g.CompressorvsDuckerboth haveattack/release, but they were in different units). Aligning on seconds removes that whole class of bug.
-
-
v0.2.0
GitHub release →minor 1 change-
#6
490d822Thanks @igaming-bulochka! - Handle iOS SafariAudioContextinterruptions (phone calls, Siri, system audio takeovers).iOS Safari moves the
AudioContextinto 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
AudioContextHostnow subscribes to the context'sstatechangeevent:- On transition into
'interrupted', a new'interrupted'engine state is emitted viaonStateChange, 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:
EngineStateadds an'interrupted'arm. Code doing exhaustiveswitchon engine state needs an additional case (TypeScript will surface this). - On transition into
patch 3 changes-
#7
12b6e46Thanks @igaming-bulochka! - FixDucker.dispose()leaking the analyser node into its source bus.The Ducker constructor wires
sourceBus.output → analyserto read RMS off the source bus, but the previousdispose()only disconnected the analyser's outgoing side. The source bus retained its outbound edge to the analyser, so the analyser (and its 1024-sampleFloat32Arrayenvelope 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 viasourceBus.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 addssetTargetAtTimetoFakeAudioParamso Ducker's envelope follower can run under tests. -
#8
e29b8daThanks @igaming-bulochka! - Add unit-test coverage forSnapshot— capture / apply / mute restore / parameter behaviour / missing-bus tolerance / re-capture. No source changes; pins the existing behaviour ofengine.captureSnapshot()andSnapshot.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
48e4155Thanks @igaming-bulochka! - FixVoiceinvoking the engine's internalonEndedcallback twice on natural end of non-looped sources.The voice constructor wired the engine cleanup hook both through
bindSourceLifecycle(sync, whenAudioBufferSourceNode.onendedfires) and throughthis.ended.then(...)(microtask, whenfinish()resolves theendedpromise).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.deleteso 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.
-
-
v0.1.1
GitHub release →patch 1 change-
Fix
homepagein 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#versionvia 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 rootCHANGELOG.md(one card per published version, grouped by bump, with commit SHA and@authorper bullet, deep-linkable#v<version>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).
- Navbar version pill is now dynamic — reads
-
-
v0.1.0
GitHub release →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. -
Sweeps the public roadmap (Tiers 1–4) end-to-end:
- Voice live control:
pause(),resume(),setPlaybackRate(), exposedvoice.spatializerfor livesetPan/setPositionwhile 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.limiterconfig (ormaster.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 <glob>(ffmpeg ladder) andnpx 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.
- Voice live control:
patch 1 change-
Docs polish + agent-readable index:
/llms.txtroute 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 indocs/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
CrossfadeDemoReact island (Engine concept page) running a realengine.crossfade()between two music beds (/audio/music-{a,b}.mp3). examples/(slot-machine, match-3, fps-footsteps) now use the casino SFX shipped underdocs/public/audio/so the examples run with no extra setup. slot-machine streams the bed viaengine.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.jsat build time; OG route renamed[slug].png.ts→[slug].tsto fix the*.png.pngfilenames. - Tests: stream, crossfade source-filter precision, voice cues paused/resumed, stretch worklet (mocked). 40 tests pass.
-