decibri 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/MIGRATION.md +14 -0
- package/README.md +46 -0
- package/examples/README.md +79 -0
- package/examples/browser-speaker-test.html +159 -0
- package/examples/decibri.browser.js +594 -0
- package/index.js +52 -52
- package/package.json +5 -5
- package/src/browser/decibri-browser.js +1 -1
- package/src/browser/decibri-output-browser.js +377 -0
- package/src/browser/index.d.ts +85 -0
- package/src/browser/index.js +2 -1
- package/src/browser/output-worklet-inline.js +16 -0
- package/src/browser/output-worklet-processor.js +168 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { OUTPUT_WORKLET_SOURCE } = require('./output-worklet-inline.js');
|
|
4
|
+
|
|
5
|
+
// Seconds of audio the output ring buffer holds. The ring capacity is derived
|
|
6
|
+
// from this and the context sample rate at start, so backpressure kicks in
|
|
7
|
+
// before more than this much audio is queued ahead of playback.
|
|
8
|
+
const BUFFER_SECONDS = 2;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Linear-interpolation resampler, the inverse of the capture worklet's: it
|
|
12
|
+
* takes samples at the user's rate and produces samples at the context rate.
|
|
13
|
+
* Carries a fractional position across calls so successive writes stay
|
|
14
|
+
* continuous. Logic mirrors worklet-processor.js resample(), with from and to
|
|
15
|
+
* swapped (from = user rate, to = context rate).
|
|
16
|
+
*/
|
|
17
|
+
class Resampler {
|
|
18
|
+
constructor(fromRate, toRate) {
|
|
19
|
+
this.ratio = fromRate / toRate;
|
|
20
|
+
this.position = 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
process(input) {
|
|
24
|
+
if (this.ratio === 1) return input;
|
|
25
|
+
|
|
26
|
+
const inputLength = input.length;
|
|
27
|
+
|
|
28
|
+
let count = 0;
|
|
29
|
+
let pos = this.position;
|
|
30
|
+
while (pos < inputLength - 1) {
|
|
31
|
+
count++;
|
|
32
|
+
pos += this.ratio;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const output = new Float32Array(count);
|
|
36
|
+
pos = this.position;
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < count; i++) {
|
|
39
|
+
const idx = Math.floor(pos);
|
|
40
|
+
const frac = pos - idx;
|
|
41
|
+
output[i] = input[idx] * (1 - frac) + input[idx + 1] * frac;
|
|
42
|
+
pos += this.ratio;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.position = Math.max(0, pos - inputLength);
|
|
46
|
+
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Browser audio playback.
|
|
53
|
+
*
|
|
54
|
+
* Plays PCM audio through the Web Audio API. Samples are converted to float32
|
|
55
|
+
* and resampled to the context rate on the main thread, then fed to an output
|
|
56
|
+
* AudioWorklet that drains them to the speakers. This is the inverse of the
|
|
57
|
+
* browser Microphone: the Microphone captures input and emits chunks; the
|
|
58
|
+
* Speaker accepts chunks and plays them.
|
|
59
|
+
*
|
|
60
|
+
* Playback is async throughout, as the browser requires: write() resolves when
|
|
61
|
+
* the samples are queued (after backpressure when the queue is full) and
|
|
62
|
+
* drain() resolves when the queued audio has finished playing. The first
|
|
63
|
+
* write() (or start()) must run in a user gesture so the browser allows audio.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const { Speaker } = require('decibri'); // browser entry via conditional export
|
|
67
|
+
* const speaker = new Speaker({ sampleRate: 16000 });
|
|
68
|
+
* button.onclick = async () => {
|
|
69
|
+
* await speaker.write(int16Chunk); // Int16Array of PCM samples
|
|
70
|
+
* await speaker.drain();
|
|
71
|
+
* speaker.stop();
|
|
72
|
+
* };
|
|
73
|
+
*/
|
|
74
|
+
class Speaker {
|
|
75
|
+
constructor(options = {}) {
|
|
76
|
+
// ── State ─────────────────────────────────────────────────────────────
|
|
77
|
+
this._audioContext = null;
|
|
78
|
+
this._workletNode = null;
|
|
79
|
+
this._resampler = null;
|
|
80
|
+
this._started = false;
|
|
81
|
+
this._starting = null;
|
|
82
|
+
this._stopRequested = false;
|
|
83
|
+
|
|
84
|
+
// Last authoritative ring level from a 'level' ack, with the context time
|
|
85
|
+
// it was observed, so the current depth can be estimated by decay.
|
|
86
|
+
this._lastAck = null;
|
|
87
|
+
// Ring capacity in samples, set at start from the context rate. The 'level'
|
|
88
|
+
// acks confirm it.
|
|
89
|
+
this._capacity = 0;
|
|
90
|
+
this._contextRate = 0;
|
|
91
|
+
|
|
92
|
+
// Whether audio queued for playback has not yet finished. Set on each feed,
|
|
93
|
+
// cleared when the worklet reports the ring drained.
|
|
94
|
+
this._unplayed = false;
|
|
95
|
+
this._pendingDrains = [];
|
|
96
|
+
|
|
97
|
+
// ── Options ───────────────────────────────────────────────────────────
|
|
98
|
+
this._sampleRate = options.sampleRate ?? 16000;
|
|
99
|
+
this._channels = options.channels ?? 1;
|
|
100
|
+
this._dtype = options.dtype ?? 'int16';
|
|
101
|
+
this._workletUrl = options.workletUrl;
|
|
102
|
+
|
|
103
|
+
// ── Validate (mirrors the browser Microphone error style: TypeError) ────
|
|
104
|
+
if (this._sampleRate < 1000 || this._sampleRate > 384000) {
|
|
105
|
+
throw new TypeError(`sample rate must be between 1000 and 384000, got ${this._sampleRate}`);
|
|
106
|
+
}
|
|
107
|
+
if (this._channels < 1 || this._channels > 32) {
|
|
108
|
+
throw new TypeError(`channels must be between 1 and 32, got ${this._channels}`);
|
|
109
|
+
}
|
|
110
|
+
if (this._dtype !== 'int16' && this._dtype !== 'float32') {
|
|
111
|
+
throw new TypeError("dtype must be 'int16' or 'float32'");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create and resume the AudioContext and load the output worklet.
|
|
119
|
+
*
|
|
120
|
+
* Must be called from a user gesture context so the browser allows audio.
|
|
121
|
+
* Optional: write() starts playback on its own if start() was not called, but
|
|
122
|
+
* calling start() from a click handler is the reliable way to unlock audio
|
|
123
|
+
* before samples are ready. No-op if already started; returns the in-flight
|
|
124
|
+
* promise if a start is already in progress.
|
|
125
|
+
*
|
|
126
|
+
* @returns {Promise<void>}
|
|
127
|
+
*/
|
|
128
|
+
start() {
|
|
129
|
+
if (this._started) return Promise.resolve();
|
|
130
|
+
if (this._starting) return this._starting;
|
|
131
|
+
|
|
132
|
+
this._starting = this._doStart().finally(() => {
|
|
133
|
+
this._starting = null;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return this._starting;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Play PCM audio. Resolves when the samples are queued.
|
|
141
|
+
*
|
|
142
|
+
* Converts the chunk to context-rate float32 (int16 to float32 if needed,
|
|
143
|
+
* then resample), and feeds it to the output worklet. When the queue is full
|
|
144
|
+
* it applies backpressure: the returned promise does not resolve until there
|
|
145
|
+
* is room, so a caller that awaits write() is paced to playback. Starts
|
|
146
|
+
* playback on the first call (gesture-sensitive).
|
|
147
|
+
*
|
|
148
|
+
* Await calls sequentially to preserve sample order. An empty chunk resolves
|
|
149
|
+
* immediately. Writing after stop() starts a fresh playback session.
|
|
150
|
+
*
|
|
151
|
+
* @param {Int16Array|Float32Array|ArrayBuffer} chunk PCM samples in the
|
|
152
|
+
* configured dtype.
|
|
153
|
+
* @returns {Promise<void>}
|
|
154
|
+
*/
|
|
155
|
+
async write(chunk) {
|
|
156
|
+
await this.start();
|
|
157
|
+
if (!this._started) throw new Error('Speaker is stopped');
|
|
158
|
+
|
|
159
|
+
const float32 = this._convert(chunk);
|
|
160
|
+
if (float32.length === 0) return;
|
|
161
|
+
|
|
162
|
+
let offset = 0;
|
|
163
|
+
while (offset < float32.length) {
|
|
164
|
+
const sliceLen = Math.min(float32.length - offset, this._capacity);
|
|
165
|
+
await this._reserve(sliceLen);
|
|
166
|
+
if (!this._started) throw new Error('Speaker is stopped');
|
|
167
|
+
|
|
168
|
+
// slice() copies into its own buffer, so transferring it does not neuter
|
|
169
|
+
// the caller's array or the rest of this conversion.
|
|
170
|
+
const slice = float32.slice(offset, offset + sliceLen);
|
|
171
|
+
this._workletNode.port.postMessage(slice.buffer, [slice.buffer]);
|
|
172
|
+
|
|
173
|
+
// Anchor the queue estimate optimistically so back-to-back writes pace
|
|
174
|
+
// correctly before the worklet's 'level' ack lands; the ack refines it.
|
|
175
|
+
this._lastAck = { queued: this._estimateQueue() + sliceLen, time: this._now() };
|
|
176
|
+
this._unplayed = true;
|
|
177
|
+
offset += sliceLen;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Wait for all queued audio to finish playing.
|
|
183
|
+
*
|
|
184
|
+
* Resolves when the worklet reports its queue drained. Resolves immediately
|
|
185
|
+
* if nothing is queued (nothing written, or playback already caught up).
|
|
186
|
+
*
|
|
187
|
+
* @returns {Promise<void>}
|
|
188
|
+
*/
|
|
189
|
+
drain() {
|
|
190
|
+
if (!this._started || !this._unplayed) return Promise.resolve();
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
this._pendingDrains.push(resolve);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Immediate stop. Discards queued audio and releases all resources.
|
|
198
|
+
* Safe to call multiple times or before start(). After stop(), write() or
|
|
199
|
+
* start() begins a fresh session.
|
|
200
|
+
*/
|
|
201
|
+
stop() {
|
|
202
|
+
if (!this._started) {
|
|
203
|
+
// If a start is in flight, tear it down once it finishes.
|
|
204
|
+
if (this._starting) this._stopRequested = true;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this._started = false;
|
|
208
|
+
|
|
209
|
+
if (this._workletNode) {
|
|
210
|
+
try {
|
|
211
|
+
this._workletNode.port.postMessage({ type: 'flush' });
|
|
212
|
+
} catch {
|
|
213
|
+
// The port may already be closing; the context close below stops audio.
|
|
214
|
+
}
|
|
215
|
+
this._workletNode.disconnect();
|
|
216
|
+
this._workletNode.port.onmessage = null;
|
|
217
|
+
this._workletNode.port.close();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (this._audioContext) this._audioContext.close();
|
|
221
|
+
|
|
222
|
+
// The queue was discarded; release anyone waiting on drain so they do not
|
|
223
|
+
// hang. Nothing more will play on this session.
|
|
224
|
+
const drains = this._pendingDrains;
|
|
225
|
+
this._pendingDrains = [];
|
|
226
|
+
for (const resolve of drains) resolve();
|
|
227
|
+
|
|
228
|
+
this._audioContext = null;
|
|
229
|
+
this._workletNode = null;
|
|
230
|
+
this._resampler = null;
|
|
231
|
+
this._lastAck = null;
|
|
232
|
+
this._unplayed = false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Whether audio is currently queued and playing. */
|
|
236
|
+
get isPlaying() {
|
|
237
|
+
return this._started && this._unplayed;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Private ─────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
async _doStart() {
|
|
243
|
+
// 1. Create the AudioContext at its native rate and resume it (Safari and
|
|
244
|
+
// the autoplay policy both leave it suspended until resumed).
|
|
245
|
+
this._audioContext = new AudioContext();
|
|
246
|
+
this._contextRate = this._audioContext.sampleRate;
|
|
247
|
+
|
|
248
|
+
await this._audioContext.resume();
|
|
249
|
+
|
|
250
|
+
// A context still suspended after resume() means the browser blocked audio
|
|
251
|
+
// (no user gesture). Surface it rather than failing silently.
|
|
252
|
+
if (this._audioContext.state === 'suspended') {
|
|
253
|
+
await this._audioContext.close();
|
|
254
|
+
this._audioContext = null;
|
|
255
|
+
throw new Error('Audio playback is blocked until a user gesture resumes the audio context');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 2. Load the output AudioWorklet processor.
|
|
259
|
+
let blobUrl = null;
|
|
260
|
+
const workletUrl = this._workletUrl ?? (blobUrl = this._createBlobUrl());
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
await this._audioContext.audioWorklet.addModule(workletUrl);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
266
|
+
await this._audioContext.close();
|
|
267
|
+
this._audioContext = null;
|
|
268
|
+
throw new Error('Failed to load audio worklet: ' + (err instanceof Error ? err.message : String(err)));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
272
|
+
|
|
273
|
+
// 3. Size the ring and build the resampler now that the context rate is known.
|
|
274
|
+
this._capacity = Math.round(this._contextRate * BUFFER_SECONDS);
|
|
275
|
+
this._resampler = new Resampler(this._sampleRate, this._contextRate);
|
|
276
|
+
this._lastAck = null;
|
|
277
|
+
this._unplayed = false;
|
|
278
|
+
|
|
279
|
+
// 4. Build the output node and connect it TO the destination (the inverse
|
|
280
|
+
// of capture, which deliberately does not connect to the destination).
|
|
281
|
+
this._workletNode = new AudioWorkletNode(this._audioContext, 'decibri-output-processor', {
|
|
282
|
+
numberOfInputs: 0,
|
|
283
|
+
numberOfOutputs: 1,
|
|
284
|
+
outputChannelCount: [this._channels],
|
|
285
|
+
processorOptions: { ringCapacity: this._capacity },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
this._workletNode.port.onmessage = (event) => this._onMessage(event);
|
|
289
|
+
this._workletNode.connect(this._audioContext.destination);
|
|
290
|
+
|
|
291
|
+
this._started = true;
|
|
292
|
+
|
|
293
|
+
// If stop() was called while start() was in flight, tear down now.
|
|
294
|
+
if (this._stopRequested) {
|
|
295
|
+
this._stopRequested = false;
|
|
296
|
+
this.stop();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
_onMessage(event) {
|
|
301
|
+
const msg = event.data;
|
|
302
|
+
if (!msg) return;
|
|
303
|
+
|
|
304
|
+
if (msg.type === 'level') {
|
|
305
|
+
// Authoritative ring depth at this moment; the capacity is confirmed too.
|
|
306
|
+
this._lastAck = { queued: msg.queued, time: this._now() };
|
|
307
|
+
this._capacity = msg.capacity;
|
|
308
|
+
} else if (msg.type === 'drained') {
|
|
309
|
+
this._unplayed = false;
|
|
310
|
+
const drains = this._pendingDrains;
|
|
311
|
+
this._pendingDrains = [];
|
|
312
|
+
for (const resolve of drains) resolve();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
_createBlobUrl() {
|
|
317
|
+
const blob = new Blob([OUTPUT_WORKLET_SOURCE], { type: 'application/javascript' });
|
|
318
|
+
return URL.createObjectURL(blob);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_now() {
|
|
322
|
+
return this._audioContext ? this._audioContext.currentTime : 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Estimate the ring depth right now: the last acked depth, decayed by the
|
|
326
|
+
// samples that have played since (the worklet consumes at the context rate).
|
|
327
|
+
_estimateQueue() {
|
|
328
|
+
if (!this._lastAck) return 0;
|
|
329
|
+
const elapsed = this._now() - this._lastAck.time;
|
|
330
|
+
const played = Math.max(0, elapsed) * this._contextRate;
|
|
331
|
+
return Math.max(0, this._lastAck.queued - played);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Wait until the ring can accept `samples` more without overflowing, so a
|
|
335
|
+
// transferred feed is never partially dropped. Returns as soon as there is
|
|
336
|
+
// room; under pressure it sleeps for the time the surplus needs to drain.
|
|
337
|
+
async _reserve(samples) {
|
|
338
|
+
while (this._started) {
|
|
339
|
+
const overflow = this._estimateQueue() + samples - this._capacity;
|
|
340
|
+
if (overflow <= 0) return;
|
|
341
|
+
const waitMs = Math.max(1, (overflow / this._contextRate) * 1000);
|
|
342
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
_convert(chunk) {
|
|
347
|
+
let float32;
|
|
348
|
+
if (this._dtype === 'int16') {
|
|
349
|
+
const int16 = this._asInt16(chunk);
|
|
350
|
+
float32 = new Float32Array(int16.length);
|
|
351
|
+
for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768;
|
|
352
|
+
} else {
|
|
353
|
+
float32 = this._asFloat32(chunk);
|
|
354
|
+
}
|
|
355
|
+
return this._resampler.process(float32);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
_asInt16(chunk) {
|
|
359
|
+
if (chunk instanceof Int16Array) return chunk;
|
|
360
|
+
if (chunk instanceof ArrayBuffer) return new Int16Array(chunk);
|
|
361
|
+
if (ArrayBuffer.isView(chunk)) {
|
|
362
|
+
return new Int16Array(chunk.buffer, chunk.byteOffset, Math.floor(chunk.byteLength / 2));
|
|
363
|
+
}
|
|
364
|
+
throw new TypeError('write() expects an Int16Array, ArrayBuffer, or typed array for int16 dtype');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
_asFloat32(chunk) {
|
|
368
|
+
if (chunk instanceof Float32Array) return chunk;
|
|
369
|
+
if (chunk instanceof ArrayBuffer) return new Float32Array(chunk);
|
|
370
|
+
if (ArrayBuffer.isView(chunk)) {
|
|
371
|
+
return new Float32Array(chunk.buffer, chunk.byteOffset, Math.floor(chunk.byteLength / 4));
|
|
372
|
+
}
|
|
373
|
+
throw new TypeError('write() expects a Float32Array, ArrayBuffer, or typed array for float32 dtype');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = { Speaker };
|
package/src/browser/index.d.ts
CHANGED
|
@@ -166,3 +166,88 @@ export declare class Microphone {
|
|
|
166
166
|
emit(event: string, ...args: any[]): boolean;
|
|
167
167
|
removeAllListeners(event?: string): this;
|
|
168
168
|
}
|
|
169
|
+
|
|
170
|
+
/** Constructor options for the browser `Speaker` class. */
|
|
171
|
+
export interface SpeakerOptions {
|
|
172
|
+
/**
|
|
173
|
+
* Sample rate of the audio you write, in Hz. Samples are resampled to the
|
|
174
|
+
* audio context's native rate before playback.
|
|
175
|
+
* @default 16000
|
|
176
|
+
* @range 1000–384000
|
|
177
|
+
*/
|
|
178
|
+
sampleRate?: number;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Number of output channels. A mono stream is played on every channel.
|
|
182
|
+
* @default 1
|
|
183
|
+
* @range 1–32
|
|
184
|
+
*/
|
|
185
|
+
channels?: number;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Sample encoding data type of the audio you write.
|
|
189
|
+
* - `'int16'`: Int16Array of PCM samples
|
|
190
|
+
* - `'float32'`: Float32Array of samples in [-1, 1]
|
|
191
|
+
* @default 'int16'
|
|
192
|
+
*/
|
|
193
|
+
dtype?: 'int16' | 'float32';
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Custom URL for the output AudioWorklet processor script.
|
|
197
|
+
* Use this for strict CSP environments that block blob URLs.
|
|
198
|
+
* Omit to use the default inline blob URL.
|
|
199
|
+
*/
|
|
200
|
+
workletUrl?: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Browser audio playback via the Web Audio API + AudioWorklet.
|
|
205
|
+
*
|
|
206
|
+
* Plays PCM audio. Samples are converted to float32 and resampled to the
|
|
207
|
+
* context rate on the main thread, then fed to an output worklet that drains
|
|
208
|
+
* them to the speakers. Playback is async throughout, as the browser requires:
|
|
209
|
+
* `write()` resolves when the samples are queued and `drain()` resolves when
|
|
210
|
+
* the queued audio has finished playing. The first `write()` (or `start()`)
|
|
211
|
+
* must run in a user gesture so the browser allows audio.
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```js
|
|
215
|
+
* import { Speaker } from 'decibri'; // browser entry via conditional export
|
|
216
|
+
*
|
|
217
|
+
* const speaker = new Speaker({ sampleRate: 16000 });
|
|
218
|
+
* button.onclick = async () => {
|
|
219
|
+
* await speaker.write(int16Chunk); // Int16Array of PCM samples
|
|
220
|
+
* await speaker.drain();
|
|
221
|
+
* speaker.stop();
|
|
222
|
+
* };
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
export declare class Speaker {
|
|
226
|
+
constructor(options?: SpeakerOptions);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create and resume the AudioContext and load the output worklet.
|
|
230
|
+
* Must be called from a user gesture context so the browser allows audio.
|
|
231
|
+
* Optional: `write()` starts playback on its own if `start()` was not called.
|
|
232
|
+
*/
|
|
233
|
+
start(): Promise<void>;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Play PCM audio. Resolves when the samples are queued, applying backpressure
|
|
237
|
+
* (the promise waits) when the playback queue is full. Await calls
|
|
238
|
+
* sequentially to preserve sample order.
|
|
239
|
+
*/
|
|
240
|
+
write(chunk: Int16Array | Float32Array | ArrayBuffer): Promise<void>;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Wait for all queued audio to finish playing. Resolves immediately if
|
|
244
|
+
* nothing is queued.
|
|
245
|
+
*/
|
|
246
|
+
drain(): Promise<void>;
|
|
247
|
+
|
|
248
|
+
/** Immediate stop. Discards queued audio and releases all resources. */
|
|
249
|
+
stop(): void;
|
|
250
|
+
|
|
251
|
+
/** Whether audio is currently queued and playing. */
|
|
252
|
+
readonly isPlaying: boolean;
|
|
253
|
+
}
|
package/src/browser/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Microphone } = require('./decibri-browser.js');
|
|
4
|
+
const { Speaker } = require('./decibri-output-browser.js');
|
|
4
5
|
const { Emitter } = require('./emitter.js');
|
|
5
6
|
|
|
6
|
-
module.exports = { Microphone, Emitter };
|
|
7
|
+
module.exports = { Microphone, Speaker, Emitter };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minified output AudioWorklet processor source, embedded as a string for Blob
|
|
5
|
+
* URL loading.
|
|
6
|
+
*
|
|
7
|
+
* THIS IS THE CODE THAT ACTUALLY RUNS IN THE BROWSER.
|
|
8
|
+
* The readable version is in output-worklet-processor.js (documentation /
|
|
9
|
+
* reference only). If output-worklet-processor.js logic changes, this string
|
|
10
|
+
* MUST be regenerated.
|
|
11
|
+
*
|
|
12
|
+
* Logic identical to output-worklet-processor.js.
|
|
13
|
+
*/
|
|
14
|
+
const OUTPUT_WORKLET_SOURCE = "class R{constructor(t){this.capacity=t,this.buffer=new Float32Array(t),this.writeIndex=0,this.readIndex=0,this.size=0}get availableRead(){return this.size}get availableWrite(){return this.capacity-this.size}get isEmpty(){return this.size===0}get isFull(){return this.size===this.capacity}write(t){let e=Math.min(t.length,this.availableWrite);for(let s=0;s<e;s++)this.buffer[this.writeIndex]=t[s],this.writeIndex=this.writeIndex+1===this.capacity?0:this.writeIndex+1;return this.size+=e,e}readInto(t,e,s){let i=Math.min(s,this.size);for(let f=0;f<i;f++)t[e+f]=this.buffer[this.readIndex],this.readIndex=this.readIndex+1===this.capacity?0:this.readIndex+1;return this.size-=i,i}clear(){this.writeIndex=0,this.readIndex=0,this.size=0}}class P extends AudioWorkletProcessor{constructor(t){super();let e=t&&t.processorOptions||{},s=e.ringCapacity??96000;this.ring=new R(s),this.hadData=!1,this.port.onmessage=i=>this._onmessage(i)}_onmessage(t){let e=t.data;if(e&&e.type===\"flush\"){this.ring.clear(),this.hadData=!1;return}if(e instanceof ArrayBuffer){let s=new Float32Array(e),i=this.ring.write(s);i>0&&(this.hadData=!0),this.port.postMessage({type:\"level\",queued:this.ring.availableRead,capacity:this.ring.capacity,accepted:i,requested:s.length})}}process(t,e,s){let i=e[0];if(!i||i.length===0)return!0;let f=i[0].length,a=this.ring.readInto(i[0],0,f);for(let o=a;o<f;o++)i[0][o]=0;for(let o=1;o<i.length;o++)i[o].set(i[0]);return this.hadData&&this.ring.isEmpty&&(this.hadData=!1,this.port.postMessage({type:\"drained\"})),!0}}P.RingBuffer=R;registerProcessor(\"decibri-output-processor\",P);\n";
|
|
15
|
+
|
|
16
|
+
module.exports = { OUTPUT_WORKLET_SOURCE };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioWorklet processor for decibri browser playback.
|
|
3
|
+
*
|
|
4
|
+
* THIS FILE IS THE READABLE SOURCE. It is NOT loaded at runtime.
|
|
5
|
+
* The minified version in output-worklet-inline.js is what actually runs.
|
|
6
|
+
* If you change logic here, you MUST regenerate output-worklet-inline.js.
|
|
7
|
+
*
|
|
8
|
+
* Runs in a dedicated audio thread. Pulls queued Float32 samples (already at
|
|
9
|
+
* the context sample rate) from a ring buffer and writes them to the output
|
|
10
|
+
* channels. On underrun (the ring buffer is empty) it outputs silence cleanly,
|
|
11
|
+
* with no throw and no glitch beyond the unavoidable gap. This is the inverse
|
|
12
|
+
* of the capture processor: capture reads input frames and posts them to the
|
|
13
|
+
* main thread; this pulls main-thread frames from the ring and writes output.
|
|
14
|
+
*
|
|
15
|
+
* This file cannot import other modules (AudioWorklet restriction).
|
|
16
|
+
*
|
|
17
|
+
* Mirrors worklet-processor.js (capture) in reverse.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fixed-capacity single-producer single-consumer ring buffer of Float32
|
|
22
|
+
* samples. The main thread writes (produces) via write(); the audio thread
|
|
23
|
+
* reads (consumes) via readInto(). Indices wrap around at capacity. A write
|
|
24
|
+
* that would overflow stores only what fits and returns the accepted count, so
|
|
25
|
+
* the producer can apply backpressure. A read past the stored data returns the
|
|
26
|
+
* real count read, so the consumer can fill the remainder with silence.
|
|
27
|
+
*/
|
|
28
|
+
class RingBuffer {
|
|
29
|
+
constructor(capacity) {
|
|
30
|
+
this.capacity = capacity;
|
|
31
|
+
this.buffer = new Float32Array(capacity);
|
|
32
|
+
this.writeIndex = 0;
|
|
33
|
+
this.readIndex = 0;
|
|
34
|
+
this.size = 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Number of samples currently queued for reading. */
|
|
38
|
+
get availableRead() {
|
|
39
|
+
return this.size;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Number of free slots a write can still accept. */
|
|
43
|
+
get availableWrite() {
|
|
44
|
+
return this.capacity - this.size;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get isEmpty() {
|
|
48
|
+
return this.size === 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get isFull() {
|
|
52
|
+
return this.size === this.capacity;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write up to samples.length samples into the buffer. Returns the number
|
|
57
|
+
* actually written, which is less than samples.length when the buffer fills.
|
|
58
|
+
*/
|
|
59
|
+
write(samples) {
|
|
60
|
+
const toWrite = Math.min(samples.length, this.availableWrite);
|
|
61
|
+
for (let i = 0; i < toWrite; i++) {
|
|
62
|
+
this.buffer[this.writeIndex] = samples[i];
|
|
63
|
+
this.writeIndex = this.writeIndex + 1 === this.capacity ? 0 : this.writeIndex + 1;
|
|
64
|
+
}
|
|
65
|
+
this.size += toWrite;
|
|
66
|
+
return toWrite;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read up to count samples into target starting at offset. Returns the
|
|
71
|
+
* number actually read, which is less than count on underrun. Positions in
|
|
72
|
+
* target beyond the returned count are left untouched (the caller fills them
|
|
73
|
+
* with silence).
|
|
74
|
+
*/
|
|
75
|
+
readInto(target, offset, count) {
|
|
76
|
+
const toRead = Math.min(count, this.size);
|
|
77
|
+
for (let i = 0; i < toRead; i++) {
|
|
78
|
+
target[offset + i] = this.buffer[this.readIndex];
|
|
79
|
+
this.readIndex = this.readIndex + 1 === this.capacity ? 0 : this.readIndex + 1;
|
|
80
|
+
}
|
|
81
|
+
this.size -= toRead;
|
|
82
|
+
return toRead;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Drop all queued samples and reset the pointers. */
|
|
86
|
+
clear() {
|
|
87
|
+
this.writeIndex = 0;
|
|
88
|
+
this.readIndex = 0;
|
|
89
|
+
this.size = 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
class DecibriOutputProcessor extends AudioWorkletProcessor {
|
|
94
|
+
constructor(options) {
|
|
95
|
+
super();
|
|
96
|
+
const opts = (options && options.processorOptions) || {};
|
|
97
|
+
const capacity = opts.ringCapacity ?? 96000;
|
|
98
|
+
this.ring = new RingBuffer(capacity);
|
|
99
|
+
// Latches a single 'drained' notification per playout: set true when audio
|
|
100
|
+
// is queued, cleared (and the notification sent) when the ring next empties.
|
|
101
|
+
this.hadData = false;
|
|
102
|
+
this.port.onmessage = (event) => this._onmessage(event);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_onmessage(event) {
|
|
106
|
+
const data = event.data;
|
|
107
|
+
|
|
108
|
+
// Control message: drop the queue (stop / reset). No drain notification is
|
|
109
|
+
// sent for a flush; a flush is an explicit stop, not a natural playout end.
|
|
110
|
+
if (data && data.type === 'flush') {
|
|
111
|
+
this.ring.clear();
|
|
112
|
+
this.hadData = false;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Data message: a raw Float32 sample buffer (transferable), mirroring the
|
|
117
|
+
// capture worklet's raw-buffer format in reverse. The samples are already
|
|
118
|
+
// at the context sample rate; the main thread does any resample and dtype
|
|
119
|
+
// conversion before feeding.
|
|
120
|
+
if (data instanceof ArrayBuffer) {
|
|
121
|
+
const samples = new Float32Array(data);
|
|
122
|
+
const accepted = this.ring.write(samples);
|
|
123
|
+
if (accepted > 0) this.hadData = true;
|
|
124
|
+
// Backpressure ack: report the new fill level so the producer can pace
|
|
125
|
+
// its writes, and signal drops (accepted < requested means the ring was
|
|
126
|
+
// full and the surplus was discarded).
|
|
127
|
+
this.port.postMessage({
|
|
128
|
+
type: 'level',
|
|
129
|
+
queued: this.ring.availableRead,
|
|
130
|
+
capacity: this.ring.capacity,
|
|
131
|
+
accepted,
|
|
132
|
+
requested: samples.length,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
process(_inputs, outputs, _parameters) {
|
|
138
|
+
const output = outputs[0];
|
|
139
|
+
if (!output || output.length === 0) return true;
|
|
140
|
+
|
|
141
|
+
const frames = output[0].length;
|
|
142
|
+
const got = this.ring.readInto(output[0], 0, frames);
|
|
143
|
+
|
|
144
|
+
// Underrun: fill the rest of the first channel with silence.
|
|
145
|
+
for (let i = got; i < frames; i++) output[0][i] = 0;
|
|
146
|
+
|
|
147
|
+
// Fan the mono stream out to any additional output channels.
|
|
148
|
+
for (let ch = 1; ch < output.length; ch++) {
|
|
149
|
+
output[ch].set(output[0]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Drain detection: notify the main thread once when the queue empties
|
|
153
|
+
// after having held audio, so the Speaker's drain() can resolve.
|
|
154
|
+
if (this.hadData && this.ring.isEmpty) {
|
|
155
|
+
this.hadData = false;
|
|
156
|
+
this.port.postMessage({ type: 'drained' });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Keep the processor alive across underruns so playback can resume.
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Exposed so the test harness can exercise the ring buffer in isolation. The
|
|
165
|
+
// runtime worklet never reads this property; it is a harmless static.
|
|
166
|
+
DecibriOutputProcessor.RingBuffer = RingBuffer;
|
|
167
|
+
|
|
168
|
+
registerProcessor('decibri-output-processor', DecibriOutputProcessor);
|