Skip to content

RAOP: an HTTP source costs a fixed ~10 s before first audio once it exceeds the buffer (StreamableSourceWrapper.seek ignores relative seeks) #2910

Description

@superfeldy

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.

  1. 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.

  2. 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.

  1. 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.

  1. 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:

  1. 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.
  2. 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.
  3. 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions