arcane-os 0.77.0 → 0.78.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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.78.0
4
+
5
+ - Add `AI.readPreparedTTS()` and shared `readPreparedSpeech()` for complete
6
+ cache-only narration lookup. Return a ready playback handle or `null` without
7
+ loading a model, unmuting, synthesizing, writing storage or repairing partial
8
+ audio. Preserve complete semantic matching and genuine storage errors.
9
+ - Keep lookup cancellation independent from a successfully returned handle.
10
+ Replay uses the existing prepared-playback owner and can borrow the caller's
11
+ silent reusable audio output; no autoplay permission is implied.
12
+ - Add optional existing-only DBOPFS table/file reads while preserving ordinary
13
+ lazy-creation defaults. Cache-only lookup and later audio reads leave missing
14
+ tables absent.
15
+ - Give Kokoro voice caching a real Worker-lifetime response cache after runtime
16
+ import, while DBOPFS remains the durable model/resource owner. Preserve saved
17
+ native caches and clear transient responses on unload.
18
+ - Report a complete stored model resource as cached loading rather than a new
19
+ network download. This release changes no model selection, native engine or
20
+ physical-device routing and makes no live-model execution claim.
21
+
3
22
  ## 0.77.0
4
23
 
5
24
  - Add `AI.prepareAudioOutput()` for synchronous, silent preparation of a reusable
package/README.md CHANGED
@@ -20,7 +20,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
20
20
  share the theme, packaging, event, cancellation,
21
21
  and browser run contracts while retaining their selected application layout.
22
22
 
