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:
@@ -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
|
||||
sidebar, `MainWindow` must hand it the first focus, or the playlist tree
|
||||
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
|
||||
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
|
||||
@@ -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)
|
||||
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
|
||||
fraction of a frame, crossfades the textures, and takes height/depth from
|
||||
`engine.preview` — the plugin's analysis run on a throwaway copy with the
|
||||
newest audio, so a beat pumps the cone one refresh later, not one 40 ms
|
||||
tick later. `preview` must never touch real state (a golden test
|
||||
interleaves it). Painting: the panel uses QPainter — each GL triangle is
|
||||
fraction of a frame and crossfades the textures — smoothing only. The
|
||||
cones' height/depth change **only on the plugin's frames**: Round 85 also
|
||||
drove them from the newest audio every refresh (snappier beats), and on
|
||||
bass-heavy songs it twitched — a jump on 84% of refreshes against 41% —
|
||||
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
|
||||
(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`
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.54.0"
|
||||
__version__ = "0.55.0"
|
||||
|
||||
@@ -30,13 +30,16 @@ def _aspect(pixmap: QPixmap | None) -> float | None:
|
||||
|
||||
class ArtWindow(QWidget):
|
||||
"""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):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
self._pixmap: QPixmap | None = None
|
||||
self.setMinimumSize(120, 120)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
|
||||
self.setToolTip("Click to close")
|
||||
|
||||
def show_artwork(self, pixmap: QPixmap | None, title: str, screen=None):
|
||||
"""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
|
||||
y = area.y() + (area.height() - scaled.height()) // 2
|
||||
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)
|
||||
|
||||
@@ -75,12 +75,7 @@ def _fade_text(seconds: float) -> str:
|
||||
|
||||
|
||||
# visualizer_styles' style keys, as the radios name them.
|
||||
_VISUALIZER_STYLES = {
|
||||
"bars": "Bars",
|
||||
"radial": "Radial dots",
|
||||
"laser": "Laser",
|
||||
"kaleidostrobe": "Kaleidostrobe",
|
||||
}
|
||||
_VISUALIZER_STYLES = visualizer_styles.STYLE_NAMES
|
||||
|
||||
|
||||
def _sync_text(ms: int) -> str:
|
||||
|
||||
+154
-27
@@ -11,8 +11,8 @@ import time
|
||||
import numpy as np
|
||||
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
from PyQt6.QtCore import Qt, QTimer, QRectF, pyqtSignal
|
||||
from PyQt6.QtGui import QPainter, QColor
|
||||
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF, pyqtSignal
|
||||
from PyQt6.QtGui import QPainter, QColor, QConicalGradient, QPen, QPolygonF
|
||||
from PyQt6.QtMultimedia import QAudioFormat
|
||||
|
||||
from lintunes import theme
|
||||
@@ -20,7 +20,7 @@ from lintunes.config import load_config
|
||||
from lintunes.gui.icons import transport_icon
|
||||
from lintunes.gui import visualizer_styles as styles
|
||||
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
|
||||
@@ -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
|
||||
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_DIM = "dim"
|
||||
MODE_OFF = "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 = {
|
||||
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
|
||||
@@ -52,11 +55,13 @@ def _style(prefs) -> str:
|
||||
|
||||
|
||||
class VisualizerWidget(QWidget):
|
||||
"""Animated visualizer; click to cycle brightness: on / dim / off, and
|
||||
double-click to open it big (``VisualizerWindow``).
|
||||
"""Animated visualizer. Hovering shows two controls over it — a colour
|
||||
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;
|
||||
off = blank (the big window, if open, keeps going in color).
|
||||
Colour = highlight-colored; gray = light gray just darker than the panel;
|
||||
off = blank (the big window, if open, keeps going).
|
||||
|
||||
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.
|
||||
@@ -70,7 +75,6 @@ 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._mode_before_press = self._mode
|
||||
self._renderer = make_renderer(_style(prefs), styles.settings(prefs))
|
||||
# Decoded audio, newest last, and the stream time its end belongs
|
||||
# to. Decoding runs ahead of the speakers — normally by a buffer or
|
||||
@@ -93,6 +97,9 @@ class VisualizerWidget(QWidget):
|
||||
self.setFixedWidth(140)
|
||||
self.setMinimumHeight(36)
|
||||
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.setInterval(FRAME_MS)
|
||||
@@ -270,29 +277,143 @@ class VisualizerWidget(QWidget):
|
||||
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_tooltip()
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if self._no_pcm:
|
||||
# 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
|
||||
def cycle_mode(self):
|
||||
"""The colour wheel: colour → gray → off → colour."""
|
||||
idx = _MODE_CYCLE.index(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."""
|
||||
def step_style(self, step: int):
|
||||
"""▲/▼: the previous or next style, wrapping around."""
|
||||
current = _style(self._prefs) if self._prefs is not None else self._renderer.name
|
||||
idx = STYLES.index(current) if current in STYLES else 0
|
||||
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:
|
||||
return # the first click already stopped casting
|
||||
self._set_mode(self._mode_before_press)
|
||||
self.open_big()
|
||||
return None
|
||||
for name, rect in self._button_rects().items():
|
||||
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):
|
||||
from lintunes.gui.visualizer_window import VisualizerWindow
|
||||
@@ -300,11 +421,13 @@ class VisualizerWidget(QWidget):
|
||||
self._big = VisualizerWindow(self)
|
||||
self._big.open(self.screen())
|
||||
self._sync_timer()
|
||||
self.update() # the panel goes blank while it is big
|
||||
|
||||
def big_window_closed(self):
|
||||
if self._mode == MODE_OFF:
|
||||
self._clear()
|
||||
self._sync_timer()
|
||||
self.update()
|
||||
|
||||
# ---- frame computation ----
|
||||
|
||||
@@ -348,6 +471,8 @@ class VisualizerWidget(QWidget):
|
||||
regardless (in color), since opening it is asking to see it."""
|
||||
if self._no_pcm or (self._mode == MODE_OFF and not big):
|
||||
return
|
||||
if not big and self._big_visible():
|
||||
return # it's showing big; once is enough
|
||||
self._tell_mode()
|
||||
self._renderer.paint(painter, rect, self._bar_color(), self._grid_gray())
|
||||
|
||||
@@ -388,3 +513,5 @@ class VisualizerWidget(QWidget):
|
||||
painter.end()
|
||||
return
|
||||
self.paint_renderer(painter, QRectF(self.rect()))
|
||||
if self._hovering:
|
||||
self._paint_controls(painter)
|
||||
|
||||
@@ -10,10 +10,11 @@ way its OpenGL did.
|
||||
- **The screen's clock.** Between those frames the picture is still drawn
|
||||
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
|
||||
textures crossfade from the last frame to the newest, and the cones'
|
||||
height and depth come from the plugin's own analysis run on the newest
|
||||
audio (``engine.preview``) — so a beat pumps the cone on the next screen
|
||||
refresh rather than at the next 40 ms tick.
|
||||
textures crossfade from the last frame to the newest. The cones' height
|
||||
and depth stay the plugin's own, changing only on its frames: driving
|
||||
them from the newest audio at every refresh (Round 85) made the picture
|
||||
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;
|
||||
how it scaled them was never documented, so ``itunes_spectrum`` is a
|
||||
calibrated stand-in (``SPECTRUM_GAIN``).
|
||||
@@ -162,20 +163,19 @@ class KaleidostrobeRenderer:
|
||||
self._frame = self._engine.step(spectrum, self._ticks())
|
||||
if self._frame is None:
|
||||
return False
|
||||
self._compose(spectrum)
|
||||
self._compose()
|
||||
return True
|
||||
|
||||
def _ticks(self, ahead=0.0):
|
||||
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
|
||||
the plugin's last frame to its next one."""
|
||||
e = self._engine
|
||||
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
|
||||
layers = e.layers(mid, signal, drawn + t)
|
||||
layers = e.layers(e.mid, e.avg_signal, drawn + t)
|
||||
cur = self._frame.textures
|
||||
prev = self._prev_textures if self._prev_textures is not None else cur
|
||||
if t > 0.0 and prev is not cur:
|
||||
|
||||
@@ -33,6 +33,12 @@ STYLE_RADIAL = "radial"
|
||||
STYLE_LASER = "laser"
|
||||
STYLE_KALEIDOSTROBE = "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
|
||||
STEPS = 20 # vertical granularity per bar
|
||||
|
||||
@@ -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
|
||||
modelview that places them). ``visualizer_kaleido`` paints that.
|
||||
"""
|
||||
import copy
|
||||
import math
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
@@ -611,20 +610,6 @@ class Kaleidostrobe:
|
||||
self.top_object, height, f32(depth * 5.0)))
|
||||
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):
|
||||
if k == 0:
|
||||
return self._draw8(h, z)
|
||||
|
||||
@@ -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