whispermix 1.4.14 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🎙️ WhisperMix
2
2
 
3
- WhisperMix is a flexible module that provides an interface for transcribing audio using OpenAI's Whisper model, Groq's Whisper Large v3 model, or local Whisper models.
3
+ WhisperMix is a flexible module that provides an interface for transcribing audio using OpenAI's Whisper model, Groq's Whisper Large v3 model, local Whisper models, or local Parakeet TDT v3 models.
4
4
 
5
5
  ## 📦 Installation
6
6
 
@@ -36,6 +36,12 @@ const whisperGroq = new WhisperMix({ model: 'groq/whisper-large-v3' }); // For G
36
36
  const whisperLocal = new WhisperMix({ model: 'xenova/whisper-large-v3' }); // For local Whisper (large)
37
37
  // or
38
38
  const whisperLocalBase = new WhisperMix({ model: 'xenova/whisper-base' }); // For local Whisper (base)
39
+ // or
40
+ const whisperParakeet = new WhisperMix({ model: 'istupakov/parakeet-tdt-0.6b-v3' }); // For local Parakeet v3 (int8)
41
+ // or
42
+ const whisperParakeetInt4 = new WhisperMix({ model: 'efederici/parakeet-tdt-0.6b-v3-int4' }); // For local Parakeet v3 (int4 encoder)
43
+ // or
44
+ const whisperParakeetAlt = new WhisperMix({ model: 'nasedkinpv/parakeet-tdt-0.6b-v3-int8' }); // For local Parakeet v3 (alt int8 repo)
39
45
  ```
40
46
 
41
47
  ### 📄 Transcribing from a File
@@ -67,7 +73,49 @@ whisperGroq.fromStream(audioStream)
67
73
  .catch(error => console.error(error));
68
74
  ```
69
75
 
70
- **Note:** Stream transcription is only available for API-based models (OpenAI and Groq). Local Whisper models require file input.
76
+ **Note:** Stream transcription is only available for API-based models (OpenAI and Groq). Local models (Whisper and Parakeet) require file input.
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
+
103
+ ### 🐦 Parakeet local models
104
+
105
+ Parakeet TDT v3 local models are downloaded once and cached in:
106
+
107
+ `~/.cache/whispermix/parakeet/<modelKey>/`
108
+
109
+ Available local Parakeet model keys:
110
+
111
+ - `istupakov/parakeet-tdt-0.6b-v3` (about 670 MB, int8)
112
+ - `efederici/parakeet-tdt-0.6b-v3-int4` (about 410 MB, int4/int8 hybrid)
113
+ - `nasedkinpv/parakeet-tdt-0.6b-v3-int8` (about 890 MB, int8)
114
+
115
+ Notes:
116
+
117
+ - Parakeet TDT v3 is multilingual (25 European languages).
118
+ - `language` is ignored for Parakeet local models.
71
119
 
72
120
  ### ⏱️ Long Audio Processing
73
121
 
@@ -108,25 +156,29 @@ You can adjust these settings based on your specific rate limiting needs. Note t
108
156
 
109
157
  Creates a new WhisperMix instance.
110
158
 
111
- - `options.model`: The model to use for transcription. Can be `'openai/whisper-1'` (OpenAI), `'groq/whisper-large-v3'` (Groq), `'xenova/whisper-large-v3'` or `'xenova/whisper-base'` (local).
159
+ - `options.model`: The model to use for transcription. Can be `'openai/whisper-1'` (OpenAI), `'groq/whisper-large-v3'` (Groq), `'xenova/whisper-large-v3'` or `'xenova/whisper-base'` (local Whisper), `'istupakov/parakeet-tdt-0.6b-v3'`, `'efederici/parakeet-tdt-0.6b-v3-int4'`, or `'nasedkinpv/parakeet-tdt-0.6b-v3-int8'` (local Parakeet).
112
160
  - `options.bottleneck`: (Optional) Configuration for Bottleneck rate limiting (API models only).
