Announce, then put the music back: a Home Assistant blueprint

Home Assistant blueprint: Announce, then restore what was playing. AutoMatter

You know the one. Music is going in the kitchen, the washer finishes, Home Assistant says so, and then the music is gone. Or it comes back at announcement volume. Or the speaker that was grouped with the living room is now on its own. I wanted an announcement that leaves the house exactly the way it found it, so I wrote one.

This is a Home Assistant blueprint. You give it your speakers and a voice, then any automation can make an announcement by firing one event with a message. Before it says anything it writes down what every speaker was doing. After, it puts it all back.

What it does

  • Takes a snapshot of each speaker: playing or paused, volume, mute, source, what was playing and how far into it.
  • Pauses the music first, so turning the volume up for the message does not blast your song.
  • Plays an optional chime, then the message, and waits for it to actually finish.
  • Puts each speaker back: volume, mute, the stream or track it was on (and the spot in it), the input it was on, and paused speakers stay paused.
  • Speakers that can announce on their own, like Music Assistant and Sonos, get the announcement the native way. The speaker lowers the music, says the message and carries on by itself. Everything else gets the snapshot and restore. You can force one way or the other if auto guesses wrong.

What it fixes

I read through the popular announcement blueprints and scripts on the Home Assistant forum before writing this one. Good work in there, but the same problems keep coming up in the threads:

  • Only works on one brand. The best restore scripts lean on the Sonos snapshot service, so they do nothing for anything else. This one works with any media player.
  • No restore at all. The general TTS blueprints play the message and leave the volume wherever it ended up.
  • Two announcements at once. When a second message comes in while the first is playing, the second one takes its snapshot in the middle of the first one, so it “restores” your speakers to announcement volume. Here a second message waits its turn and takes its snapshot after the first one has put everything back. Or you can tell it to drop the second one.
  • Speakers that are offline. An unavailable speaker gets skipped, even if it drops off halfway through the message. No error, the rest still work.
  • Speakers that were off. Left off, by default. Or, if you want, they turn on for the message and go back off.
  • Groups. If a speaker leaves its group to play the message, it goes back in after. If you pick both the group leader and a member, the member is not sent the message twice.
  • Quiet hours. A lower volume overnight, and it handles a window that crosses midnight, like 10 PM to 7 AM.
  • A restart halfway through. If Home Assistant restarts mid-message, the speakers can be left loud. Give it an optional text helper and it writes the volumes down first, then puts them back when Home Assistant comes up.

Setup

  1. Click the import button above, or in Home Assistant go to Settings, Automations & Scenes, Blueprints, Import Blueprint, and paste this address: https://automatter.ca/wp-content/uploads/blueprints/announce_restore.yaml
  2. Optional but worth it: make a text helper for restart recovery. Settings, Devices & Services, Helpers, Create Helper, Text. Set the maximum length to 255.
  3. Create an automation from the blueprint. Pick your speakers and your text-to-speech engine. Set the announcement volume, quiet hours if you want them, and the recovery helper.
  4. Make an announcement from any other automation or script by firing the event. In YAML it looks like the example below. In the visual editor, add an action, pick Other actions, then Event.
event: automatter_announce
event_data:
  message: "The washer is done."
  # optional, just for this one message:
  # players: [media_player.kitchen, media_player.living_room]
  # volume: 60

Want two separate setups, say upstairs and downstairs with different speakers? Create the automation twice and give each its own event name.

Why an automation and not a script

A script blueprint is a little easier to call, but a script cannot notice that Home Assistant just restarted. An automation can, so the restart recovery lives right in the same blueprint. Firing an event is one line, and it lets you queue or drop overlapping messages per setup.

What it was tested on

Home Assistant 2026.10.1, on a test install I run on a server, not in a real house. The speakers there are simulated: a small test integration that acts like real speakers (it plays a clip and then goes idle, a grouped speaker drops out of its group when you send it something directly, and one of them supports native announcements the way Music Assistant and Sonos do). Each one keeps a log of every command it gets, so I could check exactly what happened.

