Workaround: Reliable TTS / Long TTS Playback to Apple HomePods Using Pre-Generation + Local Media

Workaround: Reliable TTS / Long TTS Playback to Apple HomePods Using Pre-Generation + Local Media

TL;DR

I ran into an issue where TTS announcements—particularly longer Google AI TTS messages—would intermittently or consistently fail when sent to Apple HomePods through the Apple TV integration.

The failures included TTS generation/playback timeouts and, when attempting to work around those by pre-generating the audio, errors such as:

Streaming the requested media is not supported

I eventually found a reliable workaround:

Generate TTS completely first
        ↓
Get the generated /api/tts_proxy/...mp3 URL
        ↓
Download/copy that completed MP3 into /media
        ↓
Play it using Home Assistant's local media source
        ↓
HomePod

In other words:

Don’t make the HomePod wait for the TTS engine to generate the audio, and don’t ask the HomePod to directly consume the temporary TTS proxy URL.

Instead, pre-generate the speech, copy the resulting MP3 into Home Assistant’s local media directory, and then give the HomePod a normal media-source:// local-media object.

This has been reliable in my testing, and playback begins quickly.

This is a workaround, not necessarily a permanent solution. There are existing reports involving HomePod/AirPlay TTS failures and approximately 10-second timeouts, and there appears to be ongoing work around the underlying Apple TV/HomePod audio behavior. A future Home Assistant release may make this workaround unnecessary.


Environment Where This Was Tested

My environment at the time of testing:

Component Configuration
Hardware Home Assistant Green
Home Assistant Core 2026.8.2
Supervisor 2026.07.5
Home Assistant OS 18.2
Frontend 20260729.7
Speakers Apple HomePods
HomePod integration Apple TV integration
Network HA Green and HomePods on the same VLAN
Firewall between HA/HomePods None
HACS Not used

The network detail is important.

The HomePods and Home Assistant Green are on the same VLAN with no network restrictions between them. Therefore, in my environment this was not an inter-VLAN firewall, mDNS, multicast, or ACL problem.

The HomePods are integrated directly into Home Assistant using the standard Apple TV integration.


The Problem

I originally encountered this while building more sophisticated spoken automations.

Two examples that led me down this path were:

  1. Calendar/travel announcements — Home Assistant determines the travel time to an upcoming calendar event and announces when it is time to prepare to leave.
  2. Coffee-machine news brief — when the coffee machine starts, Home Assistant creates a personalized calendar/weather or news briefing and speaks it over a HomePod.

These aren’t necessarily short messages such as:

“The garage door is open.”

They can be relatively long, dynamically generated TTS messages.

Google AI TTS could generate excellent speech, but the complete process looked roughly like this:

Automation
    ↓
Generate TTS
    ↓
Home Assistant / pyatv
    ↓
HomePod

Longer generation could collide with the timeout behavior seen in the HA/HomePod playback path.

Interestingly, I also observed this behavior during testing:

Long TTS message

First attempt → fails

Wait ~10 seconds

Same message again → works

That was a major clue.

By the second attempt, the audio had already been generated/cached.


The First Workaround: Pre-Generate the TTS

Instead of asking tts.speak to generate and play everything synchronously, I used Home Assistant’s TTS URL API to generate the audio first.

The resulting response looked similar to:

status: 200
content:
  url: http://HOME_ASSISTANT_IP:8123/api/tts_proxy/xxxxxxxxxxxxxxxx.mp3
  path: /api/tts_proxy/xxxxxxxxxxxxxxxx.mp3

At this point, the expensive TTS-generation step was complete.

However, sending that URL directly to the HomePod with media_player.play_media resulted in:

Streaming the requested media is not supported

So pre-generation solved one problem but exposed another.


What Finally Worked

I manually copied one of those generated MP3 files into Home Assistant’s /media directory.

Then I played it using Home Assistant’s local media source:

action: media_player.play_media
target:
  entity_id: media_player.your_homepod
data:
  media_content_id: media-source://media_source/local/tts-test.mp3
  media_content_type: audio/mpeg

