summaryrefslogtreecommitdiffstats
path: root/recording
diff options
context:
space:
mode:
Diffstat (limited to 'recording')
-rw-r--r--recording/src/nextcloud/talk/recording/Benchmark.py314
1 files changed, 314 insertions, 0 deletions
diff --git a/recording/src/nextcloud/talk/recording/Benchmark.py b/recording/src/nextcloud/talk/recording/Benchmark.py
new file mode 100644
index 000000000..fdc7621e8
--- /dev/null
+++ b/recording/src/nextcloud/talk/recording/Benchmark.py
@@ -0,0 +1,314 @@
+#
+# @copyright Copyright (c) 2023, Daniel Calviño Sánchez (danxuliu@gmail.com)
+#
+# @license GNU AGPL version 3 or any later version
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+import argparse
+import atexit
+import logging
+import os
+import subprocess
+from threading import Event, Thread
+from time import sleep, time
+
+import psutil
+import pulsectl
+from pyvirtualdisplay import Display
+
+from nextcloud.talk.recording import RECORDING_STATUS_AUDIO_AND_VIDEO, RECORDING_STATUS_AUDIO_ONLY
+from .Config import Config
+from .Participant import SeleniumHelper
+from .RecorderArgumentsBuilder import RecorderArgumentsBuilder
+from .Service import newAudioSink, processLog
+
+class ResourcesTracker:
+ """
+ Class to track the resources used by the recorder and to stop it once the
+ benchmark ends.
+
+ The ResourcesTracker runs in a different thread to the one that started it,
+ as that thread needs to block until the recorder process ends.
+ """
+
+ def __init__(self):
+ self.logger = logging.getLogger("stats")
+
+ self.cpuPercents = []
+ self.memoryInfos = []
+ self.memoryPercents = []
+
+ def start(self, pid, length, stopResourcesTrackerThread):
+ self._thread = Thread(target=self._track, args=[pid, length, stopResourcesTrackerThread], daemon=True)
+ self._thread.start()
+
+ def _track(self, pid, length, stopResourcesTrackerThread):
+ # Wait a little for the values to stabilize.
+ sleep(5)
+
+ if stopResourcesTrackerThread.is_set():
+ return
+
+ process = psutil.Process(pid)
+ # Get first percent value before the real loop, as the first time it can
+ # be 0.
+ process.cpu_percent()
+
+ startTime = time()
+ count = 0
+ while time() - startTime < length:
+ sleep(1)
+ count += 1
+
+ if stopResourcesTrackerThread.is_set():
+ return
+
+ self.logger.info(count)
+
+ cpuPercent = process.cpu_percent()
+ self.logger.info(f"CPU percent: {cpuPercent}")
+ self.cpuPercents.append(cpuPercent)
+
+ memoryInfo = process.memory_info()
+ self.logger.info(f"Memory info: {memoryInfo}")
+ self.memoryInfos.append(memoryInfo)
+
+ memoryPercent = process.memory_percent()
+ self.logger.info(f"Memory percent: {memoryPercent}")
+ self.memoryPercents.append(memoryPercent)
+
+ process.terminate()
+
+class BenchmarkService:
+ """
+ Class to set up and tear down the needed elements to benchmark the recorder.
+
+ To benchmark the recorder a virtual display server and an audio sink are
+ created. Then a video is played in the virtual display server, and its audio
+ is routed to the audio sink. This ensures that the benchmark will not
+ interfere with other processes that could be running on the machine. Then an
+ FFMPEG process to record the virtual display driver and the audio sink is
+ started, and finally a helper object to track the resources used by the
+ recorder as well as to stop it once the benchmark ends is also started.
+
+ Once the recorder process is stopped the helper elements are automatically
+ stopped too.
+ """
+
+ def __init__(self):
+ self._logger = logging.getLogger()
+
+ self._display = None
+ self._audioModuleIndex = None
+ self._playerProcess = None
+ self._recorderProcess = None
+
+ self._recorderArguments = None
+ self._averageCpuPercents = None
+ self._averageMemoryInfos = None
+ self._averageMemoryPercents = None
+
+ def __del__(self):
+ self._stopHelpers()
+
+ def run(self, args):
+ directory = os.path.dirname(args.output)
+
+ stopResourcesTrackerThread = Event()
+
+ if not os.path.exists(args.input):
+ raise Exception("Input file does not exist")
+
+ try:
+ # Ensure that PulseAudio is running.
+ # A "long" timeout is used to prevent it from exiting before the
+ # player starts.
+ subprocess.run(['pulseaudio', '--start', '--exit-idle-time=120'], check=True)
+
+ # Ensure that the directory to store the recording exists.
+ os.makedirs(directory, exist_ok=True)
+
+ self._display = Display(size=(args.width, args.height), manage_global_env=False)
+ self._display.start()
+
+ # Start new audio sink for the audio output of the player.
+ self._audioModuleIndex, audioSinkIndex = newAudioSink("nextcloud-talk-recording-benchmark")
+ audioSinkIndex = str(audioSinkIndex)
+
+ env = self._display.env()
+ env['PULSE_SINK'] = audioSinkIndex
+
+ self._logger.debug("Playing video")
+ playerArgs = ["ffplay", "-x", str(args.width), "-y", str(args.height), args.input]
+ self._playerProcess = subprocess.Popen(playerArgs, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env)
+
+ # Log player output.
+ Thread(target=processLog, args=["player", self._playerProcess.stdout, logging.DEBUG], daemon=True).start()
+
+ extensionlessFileName, extension = args.output.rsplit(".", 1)
+
+ status = RECORDING_STATUS_AUDIO_ONLY if args.audio_only else RECORDING_STATUS_AUDIO_AND_VIDEO
+
+ recorderArgumentsBuilder = RecorderArgumentsBuilder()
+ recorderArgumentsBuilder.setFfmpegOutputAudio(args.audio_args.split())
+ recorderArgumentsBuilder.setFfmpegOutputVideo(args.video_args.split())
+ recorderArgumentsBuilder.setExtension(f".{extension}")
+ self._recorderArguments = recorderArgumentsBuilder.getRecorderArguments(status, self._display.new_display_var, audioSinkIndex, args.width, args.height, extensionlessFileName)
+
+ self._fileName = self._recorderArguments[-1]
+
+ if os.path.exists(self._fileName):
+ raise Exception("File exists")
+
+ self._logger.debug("Starting recorder")
+ self._recorderProcess = subprocess.Popen(self._recorderArguments, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+
+ self._resourcesTracker = ResourcesTracker()
+ self._resourcesTracker.start(self._recorderProcess.pid, args.length, stopResourcesTrackerThread)
+
+ # Log recorder output.
+ Thread(target=processLog, args=["recorder", self._recorderProcess.stdout], daemon=True).start()
+
+ returnCode = self._recorderProcess.wait()
+
+ # recorder process will be explicitly terminated by ResourcesTracker
+ # when needed, which returns with 255; any other return code means that
+ # it ended without an expected reason.
+ if returnCode != 255:
+ raise Exception("recorder ended unexpectedly")
+ finally:
+ stopResourcesTrackerThread.set()
+ self._stopHelpers()
+
+ if len(self._resourcesTracker.cpuPercents) > 0:
+ self._averageCpuPercents = 0
+ for cpuPercents in self._resourcesTracker.cpuPercents:
+ self._averageCpuPercents += cpuPercents
+ self._averageCpuPercents /= len(self._resourcesTracker.cpuPercents)
+
+ if len(self._resourcesTracker.memoryInfos) > 0:
+ self._averageMemoryInfos = {}
+ self._averageMemoryInfos["rss"] = 0
+ self._averageMemoryInfos["vms"] = 0
+ for memoryInfos in self._resourcesTracker.memoryInfos:
+ self._averageMemoryInfos["rss"] += memoryInfos.rss
+ self._averageMemoryInfos["vms"] += memoryInfos.vms
+ self._averageMemoryInfos["rss"] /= len(self._resourcesTracker.memoryInfos)
+ self._averageMemoryInfos["vms"] /= len(self._resourcesTracker.memoryInfos)
+
+ if len(self._resourcesTracker.memoryPercents) > 0:
+ self._averageMemoryPercents = 0
+ for memoryPercents in self._resourcesTracker.memoryPercents:
+ self._averageMemoryPercents += memoryPercents
+ self._averageMemoryPercents /= len(self._resourcesTracker.memoryPercents)
+
+ def getRecorderArguments(self):
+ return self._recorderArguments
+
+ def getAverageCpuPercents(self):
+ return self._averageCpuPercents
+
+ def getAverageMemoryInfos(self):
+ return self._averageMemoryInfos
+
+ def getAverageMemoryPercents(self):
+ return self._averageMemoryPercents
+
+ def _stopHelpers(self):
+ if self._recorderProcess:
+ self._logger.debug("Stopping recorder")
+ try:
+ self._recorderProcess.terminate()
+ self._recorderProcess.wait()
+ except:
+ self._logger.exception("Error when terminating recorder")
+ finally:
+ self._recorderProcess = None
+
+ if self._playerProcess:
+ self._logger.debug("Stopping player")
+ try:
+ self._playerProcess.terminate()
+ self._playerProcess.wait()
+ except:
+ self._logger.exception("Error when terminating player")
+ finally:
+ self._playerProcess = None
+
+ if self._audioModuleIndex:
+ self._logger.debug("Unloading audio module")
+ try:
+ with pulsectl.Pulse(f"audio-module-{self._audioModuleIndex}-unloader") as pacmd:
+ pacmd.module_unload(self._audioModuleIndex)
+ except:
+ self._logger.exception("Error when unloading audio module")
+ finally:
+ self._audioModuleIndex = None
+
+ if self._display:
+ self._logger.debug("Stopping display")
+ try:
+ self._display.stop()
+ except:
+ self._logger.exception("Error when stopping display")
+ finally:
+ self._display = None
+
+benchmarkService = None
+
+def main():
+ defaultConfig = Config()
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-l", "--length", help="benchmark duration (in seconds)", default=180, type=int)
+ parser.add_argument("--width", help="output width", default=defaultConfig.getBackendVideoWidth(""), type=int)
+ parser.add_argument("--height", help="output height", default=defaultConfig.getBackendVideoHeight(""), type=int)
+ parser.add_argument("--audio-args", help="output audio arguments for ffmpeg", default=" ".join(defaultConfig.getFfmpegOutputAudio()), type=str)
+ parser.add_argument("--video-args", help="output video arguments for ffmpeg", default=" ".join(defaultConfig.getFfmpegOutputVideo()), type=str)
+ parser.add_argument("--audio-only", help="audio only recording", action="store_true")
+ parser.add_argument("-v", "--verbose", help="verbose mode", action="store_true")
+ parser.add_argument("--verbose-extra", help="extra verbose mode", action="store_true")
+ parser.add_argument("input", help="input video filename")
+ parser.add_argument("output", help="output filename")
+ args = parser.parse_args()
+
+ if args.verbose:
+ logging.basicConfig(level=logging.INFO)
+ if args.verbose_extra:
+ logging.basicConfig(level=logging.DEBUG)
+
+ global benchmarkService
+ benchmarkService = BenchmarkService()
+ benchmarkService.run(args)
+
+ output = benchmarkService.getRecorderArguments()[-1]
+ print(f"Recorder args: {' '.join(benchmarkService.getRecorderArguments())}")
+ print(f"File size: {os.stat(output).st_size}")
+ print(f"Average CPU percents: {benchmarkService.getAverageCpuPercents()}")
+ print(f"Average memory infos: {benchmarkService.getAverageMemoryInfos()}")
+ print(f"Average memory percents: {benchmarkService.getAverageMemoryPercents()}")
+
+def _stopServiceOnExit():
+ global benchmarkService
+ if benchmarkService:
+ del benchmarkService
+
+# The service should be explicitly deleted before exiting, as if it is
+# implicitly deleted while exiting the helpers may not cleanly quit.
+atexit.register(_stopServiceOnExit)
+
+if __name__ == '__main__':
+ main()