I ran 18 cases and 93 checks, and all of them passed: offline speakers before and during a message, speakers that were off, groups, two messages back to back in queue and drop mode, quiet hours inside, outside and across midnight, forced native and forced snapshot, a chime, and a full restart of Home Assistant in the middle of an announcement. The test caught one real bug along the way, the two-messages-at-once problem above, and that is fixed in this version.

Limitations, plainly

  • It was not tested on real Sonos, Google, AirPlay, Alexa or ESPHome speakers, and not on a real Music Assistant server. The Music Assistant path is built on Home Assistant’s standard announce feature, and it was only tested against a simulated speaker that advertises that feature.
  • Some music can’t be restarted from Home Assistant. Spotify Connect and some apps can’t be resumed just from what the speaker reports, so after the message that speaker may stay quiet. Native announce speakers don’t have this problem, because they resume the music on their own.
  • On native announce speakers the script can’t see when the message ends, so it waits an estimate based on the length of the message.
  • Restart recovery puts back volume and power. It does not restart the music.
  • The recovery helper holds about six speakers. More than that and the extra ones are not saved for a restart (everything else still works).
  • Two separate setups that share a speaker do not wait for each other.

The blueprint

Here is the whole thing, so you can read it before you import it. The same file is at https://automatter.ca/wp-content/uploads/blueprints/announce_restore.yaml.

# Built by AutoMatter | automatter.ca
# Write-up, updates and more Home Assistant blueprints: https://automatter.ca
blueprint:
  name: Announce, then restore what was playing
  author: AutoMatter (automatter.ca)
  source_url: https://automatter.ca/wp-content/uploads/blueprints/announce_restore.yaml
  description: >
    Says a message (and an optional chime) on your speakers, then puts every speaker back
    the way it was: volume, mute, source, what was playing and where it was in the track,
    speaker groups, and speakers that were off go back off.


    **How to use it:** create an automation from this blueprint, then from any other
    automation or script fire the event named below (default `automatter_announce`) with
    `message:` in the event data. Optional event data: `players:` (a list of speakers, to
    override the default list for this one announcement) and `volume:` (0 to 100).


    **What it handles:**

    - Speakers that are unavailable or offline are skipped, never an error.

    - Speakers that were off stay off (or, if you choose, wake up for the message and go back off).

    - Speakers that can announce natively (Music Assistant, Sonos and others that support
    `announce`) use that path, so the speaker ducks and resumes the music itself. Everything
    else gets a full snapshot and restore.

    - Grouped speakers are put back in their group afterwards.

    - A second announcement while one is playing waits its turn (or is dropped, your choice).
    It never snapshots the first announcement's volume by mistake.

    - Quiet hours with their own volume, including hours that cross midnight.

    - A Home Assistant restart in the middle of an announcement: with the optional recovery
    helper, volumes and off speakers are put back when Home Assistant starts again.


    Tested on Home Assistant 2026.10.1 with simulated speakers. Full write-up: [Announce, then put the music back](https://automatter.ca/announce-then-put-the-music-back-a-home-assistant-blueprint/)
  domain: automation
  homeassistant:
    min_version: "2024.10.0"
  input:
    speakers_section:
      name: Speakers
      icon: mdi:speaker-multiple
      input:
        players:
          name: Speakers
          description: The speakers that get the announcement unless the event names others.
          selector:
            entity:
              filter:
                - domain: media_player
              multiple: true
        event_type:
          name: Event name
          description: >
            The event this automation listens for. Leave it as is unless you want two
            separate announcement setups (for example upstairs and downstairs), each with its
            own event name.
          default: automatter_announce
          selector:
            text:
    sound_section:
      name: Voice and chime
      icon: mdi:account-voice
      input:
        tts_engine:
          name: Text-to-speech engine
          description: Any text-to-speech entity (Piper, Google Translate, Home Assistant Cloud and so on).
          selector:
            entity:
              filter:
                - domain: tts
        tts_language:
          name: Language (optional)
          description: Leave blank to use the engine's default language. Example en-CA.
          default: ""
          selector:
            text:
        chime:
          name: Chime before the message (optional)
          description: >
            A sound to play first, as a URL or a media-source id, for example
            media-source://media_source/local/chime.mp3. Leave blank for no chime.
          default: ""
          selector:
            text:
        chime_seconds:
          name: Chime length
          default: 2
          selector:
            number:
              min: 0
              max: 15
              step: 0.5
              unit_of_measurement: s
    volume_section:
      name: Volume and quiet hours
      icon: mdi:volume-high
      input:
        announce_volume:
          name: Announcement volume
          default: 45
          selector:
            number:
              min: 0
              max: 100
              unit_of_measurement: "%"
        quiet_hours:
          name: Use quiet hours
          default: false
          selector:
            boolean:
        quiet_start:
          name: Quiet hours start
          default: "22:00:00"
          selector:
            time:
        quiet_end:
          name: Quiet hours end
          description: Can be after midnight, for example 22:00 to 07:00.
          default: "07:00:00"
          selector:
            time:
        quiet_volume:
          name: Quiet hours volume
          default: 20
          selector:
            number:
              min: 0
              max: 100
              unit_of_measurement: "%"
    behaviour_section:
      name: Behaviour
      icon: mdi:cog
      collapsed: true
      input:
        announce_method:
          name: Announcement method
          description: >
            Auto uses the speaker's own announce feature when it has one (Music Assistant,
            Sonos and others), and snapshot and restore for everything else. Pick a fixed
            method only if auto guesses wrong for your speakers.
          default: auto
          selector:
            select:
              options:
                - label: Auto (recommended)
                  value: auto
                - label: Always snapshot and restore
                  value: snapshot
                - label: Always the speaker's native announce
                  value: native
        native_volume:
          name: Volume on native-announce speakers
          description: >
            Music Assistant and Sonos set the announcement volume themselves. Leave it to the
            speaker, or set the speaker volume like the others (the music may be louder for
            the length of the message).
          default: speaker
          selector:
            select:
              options:
                - label: Leave it to the speaker
                  value: speaker
                - label: Set it like the other speakers
                  value: set
        off_players:
          name: Speakers that are off
          default: skip
          selector:
            select:
              options:
                - label: Leave them off, skip them
                  value: skip
                - label: Turn on for the message, then back off
                  value: wake
        overlap:
          name: When an announcement arrives while one is playing
          default: queued
          selector:
            select:
              options:
                - label: Queue it, play it after
                  value: queued
                - label: Drop it
                  value: single
        max_seconds:
          name: Longest wait for a message to finish
          description: A safety limit. After this, the speakers are restored anyway.
          default: 60
          selector:
            number:
              min: 5
              max: 600
              unit_of_measurement: s
        recovery_helper:
          name: Recovery helper (optional, recommended)
          description: >
            An input_text helper (max length 255). The volumes are written here before the
            announcement, so if Home Assistant restarts mid-message the speakers get their
            volume back, and woken speakers go back off, when it starts again. Holds about
            six speakers.
          default: []
          selector:
            entity:
              filter:
                - domain: input_text

