From d10847659352688f5ab2803d19ea373fa341b3d3 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 12:28:35 +0200 Subject: [PATCH 01/11] fix(stt): transcribe in chunks, with progress and per-chunk retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 30-minute recording was one `/inference` call: ~10 minutes with no progress, no recovery from a transient failure, and — the reported bug — killed outright before it ever finished. Node's global fetch (undici) applies a 300s `headersTimeout`, and whisper sends no response header until the whole upload is transcribed. Measured on the reporter's file: whisper needed 574s, undici cut the connection at 300s, and the renderer surfaced an unactionable "Transcription failed / fetch failed". The helper kept burning CPU on a transcription nobody was listening for. Split the audio into ~90s chunks and run them one at a time: - `chunking.ts` nudges each boundary to the quietest 20ms frame within ±3s of the ideal cut, so a chunk break lands in a pause instead of mid-word. Energy minimum, not a VAD: whisper.cpp's Silero VAD runs per REQUEST, so it cannot tell us where to cut before we upload. - `SttManager.transcribe` shifts each chunk's timestamps to absolute time, emits `completedSec`/`totalSec` per chunk, retries a chunk up to 3 times, and re-runs `server.start()` between attempts (idempotent when the helper is alive, a respawn when it isn't) — the usual cause of a mid-run failure is a dead helper, not a bad chunk. - The language detected on the first chunk is pinned for the rest. Left to auto-detect, whisper can flip mid-recording on a chunk that opens with a proper noun and "transcribe" the remainder as another language. - `whisperServer` bounds each request at 280s and names the failure with the helper's stderr, instead of letting undici's 300s ceiling surface as a bare "fetch failed". Chunks run SEQUENTIALLY, measured rather than assumed: whisper-stt-server holds a single model context, and two 120s chunks took 76.9s one after the other vs 144.1s fired together (0.53x — concurrency is ~1.9x SLOWER). A client-side worker pool would be a pessimisation. The progress reaches the UI: the renderer's status callback forwarded only a phase string and never subscribed to the main process's events, so the toast showed one static "transcribing" for the whole run. It now carries the chunk progress and renders a real bar. Verified end to end on a 32-minute source: 22 chunks, timestamps monotonic across every seam, last segment at 1950.7s of 1951.0s, slowest chunk 76.8s against the 280s ceiling. --- electron/stt/chunking.test.ts | 62 ++++++++++ electron/stt/chunking.ts | 114 +++++++++++++++++++ electron/stt/index.test.ts | 74 ++++++++++++ electron/stt/index.ts | 131 +++++++++++++++++++--- electron/stt/transcriptionContract.ts | 8 ++ electron/stt/whisperServer.ts | 39 ++++++- src/lib/ai-edition/document/transcribe.ts | 26 ++++- src/lib/captioning/transcribe.test.ts | 14 ++- src/lib/captioning/transcribe.ts | 19 +++- 9 files changed, 460 insertions(+), 27 deletions(-) create mode 100644 electron/stt/chunking.test.ts create mode 100644 electron/stt/chunking.ts diff --git a/electron/stt/chunking.test.ts b/electron/stt/chunking.test.ts new file mode 100644 index 000000000..eef6d2006 --- /dev/null +++ b/electron/stt/chunking.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { planChunks } from "./chunking"; + +const RATE = 16_000; + +/** Loud tone with silent gaps punched in at the given [startSec, endSec) ranges. */ +function toneWithSilences(durationSec: number, silences: [number, number][]): Float32Array { + const samples = new Float32Array(Math.round(durationSec * RATE)); + for (let i = 0; i < samples.length; i++) { + samples[i] = Math.sin((i / RATE) * 2 * Math.PI * 440); + } + for (const [from, to] of silences) { + samples.fill(0, Math.round(from * RATE), Math.round(to * RATE)); + } + return samples; +} + +describe("planChunks", () => { + it("covers the whole buffer with contiguous chunks", () => { + const samples = toneWithSilences(25, []); + const chunks = planChunks(samples, RATE, { targetSec: 10, searchSec: 1 }); + expect(chunks[0].startSample).toBe(0); + expect(chunks[chunks.length - 1].endSample).toBe(samples.length); + for (let i = 1; i < chunks.length; i++) { + expect(chunks[i].startSample).toBe(chunks[i - 1].endSample); + } + }); + + it("cuts inside a pause rather than on the fixed grid", () => { + // Pause at 9.5-9.9s: the ideal 10s boundary should be pulled back into it. + const samples = toneWithSilences(25, [ + [9.5, 9.9], + [19.4, 19.8], + ]); + const chunks = planChunks(samples, RATE, { targetSec: 10, searchSec: 1 }); + const cutSec = chunks[0].endSample / RATE; + expect(cutSec).toBeGreaterThanOrEqual(9.5); + expect(cutSec).toBeLessThan(9.9); + }); + + it("returns a single chunk when the recording is shorter than the target", () => { + const samples = toneWithSilences(5, []); + expect(planChunks(samples, RATE, { targetSec: 120 })).toEqual([ + { startSample: 0, endSample: samples.length }, + ]); + }); + + it("always makes progress, even on a fully silent buffer", () => { + // Every frame ties on energy, so the cut is whatever the scan settles on — + // what matters is that it never returns a zero-length chunk (infinite loop). + const samples = new Float32Array(60 * RATE); + const chunks = planChunks(samples, RATE, { targetSec: 10, searchSec: 5 }); + for (const chunk of chunks) { + expect(chunk.endSample).toBeGreaterThan(chunk.startSample); + } + expect(chunks[chunks.length - 1].endSample).toBe(samples.length); + }); + + it("handles an empty buffer", () => { + expect(planChunks(new Float32Array(0), RATE)).toEqual([]); + }); +}); diff --git a/electron/stt/chunking.ts b/electron/stt/chunking.ts new file mode 100644 index 000000000..8ef743eca --- /dev/null +++ b/electron/stt/chunking.ts @@ -0,0 +1,114 @@ +/** + * Splits a long recording into inference-sized chunks for the STT pipeline. + * + * Why chunk at all: whisper-stt-server answers a `/inference` request only once + * it has transcribed the WHOLE upload, so a 30-minute recording was one ~10 + * minute request with no progress and no recovery — one hiccup lost everything + * (and undici's 300s `headersTimeout` killed it outright before it ever + * finished). Per-chunk requests give the caller a progress signal, a retry unit, + * and requests short enough that no transport timeout is in play. + * + * Where the cut lands matters: slicing on a fixed grid cuts mid-word, and + * whisper then mis-transcribes both halves. So the boundary is nudged to the + * quietest 20ms frame within a search window around the ideal position — a + * pause between words in practice. + * + * ponytail: energy minimum, not a real VAD. whisper.cpp ships a Silero VAD, but + * it lives behind the server's own `--vad` flag and would run per REQUEST — it + * can't tell us where to cut BEFORE we upload. A plain RMS scan over a few + * seconds is enough to find a pause and costs nothing. If a recording is so + * dense that no pause exists in the window, the cut lands at the quietest point + * anyway and one word may be split; upgrade path is an overlap + de-duplication + * pass on the seam, which is a lot more code than it is worth today. + */ + +/** One chunk of the source buffer: `[startSample, endSample)`. */ +export interface SttChunkPlan { + startSample: number; + /** Exclusive. */ + endSample: number; +} + +/** Energy is measured over frames this long; a cut lands on a frame boundary. */ +const FRAME_MS = 20; + +export interface PlanChunksOptions { + /** Ideal chunk length. Shorter = smoother progress, more per-request overhead. */ + targetSec?: number; + /** How far on either side of the ideal boundary to hunt for a pause. */ + searchSec?: number; +} + +/** + * 90s is a compromise between three pressures: progress granularity (the bar + * only moves once a chunk lands), whisper's own quality (it decodes in 30s + * windows and loses cross-chunk context at every seam, so more seams is worse), + * and `whisperServer`'s 280s per-request ceiling — 90s of audio has to + * transcribe in under that on the SLOWEST machine we care about (~0.3x realtime; + * this Vulkan box does 3.1x, i.e. ~29s per chunk). + */ +const DEFAULT_TARGET_SEC = 90; + +/** + * Index of the quietest frame start in `[from, to)`. Both bounds are clamped by + * the caller; returns `from` when the range holds less than one full frame. + */ +function quietestFrameStart( + samples: Float32Array, + from: number, + to: number, + frameSamples: number, +): number { + let bestStart = from; + let bestEnergy = Number.POSITIVE_INFINITY; + for (let start = from; start + frameSamples <= to; start += frameSamples) { + let energy = 0; + for (let i = start; i < start + frameSamples; i++) { + energy += samples[i] * samples[i]; + } + if (energy < bestEnergy) { + bestEnergy = energy; + bestStart = start; + } + } + return bestStart; +} + +/** + * Plan the chunk boundaries for `samples`. Chunks are contiguous and cover the + * whole buffer: `chunks[0].startSample === 0`, each `endSample` is the next + * `startSample`, and the last one ends at `samples.length`. + */ +export function planChunks( + samples: Float32Array, + sampleRate: number, + options: PlanChunksOptions = {}, +): SttChunkPlan[] { + if (samples.length === 0 || sampleRate <= 0) return []; + const targetSamples = Math.max( + 1, + Math.round((options.targetSec ?? DEFAULT_TARGET_SEC) * sampleRate), + ); + const searchSamples = Math.max(0, Math.round((options.searchSec ?? 3) * sampleRate)); + const frameSamples = Math.max(1, Math.round((FRAME_MS / 1000) * sampleRate)); + + const chunks: SttChunkPlan[] = []; + let start = 0; + while (start < samples.length) { + const ideal = start + targetSamples; + // Last chunk: what's left is at most one target long, so there's nothing to cut. + if (ideal >= samples.length) { + chunks.push({ startSample: start, endSample: samples.length }); + break; + } + // The search window never reaches back to `start` (a zero-length chunk would + // loop forever) and never past the end of the buffer. + const from = Math.max(start + frameSamples, ideal - searchSamples); + const to = Math.min(samples.length, ideal + searchSamples); + const cut = to > from ? quietestFrameStart(samples, from, to, frameSamples) : ideal; + const endSample = Math.min(samples.length, Math.max(from, cut)); + chunks.push({ startSample: start, endSample }); + start = endSample; + } + return chunks; +} diff --git a/electron/stt/index.test.ts b/electron/stt/index.test.ts index df94fc102..7e255c1ff 100644 --- a/electron/stt/index.test.ts +++ b/electron/stt/index.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { planChunks } from "./chunking"; import { _resetSttManagerForTests, SttManager } from "./index"; import type { SttStatusEvent, SttTranscribeResponse } from "./transcriptionContract"; @@ -87,6 +88,79 @@ describe("SttManager", () => { expect(fakeWhisperServer.transcribe).toHaveBeenCalledOnce(); }); + it("splits a long recording and shifts each chunk's timestamps to absolute time", async () => { + // Every chunk reports the same relative segment at 1.0s; correct merging + // turns those into one absolute timestamp per chunk start. + fakeWhisperServer.transcribe.mockResolvedValue({ + segments: [{ text: "hello", startSec: 1, endSec: 1.5 }], + wordSegments: [{ word: "hello", startSec: 1, endSec: 1.5 }], + detectedLanguage: "en", + backend: "whispercpp-cpu", + }); + const samples = new Float32Array(200 * 16000); + const mgr = new SttManager(); + await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" }); + const result = await mgr.transcribe({ samples, language: "en" }); + + const expectedOffsets = planChunks(samples, 16000).map((c) => c.startSample / 16000); + expect(expectedOffsets.length).toBeGreaterThan(1); + expect(fakeWhisperServer.transcribe).toHaveBeenCalledTimes(expectedOffsets.length); + expect(result.segments.map((s) => s.startSec)).toEqual(expectedOffsets.map((o) => o + 1)); + expect(result.wordSegments.map((w) => w.startSec)).toEqual(expectedOffsets.map((o) => o + 1)); + }); + + it("reports monotonic progress that ends on the full duration", async () => { + const sink = vi.fn<(e: SttStatusEvent) => void>(); + const samples = new Float32Array(200 * 16000); + const mgr = new SttManager(); + await mgr.init({ statusSink: sink, modelsBaseDir: "/tmp/fake-stt-models" }); + sink.mockClear(); + await mgr.transcribe({ samples, language: "en" }); + + const progress = sink.mock.calls + .map(([event]) => event) + .filter((event) => event.completedSec !== undefined); + expect(progress[0].completedSec).toBe(0); + expect(progress[progress.length - 1].completedSec).toBe(200); + for (const event of progress) expect(event.totalSec).toBe(200); + for (let i = 1; i < progress.length; i++) { + expect(progress[i].completedSec).toBeGreaterThan(progress[i - 1].completedSec ?? -1); + } + }); + + it("pins later chunks to the language detected on the first one", async () => { + const mgr = new SttManager(); + await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" }); + await mgr.transcribe({ samples: new Float32Array(200 * 16000) }); + const languages = fakeWhisperServer.transcribe.mock.calls.map(([req]) => req.language); + expect(languages[0]).toBeUndefined(); + expect(languages.slice(1).every((l) => l === "en")).toBe(true); + }); + + it("retries a failed chunk instead of losing the whole transcription", async () => { + fakeWhisperServer.transcribe.mockRejectedValueOnce(new Error("helper died")).mockResolvedValue({ + segments: [{ text: "hello", startSec: 0, endSec: 0.5 }], + wordSegments: [{ word: "hello", startSec: 0, endSec: 0.5 }], + detectedLanguage: "en", + backend: "whispercpp-cpu", + }); + const mgr = new SttManager(); + await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" }); + const result = await mgr.transcribe({ samples: new Float32Array(16000), language: "en" }); + expect(result.segments).toHaveLength(1); + // start() again on the retry — the usual cause is a dead helper. + expect(fakeWhisperServer.start.mock.calls.length).toBeGreaterThan(1); + }); + + it("fails the request when a chunk never succeeds", async () => { + fakeWhisperServer.transcribe.mockRejectedValue(new Error("helper wedged")); + const mgr = new SttManager(); + await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" }); + await expect( + mgr.transcribe({ samples: new Float32Array(16000), language: "en" }), + ).rejects.toThrow("helper wedged"); + }); + it("shutdown() stops whisper-stt-server", async () => { const mgr = new SttManager(); await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" }); diff --git a/electron/stt/index.ts b/electron/stt/index.ts index 641a3abe6..3cd847c16 100644 --- a/electron/stt/index.ts +++ b/electron/stt/index.ts @@ -1,10 +1,13 @@ import path from "node:path"; import { app, type IpcMain } from "electron"; +import { planChunks } from "./chunking"; import { ensureModels, modelPaths } from "./modelManager"; import type { + SttPhraseSegment, SttStatusEvent, SttTranscribeRequest, SttTranscribeResponse, + SttWordSegment, } from "./transcriptionContract"; import { WhisperServerManager } from "./whisperServer"; @@ -13,18 +16,40 @@ import { WhisperServerManager } from "./whisperServer"; * * Workflow: * 1. `init()` spawns `whisper-stt-server` (or queues the call if it's busy). - * 2. `transcribe()` proxies the renderer's `Float32Array` through - * whisper-stt-server's HTTP `/inference`, which returns both phrase- and - * word-level segments in one pass (see whisperServer.ts). Word - * timestamps come from whisper.cpp's native DTW token timestamps - * (`t_dtw`, SMALL aheads preset, `flash_attn = false`), see + * 2. `transcribe()` splits the renderer's `Float32Array` into chunks + * (`chunking.ts`) and runs each through whisper-stt-server's HTTP + * `/inference`, which returns both phrase- and word-level segments in one + * pass (see whisperServer.ts). Word timestamps come from whisper.cpp's + * native DTW token timestamps (`t_dtw`, SMALL aheads preset, + * `flash_attn = false`), see * technical-documentation/architecture/transcription-and-captions.md § Decision rationale. * 3. `shutdown()` tears down on app quit. * * Status events fan out via `statusSink` so the renderer can drive its * "loading model" / "transcribing" indicator. + * + * Why chunked rather than one request: a 30-minute recording took ~10 minutes + * in a single `/inference` call — no progress to show, no way to recover from a + * transient failure without redoing everything, and long enough that the HTTP + * client's own header timeout killed it before whisper ever answered. Chunks + * turn that into a progress signal and a retry unit. + * + * Chunks run SEQUENTIALLY, and that is a measured choice, not an omission: + * whisper-stt-server holds a single model context, so concurrent `/inference` + * calls don't just serialize — they get SLOWER. Two 120s chunks took 76.9s one + * after the other and 144.1s fired together (0.53x, i.e. ~1.9x slower) on this + * Vulkan backend. A client-side worker pool is therefore a pessimisation. Real + * parallelism would need several server processes, each with its own copy of + * the model resident on the GPU; that trade (VRAM + spawn cost per worker) is + * worth revisiting only if a much smaller model ever becomes the default. */ +/** The renderer always sends mono 16 kHz (see `extractMono16kFromVideoUrl`). */ +const SAMPLE_RATE = 16_000; + +/** Attempts per chunk before the whole transcription fails. */ +const CHUNK_ATTEMPTS = 3; + export interface SttManagerInitOptions { statusSink?: (event: SttStatusEvent) => void; /** Override the models cache directory; defaults to `app.getPath("userData") + "/stt-models"`. */ @@ -36,6 +61,8 @@ export class SttManager { private modelsBaseDir: string | null = null; private statusSink: ((event: SttStatusEvent) => void) | null = null; private initPromise: Promise | null = null; + /** Kept from `prepare()` so a chunk retry can respawn a helper that died mid-run. */ + private modelPath: string | null = null; /** Wire a sink for the renderer status channel. */ setStatusSink(sink: ((event: SttStatusEvent) => void) | null): void { @@ -97,23 +124,97 @@ export class SttManager { }); const paths = modelPaths(modelsDir); + this.modelPath = paths.whisper; await this.server.start({ modelPath: paths.whisper }); this.emit({ phase: "transcribe" }); } - /** Run one transcription request through whisper-stt-server. */ + /** + * Run one chunk, retrying a few times before giving up on the whole request. + * + * A failure here is usually the helper process dying (OOM, driver reset) + * rather than a bad chunk, so each retry first re-runs `server.start()` — + * idempotent when the helper is alive, a respawn when it isn't. That is what + * makes a 30-minute transcription survive one bad minute instead of losing + * the twenty that already succeeded. + */ + private async transcribeChunk( + samples: Float32Array, + language: string | undefined, + ): Promise>> { + let lastError: unknown; + for (let attempt = 1; attempt <= CHUNK_ATTEMPTS; attempt++) { + try { + return await this.server.transcribe({ samples, language }); + } catch (error) { + lastError = error; + if (attempt === CHUNK_ATTEMPTS) break; + if (this.modelPath) { + await this.server.start({ modelPath: this.modelPath }).catch(() => undefined); + } + await new Promise((resolve) => setTimeout(resolve, 500 * attempt)); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + + /** Transcribe a whole recording, chunk by chunk, reporting progress as it goes. */ async transcribe(req: SttTranscribeRequest): Promise { await this.init(); - this.emit({ phase: "transcribe" }); - const phrase = await this.server.transcribe({ - samples: req.samples, - language: req.language, - }); - const backend = phrase.backend ?? this.server.status.backend ?? "whispercpp-cpu"; + + const totalSec = req.samples.length / SAMPLE_RATE; + const chunks = planChunks(req.samples, SAMPLE_RATE); + this.emit({ phase: "transcribe", completedSec: 0, totalSec }); + + const segments: SttPhraseSegment[] = []; + const wordSegments: SttWordSegment[] = []; + let detectedLanguage: string | null = null; + let backend = this.server.status.backend ?? "whispercpp-cpu"; + // Only the first chunk auto-detects. Every later chunk is forced onto that + // language: whisper can otherwise flip mid-recording on a chunk that opens + // with a proper noun or a silence, and "transcribe" the rest as another + // language entirely. + let language = req.language; + + for (const chunk of chunks) { + const offsetSec = chunk.startSample / SAMPLE_RATE; + const result = await this.transcribeChunk( + req.samples.subarray(chunk.startSample, chunk.endSample), + language, + ); + // Chunk-relative timestamps → absolute, the only thing every consumer + // (captions, transcript editor, trims) reads. + for (const segment of result.segments) { + segments.push({ + text: segment.text, + startSec: segment.startSec + offsetSec, + endSec: segment.endSec + offsetSec, + }); + } + for (const word of result.wordSegments) { + wordSegments.push({ + word: word.word, + startSec: word.startSec + offsetSec, + endSec: word.endSec + offsetSec, + confidence: word.confidence, + }); + } + if (!detectedLanguage && result.detectedLanguage && result.detectedLanguage !== "auto") { + detectedLanguage = result.detectedLanguage; + if (!language) language = detectedLanguage; + } + backend = result.backend ?? backend; + this.emit({ + phase: "transcribe", + completedSec: chunk.endSample / SAMPLE_RATE, + totalSec, + }); + } + return { - segments: phrase.segments, - wordSegments: phrase.wordSegments, - detectedLanguage: phrase.detectedLanguage, + segments, + wordSegments, + detectedLanguage: detectedLanguage ?? req.language ?? "auto", backend, }; } diff --git a/electron/stt/transcriptionContract.ts b/electron/stt/transcriptionContract.ts index 85b6c7152..2fa8a88eb 100644 --- a/electron/stt/transcriptionContract.ts +++ b/electron/stt/transcriptionContract.ts @@ -50,6 +50,14 @@ export interface SttStatusEvent { totalBytes?: number; /** Which model is downloading. */ model?: "whisper"; + /** + * Seconds of audio transcribed so far, and the total for this request. Only + * when `phase === "transcribe"`. Progress is reported per CHUNK (see + * `chunking.ts`), so it steps rather than sweeps — whisper gives no + * sub-request progress signal to interpolate from. + */ + completedSec?: number; + totalSec?: number; } /** IPC request: renderer → main. */ diff --git a/electron/stt/whisperServer.ts b/electron/stt/whisperServer.ts index 4762ebf6d..217a583ac 100644 --- a/electron/stt/whisperServer.ts +++ b/electron/stt/whisperServer.ts @@ -14,6 +14,24 @@ import { cleanupWav, writeSamplesAsWav } from "./wav"; /** whisper.cpp helper is stdio-shaped: stdin ignored, stdout/stderr captured. */ type WhisperChild = ChildProcessByStdio; +/** + * Per-request ceiling. whisper answers `/inference` only once the WHOLE upload + * is transcribed, so this bounds one chunk, not one recording. + * + * ponytail: 280s because Node's global fetch (undici) applies its own + * undocumented 300s `headersTimeout` that we cannot configure without taking a + * direct dependency on `undici` — going over it produces an opaque + * "TypeError: fetch failed" instead of anything actionable (this is exactly how + * a single 30-minute request died: killed at 300s while whisper needed 574s). + * Aborting at 280s keeps the failure OURS: named, logged with the helper's + * stderr, and retried by SttManager. The real ceiling this leaves is a machine + * so slow that one 90s chunk needs more than 280s (~0.3x realtime); the upgrade + * path is a direct `undici` dependency and + * `new Agent({ headersTimeout: 0, bodyTimeout: 0 })` as the fetch dispatcher, + * which removes the cliff entirely. + */ +const REQUEST_TIMEOUT_MS = 280_000; + /** * Owns the long-lived `whisper-stt-server` process used to recognize speech. * @@ -291,7 +309,26 @@ export class WhisperServerManager { form.set("file", blob, path.basename(opts.wavPath)); form.set("response_format", "verbose_json"); form.set("language", opts.language && opts.language !== "auto" ? opts.language : "auto"); - const res = await fetch(url, { method: "POST", body: form }); + let res: Response; + try { + res = await fetch(url, { + method: "POST", + body: form, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + // Name the failure. Node's global fetch reports BOTH a real transport + // error and its own header timeout as a bare "fetch failed", which is + // how a too-long request used to reach the user as an unactionable + // "Transcription failed" toast. + const reason = + error instanceof Error && error.name === "TimeoutError" ? "timed out" : "failed"; + throw new Error( + `whisper-stt-server /inference ${reason} after ${Math.round(REQUEST_TIMEOUT_MS / 1000)}s ` + + `(audio chunk too long for this machine, or the helper is wedged); ` + + `stderr=${this.stderrTail.slice(-256)}`, + ); + } if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`whisper-stt-server /inference HTTP ${res.status}: ${text.slice(0, 512)}`); diff --git a/src/lib/ai-edition/document/transcribe.ts b/src/lib/ai-edition/document/transcribe.ts index 460828b60..0c0a23aa8 100644 --- a/src/lib/ai-edition/document/transcribe.ts +++ b/src/lib/ai-edition/document/transcribe.ts @@ -9,9 +9,20 @@ import { toFileUrl } from "@/components/video-editor/projectPersistence"; import { extractMono16kFromVideoUrl, transcribeMono16kToSegments } from "@/lib/captioning"; import type { AxcutDocument, AxcutTranscript, AxcutTranscriptSegment, AxcutWord } from "../schema"; +/** + * What the caller can show while a transcription runs. `completedSec` / + * `totalSec` arrive only during `"transcribing"`, once the main process starts + * landing chunks — until then the phase alone is all there is to show. + */ +export interface TranscribeStatus { + phase: "extracting-audio" | "loading-model" | "transcribing"; + completedSec?: number; + totalSec?: number; +} + export interface TranscribeAssetOptions { language?: string; - onStatus?: (status: string) => void; + onStatus?: (status: TranscribeStatus) => void; signal?: AbortSignal; } @@ -27,12 +38,12 @@ export async function transcribeAsset( const videoUrl = toFileUrl(asset.originalPath); - options.onStatus?.("extracting-audio"); + options.onStatus?.({ phase: "extracting-audio" }); const audioResult = await extractMono16kFromVideoUrl(videoUrl, { signal: options.signal, }); - options.onStatus?.("transcribing"); + options.onStatus?.({ phase: "transcribing" }); // Only pass `language` to the worker when the caller forced a specific // code. `"auto"` (or any falsy value) leaves Whisper to detect from // the audio. The pipeline tags every chunk with the language it used @@ -44,6 +55,15 @@ export async function transcribeAsset( trimRegions: [], signal: options.signal, language: forcedLanguage, + // Forward the main process's per-chunk progress. Without this the status + // callback only ever fired the two coarse phases above, so a 30-minute + // recording showed one static "transcribing" for ten minutes. + onStatus: (status) => + options.onStatus?.({ + phase: status.phase === "model" ? "loading-model" : "transcribing", + completedSec: status.completedSec, + totalSec: status.totalSec, + }), }); const segments: AxcutTranscriptSegment[] = []; diff --git a/src/lib/captioning/transcribe.test.ts b/src/lib/captioning/transcribe.test.ts index 18c6cfa79..d8f461d81 100644 --- a/src/lib/captioning/transcribe.test.ts +++ b/src/lib/captioning/transcribe.test.ts @@ -115,12 +115,12 @@ describe("transcribeMono16kToSegments", () => { expect(result.segments).toEqual([{ text: "hello world", startSec: 0, endSec: 0.65 }]); }); - it("forwards 'model' / 'transcribe' phases to onStatus and tears the listener down", async () => { + it("forwards the whole status event to onStatus and tears the listener down", async () => { const onStatus = vi.fn(); mockApi.transcribe.mockImplementationOnce(async () => { // Simulate the IPC handler emitting a status event mid-flight. lastStatusCb?.({ phase: "model" }); - lastStatusCb?.({ phase: "transcribe" }); + lastStatusCb?.({ phase: "transcribe", completedSec: 90, totalSec: 300 }); return { segments: [], wordSegments: [{ word: "ok", startSec: 0, endSec: 0.1 }], @@ -130,8 +130,14 @@ describe("transcribeMono16kToSegments", () => { }); await transcribeMono16kToSegments(new Float32Array(1600), { onStatus }); - expect(onStatus).toHaveBeenCalledWith("model"); - expect(onStatus).toHaveBeenCalledWith("transcribe"); + expect(onStatus).toHaveBeenCalledWith({ phase: "model" }); + // The chunk progress must survive the hop, not just the phase — it is what + // drives the progress bar. + expect(onStatus).toHaveBeenCalledWith({ + phase: "transcribe", + completedSec: 90, + totalSec: 300, + }); // onStatus listener is detached once the promise settles. expect(lastStatusCb).toBeNull(); }); diff --git a/src/lib/captioning/transcribe.ts b/src/lib/captioning/transcribe.ts index a1b39d439..129949c22 100644 --- a/src/lib/captioning/transcribe.ts +++ b/src/lib/captioning/transcribe.ts @@ -28,6 +28,18 @@ export interface TranscribeMono16kResult { export type SttRendererStatusPhase = "model" | "transcribe"; +/** + * Progress the main process reports while a transcription runs. `completedSec` / + * `totalSec` are present only during `"transcribe"`, and only once chunking has + * started — they let the UI show a real bar instead of an indeterminate spinner + * for what can be several minutes of work. + */ +export interface SttRendererStatus { + phase: SttRendererStatusPhase; + completedSec?: number; + totalSec?: number; +} + interface RendererSttApi { transcribe: (request: { samples: Float32Array; language?: string }) => Promise<{ segments: CaptionSegment[]; @@ -35,7 +47,7 @@ interface RendererSttApi { detectedLanguage: string; backend: string; }>; - onStatus?: (callback: (event: { phase: SttRendererStatusPhase }) => void) => () => void; + onStatus?: (callback: (event: SttRendererStatus) => void) => () => void; } /** @@ -52,7 +64,7 @@ export function transcribeMono16kToSegments( samples: Float32Array, options?: { trimRegions?: TrimRegion[]; - onStatus?: (phase: SttRendererStatusPhase) => void; + onStatus?: (status: SttRendererStatus) => void; signal?: AbortSignal; language?: string; }, @@ -67,8 +79,7 @@ export function transcribeMono16kToSegments( return Promise.resolve({ segments: [], granularity: "word" }); } - const unsubscribe = - options?.onStatus && api.onStatus?.((event) => options.onStatus?.(event.phase)); + const unsubscribe = options?.onStatus && api.onStatus?.((event) => options.onStatus?.(event)); const forcedLanguage = options?.language && options.language !== "auto" ? options.language : undefined; // ponytail: word timestamps come back already absolute from whisper.cpp From 941718502d729d4f2ec81a252bb757bcbef1e4b4 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 12:35:07 +0200 Subject: [PATCH 02/11] feat(stt): show real transcription progress instead of an endless spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chunked pipeline now reports how much audio it has transcribed, so surface it. A 30-minute recording spends minutes in "Transcribing…", and a spinner that never changes is indistinguishable from a hang. Progress travels the same road the phase already did — `status.ts` owns the vocabulary, the store owns the queue, `TranscriptionStatus.tsx` owns how a job reads on screen: - `TranscriptionProgress` + `progressFraction` join `TranscriptionPhase` in status.ts, and `deriveAssetStatus` carries them onto the view. - The store's `onStatus` no longer casts its argument to `TranscriptionPhase`: the renderer's `TranscribeStatus` and `TranscriptionPhase` are now genuinely the same vocabulary, including the `"loading-model"` phase the cast used to paper over. Failure paths clear `progress` with `phase`, so a failed job cannot leave a stale bar. - `TranscriptionStatusDot`'s sibling `TranscriptionProgressBar` renders a determinate bar, and the label gains a percentage. Both render nothing until the run reports measurable progress. Queued, extracting audio and downloading the model have no fraction to report, and a bar pinned at 0% reads as "stuck" where the spinner reads as "working". --- .../ai-edition/TranscriptionStatus.tsx | 53 +++++++++++++++++-- src/components/ai-edition/v4/MediaStage.tsx | 11 +++- .../ai-edition/store/transcriptionStore.ts | 21 ++++++-- .../ai-edition/transcription/status.test.ts | 25 +++++++++ src/lib/ai-edition/transcription/status.ts | 28 ++++++++-- 5 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src/components/ai-edition/TranscriptionStatus.tsx b/src/components/ai-edition/TranscriptionStatus.tsx index 5e17bd276..cd7618b61 100644 --- a/src/components/ai-edition/TranscriptionStatus.tsx +++ b/src/components/ai-edition/TranscriptionStatus.tsx @@ -9,7 +9,10 @@ import { Loader2 } from "lucide-react"; import { useScopedT } from "@/contexts/I18nContext"; -import type { AssetTranscriptionView } from "@/lib/ai-edition/transcription/status"; +import { + type AssetTranscriptionView, + progressFraction, +} from "@/lib/ai-edition/transcription/status"; /** Human-readable state of one asset's transcript, in the user's language. */ export function useTranscriptionLabel(): (view: AssetTranscriptionView) => string { @@ -20,8 +23,16 @@ export function useTranscriptionLabel(): (view: AssetTranscriptionView) => strin return t("mediaStage.transcriptReady"); case "queued": return t("mediaStage.pendingTranscription"); - case "running": - return t("mediaStage.transcribing"); + case "running": { + // Transcribing a long recording runs for minutes. A bare + // "Transcribing…" for that whole time is indistinguishable from a + // hang, so append the percentage as soon as the main process reports + // chunk progress — and only then (see `TranscriptionProgress`). + const fraction = progressFraction(view.progress); + return fraction === null + ? t("mediaStage.transcribing") + : `${t("mediaStage.transcribing")} ${Math.round(fraction * 100)}%`; + } case "empty": return t("mediaStage.noSpeechDetected"); case "failed": @@ -79,3 +90,39 @@ export function TranscriptionStatusDot({ /> ); } + +/** + * Determinate progress bar for a running transcription. Renders nothing unless + * the job actually reports measurable progress — a job that is queued, + * extracting audio or downloading the model has no meaningful fraction, and a + * bar pinned at 0% reads as "stuck" where the spinner reads as "working". + */ +export function TranscriptionProgressBar({ view }: { view: AssetTranscriptionView }) { + const label = useTranscriptionLabel()(view); + const fraction = view.status === "running" ? progressFraction(view.progress) : null; + if (fraction === null) return null; + return ( +
+
+
+ ); +} diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx index 8c4bca65c..cd3e2e424 100644 --- a/src/components/ai-edition/v4/MediaStage.tsx +++ b/src/components/ai-edition/v4/MediaStage.tsx @@ -14,7 +14,11 @@ import type { AssetTranscriptionView, } from "@/lib/ai-edition/transcription/status"; import { formatBytes } from "@/utils/formatBytes"; -import { TranscriptionStatusDot, useTranscriptionLabel } from "../TranscriptionStatus"; +import { + TranscriptionProgressBar, + TranscriptionStatusDot, + useTranscriptionLabel, +} from "../TranscriptionStatus"; import styles from "./EditorShellV4.module.css"; const ASSET_MIME = "application/x-axcut-asset"; @@ -268,6 +272,11 @@ export function MediaStage() {
+ {/* Renders itself away unless the run reports measurable progress. */} +
+ +
+ {selectedTranscription.failure ? (

{ const transcript = await transcribeAsset(doc, assetId, { language: job.language, signal: controller.signal, - onStatus: (phase) => patchJob(assetId, runId, { phase: phase as TranscriptionPhase }), + // `TranscribeStatus` and `TranscriptionPhase` are the same vocabulary on + // purpose (see status.ts), so this no longer needs a cast. `progress` + // only arrives during "transcribing"; carrying it through undefined the + // rest of the time is what lets the UI fall back to a spinner instead + // of a bar frozen at 0%. + onStatus: (status) => + patchJob(assetId, runId, { + phase: status.phase, + progress: + status.completedSec !== undefined && status.totalSec !== undefined + ? { completedSec: status.completedSec, totalSec: status.totalSec } + : undefined, + }), }); if (controller.signal.aborted) { dropJob(assetId, runId); @@ -400,7 +415,7 @@ async function runJob(assetId: string, job: TranscriptionJob): Promise { } if (!isCurrentRun(assetId, runId)) return; // superseded by a newer request const failure = classifyTranscriptionError(error); - patchJob(assetId, runId, { status: "failed", phase: undefined, failure }); + patchJob(assetId, runId, { status: "failed", phase: undefined, progress: undefined, failure }); flushSettleWaiters(assetId); await persistPermanentFailure(projectId, assetId, failure); // A transient failure is about the ENGINE, not about this media: the model diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts index adc21cd35..00d8864e1 100644 --- a/src/lib/ai-edition/transcription/status.test.ts +++ b/src/lib/ai-edition/transcription/status.test.ts @@ -5,6 +5,7 @@ import { classifyTranscriptionError, deriveAssetStatus, isPermanentFailure, + progressFraction, resolveTranscriptGate, transcriptHasSpeech, transcriptRelevantAssetIds, @@ -93,6 +94,30 @@ describe("deriveAssetStatus", () => { expect(derived).toEqual({ assetId: "asset_1", status: "running", phase: "transcribing" }); }); + it("carries chunk progress through to the view", () => { + const derived = deriveAssetStatus({ + assetId: "asset_1", + job: { + status: "running", + phase: "transcribing", + progress: { completedSec: 90, totalSec: 300 }, + }, + }); + expect(derived.progress).toEqual({ completedSec: 90, totalSec: 300 }); + expect(progressFraction(derived.progress)).toBeCloseTo(0.3); + }); + + it("leaves progress absent while nothing measurable is running", () => { + // Audio extraction and the model download have no fraction to report; the + // UI must get `undefined` so it keeps the spinner instead of a 0% bar. + const derived = deriveAssetStatus({ + assetId: "asset_1", + job: { status: "running", phase: "extracting-audio" }, + }); + expect(derived.progress).toBeUndefined(); + expect(progressFraction(derived.progress)).toBeNull(); + }); + it("reports ready from the document, with no job at all", () => { expect( deriveAssetStatus({ assetId: "asset_1", transcript: transcript("asset_1", ["hello"]) }) diff --git a/src/lib/ai-edition/transcription/status.ts b/src/lib/ai-edition/transcription/status.ts index fced8390a..f05454b66 100644 --- a/src/lib/ai-edition/transcription/status.ts +++ b/src/lib/ai-edition/transcription/status.ts @@ -17,8 +17,28 @@ export interface TranscriptionFailure { message: string; } -/** Which half of the pipeline a running job is in (mirrors `TranscribeAssetOptions.onStatus`). */ -export type TranscriptionPhase = "extracting-audio" | "transcribing"; +/** Which part of the pipeline a running job is in (mirrors `TranscribeAssetOptions.onStatus`). */ +export type TranscriptionPhase = "extracting-audio" | "loading-model" | "transcribing"; + +/** + * How far a running transcription has got, in seconds of audio. + * + * Only the `"transcribing"` phase reports this, and only once the main process + * starts landing chunks — audio extraction and the first-run model download + * have nothing to measure. Absent means "running, no measurable progress", not + * "zero": the UI must fall back to an indeterminate spinner rather than render + * a bar stuck at 0%. + */ +export interface TranscriptionProgress { + completedSec: number; + totalSec: number; +} + +/** `0..1`, or null when the job reports no measurable progress. */ +export function progressFraction(progress: TranscriptionProgress | undefined): number | null { + if (!progress || !(progress.totalSec > 0)) return null; + return Math.min(1, Math.max(0, progress.completedSec / progress.totalSec)); +} /** * A media that has no audio track (or one Whisper cannot read) will fail the @@ -72,6 +92,7 @@ export interface AssetTranscriptionView { assetId: string; status: AssetTranscriptionStatus; phase?: TranscriptionPhase; + progress?: TranscriptionProgress; failure?: TranscriptionFailure; } @@ -79,6 +100,7 @@ export interface AssetTranscriptionView { export interface TranscriptionJobLike { status: "queued" | "running" | "failed"; phase?: TranscriptionPhase; + progress?: TranscriptionProgress; failure?: TranscriptionFailure; } @@ -122,7 +144,7 @@ export function deriveAssetStatus(input: { }): AssetTranscriptionView { const { assetId, job, transcript, persistedFailure } = input; if (job && job.status !== "failed") { - return { assetId, status: job.status, phase: job.phase }; + return { assetId, status: job.status, phase: job.phase, progress: job.progress }; } if (transcript) { return { From afec7bc2c70f04d8ddc22b68bdacc8ca205abb78 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 16:12:27 +0200 Subject: [PATCH 03/11] fix(stt): type the caption status mock for the progress fields The mock listener in transcribe.test.ts declared its own event shape and still had the pre-progress one, so `tsc -p tsconfig.test.json` rejected the `completedSec`/`totalSec` the test itself asserts on. Runtime was fine, which is why vitest passed and only the CI typecheck caught it. --- src/lib/captioning/transcribe.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/captioning/transcribe.test.ts b/src/lib/captioning/transcribe.test.ts index d8f461d81..82e630b9a 100644 --- a/src/lib/captioning/transcribe.test.ts +++ b/src/lib/captioning/transcribe.test.ts @@ -23,7 +23,13 @@ import { transcribeMono16kToSegments } from "./transcribe"; * worker that the previous Web-Worker pipeline owned, so they run in any env. */ -type Listener = (event: { phase: "model" | "transcribe" }) => void; +// Mirrors `SttRendererStatus` — the mock must accept the progress fields, since +// carrying them across the IPC hop is exactly what this file asserts. +type Listener = (event: { + phase: "model" | "transcribe"; + completedSec?: number; + totalSec?: number; +}) => void; type RendererSttApi = { transcribe: (request: { samples: Float32Array; language?: string }) => Promise<{ From 4a9a7be503c59e170d24e36fcee8fa5ea31511b0 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 17:41:58 +0200 Subject: [PATCH 04/11] fix(stt): make the chunked run cancellable, and its failures legible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five defects from the review of this PR, all in the new chunk loop. `"auto"` defeated the cross-chunk language pin. The request contract spells it as the explicit way to ask for detection, and it is truthy, so `if (!language)` never fired and every chunk detected independently — the exact flip the pin exists to prevent. Normalized to `undefined`. Verified end-to-end on 352s of real speech: the chunks now go `[undefined, "en", "en", "en"]` where they were all-detect before. Nothing could stop a run. The chunk boundary is the natural interruption point and went unused: cancelling in the renderer left main transcribing every remaining chunk while the replacement request queued behind it, which is what made "regenerate in another language" look dead. `cancel()` bumps an epoch the loop checks between chunks, reachable over a new `stt:cancel` channel, and surfaces as an `AbortError` so the store drops the job quietly. Measured on a 375s clip: cancelling after chunk 1 returns in 19s, not 143s. The IPC status sink was a single slot each request saved and restored, so two overlapping transcriptions ended with the first one to finish silencing the other for the rest of its run — no progress, which reads as a hang. It is a Set now, and each request detaches only its own. Every fetch rejection claimed "after 280s". A helper that died a moment ago rejects in a millisecond, and telling the reader it spent 280s on an over-long chunk sends them somewhere else entirely. The timeout wording is reserved for an actual timeout, the cause is attached, and the body read is named too. A chunk that exhausts its retries now says where it died rather than failing a 30-minute recording with no position at all. Energy ties broke toward the earliest frame, so digital silence — a muted track, a gap between takes — pulled every cut back by the whole search window: 90s chunks became 87s, and the silent test case 10s became 5s. Ties now break toward the target. The pin's comment also records what a stale staged helper does to it, since that cost a full debugging detour: electron/native/bin is gitignored, a binary from before cc781806 echoes the request instead of resolving it, and the failure is completely silent. --- electron/electron-env.d.ts | 1 + electron/preload.ts | 2 + electron/stt/chunking.test.ts | 18 +++- electron/stt/chunking.ts | 32 +++++-- electron/stt/index.test.ts | 70 +++++++++++++--- electron/stt/index.ts | 140 ++++++++++++++++++++++++------- electron/stt/whisperServer.ts | 38 +++++++-- src/lib/captioning/transcribe.ts | 15 ++++ 8 files changed, 257 insertions(+), 59 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index dd761b0b0..2e7b4c6d2 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -390,6 +390,7 @@ interface Window { transcribe: ( request: import("./stt/transcriptionContract").SttTranscribeRequest, ) => Promise; + cancel: () => Promise; onStatus: ( callback: (event: import("./stt/transcriptionContract").SttStatusEvent) => void, ) => () => void; diff --git a/electron/preload.ts b/electron/preload.ts index dfc9db84f..eb23828b1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -422,6 +422,8 @@ contextBridge.exposeInMainWorld("electronAPI", { transcribe: (request: SttTranscribeRequest): Promise => { return ipcRenderer.invoke("stt:transcribe", request) as Promise; }, + /** Stop the running transcription at its next chunk boundary. */ + cancel: (): Promise => ipcRenderer.invoke("stt:cancel") as Promise, onStatus: (callback: (event: SttStatusEvent) => void) => { const listener = (_event: unknown, payload: SttStatusEvent) => callback(payload); ipcRenderer.on("stt:status", listener); diff --git a/electron/stt/chunking.test.ts b/electron/stt/chunking.test.ts index eef6d2006..db19cbcfb 100644 --- a/electron/stt/chunking.test.ts +++ b/electron/stt/chunking.test.ts @@ -45,18 +45,28 @@ describe("planChunks", () => { ]); }); - it("always makes progress, even on a fully silent buffer", () => { - // Every frame ties on energy, so the cut is whatever the scan settles on — - // what matters is that it never returns a zero-length chunk (infinite loop). + it("keeps the target when every frame ties, on a fully silent buffer", () => { + // Every frame scores exactly 0, so the tie-break is what decides. Keeping + // the earliest one pulled every cut back to `ideal - searchSec` — 5s chunks + // here instead of 10s, i.e. twice the requests and twice the seams, on the + // audio most likely to tie (a muted track, a gap between takes). const samples = new Float32Array(60 * RATE); const chunks = planChunks(samples, RATE, { targetSec: 10, searchSec: 5 }); + expect(chunks.map((c) => c.endSample / RATE)).toEqual([10, 20, 30, 40, 50, 60]); for (const chunk of chunks) { expect(chunk.endSample).toBeGreaterThan(chunk.startSample); } - expect(chunks[chunks.length - 1].endSample).toBe(samples.length); }); it("handles an empty buffer", () => { expect(planChunks(new Float32Array(0), RATE)).toEqual([]); }); + + it("makes progress even when the target is shorter than one energy frame", () => { + // Degenerate but reachable through the options: below one frame the scan has + // nothing to measure, and the boundary must still move or the loop spins. + const chunks = planChunks(new Float32Array(RATE), RATE, { targetSec: 0.001 }); + for (const chunk of chunks) expect(chunk.endSample).toBeGreaterThan(chunk.startSample); + expect(chunks[chunks.length - 1].endSample).toBe(RATE); + }); }); diff --git a/electron/stt/chunking.ts b/electron/stt/chunking.ts index 8ef743eca..5f36251da 100644 --- a/electron/stt/chunking.ts +++ b/electron/stt/chunking.ts @@ -50,14 +50,23 @@ export interface PlanChunksOptions { const DEFAULT_TARGET_SEC = 90; /** - * Index of the quietest frame start in `[from, to)`. Both bounds are clamped by - * the caller; returns `from` when the range holds less than one full frame. + * Index of the quietest frame start in `[from, to)`, breaking ties toward the + * frame nearest `preferred`. Both bounds are clamped by the caller; returns + * `from` when the range holds less than one full frame. + * + * The tie-break is not decoration. Digital silence — a muted track, a gap + * between takes — makes every frame in the window score exactly 0, and keeping + * the first one would pull every boundary back to `from`, i.e. shorten every + * chunk by the whole search window (90s → 87s by default, and 10s → 5s in the + * silent test case). That is extra requests and extra seams bought for nothing, + * against the very context loss `DEFAULT_TARGET_SEC` is sized to limit. */ function quietestFrameStart( samples: Float32Array, from: number, to: number, frameSamples: number, + preferred: number, ): number { let bestStart = from; let bestEnergy = Number.POSITIVE_INFINITY; @@ -66,7 +75,10 @@ function quietestFrameStart( for (let i = start; i < start + frameSamples; i++) { energy += samples[i] * samples[i]; } - if (energy < bestEnergy) { + if ( + energy < bestEnergy || + (energy === bestEnergy && Math.abs(start - preferred) < Math.abs(bestStart - preferred)) + ) { bestEnergy = energy; bestStart = start; } @@ -85,12 +97,15 @@ export function planChunks( options: PlanChunksOptions = {}, ): SttChunkPlan[] { if (samples.length === 0 || sampleRate <= 0) return []; + const frameSamples = Math.max(1, Math.round((FRAME_MS / 1000) * sampleRate)); + // One frame is the floor: a target shorter than the unit the scan works in + // would put the ideal boundary BEFORE the earliest legal cut, which is the + // only way the loop below could fail to make progress. const targetSamples = Math.max( - 1, + frameSamples, Math.round((options.targetSec ?? DEFAULT_TARGET_SEC) * sampleRate), ); const searchSamples = Math.max(0, Math.round((options.searchSec ?? 3) * sampleRate)); - const frameSamples = Math.max(1, Math.round((FRAME_MS / 1000) * sampleRate)); const chunks: SttChunkPlan[] = []; let start = 0; @@ -105,8 +120,11 @@ export function planChunks( // loop forever) and never past the end of the buffer. const from = Math.max(start + frameSamples, ideal - searchSamples); const to = Math.min(samples.length, ideal + searchSamples); - const cut = to > from ? quietestFrameStart(samples, from, to, frameSamples) : ideal; - const endSample = Math.min(samples.length, Math.max(from, cut)); + // No clamping needed on either side: `ideal >= from` because `targetSamples` + // is at least one frame, and every value the scan can return lies in + // `[from, to)` — so the cut is always past `start` and inside the buffer. + const endSample = + to > from ? quietestFrameStart(samples, from, to, frameSamples, ideal) : ideal; chunks.push({ startSample: start, endSample }); start = endSample; } diff --git a/electron/stt/index.test.ts b/electron/stt/index.test.ts index 7e255c1ff..ad0eaf976 100644 --- a/electron/stt/index.test.ts +++ b/electron/stt/index.test.ts @@ -128,10 +128,16 @@ describe("SttManager", () => { } }); - it("pins later chunks to the language detected on the first one", async () => { + // Both spellings of "detect it for me". `"auto"` is the one the request + // contract documents, and it is truthy — which is exactly how it used to slip + // past the pin and let every chunk detect its own language. + it.each([ + ["omitted", undefined] as const, + ["auto", "auto"] as const, + ])("pins later chunks to the language detected on the first one (%s)", async (_label, language) => { const mgr = new SttManager(); await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" }); - await mgr.transcribe({ samples: new Float32Array(200 * 16000) }); + await mgr.transcribe({ samples: new Float32Array(200 * 16000), language }); const languages = fakeWhisperServer.transcribe.mock.calls.map(([req]) => req.language); expect(languages[0]).toBeUndefined(); expect(languages.slice(1).every((l) => l === "en")).toBe(true); @@ -152,13 +158,40 @@ describe("SttManager", () => { expect(fakeWhisperServer.start.mock.calls.length).toBeGreaterThan(1); }); - it("fails the request when a chunk never succeeds", async () => { + it("fails the request when a chunk never succeeds, saying how far it got", async () => { fakeWhisperServer.transcribe.mockRejectedValue(new Error("helper wedged")); const mgr = new SttManager(); await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" }); - await expect( - mgr.transcribe({ samples: new Float32Array(16000), language: "en" }), - ).rejects.toThrow("helper wedged"); + // 200s in, the failure is on chunk 2 of 3 — "Transcription failed" alone + // tells the user nothing about a recording this long. + await expect(mgr.transcribe({ samples: new Float32Array(200 * 16000) })).rejects.toThrow( + /transcription failed \d+s into a 200s recording \(chunk 1\/3\).*helper wedged/, + ); + }); + + it("stops at the next chunk boundary when cancelled", async () => { + const mgr = new SttManager(); + await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" }); + // Cancel lands while the first chunk is in flight — the loop must not go on + // to the remaining ones, which is what left "regenerate" waiting on a run + // nobody wanted any more. + fakeWhisperServer.transcribe.mockImplementation(async () => { + mgr.cancel(); + return { + segments: [], + wordSegments: [], + detectedLanguage: "en", + backend: "whispercpp-cpu" as const, + }; + }); + const samples = new Float32Array(300 * 16000); + expect(planChunks(samples, 16000).length).toBeGreaterThan(1); + + const error = await mgr.transcribe({ samples }).catch((e: unknown) => e); + // `AbortError` by name, so the renderer treats it as "the user asked" and + // drops the job silently instead of toasting an engine failure. + expect((error as Error).name).toBe("AbortError"); + expect(fakeWhisperServer.transcribe).toHaveBeenCalledOnce(); }); it("shutdown() stops whisper-stt-server", async () => { @@ -188,12 +221,25 @@ describe("SttManager", () => { expect(fakeWhisperServer.start).toHaveBeenCalledOnce(); }); - it("setStatusSink replaces the previous sink (last call wins)", () => { + it("fans status out to every sink, and detaching one leaves the others", async () => { const mgr = new SttManager(); - const a = vi.fn(); - const b = vi.fn(); - mgr.setStatusSink(a); - mgr.setStatusSink(b); - expect(mgr.getStatusSink()).toBe(b); + const a = vi.fn<(e: SttStatusEvent) => void>(); + const b = vi.fn<(e: SttStatusEvent) => void>(); + await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" }); + const detachA = mgr.addStatusSink(a); + mgr.addStatusSink(b); + await mgr.transcribe({ samples: new Float32Array(16000), language: "en" }); + expect(a).toHaveBeenCalled(); + expect(b).toHaveBeenCalled(); + + // The whole point of the Set. Two overlapping IPC requests each attach a + // sink; when the first finishes and detaches, the second must keep getting + // its own progress instead of falling silent for the rest of its run. + detachA(); + a.mockClear(); + b.mockClear(); + await mgr.transcribe({ samples: new Float32Array(16000), language: "en" }); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalled(); }); }); diff --git a/electron/stt/index.ts b/electron/stt/index.ts index 3cd847c16..aaa597184 100644 --- a/electron/stt/index.ts +++ b/electron/stt/index.ts @@ -25,14 +25,15 @@ import { WhisperServerManager } from "./whisperServer"; * technical-documentation/architecture/transcription-and-captions.md § Decision rationale. * 3. `shutdown()` tears down on app quit. * - * Status events fan out via `statusSink` so the renderer can drive its + * Status events fan out to every attached sink so the renderer can drive its * "loading model" / "transcribing" indicator. * * Why chunked rather than one request: a 30-minute recording took ~10 minutes * in a single `/inference` call — no progress to show, no way to recover from a * transient failure without redoing everything, and long enough that the HTTP * client's own header timeout killed it before whisper ever answered. Chunks - * turn that into a progress signal and a retry unit. + * turn that into a progress signal, a retry unit, and — via `cancel()` — the + * only point where a run in flight can be stopped at all. * * Chunks run SEQUENTIALLY, and that is a measured choice, not an omission: * whisper-stt-server holds a single model context, so concurrent `/inference` @@ -50,6 +51,16 @@ const SAMPLE_RATE = 16_000; /** Attempts per chunk before the whole transcription fails. */ const CHUNK_ATTEMPTS = 3; +/** + * `AbortError` by name so the renderer's `isAbortError` recognizes it as "the + * user asked for this" rather than an engine failure worth a toast. + */ +function cancelledError(): Error { + const error = new Error("Transcription cancelled"); + error.name = "AbortError"; + return error; +} + export interface SttManagerInitOptions { statusSink?: (event: SttStatusEvent) => void; /** Override the models cache directory; defaults to `app.getPath("userData") + "/stt-models"`. */ @@ -59,23 +70,48 @@ export interface SttManagerInitOptions { export class SttManager { private readonly server = new WhisperServerManager(); private modelsBaseDir: string | null = null; - private statusSink: ((event: SttStatusEvent) => void) | null = null; + private readonly statusSinks = new Set<(event: SttStatusEvent) => void>(); private initPromise: Promise | null = null; /** Kept from `prepare()` so a chunk retry can respawn a helper that died mid-run. */ private modelPath: string | null = null; + /** + * Bumped by `cancel()`. The chunk loop compares it against the value it + * captured on entry, so a cancel that lands after a new run started cannot + * kill that new run. + */ + private cancelEpoch = 0; - /** Wire a sink for the renderer status channel. */ - setStatusSink(sink: ((event: SttStatusEvent) => void) | null): void { - this.statusSink = sink; + /** + * Attach a sink for the renderer status channel; returns its detach function. + * + * A SET rather than one slot: this used to be a single field that each IPC + * invocation saved and restored, so with two overlapping transcriptions the + * first to finish restored the sink captured at ITS start and left the other + * one emitting into nothing — no progress for the rest of its run, which + * reads as a hang. + */ + addStatusSink(sink: (event: SttStatusEvent) => void): () => void { + this.statusSinks.add(sink); + return () => { + this.statusSinks.delete(sink); + }; } - /** Read the currently-installed status sink (mostly for tests). */ - getStatusSink(): ((event: SttStatusEvent) => void) | null { - return this.statusSink; + private emit(event: SttStatusEvent): void { + for (const sink of this.statusSinks) sink(event); } - private emit(event: SttStatusEvent): void { - this.statusSink?.(event); + /** + * Stop the in-flight transcription at the next chunk boundary. + * + * ponytail: one epoch for the whole manager, not a handle per request. The + * pipeline runs one transcription at a time by construction (the renderer's + * queue serializes, and `WhisperServerManager` single-flights on top), so + * "cancel what is running" is the only question anyone can ask. Per-request + * tokens the day two recordings can transcribe at once. + */ + cancel(): void { + this.cancelEpoch++; } /** @@ -83,7 +119,7 @@ export class SttManager { * means the second caller just awaits the same completion. */ init(options: SttManagerInitOptions = {}): Promise { - if (options.statusSink) this.statusSink = options.statusSink; + if (options.statusSink) this.addStatusSink(options.statusSink); if (options.modelsBaseDir) this.modelsBaseDir = options.modelsBaseDir; if (!this.initPromise) { // A REJECTED init must not be cached. `prepare()` downloads a 253 MB @@ -135,8 +171,14 @@ export class SttManager { * A failure here is usually the helper process dying (OOM, driver reset) * rather than a bad chunk, so each retry first re-runs `server.start()` — * idempotent when the helper is alive, a respawn when it isn't. That is what - * makes a 30-minute transcription survive one bad minute instead of losing - * the twenty that already succeeded. + * makes a 30-minute transcription survive a helper that dies once mid-run. + * + * What it does NOT do is salvage a chunk that fails all three attempts: the + * request fails whole and the chunks that already succeeded go with it. A + * transcript silently missing 90 seconds in the middle is worse than no + * transcript, since nothing downstream (captions, trims, the transcript + * editor) could tell the gap from a silence. The caller is told how far it + * got instead — see the wrapper in `transcribe()`. */ private async transcribeChunk( samples: Float32Array, @@ -162,6 +204,7 @@ export class SttManager { async transcribe(req: SttTranscribeRequest): Promise { await this.init(); + const epoch = this.cancelEpoch; const totalSec = req.samples.length / SAMPLE_RATE; const chunks = planChunks(req.samples, SAMPLE_RATE); this.emit({ phase: "transcribe", completedSec: 0, totalSec }); @@ -170,18 +213,52 @@ export class SttManager { const wordSegments: SttWordSegment[] = []; let detectedLanguage: string | null = null; let backend = this.server.status.backend ?? "whispercpp-cpu"; - // Only the first chunk auto-detects. Every later chunk is forced onto that - // language: whisper can otherwise flip mid-recording on a chunk that opens - // with a proper noun or a silence, and "transcribe" the rest as another - // language entirely. - let language = req.language; + // Only the first chunk auto-detects; every later chunk is forced onto the + // language it resolved, so whisper cannot flip mid-recording on a chunk + // that opens with a proper noun or a silence and "transcribe" the rest as + // another language. + // + // `"auto"` must collapse to `undefined` here rather than merely falsy + // values: `SttTranscribeRequest` documents it as the explicit way to ask + // for detection, and it is TRUTHY — left in place it makes the pin below + // unreachable for every caller that spells its intent out. + // + // This depends on the helper reporting what it RESOLVED rather than + // echoing the request, which it only does since cc781806 (30/07/2026, + // `whisper_full_lang_id()` in electron/native/whisper-stt/src/main.cpp). + // A stale `electron/native/bin//whisper-stt-server` — the directory + // is gitignored, so a dev tree keeps whatever was last staged there — + // silently reverts this to "every chunk detects on its own": the echo + // comes back as the literal "auto", the guard below rejects it, and + // nothing anywhere says why. `scripts/stage-whisper-stt.sh` refuses to + // overwrite a local binary by design, so it will not rescue you either. + // Verified end-to-end on 352s of real speech: `[undefined,"en","en","en"]`. + let language = req.language && req.language !== "auto" ? req.language : undefined; - for (const chunk of chunks) { + for (const [index, chunk] of chunks.entries()) { + // Between chunks is the only place this loop can be interrupted, and it + // is enough: a chunk is bounded by `whisperServer`'s own request ceiling. + if (this.cancelEpoch !== epoch) throw cancelledError(); const offsetSec = chunk.startSample / SAMPLE_RATE; const result = await this.transcribeChunk( req.samples.subarray(chunk.startSample, chunk.endSample), language, - ); + ).catch((error) => { + // Say where it died. Without this the user gets "Transcription + // failed" for a 30-minute recording with no hint that 18 of those + // minutes were fine and the helper fell over at one specific spot. + // `Object.assign` rather than the `{ cause }` constructor option: the + // project targets ES2020, where that overload does not exist (see + // `BackgroundLoadError` in src/lib/wallpaper.ts for the same dance). + throw Object.assign( + new Error( + `transcription failed ${Math.round(offsetSec)}s into a ${Math.round(totalSec)}s ` + + `recording (chunk ${index + 1}/${chunks.length}): ` + + `${error instanceof Error ? error.message : String(error)}`, + ), + { cause: error }, + ); + }); // Chunk-relative timestamps → absolute, the only thing every consumer // (captions, transcript editor, trims) reads. for (const segment of result.segments) { @@ -214,7 +291,7 @@ export class SttManager { return { segments, wordSegments, - detectedLanguage: detectedLanguage ?? req.language ?? "auto", + detectedLanguage: detectedLanguage ?? language ?? "auto", backend, }; } @@ -239,10 +316,11 @@ export function _resetSttManagerForTests(): void { } /** - * Wire the IPC channel. Call this from `registerIpcHandlers` so the renderer - * can `invoke("stt:transcribe", request)` and receive `SttTranscribeResponse`. - * Status events fan out on `"stt:status"` (main → renderer push), scoped to - * the calling `webContents` so two windows don't cross-talk. + * Wire the IPC channels. Call this from `registerIpcHandlers` so the renderer + * can `invoke("stt:transcribe", request)` and receive `SttTranscribeResponse`, + * and `invoke("stt:cancel")` to stop a run it no longer wants. Status events + * fan out on `"stt:status"` (main → renderer push), scoped to the calling + * `webContents` so two windows don't cross-talk. */ export function registerSttIpc(ipcMain: IpcMain): void { const manager = getSttManager(); @@ -250,8 +328,9 @@ export function registerSttIpc(ipcMain: IpcMain): void { "stt:transcribe", async (event, req: SttTranscribeRequest): Promise => { const senderId = event.sender.id; - const previous = manager.getStatusSink(); - manager.setStatusSink((statusEvent) => { + // Attach for the life of THIS request only. Overlapping requests each + // own their own sink, so neither can silence the other on the way out. + const detach = manager.addStatusSink((statusEvent) => { if (event.sender.id === senderId && !event.sender.isDestroyed()) { event.sender.send("stt:status", statusEvent); } @@ -259,8 +338,11 @@ export function registerSttIpc(ipcMain: IpcMain): void { try { return await manager.transcribe(req); } finally { - manager.setStatusSink(previous); + detach(); } }, ); + ipcMain.handle("stt:cancel", () => { + manager.cancel(); + }); } diff --git a/electron/stt/whisperServer.ts b/electron/stt/whisperServer.ts index 217a583ac..087073a2e 100644 --- a/electron/stt/whisperServer.ts +++ b/electron/stt/whisperServer.ts @@ -321,19 +321,43 @@ export class WhisperServerManager { // error and its own header timeout as a bare "fetch failed", which is // how a too-long request used to reach the user as an unactionable // "Transcription failed" toast. - const reason = - error instanceof Error && error.name === "TimeoutError" ? "timed out" : "failed"; - throw new Error( - `whisper-stt-server /inference ${reason} after ${Math.round(REQUEST_TIMEOUT_MS / 1000)}s ` + - `(audio chunk too long for this machine, or the helper is wedged); ` + - `stderr=${this.stderrTail.slice(-256)}`, + // + // The timeout wording is reserved for an ACTUAL timeout: a helper that + // died a moment ago rejects in a millisecond, and telling the reader it + // spent 280s on an over-long chunk sends them to the wrong problem + // entirely. Everything else carries its own message, plus `cause` so the + // errno survives. + // `Object.assign` rather than the `{ cause }` constructor option: the + // project targets ES2020, where that overload does not exist. + throw Object.assign( + new Error( + error instanceof Error && error.name === "TimeoutError" + ? `whisper-stt-server /inference timed out after ${Math.round(REQUEST_TIMEOUT_MS / 1000)}s ` + + `(audio chunk too long for this machine, or the helper is wedged); ` + + `stderr=${this.stderrTail.slice(-256)}` + : `whisper-stt-server /inference failed: ` + + `${error instanceof Error ? error.message : String(error)}; ` + + `stderr=${this.stderrTail.slice(-256)}`, + ), + { cause: error }, ); } if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`whisper-stt-server /inference HTTP ${res.status}: ${text.slice(0, 512)}`); } - return (await res.json()) as WhisperJsonResponse; + // The same timeout still covers the body: it can fire between the headers + // and the last byte, and an unnamed `AbortError` here would read exactly + // like the "fetch failed" this whole block exists to replace. + return (await res.json().catch((error: unknown) => { + throw Object.assign( + new Error( + `whisper-stt-server /inference response was unreadable: ` + + `${error instanceof Error ? error.message : String(error)}`, + ), + { cause: error }, + ); + })) as WhisperJsonResponse; } /** Defensive number parse for `verbose_json` values that may arrive as strings. */ diff --git a/src/lib/captioning/transcribe.ts b/src/lib/captioning/transcribe.ts index 129949c22..0ac43da2b 100644 --- a/src/lib/captioning/transcribe.ts +++ b/src/lib/captioning/transcribe.ts @@ -47,6 +47,7 @@ interface RendererSttApi { detectedLanguage: string; backend: string; }>; + cancel?: () => Promise; onStatus?: (callback: (event: SttRendererStatus) => void) => () => void; } @@ -80,6 +81,12 @@ export function transcribeMono16kToSegments( } const unsubscribe = options?.onStatus && api.onStatus?.((event) => options.onStatus?.(event)); + // Aborting has to reach the MAIN process: the work is a chunk loop over there, + // and a renderer that merely stops awaiting still leaves the helper busy for + // minutes — with the replacement request queued behind it, which is what made + // "regenerate in another language" look dead. + const onAbort = () => void api.cancel?.(); + options?.signal?.addEventListener("abort", onAbort, { once: true }); const forcedLanguage = options?.language && options.language !== "auto" ? options.language : undefined; // ponytail: word timestamps come back already absolute from whisper.cpp @@ -115,7 +122,15 @@ export function transcribeMono16kToSegments( } return { segments, granularity, detectedLanguage: result.detectedLanguage }; }) + .catch((error: unknown) => { + // A run the caller cancelled surfaces as an abort, not as an engine + // failure: the store drops it silently instead of toasting the user + // about something they asked for. + if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError"); + throw error; + }) .finally(() => { + options?.signal?.removeEventListener("abort", onAbort); unsubscribe?.(); }); } From 2bb4e344907bd5e73c9e10caa2f318c0b0c49c3b Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 17:41:59 +0200 Subject: [PATCH 05/11] fix(ui): let the progress bar own its spacing, and name the model download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar was wrapped in a div carrying `margin: -8px 0 16px`, rendered unconditionally. `.mediaDetail` is a flex column, so those margins do not collapse: every media card that was not transcribing paid 8px of dead gap, and the failure hint below it sat 8px lower than intended. A wrapper cannot render itself away with its child — the margin belongs on the bar. `"loading-model"` was added to `TranscriptionPhase` in this PR, plumbed through `TranscribeStatus` and the store, and then read by nothing: every label switches on `status`, so the 253MB first-run download was labelled "Transcribing" like everything else. That download is the wait most often mistaken for a hang, which is what this PR is about, so it gets its own words rather than the plumbing getting deleted. --- .../ai-edition/TranscriptionStatus.tsx | 17 ++++++++++++++--- src/components/ai-edition/v4/MediaStage.tsx | 6 ++---- src/i18n/locales/ar/editor.json | 1 + src/i18n/locales/en/editor.json | 1 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/fr/editor.json | 1 + src/i18n/locales/it/editor.json | 1 + src/i18n/locales/ja-JP/editor.json | 1 + src/i18n/locales/ko-KR/editor.json | 1 + src/i18n/locales/pt-BR/editor.json | 1 + src/i18n/locales/ru/editor.json | 1 + src/i18n/locales/tr/editor.json | 1 + src/i18n/locales/vi/editor.json | 1 + src/i18n/locales/zh-CN/editor.json | 1 + src/i18n/locales/zh-TW/editor.json | 1 + 15 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/components/ai-edition/TranscriptionStatus.tsx b/src/components/ai-edition/TranscriptionStatus.tsx index cd7618b61..5d74dd55f 100644 --- a/src/components/ai-edition/TranscriptionStatus.tsx +++ b/src/components/ai-edition/TranscriptionStatus.tsx @@ -24,10 +24,15 @@ export function useTranscriptionLabel(): (view: AssetTranscriptionView) => strin case "queued": return t("mediaStage.pendingTranscription"); case "running": { + // The first-run model download is a 253 MB wait with nothing else on + // screen to explain it, so it gets its own words rather than being + // labelled "Transcribing" — this is the phase most often mistaken for + // a hang, and the one `phase` was carried through the store for. + if (view.phase === "loading-model") return t("mediaStage.downloadingModel"); // Transcribing a long recording runs for minutes. A bare // "Transcribing…" for that whole time is indistinguishable from a // hang, so append the percentage as soon as the main process reports - // chunk progress — and only then (see `TranscriptionProgress`). + // chunk progress — and only then (see `TranscriptionProgressBar`). const fraction = progressFraction(view.progress); return fraction === null ? t("mediaStage.transcribing") @@ -96,20 +101,26 @@ export function TranscriptionStatusDot({ * the job actually reports measurable progress — a job that is queued, * extracting audio or downloading the model has no meaningful fraction, and a * bar pinned at 0% reads as "stuck" where the spinner reads as "working". + * + * It owns its own spacing on purpose. A wrapper in the caller cannot render + * itself away with the bar, and in a flex column (where margins don't collapse) + * an empty one still takes its margins — 8px of dead gap under every media card + * that isn't transcribing. */ export function TranscriptionProgressBar({ view }: { view: AssetTranscriptionView }) { - const label = useTranscriptionLabel()(view); + const label = useTranscriptionLabel(); const fraction = view.status === "running" ? progressFraction(view.progress) : null; if (fraction === null) return null; return (

- {/* Renders itself away unless the run reports measurable progress. */} -
- -
+ {/* Renders itself away — spacing included — unless the run reports progress. */} + {selectedTranscription.failure ? (

Date: Mon, 3 Aug 2026 17:42:00 +0200 Subject: [PATCH 06/11] fix(build): bound a stalled ffmpeg fetch, and read the nix version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--connect-timeout` only covers the handshake. An origin that accepts the connection and then trickles never errors, so nothing retried and nothing fell through to the Debian mirror — the job would sit until the runner's six-hour limit, which is the failure the mirror was added to survive. `--speed-limit 1024 --speed-time 30` bounds the part `--connect-timeout` cannot reach, and cannot fire on a merely slow link for a ~10MB tarball. `spawnSync` reports a missing curl as `{ status: null, error: ENOENT }`, so "curl exited with null" twice over sent the reader hunting a network problem instead of a missing binary. The error is surfaced when present. The comment claimed a 404 mirror is "simply skipped"; with `--retry-all-errors` it is retried three times first. The flags stay — that retry is the whole point on a connection reset — and the comment now says what actually happens. nix/package.nix restated the version by hand and had already drifted two minors behind the app it names (1.6.0, then 1.8.0 against a 1.8.0-rc.6 tree). It reads package.json now. `npmDepsHash` still needs updating by hand, but that one fails loudly. --- nix/package.nix | 6 +++++- scripts/fetch-ffmpeg-macos.mjs | 29 +++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 0ec078312..6245dcb32 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -11,7 +11,11 @@ buildNpmPackage { nodejs = nodejs_22; pname = "openscreen"; - version = "1.8.0"; + # Read, not restated. A hand-copied version is one more thing to remember at + # release time and it had already drifted two minors behind the app it names. + # (`npmDepsHash` below still has to be updated by hand — that is Nix, not a + # choice — but it fails loudly, where a stale version number never does.) + version = (lib.importJSON ../package.json).version; src = let diff --git a/scripts/fetch-ffmpeg-macos.mjs b/scripts/fetch-ffmpeg-macos.mjs index 6104a2d96..d611aa627 100644 --- a/scripts/fetch-ffmpeg-macos.mjs +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -59,7 +59,10 @@ function run(cmd, args, opts = {}) { * * When the pin moves, a mirror may not carry the new version yet. That is not * a failure mode to design around — the list is tried in order and a source - * that 404s is simply skipped, with every attempt reported if none works. + * that 404s falls through to the next, with every attempt reported if none + * works. (`--retry-all-errors` does not except a 404, so a missing mirror costs + * its retries — a few seconds, once per pin bump. Narrowing that would mean + * giving up the retry on connection resets, which is the whole point.) */ const TARBALL_URLS = [ `https://ffmpeg.org/releases/ffmpeg-${VERSION}.tar.xz`, @@ -80,17 +83,28 @@ function downloadTarball(dest) { const failures = []; for (const url of TARBALL_URLS) { console.log(`Downloading ffmpeg ${VERSION} from ${new URL(url).host}…`); - // --connect-timeout bounds the fallback, and is not decoration: a throttled - // origin does not refuse, it hangs. Measured against ffmpeg.org after a few - // rapid fetches, a single connect sat for 75s before failing — times four - // attempts, that is five minutes of a build spent before the second source + // Two ceilings, because a stuck download stalls in two different ways. + // --connect-timeout covers the handshake: a throttled origin does not + // refuse, it hangs — measured against ffmpeg.org after a few rapid + // fetches, a single connect sat for 75s before failing, and times four + // attempts that is five minutes of a build spent before the second source // is even tried. 20s is far above any healthy handshake. + // --speed-limit/--speed-time covers everything AFTER the handshake, which + // --connect-timeout does not reach at all: an origin that accepts the + // connection and then trickles (or simply stops sending) never errors, so + // nothing here would retry or fall through — the job would sit until the + // runner's own six-hour limit. Aborting below 1 KiB/s sustained for 30s + // cannot fire on a link that is merely slow: the tarball is ~10 MB. const r = spawnSync( "curl", [ "-fsSL", "--connect-timeout", "20", + "--speed-limit", + "1024", + "--speed-time", + "30", "--retry", "3", "--retry-delay", @@ -103,7 +117,10 @@ function downloadTarball(dest) { { stdio: "inherit" }, ); if (r.status !== 0) { - failures.push(` ${url}\n curl exited with ${r.status}`); + // `r.error` is the case `status` cannot express: no curl on PATH gives + // `{ status: null, error: ENOENT }`, and reporting only "exited with + // null" twice sends the reader hunting a network problem. + failures.push(` ${url}\n ${r.error ? r.error.message : `curl exited with ${r.status}`}`); continue; } const actual = crypto.createHash("sha256").update(fs.readFileSync(dest)).digest("hex"); From bea568eae772ba4131658c7f8e4aa193a7f4bd5a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 19:49:33 +0200 Subject: [PATCH 07/11] fix(ui): show the detected language where it can actually be seen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language whisper resolved on chunk 1 — the one every later chunk is pinned to — reached the document and was rendered nowhere. It had a pill, in `SourceTranscriptModal`. That modal is mounted by `LeftPanel`'s `MediaPane`, and `LeftPanel` is `active === "chat" ? : ` with exactly one mount site passing the literal `active="chat"`. So `MediaPane`, `MediaList` and the pill are unreachable, and "did the language pin work?" had no answer in the UI at all — only in the saved .openscreen. Put it on the live surface, next to the status badge in the v4 MediaStage detail panel. It also belongs beside "Regenerate as": that selector is the control you set BECAUSE of what was detected, and it currently reads "Auto" next to a transcript that resolved "en". --- src/components/ai-edition/v4/MediaStage.tsx | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx index 13cdef7b8..a9933bc8f 100644 --- a/src/components/ai-edition/v4/MediaStage.tsx +++ b/src/components/ai-edition/v4/MediaStage.tsx @@ -270,6 +270,31 @@ export function MediaStage() { {transcriptionLabel(selectedTranscription)} + {/* The language whisper resolved on the first chunk, which every later + chunk was then pinned to. It had a pill in SourceTranscriptModal, + but that lives under LeftPanel's `MediaPane` — and the only mount + site is ``, a literal, so it renders + `ChatStripPanel` and nothing else. The value was reaching the + document and being displayed nowhere. It belongs next to + "Regenerate as" below in any case: that selector is the control + you set BECAUSE of what was detected. */} + {transcript?.language && transcript.language !== "auto" ? ( + + {t("mediaStage.detectedLanguage", { language: transcript.language })} + + ) : null} {/* Renders itself away — spacing included — unless the run reports progress. */} From ccbe526943315aa79ca65621dbd5a69e6d9badc3 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 20:03:06 +0200 Subject: [PATCH 08/11] perf(transcript): stop re-rendering every word to move one underline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrubbing a 30-minute recording (4501 words) stutters. The transcript pane subscribes to the playhead and re-renders per frame, which is by design — `TranscriptClipBlock` is memoised on `cueWordId` precisely so that the frames in between cost nothing. That memo's premise is playback: "cueWordId changes at word boundaries, a few times per second, not sixty". A scrub breaks it. Dragging the playhead crosses many words per frame, so `cueWordId` changes on EVERY frame, the block re-renders every frame, and it renders one component per transcript word. `TranscriptWord` had no memo, so all 4501 re-rendered to move one underline. Measured over a 40-frame scrub in jsdom, median per frame: words before after 100 19.6 ms 11.5 ms 500 24.0 ms 13.9 ms 1500 83.9 ms 24.7 ms 4501 132.6 ms 57.4 ms The same benchmark with the cue word held inside one word — so the block's memo bails out — costs 0.1 ms/frame at 4501 words. That isolates it: the linear `findCueWordId` scan and the playhead subscription are both free, and 100% of what remains is the block re-render itself. So this halves it but does not fix the shape: the cost is still proportional to transcript length, because the block still builds 4501 React elements per frame for the memo to then discard. The O(1) fix is to stop routing the cue through render at all — subscribe outside React and toggle a class on the two nodes that changed. That is a real change to a contentEditable with caret and selection handling, so it is not smuggled in here. --- src/components/ai-edition/RightPanes.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 0490f3105..5ac70471a 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -1042,7 +1042,22 @@ const TranscriptClipBlock = memo(function TranscriptClipBlock({ // words (inside a skip range) render red+strikethrough with a hover bin. // `isCue` highlights the word the playback head is currently inside with // an accent underline (matches axcut's `word.transcript-word.cue` rule). -function TranscriptWord({ +// +// `memo` for the same reason as `TranscriptClipBlock`, one level down — and it +// is the level that actually decides the cost. The block's memo assumes +// `cueWordId` moves "a few times per second, not sixty", which holds for +// playback at 1x and NOT for a scrub: dragging the playhead crosses many words +// per frame, so `cueWordId` changes on essentially every frame and the block +// re-renders. Without a memo here that meant re-rendering one component per +// transcript word, every frame. Measured over a 40-frame scrub in jsdom: +// 19.6 ms/frame at 100 words, 132.6 ms at 4501 (a real 30-minute recording) — +// the cost was simply proportional to transcript length. With the memo only +// the two words whose `isCue` actually flipped re-render. +// +// This holds because every other prop is referentially stable across a +// playhead tick: `cw` comes from the memoised `sections`, `target` from a +// `useMemo`, and both callbacks from `useCallback`s that do not depend on time. +const TranscriptWord = memo(function TranscriptWord({ cw, isCue, target, @@ -1198,7 +1213,7 @@ function TranscriptWord({ ) : null} ); -} +}); // ─── Caret / selection helpers ──────────────────────────────────── // Ponytail port of axcut's findCollapsedDeletionWordId. The non-collapsed From a29b393a2bd5e3b35264e4d23552ee9d0c5ebabb Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 20:12:30 +0200 Subject: [PATCH 09/11] feat(media): strip the Media timeline down to arranging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Media tab's timeline exists to add, remove and reorder clips. It carried the Edit tab's furniture anyway: a transport (play, prev/next, timecode, scrub bar), the Shift/Ctrl+Scroll hints, the zoom/pan window, and a playhead — none of which has anything to act on when there is no playback and no per-clip editing on that surface. All four are now gated on the `variant === "edit"` flag the component already had, and the "Arrange clips" caption centres, being alone in the header. Two behaviours go with them rather than being left inert, which is the part that would have bitten: - `startScrub` returns early. Seeking a playhead that is not rendered still wrote `currentTimeSec`, so a click on the Media timeline silently moved the Edit tab's preview from a screen displaying no time at all. - the Ctrl/Shift+wheel listener is not attached. Zooming with the zoom window gone leaves no control to undo it and no ruler reading to explain it. --- src/components/ai-edition/v4/V4Timeline.tsx | 135 ++++++++++++-------- 1 file changed, 83 insertions(+), 52 deletions(-) diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index ca173b155..162d3601a 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -518,6 +518,11 @@ export function V4Timeline({ const startScrub = useCallback( (e: ReactPointerEvent) => { if (e.button !== 0) return; + // Media has no playhead rendered, so there is nothing to scrub. Guarded + // here rather than at the three call sites: seeking an invisible cursor + // would still move `currentTimeSec`, i.e. silently reposition the Edit + // tab's preview from a screen that shows no time at all. + if (!showLanes) return; const target = e.target as HTMLElement; if (target.closest("[data-clip-id]") || target.closest(`.${styles.lanePill}`)) return; tl.clearSelection(); @@ -543,7 +548,7 @@ export function V4Timeline({ window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }, - [seekToClientX, tl, setCurrentTime], + [seekToClientX, tl, setCurrentTime, showLanes], ); const [activePillDrag, setActivePillDrag] = useState<{ @@ -718,6 +723,9 @@ export function V4Timeline({ useEffect(() => { const el = tracksRef.current; if (!el) return; + // Media shows no zoom window, so leave the wheel alone there: a zoom with + // no control to undo it and no ruler reading to explain it is a trap. + if (!showLanes) return; const onWheelNative = (e: WheelEvent) => { const r = el.getBoundingClientRect(); const viewportPct = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); @@ -751,7 +759,7 @@ export function V4Timeline({ }; el.addEventListener("wheel", onWheelNative, { passive: false }); return () => el.removeEventListener("wheel", onWheelNative); - }, []); + }, [showLanes]); // Track the tracks' content width for the ruler. .tlTracks and .tlRulerRow // carry the same horizontal padding and the tracks' scrollbar is hidden, so @@ -1323,7 +1331,19 @@ export function V4Timeline({ ) : ( -

+ // Media is an ARRANGING surface: add, remove, reorder. Nothing here + // plays or edits, so the transport, the scroll hints, the zoom nav and + // the playhead are absent rather than inert — this caption is the whole + // header, and it centres because it is alone in the row. +
{t("toolbar.arrangeClips")} @@ -1332,23 +1352,27 @@ export function V4Timeline({
)} - -
- - Shift+Scroll {t("labels.pan")} - - - Ctrl+Scroll {t("labels.zoom")} - -
+ {showLanes ? ( + <> + +
+ + Shift+Scroll {t("labels.pan")} + + + Ctrl+Scroll {t("labels.zoom")} + +
+ + ) : null}
{/* Ruler + tracks share one relative wrapper so a single playhead overlay @@ -1516,41 +1540,48 @@ export function V4Timeline({ {/* Single playhead overlay spanning the ruler + tracks: fixed vertically (a cursor, so it doesn't scroll with the lanes) and sharing the exact same zoom/pan transform + width as the canvases, so its line stays - continuous from the ruler down through the clips and its head aligns. */} - + continuous from the ruler down through the clips and its head aligns. + Edit only: there is no playback to follow on the Media surface. */} + {showLanes ? ( + + ) : null} -
-
-
startNavDrag("pan", e)} - /> -
startNavDrag("left", e)} - > - -
-
startNavDrag("right", e)} - > - + {/* Zoom/pan window. Edit only: arranging clips needs the whole timeline + on screen at once, and there is nothing to zoom INTO without lanes. */} + {showLanes ? ( +
+
+
startNavDrag("pan", e)} + /> +
startNavDrag("left", e)} + > + +
+
startNavDrag("right", e)} + > + +
-
+ ) : null}
); } From 490e684d95efc54a579bcee4c5eabef0d7da8b6e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 20:20:44 +0200 Subject: [PATCH 10/11] perf(waveform): share the decode; stop holding 714 MB to draw 400 bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured head-to-head in Chromium on a 32-minute recording (68 MB on disk), which corrects what an earlier version of this message claimed: STREAMING total 12296 ms (peaks 12259 ms, ~192 kB peak memory) IN-MEMORY total 12198 ms (decodeAudioData 12003 ms → 714 MB, channel slice copy 160 ms) So the two pipelines take the SAME time, and the allocation is not the cost: the slice copy I suspected is 160 ms, 1.3% of the total. Browser audio decoding is the cost, in both paths, at roughly 160x realtime. What this commit therefore does and does not do: - Routing now estimates DECODED bytes from duration instead of comparing the file's size, which says nothing about decoded size on compressed video (68 MB → 714 MB here). That is a MEMORY fix — 714 MB down to ~192 kB — and buys no speed. The existing 256 MB threshold was already meant to protect memory; it just measured the wrong quantity, so the streaming path never ran for recordings that clearly needed it. - The cache moves from a `useRef` (one per mounted component) to module scope, with an in-flight map so N clips of one asset share a single decode. Every clip used to decode independently, and every unmount threw the result away, so a Media↔Edit switch re-decoded the whole recording. That is a real fix, but only for REPEAT mounts — the first waveform of a session still waits the full 12 s. Making the first one fast needs a different pipeline, not a different route: the bundled ffmpeg produces waveform-grade PCM for this same file in 2038 ms (`-vn -ac 1 -ar 1000 -f s16le`, 3.9 MB out), 6x faster than the browser and in the main process. With peaks cached on disk it would be paid once per recording, ever. Not attempted here. Both behaviours are covered, and both tests fail against the previous code. --- src/components/ai-edition/v4/V4Timeline.tsx | 4 +- src/hooks/useAudioPeaks.test.ts | 87 ++++++++++++++++ src/hooks/useAudioPeaks.ts | 105 +++++++++++++++----- 3 files changed, 169 insertions(+), 27 deletions(-) create mode 100644 src/hooks/useAudioPeaks.test.ts diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 162d3601a..860bf69f9 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -183,7 +183,9 @@ const ClipWaveform = memo(function ClipWaveform({ sourceStartSec: number; sourceEndSec: number; }) { - const peaks = useAudioPeaks(videoUrl); + // The duration is what tells `useAudioPeaks` whether this recording is small + // enough to decode whole — the file's byte size does not, on compressed video. + const peaks = useAudioPeaks(videoUrl, assetDurationSec); const bars = useMemo(() => { if (!peaks || peaks.length === 0 || !assetDurationSec) return null; const totalBlocks = Math.floor(peaks.length / 2); diff --git a/src/hooks/useAudioPeaks.test.ts b/src/hooks/useAudioPeaks.test.ts new file mode 100644 index 000000000..77a850b78 --- /dev/null +++ b/src/hooks/useAudioPeaks.test.ts @@ -0,0 +1,87 @@ +// Two properties that decide whether a long recording's waveform appears +// quickly or not at all: which pipeline a file is routed to, and how many times +// it is decoded. +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAudioPeaks } from "./useAudioPeaks"; + +const streamingCalls = vi.fn(); +const inMemoryCalls = vi.fn(); + +vi.mock("./streamingAudioPeaks", () => ({ + computePeaksFromFileStreaming: async () => { + streamingCalls(); + return new Float32Array([0, 1]); + }, +})); + +vi.mock("@/lib/exporter/localSourceFile", () => ({ + materializeLocalSourceFile: async (_url: string, name: string) => ({ name }), + releaseLocalSourceFile: () => {}, +})); + +vi.mock("@/lib/exporter/streamingDecoder", () => ({ + loadFileAsArrayBuffer: async () => { + inMemoryCalls(); + return { data: new ArrayBuffer(8) }; + }, +})); + +// A 68 MB file — comfortably under the 256 MB in-memory threshold, which is +// exactly why routing on file size sent a 32-minute recording down the +// decode-everything path. +const FILE_BYTES = 68 * 1024 * 1024; +const THIRTY_TWO_MINUTES = 1951; + +beforeEach(() => { + streamingCalls.mockClear(); + inMemoryCalls.mockClear(); + (window as unknown as { electronAPI: unknown }).electronAPI = { + getReadableFileInfo: async () => ({ success: true, size: FILE_BYTES }), + }; +}); + +afterEach(cleanup); + +describe("useAudioPeaks", () => { + it("streams a long recording instead of decoding it whole", async () => { + const { result } = renderHook(() => useAudioPeaks("/tmp/long-a.mp4", THIRTY_TWO_MINUTES)); + await waitFor(() => expect(result.current).not.toBeNull()); + expect(streamingCalls).toHaveBeenCalledOnce(); + // The whole point: 68 MB on disk is 656 MB decoded, so this must NOT be + // the path that reads the file and hands it to decodeAudioData. + expect(inMemoryCalls).not.toHaveBeenCalled(); + }); + + it("keeps decoding short clips in memory", async () => { + // Only the ROUTE is asserted: the in-memory path then needs a real + // AudioContext and a Worker, neither of which jsdom has. + renderHook(() => useAudioPeaks("/tmp/short-a.mp4", 20)); + await waitFor(() => expect(inMemoryCalls).toHaveBeenCalled()); + expect(streamingCalls).not.toHaveBeenCalled(); + }); + + it("decodes a file once, however many clips mount it and however often", async () => { + const url = "/tmp/long-b.mp4"; + // Three clips of the same asset, mounted together. + const a = renderHook(() => useAudioPeaks(url, THIRTY_TWO_MINUTES)); + const b = renderHook(() => useAudioPeaks(url, THIRTY_TWO_MINUTES)); + const c = renderHook(() => useAudioPeaks(url, THIRTY_TWO_MINUTES)); + await waitFor(() => expect(a.result.current).not.toBeNull()); + await waitFor(() => expect(b.result.current).not.toBeNull()); + await waitFor(() => expect(c.result.current).not.toBeNull()); + expect(streamingCalls).toHaveBeenCalledOnce(); + + // Unmount everything — this is a Media↔Edit tab switch — and come back. + // With the cache scoped to a component ref, this re-decoded the whole + // recording every single time. + act(() => { + a.unmount(); + b.unmount(); + c.unmount(); + }); + const again = renderHook(() => useAudioPeaks(url, THIRTY_TWO_MINUTES)); + await waitFor(() => expect(again.result.current).not.toBeNull()); + expect(streamingCalls).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index daa0abf09..ed57671ca 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { materializeLocalSourceFile, releaseLocalSourceFile } from "@/lib/exporter/localSourceFile"; import { MAX_IN_MEMORY_SOURCE_BYTES } from "@/lib/exporter/sourceFileLimits"; import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder"; @@ -62,17 +62,43 @@ function computePeaksInWorker( } /** - * Routes to the right peaks pipeline for the source size. Small/remote files - * use the original decodeAudioData → worker path. Local recordings above the - * in-memory limit stream instead: the file is materialized into OPFS (reused by - * the export afterwards) and its audio is decoded chunk-by-chunk into peaks, so - * the whole recording is never held in memory. + * Bytes one second of decoded audio occupies in an `AudioBuffer`: Float32, + * stereo, 44.1 kHz. An estimate on purpose — it picks the pipeline, before + * anything has been decoded and while the real rate is still unknown. */ -async function computePeaksForUrl(videoUrl: string, signal?: AbortSignal): Promise { +const DECODED_BYTES_PER_SEC = 44_100 * 2 * 4; + +/** + * Routes to the right peaks pipeline. Small/remote files use the original + * decodeAudioData → worker path. Recordings too big to hold decoded stream + * instead: the file is materialized into OPFS (reused by the export afterwards) + * and its audio is decoded chunk-by-chunk into peaks, so the whole recording is + * never held in memory. + * + * "Too big" is measured on the DECODED size, estimated from duration — not on + * the file's bytes, which is close to meaningless here and is what this used to + * compare. Compression ratio is the entire point of a screen recording: a + * 32-minute capture is 68 MB on disk and 656 MB decoded, and the in-memory path + * then `slice()`s every channel again for the worker transfer. That is ~1.4 GB + * of transient allocation to draw 400 bars, and it sat comfortably under a + * 256 MB *file* threshold — so the streaming path built for exactly this case + * never ran. (`ffmpeg -vn -f null` decodes the same track in 2.1s: that is the + * floor all that allocation was being piled onto.) + */ +async function computePeaksForUrl( + videoUrl: string, + signal?: AbortSignal, + durationSec?: number, +): Promise { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI?.getReadableFileInfo) { const info = await window.electronAPI.getReadableFileInfo(videoUrl); - if (info.success && typeof info.size === "number" && info.size > MAX_IN_MEMORY_SOURCE_BYTES) { + const decodedBytes = (durationSec ?? 0) * DECODED_BYTES_PER_SEC; + if ( + info.success && + ((typeof info.size === "number" && info.size > MAX_IN_MEMORY_SOURCE_BYTES) || + decodedBytes > MAX_IN_MEMORY_SOURCE_BYTES) + ) { const filename = (videoUrl.split(/[\\/]/).pop() || "video").replace(/^file:/, ""); // signal also aborts the OPFS copy (unless the export shares it). const file = await materializeLocalSourceFile(videoUrl, filename, { signal }); @@ -89,16 +115,49 @@ async function computePeaksForUrl(videoUrl: string, signal?: AbortSignal): Promi return computePeaksInWorker(audioBuffer, signal); } +/** + * Peaks describe a FILE, so they are cached per file, at module scope. + * + * This used to be a `useRef` Map, i.e. one cache per mounted component. Peaks + * for a 32-minute recording cost seconds and (before the routing fix above) a + * gigabyte-plus of transient allocation, and that was paid again for every clip + * of the same asset, and again from scratch on every remount — switching + * Media↔Edit re-decoded the whole recording, which is what "the waveform takes + * ages to appear" actually was. + * + * `inFlight` is the other half: N clips of one asset mounting together must + * share a single decode instead of racing N of them. + */ +const peaksCache = new Map(); +const peaksInFlight = new Map>(); + +function loadPeaks(videoUrl: string, durationSec?: number): Promise { + const existing = peaksInFlight.get(videoUrl); + if (existing) return existing; + // Deliberately NOT wired to any component's AbortSignal: the work is shared, + // so one subscriber unmounting must not cancel it for the others. An unmount + // drops the result instead — and the cache means the next mount is free. + const promise = computePeaksForUrl(videoUrl, undefined, durationSec) + .then((p) => { + peaksCache.set(videoUrl, p); + return p; + }) + .finally(() => { + peaksInFlight.delete(videoUrl); + }); + peaksInFlight.set(videoUrl, promise); + return promise; +} + /** * Decodes audio from `videoUrl` into paired [min, max] peaks (length = 2 * N * blocks). Returns `null` while decoding, and stays `null` on no audio track or - * decode failure (silent degradation). Results are cached in a ref scoped to the - * hook instance, so they survive re-renders and waveform toggles but not unmount. + * decode failure (silent degradation). `durationSec` only picks the pipeline + * (see `computePeaksForUrl`); omitting it is safe, just slower on long files. */ -export function useAudioPeaks(videoUrl?: string): Float32Array | null { - const cacheRef = useRef>(new Map()); +export function useAudioPeaks(videoUrl?: string, durationSec?: number): Float32Array | null { const [peaks, setPeaks] = useState(() => - videoUrl ? (cacheRef.current.get(videoUrl) ?? null) : null, + videoUrl ? (peaksCache.get(videoUrl) ?? null) : null, ); useEffect(() => { @@ -107,7 +166,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { return; } - const cached = cacheRef.current.get(videoUrl); + const cached = peaksCache.get(videoUrl); if (cached) { setPeaks(cached); return; @@ -115,27 +174,21 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { setPeaks(null); let cancelled = false; - const controller = new AbortController(); - (async () => { - try { - const p = await computePeaksForUrl(videoUrl, controller.signal); - if (cancelled) return; - cacheRef.current.set(videoUrl, p); - setPeaks(p); - } catch (err) { - // AbortError means the effect cleaned up, so no state update needed. + loadPeaks(videoUrl, durationSec) + .then((p) => { + if (!cancelled) setPeaks(p); + }) + .catch((err: unknown) => { if (err instanceof DOMException && err.name === "AbortError") return; // No audio track or unsupported format: degrade to no waveform, but log // so an unexpectedly-missing waveform is diagnosable. console.warn("useAudioPeaks: could not decode audio for waveform:", err); if (!cancelled) setPeaks(null); - } - })(); + }); return () => { cancelled = true; - controller.abort(); }; }, [videoUrl]); From 1a53c52f57ce80d6c91ba64b5e9e6ab418cc1f02 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 3 Aug 2026 21:06:48 +0200 Subject: [PATCH 11/11] perf(waveform): decode peaks natively, once per recording ever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The waveform took ~12s to appear on a 32-minute recording because both renderer pipelines decode the whole audio track in Chromium. Measured on the same 68 MB file: decodeAudioData (whole track) 12003 ms 714 MB resident WebCodecs chunk-by-chunk 12259 ms ~192 kB resident ffmpeg in the main process 3382 ms nothing resident 3.6x, off the UI process, and the result is cached on disk keyed by path+size+mtime — so it is paid once per recording rather than once per session. The renderer keeps both old pipelines and falls back to them when no native binary resolves, so nothing loses its waveform. WHICH ffmpeg, because this is where it would have broken silently: electron-builder deliberately excludes the static `ffmpeg.exe` (109 MB, "nothing in the app spawns" it). Spawning that one would have worked in dev and failed in every installed build. The SHARED build is 1 MB and links the same av*.dll set the compositor already ships, so fetch-ffmpeg.mjs now stages it as `ffmpeg-shared.exe` — named apart so the packager's `!win32-*/ffmpeg.exe` rule keeps dropping the static build while this one ships under the existing `win32-*/*` include. No packaging rule changes. Peaks are folded incrementally out of ffmpeg's stdout (mono s16 at 16 kHz), so the 62 MB of PCM never exists at once, and the block maths matches audioPeaksWorker.ts and streamingAudioPeaks.ts exactly — a clip must not change shape depending on which pipeline drew it. macOS and Linux have no binary staged in electron/native/bin, so `resolveFfmpeg` returns null there and they keep today's behaviour unchanged. ponytail: the CLI, not libav bindings in the compositor addon. Skipping the spawn saves ~20 ms against a ~2000 ms decode, and would cost a new Rust surface, an N-API entry point and a build story on three platforms. Parallelism was measured and rejected too: 4 processes over segments ran 1735 ms against 1893 ms for one, and 8 ran 2100 ms — it is demux- and spawn-bound, not CPU-bound. The disk cache is the real win. --- electron/electron-env.d.ts | 4 + electron/ipc/handlers.ts | 26 ++ electron/media/__fixtures__/peaks-sample.m4a | Bin 0 -> 42079 bytes electron/media/audioPeaks.test.ts | 100 +++++++ electron/media/audioPeaks.ts | 288 +++++++++++++++++++ electron/preload.ts | 4 + scripts/fetch-ffmpeg.mjs | 10 +- src/hooks/useAudioPeaks.ts | 16 ++ 8 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 electron/media/__fixtures__/peaks-sample.m4a create mode 100644 electron/media/audioPeaks.test.ts create mode 100644 electron/media/audioPeaks.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 2e7b4c6d2..f6e12db3d 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -295,6 +295,10 @@ interface Window { message?: string; error?: string; }>; + getAudioPeaks: ( + filePath: string, + durationSec: number, + ) => Promise; readFileChunk: ( filePath: string, offset: number, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 5e0c3af82..c74a7397b 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -55,6 +55,7 @@ import { LlmConfigStore } from "../ai-edition/llm-config-store"; import { mainLogBuffer } from "../diagnostics/main-log-buffer"; import { mainT } from "../i18n"; import { RECORDINGS_DIR } from "../main"; +import { type AudioPeaksResult, getAudioPeaks } from "../media/audioPeaks"; import { readCursorRecordingFile as readCursorRecordingFileFrom, readCursorSidecar, @@ -3093,6 +3094,31 @@ export function registerIpcHandlers( } }); + // Waveform peaks for a timeline clip, decoded natively (see media/audioPeaks). + // The renderer's own pipelines take ~12s on a 32-minute recording because they + // decode the whole track in Chromium; ffmpeg does the same work in ~2s off the + // UI process, and the result is cached on disk so it is paid once per file. + // `peaks: null` means "no native path available" — the caller falls back to + // its own decoding rather than losing the waveform. + ipcMain.handle( + "get-audio-peaks", + async (_, filePath: string, durationSec: number): Promise => { + try { + // Same approval gate as every other read of a renderer-supplied path. + const normalizedPath = await approveReadableVideoPath(filePath); + if (!normalizedPath) { + return { success: false, message: "File path is not approved" }; + } + const peaks = await getAudioPeaks(normalizedPath, durationSec); + return { success: true, peaks }; + } catch (error) { + // A clip with no audio track lands here. Degrade quietly: the renderer + // draws no waveform, which is correct, and logs its own warning. + return { success: false, message: String(error) }; + } + }, + ); + // Cap renderer-requested chunk sizes so a buggy or compromised renderer // cannot make the main process allocate an arbitrarily large buffer. const MAX_IPC_CHUNK_BYTES = 64 * 1024 * 1024; diff --git a/electron/media/__fixtures__/peaks-sample.m4a b/electron/media/__fixtures__/peaks-sample.m4a new file mode 100644 index 0000000000000000000000000000000000000000..431172aa97c4d338e0e1de6db850ae0988f5c660 GIT binary patch literal 42079 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 aRO6Vr$zEf~5TRyr*W!$%m;|`QCI1I!m$Nkh literal 0 HcmV?d00001 diff --git a/electron/media/audioPeaks.test.ts b/electron/media/audioPeaks.test.ts new file mode 100644 index 000000000..fb20b2677 --- /dev/null +++ b/electron/media/audioPeaks.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +import { existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { ffmpegCandidates, peakBlockCount, resolveFfmpeg } from "./audioPeaks"; + +const ROOT = path.resolve(__dirname, "..", ".."); + +describe("peakBlockCount", () => { + it("matches the browser pipelines' block maths", () => { + // Same formula as audioPeaksWorker.ts / streamingAudioPeaks.ts: a clip must + // not change shape depending on which pipeline drew it. + expect(peakBlockCount(10)).toBe(2000); + expect(peakBlockCount(60)).toBe(12000); + // Capped, so a 30-minute recording costs the same DOM/array budget as a + // 2-minute one. + expect(peakBlockCount(1951)).toBe(24000); + expect(peakBlockCount(99999)).toBe(24000); + }); + + it("never returns zero blocks for a sliver of audio", () => { + expect(peakBlockCount(0.001)).toBe(1); + }); +}); + +describe("ffmpeg resolution", () => { + it("prefers the shared build the installer actually ships", () => { + const candidates = ffmpegCandidates(ROOT); + const shared = candidates.findIndex((c) => c.endsWith("ffmpeg-shared.exe")); + const vendorTree = candidates.findIndex((c) => c.includes("lgpl-shared")); + if (process.platform === "win32") { + expect(shared).toBeGreaterThanOrEqual(0); + // The static ffmpeg.exe is excluded from the Windows installer + // ("!win32-*/ffmpeg.exe"), so resolving to it would work in dev and fail + // in production. It must not be a candidate at all. + expect( + candidates.some((c) => c.endsWith(`bin${path.sep}win32-x64${path.sep}ffmpeg.exe`)), + ).toBe(false); + expect(shared).toBeLessThan(vendorTree); + } + }); + + it("honours the env override first", () => { + process.env.OPENSCREEN_FFMPEG_PATH = "/custom/ffmpeg"; + try { + expect(ffmpegCandidates(ROOT)[0]).toBe("/custom/ffmpeg"); + } finally { + process.env.OPENSCREEN_FFMPEG_PATH = undefined; + } + }); + + it("returns null rather than throwing when nothing is staged", () => { + expect(resolveFfmpeg(path.join(ROOT, "does", "not", "exist"))).toBeNull(); + }); +}); + +// Only runs where the binary is actually staged; skipped elsewhere rather than +// failing a checkout that has not run scripts/fetch-ffmpeg.mjs. +const staged = resolveFfmpeg(ROOT); +describe.runIf(staged)("decoding a real file", () => { + it("produces peaks in range, with real signal in them", async () => { + // A synthetic 5s 440 Hz tone from ffmpeg's own lavfi source — no user + // recording in the repo, and a signal whose shape is known rather than + // "whatever this capture happened to contain". + const fixture = path.join(ROOT, "electron", "media", "__fixtures__", "peaks-sample.m4a"); + if (!existsSync(fixture)) return; + const { getAudioPeaks } = await import("./audioPeaks"); + const peaks = await getAudioPeaks(fixture, 5); + expect(peaks).not.toBeNull(); + if (!peaks) return; + expect(peaks.length).toBe(peakBlockCount(5) * 2); + // [min, max] pairs, both inside [-1, 1], min <= 0 <= max (the folder starts + // each block at the silence baseline, like the worker does). + for (let i = 0; i < peaks.length; i += 2) { + expect(peaks[i]).toBeLessThanOrEqual(0); + expect(peaks[i + 1]).toBeGreaterThanOrEqual(0); + expect(peaks[i]).toBeGreaterThanOrEqual(-1); + expect(peaks[i + 1]).toBeLessThanOrEqual(1); + } + // Not all silence — otherwise everything above would pass on a pipeline + // that returned a zeroed array. + // + // The bound is tight rather than "> 0" because loose is the same as + // absent here: the mistakes worth catching are all scale errors — int16 + // divided by 65536 instead of 32768, a stereo downmix halving the signal, + // a block whose samples never get compared — and every one of them is a + // factor of two. 0.214 is what ffmpeg itself decodes this fixture to + // (verified with `-f s16le` straight to a file), so this asserts the fold + // agrees with the decoder rather than restating the fixture's nominal + // amplitude, which lavfi's volume filter does not actually deliver. + let mn = 0; + let mx = 0; + for (const v of peaks) { + if (v < mn) mn = v; + if (v > mx) mx = v; + } + expect(mx).toBeCloseTo(0.214, 1); + expect(mn).toBeCloseTo(-0.205, 1); + }, 120_000); +}); diff --git a/electron/media/audioPeaks.ts b/electron/media/audioPeaks.ts new file mode 100644 index 000000000..056cab54e --- /dev/null +++ b/electron/media/audioPeaks.ts @@ -0,0 +1,288 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { app } from "electron"; + +/** + * Waveform peaks for the timeline, computed in the main process with ffmpeg. + * + * WHY THIS EXISTS. The renderer had two pipelines and both decode the whole + * audio track in Chromium, which is the entire cost. Measured head-to-head on a + * 32-minute screen recording (68 MB): + * + * decodeAudioData (whole track) 12003 ms, 714 MB resident + * WebCodecs chunk-by-chunk streaming 12259 ms, ~192 kB resident + * ffmpeg -vn -ac 1 -ar 16000 ~2000 ms, nothing resident + * + * The two browser paths differ only in memory; ffmpeg is ~6x faster than both + * because a native AAC decoder is simply faster than Chromium's, and it runs + * off the UI process entirely. The peaks then get cached on disk, so the cost + * is paid once per recording rather than once per session. + * + * ponytail: the CLI, not libav bindings in the compositor addon. The addon + * would avoid a process spawn — worth ~20 ms against a ~2000 ms decode — for a + * new Rust surface, an N-API entry point and a build story on three platforms. + * Revisit only if peaks ever need to share a decode with something else. + */ + +/** IPC reply. `peaks: null` on success means "no native ffmpeg here" — a + * fallback signal, not a failure. */ +export interface AudioPeaksResult { + success: boolean; + peaks?: Float32Array | null; + message?: string; +} + +/** PCM the peaks are computed from. Mono (ffmpeg downmixes) so channels are + * already averaged, and 16 kHz because peak buckets are at most 200/s: that + * still leaves 80 samples per bucket, far more than a min/max needs. */ +const PCM_RATE = 16_000; + +/** Matches `audioPeaksWorker.ts` and `streamingAudioPeaks.ts` so all three + * render identically — a clip must not change shape with the pipeline. */ +const MAX_PEAK_BLOCKS = 24_000; +const PEAK_BLOCKS_PER_SEC = 200; + +/** A recording whose audio takes longer than this to decode is not a recording, + * it is a wedged ffmpeg. ~30x the worst measured case. */ +const DECODE_TIMEOUT_MS = 60_000; + +/** + * Where to find an ffmpeg that actually exists at runtime, in priority order. + * + * Note the Windows shape: `electron-builder.json5` deliberately excludes the + * STATIC `ffmpeg.exe` (109 MB) from the installer, so resolving to it would + * work in dev and fail in production — the exact class of bug that is invisible + * until someone runs the packaged app. The SHARED build is 1 MB and links the + * same `av*.dll` set the compositor already ships, so that is the one that gets + * packaged (see the `filter` in electron-builder.json5) and the one preferred + * here. + */ +export function ffmpegCandidates(here: string = process.cwd()): string[] { + const tag = `${process.platform}-${process.arch}`; + const exe = process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg"; + const env = process.env.OPENSCREEN_FFMPEG_PATH?.trim(); + const roots: string[] = []; + // `app` is absent when this module is imported by a test. + const appPath = (() => { + try { + return typeof app?.getAppPath === "function" ? app.getAppPath() : null; + } catch { + return null; + } + })(); + if (appPath) roots.push(appPath); + if (process.resourcesPath) roots.push(process.resourcesPath); + roots.push(here); + + const names = + process.platform === "win32" + ? [ + // Staged flat by fetch-ffmpeg.mjs, beside the av*.dll set it links + // against — the only ffmpeg the Windows installer carries. + "ffmpeg-shared.exe", + // The unpacked vendor tree, present in a dev checkout that has not + // re-run the fetch script. + path.join("ffmpeg-n8.1.2-win64-lgpl-shared", "bin", exe), + ] + : [exe]; + return [ + ...(env ? [env] : []), + ...roots.flatMap((root) => + names.map((n) => path.join(root, "electron", "native", "bin", tag, n)), + ), + ]; +} + +let cachedFfmpeg: string | null | undefined; + +/** First candidate that exists, or null when none does (callers fall back). */ +export function resolveFfmpeg(here?: string): string | null { + if (cachedFfmpeg !== undefined && here === undefined) return cachedFfmpeg; + const found = ffmpegCandidates(here).find((p) => existsSync(p)) ?? null; + if (here === undefined) cachedFfmpeg = found; + return found; +} + +/** Number of min/max blocks for a clip of `durationSec`. */ +export function peakBlockCount(durationSec: number): number { + return Math.min(MAX_PEAK_BLOCKS, Math.max(1, Math.ceil(durationSec * PEAK_BLOCKS_PER_SEC))); +} + +/** + * Folds a stream of mono int16 samples into `[min0, max0, min1, max1, ...]`. + * + * Incremental on purpose: the PCM for a 32-minute recording is 62 MB and never + * needs to exist all at once. Kept as a class rather than a closure so it holds + * only the counters it needs, not an enclosing scope. + */ +class PeakFolder { + private readonly peaks: Float32Array; + private readonly samplesPerBlock: number; + private sampleIndex = 0; + /** int16 straddling a chunk boundary: its low byte arrived, its high byte did not. */ + private pendingLowByte: number | null = null; + + constructor( + private readonly blocks: number, + totalSamples: number, + ) { + this.peaks = new Float32Array(blocks * 2); + this.samplesPerBlock = Math.max(1, totalSamples / blocks); + } + + push(chunk: Buffer): void { + let offset = 0; + if (this.pendingLowByte !== null && chunk.length > 0) { + this.addSample((chunk[0] << 8) | this.pendingLowByte); + this.pendingLowByte = null; + offset = 1; + } + const end = chunk.length - ((chunk.length - offset) % 2); + for (let i = offset; i < end; i += 2) { + this.addSample(chunk.readInt16LE(i)); + } + if (end < chunk.length) this.pendingLowByte = chunk[end]; + } + + private addSample(raw: number): void { + // readInt16LE is signed; the hand-assembled straddling sample is not. + const signed = raw > 32767 ? raw - 65536 : raw; + const value = signed / 32768; + const block = Math.min(this.blocks - 1, Math.floor(this.sampleIndex / this.samplesPerBlock)); + const lo = block * 2; + if (value < this.peaks[lo]) this.peaks[lo] = value; + if (value > this.peaks[lo + 1]) this.peaks[lo + 1] = value; + this.sampleIndex++; + } + + result(): Float32Array { + return this.peaks; + } +} + +/** Runs ffmpeg and folds its PCM straight into peaks. Never buffers the audio. */ +async function decodePeaks( + ffmpeg: string, + filePath: string, + durationSec: number, +): Promise { + const blocks = peakBlockCount(durationSec); + const folder = new PeakFolder(blocks, durationSec * PCM_RATE); + const child = spawn( + ffmpeg, + [ + "-hide_banner", + "-loglevel", + "error", + "-i", + filePath, + "-vn", + "-ac", + "1", + "-ar", + String(PCM_RATE), + "-f", + "s16le", + "-", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + return new Promise((resolve, reject) => { + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`ffmpeg timed out after ${DECODE_TIMEOUT_MS}ms on ${filePath}`)); + }, DECODE_TIMEOUT_MS); + + child.stdout.on("data", (c: Buffer) => folder.push(c)); + child.stderr.on("data", (c: Buffer) => { + stderr = (stderr + c.toString()).slice(-2048); + }); + child.once("error", (err) => { + clearTimeout(timer); + reject(err); + }); + child.once("close", (code) => { + clearTimeout(timer); + // A file with no audio track exits non-zero. That is not an error worth + // surfacing — it is a clip that legitimately has no waveform — but it is + // the caller's job to decide, so it still rejects, with the reason. + if (code !== 0) { + reject(new Error(`ffmpeg exited ${code}${stderr ? `: ${stderr.trim()}` : ""}`)); + return; + } + resolve(folder.result()); + }); + }); +} + +/** + * Cache key: path plus size plus mtime. A recording is immutable in practice, + * but keying on identity alone would serve stale peaks for a re-encoded or + * replaced file, and that failure is silent and confusing. + */ +async function cacheKey(filePath: string): Promise { + const info = await stat(filePath); + return createHash("sha1") + .update(`${filePath}:${info.size}:${info.mtimeMs}`) + .digest("hex") + .slice(0, 32); +} + +/** Null outside Electron (tests, any headless use): decoding still works, it + * just is not cached, rather than the whole call failing on a missing `app`. */ +function cacheDir(): string | null { + try { + return typeof app?.getPath === "function" + ? path.join(app.getPath("userData"), "audio-peaks") + : null; + } catch { + return null; + } +} + +/** + * Peaks for `filePath`, from disk when they have been computed before. + * + * The cache is what makes this feel instant: peaks for a given recording never + * change, so the ~2s decode is paid once ever rather than once per session. + * Returns null when no ffmpeg is available, so the renderer can fall back to + * its own pipelines instead of losing the waveform. + */ +export async function getAudioPeaks( + filePath: string, + durationSec: number, +): Promise { + const ffmpeg = resolveFfmpeg(); + if (!ffmpeg || !durationSec || durationSec <= 0) return null; + + const dir = cacheDir(); + const cachePath = dir ? path.join(dir, `${await cacheKey(filePath)}.f32`) : null; + if (cachePath) { + try { + const cached = await readFile(cachePath); + // A Buffer's memory may not be 4-byte aligned and its byteOffset is + // almost never 0 — copy rather than viewing it in place. + return new Float32Array( + cached.buffer.slice(cached.byteOffset, cached.byteOffset + cached.byteLength), + ); + } catch { + // Not cached yet. + } + } + + const peaks = await decodePeaks(ffmpeg, filePath, durationSec); + if (cachePath && dir) { + try { + await mkdir(dir, { recursive: true }); + await writeFile(cachePath, Buffer.from(peaks.buffer)); + } catch { + // A cache we cannot write is a slower next launch, not a failure. + } + } + return peaks; +} diff --git a/electron/preload.ts b/electron/preload.ts index eb23828b1..898f92902 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -284,6 +284,10 @@ contextBridge.exposeInMainWorld("electronAPI", { getReadableFileInfo: (filePath: string) => { return ipcRenderer.invoke("get-readable-file-info", filePath); }, + /** Native waveform peaks, disk-cached. See electron/media/audioPeaks.ts. */ + getAudioPeaks: (filePath: string, durationSec: number) => { + return ipcRenderer.invoke("get-audio-peaks", filePath, durationSec); + }, readFileChunk: (filePath: string, offset: number, length: number) => { return ipcRenderer.invoke("read-file-chunk", filePath, offset, length); }, diff --git a/scripts/fetch-ffmpeg.mjs b/scripts/fetch-ffmpeg.mjs index 1c42a92ce..9a98bcf1a 100644 --- a/scripts/fetch-ffmpeg.mjs +++ b/scripts/fetch-ffmpeg.mjs @@ -457,7 +457,15 @@ async function fetchSharedDlls(tag, binDir) { fs.copyFileSync(lib, dest); } } - console.log(`Vendored ${libs.length} shared librar(ies) -> ${binDir}`); + // The shared CLI too, beside the DLLs it links against. 1 MB, against + // the 109 MB of the static exe the installer excludes — and unlike that + // one, this is spawned at runtime: electron/media/audioPeaks.ts decodes + // waveform peaks with it, ~6x faster than either browser pipeline and + // off the UI process. Named apart from `ffmpeg.exe` on purpose, so the + // packager's `!win32-*/ffmpeg.exe` rule keeps dropping the static build + // while this one ships under the plain `win32-*/*` include. + fs.copyFileSync(exe, path.join(binDir, "ffmpeg-shared.exe")); + console.log(`Vendored ${libs.length} shared librar(ies) + ffmpeg-shared.exe -> ${binDir}`); } if (sdkDest) vendorFfmpegSdk(tmp, sdkDest); console.log("LGPL verified: safe to ship with an MIT app."); diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index ed57671ca..da7512e2c 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -91,6 +91,22 @@ async function computePeaksForUrl( durationSec?: number, ): Promise { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + + // Native first. Both browser pipelines below decode the whole track in + // Chromium — 12s on a 32-minute recording, whichever one runs — where ffmpeg + // in the main process takes ~2s and caches the result on disk, so the second + // time it is free. Anything that stops this from working (no ffmpeg staged, + // an unapproved path, a clip with no audio) falls through rather than + // dropping the waveform. + if (!isRemoteUrl && durationSec && window.electronAPI?.getAudioPeaks) { + try { + const native = await window.electronAPI.getAudioPeaks(videoUrl, durationSec); + if (native.success && native.peaks && native.peaks.length > 0) return native.peaks; + } catch { + // Fall through to the browser pipelines. + } + } + if (!isRemoteUrl && window.electronAPI?.getReadableFileInfo) { const info = await window.electronAPI.getReadableFileInfo(videoUrl); const decodedBytes = (durationSec ?? 0) * DECODED_BYTES_PER_SEC;