From c9b14e07266f7779679014a20810577ddd9c56c9 Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Sat, 17 Oct 2026 16:38:16 +0200 Subject: [PATCH] Fix Piper memory pressure on iOS --- docs/adr/0003-limit-piper-wasm-to-one-thread.md | 13 +++++++++++++ piper-onnx-config.mjs | 17 +++++++++++++++++ piper-runtime.js | 15 +++++++++++++-- scripts/check-frontend.sh | 2 ++ tests/piper-onnx-config.test.cjs | 17 +++++++++++++---- tests/piper-worker-format.test.cjs | 6 ++++++ 6 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 docs/adr/0003-limit-piper-wasm-to-one-thread.md diff --git a/docs/adr/0003-limit-piper-wasm-to-one-thread.md b/docs/adr/0003-limit-piper-wasm-to-one-thread.md new file mode 100644 index 0000000..8f8e14a --- /dev/null +++ b/docs/adr/0003-limit-piper-wasm-to-one-thread.md @@ -0,0 +1,13 @@ +# ADR 0003: Limit Piper WASM to one thread and reset failed workers + +## Decision + +The Piper worker reports one available processor to Piper and configures ONNX Runtime for one thread. If a synthesis request fails, the browser client terminates that worker; the next player retry creates a fresh worker and ONNX module. + +## Context + +Piper TTS Web 1.0.3 sets `env.wasm.numThreads` from `navigator.hardwareConcurrency` during `TtsSession.init()`, after the application had configured ONNX Runtime. On iPad this could undo the single-thread setting and request a larger WASM worker pool. The library also retains a singleton session if ONNX initialization fails, so retrying in the same worker can reuse a failed initialization. + +## Consequences + +The one-thread limit reduces parallel inference but lowers memory pressure on mobile Safari. Failed initialization no longer poisons future attempts in the same worker. This cannot guarantee that every device has enough memory for the 63 MB medium voice model; the CI release and Kubernetes checks validate packaging and deployment, while actual iPad memory behavior still requires device verification. diff --git a/piper-onnx-config.mjs b/piper-onnx-config.mjs index 7dec3a3..8789272 100644 --- a/piper-onnx-config.mjs +++ b/piper-onnx-config.mjs @@ -7,6 +7,23 @@ export function createOnnxWasmPaths(origin) { export function configureOnnxRuntime(onnxRuntime) { if (!onnxRuntime?.env?.wasm) throw new TypeError('ONNX Runtime WebAssembly settings are unavailable.'); + // Piper 1.0.3 overwrites this value in TtsSession.init() with + // navigator.hardwareConcurrency. Override that source as well, otherwise an + // iPad may start a large WASM worker pool despite this setting. + const navigatorObject = globalThis.navigator; + if (navigatorObject && navigatorObject.hardwareConcurrency !== 1) { + try { + Object.defineProperty(navigatorObject, 'hardwareConcurrency', { + configurable: true, + value: 1, + }); + } catch (_) { + // The check below converts an unsupported override into a clear error. + } + } + if (navigatorObject && navigatorObject.hardwareConcurrency !== 1) { + throw new Error('Piper could not be restricted to one processor thread.'); + } // 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-runtime.js b/piper-runtime.js index dfd9cee..03dda32 100644 --- a/piper-runtime.js +++ b/piper-runtime.js @@ -17,8 +17,19 @@ function getWorker() { request.onProgress?.(message.progress); } else { pending.delete(message.id); - if (message.type === 'audio') request.resolve(new Blob([message.audio], {type: 'audio/x-wav'})); - else request.reject(new Error(message.error || 'Piper-arbetaren misslyckades.')); + if (message.type === 'audio') { + request.resolve(new Blob([message.audio], {type: 'audio/x-wav'})); + } else { + const error = new Error(message.error || 'Piper-arbetaren misslyckades.'); + request.reject(error); + // TtsSession keeps a singleton even when ONNX initialization fails. + // Discard the whole worker so a player retry starts with a clean ONNX + // module and does not reuse a poisoned WASM initialization. + for (const queued of pending.values()) queued.reject(error); + pending.clear(); + worker?.terminate(); + worker = null; + } } }; worker.onerror = event => { diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh index 1412295..3a55b6e 100755 --- a/scripts/check-frontend.sh +++ b/scripts/check-frontend.sh @@ -59,6 +59,8 @@ 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 "hardwareConcurrency'" piper-onnx-config.mjs +grep -q 'Piper could not be restricted to one processor thread' 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-onnx-config.test.cjs b/tests/piper-onnx-config.test.cjs index b57565a..04b834e 100644 --- a/tests/piper-onnx-config.test.cjs +++ b/tests/piper-onnx-config.test.cjs @@ -14,10 +14,19 @@ test('ONNX Runtime base URL rejects an invalid worker origin', async () => { assert.throws(() => createOnnxWasmPaths('undefined'), TypeError); }); -test('ONNX Runtime uses one thread and a bounded startup timeout', async () => { +test('Piper and ONNX Runtime stay on one thread even though Piper overwrites ONNX settings', async () => { const {configureOnnxRuntime} = await import('../piper-onnx-config.mjs'); + const navigatorObject = {hardwareConcurrency: 8}; + const previousNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', {configurable: true, value: navigatorObject}); const runtime = {env: {wasm: {numThreads: 0, initTimeout: 0}}}; - configureOnnxRuntime(runtime); - assert.equal(runtime.env.wasm.numThreads, 1); - assert.equal(runtime.env.wasm.initTimeout, 60000); + try { + configureOnnxRuntime(runtime); + assert.equal(navigatorObject.hardwareConcurrency, 1); + assert.equal(runtime.env.wasm.numThreads, 1); + assert.equal(runtime.env.wasm.initTimeout, 60000); + } finally { + if (previousNavigator === undefined) delete globalThis.navigator; + else Object.defineProperty(globalThis, 'navigator', {configurable: true, value: previousNavigator}); + } }); diff --git a/tests/piper-worker-format.test.cjs b/tests/piper-worker-format.test.cjs index 0768694..3f8ef6a 100644 --- a/tests/piper-worker-format.test.cjs +++ b/tests/piper-worker-format.test.cjs @@ -11,3 +11,9 @@ test('Piper worker is built and started as an ES module', async () => { assert.match(buildScript, /outfile: join\(workerRoot, 'piper-worker\.js'\),\s*bundle: true,\s*format: 'esm'/); assert.match(runtime, /new Worker\(workerUrl, \{type: 'module'\}\)/); }); + +test('Piper runtime discards a worker after a failed initialization for a clean retry', async () => { + const runtime = await readFile(join(__dirname, '..', 'piper-runtime.js'), 'utf8'); + assert.match(runtime, /TtsSession keeps a singleton even when ONNX initialization fails/); + assert.match(runtime, /worker\?\.terminate\(\);\s*worker = null;/); +});