Use single-thread ONNX runtime for mobile speech
Build and publish frontend / publish (push) Successful in 50s

This commit is contained in:
Ein Anderssono
2026-10-16 08:51:08 +02:00
parent aa832fd7cc
commit a4f89f2b82
9 changed files with 60 additions and 2 deletions
@@ -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.
+1
View File
@@ -172,6 +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.',
'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.',
+3
View File
@@ -9,6 +9,9 @@ export function trackDownloadProgress(response, {id, postMessage, asset = 'model
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;
} }
+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;
}
+3 -1
View File
@@ -1,7 +1,8 @@
import {TtsSession} from '@mintplex-labs/piper-tts-web'; import {TtsSession} from '@mintplex-labs/piper-tts-web';
import * as onnxRuntime from 'onnxruntime-web';
import {trackDownloadProgress} 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 {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';
@@ -50,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: {
+2
View File
@@ -57,6 +57,8 @@ grep -q 'localPiperModelUrl' piper-worker.js
grep -q 'trackDownloadProgress' 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
+22 -1
View File
@@ -34,5 +34,26 @@ test('model response progress works when content length is absent', async () =>
}); });
assert.equal((await tracked.arrayBuffer()).byteLength, 3); assert.equal((await tracked.arrayBuffer()).byteLength, 3);
assert.deepEqual(messages.map(message => message.progress), [{asset: 'runtime', file: 'runtime.wasm', 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);
});
+1
View File
@@ -280,6 +280,7 @@
'loading-runtime': 'Laddar WebAssembly-motorn för svensk uppläsning.', 'loading-runtime': 'Laddar WebAssembly-motorn för svensk uppläsning.',
'loading-runtime-assets': `Hämtar WebAssembly-filen ${event?.file || 'från servern'}.`, 'loading-runtime-assets': `Hämtar WebAssembly-filen ${event?.file || 'från servern'}.`,
'reading-runtime-assets': `Läser in WebAssembly-filen ${event?.file || ''}.`, '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.',