From a4f89f2b82c93f96a6a2a6c0d87c1209e812d140 Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Fri, 16 Oct 2026 08:51:08 +0200 Subject: [PATCH] Use single-thread ONNX runtime for mobile speech --- .../0002-single-thread-onnx-for-mobile-tts.md | 13 +++++++++++ index.html | 1 + piper-download-progress.mjs | 3 +++ piper-onnx-config.mjs | 7 ++++++ piper-worker.js | 4 +++- scripts/check-frontend.sh | 2 ++ tests/piper-download-progress.test.cjs | 23 ++++++++++++++++++- tests/piper-onnx-config.test.cjs | 8 +++++++ voice.js | 1 + 9 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0002-single-thread-onnx-for-mobile-tts.md diff --git a/docs/adr/0002-single-thread-onnx-for-mobile-tts.md b/docs/adr/0002-single-thread-onnx-for-mobile-tts.md new file mode 100644 index 0000000..ecbfe19 --- /dev/null +++ b/docs/adr/0002-single-thread-onnx-for-mobile-tts.md @@ -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. diff --git a/index.html b/index.html index 4d9682e..3db79b4 100644 --- a/index.html +++ b/index.html @@ -172,6 +172,7 @@ const preparing = message.startsWith('Förbereder svensk uppläsning.'); const phase = [ 'Laddar WebAssembly-motorn för svensk uppläsning.', + 'Startar WebAssembly-motorn efter nedladdning av', '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.', diff --git a/piper-download-progress.mjs b/piper-download-progress.mjs index 37df5de..5d9b656 100644 --- a/piper-download-progress.mjs +++ b/piper-download-progress.mjs @@ -9,6 +9,9 @@ export function trackDownloadProgress(response, {id, postMessage, asset = 'model try { const {done, value} = await reader.read(); if (done) { + if (asset === 'runtime') { + postMessage({id, type: 'progress', progress: {stage: 'initializing-runtime', file}}); + } controller.close(); return; } diff --git a/piper-onnx-config.mjs b/piper-onnx-config.mjs index 2766457..7dec3a3 100644 --- a/piper-onnx-config.mjs +++ b/piper-onnx-config.mjs @@ -5,3 +5,10 @@ export function createOnnxWasmPaths(origin) { 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; +} + diff --git a/piper-worker.js b/piper-worker.js index 50b41d5..d5ee66b 100644 --- a/piper-worker.js +++ b/piper-worker.js @@ -1,7 +1,8 @@ import {TtsSession} from '@mintplex-labs/piper-tts-web'; +import * as onnxRuntime from 'onnxruntime-web'; import {trackDownloadProgress} from './piper-download-progress.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 ASSET_ROOT = '/static/piper-wasm-v1'; @@ -50,6 +51,7 @@ let activeRequestId = null; async function getSession() { if (!sessionPromise) { + configureOnnxRuntime(onnxRuntime); sessionPromise = TtsSession.create({ voiceId: VOICE_ID, wasmPaths: { diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh index 60f6920..9a95f22 100755 --- a/scripts/check-frontend.sh +++ b/scripts/check-frontend.sh @@ -57,6 +57,8 @@ grep -q 'localPiperModelUrl' piper-worker.js grep -q 'trackDownloadProgress' piper-worker.js grep -q 'new ReadableStream' piper-download-progress.mjs 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 'PIPER_MODEL_ORIGIN' piper-model-routing.mjs grep -q '/static/piper-wasm-v1/voices/' piper-model-routing.mjs diff --git a/tests/piper-download-progress.test.cjs b/tests/piper-download-progress.test.cjs index 1be2644..da73156 100644 --- a/tests/piper-download-progress.test.cjs +++ b/tests/piper-download-progress.test.cjs @@ -34,5 +34,26 @@ test('model response progress works when content length is absent', async () => }); 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', + }); }); diff --git a/tests/piper-onnx-config.test.cjs b/tests/piper-onnx-config.test.cjs index cff80aa..b57565a 100644 --- a/tests/piper-onnx-config.test.cjs +++ b/tests/piper-onnx-config.test.cjs @@ -13,3 +13,11 @@ test('ONNX Runtime base URL rejects an invalid worker origin', async () => { const {createOnnxWasmPaths} = await import('../piper-onnx-config.mjs'); 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); +}); diff --git a/voice.js b/voice.js index 6019add..1cbdbb5 100644 --- a/voice.js +++ b/voice.js @@ -280,6 +280,7 @@ 'loading-runtime': 'Laddar WebAssembly-motorn 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': 'Ansluter till servern för att hämta den svenska röstmodellen.', 'reading-model': 'Läser in den svenska röstmodellen.',