diff --git a/index.html b/index.html
index 49e5dd6..aecff6b 100644
--- a/index.html
+++ b/index.html
@@ -20,6 +20,8 @@
#message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
#message-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
#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"] fieldset { display: grid; gap: .5rem; margin-block: .5rem; }
#admin-content { display: grid; gap: 1rem; }
@@ -56,6 +58,11 @@
Tryck Enter för att skicka. Tryck Skift och Enter för ny rad.
+
+
+
+
+
Administration
@@ -157,6 +164,29 @@
const authVoiceStatus = document.querySelector('#auth-voice-status');
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 preparing = message.startsWith('Förbereder svensk uppläsning.');
+ const match = message.match(/^Hämtar svensk röstmodell till enheten: (\d+) procent\.$/);
+ if (!preparing && !match) {
+ container.hidden = true;
+ progress.removeAttribute('value');
+ value.textContent = 'Väntar på nedladdning';
+ return false;
+ }
+ container.hidden = false;
+ if (match) {
+ const percent = Math.max(0, Math.min(100, Number(match[1])));
+ progress.value = percent;
+ value.textContent = `${percent}%`;
+ return true;
+ }
+ progress.removeAttribute('value');
+ value.textContent = 'Förbereder…';
+ return true;
+ }
function clearSpeechRecovery() {
speechRecoveryElement?.remove();
speechRecoveryElement = null;
@@ -218,8 +248,15 @@
microphone.setAttribute('aria-pressed', String(listening));
microphone.textContent = listening ? 'Avsluta mikrofon' : 'Starta mikrofon';
refreshMicrophone();
- if (message) setVoiceStatus(message);
- else if (state === 'IDLE') setVoiceStatus('');
+ if (message) {
+ 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);
playVoiceSignal(state === 'IDLE' && /Mikrofonen är avstängd/.test(message) ? 'STOPPED' : state);
},
diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh
index 5ddce64..691084d 100755
--- a/scripts/check-frontend.sh
+++ b/scripts/check-frontend.sh
@@ -29,6 +29,8 @@ grep -q "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="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 "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 cb991e6..ae1e345 100644
--- a/tests/voice.test.cjs
+++ b/tests/voice.test.cjs
@@ -26,6 +26,7 @@ function makeHarness({supported = true, tts = true, piper = false, onTranscript
constructor(text) { this.text = text; utterances.push(this); }
}
const audioSources = [];
+ const piperProgressCallbacks = [];
class AudioContext {
constructor() { this.state = 'running'; this.destination = {}; }
resume() { this.state = 'running'; return Promise.resolve(); }
@@ -49,7 +50,10 @@ function makeHarness({supported = true, tts = true, piper = false, onTranscript
SpeechSynthesisUtterance: tts ? Utterance : undefined,
SpeechRecognition: supported ? Recognition : undefined,
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});
const controller = window.GameVoice.createVoiceController({
@@ -58,7 +62,7 @@ function makeHarness({supported = true, tts = true, piper = false, onTranscript
onTranscript,
});
return {
- controller, recognitions, utterances, synthesis, states, timers, audioSources,
+ controller, recognitions, utterances, synthesis, states, timers, audioSources, piperProgressCallbacks,
runTimer(delay) {
const match = [...timerDelays.entries()].find(([, value]) => value === delay);
if (!match) throw new Error(`No timer found with delay ${delay}`);
@@ -250,6 +254,18 @@ test('Piper WASM output uses the unlocked audio context and starts only after lo
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]({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/);
+});
+
test('Piper output keeps per-character pitch and rate stable', async () => {
const h = makeHarness({piper: true});
h.controller.activateFromUserGesture();
diff --git a/voice.js b/voice.js
index 123499f..4545a43 100644
--- a/voice.js
+++ b/voice.js
@@ -252,8 +252,8 @@
let lastProgress = -1;
const progress = event => {
- if (token !== generation || !event?.total) return;
- const percent = Math.min(100, Math.floor((event.loaded / event.total) * 100));
+ if (token !== generation || !Number.isFinite(event?.loaded) || !Number.isFinite(event?.total) || event.total <= 0) return;
+ const percent = Math.max(0, Math.min(100, Math.floor((event.loaded / event.total) * 100)));
if (percent === lastProgress) return;
lastProgress = percent;
setState('PROCESSING', `Hämtar svensk röstmodell till enheten: ${percent} procent.`);