Skip to content

Releases: sonn-audio/core

v4.0.0-beta.21

v4.0.0-beta.21 Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 11 Sep 14:31

48 commits since beta.20, 22 of them fixes. One breaking change, and it is the one to read first.

Spotify, on Soloist alone

librespot is gone, and Soloist is the only client — in two roles rather than one daemon doing both jobs.

A room is a Connect device: one daemon per zone, advertising, never signed in from here. Whoever picks the room in their own Spotify app is the one who signs it in, so a room belongs to whoever took it last and there is nothing to pair from this side.

A room playing this server's own queue is one soloist --single-track run per track, started from the store of the account the track came from. The end of a track is a process exiting rather than something inferred from events that mean two things — which is what #360, #352 and the autoplay silencing were all working around, and all of that inference is gone. previous works again, and playing from a second account is nothing more than starting from a second directory.

Measured: a run is playing 330-430 ms after being asked and its first byte reaches the card at 700-900 ms, so a track boundary costs about 0,6-0,75 s more than a play on a warm daemon did. Silence at a skip is ~930 ms; at a natural end ~1836 ms, of which the last 700-900 ms is the engine winding down while the room plays out its buffer.

What to do when upgrading. A Soloist API key is now required for any Spotify at all — without one, no room appears in the Spotify app and no Spotify track plays. Existing per-room pairings keep a room pickable but no longer play anything of ours: playback restores a session per account, and each account is signed in once from the Accounts screen. There is no migration, because a room's store can hold more than one account with nothing to say which of them would be restored. Spotify is also no longer crossfaded, which it could not be in the first place: a blend needs two streams at once and an account plays in one place at a time.

Three more things on this backend. Loudness normalisation is a setting now instead of hardcoded on — default unchanged, because it is Spotify's own, but it is the one thing in this path that multiplies every sample (measured at 1.35 dB on one track), and on a lossless backend that belongs to whoever set the server up. A room's daemon no longer caches without limit; it was Soloist's own default, and a year of listening is a year of audio on whatever disk this server sits on. And the Spotify app no longer shows every idle room at 100% (#372): the level reaches any signed-in daemon and is read off the zone at login. That was measured rather than assumed — Soloist applies no volume itself, it hands the number to the sound server, which here is this process; the number is a label, never a taper, and a lossless stream stays bit-for-bit what Spotify sent.

