@lmk1010/speech-streaming-sherpa 0.0.0-stage → 0.2.2-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/lib/index.js +588 -0
- package/lib/types/config.d.ts +45 -0
- package/lib/types/config.js +23 -0
- package/lib/types/engine.d.ts +65 -0
- package/lib/types/engine.js +101 -0
- package/lib/types/index.d.ts +12 -0
- package/lib/types/index.js +28 -0
- package/lib/types/models.d.ts +44 -0
- package/lib/types/models.js +129 -0
- package/lib/types/provider.d.ts +51 -0
- package/lib/types/provider.js +291 -0
- package/lib/types/worker.d.ts +2 -0
- package/lib/types/worker.js +71 -0
- package/lib/worker.js +243 -0
- package/package.json +49 -3
- package/runtime/assets.json +5 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { isAbsolute, join } from "node:path";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { Agent, request } from "node:http";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { MAX_TIMER_DELAY_MS, deadline } from "@deepseek-ai/dsh-timeout";
|
|
7
|
+
import { Channel } from "@lmk1010/speech-streaming";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { createReadStream, createWriteStream, readFileSync } from "node:fs";
|
|
10
|
+
import { Transform } from "node:stream";
|
|
11
|
+
import { pipeline } from "node:stream/promises";
|
|
12
|
+
import z$1 from "@deepseek-ai/schemastery";
|
|
13
|
+
//#region lib/types/models.js
|
|
14
|
+
/** Pinned model files: inspection, verified download with origin fallback, and atomic publication. */
|
|
15
|
+
/** Locked model files shipped with the package. */
|
|
16
|
+
function readLock() {
|
|
17
|
+
return JSON.parse(readFileSync(new URL("../runtime/assets.json", import.meta.url), "utf8"));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve model file locations without touching the disk.
|
|
21
|
+
* @param config - data root or explicit model directory.
|
|
22
|
+
* @param lock - pinned file names.
|
|
23
|
+
* @returns absolute model paths.
|
|
24
|
+
*/
|
|
25
|
+
function modelPaths(config, lock = readLock()) {
|
|
26
|
+
const root = config.modelDirectory ?? join(config.dataRoot, "models", "paraformer-streaming-zh-en");
|
|
27
|
+
return {
|
|
28
|
+
encoder: join(root, lock.encoder.name),
|
|
29
|
+
decoder: join(root, lock.decoder.name),
|
|
30
|
+
tokens: join(root, lock.tokens.name)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function matches(path, asset, signal) {
|
|
34
|
+
signal.throwIfAborted();
|
|
35
|
+
try {
|
|
36
|
+
const info = await stat(path);
|
|
37
|
+
if (!info.isFile() || info.size !== asset.bytes) return false;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error.code === "ENOENT") return false;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
const digest = createHash("sha256");
|
|
43
|
+
for await (const chunk of createReadStream(path, { signal })) digest.update(chunk);
|
|
44
|
+
return digest.digest("hex") === asset.sha256;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check whether every pinned file is present and intact; an explicit model directory is trusted as configured.
|
|
48
|
+
* @param config - model location.
|
|
49
|
+
* @param signal - inspection cancellation.
|
|
50
|
+
* @returns paths when ready, otherwise `undefined`.
|
|
51
|
+
*/
|
|
52
|
+
async function inspectModels(config, signal) {
|
|
53
|
+
const lock = readLock(), paths = modelPaths(config, lock);
|
|
54
|
+
if (config.modelDirectory !== void 0) return paths;
|
|
55
|
+
for (const key of [
|
|
56
|
+
"encoder",
|
|
57
|
+
"decoder",
|
|
58
|
+
"tokens"
|
|
59
|
+
]) if (!await matches(paths[key], lock[key], signal)) return void 0;
|
|
60
|
+
return paths;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Download one file into a unique partial file, verify size and SHA-256, then rename it into place.
|
|
64
|
+
* @param asset - pinned identity; `url` is the concrete source.
|
|
65
|
+
* @param destination - final path.
|
|
66
|
+
* @param signal - preparation cancellation.
|
|
67
|
+
* @param report - download progress.
|
|
68
|
+
*/
|
|
69
|
+
async function download(asset, destination, signal, report) {
|
|
70
|
+
const partial = `${destination}.${randomUUID()}.part`;
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch(asset.url, { signal });
|
|
73
|
+
if (!response.ok || !response.body) {
|
|
74
|
+
await response.body?.cancel();
|
|
75
|
+
throw new Error(`HTTP ${response.status} from ${new URL(asset.url).origin}`);
|
|
76
|
+
}
|
|
77
|
+
const digest = createHash("sha256");
|
|
78
|
+
let completed = 0;
|
|
79
|
+
const hashing = new Transform({ transform(chunk, _encoding, callback) {
|
|
80
|
+
completed += chunk.length;
|
|
81
|
+
if (completed > asset.bytes) {
|
|
82
|
+
callback(/* @__PURE__ */ new Error(`${asset.name} is larger than expected`));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
digest.update(chunk);
|
|
86
|
+
report(completed);
|
|
87
|
+
callback(null, chunk);
|
|
88
|
+
} });
|
|
89
|
+
await pipeline(response.body, hashing, createWriteStream(partial, {
|
|
90
|
+
flags: "wx",
|
|
91
|
+
mode: 384
|
|
92
|
+
}), { signal });
|
|
93
|
+
if (completed !== asset.bytes || digest.digest("hex") !== asset.sha256) throw new Error(`${asset.name} failed integrity verification`);
|
|
94
|
+
await rename(partial, destination);
|
|
95
|
+
} finally {
|
|
96
|
+
await rm(partial, { force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Ensure every pinned file is present, downloading missing or corrupted files from the configured origins in order.
|
|
101
|
+
* @param config - data root and origins.
|
|
102
|
+
* @param signal - preparation cancellation or deadline.
|
|
103
|
+
* @param report - preparation progress.
|
|
104
|
+
* @returns verified model paths.
|
|
105
|
+
*/
|
|
106
|
+
async function prepareModels(config, signal, report) {
|
|
107
|
+
const lock = readLock(), paths = modelPaths(config, lock);
|
|
108
|
+
if (config.modelDirectory !== void 0) return paths;
|
|
109
|
+
await mkdir(join(config.dataRoot, "models", "paraformer-streaming-zh-en"), { recursive: true });
|
|
110
|
+
for (const key of [
|
|
111
|
+
"encoder",
|
|
112
|
+
"decoder",
|
|
113
|
+
"tokens"
|
|
114
|
+
]) {
|
|
115
|
+
const asset = lock[key];
|
|
116
|
+
report({
|
|
117
|
+
phase: "checking",
|
|
118
|
+
step: "check",
|
|
119
|
+
startedAt: Date.now()
|
|
120
|
+
});
|
|
121
|
+
if (await matches(paths[key], asset, signal)) continue;
|
|
122
|
+
const path = new URL(asset.url).pathname;
|
|
123
|
+
const failures = [];
|
|
124
|
+
for (const origin of config.modelOrigins) try {
|
|
125
|
+
await download({
|
|
126
|
+
...asset,
|
|
127
|
+
url: new URL(path, origin).href
|
|
128
|
+
}, paths[key], signal, (completedBytes) => {
|
|
129
|
+
report({
|
|
130
|
+
phase: "downloading",
|
|
131
|
+
step: "model",
|
|
132
|
+
resource: asset.name,
|
|
133
|
+
completedBytes,
|
|
134
|
+
totalBytes: asset.bytes
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
break;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
signal.throwIfAborted();
|
|
140
|
+
failures.push(`${origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
141
|
+
}
|
|
142
|
+
if (failures.length === config.modelOrigins.length) throw new Error(`Could not download ${asset.name}; ${failures.join("; ")}`);
|
|
143
|
+
}
|
|
144
|
+
report({
|
|
145
|
+
phase: "checking",
|
|
146
|
+
step: "verify",
|
|
147
|
+
startedAt: Date.now()
|
|
148
|
+
});
|
|
149
|
+
if (await inspectModels(config, signal) === void 0) throw new Error("Streaming model verification failed");
|
|
150
|
+
return paths;
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region lib/types/provider.js
|
|
154
|
+
/** Host side of the local streaming recognizer: preparation, recognition process lifetime and duplex streams. */
|
|
155
|
+
var __addDisposableResource = function(env, value, async) {
|
|
156
|
+
if (value !== null && value !== void 0) {
|
|
157
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
158
|
+
var dispose, inner;
|
|
159
|
+
if (async) {
|
|
160
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
161
|
+
dispose = value[Symbol.asyncDispose];
|
|
162
|
+
}
|
|
163
|
+
if (dispose === void 0) {
|
|
164
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
165
|
+
dispose = value[Symbol.dispose];
|
|
166
|
+
if (async) inner = dispose;
|
|
167
|
+
}
|
|
168
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
169
|
+
if (inner) dispose = function() {
|
|
170
|
+
try {
|
|
171
|
+
inner.call(this);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return Promise.reject(e);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
env.stack.push({
|
|
177
|
+
value,
|
|
178
|
+
dispose,
|
|
179
|
+
async
|
|
180
|
+
});
|
|
181
|
+
} else if (async) env.stack.push({ async: true });
|
|
182
|
+
return value;
|
|
183
|
+
};
|
|
184
|
+
var __disposeResources = (function(SuppressedError) {
|
|
185
|
+
return function(env) {
|
|
186
|
+
function fail(e) {
|
|
187
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
188
|
+
env.hasError = true;
|
|
189
|
+
}
|
|
190
|
+
var r, s = 0;
|
|
191
|
+
function next() {
|
|
192
|
+
while (r = env.stack.pop()) try {
|
|
193
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
194
|
+
if (r.dispose) {
|
|
195
|
+
var result = r.dispose.call(r.value);
|
|
196
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
|
|
197
|
+
fail(e);
|
|
198
|
+
return next();
|
|
199
|
+
});
|
|
200
|
+
} else s |= 1;
|
|
201
|
+
} catch (e) {
|
|
202
|
+
fail(e);
|
|
203
|
+
}
|
|
204
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
205
|
+
if (env.hasError) throw env.error;
|
|
206
|
+
}
|
|
207
|
+
return next();
|
|
208
|
+
};
|
|
209
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
|
|
210
|
+
var e = new Error(message);
|
|
211
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
212
|
+
});
|
|
213
|
+
const ready = z.object({ port: z.number().int().min(1).max(65535) }).strict();
|
|
214
|
+
const event = z.discriminatedUnion("type", [
|
|
215
|
+
z.object({
|
|
216
|
+
type: z.literal("partial"),
|
|
217
|
+
segment: z.number().int().min(0),
|
|
218
|
+
text: z.string()
|
|
219
|
+
}),
|
|
220
|
+
z.object({
|
|
221
|
+
type: z.literal("final"),
|
|
222
|
+
segment: z.number().int().min(0),
|
|
223
|
+
text: z.string()
|
|
224
|
+
}),
|
|
225
|
+
z.object({
|
|
226
|
+
type: z.literal("end"),
|
|
227
|
+
text: z.string(),
|
|
228
|
+
audioSeconds: z.number().min(0)
|
|
229
|
+
}),
|
|
230
|
+
z.object({
|
|
231
|
+
type: z.literal("error"),
|
|
232
|
+
message: z.string()
|
|
233
|
+
})
|
|
234
|
+
]);
|
|
235
|
+
async function readReady(handle, limit, signal) {
|
|
236
|
+
const stdout = handle.stdout;
|
|
237
|
+
if (!stdout) throw new Error("Streaming recognizer stdout is unavailable");
|
|
238
|
+
const pending = Promise.withResolvers();
|
|
239
|
+
let text = "";
|
|
240
|
+
const data = (chunk) => {
|
|
241
|
+
text += chunk.toString("utf8");
|
|
242
|
+
if (Buffer.byteLength(text) > limit) {
|
|
243
|
+
pending.reject(/* @__PURE__ */ new Error("Streaming recognizer readiness exceeded its byte limit"));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const end = text.indexOf("\n");
|
|
247
|
+
if (end < 0) return;
|
|
248
|
+
try {
|
|
249
|
+
pending.resolve(ready.parse(JSON.parse(text.slice(0, end))).port);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
pending.reject(new Error("Invalid streaming recognizer readiness", { cause: error }));
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const abort = () => {
|
|
255
|
+
pending.reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Streaming recognizer start cancelled"));
|
|
256
|
+
};
|
|
257
|
+
stdout.on("data", data);
|
|
258
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
259
|
+
handle.done.then(() => {
|
|
260
|
+
pending.reject(/* @__PURE__ */ new Error(`Streaming recognizer exited: ${handle.collected.stderr?.readFrom(0).text ?? ""}`));
|
|
261
|
+
}, pending.reject);
|
|
262
|
+
try {
|
|
263
|
+
return await pending.promise;
|
|
264
|
+
} finally {
|
|
265
|
+
stdout.off("data", data);
|
|
266
|
+
signal.removeEventListener("abort", abort);
|
|
267
|
+
stdout.resume();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Local streaming Paraformer provider; the recognition process starts on prepare, warm or first stream. */
|
|
271
|
+
var SherpaStreaming = class {
|
|
272
|
+
ctx;
|
|
273
|
+
config;
|
|
274
|
+
info;
|
|
275
|
+
preparation = {
|
|
276
|
+
snapshot: () => this.state,
|
|
277
|
+
subscribe: (listener) => {
|
|
278
|
+
this.listeners.add(listener);
|
|
279
|
+
return () => {
|
|
280
|
+
this.listeners.delete(listener);
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
prepare: (_options) => {
|
|
284
|
+
this.run(async (signal) => {
|
|
285
|
+
await this.start(signal, true);
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
cancel: async () => {
|
|
289
|
+
this.task?.abort.abort(/* @__PURE__ */ new Error("Streaming model preparation cancelled"));
|
|
290
|
+
await this.task?.settled;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
state = { phase: "unprepared" };
|
|
294
|
+
listeners = /* @__PURE__ */ new Set();
|
|
295
|
+
lifetime = new AbortController();
|
|
296
|
+
paths;
|
|
297
|
+
running;
|
|
298
|
+
starting;
|
|
299
|
+
task;
|
|
300
|
+
open_ = 0;
|
|
301
|
+
idle;
|
|
302
|
+
lastProgress = 0;
|
|
303
|
+
constructor(ctx, config) {
|
|
304
|
+
this.ctx = ctx;
|
|
305
|
+
this.config = config;
|
|
306
|
+
this.info = {
|
|
307
|
+
id: config.providerId,
|
|
308
|
+
name: "Paraformer streaming (zh/en, local)",
|
|
309
|
+
location: "host-local",
|
|
310
|
+
languages: [
|
|
311
|
+
"auto",
|
|
312
|
+
"zh",
|
|
313
|
+
"en"
|
|
314
|
+
],
|
|
315
|
+
downloadSources: config.modelDirectory === void 0 ? config.modelOrigins : [],
|
|
316
|
+
setupEstimate: {
|
|
317
|
+
recommendedDiskBytes: 3e8,
|
|
318
|
+
expectedMemoryBytes: 6e8,
|
|
319
|
+
minimumMinutes: 1,
|
|
320
|
+
maximumMinutes: 10
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** Read disk state without downloading or starting the recognition process. */
|
|
325
|
+
inspect() {
|
|
326
|
+
this.run(async (signal) => {
|
|
327
|
+
this.publish({
|
|
328
|
+
phase: "checking",
|
|
329
|
+
step: "check",
|
|
330
|
+
startedAt: Date.now()
|
|
331
|
+
});
|
|
332
|
+
this.paths = await inspectModels(this.config, signal);
|
|
333
|
+
this.publish({ phase: this.paths ? "standby" : "unprepared" });
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
warm() {
|
|
337
|
+
if (this.paths === void 0 || this.running !== void 0 || this.task !== void 0) return;
|
|
338
|
+
this.run(async (signal) => {
|
|
339
|
+
await this.start(signal, false);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
async open(options, signal) {
|
|
343
|
+
if (this.paths === void 0) throw new Error("Download the local streaming model before recording");
|
|
344
|
+
const running = await this.start(AbortSignal.any([signal, this.lifetime.signal]), false);
|
|
345
|
+
clearTimeout(this.idle);
|
|
346
|
+
this.open_++;
|
|
347
|
+
const events = new Channel();
|
|
348
|
+
const request$1 = request({
|
|
349
|
+
host: "127.0.0.1",
|
|
350
|
+
port: running.port,
|
|
351
|
+
path: "/stream",
|
|
352
|
+
method: "POST",
|
|
353
|
+
agent: new Agent(),
|
|
354
|
+
headers: {
|
|
355
|
+
authorization: `Bearer ${running.token}`,
|
|
356
|
+
"content-type": "application/octet-stream",
|
|
357
|
+
"x-language": options.language
|
|
358
|
+
},
|
|
359
|
+
signal
|
|
360
|
+
});
|
|
361
|
+
const settle = () => {
|
|
362
|
+
if (--this.open_ === 0) this.scheduleIdle();
|
|
363
|
+
};
|
|
364
|
+
request$1.on("error", (error) => {
|
|
365
|
+
events.fail(error);
|
|
366
|
+
});
|
|
367
|
+
request$1.on("response", (response) => {
|
|
368
|
+
let buffered = "";
|
|
369
|
+
response.setEncoding("utf8");
|
|
370
|
+
response.on("data", (chunk) => {
|
|
371
|
+
buffered += chunk;
|
|
372
|
+
for (let end = buffered.indexOf("\n"); end >= 0; end = buffered.indexOf("\n")) {
|
|
373
|
+
const parsed = event.safeParse(JSON.parse(buffered.slice(0, end)));
|
|
374
|
+
buffered = buffered.slice(end + 1);
|
|
375
|
+
if (!parsed.success) {
|
|
376
|
+
events.fail(new Error("Invalid streaming recognizer event", { cause: parsed.error }));
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (parsed.data.type === "error") {
|
|
380
|
+
events.fail(new Error(parsed.data.message));
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
events.push(parsed.data);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
response.on("end", () => {
|
|
387
|
+
events.close();
|
|
388
|
+
});
|
|
389
|
+
response.on("error", (error) => {
|
|
390
|
+
events.fail(error);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
request$1.on("close", settle);
|
|
394
|
+
request$1.flushHeaders();
|
|
395
|
+
return {
|
|
396
|
+
write: (pcm) => {
|
|
397
|
+
request$1.write(Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength));
|
|
398
|
+
},
|
|
399
|
+
finish: () => {
|
|
400
|
+
request$1.end();
|
|
401
|
+
},
|
|
402
|
+
events
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/** Stop the recognition process and pending preparation. */
|
|
406
|
+
async dispose() {
|
|
407
|
+
this.listeners.clear();
|
|
408
|
+
this.lifetime.abort(/* @__PURE__ */ new Error("Streaming provider disposed"));
|
|
409
|
+
clearTimeout(this.idle);
|
|
410
|
+
await this.task?.settled;
|
|
411
|
+
await this.stop();
|
|
412
|
+
}
|
|
413
|
+
run(work) {
|
|
414
|
+
if (this.task) return;
|
|
415
|
+
const abort = new AbortController();
|
|
416
|
+
const signal = AbortSignal.any([abort.signal, this.lifetime.signal]);
|
|
417
|
+
const task = {
|
|
418
|
+
abort,
|
|
419
|
+
settled: Promise.resolve()
|
|
420
|
+
};
|
|
421
|
+
this.task = task;
|
|
422
|
+
task.settled = work(signal).catch((error) => {
|
|
423
|
+
if (this.lifetime.signal.aborted) return;
|
|
424
|
+
this.publish(abort.signal.aborted ? { phase: "cancelled" } : {
|
|
425
|
+
phase: "failed",
|
|
426
|
+
message: error instanceof Error ? error.message : String(error)
|
|
427
|
+
});
|
|
428
|
+
}).finally(() => {
|
|
429
|
+
this.task = void 0;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
async start(caller, download) {
|
|
433
|
+
if (this.running && !this.running.closed) return this.running;
|
|
434
|
+
this.starting ??= (async () => {
|
|
435
|
+
const env_1 = {
|
|
436
|
+
stack: [],
|
|
437
|
+
error: void 0,
|
|
438
|
+
hasError: false
|
|
439
|
+
};
|
|
440
|
+
try {
|
|
441
|
+
const setup = __addDisposableResource(env_1, deadline(caller, this.config.prepareTimeoutMs, "SPEECH_PREPARE_TIMEOUT"), false);
|
|
442
|
+
const cached = this.paths !== void 0;
|
|
443
|
+
this.paths ??= download ? await prepareModels(this.config, setup.signal, (state) => {
|
|
444
|
+
this.publish(state);
|
|
445
|
+
}) : void 0;
|
|
446
|
+
if (this.paths === void 0) throw new Error("Download the local streaming model before recording");
|
|
447
|
+
this.publish({
|
|
448
|
+
phase: cached ? "waking" : "loading",
|
|
449
|
+
step: "load",
|
|
450
|
+
startedAt: Date.now()
|
|
451
|
+
});
|
|
452
|
+
await mkdir(this.config.dataRoot, { recursive: true });
|
|
453
|
+
const token = randomBytes(32).toString("hex");
|
|
454
|
+
const worker = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js", import.meta.url));
|
|
455
|
+
const handle = this.ctx.subprocess.spawn({
|
|
456
|
+
argv: [
|
|
457
|
+
process.execPath,
|
|
458
|
+
...worker.endsWith(".ts") ? ["--import", import.meta.resolve("tsx/esm")] : [],
|
|
459
|
+
worker,
|
|
460
|
+
JSON.stringify({
|
|
461
|
+
...this.config,
|
|
462
|
+
...this.paths
|
|
463
|
+
})
|
|
464
|
+
],
|
|
465
|
+
cwd: this.config.dataRoot,
|
|
466
|
+
graceMs: this.config.graceMs,
|
|
467
|
+
env: {
|
|
468
|
+
DSH_SPEECH_TOKEN: token,
|
|
469
|
+
ELECTRON_RUN_AS_NODE: "1"
|
|
470
|
+
},
|
|
471
|
+
stdio: {
|
|
472
|
+
stdin: "ignore",
|
|
473
|
+
stdout: "pipe",
|
|
474
|
+
stderr: { maxBytes: this.config.maxLogBytes }
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
try {
|
|
478
|
+
const running = {
|
|
479
|
+
handle,
|
|
480
|
+
port: await readReady(handle, this.config.maxLogBytes, setup.signal),
|
|
481
|
+
token,
|
|
482
|
+
closed: false
|
|
483
|
+
};
|
|
484
|
+
const exited = () => {
|
|
485
|
+
if (!running.closed) {
|
|
486
|
+
running.closed = true;
|
|
487
|
+
this.running = void 0;
|
|
488
|
+
this.publish({ phase: "standby" });
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
handle.done.then(exited, exited);
|
|
492
|
+
this.running = running;
|
|
493
|
+
this.publish({ phase: "ready" });
|
|
494
|
+
this.scheduleIdle();
|
|
495
|
+
return running;
|
|
496
|
+
} catch (error) {
|
|
497
|
+
handle.terminate();
|
|
498
|
+
await handle.waitForExit();
|
|
499
|
+
this.publish(this.paths ? { phase: "standby" } : { phase: "unprepared" });
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
} catch (e_1) {
|
|
503
|
+
env_1.error = e_1;
|
|
504
|
+
env_1.hasError = true;
|
|
505
|
+
} finally {
|
|
506
|
+
__disposeResources(env_1);
|
|
507
|
+
}
|
|
508
|
+
})().finally(() => {
|
|
509
|
+
this.starting = void 0;
|
|
510
|
+
});
|
|
511
|
+
return await this.starting;
|
|
512
|
+
}
|
|
513
|
+
scheduleIdle() {
|
|
514
|
+
clearTimeout(this.idle);
|
|
515
|
+
if (this.config.idleTimeoutMs === 0 || this.open_ > 0) return;
|
|
516
|
+
this.idle = setTimeout(() => {
|
|
517
|
+
this.stop().catch((error) => {
|
|
518
|
+
this.ctx.logger.warn("Streaming recognizer idle stop failed", error);
|
|
519
|
+
});
|
|
520
|
+
}, this.config.idleTimeoutMs);
|
|
521
|
+
}
|
|
522
|
+
async stop() {
|
|
523
|
+
const running = this.running;
|
|
524
|
+
if (!running) return;
|
|
525
|
+
running.closed = true;
|
|
526
|
+
this.running = void 0;
|
|
527
|
+
running.handle.terminate();
|
|
528
|
+
await running.handle.waitForExit();
|
|
529
|
+
if (!this.lifetime.signal.aborted) this.publish({ phase: "standby" });
|
|
530
|
+
}
|
|
531
|
+
publish(state) {
|
|
532
|
+
const now = performance.now();
|
|
533
|
+
if (state.phase === "downloading" && this.state.phase === "downloading" && state.completedBytes !== state.totalBytes && now - this.lastProgress < this.config.progressIntervalMs) {
|
|
534
|
+
this.state = state;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
this.lastProgress = now;
|
|
538
|
+
this.state = state;
|
|
539
|
+
for (const listener of this.listeners) listener();
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region lib/types/config.js
|
|
544
|
+
/** Validated settings shared by the Host provider and its recognition process. */
|
|
545
|
+
const origin = z$1.string().pattern(/^https?:\/\/[^/\s?#@]+\/?$/);
|
|
546
|
+
const Config = z$1.object({
|
|
547
|
+
providerId: z$1.string().min(1).default("paraformer-streaming-local"),
|
|
548
|
+
dataRoot: z$1.string().min(1).required(),
|
|
549
|
+
modelDirectory: z$1.string().min(1),
|
|
550
|
+
modelOrigins: z$1.array(origin).min(1).default(["https://huggingface.co", "https://hf-mirror.com"]),
|
|
551
|
+
threads: z$1.natural().min(1).default(2),
|
|
552
|
+
segmentOnEndpoint: z$1.boolean().default(false),
|
|
553
|
+
endpointSilenceSeconds: z$1.number().min(.1).default(2.4),
|
|
554
|
+
endpointSilenceAfterTextSeconds: z$1.number().min(.1).default(.8),
|
|
555
|
+
endpointMaxSegmentSeconds: z$1.number().min(1).default(20),
|
|
556
|
+
tailPaddingSeconds: z$1.number().min(0).max(2).default(.5),
|
|
557
|
+
maxStreamSeconds: z$1.natural().min(1).default(300),
|
|
558
|
+
prepareTimeoutMs: z$1.natural().min(1).max(MAX_TIMER_DELAY_MS).default(36e5),
|
|
559
|
+
idleTimeoutMs: z$1.natural().max(MAX_TIMER_DELAY_MS).default(6e5),
|
|
560
|
+
graceMs: z$1.natural().min(1).max(MAX_TIMER_DELAY_MS).default(1e3),
|
|
561
|
+
maxLogBytes: z$1.natural().min(1).default(64 * 1024),
|
|
562
|
+
progressIntervalMs: z$1.natural().min(1).max(MAX_TIMER_DELAY_MS).default(100)
|
|
563
|
+
});
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region lib/types/index.js
|
|
566
|
+
/** Optional local streaming recognizer; activation inspects the model cache without downloading or loading. */
|
|
567
|
+
const name = "speech-streaming-sherpa";
|
|
568
|
+
const inject = ["speechStreaming", "subprocess"];
|
|
569
|
+
/**
|
|
570
|
+
* Register the local streaming provider.
|
|
571
|
+
* @param ctx - streaming registry and subprocess owner.
|
|
572
|
+
* @param config - validated runtime configuration.
|
|
573
|
+
*/
|
|
574
|
+
function apply(ctx, config) {
|
|
575
|
+
for (const path of [config.dataRoot, config.modelDirectory]) if (path !== void 0 && !isAbsolute(path)) throw new Error(`Streaming model paths must be absolute: ${path}`);
|
|
576
|
+
const provider = new SherpaStreaming(ctx, config);
|
|
577
|
+
ctx.effect(() => {
|
|
578
|
+
const unregister = ctx.speechStreaming.register(provider);
|
|
579
|
+
provider.inspect();
|
|
580
|
+
return async () => {
|
|
581
|
+
const removing = unregister();
|
|
582
|
+
await provider.dispose();
|
|
583
|
+
await removing;
|
|
584
|
+
};
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
//#endregion
|
|
588
|
+
export { Config, apply, inject, name };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Validated settings shared by the Host provider and its recognition process. */
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
/** Local streaming Paraformer settings. */
|
|
4
|
+
export interface Config {
|
|
5
|
+
/** Registered provider id. */
|
|
6
|
+
providerId: string;
|
|
7
|
+
/** Provider-owned directory for downloaded models. */
|
|
8
|
+
dataRoot: string;
|
|
9
|
+
/** Directory already holding `encoder.int8.onnx`, `decoder.int8.onnx` and `tokens.txt`; skips download and hash checks. */
|
|
10
|
+
modelDirectory?: string;
|
|
11
|
+
/** Hugging Face-compatible origins tried in order for each pinned file. */
|
|
12
|
+
modelOrigins: string[];
|
|
13
|
+
/** ONNX Runtime CPU threads in the recognition process. */
|
|
14
|
+
threads: number;
|
|
15
|
+
/** Close a segment and reset decoding at detected pauses; resetting mid-sentence loses context, so press-to-talk keeps it off. */
|
|
16
|
+
segmentOnEndpoint: boolean;
|
|
17
|
+
/** Trailing silence that ends a segment when nothing was recognized yet. */
|
|
18
|
+
endpointSilenceSeconds: number;
|
|
19
|
+
/** Trailing silence that ends a segment after text was recognized. */
|
|
20
|
+
endpointSilenceAfterTextSeconds: number;
|
|
21
|
+
/** Segment length that forces an endpoint. */
|
|
22
|
+
endpointMaxSegmentSeconds: number;
|
|
23
|
+
/** Silence appended at finish so the last chunk is decoded. */
|
|
24
|
+
tailPaddingSeconds: number;
|
|
25
|
+
/** Longest accepted stream. */
|
|
26
|
+
maxStreamSeconds: number;
|
|
27
|
+
/** Download and process start deadline. */
|
|
28
|
+
prepareTimeoutMs: number;
|
|
29
|
+
/** Stop the idle recognition process after this delay; `0` keeps it running. */
|
|
30
|
+
idleTimeoutMs: number;
|
|
31
|
+
/** Grace period before the recognition process is killed. */
|
|
32
|
+
graceMs: number;
|
|
33
|
+
/** Retained recognition process stderr bytes. */
|
|
34
|
+
maxLogBytes: number;
|
|
35
|
+
/** Minimum interval between download progress notifications. */
|
|
36
|
+
progressIntervalMs: number;
|
|
37
|
+
}
|
|
38
|
+
export declare const Config: z<Partial<Config>, Config>;
|
|
39
|
+
/** Verified model files passed to the recognition process. */
|
|
40
|
+
export interface ModelPaths {
|
|
41
|
+
readonly encoder: string;
|
|
42
|
+
readonly decoder: string;
|
|
43
|
+
readonly tokens: string;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Validated settings shared by the Host provider and its recognition process. */
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout';
|
|
4
|
+
const origin = z.string().pattern(/^https?:\/\/[^/\s?#@]+\/?$/);
|
|
5
|
+
export const Config = z.object({
|
|
6
|
+
providerId: z.string().min(1).default('paraformer-streaming-local'),
|
|
7
|
+
dataRoot: z.string().min(1).required(),
|
|
8
|
+
modelDirectory: z.string().min(1),
|
|
9
|
+
modelOrigins: z.array(origin).min(1).default(['https://huggingface.co', 'https://hf-mirror.com']),
|
|
10
|
+
threads: z.natural().min(1).default(2),
|
|
11
|
+
segmentOnEndpoint: z.boolean().default(false),
|
|
12
|
+
endpointSilenceSeconds: z.number().min(0.1).default(2.4),
|
|
13
|
+
endpointSilenceAfterTextSeconds: z.number().min(0.1).default(0.8),
|
|
14
|
+
endpointMaxSegmentSeconds: z.number().min(1).default(20),
|
|
15
|
+
tailPaddingSeconds: z.number().min(0).max(2).default(0.5),
|
|
16
|
+
maxStreamSeconds: z.natural().min(1).default(300),
|
|
17
|
+
prepareTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(3_600_000),
|
|
18
|
+
idleTimeoutMs: z.natural().max(MAX_TIMER_DELAY_MS).default(600_000),
|
|
19
|
+
graceMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(1000),
|
|
20
|
+
maxLogBytes: z.natural().min(1).default(64 * 1024),
|
|
21
|
+
progressIntervalMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(100),
|
|
22
|
+
});
|
|
23
|
+
//# sourceMappingURL=config.js.map
|