23
- This checkout defines the `0.77.0` SDK contract. Development applications declare
23
+ This checkout defines the `0.78.0` SDK contract. Development applications declare
24
24
  `arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
25
25
  designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
26
26
  to refresh and compare the installed SDK and lockfile with the published channel;
@@ -96,7 +96,7 @@ export function createDbopfsResourceStore({dbopfs, tableName, fetchImpl = global
96
96
  const file = await parts.read(entry.name, {signal});
97
97
  if (file) {
98
98
  const state = await parts.state(entry.name, {signal});
99
- onProgress?.({phase: 'download', completed: state.parts.length,
99
+ onProgress?.({phase: 'load', message: 'Reading stored model resource', completed: state.parts.length,
100
100
  total: state.parts.length, unit: 'shards', cached: true, url: request.url});
101
101
  return {...entry.metadata, file};
102
102
  }
@@ -997,6 +997,7 @@ function createOrdinaryArtifactModuleRouter(
997
997
  role,
998
998
  reader,
999
999
  originalWorker,
1000
+ responseCaches,
1000
1001
  ) {
1001
1002
  const nestedWorkers = new Set();
1002
1003
  const router = completeValue({
@@ -1015,8 +1016,8 @@ function createOrdinaryArtifactModuleRouter(
1015
1016
  return reader.fetchResource(input, init, modulePath);
1016
1017
  },
1017
1018
 
1018
- async openCache() {
1019
- throw new Error('Browser CacheStorage is unavailable in this speech Worker; resources use DBOPFS through fetch.');
1019
+ openCache(modulePath, name) {
1020
+ return responseCaches.open(name);
1020
1021
  },
1021
1022
 
1022
1023
  createWorker(modulePath, specifier, options = {}) {
@@ -1143,6 +1144,63 @@ function installOrdinaryArtifactModuleRouter(scope, configuration, role, send, s
1143
1144
  const resourceClient = createModelResourceClient({ send });
1144
1145
  let requestSignal = null;
1145
1146
  let reportProgress = null;
1147
+ let cacheClosed = false;
1148
+ const responseCacheEntries = new Map();
1149
+ const responseCaches = {
1150
+ async open(name) {
1151
+ assertCacheActive(requestSignal);
1152
+ const cacheName = String(name);
1153
+ let cache = responseCacheEntries.get(cacheName);
1154
+ if (!cache) {
1155
+ const responses = new Map();
1156
+ cache = {
1157
+ responses,
1158
+ api: {
1159
+ async match(input) {
1160
+ assertCacheActive(requestSignal);
1161
+ const stored = responses.get(cacheRequestKey(input));
1162
+ if (!stored) return undefined;
1163
+ const response = new Response(stored.body, stored);
1164
+ Object.defineProperties(response, {
1165
+ url: { value: stored.url },
1166
+ redirected: { value: stored.redirected },
1167
+ type: { value: stored.type },
1168
+ });
1169
+ return response;
1170
+ },
1171
+ async put(input, response) {
1172
+ const signal = requestSignal;
1173
+ assertCacheActive(signal);
1174
+ const key = cacheRequestKey(input);
1175
+ const copy = response.clone();
1176
+ const body = await copy.blob();
1177
+ assertCacheActive(signal);
1178
+ responses.set(key, {
1179
+ body,
1180
+ status: response.status,
1181
+ statusText: response.statusText,
1182
+ headers: [...response.headers],
1183
+ url: response.url,
1184
+ redirected: response.redirected,
1185
+ type: response.type,
1186
+ });
1187
+ },
1188
+ },
1189
+ };
1190
+ responseCacheEntries.set(cacheName, cache);
1191
+ }
1192
+ return cache.api;
1193
+ },
1194
+ };
1195
+ function assertCacheActive(signal) {
1196
+ throwIfAborted(signal, 'speech-resource-cache-cancelled');
1197
+ if (cacheClosed) throw new Error('The speech Worker response cache was unloaded.');
1198
+ }
1199
+ function cacheRequestKey(input) {
1200
+ const RequestConstructor = scope.Request ?? Request;
1201
+ const request = new RequestConstructor(input);
1202
+ return JSON.stringify([request.method, request.url]);
1203
+ }
1146
1204
  if (!is.function(originalFetch)) {
1147
1205
  throw workerError(
1148
1206
  "ARCANE_AI_PROVIDER_UNAVAILABLE",
@@ -1166,8 +1224,9 @@ function installOrdinaryArtifactModuleRouter(scope, configuration, role, send, s
1166
1224
  role,
1167
1225
  reader,
1168
1226
  originalWorker,
1227
+ responseCaches,
1169
1228
  );
1170
- const originalProperties = ['fetch', 'Worker', MODULE_ROUTER_NAME].map(
1229
+ const originalProperties = ['fetch', 'Worker', 'caches', MODULE_ROUTER_NAME].map(
1171
1230
  function originalWorkerProperty(name) {
1172
1231
  return { name, descriptor: Object.getOwnPropertyDescriptor(scope, name) };
1173
1232
  },
@@ -1240,7 +1299,22 @@ function installOrdinaryArtifactModuleRouter(scope, configuration, role, send, s
1240
1299
  receive: resourceClient.receive,
1241
1300
  setSignal(signal) { requestSignal = signal; },
1242
1301
  setProgress(report) { reportProgress = report; },
1302
+ enableResponseCache() {
1303
+ // Transformers captures cache capability during import. Kokoro then uses
1304
+ // this real, Worker-lifetime match/put cache for voices; fetch remains the
1305
+ // sole route to durable DBOPFS resources. No native saved cache is touched.
1306
+ assertCacheActive(requestSignal);
1307
+ Object.defineProperty(scope, 'caches', {
1308
+ configurable: true,
1309
+ enumerable: true,
1310
+ writable: true,
1311
+ value: responseCaches,
1312
+ });
1313
+ },
1243
1314
  async cleanup() {
1315
+ cacheClosed = true;
1316
+ for (const cache of responseCacheEntries.values()) cache.responses.clear();
1317
+ responseCacheEntries.clear();
1244
1318
  resourceClient.close(new Error('The speech Worker resource owner was unloaded.'));
1245
1319
  let cleanupFailure = null;
1246
1320
  try {
@@ -1773,6 +1847,7 @@ export function createSpeechWorkerRuntime({ role, scope = globalThis, send } = {
1773
1847
  );
1774
1848
  const namespace = await import(entry.moduleUrl);
1775
1849
  throwIfAborted(signal, `${role}-load-cancelled`);
1850
+ environment.enableResponseCache();
1776
1851
  restoreNamespace = configureRuntimeNamespace(
1777
1852
  namespace,
1778
1853
  configuration,
@@ -2071,6 +2146,7 @@ export function installBrowserSpeechArtifactModuleWorker(role, scope = globalThi
2071
2146
  target?.sourceUrl ?? request.targetUrl,
2072
2147
  );
2073
2148
  await import(target?.moduleUrl ?? request.targetUrl);
2149
+ environment.enableResponseCache();
2074
2150
  scope.removeEventListener("message", bootstrap);
2075
2151
  await new Promise((resolve) => queueMicrotask(resolve));
2076
2152
  for (const queuedEvent of queued.splice(0)) replayWorkerMessage(scope, queuedEvent);
@@ -727,6 +727,54 @@ pause delays the next queued audio on the existing `AudioContext` clock; it
727
727
  does not delay the preceding promise after that passage's last buffer ends.
728
728
  The promise is a playback result, not a listener acknowledgement.
729
729
 
730
+ ## Read stored narration without starting generation
731
+
732
+ `await ai.readPreparedTTS({parts,textFormat,storage,identity,signal,onState})`
733
+ returns a complete prepared handle or `null`. It uses the same complete inputs,
734
+ selected speech configuration, formatting treatment, segmentation, storage key,
735
+ and semantic `identity` as `prepareTTS()`. Supply the same options used to
736
+ prepare the audio. A changed selection or input is a cache miss, not permission
737
+ to generate replacement audio.
738
+
739
+ This operation never activates or unmutes a provider, synthesizes speech,
740
+ creates stored records, repairs partial audio, or joins pending generation.
741
+ With `storage`, it reads the existing manifest and every ordered audio file.
742
+ Missing or incomplete audio returns `null`; unreadable metadata and other
743
+ storage errors reject. Without storage, it finds only already-complete audio
744
+ retained by this same AI instance. A miss leaves all existing data unchanged.
745
+
746
+ ```javascript
747
+ async function readNarration(text, key, signal) {
748
+ return ai.readPreparedTTS({
749
+ parts: [{
750
+ input: text,
751
+ voice: speechSelection.model.defaultVoice,
752
+ speed: 1,
753
+ pauseAfterMs: 0
754
+ }],
755
+ storage: {db: dbopfs, table: 'saved_narration', key},
756
+ identity: {
757
+ model: speechSelection.model,
758
+ runtime: speechSelection.runtime
759
+ },
760
+ signal
761
+ });
762
+ }
763
+ ```
764
+
765
+ A returned handle has the ordinary `segments`, `state`, `ready`, `getAudio(index)`,
766
+ and `cancel()` contract and can be passed to `ai.playPreparedTTS()` from the
767
+ application's playback action, including with its existing `audioOutput`.
768
+ The lookup signal governs only the read and rejects cancellation as
769
+ `AbortError`. After a successful return, aborting that signal does not invalidate
770
+ the retained handle; explicit handle cancellation and playback cancellation
771
+ remain separate. A successful read does not promise future autoplay permission
772
+ or prevent a later application-owned removal of stored files.
773
+
774
+ Applications decide which saved narration candidates to inspect. For example,
775
+ newest-first lookup can skip a newer partial preparation and then inspect an
776
+ older complete candidate without activating any model.
777
+
730
778
  ## Prepare narration once and replay stored audio
731
779
 
732
780
  Use `ai.prepareTTS()` when preparation must continue independently of playback,
@@ -1631,8 +1679,13 @@ as graph runtimes:
1631
1679
  Inside the dedicated speech Worker, native CacheStorage is temporarily
1632
1680
  unavailable before runtime import, so the selected runtime takes its existing
1633
1681
  fetch path. Transformers' public browser/custom/filesystem cache switches are
1634
- disabled there. The SDK does not fabricate Cache matches or mutation results,
1635
- and it neither clears nor changes saved native caches. The original Worker
1682
+ disabled there. After the runtime import, Kokoro's voice `caches.open()` calls
1683
+ use a Worker-local response cache with actual `match`/`put` results. It retains
1684
+ complete response bodies and metadata in memory for that Worker only; a miss
1685
+ does not fetch, and ordinary fetch still owns durable DBOPFS persistence. This
1686
+ temporary response retention is separate from Kokoro's own in-memory voice
1687
+ arrays. Unload clears it, and an unfinished put cannot repopulate it afterward.
1688
+ The SDK neither clears nor changes saved native caches. The original Worker
1636
1689
  environment is restored on unload or failed import. Existing explicit
1637
1690
  `removeBrowserSpeechModelCache()` remains a separate caller-selected operation.
1638
1691
 
@@ -1646,6 +1699,10 @@ closed resource parts survive cancellation. Existing whole-file caches remain
1646
1699
  readable without migration. HTTP resume is used only when the server returns
1647
1700
  the requested range; otherwise the complete resource is downloaded anew.
1648
1701
  Worker termination aborts and joins its parent-owned resource operations.
1702
+ An existing complete resource reports `phase:'load'`, `cached:true`, and
1703
+ `message:'Reading stored model resource'` with its complete shard progress.
1704
+ That event describes a DBOPFS read, not a network download or model-session
1705
+ reload. The resource owner does not fetch that stored response again.
1649
1706
 
1650
1707
  Routing discovery is best effort and is not an admission gate. If the scanner
1651
1708
  cannot interpret a module, that module is left unchanged and follows its native
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "sdkVersion": "0.76.0",
3
+ "sdkVersion": "0.77.0",
4
4
  "environment": {
5
5
  "runtime": "Node.js for Node entrypoints; browser for browser-only entrypoints",
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "artifactCount": 93,
12
12
  "javascriptArtifactCount": 91,
13
- "esmExportCount": 374,
13
+ "esmExportCount": 375,
14
14
  "artifacts": [
15
15
  {
16
16
  "file": "runtime/arcane/modules/AI.js",
@@ -31,7 +31,7 @@
31
31
  "availability": "Browser + native bridge + TWiN Cloud",
32
32
  "protocol": "arcane-ai-browser-speech-configuration/1, AIProviderRuntime arcane-ai-provider/2 routes, globalThis.arcaneEvents, TWiN Cloud HTTPS, Arcane.ollama, Arcane.speech, Android WebView bridge",
33
33
  "normalization": "Complete mutable caller-owned browser speech authority, SDK-owned provider registration/replacement/disposal, explicit STT/TTS activation, normalized Whisper STT and Kokoro TTS execution with default capacities 1 and 4 respectively, explicit provider-neutral execution snapshots through status(role,{execution:true}), sticky readiness, canonical window.user readiness without compatibility-event object-identity admission or polling, exact ordered structural calls with required arguments.message, atomic nonblank all-ID tool-result sequencing, complete all-choice streaming/data validation, native Ollama structural adaptation, complete-response then all validated tool callbacks then completion ordering, observed async callbacks, Blob/File transcription, default repeated-formatting-mark removal from cloned outbound TTS input, explicit textFormat:'plain' preservation for complete synthesis/preparation while streaming cleanup remains automatic and caller/visible content stays exact, immediate exact-segment synthesis, per-call voice/speed capture, optional final-segment pause on the existing audio clock, optional per-call terminal playback completion, detached preparation with complete semantic DBOPFS audio reuse and same-owner pending request sharing; each preparation owns its pending activation interest, final-interest cancellation aborts that activation while other interests preserve it, complete cache hits start no model, and cancellation after readiness leaves the ready model loaded; independent prepared playback without a model load for cached audio, ordered audio-clock scheduling, playable audio, active-generation TTS operation-failure routing, mute, and cancellation are normalized; provider/model/runtime/voice policy remains caller-owned. Both speech roles default execution.device to auto: webnn-npu when navigator.ml.createContext is exposed, then webgpu when navigator.gpu is exposed, then wasm for CPU execution. Failed backend loads clean up the pool before trying fresh Workers with the same prepared model and dtype; explicit devices never fall back. STT capacity only accepts 1; TTS accepts 1 through 4. Execution snapshots expose requestedDevice, selectedDevice, maxConcurrentRequests, and activeRequestCount for both roles; selectedDevice is null while unloaded and names the successful upstream session backend request when loaded. It does not prove every operation used the physical NPU: WebNN unsupported operations may use WASM, and compatibility depends on the browser, driver, hardware, and graph.",
34
- "surface": "Native fetchTTS(payload,signal) and fetchSTT(audio,signal) forward caller cancellation separately from their complete Core speech payloads, normalize aborts to AbortError and suppress late results; actual engine interruption remains service-owned. prepareTTSPlayback(payload,signal) silently returns a real Blob or native-speech descriptor; native controls expose finished/released/state/error/pause/resume/stop, and actual release owns the playback lane. fetchTTS and durable prepareTTS report ARCANE_AI_TTS_AUDIO_EXPORT_UNAVAILABLE for native voices. Request-local model overrides remain exact and never mutate the configured model. Browser-speech protocol/event/error/reason constants; default `AI`; read-only `providerRuntime`, `browserSpeechConfiguration`, and `browserSpeechDescriptor`; explicit `providerRuntime.status(role,{execution:true})` snapshots; `streamTTS(text,end,options={})` with optional voice, speed, pauseAfterMs, and waitForPlayback; default speech-input cleanup with SDK-internal preparation metadata and explicit complete-call textFormat:'plain' consumed before engine dispatch; `prepareTTS({parts,storage,identity,textFormat,signal,onState})` returning ordered segments, state, ready, getAudio(index), and cancel(); `prepareAudioOutput()` returning the persistent WebAudio handle {kind,state,contextState,error,subscribe,resume,dispose} without resume, unmute, model load, sources, sound, or gesture listeners; current-state replay and output-specific canonical `AI_AUDIO_OUTPUT_STATE_EVENT` (`ai-audio-output-state`), with ready only for an actually running context; explicit owner disposal observes context-bound cleanup and close; `playPreparedTTS(prepared,{signal,onState,audioOutput})` returning state, error, finished, pause(), resume(), and stop() with one playback lane per AI and observational state/error callbacks; explicit live same-AI audioOutput borrows its context until owner disposal, while omission or null preserves per-playback terminal close; configure/dispose speech, route lifecycle, declaration-validated chat/stream requests with exact ordered calls; optional streamRequest toolText:{name,field} and onToolText(text,call,displayId) for decoded selected argument text with actual call identity, distinct from ordinary prose and terminal tool execution; synthesis/transcription and playback controls; initializes from current canonical user readiness, installs `window.ai`, and projects `ai-ready` plus active-generation `ai-tts-failure`."
34
+ "surface": "Native fetchTTS(payload,signal) and fetchSTT(audio,signal) forward caller cancellation separately from their complete Core speech payloads, normalize aborts to AbortError and suppress late results; actual engine interruption remains service-owned. prepareTTSPlayback(payload,signal) silently returns a real Blob or native-speech descriptor; native controls expose finished/released/state/error/pause/resume/stop, and actual release owns the playback lane. fetchTTS and durable prepareTTS report ARCANE_AI_TTS_AUDIO_EXPORT_UNAVAILABLE for native voices. Request-local model overrides remain exact and never mutate the configured model. Browser-speech protocol/event/error/reason constants; default `AI`; read-only `providerRuntime`, `browserSpeechConfiguration`, and `browserSpeechDescriptor`; explicit `providerRuntime.status(role,{execution:true})` snapshots; `streamTTS(text,end,options={})` with optional voice, speed, pauseAfterMs, and waitForPlayback; default speech-input cleanup with SDK-internal preparation metadata and explicit complete-call textFormat:'plain' consumed before engine dispatch; `prepareTTS({parts,storage,identity,textFormat,signal,onState})` returning ordered segments, state, ready, getAudio(index), and cancel(); async `readPreparedTTS({parts,textFormat,storage,identity,signal,onState})` returning a complete ready handle or null through the same semantic matcher, with no activation, unmute, synthesis, playback, storage writes, or partial repair; manifest, audio availability, and returned-handle reads use DBOPFS createTable:false so absent tables remain absent; supplied adapters own that read-existing behavior; missing matches/audio return null, malformed metadata and storage failures reject, and omitted storage sees only completed matching same-AI in-memory audio; hits replay ready state, misses emit nothing, and the lookup signal detaches on settlement while returned-handle cancellation stays independent; `prepareAudioOutput()` returning the persistent WebAudio handle {kind,state,contextState,error,subscribe,resume,dispose} without resume, unmute, model load, sources, sound, or gesture listeners; current-state replay and output-specific canonical `AI_AUDIO_OUTPUT_STATE_EVENT` (`ai-audio-output-state`), with ready only for an actually running context; explicit owner disposal observes context-bound cleanup and close; `playPreparedTTS(prepared,{signal,onState,audioOutput})` returning state, error, finished, pause(), resume(), and stop() with one playback lane per AI and observational state/error callbacks; explicit live same-AI audioOutput borrows its context until owner disposal, while omission or null preserves per-playback terminal close; configure/dispose speech, route lifecycle, declaration-validated chat/stream requests with exact ordered calls; optional streamRequest toolText:{name,field} and onToolText(text,call,displayId) for decoded selected argument text with actual call identity, distinct from ordinary prose and terminal tool execution; synthesis/transcription and playback controls; initializes from current canonical user readiness, installs `window.ai`, and projects `ai-ready` plus active-generation `ai-tts-failure`."
35
35
  },
36
36
  {
37
37
  "file": "runtime/arcane/modules/AIModelSelectionController.js",
@@ -583,7 +583,7 @@
583
583
  "availability": "Browser / native WebView",
584
584
  "protocol": "OPFS, DBOPFSWorker, Compression Streams, per-realm globalThis.arcaneEvents authority, and native BroadcastChannel for live same-app/storage-partition committed changes",
585
585
  "normalization": "App scope and recognized file parsing are normalized; nonblank unreadable JSONL rows remain complete raw strings, empty-table removal reports removed, absent, or not-empty after a create:false directory lookup and without scanning or recursively clearing a directory, same-named files are preserved, aliases and cached handles are invalidated only when the directory is gone, and unexpected DOM/storage errors are preserved.",
586
- "surface": "default `DBOPFS`; installs `window.dbopfs`, emits `dbopfs-ready`; subscribeChanges(listener,{signal,once}) observes DBOPFS_EVENT_TYPES.change (dbopfs-change), returns idempotent unsubscribe/.dispose, and publishes metadata {applicationId,storagePath,tableName,directoryName,fileName,action,originId,sequence,changeId,remote} after successful storage commits only. Actions are write/append/delete/table-delete; batches preserve partial success, remote receipts invalidate targeted aliases before local publication and never rebroadcast, and pagehide/pageshow closes/reopens the native channel and clears memory caches without replay or fabricated changes. Local subscription/storage continue when BroadcastChannel is unavailable. Table/file/backup APIs include removeEmptyTable(tableName). createCompressedPNG({tableNames,additionalTables,signal}) returns a PNG Blob without download or record writes; omitted names select all, [] none, and supplied tables replace matching saved tables in the export. downloadCompressedPNG(name,options) reuses the same preparation and initiates one download; cancellation prevents it. restoreFromPNG(file,{selectTables}) closes the decoded image after pixel extraction, awaits the optional caller selection/projection before any writes, and rejects DBOPFS_RESTORE_WRITE_FAILED AggregateError after all selected table batches settle when records fail, preserving original errors and failures {tableName,fileName,reason}; successful writes remain. No-options restore and setMany retain their contracts."
586
+ "surface": "default `DBOPFS`; installs `window.dbopfs`, emits `dbopfs-ready`; subscribeChanges(listener,{signal,once}) observes DBOPFS_EVENT_TYPES.change (dbopfs-change), returns idempotent unsubscribe/.dispose, and publishes metadata {applicationId,storagePath,tableName,directoryName,fileName,action,originId,sequence,changeId,remote} after successful storage commits only. Actions are write/append/delete/table-delete; batches preserve partial success, remote receipts invalidate targeted aliases before local publication and never rebroadcast, and pagehide/pageshow closes/reopens the native channel and clears memory caches without replay or fabricated changes. Local subscription/storage continue when BroadcastChannel is unavailable. Table/file/backup APIs include getTableHandle(tableName='',{create=true}={}), readFile(tableName='',fileName='',{createTable=true}={}), get(tableName='',fileName='',force=false,{createTable=true}={}), and removeEmptyTable(tableName). Explicit create:false/createTable:false reads existing tables without creation or joining pending create work; default lazy creation remains unchanged. Low-level missing tables/files reject NotFoundError; get retains parsed caching/force semantics and resolves null only for NotFoundError, propagating other failures. Worker fallback reads already use existing app/table/file handles. createCompressedPNG({tableNames,additionalTables,signal}) returns a PNG Blob without download or record writes; omitted names select all, [] none, and supplied tables replace matching saved tables in the export. downloadCompressedPNG(name,options) reuses the same preparation and initiates one download; cancellation prevents it. restoreFromPNG(file,{selectTables}) closes the decoded image after pixel extraction, awaits the optional caller selection/projection before any writes, and rejects DBOPFS_RESTORE_WRITE_FAILED AggregateError after all selected table batches settle when records fail, preserving original errors and failures {tableName,fileName,reason}; successful writes remain. No-options restore and setMany retain their contracts."
587
587
  },
588
588
  {
589
589
  "file": "runtime/arcane/modules/DBOPFSDocumentLibrary.js",
@@ -1092,13 +1092,14 @@
1092
1092
  "name": "PreparedSpeech.js",
1093
1093
  "kind": "esm",
1094
1094
  "exports": [
1095
- "prepareSpeech"
1095
+ "prepareSpeech",
1096
+ "readPreparedSpeech"
1096
1097
  ],
1097
- "summary": "Owns detached ordered speech preparation, complete semantic audio reuse, and per-caller cancellation behind AI.prepareTTS.",
1098
- "availability": "Browser or native WebView with Blob, AbortController, an injected synthesis callback, and optional ready DBOPFS",
1098
+ "summary": "Owns detached ordered speech preparation, complete semantic audio reuse, cache-only lookup, and per-caller cancellation behind AI.prepareTTS and AI.readPreparedTTS.",
1099
+ "availability": "Browser or native WebView with Blob, AbortController, optional ready DBOPFS, and an injected synthesis callback for generation",
1099
1100
  "protocol": "In-process preparation handle plus caller-owned DBOPFS raw audio and version-1 semantic manifest",
1100
- "normalization": "Preserves complete original parts and JSON-compatible semantic context, shares pending matching work on the same owner/storage group, admits segments in order, stores complete Blobs and MIME metadata, retains successful audio after failure or cancellation, and separates generation readiness from playback.",
1101
- "surface": "Named prepareSpeech({owner,parts,originalParts,selection,segmentation,storage,identity,signal,onState,synthesize}) returns {segments,state,ready,getAudio(index),cancel()}; applications use AI.prepareTTS and AI.playPreparedTTS so the shared AI owner retains segmentation, provider readiness, capacity, and playback."
1101
+ "normalization": "Preserves complete original parts and JSON-compatible semantic context, shares pending matching work on the same owner/storage group, admits segments in order, stores complete Blobs and MIME metadata, retains successful audio after failure or cancellation, and separates generation readiness from playback. Cache-only lookup uses the same semantic matcher, reads every stored audio reference without writes or repair, and returns null for missing matches/audio while preserving genuine read failures.",
1102
+ "surface": "Named prepareSpeech({owner,parts,originalParts,selection,segmentation,storage,identity,signal,onState,synthesize}) returns {segments,state,ready,getAudio(index),cancel()}. Named async readPreparedSpeech({owner,parts,originalParts=parts,selection=null,segmentation=null,storage=null,identity=null,signal=null,onState=null}) returns a complete ready handle or null without synthesis, activation, unmute, playback, or storage writes. Manifest, audio availability, and returned-handle reads pass DBOPFS createTable:false, leaving absent tables absent; supplied adapters own the same read-existing contract. Omitted storage reads only completed matching same-owner in-memory preparations; pending or failed work returns null. A hit replays ready state and a miss emits nothing. The lookup signal detaches on settlement; the returned handle keeps independent explicit cancellation and MIME-preserving getAudio, including later storage errors. Applications use AI.prepareTTS, AI.readPreparedTTS, and AI.playPreparedTTS so the shared AI owner retains segmentation, provider readiness, capacity, and playback."
1102
1103
  },
1103
1104
  {
1104
1105
  "file": "runtime/arcane/modules/PrintView.js",
@@ -121,7 +121,7 @@ own asynchronous work, cancellation, and backpressure.
121
121
  | [`OpenMeteoWeatherProvider.js`](#openmeteoweatherproviderjs) | esm | Searches and loads Open-Meteo data into complete mutable Arcane weather entities. | Browser / native WebView / server with fetch + cloud | Provider data normalized to mutable entities; transport errors mixed. |
122
122
  | [`PersistentAIChatSession.js`](#persistentaichatsessionjs) | esm | Adds explicit retained-history/memory policy to complete configured chat without changing DBOPFS or ChatEntity semantics. | Browser / native WebView with DBOPFS and configured chat | Retained context commits atomically; `persist:false` turns are one-operation-only. |
123
123
  | [`PreferenceStore.js`](#preferencestorejs) | esm | Loads and updates schema-defined app preferences through native storage with a narrow browser fallback. | Browser/native hybrid | Complete values and operation context retained; exact optional namespace absence or existing Android unsupported capability selects the existing local adapter; partial-service and genuine failures remain errors; an advertised rejected batch is never retried serially. |
124
- | [`PreparedSpeech.js`](#preparedspeechjs) | esm | Owns detached ordered preparation, semantic audio reuse, and per-caller cancellation behind AI.prepareTTS. | Browser / native WebView with injected synthesis and optional DBOPFS | Complete original inputs, ordered audio metadata, durable reuse, and observable preparation results. |
124
+ | [`PreparedSpeech.js`](#preparedspeechjs) | esm | Owns detached ordered preparation, semantic audio reuse, cache-only lookup, and per-caller cancellation behind AI.prepareTTS and AI.readPreparedTTS. | Browser / native WebView with optional DBOPFS and injected synthesis for generation | Complete original inputs, ordered audio metadata, durable reuse, and observable preparation results. |
125
125
  | [`PrintView.js`](#printviewjs) | esm | Prints current rendered content and title through one document-owned print lifecycle. | Browser / supported native WebView print implementation | Complete rendered snapshot, preparation cancellation, resource lifetime, and honest request result. |
126
126
  | [`QRCode.min.js`](#qrcodeminjs) | classic-script | Vendored QRCode generator for DOM, canvas, SVG, and image output. | Browser vendor script | Vendor-native. |
127
127
  | [`Questionnaire.js`](#questionnairejs) | esm | Evaluates whether a one-time questionnaire prompt is due without performing the prompt. | Cross-host | Normalized conservative boolean. |
@@ -171,6 +171,7 @@ default `AI`; read-only `providerRuntime`, `speechActivationPending`, `browserSp
171
171
  read-only `ttsSegmentation`, `configureTTSSegmentation()`,
