Headless Web Audio API
See the codeA pure JavaScript implementation of the Web Audio API for Node.js.
import 'web-audio-api/polyfill'.npm install web-audio-api
import { OfflineAudioContext } from 'web-audio-api'
const ctx = new OfflineAudioContext(2, 44100, 44100) // 1 second, stereo
const osc = ctx.createOscillator()
osc.frequency.value = 440
osc.connect(ctx.destination)
osc.start()
const buffer = await ctx.startRendering()
// buffer.getChannelData(0) → Float32Array of 44100 samples
This renders one second of a 440 Hz tone into memory without opening an audio device. Use the samples in a test or save them to a file. See render-to-buffer.js.
Node.js has the broadest test coverage, including the fully passing checked-in WPT corpus under our Node runner. Deno and Bun run offline rendering checks in CI. See the runtime support details for device access and other targets.
import 'web-audio-api/polyfill'
// AudioContext, GainNode, etc. are now global
The polyfill also installs navigator.mediaDevices.getUserMedia({ audio: true }), backed by the optional @audio/mic peer dependency. This lets browser mic-capture code run verbatim in Node:
import 'web-audio-api/polyfill'
// npm install @audio/mic
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const ctx = new AudioContext()
const src = ctx.createMediaStreamSource(stream)
src.connect(ctx.destination)
// stop capture
stream.getAudioTracks()[0].stop()
Without @audio/mic installed, getUserMedia rejects with a NotFoundError containing an install hint.
Devices work as in a browser: navigator.mediaDevices.enumerateDevices() lists the inputs @audio/mic finds and the outputs @audio/speaker does (each deviceId the platform's own: CoreAudio's UID, WASAPI's endpoint id, the ALSA name), getUserMedia({ audio: { deviceId } }) opens that input, and sinkId (the constructor's or setSinkId(), also while playing) that output:
const devices = await navigator.mediaDevices.enumerateDevices()
const usb = devices.find(d => d.kind === 'audioinput' && d.label.includes('USB'))
const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: usb.deviceId } } })
const ctx = new AudioContext({ sinkId: devices.find(d => d.kind === 'audiooutput' && d.label.includes('Headphones')).deviceId })
Yes. Tone.js uses standardized-audio-context, which needs globals such as window.AudioParam for instanceof checks. Load the polyfill before Tone.js:
import 'web-audio-api/polyfill'
const Tone = await import('tone')
Tone.setContext(new AudioContext())
const synth = new Tone.Synth().toDestination()
synth.triggerAttackRelease('C4', '8n')
Tone.js must use a dynamic import() because static imports run before the polyfill. Alternatively, use --import:
node --import web-audio-api/polyfill app.js
Then static import * as Tone from 'tone' works in app.js.
const buffer = await ctx.decodeAudioData(readFileSync('track.mp3'))
decodeAudioData() uses @audio/decode for MP3, WAV, Ogg Vorbis, Opus, FLAC, AAC, ALAC, AIFF, CAF, WebM, and other supported audio or video containers without FFmpeg or native bindings.
import { AudioContext } from 'web-audio-api'
const ctx = new AudioContext()
await ctx.resume()
const osc = ctx.createOscillator()
osc.frequency.value = 440
osc.connect(ctx.destination)
osc.onended = () => ctx.close()
osc.start()
osc.stop(ctx.currentTime + 1) // play for one second, then close the device
@audio/speaker provides device output through platform-specific backends, including native dependencies. The DSP engine itself is JavaScript.
In Node, pair @audio/mic with CustomMediaStreamTrack:
npm install @audio/mic
import { AudioContext, MediaStreamAudioSourceNode, CustomMediaStreamTrack, MediaStream } from 'web-audio-api'
import mic from '@audio/mic'
const ctx = new AudioContext()
await ctx.resume()
const track = new CustomMediaStreamTrack({
kind: 'audio',
label: 'mic',
settings: { channelCount: 1, sampleSize: 16, sampleRate: ctx.sampleRate }
})
const stream = new MediaStream([track])
const src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream })
src.connect(ctx.destination) // live monitor
// @audio/mic's read(cb) is single-shot; re-arm it inside the callback.
const read = mic({ sampleRate: ctx.sampleRate, channels: 1, bitDepth: 16 })
const pump = () => read((err, buf) => {
if (err || !buf) return
track.pushData(buf, { channels: 1, bitDepth: 16 })
pump()
})
pump()
track.pushData() accepts Float32Array, Float32Array[], or interleaved 8/16/32-bit integer PCM buffers. Integer PCM conversion uses pcm-convert. CustomMediaStreamTrack extends MediaStreamTrack. Prior art: CanvasCaptureMediaStreamTrack.
See examples/mic.js for a runnable demo with gain and VU meter. To record the graph to a buffer, use OfflineAudioContext.startRendering(). To capture live graph output as a stream, use ctx.createMediaStreamDestination().
If the default microphone backend cannot open the device, pass backend: 'process' to use sox/ffmpeg instead: mic({ ..., backend: 'process' }). All bundled examples accept backend=process on the command line.
AudioContext starts suspended to match the Web Audio lifecycle. In Node, call await ctx.resume(). Browsers may require that call inside a user gesture. OfflineAudioContext doesn't need it.
await ctx.close()
Or with explicit resource management: using ctx = new AudioContext()
node examples/<name>.js runs each example with its defaults.
node examples/<name>.js --help for every accepted argument, option, keyboard control, and alternate invocation.
aplay, sox, etc.@audio/mic)@audio/mic)2 0.35 3ssine A4 2s20..20k exp 3spink 2s5 0.5s55512341k 1s80..240 10m X-x-x-x-440 (requires @audio/mic)@audio/mic)@audio/mic)square 220 16 3s440 2 5 3sA4 4sorgan 220 0.3 6s0.08 15 4 10s72 30s120 20svoice=strings melody=pentatonic freq=D3 -d 30sstyle=ambient lead=harp120 16 3,5,7 20sup 15sup 120 20s200 10 10s100 3s440 3 5s400 800 2 12s200 261.63 8s0 8 1.2440 0.6 on 15s240 4 15s600 20s2000 3 2 20sRun npm run bench:compare for an offline-render comparison with node-web-audio-api. The runner alternates engines after warmup and reports median and p95 times over 50 renders. Saved results include raw samples, hardware, and versions.
These are short offline graphs, not audio-device latency measurements or realtime guarantees. Measure your own graph and deployment host.
Pull-based audio graph. AudioDestinationNode pulls upstream via _tick(), 128-sample render quanta per spec. AudioWorklet runs synchronously (no thread isolation). DSP kernels separated from graph plumbing for future WASM swap.
EventTarget ← Emitter ← DspObject ← AudioNode ← concrete nodes
← AudioParam
EventTarget ← Emitter ← AudioPort ← AudioInput / AudioOutput
Beyond the spec, for Node.js. Not portable to browsers.
addModule(fn) – register a processor via callback instead of URL, no file neededsinkId: stream – pipe PCM to any writable: new AudioContext({ sinkId: process.stdout }) then node synth.js | aplay -f cdnumberOfChannels, bitDepth – control output format in the constructor.CustomMediaStreamTrack – extends MediaStreamTrack with a public constructor and pushData(chunk, options) to feed audio data (e.g. from a microphone). Prior art: CanvasCaptureMediaStreamTrack. See the mic FAQ.MIT
339 followers · starred Feb 2022
261 followers · starred Apr 2026
68 followers · starred Dec 2022
207 followers · starred Mar 2017
Headless Web Audio API
See the codeA pure JavaScript implementation of the Web Audio API for Node.js.
import 'web-audio-api/polyfill'.npm install web-audio-api
import { OfflineAudioContext } from 'web-audio-api'
const ctx = new OfflineAudioContext(2, 44100, 44100) // 1 second, stereo
const osc = ctx.createOscillator()
osc.frequency.value = 440
osc.connect(ctx.destination)
osc.start()
const buffer = await ctx.startRendering()
// buffer.getChannelData(0) → Float32Array of 44100 samples
This renders one second of a 440 Hz tone into memory without opening an audio device. Use the samples in a test or save them to a file. See render-to-buffer.js.
Node.js has the broadest test coverage, including the fully passing checked-in WPT corpus under our Node runner. Deno and Bun run offline rendering checks in CI. See the runtime support details for device access and other targets.
import 'web-audio-api/polyfill'
// AudioContext, GainNode, etc. are now global
The polyfill also installs navigator.mediaDevices.getUserMedia({ audio: true }), backed by the optional @audio/mic peer dependency. This lets browser mic-capture code run verbatim in Node:
import 'web-audio-api/polyfill'
// npm install @audio/mic
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const ctx = new AudioContext()
const src = ctx.createMediaStreamSource(stream)
src.connect(ctx.destination)
// stop capture
stream.getAudioTracks()[0].stop()
Without @audio/mic installed, getUserMedia rejects with a NotFoundError containing an install hint.
Devices work as in a browser: navigator.mediaDevices.enumerateDevices() lists the inputs @audio/mic finds and the outputs @audio/speaker does (each deviceId the platform's own: CoreAudio's UID, WASAPI's endpoint id, the ALSA name), getUserMedia({ audio: { deviceId } }) opens that input, and sinkId (the constructor's or setSinkId(), also while playing) that output:
const devices = await navigator.mediaDevices.enumerateDevices()
const usb = devices.find(d => d.kind === 'audioinput' && d.label.includes('USB'))
const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: usb.deviceId } } })
const ctx = new AudioContext({ sinkId: devices.find(d => d.kind === 'audiooutput' && d.label.includes('Headphones')).deviceId })
Yes. Tone.js uses standardized-audio-context, which needs globals such as window.AudioParam for instanceof checks. Load the polyfill before Tone.js:
import 'web-audio-api/polyfill'
const Tone = await import('tone')
Tone.setContext(new AudioContext())
const synth = new Tone.Synth().toDestination()
synth.triggerAttackRelease('C4', '8n')
Tone.js must use a dynamic import() because static imports run before the polyfill. Alternatively, use --import:
node --import web-audio-api/polyfill app.js
Then static import * as Tone from 'tone' works in app.js.
const buffer = await ctx.decodeAudioData(readFileSync('track.mp3'))
decodeAudioData() uses @audio/decode for MP3, WAV, Ogg Vorbis, Opus, FLAC, AAC, ALAC, AIFF, CAF, WebM, and other supported audio or video containers without FFmpeg or native bindings.
import { AudioContext } from 'web-audio-api'
const ctx = new AudioContext()
await ctx.resume()
const osc = ctx.createOscillator()
osc.frequency.value = 440
osc.connect(ctx.destination)
osc.onended = () => ctx.close()
osc.start()
osc.stop(ctx.currentTime + 1) // play for one second, then close the device
@audio/speaker provides device output through platform-specific backends, including native dependencies. The DSP engine itself is JavaScript.
In Node, pair @audio/mic with CustomMediaStreamTrack:
npm install @audio/mic
import { AudioContext, MediaStreamAudioSourceNode, CustomMediaStreamTrack, MediaStream } from 'web-audio-api'
import mic from '@audio/mic'
const ctx = new AudioContext()
await ctx.resume()
const track = new CustomMediaStreamTrack({
kind: 'audio',
label: 'mic',
settings: { channelCount: 1, sampleSize: 16, sampleRate: ctx.sampleRate }
})
const stream = new MediaStream([track])
const src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream })
src.connect(ctx.destination) // live monitor
// @audio/mic's read(cb) is single-shot; re-arm it inside the callback.
const read = mic({ sampleRate: ctx.sampleRate, channels: 1, bitDepth: 16 })
const pump = () => read((err, buf) => {
if (err || !buf) return
track.pushData(buf, { channels: 1, bitDepth: 16 })
pump()
})
pump()
track.pushData() accepts Float32Array, Float32Array[], or interleaved 8/16/32-bit integer PCM buffers. Integer PCM conversion uses pcm-convert. CustomMediaStreamTrack extends MediaStreamTrack. Prior art: CanvasCaptureMediaStreamTrack.
See examples/mic.js for a runnable demo with gain and VU meter. To record the graph to a buffer, use OfflineAudioContext.startRendering(). To capture live graph output as a stream, use ctx.createMediaStreamDestination().
If the default microphone backend cannot open the device, pass backend: 'process' to use sox/ffmpeg instead: mic({ ..., backend: 'process' }). All bundled examples accept backend=process on the command line.
AudioContext starts suspended to match the Web Audio lifecycle. In Node, call await ctx.resume(). Browsers may require that call inside a user gesture. OfflineAudioContext doesn't need it.
await ctx.close()
Or with explicit resource management: using ctx = new AudioContext()
node examples/<name>.js runs each example with its defaults.
node examples/<name>.js --help for every accepted argument, option, keyboard control, and alternate invocation.
aplay, sox, etc.@audio/mic)@audio/mic)2 0.35 3ssine A4 2s20..20k exp 3spink 2s5 0.5s55512341k 1s80..240 10m X-x-x-x-440 (requires @audio/mic)@audio/mic)@audio/mic)square 220 16 3s440 2 5 3sA4 4sorgan 220 0.3 6s0.08 15 4 10s72 30s120 20svoice=strings melody=pentatonic freq=D3 -d 30sstyle=ambient lead=harp120 16 3,5,7 20sup 15sup 120 20s200 10 10s100 3s440 3 5s400 800 2 12s200 261.63 8s0 8 1.2440 0.6 on 15s240 4 15s600 20s2000 3 2 20sRun npm run bench:compare for an offline-render comparison with node-web-audio-api. The runner alternates engines after warmup and reports median and p95 times over 50 renders. Saved results include raw samples, hardware, and versions.
These are short offline graphs, not audio-device latency measurements or realtime guarantees. Measure your own graph and deployment host.
Pull-based audio graph. AudioDestinationNode pulls upstream via _tick(), 128-sample render quanta per spec. AudioWorklet runs synchronously (no thread isolation). DSP kernels separated from graph plumbing for future WASM swap.
EventTarget ← Emitter ← DspObject ← AudioNode ← concrete nodes
← AudioParam
EventTarget ← Emitter ← AudioPort ← AudioInput / AudioOutput
Beyond the spec, for Node.js. Not portable to browsers.
addModule(fn) – register a processor via callback instead of URL, no file neededsinkId: stream – pipe PCM to any writable: new AudioContext({ sinkId: process.stdout }) then node synth.js | aplay -f cdnumberOfChannels, bitDepth – control output format in the constructor.CustomMediaStreamTrack – extends MediaStreamTrack with a public constructor and pushData(chunk, options) to feed audio data (e.g. from a microphone). Prior art: CanvasCaptureMediaStreamTrack. See the mic FAQ.MIT
339 followers · starred Feb 2022
261 followers · starred Apr 2026
68 followers · starred Dec 2022
207 followers · starred Mar 2017