diff --git a/bot.py b/bot.py index 52d2d3d..d8383b0 100644 --- a/bot.py +++ b/bot.py @@ -287,7 +287,16 @@ async def process_recording( ) return - if not await wait_for_file_ready(file_path): + # Speaker tracks are written and finalized independently in MeetingSink + # cleanup — they always have valid WAV headers if they exist. Check + # them first so we can skip the mixed-file "ready" wait (which is prone + # to timing races with the SilenceGenerator shutdown path). + if _load_speaker_tracks(file_path): + logger.info( + "Speaker tracks present for guild_id=%s; skipping mixed-file readiness check", + guild_id, + ) + elif not await wait_for_file_ready(file_path): reason = "recording file was not finalized in time" await _save_retry_state(guild_id, file_path, fallback_channel_id, reason) await safe_send_chunked( diff --git a/recording.py b/recording.py index 2fab9e2..a89853d 100644 --- a/recording.py +++ b/recording.py @@ -111,6 +111,9 @@ class MeetingSink(voice_recv.AudioSink): return track def write(self, user, data) -> None: + if self._cleaned_up: + return + pcm = getattr(data, "pcm", b"") or b"" # Mixed archive always gets every frame (including silence) so the diff --git a/tests/test_bot.py b/tests/test_bot.py index ac361d3..2027795 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -200,3 +200,56 @@ def test_transcribe_recording_falls_back_to_single_file_for_old_recordings(tmp_p assert result == "single transcript" assert calls == [("single", str(meeting))] + + +def test_process_recording_skips_ready_wait_when_tracks_exist(tmp_path, monkeypatch): + """When speaker tracks exist, process_recording should not call wait_for_file_ready.""" + meeting = tmp_path / "meeting.wav" + meeting.write_bytes(b"RIFFdemo") + tracks_dir = tmp_path / "tracks" + tracks_dir.mkdir() + alice = tracks_dir / "track-111.wav" + alice.write_bytes(b"alice audio") + (tracks_dir / "tracks.json").write_text( + '{"tracks":[{"display_name":"Alice","path":"' + str(alice) + '"}]}' + ) + calls = [] + + async def fake_transcribe_recording(path, guild_id): + calls.append(("transcribe", path)) + return "[Alice]\nhello" + + async def fake_summarize(transcript): + calls.append(("summarize", transcript)) + return "Summary text." + + async def fake_get_output_channel(guild_id): + return 999 + + async def fake_clear_retry_state(_guild_id): + pass + + async def fake_resolve_text_channel(_channel_id): + return SimpleNamespace(id=999, send=lambda t: None) + + async def fake_safe_send_chunked(_channel, text, **kw): + calls.append(("delivered", text[:30])) + return True + + monkeypatch.setattr(bot, "_transcribe_recording", fake_transcribe_recording) + monkeypatch.setattr(bot, "summarize", fake_summarize) + monkeypatch.setattr(bot, "wait_for_file_ready", lambda *a, **kw: calls.append(("ready",)) or True) + monkeypatch.setattr(bot, "safe_send_chunked", fake_safe_send_chunked) + monkeypatch.setattr(bot, "config", SimpleNamespace( + get_output_channel=fake_get_output_channel, + clear_retry_state=fake_clear_retry_state, + set_retry_state=lambda g, p, c, r: None, + )) + monkeypatch.setattr(bot, "resolve_text_channel", fake_resolve_text_channel) + + asyncio.run(bot.process_recording(str(meeting), 123, 456, None)) + + # wait_for_file_ready should NOT have been called + assert ("ready",) not in calls, "wait_for_file_ready was called despite tracks existing" + assert calls[:2] == [("transcribe", str(meeting)), + ("summarize", "[Alice]\nhello")] diff --git a/tests/test_recording.py b/tests/test_recording.py index b3eef3c..b350eb5 100644 --- a/tests/test_recording.py +++ b/tests/test_recording.py @@ -92,3 +92,25 @@ def test_meeting_sink_ignores_silence_only_and_unknown_users(tmp_path): assert manifest["tracks"] == [] # Mixed archive still finalized and valid. assert _read_wav_frames(mixed) == SILENCE * 2 + + +def test_meeting_sink_ignores_writes_after_cleanup(tmp_path): + """Verify that writes after cleanup() are silently dropped.""" + mixed = tmp_path / "meeting.wav" + tracks_dir = tmp_path / "tracks" + sink = recording.MeetingSink(str(mixed), str(tracks_dir)) + + alice = FakeUser(111, display_name="Alice") + sink.write(alice, FakeData(SPEECH)) + sink.cleanup() + + # These writes should be dropped, not cause crashes or corrupt the files. + sink.write(alice, FakeData(SPEECH)) + sink.write(None, FakeData(SILENCE)) + + # Mixed file should contain only the pre-cleanup frame. + assert _read_wav_frames(mixed) == SPEECH + assert _read_wav_frames(tracks_dir / "track-111.wav") == SPEECH + # Manifest should still reference Alice. + manifest = json.loads((tracks_dir / "tracks.json").read_text()) + assert len(manifest["tracks"]) == 1 \ No newline at end of file