One app. Your music. Your Philips Hue lights. Your Music Assistant speakers.
Play your library, sync your Hue lights, group your Music Assistant speakers —
all local, all in one place.
Most music apps make you choose. Want to play your own collection? That's one app. Stream to speakers around the house? Another one. Sync your lights to the music? A third, probably with a subscription. Group speakers? Yet another, if it's even available. This app evolved from the idea behind syncoV2, but having music playing in one place and lights sync in another is definitely annoying at times. If you prefer a dashboard card on Home Assistant have the option to do so, with syncoV2 only.
CAMusic puts it all in one app — a music player, a multi-speaker controller, and a light-sync engine, with no feature locked behind a paywall:
| Feature | CAMusic | Typical player | Light-sync app |
|---|---|---|---|
| Play your own music library | ✅ | ✅ | ❌ |
| Multiple library servers | ✅ | ✅ (some) | ❌ |
| Group speakers | ✅ | ❌ | ❌ |
| Philips Hue Entertainment sync | ✅ | ❌ | ✅ (paid) |
| Offline playback | ✅ | ✅ (some) | ❌ |
| Parametric EQ per speaker | ✅ | ❌ | ❌ |
| 24-bit / hi-res output | ✅ | ❌ | ❌ |
| Driving controls over the map | ✅ | ❌ | ❌ |
| Speed limit alert with tolerance | ✅ | ❌ | ❌ |
| Auto-pause for phone calls | ✅ | ❌ | ❌ |
| Listening statistics | ✅ | ❌ | ❌ |
| USB DAC detection | ✅ | ❌ | ❌ |
| Settings export / import (encrypted) | ✅ | ❌ | ❌ |
| Home-screen widget | ✅ | ✅ (some) | ❌ |
| Offline downloads as a browsable library | ✅ | ❌ | ❌ |
| Lights follow other apps' audio | ✅ (see limits) | ❌ | ✅ (some) |
| One app, no subscription | ✅ | — | — |
Add as many libraries as you like — Navidrome, Jellyfin, any Subsonic-compatible server, or files already on your phone — and switch between them freely. Each server is a card with its own credentials, status and stream quality; one tap sets it active.
| Server | Auth | What it brings |
|---|---|---|
| Navidrome | Username + password | The reference — OpenSubsonic extensions give synced lyrics, ReplayGain, and exact per-track format metadata |
| Subsonic / OpenSubsonic | Username + password | Gonic, Airsonic, Astista, Ampache's Subsonic API — anything speaking the protocol |
| Jellyfin | Username + password | Original file streaming with per-track codec, bitrate, depth and channels |
| This device | Audio permission | Music already on the phone or SD card, whole-device or one picked folder — works entirely offline |
| Downloads | None | Everything saved for offline, browsable like any other library — always present |
| Music Assistant | mDNS or URL | Not just a library — a full player ecosystem (see below) |
Capabilities are probed, not assumed. A plain Subsonic server is never offered a lyrics pane it can't fill; the app asks what the server supports and lights up only what it declared.
When a Music Assistant server is in the picture, CAMusic becomes more than a player:
- Browse the full library — artists, albums, tracks, playlists, radio stations and podcasts, plus recently played, recently added, continue listening, for you and favourites shelves, and search-as-you-type with debounced results.
- Play to any MA speaker — the phone, a grouped set, or any other player on the network. Now Playing reflects and controls whichever speaker you select.
- Queue management — view, jump, reorder, remove, clear, shuffle what is still to come, save as playlist, or transfer the queue and playhead to another speaker mid-track.
- Album and artist screens — about section, related albums, top tracks, similar artists.
- Version picker — every copy of a track across every provider. The 16/44 stream, the 24/96 purchase and the CD rip are a choice, not whatever the row came from.
- Cross-device resume — start on the phone, finish at the desk, same second of the same track.
- Per-player parametric EQ — bands with frequency, Q, gain and filter type, tone controls, preamp and presets, driven through MA's own DSP pipeline.
- Server-side gapless and crossfade — the app reads MA's config entries and renders whatever it finds, so a new MA build needs no update here.
- Speaker grouping around a leader, with per-player and group volume.
- Home Assistant TTS announcements arrive like any other MA player, and ask other apps to duck rather than taking the output from them, so an announcement is audible over a video.
CAMusic registers with Music Assistant over the Sendspin WebSocket protocol and stays available in the background through a foreground service, so playback and announcements start without opening the app. It stays in the background even when the app is closed for instant and reliable TTS, unless the battery saving setting in the app is enabled.
The clock-sync engine uses an NTP-style four-point exchange feeding a two-dimensional Kalman filter
that tracks offset and drift — grouped playback stays in step. The player reports state: "error"
and mutes until the filter converges, rather than joining a group with an offset still seconds wide.
FLAC, Opus and PCM are decoded through ExoPlayer, with a custom DataSource that holds each
frame until the Kalman-filtered server clock says it is due, and written to the platform
AudioTrack. 24-bit is opt-in: with bit-perfect on, the decoder is asked for float output and the
track is built from what it actually reports, not from the depth the server claimed — because
two-byte samples in a three-byte frame is noise, not degradation.
ExoPlayer is the engine here, not one of two. It was an experimental toggle until v0.9, on the
reasoning that an unvalidated path should not silently become everyone's player — and hardware
settled it the other way: the hand-built MediaCodec → AudioTrack engine it replaced produces no
audio at all on the test device, and this one does. A switch whose off position is silence is not a
safety measure, so it is gone.
On Oboe: there is a native output engine in
app/src/main/cpp/, reachable from Settings → Music Assistant server → Experimental, and it is off by default and still produces no sound. The AAudio stream opens and starts, and then nothing is ever consumed from it. Three genuine defects have been found and fixed on the way to that one without being it. The evidence, the mechanism and the one measurement that would settle it are in PR #59, which was closed unresolved rather than merged. It is opt-in, it blocks nothing, and the README used to claim audio flowed through it — it does not.
Routing the stream through ExoPlayer is also what put MA playback inside the light-sync analysis tap, so the direct Hue path drives both players rather than only local files.
Per-player sync offset is applied to frame scheduling locally and also written back to MA's config. The player's volume is the phone's media volume: the on-screen slider, the hardware rocker and Music Assistant's own level are one number, not three.
CAMusic drives Philips Hue lights in time with the music using the Hue Entertainment API (DTLS 1.2, 60 Hz). Two transports, picked in Settings → Light Sync — it follows the library backend automatically unless you pin it by hand. A Quick Settings tile toggles the direct path from the notification shade.
No Home Assistant, no integration, nothing between the music and the room. CAMusic connects directly to a Philips Hue Bridge — discover a bridge over mDNS (with cloud discovery and manual IP as fallbacks), press its link button to pair, choose an entertainment area, and the Lights tab becomes a direct control surface.
Underneath: the decoded PCM is tapped out of the player's own render chain, run through an FFT with SuperFlux onset detection and a mel filterbank, and turned into per-light colour by a Kotlin port of syncoV2's effects engine. That goes to the bridge over DTLS 1.2 on a pre-shared key, at 60 Hz — exactly what the Entertainment API expects.
What the port carries:
- Five intensity rungs (Subtle → Extreme) plus Auto — reads the music and moves between them
- A tempo PLL and structure tracker driving the show off the beat grid
- 3D room geometry and spatial waves
- The Fireworks effect
- Per-lamp attack and spectral pop
- Stereo pan — a hit lands on the side of the room it came from
- Room gestures — a sound that travels across the stereo field travels across the lamps
- Vocal shimmer
- An eye-safety limiter
- A timing delay queue so the lights land with the audio
- Album-art and song colour extracted the way syncoV2 does it — by how much of the cover each colour occupies, not by what would look good as a UI accent
- Pre-scanned tracks — the show knows the song's shape from the first bar
The room is rendered as a field, not as a set of independent lamps. Each lamp's continuous drive is blended with its neighbours' through a Gaussian kernel over the entertainment area's real 3-D positions, so a glow spreads across bulbs instead of stopping at one. The kernel is row-stochastic — it redistributes the room's energy and can neither brighten nor dim it — and being purely spatial it has no temporal memory, so it cannot shift the timing. Colour drifts along a tilted axis using height and depth as well as left-to-right, and the height-to-frequency mapping is blended rather than snapped to one of five bands.
A sustain layer carries what the beat-driven layers cannot. Onset detection deliberately gates out narrowband material, and the per-bin attack measure is taken against a baseline a held note settles into — both correct for percussion, and between them they left a long vocal lighting almost nothing. A separate envelope reads sustained, pitched, mid-heavy material (inverted onset width, chroma stability, mid presence), damped against live transients so a beat and a bloom never claim the same moment, and blooms the room slowly rather than pulsing it.
Room gestures (off by default, under Lights → Advanced) are the layer for sound that moves. Stereo pan has always placed a hit on the side of the room it came from; this reads pan over time instead of instantaneously, so a pad sweeping across the stereo field sweeps across the lamps, and a swell that rises with nothing hitting under it lifts the room as it climbs.
How it is drawn depends on the shape your lamps are actually in, classified once per session from the entertainment area's real positions:
| Your room | What a sweep looks like |
|---|---|
| Lamps in a line | A soft front travelling along it |
| Lamps around you | The same front, going round — and round the front of the room, never behind you |
| Lamps scattered | A virtual source moving through the room, brightest at whatever it passes |
| Two lamps on a shelf | Nothing. A swell still blooms them together, but a traversal is refused |
That last row is the important one. Two lamps cannot express "across the room", and flickering
between them reads as a fault rather than as motion — Philips make the same call for their own
AreaEffect: better to show nothing than to show it in the wrong place.
Most music has neither a real stereo traversal nor a linear swell in it, and the detector is built around that rather than in spite of it. It subtracts the broadband pan centroid, so a mix that simply leans left cannot trip it; it requires movement to be monotone and to not come back, so a vibrato is not a sweep; it requires a neighbouring frequency bin to agree, so one bin drifting is not a source moving; and it caps itself at four gestures a minute. A track with nothing to find looks exactly as it did before, which is the correct answer rather than a missed opportunity. Speed comes from the measured length of the sound's own movement, so a slow pad and a fast riser do not look alike, and the whole layer is additive and capped at a third of full scale — a wash, not a flash.
Scope: the tap sits in the ExoPlayer render chain, and since v0.8.8 Music Assistant playback flows through ExoPlayer too — so direct mode drives the show from real audio for both this phone's local playback (Navidrome, Jellyfin, downloads, local files) and MA playback to this phone. Music playing on a remote MA speaker is never decoded here, and since v0.10.5 that case is covered by a scheduled show instead — see below.
The original path. Drives the
syncoV2 hue_music_sync integration over the HA WebSocket
API, which controls Philips Hue lights through the Entertainment API: per-zone enable, intensity ladder, effect, brightness ceiling, timing offset, live tunables,
and all 19 colour schemes previewed with their real gradient colours. It follows an HA
media_player entity, which is what lets it reach speakers this phone is not playing through.
Four additive layers that use CAMusic's existing analysis infrastructure — pre-scanned track data, real-time structure detection, instrument-to-position mapping, and phone motion — to do things no Hue app can. Each is a toggle, off by default, layered on top of the existing music sync rather than replacing it:
- Music DNA — every track gets a deterministic visual fingerprint: tempo → wave speed, key → base hue, intensity profile → brightness arc, section structure → colour shifts. Same song always produces the same show.
- Emotional arc — colour temperature follows the song's structural journey: verses are cool blue, builds warm toward orange, drops go hot and saturated, breakdowns go cold, outros fade warm.
- Phantom stage — instruments mapped to fixed physical positions in the room. Bass left corner, vocals centre, guitar right, drums spread by height. When a solo comes in, that part of the room brightens — you can see who is playing by which part of the room is lit.
- Phone as conductor — the phone's accelerometer and gyroscope become a conductor's baton. Tilt shifts colour through the spatial field, a sharp flick flashes all lights, slow circular motion rotates hue around the room. Phone flat auto-disables after 5 seconds.
All layers implement a shared LightShowLayer interface and run after SyncoEngine.render()
and before FieldSafety. Direct-to-bridge only (the HA path has no analysis tap access).
See docs/creative-light-shows.md for the full design.
The analysis tap reads PCM out of this phone's own render chain, so anything playing anywhere else is silent to it. Two sources cover that, and neither pretends to be the real thing:
- A Music Assistant queue on a remote speaker is driven from the track's own offline analysis instead — the beat grid, the section boundaries and the intensity curve, scheduled against MA's reported playhead. This is what syncoV2 does, and it is a beat-and-structure show, not a spectrum show: the per-band detail is modelled from the track's average balance rather than heard, and stereo gestures are absent because there is no stereo field to read. The status line says "following the beat grid" rather than "reacting to the beat", because those are different things. A speaker offset slider covers the latency out to a cast group, which nothing on this phone can measure.
- Another app on this phone — Spotify, a podcast player, anything — through
MediaProjectionaudio capture, off by default and set up explicitly under Lights → Other apps. Apps may forbid capture, and YouTube and YouTube Music do; the app detects that and says so, because the platform delivers a blocked app as digital silence with no error at all.
Download a track, album or playlist for offline use — audio and cover art — with a storage cap and a Wi-Fi-only option. The storage cap never evicts the track you're listening to.
Downloads is a library of its own, sitting in the same switcher as every server, with the same artists, albums, tiles and search as any of them — it is the one library guaranteed to work with everything switched off, so it gets the same views rather than a flat list. It is always present and cannot be removed. A separate Downloads screen handles what a library has no concept of: bytes on disk, failed jobs worth retrying, and a per-album storage breakdown so the thing worth deleting is findable. It is reachable from Settings and from the library itself.
With the server unreachable the library also drops to Offline automatically and runs on what's on the phone. Stars and play counts write back to the library the track came from when you're back online.
| Feature | Music Assistant path | Standalone path |
|---|---|---|
| Hi-res (88.2 / 96 kHz) | ✅ | ✅ |
| 24-bit float output | ✅ (bit-perfect mode) | ✅ (bit-perfect mode) |
| Gapless | ✅ (server-side) | ✅ (ExoPlayer) |
| Beat-matched crossfade | ✅ (server-side) | ✅ (when scans available) |
| ReplayGain | ✅ (MA normalisation) | ✅ (track / album) |
| Original file format | ✅ (format=raw) | ✅ (original stream) |
| Continuous play / radio | ✅ (server-side) | ✅ (on-device) |
| Smooth transitions | — | ✅ (1–12 s, auto-suppressed for albums) |
| USB DAC detection | ✅ | ✅ |
| EQ | ✅ (server DSP) | Planned |
When a USB DAC is plugged in, CAMusic detects it through an AudioDeviceCallback
and posts a low-priority notification showing what the DAC can do — sample rates,
bit depths — and points to Settings → Playback & audio to pin the output to it. It does
not change routing on its own: if you already chose a Bluetooth headset, plugging
in a DAC doesn't yank the audio away. It just tells you the option is there.
A lot of cars have no Android Auto. The phone sits in a cradle running Google Maps, and changing a track means leaving the map, finding the app, hitting a small target and going back — while driving. This is the one feature in the app that is a safety feature rather than a polish or correctness one, and it is built to that standard: three controls, very large, over whatever is on screen.
Two window mechanisms, chosen in Settings → Playback & audio → Driving:
| Floating window (default) | Full-width bar | |
|---|---|---|
| Permission | None | Draw over other apps |
| Targets | System-sized | 76dp, against the platform's 48dp minimum |
| Position | System decides | Docked to either edge, draggable by its handle |
| Starts from | The app must be in front | Anywhere |
The default costs no permission at all, which for something you set up once in a car park is worth more than the larger buttons. What it costs instead is reach: the platform only allows the transition while this app's own activity is in front, so the floating window appears either as you leave for the map or the moment driving mode turns on with the app already open. The full-width bar has no such constraint — and hides itself whenever CAMusic is the thing on screen, since the in-app player is right there at full size.
What turns it on is the car's Bluetooth, not Google Maps being in front. That framing is the
expensive one: reading the foreground app needs either PACKAGE_USAGE_STATS or an
AccessibilityService — a Settings-screen grant, or the most policy-sensitive permission on the
platform — and the requirement is control music without leaving the map, not know that Maps is
running. Nominate your car from the phone's paired devices and connecting to it is the trigger. A
Quick Settings tile and a Settings switch cover a car with no pairing, an aux cable, or simply
wanting it on.
It never appears with nothing playing, whatever the trigger says. Three controls, a title that truncates rather than scrolls — movement in peripheral vision while driving is the worst possible place for a marquee — and nothing that invites reading.
Optional driving enhancements (all off by default, under Settings → Playback & audio → Driving):
- Speed limit alert — a gentle audible beep when GPS speed exceeds a limit you set, with a configurable tolerance percentage so minor overages don't nag. Uses a 5-consecutive-reading rule to filter GPS noise, and repeats at most every 30 seconds, not every reading. The speed is never shown on screen — reading a number while driving is worse than hearing a tone.
- Auto-pause for phone calls — pauses playback when a call starts ringing and does not auto-resume when it ends (a notification offers to resume instead). Surprising auto-resume after a conversation is worse than a tap.
- Speed-adaptive volume — gradually increases volume at higher speeds to compensate for road noise, with smooth 1-second ramping so the change is never a jump.
What's playing, with previous, play/pause and next, without unlocking or opening anything. Built with Glance, and driven through the same routing as every other surface — so it addresses the speaker you're actually listening to, not always the phone.
- Lyrics — LRC-timed where the provider has them. The sung line sits centred, scrolls smoothly, scales into place, with a 200 ms lead and a trim in Settings for a server whose timings are off.
- Quality badge —
FLAC • 96/24 • 3 Mb/s, channels, file size, and what Music Assistant did to the level. Normalisation is reported, not guessed at. - Similar — acoustically similar tracks, or a natural-language search over sonic embeddings ("late night drive, warm synths").
- Playlists — create, delete, and add tracks on both backends.
- Swipe-to-queue — swipe a track row right to add it to the queue, left to play it next. Both actions snap back after firing, with haptic feedback at the trigger point.
- Listening statistics — a "Your Listening" screen showing the last 7 days: total listening time, most played track, top artists, format breakdown, and server breakdown. Uses the same completion threshold as the scrobbler (half the track or four minutes), so a skip doesn't inflate the stats.
- Settings export / import — every server config, credential, and preference exported as a password-encrypted portable blob. Import re-encrypts credentials under the new device's Keystore, so the file's password-derived encryption never reaches disk.
- Appearance — OLED black, dark or light; accent from the album art, your wallpaper (Material You) or a fixed colour; Now Playing as a tab or as an overlay that slides over the app; a straight or a wave seek bar; and a Motion setting, so the app can be calmed down without turning off animations for the whole phone.
- The cover turns like a page when the album changes — and only then, never between two tracks of the same record.
- Favourites, preview, playback speed, sleep timer.
- Android 12+ (API 31)
- Music Assistant 2.9+ (optional — for the MA player, speaker control, and HA Light Sync path)
- A self-hosted music library — Navidrome, any Subsonic/OpenSubsonic server, or Jellyfin (optional — the app works with local files alone)
- Philips Hue Bridge with an entertainment area (optional — for Hue Entertainment light sync)
- Home Assistant + syncoV2 (optional — for Hue light sync through HA)
- A car stereo paired over Bluetooth (optional — the trigger for driving mode; a Quick Settings tile covers a car without one)
Everything is optional except Android 12+. Start with one server or just local files and add more as you go.
Permissions are asked for at the moment a feature needs them rather than up front, and every one past the first belongs to something optional:
| Permission | Asked for by | Needed because |
|---|---|---|
| Notifications | Onboarding | The media notification and the foreground services |
| Audio files | Adding "This device" | Reading music off the phone through MediaStore |
| Bluetooth | Driving mode | Noticing your nominated car connect |
| Location | Speed limit alert | GPS speed; nothing is sent anywhere |
| Phone state | Auto-pause on calls | Knowing a call is ringing |
| Microphone | Lights → Other apps | What AudioPlaybackCapture is gated on. No microphone is ever opened — the platform has one permission for "may receive audio it did not produce", and this is it |
| Draw over other apps | Driving mode's full-width bar | The overlay window; the default floating window needs none |
Only notifications is asked for by an install that uses nothing optional.
- Install the APK from Releases. Each release
carries two:
camusic-vX.Y.Z-mobile.apkfor phones and tablets, andcamusic-vX.Y.Z-tv.apkfor Android TV / Google TV. Sideload the TV one withadb install; it appears in the TV launcher's apps row. - The onboarding wizard asks where your music lives: Music Assistant, Navidrome, Jellyfin, or local files on the device. Credentials are encrypted at rest with the Android Keystore.
- Optional steps in the same wizard: set up Philips Hue light sync (direct to bridge and/or through Home Assistant), register this phone as a Music Assistant player, and grant the two permissions that decide whether playback survives the screen going off — notifications, and unrestricted battery.
- Everything can be changed later under Settings → Libraries and Settings → Light Sync. The Music Assistant player's own settings — name, stream format, gapless, announcements, and the live status readout — live on that server's card, so the server, its library and the player are configured in one place.
- Optional, and worth two minutes if you drive: Settings → Playback & audio → Driving. Turn it on, pick your car from the phone's paired devices, and the transport appears over the map whenever you connect to it and something is playing.
The TV build is the same app behind D-pad screens: Now Playing, Library, Queue, Light Sync and Settings, with its own first-run wizard. It plays from the same four sources and drives the same direct-to-bridge Hue light show from CAMusic's own playback, and both a library and a Hue bridge can be set up entirely from the remote under Settings.
What is not on TV: lighting up to other apps' audio (the MediaProjection capture path is not
part of the TV build, and the permission behind it is stripped from its manifest), Home Assistant
as a light-sync transport, and the Downloads, DSP, Stats and Speakers screens. Driving mode, the
call-pause and the speed alert are absent for the obvious reason — a television does not move,
take calls, or ride in a car — so the TV install asks for none of the permissions those need.
Jetpack Compose UI · Material 3 · OLED design system (true black, album accent)
Now Playing · Library · Speakers · Lights · Settings
|
ViewModels (per screen; player + library VMs hoisted to the app root)
state collected lifecycle-aware, so a backgrounded screen stops working
|
+-- Sendspin protocol -- SendspinClient · ClockSync · Kalman filter
| (this phone as an MA player) -> SendspinExoEngine -> AudioTrack
|
+-- MA main API -------- MaApiClient (/ws) · MaRepository
| (browse, search, transport, queues, grouping, DSP, player config)
|
+-- OpenSubsonic ------- SubsonicClient · LocalPlayer (ExoPlayer)
| LocalRadio · DownloadManager
|
+-- Jellyfin ----------- JellyfinClient · JellyfinSource
|
+-- Downloads ---------- DownloadManager · DownloadsSource · DownloadsIndex
| (offline copies, as a library)
|
+-- Hue Bridge --------- HueBridgeClient · HueDtlsClient · SyncoEngine
| (Philips Hue Entertainment AudioAnalysisTap · TrackScanner · AlbumColours
| direct to bridge) LightSyncFeedPicker picks one of four feeds:
| local PCM · Sendspin PCM ·
| ScanFrameSource (remote speaker) ·
| PlaybackCapture (another app)
|
+-- Home Assistant ----- HaClient · LightSyncRepository
(Philips Hue via syncoV2)
SendspinConnectionService (foreground) keeps the process and socket alive
SendspinService / LocalPlaybackService own the media notifications
One detail worth knowing: control JSON and binary audio share a single WebSocket, and
stream/clear only means something if it is still ordered against the audio around it. Everything
the socket produces therefore goes through one queue drained by one consumer, rather than
separate flows.
Colour comes from the artwork, twice, for two different jobs. The UI clusters covers in CIELAB and ranks by vividness against size, because an accent has to stay legible on black. Light Sync clusters the same cover and ranks purely by how much of it each colour occupies, because there the weights are dwell time in a room. Sharing one extraction between them was a bug, not a saving.
Music Assistant path Navidrome / offline path
-------------------- ------------------------
WebSocket binary frame HTTP (static/original) or local file
[type=4][server_ts_us][payload] |
| |
SendspinDataSource |
(blocks until the frame is due, |
per the Kalman-filtered clock) |
| |
ExoPlayer ------------------------------- ExoPlayer
| (gapless, exact seek, ReplayGain,
AudioTrack radio, smooth transitions,
(OboeAudioSink is opt-in 24-bit float output)
and currently silent) |
| |
+--------- AudioAnalysisTap ------------------+
|
| ScanFrameSource ...... a remote MA speaker: no PCM,
| (TrackScan + playhead) frames built from the analysis
|
| PlaybackCapture ...... another app, via MediaProjection
| (AudioRecord) (its own tap; excludes our own uid)
|
Philips Hue Entertainment
Both paths run through ExoPlayer, which is what lets one analysis tap serve both. Before that the
MA path was a hand-built MediaCodec → AudioTrack pipeline the tap could not see, and direct
light sync was local-playback-only as a result.
Formats are advertised, not requested: MA may only send something the client listed, so the list is the setting. 48 and 44.1 kHz are always offered; 88.2/96 kHz with hi-res on; 176.4/192 kHz only with bit-perfect on and only after probing that the device will open a track at that rate.
The native output engine in app/src/main/cpp/ is compiled and callable — it is what
OboeAudioSink drives — but it is opt-in, off, and does not currently produce sound; see the
note above and PR #59. What ships today is the
platform AudioTrack. True bit-perfect exclusive-mode output that bypasses the Android mixer is a
later phase again.
CAMusic started as a Music Assistant Sendspin client. The idea has grown: one app that replaces the two or three you're using right now — a player, a speaker controller, and a light-sync engine, all local, all free. Here's where it's heading.
- Music Assistant Sendspin player (FLAC, Opus, PCM)
- Clock-synced grouped playback (Kalman filter, NTP-style four-point exchange)
- MA library browser, search, queue, speaker control, playlists
- Navidrome, Subsonic/OpenSubsonic, and Jellyfin as standalone libraries
- Local files on device (MediaStore source)
- Multiple library servers — add, switch, manage independently
- Offline downloads with storage cap and Wi-Fi-only option
- Room-backed download index (migrated from legacy JSON on first boot)
- Continuous play / radio on the standalone path (on-device, with fallback ladder)
- Smooth transitions on the standalone path (1–12 s, auto-suppressed for albums)
- 24-bit output on both paths (bit-perfect mode)
- ReplayGain on the standalone path (track / album, boost capped at +3 dB)
- Direct Philips Hue Bridge light sync (no HA needed — DTLS 1.2, 60 Hz)
- Home Assistant light sync via syncoV2 (Philips Hue Entertainment API)
- Auto intensity, tempo tracking, stereo pan, vocal shimmer, Fireworks effect
- Track pre-scanning (the show knows the song's shape from the first bar)
- Album-art colour extraction (occupancy-weighted, not UI-accent guessing)
- Per-player parametric EQ through MA's server-side DSP
- Speaker grouping with per-player and group volume
- LRC-timed lyrics with smooth scroll and timing offset
- Quality badge and detail card (
FLAC • 96/24 • 3 Mb/s) - Version picker — every copy of a track across every provider
- Cross-device resume
- Onboarding wizard (first-launch: pick server, optional Light Sync, optional MA player, permissions with a reason)
- Adaptive grid layout for tablets and foldables
- Direct Philips Hue light sync from Music Assistant playback (v0.8.8 — MA audio now flows through ExoPlayer, so the analysis tap sees it)
- Sendspin player on ExoPlayer (v0.8.8; the Oboe output behind it is opt-in and still silent — see Next up)
- Light sync as a spatial field — neighbouring lamps share a drive, colour drifts in three dimensions, height blends between bands (v0.8.8)
- Sustain layer — held vocals bloom the room instead of lighting nothing (v0.8.8)
- Hardware volume keys follow whichever player is actually making the sound (v0.8.8)
- Radio stations and podcasts in the Music Assistant library (v0.8.8)
- Warm reconnect —
client/goodbyewithreason: "restart", so a quick app switch doesn't drop the phone from the speaker list
v0.9:
- Music Assistant playback confirmed on hardware — and five bugs found in the same session
and fixed: audio focus deafness, a pause that played 20 s on, two connections racing over one
client_id, direct light sync not noticing MA, and the idle light show lost for MA - Driving mode — a slim always-reachable transport over the map, with Picture-in-Picture as the permission-free default and a full-width overlay behind it, triggered by the car's Bluetooth
- Glance home-screen widget
- ExoPlayer promoted from experimental to the only Music Assistant engine
- The now-playing cover flies between the mini bar and the full player
- Album colour accuracy — accents keep the cover's own lightness instead of being pinned to one perceptual value, and white can no longer outvote a colour it is a minority of
- Wide colour gamut on displays that have one
- One authority on which player owns the audio output, and internal audio-focus arbitration so
the app's two players stop evicting each other through
AudioManager - Instrumented tests — the MediaSession id collision and player identity surviving a rename
- "Keep the music going" on Jellyfin, via its own Instant Mix
- Favourites from the player on Navidrome and Jellyfin
- Light Sync starts when you switch it on, rather than when something happens to play
- Room gestures — a sound that travels across the stereo field travels across the lamps, and a swell with no beat under it lifts the room as it rises. The room is classified by shape (line / ring / field / cluster) and a cluster is refused rather than faked. Off by default, confirmed on a Galaxy S23 (v0.9.1)
- Player status moved onto its own server's page; the empty top-level player section removed
- The three largest files split by responsibility (1,457 → 596 and 2,121 → 1,703)
v0.10:
- Dead code removal —
SendspinAudioEngine(1,035 lines, unselected since v0.8.8) removed - Jellyfin scrobble fix — position now tracked from the player, not wall clock, so pauses don't drift the scrobbled position
- Beat-matched crossfade — transitions align to the beat grid of both tracks when pre-scan data is available, falling back to fixed-duration smooth transitions otherwise
- USB DAC detection —
AudioDeviceCallbacknotices a USB DAC connect, posts a notification with its capabilities, and points to Settings to pin the output - Swipe-to-queue — swipe right on a track row to add to queue, left to play next, with haptic feedback and snap-back
- Listening statistics — a "Your Listening" screen with 7-day stats: total listening time,
most played track, top artists, format and server breakdown, backed by a Room
play_historytable - Settings export / import — password-encrypted portable JSON blob with all servers, credentials, and preferences; re-encrypts credentials under the new device's Keystore on import
- Speed limit alert — optional GPS-speed beep with configurable tolerance percentage, 5-consecutive-reading noise filter, and 30-second repeat interval
- Auto-pause on phone call — pauses on
CALL_STATE_RINGING, does not auto-resume (offers a notification instead) - Speed-adaptive volume — gradually increases volume at higher speeds with smooth ramping
- Creative light-show layers — four additive Hue layers using CAMusic's analysis infrastructure: Music DNA (deterministic visual fingerprint per track), Emotional Arc (colour temperature follows song structure), Phantom Stage (instruments mapped to physical positions), Phone as Conductor (motion-driven lighting). PR #71
v0.10.5 (PR #89 — fifteen items):
- Announcements that finish, and that can be heard over a video —
stream/endis byte-identical for "paused", "track ended" and "announcement finished", and the engine discarded the tail for all three. A discriminator tells them apart from a locally-issued pause and MA's own player events; announcements now request ducking focus with speech attributes rather than fighting for the output - Downloads as a first-class library — artists, albums and tiles in the same switcher as every server, replacing a flat list with its own rendering branch
- Local device files actually work — six separate defects, any one of which emptied the library, including a folder list written as JSON and read as pipe-separated, and a reachability test comparing SAF tree URIs against MediaStore URIs
- A light show for a remote Music Assistant speaker — scheduled from the track's offline analysis, with an honest status line and a speaker-offset control
- Lights from other apps —
MediaProjectioncapture, with detection of apps that forbid it - Album colours for the room — k-means++ seeding, a most-chromatic-quarter cluster representative and a neutral-swatch cap backported from the UI palette, which had them and the light-sync extraction did not
- Artist pages paint on the first round-trip again; the wave seek bar's track moves with the wave; sliders stop snapping back on release and claim a 44dp touch target; the driving bar docks to the edge, its drag and its close button work, and the floating window reads the setting that selects it; in-place queue shuffle; the cover turns like a page on an album change; a Motion setting; and the stats screen clears the mini player
- 16 KB page alignment — the one native library this project builds was 4 KB-aligned, so Android loaded the app in a compatibility mode and said so on first launch
v0.10.6 (PR #90):
- Analyser v3 — a key detector that can actually hear.
TrackScan.keyis where the Music DNA layer takes its entire anchor hue from, and measured against real music the old one agreed with itself on a transposed copy of the same track 12.4 % of the time, against a 1-in-12 chance floor. Three faults, each found by measuring rather than reading: a chroma that could not resolve a semitone below ~186 Hz, an assumption of A440 against a corpus sitting 4–7 cents sharp, and third harmonics being heard as tonics. Now 71.1 % shift consistency and 90.9 % detune stability - An analysis harness (
tools/analysis-harness/) that runs the real scan path over real music and scores it on two measures needing no ground truth — transpose invariance and detune stability. Not in CI; the corpus is somebody's music library - The light-show layers stop littering — roughly 240 maps and 2,400 short-lived arrays a second removed from the one thread in the app with a hard deadline
- On-device verification of the newest work. The playback chain is confirmed on hardware. What is not: the driving bar's window behaviour, the MediaProjection consent and foreground-service ordering (which has no compile-time signal if it is wrong), the announcement drain caps, the remote-speaker show's timing against a real cast group, and Android Auto end to end (browse tree, search, and — the one that matters most — that a track tapped in the car never starts playing on a speaker in another room). Several of these can only be judged by eye, one only in a car, and the speed alert only on a road.
- True overlapping crossfade on the standalone path — a second ExoPlayer ping-ponged with volume ramps, moving queue ownership and touching ReplayGain, the notification, and the analysis tap. The shipped smooth transitions and beat-matched crossfade are the first two halves; this is the third.
- Android Auto — built, not yet verified in a car. A
CarMediaLibraryServicebrowse tree and a session facade (CarSessionPlayer, in the same spirit asSendspinService'sShadePlayer) sit beside the two existing media services rather than replacing either. Browses every configured library server; a track tapped in the car always plays on this phone, never a remote MA speaker, whichever one the Speakers screen has selected — and for a Music Assistant track it moves the Speakers selection here too, so the car's own now-playing and transport buttons address the player it just started rather than the one in the kitchen. Untested beyond compilation — wants a Desktop Head Unit pass and, ideally, a real head unit. - The Oboe silence — PR #59, closed unresolved. Blocks nothing; the path is opt-in and off.
- How often a room gesture actually fires. The detector shipped in v0.9.1 and is
deliberately strict; what is unmeasured is how much real music contains a traversal it should
find. Every detection is logged whether or not the lights move, so an album and
adb logcat -s DirectLightSyncis what answers it.
- More library providers — each is an adapter against the
MusicSourceinterface, not a change to the app:- Emby (near-identical API to Jellyfin)
- Plex (plex.tv PIN flow — no server password ever typed in the app)
- Audiobookshelf (music libraries alongside audiobooks)
- Kodi (JSON-RPC library browser)
- Network filesystems — SMB, WebDAV, Google Drive, OneDrive, Dropbox, Box, pCloud. These
need a crawler, a tag reader and a local index.
MediaStoreproves the shared shape;IndexedFileSourceis the interface they'll plug into. - Bit-perfect exclusive-mode output — the native AAudio I24 path is written and
deliberately not compiled.
flac_decode()is still a skeleton and the ring buffer is byte-level rather than frame-level. The largest single audio item on the roadmap. - Wider instrumented coverage — 810 unit tests cover protocol, clock, DSP, parsing, the server list, the Philips Hue Entertainment sync engine, speed alert logic, beat-matched crossfade, the creative light-show layers, the announcement drain policy, the downloads index and the SAF-to-MediaStore folder mapping; two instrumented test classes pin the two regressions that each cost a release. Nothing yet covers the audio path end to end, the service lifecycle, or the UI — and nothing can cover the MediaProjection consent sequence without a device and a human tapping a dialog.
- A hosted crash backend — crashes are stored locally and, with a token configured, filed automatically as a GitHub issue. What is missing is somewhere to send them that isn't a repository, and reporting more than the most recent one per launch.
On signing: releases are signed with the local debug key, deliberately. It has been stable since v0.1.0, so updates install over each other cleanly. Because that key lives on one machine and a CI runner generates a fresh one per run, releases are cut locally — see the header of
.github/workflows/release.yml.
Written down because the alternative is discovering them by ear:
- The playback chain is confirmed on hardware; the newest features are not. A release build ran on a Galaxy S23 and Music Assistant playback produced audio, which cleared the whole of the stabilisation work in one run and turned up five further bugs in the same session, all fixed. What shipped in v0.10.5 went out without an equivalent pass: the driving bar's window behaviour, the MediaProjection consent sequence, the announcement drain caps and the remote-speaker show's timing all want a device. This is the honest gap between "the tests pass" and "it works".
- Driving mode's Picture-in-Picture path has not been tried against Google Maps in navigation
mode. Android 12+ lets an app call
setHideOverlayWindows(true)to suppress non-system overlays over itself. Whether Maps does is assumed not. If it does, the full-width overlay becomes the default instead — the code for that is already there; it is a one-line change to which mechanism leads. - The native Oboe output path is silent. Off by default, opt-in, and clearly labelled as such in Settings. See PR #59.
- A remote speaker gets a scheduled show, not a reactive one. The tap reads this phone's own render chain, which covers local playback and Music Assistant playing to this phone. Audio on another speaker never reaches this decoder, so since v0.10.5 that case is driven from the track's offline analysis instead: beats and structure are real, per-band detail is modelled from the track's average balance, and stereo gestures are absent entirely. It also needs the track to be matchable in a library this phone can read, and its timing is only as good as MA's reported playhead — which for a remote speaker the server often does not recompute between polls. Expect to use the speaker-offset slider. The Home Assistant path remains the alternative.
- Most video apps forbid audio capture, and the big ones do. YouTube and YouTube Music set
allowAudioPlaybackCapture="false", and an app that has is delivered to the capture as bit-exact silence with no error of any kind. CAMusic detects that and says so rather than looking broken, but it cannot work around it. Spotify, podcast apps and local players generally do allow it. - Capture consent cannot be remembered. On Android 14+ a
MediaProjectiongrant is single-use, so the system dialog appears every time capture starts. That is a platform rule, not an oversight. - An announcement's tail is a judgement, not a fact. The protocol sends the same message for "paused", "track ended" and "announcement finished". The discriminator is right in the cases it can see; where it cannot, it assumes music, and the fallback caps (400 ms for a track boundary, 5 s for an announcement) are reasoned rather than measured.
- Cleartext is allowed to LAN addresses only. A server reached over plain HTTP on a public hostname is refused rather than sent credentials in the clear — use HTTPS for anything off the local network.
- Audiobook chapters are unverified. Podcasts and radio browse against confirmed Music Assistant commands; the audiobook chapter listing follows the same naming pattern but has not been checked against a live server, so an audiobook may open empty.
- One streaming app per Hue entertainment area. If the Hue app or an HDMI Sync Box already holds the area, CAMusic refuses rather than taking it — the Entertainment API allows only one.
- Room gestures do not reach Extreme or Fireworks. Both are separate renderers that return before the layer the gesture joins. Correct rather than missing — Extreme is a different show, not a louder one — but it means turning the setting on and selecting Extreme looks like the feature is broken.
- "Bottom to top" will not render in most rooms. The Hue app places lamps on a two-dimensional
floor plan and its own API example reports
z: 0.0for every channel, so most entertainment areas have no height for a rising gesture to travel through. A swell in a flat room lifts every lamp together instead, which is the honest answer — sweeping "upward" across lamps that are all at the same height would pick an arbitrary order and read as a flicker. - How often a real traversal occurs is still unmeasured. The detector is deliberately strict and
every detection is logged whether or not the lights move, so playing an album with
adb logcat -s DirectLightSyncis what answers it. If the honest answer turns out to be "rare outside electronic and film music", that is a good outcome for a feature whose hard requirement is not firing when there is nothing to find.
JDK 17 or newer and the Android SDK (compileSdk 37, targetSdk 36, minSdk 31). The NDK and CMake
are required — the native Oboe output engine in app/src/main/cpp/ is part of the build even
though the path it feeds is off by default. (The README said "no NDK needed" for several releases
after that stopped being true.)
:app has a platform flavor dimension — mobile (phone/tablet) and tv (Android TV).
Both compile the same app/src/main; app/src/tv adds a manifest overlay and its own Compose
screens. Task names carry the flavor, so lintDebug and installRelease no longer exist on
their own — use lintMobileDebug, installMobileRelease, and so on. mobile is the default
variant, and it is what every release up to and including v0.10.6 shipped as.
./gradlew assembleMobileRelease # app/build/outputs/apk/mobile/release/app-mobile-release.apk
./gradlew assembleTvRelease # app/build/outputs/apk/tv/release/app-tv-release.apk
./gradlew :app:testMobileDebugUnitTest # 810 unit tests
./gradlew :app:testTvDebugUnitTest # the same tests, compiled against the tv variant
./gradlew :app:lintMobileDebug :app:lintTvDebugCI runs exactly those four tasks plus assembleMobileDebug assembleTvDebug — run that set
before pushing rather than a superset. assembleRelease looks stricter and is not:
its lintVital only reports issues marked fatal, so it will happily pass a lintMobileDebug
error. The two test…UnitTest tasks run the same app/src/test sources against each variant;
running only one lets a flavor-specific compile break through.
./gradlew :app:connectedMobileDebugAndroidTest # instrumented tests; needs a deviceJudge anything about how the app feels on a release build. A debug build carries Compose
composition tracing, skips R8, and runs debuggable, which suppresses most of ART's optimisation
— scroll performance measured on one is measuring the build, not the app.
./gradlew :app:installMobileRelease -PsideBySide # installs alongside, own empty data
./gradlew :app:installTvRelease # sideload the TV build onto a box/emulator
./gradlew :app:generateMobileBaselineProfile # needs a device; WIPES app data
./gradlew :app:compileMobileReleaseKotlin -PcomposeMetrics # skippability reportsThe baseline profile is generated against mobile on purpose: baselineprofile/'s
LibraryScrollProfile drives phone UI journeys, and the checked-in profile lives in the
release build-type source set (app/src/release/generated/), so both flavors' release
builds already consume it.
Releases are cut locally. See the header of
.github/workflows/release.yml.
- Release notes — what changed in each release, and why (through v0.9.3)
- v0.10.6 — the most recent work: what was wrong with the key detector, how it was measured, and the two attempts that were wrong before the one that worked
- Analysis harness — measuring the offline analyser against real music on two scores that need no ground truth. Not in CI
- v0.10 plan — correctness fixes, new features, and driving enhancements
- Creative light shows — the four additive Hue layers: music DNA, emotional arc, phantom stage, and phone-as-conductor
- v0.9 plan and what's left of it — the previous plan, and the working queue that re-verified it against the source. The second is worth reading for the parts that turned out wrong: it opens with five load-bearing premises of the plan that did not survive being checked, and later records three of its own that did not survive being built.
- Oboe investigation (PR #59, closed unresolved) — why the native output path is silent, what has been ruled out, and the one measurement that would settle it
- Spatial swell design and implementation — lights that move when the sound does. The first is the case for the feature; the second is how it was built, and both are worth reading for what they got wrong: the implementation doc opens with five claims of the design doc that reading the code disproved, and closes with four more that only survived until the tests ran — including a circle fit that was quietly shrinking every fitted radius, and two detector conditions that turned out to be missing entirely
- v0.8 plan — the previous plan, and what shipped against it
- Direct Hue plan and gap analysis — the direct Light Sync port, and what it was measured against
- v0.5.0 analysis — the full codebase analysis the roadmap grew out of
- Improvement roadmap — the API audit, and a Corrections section recording where earlier notes were wrong
- Providers — the
MusicSourceadapter recipe, and the auth and endpoints every planned provider needs - Protocol alignment — what MA's Sendspin provider actually speaks
- Architecture decision
- Design brief
- Track prescan — what an offline scan knows about a track, and what the show does with it
See CONTRIBUTING.md. PRs against master, conventional commits, Android Studio
for builds. The project uses Kotlin + Jetpack Compose + media3 + ExoPlayer.
Note on signing: Releases are signed with the Android debug keystore so they can be
installed without a dedicated release key. This means updating from one release to the next
requires the same signer — installing a GitHub release over a locally-built debug APK, or
vice versa, will show "package conflicts with an existing package". Uninstall the old build
first, or use the -PsideBySide Gradle flag to install a test copy with its own data directory.
The Sendspin client and audio engine are modelled on MA and Navidrome APIs. The Philips Hue Entertainment sync effects engine is a port of syncoV2. Fully compatible with the Philips Hue Entertainment API and specs.
Built by Cyborg Automation AU — cyborgautomation.com.au
MIT — see LICENSE.





