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
@@ -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