Roon Controller — Lightweight native macOS remote, looking for beta testers

Hi everyone,

I’ve built a native macOS app (SwiftUI) to control Roon, and I’m looking for beta testers with different setups to help me iron out edge cases.

Why?

My Roon Core runs on a Mac mini (late 2012) and my workstation is a Mac Studio with a USB DAC. The official Roon.app on the Mac Studio simply cannot find the Core — I’ve tried everything: automatic discovery, manual IP entry, firewall disabled, both machines on the same subnet, reboots, reinstalls… nothing works. Roon.app just sits on the “Choose your Core” screen indefinitely. Despite multiple attempts over weeks, it never connects. Meanwhile, the Core is perfectly reachable on the network (Roon Bridge on the same Mac Studio sees it just fine).

Out of frustration, I decided to build my own client. And since the official Roon.app is an Electron app (~500 MB, ~300-400 MB RAM), I wanted something lighter, faster, and more Mac-native.

What is Roon Controller?

A lightweight (~5 MB), native macOS remote that connects directly to your Roon Core. It implements the SOOD and MOO/1 protocols natively in Swift — no Node.js, no Electron, no intermediary.

English French
English UI French UI

Features

  • Automatic Core discovery via SOOD protocol (or manual IP connection)
  • Full playback control: play/pause, next/previous, seek, shuffle, repeat, radio
  • Library browsing via Browse API (albums, artists, playlists, genres, radio stations…)
  • Search within browse results
  • Queue with play-from-here
  • Per-output volume control (slider + mute)
  • Album artwork with blurred background
  • Playback history with replay (tracks and live radio stations)
  • Radio favorites: save tracks heard on live radio, export as CSV (compatible with Soundiiz for TIDAL/Spotify import)
  • Automatic reconnection with exponential backoff
  • Bilingual UI (English / French, follows system language)
  • Dark theme matching Roon’s aesthetic

What it does NOT do

  • No Roon Settings (Core configuration, DSP, streaming accounts)
  • No audio output — for that, Roon Bridge (free, ~37 MB daemon) exposes your Mac’s DAC to the Core
  • No library management (importing, tag editing)

This is a remote control, not a full Roon replacement. Think of it as a lightweight alternative to Roon.app for day-to-day listening.

Architecture

The app connects directly to the Roon Core with zero intermediary:

Roon Controller (SwiftUI)  ---SOOD (UDP multicast)--->  Roon Core
                           <--WebSocket (MOO/1)----->   (port 9330)
  • SOOD: Roon’s UDP multicast discovery protocol — reimplemented with POSIX sockets
  • MOO/1: Roon’s binary messaging protocol over WebSocket — full native implementation
  • Zero external dependencies — pure Swift, no npm, no frameworks beyond Foundation

Audio setup (for those wondering)

Roon Controller is a control app only — it doesn’t output audio. For audio output on macOS, I use Roon Bridge (free from Roon Labs). It runs as a background daemon and exposes the Mac’s USB DAC to the Core via RAAT. Together:

  • Roon Controller (~5 MB) = the remote
  • Roon Bridge (~37 MB) = the audio output
  • Total: ~42 MB vs ~500 MB for Roon.app (and Roon.app still needs Bridge for DAC output anyway)

Download

Download RoonController.dmg

Requirements:

  • macOS 15.0 (Sequoia) or later (tested on macOS 26 Tahoe)
  • A Roon Core on the local network

Installation:

  1. Open the DMG, drag Roon Controller.app to /Applications
  2. First launch: right-click > Open (the app is not code-signed)
  3. Authorize “Roon Controller macOS” in Roon > Settings > Extensions

Looking for beta testers

The app works well on my setup (Mac Studio, macOS Tahoe, Roon 2.x, USB DAC via Roon Bridge), but I’d love to test with different configurations:

  • Different DACs / endpoints (USB, network streamers, AirPlay, HDMI)
  • Multiple zones (grouped or ungrouped)
  • Large libraries (10k+ albums)
  • Different Macs (M1, M2, M3, M4, Intel?)
  • Different macOS versions (Sequoia, Tahoe)
  • Different network setups (VLANs, multiple subnets)

