Skip to content

fix(meeting-recorder): long meetings can no longer fall into a transcription loop; chunks cut at pauses, a temperature ladder, and a safe re-transcribe (1.0.2) - #526

Merged
kh0pper merged 2 commits into
mainfrom
fix/meeting-recorder-loop
Oct 20, 2026

Conversation

@kh0pper

@kh0pper kh0pper commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Why

faster-whisper-server keeps condition_on_previous_text on and accepts one float temperature, so Whisper's own temperature fallback never runs server-side. On the meeting recorder's four-minute slices that meant a decoder could fall into a repetition loop (one sentence, or "thank you", over and over) and either return pages of it or never return at all. A newer server image does not help: the current upstream router still exposes neither knob.

What changes

The worker (bundles/meeting-recorder/server/)

  • audio.js (new): reads the 16 kHz mono WAV in fixed-size blocks, computes 100 ms frame energy, and cuts each chunk at the quietest frame between 20 and 30 s (ties go to the later frame, so flat audio cuts at 30 s). A chunk is at most one decoder window, so there is no previous text to condition on. Chunks are sliced straight from the WAV; ffmpeg now runs once per session, not once per slice.
  • loop-guard.js (new, engine-agnostic): Whisper's compression ratio (computed client-side when an engine does not report it), words per second, and the most-repeated 5-gram.
  • stt-client.js (new): one request per chunk at temperature 0, no prompt carried over, vad_filter on by default. If any segment's compression ratio is above 2.4 or the loop guard flags the text (> 4 words/s, or a 5-gram repeated > 8x), the chunk is re-sent at 0.2, 0.4, 0.6, 0.8 and 1.0, and the first clean result is kept. If none is clean, the least bad one is kept and the chunk is marked suspect. A timeout moves up the ladder, but only after a one-second "drain" request comes back, because the server keeps decoding a request the client abandoned and runs one at a time. Two timeouts end a chunk's ladder. A 4xx is a configuration error and stops the run.
  • Optional fallback endpoint, WHISPER_FALLBACK_URL / WHISPER_FALLBACK_MODEL (unset by default, so behaviour is unchanged). It takes any chunk the primary cannot answer or answers only suspectly. After two chunks in a row with the primary down, chunks go straight to the fallback and the primary is re-probed every ten chunks. This is groundwork for routing meetings to a second engine; per-engine chunk sizes come later.
  • Gaps instead of failures: a chunk nothing can read becomes [transcription gap: a to b] with its real span, and the run goes on. The run is marked failed if more than half the chunks are gaps, or if no endpoint answers five chunks in a row.
  • Resume: finished chunks are cached under chunks/, keyed by index and boundaries, with a settings fingerprint (version, models, VAD, language, thresholds, window, audio hash). A cache made under other settings is moved aside to chunks.stale-<time>/, never deleted.
  • Output: transcript.json stays an array of segments (now with chunk and suspect/gap flags), clamped to their chunk. A new chunks.json holds one record per chunk: engine, model, temperature, ratio, words/s, language, wall time, and every attempt. The markdown header names the model(s) that actually ran, adds a counts line when anything was retried, suspect or missing, and drops "transcribed locally". meta.json gains transcriber, gaps and suspect_chunks, all replaced in full on each run so nothing stale survives.
  • --retranscribe [--allow-gaps] <id>: re-runs a finished session. It refuses if the session is recording or has a live worker, if another session has a live worker, or if another retranscribe holds the lock. Until it commits, it writes only its own files (retranscribe.json, the lock, chunks/). It commits only a gap-free run. On commit it copies transcript.md/.json, chunks.json and the original meta.json to *.v1-loop.* (later runs use *.prev-<UTC time>.*, and no archive is ever overwritten). It does the same in the export folder the session already points at, which is checked to belong to that session. Only then does it swap the new files in. Nothing that existed before is deleted.
  • store.js: writeMeta writes a temp file and renames it, so the panel's status poll never reads half a file.
  • Hand patches on installed 1.0.0 copies are folded in: the per-attempt timeout (WHISPER_SLICE_TIMEOUT_MS and WHISPER_SLICE_ATTEMPTS still work as aliases), VAD on by default, and gap segments plus meta.gaps in the same {start, seconds, error} shape. The old slices/ folder is never touched.

