Core, framework-agnostic vanilla-JS audio player with canvas waveform visualization.
It is the foundation the rest of the @arraypress waveform family builds on.
npm test— vitest + jsdom (run this before committing).npm run test:visual— opt-in browser check for auto-theme detection: renders the player across a matrix of page shapes in real engines and measures the painted pixels. Needs Playwright (npm i -D playwright && npx playwright install chromium webkit), so it's deliberately outsidenpm test/prepublishOnly. Run it whenever you touch theme detection — seetest/visual/README.mdfor what it proves and the engine findings behind it.npm run build— builds all dist targets (css, iife, esm, cjs, min, plus the twono-autoinitbundles).prepublishOnlyrunstest && build.npm run test:pack— packs the tarball, installs it into a throwaway consumer and drives the published artifacts: both subpaths resolving as ESM and CJS under plain Node, each bundle's scan-or-don't behaviour under jsdom, andtscundermoduleResolutionnode16andbundler. Run it whenever you touchexports,files, a build script or a.d.ts—npm testimportssrc/, so none of those failures are visible to it. It caught an extensionless re-export inno-autoinit.d.tsthat onlynode16rejects, which is the same shape as the bug that needed@arraypress/seo-astro2.2.1.npm run size— gzipped JS/CSS sizes. Currently 15,412B JS / 1,817B CSS gzipped (1.25.1 shipped ~13.8KB; the auto-init opt-out +init(root)+ registry fix added 78B; the 1.27.0 entry-point split added 8B; the 1.27.1load()url fix added 5B; the September 2026 audit fixes — load tickets, the shared per-track reset, sidecar fallback, backgroundtimeupdate, Media Session position sync and the rest of that batch — added 477B; the shadow-DOM / early-peaks fixes andutils.extractPeaksadded 142B; the no-byte-range seek fallback added 583B). Ceiling is 20KB JS (20,480B) — the owner raised it from ~14KB (14,336B) on 2026-09-24 to prioritise the audit's correctness fixes over byte-shaving — so 5,068B of headroom. Still measure before you add, don't estimate: the headroom is for correct fixes, not a licence for features. PR #29 (showAlbum) was declined when it put the old ceiling 22B over; that call was about the wrapper sweep as much as bytes (see thealbumgotcha). (The option-normalization pass cost +880B — that was a deliberate, measured trade for turning two hard crashes and a class of silent blank-player failures into warnings.) (The old "~10KB budget" sat below actual for several releases, so it could never flag anything — keep this figure current when it moves, or it rots the same way. The marketing site quotes it too:waveform-site/src/data/packages.ts.)npm run dev— watch build (rebuilds dist while you test against a local HTML page).
core.js— theWaveformPlayerclass (the bulk: DOM build, audio, seek, lifecycle,loadTrack).audio.js—generateWaveform/extractPeaks/normalizePeaks/ placeholder peaks.drawing.js— canvas draw functions, one perwaveformStyle(drawBars,drawMirror,drawSeekbar, …);DRAWING_STYLESmaps style → fn.themes.js—DEFAULT_OPTIONS(the option surface) + color presets + per-style bar sizing.utils.js—parseDataAttributes(thedata-*contract),clamp,escapeHtml,isSafeHref,extractTitleFromUrl, etc.bpm.js— BPM detection.- Two entry points, one difference.
entry.jsis the whole public surface — class,WaveformPlayer.utilsbridge,init, thewindowglobal — and is what@arraypress/waveform-player/no-autoinitbuilds from.index.jsimports it and adds the one thing it omits: the import-time[data-waveform-player]scan. Anything new belongs inentry.js; put it inindex.jsand/no-autoinitquietly becomes a smaller library than the one it documents.test/no-autoinit.test.jscompares the two public surfaces andtest/package.test.jspins which source file each bundle is built from.
index.d.tsis hand-written — when you add/rename aDEFAULT_OPTIONSkey, updateindex.d.tsin the same change, or the typed wrappers drift.- Two configuration paths, kept in sync: JS constructor options and
data-*attributes (parseDataAttributes). A new option usually needs both. - Option aliases:
style→waveformStyle,src→url(canonical wins). Normalized in the constructor +parseDataAttributes. - Logging: every
console.*and thrown message is prefixed[WaveformPlayer]. - Events: dispatch through the single
_emit()path (don't hand-rolldispatchEvent). Event details are typed inindex.d.ts(WaveformPlayerEventMap). audioMode:'self'owns an<audio>;'external'is visualization-only, driven bysetPlayingState()/setProgress()and emitswaveformplayer:request-*events. Tests use'external'to avoid Web Audio in jsdom.- Comprehensive JSDoc on public methods; match the existing density.
- Auto-theme detection is measured, not reasoned about.
detectCanvasSchemereads the page's resolved text colour and deliberately does not useprefers-color-scheme(except as a last resort) — what a browser paints behind an unpainted page under a dark preference is engine-specific: Chromium composites#121212, WebKit and Firefox stay white, and Chromium'sCanvassystem colour contradicts its own paint, so no script can tell them apart. Changing this needsnpm run test:visual, not an argument; there's a guard test intest/themes.test.jsand the full story intest/visual/README.md. - There are two auto-init opt-outs, for two kinds of consumer — don't collapse them.
data-waveform-autoinit="false"on<html>is for a page that owns its own document element.@arraypress/waveform-player/no-autoinitis for a bundle dropped into a page it doesn't author (a CMS block, a plugin, a widget), which has no<html>in scope to mark before its staticimportevaluates — WordPress core's Playlist block patched the shipped bundle for a release to get there. The attribute cannot serve the second case and the entry point is overkill for the first; both stay. - The auto-init opt-out is deliberately not an option.
data-waveform-autoinit="false"on<html>suppresses the import-time scan (src/js/index.js). It's read off the document, not a global, because static ESM imports evaluate before the importing module's body runs; and it gates the automatic call, notautoInit()itself, soWaveformPlayer.init(root)still works by hand. Both choices have mutation-checked tests intest/index.test.js. Don't fold it intoDEFAULT_OPTIONS/parseDataAttributes— it's read before any player exists, and making it an option would drag it through the 15-package wrapper sweep for nothing. - The info block is title + artist, deliberately —
albumis not a third line.albumexists inDEFAULT_OPTIONSandparseDataAttributesonly to fill out the Media Session card (_applyMediaMetadata,core.js:1025); it is lock-screen data, not player chrome. Declined in #29 — the patch was clean and green, but it cost +161B gzipped (22B over the ceiling), and one new boolean drags the whole 15-package wrapper sweep behind it. The playlist made the same call in the other direction, narrowingshowSubtitle/data-subtitleintoshowArtist/data-artist..waveform-layout-previewalso hides.waveform-metaoutright to stay compact, so a third line fights that layout's whole purpose. If a caller needs the release name,artist: "The Artist · The Album"costs nothing; if it needs to be structural, it belongs inwaveform-playlist's hero (an album is a playlist — it already hasshowArtistand already receivesalbumper-track). Note core has noshowArtisteither: artist renders whenever it's set. Don't addshowAlbumwithout also settling that asymmetry. loadTrackmust reset per-track options. It resetsmarkers,this.options.waveform(since 1.8.1),bpmandalbum.mergeOptionsskips null sources, so a track loaded without peaks would otherwise redraw the previous track's waveform. If you add per-track options (artwork/markers/peaks/bpm), reset them inloadTracktoo. Per-track state (peaks, progress, external duration, time readout, marker DOM) lives in_resetTrack(), shared byloadTrack()and aload()of a different URL; the error state is cleared by everyload().- Every
load()takes a ticket (this._loadId). Anything that resumes after anawaitinside a load — metadata wait, sidecar fetch, decode,loadTrack()'s autoplay — must check it's still the current load before touching state;destroy()bumps it too. Add a newawaitto that path without the check and a superseded track can land on the one that replaced it. - Shadow DOM is a supported host (web components, the Waveform Anywhere extension). Never compare
document.activeElementwith the player's elements — use_activeElement()(the root node's); ancestor walks must step from a shadow root to itshost(seecollectBackdrop). Both have tests in a shadow root. setWaveformData()writesoptions.waveform: supplied peaks are the track's peaks, so the first-frameload()and a decode that finishes later both leave them alone (load()compares against theinlinePeaksit started with).loadTrack()still resets them per track.- jsdom has no
AudioContext→generateWaveformfalls back to a placeholder; that warning in test output is expected. - Canvas redraw is driven by a
ResizeObserveron the canvas's parent +resizeCanvas(); a DOM move doesn't reliably trip it — callresizeCanvas()explicitly if you relocate the player. - Hosts that ignore byte ranges (answer
Rangewith200+ whole file, noAccept-Ranges: Cloudflare Pages and Workers static assets, many plain servers). Chromium can't seek such media at all —seekablestays0–0in every state, even fully downloaded — which was the "long tracks snap back to 0" symptom. Since 1.29.1_seekAudio()(the one pathseekTo/seekToPercenttake) diverts to_seekViaCache(): finish the download (preloadraised toauto, restored),load()from the HTTP cache (seekable), seek, resume._seekReloadmakesonPlay/onPauseswallow the reload's pause/play so consumers see only a seek. It only diverts whenseekablepositively excludes the target (an emptyseekable= unknown, seek as before), once per URL, and_resetTrack()cancels a pending one. WebKit/Firefox seek natively and never divert. Verified against the live Cloudflare-hosted site files; the proper fix remains a range-capable host (R2/S3/nginx) — docs: Waveform data → Hosting.
- Does
loadedmetadataever fire twice? Reported indirectly in #28 (closed, declined): a MutationObserver caught.time-totalwritten twice with the same value. In self modeonMetadataLoadedis the only writer, so that implies a duplicate event. Not reproduced, and no path incore.jsproduces it — the listener is bound once (:1095),load()'s one-shot only resolves a promise and removes itself, andloadTrack()'s reset (:1432-1435) setssrc = ''thenload(), which can't emitloadedmetadatawith no resource to read. Most likely his page (a secondload()/loadTrack(), awaveform-barinstance alongside an inline player, or the browser re-firing). To replicate: counter onloadedmetadatain a real browser across (a) a normal declarative load, (b) aloadTrack()swap, (c) a page with the bar driving an inlineexternalplayer. jsdom won't show this — it needs a real media element. If it's confirmed, guard the wholeonMetadataLoadedbody on a last-processed duration — the text write is the cheap part;renderMarkers()clears and rebuilds every marker button, which is the actual waste. #28 proposed guarding only the.time-totalassignment, i.e. the least significant line in the method. Don't take that shape. If it isn't reproducible, delete this entry rather than leaving a guard "just in case".
This package's option surface is the root of a 15-package family. All 20 repos are flat siblings; each is its own git repo on main.
waveform-bar— persistent bottom-bar singleton (window.WaveformBar.init(config)); embeds one self-mode player, drives inlineexternal-mode players viadata-wb-*triggers. Ships no.d.ts— its wrappers hand-declare the config type, so adding a bar config option means updating those wrapper types manually.waveform-playlist— multi-track playlist around embedded players; forwards this package's options through to each.- Wrappers, 4 per core × 3 cores (
-astro/-react/-svelte/-vuefor player, bar, playlist). Player and playlist wrappers derive prop types from this package'sWaveformPlayerOptionsviaOmit<>, exceptstyle(which stays the framework's CSS prop — usewaveformStyle). Bar wrappers passconfigverbatim toinit(). waveform-gen/waveform-tracker— no shared option surface; unaffected by option changes here.
Types flow, runtime does not. Every wrapper forwards options through an explicit, hand-maintained allowlist. Adding a key to DEFAULT_OPTIONS + index.d.ts makes it typecheck everywhere and work nowhere until each wrapper is edited.
Order matters — core first, then dependents (their peer dep is @arraypress/waveform-player@^1.x):
npm publishhere (no bump if package.json version already > npm).- Dependents:
npm version <patch|minor>→npm publish→git push --follow-tags. Keep dependents' peer ranges (^1.x) able to satisfy this package's published version.
A cross-repo option change or release is a 15-package + 2-site batch — load the waveform-release skill and work the whole checklist.