@moxxy/plugin-stt-local 0.28.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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/dist/audio.d.ts +55 -0
  3. package/dist/audio.d.ts.map +1 -0
  4. package/dist/audio.js +197 -0
  5. package/dist/audio.js.map +1 -0
  6. package/dist/decode.d.ts +25 -0
  7. package/dist/decode.d.ts.map +1 -0
  8. package/dist/decode.js +50 -0
  9. package/dist/decode.js.map +1 -0
  10. package/dist/ffmpeg.d.ts +26 -0
  11. package/dist/ffmpeg.d.ts.map +1 -0
  12. package/dist/ffmpeg.js +207 -0
  13. package/dist/ffmpeg.js.map +1 -0
  14. package/dist/host-client.d.ts +70 -0
  15. package/dist/host-client.d.ts.map +1 -0
  16. package/dist/host-client.js +156 -0
  17. package/dist/host-client.js.map +1 -0
  18. package/dist/host-protocol.d.ts +96 -0
  19. package/dist/host-protocol.d.ts.map +1 -0
  20. package/dist/host-protocol.js +70 -0
  21. package/dist/host-protocol.js.map +1 -0
  22. package/dist/index.d.ts +32 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +61 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/local-stt.d.ts +86 -0
  27. package/dist/local-stt.d.ts.map +1 -0
  28. package/dist/local-stt.js +201 -0
  29. package/dist/local-stt.js.map +1 -0
  30. package/dist/models.d.ts +47 -0
  31. package/dist/models.d.ts.map +1 -0
  32. package/dist/models.js +61 -0
  33. package/dist/models.js.map +1 -0
  34. package/dist/platform.d.ts +37 -0
  35. package/dist/platform.d.ts.map +1 -0
  36. package/dist/platform.js +96 -0
  37. package/dist/platform.js.map +1 -0
  38. package/dist/sidecar.d.ts +18 -0
  39. package/dist/sidecar.d.ts.map +1 -0
  40. package/dist/sidecar.js +86 -0
  41. package/dist/sidecar.js.map +1 -0
  42. package/package.json +67 -0
  43. package/src/audio.test.ts +213 -0
  44. package/src/audio.ts +220 -0
  45. package/src/decode.test.ts +126 -0
  46. package/src/decode.ts +74 -0
  47. package/src/ffmpeg.test.ts +142 -0
  48. package/src/ffmpeg.ts +215 -0
  49. package/src/host-client.test.ts +208 -0
  50. package/src/host-client.ts +200 -0
  51. package/src/host-protocol.test.ts +171 -0
  52. package/src/host-protocol.ts +152 -0
  53. package/src/index.ts +108 -0
  54. package/src/live.test.ts +80 -0
  55. package/src/local-stt.test.ts +224 -0
  56. package/src/local-stt.ts +273 -0
  57. package/src/models.test.ts +58 -0
  58. package/src/models.ts +111 -0
  59. package/src/platform.test.ts +77 -0
  60. package/src/platform.ts +109 -0
  61. package/src/sidecar.ts +97 -0
