Compare commits

..

6 Commits

Author SHA1 Message Date
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
7 changed files with 267 additions and 139 deletions

2
.envrc
View File

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

View File

@ -1,7 +1,7 @@
# Standard library imports
from dataclasses import dataclass, field
from enum import auto, Enum
from typing import Any, Optional
from typing import Any, Optional, NamedTuple
# PyQt imports
from PyQt6.QtCore import pyqtSignal, QObject
@ -65,3 +65,8 @@ class TrackFileData:
obsolete_path: Optional[str] = None
tags: dict[str, Any] = 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

@ -39,6 +39,7 @@ from PyQt6.QtWidgets import (
# Third party imports
import pipeclient
from pygame import mixer
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.session import Session
import stackprinter # type: ignore
@ -47,16 +48,17 @@ import stackprinter # type: ignore
from classes import (
MusicMusterSignals,
TrackFileData,
TrackInfo,
)
from config import Config
from dialogs import TrackSelectDialog, ReplaceFilesDialog
from helpers import file_is_unreadable
from log import log
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab
from trackmanager import (
MainTrackManager,
PreviewTrackManager,
track_sequence,
)
from ui import icons_rc # noqa F401
@ -143,6 +145,114 @@ class ImportTrack(QObject):
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):
def __init__(self, parent=None, *args, **kwargs) -> None:
super().__init__(parent)
@ -153,8 +263,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.timer500: QTimer = QTimer()
self.timer1000: QTimer = QTimer()
self.preview_track_manager: Optional[PreviewTrackManager] = None
self.set_main_window_size()
self.lblSumPlaytime = QLabel("")
self.statusbar.addPermanentWidget(self.lblSumPlaytime)
@ -162,6 +270,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.txtSearch.setHidden(True)
self.statusbar.addWidget(self.txtSearch)
self.hide_played_tracks = False
self.preview_manager = PreviewManager()
self.widgetFadeVolume.hideAxis("bottom")
self.widgetFadeVolume.hideAxis("left")
@ -291,7 +400,9 @@ class Window(QMainWindow, Ui_MainWindow):
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")
helpers.show_OK(
self, "Current track", "Can't close current track playlist"
)
return False
# Don't close next track playlist
@ -299,7 +410,9 @@ class Window(QMainWindow, Ui_MainWindow):
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")
helpers.show_OK(
self, "Next track", "Can't close next track playlist"
)
return False
# Record playlist as closed and update remaining playlist tabs
@ -883,12 +996,12 @@ class Window(QMainWindow, Ui_MainWindow):
# Need to ensure that the new playlist is committed to
# the database before it is opened by the model.
session.commit()
if playlist:
log.error("Playlist failed to create")
playlist.mark_open()
self.create_playlist_tab(playlist)
else:
log.error("Playlist failed to create")
def open_playlist(self) -> None:
"""Open existing playlist"""
@ -1046,42 +1159,27 @@ class Window(QMainWindow, Ui_MainWindow):
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():
# Get track_id for first selected track if there is one
track_id = None
row_number_and_track_id = self.active_tab().get_selected_row_and_track_id()
if row_number_and_track_id:
row_number, track_id = row_number_and_track_id
else:
# Get track path for first selected track if there is one
track_info = self.active_tab().get_selected_row_track_info()
if not track_info:
# Otherwise get track_id to next track to play
if track_sequence.next:
track_id = track_sequence.next.track_id
row_number = track_sequence.next.row_number
if not track_id or row_number is None:
self.btnPreview.setChecked(False)
return
try:
with db.Session() as session:
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
if track_sequence.next.path:
track_info = TrackInfo(track_sequence.next.track_id,
track_sequence.next.row_number
)
else:
return
self.preview_manager.set_track_info(track_info)
self.preview_manager.play()
else:
if self.preview_track_manager:
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)
self.preview_manager.stop()
def preview_arm(self):
"""Manager arm button for setting intro length"""
@ -1091,45 +1189,42 @@ class Window(QMainWindow, Ui_MainWindow):
def preview_back(self) -> None:
"""Wind back preview file"""
if self.preview_track_manager:
self.preview_track_manager.move_back()
self.preview_manager.back(5000)
def preview_end(self) -> None:
"""Advance preview file to Config.PREVIEW_END_BUFFER_MS before end of intro"""
if self.preview_track_manager:
self.preview_track_manager.move_to_intro_end()
if self.preview_manager:
self.preview_manager.move_to_intro_end()
def preview_fwd(self) -> None:
"""Advance preview file"""
if self.preview_track_manager:
self.preview_track_manager.move_forward()
self.preview_manager.forward(5000)
def preview_mark(self) -> None:
"""Set intro time"""
if self.preview_track_manager:
track_id = self.preview_track_manager.track_id
row_number = self.preview_track_manager.row_number
if self.preview_manager.is_playing():
track_id = self.preview_manager.track_id
row_number = self.preview_manager.row_number
with db.Session() as session:
track = session.get(Tracks, track_id)
if track:
# Save intro as millisends rounded to nearest 0.1
# second because editor spinbox only resolves to 0.1
# seconds
intro = round(self.preview_track_manager.time_playing() / 100) * 100
intro = round(self.preview_manager.get_playtime() / 100) * 100
track.intro = intro
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.invalidate_row(row_number)
def preview_start(self) -> None:
"""Restart preview"""
if self.preview_track_manager:
self.preview_track_manager.restart()
self.preview_manager.restart()
def rename_playlist(self) -> None:
"""
@ -1480,23 +1575,23 @@ class Window(QMainWindow, Ui_MainWindow):
pass
# Ensure preview button is reset if preview finishes playing
if self.preview_track_manager:
self.btnPreview.setChecked(self.preview_track_manager.is_playing())
# Update preview timer
if self.preview_track_manager.is_playing():
playtime = self.preview_track_manager.time_playing()
# Update preview timer
if self.btnPreview.isChecked():
if self.preview_manager.is_playing():
self.btnPreview.setChecked(True)
playtime = self.preview_manager.get_playtime()
self.label_intro_timer.setText(f"{playtime / 1000:.1f}")
if self.preview_track_manager.time_remaining_intro() <= 50:
self.label_intro_timer.setStyleSheet(
f"background: {Config.COLOUR_WARNING_TIMER}"
)
else:
self.label_intro_timer.setStyleSheet("")
else:
self.label_intro_timer.setText("0.0")
self.label_intro_timer.setStyleSheet("")
self.btnPreview.setChecked(False)
# if self.preview_track_manager.time_remaining_intro() <= 50:
# self.label_intro_timer.setStyleSheet(
# f"background: {Config.COLOUR_WARNING_TIMER}"
# )
# else:
# self.label_intro_timer.setStyleSheet("")
else:
self.btnPreview.setChecked(False)
self.label_intro_timer.setText("0.0")
self.label_intro_timer.setStyleSheet("")
self.btnPreview.setChecked(False)
def tick_1000ms(self) -> None:
"""

