Focus on speedup and not hanging on large audio files.

This commit is contained in:
Lucas Winther committed 2026-07-15 13:01:22 +02:00
1 parent cba27fc728
commit 0bd5d326a5
7 files changed
+366 -2

No files matched your search

+13 -1
View File
@@ -4,7 +4,7 @@ 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 { transcribe } from "./server/transcribe.ts";
import { MAX_UPLOAD_BYTES, transcribe } from "./server/transcribe.ts";
import { liveWebSocket } from "./server/live.ts";
const ROOT = join(import.meta.dir, "..");
@@ -81,8 +81,17 @@ async function handleApiRequest(request: Request, server: any): Promise<Response
case "GET /api/auth/me":
return auth.me(request);
case "GET /api/transcribe/status":
// The first probe spawns the sidecar and waits for faster-whisper to
// import (READY_TIMEOUT_MS = 60 s) — give it headroom over Bun's 10 s
// default idle timeout.
server.timeout(request, 90);
return transcribe.status(request);
case "POST /api/transcribe":
// Long-running ndjson stream (D-021): quiet stretches (model
// load/download, slow decode windows) exceed Bun's 10 s idle timeout,
// which would reset the connection mid-transcription. Heartbeat lines
// (transcribe.ts) cover proxies; disable Bun's timer entirely here.
server.timeout(request, 0);
return transcribe.post(request);
case "GET /api/transcribe/live": {
const user = auth.getSessionUser(request);
@@ -261,6 +270,9 @@ async function developmentServeOptions(): Promise<ServeOptions> {
const options = IS_PRODUCTION ? await productionServeOptions() : await developmentServeOptions();
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;
const server = serve(options);
+29 -1
View File
@@ -282,6 +282,19 @@ export function createSidecar(command?: readonly string[]): Sidecar {
/** Default cap ≈ 2¼ hours of raw float32 16 kHz mono (64 kB/s). */
const DEFAULT_MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
/**
* Effective upload cap (env-overridable). Exported so src/index.ts can raise
* Bun.serve's `maxRequestBodySize` to match — its 128 MB default would
* otherwise reject uploads longer than ~35 minutes of audio before this
* module ever sees them.
*/
export const MAX_UPLOAD_BYTES =
Number(process.env.TRANSCRIBE_MAX_UPLOAD_BYTES) || DEFAULT_MAX_UPLOAD_BYTES;
/** Keep the ndjson stream visibly alive through quiet stretches (D-021). */
const HEARTBEAT_INTERVAL_MS = 5_000;
const HEARTBEAT = new TextEncoder().encode("\n");
const LANGUAGE_RE = /^[a-z]{2,3}$/;
export interface TranscribeHandlers {
@@ -410,6 +423,20 @@ export function createTranscribeHandlers(deps: TranscribeDeps): TranscribeHandle
async start(controller) {
const enqueue = (line: TranscribeStreamLine) =>
controller.enqueue(encoder.encode(`${JSON.stringify(line)}\n`));
// Segment gaps can exceed any proxy/server idle timeout (model
// load/download before the first segment; slow decode windows on
// big models — 30 s+ observed on a 15-min file). Heartbeat blank
// lines keep the connection visibly alive; ServerEngine's line
// reader skips empty lines. A dead client makes the enqueue throw,
// which is caught here (request.signal separately cancels the
// sidecar pass).
const heartbeat = setInterval(() => {
try {
controller.enqueue(HEARTBEAT);
} catch {
clearInterval(heartbeat);
}
}, HEARTBEAT_INTERVAL_MS);
const chunks: TranscriptChunk[] = [];
try {
await deps.sidecar.transcribe(
@@ -445,6 +472,7 @@ export function createTranscribeHandlers(deps: TranscribeDeps): TranscribeHandle
message: error instanceof Error ? error.message : String(error),
});
} finally {
clearInterval(heartbeat);
await Bun.file(audioPath).unlink().catch(() => {});
try {
controller.close();
@@ -475,5 +503,5 @@ export const sidecar = createSidecar();
export const transcribe: TranscribeHandlers = createTranscribeHandlers({
sidecar,
getSessionUser: appGetSessionUser,
maxUploadBytes: Number(process.env.TRANSCRIBE_MAX_UPLOAD_BYTES) || undefined,
maxUploadBytes: MAX_UPLOAD_BYTES,
});
+12
View File
@@ -232,6 +232,18 @@ async function transcribe(message: TranscribeMessage): Promise<void> {
chunk_length_s: CHUNK_LENGTH_S,
stride_length_s: STRIDE_LENGTH_S,
return_timestamps: true,
// With return_timestamps and no explicit max_new_tokens, Transformers.js
// 4.2's Whisper generate() takes a seek-loop path that re-decodes each
// window's tail (1–3 generate passes per window). Under this pipeline's
// own 30 s/5 s chunking that is pure overhead: ~1.4× slower on long
// files, duplicated boundary text, on_finalize firing once per pass
// (breaking TranscribeProgressTracker's one-finalize-per-window
// assumption — the progress bar saturated early, then looked frozen),
// and a potential infinite loop when a window's last boundary timestamp
// is 0. Setting max_new_tokens selects the classic single-pass path;
// 440 ≈ Whisper's 448 decoder positions minus prompt headroom, far more
// than any real 30 s window produces.
max_new_tokens: 440,
};
if (message.stream ?? message.requestId === undefined) {
// File-flow pass (id-less, or routed through BrowserEngine with an