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:
2026-10-14 12:26:38 -07:00
parent e38c751e39
commit 7ae55e757a
11 changed files with 514 additions and 121 deletions
+18 -4
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.53.0"
__version__ = "0.54.0"
+71
View File
@@ -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())
+13 -5
View File
@@ -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)
+19 -4
View File
@@ -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)
+218 -87
View File
@@ -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()
+4 -1
View File
@@ -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:
+34
View File
@@ -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
+31 -10
View File
@@ -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)
+3 -9
View File
@@ -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):
+102
View File
@@ -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()