Files
lintunes/tests/test_round78.py
T
trav 8b42580f97 v0.42.0: laser speaker range, phosphor afterimage; line width box, radial dot size
Laser: a two-handle "Speaker range" sets what the speaker under the mirror
plays (36 dB/octave edges; the noise floor is measured per range so treble
alone still shows). Bass-only gives clean loops; treble-only is a shimmer
along one line, as a membrane far above its resonances really draws.

The afterimage is now a phosphor buffer, dimmed each frame and rounded
down so it always fades to nothing — constant cost however long the fade
(drawing every past sweep ran the big window past a frame).

Line width is a number box stepping by 0.5; radial dots have a size slider.
2026-10-10 01:24:37 -07:00

869 lines
36 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"),
(dialog._laser_fade_slider,
"visualizer_laser_afterimage")):
assert slider.minimum() == 0 # 0 = off
slider.setValue(10)
assert prefs.get(key) == 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)