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:
+218
-25
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user