Add live transcription via remote server.
This commit is contained in:
@@ -266,3 +266,9 @@ a few seconds.
|
|||||||
by aborting the `/api/transcribe` fetch instead (`AbortController`; the server
|
by aborting the `/api/transcribe` fetch instead (`AbortController`; the server
|
||||||
cancels the sidecar pass between segments, D-011/D-013) — the inference worker
|
cancels the sidecar pass between segments, D-011/D-013) — the inference worker
|
||||||
and its loaded pipeline are left untouched in that path.
|
and its loaded pipeline are left untouched in that path.
|
||||||
|
|
||||||
|
## D-019 — Server-based live microphone transcription
|
||||||
|
**Status:** Accepted
|
||||||
|
**Why:** M7 future want. Enables logged-in users on weak devices to transcribe live microphone audio using the server engine.
|
||||||
|
**Shape:** The client opens a WebSocket connection to `/api/transcribe/live`. The client streams 16kHz Float32 PCM chunks (from `MicCapture`) over the WebSocket. The server runs `LiveTranscriber` and `LiveSegmenter` (from `src/lib/live.ts`, which is pure and runs perfectly in Node/Bun). For each segment/pass, the server's `LiveTranscriber` writes the audio to a temporary file and invokes the Python sidecar. The server sends `LiveUpdate` objects back to the client over the WebSocket.
|
||||||
|
**Consequence:** The client does zero ML and minimal buffering, acting only as a mic-streamer and UI-renderer for live passes on the server.
|
||||||
+86
-13
@@ -58,9 +58,10 @@ export function App() {
|
|||||||
// Live-mode session (M7, D-016). The mic and transcriber live outside React
|
// Live-mode session (M7, D-016). The mic and transcriber live outside React
|
||||||
// state; only display text goes through setState. Segment passes are
|
// state; only display text goes through setState. Segment passes are
|
||||||
// correlated with worker responses via requestId.
|
// correlated with worker responses via requestId.
|
||||||
const liveRef = useRef<{ mic: MicCapture; transcriber: LiveTranscriber } | null>(
|
const liveRef = useRef<{ mic: MicCapture; transcriber: LiveTranscriber | null } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const liveWsRef = useRef<WebSocket | null>(null);
|
||||||
const pendingLive = useRef(
|
const pendingLive = useRef(
|
||||||
new Map<
|
new Map<
|
||||||
string,
|
string,
|
||||||
@@ -143,6 +144,8 @@ export function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
void liveRef.current?.mic.stop().catch(() => {});
|
void liveRef.current?.mic.stop().catch(() => {});
|
||||||
liveRef.current = null;
|
liveRef.current = null;
|
||||||
|
liveWsRef.current?.close();
|
||||||
|
liveWsRef.current = null;
|
||||||
workerRef.current?.terminate();
|
workerRef.current?.terminate();
|
||||||
workerRef.current = null;
|
workerRef.current = null;
|
||||||
browserEngineRef.current = null;
|
browserEngineRef.current = null;
|
||||||
@@ -465,6 +468,74 @@ export function App() {
|
|||||||
setLiveTentative("");
|
setLiveTentative("");
|
||||||
setLiveStatus("starting");
|
setLiveStatus("starting");
|
||||||
|
|
||||||
|
const useServer =
|
||||||
|
engineChoice === "server" &&
|
||||||
|
auth.user !== null &&
|
||||||
|
serverStatus?.available === true;
|
||||||
|
|
||||||
|
const options = (getModel(modelId)?.multilingual ?? false) ? { language, task } : {};
|
||||||
|
|
||||||
|
if (useServer) {
|
||||||
|
const wsUrl = new URL(`/api/transcribe/live?model=${modelId}`, window.location.href);
|
||||||
|
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
if (options.language) wsUrl.searchParams.set("language", options.language);
|
||||||
|
if (options.task) wsUrl.searchParams.set("task", options.task);
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === "update") {
|
||||||
|
setLiveCommitted(data.update.committedText);
|
||||||
|
setLiveTentative(data.update.tentativeText);
|
||||||
|
if (data.update.error) setError(data.update.error);
|
||||||
|
} else if (data.type === "result") {
|
||||||
|
setResult(data.result);
|
||||||
|
setLiveCommitted("");
|
||||||
|
setLiveTentative("");
|
||||||
|
setLiveStatus("idle");
|
||||||
|
liveWsRef.current = null;
|
||||||
|
liveRef.current = null;
|
||||||
|
} else if (data.type === "error") {
|
||||||
|
setError(data.error);
|
||||||
|
setLiveStatus("idle");
|
||||||
|
liveWsRef.current = null;
|
||||||
|
liveRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setError("WebSocket connection error.");
|
||||||
|
setLiveStatus("idle");
|
||||||
|
liveWsRef.current = null;
|
||||||
|
liveRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onopen = async () => {
|
||||||
|
try {
|
||||||
|
const mic = await startMicCapture((chunk) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(chunk.buffer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
liveWsRef.current = ws;
|
||||||
|
liveRef.current = { mic, transcriber: null };
|
||||||
|
setLiveStatus("listening");
|
||||||
|
} catch (micError) {
|
||||||
|
ws.close();
|
||||||
|
setLiveStatus("idle");
|
||||||
|
setError(
|
||||||
|
micError instanceof DOMException && micError.name === "NotAllowedError"
|
||||||
|
? "Microphone access was denied. Allow it in your browser's site settings and try again."
|
||||||
|
: micError instanceof Error
|
||||||
|
? micError.message
|
||||||
|
: String(micError),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Idempotent model load, like the file flow — a pending download runs
|
// Idempotent model load, like the file flow — a pending download runs
|
||||||
// while the mic opens; segment passes queue in the worker until ready.
|
// while the mic opens; segment passes queue in the worker until ready.
|
||||||
const worker = ensureWorker();
|
const worker = ensureWorker();
|
||||||
@@ -474,9 +545,6 @@ export function App() {
|
|||||||
postToWorker(worker, { type: "load", modelId, device: devicePreference });
|
postToWorker(worker, { type: "load", modelId, device: devicePreference });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Language/task are fixed for the session; the options are disabled while
|
|
||||||
// live. `.en` models take neither (same rule as the file flow).
|
|
||||||
const options = (getModel(modelId)?.multilingual ?? false) ? { language, task } : {};
|
|
||||||
const transcriber = new LiveTranscriber({
|
const transcriber = new LiveTranscriber({
|
||||||
...LIVE_TUNING,
|
...LIVE_TUNING,
|
||||||
transcribe: (audio) => transcribeLiveSegment(worker, audio, options),
|
transcribe: (audio) => transcribeLiveSegment(worker, audio, options),
|
||||||
@@ -509,14 +577,19 @@ export function App() {
|
|||||||
setLiveStatus("stopping");
|
setLiveStatus("stopping");
|
||||||
// Stop feeding audio first, then drain the remaining segment passes.
|
// Stop feeding audio first, then drain the remaining segment passes.
|
||||||
await session.mic.stop();
|
await session.mic.stop();
|
||||||
try {
|
|
||||||
const final = await session.transcriber.stop();
|
if (liveWsRef.current) {
|
||||||
setResult(final);
|
liveWsRef.current.send(JSON.stringify({ type: "stop" }));
|
||||||
setLiveCommitted("");
|
} else {
|
||||||
setLiveTentative("");
|
try {
|
||||||
} finally {
|
const final = await session.transcriber!.stop();
|
||||||
liveRef.current = null;
|
setResult(final);
|
||||||
setLiveStatus("idle");
|
setLiveCommitted("");
|
||||||
|
setLiveTentative("");
|
||||||
|
} finally {
|
||||||
|
liveRef.current = null;
|
||||||
|
setLiveStatus("idle");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,7 +740,7 @@ export function App() {
|
|||||||
<p>
|
<p>
|
||||||
Model: <code>{modelId}</code> · Engine:{" "}
|
Model: <code>{modelId}</code> · Engine:{" "}
|
||||||
{serverSelected
|
{serverSelected
|
||||||
? "server (faster-whisper) — files upload to this deployment"
|
? "server (faster-whisper) — audio streams to this deployment"
|
||||||
: activeDevice === "webgpu"
|
: activeDevice === "webgpu"
|
||||||
? "WebGPU (on-device)"
|
? "WebGPU (on-device)"
|
||||||
: activeDevice === "webnn"
|
: activeDevice === "webnn"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface EngineToggleProps {
|
|||||||
* Architecture.md §5). The server option stays visible but disabled when the
|
* Architecture.md §5). The server option stays visible but disabled when the
|
||||||
* deployment reports the engine unavailable (e.g. no faster-whisper sidecar,
|
* deployment reports the engine unavailable (e.g. no faster-whisper sidecar,
|
||||||
* D-013), showing the probe's reason — hiding it entirely would read as a
|
* D-013), showing the probe's reason — hiding it entirely would read as a
|
||||||
* broken account feature. Live mode always stays on-device (D-016).
|
* broken account feature. Live mode streams to the server if selected (D-019).
|
||||||
*/
|
*/
|
||||||
export function EngineToggle({
|
export function EngineToggle({
|
||||||
choice,
|
choice,
|
||||||
@@ -53,7 +53,7 @@ export function EngineToggle({
|
|||||||
? "Checking server availability…"
|
? "Checking server availability…"
|
||||||
: serverAvailable
|
: serverAvailable
|
||||||
? effective === "server"
|
? effective === "server"
|
||||||
? "Uploaded files are transcribed on the server (faster-whisper). Live mode always stays on this device."
|
? "Audio files and live microphone stream are transcribed on the server (faster-whisper)."
|
||||||
: "Your audio stays on this device."
|
: "Your audio stays on this device."
|
||||||
: (status.reason ?? "The server engine is unavailable.")}
|
: (status.reason ?? "The server engine is unavailable.")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+35
-5
@@ -5,6 +5,7 @@ import { injectPrecacheManifest } from "./pwa/precache.ts";
|
|||||||
import { auth } from "./server/auth.ts";
|
import { auth } from "./server/auth.ts";
|
||||||
import { AUTH_ENABLED } from "./server/flags.ts";
|
import { AUTH_ENABLED } from "./server/flags.ts";
|
||||||
import { transcribe } from "./server/transcribe.ts";
|
import { transcribe } from "./server/transcribe.ts";
|
||||||
|
import { liveWebSocket } from "./server/live.ts";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..");
|
const ROOT = join(import.meta.dir, "..");
|
||||||
const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
||||||
@@ -56,7 +57,7 @@ async function serveOrtFile(dir: string, name: string): Promise<Response> {
|
|||||||
// Auth endpoints are M4 (src/server/auth.ts); /api/transcribe joins in M5.
|
// Auth endpoints are M4 (src/server/auth.ts); /api/transcribe joins in M5.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function handleApiRequest(request: Request): Promise<Response> {
|
async function handleApiRequest(request: Request, server: any): Promise<Response | undefined> {
|
||||||
const { pathname } = new URL(request.url);
|
const { pathname } = new URL(request.url);
|
||||||
const apiIndex = pathname.indexOf("/api/");
|
const apiIndex = pathname.indexOf("/api/");
|
||||||
const apiPath = apiIndex !== -1 ? pathname.slice(apiIndex) : pathname;
|
const apiPath = apiIndex !== -1 ? pathname.slice(apiIndex) : pathname;
|
||||||
@@ -79,11 +80,31 @@ async function handleApiRequest(request: Request): Promise<Response> {
|
|||||||
return auth.logout(request);
|
return auth.logout(request);
|
||||||
case "GET /api/auth/me":
|
case "GET /api/auth/me":
|
||||||
return auth.me(request);
|
return auth.me(request);
|
||||||
// Server engine (M5, D-011/D-013): availability probe + streamed run.
|
|
||||||
case "GET /api/transcribe/status":
|
case "GET /api/transcribe/status":
|
||||||
return transcribe.status(request);
|
return transcribe.status(request);
|
||||||
case "POST /api/transcribe":
|
case "POST /api/transcribe":
|
||||||
return transcribe.post(request);
|
return transcribe.post(request);
|
||||||
|
case "GET /api/transcribe/live": {
|
||||||
|
const user = auth.getSessionUser(request);
|
||||||
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const modelId = url.searchParams.get("model");
|
||||||
|
if (!modelId) return new Response("Missing model", { status: 400 });
|
||||||
|
const fasterWhisperModel = transcribe.getFasterWhisperModel?.(modelId) || (await import("./lib/models.ts")).getFasterWhisperModel(modelId);
|
||||||
|
if (!fasterWhisperModel) return new Response("Unknown model", { status: 400 });
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (success) return undefined;
|
||||||
|
return new Response("WebSocket upgrade failed", { status: 500 });
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return NOT_FOUND();
|
return NOT_FOUND();
|
||||||
}
|
}
|
||||||
@@ -103,10 +124,14 @@ async function productionServeOptions(): Promise<ServeOptions> {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
development: false,
|
development: false,
|
||||||
async fetch(request) {
|
async fetch(request, server) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
// API routes are dynamic (M4/M5) and network-only in the SW (D-007).
|
// API routes are dynamic (M4/M5) and network-only in the SW (D-007).
|
||||||
if (url.pathname.includes("/api/")) return handleApiRequest(request);
|
if (url.pathname.includes("/api/")) {
|
||||||
|
const response = await handleApiRequest(request, server);
|
||||||
|
if (response) return response;
|
||||||
|
return new Response("Upgrading...", { status: 101 }); // actually Bun handles the real response, but TypeScript requires a return
|
||||||
|
}
|
||||||
|
|
||||||
let relative = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
|
let relative = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
|
||||||
if (relative.endsWith("/")) relative += "index.html";
|
if (relative.endsWith("/")) relative += "index.html";
|
||||||
@@ -190,7 +215,11 @@ async function developmentServeOptions(): Promise<ServeOptions> {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
|
|
||||||
if (pathname.includes("/api/")) return handleApiRequest(request);
|
if (pathname.includes("/api/")) {
|
||||||
|
const response = await handleApiRequest(request, server);
|
||||||
|
if (response) return response;
|
||||||
|
return new Response("Upgrading...", { status: 101 });
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.endsWith("/inference.worker.js")) {
|
if (pathname.endsWith("/inference.worker.js")) {
|
||||||
return new Response(workerScript, { headers: scriptHeaders });
|
return new Response(workerScript, { headers: scriptHeaders });
|
||||||
@@ -232,6 +261,7 @@ async function developmentServeOptions(): Promise<ServeOptions> {
|
|||||||
|
|
||||||
const options = IS_PRODUCTION ? await productionServeOptions() : await developmentServeOptions();
|
const options = IS_PRODUCTION ? await productionServeOptions() : await developmentServeOptions();
|
||||||
options.port = process.env.PORT || 3000;
|
options.port = process.env.PORT || 3000;
|
||||||
|
options.websocket = liveWebSocket as any;
|
||||||
|
|
||||||
const server = serve(options);
|
const server = serve(options);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { tmpdir } from "node:os";
|
||||||
|
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 type { WhisperTask } from "../worker/protocol";
|
||||||
|
|
||||||
|
interface LiveSessionData {
|
||||||
|
user: any;
|
||||||
|
model: string;
|
||||||
|
language?: string;
|
||||||
|
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();
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
|
message(ws: ServerWebSocket<LiveSessionData>, message: string | Buffer | Uint8Array) {
|
||||||
|
if (typeof message === "string") {
|
||||||
|
const parsed = JSON.parse(message);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
close(ws: ServerWebSocket<LiveSessionData>) {
|
||||||
|
const transcriber = (ws as any).transcriber as LiveTranscriber;
|
||||||
|
transcriber?.stop().catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -470,8 +470,10 @@ export function createTranscribeHandlers(deps: TranscribeDeps): TranscribeHandle
|
|||||||
// Default instance (real sidecar + app sessions), wired in src/index.ts
|
// Default instance (real sidecar + app sessions), wired in src/index.ts
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const sidecar = createSidecar();
|
||||||
|
|
||||||
export const transcribe: TranscribeHandlers = createTranscribeHandlers({
|
export const transcribe: TranscribeHandlers = createTranscribeHandlers({
|
||||||
sidecar: createSidecar(),
|
sidecar,
|
||||||
getSessionUser: appGetSessionUser,
|
getSessionUser: appGetSessionUser,
|
||||||
maxUploadBytes: Number(process.env.TRANSCRIBE_MAX_UPLOAD_BYTES) || undefined,
|
maxUploadBytes: Number(process.env.TRANSCRIBE_MAX_UPLOAD_BYTES) || undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user