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 `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 *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 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 brighter where it moves slower (dwell); past sweeps are kept for the
newest FFT window, hence the widget's capped `_pending` queue. 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. - **`lintunes/importers/itunes_importer.py`** — parses the iTunes XML plist.
Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.40.0" __version__ = "0.41.0"
+154 -28
View File
@@ -2,12 +2,14 @@ from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider, QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox, QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter, QFormLayout, QFontComboBox, QListWidget, QScrollArea, QSplitter,
QStackedWidget, QWidget, QDialogButtonBox, QFrame, QStackedWidget, QWidget, QDialogButtonBox, QFrame, QToolButton,
QColorDialog, QMenu,
) )
from PyQt6.QtCore import Qt, QTimer from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont, QColor
from lintunes import theme from lintunes import theme
from lintunes.config import load_config, save_config
from lintunes.gui import visualizer_styles from lintunes.gui import visualizer_styles
from lintunes.gui import sounds from lintunes.gui import sounds
from lintunes.theme import HIGHLIGHT_COLORS from lintunes.theme import HIGHLIGHT_COLORS
@@ -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' style keys, as the radios name them.
_VISUALIZER_STYLES = { _VISUALIZER_STYLES = {
"bars": "Bars", "bars": "Bars",
@@ -217,17 +227,29 @@ class PreferencesDialog(QDialog):
size_row.addStretch() size_row.addStretch()
form.addRow("UI size:", size_row) 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() color_row = QHBoxLayout()
self._color_group = QButtonGroup(self) self._color_group = QButtonGroup(self)
for name, hex_value in HIGHLIGHT_COLORS.items(): self._color_radios, self._swatches = {}, {}
radio = QRadioButton(name) for name in HIGHLIGHT_COLORS:
radio.setIcon(theme.swatch_icon(hex_value)) 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.setChecked(self._prefs.get("highlight") == name)
radio.toggled.connect( radio.toggled.connect(
lambda checked, n=name: checked and self._prefs.set("highlight", n)) lambda checked, n=name: checked and self._prefs.set("highlight", n))
self._color_group.addButton(radio) self._color_group.addButton(radio)
self._color_radios[name], self._swatches[name] = radio, swatch
color_row.addWidget(swatch)
color_row.addWidget(radio) color_row.addWidget(radio)
color_row.addSpacing(6)
self._show_palette()
color_row.addStretch() color_row.addStretch()
form.addRow("Highlight:", color_row) form.addRow("Highlight:", color_row)
@@ -265,6 +287,45 @@ class PreferencesDialog(QDialog):
form.addRow("Now-playing font:", font_row) form.addRow("Now-playing font:", font_row)
return group 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): def _set_font_mode(self, value):
self._font_combo.setEnabled(value not in (None, "")) self._font_combo.setEnabled(value not in (None, ""))
self._prefs.set("now_playing_font", value) self._prefs.set("now_playing_font", value)
@@ -351,22 +412,21 @@ class PreferencesDialog(QDialog):
# Only the chosen style's settings are shown. # Only the chosen style's settings are shown.
self._viz_pages = QStackedWidget() self._viz_pages = QStackedWidget()
bars = QLabel("The classic graphic EQ — nothing to set.") fade_tip = ("How long what's left behind takes to fade into the "
bars.setEnabled(False) "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) self._viz_pages.addWidget(bars)
radial = QWidget() radial = QWidget()
form = QFormLayout(radial) form = self._viz_form(radial)
form.setContentsMargins(0, 0, 0, 0) self._fade_slider, _ = self._viz_slider(
self._radial_fade = self._viz_check( form, "Fade:", "visualizer_ghost_fade", config, scale=20,
form, "Trails fade out", "visualizer_radial_fade", config) fmt=_fade_text, tip=fade_tip)
self._fade_slider, self._fade_label = self._viz_slider(
form, "Fade time:", "visualizer_ghost_fade", config, scale=10,
fmt=lambda v: f"{v:.1f} s")
self._radial_fade.toggled.connect(self._fade_slider.setEnabled)
self._radial_fade.toggled.connect(self._fade_label.setEnabled)
self._fade_slider.setEnabled(self._radial_fade.isChecked())
self._fade_label.setEnabled(self._radial_fade.isChecked())
self._spokes_slider, _ = self._viz_slider( self._spokes_slider, _ = self._viz_slider(
form, "Frequency bands:", "visualizer_radial_spokes", config, form, "Frequency bands:", "visualizer_radial_spokes", config,
tip="How many slices of the spectrum go around the circle") tip="How many slices of the spectrum go around the circle")
@@ -381,11 +441,27 @@ class PreferencesDialog(QDialog):
self._viz_pages.addWidget(radial) self._viz_pages.addWidget(radial)
laser = QWidget() laser = QWidget()
form = QFormLayout(laser) form = self._viz_form(laser)
form.setContentsMargins(0, 0, 0, 0) self._laser_fade_slider, _ = self._viz_slider(
self._laser_fade = self._viz_check( form, "Afterimage:", "visualizer_laser_afterimage", config,
form, "Afterimage (recent sweeps fade out)", scale=20, fmt=_fade_text,
"visualizer_laser_fade", config) 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( self._distance_slider, _ = self._viz_slider(
form, "Screen distance:", "visualizer_laser_distance", config, form, "Screen distance:", "visualizer_laser_distance", config,
scale=10, fmt=lambda v: f"{v:.1f} m", 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)) self._viz_pages.setCurrentIndex(list(_VISUALIZER_STYLES).index(current))
column.addWidget(self._viz_pages) 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 = QLabel("Double-click the visualizer to see it big.")
note.setEnabled(False) note.setEnabled(False)
column.addWidget(note) column.addWidget(note)
return group 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=""): def _viz_check(self, form, label, key, config, tip=""):
box = QCheckBox(label) box = QCheckBox(label)
box.setChecked(config[key]) # before connect box.setChecked(config[key]) # before connect
@@ -431,11 +559,10 @@ class PreferencesDialog(QDialog):
lo, hi = visualizer_styles.LIMITS[key] lo, hi = visualizer_styles.LIMITS[key]
slider = QSlider(Qt.Orientation.Horizontal) slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(round(lo * scale), round(hi * scale)) slider.setRange(round(lo * scale), round(hi * scale))
slider.setFixedWidth(160)
slider.setToolTip(tip) slider.setToolTip(tip)
slider.setValue(round(config[key] * scale)) # before connect slider.setValue(round(config[key] * scale)) # before connect
text = QLabel(fmt(config[key])) text = QLabel(fmt(config[key]))
text.setMinimumWidth(QLabel("−180°").sizeHint().width()) text.setMinimumWidth(_VALUE_WIDTH)
def changed(position): def changed(position):
value = position / scale if scale != 1 else position value = position / scale if scale != 1 else position
@@ -445,10 +572,9 @@ class PreferencesDialog(QDialog):
slider.valueChanged.connect(changed) slider.valueChanged.connect(changed)
row = QHBoxLayout() row = QHBoxLayout()
row.addWidget(slider) row.addWidget(slider, stretch=1)
row.addSpacing(8) row.addSpacing(8)
row.addWidget(text) row.addWidget(text)
row.addStretch()
form.addRow(label, row) form.addRow(label, row)
return slider, text 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``); frame turns into is the chosen style's business (``visualizer_styles``);
this widget owns intake, timing, on/dim/off and color. this widget owns intake, timing, on/dim/off and color.
""" """
import time
import numpy as np import numpy as np
from PyQt6.QtWidgets import QWidget from PyQt6.QtWidgets import QWidget
@@ -14,6 +16,7 @@ from PyQt6.QtGui import QPainter, QColor
from PyQt6.QtMultimedia import QAudioFormat from PyQt6.QtMultimedia import QAudioFormat
from lintunes import theme from lintunes import theme
from lintunes.config import load_config
from lintunes.gui.icons import transport_icon from lintunes.gui.icons import transport_icon
from lintunes.gui import visualizer_styles as styles from lintunes.gui import visualizer_styles as styles
from lintunes.gui.visualizer_styles import ( from lintunes.gui.visualizer_styles import (
@@ -23,7 +26,9 @@ from lintunes.gui.visualizer_styles import (
FRAME_MS = 16 # ~60 fps FRAME_MS = 16 # ~60 fps
RADIUS = 8 # rounded corners, matching the transport button boxes RADIUS = 8 # rounded corners, matching the transport button boxes
ICON_SIZE = 40 # the cast glyph shown in place of bars while casting ICON_SIZE = 40 # the cast glyph shown in place of bars while casting
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. # Visualizer click cycles through these brightness modes in order.
MODE_ON = "on" MODE_ON = "on"
@@ -67,10 +72,18 @@ class VisualizerWidget(QWidget):
self._mode = saved if saved in _MODE_CYCLE else MODE_ON self._mode = saved if saved in _MODE_CYCLE else MODE_ON
self._mode_before_press = self._mode self._mode_before_press = self._mode
self._renderer = make_renderer(_style(prefs), styles.settings(prefs)) self._renderer = make_renderer(_style(prefs), styles.settings(prefs))
self._samples = np.zeros(HISTORY, dtype=np.float32) # Decoded audio, newest last, and the stream time its end belongs
self._pending = [] # every sample since the last frame (the laser) # to. Decoding runs ahead of the speakers — normally by a buffer or
self._pending_len = 0 # 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._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 self._big = None # VisualizerWindow, made on first double-click
# True while audio is coming out of something other than this machine # True while audio is coming out of something other than this machine
# (a Chromecast), so there is no decoded PCM to draw. # (a Chromecast), so there is no decoded PCM to draw.
@@ -107,6 +120,7 @@ class VisualizerWidget(QWidget):
return self._renderer.levels return self._renderer.levels
def _on_prefs_changed(self): def _on_prefs_changed(self):
self._sync_delay = styles.sync_delay_ms(load_config())
style = _style(self._prefs) style = _style(self._prefs)
config = styles.settings(self._prefs) config = styles.settings(self._prefs)
if style != self._renderer.name: if style != self._renderer.name:
@@ -148,28 +162,68 @@ class VisualizerWidget(QWidget):
usable = (len(raw) // channels) * channels usable = (len(raw) // channels) * channels
raw = raw[:usable].reshape(-1, channels).mean(axis=1) raw = raw[:usable].reshape(-1, channels).mean(axis=1)
mono = (raw.astype(np.float32) - offset) / scale 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): def _reset_stream(self):
self._sample_rate = rate self._hist[:] = 0.0
if len(mono) >= HISTORY: self._hist_end_ms = None
self._samples = mono[-HISTORY:].copy() 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: else:
self._samples = np.roll(self._samples, -len(mono)) self._hist[:-n] = self._hist[n:]
self._samples[-len(mono):] = mono self._hist[-n:] = mono
# The laser needs every sample, once, not just the newest window. self._total += n
self._pending.append(mono) begin = start_ms if start_ms is not None else (self._hist_end_ms or 0.0)
self._pending_len += len(mono) self._hist_end_ms = begin + n * 1000.0 / rate
cap = int(PENDING_S * rate)
while self._pending_len > cap and len(self._pending) > 1:
self._pending_len -= len(self._pending.pop(0))
def _take_pending(self): def _heard_ms(self):
pending = (np.concatenate(self._pending) if self._pending """Where the speakers are in the stream, or None if the player
else np.zeros(0, dtype=np.float32)) can't say. Qt's position moves in buffer-sized steps; between them
self._pending = [] it is carried forward by the clock."""
self._pending_len = 0 position = getattr(self._player, "position_ms", None)
return pending 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 ---- # ---- state ----
@@ -182,7 +236,7 @@ class VisualizerWidget(QWidget):
and (self._mode != MODE_OFF or self._big_visible())) and (self._mode != MODE_OFF or self._big_visible()))
if run: if run:
if not self._timer.isActive(): 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() self._timer.start()
else: else:
self._timer.stop() self._timer.stop()
@@ -195,8 +249,7 @@ class VisualizerWidget(QWidget):
self._clear() self._clear()
def _on_track_changed(self, track): def _on_track_changed(self, track):
self._samples[:] = 0.0 self._reset_stream()
self._take_pending()
if track is None: if track is None:
self._timer.stop() self._timer.stop()
self._clear() self._clear()
@@ -256,9 +309,7 @@ class VisualizerWidget(QWidget):
# ---- frame computation ---- # ---- frame computation ----
def _advance_frame(self): def _advance_frame(self):
frame = Frame(self._samples, self._sample_rate, if self._renderer.advance(self._frame(FRAME_MS / 1000)):
self._take_pending(), FRAME_MS / 1000)
if self._renderer.advance(frame):
self._repaint() self._repaint()
# ---- painting ---- # ---- painting ----
@@ -268,6 +319,13 @@ class VisualizerWidget(QWidget):
return self._gray() return self._gray()
return QColor(self.palette().highlight().color()) 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): def _gray(self):
"""Dim mode's color.""" """Dim mode's color."""
if self._prefs is not None: if self._prefs is not None:
@@ -290,7 +348,7 @@ class VisualizerWidget(QWidget):
regardless (in color), since opening it is asking to see it.""" regardless (in color), since opening it is asking to see it."""
if self._no_pcm or (self._mode == MODE_OFF and not big): if self._no_pcm or (self._mode == MODE_OFF and not big):
return return
self._renderer.paint(painter, rect, self._bar_color()) self._renderer.paint(painter, rect, self._bar_color(), self._grid_gray())
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
+179 -44
View File
@@ -37,6 +37,7 @@ DECAY = 0.82 # slow decay; attack is instant
FLOOR_DB = -55.0 FLOOR_DB = -55.0
LOW_HZ = 40.0 LOW_HZ = 40.0
INSET = 3 # the drawing sits 3px in from every edge 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 BARS_FFT = 2048
RADIAL_FFT = 4096 # 10.8 Hz bins: the bass degrees need the resolution RADIAL_FFT = 4096 # 10.8 Hz bins: the bass degrees need the resolution
HISTORY = RADIAL_FFT # samples the widget keeps for the FFTs 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 # (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. # use the same numbers, so a hand-edited file can't go past what they offer.
LIMITS = { 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_spokes": (12, 360),
"visualizer_radial_levels": (4, 40), "visualizer_radial_levels": (4, 40),
"visualizer_laser_distance": (1.0, 4.0), # metres, mirror → screen "visualizer_laser_distance": (1.0, 4.0), # metres, mirror → screen
@@ -52,10 +57,24 @@ LIMITS = {
"visualizer_laser_yaw": (-60, 60), "visualizer_laser_yaw": (-60, 60),
"visualizer_laser_roll": (-180, 180), "visualizer_laser_roll": (-180, 180),
} }
_FLAGS = ("visualizer_radial_fade", "visualizer_radial_normalize", _FLAGS = ("visualizer_radial_normalize", "visualizer_laser_head",
"visualizer_laser_fade") "visualizer_laser_glow", "visualizer_laser_grid")
_INTEGER = ("visualizer_radial_spokes", "visualizer_radial_levels") _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: def settings(prefs) -> dict:
"""Every visualizer setting, defaulted and clamped.""" """Every visualizer setting, defaulted and clamped."""
@@ -145,44 +164,83 @@ def _faded(color: QColor, alpha: float) -> QColor:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
class BarsRenderer: 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 name = STYLE_BARS
def __init__(self, config=None): def __init__(self, config=None):
self.levels = np.zeros(BANDS) self.levels = np.zeros(BANDS)
self.bars = [0] * BANDS # quantized 0..STEPS self.bars = [0] * BANDS # quantized 0..STEPS
# (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): def configure(self, config):
pass self.fade = config["visualizer_bars_fade"]
if self.fade <= 0:
self.ghosts[:] = -np.inf
def clear(self): def clear(self):
self.levels[:] = 0.0 self.levels[:] = 0.0
self.bars = [0] * BANDS 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: def advance(self, frame: Frame) -> bool:
self.clock += frame.dt
spectrum, freqs = spectrum_of(frame.samples, BARS_FFT, frame.rate) spectrum, freqs = spectrum_of(frame.samples, BARS_FFT, frame.rate)
new_levels = band_levels(spectrum, freqs, band_edges(BANDS, frame.rate)) new_levels = band_levels(spectrum, freqs, band_edges(BANDS, frame.rate))
# Instant attack, slow decay # Instant attack, slow decay
self.levels = np.maximum(new_levels, self.levels * DECAY) self.levels = np.maximum(new_levels, self.levels * DECAY)
bars = [int(round(level * STEPS)) for level in self.levels] bars = [int(round(level * STEPS)) for level in self.levels]
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 return False
self.bars = bars self.bars = bars
return True 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_w = rect.width() - 2 * INSET
avail_h = rect.height() - 2 * INSET avail_h = rect.height() - 2 * INSET
left = int(rect.x()) + INSET left = int(rect.x()) + INSET
baseline = int(rect.y() + rect.height()) - INSET baseline = int(rect.y() + rect.height()) - INSET
bar_width = avail_w / BANDS bar_width = avail_w / BANDS
step_height = avail_h / STEPS step_height = avail_h / STEPS
for i, bar in enumerate(self.bars):
if bar <= 0: def column(i):
continue
x = left + int(i * bar_width) x = left + int(i * bar_width)
end = left + int((i + 1) * bar_width) end = left + int((i + 1) * bar_width)
gap = 1 if i < BANDS - 1 else 0 # 1px between bars, none at edge 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) bar_height = int(bar * step_height)
painter.fillRect(x, baseline - bar_height, w, bar_height, color) 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 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 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 GHOST_BUCKETS = 8 # one drawPoints() per bucket of similar alpha
NORMALIZE_S = 4.0 # how long the bass/treble balance takes to settle 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 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)) self.configure(config or settings(None))
def configure(self, config): def configure(self, config):
self.fade = (config["visualizer_ghost_fade"] self.fade = config["visualizer_ghost_fade"]
if config["visualizer_radial_fade"] else 0.0)
self.normalize = config["visualizer_radial_normalize"] self.normalize = config["visualizer_radial_normalize"]
spokes = config["visualizer_radial_spokes"] spokes = config["visualizer_radial_spokes"]
levels = config["visualizer_radial_levels"] levels = config["visualizer_radial_levels"]
@@ -329,7 +385,7 @@ class RadialRenderer:
alpha[:, 0] = 0.0 # silence isn't drawn alpha[:, 0] = 0.0 # silence isn't drawn
return rings, alpha, angle 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() cx, cy = rect.center().x(), rect.center().y()
radius = min(rect.width(), rect.height()) / 2 - INSET radius = min(rect.width(), rect.height()) / 2 - INSET
if radius <= 2: if radius <= 2:
@@ -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]) BEAM_TARGET = math.atan(FILL * SCREEN_HALF / LIMITS["visualizer_laser_distance"][1])
GAIN_RELEASE_S = 0.3 # how slowly the auto-gain turns back up after a peak GAIN_RELEASE_S = 0.3 # how slowly the auto-gain turns back up after a peak
PEAK_FLOOR = 0.0005 # ~22 dB over -60 dB hiss: a still dot, not dancing noise 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 CHUNK = 256 # samples per vectorized resonator step
ROOM = 0.95 # the figure never comes closer to the edge than this 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: def _bessel_j(m: int, x: float) -> float:
@@ -488,13 +547,22 @@ class LaserRenderer:
def __init__(self, config=None): def __init__(self, config=None):
self.levels = np.zeros(1) # the widget's shared clear() touches it self.levels = np.zeros(1) # the widget's shared clear() touches it
self._rate = None self._rate = None
self.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.configure(config or settings(None))
self.clear() self.clear()
def configure(self, config): def configure(self, config):
self.persist = PERSIST if config["visualizer_laser_fade"] else PERSIST[:1] self.afterimage = config["visualizer_laser_afterimage"]
self.paths = self.paths[:len(self.persist)] 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"] self.distance = config["visualizer_laser_distance"]
rot = _rotation(config["visualizer_laser_pitch"], rot = _rotation(config["visualizer_laser_pitch"],
config["visualizer_laser_yaw"], config["visualizer_laser_yaw"],
@@ -512,7 +580,14 @@ class LaserRenderer:
self._state = np.zeros(len(MODES), dtype=np.complex128) self._state = np.zeros(len(MODES), dtype=np.complex128)
self._carry = np.zeros(0, dtype=np.float64) self._carry = np.zeros(0, dtype=np.float64)
self._peak = PEAK_FLOOR 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 ---- # ---- the membrane ----
@@ -584,18 +659,26 @@ class LaserRenderer:
return self.on_screen(2 * gain * tilt) return self.on_screen(2 * gain * tilt)
def advance(self, frame: Frame) -> bool: def advance(self, frame: Frame) -> bool:
self.clock += frame.dt
tilt = self.drive(frame.pending, frame.rate) tilt = self.drive(frame.pending, frame.rate)
if len(tilt) == 0: if len(tilt) == 0:
return False self._prune()
return len(self.paths) > 1
path = self.project(tilt, frame.dt) path = self.project(tilt, frame.dt)
# Join this frame's path onto the last one's end, so the line is # Join this frame's path onto the last one's end, so the line is
# unbroken where two frames meet. # unbroken where two frames meet.
path = np.vstack([self.paths[0][-1:], path]) last, born, _ = self.paths[0]
self.paths = [path] + self.paths[:len(self.persist) - 1] 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 return True
def spot(self) -> np.ndarray: def spot(self) -> np.ndarray:
return self.paths[0][-1] return self.paths[0][0][-1]
def scale(self, rect) -> float: def scale(self, rect) -> float:
"""Pixels per metre: the screen's half-height fills the rect's, and """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), return unit * min(1.0, ROOM * half_w / (reach_x * unit),
ROOM * half_h / (reach_y * 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() cx, cy = rect.center().x(), rect.center().y()
half = min(rect.width(), rect.height()) / 2 - INSET half = min(rect.width(), rect.height()) / 2 - INSET
if half <= 2: if half <= 2:
return return
if self.grid and gray is not None:
self.paint_grid(painter, rect, gray) # under the beam
px = self.scale(rect) px = self.scale(rect)
s = max(1.0, half / 90) # stroke scale: the big window glows wider
pen = QPen() pen = QPen()
pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
# Oldest first, so the newest trace sits on top.
for path, persist in reversed(list(zip(self.paths, self.persist))): # The afterimage: past sweeps, oldest first, fading with age. Drawn
if len(path) < 2: # in screen metres through a transform, so each polygon is built
continue # 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 # A frame is ~180 steps; the panel is ~60 px across, so most
# would be sub-pixel segments that cost a Qt object each. # would be sub-pixel segments that cost a Qt object each.
step = max(1, int(len(path) / (2 * half))) step = max(1, int(len(path) / (2 * half)))
path = np.vstack([path[::step], path[-1:]]) xy = np.vstack([xy[::step], xy[-1:]])
xy = np.column_stack([cx + path[:, 0] * px, cy - path[:, 1] * px])
# The haze and the screen's scatter: one even bloom along the # The haze and the screen's scatter: one even bloom along the
# whole path, a single polyline so joints don't stack up alpha. # whole path, a single polyline so joints don't stack up alpha.
# Only around the newest trace — the older ones are afterimage. if self.glow:
if persist == self.persist[0]:
line = QPolygonF([QPointF(x, y) for x, y in xy]) line = QPolygonF([QPointF(x, y) for x, y in xy])
for width, strength in ((7 * s, 0.07), (3 * s, 0.18)): for width, strength in ((6 * self.width, 0.07),
(2.5 * self.width, 0.18)):
pen.setWidthF(width) pen.setWidthF(width)
pen.setColor(_faded(color, strength)) pen.setColor(_faded(color, strength))
painter.setPen(pen) painter.setPen(pen)
@@ -643,22 +773,27 @@ class LaserRenderer:
speed = np.linalg.norm(np.diff(xy, axis=0), axis=1) / step 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) bright = np.clip(0.04 * half / (speed + 1e-6), 0.15, 1.0)
bucket = np.ceil(bright * 6).astype(int) bucket = np.ceil(bright * 6).astype(int)
pen.setWidthF(1.2 * s) pen.setWidthF(self.width)
for level in range(1, 7): for level in range(1, 7):
idx = np.nonzero(bucket == level)[0] idx = np.nonzero(bucket == level)[0]
if len(idx) == 0: if len(idx) == 0:
continue continue
pen.setColor(_faded(color, persist * level / 6)) pen.setColor(_faded(color, level / 6))
painter.setPen(pen) painter.setPen(pen)
painter.drawLines([QLineF(*xy[i], *xy[i + 1]) for i in idx]) painter.drawLines([QLineF(*xy[i], *xy[i + 1]) for i in idx])
# The beam itself, wherever it is right now.
x, y = self.spot() # The beam itself. At rest it is all there is, so it always shows;
head = QPointF(cx + x * px, cy - y * px) # while the music moves it, it's the "head" setting's call.
painter.setPen(Qt.PenStyle.NoPen) resting = np.ptp(xy, axis=0).max() < 1.0
painter.setBrush(_faded(color, 0.25)) if self.head or resting:
painter.drawEllipse(head, 3.5 * s, 3.5 * s) head = QPointF(*xy[-1])
painter.setBrush(color) radius = self.dot / 2
painter.drawEllipse(head, 1.4 * s, 1.4 * s) 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): def make_renderer(style: str, config=None):
+11 -3
View File
@@ -8,18 +8,26 @@ from PyQt6.QtCore import QObject, pyqtSignal
DEFAULTS = { DEFAULTS = {
"ui_scale": "medium", # small | medium | large "ui_scale": "medium", # small | medium | large
"highlight": "blue", # see theme.HIGHLIGHT_COLORS "highlight": "blue", # see theme.HIGHLIGHT_COLORS
"highlight_palette": {}, # slot name -> "#rrggbb" the user picked
"now_playing_font": None, # None=prefer Century Gothic, ""=use app "now_playing_font": None, # None=prefer Century Gothic, ""=use app
# default, "Family"=use app # default, "Family"=use app
"volume": 0.8, # logical 0..1 master output volume "volume": 0.8, # logical 0..1 master output volume
"visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle "visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle
"visualizer_style": "bars", # bars | radial | laser (visualizer_styles) "visualizer_style": "bars", # bars | radial | laser (visualizer_styles)
# The styles' tunables — ranges in visualizer_styles.LIMITS. # The styles' tunables — ranges in visualizer_styles.LIMITS.
"visualizer_ghost_fade": 1.0, # seconds a radial dot's trail takes to fade # Fade times in seconds, 0 = off: how long what a bar or dot left
"visualizer_radial_fade": True, # radial dots leave fading trails # 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_spokes": 120, # frequency bands around the circle
"visualizer_radial_levels": 20, # volume steps along each spoke "visualizer_radial_levels": 20, # volume steps along each spoke
"visualizer_radial_normalize": True, # even out bass vs treble "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_distance": 3.5, # metres, mirror → screen center
"visualizer_laser_pitch": 0, # degrees the screen is turned "visualizer_laser_pitch": 0, # degrees the screen is turned
"visualizer_laser_yaw": 0, "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) 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: def highlight_color(prefs) -> QColor:
return QColor(HIGHLIGHT_COLORS.get(prefs.get("highlight"), palette = highlight_palette(prefs)
HIGHLIGHT_COLORS["blue"])) return QColor(palette.get(prefs.get("highlight"), palette["blue"]))
def scale_metrics(prefs) -> dict: 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 assert not np.isfinite(r.ghosts).any() # forgotten, not just faint
def test_fading_off_means_no_ghosts(self): 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(_tone(1000, amp=0.8)))
r.advance(_frame(np.zeros(vs.HISTORY, np.float32))) r.advance(_frame(np.zeros(vs.HISTORY, np.float32)))
assert r.ghost_alpha().max() == 0 assert r.ghost_alpha().max() == 0
@@ -275,7 +275,7 @@ def _run(renderer, audio, hop=706):
class TestLaser: class TestLaser:
def test_silence_is_a_still_dot_in_the_middle(self): def test_silence_is_a_still_dot_in_the_middle(self):
r = _run(vs.LaserRenderer(), np.zeros(RATE // 4, np.float32)) 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): def test_the_membrane_answers_its_resonance(self):
f = vs.MODES[1][0] f = vs.MODES[1][0]
@@ -305,14 +305,14 @@ class TestLaser:
audio = _sine(vs.MODES[1][0], 1.0) audio = _sine(vs.MODES[1][0], 1.0)
near = _run(vs.LaserRenderer(_config(laser_distance=1.0)), audio) near = _run(vs.LaserRenderer(_config(laser_distance=1.0)), audio)
far = _run(vs.LaserRenderer(_config(laser_distance=4.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 assert 3.5 < ratio < 4.5
def test_pitch_stretches_vertically_and_roll_spins(self): def test_pitch_stretches_vertically_and_roll_spins(self):
audio = _sine((vs.MODES[1][0] + vs.MODES[2][0]) / 2, 1.0) audio = _sine((vs.MODES[1][0] + vs.MODES[2][0]) / 2, 1.0)
def spread(**angles): 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]) return np.ptp(path[:, 0]), np.ptp(path[:, 1])
square_x, square_y = spread() square_x, square_y = spread()
@@ -320,8 +320,8 @@ class TestLaser:
assert tipped_y / square_y > 1.3 * tipped_x / square_x assert tipped_y / square_y > 1.3 * tipped_x / square_x
turned_x, turned_y = spread(laser_yaw=50) turned_x, turned_y = spread(laser_yaw=50)
assert turned_x / square_x > 1.3 * turned_y / square_y assert turned_x / square_x > 1.3 * turned_y / square_y
spun = _run(vs.LaserRenderer(_config(laser_roll=90)), audio).paths[0] spun = _run(vs.LaserRenderer(_config(laser_roll=90)), audio).paths[0][0]
flat = _run(vs.LaserRenderer(_config()), audio).paths[0] flat = _run(vs.LaserRenderer(_config()), audio).paths[0][0]
# A quarter turn: x becomes y (up to sign). # A quarter turn: x becomes y (up to sign).
assert np.allclose(np.abs(spun[:, 0]), np.abs(flat[:, 1]), atol=1e-9) assert np.allclose(np.abs(spun[:, 0]), np.abs(flat[:, 1]), atol=1e-9)
@@ -339,18 +339,93 @@ class TestLaser:
biggest = 0.0 biggest = 0.0
for i in range(0, len(audio) - 706, 706): for i in range(0, len(audio) - 706, 706):
r.advance(_frame(np.zeros(vs.HISTORY, np.float32), audio[i:i + 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[:, 0]).max() <= half_w
assert np.abs(px[:, 1]).max() <= half_h assert np.abs(px[:, 1]).max() <= half_h
biggest = max(biggest, np.abs(px).max()) biggest = max(biggest, np.abs(px).max())
assert biggest > 0.2 * half_h # and never shrunk to a dot assert biggest > 0.2 * half_h # and never shrunk to a dot
def test_fading_off_keeps_only_this_frame(self): def test_afterimage_lasts_its_fade_time(self):
audio = _sine(150, 0.5) audio = _sine(150, 1.0)
assert len(_run(vs.LaserRenderer(), audio).paths) == len(vs.PERSIST) r = _run(vs.LaserRenderer(_config(laser_afterimage=0.2)), audio)
r = _run(vs.LaserRenderer(_config(laser_fade=False)), 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 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 # 5. Double-click → big window
@@ -400,7 +475,7 @@ class TestBigWindow:
prefs.set("visualizer_style", "laser") prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(playing=True), prefs) vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
calls = [] calls = []
vis._renderer.paint = lambda p, rect, color: calls.append(rect) vis._renderer.paint = lambda p, rect, color, gray=None: calls.append(rect)
img = QImage(10, 10, QImage.Format.Format_ARGB32) img = QImage(10, 10, QImage.Format.Format_ARGB32)
painter = QPainter(img) painter = QPainter(img)
vis.paint_renderer(painter, QRectF(0, 0, 10, 10)) vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
@@ -416,18 +491,81 @@ class TestBigWindow:
prefs.set("visualizer_style", "laser") prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(), prefs) vis = VisualizerWidget(_FakePlayer(), prefs)
colors = [] colors = []
vis._renderer.paint = lambda p, rect, color: colors.append(color) vis._renderer.paint = lambda p, rect, color, gray=None: colors.append(color)
img = QImage(10, 10, QImage.Format.Format_ARGB32) img = QImage(10, 10, QImage.Format.Format_ARGB32)
painter = QPainter(img) painter = QPainter(img)
vis.paint_renderer(painter, QRectF(0, 0, 10, 10)) vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
painter.end() painter.end()
assert colors[0].red() == colors[0].green() == colors[0].blue() == 120 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()) vis = VisualizerWidget(_FakePlayer())
for _ in range(100): vis._feed(_marked(500), RATE)
vis._feed(_sine(440, 0.05), RATE) assert abs(vis._frame(0.016).samples[-1] - 500) < 2
assert vis._pending_len <= RATE * 0.25 + RATE * 0.05
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): def test_radial_settings(self, qapp, tmp_path):
prefs = Preferences(tmp_path) prefs = Preferences(tmp_path)
dialog = self._dialog(prefs) dialog = self._dialog(prefs)
assert dialog._fade_slider.isEnabled() dialog._fade_slider.setValue(50) # twentieths of a second
dialog._fade_slider.setValue(25)
assert prefs.get("visualizer_ghost_fade") == 2.5 assert prefs.get("visualizer_ghost_fade") == 2.5
dialog._radial_fade.setChecked(False) dialog._fade_slider.setValue(0)
assert prefs.get("visualizer_radial_fade") is False assert prefs.get("visualizer_ghost_fade") == 0
assert not dialog._fade_slider.isEnabled()
dialog._spokes_slider.setValue(60) dialog._spokes_slider.setValue(60)
dialog._levels_slider.setValue(10) dialog._levels_slider.setValue(10)
dialog._normalize.setChecked(False) dialog._normalize.setChecked(False)
@@ -474,20 +610,77 @@ class TestPreferencesPage:
prefs.get("visualizer_radial_levels"), prefs.get("visualizer_radial_levels"),
prefs.get("visualizer_radial_normalize")) == (60, 10, False) 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): def test_laser_settings(self, qapp, tmp_path):
prefs = Preferences(tmp_path) prefs = Preferences(tmp_path)
dialog = self._dialog(prefs) 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._distance_slider.setValue(15)
dialog._angle_sliders["visualizer_laser_pitch"].setValue(30) dialog._angle_sliders["visualizer_laser_pitch"].setValue(30)
dialog._angle_sliders["visualizer_laser_roll"].setValue(-90) assert prefs.get("visualizer_laser_glow") is False
assert prefs.get("visualizer_laser_fade") 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_distance") == 1.5
assert prefs.get("visualizer_laser_pitch") == 30 assert prefs.get("visualizer_laser_pitch") == 30
lo, hi = vs.LIMITS["visualizer_laser_roll"] lo, hi = vs.LIMITS["visualizer_laser_roll"]
slider = dialog._angle_sliders["visualizer_laser_roll"] slider = dialog._angle_sliders["visualizer_laser_roll"]
assert (slider.minimum(), slider.maximum()) == (lo, hi) 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): def test_building_the_dialog_writes_nothing(self, qapp, tmp_path):
prefs = Preferences(tmp_path) prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "laser") prefs.set("visualizer_style", "laser")