Skip to content

[Bug] Audiobook Streaming Delay (audioBook.lcpl) – Chunks Fully Buffered Before Playback Starts #579

Closed

Description

@mohamedAlhadadi

Describe the bug

Hello Readium team,

First of all, thank you for the recent update enabling progressive streaming for LCP-protected audiobooks — it’s a highly valuable addition, and we appreciate the continued effort.

However, while implementing this feature in our iOS app using the swift-toolkit, we encountered a couple of issues that are affecting the user experience:

1. Delayed Playback with Large Audiobooks

We've noticed that even though the content is streamed in chunks (each of 6MB), the audio does not begin playing until all chunks have been fully downloaded into the buffer.
This behavior is particularly noticeable with large audiobooks that have a single-chapter structure, where the full download can take a significant amount of time, causing a delay before playback begins.

We were expecting playback to start after the first chunk or two becomes available, with the remaining chunks being fetched progressively as needed during playback, rather than all at once upfront.
However, it seems that BufferingResource waits for the entire file to be fully buffered before playback begins.


2. Playback State During Buffering

During this initial buffering period, the playback model returns .playing even though the audio hasn’t started yet.
Ideally, we’d expect a .loading or .buffering state so we can show a loading indicator or spinner to inform the user that playback is still being prepared.


❓ Questions:

  • Is this buffering behavior expected in the current implementation?
  • Is there any internal flag or value that we can use to detect when the audio is still buffering, even if model.playback.state == .playing?
  • Are there any recommended practices to improve the responsiveness of playback and the user feedback during this buffering stage?

Please don't hesitate to ask if you need any additional information to help reproduce or better understand the issue — I’d be happy to provide whatever is needed.

Best regards

How to reproduce?

  1. Open the test app with a .lcpl audiobook file that:
  • Is LCP-protected
  • Has a large file size
  • Has a single chapter

Readium version

3.2.0

OS version

17.2

Testing device

iPhone 15 Pro

Activity

  1. mickael-menu commented on Apr 23, 2025

    @mickael-menu
    Member

    Is this buffering behavior expected in the current implementation?

    Maybe? I agree that is less than ideal and I spent some time investigating this issue without success for now.

    We are using the native AVPlayer with a custom AVAssetResourceLoaderDelegate. What I observed is that most of the time, and despite declaring support for byte range requests, the resource loader will request the full length of the resource and will start the playback only when everything is loaded.

    From what I gathered, it's could be caused by the length of the resource being under a certain length threshold, and caused by the file format of the resource. As it's closed source and the documentation is sparse, I don't have definite answers.

    Maybe there's a solution or at least a workaround. If you want to take a stab at it, the relevant implementation is here:

    // MARK: - AVAssetResourceLoaderDelegate

    And here are some additional resources that could be useful:

    Is there any internal flag or value that we can use to detect when the audio is still buffering, even if model.playback.state == .playing?

    Not that I know of, but I didn't touch this code for a while. Here again we are relying on AVPlayer and are directly reporting its internal status:

    private extension MediaPlaybackState {
    init(_ timeControlStatus: AVPlayer.TimeControlStatus) {
    switch timeControlStatus {
    case .paused:
    self = .paused
    case .waitingToPlayAtSpecifiedRate:
    self = .loading
    case .playing:
    self = .playing
    @unknown default:
    self = .loading
    }
    }
    }

    Theoretically we should be in the state waitingToPlayAtSpecifiedRate. Maybe there's an event that we don't observe and that is missing, or an issue in AVPlayer itself. I guess we could also be smarter and check for the playback rate. It think it should be 0 when it's not really playing.

    If you would like to give this a try, it would greatly be appreciated! Please feel free to discuss anything here if you need help.

  2. RomanSoviak commented on Jul 8, 2026

    @RomanSoviak

    I can confirm this is still present in 3.9.0 (iOS, physical device, not simulator).

    In my case it's an LCP audiobook with 3 tracks (~6-8MB each, MP3, stored uncompressed
    in the .audiobook zip), streamed directly from a .lcpl via PublicationOpener
    following the documented streaming guide.

    Symptom matches exactly: playbackDidChange reports state: playing immediately
    after navigator.play(), but info.time never advances from 0.0, and no error is
    ever surfaced (checked .AVPlayerItemNewErrorLogEntry — empty).

    I spent some time ruling out server-side causes, which might be useful context:

    • Confirmed via curl that the origin server correctly handles HEAD, forward
      Range, suffix Range (bytes=-1024), and an over-range request clamped to the
      actual file size — all return correct 206 Partial Content with accurate
      Content-Range.
    • Confirmed audio tracks are stored uncompressed in the zip (zip.Store, verified
      via unzip -v).
    • Added the (previously missing) properties.encrypted.original_length and
      compression fields to the manifest — no change in behavior.
    • Ruled out URLCache / client-side caching (cleared cache, reinstalled the app).

    This points pretty strongly at the resource loader always requesting the full length
    before playback starts, as you suspected, rather than anything server/manifest related.

  3. mickael-menu commented on Jul 10, 2026

    @mickael-menu
    Member

    @RomanSoviak Would you be able to check out the fix-issue-579 branch to see if it fixes the problems for you? Thank you!

  4. mickael-menu commented on Jul 21, 2026

    @mickael-menu
    Member

    @RomanSoviak Did you have a chance to try this out?

  5. mauricecarrier7 commented on Jul 31, 2026

    @mauricecarrier7

    @mickael-menu

    Not Roman, but we hit this in Palace and tested fix-issue-579. It works. Measurements below in case they are useful.

    One piece of context worth passing on: our app falls back to downloading the whole .lcpa when streaming fails, so for a while this looked like slowness rather than a bug. That kind of fallback may be masking this for other consumers too.

    Setup. LCP audiobook, 37 MP3 tracks, roughly 1.2 GB, all STORED, AES-256-CBC. We use our own AVAssetResourceLoaderDelegate doing ranged reads against the Resource from PublicationOpener, so this exercises the LCP decryptor and ZIP container range path, not the navigator.

    Method. Three runs, identical app code and instrumentation, same book, same starting state, same tap. Only the swift-toolkit pin changed, and we verified the resolved version before each run.

    3.9.0 3.11.0 fix-issue-579
    loader requests 13 4 5
    request pattern offset=0 len=2 x13 offset=0 len=2 x4 2 byte probe, then advancing ranges
    bytes served to AVPlayer 0 0 32,320,052
    time to first byte never never 1.27 s
    local archive on disk present present none

    On 3.9.0 and 3.11.0 the opening 2 byte probe is never satisfied, so AVPlayer never escalates to a real range. It reissues the same 2 byte read and playback never starts, which matches Roman's state: playing with info.time stuck at 0.0. On the branch the probe returns, ranges advance, and audio decrypts and plays.

    The last row is what decides it. In the branch run there was no .lcpa on disk at any point, so every byte served came off the network.

    Edit: we have since confirmed this end to end on a physical device (iPhone 17 Pro Max, iOS 26.5.2), with the app's background archive fetch disabled outright so no local copy could exist. The audiobook plays, audibly, streaming only. The numbers above were captured at the byte level, so we wanted to close that gap before you acted on them.

    Caveats.

    • On the 3.9.0 and 3.11.0 runs, a background archive fetch we did not fully suppress did land an archive on disk. Zero bytes reached the player either way, so the negative result holds, but those two runs were not perfectly clean.
    • The 32 MB is far more than needed. That is our delegate asking for toEnd uncapped, not branch behavior.
    • We tested the branch as a whole and did not bisect.
    • Your original read, that the loader asks for the full resource length despite declaring range support, still reproduces on the branch. The requests are toEnd=true. The difference is that they get served now. So it unblocks playback without removing the pattern you described.

    Happy to bisect, re-run against a specific commit, or share raw logs if that helps.

  6. mickael-menu commented on Aug 5, 2026

    @mickael-menu
    Member

    Thank you for the feedback @mauricecarrier7. Unfortunately this is too late to be fixed in v3, but it should be available in one of the upcoming v4 alpha/beta releases.

  7. mickael-menu commented on Sep 11, 2026

    @mickael-menu
    Member

    @mauricecarrier7 Would you mind checking on your side one last time before I merge the PR into develop? I reviewed it and rebased to develop. Thank you!

    #880

  8. mauricecarrier7 commented on Sep 14, 2026

    @mauricecarrier7

    @mickael-menu

    We re-validated LCP audiobook streaming against the rebased head (097b77d) on iOS.
    Fulfilment leaves only the license, 2,569 bytes, no .lcpa, and playback starts from it directly. We exercised cancellation mid-stream specifically: repeated seeks across a chapter boundary, backgrounding, and closing the player during playback, with no spurious errors or stalls and no crashes. Looks good to me!

  9. mickael-menu commented on Sep 14, 2026

    @mickael-menu
    Member

    Thanks, I appreciated it! It's merged.

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

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions