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
+16 -3
View File
@@ -198,9 +198,22 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
`distance`, turned by pitch/yaw/roll; the auto-gain holds the beam
*angle*, so distance is real, and `scale()` shrinks the drawing only when a
steep screen would push the loudest swing out of view. The path is drawn
brighter where it moves slower (dwell); past sweeps are kept for the
afterimage time as pre-built polygons in screen metres, drawn through a
transform with a cosmetic pen. Every style's fade is one seconds slider,
brighter where it moves slower (dwell). The afterimage is a **phosphor**
(`_Phosphor`, one per painted size, half resolution past 400 px wide):
each sweep is burned in once and the buffer is dimmed per frame, so cost
doesn't grow with the fade time. The dimming is a numpy LUT that rounds
*down*, because Qt's own fade rounds to nearest and stalls faint pixels
forever. The numpy array must be a **view of the QImage's `bits()`**, since
building a QImage *from* an array copies it. A **speaker range** (low/high
Hz, `gui/range_slider.py`) filters what drives the membrane, with
`one_pole` stages done exactly via the cumsum trick, 36 dB/octave. That is
steeper than a real speaker on purpose: the membrane answers bass ~80 dB
more than treble, so a gentler slope lets bass drown a treble-only
setting. For the same reason the auto-gain floor is *measured* per range
(reference hiss through that speaker and membrane), and the membrane is
simulated at full rate only once the bass is cut. Treble alone draws a
shimmer along one line: far above every resonance, all modes move in
phase. Every style's fade is one seconds slider,
0 = off. **Sync** (Round 78): decoding runs ahead of the speakers, by a
buffer or two normally but by up to ~0.8 s when the GUI thread stalls and
buffers arrive in a burst. So the widget keeps 1.5 s of *timestamped*
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.41.0"
__version__ = "0.42.0"
+89 -4
View File
@@ -1,9 +1,11 @@
import math
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton,
QColorDialog, QMenu,
QColorDialog, QMenu, QDoubleSpinBox,
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont, QColor
@@ -11,6 +13,7 @@ from PyQt6.QtGui import QFont, QColor
from lintunes import theme
from lintunes.config import load_config, save_config
from lintunes.gui import visualizer_styles
from lintunes.gui.range_slider import RangeSlider
from lintunes.gui import sounds
from lintunes.theme import HIGHLIGHT_COLORS
@@ -32,6 +35,33 @@ _COLOR_ROWS = [
_VALUE_WIDTH = 56
# The laser's speaker range: 20 Hz – 20 kHz in log steps of 1/30 octave.
SPEAKER_STEPS = 300
def _step_to_hz(step: int) -> int:
lo, hi = visualizer_styles.LIMITS["visualizer_laser_low_hz"]
return int(round(lo * (hi / lo) ** (step / SPEAKER_STEPS)))
def _hz_to_step(hz: float) -> int:
lo, hi = visualizer_styles.LIMITS["visualizer_laser_low_hz"]
return int(round(SPEAKER_STEPS * math.log(max(lo, hz) / lo) / math.log(hi / lo)))
def _speaker_gap() -> int:
"""The third-octave the speaker can't be narrower than, in steps."""
lo, hi = visualizer_styles.LIMITS["visualizer_laser_low_hz"]
return math.ceil(SPEAKER_STEPS * math.log(visualizer_styles.SPEAKER_MIN_RATIO)
/ math.log(hi / lo))
def _hz_text(hz: int) -> str:
if hz < 1000:
return f"{hz} Hz"
return f"{hz / 1000:.1f}".removesuffix(".0") + " kHz"
def _fade_text(seconds: float) -> str:
return "Off" if seconds <= 0 else f"{seconds:.2f} s"
@@ -433,6 +463,10 @@ class PreferencesDialog(QDialog):
self._levels_slider, _ = self._viz_slider(
form, "Volume steps:", "visualizer_radial_levels", config,
tip="How many positions each dot can take, center to rim")
self._radial_dot_slider, _ = self._viz_slider(
form, "Dot size:", "visualizer_radial_dot", config, scale=100,
fmt=lambda v: f"{round(v * 100)}%",
tip="100% is as big as the dots can be without touching")
self._normalize = self._viz_check(
form, "Even out bass and treble", "visualizer_radial_normalize",
config, tip="Music is loudest in the bass. This learns the "
@@ -446,9 +480,8 @@ class PreferencesDialog(QDialog):
form, "Afterimage:", "visualizer_laser_afterimage", config,
scale=20, fmt=_fade_text,
tip="How long past sweeps of the beam take to fade — 0 is none")
self._width_slider, _ = self._viz_slider(
form, "Line width:", "visualizer_laser_width", config, scale=2,
fmt=lambda v: f"{v:g} px")
self._speaker = self._speaker_range(form, config)
self._width_box = self._width_spinbox(form, config)
self._glow = self._viz_check(
form, "Glow", "visualizer_laser_glow", config,
tip="The haze around the beam")
@@ -506,6 +539,58 @@ class PreferencesDialog(QDialog):
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
return form
def _width_spinbox(self, form, config) -> QDoubleSpinBox:
"""Line width as a number: type it, or step it by half a pixel with
the arrows (buttons or keys)."""
lo, hi = visualizer_styles.LIMITS["visualizer_laser_width"]
box = QDoubleSpinBox()
box.setRange(lo, hi)
box.setSingleStep(0.5)
box.setDecimals(1)
box.setSuffix(" px")
box.setValue(config["visualizer_laser_width"]) # before connect
def changed(value):
self._prefs.set_live("visualizer_laser_width", value)
self._color_save_timer.start()
box.valueChanged.connect(changed)
row = QHBoxLayout()
row.addWidget(box)
row.addStretch()
form.addRow("Line width:", row)
return box
def _speaker_range(self, form, config) -> RangeSlider:
"""The speaker under the mirror: which frequencies it plays, on a
log scale (each slider step is the same musical interval)."""
slider = RangeSlider(0, SPEAKER_STEPS, gap=_speaker_gap())
slider.setToolTip(
"What the speaker under the mirror plays. Bass makes the big "
"loops; treble is a fine shiver on them. Slide the low end up to "
"watch the treble on its own.")
slider.setValues(_hz_to_step(config["visualizer_laser_low_hz"]),
_hz_to_step(config["visualizer_laser_high_hz"]))
text = QLabel()
text.setMinimumWidth(QLabel("2.5 kHz – 20 kHz").sizeHint().width())
def changed(low, high, save=True):
text.setText(f"{_hz_text(_step_to_hz(low))} – "
f"{_hz_text(_step_to_hz(high))}")
if save:
self._prefs.set_live("visualizer_laser_low_hz", _step_to_hz(low))
self._prefs.set_live("visualizer_laser_high_hz", _step_to_hz(high))
self._color_save_timer.start()
changed(*slider.values(), save=False)
slider.rangeChanged.connect(changed)
row = QHBoxLayout()
row.addWidget(slider, stretch=1)
row.addSpacing(8)
row.addWidget(text)
form.addRow("Speaker range:", row)
return slider
def _sync_delay_slider(self, form) -> QSlider:
lo, hi = visualizer_styles.SYNC_DELAY_LIMITS
slider = QSlider(Qt.Orientation.Horizontal)
+133
View File
@@ -0,0 +1,133 @@
"""A slider with two handles and the span between them highlighted — Qt has
no such widget. Integer positions, like ``QSlider``; a caller wanting a log
scale (the laser's speaker range, in Hz) maps them itself."""
from PyQt6.QtWidgets import QWidget, QSizePolicy
from PyQt6.QtCore import Qt, QRectF, QPointF, pyqtSignal
from PyQt6.QtGui import QPainter, QPen
HANDLE = 16 # handle diameter, px
GROOVE = 4 # groove thickness, px
class RangeSlider(QWidget):
"""Two handles over ``minimum..maximum``, never closer than ``gap``.
Drag either handle; a click on the groove moves the nearer one there.
The arrow keys move whichever handle was touched last."""
rangeChanged = pyqtSignal(int, int)
def __init__(self, minimum: int, maximum: int, gap: int = 1, parent=None):
super().__init__(parent)
self._min, self._max, self._gap = minimum, maximum, gap
self._values = [minimum, maximum]
self._grabbed = None # 0 / 1 while dragging
self._active = 1 # the handle the keys move
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setSizePolicy(QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed)
self.setMinimumWidth(4 * HANDLE)
def sizeHint(self):
from PyQt6.QtCore import QSize
return QSize(200, HANDLE + 6)
def minimum(self) -> int:
return self._min
def maximum(self) -> int:
return self._max
def values(self) -> tuple:
return tuple(self._values)
def setValues(self, low: int, high: int):
"""Set both handles (clamped and kept ``gap`` apart, high winning)."""
high = max(self._min + self._gap, min(self._max, int(high)))
low = max(self._min, min(high - self._gap, int(low)))
if [low, high] != self._values:
self._values = [low, high]
self.update()
self.rangeChanged.emit(low, high)
def set_handle(self, which: int, value: int):
low, high = self._values
if which == 0:
low = max(self._min, min(high - self._gap, int(value)))
else:
high = min(self._max, max(low + self._gap, int(value)))
self.setValues(low, high)
# ---- geometry ----
def _span(self):
left = HANDLE / 2
return left, self.width() - HANDLE / 2
def _x_of(self, value) -> float:
left, right = self._span()
share = (value - self._min) / max(1, self._max - self._min)
return left + share * (right - left)
def _value_at(self, x: float) -> int:
left, right = self._span()
share = (x - left) / max(1.0, right - left)
return round(self._min + max(0.0, min(1.0, share)) * (self._max - self._min))
# ---- input ----
def mousePressEvent(self, event):
if event.button() != Qt.MouseButton.LeftButton:
return super().mousePressEvent(event)
x = event.position().x()
distances = [abs(x - self._x_of(v)) for v in self._values]
if distances[0] == distances[1]: # stacked: go the way it's pulled
self._grabbed = 0 if x < self._x_of(self._values[0]) else 1
else:
self._grabbed = distances.index(min(distances))
self._active = self._grabbed
self.set_handle(self._grabbed, self._value_at(x))
def mouseMoveEvent(self, event):
if self._grabbed is not None:
self.set_handle(self._grabbed, self._value_at(event.position().x()))
def mouseReleaseEvent(self, event):
self._grabbed = None
def keyPressEvent(self, event):
step = {Qt.Key.Key_Left: -1, Qt.Key.Key_Down: -1,
Qt.Key.Key_Right: 1, Qt.Key.Key_Up: 1,
Qt.Key.Key_PageDown: -10, Qt.Key.Key_PageUp: 10}.get(event.key())
if step is None:
return super().keyPressEvent(event)
self.set_handle(self._active, self._values[self._active] + step)
# ---- painting ----
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
palette = self.palette()
cy = self.height() / 2
left, right = self._span()
groove = QRectF(left, cy - GROOVE / 2, right - left, GROOVE)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(palette.mid())
painter.drawRoundedRect(groove, GROOVE / 2, GROOVE / 2)
low_x, high_x = (self._x_of(v) for v in self._values)
painter.setBrush(palette.highlight())
painter.drawRoundedRect(QRectF(low_x, cy - GROOVE / 2,
high_x - low_x, GROOVE),
GROOVE / 2, GROOVE / 2)
border = QPen(palette.mid().color())
border.setWidthF(1.0)
for i, x in enumerate((low_x, high_x)):
painter.setPen(border)
painter.setBrush(palette.button())
r = HANDLE / 2 - 0.5
painter.drawEllipse(QPointF(x, cy), r, r)
if self.hasFocus() and i == self._active:
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(palette.highlight())
painter.drawEllipse(QPointF(x, cy), 3, 3)
+226 -32
View File
@@ -21,7 +21,7 @@ from dataclasses import dataclass
import numpy as np
from PyQt6.QtCore import Qt, QPointF, QLineF
from PyQt6.QtGui import QColor, QPen, QPolygonF
from PyQt6.QtGui import QColor, QImage, QPainter, QPen, QPolygonF
from lintunes.preferences import DEFAULTS
@@ -50,8 +50,12 @@ LIMITS = {
"visualizer_laser_afterimage": (0.0, 3.0),
"visualizer_laser_width": (1.0, 8.0), # pixels
"visualizer_laser_dot": (1.0, 16.0), # pixels across
# What the speaker under the mirror plays. At the ends, no filter.
"visualizer_laser_low_hz": (20, 20000),
"visualizer_laser_high_hz": (20, 20000),
"visualizer_radial_spokes": (12, 360),
"visualizer_radial_levels": (4, 40),
"visualizer_radial_dot": (0.2, 2.5), # × the automatic size
"visualizer_laser_distance": (1.0, 4.0), # metres, mirror → screen
"visualizer_laser_pitch": (-60, 60), # degrees
"visualizer_laser_yaw": (-60, 60),
@@ -59,7 +63,9 @@ LIMITS = {
}
_FLAGS = ("visualizer_radial_normalize", "visualizer_laser_head",
"visualizer_laser_glow", "visualizer_laser_grid")
_INTEGER = ("visualizer_radial_spokes", "visualizer_radial_levels")
_INTEGER = ("visualizer_radial_spokes", "visualizer_radial_levels",
"visualizer_laser_low_hz", "visualizer_laser_high_hz")
SPEAKER_MIN_RATIO = 2 ** (1 / 3) # the narrowest speaker: a third-octave
# How much later than Qt's playback position the speakers really are —
# PipeWire's buffer, and far more over Bluetooth. That's this machine's
@@ -89,9 +95,52 @@ def settings(prefs) -> dict:
for key in _FLAGS:
value = prefs.get(key, DEFAULTS[key]) if prefs is not None else None
out[key] = bool(DEFAULTS[key] if value is None else value)
low, high = out["visualizer_laser_low_hz"], out["visualizer_laser_high_hz"]
if low * SPEAKER_MIN_RATIO > high: # crossed or too close: keep high
out["visualizer_laser_low_hz"] = max(
LIMITS["visualizer_laser_low_hz"][0],
int(high / SPEAKER_MIN_RATIO))
return out
def one_pole(x: np.ndarray, a: float, y0: float):
"""The one-pole low-pass y[n] = a·y[n−1] + (1−a)·x[n], exact and
vectorized: y[n] = aⁿ·(a·y₀ + Σ (1−a)·x[k]·a⁻ᵏ). Chunked so a⁻ᵏ stays
under e²⁵. Returns the output and the state to carry on with."""
if len(x) == 0:
return x.copy(), y0
if a < 1e-4:
return x.copy(), float(x[-1])
chunk = max(1, min(4096, int(25 / -math.log(a))))
out = np.empty(len(x), np.float64)
for start in range(0, len(x), chunk):
piece = x[start:start + chunk]
powers = a ** np.arange(len(piece))
y = powers * (a * y0 + np.cumsum((1 - a) * piece / powers))
out[start:start + chunk] = y
y0 = float(y[-1])
return out, y0
def thin_path(xy: np.ndarray, spacing: float, cap: int = 4000) -> np.ndarray:
"""Indices of the points worth drawing: one per ``spacing`` of distance
along the path (plus the last). Thinning by *count* would alias a fast
treble zigzag into slow shapes that aren't there; by distance, a zigzag
keeps every corner and only a slow, smooth stretch loses points."""
if len(xy) <= 2 or spacing <= 0:
return np.arange(len(xy))
travel = np.concatenate([[0.0], np.cumsum(
np.linalg.norm(np.diff(xy, axis=0), axis=1))])
if travel[-1] / spacing >= len(xy):
idx = np.arange(len(xy))
else:
marks = np.arange(0.0, travel[-1], spacing)
idx = np.unique(np.append(np.searchsorted(travel, marks), len(xy) - 1))
if len(idx) > cap:
idx = idx[np.linspace(0, len(idx) - 1, cap).astype(int)]
return idx
@dataclass
class Frame:
"""One timer tick's worth of audio, handed to ``advance``."""
@@ -286,6 +335,7 @@ class RadialRenderer:
def configure(self, config):
self.fade = config["visualizer_ghost_fade"]
self.normalize = config["visualizer_radial_normalize"]
self.dot_scale = config["visualizer_radial_dot"]
spokes = config["visualizer_radial_spokes"]
levels = config["visualizer_radial_levels"]
if (spokes, levels) != (self.spokes, self.ring_count):
@@ -397,7 +447,10 @@ class RadialRenderer:
ring_step = (radius - hub) / self.ring_count
# Smaller than the gap to every neighbour, so a trail stays a row of
# dots rather than running together into a band.
dot = max(1.5, min(2 * math.pi * radius / spokes, ring_step) * 0.7)
# Then the user's share of that: dot size is a percentage, because
# the right size in pixels differs between the panel and the window.
dot = max(1.0, max(1.5, min(2 * math.pi * radius / spokes, ring_step)
* 0.7) * self.dot_scale)
sin, cos = np.sin(2 * math.pi * angle), np.cos(2 * math.pi * angle)
def points(spoke_idx, ring_idx):
@@ -480,9 +533,27 @@ FILL = 0.85 # at the farthest distance, the loudest swings fill this
# slider's far end.
BEAM_TARGET = math.atan(FILL * SCREEN_HALF / LIMITS["visualizer_laser_distance"][1])
GAIN_RELEASE_S = 0.3 # how slowly the auto-gain turns back up after a peak
PEAK_FLOOR = 0.0005 # ~22 dB over -60 dB hiss: a still dot, not dancing noise
# Silence must stay a still dot, not amplified hiss: the auto-gain never
# turns up past what -60 dB hiss would swing, 22 dB over. Measured through
# the chosen speaker, since treble alone moves the mirror ~80 dB less than
# bass — one fixed floor would hide it entirely.
HISS = 10 ** (-60 / 20)
FLOOR_OVER_HISS = 10 ** (22 / 20)
PEAK_FLOOR = 0.0005 # until a sample rate is known
AFTERIMAGE_ALPHA = 0.6 # a just-past sweep, before its fade begins
AFTERIMAGE_STEP = 3 # older sweeps keep every 3rd point: they're faint
# One-pole stages per edge: 36 dB/octave. A real speaker rolls off more
# gently, but the membrane answers bass ~80 dB more than treble, so anything
# softer lets the bass through a "treble only" speaker and drown it.
SPEAKER_POLES = 6
BASS_TOP_HZ = 300 # a speaker cut above this has lost the bass
AFTERIMAGE_POINTS = 250
# The current sweep's most points. Only a treble-only speaker (every sample
# simulated) gets near it, and its figure is a shimmer along one line: a
# solid band whether 700 corners are drawn or 360. Stroking them all ran the
# big window past a frame.
SWEEP_POINTS = 360
PHOSPHOR_GONE = 0.02 # what's left of a sweep once its afterimage time is up
PHOSPHOR_HALF_OVER = 400 # px wide: past this, the phosphor is half resolution
CHUNK = 256 # samples per vectorized resonator step
ROOM = 0.95 # the figure never comes closer to the edge than this
GRID_HALF = 4 # graticule divisions from the center to the short edge
@@ -551,17 +622,29 @@ class LaserRenderer:
# screen; the polygon is the decimated copy an afterimage is drawn
# from, built once when the sweep stops being the newest.
self.paths = []
self._phosphor = {} # (w, h) → _Phosphor
self.clock = 0.0
self.band = None
self._floor = PEAK_FLOOR
self.configure(config or settings(None))
self.clear()
def configure(self, config):
# Anything here can change how a sweep maps to pixels: start the
# phosphor afresh rather than mix two pictures.
self._phosphor = {}
self.afterimage = config["visualizer_laser_afterimage"]
self.width = config["visualizer_laser_width"]
self.dot = config["visualizer_laser_dot"]
self.head = config["visualizer_laser_head"]
self.glow = config["visualizer_laser_glow"]
self.grid = config["visualizer_laser_grid"]
band = (config["visualizer_laser_low_hz"],
config["visualizer_laser_high_hz"])
if band != getattr(self, "band", None):
self.band = band
if self._rate:
self._setup(self._rate)
self._prune()
self.distance = config["visualizer_laser_distance"]
rot = _rotation(config["visualizer_laser_pitch"],
@@ -579,8 +662,10 @@ class LaserRenderer:
def clear(self):
self._state = np.zeros(len(MODES), dtype=np.complex128)
self._carry = np.zeros(0, dtype=np.float64)
self._peak = PEAK_FLOOR
self._peak = self._floor
self._filter_state = [0.0] * (2 * SPEAKER_POLES)
self.paths = [(np.zeros((1, 2)), self.clock, None)]
self._phosphor = {}
def _prune(self):
"""Drop sweeps whose afterimage has faded (all but the newest, when
@@ -593,7 +678,22 @@ class LaserRenderer:
def _setup(self, rate: int):
self._rate = rate
self._step = max(1, int(round(rate / SIM_RATE)))
low, high = self.band
lo_end, hi_end = LIMITS["visualizer_laser_high_hz"]
# The speaker: a high-pass at `low` and a low-pass at `high`, 12
# dB/octave each, left out entirely at the slider's ends.
self._low_a = (math.exp(-2 * math.pi * low / rate)
if low > lo_end else None)
self._high_a = (math.exp(-2 * math.pi * high / rate)
if high < min(hi_end, rate * 0.45) else None)
self._filter_state = [0.0] * (2 * SPEAKER_POLES)
# Simulate fast enough for what the speaker passes. While bass gets
# through, treble moves the mirror ~80 dB less — far under a pixel —
# so a quarter rate does; only a speaker cut above the bass needs
# every sample.
fast = low >= BASS_TOP_HZ
self._step = max(1, int(rate / max(
SIM_RATE, 2.5 * min(high, rate / 2) if fast else 0)))
fs = rate / self._step
freq = np.array([f for f, _w, _x, _y in MODES])
# Each resonator is the imaginary part of a complex one-pole filter:
@@ -604,8 +704,31 @@ class LaserRenderer:
slopes = np.array([(x, y) for _f, _w, x, y in MODES])
# α normalizes each resonator to unit gain at its own resonance.
self._mix = (weights * self._alpha)[:, None] * slopes # (modes, 2)
self._state = np.zeros(len(MODES), dtype=np.complex128)
self._carry = np.zeros(0, dtype=np.float64)
# Measure the floor: reference hiss through this very speaker and
# membrane (the drive is fully deterministic, so is the floor).
hiss = np.random.default_rng(0).standard_normal(int(rate * 0.3)) * HISS
tilt = self.drive(hiss, rate)
self._floor = max(1e-12, float(np.linalg.norm(
tilt[len(tilt) // 2:], axis=1).max()) * FLOOR_OVER_HISS)
self._state[:] = 0
self._carry = np.zeros(0, dtype=np.float64)
self._filter_state = [0.0] * (2 * SPEAKER_POLES)
self._peak = self._floor
def _speaker(self, samples) -> np.ndarray:
"""What the speaker under the mirror plays of ``samples``."""
x = np.asarray(samples, np.float64)
state = self._filter_state
for i in range(SPEAKER_POLES):
if self._low_a is not None: # high-pass = input − low-pass
below, state[i] = one_pole(x, self._low_a, state[i])
x = x - below
if self._high_a is not None:
x, state[SPEAKER_POLES + i] = one_pole(
x, self._high_a, state[SPEAKER_POLES + i])
return x
def drive(self, samples: np.ndarray, rate: int) -> np.ndarray:
"""Feed audio through the membrane; returns the mirror's tilt, (n, 2),
@@ -613,7 +736,7 @@ class LaserRenderer:
same audio into different chunks gives the same tilt."""
if rate != self._rate:
self._setup(rate)
data = np.concatenate([self._carry, np.asarray(samples, np.float64)])
data = np.concatenate([self._carry, self._speaker(samples)])
usable = len(data) // self._step * self._step
self._carry = data[usable:]
# Averaging blocks is the downsample, and a little of the mirror's
@@ -652,7 +775,7 @@ class LaserRenderer:
"""Mirror tilt → spot on the screen (metres, y up)."""
peak = float(np.linalg.norm(tilt, axis=1).max()) if len(tilt) else 0.0
self._peak = max(peak, self._peak * math.exp(-dt / GAIN_RELEASE_S),
PEAK_FLOOR)
self._floor)
# The gain that swings the loudest beam to BEAM_TARGET — reflection
# doubles the mirror's angle, hence the 2.
gain = BEAM_TARGET / (2 * self._peak)
@@ -671,7 +794,12 @@ class LaserRenderer:
path = np.vstack([last[-1:], path])
older = []
if self.afterimage > 0:
thin = np.vstack([last[::AFTERIMAGE_STEP], last[-1:]])
# ~1 px in the big window, and at most AFTERIMAGE_POINTS: an
# afterimage is faint, and stroking it is the costliest thing a
# frame draws (only a treble-only speaker reaches the cap, and
# its figure is a line, where thinning can't invent a shape).
thin = last[thin_path(last, max(self.extent) / 250,
cap=AFTERIMAGE_POINTS)]
older = [(last, born, QPolygonF([QPointF(x, y) for x, y in thin]))]
self.paths = [(path, self.clock, None)] + older + self.paths[1:]
self._prune()
@@ -691,6 +819,43 @@ class LaserRenderer:
return unit * min(1.0, ROOM * half_w / (reach_x * unit),
ROOM * half_h / (reach_y * unit))
def _exposed(self, rect, px, color) -> "_Phosphor":
"""This rect's phosphor, brought up to now: dimmed for the time
since it was last shown, then every sweep that has stopped being the
newest since then burned in. Each size (panel, big window) has its
own, so either can be shown without the other."""
key = (max(1, round(rect.width())), max(1, round(rect.height())))
phosphor = self._phosphor.get(key)
if phosphor is None:
scale = 0.5 if key[0] > PHOSPHOR_HALF_OVER else 1.0
phosphor = self._phosphor[key] = _Phosphor(*key, scale)
if phosphor.clock is not None and self.clock > phosphor.clock:
phosphor.dim(PHOSPHOR_GONE ** ((self.clock - phosphor.clock)
/ self.afterimage))
phosphor.clock = self.clock
fresh = [entry for entry in self.paths[1:]
if entry[1] > phosphor.through]
if fresh:
painter = QPainter(phosphor.image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
height, width = phosphor.pixels.shape[:2]
painter.translate(width / 2, height / 2)
painter.scale(px * phosphor.scale, -px * phosphor.scale)
pen = QPen()
pen.setCosmetic(True) # the width stays in pixels
pen.setWidthF(max(1.0, self.width * phosphor.scale))
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
for _path, born, polygon in reversed(fresh): # oldest first
age = self.clock - born
pen.setColor(_faded(color, AFTERIMAGE_ALPHA * PHOSPHOR_GONE
** (age / self.afterimage)))
painter.setPen(pen)
painter.drawPolyline(polygon)
painter.end()
phosphor.through = fresh[0][1]
return phosphor
def paint_grid(self, painter, rect, gray: QColor):
"""An oscilloscope's graticule: dots at every division crossing, and
finer ticks along the two center lines. Painted *on the glass*, so
@@ -731,36 +896,29 @@ class LaserRenderer:
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
# The afterimage: past sweeps, oldest first, fading with age. Drawn
# in screen metres through a transform, so each polygon is built
# once; a cosmetic pen keeps the line width in real pixels.
if len(self.paths) > 1:
# The afterimage: what the phosphor still holds of past sweeps.
if self.afterimage > 0 and len(self.paths) > 1:
painter.save()
painter.translate(cx, cy)
painter.scale(px, -px)
pen.setCosmetic(True)
pen.setWidthF(self.width)
for _path, born, polygon in reversed(self.paths[1:]):
strength = 1.0 - (self.clock - born) / self.afterimage
if strength <= 0:
continue
pen.setColor(_faded(color, AFTERIMAGE_ALPHA * strength))
painter.setPen(pen)
painter.drawPolyline(polygon)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawImage(rect, self._exposed(rect, px, color).image)
painter.restore()
pen.setCosmetic(False)
path = self.paths[0][0]
xy = np.column_stack([cx + path[:, 0] * px, cy - path[:, 1] * px])
if len(path) >= 2:
# A frame is ~180 steps; the panel is ~60 px across, so most
# would be sub-pixel segments that cost a Qt object each.
step = max(1, int(len(path) / (2 * half)))
xy = np.vstack([xy[::step], xy[-1:]])
# A frame is 180–735 steps, mostly sub-pixel in the panel; keep
# one point per ¾ px travelled (fast wiggles keep their corners,
# up to SWEEP_POINTS).
idx = thin_path(xy, 0.75, cap=SWEEP_POINTS)
spans = np.maximum(np.diff(idx), 1)
xy = xy[idx]
# The haze and the screen's scatter: one even bloom along the
# whole path, a single polyline so joints don't stack up alpha.
if self.glow:
line = QPolygonF([QPointF(x, y) for x, y in xy])
# The bloom is several pixels wide: a coarser path is the
# same picture for a fraction of the stroking.
coarse = xy[thin_path(xy, 1.5 * self.width, cap=SWEEP_POINTS // 2)]
line = QPolygonF([QPointF(x, y) for x, y in coarse])
for width, strength in ((6 * self.width, 0.07),
(2.5 * self.width, 0.18)):
pen.setWidthF(width)
@@ -770,7 +928,7 @@ class LaserRenderer:
# The core. Dwell: the beam lights a stretch of screen for as
# long as it stays there, so slow stretches are bright and fast
# sweeps faint.
speed = np.linalg.norm(np.diff(xy, axis=0), axis=1) / step
speed = np.linalg.norm(np.diff(xy, axis=0), axis=1) / spans
bright = np.clip(0.04 * half / (speed + 1e-6), 0.15, 1.0)
bucket = np.ceil(bright * 6).astype(int)
pen.setWidthF(self.width)
@@ -796,6 +954,42 @@ class LaserRenderer:
painter.drawEllipse(head, radius, radius)
class _Phosphor:
"""An afterimage buffer, like a scope tube's phosphor: sweeps are burned
in once and the whole picture is dimmed a little each frame, so a long
afterimage costs no more than a short one (drawing every past sweep each
frame did, antialiased, and ran past a frame's budget in the big window).
Dimming is integer math that rounds *down*, through a lookup table:
Qt's own fade rounds to nearest, so faint pixels stall at a level where
×factor rounds back to itself and stay as a stain forever (at a 3 s fade,
~20% bright). Rounding down always reaches zero. Wide targets keep the
buffer at half resolution: the dimming is a numpy pass over every pixel,
and a faint afterimage loses nothing by being a touch soft."""
def __init__(self, width: int, height: int, scale: float):
self.scale = scale
w, h = max(1, int(width * scale)), max(1, int(height * scale))
self.image = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied)
self.image.fill(0)
# A numpy *view* of the image's own memory. (Building the QImage
# from a numpy array instead copies it: the dimming then changed an
# array nobody draws.)
bits = self.image.bits()
bits.setsize(self.image.sizeInBytes())
self.pixels = np.frombuffer(bits, np.uint8).reshape(
h, self.image.bytesPerLine() // 4, 4)
self.clock = None # the renderer's clock when last brought up
self.through = -math.inf # born-time of the newest sweep burned in
def dim(self, factor: float):
keep = int(max(0.0, min(1.0, factor)) * 256)
if keep >= 256:
return
table = ((np.arange(256) * keep) >> 8).astype(np.uint8)
np.take(table, self.pixels, out=self.pixels)
def make_renderer(style: str, config=None):
if style == STYLE_RADIAL:
return RadialRenderer(config)
+3
View File
@@ -25,8 +25,11 @@ DEFAULTS = {
"visualizer_laser_head": True, # a dot at the moving beam's tip
"visualizer_laser_glow": True, # the haze/scatter bloom
"visualizer_laser_grid": False, # an oscilloscope's dot graticule
"visualizer_laser_low_hz": 20, # the speaker under the mirror:
"visualizer_laser_high_hz": 20000, # what it plays (ends = no filter)
"visualizer_radial_spokes": 120, # frequency bands around the circle
"visualizer_radial_levels": 20, # volume steps along each spoke
"visualizer_radial_dot": 1.0, # dot size, × the automatic size
"visualizer_radial_normalize": True, # even out bass vs treble
"visualizer_laser_distance": 3.5, # metres, mirror → screen center
"visualizer_laser_pitch": 0, # degrees the screen is turned
+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)