Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b956527c72 | ||
|
|
7b0e43dff5 | ||
|
|
381c90e95e | ||
|
|
f317e6254f | ||
|
|
b6deecae0a | ||
|
|
a4f89f2b82 | ||
|
|
aa832fd7cc | ||
|
|
d788f8b350 | ||
|
|
04bac2ba30 | ||
|
|
8e7c74bb41 | ||
|
|
89c238bcea | ||
|
|
81990d7129 | ||
|
|
7f77b904ac | ||
|
|
ab574acd39 | ||
|
|
0bee9f40d3 | ||
|
|
291fdcbf08 | ||
|
|
846bfec04d |
@@ -2,5 +2,5 @@ apiVersion: v2
|
|||||||
name: ai-spel-frontend
|
name: ai-spel-frontend
|
||||||
description: Static web frontend for the Swedish life simulator
|
description: Static web frontend for the Swedish life simulator
|
||||||
type: application
|
type: application
|
||||||
version: 0.1.52
|
version: 0.1.60
|
||||||
appVersion: "0.1.52"
|
appVersion: "0.1.60"
|
||||||
@@ -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.
|
||||||
+171
-6
@@ -20,6 +20,8 @@
|
|||||||
#message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
|
#message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
|
||||||
#message-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
#message-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
||||||
#voice-status { min-height: 1.5em; }
|
#voice-status { min-height: 1.5em; }
|
||||||
|
#speech-progress { width: min(100%, 32rem); height: 1.25rem; }
|
||||||
|
#speech-progress-value { margin-inline-start: .5rem; font-variant-numeric: tabular-nums; }
|
||||||
#dialogue li[data-role="voice-controls"] { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
|
#dialogue li[data-role="voice-controls"] { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
|
||||||
#dialogue li[data-role="voice-controls"] fieldset { display: grid; gap: .5rem; margin-block: .5rem; }
|
#dialogue li[data-role="voice-controls"] fieldset { display: grid; gap: .5rem; margin-block: .5rem; }
|
||||||
#admin-content { display: grid; gap: 1rem; }
|
#admin-content { display: grid; gap: 1rem; }
|
||||||
@@ -27,6 +29,21 @@
|
|||||||
#admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
|
#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; }
|
#admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; }
|
||||||
#player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; }
|
#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; }
|
.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; }
|
[hidden] { display: none !important; }
|
||||||
footer { margin-top: 2rem; border-top: 1px solid; padding-top: .5rem; color: GrayText; font-size: .875rem; }
|
footer { margin-top: 2rem; border-top: 1px solid; padding-top: .5rem; color: GrayText; font-size: .875rem; }
|
||||||
@@ -46,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>
|
<div><button type="submit" name="intent" value="login">Logga in</button> <button type="submit" name="intent" value="register">Skapa konto</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
<div id="play-layout">
|
||||||
<section id="play" aria-label="Spelets dialog" hidden>
|
<section id="play" aria-label="Spelets dialog" hidden>
|
||||||
<h1 class="sr-only">Dialog</h1>
|
<h1 class="sr-only">Dialog</h1>
|
||||||
<ol id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite" aria-relevant="additions text"></ol>
|
<ol id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite" aria-relevant="additions text"></ol>
|
||||||
@@ -56,7 +74,20 @@
|
|||||||
<span id="message-help" class="sr-only">Tryck Enter för att skicka. Tryck Skift och Enter för ny rad.</span>
|
<span id="message-help" class="sr-only">Tryck Enter för att skicka. Tryck Skift och Enter för ny rad.</span>
|
||||||
</form>
|
</form>
|
||||||
<p id="voice-status" role="status" aria-live="polite"></p>
|
<p id="voice-status" role="status" aria-live="polite"></p>
|
||||||
|
<div id="speech-progress-container" hidden>
|
||||||
|
<label for="speech-progress">Hämtar svensk röstmodell till enheten</label>
|
||||||
|
<progress id="speech-progress" aria-label="Nedladdning av svensk röstmodell"></progress>
|
||||||
|
<output id="speech-progress-value" for="speech-progress">Väntar på nedladdning</output>
|
||||||
|
</div>
|
||||||
</section>
|
</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>
|
<section id="admin-view" aria-labelledby="admin-heading" hidden>
|
||||||
<h1 id="admin-heading">Administration</h1>
|
<h1 id="admin-heading">Administration</h1>
|
||||||
<p>Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.</p>
|
<p>Privilegierad läsning loggas. Känsligt spelarinnehåll visas bara här för administratörer.</p>
|
||||||
@@ -77,6 +108,10 @@
|
|||||||
<script>
|
<script>
|
||||||
const auth = document.querySelector('#auth');
|
const auth = document.querySelector('#auth');
|
||||||
const play = document.querySelector('#play');
|
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 adminView = document.querySelector('#admin-view');
|
||||||
const adminContent = document.querySelector('#admin-content');
|
const adminContent = document.querySelector('#admin-content');
|
||||||
const transcript = document.querySelector('#dialogue');
|
const transcript = document.querySelector('#dialogue');
|
||||||
@@ -86,6 +121,8 @@
|
|||||||
let characterDraft = null;
|
let characterDraft = null;
|
||||||
let worldDraft = null;
|
let worldDraft = null;
|
||||||
let gameState = null;
|
let gameState = null;
|
||||||
|
let currentAccount = null;
|
||||||
|
let debugRefreshBusy = false;
|
||||||
let submitBusy = false;
|
let submitBusy = false;
|
||||||
let pendingDialogue = null;
|
let pendingDialogue = null;
|
||||||
let latestAssistantText = '';
|
let latestAssistantText = '';
|
||||||
@@ -157,6 +194,62 @@
|
|||||||
const authVoiceStatus = document.querySelector('#auth-voice-status');
|
const authVoiceStatus = document.querySelector('#auth-voice-status');
|
||||||
if (authVoiceStatus && !auth.hidden && message) authVoiceStatus.textContent = message;
|
if (authVoiceStatus && !auth.hidden && message) authVoiceStatus.textContent = message;
|
||||||
}
|
}
|
||||||
|
function updateSpeechProgress(message = '') {
|
||||||
|
const container = document.querySelector('#speech-progress-container');
|
||||||
|
const progress = document.querySelector('#speech-progress');
|
||||||
|
const value = document.querySelector('#speech-progress-value');
|
||||||
|
const label = container.querySelector('label');
|
||||||
|
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.',
|
||||||
|
'Röstmodellen är inläst. Skapar uppläsningen.',
|
||||||
|
].find(text => message.startsWith(text));
|
||||||
|
const match = message.match(/^Hämtar svensk röstmodell till enheten: (\d+) procent\.$/);
|
||||||
|
const bytesMatch = message.match(/^Hämtar svensk röstmodell till enheten: ([\d.,]+) MB hämtade\.$/);
|
||||||
|
const runtimeFile = message.match(/^(?:Hämtar|Läser in) WebAssembly-filen ([\w.-]+)\.?$/);
|
||||||
|
const runtimePercent = message.match(/^Hämtar WebAssembly-filen ([\w.-]+): (\d+) procent\.$/);
|
||||||
|
const runtimeBytes = message.match(/^Hämtar WebAssembly-filen ([\w.-]+): ([\d.,]+) MB hämtade\.$/);
|
||||||
|
if (!preparing && !phase && !match && !bytesMatch && !runtimeFile && !runtimePercent && !runtimeBytes) {
|
||||||
|
container.hidden = true;
|
||||||
|
progress.removeAttribute('value');
|
||||||
|
value.textContent = 'Väntar på nedladdning';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
container.hidden = false;
|
||||||
|
label.textContent = runtimePercent || runtimeBytes
|
||||||
|
? `Hämtar WebAssembly-filen ${runtimePercent?.[1] || runtimeBytes?.[1]}`
|
||||||
|
: runtimeFile
|
||||||
|
? runtimeFile[0].replace(/\.$/, '')
|
||||||
|
: phase || 'Hämtar svensk röstmodell till enheten';
|
||||||
|
if (runtimePercent) {
|
||||||
|
progress.value = Math.max(0, Math.min(100, Number(runtimePercent[2])));
|
||||||
|
value.textContent = `${runtimePercent[2]}%`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (runtimeBytes) {
|
||||||
|
progress.removeAttribute('value');
|
||||||
|
value.textContent = `${runtimeBytes[2]} MB hämtade`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
const percent = Math.max(0, Math.min(100, Number(match[1])));
|
||||||
|
progress.value = percent;
|
||||||
|
value.textContent = `${percent}%`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (bytesMatch) {
|
||||||
|
progress.removeAttribute('value');
|
||||||
|
value.textContent = `${bytesMatch[1]} MB hämtade`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
progress.removeAttribute('value');
|
||||||
|
value.textContent = phase ? 'Pågår…' : 'Förbereder…';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
function clearSpeechRecovery() {
|
function clearSpeechRecovery() {
|
||||||
speechRecoveryElement?.remove();
|
speechRecoveryElement?.remove();
|
||||||
speechRecoveryElement = null;
|
speechRecoveryElement = null;
|
||||||
@@ -218,8 +311,15 @@
|
|||||||
microphone.setAttribute('aria-pressed', String(listening));
|
microphone.setAttribute('aria-pressed', String(listening));
|
||||||
microphone.textContent = listening ? 'Avsluta mikrofon' : 'Starta mikrofon';
|
microphone.textContent = listening ? 'Avsluta mikrofon' : 'Starta mikrofon';
|
||||||
refreshMicrophone();
|
refreshMicrophone();
|
||||||
if (message) setVoiceStatus(message);
|
if (message) {
|
||||||
else if (state === 'IDLE') setVoiceStatus('');
|
const downloading = updateSpeechProgress(message);
|
||||||
|
setVoiceStatus(downloading && message.startsWith('Hämtar svensk röstmodell')
|
||||||
|
? 'Hämtar svensk röstmodell till enheten.'
|
||||||
|
: message);
|
||||||
|
} else if (state === 'IDLE') {
|
||||||
|
updateSpeechProgress('');
|
||||||
|
setVoiceStatus('');
|
||||||
|
}
|
||||||
if (/Läs senaste repliken/.test(message)) showSpeechRecovery(message);
|
if (/Läs senaste repliken/.test(message)) showSpeechRecovery(message);
|
||||||
playVoiceSignal(state === 'IDLE' && /Mikrofonen är avstängd/.test(message) ? 'STOPPED' : state);
|
playVoiceSignal(state === 'IDLE' && /Mikrofonen är avstängd/.test(message) ? 'STOPPED' : state);
|
||||||
},
|
},
|
||||||
@@ -342,6 +442,9 @@
|
|||||||
function showAuth() {
|
function showAuth() {
|
||||||
stage = 'auth';
|
stage = 'auth';
|
||||||
play.hidden = true;
|
play.hidden = true;
|
||||||
|
debugPanel.hidden = true;
|
||||||
|
playLayout.classList.remove('debug-enabled');
|
||||||
|
document.body.classList.remove('debug-layout');
|
||||||
adminView.hidden = true;
|
adminView.hidden = true;
|
||||||
auth.hidden = false;
|
auth.hidden = false;
|
||||||
document.querySelector('#auth-heading').textContent = location.pathname === '/admin' ? 'Administratörsinloggning' : 'Svensk livssimulator';
|
document.querySelector('#auth-heading').textContent = location.pathname === '/admin' ? 'Administratörsinloggning' : 'Svensk livssimulator';
|
||||||
@@ -356,6 +459,10 @@
|
|||||||
auth.hidden = true;
|
auth.hidden = true;
|
||||||
adminView.hidden = true;
|
adminView.hidden = true;
|
||||||
play.hidden = false;
|
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();
|
refreshMicrophone();
|
||||||
messageField.focus();
|
messageField.focus();
|
||||||
}
|
}
|
||||||
@@ -365,6 +472,9 @@
|
|||||||
voiceController.stopAll();
|
voiceController.stopAll();
|
||||||
auth.hidden = true;
|
auth.hidden = true;
|
||||||
play.hidden = true;
|
play.hidden = true;
|
||||||
|
debugPanel.hidden = true;
|
||||||
|
playLayout.classList.remove('debug-enabled');
|
||||||
|
document.body.classList.remove('debug-layout');
|
||||||
adminView.hidden = false;
|
adminView.hidden = false;
|
||||||
document.querySelector('#version-footer').hidden = true;
|
document.querySelector('#version-footer').hidden = true;
|
||||||
}
|
}
|
||||||
@@ -528,8 +638,7 @@
|
|||||||
body: JSON.stringify({input: message, current_draft: worldDraft}),
|
body: JSON.stringify({input: message, current_draft: worldDraft}),
|
||||||
});
|
});
|
||||||
worldDraft = proposal.draft;
|
worldDraft = proposal.draft;
|
||||||
const senses = worldDraft.location_senses;
|
appendLine('assistant', `${proposal.message}\n\nVärld: ${worldDraft.name}\nStartplats: ${worldDraft.location_name}. ${worldDraft.location_description}\nStartbelopp: ${worldDraft.starting_balance_sek} kronor. ${worldDraft.offer_name} kostar ${worldDraft.offer_price_sek} kronor.\n\nSkriv ändringar eller “godkänn”.`);
|
||||||
appendLine('assistant', `${proposal.message}\n\nVärld: ${worldDraft.name}\nStartplats: ${worldDraft.location_name}. ${worldDraft.location_description}\nLjud: ${senses.sound}\nDoft: ${senses.smell}\nLuft och temperatur: ${senses.air_and_temperature}\nBeröring och ytor: ${senses.touch_and_surfaces}\nOrientering: ${senses.spatial_orientation}\nStartbelopp: ${worldDraft.starting_balance_sek} kronor. ${worldDraft.offer_name} kostar ${worldDraft.offer_price_sek} kronor.\n\nSkriv ändringar eller “godkänn”.`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appendLine('assistant', `Förslaget kunde inte tas fram: ${error.message}. Skriv “försök igen” när du vill försöka på nytt.`);
|
appendLine('assistant', `Förslaget kunde inte tas fram: ${error.message}. Skriv “försök igen” när du vill försöka på nytt.`);
|
||||||
}
|
}
|
||||||
@@ -567,16 +676,17 @@
|
|||||||
voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'});
|
voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'});
|
||||||
} else if (lines.length === 0 && gameState?.world) {
|
} else if (lines.length === 0 && gameState?.world) {
|
||||||
const place = gameState.world.location;
|
const place = gameState.world.location;
|
||||||
const senses = place.senses || {};
|
appendLine('assistant', `Du börjar på ${place.name}. ${place.description}`);
|
||||||
appendLine('assistant', `Du börjar på ${place.name}. ${place.description} Ljud: ${senses.sound || 'Inget särskilt ljud beskrivet.'} Doft: ${senses.smell || 'Ingen särskild doft beskriven.'} Luft och temperatur: ${senses.air_and_temperature || 'Inte beskrivet.'} Beröring och ytor: ${senses.touch_and_surfaces || 'Inte beskrivet.'} Orientering: ${senses.spatial_orientation || 'Inte beskrivet.'}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function openGame() {
|
async function openGame() {
|
||||||
|
currentAccount = await request('/api/me');
|
||||||
gameState = await request('/api/world');
|
gameState = await request('/api/world');
|
||||||
showPlay();
|
showPlay();
|
||||||
if (!gameState.character) {
|
if (!gameState.character) {
|
||||||
transcript.replaceChildren();
|
transcript.replaceChildren();
|
||||||
appendInitialPrompt();
|
appendInitialPrompt();
|
||||||
|
if (currentAccount.is_admin) await refreshDecisionTrace();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
characterDraft = null;
|
characterDraft = null;
|
||||||
@@ -584,13 +694,66 @@
|
|||||||
worldDraft = null;
|
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”.');
|
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');
|
setStage('world-draft');
|
||||||
|
if (currentAccount.is_admin) await refreshDecisionTrace();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadSavedDialogue();
|
await loadSavedDialogue();
|
||||||
|
if (currentAccount.is_admin) await refreshDecisionTrace();
|
||||||
setStage('game');
|
setStage('game');
|
||||||
startPlayerPresence();
|
startPlayerPresence();
|
||||||
announcePlayerChatHelp();
|
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() {
|
function announcePlayerChatHelp() {
|
||||||
const key = 'ai-spel.player-chat-help.v1';
|
const key = 'ai-spel.player-chat-help.v1';
|
||||||
try {
|
try {
|
||||||
@@ -752,6 +915,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
submitBusy = false;
|
submitBusy = false;
|
||||||
refreshMicrophone();
|
refreshMicrophone();
|
||||||
|
if (currentAccount?.is_admin) await refreshDecisionTrace();
|
||||||
if (!play.hidden && !messageField.disabled) messageField.focus();
|
if (!play.hidden && !messageField.disabled) messageField.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,6 +1016,7 @@
|
|||||||
finally { adminContent.setAttribute('aria-busy', 'false'); }
|
finally { adminContent.setAttribute('aria-busy', 'false'); }
|
||||||
}));
|
}));
|
||||||
document.querySelector('#admin-logout').addEventListener('click', handleLogout);
|
document.querySelector('#admin-logout').addEventListener('click', handleLogout);
|
||||||
|
document.querySelector('#debug-refresh').addEventListener('click', refreshDecisionTrace);
|
||||||
messageField.addEventListener('keydown', (event) => {
|
messageField.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export function trackDownloadProgress(response, {id, postMessage, asset = 'model', file}) {
|
||||||
|
const reader = response.body?.getReader?.();
|
||||||
|
if (!reader || typeof ReadableStream !== 'function' || typeof Response !== 'function') return response;
|
||||||
|
|
||||||
|
const total = Number(response.headers.get('content-length')) || 0;
|
||||||
|
let loaded = 0;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
async pull(controller) {
|
||||||
|
try {
|
||||||
|
const {done, value} = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
if (asset === 'runtime') {
|
||||||
|
postMessage({id, type: 'progress', progress: {stage: 'initializing-runtime', file}});
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loaded += value.byteLength;
|
||||||
|
const progress = {asset, loaded, total};
|
||||||
|
if (file) progress.file = file;
|
||||||
|
postMessage({id, type: 'progress', progress});
|
||||||
|
controller.enqueue(value);
|
||||||
|
} catch (error) {
|
||||||
|
controller.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel(reason) {
|
||||||
|
return reader.cancel(reason);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: response.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,3 +5,10 @@ export function createOnnxWasmPaths(origin) {
|
|||||||
return absoluteAssetRoot;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-7
@@ -1,29 +1,59 @@
|
|||||||
import {TtsSession} from '@mintplex-labs/piper-tts-web';
|
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 {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 VOICE_ID = 'sv_SE-nst-medium';
|
||||||
const ASSET_ROOT = '/static/piper-wasm-v1';
|
const ASSET_ROOT = '/static/piper-wasm-v1';
|
||||||
const localFetch = self.fetch.bind(self);
|
const localFetch = self.fetch.bind(self);
|
||||||
|
|
||||||
|
function reportStage(id, stage, details = {}) {
|
||||||
|
if (Number.isSafeInteger(id)) self.postMessage({id, type: 'progress', progress: {stage, ...details}});
|
||||||
|
}
|
||||||
|
|
||||||
// Keep the TTS library's OPFS cache logic, but serve models/config from the
|
// Keep the TTS library's OPFS cache logic, but serve models/config from the
|
||||||
// same-origin static asset service instead of its built-in external model URL.
|
// same-origin static asset service instead of its built-in external model URL.
|
||||||
self.fetch = (resource, options) => {
|
self.fetch = async (resource, options) => {
|
||||||
const requestedUrl = resource instanceof Request ? resource.url : String(resource);
|
const requestedUrl = resource instanceof Request ? resource.url : String(resource);
|
||||||
const localModelUrl = localPiperModelUrl(requestedUrl, self.location.origin);
|
const localModelUrl = localPiperModelUrl(requestedUrl, self.location.origin);
|
||||||
if (!localModelUrl) return localFetch(resource, options);
|
if (!localModelUrl) {
|
||||||
|
const runtimeAsset = requestedUrl.includes('/static/piper-wasm-v1/onnx/');
|
||||||
|
const runtimeFile = runtimeAsset ? new URL(requestedUrl, self.location.origin).pathname.split('/').pop() : undefined;
|
||||||
|
if (activeRequestId && runtimeAsset) {
|
||||||
|
reportStage(activeRequestId, 'loading-runtime-assets', {file: runtimeFile});
|
||||||
|
}
|
||||||
|
const response = await localFetch(resource, options);
|
||||||
|
if (!activeRequestId || !runtimeAsset) return response;
|
||||||
|
reportStage(activeRequestId, 'reading-runtime-assets', {file: runtimeFile});
|
||||||
|
return trackDownloadProgress(response, {
|
||||||
|
id: activeRequestId,
|
||||||
|
asset: 'runtime',
|
||||||
|
file: runtimeFile,
|
||||||
|
postMessage: message => self.postMessage(message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const isModel = localModelUrl.endsWith('.onnx');
|
||||||
|
if (activeRequestId) reportStage(activeRequestId, isModel ? 'loading-model' : 'loading-model-info');
|
||||||
const redirected = resource instanceof Request ? new Request(localModelUrl, resource) : localModelUrl;
|
const redirected = resource instanceof Request ? new Request(localModelUrl, resource) : localModelUrl;
|
||||||
return localFetch(redirected, options);
|
const response = await localFetch(redirected, options);
|
||||||
|
if (!activeRequestId || !isModel) return response;
|
||||||
|
reportStage(activeRequestId, 'reading-model');
|
||||||
|
return trackDownloadProgress(response, {
|
||||||
|
id: activeRequestId,
|
||||||
|
postMessage: message => self.postMessage(message),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let sessionPromise;
|
let sessionPromise;
|
||||||
let inferenceQueue = Promise.resolve();
|
let inferenceQueue = Promise.resolve();
|
||||||
|
let activeRequestId = null;
|
||||||
|
|
||||||
async function getSession(onProgress) {
|
async function getSession() {
|
||||||
if (!sessionPromise) {
|
if (!sessionPromise) {
|
||||||
|
configureOnnxRuntime(onnxRuntime);
|
||||||
sessionPromise = TtsSession.create({
|
sessionPromise = TtsSession.create({
|
||||||
voiceId: VOICE_ID,
|
voiceId: VOICE_ID,
|
||||||
progress: onProgress,
|
|
||||||
wasmPaths: {
|
wasmPaths: {
|
||||||
onnxWasm: createOnnxWasmPaths(self.location.origin),
|
onnxWasm: createOnnxWasmPaths(self.location.origin),
|
||||||
piperData: `${ASSET_ROOT}/piper/piper_phonemize.data`,
|
piperData: `${ASSET_ROOT}/piper/piper_phonemize.data`,
|
||||||
@@ -40,13 +70,18 @@ async function getSession(onProgress) {
|
|||||||
async function synthesizeMessage(event) {
|
async function synthesizeMessage(event) {
|
||||||
const {id, text} = event.data || {};
|
const {id, text} = event.data || {};
|
||||||
if (!Number.isSafeInteger(id) || typeof text !== 'string') return;
|
if (!Number.isSafeInteger(id) || typeof text !== 'string') return;
|
||||||
|
activeRequestId = id;
|
||||||
|
reportStage(id, 'loading-runtime');
|
||||||
try {
|
try {
|
||||||
const session = await getSession(progress => self.postMessage({id, type: 'progress', progress}));
|
const session = await getSession();
|
||||||
|
reportStage(id, 'model-ready');
|
||||||
const blob = await session.predict(text);
|
const blob = await session.predict(text);
|
||||||
const audio = await blob.arrayBuffer();
|
const audio = await blob.arrayBuffer();
|
||||||
self.postMessage({id, type: 'audio', audio}, [audio]);
|
self.postMessage({id, type: 'audio', audio}, [audio]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
self.postMessage({id, type: 'error', error: String(error?.message || error)});
|
self.postMessage({id, type: 'error', error: String(error?.message || error)});
|
||||||
|
} finally {
|
||||||
|
activeRequestId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ grep -q "location.pathname !== '/admin'" index.html
|
|||||||
grep -q "if (options.speak !== false && location.pathname !== '/admin')" index.html
|
grep -q "if (options.speak !== false && location.pathname !== '/admin')" index.html
|
||||||
grep -q 'id="microphone" aria-pressed="false"' index.html
|
grep -q 'id="microphone" aria-pressed="false"' index.html
|
||||||
grep -q 'id="voice-status" role="status" aria-live="polite"' index.html
|
grep -q 'id="voice-status" role="status" aria-live="polite"' index.html
|
||||||
|
grep -q 'id="speech-progress" aria-label="Nedladdning av svensk röstmodell"' index.html
|
||||||
|
grep -q 'id="speech-progress-value"' index.html
|
||||||
|
grep -q 'MB hämtade' index.html
|
||||||
|
grep -q 'loading-runtime-assets' piper-worker.js
|
||||||
|
grep -q 'Röstmodellen är inläst' voice.js
|
||||||
grep -q "window.GameVoice.createVoiceController" index.html
|
grep -q "window.GameVoice.createVoiceController" index.html
|
||||||
grep -q "addVoiceButton(item, 'Testa ljudet'" index.html
|
grep -q "addVoiceButton(item, 'Testa ljudet'" index.html
|
||||||
grep -q 'Jag förstår – starta mikrofonen' index.html
|
grep -q 'Jag förstår – starta mikrofonen' index.html
|
||||||
@@ -49,7 +54,11 @@ grep -q "new Worker(workerUrl, {type: 'module'})" piper-runtime.js
|
|||||||
grep -q 'worker.js?v=' piper-runtime.js
|
grep -q 'worker.js?v=' piper-runtime.js
|
||||||
grep -q 'worker.onmessageerror' piper-runtime.js
|
grep -q 'worker.onmessageerror' piper-runtime.js
|
||||||
grep -q 'localPiperModelUrl' piper-worker.js
|
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 '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 '/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
|
||||||
@@ -69,6 +78,12 @@ grep -q "Säg till Robin" index.html
|
|||||||
grep -q "result.intent === 'move' || result.intent === 'meet'" index.html
|
grep -q "result.intent === 'move' || result.intent === 'meet'" index.html
|
||||||
grep -q 'dialogue_temporarily_unavailable:' index.html
|
grep -q 'dialogue_temporarily_unavailable:' index.html
|
||||||
grep -q 'id="admin-view"' 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 '/api/admin/players?' index.html
|
||||||
grep -q "request('/api/admin/prompts'" index.html
|
grep -q "request('/api/admin/prompts'" index.html
|
||||||
grep -q "request('/api/admin/rules'" index.html
|
grep -q "request('/api/admin/rules'" index.html
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
test('model response progress forwards each downloaded chunk and preserves the response', async () => {
|
||||||
|
const {trackDownloadProgress} = await import('../piper-download-progress.mjs');
|
||||||
|
const source = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new Uint8Array([1, 2]));
|
||||||
|
controller.enqueue(new Uint8Array([3, 4, 5]));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response = new Response(source, {headers: {'content-length': '5'}});
|
||||||
|
const messages = [];
|
||||||
|
const tracked = trackDownloadProgress(response, {id: 7, postMessage: message => messages.push(message)});
|
||||||
|
|
||||||
|
assert.deepEqual([...new Uint8Array(await tracked.arrayBuffer())], [1, 2, 3, 4, 5]);
|
||||||
|
assert.deepEqual(messages.map(message => message.progress), [
|
||||||
|
{asset: 'model', loaded: 2, total: 5},
|
||||||
|
{asset: 'model', loaded: 5, total: 5},
|
||||||
|
]);
|
||||||
|
assert.ok(messages.every(message => message.id === 7 && message.type === 'progress'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('model response progress works when content length is absent', async () => {
|
||||||
|
const {trackDownloadProgress} = await import('../piper-download-progress.mjs');
|
||||||
|
const source = new Response(new Uint8Array([1, 2, 3]));
|
||||||
|
const messages = [];
|
||||||
|
const tracked = trackDownloadProgress(source, {
|
||||||
|
id: 9,
|
||||||
|
asset: 'runtime',
|
||||||
|
file: 'runtime.wasm',
|
||||||
|
postMessage: message => messages.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal((await tracked.arrayBuffer()).byteLength, 3);
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,3 +13,11 @@ test('ONNX Runtime base URL rejects an invalid worker origin', async () => {
|
|||||||
const {createOnnxWasmPaths} = await import('../piper-onnx-config.mjs');
|
const {createOnnxWasmPaths} = await import('../piper-onnx-config.mjs');
|
||||||
assert.throws(() => createOnnxWasmPaths('undefined'), TypeError);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
+24
-2
@@ -26,6 +26,7 @@ function makeHarness({supported = true, tts = true, piper = false, onTranscript
|
|||||||
constructor(text) { this.text = text; utterances.push(this); }
|
constructor(text) { this.text = text; utterances.push(this); }
|
||||||
}
|
}
|
||||||
const audioSources = [];
|
const audioSources = [];
|
||||||
|
const piperProgressCallbacks = [];
|
||||||
class AudioContext {
|
class AudioContext {
|
||||||
constructor() { this.state = 'running'; this.destination = {}; }
|
constructor() { this.state = 'running'; this.destination = {}; }
|
||||||
resume() { this.state = 'running'; return Promise.resolve(); }
|
resume() { this.state = 'running'; return Promise.resolve(); }
|
||||||
@@ -49,7 +50,10 @@ function makeHarness({supported = true, tts = true, piper = false, onTranscript
|
|||||||
SpeechSynthesisUtterance: tts ? Utterance : undefined,
|
SpeechSynthesisUtterance: tts ? Utterance : undefined,
|
||||||
SpeechRecognition: supported ? Recognition : undefined,
|
SpeechRecognition: supported ? Recognition : undefined,
|
||||||
AudioContext,
|
AudioContext,
|
||||||
GamePiper: piper ? {synthesize: () => Promise.resolve({arrayBuffer: () => Promise.resolve(new ArrayBuffer(8))})} : undefined,
|
GamePiper: piper ? {synthesize: (_text, onProgress) => {
|
||||||
|
piperProgressCallbacks.push(onProgress);
|
||||||
|
return Promise.resolve({arrayBuffer: () => Promise.resolve(new ArrayBuffer(8))});
|
||||||
|
}} : undefined,
|
||||||
};
|
};
|
||||||
vm.runInNewContext(source, {window, globalThis: window, Promise, String, Math, Object});
|
vm.runInNewContext(source, {window, globalThis: window, Promise, String, Math, Object});
|
||||||
const controller = window.GameVoice.createVoiceController({
|
const controller = window.GameVoice.createVoiceController({
|
||||||
@@ -58,7 +62,7 @@ function makeHarness({supported = true, tts = true, piper = false, onTranscript
|
|||||||
onTranscript,
|
onTranscript,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
controller, recognitions, utterances, synthesis, states, timers, audioSources,
|
controller, recognitions, utterances, synthesis, states, timers, audioSources, piperProgressCallbacks,
|
||||||
runTimer(delay) {
|
runTimer(delay) {
|
||||||
const match = [...timerDelays.entries()].find(([, value]) => value === delay);
|
const match = [...timerDelays.entries()].find(([, value]) => value === delay);
|
||||||
if (!match) throw new Error(`No timer found with delay ${delay}`);
|
if (!match) throw new Error(`No timer found with delay ${delay}`);
|
||||||
@@ -250,6 +254,24 @@ test('Piper WASM output uses the unlocked audio context and starts only after lo
|
|||||||
assert.equal(h.controller.getState(), 'IDLE');
|
assert.equal(h.controller.getState(), 'IDLE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Piper model download progress is reported as a bounded percentage', async () => {
|
||||||
|
const h = makeHarness({piper: true});
|
||||||
|
h.controller.activateFromUserGesture();
|
||||||
|
h.controller.speak('Ett svar från Piper.');
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
h.piperProgressCallbacks[0]({stage: 'loading-model'});
|
||||||
|
assert.match(h.states.at(-1).message, /Ansluter till servern/);
|
||||||
|
h.piperProgressCallbacks[0]({asset: 'runtime', file: 'ort-wasm-simd-threaded.wasm', loaded: 5 * 1024 * 1024, total: 10 * 1024 * 1024});
|
||||||
|
assert.match(h.states.at(-1).message, /ort-wasm-simd-threaded\.wasm: 50 procent/);
|
||||||
|
h.piperProgressCallbacks[0]({loaded: 31_500_000, total: 63_000_000});
|
||||||
|
assert.equal(h.states.at(-1).state, 'PROCESSING');
|
||||||
|
assert.match(h.states.at(-1).message, /50 procent/);
|
||||||
|
h.piperProgressCallbacks[0]({loaded: 90_000_000, total: 63_000_000});
|
||||||
|
assert.match(h.states.at(-1).message, /100 procent/);
|
||||||
|
h.piperProgressCallbacks[0]({loaded: 10 * 1024 * 1024, total: 0});
|
||||||
|
assert.match(h.states.at(-1).message, /10 MB hämtade/);
|
||||||
|
});
|
||||||
|
|
||||||
test('Piper output keeps per-character pitch and rate stable', async () => {
|
test('Piper output keeps per-character pitch and rate stable', async () => {
|
||||||
const h = makeHarness({piper: true});
|
const h = makeHarness({piper: true});
|
||||||
h.controller.activateFromUserGesture();
|
h.controller.activateFromUserGesture();
|
||||||
|
|||||||
@@ -250,13 +250,58 @@
|
|||||||
setState('IDLE', 'Piper tog för lång tid på sig. Dialogen finns kvar som text. Tryck på Läs senaste repliken för att försöka igen.');
|
setState('IDLE', 'Piper tog för lång tid på sig. Dialogen finns kvar som text. Tryck på Läs senaste repliken för att försöka igen.');
|
||||||
}, 180000);
|
}, 180000);
|
||||||
|
|
||||||
let lastProgress = -1;
|
let lastProgressPercent = -1;
|
||||||
|
let lastProgressMegabytes = -1;
|
||||||
|
let lastProgressResource = '';
|
||||||
const progress = event => {
|
const progress = event => {
|
||||||
if (token !== generation || !event?.total) return;
|
if (token !== generation) return;
|
||||||
const percent = Math.min(100, Math.floor((event.loaded / event.total) * 100));
|
const resource = `${event?.asset || 'model'}:${event?.file || ''}`;
|
||||||
if (percent === lastProgress) return;
|
if (resource !== lastProgressResource) {
|
||||||
lastProgress = percent;
|
lastProgressResource = resource;
|
||||||
|
lastProgressPercent = -1;
|
||||||
|
lastProgressMegabytes = -1;
|
||||||
|
}
|
||||||
|
if (event?.asset === 'runtime' && Number.isFinite(event.loaded) && event.loaded >= 0) {
|
||||||
|
const file = /^[\w.-]+$/.test(event.file || '') ? event.file : 'WebAssembly-fil';
|
||||||
|
if (Number.isFinite(event.total) && event.total > 0) {
|
||||||
|
const percent = Math.max(0, Math.min(100, Math.floor((event.loaded / event.total) * 100)));
|
||||||
|
if (percent === lastProgressPercent) return;
|
||||||
|
lastProgressPercent = percent;
|
||||||
|
setState('PROCESSING', `Hämtar WebAssembly-filen ${file}: ${percent} procent.`);
|
||||||
|
} else {
|
||||||
|
const megabytes = Math.floor((event.loaded / (1024 * 1024)) * 10) / 10;
|
||||||
|
if (megabytes === lastProgressMegabytes) return;
|
||||||
|
lastProgressMegabytes = megabytes;
|
||||||
|
setState('PROCESSING', `Hämtar WebAssembly-filen ${file}: ${megabytes.toLocaleString('sv-SE')} MB hämtade.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stages = {
|
||||||
|
'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.',
|
||||||
|
'model-ready': 'Röstmodellen är inläst. Skapar uppläsningen.',
|
||||||
|
};
|
||||||
|
if (stages[event?.stage]) {
|
||||||
|
setState('PROCESSING', stages[event.stage]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(event?.loaded) || event.loaded < 0) return;
|
||||||
|
if (Number.isFinite(event.total) && event.total > 0) {
|
||||||
|
const percent = Math.max(0, Math.min(100, Math.floor((event.loaded / event.total) * 100)));
|
||||||
|
if (percent === lastProgressPercent) return;
|
||||||
|
lastProgressPercent = percent;
|
||||||
setState('PROCESSING', `Hämtar svensk röstmodell till enheten: ${percent} procent.`);
|
setState('PROCESSING', `Hämtar svensk röstmodell till enheten: ${percent} procent.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const megabytes = Math.floor((event.loaded / (1024 * 1024)) * 10) / 10;
|
||||||
|
if (megabytes === lastProgressMegabytes) return;
|
||||||
|
lastProgressMegabytes = megabytes;
|
||||||
|
setState('PROCESSING', `Hämtar svensk röstmodell till enheten: ${megabytes.toLocaleString('sv-SE')} MB hämtade.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
|
|||||||
Reference in New Issue
Block a user