Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d59a62e
Simplify implementation
dy Apr 29, 2026
f6e8329
Make safer CustomMediaStreamTrack
dy Apr 30, 2026
ea2f4b1
Potential fix for pull request finding
dy May 2, 2026
7e9bd01
fix: CustomMediaStreamTrack.clone() shares _buffers with original
Copilot May 2, 2026
d0cd67a
fix: enforce track lifecycle in pushData and _tick
Copilot May 2, 2026
a804a69
Potential fix for pull request finding
dy May 2, 2026
981e2fc
Potential fix for pull request finding
dy May 2, 2026
377a61b
Potential fix for pull request finding
dy May 2, 2026
e346bcb
fix: use CustomMediaStreamTrack in destination node so clone() shares…
Copilot May 2, 2026
94d8f19
fix: cache track in source node; fan-out clone pattern with auto-cleanup
Copilot May 2, 2026
526c715
fix: destination track lifecycle, WeakRef clones, pushData() compat s…
Copilot May 2, 2026
d86de1f
Potential fix for pull request finding
dy May 2, 2026
38acf7a
Potential fix for pull request finding
dy May 2, 2026
4e85c1c
Potential fix for pull request finding
dy May 2, 2026
a43c4d7
Potential fix for pull request finding
dy May 2, 2026
a7e1879
Potential fix for pull request finding
dy May 2, 2026
12cd5e0
fix: restore audio-mic-backed getUserMedia in polyfill; fix stray fra…
Copilot May 2, 2026
6a48ebf
Potential fix for pull request finding
dy May 2, 2026
683ccae
Potential fix for pull request finding
dy May 2, 2026
bf930cd
Potential fix for pull request finding
dy May 2, 2026
2f64d72
Potential fix for pull request finding
dy May 2, 2026
98463f7
Potential fix for pull request finding
dy May 2, 2026
a2e5755
fix: remove strong-ref ended listener from clone(); add getUserMedia …
Copilot May 2, 2026
2515659
fix: set _ended=true on MediaStreamAudioSourceNode when track ends to…
Copilot May 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ Beyond the spec, for Node.js. Not portable to browsers.

- **`addModule(fn)`** — register a processor via callback instead of URL, no file needed
- **`sinkId: stream`** — pipe PCM to any writable: `new AudioContext({ sinkId: process.stdout })` then `node synth.js | aplay -f cd`
- **`numberOfChannels`, `bitDepth`** — control output format in the constructor
- **`navigator.mediaDevices.getUserMedia({ audio: true })`** — browser-parity microphone capture in Node. Load `web-audio-api/polyfill` and install [`audio-mic`](https://github.com/audiojs/audio-mic); browser mic code then runs verbatim. See the [mic FAQ](#how-do-i-capture-audio-from-the-microphone).
- **`numberOfChannels`, `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](#how-do-i-capture-audio-from-the-microphone).
Comment thread
dy marked this conversation as resolved.

## FAQ

Expand Down Expand Up @@ -147,35 +147,40 @@ const buffer = await ctx.decodeAudioData(readFileSync('track.mp3'))
WAV, MP3, FLAC, OGG, AAC via [audio-decode](https://github.com/audiojs/audio-decode).
</dd>

<dt>How do I capture audio from the microphone?</dt>
<dt id="how-do-i-capture-audio-from-the-microphone">How do I capture audio from the microphone?</dt>
<dd>

In Node, pair [`audio-mic`](https://github.com/audiojs/audio-mic) with `MediaStreamAudioSourceNode.pushData()`:
In Node, pair [`audio-mic`](https://github.com/audiojs/audio-mic) with `CustomMediaStreamTrack`:

```sh
npm install audio-mic
```

```js
import { AudioContext, MediaStreamAudioSourceNode } from 'web-audio-api'
import { AudioContext, MediaStreamAudioSourceNode, CustomMediaStreamTrack, MediaStream } from 'web-audio-api'
import mic from 'audio-mic'

const ctx = new AudioContext()
await ctx.resume()

const src = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: 1, bitDepth: 16 })
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

