Skip to content

fix(worker): transcribe the speech, not the padded track - #48

Merged
TheMeinerLP merged 3 commits into
mainfrom
fix/transcribe-only-the-speech
Aug 21, 2026
Merged

fix(worker): transcribe the speech, not the padded track#48
TheMeinerLP merged 3 commits into
mainfrom
fix/transcribe-only-the-speech

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Passing the whole decoded array with clip_timestamps made faster-whisper run log-mel extraction over the entire padded file — a speaker's track is mostly bit-exact zero. Measured 4954 MB for extraction alone on a 100-minute recording, which forced the production worker's memory limit from 6Gi to 12Gi as a stopgap.

This concatenates the gated speech first, so memory scales with speech instead of with session length.

peak RSS wall clock
before 5025 MB 331 s
after 2441 MB 318 s

Memory falls 51%. Speed does not change, deliberately — the design keeps one window boundary per clip so no encoder window ever spans a seam. Packing windows full would be roughly 2.2x faster and is a separate decision, unsafe until timestamp attribution is trustworthy, which is what this branch establishes.

The library's own shape was tried first and fails

Doing what faster-whisper's vad_filter branch does — concatenate, then restore_speech_timestamps — was measured end to end and broke badly: six utterances at known offsets came back as two segments, one of them 258 seconds long, because restore_speech_timestamps resolves a segment's start and end independently and nearly every 30-second window contains a join. A probed 1.00 s segment centred on a seam restored to 33.77 s.

Keeping clip_timestamps on the concatenated timeline preserves segment_size = min(nb_max_frames, content_frames - seek, seek_clip_end - seek), so a window can never cross a clip boundary.

The attribution blocker, found by all three reviewers independently

The first implementation picked a segment's clip from the midpoint of its reported times. That is safe against the ±5 ms frame-grid rounding it argued about — and not against the decoder reporting an end past the real audio in its window, which faster-whisper never clamps.

Reproduced on the real engine with that code: 57 of 462 segments reported an end past their clip, and 5 overran by more than half their own length, restoring 20–31 s from where they were spoken — one carrying 14 s of text. The displacement is always exactly one removed gap, so on a sparse recording it is minutes.

Fixed by reading Segment.seek — the first log-mel frame of the window the segment was decoded from — instead of inferring the clip from the times, then clamping into that clip's extent. Guarantee: a restored segment always lies inside the file-timeline extent of the clip whose window produced it, whatever the decoder claimed.

A test for the library behaviour the design rests on

test_a_window_never_spans_two_clips drives the real generate_segments loop with no model weights: the model is built with object.__new__ and given only what the seek loop reads, with encode/get_prompt/generate_with_fallback stubbed. Clip boundaries sit deliberately off the 10 ms grid, so a library that truncated where it now rounds would start a clip's first window one frame early and the tiling would not close.

pyproject.toml pins faster-whisper>=1.1 with no ceiling and Renovate auto-merges on green, so without this a refactor upstream would produce silently wrong timestamps. This makes it go red instead. No download, no network, not slow.

Verification

648 tests, mypy and ruff clean. 16 mutants, all killed — including roundint in the frame grid, five wrong clip-index rules, four clamp variants, and the library test's own falsifier. Two survivors were found mid-way and each got a test.

Worth second-guessing

  • The rounding convention is duplicated, not derived. _on_the_frame_grid reproduces round(ts * frames_per_second). No oracle was found that determines a window's clip without it; the mitigation is that the library test cross-checks our frames against the real loop's window starts.
  • The weightless harness is brittle by design — a benign upstream refactor reading a new attribute fails it with AttributeError, not a behaviour message. A loud false alarm was judged better than silence.
  • segment.seek is accessed unguarded: if a release drops the field the worker raises mid-job rather than falling back. Deliberate — a silent fallback is the failure mode being removed — but it is a production crash.
  • Once this ships, the deployment repo's 12Gi stopgap can go back toward the chart's 6Gi.

`WhisperEngine._transcribe` decoded a speaker's recording, found the
audible regions with the amplitude gate and then handed faster-whisper
the *whole* array, marking the speech with `clip_timestamps`. The library
shrinks the array only inside its `vad_filter` branch -- `if vad_filter
and clip_timestamps == "0"`, then `audio = np.concatenate(audio_chunks)`
-- and setting clips is exactly what skips that branch, so log-mel
extraction ran over the padding too and scaled with the padded length of
a track rather than with what was said. That is why the worker's memory
limit had to be raised from 6Gi to 12Gi in production.

