Audio in JavaScript
From the platform up: an element that plays a file, a graph that shapes the sound, the bytes that save it, the same in Node, then a library that does it in one chain. Every figure runs in this page.
1new Audio()
new Audio(url) makes an <audio> element without putting it in the page, and starts loading the file.1 It plays and pauses. currentTime is where it is, in seconds; volume runs from 0 to 1 and throws IndexSizeError outside it; playbackRate changes the speed and keeps the pitch. What happens arrives as events: loadedmetadata once the duration is known, timeupdate every 15 to 250 ms while it plays, pause, ended, error.1
const audio = new Audio('speech.mp3')
play.onclick = () => audio.paused ? audio.play() : audio.pause()
seek.oninput = () => audio.currentTime = seek.value
volume.oninput = () => audio.volume = volume.value
speed.onclick = e => audio.playbackRate = e.target.value
audio.onloadedmetadata = () => seek.max = audio.duration
audio.ontimeupdate = () => seek.value = audio.currentTime
audio.onplay = audio.onpause = () =>
play.textContent = audio.paused ? 'Play' : 'Pause'
Two minutes of NASA's Houston We Have a Podcast.15 The panel draws what the element reports; under the controls, each event and how often it has fired. The first few fired before anything was pressed.
play() returns a promise. Until the reader clicks, taps or presses a key on the site, a browser may refuse sound and reject the promise with NotAllowedError; muted playback is allowed.1,3 Chrome also allows sound on sites the reader often plays media on (desktop) or has added to the home screen (mobile).2 So start sound from a click, and on a rejection show a play button.
On iOS, volume stays 1: the phone's buttons set the level.4
An element plays a file as it is. To change the sound, start it at an exact moment or read its samples, there is the Web Audio API.
2Web Audio: a graph of nodes
An AudioContext runs a graph: sources, through nodes that change the sound, into destination, the speakers. It renders 128 samples at a time, on a thread of its own.5 Like play(), a context made before any click starts suspended; call resume() from one.2,5
An equalizer is three filters in a row, each a BiquadFilterNode. A low shelf lifts or cuts everything below its frequency; a peaking filter, a band around it, as wide as Q makes it; a high shelf, everything above. gain is in decibels, and the shelves ignore Q. The spec's formulas follow the Audio EQ Cookbook.5,6 An AnalyserNode shows what comes out: an FFT of its last fftSize samples (a power of two, 32 to 32,768), Blackman-windowed, in decibels.5
const ctx = new AudioContext()
const bass = new BiquadFilterNode(ctx, { type: 'lowshelf', frequency: 200 })
const mid = new BiquadFilterNode(ctx, { type: 'peaking', frequency: 1000, Q: 1 })
const treble = new BiquadFilterNode(ctx, { type: 'highshelf', frequency: 4000 })
const analyser = new AnalyserNode(ctx, { fftSize: 4096 })
const level = new GainNode(ctx, { gain: 0.3 })
bass.connect(mid).connect(treble).connect(analyser)
.connect(level).connect(ctx.destination)
let source
play.onclick = () => {
ctx.resume() // allowed: this is a click
source = new AudioBufferSourceNode(ctx, { buffer, loop: true })
source.connect(bass)
source.start()
}
stop.onclick = () => source.stop()
bassSlider.oninput = () => bass.gain.value = bassSlider.value // dB
const db = new Float32Array(analyser.frequencyBinCount)
analyser.getFloatFrequencyData(db) // each frame: the spectrum, dB a bin
getFrequencyResponse()The drum loop is made in this page; open a file to equalize your own. Volume starts low.
A source node plays once: a second start() throws InvalidStateError, so each Play makes a new one.5 To equalize the player from step 1 instead, route its element through the same filters: ctx.createMediaElementSource(audio).connect(bass).
3Files in, files out
decodeAudioData() turns a file's bytes into an AudioBuffer: each channel an array of 32-bit floats, resampled to the context's rate.5 The browser decides which formats it reads.
const bytes = await (await fetch('song.mp3')).arrayBuffer()
const buffer = await ctx.decodeAudioData(bytes)
buffer.getChannelData(0) // Float32Array: the first channel
Here is what yours reads, and records. Six files, each the same 50 ms tone, 2,205 samples at 44.1 kHz:
| Format | Decodes | Samples back | Records as |
|---|
decodeAudioData() on each file, MediaRecorder.isTypeSupported() on each format. A decoded MP3 can come back longer than it was made: the encoder adds silence at both ends and notes how much in the file, and browsers differ in taking it off.9 This one came back as 2,205 samples in Chromium 151, 2,927 in Firefox 153 and 3,456 in WebKit 26.5; the Ogg Vorbis file, whole in Firefox, 128 samples short in the other two (Playwright's builds, October 2026).Decoded, sound is large. An hour of stereo at 48 kHz is 3,600 × 48,000 × 2 floats of 4 bytes: 1.38 GB. The same hour as a 128 kbps MP3 is 57.6 MB, 24 times less. decodeAudioData takes a whole file at once; a long recording needs a decoder that works in pieces, such as WebCodecs' AudioDecoder.10
Web Audio has no encoder. MediaRecorder records a stream as it plays, in real time, into a format the browser picks: the table's last column.7 In October 2026 Playwright's Chromium, Firefox and WebKit each offered Opus in WebM, Firefox also Opus in Ogg, Chromium and WebKit AAC in MP4. None offered WAV, MP3 or FLAC. Chromium also takes audio/webm;codecs=pcm: uncompressed samples, in a WebM container.
WAV is plain enough to write by hand: a 44-byte header, then the samples as 16-bit integers.8 These lines are the whole encoder:
// An AudioBuffer as a WAV file: a 44-byte header, then the samples as 16-bit integers
export function wav(buffer) {
const { numberOfChannels: k, sampleRate: rate, length: n } = buffer
const bytes = n * k * 2, view = new DataView(new ArrayBuffer(44 + bytes))
const text = (at, s) => [...s].forEach((c, i) => view.setUint8(at + i, c.charCodeAt()))
const u32 = (at, v) => view.setUint32(at, v, true)
const u16 = (at, v) => view.setUint16(at, v, true)
text(0, 'RIFF'); u32(4, 36 + bytes); text(8, 'WAVE')
text(12, 'fmt '); u32(16, 16); u16(20, 1) // 16 bytes of format; 1: integer PCM
u16(22, k); u32(24, rate); u32(28, rate * k * 2) // channels, rate, bytes a second
u16(32, k * 2); u16(34, 16) // bytes a frame, bits a sample
text(36, 'data'); u32(40, bytes)
const channels = Array.from({ length: k }, (_, c) => buffer.getChannelData(c))
for (let i = 0, at = 44; i < n; i++) for (const x of channels) { // interleaved
view.setInt16(at, Math.round(Math.max(-1, Math.min(1, x[i])) * 32767), true)
at += 2
}
return new Uint8Array(view.buffer)
}
To save the equalized sound, render the graph without speakers. An OfflineAudioContext renders as fast as it can, into an AudioBuffer. A node belongs to the context that made it, so the render makes its own filters:5
const offline = new OfflineAudioContext(1, buffer.length, buffer.sampleRate)
const source = new AudioBufferSourceNode(offline, { buffer })
const bass = new BiquadFilterNode(offline, { type: 'lowshelf', frequency: 200, gain: 6 })
// … mid and treble, made on offline too
source.connect(bass).connect(mid).connect(treble).connect(offline.destination)
source.start()
const rendered = await offline.startRendering()
link.href = URL.createObjectURL(new Blob([wav(rendered)], { type: 'audio/wav' }))
The sound from step 2 through the equalizer as you left it there. Volume is for listening only and stays out of the file.
4Node.js
Node has no AudioContext, no decoder and no way to the speakers: fs reads a file's bytes, and there it stops.11 Packages add the rest. web-audio-api is the W3C API in plain JavaScript, for Node, Deno and Bun. Under its own Node runner it passes all 4,322 subtests of the Web Platform Tests it carries; that proves the corpus under that runner, not every browser behaviour, and it runs worklets on the main thread.12 node-web-audio-api, from IRCAM, runs its DSP in Rust, prebuilt for Windows, macOS and Linux.13
With web-audio-api's polyfill, the Web Audio lines from step 3 run in Node as they are; only the way the file comes in and goes out changes. From an MP3 to a WAV file:
import 'web-audio-api/polyfill' // AudioContext and its nodes, as globals
import { readFileSync, writeFileSync } from 'node:fs'
import { wav } from './wav.js' // the lines above
const file = readFileSync('in.mp3')
const sound = await new OfflineAudioContext(1, 1, 44100).decodeAudioData(file)
const ctx = new OfflineAudioContext(sound.numberOfChannels, sound.length, sound.sampleRate)
const source = new AudioBufferSourceNode(ctx, { buffer: sound })
const bass = new BiquadFilterNode(ctx, { type: 'lowshelf', frequency: 200, gain: 6 })
source.connect(bass).connect(ctx.destination)
source.start()
writeFileSync('out.wav', wav(await ctx.startRendering()))
Speakers and microphones need native code: web-audio-api reaches them through @audio/speaker and @audio/mic.12 Sébastien Piquemal started web-audio-api in 2013; it is maintained at audiojs, which also makes this page.
5A library
Each step above needed its own API. audio does them in one chain, the same in a page and in Node. It decodes with its own WebAssembly codecs (WAV, MP3, FLAC, Ogg, Opus, AAC and more), edits without touching the source, filters, measures, plays, and encodes WAV, MP3, FLAC, Opus and Ogg.14 It is made at audiojs too. Below, its code runs live: edit it and hear the change.
audio('rhodes.wav')
.lowshelf(200, 6)
.eq(1000, -3, 1)
.highshelf(4000, 3)
.normalize(-1)
audio('handpan.wav')
.trim()
.normalize(-1)
.fade(0.1, 1)
audio.from(t => [261.63, 329.63, 392]
.reduce((s, f) => s + 0.15 * Math.sin(2 * Math.PI * f * t), 0), { d: 2 })
.fade(0.01, 1)
In Node, the same chain reads and writes files:
import audio from 'audio' // npm i audio
const a = audio('in.mp3')
.lowshelf(200, 6).eq(1000, -3, 1).highshelf(4000, 3)
.trim().normalize(-1).fade(0.1, 0.5)
await a.save('out.mp3')
In a page, import it through a bundler or from https://esm.sh/audio: a.play() plays it, await a.encode('mp3') gives the file's bytes. Code written for new Audio() runs in Node after import 'audio/polyfill'.14 The playground is an audio editor built on it, each edit a line of this code.
6Which tool for which job
Versions, licences and release dates are npm's on 14 October 2026.16 Two of these, audio and web-audio-api, are made at audiojs.
| Library | For | Not for | Latest on npm |
|---|---|---|---|
| howler.js | Sounds in games and apps: sprites, many at once, fades, stereo and 3D panning; Web Audio, falling back to HTML5 Audio | Editing, synthesis, saving files | 2.2.4, MIT, 19 Sep 2023 |
| Tone.js | Music: synths, samplers, effects, and a transport that schedules notes in bars and beats | Editing or encoding files | 15.1.22, MIT, 27 Apr 2025 (15.5.57 as next, 4 Oct 2026) |
| SoundJS | Game sound with CreateJS and PreloadJS: one API over Web Audio and HTML5 Audio | A project that needs fixes: no npm release since 2017, no commit since March 2021 | 1.0.1, MIT, 18 Sep 2017 |
| wavesurfer.js | A waveform to play, zoom, and mark regions on; plugins for a timeline, a spectrogram, recording | Processing or saving sound | 8.0.2, BSD-3-Clause, 7 Oct 2026 |
| standardized-audio-context | The Web Audio API, alike in every browser it supports; Tone.js runs on it | Node; anything beyond the API itself | 25.3.77, MIT, 31 Aug 2024 |
| web-audio-api | The Web Audio API in Node, Deno and Bun: render graphs on a server, test audio code in CI | Browsers, which have their own; worklets that need a thread of their own | 1.6.0, MIT, 7 Oct 2026 |
| node-web-audio-api | The Web Audio API in Node with its DSP in Rust, for real-time graphs | Linux without Jack or pipewire-jack, which its Linux builds play through | 2.2.0, BSD-3-Clause, 9 Aug 2026 |
| audio | Files: decode, edit, filter, measure, encode and play, one chain in a page and in Node; a CLI | Synthesis and note scheduling (Tone.js); a ready waveform widget (wavesurfer.js); game sound sprites (howler.js) | 2.14.0, MIT, 12 Oct 2026 |
Came for kolber's audio.js? Anthony Kolber's 2010 drop-in player for the <audio> tag lives at github.com/kolber/audiojs. This site is a different project.
- WHATWG, HTML Living Standard, Media elements:
Audio(),timeupdateevery 15 to 250 ms,volume0 to 1,preservesPitch,play()rejected withNotAllowedError. - Chrome for Developers, Autoplay policy in Chrome: media elements from Chrome 66, Web Audio from Chrome 71.
- MDN, Autoplay guide for media and Web Audio APIs.
- MDN, HTMLMediaElement: volume, browser compatibility, Safari on iOS; Apple, Safari HTML5 Audio and Video Guide, iOS-Specific Considerations (2012).
- W3C, Web Audio API, Recommendation, 17 June 2021: render quantum, rendering thread,
AudioBuffer,decodeAudioData,BiquadFilterNode,AnalyserNode,OfflineAudioContext. Its successor, Web Audio API 1.1, is a Working Draft (22 September 2026). - R. Bristow-Johnson, Audio EQ Cookbook, W3C Group Note (2021).
- W3C, MediaStream Recording:
MediaRecorder,isTypeSupported(). - IBM & Microsoft, Multimedia Programming Interface and Data Specifications 1.0 (1991): RIFF WAVE; summarised by P. Kabal, Audio File Format Specifications: WAVE, McGill University.
- G. Bouvigne, Mp3 Info Tag rev 1 specifications: the LAME tag's encoder delay and padding.
- W3C, WebCodecs:
AudioDecoder. - Node.js, Global objects and File system.
- audiojs, web-audio-api 1.6.0: README; site-metrics.json, 4,322 subtests passing, 0 failing; test/WPT.md, what that count does and does not prove.
- IRCAM, node-web-audio-api: README, supported platforms.
- audiojs, audio 2.14.0: README.
- NASA, Houston We Have a Podcast, episode 434: Artemis III Training. Public domain; the first two minutes.
- npm registry (
npm view <name> version license time) and GitHub, read 14 October 2026.