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.
This commit is contained in:
+180
-1
@@ -628,7 +628,7 @@ class TestPreferencesPage:
|
||||
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._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)
|
||||
@@ -687,3 +687,182 @@ class TestPreferencesPage:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user