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:
2026-10-10 00:51:33 -07:00
parent a81ab1e163
commit 751273b14c
7 changed files with 651 additions and 245 deletions
+22 -10
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.39.0"
__version__ = "0.40.0"
+105 -31
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+10
View File
@@ -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
View File
@@ -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)