It played immediately.

That established a reliable path:

TTS engine
   ↓
HA TTS proxy
   ↓
Completed MP3
   ↓
/media
   ↓
HA Media Source
   ↓
Apple TV integration
   ↓
HomePod

The key difference is that the HomePod is no longer involved while the TTS audio is being generated.

By the time the HomePod receives the playback request, a complete MP3 already exists in Home Assistant’s local media storage.


Before Continuing — Important Warning

Use extreme caution before proceeding.

The following procedure involves editing configuration.yaml, creating shell scripts, changing file permissions, and executing commands within your Home Assistant environment.

Make a backup first and use Developer Tools → YAML → Check configuration before restarting Home Assistant.

Commands and filesystem paths may differ depending on your Home Assistant installation type.

Proceed at your own risk.

These examples were tested on Home Assistant OS running on Home Assistant Green. Container, Core, or other installation types may require changes, particularly around /media storage and filesystem access.


Step-by-Step Solution

Step 1 — Create a Directory for Generated Announcements

SSH into Home Assistant and create a dedicated directory:

mkdir -p /media/tts_announcements

Verify it:

ls -ld /media/tts_announcements

Why?

We want the generated speech to become a normal Home Assistant local-media object.

Keeping generated announcements in their own directory also makes troubleshooting and cleanup much easier.


Step 2 — Create a Script That Copies Generated TTS into /media

Create a directory for shell scripts if necessary:

mkdir -p /config/shell

Create the script:

nano /config/shell/copy_tts_to_media.sh

Add:

#!/bin/sh
set -eu

SOURCE_URL="$1"
FILENAME="$2"

TARGET_DIR="/media/tts_announcements"
TMP_FILE="${TARGET_DIR}/${FILENAME}.tmp"
FINAL_FILE="${TARGET_DIR}/${FILENAME}"

mkdir -p "$TARGET_DIR"

curl -fsSL \
  --connect-timeout 10 \
  --max-time 30 \
  "$SOURCE_URL" \
  -o "$TMP_FILE"

if [ ! -s "$TMP_FILE" ]; then
  echo "Downloaded TTS file is empty" >&2
  rm -f "$TMP_FILE"
  exit 1
fi

mv "$TMP_FILE" "$FINAL_FILE"

echo "$FINAL_FILE"

Save the file and make it executable:

chmod 755 /config/shell/copy_tts_to_media.sh

Why Use a Temporary File?

This is intentional:

announcement.mp3.tmp
        ↓
download completes
        ↓
announcement.mp3

The final filename doesn’t exist until the download has successfully completed.

That prevents a media player from attempting to retrieve an MP3 while it is still being written.

The final mv effectively becomes the handoff:

Downloading → not ready

Final MP3 exists → ready for playback

Step 3 — Expose the Script as a shell_command

Add this to configuration.yaml:

shell_command:
  copy_tts_to_media: >-
    /bin/sh /config/shell/copy_tts_to_media.sh
    "{{ source_url }}"
    "{{ filename }}"

Important

If you already have:

shell_command:

do not add a second shell_command: section.

Instead, add the new command beneath the existing one:

shell_command:
  existing_command: ...

  copy_tts_to_media: >-
    /bin/sh /config/shell/copy_tts_to_media.sh
    "{{ source_url }}"
    "{{ filename }}"

Why Use a Separate Script?

I initially attempted something similar to:

shell_command:
  copy_tts: >-
    mkdir -p /media/tts_announcements &&
    curl -fsSL ...

That failed unexpectedly.

Home Assistant restricts how templated shell_command commands are executed. Compound shell behavior can therefore behave differently than expected.

Putting the more complicated shell logic inside:

/config/shell/copy_tts_to_media.sh

and having shell_command invoke that script proved much more reliable.

After adding the command:

Developer Tools → YAML → Check configuration

Then reload the Shell Command integration or restart Home Assistant as appropriate.


Step 4 — Pre-Generate the TTS

The goal is to make the TTS engine finish before the HomePod becomes involved.

