Compare commits

...
4 Commits
Author SHA1 Message Date
Gitea Actions fa11314b23 Release 0.1.61 [auto-version-bump] 2026-10-17 16:38:43 +02:00
Ein Anderssono c9b14e0726 Fix Piper memory pressure on iOS
Build and publish frontend / publish (push) Successful in 49s
2026-10-17 16:38:16 +02:00
Gitea Actions b956527c72 Release 0.1.60 [auto-version-bump] 2026-10-17 03:50:23 +02:00
Ein Anderssono 7b0e43dff5 Add admin decision trace beside game
Build and publish frontend / publish (push) Successful in 51s
2026-10-17 03:44:51 +02:00
9 changed files with 170 additions and 9 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.59
0.1.61
+2 -2
View File
@@ -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
View File
@@ -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();
+17
View File
@@ -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
View File
@@ -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 => {
+8
View File
@@ -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
+13 -4
View File
@@ -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});
}
});
+6
View File
@@ -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;/);
});