v0.55.0: Kaleidostrobe smooth again; visualizer tile hover controls

The cones move only on the plugin's own 25 fps frames again: driving them
from fresh audio every refresh made bass-heavy songs twitch (a jump on 84%
of refreshes against 41%). The continuous turn, texture crossfade and the
GPU big window stay.

The tile: a click opens it big (and it stays blank while big); hovering
shows a colour wheel (colour / gray / off) and up/down arrows (previous /
next visualizer). No more double-click or click-to-cycle. The big album
art closes on a click or Esc, like the big visualizer.
This commit is contained in:
2026-10-14 12:51:38 -07:00
parent 7ae55e757a
commit 00aa5e7f2e
14 changed files with 414 additions and 95 deletions
+12 -5
View File
@@ -186,6 +186,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
share of the width. Since the Library button left the share of the width. Since the Library button left the
sidebar, `MainWindow` must hand it the first focus, or the playlist tree sidebar, `MainWindow` must hand it the first focus, or the playlist tree
takes it and opens its first playlist at startup. takes it and opens its first playlist at startup.
The transport-bar visualizer tile (`gui/visualizer.py`, Round 86): a
click opens it big, and while big the tile stays blank; hovering paints
two controls over it — a colour wheel (left: colour / gray / off) and ▲/▼
(right: previous/next style, wrapping) — hit-tested on release. No
double-click. Both big windows (visualizer, album art) close on a click
or Esc.
`track_table.py` is the shared track grid (drag/drop, copy/paste, drop `track_table.py` is the shared track grid (drag/drop, copy/paste, drop
indicator). `playlist_ops.py::add_tracks_with_dup_check` is the single funnel indicator). `playlist_ops.py::add_tracks_with_dup_check` is the single funnel
for every add-to-playlist path. `theme.py` applies the palette (highlight for every add-to-playlist path. `theme.py` applies the palette (highlight
@@ -281,11 +287,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
scale was never published, so `itunes_spectrum` (√magnitude, 43 Hz bins) scale was never published, so `itunes_spectrum` (√magnitude, 43 Hz bins)
is the one calibrated stand-in. **On screen it is 60 fps anyway** is the one calibrated stand-in. **On screen it is 60 fps anyway**
(Round 85): between the 25 Hz frames `_compose` turns the cones by a (Round 85): between the 25 Hz frames `_compose` turns the cones by a
fraction of a frame, crossfades the textures, and takes height/depth from fraction of a frame and crossfades the textures — smoothing only. The
`engine.preview` — the plugin's analysis run on a throwaway copy with the cones' height/depth change **only on the plugin's frames**: Round 85 also
newest audio, so a beat pumps the cone one refresh later, not one 40 ms drove them from the newest audio every refresh (snappier beats), and on
tick later. `preview` must never touch real state (a golden test bass-heavy songs it twitched — a jump on 84% of refreshes against 41% —
interleaves it). Painting: the panel uses QPainter — each GL triangle is so Round 86 took it out. The 25 fps cap was hiding that jitter; don't
reintroduce fresh-audio geometry between frames. Painting: the panel uses QPainter — each GL triangle is
planar, drawn in texture space through a projective **world** transform planar, drawn in texture space through a projective **world** transform
(Qt mis-draws a strongly projective *brush* transform). The **big window (Qt mis-draws a strongly projective *brush* transform). The **big window
replays the original's GL calls** (`paint_gl` in a `gui/kaleido_gl.py` replays the original's GL calls** (`paint_gl` in a `gui/kaleido_gl.py`
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.54.0" __version__ = "0.55.0"
+17 -1
View File
@@ -30,13 +30,16 @@ def _aspect(pixmap: QPixmap | None) -> float | None:
class ArtWindow(QWidget): class ArtWindow(QWidget):
"""Non-modal window showing the current track's album art as large as """Non-modal window showing the current track's album art as large as
possible. Just an album art window — closing it has no side effects.""" possible. Just an album art window — closing it has no side effects.
A click anywhere on the art (or Esc) closes it, like the big
visualizer."""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window) super().__init__(parent, Qt.WindowType.Window)
self._pixmap: QPixmap | None = None self._pixmap: QPixmap | None = None
self.setMinimumSize(120, 120) self.setMinimumSize(120, 120)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
self.setToolTip("Click to close")
def show_artwork(self, pixmap: QPixmap | None, title: str, screen=None): def show_artwork(self, pixmap: QPixmap | None, title: str, screen=None):
"""Open (or re-raise) the window, sized to the art on ``screen``.""" """Open (or re-raise) the window, sized to the art on ``screen``."""
@@ -86,3 +89,16 @@ class ArtWindow(QWidget):
x = area.x() + (area.width() - scaled.width()) // 2 x = area.x() + (area.width() - scaled.width()) // 2
y = area.y() + (area.height() - scaled.height()) // 2 y = area.y() + (area.height() - scaled.height()) // 2
painter.drawPixmap(x, y, scaled) painter.drawPixmap(x, y, scaled)
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)
+1 -6
View File
@@ -75,12 +75,7 @@ def _fade_text(seconds: float) -> str:
# visualizer_styles' style keys, as the radios name them. # visualizer_styles' style keys, as the radios name them.
_VISUALIZER_STYLES = { _VISUALIZER_STYLES = visualizer_styles.STYLE_NAMES
"bars": "Bars",
"radial": "Radial dots",
"laser": "Laser",
"kaleidostrobe": "Kaleidostrobe",
}
def _sync_text(ms: int) -> str: def _sync_text(ms: int) -> str:
+154 -27
View File
@@ -11,8 +11,8 @@ import time
import numpy as np import numpy as np
from PyQt6.QtWidgets import QWidget from PyQt6.QtWidgets import QWidget
from PyQt6.QtCore import Qt, QTimer, QRectF, pyqtSignal from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF, pyqtSignal
from PyQt6.QtGui import QPainter, QColor from PyQt6.QtGui import QPainter, QColor, QConicalGradient, QPen, QPolygonF
from PyQt6.QtMultimedia import QAudioFormat from PyQt6.QtMultimedia import QAudioFormat
from lintunes import theme from lintunes import theme
@@ -20,7 +20,7 @@ from lintunes.config import load_config
from lintunes.gui.icons import transport_icon from lintunes.gui.icons import transport_icon
from lintunes.gui import visualizer_styles as styles from lintunes.gui import visualizer_styles as styles
from lintunes.gui.visualizer_styles import ( from lintunes.gui.visualizer_styles import (
BANDS, HISTORY, STYLES, STYLE_BARS, Frame, make_renderer) BANDS, HISTORY, STYLES, STYLE_BARS, STYLE_NAMES, Frame, make_renderer)
FRAME_MS = 16 # ~60 fps FRAME_MS = 16 # ~60 fps
@@ -30,13 +30,16 @@ HISTORY_S = 1.5 # decoded audio kept, so a frame can pick what's *heard*
PENDING_S = 0.25 # the laser catches up at most this much at once PENDING_S = 0.25 # the laser catches up at most this much at once
GAP_MS = 50 # a buffer this far from the last one's end is a seek GAP_MS = 50 # a buffer this far from the last one's end is a seek
# Visualizer click cycles through these brightness modes in order. # The colour-wheel button cycles through these modes in order.
MODE_ON = "on" MODE_ON = "on"
MODE_DIM = "dim" MODE_DIM = "dim"
MODE_OFF = "off" MODE_OFF = "off"
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF) _MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
_MODE_NAMES = {MODE_ON: "colour", MODE_DIM: "gray", MODE_OFF: "off"}
_TOOLTIP = "Click to cycle: on / dim / off — double-click to enlarge" _TOOLTIP = "Click to see it big"
BUTTON = 24 # the hover buttons: colour wheel left, ▲/▼ right
BUTTON_MARGIN = 4
_SAMPLE_DTYPES = { _SAMPLE_DTYPES = {
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5), QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
@@ -52,11 +55,13 @@ def _style(prefs) -> str:
class VisualizerWidget(QWidget): class VisualizerWidget(QWidget):
"""Animated visualizer; click to cycle brightness: on / dim / off, and """Animated visualizer. Hovering shows two controls over it — a colour
double-click to open it big (``VisualizerWindow``). wheel (left) that cycles colour / gray / off, and ▲/▼ (right) that step
through the styles; a click anywhere else opens it big
(``VisualizerWindow``), and while it is big the panel stays blank.
On = highlight-colored; dim = light gray just darker than the panel; Colour = highlight-colored; gray = light gray just darker than the panel;
off = blank (the big window, if open, keeps going in color). off = blank (the big window, if open, keeps going).
While casting there is no local PCM to analyze, so the panel doubles as the 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. cast indicator: it shows the device glyph and a click stops casting.
@@ -70,7 +75,6 @@ class VisualizerWidget(QWidget):
self._prefs = prefs self._prefs = prefs
saved = prefs.get("visualizer_mode") if prefs is not None else None saved = prefs.get("visualizer_mode") if prefs is not None else None
self._mode = saved if saved in _MODE_CYCLE else MODE_ON self._mode = saved if saved in _MODE_CYCLE else MODE_ON
self._mode_before_press = self._mode
self._renderer = make_renderer(_style(prefs), styles.settings(prefs)) self._renderer = make_renderer(_style(prefs), styles.settings(prefs))
# Decoded audio, newest last, and the stream time its end belongs # Decoded audio, newest last, and the stream time its end belongs
# to. Decoding runs ahead of the speakers — normally by a buffer or # to. Decoding runs ahead of the speakers — normally by a buffer or
@@ -93,6 +97,9 @@ class VisualizerWidget(QWidget):
self.setFixedWidth(140) self.setFixedWidth(140)
self.setMinimumHeight(36) self.setMinimumHeight(36)
self.setToolTip(_TOOLTIP) self.setToolTip(_TOOLTIP)
self.setMouseTracking(True) # which hover button is under the mouse
self._hovering = False
self._hot = None # "mode" / "prev" / "next" / None
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.setInterval(FRAME_MS) self._timer.setInterval(FRAME_MS)
@@ -270,29 +277,143 @@ class VisualizerWidget(QWidget):
if self._mode == MODE_OFF and not self._big_visible(): if self._mode == MODE_OFF and not self._big_visible():
self._clear() # off = blank, not the last frame held in place self._clear() # off = blank, not the last frame held in place
self._sync_timer() self._sync_timer()
self._update_tooltip()
self.update() self.update()
def mousePressEvent(self, event): def cycle_mode(self):
if self._no_pcm: """The colour wheel: colour → gray → off → colour."""
# While casting the panel is the cast indicator, not a spectrum:
# cycling its brightness would be meaningless, so a click stops
# casting instead.
self.cast_stop_requested.emit()
return
# Cycle on -> dim -> off -> on.
self._mode_before_press = self._mode
idx = _MODE_CYCLE.index(self._mode) idx = _MODE_CYCLE.index(self._mode)
self._set_mode(_MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]) self._set_mode(_MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)])
def mouseDoubleClickEvent(self, event): def step_style(self, step: int):
"""Qt sends press, release, *double-click*: the first press already """▲/▼: the previous or next style, wrapping around."""
cycled the brightness, so put it back before opening big. A brief current = _style(self._prefs) if self._prefs is not None else self._renderer.name
flicker, in exchange for a single click that never waits to see idx = STYLES.index(current) if current in STYLES else 0
whether a second one is coming.""" style = STYLES[(idx + step) % len(STYLES)]
if self._prefs is not None:
self._prefs.set("visualizer_style", style) # swaps the renderer
else:
self._renderer = make_renderer(style, styles.settings(None))
self._update_tooltip()
self.update()
# ---- the hover controls ----
def _button_rects(self) -> dict:
w, h = self.width(), self.height()
m = BUTTON_MARGIN
arrow_h = min(BUTTON, (h - 3 * m) / 2)
right = w - m - BUTTON
return {
"mode": QRectF(m, (h - BUTTON) / 2, BUTTON, BUTTON),
"prev": QRectF(right, h / 2 - m / 2 - arrow_h, BUTTON, arrow_h),
"next": QRectF(right, h / 2 + m / 2, BUTTON, arrow_h),
}
def _hit(self, pos):
if self._no_pcm: if self._no_pcm:
return # the first click already stopped casting return None
self._set_mode(self._mode_before_press) for name, rect in self._button_rects().items():
self.open_big() if rect.contains(QPointF(pos)):
return name
return None
def _update_tooltip(self):
if self._no_pcm:
return
current = _style(self._prefs) if self._prefs is not None else self._renderer.name
idx = STYLES.index(current) if current in STYLES else 0
tips = {
"mode": f"Colour / gray / off — now {_MODE_NAMES[self._mode]}",
"prev": f"Previous visualizer: {STYLE_NAMES[STYLES[idx - 1]]}",
"next": f"Next visualizer: {STYLE_NAMES[STYLES[(idx + 1) % len(STYLES)]]}",
}
self.setToolTip(tips.get(self._hot, _TOOLTIP))
def enterEvent(self, event):
self._hovering = True
self.update()
super().enterEvent(event)
def leaveEvent(self, event):
self._hovering = False
self._hot = None
self.update()
super().leaveEvent(event)
def mouseMoveEvent(self, event):
hot = self._hit(event.position())
if hot != self._hot:
self._hot = hot
self._update_tooltip()
self.update()
super().mouseMoveEvent(event)
def mousePressEvent(self, event):
if self._no_pcm:
# While casting the panel is the cast indicator, not a picture:
# a click stops casting instead.
self.cast_stop_requested.emit()
return
event.accept() # acted on at release, like a button
def mouseReleaseEvent(self, event):
if self._no_pcm or event.button() != Qt.MouseButton.LeftButton:
return
if not self.rect().contains(event.position().toPoint()):
return # dragged off: no click
hit = self._hit(event.position())
if hit == "mode":
self.cycle_mode()
elif hit == "prev":
self.step_style(-1)
elif hit == "next":
self.step_style(1)
else:
self.open_big()
def _paint_controls(self, painter):
"""The hover buttons, over whatever is showing."""
pal = self.palette()
face = QColor(pal.window().color())
ink = QColor(pal.windowText().color())
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
for name, rect in self._button_rects().items():
fill = QColor(face)
fill.setAlphaF(0.92 if name == self._hot else 0.72)
painter.setPen(QPen(QColor(ink.red(), ink.green(), ink.blue(),
110 if name == self._hot else 50), 1))
painter.setBrush(fill)
painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 5, 5)
# colour wheel: a hue ring (colour), a gray one (gray), an outline (off)
wheel = self._button_rects()["mode"].adjusted(5, 5, -5, -5)
center = wheel.center()
if self._mode == MODE_OFF:
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(ink, 1.5))
else:
grad = QConicalGradient(center, 90)
for k in range(7):
c = QColor.fromHsvF((k / 6) % 1.0, 0.85, 0.95)
if self._mode == MODE_DIM: # a gray sweep, light to dark
v = int(225 - 150 * abs(k - 3) / 3)
c = QColor(v, v, v)
grad.setColorAt(k / 6, c)
painter.setBrush(grad)
painter.setPen(QPen(QColor(0, 0, 0, 90), 1))
painter.drawEllipse(wheel)
# ▲ / ▼
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(ink)
for name, up in (("prev", True), ("next", False)):
r = self._button_rects()[name]
cx, cy, s = r.center().x(), r.center().y(), min(r.width(), r.height()) * 0.22
tip = -s if up else s
painter.drawPolygon(QPolygonF([QPointF(cx - s * 1.2, cy - tip * 0.6),
QPointF(cx + s * 1.2, cy - tip * 0.6),
QPointF(cx, cy + tip * 0.9)]))
painter.restore()
def open_big(self): def open_big(self):
from lintunes.gui.visualizer_window import VisualizerWindow from lintunes.gui.visualizer_window import VisualizerWindow
@@ -300,11 +421,13 @@ class VisualizerWidget(QWidget):
self._big = VisualizerWindow(self) self._big = VisualizerWindow(self)
self._big.open(self.screen()) self._big.open(self.screen())
self._sync_timer() self._sync_timer()
self.update() # the panel goes blank while it is big
def big_window_closed(self): def big_window_closed(self):
if self._mode == MODE_OFF: if self._mode == MODE_OFF:
self._clear() self._clear()
self._sync_timer() self._sync_timer()
self.update()
# ---- frame computation ---- # ---- frame computation ----
@@ -348,6 +471,8 @@ class VisualizerWidget(QWidget):
regardless (in color), since opening it is asking to see it.""" regardless (in color), since opening it is asking to see it."""
if self._no_pcm or (self._mode == MODE_OFF and not big): if self._no_pcm or (self._mode == MODE_OFF and not big):
return return
if not big and self._big_visible():
return # it's showing big; once is enough
self._tell_mode() self._tell_mode()
self._renderer.paint(painter, rect, self._bar_color(), self._grid_gray()) self._renderer.paint(painter, rect, self._bar_color(), self._grid_gray())
@@ -388,3 +513,5 @@ class VisualizerWidget(QWidget):
painter.end() painter.end()
return return
self.paint_renderer(painter, QRectF(self.rect())) self.paint_renderer(painter, QRectF(self.rect()))
if self._hovering:
self._paint_controls(painter)
+8 -8
View File
@@ -10,10 +10,11 @@ way its OpenGL did.
- **The screen's clock.** Between those frames the picture is still drawn - **The screen's clock.** Between those frames the picture is still drawn
at 60 fps (``advance`` → ``_compose``): the turn continues smoothly (the at 60 fps (``advance`` → ``_compose``): the turn continues smoothly (the
rotation is a frame count, so a fraction of a frame is well defined), the rotation is a frame count, so a fraction of a frame is well defined), the
textures crossfade from the last frame to the newest, and the cones' textures crossfade from the last frame to the newest. The cones' height
height and depth come from the plugin's own analysis run on the newest and depth stay the plugin's own, changing only on its frames: driving
audio (``engine.preview``) — so a beat pumps the cone on the next screen them from the newest audio at every refresh (Round 85) made the picture
refresh rather than at the next 40 ms tick. twitch on bass-heavy songs — on 84% of refreshes against 41% — which is
the jitter the 25 fps cap had been hiding all along.
- **Input.** iTunes handed plugins 512 spectrum bins per channel as bytes; - **Input.** iTunes handed plugins 512 spectrum bins per channel as bytes;
how it scaled them was never documented, so ``itunes_spectrum`` is a how it scaled them was never documented, so ``itunes_spectrum`` is a
calibrated stand-in (``SPECTRUM_GAIN``). calibrated stand-in (``SPECTRUM_GAIN``).
@@ -162,20 +163,19 @@ class KaleidostrobeRenderer:
self._frame = self._engine.step(spectrum, self._ticks()) self._frame = self._engine.step(spectrum, self._ticks())
if self._frame is None: if self._frame is None:
return False return False
self._compose(spectrum) self._compose()
return True return True
def _ticks(self, ahead=0.0): def _ticks(self, ahead=0.0):
return 1000 + int((self._clock + ahead) * ks.TICKS_PER_S) return 1000 + int((self._clock + ahead) * ks.TICKS_PER_S)
def _compose(self, spectrum): def _compose(self):
"""This screen refresh's picture, a fraction ``t`` of the way from """This screen refresh's picture, a fraction ``t`` of the way from
the plugin's last frame to its next one.""" the plugin's last frame to its next one."""
e = self._engine e = self._engine
t = self._behind / STEP_S t = self._behind / STEP_S
mid, signal = e.preview(spectrum, self._ticks(self._behind))
drawn = (e.rotation - 1) % ks.ROTATION_STEPS # the last frame's turn drawn = (e.rotation - 1) % ks.ROTATION_STEPS # the last frame's turn
layers = e.layers(mid, signal, drawn + t) layers = e.layers(e.mid, e.avg_signal, drawn + t)
cur = self._frame.textures cur = self._frame.textures
prev = self._prev_textures if self._prev_textures is not None else cur prev = self._prev_textures if self._prev_textures is not None else cur
if t > 0.0 and prev is not cur: if t > 0.0 and prev is not cur:
+6
View File
@@ -33,6 +33,12 @@ STYLE_RADIAL = "radial"
STYLE_LASER = "laser" STYLE_LASER = "laser"
STYLE_KALEIDOSTROBE = "kaleidostrobe" STYLE_KALEIDOSTROBE = "kaleidostrobe"
STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER, STYLE_KALEIDOSTROBE) STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER, STYLE_KALEIDOSTROBE)
STYLE_NAMES = {
STYLE_BARS: "Bars",
STYLE_RADIAL: "Radial dots",
STYLE_LASER: "Laser",
STYLE_KALEIDOSTROBE: "Kaleidostrobe",
}
BANDS = 20 BANDS = 20
STEPS = 20 # vertical granularity per bar STEPS = 20 # vertical granularity per bar
-15
View File
@@ -11,7 +11,6 @@ original handed OpenGL: two 128×128 RGBA textures and, per layer, the
triangles to draw (object-space vertices, texture coordinates, and the triangles to draw (object-space vertices, texture coordinates, and the
modelview that places them). ``visualizer_kaleido`` paints that. modelview that places them). ``visualizer_kaleido`` paints that.
""" """
import copy
import math import math
import struct import struct
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -611,20 +610,6 @@ class Kaleidostrobe:
self.top_object, height, f32(depth * 5.0))) self.top_object, height, f32(depth * 5.0)))
return [bottom, top] return [bottom, top]
def preview(self, spectrum, ticks):
"""What the plugin's analysis would say if it rendered now: (mid,
avg_signal) from a throwaway copy, so nothing here is changed. The
renderer draws between the plugin's 25 fps frames with these, so a
beat moves the cone on the next screen refresh instead of up to
40 ms later; at the next real frame the numbers are the same."""
ghost = copy.copy(self)
ghost.band_peak = [list(row) for row in self.band_peak]
ghost.lp = {k: list(v) for k, v in self.lp.items()}
ghost.beat_periods = list(self.beat_periods)
ghost.frame_count += 1
ghost._process(spectrum, ticks)
return ghost.mid, ghost.avg_signal
def _object(self, k, h, z): def _object(self, k, h, z):
if k == 0: if k == 0:
return self._draw8(h, z) return self._draw8(h, z)
+1 -1
View File
@@ -68,7 +68,7 @@ class TestVisualizerModePersistence:
prefs = Preferences(tmp_path) prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(), prefs) vis = VisualizerWidget(_FakePlayer(), prefs)
vis.mousePressEvent(None) # on -> dim vis.cycle_mode() # on -> dim
assert prefs.get("visualizer_mode") == MODE_DIM assert prefs.get("visualizer_mode") == MODE_DIM
# A fresh Preferences (= next launch) sees it too. # A fresh Preferences (= next launch) sees it too.
+3 -3
View File
@@ -900,8 +900,8 @@ class TestTransportLayout:
vis = bar._visualizer vis = bar._visualizer
before = vis._mode before = vis._mode
# Not casting: a click still cycles the brightness as it always did. # Not casting: the colour wheel cycles the brightness.
vis.mousePressEvent(None) vis.cycle_mode()
assert vis._mode != before assert vis._mode != before
cast.disconnect.assert_not_called() cast.disconnect.assert_not_called()
@@ -1020,4 +1020,4 @@ class TestVisualizerWhileCasting:
assert "click to stop" in vis.toolTip() assert "click to stop" in vis.toolTip()
player.set_sink(None) player.set_sink(None)
assert "cycle" in vis.toolTip() # back to the brightness control assert "big" in vis.toolTip() # back to the tile's own tooltip
+2 -3
View File
@@ -432,11 +432,10 @@ class TestBarsFade:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
class TestBigWindow: class TestBigWindow:
def test_double_click_keeps_the_brightness_and_opens(self, qapp, tmp_path): def test_opening_keeps_the_brightness(self, qapp, tmp_path):
prefs = Preferences(tmp_path) prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(playing=True), prefs) vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
vis.mousePressEvent(None) # Qt's first press: on -> dim vis.open_big() # Round 85: a single click on the tile
vis.mouseDoubleClickEvent(None) # …which the double-click undoes
try: try:
assert vis._mode == MODE_ON assert vis._mode == MODE_ON
assert prefs.get("visualizer_mode") == MODE_ON assert prefs.get("visualizer_mode") == MODE_ON
+9 -16
View File
@@ -1,9 +1,9 @@
"""Round 85: Kaleidostrobe at 60 fps, its big window on the GPU, gray mode """Round 85: Kaleidostrobe at 60 fps, its big window on the GPU, gray mode
as two tones, and a sync slider that can also say "sooner". as two tones, and a sync slider that can also say "sooner".
1. Between the plugin's 25 fps frames: a smooth turn, crossfaded textures, 1. Between the plugin's 25 fps frames: a smooth turn and crossfaded
the cone driven by a preview of the newest audio — which must never textures. (The cone also followed a preview of the newest audio here;
change the original's own frames. Round 86 took that out — it made the picture twitch.)
2. Gray mode: the gray-mode colour on the panel background, nothing else. 2. Gray mode: the gray-mode colour on the panel background, nothing else.
3. The sync slider below zero. 3. The sync slider below zero.
4. The big window: an OpenGL canvas for Kaleidostrobe, QPainter without GL. 4. The big window: an OpenGL canvas for Kaleidostrobe, QPainter without GL.
@@ -17,24 +17,17 @@ from lintunes.gui import visualizer_styles as vs
from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer
from lintunes.preferences import Preferences from lintunes.preferences import Preferences
from test_round84 import GOLDEN, _digest, _frame, _paint, _FakePlayer, _LastFm from test_round84 import _frame, _paint, _FakePlayer, _LastFm
SEED = 7
class TestBetweenFrames: class TestBetweenFrames:
def test_previews_between_frames_change_nothing(self):
"""The renderer asks for a preview every screen refresh; the frames
must still be the original's."""
engine = ks.Kaleidostrobe(seed=1000, ticks=1000)
for f, (spectrum, ticks) in enumerate(zip(GOLDEN["spectra"], GOLDEN["ticks"])):
engine.preview(spectrum, int(ticks) + 1)
engine.preview(GOLDEN["spectra"][-1 - f], int(ticks) + 2)
frame = engine.step(spectrum, int(ticks))
assert _digest(frame.textures[0]) == GOLDEN["tex_digest"][f][0], f"frame {f}"
assert _digest(frame.textures[1]) == GOLDEN["tex_digest"][f][1], f"frame {f}"
def test_gray_mode_is_two_tone(self, qapp): def test_gray_mode_is_two_tone(self, qapp):
"""Only the gray-mode colour and the panel's background, blended.""" """Only the gray-mode colour and the panel's background, blended."""
r = KaleidostrobeRenderer() r = KaleidostrobeRenderer()
r._engine = ks.Kaleidostrobe(seed=SEED, ticks=1000)
for _ in range(5): for _ in range(5):
r.advance(_frame(dt=0.04)) r.advance(_frame(dt=0.04))
r.dimmed = True # the widget then passes its gray r.dimmed = True # the widget then passes its gray
@@ -44,7 +37,7 @@ class TestBetweenFrames:
k = (img - ground) / (ink - ground) # 0 = ground, 1 = ink k = (img - ground) / (ink - ground) # 0 = ground, 1 = ink
assert np.ptp(k, axis=-1).max() < 0.03 # on the line between assert np.ptp(k, axis=-1).max() < 0.03 # on the line between
assert k.min() > -0.02 and k.max() < 1.02 assert k.min() > -0.02 and k.max() < 1.02
assert k.max() - k.min() > 0.3 # and still a picture assert k.max() - k.min() > 0.15 # and still a picture
def test_draws_between_the_plugins_frames(self, qapp): def test_draws_between_the_plugins_frames(self, qapp):
"""60 fps on screen: the turn moves every refresh, frames at 25.""" """60 fps on screen: the turn moves every refresh, frames at 25."""
+191
View File
@@ -0,0 +1,191 @@
"""Round 86: Kaleidostrobe smooth again, and the visualizer tile's controls.
1. The cones move only on the plugin's own 25 fps frames (driving them from
fresh audio at every refresh, Round 85, made bass-heavy songs twitch).
2. The tile: hovering shows a colour wheel (colour / gray / off) and ▲/▼
(style); a click anywhere else opens it big; no double-click any more.
3. While it's big, the tile stays blank.
4. The big album art closes on a click, like the big visualizer.
"""
from unittest.mock import MagicMock
from PyQt6.QtCore import QEvent, QObject, QPointF, Qt, pyqtSignal
from PyQt6.QtGui import QColor, QImage, QKeyEvent, QMouseEvent, QPainter
from lintunes.gui import visualizer_styles as vs
from lintunes.gui.visualizer import MODE_DIM, MODE_OFF, MODE_ON, VisualizerWidget
from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer
from lintunes.preferences import Preferences
from test_round84 import _frame
class _FakePlayer(QObject):
audio_buffer = pyqtSignal(object)
playing_changed = pyqtSignal(bool)
track_changed = pyqtSignal(object)
def is_playing(self):
return False
def _click(widget, pos, button=Qt.MouseButton.LeftButton):
pos = QPointF(pos)
for kind, buttons in ((QEvent.Type.MouseButtonPress, button),
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
event = QMouseEvent(kind, pos, pos, button, buttons,
Qt.KeyboardModifier.NoModifier)
if kind == QEvent.Type.MouseButtonPress:
widget.mousePressEvent(event)
else:
widget.mouseReleaseEvent(event)
def _tile(tmp_path, style="bars"):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", style)
vis = VisualizerWidget(_FakePlayer(), prefs)
vis.resize(140, 62)
return prefs, vis
# --------------------------------------------------------------------------
# 1. Smooth again
# --------------------------------------------------------------------------
class TestSmooth:
def test_the_cone_moves_only_on_the_plugins_frames(self, qapp):
"""Height and depth are the plugin's own: driving them from fresh
audio every refresh made the picture twitch."""
r = KaleidostrobeRenderer()
r.advance(_frame(dt=0.04))
seen = []
for i in range(30):
r.advance(_frame(dt=1 / 60, hz=55 + 40 * (i % 3)))
apex = r._shown[0][0].triangles[0].xyz[0][2]
seen.append((r._engine.frame_count, apex))
for (fa, za), (fb, zb) in zip(seen, seen[1:]):
if fa == fb:
assert za == zb
# --------------------------------------------------------------------------
# 2. The tile's controls
# --------------------------------------------------------------------------
class TestTile:
def test_a_click_in_the_middle_opens_big(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path)
_click(vis, vis.rect().center())
try:
assert vis._big is not None and vis._big.isVisible()
assert vis._mode == MODE_ON # brightness untouched
finally:
vis._big.close()
def test_the_colour_wheel_cycles_and_does_not_open(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path)
wheel = vis._button_rects()["mode"].center()
seen = []
for _ in range(3):
_click(vis, wheel)
seen.append(vis._mode)
assert seen == [MODE_DIM, MODE_OFF, MODE_ON]
assert prefs.get("visualizer_mode") == MODE_ON
assert vis._big is None
def test_arrows_step_through_every_style_and_wrap(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path, "bars")
down = vis._button_rects()["next"].center()
up = vis._button_rects()["prev"].center()
seen = []
for _ in range(len(vs.STYLES)):
_click(vis, down)
seen.append(prefs.get("visualizer_style"))
assert seen == list(vs.STYLES[1:]) + [vs.STYLES[0]]
_click(vis, up) # bars → the last one
assert prefs.get("visualizer_style") == vs.STYLES[-1]
assert isinstance(vis._renderer, KaleidostrobeRenderer)
assert vis._big is None
def test_a_double_click_does_nothing_special(self, qapp, tmp_path):
assert "mouseDoubleClickEvent" not in VisualizerWidget.__dict__
def test_controls_show_only_on_hover(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path)
prefs.set("visualizer_mode", MODE_OFF)
def shot():
return vis.grab().toImage().pixel(int(vis._button_rects()["mode"].center().x()),
int(vis._button_rects()["mode"].center().y()))
plain = shot()
vis._hovering = True
assert shot() != plain
vis._hovering = False
assert shot() == plain
def test_tooltips_say_what_each_part_does(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path, "bars")
vis._hot = "next"
vis._update_tooltip()
assert "Radial dots" in vis.toolTip()
vis._hot = "mode"
vis._update_tooltip()
assert "colour" in vis.toolTip()
vis._hot = None
vis._update_tooltip()
assert "big" in vis.toolTip()
def test_casting_click_still_stops_casting(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path)
stopped = MagicMock()
vis.cast_stop_requested.connect(stopped)
vis._no_pcm = True
_click(vis, vis._button_rects()["mode"].center())
stopped.assert_called_once()
assert vis._mode == MODE_ON
# --------------------------------------------------------------------------
# 3. Blank while big
# --------------------------------------------------------------------------
class TestBlankWhileBig:
def test_tile_draws_nothing_while_big_is_open(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path)
vis._renderer = MagicMock(spec=["paint", "advance", "clear", "name"])
img = QImage(140, 62, QImage.Format.Format_ARGB32)
painter = QPainter(img)
vis.paint_renderer(painter, vis.rect())
assert vis._renderer.paint.call_count == 1
vis.open_big()
try:
vis.paint_renderer(painter, vis.rect()) # the tile
assert vis._renderer.paint.call_count == 1
vis.paint_renderer(painter, vis.rect(), big=True) # the window
assert vis._renderer.paint.call_count == 2
finally:
painter.end()
vis._big.close()
# --------------------------------------------------------------------------
# 4. Big album art
# --------------------------------------------------------------------------
class TestArtWindow:
def test_a_click_closes_it(self, qapp):
from lintunes.gui.art_window import ArtWindow
win = ArtWindow()
win.resize(300, 300)
win.show()
_click(win, win.contentsRect().center())
assert not win.isVisible()
def test_escape_closes_it(self, qapp):
from lintunes.gui.art_window import ArtWindow
win = ArtWindow()
win.show()
win.keyPressEvent(QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Escape,
Qt.KeyboardModifier.NoModifier))
assert not win.isVisible()
+9 -9
View File
@@ -80,11 +80,11 @@ class TestVisualizerToggle:
vis = VisualizerWidget(player) vis = VisualizerWidget(player)
assert vis._mode == MODE_ON assert vis._mode == MODE_ON
vis.mousePressEvent(None) # on -> dim vis.cycle_mode() # on -> dim
assert vis._mode == MODE_DIM assert vis._mode == MODE_DIM
vis.mousePressEvent(None) # dim -> off vis.cycle_mode() # dim -> off
assert vis._mode == MODE_OFF assert vis._mode == MODE_OFF
vis.mousePressEvent(None) # off -> on (wraps) vis.cycle_mode() # off -> on (wraps)
assert vis._mode == MODE_ON assert vis._mode == MODE_ON
def test_off_goes_blank_and_stops(self, qapp): def test_off_goes_blank_and_stops(self, qapp):
@@ -94,8 +94,8 @@ class TestVisualizerToggle:
vis = VisualizerWidget(player) vis = VisualizerWidget(player)
vis._bars = [7] * BANDS # pretend mid-animation vis._bars = [7] * BANDS # pretend mid-animation
vis.mousePressEvent(None) # on -> dim vis.cycle_mode() # on -> dim
vis.mousePressEvent(None) # dim -> off vis.cycle_mode() # dim -> off
assert vis._mode == MODE_OFF assert vis._mode == MODE_OFF
assert vis._bars == [0] * BANDS # blank, not the held frame assert vis._bars == [0] * BANDS # blank, not the held frame
@@ -108,7 +108,7 @@ class TestVisualizerToggle:
player._playing = True player._playing = True
vis = VisualizerWidget(player) vis = VisualizerWidget(player)
vis.mousePressEvent(None) # on -> dim vis.cycle_mode() # on -> dim
assert vis._mode == MODE_DIM assert vis._mode == MODE_DIM
assert vis._timer.isActive() # dim animates while playing assert vis._timer.isActive() # dim animates while playing
@@ -119,9 +119,9 @@ class TestVisualizerToggle:
player._playing = True player._playing = True
vis = VisualizerWidget(player) vis = VisualizerWidget(player)
vis.mousePressEvent(None) # on -> dim vis.cycle_mode() # on -> dim
vis.mousePressEvent(None) # dim -> off vis.cycle_mode() # dim -> off
vis.mousePressEvent(None) # off -> on again vis.cycle_mode() # off -> on again
assert vis._mode == MODE_ON assert vis._mode == MODE_ON
assert vis._timer.isActive() assert vis._timer.isActive()