In my case, I used a REST command that calls Home Assistant’s TTS URL API.

Conceptually:

rest_command:
  pregenerate_tts:
    url: "http://127.0.0.1:8123/api/tts_get_url"
    method: POST
    timeout: 30
    headers:
      authorization: !secret ha_rest_bearer_token
      content-type: "application/json"
    payload: >-
      {
        "engine_id": "YOUR_TTS_ENTITY",
        "message": {{ message | tojson }},
        "cache": true,
        "language": "en-US"
      }
    content_type: "application/json"

Replace:

YOUR_TTS_ENTITY

with the appropriate TTS engine/entity for your environment.

My implementation uses Google AI TTS, but the important concept here isn’t Google specifically.

The architecture is:

Generate first
      ↓
Wait for completion
      ↓
Obtain finished audio
      ↓
Then start HomePod playback

I intentionally use:

timeout: 30

for the REST generation request because longer AI-generated speech can take more than 10 seconds.

Protect Your Token

Do not publish your bearer token directly in configuration.yaml.

Store it in secrets.yaml and reference it with:

authorization: !secret ha_rest_bearer_token

Step 5 — Read the Generated TTS URL

The response variable should contain something resembling:

status: 200
content:
  url: http://HOME_ASSISTANT_IP:8123/api/tts_proxy/xxxxxxxx.mp3
  path: /api/tts_proxy/xxxxxxxx.mp3

An important discovery during troubleshooting:

content may already be a parsed object.

I originally attempted:

{{ (tts_generation.content | from_json).url }}

That failed with:

Template error: from_json got invalid input

because Home Assistant had already parsed the response.

Instead:

- variables:
    tts_ok: >
      {{
        tts_generation is defined
        and
        tts_generation.status | int(0) == 200
        and
        tts_generation.content is defined
        and
        tts_generation.content.url | default('') | length > 0
      }}

    tts_url: >
      {% if tts_ok %}
        {{ tts_generation.content.url }}
      {% else %}
        {{ '' }}
      {% endif %}

Step 6 — Copy the Completed MP3 into Local Media

After TTS generation succeeds:

- action: shell_command.copy_tts_to_media
  data:
    source_url: "{{ tts_url }}"
    filename: "announcement.mp3"
  response_variable: copy_result

A successful result should resemble:

stdout: /media/tts_announcements/announcement.mp3
stderr: ""
returncode: 0

I recommend adding a small delay before playback:

- delay:
    seconds: 1

This isn’t necessarily required on every system, but it provides a small buffer between the completed file operation and the media request.


Step 7 — Play the Local Media File

Now give the HomePod the Home Assistant Media Source URI instead of the TTS proxy URL:

- action: media_player.play_media
  target:
    entity_id: media_player.your_homepod
  data:
    media_content_id: >
      media-source://media_source/local/tts_announcements/announcement.mp3
    media_content_type: audio/mpeg

That media-source URI corresponds to:

/media/tts_announcements/announcement.mp3

In my testing, this was the important breakthrough.

The HomePod retrieved and played the local-media MP3 quickly.


Step 8 — Use Unique Filenames

For dynamic automations, I recommend not continually overwriting the same file.

For example:

- variables:
    tts_filename: >
      announcement_{{ now().strftime('%Y%m%d_%H%M%S') }}.mp3

Then:

- action: shell_command.copy_tts_to_media
  data:
    source_url: "{{ tts_url }}"
    filename: "{{ tts_filename }}"

And:

- action: media_player.play_media
  target:
    entity_id: media_player.your_homepod
  data:
    media_content_id: >
      media-source://media_source/local/tts_announcements/{{ tts_filename }}
    media_content_type: audio/mpeg

This avoids potential cache collisions and makes troubleshooting easier because you can see exactly which generated file belongs to which automation execution.


Step 9 — Clean Up Old Generated Files

You probably don’t want hundreds of generated announcements accumulating indefinitely.

Create:

nano /config/shell/cleanup_tts_media.sh

Add:

