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
+1 -1
View File
@@ -68,7 +68,7 @@ class TestVisualizerModePersistence:
prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(), prefs)
vis.mousePressEvent(None) # on -> dim
vis.cycle_mode() # on -> dim
assert prefs.get("visualizer_mode") == MODE_DIM
# A fresh Preferences (= next launch) sees it too.
+3 -3
View File
@@ -900,8 +900,8 @@ class TestTransportLayout:
vis = bar._visualizer
before = vis._mode
# Not casting: a click still cycles the brightness as it always did.
vis.mousePressEvent(None)
# Not casting: the colour wheel cycles the brightness.
vis.cycle_mode()
assert vis._mode != before
cast.disconnect.assert_not_called()
@@ -1020,4 +1020,4 @@ class TestVisualizerWhileCasting:
assert "click to stop" in vis.toolTip()
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:
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)
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
vis.mousePressEvent(None) # Qt's first press: on -> dim
vis.mouseDoubleClickEvent(None) # …which the double-click undoes
vis.open_big() # Round 85: a single click on the tile
try:
assert vis._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
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,
the cone driven by a preview of the newest audio — which must never
change the original's own frames.
1. Between the plugin's 25 fps frames: a smooth turn and crossfaded
textures. (The cone also followed a preview of the newest audio here;
Round 86 took that out — it made the picture twitch.)
2. Gray mode: the gray-mode colour on the panel background, nothing else.
3. The sync slider below zero.
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.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:
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):
"""Only the gray-mode colour and the panel's background, blended."""
r = KaleidostrobeRenderer()
r._engine = ks.Kaleidostrobe(seed=SEED, ticks=1000)
for _ in range(5):
r.advance(_frame(dt=0.04))
r.dimmed = True # the widget then passes its gray
@@ -44,7 +37,7 @@ class TestBetweenFrames:
k = (img - ground) / (ink - ground) # 0 = ground, 1 = ink
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.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):
"""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)
assert vis._mode == MODE_ON
vis.mousePressEvent(None) # on -> dim
vis.cycle_mode() # on -> dim
assert vis._mode == MODE_DIM
vis.mousePressEvent(None) # dim -> off
vis.cycle_mode() # dim -> off
assert vis._mode == MODE_OFF
vis.mousePressEvent(None) # off -> on (wraps)
vis.cycle_mode() # off -> on (wraps)
assert vis._mode == MODE_ON
def test_off_goes_blank_and_stops(self, qapp):
@@ -94,8 +94,8 @@ class TestVisualizerToggle:
vis = VisualizerWidget(player)
vis._bars = [7] * BANDS # pretend mid-animation
vis.mousePressEvent(None) # on -> dim
vis.mousePressEvent(None) # dim -> off
vis.cycle_mode() # on -> dim
vis.cycle_mode() # dim -> off
assert vis._mode == MODE_OFF
assert vis._bars == [0] * BANDS # blank, not the held frame
@@ -108,7 +108,7 @@ class TestVisualizerToggle:
player._playing = True
vis = VisualizerWidget(player)
vis.mousePressEvent(None) # on -> dim
vis.cycle_mode() # on -> dim
assert vis._mode == MODE_DIM
assert vis._timer.isActive() # dim animates while playing
@@ -119,9 +119,9 @@ class TestVisualizerToggle:
player._playing = True
vis = VisualizerWidget(player)
vis.mousePressEvent(None) # on -> dim
vis.mousePressEvent(None) # dim -> off
vis.mousePressEvent(None) # off -> on again
vis.cycle_mode() # on -> dim
vis.cycle_mode() # dim -> off
vis.cycle_mode() # off -> on again
assert vis._mode == MODE_ON
assert vis._timer.isActive()