If you try it, please let me know:

  • Does SOOD discovery find your Core?
  • Do all zones show up correctly?
  • Any issues with playback control, browsing, or artwork?

Open source

The full source code is available on GitHub: renesenses/roon-controller

104 unit tests, CI via GitHub Actions, detailed architecture documentation. Contributions welcome!

Technical details (for the curious)

The Roon protocols (SOOD discovery, MOO/1 messaging) are not publicly documented. They were reverse-engineered from the node-roon-api source code and reimplemented in pure Swift 6 with strict concurrency (actors, async/await, Sendable). The app registers as a Roon extension via the standard registry:1/register handshake with token persistence.

Key technical choices:

  • POSIX (BSD) sockets for SOOD to avoid needing the com.apple.developer.networking.multicast entitlement
  • Swift actors for thread-safe network operations
  • URLSessionWebSocketTask for MOO/1 transport
  • Local HTTP server (port 9150) for artwork caching
  • String Catalog (.xcstrings) for localization

Happy to answer any questions or discuss the implementation!

Bertrand

1 Like

It is not. It is built on dotnet.

Very cool stuff! I’m keen to try it on my Macbook Pro 14" M1 Pro

I’ll probably facilitate the installation by turning this into a Homebrew Cask. I’ve done so previously for roon-tui as well. If you’d rather do it yourself, happy to back off too.

Thanks @Nepherte! Really appreciate you offering to try it out — looking
forward to your feedback on the M1 Pro.

Fair warning: this is more of an alpha than a polished release — there’s still
a lot of work to do. The app currently has a compact Player view (central
artwork, playback controls, queue, history). A second UI mode inspired by the
native Roon layout is in the works but not ready yet.

The main feature that Roon doesn’t offer natively (as of 26.3) is radio
favorites. When you’re listening to an internet radio station (FIP, Jazz
Radio, etc.), you can tap the heart icon to save the currently playing track —
it captures the track title, artist, and station name. Your favorites are
stored locally and you can:

  • Replay a saved track by tapping it (it navigates the Roon Browse API through
    the internet_radio hierarchy to find and play the station)
  • Export to CSV (Artist, Title format) — compatible with Soundiiz for
    importing into Spotify, Apple Music, etc.
  • Delete individual favorites or clear all

This is particularly useful for stations like FIP where you constantly
discover great tracks but have no easy way to remember them.

I’ve set up a Homebrew tap to simplify installation:
brew tap renesenses/roon-controller
brew install --cask roon-controller
The tap auto-updates on each release via GitHub Actions.

The DMG is also available directly from the
Release v1.0.1-beta — Compatibilite macOS Tahoe 26.3 · renesenses/roon-controller · GitHub.

If you’d rather host it in your tap alongside roon-tui, happy to collaborate —
the Cask formula is https://github.com/renesenses/homebrew-roon-controller/bl
ob/main/Casks/roon-controller.rb.

Let me know how it goes on your setup!

Cool project, interested especially in the compact view.

  • Does SOOD discovery find your Core?
    Nope. Seems to connect for half a second but drops and goes through discovery again over and over.

Tried with direct IP but no luck.

@Astr0b0y Thanks for testing and for the report! The connect/drop loop was a bug in the first-pairing flow — the
app wasn’t properly waiting for the extension to be approved in Roon.

What was happening: When connecting for the first time, Roon Core replies “not registered yet” and waits for you
to approve the extension. The app was consuming that response and not listening for the follow-up approval, so
the connection would drop and loop.

Fixed in v1.0.2:

  • The app now shows a clear “waiting for approval” screen with instructions
  • Once you approve “Roon Controller macOS” in Roon > Settings > Extensions, the app detects it automatically and
    connects — no restart needed
  • Direct IP connection also works with this fix

Grab the updated DMG here: Release Roon Controller v1.0.2 · renesenses/roon-controller · GitHub

Let me know if it works on your setup!

@Bertrand_CLECH

Cool stuff!

Connected and working on my MacBook Pro M1, MacOS Sequoia 17.7.3

