diff --git a/README.md b/README.md index 908b160..c6049ed 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ Settings are stored in `~/.config/sendspin/`: | `manufacturer` | string | TUI/daemon | Manufacturer name reported in the client hello (`--manufacturer`) | | `product_name` | string | TUI/daemon | Product name reported in the client hello (`--product-name`); defaults to auto-detected OS/platform name | | `interface` | string | TUI/daemon | IP address of the network interface to use (`--interface`) | +| `visualizer` | boolean | TUI | Render the `visualizer@v1` audio visualizer on launch (default: false). Toggle with `v` in the TUI | | `source` | string | serve | Default audio source (file path or URL, ffmpeg input) | | `source_format` | string | serve | ffmpeg container format for audio source | | `clients` | array | serve | Client URLs to connect to (`--client`) | @@ -321,9 +322,17 @@ Hooks receive these environment variables: ### Visualizer -The TUI includes a real-time audio spectrum visualizer that displays frequency data received from the server. This uses the experimental `visualizer@_draft_r1` role. The spectrum data is computed on the server and sent via sendspin to the TUI. +The TUI includes a real-time audio visualizer driven by the `visualizer@v1` role. All analysis is computed on the server and streamed to the TUI, time-aligned to the audio playhead. It shows: -Toggle it by pressing `v` in the TUI. Your preference is saved in settings and remembered on next launch. +- **Spectrum bars** — frequency magnitude across the range, tinted by overall loudness (and by the album-artwork palette when the server provides one). +- **Beats timeline** — a `beats (NNN BPM):` strip with the estimated tempo; downbeats render differently from regular beats. +- **Peaks timeline** — a `peaks:` strip of energy onsets (transients like drum hits), independent of the beat grid, with glyph height scaled by onset strength. +- **Pitch** — the perceived musical note (e.g. `A4`) with an arrow pointing at its position on the spectrum, shown whenever a pitch is detected. +- **Dominant frequency** — an `f_peak:` readout with an arrow marking the loudest frequency on the spectrum. + +Lower rows are dropped first on short terminals, keeping the spectrum visible. + +Toggle the visualizer by pressing `v` in the TUI. Your preference is saved in settings and remembered on next launch. ### Debugging & Troubleshooting diff --git a/pyproject.toml b/pyproject.toml index ba9d446..a769501 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "aiosendspin[server]~=5.3", + "aiosendspin[server]~=6.0.1", "aiosendspin-mpris~=2.1.1", "av>=15.0.0", "numpy>=1.26.0", diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 2f6674a..dd332ce 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -22,6 +22,7 @@ GroupUpdateServerPayload, ServerCommandPayload, ServerStatePayload, + StreamStartMessage, ) from aiosendspin.models.player import ( ClientHelloPlayerSupport, @@ -29,8 +30,10 @@ SupportedAudioFormat, ) from aiosendspin.models.visualizer import ( + BeatTiming, ClientHelloVisualizerSpectrum, ClientHelloVisualizerSupport, + StreamStartVisualizer, VisualizerFrame, ) from aiosendspin.models.types import ( @@ -49,8 +52,15 @@ from sendspin.settings import ClientSettings from sendspin.tui.keyboard import keyboard_loop from sendspin.tui.ui import ColorMode, SendspinUI +from sendspin.tui.visualizer import ( + SPECTRUM_F_MAX, + SPECTRUM_F_MIN, + SPECTRUM_N_BINS, + SPECTRUM_SCALE, + PeakEvent, +) from sendspin.utils import create_task, get_device_info -from sendspin.visualizer_connector import VisualizerHandler +from sendspin.visualizer_connector import BeatHandler, PeakHandler, VisualizerHandler logger = logging.getLogger(__name__) @@ -251,6 +261,8 @@ def __init__(self, args: AppArgs) -> None: self._client: SendspinClient | None = None self._audio_handler: AudioStreamHandler | None = None self._visualizer_handler: VisualizerHandler | None = None + self._beat_handler: BeatHandler | None = None + self._peak_handler: PeakHandler | None = None self._settings = args.settings self._visualizer_enabled: bool = args.settings.visualizer # Currently-applied static delay in milliseconds, mirroring @@ -267,17 +279,16 @@ def __init__(self, args: AppArgs) -> None: @staticmethod def _build_visualizer_support() -> ClientHelloVisualizerSupport: - """Build visualizer support payload for client/hello.""" + """Build visualizer support payload for client/hello (visualizer@v1).""" return ClientHelloVisualizerSupport( buffer_capacity=65536, - types=["loudness", "spectrum"], - batch_max=8, + types=["loudness", "spectrum", "beat", "peak", "f_peak", "pitch"], + rate_max=30, spectrum=ClientHelloVisualizerSpectrum( - n_disp_bins=48, - scale="mel", - f_min=20, - f_max=20000, - rate_max=30, + n_disp_bins=SPECTRUM_N_BINS, + scale=SPECTRUM_SCALE, + f_min=SPECTRUM_F_MIN, + f_max=SPECTRUM_F_MAX, ), ) @@ -331,6 +342,21 @@ def _attach_client(self) -> None: on_frame=self._handle_visualizer_frame, ) self._visualizer_handler.attach_client(self._client) + self._beat_handler = BeatHandler( + on_beat=self._handle_beat, + on_schedule=self._handle_beat_schedule, + ) + self._beat_handler.attach_client(self._client) + self._peak_handler = PeakHandler( + on_peak=self._handle_peak, + on_schedule=self._handle_peak_schedule, + ) + self._peak_handler.attach_client(self._client) + self._listener_unsubscribes.append( + self._client.add_stream_start_listener(self._handle_stream_start) + ) + if self._ui is not None: + self._ui.set_server_clock(self._server_now_us) if MPRIS_AVAILABLE and self._args.use_mpris: self._mpris = SendspinMpris(self._client) @@ -351,6 +377,15 @@ def _detach_client(self) -> None: if self._visualizer_handler: self._visualizer_handler.detach() self._visualizer_handler = None + if self._beat_handler: + self._beat_handler.detach() + self._beat_handler = None + if self._peak_handler: + self._peak_handler.detach() + self._peak_handler = None + if self._ui is not None: + self._ui.set_server_clock(None) + self._ui.set_visualizer_types(frozenset()) if self._mpris: self._mpris.stop() @@ -492,6 +527,10 @@ def signal_handler() -> None: self._mpris.stop() if self._visualizer_handler: self._visualizer_handler.detach() + if self._beat_handler: + self._beat_handler.detach() + if self._peak_handler: + self._peak_handler.detach() if self._ui: self._ui.stop() if self._audio_handler: @@ -524,6 +563,7 @@ async def _handle_disconnect(self, message: str) -> None: self._ui.set_disconnected(message) if self._visualizer_handler: self._visualizer_handler.reset() + self._clear_visualizer_timelines() await self._audio_handler.handle_disconnect() async def _connect_cancellable(self, url: str) -> None: @@ -718,6 +758,16 @@ def _handle_metadata_update(self, payload: ServerStatePayload) -> None: ui.set_repeat_shuffle(state.repeat_mode, state.shuffle) ui.add_event(state.describe()) + def _clear_visualizer_timelines(self) -> None: + """Drop the beat and peak strips, cancelling their pending schedules.""" + if self._beat_handler: + self._beat_handler.reset() + if self._peak_handler: + self._peak_handler.reset() + if self._ui is not None: + self._ui.clear_beats() + self._ui.clear_peaks() + def _handle_color_update(self, payload: ServerStatePayload) -> None: """Forward a color@v1 palette payload to the UI.""" assert self._ui is not None @@ -744,9 +794,13 @@ def _handle_group_update(self, payload: GroupUpdateServerPayload) -> None: with ui.batch_update(): ui.set_group_name(payload.group_name) if payload.playback_state: + changed = payload.playback_state != state.playback_state state.playback_state = payload.playback_state ui.set_playback_state(payload.playback_state) ui.add_event(f"Playback state: {payload.playback_state.value}") + # No stream/clear fires on pause, so drop the frozen strips here. + if changed and payload.playback_state != PlaybackStateType.PLAYING: + self._clear_visualizer_timelines() def _handle_server_state(self, payload: ServerStatePayload) -> None: """Handle server/state messages with controller state.""" @@ -840,10 +894,57 @@ async def _toggle_visualizer(self) -> None: if not self._cancel_connect(): await old_client.disconnect() + def _server_now_us(self) -> int: + """Current time in the server clock the beat and peak strips are drawn against. + + Strip events carry server-clock timestamps, so the strip's "now" must be + in the same domain. This is the inverse of ``compute_play_time``, so an + event lands on the playhead cell exactly when it becomes audible. + """ + assert self._client is not None + return self._client.compute_server_time(self._client.now_us()) + + def _handle_stream_start(self, message: StreamStartMessage) -> None: + """Record which visualizer types the server negotiated for this stream.""" + if self._ui is None: + return + config = message.payload.visualizer + types = ( + frozenset(config.types) if isinstance(config, StreamStartVisualizer) else frozenset() + ) + self._ui.set_visualizer_types(types) + def _handle_visualizer_frame(self, frame: VisualizerFrame) -> None: """Handle a visualizer frame from the connector.""" if self._ui is not None: - self._ui.set_visualizer_frame(frame.spectrum, frame.loudness) + self._ui.set_visualizer_frame( + frame.spectrum, + frame.loudness, + frame.pitch_midi_q88, + frame.f_peak_freq, + ) + + def _handle_beat(self, beat: BeatTiming) -> None: + """Handle a beat hitting the playhead.""" + if self._ui is not None: + self._ui.record_beat(beat) + + def _handle_beat_schedule(self, scheduled: list[BeatTiming]) -> None: + """Handle an updated upcoming-beat schedule.""" + if self._ui is not None: + self._ui.set_beat_schedule(scheduled) + + def _handle_peak(self, timestamp_us: int, strength: int) -> None: + """Handle an energy-onset peak hitting the playhead.""" + if self._ui is not None: + self._ui.record_peak(PeakEvent(timestamp_us=timestamp_us, strength=strength)) + + def _handle_peak_schedule(self, scheduled: list[tuple[int, int]]) -> None: + """Handle an updated upcoming-peak schedule.""" + if self._ui is not None: + self._ui.set_peak_schedule( + [PeakEvent(timestamp_us=ts, strength=strength) for ts, strength in scheduled] + ) def _on_stream_event(self, event: str) -> None: """Handle stream lifecycle events by running hooks.""" diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 630c0d7..1d0f671 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -14,6 +14,7 @@ from aiosendspin.models.color import SessionUpdateColor from aiosendspin.models.types import PlaybackStateType, RepeatMode, UndefinedField +from aiosendspin.models.visualizer import BeatTiming from rich.console import Console, ConsoleOptions, RenderResult from rich.live import Live from rich.panel import Panel @@ -22,7 +23,14 @@ from sendspin.discovery import DiscoveredServer from sendspin.tui.visualizer import ( + BeatState, + PeakEvent, + PeakState, VisualizerState, + freq_to_display_column, + render_beat_strip, + render_freq_cursor_row, + render_peak_strip, render_spectrum, ) from sendspin.utils import create_task @@ -115,7 +123,13 @@ class UIState: # Visualizer visualizer_enabled: bool = False + # Visualizer types the server negotiated for the current stream. + visualizer_types: frozenset[str] = field(default_factory=frozenset) visualizer_state: VisualizerState = field(default_factory=VisualizerState) + beat_state: BeatState = field(default_factory=BeatState) + peak_state: PeakState = field(default_factory=PeakState) + # Synced server clock provider used for the beat and peak timeline strips. + server_now_us: Callable[[], int] | None = None # Artwork palette pushed by the server via color@v1. palette_primary: tuple[int, int, int] | None = None @@ -208,7 +222,9 @@ def _needs_playback_refresh(self) -> bool: def _needs_visualizer_refresh(self) -> bool: """Check if the visualizer needs periodic refreshes for interpolation.""" - return self._state.visualizer_enabled and self._state.visualizer_state.is_active + if not self._state.visualizer_enabled: + return False + return self._state.visualizer_state.is_active or self._state.beat_state.is_active def _next_refresh_interval(self) -> float | None: """Return the next periodic refresh interval, if any.""" @@ -766,45 +782,225 @@ def _build_server_panel(self, *, expand: bool = False, min_info_rows: int = 0) - return self._make_panel(content, title="Server", default_border="yellow", expand=expand) def _build_visualizer_rows(self, height: int) -> list[Text]: - """Build the spectrum visualizer as raw Text rows.""" + """Build the visualizer as raw Text rows, totaling `height`. + + Reserves the top row for the beat timeline strip when there's room; + the remaining rows are the spectrum. + """ state = self._state.visualizer_state state.step() magnitudes = state.get_spectrum() loudness = state.loudness peaks = state.get_peaks() - - # Low end uses opposite-mode bg to pop, high end uses on-color for contrast. - if not self._palette_active(): + beat_pulse = self._state.beat_state.pulse_intensity() + + # Two guaranteed-contrast colors per the color spec: the on-color + # (on_dark/on_light, >=4.5:1 vs its background) and white/black text + # (guaranteed vs background_dark/background_light). primary/accent carry + # NO contrast guarantee, so they are never used against the painted bg. + palette_on = self._palette_active() + if not palette_on: palette_low = None palette_high = None - freq_peak = "#ffffff" + on_color = "#ffffff" + text_color = "#ffffff" + elif self._state.color_mode == ColorMode.DARK: + palette_low = self._state.palette_background_light + palette_high = self._state.palette_on_dark + text_color = "#ffffff" + assert palette_high is not None + on_color = f"#{palette_high[0]:02x}{palette_high[1]:02x}{palette_high[2]:02x}" else: - # Marker uses accent (distinct secondary), or primary as fallback. - # Primary is required so it's always present; both stay clear of the - # bar-tip gradient (palette_high = on_dark/on_light). - marker = self._state.palette_accent or self._state.palette_primary - assert marker is not None - freq_peak = f"#{marker[0]:02x}{marker[1]:02x}{marker[2]:02x}" - if self._state.color_mode == ColorMode.DARK: - palette_low = self._state.palette_background_light - palette_high = self._state.palette_on_dark - else: - palette_low = self._state.palette_background_dark - palette_high = self._state.palette_on_light + palette_low = self._state.palette_background_dark + palette_high = self._state.palette_on_light + text_color = "#000000" + assert palette_high is not None + on_color = f"#{palette_high[0]:02x}{palette_high[1]:02x}{palette_high[2]:02x}" bar_width = max(10, self._console.width - 1) - return render_spectrum( - magnitudes, - bar_width, - height, - loudness, - peaks, - palette_low=palette_low, - palette_high=palette_high, - bg_color=self._palette_bg_hex(), - freq_peak_color=freq_peak, + bg_color = self._palette_bg_hex() + # Same background the spectrum paints, so the strips/cursor/footer match. + row_bg = f"on {bg_color}" if bg_color else "" + + # Frequency-domain cursors: arrows onto the spectrum's freq axis plus a + # footer naming each value. pitch uses the on-color, f_peak white/black — + # both guaranteed against the background, and distinct from each other. + f_peak_marker, pitch_marker, footer_parts, f_peak_col = self._build_freq_cursors( + bar_width, on_color, text_color + ) + vtypes = self._state.visualizer_types + clock = self._state.server_now_us + + # Row budget, highest keep-priority first: peaks, beats (above the + # spectrum), then the f_peak arrow, pitch arrow, and footer (below it). + # Strips and f_peak need their type negotiated. A negotiated pitch keeps + # its row reserved so the arrow appearing/vanishing with confidence + # doesn't shift the layout; an unnegotiated pitch still shows on data. + # On short terminals the lowest-priority rows drop first, spectrum >=1. + has_pitch = self._state.visualizer_state.has_pitch + show_pitch_row = "pitch" in vtypes or has_pitch + candidates: list[str] = [] + if clock is not None and "peak" in vtypes: + candidates.append("peak") + if clock is not None and "beat" in vtypes: + candidates.append("beat") + if "f_peak" in vtypes: + candidates.append("f_peak") + if show_pitch_row: + candidates.append("pitch") + if "f_peak" in vtypes or show_pitch_row: + candidates.append("footer") + visible = candidates[: max(0, height - 1)] + show_peaks = "peak" in visible + show_beats = "beat" in visible + show_f_peak = "f_peak" in visible + show_pitch = "pitch" in visible + show_footer = "footer" in visible + spectrum_height = max(1, height - len(visible)) + + rows: list[Text] = [] + if clock is not None and (show_beats or show_peaks): + now_us = clock() + bpm = self._state.beat_state.tempo_bpm() + beats_label = f"beats ({bpm} BPM):" if bpm is not None else "beats:" + peaks_label = "peaks:" + # Drop labels on narrow terminals to keep the strips usable. + gutter = max(len(beats_label), len(peaks_label)) + 1 if bar_width > 28 else 0 + strip_width = bar_width - gutter + if show_peaks: + rows.append( + self._gutter_label( + peaks_label, + gutter, + render_peak_strip( + width=strip_width, + now_us=now_us, + recent=self._state.peak_state.recent(), + upcoming=self._state.peak_state.upcoming(), + loudness=loudness, + color=on_color if palette_on else None, + playhead_color=text_color if palette_on else None, + ), + row_bg, + ) + ) + if show_beats: + rows.append( + self._gutter_label( + beats_label, + gutter, + render_beat_strip( + width=strip_width, + now_us=now_us, + recent=self._state.beat_state.recent(), + upcoming=self._state.beat_state.upcoming(), + loudness=loudness, + pulse=beat_pulse, + marker_color=on_color if palette_on else None, + playhead_color=text_color if palette_on else None, + ), + row_bg, + ) + ) + + rows.extend( + render_spectrum( + magnitudes, + bar_width, + spectrum_height, + loudness, + peaks, + beat_pulse=beat_pulse, + palette_low=palette_low, + palette_high=palette_high, + bg_color=bg_color, + freq_peak_color=text_color, + freq_peak_column=f_peak_col, + ) ) + if show_f_peak: + markers = [f_peak_marker] if f_peak_marker is not None else [] + rows.append(self._pad_bg(render_freq_cursor_row(bar_width, markers), bar_width, row_bg)) + if show_pitch: + markers = [pitch_marker] if pitch_marker is not None else [] + rows.append(self._pad_bg(render_freq_cursor_row(bar_width, markers), bar_width, row_bg)) + if show_footer: + footer = Text() + for i, (text, color) in enumerate(footer_parts): + if i: + footer.append(" ") + footer.append(text, style=color) + rows.append(self._pad_bg(footer, bar_width, row_bg)) + return rows + + @staticmethod + def _pad_bg(row: Text, width: int, row_bg: str) -> Text: + """Pad a row to full width and paint the palette background behind it.""" + if row.cell_len < width: + row.append(" " * (width - row.cell_len)) + if row_bg: + row.style = row_bg + return row + + def _build_freq_cursors( + self, + width: int, + pitch_color: str, + f_peak_color: str, + ) -> tuple[ + tuple[int, str, str] | None, + tuple[int, str, str] | None, + list[tuple[str, str]], + int | None, + ]: + """Build frequency-cursor markers and footer labels for tonal readouts. + + Returns ``(f_peak_marker, pitch_marker, footer_parts, f_peak_column)`` + where each marker is ``(column, glyph, hex_color)`` or ``None``, + footer_parts are ``(text, hex_color)``, and f_peak_column is the + dominant-frequency spectrum column (or None). The footer text leads with + each cursor's glyph so the readout is self-keying. + """ + state = self._state.visualizer_state + types = self._state.visualizer_types + footer: list[tuple[str, str]] = [] + pitch_marker: tuple[int, str, str] | None = None + f_peak_marker: tuple[int, str, str] | None = None + + f_peak_col: int | None = None + f_peak_freq = state.f_peak_freq + if "f_peak" in types and f_peak_freq is not None: + f_peak_col = freq_to_display_column(f_peak_freq, width) + if f_peak_col is not None: + f_peak_marker = (f_peak_col, "△", f_peak_color) + # Pad to 5 digits (max 20000 Hz) so the pitch label after it stays put. + footer.append((f"△ f_peak: {f_peak_freq:>5} Hz", f_peak_color)) + + note = state.pitch_note + pitch_freq = state.pitch_freq + if state.has_pitch: + assert pitch_freq is not None + col = freq_to_display_column(pitch_freq, width) + if col is not None: + pitch_marker = (col, "▲", pitch_color) + footer.append((f"▲ pitch: {note}", pitch_color)) + + return f_peak_marker, pitch_marker, footer, f_peak_col + + @staticmethod + def _gutter_label(label: str, gutter: int, strip: Text, row_bg: str) -> Text: + """Prefix a timeline strip with a left-gutter label and paint the row bg. + + The label is a dim span so the background (set as the row's base style) + shows behind both the label and the strip without dimming the strip. + """ + row = Text(style=row_bg) + if gutter > 0: + row.append(label.ljust(gutter), style=f"dim {row_bg}".strip()) + row.append_text(strip) + return row + def _measure_layout_height(self, layout: Table) -> int: """Measure the rendered height of a layout table.""" lines = 0 @@ -1100,17 +1296,77 @@ def set_repeat_shuffle( self._state.shuffle = shuffle self.refresh() - def set_visualizer_frame(self, spectrum: list[int] | None, loudness: int | None) -> None: + def set_visualizer_frame( + self, + spectrum: list[int] | None, + loudness: int | None, + pitch_midi_q88: int | None = None, + f_peak_freq: int | None = None, + ) -> None: """Update visualizer state with new frame data.""" if self._state.visualizer_enabled: - self._state.visualizer_state.update(spectrum, loudness) + self._state.visualizer_state.update(spectrum, loudness, pitch_midi_q88, f_peak_freq) self.refresh() + def set_visualizer_types(self, types: frozenset[str]) -> None: + """Record the visualizer types the server negotiated for this stream.""" + self._state.visualizer_types = types + self.refresh() + def set_visualizer_enabled(self, enabled: bool) -> None: """Update whether the visualizer is enabled.""" self._state.visualizer_enabled = enabled if not enabled: self._state.visualizer_state.clear() + self._state.beat_state.clear() + self._state.peak_state.clear() + self._state.visualizer_types = frozenset() + self.refresh() + + def set_server_clock(self, now_us: Callable[[], int] | None) -> None: + """Inject the synced-clock callable used by the beat and peak strips.""" + self._state.server_now_us = now_us + # Rebuild event state with the new clock for proper recent-event windowing. + self._state.beat_state = BeatState(now_us=now_us) + self._state.peak_state = PeakState(now_us=now_us) + self.refresh() + + def record_beat(self, beat: BeatTiming) -> None: + """Record a beat that has just landed on the client.""" + if not self._state.visualizer_enabled: + return + self._state.beat_state.record_beat(beat) + self.refresh() + + def set_beat_schedule(self, scheduled: list[BeatTiming]) -> None: + """Update the upcoming beats used by the timeline strip.""" + if not self._state.visualizer_enabled: + return + self._state.beat_state.set_schedule(scheduled) + self.refresh() + + def record_peak(self, peak: PeakEvent) -> None: + """Record an energy-onset peak that has just landed on the client.""" + if not self._state.visualizer_enabled: + return + self._state.peak_state.record_peak(peak) + self.refresh() + + def set_peak_schedule(self, scheduled: list[PeakEvent]) -> None: + """Update the upcoming peaks used by the peak strip.""" + if not self._state.visualizer_enabled: + return + self._state.peak_state.set_schedule(scheduled) + self.refresh() + + def clear_beats(self) -> None: + """Clear all beat state immediately.""" + self._state.beat_state.clear() + self.refresh() + + def clear_peaks(self) -> None: + """Clear all peak state immediately.""" + self._state.peak_state.clear() self.refresh() def show_server_selector(self, servers: list[DiscoveredServer]) -> None: diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index 51a68dd..c05201c 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -2,10 +2,34 @@ from __future__ import annotations +import math import time +from collections.abc import Callable +from dataclasses import dataclass +from aiosendspin.models.visualizer import BeatTiming, SpectrumScale from rich.text import Text +# Spectrum negotiation geometry. Single source of truth: the client/hello +# support payload is built from these (see app._build_visualizer_support), and +# the freq->column cursor math below inverts the same mapping, so the two cannot +# drift apart. +SPECTRUM_N_BINS = 48 +SPECTRUM_SCALE: SpectrumScale = "mel" +SPECTRUM_F_MIN = 20 +SPECTRUM_F_MAX = 20000 + +_NOTE_NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") + + +@dataclass(frozen=True, slots=True) +class PeakEvent: + """A single energy-onset (peak) event with its detector strength.""" + + timestamp_us: int + strength: int # 0-255 + + # Unicode block characters for bar rendering (9 levels including space) _BLOCKS = " ▁▂▃▄▅▆▇█" _BLOCK_LEVELS = len(_BLOCKS) - 1 # 8 @@ -17,6 +41,11 @@ _PEAK_HOLD_SECONDS = 0.5 _PEAK_FALL_RATE = 0.375 # normalized units per second (≈6 rows/sec at 16 rows) +# Beat flash decay (seconds for pulse to fully fade) +_BEAT_PULSE_DECAY_SECONDS = 0.18 +# Beat strip half-window (seconds shown on either side of the playhead) +_BEAT_STRIP_HALF_WINDOW_S = 4.0 + # Loudness-to-color tier stops: (loudness_threshold, (R, G, B)) _COLOR_TIERS: list[tuple[float, tuple[int, int, int]]] = [ (0.00, (0x33, 0x55, 0x88)), # steel blue @@ -75,6 +104,34 @@ def loudness_to_colors( return tip, base +def midi_to_note_name(midi_q88: int) -> str: + """Convert an 8.8 fixed-point MIDI value to a note name (e.g. 69.0 -> 'A4').""" + midi = int(round(midi_q88 / 256.0)) + octave = midi // 12 - 1 + return f"{_NOTE_NAMES[midi % 12]}{octave}" + + +def _mel(freq_hz: float) -> float: + """HTK mel scale, matching the server's spectrum binning.""" + return 2595.0 * math.log10(1.0 + freq_hz / 700.0) + + +def freq_to_display_column(freq_hz: float, width: int) -> int | None: + """Map a frequency to a spectrum column, inverting the negotiated mel binning. + + Returns None when the frequency is non-positive or the width is empty. + Frequencies outside [f_min, f_max] clamp to the edge columns. + """ + if width <= 0 or freq_hz <= 0: + return None + f = min(max(freq_hz, SPECTRUM_F_MIN), SPECTRUM_F_MAX) + m_lo, m_hi = _mel(SPECTRUM_F_MIN), _mel(SPECTRUM_F_MAX) + frac = (_mel(f) - m_lo) / (m_hi - m_lo) if m_hi > m_lo else 0.0 + bin_index = frac * (SPECTRUM_N_BINS - 1) + col = int(round(bin_index * width / SPECTRUM_N_BINS)) + return min(width - 1, max(0, col)) + + class VisualizerState: """Stores and smooths visualizer frame data for rendering.""" @@ -86,9 +143,22 @@ def __init__(self) -> None: self._peaks: list[float] = [] self._peak_hold_timers: list[float] = [] self._last_step_monotonic = time.monotonic() - - def update(self, spectrum: list[int] | None, loudness: int | None) -> None: - """Update with new frame data. Values are uint16 (0-65535).""" + # Latest tonal readouts (held, not smoothed): pitch and dominant freq. + self._pitch_midi_q88: int | None = None + self._f_peak_freq: int | None = None + + def update( + self, + spectrum: list[int] | None, + loudness: int | None, + pitch_midi_q88: int | None = None, + f_peak_freq: int | None = None, + ) -> None: + """Update with new frame data. Periodic values are uint16 (0-65535). + + Tonal readouts (pitch, f_peak) ride along on the spectrum frame as the + latest received values; they are held as-is for cursor placement. + """ if spectrum is None and loudness is None: self.clear() return @@ -97,6 +167,8 @@ def update(self, spectrum: list[int] | None, loudness: int | None) -> None: self._spectrum_target = [v / 65535.0 for v in spectrum] if loudness is not None: self._loudness_target = loudness / 65535.0 + self._pitch_midi_q88 = pitch_midi_q88 + self._f_peak_freq = f_peak_freq def clear(self) -> None: """Clear all state immediately.""" @@ -107,6 +179,8 @@ def clear(self) -> None: self._peaks = [] self._peak_hold_timers = [] self._last_step_monotonic = time.monotonic() + self._pitch_midi_q88 = None + self._f_peak_freq = None def _step(self) -> None: """Advance displayed values toward targets.""" @@ -171,6 +245,343 @@ def get_peaks(self) -> list[float]: """Return the current peak hold heights (0.0-1.0 per bin).""" return list(self._peaks) + @property + def pitch_note(self) -> str | None: + """Latest perceived pitch as a note name, or None when not detected.""" + if self._pitch_midi_q88 is None or self._pitch_midi_q88 <= 0: + return None + return midi_to_note_name(self._pitch_midi_q88) + + @property + def pitch_freq(self) -> float | None: + """Latest perceived pitch as a frequency in Hz, or None when not detected.""" + if self._pitch_midi_q88 is None or self._pitch_midi_q88 <= 0: + return None + midi = self._pitch_midi_q88 / 256.0 + return float(440.0 * (2.0 ** ((midi - 69.0) / 12.0))) + + @property + def f_peak_freq(self) -> int | None: + """Latest dominant frequency in Hz, or None when not detected.""" + if not self._f_peak_freq: + return None + return self._f_peak_freq + + @property + def has_pitch(self) -> bool: + """Whether a pitch readout is available to display.""" + return self.pitch_note is not None and self.pitch_freq is not None + + +class BeatState: + """Stores beat events and produces a decaying pulse intensity. + + The scheduled BeatHandler calls record_beat() exactly when a beat is due + on the client; the render loop reads pulse_intensity() each frame to drive + the tip flash. set_schedule() feeds upcoming beats for the timeline strip. + Each beat carries its downbeat flag so the strip can paint bar starts + differently. + """ + + def __init__(self, now_us: Callable[[], int] | None = None) -> None: + """Initialize beat state. + + :param now_us: Function returning the synced server clock in microseconds. + Optional; falls back to local monotonic time scaled to microseconds. + """ + self._now_us = now_us + self._last_beat_monotonic: float | None = None + self._scheduled: list[BeatTiming] = [] + self._recent: list[BeatTiming] = [] + + def record_beat(self, beat: BeatTiming) -> None: + """Record that a beat has just landed (call from BeatHandler.on_beat).""" + self._last_beat_monotonic = time.monotonic() + # Record the beat's actual server timestamp so it lands on the playhead + # cell instead of drifting one cell right. + self._recent.append(beat) + if self._now_us is not None: + cutoff = self._now_us() - int(_BEAT_STRIP_HALF_WINDOW_S * 1_000_000) + self._recent = [b for b in self._recent if b.timestamp_us >= cutoff] + + def set_schedule(self, scheduled: list[BeatTiming]) -> None: + """Set the upcoming beat list (BeatTiming with server-clock timestamps).""" + self._scheduled = list(scheduled) + + def clear(self) -> None: + """Clear all beat state immediately.""" + self._last_beat_monotonic = None + self._scheduled = [] + self._recent = [] + + def pulse_intensity(self) -> float: + """Return current beat pulse intensity (0.0 idle, 1.0 just hit).""" + if self._last_beat_monotonic is None: + return 0.0 + elapsed = time.monotonic() - self._last_beat_monotonic + if elapsed >= _BEAT_PULSE_DECAY_SECONDS: + return 0.0 + return max(0.0, 1.0 - elapsed / _BEAT_PULSE_DECAY_SECONDS) + + def upcoming(self) -> list[BeatTiming]: + """Return upcoming beats.""" + return list(self._scheduled) + + def recent(self) -> list[BeatTiming]: + """Return recent past beats inside the strip window.""" + return list(self._recent) + + @property + def is_active(self) -> bool: + """Whether there is current or recent beat activity.""" + return bool(self._scheduled) or self.pulse_intensity() > 0.0 + + def tempo_bpm(self) -> int | None: + """Estimate tempo from the median interval between known beats. + + Uses recent and upcoming beats together. Returns None when fewer than + two beats are known or the spacing is degenerate. + """ + times = sorted({b.timestamp_us for b in (*self._recent, *self._scheduled)}) + if len(times) < 2: + return None + intervals = [b - a for a, b in zip(times, times[1:], strict=False) if b > a] + if not intervals: + return None + intervals.sort() + median = intervals[len(intervals) // 2] + if median <= 0: + return None + return int(round(60_000_000 / median)) + + +class PeakState: + """Stores energy-onset (peak) events for the peak timeline strip. + + Mirrors BeatState: the PeakHandler calls record_peak() when a peak is due, + and set_schedule() feeds upcoming peaks. Each event carries a 0-255 strength + so the strip can scale glyph height. + """ + + def __init__(self, now_us: Callable[[], int] | None = None) -> None: + """Initialize peak state. + + :param now_us: Function returning the synced server clock in microseconds, + used to window recent peaks to the visible strip span. + """ + self._now_us = now_us + self._scheduled: list[PeakEvent] = [] + self._recent: list[PeakEvent] = [] + + def record_peak(self, peak: PeakEvent) -> None: + """Record that a peak has just landed (call from PeakHandler.on_peak).""" + self._recent.append(peak) + if self._now_us is not None: + cutoff = self._now_us() - int(_BEAT_STRIP_HALF_WINDOW_S * 1_000_000) + self._recent = [p for p in self._recent if p.timestamp_us >= cutoff] + + def set_schedule(self, scheduled: list[PeakEvent]) -> None: + """Set the upcoming peak list (server-clock timestamps).""" + self._scheduled = list(scheduled) + + def clear(self) -> None: + """Clear all peak state immediately.""" + self._scheduled = [] + self._recent = [] + + def upcoming(self) -> list[PeakEvent]: + """Return upcoming peaks.""" + return list(self._scheduled) + + def recent(self) -> list[PeakEvent]: + """Return recent past peaks inside the strip window.""" + return list(self._recent) + + @property + def is_active(self) -> bool: + """Whether there are scheduled or recent peaks.""" + return bool(self._scheduled) or bool(self._recent) + + +def render_beat_strip( + width: int, + now_us: int, + recent: list[BeatTiming], + upcoming: list[BeatTiming], + loudness: float, + pulse: float, + marker_color: str | None = None, + playhead_color: str | None = None, +) -> Text: + """Render a single-row beat timeline strip. + + Past beats appear left of center, upcoming beats appear right of center, + `│` marks the playhead. Each beat falls onto the closest character cell. + Downbeats render as ``■``, regular beats as ``●``. If a downbeat and a + regular beat land on the same cell, the downbeat wins. + + When ``marker_color`` is given (a contrast-guaranteed palette color) it + paints every beat, and ``playhead_color`` paints the playhead; otherwise the + colors follow the loudness tiers. Position conveys past vs upcoming. + """ + if width <= 0: + return Text("") + half_us = int(_BEAT_STRIP_HALF_WINDOW_S * 1_000_000) + if half_us <= 0: + return Text(" " * width) + + cells = [" "] * width + styles: list[str | None] = [None] * width + # Tracks whether a cell already shows a downbeat so a later regular beat + # doesn't overwrite it. + is_downbeat_cell = [False] * width + center = width // 2 + + tip, base = loudness_to_colors(loudness) + if marker_color is not None: + past_color = upcoming_color = marker_color + ph_color = playhead_color or marker_color + else: + past_color = _rgb_to_hex(*base) + upcoming_color = _rgb_to_hex(*_lerp_rgb(base, tip, 0.5)) + # Brighten the playhead toward white as the beat pulse peaks. + ph_color = _rgb_to_hex( + min(255, int(tip[0] + (255 - tip[0]) * pulse)), + min(255, int(tip[1] + (255 - tip[1]) * pulse)), + min(255, int(tip[2] + (255 - tip[2]) * pulse)), + ) + + def place(timestamp_us: int, glyph: str, style: str, *, downbeat: bool) -> None: + offset_us = timestamp_us - now_us + if abs(offset_us) > half_us: + return + cell = center + int(round((offset_us / half_us) * (width / 2))) + if not 0 <= cell < width: + return + if is_downbeat_cell[cell] and not downbeat: + return + cells[cell] = glyph + styles[cell] = style + if downbeat: + is_downbeat_cell[cell] = True + + for beat in recent: + glyph = "■" if beat.is_downbeat else "●" + place(beat.timestamp_us, glyph, past_color, downbeat=beat.is_downbeat) + for beat in upcoming: + glyph = "■" if beat.is_downbeat else "●" + place(beat.timestamp_us, glyph, upcoming_color, downbeat=beat.is_downbeat) + + # Playhead overlays whatever was at center. Grows on beat pulse: + # idle = thin ┃, mid pulse = heavy ┃, peak pulse = full block █. + playhead_color = ph_color + if pulse >= 0.6: + playhead_glyph = "█" + elif pulse >= 0.15: + playhead_glyph = "┃" + else: + playhead_glyph = "│" + cells[center] = playhead_glyph + styles[center] = playhead_color + + line = Text() + for ch, style in zip(cells, styles, strict=True): + if style is None: + line.append(ch) + else: + line.append(ch, style=style) + return line + + +def render_peak_strip( + width: int, + now_us: int, + recent: list[PeakEvent], + upcoming: list[PeakEvent], + loudness: float, + color: str | None = None, + playhead_color: str | None = None, +) -> Text: + """Render a single-row energy-onset (peak) timeline strip. + + Shares ``render_beat_strip``'s time geometry so it lines up directly beneath + the beat strip, with the playhead at center so "now" is marked even when no + beat strip is shown. Each peak's glyph height scales with its 0-255 strength. + + When ``color`` is given (a contrast-guaranteed palette color) it paints every + onset; otherwise the colors follow the loudness tiers. ``playhead_color`` + colors the center cursor to match the beat strip's playhead. + """ + if width <= 0: + return Text("") + half_us = int(_BEAT_STRIP_HALF_WINDOW_S * 1_000_000) + if half_us <= 0: + return Text(" " * width) + + glyphs = _BLOCKS[1:] # drop the space so even faint onsets show a tick + cells = [" "] * width + styles: list[str | None] = [None] * width + cell_strength = [-1] * width + center = width // 2 + + if color is not None: + past_color = upcoming_color = color + else: + tip, base = loudness_to_colors(loudness) + past_color = _rgb_to_hex(*base) + upcoming_color = _rgb_to_hex(*_lerp_rgb(base, tip, 0.5)) + + def place(timestamp_us: int, strength: int, color: str) -> None: + offset_us = timestamp_us - now_us + if abs(offset_us) > half_us: + return + cell = center + int(round((offset_us / half_us) * (width / 2))) + if not 0 <= cell < width: + return + if strength <= cell_strength[cell]: + return # a stronger onset already owns this cell + frac = min(255, max(0, strength)) / 255.0 + cells[cell] = glyphs[int(frac * (len(glyphs) - 1))] + styles[cell] = color + cell_strength[cell] = strength + + for peak in recent: + place(peak.timestamp_us, peak.strength, past_color) + for peak in upcoming: + place(peak.timestamp_us, peak.strength, upcoming_color) + + cells[center] = "│" + styles[center] = playhead_color or color + + line = Text() + for ch, style in zip(cells, styles, strict=True): + if style is None: + line.append(ch) + else: + line.append(ch, style=style) + return line + + +def render_freq_cursor_row(width: int, markers: list[tuple[int, str, str]]) -> Text: + """Render arrow cursors pointing at spectrum columns. + + ``markers`` is a list of ``(column, glyph, hex_color)``. Markers are drawn in + order, so a later one overwrites an earlier one sharing the same cell. + """ + cells = [" "] * max(0, width) + styles: list[str | None] = [None] * max(0, width) + for col, glyph, color in markers: + if 0 <= col < width: + cells[col] = glyph + styles[col] = color + line = Text() + for ch, style in zip(cells, styles, strict=True): + if style is None: + line.append(ch) + else: + line.append(ch, style=style) + return line + def render_spectrum( magnitudes: list[float], @@ -178,10 +589,12 @@ def render_spectrum( height: int, loudness: float, peaks: list[float], + beat_pulse: float = 0.0, palette_low: tuple[int, int, int] | None = None, palette_high: tuple[int, int, int] | None = None, bg_color: str | None = None, freq_peak_color: str = "#ffffff", + freq_peak_column: int | None = None, ) -> list[Text]: """Render spectrum bars as Rich Text lines with loudness-driven color. @@ -191,10 +604,13 @@ def render_spectrum( height: Number of text rows (each row = 8 block levels). loudness: Normalized 0.0-1.0 loudness for color selection. peaks: Normalized 0.0-1.0 peak hold heights per bin. + beat_pulse: 0.0-1.0 intensity mixing the tip color toward white on beat. palette_low: Optional RGB anchor for low-loudness tip color. palette_high: Optional RGB anchor for high-loudness tip color. bg_color: Optional hex background painted behind every cell. freq_peak_color: Hex color for the frequency-peak marker. + freq_peak_column: Column to highlight as the dominant frequency. When set + (the server's f_peak), it replaces the local max-bin guess. Returns: List of Text objects, one per row (top to bottom). @@ -204,9 +620,22 @@ def render_spectrum( return [Text(" " * max(0, width), style=empty_style) for _ in range(max(0, height))] tip, base = loudness_to_colors(loudness, palette_low, palette_high) - - # Find frequency peak bin (for highlight color on its peak marker) - freq_peak_bin = max(range(len(magnitudes)), key=lambda i: magnitudes[i]) + if beat_pulse > 0.0: + # Mix tip toward white at peak pulse; keep base untouched so the bottom + # of the bars doesn't flicker. Capped at 0.5 for a gentle lift instead + # of a hard strobe. + flash_t = max(0.0, min(1.0, beat_pulse)) * 0.5 + tip = ( + int(tip[0] + (255 - tip[0]) * flash_t), + int(tip[1] + (255 - tip[1]) * flash_t), + int(tip[2] + (255 - tip[2]) * flash_t), + ) + + # Prefer the server's dominant frequency; fall back to the local max bin. + use_server_peak = freq_peak_column is not None + freq_peak_bin = ( + -1 if use_server_peak else max(range(len(magnitudes)), key=lambda i: magnitudes[i]) + ) # Resample magnitudes and peaks to fit width n_bins = len(magnitudes) @@ -237,6 +666,9 @@ def render_spectrum( bar_peaks.append(peak_max**0.6 if peak_max > 0 else 0.0) bar_is_freq_peak.append(is_freq_peak) + if use_server_peak and freq_peak_column is not None and 0 <= freq_peak_column < width: + bar_is_freq_peak = [i == freq_peak_column for i in range(width)] + total_levels = height * _BLOCK_LEVELS rows: list[Text] = [] diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py index 35ac5c8..554819c 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -8,8 +8,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from aiosendspin.models.core import StreamStartMessage -from aiosendspin.models.visualizer import VisualizerFrame +from aiosendspin.models.visualizer import BeatTiming, VisualizerFrame if TYPE_CHECKING: from aiosendspin.client import SendspinClient @@ -38,13 +37,18 @@ def __init__( self._unsubscribes: list[Callable[[], None]] = [] self._pending: deque[tuple[int, VisualizerFrame]] = deque() self._timer: asyncio.TimerHandle | None = None + # Latest periodic values, each carried on its own single-type frame. + # Attached to the next emitted spectrum frame so they ride the same + # playhead schedule instead of being dropped. + self._latest_loudness: int | None = None + self._latest_pitch_midi: int | None = None + self._latest_f_peak_freq: int | None = None def attach_client(self, client: SendspinClient) -> None: """Attach to a SendspinClient and register listeners.""" self._client = client self._unsubscribes = [ client.add_visualizer_listener(self._on_visualizer_data), - client.add_stream_start_listener(self._on_stream_start), client.add_stream_end_listener(self._on_stream_end), client.add_stream_clear_listener(self._on_stream_clear), ] @@ -55,6 +59,9 @@ def reset(self) -> None: self._timer.cancel() self._timer = None self._pending.clear() + self._latest_loudness = None + self._latest_pitch_midi = None + self._latest_f_peak_freq = None self._on_frame(VisualizerFrame(timestamp_us=0)) def detach(self) -> None: @@ -68,7 +75,9 @@ def detach(self) -> None: def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: """Handle incoming visualizer frames. - Only use real spectrum frames; drop non-spectrum frames. + Each v1 frame carries one field. Spectrum frames are queued on the + playhead schedule; loudness rides along as the latest value attached to + the next emitted spectrum frame. """ if not frames: return @@ -78,6 +87,12 @@ def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: # Queue frames by synced server timestamps (independent of local audio delay). for frame in frames: + if frame.loudness is not None: + self._latest_loudness = frame.loudness + if frame.pitch_midi_q88 is not None: + self._latest_pitch_midi = frame.pitch_midi_q88 + if frame.f_peak_freq is not None: + self._latest_f_peak_freq = frame.f_peak_freq if frame.spectrum is None: continue play_time_us = self._client.compute_play_time(frame.timestamp_us) @@ -87,35 +102,19 @@ def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: return self._schedule_next() - def _on_stream_start(self, message: StreamStartMessage) -> None: - """Flush stale frames when a new stream begins.""" - if message.payload.visualizer is None: - return - if self._timer is not None: - self._timer.cancel() - self._timer = None - self._pending.clear() - def _on_stream_end(self, roles: list[str] | None) -> None: """Handle stream end for visualizer role.""" if roles is not None and "visualizer" not in roles: return - self._pending.clear() - if self._timer is not None: - self._timer.cancel() - self._timer = None - # Send an empty frame to trigger decay - self._on_frame(VisualizerFrame(timestamp_us=0)) + # reset() also drops the last-seen tonal values so a stale readout from + # the previous stream can't survive into the next one. + self.reset() def _on_stream_clear(self, roles: list[str] | None) -> None: """Handle stream clear for visualizer role.""" if roles is not None and "visualizer" not in roles: return - self._pending.clear() - if self._timer is not None: - self._timer.cancel() - self._timer = None - self._on_frame(VisualizerFrame(timestamp_us=0)) + self.reset() def _schedule_next(self) -> None: """Schedule emission of the next due visualizer frame.""" @@ -144,7 +143,267 @@ def _emit_due_frames(self) -> None: latest_due = frame if latest_due is not None: + if latest_due.loudness is None: + latest_due.loudness = self._latest_loudness + latest_due.pitch_midi_q88 = self._latest_pitch_midi + latest_due.f_peak_freq = self._latest_f_peak_freq self._on_frame(latest_due) if self._pending: self._schedule_next() + + +class BeatHandler: + """Bridges between SendspinClient beat events and the TUI. + + Beat binary messages arrive ahead of playhead; each beat is scheduled to + fire at its synced server time so on_beat() is invoked exactly when a beat + is audible. BeatTiming carries the downbeat flag through to the UI so the + timeline strip can render bar starts differently. + """ + + def __init__( + self, + on_beat: Callable[[BeatTiming], None], + on_schedule: Callable[[list[BeatTiming]], None] | None = None, + ) -> None: + """Initialize the beat handler. + + :param on_beat: Invoked when a scheduled beat is due (BeatTiming). + :param on_schedule: Invoked with the full list of upcoming beats whenever + the pending schedule changes (for timeline rendering). + """ + self._on_beat = on_beat + self._on_schedule = on_schedule + self._client: SendspinClient | None = None + self._unsubscribes: list[Callable[[], None]] = [] + self._pending: deque[BeatTiming] = deque() + self._timer: asyncio.TimerHandle | None = None + + def attach_client(self, client: SendspinClient) -> None: + """Attach to a SendspinClient and register listeners. + + Beats arrive through the unified visualizer callback on the v1 + wire: a `VisualizerFrame` whose `is_downbeat` is set carries a + beat event. + """ + self._client = client + self._unsubscribes = [ + client.add_visualizer_listener(self._on_visualizer_data), + client.add_stream_end_listener(self._on_stream_end), + client.add_stream_clear_listener(self._on_stream_clear), + ] + + def reset(self) -> None: + """Clear pending beats and cancel scheduled emissions.""" + if self._timer is not None: + self._timer.cancel() + self._timer = None + self._pending.clear() + if self._on_schedule is not None: + self._on_schedule([]) + + def detach(self) -> None: + """Detach from the client and unregister listeners.""" + for unsub in self._unsubscribes: + unsub() + self._unsubscribes = [] + self.reset() + self._client = None + + def pending_beats(self) -> list[BeatTiming]: + """Snapshot of upcoming beats still waiting to fire.""" + return list(self._pending) + + def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: + """Handle incoming visualizer frames, picking out beat events. + + v1 wire: each binary carries one type. Beat frames are identified + by `is_downbeat is not None`; other types are ignored here. + Duplicates are filtered by timestamp so a repeated batch doesn't + draw twice on the timeline. + """ + if self._client is None: + return + existing_ts = {beat.timestamp_us for beat in self._pending} + added = False + for frame in frames: + if frame.is_downbeat is None: + continue + if frame.timestamp_us in existing_ts: + continue + existing_ts.add(frame.timestamp_us) + self._pending.append( + BeatTiming(timestamp_us=frame.timestamp_us, is_downbeat=frame.is_downbeat) + ) + added = True + if not added: + return + if self._on_schedule is not None: + self._on_schedule(list(self._pending)) + self._schedule_next() + + def _on_stream_end(self, roles: list[str] | None) -> None: + """Handle stream end for visualizer role.""" + if roles is not None and "visualizer" not in roles: + return + self._cancel_timer() + self._pending.clear() + if self._on_schedule is not None: + self._on_schedule([]) + + def _on_stream_clear(self, roles: list[str] | None) -> None: + """Handle stream clear for visualizer role.""" + if roles is not None and "visualizer" not in roles: + return + self._cancel_timer() + self._pending.clear() + if self._on_schedule is not None: + self._on_schedule([]) + + def _cancel_timer(self) -> None: + if self._timer is not None: + self._timer.cancel() + self._timer = None + + def _schedule_next(self) -> None: + """Schedule emission of the next due beat.""" + if self._client is None or not self._pending: + return + self._cancel_timer() + + now_us = self._client.now_us() + play_us = self._client.compute_play_time(self._pending[0].timestamp_us) + delay_s = max(0.0, (play_us - now_us) / 1_000_000.0) + loop = asyncio.get_running_loop() + self._timer = loop.call_later(delay_s, self._emit_due_beats) + + def _emit_due_beats(self) -> None: + """Emit all beats whose play time is due, then reschedule for the next.""" + self._timer = None + if self._client is None or not self._pending: + return + now_us = self._client.now_us() + while self._pending and ( + self._client.compute_play_time(self._pending[0].timestamp_us) <= now_us + ): + beat = self._pending.popleft() + self._on_beat(beat) + if self._on_schedule is not None: + self._on_schedule(list(self._pending)) + if self._pending: + self._schedule_next() + + +class PeakHandler: + """Bridges between SendspinClient energy-onset (peak) events and the TUI. + + Peaks arrive as single-type visualizer frames carrying ``peak_strength`` + (0-255). Unlike spectrum frames they are events, not periodic samples, so + each peak is scheduled to fire at its synced play time rather than being + coalesced. Pending peaks ``(timestamp_us, strength)`` feed the peak strip. + """ + + def __init__( + self, + on_peak: Callable[[int, int], None], + on_schedule: Callable[[list[tuple[int, int]]], None] | None = None, + ) -> None: + """Initialize the peak handler. + + :param on_peak: Invoked as ``(timestamp_us, strength)`` when a peak is due. + :param on_schedule: Invoked with the full pending list whenever it changes. + """ + self._on_peak = on_peak + self._on_schedule = on_schedule + self._client: SendspinClient | None = None + self._unsubscribes: list[Callable[[], None]] = [] + self._pending: deque[tuple[int, int]] = deque() + self._timer: asyncio.TimerHandle | None = None + + def attach_client(self, client: SendspinClient) -> None: + """Attach to a SendspinClient and register listeners.""" + self._client = client + self._unsubscribes = [ + client.add_visualizer_listener(self._on_visualizer_data), + client.add_stream_end_listener(self._on_stream_end), + client.add_stream_clear_listener(self._on_stream_clear), + ] + + def reset(self) -> None: + """Clear pending peaks and cancel scheduled emissions.""" + self._cancel_timer() + self._pending.clear() + if self._on_schedule is not None: + self._on_schedule([]) + + def detach(self) -> None: + """Detach from the client and unregister listeners.""" + for unsub in self._unsubscribes: + unsub() + self._unsubscribes = [] + self.reset() + self._client = None + + def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: + """Queue incoming peak frames, ignoring all other visualizer types.""" + if self._client is None: + return + existing_ts = {ts for ts, _ in self._pending} + added = False + for frame in frames: + if frame.peak_strength is None: + continue + if frame.timestamp_us in existing_ts: + continue + existing_ts.add(frame.timestamp_us) + self._pending.append((frame.timestamp_us, frame.peak_strength)) + added = True + if not added: + return + if self._on_schedule is not None: + self._on_schedule(list(self._pending)) + self._schedule_next() + + def _on_stream_end(self, roles: list[str] | None) -> None: + """Handle stream end for visualizer role.""" + if roles is not None and "visualizer" not in roles: + return + self.reset() + + def _on_stream_clear(self, roles: list[str] | None) -> None: + """Handle stream clear for visualizer role.""" + if roles is not None and "visualizer" not in roles: + return + self.reset() + + def _cancel_timer(self) -> None: + if self._timer is not None: + self._timer.cancel() + self._timer = None + + def _schedule_next(self) -> None: + """Schedule emission of the next due peak.""" + if self._client is None or not self._pending: + return + self._cancel_timer() + + now_us = self._client.now_us() + play_us = self._client.compute_play_time(self._pending[0][0]) + delay_s = max(0.0, (play_us - now_us) / 1_000_000.0) + loop = asyncio.get_running_loop() + self._timer = loop.call_later(delay_s, self._emit_due_peaks) + + def _emit_due_peaks(self) -> None: + """Emit all peaks whose play time is due, then reschedule for the next.""" + self._timer = None + if self._client is None or not self._pending: + return + now_us = self._client.now_us() + while self._pending and self._client.compute_play_time(self._pending[0][0]) <= now_us: + timestamp_us, strength = self._pending.popleft() + self._on_peak(timestamp_us, strength) + if self._on_schedule is not None: + self._on_schedule(list(self._pending)) + if self._pending: + self._schedule_next() diff --git a/tests/test_visualizer_connector.py b/tests/test_visualizer_connector.py new file mode 100644 index 0000000..b89c84a --- /dev/null +++ b/tests/test_visualizer_connector.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import asyncio + +from aiosendspin.models.visualizer import VisualizerFrame + +from sendspin.visualizer_connector import VisualizerHandler + + +class _FakeClient: + def __init__(self) -> None: + self.visualizer_listeners: list[object] = [] + self.stream_start_listeners: list[object] = [] + self.stream_end_listeners: list[object] = [] + self.stream_clear_listeners: list[object] = [] + + def compute_play_time(self, timestamp_us: int) -> int: + return timestamp_us + + def now_us(self) -> int: + # Far ahead so any queued frame is immediately due. + return 10_000_000 + + def add_visualizer_listener(self, callback: object): + return self._add(self.visualizer_listeners, callback) + + def add_stream_start_listener(self, callback: object): + return self._add(self.stream_start_listeners, callback) + + def add_stream_end_listener(self, callback: object): + return self._add(self.stream_end_listeners, callback) + + def add_stream_clear_listener(self, callback: object): + return self._add(self.stream_clear_listeners, callback) + + @staticmethod + def _add(callbacks: list[object], callback: object): + callbacks.append(callback) + return lambda: callbacks.remove(callback) + + +def test_loudness_only_frame_reaches_emitted_spectrum_frame() -> None: + """Loudness arrives on its own single-type frame and must not be dropped: + its value rides along on the next emitted spectrum frame.""" + + async def exercise() -> None: + received: list[VisualizerFrame] = [] + handler = VisualizerHandler(on_frame=received.append) + client = _FakeClient() + handler.attach_client(client) + on_data = client.visualizer_listeners[0] + + on_data([VisualizerFrame(timestamp_us=1, loudness=40000)]) + on_data([VisualizerFrame(timestamp_us=2, spectrum=[1, 2, 3])]) + await asyncio.sleep(0.02) # let the scheduled emission fire + + assert received, "no frame was emitted" + emitted = received[-1] + assert emitted.spectrum == [1, 2, 3] + assert emitted.loudness == 40000 + + asyncio.run(exercise()) + + +def test_stream_clear_drops_stale_pitch() -> None: + """stream/clear resets the last-seen pitch so it can't ride into the next stream.""" + + async def exercise() -> None: + received: list[VisualizerFrame] = [] + handler = VisualizerHandler(on_frame=received.append) + client = _FakeClient() + handler.attach_client(client) + on_data = client.visualizer_listeners[0] + on_clear = client.stream_clear_listeners[0] + + on_data([VisualizerFrame(timestamp_us=1, pitch_midi_q88=17664)]) + on_clear(None) + on_data([VisualizerFrame(timestamp_us=2, spectrum=[1, 2, 3])]) + await asyncio.sleep(0.02) # let the scheduled emission fire + + emitted = received[-1] + assert emitted.spectrum == [1, 2, 3] + assert emitted.pitch_midi_q88 is None + + asyncio.run(exercise()) diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index 0c90249..1c4f962 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -3,7 +3,34 @@ import time from unittest.mock import patch -from sendspin.tui.visualizer import VisualizerState, loudness_to_colors, render_spectrum +from aiosendspin.models.visualizer import BeatTiming + +from sendspin.tui.ui import SendspinUI +from sendspin.tui.visualizer import ( + SPECTRUM_F_MAX, + SPECTRUM_F_MIN, + BeatState, + PeakEvent, + PeakState, + VisualizerState, + freq_to_display_column, + loudness_to_colors, + midi_to_note_name, + render_beat_strip, + render_peak_strip, + render_spectrum, +) + +# MIDI 69 (A4) in q8.8 fixed point, well above the pitch-detection floor. +_A4_MIDI_Q88 = 69 * 256 + + +def _ui_with_tonal_frame(types: frozenset[str]) -> SendspinUI: + """A visualizer UI holding both a pitch and an f_peak readout.""" + ui = SendspinUI(0.0, visualizer_enabled=True) + ui.set_visualizer_frame([30000] * 32, 40000, _A4_MIDI_Q88, 1000) + ui.set_visualizer_types(types) + return ui # --- loudness_to_colors tests --- @@ -160,3 +187,360 @@ def test_render_spectrum_freq_peak_color_styles_peak_marker() -> None: if row.plain[span.start : span.end] == "▔" ] assert marker_styles == ["#ff00ff"] + + +def test_render_spectrum_beat_pulse_brightens_tip() -> None: + """beat_pulse>0 should brighten the tip color (top row) without going full white.""" + magnitudes = [1.0] + peaks = [0.0] + no_pulse = render_spectrum( + magnitudes, width=1, height=2, loudness=0.5, peaks=peaks, beat_pulse=0.0 + ) + full_pulse = render_spectrum( + magnitudes, width=1, height=2, loudness=0.5, peaks=peaks, beat_pulse=1.0 + ) + no_top_style = no_pulse[0].spans[0].style if no_pulse[0].spans else "" + full_top_style = full_pulse[0].spans[0].style if full_pulse[0].spans else "" + assert no_top_style != full_top_style + # Cap of 0.5 means peak pulse must not reach pure white (#ffffff). + assert "#ffffff" not in str(full_top_style) + + +# --- BeatState tests --- + + +def test_beat_state_idle_pulse_is_zero() -> None: + state = BeatState() + assert state.pulse_intensity() == 0.0 + assert state.is_active is False + + +def test_beat_state_pulse_decays_to_zero() -> None: + state = BeatState() + state.record_beat(BeatTiming(0)) + assert state.pulse_intensity() > 0.5 + + with patch("sendspin.tui.visualizer.time") as mock_time: + # 1 second after — way past decay window + mock_time.monotonic.return_value = time.monotonic() + 1.0 + assert state.pulse_intensity() == 0.0 + + +def test_beat_state_idle_after_pulse_decays() -> None: + """is_active returns to rest once the pulse decays, so the refresh loop idles.""" + state = BeatState() + state.record_beat(BeatTiming(0)) + assert state.is_active is True + + with patch("sendspin.tui.visualizer.time") as mock_time: + mock_time.monotonic.return_value = time.monotonic() + 1.0 + assert state.is_active is False + + +def test_beat_state_set_schedule_marks_active() -> None: + state = BeatState() + state.set_schedule([BeatTiming(100), BeatTiming(200), BeatTiming(300)]) + assert state.is_active is True + assert [b.timestamp_us for b in state.upcoming()] == [100, 200, 300] + + +def test_beat_state_clear_resets() -> None: + state = BeatState() + state.record_beat(BeatTiming(0)) + state.set_schedule([BeatTiming(1), BeatTiming(2)]) + state.clear() + assert state.is_active is False + assert state.upcoming() == [] + assert state.recent() == [] + + +def test_beat_state_recent_windowed() -> None: + """Recent beats outside the visible window are pruned.""" + now = [10_000_000_000] + state = BeatState(now_us=lambda: now[0]) + state.record_beat(BeatTiming(now[0])) + # Advance clock past the strip window + now[0] += 10_000_000 # 10s + state.record_beat(BeatTiming(now[0])) + assert len(state.recent()) == 1 + assert state.recent()[0].timestamp_us == now[0] + + +# --- render_beat_strip tests --- + + +def test_render_beat_strip_playhead_in_center() -> None: + line = render_beat_strip(width=21, now_us=0, recent=[], upcoming=[], loudness=0.5, pulse=0.0) + # Idle pulse uses the thin playhead glyph. + assert line.plain[10] == "│" + + +def test_render_beat_strip_playhead_grows_on_pulse() -> None: + mid = render_beat_strip(width=21, now_us=0, recent=[], upcoming=[], loudness=0.5, pulse=0.3) + peak = render_beat_strip(width=21, now_us=0, recent=[], upcoming=[], loudness=0.5, pulse=1.0) + assert mid.plain[10] == "┃" + assert peak.plain[10] == "█" + + +def test_render_beat_strip_past_and_future_dots() -> None: + half_s = 4.0 + line = render_beat_strip( + width=21, + now_us=0, + recent=[BeatTiming(-int(half_s * 0.5 * 1_000_000))], + upcoming=[BeatTiming(int(half_s * 0.5 * 1_000_000))], + loudness=0.5, + pulse=0.0, + ) + # Past beat lands ~25% to the left of center, future beat ~25% right. + assert line.plain.count("●") == 2 + assert line.plain[5] == "●" + assert line.plain[15] == "●" + + +def test_render_beat_strip_downbeat_renders_square() -> None: + """Downbeats render as a square block (■) instead of a circle (●).""" + line = render_beat_strip( + width=21, + now_us=0, + recent=[BeatTiming(-2_000_000, is_downbeat=True)], + upcoming=[BeatTiming(2_000_000, is_downbeat=False)], + loudness=0.5, + pulse=0.0, + ) + assert line.plain.count("■") == 1 + assert line.plain.count("●") == 1 + assert line.plain[5] == "■" + assert line.plain[15] == "●" + + +def test_render_beat_strip_downbeat_wins_overlap() -> None: + """When a regular and a downbeat fall on the same cell, the downbeat keeps it.""" + line = render_beat_strip( + width=21, + now_us=0, + recent=[BeatTiming(-2_000_000, is_downbeat=True)], + upcoming=[BeatTiming(-2_000_000, is_downbeat=False)], + loudness=0.5, + pulse=0.0, + ) + assert line.plain[5] == "■" + assert line.plain.count("●") == 0 + + +def test_render_beat_strip_beats_outside_window_dropped() -> None: + line = render_beat_strip( + width=21, + now_us=0, + recent=[BeatTiming(-100_000_000)], # 100s in the past + upcoming=[BeatTiming(100_000_000)], + loudness=0.5, + pulse=0.0, + ) + assert line.plain.count("●") == 0 + assert line.plain.count("■") == 0 + + +# --- BeatState.tempo_bpm tests --- + + +def test_tempo_bpm_even_spacing() -> None: + """Beats 0.5s apart yield 120 BPM.""" + state = BeatState() + state.set_schedule([BeatTiming(0), BeatTiming(500_000), BeatTiming(1_000_000)]) + assert state.tempo_bpm() == 120 + + +def test_tempo_bpm_needs_two_beats() -> None: + state = BeatState() + assert state.tempo_bpm() is None + state.set_schedule([BeatTiming(0)]) + assert state.tempo_bpm() is None + + +# --- PeakState tests --- + + +def test_peak_state_set_schedule_marks_active() -> None: + state = PeakState() + assert state.is_active is False + state.set_schedule([PeakEvent(100, 200), PeakEvent(200, 50)]) + assert state.is_active is True + assert [p.timestamp_us for p in state.upcoming()] == [100, 200] + + +def test_peak_state_recent_windowed() -> None: + """Recent peaks outside the visible window are pruned.""" + now = [10_000_000_000] + state = PeakState(now_us=lambda: now[0]) + state.record_peak(PeakEvent(now[0], 100)) + now[0] += 10_000_000 # 10s, past the strip window + state.record_peak(PeakEvent(now[0], 100)) + assert len(state.recent()) == 1 + assert state.recent()[0].timestamp_us == now[0] + + +# --- render_peak_strip tests --- + + +def test_render_peak_strip_marker_placement() -> None: + line = render_peak_strip( + width=21, + now_us=0, + recent=[PeakEvent(-2_000_000, 200)], + upcoming=[], + loudness=0.5, + ) + # A peak 2s in the past lands ~25% left of center (cell 5 of width 21). + assert line.plain[5] != " " + + +def test_render_peak_strip_marks_playhead_at_center() -> None: + """The peak strip draws the now-cursor at center so 'now' is always marked.""" + line = render_peak_strip( + width=21, + now_us=0, + recent=[], + upcoming=[], + loudness=0.5, + ) + assert line.plain[10] == "│" + + +def test_render_peak_strip_strength_scales_glyph_height() -> None: + """A stronger onset draws a taller block glyph than a weaker one.""" + ramp = "▁▂▃▄▅▆▇█" + line = render_peak_strip( + width=21, + now_us=0, + recent=[PeakEvent(-2_000_000, 10)], + upcoming=[PeakEvent(2_000_000, 250)], + loudness=0.5, + ) + weak = line.plain[5] + strong = line.plain[15] + assert ramp.index(strong) > ramp.index(weak) + + +# --- pitch / frequency helper tests --- + + +def test_midi_to_note_name_a4() -> None: + assert midi_to_note_name(69 * 256) == "A4" + + +def test_midi_to_note_name_rounds_to_nearest_semitone() -> None: + # 69.6 in 8.8 fixed-point rounds up to MIDI 70 -> A#4. + assert midi_to_note_name(int(69.6 * 256)) == "A#4" + + +def test_freq_to_display_column_clamps_to_endpoints() -> None: + assert freq_to_display_column(SPECTRUM_F_MIN, width=48) == 0 + assert freq_to_display_column(SPECTRUM_F_MAX, width=48) == 47 + + +def test_freq_to_display_column_none_for_nonpositive() -> None: + assert freq_to_display_column(0, width=48) is None + + +def test_render_spectrum_uses_server_freq_peak_column() -> None: + """freq_peak_column overrides the local max bin for the highlight marker.""" + # Local max is bin 0, but the server says the dominant column is the last one. + magnitudes = [1.0, 0.1, 0.1] + peaks = [1.0, 1.0, 1.0] + rows = render_spectrum( + magnitudes, + width=3, + height=8, + loudness=0.5, + peaks=peaks, + freq_peak_color="#ff00ff", + freq_peak_column=2, + ) + highlighted_cols = { + span.start + for row in rows + for span in row.spans + if str(span.style) == "#ff00ff" and row.plain[span.start : span.end] == "▔" + } + assert highlighted_cols == {2} + + +def test_render_beat_strip_uses_palette_colors() -> None: + """Given palette colors, beats use the marker color and the playhead its own.""" + line = render_beat_strip( + width=21, + now_us=0, + recent=[BeatTiming(-2_000_000)], + upcoming=[], + loudness=0.5, + pulse=0.0, + marker_color="#abcdef", + playhead_color="#123456", + ) + beat_styles = { + str(span.style) for span in line.spans if line.plain[span.start : span.end] == "●" + } + playhead_styles = { + str(span.style) for span in line.spans if line.plain[span.start : span.end] == "│" + } + assert beat_styles == {"#abcdef"} + assert playhead_styles == {"#123456"} + + +def test_render_peak_strip_uses_palette_color() -> None: + line = render_peak_strip( + width=21, + now_us=0, + recent=[PeakEvent(-2_000_000, 200)], + upcoming=[], + loudness=0.5, + color="#abcdef", + ) + styles = {str(span.style) for span in line.spans if line.plain[span.start : span.end] != " "} + assert styles == {"#abcdef"} + + +# --- tonal cursor row tests --- + + +def test_f_peak_arrow_hidden_when_not_negotiated() -> None: + """f_peak is type-gated: its data without the negotiated type renders nothing.""" + rows = _ui_with_tonal_frame(frozenset({"pitch"}))._build_visualizer_rows(10) + plain = "".join(row.plain for row in rows) + assert "△" not in plain + + +def test_pitch_arrow_shown_when_not_negotiated() -> None: + """Pitch is data-gated, not type-gated: a confident readout always renders.""" + rows = _ui_with_tonal_frame(frozenset({"f_peak"}))._build_visualizer_rows(10) + plain = "".join(row.plain for row in rows) + assert "▲" in plain + + +def test_negotiated_pitch_row_keeps_layout_stable() -> None: + """A negotiated pitch reserves its row, so a lost readout doesn't shift rows.""" + types = frozenset({"f_peak", "pitch"}) + + with_pitch = SendspinUI(0.0, visualizer_enabled=True) + with_pitch.set_visualizer_frame([30000] * 32, 40000, _A4_MIDI_Q88, 1000) + with_pitch.set_visualizer_types(types) + rows_with_pitch = with_pitch._build_visualizer_rows(10) + + no_pitch = SendspinUI(0.0, visualizer_enabled=True) + no_pitch.set_visualizer_frame([30000] * 32, 40000, 0, 1000) # no pitch readout + no_pitch.set_visualizer_types(types) + rows_no_pitch = no_pitch._build_visualizer_rows(10) + + f_peak_confident = next(i for i, r in enumerate(rows_with_pitch) if "△" in r.plain) + f_peak_silent = next(i for i, r in enumerate(rows_no_pitch) if "△" in r.plain) + assert f_peak_confident == f_peak_silent + + +def test_pitch_arrow_on_separate_line_below_f_peak() -> None: + """With both negotiated, the f_peak arrow gets its own line above pitch's.""" + rows = _ui_with_tonal_frame(frozenset({"f_peak", "pitch"}))._build_visualizer_rows(10) + f_peak_row = next(i for i, row in enumerate(rows) if "△" in row.plain) + pitch_row = next(i for i, row in enumerate(rows) if "▲" in row.plain) + assert f_peak_row < pitch_row + assert "▲" not in rows[f_peak_row].plain diff --git a/uv.lock b/uv.lock index dc92e1f..57b7286 100644 --- a/uv.lock +++ b/uv.lock @@ -98,7 +98,7 @@ wheels = [ [[package]] name = "aiosendspin" -version = "5.3.0" +version = "6.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -106,9 +106,9 @@ dependencies = [ { name = "orjson" }, { name = "zeroconf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/72/70fdfa163f7dbed6901b7417bf587260001e30f7609ba5d2d51e3ae5fb8e/aiosendspin-5.3.0.tar.gz", hash = "sha256:d9c3e6be872ef3bfe7da8d7fa40dd7c48a6ee02c2ba951845df93436ffe9ee8c", size = 133139, upload-time = "2026-05-19T14:08:33.944Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/89/14c04309b2ee46095df4ce5fcd70554b26cf0c275526995a1c4aa9d59525/aiosendspin-6.0.1.tar.gz", hash = "sha256:a0c066fd7619113a643954aaa5265fc209d447589e7fe8ed09b19b070d0ed745", size = 160231, upload-time = "2026-05-31T14:54:18.649Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/1d/9d6b48b14d3e9e9f3b87872056faab6e5dcf177c59c26400dad42e757d97/aiosendspin-5.3.0-py3-none-any.whl", hash = "sha256:218e911099c85e2e2597d664a0eea5f634cf35b75144e248ad86d848ba54d8af", size = 157543, upload-time = "2026-05-19T14:08:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b6/96/bf436baac06826d2ccf5d41d871aa81c6fbb5e85d14b2cc9dd2bc4783041/aiosendspin-6.0.1-py3-none-any.whl", hash = "sha256:4bfbb3bdd68dc27d4d14ff8a4c34cb87b7ba9664d50908e1f117c545d9c7e86c", size = 187968, upload-time = "2026-05-31T14:54:17.037Z" }, ] [package.optional-dependencies] @@ -1303,7 +1303,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "aiosendspin", extras = ["server"], specifier = "~=5.3" }, + { name = "aiosendspin", extras = ["server"], specifier = "~=6.0.1" }, { name = "aiosendspin-mpris", specifier = "~=2.1.1" }, { name = "av", specifier = ">=15.0.0" }, { name = "codespell", marker = "extra == 'test'", specifier = "==2.4.1" },