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
|
`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,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"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user