Docs / manifest: README rewritten for chunks, the defences, every env var and re-transcription, with measured throughput. Its old 600 s slice default was wrong; the code said 240 s. The manifest no longer claims "nothing leaves the machine", since that holds only for a loopback endpoint. Version 1.0.2; registry rebuilt.

Tests

tests/meeting-recorder-transcribe.test.js: 40 tests against fake endpoints on loopback.

  • The cutter: quietest frame, flat audio, short tail, empty file, LIST chunk, zero data size.
  • The guard: ratio, repeated 5-gram, speed, short tail.
  • The ladder: accept at 0.6, all fail, loop guard, computed ratio, timeout plus drain, a body that stalls after the headers, a one-worker server that keeps decoding after an abort, two timeouts, 4xx, fast speaker keeps T=0, malformed answers.
  • No prompt, VAD default, language.
  • Fallback and breaker.
  • Gaps, resume, a truncated cache, a stale cache, the failure thresholds.
  • Retranscribe: archives byte-identical and every other file unchanged; export folder from the session, legacy path, folder ownership; an incomplete run leaves meta.json byte-identical and a re-run commits; every refusal; dead-lock takeover.
  • The CLI through a symlink.

Full suite: 10,123 tests, 10,101 pass, 0 fail, 22 skipped.

Live check

Four 3-minute clips of a real meeting (two of which made large-v3 loop when sent whole: 22 and 3.4 words/s, one sentence repeated 211x) were run through the new worker against a CPU faster-whisper-server large-v3 int8, compared with two independently built loop-free references (agreement between the references: WER 0.017):

clip words words/s max 5-gram repeat chunks ladder used WER vs ref 1 WER vs ref 2 time
1 475 2.64 2 7 no 0.004 0.008 40.2 s
2 439 2.44 3 8 no 0.002 0.025 42.5 s
3 491 2.73 2 7 no 0.031 0.027 42.4 s
4 474 2.63 3 7 no 0.008 0.013 41.8 s
pooled 29 0.012 0.018 ~4.3x real time

No loops, no gaps, no suspect chunks. Per-chunk speed was 1.9–3.2 words/s, so the 4 words/s guard has headroom. Each chunk took at most 7.9 s.

https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

… ladder, so a long meeting cannot loop

faster-whisper-server keeps condition_on_previous_text on and takes one
float temperature, so Whisper's own fallback never runs and a long slice
could decode into a repetition loop or never return.

The worker now cuts audio at the quietest 100 ms frame between 20 and
30 s (one decoder window), sends each chunk on its own at temperature 0
with no prompt, and re-sends any chunk whose segments compress past 2.4
or that an engine-agnostic loop guard flags (words per second, repeated
5-grams) at 0.2 ... 1.0. A timeout drains the server before the next
rung. A chunk nothing can read becomes a gap; finished chunks are cached
with a settings fingerprint so a re-run resumes. An optional fallback
endpoint (unset by default) takes chunks the primary cannot read cleanly.

--retranscribe re-runs a finished session, commits only a gap-free run,
and archives the old transcript, chunk records and meta as *.v1-loop.*
(later *.prev-<time>.*) before swapping in, deleting nothing.

Folds in the per-attempt timeout, VAD and gap handling that installed
1.0.0 copies carried as local patches (old env names still work).
writeMeta is now atomic.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ed transcription

Documents the chunking, the loop defences, every env var, re-transcription
and measured throughput; drops the "nothing leaves the machine" claim,
which holds only for a loopback endpoint, and the wrong 600 s slice default.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
@kh0pper
kh0pper merged commit 0e8fc40 into main Oct 20, 2026
4 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