Compare commits

...
5 Commits
Author SHA1 Message Date
Gitea Actions b6deecae0a Release 0.1.58 [auto-version-bump] 2026-10-16 08:51:35 +02:00
Ein Anderssono a4f89f2b82 Use single-thread ONNX runtime for mobile speech
Build and publish frontend / publish (push) Successful in 50s
2026-10-16 08:51:08 +02:00
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
12 changed files with 141 additions and 25 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.56 0.1.58
+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.58
appVersion: "0.1.56" appVersion: "0.1.58"
@@ -0,0 +1,13 @@
# ADR 0002: Run ONNX with one thread inside the Piper worker
## Decision
Configure ONNX Runtime Web to use one thread and stop backend initialization after 60 seconds. Piper already runs in a dedicated worker, so ONNX does not need to create another worker pool.
## Context
On iPad, the threaded ONNX WASM file downloaded fully, but the runtime did not report that initialization had completed. Nested worker startup and shared-memory setup are a likely compatibility boundary on mobile Safari. The available production assets currently include the threaded ONNX build.
## Consequences
This removes ONNX's additional worker pool and bounds initialization failures with a visible error. It may reduce inference throughput on desktop devices. CI and Kubernetes release verification must confirm that Piper still initializes and speaks on supported browsers; iPad acceptance remains a manual device check.
+20 -3
View File
@@ -172,7 +172,7 @@
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.', 'Startar WebAssembly-motorn efter nedladdning av',
'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 +180,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;
+7 -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;
@@ -9,11 +9,16 @@ export function trackModelDownload(response, {id, postMessage}) {
try { try {
const {done, value} = await reader.read(); const {done, value} = await reader.read();
if (done) { if (done) {
if (asset === 'runtime') {
postMessage({id, type: 'progress', progress: {stage: 'initializing-runtime', file}});
}
controller.close(); controller.close();
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);
+7
View File
@@ -5,3 +5,10 @@ export function createOnnxWasmPaths(origin) {
return absoluteAssetRoot; return absoluteAssetRoot;
} }
export function configureOnnxRuntime(onnxRuntime) {
if (!onnxRuntime?.env?.wasm) throw new TypeError('ONNX Runtime WebAssembly settings are unavailable.');
// Piper already runs in a worker. Avoid a second worker pool on mobile Safari.
onnxRuntime.env.wasm.numThreads = 1;
onnxRuntime.env.wasm.initTimeout = 60000;
}
+20 -8
View File
@@ -1,14 +1,15 @@
import {TtsSession} from '@mintplex-labs/piper-tts-web'; import {TtsSession} from '@mintplex-labs/piper-tts-web';
import {trackModelDownload} from './piper-download-progress.mjs'; import * as onnxRuntime from 'onnxruntime-web';
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 {configureOnnxRuntime, createOnnxWasmPaths} from './piper-onnx-config.mjs';
const VOICE_ID = 'sv_SE-nst-medium'; 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 +18,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 +39,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),
}); });
@@ -40,6 +51,7 @@ let activeRequestId = null;
async function getSession() { async function getSession() {
if (!sessionPromise) { if (!sessionPromise) {
configureOnnxRuntime(onnxRuntime);
sessionPromise = TtsSession.create({ sessionPromise = TtsSession.create({
voiceId: VOICE_ID, voiceId: VOICE_ID,
wasmPaths: { wasmPaths: {
+3 -1
View File
@@ -54,9 +54,11 @@ 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 'configureOnnxRuntime(onnxRuntime)' piper-worker.js
grep -q 'numThreads = 1' piper-onnx-config.mjs
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
grep -q '/static/piper-wasm-v1/voices/' piper-model-routing.mjs grep -q '/static/piper-wasm-v1/voices/' piper-model-routing.mjs
+33 -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,48 @@ 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)
.filter(progress => Number.isFinite(progress.loaded)),
[{asset: 'runtime', file: 'runtime.wasm', loaded: 3, total: 0}]);
});
test('runtime asset completion reports that initialization follows the download', async () => {
const {trackDownloadProgress} = await import('../piper-download-progress.mjs');
const response = new Response(new Uint8Array([1, 2]), {headers: {'content-length': '2'}});
const messages = [];
const tracked = trackDownloadProgress(response, {
id: 10,
asset: 'runtime',
file: 'ort-wasm-simd-threaded.wasm',
postMessage: message => messages.push(message),
});
await tracked.arrayBuffer();
assert.deepEqual(messages.at(-1).progress, {
stage: 'initializing-runtime',
file: 'ort-wasm-simd-threaded.wasm',
});
}); });
+8
View File
@@ -13,3 +13,11 @@ test('ONNX Runtime base URL rejects an invalid worker origin', async () => {
const {createOnnxWasmPaths} = await import('../piper-onnx-config.mjs'); const {createOnnxWasmPaths} = await import('../piper-onnx-config.mjs');
assert.throws(() => createOnnxWasmPaths('undefined'), TypeError); assert.throws(() => createOnnxWasmPaths('undefined'), TypeError);
}); });
test('ONNX Runtime uses one thread and a bounded startup timeout', async () => {
const {configureOnnxRuntime} = await import('../piper-onnx-config.mjs');
const runtime = {env: {wasm: {numThreads: 0, initTimeout: 0}}};
configureOnnxRuntime(runtime);
assert.equal(runtime.env.wasm.numThreads, 1);
assert.equal(runtime.env.wasm.initTimeout, 60000);
});
+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/);
+25 -1
View File
@@ -252,11 +252,35 @@
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 || ''}.`,
'initializing-runtime': `Startar WebAssembly-motorn efter nedladdning av ${event?.file || 'filen'}.`,
'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.',