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
+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);
});
});