From 89c238bcea5bb7c8d8d9817da972f92ca296e868 Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Fri, 16 Oct 2026 08:07:44 +0200 Subject: [PATCH] Show Piper startup and download stages --- index.html | 17 +++++++++++------ piper-worker.js | 19 ++++++++++++++++--- scripts/check-frontend.sh | 2 ++ tests/voice.test.cjs | 2 ++ voice.js | 12 ++++++++++-- 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/index.html b/index.html index 3bf9859..258da01 100644 --- a/index.html +++ b/index.html @@ -170,19 +170,24 @@ 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 phase = [ + 'Laddar WebAssembly-motorn för svensk uppläsning.', + 'Laddar WebAssembly-filer för svensk uppläsning.', + '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\.$/); - if (!preparing && !starting && !match && !bytesMatch) { + if (!preparing && !phase && !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'; + label.textContent = phase || 'Hämtar svensk röstmodell till enheten'; if (match) { const percent = Math.max(0, Math.min(100, Number(match[1]))); progress.value = percent; @@ -195,7 +200,7 @@ return true; } progress.removeAttribute('value'); - value.textContent = starting ? 'Startar röstmotorn…' : 'Förbereder…'; + value.textContent = phase ? 'Pågår…' : 'Förbereder…'; return true; } function clearSpeechRecovery() { diff --git a/piper-worker.js b/piper-worker.js index 4cbef16..b00eeac 100644 --- a/piper-worker.js +++ b/piper-worker.js @@ -7,15 +7,27 @@ const VOICE_ID = 'sv_SE-nst-medium'; const ASSET_ROOT = '/static/piper-wasm-v1'; const localFetch = self.fetch.bind(self); +function reportStage(id, stage) { + if (Number.isSafeInteger(id)) self.postMessage({id, type: 'progress', progress: {stage}}); +} + // 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 = 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); + if (!localModelUrl) { + if (activeRequestId && requestedUrl.includes('/static/piper-wasm-v1/onnx/')) { + reportStage(activeRequestId, 'loading-runtime-assets'); + } + return localFetch(resource, options); + } + 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 response = await localFetch(redirected, options); - if (!activeRequestId || !localModelUrl.endsWith('.onnx')) return response; + if (!activeRequestId || !isModel) return response; + reportStage(activeRequestId, 'reading-model'); return trackModelDownload(response, { id: activeRequestId, postMessage: message => self.postMessage(message), @@ -47,9 +59,10 @@ 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'}}); + reportStage(id, 'loading-runtime'); try { const session = await getSession(); + reportStage(id, 'model-ready'); const blob = await session.predict(text); const audio = await blob.arrayBuffer(); self.postMessage({id, type: 'audio', audio}, [audio]); diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh index f6c0d0c..de4d311 100755 --- a/scripts/check-frontend.sh +++ b/scripts/check-frontend.sh @@ -32,6 +32,8 @@ 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 "addVoiceButton(item, 'Testa ljudet'" index.html grep -q 'Jag förstår – starta mikrofonen' index.html diff --git a/tests/voice.test.cjs b/tests/voice.test.cjs index 095cc6f..199c2a8 100644 --- a/tests/voice.test.cjs +++ b/tests/voice.test.cjs @@ -259,6 +259,8 @@ test('Piper model download progress is reported as a bounded percentage', async 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]({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/); diff --git a/voice.js b/voice.js index bf337e0..71cae5d 100644 --- a/voice.js +++ b/voice.js @@ -254,8 +254,16 @@ let lastProgressMegabytes = -1; const progress = event => { if (token !== generation) return; - if (event?.stage === 'starting') { - setState('PROCESSING', 'Startar svensk röstmotor och förbereder modellen.'); + const stages = { + 'loading-runtime': 'Laddar WebAssembly-motorn för svensk uppläsning.', + 'loading-runtime-assets': 'Laddar WebAssembly-filer för svensk uppläsning.', + '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;