A private playlist is no longer an empty one (#365). Playlist tracks are fetched pathfinder-first, on a bearer scraped anonymously from the public web player — so it cannot be someone, and every playlist the account keeps private came back as NotFound. That fell through as an empty-but-truthy page, so the Web API fallback, which holds the account's own token and can read those playlists perfectly well, never ran. The owner saw "no items could be loaded", and playing it reported "Playback unavailable".

Alerts and power

The volume fix shipped in beta.20 and the reporter still heard the outgoing track blast (#359). The whole thing hung on getLatencyMs(), and on most outputs that is not a measurement: Squeezelite and Snapcast return an offset an operator typed in, empty unless somebody filled it, and Sonos, DLNA, Cast and Music Assistant do not implement it at all. Only sendspin answers truthfully, so everywhere else the computed lead came out at zero and the level went up the moment the source switched.

The question is asked three ways now. Outputs that report their own playback position answer it from the other end — the gap between where they say they are and where the server's clock is, is the audio still in flight to that room. Squeezelite can answer too and never did; SlimProto's own heartbeat carries the client's elapsed time. And an output that says nothing either way is assumed to be holding a second, not nothing: reading silence as "instantaneous" is the one assumption there is no recovering from.

A relay that changed its mind mid-switch is put right, which is the tail of #293 that kept coming back. Switching an amp takes real time, and every alert flips a zone twice inside that window — stopped for the announcement, started again by the restore — so the "on" regularly lands while the "off" is still executing. A switch that completed after the zone had changed its mind was not written down, and the equality guards then suppressed the very command that would have corrected it. The room went on playing into a dead amp until something else moved the bookkeeping, and in the other direction a relay stayed energised after an alert.

A replaced alert sound is measured again rather than remembered (#375). The duration cache was keyed on the path alone, so replacing the 3 s bell with a 30 s clip left the old length in place for the life of the process: the new clip was cut off after three seconds, and reverting held the zone open long after the sound ended.

Alerts can also say when a speaking style is set on a model that drops it (#342). instructions was already sent, but OpenAI's own tts-1 and tts-1-hd ignore it — and tts-1 is exactly what an empty model field falls back to. The request succeeds, the announcement plays, and only the tone is missing.

Radio

TuneIn presets filed in folders reach the room (#362). TuneIn hands an account's presets back in three layouts and only the flat one was parsed, so an account using either of the others arrived with no stations — and an empty parse quietly became the demo list, which made a working username look exactly like a wrong one. A typo now reports as not found, where an unknown name answers 200 with an empty body and was indistinguishable from an empty account.

A playlist that only points at the stream is followed (#368). A station whose url is a .m3u never played: the file holds one line naming the real stream, and ffmpeg's only m3u reader wants an HLS manifest, so a pointer came back as "Invalid data found". Pointers are followed here now, up to three hops, and a .pls lists mirrors, so a dead first entry is tried past rather than reported as a dead station.

YouTube Music

A dead cookie stops looking like an empty library (#364). An expired cookie does not fail — YouTube answers 200 with a well-formed body carrying a "Sign in" prompt where the library should be, so the only trace was one log line per section and six empty folders. That prompt is the signal now, the verdict is kept per account, and a cookie is checked as it is saved and when an account is registered, so an expiry reaches you instead of waiting to be discovered. The setup wizard gives the private-window recipe that stops the expiry happening at all, since YouTube rotates account cookies on open tabs. The PO Token provider is wired as well — a different mechanism, renewing no cookies, but it is what web_music needs before YouTube hands out any format url, and that is the client which serves a Premium account its 256k stream.

A library longer than one page is not the whole library (#369). A section answers with 25 items and a continuation token for the rest; we asked once and reported that page's length as totalitems, so both the Loxone app and the web player were correctly told they had already seen everything. Continuations are walked now — in either dialect YouTube is currently straddling — for library albums, artists and playlists, and for album tracks, which truncated the same way past 25.

The library in the Loxone app

A library of thousands arrives in one piece (#347). The scanstatus fix in beta.20 was not the whole story: the reporter measured 350 of 470 albums, 200 of 1152 artists, 150 of 6293 tracks, always a whole number of 50-row pages and never the same number twice. Reading the app's own code says why. Every answer becomes a chunk, chunks arriving while the table is still drawing are parked in a queue the app drains newest-first, and the chunk holding the end of the list latches isFinished — after which every chunk still waiting is dropped. Fifty rows at a time out of six thousand guarantees that backlog, because a page comes off SQLite in about a millisecond and no table draws that fast. A folder now goes out in one answer.

Grouping

Two zones of the same type that cannot sync themselves now get fed. Whether the server had to pipe the leader's audio to a member was decided by comparing protocol names, and that answers the wrong question: what matters is not whether the protocols differ but whether anything is going to feed that member. Four output types have no group coordinator at all — DLNA, Chromecast, Music Assistant, sendspin-cast — so two DLNA zones in one group fell through both nets. The member mirrored the leader's state, showed the right track, and played nothing. Nothing to do with the mixed-groups toggle; grouping simply did not work there.

A squeezelite zone only syncs where slimproto can. Grouping in squeezelite is leader-centric, and the only test was members.length >= 2 — so a group led by a Sonos zone was treated as a slimproto sync group with a leader that has no slimproto player behind it, and nobody orchestrated it at all.

AirPlay

A phone can reach the zone it can see again (#363). The receiver moved to node-airplay, and the advertisement that replaced the native one let bonjour-service default the SRV target to this machine's own hostname — a bare label outside .local, so a sender drops the address records that arrived beside it and asks unicast DNS instead. The shipped compose file names the cont...

Read more

v4.0.0-beta.20

v4.0.0-beta.20 Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 30 Aug 08:18

51 commits since beta.19, and 34 of them are fixes. This is a repair round on top of one new thing: AirPlay, rebuilt.

AirPlay 2

Apple receivers on OS 27(beta) accept a legacy RAOP session, report everything as healthy, and render nothing — so a HomePod zone had no working output at all (#324). AirPlay now speaks AirPlay 2: a PTP grandmaster, encrypted audio, metadata and cover art over DMAP, and synced groups that line up with AirPlay 1 zones because both express the same instant.

It is written in TypeScript. The native sender is gone, and with it a vendored C tree and a native addon; the receiver's RSA key travels with the library, so an AirPlay input no longer depends on a file outside version control.

Three things came out of testing on real devices. A receiver that advertises PTP and then ignores the clock is switched to the NTP lane on what it answers rather than what it advertises, per device — Apple receivers need PTP, a BeoSound Shape needs the opposite. A zone plays on the first connection after a restart, where a sender's FLUSH used to be read as a pause and left the zone silent until the sender gave up. And an AirPlay zone now shows track position and length, with each track marked as its own so the Loxone app stops running one progress clock straight through the whole session.

Every zone also advertises a unique AirPlay identifier now. Two rooms could share one, and a sender that selects by identifier landed in the wrong room (#356).

Playback

A track plays to its end. The engine ended its DSP stage on the decoder's exit rather than on its output draining, which raced the kernel and lost — every track was cut five seconds short, and the gap that followed read as a stop, so the next track started as a cold one and reset the zone to its default volume. One cause behind both halves of #322.

A track whose length nothing could state no longer stops at exactly two minutes: that was a placeholder standing in for a real lookup, most visible on favourites, which store no length at all (#350).

The zone clock no longer counts the amp's wake-up silence, which made every alert stop early by exactly the configured pre-delay, and a source a room has already left can no longer stop what took its place — a librespot session reporting its own death 0.6 s late was cutting announcements in half (#293). A doorbell now rings at the right level in every room at once, instead of blasting the outgoing music at bell volume first (#359).

DLNA

Three separate faults behind one silent renderer. A renderer's own first fetch is what counts as playing now, rather than its answer to Play — a HAMA DIR-3100 answers SetAVTransportURI slower than our window, so Play landed on a transport that was never armed (#343). The mid-playback title refresh that beta 18 added stops that same renderer dead, so it no longer goes out to a device that is already playing. And a control point's volume and seek reach the zone at last, where both were parsed, answered OK, and dropped a layer deeper (#339).

The volume oscillation on a Bose SoundTouch is fixed on three counts: the anti-feedback guard now remembers more than the last value it sent, a replaced output is fully disposed instead of surviving as a zombie subscription, and "reset volume on pause" skips a default that resolves to silence (#358).

Radio and library

A live stream is served whole again instead of in stitched 4 MB windows, which is what caused the TuneIn dropouts introduced in beta.19 (#349). An apostrophe no longer truncates track info — StreamTitle='Yazoo - Don't Go'; ends at the real terminator, not at the first quote (#348). And the Loxone app shows every album in a library rather than the first 200: the app reads audio/cfg/scanstatus before it trusts anything, and we answered it with a shape it could not read (#347).

Spotify

The volume slider in the Spotify app reaches the room on the Soloist backend (#344). A zone that becomes the active device again at a track boundary is re-adopted instead of freezing at end-of-track (#352, thanks @tobsch). Soloist's audio no longer leaves for the host's own speakers on a machine running PipeWire. And browsing no longer asks librespot for a token that takes the process down with it.

Elsewhere

Power management works again on the beta.19 image, which moved to trixie and its libgpiod v2 gpioset dialect (#354). Crossfade can be saved (#346). Sonos has something to resume after pausing our own stream (#345). Alerts can speak through any OpenAI-compatible TTS server — OpenAI itself, LiteLLM, Kokoro-FastAPI, AllTalk (#342). A sound quality pinned in a zone's settings actually reaches the output that reads it, where all four renderer outputs were being handed a config that never contained one. The Loxone app can cache cover art again. And debug logging no longer stalls the event loop: the log buffer was copying a megabyte per line, which was audible as stutter.

Under the hood

Content providers answer to a contract instead of an instanceof cascade with a branch per service — the last of the shape left over from when this server was only ever a Loxone Audio Server. The Loxone payloads were diffed byte for byte against the shipped client to make sure nothing moved while that happened.

v4.0.0-beta.19

v4.0.0-beta.19 Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 15 Aug 12:30

sonn core 4.0.0-beta.19

151 commits since beta.18. The heart of this round: a second Spotify backend, a DSP chain of our own in float, and Bluetooth as a first-class input.

Spotify
Soloist as a second backend (experimental). Spotify's own headless client, switchable per server. librespot stays for now as the primary path. you can switch in the adminui.

Audio
Our own DSP on a float bus between decode and encode. Dither where samples lose width, headroom for an equalizer that boosts, and a pinned resampler rate.

No more needless re-encoding. Endpoints that can take lossless get it untouched. The library records each file's audio format at scan time, so a track that already matches skips the resampler.

Bluetooth and Sonn Clients
A phone playing in a room is now an input like any other, with its own timeline and cover art. A speaker announces itself to every server and is claimed by one; each device has a log in the admin screen, there is a single published client version, and speakers update alongside everything else.

Beoremote: the room claims the remote rather than the remote claiming the room, keys are kernel key codes now, and the Essence is supported.

API and metering
Every output has a PCM feed for the meters without touching its audio, with a timeline and a clock to read it against, plus per-channel levels. Also: the story around an item, the shelf beside an artist, muting a zone and getting its level back, and an mDNS advertisement that identifies the server.

DLNA tells control points what the zone is really doing, and asks the renderer what it plays instead of assuming MP3.

Sendspin follows the reference server's controller, seek and source surfaces, offers the encrypted path, and logs the spec deviations a session tolerates.

⚠️ Breaking changes
The event stream opens with server.snapshot, not server.ready.

Two things called "ready" meant different things, and a reporter running the readiness probe caught them contradicting each other: /api/v1/ready answered {"ready":false,"phase":"starting"} while the event stream opened by announcing server.ready (#338). The event was never a lifecycle claim — it is the snapshot every connection opens with, so a still-starting server sends it quite correctly. The name was the whole problem.

server.snapshot says what it carries and matches its siblings (zone.changed, queue.changed). GET /ready is now the only thing in the API that claims readiness. This breaks anyone reading the stream, which is why it lands now rather than after 4.0; the player and the Home Assistant integration are updated in their own repos, and INTEGRATING.md names the old event so an integrator can tell what changed.

The lox-linein-bridge is gone.

Sendspin carries a source role in the spec now and Sonn Client implements it, so the bridge was a second way to do the same thing.

Removed: /api/linein and everything under it, the admin bridge routes, the WebSocket command push, config.inputs.lineIn.bridges, the bridge-only source fields (bridge_id, capture_device, ingest_resampler), and the linein* mDNS TXT keys.

Staying, because none of it was the bridge's: the ingest transports themselves — TCP 7080 and /ingest/ — plus the command queue and the wanted flag.

No migration, deliberately. A config that still holds inputs.lineIn.bridges or a source of type bridge keeps those keys as dead weight and the input goes on working as an ingest. Recreate it to clean up.

v4.0.0-beta.18

v4.0.0-beta.18 Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 01 Aug 09:07

sonn core 4.0.0-beta.18

145 commits since beta.17 — 59 features, 46 fixes, 26 refactors. The integration surface was
rebuilt from the ground up, and the server no longer assumes it is a Loxone deployment.

Read this first

Expect things to have moved. This beta changes a lot of ground at once: how services and content
ids are named, how receivers attach to players, how configuration is shaped, and what the server
takes for granted about Loxone. Config migrates automatically where it can, but not every
combination has been walked through.

4.0 has only ever existed as betas, so none of this breaks a compatibility promise — there is no
stable release to be compatible with yet. What it does mean is that upgrading from an earlier beta
can need a hand: config that migrates imperfectly, or stored ids that have to be re-resolved.

If something does not work, start from a clean config before opening an issue. Stop the server,
move data/config.json aside — keep it, do not delete it — start again and walk through setup. In a
beta this size, a surprising share of odd behaviour is old config meeting new code, and a clean
start is much faster than a diagnosis.

If it still misbehaves on a clean config, that is a real bug and worth reporting. Please include
what you did, what you expected instead, and the log lines around it.

Integration has been rebuilt — read the guide

Integration is no longer something bolted onto the Loxone protocol. The server now serves its own
versioned API at /api/v1, and the Loxone emulation is one integration among several rather than
the foundation everything else is expressed in
.

If you integrate with this server in any way, read
INTEGRATING.md. It is the
reference for the whole surface: the zone object, events, reading, commands, destinations, browsing,
announcements, MQTT, and notes for integrators. Anything you built against internal endpoints or
against the Loxone shape should be re-read against that document.

Highlights of the new API:

  • Zones over an API of our own, with live events — a full zone on every change, never a patch,
    so a client needs no prior state to interpret an event. The version is in the path.
  • Destinations, so zones are genuinely optional: a browser tab is a destination that belongs to
    that browser and stays out of everyone else's zone list.
  • Browse and search with every provider first-class, and a browse→play loop that closes.
  • The queue as a resource, with repeat and shuffle settable; favourites, recently played and
    grouping; local playlist management.
  • Announcements — say or play something into a zone, or everywhere at once.
  • Health verdict and readiness probe, so orchestration can wait for "ready" instead of sleeping.
  • The audio described honestly: source and output formats, what the pipeline does stage by
    stage, output capabilities, how a zone's audio is timed against its device, and a settable delay.
  • Structured power state, zone-addressed cover URLs, configured inputs and switching to one.

Beyond Loxone

  • Standalone runs no Loxone protocol at all — no ports, no processor, nothing listening.
  • Loxone can be switched on and off at runtime without restarting the server.
  • MQTT: publish zone state to a broker, and accept commands back.
  • Subsonic API: library, radio and bridges over /rest/*, so any Subsonic client works.
  • UPnP/DLNA: a MediaServer with real UPnP classes, per-service tile icons and Search; plus a
    per-zone MediaRenderer so apps can cast straight to a zone.
  • WebDAV: mount the music folder as a network drive, with streaming uploads.

Audio

  • Lossless sources play at their native rate and depth instead of being resampled.
  • Realtime analysis is centralized and exposed as a visualizer stream — spectrum, peak,
    loudness and pitch, with the geometry announced so a client does not have to guess it.
  • Frame-accurate pause and resume, and an engine restart when the negotiated format changes.

Library

  • A rescan reconciles the index instead of wiping it. The old behaviour could lose an
    unreachable share's contents. This one is a data-loss fix.
  • Single changed files are indexed incrementally, and artist pictures are fetched in the background.

Devices

  • Beoremote One: a served menu, key routing, standby mapped to an immediate power-off, and
    bursts of step presses folded into one skip.
  • Line-in: transport commands routed to the bridge that owns the source, pushed over the ingest
    socket rather than polled.
  • Speakers configurable from the admin UI instead of over SSH, including where a client applies
    its volume.

Admin

  • Local admin authentication with a shared user store, independent of Loxone pairing.
  • A front door on / that asks "player or console?" as a page with two links, instead of booting
    the console to ask the question.

What moved, if you are coming from an earlier beta

None of these break a released API — 4.0 has only ever been a beta. They are the changes most likely
to need attention on an existing install, which is also why a clean config is the fastest first step
when something looks wrong.

Change What it means for you
The server is no longer a Loxone deployment mode is gone. Loxone is a capability you enable, with setup state tracked separately. Existing config migrates automatically.
Content ids are service-native Audiopaths now read spotify:…, applemusic:… and so on, instead of the Loxone bridge shape. Anything that stored raw audiopaths must re-resolve them.
Providers publish named nodes Loxone maps its own slot numbers; no provider decodes a slot any more. Affects anything that relied on slot numbering.
Accounts are named service-natively outside Loxone A configured account is addressed by its service, not by a bridge id.
Receivers belong to a player, not the server Per-player receiver configuration; server-level receiver config is gone.
The zone equalizer moved to /api It is no longer on /admin/api.
Library rescan reconciles Behaviour change, in your favour — see above.

Known rough edges

  • The player UI is early (v0.1.0) and still moving.
  • This is a beta. Run it where you can tolerate surprises, and keep your old data/config.json.

Images

ghcr.io/sonn-audio/core:4.0.0-beta.18
ghcr.io/sonn-audio/core:beta
ghcr.io/sonn-audio/core:beta-latest

Full Changelog: v4.0.0-beta.17...v4.0.0-beta.18

v4.0.0-beta.17

v4.0.0-beta.17 Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 24 Jul 16:43

v4.0.0-beta.16

v4.0.0-beta.16 Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 28 Jun 10:08
chore(release): bump version to 4.0.0-beta.16

v4.0.0-beta.15

v4.0.0-beta.15 Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 13 Jun 16:03
chore(release): bump version to 4.0.0-beta.15

v4.0.0-beta.14

v4.0.0-beta.14 Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 04 Jun 16:53
chore(release): bump version to 4.0.0-beta.14

v4.0.0-beta.13

v4.0.0-beta.13 Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 24 May 18:24

v4.0.0-beta.12

v4.0.0-beta.12 Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 10 May 07:36
chore(deps): add missing typescript-eslint devDep

eslint.config.js requires the typescript-eslint meta-package but only
the @typescript-eslint/eslint-plugin and parser sub-packages were
declared, so npm run lint failed on a fresh install.