Compare commits

...

16 Commits

Author SHA1 Message Date
Keith Edmunds
8956642e05 Remove unused import. 2026-01-04 13:58:57 +00:00
Keith Edmunds
791fad680a Clean up old error logging; log excessive jitter. 2026-01-04 13:54:14 +00:00
Keith Edmunds
8c60d6a03d Jitter monitor phase 0 2026-01-03 21:37:50 +00:00
Keith Edmunds
7391b4e61c Start header in column zero 2026-01-03 18:44:36 +00:00
Keith Edmunds
266be281d0 Don't run update_track_times twice on starting track 2026-01-02 14:34:28 +00:00
Keith Edmunds
ac487a5fa5 Make vlc volume default 100 2026-01-01 17:18:58 +00:00
Keith Edmunds
7d1bb0d3f7 Escape double quotes in filename 2025-12-14 15:58:35 +00:00
Keith Edmunds
e8d9cf8f00 Ensure track marked as played in playlist
Fixes #295
2025-09-26 00:38:44 +01:00
Keith Edmunds
7e7ae7dddf Tidy config 2025-09-25 15:07:30 +01:00
Keith Edmunds
25cb444335 Set default volumen to 80% 2025-09-25 14:53:30 +01:00
Keith Edmunds
fa14fc7c52 Fixup reloading track from Audacity 2025-08-23 12:18:06 +01:00
Keith Edmunds
6e51e65ba8 Add .gitattibutes to define python diffing 2025-08-23 12:17:42 +01:00
Keith Edmunds
19b1bf3fde Fix type hint error 2025-08-17 18:42:01 +01:00
Keith Edmunds
316b4708c6 Check import filetype; install black 2025-08-17 18:26:53 +01:00
Keith Edmunds
4fd9a0381f Hide tracks, not sections 2025-08-16 15:10:45 +01:00
Keith Edmunds
88cce738d7 Move AudacityController management from playlists to musicmuster
Fixes: #292
2025-08-16 15:10:15 +01:00
13 changed files with 577 additions and 491 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.py diff=python

View File

@ -69,7 +69,8 @@ class AudacityController:
select_status = self._send_command("SelectAll")
log.debug(f"{select_status=}")
export_cmd = f'Export2: Filename="{self.path}" NumChannels=2'
# Escape any double quotes in filename
export_cmd = f'Export2: Filename="{self.path.replace('"', '\\"')}" NumChannels=2'
export_status = self._send_command(export_cmd)
log.debug(f"{export_status=}")
self.path = ""

View File

@ -132,14 +132,14 @@ class Config(object):
TRACK_TIME_FORMAT = "%H:%M:%S"
VLC_MAIN_PLAYER_NAME = "MusicMuster Main Player"
VLC_PREVIEW_PLAYER_NAME = "MusicMuster Preview Player"
VLC_VOLUME_DEFAULT = 75
VLC_VOLUME_DROP3db = 65
VLC_VOLUME_DEFAULT = 100
VLC_VOLUME_DROP3db = 70
WARNING_MS_BEFORE_FADE = 5500
WARNING_MS_BEFORE_SILENCE = 5500
WEB_ZOOM_FACTOR = 1.2
WIKIPEDIA_ON_NEXT = False
# These rely on earlier definitions
HIDE_PLAYED_MODE = HIDE_PLAYED_MODE_SECTIONS
HIDE_PLAYED_MODE = HIDE_PLAYED_MODE_TRACKS
IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
REPLACE_FILES_DEFAULT_DESTINATION = os.path.dirname(REPLACE_FILES_DEFAULT_SOURCE)

View File

