musicpom/components/AudioVisualizer.py

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