Files
lintunes/lintunes/gui/visualizer.py
T
travandClaude Opus 5 087c4bf103 v0.4.0: cast to a Chromecast from the Connections menu
The Device menu becomes Connections, with "Connect to Chromecast…" beside
the Rabbit sync. It opens a dialog that spins while it searches and lists
devices as they appear; picking one hands playback over, puts a small cast
glyph under the volume slider, and clicking that glyph disconnects.

Media-receiver model: lintunes serves the original file over the LAN on an
ephemeral port and the Chromecast decodes it itself. Bit-exact — no
transcode, no second lossy encode — and the device buffers for itself. The
cost is that lintunes is a remote control while connected: no local PCM, so
the visualizer shows the cast glyph instead of bars, and transport actions
land with about a second of round trip.

Player now walks its queue through a swappable PlaybackSink. It keeps
owning the queue, shuffle walk, start/stop times and the play-count and
scrobble bookkeeping, so casting counts plays and scrobbles exactly like
local playback. set_sink() carries track, position and playing-state both
ways, so connecting and disconnecting pick up mid-song. LocalSink stays in
player.py because the Player tests stub Qt Multimedia in that namespace.

The URL carries an opaque random token, never a path, so there is nothing
to traverse with; only the last few played tracks stay resolvable and the
whole map dies with the session. Range and HEAD are implemented because
the device seeks by re-requesting ranges and won't report a duration
without them.

Failure handling, verified against a real device: a dropped socket gets a
15s grace period, since pychromecast retries on its own and a Wi-Fi blip
heals itself. A real loss, another app taking the device, or a network
change falls back to local playback still playing, at the same position —
the sink reports the state lintunes last asked for rather than the IDLE
status a dying connection pushes just before it goes. Quitting stops the
device instead of leaving it fetching from a server that just died. The
~119 Apple Lossless / AIFF / protected-AAC tracks are skipped with a
status-bar message; the other 21,000+ MP3 and AAC files cast natively.

New dependency: pychromecast>=14.0.10, imported lazily so the app still
launches where it isn't installed (the menu item then explains the
install), and python_requires raised to >=3.11 to match its floor.

NOTE: the other machine needs `pip install -e .` before casting appears.

tests/test_round29.py: 67 tests; 440 pass overall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:49:53 -04:00

232 lines
8.7 KiB
Python

