@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
package/src/decode.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The single entry point that turns whatever audio a caller hands the local
3
+ * Whisper transcriber into the Float32 mono @ 16 kHz that sherpa wants. It
4
+ * dispatches on the MIME type (with a RIFF magic sniff as a backstop):
5
+ *
6
+ * - `MOXXY_PCM16_24KHZ_MIME` (the TUI/desktop mic) → int16→float32, then
7
+ * linear-resample 24 kHz → 16 kHz. Never touches ffmpeg.
8
+ * - `audio/wav` / `audio/x-wav`, or any buffer whose bytes start with the WAV
9
+ * magic → parse the RIFF header (PCM16 only), downmix, resample. No ffmpeg.
10
+ * - everything else (ogg/opus, mp3, m4a, webm, flac, …) → ffmpeg.
11
+ *
12
+ * The MIME string is UNTRUSTED (channels forward a messenger's `mime_type`
13
+ * verbatim), so it's runtime-guarded and canonicalized before matching.
14
+ */
15
+
16
+ import { MOXXY_PCM16_24KHZ_MIME } from '@moxxy/sdk';
17
+ import type { spawn } from 'node:child_process';
18
+
19
+ import {
20
+ downmixToMono,
21
+ isRiffWave,
22
+ parseWav,
23
+ pcm16ToFloat32,
24
+ resampleLinear,
25
+ TARGET_SAMPLE_RATE,
26
+ } from './audio.js';
27
+ import { decodeViaFfmpeg } from './ffmpeg.js';
28
+
29
+ /** The raw mic contract carries mono int16 @ this rate (see the SDK MIME tag). */
30
+ const MOXXY_PCM16_SAMPLE_RATE = 24_000;
31
+
32
+ export interface DecodeOptions {
33
+ /** Injected `spawn` for the ffmpeg path (tests). Defaults to real `spawn`. */
34
+ readonly spawnImpl?: typeof spawn;
35
+ }
36
+
37
+ /**
38
+ * Decode `audio` to Float32 mono PCM in [-1, 1] at 16 kHz. Throws a MoxxyError
39
+ * for malformed WAV (non-PCM16) or when ffmpeg is needed but missing.
40
+ */
41
+ export async function decodeToMono16k(
42
+ audio: Uint8Array | ArrayBuffer,
43
+ mimeType: string | undefined,
44
+ opts: DecodeOptions = {},
45
+ ): Promise<Float32Array> {
46
+ const bytes = audio instanceof Uint8Array ? audio : new Uint8Array(audio);
47
+ const mt = canonicalMime(mimeType);
48
+
49
+ // 1) Raw PCM16 mono @ 24 kHz from the moxxy mic recorder.
50
+ if (mt === MOXXY_PCM16_24KHZ_MIME) {
51
+ const mono = pcm16ToFloat32(bytes);
52
+ return resampleLinear(mono, MOXXY_PCM16_SAMPLE_RATE, TARGET_SAMPLE_RATE);
53
+ }
54
+
55
+ // 2) WAV — by declared MIME or by sniffing the RIFF/WAVE magic (a mislabelled
56
+ // or MIME-less WAV still decodes in-process).
57
+ if (mt === 'audio/wav' || mt === 'audio/x-wav' || mt === 'audio/wave' || isRiffWave(bytes)) {
58
+ const wav = parseWav(bytes);
59
+ const mono = wav.channels > 1 ? downmixToMono(wav.samples, wav.channels) : wav.samples;
60
+ return wav.sampleRate === TARGET_SAMPLE_RATE
61
+ ? mono
62
+ : resampleLinear(mono, wav.sampleRate, TARGET_SAMPLE_RATE);
63
+ }
64
+
65
+ // 3) Everything else is a compressed container → ffmpeg (already resamples to
66
+ // 16 kHz mono for us).
67
+ return decodeViaFfmpeg(bytes, opts.spawnImpl);
68
+ }
69
+
70
+ /** Lower-case, strip a `; codecs=…` parameter, trim. Non-string → ''. */
71
+ function canonicalMime(mimeType: string | undefined): string {
72
+ const raw = typeof mimeType === 'string' ? mimeType : '';
73
+ return raw.toLowerCase().split(';')[0]!.trim();
74
+ }
@@ -0,0 +1,142 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { EventEmitter } from 'node:events';
3
+ import type { spawn } from 'node:child_process';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+
6
+ import { __resetFfmpegProbeForTest, decodeViaFfmpeg, missingFfmpegError } from './ffmpeg.js';
7
+
8
+ /** A fake `spawn` that plays the ffmpeg probe (`-version`) and the decode
9
+ * (`f32le` pipe) roles. Configurable per-role so tests drive deterministic
10
+ * outcomes without a real ffmpeg. */
11
+ interface FakeSpawnConfig {
12
+ /** Probe: 'ok' → exit 0, 'fail' → exit 1, 'enoent' → emit spawn error. */
13
+ readonly probe?: 'ok' | 'fail' | 'enoent';
14
+ /** Decode stdout samples (encoded as f32le), or null to emit none. */
15
+ readonly decodeSamples?: Float32Array | null;
16
+ /** Decode exit code (default 0). */
17
+ readonly decodeExit?: number;
18
+ /** Decode stderr text. */
19
+ readonly decodeStderr?: string;
20
+ }
21
+
22
+ function makeFakeSpawn(config: FakeSpawnConfig): {
23
+ spawnImpl: typeof spawn;
24
+ probeCalls: number;
25
+ decodeCalls: number;
26
+ fedStdin: Buffer[];
27
+ } {
28
+ const stats = { probeCalls: 0, decodeCalls: 0, fedStdin: [] as Buffer[] };
29
+
30
+ const spawnImpl = ((_cmd: string, args: readonly string[]) => {
31
+ const child = new EventEmitter() as EventEmitter & {
32
+ stdout: EventEmitter;
33
+ stderr: EventEmitter;
34
+ stdin: { writable: boolean; on: () => void; end: (b?: Buffer) => void };
35
+ kill: () => boolean;
36
+ killed: boolean;
37
+ };
38
+ child.stdout = new EventEmitter();
39
+ child.stderr = new EventEmitter();
40
+ child.killed = false;
41
+ child.kill = () => {
42
+ child.killed = true;
43
+ return true;
44
+ };
45
+ const isProbe = args.includes('-version');
46
+ child.stdin = {
47
+ writable: true,
48
+ on: () => {},
49
+ end: (b?: Buffer) => {
50
+ if (b) stats.fedStdin.push(b);
51
+ },
52
+ };
53
+
54
+ if (isProbe) {
55
+ stats.probeCalls += 1;
56
+ setImmediate(() => {
57
+ if (config.probe === 'enoent') {
58
+ const err = Object.assign(new Error('spawn ffmpeg ENOENT'), { code: 'ENOENT' });
59
+ child.emit('error', err);
60
+ return;
61
+ }
62
+ child.emit('close', config.probe === 'fail' ? 1 : 0);
63
+ });
64
+ return child;
65
+ }
66
+
67
+ stats.decodeCalls += 1;
68
+ setImmediate(() => {
69
+ if (config.decodeStderr) child.stderr.emit('data', Buffer.from(config.decodeStderr));
70
+ const samples = config.decodeSamples;
71
+ if (samples && samples.length > 0) {
72
+ child.stdout.emit('data', Buffer.from(samples.buffer.slice(0)));
73
+ }
74
+ child.emit('close', config.decodeExit ?? 0);
75
+ });
76
+ return child;
77
+ }) as unknown as typeof spawn;
78
+
79
+ return {
80
+ spawnImpl,
81
+ get probeCalls() {
82
+ return stats.probeCalls;
83
+ },
84
+ get decodeCalls() {
85
+ return stats.decodeCalls;
86
+ },
87
+ get fedStdin() {
88
+ return stats.fedStdin;
89
+ },
90
+ };
91
+ }
92
+
93
+ afterEach(() => __resetFfmpegProbeForTest());
94
+
95
+ describe('decodeViaFfmpeg', () => {
96
+ it('decodes to Float32 samples via ffmpeg, feeding the input on stdin', async () => {
97
+ const expected = Float32Array.from([0, 0.25, -0.5, 0.999]);
98
+ const fake = makeFakeSpawn({ probe: 'ok', decodeSamples: expected });
99
+ const input = new Uint8Array([1, 2, 3, 4]);
100
+ const out = await decodeViaFfmpeg(input, fake.spawnImpl);
101
+ expect(Array.from(out)).toEqual(Array.from(expected));
102
+ expect(fake.probeCalls).toBe(1);
103
+ expect(fake.decodeCalls).toBe(1);
104
+ expect(fake.fedStdin[0]!.equals(Buffer.from(input))).toBe(true);
105
+ });
106
+
107
+ it('throws a clear PLUGIN_LOAD_FAILED error with an install hint when ffmpeg is missing', async () => {
108
+ const fake = makeFakeSpawn({ probe: 'enoent' });
109
+ await expect(decodeViaFfmpeg(new Uint8Array([1]), fake.spawnImpl)).rejects.toMatchObject({
110
+ code: 'PLUGIN_LOAD_FAILED',
111
+ });
112
+ expect(fake.decodeCalls).toBe(0); // never attempted the decode
113
+ });
114
+
115
+ it('surfaces a non-zero ffmpeg exit as an INTERNAL error carrying stderr', async () => {
116
+ const fake = makeFakeSpawn({
117
+ probe: 'ok',
118
+ decodeSamples: null,
119
+ decodeExit: 1,
120
+ decodeStderr: 'Invalid data found when processing input',
121
+ });
122
+ await expect(decodeViaFfmpeg(new Uint8Array([9]), fake.spawnImpl)).rejects.toMatchObject({
123
+ code: 'INTERNAL',
124
+ });
125
+ });
126
+
127
+ it('errors when ffmpeg exits 0 but produces no audio', async () => {
128
+ const fake = makeFakeSpawn({ probe: 'ok', decodeSamples: null, decodeExit: 0 });
129
+ await expect(decodeViaFfmpeg(new Uint8Array([9]), fake.spawnImpl)).rejects.toThrow(
130
+ /produced no audio/,
131
+ );
132
+ });
133
+ });
134
+
135
+ describe('missingFfmpegError', () => {
136
+ it('is a PLUGIN_LOAD_FAILED MoxxyError naming ffmpeg and an install command', () => {
137
+ const err = missingFfmpegError();
138
+ expect(err.code).toBe('PLUGIN_LOAD_FAILED');
139
+ expect(err.message).toMatch(/ffmpeg/i);
140
+ expect(err.hint).toMatch(/install/i);
141
+ });
142
+ });
package/src/ffmpeg.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The ffmpeg fallback for compressed audio (ogg/opus voice notes, mp3, m4a,
3
+ * webm, …) that we can't decode in-process. ffmpeg reads the container on stdin
4
+ * and writes raw 32-bit-float mono PCM at 16 kHz to stdout, which we read
5
+ * straight into a Float32Array — no intermediate WAV, no temp files.
6
+ *
7
+ * Gated behind a presence probe (mirrors channel-kit's voice-reply, cached per
8
+ * process; an injected `spawnImpl` bypasses the cache so tests are
9
+ * deterministic). When ffmpeg is absent we throw a clear, actionable MoxxyError
10
+ * — raw PCM16 and PCM16 WAV keep working without it, only compressed input
11
+ * needs it.
12
+ */
13
+
14
+ import { Buffer } from 'node:buffer';
15
+ import { spawn } from 'node:child_process';
16
+
17
+ import { getInstallHint, MoxxyError } from '@moxxy/sdk';
18
+
19
+ import { TARGET_SAMPLE_RATE } from './audio.js';
20
+
21
+ /** ffmpeg args: decode any input container → f32le mono @ 16 kHz on stdout. */
22
+ const DECODE_ARGS = [
23
+ '-hide_banner',
24
+ '-loglevel',
25
+ 'error',
26
+ '-i',
27
+ 'pipe:0',
28
+ '-f',
29
+ 'f32le',
30
+ '-ac',
31
+ '1',
32
+ '-ar',
33
+ String(TARGET_SAMPLE_RATE),
34
+ 'pipe:1',
35
+ ];
36
+
37
+ /** Ceiling on decoded PCM we buffer. At 16 kHz f32 mono (64 KB/s) this is ~30
38
+ * minutes — well past any voice note, and bounds an adversarial input. */
39
+ const MAX_DECODED_BYTES = 120 * 1024 * 1024;
40
+
41
+ const PROBE_TIMEOUT_MS = 1_500;
42
+ const DECODE_TIMEOUT_MS = 60_000;
43
+
44
+ /**
45
+ * Decode compressed audio bytes to Float32 mono @ 16 kHz via ffmpeg. Throws a
46
+ * `PLUGIN_LOAD_FAILED` MoxxyError (with an OS-specific install hint) when ffmpeg
47
+ * isn't on PATH, and an `INTERNAL` MoxxyError when ffmpeg runs but fails.
48
+ */
49
+ export async function decodeViaFfmpeg(
50
+ audio: Uint8Array,
51
+ spawnImpl?: typeof spawn,
52
+ ): Promise<Float32Array> {
53
+ const available = await probeFfmpeg(spawnImpl);
54
+ if (!available) throw missingFfmpegError();
55
+ const raw = await runDecode(audio, spawnImpl ?? spawn);
56
+ return f32leToFloat32(raw);
57
+ }
58
+
59
+ /** The user-facing error raised when compressed audio arrives but ffmpeg is
60
+ * absent. Exported so the decode orchestrator / tests can assert on it. */
61
+ export function missingFfmpegError(): MoxxyError {
62
+ const hint = getInstallHint('ffmpeg');
63
+ return new MoxxyError({
64
+ code: 'PLUGIN_LOAD_FAILED',
65
+ message:
66
+ 'Local Whisper needs ffmpeg to decode compressed audio (ogg/opus, mp3, m4a, webm). Raw PCM and 16-bit PCM WAV transcribe without it.',
67
+ hint: `Install ffmpeg via ${hint.manager}: \`${hint.command}\`.`,
68
+ });
69
+ }
70
+
71
+ // Process-cached ffmpeg availability (the probe spawns a subprocess; caching
72
+ // keeps a chatty voice channel from re-probing on every note). An injected
73
+ // `spawnImpl` (tests) bypasses the cache.
74
+ let ffmpegAvailable: Promise<boolean> | null = null;
75
+
76
+ async function probeFfmpeg(spawnImpl?: typeof spawn): Promise<boolean> {
77
+ if (spawnImpl) return runProbe(spawnImpl);
78
+ ffmpegAvailable ??= runProbe(spawn);
79
+ return ffmpegAvailable;
80
+ }
81
+
82
+ /** Reset the cached ffmpeg probe (tests only). */
83
+ export function __resetFfmpegProbeForTest(): void {
84
+ ffmpegAvailable = null;
85
+ }
86
+
87
+ function runProbe(spawnImpl: typeof spawn, command = 'ffmpeg'): Promise<boolean> {
88
+ return new Promise((resolve) => {
89
+ let settled = false;
90
+ const done = (v: boolean): void => {
91
+ if (settled) return;
92
+ settled = true;
93
+ clearTimeout(timer);
94
+ resolve(v);
95
+ };
96
+ let child: ReturnType<typeof spawn>;
97
+ try {
98
+ child = spawnImpl(command, ['-version'], { stdio: ['ignore', 'ignore', 'ignore'] });
99
+ } catch {
100
+ resolve(false);
101
+ return;
102
+ }
103
+ const timer = setTimeout(() => {
104
+ try {
105
+ if (!child.killed) child.kill('SIGKILL');
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ done(false);
110
+ }, PROBE_TIMEOUT_MS);
111
+ timer.unref?.();
112
+ child.once('error', () => done(false));
113
+ child.once('close', (code) => done(code === 0));
114
+ });
115
+ }
116
+
117
+ function runDecode(audio: Uint8Array, spawnImpl: typeof spawn, command = 'ffmpeg'): Promise<Buffer> {
118
+ return new Promise((resolve, reject) => {
119
+ let child: ReturnType<typeof spawn>;
120
+ try {
121
+ child = spawnImpl(command, DECODE_ARGS, { stdio: ['pipe', 'pipe', 'pipe'] });
122
+ } catch (err) {
123
+ reject(decodeFailure(err instanceof Error ? err.message : String(err)));
124
+ return;
125
+ }
126
+ const out: Buffer[] = [];
127
+ const errChunks: Buffer[] = [];
128
+ let outBytes = 0;
129
+ let over = false;
130
+ let settled = false;
131
+ const finish = (fn: () => void): void => {
132
+ if (settled) return;
133
+ settled = true;
134
+ clearTimeout(timer);
135
+ fn();
136
+ };
137
+ const timer = setTimeout(() => {
138
+ try {
139
+ if (!child.killed) child.kill('SIGKILL');
140
+ } catch {
141
+ /* ignore */
142
+ }
143
+ finish(() => reject(decodeFailure('ffmpeg decode timed out')));
144
+ }, DECODE_TIMEOUT_MS);
145
+ timer.unref?.();
146
+
147
+ child.stdout?.on('data', (c: Buffer) => {
148
+ if (over) return;
149
+ if (outBytes + c.byteLength > MAX_DECODED_BYTES) {
150
+ over = true;
151
+ try {
152
+ child.kill('SIGKILL');
153
+ } catch {
154
+ /* ignore */
155
+ }
156
+ finish(() => reject(decodeFailure('decoded audio exceeded the size limit')));
157
+ return;
158
+ }
159
+ outBytes += c.byteLength;
160
+ out.push(Buffer.from(c));
161
+ });
162
+ child.stderr?.on('data', (c: Buffer) => {
163
+ errChunks.push(Buffer.from(c));
164
+ while (Buffer.concat(errChunks).byteLength > 4_096) errChunks.shift();
165
+ });
166
+ child.once('error', (err) =>
167
+ finish(() => reject(decodeFailure(err instanceof Error ? err.message : String(err)))),
168
+ );
169
+ child.once('close', (code) =>
170
+ finish(() => {
171
+ if (over) return; // already rejected on the size cap
172
+ if (code === 0 && out.length > 0) {
173
+ resolve(Buffer.concat(out));
174
+ } else if (code === 0) {
175
+ reject(decodeFailure('ffmpeg produced no audio'));
176
+ } else {
177
+ reject(
178
+ decodeFailure(`ffmpeg exited ${code}: ${Buffer.concat(errChunks).toString('utf8').trim()}`),
179
+ );
180
+ }
181
+ }),
182
+ );
183
+
184
+ const stdin = child.stdin;
185
+ if (stdin) {
186
+ stdin.on('error', () => {
187
+ // EPIPE if ffmpeg died before consuming stdin — the close/error handler
188
+ // reports the real failure; swallow this to avoid an unhandled 'error'.
189
+ });
190
+ try {
191
+ stdin.end(Buffer.from(audio));
192
+ } catch {
193
+ /* the close/error path reports it */
194
+ }
195
+ }
196
+ });
197
+ }
198
+
199
+ /** Reinterpret little-endian float32 bytes as a Float32Array. Copies through a
200
+ * DataView so a non-4-aligned Buffer offset and big-endian hosts both work. */
201
+ function f32leToFloat32(buf: Buffer): Float32Array {
202
+ const usable = buf.byteLength - (buf.byteLength % 4);
203
+ const count = usable / 4;
204
+ const out = new Float32Array(count);
205
+ const view = new DataView(buf.buffer, buf.byteOffset, usable);
206
+ for (let i = 0; i < count; i += 1) out[i] = view.getFloat32(i * 4, true);
207
+ return out;
208
+ }
209
+
210
+ function decodeFailure(detail: string): MoxxyError {
211
+ return new MoxxyError({
212
+ code: 'INTERNAL',
213
+ message: `Local Whisper failed to decode audio with ffmpeg: ${detail}`,
214
+ });
215
+ }
@@ -0,0 +1,208 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { HostClient, type ChildHandle, type ForkLike } from './host-client.js';
3
+ import type { HostReply, HostRequest } from './host-protocol.js';
4
+
5
+ /** A controllable fake sidecar: records sent requests + kill, and lets the test
6
+ * drive `message`/`exit`/`error` back to the client. */
7
+ class FakeChild implements ChildHandle {
8
+ readonly sent: HostRequest[] = [];
9
+ killed = false;
10
+ private readonly listeners: Record<string, Array<(...a: unknown[]) => void>> = {
11
+ message: [],
12
+ exit: [],
13
+ error: [],
14
+ };
15
+
16
+ send(message: unknown): boolean {
17
+ this.sent.push(message as HostRequest);
18
+ return true;
19
+ }
20
+ on(event: string, listener: (...args: unknown[]) => void): this {
21
+ this.listeners[event]!.push(listener);
22
+ return this;
23
+ }
24
+ kill(): boolean {
25
+ this.killed = true;
26
+ return true;
27
+ }
28
+
29
+ private emit(event: string, ...args: unknown[]): void {
30
+ for (const cb of [...this.listeners[event]!]) cb(...args);
31
+ }
32
+ /** Reply to the last request the client sent. */
33
+ replyOk(text: string, language?: string): void {
34
+ this.replyOkTo(this.sent.at(-1)!.id, text, language);
35
+ }
36
+ /** Reply to a specific request id (drives out-of-order correlation tests). */
37
+ replyOkTo(id: number, text: string, language?: string): void {
38
+ const reply: HostReply =
39
+ language !== undefined ? { id, ok: true, text, language } : { id, ok: true, text };
40
+ this.emit('message', reply);
41
+ }
42
+ replyErr(kind: 'init' | 'runtime', message: string): void {
43
+ const id = this.sent.at(-1)!.id;
44
+ this.emit('message', { id, ok: false, error: { kind, message } } satisfies HostReply);
45
+ }
46
+ crash(code = 1): void {
47
+ this.emit('exit', code, null);
48
+ }
49
+ errorOut(message: string): void {
50
+ this.emit('error', new Error(message));
51
+ }
52
+ }
53
+
54
+ /** A fork factory handing out a fixed list of children, tracking call count. */
55
+ function forkFactory(children: FakeChild[]): { fork: ForkLike; calls: () => number } {
56
+ let n = 0;
57
+ const fork: ForkLike = () => {
58
+ const child = children[n];
59
+ n += 1;
60
+ if (!child) throw new Error(`unexpected fork #${n}`);
61
+ return child;
62
+ };
63
+ return { fork, calls: () => n };
64
+ }
65
+
66
+ const REQ = {
67
+ modelKey: '/m/base-encoder.onnx',
68
+ encoder: '/m/base-encoder.onnx',
69
+ decoder: '/m/base-decoder.onnx',
70
+ tokens: '/m/base-tokens.txt',
71
+ numThreads: 2,
72
+ provider: 'cpu',
73
+ language: '',
74
+ task: 'transcribe',
75
+ samples: new Float32Array([0.1, -0.1]),
76
+ sampleRate: 16_000,
77
+ };
78
+
79
+ const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
80
+
81
+ describe('HostClient', () => {
82
+ it('does not fork until the first transcribe (lazy spawn)', async () => {
83
+ const child = new FakeChild();
84
+ const { fork, calls } = forkFactory([child]);
85
+ const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
86
+ expect(calls()).toBe(0);
87
+ const p = host.transcribe(REQ);
88
+ await tick();
89
+ expect(calls()).toBe(1);
90
+ child.replyOk('hello', 'en');
91
+ await expect(p).resolves.toEqual({ text: 'hello', language: 'en' });
92
+ host.shutdown();
93
+ });
94
+
95
+ it('resolves text-only replies without a language field', async () => {
96
+ const child = new FakeChild();
97
+ const { fork } = forkFactory([child]);
98
+ const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
99
+ const p = host.transcribe(REQ);
100
+ await tick();
101
+ child.replyOk('just text');
102
+ await expect(p).resolves.toEqual({ text: 'just text' });
103
+ host.shutdown();
104
+ });
105
+
106
+ it('correlates replies by id and reuses one child across calls', async () => {
107
+ const child = new FakeChild();
108
+ const { fork, calls } = forkFactory([child]);
109
+ const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
110
+
111
+ const p1 = host.transcribe({ ...REQ, samples: new Float32Array([1]) });
112
+ await tick();
113
+ const p2 = host.transcribe({ ...REQ, samples: new Float32Array([2]) });
114
+ await tick();
115
+ expect(child.sent.map((m) => m.id)).toEqual([1, 2]);
116
+ expect(calls()).toBe(1);
117
+ // Reply out of order — id correlation must still settle each promise.
118
+ child.replyOkTo(2, 'two');
119
+ child.replyOkTo(1, 'one');
120
+ await expect(p1).resolves.toEqual({ text: 'one' });
121
+ await expect(p2).resolves.toEqual({ text: 'two' });
122
+ host.shutdown();
123
+ });
124
+
125
+ it('rejects a transcription error reply', async () => {
126
+ const child = new FakeChild();
127
+ const { fork } = forkFactory([child]);
128
+ const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
129
+ const p = host.transcribe(REQ);
130
+ await tick();
131
+ child.replyErr('runtime', 'boom');
132
+ await expect(p).rejects.toThrow(/runtime error: boom/);
133
+ host.shutdown();
134
+ });
135
+
136
+ it('restarts the sidecar once on a crash and completes on the fresh child', async () => {
137
+ const first = new FakeChild();
138
+ const second = new FakeChild();
139
+ const { fork, calls } = forkFactory([first, second]);
140
+ const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
141
+
142
+ const p = host.transcribe(REQ);
143
+ await tick();
144
+ expect(calls()).toBe(1);
145
+ first.crash(139); // dies with the request in flight
146
+ await tick();
147
+ expect(calls()).toBe(2);
148
+ second.replyOk('recovered');
149
+ await expect(p).resolves.toEqual({ text: 'recovered' });
150
+ host.shutdown();
151
+ });
152
+
153
+ it('gives up after a second consecutive crash', async () => {
154
+ const first = new FakeChild();
155
+ const second = new FakeChild();
156
+ const { fork } = forkFactory([first, second]);
157
+ const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
158
+ const p = host.transcribe(REQ);
159
+ await tick();
160
+ first.crash();
161
+ await tick();
162
+ second.crash();
163
+ await expect(p).rejects.toThrow(/sidecar exited/);
164
+ host.shutdown();
165
+ });
166
+
167
+ it('treats a spawn error as a crash', async () => {
168
+ const child = new FakeChild();
169
+ const second = new FakeChild();
170
+ const { fork } = forkFactory([child, second]);
171
+ const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
172
+ const p = host.transcribe(REQ);
173
+ await tick();
174
+ child.errorOut('ENOENT node');
175
+ await tick();
176
+ second.replyOk('after respawn');
177
+ await expect(p).resolves.toEqual({ text: 'after respawn' });
178
+ host.shutdown();
179
+ });
180
+
181
+ it('times out a hung transcription and kills the child', async () => {
182
+ const child = new FakeChild();
183
+ const { fork } = forkFactory([child]);
184
+ const host = new HostClient({
185
+ hostPath: '/x/sidecar.js',
186
+ env: {},
187
+ forkImpl: fork,
188
+ requestTimeoutMs: 10,
189
+ });
190
+ const p = host.transcribe(REQ);
191
+ await expect(p).rejects.toThrow(/timed out/);
192
+ expect(child.killed).toBe(true);
193
+ host.shutdown();
194
+ });
195
+
196
+ it('shutdown kills the child and rejects in-flight requests', async () => {
197
+ const child = new FakeChild();
198
+ const { fork } = forkFactory([child]);
199
+ const host = new HostClient({ hostPath: '/x/sidecar.js', env: {}, forkImpl: fork });
200
+ const p = host.transcribe(REQ);
201
+ await tick();
202
+ host.shutdown();
203
+ expect(child.killed).toBe(true);
204
+ await expect(p).rejects.toThrow(/shut down/);
205
+ // Further calls fail fast.
206
+ await expect(host.transcribe(REQ)).rejects.toThrow(/shut down/);
207
+ });
208
+ });