### CHANGELOG # Changelog All notable changes to wavesurfer.js are documented here. This project follows [semantic versioning](https://semver.org/). ## [8.0.0-beta.1] A structural refactor of the core and plugins around a new `Scope` disposal-tree primitive, fixing a large batch of memory leaks and double-emitted events, plus a declarative load/viewport layer, a functional plugin API (`definePlugin`), and a unified spectrogram implementation. Runtime behavior is unchanged for the vast majority of apps; the breaking changes below are source-level TypeScript breaks and a couple of narrow, intentionally-fixed behaviors. ### Breaking changes - **`WaveSurfer` no longer exposes `protected subscriptions` / `protected mediaSubscriptions` / `protected abortController`.** These were replaced by the new `Scope` primitive (`this.scope`). Subclasses that pushed cleanup callbacks onto `this.subscriptions`/`this.mediaSubscriptions`, or read `this.abortController`, will fail to compile against this version's types and should migrate to `this.scope.add(disposer)`. Source-level only — no runtime shim, and nothing changes for apps that only use the public API. - **`SingleRegion.subscriptions` (public field) has been removed** from the Regions plugin. Region teardown is now an internal `Scope`; code that read or pushed onto `region.subscriptions` directly will fail to compile. - **Several internal, never-part-of-the-public-API modules are no longer emitted to `dist/`**, so deep imports through `./dist/*` for these specific paths will 404: `dist/draggable.js`, `dist/reactive/event-stream-emitter.js`, `dist/reactive/media-event-bridge.js`, `dist/reactive/render-scheduler.js`, `dist/reactive/state-event-emitter.js`, `dist/timer.js` (the internal `Timer` class was ported to the existing `FrameScheduler` primitive and deleted; no replacement export — `record.ts`, its only consumer, now uses `FrameScheduler` directly). These had zero call sites in `src/`; anyone importing one directly should drop the import or vendor the code. - **`dist/fft.js` now exports only the `FFT` class.** The frequency-scale math, autoGain/color-mapping helpers, and colormap/UI helpers that used to live in the same file (under its blanket `@ts-nocheck`) moved to a new `dist/spectrogram-render-utils.js`. Anyone deep-importing e.g. `magnitudesToColorIndices`, `setupColorMap`, `hzToMel`/`scaleToHz`, or `createSparseFilterBankForScale` from `dist/fft.js` must import them from `dist/spectrogram-render-utils.js` instead. The dead, unused dense filter-bank functions `applyFilterBank` and `createFilterBankForScale` (the sparse equivalents were already the ones actually used) were deleted outright with no replacement. ### Added - **`Scope`** (`src/scope.ts`) — the disposal-tree primitive now used throughout the codebase for listeners, timers, observers, signal subscriptions, and child lifetimes; documented for plugin authors that build on `definePlugin`. - **`WaveSurfer.definePlugin(name, (ctx, options) => api)`** — a functional plugin API whose teardown is a single `Scope` disposal instead of a hand-rolled `destroy()` override. Purely additive: `BasePlugin` class-based plugins keep working unchanged. Six first-party plugins (`hover`, `zoom`, `timeline`, `minimap`, `envelope`, `regions`) are now built on it, with their public surface (exports, constructors, methods, events) unchanged. - **`wavesurfer.getState()`** gains `loadPhase` (`'idle' | 'fetching' | 'decoding' | 'ready' | 'error'`) and `scrollPosition` signals, and composes a `muted` signal alongside `volume`. - **`wavesurfer.getRenderer().getVisibleRange()`** — a derived signal returning the currently visible `{startTime, endTime}` of the viewport, recomputed every render cycle and kept accurate on scroll/zoom without needing a DOM `scroll` event first. - **`SpectrogramPlugin` accepts `rendering?: 'full' | 'windowed'`** directly, merging what previously required the separate `WindowedSpectrogramPlugin`. See Deprecated below. - **`yarn test:leaks`** — a GC-level regression harness (`--expose-gc`, not part of the default test run) asserting that destroyed instances and their heavyweight retainees actually become collectible. ### Changed - **`package.json`#`exports`' `"./dist/*"` deep-import wildcard is narrowed to `"./dist/*.js"`** (`types`/`import` only — no `require` condition, since there's no per-module `.cjs` build for internal `dist/` modules under `"type": "module"`). The old `"./dist/*"` pattern's `types`/`require` templates appended `.d.ts`/`.cjs` onto a subpath that already included `.js` (e.g. producing `./dist/webaudio.js.d.ts`, which never existed); besides fixing that, this also means a bare extensionless deep import like `wavesurfer.js/dist/webaudio` no longer resolves at all — only the `.js`-suffixed form does. `scripts/verify-exports.cjs` (new; also wired as `npm run verify-exports` and into `npm run build`) checks every export template against real post-build `dist/` files. - **`dist/types.d.ts`** (a `rollup-plugin-dts` bundle of `wavesurfer.d.ts`) is no longer built. It was referenced by nothing — not `package.json`'s `types` field, not any export entry, not any doc — so the rollup config block and the now-unused `rollup-plugin-dts` devDependency were removed rather than wired in. - **`createDragStream(element)`'s parameter type widened from `HTMLElement` to the base `Element`.** Every DOM API the function touches (`getBoundingClientRect`, add/removeEventListener) is declared on `Element`/`EventTarget`, not `HTMLElement` specifically; callers passing an `HTMLElement` (the overwhelmingly common case) are unaffected, but any reimplementation of the `ScrollStream`/drag-stream shape against the old, narrower parameter type should widen it too. - **The `ScrollStream` interface (`src/reactive/scroll-stream.ts`) gained a `refresh()` method** — re-reads the element's current scroll metrics and writes them into `scrollData` without waiting for a DOM `scroll` event. Anyone structurally implementing `ScrollStream` (rather than only consuming `createScrollStream()`'s return value) needs to add it. ### Fixed - Events (`pause`, `seeking`, `finish`, `timeupdate`, and others) are now emitted exactly once instead of twice. - The WebAudio backend now emits an `error` event on load failure instead of silently swallowing it. - `dragToSeek`'s object form (`{ debounceTime }`) can now be toggled on and off at runtime via `setOptions()` — previously a one-token bug meant the object form never actually enabled drag-to-seek. - A full destroy() → load() reuse cycle (no explicit `setMediaElement()` call) now correctly revives every event bridge — `timeupdate`, renderer click-to-seek, `play`/`pause` forwarding, and reactive state tracking all resume; previously they stayed dead after the first destroy. - Windowed spectrogram: an overlapping render call that got dropped while a prior one was in flight is now re-run once the in-flight call finishes, instead of silently losing that segment. - Windowed spectrogram's worker path now slices each channel to the segment's own sample range before `postMessage`, instead of structured-cloning the entire decoded channel buffer on every segment request. - Various destroy-time and async-continuation leak fixes across the core and plugins (record, spectrogram, regions, envelope, timeline/hover, minimap) — recordings emit their final blob even if `onstop` fires after `destroy()` returns, in-flight async work no longer touches DOM state post-destroy, and duplicate/leftover listeners are cleaned up correctly. - `Spectrogram`'s `maxCanvasWidth` is now tracked per-instance instead of on a shared static (setting it on one instance no longer affects every other spectrogram on the page). ### Deprecated - **`spectrogram-windowed.js` / `WindowedSpectrogramPlugin`** — prefer `SpectrogramPlugin.create({ ...options, rendering: 'windowed' })`. The old entry point is kept fully functional as a thin shim; no removal planned yet. ### Known inconsistencies - A handful of small stylistic inconsistencies (the regions plugin's `.subscribe` style vs. the rest of the codebase, the minimap's hand-rolled overlay, some duplicated drag-toggle logic) were flagged in review as cosmetic drift and intentionally left as-is rather than churned for their own sake. ### Known limitations - **Windowed spectrogram: a request-side segment-boundary rounding hazard.** `calculateFrequenciesWithWorkerRange`'s `startSample`/`endSample` are computed as `Math.floor(startTime * sampleRate)` / `Math.floor(endTime * sampleRate)` directly off the caller-supplied segment `startTime`/`endTime` (`src/spectrogram-setup.ts`). This is the same class of division/re-multiplication rounding hazard already fixed on the *response* side (the slice-length → `endTime` → worker reconstruction round-trip, fixed with a half-sample epsilon — see the "Fixed" entry above and `src/__tests__/spectrogram-worker-errors.test.ts`), but on the *request* side it remains unaddressed: for an adversarial `(startTime, sampleRate)` pair it can drop or duplicate a sample at a segment boundary. Deliberately deferred — currently documented only in a test comment (`spectrogram-worker-errors.test.ts`, the "reconstructs the exact slice length..." test); called out here so the gap is visible outside test source. - **`dist/*.min.js` (the terser-minified UMD bundles) have no matching `.d.ts`.** `rollup.config.js` builds them with `declaration: false` — a ` ``` Create a wavesurfer instance and pass various [options](https://wavesurfer.xyz/docs/api/options): ```js const wavesurfer = WaveSurfer.create({ container: '#waveform', waveColor: '#4F4A85', progressColor: '#383351', url: '/audio.mp3', }) ``` To import one of the plugins, e.g. the [Regions plugin](https://wavesurfer.xyz/examples/?regions.js): ```js import Regions from 'wavesurfer.js/dist/plugins/regions.esm.js' ``` Or as a script tag that will export `WaveSurfer.Regions`: ```html ``` TypeScript types are included in the package, so there's no need to install `@types/wavesurfer.js`. See more [examples](https://wavesurfer.xyz/examples). ## Documentation See the [wavesurfer.js guide](https://wavesurfer.xyz/docs/) on our website for beginner-friendly, practical documentation covering common use cases, plugins, framework integration, and troubleshooting. ## API reference The full generated API reference lives at [wavesurfer.xyz/docs/api](https://wavesurfer.xyz/docs/api/): * [methods](https://wavesurfer.xyz/docs/api/methods) * [options](https://wavesurfer.xyz/docs/api/options) * [events](https://wavesurfer.xyz/docs/api/events) ## Plugins We maintain a number of official plugins that add various extra features: * [Regions](https://wavesurfer.xyz/examples/?regions.js) – visual overlays and markers for regions of audio * [Timeline](https://wavesurfer.xyz/examples/?timeline.js) – displays notches and time labels below the waveform * [Minimap](https://wavesurfer.xyz/examples/?minimap.js) – a small waveform that serves as a scrollbar for the main waveform * [Envelope](https://wavesurfer.xyz/examples/?envelope.js) – a graphical interface to add fade-in and -out effects and control volume * [Record](https://wavesurfer.xyz/examples/?record.js) – records audio from the microphone and renders a waveform * [Spectrogram](https://wavesurfer.xyz/examples/?spectrogram.js) – visualization of an audio frequency spectrum (written by @akreal) * [Hover](https://wavesurfer.xyz/examples/?hover.js) – shows a vertical line and timestmap on waveform hover ## Advanced / reactive API Beyond the imperative `wavesurfer.on(...)` events, v8 exposes a small reactive surface for apps that want to read state instead of tracking it themselves: ```js const state = wavesurfer.getState() state.isPlaying.subscribe((playing) => console.log('playing:', playing)) console.log(state.loadPhase.value) // 'idle' | 'fetching' | 'decoding' | 'ready' | 'error' const { startTime, endTime } = wavesurfer.getRenderer().getVisibleRange().value ``` `getState()` returns read-only [`Signal`](https://wavesurfer.xyz/docs/) objects (`.value` to read, `.subscribe(fn)` to watch) for things like `currentTime`, `isPlaying`, `volume`, `muted`, `loadPhase` and `scrollPosition`. `getRenderer().getVisibleRange()` is a derived signal with the currently visible `{startTime, endTime}` of the viewport, useful for plugins that need to sync to what's on screen (e.g. a custom minimap or timeline). If you're writing a plugin, `WaveSurfer.definePlugin(name, (ctx, options) => api)` is an alternative to subclassing `BasePlugin`: `ctx` gives you `{ wavesurfer, scope, state, emit }`, and anything you register on `ctx.scope` (listeners, timers, child scopes) is torn down automatically on `destroy()` — no manual cleanup array required. ```js import WaveSurfer from 'wavesurfer.js' const MyPlugin = WaveSurfer.definePlugin('MyPlugin', (ctx, options) => { ctx.scope.listen(ctx.wavesurfer.getWrapper(), 'click', () => ctx.emit('my-event')) return { doSomething: () => {} } }) ``` The [Spectrogram plugin](https://wavesurfer.xyz/examples/?spectrogram.js) accepts a `rendering: 'windowed'` option for very long audio files: it renders only the visible time range and evicts off-screen segments, instead of computing the whole file's frequency data up front. ```js SpectrogramPlugin.create({ rendering: 'windowed', /* ...other options */ }) ``` This replaces the standalone `WindowedSpectrogramPlugin` (`spectrogram-windowed.js`), which is now deprecated in favor of the merged option above — it's kept working as a thin backward-compatible shim, but new code should use `SpectrogramPlugin` directly. ## CSS styling wavesurfer.js v7 is rendered into a Shadow DOM tree. This isolates its CSS from the rest of the web page. However, it's still possible to style various wavesurfer.js elements with CSS via the `::part()` pseudo-selector. For example: ```css #waveform ::part(cursor):before { content: '🏄'; } #waveform ::part(region) { font-family: fantasy; } ``` You can see which elements you can style in the DOM inspector – they will have a `part` attribute. See [this example](https://wavesurfer.xyz/examples/?styling.js) to play around with styling. ## Questions Have a question about integrating wavesurfer.js on your website? Feel free to ask in our [Discussions forum](https://github.com/wavesurfer-js/wavesurfer.js/discussions/categories/q-a). However, please keep in mind that this forum is dedicated to wavesurfer-specific questions. If you're new to JavaScript and need help with the general basics like importing NPM modules, please consider asking ChatGPT or StackOverflow first. ### FAQ
I'm having CORS issues Wavesurfer fetches audio from the URL you specify in order to decode it. Make sure this URL allows fetching data from your domain. In browser JavaScript, you can only fetch data eithetr from the same domain or another domain if and only if that domain enables CORS. So if your audio file is on an external domain, make sure that domain sends the right Access-Control-Allow-Origin headers. There's nothing you can do about it from the requesting side (i.e. your JS code).
Does wavesurfer support large files? Since wavesurfer decodes audio entirely in the browser using Web Audio, large clips may fail to decode due to memory constraints. We recommend using pre-decoded peaks for large files (see this example). You can use a tool like audiowaveform to generate peaks.
What about streaming audio? Streaming audio is supported only with pre-decoded peaks and duration.
There is a mismatch between my audio and the waveform. How do I fix it? If you're using a VBR (variable bit rate) audio file, there might be a mismatch between the audio and the waveform. This can be fixed by converting your file to CBR (constant bit rate).

