recorder-mx-py

[MIRROR of https://git.nordgedanken.dev/mtrnord/matrix-call-multitrack-recorder/] A tool to record every participant of a legacy or new matrix, call to separate audio and video files.
git clone git://archive.git.mtrnord.blog/MTRNord/recorder-mx-py.git
Log | Files | Refs | README | LICENSE

commit e8a85c491b6934fc8dc5ad6cb055af95f2d2c950
parent 88532ef2bb1ec0a78b2813d908a8045d205497a7
Author: MTRNord <mtrnord1@gmail.com>
Date:   Wed,  1 Feb 2023 19:05:42 +0100

Improve state keeping by using futures instead of while loops and sleep

Diffstat:
Mmatrix_call_multitrack_recorder/__main__.py | 4++--
Mmatrix_call_multitrack_recorder/bot.py | 34+++++++++++++++++-----------------
Mmatrix_call_multitrack_recorder/recorder.py | 198+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Amatrix_call_multitrack_recorder/utils/__init__.py | 4++++
Amatrix_call_multitrack_recorder/utils/future_map.py | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amatrix_call_multitrack_recorder/utils/misc_types.py | 7+++++++
Mpyproject.toml | 3++-
7 files changed, 200 insertions(+), 110 deletions(-)

diff --git a/matrix_call_multitrack_recorder/__main__.py b/matrix_call_multitrack_recorder/__main__.py @@ -3,11 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import asyncio -import uvloop import sys -from logbook import Logger, StreamHandler import logbook +import uvloop +from logbook import Logger, StreamHandler from matrix_call_multitrack_recorder.bot import RecordingBot, login diff --git a/matrix_call_multitrack_recorder/bot.py b/matrix_call_multitrack_recorder/bot.py @@ -7,28 +7,28 @@ import getpass import json import os import sys -from nio.events import to_device + +import logbook # type: ignore +from logbook import Logger, StreamHandler from nio import ( AsyncClient, - ToDeviceCallAnswerEvent, - CallInviteEvent, + AsyncClientConfig, CallCandidatesEvent, CallHangupEvent, - ToDeviceCallInviteEvent, - ToDeviceCallCandidatesEvent, - ToDeviceCallHangupEvent, - MSC3401CallEvent, + CallInviteEvent, CallMemberEvent, - RoomMessageText, - MatrixRoom, InviteEvent, - ProfileGetDisplayNameResponse, LoginResponse, - AsyncClientConfig, + MatrixRoom, + MSC3401CallEvent, + ProfileGetDisplayNameResponse, + RoomMessageText, + ToDeviceCallAnswerEvent, + ToDeviceCallCandidatesEvent, + ToDeviceCallHangupEvent, + ToDeviceCallInviteEvent, ) - -from logbook import Logger, StreamHandler -import logbook +from nio.events import to_device from matrix_call_multitrack_recorder.recorder import Recorder @@ -63,8 +63,8 @@ class RecordingBot: logger.debug("Uploaded keys") self.client.add_event_callback(self.message_callback, RoomMessageText) # type: ignore - self.client.add_event_callback(self.call_candidates, CallCandidatesEvent) # type: ignore self.client.add_event_callback(self.call_invite, CallInviteEvent) # type: ignore + self.client.add_event_callback(self.call_candidates, CallCandidatesEvent) # type: ignore self.client.add_event_callback(self.call_hangup, CallHangupEvent) # type: ignore self.client.add_event_callback(self.msc3401_call, MSC3401CallEvent) # type: ignore self.client.add_event_callback(self.msc3401_call_member, CallMemberEvent) # type: ignore @@ -188,7 +188,7 @@ class RecordingBot: """Handles call hangups.""" asyncio.create_task(self.recorder.handle_call_hangup(None, event)) - async def cb_autojoin_room(self, room: MatrixRoom, event: InviteEvent): + async def cb_autojoin_room(self, room: MatrixRoom, event: InviteEvent) -> None: """Callback to automatically joins a Matrix room on invite. Arguments: @@ -196,7 +196,7 @@ class RecordingBot: event {InviteEvent} -- Provided by nio """ - async def join_task(): + async def join_task() -> None: await self.client.join(room.room_id) sender_display_name = await self.client.get_displayname(event.sender) if isinstance(sender_display_name, ProfileGetDisplayNameResponse): diff --git a/matrix_call_multitrack_recorder/recorder.py b/matrix_call_multitrack_recorder/recorder.py @@ -3,43 +3,52 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import asyncio -from dataclasses import dataclass -from fractions import Fraction import os import random import string -from typing import Dict, List, Optional, Tuple, Union import sys import time +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple, Union + +# This one is actually used. Sadly we cant tell py that +import av # type: ignore + +import logbook # type: ignore +from aioice.candidate import Candidate +from aiortc import ( + MediaStreamTrack, + RTCConfiguration, + RTCIceCandidate, + RTCIceGatherer, + RTCIceServer, + RTCPeerConnection, + RTCSessionDescription, +) +from aiortc.contrib.media import Frame, MediaPlayer, MediaRecorder, MediaStreamError +from aiortc.rtcicetransport import candidate_from_aioice, candidate_to_aioice + +# This one is actually used. Sadly we cant tell py that +from av.filter import ( + Filter, + Graph, +) # type: ignore from logbook import Logger, StreamHandler -import logbook -from nio import ( +from nio import ( # ToDeviceCallNegotiateEvent,; CallNegotiateEvent, AsyncClient, + CallCandidatesEvent, + CallHangupEvent, CallInviteEvent, MatrixRoom, - ToDeviceMessage, - CallCandidatesEvent, RoomGetStateError, + ToDeviceCallAnswerEvent, ToDeviceCallCandidatesEvent, - CallHangupEvent, - ToDeviceCallInviteEvent, ToDeviceCallHangupEvent, - ToDeviceCallAnswerEvent, - # ToDeviceCallNegotiateEvent, - # CallNegotiateEvent, -) -from aiortc import ( - RTCPeerConnection, - RTCSessionDescription, - MediaStreamTrack, - RTCIceGatherer, - RTCIceCandidate, + ToDeviceCallInviteEvent, + ToDeviceMessage, ) -from aiortc.contrib.media import MediaRecorder, MediaPlayer, MediaStreamError, Frame -from aiortc.rtcicetransport import candidate_from_aioice, candidate_to_aioice -from aioice.candidate import Candidate -import av -from av.filter import Filter, Graph +from .utils.future_map import FutureMap +from .utils.misc_types import InputTracks # import logging @@ -59,6 +68,9 @@ ConfID = str RoomID = str UniqueCallID = Tuple[UserID, ConfID] +# TODO: Make via config +STUN = RTCConfiguration(iceServers=[RTCIceServer(urls="stun:turn.matrix.org")]) + @dataclass class WrappedConn: @@ -76,9 +88,8 @@ class Others: class ProxyTrack(MediaStreamTrack): - _source: MediaStreamTrack - _source_wait: Optional[asyncio.Future] - _graph: Optional[Graph] + __source: MediaStreamTrack + __graph: Optional[Graph] def __init__( self, @@ -86,46 +97,47 @@ class ProxyTrack(MediaStreamTrack): ) -> None: super().__init__() self.kind = source.video.kind - self._source = source.video - self._graph = None + self.__source = source.video + self.__graph = None async def start(self, frame: Frame) -> None: - self._graph = Graph() - graph_source = self._graph.add_buffer(template=frame) + self.__graph = Graph() + graph_source = self.__graph.add_buffer(template=frame) - graph_filter = self._graph.add( + graph_filter = self.__graph.add( "drawtext", r"text='Recording Duration: %{pts:gmtime:0:%H\:%M\:%S}':x=(w-text_w)/2:y=(h-text_h)/2:fontcolor=white:fontsize=128", ) - graph_sink = self._graph.add("buffersink") + graph_sink = self.__graph.add("buffersink") graph_source.link_to(graph_filter, 0, 0) graph_filter.link_to(graph_sink, 0, 0) - self._graph.configure() + self.__graph.configure() async def recv(self) -> Frame: try: - frame = await self._source.recv() - if self._graph is None: + frame = await self.__source.recv() + if not self.__graph: await self.start(frame) - self._graph.push(frame) - filtered_frame = self._graph.pull() - return filtered_frame + if self.__graph: + self.__graph.push(frame) + filtered_frame = self.__graph.pull() + return filtered_frame except MediaStreamError as e: - frame = await self._source.recv() + frame = await self.__source.recv() logger.warning(f"Error in recv: {e}") return frame except Exception as e: logger.warning(f"Error in recv: {e}") - return await self._source.recv() + return await self.__source.recv() def stop(self) -> None: - self._source.stop() + self.__source.stop() super().stop() class Recorder: - __conns: Dict[UniqueCallID, WrappedConn] + __conns: FutureMap[UniqueCallID, WrappedConn] party_id: str client: AsyncClient loop: asyncio.AbstractEventLoop @@ -140,10 +152,12 @@ class Recorder: self.loop = asyncio.get_event_loop() async def start(self) -> None: - self.__conns = {} + self.__conns = FutureMap() self.conf_room = {} self.room_conf = {} self.others = {} + # Prepare the Track we show when starting a recording + # FIXME: This is probably wrong? Since we should have this per recording session. self.output_track = ProxyTrack( MediaPlayer( "./black.png", @@ -166,7 +180,7 @@ class Recorder: async def stop(self) -> None: logger.info("Stopping recording handler") - for (_, conf_or_call_id), conn in self.__conns.items(): + for (_, conf_or_call_id), conn in await self.__conns.items(): await self.hangup(conf_or_call_id, conn) def add_call(self, conf_id: ConfID, room: MatrixRoom) -> None: @@ -217,7 +231,7 @@ class Recorder: async def leave_call(self, room: MatrixRoom) -> None: if room.room_id in self.room_conf: conf_id = self.room_conf[room.room_id] - for (user_id, conf_or_call_id), conn in list(self.__conns.items()): + for (user_id, conf_or_call_id), conn in list(await self.__conns.items()): if conf_or_call_id == conf_id: await self.hangup(conf_or_call_id, conn) del self.__conns[(user_id, conf_or_call_id)] @@ -248,10 +262,9 @@ class Recorder: async def remove_connection(self, room: MatrixRoom) -> None: if room.room_id in self.room_conf: call_id = self.room_conf[room.room_id] - if (self.client.user_id, call_id) in self.__conns: - del self.__conns[(self.client.user_id, call_id)] - del self.room_conf[room.room_id] - del self.conf_room[call_id] + del self.__conns[(self.client.user_id, call_id)] + del self.room_conf[room.room_id] + del self.conf_room[call_id] def track_others( self, conf_id: str, device_id: str, user_id: str, session_id: str @@ -328,11 +341,12 @@ class Recorder: continue logger.info(f"Making offer for {data.user_id}") + # Create offer - pc = RTCPeerConnection() + pc = RTCPeerConnection(STUN) logger.info(f"Started ice for {call_id}") unique_id = (data.user_id, self.room_conf[room.room_id]) - conn = self.__conns[unique_id] = WrappedConn( + conn = WrappedConn( pc=pc, candidate_waiter=None, prepare_waiter=None, @@ -340,9 +354,6 @@ class Recorder: ) logger.info(f"Created connection {unique_id}") - # conn.pc.addTransceiver("video", direction="recvonly") - # conn.pc.addTransceiver("audio", direction="recvonly") - conn.pc.addTrack(self.output_track) offer = await conn.pc.createOffer() @@ -364,12 +375,9 @@ class Recorder: ) ) - # @conn.pc.on("connectionstatechange") - # async def on_connectionstatechange() -> None: - # if conn.pc.connectionState == "failed": - # logger.info(f"Connection {unique_id} failed") - # await conn.pc.close() - # self.__conns.pop(unique_id, None) + # We set this late to make sure the connection is actually set up before we handle the answer. + # Order of operation is weird otherwise. + self.__conns[unique_id] = conn offer_message = ToDeviceMessage( "m.call.invite", @@ -424,7 +432,6 @@ class Recorder: }, ) await self.client.to_device(candidates_message) - await asyncio.sleep(0) # async def handle_negotiation( # self, @@ -433,7 +440,7 @@ class Recorder: # ): # pass - async def handle_call_answer(self, event: ToDeviceCallAnswerEvent): + async def handle_call_answer(self, event: ToDeviceCallAnswerEvent) -> None: logger.info(f"Received call answer from {event.sender}") unique_id: UniqueCallID = (event.sender, event.conf_id) @@ -443,12 +450,11 @@ class Recorder: logger.warning("Got invalid to-device call answer") return - while unique_id not in self.__conns: - logger.debug("Waiting for connection of answer to be created") - await asyncio.sleep(0.2) - try: - conn = self.__conns[unique_id] + conn = await asyncio.wait_for(self.__conns[unique_id], timeout=3) + except asyncio.TimeoutError: + logger.warning("Gave up waiting for call, task canceled") + return except KeyError: logger.warning("Received answer for unknown call") return @@ -467,6 +473,7 @@ class Recorder: logger.warning("Received answer for unknown call") return + logger.info(f"Sending select_answer for {unique_id}") message = ToDeviceMessage( "m.call.select_answer", recipient=event.sender, @@ -536,11 +543,12 @@ class Recorder: offer = RTCSessionDescription( sdp=str(event.offer.get("sdp")), type=str(event.offer.get("type")) ) - pc = RTCPeerConnection() + + pc = RTCPeerConnection(STUN) if isinstance(event, CallInviteEvent): unique_id: UniqueCallID = (event.sender, event.call_id) else: - unique_id: UniqueCallID = (event.sender, event.conf_id) + unique_id = (event.sender, event.conf_id) conn = self.__conns[unique_id] = WrappedConn( pc=pc, candidate_waiter=self.loop.create_future(), @@ -548,7 +556,7 @@ class Recorder: room_id=room.room_id if room else None, ) logger.info("Adding tracks") - input_tracks = {} + input_tracks: InputTracks = {} async def task() -> None: if isinstance(event, CallInviteEvent): @@ -578,8 +586,9 @@ class Recorder: wav_file = os.path.join(conf_path, f"{base_name_audio}.wav") mp4_file = os.path.join(conf_path, f"{base_name_video}.mp4") audio_recorder = MediaRecorder(wav_file, format="wav") - audio_recorder.addTrack(input_tracks["audio"]) - await audio_recorder.start() + if "audio" in input_tracks: + audio_recorder.addTrack(input_tracks["audio"]) + await audio_recorder.start() if "video" in input_tracks: video_recorder = MediaRecorder(mp4_file, format="mp4") video_recorder.addTrack(input_tracks["video"]) @@ -587,7 +596,13 @@ class Recorder: else: video_recorder = None - audio_track = input_tracks["audio"] + if "audio" in input_tracks: + audio_track = input_tracks["audio"] + + @audio_track.on("ended") + async def on_ended_audio(): + await audio_recorder.stop() + if "video" in input_tracks: video_track = input_tracks["video"] @@ -596,23 +611,22 @@ class Recorder: if video_recorder: await video_recorder.stop() - @audio_track.on("ended") - async def on_ended_audio(): - await audio_recorder.stop() - logger.info("Setting up callbacks") @pc.on("connectionstatechange") async def on_connectionstatechange() -> None: if pc.connectionState == "failed": await pc.close() - self.__conns.pop(unique_id, None) + del self.__conns[unique_id] if pc.connectionState == "connected": asyncio.create_task(task()) @pc.on("track") def on_track(track: MediaStreamTrack) -> None: - input_tracks[track.kind] = track + if track.kind == "audio": + input_tracks["audio"] = track + elif track.kind == "video": + input_tracks["video"] = track logger.info("Waiting for prepare") await pc.setRemoteDescription(offer) @@ -634,7 +648,7 @@ class Recorder: if not answer: logger.warning("Failed to create answer") await pc.close() - self.__conns.pop(unique_id, None) + del self.__conns[unique_id] if room: await self.hangup(event.call_id, conn) elif isinstance(event, ToDeviceCallInviteEvent): @@ -726,17 +740,17 @@ class Recorder: if isinstance(event, CallCandidatesEvent): unique_id: UniqueCallID = (event.sender, event.call_id) else: - unique_id: UniqueCallID = (event.sender, event.conf_id) + unique_id = (event.sender, event.conf_id) if event.conf_id not in self.conf_room: logger.warning("Got invalid to-device call candidates") return - while unique_id not in self.__conns: - logger.debug("Waiting for connection to be created") - await asyncio.sleep(0.2) try: - conn = self.__conns[unique_id] + conn = await asyncio.wait_for(self.__conns[unique_id], timeout=3) + except asyncio.TimeoutError: + logger.warning("Gave up waiting for call, task canceled") + return except KeyError: logger.warning("Received candidates for unknown call") return @@ -780,6 +794,8 @@ class Recorder: room: Optional[MatrixRoom], event: Union[CallHangupEvent, ToDeviceCallHangupEvent], ) -> None: + if event.sender == self.client.user_id: + return reason = None if "reason" in event.source["content"]: reason = event.source["content"]["reason"] @@ -791,8 +807,6 @@ class Recorder: logger.info( f"Received call hangup from {event.sender} with reason {reason}" ) - if event.sender == self.client.user_id: - return # TODO: This is incorrect: if reason == "replaced": @@ -802,8 +816,12 @@ class Recorder: if isinstance(event, CallHangupEvent): unique_id: UniqueCallID = (event.sender, event.call_id) else: - unique_id: UniqueCallID = (event.sender, event.conf_id) - await self.__conns.pop(unique_id).pc.close() + unique_id = (event.sender, event.conf_id) + try: + conn = await asyncio.wait_for(self.__conns[unique_id], timeout=3) + await conn.pc.close() + except asyncio.TimeoutError: + logger.warning("Gave up waiting for call, task canceled") if room and isinstance(event, CallHangupEvent): await self.client.update_receipt_marker(room.room_id, event.event_id) diff --git a/matrix_call_multitrack_recorder/utils/__init__.py b/matrix_call_multitrack_recorder/utils/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2023-present MTRNord <support@nordgedanken.dev> +# +# SPDX-License-Identifier: AGPL-3.0-or-later + diff --git a/matrix_call_multitrack_recorder/utils/future_map.py b/matrix_call_multitrack_recorder/utils/future_map.py @@ -0,0 +1,60 @@ +""" Map that works in an async env + +The idea is that we can await this map. + +Code by tulir: +https://matrix.to/#/!xALORqBdeiSfgdrmUb:bpulse.org/$yjWpFraV0brev_g6Wg7Ke3i-Pc-508qtsO0RIz7sUcE?via=bpulse.org&via=matrix.org&via=envs.net +""" + +import asyncio +from typing import AsyncIterator, Dict, Generic, List, Tuple, TypeVar, Union + +import logbook # type: ignore +from logbook import Logger + +Key = TypeVar("Key") +Value = TypeVar("Value") + +logger = Logger(__name__) +logger.level = logbook.INFO + + +class FutureMap(Generic[Key, Value]): + def __init__(self) -> None: + self.data: Dict[Key, Union[Value, asyncio.Future[Value]]] = {} + + async def __getitem__(self, key: Key) -> Value: + try: + value = self.data[key] + if isinstance(value, asyncio.Future): + return await asyncio.shield(value) + return value + except KeyError: + self.data[key] = fut = asyncio.get_running_loop().create_future() + return await asyncio.shield(fut) + + def __setitem__(self, key: Key, value: Value) -> None: + try: + existing = self.data[key] + except KeyError: + pass + else: + if isinstance(existing, asyncio.Future) and not existing.done(): + existing.set_result(value) + self.data[key] = value + + def __delitem__(self, key: Key) -> None: + try: + del self.data[key] + except KeyError: + pass + + async def items(self) -> List[Tuple[Key, Value]]: + return_list = [] + for key in self.data: + try: + conn = await asyncio.wait_for(self[key], timeout=0.5) + return_list.append((key, conn)) + except asyncio.TimeoutError: + logger.warning("Gave up waiting for call, task canceled") + return return_list diff --git a/matrix_call_multitrack_recorder/utils/misc_types.py b/matrix_call_multitrack_recorder/utils/misc_types.py @@ -0,0 +1,7 @@ +from typing import TypedDict +from aiortc import MediaStreamTrack + + +class InputTracks(TypedDict, total=False): + audio: MediaStreamTrack + video: MediaStreamTrack diff --git a/pyproject.toml b/pyproject.toml @@ -50,7 +50,8 @@ dependencies = [ "pytest", "pytest-cov", "black", - "pylint" + "pylint", + "mypy" ] [tool.hatch.envs.default.scripts] cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=matrix_call_multitrack_recorder --cov=tests {args}"