@moxxy/plugin-stt-local 0.47.1 → 0.48.1

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.
@@ -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 LocalWhisperOptions,
10
+ type LocalSttOptions,
10
11
  } from './local-stt.js';
11
- import { buildLocalSttPlugin, LOCAL_WHISPER_TRANSCRIBER_NAME } from './index.js';
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<LocalWhisperOptions> = {},
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('LocalWhisperTranscriber.transcribe', () => {
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 Whisper model "small"/);
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 Whisper model/,
191
+ /Unknown local speech model/,
187
192
  );
188
193
  });
189
194
  });
190
195
 
191
196
  describe('buildLocalSttPlugin', () => {
192
- it('registers exactly one transcriber named local-whisper', () => {
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` Transcriber: fully local, on-device speech-to-text.
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
- * Whisper files (once, via @moxxy/model-fetch), then decodes the inbound audio
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 WhisperModelEntry,
43
+ type LocalSttModelEntry,
43
44
  } from './models.js';
44
45
  import { resolveSherpaLibDir, sherpaEnv, sherpaPlatformPackage } from './platform.js';
45
46
 
46
- /** The single registered transcriber name. */
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 WhisperModelPaths {
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 LocalWhisperOptions {
59
- /** Model id. The only one is `turbo`, also the default. */
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<LocalWhisperTranscriber>();
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 LocalWhisperTranscriber implements Transcriber {
96
- readonly name = LOCAL_WHISPER_TRANSCRIBER_NAME;
97
+ export class LocalSttTranscriber implements Transcriber {
98
+ readonly name: string;
97
99
 
98
- private readonly model: WhisperModelEntry;
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: LocalWhisperOptions = {}) {
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 host = this.getHost();
156
- const result = await host.transcribe({
157
- modelKey: paths.encoder,
158
- encoder: paths.encoder,
159
- decoder: paths.decoder,
160
- tokens: paths.tokens,
161
- numThreads: this.numThreads,
162
- provider: this.provider,
163
- language,
164
- task: 'transcribe',
165
- samples,
166
- sampleRate: TARGET_SAMPLE_RATE,
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 Whisper's detected language; else the caller/default hint (when
171
- // set) so downstream still gets a BCP-47 tag.
172
- const reported = result.language ?? (language || undefined);
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: result.text.trim(), durationSec };
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<WhisperModelPaths> {
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 Whisper ${this.model.id} (~${mb} MB) to ${this.modelsDir}, one-time`);
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 Whisper could not load the sherpa-onnx binary (${pkg}). Reinstall @moxxy/plugin-stt-local so its platform dependency is fetched.`
250
- : `Local Whisper has no sherpa-onnx binary for ${process.platform}-${process.arch}.`,
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: LocalWhisperOptions = {}): LocalWhisperTranscriber {
266
- return new LocalWhisperTranscriber(opts);
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
  }
@@ -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 only Whisper turbo, as its three int8 files on a fixed Hugging Face revision', () => {
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 Whisper model/);
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 Whisper model catalog.
2
+ * The pinned local speech model catalog.
3
3
  *
4
- * The one model is Whisper large-v3 turbo, the multilingual sherpa-onnx int8
5
- * export. Its three files download as they are from Hugging Face, pinned to a
6
- * fixed revision and checked against their size and sha256 — byte for byte the
7
- * files the project's `asr-models` GitHub release packs into a `.tar.bz2`
8
- * (verified 2026-10-24), but with no archive to unpack: unpacking it in
9
- * JavaScript took about 67 s against 9 s for the download.
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
- * Multilingual (not an `.en` export), so Polish and every other Whisper
12
- * language work.
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 WhisperModelId = 'turbo';
23
+ export type LocalSttModelId = 'turbo' | 'parakeet';
22
24
 
23
- export interface WhisperModelEntry {
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: WhisperModelId;
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
- export const MODEL_CATALOG: readonly WhisperModelEntry[] = [
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: WhisperModelEntry): ModelFile[] {
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: WhisperModelEntry): number {
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: WhisperModelEntry, modelsDir: string): string {
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 — the only one there is. */
82
- export const DEFAULT_MODEL_ID: WhisperModelId = 'turbo';
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): WhisperModelEntry | undefined {
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): WhisperModelEntry {
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 Whisper model ${JSON.stringify(id)} (${field}).`,
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
  });