diff --git a/README.md b/README.md index a2e2c71..10012ff 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,9 @@ config.ini db/ - ~~delete songs from library (del key || right-click delete)~~ - ~~improve audio visualizer - logarithmic x-axis ISO octave bands - see Renoise~~ - ~~when table is focused, start typing to match against the primary sort column~~ +- ~~remember last window size~~ - playlist autoexporting - .wav, .ogg, .flac convertor -- remember last window size - playback modes (normal, repeat playlist, repeat 1 song, shuffle) - autoplay next song in all modes - allow spectrum analyzer to fall when playback stops or song is paused diff --git a/main.py b/main.py index 27eb89c..ddb61c2 100644 --- a/main.py +++ b/main.py @@ -38,9 +38,11 @@ from PyQt5.QtCore import ( from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent, QAudioProbe from PyQt5.QtGui import QClipboard, QCloseEvent, QPixmap, QResizeEvent from utils import ( + delete_album_art, scan_for_music, initialize_db, add_files_to_database, + set_album_art, ) from components import ( PreferencesWindow, @@ -436,63 +438,16 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): debug( f"main.py set_album_art_for_selected_songs() | updating album art for {song}" ) - self.update_album_art_for_song(song, album_art_path) - - def update_album_art_for_song( - self, song_file_path: str, album_art_path: str - ) -> None: - """Updates the ID3 tag APIC (album art) for 1 song""" - # audio = MP3(song_file_path, ID3=ID3) - audio = ID3(song_file_path) - # Remove existing APIC Frames (album art) - audio.delall("APIC") - # Add the album art - with open(album_art_path, "rb") as album_art_file: - if album_art_path.endswith(".jpg") or album_art_path.endswith(".jpeg"): - audio.add( - APIC( - encoding=3, # 3 = utf-8 - mime="image/jpeg", - type=3, # 3 = cover image - desc="Cover", - data=album_art_file.read(), - ) - ) - elif album_art_path.endswith(".png"): - audio.add( - APIC( - encoding=3, # 3 = utf-8 - mime="image/png", - type=3, # 3 = cover image - desc="Cover", - data=album_art_file.read(), - ) - ) - audio.save() + set_album_art(song, album_art_path) def delete_album_art_for_current_song(self) -> None: """Handles deleting the ID3 tag APIC (album art) for current song""" file = self.tableView.get_current_song_filepath() - try: - audio = ID3(file) - debug(audio) - if "APIC:" in audio: - del audio["APIC:"] - debug("Deleting album art") - audio.save() - else: - warning("delete_album_art_for_current_song() | no tag called APIC") - except Exception: - traceback.print_exc() - exctype, value = sys.exc_info()[:2] - error( - f"delete_album_art_for_current_song() | Error processing this file:\t {file}\n{exctype}\n{value}\n{traceback.format_exc()}" - ) - return - # Load the default album artwork in the qgraphicsview - # album_art_data = self.tableView.get_current_song_album_art() - album_art_data = get_album_art(None) - self.albumGraphicsView.load_album_art(album_art_data) + result = delete_album_art(file) + if result: + # Load the default album artwork in the qgraphicsview + album_art_data = get_album_art(None) + self.albumGraphicsView.load_album_art(album_art_data) def process_probe(self, buff) -> None: """Audio visualizer buffer processing""" diff --git a/utils/__init__.py b/utils/__init__.py index 0b7f08e..a1e1d52 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,5 +1,5 @@ from .fft_analyser import FFTAnalyser -from .id3_timestamp_to_datetime import id3_timestamp_to_datetime +from .convert_id3_timestamp_to_datetime import convert_id3_timestamp_to_datetime from .initialize_db import initialize_db from .safe_get import safe_get from .get_album_art import get_album_art @@ -13,3 +13,5 @@ from .update_song_in_database import update_song_in_database from .scan_for_music import scan_for_music from .add_files_to_database import add_files_to_database from .convert_date_str_to_tyer_tdat_id3_tag import convert_date_str_to_tyer_tdat_id3_tag +from .set_album_art import set_album_art +from .delete_album_art import delete_album_art diff --git a/utils/add_files_to_database.py b/utils/add_files_to_database.py index 9b56fdb..27e4722 100644 --- a/utils/add_files_to_database.py +++ b/utils/add_files_to_database.py @@ -1,11 +1,9 @@ import DBA -import os from logging import debug -from utils import get_id3_tags, id3_timestamp_to_datetime +from utils import get_id3_tags, convert_id3_timestamp_to_datetime from configparser import ConfigParser from pathlib import Path from appdirs import user_config_dir -import platform def add_files_to_database(files, progress_callback=None): @@ -31,10 +29,6 @@ def add_files_to_database(files, progress_callback=None): if any(filepath.lower().endswith(ext) for ext in extensions): if progress_callback: progress_callback.emit(filepath) - # if "microsoft-standard" in platform.uname().release: - # filename = filepath.split(r"\\")[-1] - # filepath = os.path.join(filepath) - # else: filename = filepath.split("/")[-1] audio = get_id3_tags(filepath) @@ -59,7 +53,7 @@ def add_files_to_database(files, progress_callback=None): except KeyError: genre = "" try: - date = id3_timestamp_to_datetime(audio["TDRC"].text[0]) + date = convert_id3_timestamp_to_datetime(audio["TDRC"].text[0]) except KeyError: date = "" try: diff --git a/utils/id3_timestamp_to_datetime.py b/utils/convert_id3_timestamp_to_datetime.py similarity index 75% rename from utils/id3_timestamp_to_datetime.py rename to utils/convert_id3_timestamp_to_datetime.py index 103d7e0..d2c9864 100644 --- a/utils/id3_timestamp_to_datetime.py +++ b/utils/convert_id3_timestamp_to_datetime.py @@ -2,13 +2,13 @@ import datetime from mutagen.id3._specs import ID3TimeStamp -def id3_timestamp_to_datetime(timestamp: ID3TimeStamp): +def convert_id3_timestamp_to_datetime(timestamp: ID3TimeStamp): """Turns a mutagen ID3TimeStamp into a format that SQLite can use for Date field""" if len(timestamp.text) == 4: # If only year is provided - datetime_obj = datetime.datetime.strptime(timestamp.text, '%Y') + datetime_obj = datetime.datetime.strptime(timestamp.text, "%Y") else: try: - datetime_obj = datetime.datetime.strptime(timestamp.text, '%Y-%m-%d') + datetime_obj = datetime.datetime.strptime(timestamp.text, "%Y-%m-%d") except ValueError: - datetime_obj = datetime.datetime.strptime(timestamp.text, '%Y%m%d') - return datetime_obj.strftime('%Y-%m-%d') + datetime_obj = datetime.datetime.strptime(timestamp.text, "%Y%m%d") + return datetime_obj.strftime("%Y-%m-%d") diff --git a/utils/delete_album_art.py b/utils/delete_album_art.py new file mode 100644 index 0000000..41543c2 --- /dev/null +++ b/utils/delete_album_art.py @@ -0,0 +1,32 @@ +from logging import debug, warning, error +from mutagen.id3 import ID3 +from traceback import print_exc, format_exc +from sys import exc_info + + +def delete_album_art(file: str) -> bool: + """Deletes the album art (APIC tag) for a specific song + + Args: + `file`: fully qualified path to an audio file + + Returns: + True on success, False on failure + """ + try: + audio = ID3(file) + debug(audio) + if "APIC:" in audio: + del audio["APIC:"] + debug("Deleting album art") + audio.save() + else: + warning("delete_album_art_for_current_song() | no tag called APIC") + return True + except Exception: + print_exc() + exctype, value = exc_info()[:2] + error( + f"delete_album_art_for_current_song() | Error processing this file:\t {file}\n{exctype}\n{value}\n{format_exc()}" + ) + return False diff --git a/utils/fft_analyzer.ai.py b/utils/fft_analyzer.ai.py deleted file mode 100644 index 53e60ad..0000000 --- a/utils/fft_analyzer.ai.py +++ /dev/null @@ -1,117 +0,0 @@ -# Credit -# https://github.com/ravenkls/MilkPlayer/blob/master/audio/fft_analyser.py - -import time -from PyQt5 import QtCore -from pydub import AudioSegment -import numpy as np -from scipy.ndimage.filters import gaussian_filter1d -from logging import debug, info - - -class FFTAnalyser(QtCore.QThread): - """Analyses a song using FFTs.""" - - calculatedVisual = QtCore.pyqtSignal(np.ndarray) - calculatedVisualRs = QtCore.pyqtSignal(np.ndarray) - - def __init__(self, player, x_resolution): # noqa: F821 - super().__init__() - self.player = player - self.reset_media() - self.player.currentMediaChanged.connect(self.reset_media) - - self.resolution = x_resolution - # this length is a number, in seconds, of how much audio is sampled to determine the frequencies - # of the audio at a specific point in time - # in this case, it takes 5% of the samples at some point in time - self.sampling_window_length = 0.05 - self.visual_delta_threshold = 1000 - self.sensitivity = 0.2 - - def reset_media(self): - """Resets the media to the currently playing song.""" - audio_file = self.player.currentMedia().canonicalUrl().path() - # if os.name == "nt" and audio_file.startswith("/"): - # audio_file = audio_file[1:] - if audio_file: - try: - self.song = AudioSegment.from_file(audio_file).set_channels(1) - except PermissionError: - self.start_animate = False - else: - self.samples = np.array(self.song.get_array_of_samples()) - - self.max_sample = self.samples.max() - self.points = np.zeros(self.resolution) - self.start_animate = True - else: - self.start_animate = False - - def calculate_amps(self): - """Calculates the amplitudes used for visualising the media.""" - - sample_count = int(self.song.frame_rate * self.sampling_window_length) - start_index = int((self.player.position() / 1000) * self.song.frame_rate) - # samples to analyse - v_sample = self.samples[start_index : start_index + sample_count] - - # Use a window function to reduce spectral leakage - window = np.hanning(len(v_sample)) - v_sample = v_sample * window - - # use FFTs to analyse frequency and amplitudes - fourier = np.fft.fft(v_sample) - freq = np.fft.fftfreq(fourier.size, d=1/self.song.frame_rate) - amps = np.abs(fourier)[:len(fourier)//2] # Only take positive frequencies - freq = freq[:len(fourier)//2] # Match frequencies to amplitudes - - # Define frequency bands (in Hz) - bands = np.logspace(np.log10(10), np.log10(23000), self.resolution + 1) - point_samples = np.zeros(self.resolution) - - # Calculate average amplitude for each frequency band - for i in range(len(bands) - 1): - mask = (freq >= bands[i]) & (freq < bands[i+1]) - if np.any(mask): - point_samples[i] = np.mean(amps[mask]) - - # Calculate RMS of the sample for dynamic sensitivity - rms = np.sqrt(np.mean(np.square(v_sample))) - rms_ratio = min(0.2, rms / (0.01 * self.max_sample)) # Smooth transition near silence - - # Normalize and apply sensitivity with RMS-based scaling - if np.max(point_samples) > 0: - point_samples = point_samples / np.max(point_samples) - point_samples = point_samples * self.sensitivity * rms_ratio - else: - point_samples = np.zeros(self.resolution) - - # Update visualization points with decay - for n in range(self.resolution): - amp = point_samples[n] - if self.player.state() in (self.player.PausedState, self.player.StoppedState): - self.points[n] *= 0.95 # Fast decay when paused/stopped - elif amp < self.points[n]: - # More aggressive decay for very quiet signals - decay_factor = 0.7 if rms_ratio < 0.1 else 0.9 - self.points[n] = max(amp, self.points[n] * decay_factor) - else: - self.points[n] = amp - - # Apply Gaussian smoothing - rs = gaussian_filter1d(self.points, sigma=1) - - # Emit the smoothed data - self.calculatedVisual.emit(rs) - - def run(self): - """Runs the animate function depending on the song.""" - while True: - if self.start_animate: - try: - self.calculate_amps() - except ValueError: - self.calculatedVisual.emit(np.zeros(self.resolution)) - self.start_animate = False - time.sleep(0.033) diff --git a/utils/scan_for_music.py b/utils/scan_for_music.py index 15c04ee..a446d5d 100644 --- a/utils/scan_for_music.py +++ b/utils/scan_for_music.py @@ -7,6 +7,11 @@ from appdirs import user_config_dir def scan_for_music(): + """ + Scans the user-defined library directory (defined in config file) + Looks for audio files with specific extensions (defined in config file) + Adds found files to the database/primary library (songs table - which holds all songs :o) + """ config = ConfigParser() cfg_file = ( Path(user_config_dir(appname="musicpom", appauthor="billypom")) / "config.ini" diff --git a/utils/set_album_art.py b/utils/set_album_art.py new file mode 100644 index 0000000..890faf9 --- /dev/null +++ b/utils/set_album_art.py @@ -0,0 +1,37 @@ +from mutagen.id3._frames import APIC +from mutagen.id3 import ID3 + + +def set_album_art(song_filepath: str, art_filepath: str) -> None: + """Updates the ID3 tag APIC (album art) for a song + + Args: + `song_filepath`: fully qualified path to audio file + `art_filepath` : fully qualified path to picture file + """ + audio = ID3(song_filepath) + # Remove existing APIC Frames (album art) + audio.delall("APIC") + # Add the album art + with open(art_filepath, "rb") as art: + if art_filepath.endswith(".jpg") or art_filepath.endswith(".jpeg"): + audio.add( + APIC( + encoding=3, # 3 = utf-8 + mime="image/jpeg", + type=3, # 3 = cover image + desc="Cover", + data=art.read(), + ) + ) + elif art_filepath.endswith(".png"): + audio.add( + APIC( + encoding=3, # 3 = utf-8 + mime="image/png", + type=3, # 3 = cover image + desc="Cover", + data=art.read(), + ) + ) + audio.save() diff --git a/utils/set_id3_tag.py b/utils/set_id3_tag.py index bd40fe8..4d225bb 100644 --- a/utils/set_id3_tag.py +++ b/utils/set_id3_tag.py @@ -1,4 +1,4 @@ -import logging +from logging import debug, error from components import ErrorDialog from utils.id3_tag_mapping import id3_tag_mapping from utils.convert_date_str_to_tyer_tdat_id3_tag import ( @@ -92,7 +92,7 @@ def set_id3_tag(filepath: str, tag_name: str, value: str): Returns: True / False""" - logging.info(f"filepath: {filepath} | tag_name: {tag_name} | value: {value}") + debug(f"filepath: {filepath} | tag_name: {tag_name} | value: {value}") try: try: # Load existing tags @@ -112,7 +112,7 @@ def set_id3_tag(filepath: str, tag_name: str, value: str): try: audio = ID3(filepath) except Exception as e: - logging.error(f"ran into an exception: {e}") + error(f"ran into an exception: {e}") audio = ID3() audio.delall("USLT") frame = USLT(encoding=3, text=value) @@ -124,7 +124,7 @@ def set_id3_tag(filepath: str, tag_name: str, value: str): tag_name = id3_tag_mapping[tag_name] # Other if tag_name in mutagen_id3_tag_mapping: # Tag accounted for - logging.info(f"tag_name = {tag_name}") + debug(f"tag_name = {tag_name}") tag_class = mutagen_id3_tag_mapping[tag_name] if issubclass(tag_class, Frame): frame = tag_class(encoding=3, text=[value])