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;