Compare commits

...

2 Commits

Author SHA1 Message Date
Keith Edmunds
a35905dee8 WIP V3: play track working 2023-11-03 15:16:27 +00:00
Keith Edmunds
bd2fa1cab0 Initialise FadeCurve in a thread
Stops a UI delay of half a second or so when marking a track 'next'
2023-11-03 09:08:06 +00:00
4 changed files with 213 additions and 122 deletions

View File

@ -1,33 +1,35 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
from typing import Any, Optional
from PyQt6.QtCore import pyqtSignal, QObject
from PyQt6.QtCore import pyqtSignal, QObject, QThread
import numpy as np
import pyqtgraph as pg # type: ignore
from config import Config
from dbconfig import scoped_session
from models import PlaylistRows
from dbconfig import scoped_session, Session
from models import PlaylistRows, Tracks
import helpers
class FadeCurve:
GraphWidget = None
def __init__(self, track):
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track.path)
audio = helpers.get_audio_segment(track_path)
if not audio:
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms = max(0, track.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1)
self.end_ms = track.silence_at
self.start_ms = max(0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1)
self.end_ms = track_silence_at
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.audio_segment.get_array_of_samples())
@ -106,7 +108,7 @@ class PlaylistTrack:
self.duration: Optional[int] = None
self.end_time: Optional[datetime] = None
self.fade_at: Optional[int] = None
self.fade_curve: Optional[FadeCurve] = None
self.fade_graph: Optional[FadeCurve] = None
self.fade_length: Optional[int] = None
self.path: Optional[str] = None
self.playlist_id: Optional[int] = None
@ -139,7 +141,6 @@ class PlaylistTrack:
self.duration = track.duration
self.end_time = None
self.fade_at = track.fade_at
self.fade_graph = FadeCurve(track) # TODO: speed this line up
self.path = track.path
self.playlist_id = plr.playlist_id
self.plr_id = plr.id
@ -153,6 +154,22 @@ class PlaylistTrack:
if track.silence_at and track.fade_at:
self.fade_length = track.silence_at - track.fade_at
# Initialise and add FadeCurve in a thread as it's slow
# Import in separate thread
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 start(self) -> None:
"""
Called when track starts playing
@ -163,16 +180,40 @@ class PlaylistTrack:
self.end_time = self.start_time + timedelta(milliseconds=self.duration)
@helpers.singleton
class CurrentTrack(PlaylistTrack):
pass
class AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
playlist_track: PlaylistTrack,
track_path: str,
track_fade_at: int,
track_silence_at: int,
):
super().__init__()
self.playlist_track = playlist_track
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self):
"""
Create fade curve and add to PlaylistTrack object
"""
self.playlist_track.fade_graph = FadeCurve(
self.track_path, self.track_fade_at, self.track_silence_at
)
self.finished.emit()
@helpers.singleton
class NextTrack(PlaylistTrack):
pass
@helpers.singleton
class PreviousTrack(PlaylistTrack):
pass
cnp_tracks = dict(
CurrentTrack = PlaylistTrack(),
NextTrack = PlaylistTrack(),
PreviousTrack = PlaylistTrack(),
)

View File

@ -52,12 +52,10 @@ from sqlalchemy import text
import stackprinter # type: ignore
from classes import (
CurrentTrack,
cnp_tracks,
FadeCurve,
MusicMusterSignals,
NextTrack,
PlaylistTrack,
PreviousTrack,
)
from config import Config
from dbconfig import (
@ -199,10 +197,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.music: music.Music = music.Music()
self.playing: bool = False
self.current_track = CurrentTrack()
self.next_track = NextTrack()
self.previous_track = PreviousTrack()
self.previous_track_position: Optional[float] = None
self.selected_plrs: Optional[List[PlaylistRows]] = None
@ -393,7 +387,7 @@ class Window(QMainWindow, Ui_MainWindow):
Clear next track
"""
self.next_track = NextTrack()
cnp_tracks['NextTrack'] = PlaylistTrack()
self.update_headers()
def clear_selection(self) -> None:
@ -714,12 +708,13 @@ class Window(QMainWindow, Ui_MainWindow):
# self.current_track.playlist_tab.play_ended()
# Reset fade graph
self.current_track.fade_graph.clear()
if cnp_tracks['CurrentTrack'].fade_graph:
cnp_tracks['CurrentTrack'].fade_graph.clear()
# Reset PlaylistTrack objects
if self.current_track.track_id:
self.previous_track = cast(PreviousTrack, self.current_track)
self.current_track = CurrentTrack()
if cnp_tracks['CurrentTrack'].track_id:
cnp_tracks['PreviousTrack'] = cnp_tracks['CurrentTrack']
cnp_tracks['CurrentTrack'] = PlaylistTrack()
# Reset clocks
self.frame_fade.setStyleSheet("")
@ -790,11 +785,11 @@ class Window(QMainWindow, Ui_MainWindow):
times a second; this function has much better resolution.
"""
if self.current_track.track_id is None or self.current_track.start_time is None:
if cnp_tracks['CurrentTrack'].track_id is None or cnp_tracks['CurrentTrack'].start_time is None:
return 0
now = datetime.now()
track_start = self.current_track.start_time
track_start = cnp_tracks['CurrentTrack'].start_time
elapsed_seconds = (now - track_start).total_seconds()
return int(elapsed_seconds * 1000)
@ -947,7 +942,7 @@ class Window(QMainWindow, Ui_MainWindow):
plrs_to_move = [
plr
for plr in playlistrows
if plr.id not in [self.current_track.plr_id, self.next_track.plr_id]
if plr.id not in [cnp_tracks['CurrentTrack'].plr_id, cnp_tracks['NextTrack'].plr_id]
]
rows_to_delete = [
@ -1143,21 +1138,24 @@ class Window(QMainWindow, Ui_MainWindow):
- If there is no next track set, return.
- If there's currently a track playing, fade it.
- Move next track to current track.
- Ensure playlist tabs are the correct colour
- Clear next track
- Restore volume if -3dB active
- Play (new) current track.
- Tell database to record it as played
- Tell playlist track is now playing
- Ensure 100% volume
- Show closing volume graph
- Notify model
- Note that track is now playing
- Disable play next controls
- Update headers
- Update clocks
"""
# If there is no next track set, return.
if not self.next_track.track_id:
if not cnp_tracks['NextTrack'].track_id:
log.debug("musicmuster.play_next(): no next track selected")
return
if not cnp_tracks['NextTrack'].path:
log.debug("musicmuster.play_next(): no path for next track")
return
with Session() as session:
# If there's currently a track playing, fade it.
@ -1166,15 +1164,10 @@ class Window(QMainWindow, Ui_MainWindow):
# Move next track to current track.
# stop_playing() above has called end_of_track_actions()
# which will have populated self.previous_track
self.current_track = cast(CurrentTrack, self.next_track)
self.clear_next()
cnp_tracks['CurrentTrack'] = cnp_tracks['NextTrack']
if not self.current_track.track_id:
log.debug("musicmuster.play_next(): no id for next track")
return
if not self.current_track.path:
log.debug("musicmuster.play_next(): no path for next track")
return
# Clear next track
self.clear_next()
# Set current track playlist_tab colour
# TODO Reimplement without reference to self.current_track.playlist_tab
@ -1187,8 +1180,12 @@ class Window(QMainWindow, Ui_MainWindow):
self.btnDrop3db.setChecked(False)
# Play (new) current track
self.current_track.start()
self.music.play(self.current_track.path, position)
if not cnp_tracks['CurrentTrack'].path:
return
cnp_tracks['CurrentTrack'].start()
self.music.play(cnp_tracks['CurrentTrack'].path, position)
# Ensure 100% volume
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
@ -1203,15 +1200,11 @@ class Window(QMainWindow, Ui_MainWindow):
sleep(0.1)
# Show closing volume graph
self.current_track.fade_graph.plot()
if cnp_tracks['CurrentTrack'].fade_graph:
cnp_tracks['CurrentTrack'].fade_graph.plot()
# Tell database to record it as played
Playdates(session, self.current_track.track_id)
# Tell playlist track is now playing
# TODO Reimplement without reference to self.current_track.playlist_tab:
# if self.current_track.playlist_tab:
# self.current_track.playlist_tab.play_started(session)
# Notify model
self.active_model().current_track_started()
# Note that track is now playing
self.playing = True
@ -1234,7 +1227,7 @@ class Window(QMainWindow, Ui_MainWindow):
track_path = self.active_tab().get_selected_row_track_path()
if not track_path:
# Otherwise get path to next track to play
track_path = self.next_track.path
track_path = cnp_tracks['NextTrack'].path
if not track_path:
self.btnPreview.setChecked(False)
return
@ -1531,7 +1524,7 @@ class Window(QMainWindow, Ui_MainWindow):
Set passed plr_id as next track to play, or clear next track if None
Actions required:
- Update self.next_track PlaylistTrack structure
- Update cnp_tracks
- Tell playlist tabs to update their 'next track' highlighting
- Update headers
- Set playlist tab colours
@ -1610,14 +1603,14 @@ class Window(QMainWindow, Ui_MainWindow):
# Update volume fade curve
if (
self.current_track.track_id
and self.current_track.fade_graph
and self.current_track.start_time
cnp_tracks['CurrentTrack'].track_id
and cnp_tracks['CurrentTrack'].fade_graph
and cnp_tracks['CurrentTrack'].start_time
):
play_time = (
datetime.now() - self.current_track.start_time
datetime.now() - cnp_tracks['CurrentTrack'].start_time
).total_seconds() * 1000
self.current_track.fade_graph.tick(play_time)
cnp_tracks['CurrentTrack'].fade_graph.tick(play_time)
def tick_500ms(self) -> None:
"""
@ -1649,22 +1642,22 @@ class Window(QMainWindow, Ui_MainWindow):
# starting play.
if (
self.music.player
and self.current_track.start_time
and cnp_tracks['CurrentTrack'].start_time
and (
self.music.player.is_playing()
or (datetime.now() - self.current_track.start_time)
or (datetime.now() - cnp_tracks['CurrentTrack'].start_time)
< timedelta(microseconds=Config.PLAY_SETTLE)
)
):
playtime = self.get_playtime()
time_to_fade = self.current_track.fade_at - playtime
time_to_silence = self.current_track.silence_at - playtime
time_to_fade = cnp_tracks['CurrentTrack'].fade_at - playtime
time_to_silence = cnp_tracks['CurrentTrack'].silence_at - playtime
# Elapsed time
self.label_elapsed_timer.setText(
helpers.ms_to_mmss(playtime)
+ " / "
+ helpers.ms_to_mmss(self.current_track.duration)
+ helpers.ms_to_mmss(cnp_tracks['CurrentTrack'].duration)
)
# Time to fade
@ -1707,25 +1700,25 @@ class Window(QMainWindow, Ui_MainWindow):
Update last / current / next track headers
"""
if self.previous_track.title and self.previous_track.artist:
if cnp_tracks['PreviousTrack'].title and cnp_tracks['PreviousTrack'].artist:
self.hdrPreviousTrack.setText(
f"{self.previous_track.title} - {self.previous_track.artist}"
f"{cnp_tracks['PreviousTrack'].title} - {cnp_tracks['PreviousTrack'].artist}"
)
else:
self.hdrPreviousTrack.setText("")
if self.current_track.title and self.current_track.artist:
if cnp_tracks['CurrentTrack'].title and cnp_tracks['CurrentTrack'].artist:
self.hdrCurrentTrack.setText(
f"{self.current_track.title.replace('&', '&&')} - "
f"{self.current_track.artist.replace('&', '&&')}"
f"{cnp_tracks['CurrentTrack'].title.replace('&', '&&')} - "
f"{cnp_tracks['CurrentTrack'].artist.replace('&', '&&')}"
)
else:
self.hdrCurrentTrack.setText("")
if self.next_track.title and self.next_track.artist:
if cnp_tracks['NextTrack'].title and cnp_tracks['NextTrack'].artist:
self.hdrNextTrack.setText(
f"{self.next_track.title.replace('&', '&&')} - "
f"{self.next_track.artist.replace('&', '&&')}"
f"{cnp_tracks['NextTrack'].title.replace('&', '&&')} - "
f"{cnp_tracks['NextTrack'].artist.replace('&', '&&')}"
)
else:
self.hdrNextTrack.setText("")