172
172
  `streamTTS(text='',end=false,options={})`,
173
173
  `prepareTTS({parts,textFormat,storage,identity,signal,onState})`,
174
+ `readPreparedTTS({parts,textFormat,storage,identity,signal,onState})`,
174
175
  `prepareAudioOutput()`, `playPreparedTTS(prepared,{signal,onState,audioOutput})`,
175
176
  `finishTTS()`, `prepareTTSPlayback()`, `fetchTTS()`, `fetchSTT()`, `stopAudio()`, `resumeAudio()`,
176
177
  `playAudio()`; consumes `user-entity-loaded` and `arcane-ollama-ready`,
@@ -497,6 +498,34 @@ cancelling one handle does not cancel another active matching caller.
497
498
  Preparation cancellation prevents later synthesis and releases its activation
498
499
  interest. The provider's operation remains responsible for actual cleanup.
499
500
 
501
+ `await readPreparedTTS({parts,textFormat,storage,identity,signal,onState})`
502
+ returns a complete ready preparation handle or `null`. It uses the same original
503
+ parts, selected speech configuration, voice/speed/pause values, text treatment,
504
+ segmentation, and application identity as `prepareTTS()` for semantic matching.
505
+ It never activates or unmutes a provider, synthesizes, starts playback, writes
506
+ storage, or repairs a partial record. With storage, it reads the existing
507
+ manifest and every ordered audio reference through the existing per-key storage
508
+ queue. It passes `{createTable:false}` to DBOPFS manifest and audio reads,
509
+ including later handle reads, so missing tables stay absent. Supplied database
510
+ adapters are responsible for honoring that read-existing contract.
511
+ No matching record or any missing audio returns `null`; unreadable
512
+ metadata and other storage errors reject. Without storage, only a completed,
513
+ matching, same-AI retained in-memory preparation can be returned; pending or
514
+ failed work returns `null`.
515
+
516
+ A hit returns `{segments,state,ready,getAudio(index),cancel()}` with `state`
517
+ and every segment already `ready`; `onState` synchronously replays that state.
518
+ A miss emits no state callback. The lookup signal rejects cancellation as
519
+ `AbortError` and detaches when lookup settles; aborting it after success does
520
+ not cancel the returned handle. An already-started non-abortable database read
521
+ may finish after cancellation; observed cancellation prevents subsequent reads
522
+ or a returned handle. The handle's explicit `cancel()` retains its
523
+ own lifetime. Durable `getAudio(index)` reopens the stored file with its retained
524
+ MIME type, so a later file removal remains an observable error. Native speech
525
+ without audio export rejects with `ARCANE_AI_TTS_AUDIO_EXPORT_UNAVAILABLE`, as
526
+ `prepareTTS()` does. Pass a returned handle to `playPreparedTTS()` only when
527
+ playback is wanted.
528
+
500
529
  `playPreparedTTS(prepared,{signal,onState,audioOutput})` can attach immediately. Its returned
501
530
  `{state,error,finished,pause(),resume(),stop()}` handle schedules segments in
502
531
  their original order using the existing AI audio clock. `finished` resolves `true` after
@@ -2254,6 +2283,23 @@ default `DBOPFS`; installs `window.dbopfs`, emits `dbopfs-ready`; table/file/bac
2254
2283
 
2255
2284
  Exact exports: `DBOPFS_EVENT_TYPES`, `DBOPFS_REASONS`, `default`.
2256
2285
 
2286
+ `getTableHandle(tableName='', {create=true}={})` lazily opens or creates the
2287
+ requested table by default. `{create:false}` looks up only an existing table
2288
+ without joining a pending create request; a missing table rejects with the
2289
+ platform's `NotFoundError`. The existing `memories` / `memory` alias is retained.
2290
+ `readFile(tableName='', fileName='', {createTable=true}={})` returns the complete
2291
+ raw file. `{createTable:false}` also prevents table creation, and missing tables
2292
+ or files reject with `NotFoundError`. Its synchronous-access worker fallback
2293
+ already opens existing app/table/file handles without creating them.
2294
+
2295
+ `get(tableName='', fileName='', force=false, {createTable=true}={})` retains its
2296
+ existing parsed-value cache and `force` behavior, forwarding `createTable` when
2297
+ it reads a file. On a read, `NotFoundError` resolves `null`; other errors reject.
2298
+ Use `get(tableName,fileName,true,{createTable:false})` for an existing-storage
2299
+ lookup that does not return an earlier cached value. Omitted options preserve the previous
2300
+ lazy table-creation behavior. These options change neither write operations nor
2301
+ the owning app scope's initialization.
2302
+
2257
2303
  `subscribeChanges(listener, {signal, once})` observes
2258
2304
  `DBOPFS_EVENT_TYPES.change` (`dbopfs-change`) through the existing canonical
2259
2305
  `arcane-os/event-manager` source. It returns an idempotent unsubscribe function
@@ -3649,10 +3695,11 @@ console.log(Object.keys(module));
3649
3695
 
3650
3696
  ### Overview
3651
3697
 
3652
- Shared preparation mechanism used by `AI.prepareTTS()`. It owns ordered
3653
- generation admission, same-owner request sharing, complete audio storage and
3654
- semantic reuse, and each caller's preparation lifetime. It does not construct
3655
- an audio context, play speech, or select application content.
3698
+ Shared preparation mechanism used by `AI.prepareTTS()` and `AI.readPreparedTTS()`.
3699
+ It owns ordered generation admission, same-owner request sharing, complete audio
3700
+ storage and semantic reuse, cache-only lookup, and each caller's preparation
3701
+ lifetime. It does not construct an audio context, play speech, or select
3702
+ application content.
3656
3703
 
3657
3704
  ### Public surface
3658
3705
 
@@ -3661,10 +3708,29 @@ segmentation=null,storage=null,identity=null,signal=null,onState,synthesize})`.
3661
3708
  The owning AI supplies already segmented speech parts, complete original parts,
3662
3709
  its selection snapshot, and its synthesis callback. The returned handle is
3663
3710
  `{segments,state,ready,getAudio(index),cancel()}`. Applications use
3664
- `AI.prepareTTS()` and `AI.playPreparedTTS()` so the existing AI owner retains
3665
- automatic formatting cleanup, segmentation, provider readiness/capacity, and
3711
+ `AI.prepareTTS()`, `AI.readPreparedTTS()`, and `AI.playPreparedTTS()` so the existing
3712
+ AI owner retains automatic formatting cleanup, segmentation, provider readiness/capacity, and
3666
3713
  ordered playback. See the [complete preparation contract](ai/browser-speech.md#prepare-narration-once-and-replay-stored-audio).
3667
3714
 
3715
+ Named async `readPreparedSpeech({owner,parts,originalParts=parts,selection=null,
3716
+ segmentation=null,storage=null,identity=null,signal=null,onState=null})` uses
3717
+ the same complete semantic matcher and resolves a ready preparation handle or
3718
+ `null`. It performs no synthesis, model activation, playback, storage writes,
3719
+ or repair. A durable lookup reads the existing manifest and every audio file;
3720
+ missing matching records or audio return `null`, while malformed metadata and
3721
+ other read failures reject. Manifest, audio availability, and returned-handle
3722
+ audio reads pass `{createTable:false}` to DBOPFS so a missing table is never
3723
+ created; supplied adapters own the same read-existing behavior. Without storage
3724
+ it reads only a completed matching same-owner in-memory preparation, never
3725
+ pending or failed work. A hit replays
3726
+ ready state through `onState`; a miss emits nothing. Lookup cancellation rejects
3727
+ with `AbortError` and is detached on settlement. After success the returned
3728
+ handle's explicit `cancel()` is independent of the lookup signal; its `ready`
3729
+ resolves the complete record and `getAudio(index)` preserves the existing MIME
3730
+ and later-read error behavior. Already-started non-abortable database reads may
3731
+ settle after cancellation; no further reads or handle are returned after the
3732
+ cancellation is observed.
3733
+
3668
3734
  `storage:{db,table,key}` is optional; the ready DBOPFS instance supplies
3669
3735
  `get`, `set`, `readFile`, and `writeFile`. JSON-compatible semantic inputs stay
3670
3736
  complete in the version-1 manifest. Raw audio is persisted separately and its
@@ -3672,13 +3738,14 @@ MIME type is retained in metadata. Storage mutation serializes by database,
3672
3738
  table, and key within the realm. Synthesis sharing is scoped to the same owner
3673
3739
  and matching semantic inputs/storage; playback remains outside this module.
3674
3740
 
3675
- Exact exports: `prepareSpeech`.
3741
+ Exact exports: `prepareSpeech`, `readPreparedSpeech`.
3676
3742
 
3677
3743
  ### Availability and normalization
3678
3744
 
3679
- **Browser or native WebView with Blob, AbortController, an injected synthesis
3680
- callback, and optional ready DBOPFS.** Import creates no provider or
3681
- playback. Calling the preparation function starts owned asynchronous work.
3745
+ **Browser or native WebView with Blob, AbortController, optional ready DBOPFS,
3746
+ and an injected synthesis callback for generation.** Import creates no provider or
3747
+ playback. Calling `prepareSpeech` starts owned asynchronous generation/reuse;
3748
+ `readPreparedSpeech` only reads existing complete audio.
3682
3749
  `ready` rejects complete synthesis/storage failures or `AbortError` after
3683
3750
  cancellation; successful audio remains available for reuse. Malformed part,
3684
3751
  storage, or semantic metadata inputs throw `TypeError`; an invalid segment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.77.0",
3
+ "version": "0.78.0",
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",
@@ -26,7 +26,7 @@ import {
26
26
  openAIResponseFormat
27
27
  } from 'arcane-os/ai/twin-cloud';
28
28
  import {MarkdownSpeech,stripSpeechFormatting} from 'arcane-os/speech-text';
29
- import {prepareSpeech} from './PreparedSpeech.js';
29
+ import {prepareSpeech, readPreparedSpeech} from './PreparedSpeech.js';
30
30
  import {createToolTextObserver} from 'arcane-os/ai/tool-text-stream';
31
31
 
32
32
  const completeValue=(value)=>value;
@@ -6341,16 +6341,15 @@ class AI {
6341
6341
  return result;
6342
6342
  }
6343
6343
 
6344
- prepareTTS({parts, textFormat, storage = null, identity = null, signal = null, onState = null} = {}) {
6344
+ #preparedSpeechInput(parts, textFormat, operation) {
6345
6345
  if(this.#providerSpeechModel()?.speech?.playback==='native'){
6346
6346
  const error=new Error('Native browser speech has no audio file to prepare or store; use prepareTTSPlayback.');
6347
6347
  error.code='ARCANE_AI_TTS_AUDIO_EXPORT_UNAVAILABLE';
6348
6348
  throw error;
6349
6349
  }
6350
6350
  if(!is.array(parts)) {
6351
- throw new TypeError('AI.prepareTTS parts must be an array.');
6351
+ throw new TypeError(`${operation} parts must be an array.`);
6352
6352
  }
6353
- const runtime = this;
6354
6353
  const selected = this.#providerRuntime.selection('tts');
6355
6354
  const configuredSpeech = this.browserSpeechConfiguration?.tts;
6356
6355
  const configuration = configuredSpeech
@@ -6367,12 +6366,9 @@ class AI {
6367
6366
  responseFormat: selected ? this.#providerSpeechResponseFormat() : this.audioFormat,
6368
6367
  configuration
6369
6368
  };
6370
- const browserGeneration = this.#browserSpeechGeneration;
6371
- const controlGeneration = this.#speechControlGeneration;
6372
6369
  const defaultVoice = selected
6373
6370
  ? this.#providerSpeechVoice()
6374
6371
  : this.#builtInSpeechDefaultVoice('tts', this.ttsService);
6375
- const responseFormat = selection.responseFormat;
6376
6372
  const segmentation = this.ttsSegmentation;
6377
6373
  const segments = [];
6378
6374
  for(const part of parts) {
@@ -6398,6 +6394,33 @@ class AI {
6398
6394
  );
6399
6395
  }
6400
6396
  }
6397
+ return {segments, selection, segmentation, selected};
6398
+ }
6399
+
6400
+ async readPreparedTTS({parts, textFormat, storage = null, identity = null, signal = null, onState = null} = {}) {
6401
+ const {segments, selection, segmentation} = this.#preparedSpeechInput(parts, textFormat, 'AI.readPreparedTTS');
6402
+ this.#traceSpeech('readPreparedTTS.call', {parts, segments, selection, segmentation, identity, storage});
6403
+ return readPreparedSpeech(
6404
+ {
6405
+ owner: this,
6406
+ parts: segments,
6407
+ originalParts: parts,
6408
+ selection,
6409
+ segmentation,
6410
+ storage,
6411
+ identity,
6412
+ signal,
6413
+ onState
6414
+ }
6415
+ );
6416
+ }
6417
+
6418
+ prepareTTS({parts, textFormat, storage = null, identity = null, signal = null, onState = null} = {}) {
6419
+ const {segments, selection, segmentation, selected} = this.#preparedSpeechInput(parts, textFormat, 'AI.prepareTTS');
6420
+ const runtime = this;
6421
+ const browserGeneration = this.#browserSpeechGeneration;
6422
+ const controlGeneration = this.#speechControlGeneration;
6423
+ const responseFormat = selection.responseFormat;
6401
6424
  this.#traceSpeech('prepareTTS.call', {parts, segments, selection, segmentation, identity, storage});
6402
6425
  async function synthesizePreparedSpeech(part, requestSignal) {
6403
6426
  function assertPreparedSpeechSelection() {
@@ -554,12 +554,13 @@ class DBOPFS {
554
554
 
555
555
  /**
556
556
  * Gets the directory handle for a table.
557
- * Creates the table if it does not exist.
557
+ * Creates the table if it does not exist unless create is false.
558
558
  *
559
559
  * @param {string} tableName
560
+ * @param {{create?:boolean}} options A false create performs an existing-table lookup only.
560
561
  * @returns {Promise<FileSystemDirectoryHandle>}
561
562
  */
562
- async getTableHandle(tableName=''){
563
+ async getTableHandle(tableName='',{create=true}={}){
563
564
  if(!this.ready){
564
565
  await this.readyPromise;
565
566
  }
@@ -582,6 +583,14 @@ class DBOPFS {
582
583
  return existingHandle;
583
584
  }
584
585
 
586
+ if(!create){
587
+ // A read-existing lookup must not wait for another caller to create the table.
588
+ return this.#db.getDirectoryHandle(
589
+ directoryName,
590
+ {create:false}
591
+ );
592
+ }
593
+
585
594
  if(!this.#tableHandlePromises[registeredTableName]){
586
595
  const handlePromise=this.#db.getDirectoryHandle(
587
596
  directoryName,
@@ -736,10 +745,14 @@ class DBOPFS {
736
745
  *
737
746
  * @param {string} tableName
738
747
  * @param {string} fileName
748
+ * @param {{createTable?:boolean}} options A false createTable preserves an absent table.
739
749
  * @returns {Promise<File>}
740
750
  */
741
- async readFile(tableName='',fileName=''){
742
- const table=await this.getTableHandle(tableName)
751
+ async readFile(tableName='',fileName='',{createTable=true}={}){
752
+ const table=await this.getTableHandle(
753
+ tableName,
754
+ {create:createTable}
755
+ )
743
756
  const handle=await table.getFileHandle(fileName,{create:false})
744
757
 
745
758
  if(is.function(handle.getFile)){
@@ -818,14 +831,19 @@ class DBOPFS {
818
831
  * @param {string} tableName
819
832
  * @param {string} fileName
820
833
  * @param {boolean} force
834
+ * @param {{createTable?:boolean}} options A false createTable preserves an absent table.
821
835
  * @returns {Promise<*>}
822
836
  */
823
- async get(tableName='',fileName='',force=false){
837
+ async get(tableName='',fileName='',force=false,{createTable=true}={}){
824
838
  if(force||!this.#tables[tableName]?.[fileName]){
825
839
  const cacheState=this.#fileCacheState(tableName,fileName);
826
840
  const cacheRevision=cacheState.revision;
827
841
  try{
828
- const file=await this.readFile(tableName,fileName)
842
+ const file=await this.readFile(
843
+ tableName,
844
+ fileName,
845
+ {createTable}
846
+ )
829
847
  const textContent=await file.text()
830
848
 
831
849
  if(!this.#tables[tableName]){
@@ -111,8 +111,10 @@ function serializeStorage(storage,execute){
111
111
  return operation;
112
112
  }
113
113
 
114
- async function readManifest(storage){
115
- const stored=await storage.db.get(storage.table,storage.fileName,true);
114
+ async function readManifest(storage,cacheOnly=false){
115
+ const stored=cacheOnly
116
+ ?await storage.db.get(storage.table,storage.fileName,true,{createTable:false})
117
+ :await storage.db.get(storage.table,storage.fileName,true);
116
118
  if(stored===null||stored===undefined)return {version:1,entries:[]};
117
119
  const manifest=copyJSON(stored);
118
120
  if(manifest.version!==1||!is.array(manifest.entries)){
@@ -349,9 +351,10 @@ function finishPreparation(job,error){
349
351
  job.finished.resolve();
350
352
  }
351
353
 
352
- function createJob(ownerState,input,storage,synthesize,previous){
353
- const job={
354
+ function createJobState(input,storage,synthesize=null,previous=null){
355
+ return {
354
356
  input,storage,synthesize,previous,
357
+ cacheOnly:false,
355
358
  controller:new AbortController(),
356
359
  interests:new Set(),
357
360
  state:'queued',
@@ -367,6 +370,10 @@ function createJob(ownerState,input,storage,synthesize,previous){
367
370
  return {...part,index,state:'queued',audioFile:null,contentType:null,error:null};
368
371
  })
369
372
  };
373
+ }
374
+
375
+ function createJob(ownerState,input,storage,synthesize,previous){
376
+ const job=createJobState(input,storage,synthesize,previous);
370
377
  const admission=ownerState.admission.then(function admitOrderedPreparation(){
371
378
  return admitPreparation(job);
372
379
  });
@@ -416,7 +423,9 @@ function createHandle(job,signal,onState){
416
423
  if(!interest.active)throw abortError();
417
424
  if(!job.storage)return job.slots[index].blob;
418
425
  const audio=job.record.segments[index];
419
- const file=await job.storage.db.readFile(job.storage.table,audio.audioFile);
426
+ const file=job.cacheOnly
427
+ ?await job.storage.db.readFile(job.storage.table,audio.audioFile,{createTable:false})
428
+ :await job.storage.db.readFile(job.storage.table,audio.audioFile);
420
429
  if(!interest.active)throw abortError();
421
430
  return file.type===audio.contentType?file:new Blob([file],{type:audio.contentType});
422
431
  },
@@ -482,4 +491,100 @@ function prepareSpeech({
482
491
  return createHandle(job,signal,onState);
483
492
  }
484
493
 
485
- export {prepareSpeech};
494
+ /** Read complete existing audio without synthesis, storage writes, or repair. */
495
+ async function readPreparedSpeech({
496
+ owner,
497
+ parts,
498
+ originalParts=parts,
499
+ selection=null,
500
+ segmentation=null,
501
+ storage=null,
502
+ identity=null,
503
+ signal=null,
504
+ onState=null
505
+ }={}){
506
+ if((!owner||!is.object(owner))&&!is.function(owner)){
507
+ throw new TypeError('Speech preparation requires its owning AI instance.');
508
+ }
509
+ if(!is.array(parts)||(onState!==null&&!is.function(onState))){
510
+ throw new TypeError('Cached speech requires ordered parts and an optional state callback.');
511
+ }
512
+ const input=copyJSON({parts,originalParts,selection,segmentation,identity});
513
+ const destination=normalizeStorage(storage);
514
+ if(signal?.aborted)throw abortError();
515
+ function completeCachedRead(job){
516
+ // The lookup signal owns only this read. Retained audio keeps its own
517
+ // explicit cancellation lifetime after a successful lookup returns.
518
+ const handle=createHandle(job,null,onState);
519
+ if(signal?.aborted){
520
+ handle.cancel();
521
+ throw abortError();
522
+ }
523
+ return handle;
524
+ }
525
+ if(!destination){
526
+ const jobs=owners.get(owner)?.jobs;
527
+ if(!jobs)return null;
528
+ for(const job of jobs){
529
+ if(job.storage||!job.done||job.failed||job.controller.signal.aborted
530
+ ||!sameInput(job.input,input))continue;
531
+ if(!job.slots.every(function hasRetainedAudio(slot){
532
+ return slot.settled&&slot.blob instanceof Blob;
533
+ }))return null;
534
+ return completeCachedRead(job);
535
+ }
536
+ return null;
537
+ }
538
+ const cancelled=deferred();
539
+ function cancelRead(){cancelled.reject(abortError());}
540
+ signal?.addEventListener('abort',cancelRead,{once:true});
541
+ try{
542
+ const reading=serializeStorage(destination,async function readCachedPreparation(){
543
+ if(signal?.aborted)throw abortError();
544
+ const manifest=await readManifest(destination,true);
545
+ if(signal?.aborted)throw abortError();
546
+ const record=manifest.entries.find(function findCachedPreparation(entry){
547
+ return sameInput(entry,input);
548
+ });
549
+ if(!record)return null;
550
+ if(!is.array(record.segments)||record.segments.length!==input.parts.length){
551
+ throw new TypeError('Stored speech preparation segments are unreadable.');
552
+ }
553
+ let complete=true;
554
+ for(const audio of record.segments){
555
+ if(!audio?.audioFile){
556
+ complete=false;
557
+ continue;
558
+ }
559
+ if(!is.string(audio.contentType)){
560
+ throw new TypeError('Stored speech audio content type is unreadable.');
561
+ }
562
+ if(signal?.aborted)throw abortError();
563
+ try{
564
+ const file=await destination.db.readFile(destination.table,audio.audioFile,{createTable:false});
565
+ if(!(file instanceof Blob)){
566
+ throw new TypeError('Stored speech audio must be a Blob.');
567
+ }
568
+ }catch(error){
569
+ if(error?.name!=='NotFoundError')throw error;
570
+ complete=false;
571
+ }
572
+ if(signal?.aborted)throw abortError();
573
+ }
574
+ return complete?record:null;
575
+ });
576
+ const record=await Promise.race([reading,cancelled.promise]);
577
+ if(signal?.aborted)throw abortError();
578
+ if(!record)return null;
579
+ const job=createJobState(input,destination);
580
+ job.cacheOnly=true;
581
+ job.record=record;
582
+ for(let index=0;index<job.slots.length;index+=1)settleSegment(job,index);
583
+ finishPreparation(job);
584
+ return completeCachedRead(job);
585
+ }finally{
586
+ signal?.removeEventListener('abort',cancelRead);
587
+ }
588
+ }
589
+
590
+ export {prepareSpeech,readPreparedSpeech};