"""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(ghost_fade=0)) 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.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][0]).max() / np.abs(near.paths[0][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][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][0] flat = _run(vs.LaserRenderer(_config()), audio).paths[0][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][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_afterimage_lasts_its_fade_time(self): audio = _sine(150, 1.0) r = _run(vs.LaserRenderer(_config(laser_afterimage=0.2)), audio) ages = [r.clock - born for _p, born, _poly in r.paths[1:]] assert ages and max(ages) < 0.2 + 1e-9 assert 10 <= len(r.paths) <= 14 # 0.2 s of 16 ms frames def test_afterimage_zero_keeps_only_this_frame(self): r = _run(vs.LaserRenderer(_config(laser_afterimage=0)), _sine(150, 0.5)) assert len(r.paths) == 1 def test_head_dot_and_glow_settings(self, qapp): audio = _sine(150, 0.5) def painted(**kw): r = _run(vs.LaserRenderer(_config(**kw)), audio) img = _paint(r, 400, 250) return sum(QColor(img.pixel(x, y)) != QColor("white") for x in range(0, 400, 2) for y in range(0, 250, 2)) full = painted() assert painted(laser_glow=False) < full assert painted(laser_head=False) <= full assert painted(laser_width=6) > painted(laser_width=1) def test_grid_is_drawn_under_the_beam_only_when_on(self, qapp): calls = [] def paint(renderer, gray): renderer.paint_grid = lambda painter, rect, g: calls.append(g) img = QImage(400, 250, QImage.Format.Format_ARGB32) painter = QPainter(img) renderer.paint(painter, QRectF(0, 0, 400, 250), QColor("blue"), gray) painter.end() paint(vs.LaserRenderer(_config(laser_grid=False)), QColor("gray")) assert calls == [] paint(vs.LaserRenderer(_config(laser_grid=True)), QColor("gray")) assert len(calls) == 1 def test_grid_dots_land_on_divisions(self, qapp): r = vs.LaserRenderer(_config(laser_grid=True)) img = QImage(400, 250, QImage.Format.Format_ARGB32) img.fill(QColor("white")) painter = QPainter(img) r.paint_grid(painter, QRectF(0, 0, 400, 250), QColor("black")) painter.end() d = (125 - vs.INSET) / vs.GRID_HALF on = QColor(img.pixel(200 + round(d), 125 + round(d))) between = QColor(img.pixel(200 + round(d / 2), 125 + round(d / 2))) assert on != QColor("white") and between == QColor("white") def test_resting_beam_always_shows_its_dot(self, qapp): r = _run(vs.LaserRenderer(_config(laser_head=False, laser_dot=10)), np.zeros(RATE // 4, np.float32)) img = _paint(r, 400, 250) assert QColor(img.pixel(200, 125)) != QColor("white") class TestBarsFade: def test_a_falling_bar_leaves_fading_steps(self): r = vs.BarsRenderer(_config(bars_fade=0.5)) r.advance(_frame(_tone(1000, 0.8))) band = int(np.argmax(r.bars)) top = r.bars[band] for _ in range(10): r.advance(_frame(np.zeros(vs.HISTORY, np.float32))) assert r.bars[band] < top assert 0 < r.ghost_alpha()[band, top] < 1 def test_a_rise_leaves_nothing(self): r = vs.BarsRenderer(_config(bars_fade=1.0)) r.advance(_frame(_tone(1000, 0.8))) assert not np.isfinite(r.ghosts).any() def test_zero_is_the_classic_bars(self): r = vs.BarsRenderer(_config(bars_fade=0)) r.advance(_frame(_tone(1000, 0.8))) r.advance(_frame(np.zeros(vs.HISTORY, np.float32))) assert r.ghost_alpha().max() == 0 # -------------------------------------------------------------------------- # 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, gray=None: 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, gray=None: 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 # -------------------------------------------------------------------------- # 5b. Lined up with what's heard # -------------------------------------------------------------------------- class _PositionedPlayer(_FakePlayer): def __init__(self): super().__init__(playing=True) self.position = 0 def position_ms(self): return self.position def _marked(ms, rate=RATE): """``ms`` of audio whose every sample is its own stream time.""" return (np.arange(int(ms * rate / 1000)) * 1000.0 / rate).astype(np.float32) class TestSync: def test_frame_ends_at_what_is_heard_not_the_newest(self, qapp, monkeypatch, tmp_path): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) player = _PositionedPlayer() vis = VisualizerWidget(player) audio = _marked(1000) vis._feed(audio, RATE, start_ms=0) # a burst: decoded 1 s ahead player.position = 400 # …but the speakers are at 0.4 s frame = vis._frame(0.016) assert abs(frame.samples[-1] - 400) < 2 assert len(frame.samples) == vs.HISTORY def test_no_position_means_the_newest(self, qapp): vis = VisualizerWidget(_FakePlayer()) vis._feed(_marked(500), RATE) assert abs(vis._frame(0.016).samples[-1] - 500) < 2 def test_the_laser_gets_each_heard_sample_once(self, qapp, monkeypatch, tmp_path): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) player = _PositionedPlayer() vis = VisualizerWidget(player) vis._feed(_marked(1000), RATE, start_ms=0) got = [] for position in (100, 116, 132, 150): player.position = position vis._anchor = None # no clock extrapolation in a test got.append(vis._frame(0.016).pending) joined = np.concatenate(got[1:]) assert abs(joined[0] - 100) < 1 and abs(joined[-1] - 150) < 1 assert np.all(np.diff(joined) > 0) # in order, nothing repeated def test_a_seek_starts_a_fresh_history(self, qapp): vis = VisualizerWidget(_FakePlayer()) vis._feed(np.ones(4410, np.float32), RATE, start_ms=0) vis._feed(np.full(4410, 2, np.float32), RATE, start_ms=60000) assert vis._hist[:-4410].max() == 0 def test_sync_delay_comes_from_config(self, qapp, monkeypatch, tmp_path): from lintunes.config import save_config monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) save_config({"visualizer_sync_delay_ms": 200}) player = _PositionedPlayer() vis = VisualizerWidget(player) vis._feed(_marked(1000), RATE, start_ms=0) player.position = 600 assert abs(vis._frame(0.016).samples[-1] - 400) < 2 # -------------------------------------------------------------------------- # 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) dialog._fade_slider.setValue(50) # twentieths of a second assert prefs.get("visualizer_ghost_fade") == 2.5 dialog._fade_slider.setValue(0) assert prefs.get("visualizer_ghost_fade") == 0 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_every_style_has_a_fade_slider(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) for slider, key in ((dialog._bars_fade_slider, "visualizer_bars_fade"), (dialog._fade_slider, "visualizer_ghost_fade")): assert slider.minimum() == 0 # 0 = off slider.setValue(10) assert prefs.get(key) == 0.5 # The laser's is a number box, like Line width. box = dialog._laser_fade_box assert box.minimum() == 0 box.setValue(0.5) assert prefs.get("visualizer_laser_afterimage") == 0.5 def test_laser_settings(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) dialog._grid.setChecked(True) assert prefs.get("visualizer_laser_grid") is True dialog._glow.setChecked(False) dialog._head.setChecked(False) dialog._width_box.setValue(dialog._width_box.minimum()) dialog._dot_slider.setValue(12) dialog._distance_slider.setValue(15) dialog._angle_sliders["visualizer_laser_pitch"].setValue(30) assert prefs.get("visualizer_laser_glow") is False assert prefs.get("visualizer_laser_head") is False assert prefs.get("visualizer_laser_width") == 1 # one pixel assert prefs.get("visualizer_laser_dot") == 6 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_sync_delay_saves_to_config_not_prefs(self, qapp, tmp_path, monkeypatch): from lintunes.config import load_config monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) prefs = Preferences(tmp_path) dialog = self._dialog(prefs) dialog._sync_slider.setValue(150) dialog._save_sync_delay() assert load_config()["visualizer_sync_delay_ms"] == 150 assert prefs.get("visualizer_sync_delay_ms") is None def test_a_swatch_repaints_its_slot(self, qapp, tmp_path): from lintunes import theme prefs = Preferences(tmp_path) dialog = self._dialog(prefs) dialog._pick_color = lambda initial: QColor("#ff3300") dialog._choose_color("seafoam") assert prefs.get("highlight") == "seafoam" assert theme.highlight_color(prefs).name() == "#ff3300" assert dialog._color_radios["seafoam"].text() == "custom" assert theme.highlight_palette(prefs)["blue"] == "#3584E4" dialog._set_slot("seafoam", None) # right-click → restore assert theme.highlight_palette(prefs)["seafoam"] == "#7FD1AE" assert dialog._color_radios["seafoam"].text() == "seafoam" def test_cancelling_the_picker_changes_nothing(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) dialog._pick_color = lambda initial: QColor() # invalid = cancel dialog._choose_color("magenta") assert prefs.get("highlight_palette") == {} assert prefs.get("highlight") == "blue" def test_a_bad_saved_color_falls_back(self, qapp, tmp_path): from lintunes import theme prefs = Preferences(tmp_path) prefs.set("highlight_palette", {"blue": "not a color", "nope": "#000"}) assert theme.highlight_palette(prefs) == theme.HIGHLIGHT_COLORS 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 # -------------------------------------------------------------------------- # 7. The laser's speaker, phosphor, and the controls for them # -------------------------------------------------------------------------- class TestSpeakerRange: def _swing(self, hz, **speaker): tilt = vs.LaserRenderer(_config(**speaker)).drive(_sine(hz, 1.0), RATE) return np.abs(tilt[len(tilt) // 2:]).max() def test_full_range_filters_nothing(self): r = vs.LaserRenderer(_config()) r.drive(_sine(100, 0.1), RATE) assert r._low_a is None and r._high_a is None def test_bass_only_drops_the_treble(self): assert self._swing(6000, laser_high_hz=200) < 1e-3 * self._swing(6000) def test_treble_only_drowns_no_treble_in_bass(self): # The membrane answers bass ~80 dB more than treble: a gentle # speaker slope would let the bass through and hide the treble. bass = self._swing(98, laser_low_hz=2000) treble = self._swing(6000, laser_low_hz=2000) assert treble > 10 * bass def test_treble_only_is_not_held_under_the_noise_floor(self): r = vs.LaserRenderer(_config(laser_low_hz=2000)) hat = _sine(6000, 1.0, 0.05) _run(r, hat) assert np.abs(r.paths[0][0]).max() > 0.3 def test_treble_only_simulates_every_sample(self): fast = vs.LaserRenderer(_config(laser_low_hz=2000)) slow = vs.LaserRenderer(_config()) fast.drive(_sine(100, 0.1), RATE) slow.drive(_sine(100, 0.1), RATE) assert fast._step == 1 and slow._step == 4 def test_filtered_chunks_match_one_long_drive(self): audio = _sine(3000, 0.3) + _sine(150, 0.3) config = _config(laser_low_hz=500, laser_high_hz=8000) whole = vs.LaserRenderer(config).drive(audio, RATE) r = vs.LaserRenderer(config) parts = [r.drive(c, RATE) for c in np.array_split(audio, [7, 900, 4000])] assert np.allclose(np.concatenate(parts), whole) def test_crossed_handles_are_uncrossed(self, tmp_path): prefs = Preferences(tmp_path) prefs.set("visualizer_laser_low_hz", 5000) prefs.set("visualizer_laser_high_hz", 1000) config = vs.settings(prefs) low, high = (config["visualizer_laser_low_hz"], config["visualizer_laser_high_hz"]) assert high == 1000 and low * vs.SPEAKER_MIN_RATIO <= high def test_one_pole_matches_the_recursion(self): x = np.random.default_rng(0).standard_normal(5000) for a in (0.05, 0.6, 0.999): y, _ = vs.one_pole(x, a, 0.0) ref, prev = np.empty_like(x), 0.0 for n, v in enumerate(x): prev = a * prev + (1 - a) * v ref[n] = prev assert np.allclose(y, ref) def test_thinning_keeps_a_fast_zigzag(self): zigzag = np.column_stack([np.arange(100.0), np.tile([0, 20.0], 50)]) assert len(vs.thin_path(zigzag, 0.75)) == 100 smooth = np.column_stack([np.linspace(0, 1, 1000), np.zeros(1000)]) assert len(vs.thin_path(smooth, 0.75)) <= 3 class TestPhosphor: def test_dimming_reaches_zero(self, qapp): p = vs._Phosphor(20, 20, 1.0) p.image.fill(QColor(255, 0, 0)) assert p.pixels.max() == 255 # the view is the image itself for _ in range(600): p.dim(0.995) # a slow fade, rounding down assert p.pixels.max() == 0 def test_afterimage_length_follows_the_setting(self, qapp): def lit(after): r = vs.LaserRenderer(_config(laser_afterimage=after, laser_glow=False)) audio = _sine(150, 1.0) * np.linspace(1, 0.2, RATE) for i in range(0, len(audio) - 706, 706): r.advance(_frame(np.zeros(vs.HISTORY, np.float32), audio[i:i + 706])) _paint(r, 300, 200) return (r._phosphor[(300, 200)].pixels[..., 3] > 0).sum() assert lit(0.05) < lit(1.0) def test_big_window_phosphor_is_half_resolution(self, qapp): r = _run(vs.LaserRenderer(_config(laser_afterimage=0.5)), _sine(150, 0.2)) _paint(r, 1200, 750) _paint(r, 140, 62) assert r._phosphor[(1200, 750)].pixels.shape[:2] == (375, 600) assert r._phosphor[(140, 62)].pixels.shape[:2] == (62, 140) def test_a_setting_change_starts_it_afresh(self, qapp): r = _run(vs.LaserRenderer(_config(laser_afterimage=0.5)), _sine(150, 0.2)) _paint(r, 300, 200) r.configure(_config(laser_afterimage=0.5, laser_pitch=30)) assert r._phosphor == {} class TestRangeSlider: def test_handles_keep_their_gap_and_order(self, qapp): from lintunes.gui.range_slider import RangeSlider slider = RangeSlider(0, 100, gap=10) got = [] slider.rangeChanged.connect(lambda lo, hi: got.append((lo, hi))) slider.setValues(20, 80) slider.set_handle(0, 95) # can't pass the other assert slider.values() == (70, 80) slider.set_handle(1, -5) assert slider.values() == (70, 80) assert got[-1] == (70, 80) def test_a_click_moves_the_nearer_handle(self, qapp): from PyQt6.QtCore import Qt, QPointF, QEvent from PyQt6.QtGui import QMouseEvent from lintunes.gui.range_slider import RangeSlider slider = RangeSlider(0, 100) slider.resize(216, 22) slider.setValues(0, 100) at = QPointF(slider._x_of(30), 11) slider.mousePressEvent(QMouseEvent( QEvent.Type.MouseButtonPress, at, at, Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier)) assert slider.values() == (30, 100) def test_paints(self, qapp): from lintunes.gui.range_slider import RangeSlider slider = RangeSlider(0, 300) slider.resize(200, 22) slider.setValues(50, 250) assert not slider.grab().isNull() class TestNewControls: def _dialog(self, prefs): return TestPreferencesPage._dialog(None, prefs) def test_speaker_range_slider_writes_hz(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) assert dialog._speaker.values() == (0, 300) # 20 Hz – 20 kHz dialog._speaker.setValues(200, 300) # 2 kHz up assert prefs.get("visualizer_laser_low_hz") == 2000 assert prefs.get("visualizer_laser_high_hz") == 20000 def test_line_width_box_steps_by_half(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) box = dialog._width_box box.setValue(2.0) box.stepUp() assert prefs.get("visualizer_laser_width") == 2.5 box.stepDown() box.stepDown() assert prefs.get("visualizer_laser_width") == 1.5 box.setValue(0.2) # under the minimum assert box.value() == 1.0 def test_radial_dot_size(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) dialog._radial_dot_slider.setValue(200) assert prefs.get("visualizer_radial_dot") == 2.0 def ink(scale): r = vs.RadialRenderer(_config(radial_dot=scale, radial_normalize=False)) r.advance(_frame(_tone(1000, 0.8))) img = _paint(r, 400, 400) return sum(QColor(img.pixel(x, y)) != QColor("white") for x in range(400) for y in range(0, 400, 2)) assert ink(0.5) < ink(1.0) < ink(2.0) # -------------------------------------------------------------------------- # 8. Presets # -------------------------------------------------------------------------- from lintunes.gui import visualizer_presets as presets def _combo_texts(combo): return [combo.itemText(i) for i in range(combo.count())] class TestPresets: def _dialog(self, prefs): return TestPreferencesPage._dialog(None, prefs) def test_every_style_has_a_preset_menu(self, qapp, tmp_path): dialog = self._dialog(Preferences(tmp_path)) for style in ("bars", "radial", "laser"): combo = dialog._preset_combos[style] assert combo.currentText() == "Default" # untouched = Default assert _combo_texts(combo)[-1] == "Edit Presets…" def test_changing_a_setting_reads_custom(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) dialog._spokes_slider.setValue(60) assert dialog._preset_combos["radial"].currentText() == "Custom" assert dialog._preset_combos["laser"].currentText() == "Default" def test_save_and_apply(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) dialog._angle_sliders["visualizer_laser_pitch"].setValue(40) dialog._grid.setChecked(True) dialog._speaker.setValues(150, 300) dialog._width_box.setValue(3.5) dialog._ask_preset_name = lambda suggestion: "Big Tilt" dialog._save_preset("laser") combo = dialog._preset_combos["laser"] assert combo.currentText() == "Big Tilt" # Back to Default: every control (and setting) follows. dialog._on_preset_chosen("laser", combo.findData("Default")) assert prefs.get("visualizer_laser_pitch") == 0 assert prefs.get("visualizer_laser_grid") is False assert prefs.get("visualizer_laser_width") == 1.5 assert dialog._speaker.values() == (0, 300) assert combo.currentText() == "Default" dialog._on_preset_chosen("laser", combo.findData("Big Tilt")) assert prefs.get("visualizer_laser_pitch") == 40 assert prefs.get("visualizer_laser_grid") is True assert prefs.get("visualizer_laser_width") == 3.5 assert dialog._angle_sliders["visualizer_laser_pitch"].value() == 40 assert dialog._speaker.values() == (150, 300) assert combo.currentText() == "Big Tilt" def test_presets_survive_a_restart(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) dialog._bars_fade_slider.setValue(20) dialog._ask_preset_name = lambda suggestion: "Ghosty" dialog._save_preset("bars") again = self._dialog(Preferences(tmp_path)) assert "Ghosty" in _combo_texts(again._preset_combos["bars"]) def test_default_is_reserved(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) warned = [] dialog._preset_warning = warned.append dialog._ask_preset_name = lambda suggestion: "Default" dialog._save_preset("radial") assert warned and presets.presets(prefs, "radial") == {} def test_overwrite_asks_first(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) dialog._ask_preset_name = lambda suggestion: "Mine" dialog._save_preset("radial") dialog._spokes_slider.setValue(200) dialog._confirm_overwrite = lambda name: False dialog._save_preset("radial") assert presets.presets(prefs, "radial")["Mine"][ "visualizer_radial_spokes"] == 120 # kept dialog._confirm_overwrite = lambda name: True dialog._save_preset("radial") assert presets.presets(prefs, "radial")["Mine"][ "visualizer_radial_spokes"] == 200 def test_a_preset_only_holds_its_own_style(self, qapp, tmp_path): prefs = Preferences(tmp_path) presets.save(prefs, "bars", "x") assert set(presets.presets(prefs, "bars")["x"]) == {"visualizer_bars_fade"} assert "visualizer_sync_delay_ms" not in presets.STYLE_KEYS["laser"] def test_an_old_preset_takes_new_settings_defaults(self, qapp, tmp_path): prefs = Preferences(tmp_path) prefs.set(presets.PRESETS_KEY, {"laser": {"Old": {"visualizer_laser_pitch": 20}}}) values = presets.values_of(prefs, "laser", "Old") assert values["visualizer_laser_pitch"] == 20 assert values["visualizer_laser_glow"] is True # default def test_every_style_key_has_a_control(self, qapp, tmp_path): dialog = self._dialog(Preferences(tmp_path)) for style, keys in presets.STYLE_KEYS.items(): for key in keys: assert key in dialog._viz_setters or key in ( "visualizer_laser_low_hz", "visualizer_laser_high_hz") class TestPresetEditor: def _editor(self, prefs, style="laser"): return presets.PresetEditor(prefs, style, "Laser") def test_lists_only_user_presets(self, qapp, tmp_path): prefs = Preferences(tmp_path) editor = self._editor(prefs) assert editor.list.count() == 0 and not editor.empty.isHidden() presets.save(prefs, "laser", "b") presets.save(prefs, "laser", "A") editor = self._editor(prefs) assert [editor.list.item(i).text() for i in range(2)] == ["A", "b"] def test_rename_in_place(self, qapp, tmp_path): prefs = Preferences(tmp_path) presets.save(prefs, "laser", "Old") editor = self._editor(prefs) editor.list.item(0).setText("New") assert list(presets.presets(prefs, "laser")) == ["New"] assert editor.list.item(0).text() == "New" def test_rename_to_a_taken_name_is_refused(self, qapp, tmp_path): prefs = Preferences(tmp_path) presets.save(prefs, "laser", "One") presets.save(prefs, "laser", "Two") editor = self._editor(prefs) warned = [] editor._warn = warned.append editor.list.item(0).setText("Two") assert warned assert sorted(presets.presets(prefs, "laser")) == ["One", "Two"] assert editor.list.item(0).text() == "One" def test_rename_to_default_is_refused(self, qapp, tmp_path): prefs = Preferences(tmp_path) presets.save(prefs, "laser", "One") editor = self._editor(prefs) editor._warn = lambda text: None editor.list.item(0).setText("Default") assert list(presets.presets(prefs, "laser")) == ["One"] def test_delete_asks_then_deletes(self, qapp, tmp_path): prefs = Preferences(tmp_path) presets.save(prefs, "laser", "Gone") editor = self._editor(prefs) editor._confirm = lambda text: False editor._delete_current() assert "Gone" in presets.presets(prefs, "laser") editor._confirm = lambda text: True editor._delete_current() assert presets.presets(prefs, "laser") == {} assert not editor.delete_button.isEnabled() def test_editing_one_style_leaves_the_others(self, qapp, tmp_path): prefs = Preferences(tmp_path) presets.save(prefs, "bars", "Keep") presets.save(prefs, "laser", "Drop") presets.delete(prefs, "laser", "Drop") assert "Keep" in presets.presets(prefs, "bars")