Will say that the UI isn’t terribly responsive, clicks take a bit to get a reaction. I’m also not sure of intended behavior when I’m browsing the library and see the list of tracks – when I click the circular play icon next to them, no response.

Hi Mark, thanks for testing!

UI responsiveness: Each click in the library triggers a round-trip to the Roon Core via WebSocket (Browse API). The delay you’re seeing is the network latency for each request — this is inherent to how Roon’s
API works (stateful, session-based browsing). I’ll look into adding visual feedback (loading indicators) to make it feel snappier.

Play button on library tracks: This depends on the item type. In the library hierarchy, some items are “list” (folders you navigate into) and some are “action_list” (tracks you can play). The circular play icon
should appear on tracks with hint action_list — could you tell me which section you were browsing? (Artists > Album > Tracks, or the top-level Library Tracks view?) I’d like to reproduce and fix this.


v1.0.3 released with the following changes:

  • Extended cover art cache — Album artwork now resolves across all screens (Queue, History, Favorites, Now Playing, Home). No more grey placeholders for previously played tracks.
  • WebSocket stability — Fixed a 15s resource timeout that was killing the connection. Reconnection is now transparent (“Reconnecting…” instead of red/green flashing).
  • 203 unit tests (up from 140) covering models, MOO protocol, image cache, and registration.
  • Universal binary — Now runs natively on both Apple Silicon and Intel Macs.

Install/update:
brew upgrade --cask renesenses/roon-controller/roon-controller
Or download from GitHub Releases.

@Astr0b0y — the v1.0.2 fix for the connection loop should have resolved your issue. If you’re still having trouble, could you try v1.0.3 and share the logs?
log stream --predicate ‘subsystem == “com.bertrand.RoonController”’ --info

First off Kudos to @Bertrand_CLECH for the effort! Great alternative to the full Roon client for simple playback!

However, being an early release there still are some bugs in the GUI. I encounter problems with the “browse” button:

a) Player Mode:

The “browse” button in Library view is only visible on startup. If you accidentally hit the “<“ button in the top level “Explore”, you end up with a blank screen (no browse button”) and need to restart.

b) Roon mode:

The Browse button only briefly flashes for a split second and disappears. No chance to access the library in Roon Mode.

Environment: Roon Server 2.60 on MacOS 15.7.4, Mac Client MacOS 15.7.4 as well.

I haven’t tried before yesterdays 2.60 update, so I can’t tell wether this is realated to the update.

Also two featurre suggestions from my side:

a) Directly switch between Player and Roon modes through a button in the GUI instead of the settings dialog.

b) Integration into the OS’es “now playing” function, if playing back on the local machine. This way you can see track info and Album art and control playback through control center (Just as the full Roon Client does).

I figure, a) should be quite easy but b) might be difficult to implement (?).

Best regards, Roland

Hi Roland,

Thanks for the detailed feedback and screenshots — very helpful for debugging!

v1.0.4 is now available on the Releases page with all your points addressed:

Bug fixes

  • Browse button disappearing (both Player and Roon modes): fixed. The issue was related to browse navigation state
    management and session key handling.

Feature requests — both implemented!

  • Mode toggle button: there’s now a small swap icon next to the “BROWSE” header in the Roon sidebar. One click switches to
    Player mode. No more trips to Settings.
  • macOS Now Playing: full integration with Control Center. You’ll see track title, artist, album, artwork, and a progress
    bar. Play/pause, next/previous and seek all work from Control Center or media keys on your keyboard.

Other highlights in v1.0.4

  • 4 specialized browse views: Genres, TIDAL/streaming, Tracks, Composers — each with a dedicated layout
  • Roon-style playlist view with full pagination (200+ playlists)
  • Default playback zone setting
  • 244 unit tests, 0 failures

Universal binary (arm64 + x86_64). As always: right-click > Open on first launch.

Looking forward to your feedback on this version!

Hi Bertrand,

wow, great work!

I’m happy to report the browse button issue to be fixed and also the two suggested feature additions work fine.