#!/bin/sh
set -eu

TARGET_DIR="/media/tts_announcements"

mkdir -p "$TARGET_DIR"

find "$TARGET_DIR" -type f \
  \( -name "*.mp3" -o -name "*.tmp" \) \
  -delete

Make it executable:

chmod 755 /config/shell/cleanup_tts_media.sh

Then add another shell command:

shell_command:
  cleanup_tts_media: >-
    /bin/sh /config/shell/cleanup_tts_media.sh

Again, if you already have a shell_command: section, add this beneath it rather than creating another one.

You can call the cleanup command from a nightly automation or use whatever retention schedule makes sense for your environment.


Complete Automation Pattern

The important portion of an automation ends up looking roughly like this:

- variables:
    announcement: >
      This is a longer dynamically generated Home Assistant
      announcement that I want to play on my HomePod.

    tts_filename: >
      announcement_{{ now().strftime('%Y%m%d_%H%M%S') }}.mp3

- action: rest_command.pregenerate_tts
  data:
    message: "{{ announcement }}"
  response_variable: tts_generation

- variables:
    tts_ok: >
      {{
        tts_generation is defined
        and
        tts_generation.status | int(0) == 200
        and
        tts_generation.content is defined
        and
        tts_generation.content.url | default('') | length > 0
      }}

    tts_url: >
      {% if tts_ok %}
        {{ tts_generation.content.url }}
      {% else %}
        {{ '' }}
      {% endif %}

- choose:
    - conditions:
        - condition: template
          value_template: "{{ tts_ok }}"

      sequence:
        - action: shell_command.copy_tts_to_media
          data:
            source_url: "{{ tts_url }}"
            filename: "{{ tts_filename }}"
          response_variable: copy_result

        - delay:
            seconds: 1

        - action: media_player.play_media
          target:
            entity_id: media_player.your_homepod
          data:
            media_content_id: >
              media-source://media_source/local/tts_announcements/{{ tts_filename }}
            media_content_type: audio/mpeg

The full architecture is therefore:

┌──────────────────────────────┐
│ Automation builds TTS text   │
└──────────────┬───────────────┘
               ↓
┌──────────────────────────────┐
│ TTS generation completes     │
│ before HomePod is involved   │
└──────────────┬───────────────┘
               ↓
┌──────────────────────────────┐
│ /api/tts_proxy/...mp3        │
└──────────────┬───────────────┘
               ↓
┌──────────────────────────────┐
│ shell_command + curl         │
│ downloads completed MP3      │
└──────────────┬───────────────┘
               ↓
┌──────────────────────────────┐
│ /media/tts_announcements/    │
│ announcement_xxx.mp3         │
└──────────────┬───────────────┘
               ↓
┌──────────────────────────────┐
│ HA Media Source              │
│ media-source://...           │
└──────────────┬───────────────┘
               ↓
┌──────────────────────────────┐
│ Apple TV integration         │
└──────────────┬───────────────┘
               ↓
          🔊 HomePod

Manual Testing / Troubleshooting

I strongly recommend testing each layer independently before putting this into a large automation.

This made troubleshooting considerably easier for me.

Test 1 — Confirm /media Works

Put a known-good MP3 in:

/media/tts_announcements/test.mp3

Verify:

ls -lh /media/tts_announcements/test.mp3

You should also be able to locate local media through Home Assistant’s Media interface.


Test 2 — Play the MP3 Manually

Go to:

Developer Tools → Actions

Run:

action: media_player.play_media
target:
  entity_id: media_player.your_homepod
data:
  media_content_id: >
    media-source://media_source/local/tts_announcements/test.mp3
  media_content_type: audio/mpeg

If this fails, stop here.

At this point the problem isn’t TTS generation.

Verify:

  • Apple TV integration
  • HomePod connectivity
  • Local Media configuration
  • MP3 compatibility
  • Home Assistant’s ability to expose /media

This test is important because it isolates the HomePod/local-media portion from everything else.


Test 3 — Generate TTS Without Playing It

Run:

