This commit is contained in:
@@ -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.
|
||||||
@@ -7,6 +7,23 @@ export function createOnnxWasmPaths(origin) {
|
|||||||
|
|
||||||
export function configureOnnxRuntime(onnxRuntime) {
|
export function configureOnnxRuntime(onnxRuntime) {
|
||||||
if (!onnxRuntime?.env?.wasm) throw new TypeError('ONNX Runtime WebAssembly settings are unavailable.');
|
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.
|
// Piper already runs in a worker. Avoid a second worker pool on mobile Safari.
|
||||||
onnxRuntime.env.wasm.numThreads = 1;
|
onnxRuntime.env.wasm.numThreads = 1;
|
||||||
onnxRuntime.env.wasm.initTimeout = 60000;
|
onnxRuntime.env.wasm.initTimeout = 60000;
|
||||||
|
|||||||
+13
-2
@@ -17,8 +17,19 @@ function getWorker() {
|
|||||||
request.onProgress?.(message.progress);
|
request.onProgress?.(message.progress);
|
||||||
} else {
|
} else {
|
||||||
pending.delete(message.id);
|
pending.delete(message.id);
|
||||||
if (message.type === 'audio') request.resolve(new Blob([message.audio], {type: 'audio/x-wav'}));
|
if (message.type === 'audio') {
|
||||||
else request.reject(new Error(message.error || 'Piper-arbetaren misslyckades.'));
|
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 => {
|
worker.onerror = event => {
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ 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 'configureOnnxRuntime(onnxRuntime)' piper-worker.js
|
||||||
grep -q 'numThreads = 1' piper-onnx-config.mjs
|
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 '/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
|
||||||
|
|||||||
@@ -14,10 +14,19 @@ test('ONNX Runtime base URL rejects an invalid worker origin', async () => {
|
|||||||
assert.throws(() => createOnnxWasmPaths('undefined'), TypeError);
|
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 {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}}};
|
const runtime = {env: {wasm: {numThreads: 0, initTimeout: 0}}};
|
||||||
|
try {
|
||||||
configureOnnxRuntime(runtime);
|
configureOnnxRuntime(runtime);
|
||||||
|
assert.equal(navigatorObject.hardwareConcurrency, 1);
|
||||||
assert.equal(runtime.env.wasm.numThreads, 1);
|
assert.equal(runtime.env.wasm.numThreads, 1);
|
||||||
assert.equal(runtime.env.wasm.initTimeout, 60000);
|
assert.equal(runtime.env.wasm.initTimeout, 60000);
|
||||||
|
} finally {
|
||||||
|
if (previousNavigator === undefined) delete globalThis.navigator;
|
||||||
|
else Object.defineProperty(globalThis, 'navigator', {configurable: true, value: previousNavigator});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(buildScript, /outfile: join\(workerRoot, 'piper-worker\.js'\),\s*bundle: true,\s*format: 'esm'/);
|
||||||
assert.match(runtime, /new Worker\(workerUrl, \{type: 'module'\}\)/);
|
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;/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user