"""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): if pending is None: pending = np.zeros(0, np.float32) return vs.Frame(np.asarray(window, np.float32), RATE, pending, dt) def _config(**overrides): config = vs.settings(None) config.update({f"visualizer_{k}": v for k, v in overrides.items()}) return config def _tone(hz, amp=0.5): return _sine(hz, vs.HISTORY / RATE, amp) 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")) 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_settings_are_clamped(self, qapp, tmp_path): prefs = Preferences(tmp_path) prefs.set("visualizer_radial_spokes", 100000) # hand-edited prefs.set("visualizer_laser_pitch", "sideways") config = vs.settings(prefs) assert config["visualizer_radial_spokes"] == 360 assert config["visualizer_laser_pitch"] == 0 def test_every_style_paints_without_error(self, qapp): for style in vs.STYLES: renderer = vs.make_renderer(style) window = _tone(1000) 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 # -------------------------------------------------------------------------- def _spectrum(window, size): return vs.spectrum_of(np.asarray(window, np.float32), size, RATE) class TestBandLevels: def test_a_sine_lights_its_own_band(self): spectrum, freqs = _spectrum(_tone(1000), vs.BARS_FFT) edges = vs.band_edges(vs.BANDS, RATE) levels = vs.band_levels(spectrum, freqs, edges) assert levels.argmax() == np.searchsorted(edges, 1000) - 1 def test_bars_read_only_the_newest_2048_samples(self): # The history grew to 4096 for the radial; the bars must not see it. old = np.concatenate([_sine(3000, 2048 / RATE), _sine(1000, 2048 / RATE)]) spectrum, freqs = _spectrum(old, vs.BARS_FFT) assert abs(freqs[spectrum.argmax()] - 1000) < 30 def test_narrow_bass_bands_are_not_silent(self): spectrum, freqs = _spectrum(_tone(45), vs.RADIAL_FFT) edges = vs.band_edges(360, RATE) levels = vs.band_levels(spectrum, freqs, edges) assert levels[:5].min() > 0.5 def test_narrow_bands_are_not_flat_copies(self): # The flat arcs: runs of bass degrees all reading one bin. spectrum, freqs = _spectrum(_tone(120) + _tone(300, 0.2), vs.RADIAL_FFT) db = vs.band_db(spectrum, freqs, vs.band_edges(360, RATE)) bass = db[:120] runs = np.diff(np.flatnonzero(np.diff(bass) != 0)) assert len(runs) == 0 or runs.max() < 3 # -------------------------------------------------------------------------- # 3. Radial # -------------------------------------------------------------------------- class TestRadial: def test_frequency_is_the_angle(self): r = vs.RadialRenderer(_config(radial_normalize=False)) r.advance(_frame(_tone(1000))) edges = vs.band_edges(r.spokes, RATE) assert abs(int(r.rings.argmax()) - (np.searchsorted(edges, 1000) - 1)) <= 1 def test_volume_is_the_radius(self): config = _config(radial_normalize=False) loud, quiet = vs.RadialRenderer(config), vs.RadialRenderer(config) loud.advance(_frame(_tone(1000, amp=0.8))) quiet.advance(_frame(_tone(1000, amp=0.01))) assert loud.rings.max() > quiet.rings.max() > 0 def test_spokes_and_levels_are_settings(self): r = vs.RadialRenderer(_config(radial_spokes=36, radial_levels=8)) r.advance(_frame(_tone(1000, amp=0.9))) assert len(r.rings) == 36 and r.rings.max() <= 8 r.configure(_config(radial_spokes=200, radial_levels=30)) assert len(r.rings) == 200 and r.ghosts.shape == (200, 31) def test_a_falling_dot_leaves_ghosts_that_fade(self): r = vs.RadialRenderer(_config(ghost_fade=0.5, radial_normalize=False)) r.advance(_frame(_tone(1000, amp=0.8))) degree, top = int(r.rings.argmax()), int(r.rings.max()) silence = _frame(np.zeros(vs.HISTORY, 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_fading_off_means_no_ghosts(self): r = vs.RadialRenderer(_config(radial_fade=False)) r.advance(_frame(_tone(1000, amp=0.8))) r.advance(_frame(np.zeros(vs.HISTORY, np.float32))) assert r.ghost_alpha().max() == 0 def test_ghosts_are_the_dot_color_fading(self, qapp): r = vs.RadialRenderer(_config(radial_spokes=12, radial_normalize=False)) r.advance(_frame(_tone(1000, amp=0.8))) r.advance(_frame(np.zeros(vs.HISTORY, np.float32))) img = _paint(r, 400, 400) tinted = [QColor(img.pixel(x, y)) for x in range(0, 400, 2) for y in range(0, 400, 2)] tinted = [c for c in tinted if c != QColor("white")] # Every painted pixel is blue over white — never gray. assert tinted and all(c.blue() == 255 and c.red() == c.green() for c in tinted) 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(_config(ghost_fade=1.0, radial_normalize=False)) r.advance(_frame(_tone(1000, amp=0.003))) before = r.rings.copy() r.advance(_frame(_tone(1000, 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(_config(ghost_fade=3.0)) rng = np.random.default_rng(1) for _ in range(200): r.advance(_frame(_tone(1000, amp=rng.random()))) rings, alpha, _angle = r.pooled(45) assert len(rings) <= 45 assert (alpha > 0).sum(axis=1).max() <= r.ring_count def test_evening_out_lines_bass_up_with_treble(self): # Pink-ish: every octave 6 dB quieter than the one below. t = np.arange(vs.HISTORY) / RATE audio = sum(0.5 / 2 ** k * np.sin(2 * np.pi * 55 * 2 ** k * t) for k in range(8)).astype(np.float32) spread = {} for normalize in (False, True): r = vs.RadialRenderer(_config(radial_normalize=normalize, radial_spokes=360)) for _ in range(300): r.advance(_frame(audio)) edges = vs.band_edges(360, RATE) peaks = [r.levels[np.searchsorted(edges, 55 * 2 ** k) - 1] for k in range(8)] spread[normalize] = max(peaks) - min(peaks) assert spread[True] < spread[False] / 2 def test_evening_out_never_draws_frequencies_the_song_lacks(): # A lone tone: the fit has one loud point to go on. Nothing far from # it may be lifted into view. r = vs.RadialRenderer(_config(radial_normalize=True, radial_spokes=360)) for _ in range(300): r.advance(_frame(_tone(180, 0.1))) edges = vs.band_edges(360, RATE) far = np.searchsorted(edges, 2000) assert r.rings[far:].max() == 0 # -------------------------------------------------------------------------- # 4. Laser # -------------------------------------------------------------------------- def _run(renderer, audio, hop=706): for i in range(0, len(audio) - hop, hop): renderer.advance(_frame(np.zeros(vs.HISTORY, np.float32), audio[i:i + hop])) return renderer class TestLaser: def test_silence_is_a_still_dot_in_the_middle(self): r = _run(vs.LaserRenderer(), np.zeros(RATE // 4, np.float32)) assert np.allclose(r.paths[0], 0.0) def test_the_membrane_answers_its_resonance(self): f = vs.MODES[1][0] on, off = vs.LaserRenderer(), vs.LaserRenderer() swing_on = np.abs(on.drive(_sine(f, 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_the_split_pair_draws_loops(self): # Between the (1,1) pair's resonances the two axes answer a quarter # cycle apart: nearly a circle, 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.4 def test_farther_screen_bigger_figure(self): audio = _sine(vs.MODES[1][0], 1.0) near = _run(vs.LaserRenderer(_config(laser_distance=1.0)), audio) far = _run(vs.LaserRenderer(_config(laser_distance=4.0)), audio) ratio = np.abs(far.paths[0]).max() / np.abs(near.paths[0]).max() assert 3.5 < ratio < 4.5 def test_pitch_stretches_vertically_and_roll_spins(self): audio = _sine((vs.MODES[1][0] + vs.MODES[2][0]) / 2, 1.0) def spread(**angles): path = _run(vs.LaserRenderer(_config(**angles)), audio).paths[0] return np.ptp(path[:, 0]), np.ptp(path[:, 1]) square_x, square_y = spread() tipped_x, tipped_y = spread(laser_pitch=50) assert tipped_y / square_y > 1.3 * tipped_x / square_x turned_x, turned_y = spread(laser_yaw=50) assert turned_x / square_x > 1.3 * turned_y / square_y spun = _run(vs.LaserRenderer(_config(laser_roll=90)), audio).paths[0] flat = _run(vs.LaserRenderer(_config()), audio).paths[0] # A quarter turn: x becomes y (up to sign). assert np.allclose(np.abs(spun[:, 0]), np.abs(flat[:, 1]), atol=1e-9) @pytest.mark.parametrize("angles", [ {}, {"laser_pitch": 60, "laser_yaw": -60}, {"laser_pitch": -60, "laser_yaw": 60, "laser_roll": 45}, {"laser_distance": 1.0, "laser_pitch": 60}]) def test_every_setting_stays_on_screen_and_visible(self, angles): rng = np.random.default_rng(0) audio = _sine(90, 2, 0.9) * (1 + rng.standard_normal(2 * RATE) * 0.3) audio = audio + _sine(150, 2, 0.5) r = vs.LaserRenderer(_config(**angles)) rect = QRectF(0, 0, 140, 62) half_w, half_h = 70 - vs.INSET, 31 - vs.INSET biggest = 0.0 for i in range(0, len(audio) - 706, 706): r.advance(_frame(np.zeros(vs.HISTORY, np.float32), audio[i:i + 706])) px = r.paths[0] * r.scale(rect) assert np.abs(px[:, 0]).max() <= half_w assert np.abs(px[:, 1]).max() <= half_h biggest = max(biggest, np.abs(px).max()) assert biggest > 0.2 * half_h # and never shrunk to a dot def test_fading_off_keeps_only_this_frame(self): audio = _sine(150, 0.5) assert len(_run(vs.LaserRenderer(), audio).paths) == len(vs.PERSIST) r = _run(vs.LaserRenderer(_config(laser_fade=False)), audio) assert len(r.paths) == 1 # -------------------------------------------------------------------------- # 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: 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: 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_show_that_styles_settings(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) assert dialog._viz_radios["bars"].isChecked() assert dialog._viz_pages.currentIndex() == 0 dialog._viz_radios["radial"].setChecked(True) assert prefs.get("visualizer_style") == "radial" assert dialog._viz_pages.currentIndex() == 1 dialog._viz_radios["laser"].setChecked(True) assert dialog._viz_pages.currentIndex() == 2 def test_radial_settings(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) assert dialog._fade_slider.isEnabled() dialog._fade_slider.setValue(25) assert prefs.get("visualizer_ghost_fade") == 2.5 dialog._radial_fade.setChecked(False) assert prefs.get("visualizer_radial_fade") is False assert not dialog._fade_slider.isEnabled() dialog._spokes_slider.setValue(60) dialog._levels_slider.setValue(10) dialog._normalize.setChecked(False) assert (prefs.get("visualizer_radial_spokes"), prefs.get("visualizer_radial_levels"), prefs.get("visualizer_radial_normalize")) == (60, 10, False) def test_laser_settings(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) dialog._laser_fade.setChecked(False) dialog._distance_slider.setValue(15) dialog._angle_sliders["visualizer_laser_pitch"].setValue(30) dialog._angle_sliders["visualizer_laser_roll"].setValue(-90) assert prefs.get("visualizer_laser_fade") is False assert prefs.get("visualizer_laser_distance") == 1.5 assert prefs.get("visualizer_laser_pitch") == 30 lo, hi = vs.LIMITS["visualizer_laser_roll"] slider = dialog._angle_sliders["visualizer_laser_roll"] assert (slider.minimum(), slider.maximum()) == (lo, hi) 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