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:
parent
973096ba3f
commit
ff76d8eb7e
196
app/classes.py
196
app/classes.py
@ -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()
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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
29
app/vlcmanager.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user