wavesurfer.js

Audio waveform player

RAW Doc

CHANGELOG

Changelog

All notable changes to wavesurfer.js are documented here. This project follows
semantic versioning.

[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
<script>-tag UMD bundle has no realistic TypeScript consumer. The
"./dist/*.js" exports wildcard (see Changed above) still nominally
matches these paths and points types at a .d.ts that will never exist;
scripts/verify-exports.cjs checks this explicitly and reports it as a
known, accepted gap (SKIP ... (known gap...)) in npm run build's
verify-exports output, rather than either failing the build or silently
passing.


CONTRIBUTING

CONTRIBUTING to wavesurfer.js

Hello there,

Firstly, a heartfelt thank you! We sincerely appreciate your interest in wavesurfer.js and are really excited to see your contributions to our community.

Here are a few guidelines to keep in mind when you're ready to contribute:

1. Search in Existing Issues

Before submitting a new issue, we kindly ask you to take a moment to search through our existing issues. There's a chance that someone has already raised the point you're interested in. This step helps to keep our issues page clean and productive.

2. Questions and Feature Requests

Got a burning question or a brilliant feature idea? That's fantastic! But instead of the issues section, we ask you to post these in our Discussions forum. This helps to separate enhancement ideas and questions from the bugs and issues which need immediate attention from the developers.

To visit the forum, click here.

3. Reporting Bugs

Stumbled upon a bug? Sorry about that! We're constantly working to improve wavesurfer.js and your bug reports help us do just that.

When you post a bug report, please include the necessary code that will help us reproduce the bug. The more details you provide, the quicker we can get to the root of the problem and resolve it.

By following these guidelines, you're helping us maintain a productive, organized community. We can't wait to see your contributions to wavesurfer.js. Thank you again for your help!


README

wavesurfer.js

[](https://www.npmjs.com/package/wavesurfer.js) [](https://github.com/sponsors/katspaugh) [](https://m.do.co/c/b2fb57f5cf9a)

Wavesurfer.js is an interactive waveform rendering and audio playback library, perfect for web applications. It leverages modern web technologies to provide a robust and visually engaging audio experience.


Gold Sponsor 💖 [Closed Caption Creator](https://www.closedcaptioncreator.com) – Professional Subtitle Editor


Table of contents

  1. Getting started
  2. API reference
  3. Plugins
  4. Advanced / reactive API
  5. CSS styling
  6. Frequent questions
  7. Development
  8. Tests
  9. Feedback

Getting started

Install and import the package:

bash
npm install --save wavesurfer.js
js
import WaveSurfer from 'wavesurfer.js'

Alternatively, insert a UMD script tag which exports the library as a global WaveSurfer variable:

html
<script src="https://unpkg.com/wavesurfer.js@7"></script>

Create a wavesurfer instance and pass various 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:

js
import Regions from 'wavesurfer.js/dist/plugins/regions.esm.js'

Or as a script tag that will export WaveSurfer.Regions:

html
<script src="https://unpkg.com/wavesurfer.js@7/dist/plugins/regions.min.js"></script>

TypeScript types are included in the package, so there's no need to install @types/wavesurfer.js.

See more examples.

Documentation

See the wavesurfer.js guide 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:

* methods
* options
* events

Plugins

We maintain a number of official plugins that add various extra features:

* Regions – visual overlays and markers for regions of audio
* Timeline – displays notches and time labels below the waveform
* Minimap – a small waveform that serves as a scrollbar for the main waveform
* Envelope – a graphical interface to add fade-in and -out effects and control volume
* Record – records audio from the microphone and renders a waveform
* Spectrogram – visualization of an audio frequency spectrum (written by @akreal)
* Hover – 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 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 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 to play around with styling.

Questions

Have a question about integrating wavesurfer.js on your website? Feel free to ask in our Discussions forum.

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:

text
yarn

2. Start the TypeScript compiler in watch mode and launch an HTTP server:

text
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:

text
yarn build

Then launch the tests:

text
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.

We hope you enjoy using wavesurfer.js and look forward to hearing about your experiences with the library!


<a href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=wavesurfer.js">

</a>