sonn docs GitHub

api reference

Zone object

{
  "id": 3,
  "name": "Kitchen",
  "state": "playing",
  "powerState": {
    "power": "on",
    "target": "on",
    "managed": true,
    "idleTimeoutMs": 300000
  },
  "position": 43,
  "duration": 210,
  "volume": 40,
  "volumeLimits": { "max": 70, "default": 20, "step": 2 },
  "repeat": "off",
  "shuffle": false,
  "track": {
    "title": "Song",
    "artist": "Artist",
    "album": "Album",
    "coverUrl": "http://server:7090/streams/3/…/cover",
    "colors": {
      "primary": [120, 30, 40],
      "accent": [220, 80, 60],
      "backgroundDark": [10, 5, 8],
      "backgroundLight": [245, 240, 240],
      "onDark": [255, 255, 255],
      "onLight": [0, 0, 0]
    }
  },
  "source": { "kind": "track", "name": "Library", "id": "library://track/9", "seekable": true },
  "group": { "leader": 3, "members": [3, 7] },
  "output": {
    "protocol": "sendspin",
    "capabilities": {
      "formats": [{ "codec": "pcm", "sampleRate": 44100, "bitDepth": 24, "channels": 2 }],
      "roles": ["player@v1", "visualizer@v1", "color@v1"],
      "visualizer": {
        "types": ["loudness", "spectrum", "pitch"],
        "rateMax": 30,
        "spectrum": { "bins": 64, "scale": "log", "fMin": 40, "fMax": 16000 }
      }
    }
  }
}
FieldTypeNotes
idnumberZone id. Stable across restarts.
namestringZone name as configured.
stateplaying \paused \stopped
powerStateobjectpower, target, managed and idleTimeoutMs; see below.
positionnumberWhole seconds into the current track.
durationnumberWhole seconds. 0 means open-ended (live radio).
volumenumber0100, but see volumeLimits.
volumeLimitsobjectmax, default and step — what this zone's volume will actually accept.
repeatoff \one \all
shuffleboolean

track.colors is the palette derived from the current cover artwork. It is null when there is no cover or when the artwork could not be processed.

output.capabilities contains the capabilities negotiated with the active output client. For Sendspin this includes supported player formats, negotiated roles and visualizer preferences. It is null when the client is not connected. format remains the format currently in use; capabilities describe what the client can accept.

powerState.power is the last confirmed physical power signal. powerState.target is the desired signal. When managed is false, no physical power action is configured and idleTimeoutMs is null. | track | object \| null | null when the zone has nothing loaded. | | source | object \| null | Where the audio comes from. | | group | object \| null | null when the zone plays on its own; members lists leader first. | | output | object \| null | protocol is e.g. sendspin, snapcast, googlecast, dlna, sonos, airplay. | | format | object \| null | The source audio and output audio format. | | error | string \| absent | Why the last thing this zone was asked to play did not play. |

output.device is present when the protocol identifies a specific device — for squeezelite that is the SlimProto MAC, i.e. what its -m is set to:

"output": {
  "protocol": "squeezelite",
  "device": { "id": "02:8C:54:A9:DC:AC", "name": "Test1", "connected": true }
}

It is reported whether or not the zone is playing and whether or not the device is reachable, so you can map your own devices onto zones from a single read. connected tells you the current link state; the id stays put either way.

output.sync says how this zone's audio is timed against the device, for protocols that keep a shared clock. It is null or absent for the rest: an output that just hands bytes to a renderer has no clock agreement to report on, so an absent sync does not mean "out of sync".

"output": {
  "protocol": "sendspin",
  "sync": {
    "state": "synchronized",
    "delayMs": 0,
    "deviceDelayMs": null,
    "targetLeadMs": 250,
    "leadMarginMs": 150,
    "leadMs": 334,
    "leadMinMs": 271,
    "driftMs": -25
  }
}

Two different things live in there. state and delayMs are the agreement: the device reports whether it locked onto the clock (synchronized, error, external_source when it switched to its own input, or unknown before it has said), and delayMs is the delay its own chain adds after the audio port — raising it makes the room play earlier, see below. deviceDelayMs is what the device says it has, which is not a confirmation of delayMs: a device applies the command at once but only mentions the value in its next state message, so this trails every write by design. Do not build a "not applied yet" indicator on the difference.

The rest is the measurement of how well the server is keeping its end, and each is null while nothing is streaming, because they describe a stream in flight.

Frames are scheduled to arrive inside a band, not at a single target: targetLeadMs is its floor — the least lead the sender allows — and leadMarginMs is how far above that it may run before it backpressures. leadMs is the lead the most recent frame achieved, so it oscillates through the band by design as the sender bursts and then waits.