"""20-band graphic-EQ visualizer fed by the player's decoded PCM tee.
The player's QAudioBufferOutput delivers buffers essentially in lockstep
with the audible position (verified: frames received ≈ frames played), so
no extra sync buffering is needed — we just FFT the newest samples.
"""
import numpy as np
from PyQt6.QtWidgets import QWidget
from PyQt6.QtCore import Qt, QTimer, QRectF
from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen
from PyQt6.QtMultimedia import QAudioFormat
from lintunes import theme
from lintunes.gui.icons import transport_icon
BANDS = 20
STEPS = 20 # vertical granularity per bar
FFT_SIZE = 2048
FRAME_MS = 16 # ~60 fps
DECAY = 0.82 # slow decay; attack is instant
FLOOR_DB = -55.0
RADIUS = 8 # rounded corners, matching the transport button boxes
ICON_SIZE = 20 # the cast glyph shown in place of bars while casting
# Visualizer click cycles through these brightness modes in order.
MODE_ON = "on"
MODE_DIM = "dim"
MODE_OFF = "off"
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
_SAMPLE_DTYPES = {
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
QAudioFormat.SampleFormat.Int16: (np.int16, 0.0, 32768.0),
QAudioFormat.SampleFormat.Int32: (np.int32, 0.0, 2147483648.0),
QAudioFormat.SampleFormat.Float: (np.float32, 0.0, 1.0),
}
class VisualizerWidget(QWidget):
"""Animated spectrum bars; click to cycle brightness: on / dim / off.
On = highlight-colored bars; dim = light-gray bars just darker than the
panel; off = blank.
"""
def __init__(self, player, prefs=None, parent=None):
super().__init__(parent)
self._player = player
self._prefs = prefs
saved = prefs.get("visualizer_mode") if prefs is not None else None
self._mode = saved if saved in _MODE_CYCLE else MODE_ON
self._levels = np.zeros(BANDS)
self._bars = [0] * BANDS # quantized 0..STEPS
self._samples = np.zeros(FFT_SIZE, dtype=np.float32)
self._sample_rate = 44100
self._window = np.hanning(FFT_SIZE).astype(np.float32)
# True while audio is coming out of something other than this machine
# (a Chromecast), so there is no decoded PCM to draw.
self._no_pcm = False
self.setFixedWidth(140)
self.setMinimumHeight(36)
self.setToolTip("Click to cycle: on / dim / off")
self._timer = QTimer(self)
self._timer.setInterval(FRAME_MS)
self._timer.timeout.connect(self._advance_frame)
player.audio_buffer.connect(self._on_buffer)
player.playing_changed.connect(self._on_playing_changed)
player.track_changed.connect(self._on_track_changed)
if hasattr(player, "sink_changed"):
player.sink_changed.connect(self._on_sink_changed)
def _on_sink_changed(self, sink):
"""Casting means no local decode, so there is nothing to draw. Say so
with the cast glyph rather than animating a flat line off a buffer of
zeros 30 times a second."""
self._no_pcm = not getattr(sink, "provides_pcm", True)
if self._no_pcm:
self._timer.stop()
self._clear()
self.setToolTip("No spectrum while casting")
else:
self.setToolTip("Click to cycle: on / dim / off")
if self._mode != MODE_OFF and self._player.is_playing():
self._timer.start()
self.update()
# ---- audio intake ----
def _on_buffer(self, buf):
fmt = buf.format()
spec = _SAMPLE_DTYPES.get(fmt.sampleFormat())
if spec is None:
return
dtype, offset, scale = spec
pointer = buf.data()
pointer.setsize(buf.byteCount())
raw = np.frombuffer(pointer, dtype=dtype)
channels = max(1, fmt.channelCount())
if channels > 1:
usable = (len(raw) // channels) * channels
raw = raw[:usable].reshape(-1, channels).mean(axis=1)
mono = (raw.astype(np.float32) - offset) / scale
self._sample_rate = fmt.sampleRate() or 44100
if len(mono) >= FFT_SIZE:
self._samples = mono[-FFT_SIZE:].copy()
else:
self._samples = np.roll(self._samples, -len(mono))
self._samples[-len(mono):] = mono
# ---- state ----
def _on_playing_changed(self, playing):
if self._no_pcm:
return # nothing to animate; don't let play/pause restart the timer
if playing:
if self._mode != MODE_OFF:
self._timer.start()
else:
self._timer.stop()
if self._mode != MODE_OFF:
self._clear()
def _on_track_changed(self, track):
self._samples[:] = 0.0
if track is None and self._mode != MODE_OFF:
self._timer.stop()
self._clear()
def _clear(self):
self._levels[:] = 0.0
self._bars = [0] * BANDS
self.update()
def mousePressEvent(self, event):
# Cycle on -> dim -> off -> on.
idx = _MODE_CYCLE.index(self._mode)
self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]
if self._prefs is not None:
self._prefs.set("visualizer_mode", self._mode)
if self._mode == MODE_OFF:
self._timer.stop()
self._clear() # off = blank, not the last frame held in place
elif self._player.is_playing():
self._timer.start()
# ---- frame computation ----
def _advance_frame(self):
spectrum = np.abs(np.fft.rfft(self._samples * self._window))
# Normalize: full-scale sine -> ~1.0
spectrum /= (FFT_SIZE / 4)
nyquist = self._sample_rate / 2
top = min(16000.0, nyquist * 0.95)
edges = np.geomspace(40.0, max(top, 80.0), BANDS + 1)
freqs = np.fft.rfftfreq(FFT_SIZE, d=1.0 / self._sample_rate)
new_levels = np.zeros(BANDS)
for i in range(BANDS):
mask = (freqs >= edges[i]) & (freqs < edges[i + 1])
if mask.any():
magnitude = spectrum[mask].max()
db = 20.0 * np.log10(magnitude + 1e-10)
new_levels[i] = np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0)
# Instant attack, slow decay
self._levels = np.maximum(new_levels, self._levels * DECAY)
bars = [int(round(level * STEPS)) for level in self._levels]
if bars != self._bars:
self._bars = bars
self.update()
# ---- painting ----
def _bar_color(self):
if self._mode == MODE_DIM:
if self._prefs is not None:
saved = self._prefs.get("color_visualizer_gray")
if saved is not None:
return theme.gray(saved)
# Light gray just darker than the panel: take alternateBase and
# drop its lightness ~12% so it tracks theme/scale changes.
base = QColor(self.palette().alternateBase().color())
h, s, l, a = base.getHslF()
base.setHslF(h, s, l * 0.88, a)
return base
return QColor(self.palette().highlight().color())
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Rounded gray panel + 1px border, matching the transport button boxes.
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
panel = QPainterPath()
panel.addRoundedRect(rect, RADIUS, RADIUS)
painter.fillPath(panel, self.palette().alternateBase().color())
painter.setPen(QPen(self.palette().mid().color(), 1))
painter.drawPath(panel)
painter.setClipPath(panel) # keep bars inside the rounded shape
if self._no_pcm:
# Casting: the audio never passes through this machine, so show
# where it went instead of pretending to analyze silence.
glyph = transport_icon("cast_connected", self._bar_color()).pixmap(
ICON_SIZE, ICON_SIZE)
painter.drawPixmap(
(self.width() - ICON_SIZE) // 2,
(self.height() - ICON_SIZE) // 2, glyph)
painter.end()
return
inset = 3 # bar field sits 3px in from every edge
avail_w = self.width() - 2 * inset
avail_h = self.height() - 2 * inset
baseline = self.height() - inset
bar_width = avail_w / BANDS
color = self._bar_color()
step_height = avail_h / STEPS
for i, bar in enumerate(self._bars):
if bar <= 0:
continue
x = inset + int(i * bar_width)
end = inset + int((i + 1) * bar_width)
gap = 1 if i < BANDS - 1 else 0 # 1px between bars, none at edge
w = max(1, end - x - gap)
bar_height = int(bar * step_height)
painter.fillRect(x, baseline - bar_height, w, bar_height, color)