Compare commits

..

8 Commits

Author SHA1 Message Date
Keith Edmunds
f2867deb2f mypy linting 2024-07-03 18:03:41 +01:00
Keith Edmunds
553376a99e Preview with pygame working 2024-07-03 17:55:09 +01:00
Keith Edmunds
e3d7ae8e0f WIP: preview forward/back working 2024-07-03 16:11:13 +01:00
Keith Edmunds
9656bac49f WIP: preview via pygame working 2024-07-03 15:41:14 +01:00
Keith Edmunds
a971298982 WIP: remove some references to preview track manager 2024-07-03 14:01:34 +01:00
Keith Edmunds
4fe6e9186c Merge branch 'sounddevice' into dev 2024-07-03 13:50:46 +01:00
Keith Edmunds
8bc41f2fcd Fix error message 2024-07-03 13:50:40 +01:00
Keith Edmunds
92eb3fc953 Fix inability to close playlists 2024-07-03 12:52:46 +01:00
9 changed files with 290 additions and 153 deletions

2
.envrc
View File

@ -4,6 +4,8 @@ export MAIL_PORT=587
export MAIL_SERVER="smtp.fastmail.com" export MAIL_SERVER="smtp.fastmail.com"
export MAIL_USERNAME="kae@midnighthax.com" export MAIL_USERNAME="kae@midnighthax.com"
export MAIL_USE_TLS=True export MAIL_USE_TLS=True
export PYGAME_HIDE_SUPPORT_PROMPT=1
branch=$(git branch --show-current) branch=$(git branch --show-current)
# Always treat running from /home/kae/mm as production # Always treat running from /home/kae/mm as production

View File

@ -1,7 +1,7 @@
# Standard library imports # Standard library imports
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import auto, Enum from enum import auto, Enum
from typing import Any, Optional from typing import Any, Optional, NamedTuple
# PyQt imports # PyQt imports
from PyQt6.QtCore import pyqtSignal, QObject from PyQt6.QtCore import pyqtSignal, QObject
@ -65,3 +65,8 @@ class TrackFileData:
obsolete_path: Optional[str] = None obsolete_path: Optional[str] = None
tags: dict[str, Any] = field(default_factory=dict) tags: dict[str, Any] = field(default_factory=dict)
audio_metadata: dict[str, str | int | float] = field(default_factory=dict) audio_metadata: dict[str, str | int | float] = field(default_factory=dict)
class TrackInfo(NamedTuple):
track_id: int
row_number: int

View File

@ -386,12 +386,12 @@ class TrackSelectDialog(QDialog):
event.accept() event.accept()
def keyPressEvent(self, event: QKeyEvent) -> None: def keyPressEvent(self, event: QKeyEvent | None) -> None:
""" """
Clear selection on ESC if there is one Clear selection on ESC if there is one
""" """
if event.key() == Qt.Key.Key_Escape: if event and event.key() == Qt.Key.Key_Escape:
if self.ui.matchList.selectedItems(): if self.ui.matchList.selectedItems():
self.ui.matchList.clearSelection() self.ui.matchList.clearSelection()
return return

View File

@ -305,6 +305,7 @@ def normalise_track(path: str) -> None:
os.chown(path, stats.st_uid, stats.st_gid) os.chown(path, stats.st_uid, stats.st_gid)
os.chmod(path, stats.st_mode) os.chmod(path, stats.st_mode)
# Copy tags # Copy tags
tag_handler: type[FLAC | MP3]
if ftype == "flac": if ftype == "flac":
tag_handler = FLAC tag_handler = FLAC
elif ftype == "mp3": elif ftype == "mp3":

View File

