From 695c1884bdd0e563dbdbe9c65530ad2763425c06 Mon Sep 17 00:00:00 2001 From: billypom on debian Date: Wed, 17 Jan 2024 23:36:00 -0500 Subject: [PATCH] music pom time --- .gitignore | 5 + DBA.py | 49 ++++ LICENSE | 2 +- README.md | 2 +- components/MusicTable.py | 24 ++ components/__init__.py | 1 + main.py | 184 +++++++++++++ requirements.txt | 7 + sample_config.ini | 12 + ui.py | 200 ++++++++++++++ ui.ui | 382 +++++++++++++++++++++++++++ utils/__init__.py | 4 + utils/audio_visualizer.py | 20 ++ utils/create_library.sql | 15 ++ utils/create_waveform_from_file.py | 50 ++++ utils/fft_analyser.py | 114 ++++++++ utils/initialize_library_database.py | 9 + utils/scan_for_music.py | 55 ++++ utils/waveform_from_wav_file.py | 36 +++ 19 files changed, 1169 insertions(+), 2 deletions(-) create mode 100644 DBA.py create mode 100644 components/MusicTable.py create mode 100644 components/__init__.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 sample_config.ini create mode 100644 ui.py create mode 100644 ui.ui create mode 100644 utils/__init__.py create mode 100644 utils/audio_visualizer.py create mode 100644 utils/create_library.sql create mode 100644 utils/create_waveform_from_file.py create mode 100644 utils/fft_analyser.py create mode 100644 utils/initialize_library_database.py create mode 100644 utils/scan_for_music.py create mode 100644 utils/waveform_from_wav_file.py diff --git a/.gitignore b/.gitignore index 68bc17f..f7509a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +# My +/references +/db +config.ini + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/DBA.py b/DBA.py new file mode 100644 index 0000000..46751e3 --- /dev/null +++ b/DBA.py @@ -0,0 +1,49 @@ +import sqlite3 +from configparser import ConfigParser + +class DBAccess: + def __init__(self, db_name=None): + config = ConfigParser() + config.read('config.ini') + if db_name is None: + db_name = config.get('db', 'library') + self._conn = sqlite3.connect(db_name) + self._cursor = self._conn.cursor() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + @property + def connection(self): + return self._conn + + @property + def cursor(self): + return self._cursor + + def commit(self): + self.connection.commit() + + def close(self, commit=True): + if commit: + self.commit() + self.connection.close() + + def execute(self, sql, params): + self.cursor.execute(sql, params or ()) + + def executemany(self, sql, seq_of_params): + self.cursor.executemany(sql, seq_of_params) #sqlite has execute many i guess? + + def fetchall(self): + return self.cursor.fetchall() + + def fetchone(self): + return self.cursor.fetchone() + + def query(self, sql, params): + self.cursor.execute(sql, params or ()) + return self.fetchall() \ No newline at end of file diff --git a/LICENSE b/LICENSE index abf09a2..8ebd132 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 billy +Copyright (c) 2024 billypom Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 99c1c29..ee1c7b3 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# musicpom \ No newline at end of file +# MusicPom \ No newline at end of file diff --git a/components/MusicTable.py b/components/MusicTable.py new file mode 100644 index 0000000..4379328 --- /dev/null +++ b/components/MusicTable.py @@ -0,0 +1,24 @@ +import DBA +from PyQt5.QtGui import QStandardItem, QStandardItemModel +from PyQt5.QtWidgets import QTableView + + +class MusicTable(QTableView): + def __init__(self): + super().__init__() + # Fetch library data + with DBA.DBAccess() as db: # returns a tuple, 1 row just for metadata purposes + data = db.query('SELECT title, artist, album, genre, codec, album_date, filepath FROM library;', ()) + headers = ['title', 'artist', 'album', 'genre', 'codec', 'year', 'path'] + # Create a model + model = QStandardItemModel() + model.setHorizontalHeaderLabels(headers) + # Populate the model + for row_data in data: + items = [QStandardItem(str(item)) for item in row_data] + model.appendRow(items) + # Set the model to the tableView + self.tableView.setModel(model) + # self.tableView.resizeColumnsToContents() + self.music_table.clicked.connect(self.set_clicked_cell_filepath) + \ No newline at end of file diff --git a/components/__init__.py b/components/__init__.py new file mode 100644 index 0000000..706d493 --- /dev/null +++ b/components/__init__.py @@ -0,0 +1 @@ +from .MusicTable import MusicTable \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..ca36107 --- /dev/null +++ b/main.py @@ -0,0 +1,184 @@ +import DBA +from ui import Ui_MainWindow +from PyQt5.QtWidgets import QMainWindow, QApplication +import qdarktheme +from PyQt5.QtCore import QUrl, QTimer, QFile, QTextStream +from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent, QAudioProbe +from PyQt5.QtGui import QStandardItem, QStandardItemModel +from utils import scan_for_music +from utils import initialize_library_database +from utils import AudioVisualizer +from pyqtgraph import mkBrush + + +class ApplicationWindow(QMainWindow, Ui_MainWindow): + def __init__(self): + super(ApplicationWindow, self).__init__() + self.setupUi(self) + self.setWindowTitle('MusicPom') + self.selected_song_filepath = None + self.current_song_filepath = None + + global stopped + stopped = False + + # Initialization + self.player = QMediaPlayer(None, QMediaPlayer.VideoSurface) # Audio player + self.probe = QAudioProbe() # Get audio data + self.timer = QTimer(self) # Audio timing things + self.model = QStandardItemModel() # Table library listing + self.audio_visualizer = AudioVisualizer(self.player) + self.current_volume = 50 + self.player.setVolume(self.current_volume) + + # Audio probe for processing audio signal in real time + # Provides faster updates than move_slider + self.probe.setSource(self.player) + self.probe.audioBufferProbed.connect(self.process_probe) + + # Probably move all the table logic to its own class + # Promote the wigdet to my custom class from utils + # inherit from QTableView + # then i can handle click events for editing metadata in a class + + # current song should also be its own class + # selected song should be its own class too... + + # ______________ + # | | + # | Table making | + # | | + # |______________| + + # Fetch library data + with DBA.DBAccess() as db: + data = db.query('SELECT title, artist, album, genre, codec, album_date, filepath FROM library;', ()) + headers = ['title', 'artist', 'album', 'genre', 'codec', 'year', 'path'] + self.model.setHorizontalHeaderLabels(headers) + for row_data in data: # Populate the model + items = [QStandardItem(str(item)) for item in row_data] + self.model.appendRow(items) + # Set the model to the tableView + self.tableView.setModel(self.model) + # self.tableView.resizeColumnsToContents() + + + # Slider Timer (realtime playback feedback horizontal bar) + self.timer.start(150) # 150ms update interval solved problem with drag seeking halting playback + self.timer.timeout.connect(self.move_slider) + + # Graphics plot + self.PlotWidget.setXRange(0,100,padding=0) # x axis range + self.PlotWidget.setYRange(0,0.3,padding=0) # y axis range + self.PlotWidget.getAxis('bottom').setTicks([]) # Remove x-axis ticks + self.PlotWidget.getAxis('bottom').setLabel('') # Remove x-axis label + self.PlotWidget.setLogMode(False,False) + # Remove y-axis labels and decorations + self.PlotWidget.getAxis('left').setTicks([]) # Remove y-axis ticks + self.PlotWidget.getAxis('left').setLabel('') # Remove y-axis label + + + # Connections + self.playbackSlider.sliderMoved[int].connect(lambda: self.player.setPosition(self.playbackSlider.value())) + self.volumeSlider.sliderMoved[int].connect(lambda: self.volume_changed()) + self.playButton.clicked.connect(self.on_play_clicked) + # self.pauseButton.clicked.connect(self.on_pause_clicked) + self.previousButton.clicked.connect(self.on_previous_clicked) + self.nextButton.clicked.connect(self.on_next_clicked) + self.tableView.clicked.connect(self.set_clicked_cell_filepath) + self.actionPreferences.triggered.connect(self.actionPreferencesClicked) + self.actionScanLibraries.triggered.connect(self.scan_libraries) + self.actionClearDatabase.triggered.connect(initialize_library_database) + + def play_audio_file(self): + """Start playback of selected track & move playback slider""" + self.current_song_filepath = self.selected_song_filepath + url = QUrl.fromLocalFile(self.current_song_filepath) # read the file + content = QMediaContent(url) # load the audio content + + self.player.setMedia(content) # what content to play + self.player.play() # play + self.move_slider() # mover + + def update_audio_visualization(self): + """Handles upading points on the pyqtgraph visual""" + self.clear_audio_visualization() + self.y = self.audio_visualizer.get_amplitudes() + self.x = [i for i in range(len(self.y))] + self.PlotWidget.plot(self.x, self.y, fillLevel=0, fillBrush=mkBrush('b')) + self.PlotWidget.show() + + def clear_audio_visualization(self): + self.PlotWidget.clear() + + def move_slider(self): + """Handles moving the playback slider""" + if stopped: + return + else: + # Update the slider + if self.player.state() == QMediaPlayer.PlayingState: + self.playbackSlider.setMinimum(0) + self.playbackSlider.setMaximum(self.player.duration()) + slider_position = self.player.position() + self.playbackSlider.setValue(slider_position) + + current_minutes, current_seconds = divmod(slider_position / 1000, 60) + duration_minutes, duration_seconds = divmod(self.player.duration() / 1000, 60) + + self.startTimeLabel.setText(f"{int(current_minutes):02d}:{int(current_seconds):02d}") + self.endTimeLabel.setText(f"{int(duration_minutes):02d}:{int(duration_seconds):02d}") + + def volume_changed(self): + """Handles volume changes""" + try: + self.current_volume = self.volumeSlider.value() + self.player.setVolume(self.current_volume) + except Exception as e: + print(f"Changing volume error: {e}") + + def on_play_clicked(self): + """Updates the Play & Pause buttons when clicked""" + if self.player.state() == QMediaPlayer.PlayingState: + self.player.pause() + self.playButton.setText("▶️") + else: + if self.player.state() == QMediaPlayer.PausedState: + self.player.play() + self.playButton.setText("⏸️") + else: + self.play_audio_file() + self.playButton.setText("⏸️") + + def set_clicked_cell_filepath(self): + """Sets the filepath of the currently selected song""" + self.selected_song_filepath = self.tableView.currentIndex().siblingAtColumn(6).data() + + def on_previous_clicked(self): + """""" + print('previous') + + def on_next_clicked(self): + print('next') + + def actionPreferencesClicked(self): + print('preferences') + + def scan_libraries(self): + scan_for_music() + # refresh datatable + + def process_probe(self, buff): + buff.startTime() + self.update_audio_visualization() + + + + +if __name__ == "__main__": + import sys + app = QApplication(sys.argv) + qdarktheme.setup_theme() + ui = ApplicationWindow() + ui.show() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..75046f5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +tinytag +matplotlib +pyqt5 +pydub +pyqtdarktheme +pyqtgraph +scipy \ No newline at end of file diff --git a/sample_config.ini b/sample_config.ini new file mode 100644 index 0000000..725aed2 --- /dev/null +++ b/sample_config.ini @@ -0,0 +1,12 @@ +[db] +# The library database file +library = db/library.db + +[directories] +# Useful paths to have stored +library = /path/to/music +reorganize_destination = /where/to/reorganize/to + +[settings] +# Which file types are scanned +extensions = mp3,wav,ogg,flac \ No newline at end of file diff --git a/ui.py b/ui.py new file mode 100644 index 0000000..84c9289 --- /dev/null +++ b/ui.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'ui.ui' +# +# Created by: PyQt5 UI code generator 5.15.10 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_MainWindow(object): + def setupUi(self, MainWindow): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(1061, 795) + MainWindow.setStatusTip("") + self.centralwidget = QtWidgets.QWidget(MainWindow) + self.centralwidget.setObjectName("centralwidget") + self.frame = QtWidgets.QFrame(self.centralwidget) + self.frame.setGeometry(QtCore.QRect(0, 160, 1061, 501)) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Ignored) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.frame.sizePolicy().hasHeightForWidth()) + self.frame.setSizePolicy(sizePolicy) + self.frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.frame.setFrameShadow(QtWidgets.QFrame.Raised) + self.frame.setObjectName("frame") + self.tableView = QtWidgets.QTableView(self.frame) + self.tableView.setGeometry(QtCore.QRect(0, 0, 1061, 501)) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.tableView.sizePolicy().hasHeightForWidth()) + self.tableView.setSizePolicy(sizePolicy) + self.tableView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.tableView.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) + self.tableView.setAlternatingRowColors(True) + self.tableView.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.tableView.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.tableView.setObjectName("tableView") + self.tableView.verticalHeader().setVisible(False) + self.frame_2 = QtWidgets.QFrame(self.centralwidget) + self.frame_2.setGeometry(QtCore.QRect(0, 0, 161, 161)) + self.frame_2.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.frame_2.setFrameShadow(QtWidgets.QFrame.Raised) + self.frame_2.setObjectName("frame_2") + self.albumGraphicsView = QtWidgets.QGraphicsView(self.frame_2) + self.albumGraphicsView.setGeometry(QtCore.QRect(0, 0, 161, 161)) + self.albumGraphicsView.setObjectName("albumGraphicsView") + self.horizontalLayoutWidget = QtWidgets.QWidget(self.centralwidget) + self.horizontalLayoutWidget.setGeometry(QtCore.QRect(540, 0, 521, 41)) + self.horizontalLayoutWidget.setObjectName("horizontalLayoutWidget") + self.horizontalLayout = QtWidgets.QHBoxLayout(self.horizontalLayoutWidget) + self.horizontalLayout.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout.setObjectName("horizontalLayout") + self.playbackSlider = QtWidgets.QSlider(self.horizontalLayoutWidget) + self.playbackSlider.setOrientation(QtCore.Qt.Horizontal) + self.playbackSlider.setObjectName("playbackSlider") + self.horizontalLayout.addWidget(self.playbackSlider) + self.startTimeLabel = QtWidgets.QLabel(self.horizontalLayoutWidget) + self.startTimeLabel.setObjectName("startTimeLabel") + self.horizontalLayout.addWidget(self.startTimeLabel) + self.slashLabel = QtWidgets.QLabel(self.horizontalLayoutWidget) + self.slashLabel.setObjectName("slashLabel") + self.horizontalLayout.addWidget(self.slashLabel) + self.endTimeLabel = QtWidgets.QLabel(self.horizontalLayoutWidget) + self.endTimeLabel.setObjectName("endTimeLabel") + self.horizontalLayout.addWidget(self.endTimeLabel) + self.frame_4 = QtWidgets.QFrame(self.centralwidget) + self.frame_4.setGeometry(QtCore.QRect(540, 40, 521, 121)) + self.frame_4.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.frame_4.setFrameShadow(QtWidgets.QFrame.Raised) + self.frame_4.setObjectName("frame_4") + self.PlotWidget = PlotWidget(self.frame_4) + self.PlotWidget.setGeometry(QtCore.QRect(0, 0, 521, 121)) + self.PlotWidget.setObjectName("PlotWidget") + self.horizontalLayoutWidget_2 = QtWidgets.QWidget(self.centralwidget) + self.horizontalLayoutWidget_2.setGeometry(QtCore.QRect(0, 660, 1061, 60)) + self.horizontalLayoutWidget_2.setObjectName("horizontalLayoutWidget_2") + self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.horizontalLayoutWidget_2) + self.horizontalLayout_2.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.previousButton = QtWidgets.QPushButton(self.horizontalLayoutWidget_2) + font = QtGui.QFont() + font.setPointSize(28) + self.previousButton.setFont(font) + self.previousButton.setObjectName("previousButton") + self.horizontalLayout_2.addWidget(self.previousButton) + self.playButton = QtWidgets.QPushButton(self.horizontalLayoutWidget_2) + font = QtGui.QFont() + font.setPointSize(28) + self.playButton.setFont(font) + self.playButton.setObjectName("playButton") + self.horizontalLayout_2.addWidget(self.playButton) + self.nextButton = QtWidgets.QPushButton(self.horizontalLayoutWidget_2) + font = QtGui.QFont() + font.setPointSize(28) + self.nextButton.setFont(font) + self.nextButton.setObjectName("nextButton") + self.horizontalLayout_2.addWidget(self.nextButton) + self.frame_3 = QtWidgets.QFrame(self.centralwidget) + self.frame_3.setGeometry(QtCore.QRect(0, 720, 1061, 31)) + self.frame_3.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.frame_3.setFrameShadow(QtWidgets.QFrame.Raised) + self.frame_3.setObjectName("frame_3") + self.volumeSlider = QtWidgets.QSlider(self.frame_3) + self.volumeSlider.setGeometry(QtCore.QRect(10, 0, 181, 31)) + self.volumeSlider.setOrientation(QtCore.Qt.Horizontal) + self.volumeSlider.setObjectName("volumeSlider") + self.frame_5 = QtWidgets.QFrame(self.centralwidget) + self.frame_5.setGeometry(QtCore.QRect(160, 0, 381, 161)) + self.frame_5.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.frame_5.setFrameShadow(QtWidgets.QFrame.Raised) + self.frame_5.setObjectName("frame_5") + self.verticalLayoutWidget = QtWidgets.QWidget(self.frame_5) + self.verticalLayoutWidget.setGeometry(QtCore.QRect(0, 0, 381, 161)) + self.verticalLayoutWidget.setObjectName("verticalLayoutWidget") + self.verticalLayout = QtWidgets.QVBoxLayout(self.verticalLayoutWidget) + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setObjectName("verticalLayout") + self.artistLabel = QtWidgets.QLabel(self.verticalLayoutWidget) + font = QtGui.QFont() + font.setPointSize(24) + font.setBold(True) + font.setWeight(75) + self.artistLabel.setFont(font) + self.artistLabel.setObjectName("artistLabel") + self.verticalLayout.addWidget(self.artistLabel) + self.titleLabel = QtWidgets.QLabel(self.verticalLayoutWidget) + font = QtGui.QFont() + font.setPointSize(18) + self.titleLabel.setFont(font) + self.titleLabel.setObjectName("titleLabel") + self.verticalLayout.addWidget(self.titleLabel) + self.albumLabel = QtWidgets.QLabel(self.verticalLayoutWidget) + font = QtGui.QFont() + font.setPointSize(16) + font.setBold(False) + font.setItalic(True) + font.setWeight(50) + self.albumLabel.setFont(font) + self.albumLabel.setObjectName("albumLabel") + self.verticalLayout.addWidget(self.albumLabel) + MainWindow.setCentralWidget(self.centralwidget) + self.menubar = QtWidgets.QMenuBar(MainWindow) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1061, 24)) + self.menubar.setObjectName("menubar") + self.menuFile = QtWidgets.QMenu(self.menubar) + self.menuFile.setObjectName("menuFile") + self.menuEdit = QtWidgets.QMenu(self.menubar) + self.menuEdit.setObjectName("menuEdit") + self.menuView = QtWidgets.QMenu(self.menubar) + self.menuView.setObjectName("menuView") + self.menuQuick_Actions = QtWidgets.QMenu(self.menubar) + self.menuQuick_Actions.setObjectName("menuQuick_Actions") + MainWindow.setMenuBar(self.menubar) + self.statusbar = QtWidgets.QStatusBar(MainWindow) + self.statusbar.setObjectName("statusbar") + MainWindow.setStatusBar(self.statusbar) + self.actionPreferences = QtWidgets.QAction(MainWindow) + self.actionPreferences.setObjectName("actionPreferences") + self.actionScanLibraries = QtWidgets.QAction(MainWindow) + self.actionScanLibraries.setObjectName("actionScanLibraries") + self.actionClearDatabase = QtWidgets.QAction(MainWindow) + self.actionClearDatabase.setObjectName("actionClearDatabase") + self.menuEdit.addAction(self.actionPreferences) + self.menuQuick_Actions.addAction(self.actionScanLibraries) + self.menuQuick_Actions.addAction(self.actionClearDatabase) + self.menubar.addAction(self.menuFile.menuAction()) + self.menubar.addAction(self.menuEdit.menuAction()) + self.menubar.addAction(self.menuView.menuAction()) + self.menubar.addAction(self.menuQuick_Actions.menuAction()) + + self.retranslateUi(MainWindow) + QtCore.QMetaObject.connectSlotsByName(MainWindow) + + def retranslateUi(self, MainWindow): + _translate = QtCore.QCoreApplication.translate + MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) + self.startTimeLabel.setText(_translate("MainWindow", "00:00")) + self.slashLabel.setText(_translate("MainWindow", "/")) + self.endTimeLabel.setText(_translate("MainWindow", "00:00")) + self.previousButton.setText(_translate("MainWindow", "⏮️")) + self.playButton.setText(_translate("MainWindow", "▶️")) + self.nextButton.setText(_translate("MainWindow", "⏭️")) + self.artistLabel.setText(_translate("MainWindow", "artist")) + self.titleLabel.setText(_translate("MainWindow", "song title")) + self.albumLabel.setText(_translate("MainWindow", "album")) + self.menuFile.setTitle(_translate("MainWindow", "File")) + self.menuEdit.setTitle(_translate("MainWindow", "Edit")) + self.menuView.setTitle(_translate("MainWindow", "View")) + self.menuQuick_Actions.setTitle(_translate("MainWindow", "Quick-Actions")) + self.actionPreferences.setText(_translate("MainWindow", "Preferences")) + self.actionPreferences.setStatusTip(_translate("MainWindow", "Open preferences")) + self.actionScanLibraries.setText(_translate("MainWindow", "Scan libraries")) + self.actionClearDatabase.setText(_translate("MainWindow", "Clear Database")) +from pyqtgraph import PlotWidget diff --git a/ui.ui b/ui.ui new file mode 100644 index 0000000..1b1a938 --- /dev/null +++ b/ui.ui @@ -0,0 +1,382 @@ + + + MainWindow + + + + 0 + 0 + 1061 + 795 + + + + MainWindow + + + + + + + + + 0 + 160 + 1061 + 501 + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + 0 + 0 + 1061 + 501 + + + + + 0 + 0 + + + + Qt::ScrollBarAlwaysOff + + + QAbstractScrollArea::AdjustToContents + + + true + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + + + + + 0 + 0 + 161 + 161 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + 0 + 0 + 161 + 161 + + + + + + + + 540 + 0 + 521 + 41 + + + + + + + Qt::Horizontal + + + + + + + 00:00 + + + + + + + / + + + + + + + 00:00 + + + + + + + + + 540 + 40 + 521 + 121 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + 0 + 0 + 521 + 121 + + + + + + + + 0 + 660 + 1061 + 60 + + + + + + + + 28 + + + + ⏮️ + + + + + + + + 28 + + + + ▶️ + + + + + + + + 28 + + + + ⏭️ + + + + + + + + + 0 + 720 + 1061 + 31 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + 10 + 0 + 181 + 31 + + + + Qt::Horizontal + + + + + + + 160 + 0 + 381 + 161 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + 0 + 0 + 381 + 161 + + + + + + + + 24 + 75 + true + + + + artist + + + + + + + + 18 + + + + song title + + + + + + + + 16 + 50 + true + false + + + + album + + + + + + + + + + + 0 + 0 + 1061 + 24 + + + + + File + + + + + Edit + + + + + + View + + + + + Quick-Actions + + + + + + + + + + + + + Preferences + + + Open preferences + + + + + Scan libraries + + + + + Clear Database + + + + + + PlotWidget + QWidget +
pyqtgraph
+ 1 +
+
+ + +
diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..67af79a --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,4 @@ +from .initialize_library_database import initialize_library_database +from .scan_for_music import scan_for_music +from .audio_visualizer import AudioVisualizer +from.fft_analyser import FFTAnalyser diff --git a/utils/audio_visualizer.py b/utils/audio_visualizer.py new file mode 100644 index 0000000..7313938 --- /dev/null +++ b/utils/audio_visualizer.py @@ -0,0 +1,20 @@ +from PyQt5 import QtWidgets, QtGui, QtCore +import numpy as np + +from .fft_analyser import FFTAnalyser + +class AudioVisualizer(QtWidgets.QWidget): + + def __init__(self, media_player): + super().__init__() + self.media_player = media_player + self.fft_analyser = FFTAnalyser(self.media_player) + self.fft_analyser.calculated_visual.connect(self.set_amplitudes) + self.fft_analyser.start() + self.amps = np.array([]) + + def get_amplitudes(self): + return self.amps + + def set_amplitudes(self, amps): + self.amps = np.array(amps) \ No newline at end of file diff --git a/utils/create_library.sql b/utils/create_library.sql new file mode 100644 index 0000000..58d8f93 --- /dev/null +++ b/utils/create_library.sql @@ -0,0 +1,15 @@ +DROP TABLE IF EXISTS library; + +CREATE TABLE library( + -- In SQLite, a column with type INTEGER PRIMARY KEY is an alias for the ROWID (except in WITHOUT ROWID tables) which is always a 64-bit signed integer. + id integer primary key, + filepath varchar(511) UNIQUE, + title varchar(255), + album varchar(255), + artist varchar(255), + genre varchar(255), + codec varchar(15), + album_date date, + bitrate int, + date_added TIMESTAMP default CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/utils/create_waveform_from_file.py b/utils/create_waveform_from_file.py new file mode 100644 index 0000000..ad33438 --- /dev/null +++ b/utils/create_waveform_from_file.py @@ -0,0 +1,50 @@ +import matplotlib.pyplot as plt +import numpy as np +import math +from pydub import AudioSegment + + +def create_waveform_from_file(file): + # Read the MP3 file + audio = AudioSegment.from_file(file) + # Convert to mono and get frame rate and number of channels + num_channels = 1 + frame_rate = audio.frame_rate + downsample = math.ceil(frame_rate * num_channels / 1) # /1 = 1 sample per second + # Process audio data + process_chunk_size = 600000 - (600000 % frame_rate) + signal = None + waveform = np.array([]) + # Convert the audio data to numpy array + audio_data = np.array(audio.get_array_of_samples()) + # Create waveforms + start_index = 0 + while start_index < len(audio_data): + end_index = start_index + process_chunk_size + signal = audio_data[start_index:end_index].astype(float) # Get chunk and convert to float + + # Take mean of absolute values per 0.5 seconds + sub_waveform = np.nanmean( + np.pad(np.absolute(signal), (0, ((downsample - (signal.size % downsample)) % downsample)), mode='constant', constant_values=np.NaN).reshape(-1, downsample), + axis=1 + ) + + waveform = np.concatenate((waveform, sub_waveform)) + start_index = end_index + + # Plot waveforms + plt.figure(1) + plt.plot(waveform, color='blue') + plt.plot(-waveform, color='blue') # Mirrored waveform + # Fill in area + plt.fill_between(np.arange(len(waveform)), waveform, -waveform, color='blue', alpha=0.5) + # Remove decorations, labels, axes, etc + plt.axis('off') + # Graph goes to ends of pic + plt.xlim(0, len(waveform)) + # Save pic + plt.savefig('now_playing_waveform.png', dpi=64, bbox_inches='tight', pad_inches=0) + # Show me tho + # plt.show() + +# create_waveform_from_file(sys.argv[1]) \ No newline at end of file diff --git a/utils/fft_analyser.py b/utils/fft_analyser.py new file mode 100644 index 0000000..07b4700 --- /dev/null +++ b/utils/fft_analyser.py @@ -0,0 +1,114 @@ +# Credit +# https://github.com/ravenkls/MilkPlayer/blob/master/audio/fft_analyser.py + +# std +import time +import os + +# qt +from PyQt5 import QtCore + +# other +from pydub import AudioSegment +import numpy as np +from scipy.ndimage.filters import gaussian_filter1d + + +class FFTAnalyser(QtCore.QThread): + """Analyses a song on a playlist using FFTs.""" + + calculated_visual = QtCore.pyqtSignal(np.ndarray) + + def __init__(self, player: 'MusicPlayer'): # noqa: F821 + super().__init__() + self.player = player + self.reset_media() + self.player.currentMediaChanged.connect(self.reset_media) + + self.resolution = 100 + self.visual_delta_threshold = 1000 + self.sensitivity = 5 + + 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 * 0.05) + 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=0.05) + amps = 2/v_sample.size * np.abs(fourier) + data = np.array([freq, amps]).T + + point_range = 1 / self.resolution + point_samples = [] + + if not data.size: + return + + for n, f in enumerate(np.arange(0, 1, point_range), start=1): + # get the amps which are in between the frequency range + amps = data[(f - point_range < data[:, 0]) & (data[:, 0] < f)] + if not amps.size: + point_samples.append(0) + else: + point_samples.append(amps.max()*((1+self.sensitivity/10+(self.sensitivity-1)/10)**(n/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) + + 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) \ No newline at end of file diff --git a/utils/initialize_library_database.py b/utils/initialize_library_database.py new file mode 100644 index 0000000..7981b24 --- /dev/null +++ b/utils/initialize_library_database.py @@ -0,0 +1,9 @@ +import DBA + +def initialize_library_database(): + with open('utils/create_library.sql', 'r') as file: + lines = file.read() + for statement in lines.split(';'): + print(f'executing [{statement}]') + with DBA.DBAccess() as db: + db.execute(statement, ()) \ No newline at end of file diff --git a/utils/scan_for_music.py b/utils/scan_for_music.py new file mode 100644 index 0000000..36423d9 --- /dev/null +++ b/utils/scan_for_music.py @@ -0,0 +1,55 @@ +import os +import DBA +from configparser import ConfigParser +from tinytag import TinyTag + +config = ConfigParser() +config.read('config.ini') + + +def scan_for_music(): + root_dir = config.get('directories', 'library') + extensions = config.get('settings', 'extensions').split(',') + insert_data = [] # To store data for batch insert + + for dirpath, dirnames, filenames in os.walk(root_dir): + for filename in filenames: + if any(filename.lower().endswith(ext) for ext in extensions): + filepath = os.path.join(dirpath, filename) + audio = TinyTag.get(filepath) + + # Append data tuple to insert_data list + insert_data.append(( + filepath, + audio.title, + audio.album, + audio.artist, + audio.genre, + filename.split('.')[-1], + audio.year, + audio.bitrate + )) + + # Check if batch size is reached + if len(insert_data) >= 1000: + with DBA.DBAccess() as db: + db.executemany('INSERT OR IGNORE INTO library (filepath, title, album, artist, genre, codec, album_date, bitrate) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', insert_data) + insert_data = [] # Reset the insert_data list + + # Insert any remaining data + if insert_data: + with DBA.DBAccess() as db: + db.executemany('INSERT OR IGNORE INTO library (filepath, title, album, artist, genre, codec, album_date, bitrate) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', insert_data) + + +# id int unsigned auto_increment, +# title varchar(255), +# album varchar(255), +# artist varchar(255), +# genre varchar(255), +# codec varchar(15), +# album_date date, +# bitrate int unsigned, +# date_added TIMESTAMP default CURRENT_TIMESTAMP, + +# scan_for_music(config.get('directories', 'library1')) \ No newline at end of file diff --git a/utils/waveform_from_wav_file.py b/utils/waveform_from_wav_file.py new file mode 100644 index 0000000..1ceb1cd --- /dev/null +++ b/utils/waveform_from_wav_file.py @@ -0,0 +1,36 @@ +import matplotlib.pyplot as plt +import numpy as np +import wave +import math + +file = 'output.wav' + +with wave.open(file,'r') as wav_file: + num_channels = wav_file.getnchannels() + frame_rate = wav_file.getframerate() + downsample = math.ceil(frame_rate * num_channels / 2) # Get two samples per second! + + process_chunk_size = 600000 - (600000 % frame_rate) + + signal = None + waveform = np.array([]) + + while signal is None or signal.size > 0: + signal = np.frombuffer(wav_file.readframes(process_chunk_size), dtype='int16') + signal = signal.astype(float) # Convert to float + # padding_value = 0 + # Take mean of absolute values per 0.5 seconds + sub_waveform = np.nanmean( + # np.pad(np.absolute(signal), (0, ((downsample - (signal.size % downsample)) % downsample)), mode='constant', constant_values=padding_value).reshape(-1, downsample), + np.pad(np.absolute(signal), (0, ((downsample - (signal.size % downsample)) % downsample)), mode='constant', constant_values=np.NaN).reshape(-1, downsample), + axis=1 + ) + + waveform = np.concatenate((waveform, sub_waveform)) + + #Plot + plt.figure(1) + plt.title('Waveform') + plt.plot(waveform) + plt.plot(-waveform) + plt.show() \ No newline at end of file