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).
315 lines
12 KiB
Python
315 lines
12 KiB
Python
"""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
|