const read = mic({ sampleRate: ctx.sampleRate, channels: 1, bitDepth: 16 })
read((err, buf) => {
if (err || !buf) return
src.pushData(buf, { channels: 1, bitDepth: 16 })
track.pushData(buf, { channels: 1, bitDepth: 16 })
})
```

`pushData()` accepts `Float32Array`, `Float32Array[]`, or interleaved 8/16/32-bit integer PCM buffers. Integer PCM conversion uses `pcm-convert`.

With `web-audio-api/polyfill`, `navigator.mediaDevices.getUserMedia()` is also available and maps constraints to `audio-mic` options: `{ audio: { sampleRate, channelCount, sampleSize } }`.
`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](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()`.
</dd>
Expand All @@ -187,6 +192,23 @@ See [examples/mic.js](examples/mic.js) for a runnable demo with gain and VU mete
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`](https://github.com/audiojs/audio-mic) peer dependency. This lets browser mic-capture code run verbatim in Node:

```js
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.
</dd>

<dt>Can I unit-test audio code?</dt>
Expand Down
9 changes: 6 additions & 3 deletions examples/mic.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Run: node examples/mic.js gain=0.8
// Keys: space pause · + / - adjust gain · q quit

import { AudioContext, MediaStreamAudioSourceNode } from 'web-audio-api'
import { AudioContext, MediaStreamAudioSourceNode, MediaStream, CustomMediaStreamTrack } from 'web-audio-api'
import mic from 'audio-mic'
import { args, keys, status, clearLine, pausedTag } from './_util.js'

Expand All @@ -18,7 +18,10 @@ let bitDepth = parseInt($('bit', '16'))
const ctx = new AudioContext({ sampleRate })
await ctx.resume()

const src = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: channels, bitDepth })
const track = new CustomMediaStreamTrack({ kind: 'audio', label: 'mic', settings: { channelCount: channels, sampleSize: bitDepth, sampleRate } })
const stream = new MediaStream([track])

const src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream })
const gain = ctx.createGain()
const analyser = ctx.createAnalyser()
gain.gain.value = gainVal
Expand All @@ -28,7 +31,7 @@ src.connect(gain).connect(analyser).connect(ctx.destination)
let read = mic({ sampleRate, channels, bitDepth })
read((err, buf) => {
if (err || !buf) return
src.pushData(buf, { channels, bitDepth })
track.pushData(buf, { channels, bitDepth })
})

let samples = new Float32Array(analyser.fftSize)
Expand Down
28 changes: 28 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,34 @@ export class MediaElementAudioSourceNode extends AudioNode {
readonly mediaElement: any;
}

export class MediaStreamTrack extends EventTarget {
readonly id: string;
readonly kind: string;
readonly label: string;
enabled: boolean;
readonly readyState: 'live' | 'ended';
stop(): void;
clone(): MediaStreamTrack;
getSettings(): Record<string, any>;
}

export class CustomMediaStreamTrack extends MediaStreamTrack {
constructor(options?: { kind?: string; label?: string; settings?: Record<string, any> });
pushData(channelData: Float32Array | Float32Array[] | ArrayBuffer | ArrayBufferView, options?: { channels?: number; numberOfChannels?: number; bitDepth?: 8 | 16 | 32 }): void;
clone(): CustomMediaStreamTrack;
}

export class MediaStream extends EventTarget {
readonly id: string;
readonly active: boolean;
constructor(tracks?: MediaStreamTrack[] | MediaStream);
getTracks(): MediaStreamTrack[];
getAudioTracks(): MediaStreamTrack[];
getVideoTracks(): MediaStreamTrack[];
addTrack(track: MediaStreamTrack): void;
removeTrack(track: MediaStreamTrack): void;
}

// Error types
export class InvalidStateError extends Error { readonly name: 'InvalidStateError' }
export class NotSupportedError extends Error { readonly name: 'NotSupportedError' }
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export { default as ScriptProcessorNode } from './src/ScriptProcessorNode.js'
export { default as PannerNode } from './src/PannerNode/index.js'
export { AudioWorkletNode, AudioWorkletProcessor } from './src/AudioWorklet.js'
export { MediaStreamAudioSourceNode, MediaStreamAudioDestinationNode, MediaElementAudioSourceNode } from './src/MediaStreamAudioSourceNode.js'
export { MediaStream, MediaStreamTrack, CustomMediaStreamTrack } from './src/MediaStream.js'
export { default as AudioListener } from './src/AudioListener.js'
export { BLOCK_SIZE } from './src/constants.js'
export { InvalidStateError, NotSupportedError, IndexSizeError, InvalidAccessError, EncodingError } from './src/errors.js'
Expand Down
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

103 changes: 39 additions & 64 deletions polyfill.js
Original file line number Diff line number Diff line change
@@ -1,71 +1,25 @@
// Web Audio API globals for Node: `import 'web-audio-api/polyfill'`
// Also exposes `navigator.mediaDevices.getUserMedia()` + MediaStream/Track so
// browser mic code runs verbatim. Requires optional peer dep `audio-mic`.
// Also exposes `navigator.mediaDevices.getUserMedia()` backed by the optional
// peer dep `audio-mic` so browser mic code runs verbatim.
import * as waa from './index.js'
import convert from 'pcm-convert'

for (let [name, value] of Object.entries(waa))
if (typeof value === 'function' && !(name in globalThis)) globalThis[name] = value

// Tone.js / standardized-audio-context checks `instanceof window.AudioParam`
if (typeof window === 'undefined') globalThis.window = globalThis

// --- MediaStream / MediaStreamTrack --------------------------------------
// Minimal spec-shaped classes for `instanceof` + track.stop() in Node.
globalThis.MediaStreamTrack ??= waa.MediaStreamTrack
globalThis.MediaStream ??= waa.MediaStream
globalThis.CustomMediaStreamTrack ??= waa.CustomMediaStreamTrack
Comment thread
dy marked this conversation as resolved.

let nextId = 0
class MediaStreamTrack extends EventTarget {
kind; label; enabled = true; readyState = 'live'
id = 'track-' + (++nextId)
#settings
constructor(kind = 'audio', label = '', settings = {}) {
super(); this.kind = kind; this.label = label; this.#settings = settings
}
stop() {
if (this.readyState === 'ended') return
this.readyState = 'ended'
this.dispatchEvent(new Event('ended'))
}
clone() { return new MediaStreamTrack(this.kind, this.label, this.#settings) }
getSettings() { return { ...this.#settings } }
}

class MediaStream {
id = 'stream-' + Math.random().toString(36).slice(2)
#tracks
_buffers = []
constructor(tracks = []) { this.#tracks = [...(tracks instanceof MediaStream ? tracks.getTracks() : tracks)] }
get active() { return this.#tracks.some(t => t.readyState === 'live') }
getTracks() { return [...this.#tracks] }
getAudioTracks() { return this.#tracks.filter(t => t.kind === 'audio') }
getVideoTracks() { return this.#tracks.filter(t => t.kind === 'video') }
addTrack(t) { if (!this.#tracks.includes(t)) this.#tracks.push(t) }
removeTrack(t) { let i = this.#tracks.indexOf(t); if (i >= 0) this.#tracks.splice(i, 1) }
}

globalThis.MediaStreamTrack ??= MediaStreamTrack
globalThis.MediaStream ??= MediaStream
globalThis.navigator ??= {}
globalThis.navigator.mediaDevices ??= {}

// --- navigator.mediaDevices.getUserMedia ---------------------------------
// Backed by the optional 'audio-mic' peer dep. Install: npm install audio-mic

let pick = v => v == null ? undefined : typeof v === 'number' ? v : (v.ideal ?? v.exact ?? v.min ?? v.max)
let splitPlanar = (data, channels) => {
if (channels === 1) return data
let frames = data.length / channels
return Array.from({ length: channels }, (_, ch) => data.subarray(ch * frames, (ch + 1) * frames))
}

let toFloat32 = (chunk, opts) => {
if (chunk instanceof Float32Array || Array.isArray(chunk)) return chunk
if (![8, 16, 32].includes(opts.bitDepth))
throw new TypeError('getUserMedia PCM conversion supports 8, 16, or 32-bit integer samples')
let bytes = chunk instanceof ArrayBuffer
? chunk
: chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
let data = convert(bytes, { dtype: `int${opts.bitDepth}`, channels: opts.channels, interleaved: true, endianness: 'le' },
{ dtype: 'float32', channels: opts.channels, interleaved: false })
return splitPlanar(data, opts.channels)
}

async function getUserMedia(constraints = {}) {
if (!constraints.audio) throw Object.assign(new Error(
Expand All @@ -82,23 +36,44 @@ async function getUserMedia(constraints = {}) {
if (![8, 16, 32].includes(opts.bitDepth)) throw Object.assign(new Error(
'getUserMedia supports 8, 16, or 32-bit integer PCM samples in Node'),
{ name: 'NotSupportedError' })

let read = mic(opts)
let track = new MediaStreamTrack('audio', 'Default audio input',
{ sampleRate: opts.sampleRate, channelCount: opts.channels, sampleSize: opts.bitDepth })
let stream = new MediaStream([track])
let live = true
let track = new waa.CustomMediaStreamTrack({
kind: 'audio', label: 'Default audio input',
settings: { sampleRate: opts.sampleRate, channelCount: opts.channels, sampleSize: opts.bitDepth }
})
let stream = new waa.MediaStream([track])

let pump = () => read((err, chunk) => {
if (!live || err || !chunk) return
stream._buffers.push(toFloat32(chunk, opts))
if (track.readyState === 'ended' || err || !chunk) return
track.pushData(chunk, { channels: opts.channels, bitDepth: opts.bitDepth })
pump()
})
pump()

let origStop = track.stop.bind(track)
track.stop = () => { live = false; try { read(null); read.close?.() } catch {} origStop() }
track.stop = () => {
// Best-effort mic close — errors here (e.g. already closed) are harmless.
try { read(null); read.close?.() } catch {}
origStop()
}
return stream
}

globalThis.navigator ??= {}
globalThis.navigator.mediaDevices ??= {}
globalThis.navigator.mediaDevices.getUserMedia ??= getUserMedia
const legacyGetUserMedia = typeof globalThis.navigator.getUserMedia === 'function'
? globalThis.navigator.getUserMedia.bind(globalThis.navigator)
: undefined

globalThis.navigator.mediaDevices.getUserMedia ??=
typeof legacyGetUserMedia === 'function'
? (constraints) => new Promise((resolve, reject) => legacyGetUserMedia(constraints, resolve, reject))
: getUserMedia

globalThis.navigator.getUserMedia ??= function (constraints, successCallback, errorCallback) {
Promise.resolve()
.then(() => globalThis.navigator.mediaDevices.getUserMedia(constraints))
.then(
(stream) => { if (typeof successCallback === 'function') successCallback(stream) },
(error) => { if (typeof errorCallback === 'function') errorCallback(error) }
)
}
Loading