Skip to content

Add announcement role (announcement@v1) - #126

Draft
marcelveldt wants to merge 3 commits into
mainfrom
announcement-role
Draft

Add announcement role (announcement@v1)#126
marcelveldt wants to merge 3 commits into
mainfrom
announcement-role

Conversation

@marcelveldt

@marcelveldt marcelveldt commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

This proposes a new optional role family, announcement@v1, giving Sendspin a native way to play short client-specific audio clips — voice-assistant responses, doorbell chimes, alerts — alongside or independent of media playback, with ducking of the media handled locally on the client.

Why

Announcements are currently outside the protocol by design: TTS/alerts are delegated to higher-level applications. In practice that means the server-side workaround is ugly — Music Assistant today fakes an announcement by snapshotting player state, ungrouping, stopping the stream, playing the clip as regular media, and restoring everything afterwards. For ESPHome devices this is a regression: their firmware has a first-class dual-pipeline announcement model (media keeps playing, announcement mixes over it, media ducks), but it is only reachable through the Home Assistant API, not through Sendspin. MA currently relays announcements for ESPHome-backed Sendspin players via HA (music-assistant/server#4916) — that stopgap works only for ESPHome devices and only when HA is present.

The headline capability this role adds: announce on any speaker(s) without touching a playing group's timeline. The media stream keeps flowing untouched to every member; each targeted client ducks its own output locally and mixes the announcement over it.

Use cases

  • Voice-assistant (TTS) response on the speaker you spoke to — that speaker ducks, the rest of the group plays on.
  • Doorbell / timer / alert on one speaker, a room, or everywhere: one stream per targeted speaker, started at the same scheduled timestamp for a coordinated announcement. Which speakers to target is the user's choice, not a protocol property.
  • Announce on an idle speaker (no media session needed; ducking is simply a no-op).
  • Announcement-only devices (a chime box without a player role).
  • Announce from a second server while another streams media (e.g. HA announcing while MA plays): the intended direction, but it needs announcement-capable secondary connections in the connection admission model — a companion change, listed under open questions.

Design in one paragraph

A client advertises the role plus an announcement@v1_support object (its announcement formats, priority-ordered, plus a dedicated buffer_capacity). The server starts a per-client announcement stream with stream/start {announcement: {codec params, media_duck_db, duck_ramp_ms, volume?}} and pushes audio as a new binary type 24 (role 6, the next free 4-slot block; 12–15 belong to source) with the usual [type][int64 BE µs timestamp] framing. Timestamps are contiguous (the server pads silence when TTS is slow); late chunks are not dropped — there is no cross-device sync to protect, so playback starts at the first chunk's timestamp or as soon as possible after. The existing role-scoped stream/clear / stream/end handle replace and completion; ducking is bound to the local stream lifetime, so a dropped transport can never leave media ducked. An optional client/state.announcement {state, required_lead_time_ms} object keeps the server informed, but completion is always server-derivable from the timeline, so minimal clients can omit it entirely.

Announcements are addressed per client; a multi-speaker announcement is one stream per targeted client with a shared scheduled start time. Only sample-accurate cross-client lock of the announcement audio itself is out of scope.

Why a new role family (and not player@v2 or a vendor role)

  • Optionality falls out of negotiation: a client that doesn't list announcement@v1 never receives an announcement-scoped message. Capability detection is just active_roles.
  • player@v2 would fork the most critical role's wire for an orthogonal optional feature — and since announcement support would still be optional inside v2, version wouldn't even equal capability. It would also forbid announcement-only devices (a chime-only notification box is a legitimate client).
  • A _vendor role works as a private experiment but fragments the ecosystem as an end state; the goal is a role ESPHome ships natively.
  • Compatibility is zero-flag-day in both directions (verified against aiosendspin's shipped validation): an old server ignores the unknown role id and support key; an old client never sees announcement messages because the server MUST NOT send them unnegotiated. That MUST is normative in the draft precisely because deployed stream/clear/stream/end validators reject unknown role names — negotiation gating is what makes the addition safe.

Ducking and volume model

Mirrors the ESPHome dual-pipeline + mixer model (the main embedded client, where all the pieces — announcement pipeline, mixer, apply_ducking(decibel_reduction, duration) — already ship):

  • media_duck_db (0–50 dB, ESPHome's scale) reduces the client's own media signal pre-mix; duck_ramp_ms ramps it in and out. Clients that can't do fractional gain MAY duck to silence. Starting an announcement MUST NOT pause, stop, mute, or shift the media timeline.
  • Master volume and mute apply post-mix to both signals — announcements respect mute and the device's volume, like ESPHome/Voice PE today.
  • volume (optional, Sonos play_audio_clip precedent): render the announcement at the loudness that master volume volume would produce, regardless of the current master volume. It's a stateless per-pipeline gain — nothing to restore if the connection dies mid-clip, and media loudness is never touched.
  • Re-sending stream/start {announcement} while active updates config without clearing buffers (change the duck level mid-clip, ramping from the current gain); replacing the audio itself requires an explicit stream/clear.

Edge cases the draft pins down

Idle announce (duck is a no-op, role works without media or even without a player role) · group churn and media stream lifecycle never tear down an announcement · ephemeral semantics (no resume/catch-up after transport loss) · stuck-duck prevention (duck released on end, abort, or transport loss) · a 5 s underrun guard for stalled streams · announcement audio MUST NOT feed visualizer/color/metadata · one announcement per client at a time, replace-requires-clear · stream/end with omitted roles ends announcements too, while stream/clear with omitted roles (a media seek) explicitly does not.

Implementation sketches

Client (sendspin-cpp + ESPHome): the role slots into sendspin-cpp's documented add-a-role pattern; type 24 routes through the existing get_binary_role() helper unchanged. The decode path is a dedicated lightweight task (own ring buffer + decoder instance, no Kalman machinery — the sink's blocking writes pace it). On the ESPHome side the speaker_source orchestrator already implements the dual pipeline and mixer ducking that Voice PE ships; the net-new work is a Sendspin announcement source feeding the announcement pipeline plus plumbing the per-stream duck parameters into apply_ducking. A companion draft PR with this skeleton is linked below. Note the release ordering: ESPHome pins sendspin-cpp as a managed component, so the role must land and release there first.

Server (aiosendspin + Music Assistant): a per-client announcement stream engine outside the group PushStream, riding the existing per-role-family send queues, epochs, and buffer tracking; public API shaped like client.play_announcement(pcm_source, fmt, *, media_duck_db, duck_ramp_ms, volume) -> awaitable. MA's side is small: a negotiated role toggles PLAY_ANNOUNCEMENT on the player, and play_announcement feeds the existing announcement PCM pipeline (chime + TTS handling included) into the role stream. The fallback ladder stays intact: native role → HA relay (music-assistant/server#4916) → today's stop-and-restore path (which also remains the answer for bridged clients).

Server-side mixing as a universal fallback — considered, not proposed. We looked hard at having the server mix announcements into the outgoing audio for clients without the role (per-client channels make per-client content on the aligned timeline possible, and the mix itself is trivial). It founders on send-ahead buffering: making the mix audible now on one grouped client requires flushing and re-feeding that client's buffered window with identical timestamps while leaving siblings untouched — a per-client clear/re-feed primitive that doesn't exist and would live in the riskiest timeline/catch-up code, and without it the onset latency equals the buffer depth (up to ~30 s). A solo-client duck-mix via the existing seek path remains a possible MA-side enhancement, but the protocol role is the right primary mechanism.

Open questions

  1. Rollout id: land as announcement@v1 directly (pre-1.0 spec), or stage as announcement@_draft_r1 per the visualizer precedent? The wire is identical either way.
  2. Binary allocation: confirm role 6 / IDs 24–27 (first allocation from the reserved-for-future-roles range).
  3. Duck defaults and bounds: media_duck_db default 0 (off) and duck_ramp_ms default 100 ms — input from the ESPHome side especially welcome.
  4. Mute override: should an announcement ever be able to override mute (alarm use-case), e.g. a reserved flag? The draft says announcements respect mute.
  5. Announcing visibility: should companion roles (metadata, or a future field) expose "announcement in progress" for display clients, or stay strictly media-only as drafted?
  6. Second-server announcements: today announcements ride the client's single admitted connection. Letting e.g. HA announce while MA streams needs announcement-capable secondary connections in connection.md's admission model (an incoming announcement-scoped connection that does not displace the playback connection). Do we want that as part of v1 or as a follow-up spec change?

Companion implementation draft (client skeleton, compiles + tested): Sendspin/sendspin-cpp#101.

@balloob

balloob commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Announcements are strictly per-client. A multi-room announcement is N independent streams; cross-client announcement sync is an explicit non-goal for v1.

Doesn't this defeat the purpose? The nice thing about MA doing the announcement is that all speakers in my living room mute at the same time, play the announcement, and then continue playing. If only 1 player ducks the music, I might still miss it?

Also, this proposal made me initially think that announcements would be possible to a second connected client, that is not the one currently streaming the music (as Sonos allows), but that doesn't seem to be the case.

All-in-all, I think this proposal could use some more thought about the use cases we want to unlock and if the current proposal covers these.

@marcelveldt

marcelveldt commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author
  1. Announcements are always per speaker, not per grouped speakers.
  2. They can optionally be addressed to multiple speakers at once but that should be user choice, not a protocol limitation.
  3. It can be nice to also support coordinated playback of an announcement to multiple playersat once but that is more a nice to have than a must-have.
  4. The player itself is responsible for mixing in an announcement stream. It announces this role only if it has this capability.

Requirements 1, 2, 3 come from experience with this after having this feature in MA for a few years.
It is used to announce something in a specific room / speaker more often than everywhere.
If users want to announce something at multiple speakers at once, they usually just target multiple speakers at once with the same announcement (and MA starts them all at the same time) or they create a dedicated group for it.

Also, this proposal made me initially think that announcements would be possible to a second connected client, that is not the one currently streaming the music

Yes that is exactly how it should work - is that not clearly described enough ?
This way you can also unlock HA streaming announcements over the sendspin route

EDIT: tightened the wording a bit as I can see where the confusion came from.

@balloob

balloob commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

But if it's per speaker, it won't work with stereo pairs, or any other set up where a user has 2 speakers set up in the same room. Or if a user targets an area in Home Assistant, it would end up hearing the same announcement in 1 room slightly out of sync. Or you think that HA could be smart to see it's targeting 2 Sendspin players, and get a sync lock before playing the announcement without too much delay?

@marcelveldt

Copy link
Copy Markdown
Contributor Author

But if it's per speaker, it won't work with stereo pairs, or any other set up where a user has 2 speakers set up in the same room. Or if a user targets an area in Home Assistant, it would end up hearing the same announcement in 1 room slightly out of sync. Or you think that HA could be smart to see it's targeting 2 Sendspin players, and get a sync lock before playing the announcement without too much delay?

It has coordinated start, which is perfect for this usecase

Comment thread roles/announcement/v1.md

- The server MUST NOT send any announcement-scoped message (`stream/start` with an `announcement` object, `stream/clear`/`stream/end` listing `announcement`, or type `24` binary) to a client whose `active_roles` do not include the announcement role.
- The chunk duration bounds of the player role apply (15-150 ms; see [Server Audio Send Constraints](../player/v1.md#server-audio-send-constraints)).
- The first chunk's timestamp SHOULD be at least the client's announcement `required_lead_time_ms` (or the 500 ms default) past the `stream/start` server transmit time.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ignores duck_ramp_ms

Comment thread roles/announcement/v1.md

Re-sending `stream/start` with an `announcement` object while an announcement stream is active updates the configuration without clearing buffers (e.g., to change the duck level mid-announcement); a new `media_duck_db` target ramps from the current gain. To replace the announcement audio itself, the server MUST first send [`stream/clear`](../../messaging.md#server--client-streamclear) listing the `announcement` role.

### Server → Client: `stream/clear` announcement

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets drop this, don't see a use case where stream/clear should be used instead of a stream/end.

Comment thread roles/announcement/v1.md
Binary messages SHOULD be rejected if there is no active announcement stream or the client is not [`available`](../../messaging.md#client--server-clientstate).

- Byte 0: message type `24` (uint8)
- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the first sample should be output

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can drop the timestamp here and instead have a single timestamp in stream/start.
The client will just play them continuously and it's easier to implement the case where it arrived too late (since you just play them continuously).
And it will still start in sync with a best effort mechanism. Not having stream/clear support also closes edge cases.

And that would also implicitly solve the slow TTS issues: the client will try to play them continuously, but if its coming it too slow it will automatically be a "best effort" approach.

buffer_capacity needs some more thought though.

Comment thread roles/announcement/v1.md
2. The announcement `volume` (when present) applies to the announcement signal only, before mixing. Since master volume is applied after the mix, the client compensates the announcement gain so the announcement is rendered at the loudness that master volume `volume` would produce. Implementations MAY snapshot the master volume at announcement start for this compensation.
3. The master volume and mute of the [player role](../player/v1.md#client--server-clientstate-player-object) apply to the mixed output. Announcements are therefore silenced on a muted client.

Starting an announcement stream MUST NOT pause, stop, mute, or shift the timeline of the client's media stream - ducking is a gain change only. This keeps a grouped client's media playback sample-identical with its group while it plays an announcement.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: it was never sample-identical due to the small corrections to keep it in sync.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, re-wording this to say that the reason for this is to keep sync with other players in the group might be the right fix?

Suggested change
Starting an announcement stream MUST NOT pause, stop, mute, or shift the timeline of the client's media stream - ducking is a gain change only. This keeps a grouped client's media playback sample-identical with its group while it plays an announcement.
Starting an announcement stream MUST NOT pause, stop, mute, or shift the timeline of the client's media stream - ducking is a gain change only. This keeps a grouped client's media playback in sync with its group while it plays an announcement.

Comment thread roles/announcement/v1.md
- The first chunk's timestamp SHOULD be at least the client's announcement `required_lead_time_ms` (or the 500 ms default) past the `stream/start` server transmit time.
- `buffer_capacity` is a hard per-client byte limit on outstanding un-played compressed announcement audio.
- The server knows when the announcement has finished playing from its own timeline (the last chunk's end timestamp). For normal completion it SHOULD NOT send `stream/end` for the announcement role before that time has passed; sending it earlier is the abort path.
- Announcements ride the client's admitted connection: the server allowed to stream media may announce. Letting a *second* server (e.g. a voice assistant) announce while another server streams media requires announcement-capable secondary connections in the [connection admission model](../../connection.md#multiple-servers-server-initiated) - a companion change tracked separately from this role.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should belong in a PR description and/or issue instead of the spec IMO.

Comment thread roles/announcement/v1.md
- `channels`: integer - channels to be used
- `bit_depth`: integer - bit depth to be used
- `codec_header?`: string - codec header encoded as standard Base64, if necessary (e.g., FLAC)
- `media_duck_db?`: integer - reduction in decibel (range 0-50, default 0) to apply to this client's own media output while the announcement stream is active. 0 means no ducking

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mention what the db is relative to (amplitude, 20*log10), the player role is explicit about its scale too.

Comment thread roles/announcement/v1.md
- `buffer_capacity` is a hard per-client byte limit on outstanding un-played compressed announcement audio.
- The server knows when the announcement has finished playing from its own timeline (the last chunk's end timestamp). For normal completion it SHOULD NOT send `stream/end` for the announcement role before that time has passed; sending it earlier is the abort path.
- Announcements ride the client's admitted connection: the server allowed to stream media may announce. Letting a *second* server (e.g. a voice assistant) announce while another server streams media requires announcement-capable secondary connections in the [connection admission model](../../connection.md#multiple-servers-server-initiated) - a companion change tracked separately from this role.
- There is no negative acknowledgement; a client MAY silently ignore an announcement stream it cannot currently service.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is client behavior, not server. move it to the lifecycle section.

Comment thread roles/announcement/v1.md
### Server behavior for announcements

- The server MUST NOT send any announcement-scoped message (`stream/start` with an `announcement` object, `stream/clear`/`stream/end` listing `announcement`, or type `24` binary) to a client whose `active_roles` do not include the announcement role.
- The chunk duration bounds of the player role apply (15-150 ms; see [Server Audio Send Constraints](../player/v1.md#server-audio-send-constraints)).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We try to limit cross role links since roles should be as self contained as possible.
Inline the 15-150ms here, same for required_lead_time_ms.

Comment thread roles/announcement/v1.md

- Byte 0: message type `24` (uint8)
- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the first sample should be output
- Rest of bytes: encoded audio frame

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't define how data (and PCM) is encoded. See player/v1.md.

Comment thread roles/announcement/v1.md

1. `media_duck_db` applies to the media signal only, before mixing.
2. The announcement `volume` (when present) applies to the announcement signal only, before mixing. Since master volume is applied after the mix, the client compensates the announcement gain so the announcement is rendered at the loudness that master volume `volume` would produce. Implementations MAY snapshot the master volume at announcement start for this compensation.
3. The master volume and mute of the [player role](../player/v1.md#client--server-clientstate-player-object) apply to the mixed output. Announcements are therefore silenced on a muted client.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 6 says this can be advertised standalone, but then there's no player object and no volume or mute at all.

@kepstin kepstin Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect that being able to override the mute on a media playback stream so the announcement can be heard (i.e. media playback stays muted, only announcement is audible) might be preferred in some cases.

Might need to be optional, so people can decide in automations on a case-by-case basis (e.g. consider time of day, room occupation, etc.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants