arcane-os 0.5.3 → 0.5.5

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.5
4
+
5
+ - Added configurable complete text-to-speech stream segmentation through
6
+ `AI.configureTTSSegmentation()`. Applications can speak on Unicode punctuation
7
+ or a whole-word cadence without discarding any text, while apostrophes,
8
+ commas, and hyphens that join Unicode letters or numbers remain intact.
9
+ Synthesis and playback stay sequential, and mute or cancellation still stops
10
+ the complete queue.
11
+
12
+ ## 0.5.4
13
+
14
+ - Reduced Browser-WASM HTTP Range parts from roughly 128 MB to roughly 4 MB
15
+ and raised the deterministic part ceiling, so a refresh re-fetches only the
16
+ small in-flight parts while every completed part remains reusable. An
17
+ incomplete 0.5.3 cache keeps its completed legacy parts and subdivides only
18
+ the missing legacy intervals, preserving existing progress during adoption.
19
+ - Kept all built-in audio processing on device: Whisper owns transcription and
20
+ Kokoro owns speech synthesis. Legacy OpenAI audio selections migrate to those
21
+ local routes, non-local speech configurations are rejected, and the retired
22
+ OpenAI audio credential and endpoints are no longer used.
23
+
3
24
  ## 0.5.3
4
25
 
5
26
  - Preserved every independently completed Browser-WASM HTTP Range part and
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.5.3` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.5.5` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -88,10 +88,10 @@ OpenAI-compatible chat-completion requests to
88
88
  `globalThis.arcane.config.twinCloud.accessKey`. The established `ai.license`
89
89
  property and internal `OPENAI` route identifier remain compatibility aliases;
90
90
  applications should present the service and credential as **TWiN Cloud** and
91
- **TWiN access key**. The TWiN key is used only for remote LLM chat. Existing
92
- OpenAI speech routes remain separate and read only their legacy
93
- `globalThis.arcane.config.openAI.apiKey`; the SDK never sends a TWiN key to
94
- those speech endpoints.
91
+ **TWiN access key**. The TWiN key is used only for remote LLM chat. Audio stays
92
+ on device: Whisper (`LOCAL_SPEACH` / `whisper-small`) owns transcription and
93
+ Kokoro (`LOCAL_SPEACH` / `kokoro`) owns speech synthesis. Neither audio route
94
+ uses the TWiN key, and no OpenAI audio key is required.
95
95
 
96
96
  ## Browser-local AI
97
97
 
@@ -124,11 +124,14 @@ Optional hardening is inactive unless the caller explicitly selects
124
124
  `secure:true`. The DBOPFS store downloads ordered members with one bounded
125
125
  parallel transfer pool and returns them in descriptor order. Completed shards
126
126
  survive an interrupted attempt and only missing shard or Range-part work is
127
- fetched on retry. A
128
- monolithic file uses up to `downloadConcurrency` active workers (four by
129
- default) over up to 16 deterministic, resumable HTTP range parts when the server
130
- confirms `206` responses and the total comes from `Content-Range` or, when that
131
- header is not exposed, optional declared `bytes`.
127
+ fetched on retry. A monolithic file uses up to `downloadConcurrency` active
128
+ workers (four by default) over deterministic, resumable HTTP range parts of
129
+ roughly 4 MB each, up to 4,096 parts, when the server confirms `206` responses
130
+ and the total comes from `Content-Range` or, when that header is not exposed,
131
+ optional declared `bytes`.
132
+ An incomplete cache written by 0.5.3 keeps its completed coarse parts and
133
+ subdivides only the missing intervals into the smaller current parts, so an SDK
134
+ update preserves existing progress while reducing later refresh loss.
132
135
  When a followed redirect turns the first Range probe into `200`, the SDK probes
133
136
  the final URL directly before reusing that original response as the single-fetch
134
137
  fallback. Completed range parts survive restart and are presented to Wllama as
@@ -150,6 +153,24 @@ must supply each runtime/model selection explicitly. Speech roles
150
153
  load, cancel, unload, fail, and recover independently, so speech failure never
151
154
  silently falls back or prevents text chat.
152
155
 
156
+ Applications that need faster spoken-response onset can configure the shared
157
+ TTS stream without taking over synthesis or playback:
158
+
159
+ ```js
160
+ ai.configureTTSSegmentation({
161
+ punctuation:'any',
162
+ wordCadence:4
163
+ });
164
+ ```
165
+
166
+ The compatibility default remains sentence punctuation with no word cadence.
167
+ The configured stream preserves every character and punctuation mark, chooses
168
+ the earliest complete boundary, and keeps synthesis and playback sequential.
169
+ Any-punctuation mode recognizes boundaries without requiring whitespace while
170
+ keeping apostrophes, commas, and hyphens that join Unicode letters or numbers
171
+ inside the same segment.
172
+ Mute, stop, provider transition, and cancellation still govern the whole queue.
173
+
153
174
  The SDK runtime also owns `DBOPFSDocumentLibrary`,
154
175
  `DocumentLexicalSearch`, and `PersistentAIChatSession`. Document bootstrap is
155
176
  schema-driven and explicit; chat never searches a corpus unless the app wires
@@ -165,7 +186,7 @@ uses the same controller for automatic memory extraction.
165
186
  Create a new repository-shaped Arcane application with the exact stable SDK:
166
187
 
167
188
  ```bash
168
- npx arcane-os@0.5.3 new my-app --path ./my-app --target portable --git
189
+ npx arcane-os@0.5.5 new my-app --path ./my-app --target portable --git
169
190
  cd my-app
170
191
  npm install
171
192
  npm run check
@@ -176,7 +197,7 @@ To enroll an existing repository, install the exact SDK and initialize only
176
197
  missing Arcane files:
177
198
 
178
199
  ```bash
179
- npm install --save-dev --save-exact arcane-os@0.5.3
200
+ npm install --save-dev --save-exact arcane-os@0.5.5
180
201
  npm exec -- arcane init my-app --target portable
181
202
  ```
182
203
 
@@ -192,7 +213,7 @@ npm exec -- arcane-os targets
192
213
  No global SDK install or standalone Arcane CLI is required. The application
193
214
  repository's exact npm dependency and lockfile own the CLI and toolchain version.
194
215
 
195
- Use `npx arcane-os@0.5.3` for the initial bootstrap because it names this npm
216
+ Use `npx arcane-os@0.5.5` for the initial bootstrap because it names this npm
196
217
  package explicitly; bare `npx arcane` outside an installed project could resolve
197
218
  a different package. Both installed commands invoke the same headless toolchain.
198
219
  Project-local npm scripts use the SDK pinned by that app's `package-lock.json`,
@@ -213,7 +234,7 @@ node ./bin/arcane.mjs new local-app --path ../local-app --target portable --git
213
234
 
214
235
  # From the generated app repository
215
236
  cd ../local-app
216
- npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.3.tgz
237
+ npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.5.tgz
217
238
  npm run check
218
239
  npm ci
219
240
  ```
@@ -223,7 +244,7 @@ same location. The lockfile retains the selected package dependency while
223
244
  Arcane uses the installed package name and version. Local directory `file:` dependencies are not
224
245
  accepted because npm may install them as links; use a packed `.tgz`. A GitHub
225
246
  runner also needs that tarball at the locked path. After publication, replace
226
- the local declaration with the exact `arcane-os@0.5.3` registry package and
247
+ the local declaration with the exact `arcane-os@0.5.5` registry package and
227
248
  commit the regenerated lock.
228
249
 
229
250
  Generated repositories use `npm ci --ignore-scripts` in CI. Run dependency
@@ -361,7 +382,7 @@ package installation, or assertions.
361
382
 
362
383
  ## Current target support
363
384
 
364
- Version `0.5.3` exposes one browser target and five explicitly paired
385
+ Version `0.5.5` exposes one browser target and five explicitly paired
365
386
  native development targets: a non-runnable portable directory, a
366
387
  Windows x64 unsigned-local-test EXE bundle, Linux x64 and Linux ARM64
367
388
  unsigned-local-test DEBs, and an Android development-signed APK. The
@@ -29,8 +29,10 @@ const MODEL_LOAD_HEARTBEAT_MS = 5_000;
29
29
  const DEFAULT_MODEL_DOWNLOAD_CONCURRENCY = 4;
30
30
  const MODEL_DOWNLOAD_PROGRESS_INTERVAL_MS = 250;
31
31
  const MODEL_DOWNLOAD_SPEED_WINDOW_MS = 5_000;
32
- const MODEL_DOWNLOAD_MAX_RANGE_PARTS = 16;
33
- const MODEL_DOWNLOAD_TARGET_RANGE_BYTES = 128_000_000;
32
+ const MODEL_DOWNLOAD_MAX_RANGE_PARTS = 4_096;
33
+ const MODEL_DOWNLOAD_TARGET_RANGE_BYTES = 4_000_000;
34
+ const LEGACY_MODEL_DOWNLOAD_MAX_RANGE_PARTS = 16;
35
+ const LEGACY_MODEL_DOWNLOAD_TARGET_RANGE_BYTES = 128_000_000;
34
36
  const INTEL_VENDOR_ID = 0x8086;
35
37
  const CAPABILITY_POLICY_PROTOCOL = "arcane-ai-browser-capability-policy/1";
36
38
  let highPerformanceGpuNoticeShown = false;
@@ -103,10 +105,10 @@ function downloadConcurrencyValue(value) {
103
105
  return value;
104
106
  }
105
107
 
106
- function modelHttpRanges(total) {
108
+ function modelHttpRangesFor(total, maximumParts, targetPartBytes) {
107
109
  const count = Math.min(
108
- MODEL_DOWNLOAD_MAX_RANGE_PARTS,
109
- Math.ceil(total / MODEL_DOWNLOAD_TARGET_RANGE_BYTES),
110
+ maximumParts,
111
+ Math.ceil(total / targetPartBytes),
110
112
  total,
111
113
  );
112
114
  const width = Math.floor(total / count);
@@ -122,6 +124,47 @@ function modelHttpRanges(total) {
122
124
  return completeValue(ranges);
123
125
  }
124
126
 
127
+ function modelHttpRanges(total) {
128
+ return modelHttpRangesFor(
129
+ total,
130
+ MODEL_DOWNLOAD_MAX_RANGE_PARTS,
131
+ MODEL_DOWNLOAD_TARGET_RANGE_BYTES,
132
+ );
133
+ }
134
+
135
+ function modelHttpSubranges(range) {
136
+ const length = range.end - range.start + 1;
137
+ const maximumParts = Math.floor(
138
+ MODEL_DOWNLOAD_MAX_RANGE_PARTS / LEGACY_MODEL_DOWNLOAD_MAX_RANGE_PARTS,
139
+ );
140
+ return completeValue(modelHttpRangesFor(
141
+ length,
142
+ maximumParts,
143
+ MODEL_DOWNLOAD_TARGET_RANGE_BYTES,
144
+ ).map((part) => completeValue({
145
+ start: range.start + part.start,
146
+ end: range.start + part.end,
147
+ total: range.total,
148
+ })));
149
+ }
150
+
151
+ function modelHttpRangePlans(total) {
152
+ const current = modelHttpRanges(total);
153
+ const legacy = modelHttpRangesFor(
154
+ total,
155
+ LEGACY_MODEL_DOWNLOAD_MAX_RANGE_PARTS,
156
+ LEGACY_MODEL_DOWNLOAD_TARGET_RANGE_BYTES,
157
+ );
158
+ if (
159
+ current.length === legacy.length
160
+ && current.every((range, index) => (
161
+ range.start === legacy[index].start
162
+ && range.end === legacy[index].end
163
+ ))
164
+ ) return completeValue([current]);
165
+ return completeValue([current, legacy]);
166
+ }
167
+
125
168
  function progressClock() {
126
169
  return typeof globalThis.performance?.now === "function"
127
170
  ? globalThis.performance.now()
@@ -930,7 +973,7 @@ export function createDbopfsModelStore({
930
973
  } = {},
931
974
  ) {
932
975
  const transport = BROWSER_MODEL_SOURCE_TRANSPORTS.get(source);
933
- const ranges = modelHttpRanges(total);
976
+ const ranges = await rangePlanForInstall(name, total, signal);
934
977
  const partFiles = new Array(ranges.length);
935
978
  const pendingRangeIndexes = [];
936
979
  for (let rangeIndex = 0; rangeIndex < ranges.length; rangeIndex += 1) {
@@ -1003,16 +1046,31 @@ export function createDbopfsModelStore({
1003
1046
  writable = null;
1004
1047
  partFiles[rangeIndex] = await storedModelFile(partName);
1005
1048
  } catch (error) {
1006
- retainFailure(error);
1007
- await cancelOpenedDownload(opened, error);
1049
+ let transferError = error;
1050
+ if (downloadSignal.aborted
1051
+ && error?.code !== "ARCANE_AI_REQUEST_ABORTED") {
1052
+ try {
1053
+ throwIfAborted(downloadSignal, "install");
1054
+ } catch (abortError) {
1055
+ transferError = abortError;
1056
+ }
1057
+ } else if (!error?.code) {
1058
+ transferError = fail(
1059
+ "ARCANE_AI_MODEL_DOWNLOAD_FAILED",
1060
+ "The model download failed while reading an HTTP byte range.",
1061
+ error,
1062
+ );
1063
+ }
1064
+ retainFailure(transferError);
1065
+ await cancelOpenedDownload(opened, transferError);
1008
1066
  try {
1009
- await writable?.abort?.(error);
1067
+ await writable?.abort?.(transferError);
1010
1068
  } catch {
1011
1069
  // The range part is removed below even if its writable already closed.
1012
1070
  }
1013
1071
  await removeEntry(partName).catch(() => undefined);
1014
1072
  progress?.discardBytes(received);
1015
- throw error;
1073
+ throw transferError;
1016
1074
  } finally {
1017
1075
  progress?.endTransfer({ publishProgress: failure === null });
1018
1076
  }
@@ -1118,16 +1176,93 @@ export function createDbopfsModelStore({
1118
1176
  return names;
1119
1177
  }
1120
1178
 
1179
+ async function rangePlanForInstall(
1180
+ modelName,
1181
+ total,
1182
+ signal,
1183
+ knownNames = undefined,
1184
+ ) {
1185
+ const plans = modelHttpRangePlans(total);
1186
+ const existingNames = knownNames === undefined
1187
+ ? await memberRangePartNames(modelName)
1188
+ : knownNames;
1189
+ const available = existingNames === null
1190
+ ? null
1191
+ : existingNames instanceof Set
1192
+ ? existingNames
1193
+ : new Set(existingNames);
1194
+ if (available?.size === 0) return plans[0];
1195
+ if (plans.length === 1) return plans[0];
1196
+ const completedParts = new Map();
1197
+
1198
+ async function completedPart(range) {
1199
+ throwIfAborted(signal, "install");
1200
+ const partName = rangePartName(modelName, range);
1201
+ if (available && !available.has(partName)) return null;
1202
+ if (completedParts.has(partName)) return completedParts.get(partName);
1203
+ const partFile = await file(partName);
1204
+ const expected = range.end - range.start + 1;
1205
+ const completed = partFile?.size === expected ? partFile : null;
1206
+ completedParts.set(partName, completed);
1207
+ return completed;
1208
+ }
1209
+
1210
+ const completedLegacyRanges = new Set();
1211
+ for (const range of plans[1]) {
1212
+ if (await completedPart(range)) completedLegacyRanges.add(range);
1213
+ }
1214
+ const hybrid = completeValue(plans[1].flatMap((range) => (
1215
+ completedLegacyRanges.has(range)
1216
+ ? [range]
1217
+ : modelHttpSubranges(range)
1218
+ )));
1219
+ let selected = plans[0];
1220
+ let selectedBytes = 0;
1221
+ let selectedLastModified = 0;
1222
+ for (const ranges of [plans[0], hybrid]) {
1223
+ let completedBytes = 0;
1224
+ let lastModified = 0;
1225
+ for (const range of ranges) {
1226
+ const partFile = await completedPart(range);
1227
+ if (!partFile) continue;
1228
+ completedBytes += range.end - range.start + 1;
1229
+ if (Number.isFinite(partFile.lastModified)) {
1230
+ lastModified = Math.max(lastModified, partFile.lastModified);
1231
+ }
1232
+ }
1233
+ if (
1234
+ completedBytes > selectedBytes
1235
+ || (
1236
+ completedBytes === selectedBytes
1237
+ && completedBytes > 0
1238
+ && lastModified > selectedLastModified
1239
+ )
1240
+ ) {
1241
+ selected = ranges;
1242
+ selectedBytes = completedBytes;
1243
+ selectedLastModified = lastModified;
1244
+ }
1245
+ }
1246
+ return selected;
1247
+ }
1248
+
1121
1249
  async function removeMemberRangeParts(modelName, {
1122
1250
  except = null,
1123
1251
  total = null,
1124
1252
  } = {}) {
1125
1253
  const existingNames = await memberRangePartNames(modelName);
1126
- const names = existingNames ?? (
1127
- Number.isSafeInteger(total) && total > 0
1128
- ? modelHttpRanges(total).map((range) => rangePartName(modelName, range))
1129
- : []
1130
- );
1254
+ let names = existingNames;
1255
+ if (names === null && Number.isSafeInteger(total) && total > 0) {
1256
+ const plans = modelHttpRangePlans(total);
1257
+ const hybridRanges = plans.length === 1
1258
+ ? []
1259
+ : plans[1].flatMap((range) => modelHttpSubranges(range));
1260
+ names = [...new Set([
1261
+ ...plans.flat(),
1262
+ ...hybridRanges,
1263
+ ].map((range) => rangePartName(modelName, range)))];
1264
+ }
1265
+ names ??= [];
1131
1266
  const removed = [];
1132
1267
  for (const name of names) {
1133
1268
  if (except?.has(name)) continue;
@@ -1211,9 +1346,10 @@ export function createDbopfsModelStore({
1211
1346
  for (let memberIndex = 0; memberIndex < members.length; memberIndex += 1) {
1212
1347
  const total = members[memberIndex].bytes;
1213
1348
  if (!Number.isSafeInteger(total) || total <= 0) continue;
1214
- for (const range of modelHttpRanges(total)) {
1215
- removed.push(await removeEntry(rangePartName(names.models[memberIndex].name, range)));
1216
- }
1349
+ removed.push(await removeMemberRangeParts(
1350
+ names.models[memberIndex].name,
1351
+ { total },
1352
+ ));
1217
1353
  }
1218
1354
  return removed.some(Boolean);
1219
1355
  }
@@ -1364,12 +1500,18 @@ export function createDbopfsModelStore({
1364
1500
  return modelFiles.every(Boolean) ? modelFiles : null;
1365
1501
  }
1366
1502
 
1367
- async function storedRangeCandidate(modelName, total, signal) {
1503
+ async function storedRangeCandidateForPlan(
1504
+ modelName,
1505
+ ranges,
1506
+ signal,
1507
+ availableNames = null,
1508
+ ) {
1368
1509
  const partFiles = [];
1369
1510
  let lastModified = 0;
1370
- for (const range of modelHttpRanges(total)) {
1511
+ for (const range of ranges) {
1371
1512
  throwIfAborted(signal, "install");
1372
1513
  const partName = rangePartName(modelName, range);
1514
+ if (availableNames && !availableNames.has(partName)) return null;
1373
1515
  const partFile = await file(partName);
1374
1516
  if (!partFile) return null;
1375
1517
  const expected = range.end - range.start + 1;
@@ -1385,26 +1527,53 @@ export function createDbopfsModelStore({
1385
1527
  return completeValue({
1386
1528
  file: new Blob(partFiles, { type: "application/octet-stream" }),
1387
1529
  lastModified,
1388
- total,
1530
+ ranges,
1531
+ total: ranges[0].total,
1389
1532
  });
1390
1533
  }
1391
1534
 
1535
+ async function storedRangeCandidate(
1536
+ modelName,
1537
+ total,
1538
+ signal,
1539
+ availableNames = null,
1540
+ ) {
1541
+ const ranges = await rangePlanForInstall(
1542
+ modelName,
1543
+ total,
1544
+ signal,
1545
+ availableNames,
1546
+ );
1547
+ return storedRangeCandidateForPlan(
1548
+ modelName,
1549
+ ranges,
1550
+ signal,
1551
+ availableNames,
1552
+ );
1553
+ }
1554
+
1392
1555
  async function storedRangeMember(member, modelName, signal) {
1556
+ const names = await memberRangePartNames(modelName);
1557
+ const availableNames = names === null ? null : new Set(names);
1393
1558
  const declaredTotal = Number.isSafeInteger(member.bytes) && member.bytes > 0
1394
1559
  ? member.bytes
1395
1560
  : null;
1396
1561
  if (declaredTotal !== null) {
1397
- const declared = await storedRangeCandidate(modelName, declaredTotal, signal);
1562
+ const declared = await storedRangeCandidate(
1563
+ modelName,
1564
+ declaredTotal,
1565
+ signal,
1566
+ availableNames,
1567
+ );
1398
1568
  if (declared) {
1399
1569
  await removeStaleRangePartsAfterCompletion(
1400
1570
  modelName,
1401
- modelHttpRanges(declaredTotal),
1571
+ declared.ranges,
1402
1572
  );
1403
1573
  return declared.file;
1404
1574
  }
1405
1575
  }
1406
1576
 
1407
- const names = await memberRangePartNames(modelName);
1408
1577
  if (names === null) return null;
1409
1578
  const discoveredTotals = new Set();
1410
1579
  for (const name of names) {
@@ -1414,7 +1583,12 @@ export function createDbopfsModelStore({
1414
1583
  let selected = null;
1415
1584
  const totals = [...discoveredTotals].sort((left, right) => right - left);
1416
1585
  for (const total of totals) {
1417
- const candidate = await storedRangeCandidate(modelName, total, signal);
1586
+ const candidate = await storedRangeCandidate(
1587
+ modelName,
1588
+ total,
1589
+ signal,
1590
+ availableNames,
1591
+ );
1418
1592
  if (
1419
1593
  candidate
1420
1594
  && (
@@ -1426,7 +1600,7 @@ export function createDbopfsModelStore({
1426
1600
  if (selected === null) return null;
1427
1601
  await removeStaleRangePartsAfterCompletion(
1428
1602
  modelName,
1429
- modelHttpRanges(selected.total),
1603
+ selected.ranges,
1430
1604
  );
1431
1605
  return selected.file;
1432
1606
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -17,6 +17,16 @@ const completeValue=(value)=>value;
17
17
 
18
18
  let credentials='include';
19
19
  const LEGACY_TTS_RESPONSE_FORMAT='opus';
20
+ const DEFAULT_TTS_SEGMENTATION={
21
+ punctuation:'sentence',
22
+ wordCadence:null
23
+ };
24
+ const TTS_PUNCTUATION_MODES=new Set(['sentence','any','none']);
25
+ // A complete punctuation run is a boundary unless the whole run consists of
26
+ // apostrophe, comma, or dash punctuation joining Unicode letters or numbers.
27
+ // The streaming expression leaves a trailing joining run for the next chunk.
28
+ const TTS_ANY_PUNCTUATION=/(?<!\p{P})(?:(?<![\p{L}\p{N}])\p{P}+(?!\p{P})(?=[\s\S])|\p{P}+(?!\p{P})(?=[^\p{L}\p{N}])|\p{P}*[^\P{P}'\u2019\uFF07,\u060C\u3001\uFF0C\p{Pd}]\p{P}*)(?!\p{P})/gu;
29
+ const TTS_ANY_PUNCTUATION_AT_END=/(?<!\p{P})(?:(?<![\p{L}\p{N}])\p{P}+|\p{P}+(?![\p{L}\p{N}])|\p{P}*[^\P{P}'\u2019\uFF07,\u060C\u3001\uFF0C\p{Pd}]\p{P}*)(?!\p{P})/gu;
20
30
  credentials='omit';
21
31
 
22
32
  const LEGACY_AI_SERVICES=new Set(['OPENAI','OLLAMA','LOCAL_SPEACH']);
@@ -142,6 +152,38 @@ function isPlainAIRecord(value){
142
152
  return prototype===Object.prototype||prototype===null;
143
153
  }
144
154
 
155
+ function normalizeTTSSegmentation(value,current=DEFAULT_TTS_SEGMENTATION){
156
+ if(value===undefined||value===null){
157
+ return {...current};
158
+ }
159
+ if(!isPlainAIRecord(value)){
160
+ throw new TypeError('TTS segmentation must be a plain object.');
161
+ }
162
+
163
+ const punctuation=value.punctuation===undefined
164
+ ?current.punctuation
165
+ :value.punctuation;
166
+ if(!TTS_PUNCTUATION_MODES.has(punctuation)){
167
+ throw new TypeError(
168
+ 'TTS segmentation punctuation must be sentence, any, or none.'
169
+ );
170
+ }
171
+
172
+ const wordCadence=value.wordCadence===undefined
173
+ ?current.wordCadence
174
+ :value.wordCadence;
175
+ if(
176
+ wordCadence!==null
177
+ &&(!Number.isSafeInteger(wordCadence)||wordCadence<1)
178
+ ){
179
+ throw new RangeError(
180
+ 'TTS segmentation wordCadence must be null or a positive integer.'
181
+ );
182
+ }
183
+
184
+ return {punctuation,wordCadence};
185
+ }
186
+
145
187
  function requireAIToolMessageSchemas(value,label='AI tools'){
146
188
  if(!Array.isArray(value)){
147
189
  throw aiStructuralError(
@@ -922,12 +964,10 @@ class AI {
922
964
  OPENAI: 'https://inference.do-ai.run/v1'
923
965
  },
924
966
  sttURL: {
925
- LOCAL_SPEACH: 'http://127.0.0.1:8011/v1',
926
- OPENAI: 'https://api.openai.com/v1'
967
+ LOCAL_SPEACH: 'http://127.0.0.1:8011/v1'
927
968
  },
928
969
  ttsURL: {
929
- LOCAL_SPEACH: 'http://127.0.0.1:8011/v1',
930
- OPENAI: 'https://api.openai.com/v1'
970
+ LOCAL_SPEACH: 'http://127.0.0.1:8011/v1'
931
971
  },
932
972
  }
933
973
 
@@ -936,12 +976,10 @@ class AI {
936
976
  OPENAI: '/chat/completions'
937
977
  },
938
978
  stt: {
939
- LOCAL_SPEACH: '/audio/transcriptions',
940
- OPENAI: '/audio/transcriptions'
979
+ LOCAL_SPEACH: '/audio/transcriptions'
941
980
  },
942
981
  tts: {
943
- LOCAL_SPEACH: '/audio/speech',
944
- OPENAI: '/audio/speech'
982
+ LOCAL_SPEACH: '/audio/speech'
945
983
  }
946
984
  }
947
985
 
@@ -950,12 +988,10 @@ class AI {
950
988
  }
951
989
 
952
990
  #sttModels = {
953
- OPENAI: 'whisper-1',
954
991
  LOCAL_SPEACH: 'whisper-small'
955
992
  }
956
993
 
957
994
  #ttsModels = {
958
- OPENAI: 'gpt-4o-mini-tts',
959
995
  LOCAL_SPEACH: 'kokoro'
960
996
  }
961
997
 
@@ -974,17 +1010,8 @@ class AI {
974
1010
  };
975
1011
  }
976
1012
 
977
- get #legacyOpenAISpeechKey(){
978
- const value=globalThis.arcane?.config?.openAI?.apiKey;
979
- return typeof value==='string'?value:'';
980
- }
981
-
982
1013
  get #ttsHeaders(){
983
1014
  return {
984
- OPENAI: {
985
- 'Content-Type': 'application/json',
986
- 'Authorization': `Bearer ${this.#legacyOpenAISpeechKey}`
987
- },
988
1015
  LOCAL_SPEACH: {
989
1016
  'Content-Type': 'application/json',
990
1017
  }
@@ -993,9 +1020,6 @@ class AI {
993
1020
 
994
1021
  get #sttHeaders(){
995
1022
  return {
996
- OPENAI: {
997
- 'Authorization': `Bearer ${this.#legacyOpenAISpeechKey}`,
998
- },
999
1023
  LOCAL_SPEACH: {}
1000
1024
  };
1001
1025
  }
@@ -1048,11 +1072,11 @@ class AI {
1048
1072
 
1049
1073
  const preferences=[
1050
1074
  llmService||'OPENAI',
1051
- sttService||'OPENAI',
1052
- ttsService||'OPENAI',
1075
+ sttService||'LOCAL_SPEACH',
1076
+ ttsService||'LOCAL_SPEACH',
1053
1077
  model||'OPENAI',
1054
- modelTTS||'OPENAI',
1055
- modelSTT||'OPENAI'
1078
+ modelTTS||'LOCAL_SPEACH',
1079
+ modelSTT||'LOCAL_SPEACH'
1056
1080
  ];
1057
1081
  this.setAI(
1058
1082
  ...preferences
@@ -1087,13 +1111,14 @@ class AI {
1087
1111
  #speechControlGeneration=0;
1088
1112
  #speechFailureSequence=0;
1089
1113
  #stopOllamaReady=null;
1114
+ #ttsSegmentation={...DEFAULT_TTS_SEGMENTATION};
1090
1115
  #preferenceTuple=completeValue([
1091
1116
  'OPENAI',
1117
+ 'LOCAL_SPEACH',
1118
+ 'LOCAL_SPEACH',
1092
1119
  'OPENAI',
1093
- 'OPENAI',
1094
- 'OPENAI',
1095
- 'OPENAI',
1096
- 'OPENAI'
1120
+ 'LOCAL_SPEACH',
1121
+ 'LOCAL_SPEACH'
1097
1122
  ]);
1098
1123
 
1099
1124
  get providerRuntime(){
@@ -1173,9 +1198,6 @@ class AI {
1173
1198
  this.#retainLegacyLLMReadiness(
1174
1199
  this.#reconcileLegacyLLMReadiness()
1175
1200
  );
1176
- this.#retainLegacySpeechReadiness(
1177
- this.#reconcileLegacySpeechReadiness()
1178
- );
1179
1201
  return this.#license;
1180
1202
  }
1181
1203
 
@@ -1427,12 +1449,6 @@ class AI {
1427
1449
  if(role!=='tts'){
1428
1450
  return null;
1429
1451
  }
1430
- if(providerId==='OPENAI'){
1431
- const selected=typeof globalThis.window?.user?.AI_voice==='string'
1432
- ?globalThis.window.user.AI_voice.trim()
1433
- :'';
1434
- return selected||'alloy';
1435
- }
1436
1452
  if(providerId==='LOCAL_SPEACH'){
1437
1453
  return 'af_heart';
1438
1454
  }
@@ -1445,10 +1461,6 @@ class AI {
1445
1461
  if(service!==providerId||!model){
1446
1462
  return false;
1447
1463
  }
1448
- if(providerId==='OPENAI'){
1449
- return Boolean(this.#legacyOpenAISpeechKey)
1450
- &&typeof globalThis.fetch==='function';
1451
- }
1452
1464
  if(providerId==='LOCAL_SPEACH'){
1453
1465
  return Boolean(this.#nativeSpeech(service,role));
1454
1466
  }
@@ -1685,8 +1697,7 @@ class AI {
1685
1697
  }
1686
1698
 
1687
1699
  #ensureLegacySpeechProvider(role,providerId){
1688
- if(!['stt','tts'].includes(role)
1689
- ||!['OPENAI','LOCAL_SPEACH'].includes(providerId)){
1700
+ if(!['stt','tts'].includes(role)||providerId!=='LOCAL_SPEACH'){
1690
1701
  return false;
1691
1702
  }
1692
1703
  if(this.#providerRuntime.hasProvider(role,providerId)){
@@ -1875,11 +1886,7 @@ class AI {
1875
1886
  throw error;
1876
1887
  }
1877
1888
 
1878
- if(service==='OPENAI'&&(
1879
- role==='llm'
1880
- ?Boolean(this.twinKey)
1881
- :Boolean(this.#legacyOpenAISpeechKey)
1882
- )){
1889
+ if(service==='OPENAI'&&role==='llm'&&Boolean(this.twinKey)){
1883
1890
  return true;
1884
1891
  }
1885
1892
 
@@ -1947,6 +1954,10 @@ class AI {
1947
1954
  }
1948
1955
  return value;
1949
1956
  });
1957
+ next[1]='LOCAL_SPEACH';
1958
+ next[2]='LOCAL_SPEACH';
1959
+ next[4]='LOCAL_SPEACH';
1960
+ next[5]='LOCAL_SPEACH';
1950
1961
  return completeValue(next);
1951
1962
  }
1952
1963
 
@@ -1961,6 +1972,26 @@ class AI {
1961
1972
  }
1962
1973
  }
1963
1974
 
1975
+ #assertDeviceSpeechConfiguration(configuration){
1976
+ for(const role of ['stt','tts']){
1977
+ for(const routeName of ['default','localOnly']){
1978
+ const selection=configuration?.[role]?.[routeName];
1979
+ if(selection&&selection.localOnly!==true){
1980
+ const operation=role==='stt'
1981
+ ?'Speech recognition'
1982
+ :'Speech synthesis';
1983
+ const error=new TypeError(
1984
+ `${operation} supports on-device providers only.`
1985
+ );
1986
+ error.code=role==='stt'
1987
+ ?'AI_STT_DEVICE_ONLY'
1988
+ :'AI_TTS_DEVICE_ONLY';
1989
+ throw error;
1990
+ }
1991
+ }
1992
+ }
1993
+ }
1994
+
1964
1995
  #normalizedLLMModel(service,model){
1965
1996
  if(service==='OLLAMA'){
1966
1997
  const mappedModel=model==='OPENAI'?null:this.#models[model];
@@ -2187,6 +2218,7 @@ class AI {
2187
2218
 
2188
2219
  configureProviders(selections){
2189
2220
  const prepared=this.#providerRuntime.validateConfiguration(selections);
2221
+ this.#assertDeviceSpeechConfiguration(prepared);
2190
2222
  this.#assertSynchronousBrowserSpeechSupersession('AI.configureProviders');
2191
2223
  this.#ensureLegacyLLMProvider(
2192
2224
  prepared.llm.default?.providerId
@@ -2221,6 +2253,7 @@ class AI {
2221
2253
 
2222
2254
  configureSpeechProviders(selections){
2223
2255
  const prepared=this.#providerRuntime.validateSpeechConfiguration(selections);
2256
+ this.#assertDeviceSpeechConfiguration(prepared);
2224
2257
  this.#assertSynchronousBrowserSpeechSupersession(
2225
2258
  'AI.configureSpeechProviders'
2226
2259
  );
@@ -2287,6 +2320,7 @@ class AI {
2287
2320
 
2288
2321
  async transitionProviders(selections){
2289
2322
  const prepared=this.#providerRuntime.validateConfiguration(selections);
2323
+ this.#assertDeviceSpeechConfiguration(prepared);
2290
2324
  await this.#supersedeBrowserSpeechForRouteChange();
2291
2325
  this.#ensureLegacyLLMProvider(
2292
2326
  prepared.llm.default?.providerId
@@ -2318,6 +2352,7 @@ class AI {
2318
2352
 
2319
2353
  async transitionSpeechProviders(selections){
2320
2354
  const prepared=this.#providerRuntime.validateSpeechConfiguration(selections);
2355
+ this.#assertDeviceSpeechConfiguration(prepared);
2321
2356
  await this.#supersedeBrowserSpeechForRouteChange();
2322
2357
  this.#invalidateSpeechControl();
2323
2358
  await this.#unloadSpeechProviderRolesForTransition();
@@ -5520,6 +5555,18 @@ class AI {
5520
5555
  return responseJSON;
5521
5556
  }
5522
5557
 
5558
+ get ttsSegmentation(){
5559
+ return {...this.#ttsSegmentation};
5560
+ }
5561
+
5562
+ configureTTSSegmentation(options={}){
5563
+ this.#ttsSegmentation=normalizeTTSSegmentation(
5564
+ options,
5565
+ this.#ttsSegmentation
5566
+ );
5567
+ return this.ttsSegmentation;
5568
+ }
5569
+
5523
5570
  streamTTS(
5524
5571
  text='',
5525
5572
  end=false
@@ -5571,10 +5618,16 @@ class AI {
5571
5618
  let remainder=this.audioMessageChunks;
5572
5619
 
5573
5620
  while(remainder.length>0){
5574
- const terminator=this.#findSpeechTerminator(remainder,end);
5575
- let boundary=terminator
5576
- ?terminator.index+terminator[0].length
5577
- :-1;
5621
+ const punctuationBoundary=this.#findSpeechPunctuationBoundary(
5622
+ remainder,
5623
+ end
5624
+ );
5625
+ const cadenceBoundary=this.#findSpeechCadenceBoundary(remainder,end);
5626
+ let boundary=punctuationBoundary;
5627
+
5628
+ if(boundary<0||(cadenceBoundary>0&&cadenceBoundary<boundary)){
5629
+ boundary=cadenceBoundary;
5630
+ }
5578
5631
 
5579
5632
  if(boundary<0&&end){
5580
5633
  boundary=remainder.length;
@@ -5596,7 +5649,14 @@ class AI {
5596
5649
  return segments;
5597
5650
  }
5598
5651
 
5599
- #findSpeechTerminator(text,end=false){
5652
+ #findSpeechPunctuationBoundary(text,end=false){
5653
+ if(this.#ttsSegmentation.punctuation==='none'){
5654
+ return -1;
5655
+ }
5656
+ if(this.#ttsSegmentation.punctuation==='any'){
5657
+ return this.#findAnySpeechPunctuationBoundary(text,end);
5658
+ }
5659
+
5600
5660
  const pattern=end
5601
5661
  ?/(?:[\u3002\uFF01\uFF1F]|[.!?](?=\s|$))/g
5602
5662
  :/(?:[\u3002\uFF01\uFF1F]|[.!?](?=\s+\S))/g;
@@ -5610,10 +5670,71 @@ class AI {
5610
5670
  continue;
5611
5671
  }
5612
5672
 
5613
- return terminator;
5673
+ return this.#includeSpeechBoundaryWhitespace(
5674
+ text,
5675
+ terminator.index+terminator[0].length
5676
+ );
5614
5677
  }
5615
5678
 
5616
- return null;
5679
+ return -1;
5680
+ }
5681
+
5682
+ #findAnySpeechPunctuationBoundary(text,end=false){
5683
+ const pattern=end
5684
+ ?TTS_ANY_PUNCTUATION_AT_END
5685
+ :TTS_ANY_PUNCTUATION;
5686
+ pattern.lastIndex=0;
5687
+ const firstContent=text.search(/\S/u);
5688
+ let punctuation;
5689
+
5690
+ while((punctuation=pattern.exec(text))){
5691
+ if(firstContent<0||punctuation.index<=firstContent){
5692
+ continue;
5693
+ }
5694
+ const boundary=punctuation.index+punctuation[0].length;
5695
+ return this.#includeSpeechBoundaryWhitespace(text,boundary);
5696
+ }
5697
+
5698
+ return -1;
5699
+ }
5700
+
5701
+ #findSpeechCadenceBoundary(text,end=false){
5702
+ const wordCadence=this.#ttsSegmentation.wordCadence;
5703
+ if(wordCadence===null){
5704
+ return -1;
5705
+ }
5706
+
5707
+ const words=/\S+/gu;
5708
+ let word;
5709
+ let wordCount=0;
5710
+
5711
+ while((word=words.exec(text))){
5712
+ wordCount+=1;
5713
+ if(wordCount!==wordCadence){
5714
+ continue;
5715
+ }
5716
+ const boundary=word.index+word[0].length;
5717
+ if(boundary===text.length&&!end){
5718
+ return -1;
5719
+ }
5720
+ if(boundary<text.length&&!/\s/u.test(text[boundary])){
5721
+ return -1;
5722
+ }
5723
+ return this.#includeSpeechBoundaryWhitespace(text,boundary);
5724
+ }
5725
+
5726
+ return -1;
5727
+ }
5728
+
5729
+ #includeSpeechBoundaryWhitespace(text,boundary){
5730
+ let completeBoundary=boundary;
5731
+ while(
5732
+ completeBoundary<text.length
5733
+ &&/\s/u.test(text[completeBoundary])
5734
+ ){
5735
+ completeBoundary+=1;
5736
+ }
5737
+ return completeBoundary;
5617
5738
  }
5618
5739
 
5619
5740
  #isSpeechAbbreviation(text,periodIndex){