Additional feedback from more testing:

  1. Profile names don’t appear on the homepage in “Roon” mode. Instead, the name of the currently logged in user (“roonserver” in my case) seems to be displayed:

  2. EDIT: The “Recently played” view in Roon mode shows different entries on the Roon Server machine and on the MacBook. I understand this is not the playback history of the selected Roon profile (as it seems to be suggested by the similar look of Roon mode to the Roon App) but the local playback history of the specific Roon Controller instance (?) - so it’s not a bug unless it’s intended otherwise.

  3. Album art generally isn’t displayed with Roon Controller running on the same machine as Roon Server, it works fine on my MacBook accessing the Roon Server over the network though. See screenshot #1 and following screenshot from the Roon Server machine*:

  4. Album art in the OS “now playing” dialog gets lost when pausing playback and doesn’t resume until a new track starts:


    Playback paused, Roon Controller icon in “now playing” for the rest of the track:

  5. Qobuz navigation gets confused when switching from other views in the side bar in Roon mode. It seems, like the currently selected navigation level is kept instead of (re)setting to the top Qobuz navigation level (I hope this makes sense). Some more screenshots to illustrate:
    a) Switching from genre view to Qobuz:



    b) switching from artist view to Qobuz


    This can be worked around by navigating back/up (“<“) to the top “Library” level and then forward/down again to Qobuz.

*Additional info regarding #3:
The user “roonserver” logged in on the Roon Server machine (MacOS 15.7.4) is a standard user without Administrator privileges. The user on my MacBook (also MacOS 15.7.4) has Administrator privileges.
Maybe user privileges have to do with the issue instead of local/remote access? I assume accessing the RoonApi should be the same on the local machine and a remote machine on the network. I tried both 127.0.0.1 and the actual IP adress (192.x.x.x) of the Roon Server in settings “Roon Core (manual connection)” with the same result (no album art).

I hope this helps in further refining the software! Again, your effort is highly appreciated!

Best Regards, Roland

One more quick addition regarding #3 Album Art display on the Roon Server machine:

As it seems, Album Art is transferred to the OS “now playing” dialog, but not displaying in Roon Controller:

Additional Info: I’m accessing the (headless) Roon Server machine via screen sharing. I don’t think, this makes a difference though.

Again one more thing:

  1. Playback time doesn’t update when scrubbing/seeking on a different client. If you scrub/seek in RoonController, Roon App shows the correct updated playback time. Vice versa, it doesn’t work, RoonController just continues counting upwards from the previous playback time.
    Also if you have two RoonControllers running on different machines and seek/scrub with one RoonController, the other RoonController doesn’t update to the new playback time.

    The screenshot was taken after scrubbing/seeking playback time position forward in Roon App:

Hi @Roland_von_Unruh and everyone following along! :waving_hand:

First off — a big thank you to Roland for the incredibly thorough testing and to the community for the encouraging feedback. It really helps shape
this project, and I appreciate every report.

I’m happy to share a new beta release: v1.0.5, with quite a few improvements driven directly by your input.

Download

:musical_note: RoonController.dmg — v1.0.5 (beta)

Universal binary (arm64 + x86_64). Unsigned: right-click > Open on first launch.

What’s new in v1.0.5

TIDAL & Qobuz tabs in Player mode

This one I’m really excited about — the Player sidebar now has dedicated TIDAL and Qobuz tabs (when these services are available in your Roon
setup). Each tab shows compact carousels with album cards that load from a 24-hour disk cache, so they appear instantly even before the Core
responds. Tapping a card takes you straight to the album in Library.

The old segmented picker has been replaced by a compact icon bar (SF Symbols) to fit everything nicely in the 250px sidebar.

Streaming pre-fetch & disk cache

On connection, the app now pre-fetches TIDAL and Qobuz sections in the background and caches them to disk. On relaunch, you see your content
immediately while fresh data loads behind the scenes — it makes the app feel much snappier.

My Live Radio

New grid view for My Live Radio stations with direct playback. Simple and clean. :radio:

Bug fixes

  • TIDAL/Qobuz navigation — Fixed a bug where navigation would break after returning from an album (session keys expired). Navigation now uses
    title-matching instead of cached session keys.
  • Playlist playback — Fixed track play using API level tracking instead of counting browse pushes.
  • Cover art flickering — Fixed artwork briefly disappearing on track changes.
  • Image server — Fixed async port retry on startup.

