- New prefs: visualizer_mode (on/dim/off, saved on every click) and color_visualizer_gray (None = keep the derived alternateBase look). - VisualizerWidget takes prefs (optional, so existing call sites/tests keep working); restores its mode at startup and validates junk values. - New "Visualizer (gray mode):" row in the Preferences color sliders; theme.default_gray mirrors the widget's 0.88-lightness derivation so an untouched slider shows the current appearance. - TransportBar.refresh_theme() repaints the visualizer so slider drags preview live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
198 lines
7.2 KiB
Python
198 lines
7.2 KiB
Python
"""20-band graphic-EQ visualizer fed by the player's decoded PCM tee.
|
|
|
|
The player's QAudioBufferOutput delivers buffers essentially in lockstep
|
|
with the audible position (verified: frames received ≈ frames played), so
|
|
no extra sync buffering is needed — we just FFT the newest samples.
|
|
"""
|
|
import numpy as np
|
|
|
|
from PyQt6.QtWidgets import QWidget
|
|
from PyQt6.QtCore import Qt, QTimer, QRectF
|
|
from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen
|
|
from PyQt6.QtMultimedia import QAudioFormat
|
|
|
|
from lintunes import theme
|
|
|
|
|
|
BANDS = 20
|
|
STEPS = 20 # vertical granularity per bar
|
|
FFT_SIZE = 2048
|
|
FRAME_MS = 16 # ~60 fps
|
|
DECAY = 0.82 # slow decay; attack is instant
|
|
FLOOR_DB = -55.0
|
|
RADIUS = 8 # rounded corners, matching the transport button boxes
|
|
|
|
# Visualizer click cycles through these brightness modes in order.
|
|
MODE_ON = "on"
|
|
MODE_DIM = "dim"
|
|
MODE_OFF = "off"
|
|
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
|
|
|
|
_SAMPLE_DTYPES = {
|
|
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
|
|
QAudioFormat.SampleFormat.Int16: (np.int16, 0.0, 32768.0),
|
|
QAudioFormat.SampleFormat.Int32: (np.int32, 0.0, 2147483648.0),
|
|
QAudioFormat.SampleFormat.Float: (np.float32, 0.0, 1.0),
|
|
}
|
|
|
|
|
|
class VisualizerWidget(QWidget):
|
|
"""Animated spectrum bars; click to cycle brightness: on / dim / off.
|
|
|
|
On = highlight-colored bars; dim = light-gray bars just darker than the
|
|
panel; off = blank.
|
|
"""
|
|
|
|
def __init__(self, player, prefs=None, parent=None):
|
|
super().__init__(parent)
|
|
self._player = player
|
|
self._prefs = prefs
|
|
saved = prefs.get("visualizer_mode") if prefs is not None else None
|
|
self._mode = saved if saved in _MODE_CYCLE else MODE_ON
|
|
self._levels = np.zeros(BANDS)
|
|
self._bars = [0] * BANDS # quantized 0..STEPS
|
|
self._samples = np.zeros(FFT_SIZE, dtype=np.float32)
|
|
self._sample_rate = 44100
|
|
self._window = np.hanning(FFT_SIZE).astype(np.float32)
|
|
|
|
self.setFixedWidth(140)
|
|
self.setMinimumHeight(36)
|
|
self.setToolTip("Click to cycle: on / dim / off")
|
|
|
|
self._timer = QTimer(self)
|
|
self._timer.setInterval(FRAME_MS)
|
|
self._timer.timeout.connect(self._advance_frame)
|
|
|
|
player.audio_buffer.connect(self._on_buffer)
|
|
player.playing_changed.connect(self._on_playing_changed)
|
|
player.track_changed.connect(self._on_track_changed)
|
|
|
|
# ---- audio intake ----
|
|
|
|
def _on_buffer(self, buf):
|
|
fmt = buf.format()
|
|
spec = _SAMPLE_DTYPES.get(fmt.sampleFormat())
|
|
if spec is None:
|
|
return
|
|
dtype, offset, scale = spec
|
|
pointer = buf.data()
|
|
pointer.setsize(buf.byteCount())
|
|
raw = np.frombuffer(pointer, dtype=dtype)
|
|
channels = max(1, fmt.channelCount())
|
|
if channels > 1:
|
|
usable = (len(raw) // channels) * channels
|
|
raw = raw[:usable].reshape(-1, channels).mean(axis=1)
|
|
mono = (raw.astype(np.float32) - offset) / scale
|
|
self._sample_rate = fmt.sampleRate() or 44100
|
|
|
|
if len(mono) >= FFT_SIZE:
|
|
self._samples = mono[-FFT_SIZE:].copy()
|
|
else:
|
|
self._samples = np.roll(self._samples, -len(mono))
|
|
self._samples[-len(mono):] = mono
|
|
|
|
# ---- state ----
|
|
|
|
def _on_playing_changed(self, playing):
|
|
if playing:
|
|
if self._mode != MODE_OFF:
|
|
self._timer.start()
|
|
else:
|
|
self._timer.stop()
|
|
if self._mode != MODE_OFF:
|
|
self._clear()
|
|
|
|
def _on_track_changed(self, track):
|
|
self._samples[:] = 0.0
|
|
if track is None and self._mode != MODE_OFF:
|
|
self._timer.stop()
|
|
self._clear()
|
|
|
|
def _clear(self):
|
|
self._levels[:] = 0.0
|
|
self._bars = [0] * BANDS
|
|
self.update()
|
|
|
|
def mousePressEvent(self, event):
|
|
# Cycle on -> dim -> off -> on.
|
|
idx = _MODE_CYCLE.index(self._mode)
|
|
self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]
|
|
if self._prefs is not None:
|
|
self._prefs.set("visualizer_mode", self._mode)
|
|
if self._mode == MODE_OFF:
|
|
self._timer.stop()
|
|
self._clear() # off = blank, not the last frame held in place
|
|
elif self._player.is_playing():
|
|
self._timer.start()
|
|
|
|
# ---- frame computation ----
|
|
|
|
def _advance_frame(self):
|
|
spectrum = np.abs(np.fft.rfft(self._samples * self._window))
|
|
# Normalize: full-scale sine -> ~1.0
|
|
spectrum /= (FFT_SIZE / 4)
|
|
|
|
nyquist = self._sample_rate / 2
|
|
top = min(16000.0, nyquist * 0.95)
|
|
edges = np.geomspace(40.0, max(top, 80.0), BANDS + 1)
|
|
freqs = np.fft.rfftfreq(FFT_SIZE, d=1.0 / self._sample_rate)
|
|
|
|
new_levels = np.zeros(BANDS)
|
|
for i in range(BANDS):
|
|
mask = (freqs >= edges[i]) & (freqs < edges[i + 1])
|
|
if mask.any():
|
|
magnitude = spectrum[mask].max()
|
|
db = 20.0 * np.log10(magnitude + 1e-10)
|
|
new_levels[i] = np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0)
|
|
|
|
# Instant attack, slow decay
|
|
self._levels = np.maximum(new_levels, self._levels * DECAY)
|
|
bars = [int(round(level * STEPS)) for level in self._levels]
|
|
if bars != self._bars:
|
|
self._bars = bars
|
|
self.update()
|
|
|
|
# ---- painting ----
|
|
|
|
def _bar_color(self):
|
|
if self._mode == MODE_DIM:
|
|
if self._prefs is not None:
|
|
saved = self._prefs.get("color_visualizer_gray")
|
|
if saved is not None:
|
|
return theme.gray(saved)
|
|
# Light gray just darker than the panel: take alternateBase and
|
|
# drop its lightness ~12% so it tracks theme/scale changes.
|
|
base = QColor(self.palette().alternateBase().color())
|
|
h, s, l, a = base.getHslF()
|
|
base.setHslF(h, s, l * 0.88, a)
|
|
return base
|
|
return QColor(self.palette().highlight().color())
|
|
|
|
def paintEvent(self, event):
|
|
painter = QPainter(self)
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
# Rounded gray panel + 1px border, matching the transport button boxes.
|
|
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
|
panel = QPainterPath()
|
|
panel.addRoundedRect(rect, RADIUS, RADIUS)
|
|
painter.fillPath(panel, self.palette().alternateBase().color())
|
|
painter.setPen(QPen(self.palette().mid().color(), 1))
|
|
painter.drawPath(panel)
|
|
painter.setClipPath(panel) # keep bars inside the rounded shape
|
|
inset = 3 # bar field sits 3px in from every edge
|
|
avail_w = self.width() - 2 * inset
|
|
avail_h = self.height() - 2 * inset
|
|
baseline = self.height() - inset
|
|
bar_width = avail_w / BANDS
|
|
color = self._bar_color()
|
|
step_height = avail_h / STEPS
|
|
for i, bar in enumerate(self._bars):
|
|
if bar <= 0:
|
|
continue
|
|
x = inset + int(i * bar_width)
|
|
end = inset + int((i + 1) * bar_width)
|
|
gap = 1 if i < BANDS - 1 else 0 # 1px between bars, none at edge
|
|
w = max(1, end - x - gap)
|
|
bar_height = int(bar * step_height)
|
|
painter.fillRect(x, baseline - bar_height, w, bar_height, color)
|