Core Player
[Unreleased]
Section titled “[Unreleased]”[1.26.0] — 2026-08-12
Section titled “[1.26.0] — 2026-08-12”- Pages can turn the automatic scan off. (#24) Setting
data-waveform-autoinit="false"on the document element suppresses the document-wide[data-waveform-player]scan that runs on import — for pages that render markup they don’t author (user content, a CMS, an editor canvas) and want initialization to stay in their own hands. Read off<html>rather than a global because ES module imports are evaluated before the importing module’s body runs, so a consumer with a staticimportnever gets the chance to set a flag first. The gate sits on the automatic call, not inside the scan, so an explicitWaveformPlayer.init()still works. This scopes initialization and is not a sanitizer:playIcon/pauseIconstill inject raw markup for any player that does get built. WaveformPlayer.init()takes an optional root. Pass aDocumentorElementto scan only that subtree; the root is matched as well as its descendants, so handing it a single player element works rather than silently doing nothing. Pairs with the opt-out above — a page that suppresses the automatic scan can still initialize what it owns and keep the claim-checking and per-element error isolation the scan provides, instead of hand-rolling aquerySelectorAllloop that has neither. Defaults todocument; existing calls are unchanged.
- A player whose construction throws no longer poisons the registry. The
instance was added to
WaveformPlayer.instancesbeforeinit()ran, so a throw partway through left a half-built player in the map permanently.getInstance()matches by container, so every later declarative scan skipped that element — a transient construction failure became a permanently blank player. Worse, the shared theme watcher anddestroyAll()bothforEachthat map, where a single throw aborts the loop and strands every instance registered after it: one OS light/dark switch could silently stop re-theming the rest of the page. A failed construction now unregisters itself and drops any listeners it had bound, then rethrows so the caller still sees it.
[1.25.1] — 2026-08-11
Section titled “[1.25.1] — 2026-08-11”- The track title no longer blinks in after the audio loads. (#25) The
title comes from the
titleoption or the URL, so it needs nothing from the audio element — but it was written after theloadedmetadatawait, leaving the info row empty for the whole fetch and then popping in. On a slow or non-Range-capable origin that’s a visible flash of an empty player. The title and the seek slider’s accessible name are now painted up front, alongside the inline peaks moved ahead of the same wait in 1.24.5. The total time still waits on metadata (it genuinely needs the duration) and shows0:00until then, as it always has. - A destroyed player can be re-initialized again.
destroy()emptied the container and dropped the instance but leftdata-waveform-initialized="true"on the element.WaveformPlayer.init()checks that flag before the instance lookup, so the element was skipped by every later scan and stayed blank permanently. AffecteddestroyAll()+init()resets and any teardown/remount cycle that reuses the same element instead of re-rendering it. The flag is now cleared with the markup it described. sideEffectsno longer under-declares the entry bundles. The field listed only*.css, which told bundlers every JS file in the package was side-effect-free — but the entry point scans the DOM for[data-waveform-player]and assignswindow.WaveformPlayerat module scope. A declarative-only consumer doing a bareimport '@arraypress/waveform-player'had an unused-binding import a bundler was licensed to drop, silently removing auto-init. The ESM/CJS/IIFE entries are now declared. No behavior or output change; the pure modules undersrc/js/are still tree-shakeable.
[1.25.0] — 2026-08-11
Section titled “[1.25.0] — 2026-08-11”- A valid-but-wrong option no longer takes the player down.
JSON.parsevalidates syntax, not shape, sodata-playback-rates="2"ordata-markers='"hello"'parsed cleanly and then threw at the first.map()/.forEach()— killing the whole player from a typo. Every option is now normalized once, on the merged result, so an unusable value resolves to its documented default and warns instead of throwing. - Non-numeric geometry no longer renders an invisible player.
data-height="tall"parsed toNaN, which sized the canvas to zero with an empty console.height,samples,barWidth,barSpacing,barRadius,bpmandplaybackRatenow fall back to their defaults (and clamp to sane ranges) with a warning naming the option. - Enumerated options are checked against their vocabularies.
layout,buttonStyle,artworkPosition,waveformStyle,waveformGradient,audioMode,preload,colorPresetandcrossOriginresolve to their defaults when unrecognised.crossOriginmattered most: any unrecognised value makes the browser fall back toanonymous, forcing a CORS request the caller never asked for. - The speed menu can no longer disagree with the audio. Rates outside the
supported range were rendered as buttons but clamped on click, so the label
read
10xwhile playback ran at the clamp and no menu item ever highlighted. Out-of-range rates are dropped from the menu. - Markers are validated per entry. A marker without a usable
timeproducedleft: NaN%and silently stacked at the left edge; a marker without alabelrendered the string"undefined"into its tooltip andaria-label. Both are now dropped/defaulted, including markers arriving from a peaks JSON sidecar. - Non-function callbacks (
onPlay,onEnd, …) are rejected at configuration time rather than throwing mid-playback.
Changed
Section titled “Changed”setPlaybackRatenow clamps to 0.25–4 (was 0.5–2), the range browsers keep audible — soplaybackRatescan offer the 2.5x/3x speeds podcast and audiobook UIs expect. The default menu is unchanged.playbackRatesaccepts a bare list.data-playback-rates="0.5, 1, 2"now works alongside the JSON array form; previously it failed to parse and was silently dropped.- Boolean options accept attribute-shaped strings.
showTime: 'false'reads asfalserather than truthy, which is what a wrapper that stringifies props actually means. - New internal coercion helpers (
toFiniteNumber,toArray,toNumberArray,toBool,toEnum) back all of the above;setWaveformDatanow shares them, so a peaks string with junk in it yields no peaks rather thanNaNones.
Thanks to @scruffian for raising declarative config validation in #22.
[1.24.5] — 2026-08-10
Section titled “[1.24.5] — 2026-08-10”- Caller-supplied peaks are drawn before the metadata wait, not after it.
load()assigned the<audio>src and awaitedloadedmetadatabefore applying thewaveformoption, so a slow or non-Range-capable audio origin left the canvas blank for as long as it took the first bytes to arrive — reported as ~1.7s. Inline peaks describe the file rather than the element’s playback state, so they now paint up front. Everything that genuinely needs duration (total time, markers) still waits. preload: 'none'no longer strandsload()permanently. That value tells the browser to fetch nothing untilplay(), soloadedmetadatanever fires and the awaited promise never settled: no title, no markers, noonLoad, and the loading state stuck on forever — even with peaks supplied inline. The wait is now skipped whenpreloadis'none'; the existingloadedmetadatalistener still fills in duration and re-renders markers whenever metadata does eventually land.
Changed
Section titled “Changed”- The loading indicator only appears while the canvas is empty. Flashing it
over an already-drawn waveform — the inline-peaks case, where the only wait
is the audio element fetching metadata — read as a flicker rather than as
progress. The decode path (no peaks supplied) still shows it.
aria-busycontinues to track the real loading state either way.
Thanks to @sporteka2 for reporting in #23.
[1.24.4] — 2026-08-10
Section titled “[1.24.4] — 2026-08-10”buttonAlignis validated at the point of use. The resolved alignment is interpolated into a class name, so an unrecognised value reached the markup from either configuration path. It is now coerced back toautoincreateDOM(), which covers constructor options anddata-button-alignalike — the documented values (auto,top,center,bottom) are unchanged.WaveformPlayer.init()no longer builds a second player over an element that already has one. The scan only recognised its owndata-waveform-initializedflag, so an element constructed programmatically (new WaveformPlayer(el)) but also carryingdata-waveform-playerwas re-initialized on the next scan. It is now skipped by instance lookup.
Thanks to @scruffian for reporting these in #22.
[1.24.3] — 2026-08-09
Section titled “[1.24.3] — 2026-08-09”Changed
Section titled “Changed”- Stopped shipping sourcemaps. The three
.mapfiles were 681KB of the package’s 1.2MB unpacked size — more than half of every install — and nothing could load them: esbuild’s--sourcemap=externalwrites the file but omits the//# sourceMappingURL=comment, so no bundler or devtools had a way to find them. The bundle bytes are unchanged, andsrc/still ships for anyone who wants to read the original source. Packed 330.7KB → 144.1KB, unpacked 1.2MB → 538.0KB.
[1.24.2] — 2026-08-09
Section titled “[1.24.2] — 2026-08-09”formatTime()rendered'Infinity:NaN:NaN'for a non-finite duration. A streamed or unseekable source reportsaudio.duration === Infinity, which is truthy, notNaNand not negative, so it slipped every guard —Infinity % 3600isNaN, and the literal string landed in the time display. Non-finite and non-numeric input now formats as'0:00'like the other invalid cases. Numeric strings are still coerced.
[1.24.1] — 2026-08-09
Section titled “[1.24.1] — 2026-08-09”- Auto-theme no longer reads an unpainted page as dark. A page that doesn’t
set a background on
<body>computes torgba(0, 0, 0, 0), and the old detector scored that as pure black — so the player picked the dark preset (white waveform, white controls) and went invisible on a white page. Theprefers-color-schemefallback was never reached, and re-detection hit the same short-circuit, so an OS theme switch didn’t recover it either. Reported by @sporteka2 in #21.
Changed
Section titled “Changed”-
Detection now reads the backdrop actually behind the player, not just
<body>: it walks the container’s ancestors up to<html>, alpha-compositing each background over the browser canvas. Three consequences worth knowing:- A player inside a themed card now matches the card rather than the page.
- Pages that paint their background on a wrapper
<div>or on<html>(both of which the old<body>-only read missed) are now detected properly. - Translucent layers are composited, so a
rgba(0, 0, 0, 0.05)scrim over white reads as light instead of as black.
-
Where the page paints nothing, the page’s own palette decides — not
prefers-color-scheme. The scheme is read from the resolved text colour (colorinherits thecanvastextsystem colour, which goes white exactly when the document’s used colour-scheme is dark). This is the only signal that catches the<meta name="color-scheme">form, which Chrome does not reflect into the computedcolor-schemeproperty — so the exact setup in #21 would be invisible to that.prefers-color-schemeis deliberately not used here, and now only serves as a last resort when no text colour resolves at all. It describes the user, not the page, and what a browser paints behind an unpainted page under a dark preference turns out to be engine-specific: screenshotting the same page in each, Chromium composites a dark#121212base background, while WebKit and Firefox both keep it white. No scripted signal distinguishes them, so a page whose text is black is treated as the light design it says it is — which matches two of the three engines.Consequence worth knowing, and it is Chrome-only: on a page that paints no background and never declares
color-scheme, viewed in Chrome with a dark OS preference, the player takes the light preset against Chrome’s dark base background. Such a page is already rendering its own body text black-on-#121212; declaringcolor-scheme(or settingcolorPreset) fixes both. Firefox and Safari paint that page white, where the light preset is correct. -
perceivedBrightness()now parsesrgb()/rgba()properly — including the modernrgb(0 0 0 / 50%)form — and returnsnullfor a fully transparent colour instead of scoring it as black. NewparseColor()helper alongside it. -
detectColorScheme(element)is exposed onWaveformPlayer.utilsso@arraypress/waveform-barcan share it instead of keeping its own copy (which had drifted, and carried this same bug).
Explicit colorPreset / data-color-preset and the dark / data-theme page
hints are unaffected — they still win before any of this runs.
[1.24.0] — 2026-07-30
Section titled “[1.24.0] — 2026-07-30”waveform-is-placeholderstate class. When waveform analysis fails and the player falls back to placeholder peaks (e.g. a CORS-blocked track), the container now gets awaveform-is-placeholderclass, removed again when the next load starts. Lets integrators detect the decorative waveform and restyle it (e.g. flat-line it into a plain progress bar). Ships no styles of its own — it’s purely a hook. Contributed by @Intenzi in #20; resolves #19.
[1.23.0] — 2026-07-22
Section titled “[1.23.0] — 2026-07-22”crossOriginoption. Controls the CORS mode of the self-mode<audio>element ('anonymous'|'use-credentials'|null), also settable withdata-cross-origin. Resolves #18.
Changed
Section titled “Changed”- The
<audio>element no longer setscrossOriginby default. Previously it was hard-coded to'anonymous', which forces a CORS request and breaks playback of media hosted on origins that don’t sendAccess-Control-Allow-Origin(a common default-config S3/CDN setup) — even though a plain<audio>would play the same URL. The player never needs CORS-clean media for playback (the element is never fed tocreateMediaElementSource; peak analysis uses a separatefetch()path with its own placeholder fallback), so this only ever broke playback. It now behaves like a native<audio>by default. PasscrossOrigin: 'anonymous'(ordata-cross-origin="anonymous") to restore the old behaviour.
[1.22.0] — 2026-07-17
Section titled “[1.22.0] — 2026-07-17”-
Artwork placement. New
artworkPositionoption ('info'|'button'), also settable withdata-artwork-position, chooses where theartworkimage renders.'info'(the default) is the existing behaviour — the cover sits in the info row beside the title.'button'fills the play/pause button with it instead, which still works when the info row is hidden. Only one placement ever renders, so the same cover can’t appear twice.'button'needs no other options. The cover is the control, so the stylesheet drops the button’s ring and uses cover-sized defaults (64px, 8px rounding) instead of the transport button’s 36px circle — those defaults are wrong for artwork in both directions, since 36px smudges a cover and 50% rounding crops square album art into a circle.buttonSize/buttonRadiusstill override it for a different tile.Button artwork is decorative: the image is hidden from assistive tech and the button keeps its own
aria-label. A broken cover falls back to the same placeholder tile as the info-row image. A scrim between the cover and the glyph keeps the glyph above the WCAG 3:1 non-text contrast minimum over any image — re-theme it with--wfp-btn-artwork-scrim/--wfp-btn-artwork-color, but re-theme both together or the contrast guarantee goes with it. -
Button corner radius. New
buttonRadiusoption, also settable withdata-button-radius, sets the play/pause button’s corner radius —0for a square button, or any CSS length (8,'0.5rem'). Defaults tonull, which leaves the stylesheet’s circular50%in place. Shapes thecirclebutton style; the bareminimalglyph has no box to round.
-
buttonSizeis no longer ignored on narrow screens. Themax-width: 480pxrule hardcodedwidth: 32px, which tied on specificity with the base.waveform-btnrule and won on source order — so abuttonSize: 64button silently collapsed to 32px on a phone. The rule now reads--wfp-btn-sizewith32pxas its fallback, so an unset button still gets the narrow-screen default while an explicit size is honoured. -
Author-supplied values are now escaped when the player first renders.
artist,artwork, andbuttonSizewere interpolated into the initial markup unescaped, so a quote in any of them — including viadata-artist,data-artwork, ordata-button-size— could close its attribute and inject markup. All values_build()interpolates now go throughescapeHtml. This aligns the first render with every other path in the player, which has always written this metadata viatextContent.
[1.21.0] — 2026-07-10
Section titled “[1.21.0] — 2026-07-10”Changed
Section titled “Changed”loadTrack()now reconciles artist & artwork metadata in place. PreviouslyloadTrack()could only update the artist text or artwork image when the corresponding element already existed, so switching to a track that added or removed artist/artwork meant destroying and recreating the whole player. It now adds, updates, or removes the artist<span>and artwork<img>directly (keepingartworkAltin sync), so consumers can swap tracks whose metadata presence differs without tearing down the instance or causing layout churn. Pass an empty artist ('') orartwork: nullto remove them, a value to add or update, or omit them to leave the existing DOM untouched. The artwork error-fallback listener is now bound through the player’s abort signal, sodestroy()tears it down. Thanks @jeryj.
[1.20.0] — 2026-07-05
Section titled “[1.20.0] — 2026-07-05”- Localizable seek value text (
seekValueText). New option templating the seek slider’s spokenaria-valuetext;%1$sis the current time and%2$sthe total duration (both formattedM:SS), with sequential%sand reordered positional args supported for translation. Defaults to'%1$s of %2$s', so existing output is unchanged. Also settable viadata-seek-value-text. Lets consumers localize the connective text without reformatting the times. - Localizable UI strings. The remaining hardcoded English strings are now
options (each also settable via
data-*), so non-English UIs can translate every screen-reader / lock-screen string:playPauseLabel(play buttonaria-label, default'Play/Pause'),speedLabel(speed button + menuaria-label, default'Playback speed'),artworkAlt(artwork<img>alt text, default'Album artwork'), andunknownTrackText(Media Session title fallback when notitleis set, default'Unknown Track'). Defaults reproduce the previous output, so existing behavior is unchanged.
[1.19.3] — 2026-07-02
Section titled “[1.19.3] — 2026-07-02”- Duplicate scrub time (readout + tooltip). During a seek-drag both the current-time display and the hover tooltip showed the same target time. The current-time readout now owns the live scrub time and the duplicate tooltip is suppressed while dragging (it still shows when there is no current-time readout to fall back on).
[1.19.1] — 2026-07-01
Section titled “[1.19.1] — 2026-07-01”Changed
Section titled “Changed”- Accessible playback-speed menu (#11). The speed selector is now a proper
role="menu"ofmenuitemradiooptions (aria-checkedreflects the active rate) with full arrow-key / Home / End navigation and focus management — keyboard- and screen-reader-complete, while keeping the custom styling (no native<select>).
- Multi-player Media Session hijack.
navigator.mediaSessionis a single global, but every player registered its action handlers (and metadata) at load, so on a page with several players the last one to load captured the lock-screen controls — play/pause and the scrubber drove the wrong track. A player now (re)claims the handlers and metadata when it starts playing, so the lock screen always controls the track you’re actually listening to.
[1.19.0] — 2026-07-01
Section titled “[1.19.0] — 2026-07-01”- Media Session track navigation. New
onNextTrack/onPreviousTrackoptions register lock-screen / Now-Playing skip-track buttons (called with the player). Wired automatically bywaveform-bar(queue) andwaveform-playlist(track nav).
- Space starts playback while the waveform itself is focused (#10). The seek slider swallowed Space, so play/pause only worked when the player root was focused. Reported by @jeryj.
- iOS lock-screen metadata. Media Session metadata was set once at load
(iOS ignores that) and
playbackStatewas never set — a blank Now-Playing card. It’s now re-asserted on play, withplaybackState+setPositionState. - Drag-seek double-fire. The synthetic click after a pointer drag re-seeked from a stale coordinate, which could snap the playhead (e.g. to the start) near the beginning of a track.
[1.18.0] — 2026-07-01
Section titled “[1.18.0] — 2026-07-01”- Gradient direction.
waveformColor/progressColorstop arrays now render along a configurable axis —waveformGradient: 'vertical'(default, top→bottom),'horizontal'(a hue sweep across the whole waveform) or'diagonal'(alsodata-waveform-gradient).
Changed
Section titled “Changed”- BREAKING — renamed the
subtitleoption toartist(data-subtitle->data-artist, the.waveform-subtitleclass ->.waveform-artist, and theloadTrack(url, title, artist, ...)parameter). Aligns with the bar/playlist and the standard “title + artist” convention. No back-compat alias. - BREAKING — DOM chrome colours moved to CSS variables. Removed the
buttonColor,buttonHoverColor,textColor,textSecondaryColor,backgroundColorandborderColoroptions (and theirdata-*attributes; the last three were already dead). Theme the button/title/meta text via--wfp-button-color/--wfp-text-color/--wfp-text-secondary-colorin CSS instead — the player adds a.waveform-theme-lightclass when it detects a light page. Canvas colours (waveformColor/progressColor) stay options. - Live time while scrubbing. Dragging to seek now updates the time readout
and a tooltip live at the drag position (Spotify-style) instead of only on
release. The tooltip appears on any waveform style during a drag (the seekbar
handle stays seekbar-only);
showHoverTimestill governs the hover tooltip.
[1.17.0] — 2026-07-01
Section titled “[1.17.0] — 2026-07-01”- Seekbar drag-to-scrub. Press and drag on the waveform/seekbar to scrub; the seek commits on release, so audio keeps playing during the drag instead of re-seeking on every move.
- Opt-in circular seek handle on the
seekbarstyle —seekHandle(defaultfalse, alsodata-seek-handle). A draggable handle that expands on hover; the bar turns it on.
Changed
Section titled “Changed”- Hardened peak extraction. Replaced
Math.max(...peaks)with a loop-based max (noRangeErroron very large arrays) and centralised the default sample count in a sharedDEFAULT_SAMPLES(1800) constant so live extraction andDEFAULT_OPTIONScan’t drift. - Playback-speed menu accessibility. The speed toggle now sets
aria-haspopup/aria-expanded(so the open/closed state is announced), closes onEscape, and returns focus to the trigger after a rate is chosen. Kept as a lightweight disclosure — the options remain plain buttons in the tab order, no ARIA-menu machinery.
- Activating a control no longer steals keyboard focus onto the player wrapper. Clicking/activating the play button — or any interactive control (slider, link, input) — now keeps focus on that control; the container only takes focus when the click lands on a non-interactive area. (Reported in #10.)
[1.16.1] — 2026-06-30
Section titled “[1.16.1] — 2026-06-30”Changed
Section titled “Changed”- Active-marker label now flashes then fades instead of staying visible the
whole time the playhead is past a marker. When the playhead reaches a marker
its label appears for ~2.5s and then fades out, while the highlight stays.
Driven by a
.show-labelclass the player adds/removes, so it’s CSS-overridable: keep it on with.waveform-marker.active .waveform-marker-tooltip { opacity: 1 }or hide it with{ opacity: 0 !important }.
[1.16.0] — 2026-06-30
Section titled “[1.16.0] — 2026-06-30”- Hover-time tooltip. With
showHoverTime: true(data-show-hover-time), a tooltip follows the pointer over the waveform showing the time at that position. The option was previously parsed but did nothing — it’s now wired up (works in both self and external modes). - Active markers. As playback passes a marker, the player highlights it and
reveals its label automatically — it drives the existing
setActiveMarker()from the progress loop (self and external modes), so chaptered tracks and DJ cues light up as you reach them. - Artwork fallback. When the
artworkimage fails to load (404 / broken URL), the player now shows a muted music-note placeholder tile instead of the browser’s broken-image icon.
index.d.ts@defaultJSDoc synced with the runtime. The hand-written type file still annotatedheightas@default 60andbarRadiusas@default 0;DEFAULT_OPTIONSis64and1. Corrected so IDE tooltips match actual behaviour. Documentation-only — no type or runtime change.
[1.15.0] — 2026-06-30
Section titled “[1.15.0] — 2026-06-30”- Live-decoded waveforms are now accurate.
extractPeaksinspected only ~1 in 10 frames per window (a real-time speed shortcut), which missed transients and made the waveform shape shift noticeably when the sample count changed. It now scans every frame — matching WaveformGen’s offline output exactly (same shape, not just same amplitude), so a live decode and a pre-generated.jsonrender identically.decodeAudioDatadominates the cost, so the full scan is effectively free.
Changed
Section titled “Changed”samplesdefault raised 256 → 1800 (the SoundCloud / WaveformGen figure). It is the source peak resolution for live decode only (ignored whenwaveformpeaks are supplied), resampled down to the visible bar count. At 256, any waveform wider than ~256 bars — common on wide or high-DPI displays — was upsampled and looked blurry; 1800 keeps it crisp. Paired with the every-frame scan it costs no extra extraction time. Override withsamples/data-samples.
[1.14.0] — 2026-06-28
Section titled “[1.14.0] — 2026-06-28”buttonSizeoption (+data-button-size) to size the play/pause button. A number is treated as px; a string (e.g.'4rem') is used verbatim. It sets a new--wfp-btn-sizeCSS variable that scales both button styles —circleandminimal, box and glyph — proportionally from a single value, so there are no per-style magic numbers left to drift. The default reproduces the prior 36px sizing exactly. Forwarded by the Astro/React wrappers.
- Minimal play button glyph size now actually applies.
.waveform-btn svg(16px) had equal specificity but later source order than the minimal-button rule, so it silently won — the glyph was pinned at 16px no matter what the minimal rule said (1.13.0/1.13.1 changes had no visible effect). The rule is now.waveform-btn.waveform-btn-minimal svg(higher specificity), and both button styles derive their size from--wfp-btn-size.
[1.13.1] — 2026-06-28
Section titled “[1.13.1] — 2026-06-28”Changed
Section titled “Changed”- Minimal play button (
buttonStyle: 'minimal') now renders the bare play/pause glyph at the same visual size as the default circle button — the glyph was noticeably smaller than the ring it replaces. Larger fixed box (2.5rem) + 36px glyph; still a fixed width so the waveform never shifts on play/pause.
[1.13.0] — 2026-06-28
Section titled “[1.13.0] — 2026-06-28”- Live theme switching. Auto-themed players (no explicit
colorPresetor hand-set colours) now re-detect the page theme and redraw on a runtime light/dark switch — a class/attribute flip on<html>/<body>(Tailwinddark,data-theme,data-color-scheme) or an OSprefers-color-schemechange. Previously the palette was fixed at load, so a toggle left the player stuck in the old theme (invisible waveform/buttons). Event-driven (one sharedMutationObserver+matchMedia, no polling), with a publicrefreshTheme()to trigger it manually. Explicit presets/colours are never overridden.
[1.12.1] — 2026-06-28
Section titled “[1.12.1] — 2026-06-28”Changed
Section titled “Changed”- Larger
minimalbutton glyph. The bare play/pause glyph read too small; bumped the fixed button box to 2.25rem and the icon to 28px (still a fixed box, so it doesn’t shift the waveform on play/pause).
[1.12.0] — 2026-06-28
Section titled “[1.12.0] — 2026-06-28”bpmoption (data-bpm). Show a known BPM in the badge (withshowBPM) without decoding the audio — ideal when peaks are pre-generated but the tempo is known (e.g. sample-pack previews). A supplied value wins over detection.
- Minimal button no longer shifts the waveform on play/pause. The
buttonStyle: 'minimal'button was auto-width, so swapping the play glyph (optically nudged 1px) for the pause glyph changed its width — moving the adjacent waveform and re-sampling its bars on every toggle. It’s now a fixed box, so the canvas stays put.
[1.11.0] — 2026-06-28
Section titled “[1.11.0] — 2026-06-28”buttonStyleoption ('circle' | 'minimal', plusdata-button-style).'minimal'renders a bare play/pause glyph with no circle — the look sample-pack and beat stores use in their preview grids. Composes with thepreviewlayout.
Changed
Section titled “Changed”- Default look refresh. The
mirrorstyle now usesbarSpacing: 2(distinct thin bars rather than a solid fill); the defaultbarRadiusis1(soft caps), defaultsamplesis256(more source fidelity — resampled to fit the bar pitch), and defaultheightis64. Players that set these explicitly are unaffected.
[1.10.0] — 2026-06-28
Section titled “[1.10.0] — 2026-06-28”previewlayout (layout: 'preview'/data-layout="preview") — centers the title under the waveform and trims the meta row, for sample-pack previews and dense grids.
Changed
Section titled “Changed”- Monochrome by default (shadcn). The keyboard-focus ring and speed-menu
active state are now neutral, driven by a new
--wfp-accentCSS variable (default zinc). Set--wfp-accentto a brand hue to re-tint.
[1.9.0] — 2026-06-28
Section titled “[1.9.0] — 2026-06-28”WaveformPlayer.utils.parseDataAttributesexposed on the utils bridge, so wrappers (waveform-bar, waveform-playlist) can read the player’s fulldata-*option surface off a host element without re-implementing — and drifting from — the contract.
[1.8.0] — 2026-06-27
Section titled “[1.8.0] — 2026-06-27”-
TypeScript definitions — the package now ships a hand-authored
index.d.ts(wired through atypesexport condition), so bundler/IDE users get full IntelliSense and type-checking with zero runtime bytes. It is the single source of truth for the option surface, theWaveformPlayerclass API, and a typed custom-event map (addEventListener('waveformplayer:timeupdate', …)now typese.detail). The React/Astro wrappers can re-export from it instead of re-declaring the option list. -
Dual ESM + CommonJS build — added a
dist/waveform-player.cjsbundle and arequireexport condition, sorequire('@arraypress/waveform-player')works under Node CJS (previously ESM-only). External sourcemaps now ship for the ESM, CJS, and minified IIFE bundles. New./styles.cssexport alias. -
Accessibility polish (near-zero footprint): a
@media (prefers-reduced-motion: reduce)guard that neutralizes transitions/animations,role="alert"on the error node, andaria-busytoggled on the seek slider while loading. -
CSS custom properties for spacing —
--waveform-line-height(1.4),--waveform-body-gap(8px), and--waveform-track-gap(12px). Lets embedders (e.g.@arraypress/waveform-bar) retune layout without!importantoverrides of internal classes. Defaults are unchanged. -
Expressive bar styling (bundle-neutral):
barRadiusfor rounded bar caps (bars/mirror; falls back to square whereroundRectis unavailable), and gradient fills —waveformColor/progressColornow also accept an array of CSS colour stops (e.g.['#fafafa', '#71717a']) rendered as a vertical canvas gradient. Both work via constructor options,data-*attributes (gradients as a JSON array), and the React/Astro wrappers. -
Lifecycle + event-contract completeness (so controllers/analytics stop reaching into internals):
waveformplayer:destroyevent — the symmetric counterpart to:ready; lets listeners release references on teardown.loadTrack(url, title, artist, { autoplay: false })— load / restore / enqueue without forcing playback.waveformplayer:endednow carries{ currentTime, duration }, and is synthesized inexternalmode when progress reaches the end (fires once).request-play/request-pause/request-seekdetail now includesmarkersandwaveform, so controllers don’t re-fetch them.parseDataAttributesnow readsdata-audio-mode,data-show-markers,data-accessible-seek,data-seek-label,data-play-icon,data-pause-icon(previously silently inert on every auto-init path).- Shorthand aliases —
style/data-styleforwaveformStyle, andsrc/data-srcforurl(e.g.<div data-waveform-player data-src="t.mp3" data-style="bars">). The canonical names still work and win if both are set.
-
Controller-support helpers (so
@arraypress/waveform-barand other external controllers stop reaching into internals / shipping divergent copies):player.setActiveMarker(index | null)— highlight the current marker via a core-owned.waveform-marker.activeclass instead of poking the marker DOM.WaveformPlayer.utils— a static bridge exposingformatTime,extractTitleFromUrl,escapeHtml, andisSafeHref(allow-listshttp/https/relative URLs; rejectsjavascript:/data:script schemes).setVolume()now coerces + guards non-finite input (no moreNaNreachingaudio.volume).- The
request-*event detail now always includes anartistfield, so the published contract is self-consistent.
-
errorTextoption (default'Unable to load audio') — customize/localize the message shown when audio fails to load. Escaped before render. Also settable viadata-error-text.
Fixed (additional)
Section titled “Fixed (additional)”- Type hygiene in
audio.js: cast thewebkitAudioContextfallback (silences ts2568) and dropped a no-opawaiton the synchronousdetectBPM().
- AudioContext leak on failed decode —
generateWaveform()now closes the context in afinallyblock. Browsers hard-cap live AudioContexts (~6 in Chrome), so leaking one per failed load could break every later player on a catalogue page. destroy()listener leak — all document/container/seek listeners are now registered against anAbortControllerand torn down ondestroy(). The old teardown left the outside-click and container listeners attached.- Constructor crash in external mode —
audioMode: 'external'combined withshowPlaybackSpeed: truethrew on init (dereferencing the null<audio>);updateSpeedUI()now no-ops without audio. onTimeUpdateargument order — external mode previously fired(player, currentTime, duration); it now matches self mode’s documented(currentTime, duration, player)so one handler works in both modes. ⚠️ Behavior change for external-mode consumers relying on the old order.- Markers in external mode —
renderMarkers()now uses a mode-agnostic duration, so chapter markers render when audio is delegated. - Accessible seek in external mode — the duration is now published
unconditionally, so keyboard seeking / the ARIA slider work even when
showTimeis off. generateId()— hashes the full URL (+ a counter) instead of a 10-charbtoa()prefix: no more collisions for same-host tracks and no throw on non-Latin1 / Unicode URLs.formatTime()— addsH:MM:SSrollover past one hour and clamps negatives (also fixesaria-valuetext).- Autoplay — the autoplay
play()no longer emits an unhandled promise rejection when the browser blocks it.
[1.7.2] — 2026-06-27
Section titled “[1.7.2] — 2026-06-27”-
Accessible seek slider — the waveform surface (
.waveform-container) is now exposed as a keyboard-operable ARIA slider:role="slider", focusable in the tab order, witharia-valuemin/aria-valuemax/aria-valuenowand a readablearia-valuetext(e.g."0:30 of 2:00") kept in sync on metadata load,timeupdate, and externalsetProgress(). When focused it handles the standard slider keys — ←/↓ and →/↑ (±5s), Page Up/Page Down (±10s), Home/End — callingpreventDefault()(no page scroll). Works in bothself(callsseekTo()) andexternalmode (dispatcheswaveformplayer:request-seek, exactly like click-to-seek). Addresses WCAG 2.1.1 (Keyboard) and 4.1.2 (Name, Role, Value). Resolves #8; upstreamed from WordPress/Gutenberg’s external accessibility layer (#9).Two new options:
accessibleSeek(boolean, defaulttrue— setfalseto opt out and keep the prior markup) andseekLabel(string, defaultnull— accessible name for the slider, falls back to the track title, then'Seek').The existing container-level keyboard shortcuts (number-key seek, space, volume on ↑/↓, mute) are untouched; they live on the outer container and only run when it is the active element, so they don’t collide with the slider on the inner element.
[1.7.1] — Unreleased
Section titled “[1.7.1] — Unreleased”-
package.jsonexports field — added"type": "module", pointedmainatdist/waveform-player.esm.js(was IIFE bundle with no exports), and added a properexportsmap so SSR / Node consumers canimport { WaveformPlayer } from '@arraypress/waveform-player'without “does not provide an export named ‘WaveformPlayer’” errors.Browser direct-script usage via the
unpkgfield ordist/waveform-player.js?urlimports is unaffected — the IIFE bundle stays on disk for<script>tag and Vite asset-URL loading.
[1.7.0] — 2026-05-23
Section titled “[1.7.0] — 2026-05-23”-
WaveformPlayer.getPeaksUrl(audioUrl)— static helper that derives a peaks-JSON URL from an audio URL by swapping the extension (.mp3 / .wav / .ogg / .flac / .m4a / .aac→.json). Pair with@arraypress/waveform-gento pre-generate peaks at build time and skip the Web Audio decode pass at runtime — big perf win on catalogues with many tracks.Preserves query strings and URL fragments. Returns
undefinedfor empty input or unrecognised extensions so callers can pass through unconditionally:new WaveformPlayer('#el', {url: track.audioUrl,waveform: WaveformPlayer.getPeaksUrl(track.audioUrl),});
[1.6.0] - 2026-05-13
Section titled “[1.6.0] - 2026-05-13”New Features
Section titled “New Features”-
External audio mode (
audioMode: 'external') — turns a WaveformPlayer instance into a visualization-only surface that delegates playback to an external controller (e.g.@arraypress/waveform-bar). The player renders the canvas + scrubber + play button as usual, but skips creating its own<audio>element. Play / pause / seek interactions dispatch cancelablewaveformplayer:request-play,waveformplayer:request-pause, andwaveformplayer:request-seekevents on the container; the controller listens and routes the action to its own audio source.Pair with WaveformBar 1.3+: any
[data-waveform-player][data-audio-mode="external"]element with a matching URL becomes a synced visual mirror of bar playback (canvas scrubs in real time, play/pause icon reflects bar state).<div data-waveform-playerdata-audio-mode="external"data-url="song.mp3"data-waveform-style="bars"></div>new WaveformPlayer(el, { audioMode: 'external' });
player.setPlayingState(playing)— external-state pump for the play/pause visual state. Toggles the play/pause icon, starts/stops the smooth-update RAF, dispatcheswaveformplayer:play/:pauseso existing listeners still fire.player.setProgress(currentTime, duration)— external-state pump for the scrubber + canvas + time displays. Drives the visualization from an external clock without touching audio.
Backward Compatibility
Section titled “Backward Compatibility”Fully additive. audioMode defaults to 'self' — every existing instance behaves exactly as before. The new event dispatchers and setPlayingState / setProgress methods only fire in external mode, so they can’t disturb self-mode callers.
[1.5.2] - 2026-03-22
Section titled “[1.5.2] - 2026-03-22”- JSON waveform files can now include
markers— automatically loaded if no markers are set via data attributes
[1.5.1] - 2026-03-22
Section titled “[1.5.1] - 2026-03-22”- Drawing style aliases:
bar,block,dotnow accepted alongsidebars,blocks,dotsforwaveformStyle
[1.5.0] - 2026-03-22
Section titled “[1.5.0] - 2026-03-22”New Features
Section titled “New Features”- JSON Waveform Loading —
data-waveformnow accepts a URL to a JSON file ending in.json- Fetches the file and reads peaks from the response (supports
[...]array or{ peaks: [...] }object) - Falls back gracefully if fetch fails (player generates waveform from audio as before)
- No changes to constructor,
load(), orloadTrack()— fully backwards compatible
- Fetches the file and reads peaks from the response (supports
<!-- JSON file instead of inline peaks --><div data-waveform-player data-url="song.mp3" data-waveform="waveforms/song.json"></div>
<!-- WaveformBar — same attribute --><div data-wb-play data-url="song.mp3" data-wb-waveform="waveforms/song.json"></div>
<!-- Inline peaks still work exactly as before --><div data-waveform-player data-url="song.mp3" data-waveform="[0.2, 0.37, 0.41, ...]"></div>[1.4.1] - 2026-03-22
Section titled “[1.4.1] - 2026-03-22”Reverted
Section titled “Reverted”- Reverted JSON config file feature (
data-config) introduced in 1.4.0 due to breaking changes with WaveformBar integration. The feature caused layout and sizing issues when the player was used inside WaveformBar. Will be revisited in a future release with proper integration testing.
[1.4.0] - 2026-03-22 [YANKED]
Section titled “[1.4.0] - 2026-03-22 [YANKED]”New Features
Section titled “New Features”- JSON Config Files — Load track configuration from external JSON files via
data-configattribute- Single attribute setup:
<div data-waveform-player data-config="waveforms/track.json"></div> - JSON supports
url,title,artist,artwork,album,samples,peaks,markers, andmeta - Priority order: JSON config (base) → data attributes (override) → JS options (override)
- Config files are cached in memory — subsequent loads of the same file are instant
- Works with
loadTrack()viaoptions.configfor dynamic track loading metaobject passes through for use by extensions (e.g. WaveformBar readsmeta.bpm,meta.key)
- Single attribute setup:
JSON Config Format
Section titled “JSON Config Format”{ "url": "audio/track.mp3", "title": "Track Title", "artist": "Artist Name", "artwork": "covers/artwork.webp", "samples": 200, "peaks": [ 0.2, 0.37, 0.41, ... ], "markers": [], "meta": { "bpm": "128", "key": "Am" }}Generate config files with @arraypress/waveform-gen:
npx @arraypress/waveform-gen ./audio/*.mp3 --output ./waveforms/[1.3.5] - 2026-03-17
Section titled “[1.3.5] - 2026-03-17”Bug Fixes
Section titled “Bug Fixes”- Fixed marker elements from previous track persisting when loading a new track without markers
[1.3.4] - 2026-03-17
Section titled “[1.3.4] - 2026-03-17”Bug Fixes
Section titled “Bug Fixes”- Fixed markers from previous track persisting when loading a new track without markers via
loadTrack()
[1.3.3] - 2026-03-17
Section titled “[1.3.3] - 2026-03-17”Bug Fixes
Section titled “Bug Fixes”- Removed inline
style.heighton canvas element that prevented it from filling available width in flex containers. Container div still controls height.
[1.3.2] - 2026-03-17
Section titled “[1.3.2] - 2026-03-17”Bug Fixes
Section titled “Bug Fixes”- Fixed waveform canvas not filling available width when embedded in flex containers (e.g. persistent bottom bars).
resizeCanvas()now reads width from the container div instead of the canvas element.
[1.3.1] - 2026-03-17
Section titled “[1.3.1] - 2026-03-17”Bug Fixes
Section titled “Bug Fixes”- Fixed uncaught
NotAllowedErrorwhenloadTrack()triggers autoplay before user interaction (e.g. on session restore). Theplay()promise returned since v1.2.2 was not being caught internally.
[1.3.0] - 2026-03-16
Section titled “[1.3.0] - 2026-03-16”Features
Section titled “Features”showControlsoption - Hide the play/pause button for custom UI implementations (data-show-controls="false")showInfooption - Hide the title, artist, time, and metadata bar (data-show-info="false")- Both options work via HTML data attributes or JavaScript API
- Waveform automatically fills the full width when controls are hidden
<!-- Waveform only, no button or info --><div data-waveform-player data-url="song.mp3" data-show-controls="false" data-show-info="false"></div>[1.2.2] - 2026-02-28
Section titled “[1.2.2] - 2026-02-28”Bug Fixes
Section titled “Bug Fixes”play()now returns the Promise fromHTMLMediaElement.play(), allowing callers to handle errors likeAbortError
Thanks to @scruffian for the contribution.
[1.2.1] - 2026-02-14
Section titled “[1.2.1] - 2026-02-14”Bug Fixes
Section titled “Bug Fixes”- Fixed null reference error when
destroy()is called during resize events - Cleaned up window resize listener on destroy to prevent memory leaks
- Added destruction guards to all event handlers to prevent race conditions
- Added
bubbles: trueto all custom events for better framework integration
Thanks to @scruffian for contributing these fixes.
[1.2.0] - 2025-10-19
Section titled “[1.2.0] - 2025-10-19”Features
Section titled “Features”- Automatic Theme Detection - Player now automatically adapts to your website’s color scheme
- Detects light/dark themes automatically
- Checks background brightness, theme classes, and system preferences
- Works seamlessly on WordPress, Shopify, and all platforms
- Override with explicit
data-color-preset="light"or"dark"if needed
[1.1.0] - 2025-09-15
Section titled “[1.1.0] - 2025-09-15”Features
Section titled “Features”- 6 visual styles: bars, mirror, line, blocks, dots, seekbar
- BPM detection
- Waveform caching with pre-generated data
- Keyboard controls
- Media Session API integration
- Speed control
- Chapter markers
- Dynamic track loading
[1.0.1] - 2025-09-15
Section titled “[1.0.1] - 2025-09-15”Bug Fixes
Section titled “Bug Fixes”- Initial patch release
[1.0.0] - 2025-09-15
Section titled “[1.0.0] - 2025-09-15”Initial Release
Section titled “Initial Release”- Zero-config audio player with waveform visualization
- HTML data attribute API
- JavaScript API
- ~8KB gzipped, zero dependencies
- Framework agnostic (React, Vue, Angular, vanilla JS)