v0.39.0: visualizer styles — radial dots and a laser, double-click to enlarge

Preferences → Appearance → Visualizer picks Bars (as before), Radial dots
(the EQ in a circle: angle is frequency, radius is volume, moving dots
leave light-gray ghosts that fade over a set time), or Laser (a mirror on
a speaker membrane bouncing a beam onto a screen: simulated membrane
modes, D·tan(2θ) reflection, dwell-weighted brightness). Double-clicking
the panel opens it big; a click closes it.
This commit is contained in:
2026-10-10 00:26:29 -07:00
parent f079d26416
commit a81ab1e163
8 changed files with 1155 additions and 85 deletions
+357
View File
@@ -0,0 +1,357 @@
"""Round 78: visualizer styles — bars, radial dots, laser — and the big window.
1. ``visualizer_style`` picks the renderer, live; nonsense falls back to bars.
2. ``band_levels`` is the bars' old analysis, and fills too-narrow bands.
3. Radial: frequency is the angle, volume the radius, moves leave ghosts.
4. Laser: a mirror on a membrane — resonance, continuity, stays on screen.
5. Double-click opens the big window without changing the brightness.
6. The Preferences page.
"""
import numpy as np
import pytest
from PyQt6.QtCore import QObject, pyqtSignal, QRectF
from PyQt6.QtGui import QImage, QPainter, QColor
from lintunes.preferences import Preferences
from lintunes.gui.visualizer import VisualizerWidget, MODE_ON, MODE_DIM, MODE_OFF
from lintunes.gui import visualizer_styles as vs
RATE = 44100
class _FakePlayer(QObject):
audio_buffer = pyqtSignal(object)
playing_changed = pyqtSignal(bool)
track_changed = pyqtSignal(object)
def __init__(self, playing=False):
super().__init__()
self._playing = playing
def is_playing(self):
return self._playing
def _sine(hz, seconds=0.1, amp=0.5):
t = np.arange(int(RATE * seconds)) / RATE
return (amp * np.sin(2 * np.pi * hz * t)).astype(np.float32)
def _frame(window, pending=None, dt=0.016):
spectrum = np.abs(np.fft.rfft(window * np.hanning(len(window)))) / (len(window) / 4)
freqs = np.fft.rfftfreq(len(window), d=1.0 / RATE)
if pending is None:
pending = np.zeros(0, np.float32)
return vs.Frame(spectrum, freqs, RATE, pending, dt)
def _paint(renderer, w=140, h=62):
img = QImage(w, h, QImage.Format.Format_ARGB32)
img.fill(QColor("white"))
painter = QPainter(img)
renderer.paint(painter, QRectF(0, 0, w, h), QColor("blue"), QColor("gray"))
painter.end()
return img
# --------------------------------------------------------------------------
# 1. The style preference
# --------------------------------------------------------------------------
class TestStylePreference:
def test_default_is_bars(self, qapp, tmp_path):
vis = VisualizerWidget(_FakePlayer(), Preferences(tmp_path))
assert isinstance(vis._renderer, vs.BarsRenderer)
@pytest.mark.parametrize("style, cls", [
("radial", vs.RadialRenderer), ("laser", vs.LaserRenderer)])
def test_saved_style_is_used(self, qapp, tmp_path, style, cls):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", style)
assert isinstance(VisualizerWidget(_FakePlayer(), prefs)._renderer, cls)
def test_nonsense_falls_back_to_bars(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "oscilloscope") # hand-edited file
vis = VisualizerWidget(_FakePlayer(), prefs)
assert isinstance(vis._renderer, vs.BarsRenderer)
def test_changing_the_pref_swaps_live(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(), prefs)
prefs.set("visualizer_style", "laser")
assert isinstance(vis._renderer, vs.LaserRenderer)
def test_an_unrelated_pref_keeps_the_renderer(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "radial")
vis = VisualizerWidget(_FakePlayer(), prefs)
before = vis._renderer
prefs.set("visualizer_ghost_fade", 2.5)
assert vis._renderer is before # state survives
assert before.fade == 2.5
def test_every_style_paints_without_error(self, qapp):
for style in vs.STYLES:
renderer = vs.make_renderer(style)
window = _sine(1000, 2048 / RATE)
for _ in range(5):
renderer.advance(_frame(window, _sine(90, 0.016)))
for size in ((140, 62), (1200, 750), (4, 4)):
_paint(renderer, *size)
# --------------------------------------------------------------------------
# 2. Shared analysis
# --------------------------------------------------------------------------
class TestBandLevels:
def test_a_sine_lights_its_own_band(self):
frame = _frame(_sine(1000, 2048 / RATE))
edges = vs.band_edges(vs.BANDS, RATE)
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
band = np.searchsorted(edges, 1000) - 1
assert levels.argmax() == band
def test_narrow_bass_bands_are_not_silent(self):
# At 360 bands the bottom ones are narrower than an FFT bin.
frame = _frame(_sine(45, 2048 / RATE))
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
assert levels[:5].min() > 0.5
# --------------------------------------------------------------------------
# 3. Radial
# --------------------------------------------------------------------------
class TestRadial:
def test_frequency_is_the_angle(self):
r = vs.RadialRenderer()
r.advance(_frame(_sine(1000, 2048 / RATE)))
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
degree = np.searchsorted(edges, 1000) - 1
assert abs(int(r.rings.argmax()) - degree) <= 2
def test_volume_is_the_radius(self):
loud, quiet = vs.RadialRenderer(), vs.RadialRenderer()
loud.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
quiet.advance(_frame(_sine(1000, 2048 / RATE, amp=0.01)))
assert loud.rings.max() > quiet.rings.max() > 0
def test_a_falling_dot_leaves_ghosts_that_fade(self):
r = vs.RadialRenderer(fade=0.5)
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
degree, top = int(r.rings.argmax()), int(r.rings.max())
silence = _frame(np.zeros(2048, np.float32))
r.advance(silence)
assert r.ghost_alpha()[degree, top] > 0.9 # where it was, fresh
for _ in range(20): # 0.32 s
r.advance(silence)
assert 0 < r.ghost_alpha()[degree, top] < 0.9
for _ in range(40): # well past 0.5 s
r.advance(silence)
assert r.ghost_alpha().max() == 0
assert not np.isfinite(r.ghosts).any() # forgotten, not just faint
def test_no_fade_means_no_ghosts(self):
r = vs.RadialRenderer(fade=0)
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
r.advance(_frame(np.zeros(2048, np.float32)))
assert r.ghost_alpha().max() == 0
def test_a_rise_leaves_only_where_it_came_from(self):
# An instant attack must not paint a bar of ghosts below the dot.
r = vs.RadialRenderer(fade=1.0)
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.003)))
before = r.rings.copy()
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
degree = int(np.argmax(r.rings - before))
alive = np.nonzero(r.ghost_alpha()[degree] > 0)[0]
assert list(alive) == [before[degree]]
def test_ghosts_per_spoke_are_bounded(self):
r = vs.RadialRenderer(fade=3.0)
rng = np.random.default_rng(1)
for _ in range(200):
r.advance(_frame(_sine(1000, 2048 / RATE, amp=rng.random())))
rings, alpha = r.pooled(45)
assert (alpha > 0).sum(axis=1).max() <= vs.STEPS
def test_bigger_circles_get_more_spokes(self):
assert vs.spoke_count(28) < vs.spoke_count(400) == 360
assert 360 % vs.spoke_count(28) == 0
# --------------------------------------------------------------------------
# 4. Laser
# --------------------------------------------------------------------------
class TestLaser:
def test_silence_is_a_still_dot_in_the_middle(self):
r = vs.LaserRenderer()
for _ in range(10):
r.advance(_frame(np.zeros(2048, np.float32),
np.zeros(706, np.float32)))
assert np.allclose(r.paths[0], 0.0)
def test_the_membrane_answers_its_resonance(self):
f0 = vs.MODES[0][0]
on, off = vs.LaserRenderer(), vs.LaserRenderer()
swing_on = np.abs(on.drive(_sine(f0, 0.5), RATE)).max()
swing_off = np.abs(off.drive(_sine(3000, 0.5), RATE)).max()
assert swing_on > 20 * swing_off
def test_chunking_does_not_change_the_path(self):
audio = _sine(143, 0.3) + _sine(90, 0.3)
whole = vs.LaserRenderer().drive(audio, RATE)
pieces = vs.LaserRenderer()
parts = [pieces.drive(chunk, RATE)
for chunk in np.array_split(audio, [101, 700, 702, 5000])]
assert np.allclose(np.concatenate(parts), whole)
def test_degenerate_modes_turn_a_line_into_a_loop(self):
# The split (1,1) pair: driven between its two resonances, the two
# axes are out of phase — an ellipse, not a line.
f = (vs.MODES[1][0] + vs.MODES[2][0]) / 2
tilt = vs.LaserRenderer().drive(_sine(f, 1.0), RATE)[-2000:]
tilt = tilt - tilt.mean(axis=0)
s = np.linalg.svd(tilt, compute_uv=False)
assert s[1] / s[0] > 0.1
def test_the_spot_stays_on_the_screen(self):
r = vs.LaserRenderer()
rng = np.random.default_rng(0)
audio = (_sine(90, 2, 0.9) * (1 + rng.standard_normal(2 * RATE) * 0.3))
hop = 706
for i in range(0, len(audio) - hop, hop):
r.advance(_frame(np.zeros(2048, np.float32),
audio[i:i + hop], dt=0.016))
assert np.abs(r.paths[0]).max() <= 1.0
def test_loud_music_fills_most_of_the_screen(self):
r = vs.LaserRenderer()
audio = _sine(vs.MODES[0][0], 1.0, 0.5)
for i in range(0, len(audio) - 706, 706):
r.advance(_frame(np.zeros(2048, np.float32), audio[i:i + 706]))
assert np.abs(r.paths[0]).max() > 0.6
# --------------------------------------------------------------------------
# 5. Double-click → big window
# --------------------------------------------------------------------------
class TestBigWindow:
def test_double_click_keeps_the_brightness_and_opens(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
vis.mousePressEvent(None) # Qt's first press: on -> dim
vis.mouseDoubleClickEvent(None) # …which the double-click undoes
try:
assert vis._mode == MODE_ON
assert prefs.get("visualizer_mode") == MODE_ON
assert vis._big is not None and vis._big.isVisible()
finally:
vis._big.close()
def test_a_click_closes_it(self, qapp):
from PyQt6.QtCore import Qt, QPointF, QEvent
from PyQt6.QtGui import QMouseEvent
vis = VisualizerWidget(_FakePlayer())
vis.open_big()
big = vis._big
center = QPointF(big.contentsRect().center())
release = QMouseEvent(
QEvent.Type.MouseButtonRelease, center, center,
Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier)
big.mouseReleaseEvent(release)
assert not big.isVisible()
def test_off_panel_still_animates_the_big_window(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_OFF)
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
assert not vis._timer.isActive()
vis.open_big()
assert vis._timer.isActive()
vis._big.close()
assert not vis._timer.isActive()
def test_big_window_paints_in_color_while_the_panel_is_off(
self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_OFF)
prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
calls = []
vis._renderer.paint = lambda p, rect, color, light: calls.append(rect)
img = QImage(10, 10, QImage.Format.Format_ARGB32)
painter = QPainter(img)
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
assert calls == [] # the panel stays blank
vis.paint_renderer(painter, QRectF(0, 0, 10, 10), big=True)
painter.end()
assert len(calls) == 1
def test_the_laser_is_drawn_in_gray_when_dim(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_DIM)
prefs.set("color_visualizer_gray", 120)
prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(), prefs)
colors = []
vis._renderer.paint = lambda p, rect, color, light: colors.append(color)
img = QImage(10, 10, QImage.Format.Format_ARGB32)
painter = QPainter(img)
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
painter.end()
assert colors[0].red() == colors[0].green() == colors[0].blue() == 120
def test_pending_audio_is_capped(self, qapp):
vis = VisualizerWidget(_FakePlayer())
for _ in range(100):
vis._feed(_sine(440, 0.05), RATE)
assert vis._pending_len <= RATE * 0.25 + RATE * 0.05
# --------------------------------------------------------------------------
# 6. Preferences
# --------------------------------------------------------------------------
class TestPreferencesPage:
def _dialog(self, prefs):
from lintunes.gui.preferences_dialog import PreferencesDialog
class _LastFm(QObject):
login_finished = pyqtSignal(bool, str)
status_message = pyqtSignal(str)
def is_logged_in(self):
return False
return PreferencesDialog(prefs, _LastFm())
def test_style_radios_and_fade_slider(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = self._dialog(prefs)
assert dialog._viz_radios["bars"].isChecked()
assert not dialog._fade_slider.isEnabled()
dialog._viz_radios["radial"].setChecked(True)
assert prefs.get("visualizer_style") == "radial"
assert dialog._fade_slider.isEnabled()
dialog._fade_slider.setValue(25)
assert prefs.get("visualizer_ghost_fade") == 2.5
dialog._viz_radios["laser"].setChecked(True)
assert not dialog._fade_slider.isEnabled()
def test_building_the_dialog_writes_nothing(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "laser")
before = (tmp_path / "preferences.json").read_text()
self._dialog(prefs)
assert (tmp_path / "preferences.json").read_text() == before