mode: !input overlap
max: 10
max_exceeded: silent

triggers:
  - trigger: event
    event_type: !input event_type
    id: announce
  - trigger: homeassistant
    event: start
    id: recover

variables:
  # ---- inputs ----
  default_players: !input players
  tts_engine: !input tts_engine
  tts_language: !input tts_language
  chime: !input chime
  chime_seconds: !input chime_seconds
  announce_volume: !input announce_volume
  quiet_hours: !input quiet_hours
  quiet_start: !input quiet_start
  quiet_end: !input quiet_end
  quiet_volume: !input quiet_volume
  method: !input announce_method
  native_volume: !input native_volume
  off_mode: !input off_players
  max_seconds: !input max_seconds
  recovery_helper: !input recovery_helper
  # media_player feature flags
  F_PAUSE: 1
  F_SEEK: 2
  F_VOLUME_SET: 4
  F_MUTE: 8
  F_TURN_ON: 128
  F_TURN_OFF: 256
  F_PLAY_MEDIA: 512
  F_SELECT_SOURCE: 2048
  F_PLAY: 16384
  F_GROUPING: 524288
  F_ANNOUNCE: 1048576
  helper: >
    {% set h = recovery_helper %}
    {% if h is string %}{{ h }}{% elif h is iterable and h | count > 0 %}{{ h[0] }}{% else %}{% endif %}
  data: "{{ trigger.event.data if trigger.id == 'announce' else {} }}"
  message: "{{ (data.message | default('', true)) | string | trim }}"
  targets: >
    {% set p = data.players | default(default_players, true) %}
    {% if p is string %}{% set p = p.split(',') %}{% endif %}
    {{ p | map('trim') | reject('eq', '') | unique | list }}

