v0.40.0: visualizer settings — laser screen angles, radial resolution
Laser: the screen's distance, pitch, yaw and roll are sliders (the beam ray is intersected with the turned screen; the drawing shrinks only when a steep screen would push the figure out of view), and the afterimage can be turned off. The membrane is now a slack 50 Hz balloon whose split rocking pairs land in the bass, so it draws loops ~60% of the time instead of ~25%. Radial: fading trails can be turned off; the number of frequency bands and volume steps are sliders; ghosts fade from the dot's own color into the background; a 4096-point FFT and interpolation end the flat bass arcs; and "Even out bass and treble" takes the song's overall tilt out so the dots sit on a circle.
This commit is contained in:
@@ -178,16 +178,28 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
`radial`, `laser`) owns the animation state and paints it into any rect, so
|
||||
the double-click `VisualizerWindow` paints the **same** renderer bigger
|
||||
rather than a copy. Since the first press of a double-click already cycled
|
||||
the brightness, `mouseDoubleClickEvent` puts it back. **Radial** is the EQ
|
||||
as polar dots (angle = 360 log bands, radius = level in `STEPS` rings), and
|
||||
a moving dot leaves light-gray ghosts. These are kept as a birth time per
|
||||
(degree, ring), so a spoke never holds more than `STEPS` and pooling to
|
||||
fewer spokes is one reshape. **Laser** simulates a mirror on a speaker
|
||||
membrane: the circular-membrane modes (split degenerate pairs are what turn
|
||||
lines into rotating loops) run as complex one-pole resonators, vectorized in
|
||||
`CHUNK`s. Tilt goes through D·tan(2θ) with an auto-gain, and the path is
|
||||
drawn brighter where it moves slower (dwell). It needs **every** sample, not
|
||||
the newest FFT window, hence the widget's capped `_pending` queue.
|
||||
the brightness, `mouseDoubleClickEvent` puts it back. Every tunable is a
|
||||
`visualizer_*` pref, read through `visualizer_styles.settings()` (clamped to
|
||||
`LIMITS`, which the Preferences sliders share) and applied live through
|
||||
`configure()`. **Radial** is the EQ as polar dots (angle = log bands,
|
||||
radius = level in rings; both counts are settings). A moving dot leaves
|
||||
ghosts in its own color fading into the background, kept as a birth time
|
||||
per (spoke, ring) so a spoke never holds more than its ring count. It reads
|
||||
a **4096**-point FFT and interpolates bands narrower than a bin: with 2048
|
||||
points, every degree under ~1.3 kHz shared a bin with its neighbours and
|
||||
drew as a flat arc. "Even out bass and treble" removes a *straight*,
|
||||
loudness-weighted dB-vs-log-f tilt, clipped to ±24 dB, and never lifts a
|
||||
band that isn't sounding (a curve fitted to a lone tone lit up silent
|
||||
treble). **Laser** simulates a mirror on a speaker membrane: the
|
||||
circular-membrane modes run as complex one-pole resonators, vectorized in
|
||||
`CHUNK`s. Loops come from the split degenerate pairs, between whose
|
||||
resonances x and y answer up to 90° apart, hence the slack 50 Hz membrane
|
||||
with a wide split. The beam ray is intersected with a screen at
|
||||
`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). It needs **every** sample, not the
|
||||
newest FFT window, hence the widget's capped `_pending` queue.
|
||||
|
||||
- **`lintunes/importers/itunes_importer.py`** — parses the iTunes XML plist.
|
||||
Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.39.0"
|
||||
__version__ = "0.40.0"
|
||||
|
||||
@@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.gui import visualizer_styles
|
||||
from lintunes.gui import sounds
|
||||
from lintunes.theme import HIGHLIGHT_COLORS
|
||||
|
||||
@@ -326,7 +327,8 @@ class PreferencesDialog(QDialog):
|
||||
|
||||
def _build_visualizer_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Visualizer")
|
||||
form = QFormLayout(group)
|
||||
column = QVBoxLayout(group)
|
||||
config = visualizer_styles.settings(self._prefs)
|
||||
|
||||
style_row = QHBoxLayout()
|
||||
self._viz_style = QButtonGroup(self)
|
||||
@@ -343,45 +345,117 @@ class PreferencesDialog(QDialog):
|
||||
self._viz_radios[key] = radio
|
||||
style_row.addWidget(radio)
|
||||
style_row.addStretch()
|
||||
form.addRow("Style:", style_row)
|
||||
style_form = QFormLayout()
|
||||
style_form.addRow("Style:", style_row)
|
||||
column.addLayout(style_form)
|
||||
|
||||
# Tenths of a second, 0 (no trail) .. 3 s.
|
||||
fade_row = QHBoxLayout()
|
||||
self._fade_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._fade_slider.setRange(0, 30)
|
||||
self._fade_slider.setFixedWidth(160)
|
||||
fade = self._prefs.get("visualizer_ghost_fade", 1.0)
|
||||
self._fade_slider.setValue(int(round(float(fade) * 10)))
|
||||
self._fade_label = QLabel()
|
||||
self._show_fade(self._fade_slider.value())
|
||||
self._fade_slider.valueChanged.connect(self._on_fade_changed)
|
||||
fade_row.addWidget(self._fade_slider)
|
||||
fade_row.addSpacing(8)
|
||||
fade_row.addWidget(self._fade_label)
|
||||
fade_row.addStretch()
|
||||
form.addRow("Trail fade:", fade_row)
|
||||
self._fade_slider.setEnabled(current == "radial")
|
||||
self._fade_label.setEnabled(current == "radial")
|
||||
# Only the chosen style's settings are shown.
|
||||
self._viz_pages = QStackedWidget()
|
||||
bars = QLabel("The classic graphic EQ — nothing to set.")
|
||||
bars.setEnabled(False)
|
||||
self._viz_pages.addWidget(bars)
|
||||
|
||||
radial = QWidget()
|
||||
form = QFormLayout(radial)
|
||||
form.setContentsMargins(0, 0, 0, 0)
|
||||
self._radial_fade = self._viz_check(
|
||||
form, "Trails fade out", "visualizer_radial_fade", config)
|
||||
self._fade_slider, self._fade_label = self._viz_slider(
|
||||
form, "Fade time:", "visualizer_ghost_fade", config, scale=10,
|
||||
fmt=lambda v: f"{v:.1f} s")
|
||||
self._radial_fade.toggled.connect(self._fade_slider.setEnabled)
|
||||
self._radial_fade.toggled.connect(self._fade_label.setEnabled)
|
||||
self._fade_slider.setEnabled(self._radial_fade.isChecked())
|
||||
self._fade_label.setEnabled(self._radial_fade.isChecked())
|
||||
self._spokes_slider, _ = self._viz_slider(
|
||||
form, "Frequency bands:", "visualizer_radial_spokes", config,
|
||||
tip="How many slices of the spectrum go around the circle")
|
||||
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._normalize = self._viz_check(
|
||||
form, "Even out bass and treble", "visualizer_radial_normalize",
|
||||
config, tip="Music is loudest in the bass. This learns the "
|
||||
"song's overall balance and takes it out, so the "
|
||||
"dots sit on a circle and move with the music")
|
||||
self._viz_pages.addWidget(radial)
|
||||
|
||||
laser = QWidget()
|
||||
form = QFormLayout(laser)
|
||||
form.setContentsMargins(0, 0, 0, 0)
|
||||
self._laser_fade = self._viz_check(
|
||||
form, "Afterimage (recent sweeps fade out)",
|
||||
"visualizer_laser_fade", config)
|
||||
self._distance_slider, _ = self._viz_slider(
|
||||
form, "Screen distance:", "visualizer_laser_distance", config,
|
||||
scale=10, fmt=lambda v: f"{v:.1f} m",
|
||||
tip="From the mirror to the middle of the screen")
|
||||
degrees = lambda v: f"{v:+.0f}°" if v else "0°"
|
||||
self._angle_sliders = {}
|
||||
for key, label, tip in (
|
||||
("visualizer_laser_pitch", "Screen pitch:",
|
||||
"Tip the screen's top toward or away from the speaker — "
|
||||
"stretches the figure up and down"),
|
||||
("visualizer_laser_yaw", "Screen yaw:",
|
||||
"Turn the screen left or right — stretches the figure "
|
||||
"sideways"),
|
||||
("visualizer_laser_roll", "Screen roll:",
|
||||
"Spin the screen in its own plane — turns the figure")):
|
||||
self._angle_sliders[key], _ = self._viz_slider(
|
||||
form, label, key, config, fmt=degrees, tip=tip)
|
||||
square = QPushButton("Face Screen Square")
|
||||
square.clicked.connect(
|
||||
lambda: [s.setValue(0) for s in self._angle_sliders.values()])
|
||||
form.addRow("", square)
|
||||
self._viz_pages.addWidget(laser)
|
||||
|
||||
self._viz_pages.setCurrentIndex(list(_VISUALIZER_STYLES).index(current))
|
||||
column.addWidget(self._viz_pages)
|
||||
note = QLabel("Double-click the visualizer to see it big.")
|
||||
note.setEnabled(False)
|
||||
form.addRow(note)
|
||||
column.addWidget(note)
|
||||
return group
|
||||
|
||||
def _viz_check(self, form, label, key, config, tip=""):
|
||||
box = QCheckBox(label)
|
||||
box.setChecked(config[key]) # before connect
|
||||
box.setToolTip(tip)
|
||||
box.toggled.connect(lambda on: self._prefs.set(key, on))
|
||||
form.addRow(box)
|
||||
return box
|
||||
|
||||
def _viz_slider(self, form, label, key, config, *, scale=1, fmt=str,
|
||||
tip=""):
|
||||
"""A slider over ``visualizer_styles.LIMITS[key]`` that previews live
|
||||
and saves once the drag settles, with its value beside it."""
|
||||
lo, hi = visualizer_styles.LIMITS[key]
|
||||
slider = QSlider(Qt.Orientation.Horizontal)
|
||||
slider.setRange(round(lo * scale), round(hi * scale))
|
||||
slider.setFixedWidth(160)
|
||||
slider.setToolTip(tip)
|
||||
slider.setValue(round(config[key] * scale)) # before connect
|
||||
text = QLabel(fmt(config[key]))
|
||||
text.setMinimumWidth(QLabel("−180°").sizeHint().width())
|
||||
|
||||
def changed(position):
|
||||
value = position / scale if scale != 1 else position
|
||||
text.setText(fmt(value))
|
||||
self._prefs.set_live(key, value)
|
||||
self._color_save_timer.start() # persist once the drag settles
|
||||
|
||||
slider.valueChanged.connect(changed)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(slider)
|
||||
row.addSpacing(8)
|
||||
row.addWidget(text)
|
||||
row.addStretch()
|
||||
form.addRow(label, row)
|
||||
return slider, text
|
||||
|
||||
def _on_viz_style(self, key: str):
|
||||
self._fade_slider.setEnabled(key == "radial")
|
||||
self._fade_label.setEnabled(key == "radial")
|
||||
self._viz_pages.setCurrentIndex(list(_VISUALIZER_STYLES).index(key))
|
||||
self._prefs.set("visualizer_style", key)
|
||||
|
||||
def _show_fade(self, tenths: int):
|
||||
self._fade_label.setText("No trail" if tenths == 0
|
||||
else f"{tenths / 10:.1f} s")
|
||||
|
||||
def _on_fade_changed(self, tenths: int):
|
||||
self._show_fade(tenths)
|
||||
self._prefs.set_live("visualizer_ghost_fade", tenths / 10)
|
||||
self._color_save_timer.start() # persist once the drag settles
|
||||
|
||||
def _build_colors_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Colors")
|
||||
form = QFormLayout(group)
|
||||
|
||||
+14
-26
@@ -15,11 +15,11 @@ from PyQt6.QtMultimedia import QAudioFormat
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.gui.icons import transport_icon
|
||||
from lintunes.gui import visualizer_styles as styles
|
||||
from lintunes.gui.visualizer_styles import (
|
||||
BANDS, STYLES, STYLE_BARS, Frame, make_renderer)
|
||||
BANDS, HISTORY, STYLES, STYLE_BARS, Frame, make_renderer)
|
||||
|
||||
|
||||
FFT_SIZE = 2048
|
||||
FRAME_MS = 16 # ~60 fps
|
||||
RADIUS = 8 # rounded corners, matching the transport button boxes
|
||||
ICON_SIZE = 40 # the cast glyph shown in place of bars while casting
|
||||
@@ -46,14 +46,6 @@ def _style(prefs) -> str:
|
||||
return saved if saved in STYLES else STYLE_BARS
|
||||
|
||||
|
||||
def _fade(prefs) -> float:
|
||||
saved = prefs.get("visualizer_ghost_fade", 1.0) if prefs is not None else 1.0
|
||||
try:
|
||||
return max(0.0, float(saved))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
|
||||
|
||||
class VisualizerWidget(QWidget):
|
||||
"""Animated visualizer; click to cycle brightness: on / dim / off, and
|
||||
double-click to open it big (``VisualizerWindow``).
|
||||
@@ -74,12 +66,11 @@ class VisualizerWidget(QWidget):
|
||||
saved = prefs.get("visualizer_mode") if prefs is not None else None
|
||||
self._mode = saved if saved in _MODE_CYCLE else MODE_ON
|
||||
self._mode_before_press = self._mode
|
||||
self._renderer = make_renderer(_style(prefs), _fade(prefs))
|
||||
self._samples = np.zeros(FFT_SIZE, dtype=np.float32)
|
||||
self._renderer = make_renderer(_style(prefs), styles.settings(prefs))
|
||||
self._samples = np.zeros(HISTORY, dtype=np.float32)
|
||||
self._pending = [] # every sample since the last frame (the laser)
|
||||
self._pending_len = 0
|
||||
self._sample_rate = 44100
|
||||
self._window = np.hanning(FFT_SIZE).astype(np.float32)
|
||||
self._big = None # VisualizerWindow, made on first double-click
|
||||
# True while audio is coming out of something other than this machine
|
||||
# (a Chromecast), so there is no decoded PCM to draw.
|
||||
@@ -117,11 +108,12 @@ class VisualizerWidget(QWidget):
|
||||
|
||||
def _on_prefs_changed(self):
|
||||
style = _style(self._prefs)
|
||||
config = styles.settings(self._prefs)
|
||||
if style != self._renderer.name:
|
||||
self._renderer = make_renderer(style, _fade(self._prefs))
|
||||
self._repaint()
|
||||
elif hasattr(self._renderer, "fade"):
|
||||
self._renderer.fade = _fade(self._prefs)
|
||||
self._renderer = make_renderer(style, config)
|
||||
else:
|
||||
self._renderer.configure(config)
|
||||
self._repaint()
|
||||
|
||||
def _on_sink_changed(self, sink):
|
||||
"""Casting means no local decode, so there is nothing to draw. The
|
||||
@@ -160,8 +152,8 @@ class VisualizerWidget(QWidget):
|
||||
|
||||
def _feed(self, mono, rate):
|
||||
self._sample_rate = rate
|
||||
if len(mono) >= FFT_SIZE:
|
||||
self._samples = mono[-FFT_SIZE:].copy()
|
||||
if len(mono) >= HISTORY:
|
||||
self._samples = mono[-HISTORY:].copy()
|
||||
else:
|
||||
self._samples = np.roll(self._samples, -len(mono))
|
||||
self._samples[-len(mono):] = mono
|
||||
@@ -264,11 +256,7 @@ class VisualizerWidget(QWidget):
|
||||
# ---- frame computation ----
|
||||
|
||||
def _advance_frame(self):
|
||||
spectrum = np.abs(np.fft.rfft(self._samples * self._window))
|
||||
# Normalize: full-scale sine -> ~1.0
|
||||
spectrum /= (FFT_SIZE / 4)
|
||||
freqs = np.fft.rfftfreq(FFT_SIZE, d=1.0 / self._sample_rate)
|
||||
frame = Frame(spectrum, freqs, self._sample_rate,
|
||||
frame = Frame(self._samples, self._sample_rate,
|
||||
self._take_pending(), FRAME_MS / 1000)
|
||||
if self._renderer.advance(frame):
|
||||
self._repaint()
|
||||
@@ -281,7 +269,7 @@ class VisualizerWidget(QWidget):
|
||||
return QColor(self.palette().highlight().color())
|
||||
|
||||
def _gray(self):
|
||||
"""Dim mode's color — and the radial's fading ghosts in any mode."""
|
||||
"""Dim mode's color."""
|
||||
if self._prefs is not None:
|
||||
saved = self._prefs.get("color_visualizer_gray")
|
||||
if saved is not None:
|
||||
@@ -302,7 +290,7 @@ class VisualizerWidget(QWidget):
|
||||
regardless (in color), since opening it is asking to see it."""
|
||||
if self._no_pcm or (self._mode == MODE_OFF and not big):
|
||||
return
|
||||
self._renderer.paint(painter, rect, self._bar_color(), self._gray())
|
||||
self._renderer.paint(painter, rect, self._bar_color())
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
|
||||
+291
-108
@@ -10,6 +10,10 @@ audio intake, the timer, on/dim/off, the color. Pure numpy + QPainter.
|
||||
frequency, radius is volume, and a dot that moves leaves fading ghosts.
|
||||
- ``LaserRenderer`` — a laser bounced off a mirror glued to a membrane
|
||||
stretched over a speaker, projected onto a screen (Tame Impala's rig).
|
||||
|
||||
Every tunable lives in preferences.json under a ``visualizer_*`` key;
|
||||
``settings(prefs)`` reads them clamped to ``LIMITS``, and a renderer takes
|
||||
the result through ``configure`` — live, without losing its state.
|
||||
"""
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
@@ -19,6 +23,8 @@ import numpy as np
|
||||
from PyQt6.QtCore import Qt, QPointF, QLineF
|
||||
from PyQt6.QtGui import QColor, QPen, QPolygonF
|
||||
|
||||
from lintunes.preferences import DEFAULTS
|
||||
|
||||
|
||||
STYLE_BARS = "bars"
|
||||
STYLE_RADIAL = "radial"
|
||||
@@ -26,23 +32,73 @@ STYLE_LASER = "laser"
|
||||
STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER)
|
||||
|
||||
BANDS = 20
|
||||
STEPS = 20 # vertical granularity per bar (and rings per radial spoke)
|
||||
STEPS = 20 # vertical granularity per bar
|
||||
DECAY = 0.82 # slow decay; attack is instant
|
||||
FLOOR_DB = -55.0
|
||||
LOW_HZ = 40.0
|
||||
INSET = 3 # the bar field sits 3px in from every edge
|
||||
INSET = 3 # the drawing sits 3px in from every edge
|
||||
BARS_FFT = 2048
|
||||
RADIAL_FFT = 4096 # 10.8 Hz bins: the bass degrees need the resolution
|
||||
HISTORY = RADIAL_FFT # samples the widget keeps for the FFTs
|
||||
|
||||
# (min, max) for every numeric visualizer setting. The Preferences sliders
|
||||
# use the same numbers, so a hand-edited file can't go past what they offer.
|
||||
LIMITS = {
|
||||
"visualizer_ghost_fade": (0.1, 3.0), # seconds
|
||||
"visualizer_radial_spokes": (12, 360),
|
||||
"visualizer_radial_levels": (4, 40),
|
||||
"visualizer_laser_distance": (1.0, 4.0), # metres, mirror → screen
|
||||
"visualizer_laser_pitch": (-60, 60), # degrees
|
||||
"visualizer_laser_yaw": (-60, 60),
|
||||
"visualizer_laser_roll": (-180, 180),
|
||||
}
|
||||
_FLAGS = ("visualizer_radial_fade", "visualizer_radial_normalize",
|
||||
"visualizer_laser_fade")
|
||||
_INTEGER = ("visualizer_radial_spokes", "visualizer_radial_levels")
|
||||
|
||||
|
||||
def settings(prefs) -> dict:
|
||||
"""Every visualizer setting, defaulted and clamped."""
|
||||
out = {}
|
||||
for key, (lo, hi) in LIMITS.items():
|
||||
value = prefs.get(key, DEFAULTS[key]) if prefs is not None else None
|
||||
try:
|
||||
value = min(hi, max(lo, float(value)))
|
||||
except (TypeError, ValueError):
|
||||
value = DEFAULTS[key]
|
||||
out[key] = int(round(value)) if key in _INTEGER else value
|
||||
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)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
"""One timer tick's worth of audio, handed to ``advance``."""
|
||||
spectrum: np.ndarray # |rfft| of the newest window, full-scale sine ~1.0
|
||||
freqs: np.ndarray # the frequency of each spectrum bin
|
||||
samples: np.ndarray # the newest HISTORY mono samples
|
||||
rate: int # sample rate
|
||||
pending: np.ndarray # every mono sample that arrived since last tick
|
||||
dt: float # seconds since the last tick
|
||||
|
||||
|
||||
_WINDOWS = {}
|
||||
|
||||
|
||||
def spectrum_of(samples: np.ndarray, size: int, rate: float):
|
||||
"""|rfft| of the newest ``size`` samples (full-scale sine ≈ 1.0), and
|
||||
the frequency of each bin."""
|
||||
window = _WINDOWS.get(size)
|
||||
if window is None:
|
||||
window = _WINDOWS[size] = np.hanning(size).astype(np.float32)
|
||||
segment = samples[-size:]
|
||||
if len(segment) < size:
|
||||
segment = np.concatenate([np.zeros(size - len(segment), np.float32),
|
||||
segment])
|
||||
spectrum = np.abs(np.fft.rfft(segment * window)) / (size / 4)
|
||||
return spectrum, np.fft.rfftfreq(size, d=1.0 / rate)
|
||||
|
||||
|
||||
def band_edges(count: int, rate: float) -> np.ndarray:
|
||||
"""``count`` log-spaced bands from 40 Hz to 16 kHz (or just under
|
||||
Nyquist): log because that's how pitch is heard — linear would spend
|
||||
@@ -51,26 +107,33 @@ def band_edges(count: int, rate: float) -> np.ndarray:
|
||||
return np.geomspace(LOW_HZ, max(top, 2 * LOW_HZ), count + 1)
|
||||
|
||||
|
||||
def band_levels(spectrum: np.ndarray, freqs: np.ndarray,
|
||||
edges: np.ndarray) -> np.ndarray:
|
||||
"""Each band's loudest bin as a 0..1 level (0 = FLOOR_DB or quieter).
|
||||
def band_db(spectrum: np.ndarray, freqs: np.ndarray,
|
||||
edges: np.ndarray) -> np.ndarray:
|
||||
"""Each band's loudest bin, in dB.
|
||||
|
||||
A band too narrow to hold a bin of its own (the bottom degrees of the
|
||||
radial, at 21 Hz a bin) takes the bin nearest its center rather than
|
||||
reading as silence."""
|
||||
A band narrower than a bin (the bass end of a many-spoked radial) holds
|
||||
no bin of its own. Copying the nearest bin into it gave runs of degrees
|
||||
the *same* value — flat arcs where there should be dots — so it reads the
|
||||
spectrum interpolated at its center instead."""
|
||||
count = len(edges) - 1
|
||||
starts = np.searchsorted(freqs, edges).clip(0, len(spectrum) - 1)
|
||||
magnitude = np.maximum.reduceat(spectrum, starts)[:count]
|
||||
empty = starts[1:] <= starts[:-1]
|
||||
if empty.any():
|
||||
centers = np.sqrt(edges[:-1] * edges[1:])[empty]
|
||||
step = freqs[1] - freqs[0] if len(freqs) > 1 else 1.0
|
||||
nearest = np.rint(centers / step).astype(int).clip(0, len(spectrum) - 1)
|
||||
magnitude[empty] = spectrum[nearest]
|
||||
db = 20.0 * np.log10(magnitude + 1e-10)
|
||||
magnitude[empty] = np.interp(centers, freqs, spectrum)
|
||||
return 20.0 * np.log10(magnitude + 1e-10)
|
||||
|
||||
|
||||
def to_level(db: np.ndarray) -> np.ndarray:
|
||||
"""dB → 0..1 (0 = FLOOR_DB or quieter)."""
|
||||
return np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0)
|
||||
|
||||
|
||||
def band_levels(spectrum, freqs, edges) -> np.ndarray:
|
||||
return to_level(band_db(spectrum, freqs, edges))
|
||||
|
||||
|
||||
def _faded(color: QColor, alpha: float) -> QColor:
|
||||
faded = QColor(color)
|
||||
faded.setAlphaF(max(0.0, min(1.0, alpha)) * color.alphaF())
|
||||
@@ -84,17 +147,20 @@ def _faded(color: QColor, alpha: float) -> QColor:
|
||||
class BarsRenderer:
|
||||
name = STYLE_BARS
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, config=None):
|
||||
self.levels = np.zeros(BANDS)
|
||||
self.bars = [0] * BANDS # quantized 0..STEPS
|
||||
|
||||
def configure(self, config):
|
||||
pass
|
||||
|
||||
def clear(self):
|
||||
self.levels[:] = 0.0
|
||||
self.bars = [0] * BANDS
|
||||
|
||||
def advance(self, frame: Frame) -> bool:
|
||||
new_levels = band_levels(frame.spectrum, frame.freqs,
|
||||
band_edges(BANDS, frame.rate))
|
||||
spectrum, freqs = spectrum_of(frame.samples, BARS_FFT, frame.rate)
|
||||
new_levels = band_levels(spectrum, freqs, band_edges(BANDS, frame.rate))
|
||||
# Instant attack, slow decay
|
||||
self.levels = np.maximum(new_levels, self.levels * DECAY)
|
||||
bars = [int(round(level * STEPS)) for level in self.levels]
|
||||
@@ -103,7 +169,7 @@ class BarsRenderer:
|
||||
self.bars = bars
|
||||
return True
|
||||
|
||||
def paint(self, painter, rect, color: QColor, light: QColor = None):
|
||||
def paint(self, painter, rect, color: QColor):
|
||||
avail_w = rect.width() - 2 * INSET
|
||||
avail_h = rect.height() - 2 * INSET
|
||||
left = int(rect.x()) + INSET
|
||||
@@ -125,44 +191,56 @@ class BarsRenderer:
|
||||
# Radial dots
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
RADIAL_BANDS = 360 # one per degree; painting pools them to fit
|
||||
DOT_PITCH = 3.0 # px between neighbouring dots on the rim
|
||||
DOT_PITCH = 3.0 # px between neighbouring dots on the rim, at least
|
||||
HUB = 0.22 # the silent middle, as a share of the radius
|
||||
GHOST_ALPHA = 1.0 # a fresh ghost is the full light gray, then fades
|
||||
GHOST_ALPHA = 0.7 # a fresh ghost: most of the way to the dot's color
|
||||
GHOST_BUCKETS = 8 # one drawPoints() per bucket of similar alpha
|
||||
_DIVISORS = [d for d in range(1, RADIAL_BANDS + 1) if RADIAL_BANDS % d == 0]
|
||||
|
||||
|
||||
def spoke_count(radius: float) -> int:
|
||||
"""The most spokes (a divisor of 360) that still leave DOT_PITCH px
|
||||
between dots around the rim — ~45 in the transport panel, all 360 in
|
||||
the big window."""
|
||||
fits = 2 * math.pi * radius / DOT_PITCH
|
||||
return max([d for d in _DIVISORS if d <= fits] or [1])
|
||||
NORMALIZE_S = 4.0 # how long the bass/treble balance takes to settle
|
||||
MAX_EVEN_DB = 24.0 # the most evening out may move a band
|
||||
|
||||
|
||||
class RadialRenderer:
|
||||
"""The graphic EQ as a polar dot plot.
|
||||
|
||||
Frequency is the angle — 360 log-spaced bands, bass at 12 o'clock
|
||||
running clockwise — and volume is the radius, quantized to STEPS rings
|
||||
so the dots line up like the bars' steps. A dot that moves leaves a
|
||||
ghost on every ring it passed; a ghost fades over ``fade`` seconds.
|
||||
Frequency is the angle — ``spokes`` log-spaced bands, bass at 12 o'clock
|
||||
running clockwise — and volume is the radius, quantized to ``levels``
|
||||
rings so the dots line up like the bars' steps. A dot that moves leaves
|
||||
a ghost that fades from its color into the background over ``fade`` s.
|
||||
|
||||
Ghosts are kept as a birth time per (degree, ring) rather than a list:
|
||||
a revisit simply refreshes it, so a spoke can never hold more than
|
||||
STEPS of them, and pooling 360 degrees down to fewer spokes is one
|
||||
reshape + max.
|
||||
Ghosts are kept as a birth time per (spoke, ring) rather than a list:
|
||||
a revisit simply refreshes it, so a spoke can never hold more ghosts
|
||||
than it has rings.
|
||||
|
||||
**Even out bass and treble**: music is loudest in the bass and falls
|
||||
away toward the treble, so the dots sit far out on one side of the
|
||||
circle and huddle in on the other. Normalizing tracks each band's
|
||||
long-run level, fits a straight tilt through the loud ones (dB against
|
||||
log-frequency — the *overall* balance, never a single band's peak), and
|
||||
takes that tilt out, so on average the dots trace a circle and what moves
|
||||
them is the music's change, not its spectral balance.
|
||||
"""
|
||||
|
||||
name = STYLE_RADIAL
|
||||
|
||||
def __init__(self, fade: float = 1.0):
|
||||
self.fade = fade
|
||||
self.levels = np.zeros(RADIAL_BANDS)
|
||||
self.rings = np.zeros(RADIAL_BANDS, dtype=int) # 0 = silent
|
||||
self.ghosts = np.full((RADIAL_BANDS, STEPS + 1), -np.inf)
|
||||
self.clock = 0.0
|
||||
def __init__(self, config=None):
|
||||
self.spokes = self.ring_count = None
|
||||
self.configure(config or settings(None))
|
||||
|
||||
def configure(self, config):
|
||||
self.fade = (config["visualizer_ghost_fade"]
|
||||
if config["visualizer_radial_fade"] else 0.0)
|
||||
self.normalize = config["visualizer_radial_normalize"]
|
||||
spokes = config["visualizer_radial_spokes"]
|
||||
levels = config["visualizer_radial_levels"]
|
||||
if (spokes, levels) != (self.spokes, self.ring_count):
|
||||
self.spokes, self.ring_count = spokes, levels
|
||||
self.levels = np.zeros(spokes)
|
||||
self.rings = np.zeros(spokes, dtype=int) # 0 = silent
|
||||
self.ghosts = np.full((spokes, levels + 1), -np.inf)
|
||||
self.clock = 0.0
|
||||
self._average = None
|
||||
if self.fade <= 0:
|
||||
self.ghosts[:] = -np.inf
|
||||
|
||||
def clear(self):
|
||||
self.levels[:] = 0.0
|
||||
@@ -170,18 +248,49 @@ class RadialRenderer:
|
||||
self.ghosts[:] = -np.inf
|
||||
|
||||
def ghost_alpha(self) -> np.ndarray:
|
||||
"""(degree, ring) → 0..1 strength of the ghost there."""
|
||||
"""(spoke, ring) → 0..1 strength of the ghost there."""
|
||||
if self.fade <= 0:
|
||||
return np.zeros_like(self.ghosts)
|
||||
age = self.clock - self.ghosts
|
||||
return np.clip(1.0 - age / self.fade, 0.0, 1.0)
|
||||
|
||||
def _evened(self, db, edges, dt):
|
||||
if not self.normalize:
|
||||
return db
|
||||
floor = np.maximum(db, FLOOR_DB - 20)
|
||||
if db.max() > FLOOR_DB: # silence teaches it nothing
|
||||
if self._average is None or len(self._average) != len(db):
|
||||
self._average = floor.copy()
|
||||
else:
|
||||
self._average += (floor - self._average) * min(1.0, dt / NORMALIZE_S)
|
||||
if self._average is None:
|
||||
return db
|
||||
x = np.log(np.sqrt(edges[:-1] * edges[1:]))
|
||||
# Weighted toward the bands that are actually sounding: the gaps
|
||||
# between notes sit near the floor at every pitch and would flatten
|
||||
# the fit, leaving the tilt the dots show. A straight line, because
|
||||
# a curve fitted through a few loud notes swings wildly where there
|
||||
# are none.
|
||||
active = to_level(self._average)
|
||||
weight = active ** 2 + 1e-3
|
||||
trend = np.polyval(np.polyfit(x, self._average, 1, w=weight), x)
|
||||
lift = np.clip(np.average(trend, weights=weight) - trend,
|
||||
-MAX_EVEN_DB, MAX_EVEN_DB)
|
||||
# Never raise a band that hasn't been sounding: that would draw dots
|
||||
# for frequencies the song doesn't have.
|
||||
# Quiet is fine — a soft cymbal is still there — so the gate is
|
||||
# "above the noise", not "loud".
|
||||
sounding = np.clip((self._average - (FLOOR_DB - 15)) / 15, 0, 1)
|
||||
lift = np.where(lift > 0, lift * sounding, lift)
|
||||
return db + lift
|
||||
|
||||
def advance(self, frame: Frame) -> bool:
|
||||
self.clock += frame.dt
|
||||
new_levels = band_levels(frame.spectrum, frame.freqs,
|
||||
band_edges(RADIAL_BANDS, frame.rate))
|
||||
self.levels = np.maximum(new_levels, self.levels * DECAY)
|
||||
rings = np.rint(self.levels * STEPS).astype(int)
|
||||
spectrum, freqs = spectrum_of(frame.samples, RADIAL_FFT, frame.rate)
|
||||
edges = band_edges(self.spokes, frame.rate)
|
||||
db = self._evened(band_db(spectrum, freqs, edges), edges, frame.dt)
|
||||
self.levels = np.maximum(to_level(db), self.levels * DECAY)
|
||||
rings = np.rint(self.levels * self.ring_count).astype(int)
|
||||
moved = rings != self.rings
|
||||
if moved.any() and self.fade > 0:
|
||||
# A ghost where the dot was. Falling (the slow decay) also leaves
|
||||
@@ -191,41 +300,49 @@ class RadialRenderer:
|
||||
old, new = self.rings[moved], rings[moved]
|
||||
lo = np.where(new < old, new, old)[:, None]
|
||||
hi = old[:, None]
|
||||
k = np.arange(STEPS + 1)[None, :]
|
||||
k = np.arange(self.ring_count + 1)[None, :]
|
||||
trail = (k >= lo) & (k <= hi) & (k != new[:, None]) & (k > 0)
|
||||
block = self.ghosts[moved]
|
||||
block[trail] = self.clock
|
||||
self.ghosts[moved] = block
|
||||
self.rings = rings
|
||||
if self.fade <= 0:
|
||||
self.ghosts[:] = -np.inf
|
||||
else:
|
||||
if self.fade > 0:
|
||||
# Forget the long-faded, so "anything still fading?" is cheap.
|
||||
self.ghosts[self.clock - self.ghosts >= self.fade] = -np.inf
|
||||
return bool(moved.any() or np.isfinite(self.ghosts).any())
|
||||
|
||||
def pooled(self, spokes: int):
|
||||
"""The state at ``spokes`` spokes: each spoke's ring, and each
|
||||
(spoke, ring) ghost strength — the loudest of the degrees it covers."""
|
||||
group = RADIAL_BANDS // spokes
|
||||
rings = self.rings.reshape(spokes, group).max(axis=1)
|
||||
alpha = self.ghost_alpha().reshape(spokes, group, STEPS + 1).max(axis=1)
|
||||
alpha[np.arange(spokes), rings] = 0.0 # under the dot itself
|
||||
alpha[:, 0] = 0.0 # silence isn't drawn
|
||||
return rings, alpha
|
||||
def pooled(self, fits: int):
|
||||
"""The state at no more than ``fits`` spokes (what the circle has
|
||||
room for): each spoke's ring and (spoke, ring) ghost strength — the
|
||||
loudest of the bands it covers — plus each spoke's angle, 0..1."""
|
||||
rings, alpha = self.rings, self.ghost_alpha()
|
||||
starts = np.arange(self.spokes)
|
||||
if self.spokes > fits:
|
||||
starts = np.unique(np.linspace(0, self.spokes, max(1, fits) + 1)
|
||||
.astype(int)[:-1])
|
||||
rings = np.maximum.reduceat(rings, starts)
|
||||
alpha = np.maximum.reduceat(alpha, starts, axis=0)
|
||||
ends = np.append(starts[1:], self.spokes)
|
||||
angle = (starts + ends) / 2 / self.spokes
|
||||
alpha = alpha.copy()
|
||||
alpha[np.arange(len(rings)), rings] = 0.0 # under the dot itself
|
||||
alpha[:, 0] = 0.0 # silence isn't drawn
|
||||
return rings, alpha, angle
|
||||
|
||||
def paint(self, painter, rect, color: QColor, light: QColor):
|
||||
def paint(self, painter, rect, color: QColor):
|
||||
cx, cy = rect.center().x(), rect.center().y()
|
||||
radius = min(rect.width(), rect.height()) / 2 - INSET
|
||||
if radius <= 2:
|
||||
return
|
||||
spokes = spoke_count(radius)
|
||||
rings, alpha = self.pooled(spokes)
|
||||
fits = int(2 * math.pi * radius / DOT_PITCH)
|
||||
rings, alpha, angle = self.pooled(fits)
|
||||
spokes = len(rings)
|
||||
hub = radius * HUB
|
||||
ring_step = (radius - hub) / STEPS
|
||||
dot = max(1.8, min(2 * math.pi * radius / spokes, ring_step * 2.5) * 0.8)
|
||||
angles = 2 * math.pi * (np.arange(spokes) + 0.5) / spokes
|
||||
sin, cos = np.sin(angles), np.cos(angles)
|
||||
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)
|
||||
sin, cos = np.sin(2 * math.pi * angle), np.cos(2 * math.pi * angle)
|
||||
|
||||
def points(spoke_idx, ring_idx):
|
||||
r = hub + ring_idx * ring_step - dot / 2
|
||||
@@ -236,13 +353,14 @@ class RadialRenderer:
|
||||
pen = QPen()
|
||||
pen.setWidthF(dot)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
# Ghosts first, faintest first, so the newer ones sit on top.
|
||||
# Ghosts first, faintest first, so the newer ones sit on top. Each is
|
||||
# the dot's own color, fading into the background behind it.
|
||||
bucket = np.ceil(alpha * GHOST_BUCKETS).astype(int)
|
||||
for level in range(1, GHOST_BUCKETS + 1):
|
||||
spoke_idx, ring_idx = np.nonzero(bucket == level)
|
||||
if len(spoke_idx) == 0:
|
||||
continue
|
||||
pen.setColor(_faded(light, GHOST_ALPHA * level / GHOST_BUCKETS))
|
||||
pen.setColor(_faded(color, GHOST_ALPHA * level / GHOST_BUCKETS))
|
||||
painter.setPen(pen)
|
||||
painter.drawPoints(points(spoke_idx, ring_idx))
|
||||
live = np.nonzero(rings > 0)[0]
|
||||
@@ -256,8 +374,8 @@ class RadialRenderer:
|
||||
# Laser
|
||||
# --------------------------------------------------------------------------
|
||||
#
|
||||
# The rig: a small mirror glued off-center on a rubber membrane stretched
|
||||
# over a speaker; a laser hits the mirror and the reflection lands on a big
|
||||
# The rig: a small mirror glued to a rubber membrane stretched over a
|
||||
# speaker; a laser hits the mirror and the reflection lands on a big
|
||||
# screen. What moves the beam is the *tilt* of the mirror, and a membrane
|
||||
# doesn't just pump in and out — it vibrates in modes, each with its own
|
||||
# resonance and its own shape. The mirror's tilt is the membrane's slope
|
||||
@@ -267,14 +385,20 @@ class RadialRenderer:
|
||||
#
|
||||
# The modes of a circular membrane are J_m(j_mn·r)·cos(mθ), resonating at
|
||||
# j_mn/j_01 times the fundamental. The m > 0 modes come in pairs at right
|
||||
# angles that a perfect drum tunes identically; a hand-stretched one never
|
||||
# is, so they split by a percent or two, the two axes drift in and out of
|
||||
# phase on a held note, and that is where the slowly turning ellipses and
|
||||
# Lissajous loops come from. A perfectly even push wouldn't excite m > 0
|
||||
# modes at all; the mirror's own mass, off-center, is what couples them.
|
||||
# angles that a perfect drum tunes identically. A hand-stretched membrane
|
||||
# never is: the pair splits, and between the two resonances they answer the
|
||||
# same note up to 90° apart — the beam swings along one axis a quarter
|
||||
# cycle behind the other, which is a circle. A mirror near the middle sits
|
||||
# where that rocking pair dominates (the (0,1) "breathing" mode has no
|
||||
# slope at the center, so it only draws lines), which is why the rig's
|
||||
# figures are loops rather than strokes. A perfectly even push wouldn't
|
||||
# excite the m > 0 modes at all; the mirror's own mass, off-center, is
|
||||
# what couples them.
|
||||
#
|
||||
# Reflection doubles the angle: tilt θ turns the beam 2θ, so the spot lands
|
||||
# at D·tan(2θ) — a faint pincushion stretch at big swings, kept in. The
|
||||
# Reflection doubles the angle: tilt θ turns the beam 2θ, and the spot is
|
||||
# where that ray meets the screen. A screen square to the beam puts it at
|
||||
# D·tan(2θ); a screen turned away (pitch, yaw) stretches the figure along
|
||||
# the turn and spreads its far side wider (keystone); roll spins it. The
|
||||
# spot moves far faster than an eye or a camera follows, so what anyone
|
||||
# sees is the whole path traced in one glance, brightest where the beam
|
||||
# moves slowest (it dwells there), with the haze and the screen's scatter
|
||||
@@ -283,17 +407,27 @@ class RadialRenderer:
|
||||
MODE_ZEROS = ( # (m, j_mn) — Bessel zeros
|
||||
(0, 2.40483), (1, 3.83171), (2, 5.13562), (0, 5.52008), (3, 6.38016),
|
||||
)
|
||||
FUNDAMENTAL_HZ = 90.0 # a drum-sized membrane: kick-drum territory
|
||||
MODE_Q = 8.0 # rubber is lossy
|
||||
SPLIT = (0.0, 0.016, 0.011, 0.0, 0.008) # the uneven stretch, per mode pair
|
||||
MIRROR_AT = (0.55, 0.6) # the mirror's spot: radius (of 1), angle (rad)
|
||||
# A big, slack balloon: its rocking pairs land in the bass, where the
|
||||
# notes are, rather than above them — measured over random bass lines, that
|
||||
# is loops ~60% of the time against ~25% for a tight 90 Hz drum head.
|
||||
FUNDAMENTAL_HZ = 50.0
|
||||
MODE_Q = 4.5 # rubber is lossy, which widens where loops happen
|
||||
# The uneven stretch, per mode: wider than a resonance, as a hand-stretched
|
||||
# balloon is, and what makes loops.
|
||||
SPLIT = (0.0, 0.30, 0.24, 0.0, 0.18)
|
||||
MIRROR_AT = (0.15, 0.6) # the mirror's spot: radius (of 1), angle (rad)
|
||||
SIM_RATE = 11025.0 # the membrane barely answers above a few hundred Hz
|
||||
BEAM_MAX = math.radians(30) # the screen's half-height, as a beam angle
|
||||
FILL = 0.85 # auto-gain aims the loudest swings at this much of it
|
||||
SCREEN_HALF = 1.0 # the screen's half-height, metres
|
||||
FILL = 0.85 # at the farthest distance, the loudest swings fill this
|
||||
# The auto-gain holds the beam's loudest swing at this angle, so distance
|
||||
# is real: the figure grows as the screen moves back, filling it at the
|
||||
# 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.02 # silence stays a still dot, not amplified hiss
|
||||
PEAK_FLOOR = 0.0005 # ~22 dB over -60 dB hiss: a still dot, not dancing noise
|
||||
PERSIST = (1.0, 0.45, 0.2) # this frame's path, then fading older ones
|
||||
CHUNK = 256 # samples per vectorized resonator step
|
||||
ROOM = 0.95 # the figure never comes closer to the edge than this
|
||||
|
||||
|
||||
def _bessel_j(m: int, x: float) -> float:
|
||||
@@ -336,17 +470,44 @@ def _membrane_modes():
|
||||
MODES = _membrane_modes()
|
||||
|
||||
|
||||
def _rotation(pitch, yaw, roll) -> np.ndarray:
|
||||
"""The screen's orientation: columns are its right, up and normal."""
|
||||
p, y, r = (math.radians(a) for a in (pitch, yaw, roll))
|
||||
rx = np.array([[1, 0, 0], [0, math.cos(p), -math.sin(p)],
|
||||
[0, math.sin(p), math.cos(p)]])
|
||||
ry = np.array([[math.cos(y), 0, math.sin(y)], [0, 1, 0],
|
||||
[-math.sin(y), 0, math.cos(y)]])
|
||||
rz = np.array([[math.cos(r), -math.sin(r), 0],
|
||||
[math.sin(r), math.cos(r), 0], [0, 0, 1]])
|
||||
return ry @ rx @ rz
|
||||
|
||||
|
||||
class LaserRenderer:
|
||||
name = STYLE_LASER
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, config=None):
|
||||
self.levels = np.zeros(1) # the widget's shared clear() touches it
|
||||
self._rate = None
|
||||
self._carry = np.zeros(0, dtype=np.float64)
|
||||
self._peak = PEAK_FLOOR
|
||||
self.paths = [] # newest first: (n, 2) arrays in -1..1 screen units
|
||||
self.paths = [] # newest first: (n, 2) arrays, metres on the screen
|
||||
self.configure(config or settings(None))
|
||||
self.clear()
|
||||
|
||||
def configure(self, config):
|
||||
self.persist = PERSIST if config["visualizer_laser_fade"] else PERSIST[:1]
|
||||
self.paths = self.paths[:len(self.persist)]
|
||||
self.distance = config["visualizer_laser_distance"]
|
||||
rot = _rotation(config["visualizer_laser_pitch"],
|
||||
config["visualizer_laser_yaw"],
|
||||
config["visualizer_laser_roll"])
|
||||
self._right, self._up, self._normal = rot[:, 0], rot[:, 1], rot[:, 2]
|
||||
# How far the loudest possible swing reaches on this screen, each
|
||||
# way: what painting scales by so it always stays in view.
|
||||
ring = np.linspace(0, 2 * math.pi, 96, endpoint=False)
|
||||
edge = np.column_stack([np.cos(ring), np.sin(ring)]) * BEAM_TARGET
|
||||
spots = self.on_screen(edge)
|
||||
self.extent = (max(np.abs(spots[:, 0]).max(), 1e-6),
|
||||
max(np.abs(spots[:, 1]).max(), 1e-6))
|
||||
|
||||
def clear(self):
|
||||
self._state = np.zeros(len(MODES), dtype=np.complex128)
|
||||
self._carry = np.zeros(0, dtype=np.float64)
|
||||
@@ -400,16 +561,27 @@ class LaserRenderer:
|
||||
|
||||
# ---- the screen ----
|
||||
|
||||
def on_screen(self, beam: np.ndarray) -> np.ndarray:
|
||||
"""Beam angles (n, 2) from the rest direction → where the ray meets
|
||||
the screen, metres from its center along its own right and up."""
|
||||
ray = np.column_stack([np.tan(beam[:, 0]), np.tan(beam[:, 1]),
|
||||
np.ones(len(beam))])
|
||||
center = np.array([0.0, 0.0, self.distance])
|
||||
# ray·t meets the plane through `center` with this normal at
|
||||
# t = (n·c)/(n·ray). The angle limits keep n·ray well above 0.
|
||||
t = (self._normal @ center) / np.maximum(ray @ self._normal, 1e-3)
|
||||
offset = ray * t[:, None] - center
|
||||
return np.column_stack([offset @ self._right, offset @ self._up])
|
||||
|
||||
def project(self, tilt: np.ndarray, dt: float) -> np.ndarray:
|
||||
"""Tilt → spot on the screen, -1..1 each way (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
|
||||
self._peak = max(peak, self._peak * math.exp(-dt / GAIN_RELEASE_S),
|
||||
PEAK_FLOOR)
|
||||
# Pick the gain that puts the loudest swing at FILL of the screen…
|
||||
gain = math.atan(FILL * math.tan(BEAM_MAX)) / (2 * self._peak)
|
||||
# …then reflection doubles the mirror's angle, and a flat screen
|
||||
# spreads big angles out: D·tan(2θ).
|
||||
return np.tan(2 * gain * tilt) / math.tan(BEAM_MAX)
|
||||
# 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)
|
||||
return self.on_screen(2 * gain * tilt)
|
||||
|
||||
def advance(self, frame: Frame) -> bool:
|
||||
tilt = self.drive(frame.pending, frame.rate)
|
||||
@@ -419,35 +591,46 @@ class LaserRenderer:
|
||||
# Join this frame's path onto the last one's end, so the line is
|
||||
# unbroken where two frames meet.
|
||||
path = np.vstack([self.paths[0][-1:], path])
|
||||
self.paths = [path] + self.paths[:len(PERSIST) - 1]
|
||||
self.paths = [path] + self.paths[:len(self.persist) - 1]
|
||||
return True
|
||||
|
||||
def spot(self) -> np.ndarray:
|
||||
return self.paths[0][-1]
|
||||
|
||||
def paint(self, painter, rect, color: QColor, light: QColor = None):
|
||||
def scale(self, rect) -> float:
|
||||
"""Pixels per metre: the screen's half-height fills the rect's, and
|
||||
shrinks only if the loudest swing would leave the rect (a steeply
|
||||
turned screen) — so the figure is always on screen."""
|
||||
half_w = rect.width() / 2 - INSET
|
||||
half_h = rect.height() / 2 - INSET
|
||||
unit = min(half_w, half_h) / SCREEN_HALF
|
||||
reach_x, reach_y = self.extent
|
||||
return unit * min(1.0, ROOM * half_w / (reach_x * unit),
|
||||
ROOM * half_h / (reach_y * unit))
|
||||
|
||||
def paint(self, painter, rect, color: QColor):
|
||||
cx, cy = rect.center().x(), rect.center().y()
|
||||
half = min(rect.width(), rect.height()) / 2 - INSET
|
||||
if half <= 2:
|
||||
return
|
||||
px = self.scale(rect)
|
||||
s = max(1.0, half / 90) # stroke scale: the big window glows wider
|
||||
pen = QPen()
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
# Oldest first, so the newest trace sits on top.
|
||||
for path, persist in reversed(list(zip(self.paths, PERSIST))):
|
||||
for path, persist in reversed(list(zip(self.paths, self.persist))):
|
||||
if len(path) < 2:
|
||||
continue
|
||||
# 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)))
|
||||
path = np.vstack([path[::step], path[-1:]])
|
||||
xy = np.column_stack([cx + path[:, 0] * half,
|
||||
cy - path[:, 1] * half])
|
||||
xy = np.column_stack([cx + path[:, 0] * px, cy - path[:, 1] * px])
|
||||
# The haze and the screen's scatter: one even bloom along the
|
||||
# whole path, a single polyline so joints don't stack up alpha.
|
||||
# Only around the newest trace — the older ones are afterimage.
|
||||
if persist == PERSIST[0]:
|
||||
if persist == self.persist[0]:
|
||||
line = QPolygonF([QPointF(x, y) for x, y in xy])
|
||||
for width, strength in ((7 * s, 0.07), (3 * s, 0.18)):
|
||||
pen.setWidthF(width)
|
||||
@@ -470,7 +653,7 @@ class LaserRenderer:
|
||||
painter.drawLines([QLineF(*xy[i], *xy[i + 1]) for i in idx])
|
||||
# The beam itself, wherever it is right now.
|
||||
x, y = self.spot()
|
||||
head = QPointF(cx + x * half, cy - y * half)
|
||||
head = QPointF(cx + x * px, cy - y * px)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(_faded(color, 0.25))
|
||||
painter.drawEllipse(head, 3.5 * s, 3.5 * s)
|
||||
@@ -478,9 +661,9 @@ class LaserRenderer:
|
||||
painter.drawEllipse(head, 1.4 * s, 1.4 * s)
|
||||
|
||||
|
||||
def make_renderer(style: str, fade: float = 1.0):
|
||||
def make_renderer(style: str, config=None):
|
||||
if style == STYLE_RADIAL:
|
||||
return RadialRenderer(fade)
|
||||
return RadialRenderer(config)
|
||||
if style == STYLE_LASER:
|
||||
return LaserRenderer()
|
||||
return BarsRenderer()
|
||||
return LaserRenderer(config)
|
||||
return BarsRenderer(config)
|
||||
|
||||
@@ -13,7 +13,17 @@ DEFAULTS = {
|
||||
"volume": 0.8, # logical 0..1 master output volume
|
||||
"visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle
|
||||
"visualizer_style": "bars", # bars | radial | laser (visualizer_styles)
|
||||
# The styles' tunables — ranges in visualizer_styles.LIMITS.
|
||||
"visualizer_ghost_fade": 1.0, # seconds a radial dot's trail takes to fade
|
||||
"visualizer_radial_fade": True, # radial dots leave fading trails
|
||||
"visualizer_radial_spokes": 120, # frequency bands around the circle
|
||||
"visualizer_radial_levels": 20, # volume steps along each spoke
|
||||
"visualizer_radial_normalize": True, # even out bass vs treble
|
||||
"visualizer_laser_fade": True, # afterimage of the last few frames
|
||||
"visualizer_laser_distance": 3.5, # metres, mirror → screen center
|
||||
"visualizer_laser_pitch": 0, # degrees the screen is turned
|
||||
"visualizer_laser_yaw": 0,
|
||||
"visualizer_laser_roll": 0,
|
||||
# Per-area grayscale overrides (0=black .. 255=white). None = inherit the
|
||||
# current/default look for that surface, so nothing changes until the user
|
||||
# drags a slider in Preferences.
|
||||
|
||||
+208
-69
@@ -40,18 +40,26 @@ def _sine(hz, seconds=0.1, amp=0.5):
|
||||
|
||||
|
||||
def _frame(window, pending=None, dt=0.016):
|
||||
spectrum = np.abs(np.fft.rfft(window * np.hanning(len(window)))) / (len(window) / 4)
|
||||
freqs = np.fft.rfftfreq(len(window), d=1.0 / RATE)
|
||||
if pending is None:
|
||||
pending = np.zeros(0, np.float32)
|
||||
return vs.Frame(spectrum, freqs, RATE, pending, dt)
|
||||
return vs.Frame(np.asarray(window, np.float32), RATE, pending, dt)
|
||||
|
||||
|
||||
def _config(**overrides):
|
||||
config = vs.settings(None)
|
||||
config.update({f"visualizer_{k}": v for k, v in overrides.items()})
|
||||
return config
|
||||
|
||||
|
||||
def _tone(hz, amp=0.5):
|
||||
return _sine(hz, vs.HISTORY / RATE, amp)
|
||||
|
||||
|
||||
def _paint(renderer, w=140, h=62):
|
||||
img = QImage(w, h, QImage.Format.Format_ARGB32)
|
||||
img.fill(QColor("white"))
|
||||
painter = QPainter(img)
|
||||
renderer.paint(painter, QRectF(0, 0, w, h), QColor("blue"), QColor("gray"))
|
||||
renderer.paint(painter, QRectF(0, 0, w, h), QColor("blue"))
|
||||
painter.end()
|
||||
return img
|
||||
|
||||
@@ -93,10 +101,18 @@ class TestStylePreference:
|
||||
assert vis._renderer is before # state survives
|
||||
assert before.fade == 2.5
|
||||
|
||||
def test_settings_are_clamped(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set("visualizer_radial_spokes", 100000) # hand-edited
|
||||
prefs.set("visualizer_laser_pitch", "sideways")
|
||||
config = vs.settings(prefs)
|
||||
assert config["visualizer_radial_spokes"] == 360
|
||||
assert config["visualizer_laser_pitch"] == 0
|
||||
|
||||
def test_every_style_paints_without_error(self, qapp):
|
||||
for style in vs.STYLES:
|
||||
renderer = vs.make_renderer(style)
|
||||
window = _sine(1000, 2048 / RATE)
|
||||
window = _tone(1000)
|
||||
for _ in range(5):
|
||||
renderer.advance(_frame(window, _sine(90, 0.016)))
|
||||
for size in ((140, 62), (1200, 750), (4, 4)):
|
||||
@@ -107,21 +123,37 @@ class TestStylePreference:
|
||||
# 2. Shared analysis
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _spectrum(window, size):
|
||||
return vs.spectrum_of(np.asarray(window, np.float32), size, RATE)
|
||||
|
||||
|
||||
class TestBandLevels:
|
||||
def test_a_sine_lights_its_own_band(self):
|
||||
frame = _frame(_sine(1000, 2048 / RATE))
|
||||
spectrum, freqs = _spectrum(_tone(1000), vs.BARS_FFT)
|
||||
edges = vs.band_edges(vs.BANDS, RATE)
|
||||
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
|
||||
band = np.searchsorted(edges, 1000) - 1
|
||||
assert levels.argmax() == band
|
||||
levels = vs.band_levels(spectrum, freqs, edges)
|
||||
assert levels.argmax() == np.searchsorted(edges, 1000) - 1
|
||||
|
||||
def test_bars_read_only_the_newest_2048_samples(self):
|
||||
# The history grew to 4096 for the radial; the bars must not see it.
|
||||
old = np.concatenate([_sine(3000, 2048 / RATE), _sine(1000, 2048 / RATE)])
|
||||
spectrum, freqs = _spectrum(old, vs.BARS_FFT)
|
||||
assert abs(freqs[spectrum.argmax()] - 1000) < 30
|
||||
|
||||
def test_narrow_bass_bands_are_not_silent(self):
|
||||
# At 360 bands the bottom ones are narrower than an FFT bin.
|
||||
frame = _frame(_sine(45, 2048 / RATE))
|
||||
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
|
||||
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
|
||||
spectrum, freqs = _spectrum(_tone(45), vs.RADIAL_FFT)
|
||||
edges = vs.band_edges(360, RATE)
|
||||
levels = vs.band_levels(spectrum, freqs, edges)
|
||||
assert levels[:5].min() > 0.5
|
||||
|
||||
def test_narrow_bands_are_not_flat_copies(self):
|
||||
# The flat arcs: runs of bass degrees all reading one bin.
|
||||
spectrum, freqs = _spectrum(_tone(120) + _tone(300, 0.2), vs.RADIAL_FFT)
|
||||
db = vs.band_db(spectrum, freqs, vs.band_edges(360, RATE))
|
||||
bass = db[:120]
|
||||
runs = np.diff(np.flatnonzero(np.diff(bass) != 0))
|
||||
assert len(runs) == 0 or runs.max() < 3
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. Radial
|
||||
@@ -129,23 +161,30 @@ class TestBandLevels:
|
||||
|
||||
class TestRadial:
|
||||
def test_frequency_is_the_angle(self):
|
||||
r = vs.RadialRenderer()
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE)))
|
||||
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
|
||||
degree = np.searchsorted(edges, 1000) - 1
|
||||
assert abs(int(r.rings.argmax()) - degree) <= 2
|
||||
r = vs.RadialRenderer(_config(radial_normalize=False))
|
||||
r.advance(_frame(_tone(1000)))
|
||||
edges = vs.band_edges(r.spokes, RATE)
|
||||
assert abs(int(r.rings.argmax()) - (np.searchsorted(edges, 1000) - 1)) <= 1
|
||||
|
||||
def test_volume_is_the_radius(self):
|
||||
loud, quiet = vs.RadialRenderer(), vs.RadialRenderer()
|
||||
loud.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
quiet.advance(_frame(_sine(1000, 2048 / RATE, amp=0.01)))
|
||||
config = _config(radial_normalize=False)
|
||||
loud, quiet = vs.RadialRenderer(config), vs.RadialRenderer(config)
|
||||
loud.advance(_frame(_tone(1000, amp=0.8)))
|
||||
quiet.advance(_frame(_tone(1000, amp=0.01)))
|
||||
assert loud.rings.max() > quiet.rings.max() > 0
|
||||
|
||||
def test_spokes_and_levels_are_settings(self):
|
||||
r = vs.RadialRenderer(_config(radial_spokes=36, radial_levels=8))
|
||||
r.advance(_frame(_tone(1000, amp=0.9)))
|
||||
assert len(r.rings) == 36 and r.rings.max() <= 8
|
||||
r.configure(_config(radial_spokes=200, radial_levels=30))
|
||||
assert len(r.rings) == 200 and r.ghosts.shape == (200, 31)
|
||||
|
||||
def test_a_falling_dot_leaves_ghosts_that_fade(self):
|
||||
r = vs.RadialRenderer(fade=0.5)
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
r = vs.RadialRenderer(_config(ghost_fade=0.5, radial_normalize=False))
|
||||
r.advance(_frame(_tone(1000, amp=0.8)))
|
||||
degree, top = int(r.rings.argmax()), int(r.rings.max())
|
||||
silence = _frame(np.zeros(2048, np.float32))
|
||||
silence = _frame(np.zeros(vs.HISTORY, np.float32))
|
||||
r.advance(silence)
|
||||
assert r.ghost_alpha()[degree, top] > 0.9 # where it was, fresh
|
||||
for _ in range(20): # 0.32 s
|
||||
@@ -156,51 +195,92 @@ class TestRadial:
|
||||
assert r.ghost_alpha().max() == 0
|
||||
assert not np.isfinite(r.ghosts).any() # forgotten, not just faint
|
||||
|
||||
def test_no_fade_means_no_ghosts(self):
|
||||
r = vs.RadialRenderer(fade=0)
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
r.advance(_frame(np.zeros(2048, np.float32)))
|
||||
def test_fading_off_means_no_ghosts(self):
|
||||
r = vs.RadialRenderer(_config(radial_fade=False))
|
||||
r.advance(_frame(_tone(1000, amp=0.8)))
|
||||
r.advance(_frame(np.zeros(vs.HISTORY, np.float32)))
|
||||
assert r.ghost_alpha().max() == 0
|
||||
|
||||
def test_ghosts_are_the_dot_color_fading(self, qapp):
|
||||
r = vs.RadialRenderer(_config(radial_spokes=12, radial_normalize=False))
|
||||
r.advance(_frame(_tone(1000, amp=0.8)))
|
||||
r.advance(_frame(np.zeros(vs.HISTORY, np.float32)))
|
||||
img = _paint(r, 400, 400)
|
||||
tinted = [QColor(img.pixel(x, y)) for x in range(0, 400, 2)
|
||||
for y in range(0, 400, 2)]
|
||||
tinted = [c for c in tinted if c != QColor("white")]
|
||||
# Every painted pixel is blue over white — never gray.
|
||||
assert tinted and all(c.blue() == 255 and c.red() == c.green()
|
||||
for c in tinted)
|
||||
|
||||
def test_a_rise_leaves_only_where_it_came_from(self):
|
||||
# An instant attack must not paint a bar of ghosts below the dot.
|
||||
r = vs.RadialRenderer(fade=1.0)
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.003)))
|
||||
r = vs.RadialRenderer(_config(ghost_fade=1.0, radial_normalize=False))
|
||||
r.advance(_frame(_tone(1000, amp=0.003)))
|
||||
before = r.rings.copy()
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
r.advance(_frame(_tone(1000, amp=0.8)))
|
||||
degree = int(np.argmax(r.rings - before))
|
||||
alive = np.nonzero(r.ghost_alpha()[degree] > 0)[0]
|
||||
assert list(alive) == [before[degree]]
|
||||
|
||||
def test_ghosts_per_spoke_are_bounded(self):
|
||||
r = vs.RadialRenderer(fade=3.0)
|
||||
r = vs.RadialRenderer(_config(ghost_fade=3.0))
|
||||
rng = np.random.default_rng(1)
|
||||
for _ in range(200):
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=rng.random())))
|
||||
rings, alpha = r.pooled(45)
|
||||
assert (alpha > 0).sum(axis=1).max() <= vs.STEPS
|
||||
r.advance(_frame(_tone(1000, amp=rng.random())))
|
||||
rings, alpha, _angle = r.pooled(45)
|
||||
assert len(rings) <= 45
|
||||
assert (alpha > 0).sum(axis=1).max() <= r.ring_count
|
||||
|
||||
def test_bigger_circles_get_more_spokes(self):
|
||||
assert vs.spoke_count(28) < vs.spoke_count(400) == 360
|
||||
assert 360 % vs.spoke_count(28) == 0
|
||||
def test_evening_out_lines_bass_up_with_treble(self):
|
||||
# Pink-ish: every octave 6 dB quieter than the one below.
|
||||
t = np.arange(vs.HISTORY) / RATE
|
||||
audio = sum(0.5 / 2 ** k * np.sin(2 * np.pi * 55 * 2 ** k * t)
|
||||
for k in range(8)).astype(np.float32)
|
||||
spread = {}
|
||||
for normalize in (False, True):
|
||||
r = vs.RadialRenderer(_config(radial_normalize=normalize,
|
||||
radial_spokes=360))
|
||||
for _ in range(300):
|
||||
r.advance(_frame(audio))
|
||||
edges = vs.band_edges(360, RATE)
|
||||
peaks = [r.levels[np.searchsorted(edges, 55 * 2 ** k) - 1]
|
||||
for k in range(8)]
|
||||
spread[normalize] = max(peaks) - min(peaks)
|
||||
assert spread[True] < spread[False] / 2
|
||||
|
||||
|
||||
def test_evening_out_never_draws_frequencies_the_song_lacks():
|
||||
# A lone tone: the fit has one loud point to go on. Nothing far from
|
||||
# it may be lifted into view.
|
||||
r = vs.RadialRenderer(_config(radial_normalize=True, radial_spokes=360))
|
||||
for _ in range(300):
|
||||
r.advance(_frame(_tone(180, 0.1)))
|
||||
edges = vs.band_edges(360, RATE)
|
||||
far = np.searchsorted(edges, 2000)
|
||||
assert r.rings[far:].max() == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Laser
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _run(renderer, audio, hop=706):
|
||||
for i in range(0, len(audio) - hop, hop):
|
||||
renderer.advance(_frame(np.zeros(vs.HISTORY, np.float32),
|
||||
audio[i:i + hop]))
|
||||
return renderer
|
||||
|
||||
|
||||
class TestLaser:
|
||||
def test_silence_is_a_still_dot_in_the_middle(self):
|
||||
r = vs.LaserRenderer()
|
||||
for _ in range(10):
|
||||
r.advance(_frame(np.zeros(2048, np.float32),
|
||||
np.zeros(706, np.float32)))
|
||||
r = _run(vs.LaserRenderer(), np.zeros(RATE // 4, np.float32))
|
||||
assert np.allclose(r.paths[0], 0.0)
|
||||
|
||||
def test_the_membrane_answers_its_resonance(self):
|
||||
f0 = vs.MODES[0][0]
|
||||
f = vs.MODES[1][0]
|
||||
on, off = vs.LaserRenderer(), vs.LaserRenderer()
|
||||
swing_on = np.abs(on.drive(_sine(f0, 0.5), RATE)).max()
|
||||
swing_on = np.abs(on.drive(_sine(f, 0.5), RATE)).max()
|
||||
swing_off = np.abs(off.drive(_sine(3000, 0.5), RATE)).max()
|
||||
assert swing_on > 20 * swing_off
|
||||
|
||||
@@ -212,31 +292,64 @@ class TestLaser:
|
||||
for chunk in np.array_split(audio, [101, 700, 702, 5000])]
|
||||
assert np.allclose(np.concatenate(parts), whole)
|
||||
|
||||
def test_degenerate_modes_turn_a_line_into_a_loop(self):
|
||||
# The split (1,1) pair: driven between its two resonances, the two
|
||||
# axes are out of phase — an ellipse, not a line.
|
||||
def test_the_split_pair_draws_loops(self):
|
||||
# Between the (1,1) pair's resonances the two axes answer a quarter
|
||||
# cycle apart: nearly a circle, not a line.
|
||||
f = (vs.MODES[1][0] + vs.MODES[2][0]) / 2
|
||||
tilt = vs.LaserRenderer().drive(_sine(f, 1.0), RATE)[-2000:]
|
||||
tilt = tilt - tilt.mean(axis=0)
|
||||
s = np.linalg.svd(tilt, compute_uv=False)
|
||||
assert s[1] / s[0] > 0.1
|
||||
assert s[1] / s[0] > 0.4
|
||||
|
||||
def test_the_spot_stays_on_the_screen(self):
|
||||
r = vs.LaserRenderer()
|
||||
def test_farther_screen_bigger_figure(self):
|
||||
audio = _sine(vs.MODES[1][0], 1.0)
|
||||
near = _run(vs.LaserRenderer(_config(laser_distance=1.0)), audio)
|
||||
far = _run(vs.LaserRenderer(_config(laser_distance=4.0)), audio)
|
||||
ratio = np.abs(far.paths[0]).max() / np.abs(near.paths[0]).max()
|
||||
assert 3.5 < ratio < 4.5
|
||||
|
||||
def test_pitch_stretches_vertically_and_roll_spins(self):
|
||||
audio = _sine((vs.MODES[1][0] + vs.MODES[2][0]) / 2, 1.0)
|
||||
|
||||
def spread(**angles):
|
||||
path = _run(vs.LaserRenderer(_config(**angles)), audio).paths[0]
|
||||
return np.ptp(path[:, 0]), np.ptp(path[:, 1])
|
||||
|
||||
square_x, square_y = spread()
|
||||
tipped_x, tipped_y = spread(laser_pitch=50)
|
||||
assert tipped_y / square_y > 1.3 * tipped_x / square_x
|
||||
turned_x, turned_y = spread(laser_yaw=50)
|
||||
assert turned_x / square_x > 1.3 * turned_y / square_y
|
||||
spun = _run(vs.LaserRenderer(_config(laser_roll=90)), audio).paths[0]
|
||||
flat = _run(vs.LaserRenderer(_config()), audio).paths[0]
|
||||
# A quarter turn: x becomes y (up to sign).
|
||||
assert np.allclose(np.abs(spun[:, 0]), np.abs(flat[:, 1]), atol=1e-9)
|
||||
|
||||
@pytest.mark.parametrize("angles", [
|
||||
{}, {"laser_pitch": 60, "laser_yaw": -60},
|
||||
{"laser_pitch": -60, "laser_yaw": 60, "laser_roll": 45},
|
||||
{"laser_distance": 1.0, "laser_pitch": 60}])
|
||||
def test_every_setting_stays_on_screen_and_visible(self, angles):
|
||||
rng = np.random.default_rng(0)
|
||||
audio = (_sine(90, 2, 0.9) * (1 + rng.standard_normal(2 * RATE) * 0.3))
|
||||
hop = 706
|
||||
for i in range(0, len(audio) - hop, hop):
|
||||
r.advance(_frame(np.zeros(2048, np.float32),
|
||||
audio[i:i + hop], dt=0.016))
|
||||
assert np.abs(r.paths[0]).max() <= 1.0
|
||||
|
||||
def test_loud_music_fills_most_of_the_screen(self):
|
||||
r = vs.LaserRenderer()
|
||||
audio = _sine(vs.MODES[0][0], 1.0, 0.5)
|
||||
audio = _sine(90, 2, 0.9) * (1 + rng.standard_normal(2 * RATE) * 0.3)
|
||||
audio = audio + _sine(150, 2, 0.5)
|
||||
r = vs.LaserRenderer(_config(**angles))
|
||||
rect = QRectF(0, 0, 140, 62)
|
||||
half_w, half_h = 70 - vs.INSET, 31 - vs.INSET
|
||||
biggest = 0.0
|
||||
for i in range(0, len(audio) - 706, 706):
|
||||
r.advance(_frame(np.zeros(2048, np.float32), audio[i:i + 706]))
|
||||
assert np.abs(r.paths[0]).max() > 0.6
|
||||
r.advance(_frame(np.zeros(vs.HISTORY, np.float32), audio[i:i + 706]))
|
||||
px = r.paths[0] * r.scale(rect)
|
||||
assert np.abs(px[:, 0]).max() <= half_w
|
||||
assert np.abs(px[:, 1]).max() <= half_h
|
||||
biggest = max(biggest, np.abs(px).max())
|
||||
assert biggest > 0.2 * half_h # and never shrunk to a dot
|
||||
|
||||
def test_fading_off_keeps_only_this_frame(self):
|
||||
audio = _sine(150, 0.5)
|
||||
assert len(_run(vs.LaserRenderer(), audio).paths) == len(vs.PERSIST)
|
||||
r = _run(vs.LaserRenderer(_config(laser_fade=False)), audio)
|
||||
assert len(r.paths) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -287,7 +400,7 @@ class TestBigWindow:
|
||||
prefs.set("visualizer_style", "laser")
|
||||
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
|
||||
calls = []
|
||||
vis._renderer.paint = lambda p, rect, color, light: calls.append(rect)
|
||||
vis._renderer.paint = lambda p, rect, color: calls.append(rect)
|
||||
img = QImage(10, 10, QImage.Format.Format_ARGB32)
|
||||
painter = QPainter(img)
|
||||
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
|
||||
@@ -303,7 +416,7 @@ class TestBigWindow:
|
||||
prefs.set("visualizer_style", "laser")
|
||||
vis = VisualizerWidget(_FakePlayer(), prefs)
|
||||
colors = []
|
||||
vis._renderer.paint = lambda p, rect, color, light: colors.append(color)
|
||||
vis._renderer.paint = lambda p, rect, color: colors.append(color)
|
||||
img = QImage(10, 10, QImage.Format.Format_ARGB32)
|
||||
painter = QPainter(img)
|
||||
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
|
||||
@@ -333,21 +446,47 @@ class TestPreferencesPage:
|
||||
return False
|
||||
return PreferencesDialog(prefs, _LastFm())
|
||||
|
||||
def test_style_radios_and_fade_slider(self, qapp, tmp_path):
|
||||
def test_style_radios_show_that_styles_settings(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = self._dialog(prefs)
|
||||
assert dialog._viz_radios["bars"].isChecked()
|
||||
assert not dialog._fade_slider.isEnabled()
|
||||
assert dialog._viz_pages.currentIndex() == 0
|
||||
|
||||
dialog._viz_radios["radial"].setChecked(True)
|
||||
assert prefs.get("visualizer_style") == "radial"
|
||||
assert dialog._fade_slider.isEnabled()
|
||||
assert dialog._viz_pages.currentIndex() == 1
|
||||
dialog._viz_radios["laser"].setChecked(True)
|
||||
assert dialog._viz_pages.currentIndex() == 2
|
||||
|
||||
def test_radial_settings(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = self._dialog(prefs)
|
||||
assert dialog._fade_slider.isEnabled()
|
||||
dialog._fade_slider.setValue(25)
|
||||
assert prefs.get("visualizer_ghost_fade") == 2.5
|
||||
|
||||
dialog._viz_radios["laser"].setChecked(True)
|
||||
dialog._radial_fade.setChecked(False)
|
||||
assert prefs.get("visualizer_radial_fade") is False
|
||||
assert not dialog._fade_slider.isEnabled()
|
||||
dialog._spokes_slider.setValue(60)
|
||||
dialog._levels_slider.setValue(10)
|
||||
dialog._normalize.setChecked(False)
|
||||
assert (prefs.get("visualizer_radial_spokes"),
|
||||
prefs.get("visualizer_radial_levels"),
|
||||
prefs.get("visualizer_radial_normalize")) == (60, 10, False)
|
||||
|
||||
def test_laser_settings(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = self._dialog(prefs)
|
||||
dialog._laser_fade.setChecked(False)
|
||||
dialog._distance_slider.setValue(15)
|
||||
dialog._angle_sliders["visualizer_laser_pitch"].setValue(30)
|
||||
dialog._angle_sliders["visualizer_laser_roll"].setValue(-90)
|
||||
assert prefs.get("visualizer_laser_fade") is False
|
||||
assert prefs.get("visualizer_laser_distance") == 1.5
|
||||
assert prefs.get("visualizer_laser_pitch") == 30
|
||||
lo, hi = vs.LIMITS["visualizer_laser_roll"]
|
||||
slider = dialog._angle_sliders["visualizer_laser_roll"]
|
||||
assert (slider.minimum(), slider.maximum()) == (lo, hi)
|
||||
|
||||
def test_building_the_dialog_writes_nothing(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user