The gated speech is now concatenated here and handed over as one short
array, with `clip_timestamps` re-expressed on that timeline and the
returned segment times mapped back onto the recording's afterwards.

Measured end to end through the real `_transcribe`, on a 100-minute
mostly-padding track of the shape the recording that started this has --
187 clips, 41.2 minutes of speech, bit-exact zero between them. Both runs
back to back on the same idle machine, `tiny`/int8, beam 8:

                peak RSS   wall clock     CPU
    before        5025 MB       331 s   1276 s
    after         2441 MB       318 s   1255 s

Peak resident memory falls by 2.58 GB, 51%, and it now scales with speech
rather than with session length -- a four-hour meeting no longer costs
more than a one-hour one for the same amount of talking. The `before`
peak moves a few percent between runs (an earlier pair gave 5214 MB); the
`after` peak did not move at all across four runs, because it is set by
the size of the speech and that is the same array every time.

Wall clock is unchanged within run-to-run noise, which is the expected
result and not a disappointment: the extraction that was allocating those
gigabytes is a small share of the time, and this change deliberately
keeps the encoder work identical (see below).

`tiny` rather than `large-v3` because those weights are not on the
measuring machine. The term that moves is the log-mel extraction, which
does not depend on the model at all; large-v3's ~1.55 GB of resident
weights adds to both rows equally, putting the old path at ~6.5 GB
against a 6Gi (6.44 GB) limit and the new one at ~3.9 GB. Segment counts
differ a little between runs of identical code, so they are not a signal
either way -- CTranslate2 is not bit-reproducible across threads.

The clip boundaries stay, on the new timeline. They are what stops an
encoder window spanning two utterances spoken minutes apart:
`segment_size = min(nb_max_frames, content_frames - seek, seek_clip_end -
seek)` caps a window at the clip it began in. Dropping them -- plain
concatenation plus `restore_speech_timestamps`, the library's own helper
-- was measured emitting a single 258-second segment covering four
utterances spoken 9 s, 47 s, 96 s and 150 s into a recording, because
that helper resolves a segment's `start` and `end` independently.

So the restore is our own arithmetic, and **a segment is attributed to
the clip its encoder window came from, never to the clip its reported
times fall in**. `Segment.seek` is that window's first log-mel frame, and
the seek loop never decodes a window from outside the clip it is walking,
so the frame names one clip and only one. Comparing it against our own
clip boundaries on the library's `round(ts * frames_per_second)` grid,
then clamping the result into that clip, gives the guarantee the change
needs: a restored segment always lies inside the file-timeline extent of
the clip whose window produced it.

Reading the clip off the times cannot give that. A decoded `end` is
`time_offset + end_timestamp_position * time_precision` and
`_split_segments_by_timestamps` caps neither it nor the seek positions it
derives at the window's content, so a tail segment routinely claims audio
the window never held. On the 187-clip track above, with the times-based
rule, 57 of 462 segments reported an `end` past their clip and 5 of them
overran by more than half the segment -- coming back 20 to 31 seconds
from where they were spoken, one of them carrying 14 seconds of text. The
error is always exactly one removed gap, so on a session with three
utterances forty minutes apart it is forty minutes. That is the same
silent wrong timestamp the library's helper produces; relocating it into
our own file would not have made it acceptable.

Nothing at any test tier observed the library behaviour all of this rests
on, and a fake model cannot: a fake breaks the way its author imagined.
`test_a_window_never_spans_two_clips` therefore drives the real
`generate_segments` with a stand-in `self` -- no weights, no download, so
it stays out of the `slow` marker -- and asserts that the windows the real
seek loop schedules tile exactly the frame ranges this adapter computes.
It fails if `segment_size` stops being capped at the clip, if
`Segment.seek` stops being the window's first frame, or if the boundary
rounding changes. `pyproject.toml` pins `faster-whisper>=1.1` with no
upper bound and Renovate merges on a green build, so that test is the
only upper bound there is.

The per-clip encoder cost is deliberately not fixed here. 187 clips still
pay a 30-second window each, and that is the trade: the 2.2x of encoder
work is bought with segment timestamps wrong by up to 258 seconds, and a
meeting protocol whose entire output is "who said what, when" cannot
spend correctness on CPU time. Making clips longer (`_MERGE_GAP_SECONDS`)
recovers it later without touching any of this.

