import asyncio import json import os import sys import wave from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) import recording class FakeUser: def __init__(self, user_id, display_name=None, name=None): self.id = user_id if display_name is not None: self.display_name = display_name if name is not None: self.name = name class FakeData: def __init__(self, pcm): self.pcm = pcm def _read_wav_frames(path): with wave.open(str(path), "rb") as w: return w.readframes(w.getnframes()) SPEECH = b"\x10\x20" * 100 # non-silent PCM SILENCE = b"\x00\x00" * 100 def test_meeting_sink_writes_mixed_and_per_speaker_tracks(tmp_path): mixed = tmp_path / "meeting.wav" tracks_dir = tmp_path / "tracks" sink = recording.MeetingSink(str(mixed), str(tracks_dir)) alice = FakeUser(111, display_name="Alice") bob = FakeUser(222, display_name="Bob") sink.write(alice, FakeData(SPEECH)) sink.write(bob, FakeData(SPEECH)) sink.write(alice, FakeData(SPEECH)) sink.cleanup() # Mixed archive is a valid, finalized WAV containing all frames. assert mixed.exists() mixed_frames = _read_wav_frames(mixed) assert mixed_frames == SPEECH * 3 # Each speaker has their own track. alice_track = tracks_dir / "track-111.wav" bob_track = tracks_dir / "track-222.wav" assert _read_wav_frames(alice_track) == SPEECH * 2 assert _read_wav_frames(bob_track) == SPEECH # Manifest maps tracks back to display names. manifest = json.loads((tracks_dir / "tracks.json").read_text()) names = {t["display_name"]: t["user_id"] for t in manifest["tracks"]} assert names == {"Alice": 111, "Bob": 222} def test_meeting_sink_excludes_silence_from_speaker_tracks(tmp_path): 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.write(alice, FakeData(SILENCE)) # synthetic silence frame sink.cleanup() # Mixed gets both frames (faithful timeline), speaker track only the speech. assert _read_wav_frames(mixed) == SPEECH + SILENCE assert _read_wav_frames(tracks_dir / "track-111.wav") == SPEECH def test_meeting_sink_ignores_silence_only_and_unknown_users(tmp_path): mixed = tmp_path / "meeting.wav" tracks_dir = tmp_path / "tracks" sink = recording.MeetingSink(str(mixed), str(tracks_dir)) # Silence with no user, then silence with a user: neither creates a track. sink.write(None, FakeData(SILENCE)) sink.write(FakeUser(999, display_name="Quiet"), FakeData(SILENCE)) sink.cleanup() manifest = json.loads((tracks_dir / "tracks.json").read_text()) 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