From 04bac2ba30b5856f3689ef642b41480cda0e0489 Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Fri, 16 Oct 2026 08:44:38 +0200 Subject: [PATCH] Track ONNX WebAssembly asset downloads --- index.html | 22 +++++++++++++++++++--- piper-download-progress.mjs | 6 ++++-- piper-worker.js | 24 +++++++++++++++++------- tests/piper-download-progress.test.cjs | 19 ++++++++++++------- tests/voice.test.cjs | 2 ++ voice.js | 25 ++++++++++++++++++++++++- 6 files changed, 78 insertions(+), 20 deletions(-) diff --git a/index.html b/index.html index 258da01..4d9682e 100644 --- a/index.html +++ b/index.html @@ -172,7 +172,6 @@ const preparing = message.startsWith('Förbereder svensk uppläsning.'); const phase = [ 'Laddar WebAssembly-motorn för svensk uppläsning.', - 'Laddar WebAssembly-filer för svensk uppläsning.', 'Hämtar information om den svenska röstmodellen.', 'Ansluter till servern för att hämta den svenska röstmodellen.', 'Läser in den svenska röstmodellen.', @@ -180,14 +179,31 @@ ].find(text => message.startsWith(text)); const match = message.match(/^Hämtar svensk röstmodell till enheten: (\d+) procent\.$/); const bytesMatch = message.match(/^Hämtar svensk röstmodell till enheten: ([\d.,]+) MB hämtade\.$/); - if (!preparing && !phase && !match && !bytesMatch) { + const runtimeFile = message.match(/^(?:Hämtar|Läser in) WebAssembly-filen ([\w.-]+)\.?$/); + const runtimePercent = message.match(/^Hämtar WebAssembly-filen ([\w.-]+): (\d+) procent\.$/); + const runtimeBytes = message.match(/^Hämtar WebAssembly-filen ([\w.-]+): ([\d.,]+) MB hämtade\.$/); + if (!preparing && !phase && !match && !bytesMatch && !runtimeFile && !runtimePercent && !runtimeBytes) { container.hidden = true; progress.removeAttribute('value'); value.textContent = 'Väntar på nedladdning'; return false; } container.hidden = false; - label.textContent = phase || 'Hämtar svensk röstmodell till enheten'; + label.textContent = runtimePercent || runtimeBytes + ? `Hämtar WebAssembly-filen ${runtimePercent?.[1] || runtimeBytes?.[1]}` + : runtimeFile + ? runtimeFile[0].replace(/\.$/, '') + : phase || 'Hämtar svensk röstmodell till enheten'; + if (runtimePercent) { + progress.value = Math.max(0, Math.min(100, Number(runtimePercent[2]))); + value.textContent = `${runtimePercent[2]}%`; + return true; + } + if (runtimeBytes) { + progress.removeAttribute('value'); + value.textContent = `${runtimeBytes[2]} MB hämtade`; + return true; + } if (match) { const percent = Math.max(0, Math.min(100, Number(match[1]))); progress.value = percent; diff --git a/piper-download-progress.mjs b/piper-download-progress.mjs index bcb7361..37df5de 100644 --- a/piper-download-progress.mjs +++ b/piper-download-progress.mjs @@ -1,4 +1,4 @@ -export function trackModelDownload(response, {id, postMessage}) { +export function trackDownloadProgress(response, {id, postMessage, asset = 'model', file}) { const reader = response.body?.getReader?.(); if (!reader || typeof ReadableStream !== 'function' || typeof Response !== 'function') return response; @@ -13,7 +13,9 @@ export function trackModelDownload(response, {id, postMessage}) { return; } loaded += value.byteLength; - postMessage({id, type: 'progress', progress: {loaded, total}}); + const progress = {asset, loaded, total}; + if (file) progress.file = file; + postMessage({id, type: 'progress', progress}); controller.enqueue(value); } catch (error) { controller.error(error); diff --git a/piper-worker.js b/piper-worker.js index b00eeac..50b41d5 100644 --- a/piper-worker.js +++ b/piper-worker.js @@ -1,5 +1,5 @@ import {TtsSession} from '@mintplex-labs/piper-tts-web'; -import {trackModelDownload} from './piper-download-progress.mjs'; +import {trackDownloadProgress} from './piper-download-progress.mjs'; import {localPiperModelUrl} from './piper-model-routing.mjs'; import {createOnnxWasmPaths} from './piper-onnx-config.mjs'; @@ -7,8 +7,8 @@ const VOICE_ID = 'sv_SE-nst-medium'; const ASSET_ROOT = '/static/piper-wasm-v1'; const localFetch = self.fetch.bind(self); -function reportStage(id, stage) { - if (Number.isSafeInteger(id)) self.postMessage({id, type: 'progress', progress: {stage}}); +function reportStage(id, stage, details = {}) { + if (Number.isSafeInteger(id)) self.postMessage({id, type: 'progress', progress: {stage, ...details}}); } // Keep the TTS library's OPFS cache logic, but serve models/config from the @@ -17,10 +17,20 @@ self.fetch = async (resource, options) => { const requestedUrl = resource instanceof Request ? resource.url : String(resource); const localModelUrl = localPiperModelUrl(requestedUrl, self.location.origin); if (!localModelUrl) { - if (activeRequestId && requestedUrl.includes('/static/piper-wasm-v1/onnx/')) { - reportStage(activeRequestId, 'loading-runtime-assets'); + const runtimeAsset = requestedUrl.includes('/static/piper-wasm-v1/onnx/'); + const runtimeFile = runtimeAsset ? new URL(requestedUrl, self.location.origin).pathname.split('/').pop() : undefined; + if (activeRequestId && runtimeAsset) { + reportStage(activeRequestId, 'loading-runtime-assets', {file: runtimeFile}); } - return localFetch(resource, options); + const response = await localFetch(resource, options); + if (!activeRequestId || !runtimeAsset) return response; + reportStage(activeRequestId, 'reading-runtime-assets', {file: runtimeFile}); + return trackDownloadProgress(response, { + id: activeRequestId, + asset: 'runtime', + file: runtimeFile, + postMessage: message => self.postMessage(message), + }); } const isModel = localModelUrl.endsWith('.onnx'); if (activeRequestId) reportStage(activeRequestId, isModel ? 'loading-model' : 'loading-model-info'); @@ -28,7 +38,7 @@ self.fetch = async (resource, options) => { const response = await localFetch(redirected, options); if (!activeRequestId || !isModel) return response; reportStage(activeRequestId, 'reading-model'); - return trackModelDownload(response, { + return trackDownloadProgress(response, { id: activeRequestId, postMessage: message => self.postMessage(message), }); diff --git a/tests/piper-download-progress.test.cjs b/tests/piper-download-progress.test.cjs index 87c40f6..1be2644 100644 --- a/tests/piper-download-progress.test.cjs +++ b/tests/piper-download-progress.test.cjs @@ -2,7 +2,7 @@ const assert = require('node:assert/strict'); const test = require('node:test'); test('model response progress forwards each downloaded chunk and preserves the response', async () => { - const {trackModelDownload} = await import('../piper-download-progress.mjs'); + const {trackDownloadProgress} = await import('../piper-download-progress.mjs'); const source = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([1, 2])); @@ -12,22 +12,27 @@ test('model response progress forwards each downloaded chunk and preserves the r }); const response = new Response(source, {headers: {'content-length': '5'}}); const messages = []; - const tracked = trackModelDownload(response, {id: 7, postMessage: message => messages.push(message)}); + const tracked = trackDownloadProgress(response, {id: 7, postMessage: message => messages.push(message)}); assert.deepEqual([...new Uint8Array(await tracked.arrayBuffer())], [1, 2, 3, 4, 5]); assert.deepEqual(messages.map(message => message.progress), [ - {loaded: 2, total: 5}, - {loaded: 5, total: 5}, + {asset: 'model', loaded: 2, total: 5}, + {asset: 'model', loaded: 5, total: 5}, ]); assert.ok(messages.every(message => message.id === 7 && message.type === 'progress')); }); test('model response progress works when content length is absent', async () => { - const {trackModelDownload} = await import('../piper-download-progress.mjs'); + const {trackDownloadProgress} = await import('../piper-download-progress.mjs'); const source = new Response(new Uint8Array([1, 2, 3])); const messages = []; - const tracked = trackModelDownload(source, {id: 9, postMessage: message => messages.push(message)}); + const tracked = trackDownloadProgress(source, { + id: 9, + asset: 'runtime', + file: 'runtime.wasm', + postMessage: message => messages.push(message), + }); assert.equal((await tracked.arrayBuffer()).byteLength, 3); - assert.deepEqual(messages.map(message => message.progress), [{loaded: 3, total: 0}]); + assert.deepEqual(messages.map(message => message.progress), [{asset: 'runtime', file: 'runtime.wasm', loaded: 3, total: 0}]); }); diff --git a/tests/voice.test.cjs b/tests/voice.test.cjs index 199c2a8..d0b4e10 100644 --- a/tests/voice.test.cjs +++ b/tests/voice.test.cjs @@ -261,6 +261,8 @@ test('Piper model download progress is reported as a bounded percentage', async await new Promise(resolve => setImmediate(resolve)); h.piperProgressCallbacks[0]({stage: 'loading-model'}); assert.match(h.states.at(-1).message, /Ansluter till servern/); + h.piperProgressCallbacks[0]({asset: 'runtime', file: 'ort-wasm-simd-threaded.wasm', loaded: 5 * 1024 * 1024, total: 10 * 1024 * 1024}); + assert.match(h.states.at(-1).message, /ort-wasm-simd-threaded\.wasm: 50 procent/); h.piperProgressCallbacks[0]({loaded: 31_500_000, total: 63_000_000}); assert.equal(h.states.at(-1).state, 'PROCESSING'); assert.match(h.states.at(-1).message, /50 procent/); diff --git a/voice.js b/voice.js index 71cae5d..6019add 100644 --- a/voice.js +++ b/voice.js @@ -252,11 +252,34 @@ let lastProgressPercent = -1; let lastProgressMegabytes = -1; + let lastProgressResource = ''; const progress = event => { if (token !== generation) return; + const resource = `${event?.asset || 'model'}:${event?.file || ''}`; + if (resource !== lastProgressResource) { + lastProgressResource = resource; + lastProgressPercent = -1; + lastProgressMegabytes = -1; + } + if (event?.asset === 'runtime' && Number.isFinite(event.loaded) && event.loaded >= 0) { + const file = /^[\w.-]+$/.test(event.file || '') ? event.file : 'WebAssembly-fil'; + if (Number.isFinite(event.total) && event.total > 0) { + const percent = Math.max(0, Math.min(100, Math.floor((event.loaded / event.total) * 100))); + if (percent === lastProgressPercent) return; + lastProgressPercent = percent; + setState('PROCESSING', `Hämtar WebAssembly-filen ${file}: ${percent} procent.`); + } else { + const megabytes = Math.floor((event.loaded / (1024 * 1024)) * 10) / 10; + if (megabytes === lastProgressMegabytes) return; + lastProgressMegabytes = megabytes; + setState('PROCESSING', `Hämtar WebAssembly-filen ${file}: ${megabytes.toLocaleString('sv-SE')} MB hämtade.`); + } + return; + } const stages = { 'loading-runtime': 'Laddar WebAssembly-motorn för svensk uppläsning.', - 'loading-runtime-assets': 'Laddar WebAssembly-filer för svensk uppläsning.', + 'loading-runtime-assets': `Hämtar WebAssembly-filen ${event?.file || 'från servern'}.`, + 'reading-runtime-assets': `Läser in WebAssembly-filen ${event?.file || ''}.`, 'loading-model-info': 'Hämtar information om den svenska röstmodellen.', 'loading-model': 'Ansluter till servern för att hämta den svenska röstmodellen.', 'reading-model': 'Läser in den svenska röstmodellen.',