actions:
  - choose:
      # ================= Restart recovery =================
      - conditions:
          - condition: template
            value_template: "{{ trigger.id == 'recover' and helper != '' and states(helper) not in ['', 'unknown', 'unavailable'] }}"
        sequence:
          - delay: 5 # give integrations a moment to bring speakers back
          - repeat:
              for_each: "{{ states(helper).split(';') | reject('eq', '') | list }}"
              sequence:
                - variables:
                    parts: "{{ repeat.item.split('=') }}"
                    ent: "{{ parts[0] }}"
                - if:
                    - condition: template
                      value_template: "{{ parts | count == 3 and states(ent) not in ['unavailable', 'unknown'] }}"
                  then:
                    - if:
                        - condition: template
                          value_template: "{{ parts[1] != '-' }}"
                      then:
                        - action: media_player.volume_set
                          continue_on_error: true
                          target:
                            entity_id: "{{ ent }}"
                          data:
                            volume_level: "{{ parts[1] | float }}"
                    - if:
                        - condition: template
                          value_template: "{{ parts[2] == '1' }}"
                      then:
                        - action: media_player.turn_off
                          continue_on_error: true
                          target:
                            entity_id: "{{ ent }}"
          - action: input_text.set_value
            continue_on_error: true
            target:
              entity_id: "{{ helper }}"
            data:
              value: ""

      # ================= Announcement =================
      - conditions:
          - condition: template
            value_template: "{{ trigger.id == 'announce' and (message != '' or chime != '') }}"
        sequence:
          # Everything below is read when this run STARTS, not when the event fired. With
          # queued announcements that matters: the second one must snapshot the speakers after
          # the first has put them back, never the first one's announcement volume.
          - variables:
              # quiet hours, including a window that crosses midnight (22:00 to 07:00)
              now_hms: "{{ now().strftime('%H:%M:%S') }}"
              in_quiet: >-
                {{ quiet_hours and ((quiet_start <= now_hms < quiet_end) if quiet_start <= quiet_end
                   else (now_hms >= quiet_start or now_hms < quiet_end)) }}
              level: >
                {% set v = data.volume | default(quiet_volume if in_quiet else announce_volume, true) | float(45) %}
                {{ ([0, v, 100] | sort)[1] / 100 }}
              # ---- snapshot, taken before anything is touched ----
              snap: >
                {% set ns = namespace(out=[]) %}
                {% set ma = integration_entities('music_assistant') %}
                {% for p in targets if p.startswith('media_player.') %}
                  {% set s = states(p) %}
                  {% if s not in ['unavailable', 'unknown', 'none', '']
                        and not (s == 'off' and off_mode == 'skip') %}
                    {% set f = state_attr(p, 'supported_features') | int(0) %}
                    {% set can_native = (f | bitwise_and(F_ANNOUNCE) > 0) or p in ma %}
                    {% set native = method == 'native' or (method == 'auto' and can_native) %}
                    {% set pos = state_attr(p, 'media_position') %}
                    {% set upd = state_attr(p, 'media_position_updated_at') %}
                    {% if pos is number and s == 'playing' and upd %}
                      {% set pos = pos + (as_timestamp(now()) - as_timestamp(upd, as_timestamp(now()))) %}
                    {% endif %}
                    {% set g = state_attr(p, 'group_members') or [] %}
                    {% set ns.out = ns.out + [{
                      'e': p, 's': s, 'native': native,
                      'vol': state_attr(p, 'volume_level'),
                      'muted': state_attr(p, 'is_volume_muted') == true,
                      'src': state_attr(p, 'source'),
                      'cid': state_attr(p, 'media_content_id'),
                      'ctype': state_attr(p, 'media_content_type') or 'music',
                      'pos': pos if pos is number else none,
                      'group': g | list if g | count > 1 else [],
                      'wake': s == 'off',
                      'f': f }] %}
                  {% endif %}
                {% endfor %}
                {{ ns.out }}
              snap_ids: "{{ snap | map(attribute='e') | list }}"
              native_ids: "{{ snap | selectattr('native') | map(attribute='e') | list }}"
              snapshot_ids: "{{ snap | rejectattr('native') | map(attribute='e') | list }}"
              # A grouped speaker whose group leader is also getting the message hears it through the
              # leader. Sending it the message too would play it twice and pull it out of the group.
              play_ids: >
                {% set ns = namespace(out=[]) %}
                {% for x in snap if not x.native %}
                  {% if not (x.group | count > 1 and x.group[0] != x.e and x.group[0] in snapshot_ids) %}
                    {% set ns.out = ns.out + [x.e] %}
                  {% endif %}
                {% endfor %}
                {{ ns.out }}
              tts_id: >
                {% if message %}media-source://tts/{{ tts_engine }}?message={{ message | urlencode }}{{ '&language=' ~ tts_language if tts_language else '' }}{% endif %}
              clip_seconds: >
                {% set t = (chime_seconds | float(0) if chime else 0) + ((message | length) * 0.075 + 1.5 if message else 0) %}
                {{ [t, max_seconds | float(60)] | min }}
          - condition: template
            value_template: "{{ snap | count > 0 }}"
          # 1. Wake speakers that were off (only when asked to)
          - repeat:
              for_each: "{{ snap | selectattr('wake') | map(attribute='e') | list }}"
              sequence:
                - action: media_player.turn_on
                  continue_on_error: true
                  target:
                    entity_id: "{{ repeat.item }}"
          - if:
              - condition: template
                value_template: "{{ snap | selectattr('wake') | list | count > 0 }}"
            then:
              - wait_template: >
                  {{ snap | selectattr('wake') | map(attribute='e') | map('states') | select('eq', 'off') | list | count == 0 }}
                timeout: 5

          # 2. Speakers that were off only report a volume once they are on
          - variables:
              wake_vols: >
                {% set ns = namespace(d={}) %}
                {% for x in snap if x.wake %}
                  {% set ns.d = dict(ns.d.items() | list + [(x.e, state_attr(x.e, 'volume_level'))]) %}
                {% endfor %}
                {{ ns.d }}
              snap: >
                {% set ns = namespace(out=[]) %}
                {% for x in snap %}
                  {% set ns.out = ns.out + [dict(x, vol=(x.vol if x.vol is number else wake_vols.get(x.e)))] %}
                {% endfor %}
                {{ ns.out }}

          # 3. Save volumes for restart recovery: entity=volume=wasoff;...
          - if:
              - condition: template
                value_template: "{{ helper != '' }}"
            then:
              - action: input_text.set_value
                continue_on_error: true
                target:
                  entity_id: "{{ helper }}"
                data:
                  value: >
                    {% set ns = namespace(s='') %}
                    {% for x in snap %}
                      {% set item = x.e ~ '=' ~ (x.vol if x.vol is number else '-') ~ '=' ~ (1 if x.wake else 0) ~ ';' %}
                      {% if (ns.s ~ item) | length <= 255 %}{% set ns.s = ns.s ~ item %}{% endif %}
                    {% endfor %}
                    {{ ns.s }}

          # 4. Pause what is playing on snapshot speakers, so the volume change does not blast the music
          - repeat:
              for_each: >
                {% set ns = namespace(out=[]) %}
                {% for x in snap if not x.native and x.s == 'playing' and (x.f | bitwise_and(F_PAUSE)) > 0 %}
                  {% set ns.out = ns.out + [x.e] %}
                {% endfor %}
                {{ ns.out }}
              sequence:
                - action: media_player.media_pause
                  continue_on_error: true
                  target:
                    entity_id: "{{ repeat.item }}"

          # 5. Unmute and set the announcement volume
          - repeat:
              for_each: "{{ snap }}"
              sequence:
                - variables:
                    x: "{{ repeat.item }}"
                - if:
                    - condition: template
                      value_template: "{{ x.muted and (x.f | bitwise_and(F_MUTE)) > 0 }}"
                  then:
                    - action: media_player.volume_mute
                      continue_on_error: true
                      target:
                        entity_id: "{{ x.e }}"
                      data:
                        is_volume_muted: false
                - if:
                    - condition: template
                      value_template: >
                        {{ (x.f | bitwise_and(F_VOLUME_SET)) > 0 and (not x.native or native_volume == 'set') }}
                  then:
                    - action: media_player.volume_set
                      continue_on_error: true
                      target:
                        entity_id: "{{ x.e }}"
                      data:
                        volume_level: "{{ level }}"
          - variables:
              t0: "{{ as_timestamp(now()) }}"

          # 6. Native speakers: chime and message as announcements (the speaker resumes its music itself)
          - repeat:
              for_each: "{{ native_ids }}"
              sequence:
                - if:
                    - condition: template
                      value_template: "{{ chime != '' }}"
                  then:
                    - action: media_player.play_media
                      continue_on_error: true
                      target:
                        entity_id: "{{ repeat.item }}"
                      data:
                        media_content_id: "{{ chime }}"
                        media_content_type: music
                        announce: true
                        extra:
                          volume: "{{ (level * 100) | int }}"
          - if:
              - condition: template
                value_template: "{{ chime != '' and message != '' and native_ids | count > 0 and snapshot_ids | count == 0 }}"
            then:
              - delay: "{{ chime_seconds }}"
          - repeat:
              for_each: "{{ native_ids if message != '' else [] }}"
              sequence:
                - action: media_player.play_media
                  continue_on_error: true
                  target:
                    entity_id: "{{ repeat.item }}"
                  data:
                    media_content_id: "{{ tts_id }}"
                    media_content_type: music
                    announce: true
                    extra:
                      volume: "{{ (level * 100) | int }}"

          # 7. Snapshot speakers: play the chime then the message, waiting for each to finish
          - repeat:
              for_each: "{{ ([chime] if chime else []) + ([tts_id] if message else []) if play_ids | count > 0 else [] }}"
              sequence:
                - variables:
                    clip: "{{ repeat.item }}"
                - repeat:
                    for_each: "{{ play_ids }}"
                    sequence:
                      - action: media_player.play_media
                        continue_on_error: true
                        target:
                          entity_id: "{{ repeat.item }}"
                        data:
                          media_content_id: "{{ clip }}"
                          media_content_type: music
                # wait for it to start (max 5 s), then for it to stop
                - wait_template: >
                    {{ play_ids | select('is_state', 'playing') | list | count > 0 }}
                  timeout: 5
                - wait_template: >
                    {{ play_ids | select('is_state', 'playing') | list | count == 0 }}
                  timeout: "{{ [max_seconds | float(60), 5] | max }}"

          # 8. Make sure native announcements have had time to finish
          - delay: "{{ [0, clip_seconds | float(0) - (as_timestamp(now()) - t0 | float(0))] | max | round(1) }}"

          # 9. Restore each speaker
          - repeat:
              for_each: "{{ snap }}"
              sequence:
                - variables:
                    x: "{{ repeat.item }}"
                - if:
                    - condition: template
                      value_template: "{{ states(x.e) not in ['unavailable', 'unknown'] }}"
                  then:
                    # 9a. back into its group (leader re-joins the others that are still around)
                    - if:
                        - condition: template
                          value_template: >
                            {{ x.group | count > 1 and x.group[0] == x.e
                               and (state_attr(x.e, 'group_members') or []) | sort != x.group | sort }}
                      then:
                        - action: media_player.join
                          continue_on_error: true
                          target:
                            entity_id: "{{ x.e }}"
                          data:
                            group_members: >
                              {{ x.group[1:] | reject('is_state', 'unavailable') | list }}
                    # 9b. a member whose leader was not part of the announcement re-joins it
                    - if:
                        - condition: template
                          value_template: >
                            {{ x.group | count > 1 and x.group[0] != x.e and x.group[0] not in snap_ids
                               and x.e not in (state_attr(x.group[0], 'group_members') or []) }}
                      then:
                        - action: media_player.join
                          continue_on_error: true
                          target:
                            entity_id: "{{ x.group[0] }}"
                          data:
                            group_members: "{{ [x.e] }}"
                    # 9c. volume and mute
                    - if:
                        - condition: template
                          value_template: >
                            {{ x.vol is number and (x.f | bitwise_and(F_VOLUME_SET)) > 0
                               and (not x.native or native_volume == 'set') }}
                      then:
                        - action: media_player.volume_set
                          continue_on_error: true
                          target:
                            entity_id: "{{ x.e }}"
                          data:
                            volume_level: "{{ x.vol }}"
                    - if:
                        - condition: template
                          value_template: "{{ x.muted and (x.f | bitwise_and(F_MUTE)) > 0 }}"
                      then:
                        - action: media_player.volume_mute
                          continue_on_error: true
                          target:
                            entity_id: "{{ x.e }}"
                          data:
                            is_volume_muted: true
                    # 9d. what was playing (snapshot speakers only; a group member follows its leader)
                    - choose:
                        - conditions:
                            - condition: template
                              value_template: "{{ x.wake }}"
                          sequence:
                            - action: media_player.turn_off
                              continue_on_error: true
                              target:
                                entity_id: "{{ x.e }}"
                        - conditions:
                            - condition: template
                              value_template: >
                                {{ not x.native and x.s in ['playing', 'paused']
                                   and not (x.group | count > 1 and x.group[0] != x.e) }}
                          sequence:
                            - choose:
                                # a stream or track we can reload
                                - conditions:
                                    - condition: template
                                      value_template: "{{ x.cid and (x.f | bitwise_and(F_PLAY_MEDIA)) > 0 }}"
                                  sequence:
                                    - action: media_player.play_media
                                      continue_on_error: true
                                      target:
                                        entity_id: "{{ x.e }}"
                                      data:
                                        media_content_id: "{{ x.cid }}"
                                        media_content_type: "{{ x.ctype }}"
                                    - if:
                                        - condition: template
                                          value_template: "{{ x.pos is number and x.pos > 5 and (x.f | bitwise_and(F_SEEK)) > 0 }}"
                                      then:
                                        - wait_template: "{{ is_state(x.e, 'playing') }}"
                                          timeout: 5
                                        - action: media_player.media_seek
                                          continue_on_error: true
                                          target:
                                            entity_id: "{{ x.e }}"
                                          data:
                                            seek_position: "{{ x.pos | round(0) }}"
                                # an input (line in, TV, radio preset) we can switch back to
                                - conditions:
                                    - condition: template
                                      value_template: "{{ x.src and (x.f | bitwise_and(F_SELECT_SOURCE)) > 0 }}"
                                  sequence:
                                    - action: media_player.select_source
                                      continue_on_error: true
                                      target:
                                        entity_id: "{{ x.e }}"
                                      data:
                                        source: "{{ x.src }}"
                            # it was paused: leave it paused where it was
                            - if:
                                - condition: template
                                  value_template: "{{ x.s == 'paused' and (x.f | bitwise_and(F_PAUSE)) > 0 }}"
                              then:
                                - wait_template: "{{ is_state(x.e, 'playing') }}"
                                  timeout: 5
                                - action: media_player.media_pause
                                  continue_on_error: true
                                  target:
                                    entity_id: "{{ x.e }}"

          # 10. Done: clear the recovery note
          - if:
              - condition: template
                value_template: "{{ helper != '' }}"
            then:
              - action: input_text.set_value
                continue_on_error: true
                target:
                  entity_id: "{{ helper }}"
                data:
                  value: ""

Tested on real hardware

Beyond the test bench, this was run on a real HomePod mini playing through Music Assistant: the volume came up for the message, the music kept playing quietly underneath (proper ducking), the message played, and the music came back to exactly where it was with the same song still playing. That real test also caught a bug the simulated speakers never showed (Music Assistant reports some values in a format that broke the saved snapshot), and it is fixed in the current version.

One limit to know: if a speaker is getting music from another device over AirPlay (your phone or Mac playing straight to it), an announcement takes the speaker over and that device gets disconnected. Home Assistant cannot tell your phone to start again, so you would need to press play. Music started through Music Assistant comes back on its own.