Transcript export (.txt/.srt) and model storage manager (M6)
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0084e0435a
commit
8f2da530e2
+19
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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:{" "}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user