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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5 committed 2026-07-14 19:41:47 +02:00
1 parent 0084e0435a
commit 8f2da530e2
9 files changed
+743 -12

No files matched your search

+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>
);
}
+57 -8
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,14 +90,46 @@ export function TranscriptView({
<span className="transcript-percent"> {percent}%</span>
)}
</h2>
<button
type="button"
className="copy-button"
onClick={copy}
disabled={!copyText}
>
{copied ? "Copied!" : "Copy"}
</button>
<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"
onClick={copy}
disabled={!copyText}
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
</div>
{showProgress && (
<div className="transcript-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;
}
}