From 751273b14c0e38e851a774389421c88d1e4957fd Mon Sep 17 00:00:00 2001 From: trav Date: Sat, 10 Oct 2026 00:51:33 -0700 Subject: [PATCH] =?UTF-8?q?v0.40.0:=20visualizer=20settings=20=E2=80=94=20?= =?UTF-8?q?laser=20screen=20angles,=20radial=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 32 ++- lintunes/__init__.py | 2 +- lintunes/gui/preferences_dialog.py | 136 +++++++--- lintunes/gui/visualizer.py | 40 +-- lintunes/gui/visualizer_styles.py | 399 +++++++++++++++++++++-------- lintunes/preferences.py | 10 + tests/test_round78.py | 277 +++++++++++++++----- 7 files changed, 651 insertions(+), 245 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d1d453f..d7cc19f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 6878098..673bcb0 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.39.0" +__version__ = "0.40.0" diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 7f44651..d95ea0f 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -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) diff --git a/lintunes/gui/visualizer.py b/lintunes/gui/visualizer.py index 9264112..e2c1971 100644 --- a/lintunes/gui/visualizer.py +++ b/lintunes/gui/visualizer.py @@ -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) diff --git a/lintunes/gui/visualizer_styles.py b/lintunes/gui/visualizer_styles.py index 49a4bb9..5716c8e 100644 --- a/lintunes/gui/visualizer_styles.py +++ b/lintunes/gui/visualizer_styles.py @@ -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) diff --git a/lintunes/preferences.py b/lintunes/preferences.py index 1fc8fb4..1eeeeef 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -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. diff --git a/tests/test_round78.py b/tests/test_round78.py index afb39a4..f8dbeb8 100644 --- a/tests/test_round78.py +++ b/tests/test_round78.py @@ -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)