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.
Files changed (4) hide show
  1. package/README.md +33 -4
  2. package/SKILL.md +12 -0
  3. package/index.js +171 -23
  4. 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
- ### `whisper.fromFile(filePath)`
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
- if (this.isLocal) {
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
- let accumulatedTranscription = "";
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
- let transcription;
175
- if (this.isLocal) {
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 accumulatedTranscription.trim();
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 fromStream(audioStream) {
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 responseData.text.trim();
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 text.trim();
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
- return result.text.trim();
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
- "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.5.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
- "scripts": {
42
- "test": "echo \"Error: no test specified\" && exit 1"
43
- },
44
- "author": "Martin Clasen",
45
- "license": "MIT",
46
- "bugs": {
47
- "url": "https://github.com/clasen/WhisperMix/issues"
48
- },
49
- "dependencies": {
50
- "@huggingface/transformers": "^4.2.0",
51
- "audio-decode": "^2.2.3",
52
- "bottleneck": "^2.19.5",
53
- "fluent-ffmpeg": "^2.1.3",
54
- "get-audio-duration": "^4.0.1",
55
- "onnx-asr-web": "^0.1.3"
56
- },
57
- "devDependencies": {
58
- "node-addon-api": "^8.7.0",
59
- "node-gyp": "^12.3.0"
60
- },
61
- "packageManager": "pnpm@11.1.2"
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
+ }