diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 429f14d..d22ca03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: cache: npm # --ignore-scripts: skip transitive `sharp` native build (unused; browser only) - run: npm ci --ignore-scripts + - run: npm run test:unit - run: npm run build # Vendored ACE-Step runtime (packages/acestep): typecheck + weight-free diff --git a/package.json b/package.json index 36ad839..e287e47 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dicose:test": "npm run test --workspace dicose-wgsl", "build": "npm run acestep:build && npm run dicose:build && tsc --noEmit && vite build && node scripts/postbuild-strip-wasm.mjs", "preview": "vite preview", + "test:unit": "node --test test/*.test.mjs", "typecheck": "tsc --noEmit", "gpu:verify": "node scripts/gpu-verify.mjs", "gpu:bench": "node scripts/gpu-bench.mjs", @@ -56,8 +57,8 @@ "wasm:build": "cd rust/wasm-kernels && RUSTFLAGS='-C target-feature=+simd128' cargo build --release --target wasm32-unknown-unknown && cp target/wasm32-unknown-unknown/release/wasm_kernels.wasm ../../src/gpu/wasm-kernels.wasm", "wasm:verify": "node scripts/wasm-verify.mjs", "backend:parity": "node scripts/backend-parity.mjs", - "format": "prettier --write \"src/**/*.{ts,js}\" \"scripts/**/*.mjs\" \"*.ts\"", - "format:check": "prettier --check \"src/**/*.{ts,js}\" \"scripts/**/*.mjs\" \"*.ts\"", + "format": "prettier --write \"src/**/*.{ts,js}\" \"scripts/**/*.mjs\" \"test/**/*.mjs\" \"*.ts\"", + "format:check": "prettier --check \"src/**/*.{ts,js}\" \"scripts/**/*.mjs\" \"test/**/*.mjs\" \"*.ts\"", "ci:smoke": "node scripts/interface-conformance.mjs && node scripts/ci-smoke-parakeet.mjs && node scripts/ci-smoke-eou.mjs && node scripts/ci-smoke-kokoro.mjs", "sdk:build": "node scripts/build-sdk.mjs", "sdk:pack": "node scripts/build-sdk.mjs && cd dist-sdk && npm pack --pack-destination ..", diff --git a/src/core/loadProgress.d.ts b/src/core/loadProgress.d.ts new file mode 100644 index 0000000..af24440 --- /dev/null +++ b/src/core/loadProgress.d.ts @@ -0,0 +1,4 @@ +import type { LoadProgress } from "./types.js"; + +/** Format one model-load progress update for the browser UI. */ +export declare function formatLoadProgress(progress: LoadProgress): string; diff --git a/src/core/loadProgress.js b/src/core/loadProgress.js new file mode 100644 index 0000000..3cfd19b --- /dev/null +++ b/src/core/loadProgress.js @@ -0,0 +1,21 @@ +/** Format one model-load progress update for the browser UI. */ +export function formatLoadProgress(progress) { + const fraction = Number.isFinite(progress.fraction) ? Math.min(1, Math.max(0, progress.fraction)) : 0; + const percentage = Math.round(fraction * 100); + const file = progress.file || "model"; + + if (progress.phase !== "download") return `Loading ${file} — ${percentage}%`; + + const amount = formatDownloadAmount(progress.loaded, progress.total); + return `Downloading ${file} — ${percentage}%${amount ? ` (${amount})` : ""}`; +} + +function formatDownloadAmount(loaded, total) { + if (!Number.isFinite(loaded) || !Number.isFinite(total) || total <= 0) return ""; + + const units = ["B", "KB", "MB", "GB"]; + const unitIndex = Math.min(units.length - 1, Math.max(0, Math.floor(Math.log10(total) / 3))); + const divisor = 1000 ** unitIndex; + const decimals = total / divisor < 10 && unitIndex > 0 ? 1 : 0; + return `${(Math.max(0, loaded) / divisor).toFixed(decimals)} / ${(total / divisor).toFixed(decimals)} ${units[unitIndex]}`; +} diff --git a/src/core/modelCache.ts b/src/core/modelCache.ts index ee13d28..4d470d0 100644 --- a/src/core/modelCache.ts +++ b/src/core/modelCache.ts @@ -53,7 +53,7 @@ export async function fetchCached(url: string, onProgress?: ProgressCb, label = if (done) break; chunks.push(value); loaded += value.byteLength; - onProgress?.({ file: label, loaded, total, fraction: total ? loaded / total : 0 }); + onProgress?.({ file: label, phase: "download", loaded, total, fraction: total ? loaded / total : 0 }); } const bytes = concat(chunks, loaded); @@ -83,6 +83,7 @@ export async function fetchAll(files: { repo: string; path: string; revision?: s (p) => { onProgress?.({ file: f.path, + phase: p.phase, loaded: doneBytes + p.loaded, total: 0, fraction: (i + p.fraction) / files.length, diff --git a/src/core/types.ts b/src/core/types.ts index 3a96b76..76fc1f0 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -10,6 +10,8 @@ export interface AudioData { export interface LoadProgress { /** File or component currently loading. */ file: string; + /** Work currently represented by this update. Defaults to local loading. */ + phase?: "download" | "load"; /** Bytes fetched so far / total (total may be 0 if unknown). */ loaded: number; total: number; diff --git a/src/live.ts b/src/live.ts index 1a9599b..48e887e 100644 --- a/src/live.ts +++ b/src/live.ts @@ -9,6 +9,7 @@ initSiteTheme(); // last finalize boundary. import { ENGINES } from "./engines/registry.js"; +import { formatLoadProgress } from "./core/loadProgress.js"; import { MicCapture } from "./core/mic.js"; import type { Engine } from "./core/types.js"; @@ -114,7 +115,7 @@ async function start() { $("status").textContent = `loading ${c.label}…`; const e = await c.make(); await e.load((p) => { - $("status").textContent = `loading ${p.file ?? ""} ${Math.round((p.fraction || 0) * 100)}%`; + $("status").textContent = formatLoadProgress(p); }); engine = e as StreamingEngine; engineId = id; diff --git a/src/pages/playground.ts b/src/pages/playground.ts index f4e1fa6..c748f48 100644 --- a/src/pages/playground.ts +++ b/src/pages/playground.ts @@ -8,6 +8,7 @@ import { initSiteTheme } from "../theme/theme.js"; import { decodeToMono16k, pcmToWav } from "../core/audio.js"; import { segmentsToSrt, segmentsToVtt } from "../core/captions.js"; +import { formatLoadProgress } from "../core/loadProgress.js"; import { webgpuAvailable } from "../core/webgpu.js"; import { ENGINES, type EngineCategory, type EngineEntry } from "../engines/registry.js"; import { MicCapture } from "../core/mic.js"; @@ -147,7 +148,7 @@ export function initPlayground(opts: PlaygroundOptions) { status.textContent = `Loading ${entry.label}…`; await eng.load((p: LoadProgress) => { progress.value = p.fraction || 0; - status.textContent = `Loading ${p.file} — ${Math.round((p.fraction || 0) * 100)}%`; + status.textContent = formatLoadProgress(p); }); status.textContent = `Ready: ${entry.label}`; runBtn.disabled = false; diff --git a/test/load-progress.test.mjs b/test/load-progress.test.mjs new file mode 100644 index 0000000..604bbf5 --- /dev/null +++ b/test/load-progress.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatLoadProgress } from "../src/core/loadProgress.js"; + +test("labels streamed model bytes as a download with byte progress", () => { + assert.equal( + formatLoadProgress({ + file: "parakeet/encoder-int8.bin", + phase: "download", + loaded: 195_743_171, + total: 611_697_408, + fraction: 0.32, + }), + "Downloading parakeet/encoder-int8.bin — 32% (196 / 612 MB)", + ); +}); + +test("keeps local initialization labeled as loading", () => { + assert.equal(formatLoadProgress({ file: "WebGPU pipelines", phase: "load", loaded: 1, total: 4, fraction: 0.25 }), "Loading WebGPU pipelines — 25%"); +}); + +test("supports existing progress producers without a phase", () => { + assert.equal(formatLoadProgress({ file: "model", loaded: 1, total: 1, fraction: 1 }), "Loading model — 100%"); +}); + +test("omits byte totals when the server does not provide one", () => { + assert.equal(formatLoadProgress({ file: "weights.bin", phase: "download", loaded: 1024, total: 0, fraction: 0 }), "Downloading weights.bin — 0%"); +});