Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa11314b23 | ||
|
|
c9b14e0726 | ||
|
|
b956527c72 | ||
|
|
7b0e43dff5 |
@@ -2,5 +2,5 @@ apiVersion: v2
|
||||
name: ai-spel-frontend
|
||||
description: Static web frontend for the Swedish life simulator
|
||||
type: application
|
||||
version: 0.1.59
|
||||
appVersion: "0.1.59"
|
||||
version: 0.1.61
|
||||
appVersion: "0.1.61"
|
||||
@@ -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.
|
||||
+97
@@ -29,6 +29,21 @@
|
||||
#admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
|
||||
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
|
||||
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
|
||||
body.debug-layout { max-width: none; margin: 0; padding: .5rem; height: 100vh; overflow: hidden; }
|
||||
#play-layout.debug-enabled { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: .75rem; height: calc(100vh - 4rem); }
|
||||
#play-layout.debug-enabled #play, #play-layout.debug-enabled #debug-panel { min-width: 0; height: 100%; overflow: auto; padding: 1rem; box-sizing: border-box; }
|
||||
#play-layout.debug-enabled #debug-panel { border-inline-start: 1px solid currentColor; }
|
||||
#debug-panel h2 { margin-block-start: 0; }
|
||||
#debug-trace { display: grid; gap: .6rem; }
|
||||
#debug-trace details { border: 1px solid currentColor; border-radius: .3rem; padding: .5rem; }
|
||||
#debug-trace summary { cursor: pointer; overflow-wrap: anywhere; }
|
||||
#debug-trace pre { white-space: pre-wrap; overflow-wrap: anywhere; font: .8rem/1.45 ui-monospace, monospace; }
|
||||
@media (max-width: 800px) {
|
||||
body.debug-layout { height: auto; overflow: auto; }
|
||||
#play-layout.debug-enabled { grid-template-columns: minmax(0, 1fr); height: auto; }
|
||||
#play-layout.debug-enabled #play { min-height: 55vh; }
|
||||
#play-layout.debug-enabled #debug-panel { min-height: 45vh; border-inline-start: 0; border-block-start: 1px solid currentColor; }
|
||||
}
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
[hidden] { display: none !important; }
|
||||
footer { margin-top: 2rem; border-top: 1px solid; padding-top: .5rem; color: GrayText; font-size: .875rem; }
|
||||
@@ -48,6 +63,7 @@
|
||||
<div><button type="submit" name="intent" value="login">Logga in</button> <button type="submit" name="intent" value="register">Skapa konto</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<div id="play-layout">
|
||||
<section id="play" aria-label="Spelets dialog" hidden>
|
||||
<h1 class="sr-only">Dialog</h1>
|
||||
<ol id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite" aria-relevant="additions text"></ol>
|
||||
@@ -64,6 +80,14 @@
|
||||
<output id="speech-progress-value" for="speech-progress">Väntar på nedladdning</output>
|
||||
</div>
|
||||
</section>
|
||||
<aside id="debug-panel" aria-labelledby="debug-heading" hidden>
|
||||
<h2 id="debug-heading">Beslutsspårning</h2>
|
||||
<p>Administratörsvy för ditt eget spelkonto. Promptar, modelltrafik, regelresultat och Rust-commit visas från revisionsloggad API-data. Hemligheter och credentials ska vara maskerade av servern.</p>
|
||||
<button type="button" id="debug-refresh">Uppdatera spår</button>
|
||||
<p id="debug-status" role="status" aria-live="polite">Hämtar spårning…</p>
|
||||
<div id="debug-trace" aria-label="Kronologiskt beslutsspår"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<section id="admin-view" aria-labelledby="admin-heading" hidden>
|
||||
<h1 id="admin-heading">Administration</h1>
|
||||
<p>Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.</p>
|
||||
@@ -84,6 +108,10 @@
|
||||
<script>
|
||||
const auth = document.querySelector('#auth');
|
||||
const play = document.querySelector('#play');
|
||||
const playLayout = document.querySelector('#play-layout');
|
||||
const debugPanel = document.querySelector('#debug-panel');
|
||||
const debugTrace = document.querySelector('#debug-trace');
|
||||
const debugStatus = document.querySelector('#debug-status');
|
||||
const adminView = document.querySelector('#admin-view');
|
||||
const adminContent = document.querySelector('#admin-content');
|
||||
const transcript = document.querySelector('#dialogue');
|
||||
@@ -93,6 +121,8 @@
|
||||
let characterDraft = null;
|
||||
let worldDraft = null;
|
||||
let gameState = null;
|
||||
let currentAccount = null;
|
||||
let debugRefreshBusy = false;
|
||||
let submitBusy = false;
|
||||
let pendingDialogue = null;
|
||||
let latestAssistantText = '';
|
||||
@@ -412,6 +442,9 @@
|
||||
function showAuth() {
|
||||
stage = 'auth';
|
||||
play.hidden = true;
|
||||
debugPanel.hidden = true;
|
||||
playLayout.classList.remove('debug-enabled');
|
||||
document.body.classList.remove('debug-layout');
|
||||
adminView.hidden = true;
|
||||
auth.hidden = false;
|
||||
document.querySelector('#auth-heading').textContent = location.pathname === '/admin' ? 'Administratörsinloggning' : 'Svensk livssimulator';
|
||||
@@ -426,6 +459,10 @@
|
||||
auth.hidden = true;
|
||||
adminView.hidden = true;
|
||||
play.hidden = false;
|
||||
const showDebug = currentAccount?.is_admin === true;
|
||||
debugPanel.hidden = !showDebug;
|
||||
playLayout.classList.toggle('debug-enabled', showDebug);
|
||||
document.body.classList.toggle('debug-layout', showDebug);
|
||||
refreshMicrophone();
|
||||
messageField.focus();
|
||||
}
|
||||
@@ -435,6 +472,9 @@
|
||||
voiceController.stopAll();
|
||||
auth.hidden = true;
|
||||
play.hidden = true;
|
||||
debugPanel.hidden = true;
|
||||
playLayout.classList.remove('debug-enabled');
|
||||
document.body.classList.remove('debug-layout');
|
||||
adminView.hidden = false;
|
||||
document.querySelector('#version-footer').hidden = true;
|
||||
}
|
||||
@@ -640,11 +680,13 @@
|
||||
}
|
||||
}
|
||||
async function openGame() {
|
||||
currentAccount = await request('/api/me');
|
||||
gameState = await request('/api/world');
|
||||
showPlay();
|
||||
if (!gameState.character) {
|
||||
transcript.replaceChildren();
|
||||
appendInitialPrompt();
|
||||
if (currentAccount.is_admin) await refreshDecisionTrace();
|
||||
return;
|
||||
}
|
||||
characterDraft = null;
|
||||
@@ -652,13 +694,66 @@
|
||||
worldDraft = null;
|
||||
appendLine('assistant', 'Nu föreslår jag en startplats som passar biografin. Du kan ändra förslaget innan något sparas. Skriv “börja”.');
|
||||
setStage('world-draft');
|
||||
if (currentAccount.is_admin) await refreshDecisionTrace();
|
||||
return;
|
||||
}
|
||||
await loadSavedDialogue();
|
||||
if (currentAccount.is_admin) await refreshDecisionTrace();
|
||||
setStage('game');
|
||||
startPlayerPresence();
|
||||
announcePlayerChatHelp();
|
||||
}
|
||||
|
||||
function traceTimestamp(record) {
|
||||
return record.recorded_at || record.requested_at || record.completed_at || '';
|
||||
}
|
||||
function renderDecisionTrace(data) {
|
||||
const groups = new Map();
|
||||
for (const [kind, records] of Object.entries({events: data.events || [], ai_interactions: data.ai_interactions || [], player_messages: data.player_messages || []})) {
|
||||
for (const record of records) {
|
||||
const key = record.correlation_id || `${kind}:${record.event_id || record.id || traceTimestamp(record)}`;
|
||||
if (!groups.has(key)) groups.set(key, {correlation_id: key, events: [], ai_interactions: [], player_messages: [], latest: ''});
|
||||
const group = groups.get(key);
|
||||
group[kind].push(record);
|
||||
const time = traceTimestamp(record);
|
||||
if (time > group.latest) group.latest = time;
|
||||
}
|
||||
}
|
||||
const ordered = [...groups.values()].sort((a, b) => b.latest.localeCompare(a.latest));
|
||||
debugTrace.replaceChildren();
|
||||
if (ordered.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.textContent = 'Ingen sparad spårning för kontot ännu.';
|
||||
debugTrace.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const group of ordered) {
|
||||
const details = document.createElement('details');
|
||||
details.open = debugTrace.childElementCount === 0;
|
||||
const summary = document.createElement('summary');
|
||||
const eventNames = group.events.map(event => `${event.event_type} (${event.commit_status || 'okänt utfall'})`).join(', ');
|
||||
const prompts = group.ai_interactions.map(interaction => `${interaction.prompt_id} v${interaction.prompt_version}: ${interaction.outcome}`).join(', ');
|
||||
summary.textContent = `${group.latest || 'Tid saknas'} · ${group.correlation_id}${eventNames ? ` · Rust: ${eventNames}` : ''}${prompts ? ` · AI: ${prompts}` : ''}`;
|
||||
const pre = document.createElement('pre');
|
||||
pre.textContent = JSON.stringify(group, null, 2);
|
||||
details.append(summary, pre);
|
||||
debugTrace.append(details);
|
||||
}
|
||||
}
|
||||
async function refreshDecisionTrace() {
|
||||
if (!currentAccount?.is_admin || debugRefreshBusy || debugPanel.hidden) return;
|
||||
debugRefreshBusy = true;
|
||||
debugStatus.textContent = 'Hämtar revisionsloggad spårning…';
|
||||
try {
|
||||
const trace = await request('/api/admin/self/trace?limit=200');
|
||||
renderDecisionTrace(trace);
|
||||
debugStatus.textContent = `Visar ${debugTrace.childElementCount} korrelerade händelsegrupper. Uppdaterad ${new Date().toLocaleTimeString('sv-SE')}.`;
|
||||
} catch (error) {
|
||||
debugStatus.textContent = `Spårningen kunde inte hämtas: ${error.message}`;
|
||||
} finally {
|
||||
debugRefreshBusy = false;
|
||||
}
|
||||
}
|
||||
function announcePlayerChatHelp() {
|
||||
const key = 'ai-spel.player-chat-help.v1';
|
||||
try {
|
||||
@@ -820,6 +915,7 @@
|
||||
} finally {
|
||||
submitBusy = false;
|
||||
refreshMicrophone();
|
||||
if (currentAccount?.is_admin) await refreshDecisionTrace();
|
||||
if (!play.hidden && !messageField.disabled) messageField.focus();
|
||||
}
|
||||
}
|
||||
@@ -920,6 +1016,7 @@
|
||||
finally { adminContent.setAttribute('aria-busy', 'false'); }
|
||||
}));
|
||||
document.querySelector('#admin-logout').addEventListener('click', handleLogout);
|
||||
document.querySelector('#debug-refresh').addEventListener('click', refreshDecisionTrace);
|
||||
messageField.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+13
-2
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
@@ -78,6 +80,12 @@ grep -q "Säg till Robin" index.html
|
||||
grep -q "result.intent === 'move' || result.intent === 'meet'" index.html
|
||||
grep -q 'dialogue_temporarily_unavailable:' index.html
|
||||
grep -q 'id="admin-view"' index.html
|
||||
grep -q 'id="play-layout"' index.html
|
||||
grep -q 'id="debug-panel"' index.html
|
||||
grep -q "request('/api/admin/self/trace?limit=200')" index.html
|
||||
grep -q 'function renderDecisionTrace' index.html
|
||||
grep -q 'JSON.stringify(group, null, 2)' index.html
|
||||
grep -q 'debug-enabled' index.html
|
||||
grep -q '/api/admin/players?' index.html
|
||||
grep -q "request('/api/admin/prompts'" index.html
|
||||
grep -q "request('/api/admin/rules'" index.html
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user