Skip to content

Commit e162cbc

Browse files
authored
Merge pull request #101 from audiojs/mic
Simplify media stream implementation
2 parents 00689f9 + 2515659 commit e162cbc

10 files changed

Lines changed: 440 additions & 164 deletions

README.md

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@ Beyond the spec, for Node.js. Not portable to browsers.
9393

9494
- **`addModule(fn)`** — register a processor via callback instead of URL, no file needed
9595
- **`sinkId: stream`** — pipe PCM to any writable: `new AudioContext({ sinkId: process.stdout })` then `node synth.js | aplay -f cd`
96-
- **`numberOfChannels`, `bitDepth`** — control output format in the constructor
97-
- **`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).
96+
- **`numberOfChannels`, `bitDepth`** — control output format in the constructor.
97+
- **`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).
9898

9999
## FAQ
100100

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

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

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

155155
```sh
156156
npm install audio-mic
157157
```
158158

159159
```js
160-
import { AudioContext, MediaStreamAudioSourceNode } from 'web-audio-api'
160+
import { AudioContext, MediaStreamAudioSourceNode, CustomMediaStreamTrack, MediaStream } from 'web-audio-api'
161161
import mic from 'audio-mic'
162162

163163
const ctx = new AudioContext()
164164
await ctx.resume()
165165

166-
const src = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: 1, bitDepth: 16 })
166+
const track = new CustomMediaStreamTrack({
167+
kind: 'audio',
168+
label: 'mic',
169+
settings: { channelCount: 1, sampleSize: 16, sampleRate: ctx.sampleRate }
170+
})
171+
const stream = new MediaStream([track])
172+
173+
const src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream })
167174
src.connect(ctx.destination) // live monitor
168175

169176
const read = mic({ sampleRate: ctx.sampleRate, channels: 1, bitDepth: 16 })
170177
read((err, buf) => {
171178
if (err || !buf) return
172-
src.pushData(buf, { channels: 1, bitDepth: 16 })
179+
track.pushData(buf, { channels: 1, bitDepth: 16 })
173180
})
174181
```
175182

176-
`pushData()` accepts `Float32Array`, `Float32Array[]`, or interleaved 8/16/32-bit integer PCM buffers. Integer PCM conversion uses `pcm-convert`.
177-
178-
With `web-audio-api/polyfill`, `navigator.mediaDevices.getUserMedia()` is also available and maps constraints to `audio-mic` options: `{ audio: { sampleRate, channelCount, sampleSize } }`.
183+
`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`.
179184

180185
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()`.
181186
</dd>
@@ -187,6 +192,23 @@ See [examples/mic.js](examples/mic.js) for a runnable demo with gain and VU mete
187192
import 'web-audio-api/polyfill'
188193
// AudioContext, GainNode, etc. are now global
189194
```
195+
196+
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:
197+
198+
```js
199+
import 'web-audio-api/polyfill'
200+
// npm install audio-mic
201+
202+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
203+
const ctx = new AudioContext()
204+
const src = ctx.createMediaStreamSource(stream)
205+
src.connect(ctx.destination)
206+
207+
// stop capture
208+
stream.getAudioTracks()[0].stop()
209+
```
210+
211+
Without `audio-mic` installed, `getUserMedia` rejects with a `NotFoundError` containing an install hint.
190212
</dd>
191213

192214
<dt>Can I unit-test audio code?</dt>

examples/mic.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// Run: node examples/mic.js gain=0.8
66
// Keys: space pause · + / - adjust gain · q quit
77

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

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

21-
const src = new MediaStreamAudioSourceNode(ctx, { numberOfChannels: channels, bitDepth })
21+
const track = new CustomMediaStreamTrack({ kind: 'audio', label: 'mic', settings: { channelCount: channels, sampleSize: bitDepth, sampleRate } })
22+
const stream = new MediaStream([track])
23+
24+
const src = new MediaStreamAudioSourceNode(ctx, { mediaStream: stream })
2225
const gain = ctx.createGain()
2326
const analyser = ctx.createAnalyser()
2427
gain.gain.value = gainVal
@@ -28,7 +31,7 @@ src.connect(gain).connect(analyser).connect(ctx.destination)
2831
let read = mic({ sampleRate, channels, bitDepth })
2932
read((err, buf) => {
3033
if (err || !buf) return
31-
src.pushData(buf, { channels, bitDepth })
34+
track.pushData(buf, { channels, bitDepth })
3235
})
3336