@ -35,6 +35,7 @@ from classes import (
)
from config import Config
from helpers import (
audio_file_extension,
file_is_unreadable,
get_tags,
show_OK,
@ -104,9 +105,7 @@ class FileImporter:
# variable or an instance variable are effectively the same thing.
workers: dict[str, DoTrackImport] = {}
def __init__(
self, base_model: PlaylistModel, row_number: int = None
) -> None:
def __init__(self, base_model: PlaylistModel, row_number: int) -> None:
"""
Initialise the FileImporter singleton instance.
"""
@ -202,6 +201,7 @@ class FileImporter:
self.sort_track_match_data(tfd)
selection = self.get_user_choices(tfd)
if self.process_selection(tfd, selection):
if self.extension_check(tfd):
if self.validate_file_data(tfd):
tfd.import_this_file = True
@ -237,6 +237,26 @@ class FileImporter:
return True
def extension_check(self, tfd: TrackFileData) -> bool:
"""
If we are replacing an existing file, check that the correct file
extension of the replacement file matches the existing file
extension and return True if it does (or if there is no exsting
file), else False.
"""
if not tfd.file_path_to_remove:
return True
if tfd.file_path_to_remove.endswith(audio_file_extension(tfd.source_path)):
return True
tfd.error = (
f"Existing file ({tfd.file_path_to_remove}) has a different "
f"extension to replacement file ({tfd.source_path})"
)
return False
def find_similar(self, tfd: TrackFileData) -> None:
"""
- Search title in existing tracks
@ -445,7 +465,8 @@ class FileImporter:
if tfd.track_id == 0 and tfd.destination_path != tfd.file_path_to_remove:
while os.path.exists(tfd.destination_path):
msg = (
f"New import requested but default destination path ({tfd.destination_path})"
"New import requested but default destination path"
f" ({tfd.destination_path})"
" already exists. Click OK and choose where to save this track"
)
show_OK(title="Desintation path exists", msg=msg, parent=None)
@ -627,7 +648,8 @@ class DoTrackImport(QThread):
f"Importing {os.path.basename(self.import_file_path)}", 5000
)
# Get audio metadata in this thread rather than calling function to save interactive time
# Get audio metadata in this thread rather than calling
# function to save interactive time
self.audio_metadata = helpers.get_audio_metadata(self.import_file_path)
# Remove old file if so requested

View File

@ -13,6 +13,7 @@ import tempfile
from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget
# Third party imports
import filetype
from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore
from pydub import AudioSegment, effects
@ -50,6 +51,14 @@ def ask_yes_no(
return button == QMessageBox.StandardButton.Yes
def audio_file_extension(fpath: str) -> str | None:
"""
Return the correct extension for this type of file.
"""
return filetype.guess(fpath).extension
def fade_point(
audio_segment: AudioSegment,
fade_threshold: float = 0.0,
@ -72,7 +81,7 @@ def fade_point(
fade_threshold = max_vol
while (
audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
audio_segment[trim_ms: trim_ms + chunk_size].dBFS < fade_threshold
and trim_ms > 0
): # noqa W503
trim_ms -= chunk_size
@ -94,6 +103,9 @@ def file_is_unreadable(path: Optional[str]) -> bool:
def get_audio_segment(path: str) -> Optional[AudioSegment]:
if not path.endswith(audio_file_extension(path)):
return None
try:
if path.endswith(".mp3"):
return AudioSegment.from_mp3(path)

56
app/jittermonitor.py Normal file
View File

@ -0,0 +1,56 @@
from PyQt6.QtCore import QObject, QTimer, QElapsedTimer
import logging
import time
from config import Config
class EventLoopJitterMonitor(QObject):
def __init__(
self,
parent=None,
interval_ms: int = 20,
jitter_threshold_ms: int = 100,
log_cooldown_s: float = 1.0,
):
super().__init__(parent)
self._interval = interval_ms
self._jitter_threshold = jitter_threshold_ms
self._log_cooldown_s = log_cooldown_s
self._timer = QTimer(self)
self._timer.setInterval(self._interval)
self._timer.timeout.connect(self._on_timeout)
self._elapsed = QElapsedTimer()
self._elapsed.start()
self._last = self._elapsed.elapsed()
# child logger: e.g. "musicmuster.jitter"
self._log = logging.getLogger(f"{Config.LOG_NAME}.jitter")
self._last_log_time = 0.0
def start(self) -> None:
self._timer.start()
def _on_timeout(self) -> None:
now_ms = self._elapsed.elapsed()
delta = now_ms - self._last
self._last = now_ms
if delta > (self._interval + self._jitter_threshold):
self._log_jitter(now_ms, delta)
def _log_jitter(self, now_ms: int, gap_ms: int) -> None:
now = time.monotonic()
# simple rate limit: only one log every log_cooldown_s
if now - self._last_log_time < self._log_cooldown_s:
return
self._last_log_time = now
self._log.warning(
"Event loop gap detected: t=%d ms, gap=%d ms (interval=%d ms)",
now_ms,
gap_ms,
self._interval,
)

View File

@ -23,8 +23,8 @@ filters:
# - function-name-1
# - function-name-2
musicmuster:
- update_clocks
- play_next
jittermonitor: []
handlers:
stderr:

View File

@ -30,6 +30,7 @@ from log import log
from models import PlaylistRows
from vlcmanager import VLCManager
# Define the VLC callback function type
# import ctypes
# import platform
@ -353,21 +354,6 @@ class _Music:
self.player.set_position(position)
self.start_dt = start_time
# 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
# Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more
# than once in the first 200mS.
# Update August 2024: This no longer seems to be an issue
# for _ in range(3):
# if self.player:
# volume = self.player.audio_get_volume()
# if volume < Config.VLC_VOLUME_DEFAULT:
# self.set_volume(Config.VLC_VOLUME_DEFAULT)
# log.error(f"Reset from {volume=}")
# sleep(0.1)
def set_position(self, position: float) -> None:
"""
Set player position
@ -391,17 +377,6 @@ class _Music:
volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume)
# Ensure volume correct
# 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
# Pipewire for sound (which may be irrelevant).
for _ in range(3):
current_volume = self.player.audio_get_volume()
if current_volume < volume:
self.player.audio_set_volume(volume)
log.debug(f"Reset from {volume=}")
sleep(0.1)
def stop(self) -> None:
"""Immediately stop playing"""

