v0.41.0: visualizer sync, fade sliders, laser look controls, custom highlight

Sync: decoding runs ahead of the speakers — by up to ~0.8 s after a GUI
stall, when buffers arrive in a burst — and the visualizer drew the newest
audio. It now keeps timestamped history and draws what the player says is
playing, plus a per-machine Sync delay (config.json) for latency Qt can't
see, like Bluetooth.

Every style has one Fade slider (0 = off); bars now leave fading steps.
Laser: afterimage time, line width down to 1 px, glow on/off, tip dot
on/off and size (a resting beam always shows its dot), and an oscilloscope
dot grid under the beam. Visualizer sliders run the full window width.

Highlight: click a swatch for a full color picker; the color picked
replaces that slot (right-click restores it).
This commit is contained in:
2026-10-10 01:11:09 -07:00
parent 751273b14c
commit 726f7c4a89
8 changed files with 682 additions and 135 deletions
+218 -25
View File
@@ -196,7 +196,7 @@ class TestRadial:
assert not np.isfinite(r.ghosts).any() # forgotten, not just faint
def test_fading_off_means_no_ghosts(self):
r = vs.RadialRenderer(_config(radial_fade=False))
r = 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
@@ -275,7 +275,7 @@ def _run(renderer, audio, hop=706):
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)
assert np.allclose(r.paths[0][0], 0.0)
def test_the_membrane_answers_its_resonance(self):
f = vs.MODES[1][0]
@@ -305,14 +305,14 @@ class TestLaser:
audio = _sine(vs.MODES[1][0], 1.0)
near = _run(vs.LaserRenderer(_config(laser_distance=1.0)), audio)
far = _run(vs.LaserRenderer(_config(laser_distance=4.0)), audio)
ratio = np.abs(far.paths[0]).max() / np.abs(near.paths[0]).max()
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]
path = _run(vs.LaserRenderer(_config(**angles)), audio).paths[0][0]
return np.ptp(path[:, 0]), np.ptp(path[:, 1])
square_x, square_y = spread()
@@ -320,8 +320,8 @@ class TestLaser:
assert tipped_y / square_y > 1.3 * tipped_x / square_x
turned_x, turned_y = spread(laser_yaw=50)
assert turned_x / square_x > 1.3 * turned_y / square_y
spun = _run(vs.LaserRenderer(_config(laser_roll=90)), audio).paths[0]
flat = _run(vs.LaserRenderer(_config()), audio).paths[0]
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)
@@ -339,18 +339,93 @@ class TestLaser:
biggest = 0.0
for i in range(0, len(audio) - 706, 706):
r.advance(_frame(np.zeros(vs.HISTORY, np.float32), audio[i:i + 706]))
px = r.paths[0] * r.scale(rect)
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_fading_off_keeps_only_this_frame(self):
audio = _sine(150, 0.5)
assert len(_run(vs.LaserRenderer(), audio).paths) == len(vs.PERSIST)
r = _run(vs.LaserRenderer(_config(laser_fade=False)), audio)
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
@@ -400,7 +475,7 @@ class TestBigWindow:
prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
calls = []
vis._renderer.paint = lambda p, rect, color: calls.append(rect)
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))
@@ -416,18 +491,81 @@ class TestBigWindow:
prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(), prefs)
colors = []
vis._renderer.paint = lambda p, rect, color: colors.append(color)
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
def test_pending_audio_is_capped(self, qapp):
# --------------------------------------------------------------------------
# 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())
for _ in range(100):
vis._feed(_sine(440, 0.05), RATE)
assert vis._pending_len <= RATE * 0.25 + RATE * 0.05
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
# --------------------------------------------------------------------------
@@ -461,12 +599,10 @@ class TestPreferencesPage:
def test_radial_settings(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = self._dialog(prefs)
assert dialog._fade_slider.isEnabled()
dialog._fade_slider.setValue(25)
dialog._fade_slider.setValue(50) # twentieths of a second
assert prefs.get("visualizer_ghost_fade") == 2.5
dialog._radial_fade.setChecked(False)
assert prefs.get("visualizer_radial_fade") is False
assert not dialog._fade_slider.isEnabled()
dialog._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)
@@ -474,20 +610,77 @@ class TestPreferencesPage:
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._laser_fade.setChecked(False)
dialog._grid.setChecked(True)
assert prefs.get("visualizer_laser_grid") is True
dialog._glow.setChecked(False)
dialog._head.setChecked(False)
dialog._width_slider.setValue(dialog._width_slider.minimum())
dialog._dot_slider.setValue(12)
dialog._distance_slider.setValue(15)
dialog._angle_sliders["visualizer_laser_pitch"].setValue(30)
dialog._angle_sliders["visualizer_laser_roll"].setValue(-90)
assert prefs.get("visualizer_laser_fade") is False
assert prefs.get("visualizer_laser_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")