@audio/speaker 2.3.1
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/binding.gyp +38 -0
- package/browser.d.ts +16 -0
- package/browser.js +84 -0
- package/index.d.ts +22 -0
- package/index.js +82 -0
- package/native/miniaudio.h +95864 -0
- package/native/speaker.c +468 -0
- package/package.json +81 -0
- package/readme.md +123 -0
- package/src/backend.js +29 -0
- package/src/backends/miniaudio.js +65 -0
- package/src/backends/null.js +19 -0
- package/src/backends/process.js +56 -0
- package/stream.d.ts +4 -0
- package/stream.js +19 -0
package/native/speaker.c
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* audio-speaker native addon
|
|
3
|
+
* Minimal miniaudio N-API binding: ring buffer bridging push writes ↔ pull callback
|
|
4
|
+
*
|
|
5
|
+
* Architecture:
|
|
6
|
+
* - writeSync: non-blocking memcpy into ring buffer (for small/real-time writes)
|
|
7
|
+
* - writeAsync: blocks on worker thread until all data written (for large buffers)
|
|
8
|
+
* - JS tries sync first, falls back to async when ring buffer is full
|
|
9
|
+
* - Optional capture ring buffer for output verification
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
#define MA_NO_DECODING
|
|
13
|
+
#define MA_NO_ENCODING
|
|
14
|
+
#define MA_NO_RESOURCE_MANAGER
|
|
15
|
+
#define MA_NO_NODE_GRAPH
|
|
16
|
+
#define MA_NO_ENGINE
|
|
17
|
+
#define MA_NO_GENERATION
|
|
18
|
+
#define MINIAUDIO_IMPLEMENTATION
|
|
19
|
+
#include "miniaudio.h"
|
|
20
|
+
|
|
21
|
+
#include <node_api.h>
|
|
22
|
+
#include <string.h>
|
|
23
|
+
|
|
24
|
+
#define NAPI_CALL(env, call) \
|
|
25
|
+
do { \
|
|
26
|
+
napi_status status = (call); \
|
|
27
|
+
if (status != napi_ok) { \
|
|
28
|
+
const napi_extended_error_info* error_info = NULL; \
|
|
29
|
+
napi_get_last_error_info((env), &error_info); \
|
|
30
|
+
const char* msg = (error_info && error_info->error_message) \
|
|
31
|
+
? error_info->error_message : "Unknown N-API error"; \
|
|
32
|
+
napi_throw_error((env), NULL, msg); \
|
|
33
|
+
return NULL; \
|
|
34
|
+
} \
|
|
35
|
+
} while (0)
|
|
36
|
+
|
|
37
|
+
/* Speaker instance */
|
|
38
|
+
typedef struct {
|
|
39
|
+
ma_device device;
|
|
40
|
+
ma_pcm_rb ring_buffer;
|
|
41
|
+
ma_pcm_rb capture_rb;
|
|
42
|
+
ma_uint32 channels;
|
|
43
|
+
ma_uint32 sample_rate;
|
|
44
|
+
ma_format format;
|
|
45
|
+
ma_uint32 start_threshold;
|
|
46
|
+
ma_uint32 pull_threshold; /* fire callback when available_read drops below this */
|
|
47
|
+
int started;
|
|
48
|
+
volatile int closed;
|
|
49
|
+
int capture;
|
|
50
|
+
} speaker_t;
|
|
51
|
+
|
|
52
|
+
/* Async write work */
|
|
53
|
+
typedef struct {
|
|
54
|
+
speaker_t* sp;
|
|
55
|
+
void* data;
|
|
56
|
+
size_t byte_length;
|
|
57
|
+
ma_uint32 frames_written;
|
|
58
|
+
napi_async_work work;
|
|
59
|
+
napi_ref callback_ref;
|
|
60
|
+
napi_ref buffer_ref;
|
|
61
|
+
} write_work_t;
|
|
62
|
+
|
|
63
|
+
/* Try to start device if threshold reached */
|
|
64
|
+
static void maybe_start(speaker_t* sp) {
|
|
65
|
+
if (!sp->started && ma_pcm_rb_available_read(&sp->ring_buffer) >= sp->start_threshold) {
|
|
66
|
+
if (ma_device_start(&sp->device) == MA_SUCCESS) {
|
|
67
|
+
sp->started = 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Playback callback — pulls from ring buffer */
|
|
73
|
+
static void playback_callback(ma_device* device, void* output, const void* input, ma_uint32 frame_count) {
|
|
74
|
+
speaker_t* sp = (speaker_t*)device->pUserData;
|
|
75
|
+
ma_uint32 bpf = ma_get_bytes_per_frame(sp->format, sp->channels);
|
|
76
|
+
|
|
77
|
+
ma_uint32 total_read = 0;
|
|
78
|
+
while (total_read < frame_count) {
|
|
79
|
+
ma_uint32 to_read = frame_count - total_read;
|
|
80
|
+
void* read_buf;
|
|
81
|
+
if (ma_pcm_rb_acquire_read(&sp->ring_buffer, &to_read, &read_buf) != MA_SUCCESS || to_read == 0) break;
|
|
82
|
+
memcpy((ma_uint8*)output + total_read * bpf, read_buf, to_read * bpf);
|
|
83
|
+
ma_pcm_rb_commit_read(&sp->ring_buffer, to_read);
|
|
84
|
+
total_read += to_read;
|
|
85
|
+
}
|
|
86
|
+
if (total_read < frame_count) {
|
|
87
|
+
memset((ma_uint8*)output + total_read * bpf, 0, (frame_count - total_read) * bpf);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (sp->capture) {
|
|
91
|
+
ma_uint32 total_cap = 0;
|
|
92
|
+
while (total_cap < frame_count) {
|
|
93
|
+
ma_uint32 to_cap = frame_count - total_cap;
|
|
94
|
+
void* cap_buf;
|
|
95
|
+
if (ma_pcm_rb_acquire_write(&sp->capture_rb, &to_cap, &cap_buf) != MA_SUCCESS || to_cap == 0) break;
|
|
96
|
+
memcpy(cap_buf, (ma_uint8*)output + total_cap * bpf, to_cap * bpf);
|
|
97
|
+
ma_pcm_rb_commit_write(&sp->capture_rb, to_cap);
|
|
98
|
+
total_cap += to_cap;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
(void)input;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* GC destructor */
|
|
106
|
+
static void speaker_destructor(napi_env env, void* data, void* hint) {
|
|
107
|
+
speaker_t* sp = (speaker_t*)data;
|
|
108
|
+
if (!sp) return;
|
|
109
|
+
if (!sp->closed) {
|
|
110
|
+
/* explicit close() was never called — do full device teardown */
|
|
111
|
+
sp->closed = 1;
|
|
112
|
+
if (sp->started) {
|
|
113
|
+
ma_device_stop(&sp->device);
|
|
114
|
+
sp->started = 0;
|
|
115
|
+
}
|
|
116
|
+
ma_device_uninit(&sp->device);
|
|
117
|
+
}
|
|
118
|
+
/* Ring buffer is always freed here, never in speaker_close().
|
|
119
|
+
* This avoids a race: worker thread may still be in its pull-wait loop
|
|
120
|
+
* when close() is called. setting closed=1 exits that loop, the worker
|
|
121
|
+
* finishes, JS fires the callback, then GC collects and we arrive here. */
|
|
122
|
+
ma_pcm_rb_uninit(&sp->ring_buffer);
|
|
123
|
+
if (sp->capture) ma_pcm_rb_uninit(&sp->capture_rb);
|
|
124
|
+
free(sp);
|
|
125
|
+
(void)env;
|
|
126
|
+
(void)hint;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* speaker_open(sampleRate, channels, bitDepth, bufferMs, capture) → external */
|
|
130
|
+
static napi_value speaker_open(napi_env env, napi_callback_info info) {
|
|
131
|
+
size_t argc = 5;
|
|
132
|
+
napi_value argv[5];
|
|
133
|
+
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL));
|
|
134
|
+
|
|
135
|
+
if (argc < 3) {
|
|
136
|
+
napi_throw_error(env, NULL, "speaker_open requires (sampleRate, channels, bitDepth[, bufferMs, capture])");
|
|
137
|
+
return NULL;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
ma_uint32 sample_rate, channels, bit_depth, buffer_ms;
|
|
141
|
+
NAPI_CALL(env, napi_get_value_uint32(env, argv[0], &sample_rate));
|
|
142
|
+
NAPI_CALL(env, napi_get_value_uint32(env, argv[1], &channels));
|
|
143
|
+
NAPI_CALL(env, napi_get_value_uint32(env, argv[2], &bit_depth));
|
|
144
|
+
|
|
145
|
+
buffer_ms = 50;
|
|
146
|
+
if (argc > 3) NAPI_CALL(env, napi_get_value_uint32(env, argv[3], &buffer_ms));
|
|
147
|
+
if (buffer_ms < 10) buffer_ms = 10;
|
|
148
|
+
if (buffer_ms > 2000) buffer_ms = 2000;
|
|
149
|
+
|
|
150
|
+
int capture = 0;
|
|
151
|
+
if (argc > 4) {
|
|
152
|
+
bool val;
|
|
153
|
+
NAPI_CALL(env, napi_get_value_bool(env, argv[4], &val));
|
|
154
|
+
capture = val ? 1 : 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
ma_format format;
|
|
158
|
+
switch (bit_depth) {
|
|
159
|
+
case 8: format = ma_format_u8; break;
|
|
160
|
+
case 16: format = ma_format_s16; break;
|
|
161
|
+
case 24: format = ma_format_s24; break;
|
|
162
|
+
case 32: format = ma_format_f32; break;
|
|
163
|
+
default:
|
|
164
|
+
napi_throw_error(env, NULL, "Unsupported bitDepth (use 8, 16, 24, 32)");
|
|
165
|
+
return NULL;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
speaker_t* sp = (speaker_t*)calloc(1, sizeof(speaker_t));
|
|
169
|
+
if (!sp) {
|
|
170
|
+
napi_throw_error(env, NULL, "Failed to allocate speaker");
|
|
171
|
+
return NULL;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
sp->channels = channels;
|
|
175
|
+
sp->sample_rate = sample_rate;
|
|
176
|
+
sp->format = format;
|
|
177
|
+
sp->capture = capture;
|
|
178
|
+
|
|
179
|
+
/* ring buffer */
|
|
180
|
+
ma_uint32 rb_frames = (sample_rate * buffer_ms) / 1000;
|
|
181
|
+
ma_uint32 rb_pow2 = 1;
|
|
182
|
+
while (rb_pow2 < rb_frames) rb_pow2 <<= 1;
|
|
183
|
+
sp->start_threshold = rb_pow2 / 2;
|
|
184
|
+
sp->pull_threshold = rb_pow2 / 2; /* callback fires when buffer drops below 50% — gives ~25ms headroom, absorbs Windows ~15ms sleep granularity */
|
|
185
|
+
|
|
186
|
+
ma_result result = ma_pcm_rb_init(format, channels, rb_pow2, NULL, NULL, &sp->ring_buffer);
|
|
187
|
+
if (result != MA_SUCCESS) {
|
|
188
|
+
free(sp);
|
|
189
|
+
napi_throw_error(env, NULL, "Failed to init ring buffer");
|
|
190
|
+
return NULL;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* capture ring buffer */
|
|
194
|
+
if (capture) {
|
|
195
|
+
ma_uint32 cap_frames = sample_rate * 5;
|
|
196
|
+
ma_uint32 cap_pow2 = 1;
|
|
197
|
+
while (cap_pow2 < cap_frames) cap_pow2 <<= 1;
|
|
198
|
+
result = ma_pcm_rb_init(format, channels, cap_pow2, NULL, NULL, &sp->capture_rb);
|
|
199
|
+
if (result != MA_SUCCESS) {
|
|
200
|
+
ma_pcm_rb_uninit(&sp->ring_buffer);
|
|
201
|
+
free(sp);
|
|
202
|
+
napi_throw_error(env, NULL, "Failed to init capture ring buffer");
|
|
203
|
+
return NULL;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* audio device (started later) */
|
|
208
|
+
ma_device_config config = ma_device_config_init(ma_device_type_playback);
|
|
209
|
+
config.playback.format = format;
|
|
210
|
+
config.playback.channels = channels;
|
|
211
|
+
config.sampleRate = sample_rate;
|
|
212
|
+
config.dataCallback = playback_callback;
|
|
213
|
+
config.pUserData = sp;
|
|
214
|
+
config.performanceProfile = ma_performance_profile_low_latency;
|
|
215
|
+
|
|
216
|
+
result = ma_device_init(NULL, &config, &sp->device);
|
|
217
|
+
if (result != MA_SUCCESS) {
|
|
218
|
+
ma_pcm_rb_uninit(&sp->ring_buffer);
|
|
219
|
+
if (capture) ma_pcm_rb_uninit(&sp->capture_rb);
|
|
220
|
+
free(sp);
|
|
221
|
+
napi_throw_error(env, NULL, "Failed to init audio device");
|
|
222
|
+
return NULL;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
sp->started = 0;
|
|
226
|
+
|
|
227
|
+
napi_value external;
|
|
228
|
+
NAPI_CALL(env, napi_create_external(env, sp, speaker_destructor, NULL, &external));
|
|
229
|
+
return external;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/*
|
|
233
|
+
* speaker_writeSync(handle, buffer) → framesWritten
|
|
234
|
+
* Non-blocking: writes as many frames as ring buffer can accept right now.
|
|
235
|
+
* Returns immediately. Zero overhead per call.
|
|
236
|
+
*/
|
|
237
|
+
static napi_value speaker_write_sync(napi_env env, napi_callback_info info) {
|
|
238
|
+
size_t argc = 2;
|
|
239
|
+
napi_value argv[2];
|
|
240
|
+
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL));
|
|
241
|
+
|
|
242
|
+
speaker_t* sp;
|
|
243
|
+
NAPI_CALL(env, napi_get_value_external(env, argv[0], (void**)&sp));
|
|
244
|
+
|
|
245
|
+
if (sp->closed) {
|
|
246
|
+
napi_value result;
|
|
247
|
+
NAPI_CALL(env, napi_create_int32(env, 0, &result));
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
void* data;
|
|
252
|
+
size_t byte_length;
|
|
253
|
+
NAPI_CALL(env, napi_get_buffer_info(env, argv[1], &data, &byte_length));
|
|
254
|
+
|
|
255
|
+
ma_uint32 bpf = ma_get_bytes_per_frame(sp->format, sp->channels);
|
|
256
|
+
if (bpf == 0) {
|
|
257
|
+
napi_value result;
|
|
258
|
+
NAPI_CALL(env, napi_create_int32(env, 0, &result));
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
ma_uint32 total = (ma_uint32)(byte_length / bpf);
|
|
263
|
+
ma_uint32 written = 0;
|
|
264
|
+
|
|
265
|
+
while (written < total) {
|
|
266
|
+
ma_uint32 to_write = total - written;
|
|
267
|
+
void* write_buf;
|
|
268
|
+
ma_result res = ma_pcm_rb_acquire_write(&sp->ring_buffer, &to_write, &write_buf);
|
|
269
|
+
if (res != MA_SUCCESS || to_write == 0) break;
|
|
270
|
+
memcpy(write_buf, (ma_uint8*)data + written * bpf, to_write * bpf);
|
|
271
|
+
ma_pcm_rb_commit_write(&sp->ring_buffer, to_write);
|
|
272
|
+
written += to_write;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
maybe_start(sp);
|
|
276
|
+
|
|
277
|
+
napi_value result;
|
|
278
|
+
NAPI_CALL(env, napi_create_uint32(env, written, &result));
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* Async write — blocks on worker thread until all data written */
|
|
283
|
+
static void write_execute(napi_env env, void* data) {
|
|
284
|
+
write_work_t* w = (write_work_t*)data;
|
|
285
|
+
speaker_t* sp = w->sp;
|
|
286
|
+
ma_uint32 bpf = ma_get_bytes_per_frame(sp->format, sp->channels);
|
|
287
|
+
if (bpf == 0) { w->frames_written = 0; return; }
|
|
288
|
+
|
|
289
|
+
ma_uint32 total = (ma_uint32)(w->byte_length / bpf);
|
|
290
|
+
ma_uint32 written = 0;
|
|
291
|
+
|
|
292
|
+
while (written < total && !sp->closed) {
|
|
293
|
+
ma_uint32 to_write = total - written;
|
|
294
|
+
void* write_buf;
|
|
295
|
+
ma_result res = ma_pcm_rb_acquire_write(&sp->ring_buffer, &to_write, &write_buf);
|
|
296
|
+
if (res == MA_SUCCESS && to_write > 0) {
|
|
297
|
+
memcpy(write_buf, (ma_uint8*)w->data + written * bpf, to_write * bpf);
|
|
298
|
+
ma_pcm_rb_commit_write(&sp->ring_buffer, to_write);
|
|
299
|
+
written += to_write;
|
|
300
|
+
maybe_start(sp);
|
|
301
|
+
} else {
|
|
302
|
+
ma_sleep(1);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!sp->started && written > 0 && !sp->closed) {
|
|
307
|
+
if (ma_device_start(&sp->device) == MA_SUCCESS) {
|
|
308
|
+
sp->started = 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* Pull pacing: wait until hardware consumes enough data before firing callback.
|
|
313
|
+
* This makes the callback fire when the device NEEDS more data, not when the
|
|
314
|
+
* ring buffer has space. Eliminates render-loop overrun without wall-clock hacks. */
|
|
315
|
+
if (sp->started && !sp->closed) {
|
|
316
|
+
while (!sp->closed && ma_pcm_rb_available_read(&sp->ring_buffer) >= sp->pull_threshold) {
|
|
317
|
+
ma_sleep(1);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
w->frames_written = written;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
static void write_complete(napi_env env, napi_status status, void* data) {
|
|
325
|
+
write_work_t* w = (write_work_t*)data;
|
|
326
|
+
|
|
327
|
+
napi_value callback, global, argv[2];
|
|
328
|
+
napi_get_reference_value(env, w->callback_ref, &callback);
|
|
329
|
+
napi_get_global(env, &global);
|
|
330
|
+
|
|
331
|
+
napi_get_null(env, &argv[0]);
|
|
332
|
+
napi_create_uint32(env, w->frames_written, &argv[1]);
|
|
333
|
+
napi_call_function(env, global, callback, 2, argv, NULL);
|
|
334
|
+
|
|
335
|
+
napi_delete_reference(env, w->callback_ref);
|
|
336
|
+
napi_delete_reference(env, w->buffer_ref);
|
|
337
|
+
napi_delete_async_work(env, w->work);
|
|
338
|
+
free(w);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* speaker_writeAsync(handle, buffer, callback) */
|
|
342
|
+
static napi_value speaker_write_async(napi_env env, napi_callback_info info) {
|
|
343
|
+
size_t argc = 3;
|
|
344
|
+
napi_value argv[3];
|
|
345
|
+
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL));
|
|
346
|
+
|
|
347
|
+
speaker_t* sp;
|
|
348
|
+
NAPI_CALL(env, napi_get_value_external(env, argv[0], (void**)&sp));
|
|
349
|
+
|
|
350
|
+
if (sp->closed) {
|
|
351
|
+
napi_throw_error(env, NULL, "Speaker is closed");
|
|
352
|
+
return NULL;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
void* data;
|
|
356
|
+
size_t byte_length;
|
|
357
|
+
NAPI_CALL(env, napi_get_buffer_info(env, argv[1], &data, &byte_length));
|
|
358
|
+
|
|
359
|
+
write_work_t* w = (write_work_t*)calloc(1, sizeof(write_work_t));
|
|
360
|
+
w->sp = sp;
|
|
361
|
+
w->data = data;
|
|
362
|
+
w->byte_length = byte_length;
|
|
363
|
+
|
|
364
|
+
NAPI_CALL(env, napi_create_reference(env, argv[2], 1, &w->callback_ref));
|
|
365
|
+
NAPI_CALL(env, napi_create_reference(env, argv[1], 1, &w->buffer_ref));
|
|
366
|
+
|
|
367
|
+
napi_value work_name;
|
|
368
|
+
NAPI_CALL(env, napi_create_string_utf8(env, "speaker_write", NAPI_AUTO_LENGTH, &work_name));
|
|
369
|
+
NAPI_CALL(env, napi_create_async_work(env, NULL, work_name, write_execute, write_complete, w, &w->work));
|
|
370
|
+
NAPI_CALL(env, napi_queue_async_work(env, w->work));
|
|
371
|
+
|
|
372
|
+
return NULL;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* speaker_read(handle, buffer) → frames */
|
|
376
|
+
static napi_value speaker_read(napi_env env, napi_callback_info info) {
|
|
377
|
+
size_t argc = 2;
|
|
378
|
+
napi_value argv[2];
|
|
379
|
+
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL));
|
|
380
|
+
|
|
381
|
+
speaker_t* sp;
|
|
382
|
+
NAPI_CALL(env, napi_get_value_external(env, argv[0], (void**)&sp));
|
|
383
|
+
|
|
384
|
+
if (!sp->capture) {
|
|
385
|
+
napi_throw_error(env, NULL, "Capture not enabled");
|
|
386
|
+
return NULL;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
void* data;
|
|
390
|
+
size_t byte_length;
|
|
391
|
+
NAPI_CALL(env, napi_get_buffer_info(env, argv[1], &data, &byte_length));
|
|
392
|
+
|
|
393
|
+
ma_uint32 bpf = ma_get_bytes_per_frame(sp->format, sp->channels);
|
|
394
|
+
ma_uint32 max_frames = (ma_uint32)(byte_length / bpf);
|
|
395
|
+
ma_uint32 frames_read = 0;
|
|
396
|
+
|
|
397
|
+
while (frames_read < max_frames) {
|
|
398
|
+
ma_uint32 to_read = max_frames - frames_read;
|
|
399
|
+
void* read_buf;
|
|
400
|
+
if (ma_pcm_rb_acquire_read(&sp->capture_rb, &to_read, &read_buf) != MA_SUCCESS || to_read == 0) break;
|
|
401
|
+
memcpy((ma_uint8*)data + frames_read * bpf, read_buf, to_read * bpf);
|
|
402
|
+
ma_pcm_rb_commit_read(&sp->capture_rb, to_read);
|
|
403
|
+
frames_read += to_read;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
napi_value result;
|
|
407
|
+
NAPI_CALL(env, napi_create_uint32(env, frames_read, &result));
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/* speaker_flush(handle) → boolean */
|
|
412
|
+
static napi_value speaker_flush(napi_env env, napi_callback_info info) {
|
|
413
|
+
size_t argc = 1;
|
|
414
|
+
napi_value argv[1];
|
|
415
|
+
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL));
|
|
416
|
+
|
|
417
|
+
speaker_t* sp;
|
|
418
|
+
NAPI_CALL(env, napi_get_value_external(env, argv[0], (void**)&sp));
|
|
419
|
+
|
|
420
|
+
if (!sp->started && ma_pcm_rb_available_read(&sp->ring_buffer) > 0) {
|
|
421
|
+
if (ma_device_start(&sp->device) == MA_SUCCESS) {
|
|
422
|
+
sp->started = 1;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
int flushed = (ma_pcm_rb_available_read(&sp->ring_buffer) == 0);
|
|
427
|
+
napi_value result;
|
|
428
|
+
NAPI_CALL(env, napi_get_boolean(env, flushed, &result));
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* speaker_close(handle) */
|
|
433
|
+
static napi_value speaker_close(napi_env env, napi_callback_info info) {
|
|
434
|
+
size_t argc = 1;
|
|
435
|
+
napi_value argv[1];
|
|
436
|
+
NAPI_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL));
|
|
437
|
+
|
|
438
|
+
speaker_t* sp;
|
|
439
|
+
NAPI_CALL(env, napi_get_value_external(env, argv[0], (void**)&sp));
|
|
440
|
+
|
|
441
|
+
if (!sp->closed) {
|
|
442
|
+
sp->closed = 1; /* signals worker pull-wait loop to exit */
|
|
443
|
+
if (sp->started) {
|
|
444
|
+
ma_device_stop(&sp->device);
|
|
445
|
+
sp->started = 0;
|
|
446
|
+
}
|
|
447
|
+
ma_device_uninit(&sp->device);
|
|
448
|
+
/* Ring buffer freed by GC destructor — worker may still be in pull-wait loop */
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return NULL;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/* Module init */
|
|
455
|
+
static napi_value init(napi_env env, napi_value exports) {
|
|
456
|
+
napi_property_descriptor props[] = {
|
|
457
|
+
{ "open", NULL, speaker_open, NULL, NULL, NULL, napi_default, NULL },
|
|
458
|
+
{ "writeSync", NULL, speaker_write_sync, NULL, NULL, NULL, napi_default, NULL },
|
|
459
|
+
{ "writeAsync", NULL, speaker_write_async, NULL, NULL, NULL, napi_default, NULL },
|
|
460
|
+
{ "read", NULL, speaker_read, NULL, NULL, NULL, napi_default, NULL },
|
|
461
|
+
{ "flush", NULL, speaker_flush, NULL, NULL, NULL, napi_default, NULL },
|
|
462
|
+
{ "close", NULL, speaker_close, NULL, NULL, NULL, napi_default, NULL },
|
|
463
|
+
};
|
|
464
|
+
NAPI_CALL(env, napi_define_properties(env, exports, 6, props));
|
|
465
|
+
return exports;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
NAPI_MODULE(NODE_GYP_MODULE_NAME, init)
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/speaker",
|
|
3
|
+
"version": "2.3.1",
|
|
4
|
+
"description": "Output audio data to speaker in browser/node",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "./index.js",
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./index.d.ts",
|
|
12
|
+
"browser": "./browser.js",
|
|
13
|
+
"default": "./index.js"
|
|
14
|
+
},
|
|
15
|
+
"./stream": {
|
|
16
|
+
"types": "./stream.d.ts",
|
|
17
|
+
"browser": "./browser.js",
|
|
18
|
+
"default": "./stream.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"browser": {
|
|
22
|
+
"./index.js": "./browser.js"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "node-gyp rebuild",
|
|
26
|
+
"test": "node test.js",
|
|
27
|
+
"version": "node -e \"const v=require('./package.json').version,fs=require('fs');for(const d of fs.readdirSync('packages')){const f='packages/'+d+'/package.json',p=JSON.parse(fs.readFileSync(f));p.version=v;fs.writeFileSync(f,JSON.stringify(p,null,2)+'\\n')}\" && git add packages/*/package.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"index.js",
|
|
31
|
+
"index.d.ts",
|
|
32
|
+
"stream.js",
|
|
33
|
+
"stream.d.ts",
|
|
34
|
+
"browser.js",
|
|
35
|
+
"browser.d.ts",
|
|
36
|
+
"src/",
|
|
37
|
+
"native/speaker.c",
|
|
38
|
+
"native/miniaudio.h",
|
|
39
|
+
"binding.gyp",
|
|
40
|
+
"prebuilds/"
|
|
41
|
+
],
|
|
42
|
+
"gypfile": true,
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/audiojs/speaker.git"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"audio",
|
|
49
|
+
"audiojs",
|
|
50
|
+
"pcm",
|
|
51
|
+
"speaker",
|
|
52
|
+
"web-audio",
|
|
53
|
+
"sound",
|
|
54
|
+
"sink",
|
|
55
|
+
"miniaudio"
|
|
56
|
+
],
|
|
57
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/audiojs/speaker/issues"
|
|
61
|
+
},
|
|
62
|
+
"homepage": "https://github.com/audiojs/speaker#readme",
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">=18"
|
|
65
|
+
},
|
|
66
|
+
"optionalDependencies": {
|
|
67
|
+
"@audio/speaker-darwin-arm64": ">=2.0.0",
|
|
68
|
+
"@audio/speaker-darwin-x64": ">=2.0.0",
|
|
69
|
+
"@audio/speaker-darwin-arm64": ">=2.0.0",
|
|
70
|
+
"@audio/speaker-linux-arm64": ">=2.0.0",
|
|
71
|
+
"@audio/speaker-linux-x64": ">=2.0.0",
|
|
72
|
+
"@audio/speaker-win32-x64": ">=2.0.0"
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"prebuildify": "^6.0.1",
|
|
76
|
+
"tst": "^9.3.0"
|
|
77
|
+
},
|
|
78
|
+
"publishConfig": {
|
|
79
|
+
"access": "public"
|
|
80
|
+
}
|
|
81
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @audio/speaker [](https://github.com/audiojs/speaker/actions/workflows/test.yml) [](https://npmjs.org/package/@audio/speaker)
|
|
2
|
+
|
|
3
|
+
Output audio data to speaker in node or browser.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import speaker from '@audio/speaker'
|
|
9
|
+
|
|
10
|
+
let write = speaker({
|
|
11
|
+
sampleRate: 44100,
|
|
12
|
+
channels: 2,
|
|
13
|
+
bitDepth: 16,
|
|
14
|
+
// bufferSize: 50, // ring buffer ms (default 50)
|
|
15
|
+
// backend: 'miniaudio', // force backend
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
write(pcmBuffer, (err) => {
|
|
19
|
+
// ready for next chunk
|
|
20
|
+
})
|
|
21
|
+
write(null) // end playback
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Async iterable
|
|
25
|
+
|
|
26
|
+
Consume an async iterable source directly:
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import speaker from '@audio/speaker'
|
|
30
|
+
|
|
31
|
+
await speaker.from(audioSource, { sampleRate: 44100, channels: 2 })
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Stream
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import SpeakerWritable from '@audio/speaker/stream'
|
|
38
|
+
|
|
39
|
+
source.pipe(SpeakerWritable({ sampleRate: 44100, channels: 2 }))
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Backends
|
|
43
|
+
|
|
44
|
+
Tried in order; first successful one wins.
|
|
45
|
+
|
|
46
|
+
| Backend | How | Latency | Install |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| `miniaudio` | N-API addon wrapping [miniaudio.h](https://github.com/mackron/miniaudio) | Low | Prebuilt via `@audio/speaker-*` packages |
|
|
49
|
+
| `process` | Pipes PCM to ffplay/sox/aplay | High | System tool must be installed |
|
|
50
|
+
| `null` | Silent, maintains timing contract | — | Built-in (CI/headless fallback) |
|
|
51
|
+
| `webaudio` | Web Audio API (browser only) | Low | Built-in |
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
### `write = speaker(opts?)`
|
|
56
|
+
|
|
57
|
+
Returns a sink function. Options:
|
|
58
|
+
|
|
59
|
+
- `sampleRate` — default `44100`
|
|
60
|
+
- `channels` — default `2`
|
|
61
|
+
- `bitDepth` — `8`, `16` (default), `24`, `32`
|
|
62
|
+
- `bufferSize` — ring buffer in ms, default `50`
|
|
63
|
+
- `backend` — force a specific backend
|
|
64
|
+
|
|
65
|
+
### `write(buffer, cb?)`
|
|
66
|
+
|
|
67
|
+
Write PCM data. Accepts `Buffer`, `Uint8Array`, or `AudioBuffer`. Callback fires when ready for next chunk.
|
|
68
|
+
|
|
69
|
+
### `write(null)`
|
|
70
|
+
|
|
71
|
+
End playback. Flushes remaining audio then closes device.
|
|
72
|
+
|
|
73
|
+
### `write.flush(cb?)`
|
|
74
|
+
|
|
75
|
+
Wait for buffered audio to finish playing.
|
|
76
|
+
|
|
77
|
+
### `write.close()`
|
|
78
|
+
|
|
79
|
+
Immediately close the audio device.
|
|
80
|
+
|
|
81
|
+
### `write.backend`
|
|
82
|
+
|
|
83
|
+
Name of the active backend (`'miniaudio'`, `'process'`, `'null'`, `'webaudio'`).
|
|
84
|
+
|
|
85
|
+
## Building
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
npm run build # compile native addon locally
|
|
89
|
+
npm test # run tests
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Publishing
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
# JS-only change (no native code changed):
|
|
96
|
+
npm version patch && git push && git push --tags
|
|
97
|
+
npm publish
|
|
98
|
+
|
|
99
|
+
# Native code changed — rebuild platform packages:
|
|
100
|
+
npm version patch && git push && git push --tags
|
|
101
|
+
gh run watch # wait for CI
|
|
102
|
+
rm -rf artifacts
|
|
103
|
+
gh run download --dir artifacts \
|
|
104
|
+
-n speaker-darwin-arm64 -n speaker-darwin-x64 \
|
|
105
|
+
-n speaker-linux-x64 -n speaker-linux-arm64 -n speaker-win32-x64
|
|
106
|
+
|
|
107
|
+
# (fallback) If darwin-x64 CI is unavailable, cross-compile locally
|
|
108
|
+
npx node-gyp@latest rebuild --arch=x64
|
|
109
|
+
mkdir -p artifacts/speaker-darwin-x64
|
|
110
|
+
cp build/Release/speaker.node artifacts/speaker-darwin-x64/
|
|
111
|
+
|
|
112
|
+
for pkg in packages/speaker-*/; do
|
|
113
|
+
cp artifacts/$(basename $pkg)/speaker.node $pkg/
|
|
114
|
+
(cd $pkg && npm publish)
|
|
115
|
+
done
|
|
116
|
+
npm publish
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
|
122
|
+
|
|
123
|
+
<a href="https://github.com/krishnized/license/">ॐ</a>
|
package/src/backend.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend detection — static imports, open() tries in priority order
|
|
3
|
+
*/
|
|
4
|
+
import { open as miniaudioOpen } from './backends/miniaudio.js'
|
|
5
|
+
import { open as processOpen } from './backends/process.js'
|
|
6
|
+
import { open as nullOpen } from './backends/null.js'
|
|
7
|
+
|
|
8
|
+
const backends = [
|
|
9
|
+
{ name: 'miniaudio', open: miniaudioOpen },
|
|
10
|
+
{ name: 'process', open: processOpen },
|
|
11
|
+
{ name: 'null', open: nullOpen },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
export function open(opts, preference) {
|
|
15
|
+
const order = preference
|
|
16
|
+
? backends.filter(b => b.name === preference)
|
|
17
|
+
: backends
|
|
18
|
+
let lastErr
|
|
19
|
+
|
|
20
|
+
for (const { name, open } of order) {
|
|
21
|
+
try { return { name, device: open(opts) } }
|
|
22
|
+
catch (e) { lastErr = e }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
throw new Error(
|
|
26
|
+
'No audio backend available. Install @audio/speaker-* for your platform or run npm run build.' +
|
|
27
|
+
(lastErr ? ` Last error: ${lastErr.message}` : '')
|
|
28
|
+
)
|
|
29
|
+
}
|