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 `radial`, `laser`) owns the animation state and paints it into any rect, so
the double-click `VisualizerWindow` paints the **same** renderer bigger the double-click `VisualizerWindow` paints the **same** renderer bigger
rather than a copy. Since the first press of a double-click already cycled rather than a copy. Since the first press of a double-click already cycled
the brightness, `mouseDoubleClickEvent` puts it back. **Radial** is the EQ the brightness, `mouseDoubleClickEvent` puts it back. Every tunable is a
as polar dots (angle = 360 log bands, radius = level in `STEPS` rings), and `visualizer_*` pref, read through `visualizer_styles.settings()` (clamped to
a moving dot leaves light-gray ghosts. These are kept as a birth time per `LIMITS`, which the Preferences sliders share) and applied live through
(degree, ring), so a spoke never holds more than `STEPS` and pooling to `configure()`. **Radial** is the EQ as polar dots (angle = log bands,
fewer spokes is one reshape. **Laser** simulates a mirror on a speaker radius = level in rings; both counts are settings). A moving dot leaves
membrane: the circular-membrane modes (split degenerate pairs are what turn ghosts in its own color fading into the background, kept as a birth time
lines into rotating loops) run as complex one-pole resonators, vectorized in per (spoke, ring) so a spoke never holds more than its ring count. It reads
`CHUNK`s. Tilt goes through D·tan(2θ) with an auto-gain, and the path is a **4096**-point FFT and interpolates bands narrower than a bin: with 2048
drawn brighter where it moves slower (dwell). It needs **every** sample, not points, every degree under ~1.3 kHz shared a bin with its neighbours and
the newest FFT window, hence the widget's capped `_pending` queue. 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. - **`lintunes/importers/itunes_importer.py`** — parses the iTunes XML plist.
Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with 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.""" """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 PyQt6.QtGui import QFont
from lintunes import theme from lintunes import theme
from lintunes.gui import visualizer_styles
from lintunes.gui import sounds from lintunes.gui import sounds
from lintunes.theme import HIGHLIGHT_COLORS from lintunes.theme import HIGHLIGHT_COLORS
@@ -326,7 +327,8 @@ class PreferencesDialog(QDialog):
def _build_visualizer_group(self) -> QGroupBox: def _build_visualizer_group(self) -> QGroupBox:
group = QGroupBox("Visualizer") group = QGroupBox("Visualizer")
form = QFormLayout(group) column = QVBoxLayout(group)
config = visualizer_styles.settings(self._prefs)
style_row = QHBoxLayout() style_row = QHBoxLayout()
self._viz_style = QButtonGroup(self) self._viz_style = QButtonGroup(self)
@@ -343,45 +345,117 @@ class PreferencesDialog(QDialog):
self._viz_radios[key] = radio self._viz_radios[key] = radio
style_row.addWidget(radio) style_row.addWidget(radio)
style_row.addStretch() 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. # Only the chosen style's settings are shown.
fade_row = QHBoxLayout() self._viz_pages = QStackedWidget()
self._fade_slider = QSlider(Qt.Orientation.Horizontal) bars = QLabel("The classic graphic EQ — nothing to set.")
self._fade_slider.setRange(0, 30) bars.setEnabled(False)
self._fade_slider.setFixedWidth(160) self._viz_pages.addWidget(bars)
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")
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 = QLabel("Double-click the visualizer to see it big.")
note.setEnabled(False) note.setEnabled(False)
form.addRow(note) column.addWidget(note)
return group 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): def _on_viz_style(self, key: str):
self._fade_slider.setEnabled(key == "radial") self._viz_pages.setCurrentIndex(list(_VISUALIZER_STYLES).index(key))
self._fade_label.setEnabled(key == "radial")
self._prefs.set("visualizer_style", 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: def _build_colors_group(self) -> QGroupBox:
group = QGroupBox("Colors") group = QGroupBox("Colors")
form = QFormLayout(group) form = QFormLayout(group)
+14 -26
View File
@@ -15,11 +15,11 @@ from PyQt6.QtMultimedia import QAudioFormat
from lintunes import theme from lintunes import theme
from lintunes.gui.icons import transport_icon from lintunes.gui.icons import transport_icon
from lintunes.gui import visualizer_styles as styles
from lintunes.gui.visualizer_styles import ( 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 FRAME_MS = 16 # ~60 fps
RADIUS = 8 # rounded corners, matching the transport button boxes RADIUS = 8 # rounded corners, matching the transport button boxes
ICON_SIZE = 40 # the cast glyph shown in place of bars while casting 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 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): class VisualizerWidget(QWidget):
"""Animated visualizer; click to cycle brightness: on / dim / off, and """Animated visualizer; click to cycle brightness: on / dim / off, and
double-click to open it big (``VisualizerWindow``). 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 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 = saved if saved in _MODE_CYCLE else MODE_ON
self._mode_before_press = self._mode self._mode_before_press = self._mode
self._renderer = make_renderer(_style(prefs), _fade(prefs)) self._renderer = make_renderer(_style(prefs), styles.settings(prefs))
self._samples = np.zeros(FFT_SIZE, dtype=np.float32) self._samples = np.zeros(HISTORY, dtype=np.float32)
self._pending = [] # every sample since the last frame (the laser) self._pending = [] # every sample since the last frame (the laser)
self._pending_len = 0 self._pending_len = 0
self._sample_rate = 44100 self._sample_rate = 44100
self._window = np.hanning(FFT_SIZE).astype(np.float32)
self._big = None # VisualizerWindow, made on first double-click self._big = None # VisualizerWindow, made on first double-click
# True while audio is coming out of something other than this machine # True while audio is coming out of something other than this machine
# (a Chromecast), so there is no decoded PCM to draw. # (a Chromecast), so there is no decoded PCM to draw.
@@ -117,11 +108,12 @@ class VisualizerWidget(QWidget):
def _on_prefs_changed(self): def _on_prefs_changed(self):
style = _style(self._prefs) style = _style(self._prefs)
config = styles.settings(self._prefs)
if style != self._renderer.name: if style != self._renderer.name:
self._renderer = make_renderer(style, _fade(self._prefs)) self._renderer = make_renderer(style, config)
self._repaint() else:
elif hasattr(self._renderer, "fade"): self._renderer.configure(config)
self._renderer.fade = _fade(self._prefs) self._repaint()
def _on_sink_changed(self, sink): def _on_sink_changed(self, sink):
"""Casting means no local decode, so there is nothing to draw. The """Casting means no local decode, so there is nothing to draw. The
@@ -160,8 +152,8 @@ class VisualizerWidget(QWidget):
def _feed(self, mono, rate): def _feed(self, mono, rate):
self._sample_rate = rate self._sample_rate = rate
if len(mono) >= FFT_SIZE: if len(mono) >= HISTORY:
self._samples = mono[-FFT_SIZE:].copy() self._samples = mono[-HISTORY:].copy()
else: else:
self._samples = np.roll(self._samples, -len(mono)) self._samples = np.roll(self._samples, -len(mono))
self._samples[-len(mono):] = mono self._samples[-len(mono):] = mono
@@ -264,11 +256,7 @@ class VisualizerWidget(QWidget):
# ---- frame computation ---- # ---- frame computation ----
def _advance_frame(self): def _advance_frame(self):
spectrum = np.abs(np.fft.rfft(self._samples * self._window)) frame = Frame(self._samples, self._sample_rate,
# 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,
self._take_pending(), FRAME_MS / 1000) self._take_pending(), FRAME_MS / 1000)
if self._renderer.advance(frame): if self._renderer.advance(frame):
self._repaint() self._repaint()
@@ -281,7 +269,7 @@ class VisualizerWidget(QWidget):
return QColor(self.palette().highlight().color()) return QColor(self.palette().highlight().color())
def _gray(self): 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: if self._prefs is not None:
saved = self._prefs.get("color_visualizer_gray") saved = self._prefs.get("color_visualizer_gray")
if saved is not None: if saved is not None:
@@ -302,7 +290,7 @@ class VisualizerWidget(QWidget):
regardless (in color), since opening it is asking to see it.""" regardless (in color), since opening it is asking to see it."""
if self._no_pcm or (self._mode == MODE_OFF and not big): if self._no_pcm or (self._mode == MODE_OFF and not big):
return return
self._renderer.paint(painter, rect, self._bar_color(), self._gray()) self._renderer.paint(painter, rect, self._bar_color())
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) 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. frequency, radius is volume, and a dot that moves leaves fading ghosts.
- ``LaserRenderer`` — a laser bounced off a mirror glued to a membrane - ``LaserRenderer`` — a laser bounced off a mirror glued to a membrane
stretched over a speaker, projected onto a screen (Tame Impala's rig). 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 import math
from dataclasses import dataclass from dataclasses import dataclass
@@ -19,6 +23,8 @@ import numpy as np
from PyQt6.QtCore import Qt, QPointF, QLineF from PyQt6.QtCore import Qt, QPointF, QLineF
from PyQt6.QtGui import QColor, QPen, QPolygonF from PyQt6.QtGui import QColor, QPen, QPolygonF
from lintunes.preferences import DEFAULTS
STYLE_BARS = "bars" STYLE_BARS = "bars"
STYLE_RADIAL = "radial" STYLE_RADIAL = "radial"
@@ -26,23 +32,73 @@ STYLE_LASER = "laser"
STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER) STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER)
BANDS = 20 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 DECAY = 0.82 # slow decay; attack is instant
FLOOR_DB = -55.0 FLOOR_DB = -55.0
LOW_HZ = 40.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 @dataclass
class Frame: class Frame:
"""One timer tick's worth of audio, handed to ``advance``.""" """One timer tick's worth of audio, handed to ``advance``."""
spectrum: np.ndarray # |rfft| of the newest window, full-scale sine ~1.0 samples: np.ndarray # the newest HISTORY mono samples
freqs: np.ndarray # the frequency of each spectrum bin
rate: int # sample rate rate: int # sample rate
pending: np.ndarray # every mono sample that arrived since last tick pending: np.ndarray # every mono sample that arrived since last tick
dt: float # seconds since the 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: def band_edges(count: int, rate: float) -> np.ndarray:
"""``count`` log-spaced bands from 40 Hz to 16 kHz (or just under """``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 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) return np.geomspace(LOW_HZ, max(top, 2 * LOW_HZ), count + 1)
def band_levels(spectrum: np.ndarray, freqs: np.ndarray, def band_db(spectrum: np.ndarray, freqs: np.ndarray,
edges: np.ndarray) -> np.ndarray: edges: np.ndarray) -> np.ndarray:
"""Each band's loudest bin as a 0..1 level (0 = FLOOR_DB or quieter). """Each band's loudest bin, in dB.
A band too narrow to hold a bin of its own (the bottom degrees of the A band narrower than a bin (the bass end of a many-spoked radial) holds
radial, at 21 Hz a bin) takes the bin nearest its center rather than no bin of its own. Copying the nearest bin into it gave runs of degrees
reading as silence.""" the *same* value — flat arcs where there should be dots — so it reads the
spectrum interpolated at its center instead."""
count = len(edges) - 1 count = len(edges) - 1
starts = np.searchsorted(freqs, edges).clip(0, len(spectrum) - 1) starts = np.searchsorted(freqs, edges).clip(0, len(spectrum) - 1)
magnitude = np.maximum.reduceat(spectrum, starts)[:count] magnitude = np.maximum.reduceat(spectrum, starts)[:count]
empty = starts[1:] <= starts[:-1] empty = starts[1:] <= starts[:-1]
if empty.any(): if empty.any():
centers = np.sqrt(edges[:-1] * edges[1:])[empty] centers = np.sqrt(edges[:-1] * edges[1:])[empty]
step = freqs[1] - freqs[0] if len(freqs) > 1 else 1.0 magnitude[empty] = np.interp(centers, freqs, spectrum)
nearest = np.rint(centers / step).astype(int).clip(0, len(spectrum) - 1) return 20.0 * np.log10(magnitude + 1e-10)
magnitude[empty] = spectrum[nearest]
db = 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) 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: def _faded(color: QColor, alpha: float) -> QColor:
faded = QColor(color) faded = QColor(color)
faded.setAlphaF(max(0.0, min(1.0, alpha)) * color.alphaF()) faded.setAlphaF(max(0.0, min(1.0, alpha)) * color.alphaF())
@@ -84,17 +147,20 @@ def _faded(color: QColor, alpha: float) -> QColor:
class BarsRenderer: class BarsRenderer:
name = STYLE_BARS name = STYLE_BARS
def __init__(self): def __init__(self, config=None):
self.levels = np.zeros(BANDS) self.levels = np.zeros(BANDS)
self.bars = [0] * BANDS # quantized 0..STEPS self.bars = [0] * BANDS # quantized 0..STEPS
def configure(self, config):
pass
def clear(self): def clear(self):
self.levels[:] = 0.0 self.levels[:] = 0.0
self.bars = [0] * BANDS self.bars = [0] * BANDS
def advance(self, frame: Frame) -> bool: def advance(self, frame: Frame) -> bool:
new_levels = band_levels(frame.spectrum, frame.freqs, spectrum, freqs = spectrum_of(frame.samples, BARS_FFT, frame.rate)
band_edges(BANDS, frame.rate)) new_levels = band_levels(spectrum, freqs, band_edges(BANDS, frame.rate))
# Instant attack, slow decay # Instant attack, slow decay
self.levels = np.maximum(new_levels, self.levels * DECAY) self.levels = np.maximum(new_levels, self.levels * DECAY)
bars = [int(round(level * STEPS)) for level in self.levels] bars = [int(round(level * STEPS)) for level in self.levels]
@@ -103,7 +169,7 @@ class BarsRenderer:
self.bars = bars self.bars = bars
return True 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_w = rect.width() - 2 * INSET
avail_h = rect.height() - 2 * INSET avail_h = rect.height() - 2 * INSET
left = int(rect.x()) + INSET left = int(rect.x()) + INSET
@@ -125,44 +191,56 @@ class BarsRenderer:
# Radial dots # Radial dots
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
RADIAL_BANDS = 360 # one per degree; painting pools them to fit DOT_PITCH = 3.0 # px between neighbouring dots on the rim, at least
DOT_PITCH = 3.0 # px between neighbouring dots on the rim
HUB = 0.22 # the silent middle, as a share of the radius 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 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] 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
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])
class RadialRenderer: class RadialRenderer:
"""The graphic EQ as a polar dot plot. """The graphic EQ as a polar dot plot.
Frequency is the angle — 360 log-spaced bands, bass at 12 o'clock Frequency is the angle — ``spokes`` log-spaced bands, bass at 12 o'clock
running clockwise — and volume is the radius, quantized to STEPS rings running clockwise — and volume is the radius, quantized to ``levels``
so the dots line up like the bars' steps. A dot that moves leaves a rings so the dots line up like the bars' steps. A dot that moves leaves
ghost on every ring it passed; a ghost fades over ``fade`` seconds. 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: 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 than a revisit simply refreshes it, so a spoke can never hold more ghosts
STEPS of them, and pooling 360 degrees down to fewer spokes is one than it has rings.
reshape + max.
**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 name = STYLE_RADIAL
def __init__(self, fade: float = 1.0): def __init__(self, config=None):
self.fade = fade self.spokes = self.ring_count = None
self.levels = np.zeros(RADIAL_BANDS) self.configure(config or settings(None))
self.rings = np.zeros(RADIAL_BANDS, dtype=int) # 0 = silent
self.ghosts = np.full((RADIAL_BANDS, STEPS + 1), -np.inf) def configure(self, config):
self.clock = 0.0 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): def clear(self):
self.levels[:] = 0.0 self.levels[:] = 0.0
@@ -170,18 +248,49 @@ class RadialRenderer:
self.ghosts[:] = -np.inf self.ghosts[:] = -np.inf
def ghost_alpha(self) -> np.ndarray: 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: if self.fade <= 0:
return np.zeros_like(self.ghosts) return np.zeros_like(self.ghosts)
age = self.clock - self.ghosts age = self.clock - self.ghosts
return np.clip(1.0 - age / self.fade, 0.0, 1.0) 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: def advance(self, frame: Frame) -> bool:
self.clock += frame.dt self.clock += frame.dt
new_levels = band_levels(frame.spectrum, frame.freqs, spectrum, freqs = spectrum_of(frame.samples, RADIAL_FFT, frame.rate)
band_edges(RADIAL_BANDS, frame.rate)) edges = band_edges(self.spokes, frame.rate)
self.levels = np.maximum(new_levels, self.levels * DECAY) db = self._evened(band_db(spectrum, freqs, edges), edges, frame.dt)
rings = np.rint(self.levels * STEPS).astype(int) self.levels = np.maximum(to_level(db), self.levels * DECAY)
rings = np.rint(self.levels * self.ring_count).astype(int)
moved = rings != self.rings moved = rings != self.rings
if moved.any() and self.fade > 0: if moved.any() and self.fade > 0:
# A ghost where the dot was. Falling (the slow decay) also leaves # 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] old, new = self.rings[moved], rings[moved]
lo = np.where(new < old, new, old)[:, None] lo = np.where(new < old, new, old)[:, None]
hi = 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) trail = (k >= lo) & (k <= hi) & (k != new[:, None]) & (k > 0)
block = self.ghosts[moved] block = self.ghosts[moved]
block[trail] = self.clock block[trail] = self.clock
self.ghosts[moved] = block self.ghosts[moved] = block
self.rings = rings self.rings = rings
if self.fade <= 0: if self.fade > 0:
self.ghosts[:] = -np.inf
else:
# Forget the long-faded, so "anything still fading?" is cheap. # Forget the long-faded, so "anything still fading?" is cheap.
self.ghosts[self.clock - self.ghosts >= self.fade] = -np.inf self.ghosts[self.clock - self.ghosts >= self.fade] = -np.inf
return bool(moved.any() or np.isfinite(self.ghosts).any()) return bool(moved.any() or np.isfinite(self.ghosts).any())
def pooled(self, spokes: int): def pooled(self, fits: int):
"""The state at ``spokes`` spokes: each spoke's ring, and each """The state at no more than ``fits`` spokes (what the circle has
(spoke, ring) ghost strength — the loudest of the degrees it covers.""" room for): each spoke's ring and (spoke, ring) ghost strength — the
group = RADIAL_BANDS // spokes loudest of the bands it covers — plus each spoke's angle, 0..1."""
rings = self.rings.reshape(spokes, group).max(axis=1) rings, alpha = self.rings, self.ghost_alpha()
alpha = self.ghost_alpha().reshape(spokes, group, STEPS + 1).max(axis=1) starts = np.arange(self.spokes)
alpha[np.arange(spokes), rings] = 0.0 # under the dot itself if self.spokes > fits:
alpha[:, 0] = 0.0 # silence isn't drawn starts = np.unique(np.linspace(0, self.spokes, max(1, fits) + 1)
return rings, alpha .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() cx, cy = rect.center().x(), rect.center().y()
radius = min(rect.width(), rect.height()) / 2 - INSET radius = min(rect.width(), rect.height()) / 2 - INSET
if radius <= 2: if radius <= 2:
return return
spokes = spoke_count(radius) fits = int(2 * math.pi * radius / DOT_PITCH)
rings, alpha = self.pooled(spokes) rings, alpha, angle = self.pooled(fits)
spokes = len(rings)
hub = radius * HUB hub = radius * HUB
ring_step = (radius - hub) / STEPS ring_step = (radius - hub) / self.ring_count
dot = max(1.8, min(2 * math.pi * radius / spokes, ring_step * 2.5) * 0.8) # Smaller than the gap to every neighbour, so a trail stays a row of
angles = 2 * math.pi * (np.arange(spokes) + 0.5) / spokes # dots rather than running together into a band.
sin, cos = np.sin(angles), np.cos(angles) 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): def points(spoke_idx, ring_idx):
r = hub + ring_idx * ring_step - dot / 2 r = hub + ring_idx * ring_step - dot / 2
@@ -236,13 +353,14 @@ class RadialRenderer:
pen = QPen() pen = QPen()
pen.setWidthF(dot) pen.setWidthF(dot)
pen.setCapStyle(Qt.PenCapStyle.RoundCap) 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) bucket = np.ceil(alpha * GHOST_BUCKETS).astype(int)
for level in range(1, GHOST_BUCKETS + 1): for level in range(1, GHOST_BUCKETS + 1):
spoke_idx, ring_idx = np.nonzero(bucket == level) spoke_idx, ring_idx = np.nonzero(bucket == level)
if len(spoke_idx) == 0: if len(spoke_idx) == 0:
continue continue
pen.setColor(_faded(light, GHOST_ALPHA * level / GHOST_BUCKETS)) pen.setColor(_faded(color, GHOST_ALPHA * level / GHOST_BUCKETS))
painter.setPen(pen) painter.setPen(pen)
painter.drawPoints(points(spoke_idx, ring_idx)) painter.drawPoints(points(spoke_idx, ring_idx))
live = np.nonzero(rings > 0)[0] live = np.nonzero(rings > 0)[0]
@@ -256,8 +374,8 @@ class RadialRenderer:
# Laser # Laser
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# #
# The rig: a small mirror glued off-center on a rubber membrane stretched # The rig: a small mirror glued to a rubber membrane stretched over a
# over a speaker; a laser hits the mirror and the reflection lands on a big # 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 # 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 # 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 # 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 # 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 # 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 # angles that a perfect drum tunes identically. A hand-stretched membrane
# is, so they split by a percent or two, the two axes drift in and out of # never is: the pair splits, and between the two resonances they answer the
# phase on a held note, and that is where the slowly turning ellipses and # same note up to 90° apart — the beam swings along one axis a quarter
# Lissajous loops come from. A perfectly even push wouldn't excite m > 0 # cycle behind the other, which is a circle. A mirror near the middle sits
# modes at all; the mirror's own mass, off-center, is what couples them. # 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 # Reflection doubles the angle: tilt θ turns the beam 2θ, and the spot is
# at D·tan(2θ) — a faint pincushion stretch at big swings, kept in. The # 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 # 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 # 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 # 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 MODE_ZEROS = ( # (m, j_mn) — Bessel zeros
(0, 2.40483), (1, 3.83171), (2, 5.13562), (0, 5.52008), (3, 6.38016), (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 # A big, slack balloon: its rocking pairs land in the bass, where the
MODE_Q = 8.0 # rubber is lossy # notes are, rather than above them — measured over random bass lines, that
SPLIT = (0.0, 0.016, 0.011, 0.0, 0.008) # the uneven stretch, per mode pair # is loops ~60% of the time against ~25% for a tight 90 Hz drum head.
MIRROR_AT = (0.55, 0.6) # the mirror's spot: radius (of 1), angle (rad) 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 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 SCREEN_HALF = 1.0 # the screen's half-height, metres
FILL = 0.85 # auto-gain aims the loudest swings at this much of it 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 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 PERSIST = (1.0, 0.45, 0.2) # this frame's path, then fading older ones
CHUNK = 256 # samples per vectorized resonator step CHUNK = 256 # samples per vectorized resonator step
ROOM = 0.95 # the figure never comes closer to the edge than this
def _bessel_j(m: int, x: float) -> float: def _bessel_j(m: int, x: float) -> float:
@@ -336,17 +470,44 @@ def _membrane_modes():
MODES = _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: class LaserRenderer:
name = STYLE_LASER name = STYLE_LASER
def __init__(self): def __init__(self, config=None):
self.levels = np.zeros(1) # the widget's shared clear() touches it self.levels = np.zeros(1) # the widget's shared clear() touches it
self._rate = None self._rate = None
self._carry = np.zeros(0, dtype=np.float64) self.paths = [] # newest first: (n, 2) arrays, metres on the screen
self._peak = PEAK_FLOOR self.configure(config or settings(None))
self.paths = [] # newest first: (n, 2) arrays in -1..1 screen units
self.clear() 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): def clear(self):
self._state = np.zeros(len(MODES), dtype=np.complex128) self._state = np.zeros(len(MODES), dtype=np.complex128)
self._carry = np.zeros(0, dtype=np.float64) self._carry = np.zeros(0, dtype=np.float64)
@@ -400,16 +561,27 @@ class LaserRenderer:
# ---- the screen ---- # ---- 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: 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 peak = float(np.linalg.norm(tilt, axis=1).max()) if len(tilt) else 0.0
self._peak = max(peak, self._peak * math.exp(-dt / GAIN_RELEASE_S), self._peak = max(peak, self._peak * math.exp(-dt / GAIN_RELEASE_S),
PEAK_FLOOR) PEAK_FLOOR)
# Pick the gain that puts the loudest swing at FILL of the screen… # The gain that swings the loudest beam to BEAM_TARGET — reflection
gain = math.atan(FILL * math.tan(BEAM_MAX)) / (2 * self._peak) # doubles the mirror's angle, hence the 2.
# …then reflection doubles the mirror's angle, and a flat screen gain = BEAM_TARGET / (2 * self._peak)
# spreads big angles out: D·tan(2θ). return self.on_screen(2 * gain * tilt)
return np.tan(2 * gain * tilt) / math.tan(BEAM_MAX)
def advance(self, frame: Frame) -> bool: def advance(self, frame: Frame) -> bool:
tilt = self.drive(frame.pending, frame.rate) 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 # Join this frame's path onto the last one's end, so the line is
# unbroken where two frames meet. # unbroken where two frames meet.
path = np.vstack([self.paths[0][-1:], path]) 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 return True
def spot(self) -> np.ndarray: def spot(self) -> np.ndarray:
return self.paths[0][-1] 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() cx, cy = rect.center().x(), rect.center().y()
half = min(rect.width(), rect.height()) / 2 - INSET half = min(rect.width(), rect.height()) / 2 - INSET
if half <= 2: if half <= 2:
return return
px = self.scale(rect)
s = max(1.0, half / 90) # stroke scale: the big window glows wider s = max(1.0, half / 90) # stroke scale: the big window glows wider
pen = QPen() pen = QPen()
pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
# Oldest first, so the newest trace sits on top. # 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: if len(path) < 2:
continue continue
# A frame is ~180 steps; the panel is ~60 px across, so most # A frame is ~180 steps; the panel is ~60 px across, so most
# would be sub-pixel segments that cost a Qt object each. # would be sub-pixel segments that cost a Qt object each.
step = max(1, int(len(path) / (2 * half))) step = max(1, int(len(path) / (2 * half)))
path = np.vstack([path[::step], path[-1:]]) path = np.vstack([path[::step], path[-1:]])
xy = np.column_stack([cx + path[:, 0] * half, xy = np.column_stack([cx + path[:, 0] * px, cy - path[:, 1] * px])
cy - path[:, 1] * half])
# The haze and the screen's scatter: one even bloom along the # The haze and the screen's scatter: one even bloom along the
# whole path, a single polyline so joints don't stack up alpha. # whole path, a single polyline so joints don't stack up alpha.
# Only around the newest trace — the older ones are afterimage. # 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]) line = QPolygonF([QPointF(x, y) for x, y in xy])
for width, strength in ((7 * s, 0.07), (3 * s, 0.18)): for width, strength in ((7 * s, 0.07), (3 * s, 0.18)):
pen.setWidthF(width) pen.setWidthF(width)
@@ -470,7 +653,7 @@ class LaserRenderer:
painter.drawLines([QLineF(*xy[i], *xy[i + 1]) for i in idx]) painter.drawLines([QLineF(*xy[i], *xy[i + 1]) for i in idx])
# The beam itself, wherever it is right now. # The beam itself, wherever it is right now.
x, y = self.spot() 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.setPen(Qt.PenStyle.NoPen)
painter.setBrush(_faded(color, 0.25)) painter.setBrush(_faded(color, 0.25))
painter.drawEllipse(head, 3.5 * s, 3.5 * s) painter.drawEllipse(head, 3.5 * s, 3.5 * s)
@@ -478,9 +661,9 @@ class LaserRenderer:
painter.drawEllipse(head, 1.4 * s, 1.4 * s) 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: if style == STYLE_RADIAL:
return RadialRenderer(fade) return RadialRenderer(config)
if style == STYLE_LASER: if style == STYLE_LASER:
return LaserRenderer() return LaserRenderer(config)
return BarsRenderer() return BarsRenderer(config)
+10
View File
@@ -13,7 +13,17 @@ DEFAULTS = {
"volume": 0.8, # logical 0..1 master output volume "volume": 0.8, # logical 0..1 master output volume
"visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle "visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle
"visualizer_style": "bars", # bars | radial | laser (visualizer_styles) "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_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 # Per-area grayscale overrides (0=black .. 255=white). None = inherit the
# current/default look for that surface, so nothing changes until the user # current/default look for that surface, so nothing changes until the user
# drags a slider in Preferences. # 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): 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: if pending is None:
pending = np.zeros(0, np.float32) 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): def _paint(renderer, w=140, h=62):
img = QImage(w, h, QImage.Format.Format_ARGB32) img = QImage(w, h, QImage.Format.Format_ARGB32)
img.fill(QColor("white")) img.fill(QColor("white"))
painter = QPainter(img) 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() painter.end()
return img return img
@@ -93,10 +101,18 @@ class TestStylePreference:
assert vis._renderer is before # state survives assert vis._renderer is before # state survives
assert before.fade == 2.5 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): def test_every_style_paints_without_error(self, qapp):
for style in vs.STYLES: for style in vs.STYLES:
renderer = vs.make_renderer(style) renderer = vs.make_renderer(style)
window = _sine(1000, 2048 / RATE) window = _tone(1000)
for _ in range(5): for _ in range(5):
renderer.advance(_frame(window, _sine(90, 0.016))) renderer.advance(_frame(window, _sine(90, 0.016)))
for size in ((140, 62), (1200, 750), (4, 4)): for size in ((140, 62), (1200, 750), (4, 4)):
@@ -107,21 +123,37 @@ class TestStylePreference:
# 2. Shared analysis # 2. Shared analysis
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def _spectrum(window, size):
return vs.spectrum_of(np.asarray(window, np.float32), size, RATE)
class TestBandLevels: class TestBandLevels:
def test_a_sine_lights_its_own_band(self): 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) edges = vs.band_edges(vs.BANDS, RATE)
levels = vs.band_levels(frame.spectrum, frame.freqs, edges) levels = vs.band_levels(spectrum, freqs, edges)
band = np.searchsorted(edges, 1000) - 1 assert levels.argmax() == np.searchsorted(edges, 1000) - 1
assert levels.argmax() == band
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): def test_narrow_bass_bands_are_not_silent(self):
# At 360 bands the bottom ones are narrower than an FFT bin. spectrum, freqs = _spectrum(_tone(45), vs.RADIAL_FFT)
frame = _frame(_sine(45, 2048 / RATE)) edges = vs.band_edges(360, RATE)
edges = vs.band_edges(vs.RADIAL_BANDS, RATE) levels = vs.band_levels(spectrum, freqs, edges)
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
assert levels[:5].min() > 0.5 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 # 3. Radial
@@ -129,23 +161,30 @@ class TestBandLevels:
class TestRadial: class TestRadial:
def test_frequency_is_the_angle(self): def test_frequency_is_the_angle(self):
r = vs.RadialRenderer() r = vs.RadialRenderer(_config(radial_normalize=False))
r.advance(_frame(_sine(1000, 2048 / RATE))) r.advance(_frame(_tone(1000)))
edges = vs.band_edges(vs.RADIAL_BANDS, RATE) edges = vs.band_edges(r.spokes, RATE)
degree = np.searchsorted(edges, 1000) - 1 assert abs(int(r.rings.argmax()) - (np.searchsorted(edges, 1000) - 1)) <= 1
assert abs(int(r.rings.argmax()) - degree) <= 2
def test_volume_is_the_radius(self): def test_volume_is_the_radius(self):
loud, quiet = vs.RadialRenderer(), vs.RadialRenderer() config = _config(radial_normalize=False)
loud.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8))) loud, quiet = vs.RadialRenderer(config), vs.RadialRenderer(config)
quiet.advance(_frame(_sine(1000, 2048 / RATE, amp=0.01))) loud.advance(_frame(_tone(1000, amp=0.8)))
quiet.advance(_frame(_tone(1000, amp=0.01)))
assert loud.rings.max() > quiet.rings.max() > 0 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): def test_a_falling_dot_leaves_ghosts_that_fade(self):
r = vs.RadialRenderer(fade=0.5) r = vs.RadialRenderer(_config(ghost_fade=0.5, radial_normalize=False))
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8))) r.advance(_frame(_tone(1000, amp=0.8)))
degree, top = int(r.rings.argmax()), int(r.rings.max()) 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) r.advance(silence)
assert r.ghost_alpha()[degree, top] > 0.9 # where it was, fresh assert r.ghost_alpha()[degree, top] > 0.9 # where it was, fresh
for _ in range(20): # 0.32 s for _ in range(20): # 0.32 s
@@ -156,51 +195,92 @@ class TestRadial:
assert r.ghost_alpha().max() == 0 assert r.ghost_alpha().max() == 0
assert not np.isfinite(r.ghosts).any() # forgotten, not just faint assert not np.isfinite(r.ghosts).any() # forgotten, not just faint
def test_no_fade_means_no_ghosts(self): def test_fading_off_means_no_ghosts(self):
r = vs.RadialRenderer(fade=0) r = vs.RadialRenderer(_config(radial_fade=False))
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8))) r.advance(_frame(_tone(1000, amp=0.8)))
r.advance(_frame(np.zeros(2048, np.float32))) r.advance(_frame(np.zeros(vs.HISTORY, np.float32)))
assert r.ghost_alpha().max() == 0 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): def test_a_rise_leaves_only_where_it_came_from(self):
# An instant attack must not paint a bar of ghosts below the dot. # An instant attack must not paint a bar of ghosts below the dot.
r = vs.RadialRenderer(fade=1.0) r = vs.RadialRenderer(_config(ghost_fade=1.0, radial_normalize=False))
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.003))) r.advance(_frame(_tone(1000, amp=0.003)))
before = r.rings.copy() 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)) degree = int(np.argmax(r.rings - before))
alive = np.nonzero(r.ghost_alpha()[degree] > 0)[0] alive = np.nonzero(r.ghost_alpha()[degree] > 0)[0]
assert list(alive) == [before[degree]] assert list(alive) == [before[degree]]
def test_ghosts_per_spoke_are_bounded(self): 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) rng = np.random.default_rng(1)
for _ in range(200): for _ in range(200):
r.advance(_frame(_sine(1000, 2048 / RATE, amp=rng.random()))) r.advance(_frame(_tone(1000, amp=rng.random())))
rings, alpha = r.pooled(45) rings, alpha, _angle = r.pooled(45)
assert (alpha > 0).sum(axis=1).max() <= vs.STEPS assert len(rings) <= 45
assert (alpha > 0).sum(axis=1).max() <= r.ring_count
def test_bigger_circles_get_more_spokes(self): def test_evening_out_lines_bass_up_with_treble(self):
assert vs.spoke_count(28) < vs.spoke_count(400) == 360 # Pink-ish: every octave 6 dB quieter than the one below.
assert 360 % vs.spoke_count(28) == 0 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 # 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: class TestLaser:
def test_silence_is_a_still_dot_in_the_middle(self): def test_silence_is_a_still_dot_in_the_middle(self):
r = vs.LaserRenderer() r = _run(vs.LaserRenderer(), np.zeros(RATE // 4, np.float32))
for _ in range(10):
r.advance(_frame(np.zeros(2048, np.float32),
np.zeros(706, np.float32)))
assert np.allclose(r.paths[0], 0.0) assert np.allclose(r.paths[0], 0.0)
def test_the_membrane_answers_its_resonance(self): def test_the_membrane_answers_its_resonance(self):
f0 = vs.MODES[0][0] f = vs.MODES[1][0]
on, off = vs.LaserRenderer(), vs.LaserRenderer() 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() swing_off = np.abs(off.drive(_sine(3000, 0.5), RATE)).max()
assert swing_on > 20 * swing_off assert swing_on > 20 * swing_off
@@ -212,31 +292,64 @@ class TestLaser:
for chunk in np.array_split(audio, [101, 700, 702, 5000])] for chunk in np.array_split(audio, [101, 700, 702, 5000])]
assert np.allclose(np.concatenate(parts), whole) assert np.allclose(np.concatenate(parts), whole)
def test_degenerate_modes_turn_a_line_into_a_loop(self): def test_the_split_pair_draws_loops(self):
# The split (1,1) pair: driven between its two resonances, the two # Between the (1,1) pair's resonances the two axes answer a quarter
# axes are out of phase — an ellipse, not a line. # cycle apart: nearly a circle, not a line.
f = (vs.MODES[1][0] + vs.MODES[2][0]) / 2 f = (vs.MODES[1][0] + vs.MODES[2][0]) / 2
tilt = vs.LaserRenderer().drive(_sine(f, 1.0), RATE)[-2000:] tilt = vs.LaserRenderer().drive(_sine(f, 1.0), RATE)[-2000:]
tilt = tilt - tilt.mean(axis=0) tilt = tilt - tilt.mean(axis=0)
s = np.linalg.svd(tilt, compute_uv=False) 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): def test_farther_screen_bigger_figure(self):
r = vs.LaserRenderer() 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) rng = np.random.default_rng(0)
audio = (_sine(90, 2, 0.9) * (1 + rng.standard_normal(2 * RATE) * 0.3)) audio = _sine(90, 2, 0.9) * (1 + rng.standard_normal(2 * RATE) * 0.3)
hop = 706 audio = audio + _sine(150, 2, 0.5)
for i in range(0, len(audio) - hop, hop): r = vs.LaserRenderer(_config(**angles))
r.advance(_frame(np.zeros(2048, np.float32), rect = QRectF(0, 0, 140, 62)
audio[i:i + hop], dt=0.016)) half_w, half_h = 70 - vs.INSET, 31 - vs.INSET
assert np.abs(r.paths[0]).max() <= 1.0 biggest = 0.0
def test_loud_music_fills_most_of_the_screen(self):
r = vs.LaserRenderer()
audio = _sine(vs.MODES[0][0], 1.0, 0.5)
for i in range(0, len(audio) - 706, 706): for i in range(0, len(audio) - 706, 706):
r.advance(_frame(np.zeros(2048, np.float32), audio[i:i + 706])) r.advance(_frame(np.zeros(vs.HISTORY, np.float32), audio[i:i + 706]))
assert np.abs(r.paths[0]).max() > 0.6 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") prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(playing=True), prefs) vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
calls = [] 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) img = QImage(10, 10, QImage.Format.Format_ARGB32)
painter = QPainter(img) painter = QPainter(img)
vis.paint_renderer(painter, QRectF(0, 0, 10, 10)) vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
@@ -303,7 +416,7 @@ class TestBigWindow:
prefs.set("visualizer_style", "laser") prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(), prefs) vis = VisualizerWidget(_FakePlayer(), prefs)
colors = [] 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) img = QImage(10, 10, QImage.Format.Format_ARGB32)
painter = QPainter(img) painter = QPainter(img)
vis.paint_renderer(painter, QRectF(0, 0, 10, 10)) vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
@@ -333,21 +446,47 @@ class TestPreferencesPage:
return False return False
return PreferencesDialog(prefs, _LastFm()) 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) prefs = Preferences(tmp_path)
dialog = self._dialog(prefs) dialog = self._dialog(prefs)
assert dialog._viz_radios["bars"].isChecked() assert dialog._viz_radios["bars"].isChecked()
assert not dialog._fade_slider.isEnabled() assert dialog._viz_pages.currentIndex() == 0
dialog._viz_radios["radial"].setChecked(True) dialog._viz_radios["radial"].setChecked(True)
assert prefs.get("visualizer_style") == "radial" 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) dialog._fade_slider.setValue(25)
assert prefs.get("visualizer_ghost_fade") == 2.5 assert prefs.get("visualizer_ghost_fade") == 2.5
dialog._radial_fade.setChecked(False)
dialog._viz_radios["laser"].setChecked(True) assert prefs.get("visualizer_radial_fade") is False
assert not dialog._fade_slider.isEnabled() 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): def test_building_the_dialog_writes_nothing(self, qapp, tmp_path):
prefs = Preferences(tmp_path) prefs = Preferences(tmp_path)