Transcript export (.txt/.srt) and model storage manager (M6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0084e0435a
commit
8f2da530e2
9 files changed
+743
-12
No files matched your search
@@ -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,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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user