Files
lintunes/tests/test_round84.py
T
trav 7ae55e757a 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).
2026-10-14 12:26:38 -07:00

315 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Round 84: Kaleidostrobe, the 2002 iTunes visualizer, as the fourth style.
``kaleidostrobe_golden.npz`` was recorded from the *original* PowerPC plugin,
run under emulation through its own VisualPluginHandler: every frame's two
uploaded textures (as digests) and every triangle it handed OpenGL, for a
fixed run of spectra; plus the output of each warp (calcDelta) and each
wave-table entry. The port has to reproduce them exactly.
1. The engine against the original: textures byte for byte, geometry.
2. The pieces: warps, wave shapes, transform's quirks, Darwin's rand().
3. The renderer: 25 fps, the GL picture, dim mode, pause.
4. The style in the app.
"""
import hashlib
import math
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from PyQt6.QtCore import QObject, QRectF, pyqtSignal
from PyQt6.QtGui import QColor, QImage, QPainter
from lintunes.kaleidostrobe import engine as ks
from lintunes.gui import visualizer_styles as vs
from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer, itunes_spectrum
from lintunes.preferences import Preferences
GOLDEN = np.load(Path(__file__).with_name("kaleidostrobe_golden.npz"))
RATE = 44100
def _digest(a):
return hashlib.sha256(np.ascontiguousarray(a).tobytes()).hexdigest()[:16]
def _pattern(seed):
y, x = np.mgrid[0:128, 0:128]
return ((x * (7 + seed) + y * (13 + 2 * seed)) ^ (x * y + seed)).astype(np.uint8)
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]])
def _lift(z):
m = np.eye(4)
m[2, 3] = z
return m
def _eye_space(frame):
"""Each triangle as (s, t, x, y, z) × 3 in eye space, the way the
golden file recorded the original's GL calls."""
out = []
for layer in frame.layers:
base = _lift(-ks.EYE_Z) @ _lift(layer.lift) @ _rotz(layer.turn)
for tri in layer.triangles:
m = base @ _rotz(tri.turn)
out.append([(*tri.st[i], *(m @ np.array((*tri.xyz[i], 1.0)))[:3])
for i in range(3)])
return np.array(out)
# --------------------------------------------------------------------------
# 1. The engine against the original
# --------------------------------------------------------------------------
class TestAgainstTheOriginal:
def test_every_frame_matches(self):
engine = ks.Kaleidostrobe(seed=1000, ticks=1000) # srand(TickCount())
engine.set_size(640, 480)
counts = GOLDEN["tri_counts"]
offsets = np.concatenate([[0], np.cumsum(counts)])
for f, (spectrum, ticks) in enumerate(zip(GOLDEN["spectra"], GOLDEN["ticks"])):
frame = engine.step(spectrum, int(ticks))
for tex in (0, 1):
assert _digest(frame.textures[tex]) == GOLDEN["tex_digest"][f][tex], \
f"frame {f}, texture {tex}"
ours = _eye_space(frame)
theirs = GOLDEN["tris"][offsets[f]:offsets[f + 1]]
assert ours.shape == theirs.shape, f"frame {f}"
np.testing.assert_allclose(ours[..., :2], theirs[..., :2], atol=1e-4)
np.testing.assert_allclose(ours[..., 2:], theirs[..., 2:], atol=1e-3)
def test_the_run_saw_beats_and_switches(self):
"""The recorded run must exercise more than the opening state."""
engine = ks.Kaleidostrobe(seed=1000, ticks=1000)
deltas = set()
for spectrum, ticks in zip(GOLDEN["spectra"], GOLDEN["ticks"]):
engine.step(spectrum, int(ticks))
deltas.add(tuple(engine.cur_delta))
assert engine.beat_count >= 8
assert len(deltas) > 1
# --------------------------------------------------------------------------
# 2. The pieces
# --------------------------------------------------------------------------
class TestPieces:
@pytest.mark.parametrize("i, n", list(enumerate(ks.DELTA_TABLE)))
def test_every_warp_matches(self, i, n):
state = {}
for it in range(3):
out = ks.delta(n, _pattern(n * 3 + it), state)
assert _digest(out) == GOLDEN["delta_digest"][i][it], f"iteration {it}"
@pytest.mark.parametrize("idx", range(len(ks.WAVE_TABLE)))
def test_every_wave_matches(self, idx):
engine = ks.Kaleidostrobe(seed=1, ticks=1000)
engine.rng = ks.ParkMiller(4321)
engine.cur_wave[0] = idx
engine._init_wave(0)
assert _digest(engine.buffers[0, 0]) == GOLDEN["wave_digest"][idx]
def test_rand_is_darwins(self):
rng = ks.ParkMiller(1)
assert [rng.rand() for _ in range(3)] == [16807, 282475249, 1622650073]
def test_flip_diag_keeps_its_off_by_one(self):
img = np.arange(128 * 128, dtype=np.int64).reshape(128, 128) % 251
img = img.astype(np.uint8)
before = img.copy()
ks.flip_diag(img)
assert img[10, 3] == before[3, 10] # transposed…
assert img[10, 9] == before[10, 9] # …except beside the diagonal
assert img[9, 10] == before[9, 10]
def test_inside_out_flips_each_half(self):
img = np.repeat(np.arange(128, dtype=np.uint8)[:, None], 128, 1)
ks.inside_out(img)
assert img[0, 0] == 63 and img[63, 0] == 0
assert img[64, 0] == 127 and img[127, 0] == 64
def test_a_reselected_warp_resumes_its_own_counter(self):
engine = ks.Kaleidostrobe(seed=3, ticks=1000)
engine.cur_delta = [0, 0] # calcDelta000 on both layers
for _ in range(5):
engine._update_textures()
engine.cur_delta[0] = 1 # another warp: its own counter, 0
engine._update_textures()
assert engine.delta_state[0][1] == {'h': 1, 'v': 1}
assert engine.delta_state[0][0] == {'h': 5}
# --------------------------------------------------------------------------
# 3. The renderer
# --------------------------------------------------------------------------
def _frame(dt=0.016, seconds=4096 / RATE, hz=60.0):
t = np.arange(int(seconds * RATE)) / RATE
samples = (0.4 * np.sin(2 * np.pi * hz * t)).astype(np.float32)
return vs.Frame(samples, RATE, np.zeros(0, np.float32), dt)
def _paint(renderer, w, h, background="white", color="blue"):
img = QImage(w, h, QImage.Format.Format_RGB32)
img.fill(QColor(background))
painter = QPainter(img)
renderer.paint(painter, QRectF(0, 0, w, h), QColor(color), QColor("gray"))
painter.end()
a = np.frombuffer(img.constBits().asstring(img.sizeInBytes()), np.uint8)
return a.reshape(h, w, 4)[..., [2, 1, 0]].astype(float)
def _bilinear(tex, s, t):
u, v = s * 128 - 0.5, t * 128 - 0.5
u0, v0 = np.floor(u).astype(int), np.floor(v).astype(int)
fu, fv = (u - u0)[..., None], (v - v0)[..., None]
T = tex.astype(float)
def at(y, x):
return T[y % 128, x % 128]
return (at(v0, u0) * (1 - fu) * (1 - fv) + at(v0, u0 + 1) * fu * (1 - fv)
+ at(v0 + 1, u0) * (1 - fu) * fv + at(v0 + 1, u0 + 1) * fu * fv)
def _gl_reference(frame, w, h):
"""What OpenGL drew: cast a ray through each pixel centre, take the
nearest triangle, interpolate (s, t), GL_LINEAR + GL_REPEAT, blend."""
f = 1 / math.tan(math.radians(ks.FOVY) / 2)
px, py = np.meshgrid(np.arange(w) + 0.5, np.arange(h) + 0.5)
d = np.stack([(2 * px / w - 1) * (w / h) / f, (1 - 2 * py / h) / f,
-np.ones_like(px)], -1)
out = np.zeros((h, w, 3))
for layer in frame.layers:
depth = np.full((h, w), -np.inf)
col = np.zeros((h, w, 3))
hit = np.zeros((h, w), bool)
base = _lift(-ks.EYE_Z) @ _lift(layer.lift) @ _rotz(layer.turn)
for tri in layer.triangles:
m = base @ _rotz(tri.turn)
p = np.array([(m @ np.array((*v, 1.0)))[:3] for v in tri.xyz])
e1, e2 = p[1] - p[0], p[2] - p[0]
n = np.cross(e1, e2)
with np.errstate(divide='ignore', invalid='ignore'):
x = d * ((p[0] @ n) / (d @ n))[..., None]
d00, d01, d11 = e1 @ e1, e1 @ e2, e2 @ e2
v2 = x - p[0]
den = d00 * d11 - d01 * d01
b1 = (d11 * (v2 @ e1) - d01 * (v2 @ e2)) / den
b2 = (d00 * (v2 @ e2) - d01 * (v2 @ e1)) / den
b0 = 1 - b1 - b2
take = (b0 >= 0) & (b1 >= 0) & (b2 >= 0) & (x[..., 2] > depth)
st = np.array(tri.st)
s = b0 * st[0, 0] + b1 * st[1, 0] + b2 * st[2, 0]
t = b0 * st[0, 1] + b1 * st[1, 1] + b2 * st[2, 1]
c = _bilinear(frame.textures[layer.texture][..., :3], s, t)
col[take] = c[take]
depth[take] = x[..., 2][take]
hit |= take
out[hit] = out[hit] * (1 - layer.alpha) + col[hit] * layer.alpha
return out
class TestRenderer:
def test_runs_at_the_plugins_25_fps(self, qapp):
r = KaleidostrobeRenderer()
for _ in range(125): # 2 s of 16 ms widget ticks
r.advance(_frame())
assert r._engine.frame_count == 50
def test_a_stall_is_not_replayed(self, qapp):
r = KaleidostrobeRenderer()
r.advance(_frame())
r.advance(_frame(dt=5.0)) # the GUI thread froze for 5 s
assert r._engine.frame_count <= 1 + 3
@pytest.mark.parametrize("frames", [3, 30, 90])
def test_paints_what_opengl_drew(self, qapp, frames):
r = KaleidostrobeRenderer()
r.advance(_frame())
r._engine.set_size(96, 60)
for _ in range(frames):
r.advance(_frame(dt=0.04))
ours = _paint(r, 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):
r = KaleidostrobeRenderer()
for _ in range(5):
r.advance(_frame(dt=0.04))
for w, h in ((140, 36), (300, 900), (4, 4)):
img = _paint(r, w, h, background="magenta")
magenta = (img[..., 0] == 255) & (img[..., 1] == 0) & (img[..., 2] == 255)
assert not magenta.any() # the cone reaches every corner
def test_pause_blanks_but_keeps_the_plugin(self, qapp):
r = KaleidostrobeRenderer()
for _ in range(5):
r.advance(_frame(dt=0.04))
engine, count = r._engine, r._engine.frame_count
r.clear()
img = _paint(r, 40, 30, background="white")
assert (img == 255).all() # nothing drawn
r.advance(_frame(dt=0.04))
assert r._engine is engine and engine.frame_count == count + 1
@pytest.mark.parametrize("rate", [44100, 48000, 96000])
def test_spectrum_bins_are_itunes_43_hz(self, rate):
t = np.arange(4096) / rate
tone = (0.5 * np.sin(2 * np.pi * 1000 * t)).astype(np.float32)
spec = itunes_spectrum(tone, rate)
assert spec.shape == (2, 512) and spec.dtype == np.uint8
assert abs(int(spec[0].argmax()) - round(1000 / (44100 / 1024))) <= 1
assert (spec[0] == spec[1]).all()
def test_silence_is_zero(self):
assert not itunes_spectrum(np.zeros(4096, np.float32), RATE).any()
# --------------------------------------------------------------------------
# 4. In the app
# --------------------------------------------------------------------------
class _FakePlayer(QObject):
audio_buffer = pyqtSignal(object)
playing_changed = pyqtSignal(bool)
track_changed = pyqtSignal(object)
def is_playing(self):
return False
class _LastFm(QObject):
login_finished = pyqtSignal(bool, str)
status_message = pyqtSignal(str)
def is_logged_in(self):
return False
class TestInTheApp:
def test_is_a_style(self, qapp, tmp_path):
from lintunes.gui.visualizer import VisualizerWidget
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "kaleidostrobe")
vis = VisualizerWidget(_FakePlayer(), prefs)
assert isinstance(vis._renderer, KaleidostrobeRenderer)
def test_preferences_offers_it(self, qapp, tmp_path):
from lintunes.gui.preferences_dialog import PreferencesDialog
prefs = Preferences(tmp_path)
dialog = PreferencesDialog(prefs, _LastFm())
dialog._viz_radios["kaleidostrobe"].setChecked(True)
assert prefs.get("visualizer_style") == "kaleidostrobe"
assert dialog._viz_pages.currentIndex() == 3