leadMinMs is the health signal, not leadMs: the lowest lead seen over the last couple of seconds. While that floor stays at or above targetLeadMs the client always has audio in hand; a floor sinking toward zero is what a listener hears as dropouts. It is deliberately a floor rather than a jitter average or spread — those measure the designed oscillation above and report a perfectly steady stream as a fault.

driftMs compares the server's modelled timeline against the frame clock — a value that keeps growing is a slipping timeline; one that sits still is fine, whatever its sign.

Only the agreement is treated as state. A change in state, delayMs or targetLeadMs arrives as a zone.changed; the measurements move every frame and would turn every progress tick into a full zone, so they ride along on whatever zone.changed comes next rather than causing one. Poll GET /zones/{id} if you want them live.

source.kind is one of track, radio, playlist, linein, airplay, spotify, bluetooth, unknown. Treat the list as open: new kinds may be added, and a client must not fail on one it does not recognise — that is what unknown is a placeholder for.

source.seekable says whether PUT /zones/{id}/position will do anything — false for a live stream, which has no position to seek to.

source.id is an opaque provider-native identifier. You may store it and pass it back, but do not parse it: its internal form is service-specific and explicitly not part of this contract.

null is used deliberately instead of empty strings, so if (zone.track) is enough to tell "playing something" from "idle".

format.output is what the device is actually receiving, which is not the file's own format — a zone whose output cannot take 192 kHz gets it resampled. format.source is the native source format when it was declared by the provider or successfully probed:

"format": {
  "bitPerfect": true,
  "dspApplied": false,
  "source": { "codec": "flac", "sampleRate": 96000, "bitDepth": 24, "channels": 2, "bitrate": null, "highRes": true },
  "output": { "codec": "pcm", "sampleRate": 44100, "bitDepth": 24, "channels": 2, "bitrate": null, "highRes": true },
  "processing": {
    "resampled": true,
    "resampler": { "name": "soxr", "precision": 28, "cutoff": 0.91 },
    "requantised": false,
    "channelsRemapped": false,
    "reencoded": false,
    "equalizer": null,
    "gainDb": null,
    "delayMs": null,
    "crossfading": false
  }
}

bitrate is bits per second where it is known, and null when it is unavailable. source is null when the source format is unknown; format is null when the zone is streaming nothing. Engine internals — buffer sizes, restart counts, subscriber drops — stay out; those describe the server's health rather than the audio, and live in the admin surface.

format.bitPerfect is true only when a lossless source reaches the output with matching sample rate, bit depth and channel count and without EQ, gain, pre-delay or other DSP. It is false for lossy sources such as MP3 and AAC, even when their output parameters happen to match. format.dspApplied indicates whether the server performed conversion, filtering, gain, delay or re-encoding. An AAC source can therefore be bitPerfect: false and dspApplied: false. format.source.highRes and format.output.highRes are true above 48 kHz or above 16-bit depth.

format.processing is dspApplied itemised — what was done to the audio, stage by stage, so a client can show why a stream is not bit-perfect rather than only that it is not.

fieldmeaning
resampledThe resampler ran: rate, channels or depth changed, or a filter forced the path.
resamplerWhich one and how it was configured, when it ran. null otherwise.
requantisedThe sample depth changed — the source declared one, the output carries another.
channelsRemappedThe channel count changed: a downmix or an upmix.
reencodedThe output codec re-encodes rather than carrying samples (aac, mp3, opus).
equalizerThe zone's 10-band EQ, when any band is off zero. Gains in dB, low band first.
gainDbGain by origin: source is the provider's own loudness normalisation (Spotify sends one), output a fixed trim. null when both are zero.
delayMsPre-delay for aligning this source against another output. null when none.
crossfadingTrue while a crossfade is blending, which requantises by definition.

It is null-able rather than defaulted to a chain of false, and the distinction carries information: null means the engine cannot say, so a server that does not report a chain stays distinguishable from one whose chain is genuinely empty.

The zone's volume is deliberately not in here. It is applied at the device rather than in this pipeline, so listing it as processing would claim an alteration this server did not make.

error is present only when something went wrong, so if (zone.error) is the whole check. It exists because POST /play answers 204 for a uri the server cannot resolve — resolution is asynchronous, so the call is accepted before anything has been looked up. The failure then arrives as a zone.changed like any other state change, which means you do not need a verification timer: send the play, watch the stream. track is null while a zone carries an error, and the error clears on the next successful play.

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.