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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user