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_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

@ -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")
@ -291,7 +400,9 @@ class Window(QMainWindow, Ui_MainWindow):
current_track_playlist_id = track_sequence.current.playlist_id current_track_playlist_id = track_sequence.current.playlist_id
if current_track_playlist_id: if current_track_playlist_id:
if closing_tab_playlist_id == 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 return False
# Don't close next track playlist # Don't close next track playlist
@ -299,7 +410,9 @@ class Window(QMainWindow, Ui_MainWindow):
next_track_playlist_id = track_sequence.next.playlist_id next_track_playlist_id = track_sequence.next.playlist_id
if next_track_playlist_id: if next_track_playlist_id:
if closing_tab_playlist_id == 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 return False
# Record playlist as closed and update remaining playlist tabs # 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 # 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"""
@ -1046,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
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
else: else:
if self.preview_track_manager: return
self.preview_track_manager.stop() self.preview_manager.set_track_info(track_info)
self.preview_track_manager = None self.preview_manager.play()
self.label_intro_timer.setText("0.0") else:
self.label_intro_timer.setStyleSheet("") self.preview_manager.stop()
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"""
@ -1091,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:
""" """
@ -1480,20 +1575,20 @@ 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:
self.btnPreview.setChecked(self.preview_track_manager.is_playing())
# Update preview timer # Update preview timer
if self.preview_track_manager.is_playing(): if self.btnPreview.isChecked():
playtime = self.preview_track_manager.time_playing() 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}") 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.btnPreview.setChecked(False)
self.label_intro_timer.setText("0.0") self.label_intro_timer.setText("0.0")
self.label_intro_timer.setStyleSheet("") self.label_intro_timer.setStyleSheet("")
self.btnPreview.setChecked(False) self.btnPreview.setChecked(False)

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

@ -328,7 +328,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 +368,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 +380,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,7 +409,6 @@ 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,
@ -429,9 +428,6 @@ class _TrackManager:
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 +526,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 +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: 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"