Compare commits

...
4 Commits
Author SHA1 Message Date
Gitea Actions 8e7c74bb41 Release 0.1.56 [auto-version-bump] 2026-10-16 08:08:12 +02:00
Ein Anderssono 89c238bcea Show Piper startup and download stages
Build and publish frontend / publish (push) Successful in 50s
2026-10-16 08:07:44 +02:00
Gitea Actions 81990d7129 Release 0.1.55 [auto-version-bump] 2026-10-16 08:02:41 +02:00
Ein Anderssono 7f77b904ac Track Piper model bytes in the worker
Build and publish frontend / publish (push) Successful in 47s
2026-10-16 08:02:17 +02:00
9 changed files with 129 additions and 12 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.54 0.1.56
+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.54 version: 0.1.56
appVersion: "0.1.54" appVersion: "0.1.56"
+12 -2
View File
@@ -168,16 +168,26 @@
const container = document.querySelector('#speech-progress-container'); const container = document.querySelector('#speech-progress-container');
const progress = document.querySelector('#speech-progress'); const progress = document.querySelector('#speech-progress');
const value = document.querySelector('#speech-progress-value'); const value = document.querySelector('#speech-progress-value');
const label = container.querySelector('label');
const preparing = message.startsWith('Förbereder svensk uppläsning.'); 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.',
'Röstmodellen är inläst. Skapar uppläsningen.',
].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 && !match && !bytesMatch) { if (!preparing && !phase && !match && !bytesMatch) {
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';
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;
@@ -190,7 +200,7 @@
return true; return true;
} }
progress.removeAttribute('value'); progress.removeAttribute('value');
value.textContent = 'Förbereder…'; value.textContent = phase ? 'Pågår…' : 'Förbereder…';
return true; return true;
} }
function clearSpeechRecovery() { function clearSpeechRecovery() {
+32
View File
@@ -0,0 +1,32 @@
export function trackModelDownload(response, {id, postMessage}) {
const reader = response.body?.getReader?.();
if (!reader || typeof ReadableStream !== 'function' || typeof Response !== 'function') return response;
const total = Number(response.headers.get('content-length')) || 0;
let loaded = 0;
const body = new ReadableStream({
async pull(controller) {
try {
const {done, value} = await reader.read();
if (done) {
controller.close();
return;
}
loaded += value.byteLength;
postMessage({id, type: 'progress', progress: {loaded, total}});
controller.enqueue(value);
} catch (error) {
controller.error(error);
}
},
cancel(reason) {
return reader.cancel(reason);
},
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
+29 -6
View File
@@ -1,4 +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 {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';
@@ -6,24 +7,41 @@ 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) {
if (Number.isSafeInteger(id)) self.postMessage({id, type: 'progress', progress: {stage}});
}
// 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
// same-origin static asset service instead of its built-in external model URL. // same-origin static asset service instead of its built-in external model URL.
self.fetch = (resource, options) => { 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) return localFetch(resource, options); if (!localModelUrl) {
if (activeRequestId && requestedUrl.includes('/static/piper-wasm-v1/onnx/')) {
reportStage(activeRequestId, 'loading-runtime-assets');
}
return localFetch(resource, options);
}
const isModel = localModelUrl.endsWith('.onnx');
if (activeRequestId) reportStage(activeRequestId, isModel ? 'loading-model' : 'loading-model-info');
const redirected = resource instanceof Request ? new Request(localModelUrl, resource) : localModelUrl; const redirected = resource instanceof Request ? new Request(localModelUrl, resource) : localModelUrl;
return localFetch(redirected, options); const response = await localFetch(redirected, options);
if (!activeRequestId || !isModel) return response;
reportStage(activeRequestId, 'reading-model');
return trackModelDownload(response, {
id: activeRequestId,
postMessage: message => self.postMessage(message),
});
}; };
let sessionPromise; let sessionPromise;
let inferenceQueue = Promise.resolve(); let inferenceQueue = Promise.resolve();
let activeRequestId = null;
async function getSession(onProgress) { async function getSession() {
if (!sessionPromise) { if (!sessionPromise) {
sessionPromise = TtsSession.create({ sessionPromise = TtsSession.create({
voiceId: VOICE_ID, voiceId: VOICE_ID,
progress: onProgress,
wasmPaths: { wasmPaths: {
onnxWasm: createOnnxWasmPaths(self.location.origin), onnxWasm: createOnnxWasmPaths(self.location.origin),
piperData: `${ASSET_ROOT}/piper/piper_phonemize.data`, piperData: `${ASSET_ROOT}/piper/piper_phonemize.data`,
@@ -40,13 +58,18 @@ async function getSession(onProgress) {
async function synthesizeMessage(event) { async function synthesizeMessage(event) {
const {id, text} = event.data || {}; const {id, text} = event.data || {};
if (!Number.isSafeInteger(id) || typeof text !== 'string') return; if (!Number.isSafeInteger(id) || typeof text !== 'string') return;
activeRequestId = id;
reportStage(id, 'loading-runtime');
try { try {
const session = await getSession(progress => self.postMessage({id, type: 'progress', progress})); const session = await getSession();
reportStage(id, 'model-ready');
const blob = await session.predict(text); const blob = await session.predict(text);
const audio = await blob.arrayBuffer(); const audio = await blob.arrayBuffer();
self.postMessage({id, type: 'audio', audio}, [audio]); self.postMessage({id, type: 'audio', audio}, [audio]);
} catch (error) { } catch (error) {
self.postMessage({id, type: 'error', error: String(error?.message || error)}); self.postMessage({id, type: 'error', error: String(error?.message || error)});
} finally {
activeRequestId = null;
} }
} }
+4
View File
@@ -32,6 +32,8 @@ grep -q 'id="voice-status" role="status" aria-live="polite"' index.html
grep -q 'id="speech-progress" aria-label="Nedladdning av svensk röstmodell"' index.html grep -q 'id="speech-progress" aria-label="Nedladdning av svensk röstmodell"' index.html
grep -q 'id="speech-progress-value"' index.html grep -q 'id="speech-progress-value"' index.html
grep -q 'MB hämtade' index.html grep -q 'MB hämtade' index.html
grep -q 'loading-runtime-assets' piper-worker.js
grep -q 'Röstmodellen är inläst' voice.js
grep -q "window.GameVoice.createVoiceController" index.html grep -q "window.GameVoice.createVoiceController" index.html
grep -q "addVoiceButton(item, 'Testa ljudet'" index.html grep -q "addVoiceButton(item, 'Testa ljudet'" index.html
grep -q 'Jag förstår – starta mikrofonen' index.html grep -q 'Jag förstår – starta mikrofonen' index.html
@@ -52,6 +54,8 @@ 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 '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
grep -q 'PIPER_MODEL_ORIGIN' piper-model-routing.mjs grep -q 'PIPER_MODEL_ORIGIN' piper-model-routing.mjs
+33
View File
@@ -0,0 +1,33 @@
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 source = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2]));
controller.enqueue(new Uint8Array([3, 4, 5]));
controller.close();
},
});
const response = new Response(source, {headers: {'content-length': '5'}});
const messages = [];
const tracked = trackModelDownload(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},
]);
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 source = new Response(new Uint8Array([1, 2, 3]));
const messages = [];
const tracked = trackModelDownload(source, {id: 9, postMessage: message => messages.push(message)});
assert.equal((await tracked.arrayBuffer()).byteLength, 3);
assert.deepEqual(messages.map(message => message.progress), [{loaded: 3, total: 0}]);
});
+2
View File
@@ -259,6 +259,8 @@ test('Piper model download progress is reported as a bounded percentage', async
h.controller.activateFromUserGesture(); h.controller.activateFromUserGesture();
h.controller.speak('Ett svar från Piper.'); h.controller.speak('Ett svar från Piper.');
await new Promise(resolve => setImmediate(resolve)); 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]({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/);
+14 -1
View File
@@ -253,7 +253,20 @@
let lastProgressPercent = -1; let lastProgressPercent = -1;
let lastProgressMegabytes = -1; let lastProgressMegabytes = -1;
const progress = event => { const progress = event => {
if (token !== generation || !Number.isFinite(event?.loaded) || event.loaded < 0) return; if (token !== generation) 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-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.',
'model-ready': 'Röstmodellen är inläst. Skapar uppläsningen.',
};
if (stages[event?.stage]) {
setState('PROCESSING', stages[event.stage]);
return;
}
if (!Number.isFinite(event?.loaded) || event.loaded < 0) return;
if (Number.isFinite(event.total) && event.total > 0) { if (Number.isFinite(event.total) && event.total > 0) {
const percent = Math.max(0, Math.min(100, Math.floor((event.loaded / event.total) * 100))); const percent = Math.max(0, Math.min(100, Math.floor((event.loaded / event.total) * 100)));
if (percent === lastProgressPercent) return; if (percent === lastProgressPercent) return;