Skip to content

Commit 67f7ee4

Browse files
Add visualizer@v1 with beats, peaks, pitch, and frequency (#259)
Upgrades the TUI visualizer from the old `_draft_r1` draft to the newer `visualizer@v1` role, rendering everything the server streams time-aligned to the playhead: a beats strip with estimated BPM and downbeats, a peaks strip of energy onsets, and pitch and dominant-frequency (`f_peak`) cursors over the spectrum. Only data that is sent by the server is displayed. For example, Music Assistant 2.9 does not support pitch.
1 parent addff75 commit 67f7ee4

9 files changed

Lines changed: 1604 additions & 78 deletions

File tree

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ Settings are stored in `~/.config/sendspin/`:
155155
| `manufacturer` | string | TUI/daemon | Manufacturer name reported in the client hello (`--manufacturer`) |
156156
| `product_name` | string | TUI/daemon | Product name reported in the client hello (`--product-name`); defaults to auto-detected OS/platform name |
157157
| `interface` | string | TUI/daemon | IP address of the network interface to use (`--interface`) |
158+
| `visualizer` | boolean | TUI | Render the `visualizer@v1` audio visualizer on launch (default: false). Toggle with `v` in the TUI |
158159
| `source` | string | serve | Default audio source (file path or URL, ffmpeg input) |
159160
| `source_format` | string | serve | ffmpeg container format for audio source |
160161
| `clients` | array | serve | Client URLs to connect to (`--client`) |
@@ -321,9 +322,17 @@ Hooks receive these environment variables:
321322

322323
### Visualizer
323324

324-
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.
325+
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:
325326

326-
Toggle it by pressing `v` in the TUI. Your preference is saved in settings and remembered on next launch.
327+
- **Spectrum bars** — frequency magnitude across the range, tinted by overall loudness (and by the album-artwork palette when the server provides one).
328+
- **Beats timeline** — a `beats (NNN BPM):` strip with the estimated tempo; downbeats render differently from regular beats.
329+
- **Peaks timeline** — a `peaks:` strip of energy onsets (transients like drum hits), independent of the beat grid, with glyph height scaled by onset strength.
330+
- **Pitch** — the perceived musical note (e.g. `A4`) with an arrow pointing at its position on the spectrum, shown whenever a pitch is detected.
331+
- **Dominant frequency** — an `f_peak:` readout with an arrow marking the loudest frequency on the spectrum.
332+
333+
Lower rows are dropped first on short terminals, keeping the spectrum visible.
334+
335+
Toggle the visualizer by pressing `v` in the TUI. Your preference is saved in settings and remembered on next launch.
327336

328337
### Debugging & Troubleshooting
329338

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ classifiers = [
1414
"Programming Language :: Python :: 3.13",
1515
]
1616
dependencies = [
17-
"aiosendspin[server]~=5.3",
17+
"aiosendspin[server]~=6.0.1",
1818
"aiosendspin-mpris~=2.1.1",
1919
"av>=15.0.0",
2020
"numpy>=1.26.0",

sendspin/tui/app.py

Lines changed: 111 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,18 @@
2222
GroupUpdateServerPayload,
2323
ServerCommandPayload,
2424
ServerStatePayload,
25+
StreamStartMessage,
2526
)
2627
from aiosendspin.models.player import (
2728
ClientHelloPlayerSupport,
2829
PlayerCommandPayload,
2930
SupportedAudioFormat,
3031
)
3132
from aiosendspin.models.visualizer import (
33+
BeatTiming,
3234
ClientHelloVisualizerSpectrum,
3335
ClientHelloVisualizerSupport,
36+
StreamStartVisualizer,
3437
VisualizerFrame,
3538
)
3639
from aiosendspin.models.types import (
@@ -49,8 +52,15 @@
4952
from sendspin.settings import ClientSettings
5053
from sendspin.tui.keyboard import keyboard_loop
5154
from sendspin.tui.ui import ColorMode, SendspinUI
55+
from sendspin.tui.visualizer import (
56+
SPECTRUM_F_MAX,
57+
SPECTRUM_F_MIN,
58+
SPECTRUM_N_BINS,
59+
SPECTRUM_SCALE,
60+
PeakEvent,
61+
)
5262
from sendspin.utils import create_task, get_device_info
53-
from sendspin.visualizer_connector import VisualizerHandler
63+
from sendspin.visualizer_connector import BeatHandler, PeakHandler, VisualizerHandler
5464

5565
logger = logging.getLogger(__name__)
5666

@@ -251,6 +261,8 @@ def __init__(self, args: AppArgs) -> None:
251261
self._client: SendspinClient | None = None
252262
self._audio_handler: AudioStreamHandler | None = None
253263
self._visualizer_handler: VisualizerHandler | None = None
264+
self._beat_handler: BeatHandler | None = None
265+
self._peak_handler: PeakHandler | None = None
254266
self._settings = args.settings
255267
self._visualizer_enabled: bool = args.settings.visualizer
256268
# Currently-applied static delay in milliseconds, mirroring
@@ -267,17 +279,16 @@ def __init__(self, args: AppArgs) -> None:
267279

268280
@staticmethod
269281
def _build_visualizer_support() -> ClientHelloVisualizerSupport:
270-
"""Build visualizer support payload for client/hello."""
282+
"""Build visualizer support payload for client/hello (visualizer@v1)."""
271283
return ClientHelloVisualizerSupport(
272284
buffer_capacity=65536,
273-
types=["loudness", "spectrum"],
274-
batch_max=8,
285+
types=["loudness", "spectrum", "beat", "peak", "f_peak", "pitch"],
286+
rate_max=30,
275287
spectrum=ClientHelloVisualizerSpectrum(
276-
n_disp_bins=48,
277-
scale="mel",
278-
f_min=20,
279-
f_max=20000,
280-
rate_max=30,
288+
n_disp_bins=SPECTRUM_N_BINS,
289+
scale=SPECTRUM_SCALE,
290+
f_min=SPECTRUM_F_MIN,
291+
f_max=SPECTRUM_F_MAX,
281292
),
282293
)
283294

@@ -331,6 +342,21 @@ def _attach_client(self) -> None:
331342
on_frame=self._handle_visualizer_frame,
332343
)
333344
self._visualizer_handler.attach_client(self._client)
345+
self._beat_handler = BeatHandler(
346+
on_beat=self._handle_beat,
347+
on_schedule=self._handle_beat_schedule,
348+
)
349+
self._beat_handler.attach_client(self._client)
350+
self._peak_handler = PeakHandler(
351+
on_peak=self._handle_peak,
352+
on_schedule=self._handle_peak_schedule,
353+
)
354+
self._peak_handler.attach_client(self._client)
355+
self._listener_unsubscribes.append(
356+
self._client.add_stream_start_listener(self._handle_stream_start)
357+
)
358+
if self._ui is not None:
359+
self._ui.set_server_clock(self._server_now_us)
334360

335361
if MPRIS_AVAILABLE and self._args.use_mpris:
336362
self._mpris = SendspinMpris(self._client)
@@ -351,6 +377,15 @@ def _detach_client(self) -> None:
351377
if self._visualizer_handler:
352378
self._visualizer_handler.detach()
353379
self._visualizer_handler = None
380+
if self._beat_handler:
381+
self._beat_handler.detach()
382+
self._beat_handler = None
383+
if self._peak_handler:
384+
self._peak_handler.detach()
385+
self._peak_handler = None
386+
if self._ui is not None:
387+
self._ui.set_server_clock(None)
388+
self._ui.set_visualizer_types(frozenset())
354389

355390
if self._mpris:
356391
self._mpris.stop()
@@ -492,6 +527,10 @@ def signal_handler() -> None:
492527
self._mpris.stop()
493528
if self._visualizer_handler:
494529
self._visualizer_handler.detach()
530+
if self._beat_handler:
531+
self._beat_handler.detach()
532+
if self._peak_handler:
533+
self._peak_handler.detach()
495534
if self._ui:
496535
self._ui.stop()
497536
if self._audio_handler:
@@ -524,6 +563,7 @@ async def _handle_disconnect(self, message: str) -> None:
524563
self._ui.set_disconnected(message)
525564
if self._visualizer_handler:
526565
self._visualizer_handler.reset()
566+
self._clear_visualizer_timelines()
527567
await self._audio_handler.handle_disconnect()
528568

529569
async def _connect_cancellable(self, url: str) -> None:
@@ -718,6 +758,16 @@ def _handle_metadata_update(self, payload: ServerStatePayload) -> None:
718758
ui.set_repeat_shuffle(state.repeat_mode, state.shuffle)
719759
ui.add_event(state.describe())
720760

761+
def _clear_visualizer_timelines(self) -> None:
762+
"""Drop the beat and peak strips, cancelling their pending schedules."""
763+
if self._beat_handler:
764+
self._beat_handler.reset()
765+
if self._peak_handler:
766+
self._peak_handler.reset()
767+
if self._ui is not None:
768+
self._ui.clear_beats()
769+
self._ui.clear_peaks()
770+
721771
def _handle_color_update(self, payload: ServerStatePayload) -> None:
722772
"""Forward a color@v1 palette payload to the UI."""
723773
assert self._ui is not None
@@ -744,9 +794,13 @@ def _handle_group_update(self, payload: GroupUpdateServerPayload) -> None:
744794
with ui.batch_update():
745795
ui.set_group_name(payload.group_name)
746796
if payload.playback_state:
797+
changed = payload.playback_state != state.playback_state
747798
state.playback_state = payload.playback_state
748799
ui.set_playback_state(payload.playback_state)
749800
ui.add_event(f"Playback state: {payload.playback_state.value}")
801+
# No stream/clear fires on pause, so drop the frozen strips here.
802+
if changed and payload.playback_state != PlaybackStateType.PLAYING:
803+
self._clear_visualizer_timelines()
750804

751805
def _handle_server_state(self, payload: ServerStatePayload) -> None:
752806
"""Handle server/state messages with controller state."""
@@ -840,10 +894,57 @@ async def _toggle_visualizer(self) -> None:
840894
if not self._cancel_connect():
841895
await old_client.disconnect()
842896

897+
def _server_now_us(self) -> int:
898+
"""Current time in the server clock the beat and peak strips are drawn against.
899+
900+
Strip events carry server-clock timestamps, so the strip's "now" must be
901+
in the same domain. This is the inverse of ``compute_play_time``, so an
902+
event lands on the playhead cell exactly when it becomes audible.
903+
"""
904+
assert self._client is not None
905+
return self._client.compute_server_time(self._client.now_us())
906+
907+
def _handle_stream_start(self, message: StreamStartMessage) -> None:
908+
"""Record which visualizer types the server negotiated for this stream."""
909+
if self._ui is None:
910+
return
911+
config = message.payload.visualizer
912+
types = (
913+
frozenset(config.types) if isinstance(config, StreamStartVisualizer) else frozenset()
914+
)
915+
self._ui.set_visualizer_types(types)
916+
843917
def _handle_visualizer_frame(self, frame: VisualizerFrame) -> None:
844918
"""Handle a visualizer frame from the connector."""
845919
if self._ui is not None:
846-
self._ui.set_visualizer_frame(frame.spectrum, frame.loudness)
920+
self._ui.set_visualizer_frame(
921+
frame.spectrum,
922+
frame.loudness,
923+
frame.pitch_midi_q88,
924+
frame.f_peak_freq,
925+
)
926+
927+
def _handle_beat(self, beat: BeatTiming) -> None:
928+
"""Handle a beat hitting the playhead."""
929+
if self._ui is not None:
930+
self._ui.record_beat(beat)
931+
932+
def _handle_beat_schedule(self, scheduled: list[BeatTiming]) -> None:
933+
"""Handle an updated upcoming-beat schedule."""
934+
if self._ui is not None:
935+
self._ui.set_beat_schedule(scheduled)
936+
937+
def _handle_peak(self, timestamp_us: int, strength: int) -> None:
938+
"""Handle an energy-onset peak hitting the playhead."""
939+
if self._ui is not None:
940+
self._ui.record_peak(PeakEvent(timestamp_us=timestamp_us, strength=strength))
941+
942+
def _handle_peak_schedule(self, scheduled: list[tuple[int, int]]) -> None:
943+
"""Handle an updated upcoming-peak schedule."""
944+
if self._ui is not None:
945+
self._ui.set_peak_schedule(
946+
[PeakEvent(timestamp_us=ts, strength=strength) for ts, strength in scheduled]
947+
)
847948

848949
def _on_stream_event(self, event: str) -> None:
849950
"""Handle stream lifecycle events by running hooks."""

0 commit comments

Comments
 (0)