fix(meeting): close a session on transcript idle, not just mic silence - #38
Merged
Conversation
A real room is rarely silent (HVAC, distant talk), so the energy gate alone kept a Speechmatics session open — and billing — long after a meeting's words stopped: a real 98-min capture ran ~80 min past the meeting on background noise above the VAD threshold, producing almost no transcript. Add a second close trigger: finalize when Speechmatics commits no new transcript turn for MEETING_TRANSCRIPT_IDLE_CLOSE_S (default 300s), using the session's already-exposed committed-turn count. To avoid billing churn (energy stays high → instant reopen), the connection then goes DORMANT: mere room noise opens no new session until it re-arms on a genuine silence gap, a marker, or a reconnect. Contained to meeting_bridge (server.py + one config field); guarded by gate.enabled so the legacy threshold=0 one-session mode is unchanged; defensive getattr so a backend without a turn signal stays on the energy-only path. No change to the shared ambient active_session. Tests cover idle-close + dormant suppression, turn-activity keep-open, silence/marker re-arm, and the disable knobs. Docs updated (README, meeting.env.example, CONTRACTS 1c). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WingedGuardian
added a commit
that referenced
this pull request
Jul 16, 2026
…nals The #38 idle-close used the committed-turn count as its "meeting still going?" signal. Day-one real use showed that fails on quiet/far-field audio: Speechmatics can go many minutes hearing speech it never commits as a final, so a LIVE meeting read as idle — the session closed, went dormant, and ~15min of a real meeting were silently dropped until a quiet gap re-armed it. Switch the idle signal to ASR SPEECH EVIDENCE: ActiveSession now stamps last_activity (monotonic) on every NON-EMPTY partial and every committed final, and the meeting server gates the idle close on that — with the turn count kept as fallback for backends without it. Partials fire whenever the ASR hears anything speech-like, so a hard-to-hear meeting stays open while a post-meeting noisy room (no partials) still closes and stops billing. ambient_bridge/active_session.py change is additive (a timestamp + property; the partial lambda became a bound method with identical accumulator/flush behavior) — ambient bridge behavior is unchanged. Empty/keep-alive partials don't count as evidence (same extraction as set_partial). Tests cover the evidence rules on ActiveSession and the keep-open/close/dormant lifecycle on the server; docs updated (config comment, README, meeting.env.example, CONTRACTS 1c). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WingedGuardian
added a commit
that referenced
this pull request
Jul 16, 2026
…nals (#39) The #38 idle-close used the committed-turn count as its "meeting still going?" signal. Day-one real use showed that fails on quiet/far-field audio: Speechmatics can go many minutes hearing speech it never commits as a final, so a LIVE meeting read as idle — the session closed, went dormant, and ~15min of a real meeting were silently dropped until a quiet gap re-armed it. Switch the idle signal to ASR SPEECH EVIDENCE: ActiveSession now stamps last_activity (monotonic) on every NON-EMPTY partial and every committed final, and the meeting server gates the idle close on that — with the turn count kept as fallback for backends without it. Partials fire whenever the ASR hears anything speech-like, so a hard-to-hear meeting stays open while a post-meeting noisy room (no partials) still closes and stops billing. ambient_bridge/active_session.py change is additive (a timestamp + property; the partial lambda became a bound method with identical accumulator/flush behavior) — ambient bridge behavior is unchanged. Empty/keep-alive partials don't count as evidence (same extraction as set_partial). Tests cover the evidence rules on ActiveSession and the keep-open/close/dormant lifecycle on the server; docs updated (config comment, README, meeting.env.example, CONTRACTS 1c). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.
Why
Real-day validation surfaced a cost bug independent of ASR quality: a real ~98-min capture kept one Speechmatics session open (and billing) for ~80 min after the meeting ended. A real room is rarely silent (HVAC, distant talk keep the mic peak above the VAD threshold), so the energy gate never saw its 45s of silence — and that 80 min produced almost no transcript.
What
Add a second close trigger: finalize when Speechmatics commits no new transcript turn for
MEETING_TRANSCRIPT_IDLE_CLOSE_S(default 300s), even while the mic stays loud — usingActiveSession.turns(already exposed, already deployed on the edge). The committed-turn cadence is a far better "meeting still going?" signal than mic energy.To avoid billing churn (energy stays high → the next frame instantly reopens an empty session → close → reopen…), the connection then goes dormant: mere room noise opens no new billed session until it re-arms on a genuine
MEETING_SILENCE_CLOSE_Squiet gap, a marker, or a reconnect.config.py: one field,transcript_idle_close_s(default 300,0disables).server.py::_handle_ws: dormant state machine + turn-cadence tracking + a local silence timer for re-arm. Guarded bygate.enabledso legacythreshold=0one-session mode is byte-for-byte unchanged; defensivegetattr(session,"turns",…)keeps a backend without the signal on the energy-only path.ambient_bridge/active_session.py— the whole fix is meeting-bridge-only, so it deploys via the standard overlay with no ambient risk.deploy/meeting.env.example,CONTRACTS.md§1c.Verification
--no-cache+ compileall clean.CLOSE (transcript idle 6s)fired withturns=0; continued loud tone was gated (fwd=0) not billed and opened no new session (dormant); a silence gap loggedre-armed after 4s silence; the next tone opened session fix(s2s): age cached context from the last real turn, not last reconnect #2. Exactly the intended lifecycle.Known tradeoff (accepted)
Dormant recovery is silent: a real meeting that pauses >5 min with the room still noisy and resumes without a 45s quiet gap or a marker press will drop the resumed audio until it re-arms. Narrow (a real meeting commits a turn ~every 40s), recoverable, and env-tunable. The proper mitigation — a phone-side "recording paused / tap to resume" indicator — is tracked as a separate follow-up (needs a bidirectional-WS + Android-client change).
🤖 Generated with Claude Code