From be8697d68c7ed61e6bf861e026eb7ce1a16ee1ed Mon Sep 17 00:00:00 2001 From: Nando Bosshart Date: Fri, 21 Aug 2026 14:35:17 +0200 Subject: [PATCH 1/2] fix: drop caption segments that span a recording pause Closed captions arrive as growing segment texts keyed by deviceId:captionId. Ignoring caption updates only while the recording is paused is not enough to keep paused speech out of the transcript: the first update after resume carries the whole accumulated segment text, including the words spoken during the pause, and gets recorded with the resume timestamp. Freeze any segment that sends an update during a pause and keep dropping it after resume; segments that start after the resume pass normally. Applies to all platforms using WebBotAdapter caption handling. Reproduced on Google Meet with meeting_closed_captions transcription: speech during a pause showed up verbatim in the transcript after resume. --- bots/paused_caption_filter.py | 23 ++++++++++++++++++ bots/tests/test_paused_caption_filter.py | 31 ++++++++++++++++++++++++ bots/web_bot_adapter/web_bot_adapter.py | 5 +++- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 bots/paused_caption_filter.py create mode 100644 bots/tests/test_paused_caption_filter.py diff --git a/bots/paused_caption_filter.py b/bots/paused_caption_filter.py new file mode 100644 index 000000000..c43e7d9ba --- /dev/null +++ b/bots/paused_caption_filter.py @@ -0,0 +1,23 @@ +class PausedCaptionFilter: + """Drops caption updates that would leak speech from a recording pause. + + Platform captions (Google Meet/Teams/Zoom web) arrive as *growing* segment texts keyed + by ``deviceId:captionId``. Ignoring updates only while paused is not enough: + the first update after resume carries the whole segment, including the words + spoken during the pause. Any segment that sends an update during a pause is + therefore frozen for good; segments that start after resume pass normally. + """ + + def __init__(self): + self._frozen_keys = set() + + @staticmethod + def key(caption): + return f"{caption.get('deviceId')}:{caption.get('captionId')}" + + def should_drop(self, caption, recording_paused): + key = self.key(caption) + if recording_paused: + self._frozen_keys.add(key) + return True + return key in self._frozen_keys diff --git a/bots/tests/test_paused_caption_filter.py b/bots/tests/test_paused_caption_filter.py new file mode 100644 index 000000000..f8805f5cc --- /dev/null +++ b/bots/tests/test_paused_caption_filter.py @@ -0,0 +1,31 @@ +import unittest + +from bots.paused_caption_filter import PausedCaptionFilter + + +class PausedCaptionFilterTest(unittest.TestCase): + def test_segment_spanning_a_pause_stays_dropped_after_resume(self): + f = PausedCaptionFilter() + seg = {"captionId": "c1", "deviceId": "u1"} + self.assertFalse(f.should_drop(seg, recording_paused=False)) # before pause: recorded + self.assertTrue(f.should_drop(seg, recording_paused=True)) # during pause: dropped + # After resume the same segment carries the paused words -> still dropped + self.assertTrue(f.should_drop(seg, recording_paused=False)) + + def test_new_segment_after_resume_passes(self): + f = PausedCaptionFilter() + f.should_drop({"captionId": "c1", "deviceId": "u1"}, recording_paused=True) + self.assertFalse(f.should_drop({"captionId": "c2", "deviceId": "u1"}, recording_paused=False)) + + def test_segments_are_keyed_per_device(self): + f = PausedCaptionFilter() + f.should_drop({"captionId": "c1", "deviceId": "u1"}, recording_paused=True) + self.assertFalse(f.should_drop({"captionId": "c1", "deviceId": "u2"}, recording_paused=False)) + + def test_untouched_segments_pass_when_not_paused(self): + f = PausedCaptionFilter() + self.assertFalse(f.should_drop({"captionId": "c1", "deviceId": "u1"}, recording_paused=False)) + + +if __name__ == "__main__": + unittest.main() diff --git a/bots/web_bot_adapter/web_bot_adapter.py b/bots/web_bot_adapter/web_bot_adapter.py index 16ba2c0b0..2ab7614c6 100644 --- a/bots/web_bot_adapter/web_bot_adapter.py +++ b/bots/web_bot_adapter/web_bot_adapter.py @@ -25,6 +25,7 @@ from bots.automatic_leave_utils import participant_is_another_bot from bots.bot_adapter import BotAdapter from bots.models import ParticipantEventTypes, RecordingViews +from bots.paused_caption_filter import PausedCaptionFilter from bots.per_participant_realtime_video_configuration import PerParticipantRealtimeVideoConfiguration from bots.room_sync_source_participant_configuration import RoomSyncSourceParticipantConfiguration from bots.room_sync_utils import add_bot_indicator_to_display_name @@ -132,6 +133,7 @@ def __init__( self.ready_to_send_chat_messages = False self.recording_paused = False + self.paused_caption_filter = PausedCaptionFilter() def pause_recording(self): self.recording_paused = True @@ -378,7 +380,8 @@ def handle_failed_to_join(self, reason): self.subclass_specific_handle_failed_to_join(reason) def handle_caption_update(self, json_data): - if self.recording_paused: + # Segments that carry speech from a pause stay dropped after resume, too. + if self.paused_caption_filter.should_drop(json_data["caption"], self.recording_paused): return # Count a caption as audio activity From fb3272e77a6b5a6dd7cf72841864eed9bc731813 Mon Sep 17 00:00:00 2001 From: Nando Bosshart Date: Thu, 8 Oct 2026 23:39:59 +0200 Subject: [PATCH 2/2] chore: bump version.json to 1.82.7 --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 21dff1d57..8678376ce 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "1.82.6" + "version": "1.82.7" }