v0.54.0: Kaleidostrobe at 60 fps, its big window on the GPU, two-tone gray mode, sync can go sooner
The plugin still steps at its own 25 fps, frame for frame; between frames the turn continues, textures crossfade, and the cone follows a preview of the newest audio, so beats land a refresh sooner. The big window replays the original's GL calls through OpenGL (QPainter took ~40 ms a frame at 1080p); without GL it falls back. Gray mode is only the gray-mode colour on the panel background. The sync slider goes to -50 ms (sooner).
This commit is contained in:
@@ -279,10 +279,24 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
were Apple SDK leftovers its code never read, so the style has none. It
|
||||
reads only iTunes' **spectrum** bytes (never the waveform); iTunes' byte
|
||||
scale was never published, so `itunes_spectrum` (√magnitude, 43 Hz bins)
|
||||
is the one calibrated stand-in. Painting: each GL triangle is planar, so
|
||||
it is drawn in texture space through a projective **world** transform
|
||||
(Qt mis-draws a strongly projective *brush* transform); the top cone is
|
||||
always nearer, so painter's order is the depth test.
|
||||
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
|
||||
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`
|
||||
QOpenGLWidget, GL 2.1 fixed-function via QOpenGLVersionFunctionsFactory):
|
||||
QPainter cost ~40 ms a frame at 1920×1080, which is what stuttered. No GL
|
||||
→ `unavailable` → the window paints with QPainter again. Gray mode is
|
||||
strictly two-tone (gray-mode colour on the panel background, by HSV
|
||||
value — luma calls the saturated palettes dark). The sync slider now goes
|
||||
below 0 ("sooner"), borrowing audio decoded but not yet heard — only
|
||||
~0–30 ms of it exists, hence the −50 ms floor. The top cone is always
|
||||
nearer, so painter's order is the depth test.
|
||||
`tests/kaleidostrobe_golden.npz` was recorded from the emulated original
|
||||
and `test_round84` holds the port to it byte for byte.
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.53.0"
|
||||
__version__ = "0.54.0"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""The big visualizer window's OpenGL canvas, for Kaleidostrobe.
|
||||
|
||||
Kaleidostrobe drew with fixed-function OpenGL; this replays those calls
|
||||
(``KaleidostrobeRenderer.paint_gl``) through Qt's GL 2.x functions, so a
|
||||
full-screen picture costs the GPU what it cost a 2002 Mac and the GUI
|
||||
thread almost nothing. Painting the same picture with QPainter takes tens of
|
||||
milliseconds a frame at 1920×1080 — the stutter this exists to remove.
|
||||
|
||||
If no usable OpenGL context turns up (no driver, a core-only context),
|
||||
``failed`` is set and the window goes back to painting with QPainter.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PyQt6.QtOpenGL import QOpenGLVersionFunctionsFactory, QOpenGLVersionProfile
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
|
||||
from lintunes.gui import visualizer_kaleido as vk
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KaleidoGLView(QOpenGLWidget):
|
||||
"""Draws the visualizer's Kaleidostrobe renderer with OpenGL."""
|
||||
|
||||
unavailable = pyqtSignal()
|
||||
|
||||
def __init__(self, visualizer, parent=None):
|
||||
super().__init__(parent)
|
||||
self._visualizer = visualizer
|
||||
self._gl = None
|
||||
self._textures = None
|
||||
self.failed = False
|
||||
|
||||
def initializeGL(self):
|
||||
try:
|
||||
profile = QOpenGLVersionProfile()
|
||||
profile.setVersion(2, 1)
|
||||
self._gl = QOpenGLVersionFunctionsFactory.get(profile, self.context())
|
||||
except Exception: # pragma: no cover — depends on the driver
|
||||
log.exception("kaleidostrobe: no OpenGL 2.1 functions")
|
||||
self._gl = None
|
||||
if self._gl is None:
|
||||
self.failed = True
|
||||
self.unavailable.emit()
|
||||
return
|
||||
gl = self._gl
|
||||
gl.initializeOpenGLFunctions()
|
||||
# InitGL(): smooth shading, depth test, blending, byte-aligned rows,
|
||||
# and two 128×128 textures, GL_REPEAT + GL_LINEAR.
|
||||
gl.glShadeModel(vk.GL_SMOOTH)
|
||||
gl.glPixelStorei(vk.GL_UNPACK_ALIGNMENT, 1)
|
||||
self._textures = list(gl.glGenTextures(2))
|
||||
for name in self._textures:
|
||||
gl.glBindTexture(vk.GL_TEXTURE_2D, name)
|
||||
for pname, value in ((vk.GL_TEXTURE_WRAP_S, vk.GL_REPEAT),
|
||||
(vk.GL_TEXTURE_WRAP_T, vk.GL_REPEAT),
|
||||
(vk.GL_TEXTURE_MAG_FILTER, vk.GL_LINEAR),
|
||||
(vk.GL_TEXTURE_MIN_FILTER, vk.GL_LINEAR)):
|
||||
gl.glTexParameteri(vk.GL_TEXTURE_2D, pname, value)
|
||||
|
||||
def paintGL(self):
|
||||
if self._gl is None:
|
||||
return
|
||||
renderer = self._visualizer.gl_renderer()
|
||||
if renderer is None:
|
||||
return
|
||||
ratio = self.devicePixelRatioF()
|
||||
renderer.paint_gl(self._gl, self._textures,
|
||||
round(self.width() * ratio), round(self.height() * ratio),
|
||||
self._visualizer.paint_color())
|
||||
@@ -83,6 +83,12 @@ _VISUALIZER_STYLES = {
|
||||
}
|
||||
|
||||
|
||||
def _sync_text(ms: int) -> str:
|
||||
if ms < 0:
|
||||
return f"{-ms} ms sooner"
|
||||
return f"{ms} ms later" if ms else "0 ms"
|
||||
|
||||
|
||||
class PreferencesDialog(QDialog):
|
||||
"""App preferences. Applies immediately — there is no OK/Cancel.
|
||||
|
||||
@@ -792,14 +798,16 @@ class PreferencesDialog(QDialog):
|
||||
slider.setPageStep(25)
|
||||
slider.setToolTip(
|
||||
"If the visualizer runs ahead of what you hear — Bluetooth "
|
||||
"headphones especially — hold it back this much. Set per "
|
||||
"computer.")
|
||||
"headphones especially — hold it back this much. Below zero it "
|
||||
"reacts sooner, by up to as much audio as the player has decoded "
|
||||
"ahead (usually a few tens of ms). Set per computer.")
|
||||
slider.setValue(visualizer_styles.sync_delay_ms(load_config()))
|
||||
text = QLabel(f"{slider.value()} ms")
|
||||
text.setMinimumWidth(_VALUE_WIDTH)
|
||||
text = QLabel(_sync_text(slider.value()))
|
||||
text.setMinimumWidth(max(_VALUE_WIDTH, text.fontMetrics().horizontalAdvance(
|
||||
_sync_text(visualizer_styles.SYNC_DELAY_LIMITS[0])) + 4))
|
||||
|
||||
def changed(value):
|
||||
text.setText(f"{value} ms")
|
||||
text.setText(_sync_text(value))
|
||||
self._sync_save_timer.start()
|
||||
|
||||
slider.valueChanged.connect(changed)
|
||||
|
||||
@@ -261,7 +261,7 @@ class VisualizerWidget(QWidget):
|
||||
def _repaint(self):
|
||||
self.update()
|
||||
if self._big_visible():
|
||||
self._big.update()
|
||||
self._big.refresh()
|
||||
|
||||
def _set_mode(self, mode):
|
||||
self._mode = mode
|
||||
@@ -348,11 +348,26 @@ 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
|
||||
# A style with colours of its own (Kaleidostrobe) can't take the
|
||||
# gray as its colour; it is told instead.
|
||||
self._renderer.dimmed = self._mode == MODE_DIM
|
||||
self._tell_mode()
|
||||
self._renderer.paint(painter, rect, self._bar_color(), self._grid_gray())
|
||||
|
||||
def _tell_mode(self):
|
||||
"""A style with colours of its own (Kaleidostrobe) can't take the
|
||||
gray as its colour, so it is told the mode — and, for gray mode's
|
||||
two tones, the panel's background."""
|
||||
self._renderer.dimmed = self._mode == MODE_DIM
|
||||
self._renderer.panel = self.panel_color()
|
||||
|
||||
def paint_color(self):
|
||||
return self._bar_color()
|
||||
|
||||
def gl_renderer(self):
|
||||
"""The renderer, if the big window should draw it with OpenGL."""
|
||||
if self._no_pcm or not hasattr(self._renderer, "paint_gl"):
|
||||
return None
|
||||
self._tell_mode()
|
||||
return self._renderer
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
"""Kaleidostrobe as a visualizer style: the 2002 iTunes plugin's simulation
|
||||
(``lintunes.kaleidostrobe.engine``), fed what iTunes fed it and painted the
|
||||
(``lintunes.kaleidostrobe.engine``), fed what iTunes fed it and drawn the
|
||||
way its OpenGL did.
|
||||
|
||||
Two things are this file's business, the rest is the engine's:
|
||||
- **The plugin's clock.** It rendered only when more than 40 ms had passed
|
||||
since its last frame, so it ran at 25 fps, and its rotation and pattern
|
||||
switching count *frames*. The engine is stepped on a 25 Hz clock of our
|
||||
own, never the widget's timer — otherwise everything would spin 2.4× too
|
||||
fast — and every one of those frames is the original's, exactly.
|
||||
- **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.
|
||||
- **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``).
|
||||
|
||||
- **Time.** The plugin rendered only when more than 40 ms had passed since
|
||||
its last frame, so it ran at 25 fps however often iTunes asked, and its
|
||||
rotation and pattern switching count *frames*. The renderer keeps its own
|
||||
25 Hz clock and steps the engine on it, never on the widget's 60 Hz timer
|
||||
— otherwise everything would spin 2.4× too fast. TickCount (1/60 s) is
|
||||
derived from the same clock.
|
||||
- **Input.** iTunes handed plugins 512 spectrum bins per channel as bytes.
|
||||
How iTunes scaled them was never documented; ``itunes_spectrum`` is a
|
||||
calibrated stand-in (``SPECTRUM_GAIN``). Kaleidostrobe divides everything by
|
||||
running peaks, so it is the *shape* over time that matters, not the units.
|
||||
|
||||
Painting: every GL triangle is planar, so its texture → screen mapping is a
|
||||
homography; each one is filled with the texture as a tiled brush under that
|
||||
projective transform (GL_REPEAT, GL_LINEAR via smooth transforms). The top
|
||||
layer's depth is always in front of the bottom one's, so painter's order is
|
||||
the depth test.
|
||||
Drawing: ``paint`` is QPainter — each GL triangle is planar, so its texture
|
||||
→ screen mapping is a homography, drawn in texture space through a
|
||||
projective world transform (Qt mis-draws a strongly projective *brush*
|
||||
transform). That suits the transport-bar panel. The big window replays the
|
||||
original's own GL calls instead (``paint_gl``, from ``kaleido_gl``), since
|
||||
a full-screen picture costs the CPU tens of milliseconds a frame and the GPU
|
||||
next to nothing. The top cone is always nearer, so painter's order is the
|
||||
depth test either way.
|
||||
"""
|
||||
import math
|
||||
import time
|
||||
@@ -40,6 +46,17 @@ SPECTRUM_GAIN = 320.0 # byte = √magnitude × this (full-scale sine =
|
||||
|
||||
_PROJ_F = 1.0 / math.tan(math.radians(ks.FOVY) / 2)
|
||||
_WINDOWS = {}
|
||||
# GL enums the replay needs
|
||||
GL_TEXTURE_2D, GL_TRIANGLES = 0x0DE1, 0x0004
|
||||
GL_COLOR_BUFFER_BIT, GL_DEPTH_BUFFER_BIT = 0x4000, 0x0100
|
||||
GL_DEPTH_TEST, GL_BLEND = 0x0B71, 0x0BE2
|
||||
GL_PROJECTION, GL_MODELVIEW = 0x1701, 0x1700
|
||||
GL_ONE, GL_ZERO, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA = 1, 0, 0x0302, 0x0303
|
||||
GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE = 0x2300, 0x2200, 0x2100
|
||||
GL_RGBA, GL_UNSIGNED_BYTE = 0x1908, 0x1401
|
||||
GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER = 0x2801, 0x2800
|
||||
GL_TEXTURE_WRAP_S, GL_TEXTURE_WRAP_T = 0x2802, 0x2803
|
||||
GL_LINEAR, GL_REPEAT, GL_SMOOTH, GL_UNPACK_ALIGNMENT = 0x2601, 0x2901, 0x1D01, 0x0CF5
|
||||
|
||||
|
||||
def itunes_spectrum(samples: np.ndarray, rate: float) -> np.ndarray:
|
||||
@@ -68,9 +85,16 @@ def itunes_spectrum(samples: np.ndarray, rate: float) -> np.ndarray:
|
||||
|
||||
|
||||
def _rotz(deg):
|
||||
r = math.radians(deg)
|
||||
c, s = math.cos(r), math.sin(r)
|
||||
return np.array([[c, -s, 0, 0], [s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]])
|
||||
r = np.radians(np.asarray(deg, float))
|
||||
c, s = np.cos(r), np.sin(r)
|
||||
m = np.zeros(r.shape + (4, 4))
|
||||
m[..., 0, 0] = c
|
||||
m[..., 0, 1] = -s
|
||||
m[..., 1, 0] = s
|
||||
m[..., 1, 1] = c
|
||||
m[..., 2, 2] = 1.0
|
||||
m[..., 3, 3] = 1.0
|
||||
return m
|
||||
|
||||
|
||||
def _lift(z):
|
||||
@@ -79,16 +103,36 @@ def _lift(z):
|
||||
return m
|
||||
|
||||
|
||||
def _two_tone(rgba: np.ndarray, ink: QColor, ground: QColor) -> np.ndarray:
|
||||
"""Gray mode: the picture in exactly two colours — the panel's own
|
||||
background where it is dark, the gray-mode colour where it is bright.
|
||||
Brightness is HSV value (the strongest channel), not luma: the palettes
|
||||
are saturated, and luma calls a pure blue 11% bright."""
|
||||
tone = rgba[..., :3].max(-1)[..., None] / 255.0
|
||||
a = np.array([ground.red(), ground.green(), ground.blue()], float)
|
||||
b = np.array([ink.red(), ink.green(), ink.blue()], float)
|
||||
out = np.empty(rgba.shape, np.uint8)
|
||||
out[..., :3] = np.clip(a + (b - a) * tone + 0.5, 0, 255)
|
||||
out[..., 3] = 255
|
||||
return out
|
||||
|
||||
|
||||
class KaleidostrobeRenderer:
|
||||
name = STYLE
|
||||
dimmed = False # set by the widget: paint in dim mode's gray
|
||||
dimmed = False # set by the widget: gray mode
|
||||
panel = QColor(0, 0, 0) # set by the widget: gray mode's background
|
||||
|
||||
def __init__(self, config=None):
|
||||
self._engine = None
|
||||
self._clock = 0.0 # simulated seconds since the engine began
|
||||
self._behind = 0.0 # time owed to the 25 Hz clock
|
||||
self._frame = None # the last FrameOut, or None = blank
|
||||
self._images = [None, None]
|
||||
self._prev_textures = None # the frame before it
|
||||
self._shown = None # (layers, textures) to draw now
|
||||
self._images = {}
|
||||
self._ink = QColor(0, 0, 0)
|
||||
self._gl_uploaded = None
|
||||
self.version = 0 # bumped whenever _shown changes
|
||||
|
||||
# ---- the renderer contract ----
|
||||
|
||||
@@ -99,6 +143,8 @@ class KaleidostrobeRenderer:
|
||||
"""Paused or stopped: blank, but the plugin keeps its state, as it
|
||||
did across iTunes' pause."""
|
||||
self._frame = None
|
||||
self._shown = None
|
||||
self.version += 1
|
||||
|
||||
def advance(self, frame) -> bool:
|
||||
if self._engine is None:
|
||||
@@ -107,44 +153,78 @@ class KaleidostrobeRenderer:
|
||||
self._engine = ks.Kaleidostrobe(seed, self._ticks())
|
||||
self._behind = min(self._behind + max(0.0, frame.dt),
|
||||
STEP_S * MAX_CATCH_UP)
|
||||
stepped = False
|
||||
spectrum = itunes_spectrum(frame.samples, frame.rate)
|
||||
while self._behind >= STEP_S:
|
||||
self._behind -= STEP_S
|
||||
self._clock += STEP_S
|
||||
spectrum = itunes_spectrum(frame.samples, frame.rate)
|
||||
if self._frame is not None:
|
||||
self._prev_textures = self._frame.textures
|
||||
self._frame = self._engine.step(spectrum, self._ticks())
|
||||
self._images = [None, None]
|
||||
stepped = True
|
||||
return stepped
|
||||
if self._frame is None:
|
||||
return False
|
||||
self._compose(spectrum)
|
||||
return True
|
||||
|
||||
def _ticks(self):
|
||||
return 1000 + int(self._clock * ks.TICKS_PER_S)
|
||||
def _ticks(self, ahead=0.0):
|
||||
return 1000 + int((self._clock + ahead) * ks.TICKS_PER_S)
|
||||
|
||||
# ---- painting ----
|
||||
def _compose(self, spectrum):
|
||||
"""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)
|
||||
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:
|
||||
textures = (prev * (1.0 - t) + cur * t + 0.5).astype(np.uint8)
|
||||
else:
|
||||
textures = cur
|
||||
self._shown = (layers, textures)
|
||||
self._images = {}
|
||||
self.version += 1
|
||||
|
||||
def _texture(self, i, dim):
|
||||
img = self._images[i]
|
||||
if img is None:
|
||||
rgba = np.ascontiguousarray(self._frame.textures[i])
|
||||
img = QImage(rgba.data, ks.SIZE, ks.SIZE, ks.SIZE * 4,
|
||||
QImage.Format.Format_RGBA8888).copy()
|
||||
self._images[i] = img
|
||||
if dim is not None:
|
||||
img = _dimmed(img, dim)
|
||||
return img
|
||||
# ---- what to draw ----
|
||||
|
||||
def _rgba(self, i):
|
||||
"""Texture ``i`` as RGBA bytes, two-toned in gray mode."""
|
||||
key = ('rgba', i, self.dimmed)
|
||||
if key not in self._images:
|
||||
rgba = np.ascontiguousarray(self._shown[1][i])
|
||||
if self.dimmed:
|
||||
rgba = _two_tone(rgba, self._ink, self.panel)
|
||||
self._images[key] = rgba
|
||||
return self._images[key]
|
||||
|
||||
def _background(self) -> QColor:
|
||||
return QColor(self.panel) if self.dimmed else QColor(0, 0, 0)
|
||||
|
||||
def _rim_scale(self, aspect):
|
||||
"""The rim reaches this view's corners (reshape's gFarHalfWidth)."""
|
||||
half = ks.f32(ks.FAR_HALF_HEIGHT * aspect)
|
||||
engine_fhw = self._engine.far_half_width
|
||||
return math.sqrt(2 * half * half) / engine_fhw if engine_fhw else 1.0
|
||||
|
||||
@staticmethod
|
||||
def _triangles(layer, rim):
|
||||
st = np.array([t.st for t in layer.triangles], float) # N,3,2
|
||||
xyz = np.array([t.xyz for t in layer.triangles], float) # N,3,3
|
||||
rimmed = xyz[..., 2] == 0.0
|
||||
xyz[..., :2] *= np.where(rimmed, rim, 1.0)[..., None]
|
||||
turns = np.array([t.turn for t in layer.triangles], float)
|
||||
return st, xyz, turns
|
||||
|
||||
# ---- QPainter (the panel, and the big window without OpenGL) ----
|
||||
|
||||
def paint(self, painter, rect, color: QColor, gray: QColor = None):
|
||||
if self._frame is None or rect.width() < 2 or rect.height() < 2:
|
||||
if self._shown is None or rect.width() < 2 or rect.height() < 2:
|
||||
return
|
||||
# In colour mode the highlight goes unused — Kaleidostrobe brought
|
||||
# its own palettes. Dim mode tints the picture with its gray.
|
||||
dim = color if self.dimmed else None
|
||||
painter.save()
|
||||
painter.setClipRect(rect, Qt.ClipOperation.IntersectClip)
|
||||
painter.fillRect(rect, QColor(0, 0, 0)) # glClearColor(0, 0, 0, 0)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
if QColor(color) != self._ink:
|
||||
self._ink = QColor(color)
|
||||
self._images = {}
|
||||
layers, _ = self._shown
|
||||
w, h = rect.width(), rect.height()
|
||||
aspect = w / h
|
||||
proj = np.array([[_PROJ_F / aspect, 0, 0, 0],
|
||||
@@ -153,46 +233,97 @@ class KaleidostrobeRenderer:
|
||||
screen = np.array([[w / 2, 0, rect.left() + w / 2],
|
||||
[0, -h / 2, rect.top() + h / 2],
|
||||
[0, 0, 1.0]])
|
||||
# The rim reaches this rect's corners (reshape's gFarHalfWidth).
|
||||
engine_fhw = self._engine.far_half_width
|
||||
half = ks.f32(ks.FAR_HALF_HEIGHT * aspect)
|
||||
rim = math.sqrt(2 * half * half) / engine_fhw if engine_fhw else 1.0
|
||||
view = _lift(-ks.EYE_Z)
|
||||
rim = self._rim_scale(aspect)
|
||||
painter.save()
|
||||
painter.setClipRect(rect, Qt.ClipOperation.IntersectClip)
|
||||
painter.fillRect(rect, self._background()) # glClearColor
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
world = painter.worldTransform()
|
||||
for layer in self._frame.layers:
|
||||
painter.setBrush(QBrush(self._texture(layer.texture, dim)))
|
||||
for layer in layers:
|
||||
key = ('image', layer.texture, self.dimmed)
|
||||
if key not in self._images:
|
||||
rgba = self._rgba(layer.texture)
|
||||
self._images[key] = QImage(rgba.data, ks.SIZE, ks.SIZE, ks.SIZE * 4,
|
||||
QImage.Format.Format_RGBA8888).copy()
|
||||
painter.setBrush(QBrush(self._images[key]))
|
||||
painter.setOpacity(layer.alpha)
|
||||
base = view @ _lift(layer.lift) @ _rotz(layer.turn)
|
||||
for tri in layer.triangles:
|
||||
m = screen @ proj @ (base @ _rotz(tri.turn))
|
||||
pts = np.array([(x * rim, y * rim, z, 1.0) if z == 0.0
|
||||
else (x, y, z, 1.0) for x, y, z in tri.xyz])
|
||||
st = np.array([(s, t, 1.0) for s, t in tri.st])
|
||||
to_xyz = pts.T @ np.linalg.inv(st.T) # (s, t, 1) → object
|
||||
hom = m @ to_xyz @ np.diag([1 / ks.SIZE, 1 / ks.SIZE, 1.0])
|
||||
# Scale so w is 1 at the first vertex: every vertex is in
|
||||
# front of the eye, so w stays positive across the triangle.
|
||||
hom = hom / (m @ pts[0])[2]
|
||||
# Draw the triangle in texture space through the homography:
|
||||
# Qt's raster engine is exact for a projective *world*
|
||||
# transform, not for a projective brush transform.
|
||||
st, xyz, turns = self._triangles(layer, rim)
|
||||
base = _lift(-ks.EYE_Z) @ _lift(layer.lift) @ _rotz(layer.turn)
|
||||
m = screen @ proj @ (base @ _rotz(turns)) # N,3,4
|
||||
pts = np.concatenate([xyz, np.ones(xyz.shape[:2] + (1,))], -1)
|
||||
stw = np.concatenate([st, np.ones(st.shape[:2] + (1,))], -1)
|
||||
to_xyz = np.swapaxes(pts, 1, 2) @ np.linalg.inv(np.swapaxes(stw, 1, 2))
|
||||
hom = m @ to_xyz @ np.diag([1 / ks.SIZE, 1 / ks.SIZE, 1.0])
|
||||
# w = 1 at the first vertex: every vertex is in front of the eye,
|
||||
# so w stays positive across the triangle.
|
||||
hom /= (m @ pts[:, 0, :, None])[:, 2:3, :]
|
||||
for k in range(len(hom)):
|
||||
H = hom[k]
|
||||
painter.setWorldTransform(QTransform(
|
||||
hom[0, 0], hom[1, 0], hom[2, 0],
|
||||
hom[0, 1], hom[1, 1], hom[2, 1],
|
||||
hom[0, 2], hom[1, 2], hom[2, 2]) * world)
|
||||
H[0, 0], H[1, 0], H[2, 0], H[0, 1], H[1, 1], H[2, 1],
|
||||
H[0, 2], H[1, 2], H[2, 2]) * world)
|
||||
painter.drawPolygon(QPolygonF(
|
||||
[QPointF(s * ks.SIZE, t * ks.SIZE) for s, t in tri.st]))
|
||||
[QPointF(s * ks.SIZE, t * ks.SIZE) for s, t in st[k]]))
|
||||
painter.restore()
|
||||
|
||||
# ---- OpenGL: the original's own calls ----
|
||||
|
||||
def _dimmed(img: QImage, tint: QColor) -> QImage:
|
||||
"""Dim mode: the picture's luminance in the panel's gray."""
|
||||
a = np.frombuffer(img.constBits().asstring(img.sizeInBytes()), np.uint8)
|
||||
a = a.reshape(ks.SIZE, ks.SIZE, 4).astype(np.float32)
|
||||
luma = (a[..., 0] * 0.299 + a[..., 1] * 0.587 + a[..., 2] * 0.114) / 255.0
|
||||
out = np.empty((ks.SIZE, ks.SIZE, 4), np.uint8)
|
||||
for i, c in enumerate((tint.red(), tint.green(), tint.blue())):
|
||||
out[..., i] = (luma * c).astype(np.uint8)
|
||||
out[..., 3] = 255
|
||||
return QImage(out.data, ks.SIZE, ks.SIZE, ks.SIZE * 4,
|
||||
QImage.Format.Format_RGBA8888).copy()
|
||||
def paint_gl(self, gl, textures, width, height, color: QColor):
|
||||
"""Replay display(): ``gl`` is a QOpenGLFunctions_2_x, ``textures``
|
||||
the two texture names, width/height the viewport in pixels."""
|
||||
if QColor(color) != self._ink:
|
||||
self._ink = QColor(color)
|
||||
self._images = {}
|
||||
self._gl_uploaded = None
|
||||
bg = self._background()
|
||||
gl.glViewport(0, 0, width, height)
|
||||
gl.glClearColor(bg.redF(), bg.greenF(), bg.blueF(), 0.0)
|
||||
gl.glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
|
||||
if self._shown is None or width < 1 or height < 1:
|
||||
return
|
||||
layers, _ = self._shown
|
||||
aspect = width / height
|
||||
# reshape(): gluPerspective(fovy, aspect, 5, 25) as a frustum
|
||||
top = ks.NEAR * math.tan(math.radians(ks.FOVY) / 2)
|
||||
gl.glMatrixMode(GL_PROJECTION)
|
||||
gl.glLoadIdentity()
|
||||
gl.glFrustum(-top * aspect, top * aspect, -top, top, ks.NEAR, ks.FAR)
|
||||
gl.glMatrixMode(GL_MODELVIEW)
|
||||
gl.glEnable(GL_DEPTH_TEST)
|
||||
gl.glEnable(GL_BLEND)
|
||||
gl.glEnable(GL_TEXTURE_2D)
|
||||
gl.glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE)
|
||||
stamp = (self.version, self.dimmed, tuple(textures))
|
||||
if self._gl_uploaded != stamp:
|
||||
for i in (0, 1):
|
||||
gl.glBindTexture(GL_TEXTURE_2D, textures[i])
|
||||
gl.glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, ks.SIZE, ks.SIZE, 0,
|
||||
GL_RGBA, GL_UNSIGNED_BYTE, self._rgba(i).tobytes())
|
||||
self._gl_uploaded = stamp
|
||||
rim = self._rim_scale(aspect)
|
||||
gl.glLoadIdentity()
|
||||
gl.glTranslatef(0.0, 0.0, -ks.EYE_Z) # gluLookAt
|
||||
for layer in layers:
|
||||
gl.glBindTexture(GL_TEXTURE_2D, textures[layer.texture])
|
||||
if layer.texture == 0:
|
||||
gl.glBlendFunc(GL_ONE, GL_ZERO)
|
||||
else:
|
||||
gl.glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||
gl.glColor4f(1.0, 1.0, 1.0, layer.alpha)
|
||||
gl.glPushMatrix()
|
||||
gl.glTranslatef(0.0, 0.0, layer.lift)
|
||||
gl.glRotatef(layer.turn, 0.0, 0.0, 1.0)
|
||||
st, xyz, turns = self._triangles(layer, rim)
|
||||
for k in range(len(st)):
|
||||
gl.glPushMatrix()
|
||||
gl.glRotatef(float(turns[k]), 0.0, 0.0, 1.0)
|
||||
gl.glBegin(GL_TRIANGLES)
|
||||
for v in range(3):
|
||||
gl.glTexCoord2f(float(st[k, v, 0]), float(st[k, v, 1]))
|
||||
gl.glVertex3f(float(xyz[k, v, 0]), float(xyz[k, v, 1]),
|
||||
float(xyz[k, v, 2]))
|
||||
gl.glEnd()
|
||||
gl.glPopMatrix()
|
||||
gl.glPopMatrix()
|
||||
|
||||
@@ -74,7 +74,10 @@ SPEAKER_MIN_RATIO = 2 ** (1 / 3) # the narrowest speaker: a third-octave
|
||||
# PipeWire's buffer, and far more over Bluetooth. That's this machine's
|
||||
# audio hardware, so it lives in config.json, never the synced prefs.
|
||||
SYNC_DELAY_KEY = "visualizer_sync_delay_ms"
|
||||
SYNC_DELAY_LIMITS = (0, 400)
|
||||
# Negative = sooner: the frame is taken from audio decoded but not yet
|
||||
# heard. The player decodes only a few tens of ms ahead (measured 0–30 ms),
|
||||
# so that is all "sooner" can borrow; past it the newest audio is used.
|
||||
SYNC_DELAY_LIMITS = (-50, 400)
|
||||
|
||||
|
||||
def sync_delay_ms(config: dict) -> int:
|
||||
|
||||
@@ -19,6 +19,39 @@ class VisualizerWindow(QWidget):
|
||||
self.setWindowTitle("Visualizer")
|
||||
self.setMinimumSize(160, 100)
|
||||
self.setToolTip("Click to close")
|
||||
# An OpenGL canvas over the contents, for a style that draws with
|
||||
# GL (Kaleidostrobe); made on first need, dropped if GL fails.
|
||||
self._canvas = None
|
||||
self._gl_failed = False
|
||||
|
||||
def _wants_gl(self) -> bool:
|
||||
return not self._gl_failed and self._visualizer.gl_renderer() is not None
|
||||
|
||||
def refresh(self):
|
||||
"""A new frame: repaint whichever surface is showing it."""
|
||||
if self._wants_gl():
|
||||
if self._canvas is None:
|
||||
from lintunes.gui.kaleido_gl import KaleidoGLView
|
||||
self._canvas = KaleidoGLView(self._visualizer, self)
|
||||
self._canvas.unavailable.connect(self._gl_unavailable)
|
||||
self._canvas.setToolTip(self.toolTip())
|
||||
self._canvas.setGeometry(self.contentsRect())
|
||||
if not self._canvas.isVisible():
|
||||
self._canvas.show()
|
||||
self._canvas.update()
|
||||
else:
|
||||
if self._canvas is not None and self._canvas.isVisible():
|
||||
self._canvas.hide()
|
||||
self.update()
|
||||
|
||||
def _gl_unavailable(self):
|
||||
self._gl_failed = True
|
||||
self.refresh()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if self._canvas is not None:
|
||||
self._canvas.setGeometry(self.contentsRect())
|
||||
|
||||
def open(self, screen=None):
|
||||
screen = screen or self.screen() or QApplication.primaryScreen()
|
||||
@@ -32,6 +65,7 @@ class VisualizerWindow(QWidget):
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
self.refresh()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if (event.button() == Qt.MouseButton.LeftButton
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -587,23 +588,43 @@ class Kaleidostrobe:
|
||||
return int(math.fma(_unit(self.rng), 17.999999, 1.0)) - 1
|
||||
|
||||
def _display(self) -> list:
|
||||
height = f32(math.fma(self.mid, 0.5, 2.0))
|
||||
if height < 2.0:
|
||||
height = f32(2.0)
|
||||
depth = f32(math.pow(self.avg_signal, 1.25)) if self.avg_signal >= 0 \
|
||||
else float('nan')
|
||||
if (self.beat and ((self.beat_count + 16) & 31) == 0) or self.bottom_object < 0:
|
||||
self.bottom_object = self._pick_object()
|
||||
if (self.beat and ((self.beat_count + 48) & 31) == 0) or self.top_object < 0:
|
||||
self.top_object = self._pick_object()
|
||||
spin = f32(self.rotation / 3.0)
|
||||
bottom = Layer(0, 1.0, 0.0, spin, self._object(
|
||||
self.bottom_object, f32(height / 1.5), f32(math.fma(depth, 10.0, -10.0))))
|
||||
top = Layer(1, 0.5, 5.0, f32(-self.rotation / 3.0), self._object(
|
||||
self.top_object, height, f32(depth * 5.0)))
|
||||
layers = self.layers(self.mid, self.avg_signal, self.rotation)
|
||||
self.rotation = (self.rotation + 1) % ROTATION_STEPS
|
||||
return layers
|
||||
|
||||
def layers(self, mid, avg_signal, rotation) -> list:
|
||||
"""display()'s geometry for the current objects: the cones' height
|
||||
from the mid band, their depth from the beat signal, the turn from
|
||||
``rotation`` (a frame count; the renderer may pass a fraction)."""
|
||||
height = f32(math.fma(mid, 0.5, 2.0))
|
||||
if height < 2.0:
|
||||
height = f32(2.0)
|
||||
depth = f32(math.pow(avg_signal, 1.25)) if avg_signal >= 0 \
|
||||
else float('nan')
|
||||
bottom = Layer(0, 1.0, 0.0, f32(rotation / 3.0), self._object(
|
||||
self.bottom_object, f32(height / 1.5), f32(math.fma(depth, 10.0, -10.0))))
|
||||
top = Layer(1, 0.5, 5.0, f32(-rotation / 3.0), self._object(
|
||||
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)
|
||||
|
||||
@@ -14,6 +14,7 @@ wave-table entry. The port has to reproduce them exactly.
|
||||
import hashlib
|
||||
import math
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -238,7 +239,8 @@ class TestRenderer:
|
||||
for _ in range(frames):
|
||||
r.advance(_frame(dt=0.04))
|
||||
ours = _paint(r, 96, 60)
|
||||
gl = _gl_reference(r._frame, 96, 60)
|
||||
layers, textures = r._shown # the in-between picture, not the frame
|
||||
gl = _gl_reference(SimpleNamespace(layers=layers, textures=textures), 96, 60)
|
||||
assert np.abs(ours - gl).max() <= 3
|
||||
|
||||
def test_fills_any_shape(self, qapp):
|
||||
@@ -250,14 +252,6 @@ class TestRenderer:
|
||||
magenta = (img[..., 0] == 255) & (img[..., 1] == 0) & (img[..., 2] == 255)
|
||||
assert not magenta.any() # the cone reaches every corner
|
||||
|
||||
def test_dim_mode_is_gray(self, qapp):
|
||||
r = KaleidostrobeRenderer()
|
||||
for _ in range(5):
|
||||
r.advance(_frame(dt=0.04))
|
||||
r.dimmed = True # the widget then passes its gray
|
||||
img = _paint(r, 120, 80, color="gray")
|
||||
assert np.abs(img[..., 0] - img[..., 2]).max() <= 2 # gray tint
|
||||
|
||||
def test_pause_blanks_but_keeps_the_plugin(self, qapp):
|
||||
r = KaleidostrobeRenderer()
|
||||
for _ in range(5):
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""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.
|
||||
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.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from PyQt6.QtGui import QColor
|
||||
|
||||
from lintunes.kaleidostrobe import engine as ks
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
for _ in range(5):
|
||||
r.advance(_frame(dt=0.04))
|
||||
r.dimmed = True # the widget then passes its gray
|
||||
r.panel = QColor(240, 236, 230)
|
||||
img = _paint(r, 120, 80, color="#606060")
|
||||
ground, ink = np.array([240, 236, 230.]), np.array([96, 96, 96.])
|
||||
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
|
||||
|
||||
def test_draws_between_the_plugins_frames(self, qapp):
|
||||
"""60 fps on screen: the turn moves every refresh, frames at 25."""
|
||||
r = KaleidostrobeRenderer()
|
||||
r.advance(_frame(dt=0.04))
|
||||
turns, frames = [], []
|
||||
for _ in range(12):
|
||||
r.advance(_frame(dt=1 / 60))
|
||||
turns.append(r._shown[0][0].turn)
|
||||
frames.append(r._engine.frame_count)
|
||||
assert len(set(turns)) == 12 # every refresh
|
||||
assert np.all(np.diff(turns) > 0) # smoothly onward
|
||||
assert frames[-1] - frames[0] == 4 # 12/60 s at 25 fps
|
||||
|
||||
class TestSyncSooner:
|
||||
def test_negative_delay_is_kept(self):
|
||||
assert vs.sync_delay_ms({"visualizer_sync_delay_ms": -30}) == -30
|
||||
assert vs.sync_delay_ms({"visualizer_sync_delay_ms": -999}) == vs.SYNC_DELAY_LIMITS[0]
|
||||
|
||||
def test_slider_reads_sooner_and_later(self, qapp, tmp_path, monkeypatch):
|
||||
from lintunes.gui import preferences_dialog as pd
|
||||
monkeypatch.setattr(pd, "load_config", lambda: {})
|
||||
dialog = pd.PreferencesDialog(Preferences(tmp_path), _LastFm())
|
||||
assert dialog._sync_slider.minimum() < 0
|
||||
assert pd._sync_text(-20) == "20 ms sooner"
|
||||
assert pd._sync_text(40) == "40 ms later"
|
||||
|
||||
|
||||
class TestBigWindow:
|
||||
def _vis(self, tmp_path, style):
|
||||
from lintunes.gui.visualizer import VisualizerWidget
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set("visualizer_style", style)
|
||||
return prefs, VisualizerWidget(_FakePlayer(), prefs)
|
||||
|
||||
def test_kaleidostrobe_gets_the_gl_canvas(self, qapp, tmp_path):
|
||||
prefs, vis = self._vis(tmp_path, "kaleidostrobe")
|
||||
vis.open_big()
|
||||
try:
|
||||
assert vis._big._canvas is not None and not vis._big._canvas.isHidden()
|
||||
prefs.set("visualizer_style", "bars") # another style
|
||||
vis._repaint()
|
||||
assert vis._big._canvas.isHidden()
|
||||
finally:
|
||||
vis._big.close()
|
||||
|
||||
def test_without_opengl_it_paints_with_qpainter(self, qapp, tmp_path):
|
||||
prefs, vis = self._vis(tmp_path, "kaleidostrobe")
|
||||
vis.open_big()
|
||||
try:
|
||||
vis._big._canvas.unavailable.emit() # no GL driver
|
||||
assert vis._big._canvas.isHidden()
|
||||
assert not vis._big._wants_gl()
|
||||
finally:
|
||||
vis._big.close()
|
||||
Reference in New Issue
Block a user