@moxxy/plugin-stt-local 0.47.0 → 0.48.0
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/MODEL_NOTICE.md +12 -0
- package/dist/audio.d.ts +7 -0
- package/dist/audio.d.ts.map +1 -1
- package/dist/audio.js +45 -0
- package/dist/audio.js.map +1 -1
- package/dist/host-protocol.d.ts +5 -3
- package/dist/host-protocol.d.ts.map +1 -1
- package/dist/host-protocol.js +20 -9
- package/dist/host-protocol.js.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -6
- package/dist/index.js.map +1 -1
- package/dist/local-stt.d.ts +23 -12
- package/dist/local-stt.d.ts.map +1 -1
- package/dist/local-stt.js +76 -29
- package/dist/local-stt.js.map +1 -1
- package/dist/models.d.ts +34 -20
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +53 -12
- package/dist/models.js.map +1 -1
- package/package.json +6 -4
- package/src/audio.test.ts +35 -0
- package/src/audio.ts +46 -0
- package/src/host-protocol.test.ts +21 -0
- package/src/host-protocol.ts +26 -12
- package/src/index.ts +23 -13
- package/src/live.test.ts +12 -1
- package/src/local-stt.test.ts +128 -9
- package/src/local-stt.ts +90 -39
- package/src/models.test.ts +41 -4
- package/src/models.ts +73 -22
package/src/local-stt.test.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { MOXXY_PCM16_24KHZ_MIME } from '@moxxy/sdk';
|
|
5
5
|
import type { ensureModelFiles } from '@moxxy/model-fetch';
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
createLocalParakeetTranscriber,
|
|
8
9
|
createLocalWhisperTranscriber,
|
|
9
|
-
type
|
|
10
|
+
type LocalSttOptions,
|
|
10
11
|
} from './local-stt.js';
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
buildLocalSttPlugin,
|
|
14
|
+
LOCAL_PARAKEET_TRANSCRIBER_NAME,
|
|
15
|
+
LOCAL_WHISPER_TRANSCRIBER_NAME,
|
|
16
|
+
} from './index.js';
|
|
12
17
|
import type { HostClientLike } from './host-client.js';
|
|
13
18
|
import type { HostRequest, TranscribeResult } from './host-protocol.js';
|
|
14
19
|
|
|
@@ -60,7 +65,7 @@ function fakeEnsure(): { impl: typeof ensureModelFiles; calls: Parameters<typeof
|
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
function makeTranscriber(
|
|
63
|
-
over: Partial<
|
|
68
|
+
over: Partial<LocalSttOptions> = {},
|
|
64
69
|
result?: TranscribeResult,
|
|
65
70
|
): {
|
|
66
71
|
transcriber: ReturnType<typeof createLocalWhisperTranscriber>;
|
|
@@ -79,7 +84,7 @@ function makeTranscriber(
|
|
|
79
84
|
return { transcriber, host, ensure };
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
describe('
|
|
87
|
+
describe('LocalSttTranscriber.transcribe', () => {
|
|
83
88
|
it('defaults to the turbo model, decodes raw PCM, and returns the transcript', async () => {
|
|
84
89
|
const { transcriber, host, ensure } = makeTranscriber();
|
|
85
90
|
const out = await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
|
|
@@ -102,7 +107,7 @@ describe('LocalWhisperTranscriber.transcribe', () => {
|
|
|
102
107
|
});
|
|
103
108
|
|
|
104
109
|
it('rejects a removed model (small) left in config, before any download', () => {
|
|
105
|
-
expect(() => makeTranscriber({ model: 'small' })).toThrow(/Unknown local
|
|
110
|
+
expect(() => makeTranscriber({ model: 'small' })).toThrow(/Unknown local speech model "small"/);
|
|
106
111
|
});
|
|
107
112
|
|
|
108
113
|
it('passes a per-call language hint through to the recognizer', async () => {
|
|
@@ -183,15 +188,14 @@ describe('LocalWhisperTranscriber.transcribe', () => {
|
|
|
183
188
|
describe('constructor validation', () => {
|
|
184
189
|
it('throws CONFIG_INVALID for an unknown configured model', () => {
|
|
185
190
|
expect(() => createLocalWhisperTranscriber({ model: 'medium' })).toThrow(
|
|
186
|
-
/Unknown local
|
|
191
|
+
/Unknown local speech model/,
|
|
187
192
|
);
|
|
188
193
|
});
|
|
189
194
|
});
|
|
190
195
|
|
|
191
196
|
describe('buildLocalSttPlugin', () => {
|
|
192
|
-
it('registers
|
|
197
|
+
it('registers local-whisper first', () => {
|
|
193
198
|
const plugin = buildLocalSttPlugin();
|
|
194
|
-
expect(plugin.transcribers).toHaveLength(1);
|
|
195
199
|
const def = plugin.transcribers![0]!;
|
|
196
200
|
expect(def.name).toBe(LOCAL_WHISPER_TRANSCRIBER_NAME);
|
|
197
201
|
expect(def.displayName).toContain('Local Whisper');
|
|
@@ -224,3 +228,118 @@ describe('buildLocalSttPlugin', () => {
|
|
|
224
228
|
expect(typeof plugin.hooks?.onShutdown).toBe('function');
|
|
225
229
|
});
|
|
226
230
|
});
|
|
231
|
+
|
|
232
|
+
describe('local-parakeet', () => {
|
|
233
|
+
/** A minute and a half of loud audio, as a 16-bit PCM WAV-free raw clip @ 24 kHz. */
|
|
234
|
+
const LONG_AUDIO = rawPcm16(24_000 * 90);
|
|
235
|
+
|
|
236
|
+
function makeParakeet(over: Partial<LocalSttOptions> = {}, result?: TranscribeResult) {
|
|
237
|
+
const host = fakeHost(result);
|
|
238
|
+
const ensure = fakeEnsure();
|
|
239
|
+
const hosts = { made: 0 };
|
|
240
|
+
const transcriber = createLocalParakeetTranscriber({
|
|
241
|
+
modelsDir: '/models/stt',
|
|
242
|
+
ensureModelFilesImpl: ensure.impl,
|
|
243
|
+
hostFactory: () => {
|
|
244
|
+
hosts.made += 1;
|
|
245
|
+
return host.host;
|
|
246
|
+
},
|
|
247
|
+
log: () => {},
|
|
248
|
+
...over,
|
|
249
|
+
});
|
|
250
|
+
return { transcriber, host, ensure, hosts };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
afterEach(() => {
|
|
254
|
+
vi.useRealTimers();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('downloads its four files and sends the joiner to the sidecar', async () => {
|
|
258
|
+
const { transcriber, host, ensure } = makeParakeet();
|
|
259
|
+
const out = await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
|
|
260
|
+
expect(transcriber.name).toBe(LOCAL_PARAKEET_TRANSCRIBER_NAME);
|
|
261
|
+
expect(out.text).toBe('hello world');
|
|
262
|
+
expect(ensure.calls[0]!.dir).toBe(path.join('/models/stt', 'parakeet'));
|
|
263
|
+
expect(ensure.calls[0]!.files.map((file) => file.name)).toEqual([
|
|
264
|
+
'encoder.int8.onnx',
|
|
265
|
+
'decoder.int8.onnx',
|
|
266
|
+
'joiner.int8.onnx',
|
|
267
|
+
'tokens.txt',
|
|
268
|
+
]);
|
|
269
|
+
expect(host.reqs[0]!.joiner).toBe(path.join('/models/stt', 'parakeet', 'joiner.int8.onnx'));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('reads a long recording piece by piece and joins the text', async () => {
|
|
273
|
+
const { transcriber, host } = makeParakeet();
|
|
274
|
+
const out = await transcriber.transcribe(LONG_AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
|
|
275
|
+
expect(host.reqs).toHaveLength(2);
|
|
276
|
+
expect(out.text).toBe('hello world hello world');
|
|
277
|
+
expect(out.durationSec).toBeCloseTo(90, 0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('stops between pieces when the caller aborts', async () => {
|
|
281
|
+
const controller = new AbortController();
|
|
282
|
+
const host = fakeHost();
|
|
283
|
+
const transcribe = host.host.transcribe.bind(host.host);
|
|
284
|
+
host.host.transcribe = async (req) => {
|
|
285
|
+
controller.abort(new Error('stop'));
|
|
286
|
+
return transcribe(req);
|
|
287
|
+
};
|
|
288
|
+
const transcriber = createLocalParakeetTranscriber({
|
|
289
|
+
modelsDir: '/models/stt',
|
|
290
|
+
ensureModelFilesImpl: fakeEnsure().impl,
|
|
291
|
+
hostFactory: () => host.host,
|
|
292
|
+
log: () => {},
|
|
293
|
+
});
|
|
294
|
+
await expect(
|
|
295
|
+
transcriber.transcribe(LONG_AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME, signal: controller.signal }),
|
|
296
|
+
).rejects.toThrow(/stop/);
|
|
297
|
+
expect(host.reqs).toHaveLength(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('does not report the hint as the language it heard', async () => {
|
|
301
|
+
const { transcriber } = makeParakeet();
|
|
302
|
+
const out = await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME, language: 'pl' });
|
|
303
|
+
expect(out.language).toBeUndefined();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('gives its memory back after five idle minutes and loads again on the next use', async () => {
|
|
307
|
+
vi.useFakeTimers();
|
|
308
|
+
const { transcriber, host, hosts } = makeParakeet();
|
|
309
|
+
await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
|
|
310
|
+
await vi.advanceTimersByTimeAsync(5 * 60_000 - 1);
|
|
311
|
+
expect(host.shutdowns).toBe(0);
|
|
312
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
313
|
+
expect(host.shutdowns).toBe(1);
|
|
314
|
+
await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
|
|
315
|
+
expect(hosts.made).toBe(2);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('keeps the model while another transcription is running', async () => {
|
|
319
|
+
vi.useFakeTimers();
|
|
320
|
+
const { transcriber, host } = makeParakeet();
|
|
321
|
+
await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
|
|
322
|
+
await vi.advanceTimersByTimeAsync(4 * 60_000);
|
|
323
|
+
await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
|
|
324
|
+
await vi.advanceTimersByTimeAsync(4 * 60_000);
|
|
325
|
+
expect(host.shutdowns).toBe(0);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('keeps Whisper loaded, as it always was', async () => {
|
|
329
|
+
vi.useFakeTimers();
|
|
330
|
+
const { transcriber, host } = makeTranscriber();
|
|
331
|
+
await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
|
|
332
|
+
await vi.advanceTimersByTimeAsync(60 * 60_000);
|
|
333
|
+
expect(host.shutdowns).toBe(0);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('registers next to local-whisper without choosing itself', () => {
|
|
337
|
+
const plugin = buildLocalSttPlugin({ defaults: { modelsDir: '/models/stt' } });
|
|
338
|
+
expect(plugin.transcribers?.map((def) => def.name)).toEqual([
|
|
339
|
+
LOCAL_WHISPER_TRANSCRIBER_NAME,
|
|
340
|
+
LOCAL_PARAKEET_TRANSCRIBER_NAME,
|
|
341
|
+
]);
|
|
342
|
+
const inst = plugin.transcribers![1]!.createClient({ model: 'turbo' });
|
|
343
|
+
expect(inst.name).toBe(LOCAL_PARAKEET_TRANSCRIBER_NAME);
|
|
344
|
+
});
|
|
345
|
+
});
|
package/src/local-stt.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `local-whisper`
|
|
2
|
+
* The `local-whisper` and `local-parakeet` Transcribers: fully local, on-device
|
|
3
|
+
* speech-to-text. They differ only in the catalog entry they read.
|
|
3
4
|
*
|
|
4
5
|
* On the first transcription for a model it downloads + verifies that model's
|
|
5
|
-
*
|
|
6
|
+
* files (once, via @moxxy/model-fetch), then decodes the inbound audio
|
|
6
7
|
* to Float32 mono @ 16 kHz IN-PROCESS (raw PCM / WAV) or via ffmpeg (compressed
|
|
7
8
|
* containers) and hands the samples to the sherpa sidecar ({@link HostClient}).
|
|
8
9
|
* No key, no network at transcription time.
|
|
@@ -29,7 +30,7 @@ import {
|
|
|
29
30
|
type ModelFilesProgress,
|
|
30
31
|
} from '@moxxy/model-fetch';
|
|
31
32
|
|
|
32
|
-
import { TARGET_SAMPLE_RATE } from './audio.js';
|
|
33
|
+
import { splitAtSilence, TARGET_SAMPLE_RATE } from './audio.js';
|
|
33
34
|
import { decodeToMono16k } from './decode.js';
|
|
34
35
|
import { HostClient, type HostClientLike } from './host-client.js';
|
|
35
36
|
import {
|
|
@@ -39,27 +40,28 @@ import {
|
|
|
39
40
|
modelDir,
|
|
40
41
|
modelFiles,
|
|
41
42
|
requireModel,
|
|
42
|
-
type
|
|
43
|
+
type LocalSttModelEntry,
|
|
43
44
|
} from './models.js';
|
|
44
45
|
import { resolveSherpaLibDir, sherpaEnv, sherpaPlatformPackage } from './platform.js';
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
export const LOCAL_WHISPER_TRANSCRIBER_NAME = 'local-whisper';
|
|
47
|
+
export { LOCAL_PARAKEET_TRANSCRIBER_NAME, LOCAL_WHISPER_TRANSCRIBER_NAME } from './models.js';
|
|
48
48
|
|
|
49
49
|
const DEFAULT_NUM_THREADS = 2;
|
|
50
50
|
|
|
51
51
|
/** Resolved on-disk paths sherpa needs for one model. */
|
|
52
|
-
export interface
|
|
52
|
+
export interface LocalSttModelPaths {
|
|
53
53
|
readonly encoder: string;
|
|
54
54
|
readonly decoder: string;
|
|
55
|
+
readonly joiner?: string;
|
|
55
56
|
readonly tokens: string;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
export interface
|
|
59
|
-
/** Model id
|
|
59
|
+
export interface LocalSttOptions {
|
|
60
|
+
/** Model id, `turbo` by default. */
|
|
60
61
|
readonly model?: string;
|
|
61
62
|
/** Default Whisper language tag (BCP-47-ish, e.g. `en`, `pl`). Per-call
|
|
62
|
-
* `TranscribeOptions.language` overrides it; omit to let Whisper auto-detect.
|
|
63
|
+
* `TranscribeOptions.language` overrides it; omit to let Whisper auto-detect.
|
|
64
|
+
* Parakeet takes no hint: it works the language out itself. */
|
|
63
65
|
readonly language?: string;
|
|
64
66
|
/** Inference threads. Default 2. */
|
|
65
67
|
readonly numThreads?: number;
|
|
@@ -84,7 +86,7 @@ export interface LocalWhisperOptions {
|
|
|
84
86
|
|
|
85
87
|
/** Process-wide set of live transcribers so the plugin's `onShutdown` hook can
|
|
86
88
|
* kill every sidecar it spawned. */
|
|
87
|
-
const ACTIVE_STT = new Set<
|
|
89
|
+
const ACTIVE_STT = new Set<LocalSttTranscriber>();
|
|
88
90
|
|
|
89
91
|
/** Shut down every live local transcriber (kills their sidecars). */
|
|
90
92
|
export function shutdownLocalStt(): void {
|
|
@@ -92,10 +94,10 @@ export function shutdownLocalStt(): void {
|
|
|
92
94
|
ACTIVE_STT.clear();
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
export class
|
|
96
|
-
readonly name
|
|
97
|
+
export class LocalSttTranscriber implements Transcriber {
|
|
98
|
+
readonly name: string;
|
|
97
99
|
|
|
98
|
-
private readonly model:
|
|
100
|
+
private readonly model: LocalSttModelEntry;
|
|
99
101
|
private readonly defaultLanguage: string;
|
|
100
102
|
private readonly numThreads: number;
|
|
101
103
|
private readonly modelsDir: string;
|
|
@@ -110,11 +112,15 @@ export class LocalWhisperTranscriber implements Transcriber {
|
|
|
110
112
|
private host: HostClientLike | null = null;
|
|
111
113
|
/** In-flight model download promise — de-dupes concurrent first-use. */
|
|
112
114
|
private ensuring: Promise<EnsureModelFilesResult> | null = null;
|
|
115
|
+
/** Transcriptions in flight: the model stays in memory while any is running. */
|
|
116
|
+
private running = 0;
|
|
117
|
+
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
113
118
|
|
|
114
|
-
constructor(opts:
|
|
119
|
+
constructor(opts: LocalSttOptions = {}) {
|
|
115
120
|
// Validate the configured model up front so a bad config fails clearly at
|
|
116
121
|
// construction rather than on the first (possibly channel-triggered) call.
|
|
117
122
|
this.model = requireModel(opts.model ?? DEFAULT_MODEL_ID, 'model');
|
|
123
|
+
this.name = this.model.transcriber;
|
|
118
124
|
this.defaultLanguage = typeof opts.language === 'string' ? opts.language.trim() : '';
|
|
119
125
|
this.numThreads =
|
|
120
126
|
opts.numThreads && Number.isInteger(opts.numThreads) && opts.numThreads > 0
|
|
@@ -151,48 +157,88 @@ export class LocalWhisperTranscriber implements Transcriber {
|
|
|
151
157
|
const paths = await this.ensureModelFiles(opts.signal);
|
|
152
158
|
opts.signal?.throwIfAborted();
|
|
153
159
|
|
|
154
|
-
const language = ((opts.language ?? this.defaultLanguage) || '').trim();
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
160
|
+
const language = this.model.joiner ? '' : ((opts.language ?? this.defaultLanguage) || '').trim();
|
|
161
|
+
const { maxChunkSeconds } = this.model;
|
|
162
|
+
const parts = maxChunkSeconds ? splitAtSilence(samples, TARGET_SAMPLE_RATE, maxChunkSeconds) : [samples];
|
|
163
|
+
const texts: string[] = [];
|
|
164
|
+
let detected: string | undefined;
|
|
165
|
+
|
|
166
|
+
this.holdModel();
|
|
167
|
+
try {
|
|
168
|
+
for (const part of parts) {
|
|
169
|
+
opts.signal?.throwIfAborted();
|
|
170
|
+
const result = await this.getHost().transcribe({
|
|
171
|
+
modelKey: paths.encoder,
|
|
172
|
+
encoder: paths.encoder,
|
|
173
|
+
decoder: paths.decoder,
|
|
174
|
+
...(paths.joiner ? { joiner: paths.joiner } : {}),
|
|
175
|
+
tokens: paths.tokens,
|
|
176
|
+
numThreads: this.numThreads,
|
|
177
|
+
provider: this.provider,
|
|
178
|
+
language,
|
|
179
|
+
task: 'transcribe',
|
|
180
|
+
samples: part,
|
|
181
|
+
sampleRate: TARGET_SAMPLE_RATE,
|
|
182
|
+
});
|
|
183
|
+
texts.push(result.text.trim());
|
|
184
|
+
detected ??= result.language;
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
this.letModelGo();
|
|
188
|
+
}
|
|
168
189
|
|
|
169
190
|
const durationSec = samples.length / TARGET_SAMPLE_RATE;
|
|
170
|
-
// Prefer
|
|
171
|
-
//
|
|
172
|
-
const reported =
|
|
191
|
+
// Prefer the detected language; else the caller/default hint (when set) so
|
|
192
|
+
// downstream still gets a BCP-47 tag.
|
|
193
|
+
const reported = detected ?? (language || undefined);
|
|
173
194
|
const out: {
|
|
174
195
|
text: string;
|
|
175
196
|
language?: string;
|
|
176
197
|
durationSec?: number;
|
|
177
|
-
} = { text:
|
|
198
|
+
} = { text: texts.filter(Boolean).join(' '), durationSec };
|
|
178
199
|
if (reported) out.language = reported;
|
|
179
200
|
return out;
|
|
180
201
|
}
|
|
181
202
|
|
|
182
203
|
/** Kill this transcriber's sidecar (if any) and drop registration. */
|
|
183
204
|
shutdown(): void {
|
|
205
|
+
this.cancelIdleRelease();
|
|
184
206
|
this.host?.shutdown();
|
|
185
207
|
this.host = null;
|
|
186
208
|
ACTIVE_STT.delete(this);
|
|
187
209
|
}
|
|
188
210
|
|
|
211
|
+
private holdModel(): void {
|
|
212
|
+
this.running += 1;
|
|
213
|
+
this.cancelIdleRelease();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The last transcription to finish starts the idle clock. Killing the sidecar
|
|
217
|
+
* is what frees the model's memory; the next call forks a new one. */
|
|
218
|
+
private letModelGo(): void {
|
|
219
|
+
this.running -= 1;
|
|
220
|
+
const { idleReleaseMs } = this.model;
|
|
221
|
+
if (!idleReleaseMs || this.running > 0) return;
|
|
222
|
+
this.idleTimer = setTimeout(() => {
|
|
223
|
+
this.idleTimer = null;
|
|
224
|
+
this.host?.shutdown();
|
|
225
|
+
this.host = null;
|
|
226
|
+
}, idleReleaseMs);
|
|
227
|
+
this.idleTimer.unref?.();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private cancelIdleRelease(): void {
|
|
231
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
232
|
+
this.idleTimer = null;
|
|
233
|
+
}
|
|
234
|
+
|
|
189
235
|
private getHost(): HostClientLike {
|
|
190
236
|
this.host ??= this.hostFactory();
|
|
191
237
|
return this.host;
|
|
192
238
|
}
|
|
193
239
|
|
|
194
240
|
/** Resolve on-disk paths for the model, downloading its files once. */
|
|
195
|
-
private async ensureModelFiles(signal?: AbortSignal): Promise<
|
|
241
|
+
private async ensureModelFiles(signal?: AbortSignal): Promise<LocalSttModelPaths> {
|
|
196
242
|
const dir = modelDir(this.model, this.modelsDir);
|
|
197
243
|
let inflight = this.ensuring;
|
|
198
244
|
if (!inflight) {
|
|
@@ -213,6 +259,7 @@ export class LocalWhisperTranscriber implements Transcriber {
|
|
|
213
259
|
return {
|
|
214
260
|
encoder: path.join(dir, this.model.encoder.name),
|
|
215
261
|
decoder: path.join(dir, this.model.decoder.name),
|
|
262
|
+
...(this.model.joiner ? { joiner: path.join(dir, this.model.joiner.name) } : {}),
|
|
216
263
|
tokens: path.join(dir, this.model.tokens.name),
|
|
217
264
|
};
|
|
218
265
|
}
|
|
@@ -228,7 +275,7 @@ export class LocalWhisperTranscriber implements Transcriber {
|
|
|
228
275
|
if (pct >= 100) return;
|
|
229
276
|
started = true;
|
|
230
277
|
const mb = Math.round(modelBytes(this.model) / 1_000_000);
|
|
231
|
-
this.log(`stt-local: first use — downloading
|
|
278
|
+
this.log(`stt-local: first use — downloading ${this.model.label} (~${mb} MB) to ${this.modelsDir}, one-time`);
|
|
232
279
|
}
|
|
233
280
|
if (pct >= lastPct + 10) {
|
|
234
281
|
lastPct = pct - (pct % 10);
|
|
@@ -246,8 +293,8 @@ export class LocalWhisperTranscriber implements Transcriber {
|
|
|
246
293
|
throw new MoxxyError({
|
|
247
294
|
code: 'PLUGIN_LOAD_FAILED',
|
|
248
295
|
message: pkg
|
|
249
|
-
? `Local
|
|
250
|
-
: `Local
|
|
296
|
+
? `Local transcription could not load the sherpa-onnx binary (${pkg}). Reinstall @moxxy/plugin-stt-local so its platform dependency is fetched.`
|
|
297
|
+
: `Local transcription has no sherpa-onnx binary for ${process.platform}-${process.arch}.`,
|
|
251
298
|
hint: 'Use the OpenAI Whisper backend instead, or run on a supported platform (macOS/Linux/Windows x64, macOS/Linux arm64).',
|
|
252
299
|
context: { platform: process.platform, arch: process.arch },
|
|
253
300
|
});
|
|
@@ -262,6 +309,10 @@ export class LocalWhisperTranscriber implements Transcriber {
|
|
|
262
309
|
}
|
|
263
310
|
}
|
|
264
311
|
|
|
265
|
-
export function createLocalWhisperTranscriber(opts:
|
|
266
|
-
return new
|
|
312
|
+
export function createLocalWhisperTranscriber(opts: LocalSttOptions = {}): LocalSttTranscriber {
|
|
313
|
+
return new LocalSttTranscriber(opts);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function createLocalParakeetTranscriber(opts: LocalSttOptions = {}): LocalSttTranscriber {
|
|
317
|
+
return new LocalSttTranscriber({ ...opts, model: 'parakeet' });
|
|
267
318
|
}
|
package/src/models.test.ts
CHANGED
|
@@ -14,8 +14,45 @@ import {
|
|
|
14
14
|
describe('MODEL_CATALOG', () => {
|
|
15
15
|
const HF = 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-turbo/resolve/2ca6ff69fc878651b770880507669577ac41c2ff';
|
|
16
16
|
|
|
17
|
-
it('pins
|
|
18
|
-
expect(modelIds()).toEqual(['turbo']);
|
|
17
|
+
it('pins Whisper turbo and Parakeet, each read by its own transcriber', () => {
|
|
18
|
+
expect(modelIds()).toEqual(['turbo', 'parakeet']);
|
|
19
|
+
expect(findModel('turbo')!.transcriber).toBe('local-whisper');
|
|
20
|
+
expect(findModel('parakeet')!.transcriber).toBe('local-parakeet');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('pins Parakeet as its four int8 files on a fixed Hugging Face revision', () => {
|
|
24
|
+
const PARAKEET = 'https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/resolve/2bda32ec70b097a55adaa07d9a7173915b43cc78';
|
|
25
|
+
const parakeet = findModel('parakeet')!;
|
|
26
|
+
expect(modelFiles(parakeet)).toEqual([
|
|
27
|
+
{
|
|
28
|
+
name: 'encoder.int8.onnx',
|
|
29
|
+
url: `${PARAKEET}/encoder.int8.onnx`,
|
|
30
|
+
bytes: 652_184_281,
|
|
31
|
+
sha256: 'acfc2b4456377e15d04f0243af540b7fe7c992f8d898d751cf134c3a55fd2247',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'decoder.int8.onnx',
|
|
35
|
+
url: `${PARAKEET}/decoder.int8.onnx`,
|
|
36
|
+
bytes: 11_845_275,
|
|
37
|
+
sha256: '179e50c43d1a9de79c8a24149a2f9bac6eb5981823f2a2ed88d655b24248db4e',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'joiner.int8.onnx',
|
|
41
|
+
url: `${PARAKEET}/joiner.int8.onnx`,
|
|
42
|
+
bytes: 6_355_277,
|
|
43
|
+
sha256: '3164c13fc2821009440d20fcb5fdc78bff28b4db2f8d0f0b329101719c0948b3',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'tokens.txt',
|
|
47
|
+
url: `${PARAKEET}/tokens.txt`,
|
|
48
|
+
bytes: 93_939,
|
|
49
|
+
sha256: 'd58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d',
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
expect(modelDir(parakeet, '/m')).toBe(path.join('/m', 'parakeet'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('pins Whisper turbo as its three int8 files on a fixed Hugging Face revision', () => {
|
|
19
56
|
const turbo = findModel('turbo')!;
|
|
20
57
|
expect(turbo.encoder).toEqual({
|
|
21
58
|
name: 'turbo-encoder.int8.onnx',
|
|
@@ -71,8 +108,8 @@ describe('requireModel', () => {
|
|
|
71
108
|
expect.unreachable(`${removed} should be rejected`);
|
|
72
109
|
} catch (err) {
|
|
73
110
|
expect((err as { code?: string }).code).toBe('CONFIG_INVALID');
|
|
74
|
-
expect((err as Error).message).toMatch(/Unknown local
|
|
75
|
-
expect((err as { hint?: string }).hint).toBe('Valid models: turbo.');
|
|
111
|
+
expect((err as Error).message).toMatch(/Unknown local speech model/);
|
|
112
|
+
expect((err as { hint?: string }).hint).toBe('Valid models: turbo, parakeet.');
|
|
76
113
|
}
|
|
77
114
|
}
|
|
78
115
|
});
|
package/src/models.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pinned
|
|
2
|
+
* The pinned local speech model catalog.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Whisper large-v3 turbo is the multilingual sherpa-onnx int8 export. Its three
|
|
5
|
+
* files download as they are from Hugging Face, pinned to a fixed revision and
|
|
6
|
+
* checked against their size and sha256 — byte for byte the files the project's
|
|
7
|
+
* `asr-models` GitHub release packs into a `.tar.bz2` (verified 2026-10-24), but
|
|
8
|
+
* with no archive to unpack: unpacking it in JavaScript took about 67 s against
|
|
9
|
+
* 9 s for the download. Multilingual (not an `.en` export), so Polish and every
|
|
10
|
+
* other Whisper language work.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
* language
|
|
12
|
+
* Parakeet TDT 0.6B v3 is the sherpa-onnx int8 export of NVIDIA's transducer
|
|
13
|
+
* (CC-BY-4.0), pinned the same way. It works out the spoken language itself, in
|
|
14
|
+
* 25 languages, and takes no language hint.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import path from 'node:path';
|
|
@@ -18,25 +20,42 @@ import { MoxxyError } from '@moxxy/sdk';
|
|
|
18
20
|
import { moxxyPath } from '@moxxy/sdk/server';
|
|
19
21
|
import type { ModelFile } from '@moxxy/model-fetch/files';
|
|
20
22
|
|
|
21
|
-
export type
|
|
23
|
+
export type LocalSttModelId = 'turbo' | 'parakeet';
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
/** The transcriber that reads Whisper turbo. */
|
|
26
|
+
export const LOCAL_WHISPER_TRANSCRIBER_NAME = 'local-whisper';
|
|
27
|
+
/** The transcriber that reads Parakeet. */
|
|
28
|
+
export const LOCAL_PARAKEET_TRANSCRIBER_NAME = 'local-parakeet';
|
|
29
|
+
|
|
30
|
+
export interface LocalSttModelEntry {
|
|
24
31
|
/** Stable model id (also the `model` config value and its directory name). */
|
|
25
|
-
readonly id:
|
|
32
|
+
readonly id: LocalSttModelId;
|
|
26
33
|
/** Human label for UI surfaces / the first-use notice. */
|
|
27
34
|
readonly label: string;
|
|
35
|
+
/** The registered transcriber that reads this model. */
|
|
36
|
+
readonly transcriber: string;
|
|
28
37
|
readonly encoder: ModelFile;
|
|
29
38
|
readonly decoder: ModelFile;
|
|
39
|
+
/** Only a transducer has one; it is what tells the sidecar which engine to load. */
|
|
40
|
+
readonly joiner?: ModelFile;
|
|
30
41
|
readonly tokens: ModelFile;
|
|
42
|
+
/** Longer audio is read piece by piece, for a model whose memory grows with it. */
|
|
43
|
+
readonly maxChunkSeconds?: number;
|
|
44
|
+
/** Drop the model from memory after this long without use. */
|
|
45
|
+
readonly idleReleaseMs?: number;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
const TURBO_REVISION = '2ca6ff69fc878651b770880507669577ac41c2ff';
|
|
34
49
|
const TURBO_BASE = `https://huggingface.co/csukuangfj/sherpa-onnx-whisper-turbo/resolve/${TURBO_REVISION}`;
|
|
35
50
|
|
|
36
|
-
|
|
51
|
+
const PARAKEET_REVISION = '2bda32ec70b097a55adaa07d9a7173915b43cc78';
|
|
52
|
+
const PARAKEET_BASE = `https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/resolve/${PARAKEET_REVISION}`;
|
|
53
|
+
|
|
54
|
+
export const MODEL_CATALOG: readonly LocalSttModelEntry[] = [
|
|
37
55
|
{
|
|
38
56
|
id: 'turbo',
|
|
39
57
|
label: 'Whisper turbo (multilingual, large-v3 turbo)',
|
|
58
|
+
transcriber: LOCAL_WHISPER_TRANSCRIBER_NAME,
|
|
40
59
|
encoder: {
|
|
41
60
|
name: 'turbo-encoder.int8.onnx',
|
|
42
61
|
url: `${TURBO_BASE}/turbo-encoder.int8.onnx`,
|
|
@@ -56,15 +75,47 @@ export const MODEL_CATALOG: readonly WhisperModelEntry[] = [
|
|
|
56
75
|
sha256: 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
|
|
57
76
|
},
|
|
58
77
|
},
|
|
78
|
+
{
|
|
79
|
+
id: 'parakeet',
|
|
80
|
+
label: 'Parakeet TDT 0.6B v3',
|
|
81
|
+
transcriber: LOCAL_PARAKEET_TRANSCRIBER_NAME,
|
|
82
|
+
encoder: {
|
|
83
|
+
name: 'encoder.int8.onnx',
|
|
84
|
+
url: `${PARAKEET_BASE}/encoder.int8.onnx`,
|
|
85
|
+
bytes: 652_184_281,
|
|
86
|
+
sha256: 'acfc2b4456377e15d04f0243af540b7fe7c992f8d898d751cf134c3a55fd2247',
|
|
87
|
+
},
|
|
88
|
+
decoder: {
|
|
89
|
+
name: 'decoder.int8.onnx',
|
|
90
|
+
url: `${PARAKEET_BASE}/decoder.int8.onnx`,
|
|
91
|
+
bytes: 11_845_275,
|
|
92
|
+
sha256: '179e50c43d1a9de79c8a24149a2f9bac6eb5981823f2a2ed88d655b24248db4e',
|
|
93
|
+
},
|
|
94
|
+
joiner: {
|
|
95
|
+
name: 'joiner.int8.onnx',
|
|
96
|
+
url: `${PARAKEET_BASE}/joiner.int8.onnx`,
|
|
97
|
+
bytes: 6_355_277,
|
|
98
|
+
sha256: '3164c13fc2821009440d20fcb5fdc78bff28b4db2f8d0f0b329101719c0948b3',
|
|
99
|
+
},
|
|
100
|
+
tokens: {
|
|
101
|
+
name: 'tokens.txt',
|
|
102
|
+
url: `${PARAKEET_BASE}/tokens.txt`,
|
|
103
|
+
bytes: 93_939,
|
|
104
|
+
sha256: 'd58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d',
|
|
105
|
+
},
|
|
106
|
+
// Measured: 5 minutes in one go took 3 GB, and the model holds 850 MB at rest.
|
|
107
|
+
maxChunkSeconds: 60,
|
|
108
|
+
idleReleaseMs: 5 * 60_000,
|
|
109
|
+
},
|
|
59
110
|
];
|
|
60
111
|
|
|
61
112
|
/** Every file of a model, in download order. */
|
|
62
|
-
export function modelFiles(entry:
|
|
63
|
-
return [entry.encoder, entry.decoder, entry.tokens];
|
|
113
|
+
export function modelFiles(entry: LocalSttModelEntry): ModelFile[] {
|
|
114
|
+
return [entry.encoder, entry.decoder, ...(entry.joiner ? [entry.joiner] : []), entry.tokens];
|
|
64
115
|
}
|
|
65
116
|
|
|
66
117
|
/** The model's download size in bytes. */
|
|
67
|
-
export function modelBytes(entry:
|
|
118
|
+
export function modelBytes(entry: LocalSttModelEntry): number {
|
|
68
119
|
return modelFiles(entry).reduce((sum, file) => sum + file.bytes, 0);
|
|
69
120
|
}
|
|
70
121
|
|
|
@@ -74,30 +125,30 @@ export function defaultModelsDir(): string {
|
|
|
74
125
|
}
|
|
75
126
|
|
|
76
127
|
/** Where a model's files live under the models root. */
|
|
77
|
-
export function modelDir(entry:
|
|
128
|
+
export function modelDir(entry: LocalSttModelEntry, modelsDir: string): string {
|
|
78
129
|
return path.join(modelsDir, entry.id);
|
|
79
130
|
}
|
|
80
131
|
|
|
81
|
-
/** The model used when the caller doesn't pick one
|
|
82
|
-
export const DEFAULT_MODEL_ID:
|
|
132
|
+
/** The model used when the caller doesn't pick one. */
|
|
133
|
+
export const DEFAULT_MODEL_ID: LocalSttModelId = 'turbo';
|
|
83
134
|
|
|
84
135
|
/** All valid model ids, for error messages and validation. */
|
|
85
136
|
export function modelIds(): string[] {
|
|
86
137
|
return MODEL_CATALOG.map((m) => m.id);
|
|
87
138
|
}
|
|
88
139
|
|
|
89
|
-
export function findModel(id: string):
|
|
140
|
+
export function findModel(id: string): LocalSttModelEntry | undefined {
|
|
90
141
|
return MODEL_CATALOG.find((m) => m.id === id);
|
|
91
142
|
}
|
|
92
143
|
|
|
93
144
|
/** Resolve a model id to its catalog entry, or throw a clear, actionable error
|
|
94
145
|
* listing the valid ids. `field` names where the bad id came from. */
|
|
95
|
-
export function requireModel(id: string, field: string):
|
|
146
|
+
export function requireModel(id: string, field: string): LocalSttModelEntry {
|
|
96
147
|
const entry = findModel(id);
|
|
97
148
|
if (!entry) {
|
|
98
149
|
throw new MoxxyError({
|
|
99
150
|
code: 'CONFIG_INVALID',
|
|
100
|
-
message: `Unknown local
|
|
151
|
+
message: `Unknown local speech model ${JSON.stringify(id)} (${field}).`,
|
|
101
152
|
hint: `Valid models: ${modelIds().join(', ')}.`,
|
|
102
153
|
context: { field, model: id },
|
|
103
154
|
});
|