action: rest_command.pregenerate_tts
data:
  message: >
    This is a test of pre-generated text to speech.
response_variable: test_tts

You want a response resembling:

status: 200
content:
  url: http://.../api/tts_proxy/....mp3

If you don’t get this, troubleshoot the TTS-generation layer before continuing.


Test 4 — Copy the Generated File

Take the generated URL and run:

action: shell_command.copy_tts_to_media
data:
  source_url: "YOUR_GENERATED_TTS_URL"
  filename: "tts-test.mp3"
response_variable: copy_test

Expected:

stdout: /media/tts_announcements/tts-test.mp3
stderr: ""
returncode: 0

SSH into Home Assistant:

ls -lh /media/tts_announcements/

You should see:

tts-test.mp3

You can also inspect it:

file /media/tts_announcements/tts-test.mp3

Test 5 — Play the Copied File

Finally:

action: media_player.play_media
target:
  entity_id: media_player.your_homepod
data:
  media_content_id: >
    media-source://media_source/local/tts_announcements/tts-test.mp3
  media_content_type: audio/mpeg

If this works, you have independently proven:

TTS generation       ✓
TTS proxy output     ✓
Shell command        ✓
curl download        ✓
/media storage       ✓
Media Source         ✓
Apple TV integration ✓
HomePod playback     ✓

At that point, putting the pieces together in an automation is relatively straightforward.


Useful Debugging

For Apple TV/HomePod troubleshooting, additional logging can be useful:

logger:
  logs:
    pyatv: debug
    homeassistant.components.apple_tv: debug

For the copy process, inspect the shell_command response:

stdout:
stderr:
returncode:

A successful copy should have:

returncode: 0

You can also SSH into Home Assistant and watch the media directory while testing:

watch -n 1 'ls -lh /media/tts_announcements'

During generation/copying, you may briefly see:

announcement_xxx.mp3.tmp

followed by:

announcement_xxx.mp3

That confirms the atomic copy process is doing what it is supposed to do.


Why I Think This Works

This section is my technical interpretation based on testing rather than a claim about the exact root cause inside Home Assistant or pyatv.

The problematic path appears to couple several operations:

Generate potentially slow TTS
        +
make that stream available
        +
initialize AirPlay
        +
HomePod retrieves/decodes it

within a relatively time-sensitive operation.

The behavior I observed strongly suggested that TTS generation time was an important factor:

First long-message attempt → fails

Same message shortly afterward → works

The workaround deliberately decouples the operations.

Phase 1 — Generate

Generate audio
Wait for completion
Confirm generation succeeded

Phase 2 — Stage

Download the finished MP3
Verify the file isn't empty
Atomically move it into /media

Phase 3 — Play

Give the HomePod a normal,
already-existing local-media object

The HomePod therefore doesn’t participate until there is a complete MP3 waiting for it.

In my testing, playback from /media was fast and reliable.


Final Notes

This is a workaround, not necessarily the permanent solution.

There has been ongoing work around Home Assistant’s Apple TV/HomePod audio behavior, so future versions of Home Assistant and/or pyatv may make this unnecessary.

That said, I think this architecture has value even beyond this particular bug.

It deliberately separates:

Speech generation

from:

Speaker playback

For longer dynamic announcements—AI summaries, calendar/travel announcements, morning briefings, news summaries, weather briefings, etc.—that separation can make automations easier to troubleshoot and more deterministic.

It also gives you a very clear troubleshooting boundary:

Did TTS generate successfully?

Did the MP3 copy successfully?

Can HA Local Media serve it?

Can the HomePod play it?

Rather than troubleshooting all four things simultaneously.

I hope this helps anyone running into the same HomePod/TTS behavior. If you’re testing this on a different HA installation type, TTS engine, or Apple device, it would be useful to hear whether the same approach works there as well.


Useful Home Assistant Documentation

I had the same issue with my HomePod and created an integration, Chime TTS which solves the problem in the same way as you described and also adds a lot of announcement and TTS generation features on top.
Perhaps you might also benefit from it.