3437
let samples = new Float32Array(analyser.fftSize)

index.d.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,34 @@ export class MediaElementAudioSourceNode extends AudioNode {
270270
readonly mediaElement: any;
271271
}
272272

273+
export class MediaStreamTrack extends EventTarget {
274+
readonly id: string;
275+
readonly kind: string;
276+
readonly label: string;
277+
enabled: boolean;
278+
readonly readyState: 'live' | 'ended';
279+
stop(): void;
280+
clone(): MediaStreamTrack;
281+
getSettings(): Record<string, any>;
282+
}
283+
284+
export class CustomMediaStreamTrack extends MediaStreamTrack {
285+
constructor(options?: { kind?: string; label?: string; settings?: Record<string, any> });
286+
pushData(channelData: Float32Array | Float32Array[] | ArrayBuffer | ArrayBufferView, options?: { channels?: number; numberOfChannels?: number; bitDepth?: 8 | 16 | 32 }): void;
287+
clone(): CustomMediaStreamTrack;
288+
}
289+
290+
export class MediaStream extends EventTarget {
291+
readonly id: string;
292+
readonly active: boolean;
293+
constructor(tracks?: MediaStreamTrack[] | MediaStream);
294+
getTracks(): MediaStreamTrack[];
295+
getAudioTracks(): MediaStreamTrack[];
296+
getVideoTracks(): MediaStreamTrack[];
297+
addTrack(track: MediaStreamTrack): void;
298+
removeTrack(track: MediaStreamTrack): void;
299+
}
300+
273301
// Error types
274302
export class InvalidStateError extends Error { readonly name: 'InvalidStateError' }
275303
export class NotSupportedError extends Error { readonly name: 'NotSupportedError' }

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export { default as ScriptProcessorNode } from './src/ScriptProcessorNode.js'
2525
export { default as PannerNode } from './src/PannerNode/index.js'
2626
export { AudioWorkletNode, AudioWorkletProcessor } from './src/AudioWorklet.js'
2727
export { MediaStreamAudioSourceNode, MediaStreamAudioDestinationNode, MediaElementAudioSourceNode } from './src/MediaStreamAudioSourceNode.js'
28+
export { MediaStream, MediaStreamTrack, CustomMediaStreamTrack } from './src/MediaStream.js'
2829
export { default as AudioListener } from './src/AudioListener.js'
2930
export { BLOCK_SIZE } from './src/constants.js'
3031
export { InvalidStateError, NotSupportedError, IndexSizeError, InvalidAccessError, EncodingError } from './src/errors.js'

package-lock.json

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

polyfill.js

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

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

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

13-
// --- MediaStream / MediaStreamTrack --------------------------------------
14-
// Minimal spec-shaped classes for `instanceof` + track.stop() in Node.
12+
globalThis.MediaStreamTrack ??= waa.MediaStreamTrack
13+
globalThis.MediaStream ??= waa.MediaStream
14+
globalThis.CustomMediaStreamTrack ??= waa.CustomMediaStreamTrack
1515