`charts/sturnus/values.yaml` is fixed in the same commit: the comment
above `resources` still claimed `vad_filter` was what kept the padded
length out of the decoder, and it never costed the extraction term at
all. The numbers are unchanged -- requests 4Gi, limits 6Gi -- because the
point is that they are true again, so the 12Gi override in the deployment
repo can be reverted rather than replaced.
0.5.1 (#46) landed in the same method: it closes the `log_prob_threshold`
veto that let Whisper's invented subtitle credits through, and it logs what
the guard discarded. Both are additive to what this branch does and both are
kept.

Two of main's tests were dropped rather than merged, because they pin the
mechanism this branch inverts: `test_the_model_is_given_the_whole_array_and_
the_clips_the_gate_found` and `test_offsets_are_returned_unchanged` assert
that the model receives the padded array and that offsets pass through
unmapped. Neither is true any more -- the model is handed the concatenated
speech and the offsets are restored -- and the replacements
(`test_the_model_is_handed_the_speech_and_not_the_padded_track`,
`test_segment_times_come_back_on_the_recordings_own_timeline`) assert the
same properties of the new arrangement.

main's two logging tests predate `_FakeSegment.seek` and now go through
`_as_decoded_from`, the helper this branch added for exactly that.

Main's closing comment about `clip_timestamps` starting language detection
at the first clip is superseded: the model no longer sees the padding at
all, which is a stronger version of the same statement.
@TheMeinerLP
TheMeinerLP merged commit d3e773a into main Aug 21, 2026
5 checks passed
TheMeinerLP added a commit that referenced this pull request Aug 21, 2026
#43 (silent-audio detection) and #48 (transcribe the speech, not the padded
track) landed while this waited. Two conflicts, both additive:

`tests/infrastructure/test_repositories.py` needed `select` and `update`
from sqlalchemy rather than one or the other -- #43's repository test reads
a column back, this branch's compare-and-set writes one.

`docs/operations.md` section 5 gained two independent troubleshooting
entries at the same anchor: this branch's `/queue` walkthrough and #43's
'a speaker's audio arrives with no level'. Both stay; they answer different
questions and neither supersedes the other.
TheMeinerLP added a commit that referenced this pull request Aug 21, 2026
…content

OpenTelemetry traces and metrics, plus structured logging shaped for Loki.

Squashed to a single commit on top of main. Two earlier commits on this
branch carried fixtures with the literal shape of a credential -- an AWS
access key id and a Discord bot token -- which secret scanning detects,
correctly, whether or not either string opens anything. Both are now
assembled from parts at import: identical at runtime, so the tests still
prove the redaction catches those exact shapes, with nothing in the file
for a scanner or a reader to mistake for a credential. Rewriting the tip
alone would not have helped; a scan reads every commit in the pull
request, so the literals had to leave the history.

**Redaction is an allowlist, not a denylist.** Unregistered field names are
dropped, `bytes` is dropped as a class (audio and wrapped data keys are
always bytes, so that closes the highest-value leak by construction),
strings are pattern-scrubbed and capped, and every replacement is visible
(`«redacted:discord_token»`) rather than silent.

**The leak this branch closed.** With `STURNUS_LOG_LEVEL=DEBUG` the Discord
voice `secret_key` reached the logs and would have reached Loki.
Reproduced:

    discord/ext/voice_recv/gateway.py:57
      log.debug("Received op %s: \n%s", op, pformat(data))
      -> {'mode': ..., 'secret_key': [1, 2, ..., 32], 'ssrc': ...}

Not a redaction failure: `extra={"secret_key": ...}` was dropped, and so was
`extra={"voice_ready": {...}}`. The key arrived already formatted into a
third-party logger's *message string*, which a field allowlist cannot
touch. The cause was `root.setLevel(min(resolved_level,
resolved_third_party))`, which put 28 unclamped third-party loggers at
DEBUG -- every logger absent from the enumerated clamp list inherits from
root, and one list cannot be complete about libraries it does not import.
That `min()` was never load-bearing (Python checks the *originating*
logger's effective level on propagation, never root's), so removing it
costs nothing and closes the hole. `THIRD_PARTY_FLOOR` replaces the
enumeration as the structural half of the fix, and
`tests/observability/test_third_party_log_floor.py` asserts the property
over `logging.Logger.manager.loggerDict` rather than over a list.

`discord.voice_state` is pinned at INFO rather than silenced: DEBUG is where
the leak lives, but INFO is the connect narrative -- handshake attempts,
endpoint, close codes, resume -- and it is the evidence base for telling
the three capture failures apart. Pinning needed a `NEVER_ABOVE`
counterpart: `NEVER_BELOW` is applied as `max(level, floor)` and can only
ever make a logger *quieter*, so an INFO entry there was a no-op at the
deployed `WARNING` default -- the logger still ended at WARNING and the
line the entry was written to keep was still gone.

