diff --git a/index.html b/index.html
index 76a3db5..3bf9859 100644
--- a/index.html
+++ b/index.html
@@ -168,16 +168,21 @@
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 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 bytesMatch = message.match(/^Hämtar svensk röstmodell till enheten: ([\d.,]+) MB hämtade\.$/);
- if (!preparing && !match && !bytesMatch) {
+ if (!preparing && !starting && !match && !bytesMatch) {
container.hidden = true;
progress.removeAttribute('value');
value.textContent = 'Väntar på nedladdning';
return 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) {
const percent = Math.max(0, Math.min(100, Number(match[1])));
progress.value = percent;
@@ -190,7 +195,7 @@
return true;
}
progress.removeAttribute('value');
- value.textContent = 'Förbereder…';
+ value.textContent = starting ? 'Startar röstmotorn…' : 'Förbereder…';
return true;
}
function clearSpeechRecovery() {
diff --git a/piper-download-progress.mjs b/piper-download-progress.mjs
new file mode 100644
index 0000000..bcb7361
--- /dev/null
+++ b/piper-download-progress.mjs
@@ -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,
+ });
+}
diff --git a/piper-worker.js b/piper-worker.js
index 7225fd1..4cbef16 100644
--- a/piper-worker.js
+++ b/piper-worker.js
@@ -1,4 +1,5 @@
import {TtsSession} from '@mintplex-labs/piper-tts-web';
+import {trackModelDownload} from './piper-download-progress.mjs';
import {localPiperModelUrl} from './piper-model-routing.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
// 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 localModelUrl = localPiperModelUrl(requestedUrl, self.location.origin);
if (!localModelUrl) return localFetch(resource, options);
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 inferenceQueue = Promise.resolve();
+let activeRequestId = null;
-async function getSession(onProgress) {
+async function getSession() {
if (!sessionPromise) {
sessionPromise = TtsSession.create({
voiceId: VOICE_ID,
- progress: onProgress,
wasmPaths: {
onnxWasm: createOnnxWasmPaths(self.location.origin),
piperData: `${ASSET_ROOT}/piper/piper_phonemize.data`,
@@ -40,13 +46,17 @@ async function getSession(onProgress) {
async function synthesizeMessage(event) {
const {id, text} = event.data || {};
if (!Number.isSafeInteger(id) || typeof text !== 'string') return;
+ activeRequestId = id;
+ self.postMessage({id, type: 'progress', progress: {stage: 'starting'}});
try {
- const session = await getSession(progress => self.postMessage({id, type: 'progress', progress}));
+ const session = await getSession();
const blob = await session.predict(text);
const audio = await blob.arrayBuffer();
self.postMessage({id, type: 'audio', audio}, [audio]);
} catch (error) {
self.postMessage({id, type: 'error', error: String(error?.message || error)});
+ } finally {
+ activeRequestId = null;
}
}
diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh
index 1d0071f..f6c0d0c 100755
--- a/scripts/check-frontend.sh
+++ b/scripts/check-frontend.sh
@@ -52,6 +52,8 @@ grep -q "new Worker(workerUrl, {type: 'module'})" piper-runtime.js
grep -q 'worker.js?v=' piper-runtime.js
grep -q 'worker.onmessageerror' piper-runtime.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 '/static/piper-wasm-v1/onnx/' piper-onnx-config.mjs
grep -q 'PIPER_MODEL_ORIGIN' piper-model-routing.mjs
diff --git a/tests/piper-download-progress.test.cjs b/tests/piper-download-progress.test.cjs
new file mode 100644
index 0000000..87c40f6
--- /dev/null
+++ b/tests/piper-download-progress.test.cjs
@@ -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}]);
+});
diff --git a/voice.js b/voice.js
index 3ea624a..bf337e0 100644
--- a/voice.js
+++ b/voice.js
@@ -253,7 +253,12 @@
let lastProgressPercent = -1;
let lastProgressMegabytes = -1;
const progress = event => {
- if (token !== generation || !Number.isFinite(event?.loaded) || event.loaded < 0) return;
+ if (token !== generation) return;
+ if (event?.stage === 'starting') {
+ setState('PROCESSING', 'Startar svensk röstmotor och förbereder modellen.');
+ 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;