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:
@@ -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,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.40.0"
|
||||
__version__ = "0.41.0"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user