Responses to your reported issues

Roland, here’s where things stand on each of the items you raised on Feb 15:

  1. Profile name showing “roonserver” — Good catch. The profile name comes from the Roon Core’s registration response, so if the Core runs headless
    (e.g. on a dedicated server), it may just report the machine hostname. That’s what Roon sends us — I’ll dig into whether there’s a way to fetch
    the actual user profile name separately.
  2. “Recently played” differs between machines — This is actually by design: playback history is stored locally per instance and isn’t synced from
    the Core. Unfortunately the Roon Browse API doesn’t expose a server-side “recently played” list, so each Roon Controller instance tracks what it
    observes playing. I agree it can be confusing though — I’ll think about how to make this clearer in the UI.
  3. Album art not displaying on the Roon Server machine — Thanks for reporting this one. It’s likely a localhost image routing issue — the app runs
    a local HTTP server for artwork caching, and when running on the same machine as the Core, there can be a port or loopback conflict. I’ve
    improved the port retry logic in v1.0.5 — please give it a try and let me know if it helps! If not, a few details about your setup would help me
    debug further (is the Core running as a different user? Different network interface?).
  4. Artwork disappearing from Now Playing when paused — Starting with v1.0.4, the app preserves existing artwork when updating Now Playing on
    pause. Please check if v1.0.5 still shows this issue — it may have been a race condition that’s since been resolved. Let me know!
  5. Qobuz navigation state persisting — Great news here: the TIDAL/Qobuz navigation has been significantly reworked in v1.0.5. Navigation now
    resets properly when switching sections and uses title-based matching instead of stale session keys. This should be fully resolved — looking
    forward to your confirmation. :white_check_mark:
  6. Seek position not syncing from other controllers — The app subscribes to zones_seek_changed events from the Core and should update the
    position. However, the local seek interpolation timer (for smooth progress bar animation) may override incoming values in some edge cases. I’ll
    investigate this further — it might need a priority mechanism for server-side seek updates. Definitely want to get this right.

Stats

273 unit tests, 0 failures. Full changelog here.

Really looking forward to your feedback on v1.0.5 — enjoy! :musical_notes:

Bertrand

Hello, why is Sequoia the minimum version of macOS ?

Hi,

Good question! The minimum is macOS 15 (Sequoia) because of two SwiftUI APIs introduced in that version:

  • .onChange(of:) with the new { oldValue, newValue in } signature — used in 3 places to react to state changes (selected zone, navigation, etc.)
  • .defaultSize(width:height:) to set the initial window size

These were choices made from the start to use the latest APIs and avoid workarounds. In theory we could go down to macOS 14 (Sonoma) by refactoring those two points, but it hasn’t
been a priority since Sequoia is free and available on all Macs from around 2018 onwards.

If there’s enough demand for Sonoma support, it’s doable — feel free to open an issue on the GitHub repo.

Thank you for the clear and quick response. I’ll pass, as I own two older Macs “stuck” on macOS Monterey.

v1.0.6 — macOS Monterey (12) support

@Dirk-Pitt Good news! I’ve lowered the deployment target to macOS 12 (Monterey). The app now runs on macOS 12, 13, 14 and 15.

The newer SwiftUI APIs have been replaced with backward-compatible alternatives while preserving the exact same look and behavior on recent macOS versions.

Download: RoonController v1.0.6 DMG

As always, since the app is unsigned: right-click → Open on first launch.

Let me know how it works on your Monterey Macs!

Hello @Bertrand_CLECH,

the project moves ahead very quickly and with large steps as well, as it seems. A lot to catch up on in just one day :slight_smile: !

I played around with the latest version 1.0.6 and can report the following:

#1 (profile name)
Still the same behavior. It definitely show the logged in user name not the host machine name.
In “Library > Settings” it’s possible to switch profiles. The correct Profile names are shown there. Probably you can catch the right profile name there?

