Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab574acd39 | ||
|
|
0bee9f40d3 | ||
|
|
291fdcbf08 | ||
|
|
846bfec04d | ||
|
|
ccaa14896c | ||
|
|
d26d7f0b55 | ||
|
|
874b9be841 | ||
|
|
d3b5308097 |
@@ -3,6 +3,7 @@
|
|||||||
- This repository owns only the static, accessible web client and its container/chart packaging.
|
- This repository owns only the static, accessible web client and its container/chart packaging.
|
||||||
- Rust remains the sole authority for game state. The frontend calls the backend through same-origin `/api/...` and `/version` paths.
|
- Rust remains the sole authority for game state. The frontend calls the backend through same-origin `/api/...` and `/version` paths.
|
||||||
- Keep frontend and backend behind `ai-spel.k3s.k0.nu` so session cookies remain same-origin.
|
- Keep frontend and backend behind `ai-spel.k3s.k0.nu` so session cookies remain same-origin.
|
||||||
|
- The public footer displays frontend, static-service, and API versions; load static-service version from uncached `/static/version.json`.
|
||||||
- `VERSION` is the only release-version source in this repository. Use `scripts/bump-version.py`; do not manually edit chart versions.
|
- `VERSION` is the only release-version source in this repository. Use `scripts/bump-version.py`; do not manually edit chart versions.
|
||||||
- Changes are written locally and built, checked, published, and exercised by Gitea Actions on the existing `large` runner and Kubernetes. Never run apps, tests, containers, images, or binaries locally. Do not create or register runners.
|
- Changes are written locally and built, checked, published, and exercised by Gitea Actions on the existing `large` runner and Kubernetes. Never run apps, tests, containers, images, or binaries locally. Do not create or register runners.
|
||||||
- Never commit secrets, generated credentials, kubeconfigs, or environment files.
|
- Never commit secrets, generated credentials, kubeconfigs, or environment files.
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ On iPhone and iPad, Safari requires a user gesture to unlock Web Audio. The fron
|
|||||||
|
|
||||||
The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/version`, and `/health` to the Rust service. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration.
|
The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/version`, and `/health` to the Rust service. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration.
|
||||||
|
|
||||||
|
The public footer displays the frontend, static-service, and API versions. It fetches `/static/version.json` without caching; the static service creates that file from its sole `VERSION` source during the same image build used for the matching Helm chart and release tag.
|
||||||
|
|
||||||
## Release flow
|
## Release flow
|
||||||
|
|
||||||
`VERSION` is the only version source for this repository. Gitea Actions on the existing `large` runner runs the static checks, Helm lint/template, publishes a commit image, bumps the patch version through `scripts/bump-version.py`, creates the matching release commit and Git tag, and publishes the release image, Helm chart, and rendered frontend manifest to Harbor. Argo CD tracks the newest published frontend chart. The image and chart releases use the same version as the commit and tag.
|
`VERSION` is the only version source for this repository. Gitea Actions on the existing `large` runner runs the static checks, Helm lint/template, publishes a commit image, bumps the patch version through `scripts/bump-version.py`, creates the matching release commit and Git tag, and publishes the release image, Helm chart, and rendered frontend manifest to Harbor. Argo CD tracks the newest published frontend chart. The image and chart releases use the same version as the commit and tag.
|
||||||
|
|||||||
@@ -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.50
|
version: 0.1.54
|
||||||
appVersion: "0.1.50"
|
appVersion: "0.1.54"
|
||||||
@@ -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.
|
||||||
+51
-3
@@ -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>
|
||||||
@@ -71,7 +78,7 @@
|
|||||||
<div id="admin-content" aria-live="polite" aria-busy="false"></div>
|
<div id="admin-content" aria-live="polite" aria-busy="false"></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<footer id="version-footer">Frontend <span id="frontend-version">__FRONTEND_VERSION__</span> · API <span id="api-version">hämtar version…</span></footer>
|
<footer id="version-footer">Frontend <span id="frontend-version">__FRONTEND_VERSION__</span> · Statik <span id="static-version">hämtar version…</span> · API <span id="api-version">hämtar version…</span></footer>
|
||||||
<script src="/piper-runtime.js?v=__FRONTEND_VERSION__"></script>
|
<script src="/piper-runtime.js?v=__FRONTEND_VERSION__"></script>
|
||||||
<script src="/voice.js?v=__FRONTEND_VERSION__"></script>
|
<script src="/voice.js?v=__FRONTEND_VERSION__"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -157,6 +164,35 @@
|
|||||||
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 preparing = message.startsWith('Förbereder svensk uppläsning.');
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (bytesMatch) {
|
||||||
|
progress.removeAttribute('value');
|
||||||
|
value.textContent = `${bytesMatch[1]} MB hämtade`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
progress.removeAttribute('value');
|
||||||
|
value.textContent = 'Förbereder…';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
function clearSpeechRecovery() {
|
function clearSpeechRecovery() {
|
||||||
speechRecoveryElement?.remove();
|
speechRecoveryElement?.remove();
|
||||||
speechRecoveryElement = null;
|
speechRecoveryElement = null;
|
||||||
@@ -218,8 +254,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);
|
||||||
},
|
},
|
||||||
@@ -863,6 +906,11 @@
|
|||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
document.querySelector('#api-version').textContent = 'otillgänglig';
|
document.querySelector('#api-version').textContent = 'otillgänglig';
|
||||||
});
|
});
|
||||||
|
request('/static/version.json', {cache: 'no-store'}).then(({version}) => {
|
||||||
|
document.querySelector('#static-version').textContent = version;
|
||||||
|
}).catch(() => {
|
||||||
|
document.querySelector('#static-version').textContent = 'otillgänglig';
|
||||||
|
});
|
||||||
request('/api/me').then(() => location.pathname === '/admin' ? openAdmin() : openGame()).catch(showAuth);
|
request('/api/me').then(() => location.pathname === '/admin' ? openAdmin() : openGame()).catch(showAuth);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+3
-1
@@ -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);
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ grep -q 'add_header Cross-Origin-Embedder-Policy "require-corp" always;' nginx/d
|
|||||||
grep -q 'add_header Cross-Origin-Resource-Policy "same-origin" always;' nginx/default.conf
|
grep -q 'add_header Cross-Origin-Resource-Policy "same-origin" always;' nginx/default.conf
|
||||||
grep -q 'application/javascript js mjs;' nginx/default.conf
|
grep -q 'application/javascript js mjs;' nginx/default.conf
|
||||||
grep -q "request('/version')" index.html
|
grep -q "request('/version')" index.html
|
||||||
|
grep -q 'id="static-version">hämtar version…' index.html
|
||||||
|
grep -q "request('/static/version.json', {cache: 'no-store'})" index.html
|
||||||
grep -Fq "credentials: 'same-origin'" index.html
|
grep -Fq "credentials: 'same-origin'" index.html
|
||||||
grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html
|
grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html
|
||||||
grep -q 'id="message" name="message"' index.html
|
grep -q 'id="message" name="message"' index.html
|
||||||
@@ -27,6 +29,9 @@ 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 "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
|
||||||
@@ -43,7 +48,7 @@ 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
|
||||||
|
|||||||
@@ -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'\}\)/);
|
||||||
|
});
|
||||||
+20
-2
@@ -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,20 @@ 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]({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();
|
||||||
|
|||||||
@@ -250,13 +250,21 @@
|
|||||||
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;
|
||||||
const progress = event => {
|
const progress = event => {
|
||||||
if (token !== generation || !event?.total) return;
|
if (token !== generation || !Number.isFinite(event?.loaded) || event.loaded < 0) return;
|
||||||
const percent = Math.min(100, Math.floor((event.loaded / event.total) * 100));
|
if (Number.isFinite(event.total) && event.total > 0) {
|
||||||
if (percent === lastProgress) return;
|
const percent = Math.max(0, Math.min(100, Math.floor((event.loaded / event.total) * 100)));
|
||||||
lastProgress = percent;
|
if (percent === lastProgressPercent) return;
|
||||||
|
lastProgressPercent = percent;
|
||||||
setState('PROCESSING', `Hämtar svensk röstmodell till enheten: ${percent} procent.`);
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user