From 8b42580f9722d8cadfcbdeaf406df930fd86688f Mon Sep 17 00:00:00 2001 From: trav Date: Sat, 10 Oct 2026 01:24:37 -0700 Subject: [PATCH] v0.42.0: laser speaker range, phosphor afterimage; line width box, radial dot size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 19 ++- lintunes/__init__.py | 2 +- lintunes/gui/preferences_dialog.py | 93 ++++++++++- lintunes/gui/range_slider.py | 133 +++++++++++++++ lintunes/gui/visualizer_styles.py | 258 +++++++++++++++++++++++++---- lintunes/preferences.py | 3 + tests/test_round78.py | 181 +++++++++++++++++++- 7 files changed, 648 insertions(+), 41 deletions(-) create mode 100644 lintunes/gui/range_slider.py diff --git a/CLAUDE.md b/CLAUDE.md index 052f624..2e36a9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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* diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 3d31353..b085ef0 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.41.0" +__version__ = "0.42.0" diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 4d39eb0..2f8da56 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -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) diff --git a/lintunes/gui/range_slider.py b/lintunes/gui/range_slider.py new file mode 100644 index 0000000..933340a --- /dev/null +++ b/lintunes/gui/range_slider.py @@ -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) diff --git a/lintunes/gui/visualizer_styles.py b/lintunes/gui/visualizer_styles.py index 0392d54..2024af4 100644 --- a/lintunes/gui/visualizer_styles.py +++ b/lintunes/gui/visualizer_styles.py @@ -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) diff --git a/lintunes/preferences.py b/lintunes/preferences.py index 92e1cbc..df2e12a 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -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 diff --git a/tests/test_round78.py b/tests/test_round78.py index d4f7c0a..f011050 100644 --- a/tests/test_round78.py +++ b/tests/test_round78.py @@ -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)