Spaces:
Running
Running
Cache verified model files across browser visits
Browse files- README.md +7 -4
- app.js +5 -5
- index.html +1 -1
- pnc-worker.js +44 -32
- worker.js +44 -33
README.md
CHANGED
|
@@ -23,10 +23,12 @@ saved.
|
|
| 23 |
|
| 24 |
The model URLs and checksums are pinned in [`model-config.json`](model-config.json).
|
| 25 |
The browser downloads Tiny's runtime configuration and ONNX files, then PnC's
|
| 26 |
-
files, directly from their Hugging Face model repos.
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
physical-device and mobile checks are completed.
|
| 31 |
|
| 32 |
The browser and Python CLI pin the same PnC v2 int8 artifact. The Python
|
|
@@ -78,4 +80,5 @@ node tests/browser_live_chunking.mjs
|
|
| 78 |
node tests/browser_pnc_sentence_casing.mjs
|
| 79 |
node tests/browser_space_signed_assets.mjs
|
| 80 |
node tests/browser_tiny_runtime_config.mjs
|
|
|
|
| 81 |
```
|
|
|
|
| 23 |
|
| 24 |
The model URLs and checksums are pinned in [`model-config.json`](model-config.json).
|
| 25 |
The browser downloads Tiny's runtime configuration and ONNX files, then PnC's
|
| 26 |
+
files, directly from their Hugging Face model repos. Verified model files are
|
| 27 |
+
kept in browser storage for later visits when storage is available; every load
|
| 28 |
+
still verifies their checksums and initializes the WebAssembly runtime. The
|
| 29 |
+
small third-party Silero VAD file is bundled with the Space for browser access.
|
| 30 |
+
Microphone access requires a secure origin and the browser's permission prompt,
|
| 31 |
+
shown after the app's introductory mic dialog. Browser support remains a preview while
|
| 32 |
physical-device and mobile checks are completed.
|
| 33 |
|
| 34 |
The browser and Python CLI pin the same PnC v2 int8 artifact. The Python
|
|
|
|
| 80 |
node tests/browser_pnc_sentence_casing.mjs
|
| 81 |
node tests/browser_space_signed_assets.mjs
|
| 82 |
node tests/browser_tiny_runtime_config.mjs
|
| 83 |
+
node tests/browser_model_cache.mjs
|
| 84 |
```
|
app.js
CHANGED
|
@@ -91,8 +91,8 @@ const state = {
|
|
| 91 |
level: 0,
|
| 92 |
};
|
| 93 |
|
| 94 |
-
const worker = new Worker(signedAsset("worker.js?v=20260927-
|
| 95 |
-
const pncWorker = new Worker(signedAsset("pnc-worker.js?v=20260927-
|
| 96 |
|
| 97 |
function setStatus(text, detail = "", kind = "loading") {
|
| 98 |
elements.statusText.textContent = text;
|
|
@@ -611,13 +611,13 @@ worker.onmessage = (event) => {
|
|
| 611 |
const message = event.data || {};
|
| 612 |
if (message.type === "model-progress") {
|
| 613 |
const percent = Math.round(message.overall * 100);
|
| 614 |
-
setStatus("
|
| 615 |
setProgress(message.overall);
|
| 616 |
} else if (message.type === "ready") {
|
| 617 |
state.ready = true;
|
| 618 |
elements.recordButton.disabled = false;
|
| 619 |
elements.recordLabel.textContent = "Klik for at tale";
|
| 620 |
-
setStatus("Klar", `${formatBytes(message.bytes)}
|
| 621 |
setProgress(1, true);
|
| 622 |
ensurePunctuation();
|
| 623 |
} else if (message.type === "live-result") {
|
|
@@ -665,7 +665,7 @@ worker.onmessage = (event) => {
|
|
| 665 |
pncWorker.onmessage = (event) => {
|
| 666 |
const message = event.data || {};
|
| 667 |
if (message.type === "pnc-progress") {
|
| 668 |
-
elements.pncStatus.textContent = `
|
| 669 |
} else if (message.type === "pnc-ready") {
|
| 670 |
state.pncReady = true;
|
| 671 |
elements.pncStatus.textContent = `Klar · ${formatBytes(message.bytes)}`;
|
|
|
|
| 91 |
level: 0,
|
| 92 |
};
|
| 93 |
|
| 94 |
+
const worker = new Worker(signedAsset("worker.js?v=20260927-2"));
|
| 95 |
+
const pncWorker = new Worker(signedAsset("pnc-worker.js?v=20260927-2"));
|
| 96 |
|
| 97 |
function setStatus(text, detail = "", kind = "loading") {
|
| 98 |
elements.statusText.textContent = text;
|
|
|
|
| 611 |
const message = event.data || {};
|
| 612 |
if (message.type === "model-progress") {
|
| 613 |
const percent = Math.round(message.overall * 100);
|
| 614 |
+
setStatus(message.cached ? "Indlæser gemt model" : "Indlæser lokal model", `${percent}% · ${message.file}`);
|
| 615 |
setProgress(message.overall);
|
| 616 |
} else if (message.type === "ready") {
|
| 617 |
state.ready = true;
|
| 618 |
elements.recordButton.disabled = false;
|
| 619 |
elements.recordLabel.textContent = "Klik for at tale";
|
| 620 |
+
setStatus("Klar", `${formatBytes(message.bytes)} verificeret`, "ready");
|
| 621 |
setProgress(1, true);
|
| 622 |
ensurePunctuation();
|
| 623 |
} else if (message.type === "live-result") {
|
|
|
|
| 665 |
pncWorker.onmessage = (event) => {
|
| 666 |
const message = event.data || {};
|
| 667 |
if (message.type === "pnc-progress") {
|
| 668 |
+
elements.pncStatus.textContent = `${message.cached ? "Indlæser gemt model" : "Indlæser lokalt"} · ${Math.round(message.overall * 100)}%`;
|
| 669 |
} else if (message.type === "pnc-ready") {
|
| 670 |
state.pncReady = true;
|
| 671 |
elements.pncStatus.textContent = `Klar · ${formatBytes(message.bytes)}`;
|
index.html
CHANGED
|
@@ -195,6 +195,6 @@
|
|
| 195 |
</form>
|
| 196 |
</dialog>
|
| 197 |
|
| 198 |
-
<script src="app.js?v=20260927-
|
| 199 |
</body>
|
| 200 |
</html>
|
|
|
|
| 195 |
</form>
|
| 196 |
</dialog>
|
| 197 |
|
| 198 |
+
<script src="app.js?v=20260927-2" type="module"></script>
|
| 199 |
</body>
|
| 200 |
</html>
|
pnc-worker.js
CHANGED
|
@@ -32,39 +32,51 @@ async function sha256(bytes) {
|
|
| 32 |
}
|
| 33 |
|
| 34 |
async function fetchVerified(file, completedBytes, totalBytes) {
|
| 35 |
-
const
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
const
|
| 44 |
-
if (
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
-
return bytes;
|
| 68 |
}
|
| 69 |
|
| 70 |
async function initialize(configUrl) {
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
async function fetchVerified(file, completedBytes, totalBytes) {
|
| 35 |
+
const url = signedAsset(file.url);
|
| 36 |
+
let cache = null;
|
| 37 |
+
try { cache = await self.caches?.open("ekko-browser-models-v1"); } catch { /* Storage is optional. */ }
|
| 38 |
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
| 39 |
+
let cached = null;
|
| 40 |
+
if (!attempt && cache) {
|
| 41 |
+
try { cached = await cache.match(url); } catch { /* Use the network. */ }
|
| 42 |
+
}
|
| 43 |
+
const response = cached || await fetch(url, {cache: attempt ? "reload" : "force-cache"});
|
| 44 |
+
if (!response.ok || !response.body) {
|
| 45 |
+
throw new Error(`Kunne ikke hente ${file.path} (${response.status})`);
|
| 46 |
+
}
|
| 47 |
+
const reader = response.body.getReader();
|
| 48 |
+
const bytes = new Uint8Array(file.bytes);
|
| 49 |
+
let loaded = 0;
|
| 50 |
+
while (true) {
|
| 51 |
+
const {done, value} = await reader.read();
|
| 52 |
+
if (done) break;
|
| 53 |
+
if (loaded + value.byteLength > bytes.length) {
|
| 54 |
+
await reader.cancel();
|
| 55 |
+
loaded = bytes.length + 1;
|
| 56 |
+
break;
|
| 57 |
+
}
|
| 58 |
+
bytes.set(value, loaded);
|
| 59 |
+
loaded += value.byteLength;
|
| 60 |
+
self.postMessage({
|
| 61 |
+
type: "pnc-progress",
|
| 62 |
+
cached: Boolean(cached),
|
| 63 |
+
file: file.path,
|
| 64 |
+
loaded,
|
| 65 |
+
fileBytes: file.bytes,
|
| 66 |
+
overall: Math.min(1, (completedBytes + loaded) / totalBytes),
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
if (loaded === file.bytes && await sha256(bytes) === file.sha256) {
|
| 70 |
+
if (!cached && cache) {
|
| 71 |
+
try { await cache.put(url, new Response(bytes)); } catch { /* Keep using the verified bytes. */ }
|
| 72 |
+
}
|
| 73 |
+
return bytes;
|
| 74 |
+
}
|
| 75 |
+
if (cached) {
|
| 76 |
+
try { await cache.delete(url); } catch { /* Retry without this entry. */ }
|
| 77 |
+
}
|
| 78 |
+
if (attempt) throw new Error(`${file.path} fejlede størrelses- eller SHA-256-kontrol`);
|
| 79 |
}
|
|
|
|
| 80 |
}
|
| 81 |
|
| 82 |
async function initialize(configUrl) {
|
worker.js
CHANGED
|
@@ -143,40 +143,51 @@ async function sha256(bytes) {
|
|
| 143 |
}
|
| 144 |
|
| 145 |
async function fetchVerified(file, completedBytes, totalBytes) {
|
| 146 |
-
const
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
const
|
| 155 |
-
if (
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}
|
| 179 |
-
return bytes;
|
| 180 |
}
|
| 181 |
|
| 182 |
function createRecognizer(runtimeConfig) {
|
|
|
|
| 143 |
}
|
| 144 |
|
| 145 |
async function fetchVerified(file, completedBytes, totalBytes) {
|
| 146 |
+
const url = signedAsset(file.url);
|
| 147 |
+
let cache = null;
|
| 148 |
+
try { cache = await self.caches?.open("ekko-browser-models-v1"); } catch { /* Storage is optional. */ }
|
| 149 |
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
| 150 |
+
let cached = null;
|
| 151 |
+
if (!attempt && cache) {
|
| 152 |
+
try { cached = await cache.match(url); } catch { /* Use the network. */ }
|
| 153 |
+
}
|
| 154 |
+
const response = cached || await fetch(url, {cache: attempt ? "reload" : "force-cache"});
|
| 155 |
+
if (!response.ok || !response.body) {
|
| 156 |
+
throw new Error(`Kunne ikke hente ${file.path} (${response.status})`);
|
| 157 |
+
}
|
| 158 |
+
const reader = response.body.getReader();
|
| 159 |
+
const bytes = new Uint8Array(file.bytes);
|
| 160 |
+
let loaded = 0;
|
| 161 |
+
while (true) {
|
| 162 |
+
const {done, value} = await reader.read();
|
| 163 |
+
if (done) break;
|
| 164 |
+
if (loaded + value.byteLength > bytes.length) {
|
| 165 |
+
await reader.cancel();
|
| 166 |
+
loaded = bytes.length + 1;
|
| 167 |
+
break;
|
| 168 |
+
}
|
| 169 |
+
bytes.set(value, loaded);
|
| 170 |
+
loaded += value.byteLength;
|
| 171 |
+
self.postMessage({
|
| 172 |
+
type: "model-progress",
|
| 173 |
+
cached: Boolean(cached),
|
| 174 |
+
file: file.path,
|
| 175 |
+
loaded,
|
| 176 |
+
fileBytes: file.bytes,
|
| 177 |
+
overall: Math.min(1, (completedBytes + loaded) / totalBytes),
|
| 178 |
+
});
|
| 179 |
+
}
|
| 180 |
+
if (loaded === file.bytes && await sha256(bytes) === file.sha256) {
|
| 181 |
+
if (!cached && cache) {
|
| 182 |
+
try { await cache.put(url, new Response(bytes)); } catch { /* Keep using the verified bytes. */ }
|
| 183 |
+
}
|
| 184 |
+
return bytes;
|
| 185 |
+
}
|
| 186 |
+
if (cached) {
|
| 187 |
+
try { await cache.delete(url); } catch { /* Retry without this entry. */ }
|
| 188 |
+
}
|
| 189 |
+
if (attempt) throw new Error(`${file.path} fejlede størrelses- eller SHA-256-kontrol`);
|
| 190 |
}
|
|
|
|
| 191 |
}
|
| 192 |
|
| 193 |
function createRecognizer(runtimeConfig) {
|