diff --git a/README.md b/README.md
index 47f1209..ec96bda 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
-- **`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).
## FAQ
@@ -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).
-
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 `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()`.
@@ -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.
Can I unit-test audio code?
diff --git a/examples/mic.js b/examples/mic.js
index b89e5f3..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 } 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,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
@@ -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..079636b 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -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;
+}
+
+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 {
+ 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..1bf0364 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, 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/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/polyfill.js b/polyfill.js
index e351c18..ae25782 100644
--- a/polyfill.js
+++ b/polyfill.js
@@ -1,8 +1,7 @@
// 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
@@ -10,62 +9,17 @@ 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.
+globalThis.MediaStreamTrack ??= waa.MediaStreamTrack
+globalThis.MediaStream ??= waa.MediaStream
+globalThis.CustomMediaStreamTrack ??= waa.CustomMediaStreamTrack
-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(
@@ -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) }
+ )
+}
diff --git a/src/MediaStream.js b/src/MediaStream.js
new file mode 100644
index 0000000..881bab2
--- /dev/null
+++ b/src/MediaStream.js
@@ -0,0 +1,146 @@
+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)
+}
+
+// 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 = 'audio'
+ #label = ''
+ enabled = true
+ #readyState = 'live'
+ #settings = {}
+
+ constructor(kind = 'audio', label = '', settings = {}) {
+ super()
+ 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'
+ }
+
+ 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 } }
+}
+
+// Node extension: custom track with public constructor and pushData().
+// Prior art: CanvasCaptureMediaStreamTrack extends MediaStreamTrack.
+export class CustomMediaStreamTrack extends MediaStreamTrack {
+ _buffers = []
+ // 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)
+ }
+
+ // Internal: fan out an already-normalised chunk to this track and all live clones.
+ _pushNormalized(chunk) {
+ this._buffers.push(chunk)
+ for (let ref of this.#clones) {
+ let clone = ref.deref()
+ // Lazily remove dead WeakRefs and clones that have been explicitly stopped.
+ if (!clone || clone.readyState === 'ended') this.#clones.delete(ref)
+ else 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._pushNormalized(normalizeChunk(chunk, channels, bitDepth))
+ }
+
+ 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)
+ return clone
+ }
+}
+
+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)]
+ }
+
+ #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)
+ this.#dispatchTrackEvent('addtrack', t)
+ }
+ }
+ removeTrack(t) {
+ let i = this.#tracks.indexOf(t)
+ if (i >= 0) {
+ this.#tracks.splice(i, 1)
+ this.#dispatchTrackEvent('removetrack', t)
+ }
+ }
+}
diff --git a/src/MediaStreamAudioSourceNode.js b/src/MediaStreamAudioSourceNode.js
index 62c48cd..3219eb3 100644
--- a/src/MediaStreamAudioSourceNode.js
+++ b/src/MediaStreamAudioSourceNode.js
@@ -1,52 +1,16 @@
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 { 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
- #bitDepth
get mediaStream() { return this.#stream }
@@ -56,22 +20,28 @@ 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] ?? 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,
+ ...(bitDepth == null ? {} : { bitDepth })
+ }
+ })
this.#channels = channels
- this.#bitDepth = options.bitDepth ?? 16
+ this._ended = false
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.#stream ??= { _buffers: [] })._buffers ??= []
- this.#stream._buffers.push(normalizeChunk(
- chunk,
- options.channels ?? options.numberOfChannels ?? this.#channels,
- options.bitDepth ?? this.#bitDepth
- ))
+ this.#track.pushData(chunk, options)
}
_tick() {
@@ -79,9 +49,24 @@ class MediaStreamAudioSourceNode extends AudioNode {
let out = this._outBuf
for (let ch = 0; ch < this.#channels; ch++) out.getChannelData(ch).fill(0)
+ let track = this.#track
+
+ // go silent and clear state if track has ended
+ if (track.readyState === 'ended') {
+ this._ended = true
+ this.#pending = null
+ this.#pos = 0
+ return out
+ }
+
+ // go silent without draining if track is disabled (resumes on re-enable)
+ if (!track.enabled) return out
+
+ 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
@@ -113,17 +98,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 = makeTrack('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: () => [],
@@ -135,9 +122,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.#stream._buffers.push(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 cd525b4..9de0072 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, CustomMediaStreamTrack } from '../src/MediaStream.js'
import { BLOCK_SIZE } from '../src/constants.js'
let mkCtx = () => new AudioContext()
+let mkStream = (settings) => {
+ let track = new CustomMediaStreamTrack({ kind: 'audio', settings })
+ 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)
@@ -128,3 +140,80 @@ 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('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], 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-4, '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 })
+ 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 d87f2f7..67d9c82 100644
--- a/test/polyfill.test.js
+++ b/test/polyfill.test.js
@@ -6,7 +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 navigator.mediaDevices.getUserMedia === 'function')
+ ok(typeof globalThis.CustomMediaStreamTrack === 'function')
})
test('polyfill > MediaStreamTrack lifecycle', () => {
@@ -16,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])
@@ -26,28 +34,34 @@ 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')])
+ let s = new MediaStream([new CustomMediaStreamTrack({ kind: 'audio' })])
let node = ctx.createMediaStreamSource(s)
is(node.mediaStream, s)
})
+
+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 && 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')
+ }
+})