diff --git a/components/DebugWindow.py b/components/DebugWindow.py index c70586c..ca7ce6a 100644 --- a/components/DebugWindow.py +++ b/components/DebugWindow.py @@ -4,6 +4,7 @@ from PyQt5.QtWidgets import ( QVBoxLayout, ) from pprint import pformat +from logging import debug class DebugWindow(QDialog): @@ -16,6 +17,7 @@ class DebugWindow(QDialog): layout = QVBoxLayout() # Labels & input fields + # debug(pformat(self.text)) self.input_field = QPlainTextEdit(pformat(self.text)) layout.addWidget(self.input_field) diff --git a/components/MusicTable.py b/components/MusicTable.py index f6f8620..17be0dc 100644 --- a/components/MusicTable.py +++ b/components/MusicTable.py @@ -148,8 +148,8 @@ class MusicTable(QTableView): # CONNECTIONS self.clicked.connect(self.set_selected_song_filepath) - self.doubleClicked.connect(self.set_current_song_filepath) - self.enterKey.connect(self.set_current_song_filepath) + # self.doubleClicked.connect(self.set_current_song_filepath) + # self.enterKey.connect(self.set_current_song_filepath) self.deleteKey.connect(self.delete_songs) self.model2.dataChanged.connect(self.on_cell_data_changed) # editing cells self.model2.layoutChanged.connect(self.restore_scroll_position) @@ -465,6 +465,7 @@ class MusicTable(QTableView): ) if new_index.isValid(): self.setCurrentIndex(new_index) + super().keyPressEvent(e) elif key == Qt.Key.Key_Down: # Arrow key navigation current_index = self.currentIndex() new_index = self.model2.index( @@ -472,6 +473,7 @@ class MusicTable(QTableView): ) if new_index.isValid(): self.setCurrentIndex(new_index) + super().keyPressEvent(e) elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if self.state() != QAbstractItemView.EditingState: self.enterKey.emit() # Enter key detected @@ -737,7 +739,7 @@ class MusicTable(QTableView): ) def set_current_song_filepath(self) -> None: - """Sets the filepath of the currently playing song""" + """Sets the current song filepath to the value in column 'path' with current selected row index""" # NOTE: # Setting the current song filepath automatically plays that song # self.tableView listens to this function and plays the audio file located at self.current_song_filepath diff --git a/main.py b/main.py index 1810a84..a7c46e7 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from mutagen.id3._frames import APIC from configparser import ConfigParser from pathlib import Path from appdirs import user_config_dir +from numpy import array as nparray from logging import debug, error, warning, basicConfig, INFO, DEBUG from ui import Ui_MainWindow from PyQt5.QtWidgets import ( @@ -148,6 +149,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): # UI self.setupUi(self) self.setWindowTitle("musicpom") + # self.vLayoutAlbumArt.SetFixedSize() self.status_bar = QStatusBar() self.permanent_status_label = QLabel("Status...") self.status_bar.addPermanentWidget(self.permanent_status_label) @@ -160,7 +162,7 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.config.read(self.cfg_file) self.player: QMediaPlayer = QMediaPlayer() # Audio player object self.probe: QAudioProbe = QAudioProbe() # Gets audio data - self.analyzer_x_resolution = 100 + self.analyzer_x_resolution = 200 self.audio_visualizer: AudioVisualizer = AudioVisualizer(self.player, self.analyzer_x_resolution) self.timer = QTimer(self) # Audio timing things self.clipboard = clipboard @@ -186,14 +188,16 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.PlotWidget.setLogMode(False, False) self.PlotWidget.setMouseEnabled(x=False, y=False) # Remove x-axis ticks - ticks = ['20', '31.25', '62.5', '125', '250', '500', '1000', '2000', '4000', '10000', '20000'] - self.PlotWidget.getAxis("bottom").setTicks([]) - self.PlotWidget.getAxis("bottom").setLabel("") # Remove x-axis label - # x = nparray([0, 31.25, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 15000, 20000]) + # ticks = ['20', '31.25', '62.5', '125', '250', '500', '1000', '2000', '4000', '10000', '20000'] + # self.PlotWidget.getAxis("bottom").setTicks([]) + # self.PlotWidget.getAxis("bottom").setLabel("") # Remove x-axis label + # ticks = nparray([0, 31.25, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 15000, 20000]) + # Remove y-axis labels and decorations # self.PlotWidget.getAxis("left").setTicks([[(str(tick), tick) for tick in ticks]]) - self.PlotWidget.getAxis("left").setTicks([]) - self.PlotWidget.getAxis("left").setLabel("") # Remove y-axis label + # self.PlotWidget.getAxis("left").setTicks([]) + # self.PlotWidget.getAxis("left").setLabel("") # Remove y-axis label + self.PlotWidget.showGrid(x=True, y=True) # Connections self.playbackSlider.sliderReleased.connect( @@ -306,7 +310,10 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): self.permanent_status_label.setText(message) def play_audio_file(self) -> None: - """Start playback of `tableView.current_song_filepath` & moves playback slider""" + """ + Start playback of `tableView.current_song_filepath` & moves playback slider + """ + self.tableView.set_current_song_filepath() # get metadata self.current_song_metadata = self.tableView.get_current_song_metadata() # read the file @@ -333,6 +340,9 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow): ) title = self.current_song_metadata["TIT2"][0] + debug(artist) + debug(title) + debug(album) self.artistLabel.setText(artist) self.albumLabel.setText(album) self.titleLabel.setText(title) diff --git a/ui.py b/ui.py index d3955ae..e7698fd 100644 --- a/ui.py +++ b/ui.py @@ -35,29 +35,6 @@ class Ui_MainWindow(object): self.hLayoutHead.addLayout(self.vlayoutAlbumArt) self.vLayoutSongDetails = QtWidgets.QVBoxLayout() self.vLayoutSongDetails.setObjectName("vLayoutSongDetails") - self.artistLabel = QtWidgets.QLabel(self.centralwidget) - font = QtGui.QFont() - font.setPointSize(24) - font.setBold(True) - font.setWeight(75) - self.artistLabel.setFont(font) - self.artistLabel.setObjectName("artistLabel") - self.vLayoutSongDetails.addWidget(self.artistLabel) - self.titleLabel = QtWidgets.QLabel(self.centralwidget) - font = QtGui.QFont() - font.setPointSize(18) - self.titleLabel.setFont(font) - self.titleLabel.setObjectName("titleLabel") - self.vLayoutSongDetails.addWidget(self.titleLabel) - self.albumLabel = QtWidgets.QLabel(self.centralwidget) - font = QtGui.QFont() - font.setPointSize(16) - font.setBold(False) - font.setItalic(True) - font.setWeight(50) - self.albumLabel.setFont(font) - self.albumLabel.setObjectName("albumLabel") - self.vLayoutSongDetails.addWidget(self.albumLabel) self.hLayoutHead.addLayout(self.vLayoutSongDetails) self.vLayoutPlaybackVisuals = QtWidgets.QVBoxLayout() self.vLayoutPlaybackVisuals.setObjectName("vLayoutPlaybackVisuals") @@ -118,7 +95,6 @@ class Ui_MainWindow(object): self.vLayoutPlaybackVisuals.addWidget(self.PlotWidget) self.hLayoutHead.addLayout(self.vLayoutPlaybackVisuals) self.hLayoutHead.setStretch(0, 1) - self.hLayoutHead.setStretch(1, 4) self.hLayoutHead.setStretch(2, 6) self.verticalLayout.addLayout(self.hLayoutHead) self.hLayoutMusicTable = QtWidgets.QHBoxLayout() @@ -134,9 +110,6 @@ class Ui_MainWindow(object): self.hLayoutMusicTable.setStretch(0, 2) self.hLayoutMusicTable.setStretch(1, 10) self.verticalLayout.addLayout(self.hLayoutMusicTable) - self.verticalLayout.setStretch(0, 1) - self.verticalLayout.setStretch(1, 2) - self.verticalLayout_3.addLayout(self.verticalLayout) self.hLayoutControls = QtWidgets.QHBoxLayout() self.hLayoutControls.setSpacing(6) self.hLayoutControls.setObjectName("hLayoutControls") @@ -167,7 +140,7 @@ class Ui_MainWindow(object): self.hLayoutControls.setStretch(2, 2) self.hLayoutControls.setStretch(3, 2) self.hLayoutControls.setStretch(4, 1) - self.verticalLayout_3.addLayout(self.hLayoutControls) + self.verticalLayout.addLayout(self.hLayoutControls) self.hLayoutControls2 = QtWidgets.QHBoxLayout() self.hLayoutControls2.setSpacing(6) self.hLayoutControls2.setObjectName("hLayoutControls2") @@ -182,19 +155,46 @@ class Ui_MainWindow(object): self.volumeLabel = QtWidgets.QLabel(self.centralwidget) self.volumeLabel.setObjectName("volumeLabel") self.hLayoutControls2.addWidget(self.volumeLabel) + self.hLayoutSongDetails = QtWidgets.QHBoxLayout() + self.hLayoutSongDetails.setObjectName("hLayoutSongDetails") + self.titleLabel = QtWidgets.QLabel(self.centralwidget) + font = QtGui.QFont() + font.setPointSize(18) + self.titleLabel.setFont(font) + self.titleLabel.setObjectName("titleLabel") + self.hLayoutSongDetails.addWidget(self.titleLabel) + self.artistLabel = QtWidgets.QLabel(self.centralwidget) + font = QtGui.QFont() + font.setPointSize(24) + font.setBold(True) + font.setWeight(75) + self.artistLabel.setFont(font) + self.artistLabel.setObjectName("artistLabel") + self.hLayoutSongDetails.addWidget(self.artistLabel) + self.albumLabel = QtWidgets.QLabel(self.centralwidget) + font = QtGui.QFont() + font.setPointSize(16) + font.setBold(False) + font.setItalic(True) + font.setWeight(50) + self.albumLabel.setFont(font) + self.albumLabel.setObjectName("albumLabel") + self.hLayoutSongDetails.addWidget(self.albumLabel) + self.hLayoutControls2.addLayout(self.hLayoutSongDetails) spacerItem2 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.hLayoutControls2.addItem(spacerItem2) self.pushButton = QtWidgets.QPushButton(self.centralwidget) self.pushButton.setObjectName("pushButton") self.hLayoutControls2.addWidget(self.pushButton) self.hLayoutControls2.setStretch(0, 1) - self.hLayoutControls2.setStretch(2, 4) - self.hLayoutControls2.setStretch(3, 1) - self.verticalLayout_3.addLayout(self.hLayoutControls2) + self.hLayoutControls2.setStretch(3, 4) + self.hLayoutControls2.setStretch(4, 1) + self.verticalLayout.addLayout(self.hLayoutControls2) + self.verticalLayout_3.addLayout(self.verticalLayout) self.verticalLayout_3.setStretch(0, 20) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 1152, 41)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1152, 24)) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(self.menubar) self.menuFile.setObjectName("menuFile") @@ -243,8 +243,6 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) - self.artistLabel.setText(_translate("MainWindow", "artist")) - self.albumLabel.setText(_translate("MainWindow", "album")) self.startTimeLabel.setText(_translate("MainWindow", "00:00")) self.slashLabel.setText(_translate("MainWindow", "/")) self.endTimeLabel.setText(_translate("MainWindow", "00:00")) @@ -253,6 +251,8 @@ class Ui_MainWindow(object): self.playButton.setText(_translate("MainWindow", "▶️")) self.nextButton.setText(_translate("MainWindow", "⏭️")) self.volumeLabel.setText(_translate("MainWindow", "50")) + self.artistLabel.setText(_translate("MainWindow", "artist")) + self.albumLabel.setText(_translate("MainWindow", "album")) self.pushButton.setText(_translate("MainWindow", "nothing")) self.menuFile.setTitle(_translate("MainWindow", "File")) self.menuEdit.setTitle(_translate("MainWindow", "Edit")) diff --git a/ui.ui b/ui.ui index ed5aaf2..c6c1496 100644 --- a/ui.ui +++ b/ui.ui @@ -17,9 +17,9 @@ - + - + 6 @@ -27,7 +27,7 @@ 0 - + @@ -39,46 +39,7 @@ - - - - - - 24 - 75 - true - - - - artist - - - - - - - - 18 - - - - - - - - - 16 - 50 - true - false - - - - album - - - - + @@ -201,127 +162,169 @@ - - - - - - 6 - - - - Qt::Horizontal + + + 6 - - - 40 - 20 - - - + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 28 + + + + ⏮️ + + + + + + + + 28 + + + + ▶️ + + + + + + + + 28 + + + + ⏭️ + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + - - - - 28 - + + + 6 - - ⏮️ - - - - - - - - 28 - - - - ▶️ - - - - - - - - 28 - - - - ⏭️ - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - 6 - - - - - -1 - - - 101 - - - 50 - - - Qt::Horizontal - - - QSlider::TicksAbove - - - - - - - 50 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - nothing - - + + + + -1 + + + 101 + + + 50 + + + Qt::Horizontal + + + QSlider::TicksAbove + + + + + + + 50 + + + + + + + + + + 18 + + + + + + + + + 24 + 75 + true + + + + artist + + + + + + + + 16 + 50 + true + false + + + + album + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + nothing + + + + @@ -333,7 +336,7 @@ 0 0 1152 - 41 + 24 diff --git a/utils/fft_analyser.py b/utils/fft_analyser.py index c3de6a4..7cbecfd 100644 --- a/utils/fft_analyser.py +++ b/utils/fft_analyser.py @@ -21,7 +21,10 @@ class FFTAnalyser(QtCore.QThread): self.player.currentMediaChanged.connect(self.reset_media) self.resolution = x_resolution - self.sampling_window_length = 0.05 + # 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.09 self.visual_delta_threshold = 1000 self.sensitivity = 10 @@ -59,23 +62,38 @@ class FFTAnalyser(QtCore.QThread): freq = np.fft.fftfreq(fourier.size, d=self.sampling_window_length) amps = 2 / v_sample.size * np.abs(fourier) data = np.array([freq, amps]).T - print(freq * .05 * self.song.frame_rate) + # print(freq * .05 * self.song.frame_rate) + # NOTE: # given 520 hz sine wave # np.argmax(fourier) = 2374 - # freq[2374] * .05 * self.song.frame_rate = 520 - - # x values = freq * self.song.frame_rate * .05 + # freq[2374] * .05 * self.song.frame_rate = 520 :O omg! thats the hz value + # x values = freq * self.song.frame_rate * self.sampling_window_length + # print(freq * self.song.frame_rate * .05) point_range = 1 / self.resolution + + # Logarithmic frequency scaling + min_freq = np.min(freq[freq > 0]) # minimum positive frequency + # 20hz + # print('min') + # print(min_freq * .05 * self.song.frame_rate) + max_freq = np.max(freq) # maximum frequency + # 20khz + # print('max') + # print(max_freq * .05 * self.song.frame_rate) + log_freqs = np.logspace(np.log10(min_freq), np.log10(max_freq), self.resolution) + point_samples = [] if not data.size: return - for i, freq in enumerate(np.arange(0, 1, point_range), start=1): + #for i, freq in enumerate(np.arange(0, 1, point_range), start=1): + for i, log_freq in enumerate(log_freqs): # get the amps which are in between the frequency range - amps = data[(freq - point_range < data[:, 0]) & (data[:, 0] < freq)] + #amps = data[(freq - point_range < data[:, 0]) & (data[:, 0] < freq)] + amps = data[(log_freq - point_range < data[:, 0]) & (data[:, 0] < log_freq)] if not amps.size: point_samples.append(0) else: @@ -91,7 +109,7 @@ class FFTAnalyser(QtCore.QThread): # array (self.bars) is so that we can fade out the previous amplitudes from # the past for n, amp in enumerate(point_samples): - amp *= 2 + # amp *= 2 if ( self.points[n] > 0 @@ -108,14 +126,11 @@ class FFTAnalyser(QtCore.QThread): # interpolate points rs = gaussian_filter1d(self.points, sigma=2) - # Mirror the amplitudes, these are renamed to 'rs' because we are using them - # for polar plotting, which is plotted in terms of r and theta - # rs = np.concatenate((rs, np.flip(rs))) - # rs = np.concatenate((rs, np.flip(rs))) - - # they are divided by the highest sample in the song to normalise the + # divide by the highest sample in the song to normalise the # amps in terms of decimals from 0 -> 1 self.calculated_visual.emit(rs / self.max_sample) + # self.calculated_visual.emit(rs) + # print(rs) # print(rs/self.max_sample) def run(self): diff --git a/utils/fft_analyser.py.dumb b/utils/fft_analyser.py.dumb new file mode 100644 index 0000000..95ca2ce --- /dev/null +++ b/utils/fft_analyser.py.dumb @@ -0,0 +1,168 @@ +# 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.""" + + calculated_visual = 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.09 + self.visual_delta_threshold = 1000 + self.sensitivity = 10 + + 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) + v_sample = self.samples[ + start_index : start_index + sample_count + ] # samples to analyse + + + # use FFTs to analyse frequency and amplitudes + fourier = np.fft.fft(v_sample) + freq = np.fft.fftfreq(fourier.size, d=self.sampling_window_length) + amps = 2 / v_sample.size * np.abs(fourier) + data = np.array([freq, amps]).T + # print(freq * .05 * self.song.frame_rate) + + # NOTE: + # given 520 hz sine wave + # np.argmax(fourier) = 2374 + # freq[2374] * .05 * self.song.frame_rate = 520 :O omg! thats the hz value + # x values = freq * self.song.frame_rate * self.sampling_window_length + # print(freq * self.song.frame_rate * .05) + + freq_log_intervals = [ + (1,100), + (100,1000), + (1000, 10000), + (10000,22000) + ] + + all_log_freqs = [] + + for low, high in freq_log_intervals: + # generate logarithmic frequencies for each range + log_freqs = np.logspace(np.log10(low + 1e-6), np.log10(high), self.resolution) + all_log_freqs.extend(log_freqs) + + log_freqs = np.array(all_log_freqs) + + # point_range = 1 / self.resolution + + # Logarithmic frequency scaling + # min_freq = np.min(freq[freq > 0]) # minimum positive frequency + # 20hz + # print('min') + # print(min_freq * .05 * self.song.frame_rate) + # max_freq = np.max(freq) # maximum frequency + # 20khz + # print('max') + # print(max_freq * .05 * self.song.frame_rate) + # log_freqs = np.logspace(np.log10(min_freq), np.log10(max_freq), self.resolution) + + if len(self.points) != len(log_freqs): + self.points = np.zeros(len(log_freqs)) + + point_samples = [] + + if not data.size: + return + + #for i, freq in enumerate(np.arange(0, 1, point_range), start=1): + for i, log_freq in enumerate(log_freqs): + # get the amps which are in between the frequency range + #amps = data[(freq - point_range < data[:, 0]) & (data[:, 0] < freq)] + freq_range = log_freq * 0.01 + amps = data[(log_freq - freq_range < data[:, 0]) & (data[:, 0] < log_freq + freq_range)] + if not amps.size: + point_samples.append(0) + else: + point_samples.append( + amps.max() + * ( + (1 + self.sensitivity / 10 + (self.sensitivity - 1) / 10) + ** (i / 50) + ) + ) + + # Add the point_samples to the self.points array, the reason we have a separate + # array (self.bars) is so that we can fade out the previous amplitudes from + # the past + for n, amp in enumerate(point_samples): + amp *= 2 + + if ( + self.points[n] > 0 + and amp < self.points[n] + or self.player.state() + in (self.player.PausedState, self.player.StoppedState) + ): + self.points[n] -= self.points[n] / 10 # fade out + elif abs(self.points[n] - amp) > self.visual_delta_threshold: + self.points[n] = amp + if self.points[n] < 1: + self.points[n] = 1e-5 + + # interpolate points + rs = gaussian_filter1d(self.points, sigma=2) + + # Mirror the amplitudes, these are renamed to 'rs' because we are using them + # for polar plotting, which is plotted in terms of r and theta + # rs = np.concatenate((rs, np.flip(rs))) + # rs = np.concatenate((rs, np.flip(rs))) + + # they are divided by the highest sample in the song to normalise the + # amps in terms of decimals from 0 -> 1 + self.calculated_visual.emit(rs / self.max_sample) + # print(rs/self.max_sample) + + def run(self): + """Runs the animate function depending on the song.""" + while True: + if self.start_animate: + try: + self.calculate_amps() + except ValueError: + self.calculated_visual.emit(np.zeros(self.resolution)) + self.start_animate = False + time.sleep(0.025) diff --git a/utils/get_id3_tags.py b/utils/get_id3_tags.py index efa607f..4403268 100644 --- a/utils/get_id3_tags.py +++ b/utils/get_id3_tags.py @@ -1,5 +1,5 @@ import os -import logging +from logging import debug, error from mutagen.id3 import ID3 from mutagen.id3._frames import TIT2 from mutagen.id3._util import ID3NoHeaderError @@ -7,7 +7,7 @@ from mutagen.id3._util import ID3NoHeaderError def get_id3_tags(filename): """Get the ID3 tags for an audio file""" - logging.info(filename) + debug(filename) try: # Open the MP3 file and read its content @@ -34,6 +34,6 @@ def get_id3_tags(filename): audio.save() except Exception as e: - logging.error(f"Could not assign file ID3 tag: {e}") + error(f"Could not assign file ID3 tag: {e}") return audio