@@ -0,0 +1,80 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
7
+
8
+ import { createLocalWhisperTranscriber } from './local-stt.js';
9
+
10
+ // vitest runs the TypeScript SOURCE, so `import.meta.url` here is src/. The
11
+ // sidecar must be the COMPILED entry, so point at dist/sidecar.js (build first:
12
+ // `pnpm -F @moxxy/plugin-stt-local build`).
13
+ const SIDECAR_PATH = fileURLToPath(new URL('../dist/sidecar.js', import.meta.url));
14
+
15
+ /**
16
+ * Opt-in end-to-end test that REALLY downloads the tiny Whisper model and runs
17
+ * the native sherpa sidecar — off by default (it fetches ~111 MB and needs the
18
+ * platform binary). Run manually with:
19
+ *
20
+ * MOXXY_STT_LOCAL_LIVE=1 pnpm -F @moxxy/plugin-stt-local test
21
+ */
22
+ const LIVE = !!process.env.MOXXY_STT_LOCAL_LIVE;
23
+
24
+ /** Generate a 1-second 16 kHz mono PCM16 WAV of a 440 Hz tone as a fixture. The
25
+ * transcript of a pure tone is meaningless — the assertion is that the whole
26
+ * download → decode → recognize pipeline runs and yields a string. */
27
+ function toneWav(): Uint8Array {
28
+ const rate = 16_000;
29
+ const n = rate; // 1 s
30
+ const dataSize = n * 2;
31
+ const buf = new Uint8Array(44 + dataSize);
32
+ const view = new DataView(buf.buffer);
33
+ const ascii = (o: number, s: string): void => {
34
+ for (let i = 0; i < s.length; i += 1) buf[o + i] = s.charCodeAt(i);
35
+ };
36
+ ascii(0, 'RIFF');
37
+ view.setUint32(4, 36 + dataSize, true);
38
+ ascii(8, 'WAVE');
39
+ ascii(12, 'fmt ');
40
+ view.setUint32(16, 16, true);
41
+ view.setUint16(20, 1, true);
42
+ view.setUint16(22, 1, true);
43
+ view.setUint32(24, rate, true);
44
+ view.setUint32(28, rate * 2, true);
45
+ view.setUint16(32, 2, true);
46
+ view.setUint16(34, 16, true);
47
+ ascii(36, 'data');
48
+ view.setUint32(40, dataSize, true);
49
+ for (let i = 0; i < n; i += 1) {
50
+ const s = Math.sin((2 * Math.PI * 440 * i) / rate) * 0.3;
51
+ view.setInt16(44 + i * 2, Math.round(s * 32767), true);
52
+ }
53
+ return buf;
54
+ }
55
+
56
+ let modelsDir: string;
57
+ beforeAll(async () => {
58
+ if (!LIVE) return;
59
+ modelsDir = await mkdtemp(path.join(tmpdir(), 'stt-local-live-'));
60
+ });
61
+ afterAll(async () => {
62
+ if (modelsDir) await rm(modelsDir, { recursive: true, force: true });
63
+ });
64
+
65
+ describe.skipIf(!LIVE)('local STT (live, real download + native sidecar)', () => {
66
+ it('downloads the tiny model and transcribes a generated WAV fixture', async () => {
67
+ const stt = createLocalWhisperTranscriber({
68
+ model: 'tiny',
69
+ modelsDir,
70
+ sidecarPath: SIDECAR_PATH,
71
+ });
72
+ try {
73
+ const out = await stt.transcribe(toneWav(), { mimeType: 'audio/wav' });
74
+ expect(typeof out.text).toBe('string');
75
+ expect(out.durationSec).toBeCloseTo(1, 1);
76
+ } finally {
77
+ stt.shutdown();
78
+ }
79
+ }, 600_000);
80
+ });
@@ -0,0 +1,224 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { MOXXY_PCM16_24KHZ_MIME } from '@moxxy/sdk';
4
+ import { ensureModel } from '@moxxy/model-fetch';
5
+
6
+ import {
7
+ createLocalWhisperTranscriber,
8
+ type LocalWhisperOptions,
9
+ } from './local-stt.js';
10
+ import { buildLocalSttPlugin, LOCAL_WHISPER_TRANSCRIBER_NAME } from './index.js';
11
+ import type { HostClientLike } from './host-client.js';
12
+ import type { HostRequest, TranscribeResult } from './host-protocol.js';
13
+
14
+ /** Raw PCM16 mono little-endian bytes (the moxxy mic contract) for N samples. */
15
+ function rawPcm16(n: number, value = 0.3): Uint8Array {
16
+ const bytes = new Uint8Array(n * 2);
17
+ const view = new DataView(bytes.buffer);
18
+ for (let i = 0; i < n; i += 1) view.setInt16(i * 2, Math.round(value * 32767), true);
19
+ return bytes;
20
+ }
21
+
22
+ const AUDIO = rawPcm16(240); // 240 @24k → 160 @16k after resample
23
+
24
+ /** A fake host recording each transcribe request; returns a canned result. */
25
+ function fakeHost(result: TranscribeResult = { text: 'hello world' }): {
26
+ host: HostClientLike;
27
+ reqs: Array<Omit<HostRequest, 'id' | 'type'>>;
28
+ shutdowns: number;
29
+ } {
30
+ const state = { reqs: [] as Array<Omit<HostRequest, 'id' | 'type'>>, shutdowns: 0 };
31
+ const host: HostClientLike = {
32
+ async transcribe(req) {
33
+ state.reqs.push(req);
34
+ return result;
35
+ },
36
+ shutdown() {
37
+ state.shutdowns += 1;
38
+ },
39
+ };
40
+ return {
41
+ host,
42
+ get reqs() {
43
+ return state.reqs;
44
+ },
45
+ get shutdowns() {
46
+ return state.shutdowns;
47
+ },
48
+ };
49
+ }
50
+
51
+ /** A fake ensureModel recording the urls it was asked to fetch. */
52
+ function fakeEnsure(): { impl: typeof ensureModel; urls: string[] } {
53
+ const urls: string[] = [];
54
+ const impl = (async (opts: Parameters<typeof ensureModel>[0]) => {
55
+ urls.push(opts.url);
56
+ return { dir: opts.dir, skipped: false };
57
+ }) as unknown as typeof ensureModel;
58
+ return { impl, urls };
59
+ }
60
+
61
+ function makeTranscriber(
62
+ over: Partial<LocalWhisperOptions> = {},
63
+ result?: TranscribeResult,
64
+ ): {
65
+ transcriber: ReturnType<typeof createLocalWhisperTranscriber>;
66
+ host: ReturnType<typeof fakeHost>;
67
+ ensure: ReturnType<typeof fakeEnsure>;
68
+ } {
69
+ const host = fakeHost(result);
70
+ const ensure = fakeEnsure();
71
+ const transcriber = createLocalWhisperTranscriber({
72
+ modelsDir: '/models/stt',
73
+ ensureModelImpl: ensure.impl,
74
+ hostFactory: () => host.host,
75
+ log: () => {},
76
+ ...over,
77
+ });
78
+ return { transcriber, host, ensure };
79
+ }
80
+
81
+ describe('LocalWhisperTranscriber.transcribe', () => {
82
+ it('defaults to the base model, decodes raw PCM, and returns the transcript', async () => {
83
+ const { transcriber, host, ensure } = makeTranscriber();
84
+ const out = await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
85
+ expect(out.text).toBe('hello world');
86
+ expect(ensure.urls).toEqual([
87
+ 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-base.tar.bz2',
88
+ ]);
89
+ const req = host.reqs[0]!;
90
+ expect(req.encoder).toContain('sherpa-onnx-whisper-base/base-encoder.onnx');
91
+ expect(req.decoder).toContain('base-decoder.onnx');
92
+ expect(req.tokens).toContain('base-tokens.txt');
93
+ expect(req.sampleRate).toBe(16_000);
94
+ expect(req.samples.length).toBe(160); // resampled 24k → 16k
95
+ expect(req.task).toBe('transcribe');
96
+ expect(out.durationSec).toBeCloseTo(160 / 16_000, 6);
97
+ });
98
+
99
+ it('selects a configured model (small) and its files', async () => {
100
+ const { transcriber, host, ensure } = makeTranscriber({ model: 'small' });
101
+ await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
102
+ expect(ensure.urls[0]).toContain('sherpa-onnx-whisper-small.tar.bz2');
103
+ expect(host.reqs[0]!.encoder).toContain('small-encoder.onnx');
104
+ });
105
+
106
+ it('passes a per-call language hint through to the recognizer', async () => {
107
+ const { transcriber, host } = makeTranscriber();
108
+ await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME, language: 'pl' });
109
+ expect(host.reqs[0]!.language).toBe('pl');
110
+ });
111
+
112
+ it('falls back to the configured default language when no per-call hint is given', async () => {
113
+ const { transcriber, host } = makeTranscriber({ language: 'en' });
114
+ await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
115
+ expect(host.reqs[0]!.language).toBe('en');
116
+ });
117
+
118
+ it('sends an empty language (auto-detect) when neither is set', async () => {
119
+ const { transcriber, host } = makeTranscriber();
120
+ await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
121
+ expect(host.reqs[0]!.language).toBe('');
122
+ });
123
+
124
+ it('prefers the language the recognizer detected in the result', async () => {
125
+ const { transcriber } = makeTranscriber({ language: 'en' }, { text: 'cześć', language: 'pl' });
126
+ const out = await transcriber.transcribe(AUDIO, {
127
+ mimeType: MOXXY_PCM16_24KHZ_MIME,
128
+ language: 'en',
129
+ });
130
+ expect(out.language).toBe('pl');
131
+ });
132
+
133
+ it('reports the hint language when the recognizer returns none', async () => {
134
+ const { transcriber } = makeTranscriber({}, { text: 'x' });
135
+ const out = await transcriber.transcribe(AUDIO, {
136
+ mimeType: MOXXY_PCM16_24KHZ_MIME,
137
+ language: 'de',
138
+ });
139
+ expect(out.language).toBe('de');
140
+ });
141
+
142
+ it('downloads the model only once across calls', async () => {
143
+ const { transcriber, ensure } = makeTranscriber();
144
+ await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
145
+ await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
146
+ await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
147
+ expect(ensure.urls.length).toBe(1);
148
+ });
149
+
150
+ it('returns empty text for empty/silent audio without touching the model', async () => {
151
+ const { transcriber, host, ensure } = makeTranscriber();
152
+ const out = await transcriber.transcribe(new Uint8Array(0), { mimeType: MOXXY_PCM16_24KHZ_MIME });
153
+ expect(out.text).toBe('');
154
+ expect(ensure.urls.length).toBe(0);
155
+ expect(host.reqs.length).toBe(0);
156
+ });
157
+
158
+ it('respects a pre-aborted signal', async () => {
159
+ const { transcriber } = makeTranscriber();
160
+ const ac = new AbortController();
161
+ ac.abort(new Error('stop'));
162
+ await expect(
163
+ transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME, signal: ac.signal }),
164
+ ).rejects.toThrow(/stop/);
165
+ });
166
+
167
+ it('trims whitespace from the transcript', async () => {
168
+ const { transcriber } = makeTranscriber({}, { text: ' padded ' });
169
+ const out = await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
170
+ expect(out.text).toBe('padded');
171
+ });
172
+
173
+ it('shutdown tears down the host', async () => {
174
+ const { transcriber, host } = makeTranscriber();
175
+ await transcriber.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
176
+ transcriber.shutdown();
177
+ expect(host.shutdowns).toBe(1);
178
+ });
179
+ });
180
+
181
+ describe('constructor validation', () => {
182
+ it('throws CONFIG_INVALID for an unknown configured model', () => {
183
+ expect(() => createLocalWhisperTranscriber({ model: 'medium' })).toThrow(
184
+ /Unknown local Whisper model/,
185
+ );
186
+ });
187
+ });
188
+
189
+ describe('buildLocalSttPlugin', () => {
190
+ it('registers exactly one transcriber named local-whisper', () => {
191
+ const plugin = buildLocalSttPlugin();
192
+ expect(plugin.transcribers).toHaveLength(1);
193
+ const def = plugin.transcribers![0]!;
194
+ expect(def.name).toBe(LOCAL_WHISPER_TRANSCRIBER_NAME);
195
+ expect(def.displayName).toContain('Local Whisper');
196
+ });
197
+
198
+ it('createClient() flows per-activation model config through to routing', async () => {
199
+ const host = fakeHost();
200
+ const ensure = fakeEnsure();
201
+ const plugin = buildLocalSttPlugin({
202
+ defaults: {
203
+ modelsDir: '/models/stt',
204
+ ensureModelImpl: ensure.impl,
205
+ hostFactory: () => host.host,
206
+ log: () => {},
207
+ },
208
+ });
209
+ const inst = plugin.transcribers![0]!.createClient({ model: 'small', language: 'pl' });
210
+ await inst.transcribe(AUDIO, { mimeType: MOXXY_PCM16_24KHZ_MIME });
211
+ expect(ensure.urls[0]).toContain('sherpa-onnx-whisper-small.tar.bz2');
212
+ expect(host.reqs[0]!.language).toBe('pl');
213
+ });
214
+
215
+ it('createClient() rejects an unknown model in config at construction', () => {
216
+ const plugin = buildLocalSttPlugin();
217
+ expect(() => plugin.transcribers![0]!.createClient({ model: 'bad-id' })).toThrow();
218
+ });
219
+
220
+ it('exposes an onShutdown hook', () => {
221
+ const plugin = buildLocalSttPlugin();
222
+ expect(typeof plugin.hooks?.onShutdown).toBe('function');
223
+ });
224
+ });
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The `local-whisper` Transcriber: fully local, on-device speech-to-text.
3
+ *
4
+ * On the first transcription for a model it downloads + verifies that model's
5
+ * Whisper export (once, via @moxxy/model-fetch), then decodes the inbound audio
6
+ * to Float32 mono @ 16 kHz IN-PROCESS (raw PCM / WAV) or via ffmpeg (compressed
7
+ * containers) and hands the samples to the sherpa sidecar ({@link HostClient}).
8
+ * No key, no network at transcription time.
9
+ *
10
+ * Every collaborator is injectable (`ensureModelImpl`, `hostFactory`, `log`,
11
+ * `modelsDir`, `fetchImpl`, `spawnImpl`) so the whole flow is unit-testable
12
+ * without the native addon, a real download, or ffmpeg.
13
+ */
14
+
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import type { spawn } from 'node:child_process';
18
+
19
+ import {
20
+ MoxxyError,
21
+ type TranscribeOptions,
22
+ type Transcriber,
23
+ type TranscriptionResult,
24
+ } from '@moxxy/sdk';
25
+ import { moxxyPath } from '@moxxy/sdk/server';
26
+ import {
27
+ ensureModel,
28
+ type EnsureModelProgress,
29
+ type EnsureModelResult,
30
+ type FetchLike,
31
+ } from '@moxxy/model-fetch';
32
+
33
+ import { TARGET_SAMPLE_RATE } from './audio.js';
34
+ import { decodeToMono16k } from './decode.js';
35
+ import { HostClient, type HostClientLike } from './host-client.js';
36
+ import { DEFAULT_MODEL_ID, requireModel, type WhisperModelEntry } from './models.js';
37
+ import { resolveSherpaLibDir, sherpaEnv, sherpaPlatformPackage } from './platform.js';
38
+
39
+ /** The single registered transcriber name. */
40
+ export const LOCAL_WHISPER_TRANSCRIBER_NAME = 'local-whisper';
41
+
42
+ const DEFAULT_NUM_THREADS = 2;
43
+
44
+ /** Resolved on-disk paths sherpa needs for one model. */
45
+ export interface WhisperModelPaths {
46
+ readonly encoder: string;
47
+ readonly decoder: string;
48
+ readonly tokens: string;
49
+ }
50
+
51
+ export interface LocalWhisperOptions {
52
+ /** Model id: `tiny` | `base` | `small`. Default `base` (`small` for Polish). */
53
+ readonly model?: string;
54
+ /** Default Whisper language tag (BCP-47-ish, e.g. `en`, `pl`). Per-call
55
+ * `TranscribeOptions.language` overrides it; omit to let Whisper auto-detect. */
56
+ readonly language?: string;
57
+ /** Inference threads. Default 2. */
58
+ readonly numThreads?: number;
59
+ /** Root for downloaded models. Default `~/.moxxy/models/stt` (MOXXY_HOME-aware). */
60
+ readonly modelsDir?: string;
61
+ /** sherpa compute provider. Default `cpu`. */
62
+ readonly provider?: string;
63
+ /** Absolute path to the built sidecar entry. Default: `sidecar.js` beside
64
+ * this module (i.e. `dist/sidecar.js` in a published build). */
65
+ readonly sidecarPath?: string;
66
+ /** Injected model-ensure (tests). Defaults to @moxxy/model-fetch `ensureModel`. */
67
+ readonly ensureModelImpl?: typeof ensureModel;
68
+ /** Injected `fetch`, threaded into `ensureModel` (tests). */
69
+ readonly fetchImpl?: FetchLike;
70
+ /** Injected sidecar host factory (tests). Defaults to a real {@link HostClient}. */
71
+ readonly hostFactory?: () => HostClientLike;
72
+ /** Injected `spawn` for the ffmpeg decode path (tests). */
73
+ readonly spawnImpl?: typeof spawn;
74
+ /** Diagnostic log sink (download progress etc.). Defaults to stderr. */
75
+ readonly log?: (msg: string) => void;
76
+ }
77
+
78
+ /** Process-wide set of live transcribers so the plugin's `onShutdown` hook can
79
+ * kill every sidecar it spawned. */
80
+ const ACTIVE_STT = new Set<LocalWhisperTranscriber>();
81
+
82
+ /** Shut down every live local transcriber (kills their sidecars). */
83
+ export function shutdownLocalStt(): void {
84
+ for (const t of ACTIVE_STT) t.shutdown();
85
+ ACTIVE_STT.clear();
86
+ }
87
+
88
+ export class LocalWhisperTranscriber implements Transcriber {
89
+ readonly name = LOCAL_WHISPER_TRANSCRIBER_NAME;
90
+
91
+ private readonly model: WhisperModelEntry;
92
+ private readonly defaultLanguage: string;
93
+ private readonly numThreads: number;
94
+ private readonly modelsDir: string;
95
+ private readonly provider: string;
96
+ private readonly sidecarPath: string | undefined;
97
+ private readonly ensureModelImpl: typeof ensureModel;
98
+ private readonly fetchImpl: FetchLike | undefined;
99
+ private readonly hostFactory: () => HostClientLike;
100
+ private readonly spawnImpl: typeof spawn | undefined;
101
+ private readonly log: (msg: string) => void;
102
+
103
+ private host: HostClientLike | null = null;
104
+ /** In-flight model download promise — de-dupes concurrent first-use. */
105
+ private ensuring: Promise<EnsureModelResult> | null = null;
106
+
107
+ constructor(opts: LocalWhisperOptions = {}) {
108
+ // Validate the configured model up front so a bad config fails clearly at
109
+ // construction rather than on the first (possibly channel-triggered) call.
110
+ this.model = requireModel(opts.model ?? DEFAULT_MODEL_ID, 'model');
111
+ this.defaultLanguage = typeof opts.language === 'string' ? opts.language.trim() : '';
112
+ this.numThreads =
113
+ opts.numThreads && Number.isInteger(opts.numThreads) && opts.numThreads > 0
114
+ ? opts.numThreads
115
+ : DEFAULT_NUM_THREADS;
116
+ this.modelsDir = opts.modelsDir ?? moxxyPath('models', 'stt');
117
+ this.provider = opts.provider ?? 'cpu';
118
+ this.sidecarPath = opts.sidecarPath;
119
+ this.ensureModelImpl = opts.ensureModelImpl ?? ensureModel;
120
+ this.fetchImpl = opts.fetchImpl;
121
+ this.hostFactory = opts.hostFactory ?? (() => this.defaultHost());
122
+ this.spawnImpl = opts.spawnImpl;
123
+ this.log = opts.log ?? ((msg) => process.stderr.write(`${msg}\n`));
124
+ ACTIVE_STT.add(this);
125
+ }
126
+
127
+ async transcribe(
128
+ audio: Uint8Array | ArrayBuffer,
129
+ opts: TranscribeOptions = {},
130
+ ): Promise<TranscriptionResult> {
131
+ opts.signal?.throwIfAborted();
132
+
133
+ // Decode to the canonical Float32 mono @ 16 kHz sherpa wants. This is where
134
+ // raw PCM / WAV are handled in-process and compressed audio goes to ffmpeg.
135
+ const samples = await decodeToMono16k(audio, opts.mimeType, {
136
+ ...(this.spawnImpl ? { spawnImpl: this.spawnImpl } : {}),
137
+ });
138
+ opts.signal?.throwIfAborted();
139
+
140
+ if (samples.length === 0) {
141
+ return { text: '' };
142
+ }
143
+
144
+ const paths = await this.ensureModelFiles(opts.signal);
145
+ opts.signal?.throwIfAborted();
146
+
147
+ const language = ((opts.language ?? this.defaultLanguage) || '').trim();
148
+ const host = this.getHost();
149
+ const result = await host.transcribe({
150
+ modelKey: paths.encoder,
151
+ encoder: paths.encoder,
152
+ decoder: paths.decoder,
153
+ tokens: paths.tokens,
154
+ numThreads: this.numThreads,
155
+ provider: this.provider,
156
+ language,
157
+ task: 'transcribe',
158
+ samples,
159
+ sampleRate: TARGET_SAMPLE_RATE,
160
+ });
161
+
162
+ const durationSec = samples.length / TARGET_SAMPLE_RATE;
163
+ // Prefer Whisper's detected language; else the caller/default hint (when
164
+ // set) so downstream still gets a BCP-47 tag.
165
+ const reported = result.language ?? (language || undefined);
166
+ const out: {
167
+ text: string;
168
+ language?: string;
169
+ durationSec?: number;
170
+ } = { text: result.text.trim(), durationSec };
171
+ if (reported) out.language = reported;
172
+ return out;
173
+ }
174
+
175
+ /** Kill this transcriber's sidecar (if any) and drop registration. */
176
+ shutdown(): void {
177
+ this.host?.shutdown();
178
+ this.host = null;
179
+ ACTIVE_STT.delete(this);
180
+ }
181
+
182
+ private getHost(): HostClientLike {
183
+ this.host ??= this.hostFactory();
184
+ return this.host;
185
+ }
186
+
187
+ /** Resolve on-disk paths for the model, downloading + extracting it once. */
188
+ private async ensureModelFiles(signal?: AbortSignal): Promise<WhisperModelPaths> {
189
+ const dir = path.join(this.modelsDir, this.model.id);
190
+ let inflight = this.ensuring;
191
+ if (!inflight) {
192
+ inflight = this.ensureModelImpl({
193
+ url: this.model.url,
194
+ sha256: this.model.sha256,
195
+ dir,
196
+ ...(this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}),
197
+ ...(signal ? { signal } : {}),
198
+ onProgress: this.makeProgressLogger(),
199
+ });
200
+ this.ensuring = inflight;
201
+ // On failure, forget the promise so a later call retries the download.
202
+ inflight.catch(() => {
203
+ if (this.ensuring === inflight) this.ensuring = null;
204
+ });
205
+ }
206
+ await inflight;
207
+ const root = path.join(dir, this.model.archiveRootDir);
208
+ return {
209
+ encoder: path.join(root, this.model.encoderFile),
210
+ decoder: path.join(root, this.model.decoderFile),
211
+ tokens: path.join(root, this.model.tokensFile),
212
+ };
213
+ }
214
+
215
+ /** A progress callback that logs a single start line then ~every-10% ticks,
216
+ * and an extraction line — but stays silent when the model is already
217
+ * present (ensureModel emits only `done`). */
218
+ private makeProgressLogger(): (p: EnsureModelProgress) => void {
219
+ let started = false;
220
+ let extracting = false;
221
+ let lastPct = -1;
222
+ return (p) => {
223
+ if (p.phase === 'downloading') {
224
+ if (!started) {
225
+ started = true;
226
+ this.log(
227
+ `stt-local: first use — downloading Whisper ${this.model.id} (~${this.model.approxMb} MB) to ${this.modelsDir}, one-time`,
228
+ );
229
+ }
230
+ if (p.totalBytes > 0) {
231
+ const pct = Math.floor((p.receivedBytes / p.totalBytes) * 100);
232
+ if (pct >= lastPct + 10) {
233
+ lastPct = pct - (pct % 10);
234
+ this.log(`stt-local: downloading ${this.model.id} … ${lastPct}%`);
235
+ }
236
+ }
237
+ } else if (p.phase === 'extracting' && !extracting) {
238
+ extracting = true;
239
+ this.log(`stt-local: extracting ${this.model.id} …`);
240
+ } else if (p.phase === 'done' && started) {
241
+ this.log(`stt-local: ${this.model.id} ready.`);
242
+ }
243
+ };
244
+ }
245
+
246
+ /** Build a real sidecar-backed host, resolving the platform loader path.
247
+ * Fails clearly when no sherpa binary exists for this platform/arch. */
248
+ private defaultHost(): HostClientLike {
249
+ const libDir = resolveSherpaLibDir();
250
+ if (!libDir) {
251
+ const pkg = sherpaPlatformPackage();
252
+ throw new MoxxyError({
253
+ code: 'PLUGIN_LOAD_FAILED',
254
+ message: pkg
255
+ ? `Local Whisper could not load the sherpa-onnx binary (${pkg}). Reinstall @moxxy/plugin-stt-local so its platform dependency is fetched.`
256
+ : `Local Whisper has no sherpa-onnx binary for ${process.platform}-${process.arch}.`,
257
+ hint: 'Use the OpenAI Whisper backend instead, or run on a supported platform (macOS/Linux/Windows x64, macOS/Linux arm64).',
258
+ context: { platform: process.platform, arch: process.arch },
259
+ });
260
+ }
261
+ const hostPath =
262
+ this.sidecarPath ?? path.join(path.dirname(fileURLToPath(import.meta.url)), 'sidecar.js');
263
+ return new HostClient({
264
+ hostPath,
265
+ env: { ...process.env, ...sherpaEnv(libDir) },
266
+ log: this.log,
267
+ });
268
+ }
269
+ }
270
+
271
+ export function createLocalWhisperTranscriber(opts: LocalWhisperOptions = {}): LocalWhisperTranscriber {
272
+ return new LocalWhisperTranscriber(opts);
273
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ DEFAULT_MODEL_ID,
4
+ MODEL_CATALOG,
5
+ findModel,
6
+ modelIds,
7
+ requireModel,
8
+ } from './models.js';
9
+
10
+ describe('MODEL_CATALOG', () => {
11
+ it('pins tiny/base/small multilingual models with their release URLs + hashes', () => {
12
+ expect(modelIds()).toEqual(['tiny', 'base', 'small']);
13
+ for (const m of MODEL_CATALOG) {
14
+ expect(m.url).toBe(
15
+ `https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-${m.id}.tar.bz2`,
16
+ );
17
+ expect(m.archiveRootDir).toBe(`sherpa-onnx-whisper-${m.id}`);
18
+ expect(m.encoderFile).toBe(`${m.id}-encoder.onnx`);
19
+ expect(m.decoderFile).toBe(`${m.id}-decoder.onnx`);
20
+ expect(m.tokensFile).toBe(`${m.id}-tokens.txt`);
21
+ // 64-hex sha256, lower-case.
22
+ expect(m.sha256).toMatch(/^[0-9a-f]{64}$/);
23
+ }
24
+ });
25
+
26
+ it('records the scout-verified checksums verbatim', () => {
27
+ expect(findModel('tiny')!.sha256).toBe(
28
+ 'c46116994e539aa165266d96b325252728429c12535eb9d8b6a2b10f129e66b1',
29
+ );
30
+ expect(findModel('base')!.sha256).toBe(
31
+ '911b2083efd7c0dca2ac3b358b75222660dc09fb716d64fbfc417ba6c99ff3de',
32
+ );
33
+ expect(findModel('small')!.sha256).toBe(
34
+ '486a46afbb7ba798507190ffe02fea2dd726049af212e774537efac6afb210a6',
35
+ );
36
+ });
37
+
38
+ it('defaults to base', () => {
39
+ expect(DEFAULT_MODEL_ID).toBe('base');
40
+ expect(findModel(DEFAULT_MODEL_ID)).toBeDefined();
41
+ });
42
+ });
43
+
44
+ describe('requireModel', () => {
45
+ it('returns the catalog entry for a known id', () => {
46
+ expect(requireModel('small', 'model').id).toBe('small');
47
+ });
48
+
49
+ it('throws CONFIG_INVALID with the valid ids for an unknown model', () => {
50
+ expect(() => requireModel('medium', 'model')).toThrow(/Unknown local Whisper model/);
51
+ try {
52
+ requireModel('medium', 'model');
53
+ } catch (err) {
54
+ expect((err as { code?: string }).code).toBe('CONFIG_INVALID');
55
+ expect((err as { hint?: string }).hint).toContain('tiny, base, small');
56
+ }
57
+ });
58
+ });