Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81990d7129 | ||
|
|
7f77b904ac | ||
|
|
ab574acd39 | ||
|
|
0bee9f40d3 |
@@ -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.53
|
version: 0.1.55
|
||||||
appVersion: "0.1.53"
|
appVersion: "0.1.55"
|
||||||
+13
-2
@@ -168,23 +168,34 @@
|
|||||||
const container = document.querySelector('#speech-progress-container');
|
const container = document.querySelector('#speech-progress-container');
|
||||||
const progress = document.querySelector('#speech-progress');
|
const progress = document.querySelector('#speech-progress');
|
||||||
const value = document.querySelector('#speech-progress-value');
|
const value = document.querySelector('#speech-progress-value');
|
||||||
|
const label = container.querySelector('label');
|
||||||
const preparing = message.startsWith('Förbereder svensk uppläsning.');
|
const preparing = message.startsWith('Förbereder svensk uppläsning.');
|
||||||
|
const starting = message.startsWith('Startar svensk röstmotor och förbereder modellen.');
|
||||||
const match = message.match(/^Hämtar svensk röstmodell till enheten: (\d+) procent\.$/);
|
const match = message.match(/^Hämtar svensk röstmodell till enheten: (\d+) procent\.$/);
|
||||||
if (!preparing && !match) {
|
const bytesMatch = message.match(/^Hämtar svensk röstmodell till enheten: ([\d.,]+) MB hämtade\.$/);
|
||||||
|
if (!preparing && !starting && !match && !bytesMatch) {
|
||||||
container.hidden = true;
|
container.hidden = true;
|
||||||
progress.removeAttribute('value');
|
progress.removeAttribute('value');
|
||||||
value.textContent = 'Väntar på nedladdning';
|
value.textContent = 'Väntar på nedladdning';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
container.hidden = false;
|
container.hidden = false;
|
||||||
|
label.textContent = starting
|
||||||
|
? 'Startar och läser in den svenska röstmodellen'
|
||||||
|
: 'Hämtar svensk röstmodell till enheten';
|
||||||
if (match) {
|
if (match) {
|
||||||
const percent = Math.max(0, Math.min(100, Number(match[1])));
|
const percent = Math.max(0, Math.min(100, Number(match[1])));
|
||||||
progress.value = percent;
|
progress.value = percent;
|
||||||
value.textContent = `${percent}%`;
|
value.textContent = `${percent}%`;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (bytesMatch) {
|
||||||
|
progress.removeAttribute('value');
|
||||||
|
value.textContent = `${bytesMatch[1]} MB hämtade`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
progress.removeAttribute('value');
|
progress.removeAttribute('value');
|
||||||
value.textContent = 'Förbereder…';
|
value.textContent = starting ? 'Startar röstmotorn…' : 'Förbereder…';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function clearSpeechRecovery() {
|
function clearSpeechRecovery() {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export function trackModelDownload(response, {id, postMessage}) {
|
||||||
|
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) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loaded += value.byteLength;
|
||||||
|
postMessage({id, type: 'progress', progress: {loaded, total}});
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+15
-5
@@ -1,4 +1,5 @@
|
|||||||
import {TtsSession} from '@mintplex-labs/piper-tts-web';
|
import {TtsSession} from '@mintplex-labs/piper-tts-web';
|
||||||
|
import {trackModelDownload} 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 {createOnnxWasmPaths} from './piper-onnx-config.mjs';
|
||||||
|
|
||||||
@@ -8,22 +9,27 @@ const localFetch = self.fetch.bind(self);
|
|||||||
|
|
||||||
// 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) return localFetch(resource, options);
|
||||||
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 || !localModelUrl.endsWith('.onnx')) return response;
|
||||||
|
return trackModelDownload(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) {
|
||||||
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 +46,17 @@ 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;
|
||||||
|
self.postMessage({id, type: 'progress', progress: {stage: 'starting'}});
|
||||||
try {
|
try {
|
||||||
const session = await getSession(progress => self.postMessage({id, type: 'progress', progress}));
|
const session = await getSession();
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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" aria-label="Nedladdning av svensk röstmodell"' index.html
|
||||||
grep -q 'id="speech-progress-value"' 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
|
||||||
@@ -51,6 +52,8 @@ 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 'trackModelDownload' 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 '/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
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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 {trackModelDownload} = 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 = trackModelDownload(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), [
|
||||||
|
{loaded: 2, total: 5},
|
||||||
|
{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 {trackModelDownload} = await import('../piper-download-progress.mjs');
|
||||||
|
const source = new Response(new Uint8Array([1, 2, 3]));
|
||||||
|
const messages = [];
|
||||||
|
const tracked = trackModelDownload(source, {id: 9, postMessage: message => messages.push(message)});
|
||||||
|
|
||||||
|
assert.equal((await tracked.arrayBuffer()).byteLength, 3);
|
||||||
|
assert.deepEqual(messages.map(message => message.progress), [{loaded: 3, total: 0}]);
|
||||||
|
});
|
||||||
@@ -264,6 +264,8 @@ test('Piper model download progress is reported as a bounded percentage', async
|
|||||||
assert.match(h.states.at(-1).message, /50 procent/);
|
assert.match(h.states.at(-1).message, /50 procent/);
|
||||||
h.piperProgressCallbacks[0]({loaded: 90_000_000, total: 63_000_000});
|
h.piperProgressCallbacks[0]({loaded: 90_000_000, total: 63_000_000});
|
||||||
assert.match(h.states.at(-1).message, /100 procent/);
|
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 () => {
|
||||||
|
|||||||
@@ -250,13 +250,26 @@
|
|||||||
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 || !Number.isFinite(event?.loaded) || !Number.isFinite(event?.total) || event.total <= 0) return;
|
if (token !== generation) return;
|
||||||
const percent = Math.max(0, Math.min(100, Math.floor((event.loaded / event.total) * 100)));
|
if (event?.stage === 'starting') {
|
||||||
if (percent === lastProgress) return;
|
setState('PROCESSING', 'Startar svensk röstmotor och förbereder modellen.');
|
||||||
lastProgress = percent;
|
return;
|
||||||
setState('PROCESSING', `Hämtar svensk röstmodell till enheten: ${percent} procent.`);
|
}
|
||||||
|
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.`);
|
||||||
|
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