sonn docs GitHub

api reference

Events

GET /api/v1/events        →  text/event-stream

Server-Sent Events. Every message is a JSON object on a data: line with a type discriminator. The stream opens with a full snapshot, so a client can render before the first change arrives. A : keep-alive comment is sent every 25 s.

data: {"type":"server.ready","zones":[ … ]}

data: {"type":"zone.changed","zone":{ … }}

data: {"type":"zone.progress","id":3,"position":44}

data: {"type":"queue.changed","id":3,"size":12}

data: {"type":"favorites.changed","id":3,"count":4}

data: {"type":"recents.changed","id":3}

zone.changed always carries the complete zone, never a patch — you never need to keep prior state to interpret an event, and a client that reconnects is immediately correct.

zone.progress is the one exception: while a track plays and nothing but the clock has moved, only the new position is sent. A full zone is ~550 bytes and this fires once a second per playing zone. Anything else that changes — volume, a new track, a source switch — still arrives as a zone.changed, so a client that ignores zone.progress stays correct; its progress bar just moves a beat later.

The three collection events carry a size, not the collection. A queue is paged and can hold thousands of entries, so shipping it on every edit would be the wrong trade — re-read GET /zones/{id}/queue for the page you are showing. They also fire when another client makes the change, which is the point: two tabs, or a tab and a Loxone panel, stay in step.

Unlike zone.changed these are not deduplicated. "The queue changed" is an event rather than a value, so two identical ones mean it changed twice — a reorder keeps the size and still reports.

Browser:

new EventSource('http://server:7090/api/v1/events')
  .onmessage = (e) => console.log(JSON.parse(e.data));

Shell:

curl -N http://server:7090/api/v1/events

Realtime audio analysis

For visualizers, an individual zone also exposes the central audio analysis as an SSE stream:

GET /api/v1/zones/{id}/analysis?types=loudness,spectrum,f_peak,peak,pitch&rate=20&bins=32

The stream starts with analysis.ready. After that, each data: line is one analysis event:

{"type":"loudness","value":0.42,"timestampUs":1720000000000000}
{"type":"spectrum","bins":[12,18,31],"timestampUs":1720000000050000}
{"type":"pitch","midiQ88":17612,"confidence":0.81,"timestampUs":1720000000050000}

types selects the features, rate is capped at 60 events per second and bins controls the spectrum resolution. The stream is fed from the zone's PCM output, so it is independent of the Sendspin protocol; Sendspin and browser clients consume the same central analysis pipeline. Analysis is realtime data rather than zone state and is therefore deliberately not included in zone.changed or persisted in the zone object.

Waveforms

The other half of the same job, for the shape of a whole track rather than the sound of this instant — a scrubber that shows where the loud parts are.

curl -s "http://server:7090/api/v1/waveform?uri=<source id>"
{ "uri": "library://local/…/01 - Don't Panic.flac", "buckets": [0, 3, 11, 42, 40, 38, …], "durationMs": 224000 }

Keyed by uri, not by zone — deliberately. The same track has the same shape in every room, so hanging it off /zones/{id}/waveform would serve identical bytes under a dozen URLs and make it uncacheable in a browser. A zone's source.id is exactly what goes in here, and the response echoes it back so a late reply can be matched to the track that is playing now.

It is served Cache-Control: private, max-age=86400. That is safe to lean on rather than re-fetching: the bytes derive from a file whose size and mtime are part of its audiopath, so a file that changes is recomputed under a different response rather than staling this one.

404 no-waveform means there is no shape to draw, and covers two cases a caller can neither tell apart nor needs to: a live stream that can never have one, and a file not yet analysed. Both mean draw what you have and ask again later, so treat it as an empty state rather than an error — and note that an empty buckets array would be a different answer, a track that really is silent. A missing or blank uri is 400 missing-uri.

This chapter is rendered from INTEGRATING.md in sonn-audio/core — the API's single source of truth, kept next to the code it describes.