@ -39,6 +39,7 @@ from PyQt6.QtWidgets import (
# Third party imports # Third party imports
import pipeclient import pipeclient
from pygame import mixer
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
import stackprinter # type: ignore import stackprinter # type: ignore
@ -47,16 +48,17 @@ import stackprinter # type: ignore
from classes import ( from classes import (
MusicMusterSignals, MusicMusterSignals,
TrackFileData, TrackFileData,
TrackInfo,
) )
from config import Config from config import Config
from dialogs import TrackSelectDialog, ReplaceFilesDialog from dialogs import TrackSelectDialog, ReplaceFilesDialog
from helpers import file_is_unreadable
from log import log from log import log
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab from playlists import PlaylistTab
from trackmanager import ( from trackmanager import (
MainTrackManager, MainTrackManager,
PreviewTrackManager,
track_sequence, track_sequence,
) )
from ui import icons_rc # noqa F401 from ui import icons_rc # noqa F401
@ -143,6 +145,114 @@ class ImportTrack(QObject):
self.import_finished.emit() self.import_finished.emit()
class PreviewManager:
"""
Manage track preview player
"""
def __init__(self) -> None:
mixer.init()
self.intro: Optional[int] = None
self.path: str = ""
self.row_number: Optional[int] = None
self.track_id: int = 0
self.start_time: Optional[dt.datetime] = None
def back(self, ms: int) -> None:
"""
Move play position back by 'ms' milliseconds
"""
position = max(0, (self.get_playtime() - ms)) / 1000
mixer.music.set_pos(position)
self.start_time = dt.datetime.now() - dt.timedelta(seconds=position)
def forward(self, ms: int) -> None:
"""
Move play position forward by 'ms' milliseconds
"""
position = (self.get_playtime() + ms) / 1000
mixer.music.set_pos(position)
self.start_time = dt.datetime.now() - dt.timedelta(seconds=position)
def get_playtime(self) -> int:
"""
Return time since track started in milliseconds, 0 if not playing
"""
if not mixer.music.get_busy():
return 0
if not self.start_time:
return 0
return int((dt.datetime.now() - self.start_time).total_seconds() * 1000)
def is_playing(self) -> bool:
return mixer.music.get_busy()
def move_to_intro_end(self) -> None:
"""
Move play position to 'buffer' milliseconds before end of intro.
If no intro defined, do nothing.
"""
if self.intro is None:
return
position = max(0, self.intro - Config.PREVIEW_END_BUFFER_MS) / 1000
mixer.music.set_pos(position)
self.start_time = dt.datetime.now() - dt.timedelta(seconds=position)
def play(self) -> None:
mixer.music.play()
self.start_time = dt.datetime.now()
def restart(self) -> None:
"""
Restart player from beginning
"""
if not mixer.music.get_busy():
return
mixer.music.set_pos(0)
self.start_time = dt.datetime.now()
def set_intro(self, ms: int) -> None:
"""
Set intro time
"""
self.intro = ms
def set_track_info(self, info: TrackInfo) -> None:
self.track_id = info.track_id
self.row_number = info.row_number
with db.Session() as session:
track = session.get(Tracks, self.track_id)
if not track:
raise ValueError(f"PreviewManager: unable to retreive track {self.track_id=}")
self.intro = track.intro
self.path = track.path
# Check file readable
if file_is_unreadable(self.path):
raise ValueError(f"PreviewManager.__init__: {track.path=} unreadable")
mixer.music.load(self.path)
def stop(self) -> None:
mixer.music.stop()
self.path = ""
self.row_number = None
self.track_id = 0
self.start_time = None
class Window(QMainWindow, Ui_MainWindow): class Window(QMainWindow, Ui_MainWindow):
def __init__(self, parent=None, *args, **kwargs) -> None: def __init__(self, parent=None, *args, **kwargs) -> None:
super().__init__(parent) super().__init__(parent)
@ -153,8 +263,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.timer500: QTimer = QTimer() self.timer500: QTimer = QTimer()
self.timer1000: QTimer = QTimer() self.timer1000: QTimer = QTimer()
self.preview_track_manager: Optional[PreviewTrackManager] = None
self.set_main_window_size() self.set_main_window_size()
self.lblSumPlaytime = QLabel("") self.lblSumPlaytime = QLabel("")
self.statusbar.addPermanentWidget(self.lblSumPlaytime) self.statusbar.addPermanentWidget(self.lblSumPlaytime)
@ -162,6 +270,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.txtSearch.setHidden(True) self.txtSearch.setHidden(True)
self.statusbar.addWidget(self.txtSearch) self.statusbar.addWidget(self.txtSearch)
self.hide_played_tracks = False self.hide_played_tracks = False
self.preview_manager = PreviewManager()
self.widgetFadeVolume.hideAxis("bottom") self.widgetFadeVolume.hideAxis("bottom")
self.widgetFadeVolume.hideAxis("left") self.widgetFadeVolume.hideAxis("left")
@ -284,16 +393,27 @@ class Window(QMainWindow, Ui_MainWindow):
Return True if tab closed else False. Return True if tab closed else False.
""" """
# Don't close current track playlist
if track_sequence.current is None:
return True
current_track_playlist_id = track_sequence.current.playlist_id
closing_tab_playlist_id = self.tabPlaylist.widget(tab_index).playlist_id closing_tab_playlist_id = self.tabPlaylist.widget(tab_index).playlist_id
if current_track_playlist_id:
if closing_tab_playlist_id == current_track_playlist_id: # Don't close current track playlist
self.show_status_message("Can't close current track playlist", 5000) if track_sequence.current is not None:
return False current_track_playlist_id = track_sequence.current.playlist_id
if current_track_playlist_id:
if closing_tab_playlist_id == current_track_playlist_id:
helpers.show_OK(
self, "Current track", "Can't close current track playlist"
)
return False
# Don't close next track playlist
if track_sequence.next is not None:
next_track_playlist_id = track_sequence.next.playlist_id
if next_track_playlist_id:
if closing_tab_playlist_id == next_track_playlist_id:
helpers.show_OK(
self, "Next track", "Can't close next track playlist"
)
return False
# Record playlist as closed and update remaining playlist tabs # Record playlist as closed and update remaining playlist tabs
with db.Session() as session: with db.Session() as session:
@ -876,12 +996,12 @@ class Window(QMainWindow, Ui_MainWindow):
# Need to ensure that the new playlist is committed to # Need to ensure that the new playlist is committed to
# the database before it is opened by the model. # the database before it is opened by the model.
session.commit() session.commit()
if playlist: if playlist:
log.error("Playlist failed to create")
playlist.mark_open() playlist.mark_open()
self.create_playlist_tab(playlist) self.create_playlist_tab(playlist)
else:
log.error("Playlist failed to create")
def open_playlist(self) -> None: def open_playlist(self) -> None:
"""Open existing playlist""" """Open existing playlist"""
@ -896,7 +1016,7 @@ class Window(QMainWindow, Ui_MainWindow):
playlist.mark_open() playlist.mark_open()
session.commit() session.commit()
self.tabPlaylist.setCurrentIndex(idx) self.tabPlaylist.setCurrentIndex(idx)
def paste_rows(self) -> None: def paste_rows(self) -> None:
""" """
@ -1039,42 +1159,27 @@ class Window(QMainWindow, Ui_MainWindow):
def preview(self) -> None: def preview(self) -> None:
""" """
Preview selected or next track. Preview selected or next track. We use a different mechanism to
normal track playing so that the user can route the output audio
differently (eg, to headphones).
""" """
if self.btnPreview.isChecked(): if self.btnPreview.isChecked():
# Get track_id for first selected track if there is one # Get track path for first selected track if there is one
track_id = None track_info = self.active_tab().get_selected_row_track_info()
row_number_and_track_id = self.active_tab().get_selected_row_and_track_id() if not track_info:
if row_number_and_track_id:
row_number, track_id = row_number_and_track_id
else:
# Otherwise get track_id to next track to play # Otherwise get track_id to next track to play
if track_sequence.next: if track_sequence.next:
track_id = track_sequence.next.track_id if track_sequence.next.path:
row_number = track_sequence.next.row_number track_info = TrackInfo(track_sequence.next.track_id,
if not track_id or row_number is None: track_sequence.next.row_number
self.btnPreview.setChecked(False) )
return else:
return
try: self.preview_manager.set_track_info(track_info)
with db.Session() as session: self.preview_manager.play()
self.preview_track_manager = PreviewTrackManager(
session=session, track_id=track_id, row_number=row_number
)
self.preview_track_manager.play()
except ValueError as e:
log.error(f"Error creating PreviewTrackManager({str(e)})")
return
else: else:
if self.preview_track_manager: self.preview_manager.stop()
self.preview_track_manager.stop()
self.preview_track_manager = None
self.label_intro_timer.setText("0.0")
self.label_intro_timer.setStyleSheet("")
self.btnPreviewMark.setEnabled(False)
self.btnPreviewArm.setChecked(False)
def preview_arm(self): def preview_arm(self):
"""Manager arm button for setting intro length""" """Manager arm button for setting intro length"""
@ -1084,45 +1189,42 @@ class Window(QMainWindow, Ui_MainWindow):
def preview_back(self) -> None: def preview_back(self) -> None:
"""Wind back preview file""" """Wind back preview file"""
if self.preview_track_manager: self.preview_manager.back(5000)
self.preview_track_manager.move_back()
def preview_end(self) -> None: def preview_end(self) -> None:
"""Advance preview file to Config.PREVIEW_END_BUFFER_MS before end of intro""" """Advance preview file to Config.PREVIEW_END_BUFFER_MS before end of intro"""
if self.preview_track_manager: if self.preview_manager:
self.preview_track_manager.move_to_intro_end() self.preview_manager.move_to_intro_end()
def preview_fwd(self) -> None: def preview_fwd(self) -> None:
"""Advance preview file""" """Advance preview file"""
if self.preview_track_manager: self.preview_manager.forward(5000)
self.preview_track_manager.move_forward()
def preview_mark(self) -> None: def preview_mark(self) -> None:
"""Set intro time""" """Set intro time"""
if self.preview_track_manager: if self.preview_manager.is_playing():
track_id = self.preview_track_manager.track_id track_id = self.preview_manager.track_id
row_number = self.preview_track_manager.row_number row_number = self.preview_manager.row_number
with db.Session() as session: with db.Session() as session:
track = session.get(Tracks, track_id) track = session.get(Tracks, track_id)
if track: if track:
# Save intro as millisends rounded to nearest 0.1 # Save intro as millisends rounded to nearest 0.1
# second because editor spinbox only resolves to 0.1 # second because editor spinbox only resolves to 0.1
# seconds # seconds
intro = round(self.preview_track_manager.time_playing() / 100) * 100 intro = round(self.preview_manager.get_playtime() / 100) * 100
track.intro = intro track.intro = intro
session.commit() session.commit()
self.preview_track_manager.intro = intro self.preview_manager.set_intro(intro)
self.active_tab().source_model.refresh_row(session, row_number) self.active_tab().source_model.refresh_row(session, row_number)
self.active_tab().source_model.invalidate_row(row_number) self.active_tab().source_model.invalidate_row(row_number)
def preview_start(self) -> None: def preview_start(self) -> None:
"""Restart preview""" """Restart preview"""
if self.preview_track_manager: self.preview_manager.restart()
self.preview_track_manager.restart()
def rename_playlist(self) -> None: def rename_playlist(self) -> None:
""" """
@ -1473,23 +1575,23 @@ class Window(QMainWindow, Ui_MainWindow):
pass pass
# Ensure preview button is reset if preview finishes playing # Ensure preview button is reset if preview finishes playing
if self.preview_track_manager: # Update preview timer
self.btnPreview.setChecked(self.preview_track_manager.is_playing()) if self.btnPreview.isChecked():
if self.preview_manager.is_playing():
# Update preview timer self.btnPreview.setChecked(True)
if self.preview_track_manager.is_playing(): playtime = self.preview_manager.get_playtime()
playtime = self.preview_track_manager.time_playing()
self.label_intro_timer.setText(f"{playtime / 1000:.1f}") self.label_intro_timer.setText(f"{playtime / 1000:.1f}")
if self.preview_track_manager.time_remaining_intro() <= 50: # if self.preview_track_manager.time_remaining_intro() <= 50:
self.label_intro_timer.setStyleSheet( # self.label_intro_timer.setStyleSheet(
f"background: {Config.COLOUR_WARNING_TIMER}" # f"background: {Config.COLOUR_WARNING_TIMER}"
) # )
else: # else:
self.label_intro_timer.setStyleSheet("") # self.label_intro_timer.setStyleSheet("")
else: else:
self.label_intro_timer.setText("0.0") self.btnPreview.setChecked(False)
self.label_intro_timer.setStyleSheet("") self.label_intro_timer.setText("0.0")
self.btnPreview.setChecked(False) self.label_intro_timer.setStyleSheet("")
self.btnPreview.setChecked(False)
def tick_1000ms(self) -> None: def tick_1000ms(self) -> None:
""" """
@ -1714,8 +1816,8 @@ if __name__ == "__main__":
msg = stackprinter.format(exc) msg = stackprinter.format(exc)
send_mail( send_mail(
Config.ERRORS_TO, ",".join(Config.ERRORS_TO),
Config.ERRORS_FROM, ",".join(Config.ERRORS_FROM),
"Exception from musicmuster", "Exception from musicmuster",
msg, msg,
) )

View File

@ -35,7 +35,7 @@ from PyQt6.QtWidgets import (
# Third party imports # Third party imports
# App imports # App imports
from classes import Col, MusicMusterSignals from classes import Col, MusicMusterSignals, TrackInfo
from config import Config from config import Config
from dialogs import TrackSelectDialog from dialogs import TrackSelectDialog
from helpers import ( from helpers import (
@ -644,42 +644,26 @@ class PlaylistTab(QTableView):
self.source_model.delete_rows(self.selected_model_row_numbers()) self.source_model.delete_rows(self.selected_model_row_numbers())
self.clear_selection() self.clear_selection()
def get_selected_row_and_track_id(self) -> Optional[tuple[int, int]]: def get_selected_row_track_info(self) -> Optional[TrackInfo]:
""" """
Return the (row_number, track_id) of the selected row. If no row selected or selected Return the track_path, track_id and row number of the selected
row does not have a track, return None. row. If no row selected or selected row does not have a track,
return None.
""" """
row_number = self.source_model_selected_row_number() selected_row = self.get_selected_row()
if row_number is None: if selected_row is None:
result = None return None
else:
track_id = self.source_model.get_row_track_id(row_number)
if not track_id:
result = None
else:
result = (row_number, track_id)
log.debug(f"get_selected_row_and_track_id() returned: {result=}")
return result
def get_selected_row_track_path(self) -> str:
"""
Return the path of the selected row. If no row selected or selected
row does not have a track, return empty string.
"""
log.debug("get_selected_row_track_path() called")
model_row_number = self.source_model_selected_row_number() model_row_number = self.source_model_selected_row_number()
if model_row_number is None: if model_row_number is None:
result = "" return None
else: else:
result = self.source_model.get_row_track_path(model_row_number) track_id = self.source_model.get_row_track_id(model_row_number)
if not track_id:
log.debug(f"get_selected_row_track_path() returned: {result=}") return None
return result else:
return TrackInfo(track_id, selected_row)
def get_selected_row(self) -> Optional[int]: def get_selected_row(self) -> Optional[int]:
""" """

View File

@ -103,8 +103,9 @@ class _FadeCurve:
self.GraphWidget.clear() self.GraphWidget.clear()
def plot(self) -> None: def plot(self) -> None:
self.curve = self.GraphWidget.plot(self.graph_array) if self.GraphWidget:
self.curve.setPen(Config.FADE_CURVE_FOREGROUND) self.curve = self.GraphWidget.plot(self.graph_array)
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
def tick(self, play_time) -> None: def tick(self, play_time) -> None:
"""Update volume fade curve""" """Update volume fade curve"""
@ -328,7 +329,9 @@ class _Music:
if self.player: if self.player:
self.player.set_position(position) self.player.set_position(position)
def set_volume(self, volume: Optional[int] = None, set_default: bool = True) -> None: def set_volume(
self, volume: Optional[int] = None, set_default: bool = True
) -> None:
"""Set maximum volume used for player""" """Set maximum volume used for player"""
if not self.player: if not self.player:
@ -366,7 +369,6 @@ class _TrackManager:
player_name: str, player_name: str,
track_id: int, track_id: int,
row_number: int, row_number: int,
preview_player: bool = False,
) -> None: ) -> None:
""" """
Initialises data structure. Initialises data structure.
@ -379,7 +381,6 @@ class _TrackManager:
raise ValueError(f"_TrackPlayer: unable to retreived {track_id=}") raise ValueError(f"_TrackPlayer: unable to retreived {track_id=}")
self.player_name = player_name self.player_name = player_name
self.row_number = row_number self.row_number = row_number
self.preview_player = preview_player
# Check file readable # Check file readable
if file_is_unreadable(track.path): if file_is_unreadable(track.path):
@ -409,29 +410,25 @@ class _TrackManager:
self.player = _Music(name=player_name) self.player = _Music(name=player_name)
# Initialise and add FadeCurve in a thread as it's slow # Initialise and add FadeCurve in a thread as it's slow
if not self.preview_player: self.fadecurve_thread = QThread()
self.fadecurve_thread = QThread() self.worker = _AddFadeCurve(
self.worker = _AddFadeCurve( self,
self, track_path=track.path,
track_path=track.path, track_fade_at=track.fade_at,
track_fade_at=track.fade_at, track_silence_at=track.silence_at,
track_silence_at=track.silence_at, )
) self.worker.moveToThread(self.fadecurve_thread)
self.worker.moveToThread(self.fadecurve_thread) self.fadecurve_thread.started.connect(self.worker.run)
self.fadecurve_thread.started.connect(self.worker.run) self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.fadecurve_thread.quit) self.worker.finished.connect(self.worker.deleteLater)
self.worker.finished.connect(self.worker.deleteLater) self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater) self.fadecurve_thread.start()
self.fadecurve_thread.start()
def check_for_end_of_track(self) -> None: def check_for_end_of_track(self) -> None:
""" """
Check whether track has ended. If so, emit track_ended_signal Check whether track has ended. If so, emit track_ended_signal
""" """
if self.preview_player:
return
if self.start_time is None: if self.start_time is None:
return return
@ -530,9 +527,6 @@ class _TrackManager:
Send end of track signal unless we are a preview player Send end of track signal unless we are a preview player
""" """
if not self.preview_player:
self.signals.track_ended_signal.emit()
def stop(self, fade_seconds: int = 0) -> None: def stop(self, fade_seconds: int = 0) -> None:
""" """
Stop this track playing Stop this track playing
@ -641,24 +635,6 @@ class MainTrackManager(_TrackManager):
) )
class PreviewTrackManager(_TrackManager):
"""
Manage previewing tracks
"""
def __init__(self, session: db.Session, track_id: int, row_number: int) -> None:
super().__init__(
session=session,
player_name=Config.VLC_PREVIEW_PLAYER_NAME,
track_id=track_id,
row_number=row_number,
preview_player=True,
)
def __repr__(self) -> str:
return f"<PreviewTrackManager(track_id={self.track_id}>"
class TrackSequence: class TrackSequence:
next: Optional[MainTrackManager] = None next: Optional[MainTrackManager] = None
current: Optional[MainTrackManager] = None current: Optional[MainTrackManager] = None