View File

@ -63,6 +63,7 @@ from sqlalchemy.orm.session import Session
import stackprinter # type: ignore
# App imports
from audacity_controller import AudacityController
from classes import (
ApplicationError,
Filter,
@ -72,8 +73,8 @@ from classes import (
from config import Config
from dialogs import TrackSelectDialog
from file_importer import FileImporter
from helpers import ask_yes_no, file_is_unreadable, get_name
from log import log, log_call
from helpers import ask_yes_no, file_is_unreadable, get_name, show_warning
from models import db, Playdates, PlaylistRows, Playlists, Queries, Settings, Tracks
from music_manager import RowAndTrack, track_sequence
from playlistmodel import PlaylistModel, PlaylistProxyModel
@ -89,6 +90,7 @@ from ui.main_window_footer_ui import Ui_FooterSection # type: ignore
from utilities import check_db, update_bitrates
import helpers
from jittermonitor import EventLoopJitterMonitor
class Current:
base_model: PlaylistModel
@ -1205,9 +1207,26 @@ class Window(QMainWindow):
self.action_quicklog = QShortcut(QKeySequence("Ctrl+L"), self)
self.action_quicklog.activated.connect(self.quicklog)
# Jitter monitor - log delays in main event loop
self.jitter_monitor = EventLoopJitterMonitor(
parent=self,
interval_ms=20,
jitter_threshold_ms=100, # only care about >~100 ms
log_cooldown_s=1.0, # at most 1 warning per second
)
self.jitter_monitor.start()
self.load_last_playlists()
self.stop_autoplay = False
# Set up for Audacity
try:
self.ac: Optional[AudacityController] = AudacityController()
except ApplicationError as e:
self.ac = None
show_warning(self, "Audacity error", str(e))
# # # # # # # # # # Overrides # # # # # # # # # #
def closeEvent(self, event: Optional[QCloseEvent]) -> None:
@ -2198,14 +2217,6 @@ class Window(QMainWindow):
if self.return_pressed_in_error():
return
# Issue #223 concerns a very short pause (maybe 0.1s) sometimes
# when starting to play at track. Resolution appears to be to
# disable timer10 for a short time. Timer is re-enabled in
# update_clocks.
self.timer10.stop()
log.debug("issue223: play_next: 10ms timer disabled")
# If there's currently a track playing, fade it.
if track_sequence.current:
track_sequence.current.fade()
@ -2717,8 +2728,7 @@ class Window(QMainWindow):
# WARNING_MS_BEFORE_FADE milliseconds before fade starts, set
# warning colour on time to silence box and enable play
# controls. This is also a good time to re-enable the 10ms
# timer (see play_next() and issue #223).
# controls.
elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE:
self.footer_section.frame_fade.setStyleSheet(
@ -2726,11 +2736,6 @@ class Window(QMainWindow):
)
self.catch_return_key = False
self.show_status_message("Play controls: Enabled", 0)
# Re-enable 10ms timer (see above)
log.debug(f"issue287: {self.timer10.isActive()=}")
if not self.timer10.isActive():
self.timer10.start(10)
log.debug("issue223: update_clocks: 10ms timer enabled")
else:
self.footer_section.frame_silent.setStyleSheet("")

View File

@ -51,7 +51,7 @@ from models import db, NoteColours, Playdates, PlaylistRows, Tracks
from music_manager import RowAndTrack, track_sequence
HEADER_NOTES_COLUMN = 1
HEADER_NOTES_COLUMN = 0
scene_change_re = re.compile(r"SetScene=\[([^[\]]*)\]")
@ -288,7 +288,6 @@ class PlaylistModel(QAbstractTableModel):
# Update Playdates in database
log.debug(f"{self}: update playdates {track_id=}")
Playdates(session, track_id)
session.commit()
# Mark track as played in playlist
log.debug(f"{self}: Mark track as played")
@ -298,8 +297,10 @@ class PlaylistModel(QAbstractTableModel):
self.refresh_row(session, plr.row_number)
else:
log.error(
f"{self}: Can't retrieve plr, {track_sequence.current.playlistrow_id=}"
f"{self}: Can't retrieve plr, "
f"{track_sequence.current.playlistrow_id=}"
)
session.commit()
# Update colour and times for current row
# only invalidate required roles
@ -313,9 +314,6 @@ class PlaylistModel(QAbstractTableModel):
# only invalidate required roles
self.invalidate_row(track_sequence.previous.row_number, roles)
# Update all other track times
self.update_track_times()
# Find next track
next_row = None
unplayed_rows = [
@ -331,6 +329,10 @@ class PlaylistModel(QAbstractTableModel):
next_row = min(unplayed_rows)
if next_row is not None:
self.set_next_row(next_row)
else:
# set_next_row() calls update_track_times(); else we call it
self.update_track_times()
def data(
self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole
@ -415,7 +417,7 @@ class PlaylistModel(QAbstractTableModel):
if column == HEADER_NOTES_COLUMN:
column_span = 1
if header_row:
column_span = self.columnCount() - 1
column_span = self.columnCount() - HEADER_NOTES_COLUMN
self.signals.span_cells_signal.emit(
self.playlist_id, row, HEADER_NOTES_COLUMN, 1, column_span
)
@ -532,7 +534,7 @@ class PlaylistModel(QAbstractTableModel):
Col.ARTIST.value,
Col.NOTE.value,
Col.INTRO.value,
]:
] or self.is_header_row(index.row()) and index.column() == HEADER_NOTES_COLUMN:
return default | Qt.ItemFlag.ItemIsEditable
return default
@ -763,8 +765,6 @@ class PlaylistModel(QAbstractTableModel):
Signal to view to refresh invalidated row
"""
log.debug(f"issue285: {self}: invalidate_row({modified_row=})")
self.dataChanged.emit(
self.index(modified_row, 0),
self.index(modified_row, self.columnCount() - 1),
@ -776,8 +776,6 @@ class PlaylistModel(QAbstractTableModel):
Signal to view to refresh invlidated rows
"""
log.debug(f"issue285: {self}: invalidate_rows({modified_rows=})")
for modified_row in modified_rows:
# only invalidate required roles
self.invalidate_row(modified_row, roles)
@ -1199,8 +1197,6 @@ class PlaylistModel(QAbstractTableModel):
looking up the playlistrow_id and retrieving the row number from the database.
"""
log.debug(f"issue285: {self}: reset_track_sequence_row_numbers()")
# Check the track_sequence.next, current and previous plrs and
# update the row number
with db.Session() as session:
@ -1665,8 +1661,6 @@ class PlaylistModel(QAbstractTableModel):
Update track start/end times in self.playlist_rows
"""
log.debug(f"issue285: {self}: update_track_times()")
next_start_time: Optional[dt.datetime] = None
update_rows: list[int] = []
row_count = len(self.playlist_rows)

View File

@ -34,7 +34,6 @@ from PyQt6.QtWidgets import (
# import line_profiler
# App imports
from audacity_controller import AudacityController
from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo
from config import Config
from dialogs import TrackSelectDialog
@ -308,13 +307,6 @@ class PlaylistTab(QTableView):
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# Set up for Audacity
try:
self.ac: Optional[AudacityController] = AudacityController()
except ApplicationError as e:
self.ac = None
show_warning(self.musicmuster, "Audacity error", str(e))
# Load model, set column widths
self.setModel(model)
self._set_column_widths()
@ -546,8 +538,8 @@ class PlaylistTab(QTableView):
track_path = base_model.get_row_info(model_row_number).path
# Open/import in/from Audacity
if track_row and not this_is_current_row:
if self.ac and track_path == self.ac.path:
if track_row and not this_is_current_row and self.musicmuster.ac:
if track_path == self.musicmuster.ac.path:
# This track was opened in Audacity
self._add_context_menu(
"Update from Audacity",
@ -657,8 +649,8 @@ class PlaylistTab(QTableView):
that we have an edit open.
"""
if self.ac:
self.ac.path = None
if self.musicmuster.ac:
self.musicmuster.ac.path = None
def clear_selection(self) -> None:
"""Unselect all tracks and reset drag mode"""
@ -860,10 +852,10 @@ class PlaylistTab(QTableView):
Import current Audacity track to passed row
"""
if not self.ac:
if not self.musicmuster.ac:
return
try:
self.ac.export()
self.musicmuster.ac.export()
self._rescan(row_number)
except ApplicationError as e:
show_warning(self.musicmuster, "Audacity error", str(e))
@ -920,15 +912,16 @@ class PlaylistTab(QTableView):
Open track in passed row in Audacity
"""
if not self.musicmuster.ac:
return
path = self.get_base_model().get_row_track_path(row_number)
if not path:
log.error(f"_open_in_audacity: can't get path for {row_number=}")
return
try:
if not self.ac:
self.ac = AudacityController()
self.ac.open(path)
self.musicmuster.ac.open(path)
except ApplicationError as e:
show_warning(self.musicmuster, "Audacity error", str(e))

View File

@ -33,6 +33,9 @@ dependencies = [
"types-pyyaml>=6.0.12.20241230",
"dogpile-cache>=1.3.4",
"pdbpp>=0.10.3",
"filetype>=1.2.0",
"black>=25.1.0",
"slugify>=0.0.1",
]
[dependency-groups]
@ -63,6 +66,9 @@ python_version = 3.11
warn_unused_configs = true
disallow_incomplete_defs = true
[tool.pylsp.plugins.pycodestyle]
maxLineLength = 88
[tool.pytest.ini_options]
addopts = "--exitfirst --showlocals --capture=no"
pythonpath = [".", "app"]

781
uv.lock

File diff suppressed because it is too large Load Diff