@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.
- package/LICENSE +21 -0
- package/dist/audio.d.ts +55 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +197 -0
- package/dist/audio.js.map +1 -0
- package/dist/decode.d.ts +25 -0
- package/dist/decode.d.ts.map +1 -0
- package/dist/decode.js +50 -0
- package/dist/decode.js.map +1 -0
- package/dist/ffmpeg.d.ts +26 -0
- package/dist/ffmpeg.d.ts.map +1 -0
- package/dist/ffmpeg.js +207 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/host-client.d.ts +70 -0
- package/dist/host-client.d.ts.map +1 -0
- package/dist/host-client.js +156 -0
- package/dist/host-client.js.map +1 -0
- package/dist/host-protocol.d.ts +96 -0
- package/dist/host-protocol.d.ts.map +1 -0
- package/dist/host-protocol.js +70 -0
- package/dist/host-protocol.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/local-stt.d.ts +86 -0
- package/dist/local-stt.d.ts.map +1 -0
- package/dist/local-stt.js +201 -0
- package/dist/local-stt.js.map +1 -0
- package/dist/models.d.ts +47 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +61 -0
- package/dist/models.js.map +1 -0
- package/dist/platform.d.ts +37 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +96 -0
- package/dist/platform.js.map +1 -0
- package/dist/sidecar.d.ts +18 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +86 -0
- package/dist/sidecar.js.map +1 -0
- package/package.json +67 -0
- package/src/audio.test.ts +213 -0
- package/src/audio.ts +220 -0
- package/src/decode.test.ts +126 -0
- package/src/decode.ts +74 -0
- package/src/ffmpeg.test.ts +142 -0
- package/src/ffmpeg.ts +215 -0
- package/src/host-client.test.ts +208 -0
- package/src/host-client.ts +200 -0
- package/src/host-protocol.test.ts +171 -0
- package/src/host-protocol.ts +152 -0
- package/src/index.ts +108 -0
- package/src/live.test.ts +80 -0
- package/src/local-stt.test.ts +224 -0
- package/src/local-stt.ts +273 -0
- package/src/models.test.ts +58 -0
- package/src/models.ts +111 -0
- package/src/platform.test.ts +77 -0
- package/src/platform.ts +109 -0
- package/src/sidecar.ts +97 -0
package/src/models.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned Whisper model catalog.
|
|
3
|
+
*
|
|
4
|
+
* Every model is a multilingual sherpa-onnx Whisper export published as a
|
|
5
|
+
* `.tar.bz2` under the project's `asr-models` GitHub release. Each `sha256` is
|
|
6
|
+
* copied VERBATIM from that release's `checksum.txt` (re-verified 2026-07-05) —
|
|
7
|
+
* so a first-use download is content-addressed and tamper-evident, unlike an
|
|
8
|
+
* unverified blob fetch. The archive extracts to a single top directory
|
|
9
|
+
* (`archiveRootDir`) holding `<id>-encoder.onnx`, `<id>-decoder.onnx` (+ int8
|
|
10
|
+
* variants we don't use) and `<id>-tokens.txt`.
|
|
11
|
+
*
|
|
12
|
+
* All three are the MULTILINGUAL models (not the `.en` English-only exports),
|
|
13
|
+
* so Polish (and any other Whisper language) works — `small` is the accuracy
|
|
14
|
+
* sweet spot for Polish, `base` the balanced default, `tiny` the fastest.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { MoxxyError } from '@moxxy/sdk';
|
|
18
|
+
|
|
19
|
+
export type WhisperModelId = 'tiny' | 'base' | 'small';
|
|
20
|
+
|
|
21
|
+
export interface WhisperModelEntry {
|
|
22
|
+
/** Stable model id (also the `model` config value). */
|
|
23
|
+
readonly id: WhisperModelId;
|
|
24
|
+
/** Human label for UI surfaces / the first-use notice. */
|
|
25
|
+
readonly label: string;
|
|
26
|
+
/** Pinned archive URL (sherpa-onnx GitHub `asr-models` release). */
|
|
27
|
+
readonly url: string;
|
|
28
|
+
/** Pinned hex sha256 of the archive (from the release checksum.txt). */
|
|
29
|
+
readonly sha256: string;
|
|
30
|
+
/** Top directory the archive extracts to (`sherpa-onnx-whisper-<id>`). */
|
|
31
|
+
readonly archiveRootDir: string;
|
|
32
|
+
/** Encoder ONNX filename inside `archiveRootDir` (`<id>-encoder.onnx`). */
|
|
33
|
+
readonly encoderFile: string;
|
|
34
|
+
/** Decoder ONNX filename inside `archiveRootDir` (`<id>-decoder.onnx`). */
|
|
35
|
+
readonly decoderFile: string;
|
|
36
|
+
/** Tokens filename inside `archiveRootDir` (`<id>-tokens.txt`). */
|
|
37
|
+
readonly tokensFile: string;
|
|
38
|
+
/** Approximate download size in MB (for the one-time first-use notice). */
|
|
39
|
+
readonly approxMb: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const RELEASE_BASE = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models';
|
|
43
|
+
|
|
44
|
+
function whisperModel(
|
|
45
|
+
id: WhisperModelId,
|
|
46
|
+
label: string,
|
|
47
|
+
sha256: string,
|
|
48
|
+
approxMb: number,
|
|
49
|
+
): WhisperModelEntry {
|
|
50
|
+
const archiveRootDir = `sherpa-onnx-whisper-${id}`;
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
label,
|
|
54
|
+
url: `${RELEASE_BASE}/${archiveRootDir}.tar.bz2`,
|
|
55
|
+
sha256,
|
|
56
|
+
archiveRootDir,
|
|
57
|
+
encoderFile: `${id}-encoder.onnx`,
|
|
58
|
+
decoderFile: `${id}-decoder.onnx`,
|
|
59
|
+
tokensFile: `${id}-tokens.txt`,
|
|
60
|
+
approxMb,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const MODEL_CATALOG: readonly WhisperModelEntry[] = [
|
|
65
|
+
whisperModel(
|
|
66
|
+
'tiny',
|
|
67
|
+
'Whisper tiny (multilingual — fastest, lowest accuracy)',
|
|
68
|
+
'c46116994e539aa165266d96b325252728429c12535eb9d8b6a2b10f129e66b1',
|
|
69
|
+
111,
|
|
70
|
+
),
|
|
71
|
+
whisperModel(
|
|
72
|
+
'base',
|
|
73
|
+
'Whisper base (multilingual — balanced; default)',
|
|
74
|
+
'911b2083efd7c0dca2ac3b358b75222660dc09fb716d64fbfc417ba6c99ff3de',
|
|
75
|
+
198,
|
|
76
|
+
),
|
|
77
|
+
whisperModel(
|
|
78
|
+
'small',
|
|
79
|
+
'Whisper small (multilingual — best accuracy; recommended for Polish)',
|
|
80
|
+
'486a46afbb7ba798507190ffe02fea2dd726049af212e774537efac6afb210a6',
|
|
81
|
+
610,
|
|
82
|
+
),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/** Default model when the caller doesn't pick one. `base` balances size vs.
|
|
86
|
+
* accuracy; `small` is recommended for Polish (see the catalog description). */
|
|
87
|
+
export const DEFAULT_MODEL_ID: WhisperModelId = 'base';
|
|
88
|
+
|
|
89
|
+
/** All valid model ids, for error messages and validation. */
|
|
90
|
+
export function modelIds(): string[] {
|
|
91
|
+
return MODEL_CATALOG.map((m) => m.id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function findModel(id: string): WhisperModelEntry | undefined {
|
|
95
|
+
return MODEL_CATALOG.find((m) => m.id === id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolve a model id to its catalog entry, or throw a clear, actionable error
|
|
99
|
+
* listing the valid ids. `field` names where the bad id came from. */
|
|
100
|
+
export function requireModel(id: string, field: string): WhisperModelEntry {
|
|
101
|
+
const entry = findModel(id);
|
|
102
|
+
if (!entry) {
|
|
103
|
+
throw new MoxxyError({
|
|
104
|
+
code: 'CONFIG_INVALID',
|
|
105
|
+
message: `Unknown local Whisper model ${JSON.stringify(id)} (${field}).`,
|
|
106
|
+
hint: `Valid models: ${modelIds().join(', ')}.`,
|
|
107
|
+
context: { field, model: id },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return entry;
|
|
111
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
libraryPathVar,
|
|
5
|
+
resolveSherpaLibDir,
|
|
6
|
+
sherpaEnv,
|
|
7
|
+
sherpaPlatformPackage,
|
|
8
|
+
} from './platform.js';
|
|
9
|
+
|
|
10
|
+
describe('sherpaPlatformPackage', () => {
|
|
11
|
+
it.each([
|
|
12
|
+
['darwin', 'arm64', 'sherpa-onnx-darwin-arm64'],
|
|
13
|
+
['darwin', 'x64', 'sherpa-onnx-darwin-x64'],
|
|
14
|
+
['linux', 'x64', 'sherpa-onnx-linux-x64'],
|
|
15
|
+
['linux', 'arm64', 'sherpa-onnx-linux-arm64'],
|
|
16
|
+
['win32', 'x64', 'sherpa-onnx-win-x64'],
|
|
17
|
+
] as const)('maps %s-%s to %s', (platform, arch, pkg) => {
|
|
18
|
+
expect(sherpaPlatformPackage(platform as NodeJS.Platform, arch)).toBe(pkg);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns null for an unsupported platform/arch', () => {
|
|
22
|
+
expect(sherpaPlatformPackage('linux', 'ia32')).toBeNull();
|
|
23
|
+
expect(sherpaPlatformPackage('freebsd', 'x64')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('libraryPathVar', () => {
|
|
28
|
+
it('picks the platform dynamic-loader var, or null on Windows', () => {
|
|
29
|
+
expect(libraryPathVar('darwin')).toBe('DYLD_LIBRARY_PATH');
|
|
30
|
+
expect(libraryPathVar('linux')).toBe('LD_LIBRARY_PATH');
|
|
31
|
+
expect(libraryPathVar('win32')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('sherpaEnv', () => {
|
|
36
|
+
it('sets the loader var to the lib dir when none is present', () => {
|
|
37
|
+
expect(sherpaEnv('/libs', 'darwin', {})).toEqual({ DYLD_LIBRARY_PATH: '/libs' });
|
|
38
|
+
expect(sherpaEnv('/libs', 'linux', {})).toEqual({ LD_LIBRARY_PATH: '/libs' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('prepends the lib dir to an existing loader path', () => {
|
|
42
|
+
expect(sherpaEnv('/libs', 'darwin', { DYLD_LIBRARY_PATH: '/other' })).toEqual({
|
|
43
|
+
DYLD_LIBRARY_PATH: `/libs${path.delimiter}/other`,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns no env on Windows (DLLs resolve next to the addon)', () => {
|
|
48
|
+
expect(sherpaEnv('C:\\libs', 'win32', {})).toEqual({});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('resolveSherpaLibDir', () => {
|
|
53
|
+
it('returns the dirname of the resolved platform package.json', () => {
|
|
54
|
+
const fakeResolve = (req: string): string => {
|
|
55
|
+
expect(req).toBe('sherpa-onnx-darwin-arm64/package.json');
|
|
56
|
+
return '/store/sherpa-onnx-darwin-arm64/package.json';
|
|
57
|
+
};
|
|
58
|
+
expect(resolveSherpaLibDir('darwin', 'arm64', fakeResolve)).toBe('/store/sherpa-onnx-darwin-arm64');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns null when the platform package cannot be resolved', () => {
|
|
62
|
+
const throwing = (): string => {
|
|
63
|
+
throw new Error('MODULE_NOT_FOUND');
|
|
64
|
+
};
|
|
65
|
+
expect(resolveSherpaLibDir('darwin', 'arm64', throwing)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns null for an unsupported platform without calling resolve', () => {
|
|
69
|
+
let called = false;
|
|
70
|
+
const spy = (): string => {
|
|
71
|
+
called = true;
|
|
72
|
+
return 'x';
|
|
73
|
+
};
|
|
74
|
+
expect(resolveSherpaLibDir('sunos' as NodeJS.Platform, 'x64', spy)).toBeNull();
|
|
75
|
+
expect(called).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the sherpa-onnx platform binary package and the environment the
|
|
3
|
+
* sidecar must be spawned with.
|
|
4
|
+
*
|
|
5
|
+
* CRITICAL native gotcha (scout-verified): sherpa-onnx-node's addon dlopen's
|
|
6
|
+
* sibling shared libs (`libonnxruntime.dylib`, `libsherpa-onnx-c-api.dylib`, …)
|
|
7
|
+
* that live inside the per-platform package dir, and dyld/ld.so read
|
|
8
|
+
* `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH` ONCE at process start. So the addon
|
|
9
|
+
* only resolves its libs when that env var already points at the platform
|
|
10
|
+
* package dir when the process launches — which is exactly why the host runs in
|
|
11
|
+
* a freshly-forked child: the PARENT resolves the dir here and sets the var in
|
|
12
|
+
* the fork's env. On Windows the DLLs sit next to the `.node`, so no env var is
|
|
13
|
+
* needed.
|
|
14
|
+
*
|
|
15
|
+
* Shared verbatim with @moxxy/plugin-tts-local (deliberately duplicated rather
|
|
16
|
+
* than cross-imported so neither voice plugin depends on the other).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** `${platform}-${arch}` → npm package that ships the native addon + libs.
|
|
23
|
+
* Note the Windows package is `sherpa-onnx-win-x64` (renamed from win32-x64). */
|
|
24
|
+
const PLATFORM_PACKAGES: Readonly<Record<string, string>> = {
|
|
25
|
+
'darwin-arm64': 'sherpa-onnx-darwin-arm64',
|
|
26
|
+
'darwin-x64': 'sherpa-onnx-darwin-x64',
|
|
27
|
+
'linux-x64': 'sherpa-onnx-linux-x64',
|
|
28
|
+
'linux-arm64': 'sherpa-onnx-linux-arm64',
|
|
29
|
+
'win32-x64': 'sherpa-onnx-win-x64',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The platform binary package name for a host, or null if unsupported. */
|
|
33
|
+
export function sherpaPlatformPackage(
|
|
34
|
+
platform: NodeJS.Platform = process.platform,
|
|
35
|
+
arch: string = process.arch,
|
|
36
|
+
): string | null {
|
|
37
|
+
return PLATFORM_PACKAGES[`${platform}-${arch}`] ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A `require.resolve`-shaped function (injectable for tests). */
|
|
41
|
+
export type ResolveLike = (request: string) => string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A `require.resolve` anchored at sherpa-onnx-node itself.
|
|
45
|
+
*
|
|
46
|
+
* Load-bearing: sherpa's per-platform binary packages are its OWN
|
|
47
|
+
* `optionalDependencies`, so under pnpm's strict layout they are visible from
|
|
48
|
+
* sherpa-onnx-node's `node_modules` but NOT hoisted into this plugin's — a
|
|
49
|
+
* resolve anchored here would `MODULE_NOT_FOUND`. So we first resolve
|
|
50
|
+
* sherpa-onnx-node, then resolve the platform package from ITS context. Cached
|
|
51
|
+
* because it walks two package boundaries.
|
|
52
|
+
*/
|
|
53
|
+
let cachedSherpaResolve: ResolveLike | null | undefined;
|
|
54
|
+
function sherpaAnchoredResolve(): ResolveLike | null {
|
|
55
|
+
if (cachedSherpaResolve !== undefined) return cachedSherpaResolve;
|
|
56
|
+
try {
|
|
57
|
+
const pluginRequire = createRequire(import.meta.url);
|
|
58
|
+
const sherpaPkg = pluginRequire.resolve('sherpa-onnx-node/package.json');
|
|
59
|
+
cachedSherpaResolve = createRequire(sherpaPkg).resolve;
|
|
60
|
+
} catch {
|
|
61
|
+
cachedSherpaResolve = null;
|
|
62
|
+
}
|
|
63
|
+
return cachedSherpaResolve;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Absolute directory of the resolved platform package (which holds the `.node`
|
|
68
|
+
* addon and its sibling shared libraries), or null when the package can't be
|
|
69
|
+
* resolved (unsupported platform, or the optionalDependency didn't install).
|
|
70
|
+
* `resolve` is injectable for tests; the default is anchored at sherpa-onnx-node.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveSherpaLibDir(
|
|
73
|
+
platform: NodeJS.Platform = process.platform,
|
|
74
|
+
arch: string = process.arch,
|
|
75
|
+
resolve?: ResolveLike,
|
|
76
|
+
): string | null {
|
|
77
|
+
const pkg = sherpaPlatformPackage(platform, arch);
|
|
78
|
+
if (!pkg) return null;
|
|
79
|
+
const r = resolve ?? sherpaAnchoredResolve();
|
|
80
|
+
if (!r) return null;
|
|
81
|
+
try {
|
|
82
|
+
return path.dirname(r(`${pkg}/package.json`));
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The dynamic-loader env var name for a platform, or null on Windows. */
|
|
89
|
+
export function libraryPathVar(platform: NodeJS.Platform = process.platform): string | null {
|
|
90
|
+
if (platform === 'win32') return null;
|
|
91
|
+
return platform === 'darwin' ? 'DYLD_LIBRARY_PATH' : 'LD_LIBRARY_PATH';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the env overrides the sidecar must launch with: the platform loader var
|
|
96
|
+
* with `libDir` PREPENDED to any existing value. Returns `{}` on Windows (DLLs
|
|
97
|
+
* resolve next to the addon). `existing` defaults to `process.env`.
|
|
98
|
+
*/
|
|
99
|
+
export function sherpaEnv(
|
|
100
|
+
libDir: string,
|
|
101
|
+
platform: NodeJS.Platform = process.platform,
|
|
102
|
+
existing: NodeJS.ProcessEnv = process.env,
|
|
103
|
+
): NodeJS.ProcessEnv {
|
|
104
|
+
const varName = libraryPathVar(platform);
|
|
105
|
+
if (!varName) return {};
|
|
106
|
+
const prev = existing[varName];
|
|
107
|
+
const value = prev ? `${libDir}${path.delimiter}${prev}` : libDir;
|
|
108
|
+
return { [varName]: value };
|
|
109
|
+
}
|
package/src/sidecar.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sherpa-onnx STT sidecar — a forked child that owns the native sherpa addon.
|
|
4
|
+
*
|
|
5
|
+
* It exists as a SEPARATE process for one hard reason (see ./platform.ts): the
|
|
6
|
+
* addon's shared libraries only resolve when `DYLD_LIBRARY_PATH` /
|
|
7
|
+
* `LD_LIBRARY_PATH` points at the platform package dir AT PROCESS START, so the
|
|
8
|
+
* parent forks this with that env pre-set. Forking (not `spawn`) gives us a
|
|
9
|
+
* structured IPC channel; the parent uses `serialization: 'advanced'` so the
|
|
10
|
+
* `Float32Array` of samples round-trips without manual byte packing.
|
|
11
|
+
*
|
|
12
|
+
* Protocol: {@link ./host-protocol.ts}. This module is the thin runtime glue —
|
|
13
|
+
* lazily `require`ing the native module (a dlopen failure becomes a classified
|
|
14
|
+
* `init` reply rather than a boot crash), plus lifecycle self-termination so an
|
|
15
|
+
* orphaned sidecar never lingers.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
createMessageHandler,
|
|
23
|
+
type HostReply,
|
|
24
|
+
type HostRequest,
|
|
25
|
+
type SherpaModule,
|
|
26
|
+
} from './host-protocol.js';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
|
|
30
|
+
/** Lazy native load — deferred so a missing/broken addon is a caught, reported
|
|
31
|
+
* `init` error on the first transcribe, not an unhandled boot exception. */
|
|
32
|
+
function loadSherpa(): SherpaModule {
|
|
33
|
+
return require('sherpa-onnx-node') as SherpaModule;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function send(reply: HostReply): void {
|
|
37
|
+
// `process.send` is defined only when spawned with an IPC channel (our fork).
|
|
38
|
+
process.send?.(reply);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRequest(msg: unknown): msg is HostRequest {
|
|
42
|
+
return (
|
|
43
|
+
typeof msg === 'object' &&
|
|
44
|
+
msg !== null &&
|
|
45
|
+
(msg as { type?: unknown }).type === 'transcribe' &&
|
|
46
|
+
typeof (msg as { id?: unknown }).id === 'number'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Self-terminate if the parent runner disappears. `fork` delivers a
|
|
52
|
+
* `disconnect` event when the IPC channel closes (parent exit / explicit
|
|
53
|
+
* disconnect); belt-and-braces, poll the parent PID too — a hard SIGKILL of the
|
|
54
|
+
* parent may not always close the channel promptly.
|
|
55
|
+
*/
|
|
56
|
+
function armLifecycle(): void {
|
|
57
|
+
process.on('disconnect', () => process.exit(0));
|
|
58
|
+
const parentPid = process.ppid;
|
|
59
|
+
if (!parentPid || parentPid <= 1) return;
|
|
60
|
+
const timer = setInterval(() => {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(parentPid, 0); // existence probe, no signal delivered
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if ((err as NodeJS.ErrnoException).code === 'ESRCH') process.exit(0);
|
|
65
|
+
// EPERM ⇒ the process exists but we can't signal it — still alive.
|
|
66
|
+
}
|
|
67
|
+
}, 3000);
|
|
68
|
+
timer.unref?.();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function main(): void {
|
|
72
|
+
const handle = createMessageHandler(loadSherpa);
|
|
73
|
+
// Serialise requests: sherpa's OfflineRecognizer is not re-entrant, and
|
|
74
|
+
// processing one at a time keeps memory bounded under a burst of voice notes.
|
|
75
|
+
let queue: Promise<void> = Promise.resolve();
|
|
76
|
+
process.on('message', (msg: unknown) => {
|
|
77
|
+
if (!isRequest(msg)) return;
|
|
78
|
+
queue = queue
|
|
79
|
+
.then(async () => {
|
|
80
|
+
const reply = await handle(msg);
|
|
81
|
+
send(reply);
|
|
82
|
+
})
|
|
83
|
+
.catch((err) => {
|
|
84
|
+
send({
|
|
85
|
+
id: msg.id,
|
|
86
|
+
ok: false,
|
|
87
|
+
error: { message: err instanceof Error ? err.message : String(err), kind: 'runtime' },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
armLifecycle();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Run only when executed as the forked child, never when imported by a test.
|
|
95
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
96
|
+
main();
|
|
97
|
+
}
|