68
poetry.lock generated
View File

@ -1253,6 +1253,72 @@ files = [
{file = "pyfzf-0.3.1.tar.gz", hash = "sha256:dd902e34cffeca9c3082f96131593dd20b4b3a9bba5b9dde1b0688e424b46bd2"}, {file = "pyfzf-0.3.1.tar.gz", hash = "sha256:dd902e34cffeca9c3082f96131593dd20b4b3a9bba5b9dde1b0688e424b46bd2"},
] ]
[[package]]
name = "pygame"
version = "2.6.0"
description = "Python Game Development"
optional = false
python-versions = ">=3.6"
files = [
{file = "pygame-2.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e5707aa9d029752495b3eddc1edff62e0e390a02f699b0f1ce77fe0b8c70ea4f"},
{file = "pygame-2.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3ed0547368733b854c0d9981c982a3cdfabfa01b477d095c57bf47f2199da44"},
{file = "pygame-2.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6050f3e95f1f16602153d616b52619c6a2041cee7040eb529f65689e9633fc3e"},
{file = "pygame-2.6.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89be55b7e9e22e0eea08af9d6cfb97aed5da780f0b3a035803437d481a16d972"},
{file = "pygame-2.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d65fb222eea1294cfc8206d9e5754d476a1673eb2783c03c4f70e0455320274"},
{file = "pygame-2.6.0-cp310-cp310-win32.whl", hash = "sha256:71eebb9803cb350298de188fb7cdd3ebf13299f78d59a71c7e81efc649aae348"},
{file = "pygame-2.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:1551852a2cd5b4139a752888f6cbeeb4a96fc0fe6e6f3f8b9d9784eb8fceab13"},
{file = "pygame-2.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f6e5e6c010b1bf429388acf4d41d7ab2f7ad8fbf241d0db822102d35c9a2eb84"},
{file = "pygame-2.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99902f4a2f6a338057200d99b5120a600c27a9f629ca012a9b0087c045508d08"},
{file = "pygame-2.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a284664978a1989c1e31a0888b2f70cfbcbafdfa3bb310e750b0d3366416225"},
{file = "pygame-2.6.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:829623cee298b3dbaa1dd9f52c3051ae82f04cad7708c8c67cb9a1a4b8fd3c0b"},
{file = "pygame-2.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6acf7949ed764487d51123f4f3606e8f76b0df167fef12ef73ef423c35fdea39"},
{file = "pygame-2.6.0-cp311-cp311-win32.whl", hash = "sha256:3f809560c99bd1fb4716610eca0cd36412528f03da1a63841a347b71d0c604ee"},
{file = "pygame-2.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6897ab87f9193510a774a3483e00debfe166f340ca159f544ef99807e2a44ec4"},
{file = "pygame-2.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b834711ebc8b9d0c2a5f9bfae4403dd277b2c61bcb689e1aa630d01a1ebcf40a"},
{file = "pygame-2.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b5ac288655e8a31a303cc286e79cc57979ed2ba19c3a14042d4b6391c1d3bed2"},
{file = "pygame-2.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d666667b7826b0a7921b8ce0a282ba5281dfa106976c1a3b24e32a0af65ad3b1"},
{file = "pygame-2.6.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd8848a37a7cee37854c7efb8d451334477c9f8ce7ac339c079e724dc1334a76"},
{file = "pygame-2.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:315e7b3c1c573984f549ac5da9778ac4709b3b4e3a4061050d94eab63fa4fe31"},
{file = "pygame-2.6.0-cp312-cp312-win32.whl", hash = "sha256:e44bde0840cc21a91c9d368846ac538d106cf0668be1a6030f48df139609d1e8"},
{file = "pygame-2.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c429824b1f881a7a5ce3b5c2014d3d182aa45a22cea33c8347a3971a5446907"},
{file = "pygame-2.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b832200bd8b6fc485e087bf3ef7ec1a21437258536413a5386088f5dcd3a9870"},
{file = "pygame-2.6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:098029d01a46ea4e30620dfb7c28a577070b456c8fc96350dde05f85c0bf51b5"},
{file = "pygame-2.6.0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a858bbdeac5ec473ec9e726c55fb8fbdc2f4aad7c55110e899883738071c7c9b"},
{file = "pygame-2.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f908762941fd99e1f66d1211d26383184f6045c45673443138b214bf48a89aa"},
{file = "pygame-2.6.0-cp36-cp36m-win32.whl", hash = "sha256:4a63daee99d050f47d6ec7fa7dbd1c6597b8f082cdd58b6918d382d2bc31262d"},
{file = "pygame-2.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:ace471b3849d68968e5427fc01166ef5afaf552a5c442fc2c28d3b7226786f55"},
{file = "pygame-2.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fea019713d0c89dfd5909225aa933010100035d1cd30e6c936e8b6f00529fb80"},
{file = "pygame-2.6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:249dbf2d51d9f0266009a380ccf0532e1a57614a1528bb2f89a802b01d61f93e"},
{file = "pygame-2.6.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb51533ee3204e8160600b0de34eaad70eb913a182c94a7777b6051e8fc52f1"},
{file = "pygame-2.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f637636a44712e94e5601ec69160a080214626471983dfb0b5b68aa0c61563d"},
{file = "pygame-2.6.0-cp37-cp37m-win32.whl", hash = "sha256:e432156b6f346f4cc6cab03ce9657600093390f4c9b10bf458716b25beebfe33"},
{file = "pygame-2.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a0194652db7874bdde7dfc69d659ca954544c012e04ae527151325bfb970f423"},
{file = "pygame-2.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eae3ee62cc172e268121d5bd9dc406a67094d33517de3a91de3323d6ae23eb02"},
{file = "pygame-2.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f6a58b0a5a8740a3c2cf6fc5366888bd4514561253437f093c12a9ab4fb3ecae"},
{file = "pygame-2.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c71da36997dc7b9b4ee973fa3a5d4a6cfb2149161b5b1c08b712d2f13a63ccfe"},
{file = "pygame-2.6.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b86771801a7fc10d9a62218f27f1d5c13341c3a27394aa25578443a9cd199830"},
{file = "pygame-2.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4928f3acf5a9ce5fbab384c21f1245304535ffd5fb167ae92a6b4d3cdb55a3b6"},
{file = "pygame-2.6.0-cp38-cp38-win32.whl", hash = "sha256:4faab2df9926c4d31215986536b112f0d76f711cf02f395805f1ff5df8fd55fc"},
{file = "pygame-2.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:afbb8d97aed93dfb116fe105603dacb68f8dab05b978a40a9e4ab1b6c1f683fd"},
{file = "pygame-2.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d11f3646b53819892f4a731e80b8589a9140343d0d4b86b826802191b241228c"},
{file = "pygame-2.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5ef92ed93c354eabff4b85e457d4d6980115004ec7ff52a19fd38b929c3b80fb"},
{file = "pygame-2.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc1795f2e36302882546faacd5a0191463c4f4ae2b90e7c334a7733aa4190d2"},
{file = "pygame-2.6.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e92294fcc85c4955fe5bc6a0404e4cc870808005dc8f359e881544e3cc214108"},
{file = "pygame-2.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0cb7bdf3ee0233a3ac02ef777c01dfe315e6d4670f1312c83b91c1ef124359a"},
{file = "pygame-2.6.0-cp39-cp39-win32.whl", hash = "sha256:ac906478ae489bb837bf6d2ae1eb9261d658aa2c34fa5b283027a04149bda81a"},
{file = "pygame-2.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:92cf12a9722f6f0bdc5520d8925a8f085cff9c054a2ea462fc409cba3781be27"},
{file = "pygame-2.6.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:a6636f452fdaddf604a060849feb84c056930b6a3c036214f607741f16aac942"},
{file = "pygame-2.6.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dc242dc15d067d10f25c5b12a1da48ca9436d8e2d72353eaf757e83612fba2f"},
{file = "pygame-2.6.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f82df23598a281c8c342d3c90be213c8fe762a26c15815511f60d0aac6e03a70"},
{file = "pygame-2.6.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2ed2539bb6bd211fc570b1169dc4a64a74ec5cd95741e62a0ab46bd18fe08e0d"},
{file = "pygame-2.6.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:904aaf29710c6b03a7e1a65b198f5467ed6525e8e60bdcc5e90ff8584c1d54ea"},
{file = "pygame-2.6.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcd28f96f0fffd28e71a98773843074597e10d7f55a098e2e5bcb2bef1bdcbf5"},
{file = "pygame-2.6.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4fad1ab33443ecd4f958dbbb67fc09fcdc7a37e26c34054e3296fb7e26ad641e"},
{file = "pygame-2.6.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e909186d4d512add39b662904f0f79b73028fbfc4fbfdaf6f9412aed4e500e9c"},
{file = "pygame-2.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79abcbf6d12fce51a955a0652ccd50b6d0a355baa27799535eaf21efb43433dd"},
{file = "pygame-2.6.0.tar.gz", hash = "sha256:722d33ae676aa8533c1f955eded966411298831346b8d51a77dad22e46ba3e35"},
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.17.2" version = "2.17.2"
@ -1970,4 +2036,4 @@ test = ["websockets"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "3b2e747f93972b78a9a35454810c99c4ec81e14fc9780e65a6a4434a97d1a713" content-hash = "4400e265162fa56d70d4ef5501896dec6f22b743414364ef99bdfef0be979785"

View File

@ -25,6 +25,7 @@ pyqtgraph = "^0.13.3"
colorlog = "^6.8.2" colorlog = "^6.8.2"
alchemical = "^1.0.2" alchemical = "^1.0.2"
obs-websocket-py = "^1.0" obs-websocket-py = "^1.0"
pygame = "^2.6.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
ipdb = "^0.13.9" ipdb = "^0.13.9"