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:
@@ -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
|
`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
|
*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
|
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
|
brighter where it moves slower (dwell). The afterimage is a **phosphor**
|
||||||
afterimage time as pre-built polygons in screen metres, drawn through a
|
(`_Phosphor`, one per painted size, half resolution past 400 px wide):
|
||||||
transform with a cosmetic pen. Every style's fade is one seconds slider,
|
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
|
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
|
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*
|
buffers arrive in a burst. So the widget keeps 1.5 s of *timestamped*
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.41.0"
|
__version__ = "0.42.0"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
|
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
|
||||||
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
|
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
|
||||||
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
|
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
|
||||||
QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton,
|
QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton,
|
||||||
QColorDialog, QMenu,
|
QColorDialog, QMenu, QDoubleSpinBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QTimer
|
from PyQt6.QtCore import Qt, QTimer
|
||||||
from PyQt6.QtGui import QFont, QColor
|
from PyQt6.QtGui import QFont, QColor
|
||||||
@@ -11,6 +13,7 @@ from PyQt6.QtGui import QFont, QColor
|
|||||||
from lintunes import theme
|
from lintunes import theme
|
||||||
from lintunes.config import load_config, save_config
|
from lintunes.config import load_config, save_config
|
||||||
from lintunes.gui import visualizer_styles
|
from lintunes.gui import visualizer_styles
|
||||||
|
from lintunes.gui.range_slider import RangeSlider
|
||||||
from lintunes.gui import sounds
|
from lintunes.gui import sounds
|
||||||
from lintunes.theme import HIGHLIGHT_COLORS
|
from lintunes.theme import HIGHLIGHT_COLORS
|
||||||
|
|
||||||
@@ -32,6 +35,33 @@ _COLOR_ROWS = [
|
|||||||
_VALUE_WIDTH = 56
|
_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:
|
def _fade_text(seconds: float) -> str:
|
||||||
return "Off" if seconds <= 0 else f"{seconds:.2f} s"
|
return "Off" if seconds <= 0 else f"{seconds:.2f} s"
|
||||||
|
|
||||||
@@ -433,6 +463,10 @@ class PreferencesDialog(QDialog):
|
|||||||
self._levels_slider, _ = self._viz_slider(
|
self._levels_slider, _ = self._viz_slider(
|
||||||
form, "Volume steps:", "visualizer_radial_levels", config,
|
form, "Volume steps:", "visualizer_radial_levels", config,
|
||||||
tip="How many positions each dot can take, center to rim")
|
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(
|
self._normalize = self._viz_check(
|
||||||
form, "Even out bass and treble", "visualizer_radial_normalize",
|
form, "Even out bass and treble", "visualizer_radial_normalize",
|
||||||
config, tip="Music is loudest in the bass. This learns the "
|
config, tip="Music is loudest in the bass. This learns the "
|
||||||
@@ -446,9 +480,8 @@ class PreferencesDialog(QDialog):
|
|||||||
form, "Afterimage:", "visualizer_laser_afterimage", config,
|
form, "Afterimage:", "visualizer_laser_afterimage", config,
|
||||||
scale=20, fmt=_fade_text,
|
scale=20, fmt=_fade_text,
|
||||||
tip="How long past sweeps of the beam take to fade — 0 is none")
|
tip="How long past sweeps of the beam take to fade — 0 is none")
|
||||||
self._width_slider, _ = self._viz_slider(
|
self._speaker = self._speaker_range(form, config)
|
||||||
form, "Line width:", "visualizer_laser_width", config, scale=2,
|
self._width_box = self._width_spinbox(form, config)
|
||||||
fmt=lambda v: f"{v:g} px")
|
|
||||||
self._glow = self._viz_check(
|
self._glow = self._viz_check(
|
||||||
form, "Glow", "visualizer_laser_glow", config,
|
form, "Glow", "visualizer_laser_glow", config,
|
||||||
tip="The haze around the beam")
|
tip="The haze around the beam")
|
||||||
@@ -506,6 +539,58 @@ class PreferencesDialog(QDialog):
|
|||||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||||
return form
|
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:
|
def _sync_delay_slider(self, form) -> QSlider:
|
||||||
lo, hi = visualizer_styles.SYNC_DELAY_LIMITS
|
lo, hi = visualizer_styles.SYNC_DELAY_LIMITS
|
||||||
slider = QSlider(Qt.Orientation.Horizontal)
|
slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -21,7 +21,7 @@ from dataclasses import dataclass
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt, QPointF, QLineF
|
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
|
from lintunes.preferences import DEFAULTS
|
||||||
|
|
||||||
@@ -50,8 +50,12 @@ LIMITS = {
|
|||||||
"visualizer_laser_afterimage": (0.0, 3.0),
|
"visualizer_laser_afterimage": (0.0, 3.0),
|
||||||
"visualizer_laser_width": (1.0, 8.0), # pixels
|
"visualizer_laser_width": (1.0, 8.0), # pixels
|
||||||
"visualizer_laser_dot": (1.0, 16.0), # pixels across
|
"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_spokes": (12, 360),
|
||||||
"visualizer_radial_levels": (4, 40),
|
"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_distance": (1.0, 4.0), # metres, mirror → screen
|
||||||
"visualizer_laser_pitch": (-60, 60), # degrees
|
"visualizer_laser_pitch": (-60, 60), # degrees
|
||||||
"visualizer_laser_yaw": (-60, 60),
|
"visualizer_laser_yaw": (-60, 60),
|
||||||
@@ -59,7 +63,9 @@ LIMITS = {
|
|||||||
}
|
}
|
||||||
_FLAGS = ("visualizer_radial_normalize", "visualizer_laser_head",
|
_FLAGS = ("visualizer_radial_normalize", "visualizer_laser_head",
|
||||||
"visualizer_laser_glow", "visualizer_laser_grid")
|
"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 —
|
# 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
|
# 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:
|
for key in _FLAGS:
|
||||||
value = prefs.get(key, DEFAULTS[key]) if prefs is not None else None
|
value = prefs.get(key, DEFAULTS[key]) if prefs is not None else None
|
||||||
out[key] = bool(DEFAULTS[key] if value is None else value)
|
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
|
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
|
@dataclass
|
||||||
class Frame:
|
class Frame:
|
||||||
"""One timer tick's worth of audio, handed to ``advance``."""
|
"""One timer tick's worth of audio, handed to ``advance``."""
|
||||||
@@ -286,6 +335,7 @@ class RadialRenderer:
|
|||||||
def configure(self, config):
|
def configure(self, config):
|
||||||
self.fade = config["visualizer_ghost_fade"]
|
self.fade = config["visualizer_ghost_fade"]
|
||||||
self.normalize = config["visualizer_radial_normalize"]
|
self.normalize = config["visualizer_radial_normalize"]
|
||||||
|
self.dot_scale = config["visualizer_radial_dot"]
|
||||||
spokes = config["visualizer_radial_spokes"]
|
spokes = config["visualizer_radial_spokes"]
|
||||||
levels = config["visualizer_radial_levels"]
|
levels = config["visualizer_radial_levels"]
|
||||||
if (spokes, levels) != (self.spokes, self.ring_count):
|
if (spokes, levels) != (self.spokes, self.ring_count):
|
||||||
@@ -397,7 +447,10 @@ class RadialRenderer:
|
|||||||
ring_step = (radius - hub) / self.ring_count
|
ring_step = (radius - hub) / self.ring_count
|
||||||
# Smaller than the gap to every neighbour, so a trail stays a row of
|
# Smaller than the gap to every neighbour, so a trail stays a row of
|
||||||
# dots rather than running together into a band.
|
# 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)
|
sin, cos = np.sin(2 * math.pi * angle), np.cos(2 * math.pi * angle)
|
||||||
|
|
||||||
def points(spoke_idx, ring_idx):
|
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.
|
# slider's far end.
|
||||||
BEAM_TARGET = math.atan(FILL * SCREEN_HALF / LIMITS["visualizer_laser_distance"][1])
|
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
|
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_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
|
CHUNK = 256 # samples per vectorized resonator step
|
||||||
ROOM = 0.95 # the figure never comes closer to the edge than this
|
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
|
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
|
# screen; the polygon is the decimated copy an afterimage is drawn
|
||||||
# from, built once when the sweep stops being the newest.
|
# from, built once when the sweep stops being the newest.
|
||||||
self.paths = []
|
self.paths = []
|
||||||
|
self._phosphor = {} # (w, h) → _Phosphor
|
||||||
self.clock = 0.0
|
self.clock = 0.0
|
||||||
|
self.band = None
|
||||||
|
self._floor = PEAK_FLOOR
|
||||||
self.configure(config or settings(None))
|
self.configure(config or settings(None))
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
def configure(self, config):
|
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.afterimage = config["visualizer_laser_afterimage"]
|
||||||
self.width = config["visualizer_laser_width"]
|
self.width = config["visualizer_laser_width"]
|
||||||
self.dot = config["visualizer_laser_dot"]
|
self.dot = config["visualizer_laser_dot"]
|
||||||
self.head = config["visualizer_laser_head"]
|
self.head = config["visualizer_laser_head"]
|
||||||
self.glow = config["visualizer_laser_glow"]
|
self.glow = config["visualizer_laser_glow"]
|
||||||
self.grid = config["visualizer_laser_grid"]
|
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._prune()
|
||||||
self.distance = config["visualizer_laser_distance"]
|
self.distance = config["visualizer_laser_distance"]
|
||||||
rot = _rotation(config["visualizer_laser_pitch"],
|
rot = _rotation(config["visualizer_laser_pitch"],
|
||||||
@@ -579,8 +662,10 @@ class LaserRenderer:
|
|||||||
def clear(self):
|
def clear(self):
|
||||||
self._state = np.zeros(len(MODES), dtype=np.complex128)
|
self._state = np.zeros(len(MODES), dtype=np.complex128)
|
||||||
self._carry = np.zeros(0, dtype=np.float64)
|
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.paths = [(np.zeros((1, 2)), self.clock, None)]
|
||||||
|
self._phosphor = {}
|
||||||
|
|
||||||
def _prune(self):
|
def _prune(self):
|
||||||
"""Drop sweeps whose afterimage has faded (all but the newest, when
|
"""Drop sweeps whose afterimage has faded (all but the newest, when
|
||||||
@@ -593,7 +678,22 @@ class LaserRenderer:
|
|||||||
|
|
||||||
def _setup(self, rate: int):
|
def _setup(self, rate: int):
|
||||||
self._rate = rate
|
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
|
fs = rate / self._step
|
||||||
freq = np.array([f for f, _w, _x, _y in MODES])
|
freq = np.array([f for f, _w, _x, _y in MODES])
|
||||||
# Each resonator is the imaginary part of a complex one-pole filter:
|
# 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])
|
slopes = np.array([(x, y) for _f, _w, x, y in MODES])
|
||||||
# α normalizes each resonator to unit gain at its own resonance.
|
# α normalizes each resonator to unit gain at its own resonance.
|
||||||
self._mix = (weights * self._alpha)[:, None] * slopes # (modes, 2)
|
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._state[:] = 0
|
||||||
self._carry = np.zeros(0, dtype=np.float64)
|
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:
|
def drive(self, samples: np.ndarray, rate: int) -> np.ndarray:
|
||||||
"""Feed audio through the membrane; returns the mirror's tilt, (n, 2),
|
"""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."""
|
same audio into different chunks gives the same tilt."""
|
||||||
if rate != self._rate:
|
if rate != self._rate:
|
||||||
self._setup(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
|
usable = len(data) // self._step * self._step
|
||||||
self._carry = data[usable:]
|
self._carry = data[usable:]
|
||||||
# Averaging blocks is the downsample, and a little of the mirror's
|
# 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)."""
|
"""Mirror tilt → spot on the screen (metres, y up)."""
|
||||||
peak = float(np.linalg.norm(tilt, axis=1).max()) if len(tilt) else 0.0
|
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),
|
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
|
# The gain that swings the loudest beam to BEAM_TARGET — reflection
|
||||||
# doubles the mirror's angle, hence the 2.
|
# doubles the mirror's angle, hence the 2.
|
||||||
gain = BEAM_TARGET / (2 * self._peak)
|
gain = BEAM_TARGET / (2 * self._peak)
|
||||||
@@ -671,7 +794,12 @@ class LaserRenderer:
|
|||||||
path = np.vstack([last[-1:], path])
|
path = np.vstack([last[-1:], path])
|
||||||
older = []
|
older = []
|
||||||
if self.afterimage > 0:
|
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]))]
|
older = [(last, born, QPolygonF([QPointF(x, y) for x, y in thin]))]
|
||||||
self.paths = [(path, self.clock, None)] + older + self.paths[1:]
|
self.paths = [(path, self.clock, None)] + older + self.paths[1:]
|
||||||
self._prune()
|
self._prune()
|
||||||
@@ -691,6 +819,43 @@ class LaserRenderer:
|
|||||||
return unit * min(1.0, ROOM * half_w / (reach_x * unit),
|
return unit * min(1.0, ROOM * half_w / (reach_x * unit),
|
||||||
ROOM * half_h / (reach_y * 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):
|
def paint_grid(self, painter, rect, gray: QColor):
|
||||||
"""An oscilloscope's graticule: dots at every division crossing, and
|
"""An oscilloscope's graticule: dots at every division crossing, and
|
||||||
finer ticks along the two center lines. Painted *on the glass*, so
|
finer ticks along the two center lines. Painted *on the glass*, so
|
||||||
@@ -731,36 +896,29 @@ class LaserRenderer:
|
|||||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
|
||||||
# The afterimage: past sweeps, oldest first, fading with age. Drawn
|
# The afterimage: what the phosphor still holds of past sweeps.
|
||||||
# in screen metres through a transform, so each polygon is built
|
if self.afterimage > 0 and len(self.paths) > 1:
|
||||||
# once; a cosmetic pen keeps the line width in real pixels.
|
|
||||||
if len(self.paths) > 1:
|
|
||||||
painter.save()
|
painter.save()
|
||||||
painter.translate(cx, cy)
|
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||||
painter.scale(px, -px)
|
painter.drawImage(rect, self._exposed(rect, px, color).image)
|
||||||
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.restore()
|
painter.restore()
|
||||||
pen.setCosmetic(False)
|
|
||||||
|
|
||||||
path = self.paths[0][0]
|
path = self.paths[0][0]
|
||||||
xy = np.column_stack([cx + path[:, 0] * px, cy - path[:, 1] * px])
|
xy = np.column_stack([cx + path[:, 0] * px, cy - path[:, 1] * px])
|
||||||
if len(path) >= 2:
|
if len(path) >= 2:
|
||||||
# A frame is ~180 steps; the panel is ~60 px across, so most
|
# A frame is 180–735 steps, mostly sub-pixel in the panel; keep
|
||||||
# would be sub-pixel segments that cost a Qt object each.
|
# one point per ¾ px travelled (fast wiggles keep their corners,
|
||||||
step = max(1, int(len(path) / (2 * half)))
|
# up to SWEEP_POINTS).
|
||||||
xy = np.vstack([xy[::step], xy[-1:]])
|
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
|
# The haze and the screen's scatter: one even bloom along the
|
||||||
# whole path, a single polyline so joints don't stack up alpha.
|
# whole path, a single polyline so joints don't stack up alpha.
|
||||||
if self.glow:
|
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),
|
for width, strength in ((6 * self.width, 0.07),
|
||||||
(2.5 * self.width, 0.18)):
|
(2.5 * self.width, 0.18)):
|
||||||
pen.setWidthF(width)
|
pen.setWidthF(width)
|
||||||
@@ -770,7 +928,7 @@ class LaserRenderer:
|
|||||||
# The core. Dwell: the beam lights a stretch of screen for as
|
# The core. Dwell: the beam lights a stretch of screen for as
|
||||||
# long as it stays there, so slow stretches are bright and fast
|
# long as it stays there, so slow stretches are bright and fast
|
||||||
# sweeps faint.
|
# 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)
|
bright = np.clip(0.04 * half / (speed + 1e-6), 0.15, 1.0)
|
||||||
bucket = np.ceil(bright * 6).astype(int)
|
bucket = np.ceil(bright * 6).astype(int)
|
||||||
pen.setWidthF(self.width)
|
pen.setWidthF(self.width)
|
||||||
@@ -796,6 +954,42 @@ class LaserRenderer:
|
|||||||
painter.drawEllipse(head, radius, radius)
|
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):
|
def make_renderer(style: str, config=None):
|
||||||
if style == STYLE_RADIAL:
|
if style == STYLE_RADIAL:
|
||||||
return RadialRenderer(config)
|
return RadialRenderer(config)
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ DEFAULTS = {
|
|||||||
"visualizer_laser_head": True, # a dot at the moving beam's tip
|
"visualizer_laser_head": True, # a dot at the moving beam's tip
|
||||||
"visualizer_laser_glow": True, # the haze/scatter bloom
|
"visualizer_laser_glow": True, # the haze/scatter bloom
|
||||||
"visualizer_laser_grid": False, # an oscilloscope's dot graticule
|
"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_spokes": 120, # frequency bands around the circle
|
||||||
"visualizer_radial_levels": 20, # volume steps along each spoke
|
"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_radial_normalize": True, # even out bass vs treble
|
||||||
"visualizer_laser_distance": 3.5, # metres, mirror → screen center
|
"visualizer_laser_distance": 3.5, # metres, mirror → screen center
|
||||||
"visualizer_laser_pitch": 0, # degrees the screen is turned
|
"visualizer_laser_pitch": 0, # degrees the screen is turned
|
||||||
|
|||||||
+180
-1
@@ -628,7 +628,7 @@ class TestPreferencesPage:
|
|||||||
assert prefs.get("visualizer_laser_grid") is True
|
assert prefs.get("visualizer_laser_grid") is True
|
||||||
dialog._glow.setChecked(False)
|
dialog._glow.setChecked(False)
|
||||||
dialog._head.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._dot_slider.setValue(12)
|
||||||
dialog._distance_slider.setValue(15)
|
dialog._distance_slider.setValue(15)
|
||||||
dialog._angle_sliders["visualizer_laser_pitch"].setValue(30)
|
dialog._angle_sliders["visualizer_laser_pitch"].setValue(30)
|
||||||
@@ -687,3 +687,182 @@ class TestPreferencesPage:
|
|||||||
before = (tmp_path / "preferences.json").read_text()
|
before = (tmp_path / "preferences.json").read_text()
|
||||||
self._dialog(prefs)
|
self._dialog(prefs)
|
||||||
assert (tmp_path / "preferences.json").read_text() == before
|
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