From dac83b3a3fc51a14518a060e3e61c85e535d7562 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 19 May 2026 11:34:19 +0200 Subject: [PATCH 01/15] Render visualizer beats with downbeat support Negotiates the visualizer@v1 role (downbeat-aware msg 17 frames). BeatTiming flows through BeatHandler, BeatState, and the render_beat_strip pipeline; downbeats render as a filled square while regular beats stay as filled circles. Downbeat wins when both land on the same cell. Schedule appends are deduplicated by timestamp so repeated batches don't paint the same beat twice on the timeline. --- sendspin/tui/app.py | 36 ++++++- sendspin/tui/ui.py | 87 ++++++++++++++--- sendspin/tui/visualizer.py | 161 +++++++++++++++++++++++++++++++ sendspin/visualizer_connector.py | 150 +++++++++++++++++++++++++++- tests/tui/test_visualizer.py | 151 ++++++++++++++++++++++++++++- 5 files changed, 569 insertions(+), 16 deletions(-) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 2f6674a..d9b760c 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -29,6 +29,7 @@ SupportedAudioFormat, ) from aiosendspin.models.visualizer import ( + BeatTiming, ClientHelloVisualizerSpectrum, ClientHelloVisualizerSupport, VisualizerFrame, @@ -50,7 +51,7 @@ from sendspin.tui.keyboard import keyboard_loop from sendspin.tui.ui import ColorMode, SendspinUI from sendspin.utils import create_task, get_device_info -from sendspin.visualizer_connector import VisualizerHandler +from sendspin.visualizer_connector import BeatHandler, VisualizerHandler logger = logging.getLogger(__name__) @@ -251,6 +252,7 @@ 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._settings = args.settings self._visualizer_enabled: bool = args.settings.visualizer # Currently-applied static delay in milliseconds, mirroring @@ -270,7 +272,7 @@ def _build_visualizer_support() -> ClientHelloVisualizerSupport: """Build visualizer support payload for client/hello.""" return ClientHelloVisualizerSupport( buffer_capacity=65536, - types=["loudness", "spectrum"], + types=["loudness", "spectrum", "beat"], batch_max=8, spectrum=ClientHelloVisualizerSpectrum( n_disp_bins=48, @@ -288,6 +290,9 @@ def _create_client(self) -> SendspinClient: visualizer_support = None if self._visualizer_enabled: visualizer_support = self._build_visualizer_support() + # Prefer v1 (downbeat-aware); fall back to the draft wire when a + # server only implements the legacy negotiation. + roles.append(Roles.VISUALIZER_V1) roles.append(Roles.VISUALIZER) assert self._audio_handler is not None @@ -331,6 +336,13 @@ 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) + if self._ui is not None: + self._ui.set_server_clock(self._client.now_us) if MPRIS_AVAILABLE and self._args.use_mpris: self._mpris = SendspinMpris(self._client) @@ -351,6 +363,11 @@ 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._ui is not None: + self._ui.set_server_clock(None) if self._mpris: self._mpris.stop() @@ -492,6 +509,8 @@ 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._ui: self._ui.stop() if self._audio_handler: @@ -524,6 +543,9 @@ async def _handle_disconnect(self, message: str) -> None: self._ui.set_disconnected(message) if self._visualizer_handler: self._visualizer_handler.reset() + if self._beat_handler: + self._beat_handler.reset() + self._ui.clear_beats() await self._audio_handler.handle_disconnect() async def _connect_cancellable(self, url: str) -> None: @@ -845,6 +867,16 @@ def _handle_visualizer_frame(self, frame: VisualizerFrame) -> None: if self._ui is not None: self._ui.set_visualizer_frame(frame.spectrum, frame.loudness) + 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 _on_stream_event(self, event: str) -> None: """Handle stream lifecycle events by running hooks.""" hook = self._args.hook_start if event == "start" else self._args.hook_stop diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 630c0d7..81425aa 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,9 @@ from sendspin.discovery import DiscoveredServer from sendspin.tui.visualizer import ( + BeatState, VisualizerState, + render_beat_strip, render_spectrum, ) from sendspin.utils import create_task @@ -116,6 +119,9 @@ class UIState: # Visualizer visualizer_enabled: bool = False visualizer_state: VisualizerState = field(default_factory=VisualizerState) + beat_state: BeatState = field(default_factory=BeatState) + # Synced server clock provider used for the beat timeline strip. + 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 +214,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,12 +774,17 @@ 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() + beat_pulse = self._state.beat_state.pulse_intensity() # Low end uses opposite-mode bg to pop, high end uses on-color for contrast. if not self._palette_active(): @@ -793,17 +806,40 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: palette_high = self._state.palette_on_light 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() + rows: list[Text] = [] + spectrum_height = height + if height >= 2: + spectrum_height = height - 1 + if self._state.server_now_us is not None: + now_us = self._state.server_now_us() + rows.append( + render_beat_strip( + width=bar_width, + now_us=now_us, + recent=self._state.beat_state.recent(), + upcoming=self._state.beat_state.upcoming(), + loudness=loudness, + pulse=beat_pulse, + ) + ) + else: + rows.append(Text(" " * bar_width)) + 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=freq_peak, + ) ) + return rows def _measure_layout_height(self, layout: Table) -> int: """Measure the rendered height of a layout table.""" @@ -1111,6 +1147,33 @@ def set_visualizer_enabled(self, enabled: bool) -> None: self._state.visualizer_enabled = enabled if not enabled: self._state.visualizer_state.clear() + self._state.beat_state.clear() + self.refresh() + + def set_server_clock(self, now_us: Callable[[], int] | None) -> None: + """Inject the synced-clock callable used by the beat timeline strip.""" + self._state.server_now_us = now_us + # Rebuild beat state with the new clock for proper recent-beat windowing. + self._state.beat_state = BeatState(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 clear_beats(self) -> None: + """Clear all beat state immediately.""" + self._state.beat_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..833491c 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -3,7 +3,9 @@ from __future__ import annotations import time +from collections.abc import Callable +from aiosendspin.models.visualizer import BeatTiming from rich.text import Text # Unicode block characters for bar rendering (9 levels including space) @@ -17,6 +19,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 @@ -172,12 +179,155 @@ def get_peaks(self) -> list[float]: return list(self._peaks) +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._last_beat_monotonic is not None + + +def render_beat_strip( + width: int, + now_us: int, + recent: list[BeatTiming], + upcoming: list[BeatTiming], + loudness: float, + pulse: float, +) -> 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. + """ + 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) + past_color = _rgb_to_hex(*base) + upcoming_color = _rgb_to_hex(*_lerp_rgb(base, tip, 0.5)) + + 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 = _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)), + ) + 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_spectrum( magnitudes: list[float], width: int, 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, @@ -191,6 +341,7 @@ 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. @@ -204,6 +355,16 @@ 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) + 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), + ) # Find frequency peak bin (for highlight color on its peak marker) freq_peak_bin = max(range(len(magnitudes)), key=lambda i: magnitudes[i]) diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py index 35ac5c8..c2a95f3 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -9,7 +9,7 @@ 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 @@ -148,3 +148,151 @@ def _emit_due_frames(self) -> None: 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.""" + self._client = client + self._unsubscribes = [ + client.add_beat_listener(self._on_beat_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), + ] + + 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_beat_data(self, beats: list[BeatTiming]) -> None: + """Handle incoming beat events. + + Empty list = explicit clear from the server (e.g. before a fresh schedule + is pushed after a track change or seek). Otherwise duplicates are filtered + by timestamp so a repeated batch doesn't draw twice on the timeline. + """ + if self._client is None: + return + if not beats: + self._cancel_timer() + self._pending.clear() + if self._on_schedule is not None: + self._on_schedule([]) + return + existing_ts = {beat.timestamp_us for beat in self._pending} + added = False + for beat in beats: + if beat.timestamp_us in existing_ts: + continue + existing_ts.add(beat.timestamp_us) + self._pending.append(beat) + 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_start(self, message: StreamStartMessage) -> None: + """Flush stale beats when a new visualizer stream starts.""" + if message.payload.visualizer is None: + return + self._cancel_timer() + self._pending.clear() + if self._on_schedule is not None: + self._on_schedule([]) + + 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() diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index 0c90249..88cf448 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -3,7 +3,15 @@ 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.visualizer import ( + BeatState, + VisualizerState, + loudness_to_colors, + render_beat_strip, + render_spectrum, +) # --- loudness_to_colors tests --- @@ -160,3 +168,144 @@ 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_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 From 576502f9da1490fe3e7b5db5cdcac11f0eebdabc Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 1 Jun 2026 16:37:06 +0200 Subject: [PATCH 02/15] Negotiate the visualizer@v1 role --- sendspin/tui/app.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index d9b760c..9a98a89 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -269,17 +269,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", "beat"], - batch_max=8, + rate_max=30, spectrum=ClientHelloVisualizerSpectrum( n_disp_bins=48, scale="mel", f_min=20, f_max=20000, - rate_max=30, ), ) @@ -290,9 +289,6 @@ def _create_client(self) -> SendspinClient: visualizer_support = None if self._visualizer_enabled: visualizer_support = self._build_visualizer_support() - # Prefer v1 (downbeat-aware); fall back to the draft wire when a - # server only implements the legacy negotiation. - roles.append(Roles.VISUALIZER_V1) roles.append(Roles.VISUALIZER) assert self._audio_handler is not None From cc325739e8a0b0868e614e38a0b396f558a1d567 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 1 Jun 2026 16:20:17 +0200 Subject: [PATCH 03/15] chore(deps): bump `aiosendspin` to 6.0.1 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/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" }, From 3979750fc920bd1dd38a0169e2cf9cd39ba31d51 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 1 Jun 2026 16:37:25 +0200 Subject: [PATCH 04/15] Add peaks, pitch, and dominant-frequency to visualizer@v1 --- README.md | 12 +- sendspin/tui/app.py | 48 +++++- sendspin/tui/ui.py | 195 ++++++++++++++++++--- sendspin/tui/visualizer.py | 266 ++++++++++++++++++++++++++++- sendspin/visualizer_connector.py | 148 +++++++++++++++- tests/test_visualizer_connector.py | 62 +++++++ tests/tui/test_visualizer.py | 120 +++++++++++++ 7 files changed, 816 insertions(+), 35 deletions(-) create mode 100644 tests/test_visualizer_connector.py diff --git a/README.md b/README.md index 908b160..f7908b6 100644 --- a/README.md +++ b/README.md @@ -321,9 +321,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 only when detection confidence is high. +- **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/sendspin/tui/app.py b/sendspin/tui/app.py index 9a98a89..bf62fb7 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -50,8 +50,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 BeatHandler, VisualizerHandler +from sendspin.visualizer_connector import BeatHandler, PeakHandler, VisualizerHandler logger = logging.getLogger(__name__) @@ -253,6 +260,7 @@ def __init__(self, args: AppArgs) -> 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 @@ -272,13 +280,13 @@ def _build_visualizer_support() -> ClientHelloVisualizerSupport: """Build visualizer support payload for client/hello (visualizer@v1).""" return ClientHelloVisualizerSupport( buffer_capacity=65536, - types=["loudness", "spectrum", "beat"], + 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, + n_disp_bins=SPECTRUM_N_BINS, + scale=SPECTRUM_SCALE, + f_min=SPECTRUM_F_MIN, + f_max=SPECTRUM_F_MAX, ), ) @@ -337,6 +345,11 @@ def _attach_client(self) -> None: 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) if self._ui is not None: self._ui.set_server_clock(self._client.now_us) @@ -362,6 +375,9 @@ def _detach_client(self) -> 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) @@ -861,7 +877,13 @@ async def _toggle_visualizer(self) -> None: 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.pitch_confidence, + frame.f_peak_freq, + ) def _handle_beat(self, beat: BeatTiming) -> None: """Handle a beat hitting the playhead.""" @@ -873,6 +895,18 @@ def _handle_beat_schedule(self, scheduled: list[BeatTiming]) -> None: 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.""" hook = self._args.hook_start if event == "start" else self._args.hook_stop diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 81425aa..8c07014 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -23,9 +23,15 @@ from sendspin.discovery import DiscoveredServer from sendspin.tui.visualizer import ( + PITCH_CONFIDENCE_MIN, 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 @@ -120,7 +126,8 @@ class UIState: visualizer_enabled: bool = False visualizer_state: VisualizerState = field(default_factory=VisualizerState) beat_state: BeatState = field(default_factory=BeatState) - # Synced server clock provider used for the beat timeline strip. + 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. @@ -807,24 +814,68 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: bar_width = max(10, self._console.width - 1) 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 bright on-color; f_peak reuses + # the spectrum's freq-peak marker color. + cursor_markers, footer_parts, f_peak_col = self._build_freq_cursors( + bar_width, palette_high, freq_peak + ) + has_tonal = bool(footer_parts) + + clock = self._state.server_now_us + # Row budget, highest priority first: beats, peaks (above the spectrum), + # then the tonal cursor + footer (below it). The spectrum keeps >=1 row. + show_beats = clock is not None and height >= 2 + show_peaks = clock is not None and height >= 3 + show_cursor = has_tonal and height >= 4 + show_footer = has_tonal and height >= 5 + reserved = sum((show_beats, show_peaks, show_cursor, show_footer)) + spectrum_height = max(1, height - reserved) + rows: list[Text] = [] - spectrum_height = height - if height >= 2: - spectrum_height = height - 1 - if self._state.server_now_us is not None: - now_us = self._state.server_now_us() + 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_beats: rows.append( - render_beat_strip( - width=bar_width, - now_us=now_us, - recent=self._state.beat_state.recent(), - upcoming=self._state.beat_state.upcoming(), - loudness=loudness, - pulse=beat_pulse, + 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, + ), + row_bg, ) ) - else: - rows.append(Text(" " * bar_width)) + 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, + ), + row_bg, + ) + ) + rows.extend( render_spectrum( magnitudes, @@ -837,10 +888,91 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: palette_high=palette_high, bg_color=bg_color, freq_peak_color=freq_peak, + freq_peak_column=f_peak_col, ) ) + + if show_cursor: + rows.append( + self._pad_bg(render_freq_cursor_row(bar_width, cursor_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, + palette_high: tuple[int, int, int] | None, + freq_peak_color: str, + ) -> tuple[list[tuple[int, str, str]], list[tuple[str, str]], int | None]: + """Build frequency-cursor markers and footer labels for tonal readouts. + + Returns ``(markers, footer_parts, f_peak_column)`` where markers are + ``(column, glyph, hex_color)``, footer_parts are ``(text, hex_color)``, + and f_peak_column is the dominant-frequency spectrum column (or None). + """ + state = self._state.visualizer_state + footer: list[tuple[str, str]] = [] + pitch_marker: tuple[int, str, str] | None = None + f_peak_marker: tuple[int, str, str] | None = None + + if self._palette_active() and palette_high is not None: + pitch_color = f"#{palette_high[0]:02x}{palette_high[1]:02x}{palette_high[2]:02x}" + else: + pitch_color = "#ffffff" + + note = state.pitch_note + pitch_freq = state.pitch_freq + if ( + note is not None + and pitch_freq is not None + and state.pitch_confidence >= PITCH_CONFIDENCE_MIN + ): + 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)) + + f_peak_col: int | None = None + f_peak_freq = state.f_peak_freq + if 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, "△", freq_peak_color) + footer.append((f"f_peak: {f_peak_freq} Hz", freq_peak_color)) + + # Pitch is drawn last so it wins when both land on the same column. + markers = [m for m in (f_peak_marker, pitch_marker) if m is not None] + return markers, 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 @@ -1136,10 +1268,19 @@ 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, + pitch_confidence: 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, pitch_confidence, f_peak_freq + ) self.refresh() def set_visualizer_enabled(self, enabled: bool) -> None: @@ -1148,13 +1289,15 @@ def set_visualizer_enabled(self, enabled: bool) -> None: if not enabled: self._state.visualizer_state.clear() self._state.beat_state.clear() + self._state.peak_state.clear() self.refresh() def set_server_clock(self, now_us: Callable[[], int] | None) -> None: - """Inject the synced-clock callable used by the beat timeline strip.""" + """Inject the synced-clock callable used by the beat and peak strips.""" self._state.server_now_us = now_us - # Rebuild beat state with the new clock for proper recent-beat windowing. + # 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: @@ -1171,6 +1314,20 @@ def set_beat_schedule(self, scheduled: list[BeatTiming]) -> None: 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() diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index 833491c..9d415a7 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -2,12 +2,37 @@ from __future__ import annotations +import math import time from collections.abc import Callable +from dataclasses import dataclass -from aiosendspin.models.visualizer import BeatTiming +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") + +# Below this confidence (0-255) the pitch readout is hidden as unreliable. +PITCH_CONFIDENCE_MIN = 64 + + +@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 @@ -82,6 +107,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.""" @@ -93,9 +146,24 @@ 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._pitch_confidence: int = 0 + self._f_peak_freq: int | None = None + + def update( + self, + spectrum: list[int] | None, + loudness: int | None, + pitch_midi_q88: int | None = None, + pitch_confidence: 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 @@ -104,6 +172,9 @@ 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._pitch_confidence = pitch_confidence or 0 + self._f_peak_freq = f_peak_freq def clear(self) -> None: """Clear all state immediately.""" @@ -114,6 +185,9 @@ def clear(self) -> None: self._peaks = [] self._peak_hold_timers = [] self._last_step_monotonic = time.monotonic() + self._pitch_midi_q88 = None + self._pitch_confidence = 0 + self._f_peak_freq = None def _step(self) -> None: """Advance displayed values toward targets.""" @@ -178,6 +252,33 @@ 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_confidence(self) -> int: + """Latest pitch confidence (0-255).""" + return self._pitch_confidence + + @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 + class BeatState: """Stores beat events and produces a decaying pulse intensity. @@ -242,6 +343,72 @@ def is_active(self) -> bool: """Whether there is current or recent beat activity.""" return bool(self._scheduled) or self._last_beat_monotonic is not None + 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, @@ -321,6 +488,84 @@ def place(timestamp_us: int, glyph: str, style: str, *, downbeat: bool) -> None: return line +def render_peak_strip( + width: int, + now_us: int, + recent: list[PeakEvent], + upcoming: list[PeakEvent], + loudness: float, +) -> 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. Each peak's glyph height scales with its 0-255 strength. No + playhead glyph: the beat strip above carries it. + """ + 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 + + 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) + + 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], width: int, @@ -332,6 +577,7 @@ def render_spectrum( 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. @@ -346,6 +592,8 @@ def render_spectrum( 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). @@ -366,8 +614,11 @@ def render_spectrum( int(tip[2] + (255 - tip[2]) * flash_t), ) - # Find frequency peak bin (for highlight color on its peak marker) - freq_peak_bin = max(range(len(magnitudes)), key=lambda i: magnitudes[i]) + # 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) @@ -398,6 +649,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 c2a95f3..ce85659 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -38,6 +38,13 @@ 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_pitch_conf: 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.""" @@ -55,6 +62,10 @@ def reset(self) -> None: self._timer.cancel() self._timer = None self._pending.clear() + self._latest_loudness = None + self._latest_pitch_midi = None + self._latest_pitch_conf = None + self._latest_f_peak_freq = None self._on_frame(VisualizerFrame(timestamp_us=0)) def detach(self) -> None: @@ -68,7 +79,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 +91,13 @@ 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 + self._latest_pitch_conf = frame.pitch_confidence + 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) @@ -144,6 +164,11 @@ 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.pitch_confidence = self._latest_pitch_conf + latest_due.f_peak_freq = self._latest_f_peak_freq self._on_frame(latest_due) if self._pending: @@ -296,3 +321,124 @@ def _emit_due_beats(self) -> 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_start_listener(self._on_stream_start), + 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_start(self, message: StreamStartMessage) -> None: + """Flush stale peaks when a new visualizer stream starts.""" + if message.payload.visualizer is None: + return + self.reset() + + 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..7c78e6f --- /dev/null +++ b/tests/test_visualizer_connector.py @@ -0,0 +1,62 @@ +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()) diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index 88cf448..f508ff6 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -6,10 +6,17 @@ from aiosendspin.models.visualizer import BeatTiming 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, ) @@ -309,3 +316,116 @@ def test_render_beat_strip_beats_outside_window_dropped() -> None: ) 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_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} From 5c495efa83d25200d3edbe55fc18fe4685c8cf8e Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 1 Jun 2026 16:37:25 +0200 Subject: [PATCH 05/15] Color the beat strip from the palette and self-key the spectrum footer --- sendspin/tui/ui.py | 63 +++++++++++++++++--------------- sendspin/tui/visualizer.py | 39 +++++++++++++++----- sendspin/visualizer_connector.py | 28 -------------- tests/tui/test_visualizer.py | 35 ++++++++++++++++++ 4 files changed, 97 insertions(+), 68 deletions(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 8c07014..447fd27 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -793,24 +793,28 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: peaks = state.get_peaks() beat_pulse = self._state.beat_state.pulse_intensity() - # Low end uses opposite-mode bg to pop, high end uses on-color for contrast. - if not self._palette_active(): + # 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) bg_color = self._palette_bg_hex() @@ -818,10 +822,10 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: 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 bright on-color; f_peak reuses - # the spectrum's freq-peak marker color. + # footer naming each value. pitch uses the on-color, f_peak white/black — + # both guaranteed against the background, and distinct from each other. cursor_markers, footer_parts, f_peak_col = self._build_freq_cursors( - bar_width, palette_high, freq_peak + bar_width, on_color, text_color ) has_tonal = bool(footer_parts) @@ -856,6 +860,8 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: 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, ) @@ -871,6 +877,7 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: recent=self._state.peak_state.recent(), upcoming=self._state.peak_state.upcoming(), loudness=loudness, + color=on_color if palette_on else None, ), row_bg, ) @@ -887,7 +894,7 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: palette_low=palette_low, palette_high=palette_high, bg_color=bg_color, - freq_peak_color=freq_peak, + freq_peak_color=text_color, freq_peak_column=f_peak_col, ) ) @@ -917,25 +924,21 @@ def _pad_bg(row: Text, width: int, row_bg: str) -> Text: def _build_freq_cursors( self, width: int, - palette_high: tuple[int, int, int] | None, - freq_peak_color: str, + pitch_color: str, + f_peak_color: str, ) -> tuple[list[tuple[int, str, str]], list[tuple[str, str]], int | None]: """Build frequency-cursor markers and footer labels for tonal readouts. Returns ``(markers, footer_parts, f_peak_column)`` where markers are ``(column, glyph, hex_color)``, footer_parts are ``(text, hex_color)``, - and f_peak_column is the dominant-frequency spectrum column (or None). + 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 footer: list[tuple[str, str]] = [] pitch_marker: tuple[int, str, str] | None = None f_peak_marker: tuple[int, str, str] | None = None - if self._palette_active() and palette_high is not None: - pitch_color = f"#{palette_high[0]:02x}{palette_high[1]:02x}{palette_high[2]:02x}" - else: - pitch_color = "#ffffff" - note = state.pitch_note pitch_freq = state.pitch_freq if ( @@ -946,15 +949,15 @@ def _build_freq_cursors( 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)) + footer.append((f"▲ pitch: {note}", pitch_color)) f_peak_col: int | None = None f_peak_freq = state.f_peak_freq if 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, "△", freq_peak_color) - footer.append((f"f_peak: {f_peak_freq} Hz", freq_peak_color)) + f_peak_marker = (f_peak_col, "△", f_peak_color) + footer.append((f"△ f_peak: {f_peak_freq} Hz", f_peak_color)) # Pitch is drawn last so it wins when both land on the same column. markers = [m for m in (f_peak_marker, pitch_marker) if m is not None] diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index 9d415a7..8042c45 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -417,6 +417,8 @@ def render_beat_strip( 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. @@ -424,6 +426,10 @@ def render_beat_strip( `│` 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("") @@ -439,8 +445,18 @@ def render_beat_strip( center = width // 2 tip, base = loudness_to_colors(loudness) - past_color = _rgb_to_hex(*base) - upcoming_color = _rgb_to_hex(*_lerp_rgb(base, tip, 0.5)) + 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 @@ -465,11 +481,7 @@ def place(timestamp_us: int, glyph: str, style: str, *, downbeat: bool) -> None: # Playhead overlays whatever was at center. Grows on beat pulse: # idle = thin ┃, mid pulse = heavy ┃, peak pulse = full block █. - playhead_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)), - ) + playhead_color = ph_color if pulse >= 0.6: playhead_glyph = "█" elif pulse >= 0.15: @@ -494,12 +506,16 @@ def render_peak_strip( recent: list[PeakEvent], upcoming: list[PeakEvent], loudness: float, + 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. Each peak's glyph height scales with its 0-255 strength. No playhead glyph: the beat strip above carries it. + + When ``color`` is given (a contrast-guaranteed palette color) it paints every + onset; otherwise the colors follow the loudness tiers. """ if width <= 0: return Text("") @@ -513,9 +529,12 @@ def render_peak_strip( cell_strength = [-1] * width center = width // 2 - tip, base = loudness_to_colors(loudness) - past_color = _rgb_to_hex(*base) - upcoming_color = _rgb_to_hex(*_lerp_rgb(base, tip, 0.5)) + 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 diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py index ce85659..bacbaac 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -8,7 +8,6 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from aiosendspin.models.core import StreamStartMessage from aiosendspin.models.visualizer import BeatTiming, VisualizerFrame if TYPE_CHECKING: @@ -51,7 +50,6 @@ def attach_client(self, client: SendspinClient) -> None: 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), ] @@ -107,15 +105,6 @@ 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: @@ -207,7 +196,6 @@ def attach_client(self, client: SendspinClient) -> None: self._client = client self._unsubscribes = [ client.add_beat_listener(self._on_beat_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), ] @@ -262,15 +250,6 @@ def _on_beat_data(self, beats: list[BeatTiming]) -> None: self._on_schedule(list(self._pending)) self._schedule_next() - def _on_stream_start(self, message: StreamStartMessage) -> None: - """Flush stale beats when a new visualizer stream starts.""" - if message.payload.visualizer is None: - return - self._cancel_timer() - self._pending.clear() - if self._on_schedule is not None: - self._on_schedule([]) - 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: @@ -354,7 +333,6 @@ def attach_client(self, client: SendspinClient) -> None: 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), ] @@ -394,12 +372,6 @@ def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: self._on_schedule(list(self._pending)) self._schedule_next() - def _on_stream_start(self, message: StreamStartMessage) -> None: - """Flush stale peaks when a new visualizer stream starts.""" - if message.payload.visualizer is None: - return - self.reset() - 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: diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index f508ff6..25c37ec 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -429,3 +429,38 @@ def test_render_spectrum_uses_server_freq_peak_column() -> None: 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"} From 58a806d923c64214e4b6fd53bcddf910a413b3a3 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 1 Jun 2026 16:37:26 +0200 Subject: [PATCH 06/15] Route beats through the unified visualizer@v1 frame callback --- sendspin/visualizer_connector.py | 38 ++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py index bacbaac..cd126b1 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -192,10 +192,15 @@ def __init__( self._timer: asyncio.TimerHandle | None = None def attach_client(self, client: SendspinClient) -> None: - """Attach to a SendspinClient and register listeners.""" + """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_beat_listener(self._on_beat_data), + 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), ] @@ -221,28 +226,27 @@ def pending_beats(self) -> list[BeatTiming]: """Snapshot of upcoming beats still waiting to fire.""" return list(self._pending) - def _on_beat_data(self, beats: list[BeatTiming]) -> None: - """Handle incoming beat events. + def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: + """Handle incoming visualizer frames, picking out beat events. - Empty list = explicit clear from the server (e.g. before a fresh schedule - is pushed after a track change or seek). Otherwise duplicates are filtered - by timestamp so a repeated batch doesn't draw twice on the timeline. + 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 - if not beats: - self._cancel_timer() - self._pending.clear() - if self._on_schedule is not None: - self._on_schedule([]) - return existing_ts = {beat.timestamp_us for beat in self._pending} added = False - for beat in beats: - if beat.timestamp_us in existing_ts: + for frame in frames: + if frame.is_downbeat is None: continue - existing_ts.add(beat.timestamp_us) - self._pending.append(beat) + 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 From 142c13802b91faece1bdc9a21deee931df212a88 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 1 Jun 2026 16:37:55 +0200 Subject: [PATCH 07/15] Draw beat/peak strips against the server clock --- README.md | 1 + sendspin/tui/app.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f7908b6..9aed2ed 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`) | diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index bf62fb7..a4e531f 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -351,7 +351,7 @@ def _attach_client(self) -> None: ) self._peak_handler.attach_client(self._client) if self._ui is not None: - self._ui.set_server_clock(self._client.now_us) + self._ui.set_server_clock(self._server_now_us) if MPRIS_AVAILABLE and self._args.use_mpris: self._mpris = SendspinMpris(self._client) @@ -874,6 +874,16 @@ 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_visualizer_frame(self, frame: VisualizerFrame) -> None: """Handle a visualizer frame from the connector.""" if self._ui is not None: From dd20ee0346924d5bff1ff60fe61653df258dc3aa Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 2 Jun 2026 08:37:54 +0200 Subject: [PATCH 08/15] Split f_peak and pitch cursors onto separate rows The dominant-frequency and pitch arrows shared one cursor row; give each its own line (f_peak above pitch) with the footer below. Each row is gated on the visualizer type the server negotiated in stream/start, so a row is only reserved when its data can actually arrive. --- sendspin/tui/app.py | 16 +++++++ sendspin/tui/ui.py | 84 ++++++++++++++++++++++++------------ tests/tui/test_visualizer.py | 32 ++++++++++++++ 3 files changed, 104 insertions(+), 28 deletions(-) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index a4e531f..c751ec9 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, @@ -32,6 +33,7 @@ BeatTiming, ClientHelloVisualizerSpectrum, ClientHelloVisualizerSupport, + StreamStartVisualizer, VisualizerFrame, ) from aiosendspin.models.types import ( @@ -350,6 +352,9 @@ def _attach_client(self) -> None: 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) @@ -380,6 +385,7 @@ def _detach_client(self) -> None: 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() @@ -884,6 +890,16 @@ def _server_now_us(self) -> int: 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: diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 447fd27..d3d3576 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -124,6 +124,8 @@ 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) @@ -824,20 +826,32 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: # 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. - cursor_markers, footer_parts, f_peak_col = self._build_freq_cursors( + f_peak_marker, pitch_marker, footer_parts, f_peak_col = self._build_freq_cursors( bar_width, on_color, text_color ) - has_tonal = bool(footer_parts) + # Reserve a row per negotiated type so the layout stays put even before + # the first frame of that type lands. + vtypes = self._state.visualizer_types clock = self._state.server_now_us # Row budget, highest priority first: beats, peaks (above the spectrum), - # then the tonal cursor + footer (below it). The spectrum keeps >=1 row. + # then the f_peak arrow, pitch arrow, and footer (below it). On short + # terminals the lower tonal rows drop first and the spectrum keeps >=1 row. show_beats = clock is not None and height >= 2 show_peaks = clock is not None and height >= 3 - show_cursor = has_tonal and height >= 4 - show_footer = has_tonal and height >= 5 - reserved = sum((show_beats, show_peaks, show_cursor, show_footer)) - spectrum_height = max(1, height - reserved) + top_reserved = show_beats + show_peaks + tonal: list[str] = [] + if "f_peak" in vtypes: + tonal.append("f_peak") + if "pitch" in vtypes: + tonal.append("pitch") + if tonal: + tonal.append("footer") + visible_tonal = tonal[: max(0, height - top_reserved - 1)] + show_f_peak = "f_peak" in visible_tonal + show_pitch = "pitch" in visible_tonal + show_footer = "footer" in visible_tonal + spectrum_height = max(1, height - top_reserved - len(visible_tonal)) rows: list[Text] = [] if clock is not None and (show_beats or show_peaks): @@ -899,10 +913,12 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: ) ) - if show_cursor: - rows.append( - self._pad_bg(render_freq_cursor_row(bar_width, cursor_markers), bar_width, row_bg) - ) + 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): @@ -926,23 +942,39 @@ def _build_freq_cursors( width: int, pitch_color: str, f_peak_color: str, - ) -> tuple[list[tuple[int, str, str]], list[tuple[str, str]], int | None]: + ) -> 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 ``(markers, footer_parts, f_peak_column)`` where markers are - ``(column, glyph, hex_color)``, 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. + 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) + footer.append((f"△ f_peak: {f_peak_freq} Hz", f_peak_color)) + note = state.pitch_note pitch_freq = state.pitch_freq if ( - note is not None + "pitch" in types + and note is not None and pitch_freq is not None and state.pitch_confidence >= PITCH_CONFIDENCE_MIN ): @@ -951,17 +983,7 @@ def _build_freq_cursors( pitch_marker = (col, "▲", pitch_color) footer.append((f"▲ pitch: {note}", pitch_color)) - f_peak_col: int | None = None - f_peak_freq = state.f_peak_freq - if 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) - footer.append((f"△ f_peak: {f_peak_freq} Hz", f_peak_color)) - - # Pitch is drawn last so it wins when both land on the same column. - markers = [m for m in (f_peak_marker, pitch_marker) if m is not None] - return markers, footer, f_peak_col + return f_peak_marker, pitch_marker, footer, f_peak_col @staticmethod def _gutter_label(label: str, gutter: int, strip: Text, row_bg: str) -> Text: @@ -1286,6 +1308,11 @@ def set_visualizer_frame( ) 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 @@ -1293,6 +1320,7 @@ def set_visualizer_enabled(self, enabled: bool) -> None: 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: diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index 25c37ec..0566c05 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -5,6 +5,7 @@ from aiosendspin.models.visualizer import BeatTiming +from sendspin.tui.ui import SendspinUI from sendspin.tui.visualizer import ( SPECTRUM_F_MAX, SPECTRUM_F_MIN, @@ -20,6 +21,17 @@ 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, 200, 1000) + ui.set_visualizer_types(types) + return ui + # --- loudness_to_colors tests --- @@ -464,3 +476,23 @@ def test_render_peak_strip_uses_palette_color() -> None: ) 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_pitch_arrow_hidden_when_not_negotiated() -> None: + """Pitch data present but not in negotiated types renders no pitch arrow.""" + rows = _ui_with_tonal_frame(frozenset({"f_peak"}))._build_visualizer_rows(10) + plain = "".join(row.plain for row in rows) + assert "△" in plain + assert "▲" not in plain + + +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 From 8eb016791ac28aaa4d343b3e4d8622ed47c34480 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 2 Jun 2026 08:38:48 +0200 Subject: [PATCH 09/15] Put the peaks strip above the beats strip --- sendspin/tui/ui.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index d3d3576..ae25dbd 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -834,11 +834,11 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: vtypes = self._state.visualizer_types clock = self._state.server_now_us - # Row budget, highest priority first: beats, peaks (above the spectrum), + # Row budget, highest priority first: peaks, beats (above the spectrum), # then the f_peak arrow, pitch arrow, and footer (below it). On short # terminals the lower tonal rows drop first and the spectrum keeps >=1 row. - show_beats = clock is not None and height >= 2 - show_peaks = clock is not None and height >= 3 + show_peaks = clock is not None and height >= 2 + show_beats = clock is not None and height >= 3 top_reserved = show_beats + show_peaks tonal: list[str] = [] if "f_peak" in vtypes: @@ -862,36 +862,36 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: # 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_beats: + if show_peaks: rows.append( self._gutter_label( - beats_label, + peaks_label, gutter, - render_beat_strip( + render_peak_strip( width=strip_width, now_us=now_us, - recent=self._state.beat_state.recent(), - upcoming=self._state.beat_state.upcoming(), + recent=self._state.peak_state.recent(), + upcoming=self._state.peak_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, + color=on_color if palette_on else None, ), row_bg, ) ) - if show_peaks: + if show_beats: rows.append( self._gutter_label( - peaks_label, + beats_label, gutter, - render_peak_strip( + render_beat_strip( width=strip_width, now_us=now_us, - recent=self._state.peak_state.recent(), - upcoming=self._state.peak_state.upcoming(), + recent=self._state.beat_state.recent(), + upcoming=self._state.beat_state.upcoming(), loudness=loudness, - color=on_color if palette_on else None, + pulse=beat_pulse, + marker_color=on_color if palette_on else None, + playhead_color=text_color if palette_on else None, ), row_bg, ) From 730f6a97fec1cdf1f63578312c6a440faa5a6633 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 2 Jun 2026 08:48:23 +0200 Subject: [PATCH 10/15] Gate the beats and peaks strips on negotiated types Fold the timeline strips and tonal cursor rows into one keep-priority row budget (peaks, beats, f_peak, pitch, footer). Each strip needs its type in the server-negotiated set, so a strip is hidden when the server won't send it instead of reserving an always-empty row. --- sendspin/tui/ui.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index ae25dbd..78a2cf7 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -832,26 +832,30 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: # Reserve a row per negotiated type so the layout stays put even before # the first frame of that type lands. vtypes = self._state.visualizer_types - clock = self._state.server_now_us - # Row budget, highest priority first: peaks, beats (above the spectrum), - # then the f_peak arrow, pitch arrow, and footer (below it). On short - # terminals the lower tonal rows drop first and the spectrum keeps >=1 row. - show_peaks = clock is not None and height >= 2 - show_beats = clock is not None and height >= 3 - top_reserved = show_beats + show_peaks - tonal: list[str] = [] + + # Row budget, highest keep-priority first: peaks, beats (above the + # spectrum), then the f_peak arrow, pitch arrow, and footer (below it). + # Each row needs its type in the negotiated set; on short terminals the + # lowest-priority rows drop first and the spectrum keeps >=1 row. + 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: - tonal.append("f_peak") + candidates.append("f_peak") if "pitch" in vtypes: - tonal.append("pitch") - if tonal: - tonal.append("footer") - visible_tonal = tonal[: max(0, height - top_reserved - 1)] - show_f_peak = "f_peak" in visible_tonal - show_pitch = "pitch" in visible_tonal - show_footer = "footer" in visible_tonal - spectrum_height = max(1, height - top_reserved - len(visible_tonal)) + candidates.append("pitch") + if "f_peak" in vtypes or "pitch" in vtypes: + 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): From a86c21827263e9f4af7af3997ed6dbfa269899df Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 2 Jun 2026 08:50:26 +0200 Subject: [PATCH 11/15] Data-gate the pitch readout instead of requiring it negotiated Show the pitch arrow and label whenever a confident readout arrives, even if the server didn't list "pitch" in the stream/start types. f_peak and the timeline strips stay type-gated. --- sendspin/tui/ui.py | 21 ++++++++------------- sendspin/tui/visualizer.py | 9 +++++++++ tests/tui/test_visualizer.py | 14 ++++++++++---- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 78a2cf7..4a1f9e5 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -23,7 +23,6 @@ from sendspin.discovery import DiscoveredServer from sendspin.tui.visualizer import ( - PITCH_CONFIDENCE_MIN, BeatState, PeakEvent, PeakState, @@ -829,25 +828,25 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: f_peak_marker, pitch_marker, footer_parts, f_peak_col = self._build_freq_cursors( bar_width, on_color, text_color ) - # Reserve a row per negotiated type so the layout stays put even before - # the first frame of that type lands. 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). - # Each row needs its type in the negotiated set; on short terminals the - # lowest-priority rows drop first and the spectrum keeps >=1 row. + # Strips and f_peak need their type negotiated; pitch shows whenever a + # confident readout arrives. On short terminals the lowest-priority rows + # drop first and the spectrum keeps >=1 row. 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") + has_pitch = self._state.visualizer_state.has_pitch if "f_peak" in vtypes: candidates.append("f_peak") - if "pitch" in vtypes: + if has_pitch: candidates.append("pitch") - if "f_peak" in vtypes or "pitch" in vtypes: + if "f_peak" in vtypes or has_pitch: candidates.append("footer") visible = candidates[: max(0, height - 1)] show_peaks = "peak" in visible @@ -976,12 +975,8 @@ def _build_freq_cursors( note = state.pitch_note pitch_freq = state.pitch_freq - if ( - "pitch" in types - and note is not None - and pitch_freq is not None - and state.pitch_confidence >= PITCH_CONFIDENCE_MIN - ): + 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) diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index 8042c45..909af12 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -279,6 +279,15 @@ def f_peak_freq(self) -> int | None: return None return self._f_peak_freq + @property + def has_pitch(self) -> bool: + """Whether a confident pitch readout is available to display.""" + return ( + self.pitch_note is not None + and self.pitch_freq is not None + and self.pitch_confidence >= PITCH_CONFIDENCE_MIN + ) + class BeatState: """Stores beat events and produces a decaying pulse intensity. diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index 0566c05..6468f40 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -481,12 +481,18 @@ def test_render_peak_strip_uses_palette_color() -> None: # --- tonal cursor row tests --- -def test_pitch_arrow_hidden_when_not_negotiated() -> None: - """Pitch data present but not in negotiated types renders no pitch arrow.""" +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 - assert "▲" not in plain + assert "▲" in plain def test_pitch_arrow_on_separate_line_below_f_peak() -> None: From de2f0b81c7a44040f44daa1ab983cfc8f2a722cc Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 2 Jun 2026 08:55:26 +0200 Subject: [PATCH 12/15] Reserve the pitch row when negotiated and drop the confidence gate Keep the pitch cursor row when "pitch" is negotiated so the arrow appearing and vanishing no longer shifts the spectrum and f_peak rows. Show the arrow whenever a pitch readout exists, regardless of confidence. --- README.md | 2 +- sendspin/tui/ui.py | 14 ++++++++------ sendspin/tui/visualizer.py | 11 ++--------- tests/tui/test_visualizer.py | 19 +++++++++++++++++++ 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9aed2ed..c6049ed 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ The TUI includes a real-time audio visualizer driven by the `visualizer@v1` role - **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 only when detection confidence is high. +- **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. diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 4a1f9e5..21babff 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -833,20 +833,22 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: # 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; pitch shows whenever a - # confident readout arrives. On short terminals the lowest-priority rows - # drop first and the spectrum keeps >=1 row. + # 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") - has_pitch = self._state.visualizer_state.has_pitch if "f_peak" in vtypes: candidates.append("f_peak") - if has_pitch: + if show_pitch_row: candidates.append("pitch") - if "f_peak" in vtypes or has_pitch: + if "f_peak" in vtypes or show_pitch_row: candidates.append("footer") visible = candidates[: max(0, height - 1)] show_peaks = "peak" in visible diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index 909af12..47b5a8c 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -21,9 +21,6 @@ _NOTE_NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") -# Below this confidence (0-255) the pitch readout is hidden as unreliable. -PITCH_CONFIDENCE_MIN = 64 - @dataclass(frozen=True, slots=True) class PeakEvent: @@ -281,12 +278,8 @@ def f_peak_freq(self) -> int | None: @property def has_pitch(self) -> bool: - """Whether a confident pitch readout is available to display.""" - return ( - self.pitch_note is not None - and self.pitch_freq is not None - and self.pitch_confidence >= PITCH_CONFIDENCE_MIN - ) + """Whether a pitch readout is available to display.""" + return self.pitch_note is not None and self.pitch_freq is not None class BeatState: diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index 6468f40..1cafb89 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -495,6 +495,25 @@ def test_pitch_arrow_shown_when_not_negotiated() -> None: 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"}) + + confident = SendspinUI(0.0, visualizer_enabled=True) + confident.set_visualizer_frame([30000] * 32, 40000, _A4_MIDI_Q88, 200, 1000) + confident.set_visualizer_types(types) + rows_confident = confident._build_visualizer_rows(10) + + silent = SendspinUI(0.0, visualizer_enabled=True) + silent.set_visualizer_frame([30000] * 32, 40000, 0, 0, 1000) # no confident pitch + silent.set_visualizer_types(types) + rows_silent = silent._build_visualizer_rows(10) + + f_peak_confident = next(i for i, r in enumerate(rows_confident) if "△" in r.plain) + f_peak_silent = next(i for i, r in enumerate(rows_silent) 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) From 35763c0876755a10f1955c594f9beb383ead01af Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 2 Jun 2026 08:56:51 +0200 Subject: [PATCH 13/15] Pad the f_peak readout to a fixed width --- sendspin/tui/ui.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 21babff..8d0d944 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -973,7 +973,8 @@ def _build_freq_cursors( 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) - footer.append((f"△ f_peak: {f_peak_freq} Hz", 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 From ecf45e148b194f5faed3667fb1ea68108c1f01e9 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 2 Jun 2026 09:14:53 +0200 Subject: [PATCH 14/15] Fix peak-handler reset, beat idle refresh, and sticky readouts Review findings on the visualizer feature: - Reset and detach the peak handler on disconnect and shutdown, and clear the peak strip, matching the beat handler. A reconnect no longer fires a stale asyncio timer or stale peaks against the new stream. - Gate BeatState.is_active on the decaying pulse instead of "a beat ever landed", so the high-rate visualizer refresh idles when paused or silent. - Drop the last-seen loudness/pitch/f_peak on stream/clear and stream/end so a stale readout from one stream can't ride into the next. - Remove the now-unused pitch_confidence plumbing left after the confidence gate was dropped. --- sendspin/tui/app.py | 21 +++++++++++++++---- sendspin/tui/ui.py | 10 +++++---- sendspin/tui/visualizer.py | 11 +--------- sendspin/visualizer_connector.py | 19 ++++------------- tests/test_visualizer_connector.py | 23 +++++++++++++++++++++ tests/tui/test_visualizer.py | 33 ++++++++++++++++++++---------- 6 files changed, 73 insertions(+), 44 deletions(-) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index c751ec9..dd332ce 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -529,6 +529,8 @@ def signal_handler() -> None: 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: @@ -561,9 +563,7 @@ async def _handle_disconnect(self, message: str) -> None: self._ui.set_disconnected(message) if self._visualizer_handler: self._visualizer_handler.reset() - if self._beat_handler: - self._beat_handler.reset() - self._ui.clear_beats() + self._clear_visualizer_timelines() await self._audio_handler.handle_disconnect() async def _connect_cancellable(self, url: str) -> None: @@ -758,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 @@ -784,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.""" @@ -907,7 +921,6 @@ def _handle_visualizer_frame(self, frame: VisualizerFrame) -> None: frame.spectrum, frame.loudness, frame.pitch_midi_q88, - frame.pitch_confidence, frame.f_peak_freq, ) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 8d0d944..f717bf2 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -1300,14 +1300,11 @@ def set_visualizer_frame( spectrum: list[int] | None, loudness: int | None, pitch_midi_q88: int | None = None, - pitch_confidence: 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, pitch_midi_q88, pitch_confidence, f_peak_freq - ) + self._state.visualizer_state.update(spectrum, loudness, pitch_midi_q88, f_peak_freq) self.refresh() def set_visualizer_types(self, types: frozenset[str]) -> None: @@ -1366,6 +1363,11 @@ def clear_beats(self) -> None: 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: """Show the server selector with available servers.""" self._state.available_servers = servers diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index 47b5a8c..2a73e2b 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -145,7 +145,6 @@ def __init__(self) -> None: self._last_step_monotonic = time.monotonic() # Latest tonal readouts (held, not smoothed): pitch and dominant freq. self._pitch_midi_q88: int | None = None - self._pitch_confidence: int = 0 self._f_peak_freq: int | None = None def update( @@ -153,7 +152,6 @@ def update( spectrum: list[int] | None, loudness: int | None, pitch_midi_q88: int | None = None, - pitch_confidence: int | None = None, f_peak_freq: int | None = None, ) -> None: """Update with new frame data. Periodic values are uint16 (0-65535). @@ -170,7 +168,6 @@ def update( if loudness is not None: self._loudness_target = loudness / 65535.0 self._pitch_midi_q88 = pitch_midi_q88 - self._pitch_confidence = pitch_confidence or 0 self._f_peak_freq = f_peak_freq def clear(self) -> None: @@ -183,7 +180,6 @@ def clear(self) -> None: self._peak_hold_timers = [] self._last_step_monotonic = time.monotonic() self._pitch_midi_q88 = None - self._pitch_confidence = 0 self._f_peak_freq = None def _step(self) -> None: @@ -256,11 +252,6 @@ def pitch_note(self) -> str | None: return None return midi_to_note_name(self._pitch_midi_q88) - @property - def pitch_confidence(self) -> int: - """Latest pitch confidence (0-255).""" - return self._pitch_confidence - @property def pitch_freq(self) -> float | None: """Latest perceived pitch as a frequency in Hz, or None when not detected.""" @@ -343,7 +334,7 @@ def recent(self) -> list[BeatTiming]: @property def is_active(self) -> bool: """Whether there is current or recent beat activity.""" - return bool(self._scheduled) or self._last_beat_monotonic is not None + 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. diff --git a/sendspin/visualizer_connector.py b/sendspin/visualizer_connector.py index cd126b1..554819c 100644 --- a/sendspin/visualizer_connector.py +++ b/sendspin/visualizer_connector.py @@ -42,7 +42,6 @@ def __init__( # playhead schedule instead of being dropped. self._latest_loudness: int | None = None self._latest_pitch_midi: int | None = None - self._latest_pitch_conf: int | None = None self._latest_f_peak_freq: int | None = None def attach_client(self, client: SendspinClient) -> None: @@ -62,7 +61,6 @@ def reset(self) -> None: self._pending.clear() self._latest_loudness = None self._latest_pitch_midi = None - self._latest_pitch_conf = None self._latest_f_peak_freq = None self._on_frame(VisualizerFrame(timestamp_us=0)) @@ -93,7 +91,6 @@ def _on_visualizer_data(self, frames: list[VisualizerFrame]) -> None: self._latest_loudness = frame.loudness if frame.pitch_midi_q88 is not None: self._latest_pitch_midi = frame.pitch_midi_q88 - self._latest_pitch_conf = frame.pitch_confidence if frame.f_peak_freq is not None: self._latest_f_peak_freq = frame.f_peak_freq if frame.spectrum is None: @@ -109,22 +106,15 @@ 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.""" @@ -156,7 +146,6 @@ def _emit_due_frames(self) -> None: if latest_due.loudness is None: latest_due.loudness = self._latest_loudness latest_due.pitch_midi_q88 = self._latest_pitch_midi - latest_due.pitch_confidence = self._latest_pitch_conf latest_due.f_peak_freq = self._latest_f_peak_freq self._on_frame(latest_due) diff --git a/tests/test_visualizer_connector.py b/tests/test_visualizer_connector.py index 7c78e6f..b89c84a 100644 --- a/tests/test_visualizer_connector.py +++ b/tests/test_visualizer_connector.py @@ -60,3 +60,26 @@ async def exercise() -> None: 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 1cafb89..39743cc 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -28,7 +28,7 @@ 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, 200, 1000) + ui.set_visualizer_frame([30000] * 32, 40000, _A4_MIDI_Q88, 1000) ui.set_visualizer_types(types) return ui @@ -226,6 +226,17 @@ def test_beat_state_pulse_decays_to_zero() -> None: 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)]) @@ -499,18 +510,18 @@ 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"}) - confident = SendspinUI(0.0, visualizer_enabled=True) - confident.set_visualizer_frame([30000] * 32, 40000, _A4_MIDI_Q88, 200, 1000) - confident.set_visualizer_types(types) - rows_confident = confident._build_visualizer_rows(10) + 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) - silent = SendspinUI(0.0, visualizer_enabled=True) - silent.set_visualizer_frame([30000] * 32, 40000, 0, 0, 1000) # no confident pitch - silent.set_visualizer_types(types) - rows_silent = silent._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_confident) if "△" in r.plain) - f_peak_silent = next(i for i, r in enumerate(rows_silent) if "△" in r.plain) + 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 From af0b17504ae3db3d9eac43647737b81a51b22f0c Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 2 Jun 2026 09:33:50 +0200 Subject: [PATCH 15/15] Draw the playhead on the peak strip The beat strip used to be the only carrier of the "now" cursor, so a stream with peaks but no beats had no time reference. Draw the center playhead on the peak strip too, colored to match the beat strip's. --- sendspin/tui/ui.py | 1 + sendspin/tui/visualizer.py | 11 ++++++++--- tests/tui/test_visualizer.py | 12 ++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index f717bf2..1d0f671 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -879,6 +879,7 @@ def _build_visualizer_rows(self, height: int) -> list[Text]: 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, ) diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index 2a73e2b..c05201c 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -500,15 +500,17 @@ def render_peak_strip( 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. Each peak's glyph height scales with its 0-255 strength. No - playhead glyph: the beat strip above carries it. + 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. + 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("") @@ -548,6 +550,9 @@ def place(timestamp_us: int, strength: int, color: str) -> None: 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: diff --git a/tests/tui/test_visualizer.py b/tests/tui/test_visualizer.py index 39743cc..1c4f962 100644 --- a/tests/tui/test_visualizer.py +++ b/tests/tui/test_visualizer.py @@ -395,6 +395,18 @@ def test_render_peak_strip_marker_placement() -> None: 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 = "▁▂▃▄▅▆▇█"