View File

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

View File

@ -328,7 +328,9 @@ class _Music:
if self.player:
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"""
if not self.player:
@ -366,7 +368,6 @@ class _TrackManager:
player_name: str,
track_id: int,
row_number: int,
preview_player: bool = False,
) -> None:
"""
Initialises data structure.
@ -379,7 +380,6 @@ class _TrackManager:
raise ValueError(f"_TrackPlayer: unable to retreived {track_id=}")
self.player_name = player_name
self.row_number = row_number
self.preview_player = preview_player
# Check file readable
if file_is_unreadable(track.path):
@ -409,29 +409,25 @@ class _TrackManager:
self.player = _Music(name=player_name)
# Initialise and add FadeCurve in a thread as it's slow
if not self.preview_player:
self.fadecurve_thread = QThread()
self.worker = _AddFadeCurve(
self,
track_path=track.path,
track_fade_at=track.fade_at,
track_silence_at=track.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
self.fadecurve_thread = QThread()
self.worker = _AddFadeCurve(
self,
track_path=track.path,
track_fade_at=track.fade_at,
track_silence_at=track.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def check_for_end_of_track(self) -> None:
"""
Check whether track has ended. If so, emit track_ended_signal
"""
if self.preview_player:
return
if self.start_time is None:
return
@ -530,9 +526,6 @@ class _TrackManager:
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:
"""
Stop this track playing
@ -641,24 +634,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:
next: 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"},
]
[[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]]
name = "pygments"
version = "2.17.2"
@ -1970,4 +2036,4 @@ test = ["websockets"]
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "3b2e747f93972b78a9a35454810c99c4ec81e14fc9780e65a6a4434a97d1a713"
content-hash = "4400e265162fa56d70d4ef5501896dec6f22b743414364ef99bdfef0be979785"

View File

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