A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
README.md

playerd #

A headless Rocksky remote player. playerd registers itself as a playback device on Rocksky's remote-control WebSocket, shows up in the device picker of the web and desktop miniplayers, and plays whatever you send to it — uploads, library tracks, or local files — through the native rockbox-playback engine (the same decoding/DSP stack as the Rockbox firmware, with rockbox-metadata for tag reading and rockbox-dsp under the hood).

Think "Spotify Connect target", but for Rocksky: run it on a headphone rig, a Raspberry Pi wired to the living-room amp, or a server piping audio into a socket — then control it from any Rocksky client.

You can also control the same player through MPD clients such as mpc, or through desktop media controls and playerctl using MPRIS on Linux. MPD is opt-in; MPRIS is enabled by default on Linux.

Contents #

How it works #

  • On start, playerd connects to wss://api.rocksky.app/ws and registers with your access token and a device name. The server assigns it a device id and every controller (web miniplayer, desktop app, mobile) sees it in its device list.
  • Controllers send commands — play, pause, next, previous, seek, queue jump, queue remove, enqueue (play now / play next / add to queue, with shuffle and start index), shuffle, repeat, volume, and the full DSP surface via audio_settings (EQ, tone, crossfade, ReplayGain, crossfeed, compressor, surround, PBE). playerd applies them all to the local engine.
  • playerd pushes now-playing (title, artist, album, codec, sample rate, position), transport state, and the queue back every couple of seconds, so the miniplayer stays live.
  • The queue and the exact position survive a restart (resume, on by default): the daemon comes back cued paused where it left off.
  • If no other device is primary, the server adopts playerd as the primary device: its now-playing drives your public profile status. That stream only writes the status record, though — scrobbles come from playerd itself (see scrobble below), which submits at half the track or 4 minutes.
  • Enqueued tracks resolve to audio like this:
    1. tracks you uploaded to Rocksky stream from https://api.rocksky.app/uploads/<id>/stream (via a short-lived stream token minted from your access token);
    2. library tracks without an upload stream from Navidrome (https://navidrome.rocksky.app) using Subsonic credentials that playerd provisions on first use (your handle + a dedicated API key, cached in ~/.rocksky/navidrome.json — shared with the Rocksky CLI).

Reconnects, heartbeats, and re-advertising state after a drop are handled by the rocksky-sdk remote-player client.

Audio settings from your atproto repo #

The web and desktop apps persist your DSP settings (equalizer, tone, crossfade, ReplayGain) to the app.rocksky.rockbox.audio.settings record in your atproto repo. playerd fetches that record once on startup and then listens for record commits on the Jetstream firehose (all four public servers at once, deduplicated by a shared watermark — the same pattern the SDK uses for repo hydration), so tweaking the EQ in the web player reaches the daemon in real time; there is no polling. Only the sections that actually changed are re-applied — re-pushing an identical EQ would recompute the filter coefficients and audibly disturb playback. Fields the record specifies override the local [equalizer] baseline; anything it doesn't specify keeps the TOML value. Set sync_audio_settings = false to run purely from the local config.

You can also load a saved EQ preset (an app.rocksky.equalizer record) at startup with preset in the [equalizer] section — either an AT URI, or the name/rkey of one of your own presets:

[equalizer]
preset = "Bass Boost"                                          # your preset, by name or rkey
# preset = "at://did:plc:xyz/app.rocksky.equalizer/bass-boost" # anyone's, by AT URI

The preset's bands and precut replace enabled/bands below as the EQ baseline (the EQ is switched on). It is fetched once at startup; if it can't be resolved, playerd warns and falls back to the TOML bands. Note that with sync_audio_settings = true an equalizer section in the synced record still wins over the preset — set it to false to pin the preset.

Install #

One command — detects your OS and CPU architecture and installs the matching binary from the latest playerd release:

curl -fsSL https://raw.githubusercontent.com/tsirysndr/rocksky/main/playerd/install.sh | sh

Or via npm — same binaries, fetched and checksum-verified at install time:

npm install -g @rocksky/playerd

Prebuilt targets: macOS arm64 (Apple Silicon), Linux x86_64 and Linux aarch64. Anything else (e.g. an Intel Mac) builds from source — see below. On Linux the binary needs ALSA at runtime (sudo apt-get install libasound2 on Debian/Ubuntu).

The script verifies the release's SHA-256 checksum and installs to /usr/local/bin when writable, else ~/.local/bin. To customize:

# Pin a version
curl -fsSL https://raw.githubusercontent.com/tsirysndr/rocksky/main/playerd/install.sh \
  | PLAYERD_VERSION=v0.2.0 sh

# Choose the install directory
curl -fsSL https://raw.githubusercontent.com/tsirysndr/rocksky/main/playerd/install.sh \
  | PLAYERD_INSTALL_DIR="$HOME/bin" sh

Building #

playerd is a standalone crate (it is excluded from the repo's root workspace because the rockbox crates compile C and use Cargo links):

cd playerd
cargo build --release
# binary at target/release/playerd

You need a Rust toolchain and a C compiler (Xcode CLT on macOS, build-essential on Debian/Ubuntu). On Linux, ALSA headers are required for the default cpal output (libasound2-dev).

Authentication #

playerd uses your Rocksky access token. In order of precedence:

  1. --token <JWT> or the ROCKSKY_TOKEN environment variable;
  2. token in the config file;
  3. the token file written by rocksky login, ~/.rocksky/token.json (path overridable with token_path in the config).

If none is found it exits with a hint to run rocksky login.

Quick start #

# Log in once (writes ~/.rocksky/token.json)
rocksky login

# Start the daemon; the device name defaults to your hostname
playerd

# ...or name it explicitly
playerd --name "Living Room"

# ...or queue some local music right away
playerd --name "Living Room" ~/Music/albums/some-album

Open Rocksky in the browser or the desktop app, click the device icon in the miniplayer, pick your player, and hit play on anything.

Configuration #

Configuration merges, in increasing precedence: built-in defaults → the TOML file → environment variables → CLI flags.

The config file lives at ~/.rocksky/playerd.toml by default (--config/PLAYERD_CONFIG to override; an explicitly given path must exist, the default one is optional).

Full reference with defaults:

# Device name shown in the miniplayer picker. Empty = hostname.
name = ""

# Remote-control WebSocket and API endpoints.
ws_url = "wss://api.rocksky.app/ws"
api_url = "https://api.rocksky.app"
navidrome_url = "https://navidrome.rocksky.app"

# Access token. Usually leave both as-is and use `rocksky login`.
# token = "eyJ..."
token_path = "~/.rocksky/token.json"

# Audio output backend, see below. Empty = "cpal".
output = ""

volume = 1.0          # 0.0..=1.0
shuffle = false
repeat = "off"        # off | one | all
buffer_seconds = 10.0 # decode-ahead cushion; keep >= 10 for network streams

# Apply the cross-device audio settings from your atproto repo
# (app.rocksky.rockbox.audio.settings) over the [equalizer] baseline below,
# updated live from the Jetstream firehose so web/desktop EQ tweaks reach
# the daemon in real time (no polling).
sync_audio_settings = true
# Jetstream servers to watch, all connected at once (deduplicated).
# Empty/omitted = the four public Bluesky servers.
# jetstream_urls = ["wss://jetstream1.us-east.bsky.network"]

# Scrobble what this daemon plays to your Rocksky account, at half the track or
# 4 minutes (whichever comes first). On by default — nothing is watching a
# headless player, so there is no UI to make the decision for it.
scrobble = true

# Remember the queue and the exact position, and pick up there on the next
# start — cued PAUSED, never auto-playing. Stream URLs carry a short-lived
# token, so remote tracks are re-resolved from their ids on restore; entries
# that no longer resolve (a deleted upload, a moved local file) are dropped.
# Passing paths on the command line skips the restore.
resume = true
resume_path = "~/.rocksky/playerd-queue.m3u8"  # + a .meta.json sidecar beside it

# Auto DJ: transitions derived from the audio instead of from a stopwatch.
# Can also be turned on live from any controller — see "Auto DJ" below.
[autodj]
enabled = false
overlap_seconds = 6      # music-over-music blend each transition aims for
target_lufs = -14.0      # what the per-track gain recommendation aims at
cache_path = "~/.rocksky/playerd-analysis.db"
cache_entries = 5000     # oldest analyses past this are pruned at startup

# Local DSP baseline; fields present in the synced atproto record win.
[equalizer]
# Saved EQ preset to load at startup: an AT URI to an app.rocksky.equalizer
# record, or the name/rkey of one of your own presets. Replaces
# enabled/bands below as the EQ baseline.
# preset = "at://did:plc:xyz/app.rocksky.equalizer/bass-boost"
# preset = "Bass Boost"
enabled = false
# dB gain for the 10 bands at 32, 64, 125, 250, 500, 1k, 2k, 4k, 8k, 16k Hz.
bands = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
bass = 0              # dB
treble = 0            # dB
# Crossfade: 0 off, 1 auto-skip, 2 manual-skip, 3 shuffle,
# 4 shuffle-or-manual, 5 always. Fade times in seconds.
crossfade = 0
fadeInDelay = 0
fadeInDuration = 2
fadeOutDelay = 0
fadeOutDuration = 2
mixMode = 0           # 0 crossfade, 1 mix
# ReplayGain (read from file tags): 0 track, 1 album, 2 shuffle, 3 off.
replaygain = 3
replaygainPreamp = 0.0
replaygainClip = true # prevent clipping

The [equalizer] section uses the same keys and units as ~/.rocksky/settings.toml, so values can be copied between the two files.

Environment variables #

Variable Meaning
PLAYERD_CONFIG config file path
PLAYERD_NAME device name
PLAYERD_WS_URL (or ROCKSKY_WS) WebSocket URL
PLAYERD_API_URL API base URL
PLAYERD_OUTPUT output backend
ROCKSKY_TOKEN access token
RUST_LOG log filter (default playerd=info)

CLI #

playerd [OPTIONS] [PATHS]...
playerd mcp
playerd analyze [--json] <PATHS>...

  PATHS                  audio files or directories to queue and play at startup
  mcp                    run an MCP server on stdio instead of a player
  analyze                analyse local files (loudness, edges, tempo, key) and
                         print the result, warming the Auto DJ cache
  -c, --config <PATH>    TOML config file
  -n, --name <NAME>      device name shown in the miniplayer picker
      --ws-url <URL>     remote-control WebSocket URL
      --api-url <URL>    Rocksky API base URL
      --token <JWT>      access token
  -o, --output <SPEC>    audio output backend

MPD and Linux MPRIS #

MPD configuration and mpc #

MPD is disabled unless a port is configured in ~/.rocksky/playerd.toml:

[mpd]
port = 6600
bind = "127.0.0.1"   # default: local clients only
# enabled = false    # disable while retaining the port

Defining port enables MPD automatically. Set enabled = false to disable it, or remove port; enabled = true alone does not start a listener.

For remote clients, both the port and bind address are configurable. For example, listen on all IPv4 interfaces at port 6601:

[mpd]
enabled = true
port = 6601
bind = "0.0.0.0"

Choose an unused port if another player already provides MPD. Restart playerd after changing the configuration. For a systemd user service:

systemctl --user restart playerd

With the local port-6600 configuration, use mpc on the player machine:

mpc -h 127.0.0.1 -p 6600 current    # artist and title of the current song
mpc -h 127.0.0.1 -p 6600 status
mpc -h 127.0.0.1 -p 6600 play
mpc -h 127.0.0.1 -p 6600 pause
mpc -h 127.0.0.1 -p 6600 next

For the all-interface port-6601 example, replace PLAYER_HOST with the player machine's LAN address or Tailscale hostname:

mpc -h PLAYER_HOST -p 6601 current
mpc -h PLAYER_HOST -p 6601 status

MPD controls the same playback engine and queue as Rocksky. The adapter supports transport, seeking, volume, shuffle/repeat, queue editing with stable song IDs, command lists, idle notifications, library browsing and tag/value searches, loading library playlists, and cover-art reads. commands lists supported commands; unsupported commands and modes return MPD errors. Library browsing indexes your Rocksky uploads and the current queue. Stored playlists come from the Subsonic library. Modern filter expressions, playlist writes, consume, and single-without-repeat are not supported. The listener has no MPD password authentication; keep the loopback bind or use a trusted, access-controlled network.

Linux MPRIS and playerctl #

MPRIS is enabled by default on Linux, using the session D-Bus name org.mpris.MediaPlayer2.playerd. It provides playback controls, position and seek notifications, volume, shuffle/repeat, track metadata, and an editable track list. Desktop media controls and clients such as playerctl operate on the same queue as MPD and Rocksky. Album art uses mpris:artUrl: remote covers are cached locally, embedded covers are extracted, and local cover.jpg, cover.png, folder.jpg, or folder.png are used as a fallback. Metadata notifications refresh the cover when the track or artwork changes.

Run playerctl as the same user and in the same D-Bus session as playerd:

playerctl -p playerd metadata --format '{{artist}} - {{title}}'
playerctl -p playerd metadata                 # all current-track metadata
playerctl -p playerd metadata mpris:artUrl    # album-art URI
playerctl -p playerd status
playerctl -p playerd play-pause
playerctl -p playerd next

Unlike mpc, playerctl uses the local session bus rather than a TCP port. For a remote player, run it on that machine over SSH with access to the player's user session bus, or use mpc over the network.

To disable MPRIS:

[mpris]
enabled = false

MPRIS uses zbus with its pure Rust Tokio backend and requires no libdbus. Both the module and dependency compile only on Linux. A user session bus must be available (normally automatic for a desktop or systemd user service). If the bus is unavailable, playerd logs a warning and continues playing. The MPRIS setting has no effect on other operating systems.

If playerctl cannot find playerd, check playerctl --list-all and the playerd logs. A missing session bus or [mpris] enabled = false prevents registration even while audio playback and MPD remain available.

Audio output #

The output setting (or -o/PLAYERD_OUTPUT) selects where decoded audio goes:

Spec Behavior
cpal the system's default audio device (default)
stdout raw interleaved S16LE stereo PCM on stdout
fifo:/path/to/pipe write PCM into an existing FIFO (mkfifo it first)
unix:/path/to.sock Unix socket
tcp:HOST:PORT or tcp::PORT TCP socket

Socket backends listen and block startup until a client connects. Example — pipe into ffmpeg:

playerd -o stdout | ffmpeg -f s16le -ar 44100 -ac 2 -i - ...

Local playback #

UPnP/DLNA media URLs use playerd's HTTP playback support; no separate playerd UPnP switch is required. Finite MP4/M4A/M4B files are downloaded into a verified temporary cache before playback so Rockbox retains HE-AAC seek/resume metadata. Startup waits for that download; playback and seeking then use the local cache.

Positional paths are scanned (directories recursively), filtered to formats the rockbox codecs understand, tagged via rockbox-metadata, queued, and played. The queue — including titles, artists, and durations from the file tags — is visible and controllable from any Rocksky client, and album art + likes are enriched server-side.

What is (and isn't) remotely controllable #

Supported remote commands: play, pause, next, previous, seek, jump to a queue position, remove from queue, move within the queue, enqueue (now / next / last, with shuffle and start index — this is what the miniplayer's "play on device" does for songs, albums, and playlists), shuffle, repeat, volume, and the DSP surface via audio_settings. The volume, shuffle and repeat config keys are the startup values for those; the live setting is whatever a controller last sent, and it survives a restart alongside the queue.

What stays startup-only is everything the protocol has no command for: the output backend, buffer_seconds, resume, scrobble and sync_audio_settings. Changing those means editing playerd.toml and restarting the daemon.

Auto DJ #

Most players crossfade on a stopwatch: blend the last N seconds of one track into the first N of the next, whatever is in them. On real records that is often two seconds of room tone fading into a second of silence — the "crossfade" you hear is the gap.

Auto DJ analyses both sides of the transition and programs the engine so the fade lands on the music: the outgoing ramp finishes where the last note does, and the incoming track's own lead-in is spent inside the blend instead of after it.

# in ~/.rocksky/playerd.toml
[autodj]
enabled = true
overlap_seconds = 6

…or turn it on live from any controller — it is a crossfade mode, so it rides the existing audio_settings command:

{ "type": "command", "action": "audio_settings",
  "args": { "crossfade": { "mode": "auto", "fadeOutDuration": 6000 } } }

From an agent, that is set_auto_dj (see below). A player that predates Auto DJ reads "auto" as an unknown mode, falls back to "off", and keeps playing.

The analysis #

One decode pass per track, cached in SQLite (~/.rocksky/playerd-analysis.db) so a track is only ever analysed once:

Loudness EBU R128 integrated LUFS, true peak, loudness range, and the gain that would bring the track to target_lufs without clipping (ebur128)
Edges where the music actually starts and ends — what makes a transition land on music instead of silence
Shape a 200-bin peak waveform
Tempo & key BPM with confidence and stability, musical key and its Camelot code, for beat- and harmonic-matching (oximedia-mir)
Energy a 0–1 figure derived from loudness, tempo and dynamic range, for sequencing a set

Decoding for analysis uses symphonia rather than the rockbox codecs the player itself uses: those keep global codec state behind a process-wide gate, so analysing the next track would block until the current one finished playing. The trade-off is that a few exotic AAC profiles symphonia cannot decode ("aac too complex") get no analysis, and their transitions fall back to a plain crossfade.

Inspect it yourself — this also warms the cache, so an album analysed up front has nothing left to compute when it plays:

playerd analyze ~/Music/some-album          # summary per track
playerd analyze --json track.flac           # the whole document
01 - Radiohead - You.m4a
  -14.5 LUFS  peak -0.94 dBTP  gain -0.1 dB  (2 ch @ 22050 Hz, 3:28)
  music 0:01 → 3:26  (lead-in 1180 ms, tail 2613 ms)
  149.0 BPM (89% confident)  key E minor (9A)  energy 0.39  range 5.9 LU

How the fade is programmed #

Rockbox anchors the crossfade region to the end of the outgoing file: the region is max(out_delay + out_duration, in_delay + in_duration) long, and the outgoing ramp finishes out_delay + out_duration into it. So with trailing for the outgoing silent tail, lead for the incoming silent head and overlap for the blend you asked for:

out_delay    = 0                             the outgoing ramp runs for the
out_duration = overlap                       last `overlap` of actual music

in_delay     = lead                          the incoming fade starts when
in_duration  = overlap + trailing − lead     its music does

⇒ region − (out_delay + out_duration) = trailing
  — the ramp ends exactly where the music ends.

AI control: the MCP server #

playerd mcp runs a Model Context Protocol server on stdio, so Claude, Codex, Copilot and anything else that speaks MCP can drive playback: pick a device, search the library, build a queue, skip, seek, set the volume, tune the EQ.

It is a controller, not a player. It registers on the same remote-control WebSocket as the web and desktop miniplayers and commands every device on the account, so the agent can run on a laptop and start music on the amp in the living room. It needs the same access token as the daemon (rocksky login, or ROCKSKY_TOKEN) and no audio hardware of its own.

Register it once:

# Claude Code
claude mcp add rocksky-player -- playerd mcp
// Claude Desktop (claude_desktop_config.json); VS Code / Copilot use
// .vscode/mcp.json with "servers" and "type": "stdio"
{ "mcpServers": { "rocksky-player": { "command": "playerd", "args": ["mcp"] } } }
# Codex (~/.codex/config.toml)
[mcp_servers.rocksky-player]
command = "playerd"
args = ["mcp"]

The 35 tools cover the whole live surface:

Group Tools
Devices list_devices, get_player_state, set_primary_device
Transport play, pause, next_track, previous_track, seek, set_volume, set_playback_mode
Queue get_queue, enqueue, queue_jump, queue_remove, queue_move, clear_queue
Library search_library, get_album, get_artist, browse_songs, browse_albums, list_genres, list_playlists, get_playlist
Taste whoami, get_recommendations, get_listening_history
Sound get_audio_settings, set_equalizer, set_audio_settings, list_equalizer_presets, apply_equalizer_preset
Auto DJ set_auto_dj, analyze_tracks, plan_set

Library lookups return an id on every track, and that id is what enqueue takes. Recommendations and listening history come back as names instead, so enqueue accepts plain {title, artist} entries too and matches them against the library itself — which is what makes an AI DJ one call rather than a resolution loop:

"read what I've been listening to, build a 10-track set for cooking dinner,
 and queue it on the Living Room without interrupting what's on"

plan_set takes that further: it analyses the candidates and orders them so tempo, key and energy move sensibly instead of lurching — a greedy walk over a transition cost of BPM stretch, Camelot compatibility and energy step, shaped by smooth / build / wind_down / arc. It hands back ordered ids for enqueue, plus what each transition will do:

0. Bullet Proof ... I Wish I  bpm  79.1  key 9B  energy 0.45
1. Black Star                 bpm  79.1  key 9B  energy 0.59   Δbpm 0.0   keys OK
2. My Iron Lung               bpm  97.4  key 9B  energy 0.62   Δbpm 18.3  keys OK
3. Sulk                       bpm  97.4  key 10B energy 0.67   Δbpm 0.0   keys OK

Pair it with set_auto_dj and the agent picks the records while the player handles the mix.

Startup-only settings — the output backend, buffer_seconds, resume, scrobble, sync_audio_settings — stay in playerd.toml; they are not remotely controllable and the MCP server does not expose them.

Prompts, examples, and what to expect #

AI-DJ.md is the guide: setup for every client, prompts that work well, and real output for each one — planned sets, analysis, transitions — plus an honest list of what it cannot do.

The DJ skill #

skills/rocksky-dj/ is an agent skill teaching the craft on top of the tools: device etiquette, how to sequence a set rather than dump a playlist, when now is rude and last is right, and how to install all of this from scratch.

cp -r playerd/skills/rocksky-dj ~/.claude/skills/

Running as a service #

systemd unit example (~/.config/systemd/user/playerd.service):

[Unit]
Description=Rocksky remote player
After=network-online.target sound.target

[Service]
ExecStart=%h/.local/bin/playerd --name "Living Room"
Restart=on-failure
RestartSec=3

[Install]
WantedBy=default.target
systemctl --user enable --now playerd

Troubleshooting #

  • "audio engine: NoOutputDevice" — no usable output device for cpal; on a headless box either fix ALSA/PipeWire or use a stdout/fifo/socket output.
  • "no access token: run rocksky login first" — authenticate, or pass --token/ROCKSKY_TOKEN.
  • Device not in the picker — devices appear once they have pushed state; give it a couple of seconds after "registered as device …" shows in the log. Also make sure the controller is logged in to the same Rocksky account: devices are per-user.
  • "enqueue: track is not streamable, skipping" — the track has neither an upload id nor a resolvable Navidrome id, or credential provisioning failed (check the log for navidrome credentials unavailable).
  • Choppy start on remote tracks — raise buffer_seconds.
  • "aac too complex" from analyze — symphonia cannot decode that AAC profile, so the track gets no analysis and its transitions fall back to a plain crossfade. Everything else about playback is unaffected.
  • Auto DJ transitions sound ordinary at first — the analysis of a newly queued pair runs in the background; the transition after it is shaped. RUST_LOG=playerd=debug logs each one it programs.
  • Verbose logs: RUST_LOG=playerd=debug,rocksky_sdk=debug playerd (all logs go to stderr, so -o stdout stays pure PCM).