v3.11.5 — per-speaker recordings never shared a session clock (#320) #418

Merged
claude-bot merged 6 commits from fix/recording-wall-clock into main 2026-08-25 23:11:50 +00:00
Contributor

Closes #320, #321, #322, #323, #324, #325, #326, #327, #328, #417. Tracking issue: #319.

The bug

Per-speaker audio tracks have never been on a shared timeline. PerUserPCMSink appended decoded PCM as packets arrived, and Discord clients stop transmitting entirely during silence — so every pause was deleted from every track. A track ended up as long as its speaker's total talk time rather than the session, and because Whisper timestamps are relative to the file it is given, each speaker carried a private, compressed clock. merge_attributed_transcript then sorted six mutually incomparable clocks onto one axis.

The thing that looked like a session clock was not one: _session_bytes is documented as "bytes on the first speaker's timeline" and advanced only for one arbitrary user, whose own track also only advanced while they transmitted. Commit 7ddff16 claimed it tracked wall-clock position; it never read a clock.

This affected every multi-speaker recording ever produced, not one bad session.

Production evidence

Session 56cc4dee — ~3 hours scheduled. Stored transcript, 1904 lines, 0 unparsed:

Speaker Lines First Last
DesertCreosote 675 00:00:04 00:54:47
Viquilonto (Viq) 203 00:00:03 00:15:46
Wyatt 327 00:00:01 00:14:59
Idani 343 00:00:00 00:14:45
Harrowhark 223 00:00:00 00:14:11
Clio 133 00:00:00 00:10:07

Max timestamp 00:54:47 for a multi-hour session; per-speaker spans summing to ~2.08 h; and impossible sustained speech density (Idani at one utterance every 2.6 seconds for their whole track).

Note what is not evidence: the transcript's timestamps are perfectly monotonic. The merge sorts by start time, so output is non-decreasing for any input — that validates the sorted() call, not the data. The detectable signal was always magnitude.

Before/after, measured

Driving the pre-fix sink through a simulated 30-minute session with realistic talk shares, then the same simulation through the fixed sink:

speaker talk % pre-fix track fixed track
111 30% 12.8 min (0.43) 30.0 min (1.00)
222 9% 3.8 min (0.13) 30.0 min (1.00)
333 9% 3.8 min (0.13) 30.0 min (1.00)
444 8% 3.4 min (0.11) 30.0 min (1.00)
555 6% 2.5 min (0.08) 30.0 min (1.00)
666 9% 3.8 min (0.13) 30.0 min (1.00)

The pre-fix reference speaker lands at exactly their talk time — the same signature as DesertCreosote's 00:54:47 in the real transcript.

The fix

Placement is now absolute rather than incremental. Before appending a packet the sink computes where it belongs from clock() - t0 and pads that speaker's file with silence to close the gap. _session_bytes, the reference-user checks and the first-packet pad are deleted — the per-packet gap logic subsumes all three. Error per talk spurt is bounded by jitter-buffer delay plus a 250 ms threshold and is non-cumulative, so it cannot grow over a four-hour session. Mutes, leaves, rejoins, reconnects and packet loss all become silence for free, because the clock does not care why packets stopped.

RTP-timestamp anchoring would be frame-accurate and the data is available, but it assumes every client advances its timestamp across silence — any client that does not would silently recreate this bug. Deferred behind one release of AUDIO_DIAG logging.

Accepting the jitter-buffer PLC FakePackets cannot fix it and must not be attempted: PLC fires only on sequence-number gaps, which do not occur across silence, and 02898b3 documents the 148× PLC explosion that is why the skip exists.

Required companions, not nice-to-haves

  • Headerless .s16le capture (#321). Gap-filled 48 kHz stereo is 192 KB/s of wall clock, reaching 4.15 GB at the six-hour cap and overflowing WAV's 4 GB RIFF size field — failing at close(), after capturing the whole session.
  • Opus decoder reset on SSRC change (#322). Opus is stateful and a rejoin gets a fresh SSRC.
  • VAD on by default at −45 dB (#323). VAD was off, which was survivable only because the capture bug deleted all silence before Whisper saw it. With tracks correctly spanning the session, each is mostly silence — Whisper's documented worst case for hallucinated phantom text, attributed to that track's speaker. Fixing capture without this trades one misattribution vector for another and sends ~9× the audio. The floor moves to −45 because Discord per-user tracks are digitally silent when nobody transmits, so −30 had no quiet-room noise to clear and could cut genuinely quiet speech.

Why nothing caught it

process_audio already received the wall-clock duration_seconds, logged it into the Discord embed, and compared it against nothing. Two guards now fail loud into the existing failed-status path (#324) — one before transcription so a bad recording costs no GPU time, one before the merge. Both skip when the duration is unknown, since reprocess endpoints pass 0.

The live dashboard has been publishing captured time next to elapsed time since the feature shipped, with nothing comparing them. It now warns (#327), and close() logs a per-speaker captured/expected ratio — one grep after the first real session proves the fix, which matters because the dev stack has no Discord token and cannot exercise the bot end to end.

Also in here

#417 — seven fields the session API declared but never sent. Found while adding pre_timeline_fix to the same builder. Among them lore_generation_status, which the session page gates its whole lore block on, so the progress display, the failure/re-run message, and the only link to the wiki proposals queue could never render. Pulled into the hotfix rather than deferred: a fix that adds one field to a builder while leaving six broken beside it — without the guard that would have caught its own near-miss — is not a fix. The guard is source-level and deliberately not a value comparison, because a value test only catches fields a test author remembered to set. It failed on first run and found two fields (series_id, series_occurrence_date) that reading the code by hand had missed.

Two things caught while in there: erasure_notes were shipping the erased member's member_user_id alongside a deliberately anonymous notice shown to every member, and the "Review proposals" link pointed at the wiki home (taken from #375; the rest of its scope is untouched).

#328 — the nine already-affected sessions are marked. They cannot be repaired: the audio is reaped and reprocessing would re-derive the same scrambled merge. Backfilled by migration for every session that already had a transcript, which needs no date comparison because the fix ships in the same release. Surfaced as an explanation rather than a bare badge, and shown to players too. Content is not hidden — the events happened, and at least one summary has been hand-corrected.

#325 — transcript text out of the logs. The transcription response was logged at INFO including its opening segments with speaker names, against the invariant in that module's own docstring. Verified present in production logs.

#326 — the tests that asserted the broken model are rewritten. The sink takes an injectable clock; test_track_duration_equals_wall_clock is the core invariant and fails against the pre-fix sink three independent ways. Companions cover reference-independence, leave/rejoin, SSRC reset, sub-threshold jitter not stretching speech, frame alignment, and the cleanup() fallback. All pure-unit, no Discord.

Upgrade note

BOT_CONTRACT_VERSION stays 1 and nothing refuses to start, but backend and bot must be upgraded together. New backend + old bot now rejects recordings (correctly). New bot + old backend fails quietly instead: full-length tracks with trimming still off. One migration, f1a2b3c4d5e7.

Verification

Bot 199 passed · backend 914 passed · frontend 431 passed + eslint clean · ruff check and format clean · check_version_sync OK at 3.11.5 · migration applies to a single head.

A repo-wide audit for other instances of the #417 class found none — one apparent hit was a false positive from a locally-defined SessionListItem in routers/bot.py. The mirror check (frontend reading session fields the API never sends) is also clean.

Before deploying to production

  • Validate the −45 dB floor against a real recording. It is the one value here that could newly lose data, and trimming is invisible by nature.
  • Scrub the retained production log line from #325.
  • Watch the first session's captured/expected ratio — it must be ~1.0. Near 0.2 means the timeline has regressed.

🤖 Generated with Claude Code

Closes #320, #321, #322, #323, #324, #325, #326, #327, #328, #417. Tracking issue: #319. ## The bug Per-speaker audio tracks have never been on a shared timeline. `PerUserPCMSink` appended decoded PCM as packets arrived, and Discord clients stop transmitting entirely during silence — so every pause was deleted from every track. A track ended up as long as its speaker's *total talk time* rather than the session, and because Whisper timestamps are relative to the file it is given, each speaker carried a private, compressed clock. `merge_attributed_transcript` then sorted six mutually incomparable clocks onto one axis. The thing that looked like a session clock was not one: `_session_bytes` is documented as *"bytes on the first speaker's timeline"* and advanced only for one arbitrary user, whose own track also only advanced while they transmitted. Commit `7ddff16` claimed it tracked wall-clock position; it never read a clock. **This affected every multi-speaker recording ever produced**, not one bad session. ### Production evidence Session `56cc4dee` — ~3 hours scheduled. Stored transcript, 1904 lines, 0 unparsed: | Speaker | Lines | First | Last | |---|---|---|---| | DesertCreosote | 675 | 00:00:04 | **00:54:47** | | Viquilonto (Viq) | 203 | 00:00:03 | 00:15:46 | | Wyatt | 327 | 00:00:01 | 00:14:59 | | Idani | 343 | 00:00:00 | 00:14:45 | | Harrowhark | 223 | 00:00:00 | 00:14:11 | | Clio | 133 | 00:00:00 | 00:10:07 | Max timestamp 00:54:47 for a multi-hour session; per-speaker spans summing to ~2.08 h; and impossible sustained speech density (Idani at one utterance every 2.6 seconds for their whole track). Note what is *not* evidence: the transcript's timestamps are perfectly monotonic. The merge sorts by start time, so output is non-decreasing for **any** input — that validates the `sorted()` call, not the data. The detectable signal was always magnitude. ### Before/after, measured Driving the pre-fix sink through a simulated 30-minute session with realistic talk shares, then the same simulation through the fixed sink: | speaker | talk % | pre-fix track | fixed track | |---|---|---|---| | 111 | 30% | 12.8 min (0.43) | 30.0 min (**1.00**) | | 222 | 9% | 3.8 min (0.13) | 30.0 min (**1.00**) | | 333 | 9% | 3.8 min (0.13) | 30.0 min (**1.00**) | | 444 | 8% | 3.4 min (0.11) | 30.0 min (**1.00**) | | 555 | 6% | 2.5 min (0.08) | 30.0 min (**1.00**) | | 666 | 9% | 3.8 min (0.13) | 30.0 min (**1.00**) | The pre-fix reference speaker lands at exactly their talk time — the same signature as DesertCreosote's 00:54:47 in the real transcript. ## The fix Placement is now absolute rather than incremental. Before appending a packet the sink computes where it belongs from `clock() - t0` and pads that speaker's file with silence to close the gap. `_session_bytes`, the reference-user checks and the first-packet pad are deleted — the per-packet gap logic subsumes all three. Error per talk spurt is bounded by jitter-buffer delay plus a 250 ms threshold and is **non-cumulative**, so it cannot grow over a four-hour session. Mutes, leaves, rejoins, reconnects and packet loss all become silence for free, because the clock does not care why packets stopped. **RTP-timestamp anchoring** would be frame-accurate and the data is available, but it assumes every client advances its timestamp across silence — any client that does not would silently recreate this bug. Deferred behind one release of `AUDIO_DIAG` logging. **Accepting the jitter-buffer PLC FakePackets cannot fix it** and must not be attempted: PLC fires only on sequence-number gaps, which do not occur across silence, and `02898b3` documents the 148× PLC explosion that is why the skip exists. ### Required companions, not nice-to-haves - **Headerless `.s16le` capture (#321).** Gap-filled 48 kHz stereo is 192 KB/s of wall clock, reaching 4.15 GB at the six-hour cap and overflowing WAV's 4 GB RIFF size field — failing at `close()`, *after* capturing the whole session. - **Opus decoder reset on SSRC change (#322).** Opus is stateful and a rejoin gets a fresh SSRC. - **VAD on by default at −45 dB (#323).** VAD was off, which was survivable only because the capture bug deleted all silence before Whisper saw it. With tracks correctly spanning the session, each is mostly silence — Whisper's documented worst case for hallucinated phantom text, attributed to that track's speaker. Fixing capture without this trades one misattribution vector for another and sends ~9× the audio. The floor moves to −45 because Discord per-user tracks are *digitally* silent when nobody transmits, so −30 had no quiet-room noise to clear and could cut genuinely quiet speech. ### Why nothing caught it `process_audio` already received the wall-clock `duration_seconds`, logged it into the Discord embed, and compared it against nothing. Two guards now fail loud into the existing failed-status path (#324) — one before transcription so a bad recording costs no GPU time, one before the merge. Both skip when the duration is unknown, since reprocess endpoints pass 0. The live dashboard has been publishing captured time next to elapsed time since the feature shipped, with nothing comparing them. It now warns (#327), and `close()` logs a per-speaker captured/expected ratio — one grep after the first real session proves the fix, which matters because the dev stack has no Discord token and cannot exercise the bot end to end. ## Also in here **#417 — seven fields the session API declared but never sent.** Found while adding `pre_timeline_fix` to the same builder. Among them `lore_generation_status`, which the session page gates its whole lore block on, so the progress display, the failure/re-run message, and the only link to the wiki proposals queue could never render. Pulled into the hotfix rather than deferred: a fix that adds one field to a builder while leaving six broken beside it — without the guard that would have caught its own near-miss — is not a fix. The guard is source-level and deliberately not a value comparison, because a value test only catches fields a test author remembered to set. It failed on first run and found two fields (`series_id`, `series_occurrence_date`) that reading the code by hand had missed. Two things caught while in there: `erasure_notes` were shipping the erased member's `member_user_id` alongside a deliberately anonymous notice shown to every member, and the "Review proposals" link pointed at the wiki home (taken from #375; the rest of its scope is untouched). **#328 — the nine already-affected sessions are marked.** They cannot be repaired: the audio is reaped and reprocessing would re-derive the same scrambled merge. Backfilled by migration for every session that already had a transcript, which needs no date comparison because the fix ships in the same release. Surfaced as an explanation rather than a bare badge, and shown to players too. Content is not hidden — the events happened, and at least one summary has been hand-corrected. **#325 — transcript text out of the logs.** The transcription response was logged at INFO including its opening segments with speaker names, against the invariant in that module's own docstring. Verified present in production logs. **#326 — the tests that asserted the broken model are rewritten.** The sink takes an injectable clock; `test_track_duration_equals_wall_clock` is the core invariant and fails against the pre-fix sink three independent ways. Companions cover reference-independence, leave/rejoin, SSRC reset, sub-threshold jitter not stretching speech, frame alignment, and the `cleanup()` fallback. All pure-unit, no Discord. ## Upgrade note `BOT_CONTRACT_VERSION` stays 1 and nothing refuses to start, **but backend and bot must be upgraded together.** New backend + old bot now rejects recordings (correctly). New bot + old backend fails quietly instead: full-length tracks with trimming still off. One migration, `f1a2b3c4d5e7`. ## Verification Bot 199 passed · backend 914 passed · frontend 431 passed + eslint clean · ruff check and format clean · `check_version_sync` OK at 3.11.5 · migration applies to a single head. A repo-wide audit for other instances of the #417 class found none — one apparent hit was a false positive from a locally-defined `SessionListItem` in `routers/bot.py`. The mirror check (frontend reading session fields the API never sends) is also clean. ## Before deploying to production - **Validate the −45 dB floor against a real recording.** It is the one value here that could newly *lose* data, and trimming is invisible by nature. - **Scrub the retained production log line** from #325. - Watch the first session's captured/expected ratio — it must be ~1.0. Near 0.2 means the timeline has regressed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Per-speaker tracks have never shared a timeline. The sink appended decoded PCM
on packet arrival, and Discord clients stop transmitting entirely during
silence, so every pause was deleted from every track — a track ended up as long
as its speaker's total talk time rather than the session. Whisper timestamps are
relative to the file it is given, so each speaker carried a private, compressed
clock, and merge_attributed_transcript sorted six mutually incomparable clocks
onto one axis. This affected every multi-speaker recording ever produced.

The one piece of machinery that looked like a session clock was not one:
_session_bytes is documented as "bytes on the first speaker's timeline" and
advanced only for one arbitrary user, whose own track also only advanced while
they transmitted. Commit 7ddff16 claimed it tracked wall-clock position; it
never read a clock.

Confirmed against production session 56cc4dee (~3 h scheduled): max transcript
timestamp 00:54:47, per-speaker spans summing to ~2.08 h, and one utterance
every 2.6 seconds sustained. A simulation of the pre-fix sink reproduces the
signature — tracks at 0.08-0.43 of session length, each equal to its speaker's
talk time; the fixed sink puts every track at exactly 1.00.

Placement is now absolute rather than incremental: before appending a packet the
sink computes where it belongs from clock() - t0 and pads that speaker's file
with silence to close the gap. Error per talk spurt is bounded by jitter-buffer
delay plus a 250 ms threshold and is non-cumulative, so it cannot grow over a
four-hour session. Mutes, leaves, rejoins, reconnects, region changes and packet
loss all become silence for free. close() tail-pads every track to the duration
reported to the backend, so all tracks are the same length.

RTP-timestamp anchoring would be frame-accurate and the data is available, but
it assumes every client advances its timestamp across silence — any client that
does not would silently recreate this bug. Deferred behind one release of
AUDIO_DIAG logging. Accepting the jitter-buffer PLC FakePackets cannot fix it
either: PLC fires only on sequence-number gaps, which do not occur across
silence, and 02898b3 documents why the skip exists.

Two required companions:

- Raw capture moves off WAV to headerless .s16le (#321). Gap-filled 48 kHz
  stereo is 192 KB/s of wall clock, reaching 4.15 GB at the 6-hour cap and
  overflowing WAV's 4 GB RIFF size field — the write would fail at close(),
  after capturing the whole session. FFmpeg now gets an explicit input format.
- A speaker's Opus decoder is reset when their SSRC changes (#322). Opus is
  stateful, and a rejoin gets a fresh SSRC; decoding the new stream through the
  old decoder garbles its first frames.

Also:

- close() logs a per-speaker captured/expected ratio and warns below 0.9 (#327).
  With a correct clock this must be ~1.0; ~0.2 means the timeline has regressed.
  One grep after the first real session settles it. This matters because the dev
  stack has no Discord token and cannot exercise the bot end to end.
- _peak_amplitude samples across the file instead of reading the first 0.5 s,
  which is now silence for every speaker who was not first to talk and would
  have reported 0 for almost everyone.
- Speech bytes are tracked separately from track position, so the live dashboard
  still answers "is this person's mic reaching us?" now that position tracks
  elapsed time for everyone. /record/status gains track_seconds alongside
  seconds_captured.
- The startup sweep removes both .s16le and legacy _raw.wav files.

Tests (#326): the two tests that asserted the broken model are rewritten, and
the sink takes an injectable clock. test_track_duration_equals_wall_clock is the
core invariant and fails against the pre-fix sink three independent ways.
Companions cover reference-independence, leave/rejoin, SSRC decoder reset,
sub-threshold jitter not stretching speech, frame alignment, and the cleanup()
fallback path. All pure-unit, no Discord required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three backend halves of the recording-clock hotfix.

VAD is now enabled by default with the noise floor at -45 dB (#323). It was off,
which was survivable only because the capture bug deleted every silence before
audio reached Whisper. With tracks correctly spanning the full session each one
is mostly silence — Whisper's documented worst case, where it hallucinates
repeated phantom text and attributes it to that track's speaker. So fixing
capture without this would trade one misattribution vector for another, and
would send roughly nine times more audio for transcription.

The floor moves from -30 to -45 because Discord per-user tracks are *digitally*
silent when that user is not transmitting — there is no quiet-room floor to
clear, and at -30 a soft-spoken player or a whispered aside can fall below the
threshold and be cut entirely, silently. Trimming is invisible by nature, so a
track that keeps almost nothing now logs a warning naming the configured floor.
This is the one value in the hotfix that should be checked against a real
recording before it is trusted.

Duration invariants added (#324). process_audio already received the wall-clock
duration_seconds from the bot, logged it into the Discord embed, and compared it
against nothing — which is why the capture bug ran undetected for months despite
being arithmetically obvious from data the backend already held. Two guards,
both failing loud into the existing failed-status path:

- before transcription, reject any track under 90% of the recording, so a
  recording that cannot produce a correct transcript never reaches the GPU. On
  the 2026-08-11 incident this fires on every track (607-3287 s vs 10800 s).
- before the merge, reject a timeline covering under 60% of the recording.

Both are skipped when the duration is unknown, since the manual reprocess
endpoints pass 0 and re-running a suspect recording is an explicit admin action.
They live in audio_service as pure functions rather than inline in the Celery
closure, so they are directly testable.

Worth recording why the obvious check would not have helped: timestamp
monotonicity proves nothing, because the merge sorts by start time and its
output is non-decreasing for any input whatsoever. That validates the sorted()
call, not the data. The detectable signal was always magnitude, not order.

Transcript text no longer goes to logs (#325). audio_service logged
str(data)[:500] of the transcription response at INFO; that repr opens with the
segments array, so it emitted several utterances of real dialogue together with
the speakers' display names — in direct violation of the invariant stated in
this module's own docstring. Verified present in production worker logs. Now
logs response keys, segment count and character totals only. For a hosted
product this is customer conversation content in operator logs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine sessions were recorded and processed while per-speaker tracks did not
share a session clock (#320). They cannot be repaired: their audio has already
been reaped, and reprocessing would only re-derive the same scrambled merge
from the same broken input.

Leaving them unannotated is the actual problem. The transcripts read coherently
— within each speaker the order is preserved, stamps ascend cleanly, no text is
garbled — so nothing about them looks wrong. And their summaries have already
fed the lore pipeline into campaign canon, so a GM reading back through the
wiki has no way to know which entries came from a scrambled source. This is
provenance, not a to-do.

Adds sessions.pre_timeline_fix, backfilled true for every session that already
had a transcript when the migration runs. That needs no date comparison and no
guess about deploy timing: the fix ships in the same release, so anything
transcribed afterwards is by definition on a real clock. Self-hosters upgrading
at different times get the same correct answer.

Surfaced on the session page as an explained notice rather than a bare badge,
and shown to players as well as GMs — players read these recaps too. The
content is deliberately not hidden or deleted: the events did happen, only
their order and some attributions are wrong, and at least one summary has
already been hand-corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the live half of the capture tripwire. The dashboard has published
per-speaker captured time next to elapsed time since the feature shipped, and
nothing ever compared the two — so when per-speaker tracks lost their shared
clock (#320), the divergence sat on screen for months in plain sight.

Now that every track is placed against one session wall clock and padded
through silence, track_seconds must stay level with elapsed_seconds for
everyone. A speaker more than a minute behind is flagged, with a minute of
slack for jitter and a two-minute warm-up so the comparison only fires once it
means something.

This is the first thing to watch on the first real session after deploy: the
warning should never appear, and if it does the transcript should not be
trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build_session_response constructs SessionResponse field by field, and that list
had drifted from the schema. Seven declared, Session-backed fields were never
passed, so the API returned their defaults regardless of what was in the row:

  lore_generation_status, lore_generation_error, content_approved_at,
  content_approved_by_id, erasure_notes, series_id, series_occurrence_date

The user-visible cost: SessionDetail gates its whole lore-generation block on
lore_generation_status, so the four-stage progress labelling, the failure
message with its re-run button, and the only link the product offers to the
proposals queue could never render. The GM's approval state was invisible, and
#118 erasure notices never appeared for anyone.

Found by accident while adding pre_timeline_fix (#328) to the same builder —
which is the reason this belongs in the hotfix rather than after it. A fix that
adds one field to a builder while leaving six broken ones beside it, without
the guard that would have caught its own near-miss, is not a fix.

The guard is the substantive part: a source-level test asserting every
SessionResponse field that maps to a Session attribute is actually passed. It
is deliberately not a value comparison, because a value test only catches
fields the test author remembered to set — the same failure mode that allowed
this. It immediately found series_id and series_occurrence_date, which I had
missed reading the code by hand.

Two things handled while in here:

- erasure_notes are now served without member_user_id. The note text is
  deliberately anonymous ("Contributions from a former member were removed
  on ..."), and #118 shows it to every member; shipping the erased member's id
  alongside it would undo that anonymity for anyone reading the API. Nothing
  renders the field.
- The "Review proposals" link pointed at the wiki home rather than the
  proposals page. Populating the status makes that link visible for the first
  time, and shipping a newly-visible link to the wrong page would just be a
  new defect. Taken from #375, which keeps the rest of its scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chore(release): v3.11.5
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 40s
CI / Bot/backend version sync (pull_request) Successful in 24s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m59s
CI / Bot tests and audit (pull_request) Successful in 2m5s
CI / Backend migration, tests, and audit (pull_request) Successful in 6m4s
CI / Docker image build (pull_request) Successful in 5m40s
ad5b4faa21
Version constants and changelog for the recording-clock hotfix.

The changelog preamble leads with the upgrade constraint rather than the fix,
because this is the release where getting that wrong is expensive.
BOT_CONTRACT_VERSION stays 1, so neither image refuses to start — but the two
halves depend on each other. A new backend with an old bot now rejects
recordings outright (correctly: the old bot cannot produce a usable one). A new
bot with an old backend fails quietly instead, sending roughly nine times the
audio to Whisper with silence trimming still off.

Also flags the migration and the changed VAD defaults, since trimming is
invisible by nature and a self-hoster with a quiet speaker should check one
recording after upgrading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.