Transcript export (.txt/.srt) and model storage manager (M6)

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-07-14 19:41:47 +02:00
co-authored by Claude Fable 5
parent 0084e0435a
commit 8f2da530e2
9 changed files with 743 additions and 12 deletions
+19 -2
View File
@@ -223,8 +223,25 @@ Notes (M5):
- [x] Cancel in-flight transcription (both engines) — browser engine via
worker termination (D-018), server engine via aborting the
`/api/transcribe` fetch (D-018 amendment; landed with M5's EngineToggle)
- [ ] Export transcript: .txt and .srt (timestamps already available)
- [ ] Storage management UI: list cached models, per-model delete
- [x] Export transcript: .txt and .srt (timestamps already available) —
pure formatters in `src/lib/export.ts` (unit-tested: SRT cue numbering,
HH:MM:SS,mmm timestamps, null-end segments borrow the next cue's start
or fall back to +2 s, filename derived from the source file); .txt/.srt
download buttons next to Copy in `TranscriptView`. Live-mode results
reuse the same `result` state with absolute timestamps (D-016), so both
flows export identically.
- [x] Storage management UI: list cached models, per-model delete —
collapsible "Storage" panel (`StorageManager`) listing cached models
with exact on-disk size (summed `blob().size`, measured lazily on
open) and per-model delete; delete is confirm-free (weights are just
cache — they re-download on next use) and refreshes the picker's
downloaded/offline badges. Helpers in `src/lib/modelCache.ts` take an
injectable Cache-Storage-shaped object (mocked in
`tests/modelCache.test.ts`). Scope is the transformers-cache only:
the SW's `whisper-runtime-*` cache (onnxruntime .wasm, shared by all
models, versioned/cleaned by the SW itself) is deliberately not
surfaced — deleting it per-model makes no sense and the SW would
just re-fetch it.
- [ ] Basic a11y pass (keyboard, focus, aria on progress/live transcript)
- [ ] Production build + `bun start` deployment check; README for deployers
+9
View File
@@ -7,6 +7,7 @@ import { FileDropzone } from "./components/FileDropzone";
import { LiveControls, type LiveStatus } from "./components/LiveControls";
import { ModelLoadProgress } from "./components/ModelLoadProgress";
import { ModelPicker } from "./components/ModelPicker";
import { StorageManager } from "./components/StorageManager";
import { TranscriptView } from "./components/TranscriptView";
import { TranscriptionOptions } from "./components/TranscriptionOptions";
import { decodeAudioFile } from "./lib/audio";
@@ -626,6 +627,7 @@ export function App() {
result={result}
transcribing={liveActive || transcribing}
progress={liveActive ? null : transcribeProgress}
exportName={fileName}
placeholder={
liveActive ? "Listening for the first words…" : "Processing audio…"
}
@@ -654,6 +656,13 @@ export function App() {
</div>
)}
{/* Storage management (M6): cached models with per-model delete. */}
<StorageManager
downloaded={downloaded}
disabled={busy || liveActive}
onChanged={refreshDownloaded}
/>
<footer className="app-footer">
<p>
Model: <code>{modelId}</code> · Engine:{" "}
+107
View File
@@ -0,0 +1,107 @@
import { useCallback, useEffect, useState } from "react";
import {
deleteModelFromCache,
getModelStorageInfo,
type ModelStorageInfo,
} from "../lib/modelCache";
import { MODELS, getModel } from "../lib/models";
interface StorageManagerProps {
/** Downloaded-badge set from App — changes re-measure an open panel. */
downloaded: ReadonlySet<string>;
/** True while transcribing/live — weights may be mid-read, so no deletes. */
disabled: boolean;
/** Called after a delete so App refreshes the downloaded badges (D-005). */
onChanged: () => void;
}
const MODEL_IDS = MODELS.map((model) => model.id);
function formatBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} kB`;
return `${bytes} B`;
}
/**
* Storage management panel (M6): lists models with cached weights and their
* exact on-disk size, with per-model delete. Collapsed by default — measuring
* reads every cached response body, so it only runs when the panel is opened
* (and again when the downloaded set changes or a model is deleted).
*/
export function StorageManager({ downloaded, disabled, onChanged }: StorageManagerProps) {
const [open, setOpen] = useState(false);
const [info, setInfo] = useState<ModelStorageInfo[] | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const measure = useCallback(() => {
getModelStorageInfo(MODEL_IDS).then(setInfo, () => setInfo([]));
}, []);
useEffect(() => {
if (open) measure();
}, [open, downloaded, measure]);
async function handleDelete(modelId: string) {
setDeleting(modelId);
try {
await deleteModelFromCache(modelId);
measure();
// Refresh the picker's downloaded/offline badges too.
onChanged();
} finally {
setDeleting(null);
}
}
const totalBytes = (info ?? []).reduce((sum, entry) => sum + entry.bytes, 0);
return (
<details
className="storage-manager"
onToggle={(event) => setOpen(event.currentTarget.open)}
>
<summary>
Storage
{open && info !== null && info.length > 0 && (
<span className="storage-total"> — {formatBytes(totalBytes)} used</span>
)}
</summary>
<p className="storage-hint">
Model weights cached in this browser. Deleting frees the space; the
model simply re-downloads the next time you use it.
</p>
{info === null ? (
<p className="storage-hint">Measuring…</p>
) : info.length === 0 ? (
<p className="storage-hint">No models downloaded yet.</p>
) : (
<ul className="storage-list">
{info.map((entry) => (
<li key={entry.modelId} className="storage-row">
<div className="storage-model">
<span className="storage-name">
{getModel(entry.modelId)?.displayName ?? entry.modelId}
</span>
<code className="storage-id">{entry.modelId}</code>
</div>
<span className="storage-size">
{formatBytes(entry.bytes)} · {entry.fileCount}{" "}
{entry.fileCount === 1 ? "file" : "files"}
</span>
<button
type="button"
className="storage-delete"
onClick={() => void handleDelete(entry.modelId)}
disabled={disabled || deleting !== null}
>
{deleting === entry.modelId ? "Deleting…" : "Delete"}
</button>
</li>
))}
</ul>
)}
</details>
);
}
+49
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { exportFilename, transcriptToSrt, transcriptToTxt } from "../lib/export";
import type { TranscriptChunk } from "../worker/protocol";
interface TranscriptViewProps {
@@ -23,6 +24,21 @@ interface TranscriptViewProps {
progress?: { processedSeconds: number; totalSeconds: number } | null;
/** Text shown before the first words arrive. */
placeholder?: string;
/**
* Source file name the export filenames derive from ("talk.m4a" →
* "talk.txt"/"talk.srt"); null falls back to "transcript.*".
*/
exportName?: string | null;
}
/** Trigger a browser download of `contents` as `filename`. */
function download(contents: string, filename: string, mime: string) {
const url = URL.createObjectURL(new Blob([contents], { type: mime }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function formatTimestamp(seconds: number): string {
@@ -40,6 +56,7 @@ export function TranscriptView({
activeLabel = "Transcribing…",
progress = null,
placeholder = "Listening for the first words…",
exportName = null,
}: TranscriptViewProps) {
const [copied, setCopied] = useState(false);
const text = result?.text ?? partialText;
@@ -73,6 +90,37 @@ export function TranscriptView({
<span className="transcript-percent"> {percent}%</span>
)}
</h2>
<div className="transcript-actions">
{result && (
<button
type="button"
className="copy-button"
onClick={() =>
download(
transcriptToTxt(result),
exportFilename(exportName, "txt"),
"text/plain",
)
}
>
.txt
</button>
)}
{result && result.chunks.length > 0 && (
<button
type="button"
className="copy-button"
onClick={() =>
download(
transcriptToSrt(result.chunks),
exportFilename(exportName, "srt"),
"application/x-subrip",
)
}
>
.srt
</button>
)}
<button
type="button"
className="copy-button"
@@ -82,6 +130,7 @@ export function TranscriptView({
{copied ? "Copied!" : "Copy"}
</button>
</div>
</div>
{showProgress && (
<div className="transcript-progress">
<progress
+100
View File
@@ -717,3 +717,103 @@ code {
animation: none !important;
}
}
/* --------------------------------------------------------------------------
Storage management panel (M6): cached models, per-model delete.
-------------------------------------------------------------------------- */
.storage-manager {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.75rem 1rem;
}
.storage-manager summary {
cursor: pointer;
font-weight: 600;
}
.storage-total {
color: var(--muted);
font-weight: normal;
font-variant-numeric: tabular-nums;
}
.storage-hint {
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.storage-list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.storage-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.storage-model {
flex: 1 1 14rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.storage-name {
font-size: 0.95rem;
}
.storage-id {
font-size: 0.75rem;
color: var(--muted);
background: transparent;
padding: 0;
overflow-wrap: anywhere;
}
.storage-size {
color: var(--muted);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.storage-delete {
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 8px;
padding: 0.3rem 0.9rem;
font-size: 0.85rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.storage-delete:hover:enabled {
border-color: #8b3a3a;
color: #f2b8b5;
}
.storage-delete:disabled {
opacity: 0.5;
cursor: default;
}
/* --- Transcript export (M6) — .txt/.srt buttons grouped with Copy --- */
.transcript-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Transcript export to plain text and SubRip (M6). Pure functions — no DOM —
* so the formats are unit-testable under `bun test`; the download plumbing
* (Blob + anchor click) lives in TranscriptView.
*/
import type { TranscriptChunk } from "../worker/protocol";
/**
* Cue length used when Whisper never closed the final segment
* (`timestamp[1] === null`) and there is no next chunk to borrow a start time
* from. Two seconds is roughly the floor of a spoken Whisper segment — long
* enough to read, short enough not to linger over silence.
*/
const FALLBACK_CUE_SECONDS = 2;
/** Final transcript as plain text: trimmed, single trailing newline. */
export function transcriptToTxt(result: { text: string }): string {
return `${result.text.trim()}\n`;
}
/** Seconds → SRT timestamp, e.g. 3661.5 → "01:01:01,500". */
export function formatSrtTimestamp(seconds: number): string {
const totalMs = Math.max(0, Math.round(seconds * 1000));
const ms = totalMs % 1000;
const totalS = (totalMs - ms) / 1000;
const s = totalS % 60;
const totalM = (totalS - s) / 60;
const m = totalM % 60;
const h = (totalM - m) / 60;
const pad = (value: number, width: number) => String(value).padStart(width, "0");
return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(ms, 3)}`;
}
/**
* Timestamped chunks → SubRip. Empty-text chunks are skipped (they carry no
* caption). A null end time — Whisper sometimes never closes the last
* segment — borrows the next chunk's start, or falls back to
* start + FALLBACK_CUE_SECONDS on the final chunk; either way the cue never
* ends before it starts.
*/
export function transcriptToSrt(chunks: TranscriptChunk[]): string {
const cues: string[] = [];
const captioned = chunks.filter((chunk) => chunk.text.trim() !== "");
captioned.forEach((chunk, index) => {
const start = chunk.timestamp[0];
const end =
chunk.timestamp[1] ??
captioned[index + 1]?.timestamp[0] ??
start + FALLBACK_CUE_SECONDS;
cues.push(
[
String(index + 1),
`${formatSrtTimestamp(start)} --> ${formatSrtTimestamp(Math.max(start, end))}`,
chunk.text.trim(),
].join("\n"),
);
});
return cues.length === 0 ? "" : `${cues.join("\n\n")}\n`;
}
/**
* Download filename for an export: the source file's base name with the
* extension swapped ("talk.m4a" → "talk.srt"), or "transcript.<ext>" when no
* usable source name is known.
*/
export function exportFilename(sourceName: string | null | undefined, ext: string): string {
const trimmed = sourceName?.trim() ?? "";
if (trimmed === "") return `transcript.${ext}`;
const lastDot = trimmed.lastIndexOf(".");
// Dot at position 0 is a hidden file (".bashrc"), not an extension.
const base = lastDot > 0 ? trimmed.slice(0, lastDot) : trimmed;
return base === "" ? `transcript.${ext}` : `${base}.${ext}`;
}
+120
View File
@@ -58,3 +58,123 @@ export async function getDownloadedModels(
return new Set();
}
}
// ---------------------------------------------------------------------------
// Storage management (M6): per-model size + delete.
//
// The functions below take an injectable Cache-Storage-shaped parameter so the
// logic is unit-testable under `bun test` (no `caches` global there); the
// structural types cover exactly the subset of the Cache API we touch, and the
// real `caches` satisfies them. Scope is the transformers-cache only — the
// service worker's `whisper-runtime-*` cache (onnxruntime .wasm, shared by all
// models and versioned/cleaned by the SW itself) is deliberately not surfaced.
// ---------------------------------------------------------------------------
/** The slice of `Cache` the storage helpers use. */
export interface StorageCache {
keys(): Promise<ReadonlyArray<{ url: string }>>;
match(
request: { url: string },
): Promise<{ blob(): Promise<{ size: number }> } | undefined>;
delete(request: { url: string }): Promise<boolean>;
}
/** The slice of `CacheStorage` the storage helpers use. */
export interface StorageCacheStorage {
keys(): Promise<string[]>;
open(name: string): Promise<StorageCache>;
}
/** Per-model cache usage, for the storage management panel. */
export interface ModelStorageInfo {
modelId: string;
/** Number of cached files (weights + tokenizer/config aux files). */
fileCount: number;
/** Exact stored bytes, summed from the cached response bodies. */
bytes: number;
}
/**
* Pure matcher: which catalog model owns a cached URL? Same segment-boundary
* rule as `matchCachedModels` (`/<modelId>/`), so `.en` variants never match
* their multilingual sibling. Any file counts here — a partially downloaded
* model still occupies storage and must be listable/deletable.
*/
export function modelIdForUrl(
url: string,
modelIds: readonly string[],
): string | undefined {
return modelIds.find((modelId) => url.includes(`/${modelId}/`));
}
function defaultCacheStorage(): StorageCacheStorage | undefined {
// The cast is sound: we only ever hand `match`/`delete` the `Request`
// objects that `keys()` itself returned (lib.dom types the params wider).
return typeof caches === "undefined"
? undefined
: (caches as unknown as StorageCacheStorage);
}
/**
* Measure cached storage per model: file count and exact byte size (summed
* `blob().size` of every cached response). Models with nothing cached are
* omitted. Deliberately lazy/heavier than `getDownloadedModels` — it reads
* every cached body, so call it when the user opens the storage panel, not on
* every render. Returns [] where Cache Storage is unavailable.
*/
export async function getModelStorageInfo(
modelIds: readonly string[],
cacheStorage: StorageCacheStorage | undefined = defaultCacheStorage(),
): Promise<ModelStorageInfo[]> {
if (!cacheStorage) return [];
try {
const byModel = new Map<string, ModelStorageInfo>();
for (const name of await cacheStorage.keys()) {
if (!name.includes("transformers")) continue;
const cache = await cacheStorage.open(name);
for (const request of await cache.keys()) {
const modelId = modelIdForUrl(request.url, modelIds);
if (modelId === undefined) continue;
const response = await cache.match(request);
if (!response) continue;
const size = (await response.blob()).size;
const info = byModel.get(modelId) ?? { modelId, fileCount: 0, bytes: 0 };
info.fileCount += 1;
info.bytes += size;
byModel.set(modelId, info);
}
}
// Catalog order, so the panel lists models the same way the picker does.
return modelIds
.map((modelId) => byModel.get(modelId))
.filter((info): info is ModelStorageInfo => info !== undefined);
} catch {
return [];
}
}
/**
* Delete every cached file belonging to `modelId` (weights and aux files)
* from the transformers cache(s). Returns true if anything was deleted. The
* weights re-download on the model's next use — nothing but cache lives here.
*/
export async function deleteModelFromCache(
modelId: string,
cacheStorage: StorageCacheStorage | undefined = defaultCacheStorage(),
): Promise<boolean> {
if (!cacheStorage) return false;
try {
let deleted = false;
for (const name of await cacheStorage.keys()) {
if (!name.includes("transformers")) continue;
const cache = await cacheStorage.open(name);
for (const request of await cache.keys()) {
if (modelIdForUrl(request.url, [modelId]) !== modelId) continue;
if (await cache.delete(request)) deleted = true;
}
}
return deleted;
} catch {
return false;
}
}
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, test } from "bun:test";
import {
exportFilename,
formatSrtTimestamp,
transcriptToSrt,
transcriptToTxt,
} from "../src/lib/export";
import type { TranscriptChunk } from "../src/worker/protocol";
describe("transcriptToTxt", () => {
test("trims and appends a single trailing newline", () => {
expect(transcriptToTxt({ text: " Hello world. " })).toBe("Hello world.\n");
});
test("empty transcript becomes a bare newline", () => {
expect(transcriptToTxt({ text: "" })).toBe("\n");
});
});
describe("formatSrtTimestamp", () => {
test("zero", () => {
expect(formatSrtTimestamp(0)).toBe("00:00:00,000");
});
test("pads milliseconds to three digits", () => {
expect(formatSrtTimestamp(1.5)).toBe("00:00:01,500");
expect(formatSrtTimestamp(1.05)).toBe("00:00:01,050");
expect(formatSrtTimestamp(1.005)).toBe("00:00:01,005");
});
test("rolls over past one hour", () => {
expect(formatSrtTimestamp(3661.25)).toBe("01:01:01,250");
});
test("rounds sub-millisecond values", () => {
expect(formatSrtTimestamp(0.9996)).toBe("00:00:01,000");
});
test("clamps negatives to zero", () => {
expect(formatSrtTimestamp(-1)).toBe("00:00:00,000");
});
});
describe("transcriptToSrt", () => {
const chunk = (
text: string,
start: number,
end: number | null,
): TranscriptChunk => ({ text, timestamp: [start, end] });
test("formats numbered cues separated by blank lines", () => {
const srt = transcriptToSrt([
chunk(" Hello.", 0, 2.5),
chunk(" World.", 2.5, 4),
]);
expect(srt).toBe(
"1\n00:00:00,000 --> 00:00:02,500\nHello.\n" +
"\n" +
"2\n00:00:02,500 --> 00:00:04,000\nWorld.\n",
);
});
test("null end borrows the next chunk's start", () => {
const srt = transcriptToSrt([
chunk("One", 0, null),
chunk("Two", 3, 5),
]);
expect(srt).toContain("1\n00:00:00,000 --> 00:00:03,000\nOne");
});
test("null end on the final chunk falls back to start + 2s", () => {
const srt = transcriptToSrt([chunk("Tail", 10, null)]);
expect(srt).toBe("1\n00:00:10,000 --> 00:00:12,000\nTail\n");
});
test("skips empty and whitespace-only chunks (numbering stays contiguous)", () => {
const srt = transcriptToSrt([
chunk(" ", 0, 1),
chunk("Kept", 1, null),
chunk("", 2, 3),
chunk("Also kept", 4, 5),
]);
// The null end skips over the empty chunk to the next *captioned* start.
expect(srt).toBe(
"1\n00:00:01,000 --> 00:00:04,000\nKept\n" +
"\n" +
"2\n00:00:04,000 --> 00:00:05,000\nAlso kept\n",
);
});
test("cue never ends before it starts", () => {
// Out-of-order borrow: next captioned chunk starts before this one.
const srt = transcriptToSrt([chunk("A", 5, null), chunk("B", 4, 6)]);
expect(srt).toContain("00:00:05,000 --> 00:00:05,000");
});
test("no captioned chunks yields an empty string", () => {
expect(transcriptToSrt([])).toBe("");
expect(transcriptToSrt([chunk(" ", 0, 1)])).toBe("");
});
});
describe("exportFilename", () => {
test("swaps the extension", () => {
expect(exportFilename("talk.m4a", "srt")).toBe("talk.srt");
expect(exportFilename("talk.m4a", "txt")).toBe("talk.txt");
});
test("only the last dot counts", () => {
expect(exportFilename("2026.07.14 meeting.wav", "srt")).toBe(
"2026.07.14 meeting.srt",
);
});
test("no extension keeps the whole name", () => {
expect(exportFilename("recording", "txt")).toBe("recording.txt");
});
test("leading dot is a hidden file, not an extension", () => {
expect(exportFilename(".bashrc", "txt")).toBe(".bashrc.txt");
});
test("null, undefined, and blank fall back to transcript.*", () => {
expect(exportFilename(null, "srt")).toBe("transcript.srt");
expect(exportFilename(undefined, "txt")).toBe("transcript.txt");
expect(exportFilename(" ", "srt")).toBe("transcript.srt");
});
});
+129 -2
View File
@@ -1,8 +1,16 @@
import { describe, test, expect } from "bun:test";
import { matchCachedModels } from "../src/lib/modelCache";
import {
deleteModelFromCache,
getModelStorageInfo,
matchCachedModels,
modelIdForUrl,
type StorageCache,
type StorageCacheStorage,
} from "../src/lib/modelCache";
// Pure URL matching (D-005 downloaded badge); the `caches` wrapper is
// browser-only and exercised manually.
// browser-only and exercised manually. The M6 storage helpers take an
// injectable CacheStorage-shaped object, mocked below.
const TINY = "onnx-community/whisper-tiny.en";
const BASE = "onnx-community/whisper-base";
@@ -47,3 +55,122 @@ describe("matchCachedModels", () => {
);
});
});
describe("modelIdForUrl", () => {
test("matches on the /modelId/ segment, any file type", () => {
expect(modelIdForUrl(hubUrl(TINY, "tokenizer.json"), [TINY, BASE])).toBe(TINY);
expect(modelIdForUrl(hubUrl(BASE, "onnx/encoder_model_q4.onnx"), [TINY, BASE])).toBe(
BASE,
);
});
test("'.en' variant URLs never match the multilingual sibling", () => {
const url = hubUrl(TINY, "onnx/encoder_model.onnx");
expect(modelIdForUrl(url, ["onnx-community/whisper-tiny"])).toBeUndefined();
});
test("unmatched URLs yield undefined", () => {
expect(modelIdForUrl("https://example.com/other.bin", [TINY])).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// M6 storage helpers, against a mocked Cache Storage
// ---------------------------------------------------------------------------
/** Mutable mock: cache name → (url → body size in bytes). */
function mockCacheStorage(
data: Record<string, Record<string, number>>,
): StorageCacheStorage {
const stores = new Map<string, Map<string, number>>(
Object.entries(data).map(([name, entries]) => [
name,
new Map(Object.entries(entries)),
]),
);
function openCache(name: string): StorageCache {
const entries = stores.get(name) ?? new Map<string, number>();
return {
keys: async () => [...entries.keys()].map((url) => ({ url })),
match: async (request) => {
const size = entries.get(request.url);
if (size === undefined) return undefined;
return { blob: async () => new Blob([new Uint8Array(size)]) };
},
delete: async (request) => entries.delete(request.url),
};
}
return {
keys: async () => [...stores.keys()],
open: async (name) => openCache(name),
};
}
describe("getModelStorageInfo", () => {
test("sums bytes and counts files per model, in catalog order", async () => {
const storage = mockCacheStorage({
"transformers-cache": {
[hubUrl(BASE, "onnx/encoder_model_q8.onnx")]: 2_000,
[hubUrl(TINY, "onnx/encoder_model_quantized.onnx")]: 1_000,
[hubUrl(TINY, "onnx/decoder_model_merged_quantized.onnx")]: 3_000,
[hubUrl(TINY, "tokenizer.json")]: 50,
},
});
expect(await getModelStorageInfo([TINY, BASE], storage)).toEqual([
{ modelId: TINY, fileCount: 3, bytes: 4_050 },
{ modelId: BASE, fileCount: 1, bytes: 2_000 },
]);
});
test("ignores non-transformers caches and unmatched URLs", async () => {
const storage = mockCacheStorage({
"whisper-runtime-v1": { "https://example.com/ort/ort-wasm.wasm": 9_999_999 },
"transformers-cache": {
[hubUrl(TINY, "config.json")]: 10,
"https://example.com/unrelated.bin": 123,
},
});
expect(await getModelStorageInfo([TINY, BASE], storage)).toEqual([
{ modelId: TINY, fileCount: 1, bytes: 10 },
]);
});
test("returns [] with no cache storage (non-browser contexts)", async () => {
expect(await getModelStorageInfo([TINY], undefined)).toEqual([]);
});
});
describe("deleteModelFromCache", () => {
test("removes only the target model's entries and reports deletion", async () => {
const storage = mockCacheStorage({
"transformers-cache": {
[hubUrl(TINY, "onnx/encoder_model_quantized.onnx")]: 1_000,
[hubUrl(TINY, "tokenizer.json")]: 50,
[hubUrl(BASE, "onnx/encoder_model_q8.onnx")]: 2_000,
},
});
expect(await deleteModelFromCache(TINY, storage)).toBe(true);
expect(await getModelStorageInfo([TINY, BASE], storage)).toEqual([
{ modelId: BASE, fileCount: 1, bytes: 2_000 },
]);
});
test("does not delete the '.en' sibling's files", async () => {
const multilingualTiny = "onnx-community/whisper-tiny";
const storage = mockCacheStorage({
"transformers-cache": {
[hubUrl(TINY, "onnx/encoder_model_quantized.onnx")]: 1_000,
},
});
expect(await deleteModelFromCache(multilingualTiny, storage)).toBe(false);
expect(await getModelStorageInfo([TINY], storage)).toEqual([
{ modelId: TINY, fileCount: 1, bytes: 1_000 },
]);
});
test("returns false when nothing matched or storage is unavailable", async () => {
const storage = mockCacheStorage({ "transformers-cache": {} });
expect(await deleteModelFromCache(TINY, storage)).toBe(false);
expect(await deleteModelFromCache(TINY, undefined)).toBe(false);
});
});