Compare commits

..
15 Commits
Author SHA1 Message Date
Gitea Actions b6deecae0a Release 0.1.58 [auto-version-bump] 2026-10-16 08:51:35 +02:00
Ein Anderssono a4f89f2b82 Use single-thread ONNX runtime for mobile speech
Build and publish frontend / publish (push) Successful in 50s
2026-10-16 08:51:08 +02:00
Gitea Actions aa832fd7cc Release 0.1.57 [auto-version-bump] 2026-10-16 08:46:23 +02:00
Ein Anderssono d788f8b350 Fix frontend check for download progress helper
Build and publish frontend / publish (push) Successful in 48s
2026-10-16 08:45:56 +02:00
Ein Anderssono 04bac2ba30 Track ONNX WebAssembly asset downloads
Build and publish frontend / publish (push) Failing after 3s
2026-10-16 08:44:38 +02:00
Gitea Actions 8e7c74bb41 Release 0.1.56 [auto-version-bump] 2026-10-16 08:08:12 +02:00
Ein Anderssono 89c238bcea Show Piper startup and download stages
Build and publish frontend / publish (push) Successful in 50s
2026-10-16 08:07:44 +02:00
Gitea Actions 81990d7129 Release 0.1.55 [auto-version-bump] 2026-10-16 08:02:41 +02:00
Ein Anderssono 7f77b904ac Track Piper model bytes in the worker
Build and publish frontend / publish (push) Successful in 47s
2026-10-16 08:02:17 +02:00
Gitea Actions ab574acd39 Release 0.1.54 [auto-version-bump] 2026-10-16 07:55:43 +02:00
Ein Anderssono 0bee9f40d3 Show download size when model length is unavailable
Build and publish frontend / publish (push) Successful in 49s
2026-10-16 07:55:18 +02:00
Gitea Actions 291fdcbf08 Release 0.1.53 [auto-version-bump] 2026-10-16 07:50:26 +02:00
Ein Anderssono 846bfec04d Show Piper model download progress
Build and publish frontend / publish (push) Successful in 47s
2026-10-16 07:49:58 +02:00
Gitea Actions ccaa14896c Release 0.1.52 [auto-version-bump] 2026-10-16 07:45:46 +02:00
Ein Anderssono d26d7f0b55 Run Piper ONNX in a module worker
Build and publish frontend / publish (push) Successful in 46s
2026-10-16 07:45:21 +02:00
16 changed files with 356 additions and 23 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.51 0.1.58
+2 -2
View File
@@ -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.58
appVersion: "0.1.51" appVersion: "0.1.58"
+13
View File
@@ -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.
@@ -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.
+72 -2
View File
@@ -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,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 +281,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);
}, },
+37
View File
@@ -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,
});
}
+7
View File
@@ -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;
}
+3 -1
View File
@@ -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);
+42 -7
View File
@@ -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;
} }
} }
+1 -1
View File
@@ -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'],
+10 -1
View File
@@ -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
@@ -45,11 +50,15 @@ 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
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
+59
View File
@@ -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',
});
});
+8
View File
@@ -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);
});
+13
View File
@@ -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'\}\)/);
});
+24 -2
View File
@@ -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();
+51 -6
View File
@@ -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;
setState('PROCESSING', `Hämtar svensk röstmodell till enheten: ${percent} procent.`); 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.`);
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()