Skip to content

fix(meeting): close a session on transcript idle, not just mic silence - #38

Merged
WingedGuardian merged 1 commit into
mainfrom
feat/meeting-idle-close
Jul 15, 2026
Merged

fix(meeting): close a session on transcript idle, not just mic silence#38
WingedGuardian merged 1 commit into
mainfrom
feat/meeting-idle-close

Conversation

@WingedGuardian

Copy link
Copy Markdown
Owner

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 — using ActiveSession.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_S quiet gap, a marker, or a reconnect.

  • config.py: one field, transcript_idle_close_s (default 300, 0 disables).
  • server.py::_handle_ws: dormant state machine + turn-cadence tracking + a local silence timer for re-arm. Guarded by gate.enabled so legacy threshold=0 one-session mode is byte-for-byte unchanged; defensive getattr(session,"turns",…) keeps a backend without the signal on the energy-only path.
  • No change to the shared ambient_bridge/active_session.py — the whole fix is meeting-bridge-only, so it deploys via the standard overlay with no ambient risk.
  • 6 new tests: idle-close + dormant suppression, turn-activity keep-open, silence/marker re-arm, and both disable knobs.
  • Docs: README config table, deploy/meeting.env.example, CONTRACTS.md §1c.

Verification

  • 49 meeting-bridge tests pass (ran 7× for the real-timing lifecycle tests — stable); ruff --no-cache + compileall clean.
  • E2E against real Speechmatics (isolated instance on the edge): a loud tone (above-threshold energy, no speech = the noise case) opened a session, then CLOSE (transcript idle 6s) fired with turns=0; continued loud tone was gated (fwd=0) not billed and opened no new session (dormant); a silence gap logged re-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.
  • Code-reviewed: no blockers; state machine (churn / re-arm ordering / double-finalize) verified correct.

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

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
WingedGuardian merged commit 982a4a7 into main Jul 15, 2026
4 checks passed
@WingedGuardian
WingedGuardian deleted the feat/meeting-idle-close branch July 15, 2026 17:16
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>
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