The cones move only on the plugin's own 25 fps frames again: driving them from fresh audio every refresh made bass-heavy songs twitch (a jump on 84% of refreshes against 41%). The continuous turn, texture crossfade and the GPU big window stay. The tile: a click opens it big (and it stays blank while big); hovering shows a colour wheel (colour / gray / off) and up/down arrows (previous / next visualizer). No more double-click or click-to-cycle. The big album art closes on a click or Esc, like the big visualizer.
1014 lines
46 KiB
Python
1014 lines
46 KiB
Python
"""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).
|
||
- ``KaleidostrobeRenderer`` (``visualizer_kaleido``) — the 2002 iTunes
|
||
plugin, ported from its PowerPC binary.
|
||
|
||
Every tunable lives in preferences.json under a ``visualizer_*`` key;
|
||
``settings(prefs)`` reads them clamped to ``LIMITS``, and a renderer takes
|
||
the result through ``configure`` — live, without losing its state.
|
||
"""
|
||
import math
|
||
from dataclasses import dataclass
|
||
|
||
import numpy as np
|
||
|
||
from PyQt6.QtCore import Qt, QPointF, QLineF
|
||
from PyQt6.QtGui import QColor, QImage, QPainter, QPen, QPolygonF
|
||
|
||
from lintunes.preferences import DEFAULTS
|
||
|
||
|
||
STYLE_BARS = "bars"
|
||
STYLE_RADIAL = "radial"
|
||
STYLE_LASER = "laser"
|
||
STYLE_KALEIDOSTROBE = "kaleidostrobe"
|
||
STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER, STYLE_KALEIDOSTROBE)
|
||
STYLE_NAMES = {
|
||
STYLE_BARS: "Bars",
|
||
STYLE_RADIAL: "Radial dots",
|
||
STYLE_LASER: "Laser",
|
||
STYLE_KALEIDOSTROBE: "Kaleidostrobe",
|
||
}
|
||
|
||
BANDS = 20
|
||
STEPS = 20 # vertical granularity per bar
|
||
DECAY = 0.82 # slow decay; attack is instant
|
||
FLOOR_DB = -55.0
|
||
LOW_HZ = 40.0
|
||
INSET = 3 # the drawing sits 3px in from every edge
|
||
GHOST_ALPHA = 0.7 # what a falling bar or dot leaves starts this strong
|
||
BARS_FFT = 2048
|
||
RADIAL_FFT = 4096 # 10.8 Hz bins: the bass degrees need the resolution
|
||
HISTORY = RADIAL_FFT # samples the widget keeps for the FFTs
|
||
|
||
# (min, max) for every numeric visualizer setting. The Preferences sliders
|
||
# use the same numbers, so a hand-edited file can't go past what they offer.
|
||
LIMITS = {
|
||
"visualizer_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
|
||
# What the speaker under the mirror plays. At the ends, no filter.
|
||
"visualizer_laser_low_hz": (20, 20000),
|
||
"visualizer_laser_high_hz": (20, 20000),
|
||
"visualizer_radial_spokes": (12, 360),
|
||
"visualizer_radial_levels": (4, 40),
|
||
"visualizer_radial_dot": (0.2, 2.5), # × the automatic size
|
||
"visualizer_laser_distance": (1.0, 4.0), # metres, mirror → screen
|
||
"visualizer_laser_pitch": (-60, 60), # degrees
|
||
"visualizer_laser_yaw": (-60, 60),
|
||
"visualizer_laser_roll": (-180, 180),
|
||
}
|
||
_FLAGS = ("visualizer_radial_normalize", "visualizer_laser_head",
|
||
"visualizer_laser_glow", "visualizer_laser_grid")
|
||
_INTEGER = ("visualizer_radial_spokes", "visualizer_radial_levels",
|
||
"visualizer_laser_low_hz", "visualizer_laser_high_hz")
|
||
SPEAKER_MIN_RATIO = 2 ** (1 / 3) # the narrowest speaker: a third-octave
|
||
|
||
# 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"
|
||
# Negative = sooner: the frame is taken from audio decoded but not yet
|
||
# heard. The player decodes only a few tens of ms ahead (measured 0–30 ms),
|
||
# so that is all "sooner" can borrow; past it the newest audio is used.
|
||
SYNC_DELAY_LIMITS = (-50, 400)
|
||
|
||
|
||
def sync_delay_ms(config: dict) -> int:
|
||
try:
|
||
value = int(config.get(SYNC_DELAY_KEY, 0))
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
return min(SYNC_DELAY_LIMITS[1], max(SYNC_DELAY_LIMITS[0], value))
|
||
|
||
|
||
def settings(prefs) -> dict:
|
||
"""Every visualizer setting, defaulted and clamped."""
|
||
out = {}
|
||
for key, (lo, hi) in LIMITS.items():
|
||
value = prefs.get(key, DEFAULTS[key]) if prefs is not None else None
|
||
try:
|
||
value = min(hi, max(lo, float(value)))
|
||
except (TypeError, ValueError):
|
||
value = DEFAULTS[key]
|
||
out[key] = int(round(value)) if key in _INTEGER else value
|
||
for key in _FLAGS:
|
||
value = prefs.get(key, DEFAULTS[key]) if prefs is not None else None
|
||
out[key] = bool(DEFAULTS[key] if value is None else value)
|
||
low, high = out["visualizer_laser_low_hz"], out["visualizer_laser_high_hz"]
|
||
if low * SPEAKER_MIN_RATIO > high: # crossed or too close: keep high
|
||
out["visualizer_laser_low_hz"] = max(
|
||
LIMITS["visualizer_laser_low_hz"][0],
|
||
int(high / SPEAKER_MIN_RATIO))
|
||
return out
|
||
|
||
|
||
def one_pole(x: np.ndarray, a: float, y0: float):
|
||
"""The one-pole low-pass y[n] = a·y[n−1] + (1−a)·x[n], exact and
|
||
vectorized: y[n] = aⁿ·(a·y₀ + Σ (1−a)·x[k]·a⁻ᵏ). Chunked so a⁻ᵏ stays
|
||
under e²⁵. Returns the output and the state to carry on with."""
|
||
if len(x) == 0:
|
||
return x.copy(), y0
|
||
if a < 1e-4:
|
||
return x.copy(), float(x[-1])
|
||
chunk = max(1, min(4096, int(25 / -math.log(a))))
|
||
out = np.empty(len(x), np.float64)
|
||
for start in range(0, len(x), chunk):
|
||
piece = x[start:start + chunk]
|
||
powers = a ** np.arange(len(piece))
|
||
y = powers * (a * y0 + np.cumsum((1 - a) * piece / powers))
|
||
out[start:start + chunk] = y
|
||
y0 = float(y[-1])
|
||
return out, y0
|
||
|
||
|
||
def thin_path(xy: np.ndarray, spacing: float, cap: int = 4000) -> np.ndarray:
|
||
"""Indices of the points worth drawing: one per ``spacing`` of distance
|
||
along the path (plus the last). Thinning by *count* would alias a fast
|
||
treble zigzag into slow shapes that aren't there; by distance, a zigzag
|
||
keeps every corner and only a slow, smooth stretch loses points."""
|
||
if len(xy) <= 2 or spacing <= 0:
|
||
return np.arange(len(xy))
|
||
travel = np.concatenate([[0.0], np.cumsum(
|
||
np.linalg.norm(np.diff(xy, axis=0), axis=1))])
|
||
if travel[-1] / spacing >= len(xy):
|
||
idx = np.arange(len(xy))
|
||
else:
|
||
marks = np.arange(0.0, travel[-1], spacing)
|
||
idx = np.unique(np.append(np.searchsorted(travel, marks), len(xy) - 1))
|
||
if len(idx) > cap:
|
||
idx = idx[np.linspace(0, len(idx) - 1, cap).astype(int)]
|
||
return idx
|
||
|
||
|
||
@dataclass
|
||
class Frame:
|
||
"""One timer tick's worth of audio, handed to ``advance``."""
|
||
samples: np.ndarray # the newest HISTORY mono samples
|
||
rate: int # sample rate
|
||
pending: np.ndarray # every mono sample that arrived since last tick
|
||
dt: float # seconds since the last tick
|
||
|
||
|
||
_WINDOWS = {}
|
||
|
||
|
||
def spectrum_of(samples: np.ndarray, size: int, rate: float):
|
||
"""|rfft| of the newest ``size`` samples (full-scale sine ≈ 1.0), and
|
||
the frequency of each bin."""
|
||
window = _WINDOWS.get(size)
|
||
if window is None:
|
||
window = _WINDOWS[size] = np.hanning(size).astype(np.float32)
|
||
segment = samples[-size:]
|
||
if len(segment) < size:
|
||
segment = np.concatenate([np.zeros(size - len(segment), np.float32),
|
||
segment])
|
||
spectrum = np.abs(np.fft.rfft(segment * window)) / (size / 4)
|
||
return spectrum, np.fft.rfftfreq(size, d=1.0 / rate)
|
||
|
||
|
||
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_db(spectrum: np.ndarray, freqs: np.ndarray,
|
||
edges: np.ndarray) -> np.ndarray:
|
||
"""Each band's loudest bin, in dB.
|
||
|
||
A band narrower than a bin (the bass end of a many-spoked radial) holds
|
||
no bin of its own. Copying the nearest bin into it gave runs of degrees
|
||
the *same* value — flat arcs where there should be dots — so it reads the
|
||
spectrum interpolated at its center instead."""
|
||
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]
|
||
magnitude[empty] = np.interp(centers, freqs, spectrum)
|
||
return 20.0 * np.log10(magnitude + 1e-10)
|
||
|
||
|
||
def to_level(db: np.ndarray) -> np.ndarray:
|
||
"""dB → 0..1 (0 = FLOOR_DB or quieter)."""
|
||
return np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0)
|
||
|
||
|
||
def band_levels(spectrum, freqs, edges) -> np.ndarray:
|
||
return to_level(band_db(spectrum, freqs, edges))
|
||
|
||
|
||
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:
|
||
"""The 20-band graphic EQ. With a fade time, the steps a falling bar
|
||
leaves behind fade from its color into the background, rather than
|
||
vanishing — the radial's ghosts, as blocks."""
|
||
|
||
name = STYLE_BARS
|
||
|
||
def __init__(self, config=None):
|
||
self.levels = np.zeros(BANDS)
|
||
self.bars = [0] * BANDS # quantized 0..STEPS
|
||
# (band, step) → when the bar last fell past it; -inf = never.
|
||
self.ghosts = np.full((BANDS, STEPS + 1), -np.inf)
|
||
self.clock = 0.0
|
||
self.configure(config or settings(None))
|
||
|
||
def configure(self, config):
|
||
self.fade = config["visualizer_bars_fade"]
|
||
if self.fade <= 0:
|
||
self.ghosts[:] = -np.inf
|
||
|
||
def clear(self):
|
||
self.levels[:] = 0.0
|
||
self.bars = [0] * BANDS
|
||
self.ghosts[:] = -np.inf
|
||
|
||
def ghost_alpha(self) -> np.ndarray:
|
||
if self.fade <= 0:
|
||
return np.zeros_like(self.ghosts)
|
||
return np.clip(1.0 - (self.clock - self.ghosts) / self.fade, 0.0, 1.0)
|
||
|
||
def advance(self, frame: Frame) -> bool:
|
||
self.clock += frame.dt
|
||
spectrum, freqs = spectrum_of(frame.samples, BARS_FFT, frame.rate)
|
||
new_levels = band_levels(spectrum, freqs, band_edges(BANDS, frame.rate))
|
||
# Instant attack, slow decay
|
||
self.levels = np.maximum(new_levels, self.levels * DECAY)
|
||
bars = [int(round(level * STEPS)) for level in self.levels]
|
||
if self.fade > 0:
|
||
old, new = np.array(self.bars), np.array(bars)
|
||
k = np.arange(STEPS + 1)[None, :]
|
||
# Only a fall leaves anything: a rise covers its own steps.
|
||
vacated = (k > new[:, None]) & (k <= old[:, None])
|
||
self.ghosts[vacated] = self.clock
|
||
self.ghosts[self.clock - self.ghosts >= self.fade] = -np.inf
|
||
fading = self.fade > 0 and np.isfinite(self.ghosts).any()
|
||
if bars == self.bars and not fading:
|
||
return False
|
||
self.bars = bars
|
||
return True
|
||
|
||
def paint(self, painter, rect, color: QColor, gray: QColor = None):
|
||
avail_w = rect.width() - 2 * INSET
|
||
avail_h = rect.height() - 2 * INSET
|
||
left = int(rect.x()) + INSET
|
||
baseline = int(rect.y() + rect.height()) - INSET
|
||
bar_width = avail_w / BANDS
|
||
step_height = avail_h / STEPS
|
||
|
||
def column(i):
|
||
x = left + int(i * bar_width)
|
||
end = left + int((i + 1) * bar_width)
|
||
gap = 1 if i < BANDS - 1 else 0 # 1px between bars, none at edge
|
||
return x, max(1, end - x - gap)
|
||
|
||
alpha = self.ghost_alpha()
|
||
if alpha.any():
|
||
for i, k in zip(*np.nonzero(alpha)):
|
||
if k <= self.bars[i]:
|
||
continue # under the bar itself
|
||
x, w = column(i)
|
||
top = baseline - int(k * step_height)
|
||
bottom = baseline - int((k - 1) * step_height)
|
||
painter.fillRect(x, top, w, bottom - top,
|
||
_faded(color, GHOST_ALPHA * alpha[i, k]))
|
||
for i, bar in enumerate(self.bars):
|
||
if bar <= 0:
|
||
continue
|
||
x, w = column(i)
|
||
bar_height = int(bar * step_height)
|
||
painter.fillRect(x, baseline - bar_height, w, bar_height, color)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Radial dots
|
||
# --------------------------------------------------------------------------
|
||
|
||
DOT_PITCH = 3.0 # px between neighbouring dots on the rim, at least
|
||
HUB = 0.22 # the silent middle, as a share of the radius
|
||
GHOST_BUCKETS = 8 # one drawPoints() per bucket of similar alpha
|
||
NORMALIZE_S = 4.0 # how long the bass/treble balance takes to settle
|
||
MAX_EVEN_DB = 24.0 # the most evening out may move a band
|
||
|
||
|
||
class RadialRenderer:
|
||
"""The graphic EQ as a polar dot plot.
|
||
|
||
Frequency is the angle — ``spokes`` log-spaced bands, bass at 12 o'clock
|
||
running clockwise — and volume is the radius, quantized to ``levels``
|
||
rings so the dots line up like the bars' steps. A dot that moves leaves
|
||
a ghost that fades from its color into the background over ``fade`` s.
|
||
|
||
Ghosts are kept as a birth time per (spoke, ring) rather than a list:
|
||
a revisit simply refreshes it, so a spoke can never hold more ghosts
|
||
than it has rings.
|
||
|
||
**Even out bass and treble**: music is loudest in the bass and falls
|
||
away toward the treble, so the dots sit far out on one side of the
|
||
circle and huddle in on the other. Normalizing tracks each band's
|
||
long-run level, fits a straight tilt through the loud ones (dB against
|
||
log-frequency — the *overall* balance, never a single band's peak), and
|
||
takes that tilt out, so on average the dots trace a circle and what moves
|
||
them is the music's change, not its spectral balance.
|
||
"""
|
||
|
||
name = STYLE_RADIAL
|
||
|
||
def __init__(self, config=None):
|
||
self.spokes = self.ring_count = None
|
||
self.configure(config or settings(None))
|
||
|
||
def configure(self, config):
|
||
self.fade = config["visualizer_ghost_fade"]
|
||
self.normalize = config["visualizer_radial_normalize"]
|
||
self.dot_scale = config["visualizer_radial_dot"]
|
||
spokes = config["visualizer_radial_spokes"]
|
||
levels = config["visualizer_radial_levels"]
|
||
if (spokes, levels) != (self.spokes, self.ring_count):
|
||
self.spokes, self.ring_count = spokes, levels
|
||
self.levels = np.zeros(spokes)
|
||
self.rings = np.zeros(spokes, dtype=int) # 0 = silent
|
||
self.ghosts = np.full((spokes, levels + 1), -np.inf)
|
||
self.clock = 0.0
|
||
self._average = None
|
||
if self.fade <= 0:
|
||
self.ghosts[:] = -np.inf
|
||
|
||
def clear(self):
|
||
self.levels[:] = 0.0
|
||
self.rings[:] = 0
|
||
self.ghosts[:] = -np.inf
|
||
|
||
def ghost_alpha(self) -> np.ndarray:
|
||
"""(spoke, 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 _evened(self, db, edges, dt):
|
||
if not self.normalize:
|
||
return db
|
||
floor = np.maximum(db, FLOOR_DB - 20)
|
||
if db.max() > FLOOR_DB: # silence teaches it nothing
|
||
if self._average is None or len(self._average) != len(db):
|
||
self._average = floor.copy()
|
||
else:
|
||
self._average += (floor - self._average) * min(1.0, dt / NORMALIZE_S)
|
||
if self._average is None:
|
||
return db
|
||
x = np.log(np.sqrt(edges[:-1] * edges[1:]))
|
||
# Weighted toward the bands that are actually sounding: the gaps
|
||
# between notes sit near the floor at every pitch and would flatten
|
||
# the fit, leaving the tilt the dots show. A straight line, because
|
||
# a curve fitted through a few loud notes swings wildly where there
|
||
# are none.
|
||
active = to_level(self._average)
|
||
weight = active ** 2 + 1e-3
|
||
trend = np.polyval(np.polyfit(x, self._average, 1, w=weight), x)
|
||
lift = np.clip(np.average(trend, weights=weight) - trend,
|
||
-MAX_EVEN_DB, MAX_EVEN_DB)
|
||
# Never raise a band that hasn't been sounding: that would draw dots
|
||
# for frequencies the song doesn't have.
|
||
# Quiet is fine — a soft cymbal is still there — so the gate is
|
||
# "above the noise", not "loud".
|
||
sounding = np.clip((self._average - (FLOOR_DB - 15)) / 15, 0, 1)
|
||
lift = np.where(lift > 0, lift * sounding, lift)
|
||
return db + lift
|
||
|
||
def advance(self, frame: Frame) -> bool:
|
||
self.clock += frame.dt
|
||
spectrum, freqs = spectrum_of(frame.samples, RADIAL_FFT, frame.rate)
|
||
edges = band_edges(self.spokes, frame.rate)
|
||
db = self._evened(band_db(spectrum, freqs, edges), edges, frame.dt)
|
||
self.levels = np.maximum(to_level(db), self.levels * DECAY)
|
||
rings = np.rint(self.levels * self.ring_count).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(self.ring_count + 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:
|
||
# 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, fits: int):
|
||
"""The state at no more than ``fits`` spokes (what the circle has
|
||
room for): each spoke's ring and (spoke, ring) ghost strength — the
|
||
loudest of the bands it covers — plus each spoke's angle, 0..1."""
|
||
rings, alpha = self.rings, self.ghost_alpha()
|
||
starts = np.arange(self.spokes)
|
||
if self.spokes > fits:
|
||
starts = np.unique(np.linspace(0, self.spokes, max(1, fits) + 1)
|
||
.astype(int)[:-1])
|
||
rings = np.maximum.reduceat(rings, starts)
|
||
alpha = np.maximum.reduceat(alpha, starts, axis=0)
|
||
ends = np.append(starts[1:], self.spokes)
|
||
angle = (starts + ends) / 2 / self.spokes
|
||
alpha = alpha.copy()
|
||
alpha[np.arange(len(rings)), rings] = 0.0 # under the dot itself
|
||
alpha[:, 0] = 0.0 # silence isn't drawn
|
||
return rings, alpha, angle
|
||
|
||
def paint(self, painter, rect, color: QColor, gray: QColor = None):
|
||
cx, cy = rect.center().x(), rect.center().y()
|
||
radius = min(rect.width(), rect.height()) / 2 - INSET
|
||
if radius <= 2:
|
||
return
|
||
fits = int(2 * math.pi * radius / DOT_PITCH)
|
||
rings, alpha, angle = self.pooled(fits)
|
||
spokes = len(rings)
|
||
hub = radius * HUB
|
||
ring_step = (radius - hub) / self.ring_count
|
||
# Smaller than the gap to every neighbour, so a trail stays a row of
|
||
# dots rather than running together into a band.
|
||
# Then the user's share of that: dot size is a percentage, because
|
||
# the right size in pixels differs between the panel and the window.
|
||
dot = max(1.0, max(1.5, min(2 * math.pi * radius / spokes, ring_step)
|
||
* 0.7) * self.dot_scale)
|
||
sin, cos = np.sin(2 * math.pi * angle), np.cos(2 * math.pi * angle)
|
||
|
||
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. Each is
|
||
# the dot's own color, fading into the background behind it.
|
||
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(color, 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 to 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 membrane
|
||
# never is: the pair splits, and between the two resonances they answer the
|
||
# same note up to 90° apart — the beam swings along one axis a quarter
|
||
# cycle behind the other, which is a circle. A mirror near the middle sits
|
||
# where that rocking pair dominates (the (0,1) "breathing" mode has no
|
||
# slope at the center, so it only draws lines), which is why the rig's
|
||
# figures are loops rather than strokes. A perfectly even push wouldn't
|
||
# excite the 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θ, and the spot is
|
||
# where that ray meets the screen. A screen square to the beam puts it at
|
||
# D·tan(2θ); a screen turned away (pitch, yaw) stretches the figure along
|
||
# the turn and spreads its far side wider (keystone); roll spins it. 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),
|
||
)
|
||
# A big, slack balloon: its rocking pairs land in the bass, where the
|
||
# notes are, rather than above them — measured over random bass lines, that
|
||
# is loops ~60% of the time against ~25% for a tight 90 Hz drum head.
|
||
FUNDAMENTAL_HZ = 50.0
|
||
MODE_Q = 4.5 # rubber is lossy, which widens where loops happen
|
||
# The uneven stretch, per mode: wider than a resonance, as a hand-stretched
|
||
# balloon is, and what makes loops.
|
||
SPLIT = (0.0, 0.30, 0.24, 0.0, 0.18)
|
||
MIRROR_AT = (0.15, 0.6) # the mirror's spot: radius (of 1), angle (rad)
|
||
SIM_RATE = 11025.0 # the membrane barely answers above a few hundred Hz
|
||
SCREEN_HALF = 1.0 # the screen's half-height, metres
|
||
FILL = 0.85 # at the farthest distance, the loudest swings fill this
|
||
# The auto-gain holds the beam's loudest swing at this angle, so distance
|
||
# is real: the figure grows as the screen moves back, filling it at the
|
||
# slider's far end.
|
||
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
|
||
# Silence must stay a still dot, not amplified hiss: the auto-gain never
|
||
# turns up past what -60 dB hiss would swing, 22 dB over. Measured through
|
||
# the chosen speaker, since treble alone moves the mirror ~80 dB less than
|
||
# bass — one fixed floor would hide it entirely.
|
||
HISS = 10 ** (-60 / 20)
|
||
FLOOR_OVER_HISS = 10 ** (22 / 20)
|
||
PEAK_FLOOR = 0.0005 # until a sample rate is known
|
||
AFTERIMAGE_ALPHA = 0.6 # a just-past sweep, before its fade begins
|
||
# One-pole stages per edge: 36 dB/octave. A real speaker rolls off more
|
||
# gently, but the membrane answers bass ~80 dB more than treble, so anything
|
||
# softer lets the bass through a "treble only" speaker and drown it.
|
||
SPEAKER_POLES = 6
|
||
BASS_TOP_HZ = 300 # a speaker cut above this has lost the bass
|
||
AFTERIMAGE_POINTS = 250
|
||
# The current sweep's most points. Only a treble-only speaker (every sample
|
||
# simulated) gets near it, and its figure is a shimmer along one line: a
|
||
# solid band whether 700 corners are drawn or 360. Stroking them all ran the
|
||
# big window past a frame.
|
||
SWEEP_POINTS = 360
|
||
PHOSPHOR_GONE = 0.02 # what's left of a sweep once its afterimage time is up
|
||
PHOSPHOR_HALF_OVER = 400 # px wide: past this, the phosphor is half resolution
|
||
CHUNK = 256 # samples per vectorized resonator step
|
||
ROOM = 0.95 # the figure never comes closer to the edge than this
|
||
GRID_HALF = 4 # graticule divisions from the center to the short edge
|
||
GRID_TICKS = 5 # fine ticks per division along the center lines
|
||
|
||
|
||
def _bessel_j(m: int, x: float) -> float:
|
||
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()
|
||
|
||
|
||
def _rotation(pitch, yaw, roll) -> np.ndarray:
|
||
"""The screen's orientation: columns are its right, up and normal."""
|
||
p, y, r = (math.radians(a) for a in (pitch, yaw, roll))
|
||
rx = np.array([[1, 0, 0], [0, math.cos(p), -math.sin(p)],
|
||
[0, math.sin(p), math.cos(p)]])
|
||
ry = np.array([[math.cos(y), 0, math.sin(y)], [0, 1, 0],
|
||
[-math.sin(y), 0, math.cos(y)]])
|
||
rz = np.array([[math.cos(r), -math.sin(r), 0],
|
||
[math.sin(r), math.cos(r), 0], [0, 0, 1]])
|
||
return ry @ rx @ rz
|
||
|
||
|
||
class LaserRenderer:
|
||
name = STYLE_LASER
|
||
|
||
def __init__(self, config=None):
|
||
self.levels = np.zeros(1) # the widget's shared clear() touches it
|
||
self._rate = None
|
||
# 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._phosphor = {} # (w, h) → _Phosphor
|
||
self.clock = 0.0
|
||
self.band = None
|
||
self._floor = PEAK_FLOOR
|
||
self.configure(config or settings(None))
|
||
self.clear()
|
||
|
||
def configure(self, config):
|
||
# Anything here can change how a sweep maps to pixels: start the
|
||
# phosphor afresh rather than mix two pictures.
|
||
self._phosphor = {}
|
||
self.afterimage = config["visualizer_laser_afterimage"]
|
||
self.width = config["visualizer_laser_width"]
|
||
self.dot = config["visualizer_laser_dot"]
|
||
self.head = config["visualizer_laser_head"]
|
||
self.glow = config["visualizer_laser_glow"]
|
||
self.grid = config["visualizer_laser_grid"]
|
||
band = (config["visualizer_laser_low_hz"],
|
||
config["visualizer_laser_high_hz"])
|
||
if band != getattr(self, "band", None):
|
||
self.band = band
|
||
if self._rate:
|
||
self._setup(self._rate)
|
||
self._prune()
|
||
self.distance = config["visualizer_laser_distance"]
|
||
rot = _rotation(config["visualizer_laser_pitch"],
|
||
config["visualizer_laser_yaw"],
|
||
config["visualizer_laser_roll"])
|
||
self._right, self._up, self._normal = rot[:, 0], rot[:, 1], rot[:, 2]
|
||
# How far the loudest possible swing reaches on this screen, each
|
||
# way: what painting scales by so it always stays in view.
|
||
ring = np.linspace(0, 2 * math.pi, 96, endpoint=False)
|
||
edge = np.column_stack([np.cos(ring), np.sin(ring)]) * BEAM_TARGET
|
||
spots = self.on_screen(edge)
|
||
self.extent = (max(np.abs(spots[:, 0]).max(), 1e-6),
|
||
max(np.abs(spots[:, 1]).max(), 1e-6))
|
||
|
||
def clear(self):
|
||
self._state = np.zeros(len(MODES), dtype=np.complex128)
|
||
self._carry = np.zeros(0, dtype=np.float64)
|
||
self._peak = self._floor
|
||
self._filter_state = [0.0] * (2 * SPEAKER_POLES)
|
||
self.paths = [(np.zeros((1, 2)), self.clock, None)]
|
||
self._phosphor = {}
|
||
|
||
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 ----
|
||
|
||
def _setup(self, rate: int):
|
||
self._rate = rate
|
||
low, high = self.band
|
||
lo_end, hi_end = LIMITS["visualizer_laser_high_hz"]
|
||
# The speaker: a high-pass at `low` and a low-pass at `high`, 12
|
||
# dB/octave each, left out entirely at the slider's ends.
|
||
self._low_a = (math.exp(-2 * math.pi * low / rate)
|
||
if low > lo_end else None)
|
||
self._high_a = (math.exp(-2 * math.pi * high / rate)
|
||
if high < min(hi_end, rate * 0.45) else None)
|
||
self._filter_state = [0.0] * (2 * SPEAKER_POLES)
|
||
# Simulate fast enough for what the speaker passes. While bass gets
|
||
# through, treble moves the mirror ~80 dB less — far under a pixel —
|
||
# so a quarter rate does; only a speaker cut above the bass needs
|
||
# every sample.
|
||
fast = low >= BASS_TOP_HZ
|
||
self._step = max(1, int(rate / max(
|
||
SIM_RATE, 2.5 * min(high, rate / 2) if fast else 0)))
|
||
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 = np.zeros(len(MODES), dtype=np.complex128)
|
||
self._carry = np.zeros(0, dtype=np.float64)
|
||
# Measure the floor: reference hiss through this very speaker and
|
||
# membrane (the drive is fully deterministic, so is the floor).
|
||
hiss = np.random.default_rng(0).standard_normal(int(rate * 0.3)) * HISS
|
||
tilt = self.drive(hiss, rate)
|
||
self._floor = max(1e-12, float(np.linalg.norm(
|
||
tilt[len(tilt) // 2:], axis=1).max()) * FLOOR_OVER_HISS)
|
||
self._state[:] = 0
|
||
self._carry = np.zeros(0, dtype=np.float64)
|
||
self._filter_state = [0.0] * (2 * SPEAKER_POLES)
|
||
self._peak = self._floor
|
||
|
||
def _speaker(self, samples) -> np.ndarray:
|
||
"""What the speaker under the mirror plays of ``samples``."""
|
||
x = np.asarray(samples, np.float64)
|
||
state = self._filter_state
|
||
for i in range(SPEAKER_POLES):
|
||
if self._low_a is not None: # high-pass = input − low-pass
|
||
below, state[i] = one_pole(x, self._low_a, state[i])
|
||
x = x - below
|
||
if self._high_a is not None:
|
||
x, state[SPEAKER_POLES + i] = one_pole(
|
||
x, self._high_a, state[SPEAKER_POLES + i])
|
||
return x
|
||
|
||
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, self._speaker(samples)])
|
||
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 on_screen(self, beam: np.ndarray) -> np.ndarray:
|
||
"""Beam angles (n, 2) from the rest direction → where the ray meets
|
||
the screen, metres from its center along its own right and up."""
|
||
ray = np.column_stack([np.tan(beam[:, 0]), np.tan(beam[:, 1]),
|
||
np.ones(len(beam))])
|
||
center = np.array([0.0, 0.0, self.distance])
|
||
# ray·t meets the plane through `center` with this normal at
|
||
# t = (n·c)/(n·ray). The angle limits keep n·ray well above 0.
|
||
t = (self._normal @ center) / np.maximum(ray @ self._normal, 1e-3)
|
||
offset = ray * t[:, None] - center
|
||
return np.column_stack([offset @ self._right, offset @ self._up])
|
||
|
||
def project(self, tilt: np.ndarray, dt: float) -> np.ndarray:
|
||
"""Mirror tilt → spot on the screen (metres, 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),
|
||
self._floor)
|
||
# The gain that swings the loudest beam to BEAM_TARGET — reflection
|
||
# doubles the mirror's angle, hence the 2.
|
||
gain = BEAM_TARGET / (2 * self._peak)
|
||
return self.on_screen(2 * gain * tilt)
|
||
|
||
def advance(self, frame: Frame) -> bool:
|
||
self.clock += frame.dt
|
||
tilt = self.drive(frame.pending, frame.rate)
|
||
if len(tilt) == 0:
|
||
self._prune()
|
||
return len(self.paths) > 1
|
||
path = self.project(tilt, frame.dt)
|
||
# Join this frame's path onto the last one's end, so the line is
|
||
# unbroken where two frames meet.
|
||
last, born, _ = self.paths[0]
|
||
path = np.vstack([last[-1:], path])
|
||
older = []
|
||
if self.afterimage > 0:
|
||
# ~1 px in the big window, and at most AFTERIMAGE_POINTS: an
|
||
# afterimage is faint, and stroking it is the costliest thing a
|
||
# frame draws (only a treble-only speaker reaches the cap, and
|
||
# its figure is a line, where thinning can't invent a shape).
|
||
thin = last[thin_path(last, max(self.extent) / 250,
|
||
cap=AFTERIMAGE_POINTS)]
|
||
older = [(last, born, QPolygonF([QPointF(x, y) for x, y in thin]))]
|
||
self.paths = [(path, self.clock, None)] + older + self.paths[1:]
|
||
self._prune()
|
||
return True
|
||
|
||
def spot(self) -> np.ndarray:
|
||
return self.paths[0][0][-1]
|
||
|
||
def scale(self, rect) -> float:
|
||
"""Pixels per metre: the screen's half-height fills the rect's, and
|
||
shrinks only if the loudest swing would leave the rect (a steeply
|
||
turned screen) — so the figure is always on screen."""
|
||
half_w = rect.width() / 2 - INSET
|
||
half_h = rect.height() / 2 - INSET
|
||
unit = min(half_w, half_h) / SCREEN_HALF
|
||
reach_x, reach_y = self.extent
|
||
return unit * min(1.0, ROOM * half_w / (reach_x * unit),
|
||
ROOM * half_h / (reach_y * unit))
|
||
|
||
def _exposed(self, rect, px, color) -> "_Phosphor":
|
||
"""This rect's phosphor, brought up to now: dimmed for the time
|
||
since it was last shown, then every sweep that has stopped being the
|
||
newest since then burned in. Each size (panel, big window) has its
|
||
own, so either can be shown without the other."""
|
||
key = (max(1, round(rect.width())), max(1, round(rect.height())))
|
||
phosphor = self._phosphor.get(key)
|
||
if phosphor is None:
|
||
scale = 0.5 if key[0] > PHOSPHOR_HALF_OVER else 1.0
|
||
phosphor = self._phosphor[key] = _Phosphor(*key, scale)
|
||
if phosphor.clock is not None and self.clock > phosphor.clock:
|
||
phosphor.dim(PHOSPHOR_GONE ** ((self.clock - phosphor.clock)
|
||
/ self.afterimage))
|
||
phosphor.clock = self.clock
|
||
fresh = [entry for entry in self.paths[1:]
|
||
if entry[1] > phosphor.through]
|
||
if fresh:
|
||
painter = QPainter(phosphor.image)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
height, width = phosphor.pixels.shape[:2]
|
||
painter.translate(width / 2, height / 2)
|
||
painter.scale(px * phosphor.scale, -px * phosphor.scale)
|
||
pen = QPen()
|
||
pen.setCosmetic(True) # the width stays in pixels
|
||
pen.setWidthF(max(1.0, self.width * phosphor.scale))
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||
for _path, born, polygon in reversed(fresh): # oldest first
|
||
age = self.clock - born
|
||
pen.setColor(_faded(color, AFTERIMAGE_ALPHA * PHOSPHOR_GONE
|
||
** (age / self.afterimage)))
|
||
painter.setPen(pen)
|
||
painter.drawPolyline(polygon)
|
||
painter.end()
|
||
phosphor.through = fresh[0][1]
|
||
return phosphor
|
||
|
||
def paint_grid(self, painter, rect, gray: QColor):
|
||
"""An oscilloscope's graticule: dots at every division crossing, and
|
||
finer ticks along the two center lines. Painted *on the glass*, so
|
||
it stays put in the window whatever the screen's angle or distance."""
|
||
cx, cy = rect.center().x(), rect.center().y()
|
||
division = (min(rect.width(), rect.height()) / 2 - INSET) / GRID_HALF
|
||
if division < 4:
|
||
return
|
||
nx = int((rect.width() / 2 - INSET) // division)
|
||
ny = int((rect.height() / 2 - INSET) // division)
|
||
major = [QPointF(cx + i * division, cy + j * division)
|
||
for i in range(-nx, nx + 1) for j in range(-ny, ny + 1)]
|
||
fine = division / GRID_TICKS
|
||
ticks = [QPointF(cx + k * fine, cy)
|
||
for k in range(-nx * GRID_TICKS, nx * GRID_TICKS + 1)
|
||
if k % GRID_TICKS]
|
||
ticks += [QPointF(cx, cy + k * fine)
|
||
for k in range(-ny * GRID_TICKS, ny * GRID_TICKS + 1)
|
||
if k % GRID_TICKS]
|
||
pen = QPen(gray)
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
pen.setWidthF(max(1.5, division / 12))
|
||
painter.setPen(pen)
|
||
painter.drawPoints(major)
|
||
pen.setWidthF(max(1.0, division / 20))
|
||
painter.setPen(pen)
|
||
painter.drawPoints(ticks)
|
||
|
||
def paint(self, painter, rect, color: QColor, gray: QColor = None):
|
||
cx, cy = rect.center().x(), rect.center().y()
|
||
half = min(rect.width(), rect.height()) / 2 - INSET
|
||
if half <= 2:
|
||
return
|
||
if self.grid and gray is not None:
|
||
self.paint_grid(painter, rect, gray) # under the beam
|
||
px = self.scale(rect)
|
||
pen = QPen()
|
||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||
|
||
# The afterimage: what the phosphor still holds of past sweeps.
|
||
if self.afterimage > 0 and len(self.paths) > 1:
|
||
painter.save()
|
||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||
painter.drawImage(rect, self._exposed(rect, px, color).image)
|
||
painter.restore()
|
||
|
||
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–735 steps, mostly sub-pixel in the panel; keep
|
||
# one point per ¾ px travelled (fast wiggles keep their corners,
|
||
# up to SWEEP_POINTS).
|
||
idx = thin_path(xy, 0.75, cap=SWEEP_POINTS)
|
||
spans = np.maximum(np.diff(idx), 1)
|
||
xy = xy[idx]
|
||
# The haze and the screen's scatter: one even bloom along the
|
||
# whole path, a single polyline so joints don't stack up alpha.
|
||
if self.glow:
|
||
# The bloom is several pixels wide: a coarser path is the
|
||
# same picture for a fraction of the stroking.
|
||
coarse = xy[thin_path(xy, 1.5 * self.width, cap=SWEEP_POINTS // 2)]
|
||
line = QPolygonF([QPointF(x, y) for x, y in coarse])
|
||
for width, strength in ((6 * self.width, 0.07),
|
||
(2.5 * self.width, 0.18)):
|
||
pen.setWidthF(width)
|
||
pen.setColor(_faded(color, strength))
|
||
painter.setPen(pen)
|
||
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) / spans
|
||
bright = np.clip(0.04 * half / (speed + 1e-6), 0.15, 1.0)
|
||
bucket = np.ceil(bright * 6).astype(int)
|
||
pen.setWidthF(self.width)
|
||
for level in range(1, 7):
|
||
idx = np.nonzero(bucket == level)[0]
|
||
if len(idx) == 0:
|
||
continue
|
||
pen.setColor(_faded(color, level / 6))
|
||
painter.setPen(pen)
|
||
painter.drawLines([QLineF(*xy[i], *xy[i + 1]) for i in idx])
|
||
|
||
# The beam itself. At rest it is all there is, so it always shows;
|
||
# while the music moves it, it's the "head" setting's call.
|
||
resting = np.ptp(xy, axis=0).max() < 1.0
|
||
if self.head or resting:
|
||
head = QPointF(*xy[-1])
|
||
radius = self.dot / 2
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
if self.glow:
|
||
painter.setBrush(_faded(color, 0.25))
|
||
painter.drawEllipse(head, radius * 2.5, radius * 2.5)
|
||
painter.setBrush(color)
|
||
painter.drawEllipse(head, radius, radius)
|
||
|
||
|
||
class _Phosphor:
|
||
"""An afterimage buffer, like a scope tube's phosphor: sweeps are burned
|
||
in once and the whole picture is dimmed a little each frame, so a long
|
||
afterimage costs no more than a short one (drawing every past sweep each
|
||
frame did, antialiased, and ran past a frame's budget in the big window).
|
||
|
||
Dimming is integer math that rounds *down*, through a lookup table:
|
||
Qt's own fade rounds to nearest, so faint pixels stall at a level where
|
||
×factor rounds back to itself and stay as a stain forever (at a 3 s fade,
|
||
~20% bright). Rounding down always reaches zero. Wide targets keep the
|
||
buffer at half resolution: the dimming is a numpy pass over every pixel,
|
||
and a faint afterimage loses nothing by being a touch soft."""
|
||
|
||
def __init__(self, width: int, height: int, scale: float):
|
||
self.scale = scale
|
||
w, h = max(1, int(width * scale)), max(1, int(height * scale))
|
||
self.image = QImage(w, h, QImage.Format.Format_ARGB32_Premultiplied)
|
||
self.image.fill(0)
|
||
# A numpy *view* of the image's own memory. (Building the QImage
|
||
# from a numpy array instead copies it: the dimming then changed an
|
||
# array nobody draws.)
|
||
bits = self.image.bits()
|
||
bits.setsize(self.image.sizeInBytes())
|
||
self.pixels = np.frombuffer(bits, np.uint8).reshape(
|
||
h, self.image.bytesPerLine() // 4, 4)
|
||
self.clock = None # the renderer's clock when last brought up
|
||
self.through = -math.inf # born-time of the newest sweep burned in
|
||
|
||
def dim(self, factor: float):
|
||
keep = int(max(0.0, min(1.0, factor)) * 256)
|
||
if keep >= 256:
|
||
return
|
||
table = ((np.arange(256) * keep) >> 8).astype(np.uint8)
|
||
np.take(table, self.pixels, out=self.pixels)
|
||
|
||
|
||
def make_renderer(style: str, config=None):
|
||
if style == STYLE_RADIAL:
|
||
return RadialRenderer(config)
|
||
if style == STYLE_LASER:
|
||
return LaserRenderer(config)
|
||
if style == STYLE_KALEIDOSTROBE:
|
||
from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer
|
||
return KaleidostrobeRenderer(config)
|
||
return BarsRenderer(config)
|