Usability and test infrastructure updates.

This commit is contained in:
Lucas Winther committed 2026-07-29 09:20:43 +02:00
1 parent 99b1732003
commit 7040f031a6
12 files changed
+549 -127

No files matched your search

+8
View File
@@ -41,6 +41,14 @@ export function LiveControls({
? "Listening — like everything else, your audio stays on this device."
: "…or transcribe live from your microphone."}
</p>
{/* Announce mode transitions for screen-reader users not focused on the
button — the button's own label change is only reliably heard by
whoever triggered it. */}
<span className="visually-hidden" role="status">
{status === "starting" && "Requesting microphone access"}
{status === "listening" && "Live transcription started"}
{status === "stopping" && "Finishing live transcription"}
</span>
</div>
);
}
+5 -1
View File
@@ -39,7 +39,11 @@ export function ModelLoadProgress({ modelId, progress }: ModelLoadProgressProps)
: "…"}
</span>
</div>
<progress max={100} value={percent} />
<progress
max={100}
value={percent}
aria-label={`Download progress for ${file.file}`}
/>
</li>
);
})}
+18 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
exportFilename,
transcriptToSrt,
@@ -64,6 +64,8 @@ export function TranscriptView({
exportName = null,
}: TranscriptViewProps) {
const [copied, setCopied] = useState(false);
const headingRef = useRef<HTMLHeadingElement>(null);
const prevResultRef = useRef<typeof result>(null);
const text = result?.text ?? partialText;
const copyText = `${text}${tentativeText}`.trim();
const showProgress =
@@ -78,6 +80,16 @@ export function TranscriptView({
return () => clearTimeout(timer);
}, [copied]);
// Move focus to the transcript heading the moment a final result lands, so
// keyboard/screen-reader users land on the new content instead of having to
// hunt for it after a file or live run finishes.
useEffect(() => {
if (result && !prevResultRef.current) {
headingRef.current?.focus();
}
prevResultRef.current = result;
}, [result]);
if (!transcribing && !text && !tentativeText) return null;
async function copy() {
@@ -89,7 +101,7 @@ export function TranscriptView({
return (
<section className="transcript">
<div className="transcript-header">
<h2>
<h2 ref={headingRef} tabIndex={-1}>
{result ? "Transcript" : activeLabel}
{percent !== null && (
<span className="transcript-percent"> {percent}%</span>
@@ -150,6 +162,9 @@ export function TranscriptView({
>
{copied ? "Copied!" : "Copy"}
</button>
<span className="visually-hidden" role="status">
{copied ? "Copied to clipboard" : ""}
</span>
</div>
</div>
{showProgress && (
@@ -157,6 +172,7 @@ export function TranscriptView({
<progress
max={progress.totalSeconds}
value={Math.min(progress.processedSeconds, progress.totalSeconds)}
aria-label="Transcription progress"
/>
<span className="transcript-progress-time">
{formatTimestamp(progress.processedSeconds)} /{" "}
+12
View File
@@ -16,6 +16,18 @@ body {
min-height: 100vh;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.app {
max-width: 720px;
margin: 0 auto;
+20 -7
View File
@@ -1,10 +1,11 @@
import { serve } from "bun";
import { tmpdir } from "node:os";
import { join, normalize, sep } from "node:path";
import { ensureIcons, ICONS_DIR } from "../scripts/generate-icons.ts";
import { injectPrecacheManifest } from "./pwa/precache.ts";
import { auth } from "./server/auth.ts";
import { AUTH_ENABLED } from "./server/flags.ts";
import { MAX_UPLOAD_BYTES, transcribe } from "./server/transcribe.ts";
import { MAX_UPLOAD_BYTES, parseLanguageAndTask, sidecar, transcribe } from "./server/transcribe.ts";
import { liveWebSocket } from "./server/live.ts";
import { getFasterWhisperModel } from "./lib/models.ts";
@@ -132,15 +133,27 @@ async function handleApiRequest(request: Request, server: any): Promise<Response
if (!modelId) return new Response("Missing model", { status: 400 });
const fasterWhisperModel = getFasterWhisperModel(modelId);
if (!fasterWhisperModel) return new Response("Unknown model", { status: 400 });
// Same validation as POST /api/transcribe (D-030-adjacent: never forward
// an unvalidated task/language string into the sidecar protocol just
// because this entry point is a WebSocket, not JSON over HTTP).
const options = parseLanguageAndTask(url);
if (typeof options === "string") return new Response(options, { status: 400 });
// Fail fast like the HTTP route (D-013 "degrade gracefully") instead of
// opening a socket that can only report unavailability once the first
// segment reaches the sidecar.
const status = await sidecar.status();
if (!status.available) {
return new Response(status.reason ?? "server engine unavailable", { status: 503 });
}
const success = server.upgrade(request, {
data: {
user,
model: fasterWhisperModel,
language: url.searchParams.get("language") || undefined,
task: url.searchParams.get("task") || undefined,
tempDir: require("node:os").tmpdir()
}
language: options.language,
task: options.task,
tempDir: tmpdir(),
},
});
if (success) return undefined;
return new Response("WebSocket upgrade failed", { status: 500 });
@@ -310,7 +323,7 @@ options.port = process.env.PORT || 3000;
// Bun's default cap is 128 MB, which would 4xx a /api/transcribe upload of
// more than ~35 minutes of raw PCM before the route's own limit applies.
options.maxRequestBodySize = MAX_UPLOAD_BYTES;
options.websocket = liveWebSocket as any;
options.websocket = liveWebSocket;
const server = serve(options);
+116 -88
View File
@@ -1,103 +1,131 @@
import { tmpdir } from "node:os";
/**
* Server-side live microphone transcription over WebSocket (D-019): the
* browser streams raw float32 16 kHz mono PCM frames, the server runs the
* same pure `LiveTranscriber`/segmenter as the browser engine (src/lib/live.ts)
* and bridges each pass to the faster-whisper sidecar (src/server/transcribe.ts,
* D-013) by writing the segment audio to a temp file. Wired at
* `GET /api/transcribe/live` in src/index.ts.
*
* `createLiveWebSocket` takes the `Sidecar` as an injected dependency —
* mirroring `createTranscribeHandlers` in transcribe.ts — so tests exercise
* this module against a fake sidecar instead of spawning a real Python
* process (see tests/server-live.test.ts).
*/
import { join } from "node:path";
import type { ServerWebSocket } from "bun";
import { LIVE_TUNING, LiveTranscriber, type LiveUpdate } from "../lib/live";
import { sidecar } from "./transcribe";
import { getSessionUser } from "./auth";
import { getFasterWhisperModel } from "../lib/models";
import { sidecar, type Sidecar } from "./transcribe";
import type { SessionUser } from "./auth";
import type { WhisperTask } from "../worker/protocol";
interface LiveSessionData {
user: any;
export interface LiveSessionData {
user: SessionUser;
/** faster-whisper model name (catalog mapping, src/lib/models.ts). */
model: string;
language?: string;
task?: WhisperTask;
task: WhisperTask;
tempDir: string;
}
export const liveWebSocket = {
open(ws: ServerWebSocket<LiveSessionData>) {
// Initialize the transcriber for this connection.
const transcriber = new LiveTranscriber({
...LIVE_TUNING,
transcribe: async (audio: Float32Array) => {
const audioPath = join(
ws.data.tempDir,
`whisper-live-${crypto.randomUUID()}.f32`
);
const sink = Bun.file(audioPath).writer();
// audio.buffer is an ArrayBuffer, we need a Uint8Array view
sink.write(new Uint8Array(audio.buffer as ArrayBuffer, audio.byteOffset, audio.byteLength));
await sink.end();
export interface LiveWebSocketDeps {
sidecar: Sidecar;
}
return new Promise((resolve, reject) => {
let text = "";
let chunks: any[] = [];
sidecar.transcribe(
{
model: ws.data.model,
audioPath,
language: ws.data.language,
task: ws.data.task,
},
(segment) => {
text += segment.text;
chunks.push({
text: segment.text,
timestamp: [segment.start, segment.end],
});
}
)
.then(() => resolve({ text: text.trim(), chunks }))
.catch(reject)
.finally(() => Bun.file(audioPath).unlink().catch(() => {}));
});
},
onUpdate: (update: LiveUpdate) => {
ws.send(JSON.stringify({ type: "update", update }));
},
});
// Store it on the websocket
(ws as any).transcriber = transcriber;
},
/** Exported for tests (tests/server-live.test.ts), which fake this shape. */
export type LiveSocket = ServerWebSocket<LiveSessionData> & { transcriber?: LiveTranscriber };
message(ws: ServerWebSocket<LiveSessionData>, message: string | Buffer | Uint8Array) {
if (typeof message === "string") {
let parsed: any;
try {
parsed = JSON.parse(message);
} catch {
// Not a frame we understand — ignore rather than crash the handler
// (which would otherwise take the whole connection down for every
// client sharing this Bun.serve process, not just this session).
export function createLiveWebSocket(deps: LiveWebSocketDeps) {
return {
open(ws: LiveSocket) {
const transcriber = new LiveTranscriber({
...LIVE_TUNING,
transcribe: async (audio) => {
const audioPath = join(
ws.data.tempDir,
`whisper-live-${crypto.randomUUID()}.f32`,
);
// audio may be a view into a larger buffer — byteOffset/byteLength
// must be preserved, not just the underlying buffer.
await Bun.write(
audioPath,
new Uint8Array(audio.buffer as ArrayBuffer, audio.byteOffset, audio.byteLength),
);
try {
const text: string[] = [];
const chunks: { text: string; timestamp: [number, number | null] }[] = [];
await deps.sidecar.transcribe(
{
model: ws.data.model,
audioPath,
language: ws.data.language,
task: ws.data.task,
},
(segment) => {
text.push(segment.text);
chunks.push({ text: segment.text, timestamp: [segment.start, segment.end] });
},
);
return { text: text.join("").trim(), chunks };
} finally {
await Bun.file(audioPath).unlink().catch(() => {});
}
},
onUpdate: (update: LiveUpdate) => {
ws.send(JSON.stringify({ type: "update", update }));
},
});
ws.transcriber = transcriber;
},
message(ws: LiveSocket, message: string | Buffer) {
if (typeof message === "string") {
let parsed: { type?: string };
try {
parsed = JSON.parse(message);
} catch {
// Not a frame we understand — ignore rather than crash the handler
// (which would otherwise take the whole connection down for every
// client sharing this Bun.serve process, not just this session).
return;
}
if (parsed.type === "stop") {
ws.transcriber
?.stop()
.then((result) => {
ws.send(JSON.stringify({ type: "result", result }));
ws.close();
})
.catch((err) => {
ws.send(
JSON.stringify({
type: "error",
error: err instanceof Error ? err.message : String(err),
}),
);
ws.close();
});
}
return;
}
if (parsed.type === "stop") {
const transcriber = (ws as any).transcriber as LiveTranscriber;
transcriber.stop().then((result) => {
ws.send(JSON.stringify({ type: "result", result }));
ws.close();
}).catch((err) => {
ws.send(JSON.stringify({ type: "error", error: err instanceof Error ? err.message : String(err) }));
ws.close();
});
}
} else {
// Audio chunk arrived
const transcriber = (ws as any).transcriber as LiveTranscriber;
// Convert raw bytes to Float32Array (16kHz mono)
const buffer = message instanceof Buffer ? message.buffer : message.buffer;
const byteOffset = message instanceof Buffer ? message.byteOffset : (message as Uint8Array).byteOffset;
const byteLength = message.byteLength;
const audio = new Float32Array(buffer, byteOffset, byteLength / 4);
transcriber.push(audio);
}
},
// Binary frame: a raw float32 mono 16 kHz PCM chunk (lib/mic.ts already
// resampled it on the main thread, same contract as the browser engine).
const audio = new Float32Array(
message.buffer,
message.byteOffset,
message.byteLength / 4,
);
ws.transcriber?.push(audio);
},
close(ws: ServerWebSocket<LiveSessionData>) {
const transcriber = (ws as any).transcriber as LiveTranscriber;
transcriber?.stop().catch(() => {});
},
};
close(ws: LiveSocket) {
ws.transcriber?.stop().catch(() => {});
},
};
}
// ---------------------------------------------------------------------------
// Default instance (real sidecar), wired in src/index.ts
// ---------------------------------------------------------------------------
export const liveWebSocket = createLiveWebSocket({ sidecar });
+25 -9
View File
@@ -324,14 +324,16 @@ interface TranscribeParams {
task: WhisperTask;
}
/** Parse + validate query params; a string is the 400 error text. */
function parseParams(url: URL): TranscribeParams | string {
const modelId = url.searchParams.get("model");
if (!modelId) return "missing model parameter";
const fasterWhisperModel = getFasterWhisperModel(modelId);
// Catalog-only (D-006/D-013): never forward arbitrary ids to the sidecar.
if (!fasterWhisperModel) return `unknown model: ${modelId}`;
/**
* Parse + validate the `language`/`task` query params shared by both the
* HTTP (`POST /api/transcribe`) and WebSocket (`GET /api/transcribe/live`,
* src/server/live.ts) entry points, so a live session can't forward an
* unvalidated string into the sidecar protocol just because it skips
* `parseParams` below. A string return is the 400 error text.
*/
export function parseLanguageAndTask(
url: URL,
): { language?: string; task: WhisperTask } | string {
const rawLanguage = url.searchParams.get("language");
let language: string | undefined;
if (rawLanguage !== null && rawLanguage !== "" && rawLanguage !== "auto") {
@@ -344,7 +346,21 @@ function parseParams(url: URL): TranscribeParams | string {
return `invalid task: ${rawTask}`;
}
return { modelId, fasterWhisperModel, language, task: rawTask };
return { language, task: rawTask };
}
/** Parse + validate query params; a string is the 400 error text. */
function parseParams(url: URL): TranscribeParams | string {
const modelId = url.searchParams.get("model");
if (!modelId) return "missing model parameter";
const fasterWhisperModel = getFasterWhisperModel(modelId);
// Catalog-only (D-006/D-013): never forward arbitrary ids to the sidecar.
if (!fasterWhisperModel) return `unknown model: ${modelId}`;
const options = parseLanguageAndTask(url);
if (typeof options === "string") return options;
return { modelId, fasterWhisperModel, ...options };
}
/**