Preferences → Appearance → Visualizer picks Bars (as before), Radial dots (the EQ in a circle: angle is frequency, radius is volume, moving dots leave light-gray ghosts that fade over a set time), or Laser (a mirror on a speaker membrane bouncing a beam onto a screen: simulated membrane modes, D·tan(2θ) reflection, dwell-weighted brightness). Double-clicking the panel opens it big; a click closes it.
358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""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):
|
|
spectrum = np.abs(np.fft.rfft(window * np.hanning(len(window)))) / (len(window) / 4)
|
|
freqs = np.fft.rfftfreq(len(window), d=1.0 / RATE)
|
|
if pending is None:
|
|
pending = np.zeros(0, np.float32)
|
|
return vs.Frame(spectrum, freqs, RATE, pending, dt)
|
|
|
|
|
|
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"), QColor("gray"))
|
|
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_every_style_paints_without_error(self, qapp):
|
|
for style in vs.STYLES:
|
|
renderer = vs.make_renderer(style)
|
|
window = _sine(1000, 2048 / RATE)
|
|
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
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestBandLevels:
|
|
def test_a_sine_lights_its_own_band(self):
|
|
frame = _frame(_sine(1000, 2048 / RATE))
|
|
edges = vs.band_edges(vs.BANDS, RATE)
|
|
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
|
|
band = np.searchsorted(edges, 1000) - 1
|
|
assert levels.argmax() == band
|
|
|
|
def test_narrow_bass_bands_are_not_silent(self):
|
|
# At 360 bands the bottom ones are narrower than an FFT bin.
|
|
frame = _frame(_sine(45, 2048 / RATE))
|
|
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
|
|
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
|
|
assert levels[:5].min() > 0.5
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 3. Radial
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestRadial:
|
|
def test_frequency_is_the_angle(self):
|
|
r = vs.RadialRenderer()
|
|
r.advance(_frame(_sine(1000, 2048 / RATE)))
|
|
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
|
|
degree = np.searchsorted(edges, 1000) - 1
|
|
assert abs(int(r.rings.argmax()) - degree) <= 2
|
|
|
|
def test_volume_is_the_radius(self):
|
|
loud, quiet = vs.RadialRenderer(), vs.RadialRenderer()
|
|
loud.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
|
quiet.advance(_frame(_sine(1000, 2048 / RATE, amp=0.01)))
|
|
assert loud.rings.max() > quiet.rings.max() > 0
|
|
|
|
def test_a_falling_dot_leaves_ghosts_that_fade(self):
|
|
r = vs.RadialRenderer(fade=0.5)
|
|
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
|
degree, top = int(r.rings.argmax()), int(r.rings.max())
|
|
silence = _frame(np.zeros(2048, 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_no_fade_means_no_ghosts(self):
|
|
r = vs.RadialRenderer(fade=0)
|
|
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
|
r.advance(_frame(np.zeros(2048, np.float32)))
|
|
assert r.ghost_alpha().max() == 0
|
|
|
|
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(fade=1.0)
|
|
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.003)))
|
|
before = r.rings.copy()
|
|
r.advance(_frame(_sine(1000, 2048 / RATE, 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(fade=3.0)
|
|
rng = np.random.default_rng(1)
|
|
for _ in range(200):
|
|
r.advance(_frame(_sine(1000, 2048 / RATE, amp=rng.random())))
|
|
rings, alpha = r.pooled(45)
|
|
assert (alpha > 0).sum(axis=1).max() <= vs.STEPS
|
|
|
|
def test_bigger_circles_get_more_spokes(self):
|
|
assert vs.spoke_count(28) < vs.spoke_count(400) == 360
|
|
assert 360 % vs.spoke_count(28) == 0
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 4. Laser
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestLaser:
|
|
def test_silence_is_a_still_dot_in_the_middle(self):
|
|
r = vs.LaserRenderer()
|
|
for _ in range(10):
|
|
r.advance(_frame(np.zeros(2048, np.float32),
|
|
np.zeros(706, np.float32)))
|
|
assert np.allclose(r.paths[0], 0.0)
|
|
|
|
def test_the_membrane_answers_its_resonance(self):
|
|
f0 = vs.MODES[0][0]
|
|
on, off = vs.LaserRenderer(), vs.LaserRenderer()
|
|
swing_on = np.abs(on.drive(_sine(f0, 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_degenerate_modes_turn_a_line_into_a_loop(self):
|
|
# The split (1,1) pair: driven between its two resonances, the two
|
|
# axes are out of phase — an ellipse, 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.1
|
|
|
|
def test_the_spot_stays_on_the_screen(self):
|
|
r = vs.LaserRenderer()
|
|
rng = np.random.default_rng(0)
|
|
audio = (_sine(90, 2, 0.9) * (1 + rng.standard_normal(2 * RATE) * 0.3))
|
|
hop = 706
|
|
for i in range(0, len(audio) - hop, hop):
|
|
r.advance(_frame(np.zeros(2048, np.float32),
|
|
audio[i:i + hop], dt=0.016))
|
|
assert np.abs(r.paths[0]).max() <= 1.0
|
|
|
|
def test_loud_music_fills_most_of_the_screen(self):
|
|
r = vs.LaserRenderer()
|
|
audio = _sine(vs.MODES[0][0], 1.0, 0.5)
|
|
for i in range(0, len(audio) - 706, 706):
|
|
r.advance(_frame(np.zeros(2048, np.float32), audio[i:i + 706]))
|
|
assert np.abs(r.paths[0]).max() > 0.6
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 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, light: 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, light: 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):
|
|
vis = VisualizerWidget(_FakePlayer())
|
|
for _ in range(100):
|
|
vis._feed(_sine(440, 0.05), RATE)
|
|
assert vis._pending_len <= RATE * 0.25 + RATE * 0.05
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 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_and_fade_slider(self, qapp, tmp_path):
|
|
prefs = Preferences(tmp_path)
|
|
dialog = self._dialog(prefs)
|
|
assert dialog._viz_radios["bars"].isChecked()
|
|
assert not dialog._fade_slider.isEnabled()
|
|
|
|
dialog._viz_radios["radial"].setChecked(True)
|
|
assert prefs.get("visualizer_style") == "radial"
|
|
assert dialog._fade_slider.isEnabled()
|
|
|
|
dialog._fade_slider.setValue(25)
|
|
assert prefs.get("visualizer_ghost_fade") == 2.5
|
|
|
|
dialog._viz_radios["laser"].setChecked(True)
|
|
assert not dialog._fade_slider.isEnabled()
|
|
|
|
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
|