113
161
  - `options.chunkSize`: (Optional) The size in seconds of the chunks to split the audio into. Default is 890 seconds.
114
162
  - `options.language`: (Optional) Language for local Whisper model. Defaults to 'auto' for automatic detection.
115
- ### `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)`
116
166
 
117
167
  Transcribes audio from a file.
118
168
 
119
169
  - `filePath`: Path to the audio file.
170
+ - `options.wordTimestamps`: (Optional) Return word-level timestamps for this call.
120
171
 
121
- 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.
122
173
 
123
- ### `whisper.fromStream(audioStream)`
174
+ ### `whisper.fromStream(audioStream, options)`
124
175
 
125
176
  Transcribes audio from a stream.
126
177
 
127
178
  - `audioStream`: A readable stream of the audio data.
179
+ - `options.wordTimestamps`: (Optional) Return word-level timestamps for this call.
128
180
 
129
- 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.
130
182
 
131
183
  ## 📄 License
132
184
 
package/SKILL.md ADDED
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: whispermix
3
+ description: Transcribe audio to text using WhisperMix, a Node.js wrapper around OpenAI Whisper, Groq Whisper Large v3, local Whisper (xenova) and local Parakeet TDT v3 models. Use when the user asks to "transcribe audio", "speech to text", "convert audio/voice to text", mentions an audio file (.mp3, .wav, .m4a, .ogg, .flac, .webm) to turn into text, or names any of these models/providers: Whisper, OpenAI Whisper, Groq Whisper, Whisper Large v3, Parakeet, NVIDIA Parakeet, xenova/whisper, onnx-asr. Also use when the user wants to pick between a cloud API and a local on-device transcription model in a Node.js project.
4
+ ---
5
+
6
+ # WhisperMix
7
+
8
+ Single Node.js entry point for audio transcription. Picks one of four backends behind the same API: OpenAI Whisper, Groq Whisper Large v3, local Whisper (xenova), or local Parakeet TDT v3. Handles long files by chunking and rate-limits API calls automatically.
9
+
10
+ Do NOT use for TTS, live microphone streaming, or translation. WhisperMix is one-way: a complete audio file/stream → text.
11
+
12
+ ## Model selection (decide first)
13
+
14
+ | Need | Pick |
15
+ |---|---|
16
+ | Offline, lowest latency | `istupakov/parakeet-tdt-0.6b-v3` |
17
+ | Offline, smallest footprint (~410 MB) | `efederici/parakeet-tdt-0.6b-v3-int4` |
18
+ | Highest accuracy, cloud | `groq/whisper-large-v3` (fast) or `openai/whisper-1` |
19
+ | Offline + per-language control | `xenova/whisper-large-v3` or `xenova/whisper-base` |
20
+ | Node stream input (not a file) | API only: `openai/whisper-1` or `groq/whisper-large-v3` |
21
+
22
+ Constraints to surface before coding:
23
+ - Local models (`xenova/*`, `*/parakeet-tdt-0.6b-v3*`) accept **files only**, not streams.
24
+ - `language` option applies only to local Whisper. Parakeet is multilingual and ignores it.
25
+ - API models need `OPENAI_API_KEY` or `GROQ_API_KEY` in the environment.
26
+ - First Parakeet run downloads weights to `~/.cache/whispermix/parakeet/<modelKey>/` — warn the user.
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.
29
+
30
+ ## API
31
+
32
+ ```bash
33
+ npm install whispermix
34
+ ```
35
+
36
+ ESM only. If the consumer is CommonJS, use `const WhisperMix = (await import('whispermix')).default;`.
37
+
38
+ ```javascript
39
+ import WhisperMix from 'whispermix';
40
+
41
+ const w = new WhisperMix({ model: '<modelKey>' });
42
+ const text = await w.fromFile('path/to/audio.mp3');
43
+ // API models only:
44
+ const text2 = await w.fromStream(fs.createReadStream('path/to/audio.mp3'));
45
+ ```
46
+
47
+ Constructor options:
48
+ - `model` (required) — see selection table.
49
+ - `language` — local Whisper only, e.g. `'spanish'`. Default `'auto'`.
50
+ - `chunkSize` — seconds per chunk for long audio. Default `890` (~14m50s).
51
+ - `bottleneck` — Bottleneck config for API models. Defaults: `minTime: 3000`, `maxConcurrent: 1`, `reservoir: 18`, `reservoirRefreshAmount: 18`, `reservoirRefreshInterval: 60000`.
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 })`.
54
+
55
+ ## Examples
56
+
57
+ Cheapest local:
58
+
59
+ ```javascript
60
+ import WhisperMix from 'whispermix';
61
+ const w = new WhisperMix({ model: 'efederici/parakeet-tdt-0.6b-v3-int4', showProgress: true });
62
+ console.log(await w.fromFile('meeting.wav'));
63
+ ```
64
+
65
+ Groq with custom rate limit:
66
+
67
+ ```javascript
68
+ import WhisperMix from 'whispermix';
69
+ const w = new WhisperMix({
70
+ model: 'groq/whisper-large-v3',
71
+ bottleneck: { minTime: 4000, maxConcurrent: 1 },
72
+ });
73
+ console.log(await w.fromFile('podcast.mp3'));
74
+ ```
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
+
86
+ Local Whisper, fixed language:
87
+
88
+ ```javascript
89
+ import WhisperMix from 'whispermix';
90
+ const w = new WhisperMix({ model: 'xenova/whisper-large-v3', language: 'spanish' });
91
+ console.log(await w.fromFile('entrevista.m4a'));
92
+ ```
93
+
94
+ ## Troubleshooting
95
+
96
+ - **`OPENAI_API_KEY`/`GROQ_API_KEY is not set`** — export the key, or switch to a local model.
97
+ - **`fromStream` not supported** — local models are file-only. Use `fromFile`, or switch to an API model.
98
+ - **`Cannot find ffmpeg`** — install it (`brew install ffmpeg` / `apt install ffmpeg`).
99
+ - **First Parakeet call hangs** — weights downloading; enable `showProgress: true`.
100
+ - **`ERR_REQUIRE_ESM`** — WhisperMix is ESM-only; use dynamic `import()` from CommonJS.
@@ -0,0 +1,9 @@
1
+ import WhisperMix from '../index.js';
2
+
3
+ const whisperParakeet = new WhisperMix({
4
+ model: 'efederici/parakeet-tdt-0.6b-v3-int4',
5
+ showProgress: true,
6
+ });
7
+
8
+ const result = await whisperParakeet.fromFile('conversation.wav');
9
+ console.log(result);
package/index.js CHANGED
@@ -4,11 +4,52 @@ import path from 'path';
4
4
  import ffmpeg from 'fluent-ffmpeg';
5
5
  import { getAudioDurationInSeconds } from 'get-audio-duration';
6
6
  import os from 'os'; // For temporary directory
7
+ import { Readable } from 'stream';
8
+ import { pipeline as streamPipeline } from 'stream/promises';
7
9
 
8
10
  // Static imports for local dependencies
9
- import { pipeline, env } from '@huggingface/transformers';
11
+ import { pipeline as hfPipeline, env } from '@huggingface/transformers';
10
12
  import audioDecode from 'audio-decode';
11
13
 
14
+ const PARAKEET_LAYOUTS = {
15
+ 'istupakov/parakeet-tdt-0.6b-v3': {
16
+ cacheKey: 'istupakov-parakeet-tdt-0.6b-v3',
17
+ repo: 'istupakov/parakeet-tdt-0.6b-v3-onnx',
18
+ files: [
19
+ { src: 'config.json', dst: 'config.json' },
20
+ { src: 'nemo128.onnx', dst: 'nemo128.onnx' },
21
+ { src: 'encoder-model.int8.onnx', dst: 'encoder-model.int8.onnx' },
22
+ { src: 'decoder_joint-model.int8.onnx', dst: 'decoder_joint-model.int8.onnx' },
23
+ { src: 'vocab.txt', dst: 'vocab.txt' },
24
+ ],
25
+ },
26
+ 'efederici/parakeet-tdt-0.6b-v3-int4': {
27
+ cacheKey: 'efederici-parakeet-tdt-0.6b-v3-int4',
28
+ repo: 'efederici/parakeet-tdt-0.6b-v3-onnx-int4',
29
+ files: [
30
+ { src: 'config.json', dst: 'config.json' },
31
+ { src: 'nemo128.onnx', dst: 'nemo128.onnx' },
32
+ { src: 'encoder-model.int4.onnx', dst: 'encoder-model.int8.onnx' },
33
+ { src: 'decoder_joint-model.int8.onnx', dst: 'decoder_joint-model.int8.onnx' },
34
+ { src: 'vocab.txt', dst: 'vocab.txt' },
35
+ ],
36
+ },
37
+ 'nasedkinpv/parakeet-tdt-0.6b-v3-int8': {
38
+ cacheKey: 'nasedkinpv-parakeet-tdt-0.6b-v3-int8',
39
+ repo: 'nasedkinpv/parakeet-tdt-0.6b-v3-onnx-int8',
40
+ files: [
41
+ { src: 'encoder-int8.onnx', dst: 'encoder-model.int8.onnx' },
42
+ { src: 'encoder-int8.onnx.data', dst: 'encoder-model.int8.onnx.data' },
43
+ { src: 'decoder_joint-int8.onnx', dst: 'decoder_joint-model.int8.onnx' },
44
+ { src: 'vocab.txt', dst: 'vocab.txt' },
45
+ { srcRepo: 'istupakov/parakeet-tdt-0.6b-v3-onnx', src: 'nemo128.onnx', dst: 'nemo128.onnx' },
46
+ ],
47
+ synthConfig: {
48
+ model_type: 'nemo-conformer-tdt',
49
+ },
50
+ },
51
+ };
52
+
12
53
  class WhisperMix {
13
54
  constructor(setup = {}) {
14
55
  this.model = 'openai/whisper-1';
@@ -41,7 +82,26 @@ class WhisperMix {
41
82
  local: true,
42
83
  modelName: 'Xenova/whisper-base',
43
84
  dtype: 'q8',
44
- },
85
+ backend: 'transformers',
86
+ },
87
+ 'istupakov/parakeet-tdt-0.6b-v3': {
88
+ local: true,
89
+ modelName: 'Parakeet-TDT-0.6B-v3',
90
+ backend: 'onnx-asr-web',
91
+ layout: PARAKEET_LAYOUTS['istupakov/parakeet-tdt-0.6b-v3'],
92
+ },
93
+ 'efederici/parakeet-tdt-0.6b-v3-int4': {
94
+ local: true,
95
+ modelName: 'Parakeet-TDT-0.6B-v3-int4',
96
+ backend: 'onnx-asr-web',
97
+ layout: PARAKEET_LAYOUTS['efederici/parakeet-tdt-0.6b-v3-int4'],
98
+ },
99
+ 'nasedkinpv/parakeet-tdt-0.6b-v3-int8': {
100
+ local: true,
101
+ modelName: 'Parakeet-TDT-0.6B-v3-int8',
102
+ backend: 'onnx-asr-web',
103
+ layout: PARAKEET_LAYOUTS['nasedkinpv/parakeet-tdt-0.6b-v3-int8'],
104
+ },
45
105
  };
46
106
 
47
107
  Object.assign(this, setup);
@@ -56,12 +116,17 @@ class WhisperMix {
56
116
  this.modelName = this.config.modelName;
57
117
  this.dtype = this.dtype || this.config.dtype;
58
118
  this.showProgress = this.showProgress || false;
119
+ this.localBackend = this.config.backend || 'transformers';
120
+ this.layout = this.config.layout;
59
121
  this.transcriber = null;
122
+ this._onnxAsrNodeModule = null;
123
+ this._warnedParakeetLanguage = false;
60
124
 
61
125
  this.limiter = new Bottleneck(this.bottleneck);
62
126
  }
63
127
 
64
- async fromFile(filePath) {
128
+ async fromFile(filePath, options = {}) {
129
+ const transcriptionOptions = this._resolveTranscriptionOptions(options);
65
130
  const absolutePath = path.resolve(filePath);
66
131
 
67
132
  // Check if file exists
@@ -78,14 +143,10 @@ class WhisperMix {
78
143
 
79
144
  if (duration <= this.chunkSize) {
80
145
  // Process as a single file
81
- if (this.isLocal) {
82
- return this._transcribeLocalFile(absolutePath);
83
- } else {
84
- return this.fromStream(fs.createReadStream(absolutePath));
85
- }
146
+ return this._transcribeFilePath(absolutePath, transcriptionOptions);
86
147
  } else {
87
148
  // Split audio and process chunks
88
- let accumulatedTranscription = "";
149
+ const transcriptions = [];
89
150
  const numChunks = Math.ceil(duration / this.chunkSize);
90
151
 
91
152
  for (let i = 0; i < numChunks; i++) {
@@ -107,14 +168,8 @@ class WhisperMix {
107
168
  .run();
108
169
  });
109
170
 
110
- let transcription;
111
- if (this.isLocal) {
112
- transcription = await this._transcribeLocalFile(chunkPath);
113
- } else {
114
- const chunkStream = fs.createReadStream(chunkPath);
115
- transcription = await this.fromStream(chunkStream);
116
- }
117
- accumulatedTranscription += (transcription + " ").trimStart();
171
+ const transcription = await this._transcribeFilePath(chunkPath, transcriptionOptions);
172
+ transcriptions.push(this._offsetTranscription(transcription, startTime));
118
173
 
119
174
  // Clean up chunk immediately after processing
120
175
  try {
@@ -123,7 +178,7 @@ class WhisperMix {
123
178
  console.warn(`Could not delete chunk ${chunkPath}:`, unlinkErr);
124
179
  }
125
180
  }
126
- return accumulatedTranscription.trim();
181
+ return this._mergeTranscriptions(transcriptions, transcriptionOptions, duration);
127
182
  }
128
183
  } finally {
129
184
  // Clean up the temporary directory
@@ -135,10 +190,18 @@ class WhisperMix {
135
190
  }
136
191
  }
137
192
 
138
- 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 = {}) {
139
201
  if (this.isLocal) {
140
202
  throw new Error('fromStream is not supported for local Whisper model. Use fromFile instead.');
141
203
  }
204
+ const transcriptionOptions = this._resolveTranscriptionOptions(options);
142
205
 
143
206
  const chunks = [];
144
207
  for await (const chunk of audioStream) {
@@ -146,10 +209,10 @@ class WhisperMix {
146
209
  }
147
210
  const buffer = Buffer.concat(chunks);
148
211
 
149
- return this.limiter.schedule(() => this._makeRequest(buffer));
212
+ return this.limiter.schedule(() => this._makeRequest(buffer, transcriptionOptions));
150
213
  }
151
214
 
152
- async _makeRequest(buffer) {
215
+ async _makeRequest(buffer, options = {}) {
153
216
  try {
154
217
  const blob = new Blob([buffer], { type: 'audio/mpeg' });
155
218
  const formData = new FormData();
@@ -158,6 +221,10 @@ class WhisperMix {
158
221
  if (this.language) {
159
222
  formData.append('language', this.language);
160
223
  }
224
+ if (options.wordTimestamps) {
225
+ formData.append('response_format', 'verbose_json');
226
+ formData.append('timestamp_granularities[]', 'word');
227
+ }
161
228
 
162
229
  const response = await fetch(this.apiUrl, {
163
230
  method: 'POST',
@@ -173,13 +240,13 @@ class WhisperMix {
173
240
  throw responseData;
174
241
  }
175
242
 
176
- return responseData.text.trim();
243
+ return this._formatApiTranscription(responseData, options);
177
244
  } catch (error) {
178
245
  throw error?.message || error;
179
246
  }
180
247
  }
181
248
 
182
- async _transcribeLocalFile(filePath) {
249
+ async _transcribeLocalFile(filePath, options = {}) {
183
250
  try {
184
251
  // Read the audio file as a buffer
185
252
  const buffer = fs.readFileSync(filePath);
@@ -212,6 +279,19 @@ class WhisperMix {
212
279
  // Reuse a single pipeline instance so repeated calls don't re-download/re-initialize.
213
280
  const transcriber = await this._getLocalTranscriber();
214
281
 
282
+ if (this.localBackend === 'onnx-asr-web') {
283
+ if (this.language !== undefined && this.showProgress && !this._warnedParakeetLanguage) {
284
+ console.log('[WhisperMix] "language" option is ignored for Parakeet local models.');
285
+ this._warnedParakeetLanguage = true;
286
+ }
287
+ const result = await transcriber.transcribeSamples(audioData, 16000);
288
+ const text = result?.text || result?.utterance_text;
289
+ if (typeof text !== 'string') {
290
+ throw new Error('Parakeet transcription returned no text output.');
291
+ }
292
+ return this._formatLocalTranscription(text, result?.words, options);
293
+ }
294
+
215
295
  // Pass the processed audio data
216
296
  const transcriberOptions = {
217
297
  task: 'transcribe',
@@ -219,16 +299,178 @@ class WhisperMix {
219
299
  if (this.language !== undefined) {
220
300
  transcriberOptions.language = this.language;
221
301
  }
302
+ if (options.wordTimestamps) {
303
+ transcriberOptions.return_timestamps = 'word';
304
+ }
222
305
  const result = await transcriber(audioData, transcriberOptions);
223
-
224
- return result.text.trim();
306
+ const words = options.wordTimestamps ? this._wordsFromTransformersChunks(result.chunks) : undefined;
307
+ return this._formatLocalTranscription(result.text, words, options);
225
308
  } catch (error) {
226
- throw new Error(`Local transcription failed: ${error.message}. If this happened after an interrupted download, remove the model cache at ${env.cacheDir}${this.modelName}/ and try again.`);
309
+ const cacheHint = this.localBackend === 'onnx-asr-web'
310
+ ? this._getParakeetCacheDir(this.layout)
311
+ : `${env.cacheDir}${this.modelName}/`;
312
+ throw new Error(`Local transcription failed: ${error.message}. If this happened after an interrupted download, remove the model cache at ${cacheHint} and try again.`);
313
+ }
314
+ }
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();
227
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));
228
455
  }
229
456
 
230
457
  async _getLocalTranscriber() {
231
458
  if (!this.transcriber) {
459
+ if (this.localBackend === 'onnx-asr-web') {
460
+ if (!this.layout) {
461
+ throw new Error(`Missing Parakeet layout for model: ${this.model}`);
462
+ }
463
+ const modelDir = await this._ensureParakeetAssets(this.layout);
464
+ const { loadLocalModel } = await this._getOnnxAsrNodeModule();
465
+ this.transcriber = await loadLocalModel(modelDir, {
466
+ quantization: 'int8',
467
+ sessionOptions: {
468
+ executionProviders: ['wasm'],
469
+ },
470
+ });
471
+ return this.transcriber;
472
+ }
473
+
232
474
  const options = {};
233
475
  if (this.dtype) {
234
476
  options.dtype = this.dtype;
@@ -244,11 +486,90 @@ class WhisperMix {
244
486
  };
245
487
  }
246
488
 
247
- this.transcriber = pipeline('automatic-speech-recognition', this.modelName, options);
489
+ this.transcriber = hfPipeline('automatic-speech-recognition', this.modelName, options);
248
490
  }
249
491
 
250
492
  return this.transcriber;
251
493
  }
494
+
495
+ async _getOnnxAsrNodeModule() {
496
+ if (!this._onnxAsrNodeModule) {
497
+ this._onnxAsrNodeModule = await import('onnx-asr-web/node');
498
+ }
499
+ return this._onnxAsrNodeModule;
500
+ }
501
+
502
+ _getParakeetCacheDir(layout) {
503
+ if (this.cacheDir) {
504
+ return path.resolve(this.cacheDir);
505
+ }
506
+ if (!layout?.cacheKey) {
507
+ throw new Error(`Missing cache key for Parakeet layout: ${this.model}`);
508
+ }
509
+ return path.join(os.homedir(), '.cache', 'whispermix', 'parakeet', layout.cacheKey);
510
+ }
511
+
512
+ async _ensureParakeetAssets(layout) {
513
+ const cacheDir = this._getParakeetCacheDir(layout);
514
+ await fs.promises.mkdir(cacheDir, { recursive: true });
515
+
516
+ for (const fileDef of layout.files) {
517
+ const destination = path.join(cacheDir, fileDef.dst);
518
+ const sourceRepo = fileDef.srcRepo || layout.repo;
519
+
520
+ if (await this._hasFileWithContent(destination)) {
521
+ continue;
522
+ }
523
+
524
+ const sourceUrl = `https://huggingface.co/${sourceRepo}/resolve/main/${fileDef.src}`;
525
+ if (this.showProgress) {
526
+ console.log(`[WhisperMix] Downloading ${fileDef.src} from ${sourceRepo}`);
527
+ }
528
+ await this._downloadToFile(sourceUrl, destination);
529
+ }
530
+
531
+ if (layout.synthConfig) {
532
+ const configPath = path.join(cacheDir, 'config.json');
533
+ if (!(await this._hasFileWithContent(configPath))) {
534
+ await fs.promises.writeFile(configPath, JSON.stringify(layout.synthConfig, null, 2));
535
+ }
536
+ }
537
+
538
+ return cacheDir;
539
+ }
540
+
541
+ async _hasFileWithContent(filePath) {
542
+ try {
543
+ const stats = await fs.promises.stat(filePath);
544
+ return stats.isFile() && stats.size > 0;
545
+ } catch {
546
+ return false;
547
+ }
548
+ }
549
+
550
+ async _downloadToFile(url, filePath) {
551
+ const response = await fetch(url);
552
+ if (!response.ok) {
553
+ throw new Error(`Download failed (${response.status} ${response.statusText}) for ${url}`);
554
+ }
555
+ if (!response.body) {
556
+ throw new Error(`Empty response body while downloading ${url}`);
557
+ }
558
+
559
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
560
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
561
+
562
+ try {
563
+ await streamPipeline(
564
+ Readable.fromWeb(response.body),
565
+ fs.createWriteStream(tempPath),
566
+ );
567
+ await fs.promises.rename(tempPath, filePath);
568
+ } catch (error) {
569
+ await fs.promises.rm(tempPath, { force: true });
570
+ throw error;
571
+ }
572
+ }
252
573
  }
253
574
 
254
575
  // Support both CommonJS and ES modules
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "whispermix",
3
3
  "description": "🎙️ WhisperMix is a versatile module for transcribing audio using OpenAI’s Whisper or Groq’s Whisper v3 model.",
4
- "version": "1.4.14",
4
+ "version": "1.6.2",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "whisper",
@@ -28,7 +28,10 @@
28
28
  "chunk",
29
29
  "clasen",
30
30
  "local",
31
- "xenova"
31
+ "xenova",
32
+ "parakeet",
33
+ "nvidia",
34
+ "onnx"
32
35
  ],
33
36
  "repository": {
34
37
  "type": "git",
@@ -45,7 +48,8 @@
45
48
  "audio-decode": "^2.2.3",
46
49
  "bottleneck": "^2.19.5",
47
50
  "fluent-ffmpeg": "^2.1.3",
48
- "get-audio-duration": "^4.0.1"
51
+ "get-audio-duration": "^4.0.1",
52
+ "onnx-asr-web": "^0.1.3"
49
53
  },
50
54
  "devDependencies": {
51
55
  "node-addon-api": "^8.7.0",