From 7ae55e757a5500b12fb2a5ec462ed6454eb93f81 Mon Sep 17 00:00:00 2001 From: trav Date: Wed, 14 Oct 2026 12:26:38 -0700 Subject: [PATCH] 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). --- CLAUDE.md | 22 ++- lintunes/__init__.py | 2 +- lintunes/gui/kaleido_gl.py | 71 +++++++ lintunes/gui/preferences_dialog.py | 18 +- lintunes/gui/visualizer.py | 23 ++- lintunes/gui/visualizer_kaleido.py | 305 +++++++++++++++++++++-------- lintunes/gui/visualizer_styles.py | 5 +- lintunes/gui/visualizer_window.py | 34 ++++ lintunes/kaleidostrobe/engine.py | 41 +++- tests/test_round84.py | 12 +- tests/test_round85.py | 102 ++++++++++ 11 files changed, 514 insertions(+), 121 deletions(-) create mode 100644 lintunes/gui/kaleido_gl.py create mode 100644 tests/test_round85.py diff --git a/CLAUDE.md b/CLAUDE.md index 1867947..49972d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/lintunes/__init__.py b/lintunes/__init__.py index f011870..4cf1719 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.53.0" +__version__ = "0.54.0" diff --git a/lintunes/gui/kaleido_gl.py b/lintunes/gui/kaleido_gl.py new file mode 100644 index 0000000..e010c19 --- /dev/null +++ b/lintunes/gui/kaleido_gl.py @@ -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()) diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 0d5621b..040d684 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -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) diff --git a/lintunes/gui/visualizer.py b/lintunes/gui/visualizer.py index 6c01eb0..4239a61 100644 --- a/lintunes/gui/visualizer.py +++ b/lintunes/gui/visualizer.py @@ -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) diff --git a/lintunes/gui/visualizer_kaleido.py b/lintunes/gui/visualizer_kaleido.py index c7cb753..e5445c8 100644 --- a/lintunes/gui/visualizer_kaleido.py +++ b/lintunes/gui/visualizer_kaleido.py @@ -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() diff --git a/lintunes/gui/visualizer_styles.py b/lintunes/gui/visualizer_styles.py index 4e58f09..fe4c6c5 100644 --- a/lintunes/gui/visualizer_styles.py +++ b/lintunes/gui/visualizer_styles.py @@ -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: diff --git a/lintunes/gui/visualizer_window.py b/lintunes/gui/visualizer_window.py index 2186f78..8e0c521 100644 --- a/lintunes/gui/visualizer_window.py +++ b/lintunes/gui/visualizer_window.py @@ -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 diff --git a/lintunes/kaleidostrobe/engine.py b/lintunes/kaleidostrobe/engine.py index 87c3215..e6e3cfb 100644 --- a/lintunes/kaleidostrobe/engine.py +++ b/lintunes/kaleidostrobe/engine.py @@ -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) diff --git a/tests/test_round84.py b/tests/test_round84.py index ff70ed5..9c0526a 100644 --- a/tests/test_round84.py +++ b/tests/test_round84.py @@ -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): diff --git a/tests/test_round85.py b/tests/test_round85.py new file mode 100644 index 0000000..ac0be7d --- /dev/null +++ b/tests/test_round85.py @@ -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()