Skip to content

Commit c042222

Browse files
committed
Consolidate escape hatch API
1 parent cfdab84 commit c042222

12 files changed

Lines changed: 102 additions & 108 deletions

.github/workflows/test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ jobs:
1414
node-version: [18, 20, 22]
1515
steps:
1616
- uses: actions/checkout@v4
17+
with:
18+
submodules: true
1719
- uses: actions/setup-node@v4
1820
with:
1921
node-version: ${{ matrix.node-version }}
@@ -25,6 +27,8 @@ jobs:
2527
runs-on: ubuntu-latest
2628
steps:
2729
- uses: actions/checkout@v4
30+
with:
31+
submodules: true
2832
- uses: denoland/setup-deno@v2
2933
- uses: actions/setup-node@v4
3034
with:
@@ -37,6 +41,8 @@ jobs:
3741
runs-on: ubuntu-latest
3842
steps:
3943
- uses: actions/checkout@v4
44+
with:
45+
submodules: true
4046
- uses: oven-sh/setup-bun@v2
4147
- run: bun install
4248
- run: bun test/index.js

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ const buffer = await ctx.startRendering()
4040

4141
### Custom output stream
4242

43-
For piping to external tools or custom sinks, set `outStream` to any writable:
43+
Pass any writable stream as `sinkId` to pipe PCM to external tools:
4444

4545
```js
46-
ctx.outStream = myWritableStream
46+
const ctx = new AudioContext({ sinkId: process.stdout })
4747
```
4848

