Agent completed work jubii
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "./index.css";
|
||||
|
||||
import { FileDropzone } from "./components/FileDropzone";
|
||||
import { ModelLoadProgress } from "./components/ModelLoadProgress";
|
||||
import { TranscriptView } from "./components/TranscriptView";
|
||||
import { decodeAudioFile } from "./lib/audio";
|
||||
import { DEFAULT_DEVICE, DEFAULT_MODEL_ID } from "./lib/models";
|
||||
import {
|
||||
applyLoadProgress,
|
||||
initialLoadProgress,
|
||||
isWorkerToUiMessage,
|
||||
type TranscriptChunk,
|
||||
type UiToWorkerMessage,
|
||||
} from "./worker/protocol";
|
||||
|
||||
interface TranscriptResult {
|
||||
text: string;
|
||||
chunks: TranscriptChunk[];
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [decoding, setDecoding] = useState(false);
|
||||
const [loadStarted, setLoadStarted] = useState(false);
|
||||
const [loadProgress, setLoadProgress] = useState(initialLoadProgress);
|
||||
const [partialText, setPartialText] = useState("");
|
||||
const [result, setResult] = useState<TranscriptResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
// Terminate the worker when the app unmounts (also keeps HMR tidy).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function ensureWorker(): Worker {
|
||||
if (workerRef.current) return workerRef.current;
|
||||
// Inference always runs in a dedicated Web Worker (D-003), never on the
|
||||
// main thread (Architecture.md §5). The worker script is bundled by the
|
||||
// server at startup and served at a stable URL (see src/index.ts) because
|
||||
// Bun's bundler does not follow `new Worker(new URL(...))` graphs yet.
|
||||
const worker = new Worker("/inference.worker.js", { type: "module" });
|
||||
worker.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
const message = event.data;
|
||||
if (!isWorkerToUiMessage(message)) return;
|
||||
switch (message.type) {
|
||||
case "load-progress":
|
||||
setLoadProgress((state) => applyLoadProgress(state, message));
|
||||
break;
|
||||
case "partial":
|
||||
setPartialText((text) => text + message.text);
|
||||
break;
|
||||
case "result":
|
||||
setResult({ text: message.text, chunks: message.chunks });
|
||||
setBusy(false);
|
||||
break;
|
||||
case "error":
|
||||
setError(message.message);
|
||||
setBusy(false);
|
||||
break;
|
||||
}
|
||||
});
|
||||
worker.addEventListener("error", (event) => {
|
||||
setError(event.message || "The inference worker crashed unexpectedly.");
|
||||
setBusy(false);
|
||||
});
|
||||
workerRef.current = worker;
|
||||
return worker;
|
||||
}
|
||||
|
||||
function postToWorker(worker: Worker, message: UiToWorkerMessage, transfer?: Transferable[]) {
|
||||
worker.postMessage(message, transfer ?? []);
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setPartialText("");
|
||||
setResult(null);
|
||||
setFileName(file.name);
|
||||
|
||||
// Kick off the (idempotent) model load immediately so the download runs
|
||||
// while we decode the audio locally.
|
||||
const worker = ensureWorker();
|
||||
postToWorker(worker, {
|
||||
type: "load",
|
||||
modelId: DEFAULT_MODEL_ID,
|
||||
device: DEFAULT_DEVICE,
|
||||
});
|
||||
setLoadStarted(true);
|
||||
|
||||
setDecoding(true);
|
||||
try {
|
||||
// Decoded entirely on-device via Web Audio (D-012); the audio never
|
||||
// touches the network (Architecture.md §5).
|
||||
const audio = await decodeAudioFile(file);
|
||||
// Transfer the buffer to the worker — zero copy.
|
||||
postToWorker(worker, { type: "transcribe", audio }, [audio.buffer as ArrayBuffer]);
|
||||
} catch (decodeError) {
|
||||
setError(decodeError instanceof Error ? decodeError.message : String(decodeError));
|
||||
setBusy(false);
|
||||
} finally {
|
||||
setDecoding(false);
|
||||
}
|
||||
}
|
||||
|
||||
const modelReady = loadProgress.ready;
|
||||
const showModelProgress = loadStarted && !modelReady && !error;
|
||||
const transcribing = busy && !decoding && modelReady;
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<header className="app-header">
|
||||
<h1>Whisper Web</h1>
|
||||
<p className="tagline">
|
||||
Speech-to-text that runs entirely in your browser. Your audio never
|
||||
leaves this device.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FileDropzone onFile={handleFile} disabled={busy} />
|
||||
|
||||
{fileName && (
|
||||
<p className="file-name">
|
||||
File: <strong>{fileName}</strong>
|
||||
{decoding && " — decoding audio…"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showModelProgress && (
|
||||
<ModelLoadProgress modelId={DEFAULT_MODEL_ID} progress={loadProgress} />
|
||||
)}
|
||||
|
||||
<TranscriptView
|
||||
partialText={partialText}
|
||||
result={result}
|
||||
transcribing={transcribing}
|
||||
/>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
Model: <code>{DEFAULT_MODEL_ID}</code> · Engine: WASM (on-device)
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user