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:
@@ -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
|
for every add-to-playlist path. `theme.py` applies the palette (highlight
|
||||||
colors, UI scales) from prefs.
|
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.
|
- **`lintunes/importers/itunes_importer.py`** — parses the iTunes XML plist.
|
||||||
Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with
|
Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with
|
||||||
case/Unicode-normalization fuzzy matching (macOS is case-insensitive + NFD vs
|
case/Unicode-normalization fuzzy matching (macOS is case-insensitive + NFD vs
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.38.0"
|
__version__ = "0.39.0"
|
||||||
|
|||||||
@@ -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):
|
class PreferencesDialog(QDialog):
|
||||||
"""App preferences. Applies immediately — there is no OK/Cancel.
|
"""App preferences. Applies immediately — there is no OK/Cancel.
|
||||||
|
|
||||||
@@ -62,6 +70,7 @@ class PreferencesDialog(QDialog):
|
|||||||
for title, groups in (
|
for title, groups in (
|
||||||
("General", general),
|
("General", general),
|
||||||
("Appearance", [self._build_general_group(),
|
("Appearance", [self._build_general_group(),
|
||||||
|
self._build_visualizer_group(),
|
||||||
self._build_colors_group()]),
|
self._build_colors_group()]),
|
||||||
("Integrations", [self._build_lastfm_group(),
|
("Integrations", [self._build_lastfm_group(),
|
||||||
self._build_acoustid_group()])):
|
self._build_acoustid_group()])):
|
||||||
@@ -313,6 +322,66 @@ class PreferencesDialog(QDialog):
|
|||||||
|
|
||||||
# ---- Colors ----
|
# ---- 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:
|
def _build_colors_group(self) -> QGroupBox:
|
||||||
group = QGroupBox("Colors")
|
group = QGroupBox("Colors")
|
||||||
form = QFormLayout(group)
|
form = QFormLayout(group)
|
||||||
|
|||||||
+164
-84
@@ -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
|
The player's QAudioBufferOutput delivers buffers essentially in lockstep
|
||||||
with the audible position (verified: frames received ≈ frames played), so
|
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
|
import numpy as np
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QWidget
|
from PyQt6.QtWidgets import QWidget
|
||||||
from PyQt6.QtCore import Qt, QTimer, QRectF, pyqtSignal
|
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 PyQt6.QtMultimedia import QAudioFormat
|
||||||
|
|
||||||
from lintunes import theme
|
from lintunes import theme
|
||||||
from lintunes.gui.icons import transport_icon
|
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
|
FFT_SIZE = 2048
|
||||||
FRAME_MS = 16 # ~60 fps
|
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
|
RADIUS = 8 # rounded corners, matching the transport button boxes
|
||||||
ICON_SIZE = 40 # the cast glyph shown in place of bars while casting
|
ICON_SIZE = 40 # the cast glyph shown in place of bars while casting
|
||||||
|
PENDING_S = 0.25 # most unconsumed audio kept for the laser
|
||||||
|
|
||||||
# Visualizer click cycles through these brightness modes in order.
|
# Visualizer click cycles through these brightness modes in order.
|
||||||
MODE_ON = "on"
|
MODE_ON = "on"
|
||||||
@@ -30,6 +31,8 @@ MODE_DIM = "dim"
|
|||||||
MODE_OFF = "off"
|
MODE_OFF = "off"
|
||||||
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
|
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
|
||||||
|
|
||||||
|
_TOOLTIP = "Click to cycle: on / dim / off — double-click to enlarge"
|
||||||
|
|
||||||
_SAMPLE_DTYPES = {
|
_SAMPLE_DTYPES = {
|
||||||
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
|
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
|
||||||
QAudioFormat.SampleFormat.Int16: (np.int16, 0.0, 32768.0),
|
QAudioFormat.SampleFormat.Int16: (np.int16, 0.0, 32768.0),
|
||||||
@@ -38,11 +41,25 @@ _SAMPLE_DTYPES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class VisualizerWidget(QWidget):
|
def _style(prefs) -> str:
|
||||||
"""Animated spectrum bars; click to cycle brightness: on / dim / off.
|
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
|
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.
|
cast indicator: it shows the device glyph and a click stops casting.
|
||||||
@@ -56,11 +73,14 @@ class VisualizerWidget(QWidget):
|
|||||||
self._prefs = prefs
|
self._prefs = prefs
|
||||||
saved = prefs.get("visualizer_mode") if prefs is not None else None
|
saved = prefs.get("visualizer_mode") if prefs is not None else None
|
||||||
self._mode = saved if saved in _MODE_CYCLE else MODE_ON
|
self._mode = saved if saved in _MODE_CYCLE else MODE_ON
|
||||||
self._levels = np.zeros(BANDS)
|
self._mode_before_press = self._mode
|
||||||
self._bars = [0] * BANDS # quantized 0..STEPS
|
self._renderer = make_renderer(_style(prefs), _fade(prefs))
|
||||||
self._samples = np.zeros(FFT_SIZE, dtype=np.float32)
|
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._sample_rate = 44100
|
||||||
self._window = np.hanning(FFT_SIZE).astype(np.float32)
|
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
|
# True while audio is coming out of something other than this machine
|
||||||
# (a Chromecast), so there is no decoded PCM to draw.
|
# (a Chromecast), so there is no decoded PCM to draw.
|
||||||
self._no_pcm = False
|
self._no_pcm = False
|
||||||
@@ -68,7 +88,7 @@ class VisualizerWidget(QWidget):
|
|||||||
|
|
||||||
self.setFixedWidth(140)
|
self.setFixedWidth(140)
|
||||||
self.setMinimumHeight(36)
|
self.setMinimumHeight(36)
|
||||||
self.setToolTip("Click to cycle: on / dim / off")
|
self.setToolTip(_TOOLTIP)
|
||||||
|
|
||||||
self._timer = QTimer(self)
|
self._timer = QTimer(self)
|
||||||
self._timer.setInterval(FRAME_MS)
|
self._timer.setInterval(FRAME_MS)
|
||||||
@@ -79,6 +99,29 @@ class VisualizerWidget(QWidget):
|
|||||||
player.track_changed.connect(self._on_track_changed)
|
player.track_changed.connect(self._on_track_changed)
|
||||||
if hasattr(player, "sink_changed"):
|
if hasattr(player, "sink_changed"):
|
||||||
player.sink_changed.connect(self._on_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):
|
def _on_sink_changed(self, sink):
|
||||||
"""Casting means no local decode, so there is nothing to draw. The
|
"""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)
|
self._no_pcm = not getattr(sink, "provides_pcm", True)
|
||||||
if self._no_pcm:
|
if self._no_pcm:
|
||||||
self._device = getattr(sink, "name", "") or "a Chromecast"
|
self._device = getattr(sink, "name", "") or "a Chromecast"
|
||||||
self._timer.stop()
|
|
||||||
self._clear()
|
self._clear()
|
||||||
self.setToolTip(f"Casting to {self._device} — click to stop")
|
self.setToolTip(f"Casting to {self._device} — click to stop")
|
||||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
else:
|
else:
|
||||||
self._device = ""
|
self._device = ""
|
||||||
self.setToolTip("Click to cycle: on / dim / off")
|
self.setToolTip(_TOOLTIP)
|
||||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||||
if self._mode != MODE_OFF and self._player.is_playing():
|
self._sync_timer()
|
||||||
self._timer.start()
|
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
# ---- audio intake ----
|
# ---- audio intake ----
|
||||||
@@ -115,36 +156,75 @@ class VisualizerWidget(QWidget):
|
|||||||
usable = (len(raw) // channels) * channels
|
usable = (len(raw) // channels) * channels
|
||||||
raw = raw[:usable].reshape(-1, channels).mean(axis=1)
|
raw = raw[:usable].reshape(-1, channels).mean(axis=1)
|
||||||
mono = (raw.astype(np.float32) - offset) / scale
|
mono = (raw.astype(np.float32) - offset) / scale
|
||||||
self._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:
|
if len(mono) >= FFT_SIZE:
|
||||||
self._samples = mono[-FFT_SIZE:].copy()
|
self._samples = mono[-FFT_SIZE:].copy()
|
||||||
else:
|
else:
|
||||||
self._samples = np.roll(self._samples, -len(mono))
|
self._samples = np.roll(self._samples, -len(mono))
|
||||||
self._samples[-len(mono):] = 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 ----
|
# ---- 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):
|
def _on_playing_changed(self, playing):
|
||||||
if self._no_pcm:
|
if self._no_pcm:
|
||||||
return # nothing to animate; don't let play/pause restart the timer
|
return # nothing to animate; don't let play/pause restart the timer
|
||||||
if playing:
|
self._sync_timer()
|
||||||
if self._mode != MODE_OFF:
|
if not playing:
|
||||||
self._timer.start()
|
self._clear()
|
||||||
else:
|
|
||||||
self._timer.stop()
|
|
||||||
if self._mode != MODE_OFF:
|
|
||||||
self._clear()
|
|
||||||
|
|
||||||
def _on_track_changed(self, track):
|
def _on_track_changed(self, track):
|
||||||
self._samples[:] = 0.0
|
self._samples[:] = 0.0
|
||||||
if track is None and self._mode != MODE_OFF:
|
self._take_pending()
|
||||||
|
if track is None:
|
||||||
self._timer.stop()
|
self._timer.stop()
|
||||||
self._clear()
|
self._clear()
|
||||||
|
|
||||||
def _clear(self):
|
def _clear(self):
|
||||||
self._levels[:] = 0.0
|
self._renderer.clear()
|
||||||
self._bars = [0] * BANDS
|
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()
|
self.update()
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
@@ -155,15 +235,31 @@ class VisualizerWidget(QWidget):
|
|||||||
self.cast_stop_requested.emit()
|
self.cast_stop_requested.emit()
|
||||||
return
|
return
|
||||||
# Cycle on -> dim -> off -> on.
|
# Cycle on -> dim -> off -> on.
|
||||||
|
self._mode_before_press = self._mode
|
||||||
idx = _MODE_CYCLE.index(self._mode)
|
idx = _MODE_CYCLE.index(self._mode)
|
||||||
self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]
|
self._set_mode(_MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)])
|
||||||
if self._prefs is not None:
|
|
||||||
self._prefs.set("visualizer_mode", self._mode)
|
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:
|
if self._mode == MODE_OFF:
|
||||||
self._timer.stop()
|
self._clear()
|
||||||
self._clear() # off = blank, not the last frame held in place
|
self._sync_timer()
|
||||||
elif self._player.is_playing():
|
|
||||||
self._timer.start()
|
|
||||||
|
|
||||||
# ---- frame computation ----
|
# ---- frame computation ----
|
||||||
|
|
||||||
@@ -171,52 +267,51 @@ class VisualizerWidget(QWidget):
|
|||||||
spectrum = np.abs(np.fft.rfft(self._samples * self._window))
|
spectrum = np.abs(np.fft.rfft(self._samples * self._window))
|
||||||
# Normalize: full-scale sine -> ~1.0
|
# Normalize: full-scale sine -> ~1.0
|
||||||
spectrum /= (FFT_SIZE / 4)
|
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)
|
freqs = np.fft.rfftfreq(FFT_SIZE, d=1.0 / self._sample_rate)
|
||||||
|
frame = Frame(spectrum, freqs, self._sample_rate,
|
||||||
new_levels = np.zeros(BANDS)
|
self._take_pending(), FRAME_MS / 1000)
|
||||||
for i in range(BANDS):
|
if self._renderer.advance(frame):
|
||||||
mask = (freqs >= edges[i]) & (freqs < edges[i + 1])
|
self._repaint()
|
||||||
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()
|
|
||||||
|
|
||||||
# ---- painting ----
|
# ---- painting ----
|
||||||
|
|
||||||
def _bar_color(self):
|
def _bar_color(self):
|
||||||
if self._mode == MODE_DIM:
|
if self._mode == MODE_DIM:
|
||||||
if self._prefs is not None:
|
return self._gray()
|
||||||
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 QColor(self.palette().highlight().color())
|
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):
|
def paintEvent(self, event):
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
# A rounded panel debossed as far as the transport buttons stand out:
|
# A rounded panel debossed as far as the transport buttons stand out:
|
||||||
# it shows things, it isn't pressed.
|
# it shows things, it isn't pressed.
|
||||||
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||||
panel = theme.paint_deboss(painter, rect,
|
panel = theme.paint_deboss(painter, rect, self.panel_color(), RADIUS)
|
||||||
self.palette().alternateBase().color(), RADIUS)
|
painter.setClipPath(panel) # keep the drawing inside the rounded shape
|
||||||
painter.setClipPath(panel) # keep bars inside the rounded shape
|
|
||||||
if self._no_pcm:
|
if self._no_pcm:
|
||||||
# Casting: the audio never passes through this machine, so show
|
# Casting: the audio never passes through this machine, so show
|
||||||
# where it went instead of pretending to analyze silence.
|
# where it went instead of pretending to analyze silence.
|
||||||
@@ -228,19 +323,4 @@ class VisualizerWidget(QWidget):
|
|||||||
(self.height() - ICON_SIZE) // 2, glyph)
|
(self.height() - ICON_SIZE) // 2, glyph)
|
||||||
painter.end()
|
painter.end()
|
||||||
return
|
return
|
||||||
inset = 3 # bar field sits 3px in from every edge
|
self.paint_renderer(painter, QRectF(self.rect()))
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -12,6 +12,8 @@ DEFAULTS = {
|
|||||||
# default, "Family"=use app
|
# default, "Family"=use app
|
||||||
"volume": 0.8, # logical 0..1 master output volume
|
"volume": 0.8, # logical 0..1 master output volume
|
||||||
"visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle
|
"visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle
|
||||||
|
"visualizer_style": "bars", # bars | radial | laser (visualizer_styles)
|
||||||
|
"visualizer_ghost_fade": 1.0, # seconds a radial dot's trail takes to fade
|
||||||
# Per-area grayscale overrides (0=black .. 255=white). None = inherit the
|
# Per-area grayscale overrides (0=black .. 255=white). None = inherit the
|
||||||
# current/default look for that surface, so nothing changes until the user
|
# current/default look for that surface, so nothing changes until the user
|
||||||
# drags a slider in Preferences.
|
# drags a slider in Preferences.
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user