View File

@ -14,12 +14,12 @@ from PyQt6.QtGui import (
QFont,
)
from classes import CurrentTrack, MusicMusterSignals, NextTrack
from classes import cnp_tracks, MusicMusterSignals, PlaylistTrack
from config import Config
from dbconfig import scoped_session, Session
from helpers import file_is_unreadable
from log import log
from models import PlaylistRows, Tracks
from models import Playdates, PlaylistRows, Tracks
HEADER_NOTES_COLUMN = 1
@ -104,9 +104,6 @@ class PlaylistModel(QAbstractTableModel):
self.signals.add_track_to_header_signal.connect(self.add_track_to_header)
self.signals.add_track_to_playlist_signal.connect(self.add_track)
self.current_track = CurrentTrack()
self.next_track = NextTrack()
with Session() as session:
self.refresh_data(session)
@ -194,10 +191,10 @@ class PlaylistModel(QAbstractTableModel):
if file_is_unreadable(prd.path):
return QBrush(QColor(Config.COLOUR_UNREADABLE))
# Current track
if prd.plrid == self.current_track.plr_id:
if prd.plrid == cnp_tracks['CurrentTrack'].plr_id:
return QBrush(QColor(Config.COLOUR_CURRENT_PLAYLIST))
# Next track
if prd.plrid == self.next_track.plr_id:
if prd.plrid == cnp_tracks['NextTrack'].plr_id:
return QBrush(QColor(Config.COLOUR_NEXT_PLAYLIST))
# Individual cell colouring
@ -219,6 +216,66 @@ class PlaylistModel(QAbstractTableModel):
return 9
def current_track_started(self) -> None:
"""
Notification from musicmuster that the current track has just
started playing
Actions required:
- sanity check
- update display
- update track times
- update Playdates in database
- update PlaylistRows in database
- find next track
"""
# Sanity check
if not cnp_tracks['CurrentTrack'].track_id:
log.error(
"playlistmodel:current_track_started called with no current track"
)
return
if cnp_tracks['CurrentTrack'].plr_rownum is None:
log.error(
"playlistmodel:current_track_started called with no row number "
f"({self.current_track=})"
)
return
# Update display
self.invalidate_row(cnp_tracks['CurrentTrack'].plr_rownum)
# Update track times
# TODO
# Update Playdates in database
with Session() as session:
Playdates(session, cnp_tracks['CurrentTrack'].track_id)
plr = session.get(PlaylistRows, cnp_tracks['CurrentTrack'].plr_id)
if plr:
plr.played = True
# Find next track
# Get all unplayed track rows
next_row = None
unplayed_rows = [
a.plr_rownum
for a in PlaylistRows.get_unplayed_rows(session, self.playlist_id)
]
if unplayed_rows:
try:
# Find next row after current track
next_row = min(
[a for a in unplayed_rows if a > cnp_tracks['CurrentTrack'].plr_rownum]
)
except ValueError:
# Find first unplayed track
next_row = min(unplayed_rows)
if next_row is not None:
self.set_next_row(next_row)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
"""Return data to view"""
@ -523,14 +580,14 @@ class PlaylistModel(QAbstractTableModel):
return len(self.playlist_rows)
def set_next_track(self, row_number: int) -> None:
def set_next_row(self, row_number: int) -> None:
"""
Update display if necessary
Set row_number as next track
"""
# Update self.next_track PlaylistTrack structure
# Update cnp_tracks
with Session() as session:
self.next_track = NextTrack()
cnp_tracks['NextTrack'] = PlaylistTrack()
try:
plrid = self.playlist_rows[row_number].plrid
except IndexError:
@ -547,7 +604,7 @@ class PlaylistModel(QAbstractTableModel):
# Check track is readable
if file_is_unreadable(plr.track.path):
return
self.next_track.set_plr(session, plr)
cnp_tracks['NextTrack'].set_plr(session, plr)
self.signals.next_track_changed_signal.emit()
self.invalidate_row(row_number)

