@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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-side manager for the sherpa sidecar: lazy-spawn on first transcribe,
|
|
3
|
+
* a correlated request/reply protocol over the fork IPC channel, restart-once
|
|
4
|
+
* on a crash, and a clean shutdown. The `fork` itself is injectable (a
|
|
5
|
+
* {@link ForkLike}) so tests drive the whole protocol against a fake child with
|
|
6
|
+
* no real process.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fork } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
import type { HostReply, HostRequest, TranscribeResult } from './host-protocol.js';
|
|
12
|
+
|
|
13
|
+
/** The minimal child-process surface the client drives — satisfied by a real
|
|
14
|
+
* `ChildProcess` and by a test fake. */
|
|
15
|
+
export interface ChildHandle {
|
|
16
|
+
send(message: unknown): boolean;
|
|
17
|
+
on(event: 'message', listener: (message: unknown) => void): this;
|
|
18
|
+
on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
|
19
|
+
on(event: 'error', listener: (err: Error) => void): this;
|
|
20
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fork the sidecar with the given env overrides. Injectable for tests. */
|
|
24
|
+
export type ForkLike = (modulePath: string, env: NodeJS.ProcessEnv) => ChildHandle;
|
|
25
|
+
|
|
26
|
+
/** Default fork: advanced serialization (Float32Array round-trip), an IPC
|
|
27
|
+
* channel, and inherited std streams so sherpa's own diagnostics reach the
|
|
28
|
+
* runner log. `env` already carries the platform loader-path var. */
|
|
29
|
+
export const defaultFork: ForkLike = (modulePath, env) =>
|
|
30
|
+
fork(modulePath, [], {
|
|
31
|
+
serialization: 'advanced',
|
|
32
|
+
env,
|
|
33
|
+
execArgv: [],
|
|
34
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
35
|
+
}) as unknown as ChildHandle;
|
|
36
|
+
|
|
37
|
+
export interface HostClientOptions {
|
|
38
|
+
/** Absolute path to the built sidecar (`dist/sidecar.js`). */
|
|
39
|
+
readonly hostPath: string;
|
|
40
|
+
/** Env the child is forked with (platform loader path merged over process.env). */
|
|
41
|
+
readonly env: NodeJS.ProcessEnv;
|
|
42
|
+
/** Injected fork (tests). Defaults to {@link defaultFork}. */
|
|
43
|
+
readonly forkImpl?: ForkLike;
|
|
44
|
+
/** Per-request deadline. Default 300s (a long clip on a big model on CPU). */
|
|
45
|
+
readonly requestTimeoutMs?: number;
|
|
46
|
+
/** Optional diagnostic sink. */
|
|
47
|
+
readonly log?: (msg: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The slice of a host the transcriber depends on — lets tests swap a fake. */
|
|
51
|
+
export interface HostClientLike {
|
|
52
|
+
transcribe(req: Omit<HostRequest, 'id' | 'type'>): Promise<TranscribeResult>;
|
|
53
|
+
shutdown(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Raised when the sidecar dies with a request in flight; drives the one retry. */
|
|
57
|
+
export class HostCrashError extends Error {
|
|
58
|
+
constructor(message: string) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'HostCrashError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Pending {
|
|
65
|
+
readonly resolve: (r: TranscribeResult) => void;
|
|
66
|
+
readonly reject: (err: Error) => void;
|
|
67
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
71
|
+
|
|
72
|
+
export class HostClient implements HostClientLike {
|
|
73
|
+
private readonly hostPath: string;
|
|
74
|
+
private readonly env: NodeJS.ProcessEnv;
|
|
75
|
+
private readonly forkImpl: ForkLike;
|
|
76
|
+
private readonly timeoutMs: number;
|
|
77
|
+
private readonly log: (msg: string) => void;
|
|
78
|
+
|
|
79
|
+
private child: ChildHandle | null = null;
|
|
80
|
+
private readonly pending = new Map<number, Pending>();
|
|
81
|
+
private nextId = 1;
|
|
82
|
+
private disposed = false;
|
|
83
|
+
|
|
84
|
+
constructor(opts: HostClientOptions) {
|
|
85
|
+
this.hostPath = opts.hostPath;
|
|
86
|
+
this.env = opts.env;
|
|
87
|
+
this.forkImpl = opts.forkImpl ?? defaultFork;
|
|
88
|
+
this.timeoutMs = opts.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
89
|
+
this.log = opts.log ?? (() => {});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Transcribe, restarting the sidecar ONCE if it crashes mid-request. */
|
|
93
|
+
async transcribe(req: Omit<HostRequest, 'id' | 'type'>): Promise<TranscribeResult> {
|
|
94
|
+
try {
|
|
95
|
+
return await this.send(req);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err instanceof HostCrashError && !this.disposed) {
|
|
98
|
+
this.log(`stt-local: sherpa sidecar crashed (${err.message}) — restarting once`);
|
|
99
|
+
return await this.send(req); // a second crash propagates
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private send(req: Omit<HostRequest, 'id' | 'type'>): Promise<TranscribeResult> {
|
|
106
|
+
if (this.disposed) return Promise.reject(new Error('stt-local host is shut down'));
|
|
107
|
+
const child = this.ensureChild();
|
|
108
|
+
const id = this.nextId++;
|
|
109
|
+
const message: HostRequest = { ...req, id, type: 'transcribe' };
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const timer = setTimeout(() => {
|
|
112
|
+
this.pending.delete(id);
|
|
113
|
+
// A hung decode is unrecoverable for this child — reset it so the next
|
|
114
|
+
// call starts fresh.
|
|
115
|
+
this.killChild();
|
|
116
|
+
reject(new Error(`stt-local transcription timed out after ${this.timeoutMs} ms`));
|
|
117
|
+
}, this.timeoutMs);
|
|
118
|
+
timer.unref?.();
|
|
119
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
120
|
+
try {
|
|
121
|
+
child.send(message);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
this.settle(id, () => reject(err instanceof Error ? err : new Error(String(err))));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private ensureChild(): ChildHandle {
|
|
129
|
+
if (this.child) return this.child;
|
|
130
|
+
const child = this.forkImpl(this.hostPath, this.env);
|
|
131
|
+
this.child = child;
|
|
132
|
+
child.on('message', (msg: unknown) => this.onMessage(msg));
|
|
133
|
+
child.on('exit', (code, signal) => this.onExit(code, signal));
|
|
134
|
+
child.on('error', (err) => this.onError(err));
|
|
135
|
+
return child;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private onMessage(msg: unknown): void {
|
|
139
|
+
const reply = msg as HostReply;
|
|
140
|
+
if (!reply || typeof reply !== 'object' || typeof reply.id !== 'number') return;
|
|
141
|
+
const p = this.pending.get(reply.id);
|
|
142
|
+
if (!p) return;
|
|
143
|
+
this.settle(reply.id, () => {
|
|
144
|
+
if (reply.ok) {
|
|
145
|
+
p.resolve(reply.language !== undefined ? { text: reply.text, language: reply.language } : { text: reply.text });
|
|
146
|
+
} else {
|
|
147
|
+
p.reject(new Error(`stt-local sidecar ${reply.error.kind} error: ${reply.error.message}`));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private onExit(code: number | null, signal: NodeJS.Signals | null): void {
|
|
153
|
+
this.child = null;
|
|
154
|
+
if (this.pending.size === 0) return;
|
|
155
|
+
const why = `sidecar exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`;
|
|
156
|
+
this.rejectAll(new HostCrashError(why));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private onError(err: Error): void {
|
|
160
|
+
this.child = null;
|
|
161
|
+
this.rejectAll(new HostCrashError(`sidecar spawn/runtime error: ${err.message}`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Resolve/reject one pending request, clearing its timer and map entry. */
|
|
165
|
+
private settle(id: number, run: () => void): void {
|
|
166
|
+
const p = this.pending.get(id);
|
|
167
|
+
if (!p) return;
|
|
168
|
+
if (p.timer) clearTimeout(p.timer);
|
|
169
|
+
this.pending.delete(id);
|
|
170
|
+
run();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private rejectAll(err: Error): void {
|
|
174
|
+
for (const [id, p] of this.pending) {
|
|
175
|
+
if (p.timer) clearTimeout(p.timer);
|
|
176
|
+
this.pending.delete(id);
|
|
177
|
+
p.reject(err);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private killChild(): void {
|
|
182
|
+
const child = this.child;
|
|
183
|
+
this.child = null;
|
|
184
|
+
if (child) {
|
|
185
|
+
try {
|
|
186
|
+
child.kill('SIGKILL');
|
|
187
|
+
} catch {
|
|
188
|
+
/* already gone */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Kill the sidecar and fail any in-flight requests. Idempotent. */
|
|
194
|
+
shutdown(): void {
|
|
195
|
+
if (this.disposed) return;
|
|
196
|
+
this.disposed = true;
|
|
197
|
+
this.rejectAll(new Error('stt-local host shut down'));
|
|
198
|
+
this.killChild();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createMessageHandler,
|
|
4
|
+
type HostRequest,
|
|
5
|
+
type SherpaModule,
|
|
6
|
+
type SherpaOfflineRecognizer,
|
|
7
|
+
type SherpaOfflineRecognizerResult,
|
|
8
|
+
type SherpaOfflineStream,
|
|
9
|
+
} from './host-protocol.js';
|
|
10
|
+
|
|
11
|
+
function req(over: Partial<HostRequest> = {}): HostRequest {
|
|
12
|
+
return {
|
|
13
|
+
id: 1,
|
|
14
|
+
type: 'transcribe',
|
|
15
|
+
modelKey: '/models/base/base-encoder.onnx',
|
|
16
|
+
encoder: '/models/base/base-encoder.onnx',
|
|
17
|
+
decoder: '/models/base/base-decoder.onnx',
|
|
18
|
+
tokens: '/models/base/base-tokens.txt',
|
|
19
|
+
numThreads: 2,
|
|
20
|
+
provider: 'cpu',
|
|
21
|
+
language: '',
|
|
22
|
+
task: 'transcribe',
|
|
23
|
+
samples: new Float32Array([0.1, -0.1, 0.2]),
|
|
24
|
+
sampleRate: 16_000,
|
|
25
|
+
...over,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A fake sherpa module recording constructions + decode calls, with
|
|
30
|
+
* configurable text/behaviour. */
|
|
31
|
+
function fakeSherpa(
|
|
32
|
+
opts: {
|
|
33
|
+
text?: string;
|
|
34
|
+
lang?: string;
|
|
35
|
+
throwOnConstruct?: boolean;
|
|
36
|
+
throwOnDecode?: boolean;
|
|
37
|
+
} = {},
|
|
38
|
+
): {
|
|
39
|
+
module: SherpaModule;
|
|
40
|
+
constructed: number;
|
|
41
|
+
decoded: number;
|
|
42
|
+
lastConfig: unknown;
|
|
43
|
+
lastSamples: Float32Array | null;
|
|
44
|
+
} {
|
|
45
|
+
const state = {
|
|
46
|
+
constructed: 0,
|
|
47
|
+
decoded: 0,
|
|
48
|
+
lastConfig: undefined as unknown,
|
|
49
|
+
lastSamples: null as Float32Array | null,
|
|
50
|
+
};
|
|
51
|
+
class FakeStream implements SherpaOfflineStream {
|
|
52
|
+
accepted: Float32Array | null = null;
|
|
53
|
+
acceptWaveform(args: { sampleRate: number; samples: Float32Array }): void {
|
|
54
|
+
this.accepted = args.samples;
|
|
55
|
+
state.lastSamples = args.samples;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
class FakeRecognizer implements SherpaOfflineRecognizer {
|
|
59
|
+
constructor(config: unknown) {
|
|
60
|
+
if (opts.throwOnConstruct) throw new Error('addon failed to load libonnxruntime.dylib');
|
|
61
|
+
state.constructed += 1;
|
|
62
|
+
state.lastConfig = config;
|
|
63
|
+
}
|
|
64
|
+
createStream(): SherpaOfflineStream {
|
|
65
|
+
return new FakeStream();
|
|
66
|
+
}
|
|
67
|
+
decode(): void {
|
|
68
|
+
state.decoded += 1;
|
|
69
|
+
if (opts.throwOnDecode) throw new Error('decode blew up');
|
|
70
|
+
}
|
|
71
|
+
getResult(): SherpaOfflineRecognizerResult {
|
|
72
|
+
return opts.lang !== undefined
|
|
73
|
+
? { text: opts.text ?? 'hello world', lang: opts.lang }
|
|
74
|
+
: { text: opts.text ?? 'hello world' };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
module: { OfflineRecognizer: FakeRecognizer as unknown as SherpaModule['OfflineRecognizer'] },
|
|
79
|
+
get constructed() {
|
|
80
|
+
return state.constructed;
|
|
81
|
+
},
|
|
82
|
+
get decoded() {
|
|
83
|
+
return state.decoded;
|
|
84
|
+
},
|
|
85
|
+
get lastConfig() {
|
|
86
|
+
return state.lastConfig;
|
|
87
|
+
},
|
|
88
|
+
get lastSamples() {
|
|
89
|
+
return state.lastSamples;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('createMessageHandler', () => {
|
|
95
|
+
it('transcribes and returns the text', async () => {
|
|
96
|
+
const s = fakeSherpa({ text: 'the quick brown fox' });
|
|
97
|
+
const handle = createMessageHandler(() => s.module);
|
|
98
|
+
const reply = await handle(req({ id: 7 }));
|
|
99
|
+
expect(reply).toMatchObject({ id: 7, ok: true, text: 'the quick brown fox' });
|
|
100
|
+
expect(s.lastSamples).toEqual(new Float32Array([0.1, -0.1, 0.2]));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('passes the whisper model config (encoder/decoder/tokens/language/task) through', async () => {
|
|
104
|
+
const s = fakeSherpa();
|
|
105
|
+
const handle = createMessageHandler(() => s.module);
|
|
106
|
+
await handle(req({ language: 'pl', task: 'transcribe' }));
|
|
107
|
+
expect(s.lastConfig).toMatchObject({
|
|
108
|
+
featConfig: { sampleRate: 16_000, featureDim: 80 },
|
|
109
|
+
modelConfig: {
|
|
110
|
+
whisper: {
|
|
111
|
+
encoder: '/models/base/base-encoder.onnx',
|
|
112
|
+
decoder: '/models/base/base-decoder.onnx',
|
|
113
|
+
language: 'pl',
|
|
114
|
+
task: 'transcribe',
|
|
115
|
+
},
|
|
116
|
+
tokens: '/models/base/base-tokens.txt',
|
|
117
|
+
numThreads: 2,
|
|
118
|
+
provider: 'cpu',
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('surfaces a detected language when the native result reports one', async () => {
|
|
124
|
+
const s = fakeSherpa({ text: 'cześć', lang: 'pl' });
|
|
125
|
+
const handle = createMessageHandler(() => s.module);
|
|
126
|
+
const reply = await handle(req());
|
|
127
|
+
expect(reply).toMatchObject({ ok: true, text: 'cześć', language: 'pl' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('caches one OfflineRecognizer per modelKey (loads the model once)', async () => {
|
|
131
|
+
const s = fakeSherpa();
|
|
132
|
+
let loads = 0;
|
|
133
|
+
const handle = createMessageHandler(() => {
|
|
134
|
+
loads += 1;
|
|
135
|
+
return s.module;
|
|
136
|
+
});
|
|
137
|
+
await handle(req({ id: 1 }));
|
|
138
|
+
await handle(req({ id: 2 }));
|
|
139
|
+
await handle(
|
|
140
|
+
req({ id: 3, modelKey: '/models/small/small-encoder.onnx', encoder: '/models/small/small-encoder.onnx' }),
|
|
141
|
+
);
|
|
142
|
+
expect(loads).toBe(1); // module loaded once
|
|
143
|
+
expect(s.constructed).toBe(2); // one recognizer per distinct modelKey
|
|
144
|
+
expect(s.decoded).toBe(3);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('classifies a construct failure as an init error', async () => {
|
|
148
|
+
const s = fakeSherpa({ throwOnConstruct: true });
|
|
149
|
+
const handle = createMessageHandler(() => s.module);
|
|
150
|
+
const reply = await handle(req());
|
|
151
|
+
expect(reply.ok).toBe(false);
|
|
152
|
+
if (!reply.ok) expect(reply.error.kind).toBe('init');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('classifies a load-time throw as an init error', async () => {
|
|
156
|
+
const handle = createMessageHandler(() => {
|
|
157
|
+
throw new Error('cannot find module sherpa-onnx-node');
|
|
158
|
+
});
|
|
159
|
+
const reply = await handle(req());
|
|
160
|
+
expect(reply.ok).toBe(false);
|
|
161
|
+
if (!reply.ok) expect(reply.error.kind).toBe('init');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('classifies a decode failure as a runtime error', async () => {
|
|
165
|
+
const s = fakeSherpa({ throwOnDecode: true });
|
|
166
|
+
const handle = createMessageHandler(() => s.module);
|
|
167
|
+
const reply = await handle(req());
|
|
168
|
+
expect(reply.ok).toBe(false);
|
|
169
|
+
if (!reply.ok) expect(reply.error.kind).toBe('runtime');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tiny message protocol spoken between the parent (host-client) and the
|
|
3
|
+
* forked sherpa sidecar, plus the pure per-message handler the sidecar runs.
|
|
4
|
+
* Kept free of `node:child_process` so BOTH sides can import it (the parent for
|
|
5
|
+
* the types, the child for the handler) without dragging fork plumbing into
|
|
6
|
+
* either bundle.
|
|
7
|
+
*
|
|
8
|
+
* Wire shape (over the fork IPC channel, `serialization: 'advanced'` so the
|
|
9
|
+
* `Float32Array` of samples round-trips intact):
|
|
10
|
+
* parent → child: TranscribeRequest (carries the decoded 16 kHz mono PCM)
|
|
11
|
+
* child → parent: HostReply (the transcript text)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** One transcription request. Carries the full model config so the sidecar is
|
|
15
|
+
* stateless-per-message except for a recognizer cache keyed by `modelKey`. The
|
|
16
|
+
* audio is already decoded to Float32 mono @ 16 kHz by the parent — the sidecar
|
|
17
|
+
* only owns the native recognition. */
|
|
18
|
+
export interface TranscribeRequest {
|
|
19
|
+
readonly id: number;
|
|
20
|
+
readonly type: 'transcribe';
|
|
21
|
+
/** Cache key for the loaded recognizer (the absolute encoder path). */
|
|
22
|
+
readonly modelKey: string;
|
|
23
|
+
/** Absolute path to the Whisper encoder `.onnx`. */
|
|
24
|
+
readonly encoder: string;
|
|
25
|
+
/** Absolute path to the Whisper decoder `.onnx`. */
|
|
26
|
+
readonly decoder: string;
|
|
27
|
+
/** Absolute path to `<id>-tokens.txt`. */
|
|
28
|
+
readonly tokens: string;
|
|
29
|
+
/** Inference thread count. */
|
|
30
|
+
readonly numThreads: number;
|
|
31
|
+
/** Compute provider (`cpu`). */
|
|
32
|
+
readonly provider: string;
|
|
33
|
+
/** Best-effort Whisper language tag; empty string ⇒ auto-detect. */
|
|
34
|
+
readonly language: string;
|
|
35
|
+
/** Whisper task: `transcribe` (same-language) or `translate` (→ English). */
|
|
36
|
+
readonly task: string;
|
|
37
|
+
/** Mono PCM in [-1, 1] at `sampleRate`; structured-cloned intact over IPC. */
|
|
38
|
+
readonly samples: Float32Array;
|
|
39
|
+
/** Sample rate of `samples` (16000 — the parent resamples before sending). */
|
|
40
|
+
readonly sampleRate: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type HostRequest = TranscribeRequest;
|
|
44
|
+
|
|
45
|
+
export type HostErrorKind =
|
|
46
|
+
/** Recognizer failed to load (bad/absent files, unsupported arch, dlopen). */
|
|
47
|
+
| 'init'
|
|
48
|
+
/** Decoding itself threw. */
|
|
49
|
+
| 'runtime';
|
|
50
|
+
|
|
51
|
+
export interface TranscribeResult {
|
|
52
|
+
readonly text: string;
|
|
53
|
+
/** Whisper's detected language, when the native result reports one. */
|
|
54
|
+
readonly language?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type HostReply =
|
|
58
|
+
| ({ readonly id: number; readonly ok: true } & TranscribeResult)
|
|
59
|
+
| {
|
|
60
|
+
readonly id: number;
|
|
61
|
+
readonly ok: false;
|
|
62
|
+
readonly error: { readonly message: string; readonly kind: HostErrorKind };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The subset of the sherpa-onnx-node surface the sidecar uses. Declared here
|
|
66
|
+
* (rather than leaning on the addon's ambient types) so the handler is
|
|
67
|
+
* unit-testable with a fake module and typechecks without the native addon. */
|
|
68
|
+
export interface SherpaOfflineStream {
|
|
69
|
+
acceptWaveform(args: { sampleRate: number; samples: Float32Array }): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SherpaOfflineRecognizerResult {
|
|
73
|
+
readonly text: string;
|
|
74
|
+
/** Whisper detected-language field on some builds; read defensively. */
|
|
75
|
+
readonly lang?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SherpaOfflineRecognizer {
|
|
79
|
+
createStream(): SherpaOfflineStream;
|
|
80
|
+
decode(stream: SherpaOfflineStream): void;
|
|
81
|
+
getResult(stream: SherpaOfflineStream): SherpaOfflineRecognizerResult;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SherpaModule {
|
|
85
|
+
OfflineRecognizer: new (config: unknown) => SherpaOfflineRecognizer;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Lazily load the native sherpa module (so a dlopen failure surfaces as a
|
|
89
|
+
* classified reply, not a boot crash). */
|
|
90
|
+
export type LoadSherpa = () => SherpaModule;
|
|
91
|
+
|
|
92
|
+
export type HostMessageHandler = (req: HostRequest) => Promise<HostReply>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the sidecar's message handler. Loads the sherpa module on first use and
|
|
96
|
+
* caches one `OfflineRecognizer` per `modelKey` so repeated calls on the same
|
|
97
|
+
* model reuse the loaded weights. Every failure becomes a typed `ok:false`
|
|
98
|
+
* reply — nothing throws out of `handle`.
|
|
99
|
+
*/
|
|
100
|
+
export function createMessageHandler(loadSherpa: LoadSherpa): HostMessageHandler {
|
|
101
|
+
const cache = new Map<string, SherpaOfflineRecognizer>();
|
|
102
|
+
let mod: SherpaModule | null = null;
|
|
103
|
+
|
|
104
|
+
return async function handle(req: HostRequest): Promise<HostReply> {
|
|
105
|
+
let recognizer = cache.get(req.modelKey);
|
|
106
|
+
if (!recognizer) {
|
|
107
|
+
try {
|
|
108
|
+
mod ??= loadSherpa();
|
|
109
|
+
recognizer = new mod.OfflineRecognizer({
|
|
110
|
+
featConfig: { sampleRate: 16_000, featureDim: 80 },
|
|
111
|
+
modelConfig: {
|
|
112
|
+
whisper: {
|
|
113
|
+
encoder: req.encoder,
|
|
114
|
+
decoder: req.decoder,
|
|
115
|
+
// `language`/`task` are present in the OfflineWhisperModelConfig
|
|
116
|
+
// types but exercised by no upstream example — pass them through
|
|
117
|
+
// best-effort (empty language ⇒ Whisper auto-detects anyway).
|
|
118
|
+
language: req.language,
|
|
119
|
+
task: req.task,
|
|
120
|
+
},
|
|
121
|
+
tokens: req.tokens,
|
|
122
|
+
numThreads: req.numThreads,
|
|
123
|
+
provider: req.provider,
|
|
124
|
+
debug: false,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
cache.set(req.modelKey, recognizer);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return { id: req.id, ok: false, error: { message: errMsg(err), kind: 'init' } };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const stream = recognizer.createStream();
|
|
135
|
+
stream.acceptWaveform({ sampleRate: req.sampleRate, samples: req.samples });
|
|
136
|
+
recognizer.decode(stream);
|
|
137
|
+
const result = recognizer.getResult(stream);
|
|
138
|
+
const text = typeof result?.text === 'string' ? result.text : '';
|
|
139
|
+
const reply: { id: number; ok: true } & TranscribeResult =
|
|
140
|
+
typeof result?.lang === 'string' && result.lang
|
|
141
|
+
? { id: req.id, ok: true, text, language: result.lang }
|
|
142
|
+
: { id: req.id, ok: true, text };
|
|
143
|
+
return reply;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { id: req.id, ok: false, error: { message: errMsg(err), kind: 'runtime' } };
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function errMsg(err: unknown): string {
|
|
151
|
+
return err instanceof Error ? err.message : String(err);
|
|
152
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { definePlugin, defineTranscriber, type Plugin } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createLocalWhisperTranscriber,
|
|
5
|
+
LOCAL_WHISPER_TRANSCRIBER_NAME,
|
|
6
|
+
shutdownLocalStt,
|
|
7
|
+
type LocalWhisperOptions,
|
|
8
|
+
} from './local-stt.js';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
LocalWhisperTranscriber,
|
|
12
|
+
createLocalWhisperTranscriber,
|
|
13
|
+
shutdownLocalStt,
|
|
14
|
+
LOCAL_WHISPER_TRANSCRIBER_NAME,
|
|
15
|
+
type LocalWhisperOptions,
|
|
16
|
+
type WhisperModelPaths,
|
|
17
|
+
} from './local-stt.js';
|
|
18
|
+
export {
|
|
19
|
+
MODEL_CATALOG,
|
|
20
|
+
DEFAULT_MODEL_ID,
|
|
21
|
+
modelIds,
|
|
22
|
+
findModel,
|
|
23
|
+
requireModel,
|
|
24
|
+
type WhisperModelEntry,
|
|
25
|
+
type WhisperModelId,
|
|
26
|
+
} from './models.js';
|
|
27
|
+
export {
|
|
28
|
+
pcm16ToFloat32,
|
|
29
|
+
downmixToMono,
|
|
30
|
+
resampleLinear,
|
|
31
|
+
parseWav,
|
|
32
|
+
isRiffWave,
|
|
33
|
+
TARGET_SAMPLE_RATE,
|
|
34
|
+
type ParsedWav,
|
|
35
|
+
} from './audio.js';
|
|
36
|
+
export { decodeToMono16k, type DecodeOptions } from './decode.js';
|
|
37
|
+
export {
|
|
38
|
+
decodeViaFfmpeg,
|
|
39
|
+
missingFfmpegError,
|
|
40
|
+
__resetFfmpegProbeForTest,
|
|
41
|
+
} from './ffmpeg.js';
|
|
42
|
+
export { HostClient, type HostClientLike, type ForkLike, type ChildHandle } from './host-client.js';
|
|
43
|
+
export {
|
|
44
|
+
sherpaPlatformPackage,
|
|
45
|
+
resolveSherpaLibDir,
|
|
46
|
+
sherpaEnv,
|
|
47
|
+
libraryPathVar,
|
|
48
|
+
} from './platform.js';
|
|
49
|
+
|
|
50
|
+
export interface BuildLocalSttPluginOptions {
|
|
51
|
+
/** Build-time defaults / test seams for the transcriber (injected
|
|
52
|
+
* `ensureModelImpl`, `hostFactory`, `fetchImpl`, `modelsDir`, `log`, …). */
|
|
53
|
+
readonly defaults?: LocalWhisperOptions;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the @moxxy/plugin-stt-local plugin. Registers exactly one transcriber,
|
|
58
|
+
* `local-whisper`, backed by sherpa-onnx Whisper models running in a forked
|
|
59
|
+
* sidecar. Side-effect free at load: no model is downloaded and no process is
|
|
60
|
+
* spawned until the first `transcribe`. Per-activation config
|
|
61
|
+
* (`session.transcribers.setActive('local-whisper', { model, language,
|
|
62
|
+
* numThreads })`) overrides the build-time defaults.
|
|
63
|
+
*
|
|
64
|
+
* Like the OpenAI STT sibling, the plugin does NOT set itself active —
|
|
65
|
+
* registering it is side-effect free (no auto-adopt in TranscriberRegistry).
|
|
66
|
+
* The host/user activates it explicitly, so a local-only setup never
|
|
67
|
+
* surprises anyone with a network call (there isn't one here) and a mixed
|
|
68
|
+
* setup keeps whatever STT backend was chosen.
|
|
69
|
+
*/
|
|
70
|
+
export function buildLocalSttPlugin(opts: BuildLocalSttPluginOptions = {}): Plugin {
|
|
71
|
+
const defaults = opts.defaults ?? {};
|
|
72
|
+
return definePlugin({
|
|
73
|
+
name: '@moxxy/plugin-stt-local',
|
|
74
|
+
version: '0.0.0',
|
|
75
|
+
transcribers: [
|
|
76
|
+
defineTranscriber({
|
|
77
|
+
name: LOCAL_WHISPER_TRANSCRIBER_NAME,
|
|
78
|
+
displayName: 'Local Whisper (offline, multilingual)',
|
|
79
|
+
createClient: (config: Record<string, unknown>) =>
|
|
80
|
+
createLocalWhisperTranscriber({ ...defaults, ...configToOptions(config) }),
|
|
81
|
+
}),
|
|
82
|
+
],
|
|
83
|
+
// Kill every spawned sidecar when the session/runner shuts down.
|
|
84
|
+
hooks: { onShutdown: () => shutdownLocalStt() },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Narrow the untrusted per-activation config into typed options — only the
|
|
89
|
+
* known string `model` / `language` / `provider` and a positive-integer
|
|
90
|
+
* `numThreads` are honored so a malformed config can't break `createClient` or
|
|
91
|
+
* route to a bad model (unknown ids are caught by `requireModel`). */
|
|
92
|
+
function configToOptions(config: Record<string, unknown>): Partial<LocalWhisperOptions> {
|
|
93
|
+
const out: { -readonly [K in keyof LocalWhisperOptions]?: LocalWhisperOptions[K] } = {};
|
|
94
|
+
if (typeof config.model === 'string' && config.model) out.model = config.model;
|
|
95
|
+
if (typeof config.language === 'string') out.language = config.language;
|
|
96
|
+
if (typeof config.provider === 'string' && config.provider) out.provider = config.provider;
|
|
97
|
+
if (
|
|
98
|
+
typeof config.numThreads === 'number' &&
|
|
99
|
+
Number.isInteger(config.numThreads) &&
|
|
100
|
+
config.numThreads > 0
|
|
101
|
+
) {
|
|
102
|
+
out.numThreads = config.numThreads;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
108
|
+
export default buildLocalSttPlugin();
|