v0.39.0: visualizer styles — radial dots and a laser, double-click to enlarge
Preferences → Appearance → Visualizer picks Bars (as before), Radial dots (the EQ in a circle: angle is frequency, radius is volume, moving dots leave light-gray ghosts that fade over a set time), or Laser (a mirror on a speaker membrane bouncing a beam onto a screen: simulated membrane modes, D·tan(2θ) reflection, dwell-weighted brightness). Double-clicking the panel opens it big; a click closes it.
This commit is contained in:
@@ -172,6 +172,23 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
for every add-to-playlist path. `theme.py` applies the palette (highlight
|
||||
colors, UI scales) from prefs.
|
||||
|
||||
- **`lintunes/gui/visualizer.py`** + **`visualizer_styles.py`** — the
|
||||
transport-bar visualizer (Round 78). The widget owns PCM intake, the timer,
|
||||
on/dim/off and the color; a *renderer* (`visualizer_style` pref: `bars`,
|
||||
`radial`, `laser`) owns the animation state and paints it into any rect, so
|
||||
the double-click `VisualizerWindow` paints the **same** renderer bigger
|
||||
rather than a copy. Since the first press of a double-click already cycled
|
||||
the brightness, `mouseDoubleClickEvent` puts it back. **Radial** is the EQ
|
||||
as polar dots (angle = 360 log bands, radius = level in `STEPS` rings), and
|
||||
a moving dot leaves light-gray ghosts. These are kept as a birth time per
|
||||
(degree, ring), so a spoke never holds more than `STEPS` and pooling to
|
||||
fewer spokes is one reshape. **Laser** simulates a mirror on a speaker
|
||||
membrane: the circular-membrane modes (split degenerate pairs are what turn
|
||||
lines into rotating loops) run as complex one-pole resonators, vectorized in
|
||||
`CHUNK`s. Tilt goes through D·tan(2θ) with an auto-gain, and the path is
|
||||
drawn brighter where it moves slower (dwell). It needs **every** sample, not
|
||||
the newest FFT window, hence the widget's capped `_pending` queue.
|
||||
|
||||
- **`lintunes/importers/itunes_importer.py`** — parses the iTunes XML plist.
|
||||
Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with
|
||||
case/Unicode-normalization fuzzy matching (macOS is case-insensitive + NFD vs
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.38.0"
|
||||
__version__ = "0.39.0"
|
||||
|
||||
@@ -25,6 +25,14 @@ _COLOR_ROWS = [
|
||||
]
|
||||
|
||||
|
||||
# visualizer_styles' style keys, as the radios name them.
|
||||
_VISUALIZER_STYLES = {
|
||||
"bars": "Bars",
|
||||
"radial": "Radial dots",
|
||||
"laser": "Laser",
|
||||
}
|
||||
|
||||
|
||||
class PreferencesDialog(QDialog):
|
||||
"""App preferences. Applies immediately — there is no OK/Cancel.
|
||||
|
||||
@@ -62,6 +70,7 @@ class PreferencesDialog(QDialog):
|
||||
for title, groups in (
|
||||
("General", general),
|
||||
("Appearance", [self._build_general_group(),
|
||||
self._build_visualizer_group(),
|
||||
self._build_colors_group()]),
|
||||
("Integrations", [self._build_lastfm_group(),
|
||||
self._build_acoustid_group()])):
|
||||
@@ -313,6 +322,66 @@ class PreferencesDialog(QDialog):
|
||||
|
||||
# ---- Colors ----
|
||||
|
||||
# ---- Visualizer ----
|
||||
|
||||
def _build_visualizer_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Visualizer")
|
||||
form = QFormLayout(group)
|
||||
|
||||
style_row = QHBoxLayout()
|
||||
self._viz_style = QButtonGroup(self)
|
||||
self._viz_radios = {}
|
||||
current = self._prefs.get("visualizer_style", "bars")
|
||||
if current not in _VISUALIZER_STYLES:
|
||||
current = "bars"
|
||||
for key, label in _VISUALIZER_STYLES.items():
|
||||
radio = QRadioButton(label)
|
||||
radio.setChecked(key == current) # before connect
|
||||
radio.toggled.connect(
|
||||
lambda on, k=key: on and self._on_viz_style(k))
|
||||
self._viz_style.addButton(radio)
|
||||
self._viz_radios[key] = radio
|
||||
style_row.addWidget(radio)
|
||||
style_row.addStretch()
|
||||
form.addRow("Style:", style_row)
|
||||
|
||||
# Tenths of a second, 0 (no trail) .. 3 s.
|
||||
fade_row = QHBoxLayout()
|
||||
self._fade_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._fade_slider.setRange(0, 30)
|
||||
self._fade_slider.setFixedWidth(160)
|
||||
fade = self._prefs.get("visualizer_ghost_fade", 1.0)
|
||||
self._fade_slider.setValue(int(round(float(fade) * 10)))
|
||||
self._fade_label = QLabel()
|
||||
self._show_fade(self._fade_slider.value())
|
||||
self._fade_slider.valueChanged.connect(self._on_fade_changed)
|
||||
fade_row.addWidget(self._fade_slider)
|
||||
fade_row.addSpacing(8)
|
||||
fade_row.addWidget(self._fade_label)
|
||||
fade_row.addStretch()
|
||||
form.addRow("Trail fade:", fade_row)
|
||||
self._fade_slider.setEnabled(current == "radial")
|
||||
self._fade_label.setEnabled(current == "radial")
|
||||
|
||||
note = QLabel("Double-click the visualizer to see it big.")
|
||||
note.setEnabled(False)
|
||||
form.addRow(note)
|
||||
return group
|
||||
|
||||
def _on_viz_style(self, key: str):
|
||||
self._fade_slider.setEnabled(key == "radial")
|
||||
self._fade_label.setEnabled(key == "radial")
|
||||
self._prefs.set("visualizer_style", key)
|
||||
|
||||
def _show_fade(self, tenths: int):
|
||||
self._fade_label.setText("No trail" if tenths == 0
|
||||
else f"{tenths / 10:.1f} s")
|
||||
|
||||
def _on_fade_changed(self, tenths: int):
|
||||
self._show_fade(tenths)
|
||||
self._prefs.set_live("visualizer_ghost_fade", tenths / 10)
|
||||
self._color_save_timer.start() # persist once the drag settles
|
||||
|
||||
def _build_colors_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Colors")
|
||||
form = QFormLayout(group)
|
||||
|
||||
+164
-84
@@ -1,28 +1,29 @@
|
||||
"""20-band graphic-EQ visualizer fed by the player's decoded PCM tee.
|
||||
"""The transport bar's 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.
|
||||
no extra sync buffering is needed — we just FFT the newest samples. What a
|
||||
frame turns into is the chosen style's business (``visualizer_styles``);
|
||||
this widget owns intake, timing, on/dim/off and color.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
from PyQt6.QtCore import Qt, QTimer, QRectF, pyqtSignal
|
||||
from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen
|
||||
from PyQt6.QtGui import QPainter, QColor
|
||||
from PyQt6.QtMultimedia import QAudioFormat
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.gui.icons import transport_icon
|
||||
from lintunes.gui.visualizer_styles import (
|
||||
BANDS, STYLES, STYLE_BARS, Frame, make_renderer)
|
||||
|
||||
|
||||
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
|
||||
ICON_SIZE = 40 # the cast glyph shown in place of bars while casting
|
||||
PENDING_S = 0.25 # most unconsumed audio kept for the laser
|
||||
|
||||
# Visualizer click cycles through these brightness modes in order.
|
||||
MODE_ON = "on"
|
||||
@@ -30,6 +31,8 @@ MODE_DIM = "dim"
|
||||
MODE_OFF = "off"
|
||||
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
|
||||
|
||||
_TOOLTIP = "Click to cycle: on / dim / off — double-click to enlarge"
|
||||
|
||||
_SAMPLE_DTYPES = {
|
||||
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
|
||||
QAudioFormat.SampleFormat.Int16: (np.int16, 0.0, 32768.0),
|
||||
@@ -38,11 +41,25 @@ _SAMPLE_DTYPES = {
|
||||
}
|
||||
|
||||
|
||||
class VisualizerWidget(QWidget):
|
||||
"""Animated spectrum bars; click to cycle brightness: on / dim / off.
|
||||
def _style(prefs) -> str:
|
||||
saved = prefs.get("visualizer_style") if prefs is not None else None
|
||||
return saved if saved in STYLES else STYLE_BARS
|
||||
|
||||
On = highlight-colored bars; dim = light-gray bars just darker than the
|
||||
panel; off = blank.
|
||||
|
||||
def _fade(prefs) -> float:
|
||||
saved = prefs.get("visualizer_ghost_fade", 1.0) if prefs is not None else 1.0
|
||||
try:
|
||||
return max(0.0, float(saved))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
|
||||
|
||||
class VisualizerWidget(QWidget):
|
||||
"""Animated visualizer; click to cycle brightness: on / dim / off, and
|
||||
double-click to open it big (``VisualizerWindow``).
|
||||
|
||||
On = highlight-colored; dim = light gray just darker than the panel;
|
||||
off = blank (the big window, if open, keeps going in color).
|
||||
|
||||
While casting there is no local PCM to analyze, so the panel doubles as the
|
||||
cast indicator: it shows the device glyph and a click stops casting.
|
||||
@@ -56,11 +73,14 @@ class VisualizerWidget(QWidget):
|
||||
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._mode_before_press = self._mode
|
||||
self._renderer = make_renderer(_style(prefs), _fade(prefs))
|
||||
self._samples = np.zeros(FFT_SIZE, dtype=np.float32)
|
||||
self._pending = [] # every sample since the last frame (the laser)
|
||||
self._pending_len = 0
|
||||
self._sample_rate = 44100
|
||||
self._window = np.hanning(FFT_SIZE).astype(np.float32)
|
||||
self._big = None # VisualizerWindow, made on first double-click
|
||||
# True while audio is coming out of something other than this machine
|
||||
# (a Chromecast), so there is no decoded PCM to draw.
|
||||
self._no_pcm = False
|
||||
@@ -68,7 +88,7 @@ class VisualizerWidget(QWidget):
|
||||
|
||||
self.setFixedWidth(140)
|
||||
self.setMinimumHeight(36)
|
||||
self.setToolTip("Click to cycle: on / dim / off")
|
||||
self.setToolTip(_TOOLTIP)
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(FRAME_MS)
|
||||
@@ -79,6 +99,29 @@ class VisualizerWidget(QWidget):
|
||||
player.track_changed.connect(self._on_track_changed)
|
||||
if hasattr(player, "sink_changed"):
|
||||
player.sink_changed.connect(self._on_sink_changed)
|
||||
if prefs is not None:
|
||||
prefs.changed.connect(self._on_prefs_changed)
|
||||
|
||||
# The bars' state, under the names the widget always had for it.
|
||||
@property
|
||||
def _bars(self):
|
||||
return getattr(self._renderer, "bars", [0] * BANDS)
|
||||
|
||||
@_bars.setter
|
||||
def _bars(self, bars):
|
||||
self._renderer.bars = bars
|
||||
|
||||
@property
|
||||
def _levels(self):
|
||||
return self._renderer.levels
|
||||
|
||||
def _on_prefs_changed(self):
|
||||
style = _style(self._prefs)
|
||||
if style != self._renderer.name:
|
||||
self._renderer = make_renderer(style, _fade(self._prefs))
|
||||
self._repaint()
|
||||
elif hasattr(self._renderer, "fade"):
|
||||
self._renderer.fade = _fade(self._prefs)
|
||||
|
||||
def _on_sink_changed(self, sink):
|
||||
"""Casting means no local decode, so there is nothing to draw. The
|
||||
@@ -87,16 +130,14 @@ class VisualizerWidget(QWidget):
|
||||
self._no_pcm = not getattr(sink, "provides_pcm", True)
|
||||
if self._no_pcm:
|
||||
self._device = getattr(sink, "name", "") or "a Chromecast"
|
||||
self._timer.stop()
|
||||
self._clear()
|
||||
self.setToolTip(f"Casting to {self._device} — click to stop")
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
else:
|
||||
self._device = ""
|
||||
self.setToolTip("Click to cycle: on / dim / off")
|
||||
self.setToolTip(_TOOLTIP)
|
||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
if self._mode != MODE_OFF and self._player.is_playing():
|
||||
self._timer.start()
|
||||
self._sync_timer()
|
||||
self.update()
|
||||
|
||||
# ---- audio intake ----
|
||||
@@ -115,36 +156,75 @@ class VisualizerWidget(QWidget):
|
||||
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
|
||||
self._feed(mono, fmt.sampleRate() or 44100)
|
||||
|
||||
def _feed(self, mono, rate):
|
||||
self._sample_rate = rate
|
||||
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
|
||||
# The laser needs every sample, once, not just the newest window.
|
||||
self._pending.append(mono)
|
||||
self._pending_len += len(mono)
|
||||
cap = int(PENDING_S * rate)
|
||||
while self._pending_len > cap and len(self._pending) > 1:
|
||||
self._pending_len -= len(self._pending.pop(0))
|
||||
|
||||
def _take_pending(self):
|
||||
pending = (np.concatenate(self._pending) if self._pending
|
||||
else np.zeros(0, dtype=np.float32))
|
||||
self._pending = []
|
||||
self._pending_len = 0
|
||||
return pending
|
||||
|
||||
# ---- state ----
|
||||
|
||||
def _big_visible(self):
|
||||
return self._big is not None and self._big.isVisible()
|
||||
|
||||
def _sync_timer(self):
|
||||
"""Animate while playing, unless nothing would show it."""
|
||||
run = (not self._no_pcm and self._player.is_playing()
|
||||
and (self._mode != MODE_OFF or self._big_visible()))
|
||||
if run:
|
||||
if not self._timer.isActive():
|
||||
self._take_pending() # stale audio from while we were idle
|
||||
self._timer.start()
|
||||
else:
|
||||
self._timer.stop()
|
||||
|
||||
def _on_playing_changed(self, playing):
|
||||
if self._no_pcm:
|
||||
return # nothing to animate; don't let play/pause restart the timer
|
||||
if playing:
|
||||
if self._mode != MODE_OFF:
|
||||
self._timer.start()
|
||||
else:
|
||||
self._timer.stop()
|
||||
if self._mode != MODE_OFF:
|
||||
self._clear()
|
||||
self._sync_timer()
|
||||
if not playing:
|
||||
self._clear()
|
||||
|
||||
def _on_track_changed(self, track):
|
||||
self._samples[:] = 0.0
|
||||
if track is None and self._mode != MODE_OFF:
|
||||
self._take_pending()
|
||||
if track is None:
|
||||
self._timer.stop()
|
||||
self._clear()
|
||||
|
||||
def _clear(self):
|
||||
self._levels[:] = 0.0
|
||||
self._bars = [0] * BANDS
|
||||
self._renderer.clear()
|
||||
self._repaint()
|
||||
|
||||
def _repaint(self):
|
||||
self.update()
|
||||
if self._big_visible():
|
||||
self._big.update()
|
||||
|
||||
def _set_mode(self, mode):
|
||||
self._mode = mode
|
||||
if self._prefs is not None:
|
||||
self._prefs.set("visualizer_mode", self._mode)
|
||||
if self._mode == MODE_OFF and not self._big_visible():
|
||||
self._clear() # off = blank, not the last frame held in place
|
||||
self._sync_timer()
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
@@ -155,15 +235,31 @@ class VisualizerWidget(QWidget):
|
||||
self.cast_stop_requested.emit()
|
||||
return
|
||||
# Cycle on -> dim -> off -> on.
|
||||
self._mode_before_press = self._mode
|
||||
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)
|
||||
self._set_mode(_MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)])
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
"""Qt sends press, release, *double-click*: the first press already
|
||||
cycled the brightness, so put it back before opening big. A brief
|
||||
flicker, in exchange for a single click that never waits to see
|
||||
whether a second one is coming."""
|
||||
if self._no_pcm:
|
||||
return # the first click already stopped casting
|
||||
self._set_mode(self._mode_before_press)
|
||||
self.open_big()
|
||||
|
||||
def open_big(self):
|
||||
from lintunes.gui.visualizer_window import VisualizerWindow
|
||||
if self._big is None:
|
||||
self._big = VisualizerWindow(self)
|
||||
self._big.open(self.screen())
|
||||
self._sync_timer()
|
||||
|
||||
def big_window_closed(self):
|
||||
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()
|
||||
self._clear()
|
||||
self._sync_timer()
|
||||
|
||||
# ---- frame computation ----
|
||||
|
||||
@@ -171,52 +267,51 @@ class VisualizerWidget(QWidget):
|
||||
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()
|
||||
frame = Frame(spectrum, freqs, self._sample_rate,
|
||||
self._take_pending(), FRAME_MS / 1000)
|
||||
if self._renderer.advance(frame):
|
||||
self._repaint()
|
||||
|
||||
# ---- 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 self._gray()
|
||||
return QColor(self.palette().highlight().color())
|
||||
|
||||
def _gray(self):
|
||||
"""Dim mode's color — and the radial's fading ghosts in any mode."""
|
||||
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
|
||||
|
||||
def panel_color(self):
|
||||
return self.palette().alternateBase().color()
|
||||
|
||||
def paint_renderer(self, painter, rect, big=False):
|
||||
"""Paint the current frame into ``rect`` — the panel's, or the big
|
||||
window's. The panel draws nothing when off; the big window draws
|
||||
regardless (in color), since opening it is asking to see it."""
|
||||
if self._no_pcm or (self._mode == MODE_OFF and not big):
|
||||
return
|
||||
self._renderer.paint(painter, rect, self._bar_color(), self._gray())
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
# A rounded panel debossed as far as the transport buttons stand out:
|
||||
# it shows things, it isn't pressed.
|
||||
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||
panel = theme.paint_deboss(painter, rect,
|
||||
self.palette().alternateBase().color(), RADIUS)
|
||||
painter.setClipPath(panel) # keep bars inside the rounded shape
|
||||
panel = theme.paint_deboss(painter, rect, self.panel_color(), RADIUS)
|
||||
painter.setClipPath(panel) # keep the drawing inside the rounded shape
|
||||
if self._no_pcm:
|
||||
# Casting: the audio never passes through this machine, so show
|
||||
# where it went instead of pretending to analyze silence.
|
||||
@@ -228,19 +323,4 @@ class VisualizerWidget(QWidget):
|
||||
(self.height() - ICON_SIZE) // 2, glyph)
|
||||
painter.end()
|
||||
return
|
||||
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)
|
||||
self.paint_renderer(painter, QRectF(self.rect()))
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"""The visualizer's styles: what a frame of audio is turned into.
|
||||
|
||||
A renderer owns its own animation state and knows how to paint it into any
|
||||
rect, so the transport-bar panel and the enlarged ``VisualizerWindow`` paint
|
||||
the very same state at two sizes. ``VisualizerWidget`` owns everything else:
|
||||
audio intake, the timer, on/dim/off, the color. Pure numpy + QPainter.
|
||||
|
||||
- ``BarsRenderer`` — the original 20-band graphic EQ.
|
||||
- ``RadialRenderer`` — that EQ wrapped into a circle as dots: angle is
|
||||
frequency, radius is volume, and a dot that moves leaves fading ghosts.
|
||||
- ``LaserRenderer`` — a laser bounced off a mirror glued to a membrane
|
||||
stretched over a speaker, projected onto a screen (Tame Impala's rig).
|
||||
"""
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from PyQt6.QtCore import Qt, QPointF, QLineF
|
||||
from PyQt6.QtGui import QColor, QPen, QPolygonF
|
||||
|
||||
|
||||
STYLE_BARS = "bars"
|
||||
STYLE_RADIAL = "radial"
|
||||
STYLE_LASER = "laser"
|
||||
STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER)
|
||||
|
||||
BANDS = 20
|
||||
STEPS = 20 # vertical granularity per bar (and rings per radial spoke)
|
||||
DECAY = 0.82 # slow decay; attack is instant
|
||||
FLOOR_DB = -55.0
|
||||
LOW_HZ = 40.0
|
||||
INSET = 3 # the bar field sits 3px in from every edge
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
"""One timer tick's worth of audio, handed to ``advance``."""
|
||||
spectrum: np.ndarray # |rfft| of the newest window, full-scale sine ~1.0
|
||||
freqs: np.ndarray # the frequency of each spectrum bin
|
||||
rate: int # sample rate
|
||||
pending: np.ndarray # every mono sample that arrived since last tick
|
||||
dt: float # seconds since the last tick
|
||||
|
||||
|
||||
def band_edges(count: int, rate: float) -> np.ndarray:
|
||||
"""``count`` log-spaced bands from 40 Hz to 16 kHz (or just under
|
||||
Nyquist): log because that's how pitch is heard — linear would spend
|
||||
most of the bands above anything but cymbals."""
|
||||
top = min(16000.0, rate / 2 * 0.95)
|
||||
return np.geomspace(LOW_HZ, max(top, 2 * LOW_HZ), count + 1)
|
||||
|
||||
|
||||
def band_levels(spectrum: np.ndarray, freqs: np.ndarray,
|
||||
edges: np.ndarray) -> np.ndarray:
|
||||
"""Each band's loudest bin as a 0..1 level (0 = FLOOR_DB or quieter).
|
||||
|
||||
A band too narrow to hold a bin of its own (the bottom degrees of the
|
||||
radial, at 21 Hz a bin) takes the bin nearest its center rather than
|
||||
reading as silence."""
|
||||
count = len(edges) - 1
|
||||
starts = np.searchsorted(freqs, edges).clip(0, len(spectrum) - 1)
|
||||
magnitude = np.maximum.reduceat(spectrum, starts)[:count]
|
||||
empty = starts[1:] <= starts[:-1]
|
||||
if empty.any():
|
||||
centers = np.sqrt(edges[:-1] * edges[1:])[empty]
|
||||
step = freqs[1] - freqs[0] if len(freqs) > 1 else 1.0
|
||||
nearest = np.rint(centers / step).astype(int).clip(0, len(spectrum) - 1)
|
||||
magnitude[empty] = spectrum[nearest]
|
||||
db = 20.0 * np.log10(magnitude + 1e-10)
|
||||
return np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0)
|
||||
|
||||
|
||||
def _faded(color: QColor, alpha: float) -> QColor:
|
||||
faded = QColor(color)
|
||||
faded.setAlphaF(max(0.0, min(1.0, alpha)) * color.alphaF())
|
||||
return faded
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bars
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class BarsRenderer:
|
||||
name = STYLE_BARS
|
||||
|
||||
def __init__(self):
|
||||
self.levels = np.zeros(BANDS)
|
||||
self.bars = [0] * BANDS # quantized 0..STEPS
|
||||
|
||||
def clear(self):
|
||||
self.levels[:] = 0.0
|
||||
self.bars = [0] * BANDS
|
||||
|
||||
def advance(self, frame: Frame) -> bool:
|
||||
new_levels = band_levels(frame.spectrum, frame.freqs,
|
||||
band_edges(BANDS, frame.rate))
|
||||
# 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:
|
||||
return False
|
||||
self.bars = bars
|
||||
return True
|
||||
|
||||
def paint(self, painter, rect, color: QColor, light: QColor = None):
|
||||
avail_w = rect.width() - 2 * INSET
|
||||
avail_h = rect.height() - 2 * INSET
|
||||
left = int(rect.x()) + INSET
|
||||
baseline = int(rect.y() + rect.height()) - INSET
|
||||
bar_width = avail_w / BANDS
|
||||
step_height = avail_h / STEPS
|
||||
for i, bar in enumerate(self.bars):
|
||||
if bar <= 0:
|
||||
continue
|
||||
x = left + int(i * bar_width)
|
||||
end = left + 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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Radial dots
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
RADIAL_BANDS = 360 # one per degree; painting pools them to fit
|
||||
DOT_PITCH = 3.0 # px between neighbouring dots on the rim
|
||||
HUB = 0.22 # the silent middle, as a share of the radius
|
||||
GHOST_ALPHA = 1.0 # a fresh ghost is the full light gray, then fades
|
||||
GHOST_BUCKETS = 8 # one drawPoints() per bucket of similar alpha
|
||||
_DIVISORS = [d for d in range(1, RADIAL_BANDS + 1) if RADIAL_BANDS % d == 0]
|
||||
|
||||
|
||||
def spoke_count(radius: float) -> int:
|
||||
"""The most spokes (a divisor of 360) that still leave DOT_PITCH px
|
||||
between dots around the rim — ~45 in the transport panel, all 360 in
|
||||
the big window."""
|
||||
fits = 2 * math.pi * radius / DOT_PITCH
|
||||
return max([d for d in _DIVISORS if d <= fits] or [1])
|
||||
|
||||
|
||||
class RadialRenderer:
|
||||
"""The graphic EQ as a polar dot plot.
|
||||
|
||||
Frequency is the angle — 360 log-spaced bands, bass at 12 o'clock
|
||||
running clockwise — and volume is the radius, quantized to STEPS rings
|
||||
so the dots line up like the bars' steps. A dot that moves leaves a
|
||||
ghost on every ring it passed; a ghost fades over ``fade`` seconds.
|
||||
|
||||
Ghosts are kept as a birth time per (degree, ring) rather than a list:
|
||||
a revisit simply refreshes it, so a spoke can never hold more than
|
||||
STEPS of them, and pooling 360 degrees down to fewer spokes is one
|
||||
reshape + max.
|
||||
"""
|
||||
|
||||
name = STYLE_RADIAL
|
||||
|
||||
def __init__(self, fade: float = 1.0):
|
||||
self.fade = fade
|
||||
self.levels = np.zeros(RADIAL_BANDS)
|
||||
self.rings = np.zeros(RADIAL_BANDS, dtype=int) # 0 = silent
|
||||
self.ghosts = np.full((RADIAL_BANDS, STEPS + 1), -np.inf)
|
||||
self.clock = 0.0
|
||||
|
||||
def clear(self):
|
||||
self.levels[:] = 0.0
|
||||
self.rings[:] = 0
|
||||
self.ghosts[:] = -np.inf
|
||||
|
||||
def ghost_alpha(self) -> np.ndarray:
|
||||
"""(degree, ring) → 0..1 strength of the ghost there."""
|
||||
if self.fade <= 0:
|
||||
return np.zeros_like(self.ghosts)
|
||||
age = self.clock - self.ghosts
|
||||
return np.clip(1.0 - age / self.fade, 0.0, 1.0)
|
||||
|
||||
def advance(self, frame: Frame) -> bool:
|
||||
self.clock += frame.dt
|
||||
new_levels = band_levels(frame.spectrum, frame.freqs,
|
||||
band_edges(RADIAL_BANDS, frame.rate))
|
||||
self.levels = np.maximum(new_levels, self.levels * DECAY)
|
||||
rings = np.rint(self.levels * STEPS).astype(int)
|
||||
moved = rings != self.rings
|
||||
if moved.any() and self.fade > 0:
|
||||
# A ghost where the dot was. Falling (the slow decay) also leaves
|
||||
# one on every ring it skipped, so the fall reads as a trail; a
|
||||
# rise is an instant attack — it leaves only where it came from,
|
||||
# or every hit would paint a bar.
|
||||
old, new = self.rings[moved], rings[moved]
|
||||
lo = np.where(new < old, new, old)[:, None]
|
||||
hi = old[:, None]
|
||||
k = np.arange(STEPS + 1)[None, :]
|
||||
trail = (k >= lo) & (k <= hi) & (k != new[:, None]) & (k > 0)
|
||||
block = self.ghosts[moved]
|
||||
block[trail] = self.clock
|
||||
self.ghosts[moved] = block
|
||||
self.rings = rings
|
||||
if self.fade <= 0:
|
||||
self.ghosts[:] = -np.inf
|
||||
else:
|
||||
# Forget the long-faded, so "anything still fading?" is cheap.
|
||||
self.ghosts[self.clock - self.ghosts >= self.fade] = -np.inf
|
||||
return bool(moved.any() or np.isfinite(self.ghosts).any())
|
||||
|
||||
def pooled(self, spokes: int):
|
||||
"""The state at ``spokes`` spokes: each spoke's ring, and each
|
||||
(spoke, ring) ghost strength — the loudest of the degrees it covers."""
|
||||
group = RADIAL_BANDS // spokes
|
||||
rings = self.rings.reshape(spokes, group).max(axis=1)
|
||||
alpha = self.ghost_alpha().reshape(spokes, group, STEPS + 1).max(axis=1)
|
||||
alpha[np.arange(spokes), rings] = 0.0 # under the dot itself
|
||||
alpha[:, 0] = 0.0 # silence isn't drawn
|
||||
return rings, alpha
|
||||
|
||||
def paint(self, painter, rect, color: QColor, light: QColor):
|
||||
cx, cy = rect.center().x(), rect.center().y()
|
||||
radius = min(rect.width(), rect.height()) / 2 - INSET
|
||||
if radius <= 2:
|
||||
return
|
||||
spokes = spoke_count(radius)
|
||||
rings, alpha = self.pooled(spokes)
|
||||
hub = radius * HUB
|
||||
ring_step = (radius - hub) / STEPS
|
||||
dot = max(1.8, min(2 * math.pi * radius / spokes, ring_step * 2.5) * 0.8)
|
||||
angles = 2 * math.pi * (np.arange(spokes) + 0.5) / spokes
|
||||
sin, cos = np.sin(angles), np.cos(angles)
|
||||
|
||||
def points(spoke_idx, ring_idx):
|
||||
r = hub + ring_idx * ring_step - dot / 2
|
||||
xs = cx + r * sin[spoke_idx]
|
||||
ys = cy - r * cos[spoke_idx]
|
||||
return [QPointF(x, y) for x, y in zip(xs, ys)]
|
||||
|
||||
pen = QPen()
|
||||
pen.setWidthF(dot)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
# Ghosts first, faintest first, so the newer ones sit on top.
|
||||
bucket = np.ceil(alpha * GHOST_BUCKETS).astype(int)
|
||||
for level in range(1, GHOST_BUCKETS + 1):
|
||||
spoke_idx, ring_idx = np.nonzero(bucket == level)
|
||||
if len(spoke_idx) == 0:
|
||||
continue
|
||||
pen.setColor(_faded(light, GHOST_ALPHA * level / GHOST_BUCKETS))
|
||||
painter.setPen(pen)
|
||||
painter.drawPoints(points(spoke_idx, ring_idx))
|
||||
live = np.nonzero(rings > 0)[0]
|
||||
if len(live):
|
||||
pen.setColor(color)
|
||||
painter.setPen(pen)
|
||||
painter.drawPoints(points(live, rings[live]))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Laser
|
||||
# --------------------------------------------------------------------------
|
||||
#
|
||||
# The rig: a small mirror glued off-center on a rubber membrane stretched
|
||||
# over a speaker; a laser hits the mirror and the reflection lands on a big
|
||||
# screen. What moves the beam is the *tilt* of the mirror, and a membrane
|
||||
# doesn't just pump in and out — it vibrates in modes, each with its own
|
||||
# resonance and its own shape. The mirror's tilt is the membrane's slope
|
||||
# where the mirror sits, so every mode pushes the beam along one fixed
|
||||
# direction on the screen, at a strength set by how near the music is to
|
||||
# that mode's resonance.
|
||||
#
|
||||
# The modes of a circular membrane are J_m(j_mn·r)·cos(mθ), resonating at
|
||||
# j_mn/j_01 times the fundamental. The m > 0 modes come in pairs at right
|
||||
# angles that a perfect drum tunes identically; a hand-stretched one never
|
||||
# is, so they split by a percent or two, the two axes drift in and out of
|
||||
# phase on a held note, and that is where the slowly turning ellipses and
|
||||
# Lissajous loops come from. A perfectly even push wouldn't excite m > 0
|
||||
# modes at all; the mirror's own mass, off-center, is what couples them.
|
||||
#
|
||||
# Reflection doubles the angle: tilt θ turns the beam 2θ, so the spot lands
|
||||
# at D·tan(2θ) — a faint pincushion stretch at big swings, kept in. The
|
||||
# spot moves far faster than an eye or a camera follows, so what anyone
|
||||
# sees is the whole path traced in one glance, brightest where the beam
|
||||
# moves slowest (it dwells there), with the haze and the screen's scatter
|
||||
# blooming around it.
|
||||
|
||||
MODE_ZEROS = ( # (m, j_mn) — Bessel zeros
|
||||
(0, 2.40483), (1, 3.83171), (2, 5.13562), (0, 5.52008), (3, 6.38016),
|
||||
)
|
||||
FUNDAMENTAL_HZ = 90.0 # a drum-sized membrane: kick-drum territory
|
||||
MODE_Q = 8.0 # rubber is lossy
|
||||
SPLIT = (0.0, 0.016, 0.011, 0.0, 0.008) # the uneven stretch, per mode pair
|
||||
MIRROR_AT = (0.55, 0.6) # the mirror's spot: radius (of 1), angle (rad)
|
||||
SIM_RATE = 11025.0 # the membrane barely answers above a few hundred Hz
|
||||
BEAM_MAX = math.radians(30) # the screen's half-height, as a beam angle
|
||||
FILL = 0.85 # auto-gain aims the loudest swings at this much of it
|
||||
GAIN_RELEASE_S = 0.3 # how slowly the auto-gain turns back up after a peak
|
||||
PEAK_FLOOR = 0.02 # silence stays a still dot, not amplified hiss
|
||||
PERSIST = (1.0, 0.45, 0.2) # this frame's path, then fading older ones
|
||||
CHUNK = 256 # samples per vectorized resonator step
|
||||
|
||||
|
||||
def _bessel_j(m: int, x: float) -> float:
|
||||
return sum((-1) ** k / (math.factorial(k) * math.factorial(k + m))
|
||||
* (x / 2) ** (2 * k + m) for k in range(30))
|
||||
|
||||
|
||||
def _mode_slope(m: int, j: float, phase: float) -> tuple:
|
||||
"""d/dx, d/dy of J_m(j·r)·cos(m(θ − phase)) at the mirror."""
|
||||
r0, a0 = MIRROR_AT
|
||||
x0, y0 = r0 * math.cos(a0), r0 * math.sin(a0)
|
||||
|
||||
def shape(x, y):
|
||||
return (_bessel_j(m, j * math.hypot(x, y))
|
||||
* math.cos(m * (math.atan2(y, x) - phase)))
|
||||
|
||||
h = 1e-4
|
||||
return ((shape(x0 + h, y0) - shape(x0 - h, y0)) / (2 * h),
|
||||
(shape(x0, y0 + h) - shape(x0, y0 - h)) / (2 * h))
|
||||
|
||||
|
||||
def _membrane_modes():
|
||||
"""[(frequency Hz, weight, slope x, slope y)] for every resonator."""
|
||||
modes = []
|
||||
j01 = MODE_ZEROS[0][1]
|
||||
for (m, j), split in zip(MODE_ZEROS, SPLIT):
|
||||
ratio = j / j01
|
||||
# Pressure drives a mode in proportion to how evenly it moves (m = 0
|
||||
# fully; the rest only through the mirror's off-center mass), and a
|
||||
# stiffer, higher mode moves less for the same push (1/ratio²).
|
||||
weight = (1.0 if m == 0 else 0.8) / ratio ** 2
|
||||
orientations = [(0.0, 1.0)] if m == 0 else \
|
||||
[(0.0, 1.0), (math.pi / (2 * m), 1.0 + split)]
|
||||
for phase, detune in orientations:
|
||||
sx, sy = _mode_slope(m, j, phase)
|
||||
modes.append((FUNDAMENTAL_HZ * ratio * detune, weight, sx, sy))
|
||||
return modes
|
||||
|
||||
|
||||
MODES = _membrane_modes()
|
||||
|
||||
|
||||
class LaserRenderer:
|
||||
name = STYLE_LASER
|
||||
|
||||
def __init__(self):
|
||||
self.levels = np.zeros(1) # the widget's shared clear() touches it
|
||||
self._rate = None
|
||||
self._carry = np.zeros(0, dtype=np.float64)
|
||||
self._peak = PEAK_FLOOR
|
||||
self.paths = [] # newest first: (n, 2) arrays in -1..1 screen units
|
||||
self.clear()
|
||||
|
||||
def clear(self):
|
||||
self._state = np.zeros(len(MODES), dtype=np.complex128)
|
||||
self._carry = np.zeros(0, dtype=np.float64)
|
||||
self._peak = PEAK_FLOOR
|
||||
self.paths = [np.zeros((1, 2))]
|
||||
|
||||
# ---- the membrane ----
|
||||
|
||||
def _setup(self, rate: int):
|
||||
self._rate = rate
|
||||
self._step = max(1, int(round(rate / SIM_RATE)))
|
||||
fs = rate / self._step
|
||||
freq = np.array([f for f, _w, _x, _y in MODES])
|
||||
# Each resonator is the imaginary part of a complex one-pole filter:
|
||||
# its impulse response is e^(−αn)·sin(ωn), a damped ring at f.
|
||||
self._alpha = math.pi * freq / (MODE_Q * fs)
|
||||
self._pole = np.exp(-self._alpha + 2j * math.pi * freq / fs)
|
||||
weights = np.array([w for _f, w, _x, _y in MODES])
|
||||
slopes = np.array([(x, y) for _f, _w, x, y in MODES])
|
||||
# α normalizes each resonator to unit gain at its own resonance.
|
||||
self._mix = (weights * self._alpha)[:, None] * slopes # (modes, 2)
|
||||
self._state[:] = 0
|
||||
self._carry = np.zeros(0, dtype=np.float64)
|
||||
|
||||
def drive(self, samples: np.ndarray, rate: int) -> np.ndarray:
|
||||
"""Feed audio through the membrane; returns the mirror's tilt, (n, 2),
|
||||
one row per simulated step. Continuous across calls, so splitting the
|
||||
same audio into different chunks gives the same tilt."""
|
||||
if rate != self._rate:
|
||||
self._setup(rate)
|
||||
data = np.concatenate([self._carry, np.asarray(samples, np.float64)])
|
||||
usable = len(data) // self._step * self._step
|
||||
self._carry = data[usable:]
|
||||
# Averaging blocks is the downsample, and a little of the mirror's
|
||||
# inertia: it can't follow anything that fast anyway.
|
||||
x = data[:usable].reshape(-1, self._step).mean(axis=1)
|
||||
out = []
|
||||
for start in range(0, len(x), CHUNK):
|
||||
chunk = x[start:start + CHUNK]
|
||||
n = np.arange(len(chunk))
|
||||
# y[n] = p^n · (p·y[−1] + Σ_{k≤n} x[k]·p^−k), all modes at once.
|
||||
# CHUNK keeps p^−k far from overflowing.
|
||||
powers = self._pole[:, None] ** n[None, :]
|
||||
acc = np.cumsum(chunk[None, :] / powers, axis=1)
|
||||
y = powers * (self._pole[:, None] * self._state[:, None] + acc)
|
||||
self._state = y[:, -1]
|
||||
out.append(y.imag.T @ self._mix)
|
||||
if not out:
|
||||
return np.zeros((0, 2))
|
||||
return np.concatenate(out)
|
||||
|
||||
# ---- the screen ----
|
||||
|
||||
def project(self, tilt: np.ndarray, dt: float) -> np.ndarray:
|
||||
"""Tilt → spot on the screen, -1..1 each way (y up)."""
|
||||
peak = float(np.linalg.norm(tilt, axis=1).max()) if len(tilt) else 0.0
|
||||
self._peak = max(peak, self._peak * math.exp(-dt / GAIN_RELEASE_S),
|
||||
PEAK_FLOOR)
|
||||
# Pick the gain that puts the loudest swing at FILL of the screen…
|
||||
gain = math.atan(FILL * math.tan(BEAM_MAX)) / (2 * self._peak)
|
||||
# …then reflection doubles the mirror's angle, and a flat screen
|
||||
# spreads big angles out: D·tan(2θ).
|
||||
return np.tan(2 * gain * tilt) / math.tan(BEAM_MAX)
|
||||
|
||||
def advance(self, frame: Frame) -> bool:
|
||||
tilt = self.drive(frame.pending, frame.rate)
|
||||
if len(tilt) == 0:
|
||||
return False
|
||||
path = self.project(tilt, frame.dt)
|
||||
# Join this frame's path onto the last one's end, so the line is
|
||||
# unbroken where two frames meet.
|
||||
path = np.vstack([self.paths[0][-1:], path])
|
||||
self.paths = [path] + self.paths[:len(PERSIST) - 1]
|
||||
return True
|
||||
|
||||
def spot(self) -> np.ndarray:
|
||||
return self.paths[0][-1]
|
||||
|
||||
def paint(self, painter, rect, color: QColor, light: QColor = None):
|
||||
cx, cy = rect.center().x(), rect.center().y()
|
||||
half = min(rect.width(), rect.height()) / 2 - INSET
|
||||
if half <= 2:
|
||||
return
|
||||
s = max(1.0, half / 90) # stroke scale: the big window glows wider
|
||||
pen = QPen()
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
# Oldest first, so the newest trace sits on top.
|
||||
for path, persist in reversed(list(zip(self.paths, PERSIST))):
|
||||
if len(path) < 2:
|
||||
continue
|
||||
# A frame is ~180 steps; the panel is ~60 px across, so most
|
||||
# would be sub-pixel segments that cost a Qt object each.
|
||||
step = max(1, int(len(path) / (2 * half)))
|
||||
path = np.vstack([path[::step], path[-1:]])
|
||||
xy = np.column_stack([cx + path[:, 0] * half,
|
||||
cy - path[:, 1] * half])
|
||||
# The haze and the screen's scatter: one even bloom along the
|
||||
# whole path, a single polyline so joints don't stack up alpha.
|
||||
# Only around the newest trace — the older ones are afterimage.
|
||||
if persist == PERSIST[0]:
|
||||
line = QPolygonF([QPointF(x, y) for x, y in xy])
|
||||
for width, strength in ((7 * s, 0.07), (3 * s, 0.18)):
|
||||
pen.setWidthF(width)
|
||||
pen.setColor(_faded(color, strength))
|
||||
painter.setPen(pen)
|
||||
painter.drawPolyline(line)
|
||||
# The core. Dwell: the beam lights a stretch of screen for as
|
||||
# long as it stays there, so slow stretches are bright and fast
|
||||
# sweeps faint.
|
||||
speed = np.linalg.norm(np.diff(xy, axis=0), axis=1) / step
|
||||
bright = np.clip(0.04 * half / (speed + 1e-6), 0.15, 1.0)
|
||||
bucket = np.ceil(bright * 6).astype(int)
|
||||
pen.setWidthF(1.2 * s)
|
||||
for level in range(1, 7):
|
||||
idx = np.nonzero(bucket == level)[0]
|
||||
if len(idx) == 0:
|
||||
continue
|
||||
pen.setColor(_faded(color, persist * level / 6))
|
||||
painter.setPen(pen)
|
||||
painter.drawLines([QLineF(*xy[i], *xy[i + 1]) for i in idx])
|
||||
# The beam itself, wherever it is right now.
|
||||
x, y = self.spot()
|
||||
head = QPointF(cx + x * half, cy - y * half)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(_faded(color, 0.25))
|
||||
painter.drawEllipse(head, 3.5 * s, 3.5 * s)
|
||||
painter.setBrush(color)
|
||||
painter.drawEllipse(head, 1.4 * s, 1.4 * s)
|
||||
|
||||
|
||||
def make_renderer(style: str, fade: float = 1.0):
|
||||
if style == STYLE_RADIAL:
|
||||
return RadialRenderer(fade)
|
||||
if style == STYLE_LASER:
|
||||
return LaserRenderer()
|
||||
return BarsRenderer()
|
||||
@@ -0,0 +1,59 @@
|
||||
from PyQt6.QtWidgets import QWidget, QApplication
|
||||
from PyQt6.QtCore import Qt, QSize, QRectF
|
||||
from PyQt6.QtGui import QPainter
|
||||
|
||||
from lintunes.gui.art_window import fit_size
|
||||
|
||||
# The window takes a projection screen's shape.
|
||||
SCREEN_SHAPE = QSize(16, 10)
|
||||
|
||||
|
||||
class VisualizerWindow(QWidget):
|
||||
"""The transport-bar visualizer, enlarged: opened by double-clicking the
|
||||
panel, closed by a click (or Esc). It holds no state of its own — it
|
||||
paints the panel's renderer, so both always show the same frame."""
|
||||
|
||||
def __init__(self, visualizer):
|
||||
super().__init__(visualizer, Qt.WindowType.Window)
|
||||
self._visualizer = visualizer
|
||||
self.setWindowTitle("Visualizer")
|
||||
self.setMinimumSize(160, 100)
|
||||
self.setToolTip("Click to close")
|
||||
|
||||
def open(self, screen=None):
|
||||
screen = screen or self.screen() or QApplication.primaryScreen()
|
||||
if screen is not None:
|
||||
geo = screen.availableGeometry()
|
||||
size = fit_size(SCREEN_SHAPE, geo.size())
|
||||
margins = self.contentsMargins() # the app-drawn title bar
|
||||
self.resize(size.width(), size.height() + margins.top())
|
||||
self.move(geo.x() + (geo.width() - size.width()) // 2,
|
||||
geo.y() + (geo.height() - size.height()) // 2)
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if (event.button() == Qt.MouseButton.LeftButton
|
||||
and self.contentsRect().contains(event.position().toPoint())):
|
||||
self.close()
|
||||
return
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == Qt.Key.Key_Escape:
|
||||
self.close()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def hideEvent(self, event):
|
||||
super().hideEvent(event)
|
||||
self._visualizer.big_window_closed()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
area = self.contentsRect() # below our own title bar (titlebar.py)
|
||||
painter.fillRect(area, self._visualizer.panel_color())
|
||||
painter.setClipRect(area)
|
||||
self._visualizer.paint_renderer(painter, QRectF(area), big=True)
|
||||
@@ -12,6 +12,8 @@ DEFAULTS = {
|
||||
# default, "Family"=use app
|
||||
"volume": 0.8, # logical 0..1 master output volume
|
||||
"visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle
|
||||
"visualizer_style": "bars", # bars | radial | laser (visualizer_styles)
|
||||
"visualizer_ghost_fade": 1.0, # seconds a radial dot's trail takes to fade
|
||||
# Per-area grayscale overrides (0=black .. 255=white). None = inherit the
|
||||
# current/default look for that surface, so nothing changes until the user
|
||||
# drags a slider in Preferences.
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Round 78: visualizer styles — bars, radial dots, laser — and the big window.
|
||||
|
||||
1. ``visualizer_style`` picks the renderer, live; nonsense falls back to bars.
|
||||
2. ``band_levels`` is the bars' old analysis, and fills too-narrow bands.
|
||||
3. Radial: frequency is the angle, volume the radius, moves leave ghosts.
|
||||
4. Laser: a mirror on a membrane — resonance, continuity, stays on screen.
|
||||
5. Double-click opens the big window without changing the brightness.
|
||||
6. The Preferences page.
|
||||
"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, QRectF
|
||||
from PyQt6.QtGui import QImage, QPainter, QColor
|
||||
|
||||
from lintunes.preferences import Preferences
|
||||
from lintunes.gui.visualizer import VisualizerWidget, MODE_ON, MODE_DIM, MODE_OFF
|
||||
from lintunes.gui import visualizer_styles as vs
|
||||
|
||||
|
||||
RATE = 44100
|
||||
|
||||
|
||||
class _FakePlayer(QObject):
|
||||
audio_buffer = pyqtSignal(object)
|
||||
playing_changed = pyqtSignal(bool)
|
||||
track_changed = pyqtSignal(object)
|
||||
|
||||
def __init__(self, playing=False):
|
||||
super().__init__()
|
||||
self._playing = playing
|
||||
|
||||
def is_playing(self):
|
||||
return self._playing
|
||||
|
||||
|
||||
def _sine(hz, seconds=0.1, amp=0.5):
|
||||
t = np.arange(int(RATE * seconds)) / RATE
|
||||
return (amp * np.sin(2 * np.pi * hz * t)).astype(np.float32)
|
||||
|
||||
|
||||
def _frame(window, pending=None, dt=0.016):
|
||||
spectrum = np.abs(np.fft.rfft(window * np.hanning(len(window)))) / (len(window) / 4)
|
||||
freqs = np.fft.rfftfreq(len(window), d=1.0 / RATE)
|
||||
if pending is None:
|
||||
pending = np.zeros(0, np.float32)
|
||||
return vs.Frame(spectrum, freqs, RATE, pending, dt)
|
||||
|
||||
|
||||
def _paint(renderer, w=140, h=62):
|
||||
img = QImage(w, h, QImage.Format.Format_ARGB32)
|
||||
img.fill(QColor("white"))
|
||||
painter = QPainter(img)
|
||||
renderer.paint(painter, QRectF(0, 0, w, h), QColor("blue"), QColor("gray"))
|
||||
painter.end()
|
||||
return img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. The style preference
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestStylePreference:
|
||||
def test_default_is_bars(self, qapp, tmp_path):
|
||||
vis = VisualizerWidget(_FakePlayer(), Preferences(tmp_path))
|
||||
assert isinstance(vis._renderer, vs.BarsRenderer)
|
||||
|
||||
@pytest.mark.parametrize("style, cls", [
|
||||
("radial", vs.RadialRenderer), ("laser", vs.LaserRenderer)])
|
||||
def test_saved_style_is_used(self, qapp, tmp_path, style, cls):
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set("visualizer_style", style)
|
||||
assert isinstance(VisualizerWidget(_FakePlayer(), prefs)._renderer, cls)
|
||||
|
||||
def test_nonsense_falls_back_to_bars(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set("visualizer_style", "oscilloscope") # hand-edited file
|
||||
vis = VisualizerWidget(_FakePlayer(), prefs)
|
||||
assert isinstance(vis._renderer, vs.BarsRenderer)
|
||||
|
||||
def test_changing_the_pref_swaps_live(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
vis = VisualizerWidget(_FakePlayer(), prefs)
|
||||
prefs.set("visualizer_style", "laser")
|
||||
assert isinstance(vis._renderer, vs.LaserRenderer)
|
||||
|
||||
def test_an_unrelated_pref_keeps_the_renderer(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set("visualizer_style", "radial")
|
||||
vis = VisualizerWidget(_FakePlayer(), prefs)
|
||||
before = vis._renderer
|
||||
prefs.set("visualizer_ghost_fade", 2.5)
|
||||
assert vis._renderer is before # state survives
|
||||
assert before.fade == 2.5
|
||||
|
||||
def test_every_style_paints_without_error(self, qapp):
|
||||
for style in vs.STYLES:
|
||||
renderer = vs.make_renderer(style)
|
||||
window = _sine(1000, 2048 / RATE)
|
||||
for _ in range(5):
|
||||
renderer.advance(_frame(window, _sine(90, 0.016)))
|
||||
for size in ((140, 62), (1200, 750), (4, 4)):
|
||||
_paint(renderer, *size)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Shared analysis
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestBandLevels:
|
||||
def test_a_sine_lights_its_own_band(self):
|
||||
frame = _frame(_sine(1000, 2048 / RATE))
|
||||
edges = vs.band_edges(vs.BANDS, RATE)
|
||||
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
|
||||
band = np.searchsorted(edges, 1000) - 1
|
||||
assert levels.argmax() == band
|
||||
|
||||
def test_narrow_bass_bands_are_not_silent(self):
|
||||
# At 360 bands the bottom ones are narrower than an FFT bin.
|
||||
frame = _frame(_sine(45, 2048 / RATE))
|
||||
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
|
||||
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
|
||||
assert levels[:5].min() > 0.5
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. Radial
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestRadial:
|
||||
def test_frequency_is_the_angle(self):
|
||||
r = vs.RadialRenderer()
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE)))
|
||||
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
|
||||
degree = np.searchsorted(edges, 1000) - 1
|
||||
assert abs(int(r.rings.argmax()) - degree) <= 2
|
||||
|
||||
def test_volume_is_the_radius(self):
|
||||
loud, quiet = vs.RadialRenderer(), vs.RadialRenderer()
|
||||
loud.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
quiet.advance(_frame(_sine(1000, 2048 / RATE, amp=0.01)))
|
||||
assert loud.rings.max() > quiet.rings.max() > 0
|
||||
|
||||
def test_a_falling_dot_leaves_ghosts_that_fade(self):
|
||||
r = vs.RadialRenderer(fade=0.5)
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
degree, top = int(r.rings.argmax()), int(r.rings.max())
|
||||
silence = _frame(np.zeros(2048, np.float32))
|
||||
r.advance(silence)
|
||||
assert r.ghost_alpha()[degree, top] > 0.9 # where it was, fresh
|
||||
for _ in range(20): # 0.32 s
|
||||
r.advance(silence)
|
||||
assert 0 < r.ghost_alpha()[degree, top] < 0.9
|
||||
for _ in range(40): # well past 0.5 s
|
||||
r.advance(silence)
|
||||
assert r.ghost_alpha().max() == 0
|
||||
assert not np.isfinite(r.ghosts).any() # forgotten, not just faint
|
||||
|
||||
def test_no_fade_means_no_ghosts(self):
|
||||
r = vs.RadialRenderer(fade=0)
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
r.advance(_frame(np.zeros(2048, np.float32)))
|
||||
assert r.ghost_alpha().max() == 0
|
||||
|
||||
def test_a_rise_leaves_only_where_it_came_from(self):
|
||||
# An instant attack must not paint a bar of ghosts below the dot.
|
||||
r = vs.RadialRenderer(fade=1.0)
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.003)))
|
||||
before = r.rings.copy()
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
degree = int(np.argmax(r.rings - before))
|
||||
alive = np.nonzero(r.ghost_alpha()[degree] > 0)[0]
|
||||
assert list(alive) == [before[degree]]
|
||||
|
||||
def test_ghosts_per_spoke_are_bounded(self):
|
||||
r = vs.RadialRenderer(fade=3.0)
|
||||
rng = np.random.default_rng(1)
|
||||
for _ in range(200):
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=rng.random())))
|
||||
rings, alpha = r.pooled(45)
|
||||
assert (alpha > 0).sum(axis=1).max() <= vs.STEPS
|
||||
|
||||
def test_bigger_circles_get_more_spokes(self):
|
||||
assert vs.spoke_count(28) < vs.spoke_count(400) == 360
|
||||
assert 360 % vs.spoke_count(28) == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Laser
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestLaser:
|
||||
def test_silence_is_a_still_dot_in_the_middle(self):
|
||||
r = vs.LaserRenderer()
|
||||
for _ in range(10):
|
||||
r.advance(_frame(np.zeros(2048, np.float32),
|
||||
np.zeros(706, np.float32)))
|
||||
assert np.allclose(r.paths[0], 0.0)
|
||||
|
||||
def test_the_membrane_answers_its_resonance(self):
|
||||
f0 = vs.MODES[0][0]
|
||||
on, off = vs.LaserRenderer(), vs.LaserRenderer()
|
||||
swing_on = np.abs(on.drive(_sine(f0, 0.5), RATE)).max()
|
||||
swing_off = np.abs(off.drive(_sine(3000, 0.5), RATE)).max()
|
||||
assert swing_on > 20 * swing_off
|
||||
|
||||
def test_chunking_does_not_change_the_path(self):
|
||||
audio = _sine(143, 0.3) + _sine(90, 0.3)
|
||||
whole = vs.LaserRenderer().drive(audio, RATE)
|
||||
pieces = vs.LaserRenderer()
|
||||
parts = [pieces.drive(chunk, RATE)
|
||||
for chunk in np.array_split(audio, [101, 700, 702, 5000])]
|
||||
assert np.allclose(np.concatenate(parts), whole)
|
||||
|
||||
def test_degenerate_modes_turn_a_line_into_a_loop(self):
|
||||
# The split (1,1) pair: driven between its two resonances, the two
|
||||
# axes are out of phase — an ellipse, not a line.
|
||||
f = (vs.MODES[1][0] + vs.MODES[2][0]) / 2
|
||||
tilt = vs.LaserRenderer().drive(_sine(f, 1.0), RATE)[-2000:]
|
||||
tilt = tilt - tilt.mean(axis=0)
|
||||
s = np.linalg.svd(tilt, compute_uv=False)
|
||||
assert s[1] / s[0] > 0.1
|
||||
|
||||
def test_the_spot_stays_on_the_screen(self):
|
||||
r = vs.LaserRenderer()
|
||||
rng = np.random.default_rng(0)
|
||||
audio = (_sine(90, 2, 0.9) * (1 + rng.standard_normal(2 * RATE) * 0.3))
|
||||
hop = 706
|
||||
for i in range(0, len(audio) - hop, hop):
|
||||
r.advance(_frame(np.zeros(2048, np.float32),
|
||||
audio[i:i + hop], dt=0.016))
|
||||
assert np.abs(r.paths[0]).max() <= 1.0
|
||||
|
||||
def test_loud_music_fills_most_of_the_screen(self):
|
||||
r = vs.LaserRenderer()
|
||||
audio = _sine(vs.MODES[0][0], 1.0, 0.5)
|
||||
for i in range(0, len(audio) - 706, 706):
|
||||
r.advance(_frame(np.zeros(2048, np.float32), audio[i:i + 706]))
|
||||
assert np.abs(r.paths[0]).max() > 0.6
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. Double-click → big window
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestBigWindow:
|
||||
def test_double_click_keeps_the_brightness_and_opens(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
|
||||
vis.mousePressEvent(None) # Qt's first press: on -> dim
|
||||
vis.mouseDoubleClickEvent(None) # …which the double-click undoes
|
||||
try:
|
||||
assert vis._mode == MODE_ON
|
||||
assert prefs.get("visualizer_mode") == MODE_ON
|
||||
assert vis._big is not None and vis._big.isVisible()
|
||||
finally:
|
||||
vis._big.close()
|
||||
|
||||
def test_a_click_closes_it(self, qapp):
|
||||
from PyQt6.QtCore import Qt, QPointF, QEvent
|
||||
from PyQt6.QtGui import QMouseEvent
|
||||
vis = VisualizerWidget(_FakePlayer())
|
||||
vis.open_big()
|
||||
big = vis._big
|
||||
center = QPointF(big.contentsRect().center())
|
||||
release = QMouseEvent(
|
||||
QEvent.Type.MouseButtonRelease, center, center,
|
||||
Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.NoModifier)
|
||||
big.mouseReleaseEvent(release)
|
||||
assert not big.isVisible()
|
||||
|
||||
def test_off_panel_still_animates_the_big_window(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set("visualizer_mode", MODE_OFF)
|
||||
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
|
||||
assert not vis._timer.isActive()
|
||||
vis.open_big()
|
||||
assert vis._timer.isActive()
|
||||
vis._big.close()
|
||||
assert not vis._timer.isActive()
|
||||
|
||||
def test_big_window_paints_in_color_while_the_panel_is_off(
|
||||
self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set("visualizer_mode", MODE_OFF)
|
||||
prefs.set("visualizer_style", "laser")
|
||||
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
|
||||
calls = []
|
||||
vis._renderer.paint = lambda p, rect, color, light: calls.append(rect)
|
||||
img = QImage(10, 10, QImage.Format.Format_ARGB32)
|
||||
painter = QPainter(img)
|
||||
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
|
||||
assert calls == [] # the panel stays blank
|
||||
vis.paint_renderer(painter, QRectF(0, 0, 10, 10), big=True)
|
||||
painter.end()
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_the_laser_is_drawn_in_gray_when_dim(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set("visualizer_mode", MODE_DIM)
|
||||
prefs.set("color_visualizer_gray", 120)
|
||||
prefs.set("visualizer_style", "laser")
|
||||
vis = VisualizerWidget(_FakePlayer(), prefs)
|
||||
colors = []
|
||||
vis._renderer.paint = lambda p, rect, color, light: colors.append(color)
|
||||
img = QImage(10, 10, QImage.Format.Format_ARGB32)
|
||||
painter = QPainter(img)
|
||||
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
|
||||
painter.end()
|
||||
assert colors[0].red() == colors[0].green() == colors[0].blue() == 120
|
||||
|
||||
def test_pending_audio_is_capped(self, qapp):
|
||||
vis = VisualizerWidget(_FakePlayer())
|
||||
for _ in range(100):
|
||||
vis._feed(_sine(440, 0.05), RATE)
|
||||
assert vis._pending_len <= RATE * 0.25 + RATE * 0.05
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6. Preferences
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestPreferencesPage:
|
||||
def _dialog(self, prefs):
|
||||
from lintunes.gui.preferences_dialog import PreferencesDialog
|
||||
|
||||
class _LastFm(QObject):
|
||||
login_finished = pyqtSignal(bool, str)
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def is_logged_in(self):
|
||||
return False
|
||||
return PreferencesDialog(prefs, _LastFm())
|
||||
|
||||
def test_style_radios_and_fade_slider(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = self._dialog(prefs)
|
||||
assert dialog._viz_radios["bars"].isChecked()
|
||||
assert not dialog._fade_slider.isEnabled()
|
||||
|
||||
dialog._viz_radios["radial"].setChecked(True)
|
||||
assert prefs.get("visualizer_style") == "radial"
|
||||
assert dialog._fade_slider.isEnabled()
|
||||
|
||||
dialog._fade_slider.setValue(25)
|
||||
assert prefs.get("visualizer_ghost_fade") == 2.5
|
||||
|
||||
dialog._viz_radios["laser"].setChecked(True)
|
||||
assert not dialog._fade_slider.isEnabled()
|
||||
|
||||
def test_building_the_dialog_writes_nothing(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set("visualizer_style", "laser")
|
||||
before = (tmp_path / "preferences.json").read_text()
|
||||
self._dialog(prefs)
|
||||
assert (tmp_path / "preferences.json").read_text() == before
|
||||
Reference in New Issue
Block a user