Alternatively, you can use the Web Audio shim which is more accurate.

How do I connect wavesurfer.js to Web Audio effects? Generally, wavesurfer.js doesn't aim to be a wrapper for all things Web Audio. It's just a player with a waveform visualization. It does allow connecting itself to a Web Audio graph by exporting its audio element (see this example) but nothign more than that. Please don't expect wavesurfer to be able to cut, add effects, or process your audio in any way.
Why does my waveform look asymmetrical? By default, wavesurfer.js renders stereo audio as a single waveform, with the left channel on the top half and the right channel on the bottom half. If your audio has hard-panned sounds, this will result in an asymmetrical waveform. To render each channel separately, use the splitChannels option.
## Development To get started with development, follow these steps: 1. Install dev dependencies: ``` yarn ``` 2. Start the TypeScript compiler in watch mode and launch an HTTP server: ``` yarn start ``` This command will open http://localhost:9090 in your browser with live reload, allowing you to see the changes as you develop. ## Tests The tests are written in the Cypress framework. They are a mix of e2e and visual regression tests. To run the test suite locally, first build the project: ``` yarn build ``` Then launch the tests: ``` yarn cypress ``` ## Feedback We appreciate your feedback and contributions! If you encounter any issues or have suggestions for improvements, please don't hesitate to post in our [forum](https://github.com/wavesurfer-js/wavesurfer.js/discussions/categories/q-a). We hope you enjoy using wavesurfer.js and look forward to hearing about your experiences with the library! --- ---