184 lines
6.3 KiB
Python
184 lines
6.3 KiB
Python
from PyQt5.QtCore import QTimer
|
|
from PyQt5.QtMultimedia import QAudioProbe, QMediaPlayer
|
|
import numpy as np
|
|
from PyQt5 import QtWidgets
|
|
from pyqtgraph.Qt.QtWidgets import QWidget
|
|
from utils import FFTAnalyser
|
|
from pyqtgraph import mkBrush
|
|
|
|
|
|
class AudioVisualizer(QtWidgets.QWidget):
|
|
"""Audio Visualizer component"""
|
|
|
|
def __init__(self, player, probe, PlotWidget):
|
|
super().__init__()
|
|
self.player: QMediaPlayer = player
|
|
self.probe: QAudioProbe = probe
|
|
self.PlotWidget: QWidget = PlotWidget
|
|
self.x_resolution = 100
|
|
self.fft_analyser = FFTAnalyser(self.player, self.x_resolution)
|
|
self.fft_analyser.calculatedVisual.connect(self.set_amplitudes)
|
|
self.fft_analyser.calculatedVisualRs.connect(self.set_rs)
|
|
self.fft_analyser.start()
|
|
self.amps = np.array([])
|
|
self._plot_item = None
|
|
self._x_data = np.arange(self.x_resolution)
|
|
self.use_decibels = True # Set to True to use decibel scale
|
|
|
|
# Generate logarithmic frequency scale (20Hz - 20kHz)
|
|
self.min_freq = 20
|
|
self.max_freq = 23000
|
|
self.frequency_values = np.logspace(
|
|
np.log10(self.min_freq), np.log10(self.max_freq), self.x_resolution
|
|
)
|
|
|
|
# Audio probe for processing audio signal in real time
|
|
self.probe.setSource(self.player)
|
|
self.probe.audioBufferProbed.connect(self.process_probe)
|
|
|
|
self.is_playing = False
|
|
self.visualizer_timer = QTimer(self)
|
|
self.visualizer_timer.setInterval(20) # Update every 100ms (adjust as needed)
|
|
self.visualizer_timer.timeout.connect(self.update_audio_visualization)
|
|
|
|
# Graphics plot
|
|
# Make sure PlotWidget doesn't exceed album art height
|
|
# Adjust to leave room for playback controls
|
|
self.PlotWidget.setFixedHeight(225)
|
|
# x range
|
|
self.PlotWidget.setXRange(
|
|
0, self.get_x_resolution(), padding=0
|
|
)
|
|
# y axis range for decibals (-96db to 0db)
|
|
self.PlotWidget.setYRange(-96, 0, padding=0)
|
|
# Logarithmic x-axis for frequency display
|
|
self.PlotWidget.setLogMode(x=False, y=False)
|
|
self.PlotWidget.setMouseEnabled(x=False, y=False)
|
|
self.PlotWidget.showGrid(x=True, y=True)
|
|
# Performance optimizations
|
|
self.PlotWidget.setAntialiasing(False)
|
|
self.PlotWidget.setDownsampling(auto=True, mode="peak")
|
|
self.PlotWidget.setClipToView(True)
|
|
|
|
# Add tick marks for common decibel values (expanded range)
|
|
y_ticks = [
|
|
(-84, "-84dB"),
|
|
(-60, "-60dB"),
|
|
(-36, "-36dB"),
|
|
(-12, "-12dB"),
|
|
(0, "0dB"),
|
|
]
|
|
self.PlotWidget.getAxis("left").setTicks([y_ticks])
|
|
|
|
# Add frequency ticks on x-axis
|
|
freq_ticks = self.get_frequency_ticks()
|
|
self.PlotWidget.getAxis("bottom").setTicks([freq_ticks])
|
|
|
|
def get_x_resolution(self):
|
|
"""Returns the resolution for the graphics plot"""
|
|
return self.x_resolution
|
|
|
|
def get_frequency_ticks(self):
|
|
"""Returns frequency ticks for x-axis display
|
|
|
|
Returns:
|
|
list: List of tuples with (position, label) for each tick
|
|
"""
|
|
# Standard frequency bands for audio visualization
|
|
standard_freqs = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
|
|
|
|
# Map frequencies to x-axis positions
|
|
ticks = []
|
|
for freq in standard_freqs:
|
|
# Find closest index to this frequency
|
|
idx = np.argmin(np.abs(self.frequency_values - freq))
|
|
# Format labels: Hz for <1000, kHz for >=1000
|
|
if freq < 1000:
|
|
label = f"{freq}Hz"
|
|
else:
|
|
label = f"{freq / 1000:.0f}kHz"
|
|
ticks.append((idx, label))
|
|
|
|
return ticks
|
|
|
|
def get_frequencies(self):
|
|
"""Return the frequency values for x-axis"""
|
|
return self.frequency_values
|
|
|
|
def get_amplitudes(self):
|
|
return self.amps
|
|
|
|
def get_rs(self):
|
|
return self.rs
|
|
|
|
def get_decibels(self):
|
|
"""Convert amplitude values to decibel scale
|
|
|
|
Formula: dB = 20 * log10(amplitude)
|
|
For normalized amplitude values, this gives a range of approx -96dB to 0dB
|
|
With a noise floor cutoff at around -96dB (for very small values)
|
|
"""
|
|
# Avoid log(0) by adding a small epsilon
|
|
epsilon = 1e-7
|
|
amplitudes = np.maximum(self.amps, epsilon)
|
|
# Convert to decibels (20*log10 is the standard formula for amplitude to dB)
|
|
db_values = 20 * np.log10(amplitudes)
|
|
# Clip very low values to have a reasonable floor (e.g. -96dB)
|
|
db_values = np.maximum(db_values, -96)
|
|
return db_values
|
|
|
|
def set_rs(self, rs):
|
|
self.rs = np.array(rs)
|
|
|
|
def set_amplitudes(self, amps):
|
|
"""
|
|
This function is hooked into the calculatedVisual signal from FFTAnalyzer() object
|
|
Amps are assigned here, based on values passed by the signal
|
|
"""
|
|
# self.amps = np.maximum(np.array(amps), 1e-12) # Set a very small threshold
|
|
# print(self.amps)
|
|
self.amps = np.array(amps)
|
|
|
|
|
|
def process_probe(self, buff):
|
|
"""Audio visualizer buffer processing"""
|
|
# buff.startTime() # what is this? why need? dont need...
|
|
self.is_playing = True
|
|
|
|
# If music is playing, reset the timer
|
|
if not self.visualizer_timer.isActive():
|
|
self.visualizer_timer.start()
|
|
|
|
def update_audio_visualization(self):
|
|
"""Update the visualization for audio signal"""
|
|
if not self.is_playing:
|
|
# If music stopped, continue updating visualizer for a few seconds
|
|
self.visualizer_timer.stop() # Stop the timer once it's done
|
|
|
|
# Assuming y is the decibel data
|
|
y = self.get_decibels()
|
|
|
|
if len(y) == 0:
|
|
return
|
|
|
|
# Clear the previous plot
|
|
self.PlotWidget.clear()
|
|
|
|
# Plot new values
|
|
self._plot_item = self.PlotWidget.plot(
|
|
self._x_data,
|
|
y,
|
|
pen="b",
|
|
fillLevel=-96 if self.use_decibels else 0,
|
|
fillBrush=mkBrush("b"),
|
|
)
|
|
|
|
# If the visualizer is done, stop updating
|
|
if all(val == -96 for val in y):
|
|
self.is_playing = False
|
|
self.visualizer_timer.stop()
|
|
|
|
def clear_audio_visualization(self) -> None:
|
|
self.PlotWidget.clear()
|
|
self._plot_item = None
|