Songr Roethlar
winget install --id=Roethlar.Songr -e Multi platform controller for your Roon Core. Linux, macOS, Windows, or browser, your library is at your fingertips.
winget install --id=Roethlar.Songr -e Multi platform controller for your Roon Core. Linux, macOS, Windows, or browser, your library is at your fingertips.
Web-based controller for a local Roon Core, built with Node.js + SvelteKit.
Download the desktop app or headless server from
GitHub Releases.
Desktop packages are available through Homebrew (roethlar/tap/songr),
Scoop (roethlar bucket, songr), AUR (songr-bin), and WinGet
(roethlar.Songr). The headless server is songr-server on npm;
Docker images are ghcr.io/roethlar/songr:latest and versioned :vX.Y.Z tags.
![]() | ![]() |
![]() |
The selected zone's now-playing state is published to the browser's Media Session API: title, artist, album, artwork and position, with play, pause, next, previous and seek handlers that send the same socket commands the on-screen transport buttons send. In Chromium that feeds the OS media surface — MPRIS on Linux, System Media Transport Controls on Windows, Now Playing on macOS — so hardware media keys can drive Roon playback.
Why the page plays a silent audio clip. A controller plays no audio of its
own, and metadata alone does not get a page onto the OS media surface. The
Media Session spec is explicit that playbackState "MUST not affect media
session routing", and the routing itself is the user agent's choice: Chromium
selects the page holding audio focus, based on media elements that are
potentially playing and not muted. So while a track is loaded in the selected
zone, the page loops a generated clip of digital silence through one detached
`` element. It is left unmuted at full volume on purpose — muting it
would remove the player from the media session and defeat the point — and it
carries no signal, so there is nothing to hear. The clip is 20 seconds long
because Chromium treats very short media as a transient sound effect rather
than as media worth a session. Browsers may refuse to start it until the page
has seen a user gesture; it retries on the next click or keypress.
What is and is not verified. The repository's Playwright suite proves in the pinned Chromium that the clip decodes and plays unmuted, that the metadata, artwork and position payloads are accepted, and that every action handler registers. Whether a given desktop actually routes its hardware media keys to the browser is a property of the browser build and the desktop environment, and no automated test can press a hardware key: MPRIS behaviour on Linux and the macOS Now Playing panel have not been verified in this repository.
The Library includes Artists, Albums, Genres, Tracks, Composers, Tags,
My Live Radio, Recently played, Favorites and Surprise me, with instant
search, sorting, filtering and density control. Artist, album, genre,
composer and track pages carry durable /library/... addresses that survive
a reload, a fresh tab, and Back/Forward.
Open Settings → Library navigation to choose the pages on the main row and
their order; More contains the rest. Choices are saved by the current Songr
server and shared with its desktop and browser clients. The file is
DATA_DIR/navigation-preferences.json (./data/navigation-preferences.json by
default). Docker uses the existing /app/data volume; an embedded desktop engine
uses Electron's userData/data directory. A desktop connected to a remote server
uses that server's saved choices.
Recently played records tracks observed while the connected Songr server was running and connected to Roon; it does not import Roon's earlier history. Desktop and browser clients of that server share this list. Select a card to search its recorded title (or artist when the title is missing), then choose the current result. Opening a card does not start playback.
The Artists tab has a compact Album / All switch. Album artists is the default and groups albums by the exact credit Roon supplies; single-album artists stay included, collaboration credits stay together, and albums without a credit have their own Unknown album artist group. All artists shows Roon's full Artists list, including contributors. Songr remembers your choice, while an explicit page address always wins. Credit groups and their album/track pages can also be bookmarked or opened in a new tab.
The list heading, count, artist-view switch, and Sort controls stay visible below the scope tabs while the list scrolls.
Songr reads the current library through Roon's public Browse API and does not retain a separate library catalog. Recently played and Favorites are stored by Songr.
git pull && sudo ./scripts/install.sh --reinstall
That is the whole upgrade. The install rebuilds the backend and frontend,
preserves pairing/config/data, and restarts the service. Dependencies are
vendored in the repository, so a plain npm ci works everywhere — no git
sourcing, no flags.
Roon's public transport API (node-roon-api-transport) does not expose remove/reorder endpoints. All currently available queue controls are implemented.
node-roon-api, node-roon-api-transport, node-roon-api-browse, node-roon-api-imagesrc/ Backend TypeScript source
ui/ SvelteKit frontend (built to ui/build/)
vendor/ Vendored Roon dependencies (node-roon-api*, pinned commits)
scripts/ Installer scripts (Linux, macOS, Windows)
deploy/ Systemd service template
config/ Roon pairing token (gitignored)
Dockerfile Multi-stage build: backend + frontend → single image/port
Copy .env.example to .env and adjust as needed.
| Variable | Description | Default |
|---|---|---|
HOST | Bind address. 0.0.0.0 makes the UI reachable on the LAN; set 127.0.0.1 for localhost-only (recommended behind a reverse proxy) | 0.0.0.0 |
PORT | HTTP port (serves API + UI) | 3333 |
LOG_LEVEL | Pino log level. trace enables raw Roon payload dumps for debugging | info |
ROON_TOKEN_PATH | Roon pairing-state file (paired_core_id + per-core tokens) | ./config/roon-token.json |
IMAGE_CACHE_PATH | Artwork disk cache | ./data/image-cache |
IMAGE_CACHE_MAX_BYTES | Disk cache cap (bytes); LRU eviction when exceeded | 10737418240 (10 GB) |
RECENTLY_PLAYED_PATH | JSON file for "Recently played on this controller" persistence | ./data/recently-played.json |
RECENTLY_PLAYED_CAP | Max entries kept in the rolling list (1-1000) | 50 |
FAVORITES_PATH | JSON file for user-curated favorites (tracks/albums/artists) | ./data/favorites.json |
CLIENT_ORIGIN | Comma-separated Socket.IO CORS allowlist, or * for any | * |
TRUST_PROXY | Set to true when fronted by a reverse proxy so rate limits identify the real client IP | unset |
HOST=0.0.0.0 exposes the controller on every interface. There is no built-in authentication — anyone reachable on the network can browse, search, and control playback. For a single-purpose home appliance on a trusted LAN this is intentional. For anything broader, bind to 127.0.0.1 and front with a reverse proxy that adds auth, or set CLIENT_ORIGIN to your specific frontend origin(s).X-Content-Type-Options, etc.). The /api/* surface is rate-limited to 600 requests/minute per IP.0o600 under a directory created with mode 0o700.Each installer builds from source, deploys to a system directory, and registers a service that starts on boot. Run from the repository root.
sudo ./scripts/install.sh
Options: --port PORT, --install-dir DIR (default: /opt/roon-controller), --user USER (default: roon), --reinstall, --no-start
sudo ./scripts/install-macos.sh
Options: --port PORT, --install-dir DIR (default: /opt/roon-controller), --reinstall, --no-start
Installs as a launchd daemon. Logs at /Library/Logs/RoonController/.
Requires NSSM (winget install nssm or choco install nssm). Run in an elevated PowerShell:
.\scripts\install-windows.ps1
Options: -Port, -InstallDir (default: C:\Program Files\RoonController), -Reinstall, -NoStart
cp .env.example .env # optional — only to override defaults
docker compose build
docker compose up -d
With the default paths, the ./config/ and ./data/ volumes persist the Roon
pairing token, artwork cache, Recently Played history, and
Favorites across container restarts. If you override any persistence path to a
location outside those directories, mount that location separately.
./scripts/run-local.sh # installs deps and starts both servers
Or manually:
npm install && npm run dev # backend on :3333
cd ui && npm install && npm run dev -- --host # frontend on :5173 (proxies /api → :3333)
npm run build
npm test -- --runInBand
npm run lint
npm --prefix ui run check
npm --prefix ui test
npm --prefix ui run build
On first run, Songr searches for your Core and shows its name and connection progress. Once it requests approval: Roon → Settings → Extensions → enable Songr (your machine's name) (installs paired before mid-2026 may still show the older name "Custom Roon Controller").
If no Core responds after 15 seconds, the guide shows network/firewall checks and keeps searching. A discovery timeout does not identify the cause by itself. Pairing finishes setup immediately. Roon Bridge is optional: install it only if you want audio playback on this computer, then enable the output in Roon → Settings → Audio. The final Bridge note does not block the library.
Roon's pairing state — paired_core_id plus a per-core token map — is persisted to ROON_TOKEN_PATH (mode 0o600, atomic write). Reconnect is automatic on subsequent starts.
Older builds accidentally let node-roon-api write config.json in the working directory. On first run, an existing config.json in the cwd is migrated to ROON_TOKEN_PATH and the cwd copy removed. No action required from you.
Read AGENTS.md (canonical guidance) and .agents/state.md (current state, active
work, next steps) before continuing work. Durable decisions live in
.agents/decisions.md. Update .agents/state.md at the end of a session.