From a81ab1e1630eb50d126700a2857ac5d838ddbf4c Mon Sep 17 00:00:00 2001 From: trav Date: Sat, 10 Oct 2026 00:26:29 -0700 Subject: [PATCH] =?UTF-8?q?v0.39.0:=20visualizer=20styles=20=E2=80=94=20ra?= =?UTF-8?q?dial=20dots=20and=20a=20laser,=20double-click=20to=20enlarge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferences → Appearance → Visualizer picks Bars (as before), Radial dots (the EQ in a circle: angle is frequency, radius is volume, moving dots leave light-gray ghosts that fade over a set time), or Laser (a mirror on a speaker membrane bouncing a beam onto a screen: simulated membrane modes, D·tan(2θ) reflection, dwell-weighted brightness). Double-clicking the panel opens it big; a click closes it. --- CLAUDE.md | 17 + lintunes/__init__.py | 2 +- lintunes/gui/preferences_dialog.py | 69 ++++ lintunes/gui/visualizer.py | 248 ++++++++++----- lintunes/gui/visualizer_styles.py | 486 +++++++++++++++++++++++++++++ lintunes/gui/visualizer_window.py | 59 ++++ lintunes/preferences.py | 2 + tests/test_round78.py | 357 +++++++++++++++++++++ 8 files changed, 1155 insertions(+), 85 deletions(-) create mode 100644 lintunes/gui/visualizer_styles.py create mode 100644 lintunes/gui/visualizer_window.py create mode 100644 tests/test_round78.py diff --git a/CLAUDE.md b/CLAUDE.md index 5b0b6ce..d1d453f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,6 +172,23 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal for every add-to-playlist path. `theme.py` applies the palette (highlight colors, UI scales) from prefs. +- **`lintunes/gui/visualizer.py`** + **`visualizer_styles.py`** — the + transport-bar visualizer (Round 78). The widget owns PCM intake, the timer, + on/dim/off and the color; a *renderer* (`visualizer_style` pref: `bars`, + `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. + - **`lintunes/importers/itunes_importer.py`** — parses the iTunes XML plist. Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with case/Unicode-normalization fuzzy matching (macOS is case-insensitive + NFD vs diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 732ebae..6878098 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.38.0" +__version__ = "0.39.0" diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 77c8d29..7f44651 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -25,6 +25,14 @@ _COLOR_ROWS = [ ] +# visualizer_styles' style keys, as the radios name them. +_VISUALIZER_STYLES = { + "bars": "Bars", + "radial": "Radial dots", + "laser": "Laser", +} + + class PreferencesDialog(QDialog): """App preferences. Applies immediately — there is no OK/Cancel. @@ -62,6 +70,7 @@ class PreferencesDialog(QDialog): for title, groups in ( ("General", general), ("Appearance", [self._build_general_group(), + self._build_visualizer_group(), self._build_colors_group()]), ("Integrations", [self._build_lastfm_group(), self._build_acoustid_group()])): @@ -313,6 +322,66 @@ class PreferencesDialog(QDialog): # ---- Colors ---- + # ---- Visualizer ---- + + def _build_visualizer_group(self) -> QGroupBox: + group = QGroupBox("Visualizer") + form = QFormLayout(group) + + style_row = QHBoxLayout() + self._viz_style = QButtonGroup(self) + self._viz_radios = {} + current = self._prefs.get("visualizer_style", "bars") + if current not in _VISUALIZER_STYLES: + current = "bars" + for key, label in _VISUALIZER_STYLES.items(): + radio = QRadioButton(label) + radio.setChecked(key == current) # before connect + radio.toggled.connect( + lambda on, k=key: on and self._on_viz_style(k)) + self._viz_style.addButton(radio) + self._viz_radios[key] = radio + style_row.addWidget(radio) + style_row.addStretch() + form.addRow("Style:", style_row) + + # 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") + + note = QLabel("Double-click the visualizer to see it big.") + note.setEnabled(False) + form.addRow(note) + return group + + def _on_viz_style(self, key: str): + self._fade_slider.setEnabled(key == "radial") + self._fade_label.setEnabled(key == "radial") + 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 9fcd2e5..9264112 100644 --- a/lintunes/gui/visualizer.py +++ b/lintunes/gui/visualizer.py @@ -1,28 +1,29 @@ -"""20-band graphic-EQ visualizer fed by the player's decoded PCM tee. +"""The transport bar's visualizer, fed by the player's decoded PCM tee. The player's QAudioBufferOutput delivers buffers essentially in lockstep with the audible position (verified: frames received ≈ frames played), so -no extra sync buffering is needed — we just FFT the newest samples. +no extra sync buffering is needed — we just FFT the newest samples. What a +frame turns into is the chosen style's business (``visualizer_styles``); +this widget owns intake, timing, on/dim/off and color. """ import numpy as np from PyQt6.QtWidgets import QWidget from PyQt6.QtCore import Qt, QTimer, QRectF, pyqtSignal -from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen +from PyQt6.QtGui import QPainter, QColor from PyQt6.QtMultimedia import QAudioFormat from lintunes import theme from lintunes.gui.icons import transport_icon +from lintunes.gui.visualizer_styles import ( + BANDS, STYLES, STYLE_BARS, Frame, make_renderer) -BANDS = 20 -STEPS = 20 # vertical granularity per bar FFT_SIZE = 2048 FRAME_MS = 16 # ~60 fps -DECAY = 0.82 # slow decay; attack is instant -FLOOR_DB = -55.0 RADIUS = 8 # rounded corners, matching the transport button boxes ICON_SIZE = 40 # the cast glyph shown in place of bars while casting +PENDING_S = 0.25 # most unconsumed audio kept for the laser # Visualizer click cycles through these brightness modes in order. MODE_ON = "on" @@ -30,6 +31,8 @@ MODE_DIM = "dim" MODE_OFF = "off" _MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF) +_TOOLTIP = "Click to cycle: on / dim / off — double-click to enlarge" + _SAMPLE_DTYPES = { QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5), QAudioFormat.SampleFormat.Int16: (np.int16, 0.0, 32768.0), @@ -38,11 +41,25 @@ _SAMPLE_DTYPES = { } -class VisualizerWidget(QWidget): - """Animated spectrum bars; click to cycle brightness: on / dim / off. +def _style(prefs) -> str: + saved = prefs.get("visualizer_style") if prefs is not None else None + return saved if saved in STYLES else STYLE_BARS - On = highlight-colored bars; dim = light-gray bars just darker than the - panel; off = blank. + +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``). + + On = highlight-colored; dim = light gray just darker than the panel; + off = blank (the big window, if open, keeps going in color). While casting there is no local PCM to analyze, so the panel doubles as the cast indicator: it shows the device glyph and a click stops casting. @@ -56,11 +73,14 @@ class VisualizerWidget(QWidget): self._prefs = prefs saved = prefs.get("visualizer_mode") if prefs is not None else None self._mode = saved if saved in _MODE_CYCLE else MODE_ON - self._levels = np.zeros(BANDS) - self._bars = [0] * BANDS # quantized 0..STEPS + self._mode_before_press = self._mode + self._renderer = make_renderer(_style(prefs), _fade(prefs)) self._samples = np.zeros(FFT_SIZE, 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. self._no_pcm = False @@ -68,7 +88,7 @@ class VisualizerWidget(QWidget): self.setFixedWidth(140) self.setMinimumHeight(36) - self.setToolTip("Click to cycle: on / dim / off") + self.setToolTip(_TOOLTIP) self._timer = QTimer(self) self._timer.setInterval(FRAME_MS) @@ -79,6 +99,29 @@ class VisualizerWidget(QWidget): player.track_changed.connect(self._on_track_changed) if hasattr(player, "sink_changed"): player.sink_changed.connect(self._on_sink_changed) + if prefs is not None: + prefs.changed.connect(self._on_prefs_changed) + + # The bars' state, under the names the widget always had for it. + @property + def _bars(self): + return getattr(self._renderer, "bars", [0] * BANDS) + + @_bars.setter + def _bars(self, bars): + self._renderer.bars = bars + + @property + def _levels(self): + return self._renderer.levels + + def _on_prefs_changed(self): + style = _style(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) def _on_sink_changed(self, sink): """Casting means no local decode, so there is nothing to draw. The @@ -87,16 +130,14 @@ class VisualizerWidget(QWidget): self._no_pcm = not getattr(sink, "provides_pcm", True) if self._no_pcm: self._device = getattr(sink, "name", "") or "a Chromecast" - self._timer.stop() self._clear() self.setToolTip(f"Casting to {self._device} — click to stop") self.setCursor(Qt.CursorShape.PointingHandCursor) else: self._device = "" - self.setToolTip("Click to cycle: on / dim / off") + self.setToolTip(_TOOLTIP) self.setCursor(Qt.CursorShape.ArrowCursor) - if self._mode != MODE_OFF and self._player.is_playing(): - self._timer.start() + self._sync_timer() self.update() # ---- audio intake ---- @@ -115,36 +156,75 @@ class VisualizerWidget(QWidget): usable = (len(raw) // channels) * channels raw = raw[:usable].reshape(-1, channels).mean(axis=1) mono = (raw.astype(np.float32) - offset) / scale - self._sample_rate = fmt.sampleRate() or 44100 + self._feed(mono, fmt.sampleRate() or 44100) + def _feed(self, mono, rate): + self._sample_rate = rate if len(mono) >= FFT_SIZE: self._samples = mono[-FFT_SIZE:].copy() else: self._samples = np.roll(self._samples, -len(mono)) self._samples[-len(mono):] = mono + # The laser needs every sample, once, not just the newest window. + self._pending.append(mono) + self._pending_len += len(mono) + cap = int(PENDING_S * rate) + while self._pending_len > cap and len(self._pending) > 1: + self._pending_len -= len(self._pending.pop(0)) + + def _take_pending(self): + pending = (np.concatenate(self._pending) if self._pending + else np.zeros(0, dtype=np.float32)) + self._pending = [] + self._pending_len = 0 + return pending # ---- state ---- + def _big_visible(self): + return self._big is not None and self._big.isVisible() + + def _sync_timer(self): + """Animate while playing, unless nothing would show it.""" + run = (not self._no_pcm and self._player.is_playing() + and (self._mode != MODE_OFF or self._big_visible())) + if run: + if not self._timer.isActive(): + self._take_pending() # stale audio from while we were idle + self._timer.start() + else: + self._timer.stop() + def _on_playing_changed(self, playing): if self._no_pcm: return # nothing to animate; don't let play/pause restart the timer - if playing: - if self._mode != MODE_OFF: - self._timer.start() - else: - self._timer.stop() - if self._mode != MODE_OFF: - self._clear() + self._sync_timer() + if not playing: + self._clear() def _on_track_changed(self, track): self._samples[:] = 0.0 - if track is None and self._mode != MODE_OFF: + self._take_pending() + if track is None: self._timer.stop() self._clear() def _clear(self): - self._levels[:] = 0.0 - self._bars = [0] * BANDS + self._renderer.clear() + self._repaint() + + def _repaint(self): + self.update() + if self._big_visible(): + self._big.update() + + def _set_mode(self, mode): + self._mode = mode + if self._prefs is not None: + self._prefs.set("visualizer_mode", self._mode) + if self._mode == MODE_OFF and not self._big_visible(): + self._clear() # off = blank, not the last frame held in place + self._sync_timer() self.update() def mousePressEvent(self, event): @@ -155,15 +235,31 @@ class VisualizerWidget(QWidget): self.cast_stop_requested.emit() return # Cycle on -> dim -> off -> on. + self._mode_before_press = self._mode idx = _MODE_CYCLE.index(self._mode) - self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)] - if self._prefs is not None: - self._prefs.set("visualizer_mode", self._mode) + self._set_mode(_MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]) + + def mouseDoubleClickEvent(self, event): + """Qt sends press, release, *double-click*: the first press already + cycled the brightness, so put it back before opening big. A brief + flicker, in exchange for a single click that never waits to see + whether a second one is coming.""" + if self._no_pcm: + return # the first click already stopped casting + self._set_mode(self._mode_before_press) + self.open_big() + + def open_big(self): + from lintunes.gui.visualizer_window import VisualizerWindow + if self._big is None: + self._big = VisualizerWindow(self) + self._big.open(self.screen()) + self._sync_timer() + + def big_window_closed(self): if self._mode == MODE_OFF: - self._timer.stop() - self._clear() # off = blank, not the last frame held in place - elif self._player.is_playing(): - self._timer.start() + self._clear() + self._sync_timer() # ---- frame computation ---- @@ -171,52 +267,51 @@ class VisualizerWidget(QWidget): spectrum = np.abs(np.fft.rfft(self._samples * self._window)) # Normalize: full-scale sine -> ~1.0 spectrum /= (FFT_SIZE / 4) - - nyquist = self._sample_rate / 2 - top = min(16000.0, nyquist * 0.95) - edges = np.geomspace(40.0, max(top, 80.0), BANDS + 1) freqs = np.fft.rfftfreq(FFT_SIZE, d=1.0 / self._sample_rate) - - new_levels = np.zeros(BANDS) - for i in range(BANDS): - mask = (freqs >= edges[i]) & (freqs < edges[i + 1]) - if mask.any(): - magnitude = spectrum[mask].max() - db = 20.0 * np.log10(magnitude + 1e-10) - new_levels[i] = np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0) - - # Instant attack, slow decay - self._levels = np.maximum(new_levels, self._levels * DECAY) - bars = [int(round(level * STEPS)) for level in self._levels] - if bars != self._bars: - self._bars = bars - self.update() + frame = Frame(spectrum, freqs, self._sample_rate, + self._take_pending(), FRAME_MS / 1000) + if self._renderer.advance(frame): + self._repaint() # ---- painting ---- def _bar_color(self): if self._mode == MODE_DIM: - if self._prefs is not None: - saved = self._prefs.get("color_visualizer_gray") - if saved is not None: - return theme.gray(saved) - # Light gray just darker than the panel: take alternateBase and - # drop its lightness ~12% so it tracks theme/scale changes. - base = QColor(self.palette().alternateBase().color()) - h, s, l, a = base.getHslF() - base.setHslF(h, s, l * 0.88, a) - return base + return self._gray() return QColor(self.palette().highlight().color()) + def _gray(self): + """Dim mode's color — and the radial's fading ghosts in any mode.""" + if self._prefs is not None: + saved = self._prefs.get("color_visualizer_gray") + if saved is not None: + return theme.gray(saved) + # Light gray just darker than the panel: take alternateBase and + # drop its lightness ~12% so it tracks theme/scale changes. + base = QColor(self.palette().alternateBase().color()) + h, s, l, a = base.getHslF() + base.setHslF(h, s, l * 0.88, a) + return base + + def panel_color(self): + return self.palette().alternateBase().color() + + def paint_renderer(self, painter, rect, big=False): + """Paint the current frame into ``rect`` — the panel's, or the big + window's. The panel draws nothing when off; the big window draws + 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()) + def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) # A rounded panel debossed as far as the transport buttons stand out: # it shows things, it isn't pressed. rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) - panel = theme.paint_deboss(painter, rect, - self.palette().alternateBase().color(), RADIUS) - painter.setClipPath(panel) # keep bars inside the rounded shape + panel = theme.paint_deboss(painter, rect, self.panel_color(), RADIUS) + painter.setClipPath(panel) # keep the drawing inside the rounded shape if self._no_pcm: # Casting: the audio never passes through this machine, so show # where it went instead of pretending to analyze silence. @@ -228,19 +323,4 @@ class VisualizerWidget(QWidget): (self.height() - ICON_SIZE) // 2, glyph) painter.end() return - inset = 3 # bar field sits 3px in from every edge - avail_w = self.width() - 2 * inset - avail_h = self.height() - 2 * inset - baseline = self.height() - inset - bar_width = avail_w / BANDS - color = self._bar_color() - step_height = avail_h / STEPS - for i, bar in enumerate(self._bars): - if bar <= 0: - continue - x = inset + int(i * bar_width) - end = inset + int((i + 1) * bar_width) - gap = 1 if i < BANDS - 1 else 0 # 1px between bars, none at edge - w = max(1, end - x - gap) - bar_height = int(bar * step_height) - painter.fillRect(x, baseline - bar_height, w, bar_height, color) + self.paint_renderer(painter, QRectF(self.rect())) diff --git a/lintunes/gui/visualizer_styles.py b/lintunes/gui/visualizer_styles.py new file mode 100644 index 0000000..49a4bb9 --- /dev/null +++ b/lintunes/gui/visualizer_styles.py @@ -0,0 +1,486 @@ +"""The visualizer's styles: what a frame of audio is turned into. + +A renderer owns its own animation state and knows how to paint it into any +rect, so the transport-bar panel and the enlarged ``VisualizerWindow`` paint +the very same state at two sizes. ``VisualizerWidget`` owns everything else: +audio intake, the timer, on/dim/off, the color. Pure numpy + QPainter. + +- ``BarsRenderer`` — the original 20-band graphic EQ. +- ``RadialRenderer`` — that EQ wrapped into a circle as dots: angle is + 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). +""" +import math +from dataclasses import dataclass + +import numpy as np + +from PyQt6.QtCore import Qt, QPointF, QLineF +from PyQt6.QtGui import QColor, QPen, QPolygonF + + +STYLE_BARS = "bars" +STYLE_RADIAL = "radial" +STYLE_LASER = "laser" +STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER) + +BANDS = 20 +STEPS = 20 # vertical granularity per bar (and rings per radial spoke) +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 + + +@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 + rate: int # sample rate + pending: np.ndarray # every mono sample that arrived since last tick + dt: float # seconds since the last tick + + +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 + most of the bands above anything but cymbals.""" + top = min(16000.0, rate / 2 * 0.95) + 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). + + 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.""" + 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) + return np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0) + + +def _faded(color: QColor, alpha: float) -> QColor: + faded = QColor(color) + faded.setAlphaF(max(0.0, min(1.0, alpha)) * color.alphaF()) + return faded + + +# -------------------------------------------------------------------------- +# Bars +# -------------------------------------------------------------------------- + +class BarsRenderer: + name = STYLE_BARS + + def __init__(self): + self.levels = np.zeros(BANDS) + self.bars = [0] * BANDS # quantized 0..STEPS + + 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)) + # Instant attack, slow decay + self.levels = np.maximum(new_levels, self.levels * DECAY) + bars = [int(round(level * STEPS)) for level in self.levels] + if bars == self.bars: + return False + self.bars = bars + return True + + def paint(self, painter, rect, color: QColor, light: QColor = None): + avail_w = rect.width() - 2 * INSET + avail_h = rect.height() - 2 * INSET + left = int(rect.x()) + INSET + baseline = int(rect.y() + rect.height()) - INSET + bar_width = avail_w / BANDS + step_height = avail_h / STEPS + for i, bar in enumerate(self.bars): + if bar <= 0: + continue + x = left + int(i * bar_width) + end = left + int((i + 1) * bar_width) + gap = 1 if i < BANDS - 1 else 0 # 1px between bars, none at edge + w = max(1, end - x - gap) + bar_height = int(bar * step_height) + painter.fillRect(x, baseline - bar_height, w, bar_height, color) + + +# -------------------------------------------------------------------------- +# Radial dots +# -------------------------------------------------------------------------- + +RADIAL_BANDS = 360 # one per degree; painting pools them to fit +DOT_PITCH = 3.0 # px between neighbouring dots on the rim +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_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]) + + +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. + + 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. + """ + + 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 clear(self): + self.levels[:] = 0.0 + self.rings[:] = 0 + self.ghosts[:] = -np.inf + + def ghost_alpha(self) -> np.ndarray: + """(degree, 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 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) + moved = rings != self.rings + if moved.any() and self.fade > 0: + # A ghost where the dot was. Falling (the slow decay) also leaves + # one on every ring it skipped, so the fall reads as a trail; a + # rise is an instant attack — it leaves only where it came from, + # or every hit would paint a bar. + old, new = self.rings[moved], rings[moved] + lo = np.where(new < old, new, old)[:, None] + hi = old[:, None] + k = np.arange(STEPS + 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: + # 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 paint(self, painter, rect, color: QColor, light: 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) + 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) + + def points(spoke_idx, ring_idx): + r = hub + ring_idx * ring_step - dot / 2 + xs = cx + r * sin[spoke_idx] + ys = cy - r * cos[spoke_idx] + return [QPointF(x, y) for x, y in zip(xs, ys)] + + pen = QPen() + pen.setWidthF(dot) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + # Ghosts first, faintest first, so the newer ones sit on top. + 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)) + painter.setPen(pen) + painter.drawPoints(points(spoke_idx, ring_idx)) + live = np.nonzero(rings > 0)[0] + if len(live): + pen.setColor(color) + painter.setPen(pen) + painter.drawPoints(points(live, rings[live])) + + +# -------------------------------------------------------------------------- +# 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 +# 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 +# where the mirror sits, so every mode pushes the beam along one fixed +# direction on the screen, at a strength set by how near the music is to +# that mode's resonance. +# +# 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. +# +# 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 +# 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 +# blooming around it. + +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) +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 +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 +PERSIST = (1.0, 0.45, 0.2) # this frame's path, then fading older ones +CHUNK = 256 # samples per vectorized resonator step + + +def _bessel_j(m: int, x: float) -> float: + return sum((-1) ** k / (math.factorial(k) * math.factorial(k + m)) + * (x / 2) ** (2 * k + m) for k in range(30)) + + +def _mode_slope(m: int, j: float, phase: float) -> tuple: + """d/dx, d/dy of J_m(j·r)·cos(m(θ − phase)) at the mirror.""" + r0, a0 = MIRROR_AT + x0, y0 = r0 * math.cos(a0), r0 * math.sin(a0) + + def shape(x, y): + return (_bessel_j(m, j * math.hypot(x, y)) + * math.cos(m * (math.atan2(y, x) - phase))) + + h = 1e-4 + return ((shape(x0 + h, y0) - shape(x0 - h, y0)) / (2 * h), + (shape(x0, y0 + h) - shape(x0, y0 - h)) / (2 * h)) + + +def _membrane_modes(): + """[(frequency Hz, weight, slope x, slope y)] for every resonator.""" + modes = [] + j01 = MODE_ZEROS[0][1] + for (m, j), split in zip(MODE_ZEROS, SPLIT): + ratio = j / j01 + # Pressure drives a mode in proportion to how evenly it moves (m = 0 + # fully; the rest only through the mirror's off-center mass), and a + # stiffer, higher mode moves less for the same push (1/ratio²). + weight = (1.0 if m == 0 else 0.8) / ratio ** 2 + orientations = [(0.0, 1.0)] if m == 0 else \ + [(0.0, 1.0), (math.pi / (2 * m), 1.0 + split)] + for phase, detune in orientations: + sx, sy = _mode_slope(m, j, phase) + modes.append((FUNDAMENTAL_HZ * ratio * detune, weight, sx, sy)) + return modes + + +MODES = _membrane_modes() + + +class LaserRenderer: + name = STYLE_LASER + + def __init__(self): + 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.clear() + + def clear(self): + self._state = np.zeros(len(MODES), dtype=np.complex128) + self._carry = np.zeros(0, dtype=np.float64) + self._peak = PEAK_FLOOR + self.paths = [np.zeros((1, 2))] + + # ---- the membrane ---- + + def _setup(self, rate: int): + self._rate = rate + self._step = max(1, int(round(rate / SIM_RATE))) + fs = rate / self._step + freq = np.array([f for f, _w, _x, _y in MODES]) + # Each resonator is the imaginary part of a complex one-pole filter: + # its impulse response is e^(−αn)·sin(ωn), a damped ring at f. + self._alpha = math.pi * freq / (MODE_Q * fs) + self._pole = np.exp(-self._alpha + 2j * math.pi * freq / fs) + weights = np.array([w for _f, w, _x, _y in MODES]) + slopes = np.array([(x, y) for _f, _w, x, y in MODES]) + # α normalizes each resonator to unit gain at its own resonance. + self._mix = (weights * self._alpha)[:, None] * slopes # (modes, 2) + self._state[:] = 0 + self._carry = np.zeros(0, dtype=np.float64) + + def drive(self, samples: np.ndarray, rate: int) -> np.ndarray: + """Feed audio through the membrane; returns the mirror's tilt, (n, 2), + one row per simulated step. Continuous across calls, so splitting the + same audio into different chunks gives the same tilt.""" + if rate != self._rate: + self._setup(rate) + data = np.concatenate([self._carry, np.asarray(samples, np.float64)]) + usable = len(data) // self._step * self._step + self._carry = data[usable:] + # Averaging blocks is the downsample, and a little of the mirror's + # inertia: it can't follow anything that fast anyway. + x = data[:usable].reshape(-1, self._step).mean(axis=1) + out = [] + for start in range(0, len(x), CHUNK): + chunk = x[start:start + CHUNK] + n = np.arange(len(chunk)) + # y[n] = p^n · (p·y[−1] + Σ_{k≤n} x[k]·p^−k), all modes at once. + # CHUNK keeps p^−k far from overflowing. + powers = self._pole[:, None] ** n[None, :] + acc = np.cumsum(chunk[None, :] / powers, axis=1) + y = powers * (self._pole[:, None] * self._state[:, None] + acc) + self._state = y[:, -1] + out.append(y.imag.T @ self._mix) + if not out: + return np.zeros((0, 2)) + return np.concatenate(out) + + # ---- the screen ---- + + def project(self, tilt: np.ndarray, dt: float) -> np.ndarray: + """Tilt → spot on the screen, -1..1 each way (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) + + def advance(self, frame: Frame) -> bool: + tilt = self.drive(frame.pending, frame.rate) + if len(tilt) == 0: + return False + path = self.project(tilt, frame.dt) + # 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] + return True + + def spot(self) -> np.ndarray: + return self.paths[0][-1] + + def paint(self, painter, rect, color: QColor, light: QColor = None): + cx, cy = rect.center().x(), rect.center().y() + half = min(rect.width(), rect.height()) / 2 - INSET + if half <= 2: + return + 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))): + 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]) + # 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]: + 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) + pen.setColor(_faded(color, strength)) + painter.setPen(pen) + painter.drawPolyline(line) + # The core. Dwell: the beam lights a stretch of screen for as + # long as it stays there, so slow stretches are bright and fast + # sweeps faint. + speed = np.linalg.norm(np.diff(xy, axis=0), axis=1) / step + bright = np.clip(0.04 * half / (speed + 1e-6), 0.15, 1.0) + bucket = np.ceil(bright * 6).astype(int) + pen.setWidthF(1.2 * s) + for level in range(1, 7): + idx = np.nonzero(bucket == level)[0] + if len(idx) == 0: + continue + pen.setColor(_faded(color, persist * level / 6)) + painter.setPen(pen) + 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) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(_faded(color, 0.25)) + painter.drawEllipse(head, 3.5 * s, 3.5 * s) + painter.setBrush(color) + painter.drawEllipse(head, 1.4 * s, 1.4 * s) + + +def make_renderer(style: str, fade: float = 1.0): + if style == STYLE_RADIAL: + return RadialRenderer(fade) + if style == STYLE_LASER: + return LaserRenderer() + return BarsRenderer() diff --git a/lintunes/gui/visualizer_window.py b/lintunes/gui/visualizer_window.py new file mode 100644 index 0000000..2186f78 --- /dev/null +++ b/lintunes/gui/visualizer_window.py @@ -0,0 +1,59 @@ +from PyQt6.QtWidgets import QWidget, QApplication +from PyQt6.QtCore import Qt, QSize, QRectF +from PyQt6.QtGui import QPainter + +from lintunes.gui.art_window import fit_size + +# The window takes a projection screen's shape. +SCREEN_SHAPE = QSize(16, 10) + + +class VisualizerWindow(QWidget): + """The transport-bar visualizer, enlarged: opened by double-clicking the + panel, closed by a click (or Esc). It holds no state of its own — it + paints the panel's renderer, so both always show the same frame.""" + + def __init__(self, visualizer): + super().__init__(visualizer, Qt.WindowType.Window) + self._visualizer = visualizer + self.setWindowTitle("Visualizer") + self.setMinimumSize(160, 100) + self.setToolTip("Click to close") + + def open(self, screen=None): + screen = screen or self.screen() or QApplication.primaryScreen() + if screen is not None: + geo = screen.availableGeometry() + size = fit_size(SCREEN_SHAPE, geo.size()) + margins = self.contentsMargins() # the app-drawn title bar + self.resize(size.width(), size.height() + margins.top()) + self.move(geo.x() + (geo.width() - size.width()) // 2, + geo.y() + (geo.height() - size.height()) // 2) + self.show() + self.raise_() + self.activateWindow() + + def mouseReleaseEvent(self, event): + if (event.button() == Qt.MouseButton.LeftButton + and self.contentsRect().contains(event.position().toPoint())): + self.close() + return + super().mouseReleaseEvent(event) + + def keyPressEvent(self, event): + if event.key() == Qt.Key.Key_Escape: + self.close() + return + super().keyPressEvent(event) + + def hideEvent(self, event): + super().hideEvent(event) + self._visualizer.big_window_closed() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + area = self.contentsRect() # below our own title bar (titlebar.py) + painter.fillRect(area, self._visualizer.panel_color()) + painter.setClipRect(area) + self._visualizer.paint_renderer(painter, QRectF(area), big=True) diff --git a/lintunes/preferences.py b/lintunes/preferences.py index f0e0fb0..1fc8fb4 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -12,6 +12,8 @@ DEFAULTS = { # default, "Family"=use app "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) + "visualizer_ghost_fade": 1.0, # seconds a radial dot's trail takes to fade # 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 new file mode 100644 index 0000000..afb39a4 --- /dev/null +++ b/tests/test_round78.py @@ -0,0 +1,357 @@ +"""Round 78: visualizer styles — bars, radial dots, laser — and the big window. + +1. ``visualizer_style`` picks the renderer, live; nonsense falls back to bars. +2. ``band_levels`` is the bars' old analysis, and fills too-narrow bands. +3. Radial: frequency is the angle, volume the radius, moves leave ghosts. +4. Laser: a mirror on a membrane — resonance, continuity, stays on screen. +5. Double-click opens the big window without changing the brightness. +6. The Preferences page. +""" +import numpy as np +import pytest + +from PyQt6.QtCore import QObject, pyqtSignal, QRectF +from PyQt6.QtGui import QImage, QPainter, QColor + +from lintunes.preferences import Preferences +from lintunes.gui.visualizer import VisualizerWidget, MODE_ON, MODE_DIM, MODE_OFF +from lintunes.gui import visualizer_styles as vs + + +RATE = 44100 + + +class _FakePlayer(QObject): + audio_buffer = pyqtSignal(object) + playing_changed = pyqtSignal(bool) + track_changed = pyqtSignal(object) + + def __init__(self, playing=False): + super().__init__() + self._playing = playing + + def is_playing(self): + return self._playing + + +def _sine(hz, seconds=0.1, amp=0.5): + t = np.arange(int(RATE * seconds)) / RATE + return (amp * np.sin(2 * np.pi * hz * t)).astype(np.float32) + + +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) + + +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")) + painter.end() + return img + + +# -------------------------------------------------------------------------- +# 1. The style preference +# -------------------------------------------------------------------------- + +class TestStylePreference: + def test_default_is_bars(self, qapp, tmp_path): + vis = VisualizerWidget(_FakePlayer(), Preferences(tmp_path)) + assert isinstance(vis._renderer, vs.BarsRenderer) + + @pytest.mark.parametrize("style, cls", [ + ("radial", vs.RadialRenderer), ("laser", vs.LaserRenderer)]) + def test_saved_style_is_used(self, qapp, tmp_path, style, cls): + prefs = Preferences(tmp_path) + prefs.set("visualizer_style", style) + assert isinstance(VisualizerWidget(_FakePlayer(), prefs)._renderer, cls) + + def test_nonsense_falls_back_to_bars(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("visualizer_style", "oscilloscope") # hand-edited file + vis = VisualizerWidget(_FakePlayer(), prefs) + assert isinstance(vis._renderer, vs.BarsRenderer) + + def test_changing_the_pref_swaps_live(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + vis = VisualizerWidget(_FakePlayer(), prefs) + prefs.set("visualizer_style", "laser") + assert isinstance(vis._renderer, vs.LaserRenderer) + + def test_an_unrelated_pref_keeps_the_renderer(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("visualizer_style", "radial") + vis = VisualizerWidget(_FakePlayer(), prefs) + before = vis._renderer + prefs.set("visualizer_ghost_fade", 2.5) + assert vis._renderer is before # state survives + assert before.fade == 2.5 + + def test_every_style_paints_without_error(self, qapp): + for style in vs.STYLES: + renderer = vs.make_renderer(style) + window = _sine(1000, 2048 / RATE) + for _ in range(5): + renderer.advance(_frame(window, _sine(90, 0.016))) + for size in ((140, 62), (1200, 750), (4, 4)): + _paint(renderer, *size) + + +# -------------------------------------------------------------------------- +# 2. Shared analysis +# -------------------------------------------------------------------------- + +class TestBandLevels: + def test_a_sine_lights_its_own_band(self): + frame = _frame(_sine(1000, 2048 / RATE)) + 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 + + 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) + assert levels[:5].min() > 0.5 + + +# -------------------------------------------------------------------------- +# 3. Radial +# -------------------------------------------------------------------------- + +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 + + 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))) + assert loud.rings.max() > quiet.rings.max() > 0 + + 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))) + degree, top = int(r.rings.argmax()), int(r.rings.max()) + silence = _frame(np.zeros(2048, np.float32)) + r.advance(silence) + assert r.ghost_alpha()[degree, top] > 0.9 # where it was, fresh + for _ in range(20): # 0.32 s + r.advance(silence) + assert 0 < r.ghost_alpha()[degree, top] < 0.9 + for _ in range(40): # well past 0.5 s + r.advance(silence) + 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))) + assert r.ghost_alpha().max() == 0 + + 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))) + before = r.rings.copy() + r.advance(_frame(_sine(1000, 2048 / RATE, 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) + 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 + + 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 + + +# -------------------------------------------------------------------------- +# 4. Laser +# -------------------------------------------------------------------------- + +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))) + assert np.allclose(r.paths[0], 0.0) + + def test_the_membrane_answers_its_resonance(self): + f0 = vs.MODES[0][0] + on, off = vs.LaserRenderer(), vs.LaserRenderer() + swing_on = np.abs(on.drive(_sine(f0, 0.5), RATE)).max() + swing_off = np.abs(off.drive(_sine(3000, 0.5), RATE)).max() + assert swing_on > 20 * swing_off + + def test_chunking_does_not_change_the_path(self): + audio = _sine(143, 0.3) + _sine(90, 0.3) + whole = vs.LaserRenderer().drive(audio, RATE) + pieces = vs.LaserRenderer() + parts = [pieces.drive(chunk, RATE) + 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. + 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 + + def test_the_spot_stays_on_the_screen(self): + r = vs.LaserRenderer() + 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) + 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 + + +# -------------------------------------------------------------------------- +# 5. Double-click → big window +# -------------------------------------------------------------------------- + +class TestBigWindow: + def test_double_click_keeps_the_brightness_and_opens(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + vis = VisualizerWidget(_FakePlayer(playing=True), prefs) + vis.mousePressEvent(None) # Qt's first press: on -> dim + vis.mouseDoubleClickEvent(None) # …which the double-click undoes + try: + assert vis._mode == MODE_ON + assert prefs.get("visualizer_mode") == MODE_ON + assert vis._big is not None and vis._big.isVisible() + finally: + vis._big.close() + + def test_a_click_closes_it(self, qapp): + from PyQt6.QtCore import Qt, QPointF, QEvent + from PyQt6.QtGui import QMouseEvent + vis = VisualizerWidget(_FakePlayer()) + vis.open_big() + big = vis._big + center = QPointF(big.contentsRect().center()) + release = QMouseEvent( + QEvent.Type.MouseButtonRelease, center, center, + Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier) + big.mouseReleaseEvent(release) + assert not big.isVisible() + + def test_off_panel_still_animates_the_big_window(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("visualizer_mode", MODE_OFF) + vis = VisualizerWidget(_FakePlayer(playing=True), prefs) + assert not vis._timer.isActive() + vis.open_big() + assert vis._timer.isActive() + vis._big.close() + assert not vis._timer.isActive() + + def test_big_window_paints_in_color_while_the_panel_is_off( + self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("visualizer_mode", MODE_OFF) + prefs.set("visualizer_style", "laser") + vis = VisualizerWidget(_FakePlayer(playing=True), prefs) + calls = [] + vis._renderer.paint = lambda p, rect, color, light: calls.append(rect) + img = QImage(10, 10, QImage.Format.Format_ARGB32) + painter = QPainter(img) + vis.paint_renderer(painter, QRectF(0, 0, 10, 10)) + assert calls == [] # the panel stays blank + vis.paint_renderer(painter, QRectF(0, 0, 10, 10), big=True) + painter.end() + assert len(calls) == 1 + + def test_the_laser_is_drawn_in_gray_when_dim(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("visualizer_mode", MODE_DIM) + prefs.set("color_visualizer_gray", 120) + prefs.set("visualizer_style", "laser") + vis = VisualizerWidget(_FakePlayer(), prefs) + colors = [] + vis._renderer.paint = lambda p, rect, color, light: colors.append(color) + img = QImage(10, 10, QImage.Format.Format_ARGB32) + painter = QPainter(img) + vis.paint_renderer(painter, QRectF(0, 0, 10, 10)) + painter.end() + assert colors[0].red() == colors[0].green() == colors[0].blue() == 120 + + def test_pending_audio_is_capped(self, qapp): + vis = VisualizerWidget(_FakePlayer()) + for _ in range(100): + vis._feed(_sine(440, 0.05), RATE) + assert vis._pending_len <= RATE * 0.25 + RATE * 0.05 + + +# -------------------------------------------------------------------------- +# 6. Preferences +# -------------------------------------------------------------------------- + +class TestPreferencesPage: + def _dialog(self, prefs): + from lintunes.gui.preferences_dialog import PreferencesDialog + + class _LastFm(QObject): + login_finished = pyqtSignal(bool, str) + status_message = pyqtSignal(str) + + def is_logged_in(self): + return False + return PreferencesDialog(prefs, _LastFm()) + + def test_style_radios_and_fade_slider(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + dialog = self._dialog(prefs) + assert dialog._viz_radios["bars"].isChecked() + assert not dialog._fade_slider.isEnabled() + + dialog._viz_radios["radial"].setChecked(True) + assert prefs.get("visualizer_style") == "radial" + assert dialog._fade_slider.isEnabled() + + dialog._fade_slider.setValue(25) + assert prefs.get("visualizer_ghost_fade") == 2.5 + + dialog._viz_radios["laser"].setChecked(True) + assert not dialog._fade_slider.isEnabled() + + def test_building_the_dialog_writes_nothing(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + prefs.set("visualizer_style", "laser") + before = (tmp_path / "preferences.json").read_text() + self._dialog(prefs) + assert (tmp_path / "preferences.json").read_text() == before