Fix transcripts truncating mid-file, and bound transcription memory - #75
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
trimTrailingDegenerateTailscanned backwards from the end for a 6-word window with ≤2 distinct tokens, but onlybreaks 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 uhat 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_diarizationcallinglogits.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.tswindows 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.tsbuilds 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:
maxGapSput 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.
lib/align.tslib/disfluencies.tsfilter().pop()/find()→ two forward cursorsassignSpeakersDeliberately not done
The transcript renders every word as a
<span>(~10k DOM nodes for an hour). Virtualizing it would break the native text selectionuseTranscriptSelectiondepends on, so it needs its own design pass. The per-frameFloat32Arrayin 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 buildpass. 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 intests/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