arcane-os 0.1.2 → 0.2.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/CHANGELOG.md +17 -0
- package/NOTICE +5 -3
- package/README.md +73 -24
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
- package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
- package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
- package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
- package/browser-runtime/ai/browser-speech-providers.mjs +475 -0
- package/browser-runtime/ai/browser-speech.mjs +9 -0
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
- package/browser-runtime/ai/browser-wasm.mjs +46 -1
- package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
- package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
- package/browser-runtime/ai/model-controller.mjs +138 -12
- package/browser-runtime/ai/speech-worker-client.mjs +207 -0
- package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
- package/browser-runtime/ai/wllama/index.mjs +389 -0
- package/docs/architecture.md +132 -22
- package/docs/reference/README.md +1 -1
- package/docs/reference/ai/browser-wasm.md +101 -42
- package/docs/reference/availability-and-normalization.md +19 -5
- package/docs/reference/behavioral-testing.md +18 -5
- package/docs/reference/cli.md +2 -2
- package/docs/reference/inventory/package-api.json +14 -14
- package/docs/reference/protocols.md +4 -4
- package/docs/reference/sdk-api.md +68 -38
- package/docs/work-amplification.md +8 -4
- package/package.json +7 -3
- package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
- package/runtime/arcane/components/chat.html +280 -62
- package/runtime/arcane/components/speech.html +1113 -265
- package/runtime/arcane/entities/Chat.js +246 -43
- package/runtime/arcane/modules/AI.js +713 -162
- package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
- package/runtime/arcane/modules/AIRuntimeState.js +872 -0
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +293 -27
- package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +682 -0
- package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
- package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
- package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
- package/schemas/arcane-lock.schema.json +6 -4
- package/src/cli/main.mjs +14 -2
- package/src/constants.mjs +1 -1
- package/src/dev-server.mjs +244 -13
- package/src/import-map.mjs +59 -1
- package/src/packager/core.mjs +2 -2
- package/src/runtime.mjs +14 -4
- package/src/sdk-browser-runtime.mjs +28 -75
- package/src/templates/workspace-template.mjs +4 -4
- package/src/toolchain.mjs +3 -0
- package/src/workspace-runtime.mjs +1 -1
- package/src/workspace.mjs +1 -1
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
export const SPEECH_WORKER_PROTOCOL = "arcane-ai-speech-worker/1";
|
|
2
|
+
|
|
3
|
+
const ADAPTERS = Object.freeze({
|
|
4
|
+
stt: "transformers-whisper",
|
|
5
|
+
tts: "kokoro-js",
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
function workerError(code, message, cause) {
|
|
9
|
+
const error = cause === undefined
|
|
10
|
+
? new Error(message)
|
|
11
|
+
: new Error(message, { cause });
|
|
12
|
+
error.name = code === "ARCANE_AI_REQUEST_ABORTED"
|
|
13
|
+
? "AbortError"
|
|
14
|
+
: "ArcaneSpeechWorkerError";
|
|
15
|
+
error.code = code;
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function throwIfAborted(signal) {
|
|
20
|
+
if (!signal?.aborted) return;
|
|
21
|
+
throw workerError(
|
|
22
|
+
"ARCANE_AI_REQUEST_ABORTED",
|
|
23
|
+
"The speech worker operation was cancelled.",
|
|
24
|
+
signal.reason,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function collectSpeechTransferables(value) {
|
|
29
|
+
const transfers = [];
|
|
30
|
+
const buffers = new Set();
|
|
31
|
+
const seen = new WeakSet();
|
|
32
|
+
function visit(candidate) {
|
|
33
|
+
if (candidate instanceof ArrayBuffer) {
|
|
34
|
+
if (!buffers.has(candidate)) {
|
|
35
|
+
buffers.add(candidate);
|
|
36
|
+
transfers.push(candidate);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (ArrayBuffer.isView(candidate)) {
|
|
41
|
+
visit(candidate.buffer);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return;
|
|
45
|
+
seen.add(candidate);
|
|
46
|
+
for (const child of Array.isArray(candidate)
|
|
47
|
+
? candidate
|
|
48
|
+
: Object.values(candidate)) visit(child);
|
|
49
|
+
}
|
|
50
|
+
visit(value);
|
|
51
|
+
return transfers;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function serializedError(error) {
|
|
55
|
+
const code = typeof error?.code === "string" && /^ARCANE_AI_[A-Z0-9_]+$/u.test(error.code)
|
|
56
|
+
? error.code
|
|
57
|
+
: "ARCANE_AI_PROVIDER_REQUEST_FAILED";
|
|
58
|
+
const messages = Object.freeze({
|
|
59
|
+
ARCANE_AI_REQUEST_ABORTED: "The speech worker operation was cancelled.",
|
|
60
|
+
ARCANE_AI_NOT_READY: "The speech worker is not loaded.",
|
|
61
|
+
ARCANE_AI_INVALID_REQUEST: "The speech worker request is invalid.",
|
|
62
|
+
ARCANE_AI_INVALID_PROVIDER_RESULT: "The speech engine returned an invalid result.",
|
|
63
|
+
ARCANE_AI_UNDECLARED_ARTIFACT: "The speech engine requested an undeclared artifact.",
|
|
64
|
+
ARCANE_AI_PROVIDER_UNAVAILABLE: "The selected speech engine is unavailable.",
|
|
65
|
+
});
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
code,
|
|
68
|
+
message: messages[code] ?? "The speech worker operation failed.",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requiredText(value, label) {
|
|
73
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
74
|
+
throw workerError("ARCANE_AI_INVALID_REQUEST", `${label} is required.`);
|
|
75
|
+
}
|
|
76
|
+
return value.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateConfiguration(configuration, role) {
|
|
80
|
+
if (!configuration || typeof configuration !== "object" || Array.isArray(configuration)) {
|
|
81
|
+
throw workerError("ARCANE_AI_INVALID_REQUEST", "Speech worker configuration is required.");
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
configuration.role !== role
|
|
85
|
+
|| configuration.runtime?.adapter !== ADAPTERS[role]
|
|
86
|
+
|| configuration.runtime?.moduleGraph !== "self-contained"
|
|
87
|
+
) {
|
|
88
|
+
throw workerError("ARCANE_AI_INVALID_REQUEST", "Speech worker role and runtime adapter do not match.");
|
|
89
|
+
}
|
|
90
|
+
requiredText(configuration.model?.id, "Speech model id");
|
|
91
|
+
requiredText(configuration.model?.repository, "Speech model repository");
|
|
92
|
+
requiredText(configuration.model?.revision, "Speech model revision");
|
|
93
|
+
requiredText(configuration.runtime?.entry, "Speech runtime entry");
|
|
94
|
+
if (!Array.isArray(configuration.runtime?.files) || !Array.isArray(configuration.model?.files)) {
|
|
95
|
+
throw workerError("ARCANE_AI_INVALID_REQUEST", "Speech runtime and model files are required.");
|
|
96
|
+
}
|
|
97
|
+
const entry = configuration.runtime.files.find((file) =>
|
|
98
|
+
file.path === configuration.runtime.entry);
|
|
99
|
+
if (!entry?.moduleUrl) {
|
|
100
|
+
throw workerError("ARCANE_AI_INVALID_REQUEST", "Speech runtime entry was not materialized.");
|
|
101
|
+
}
|
|
102
|
+
return configuration;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function artifactMap(configuration) {
|
|
106
|
+
const map = new Map();
|
|
107
|
+
const materialized = new Set();
|
|
108
|
+
for (const file of [
|
|
109
|
+
...configuration.runtime.files,
|
|
110
|
+
...configuration.model.files,
|
|
111
|
+
]) {
|
|
112
|
+
const sourceUrl = new URL(file.sourceUrl).href;
|
|
113
|
+
map.set(sourceUrl, file.moduleUrl);
|
|
114
|
+
materialized.add(file.moduleUrl);
|
|
115
|
+
}
|
|
116
|
+
return Object.freeze({ map, materialized });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function installAuthorizedFetch(scope, configuration) {
|
|
120
|
+
const original = scope.fetch?.bind(scope);
|
|
121
|
+
if (typeof original !== "function") {
|
|
122
|
+
throw workerError("ARCANE_AI_PROVIDER_UNAVAILABLE", "Browser fetch is unavailable in the speech worker.");
|
|
123
|
+
}
|
|
124
|
+
const allowed = artifactMap(configuration);
|
|
125
|
+
scope.fetch = async function fetchAuthorizedSpeechArtifact(input, init) {
|
|
126
|
+
const requested = typeof Request === "function" && input instanceof Request
|
|
127
|
+
? input.url
|
|
128
|
+
: String(input);
|
|
129
|
+
let absolute;
|
|
130
|
+
try {
|
|
131
|
+
absolute = new URL(requested, scope.location?.href).href;
|
|
132
|
+
} catch {
|
|
133
|
+
throw workerError("ARCANE_AI_UNDECLARED_ARTIFACT", "The speech engine requested an invalid artifact URL.");
|
|
134
|
+
}
|
|
135
|
+
const replacement = allowed.map.get(absolute);
|
|
136
|
+
if (replacement) return original(replacement, init);
|
|
137
|
+
if (allowed.materialized.has(absolute)) return original(absolute, init);
|
|
138
|
+
throw workerError(
|
|
139
|
+
"ARCANE_AI_UNDECLARED_ARTIFACT",
|
|
140
|
+
"The speech engine requested an artifact outside its admitted file map.",
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
return function restoreSpeechWorkerFetch() {
|
|
144
|
+
scope.fetch = original;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function installCacheIsolation(scope) {
|
|
149
|
+
const ownDescriptor = Object.getOwnPropertyDescriptor(scope, "caches");
|
|
150
|
+
if (ownDescriptor && ownDescriptor.configurable === false) {
|
|
151
|
+
throw workerError(
|
|
152
|
+
"ARCANE_AI_PROVIDER_UNAVAILABLE",
|
|
153
|
+
"The speech worker cannot isolate the browser cache API.",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const denied = Object.freeze({
|
|
157
|
+
async open() {
|
|
158
|
+
throw workerError(
|
|
159
|
+
"ARCANE_AI_UNDECLARED_ARTIFACT",
|
|
160
|
+
"Speech runtime cache access is disabled; DBOPFS admission is the sole artifact source.",
|
|
161
|
+
);
|
|
162
|
+
},
|
|
163
|
+
async match() {
|
|
164
|
+
throw workerError(
|
|
165
|
+
"ARCANE_AI_UNDECLARED_ARTIFACT",
|
|
166
|
+
"Speech runtime cache access is disabled; DBOPFS admission is the sole artifact source.",
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
async has() {
|
|
170
|
+
return false;
|
|
171
|
+
},
|
|
172
|
+
async keys() {
|
|
173
|
+
return Object.freeze([]);
|
|
174
|
+
},
|
|
175
|
+
async delete() {
|
|
176
|
+
return false;
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
try {
|
|
180
|
+
Object.defineProperty(scope, "caches", {
|
|
181
|
+
configurable: true,
|
|
182
|
+
enumerable: ownDescriptor?.enumerable ?? true,
|
|
183
|
+
writable: false,
|
|
184
|
+
value: denied,
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
throw workerError(
|
|
188
|
+
"ARCANE_AI_PROVIDER_UNAVAILABLE",
|
|
189
|
+
"The speech worker could not isolate the browser cache API.",
|
|
190
|
+
error,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return function restoreSpeechWorkerCaches() {
|
|
194
|
+
if (ownDescriptor) Object.defineProperty(scope, "caches", ownDescriptor);
|
|
195
|
+
else delete scope.caches;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function configureWasmPaths(namespace, configuration) {
|
|
200
|
+
if (namespace?.env) {
|
|
201
|
+
namespace.env.allowLocalModels = false;
|
|
202
|
+
namespace.env.allowRemoteModels = true;
|
|
203
|
+
namespace.env.useBrowserCache = false;
|
|
204
|
+
namespace.env.useFSCache = false;
|
|
205
|
+
namespace.env.useCustomCache = false;
|
|
206
|
+
namespace.env.customCache = null;
|
|
207
|
+
}
|
|
208
|
+
const wasm = namespace?.env?.backends?.onnx?.wasm;
|
|
209
|
+
if (!wasm) return;
|
|
210
|
+
const paths = {};
|
|
211
|
+
for (const file of configuration.runtime.files) {
|
|
212
|
+
if (/\.(?:m?js|wasm)$/iu.test(file.path) && file.path !== configuration.runtime.entry) {
|
|
213
|
+
paths[file.path.split("/").pop()] = file.moduleUrl;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (Object.keys(paths).length > 0) wasm.wasmPaths = Object.freeze(paths);
|
|
217
|
+
if (scopeIsCrossOriginIsolated() && Number.isSafeInteger(globalThis.navigator?.hardwareConcurrency)) {
|
|
218
|
+
wasm.numThreads = Math.max(1, Math.min(8, globalThis.navigator.hardwareConcurrency));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function scopeIsCrossOriginIsolated() {
|
|
223
|
+
return globalThis.crossOriginIsolated === true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function workerProgress(send, requestId, phase, completed = 0, total = null, unit = "items") {
|
|
227
|
+
send({
|
|
228
|
+
protocol: SPEECH_WORKER_PROTOCOL,
|
|
229
|
+
event: "progress",
|
|
230
|
+
requestId,
|
|
231
|
+
progress: Object.freeze({
|
|
232
|
+
phase,
|
|
233
|
+
completed,
|
|
234
|
+
total,
|
|
235
|
+
unit,
|
|
236
|
+
heartbeat: true,
|
|
237
|
+
}),
|
|
238
|
+
}, []);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function disposeEngine(engine) {
|
|
242
|
+
if (!engine) return;
|
|
243
|
+
if (typeof engine.dispose === "function") {
|
|
244
|
+
await engine.dispose();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const disposed = new Set();
|
|
248
|
+
for (const part of [engine.model, engine.tokenizer, engine.processor]) {
|
|
249
|
+
if (!part || disposed.has(part) || typeof part.dispose !== "function") continue;
|
|
250
|
+
disposed.add(part);
|
|
251
|
+
await part.dispose();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function createWhisperEngine(namespace, configuration, signal, report) {
|
|
256
|
+
if (typeof namespace?.pipeline !== "function") {
|
|
257
|
+
throw workerError("ARCANE_AI_PROVIDER_UNAVAILABLE", "The Whisper runtime does not export pipeline().");
|
|
258
|
+
}
|
|
259
|
+
configureWasmPaths(namespace, configuration);
|
|
260
|
+
throwIfAborted(signal);
|
|
261
|
+
const transcriber = await namespace.pipeline(
|
|
262
|
+
"automatic-speech-recognition",
|
|
263
|
+
configuration.model.repository,
|
|
264
|
+
{
|
|
265
|
+
device: "wasm",
|
|
266
|
+
dtype: "fp32",
|
|
267
|
+
revision: configuration.model.revision,
|
|
268
|
+
progress_callback: report,
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
throwIfAborted(signal);
|
|
272
|
+
return Object.freeze({
|
|
273
|
+
async transcribe(input, { signal: requestSignal } = {}) {
|
|
274
|
+
throwIfAborted(requestSignal);
|
|
275
|
+
const output = await transcriber(input.audio, { signal: requestSignal });
|
|
276
|
+
throwIfAborted(requestSignal);
|
|
277
|
+
return Object.freeze({ text: String(output?.text ?? "").trim() });
|
|
278
|
+
},
|
|
279
|
+
dispose: () => disposeEngine(transcriber),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function createKokoroEngine(namespace, configuration, signal, report) {
|
|
284
|
+
if (typeof namespace?.KokoroTTS?.from_pretrained !== "function") {
|
|
285
|
+
throw workerError("ARCANE_AI_PROVIDER_UNAVAILABLE", "The Kokoro runtime does not export KokoroTTS.");
|
|
286
|
+
}
|
|
287
|
+
configureWasmPaths(namespace, configuration);
|
|
288
|
+
throwIfAborted(signal);
|
|
289
|
+
const synthesizer = await namespace.KokoroTTS.from_pretrained(
|
|
290
|
+
configuration.model.repository,
|
|
291
|
+
{
|
|
292
|
+
device: "wasm",
|
|
293
|
+
dtype: "q8",
|
|
294
|
+
revision: configuration.model.revision,
|
|
295
|
+
progress_callback: report,
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
throwIfAborted(signal);
|
|
299
|
+
return Object.freeze({
|
|
300
|
+
async synthesize(input, { signal: requestSignal } = {}) {
|
|
301
|
+
throwIfAborted(requestSignal);
|
|
302
|
+
const output = await synthesizer.generate(input.text, {
|
|
303
|
+
voice: input.voice,
|
|
304
|
+
speed: input.speed,
|
|
305
|
+
signal: requestSignal,
|
|
306
|
+
});
|
|
307
|
+
throwIfAborted(requestSignal);
|
|
308
|
+
const audio = output?.audio instanceof Float32Array
|
|
309
|
+
? output.audio
|
|
310
|
+
: new Float32Array(output?.audio ?? []);
|
|
311
|
+
return Object.freeze({
|
|
312
|
+
audio,
|
|
313
|
+
sampleRate: output?.sampling_rate,
|
|
314
|
+
voice: input.voice,
|
|
315
|
+
});
|
|
316
|
+
},
|
|
317
|
+
dispose: () => disposeEngine(synthesizer),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateInput(role, payload, configuration) {
|
|
322
|
+
if (role === "stt") {
|
|
323
|
+
if (!(payload?.audio instanceof Float32Array) || payload.sampleRate !== 16_000) {
|
|
324
|
+
throw workerError(
|
|
325
|
+
"ARCANE_AI_INVALID_REQUEST",
|
|
326
|
+
"Whisper requires Float32Array audio sampled at exactly 16000 Hz.",
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return Object.freeze({ audio: payload.audio, sampleRate: 16_000 });
|
|
330
|
+
}
|
|
331
|
+
const text = requiredText(payload?.text, "Kokoro text");
|
|
332
|
+
const voice = requiredText(
|
|
333
|
+
payload?.voice ?? configuration.model.defaultVoice,
|
|
334
|
+
"Kokoro voice",
|
|
335
|
+
);
|
|
336
|
+
const speed = payload?.speed ?? 1;
|
|
337
|
+
if (!Number.isFinite(speed) || speed <= 0 || speed > 4) {
|
|
338
|
+
throw workerError("ARCANE_AI_INVALID_REQUEST", "Kokoro speed must be greater than 0 and at most 4.");
|
|
339
|
+
}
|
|
340
|
+
return Object.freeze({ text, voice, speed });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function validateResult(role, result) {
|
|
344
|
+
if (role === "stt") {
|
|
345
|
+
if (!result || typeof result.text !== "string") {
|
|
346
|
+
throw workerError("ARCANE_AI_INVALID_PROVIDER_RESULT", "Whisper did not return text.");
|
|
347
|
+
}
|
|
348
|
+
return Object.freeze({ text: result.text.trim() });
|
|
349
|
+
}
|
|
350
|
+
if (!(result?.audio instanceof Float32Array) || result.sampleRate !== 24_000) {
|
|
351
|
+
throw workerError(
|
|
352
|
+
"ARCANE_AI_INVALID_PROVIDER_RESULT",
|
|
353
|
+
"Kokoro must return 24000 Hz Float32 PCM.",
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
for (const sample of result.audio) {
|
|
357
|
+
if (!Number.isFinite(sample)) {
|
|
358
|
+
throw workerError("ARCANE_AI_INVALID_PROVIDER_RESULT", "Kokoro returned non-finite PCM.");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return Object.freeze({
|
|
362
|
+
audio: result.audio,
|
|
363
|
+
sampleRate: 24_000,
|
|
364
|
+
voice: result.voice,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function createSpeechWorkerRuntime({ role, scope = globalThis, send } = {}) {
|
|
369
|
+
if (role !== "stt" && role !== "tts") {
|
|
370
|
+
throw new TypeError('Speech worker role must be "stt" or "tts".');
|
|
371
|
+
}
|
|
372
|
+
if (typeof send !== "function") {
|
|
373
|
+
throw new TypeError("Speech worker send() is required.");
|
|
374
|
+
}
|
|
375
|
+
let configuration = null;
|
|
376
|
+
let engine = null;
|
|
377
|
+
let restoreFetch = null;
|
|
378
|
+
let restoreCaches = null;
|
|
379
|
+
let disposed = false;
|
|
380
|
+
let tail = Promise.resolve();
|
|
381
|
+
const operations = new Map();
|
|
382
|
+
|
|
383
|
+
function status() {
|
|
384
|
+
return Object.freeze({
|
|
385
|
+
state: disposed ? "disposed" : engine ? "ready" : "unloaded",
|
|
386
|
+
loaded: engine !== null,
|
|
387
|
+
busy: operations.size > 0,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function load(request, signal) {
|
|
392
|
+
if (disposed) {
|
|
393
|
+
throw workerError("ARCANE_AI_PROVIDER_DISPOSED", "The speech worker is disposed.");
|
|
394
|
+
}
|
|
395
|
+
if (engine) return status();
|
|
396
|
+
configuration = validateConfiguration(request.payload?.configuration, role);
|
|
397
|
+
const entry = configuration.runtime.files.find((file) =>
|
|
398
|
+
file.path === configuration.runtime.entry);
|
|
399
|
+
restoreFetch = installAuthorizedFetch(scope, configuration);
|
|
400
|
+
try {
|
|
401
|
+
restoreCaches = installCacheIsolation(scope);
|
|
402
|
+
workerProgress(send, request.id, "runtime-import");
|
|
403
|
+
const namespace = await import(entry.moduleUrl);
|
|
404
|
+
throwIfAborted(signal);
|
|
405
|
+
const report = () => workerProgress(send, request.id, "model-load");
|
|
406
|
+
engine = role === "stt"
|
|
407
|
+
? await createWhisperEngine(namespace, configuration, signal, report)
|
|
408
|
+
: await createKokoroEngine(namespace, configuration, signal, report);
|
|
409
|
+
workerProgress(send, request.id, "ready", 1, 1);
|
|
410
|
+
return status();
|
|
411
|
+
} catch (error) {
|
|
412
|
+
restoreFetch?.();
|
|
413
|
+
restoreFetch = null;
|
|
414
|
+
restoreCaches?.();
|
|
415
|
+
restoreCaches = null;
|
|
416
|
+
configuration = null;
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function use(request, signal) {
|
|
422
|
+
if (!engine || !configuration) {
|
|
423
|
+
throw workerError("ARCANE_AI_NOT_READY", "The speech worker is not loaded.");
|
|
424
|
+
}
|
|
425
|
+
const input = validateInput(role, request.payload, configuration);
|
|
426
|
+
const method = role === "stt" ? engine.transcribe : engine.synthesize;
|
|
427
|
+
if (typeof method !== "function") {
|
|
428
|
+
throw workerError("ARCANE_AI_PROVIDER_UNAVAILABLE", "The speech engine operation is unavailable.");
|
|
429
|
+
}
|
|
430
|
+
return validateResult(role, await method(input, { signal }));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function unload() {
|
|
434
|
+
for (const controller of operations.values()) controller.abort();
|
|
435
|
+
const current = engine;
|
|
436
|
+
engine = null;
|
|
437
|
+
configuration = null;
|
|
438
|
+
try {
|
|
439
|
+
await disposeEngine(current);
|
|
440
|
+
} finally {
|
|
441
|
+
restoreFetch?.();
|
|
442
|
+
restoreFetch = null;
|
|
443
|
+
restoreCaches?.();
|
|
444
|
+
restoreCaches = null;
|
|
445
|
+
}
|
|
446
|
+
return status();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function dispatch(request, signal) {
|
|
450
|
+
if (request.op === "load") return load(request, signal);
|
|
451
|
+
if (request.op === "use") return use(request, signal);
|
|
452
|
+
if (request.op === "status") return status();
|
|
453
|
+
if (request.op === "unload") return unload();
|
|
454
|
+
if (request.op === "dispose") {
|
|
455
|
+
await unload();
|
|
456
|
+
disposed = true;
|
|
457
|
+
return status();
|
|
458
|
+
}
|
|
459
|
+
throw workerError("ARCANE_AI_INVALID_REQUEST", "The speech worker operation is unsupported.");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function respond(request, operation) {
|
|
463
|
+
operation.then((result) => send({
|
|
464
|
+
protocol: SPEECH_WORKER_PROTOCOL,
|
|
465
|
+
id: request.id,
|
|
466
|
+
ok: true,
|
|
467
|
+
result: result ?? null,
|
|
468
|
+
}, collectSpeechTransferables(result)), (error) => send({
|
|
469
|
+
protocol: SPEECH_WORKER_PROTOCOL,
|
|
470
|
+
id: request.id,
|
|
471
|
+
ok: false,
|
|
472
|
+
error: serializedError(error),
|
|
473
|
+
}, []));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function handleMessage(request) {
|
|
477
|
+
if (request?.protocol !== SPEECH_WORKER_PROTOCOL
|
|
478
|
+
|| !Number.isSafeInteger(request.id)
|
|
479
|
+
|| request.id < 1) {
|
|
480
|
+
return Promise.reject(workerError("ARCANE_AI_INVALID_REQUEST", "The speech worker envelope is invalid."));
|
|
481
|
+
}
|
|
482
|
+
if (request.op === "cancel") {
|
|
483
|
+
const target = operations.get(request.payload?.targetId);
|
|
484
|
+
target?.abort(request.payload?.reason);
|
|
485
|
+
const result = Promise.resolve(Object.freeze({ cancelled: Boolean(target) }));
|
|
486
|
+
respond(request, result);
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
const controller = new AbortController();
|
|
490
|
+
const execute = tail.catch(() => undefined).then(() => {
|
|
491
|
+
throwIfAborted(controller.signal);
|
|
492
|
+
return dispatch(request, controller.signal);
|
|
493
|
+
});
|
|
494
|
+
operations.set(request.id, controller);
|
|
495
|
+
const operation = execute.finally(() => operations.delete(request.id));
|
|
496
|
+
tail = operation.catch(() => undefined);
|
|
497
|
+
respond(request, operation);
|
|
498
|
+
return operation;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return Object.freeze({ handleMessage, status });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function installBrowserSpeechWorker(role, scope = globalThis) {
|
|
505
|
+
const runtime = createSpeechWorkerRuntime({
|
|
506
|
+
role,
|
|
507
|
+
scope,
|
|
508
|
+
send: (message, transfers) => scope.postMessage(message, transfers),
|
|
509
|
+
});
|
|
510
|
+
scope.addEventListener("message", (event) => {
|
|
511
|
+
// Valid envelopes own their one response inside handleMessage(). Invalid
|
|
512
|
+
// envelopes have no trustworthy request id and are intentionally ignored.
|
|
513
|
+
void runtime.handleMessage(event.data).catch(() => undefined);
|
|
514
|
+
});
|
|
515
|
+
return runtime;
|
|
516
|
+
}
|