From d59a62e6208cdc21f3ba189bee3e821541ce1f14 Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Wed, 29 Apr 2026 18:31:34 -0400 Subject: [PATCH 01/24] Simplify implementation --- README.md | 19 +++--- examples/mic.js | 9 ++- index.d.ts | 24 +++++++- index.js | 1 + polyfill.js | 97 +------------------------------ src/MediaStream.js | 79 +++++++++++++++++++++++++ src/MediaStreamAudioSourceNode.js | 60 +++---------------- test/MediaStreamNodes.test.js | 36 ++++++++---- test/polyfill.test.js | 20 ------- 9 files changed, 154 insertions(+), 191 deletions(-) create mode 100644 src/MediaStream.js diff --git a/README.md b/README.md index 47f1209..e8e5cb3 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ 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). +- **`MediaStreamTrack`, `MediaStream`** — exposed from `web-audio-api`. `MediaStreamTrack` has a `pushData(chunk, options)` method to feed audio (e.g. from a microphone). See the [mic FAQ](#how-do-i-capture-audio-from-the-microphone). ## FAQ @@ -147,35 +147,36 @@ const buffer = await ctx.decodeAudioData(readFileSync('track.mp3')) WAV, MP3, FLAC, OGG, AAC via [audio-decode](https://github.com/audiojs/audio-decode). -
How do I capture audio from the microphone?
+
How do I capture audio from the microphone?
-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 `MediaStreamTrack.pushData()`: ```sh npm install audio-mic ``` ```js -import { AudioContext, MediaStreamAudioSourceNode } from 'web-audio-api' +import { AudioContext, MediaStreamAudioSourceNode, MediaStreamTrack, 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 MediaStreamTrack('audio', 'mic', { 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`. 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()`.
diff --git a/examples/mic.js b/examples/mic.js index b89e5f3..26eb78e 100644 --- a/examples/mic.js +++ b/examples/mic.js @@ -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, MediaStreamTrack } from 'web-audio-api' import mic from 'audio-mic' import { args, keys, status, clearLine, pausedTag } from './_util.js' @@ -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 MediaStreamTrack('audio', 'mic', { 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 @@ -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) diff --git a/index.d.ts b/index.d.ts index beae5a9..22ee1a4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -259,7 +259,6 @@ export class AudioWorkletProcessor { export class MediaStreamAudioSourceNode extends AudioNode { readonly mediaStream: any; - pushData(channelData: Float32Array | Float32Array[] | ArrayBuffer | ArrayBufferView, options?: { channels?: number; numberOfChannels?: number; bitDepth?: 8 | 16 | 32 }): void; } export class MediaStreamAudioDestinationNode extends AudioNode { @@ -270,6 +269,29 @@ 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; + pushData(channelData: Float32Array | Float32Array[] | ArrayBuffer | ArrayBufferView, options?: { channels?: number; numberOfChannels?: number; bitDepth?: 8 | 16 | 32 }): void; +} + +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' } diff --git a/index.js b/index.js index 74a0bba..0252488 100644 --- a/index.js +++ b/index.js @@ -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 } 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' diff --git a/polyfill.js b/polyfill.js index e351c18..1fbeed0 100644 --- a/polyfill.js +++ b/polyfill.js @@ -1,8 +1,5 @@ // 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`. 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 @@ -10,95 +7,5 @@ for (let [name, value] of Object.entries(waa)) // 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. - -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 - -// --- navigator.mediaDevices.getUserMedia --------------------------------- - -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( - 'getUserMedia: only { audio } is supported in Node'), { name: 'NotSupportedError' }) - - let mic - try { mic = (await import('audio-mic')).default } - catch { throw Object.assign(new Error( - "getUserMedia requires 'audio-mic' in Node. Install: npm install audio-mic"), - { name: 'NotFoundError' }) } - - let c = constraints.audio === true ? {} : constraints.audio - let opts = { sampleRate: pick(c.sampleRate) ?? 44100, channels: pick(c.channelCount) ?? 1, bitDepth: pick(c.sampleSize) ?? 16 } - 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 pump = () => read((err, chunk) => { - if (!live || err || !chunk) return - stream._buffers.push(toFloat32(chunk, opts)) - pump() - }) - pump() - - let origStop = track.stop.bind(track) - track.stop = () => { live = false; try { read(null); read.close?.() } catch {} origStop() } - return stream -} - -globalThis.navigator ??= {} -globalThis.navigator.mediaDevices ??= {} -globalThis.navigator.mediaDevices.getUserMedia ??= getUserMedia +globalThis.MediaStreamTrack ??= waa.MediaStreamTrack +globalThis.MediaStream ??= waa.MediaStream diff --git a/src/MediaStream.js b/src/MediaStream.js new file mode 100644 index 0000000..5c6ad31 --- /dev/null +++ b/src/MediaStream.js @@ -0,0 +1,79 @@ +import convert from 'pcm-convert' + +let nextId = 0 +let splitPlanar = (data, channels) => { + if (channels === 1) return data + let frames = data.length / channels + let planes = [] + for (let ch = 0; ch < channels; ch++) planes.push(data.subarray(ch * frames, (ch + 1) * frames)) + return planes +} + +let isFloatChunk = chunk => + chunk instanceof Float32Array || + (Array.isArray(chunk) && chunk.every(ch => ch instanceof Float32Array)) + +let normalizeChunk = (chunk, channels, bitDepth) => { + if (isFloatChunk(chunk)) return chunk + if (![8, 16, 32].includes(bitDepth)) + throw new TypeError('pushData PCM conversion supports 8, 16, or 32-bit integer samples') + if (!chunk?.buffer && !(chunk instanceof ArrayBuffer)) + throw new TypeError('pushData expects Float32Array, Float32Array[], or interleaved PCM data') + + let bytes = chunk instanceof ArrayBuffer + ? chunk + : chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) + let data = convert(bytes, { dtype: `int${bitDepth}`, channels, interleaved: true, endianness: 'le' }, + { dtype: 'float32', channels, interleaved: false }) + return splitPlanar(data, channels) +} + +export class MediaStreamTrack extends EventTarget { + id = 'track-' + (++nextId) + kind + label + enabled = true + readyState = 'live' + #settings + _buffers = [] + + 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 } } + + pushData(chunk, options = {}) { + let channels = options.channels ?? options.numberOfChannels ?? this.#settings.channelCount ?? 1 + let bitDepth = options.bitDepth ?? this.#settings.sampleSize ?? 16 + this._buffers.push(normalizeChunk(chunk, channels, bitDepth)) + } +} + +export class MediaStream extends EventTarget { + id = 'stream-' + Math.random().toString(36).slice(2) + #tracks + + constructor(tracks = []) { + super() + 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) } +} diff --git a/src/MediaStreamAudioSourceNode.js b/src/MediaStreamAudioSourceNode.js index 62c48cd..ae0c8c6 100644 --- a/src/MediaStreamAudioSourceNode.js +++ b/src/MediaStreamAudioSourceNode.js @@ -1,44 +1,8 @@ import AudioNode from './AudioNode.js' import AudioBuffer from 'audio-buffer' -import convert from 'pcm-convert' import { BLOCK_SIZE } from './constants.js' import { DOMErr } from './errors.js' - -// Make a minimal MediaStreamTrack-shaped object (used by destination node below) -let nextId = 0 -let makeTrack = (kind = 'audio', settings = {}) => ({ - id: 'track-' + (++nextId), kind, enabled: true, readyState: 'live', - stop() { this.readyState = 'ended' }, - clone() { return makeTrack(this.kind, settings) }, - getSettings: () => ({ ...settings }), -}) - -let splitPlanar = (data, channels) => { - if (channels === 1) return data - let frames = data.length / channels - let planes = [] - for (let ch = 0; ch < channels; ch++) planes.push(data.subarray(ch * frames, (ch + 1) * frames)) - return planes -} - -let isFloatChunk = chunk => - chunk instanceof Float32Array || - (Array.isArray(chunk) && chunk.every(ch => ch instanceof Float32Array)) - -let normalizeChunk = (chunk, channels, bitDepth) => { - if (isFloatChunk(chunk)) return chunk - if (![8, 16, 32].includes(bitDepth)) - throw new TypeError('pushData PCM conversion supports 8, 16, or 32-bit integer samples') - if (!chunk?.buffer && !(chunk instanceof ArrayBuffer)) - throw new TypeError('pushData expects Float32Array, Float32Array[], or interleaved PCM data') - - let bytes = chunk instanceof ArrayBuffer - ? chunk - : chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) - let data = convert(bytes, { dtype: `int${bitDepth}`, channels, interleaved: true, endianness: 'le' }, - { dtype: 'float32', channels, interleaved: false }) - return splitPlanar(data, channels) -} +import { MediaStreamTrack } from './MediaStream.js' // Reads audio from a MediaStream-shaped source into the graph class MediaStreamAudioSourceNode extends AudioNode { @@ -46,7 +10,6 @@ class MediaStreamAudioSourceNode extends AudioNode { #pending = null // current chunk being drained #pos = 0 #channels - #bitDepth get mediaStream() { return this.#stream } @@ -56,32 +19,27 @@ class MediaStreamAudioSourceNode extends AudioNode { if (ms && (ms.getAudioTracks?.() ?? []).length === 0) throw DOMErr('MediaStream has no audio tracks', 'InvalidStateError') - let channels = options.numberOfChannels ?? 1 + let track = ms?.getAudioTracks?.()[0] + let settings = track?.getSettings?.() + let channels = options.numberOfChannels ?? settings?.channelCount ?? 1 super(context, 0, 1, channels, 'max', 'speakers') this.#stream = ms this.#channels = channels - this.#bitDepth = options.bitDepth ?? 16 this._outBuf = new AudioBuffer(channels, BLOCK_SIZE, context.sampleRate) this._applyOpts(options) } - pushData(chunk, options = {}) { - ;(this.#stream ??= { _buffers: [] })._buffers ??= [] - this.#stream._buffers.push(normalizeChunk( - chunk, - options.channels ?? options.numberOfChannels ?? this.#channels, - options.bitDepth ?? this.#bitDepth - )) - } - _tick() { super._tick() let out = this._outBuf for (let ch = 0; ch < this.#channels; ch++) out.getChannelData(ch).fill(0) + let track = this.#stream?.getAudioTracks?.()[0] + let buffers = track?._buffers ?? this.#stream?._buffers + let offset = 0 while (offset < BLOCK_SIZE) { - if (!this.#pending) this.#pending = this.#stream?._buffers?.shift() ?? null + if (!this.#pending) this.#pending = buffers?.shift() ?? null if (!this.#pending) break let chunk = this.#pending @@ -119,7 +77,7 @@ class MediaStreamAudioDestinationNode extends AudioNode { options = AudioNode._checkOpts(options) let channels = options.numberOfChannels ?? 2 super(context, 1, 0, channels, 'explicit', 'speakers') - let track = makeTrack('audio', { channelCount: channels, sampleRate: context.sampleRate }) + let track = new MediaStreamTrack('audio', '', { channelCount: channels, sampleRate: context.sampleRate }) this.#stream = { _buffers: [], read() { return this._buffers.shift() || null }, diff --git a/test/MediaStreamNodes.test.js b/test/MediaStreamNodes.test.js index cd525b4..99c50d5 100644 --- a/test/MediaStreamNodes.test.js +++ b/test/MediaStreamNodes.test.js @@ -5,17 +5,24 @@ import AudioNode from '../src/AudioNode.js' import AudioBuffer from 'audio-buffer' import { fill } from 'audio-buffer/util' import { MediaStreamAudioSourceNode, MediaStreamAudioDestinationNode } from '../src/MediaStreamAudioSourceNode.js' +import { MediaStream, MediaStreamTrack } from '../src/MediaStream.js' import { BLOCK_SIZE } from '../src/constants.js' let mkCtx = () => new AudioContext() +let mkStream = (opts) => { + let track = new MediaStreamTrack('audio', '', opts) + return new MediaStream([track]) +} + test('MediaStreamAudioSourceNode > outputs pushed data', () => { let ctx = mkCtx() - let node = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: 1 }) + let stream = mkStream({ channelCount: 1 }) + let node = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream }) let data = new Float32Array(BLOCK_SIZE) data.fill(0.6) - node.pushData(data) + stream.getAudioTracks()[0].pushData(data) ctx._state = 'running' let buf = node._tick() @@ -25,7 +32,8 @@ test('MediaStreamAudioSourceNode > outputs pushed data', () => { test('MediaStreamAudioSourceNode > outputs silence when no data', () => { let ctx = mkCtx() - let node = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: 1 }) + let stream = mkStream({ channelCount: 1 }) + let node = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream }) ctx._state = 'running' let buf = node._tick() @@ -73,10 +81,11 @@ test('ctx.createMediaStreamSource > rejects non-MediaStream input', () => { test('MediaStreamAudioSourceNode > pushData converts Int16 PCM buffer', () => { let ctx = mkCtx() - let src = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: 1 }) + let stream = mkStream({ channelCount: 1 }) + let src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream }) let pcm = Buffer.alloc(BLOCK_SIZE * 2) for (let i = 0; i < BLOCK_SIZE; i++) pcm.writeInt16LE(Math.round(0.5 * 32767), i * 2) - src.pushData(pcm, { channels: 1, bitDepth: 16 }) + stream.getAudioTracks()[0].pushData(pcm, { channels: 1, bitDepth: 16 }) ctx._state = 'running' let out = src._tick() @@ -86,13 +95,14 @@ test('MediaStreamAudioSourceNode > pushData converts Int16 PCM buffer', () => { test('MediaStreamAudioSourceNode > pushData deinterleaves stereo PCM buffer', () => { let ctx = mkCtx() - let src = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: 2 }) + let stream = mkStream({ channelCount: 2 }) + let src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream }) let pcm = Buffer.alloc(BLOCK_SIZE * 2 * 2) for (let i = 0; i < BLOCK_SIZE; i++) { pcm.writeInt16LE(Math.round(0.3 * 32767), (i * 2) * 2) pcm.writeInt16LE(Math.round(-0.4 * 32767), (i * 2 + 1) * 2) } - src.pushData(pcm, { channels: 2, bitDepth: 16 }) + stream.getAudioTracks()[0].pushData(pcm, { channels: 2, bitDepth: 16 }) ctx._state = 'running' let out = src._tick() @@ -103,9 +113,10 @@ test('MediaStreamAudioSourceNode > pushData deinterleaves stereo PCM buffer', () test('MediaStreamAudioSourceNode > queued chunks drain in order', () => { let ctx = mkCtx() - let src = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: 1 }) + let stream = mkStream({ channelCount: 1 }) + let src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream }) let values = [0.1, 0.2, 0.3] - for (let v of values) src.pushData(new Float32Array(BLOCK_SIZE).fill(v)) + for (let v of values) stream.getAudioTracks()[0].pushData(new Float32Array(BLOCK_SIZE).fill(v)) ctx._state = 'running' for (let v of values) { @@ -117,9 +128,10 @@ test('MediaStreamAudioSourceNode > queued chunks drain in order', () => { test('MediaStreamAudioSourceNode > short chunks fill the same quantum', () => { let ctx = mkCtx() - let src = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: 1 }) - src.pushData(new Float32Array(32).fill(0.25)) - src.pushData(new Float32Array(BLOCK_SIZE - 32).fill(-0.25)) + let stream = mkStream({ channelCount: 1 }) + let src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream }) + stream.getAudioTracks()[0].pushData(new Float32Array(32).fill(0.25)) + stream.getAudioTracks()[0].pushData(new Float32Array(BLOCK_SIZE - 32).fill(-0.25)) ctx._state = 'running' let out = src._tick().getChannelData(0) diff --git a/test/polyfill.test.js b/test/polyfill.test.js index d87f2f7..3e801b6 100644 --- a/test/polyfill.test.js +++ b/test/polyfill.test.js @@ -6,7 +6,6 @@ test('polyfill > Web Audio + MediaStream globals', () => { ok(typeof globalThis.AudioContext === 'function') ok(typeof globalThis.MediaStream === 'function') ok(typeof globalThis.MediaStreamTrack === 'function') - ok(typeof navigator.mediaDevices.getUserMedia === 'function') }) test('polyfill > MediaStreamTrack lifecycle', () => { @@ -26,25 +25,6 @@ test('polyfill > MediaStream aggregates tracks', () => { ok(!s.active) }) -test('polyfill > getUserMedia rejects without audio constraint', async () => { - let err - try { await navigator.mediaDevices.getUserMedia({}) } catch (e) { err = e } - is(err?.name, 'NotSupportedError') -}) - -test('polyfill > getUserMedia({audio:true}) — real stream or NotFoundError', async () => { - let stream, err - try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }) } catch (e) { err = e } - if (err) { - is(err.name, 'NotFoundError') - ok(/audio-mic/.test(err.message)) - } else { - ok(stream instanceof MediaStream) - is(stream.getAudioTracks().length, 1) - stream.getTracks().forEach(t => t.stop()) - } -}) - test('polyfill > createMediaStreamSource accepts polyfill MediaStream', () => { let ctx = new AudioContext() let s = new MediaStream([new MediaStreamTrack('audio')]) From f6e8329fa807e583561377776bf9eef04893f72b Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Thu, 30 Apr 2026 08:22:00 -0400 Subject: [PATCH 02/24] Make safer CustomMediaStreamTrack --- README.md | 16 ++++++++++------ examples/mic.js | 4 ++-- index.d.ts | 5 +++++ index.js | 2 +- polyfill.js | 1 + src/MediaStream.js | 26 ++++++++++++++++++++------ test/MediaStreamNodes.test.js | 6 +++--- test/polyfill.test.js | 11 ++++++++++- 8 files changed, 52 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e8e5cb3..c546895 100644 --- a/README.md +++ b/README.md @@ -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 -- **`MediaStreamTrack`, `MediaStream`** — exposed from `web-audio-api`. `MediaStreamTrack` has a `pushData(chunk, options)` method to feed audio (e.g. from a microphone). 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). ## FAQ @@ -150,20 +150,24 @@ WAV, MP3, FLAC, OGG, AAC via [audio-decode](https://github.com/audiojs/audio-dec
How do I capture audio from the microphone?
-In Node, pair [`audio-mic`](https://github.com/audiojs/audio-mic) with `MediaStreamTrack.pushData()`: +In Node, pair [`audio-mic`](https://github.com/audiojs/audio-mic) with `CustomMediaStreamTrack`: ```sh npm install audio-mic ``` ```js -import { AudioContext, MediaStreamAudioSourceNode, MediaStreamTrack, MediaStream } 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 track = new MediaStreamTrack('audio', 'mic', { channelCount: 1, sampleSize: 16, sampleRate: ctx.sampleRate }) +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 }) @@ -176,7 +180,7 @@ read((err, buf) => { }) ``` -`track.pushData()` accepts `Float32Array`, `Float32Array[]`, or interleaved 8/16/32-bit integer PCM buffers. Integer PCM conversion uses `pcm-convert`. +`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()`.
diff --git a/examples/mic.js b/examples/mic.js index 26eb78e..26f4d00 100644 --- a/examples/mic.js +++ b/examples/mic.js @@ -5,7 +5,7 @@ // Run: node examples/mic.js gain=0.8 // Keys: space pause · + / - adjust gain · q quit -import { AudioContext, MediaStreamAudioSourceNode, MediaStream, MediaStreamTrack } 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' @@ -18,7 +18,7 @@ let bitDepth = parseInt($('bit', '16')) const ctx = new AudioContext({ sampleRate }) await ctx.resume() -const track = new MediaStreamTrack('audio', 'mic', { channelCount: channels, sampleSize: bitDepth, sampleRate }) +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 }) diff --git a/index.d.ts b/index.d.ts index 22ee1a4..d5987ef 100644 --- a/index.d.ts +++ b/index.d.ts @@ -278,7 +278,12 @@ export class MediaStreamTrack extends EventTarget { stop(): void; clone(): MediaStreamTrack; getSettings(): Record; +} + +export class CustomMediaStreamTrack extends MediaStreamTrack { + constructor(options?: { kind?: string; label?: string; settings?: Record }); pushData(channelData: Float32Array | Float32Array[] | ArrayBuffer | ArrayBufferView, options?: { channels?: number; numberOfChannels?: number; bitDepth?: 8 | 16 | 32 }): void; + clone(): CustomMediaStreamTrack; } export class MediaStream extends EventTarget { diff --git a/index.js b/index.js index 0252488..1bf0364 100644 --- a/index.js +++ b/index.js @@ -25,7 +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 } from './src/MediaStream.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' diff --git a/polyfill.js b/polyfill.js index 1fbeed0..e29380b 100644 --- a/polyfill.js +++ b/polyfill.js @@ -9,3 +9,4 @@ if (typeof window === 'undefined') globalThis.window = globalThis globalThis.MediaStreamTrack ??= waa.MediaStreamTrack globalThis.MediaStream ??= waa.MediaStream +globalThis.CustomMediaStreamTrack ??= waa.CustomMediaStreamTrack diff --git a/src/MediaStream.js b/src/MediaStream.js index 5c6ad31..abf67d5 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -28,14 +28,15 @@ let normalizeChunk = (chunk, channels, bitDepth) => { return splitPlanar(data, channels) } +// Per W3C Media Capture spec, MediaStreamTrack has no public constructor. +// We provide one as base class for subclassing (like CanvasCaptureMediaStreamTrack). export class MediaStreamTrack extends EventTarget { id = 'track-' + (++nextId) - kind - label + kind = 'audio' + label = '' enabled = true readyState = 'live' - #settings - _buffers = [] + #settings = {} constructor(kind = 'audio', label = '', settings = {}) { super() @@ -53,12 +54,25 @@ export class MediaStreamTrack extends EventTarget { clone() { return new MediaStreamTrack(this.kind, this.label, this.#settings) } getSettings() { return { ...this.#settings } } +} + +// Node extension: custom track with public constructor and pushData(). +// Prior art: CanvasCaptureMediaStreamTrack extends MediaStreamTrack. +export class CustomMediaStreamTrack extends MediaStreamTrack { + _buffers = [] + + constructor({ kind = 'audio', label = '', settings = {} } = {}) { + super(kind, label, settings) + } pushData(chunk, options = {}) { - let channels = options.channels ?? options.numberOfChannels ?? this.#settings.channelCount ?? 1 - let bitDepth = options.bitDepth ?? this.#settings.sampleSize ?? 16 + let settings = this.getSettings() + let channels = options.channels ?? options.numberOfChannels ?? settings.channelCount ?? 1 + let bitDepth = options.bitDepth ?? settings.sampleSize ?? settings.bitDepth ?? 16 this._buffers.push(normalizeChunk(chunk, channels, bitDepth)) } + + clone() { return new CustomMediaStreamTrack({ kind: this.kind, label: this.label, settings: this.getSettings() }) } } export class MediaStream extends EventTarget { diff --git a/test/MediaStreamNodes.test.js b/test/MediaStreamNodes.test.js index 99c50d5..bd911ea 100644 --- a/test/MediaStreamNodes.test.js +++ b/test/MediaStreamNodes.test.js @@ -5,13 +5,13 @@ import AudioNode from '../src/AudioNode.js' import AudioBuffer from 'audio-buffer' import { fill } from 'audio-buffer/util' import { MediaStreamAudioSourceNode, MediaStreamAudioDestinationNode } from '../src/MediaStreamAudioSourceNode.js' -import { MediaStream, MediaStreamTrack } from '../src/MediaStream.js' +import { MediaStream, CustomMediaStreamTrack } from '../src/MediaStream.js' import { BLOCK_SIZE } from '../src/constants.js' let mkCtx = () => new AudioContext() -let mkStream = (opts) => { - let track = new MediaStreamTrack('audio', '', opts) +let mkStream = (settings) => { + let track = new CustomMediaStreamTrack({ kind: 'audio', settings }) return new MediaStream([track]) } diff --git a/test/polyfill.test.js b/test/polyfill.test.js index 3e801b6..9daeb96 100644 --- a/test/polyfill.test.js +++ b/test/polyfill.test.js @@ -6,6 +6,7 @@ test('polyfill > Web Audio + MediaStream globals', () => { ok(typeof globalThis.AudioContext === 'function') ok(typeof globalThis.MediaStream === 'function') ok(typeof globalThis.MediaStreamTrack === 'function') + ok(typeof globalThis.CustomMediaStreamTrack === 'function') }) test('polyfill > MediaStreamTrack lifecycle', () => { @@ -15,6 +16,14 @@ test('polyfill > MediaStreamTrack lifecycle', () => { ok(t.clone() instanceof MediaStreamTrack) }) +test('polyfill > CustomMediaStreamTrack pushData', () => { + let t = new CustomMediaStreamTrack({ kind: 'audio', label: 'Mic', settings: { channelCount: 1 } }) + is(t.kind, 'audio'); is(t.label, 'Mic'); is(t.readyState, 'live') + t.pushData(new Float32Array(128).fill(0.5)) + is(t._buffers.length, 1) + ok(t.clone() instanceof CustomMediaStreamTrack) +}) + test('polyfill > MediaStream aggregates tracks', () => { let a = new MediaStreamTrack('audio') let s = new MediaStream([a]) @@ -27,7 +36,7 @@ test('polyfill > MediaStream aggregates tracks', () => { test('polyfill > createMediaStreamSource accepts polyfill MediaStream', () => { let ctx = new AudioContext() - let s = new MediaStream([new MediaStreamTrack('audio')]) + let s = new MediaStream([new CustomMediaStreamTrack({ kind: 'audio' })]) let node = ctx.createMediaStreamSource(s) is(node.mediaStream, s) }) From ea2f4b1e3b8103d2f8269a972892e9b23f8d3895 Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Fri, 1 May 2026 20:09:20 -0400 Subject: [PATCH 03/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- polyfill.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/polyfill.js b/polyfill.js index e29380b..d51862f 100644 --- a/polyfill.js +++ b/polyfill.js @@ -10,3 +10,10 @@ if (typeof window === 'undefined') globalThis.window = globalThis globalThis.MediaStreamTrack ??= waa.MediaStreamTrack globalThis.MediaStream ??= waa.MediaStream globalThis.CustomMediaStreamTrack ??= waa.CustomMediaStreamTrack + +if (typeof waa.getUserMedia === 'function') { + globalThis.navigator ??= {} + globalThis.navigator.mediaDevices ??= {} + globalThis.navigator.mediaDevices.getUserMedia ??= waa.getUserMedia.bind(waa) + globalThis.navigator.getUserMedia ??= globalThis.navigator.mediaDevices.getUserMedia +} From 7e9bd01441f62a865cc8b3accd8c84ecf47d2633 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 00:11:27 +0000 Subject: [PATCH 04/24] fix: CustomMediaStreamTrack.clone() shares _buffers with original Agent-Logs-Url: https://github.com/audiojs/web-audio-api/sessions/65b9b190-f4f3-4010-b138-002f2c928f29 Co-authored-by: dy <300067+dy@users.noreply.github.com> --- package-lock.json | 9 +++++++++ src/MediaStream.js | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 5d37f34..09aadc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,14 @@ }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "audio-mic": "*" + }, + "peerDependenciesMeta": { + "audio-mic": { + "optional": true + } } }, "node_modules/@audio/aac-decode": { @@ -262,6 +270,7 @@ }, "node_modules/audio-speaker": { "version": "2.0.5", + "hasInstallScript": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/src/MediaStream.js b/src/MediaStream.js index abf67d5..10cf987 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -72,7 +72,11 @@ export class CustomMediaStreamTrack extends MediaStreamTrack { this._buffers.push(normalizeChunk(chunk, channels, bitDepth)) } - clone() { return new CustomMediaStreamTrack({ kind: this.kind, label: this.label, settings: this.getSettings() }) } + clone() { + let clone = new CustomMediaStreamTrack({ kind: this.kind, label: this.label, settings: this.getSettings() }) + clone._buffers = this._buffers + return clone + } } export class MediaStream extends EventTarget { From d0cd67a1bfe9a4d5c77bc570d3d6b9e3da837e71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 00:13:59 +0000 Subject: [PATCH 05/24] fix: enforce track lifecycle in pushData and _tick Agent-Logs-Url: https://github.com/audiojs/web-audio-api/sessions/7235af34-e944-4ffd-a5d3-799530153992 Co-authored-by: dy <300067+dy@users.noreply.github.com> --- src/MediaStream.js | 1 + src/MediaStreamAudioSourceNode.js | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/MediaStream.js b/src/MediaStream.js index 10cf987..9ce32d9 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -66,6 +66,7 @@ export class CustomMediaStreamTrack extends MediaStreamTrack { } pushData(chunk, options = {}) { + if (this.readyState === 'ended') return let settings = this.getSettings() let channels = options.channels ?? options.numberOfChannels ?? settings.channelCount ?? 1 let bitDepth = options.bitDepth ?? settings.sampleSize ?? settings.bitDepth ?? 16 diff --git a/src/MediaStreamAudioSourceNode.js b/src/MediaStreamAudioSourceNode.js index ae0c8c6..48fe0c5 100644 --- a/src/MediaStreamAudioSourceNode.js +++ b/src/MediaStreamAudioSourceNode.js @@ -35,6 +35,17 @@ class MediaStreamAudioSourceNode extends AudioNode { for (let ch = 0; ch < this.#channels; ch++) out.getChannelData(ch).fill(0) let track = this.#stream?.getAudioTracks?.()[0] + + // go silent and clear state if track has ended + if (track?.readyState === 'ended') { + this.#pending = null + this.#pos = 0 + return out + } + + // go silent without draining if track is disabled (resumes on re-enable) + if (track && !track.enabled) return out + let buffers = track?._buffers ?? this.#stream?._buffers let offset = 0 From a804a69cb24fadb158da59627ccb90c561967526 Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Fri, 1 May 2026 21:08:31 -0400 Subject: [PATCH 06/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/MediaStream.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/MediaStream.js b/src/MediaStream.js index 9ce32d9..2e660ee 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -32,22 +32,28 @@ let normalizeChunk = (chunk, channels, bitDepth) => { // We provide one as base class for subclassing (like CanvasCaptureMediaStreamTrack). export class MediaStreamTrack extends EventTarget { id = 'track-' + (++nextId) - kind = 'audio' - label = '' + #kind = 'audio' + #label = '' enabled = true - readyState = 'live' + #readyState = 'live' #settings = {} constructor(kind = 'audio', label = '', settings = {}) { super() - this.kind = kind - this.label = label + this.#kind = kind + this.#label = label this.#settings = settings } + get kind() { return this.#kind } + + get label() { return this.#label } + + get readyState() { return this.#readyState } + stop() { - if (this.readyState === 'ended') return - this.readyState = 'ended' + if (this.#readyState === 'ended') return + this.#readyState = 'ended' this.dispatchEvent(new Event('ended')) } From 981e2fcd607f0e74eb3684facc4aac4ca166533e Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Fri, 1 May 2026 21:08:39 -0400 Subject: [PATCH 07/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/MediaStream.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/MediaStream.js b/src/MediaStream.js index 2e660ee..4c909c2 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -95,10 +95,27 @@ export class MediaStream extends EventTarget { this.#tracks = [...(tracks instanceof MediaStream ? tracks.getTracks() : tracks)] } + #dispatchTrackEvent(type, track) { + let event = new Event(type) + Object.defineProperty(event, 'track', { value: track, enumerable: true }) + this.dispatchEvent(event) + } + 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) } + addTrack(t) { + if (!this.#tracks.includes(t)) { + this.#tracks.push(t) + this.#dispatchTrackEvent('addtrack', t) + } + } + removeTrack(t) { + let i = this.#tracks.indexOf(t) + if (i >= 0) { + this.#tracks.splice(i, 1) + this.#dispatchTrackEvent('removetrack', t) + } + } } From 377a61b5c2d09235b10e501e87140e820c3526f6 Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Fri, 1 May 2026 21:08:48 -0400 Subject: [PATCH 08/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- polyfill.js | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/polyfill.js b/polyfill.js index d51862f..c59f232 100644 --- a/polyfill.js +++ b/polyfill.js @@ -11,9 +11,34 @@ globalThis.MediaStreamTrack ??= waa.MediaStreamTrack globalThis.MediaStream ??= waa.MediaStream globalThis.CustomMediaStreamTrack ??= waa.CustomMediaStreamTrack -if (typeof waa.getUserMedia === 'function') { - globalThis.navigator ??= {} - globalThis.navigator.mediaDevices ??= {} - globalThis.navigator.mediaDevices.getUserMedia ??= waa.getUserMedia.bind(waa) - globalThis.navigator.getUserMedia ??= globalThis.navigator.mediaDevices.getUserMedia +globalThis.navigator ??= {} +globalThis.navigator.mediaDevices ??= {} + +const legacyGetUserMedia = typeof globalThis.navigator.getUserMedia === 'function' + ? globalThis.navigator.getUserMedia.bind(globalThis.navigator) + : undefined + +const installedGetUserMedia = + typeof waa.getUserMedia === 'function' + ? waa.getUserMedia.bind(waa) + : typeof legacyGetUserMedia === 'function' + ? (constraints) => + new Promise((resolve, reject) => { + legacyGetUserMedia(constraints, resolve, reject) + }) + : () => + Promise.reject( + new TypeError('navigator.mediaDevices.getUserMedia is not implemented') + ) + +globalThis.navigator.mediaDevices.getUserMedia ??= installedGetUserMedia +globalThis.navigator.getUserMedia ??= function (constraints, successCallback, errorCallback) { + globalThis.navigator.mediaDevices.getUserMedia(constraints).then( + (stream) => { + if (typeof successCallback === 'function') successCallback(stream) + }, + (error) => { + if (typeof errorCallback === 'function') errorCallback(error) + } + ) } From e346bcbc235b8f83c25af2f8926bd3cd5168e1ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 01:11:12 +0000 Subject: [PATCH 09/24] fix: use CustomMediaStreamTrack in destination node so clone() shares buffers Agent-Logs-Url: https://github.com/audiojs/web-audio-api/sessions/27f3cc8d-37f9-4feb-865a-8f33d5b75a8a Co-authored-by: dy <300067+dy@users.noreply.github.com> --- src/MediaStreamAudioSourceNode.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/MediaStreamAudioSourceNode.js b/src/MediaStreamAudioSourceNode.js index 48fe0c5..7959139 100644 --- a/src/MediaStreamAudioSourceNode.js +++ b/src/MediaStreamAudioSourceNode.js @@ -2,7 +2,7 @@ import AudioNode from './AudioNode.js' import AudioBuffer from 'audio-buffer' import { BLOCK_SIZE } from './constants.js' import { DOMErr } from './errors.js' -import { MediaStreamTrack } from './MediaStream.js' +import { CustomMediaStreamTrack } from './MediaStream.js' // Reads audio from a MediaStream-shaped source into the graph class MediaStreamAudioSourceNode extends AudioNode { @@ -82,17 +82,19 @@ class MediaStreamAudioSourceNode extends AudioNode { // Captures graph output into a MediaStream for external consumers. class MediaStreamAudioDestinationNode extends AudioNode { #stream + #track get stream() { return this.#stream } constructor(context, options) { options = AudioNode._checkOpts(options) let channels = options.numberOfChannels ?? 2 super(context, 1, 0, channels, 'explicit', 'speakers') - let track = new MediaStreamTrack('audio', '', { channelCount: channels, sampleRate: context.sampleRate }) + let track = new CustomMediaStreamTrack({ kind: 'audio', settings: { channelCount: channels, sampleRate: context.sampleRate } }) + this.#track = track this.#stream = { - _buffers: [], - read() { return this._buffers.shift() || null }, - get readable() { return this._buffers.length > 0 }, + get _buffers() { return track._buffers }, + read() { return track._buffers.shift() || null }, + get readable() { return track._buffers.length > 0 }, getTracks: () => [track], getAudioTracks: () => [track], getVideoTracks: () => [], @@ -106,7 +108,7 @@ class MediaStreamAudioDestinationNode extends AudioNode { let inBuf = this._inputs[0]._tick() let chunk = [] for (let ch = 0; ch < inBuf.numberOfChannels; ch++) chunk.push(new Float32Array(inBuf.getChannelData(ch))) - this.#stream._buffers.push(chunk) + this.#track._buffers.push(chunk) return inBuf } } From 94d8f19b5a765d61285649a4acb0af84451c8a75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 01:15:46 +0000 Subject: [PATCH 10/24] fix: cache track in source node; fan-out clone pattern with auto-cleanup Agent-Logs-Url: https://github.com/audiojs/web-audio-api/sessions/9e18cfad-5d7c-4809-b293-e4c1f3de4c01 Co-authored-by: dy <300067+dy@users.noreply.github.com> --- src/MediaStream.js | 13 +++++++++++-- src/MediaStreamAudioSourceNode.js | 6 ++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/MediaStream.js b/src/MediaStream.js index 4c909c2..807e03d 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -66,22 +66,31 @@ export class MediaStreamTrack extends EventTarget { // Prior art: CanvasCaptureMediaStreamTrack extends MediaStreamTrack. export class CustomMediaStreamTrack extends MediaStreamTrack { _buffers = [] + #clones = new Set() constructor({ kind = 'audio', label = '', settings = {} } = {}) { super(kind, label, settings) } + // Internal: fan out an already-normalised chunk to this track and all live clones. + _pushNormalized(chunk) { + this._buffers.push(chunk) + for (let clone of this.#clones) clone._pushNormalized(chunk) + } + pushData(chunk, options = {}) { if (this.readyState === 'ended') return let settings = this.getSettings() let channels = options.channels ?? options.numberOfChannels ?? settings.channelCount ?? 1 let bitDepth = options.bitDepth ?? settings.sampleSize ?? settings.bitDepth ?? 16 - this._buffers.push(normalizeChunk(chunk, channels, bitDepth)) + this._pushNormalized(normalizeChunk(chunk, channels, bitDepth)) } clone() { let clone = new CustomMediaStreamTrack({ kind: this.kind, label: this.label, settings: this.getSettings() }) - clone._buffers = this._buffers + this.#clones.add(clone) + // Remove from fan-out set when the clone ends to avoid memory leaks. + clone.addEventListener('ended', () => this.#clones.delete(clone), { once: true }) return clone } } diff --git a/src/MediaStreamAudioSourceNode.js b/src/MediaStreamAudioSourceNode.js index 7959139..6cdaf5e 100644 --- a/src/MediaStreamAudioSourceNode.js +++ b/src/MediaStreamAudioSourceNode.js @@ -7,6 +7,7 @@ import { CustomMediaStreamTrack } from './MediaStream.js' // Reads audio from a MediaStream-shaped source into the graph class MediaStreamAudioSourceNode extends AudioNode { #stream + #track // cached first audio track — avoids per-quantum getAudioTracks() allocation #pending = null // current chunk being drained #pos = 0 #channels @@ -24,6 +25,7 @@ class MediaStreamAudioSourceNode extends AudioNode { let channels = options.numberOfChannels ?? settings?.channelCount ?? 1 super(context, 0, 1, channels, 'max', 'speakers') this.#stream = ms + this.#track = track ?? null this.#channels = channels this._outBuf = new AudioBuffer(channels, BLOCK_SIZE, context.sampleRate) this._applyOpts(options) @@ -34,7 +36,7 @@ class MediaStreamAudioSourceNode extends AudioNode { let out = this._outBuf for (let ch = 0; ch < this.#channels; ch++) out.getChannelData(ch).fill(0) - let track = this.#stream?.getAudioTracks?.()[0] + let track = this.#track // go silent and clear state if track has ended if (track?.readyState === 'ended') { @@ -108,7 +110,7 @@ class MediaStreamAudioDestinationNode extends AudioNode { let inBuf = this._inputs[0]._tick() let chunk = [] for (let ch = 0; ch < inBuf.numberOfChannels; ch++) chunk.push(new Float32Array(inBuf.getChannelData(ch))) - this.#track._buffers.push(chunk) + this.#track._pushNormalized(chunk) return inBuf } } From 526c715cb336f3b518f007394db64e2260f89c7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 03:54:12 +0000 Subject: [PATCH 11/24] fix: destination track lifecycle, WeakRef clones, pushData() compat shim, new tests Agent-Logs-Url: https://github.com/audiojs/web-audio-api/sessions/d596fb48-5208-4d87-870a-2037c2776bf0 Co-authored-by: dy <300067+dy@users.noreply.github.com> --- index.d.ts | 1 + src/MediaStream.js | 21 ++++++++--- src/MediaStreamAudioSourceNode.js | 24 ++++++++---- test/MediaStreamNodes.test.js | 61 +++++++++++++++++++++++++++++++ test/polyfill.test.js | 5 +++ 5 files changed, 99 insertions(+), 13 deletions(-) diff --git a/index.d.ts b/index.d.ts index d5987ef..079636b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -259,6 +259,7 @@ export class AudioWorkletProcessor { export class MediaStreamAudioSourceNode extends AudioNode { readonly mediaStream: any; + pushData(channelData: Float32Array | Float32Array[] | ArrayBuffer | ArrayBufferView, options?: { channels?: number; numberOfChannels?: number; bitDepth?: 8 | 16 | 32 }): void; } export class MediaStreamAudioDestinationNode extends AudioNode { diff --git a/src/MediaStream.js b/src/MediaStream.js index 807e03d..02dcd53 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -66,7 +66,9 @@ export class MediaStreamTrack extends EventTarget { // Prior art: CanvasCaptureMediaStreamTrack extends MediaStreamTrack. export class CustomMediaStreamTrack extends MediaStreamTrack { _buffers = [] - #clones = new Set() + // WeakRef-based fan-out: clones can be GC'd when no external reference is held. + #clones = new Set() // Set> + #registry = new FinalizationRegistry(ref => this.#clones.delete(ref)) constructor({ kind = 'audio', label = '', settings = {} } = {}) { super(kind, label, settings) @@ -75,7 +77,11 @@ export class CustomMediaStreamTrack extends MediaStreamTrack { // Internal: fan out an already-normalised chunk to this track and all live clones. _pushNormalized(chunk) { this._buffers.push(chunk) - for (let clone of this.#clones) clone._pushNormalized(chunk) + for (let ref of this.#clones) { + let clone = ref.deref() + if (clone) clone._pushNormalized(chunk) + else this.#clones.delete(ref) + } } pushData(chunk, options = {}) { @@ -88,9 +94,14 @@ export class CustomMediaStreamTrack extends MediaStreamTrack { clone() { let clone = new CustomMediaStreamTrack({ kind: this.kind, label: this.label, settings: this.getSettings() }) - this.#clones.add(clone) - // Remove from fan-out set when the clone ends to avoid memory leaks. - clone.addEventListener('ended', () => this.#clones.delete(clone), { once: true }) + let ref = new WeakRef(clone) + this.#clones.add(ref) + this.#registry.register(clone, ref, clone) + // Also eagerly clean up when the clone is explicitly stopped. + clone.addEventListener('ended', () => { + this.#registry.unregister(clone) + this.#clones.delete(ref) + }, { once: true }) return clone } } diff --git a/src/MediaStreamAudioSourceNode.js b/src/MediaStreamAudioSourceNode.js index 6cdaf5e..0dcfb3d 100644 --- a/src/MediaStreamAudioSourceNode.js +++ b/src/MediaStreamAudioSourceNode.js @@ -20,17 +20,23 @@ class MediaStreamAudioSourceNode extends AudioNode { if (ms && (ms.getAudioTracks?.() ?? []).length === 0) throw DOMErr('MediaStream has no audio tracks', 'InvalidStateError') - let track = ms?.getAudioTracks?.()[0] + let track = ms?.getAudioTracks?.()[0] ?? null let settings = track?.getSettings?.() let channels = options.numberOfChannels ?? settings?.channelCount ?? 1 super(context, 0, 1, channels, 'max', 'speakers') this.#stream = ms - this.#track = track ?? null + // When no MediaStream is given, create an internal track so pushData() still works. + this.#track = track ?? new CustomMediaStreamTrack({ settings: { channelCount: channels } }) this.#channels = channels this._outBuf = new AudioBuffer(channels, BLOCK_SIZE, context.sampleRate) this._applyOpts(options) } + // Backward-compatible entry point: delegates to the track's pushData(). + pushData(chunk, options = {}) { + this.#track.pushData(chunk, options) + } + _tick() { super._tick() let out = this._outBuf @@ -39,16 +45,16 @@ class MediaStreamAudioSourceNode extends AudioNode { let track = this.#track // go silent and clear state if track has ended - if (track?.readyState === 'ended') { + if (track.readyState === 'ended') { this.#pending = null this.#pos = 0 return out } // go silent without draining if track is disabled (resumes on re-enable) - if (track && !track.enabled) return out + if (!track.enabled) return out - let buffers = track?._buffers ?? this.#stream?._buffers + let buffers = track._buffers ?? this.#stream?._buffers let offset = 0 while (offset < BLOCK_SIZE) { @@ -108,9 +114,11 @@ class MediaStreamAudioDestinationNode extends AudioNode { _tick() { super._tick() let inBuf = this._inputs[0]._tick() - let chunk = [] - for (let ch = 0; ch < inBuf.numberOfChannels; ch++) chunk.push(new Float32Array(inBuf.getChannelData(ch))) - this.#track._pushNormalized(chunk) + if (this.#track.readyState !== 'ended') { + let chunk = [] + for (let ch = 0; ch < inBuf.numberOfChannels; ch++) chunk.push(new Float32Array(inBuf.getChannelData(ch))) + this.#track._pushNormalized(chunk) + } return inBuf } } diff --git a/test/MediaStreamNodes.test.js b/test/MediaStreamNodes.test.js index bd911ea..1714955 100644 --- a/test/MediaStreamNodes.test.js +++ b/test/MediaStreamNodes.test.js @@ -140,3 +140,64 @@ test('MediaStreamAudioSourceNode > short chunks fill the same quantum', () => { almost(out[32], -0.25, 1e-6, 'second chunk continues in same quantum') almost(out[BLOCK_SIZE - 1], -0.25, 1e-6, 'second chunk fills quantum') }) + +test('MediaStreamAudioSourceNode > pushData() compat: works without MediaStream', () => { + let ctx = mkCtx() + let src = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: 1 }) + src.pushData(new Float32Array(BLOCK_SIZE).fill(0.5)) + + ctx._state = 'running' + let out = src._tick() + almost(out.getChannelData(0)[0], 0.5, 1e-6, 'legacy pushData() still works') +}) + +test('MediaStreamAudioDestinationNode > stops capturing after track.stop()', () => { + let ctx = mkCtx() + let dest = new MediaStreamAudioDestinationNode(ctx, { numberOfChannels: 1 }) + let src = new AudioNode(ctx, 0, 1) + src.connect(dest) + src._tick = () => { let b = new AudioBuffer(1, BLOCK_SIZE, 44100); b.getChannelData(0).fill(0.7); return b } + + ctx._state = 'running' + dest._tick() + ok(dest.stream.readable, 'has data before stop') + + // drain the queue then stop the track + while (dest.stream.readable) dest.stream.read() + dest.stream.getAudioTracks()[0].stop() + dest._tick() + ok(!dest.stream.readable, 'no new data after track.stop()') +}) + +test('CustomMediaStreamTrack > clone fan-out: clone receives future chunks', () => { + let track = new CustomMediaStreamTrack({}) + let clone = track.clone() + + track.pushData(new Float32Array(BLOCK_SIZE).fill(0.3)) + is(clone._buffers.length, 1, 'clone receives pushed chunk') + almost(clone._buffers[0][0], 0.3, 1e-6, 'clone chunk has correct data') +}) + +test('CustomMediaStreamTrack > clone fan-out: stop() unsubscribes clone', () => { + let track = new CustomMediaStreamTrack({}) + let clone = track.clone() + clone.stop() + + track.pushData(new Float32Array(BLOCK_SIZE).fill(0.3)) + is(clone._buffers.length, 0, 'stopped clone no longer receives data') +}) + +test('MediaStream > addTrack / removeTrack fire events with event.track', () => { + let stream = new MediaStream() + let track = new CustomMediaStreamTrack({ kind: 'audio' }) + + let added = null, removed = null + stream.addEventListener('addtrack', e => { added = e.track }) + stream.addEventListener('removetrack', e => { removed = e.track }) + + stream.addTrack(track) + ok(added === track, 'addtrack event fired with correct track') + + stream.removeTrack(track) + ok(removed === track, 'removetrack event fired with correct track') +}) diff --git a/test/polyfill.test.js b/test/polyfill.test.js index 9daeb96..d1a6482 100644 --- a/test/polyfill.test.js +++ b/test/polyfill.test.js @@ -40,3 +40,8 @@ test('polyfill > createMediaStreamSource accepts polyfill MediaStream', () => { let node = ctx.createMediaStreamSource(s) is(node.mediaStream, s) }) + +test('polyfill > navigator.mediaDevices.getUserMedia is a function', () => { + ok(typeof globalThis.navigator.mediaDevices.getUserMedia === 'function', + 'getUserMedia installed on mediaDevices') +}) From d86de1ffbe1f8eaac2c0a8d78c59e8982c9f08a8 Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Fri, 1 May 2026 23:56:07 -0400 Subject: [PATCH 12/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/MediaStreamAudioSourceNode.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MediaStreamAudioSourceNode.js b/src/MediaStreamAudioSourceNode.js index 0dcfb3d..e54f0c3 100644 --- a/src/MediaStreamAudioSourceNode.js +++ b/src/MediaStreamAudioSourceNode.js @@ -110,7 +110,7 @@ class MediaStreamAudioDestinationNode extends AudioNode { this._applyOpts(options) context._tailNodes?.add(this) } - + if (this.#track?.readyState !== 'ended') this.#track._pushNormalized(chunk) _tick() { super._tick() let inBuf = this._inputs[0]._tick() From 38acf7af679697bf6fe5d3e2b94cb41dabecbb5c Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Sat, 2 May 2026 00:04:33 -0400 Subject: [PATCH 13/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/MediaStreamAudioSourceNode.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/MediaStreamAudioSourceNode.js b/src/MediaStreamAudioSourceNode.js index e54f0c3..04120ce 100644 --- a/src/MediaStreamAudioSourceNode.js +++ b/src/MediaStreamAudioSourceNode.js @@ -23,10 +23,16 @@ class MediaStreamAudioSourceNode extends AudioNode { let track = ms?.getAudioTracks?.()[0] ?? null let settings = track?.getSettings?.() let channels = options.numberOfChannels ?? settings?.channelCount ?? 1 + let bitDepth = options.bitDepth ?? settings?.bitDepth super(context, 0, 1, channels, 'max', 'speakers') this.#stream = ms // When no MediaStream is given, create an internal track so pushData() still works. - this.#track = track ?? new CustomMediaStreamTrack({ settings: { channelCount: channels } }) + this.#track = track ?? new CustomMediaStreamTrack({ + settings: { + channelCount: channels, + ...(bitDepth == null ? {} : { bitDepth }) + } + }) this.#channels = channels this._outBuf = new AudioBuffer(channels, BLOCK_SIZE, context.sampleRate) this._applyOpts(options) From 4e85c1cb89ecb6878a16dd3d47abc5d3ef9efe0c Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Sat, 2 May 2026 00:04:44 -0400 Subject: [PATCH 14/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/MediaStream.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/MediaStream.js b/src/MediaStream.js index 02dcd53..a7f8b71 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -97,11 +97,22 @@ export class CustomMediaStreamTrack extends MediaStreamTrack { let ref = new WeakRef(clone) this.#clones.add(ref) this.#registry.register(clone, ref, clone) - // Also eagerly clean up when the clone is explicitly stopped. + + let stopCloneWhenSourceEnds = () => { + if (clone.readyState !== 'ended') clone.stop() + } + + // Clones are fed through this source track, so they must end when the source ends. + this.addEventListener('ended', stopCloneWhenSourceEnds, { once: true }) + + // Also eagerly clean up when the clone is explicitly stopped or is ended via the source. clone.addEventListener('ended', () => { this.#registry.unregister(clone) this.#clones.delete(ref) }, { once: true }) + + if (this.readyState === 'ended') stopCloneWhenSourceEnds() + return clone } } From a43c4d7c086f78e70af3a5a5570075e769484cc2 Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Sat, 2 May 2026 00:04:53 -0400 Subject: [PATCH 15/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- test/polyfill.test.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/polyfill.test.js b/test/polyfill.test.js index d1a6482..db1423e 100644 --- a/test/polyfill.test.js +++ b/test/polyfill.test.js @@ -41,7 +41,23 @@ test('polyfill > createMediaStreamSource accepts polyfill MediaStream', () => { is(node.mediaStream, s) }) -test('polyfill > navigator.mediaDevices.getUserMedia is a function', () => { +test('polyfill > navigator.mediaDevices.getUserMedia acquires or meaningfully rejects microphone streams', async () => { ok(typeof globalThis.navigator.mediaDevices.getUserMedia === 'function', 'getUserMedia installed on mediaDevices') + + let result = globalThis.navigator.mediaDevices.getUserMedia({ audio: true }) + ok(result && typeof result.then === 'function', + 'getUserMedia returns a promise') + + try { + let stream = await result + ok(stream instanceof MediaStream, 'resolved value is a MediaStream') + ok(stream.getAudioTracks().length > 0, + 'resolved stream exposes at least one audio track') + } catch (err) { + ok(err instanceof Error || (err && typeof err === 'object'), + 'rejection is Error-like') + ok(!!(err && typeof err.message === 'string' && err.message.length > 0), + 'rejection includes a non-empty message') + } }) From a7e1879f8d0b73c4b8d2e68f3292448bb49291fb Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Sat, 2 May 2026 00:05:01 -0400 Subject: [PATCH 16/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- test/MediaStreamNodes.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/MediaStreamNodes.test.js b/test/MediaStreamNodes.test.js index 1714955..3eb44db 100644 --- a/test/MediaStreamNodes.test.js +++ b/test/MediaStreamNodes.test.js @@ -151,6 +151,22 @@ test('MediaStreamAudioSourceNode > pushData() compat: works without MediaStream' almost(out.getChannelData(0)[0], 0.5, 1e-6, 'legacy pushData() still works') }) +test('MediaStreamAudioSourceNode > pushData() compat: uses constructor bitDepth for raw PCM when call options are omitted', () => { + let ctx = mkCtx() + let src = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: 1, bitDepth: 16 }) + let samples = new Int16Array(BLOCK_SIZE) + samples[0] = 32767 + samples[1] = -32768 + samples[2] = 16384 + src.pushData(samples) + + ctx._state = 'running' + let out = src._tick().getChannelData(0) + almost(out[0], 32767 / 32768, 1e-6, 'decodes positive 16-bit PCM using constructor bitDepth') + almost(out[1], -1, 1e-6, 'decodes negative 16-bit PCM using constructor bitDepth') + almost(out[2], 0.5, 1e-6, 'decodes mid-scale 16-bit PCM using constructor bitDepth') +}) + test('MediaStreamAudioDestinationNode > stops capturing after track.stop()', () => { let ctx = mkCtx() let dest = new MediaStreamAudioDestinationNode(ctx, { numberOfChannels: 1 }) From 12cd5e026d7a8801d68a7aacd5cf93f12673b8da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 04:10:39 +0000 Subject: [PATCH 17/24] fix: restore audio-mic-backed getUserMedia in polyfill; fix stray fragment in dest node Agent-Logs-Url: https://github.com/audiojs/web-audio-api/sessions/5c1f80bb-b76b-4ff2-9b7f-c8ca82dc86c5 Co-authored-by: dy <300067+dy@users.noreply.github.com> --- polyfill.js | 73 ++++++++++++++++++++++--------- src/MediaStreamAudioSourceNode.js | 2 +- test/MediaStreamNodes.test.js | 4 +- 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/polyfill.js b/polyfill.js index c59f232..e89828e 100644 --- a/polyfill.js +++ b/polyfill.js @@ -1,4 +1,6 @@ // Web Audio API globals for Node: `import 'web-audio-api/polyfill'` +// 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' for (let [name, value] of Object.entries(waa)) @@ -14,31 +16,62 @@ globalThis.CustomMediaStreamTrack ??= waa.CustomMediaStreamTrack 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) + +async function getUserMedia(constraints = {}) { + if (!constraints.audio) throw Object.assign(new Error( + 'getUserMedia: only { audio } is supported in Node'), { name: 'NotSupportedError' }) + + let mic + try { mic = (await import('audio-mic')).default } + catch { throw Object.assign(new Error( + "getUserMedia requires 'audio-mic' in Node. Install: npm install audio-mic"), + { name: 'NotFoundError' }) } + + let c = constraints.audio === true ? {} : constraints.audio + let opts = { sampleRate: pick(c.sampleRate) ?? 44100, channels: pick(c.channelCount) ?? 1, bitDepth: pick(c.sampleSize) ?? 16 } + 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 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 (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 = () => { + // Best-effort mic close — errors here (e.g. already closed) are harmless. + try { read(null); read.close?.() } catch {} + origStop() + } + return stream +} + const legacyGetUserMedia = typeof globalThis.navigator.getUserMedia === 'function' ? globalThis.navigator.getUserMedia.bind(globalThis.navigator) : undefined -const installedGetUserMedia = - typeof waa.getUserMedia === 'function' - ? waa.getUserMedia.bind(waa) - : typeof legacyGetUserMedia === 'function' - ? (constraints) => - new Promise((resolve, reject) => { - legacyGetUserMedia(constraints, resolve, reject) - }) - : () => - Promise.reject( - new TypeError('navigator.mediaDevices.getUserMedia is not implemented') - ) - -globalThis.navigator.mediaDevices.getUserMedia ??= installedGetUserMedia +globalThis.navigator.mediaDevices.getUserMedia ??= + typeof legacyGetUserMedia === 'function' + ? (constraints) => new Promise((resolve, reject) => legacyGetUserMedia(constraints, resolve, reject)) + : getUserMedia + globalThis.navigator.getUserMedia ??= function (constraints, successCallback, errorCallback) { globalThis.navigator.mediaDevices.getUserMedia(constraints).then( - (stream) => { - if (typeof successCallback === 'function') successCallback(stream) - }, - (error) => { - if (typeof errorCallback === 'function') errorCallback(error) - } + (stream) => { if (typeof successCallback === 'function') successCallback(stream) }, + (error) => { if (typeof errorCallback === 'function') errorCallback(error) } ) } diff --git a/src/MediaStreamAudioSourceNode.js b/src/MediaStreamAudioSourceNode.js index 04120ce..1bb55bc 100644 --- a/src/MediaStreamAudioSourceNode.js +++ b/src/MediaStreamAudioSourceNode.js @@ -116,7 +116,7 @@ class MediaStreamAudioDestinationNode extends AudioNode { this._applyOpts(options) context._tailNodes?.add(this) } - if (this.#track?.readyState !== 'ended') this.#track._pushNormalized(chunk) + _tick() { super._tick() let inBuf = this._inputs[0]._tick() diff --git a/test/MediaStreamNodes.test.js b/test/MediaStreamNodes.test.js index 3eb44db..9de0072 100644 --- a/test/MediaStreamNodes.test.js +++ b/test/MediaStreamNodes.test.js @@ -162,9 +162,9 @@ test('MediaStreamAudioSourceNode > pushData() compat: uses constructor bitDepth ctx._state = 'running' let out = src._tick().getChannelData(0) - almost(out[0], 32767 / 32768, 1e-6, 'decodes positive 16-bit PCM using constructor bitDepth') + almost(out[0], 1, 1e-6, 'decodes positive 16-bit PCM using constructor bitDepth') almost(out[1], -1, 1e-6, 'decodes negative 16-bit PCM using constructor bitDepth') - almost(out[2], 0.5, 1e-6, 'decodes mid-scale 16-bit PCM using constructor bitDepth') + almost(out[2], 0.5, 1e-4, 'decodes mid-scale 16-bit PCM using constructor bitDepth') }) test('MediaStreamAudioDestinationNode > stops capturing after track.stop()', () => { From 6a48ebf317fc3a6dbd4b24c99f2555ff2283431e Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Sat, 2 May 2026 08:02:04 -0400 Subject: [PATCH 18/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/MediaStream.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/MediaStream.js b/src/MediaStream.js index a7f8b71..96d7ffd 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -57,7 +57,12 @@ export class MediaStreamTrack extends EventTarget { this.dispatchEvent(new Event('ended')) } - clone() { return new MediaStreamTrack(this.kind, this.label, this.#settings) } + clone() { + let track = new MediaStreamTrack(this.kind, this.label, this.#settings) + track.enabled = this.enabled + if (this.#readyState === 'ended') track.#readyState = 'ended' + return track + } getSettings() { return { ...this.#settings } } } From 683ccae89d1420ec142b642adbc1eabe35f7439c Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Sat, 2 May 2026 08:02:17 -0400 Subject: [PATCH 19/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- test/polyfill.test.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/polyfill.test.js b/test/polyfill.test.js index db1423e..67d9c82 100644 --- a/test/polyfill.test.js +++ b/test/polyfill.test.js @@ -55,9 +55,13 @@ test('polyfill > navigator.mediaDevices.getUserMedia acquires or meaningfully re ok(stream.getAudioTracks().length > 0, 'resolved stream exposes at least one audio track') } catch (err) { - ok(err instanceof Error || (err && typeof err === 'object'), - 'rejection is Error-like') - ok(!!(err && typeof err.message === 'string' && err.message.length > 0), + ok(err && typeof err === 'object', + 'rejection is object-like') + ok(typeof err.name === 'string', + 'rejection includes an error name') + ok(err.name === 'NotSupportedError' || err.name === 'NotFoundError', + 'rejection uses a supported getUserMedia error name') + ok(typeof err.message === 'string' && err.message.length > 0, 'rejection includes a non-empty message') } }) From bf930cdb4a252cfcc089e2c603205fd20af4fd97 Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Sat, 2 May 2026 08:02:25 -0400 Subject: [PATCH 20/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/MediaStream.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/MediaStream.js b/src/MediaStream.js index 96d7ffd..a0f82f4 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -54,7 +54,6 @@ export class MediaStreamTrack extends EventTarget { stop() { if (this.#readyState === 'ended') return this.#readyState = 'ended' - this.dispatchEvent(new Event('ended')) } clone() { From 2f64d72cecfa7180d8f316191fe8e2de068d6547 Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Sat, 2 May 2026 08:02:34 -0400 Subject: [PATCH 21/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/MediaStream.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/MediaStream.js b/src/MediaStream.js index a0f82f4..dee0af9 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -98,6 +98,7 @@ export class CustomMediaStreamTrack extends MediaStreamTrack { clone() { let clone = new CustomMediaStreamTrack({ kind: this.kind, label: this.label, settings: this.getSettings() }) + clone.enabled = this.enabled let ref = new WeakRef(clone) this.#clones.add(ref) this.#registry.register(clone, ref, clone) From 98463f7c8f67b33733722b11c908a7f9478585e5 Mon Sep 17 00:00:00 2001 From: "Dmitry Iv." Date: Sat, 2 May 2026 08:02:44 -0400 Subject: [PATCH 22/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- polyfill.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/polyfill.js b/polyfill.js index e89828e..ae25782 100644 --- a/polyfill.js +++ b/polyfill.js @@ -70,8 +70,10 @@ globalThis.navigator.mediaDevices.getUserMedia ??= : getUserMedia globalThis.navigator.getUserMedia ??= function (constraints, successCallback, errorCallback) { - globalThis.navigator.mediaDevices.getUserMedia(constraints).then( - (stream) => { if (typeof successCallback === 'function') successCallback(stream) }, - (error) => { if (typeof errorCallback === 'function') errorCallback(error) } - ) + Promise.resolve() + .then(() => globalThis.navigator.mediaDevices.getUserMedia(constraints)) + .then( + (stream) => { if (typeof successCallback === 'function') successCallback(stream) }, + (error) => { if (typeof errorCallback === 'function') errorCallback(error) } + ) } From a2e575594767d84122879b0a10dcb3bced5059ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 12:08:42 +0000 Subject: [PATCH 23/24] fix: remove strong-ref ended listener from clone(); add getUserMedia docs to README Agent-Logs-Url: https://github.com/audiojs/web-audio-api/sessions/21dacecc-c458-4c59-9617-29c97ae5ac2c Co-authored-by: dy <300067+dy@users.noreply.github.com> --- README.md | 17 +++++++++++++++++ src/MediaStream.js | 25 +++++++------------------ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c546895..ec96bda 100644 --- a/README.md +++ b/README.md @@ -192,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.
Can I unit-test audio code?
diff --git a/src/MediaStream.js b/src/MediaStream.js index dee0af9..881bab2 100644 --- a/src/MediaStream.js +++ b/src/MediaStream.js @@ -83,8 +83,9 @@ export class CustomMediaStreamTrack extends MediaStreamTrack { this._buffers.push(chunk) for (let ref of this.#clones) { let clone = ref.deref() - if (clone) clone._pushNormalized(chunk) - else this.#clones.delete(ref) + // Lazily remove dead WeakRefs and clones that have been explicitly stopped. + if (!clone || clone.readyState === 'ended') this.#clones.delete(ref) + else clone._pushNormalized(chunk) } } @@ -99,25 +100,13 @@ export class CustomMediaStreamTrack extends MediaStreamTrack { clone() { let clone = new CustomMediaStreamTrack({ kind: this.kind, label: this.label, settings: this.getSettings() }) clone.enabled = this.enabled + if (this.readyState === 'ended') { + clone.stop() + return clone + } let ref = new WeakRef(clone) this.#clones.add(ref) this.#registry.register(clone, ref, clone) - - let stopCloneWhenSourceEnds = () => { - if (clone.readyState !== 'ended') clone.stop() - } - - // Clones are fed through this source track, so they must end when the source ends. - this.addEventListener('ended', stopCloneWhenSourceEnds, { once: true }) - - // Also eagerly clean up when the clone is explicitly stopped or is ended via the source. - clone.addEventListener('ended', () => { - this.#registry.unregister(clone) - this.#clones.delete(ref) - }, { once: true }) - - if (this.readyState === 'ended') stopCloneWhenSourceEnds() - return clone } } From 2515659f6815fd953be6f79bd96594f51d507977 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:38:09 +0000 Subject: [PATCH 24/24] fix: set _ended=true on MediaStreamAudioSourceNode when track ends to fix WPT silence-detector test Agent-Logs-Url: https://github.com/audiojs/web-audio-api/sessions/0daa36f8-3469-425c-89fa-365d5fb40d2c Co-authored-by: dy <300067+dy@users.noreply.github.com> --- src/MediaStreamAudioSourceNode.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/MediaStreamAudioSourceNode.js b/src/MediaStreamAudioSourceNode.js index 1bb55bc..3219eb3 100644 --- a/src/MediaStreamAudioSourceNode.js +++ b/src/MediaStreamAudioSourceNode.js @@ -34,6 +34,7 @@ class MediaStreamAudioSourceNode extends AudioNode { } }) this.#channels = channels + this._ended = false this._outBuf = new AudioBuffer(channels, BLOCK_SIZE, context.sampleRate) this._applyOpts(options) } @@ -52,6 +53,7 @@ class MediaStreamAudioSourceNode extends AudioNode { // go silent and clear state if track has ended if (track.readyState === 'ended') { + this._ended = true this.#pending = null this.#pos = 0 return out