Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab574acd39 | ||
|
|
0bee9f40d3 | ||
|
|
291fdcbf08 | ||
|
|
846bfec04d | ||
|
|
ccaa14896c | ||
|
|
d26d7f0b55 |
@@ -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.51
|
version: 0.1.54
|
||||||
appVersion: "0.1.51"
|
appVersion: "0.1.54"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ADR 0001: Run Piper in an ES module worker
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Bundle the Piper worker as an ES module and start it with `new Worker(url, { type: 'module' })`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Piper loads ONNX Runtime Web and its WebAssembly loader dynamically. The current IIFE/classic worker bundle reports `TypeError: "undefined" cannot be parsed as a URL` during WASM backend initialization, even though the pinned same-origin ONNX loader and binary are available. This is consistent with module-relative URL metadata being lost in the classic bundle.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The browser must support module workers. Current supported browser targets already require modern WebAssembly and worker support. CI asserts that the worker is built and started in module mode; production acceptance remains the real deployed browser flow.
|
||||||
+45
-2
@@ -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; }
|
||||||
@@ -56,6 +58,11 @@
|
|||||||
<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>
|
||||||
<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>
|
||||||
@@ -157,6 +164,35 @@
|
|||||||
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 preparing = message.startsWith('Förbereder svensk uppläsning.');
|
||||||
|
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\.$/);
|
||||||
|
if (!preparing && !match && !bytesMatch) {
|
||||||
|
container.hidden = true;
|
||||||
|
progress.removeAttribute('value');
|
||||||
|
value.textContent = 'Väntar på nedladdning';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
container.hidden = false;
|
||||||
|
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 = 'Förbereder…';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
function clearSpeechRecovery() {
|
function clearSpeechRecovery() {
|
||||||
speechRecoveryElement?.remove();
|
speechRecoveryElement?.remove();
|
||||||
speechRecoveryElement = null;
|
speechRecoveryElement = null;
|
||||||
@@ -218,8 +254,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);
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-1
@@ -6,7 +6,9 @@ const pending = new Map();
|
|||||||
|
|
||||||
function getWorker() {
|
function getWorker() {
|
||||||
if (worker) return worker;
|
if (worker) return worker;
|
||||||
worker = new Worker(workerUrl);
|
// ONNX Runtime resolves its WASM loader relative to the worker module URL.
|
||||||
|
// Keep the worker as ESM so import.meta.url remains available at runtime.
|
||||||
|
worker = new Worker(workerUrl, {type: 'module'});
|
||||||
worker.onmessage = event => {
|
worker.onmessage = event => {
|
||||||
const message = event.data || {};
|
const message = event.data || {};
|
||||||
const request = pending.get(message.id);
|
const request = pending.get(message.id);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ await build({
|
|||||||
entryPoints: [resolve(root, 'piper-worker.js')],
|
entryPoints: [resolve(root, 'piper-worker.js')],
|
||||||
outfile: join(workerRoot, 'piper-worker.js'),
|
outfile: join(workerRoot, 'piper-worker.js'),
|
||||||
bundle: true,
|
bundle: true,
|
||||||
format: 'iife',
|
format: 'esm',
|
||||||
platform: 'browser',
|
platform: 'browser',
|
||||||
target: ['es2022'],
|
target: ['es2022'],
|
||||||
external: ['fs', 'path'],
|
external: ['fs', 'path'],
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ 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 "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
|
||||||
@@ -45,7 +48,7 @@ grep -q 'COPY public/tts/piper-wasm-v1/' Dockerfile
|
|||||||
grep -q 'build:piper' .gitea/workflows/publish.yaml
|
grep -q 'build:piper' .gitea/workflows/publish.yaml
|
||||||
grep -q 'Piper' piper-runtime.js
|
grep -q 'Piper' piper-runtime.js
|
||||||
grep -q 'sv_SE-nst-medium' piper-worker.js
|
grep -q 'sv_SE-nst-medium' piper-worker.js
|
||||||
grep -q 'new Worker(workerUrl)' piper-runtime.js
|
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
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {readFile} = require('node:fs/promises');
|
||||||
|
const {join} = require('node:path');
|
||||||
|
|
||||||
|
test('Piper worker is built and started as an ES module', async () => {
|
||||||
|
const [buildScript, runtime] = await Promise.all([
|
||||||
|
readFile(join(__dirname, '..', 'scripts', 'build-piper.mjs'), 'utf8'),
|
||||||
|
readFile(join(__dirname, '..', 'piper-runtime.js'), 'utf8'),
|
||||||
|
]);
|
||||||
|
assert.match(buildScript, /outfile: join\(workerRoot, 'piper-worker\.js'\),\s*bundle: true,\s*format: 'esm'/);
|
||||||
|
assert.match(runtime, /new Worker\(workerUrl, \{type: 'module'\}\)/);
|
||||||
|
});
|
||||||
+20
-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,20 @@ 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]({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,21 @@
|
|||||||
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;
|
||||||
const progress = event => {
|
const progress = event => {
|
||||||
if (token !== generation || !event?.total) return;
|
if (token !== generation || !Number.isFinite(event?.loaded) || event.loaded < 0) return;
|
||||||
const percent = Math.min(100, Math.floor((event.loaded / event.total) * 100));
|
if (Number.isFinite(event.total) && event.total > 0) {
|
||||||
if (percent === lastProgress) return;
|
const percent = Math.max(0, Math.min(100, Math.floor((event.loaded / event.total) * 100)));
|
||||||
lastProgress = percent;
|
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