"""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