16-
let nextId = 0
17-
class MediaStreamTrack extends EventTarget {
18-
kind; label; enabled = true; readyState = 'live'
19-
id = 'track-' + (++nextId)
20-
#settings
21-
constructor(kind = 'audio', label = '', settings = {}) {
22-
super(); this.kind = kind; this.label = label; this.#settings = settings
23-
}
24-
stop() {
25-
if (this.readyState === 'ended') return
26-
this.readyState = 'ended'
27-
this.dispatchEvent(new Event('ended'))
28-
}
29-
clone() { return new MediaStreamTrack(this.kind, this.label, this.#settings) }
30-
getSettings() { return { ...this.#settings } }
31-
}
32-
33-
class MediaStream {
34-
id = 'stream-' + Math.random().toString(36).slice(2)
35-
#tracks
36-
_buffers = []
37-
constructor(tracks = []) { this.#tracks = [...(tracks instanceof MediaStream ? tracks.getTracks() : tracks)] }
38-
get active() { return this.#tracks.some(t => t.readyState === 'live') }
39-
getTracks() { return [...this.#tracks] }
40-
getAudioTracks() { return this.#tracks.filter(t => t.kind === 'audio') }
41-
getVideoTracks() { return this.#tracks.filter(t => t.kind === 'video') }
42-
addTrack(t) { if (!this.#tracks.includes(t)) this.#tracks.push(t) }
43-
removeTrack(t) { let i = this.#tracks.indexOf(t); if (i >= 0) this.#tracks.splice(i, 1) }
44-
}
45-
46-
globalThis.MediaStreamTrack ??= MediaStreamTrack
47-
globalThis.MediaStream ??= MediaStream
16+
globalThis.navigator ??= {}
17+
globalThis.navigator.mediaDevices ??= {}
4818

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

5122
let pick = v => v == null ? undefined : typeof v === 'number' ? v : (v.ideal ?? v.exact ?? v.min ?? v.max)
52-
let splitPlanar = (data, channels) => {
53-
if (channels === 1) return data
54-
let frames = data.length / channels
55-
return Array.from({ length: channels }, (_, ch) => data.subarray(ch * frames, (ch + 1) * frames))
56-
}
57-
58-
let toFloat32 = (chunk, opts) => {
59-
if (chunk instanceof Float32Array || Array.isArray(chunk)) return chunk
60-
if (![8, 16, 32].includes(opts.bitDepth))
61-
throw new TypeError('getUserMedia PCM conversion supports 8, 16, or 32-bit integer samples')
62-
let bytes = chunk instanceof ArrayBuffer
63-
? chunk
64-
: chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
65-
let data = convert(bytes, { dtype: `int${opts.bitDepth}`, channels: opts.channels, interleaved: true, endianness: 'le' },
66-
{ dtype: 'float32', channels: opts.channels, interleaved: false })
67-
return splitPlanar(data, opts.channels)
68-
}
6923

7024
async function getUserMedia(constraints = {}) {
7125
if (!constraints.audio) throw Object.assign(new Error(
@@ -82,23 +36,44 @@ async function getUserMedia(constraints = {}) {
8236
if (![8, 16, 32].includes(opts.bitDepth)) throw Object.assign(new Error(
8337
'getUserMedia supports 8, 16, or 32-bit integer PCM samples in Node'),
8438
{ name: 'NotSupportedError' })
39+
8540
let read = mic(opts)
86-
let track = new MediaStreamTrack('audio', 'Default audio input',
87-
{ sampleRate: opts.sampleRate, channelCount: opts.channels, sampleSize: opts.bitDepth })
88-
let stream = new MediaStream([track])
89-
let live = true
41+
let track = new waa.CustomMediaStreamTrack({
42+
kind: 'audio', label: 'Default audio input',
43+
settings: { sampleRate: opts.sampleRate, channelCount: opts.channels, sampleSize: opts.bitDepth }
44+
})
45+
let stream = new waa.MediaStream([track])
46+
9047
let pump = () => read((err, chunk) => {
91-
if (!live || err || !chunk) return
92-
stream._buffers.push(toFloat32(chunk, opts))
48+
if (track.readyState === 'ended' || err || !chunk) return
49+
track.pushData(chunk, { channels: opts.channels, bitDepth: opts.bitDepth })
9350
pump()
9451
})
9552
pump()
9653

9754
let origStop = track.stop.bind(track)
98-
track.stop = () => { live = false; try { read(null); read.close?.() } catch {} origStop() }
55+
track.stop = () => {
56+
// Best-effort mic close — errors here (e.g. already closed) are harmless.
57+
try { read(null); read.close?.() } catch {}
58+
origStop()
59+
}
9960
return stream
10061
}
10162

102-
globalThis.navigator ??= {}
103-
globalThis.navigator.mediaDevices ??= {}
104-
globalThis.navigator.mediaDevices.getUserMedia ??= getUserMedia
63+
const legacyGetUserMedia = typeof globalThis.navigator.getUserMedia === 'function'
64+
? globalThis.navigator.getUserMedia.bind(globalThis.navigator)
65+
: undefined
66+
67+
globalThis.navigator.mediaDevices.getUserMedia ??=
68+
typeof legacyGetUserMedia === 'function'
69+
? (constraints) => new Promise((resolve, reject) => legacyGetUserMedia(constraints, resolve, reject))
70+
: getUserMedia
71+
72+
globalThis.navigator.getUserMedia ??= function (constraints, successCallback, errorCallback) {
73+
Promise.resolve()
74+
.then(() => globalThis.navigator.mediaDevices.getUserMedia(constraints))
75+
.then(
76+
(stream) => { if (typeof successCallback === 'function') successCallback(stream) },
77+
(error) => { if (typeof errorCallback === 'function') errorCallback(error) }
78+
)
79+
}

0 commit comments

Comments
 (0)