#1a (switching profiles)
Switching profiles only works in player mode, in Roon mode you get to an empty playback history screen of some sort but can’t select a different profile (also see #7 for a suggestion to move settings in a different place) :

#2 Recently played (on this machine) :
I guessed it was local history which is fine with me. After all from my understanding this project doesn’t aim to replace/rebuild the full Roon Client but instead offers an lightweight alternative for the most often used playback functions.
One minor thing though: If the playback history is empty, clicking on “played” makes the whole purple box disappear instead of showing an empty box:

#3 Album art display on same machine as Roon Server:
This is resolved in my setup :white_check_mark: . Album art now displays nicely.
My setup isn’t particularly tricky. It’s a (headless) MacMini M1, single wired LAN connection, no WiFi, Roon Server runs as the same user (roonserver) as Roon Controller.
Probably other multi user setups and/or LAN + WiFi enabled in parallel setups might still show issues.

#4 Artwork disappearing from Now Playing when paused:
This also is resolved in my setup :white_check_mark:

#5 Qobuz navigation state persisting:
This also is resolved in my setup :white_check_mark: . Thanks to the new caching methods navigation also is a lot quicker!
Can’t tell for Tidal though as I don’t have a Tidal subscription.
May I suggest using a different icon though? The headphones icon isn’t a good match. Maybe a generic streaming icon will do to avoid copyright issues with Qobuz (same goes for other streaming sources).

#6 Seek position not syncing from other controllers:
Still unchanged. As I understand it this needs more thinking to get it right.
I observed the timer to run far beyond the maximum track time when seeking backwards in a different controller.
Probably server side seek position updates should be handled the same way as seek actions by the user by the local seek interpolation timer? However, a smooth animation probably isn’t needed in this case as most likely the user isn’t observing this anyways. After all, the seek position update was done from a different GUI.

New additions:

#7 Accessing the Settings Dialog (change request):
The Settings dialog (Profile and display settings) is located in a weird place under “Library”. This is not very intuitive as it’s not directly related to the Library but refers to general settings. It’s therefore not easy to find. In fact, I discovered it by accident.
May I suggest to place it separately in the sidebar for example as a cogwheel next to the mode switcher? This way it would be much easier to access.

#8 “Recently added” doesn’t show recently added items:
Instead, it shows all Albums in alphabetical order, just as in Album view.
Also clicking on an album in this view doesn’t open the album but the now playing screen.

I guess, it might not be possible to retrieve this info (sort by: date added) via RoonAPI, so probably this should be removed or replaced by something else?

Of course it would be great, if this could be done. I for my part use this feature a lot to quickly access my latest additions.

#9 Make Artist / Albums / Tracks / Composer boxes in Roon mode clickable (feature suggestion)
As the look and feel seems to be intended to copy the Roon App home page, one expects to be able to click on the boxes and open the respective view. The boxes also react on “mouse over” with getting a bit larger, so they are screaming “click me” in a way :slight_smile:

#10 Sidebar unrecoverable in player mode:
The canned layout lacks a sidebar toggle function in player mode. You can deadlock yourself by minimizing the sidebar when dragging to adjust the sidebar width making it disappear with not option to make it reappear.
I suggest adding a “burger”-style button in player mode just as in Roon mode to toggle the sidebar on/off.

Again Kudos and THX for the great work!

Best regards, Roland

Some feedback:

  1. Do yourself a favor and develop an “English” first application. Many parts of the application are still not yet translated (which is understandable). I know French love their language, but if you do English first, you’ll have a larger user base. For the record, I can read French.
  2. The album column of the tracks in an album is empty (see screenshot). Maybe don’t show that column at all? It’s implied.


3. Clicking on the ‘Played’ button in the Recent section of the home page, gives me a blank home page with no way to revert back.


4. Use tooltips more extensively. Some buttons have icons whose intent is not immediately clear to me (that’s fine). A tooltip that explains what it does, helps.
5. Clicking on the album icon in the bottom left gives me a full screen ‘Now playing’ screen. It’s not immediately obvious how we can exit that screen. My first instict was to click back on the album cover in the bottom left corner. That didn’t do the trick.


6. Clicking on the stats (artists, albums, tracks, …) on the homepage doesn’t do anything for me. Is this intensional? I sort of expected it to take me to those sections


7. Not a bug but a feature. Picture paints a thousand words.