v0.41.0: visualizer sync, fade sliders, laser look controls, custom highlight

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).
This commit is contained in:
2026-10-10 01:11:09 -07:00
parent 751273b14c
commit 726f7c4a89
8 changed files with 682 additions and 135 deletions
+16 -2
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.40.0"
__version__ = "0.41.0"
+154 -28
View File
@@ -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
+88 -30
View File
@@ -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)
+179 -44
View File
@@ -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):
+11 -3
View File
@@ -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,
+15 -2
View File
@@ -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:
+218 -25
View File
@@ -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")