**The metrics answer questions this project actually had.**
`sturnus.transcription.decoded_seconds` divided by wall time is the
real-time factor: a job that "finished" a 100-minute recording in 43
seconds reports an impossible, unmistakable number -- where the symptom
everyone saw, an empty transcript, looked exactly like a participant who
never spoke and was misread as one for a day. `position_seconds`,
`total_seconds` and `seconds_since_progress` are **observable instruments,
not synchronous gauges**: a gauge only changes when a call site sets it, so
a decoder that wedges freezes it and `seconds_since_progress` -- the actual
alert -- could never grow. The SDK calls these callbacks once per export
interval instead, which is also what lets them emit nothing at all while
the worker is idle, so the series goes stale rather than reporting a
finished job's numbers forever. The stall clock starts before the library
call, since the collapse happened inside feature extraction. Labels are
`model` only: no session, job, guild or user id, which would be unbounded
cardinality and a record of who was in a voice channel when, kept for as
long as the metric store keeps anything.

`sturnus.job.outcome` reported `done` for every failed job, because
`process_one` returns True after `queue.fail(...)` exactly as it does after
`queue.complete(...)` -- the boolean means "work was attempted", never
"work succeeded", and a metric that reports failures as successes is worse
than no metric because it will be believed. The label is now recorded by
the transitions that decide a job's terminal state, and `crashed` is the
one the worker loop still owns.

**Rebased onto #48, which rewrote the same method.** The transcription
mechanics are main's: the model is handed the gated speech concatenated,
`clip_timestamps` is re-expressed on that timeline, and every returned
segment goes back onto the recording's through
`_on_the_original_timeline(segment.start, segment.end, segment.seek, ...)`,
where `Segment.seek` -- the encoder window the segment was decoded from --
is what names its clip. The observability is re-expressed on top: the
segment generator is drained by a loop and not a comprehension, so
`TRANSCRIPTION_PROGRESS` sees each segment as it arrives rather than only
after the job has already finished.

Progress is reported on the *concatenated* timeline -- `advance(segment.end)`
and not the restored end -- because the denominator is
`duration_after_vad`, which since #48 is the concatenated speech. Reporting
a restored end against it would put a job that had decoded its first clip
at several hundred percent. `telemetry.TranscriptionProgress` and
`docs/operations.md` § 7.5 say so; they described the whole file before.

Two call sites arrived from main that the merge could not have seen, both
of them what `tests/test_logging_discipline.py` R2 and R6 forbid, and for
the reason R6 exists: `%s` on an exception prints `str(exc)` verbatim into
the message `observability.scrub_event` forwards to Sentry.
`RecordingService._report_silent_audio` (#48) was three `log.warning`
calls, two interpolating an exception and one passing `display_name`; it is
now `speaker.audio_silent`, `speaker.silent_warning_failed` and
`speaker.silent_record_failed` through `log_event`/`log_exception`, with
`display_name` gone -- the channel message renders the mention, and the
operator has the id. `RequeueConfirmView._disable` (#49) is now
`queue.view_disable_failed` the same way.
TheMeinerLP added a commit that referenced this pull request Aug 21, 2026
…content (#50)

OpenTelemetry traces and metrics, plus structured logging shaped for Loki.

Squashed to a single commit on top of main. Two earlier commits on this
branch carried fixtures with the literal shape of a credential -- an AWS
access key id and a Discord bot token -- which secret scanning detects,
correctly, whether or not either string opens anything. Both are now
assembled from parts at import: identical at runtime, so the tests still
prove the redaction catches those exact shapes, with nothing in the file
for a scanner or a reader to mistake for a credential. Rewriting the tip
alone would not have helped; a scan reads every commit in the pull
request, so the literals had to leave the history.

**Redaction is an allowlist, not a denylist.** Unregistered field names are
dropped, `bytes` is dropped as a class (audio and wrapped data keys are
always bytes, so that closes the highest-value leak by construction),
strings are pattern-scrubbed and capped, and every replacement is visible
(`«redacted:discord_token»`) rather than silent.

**The leak this branch closed.** With `STURNUS_LOG_LEVEL=DEBUG` the Discord
voice `secret_key` reached the logs and would have reached Loki.
Reproduced:

    discord/ext/voice_recv/gateway.py:57
      log.debug("Received op %s: \n%s", op, pformat(data))
      -> {'mode': ..., 'secret_key': [1, 2, ..., 32], 'ssrc': ...}

Not a redaction failure: `extra={"secret_key": ...}` was dropped, and so was
`extra={"voice_ready": {...}}`. The key arrived already formatted into a
third-party logger's *message string*, which a field allowlist cannot
touch. The cause was `root.setLevel(min(resolved_level,
resolved_third_party))`, which put 28 unclamped third-party loggers at
DEBUG -- every logger absent from the enumerated clamp list inherits from
root, and one list cannot be complete about libraries it does not import.
That `min()` was never load-bearing (Python checks the *originating*
logger's effective level on propagation, never root's), so removing it
costs nothing and closes the hole. `THIRD_PARTY_FLOOR` replaces the
enumeration as the structural half of the fix, and
`tests/observability/test_third_party_log_floor.py` asserts the property
over `logging.Logger.manager.loggerDict` rather than over a list.

`discord.voice_state` is pinned at INFO rather than silenced: DEBUG is where
the leak lives, but INFO is the connect narrative -- handshake attempts,
endpoint, close codes, resume -- and it is the evidence base for telling
the three capture failures apart. Pinning needed a `NEVER_ABOVE`
counterpart: `NEVER_BELOW` is applied as `max(level, floor)` and can only
ever make a logger *quieter*, so an INFO entry there was a no-op at the
deployed `WARNING` default -- the logger still ended at WARNING and the
line the entry was written to keep was still gone.

**The metrics answer questions this project actually had.**
`sturnus.transcription.decoded_seconds` divided by wall time is the
real-time factor: a job that "finished" a 100-minute recording in 43
seconds reports an impossible, unmistakable number -- where the symptom
everyone saw, an empty transcript, looked exactly like a participant who
never spoke and was misread as one for a day. `position_seconds`,
`total_seconds` and `seconds_since_progress` are **observable instruments,
not synchronous gauges**: a gauge only changes when a call site sets it, so
a decoder that wedges freezes it and `seconds_since_progress` -- the actual
alert -- could never grow. The SDK calls these callbacks once per export
interval instead, which is also what lets them emit nothing at all while
the worker is idle, so the series goes stale rather than reporting a
finished job's numbers forever. The stall clock starts before the library
call, since the collapse happened inside feature extraction. Labels are
`model` only: no session, job, guild or user id, which would be unbounded
cardinality and a record of who was in a voice channel when, kept for as
long as the metric store keeps anything.

`sturnus.job.outcome` reported `done` for every failed job, because
`process_one` returns True after `queue.fail(...)` exactly as it does after
`queue.complete(...)` -- the boolean means "work was attempted", never
"work succeeded", and a metric that reports failures as successes is worse
than no metric because it will be believed. The label is now recorded by
the transitions that decide a job's terminal state, and `crashed` is the
one the worker loop still owns.

**Rebased onto #48, which rewrote the same method.** The transcription
mechanics are main's: the model is handed the gated speech concatenated,
`clip_timestamps` is re-expressed on that timeline, and every returned
segment goes back onto the recording's through
`_on_the_original_timeline(segment.start, segment.end, segment.seek, ...)`,
where `Segment.seek` -- the encoder window the segment was decoded from --
is what names its clip. The observability is re-expressed on top: the
segment generator is drained by a loop and not a comprehension, so
`TRANSCRIPTION_PROGRESS` sees each segment as it arrives rather than only
after the job has already finished.

Progress is reported on the *concatenated* timeline -- `advance(segment.end)`
and not the restored end -- because the denominator is
`duration_after_vad`, which since #48 is the concatenated speech. Reporting
a restored end against it would put a job that had decoded its first clip
at several hundred percent. `telemetry.TranscriptionProgress` and
`docs/operations.md` § 7.5 say so; they described the whole file before.

Two call sites arrived from main that the merge could not have seen, both
of them what `tests/test_logging_discipline.py` R2 and R6 forbid, and for
the reason R6 exists: `%s` on an exception prints `str(exc)` verbatim into
the message `observability.scrub_event` forwards to Sentry.
`RecordingService._report_silent_audio` (#48) was three `log.warning`
calls, two interpolating an exception and one passing `display_name`; it is
now `speaker.audio_silent`, `speaker.silent_warning_failed` and
`speaker.silent_record_failed` through `log_event`/`log_exception`, with
`display_name` gone -- the channel message renders the mention, and the
operator has the id. `RequeueConfirmView._disable` (#49) is now
`queue.view_disable_failed` the same way.
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