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:
2026-10-10 01:24:37 -07:00
parent 726f7c4a89
commit 8b42580f97
7 changed files with 648 additions and 41 deletions
+180 -1
View File
@@ -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)