From 726f7c4a899fbfb650fc0b053ea7545abbc7376c Mon Sep 17 00:00:00 2001 From: trav Date: Sat, 10 Oct 2026 01:11:09 -0700 Subject: [PATCH] v0.41.0: visualizer sync, fade sliders, laser look controls, custom highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync: decoding runs ahead of the speakers — by up to ~0.8 s after a GUI stall, when buffers arrive in a burst — and the visualizer drew the newest audio. It now keeps timestamped history and draws what the player says is playing, plus a per-machine Sync delay (config.json) for latency Qt can't see, like Bluetooth. Every style has one Fade slider (0 = off); bars now leave fading steps. Laser: afterimage time, line width down to 1 px, glow on/off, tip dot on/off and size (a resting beam always shows its dot), and an oscilloscope dot grid under the beam. Visualizer sliders run the full window width. Highlight: click a swatch for a full color picker; the color picked replaces that slot (right-click restores it). --- CLAUDE.md | 18 ++- lintunes/__init__.py | 2 +- lintunes/gui/preferences_dialog.py | 182 +++++++++++++++++---- lintunes/gui/visualizer.py | 118 ++++++++++---- lintunes/gui/visualizer_styles.py | 223 ++++++++++++++++++++------ lintunes/preferences.py | 14 +- lintunes/theme.py | 17 +- tests/test_round78.py | 243 ++++++++++++++++++++++++++--- 8 files changed, 682 insertions(+), 135 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d7cc19f..052f624 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,8 +198,22 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal `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. + brighter where it moves slower (dwell); past sweeps are kept for the + afterimage time as pre-built polygons in screen metres, drawn through a + transform with a cosmetic pen. Every style's fade is one seconds slider, + 0 = off. **Sync** (Round 78): decoding runs ahead of the speakers, by a + buffer or two normally but by up to ~0.8 s when the GUI thread stalls and + buffers arrive in a burst. So the widget keeps 1.5 s of *timestamped* + history (`QAudioBuffer.startTime()`) and each frame draws the stretch + ending at `Player.position_ms()`, carried forward by the clock between + Qt's buffer-sized steps, minus `visualizer_sync_delay_ms`. That delay is + output latency Qt can't see (Bluetooth), this machine's hardware, so it + lives in **`config.json`**; the dialog emits `prefs.changed` after writing + it so the widget re-reads. The laser takes every heard sample exactly once + (`_drawn` index), never the newest-decoded ones. + The highlight swatches can be repainted with a color picker: + `highlight_palette` overrides a slot, and `theme.highlight_color` reads + through `theme.highlight_palette`, so nothing else needs to know. - **`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 673bcb0..3d31353 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.40.0" +__version__ = "0.41.0" diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index d95ea0f..4d39eb0 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -2,12 +2,14 @@ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider, QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox, QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter, - QStackedWidget, QWidget, QDialogButtonBox, QFrame, + QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton, + QColorDialog, QMenu, ) from PyQt6.QtCore import Qt, QTimer -from PyQt6.QtGui import QFont +from PyQt6.QtGui import QFont, QColor from lintunes import theme +from lintunes.config import load_config, save_config from lintunes.gui import visualizer_styles from lintunes.gui import sounds from lintunes.theme import HIGHLIGHT_COLORS @@ -26,6 +28,14 @@ _COLOR_ROWS = [ ] +# Room for the widest value a visualizer slider shows ("−180°", "400 ms"). +_VALUE_WIDTH = 56 + + +def _fade_text(seconds: float) -> str: + return "Off" if seconds <= 0 else f"{seconds:.2f} s" + + # visualizer_styles' style keys, as the radios name them. _VISUALIZER_STYLES = { "bars": "Bars", @@ -217,17 +227,29 @@ class PreferencesDialog(QDialog): size_row.addStretch() form.addRow("UI size:", size_row) - # Highlight color swatches + # Highlight color: a radio per slot, and beside it the slot's swatch, + # which opens a full color picker; the color picked replaces that + # slot's (right-click the swatch to put the original back). color_row = QHBoxLayout() self._color_group = QButtonGroup(self) - for name, hex_value in HIGHLIGHT_COLORS.items(): - radio = QRadioButton(name) - radio.setIcon(theme.swatch_icon(hex_value)) + self._color_radios, self._swatches = {}, {} + for name in HIGHLIGHT_COLORS: + swatch = QToolButton() + swatch.setAutoRaise(True) + swatch.clicked.connect(lambda _=False, n=name: self._choose_color(n)) + swatch.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + swatch.customContextMenuRequested.connect( + lambda point, n=name: self._swatch_menu(n, point)) + radio = QRadioButton() radio.setChecked(self._prefs.get("highlight") == name) radio.toggled.connect( lambda checked, n=name: checked and self._prefs.set("highlight", n)) self._color_group.addButton(radio) + self._color_radios[name], self._swatches[name] = radio, swatch + color_row.addWidget(swatch) color_row.addWidget(radio) + color_row.addSpacing(6) + self._show_palette() color_row.addStretch() form.addRow("Highlight:", color_row) @@ -265,6 +287,45 @@ class PreferencesDialog(QDialog): form.addRow("Now-playing font:", font_row) return group + def _show_palette(self): + palette = theme.highlight_palette(self._prefs) + saved = self._prefs.get("highlight_palette") or {} + for name, swatch in self._swatches.items(): + custom = name in saved + swatch.setIcon(theme.swatch_icon(palette[name])) + swatch.setToolTip( + f"{palette[name]} — click to pick a color" + + (f"; right-click to restore {name}" if custom else "")) + self._color_radios[name].setText("custom" if custom else name) + + def _pick_color(self, initial: QColor) -> QColor: + return QColorDialog.getColor(initial, self, "Highlight Color") + + def _choose_color(self, name: str): + initial = QColor(theme.highlight_palette(self._prefs)[name]) + picked = self._pick_color(initial) + if not picked.isValid(): + return # cancelled + self._set_slot(name, picked.name()) + self._color_radios[name].setChecked(True) # use what was just picked + + def _set_slot(self, name: str, value): + saved = dict(self._prefs.get("highlight_palette") or {}) + if value is None or QColor(value) == QColor(HIGHLIGHT_COLORS[name]): + saved.pop(name, None) + else: + saved[name] = value + self._prefs.set("highlight_palette", saved) + self._show_palette() + + def _swatch_menu(self, name: str, point): + if name not in (self._prefs.get("highlight_palette") or {}): + return + menu = QMenu(self) + menu.addAction(f"Restore {name.capitalize()}", + lambda: self._set_slot(name, None)) + menu.exec(self._swatches[name].mapToGlobal(point)) + def _set_font_mode(self, value): self._font_combo.setEnabled(value not in (None, "")) self._prefs.set("now_playing_font", value) @@ -351,22 +412,21 @@ class PreferencesDialog(QDialog): # Only the chosen style's settings are shown. self._viz_pages = QStackedWidget() - bars = QLabel("The classic graphic EQ — nothing to set.") - bars.setEnabled(False) + fade_tip = ("How long what's left behind takes to fade into the " + "background — 0 is no trail") + + bars = QWidget() + form = self._viz_form(bars) + self._bars_fade_slider, _ = self._viz_slider( + form, "Fade:", "visualizer_bars_fade", config, scale=20, + fmt=_fade_text, tip=fade_tip) 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()) + form = self._viz_form(radial) + self._fade_slider, _ = self._viz_slider( + form, "Fade:", "visualizer_ghost_fade", config, scale=20, + fmt=_fade_text, tip=fade_tip) self._spokes_slider, _ = self._viz_slider( form, "Frequency bands:", "visualizer_radial_spokes", config, tip="How many slices of the spectrum go around the circle") @@ -381,11 +441,27 @@ class PreferencesDialog(QDialog): 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) + form = self._viz_form(laser) + self._laser_fade_slider, _ = self._viz_slider( + form, "Afterimage:", "visualizer_laser_afterimage", config, + scale=20, fmt=_fade_text, + tip="How long past sweeps of the beam take to fade — 0 is none") + self._width_slider, _ = self._viz_slider( + form, "Line width:", "visualizer_laser_width", config, scale=2, + fmt=lambda v: f"{v:g} px") + self._glow = self._viz_check( + form, "Glow", "visualizer_laser_glow", config, + tip="The haze around the beam") + self._grid = self._viz_check( + form, "Oscilloscope grid", "visualizer_laser_grid", config, + tip="A graticule of gray dots painted under the beam") + self._head = self._viz_check( + form, "Dot at the tip of the beam", "visualizer_laser_head", + config, tip="When the music is silent the beam rests as a dot " + "in the middle either way") + self._dot_slider, _ = self._viz_slider( + form, "Dot size:", "visualizer_laser_dot", config, scale=2, + fmt=lambda v: f"{v:g} px") self._distance_slider, _ = self._viz_slider( form, "Screen distance:", "visualizer_laser_distance", config, scale=10, fmt=lambda v: f"{v:.1f} m", @@ -411,11 +487,63 @@ class PreferencesDialog(QDialog): self._viz_pages.setCurrentIndex(list(_VISUALIZER_STYLES).index(current)) column.addWidget(self._viz_pages) + + # For every style: this machine's speakers' latency (config.json). + form = QFormLayout() + self._sync_slider = self._sync_delay_slider(form) + column.addLayout(form) note = QLabel("Double-click the visualizer to see it big.") note.setEnabled(False) column.addWidget(note) return group + @staticmethod + def _viz_form(page) -> QFormLayout: + form = QFormLayout(page) + form.setContentsMargins(0, 0, 0, 0) + # Sliders run the full width of the window. + form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + return form + + def _sync_delay_slider(self, form) -> QSlider: + lo, hi = visualizer_styles.SYNC_DELAY_LIMITS + slider = QSlider(Qt.Orientation.Horizontal) + slider.setRange(lo, hi) + slider.setSingleStep(5) + slider.setPageStep(25) + slider.setToolTip( + "If the visualizer runs ahead of what you hear — Bluetooth " + "headphones especially — hold it back this much. Set per " + "computer.") + slider.setValue(visualizer_styles.sync_delay_ms(load_config())) + text = QLabel(f"{slider.value()} ms") + text.setMinimumWidth(_VALUE_WIDTH) + + def changed(value): + text.setText(f"{value} ms") + self._sync_save_timer.start() + + slider.valueChanged.connect(changed) + self._sync_save_timer = QTimer(self) + self._sync_save_timer.setSingleShot(True) + self._sync_save_timer.setInterval(300) + self._sync_save_timer.timeout.connect(self._save_sync_delay) + row = QHBoxLayout() + row.addWidget(slider, stretch=1) + row.addSpacing(8) + row.addWidget(text) + form.addRow("Sync delay:", row) + return slider + + def _save_sync_delay(self): + config = load_config() + config[visualizer_styles.SYNC_DELAY_KEY] = self._sync_slider.value() + save_config(config) + # config.json has no signal of its own; the visualizer re-reads it + # whenever preferences change. + self._prefs.changed.emit() + def _viz_check(self, form, label, key, config, tip=""): box = QCheckBox(label) box.setChecked(config[key]) # before connect @@ -431,11 +559,10 @@ class PreferencesDialog(QDialog): 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()) + text.setMinimumWidth(_VALUE_WIDTH) def changed(position): value = position / scale if scale != 1 else position @@ -445,10 +572,9 @@ class PreferencesDialog(QDialog): slider.valueChanged.connect(changed) row = QHBoxLayout() - row.addWidget(slider) + row.addWidget(slider, stretch=1) row.addSpacing(8) row.addWidget(text) - row.addStretch() form.addRow(label, row) return slider, text diff --git a/lintunes/gui/visualizer.py b/lintunes/gui/visualizer.py index e2c1971..7776046 100644 --- a/lintunes/gui/visualizer.py +++ b/lintunes/gui/visualizer.py @@ -6,6 +6,8 @@ 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 time + import numpy as np from PyQt6.QtWidgets import QWidget @@ -14,6 +16,7 @@ from PyQt6.QtGui import QPainter, QColor from PyQt6.QtMultimedia import QAudioFormat from lintunes import theme +from lintunes.config import load_config from lintunes.gui.icons import transport_icon from lintunes.gui import visualizer_styles as styles from lintunes.gui.visualizer_styles import ( @@ -23,7 +26,9 @@ from lintunes.gui.visualizer_styles import ( 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 -PENDING_S = 0.25 # most unconsumed audio kept for the laser +HISTORY_S = 1.5 # decoded audio kept, so a frame can pick what's *heard* +PENDING_S = 0.25 # the laser catches up at most this much at once +GAP_MS = 50 # a buffer this far from the last one's end is a seek # Visualizer click cycles through these brightness modes in order. MODE_ON = "on" @@ -67,10 +72,18 @@ class VisualizerWidget(QWidget): self._mode = saved if saved in _MODE_CYCLE else MODE_ON self._mode_before_press = self._mode 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 + # Decoded audio, newest last, and the stream time its end belongs + # to. Decoding runs ahead of the speakers — normally by a buffer or + # two, but by most of a second when the GUI thread stalls and the + # buffers arrive in a burst — so each frame draws the stretch the + # player says is playing *now*, not simply the newest. self._sample_rate = 44100 + self._hist = np.zeros(int(HISTORY_S * self._sample_rate), np.float32) + self._hist_end_ms = None + self._total = 0 # samples ever appended: the history's end index + self._drawn = None # index the laser has consumed up to + self._anchor = None # (position ms, monotonic s) of the last update + self._sync_delay = styles.sync_delay_ms(load_config()) 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. @@ -107,6 +120,7 @@ class VisualizerWidget(QWidget): return self._renderer.levels def _on_prefs_changed(self): + self._sync_delay = styles.sync_delay_ms(load_config()) style = _style(self._prefs) config = styles.settings(self._prefs) if style != self._renderer.name: @@ -148,28 +162,68 @@ 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._feed(mono, fmt.sampleRate() or 44100) + self._feed(mono, fmt.sampleRate() or 44100, buf.startTime() / 1000) - def _feed(self, mono, rate): - self._sample_rate = rate - if len(mono) >= HISTORY: - self._samples = mono[-HISTORY:].copy() + def _reset_stream(self): + self._hist[:] = 0.0 + self._hist_end_ms = None + self._drawn = None + self._anchor = None + + def _feed(self, mono, rate, start_ms=None): + """Append decoded audio. ``start_ms`` is its place in the stream; + without one (tests) it is taken to follow on from the last.""" + if rate != self._sample_rate: + self._sample_rate = rate + self._hist = np.zeros(int(HISTORY_S * rate), np.float32) + self._reset_stream() + elif (start_ms is not None and self._hist_end_ms is not None + and abs(start_ms - self._hist_end_ms) > GAP_MS): + self._reset_stream() # a seek or a new track: another place + n = len(mono) + if n >= len(self._hist): + self._hist[:] = mono[-len(self._hist):] 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)) + self._hist[:-n] = self._hist[n:] + self._hist[-n:] = mono + self._total += n + begin = start_ms if start_ms is not None else (self._hist_end_ms or 0.0) + self._hist_end_ms = begin + n * 1000.0 / rate - 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 + def _heard_ms(self): + """Where the speakers are in the stream, or None if the player + can't say. Qt's position moves in buffer-sized steps; between them + it is carried forward by the clock.""" + position = getattr(self._player, "position_ms", None) + if position is None or self._hist_end_ms is None: + return None + pos, now = position(), time.monotonic() + if self._anchor is None or pos != self._anchor[0]: + self._anchor = (pos, now) + heard = pos + else: + heard = pos + min((now - self._anchor[1]) * 1000.0, 100.0) + return heard - self._sync_delay + + def _frame(self, dt): + """This tick's Frame: the FFT window ending at what's heard, and + every sample between the last tick's point and this one's.""" + rate = self._sample_rate + heard = self._heard_ms() + lead_ms = 0.0 if heard is None else self._hist_end_ms - heard + lead = int(round(lead_ms * rate / 1000.0)) + lead = max(0, min(lead, len(self._hist) - HISTORY)) + end = len(self._hist) - lead + samples = self._hist[end - HISTORY:end] + now = self._total - lead + if (self._drawn is None or now < self._drawn + or now - self._drawn > PENDING_S * rate): + pending = np.zeros(0, np.float32) # a jump: start afresh + else: + count = min(now - self._drawn, end) + pending = self._hist[end - count:end].copy() + self._drawn = now + return Frame(samples, rate, pending, dt) # ---- state ---- @@ -182,7 +236,7 @@ class VisualizerWidget(QWidget): 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._drawn = None # the laser skips what played meanwhile self._timer.start() else: self._timer.stop() @@ -195,8 +249,7 @@ class VisualizerWidget(QWidget): self._clear() def _on_track_changed(self, track): - self._samples[:] = 0.0 - self._take_pending() + self._reset_stream() if track is None: self._timer.stop() self._clear() @@ -256,9 +309,7 @@ class VisualizerWidget(QWidget): # ---- frame computation ---- def _advance_frame(self): - frame = Frame(self._samples, self._sample_rate, - self._take_pending(), FRAME_MS / 1000) - if self._renderer.advance(frame): + if self._renderer.advance(self._frame(FRAME_MS / 1000)): self._repaint() # ---- painting ---- @@ -268,6 +319,13 @@ class VisualizerWidget(QWidget): return self._gray() return QColor(self.palette().highlight().color()) + def _grid_gray(self): + """The laser's graticule: the gray, faded so the beam reads over it + even when the beam is gray too.""" + gray = self._gray().darker(115) + gray.setAlphaF(0.75) + return gray + def _gray(self): """Dim mode's color.""" if self._prefs is not None: @@ -290,7 +348,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._renderer.paint(painter, rect, self._bar_color(), self._grid_gray()) def paintEvent(self, event): painter = QPainter(self) diff --git a/lintunes/gui/visualizer_styles.py b/lintunes/gui/visualizer_styles.py index 5716c8e..0392d54 100644 --- a/lintunes/gui/visualizer_styles.py +++ b/lintunes/gui/visualizer_styles.py @@ -37,6 +37,7 @@ DECAY = 0.82 # slow decay; attack is instant FLOOR_DB = -55.0 LOW_HZ = 40.0 INSET = 3 # the drawing sits 3px in from every edge +GHOST_ALPHA = 0.7 # what a falling bar or dot leaves starts this strong 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 @@ -44,7 +45,11 @@ 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_bars_fade": (0.0, 3.0), # seconds; 0 = off + "visualizer_ghost_fade": (0.0, 3.0), + "visualizer_laser_afterimage": (0.0, 3.0), + "visualizer_laser_width": (1.0, 8.0), # pixels + "visualizer_laser_dot": (1.0, 16.0), # pixels across "visualizer_radial_spokes": (12, 360), "visualizer_radial_levels": (4, 40), "visualizer_laser_distance": (1.0, 4.0), # metres, mirror → screen @@ -52,10 +57,24 @@ LIMITS = { "visualizer_laser_yaw": (-60, 60), "visualizer_laser_roll": (-180, 180), } -_FLAGS = ("visualizer_radial_fade", "visualizer_radial_normalize", - "visualizer_laser_fade") +_FLAGS = ("visualizer_radial_normalize", "visualizer_laser_head", + "visualizer_laser_glow", "visualizer_laser_grid") _INTEGER = ("visualizer_radial_spokes", "visualizer_radial_levels") +# How much later than Qt's playback position the speakers really are — +# PipeWire's buffer, and far more over Bluetooth. That's this machine's +# audio hardware, so it lives in config.json, never the synced prefs. +SYNC_DELAY_KEY = "visualizer_sync_delay_ms" +SYNC_DELAY_LIMITS = (0, 400) + + +def sync_delay_ms(config: dict) -> int: + try: + value = int(config.get(SYNC_DELAY_KEY, 0)) + except (TypeError, ValueError): + return 0 + return min(SYNC_DELAY_LIMITS[1], max(SYNC_DELAY_LIMITS[0], value)) + def settings(prefs) -> dict: """Every visualizer setting, defaulted and clamped.""" @@ -145,44 +164,83 @@ def _faded(color: QColor, alpha: float) -> QColor: # -------------------------------------------------------------------------- class BarsRenderer: + """The 20-band graphic EQ. With a fade time, the steps a falling bar + leaves behind fade from its color into the background, rather than + vanishing — the radial's ghosts, as blocks.""" + name = STYLE_BARS def __init__(self, config=None): self.levels = np.zeros(BANDS) self.bars = [0] * BANDS # quantized 0..STEPS + # (band, step) → when the bar last fell past it; -inf = never. + self.ghosts = np.full((BANDS, STEPS + 1), -np.inf) + self.clock = 0.0 + self.configure(config or settings(None)) def configure(self, config): - pass + self.fade = config["visualizer_bars_fade"] + if self.fade <= 0: + self.ghosts[:] = -np.inf def clear(self): self.levels[:] = 0.0 self.bars = [0] * BANDS + self.ghosts[:] = -np.inf + + def ghost_alpha(self) -> np.ndarray: + if self.fade <= 0: + return np.zeros_like(self.ghosts) + return np.clip(1.0 - (self.clock - self.ghosts) / self.fade, 0.0, 1.0) def advance(self, frame: Frame) -> bool: + self.clock += frame.dt 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] - if bars == self.bars: + if self.fade > 0: + old, new = np.array(self.bars), np.array(bars) + k = np.arange(STEPS + 1)[None, :] + # Only a fall leaves anything: a rise covers its own steps. + vacated = (k > new[:, None]) & (k <= old[:, None]) + self.ghosts[vacated] = self.clock + self.ghosts[self.clock - self.ghosts >= self.fade] = -np.inf + fading = self.fade > 0 and np.isfinite(self.ghosts).any() + if bars == self.bars and not fading: return False self.bars = bars return True - def paint(self, painter, rect, color: QColor): + def paint(self, painter, rect, color: QColor, gray: 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 + + def column(i): 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) + return x, max(1, end - x - gap) + + alpha = self.ghost_alpha() + if alpha.any(): + for i, k in zip(*np.nonzero(alpha)): + if k <= self.bars[i]: + continue # under the bar itself + x, w = column(i) + top = baseline - int(k * step_height) + bottom = baseline - int((k - 1) * step_height) + painter.fillRect(x, top, w, bottom - top, + _faded(color, GHOST_ALPHA * alpha[i, k])) + for i, bar in enumerate(self.bars): + if bar <= 0: + continue + x, w = column(i) bar_height = int(bar * step_height) painter.fillRect(x, baseline - bar_height, w, bar_height, color) @@ -193,7 +251,6 @@ class BarsRenderer: 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 = 0.7 # a fresh ghost: most of the way to the dot's color GHOST_BUCKETS = 8 # one drawPoints() per bucket of similar alpha 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 @@ -227,8 +284,7 @@ class RadialRenderer: 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.fade = config["visualizer_ghost_fade"] self.normalize = config["visualizer_radial_normalize"] spokes = config["visualizer_radial_spokes"] levels = config["visualizer_radial_levels"] @@ -329,7 +385,7 @@ class RadialRenderer: alpha[:, 0] = 0.0 # silence isn't drawn return rings, alpha, angle - def paint(self, painter, rect, color: QColor): + def paint(self, painter, rect, color: QColor, gray: QColor = None): cx, cy = rect.center().x(), rect.center().y() radius = min(rect.width(), rect.height()) / 2 - INSET if radius <= 2: @@ -425,9 +481,12 @@ FILL = 0.85 # at the farthest distance, the loudest swings fill this 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.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 +AFTERIMAGE_ALPHA = 0.6 # a just-past sweep, before its fade begins +AFTERIMAGE_STEP = 3 # older sweeps keep every 3rd point: they're faint CHUNK = 256 # samples per vectorized resonator step ROOM = 0.95 # the figure never comes closer to the edge than this +GRID_HALF = 4 # graticule divisions from the center to the short edge +GRID_TICKS = 5 # fine ticks per division along the center lines def _bessel_j(m: int, x: float) -> float: @@ -488,13 +547,22 @@ class LaserRenderer: def __init__(self, config=None): self.levels = np.zeros(1) # the widget's shared clear() touches it self._rate = None - self.paths = [] # newest first: (n, 2) arrays, metres on the screen + # Newest first: (path, born, polygon). A path is (n, 2) metres on the + # screen; the polygon is the decimated copy an afterimage is drawn + # from, built once when the sweep stops being the newest. + self.paths = [] + self.clock = 0.0 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.afterimage = config["visualizer_laser_afterimage"] + self.width = config["visualizer_laser_width"] + self.dot = config["visualizer_laser_dot"] + self.head = config["visualizer_laser_head"] + self.glow = config["visualizer_laser_glow"] + self.grid = config["visualizer_laser_grid"] + self._prune() self.distance = config["visualizer_laser_distance"] rot = _rotation(config["visualizer_laser_pitch"], config["visualizer_laser_yaw"], @@ -512,7 +580,14 @@ class LaserRenderer: 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))] + self.paths = [(np.zeros((1, 2)), self.clock, None)] + + def _prune(self): + """Drop sweeps whose afterimage has faded (all but the newest, when + the afterimage is off).""" + keep = self.paths[:1] + [entry for entry in self.paths[1:] + if self.clock - entry[1] < self.afterimage] + self.paths = keep # ---- the membrane ---- @@ -584,18 +659,26 @@ class LaserRenderer: return self.on_screen(2 * gain * tilt) def advance(self, frame: Frame) -> bool: + self.clock += frame.dt tilt = self.drive(frame.pending, frame.rate) if len(tilt) == 0: - return False + self._prune() + return len(self.paths) > 1 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(self.persist) - 1] + last, born, _ = self.paths[0] + path = np.vstack([last[-1:], path]) + older = [] + if self.afterimage > 0: + thin = np.vstack([last[::AFTERIMAGE_STEP], last[-1:]]) + older = [(last, born, QPolygonF([QPointF(x, y) for x, y in thin]))] + self.paths = [(path, self.clock, None)] + older + self.paths[1:] + self._prune() return True def spot(self) -> np.ndarray: - return self.paths[0][-1] + return self.paths[0][0][-1] def scale(self, rect) -> float: """Pixels per metre: the screen's half-height fills the rect's, and @@ -608,31 +691,78 @@ class LaserRenderer: return unit * min(1.0, ROOM * half_w / (reach_x * unit), ROOM * half_h / (reach_y * unit)) - def paint(self, painter, rect, color: QColor): + def paint_grid(self, painter, rect, gray: QColor): + """An oscilloscope's graticule: dots at every division crossing, and + finer ticks along the two center lines. Painted *on the glass*, so + it stays put in the window whatever the screen's angle or distance.""" + cx, cy = rect.center().x(), rect.center().y() + division = (min(rect.width(), rect.height()) / 2 - INSET) / GRID_HALF + if division < 4: + return + nx = int((rect.width() / 2 - INSET) // division) + ny = int((rect.height() / 2 - INSET) // division) + major = [QPointF(cx + i * division, cy + j * division) + for i in range(-nx, nx + 1) for j in range(-ny, ny + 1)] + fine = division / GRID_TICKS + ticks = [QPointF(cx + k * fine, cy) + for k in range(-nx * GRID_TICKS, nx * GRID_TICKS + 1) + if k % GRID_TICKS] + ticks += [QPointF(cx, cy + k * fine) + for k in range(-ny * GRID_TICKS, ny * GRID_TICKS + 1) + if k % GRID_TICKS] + pen = QPen(gray) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setWidthF(max(1.5, division / 12)) + painter.setPen(pen) + painter.drawPoints(major) + pen.setWidthF(max(1.0, division / 20)) + painter.setPen(pen) + painter.drawPoints(ticks) + + def paint(self, painter, rect, color: QColor, gray: QColor = None): cx, cy = rect.center().x(), rect.center().y() half = min(rect.width(), rect.height()) / 2 - INSET if half <= 2: return + if self.grid and gray is not None: + self.paint_grid(painter, rect, gray) # under the beam 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, self.persist))): - if len(path) < 2: - continue + + # The afterimage: past sweeps, oldest first, fading with age. Drawn + # in screen metres through a transform, so each polygon is built + # once; a cosmetic pen keeps the line width in real pixels. + if len(self.paths) > 1: + painter.save() + painter.translate(cx, cy) + painter.scale(px, -px) + pen.setCosmetic(True) + pen.setWidthF(self.width) + for _path, born, polygon in reversed(self.paths[1:]): + strength = 1.0 - (self.clock - born) / self.afterimage + if strength <= 0: + continue + pen.setColor(_faded(color, AFTERIMAGE_ALPHA * strength)) + painter.setPen(pen) + painter.drawPolyline(polygon) + painter.restore() + pen.setCosmetic(False) + + path = self.paths[0][0] + xy = np.column_stack([cx + path[:, 0] * px, cy - path[:, 1] * px]) + if len(path) >= 2: # 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] * px, cy - path[:, 1] * px]) + xy = np.vstack([xy[::step], xy[-1:]]) # 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 == self.persist[0]: + if self.glow: line = QPolygonF([QPointF(x, y) for x, y in xy]) - for width, strength in ((7 * s, 0.07), (3 * s, 0.18)): + for width, strength in ((6 * self.width, 0.07), + (2.5 * self.width, 0.18)): pen.setWidthF(width) pen.setColor(_faded(color, strength)) painter.setPen(pen) @@ -643,22 +773,27 @@ class LaserRenderer: 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) + pen.setWidthF(self.width) for level in range(1, 7): idx = np.nonzero(bucket == level)[0] if len(idx) == 0: continue - pen.setColor(_faded(color, persist * level / 6)) + pen.setColor(_faded(color, 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 * px, cy - y * px) - 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) + + # The beam itself. At rest it is all there is, so it always shows; + # while the music moves it, it's the "head" setting's call. + resting = np.ptp(xy, axis=0).max() < 1.0 + if self.head or resting: + head = QPointF(*xy[-1]) + radius = self.dot / 2 + painter.setPen(Qt.PenStyle.NoPen) + if self.glow: + painter.setBrush(_faded(color, 0.25)) + painter.drawEllipse(head, radius * 2.5, radius * 2.5) + painter.setBrush(color) + painter.drawEllipse(head, radius, radius) def make_renderer(style: str, config=None): diff --git a/lintunes/preferences.py b/lintunes/preferences.py index 1eeeeef..92e1cbc 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -8,18 +8,26 @@ from PyQt6.QtCore import QObject, pyqtSignal DEFAULTS = { "ui_scale": "medium", # small | medium | large "highlight": "blue", # see theme.HIGHLIGHT_COLORS + "highlight_palette": {}, # slot name -> "#rrggbb" the user picked "now_playing_font": None, # None=prefer Century Gothic, ""=use app # 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) # 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 + # Fade times in seconds, 0 = off: how long what a bar or dot left + # behind, or the laser's past sweeps, take to fade into the background. + "visualizer_bars_fade": 0.0, + "visualizer_ghost_fade": 1.0, # the radial's + "visualizer_laser_afterimage": 0.1, + "visualizer_laser_width": 1.5, # pixels + "visualizer_laser_dot": 3.0, # pixels across + "visualizer_laser_head": True, # a dot at the moving beam's tip + "visualizer_laser_glow": True, # the haze/scatter bloom + "visualizer_laser_grid": False, # an oscilloscope's dot graticule "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, diff --git a/lintunes/theme.py b/lintunes/theme.py index 5b48b3f..5275d2c 100644 --- a/lintunes/theme.py +++ b/lintunes/theme.py @@ -114,9 +114,22 @@ def swatch_icon(color_hex: str, size: int = 18) -> QIcon: return QIcon(pixmap) +def highlight_palette(prefs) -> dict: + """The highlight swatches as this user has them: the stock colors, with + any slot they repainted (Preferences → a swatch → color picker) in its + place. Saved in ``highlight_palette`` as {slot name: "#rrggbb"}.""" + palette = dict(HIGHLIGHT_COLORS) + saved = prefs.get("highlight_palette") or {} + if isinstance(saved, dict): + for name, value in saved.items(): + if name in palette and QColor.isValidColorName(str(value)): + palette[name] = QColor(str(value)).name() + return palette + + def highlight_color(prefs) -> QColor: - return QColor(HIGHLIGHT_COLORS.get(prefs.get("highlight"), - HIGHLIGHT_COLORS["blue"])) + palette = highlight_palette(prefs) + return QColor(palette.get(prefs.get("highlight"), palette["blue"])) def scale_metrics(prefs) -> dict: diff --git a/tests/test_round78.py b/tests/test_round78.py index f8dbeb8..d4f7c0a 100644 --- a/tests/test_round78.py +++ b/tests/test_round78.py @@ -196,7 +196,7 @@ class TestRadial: assert not np.isfinite(r.ghosts).any() # forgotten, not just faint def test_fading_off_means_no_ghosts(self): - r = vs.RadialRenderer(_config(radial_fade=False)) + r = vs.RadialRenderer(_config(ghost_fade=0)) r.advance(_frame(_tone(1000, amp=0.8))) r.advance(_frame(np.zeros(vs.HISTORY, np.float32))) assert r.ghost_alpha().max() == 0 @@ -275,7 +275,7 @@ def _run(renderer, audio, hop=706): class TestLaser: def test_silence_is_a_still_dot_in_the_middle(self): r = _run(vs.LaserRenderer(), np.zeros(RATE // 4, np.float32)) - assert np.allclose(r.paths[0], 0.0) + assert np.allclose(r.paths[0][0], 0.0) def test_the_membrane_answers_its_resonance(self): f = vs.MODES[1][0] @@ -305,14 +305,14 @@ class TestLaser: 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() + ratio = np.abs(far.paths[0][0]).max() / np.abs(near.paths[0][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] + path = _run(vs.LaserRenderer(_config(**angles)), audio).paths[0][0] return np.ptp(path[:, 0]), np.ptp(path[:, 1]) square_x, square_y = spread() @@ -320,8 +320,8 @@ class TestLaser: 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] + spun = _run(vs.LaserRenderer(_config(laser_roll=90)), audio).paths[0][0] + flat = _run(vs.LaserRenderer(_config()), audio).paths[0][0] # A quarter turn: x becomes y (up to sign). assert np.allclose(np.abs(spun[:, 0]), np.abs(flat[:, 1]), atol=1e-9) @@ -339,18 +339,93 @@ class TestLaser: biggest = 0.0 for i in range(0, len(audio) - 706, 706): r.advance(_frame(np.zeros(vs.HISTORY, np.float32), audio[i:i + 706])) - px = r.paths[0] * r.scale(rect) + px = r.paths[0][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) + def test_afterimage_lasts_its_fade_time(self): + audio = _sine(150, 1.0) + r = _run(vs.LaserRenderer(_config(laser_afterimage=0.2)), audio) + ages = [r.clock - born for _p, born, _poly in r.paths[1:]] + assert ages and max(ages) < 0.2 + 1e-9 + assert 10 <= len(r.paths) <= 14 # 0.2 s of 16 ms frames + + def test_afterimage_zero_keeps_only_this_frame(self): + r = _run(vs.LaserRenderer(_config(laser_afterimage=0)), _sine(150, 0.5)) assert len(r.paths) == 1 + def test_head_dot_and_glow_settings(self, qapp): + audio = _sine(150, 0.5) + + def painted(**kw): + r = _run(vs.LaserRenderer(_config(**kw)), audio) + img = _paint(r, 400, 250) + return sum(QColor(img.pixel(x, y)) != QColor("white") + for x in range(0, 400, 2) for y in range(0, 250, 2)) + + full = painted() + assert painted(laser_glow=False) < full + assert painted(laser_head=False) <= full + assert painted(laser_width=6) > painted(laser_width=1) + + def test_grid_is_drawn_under_the_beam_only_when_on(self, qapp): + calls = [] + + def paint(renderer, gray): + renderer.paint_grid = lambda painter, rect, g: calls.append(g) + img = QImage(400, 250, QImage.Format.Format_ARGB32) + painter = QPainter(img) + renderer.paint(painter, QRectF(0, 0, 400, 250), QColor("blue"), gray) + painter.end() + + paint(vs.LaserRenderer(_config(laser_grid=False)), QColor("gray")) + assert calls == [] + paint(vs.LaserRenderer(_config(laser_grid=True)), QColor("gray")) + assert len(calls) == 1 + + def test_grid_dots_land_on_divisions(self, qapp): + r = vs.LaserRenderer(_config(laser_grid=True)) + img = QImage(400, 250, QImage.Format.Format_ARGB32) + img.fill(QColor("white")) + painter = QPainter(img) + r.paint_grid(painter, QRectF(0, 0, 400, 250), QColor("black")) + painter.end() + d = (125 - vs.INSET) / vs.GRID_HALF + on = QColor(img.pixel(200 + round(d), 125 + round(d))) + between = QColor(img.pixel(200 + round(d / 2), 125 + round(d / 2))) + assert on != QColor("white") and between == QColor("white") + + def test_resting_beam_always_shows_its_dot(self, qapp): + r = _run(vs.LaserRenderer(_config(laser_head=False, laser_dot=10)), + np.zeros(RATE // 4, np.float32)) + img = _paint(r, 400, 250) + assert QColor(img.pixel(200, 125)) != QColor("white") + + +class TestBarsFade: + def test_a_falling_bar_leaves_fading_steps(self): + r = vs.BarsRenderer(_config(bars_fade=0.5)) + r.advance(_frame(_tone(1000, 0.8))) + band = int(np.argmax(r.bars)) + top = r.bars[band] + for _ in range(10): + r.advance(_frame(np.zeros(vs.HISTORY, np.float32))) + assert r.bars[band] < top + assert 0 < r.ghost_alpha()[band, top] < 1 + + def test_a_rise_leaves_nothing(self): + r = vs.BarsRenderer(_config(bars_fade=1.0)) + r.advance(_frame(_tone(1000, 0.8))) + assert not np.isfinite(r.ghosts).any() + + def test_zero_is_the_classic_bars(self): + r = vs.BarsRenderer(_config(bars_fade=0)) + r.advance(_frame(_tone(1000, 0.8))) + r.advance(_frame(np.zeros(vs.HISTORY, np.float32))) + assert r.ghost_alpha().max() == 0 + # -------------------------------------------------------------------------- # 5. Double-click → big window @@ -400,7 +475,7 @@ class TestBigWindow: prefs.set("visualizer_style", "laser") vis = VisualizerWidget(_FakePlayer(playing=True), prefs) calls = [] - vis._renderer.paint = lambda p, rect, color: calls.append(rect) + vis._renderer.paint = lambda p, rect, color, gray=None: calls.append(rect) img = QImage(10, 10, QImage.Format.Format_ARGB32) painter = QPainter(img) vis.paint_renderer(painter, QRectF(0, 0, 10, 10)) @@ -416,18 +491,81 @@ class TestBigWindow: prefs.set("visualizer_style", "laser") vis = VisualizerWidget(_FakePlayer(), prefs) colors = [] - vis._renderer.paint = lambda p, rect, color: colors.append(color) + vis._renderer.paint = lambda p, rect, color, gray=None: 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): + + +# -------------------------------------------------------------------------- +# 5b. Lined up with what's heard +# -------------------------------------------------------------------------- + +class _PositionedPlayer(_FakePlayer): + def __init__(self): + super().__init__(playing=True) + self.position = 0 + + def position_ms(self): + return self.position + + +def _marked(ms, rate=RATE): + """``ms`` of audio whose every sample is its own stream time.""" + return (np.arange(int(ms * rate / 1000)) * 1000.0 / rate).astype(np.float32) + + +class TestSync: + def test_frame_ends_at_what_is_heard_not_the_newest(self, qapp, monkeypatch, + tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + player = _PositionedPlayer() + vis = VisualizerWidget(player) + audio = _marked(1000) + vis._feed(audio, RATE, start_ms=0) # a burst: decoded 1 s ahead + player.position = 400 # …but the speakers are at 0.4 s + frame = vis._frame(0.016) + assert abs(frame.samples[-1] - 400) < 2 + assert len(frame.samples) == vs.HISTORY + + def test_no_position_means_the_newest(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 + vis._feed(_marked(500), RATE) + assert abs(vis._frame(0.016).samples[-1] - 500) < 2 + + def test_the_laser_gets_each_heard_sample_once(self, qapp, monkeypatch, + tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + player = _PositionedPlayer() + vis = VisualizerWidget(player) + vis._feed(_marked(1000), RATE, start_ms=0) + got = [] + for position in (100, 116, 132, 150): + player.position = position + vis._anchor = None # no clock extrapolation in a test + got.append(vis._frame(0.016).pending) + joined = np.concatenate(got[1:]) + assert abs(joined[0] - 100) < 1 and abs(joined[-1] - 150) < 1 + assert np.all(np.diff(joined) > 0) # in order, nothing repeated + + def test_a_seek_starts_a_fresh_history(self, qapp): + vis = VisualizerWidget(_FakePlayer()) + vis._feed(np.ones(4410, np.float32), RATE, start_ms=0) + vis._feed(np.full(4410, 2, np.float32), RATE, start_ms=60000) + assert vis._hist[:-4410].max() == 0 + + def test_sync_delay_comes_from_config(self, qapp, monkeypatch, tmp_path): + from lintunes.config import save_config + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + save_config({"visualizer_sync_delay_ms": 200}) + player = _PositionedPlayer() + vis = VisualizerWidget(player) + vis._feed(_marked(1000), RATE, start_ms=0) + player.position = 600 + assert abs(vis._frame(0.016).samples[-1] - 400) < 2 # -------------------------------------------------------------------------- @@ -461,12 +599,10 @@ class TestPreferencesPage: def test_radial_settings(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) - assert dialog._fade_slider.isEnabled() - dialog._fade_slider.setValue(25) + dialog._fade_slider.setValue(50) # twentieths of a second assert prefs.get("visualizer_ghost_fade") == 2.5 - dialog._radial_fade.setChecked(False) - assert prefs.get("visualizer_radial_fade") is False - assert not dialog._fade_slider.isEnabled() + dialog._fade_slider.setValue(0) + assert prefs.get("visualizer_ghost_fade") == 0 dialog._spokes_slider.setValue(60) dialog._levels_slider.setValue(10) dialog._normalize.setChecked(False) @@ -474,20 +610,77 @@ class TestPreferencesPage: prefs.get("visualizer_radial_levels"), prefs.get("visualizer_radial_normalize")) == (60, 10, False) + def test_every_style_has_a_fade_slider(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + dialog = self._dialog(prefs) + for slider, key in ((dialog._bars_fade_slider, "visualizer_bars_fade"), + (dialog._fade_slider, "visualizer_ghost_fade"), + (dialog._laser_fade_slider, + "visualizer_laser_afterimage")): + assert slider.minimum() == 0 # 0 = off + slider.setValue(10) + assert prefs.get(key) == 0.5 + def test_laser_settings(self, qapp, tmp_path): prefs = Preferences(tmp_path) dialog = self._dialog(prefs) - dialog._laser_fade.setChecked(False) + dialog._grid.setChecked(True) + assert prefs.get("visualizer_laser_grid") is True + dialog._glow.setChecked(False) + dialog._head.setChecked(False) + dialog._width_slider.setValue(dialog._width_slider.minimum()) + dialog._dot_slider.setValue(12) 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_glow") is False + assert prefs.get("visualizer_laser_head") is False + assert prefs.get("visualizer_laser_width") == 1 # one pixel + assert prefs.get("visualizer_laser_dot") == 6 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_sync_delay_saves_to_config_not_prefs(self, qapp, tmp_path, + monkeypatch): + from lintunes.config import load_config + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) + prefs = Preferences(tmp_path) + dialog = self._dialog(prefs) + dialog._sync_slider.setValue(150) + dialog._save_sync_delay() + assert load_config()["visualizer_sync_delay_ms"] == 150 + assert prefs.get("visualizer_sync_delay_ms") is None + + def test_a_swatch_repaints_its_slot(self, qapp, tmp_path): + from lintunes import theme + prefs = Preferences(tmp_path) + dialog = self._dialog(prefs) + dialog._pick_color = lambda initial: QColor("#ff3300") + dialog._choose_color("seafoam") + assert prefs.get("highlight") == "seafoam" + assert theme.highlight_color(prefs).name() == "#ff3300" + assert dialog._color_radios["seafoam"].text() == "custom" + assert theme.highlight_palette(prefs)["blue"] == "#3584E4" + dialog._set_slot("seafoam", None) # right-click → restore + assert theme.highlight_palette(prefs)["seafoam"] == "#7FD1AE" + assert dialog._color_radios["seafoam"].text() == "seafoam" + + def test_cancelling_the_picker_changes_nothing(self, qapp, tmp_path): + prefs = Preferences(tmp_path) + dialog = self._dialog(prefs) + dialog._pick_color = lambda initial: QColor() # invalid = cancel + dialog._choose_color("magenta") + assert prefs.get("highlight_palette") == {} + assert prefs.get("highlight") == "blue" + + def test_a_bad_saved_color_falls_back(self, qapp, tmp_path): + from lintunes import theme + prefs = Preferences(tmp_path) + prefs.set("highlight_palette", {"blue": "not a color", "nope": "#000"}) + assert theme.highlight_palette(prefs) == theme.HIGHLIGHT_COLORS + def test_building_the_dialog_writes_nothing(self, qapp, tmp_path): prefs = Preferences(tmp_path) prefs.set("visualizer_style", "laser")