arcane-os 0.1.2 → 0.2.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/CHANGELOG.md +35 -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 +780 -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 +551 -62
- package/runtime/arcane/components/speech.html +1113 -265
- package/runtime/arcane/entities/Chat.js +246 -43
- package/runtime/arcane/modules/AI.js +1394 -162
- package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
- package/runtime/arcane/modules/AIRuntimeState.js +872 -0
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +382 -31
- package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +1106 -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 +10 -6
- package/src/cli/main.mjs +14 -2
- package/src/constants.mjs +1 -1
- package/src/dev-server.mjs +273 -26
- package/src/doctor.mjs +1 -3
- package/src/import-map.mjs +193 -84
- package/src/packager/core.mjs +313 -41
- package/src/runtime.mjs +14 -4
- package/src/scaffold.mjs +45 -17
- package/src/sdk-browser-runtime.mjs +28 -75
- package/src/templates/workspace-template.mjs +27 -8
- package/src/toolchain.mjs +13 -2
- package/src/workspace-runtime.mjs +1 -1
- package/src/workspace.mjs +178 -25
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBrowserSpeechAuthority,
|
|
3
|
+
isBrowserSpeechAuthority,
|
|
4
|
+
isDbopfsSpeechArtifactStore,
|
|
5
|
+
} from "./browser-speech-artifacts.mjs";
|
|
6
|
+
import {
|
|
7
|
+
normalizeModelSecurity,
|
|
8
|
+
resolveModelSecurity,
|
|
9
|
+
sameModelSecurity,
|
|
10
|
+
} from "./model-controller.mjs";
|
|
11
|
+
import {
|
|
12
|
+
createSpeechWorkerClient,
|
|
13
|
+
isSpeechWorkerClient,
|
|
14
|
+
} from "./speech-worker-client.mjs";
|
|
15
|
+
|
|
16
|
+
const AI_PROVIDER_PROTOCOL = "arcane-ai-provider/2";
|
|
17
|
+
const ROLE_OPERATION = Object.freeze({ stt: "transcribe", tts: "synthesize" });
|
|
18
|
+
const STT_SAMPLE_RATE = 16_000;
|
|
19
|
+
const TTS_SAMPLE_RATE = 24_000;
|
|
20
|
+
const TTS_RESPONSE_FORMAT = "wav";
|
|
21
|
+
const WORKER_FAILURE_CODES = new Set([
|
|
22
|
+
"ARCANE_AI_WORKER_CRASHED",
|
|
23
|
+
"ARCANE_AI_WORKER_MESSAGE_ERROR",
|
|
24
|
+
"ARCANE_AI_ADAPTER_PROTOCOL_MISMATCH",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function providerError(code, message, cause) {
|
|
28
|
+
const error = cause === undefined
|
|
29
|
+
? new Error(message)
|
|
30
|
+
: new Error(message, { cause });
|
|
31
|
+
error.name = code === "ARCANE_AI_REQUEST_ABORTED"
|
|
32
|
+
? "AbortError"
|
|
33
|
+
: "ArcaneBrowserSpeechProviderError";
|
|
34
|
+
error.code = code;
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function abortError(signal) {
|
|
39
|
+
return providerError(
|
|
40
|
+
"ARCANE_AI_REQUEST_ABORTED",
|
|
41
|
+
"The browser speech operation was cancelled.",
|
|
42
|
+
signal?.reason,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function throwIfAborted(signal) {
|
|
47
|
+
if (signal?.aborted) throw abortError(signal);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function workerFailureCode(error) {
|
|
51
|
+
return WORKER_FAILURE_CODES.has(error?.code)
|
|
52
|
+
? error.code
|
|
53
|
+
: "ARCANE_AI_WORKER_CRASHED";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function requiredIdentifier(value, label) {
|
|
57
|
+
if (typeof value !== "string" || !value.trim() || value.trim().length > 128) {
|
|
58
|
+
throw new TypeError(`${label} must be a trimmed 1-128 character string.`);
|
|
59
|
+
}
|
|
60
|
+
return value.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function linkSignal(signal) {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const abort = () => controller.abort(signal?.reason);
|
|
66
|
+
if (signal?.aborted) abort();
|
|
67
|
+
else signal?.addEventListener?.("abort", abort, { once: true });
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
controller,
|
|
70
|
+
release: () => signal?.removeEventListener?.("abort", abort),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function publicStatus({ role, id, authority, state, busy, generation, errorCode, cache }) {
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
role,
|
|
77
|
+
providerId: id,
|
|
78
|
+
modelId: authority.modelId,
|
|
79
|
+
state,
|
|
80
|
+
loaded: state === "ready",
|
|
81
|
+
busy,
|
|
82
|
+
generation,
|
|
83
|
+
errorCode,
|
|
84
|
+
cache,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assertRequestAuthority(context, authority, providerId) {
|
|
89
|
+
if (!Object.hasOwn(context, "selection")) return;
|
|
90
|
+
const selection = context.selection;
|
|
91
|
+
if (selection?.providerId !== providerId
|
|
92
|
+
|| selection?.modelId !== authority.modelId
|
|
93
|
+
|| selection?.localOnly === false) {
|
|
94
|
+
throw providerError(
|
|
95
|
+
"ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
|
|
96
|
+
"The browser speech request selection changed from its immutable provider authority.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function assertPayloadModel(payload, authority, { required = false } = {}) {
|
|
102
|
+
if (!Object.hasOwn(payload, "model")) {
|
|
103
|
+
if (!required) return;
|
|
104
|
+
throw providerError(
|
|
105
|
+
"ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
|
|
106
|
+
"The shared speech request must identify the provider's immutable model authority.",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const descriptor = Object.getOwnPropertyDescriptor(payload, "model");
|
|
110
|
+
if (!Object.hasOwn(descriptor ?? {}, "value") || descriptor.value !== authority.modelId) {
|
|
111
|
+
throw providerError(
|
|
112
|
+
"ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
|
|
113
|
+
"The shared speech request model does not match this provider authority.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function genericPayloadDescriptors(payload, allowedKeys, requiredKeys, label) {
|
|
119
|
+
const prototype = Object.getPrototypeOf(payload);
|
|
120
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
121
|
+
throw providerError("ARCANE_AI_INVALID_REQUEST", `${label} must be a plain object.`);
|
|
122
|
+
}
|
|
123
|
+
const descriptors = Object.getOwnPropertyDescriptors(payload);
|
|
124
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
125
|
+
if (typeof key === "symbol" || !allowedKeys.includes(key)) {
|
|
126
|
+
throw providerError("ARCANE_AI_INVALID_REQUEST", `${label} contains an unknown field.`);
|
|
127
|
+
}
|
|
128
|
+
if (!Object.hasOwn(descriptors[key], "value")) {
|
|
129
|
+
throw providerError("ARCANE_AI_INVALID_REQUEST", `${label}.${key} must be a data property.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const key of requiredKeys) {
|
|
133
|
+
if (!Object.hasOwn(descriptors, key)) {
|
|
134
|
+
throw providerError("ARCANE_AI_INVALID_REQUEST", `${label}.${key} is required.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return descriptors;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function audioMimeEssence(value, label) {
|
|
141
|
+
if (typeof value !== "string") {
|
|
142
|
+
throw providerError("ARCANE_AI_INVALID_REQUEST", `${label} must be an audio MIME type.`);
|
|
143
|
+
}
|
|
144
|
+
const essence = value.split(";", 1)[0].trim().toLowerCase();
|
|
145
|
+
if (!/^audio\/[a-z0-9!#$%&'*+.^_`|~-]+$/u.test(essence)) {
|
|
146
|
+
throw providerError("ARCANE_AI_INVALID_REQUEST", `${label} must be an audio MIME type.`);
|
|
147
|
+
}
|
|
148
|
+
return essence;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function awaitAbortableSpeechOperation(operation, signal) {
|
|
152
|
+
const observed = Promise.resolve(operation);
|
|
153
|
+
if (!signal) return observed;
|
|
154
|
+
if (signal.aborted) {
|
|
155
|
+
// Blob and Web Audio operations cannot be preempted; retain their eventual
|
|
156
|
+
// rejection after cancellation instead of leaving an unobserved promise.
|
|
157
|
+
observed.catch(function retainCancelledSpeechOperation() {});
|
|
158
|
+
return Promise.reject(abortError(signal));
|
|
159
|
+
}
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
let settled = false;
|
|
162
|
+
const release = () => signal.removeEventListener("abort", cancel);
|
|
163
|
+
const settle = (callback, value) => {
|
|
164
|
+
if (settled) return;
|
|
165
|
+
settled = true;
|
|
166
|
+
release();
|
|
167
|
+
callback(value);
|
|
168
|
+
};
|
|
169
|
+
const cancel = () => settle(reject, abortError(signal));
|
|
170
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
171
|
+
observed.then(
|
|
172
|
+
(value) => settle(resolve, value),
|
|
173
|
+
(error) => settle(reject, error),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function decodeSharedTranscriptionPayload(payload, authority, signal) {
|
|
179
|
+
const descriptors = genericPayloadDescriptors(
|
|
180
|
+
payload,
|
|
181
|
+
["audio", "mimeType", "model"],
|
|
182
|
+
["audio", "mimeType", "model"],
|
|
183
|
+
"Shared speech transcription payload",
|
|
184
|
+
);
|
|
185
|
+
assertPayloadModel(payload, authority, { required: true });
|
|
186
|
+
const audio = descriptors.audio.value;
|
|
187
|
+
if (typeof Blob !== "function" || !(audio instanceof Blob) || audio.size < 1) {
|
|
188
|
+
throw providerError(
|
|
189
|
+
"ARCANE_AI_INVALID_REQUEST",
|
|
190
|
+
"Shared speech transcription requires a nonempty audio Blob or File.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const mimeType = audioMimeEssence(descriptors.mimeType.value, "Speech transcription mimeType");
|
|
194
|
+
if (audio.type && audioMimeEssence(audio.type, "Speech transcription Blob.type") !== mimeType) {
|
|
195
|
+
throw providerError(
|
|
196
|
+
"ARCANE_AI_INVALID_REQUEST",
|
|
197
|
+
"Speech transcription mimeType does not match the audio Blob type.",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const OfflineAudioContext = globalThis.OfflineAudioContext
|
|
201
|
+
?? globalThis.webkitOfflineAudioContext;
|
|
202
|
+
if (typeof OfflineAudioContext !== "function") {
|
|
203
|
+
throw providerError(
|
|
204
|
+
"ARCANE_AI_AUDIO_DECODE_UNAVAILABLE",
|
|
205
|
+
"Shared Blob transcription requires OfflineAudioContext decoding at 16000 Hz.",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
let decoder;
|
|
209
|
+
try {
|
|
210
|
+
decoder = new OfflineAudioContext(1, 1, STT_SAMPLE_RATE);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
throw providerError(
|
|
213
|
+
"ARCANE_AI_AUDIO_DECODE_UNAVAILABLE",
|
|
214
|
+
"Unable to create the required 16000 Hz speech decoder.",
|
|
215
|
+
error,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (typeof decoder.decodeAudioData !== "function") {
|
|
219
|
+
throw providerError(
|
|
220
|
+
"ARCANE_AI_AUDIO_DECODE_UNAVAILABLE",
|
|
221
|
+
"Shared Blob transcription requires browser audio decoding.",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
let decoded;
|
|
225
|
+
try {
|
|
226
|
+
const encoded = await awaitAbortableSpeechOperation(audio.arrayBuffer(), signal);
|
|
227
|
+
throwIfAborted(signal);
|
|
228
|
+
decoded = await awaitAbortableSpeechOperation(decoder.decodeAudioData(encoded), signal);
|
|
229
|
+
throwIfAborted(signal);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (signal?.aborted && error?.code === "ARCANE_AI_REQUEST_ABORTED") throw error;
|
|
232
|
+
throw providerError(
|
|
233
|
+
"ARCANE_AI_AUDIO_DECODE_FAILED",
|
|
234
|
+
"The browser could not decode the supplied speech audio.",
|
|
235
|
+
error,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (decoded?.sampleRate !== STT_SAMPLE_RATE
|
|
239
|
+
|| !Number.isSafeInteger(decoded.length)
|
|
240
|
+
|| decoded.length < 1
|
|
241
|
+
|| !Number.isSafeInteger(decoded.numberOfChannels)
|
|
242
|
+
|| decoded.numberOfChannels < 1
|
|
243
|
+
|| typeof decoded.getChannelData !== "function") {
|
|
244
|
+
throw providerError(
|
|
245
|
+
"ARCANE_AI_AUDIO_DECODE_FAILED",
|
|
246
|
+
"Decoded speech audio must provide nonempty 16000 Hz channel data.",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const channels = [];
|
|
250
|
+
for (let index = 0; index < decoded.numberOfChannels; index += 1) {
|
|
251
|
+
const channel = decoded.getChannelData(index);
|
|
252
|
+
if (!(channel instanceof Float32Array) || channel.length !== decoded.length) {
|
|
253
|
+
throw providerError(
|
|
254
|
+
"ARCANE_AI_AUDIO_DECODE_FAILED",
|
|
255
|
+
"Decoded speech audio returned invalid channel data.",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
channels.push(channel);
|
|
259
|
+
}
|
|
260
|
+
const mono = new Float32Array(decoded.length);
|
|
261
|
+
for (let index = 0; index < mono.length; index += 1) {
|
|
262
|
+
let sample = 0;
|
|
263
|
+
for (const channel of channels) sample += channel[index];
|
|
264
|
+
sample /= channels.length;
|
|
265
|
+
if (!Number.isFinite(sample)) {
|
|
266
|
+
throw providerError(
|
|
267
|
+
"ARCANE_AI_AUDIO_DECODE_FAILED",
|
|
268
|
+
"Decoded speech audio contains a non-finite sample.",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
mono[index] = sample;
|
|
272
|
+
}
|
|
273
|
+
throwIfAborted(signal);
|
|
274
|
+
return Object.freeze({
|
|
275
|
+
payload: Object.freeze({ audio: mono, sampleRate: STT_SAMPLE_RATE }),
|
|
276
|
+
shared: true,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function cloneNativeTranscriptionPayload(payload, authority) {
|
|
281
|
+
assertPayloadModel(payload, authority);
|
|
282
|
+
if (!(payload.audio instanceof Float32Array) || payload.sampleRate !== STT_SAMPLE_RATE) {
|
|
283
|
+
throw providerError(
|
|
284
|
+
"ARCANE_AI_INVALID_REQUEST",
|
|
285
|
+
"Whisper requires Float32Array audio sampled at exactly 16000 Hz.",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return Object.freeze({
|
|
289
|
+
payload: Object.freeze({
|
|
290
|
+
audio: new Float32Array(payload.audio),
|
|
291
|
+
sampleRate: STT_SAMPLE_RATE,
|
|
292
|
+
}),
|
|
293
|
+
shared: false,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeSynthesisPayload(payload, authority) {
|
|
298
|
+
const shared = Object.hasOwn(payload, "input");
|
|
299
|
+
let textValue = payload.text;
|
|
300
|
+
if (shared) {
|
|
301
|
+
const descriptors = genericPayloadDescriptors(
|
|
302
|
+
payload,
|
|
303
|
+
["model", "voice", "input", "responseFormat", "speed"],
|
|
304
|
+
["model", "input", "responseFormat"],
|
|
305
|
+
"Shared speech synthesis payload",
|
|
306
|
+
);
|
|
307
|
+
assertPayloadModel(payload, authority, { required: true });
|
|
308
|
+
if (descriptors.responseFormat.value !== TTS_RESPONSE_FORMAT) {
|
|
309
|
+
throw providerError(
|
|
310
|
+
"ARCANE_AI_UNSUPPORTED_RESPONSE_FORMAT",
|
|
311
|
+
"Browser Kokoro supports only wav responses for shared speech requests.",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
textValue = descriptors.input.value;
|
|
315
|
+
} else {
|
|
316
|
+
assertPayloadModel(payload, authority);
|
|
317
|
+
}
|
|
318
|
+
const text = typeof textValue === "string" ? textValue.trim() : "";
|
|
319
|
+
const voice = typeof (payload.voice ?? authority.defaultVoice) === "string"
|
|
320
|
+
? (payload.voice ?? authority.defaultVoice).trim()
|
|
321
|
+
: "";
|
|
322
|
+
const speed = payload.speed ?? 1;
|
|
323
|
+
if (!text || !voice || !Number.isFinite(speed) || speed <= 0 || speed > 4) {
|
|
324
|
+
throw providerError(
|
|
325
|
+
"ARCANE_AI_INVALID_REQUEST",
|
|
326
|
+
"Kokoro requires text, a voice, and speed greater than 0 and at most 4.",
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return Object.freeze({
|
|
330
|
+
payload: Object.freeze({ text, voice, speed }),
|
|
331
|
+
shared,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function normalizeRequestPayload(role, payload, authority, signal) {
|
|
336
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
337
|
+
throw providerError("ARCANE_AI_INVALID_REQUEST", "Speech request payload must be an object.");
|
|
338
|
+
}
|
|
339
|
+
if (role === "stt") {
|
|
340
|
+
const audio = Object.getOwnPropertyDescriptor(payload, "audio");
|
|
341
|
+
if (Object.hasOwn(audio ?? {}, "value")
|
|
342
|
+
&& typeof Blob === "function"
|
|
343
|
+
&& audio.value instanceof Blob) {
|
|
344
|
+
return decodeSharedTranscriptionPayload(payload, authority, signal);
|
|
345
|
+
}
|
|
346
|
+
return cloneNativeTranscriptionPayload(payload, authority);
|
|
347
|
+
}
|
|
348
|
+
return normalizeSynthesisPayload(payload, authority);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function encodeSharedSynthesisResult(result) {
|
|
352
|
+
if (!(result?.audio instanceof Float32Array)
|
|
353
|
+
|| result.sampleRate !== TTS_SAMPLE_RATE
|
|
354
|
+
|| result.audio.length < 1
|
|
355
|
+
|| result.audio.length > (0xffffffff - 44) / 2) {
|
|
356
|
+
throw providerError(
|
|
357
|
+
"ARCANE_AI_INVALID_PROVIDER_RESULT",
|
|
358
|
+
"Browser Kokoro must return nonempty 24000 Hz Float32 PCM.",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
const buffer = new ArrayBuffer(44 + result.audio.length * 2);
|
|
362
|
+
const bytes = new Uint8Array(buffer);
|
|
363
|
+
const view = new DataView(buffer);
|
|
364
|
+
const writeText = (offset, value) => {
|
|
365
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
366
|
+
view.setUint8(offset + index, value.charCodeAt(index));
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
writeText(0, "RIFF");
|
|
370
|
+
view.setUint32(4, buffer.byteLength - 8, true);
|
|
371
|
+
writeText(8, "WAVE");
|
|
372
|
+
writeText(12, "fmt ");
|
|
373
|
+
view.setUint32(16, 16, true);
|
|
374
|
+
view.setUint16(20, 1, true);
|
|
375
|
+
view.setUint16(22, 1, true);
|
|
376
|
+
view.setUint32(24, TTS_SAMPLE_RATE, true);
|
|
377
|
+
view.setUint32(28, TTS_SAMPLE_RATE * 2, true);
|
|
378
|
+
view.setUint16(32, 2, true);
|
|
379
|
+
view.setUint16(34, 16, true);
|
|
380
|
+
writeText(36, "data");
|
|
381
|
+
view.setUint32(40, result.audio.length * 2, true);
|
|
382
|
+
for (let index = 0; index < result.audio.length; index += 1) {
|
|
383
|
+
const sample = result.audio[index];
|
|
384
|
+
if (!Number.isFinite(sample)) {
|
|
385
|
+
throw providerError(
|
|
386
|
+
"ARCANE_AI_INVALID_PROVIDER_RESULT",
|
|
387
|
+
"Browser Kokoro returned a non-finite PCM sample.",
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const clamped = Math.max(-1, Math.min(1, sample));
|
|
391
|
+
view.setInt16(
|
|
392
|
+
44 + index * 2,
|
|
393
|
+
Math.round(clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff),
|
|
394
|
+
true,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
return Object.freeze({ audio: bytes, contentType: "audio/wav" });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function createBrowserSpeechProvider({
|
|
401
|
+
role,
|
|
402
|
+
id,
|
|
403
|
+
localOnly = true,
|
|
404
|
+
model,
|
|
405
|
+
runtime,
|
|
406
|
+
appSecurity,
|
|
407
|
+
security,
|
|
408
|
+
store,
|
|
409
|
+
offline = false,
|
|
410
|
+
} = {}) {
|
|
411
|
+
const providerId = requiredIdentifier(id, "Browser speech provider id");
|
|
412
|
+
if (localOnly !== true) {
|
|
413
|
+
throw new TypeError("Browser speech providers are localOnly.");
|
|
414
|
+
}
|
|
415
|
+
if (typeof offline !== "boolean") {
|
|
416
|
+
throw new TypeError("Browser speech offline must be a boolean.");
|
|
417
|
+
}
|
|
418
|
+
if (!isDbopfsSpeechArtifactStore(store)) {
|
|
419
|
+
throw new TypeError("Browser speech providers require an SDK-created DBOPFS artifact store.");
|
|
420
|
+
}
|
|
421
|
+
const configuredAppSecurity = normalizeModelSecurity(
|
|
422
|
+
appSecurity,
|
|
423
|
+
"Browser speech app security",
|
|
424
|
+
);
|
|
425
|
+
const configuredProviderSecurity = normalizeModelSecurity(
|
|
426
|
+
security,
|
|
427
|
+
"Browser speech provider security",
|
|
428
|
+
);
|
|
429
|
+
const authority = createBrowserSpeechAuthority({
|
|
430
|
+
providerId,
|
|
431
|
+
role,
|
|
432
|
+
model,
|
|
433
|
+
runtime,
|
|
434
|
+
security: configuredProviderSecurity,
|
|
435
|
+
});
|
|
436
|
+
if (!isBrowserSpeechAuthority(authority)) {
|
|
437
|
+
throw new TypeError("Browser speech authority construction failed.");
|
|
438
|
+
}
|
|
439
|
+
const operation = ROLE_OPERATION[role];
|
|
440
|
+
const speech = role === "stt"
|
|
441
|
+
? Object.freeze({ inputSampleRate: STT_SAMPLE_RATE })
|
|
442
|
+
: Object.freeze({
|
|
443
|
+
outputSampleRate: TTS_SAMPLE_RATE,
|
|
444
|
+
responseFormats: Object.freeze([TTS_RESPONSE_FORMAT]),
|
|
445
|
+
defaultResponseFormat: TTS_RESPONSE_FORMAT,
|
|
446
|
+
});
|
|
447
|
+
const catalogEntry = Object.freeze({
|
|
448
|
+
id: authority.modelId,
|
|
449
|
+
providerId,
|
|
450
|
+
role,
|
|
451
|
+
localOnly: true,
|
|
452
|
+
repository: authority.repository,
|
|
453
|
+
revision: authority.revision,
|
|
454
|
+
runtime: authority.runtime,
|
|
455
|
+
files: authority.files,
|
|
456
|
+
speech,
|
|
457
|
+
});
|
|
458
|
+
let state = "unloaded";
|
|
459
|
+
let errorCode = null;
|
|
460
|
+
let cache = null;
|
|
461
|
+
let generation = 0;
|
|
462
|
+
let active = null;
|
|
463
|
+
let loadOperation = null;
|
|
464
|
+
let unloadOperation = null;
|
|
465
|
+
let disposeOperation = null;
|
|
466
|
+
let requestOperation = null;
|
|
467
|
+
|
|
468
|
+
function status() {
|
|
469
|
+
return publicStatus({
|
|
470
|
+
role,
|
|
471
|
+
id: providerId,
|
|
472
|
+
authority,
|
|
473
|
+
state,
|
|
474
|
+
busy: requestOperation !== null,
|
|
475
|
+
generation,
|
|
476
|
+
errorCode,
|
|
477
|
+
cache,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function releaseSlot(slot) {
|
|
482
|
+
if (!slot || slot.released) return;
|
|
483
|
+
slot.released = true;
|
|
484
|
+
slot.prepared.release();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function terminateSlot(slot, reason, { intentional = true } = {}) {
|
|
488
|
+
if (!slot) return;
|
|
489
|
+
if (!isSpeechWorkerClient(slot.client)) {
|
|
490
|
+
throw providerError(
|
|
491
|
+
"ARCANE_AI_ADAPTER_PROTOCOL_MISMATCH",
|
|
492
|
+
"The browser speech Worker client is not SDK-owned.",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
await slot.client.terminate(reason, { intentional });
|
|
496
|
+
releaseSlot(slot);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const provider = {
|
|
500
|
+
protocol: AI_PROVIDER_PROTOCOL,
|
|
501
|
+
role,
|
|
502
|
+
id: providerId,
|
|
503
|
+
localOnly: true,
|
|
504
|
+
|
|
505
|
+
catalog() {
|
|
506
|
+
return Object.freeze([catalogEntry]);
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
inspect(selection, { signal } = {}) {
|
|
510
|
+
throwIfAborted(signal);
|
|
511
|
+
const available = selection?.providerId === providerId
|
|
512
|
+
&& selection?.modelId === authority.modelId
|
|
513
|
+
&& selection?.localOnly !== false;
|
|
514
|
+
if (!available) {
|
|
515
|
+
return Object.freeze({
|
|
516
|
+
available: false,
|
|
517
|
+
code: "ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
|
|
518
|
+
message: "The selected browser speech model does not match this provider authority.",
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
return Object.freeze({ available: true, authority });
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
status,
|
|
525
|
+
|
|
526
|
+
load(context = {}) {
|
|
527
|
+
if (state === "disposed" || disposeOperation) {
|
|
528
|
+
return Promise.reject(providerError("ARCANE_AI_PROVIDER_DISPOSED", "The browser speech provider is disposed."));
|
|
529
|
+
}
|
|
530
|
+
if (unloadOperation || state === "unloading") {
|
|
531
|
+
return Promise.reject(providerError(
|
|
532
|
+
"ARCANE_AI_OPERATION_SUPERSEDED",
|
|
533
|
+
"The browser speech provider is unloading.",
|
|
534
|
+
));
|
|
535
|
+
}
|
|
536
|
+
if (typeof context.progress !== "function") {
|
|
537
|
+
return Promise.reject(new TypeError("Browser speech load progress must be a function."));
|
|
538
|
+
}
|
|
539
|
+
if (context.role !== role
|
|
540
|
+
|| context.selection?.providerId !== providerId
|
|
541
|
+
|| context.selection?.modelId !== authority.modelId
|
|
542
|
+
|| context.selection?.localOnly === false) {
|
|
543
|
+
return Promise.reject(providerError("ARCANE_AI_MODEL_AUTHORITY_REQUIRED", "Browser speech load selection changed."));
|
|
544
|
+
}
|
|
545
|
+
let effectiveSecurity;
|
|
546
|
+
try {
|
|
547
|
+
effectiveSecurity = resolveModelSecurity({
|
|
548
|
+
app: configuredAppSecurity,
|
|
549
|
+
binding: configuredProviderSecurity,
|
|
550
|
+
load: context.security,
|
|
551
|
+
});
|
|
552
|
+
throwIfAborted(context.signal);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
return Promise.reject(error);
|
|
555
|
+
}
|
|
556
|
+
if (state === "ready" && active) {
|
|
557
|
+
if (sameModelSecurity(active.security, effectiveSecurity)) {
|
|
558
|
+
return Promise.resolve(status());
|
|
559
|
+
}
|
|
560
|
+
return provider.unload().then(() => provider.load(context));
|
|
561
|
+
}
|
|
562
|
+
if (loadOperation) {
|
|
563
|
+
if (sameModelSecurity(loadOperation.security, effectiveSecurity)) {
|
|
564
|
+
return loadOperation.promise;
|
|
565
|
+
}
|
|
566
|
+
loadOperation.abort();
|
|
567
|
+
return loadOperation.promise.catch(() => undefined).then(() => provider.load(context));
|
|
568
|
+
}
|
|
569
|
+
generation += 1;
|
|
570
|
+
const operationGeneration = generation;
|
|
571
|
+
const linked = linkSignal(context.signal);
|
|
572
|
+
state = "loading";
|
|
573
|
+
errorCode = null;
|
|
574
|
+
const promise = (async () => {
|
|
575
|
+
let prepared = null;
|
|
576
|
+
let slot = null;
|
|
577
|
+
try {
|
|
578
|
+
prepared = await store.prepare(authority, {
|
|
579
|
+
signal: linked.controller.signal,
|
|
580
|
+
onProgress: context.progress,
|
|
581
|
+
offline,
|
|
582
|
+
security: effectiveSecurity,
|
|
583
|
+
});
|
|
584
|
+
throwIfAborted(linked.controller.signal);
|
|
585
|
+
if (operationGeneration !== generation) {
|
|
586
|
+
throw providerError("ARCANE_AI_OPERATION_SUPERSEDED", "Browser speech loading was superseded.");
|
|
587
|
+
}
|
|
588
|
+
slot = {
|
|
589
|
+
prepared,
|
|
590
|
+
released: false,
|
|
591
|
+
client: null,
|
|
592
|
+
security: effectiveSecurity,
|
|
593
|
+
};
|
|
594
|
+
slot.client = createSpeechWorkerClient({
|
|
595
|
+
role,
|
|
596
|
+
onTermination({ reason, intentional }) {
|
|
597
|
+
if (active === slot) {
|
|
598
|
+
active = null;
|
|
599
|
+
if (state !== "disposed" && state !== "unloading") {
|
|
600
|
+
state = intentional ? "unloaded" : "error";
|
|
601
|
+
errorCode = intentional ? null : workerFailureCode(reason);
|
|
602
|
+
}
|
|
603
|
+
releaseSlot(slot);
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
if (!isSpeechWorkerClient(slot.client)) {
|
|
608
|
+
throw providerError(
|
|
609
|
+
"ARCANE_AI_ADAPTER_PROTOCOL_MISMATCH",
|
|
610
|
+
"The browser speech Worker client is not SDK-owned.",
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const configuration = Object.freeze({
|
|
614
|
+
role,
|
|
615
|
+
runtime: prepared.runtime,
|
|
616
|
+
model: prepared.model,
|
|
617
|
+
});
|
|
618
|
+
await slot.client.request("load", { configuration }, {
|
|
619
|
+
signal: linked.controller.signal,
|
|
620
|
+
progress: context.progress,
|
|
621
|
+
});
|
|
622
|
+
throwIfAborted(linked.controller.signal);
|
|
623
|
+
if (operationGeneration !== generation) {
|
|
624
|
+
throw providerError("ARCANE_AI_OPERATION_SUPERSEDED", "Browser speech loading was superseded.");
|
|
625
|
+
}
|
|
626
|
+
active = slot;
|
|
627
|
+
cache = prepared.cache;
|
|
628
|
+
state = "ready";
|
|
629
|
+
return status();
|
|
630
|
+
} catch (error) {
|
|
631
|
+
if (slot) await terminateSlot(slot, error).catch(() => undefined);
|
|
632
|
+
else prepared?.release();
|
|
633
|
+
if (operationGeneration === generation && state !== "unloading" && state !== "disposed") {
|
|
634
|
+
state = error?.code === "ARCANE_AI_REQUEST_ABORTED"
|
|
635
|
+
|| error?.code === "ARCANE_AI_OPERATION_SUPERSEDED"
|
|
636
|
+
? "unloaded"
|
|
637
|
+
: "error";
|
|
638
|
+
errorCode = error?.code ?? "ARCANE_AI_PROVIDER_LOAD_FAILED";
|
|
639
|
+
}
|
|
640
|
+
throw error;
|
|
641
|
+
} finally {
|
|
642
|
+
linked.release();
|
|
643
|
+
if (loadOperation?.promise === promise) loadOperation = null;
|
|
644
|
+
}
|
|
645
|
+
})();
|
|
646
|
+
loadOperation = Object.freeze({
|
|
647
|
+
promise,
|
|
648
|
+
security: effectiveSecurity,
|
|
649
|
+
abort: () => linked.controller.abort(),
|
|
650
|
+
});
|
|
651
|
+
return promise;
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
async request(context = {}) {
|
|
655
|
+
if (context.role !== role || context.operation !== operation) {
|
|
656
|
+
throw providerError("ARCANE_AI_INVALID_REQUEST", `Browser ${role} supports only ${operation}.`);
|
|
657
|
+
}
|
|
658
|
+
assertRequestAuthority(context, authority, providerId);
|
|
659
|
+
const externalSignal = context.signal ?? null;
|
|
660
|
+
throwIfAborted(externalSignal);
|
|
661
|
+
if (state !== "ready" || !active) {
|
|
662
|
+
throw providerError("ARCANE_AI_NOT_READY", "The browser speech provider is not ready.");
|
|
663
|
+
}
|
|
664
|
+
if (requestOperation) {
|
|
665
|
+
throw providerError("ARCANE_AI_PROVIDER_BUSY", "The browser speech provider is already processing a request.");
|
|
666
|
+
}
|
|
667
|
+
const linked = linkSignal(externalSignal);
|
|
668
|
+
if (linked.controller.signal.aborted) {
|
|
669
|
+
linked.release();
|
|
670
|
+
throw abortError(externalSignal);
|
|
671
|
+
}
|
|
672
|
+
const slot = active;
|
|
673
|
+
const requestGeneration = generation;
|
|
674
|
+
let workerRequestStarted = false;
|
|
675
|
+
const promise = (async () => {
|
|
676
|
+
const normalized = await normalizeRequestPayload(
|
|
677
|
+
role,
|
|
678
|
+
context.payload,
|
|
679
|
+
authority,
|
|
680
|
+
linked.controller.signal,
|
|
681
|
+
);
|
|
682
|
+
throwIfAborted(linked.controller.signal);
|
|
683
|
+
workerRequestStarted = true;
|
|
684
|
+
const result = await slot.client.request("use", normalized.payload, {
|
|
685
|
+
signal: linked.controller.signal,
|
|
686
|
+
});
|
|
687
|
+
if (requestGeneration !== generation || active !== slot) {
|
|
688
|
+
throw providerError("ARCANE_AI_OPERATION_SUPERSEDED", "The browser speech result was superseded.");
|
|
689
|
+
}
|
|
690
|
+
return role === "tts" && normalized.shared
|
|
691
|
+
? encodeSharedSynthesisResult(result)
|
|
692
|
+
: result;
|
|
693
|
+
})();
|
|
694
|
+
requestOperation = Object.freeze({
|
|
695
|
+
promise,
|
|
696
|
+
abort: () => linked.controller.abort(),
|
|
697
|
+
});
|
|
698
|
+
try {
|
|
699
|
+
return await promise;
|
|
700
|
+
} catch (error) {
|
|
701
|
+
if (error?.code === "ARCANE_AI_REQUEST_ABORTED"
|
|
702
|
+
&& workerRequestStarted
|
|
703
|
+
&& active === slot) {
|
|
704
|
+
active = null;
|
|
705
|
+
releaseSlot(slot);
|
|
706
|
+
state = "unloaded";
|
|
707
|
+
}
|
|
708
|
+
throw error;
|
|
709
|
+
} finally {
|
|
710
|
+
linked.release();
|
|
711
|
+
if (requestOperation?.promise === promise) requestOperation = null;
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
|
|
715
|
+
unload() {
|
|
716
|
+
if (state === "disposed") return Promise.resolve(status());
|
|
717
|
+
if (unloadOperation) return unloadOperation;
|
|
718
|
+
generation += 1;
|
|
719
|
+
state = "unloading";
|
|
720
|
+
errorCode = null;
|
|
721
|
+
loadOperation?.abort();
|
|
722
|
+
requestOperation?.abort();
|
|
723
|
+
const promise = (async () => {
|
|
724
|
+
await Promise.allSettled([
|
|
725
|
+
loadOperation?.promise,
|
|
726
|
+
requestOperation?.promise,
|
|
727
|
+
].filter(Boolean));
|
|
728
|
+
const slot = active;
|
|
729
|
+
active = null;
|
|
730
|
+
await terminateSlot(slot, providerError(
|
|
731
|
+
"ARCANE_AI_OPERATION_SUPERSEDED",
|
|
732
|
+
"The browser speech Worker was terminated by unload().",
|
|
733
|
+
));
|
|
734
|
+
cache = null;
|
|
735
|
+
state = "unloaded";
|
|
736
|
+
return status();
|
|
737
|
+
})();
|
|
738
|
+
unloadOperation = promise.finally(() => {
|
|
739
|
+
if (unloadOperation === promise || unloadOperation === wrapped) unloadOperation = null;
|
|
740
|
+
});
|
|
741
|
+
const wrapped = unloadOperation;
|
|
742
|
+
return wrapped;
|
|
743
|
+
},
|
|
744
|
+
|
|
745
|
+
dispose() {
|
|
746
|
+
if (state === "disposed") return Promise.resolve(status());
|
|
747
|
+
if (disposeOperation) return disposeOperation;
|
|
748
|
+
const promise = (async () => {
|
|
749
|
+
await provider.unload();
|
|
750
|
+
state = "disposed";
|
|
751
|
+
return status();
|
|
752
|
+
})();
|
|
753
|
+
disposeOperation = promise.then((value) => {
|
|
754
|
+
disposeOperation = null;
|
|
755
|
+
return value;
|
|
756
|
+
}, (error) => {
|
|
757
|
+
disposeOperation = null;
|
|
758
|
+
throw error;
|
|
759
|
+
});
|
|
760
|
+
return disposeOperation;
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
return Object.freeze(provider);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function createBrowserWhisperProvider(options = {}) {
|
|
767
|
+
return createBrowserSpeechProvider({
|
|
768
|
+
...options,
|
|
769
|
+
role: "stt",
|
|
770
|
+
id: options.id ?? "arcane-browser-whisper",
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export function createBrowserKokoroProvider(options = {}) {
|
|
775
|
+
return createBrowserSpeechProvider({
|
|
776
|
+
...options,
|
|
777
|
+
role: "tts",
|
|
778
|
+
id: options.id ?? "arcane-browser-kokoro",
|
|
779
|
+
});
|
|
780
|
+
}
|