Files
lintunes/tests/test_round78.py
T
trav c312751c86 v0.45.1: the laser's Afterimage is a number box, like Line width
Type the seconds, or step by 0.05 with the arrows or ↑/↓; it reads "Off"
at zero. Line width and Afterimage now share one number-box helper.
2026-10-10 02:24:17 -07:00

1045 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")