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') + } +})