cleanup more
This commit is contained in:
parent
dfb17a7459
commit
5980950185
@ -55,9 +55,9 @@ config.ini db/
|
|||||||
- ~~delete songs from library (del key || right-click delete)~~
|
- ~~delete songs from library (del key || right-click delete)~~
|
||||||
- ~~improve audio visualizer - logarithmic x-axis ISO octave bands - see Renoise~~
|
- ~~improve audio visualizer - logarithmic x-axis ISO octave bands - see Renoise~~
|
||||||
- ~~when table is focused, start typing to match against the primary sort column~~
|
- ~~when table is focused, start typing to match against the primary sort column~~
|
||||||
|
- ~~remember last window size~~
|
||||||
- playlist autoexporting
|
- playlist autoexporting
|
||||||
- .wav, .ogg, .flac convertor
|
- .wav, .ogg, .flac convertor
|
||||||
- remember last window size
|
|
||||||
- playback modes (normal, repeat playlist, repeat 1 song, shuffle)
|
- playback modes (normal, repeat playlist, repeat 1 song, shuffle)
|
||||||
- autoplay next song in all modes
|
- autoplay next song in all modes
|
||||||
- allow spectrum analyzer to fall when playback stops or song is paused
|
- allow spectrum analyzer to fall when playback stops or song is paused
|
||||||
|
|||||||
55
main.py
55
main.py
@ -38,9 +38,11 @@ from PyQt5.QtCore import (
|
|||||||
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent, QAudioProbe
|
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent, QAudioProbe
|
||||||
from PyQt5.QtGui import QClipboard, QCloseEvent, QPixmap, QResizeEvent
|
from PyQt5.QtGui import QClipboard, QCloseEvent, QPixmap, QResizeEvent
|
||||||
from utils import (
|
from utils import (
|
||||||
|
delete_album_art,
|
||||||
scan_for_music,
|
scan_for_music,
|
||||||
initialize_db,
|
initialize_db,
|
||||||
add_files_to_database,
|
add_files_to_database,
|
||||||
|
set_album_art,
|
||||||
)
|
)
|
||||||
from components import (
|
from components import (
|
||||||
PreferencesWindow,
|
PreferencesWindow,
|
||||||
@ -436,61 +438,14 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
|
|||||||
debug(
|
debug(
|
||||||
f"main.py set_album_art_for_selected_songs() | updating album art for {song}"
|
f"main.py set_album_art_for_selected_songs() | updating album art for {song}"
|
||||||
)
|
)
|
||||||
self.update_album_art_for_song(song, album_art_path)
|
set_album_art(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()
|
|
||||||
|
|
||||||
def delete_album_art_for_current_song(self) -> None:
|
def delete_album_art_for_current_song(self) -> None:
|
||||||
"""Handles deleting the ID3 tag APIC (album art) for current song"""
|
"""Handles deleting the ID3 tag APIC (album art) for current song"""
|
||||||
file = self.tableView.get_current_song_filepath()
|
file = self.tableView.get_current_song_filepath()
|
||||||
try:
|
result = delete_album_art(file)
|
||||||
audio = ID3(file)
|
if result:
|
||||||
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
|
# 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)
|
album_art_data = get_album_art(None)
|
||||||
self.albumGraphicsView.load_album_art(album_art_data)
|
self.albumGraphicsView.load_album_art(album_art_data)
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from .fft_analyser import FFTAnalyser
|
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 .initialize_db import initialize_db
|
||||||
from .safe_get import safe_get
|
from .safe_get import safe_get
|
||||||
from .get_album_art import get_album_art
|
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 .scan_for_music import scan_for_music
|
||||||
from .add_files_to_database import add_files_to_database
|
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 .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
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import DBA
|
import DBA
|
||||||
import os
|
|
||||||
from logging import debug
|
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 configparser import ConfigParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from appdirs import user_config_dir
|
from appdirs import user_config_dir
|
||||||
import platform
|
|
||||||
|
|
||||||
|
|
||||||
def add_files_to_database(files, progress_callback=None):
|
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 any(filepath.lower().endswith(ext) for ext in extensions):
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback.emit(filepath)
|
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]
|
filename = filepath.split("/")[-1]
|
||||||
audio = get_id3_tags(filepath)
|
audio = get_id3_tags(filepath)
|
||||||
|
|
||||||
@ -59,7 +53,7 @@ def add_files_to_database(files, progress_callback=None):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
genre = ""
|
genre = ""
|
||||||
try:
|
try:
|
||||||
date = id3_timestamp_to_datetime(audio["TDRC"].text[0])
|
date = convert_id3_timestamp_to_datetime(audio["TDRC"].text[0])
|
||||||
except KeyError:
|
except KeyError:
|
||||||
date = ""
|
date = ""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -2,13 +2,13 @@ import datetime
|
|||||||
from mutagen.id3._specs import ID3TimeStamp
|
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"""
|
"""Turns a mutagen ID3TimeStamp into a format that SQLite can use for Date field"""
|
||||||
if len(timestamp.text) == 4: # If only year is provided
|
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:
|
else:
|
||||||
try:
|
try:
|
||||||
datetime_obj = datetime.datetime.strptime(timestamp.text, '%Y-%m-%d')
|
datetime_obj = datetime.datetime.strptime(timestamp.text, "%Y-%m-%d")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
datetime_obj = datetime.datetime.strptime(timestamp.text, '%Y%m%d')
|
datetime_obj = datetime.datetime.strptime(timestamp.text, "%Y%m%d")
|
||||||
return datetime_obj.strftime('%Y-%m-%d')
|
return datetime_obj.strftime("%Y-%m-%d")
|
||||||
32
utils/delete_album_art.py
Normal file
32
utils/delete_album_art.py
Normal file
@ -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
|
||||||
@ -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)
|
|
||||||
@ -7,6 +7,11 @@ from appdirs import user_config_dir
|
|||||||
|
|
||||||
|
|
||||||
def scan_for_music():
|
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()
|
config = ConfigParser()
|
||||||
cfg_file = (
|
cfg_file = (
|
||||||
Path(user_config_dir(appname="musicpom", appauthor="billypom")) / "config.ini"
|
Path(user_config_dir(appname="musicpom", appauthor="billypom")) / "config.ini"
|
||||||
|
|||||||
37
utils/set_album_art.py
Normal file
37
utils/set_album_art.py
Normal file
@ -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()
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import logging
|
from logging import debug, error
|
||||||
from components import ErrorDialog
|
from components import ErrorDialog
|
||||||
from utils.id3_tag_mapping import id3_tag_mapping
|
from utils.id3_tag_mapping import id3_tag_mapping
|
||||||
from utils.convert_date_str_to_tyer_tdat_id3_tag import (
|
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:
|
Returns:
|
||||||
True / False"""
|
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:
|
||||||
try: # Load existing tags
|
try: # Load existing tags
|
||||||
@ -112,7 +112,7 @@ def set_id3_tag(filepath: str, tag_name: str, value: str):
|
|||||||
try:
|
try:
|
||||||
audio = ID3(filepath)
|
audio = ID3(filepath)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"ran into an exception: {e}")
|
error(f"ran into an exception: {e}")
|
||||||
audio = ID3()
|
audio = ID3()
|
||||||
audio.delall("USLT")
|
audio.delall("USLT")
|
||||||
frame = USLT(encoding=3, text=value)
|
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]
|
tag_name = id3_tag_mapping[tag_name]
|
||||||
# Other
|
# Other
|
||||||
if tag_name in mutagen_id3_tag_mapping: # Tag accounted for
|
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]
|
tag_class = mutagen_id3_tag_mapping[tag_name]
|
||||||
if issubclass(tag_class, Frame):
|
if issubclass(tag_class, Frame):
|
||||||
frame = tag_class(encoding=3, text=[value])
|
frame = tag_class(encoding=3, text=[value])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user