Fix resource leak

After around 1.5h of operation, we'd get messages such as:

vlcpulse audio output error: PulseAudio server connection failure: Connection terminated

Tracked down to not correctly releasing vlc player resources when
track had finished playing. Fixed now, and much simplified the fadeout
code as well.
This commit is contained in:
Keith Edmunds 2024-08-02 18:34:30 +01:00
parent 5f5bb27a5f
commit 40cad1c98f
4 changed files with 107 additions and 121 deletions

View File

@ -5,15 +5,12 @@ import ctypes
from dataclasses import dataclass, field from dataclasses import dataclass, field
import datetime as dt import datetime as dt
from enum import auto, Enum from enum import auto, Enum
import os
import platform import platform
import threading
from time import sleep from time import sleep
from typing import Any, Optional, NamedTuple from typing import Any, Optional, NamedTuple
# Third party imports # Third party imports
import numpy as np import numpy as np
from pprint import pprint as pp
import pyqtgraph as pg # type: ignore import pyqtgraph as pg # type: ignore
import vlc # type: ignore import vlc # type: ignore
@ -21,9 +18,7 @@ import vlc # type: ignore
from PyQt6.QtCore import ( from PyQt6.QtCore import (
pyqtSignal, pyqtSignal,
QObject, QObject,
QRunnable,
QThread, QThread,
QThreadPool,
) )
from pyqtgraph import PlotWidget from pyqtgraph import PlotWidget
from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
@ -39,19 +34,18 @@ from helpers import (
show_warning, show_warning,
singleton, singleton,
) )
from vlcmanager import VLCManager
lock = threading.Lock()
players: dict[int, str] = {}
# Define the VLC callback function type # Define the VLC callback function type
VLC_LOG_CB = ctypes.CFUNCTYPE( # VLC logging is very noisy so comment out unless needed
None, # VLC_LOG_CB = ctypes.CFUNCTYPE(
ctypes.c_void_p, # None,
ctypes.c_int, # ctypes.c_void_p,
ctypes.c_void_p, # ctypes.c_int,
ctypes.c_char_p, # ctypes.c_void_p,
ctypes.c_void_p, # ctypes.c_char_p,
) # ctypes.c_void_p,
# )
# Determine the correct C library for vsnprintf based on the platform # Determine the correct C library for vsnprintf based on the platform
if platform.system() == "Windows": if platform.system() == "Windows":
@ -184,11 +178,15 @@ class _FadeCurve:
# Update region position # Update region position
if self.region: if self.region:
log.debug("issue223: _FadeCurve: update region") # Next line is very noisy
# log.debug("issue223: _FadeCurve: update region")
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor]) self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QRunnable): class _FadeTrack(QThread):
finished = pyqtSignal()
def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None: def __init__(self, player: vlc.MediaPlayer, fade_seconds: int) -> None:
super().__init__() super().__init__()
self.player = player self.player = player
@ -202,7 +200,6 @@ class _FadeTrack(QRunnable):
if not self.player: if not self.player:
return return
log.info("fade starting")
# Reduce volume logarithmically # Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
db_reduction_per_step = Config.FADEOUT_DB / total_steps db_reduction_per_step = Config.FADEOUT_DB / total_steps
@ -216,13 +213,10 @@ class _FadeTrack(QRunnable):
) )
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND) sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
log.info("fade ended") self.finished.emit()
if self.player:
log.info(f"Releasing {self.player=}")
self.player.release() # Release resources vlc_instance = VLCManager().vlc_instance
del players[id(self.player)]
pp(players)
self.player = None # Clear the reference
class _Music: class _Music:
@ -231,38 +225,37 @@ class _Music:
""" """
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
self.VLC = vlc.Instance() vlc_instance.set_user_agent(name, name)
self.VLC.set_user_agent(name, name)
self.player: Optional[vlc.MediaPlayer] = None self.player: Optional[vlc.MediaPlayer] = None
self.name = name self.name = name
self.max_volume: int = Config.VLC_VOLUME_DEFAULT self.max_volume: int = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None self.start_dt: Optional[dt.datetime] = None
self.player_count: int = 0
# Set up logging # Set up logging
self._set_vlc_log() # self._set_vlc_log()
@VLC_LOG_CB # VLC logging very noisy so comment out unless needed
def log_callback(data, level, ctx, fmt, args): # @VLC_LOG_CB
try: # def log_callback(data, level, ctx, fmt, args):
# Create a ctypes string buffer to hold the formatted message # try:
buf = ctypes.create_string_buffer(1024) # # Create a ctypes string buffer to hold the formatted message
# buf = ctypes.create_string_buffer(1024)
# Use vsnprintf to format the string with the va_list # # Use vsnprintf to format the string with the va_list
libc.vsnprintf(buf, len(buf), fmt, args) # libc.vsnprintf(buf, len(buf), fmt, args)
# Decode the formatted message # # Decode the formatted message
message = buf.value.decode("utf-8", errors="replace") # message = buf.value.decode("utf-8", errors="replace")
log.debug("VLC: " + message) # log.debug("VLC: " + message)
except Exception as e: # except Exception as e:
log.error(f"Error in VLC log callback: {e}") # log.error(f"Error in VLC log callback: {e}")
def _set_vlc_log(self): # def _set_vlc_log(self):
try: # try:
vlc.libvlc_log_set(self.VLC, self.log_callback, None) # vlc.libvlc_log_set(vlc_instance, self.log_callback, None)
log.debug("VLC logging set up successfully") # log.debug("VLC logging set up successfully")
except Exception as e: # except Exception as e:
log.error(f"Failed to set up VLC logging: {e}") # log.error(f"Failed to set up VLC logging: {e}")
def adjust_by_ms(self, ms: int) -> None: def adjust_by_ms(self, ms: int) -> None:
"""Move player position by ms milliseconds""" """Move player position by ms milliseconds"""
@ -303,26 +296,10 @@ class _Music:
self.stop() self.stop()
return return
# Take a copy of current player to allow another track to be self.fader_worker = _FadeTrack(self.player, fade_seconds=fade_seconds)
# started without interfering here self.fader_worker.finished.connect(self.player.release)
with lock: self.fader_worker.start()
p = self.player self.start_dt = None
# Connect to the end-of-playback event
p.event_manager().event_attach(
vlc.EventType.MediaPlayerEndReached, self.on_playback_end
)
del players[id(self.player)]
players[id(p)] = f"From fade {self.player=}"
pp(players)
self.player = None
pool = QThreadPool.globalInstance()
if pool:
fader = _FadeTrack(p, fade_seconds=fade_seconds)
pool.start(fader)
self.start_dt = None
else:
log.error("_Music: failed to allocate QThreadPool")
def get_playtime(self) -> int: def get_playtime(self) -> int:
""" """
@ -369,11 +346,8 @@ class _Music:
""" """
if self.player: if self.player:
log.info(f"Releasing {self.player=}") self.player.release()
del players[id(self.player)] self.player = None
pp(players)
self.player.release() # Release resources
self.player = None # Clear the reference
def play( def play(
self, self,
@ -396,47 +370,38 @@ class _Music:
log.error(f"play({path}): path not readable") log.error(f"play({path}): path not readable")
return None return None
media = self.VLC.media_new_path(path) self.player = vlc.MediaPlayer(vlc_instance, path)
if media is None: if self.player is None:
log.error(f"_Music:play: failed to create media ({path=})") log.error(f"_Music:play: failed to create MediaPlayer ({path=})")
show_warning(None, "Error loading file", f"Cannot play file ({path})") show_warning(None, "Error creating MediaPlayer", f"Cannot play file ({path})")
return return
self.player = media.player_new_from_media()
log.info(f"Created {self.player=}")
players[id(self.player)] = os.path.basename(path)
pp(players)
# Connect to the end-of-playback event # Connect to the end-of-playback event
self.player.event_manager().event_attach( self.player.event_manager().event_attach(
vlc.EventType.MediaPlayerEndReached, self.on_playback_end vlc.EventType.MediaPlayerEndReached, self.on_playback_end
) )
if self.player: _ = self.player.play()
_ = self.player.play() self.set_volume(self.max_volume)
self.set_volume(self.max_volume)
self.player_count += 1
log.debug(f"_Music.play: {self.player_count=}")
if position: if position:
self.player.set_position(position) self.player.set_position(position)
self.start_dt = start_time self.start_dt = start_time
# For as-yet unknown reasons. sometimes the volume gets # For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This # reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses # only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant). # Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more # It has been known for the volume to need correcting more
# than once in the first 200mS. # than once in the first 200mS.
for _ in range(3): # Update August 2024: This no longer seems to be an issue
if self.player: # for _ in range(3):
volume = self.player.audio_get_volume() # if self.player:
if volume < Config.VLC_VOLUME_DEFAULT: # volume = self.player.audio_get_volume()
self.set_volume(Config.VLC_VOLUME_DEFAULT) # if volume < Config.VLC_VOLUME_DEFAULT:
log.error(f"Reset from {volume=}") # self.set_volume(Config.VLC_VOLUME_DEFAULT)
sleep(0.1) # log.error(f"Reset from {volume=}")
else: # sleep(0.1)
log.error("_Music:play: failed to create media player")
show_warning(None, "Media player", "Unable to create media player")
def set_position(self, position: float) -> None: def set_position(self, position: float) -> None:
""" """
@ -483,20 +448,11 @@ class _Music:
if not self.player: if not self.player:
return return
p = self.player
del players[id(self.player)]
players[id(p)] = f"From stop {self.player=}"
pp(players)
self.player = None
self.start_dt = None self.start_dt = None
if self.player.is_playing():
with lock: self.player.stop()
p.stop() self.player.release()
p.release() self.player = None
del players[id(p)]
pp(players)
log.info(f"_Music.stop: Releasing player {p=}, {self.player_count=}")
p = None
@singleton @singleton
@ -613,6 +569,8 @@ class RowAndTrack:
self.start_time = None self.start_time = None
if self.fade_graph: if self.fade_graph:
self.fade_graph.clear() self.fade_graph.clear()
# Ensure that player is released
self.music.stop()
self.signal_end_of_track() self.signal_end_of_track()
self.end_of_track_signalled = True self.end_of_track_signalled = True
@ -739,7 +697,7 @@ class RowAndTrack:
def signal_end_of_track(self) -> None: def signal_end_of_track(self) -> None:
""" """
Send end of track signal unless we are a preview player Send end of track signal
""" """
self.signals.track_ended_signal.emit() self.signals.track_ended_signal.emit()

View File

@ -670,7 +670,6 @@ class Window(QMainWindow, Ui_MainWindow):
# Reset clocks # Reset clocks
self.frame_fade.setStyleSheet("") self.frame_fade.setStyleSheet("")
self.frame_silent.setStyleSheet("") self.frame_silent.setStyleSheet("")
self.label_elapsed_timer.setText("00:00 / 00:00")
self.label_fade_timer.setText("00:00") self.label_fade_timer.setText("00:00")
self.label_silent_timer.setText("00:00") self.label_silent_timer.setText("00:00")

View File

@ -561,7 +561,7 @@ class PlaylistModel(QAbstractTableModel):
for a in self.playlist_rows.values() for a in self.playlist_rows.values()
if not a.played and a.track_id is not None if not a.played and a.track_id is not None
] ]
log.debug(f"{self}: get_unplayed_rows() returned: {result=}") # log.debug(f"{self}: get_unplayed_rows() returned: {result=}")
return result return result
def headerData( def headerData(

29
app/vlcmanager.py Normal file
View File

@ -0,0 +1,29 @@
# Standard library imports
# PyQt imports
# Third party imports
import vlc # type: ignore
# App imports
class VLCManager:
"""
Singleton class to ensure we only ever have one vlc Instance
"""
__instance = None
def __init__(self) -> None:
if VLCManager.__instance is None:
self.vlc_instance = vlc.Instance()
VLCManager.__instance = self
else:
raise Exception("Attempted to create a second VLCManager instance")
@staticmethod
def get_instance() -> vlc.Instance:
if VLCManager.__instance is None:
VLCManager()
return VLCManager.__instance