music pom time

This commit is contained in:
billypom on debian 2024-01-17 23:36:00 -05:00
parent 9bc8a460a5
commit 695c1884bd
19 changed files with 1169 additions and 2 deletions

5
.gitignore vendored
View File

@ -1,3 +1,8 @@
# My
/references
/db
config.ini
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

49
DBA.py Normal file
View File

@ -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()

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 billy Copyright (c) 2024 billypom
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -1 +1 @@
# musicpom # MusicPom

24
components/MusicTable.py Normal file
View File

@ -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)

1
components/__init__.py Normal file
View File

@ -0,0 +1 @@
from .MusicTable import MusicTable

184
main.py Normal file
View File

@ -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_())

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
tinytag
matplotlib
pyqt5
pydub
pyqtdarktheme
pyqtgraph
scipy

12
sample_config.ini Normal file
View File

@ -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

200
ui.py Normal file
View File

@ -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

382
ui.ui Normal file
View File

@ -0,0 +1,382 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1061</width>
<height>795</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<property name="statusTip">
<string/>
</property>
<widget class="QWidget" name="centralwidget">
<widget class="QFrame" name="frame">
<property name="geometry">
<rect>
<x>0</x>
<y>160</y>
<width>1061</width>
<height>501</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Ignored" vsizetype="Ignored">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<widget class="QTableView" name="tableView">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1061</width>
<height>501</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContents</enum>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
</widget>
</widget>
<widget class="QFrame" name="frame_2">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>161</width>
<height>161</height>
</rect>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<widget class="QGraphicsView" name="albumGraphicsView">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>161</width>
<height>161</height>
</rect>
</property>
</widget>
</widget>
<widget class="QWidget" name="horizontalLayoutWidget">
<property name="geometry">
<rect>
<x>540</x>
<y>0</y>
<width>521</width>
<height>41</height>
</rect>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QSlider" name="playbackSlider">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="startTimeLabel">
<property name="text">
<string>00:00</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="slashLabel">
<property name="text">
<string>/</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="endTimeLabel">
<property name="text">
<string>00:00</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QFrame" name="frame_4">
<property name="geometry">
<rect>
<x>540</x>
<y>40</y>
<width>521</width>
<height>121</height>
</rect>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<widget class="PlotWidget" name="PlotWidget" native="true">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>521</width>
<height>121</height>
</rect>
</property>
</widget>
</widget>
<widget class="QWidget" name="horizontalLayoutWidget_2">
<property name="geometry">
<rect>
<x>0</x>
<y>660</y>
<width>1061</width>
<height>60</height>
</rect>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QPushButton" name="previousButton">
<property name="font">
<font>
<pointsize>28</pointsize>
</font>
</property>
<property name="text">
<string>⏮️</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="playButton">
<property name="font">
<font>
<pointsize>28</pointsize>
</font>
</property>
<property name="text">
<string>▶️</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="nextButton">
<property name="font">
<font>
<pointsize>28</pointsize>
</font>
</property>
<property name="text">
<string>⏭️</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QFrame" name="frame_3">
<property name="geometry">
<rect>
<x>0</x>
<y>720</y>
<width>1061</width>
<height>31</height>
</rect>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<widget class="QSlider" name="volumeSlider">
<property name="geometry">
<rect>
<x>10</x>
<y>0</y>
<width>181</width>
<height>31</height>
</rect>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</widget>
<widget class="QFrame" name="frame_5">
<property name="geometry">
<rect>
<x>160</x>
<y>0</y>
<width>381</width>
<height>161</height>
</rect>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<widget class="QWidget" name="verticalLayoutWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>381</width>
<height>161</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="artistLabel">
<property name="font">
<font>
<pointsize>24</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>artist</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="titleLabel">
<property name="font">
<font>
<pointsize>18</pointsize>
</font>
</property>
<property name="text">
<string>song title</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="albumLabel">
<property name="font">
<font>
<pointsize>16</pointsize>
<weight>50</weight>
<italic>true</italic>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>album</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1061</width>
<height>24</height>
</rect>
</property>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>File</string>
</property>
</widget>
<widget class="QMenu" name="menuEdit">
<property name="title">
<string>Edit</string>
</property>
<addaction name="actionPreferences"/>
</widget>
<widget class="QMenu" name="menuView">
<property name="title">
<string>View</string>
</property>
</widget>
<widget class="QMenu" name="menuQuick_Actions">
<property name="title">
<string>Quick-Actions</string>
</property>
<addaction name="actionScanLibraries"/>
<addaction name="actionClearDatabase"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuEdit"/>
<addaction name="menuView"/>
<addaction name="menuQuick_Actions"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<action name="actionPreferences">
<property name="text">
<string>Preferences</string>
</property>
<property name="statusTip">
<string>Open preferences</string>
</property>
</action>
<action name="actionScanLibraries">
<property name="text">
<string>Scan libraries</string>
</property>
</action>
<action name="actionClearDatabase">
<property name="text">
<string>Clear Database</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
<class>PlotWidget</class>
<extends>QWidget</extends>
<header>pyqtgraph</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

4
utils/__init__.py Normal file
View File

@ -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

20
utils/audio_visualizer.py Normal file
View File

@ -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)

15
utils/create_library.sql Normal file
View File

@ -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
);

View File

@ -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])

114
utils/fft_analyser.py Normal file
View File

@ -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)

View File

@ -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, ())

55
utils/scan_for_music.py Normal file
View File

@ -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'))

View File

@ -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()