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 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" }