from __future__ import annotations import logging from pathlib import Path from typing import Callable from discord.ext import voice_recv from recording import MANIFEST_NAME, MeetingSink logger = logging.getLogger(__name__) class MeetingRecorder: """Wrapper around discord-ext-voice-recv's listen/stop_listening API. Records a hybrid capture: * a mixed archive WAV at ``output_path`` (everyone, silence-padded) * one speech-only WAV per speaker under ``tracks/`` next to the archive * a ``tracks.json`` manifest in that ``tracks/`` directory """ def __init__(self, voice_client: voice_recv.VoiceRecvClient, output_path: str): self.vc = voice_client self.output_path = output_path self.tracks_dir = str(Path(output_path).parent / "tracks") self.manifest_path = str(Path(self.tracks_dir) / MANIFEST_NAME) self.recording = False self.sink: voice_recv.AudioSink | None = None async def start(self, after_callback: Callable[[Exception | None], None]) -> None: if self.vc.is_listening(): raise RuntimeError("Voice client is already listening") Path(self.output_path).parent.mkdir(parents=True, exist_ok=True) meeting_sink = MeetingSink(self.output_path, self.tracks_dir) # SilenceGeneratorSink keeps the mixed archive timeline faithful when # packets are dropped; MeetingSink itself excludes silence frames from # the per-speaker tracks. self.sink = voice_recv.SilenceGeneratorSink(meeting_sink) self.vc.listen(self.sink, after=after_callback) self.recording = True logger.info( "Voice receive started: output_path=%s tracks_dir=%s", self.output_path, self.tracks_dir, ) async def stop(self) -> None: if not self.recording: logger.info("Stop requested, but recorder was already inactive: output_path=%s", self.output_path) return if self.vc.is_listening(): self.vc.stop_listening() logger.info("Voice receive stop requested: output_path=%s", self.output_path) self.recording = False