decibri 4.0.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 +13 -0
- package/MIGRATION.md +31 -0
- package/README.md +80 -0
- package/examples/README.md +79 -0
- package/examples/browser-speaker-test.html +159 -0
- package/examples/decibri.browser.js +594 -0
- package/index.d.ts +31 -0
- package/index.js +66 -56
- package/package.json +6 -6
- 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
- package/src/decibri-output.js +114 -11
- package/src/decibri.d.ts +61 -0
- package/src/decibri.js +84 -18
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// decibri browser bundle. GENERATED from src/browser/index.js. Do not edit by hand; see examples/README.md to regenerate.
|
|
2
|
+
"use strict";
|
|
3
|
+
var decibri = (function() {
|
|
4
|
+
//#region \0rolldown/runtime.js
|
|
5
|
+
var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region npm/decibri/src/browser/emitter.js
|
|
8
|
+
var require_emitter = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
9
|
+
/**
|
|
10
|
+
* Minimal event emitter for browsers.
|
|
11
|
+
*
|
|
12
|
+
* Provides .on() / .off() / .once() / .emit() with the same API pattern
|
|
13
|
+
* as Node.js EventEmitter. Used instead of browser-native EventTarget to
|
|
14
|
+
* preserve API parity with the Node.js decibri (.on('data', cb) pattern).
|
|
15
|
+
*
|
|
16
|
+
* Ported from decibri-web emitter.ts. Logic identical, types removed.
|
|
17
|
+
*/
|
|
18
|
+
var Emitter = class {
|
|
19
|
+
constructor() {
|
|
20
|
+
this._listeners = /* @__PURE__ */ new Map();
|
|
21
|
+
}
|
|
22
|
+
on(event, fn) {
|
|
23
|
+
let set = this._listeners.get(event);
|
|
24
|
+
if (!set) {
|
|
25
|
+
set = /* @__PURE__ */ new Set();
|
|
26
|
+
this._listeners.set(event, set);
|
|
27
|
+
}
|
|
28
|
+
set.add(fn);
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
off(event, fn) {
|
|
32
|
+
const set = this._listeners.get(event);
|
|
33
|
+
if (!set) return this;
|
|
34
|
+
if (set.delete(fn)) return this;
|
|
35
|
+
for (const listener of set) if (listener._original === fn) {
|
|
36
|
+
set.delete(listener);
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
once(event, fn) {
|
|
42
|
+
const wrapper = (...args) => {
|
|
43
|
+
this.off(event, wrapper);
|
|
44
|
+
fn(...args);
|
|
45
|
+
};
|
|
46
|
+
wrapper._original = fn;
|
|
47
|
+
return this.on(event, wrapper);
|
|
48
|
+
}
|
|
49
|
+
emit(event, ...args) {
|
|
50
|
+
const set = this._listeners.get(event);
|
|
51
|
+
if (!set || set.size === 0) return false;
|
|
52
|
+
for (const fn of set) fn(...args);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
removeAllListeners(event) {
|
|
56
|
+
if (event !== void 0) this._listeners.delete(event);
|
|
57
|
+
else this._listeners.clear();
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
module.exports = { Emitter };
|
|
62
|
+
}));
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region npm/decibri/src/browser/worklet-inline.js
|
|
65
|
+
var require_worklet_inline = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
66
|
+
module.exports = { WORKLET_SOURCE: "var u=class extends AudioWorkletProcessor{constructor(r){super();let e=r.processorOptions;this.framesPerBuffer=e.framesPerBuffer,this.format=e.format,this.ratio=e.nativeSampleRate/e.targetSampleRate,this.needsResample=e.nativeSampleRate!==e.targetSampleRate,this.position=0,this.buffer=new Float32Array(this.framesPerBuffer),this.bufferIndex=0}process(r,e,s){let t=r[0]?.[0];if(!t||t.length===0)return!0;let f;this.needsResample?f=this.resample(t):f=t;let a=0;for(;a<f.length;){let i=this.framesPerBuffer-this.bufferIndex,o=f.length-a,n=Math.min(i,o);this.buffer.set(f.subarray(a,a+n),this.bufferIndex),this.bufferIndex+=n,a+=n,this.bufferIndex>=this.framesPerBuffer&&this.flush()}return!0}resample(r){let e=r.length,s=0,t=this.position;for(;t<e-1;)s++,t+=this.ratio;let f=new Float32Array(s);t=this.position;for(let a=0;a<s;a++){let i=Math.floor(t),o=t-i;f[a]=r[i]*(1-o)+r[i+1]*o,t+=this.ratio}return this.position=Math.max(0,t-e),f}flush(){let r;if(this.format===\"int16\"){let e=new Int16Array(this.framesPerBuffer);for(let s=0;s<this.framesPerBuffer;s++)e[s]=Math.max(-32768,Math.min(32767,Math.round(this.buffer[s]*32768)));r=e.buffer}else r=this.buffer.slice(0,this.framesPerBuffer).buffer;this.port.postMessage(r,[r]),this.buffer=new Float32Array(this.framesPerBuffer),this.bufferIndex=0}};registerProcessor(\"decibri-processor\",u);\n" };
|
|
67
|
+
}));
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region npm/decibri/src/browser/decibri-browser.js
|
|
70
|
+
var require_decibri_browser = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
71
|
+
const { Emitter } = require_emitter();
|
|
72
|
+
const { WORKLET_SOURCE } = require_worklet_inline();
|
|
73
|
+
const VERSION = "4.2.0";
|
|
74
|
+
/**
|
|
75
|
+
* Browser microphone capture.
|
|
76
|
+
*
|
|
77
|
+
* Uses getUserMedia + AudioWorklet for real-time audio capture in browsers.
|
|
78
|
+
* Emits 'data' events with Int16Array or Float32Array chunks.
|
|
79
|
+
*
|
|
80
|
+
* Ported from decibri-web decibri.ts. Logic identical, types removed.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* const { Microphone } = require('decibri'); // browser entry via conditional export
|
|
84
|
+
* const mic = new Microphone({ sampleRate: 16000 });
|
|
85
|
+
* mic.on('data', (chunk) => { // chunk is Int16Array });
|
|
86
|
+
* await mic.start();
|
|
87
|
+
* // later...
|
|
88
|
+
* mic.stop();
|
|
89
|
+
*/
|
|
90
|
+
var Microphone = class extends Emitter {
|
|
91
|
+
constructor(options = {}) {
|
|
92
|
+
super();
|
|
93
|
+
this._audioContext = null;
|
|
94
|
+
this._stream = null;
|
|
95
|
+
this._sourceNode = null;
|
|
96
|
+
this._workletNode = null;
|
|
97
|
+
this._started = false;
|
|
98
|
+
this._starting = null;
|
|
99
|
+
this._stopRequested = false;
|
|
100
|
+
const vad = options.vad ?? false;
|
|
101
|
+
if (vad === false) this._vad = false;
|
|
102
|
+
else if (vad === true) throw new TypeError("vad: true is no longer supported. Specify the mode explicitly: vad: 'energy'.");
|
|
103
|
+
else if (vad === "energy") this._vad = true;
|
|
104
|
+
else throw new TypeError(`Invalid vad value: ${JSON.stringify(vad)}. Expected false or 'energy'.`);
|
|
105
|
+
this._vadThreshold = options.vadThreshold ?? .01;
|
|
106
|
+
this._vadHoldoff = options.vadHoldoff ?? 300;
|
|
107
|
+
this._vadScore = 0;
|
|
108
|
+
this._isSpeaking = false;
|
|
109
|
+
this._silenceTimer = null;
|
|
110
|
+
this._sampleRate = options.sampleRate ?? 16e3;
|
|
111
|
+
this._channels = options.channels ?? 1;
|
|
112
|
+
this._framesPerBuffer = options.framesPerBuffer ?? 1600;
|
|
113
|
+
this._device = options.device;
|
|
114
|
+
this._dtype = options.dtype ?? "int16";
|
|
115
|
+
this._echoCancellation = options.echoCancellation ?? true;
|
|
116
|
+
this._noiseSuppression = options.noiseSuppression ?? true;
|
|
117
|
+
this._workletUrl = options.workletUrl;
|
|
118
|
+
if (this._sampleRate < 1e3 || this._sampleRate > 384e3) throw new TypeError(`sample rate must be between 1000 and 384000, got ${this._sampleRate}`);
|
|
119
|
+
if (this._channels < 1 || this._channels > 32) throw new TypeError(`channels must be between 1 and 32, got ${this._channels}`);
|
|
120
|
+
if (this._framesPerBuffer < 64 || this._framesPerBuffer > 65536) throw new TypeError(`frames per buffer must be between 64 and 65536, got ${this._framesPerBuffer}`);
|
|
121
|
+
if (this._dtype !== "int16" && this._dtype !== "float32") throw new TypeError("dtype must be 'int16' or 'float32'");
|
|
122
|
+
if (this._vadThreshold < 0 || this._vadThreshold > 1) throw new TypeError(`vadThreshold must be between 0 and 1, got ${this._vadThreshold}`);
|
|
123
|
+
if (this._vadHoldoff < 0) throw new TypeError(`vadHoldoff must be >= 0, got ${this._vadHoldoff}`);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Start microphone capture.
|
|
127
|
+
* Requests microphone permission and sets up the audio pipeline.
|
|
128
|
+
* Must be called from a user gesture context in Safari.
|
|
129
|
+
* No-op if already started. Returns the existing promise if a start
|
|
130
|
+
* is already in progress.
|
|
131
|
+
*/
|
|
132
|
+
start() {
|
|
133
|
+
if (this._started) return Promise.resolve();
|
|
134
|
+
if (this._starting) return this._starting;
|
|
135
|
+
this._starting = this._doStart().finally(() => {
|
|
136
|
+
this._starting = null;
|
|
137
|
+
});
|
|
138
|
+
return this._starting;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Stop microphone capture and release all resources.
|
|
142
|
+
* Safe to call multiple times or before start().
|
|
143
|
+
* After stop(), calling start() again creates a fresh session.
|
|
144
|
+
*/
|
|
145
|
+
stop() {
|
|
146
|
+
if (!this._started) {
|
|
147
|
+
if (this._starting) this._stopRequested = true;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this._started = false;
|
|
151
|
+
if (this._stream) this._stream.getTracks().forEach((t) => t.stop());
|
|
152
|
+
if (this._sourceNode) this._sourceNode.disconnect();
|
|
153
|
+
if (this._workletNode) {
|
|
154
|
+
this._workletNode.disconnect();
|
|
155
|
+
this._workletNode.port.close();
|
|
156
|
+
}
|
|
157
|
+
if (this._audioContext) this._audioContext.close();
|
|
158
|
+
if (this._silenceTimer !== null) {
|
|
159
|
+
clearTimeout(this._silenceTimer);
|
|
160
|
+
this._silenceTimer = null;
|
|
161
|
+
}
|
|
162
|
+
this._isSpeaking = false;
|
|
163
|
+
this._audioContext = null;
|
|
164
|
+
this._stream = null;
|
|
165
|
+
this._sourceNode = null;
|
|
166
|
+
this._workletNode = null;
|
|
167
|
+
this.emit("end");
|
|
168
|
+
this.emit("close");
|
|
169
|
+
}
|
|
170
|
+
/** Whether the microphone is currently capturing. */
|
|
171
|
+
get isOpen() {
|
|
172
|
+
return this._started;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Most recent VAD score: the normalized RMS of the last chunk in `'energy'`
|
|
176
|
+
* mode, or 0 when VAD is disabled or before the first chunk is processed.
|
|
177
|
+
* @returns {number}
|
|
178
|
+
*/
|
|
179
|
+
get vadScore() {
|
|
180
|
+
return this._vadScore;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* List available audio input devices.
|
|
184
|
+
* Device labels may be empty until microphone permission is granted.
|
|
185
|
+
*/
|
|
186
|
+
static async devices() {
|
|
187
|
+
return (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === "audioinput").map((d) => ({
|
|
188
|
+
deviceId: d.deviceId,
|
|
189
|
+
label: d.label,
|
|
190
|
+
groupId: d.groupId
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
/** Version information. */
|
|
194
|
+
static version() {
|
|
195
|
+
return { decibri: VERSION };
|
|
196
|
+
}
|
|
197
|
+
async _doStart() {
|
|
198
|
+
this._audioContext = new AudioContext();
|
|
199
|
+
const nativeSampleRate = this._audioContext.sampleRate;
|
|
200
|
+
await this._audioContext.resume();
|
|
201
|
+
const audioConstraints = {
|
|
202
|
+
channelCount: this._channels,
|
|
203
|
+
echoCancellation: this._echoCancellation,
|
|
204
|
+
noiseSuppression: this._noiseSuppression
|
|
205
|
+
};
|
|
206
|
+
if (this._device) audioConstraints.deviceId = { exact: this._device };
|
|
207
|
+
try {
|
|
208
|
+
this._stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
await this._audioContext.close();
|
|
211
|
+
this._audioContext = null;
|
|
212
|
+
const error = this._mapError(err);
|
|
213
|
+
this.emit("error", error);
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
let blobUrl = null;
|
|
217
|
+
const workletUrl = this._workletUrl ?? (blobUrl = this._createBlobUrl());
|
|
218
|
+
try {
|
|
219
|
+
await this._audioContext.audioWorklet.addModule(workletUrl);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
222
|
+
this._stream.getTracks().forEach((t) => t.stop());
|
|
223
|
+
this._stream = null;
|
|
224
|
+
await this._audioContext.close();
|
|
225
|
+
this._audioContext = null;
|
|
226
|
+
const error = /* @__PURE__ */ new Error("Failed to load audio worklet: " + (err instanceof Error ? err.message : String(err)));
|
|
227
|
+
this.emit("error", error);
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
231
|
+
this._sourceNode = this._audioContext.createMediaStreamSource(this._stream);
|
|
232
|
+
this._workletNode = new AudioWorkletNode(this._audioContext, "decibri-processor", { processorOptions: {
|
|
233
|
+
framesPerBuffer: this._framesPerBuffer,
|
|
234
|
+
format: this._dtype,
|
|
235
|
+
nativeSampleRate,
|
|
236
|
+
targetSampleRate: this._sampleRate
|
|
237
|
+
} });
|
|
238
|
+
this._workletNode.port.onmessage = (event) => {
|
|
239
|
+
const buffer = event.data;
|
|
240
|
+
const chunk = this._dtype === "int16" ? new Int16Array(buffer) : new Float32Array(buffer);
|
|
241
|
+
this.emit("data", chunk);
|
|
242
|
+
if (this._vad) this._processVad(chunk);
|
|
243
|
+
};
|
|
244
|
+
this._sourceNode.connect(this._workletNode);
|
|
245
|
+
this._started = true;
|
|
246
|
+
if (this._stopRequested) {
|
|
247
|
+
this._stopRequested = false;
|
|
248
|
+
this.stop();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
_createBlobUrl() {
|
|
252
|
+
const blob = new Blob([WORKLET_SOURCE], { type: "application/javascript" });
|
|
253
|
+
return URL.createObjectURL(blob);
|
|
254
|
+
}
|
|
255
|
+
_mapError(err) {
|
|
256
|
+
if (err instanceof DOMException) switch (err.name) {
|
|
257
|
+
case "NotAllowedError": return /* @__PURE__ */ new Error("Microphone permission denied");
|
|
258
|
+
case "NotFoundError": return /* @__PURE__ */ new Error("No microphone found");
|
|
259
|
+
default: return /* @__PURE__ */ new Error("Microphone access failed: " + err.message);
|
|
260
|
+
}
|
|
261
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
262
|
+
}
|
|
263
|
+
_processVad(chunk) {
|
|
264
|
+
const rms = this._computeRms(chunk);
|
|
265
|
+
this._vadScore = rms;
|
|
266
|
+
if (rms >= this._vadThreshold) {
|
|
267
|
+
if (this._silenceTimer !== null) {
|
|
268
|
+
clearTimeout(this._silenceTimer);
|
|
269
|
+
this._silenceTimer = null;
|
|
270
|
+
}
|
|
271
|
+
if (!this._isSpeaking) {
|
|
272
|
+
this._isSpeaking = true;
|
|
273
|
+
this.emit("speech");
|
|
274
|
+
}
|
|
275
|
+
} else if (this._isSpeaking && this._silenceTimer === null) this._silenceTimer = setTimeout(() => {
|
|
276
|
+
this._isSpeaking = false;
|
|
277
|
+
this._silenceTimer = null;
|
|
278
|
+
this.emit("silence");
|
|
279
|
+
}, this._vadHoldoff);
|
|
280
|
+
}
|
|
281
|
+
_computeRms(chunk) {
|
|
282
|
+
let sum = 0;
|
|
283
|
+
const n = chunk.length;
|
|
284
|
+
if (n === 0) return 0;
|
|
285
|
+
if (chunk instanceof Float32Array) for (let i = 0; i < n; i++) sum += chunk[i] * chunk[i];
|
|
286
|
+
else for (let i = 0; i < n; i++) {
|
|
287
|
+
const s = chunk[i] / 32768;
|
|
288
|
+
sum += s * s;
|
|
289
|
+
}
|
|
290
|
+
return Math.sqrt(sum / n);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
module.exports = { Microphone };
|
|
294
|
+
}));
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region npm/decibri/src/browser/output-worklet-inline.js
|
|
297
|
+
var require_output_worklet_inline = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
298
|
+
module.exports = { 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" };
|
|
299
|
+
}));
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region npm/decibri/src/browser/decibri-output-browser.js
|
|
302
|
+
var require_decibri_output_browser = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
303
|
+
const { OUTPUT_WORKLET_SOURCE } = require_output_worklet_inline();
|
|
304
|
+
const BUFFER_SECONDS = 2;
|
|
305
|
+
/**
|
|
306
|
+
* Linear-interpolation resampler, the inverse of the capture worklet's: it
|
|
307
|
+
* takes samples at the user's rate and produces samples at the context rate.
|
|
308
|
+
* Carries a fractional position across calls so successive writes stay
|
|
309
|
+
* continuous. Logic mirrors worklet-processor.js resample(), with from and to
|
|
310
|
+
* swapped (from = user rate, to = context rate).
|
|
311
|
+
*/
|
|
312
|
+
var Resampler = class {
|
|
313
|
+
constructor(fromRate, toRate) {
|
|
314
|
+
this.ratio = fromRate / toRate;
|
|
315
|
+
this.position = 0;
|
|
316
|
+
}
|
|
317
|
+
process(input) {
|
|
318
|
+
if (this.ratio === 1) return input;
|
|
319
|
+
const inputLength = input.length;
|
|
320
|
+
let count = 0;
|
|
321
|
+
let pos = this.position;
|
|
322
|
+
while (pos < inputLength - 1) {
|
|
323
|
+
count++;
|
|
324
|
+
pos += this.ratio;
|
|
325
|
+
}
|
|
326
|
+
const output = new Float32Array(count);
|
|
327
|
+
pos = this.position;
|
|
328
|
+
for (let i = 0; i < count; i++) {
|
|
329
|
+
const idx = Math.floor(pos);
|
|
330
|
+
const frac = pos - idx;
|
|
331
|
+
output[i] = input[idx] * (1 - frac) + input[idx + 1] * frac;
|
|
332
|
+
pos += this.ratio;
|
|
333
|
+
}
|
|
334
|
+
this.position = Math.max(0, pos - inputLength);
|
|
335
|
+
return output;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
/**
|
|
339
|
+
* Browser audio playback.
|
|
340
|
+
*
|
|
341
|
+
* Plays PCM audio through the Web Audio API. Samples are converted to float32
|
|
342
|
+
* and resampled to the context rate on the main thread, then fed to an output
|
|
343
|
+
* AudioWorklet that drains them to the speakers. This is the inverse of the
|
|
344
|
+
* browser Microphone: the Microphone captures input and emits chunks; the
|
|
345
|
+
* Speaker accepts chunks and plays them.
|
|
346
|
+
*
|
|
347
|
+
* Playback is async throughout, as the browser requires: write() resolves when
|
|
348
|
+
* the samples are queued (after backpressure when the queue is full) and
|
|
349
|
+
* drain() resolves when the queued audio has finished playing. The first
|
|
350
|
+
* write() (or start()) must run in a user gesture so the browser allows audio.
|
|
351
|
+
*
|
|
352
|
+
* @example
|
|
353
|
+
* const { Speaker } = require('decibri'); // browser entry via conditional export
|
|
354
|
+
* const speaker = new Speaker({ sampleRate: 16000 });
|
|
355
|
+
* button.onclick = async () => {
|
|
356
|
+
* await speaker.write(int16Chunk); // Int16Array of PCM samples
|
|
357
|
+
* await speaker.drain();
|
|
358
|
+
* speaker.stop();
|
|
359
|
+
* };
|
|
360
|
+
*/
|
|
361
|
+
var Speaker = class {
|
|
362
|
+
constructor(options = {}) {
|
|
363
|
+
this._audioContext = null;
|
|
364
|
+
this._workletNode = null;
|
|
365
|
+
this._resampler = null;
|
|
366
|
+
this._started = false;
|
|
367
|
+
this._starting = null;
|
|
368
|
+
this._stopRequested = false;
|
|
369
|
+
this._lastAck = null;
|
|
370
|
+
this._capacity = 0;
|
|
371
|
+
this._contextRate = 0;
|
|
372
|
+
this._unplayed = false;
|
|
373
|
+
this._pendingDrains = [];
|
|
374
|
+
this._sampleRate = options.sampleRate ?? 16e3;
|
|
375
|
+
this._channels = options.channels ?? 1;
|
|
376
|
+
this._dtype = options.dtype ?? "int16";
|
|
377
|
+
this._workletUrl = options.workletUrl;
|
|
378
|
+
if (this._sampleRate < 1e3 || this._sampleRate > 384e3) throw new TypeError(`sample rate must be between 1000 and 384000, got ${this._sampleRate}`);
|
|
379
|
+
if (this._channels < 1 || this._channels > 32) throw new TypeError(`channels must be between 1 and 32, got ${this._channels}`);
|
|
380
|
+
if (this._dtype !== "int16" && this._dtype !== "float32") throw new TypeError("dtype must be 'int16' or 'float32'");
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Create and resume the AudioContext and load the output worklet.
|
|
384
|
+
*
|
|
385
|
+
* Must be called from a user gesture context so the browser allows audio.
|
|
386
|
+
* Optional: write() starts playback on its own if start() was not called, but
|
|
387
|
+
* calling start() from a click handler is the reliable way to unlock audio
|
|
388
|
+
* before samples are ready. No-op if already started; returns the in-flight
|
|
389
|
+
* promise if a start is already in progress.
|
|
390
|
+
*
|
|
391
|
+
* @returns {Promise<void>}
|
|
392
|
+
*/
|
|
393
|
+
start() {
|
|
394
|
+
if (this._started) return Promise.resolve();
|
|
395
|
+
if (this._starting) return this._starting;
|
|
396
|
+
this._starting = this._doStart().finally(() => {
|
|
397
|
+
this._starting = null;
|
|
398
|
+
});
|
|
399
|
+
return this._starting;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Play PCM audio. Resolves when the samples are queued.
|
|
403
|
+
*
|
|
404
|
+
* Converts the chunk to context-rate float32 (int16 to float32 if needed,
|
|
405
|
+
* then resample), and feeds it to the output worklet. When the queue is full
|
|
406
|
+
* it applies backpressure: the returned promise does not resolve until there
|
|
407
|
+
* is room, so a caller that awaits write() is paced to playback. Starts
|
|
408
|
+
* playback on the first call (gesture-sensitive).
|
|
409
|
+
*
|
|
410
|
+
* Await calls sequentially to preserve sample order. An empty chunk resolves
|
|
411
|
+
* immediately. Writing after stop() starts a fresh playback session.
|
|
412
|
+
*
|
|
413
|
+
* @param {Int16Array|Float32Array|ArrayBuffer} chunk PCM samples in the
|
|
414
|
+
* configured dtype.
|
|
415
|
+
* @returns {Promise<void>}
|
|
416
|
+
*/
|
|
417
|
+
async write(chunk) {
|
|
418
|
+
await this.start();
|
|
419
|
+
if (!this._started) throw new Error("Speaker is stopped");
|
|
420
|
+
const float32 = this._convert(chunk);
|
|
421
|
+
if (float32.length === 0) return;
|
|
422
|
+
let offset = 0;
|
|
423
|
+
while (offset < float32.length) {
|
|
424
|
+
const sliceLen = Math.min(float32.length - offset, this._capacity);
|
|
425
|
+
await this._reserve(sliceLen);
|
|
426
|
+
if (!this._started) throw new Error("Speaker is stopped");
|
|
427
|
+
const slice = float32.slice(offset, offset + sliceLen);
|
|
428
|
+
this._workletNode.port.postMessage(slice.buffer, [slice.buffer]);
|
|
429
|
+
this._lastAck = {
|
|
430
|
+
queued: this._estimateQueue() + sliceLen,
|
|
431
|
+
time: this._now()
|
|
432
|
+
};
|
|
433
|
+
this._unplayed = true;
|
|
434
|
+
offset += sliceLen;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Wait for all queued audio to finish playing.
|
|
439
|
+
*
|
|
440
|
+
* Resolves when the worklet reports its queue drained. Resolves immediately
|
|
441
|
+
* if nothing is queued (nothing written, or playback already caught up).
|
|
442
|
+
*
|
|
443
|
+
* @returns {Promise<void>}
|
|
444
|
+
*/
|
|
445
|
+
drain() {
|
|
446
|
+
if (!this._started || !this._unplayed) return Promise.resolve();
|
|
447
|
+
return new Promise((resolve) => {
|
|
448
|
+
this._pendingDrains.push(resolve);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Immediate stop. Discards queued audio and releases all resources.
|
|
453
|
+
* Safe to call multiple times or before start(). After stop(), write() or
|
|
454
|
+
* start() begins a fresh session.
|
|
455
|
+
*/
|
|
456
|
+
stop() {
|
|
457
|
+
if (!this._started) {
|
|
458
|
+
if (this._starting) this._stopRequested = true;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
this._started = false;
|
|
462
|
+
if (this._workletNode) {
|
|
463
|
+
try {
|
|
464
|
+
this._workletNode.port.postMessage({ type: "flush" });
|
|
465
|
+
} catch {}
|
|
466
|
+
this._workletNode.disconnect();
|
|
467
|
+
this._workletNode.port.onmessage = null;
|
|
468
|
+
this._workletNode.port.close();
|
|
469
|
+
}
|
|
470
|
+
if (this._audioContext) this._audioContext.close();
|
|
471
|
+
const drains = this._pendingDrains;
|
|
472
|
+
this._pendingDrains = [];
|
|
473
|
+
for (const resolve of drains) resolve();
|
|
474
|
+
this._audioContext = null;
|
|
475
|
+
this._workletNode = null;
|
|
476
|
+
this._resampler = null;
|
|
477
|
+
this._lastAck = null;
|
|
478
|
+
this._unplayed = false;
|
|
479
|
+
}
|
|
480
|
+
/** Whether audio is currently queued and playing. */
|
|
481
|
+
get isPlaying() {
|
|
482
|
+
return this._started && this._unplayed;
|
|
483
|
+
}
|
|
484
|
+
async _doStart() {
|
|
485
|
+
this._audioContext = new AudioContext();
|
|
486
|
+
this._contextRate = this._audioContext.sampleRate;
|
|
487
|
+
await this._audioContext.resume();
|
|
488
|
+
if (this._audioContext.state === "suspended") {
|
|
489
|
+
await this._audioContext.close();
|
|
490
|
+
this._audioContext = null;
|
|
491
|
+
throw new Error("Audio playback is blocked until a user gesture resumes the audio context");
|
|
492
|
+
}
|
|
493
|
+
let blobUrl = null;
|
|
494
|
+
const workletUrl = this._workletUrl ?? (blobUrl = this._createBlobUrl());
|
|
495
|
+
try {
|
|
496
|
+
await this._audioContext.audioWorklet.addModule(workletUrl);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
499
|
+
await this._audioContext.close();
|
|
500
|
+
this._audioContext = null;
|
|
501
|
+
throw new Error("Failed to load audio worklet: " + (err instanceof Error ? err.message : String(err)));
|
|
502
|
+
}
|
|
503
|
+
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
504
|
+
this._capacity = Math.round(this._contextRate * BUFFER_SECONDS);
|
|
505
|
+
this._resampler = new Resampler(this._sampleRate, this._contextRate);
|
|
506
|
+
this._lastAck = null;
|
|
507
|
+
this._unplayed = false;
|
|
508
|
+
this._workletNode = new AudioWorkletNode(this._audioContext, "decibri-output-processor", {
|
|
509
|
+
numberOfInputs: 0,
|
|
510
|
+
numberOfOutputs: 1,
|
|
511
|
+
outputChannelCount: [this._channels],
|
|
512
|
+
processorOptions: { ringCapacity: this._capacity }
|
|
513
|
+
});
|
|
514
|
+
this._workletNode.port.onmessage = (event) => this._onMessage(event);
|
|
515
|
+
this._workletNode.connect(this._audioContext.destination);
|
|
516
|
+
this._started = true;
|
|
517
|
+
if (this._stopRequested) {
|
|
518
|
+
this._stopRequested = false;
|
|
519
|
+
this.stop();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
_onMessage(event) {
|
|
523
|
+
const msg = event.data;
|
|
524
|
+
if (!msg) return;
|
|
525
|
+
if (msg.type === "level") {
|
|
526
|
+
this._lastAck = {
|
|
527
|
+
queued: msg.queued,
|
|
528
|
+
time: this._now()
|
|
529
|
+
};
|
|
530
|
+
this._capacity = msg.capacity;
|
|
531
|
+
} else if (msg.type === "drained") {
|
|
532
|
+
this._unplayed = false;
|
|
533
|
+
const drains = this._pendingDrains;
|
|
534
|
+
this._pendingDrains = [];
|
|
535
|
+
for (const resolve of drains) resolve();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
_createBlobUrl() {
|
|
539
|
+
const blob = new Blob([OUTPUT_WORKLET_SOURCE], { type: "application/javascript" });
|
|
540
|
+
return URL.createObjectURL(blob);
|
|
541
|
+
}
|
|
542
|
+
_now() {
|
|
543
|
+
return this._audioContext ? this._audioContext.currentTime : 0;
|
|
544
|
+
}
|
|
545
|
+
_estimateQueue() {
|
|
546
|
+
if (!this._lastAck) return 0;
|
|
547
|
+
const elapsed = this._now() - this._lastAck.time;
|
|
548
|
+
const played = Math.max(0, elapsed) * this._contextRate;
|
|
549
|
+
return Math.max(0, this._lastAck.queued - played);
|
|
550
|
+
}
|
|
551
|
+
async _reserve(samples) {
|
|
552
|
+
while (this._started) {
|
|
553
|
+
const overflow = this._estimateQueue() + samples - this._capacity;
|
|
554
|
+
if (overflow <= 0) return;
|
|
555
|
+
const waitMs = Math.max(1, overflow / this._contextRate * 1e3);
|
|
556
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
_convert(chunk) {
|
|
560
|
+
let float32;
|
|
561
|
+
if (this._dtype === "int16") {
|
|
562
|
+
const int16 = this._asInt16(chunk);
|
|
563
|
+
float32 = new Float32Array(int16.length);
|
|
564
|
+
for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768;
|
|
565
|
+
} else float32 = this._asFloat32(chunk);
|
|
566
|
+
return this._resampler.process(float32);
|
|
567
|
+
}
|
|
568
|
+
_asInt16(chunk) {
|
|
569
|
+
if (chunk instanceof Int16Array) return chunk;
|
|
570
|
+
if (chunk instanceof ArrayBuffer) return new Int16Array(chunk);
|
|
571
|
+
if (ArrayBuffer.isView(chunk)) return new Int16Array(chunk.buffer, chunk.byteOffset, Math.floor(chunk.byteLength / 2));
|
|
572
|
+
throw new TypeError("write() expects an Int16Array, ArrayBuffer, or typed array for int16 dtype");
|
|
573
|
+
}
|
|
574
|
+
_asFloat32(chunk) {
|
|
575
|
+
if (chunk instanceof Float32Array) return chunk;
|
|
576
|
+
if (chunk instanceof ArrayBuffer) return new Float32Array(chunk);
|
|
577
|
+
if (ArrayBuffer.isView(chunk)) return new Float32Array(chunk.buffer, chunk.byteOffset, Math.floor(chunk.byteLength / 4));
|
|
578
|
+
throw new TypeError("write() expects a Float32Array, ArrayBuffer, or typed array for float32 dtype");
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
module.exports = { Speaker };
|
|
582
|
+
}));
|
|
583
|
+
//#endregion
|
|
584
|
+
return (/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
585
|
+
const { Microphone } = require_decibri_browser();
|
|
586
|
+
const { Speaker } = require_decibri_output_browser();
|
|
587
|
+
const { Emitter } = require_emitter();
|
|
588
|
+
module.exports = {
|
|
589
|
+
Microphone,
|
|
590
|
+
Speaker,
|
|
591
|
+
Emitter
|
|
592
|
+
};
|
|
593
|
+
})))();
|
|
594
|
+
})();
|
package/index.d.ts
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
/** Native bridge class exposed to Node.js via napi-rs. */
|
|
4
4
|
export declare class DecibriBridge {
|
|
5
5
|
constructor(options?: DecibriOptions | undefined | null)
|
|
6
|
+
/**
|
|
7
|
+
* Construct a microphone bridge without blocking the JS event loop. The
|
|
8
|
+
* device resolution and Silero model load run on the libuv thread pool;
|
|
9
|
+
* the returned Promise resolves to a fully constructed bridge, or rejects
|
|
10
|
+
* with the matching error. The synchronous `new` remains available and
|
|
11
|
+
* unchanged.
|
|
12
|
+
*/
|
|
13
|
+
static openAsync(options?: DecibriOptions | undefined | null): Promise<unknown>
|
|
6
14
|
/** Start capturing audio. The callback receives `(err, chunk)` for each buffer. */
|
|
7
15
|
start(callback: (err: Error | null, chunk: Buffer) => void): void
|
|
8
16
|
/** Stop capturing audio. */
|
|
@@ -23,13 +31,36 @@ export declare class DecibriBridge {
|
|
|
23
31
|
/** Native bridge class for audio output, exposed to Node.js via napi-rs. */
|
|
24
32
|
export declare class DecibriOutputBridge {
|
|
25
33
|
constructor(options?: DecibriOutputOptions | undefined | null)
|
|
34
|
+
/**
|
|
35
|
+
* Construct a speaker bridge without blocking the JS event loop. The device
|
|
36
|
+
* resolution runs on the libuv thread pool; the returned Promise resolves
|
|
37
|
+
* to a constructed bridge, or rejects with the matching error. The
|
|
38
|
+
* synchronous `new` remains available and unchanged.
|
|
39
|
+
*/
|
|
40
|
+
static openAsync(options?: DecibriOutputOptions | undefined | null): Promise<unknown>
|
|
26
41
|
/**
|
|
27
42
|
* Write PCM data for playback. Starts the output stream on first call.
|
|
28
43
|
* Empty buffers are a no-op.
|
|
29
44
|
*/
|
|
30
45
|
write(buffer: Buffer): void
|
|
46
|
+
/**
|
|
47
|
+
* Non-blocking write: convert the samples and start the stream on the JS
|
|
48
|
+
* thread (a fast device open, same as the synchronous first write), then
|
|
49
|
+
* perform the blocking channel `send` (which stalls under backpressure when
|
|
50
|
+
* the queue is full) on the libuv thread pool. The returned Promise
|
|
51
|
+
* resolves when the samples are queued, or rejects with the matching error.
|
|
52
|
+
* Empty buffers resolve immediately. The synchronous `write` is unchanged.
|
|
53
|
+
*/
|
|
54
|
+
writeAsync(buffer: Buffer): Promise<unknown>
|
|
31
55
|
/** Graceful drain: blocks until all queued samples have been played. */
|
|
32
56
|
drain(): void
|
|
57
|
+
/**
|
|
58
|
+
* Non-blocking drain: the poll loop that waits for the cpal callback to play
|
|
59
|
+
* everything queued runs on the libuv thread pool instead of the event loop.
|
|
60
|
+
* The returned Promise resolves when the buffer has drained. With no stream
|
|
61
|
+
* yet created it resolves immediately. The synchronous `drain` is unchanged.
|
|
62
|
+
*/
|
|
63
|
+
drainAsync(): Promise<unknown>
|
|
33
64
|
/** Immediate stop. Discards remaining samples. */
|
|
34
65
|
stop(): void
|
|
35
66
|
/** Whether audio is currently being output. */
|