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 +59 -7
- package/SKILL.md +100 -0
- package/demo/parakeet.js +9 -0
- package/index.js +348 -27
- package/package.json +7 -3
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
|
|
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
|
|
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
|
-
|
|
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.
|
package/demo/parakeet.js
ADDED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
306
|
+
const words = options.wordTimestamps ? this._wordsFromTransformersChunks(result.chunks) : undefined;
|
|
307
|
+
return this._formatLocalTranscription(result.text, words, options);
|
|
225
308
|
} catch (error) {
|
|
226
|
-
|
|
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 =
|
|
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
|
+
"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",
|