v0.39.0: visualizer styles — radial dots and a laser, double-click to enlarge

Preferences → Appearance → Visualizer picks Bars (as before), Radial dots
(the EQ in a circle: angle is frequency, radius is volume, moving dots
leave light-gray ghosts that fade over a set time), or Laser (a mirror on
a speaker membrane bouncing a beam onto a screen: simulated membrane
modes, D·tan(2θ) reflection, dwell-weighted brightness). Double-clicking
the panel opens it big; a click closes it.
This commit is contained in:
2026-10-10 00:26:29 -07:00
parent f079d26416
commit a81ab1e163
8 changed files with 1155 additions and 85 deletions
+17
View File
@@ -172,6 +172,23 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
for every add-to-playlist path. `theme.py` applies the palette (highlight
colors, UI scales) from prefs.
- **`lintunes/gui/visualizer.py`** + **`visualizer_styles.py`** — the
transport-bar visualizer (Round 78). The widget owns PCM intake, the timer,
on/dim/off and the color; a *renderer* (`visualizer_style` pref: `bars`,
`radial`, `laser`) owns the animation state and paints it into any rect, so
the double-click `VisualizerWindow` paints the **same** renderer bigger
rather than a copy. Since the first press of a double-click already cycled
the brightness, `mouseDoubleClickEvent` puts it back. **Radial** is the EQ
as polar dots (angle = 360 log bands, radius = level in `STEPS` rings), and
a moving dot leaves light-gray ghosts. These are kept as a birth time per
(degree, ring), so a spoke never holds more than `STEPS` and pooling to
fewer spokes is one reshape. **Laser** simulates a mirror on a speaker
membrane: the circular-membrane modes (split degenerate pairs are what turn
lines into rotating loops) run as complex one-pole resonators, vectorized in
`CHUNK`s. Tilt goes through D·tan(2θ) with an auto-gain, and 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.
- **`lintunes/importers/itunes_importer.py`** — parses the iTunes XML plist.
Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with
case/Unicode-normalization fuzzy matching (macOS is case-insensitive + NFD vs
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.38.0"
__version__ = "0.39.0"
+69
View File
@@ -25,6 +25,14 @@ _COLOR_ROWS = [
]
# visualizer_styles' style keys, as the radios name them.
_VISUALIZER_STYLES = {
"bars": "Bars",
"radial": "Radial dots",
"laser": "Laser",
}
class PreferencesDialog(QDialog):
"""App preferences. Applies immediately — there is no OK/Cancel.
@@ -62,6 +70,7 @@ class PreferencesDialog(QDialog):
for title, groups in (
("General", general),
("Appearance", [self._build_general_group(),
self._build_visualizer_group(),
self._build_colors_group()]),
("Integrations", [self._build_lastfm_group(),
self._build_acoustid_group()])):
@@ -313,6 +322,66 @@ class PreferencesDialog(QDialog):
# ---- Colors ----
# ---- Visualizer ----
def _build_visualizer_group(self) -> QGroupBox:
group = QGroupBox("Visualizer")
form = QFormLayout(group)
style_row = QHBoxLayout()
self._viz_style = QButtonGroup(self)
self._viz_radios = {}
current = self._prefs.get("visualizer_style", "bars")
if current not in _VISUALIZER_STYLES:
current = "bars"
for key, label in _VISUALIZER_STYLES.items():
radio = QRadioButton(label)
radio.setChecked(key == current) # before connect
radio.toggled.connect(
lambda on, k=key: on and self._on_viz_style(k))
self._viz_style.addButton(radio)
self._viz_radios[key] = radio
style_row.addWidget(radio)
style_row.addStretch()
form.addRow("Style:", style_row)
# Tenths of a second, 0 (no trail) .. 3 s.
fade_row = QHBoxLayout()
self._fade_slider = QSlider(Qt.Orientation.Horizontal)
self._fade_slider.setRange(0, 30)
self._fade_slider.setFixedWidth(160)
fade = self._prefs.get("visualizer_ghost_fade", 1.0)
self._fade_slider.setValue(int(round(float(fade) * 10)))
self._fade_label = QLabel()
self._show_fade(self._fade_slider.value())
self._fade_slider.valueChanged.connect(self._on_fade_changed)
fade_row.addWidget(self._fade_slider)
fade_row.addSpacing(8)
fade_row.addWidget(self._fade_label)
fade_row.addStretch()
form.addRow("Trail fade:", fade_row)
self._fade_slider.setEnabled(current == "radial")
self._fade_label.setEnabled(current == "radial")
note = QLabel("Double-click the visualizer to see it big.")
note.setEnabled(False)
form.addRow(note)
return group
def _on_viz_style(self, key: str):
self._fade_slider.setEnabled(key == "radial")
self._fade_label.setEnabled(key == "radial")
self._prefs.set("visualizer_style", key)
def _show_fade(self, tenths: int):
self._fade_label.setText("No trail" if tenths == 0
else f"{tenths / 10:.1f} s")
def _on_fade_changed(self, tenths: int):
self._show_fade(tenths)
self._prefs.set_live("visualizer_ghost_fade", tenths / 10)
self._color_save_timer.start() # persist once the drag settles
def _build_colors_group(self) -> QGroupBox:
group = QGroupBox("Colors")
form = QFormLayout(group)
+164 -84
View File
@@ -1,28 +1,29 @@
"""20-band graphic-EQ visualizer fed by the player's decoded PCM tee.
"""The transport bar's visualizer, fed by the player's decoded PCM tee.
The player's QAudioBufferOutput delivers buffers essentially in lockstep
with the audible position (verified: frames received ≈ frames played), so
no extra sync buffering is needed — we just FFT the newest samples.
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 numpy as np
from PyQt6.QtWidgets import QWidget
from PyQt6.QtCore import Qt, QTimer, QRectF, pyqtSignal
from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen
from PyQt6.QtGui import QPainter, QColor
from PyQt6.QtMultimedia import QAudioFormat
from lintunes import theme
from lintunes.gui.icons import transport_icon
from lintunes.gui.visualizer_styles import (
BANDS, STYLES, STYLE_BARS, Frame, make_renderer)
BANDS = 20
STEPS = 20 # vertical granularity per bar
FFT_SIZE = 2048
FRAME_MS = 16 # ~60 fps
DECAY = 0.82 # slow decay; attack is instant
FLOOR_DB = -55.0
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
# Visualizer click cycles through these brightness modes in order.
MODE_ON = "on"
@@ -30,6 +31,8 @@ MODE_DIM = "dim"
MODE_OFF = "off"
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
_TOOLTIP = "Click to cycle: on / dim / off — double-click to enlarge"
_SAMPLE_DTYPES = {
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
QAudioFormat.SampleFormat.Int16: (np.int16, 0.0, 32768.0),
@@ -38,11 +41,25 @@ _SAMPLE_DTYPES = {
}
class VisualizerWidget(QWidget):
"""Animated spectrum bars; click to cycle brightness: on / dim / off.
def _style(prefs) -> str:
saved = prefs.get("visualizer_style") if prefs is not None else None
return saved if saved in STYLES else STYLE_BARS
On = highlight-colored bars; dim = light-gray bars just darker than the
panel; off = blank.
def _fade(prefs) -> float:
saved = prefs.get("visualizer_ghost_fade", 1.0) if prefs is not None else 1.0
try:
return max(0.0, float(saved))
except (TypeError, ValueError):
return 1.0
class VisualizerWidget(QWidget):
"""Animated visualizer; click to cycle brightness: on / dim / off, and
double-click to open it big (``VisualizerWindow``).
On = highlight-colored; dim = light gray just darker than the panel;
off = blank (the big window, if open, keeps going in color).
While casting there is no local PCM to analyze, so the panel doubles as the
cast indicator: it shows the device glyph and a click stops casting.
@@ -56,11 +73,14 @@ class VisualizerWidget(QWidget):
self._prefs = prefs
saved = prefs.get("visualizer_mode") if prefs is not None else None
self._mode = saved if saved in _MODE_CYCLE else MODE_ON
self._levels = np.zeros(BANDS)
self._bars = [0] * BANDS # quantized 0..STEPS
self._mode_before_press = self._mode
self._renderer = make_renderer(_style(prefs), _fade(prefs))
self._samples = np.zeros(FFT_SIZE, dtype=np.float32)
self._pending = [] # every sample since the last frame (the laser)
self._pending_len = 0
self._sample_rate = 44100
self._window = np.hanning(FFT_SIZE).astype(np.float32)
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.
self._no_pcm = False
@@ -68,7 +88,7 @@ class VisualizerWidget(QWidget):
self.setFixedWidth(140)
self.setMinimumHeight(36)
self.setToolTip("Click to cycle: on / dim / off")
self.setToolTip(_TOOLTIP)
self._timer = QTimer(self)
self._timer.setInterval(FRAME_MS)
@@ -79,6 +99,29 @@ class VisualizerWidget(QWidget):
player.track_changed.connect(self._on_track_changed)
if hasattr(player, "sink_changed"):
player.sink_changed.connect(self._on_sink_changed)
if prefs is not None:
prefs.changed.connect(self._on_prefs_changed)
# The bars' state, under the names the widget always had for it.
@property
def _bars(self):
return getattr(self._renderer, "bars", [0] * BANDS)
@_bars.setter
def _bars(self, bars):
self._renderer.bars = bars
@property
def _levels(self):
return self._renderer.levels
def _on_prefs_changed(self):
style = _style(self._prefs)
if style != self._renderer.name:
self._renderer = make_renderer(style, _fade(self._prefs))
self._repaint()
elif hasattr(self._renderer, "fade"):
self._renderer.fade = _fade(self._prefs)
def _on_sink_changed(self, sink):
"""Casting means no local decode, so there is nothing to draw. The
@@ -87,16 +130,14 @@ class VisualizerWidget(QWidget):
self._no_pcm = not getattr(sink, "provides_pcm", True)
if self._no_pcm:
self._device = getattr(sink, "name", "") or "a Chromecast"
self._timer.stop()
self._clear()
self.setToolTip(f"Casting to {self._device} — click to stop")
self.setCursor(Qt.CursorShape.PointingHandCursor)
else:
self._device = ""
self.setToolTip("Click to cycle: on / dim / off")
self.setToolTip(_TOOLTIP)
self.setCursor(Qt.CursorShape.ArrowCursor)
if self._mode != MODE_OFF and self._player.is_playing():
self._timer.start()
self._sync_timer()
self.update()
# ---- audio intake ----
@@ -115,36 +156,75 @@ 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._sample_rate = fmt.sampleRate() or 44100
self._feed(mono, fmt.sampleRate() or 44100)
def _feed(self, mono, rate):
self._sample_rate = rate
if len(mono) >= FFT_SIZE:
self._samples = mono[-FFT_SIZE:].copy()
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))
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
# ---- state ----
def _big_visible(self):
return self._big is not None and self._big.isVisible()
def _sync_timer(self):
"""Animate while playing, unless nothing would show it."""
run = (not self._no_pcm and self._player.is_playing()
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._timer.start()
else:
self._timer.stop()
def _on_playing_changed(self, playing):
if self._no_pcm:
return # nothing to animate; don't let play/pause restart the timer
if playing:
if self._mode != MODE_OFF:
self._timer.start()
else:
self._timer.stop()
if self._mode != MODE_OFF:
self._clear()
self._sync_timer()
if not playing:
self._clear()
def _on_track_changed(self, track):
self._samples[:] = 0.0
if track is None and self._mode != MODE_OFF:
self._take_pending()
if track is None:
self._timer.stop()
self._clear()
def _clear(self):
self._levels[:] = 0.0
self._bars = [0] * BANDS
self._renderer.clear()
self._repaint()
def _repaint(self):
self.update()
if self._big_visible():
self._big.update()
def _set_mode(self, mode):
self._mode = mode
if self._prefs is not None:
self._prefs.set("visualizer_mode", self._mode)
if self._mode == MODE_OFF and not self._big_visible():
self._clear() # off = blank, not the last frame held in place
self._sync_timer()
self.update()
def mousePressEvent(self, event):
@@ -155,15 +235,31 @@ class VisualizerWidget(QWidget):
self.cast_stop_requested.emit()
return
# Cycle on -> dim -> off -> on.
self._mode_before_press = self._mode
idx = _MODE_CYCLE.index(self._mode)
self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]
if self._prefs is not None:
self._prefs.set("visualizer_mode", self._mode)
self._set_mode(_MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)])
def mouseDoubleClickEvent(self, event):
"""Qt sends press, release, *double-click*: the first press already
cycled the brightness, so put it back before opening big. A brief
flicker, in exchange for a single click that never waits to see
whether a second one is coming."""
if self._no_pcm:
return # the first click already stopped casting
self._set_mode(self._mode_before_press)
self.open_big()
def open_big(self):
from lintunes.gui.visualizer_window import VisualizerWindow
if self._big is None:
self._big = VisualizerWindow(self)
self._big.open(self.screen())
self._sync_timer()
def big_window_closed(self):
if self._mode == MODE_OFF:
self._timer.stop()
self._clear() # off = blank, not the last frame held in place
elif self._player.is_playing():
self._timer.start()
self._clear()
self._sync_timer()
# ---- frame computation ----
@@ -171,52 +267,51 @@ class VisualizerWidget(QWidget):
spectrum = np.abs(np.fft.rfft(self._samples * self._window))
# Normalize: full-scale sine -> ~1.0
spectrum /= (FFT_SIZE / 4)
nyquist = self._sample_rate / 2
top = min(16000.0, nyquist * 0.95)
edges = np.geomspace(40.0, max(top, 80.0), BANDS + 1)
freqs = np.fft.rfftfreq(FFT_SIZE, d=1.0 / self._sample_rate)
new_levels = np.zeros(BANDS)
for i in range(BANDS):
mask = (freqs >= edges[i]) & (freqs < edges[i + 1])
if mask.any():
magnitude = spectrum[mask].max()
db = 20.0 * np.log10(magnitude + 1e-10)
new_levels[i] = np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0)
# 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:
self._bars = bars
self.update()
frame = Frame(spectrum, freqs, self._sample_rate,
self._take_pending(), FRAME_MS / 1000)
if self._renderer.advance(frame):
self._repaint()
# ---- painting ----
def _bar_color(self):
if self._mode == MODE_DIM:
if self._prefs is not None:
saved = self._prefs.get("color_visualizer_gray")
if saved is not None:
return theme.gray(saved)
# Light gray just darker than the panel: take alternateBase and
# drop its lightness ~12% so it tracks theme/scale changes.
base = QColor(self.palette().alternateBase().color())
h, s, l, a = base.getHslF()
base.setHslF(h, s, l * 0.88, a)
return base
return self._gray()
return QColor(self.palette().highlight().color())
def _gray(self):
"""Dim mode's color — and the radial's fading ghosts in any mode."""
if self._prefs is not None:
saved = self._prefs.get("color_visualizer_gray")
if saved is not None:
return theme.gray(saved)
# Light gray just darker than the panel: take alternateBase and
# drop its lightness ~12% so it tracks theme/scale changes.
base = QColor(self.palette().alternateBase().color())
h, s, l, a = base.getHslF()
base.setHslF(h, s, l * 0.88, a)
return base
def panel_color(self):
return self.palette().alternateBase().color()
def paint_renderer(self, painter, rect, big=False):
"""Paint the current frame into ``rect`` — the panel's, or the big
window's. The panel draws nothing when off; the big window draws
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._gray())
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# A rounded panel debossed as far as the transport buttons stand out:
# it shows things, it isn't pressed.
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
panel = theme.paint_deboss(painter, rect,
self.palette().alternateBase().color(), RADIUS)
painter.setClipPath(panel) # keep bars inside the rounded shape
panel = theme.paint_deboss(painter, rect, self.panel_color(), RADIUS)
painter.setClipPath(panel) # keep the drawing inside the rounded shape
if self._no_pcm:
# Casting: the audio never passes through this machine, so show
# where it went instead of pretending to analyze silence.
@@ -228,19 +323,4 @@ class VisualizerWidget(QWidget):
(self.height() - ICON_SIZE) // 2, glyph)
painter.end()
return
inset = 3 # bar field sits 3px in from every edge
avail_w = self.width() - 2 * inset
avail_h = self.height() - 2 * inset
baseline = self.height() - inset
bar_width = avail_w / BANDS
color = self._bar_color()
step_height = avail_h / STEPS
for i, bar in enumerate(self._bars):
if bar <= 0:
continue
x = inset + int(i * bar_width)
end = inset + 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)
bar_height = int(bar * step_height)
painter.fillRect(x, baseline - bar_height, w, bar_height, color)
self.paint_renderer(painter, QRectF(self.rect()))
+486
View File
@@ -0,0 +1,486 @@
"""The visualizer's styles: what a frame of audio is turned into.
A renderer owns its own animation state and knows how to paint it into any
rect, so the transport-bar panel and the enlarged ``VisualizerWindow`` paint
the very same state at two sizes. ``VisualizerWidget`` owns everything else:
audio intake, the timer, on/dim/off, the color. Pure numpy + QPainter.
- ``BarsRenderer`` — the original 20-band graphic EQ.
- ``RadialRenderer`` — that EQ wrapped into a circle as dots: angle is
frequency, radius is volume, and a dot that moves leaves fading ghosts.
- ``LaserRenderer`` — a laser bounced off a mirror glued to a membrane
stretched over a speaker, projected onto a screen (Tame Impala's rig).
"""
import math
from dataclasses import dataclass
import numpy as np
from PyQt6.QtCore import Qt, QPointF, QLineF
from PyQt6.QtGui import QColor, QPen, QPolygonF
STYLE_BARS = "bars"
STYLE_RADIAL = "radial"
STYLE_LASER = "laser"
STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER)
BANDS = 20
STEPS = 20 # vertical granularity per bar (and rings per radial spoke)
DECAY = 0.82 # slow decay; attack is instant
FLOOR_DB = -55.0
LOW_HZ = 40.0
INSET = 3 # the bar field sits 3px in from every edge
@dataclass
class Frame:
"""One timer tick's worth of audio, handed to ``advance``."""
spectrum: np.ndarray # |rfft| of the newest window, full-scale sine ~1.0
freqs: np.ndarray # the frequency of each spectrum bin
rate: int # sample rate
pending: np.ndarray # every mono sample that arrived since last tick
dt: float # seconds since the last tick
def band_edges(count: int, rate: float) -> np.ndarray:
"""``count`` log-spaced bands from 40 Hz to 16 kHz (or just under
Nyquist): log because that's how pitch is heard — linear would spend
most of the bands above anything but cymbals."""
top = min(16000.0, rate / 2 * 0.95)
return np.geomspace(LOW_HZ, max(top, 2 * LOW_HZ), count + 1)
def band_levels(spectrum: np.ndarray, freqs: np.ndarray,
edges: np.ndarray) -> np.ndarray:
"""Each band's loudest bin as a 0..1 level (0 = FLOOR_DB or quieter).
A band too narrow to hold a bin of its own (the bottom degrees of the
radial, at 21 Hz a bin) takes the bin nearest its center rather than
reading as silence."""
count = len(edges) - 1
starts = np.searchsorted(freqs, edges).clip(0, len(spectrum) - 1)
magnitude = np.maximum.reduceat(spectrum, starts)[:count]
empty = starts[1:] <= starts[:-1]
if empty.any():
centers = np.sqrt(edges[:-1] * edges[1:])[empty]
step = freqs[1] - freqs[0] if len(freqs) > 1 else 1.0
nearest = np.rint(centers / step).astype(int).clip(0, len(spectrum) - 1)
magnitude[empty] = spectrum[nearest]
db = 20.0 * np.log10(magnitude + 1e-10)
return np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0)
def _faded(color: QColor, alpha: float) -> QColor:
faded = QColor(color)
faded.setAlphaF(max(0.0, min(1.0, alpha)) * color.alphaF())
return faded
# --------------------------------------------------------------------------
# Bars
# --------------------------------------------------------------------------
class BarsRenderer:
name = STYLE_BARS
def __init__(self):
self.levels = np.zeros(BANDS)
self.bars = [0] * BANDS # quantized 0..STEPS
def clear(self):
self.levels[:] = 0.0
self.bars = [0] * BANDS
def advance(self, frame: Frame) -> bool:
new_levels = band_levels(frame.spectrum, frame.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:
return False
self.bars = bars
return True
def paint(self, painter, rect, color: QColor, light: 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
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)
bar_height = int(bar * step_height)
painter.fillRect(x, baseline - bar_height, w, bar_height, color)
# --------------------------------------------------------------------------
# Radial dots
# --------------------------------------------------------------------------
RADIAL_BANDS = 360 # one per degree; painting pools them to fit
DOT_PITCH = 3.0 # px between neighbouring dots on the rim
HUB = 0.22 # the silent middle, as a share of the radius
GHOST_ALPHA = 1.0 # a fresh ghost is the full light gray, then fades
GHOST_BUCKETS = 8 # one drawPoints() per bucket of similar alpha
_DIVISORS = [d for d in range(1, RADIAL_BANDS + 1) if RADIAL_BANDS % d == 0]
def spoke_count(radius: float) -> int:
"""The most spokes (a divisor of 360) that still leave DOT_PITCH px
between dots around the rim — ~45 in the transport panel, all 360 in
the big window."""
fits = 2 * math.pi * radius / DOT_PITCH
return max([d for d in _DIVISORS if d <= fits] or [1])
class RadialRenderer:
"""The graphic EQ as a polar dot plot.
Frequency is the angle — 360 log-spaced bands, bass at 12 o'clock
running clockwise — and volume is the radius, quantized to STEPS rings
so the dots line up like the bars' steps. A dot that moves leaves a
ghost on every ring it passed; a ghost fades over ``fade`` seconds.
Ghosts are kept as a birth time per (degree, ring) rather than a list:
a revisit simply refreshes it, so a spoke can never hold more than
STEPS of them, and pooling 360 degrees down to fewer spokes is one
reshape + max.
"""
name = STYLE_RADIAL
def __init__(self, fade: float = 1.0):
self.fade = fade
self.levels = np.zeros(RADIAL_BANDS)
self.rings = np.zeros(RADIAL_BANDS, dtype=int) # 0 = silent
self.ghosts = np.full((RADIAL_BANDS, STEPS + 1), -np.inf)
self.clock = 0.0
def clear(self):
self.levels[:] = 0.0
self.rings[:] = 0
self.ghosts[:] = -np.inf
def ghost_alpha(self) -> np.ndarray:
"""(degree, ring) → 0..1 strength of the ghost there."""
if self.fade <= 0:
return np.zeros_like(self.ghosts)
age = self.clock - self.ghosts
return np.clip(1.0 - age / self.fade, 0.0, 1.0)
def advance(self, frame: Frame) -> bool:
self.clock += frame.dt
new_levels = band_levels(frame.spectrum, frame.freqs,
band_edges(RADIAL_BANDS, frame.rate))
self.levels = np.maximum(new_levels, self.levels * DECAY)
rings = np.rint(self.levels * STEPS).astype(int)
moved = rings != self.rings
if moved.any() and self.fade > 0:
# A ghost where the dot was. Falling (the slow decay) also leaves
# one on every ring it skipped, so the fall reads as a trail; a
# rise is an instant attack — it leaves only where it came from,
# or every hit would paint a bar.
old, new = self.rings[moved], rings[moved]
lo = np.where(new < old, new, old)[:, None]
hi = old[:, None]
k = np.arange(STEPS + 1)[None, :]
trail = (k >= lo) & (k <= hi) & (k != new[:, None]) & (k > 0)
block = self.ghosts[moved]
block[trail] = self.clock
self.ghosts[moved] = block
self.rings = rings
if self.fade <= 0:
self.ghosts[:] = -np.inf
else:
# Forget the long-faded, so "anything still fading?" is cheap.
self.ghosts[self.clock - self.ghosts >= self.fade] = -np.inf
return bool(moved.any() or np.isfinite(self.ghosts).any())
def pooled(self, spokes: int):
"""The state at ``spokes`` spokes: each spoke's ring, and each
(spoke, ring) ghost strength — the loudest of the degrees it covers."""
group = RADIAL_BANDS // spokes
rings = self.rings.reshape(spokes, group).max(axis=1)
alpha = self.ghost_alpha().reshape(spokes, group, STEPS + 1).max(axis=1)
alpha[np.arange(spokes), rings] = 0.0 # under the dot itself
alpha[:, 0] = 0.0 # silence isn't drawn
return rings, alpha
def paint(self, painter, rect, color: QColor, light: QColor):
cx, cy = rect.center().x(), rect.center().y()
radius = min(rect.width(), rect.height()) / 2 - INSET
if radius <= 2:
return
spokes = spoke_count(radius)
rings, alpha = self.pooled(spokes)
hub = radius * HUB
ring_step = (radius - hub) / STEPS
dot = max(1.8, min(2 * math.pi * radius / spokes, ring_step * 2.5) * 0.8)
angles = 2 * math.pi * (np.arange(spokes) + 0.5) / spokes
sin, cos = np.sin(angles), np.cos(angles)
def points(spoke_idx, ring_idx):
r = hub + ring_idx * ring_step - dot / 2
xs = cx + r * sin[spoke_idx]
ys = cy - r * cos[spoke_idx]
return [QPointF(x, y) for x, y in zip(xs, ys)]
pen = QPen()
pen.setWidthF(dot)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
# Ghosts first, faintest first, so the newer ones sit on top.
bucket = np.ceil(alpha * GHOST_BUCKETS).astype(int)
for level in range(1, GHOST_BUCKETS + 1):
spoke_idx, ring_idx = np.nonzero(bucket == level)
if len(spoke_idx) == 0:
continue
pen.setColor(_faded(light, GHOST_ALPHA * level / GHOST_BUCKETS))
painter.setPen(pen)
painter.drawPoints(points(spoke_idx, ring_idx))
live = np.nonzero(rings > 0)[0]
if len(live):
pen.setColor(color)
painter.setPen(pen)
painter.drawPoints(points(live, rings[live]))
# --------------------------------------------------------------------------
# Laser
# --------------------------------------------------------------------------
#
# The rig: a small mirror glued off-center on a rubber membrane stretched
# over a speaker; a laser hits the mirror and the reflection lands on a big
# screen. What moves the beam is the *tilt* of the mirror, and a membrane
# doesn't just pump in and out — it vibrates in modes, each with its own
# resonance and its own shape. The mirror's tilt is the membrane's slope
# where the mirror sits, so every mode pushes the beam along one fixed
# direction on the screen, at a strength set by how near the music is to
# that mode's resonance.
#
# The modes of a circular membrane are J_m(j_mn·r)·cos(mθ), resonating at
# j_mn/j_01 times the fundamental. The m > 0 modes come in pairs at right
# angles that a perfect drum tunes identically; a hand-stretched one never
# is, so they split by a percent or two, the two axes drift in and out of
# phase on a held note, and that is where the slowly turning ellipses and
# Lissajous loops come from. A perfectly even push wouldn't excite m > 0
# modes at all; the mirror's own mass, off-center, is what couples them.
#
# Reflection doubles the angle: tilt θ turns the beam 2θ, so the spot lands
# at D·tan(2θ) — a faint pincushion stretch at big swings, kept in. The
# spot moves far faster than an eye or a camera follows, so what anyone
# sees is the whole path traced in one glance, brightest where the beam
# moves slowest (it dwells there), with the haze and the screen's scatter
# blooming around it.
MODE_ZEROS = ( # (m, j_mn) — Bessel zeros
(0, 2.40483), (1, 3.83171), (2, 5.13562), (0, 5.52008), (3, 6.38016),
)
FUNDAMENTAL_HZ = 90.0 # a drum-sized membrane: kick-drum territory
MODE_Q = 8.0 # rubber is lossy
SPLIT = (0.0, 0.016, 0.011, 0.0, 0.008) # the uneven stretch, per mode pair
MIRROR_AT = (0.55, 0.6) # the mirror's spot: radius (of 1), angle (rad)
SIM_RATE = 11025.0 # the membrane barely answers above a few hundred Hz
BEAM_MAX = math.radians(30) # the screen's half-height, as a beam angle
FILL = 0.85 # auto-gain aims the loudest swings at this much of it
GAIN_RELEASE_S = 0.3 # how slowly the auto-gain turns back up after a peak
PEAK_FLOOR = 0.02 # silence stays a still dot, not amplified hiss
PERSIST = (1.0, 0.45, 0.2) # this frame's path, then fading older ones
CHUNK = 256 # samples per vectorized resonator step
def _bessel_j(m: int, x: float) -> float:
return sum((-1) ** k / (math.factorial(k) * math.factorial(k + m))
* (x / 2) ** (2 * k + m) for k in range(30))
def _mode_slope(m: int, j: float, phase: float) -> tuple:
"""d/dx, d/dy of J_m(j·r)·cos(m(θ − phase)) at the mirror."""
r0, a0 = MIRROR_AT
x0, y0 = r0 * math.cos(a0), r0 * math.sin(a0)
def shape(x, y):
return (_bessel_j(m, j * math.hypot(x, y))
* math.cos(m * (math.atan2(y, x) - phase)))
h = 1e-4
return ((shape(x0 + h, y0) - shape(x0 - h, y0)) / (2 * h),
(shape(x0, y0 + h) - shape(x0, y0 - h)) / (2 * h))
def _membrane_modes():
"""[(frequency Hz, weight, slope x, slope y)] for every resonator."""
modes = []
j01 = MODE_ZEROS[0][1]
for (m, j), split in zip(MODE_ZEROS, SPLIT):
ratio = j / j01
# Pressure drives a mode in proportion to how evenly it moves (m = 0
# fully; the rest only through the mirror's off-center mass), and a
# stiffer, higher mode moves less for the same push (1/ratio²).
weight = (1.0 if m == 0 else 0.8) / ratio ** 2
orientations = [(0.0, 1.0)] if m == 0 else \
[(0.0, 1.0), (math.pi / (2 * m), 1.0 + split)]
for phase, detune in orientations:
sx, sy = _mode_slope(m, j, phase)
modes.append((FUNDAMENTAL_HZ * ratio * detune, weight, sx, sy))
return modes
MODES = _membrane_modes()
class LaserRenderer:
name = STYLE_LASER
def __init__(self):
self.levels = np.zeros(1) # the widget's shared clear() touches it
self._rate = None
self._carry = np.zeros(0, dtype=np.float64)
self._peak = PEAK_FLOOR
self.paths = [] # newest first: (n, 2) arrays in -1..1 screen units
self.clear()
def clear(self):
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))]
# ---- the membrane ----
def _setup(self, rate: int):
self._rate = rate
self._step = max(1, int(round(rate / SIM_RATE)))
fs = rate / self._step
freq = np.array([f for f, _w, _x, _y in MODES])
# Each resonator is the imaginary part of a complex one-pole filter:
# its impulse response is e^(−αn)·sin(ωn), a damped ring at f.
self._alpha = math.pi * freq / (MODE_Q * fs)
self._pole = np.exp(-self._alpha + 2j * math.pi * freq / fs)
weights = np.array([w for _f, w, _x, _y in MODES])
slopes = np.array([(x, y) for _f, _w, x, y in MODES])
# α normalizes each resonator to unit gain at its own resonance.
self._mix = (weights * self._alpha)[:, None] * slopes # (modes, 2)
self._state[:] = 0
self._carry = np.zeros(0, dtype=np.float64)
def drive(self, samples: np.ndarray, rate: int) -> np.ndarray:
"""Feed audio through the membrane; returns the mirror's tilt, (n, 2),
one row per simulated step. Continuous across calls, so splitting the
same audio into different chunks gives the same tilt."""
if rate != self._rate:
self._setup(rate)
data = np.concatenate([self._carry, np.asarray(samples, np.float64)])
usable = len(data) // self._step * self._step
self._carry = data[usable:]
# Averaging blocks is the downsample, and a little of the mirror's
# inertia: it can't follow anything that fast anyway.
x = data[:usable].reshape(-1, self._step).mean(axis=1)
out = []
for start in range(0, len(x), CHUNK):
chunk = x[start:start + CHUNK]
n = np.arange(len(chunk))
# y[n] = p^n · (p·y[−1] + Σ_{k≤n} x[k]·p^−k), all modes at once.
# CHUNK keeps p^−k far from overflowing.
powers = self._pole[:, None] ** n[None, :]
acc = np.cumsum(chunk[None, :] / powers, axis=1)
y = powers * (self._pole[:, None] * self._state[:, None] + acc)
self._state = y[:, -1]
out.append(y.imag.T @ self._mix)
if not out:
return np.zeros((0, 2))
return np.concatenate(out)
# ---- the screen ----
def project(self, tilt: np.ndarray, dt: float) -> np.ndarray:
"""Tilt → spot on the screen, -1..1 each way (y up)."""
peak = float(np.linalg.norm(tilt, axis=1).max()) if len(tilt) else 0.0
self._peak = max(peak, self._peak * math.exp(-dt / GAIN_RELEASE_S),
PEAK_FLOOR)
# Pick the gain that puts the loudest swing at FILL of the screen…
gain = math.atan(FILL * math.tan(BEAM_MAX)) / (2 * self._peak)
# …then reflection doubles the mirror's angle, and a flat screen
# spreads big angles out: D·tan(2θ).
return np.tan(2 * gain * tilt) / math.tan(BEAM_MAX)
def advance(self, frame: Frame) -> bool:
tilt = self.drive(frame.pending, frame.rate)
if len(tilt) == 0:
return False
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(PERSIST) - 1]
return True
def spot(self) -> np.ndarray:
return self.paths[0][-1]
def paint(self, painter, rect, color: QColor, light: QColor = None):
cx, cy = rect.center().x(), rect.center().y()
half = min(rect.width(), rect.height()) / 2 - INSET
if half <= 2:
return
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, PERSIST))):
if len(path) < 2:
continue
# 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] * half,
cy - path[:, 1] * half])
# 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 == PERSIST[0]:
line = QPolygonF([QPointF(x, y) for x, y in xy])
for width, strength in ((7 * s, 0.07), (3 * s, 0.18)):
pen.setWidthF(width)
pen.setColor(_faded(color, strength))
painter.setPen(pen)
painter.drawPolyline(line)
# The core. Dwell: the beam lights a stretch of screen for as
# long as it stays there, so slow stretches are bright and fast
# sweeps faint.
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)
for level in range(1, 7):
idx = np.nonzero(bucket == level)[0]
if len(idx) == 0:
continue
pen.setColor(_faded(color, persist * 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 * half, cy - y * half)
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)
def make_renderer(style: str, fade: float = 1.0):
if style == STYLE_RADIAL:
return RadialRenderer(fade)
if style == STYLE_LASER:
return LaserRenderer()
return BarsRenderer()
+59
View File
@@ -0,0 +1,59 @@
from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtCore import Qt, QSize, QRectF
from PyQt6.QtGui import QPainter
from lintunes.gui.art_window import fit_size
# The window takes a projection screen's shape.
SCREEN_SHAPE = QSize(16, 10)
class VisualizerWindow(QWidget):
"""The transport-bar visualizer, enlarged: opened by double-clicking the
panel, closed by a click (or Esc). It holds no state of its own — it
paints the panel's renderer, so both always show the same frame."""
def __init__(self, visualizer):
super().__init__(visualizer, Qt.WindowType.Window)
self._visualizer = visualizer
self.setWindowTitle("Visualizer")
self.setMinimumSize(160, 100)
self.setToolTip("Click to close")
def open(self, screen=None):
screen = screen or self.screen() or QApplication.primaryScreen()
if screen is not None:
geo = screen.availableGeometry()
size = fit_size(SCREEN_SHAPE, geo.size())
margins = self.contentsMargins() # the app-drawn title bar
self.resize(size.width(), size.height() + margins.top())
self.move(geo.x() + (geo.width() - size.width()) // 2,
geo.y() + (geo.height() - size.height()) // 2)
self.show()
self.raise_()
self.activateWindow()
def mouseReleaseEvent(self, event):
if (event.button() == Qt.MouseButton.LeftButton
and self.contentsRect().contains(event.position().toPoint())):
self.close()
return
super().mouseReleaseEvent(event)
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_Escape:
self.close()
return
super().keyPressEvent(event)
def hideEvent(self, event):
super().hideEvent(event)
self._visualizer.big_window_closed()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
area = self.contentsRect() # below our own title bar (titlebar.py)
painter.fillRect(area, self._visualizer.panel_color())
painter.setClipRect(area)
self._visualizer.paint_renderer(painter, QRectF(area), big=True)
+2
View File
@@ -12,6 +12,8 @@ DEFAULTS = {
# 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)
"visualizer_ghost_fade": 1.0, # seconds a radial dot's trail takes to fade
# Per-area grayscale overrides (0=black .. 255=white). None = inherit the
# current/default look for that surface, so nothing changes until the user
# drags a slider in Preferences.
+357
View File
@@ -0,0 +1,357 @@
"""Round 78: visualizer styles — bars, radial dots, laser — and the big window.
1. ``visualizer_style`` picks the renderer, live; nonsense falls back to bars.
2. ``band_levels`` is the bars' old analysis, and fills too-narrow bands.
3. Radial: frequency is the angle, volume the radius, moves leave ghosts.
4. Laser: a mirror on a membrane — resonance, continuity, stays on screen.
5. Double-click opens the big window without changing the brightness.
6. The Preferences page.
"""
import numpy as np
import pytest
from PyQt6.QtCore import QObject, pyqtSignal, QRectF
from PyQt6.QtGui import QImage, QPainter, QColor
from lintunes.preferences import Preferences
from lintunes.gui.visualizer import VisualizerWidget, MODE_ON, MODE_DIM, MODE_OFF
from lintunes.gui import visualizer_styles as vs
RATE = 44100
class _FakePlayer(QObject):
audio_buffer = pyqtSignal(object)
playing_changed = pyqtSignal(bool)
track_changed = pyqtSignal(object)
def __init__(self, playing=False):
super().__init__()
self._playing = playing
def is_playing(self):
return self._playing
def _sine(hz, seconds=0.1, amp=0.5):
t = np.arange(int(RATE * seconds)) / RATE
return (amp * np.sin(2 * np.pi * hz * t)).astype(np.float32)
def _frame(window, pending=None, dt=0.016):
spectrum = np.abs(np.fft.rfft(window * np.hanning(len(window)))) / (len(window) / 4)
freqs = np.fft.rfftfreq(len(window), d=1.0 / RATE)
if pending is None:
pending = np.zeros(0, np.float32)
return vs.Frame(spectrum, freqs, RATE, pending, dt)
def _paint(renderer, w=140, h=62):
img = QImage(w, h, QImage.Format.Format_ARGB32)
img.fill(QColor("white"))
painter = QPainter(img)
renderer.paint(painter, QRectF(0, 0, w, h), QColor("blue"), QColor("gray"))
painter.end()
return img
# --------------------------------------------------------------------------
# 1. The style preference
# --------------------------------------------------------------------------
class TestStylePreference:
def test_default_is_bars(self, qapp, tmp_path):
vis = VisualizerWidget(_FakePlayer(), Preferences(tmp_path))
assert isinstance(vis._renderer, vs.BarsRenderer)
@pytest.mark.parametrize("style, cls", [
("radial", vs.RadialRenderer), ("laser", vs.LaserRenderer)])
def test_saved_style_is_used(self, qapp, tmp_path, style, cls):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", style)
assert isinstance(VisualizerWidget(_FakePlayer(), prefs)._renderer, cls)
def test_nonsense_falls_back_to_bars(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "oscilloscope") # hand-edited file
vis = VisualizerWidget(_FakePlayer(), prefs)
assert isinstance(vis._renderer, vs.BarsRenderer)
def test_changing_the_pref_swaps_live(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(), prefs)
prefs.set("visualizer_style", "laser")
assert isinstance(vis._renderer, vs.LaserRenderer)
def test_an_unrelated_pref_keeps_the_renderer(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "radial")
vis = VisualizerWidget(_FakePlayer(), prefs)
before = vis._renderer
prefs.set("visualizer_ghost_fade", 2.5)
assert vis._renderer is before # state survives
assert before.fade == 2.5
def test_every_style_paints_without_error(self, qapp):
for style in vs.STYLES:
renderer = vs.make_renderer(style)
window = _sine(1000, 2048 / RATE)
for _ in range(5):
renderer.advance(_frame(window, _sine(90, 0.016)))
for size in ((140, 62), (1200, 750), (4, 4)):
_paint(renderer, *size)
# --------------------------------------------------------------------------
# 2. Shared analysis
# --------------------------------------------------------------------------
class TestBandLevels:
def test_a_sine_lights_its_own_band(self):
frame = _frame(_sine(1000, 2048 / RATE))
edges = vs.band_edges(vs.BANDS, RATE)
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
band = np.searchsorted(edges, 1000) - 1
assert levels.argmax() == band
def test_narrow_bass_bands_are_not_silent(self):
# At 360 bands the bottom ones are narrower than an FFT bin.
frame = _frame(_sine(45, 2048 / RATE))
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
assert levels[:5].min() > 0.5
# --------------------------------------------------------------------------
# 3. Radial
# --------------------------------------------------------------------------
class TestRadial:
def test_frequency_is_the_angle(self):
r = vs.RadialRenderer()
r.advance(_frame(_sine(1000, 2048 / RATE)))
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
degree = np.searchsorted(edges, 1000) - 1
assert abs(int(r.rings.argmax()) - degree) <= 2
def test_volume_is_the_radius(self):
loud, quiet = vs.RadialRenderer(), vs.RadialRenderer()
loud.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
quiet.advance(_frame(_sine(1000, 2048 / RATE, amp=0.01)))
assert loud.rings.max() > quiet.rings.max() > 0
def test_a_falling_dot_leaves_ghosts_that_fade(self):
r = vs.RadialRenderer(fade=0.5)
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
degree, top = int(r.rings.argmax()), int(r.rings.max())
silence = _frame(np.zeros(2048, np.float32))
r.advance(silence)
assert r.ghost_alpha()[degree, top] > 0.9 # where it was, fresh
for _ in range(20): # 0.32 s
r.advance(silence)
assert 0 < r.ghost_alpha()[degree, top] < 0.9
for _ in range(40): # well past 0.5 s
r.advance(silence)
assert r.ghost_alpha().max() == 0
assert not np.isfinite(r.ghosts).any() # forgotten, not just faint
def test_no_fade_means_no_ghosts(self):
r = vs.RadialRenderer(fade=0)
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
r.advance(_frame(np.zeros(2048, np.float32)))
assert r.ghost_alpha().max() == 0
def test_a_rise_leaves_only_where_it_came_from(self):
# An instant attack must not paint a bar of ghosts below the dot.
r = vs.RadialRenderer(fade=1.0)
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.003)))
before = r.rings.copy()
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
degree = int(np.argmax(r.rings - before))
alive = np.nonzero(r.ghost_alpha()[degree] > 0)[0]
assert list(alive) == [before[degree]]
def test_ghosts_per_spoke_are_bounded(self):
r = vs.RadialRenderer(fade=3.0)
rng = np.random.default_rng(1)
for _ in range(200):
r.advance(_frame(_sine(1000, 2048 / RATE, amp=rng.random())))
rings, alpha = r.pooled(45)
assert (alpha > 0).sum(axis=1).max() <= vs.STEPS
def test_bigger_circles_get_more_spokes(self):
assert vs.spoke_count(28) < vs.spoke_count(400) == 360
assert 360 % vs.spoke_count(28) == 0
# --------------------------------------------------------------------------
# 4. Laser
# --------------------------------------------------------------------------
class TestLaser:
def test_silence_is_a_still_dot_in_the_middle(self):
r = vs.LaserRenderer()
for _ in range(10):
r.advance(_frame(np.zeros(2048, np.float32),
np.zeros(706, np.float32)))
assert np.allclose(r.paths[0], 0.0)
def test_the_membrane_answers_its_resonance(self):
f0 = vs.MODES[0][0]
on, off = vs.LaserRenderer(), vs.LaserRenderer()
swing_on = np.abs(on.drive(_sine(f0, 0.5), RATE)).max()
swing_off = np.abs(off.drive(_sine(3000, 0.5), RATE)).max()
assert swing_on > 20 * swing_off
def test_chunking_does_not_change_the_path(self):
audio = _sine(143, 0.3) + _sine(90, 0.3)
whole = vs.LaserRenderer().drive(audio, RATE)
pieces = vs.LaserRenderer()
parts = [pieces.drive(chunk, RATE)
for chunk in np.array_split(audio, [101, 700, 702, 5000])]
assert np.allclose(np.concatenate(parts), whole)
def test_degenerate_modes_turn_a_line_into_a_loop(self):
# The split (1,1) pair: driven between its two resonances, the two
# axes are out of phase — an ellipse, not a line.
f = (vs.MODES[1][0] + vs.MODES[2][0]) / 2
tilt = vs.LaserRenderer().drive(_sine(f, 1.0), RATE)[-2000:]
tilt = tilt - tilt.mean(axis=0)
s = np.linalg.svd(tilt, compute_uv=False)
assert s[1] / s[0] > 0.1
def test_the_spot_stays_on_the_screen(self):
r = vs.LaserRenderer()
rng = np.random.default_rng(0)
audio = (_sine(90, 2, 0.9) * (1 + rng.standard_normal(2 * RATE) * 0.3))
hop = 706
for i in range(0, len(audio) - hop, hop):
r.advance(_frame(np.zeros(2048, np.float32),
audio[i:i + hop], dt=0.016))
assert np.abs(r.paths[0]).max() <= 1.0
def test_loud_music_fills_most_of_the_screen(self):
r = vs.LaserRenderer()
audio = _sine(vs.MODES[0][0], 1.0, 0.5)
for i in range(0, len(audio) - 706, 706):
r.advance(_frame(np.zeros(2048, np.float32), audio[i:i + 706]))
assert np.abs(r.paths[0]).max() > 0.6
# --------------------------------------------------------------------------
# 5. Double-click → big window
# --------------------------------------------------------------------------
class TestBigWindow:
def test_double_click_keeps_the_brightness_and_opens(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
vis.mousePressEvent(None) # Qt's first press: on -> dim
vis.mouseDoubleClickEvent(None) # …which the double-click undoes
try:
assert vis._mode == MODE_ON
assert prefs.get("visualizer_mode") == MODE_ON
assert vis._big is not None and vis._big.isVisible()
finally:
vis._big.close()
def test_a_click_closes_it(self, qapp):
from PyQt6.QtCore import Qt, QPointF, QEvent
from PyQt6.QtGui import QMouseEvent
vis = VisualizerWidget(_FakePlayer())
vis.open_big()
big = vis._big
center = QPointF(big.contentsRect().center())
release = QMouseEvent(
QEvent.Type.MouseButtonRelease, center, center,
Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier)
big.mouseReleaseEvent(release)
assert not big.isVisible()
def test_off_panel_still_animates_the_big_window(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_OFF)
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
assert not vis._timer.isActive()
vis.open_big()
assert vis._timer.isActive()
vis._big.close()
assert not vis._timer.isActive()
def test_big_window_paints_in_color_while_the_panel_is_off(
self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_OFF)
prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
calls = []
vis._renderer.paint = lambda p, rect, color, light: calls.append(rect)
img = QImage(10, 10, QImage.Format.Format_ARGB32)
painter = QPainter(img)
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
assert calls == [] # the panel stays blank
vis.paint_renderer(painter, QRectF(0, 0, 10, 10), big=True)
painter.end()
assert len(calls) == 1
def test_the_laser_is_drawn_in_gray_when_dim(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_DIM)
prefs.set("color_visualizer_gray", 120)
prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(), prefs)
colors = []
vis._renderer.paint = lambda p, rect, color, light: 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):
vis = VisualizerWidget(_FakePlayer())
for _ in range(100):
vis._feed(_sine(440, 0.05), RATE)
assert vis._pending_len <= RATE * 0.25 + RATE * 0.05
# --------------------------------------------------------------------------
# 6. Preferences
# --------------------------------------------------------------------------
class TestPreferencesPage:
def _dialog(self, prefs):
from lintunes.gui.preferences_dialog import PreferencesDialog
class _LastFm(QObject):
login_finished = pyqtSignal(bool, str)
status_message = pyqtSignal(str)
def is_logged_in(self):
return False
return PreferencesDialog(prefs, _LastFm())
def test_style_radios_and_fade_slider(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = self._dialog(prefs)
assert dialog._viz_radios["bars"].isChecked()
assert not dialog._fade_slider.isEnabled()
dialog._viz_radios["radial"].setChecked(True)
assert prefs.get("visualizer_style") == "radial"
assert dialog._fade_slider.isEnabled()
dialog._fade_slider.setValue(25)
assert prefs.get("visualizer_ghost_fade") == 2.5
dialog._viz_radios["laser"].setChecked(True)
assert not dialog._fade_slider.isEnabled()
def test_building_the_dialog_writes_nothing(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "laser")
before = (tmp_path / "preferences.json").read_text()
self._dialog(prefs)
assert (tmp_path / "preferences.json").read_text() == before