View File

@ -1028,7 +1028,7 @@ class PlaylistTab(QTableView):
if selected_row is None:
return
model = cast(PlaylistModel, self.model())
model.set_next_track(selected_row)
model.set_next_row(selected_row)
self.clearSelection()
# def tab_visible(self) -> None:
@ -1266,41 +1266,41 @@ class PlaylistTab(QTableView):
self._update_start_end_times(session)
def _find_next_track_row(
self, session: scoped_session, starting_row: Optional[int] = None
) -> Optional[int]:
"""
Find next track to play. If a starting row is given, start there;
otherwise, start from top. Skip rows already played.
# def _find_next_track_row(
# self, session: scoped_session, starting_row: Optional[int] = None
# ) -> Optional[int]:
# """
# Find next track to play. If a starting row is given, start there;
# otherwise, start from top. Skip rows already played.
If not found, return None.
# If not found, return None.
If found, return row number.
"""
# If found, return row number.
# """
if starting_row is None:
starting_row = 0
# if starting_row is None:
# starting_row = 0
track_rows = [
p.plr_rownum
for p in PlaylistRows.get_rows_with_tracks(session, self.playlist_id)
]
played_rows = [
p.plr_rownum
for p in PlaylistRows.get_played_rows(session, self.playlist_id)
]
for row_number in range(starting_row, self.rowCount()):
if row_number not in track_rows or row_number in played_rows:
continue
plr = self._get_row_plr(session, row_number)
if not plr:
continue
if file_is_unreadable(plr.track.path):
continue
else:
return row_number
# track_rows = [
# p.plr_rownum
# for p in PlaylistRows.get_rows_with_tracks(session, self.playlist_id)
# ]
# played_rows = [
# p.plr_rownum
# for p in PlaylistRows.get_played_rows(session, self.playlist_id)
# ]
# for row_number in range(starting_row, self.rowCount()):
# if row_number not in track_rows or row_number in played_rows:
# continue
# plr = self._get_row_plr(session, row_number)
# if not plr:
# continue
# if file_is_unreadable(plr.track.path):
# continue
# else:
# return row_number
return None
# return None
def _get_current_track_row_number(self) -> Optional[int]:
"""Return current track row or None"""