diff --git a/Components/HorizontalLabelComboBox.py b/Components/HorizontalLabelComboBox.py new file mode 100644 index 0000000..82356ff --- /dev/null +++ b/Components/HorizontalLabelComboBox.py @@ -0,0 +1,21 @@ +from PyQt6.QtGui import * +from PyQt6.QtWidgets import * +from PyQt6.QtCore import * + + +class HorizontalLabelComboBox(QWidget): + def __init__(self, description, items, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + + self.description_label = QLabel(description) + self.combo_box = QComboBox(self) + self.combo_box.addItems(items) + + layout.addWidget(self.description_label) + layout.addWidget(self.combo_box) + + self.setLayout(layout) + + def getInputText(self): + return self.combo_box.currentText() diff --git a/Components/__pycache__/HorizontalLabelComboBox.cpython-312.pyc b/Components/__pycache__/HorizontalLabelComboBox.cpython-312.pyc new file mode 100644 index 0000000..1117596 Binary files /dev/null and b/Components/__pycache__/HorizontalLabelComboBox.cpython-312.pyc differ diff --git a/PlotConfig.py b/PlotConfig.py new file mode 100644 index 0000000..876ac3c --- /dev/null +++ b/PlotConfig.py @@ -0,0 +1,2 @@ +class PlotConfig: + pass \ No newline at end of file diff --git a/__pycache__/PlotConfig.cpython-312.pyc b/__pycache__/PlotConfig.cpython-312.pyc new file mode 100644 index 0000000..a2b697d Binary files /dev/null and b/__pycache__/PlotConfig.cpython-312.pyc differ diff --git a/viewer.py b/viewer.py index faee3f9..55c8556 100644 --- a/viewer.py +++ b/viewer.py @@ -5,6 +5,8 @@ import librosa import librosa.display import pygame +import time + from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg, NavigationToolbar2QT as NavigationToolbar import matplotlib.pyplot as plt @@ -16,48 +18,25 @@ import os from Components.HorizontalLabelInput import HorizontalLabelInput +from Components.HorizontalLabelComboBox import HorizontalLabelComboBox +from PlotConfig import PlotConfig class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) - self.setWindowTitle("Main Window with Input Box") + self.setWindowTitle("Insert Funny and Quirky Comment") # Central widget and layout central_widget = QWidget() layout = QHBoxLayout() # Use QHBoxLayout for horizontal arrangement central_widget.setLayout(layout) - plots_layout = QVBoxLayout() - - ### Spectrogram ### - - # Plot area for the spectrogram - self.figure, self.axes = plt.subplots() - self.figure.subplots_adjust(left=0.1, right=0.95, top=0.85, bottom=0.15) - self.spectrogram_canvas = FigureCanvasQTAgg(self.figure) - self.axes.plot([],[]) + self.channel_plots_tabs = QTabWidget() + self.channel_plots = {} + self.num_channels = 0 - # Toolbar and figure layout - plots_layout.addWidget(NavigationToolbar(self.spectrogram_canvas, self)) - plots_layout.addWidget(self.spectrogram_canvas) - - ### Spectrum ### - - # Add a second figure for plotting the column data - self.spectrum_figure, self.spectrum_axes = plt.subplots() - self.spectrum_figure.subplots_adjust(left=0.1, right=0.95, top=0.85, bottom=0.15) - self.spectrum_canvas = FigureCanvasQTAgg(self.spectrum_figure) - self.spectrum_axes.plot([],[]) - - # Toolbar and figure layout - plots_layout.addWidget(NavigationToolbar(self.spectrum_canvas, self)) - plots_layout.addWidget(self.spectrum_canvas) - - # Create a new widget to hold the spectrogran_toolbar_layout - toolbar_widget = QWidget() - toolbar_widget.setLayout(plots_layout) - layout.addWidget(toolbar_widget) + layout.addWidget(self.channel_plots_tabs) ### @@ -76,27 +55,24 @@ def __init__(self, *args, **kwargs): # Inputs self.fft_size = HorizontalLabelInput("FFT Size", "256") - self.fft_overlap = HorizontalLabelInput("FFT Overlap","0") + self.fft_hop = HorizontalLabelInput("FFT Hop","256") self.integration_count = HorizontalLabelInput("Integration Count","1") + self.fft_window = HorizontalLabelComboBox("FFT Window", ["Rect", "Hanning", "Hamming", "Blackman"]) + self.spectrum_mode = HorizontalLabelComboBox("Spectrum Mode", ["Amplitude [dB]", "Amplitude [lin]", "Phase [Rad]", "Phase [Deg]"]) + input_layout.addWidget(self.fft_size) - input_layout.addWidget(self.fft_overlap) + input_layout.addWidget(self.fft_hop) input_layout.addWidget(self.integration_count) + input_layout.addWidget(self.fft_window) + input_layout.addWidget(self.spectrum_mode) # Buttons self.plot_image_button = QPushButton("Plot Image") - self.plot_image_button.clicked.connect(lambda: self.update_image()) + self.plot_image_button.clicked.connect(lambda: self.update_images()) input_layout.addWidget(self.plot_image_button) - self.save_image_button = QPushButton("Save Spectrogram") - self.save_image_button.clicked.connect(self.save_spectrogram_image) - input_layout.addWidget(self.save_image_button) - - self.save_image_button = QPushButton("Save Spectrum") - self.save_image_button.clicked.connect(self.save_spectrum_image) - input_layout.addWidget(self.save_image_button) - self.play_audio_button = QPushButton("Play Audio") self.play_audio_button.clicked.connect(self.play_audio) input_layout.addWidget(self.play_audio_button) @@ -107,71 +83,98 @@ def __init__(self, *args, **kwargs): self.setCentralWidget(central_widget) # Connect the canvas click event - self.spectrogram_canvas.mpl_connect('button_press_event', self.on_click) - self.Sxx = None # Store spectrogram data - self.show() - - def update_image(self): - - # First check if path exists - if not(os.path.exists(self.file_path_label.text())): - self.show_popup("File Not Found") - return - - # Then load it - data, fs = librosa.load(self.file_path_label.text(),sr=None) - - f, t, self.Sxx = signal.spectrogram(data, - nperseg=int(self.fft_size.getInputText()), - noverlap=int(self.fft_overlap.getInputText()), - fs=fs, - mode="psd", - return_onesided=True - ) - - # Split the array into groups - tmp = np.abs(self.Sxx) - averaging_count = int(self.integration_count.getInputText()) - groups = np.array_split(self.Sxx, np.arange(averaging_count, tmp.shape[1], averaging_count), axis=1) - tmp =np.array([group.sum(axis=1) for group in groups]).T + self.showMaximized() - - self.Sxx_proc = tmp - - freq_max = fs/(2*1000) - time_max = len(data)*1/fs - - print(freq_max) + def update_images(self): - # Plot the spectrogram - self.axes.clear() - self.axes.imshow(20*np.log10(self.Sxx_proc), origin='lower', aspect="auto", extent=[0,time_max, 0, freq_max]) - self.axes.set_xlabel('Time [s]') - self.axes.set_ylabel('Frequency [KHz]') - self.axes.set_title('Spectrogram of \n' + self.file_path_label.text()) + if not(self.load_audio_data()): + self.show_popup("File Not Found") + return False - # Update the canvas to display the plot - self.spectrogram_canvas.draw() + msg = self.show_popup("Loading", False) - def on_click(self, event): - if event.inaxes == self.axes: # Ensure the click is inside the spectrogram plot - x_click = int(event.xdata) + self.Sxx = {} + for i in range(self.num_channels): + self.add_plot_tab(i) + self.generate_spectrogram_data(i) + self.update_spectrogram_image(i) + self.update_spectrum_image(i, 0) - if self.Sxx is not None: + msg.close() + + def update_spectrogram_image(self, channel_index): + + self.freq_max_khz = self.sample_rate_hz/(2*1000) + time_max_s = len(self.data[0,:])*1/self.sample_rate_hz + + # Plot the spectrogram + self.channel_plots[channel_index].axes.clear() + self.channel_plots[channel_index].axes.imshow(self.Sxx[channel_index], origin='lower', aspect="auto", extent=[0,time_max_s, 0, self.freq_max_khz]) + self.channel_plots[channel_index].axes.set_xlabel('Time [s]') + self.channel_plots[channel_index].axes.set_ylabel('Frequency [KHz]') + self.channel_plots[channel_index].axes.set_title('Spectrogram of \n' + self.file_path_label.text()) + + self.channel_plots[channel_index].spectrogram_canvas.draw() + + def update_spectrum_image(self, channel_index, spectrum_column_index): + + if self.Sxx is not None: + # Get the column corresponding to the clicked x-coordinate (time index) - column_data = 20 * np.log10(np.abs(self.Sxx_proc[:, x_click])) + column_data = self.Sxx[channel_index][:, spectrum_column_index] + freq_axis = np.linspace(1,len(column_data),len(column_data))*self.freq_max_khz/len(column_data) + _, x_max = self.channel_plots[channel_index].axes.get_xlim() + slice_time = np.round(x_max*spectrum_column_index/np.shape(self.Sxx[channel_index])[1],1) # Plot the column data (frequency vs. intensity) - self.spectrum_axes.clear() - self.spectrum_axes.plot(column_data) - self.spectrum_axes.set_xlabel('Frequency [Hz]') - self.spectrum_axes.set_ylabel('Intensity [Arb dB]') - self.spectrum_axes.set_title(f'Spectrum Sample at Time of {x_click} Seconds of \n ' + self.file_path_label.text()) - + self.channel_plots[channel_index].spectrum_axes.clear() + self.channel_plots[channel_index].spectrum_axes.plot(freq_axis,column_data) + self.channel_plots[channel_index].spectrum_axes.set_xlabel('Frequency [KHz]') + self.channel_plots[channel_index].spectrum_axes.set_ylabel('Intensity [Arb dB]') + self.channel_plots[channel_index].spectrum_axes.set_title(f'Spectrum Sample at Time of {slice_time} Seconds of \n ' + self.file_path_label.text()) + + self.set_spectrum_axes_limits() + # Update the column plot canvas - self.spectrum_canvas.draw() + self.channel_plots[channel_index].spectrum_canvas.draw() + + def set_spectrum_axes_limits(self): + + selected_spectrum_mode = self.spectrum_mode.getInputText() + + if selected_spectrum_mode == "Amplitude [dB]": + self.channel_plots[0].spectrum_axes.set_ylim([-60,60]) + elif selected_spectrum_mode == "Amplitude [lin]": + self.channel_plots[0].spectrum_axes.set_ylim([-1,50]) + elif selected_spectrum_mode == "Phase [Rad]": + self.channel_plots[0].spectrum_axes.set_ylim([-1.1*np.pi,1.1*np.pi]) + elif selected_spectrum_mode == "Phase [Deg]": + self.channel_plots[0].spectrum_axes.set_ylim([-185,185]) + + def on_click(self, event): + + for i in range(self.num_channels): + + if event.inaxes == self.channel_plots[i].axes: # Ensure the click is inside the spectrogram plot + + # Check where one clicked + x_axis_timestamp = int(event.xdata) + _, x_max = self.channel_plots[i].axes.get_xlim() + + # Convert the time stamp to the index in spectorgram + spectrogram_shape_tuple = np.shape(self.Sxx[i]) + spectrum_column_index = int(np.floor(spectrogram_shape_tuple[1]*x_axis_timestamp/x_max)) + + # Plot that part of the spectrogram + self.update_spectrum_image(i, spectrum_column_index) def load_file(self): + + # Clear current tabs if they exist + if self.num_channels: + for i in range(self.num_channels): + self.remove_plot_tab(i) + # Open a file dialog to let the user select a file file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "*.wav") @@ -182,22 +185,30 @@ def load_file(self): else: # Handle the case where no file was selected self.file_path_label.setText("No file selected") + return + + + def load_audio_data(self): - def save_spectrogram_image(self): - # Open a file dialog to select where to save the image - save_path, _ = QFileDialog.getSaveFileName(self, "Save Image", "", "PNG Files (*.png);;JPEG Files (*.jpg)") + # First check if path exists + if not(os.path.exists(self.file_path_label.text())): + return False - if save_path: - # Save the current figure as an image - self.figure.savefig(save_path) + # Then load it + self.data, self.sample_rate_hz \ + = librosa.load(self.file_path_label.text(),sr=None, mono=False) - def save_spectrum_image(self): - # Open a file dialog to select where to save the image - save_path, _ = QFileDialog.getSaveFileName(self, "Save Image", "", "PNG Files (*.png);;JPEG Files (*.jpg)") + # Lets determine the number of audio channels + if len(np.shape(self.data)) == 1: + self.num_channels = 1 + else: + self.num_channels = np.shape(self.data)[0] - if save_path: - # Save the current figure as an image - self.spectrum_figure.savefig(save_path) + # Then reshape to what the program expects in the case of one channel + if self.num_channels == 1: + self.data = np.reshape(self.data, [1,-1]) + + return True def play_audio(self): @@ -212,12 +223,114 @@ def play_audio(self): pygame.mixer.music.play() - def show_popup(self, text): + def show_popup(self, text, use_button_continue = True): msg = QMessageBox() msg.setIcon(QMessageBox.Icon.Information) msg.setText(text) - msg.setStandardButtons(QMessageBox.StandardButton.Ok) - msg.exec() + + if use_button_continue: + msg.setStandardButtons(QMessageBox.StandardButton.Ok) + else: + msg.setStandardButtons(QMessageBox.StandardButton.NoButton) + + # Show the message box non-blocking + msg.show() + # Process pending events to ensure the text is displayed + QApplication.processEvents() + + return msg + + def get_selected_window(self): + + selected_window = self.fft_window.getInputText() + window_length = int(self.fft_size.getInputText()) + window = np.ones(window_length) + + if selected_window == "Blackman": + window = np.blackman(window_length) + elif selected_window == "Hamming": + window = np.hamming(window_length) + elif selected_window == "Hanning": + window = np.hanning(window_length) + + return window + + def generate_spectrogram_data(self, channel_index): + + # Generate spectrogram game + window = self.get_selected_window() + + SFT = signal.ShortTimeFFT(win=window, + mfft=int(self.fft_size.getInputText()), + hop=int(self.fft_hop.getInputText()), + fs=self.sample_rate_hz, + fft_mode="onesided" + ) + + self.Sxx[channel_index] = SFT.stft(self.data[channel_index,:]) + + # Process data further + self.apply_integration(channel_index) + self.apply_spectrum_mode(channel_index) + + + def apply_integration(self, channel_index): + + # Split the array into groups + tmp = np.abs(self.Sxx[channel_index]) + averaging_count = int(self.integration_count.getInputText()) + groups = np.array_split(self.Sxx[channel_index], np.arange(averaging_count, tmp.shape[1], averaging_count), axis=1) + self.Sxx[channel_index] = np.array([group.sum(axis=1) for group in groups]).T + + def apply_spectrum_mode(self, channel_index): + + selected_spectrum_mode = self.spectrum_mode.getInputText() + + if selected_spectrum_mode == "Amplitude [dB]": + self.Sxx[channel_index] = 20*np.log10(np.abs(self.Sxx[channel_index])) + elif selected_spectrum_mode == "Amplitude [lin]": + self.Sxx[channel_index] = np.abs(self.Sxx[channel_index]) + elif selected_spectrum_mode == "Phase [Rad]": + self.Sxx[channel_index] = np.angle(self.Sxx[channel_index], deg=False) + elif selected_spectrum_mode == "Phase [Deg]": + self.Sxx[channel_index] = np.angle(self.Sxx[channel_index], deg=True) + + def remove_plot_tab(self, channel_index): + self.channel_plots[channel_index] = PlotConfig() + self.channel_plots_tabs.removeTab(0) + + def add_plot_tab(self, channel_index): + + self.channel_plots[channel_index] = PlotConfig() + self.channel_plots[channel_index].plots_layout = QVBoxLayout() + + ### Spectrogram ### + self.channel_plots[channel_index].figure, self.channel_plots[channel_index].axes = plt.subplots() + self.channel_plots[channel_index].figure.subplots_adjust(left=0.1, right=0.95, top=0.85, bottom=0.15) + self.channel_plots[channel_index].spectrogram_canvas = FigureCanvasQTAgg(self.channel_plots[channel_index].figure) + self.channel_plots[channel_index].axes.plot([],[]) + + # Toolbar and figure layout + self.channel_plots[channel_index].plots_layout.addWidget(NavigationToolbar(self.channel_plots[channel_index].spectrogram_canvas, self)) + self.channel_plots[channel_index].plots_layout.addWidget(self.channel_plots[channel_index].spectrogram_canvas) + + ### Spectrum ### + self.channel_plots[channel_index].spectrum_figure, self.channel_plots[channel_index].spectrum_axes = plt.subplots() + self.channel_plots[channel_index].spectrum_figure.subplots_adjust(left=0.1, right=0.95, top=0.85, bottom=0.15) + self.channel_plots[channel_index].spectrum_canvas = FigureCanvasQTAgg(self.channel_plots[channel_index].spectrum_figure) + self.channel_plots[channel_index].spectrum_axes.plot([],[]) + + # Toolbar and figure layout + self.channel_plots[channel_index].plots_layout.addWidget(NavigationToolbar(self.channel_plots[channel_index].spectrum_canvas, self)) + self.channel_plots[channel_index].plots_layout.addWidget(self.channel_plots[channel_index].spectrum_canvas) + + # Create a new widget to hold the spectrogran_toolbar_layout + toolbar_widget = QWidget() + toolbar_widget.setLayout(self.channel_plots[channel_index].plots_layout) + + self.channel_plots_tabs.addTab(toolbar_widget,"Channel " + str(channel_index)) + self.channel_plots[channel_index].spectrogram_canvas.mpl_connect('button_press_event', self.on_click) + app = QApplication([]) window = MainWindow()