whispermix 1.5.2 → 1.6.2
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/README.md +33 -4
- package/SKILL.md +12 -0
- package/index.js +171 -23
- package/package.json +60 -61
package/README.md
CHANGED
|
@@ -75,6 +75,31 @@ whisperGroq.fromStream(audioStream)
|
|
|
75
75
|
|
|
76
76
|
**Note:** Stream transcription is only available for API-based models (OpenAI and Groq). Local models (Whisper and Parakeet) require file input.
|
|
77
77
|
|
|
78
|
+
### ⏱️ Word-Level Timestamps
|
|
79
|
+
|
|
80
|
+
By default, WhisperMix returns only the transcribed text. To include the start and end time for each word, pass `wordTimestamps: true` per call:
|
|
81
|
+
|
|
82
|
+
```javascript
|
|
83
|
+
const result = await whisperGroq.fromFile('path/to/audio.mp3', {
|
|
84
|
+
wordTimestamps: true
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
console.log(result.text);
|
|
88
|
+
console.log(result.words);
|
|
89
|
+
// [{ word: 'Hello', start: 0.12, end: 0.48 }, ...]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
You can also enable it on the instance:
|
|
93
|
+
|
|
94
|
+
```javascript
|
|
95
|
+
const whisper = new WhisperMix({
|
|
96
|
+
model: 'openai/whisper-1',
|
|
97
|
+
wordTimestamps: true
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const result = await whisper.fromStream(audioStream);
|
|
101
|
+
```
|
|
102
|
+
|
|
78
103
|
### 🐦 Parakeet local models
|
|
79
104
|
|
|
80
105
|
Parakeet TDT v3 local models are downloaded once and cached in:
|
|
@@ -135,21 +160,25 @@ Creates a new WhisperMix instance.
|
|
|
135
160
|
- `options.bottleneck`: (Optional) Configuration for Bottleneck rate limiting (API models only).
|
|
136
161
|
- `options.chunkSize`: (Optional) The size in seconds of the chunks to split the audio into. Default is 890 seconds.
|
|
137
162
|
- `options.language`: (Optional) Language for local Whisper model. Defaults to 'auto' for automatic detection.
|
|
138
|
-
|
|
163
|
+
- `options.wordTimestamps`: (Optional) Return `{ text, words }` instead of plain text, where each word has `start` and `end` times in seconds.
|
|
164
|
+
|
|
165
|
+
### `whisper.fromFile(filePath, options)`
|
|
139
166
|
|
|
140
167
|
Transcribes audio from a file.
|
|
141
168
|
|
|
142
169
|
- `filePath`: Path to the audio file.
|
|
170
|
+
- `options.wordTimestamps`: (Optional) Return word-level timestamps for this call.
|
|
143
171
|
|
|
144
|
-
Returns a Promise that resolves with the transcription text.
|
|
172
|
+
Returns a Promise that resolves with the transcription text, or `{ text, words }` when `wordTimestamps` is enabled.
|
|
145
173
|
|
|
146
|
-
### `whisper.fromStream(audioStream)`
|
|
174
|
+
### `whisper.fromStream(audioStream, options)`
|
|
147
175
|
|
|
148
176
|
Transcribes audio from a stream.
|
|
149
177
|
|
|
150
178
|
- `audioStream`: A readable stream of the audio data.
|
|
179
|
+
- `options.wordTimestamps`: (Optional) Return word-level timestamps for this call.
|
|
151
180
|
|
|
152
|
-
Returns a Promise that resolves with the transcription text.
|
|
181
|
+
Returns a Promise that resolves with the transcription text, or `{ text, words }` when `wordTimestamps` is enabled.
|
|
153
182
|
|
|
154
183
|
## 📄 License
|
|
155
184
|
|
package/SKILL.md
CHANGED
|
@@ -25,6 +25,7 @@ Constraints to surface before coding:
|
|
|
25
25
|
- API models need `OPENAI_API_KEY` or `GROQ_API_KEY` in the environment.
|
|
26
26
|
- First Parakeet run downloads weights to `~/.cache/whispermix/parakeet/<modelKey>/` — warn the user.
|
|
27
27
|
- Requires `ffmpeg` on `PATH` for long-audio chunking (>15 min split automatically).
|
|
28
|
+
- `wordTimestamps: true` changes the return value from plain text to `{ text, words }`, where each word has `start` and `end` times in seconds.
|
|
28
29
|
|
|
29
30
|
## API
|
|
30
31
|
|
|
@@ -49,6 +50,7 @@ Constructor options:
|
|
|
49
50
|
- `chunkSize` — seconds per chunk for long audio. Default `890` (~14m50s).
|
|
50
51
|
- `bottleneck` — Bottleneck config for API models. Defaults: `minTime: 3000`, `maxConcurrent: 1`, `reservoir: 18`, `reservoirRefreshAmount: 18`, `reservoirRefreshInterval: 60000`.
|
|
51
52
|
- `showProgress` — boolean, prints chunk/decoding progress.
|
|
53
|
+
- `wordTimestamps` — boolean, returns `{ text, words }` instead of a string. Can also be passed per call to `fromFile(filePath, { wordTimestamps: true })` or `fromStream(stream, { wordTimestamps: true })`.
|
|
52
54
|
|
|
53
55
|
## Examples
|
|
54
56
|
|
|
@@ -71,6 +73,16 @@ const w = new WhisperMix({
|
|
|
71
73
|
console.log(await w.fromFile('podcast.mp3'));
|
|
72
74
|
```
|
|
73
75
|
|
|
76
|
+
Word-level timestamps:
|
|
77
|
+
|
|
78
|
+
```javascript
|
|
79
|
+
import WhisperMix from 'whispermix';
|
|
80
|
+
const w = new WhisperMix({ model: 'openai/whisper-1' });
|
|
81
|
+
const result = await w.fromFile('meeting.mp3', { wordTimestamps: true });
|
|
82
|
+
console.log(result.text);
|
|
83
|
+
console.log(result.words); // [{ word, start, end }, ...]
|
|
84
|
+
```
|
|
85
|
+
|
|
74
86
|
Local Whisper, fixed language:
|
|
75
87
|
|
|
76
88
|
```javascript
|
package/index.js
CHANGED
|
@@ -125,7 +125,8 @@ class WhisperMix {
|
|
|
125
125
|
this.limiter = new Bottleneck(this.bottleneck);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
async fromFile(filePath) {
|
|
128
|
+
async fromFile(filePath, options = {}) {
|
|
129
|
+
const transcriptionOptions = this._resolveTranscriptionOptions(options);
|
|
129
130
|
const absolutePath = path.resolve(filePath);
|
|
130
131
|
|
|
131
132
|
// Check if file exists
|
|
@@ -142,14 +143,10 @@ class WhisperMix {
|
|
|
142
143
|
|
|
143
144
|
if (duration <= this.chunkSize) {
|
|
144
145
|
// Process as a single file
|
|
145
|
-
|
|
146
|
-
return this._transcribeLocalFile(absolutePath);
|
|
147
|
-
} else {
|
|
148
|
-
return this.fromStream(fs.createReadStream(absolutePath));
|
|
149
|
-
}
|
|
146
|
+
return this._transcribeFilePath(absolutePath, transcriptionOptions);
|
|
150
147
|
} else {
|
|
151
148
|
// Split audio and process chunks
|
|
152
|
-
|
|
149
|
+
const transcriptions = [];
|
|
153
150
|
const numChunks = Math.ceil(duration / this.chunkSize);
|
|
154
151
|
|
|
155
152
|
for (let i = 0; i < numChunks; i++) {
|
|
@@ -171,14 +168,8 @@ class WhisperMix {
|
|
|
171
168
|
.run();
|
|
172
169
|
});
|
|
173
170
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
transcription = await this._transcribeLocalFile(chunkPath);
|
|
177
|
-
} else {
|
|
178
|
-
const chunkStream = fs.createReadStream(chunkPath);
|
|
179
|
-
transcription = await this.fromStream(chunkStream);
|
|
180
|
-
}
|
|
181
|
-
accumulatedTranscription += (transcription + " ").trimStart();
|
|
171
|
+
const transcription = await this._transcribeFilePath(chunkPath, transcriptionOptions);
|
|
172
|
+
transcriptions.push(this._offsetTranscription(transcription, startTime));
|
|
182
173
|
|
|
183
174
|
// Clean up chunk immediately after processing
|
|
184
175
|
try {
|
|
@@ -187,7 +178,7 @@ class WhisperMix {
|
|
|
187
178
|
console.warn(`Could not delete chunk ${chunkPath}:`, unlinkErr);
|
|
188
179
|
}
|
|
189
180
|
}
|
|
190
|
-
return
|
|
181
|
+
return this._mergeTranscriptions(transcriptions, transcriptionOptions, duration);
|
|
191
182
|
}
|
|
192
183
|
} finally {
|
|
193
184
|
// Clean up the temporary directory
|
|
@@ -199,10 +190,18 @@ class WhisperMix {
|
|
|
199
190
|
}
|
|
200
191
|
}
|
|
201
192
|
|
|
202
|
-
async
|
|
193
|
+
async _transcribeFilePath(filePath, options) {
|
|
194
|
+
if (this.isLocal) {
|
|
195
|
+
return this._transcribeLocalFile(filePath, options);
|
|
196
|
+
}
|
|
197
|
+
return this.fromStream(fs.createReadStream(filePath), options);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async fromStream(audioStream, options = {}) {
|
|
203
201
|
if (this.isLocal) {
|
|
204
202
|
throw new Error('fromStream is not supported for local Whisper model. Use fromFile instead.');
|
|
205
203
|
}
|
|
204
|
+
const transcriptionOptions = this._resolveTranscriptionOptions(options);
|
|
206
205
|
|
|
207
206
|
const chunks = [];
|
|
208
207
|
for await (const chunk of audioStream) {
|
|
@@ -210,10 +209,10 @@ class WhisperMix {
|
|
|
210
209
|
}
|
|
211
210
|
const buffer = Buffer.concat(chunks);
|
|
212
211
|
|
|
213
|
-
return this.limiter.schedule(() => this._makeRequest(buffer));
|
|
212
|
+
return this.limiter.schedule(() => this._makeRequest(buffer, transcriptionOptions));
|
|
214
213
|
}
|
|
215
214
|
|
|
216
|
-
async _makeRequest(buffer) {
|
|
215
|
+
async _makeRequest(buffer, options = {}) {
|
|
217
216
|
try {
|
|
218
217
|
const blob = new Blob([buffer], { type: 'audio/mpeg' });
|
|
219
218
|
const formData = new FormData();
|
|
@@ -222,6 +221,10 @@ class WhisperMix {
|
|
|
222
221
|
if (this.language) {
|
|
223
222
|
formData.append('language', this.language);
|
|
224
223
|
}
|
|
224
|
+
if (options.wordTimestamps) {
|
|
225
|
+
formData.append('response_format', 'verbose_json');
|
|
226
|
+
formData.append('timestamp_granularities[]', 'word');
|
|
227
|
+
}
|
|
225
228
|
|
|
226
229
|
const response = await fetch(this.apiUrl, {
|
|
227
230
|
method: 'POST',
|
|
@@ -237,13 +240,13 @@ class WhisperMix {
|
|
|
237
240
|
throw responseData;
|
|
238
241
|
}
|
|
239
242
|
|
|
240
|
-
return
|
|
243
|
+
return this._formatApiTranscription(responseData, options);
|
|
241
244
|
} catch (error) {
|
|
242
245
|
throw error?.message || error;
|
|
243
246
|
}
|
|
244
247
|
}
|
|
245
248
|
|
|
246
|
-
async _transcribeLocalFile(filePath) {
|
|
249
|
+
async _transcribeLocalFile(filePath, options = {}) {
|
|
247
250
|
try {
|
|
248
251
|
// Read the audio file as a buffer
|
|
249
252
|
const buffer = fs.readFileSync(filePath);
|
|
@@ -286,7 +289,7 @@ class WhisperMix {
|
|
|
286
289
|
if (typeof text !== 'string') {
|
|
287
290
|
throw new Error('Parakeet transcription returned no text output.');
|
|
288
291
|
}
|
|
289
|
-
return
|
|
292
|
+
return this._formatLocalTranscription(text, result?.words, options);
|
|
290
293
|
}
|
|
291
294
|
|
|
292
295
|
// Pass the processed audio data
|
|
@@ -296,8 +299,12 @@ class WhisperMix {
|
|
|
296
299
|
if (this.language !== undefined) {
|
|
297
300
|
transcriberOptions.language = this.language;
|
|
298
301
|
}
|
|
302
|
+
if (options.wordTimestamps) {
|
|
303
|
+
transcriberOptions.return_timestamps = 'word';
|
|
304
|
+
}
|
|
299
305
|
const result = await transcriber(audioData, transcriberOptions);
|
|
300
|
-
|
|
306
|
+
const words = options.wordTimestamps ? this._wordsFromTransformersChunks(result.chunks) : undefined;
|
|
307
|
+
return this._formatLocalTranscription(result.text, words, options);
|
|
301
308
|
} catch (error) {
|
|
302
309
|
const cacheHint = this.localBackend === 'onnx-asr-web'
|
|
303
310
|
? this._getParakeetCacheDir(this.layout)
|
|
@@ -306,6 +313,147 @@ class WhisperMix {
|
|
|
306
313
|
}
|
|
307
314
|
}
|
|
308
315
|
|
|
316
|
+
_resolveTranscriptionOptions(options = {}) {
|
|
317
|
+
return {
|
|
318
|
+
wordTimestamps: Boolean(options.wordTimestamps ?? this.wordTimestamps),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
_formatApiTranscription(responseData, options) {
|
|
323
|
+
const text = this._formatText(responseData.text);
|
|
324
|
+
if (!options.wordTimestamps) {
|
|
325
|
+
return text;
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
...responseData,
|
|
329
|
+
text,
|
|
330
|
+
words: this._normalizeWords(responseData.words),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_formatLocalTranscription(text, words, options) {
|
|
335
|
+
const formattedText = this._formatText(text);
|
|
336
|
+
if (!options.wordTimestamps) {
|
|
337
|
+
return formattedText;
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
text: formattedText,
|
|
341
|
+
words: this._normalizeWords(words),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_formatText(text) {
|
|
346
|
+
if (typeof text !== 'string') {
|
|
347
|
+
throw new Error('Transcription returned no text output.');
|
|
348
|
+
}
|
|
349
|
+
return text.trim();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_wordsFromTransformersChunks(chunks) {
|
|
353
|
+
if (!Array.isArray(chunks)) {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
return chunks.map((chunk) => ({
|
|
357
|
+
word: chunk.text,
|
|
358
|
+
timestamp: chunk.timestamp,
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
_normalizeWords(words) {
|
|
363
|
+
if (!Array.isArray(words)) {
|
|
364
|
+
throw new Error('Word timestamps were requested, but the selected backend did not return word timestamps.');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return words
|
|
368
|
+
.map((word) => {
|
|
369
|
+
if (!word || typeof word !== 'object') {
|
|
370
|
+
throw new Error('Word timestamp response contains an invalid word entry.');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const text = typeof word.word === 'string' ? word.word : word.text;
|
|
374
|
+
const start = typeof word.start === 'number' ? word.start : word.timestamp?.[0];
|
|
375
|
+
const end = typeof word.end === 'number' ? word.end : word.timestamp?.[1];
|
|
376
|
+
|
|
377
|
+
if (typeof text !== 'string' || typeof start !== 'number' || typeof end !== 'number') {
|
|
378
|
+
throw new Error('Word timestamp response contains an invalid word entry.');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
word: text.trim(),
|
|
383
|
+
start,
|
|
384
|
+
end,
|
|
385
|
+
};
|
|
386
|
+
})
|
|
387
|
+
.filter((word) => word.word.length > 0);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
_mergeTranscriptions(transcriptions, options, duration) {
|
|
391
|
+
const text = transcriptions
|
|
392
|
+
.map((transcription) => this._transcriptionText(transcription))
|
|
393
|
+
.filter((chunkText) => chunkText.length > 0)
|
|
394
|
+
.join(' ')
|
|
395
|
+
.trim();
|
|
396
|
+
|
|
397
|
+
if (!options.wordTimestamps) {
|
|
398
|
+
return text;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const words = transcriptions.flatMap((transcription) => {
|
|
402
|
+
if (!Array.isArray(transcription.words)) {
|
|
403
|
+
throw new Error('Word timestamps were requested, but a chunk returned no word timestamps.');
|
|
404
|
+
}
|
|
405
|
+
return transcription.words;
|
|
406
|
+
});
|
|
407
|
+
const segments = transcriptions.flatMap((transcription) => (
|
|
408
|
+
Array.isArray(transcription.segments) ? transcription.segments : []
|
|
409
|
+
));
|
|
410
|
+
const result = { text, words, duration };
|
|
411
|
+
|
|
412
|
+
if (segments.length > 0) {
|
|
413
|
+
result.segments = segments;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
_transcriptionText(transcription) {
|
|
420
|
+
if (typeof transcription === 'string') {
|
|
421
|
+
return transcription.trim();
|
|
422
|
+
}
|
|
423
|
+
return this._formatText(transcription.text);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
_offsetTranscription(transcription, offsetSeconds) {
|
|
427
|
+
if (typeof transcription === 'string' || offsetSeconds === 0) {
|
|
428
|
+
return transcription;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
...transcription,
|
|
433
|
+
words: Array.isArray(transcription.words)
|
|
434
|
+
? this._offsetTimedItems(transcription.words, offsetSeconds)
|
|
435
|
+
: transcription.words,
|
|
436
|
+
segments: Array.isArray(transcription.segments)
|
|
437
|
+
? this._offsetTimedItems(transcription.segments, offsetSeconds)
|
|
438
|
+
: transcription.segments,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
_offsetTimedItems(items, offsetSeconds) {
|
|
443
|
+
return items.map((item) => ({
|
|
444
|
+
...item,
|
|
445
|
+
start: this._offsetTime(item.start, offsetSeconds),
|
|
446
|
+
end: this._offsetTime(item.end, offsetSeconds),
|
|
447
|
+
}));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
_offsetTime(value, offsetSeconds) {
|
|
451
|
+
if (typeof value !== 'number') {
|
|
452
|
+
return value;
|
|
453
|
+
}
|
|
454
|
+
return Number((value + offsetSeconds).toFixed(3));
|
|
455
|
+
}
|
|
456
|
+
|
|
309
457
|
async _getLocalTranscriber() {
|
|
310
458
|
if (!this.transcriber) {
|
|
311
459
|
if (this.localBackend === 'onnx-asr-web') {
|
package/package.json
CHANGED
|
@@ -1,62 +1,61 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
2
|
+
"name": "whispermix",
|
|
3
|
+
"description": "🎙️ WhisperMix is a versatile module for transcribing audio using OpenAI’s Whisper or Groq’s Whisper v3 model.",
|
|
4
|
+
"version": "1.6.2",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"whisper",
|
|
8
|
+
"openai",
|
|
9
|
+
"groq",
|
|
10
|
+
"transcription",
|
|
11
|
+
"speech-to-text",
|
|
12
|
+
"audio",
|
|
13
|
+
"voice",
|
|
14
|
+
"ai",
|
|
15
|
+
"machine-learning",
|
|
16
|
+
"nlp",
|
|
17
|
+
"natural-language-processing",
|
|
18
|
+
"audio-processing",
|
|
19
|
+
"voice-recognition",
|
|
20
|
+
"speech-recognition",
|
|
21
|
+
"api-wrapper",
|
|
22
|
+
"whisper-large-v3",
|
|
23
|
+
"whisper-1",
|
|
24
|
+
"streaming",
|
|
25
|
+
"file-processing",
|
|
26
|
+
"multilingual",
|
|
27
|
+
"bottleneck",
|
|
28
|
+
"chunk",
|
|
29
|
+
"clasen",
|
|
30
|
+
"local",
|
|
31
|
+
"xenova",
|
|
32
|
+
"parakeet",
|
|
33
|
+
"nvidia",
|
|
34
|
+
"onnx"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/clasen/WhisperMix.git"
|
|
39
|
+
},
|
|
40
|
+
"main": "index.js",
|
|
41
|
+
"author": "Martin Clasen",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/clasen/WhisperMix/issues"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@huggingface/transformers": "^4.2.0",
|
|
48
|
+
"audio-decode": "^2.2.3",
|
|
49
|
+
"bottleneck": "^2.19.5",
|
|
50
|
+
"fluent-ffmpeg": "^2.1.3",
|
|
51
|
+
"get-audio-duration": "^4.0.1",
|
|
52
|
+
"onnx-asr-web": "^0.1.3"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"node-addon-api": "^8.7.0",
|
|
56
|
+
"node-gyp": "^12.3.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
60
|
+
}
|
|
61
|
+
}
|