Skip to content

Fix transcripts truncating mid-file, and bound transcription memory - #75

Merged
wassgha merged 3 commits into
mainfrom
fix/transcript-truncation-and-memory
Aug 6, 2026
Merged

Fix transcripts truncating mid-file, and bound transcription memory#75
wassgha merged 3 commits into
mainfrom
fix/transcript-truncation-and-memory

Conversation

@wassgha

@wassgha wassgha commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Two reports, one investigation: Safari reloading the tab on long media, and users on other browsers seeing the transcript stop after a few minutes.

The transcript truncation

trimTrailingDegenerateTail scanned backwards from the end for a 6-word window with ≤2 distinct tokens, but only breaks after finding one. With no loop at the end it therefore walked the entire transcript and cut from wherever it first matched — a tail trim only when the transcript happened to end in a loop.

A mid-conversation um uh um uh um uh at minute three took the remaining forty minutes with it. Reproduced at 8,025 words → 19. Verbatim models emit those runs constantly, which is why this surfaced when it did.

Now anchored to the end: if the last window isn't degenerate, nothing is trimmed. Regression test covers the mid-transcript case and the genuine trailing loop it was written for.

The memory ceiling

Diarization ran one forward pass over the whole file — the dominant allocation by a wide margin. A 230 MB input tensor for an hour, SincNet activations several times larger again in the onnxruntime heap, and post_process_speaker_diarization calling logits.tolist() to turn ~213k frames into as many JS arrays. Chromium usually threw and silently fell back to one speaker; WebKit killed the tab.

lib/diarize.ts windows it into 30 s passes with 5 s overlap. pyannote's class indices are only meaningful within a pass, so the overlap is used to match each window's classes onto the ones already emitted. Peak memory is now flat in duration, long files get speakers at all, and there's a real progress bar instead of an indeterminate spinner.

The decoded PCM was retained on the main thread purely for the waveform, which only ever wants one min/max pair per pixel column. lib/waveform.ts builds a bounded Int8 envelope (~4 MB ceiling; full sample resolution for short media) and the buffer is now transferred to the worker instead of copied. Removes two full-length copies.

Two smaller ones: maxGapS put no ceiling on segment length, so any continuous talk became one file-length slice copied into a padded buffer — capped at 120 s, split at the quietest interior frame. And the undo stack was unbounded, pinning a distinct words array per step — capped at 100.

Quadratic passes

Three hot paths were quadratic in transcript length. Each rewrite is verified output-identical to the original by differential fuzzing, not by inspection — worth stressing, because the disfluency rewrite changed behaviour on the first attempt (dropping placeholders, because "nearest end" is not "last word in start order") and only the fuzz caught it.

change verified
lib/align.ts nested scans over sorted arrays → binary search; 735 ms → 20 ms at one hour, and near-linear where it was quadratic (~7 s at three hours) identical on 1,500 random transcripts
lib/disfluencies.ts per-run filter().pop()/find() → two forward cursors identical on 3,000 random transcripts
assignSpeakers O(words × segments) → single cursor. Pre-existing, but only reachable now that diarization survives a long file identical on 4,000 random cases

Deliberately not done

The transcript renders every word as a <span> (~10k DOM nodes for an hour). Virtualizing it would break the native text selection useTranscriptSelection depends on, so it needs its own design pass. The per-frame Float32Array in the VAD loop is also untouched — the existing comment says the fresh buffer is deliberate.

Verification

Typecheck, lint, all 19 test files and npm run build pass. New suites: tests/diarize-test.ts (window tiling, speaker identity across a boundary, new speaker mid-file, continuous speaker, silence), tests/waveform-test.ts (agreement with the raw computation at render resolution, transient survival, clamping), plus segment-ceiling coverage in tests/vad-test.ts.

One thing worth checking before merge: windowed diarization is ~144 forward passes for an hour where there was previously one. Each is small and it turns a fast failure into real work — I'd estimate well under a minute, but I have not measured it against a real long recording.

🤖 Generated with Claude Code

wassgha and others added 2 commits August 5, 2026 20:47
Long recordings reload the tab on WebKit ("This webpage was reloaded
because it was using significant memory") and lose speakers everywhere
else. The causes were all allocations that scale with duration.

Diarization ran one forward pass over the entire file. That is a 230 MB
input tensor for an hour, SincNet activations several times larger again
inside the onnxruntime heap, and a post-processing step that calls
logits.tolist() to turn ~213k frames into as many JS arrays. Chromium
usually threw and fell back to a single speaker; WebKit killed the tab.
lib/diarize.ts windows the audio into bounded passes and stitches
pyannote's per-pass class indices together by shared activity in the
overlap, so peak memory no longer grows with duration and long files get
speakers at all.

The decoded PCM was also kept on the main thread for the waveform, which
only ever wants one min/max pair per pixel column. lib/waveform.ts builds
a bounded envelope instead and the buffer is transferred to the worker
rather than copied, removing two full-length copies.

Alongside those: cap ASR segments so continuous speech is not decoded as
one file-length slice, and cap the undo stack, which pinned a distinct
words array per step.

Three passes were quadratic in transcript length and only bite on long
files. Each rewrite is verified output-identical to the original by
differential fuzzing, not by inspection — the disfluency one caught a
real behaviour change on the first attempt.

- align.ts nested scans over sorted arrays -> binary search: 735ms -> 20ms
  at one hour, and near-linear where it was quadratic
- disfluencies.ts per-run filter().pop()/find() -> two forward cursors
- assignSpeakers O(words x segments) -> single cursor; pre-existing, but
  only reachable now that diarization survives a long file

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
app.rescript Ready Ready Preview Aug 6, 2026 4:47am

@wassgha
wassgha merged commit 8e8aa47 into main Aug 6, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant