Skip to content

feat(worker): transcribe for the language and the vocabulary of the room - #44

Merged
TheMeinerLP merged 3 commits into
mainfrom
feat/transcription-quality
Aug 20, 2026
Merged

feat(worker): transcribe for the language and the vocabulary of the room#44
TheMeinerLP merged 3 commits into
mainfrom
feat/transcription-quality

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

The first protocols Sturnus published transcribed badly. This is the configuration side of that: language, vocabulary, decoding, model.

Measured, not argued

Same 4.7 minutes of German speech, x86 CPU pinned to four threads (matching the worker's cpu: "4"):

model compute beam transcribe peak RSS
before large-v3-turbo int8 5 61.6s 1.96 GB
after large-v3 int8_float32 8 145.0s 4.11 GB

before: "Der Ender läuft inzwischen auf dem Kluster, aber die Speicherlimits sind noch zu knapp gemessen."
after: "Der Renderer läuft inzwischen auf dem Cluster, aber die Speicherlimits sind noch zu knapp bemessen."

Three errors in one sentence, all three gone.

4.11 GB against the 4.25 GB the chart comment works out on paper, so the arithmetic holds and the 4Gi request / 6Gi limit stand. 282s of speech in 145s is 1.94× faster than real time, against 4.6× before.

What changed

  1. The default language was en. On a German-speaking server that alone accounts for a great deal, and it compounded: with nothing pinned, Whisper guessed, set_detected_language wrote the guess down, and every later job for that speaker in that session was then bound to it. One unlucky guess was not one bad job, it was all of them. Now a per-guild transcription_language (default de), and a configured language beats a previously stored detection — which is what actually breaks that chain.
  2. initial_prompt, per-guild, defaulting to a German sentence naming the bird projects and the stack. Proper nouns are where a general model fails and where a protocol is judged: a decision recorded about "Dracula" instead of "Ducula" is worse than no minutes.
  3. condition_on_previous_text=False. The library default feeds each segment's text in as the next one's prompt, so one hallucination becomes the context for everything after it — and vad_filter makes that worse rather than better here, since it cuts a speaker's track into fragments whose "previous text" is routinely minutes earlier and about something else.
  4. beam_size=8, large-v3, int8_float32, resources raised to 4Gi/6Gi, startupProbe.failureThreshold 60 → 120.

The existing vad_filter / compression_ratio_threshold / no_speech_threshold are untouched and now have a regression test.

The one addition beyond the brief — worth reviewing

transcription_language accepts auto. Without it there would be no state in which detection can run at all: DEFAULTS supplies de, and /config clear restores a default rather than removing it, so the whole detect-and-pin mechanism and the detected_language column would have become dead code. auto is how a genuinely multilingual guild opts back in. A blank or whitespace value reads the same way — guild_config is a table operators are told they may edit with SQL, where ConfigStore.set's validation never runs, and "" handed to faster-whisper fails every job of that guild.

When a language is configured, detection does not run and nothing is written to detected_language: a configured value overridden by a guess would be self-locking, and the column would stop meaning "what the engine detected".

Verification

600 tests pass (was 576; the 4 slow model-downloading tests were not run and none added), mypy and ruff clean, chart lints and renders. Fifteen mutations, each caught by a named test.

One mutation survived and was treated as a finding: the first language guard read if detecting and pinned_language is None:, where the first conjunct is provably dead. Removed, and the blank-value handling above came out of looking at it.

Worth second-guessing

  • auto as a sentinel — one more concept in exchange for detection staying reachable.
  • The job lease. STURNUS_JOB_LEASE_SECONDS is 1800s. At the measured 1.94× real time, and since vad_filter decodes only the speech in a track rather than its padded length, that covers roughly 58 minutes of one person actually talking. Past that the lease expires mid-job. Harmless today — replicas: 1 is hardcoded in the Deployment template and the worker takes one job at a time, so nothing else can claim it, and the only effect is that a restart may reclaim a job that was still running. Raising the worker beyond one replica is what turns this into two workers transcribing the same recording, and is the moment to revisit the lease. Documented in the chart rather than changed here.
  • Deployment note: the model cache PVC holds a stale turbo checkpoint; the first start after this rolls out re-downloads the large-v3 float16 checkpoint (~2.8 GB), which is what the raised startupProbe budget is for.

Six changes to how a recording is turned into text, all of them aimed at
the same thing: a protocol somebody can read afterwards and trust.

**The language was the defect, not a setting.** `whisper_default_language`
was `en` on a server whose meetings are held in German, and the fallback
was only half the problem. With nothing pinned, the engine detected the
language per speaker and `set_detected_language` wrote whatever came back
-- for the rest of that speaker's session. Detection here is weak in
exactly the way that matters: it runs on one speaker's track, which
`vad_filter` has already cut down to the fragments where that person
actually spoke, so a participant whose first contribution is a
three-second "ja, genau" is close to a coin flip between several
languages. One unlucky guess was not one bad job, it was every job for
that person from then on.

So `transcription_language` becomes per-guild configuration (Spec 11),
resolved through `ConfigStore` on the path `document_target` and
`merge_gap_seconds` already use, defaulting to `de`, with
`STURNUS_WHISPER_DEFAULT_LANGUAGE` (now also `de`) left as the floor
under it.

The order between configuration and detection is the part worth arguing.
A configured language wins outright: it is handed to the engine, no
detection runs, and nothing is written to `detected_language`. A
configured setting a guess may override is a trap, and here it would be a
self-locking one -- the guess gets pinned and then beats the
configuration on every later job of that session, while the column stops
meaning "what the engine detected" and starts meaning "what was
configured when this session's first job ran", with no way to tell the
two apart in the data. Detection is still reachable, deliberately: `auto`
asks for it, and is what a genuinely multilingual guild sets. Without
that spelling the detect-and-pin path would have been unreachable in
production, since clearing a key restores its default rather than
removing it.

**`initial_prompt`, carrying the organisation's vocabulary.** Proper
nouns are both what a general model reliably gets wrong and what a
protocol is read for -- a decision minuted about "Dracula" is not a
decision about Ducula. `transcription_prompt` is per-guild for the same
reason the language is, and defaults to OneLiteFeather's bird-named
projects and their stack, written as an ordinary German sentence so the
style it biases towards is punctuated prose as well as the right words.

**`condition_on_previous_text=False`.** The library defaults it to
`True`, which feeds each segment's own text back in as the next one's
prompt, so one hallucination becomes the context every following segment
is decoded against -- the cascade `compression_ratio_threshold` and
`no_speech_threshold` exist to catch is what that produces. `vad_filter`
makes the default worse rather than better here: it removes the silence,
so the "previous text" is routinely from minutes earlier and about
something else. Per-speaker recordings of a conversation are the case
that default suits least.

**`beam_size=8`**, over the library's 5. Beam cost is roughly linear in
the width, and the trade is CPU seconds the worker has against a wrong
word in a document people read instead of having been in the room.

**`large-v3` instead of `large-v3-turbo`.** Turbo is a distilled decoder,
four layers where large-v3 has thirty-two, and what it gives up is
concentrated outside English -- the only place this deployment operates.
It buys latency nobody is waiting for: transcription is offline, per
speaker, hours after the meeting.

**`int8_float32` instead of `int8`.** The weights are quantised to int8
either way; the suffix names the type everything else runs in. Bare
`int8` is an alias whose float type CTranslate2 picks for whatever
machine it finds itself on, which leaves transcription quality depending
on the node the pod was scheduled to, and CTranslate2 falls back silently
rather than refusing a compute type it cannot provide.

The chart pays for the last two. `worker.resources` works the budget out
rather than guessing: ~1.55GB of int8 weights (1.55B parameters against
turbo's 809M), ~2.2GB of decoding buffers (cached attention keys and
values are held per beam per decoder layer, and this has 32 decoder
layers where turbo has 4 -- scaled from faster-whisper's own published
CPU benchmark for this architecture at beam 5), ~0.5GB of process. 4Gi
request, 6Gi limit; exceeding the limit is an OOMKill mid-transcription.
The startup budget doubles with the download, which is ~3.1GB of float16
checkpoint against turbo's ~1.6GB, and nothing answers /healthz until the
engine is built.

Every parameter that reaches `WhisperModel` or `model.transcribe` is now
pinned against a fake in `tests/infrastructure/test_whisper.py`. The
existing tests there run real inference and cannot do this job: a wrong
`beam_size` still transcribes a two-second fixture perfectly.

A blank or whitespace-only `transcription_language` is read as "detect"
rather than passed to the engine, which rejects it. `guild_config` is a
table operators are told they may edit with SQL, where `ConfigStore.set`'s
validation never runs, and one careless UPDATE should not fail every job
of a guild.
The resource figures were derived rather than observed, which is the right
way to arrive at a first number and the wrong way to leave one. Measured
on an x86 CPU pinned to four threads, transcribing 4.7 minutes of German
speech:

    large-v3-turbo  int8          beam 5   61.6s  1.96GB
    large-v3        int8_float32  beam 8  145.0s  4.11GB

4.11GB against the 4.25GB the comment worked out, so the arithmetic holds
and the 4Gi request / 6Gi limit stand as chosen.

The throughput number is the more useful of the two, because it bounds
something the comment could only gesture at: 282s of speech in 145s is
1.94x faster than real time, against 4.6x before. Since `vad_filter`
decodes only the speech in a track rather than its padded length, the
1800s job lease covers roughly 58 minutes of one person actually talking.
Past that the lease expires mid-job -- harmless at `replicas: 1` with the
worker taking one job at a time, since nothing else can claim it, and the
reason raising the worker beyond one replica means revisiting the lease
first.

The same run is also the clearest evidence for the model change itself.
The old configuration produced "Der Ender läuft inzwischen auf dem
Kluster, aber die Speicherlimits sind noch zu knapp gemessen"; the new one
produced "Der Renderer läuft inzwischen auf dem Cluster, aber die
Speicherlimits sind noch zu knapp bemessen".
The VAD fix (#45) landed first and rewrote the same method this branch
changes, so the two met in `_transcribe`. The fix's structure is the trunk:
audio is decoded once in the engine, the stateless gate produces
`clip_timestamps`, and an empty clip list short-circuits before the model
is touched. This branch's `initial_prompt` is threaded through it.

`test_the_hallucination_guards_are_still_in_place` now asserts
`vad_filter is False`, the reverse of what it asserted when written. That
is not a weakened test: Silero was the guard until it was found to be the
defect -- its recurrent state collapses on the bit-exact zero padding
`SpeakerWriter` writes, and it reported about a second of speech in two
minutes of a real recording. Turning it back on would restore the defect,
not a safeguard, so the test now pins the arrangement that replaced it: no
Silero, a non-empty clip list, and the two decoder-side thresholds.

The two fakes coexist deliberately. `_RecordingModel` is handed to an
engine built with `object.__new__`, so it can never see constructor
arguments; `_ModelSpy` patches the imported name and records both. They
answer different questions.
@TheMeinerLP
TheMeinerLP merged commit 8822b6b into main Aug 20, 2026
7 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