Summary
Streaming an HTTP URL to a RAOP device via stream_file() costs a fixed ~10 s before the first audio sample, as soon as the source is larger than roughly 56 KB. The same audio as a local file starts in 0.01 s. The cost is constant, not proportional to size, and it is paid before playback every time. It also logs Failed to parse metadata on the root logger, so downstream it appears as a bare ERROR [root] with no indication of origin.
I hit this streaming short TTS clips (WAV/FLAC/MP3) to a HomePod from Home Assistant. Anything long enough to matter — a full sentence — exceeded the buffer and paid the 10 s, which for a spoken alert is the difference between "now" and "too late".
Environment
- pyatv 0.18.0, Python 3.14
- Home Assistant 2026.9.1 (
apple_tv), streaming from its own tts_proxy over HTTP
- Device: HomePod (RAOP)
Mechanism
All line numbers are 0.18.0, pyatv/protocols/raop/audio_source.py unless noted.
-
InternetSource.open (:574) buffers into SemiSeekableBuffer(BUFFER_SIZE, seekable_headroom=HEADROOM_SIZE, protected_headroom=True) — BUFFER_SIZE = 64 * 1024, HEADROOM_SIZE = 32 * 1024 (:32-33) — and parses metadata before releasing the headroom (buffer.protected_headroom = False, :595). While the headroom is protected the buffer will not discard, so the download thread parks once it is full.
-
Metadata parsing goes through StreamableSourceWrapper (:213), whose seek (:234) is:
def seek(self, pos, origin=io.SEEK_SET):
"""Seek to position in stream."""
if origin == io.SEEK_SET:
self.source.seek(pos, miniaudio.SeekOrigin.START)
return self.buffer.position
For io.SEEK_CUR / io.SEEK_END this does nothing at all, yet still returns self.buffer.position — a value that reads as a successful seek. TinyTag therefore believes it skipped a chunk when the position never moved, and its parse degenerates into reading the file linearly.
- That linear read runs past what the parked 64 KiB buffer can supply, so
PatchedIceCastClient.read (:483) enters its poll:
# TODO: Should not be based on polling
while len(self._buffer) < num_bytes and not self._stop_stream:
if time.monotonic() - start_time > DEFAULT_TIMEOUT: # 10.0 s, :30
raise OperationTimeoutError("timed out reading from stream")
time.sleep(0.1)
The read can never be satisfied, so it always costs the full DEFAULT_TIMEOUT.
get_buffered_io_metadata (:253) catches it with except Exception: and logging.exception("Failed to parse metadata") — the root logger — and playback then proceeds without metadata.
get_metadata (pyatv/support/metadata.py:21) already carries the related TODO: "TinyTag will always start by seeking to the end of a file, which isn't possible for streaming buffers. So this works as long as the entire file is in the buffer, otherwise it will fail." What seems not to be recorded is that failing is not cheap — it costs a fixed 10 s in front of playback — and that the silently-ignored relative seek is what turns "cannot seek" into "walk the entire file".
Checked and not a problem: the blocking poll does not run on the caller's event loop. get_metadata offloads through loop.run_in_executor (support/metadata.py:31-33), so it occupies a thread-pool thread.
Suggested fixes
Any one of these removes the 10 s:
- Make
StreamableSourceWrapper.seek honest — implement SEEK_CUR/SEEK_END where the buffer allows it, and otherwise signal failure instead of returning a position that implies success. TinyTag then gives up immediately rather than walking the file.
- Give metadata parsing its own short budget, separate from the stream timeout, or skip it when the source is not fully buffered (per the existing TODO). Metadata is optional; first audio is not.
- Log through the module logger rather than
logging.exception on root, so the failure is attributable.
Workaround, for anyone else hitting this
Don't hand pyatv a URL. Write the audio to a local file and pass that path, so stream_file takes the FileSource branch (a single miniaudio.decode_file, no poll loop). First audio went from ~11 s to ~3 s end to end for us, and the Failed to parse metadata errors stopped.
Happy to test a patch against a HomePod.
Summary
Streaming an HTTP URL to a RAOP device via
stream_file()costs a fixed ~10 s before the first audio sample, as soon as the source is larger than roughly 56 KB. The same audio as a local file starts in 0.01 s. The cost is constant, not proportional to size, and it is paid before playback every time. It also logsFailed to parse metadataon the root logger, so downstream it appears as a bareERROR [root]with no indication of origin.I hit this streaming short TTS clips (WAV/FLAC/MP3) to a HomePod from Home Assistant. Anything long enough to matter — a full sentence — exceeded the buffer and paid the 10 s, which for a spoken alert is the difference between "now" and "too late".
Environment
apple_tv), streaming from its owntts_proxyover HTTPMechanism
All line numbers are 0.18.0,
pyatv/protocols/raop/audio_source.pyunless noted.InternetSource.open(:574) buffers intoSemiSeekableBuffer(BUFFER_SIZE, seekable_headroom=HEADROOM_SIZE, protected_headroom=True)—BUFFER_SIZE = 64 * 1024,HEADROOM_SIZE = 32 * 1024(:32-33) — and parses metadata before releasing the headroom (buffer.protected_headroom = False, :595). While the headroom is protected the buffer will not discard, so the download thread parks once it is full.Metadata parsing goes through
StreamableSourceWrapper(:213), whoseseek(:234) is:For
io.SEEK_CUR/io.SEEK_ENDthis does nothing at all, yet still returnsself.buffer.position— a value that reads as a successful seek. TinyTag therefore believes it skipped a chunk when the position never moved, and its parse degenerates into reading the file linearly.PatchedIceCastClient.read(:483) enters its poll:The read can never be satisfied, so it always costs the full
DEFAULT_TIMEOUT.get_buffered_io_metadata(:253) catches it withexcept Exception:andlogging.exception("Failed to parse metadata")— the root logger — and playback then proceeds without metadata.get_metadata(pyatv/support/metadata.py:21) already carries the related TODO: "TinyTag will always start by seeking to the end of a file, which isn't possible for streaming buffers. So this works as long as the entire file is in the buffer, otherwise it will fail." What seems not to be recorded is that failing is not cheap — it costs a fixed 10 s in front of playback — and that the silently-ignored relative seek is what turns "cannot seek" into "walk the entire file".Checked and not a problem: the blocking poll does not run on the caller's event loop.
get_metadataoffloads throughloop.run_in_executor(support/metadata.py:31-33), so it occupies a thread-pool thread.Suggested fixes
Any one of these removes the 10 s:
StreamableSourceWrapper.seekhonest — implementSEEK_CUR/SEEK_ENDwhere the buffer allows it, and otherwise signal failure instead of returning a position that implies success. TinyTag then gives up immediately rather than walking the file.logging.exceptionon root, so the failure is attributable.Workaround, for anyone else hitting this
Don't hand pyatv a URL. Write the audio to a local file and pass that path, so
stream_filetakes theFileSourcebranch (a singleminiaudio.decode_file, no poll loop). First audio went from ~11 s to ~3 s end to end for us, and theFailed to parse metadataerrors stopped.Happy to test a patch against a HomePod.