Compare commits

..
3 Commits
Author SHA1 Message Date
Gitea Actions aa832fd7cc Release 0.1.57 [auto-version-bump] 2026-10-16 08:46:23 +02:00
Ein Anderssono d788f8b350 Fix frontend check for download progress helper
Build and publish frontend / publish (push) Successful in 48s
2026-10-16 08:45:56 +02:00
Ein Anderssono 04bac2ba30 Track ONNX WebAssembly asset downloads
Build and publish frontend / publish (push) Failing after 3s
2026-10-16 08:44:38 +02:00
9 changed files with 82 additions and 24 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.56 0.1.57
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: ai-spel-frontend name: ai-spel-frontend
description: Static web frontend for the Swedish life simulator description: Static web frontend for the Swedish life simulator
type: application type: application
version: 0.1.56 version: 0.1.57
appVersion: "0.1.56" appVersion: "0.1.57"
+19 -3
View File
@@ -172,7 +172,6 @@
const preparing = message.startsWith('Förbereder svensk uppläsning.'); const preparing = message.startsWith('Förbereder svensk uppläsning.');
const phase = [ const phase = [
'Laddar WebAssembly-motorn för svensk uppläsning.', 'Laddar WebAssembly-motorn för svensk uppläsning.',
'Laddar WebAssembly-filer för svensk uppläsning.',
'Hämtar information om den svenska röstmodellen.', 'Hämtar information om den svenska röstmodellen.',
'Ansluter till servern för att hämta den svenska röstmodellen.', 'Ansluter till servern för att hämta den svenska röstmodellen.',
'Läser in den svenska röstmodellen.', 'Läser in den svenska röstmodellen.',
@@ -180,14 +179,31 @@
].find(text => message.startsWith(text)); ].find(text => message.startsWith(text));
const match = message.match(/^Hämtar svensk röstmodell till enheten: (\d+) procent\.$/); 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\.$/); 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; container.hidden = true;
progress.removeAttribute('value'); progress.removeAttribute('value');
value.textContent = 'Väntar på nedladdning'; value.textContent = 'Väntar på nedladdning';
return false; return false;
} }
container.hidden = 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) { if (match) {
const percent = Math.max(0, Math.min(100, Number(match[1]))); const percent = Math.max(0, Math.min(100, Number(match[1])));
progress.value = percent; progress.value = percent;
+4 -2
View File
@@ -1,4 +1,4 @@
export function trackModelDownload(response, {id, postMessage}) { export function trackDownloadProgress(response, {id, postMessage, asset = 'model', file}) {
const reader = response.body?.getReader?.(); const reader = response.body?.getReader?.();
if (!reader || typeof ReadableStream !== 'function' || typeof Response !== 'function') return response; if (!reader || typeof ReadableStream !== 'function' || typeof Response !== 'function') return response;
@@ -13,7 +13,9 @@ export function trackModelDownload(response, {id, postMessage}) {
return; return;
} }
loaded += value.byteLength; 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); controller.enqueue(value);
} catch (error) { } catch (error) {
controller.error(error); controller.error(error);
+17 -7
View File
@@ -1,5 +1,5 @@
import {TtsSession} from '@mintplex-labs/piper-tts-web'; 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 {localPiperModelUrl} from './piper-model-routing.mjs';
import {createOnnxWasmPaths} from './piper-onnx-config.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 ASSET_ROOT = '/static/piper-wasm-v1';
const localFetch = self.fetch.bind(self); const localFetch = self.fetch.bind(self);
function reportStage(id, stage) { function reportStage(id, stage, details = {}) {
if (Number.isSafeInteger(id)) self.postMessage({id, type: 'progress', progress: {stage}}); 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 // 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 requestedUrl = resource instanceof Request ? resource.url : String(resource);
const localModelUrl = localPiperModelUrl(requestedUrl, self.location.origin); const localModelUrl = localPiperModelUrl(requestedUrl, self.location.origin);
if (!localModelUrl) { if (!localModelUrl) {
if (activeRequestId && requestedUrl.includes('/static/piper-wasm-v1/onnx/')) { const runtimeAsset = requestedUrl.includes('/static/piper-wasm-v1/onnx/');
reportStage(activeRequestId, 'loading-runtime-assets'); 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'); const isModel = localModelUrl.endsWith('.onnx');
if (activeRequestId) reportStage(activeRequestId, isModel ? 'loading-model' : 'loading-model-info'); 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); const response = await localFetch(redirected, options);
if (!activeRequestId || !isModel) return response; if (!activeRequestId || !isModel) return response;
reportStage(activeRequestId, 'reading-model'); reportStage(activeRequestId, 'reading-model');
return trackModelDownload(response, { return trackDownloadProgress(response, {
id: activeRequestId, id: activeRequestId,
postMessage: message => self.postMessage(message), postMessage: message => self.postMessage(message),
}); });
+1 -1
View File
@@ -54,7 +54,7 @@ grep -q "new Worker(workerUrl, {type: 'module'})" piper-runtime.js
grep -q 'worker.js?v=' piper-runtime.js grep -q 'worker.js?v=' piper-runtime.js
grep -q 'worker.onmessageerror' piper-runtime.js grep -q 'worker.onmessageerror' piper-runtime.js
grep -q 'localPiperModelUrl' piper-worker.js grep -q 'localPiperModelUrl' piper-worker.js
grep -q 'trackModelDownload' piper-worker.js grep -q 'trackDownloadProgress' piper-worker.js
grep -q 'new ReadableStream' piper-download-progress.mjs grep -q 'new ReadableStream' piper-download-progress.mjs
grep -q 'createOnnxWasmPaths(self.location.origin)' piper-worker.js grep -q 'createOnnxWasmPaths(self.location.origin)' piper-worker.js
grep -q '/static/piper-wasm-v1/onnx/' piper-onnx-config.mjs grep -q '/static/piper-wasm-v1/onnx/' piper-onnx-config.mjs
+12 -7
View File
@@ -2,7 +2,7 @@ const assert = require('node:assert/strict');
const test = require('node:test'); const test = require('node:test');
test('model response progress forwards each downloaded chunk and preserves the response', async () => { 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({ const source = new ReadableStream({
start(controller) { start(controller) {
controller.enqueue(new Uint8Array([1, 2])); 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 response = new Response(source, {headers: {'content-length': '5'}});
const messages = []; 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([...new Uint8Array(await tracked.arrayBuffer())], [1, 2, 3, 4, 5]);
assert.deepEqual(messages.map(message => message.progress), [ assert.deepEqual(messages.map(message => message.progress), [
{loaded: 2, total: 5}, {asset: 'model', loaded: 2, total: 5},
{loaded: 5, total: 5}, {asset: 'model', loaded: 5, total: 5},
]); ]);
assert.ok(messages.every(message => message.id === 7 && message.type === 'progress')); assert.ok(messages.every(message => message.id === 7 && message.type === 'progress'));
}); });
test('model response progress works when content length is absent', async () => { 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 source = new Response(new Uint8Array([1, 2, 3]));
const messages = []; 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.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}]);
}); });
+2
View File
@@ -261,6 +261,8 @@ test('Piper model download progress is reported as a bounded percentage', async
await new Promise(resolve => setImmediate(resolve)); await new Promise(resolve => setImmediate(resolve));
h.piperProgressCallbacks[0]({stage: 'loading-model'}); h.piperProgressCallbacks[0]({stage: 'loading-model'});
assert.match(h.states.at(-1).message, /Ansluter till servern/); 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}); h.piperProgressCallbacks[0]({loaded: 31_500_000, total: 63_000_000});
assert.equal(h.states.at(-1).state, 'PROCESSING'); assert.equal(h.states.at(-1).state, 'PROCESSING');
assert.match(h.states.at(-1).message, /50 procent/); assert.match(h.states.at(-1).message, /50 procent/);
+24 -1
View File
@@ -252,11 +252,34 @@
let lastProgressPercent = -1; let lastProgressPercent = -1;
let lastProgressMegabytes = -1; let lastProgressMegabytes = -1;
let lastProgressResource = '';
const progress = event => { const progress = event => {
if (token !== generation) return; 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 = { const stages = {
'loading-runtime': 'Laddar WebAssembly-motorn för svensk uppläsning.', '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-info': 'Hämtar information om den svenska röstmodellen.',
'loading-model': 'Ansluter till servern för att hämta 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.', 'reading-model': 'Läser in den svenska röstmodellen.',