Files
lintunes/lintunes/gui/visualizer_styles.py
T
trav 00aa5e7f2e v0.55.0: Kaleidostrobe smooth again; visualizer tile hover controls
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.
2026-10-14 12:51:38 -07:00

1014 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)