Files
lintunes/lintunes/gui/visualizer.py
trav 0d8c9dd033 Visualizer: gray-mode color slider + mode persists across launches
- 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>
2026-07-02 16:52:59 -04:00

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)