4949
```sh
@@ -167,6 +167,16 @@ Supports WAV, MP3, FLAC, OGG, AAC, and [more](https://github.com/audiojs/audio-d
167167

168168
</dl>
169169

170+
## Node extensions
171+
172+
These APIs extend the Web Audio spec for Node.js use cases. Code using them is not directly portable to browsers.
173+
174+
| API | What it does | Browser equivalent |
175+
|---|---|---|
176+
| `addModule(fn)` | Register processor via callback | `addModule(url)` &mdash; URL string only |
177+
| `sinkId: writableStream` | Pipe PCM to any writable stream | N/A (hardware output) |
178+
| `numberOfChannels`, `bitDepth` | Constructor options for output format | N/A (hardware-determined) |
179+
170180
## Architecture
171181

172182
Pull-based audio graph. `AudioDestinationNode` pulls upstream via `_tick()`, 128-sample render quanta per the spec. AudioWorklet runs synchronously (no thread isolation). DSP kernels separated from graph plumbing for future WASM swap.

examples/pipe-stdout.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { AudioContext } from 'web-audio-api'
66

77
const duration = 2
8-
const ctx = new AudioContext()
8+
const ctx = new AudioContext({ sinkId: process.stdout })
99
await ctx.resume()
1010

1111
const osc = ctx.createOscillator()

index.d.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,19 +87,17 @@ export class BaseAudioContext extends EventTarget {
8787
}
8888

8989
export class AudioContext extends BaseAudioContext {
90-
constructor(options?: { sampleRate?: number; numberOfChannels?: number; bitDepth?: number; latencyHint?: 'interactive' | 'balanced' | 'playback' | number; sinkId?: string | { type: 'none' } });
90+
constructor(options?: { sampleRate?: number; numberOfChannels?: number; bitDepth?: number; bufferSize?: number; numBuffers?: number; latencyHint?: 'interactive' | 'balanced' | 'playback' | number; sinkId?: string | { type: 'none' } | { write(chunk: Uint8Array): boolean; once?(event: string, fn: () => void): void; end?(): void; close?(): void } });
9191
readonly numberOfChannels: number;
9292
readonly baseLatency: number;
9393
readonly outputLatency: number;
9494
readonly renderQuantumSize: number;
9595
readonly sinkId: string | { type: string };
9696
readonly playbackStats: { totalDuration: number; underrunDuration: number; underrunEvents: number; minimumLatency: number; maximumLatency: number; averageLatency: number };
9797
readonly playoutStats: { totalFramesDuration: number; fallbackFramesDuration: number; fallbackFramesEvents: number; minimumLatency: number; maximumLatency: number; averageLatency: number };
98-
outStream: any;
99-
format: { numberOfChannels: number; bitDepth: number; sampleRate: number };
10098
onsinkchange: ((event: Event) => void) | null;
10199
getOutputTimestamp(): { contextTime: number; performanceTime: number };
102-
setSinkId(sinkId: string | { type: 'none' }): Promise<void>;
100+
setSinkId(sinkId: string | { type: 'none' } | { write(chunk: Uint8Array): boolean }): Promise<void>;
103101
suspend(): Promise<void>;
104102
resume(): Promise<void>;
105103
close(): Promise<void>;

src/AudioContext.js

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class AudioContext extends BaseAudioContext {
2525
#bitDepth
2626
#encoder
2727
#speaker = null // audio-speaker write function (default output)
28+
#stream = null // writable stream sink (when sinkId is a stream)
2829
#loopDeferred = false
2930
#sinkId = ''
3031
#playbackStats = new PlaybackStats()
@@ -70,9 +71,10 @@ class AudioContext extends BaseAudioContext {
7071
}
7172

7273
// Validate sinkId option
73-
if (opts.sinkId !== undefined) {
74-
if (typeof opts.sinkId === 'object' && opts.sinkId !== null) {
75-
if (opts.sinkId.type !== 'none')
74+
let sinkId = opts.sinkId
75+
if (sinkId !== undefined) {
76+
if (typeof sinkId === 'object' && sinkId !== null && typeof sinkId.write !== 'function') {
77+
if (sinkId.type !== 'none')
7678
throw new TypeError("Failed to construct 'AudioContext': Invalid AudioSinkOptions.type value.")
7779
}
7880
}
@@ -86,38 +88,39 @@ class AudioContext extends BaseAudioContext {
8688
this.#bitDepth = opts.bitDepth || 16
8789

8890
// Handle sinkId from constructor options
89-
if (opts.sinkId !== undefined) {
90-
if (typeof opts.sinkId === 'object' && opts.sinkId !== null) {
91-
this.#sinkId = { type: opts.sinkId.type }
92-
} else if (typeof opts.sinkId === 'string') {
93-
// Validate against known devices if a registry exists
91+
if (sinkId !== undefined) {
92+
if (typeof sinkId === 'object' && sinkId !== null) {
93+
if (typeof sinkId.write === 'function') {
94+
this.#stream = sinkId
95+
} else {
96+
this.#sinkId = { type: sinkId.type }
97+
}
98+
} else if (typeof sinkId === 'string') {
9499
let known = this.constructor._knownDeviceIds || AudioContext._knownDeviceIds
95-
if (opts.sinkId !== '' && known && !known.has(opts.sinkId)) {
96-
// Invalid device ID: dispatch onerror asynchronously per spec
100+
if (sinkId !== '' && known && !known.has(sinkId)) {
97101
setTimeout(() => this.dispatchEvent(new Event('error')), 0)
98102
} else {
99-
this.#sinkId = opts.sinkId
103+
this.#sinkId = sinkId
100104
}
101105
}
102106
}
103107

104-
this.format = {
108+
let format = {
105109
numberOfChannels: this.#numberOfChannels,
106110
bitDepth: this.#bitDepth,
107111
sampleRate: this.sampleRate
108112
}
109-
if (opts.bufferSize) this.format.bufferSize = opts.bufferSize
110-
if (opts.numBuffers) this.format.numBuffers = opts.numBuffers
113+
if (opts.bufferSize) format.bufferSize = opts.bufferSize
114+
if (opts.numBuffers) format.numBuffers = opts.numBuffers
111115

112-
this.#encoder = BufferEncoder(this.format)
113-
this.outStream = null
116+
this.#encoder = BufferEncoder(format)
114117

115118
// Start render loop when a connection is established.
116119
// Deferred via queueMicrotask so user can finish setting up the graph
117120
// (connect + start) before rendering begins.
118121
this._destination._inputs[0].on('connection', () => {
119122
if (this.#loopRunning || this.#loopDeferred || this._state !== 'running') return
120-
if (!this.outStream && !this.#speaker) return
123+
if (!this.#stream && !this.#speaker) return
121124
this.#loopDeferred = true
122125
queueMicrotask(() => {
123126
this.#loopDeferred = false
@@ -156,6 +159,14 @@ class AudioContext extends BaseAudioContext {
156159
if (this._state === 'closed')
157160
return Promise.reject(DOMErr('Cannot setSinkId on a closed AudioContext', 'InvalidStateError'))
158161
if (typeof sinkId === 'object' && sinkId !== null) {
162+
if (typeof sinkId.write === 'function') {
163+
// Writable stream as sink
164+
if (this.#speaker) { this.#speaker.close(); this.#speaker = null }
165+
let prev = this.#stream
166+
this.#stream = sinkId
167+
if (prev !== sinkId) this.dispatchEvent(new Event('sinkchange'))
168+
return Promise.resolve()
169+
}
159170
if (sinkId.type !== 'none')
160171
return Promise.reject(new TypeError('Invalid AudioSinkOptions.type value.'))
161172
let prev = this.#sinkId
@@ -195,15 +206,16 @@ class AudioContext extends BaseAudioContext {
195206
if (this._state === 'closed') return Promise.reject(DOMErr('Cannot resume a closed AudioContext', 'InvalidStateError'))
196207
// Create speaker eagerly on resume — starts device with silence so there's
197208
// no hardware pop when audio actually begins (same as browsers).
198-
if (!this.outStream && !this.#speaker) {
209+
let isNone = typeof this.#sinkId === 'object' && this.#sinkId?.type === 'none'
210+
if (!this.#stream && !this.#speaker && !isNone) {
199211
this.#speaker = await Speaker({
200212
sampleRate: this.sampleRate,
201213
channels: this.#numberOfChannels,
202214
bitDepth: this.#bitDepth
203215
})
204216
}
205217
this._setState('running')
206-
if (!this.#loopRunning && (this.#speaker || this.outStream) && this._destination._inputs[0].sources.length) {
218+
if (!this.#loopRunning && (this.#speaker || this.#stream) && this._destination._inputs[0].sources.length) {
207219
this.#loopRunning = true
208220
this._renderLoop()
209221
}
@@ -219,7 +231,7 @@ class AudioContext extends BaseAudioContext {
219231

220232
_closeOutput() {
221233
if (this.#speaker) { this.#speaker.close(); this.#speaker = null }
222-
if (this.outStream) (this.outStream.close ?? this.outStream.end)?.call(this.outStream)
234+
if (this.#stream) { (this.#stream.close ?? this.#stream.end)?.call(this.#stream); this.#stream = null }
223235
}
224236

225237
_renderLoop() {
@@ -230,20 +242,17 @@ class AudioContext extends BaseAudioContext {
230242
try {
231243
let buf = this._renderQuantum()
232244

245+
let nch = buf.numberOfChannels
246+
let channels = []
247+
for (let c = 0; c < nch; c++) channels.push(buf.getChannelData(c))
248+
let encoded = this.#encoder(channels)
249+
233250
if (this.#speaker) {
234-
let nch = buf.numberOfChannels
235-
let channels = []
236-
for (let c = 0; c < nch; c++) channels.push(buf.getChannelData(c))
237-
let encoded = this.#encoder(channels)
238251
this.#speaker(encoded, () => this._renderLoop())
239-
} else if (this.outStream) {
240-
let nch = buf.numberOfChannels
241-
let channels = []
242-
for (let c = 0; c < nch; c++) channels.push(buf.getChannelData(c))
243-
let encoded = this.#encoder(channels)
244-
let ok = this.outStream.write(encoded)
245-
if (ok || !this.outStream.once) setTimeout(() => this._renderLoop(), 0)
246-
else this.outStream.once('drain', () => this._renderLoop())
252+
} else if (this.#stream) {
253+
let ok = this.#stream.write(encoded)
254+
if (ok || !this.#stream.once) setTimeout(() => this._renderLoop(), 0)
255+
else this.#stream.once('drain', () => this._renderLoop())
247256
}
248257
} catch (e) {
249258
console.error('AudioContext render error:', e)

test/AudioContext.test.js

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,10 @@ import AudioContext from '../src/AudioContext.js'
44
import AudioNode from '../src/AudioNode.js'
55
import { BLOCK_SIZE } from '../src/constants.js'
66

7-
let mkCtx = () => {
8-
let ctx = new AudioContext()
9-
ctx.outStream = { end() {} }
10-
return ctx
11-
}
7+
let mkCtx = () => new AudioContext({ sinkId: { type: 'none' } })
128

139
test('AudioContext > graph traversal collects all connected nodes', () => {
14-
let ctx = new AudioContext()
15-
ctx.outStream = { write() { return true }, once() {} }
10+
let ctx = new AudioContext({ sinkId: { write() { return true }, once() {} } })
1611

1712
let n1a = new AudioNode(ctx, 2, 1)
1813
let n1b = new AudioNode(ctx, 0, 1)
@@ -65,8 +60,8 @@ test('AudioContext > sampleRate is read-only', () => {
6560
})
6661

6762
test('AudioContext > sampleRate from constructor option', () => {
68-
let ctx = new AudioContext({ sampleRate: 48000 })
69-
ctx.outStream = { end() {} }; ctx[Symbol.dispose]()
63+
let ctx = new AudioContext({ sampleRate: 48000, sinkId: { type: 'none' } })
64+
ctx[Symbol.dispose]()
7065
is(ctx.sampleRate, 48000)
7166
})
7267

test/OfflineAudioContext.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { BLOCK_SIZE } from '../src/constants.js'
1111
// --- BaseAudioContext ---
1212

1313
test('BaseAudioContext > AudioContext extends it', () => {
14-
let ctx = new AudioContext()
15-
ctx.outStream = { end() {} }; ctx[Symbol.dispose]()
14+
let ctx = new AudioContext({ sinkId: { type: 'none' } })
15+
ctx[Symbol.dispose]()
1616
ok(ctx instanceof BaseAudioContext, 'AudioContext is BaseAudioContext')
1717
})
1818

test/audit-fixes.test.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,7 @@ test('enum setters > silently ignore invalid values per spec', () => {
223223
})
224224

225225
test('AudioContext > onstatechange as event handler property', async () => {
226-
let ctx = new AudioContext()
227-
ctx.outStream = { end() {} }
226+
let ctx = new AudioContext({ sinkId: { type: 'none' } })
228227
let states = []
229228

230229
// set via property

test/manual-testing/AudioContext-sound-output.js

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,9 @@
1-
// if (require.main === module) { // Just to avoid mocha running this
2-
31
import fs from 'fs'
42
import AudioContext from '../../src/AudioContext.js'
53
import Speaker from 'speaker'
64

7-
const context = new AudioContext
8-
9-
console.log('encoding format : '
10-
+ context.format.numberOfChannels + ' channels ; '
11-
+ context.format.bitDepth + ' bits ; '
12-
+ context.sampleRate + ' Hz'
13-
)
14-
context.outStream = new Speaker({
15-
channels: context.format.numberOfChannels,
16-
bitDepth: context.format.bitDepth,
17-
sampleRate: context.sampleRate
18-
})
5+
const speaker = new Speaker({ channels: 2, bitDepth: 16, sampleRate: 44100 })
6+
const context = new AudioContext({ sinkId: speaker })
197

208
fs.readFile(new URL('./sounds/powerpad.wav', import.meta.url), function(err, buffer) {
219
if (err) throw err

test/manual-testing/AudioContext-stdout.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
// if (require.main === module) { // Just to avoid mocha running this
2-
31
import fs from 'fs'
4-
import AudioContext from '../../src/AudioContext'
5-
import context = new AudioContext
2+
import AudioContext from '../../src/AudioContext.js'
63

7-
context.outStream = process.stdout
4+
const context = new AudioContext({ sinkId: process.stdout })
85

9-
fs.readFile(__dirname + '/sounds/powerpad.wav', function(err, buffer) {
6+
fs.readFile(new URL('./sounds/powerpad.wav', import.meta.url), function(err, buffer) {
107
if (err) throw err
118
context.decodeAudioData(buffer, function(audioBuffer) {
129
var bufferNode = context.createBufferSource()

0 commit comments

Comments
 (0)