How to Do Sample-Accurate AVPlayer Audio Scrubbing in Reverse

How to Do Sample-Accurate AVPlayer Audio Scrubbing in Reverse

Written by

in

Quick answer: AVPlayer is a playback engine, not a sample-accurate transport: it decodes ahead, drops frames, and does not decode audio in reverse. For accurate scrubbing in both directions, decode the stream to PCM and play it yourself with AVAudioEngine and AVAudioPlayerNode, optionally sharing one download through AVAssetResourceLoaderDelegate.

Why this happens

AVPlayer is a playback engine. Its job is to keep audio and video running smoothly, not to act like a tape head you can drag. It buffers ahead, decodes in chunks, and may drop or repeat frames when you change rate. The time-pitch algorithms (.spectral, .varispeed, .timeDomain) are built for changing playback speed; they do not guarantee that the audio you hear at a given moment matches the exact playhead position during fast scrubbing.

Reverse is a separate problem. AVPlayer can accept negative rates for some assets, but it does not decode audio samples in reverse. Reverse audio means reading PCM frames from the end of a segment to the beginning and presenting them in that order. AVPlayer does not expose that operation.

So if you need sample-accurate scrubbing, correct audio at any scrub speed, reverse audio when dragging backwards, and streamed assets, you are outside AVPlayer’s design goals. You need a PCM path.

How to fix it

1. Split normal playback from scrubbing

Use AVPlayer for normal playback if it works for you. Use a separate PCM renderer for scrubbing. Trying to make one AVPlayer do both leads to rate hacks and timing bugs.

The PCM path is: network bytes -> decoder -> AVAudioPCMBuffer -> AVAudioEngine + AVAudioPlayerNode. When the user scrubs, stop the player node, schedule the buffer for the target window, and play it. When the user releases, return to AVPlayer at the final position.

2. Decode the stream into PCM buffers

For local files, AVAssetReader with an AVAssetReaderTrackOutput configured for LPCM is the usual route. For a stream, you need the bytes first. The source material uses AudioFileOpenWithCallbacks from AudioToolbox to decode raw bytes into an AVAudioPCMBuffer. That is a valid approach when you already have the bytes in memory or on disk.

Keep the decoded window small. You do not need the whole track in PCM. Decode a window around the playhead, play it, and discard it when the playhead moves on. This keeps memory bounded and avoids a second full download.

3. Reverse the PCM samples for backward scrubbing

Reverse playback is a buffer operation. Take the decoded segment, copy the frames in reverse order per channel, and schedule the reversed buffer.

import AVFoundation

func reversedBuffer(from buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
    guard let source = buffer.floatChannelData,
          let reversed = AVAudioPCMBuffer(
            pcmFormat: buffer.format,
            frameCapacity: buffer.frameLength
          ),
          let destination = reversed.floatChannelData else {
        return nil
    }

    reversed.frameLength = buffer.frameLength
    let frameCount = Int(buffer.frameLength)
    let channelCount = Int(buffer.format.channelCount)

    for channel in 0..<channelCount {
        for frame in 0..<frameCount {
            destination[channel][frame] = source[channel][frameCount - 1 - frame]
        }
    }

    return reversed
}

This assumes a non-interleaved Float32 format, which is what AVAudioEngine works with most naturally. If your decoder produces another format, convert it first with AVAudioConverter.

Then schedule the segment:

func playScrubSegment(_ buffer: AVAudioPCMBuffer, backwards: Bool) {
    guard let segment = backwards ? reversedBuffer(from: buffer) : buffer else { return }

    playerNode.stop()
    playerNode.scheduleBuffer(segment, at: nil, options: .interrupts)
    playerNode.play()
}

playerNode.stop() clears anything already queued. That is what you want during scrubbing: the latest playhead wins. Make sure the engine is running before you call play().

For a backward drag, decode a window that ends at the playhead, reverse it, and schedule it. For a forward drag, decode a window that starts at the playhead.

4. Share one download with AVAssetResourceLoaderDelegate

If you still want AVPlayer for normal playback, you can avoid two independent downloads by implementing AVAssetResourceLoaderDelegate. Point an AVURLAsset at a custom URL scheme, set the resource loader delegate, and fetch the bytes yourself. Feed those same bytes to AVPlayer’s loading request and to your PCM cache.

final class SharedResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
    func resourceLoader(
        _ resourceLoader: AVAssetResourceLoader,
        shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
    ) -> Bool {
        // Start a URLSession task for loadingRequest.request.url,
        // write bytes into loadingRequest.dataRequest,
        // and call loadingRequest.finishLoading() when done.
        return true
    }
}

This is an optimization, not a requirement. It adds complexity: you must handle content information requests, byte ranges, cancellation, and errors. If normal playback can use your PCM path too, skip the resource loader and keep one code path.

5. Consider a lower-level Audio Unit only if needed

AVAudioEngine + AVAudioPlayerNode is enough for most scrubbing UIs. If you need tighter control over scheduling or real-time sample generation, an AUAudioUnit render callback gives you direct access to the render loop. Start with AVAudioEngine; move lower only when you have a measured problem.

How to prevent it

  • Do not use AVPlayer.rate as a scrub-position API. It is a playback speed control.
  • Treat reverse audio as a separate rendering path, not a negative rate.
  • Decode only a window around the playhead; do not cache the whole stream in PCM.
  • Stop and reschedule the player node on every scrub update so stale audio cannot play.
  • Keep one network source if you use both AVPlayer and a PCM cache.
  • Test rapid direction changes, not just slow drags.

FAQ

Can AVPlayer play audio backwards?

Not by decoding samples in reverse. It may accept a negative rate for some assets, but that is not the same as sample-accurate reverse playback. For true reverse audio, reverse the PCM buffer yourself.

Do I have to use AVAssetResourceLoaderDelegate?

No. It only helps when you want AVPlayer and your PCM cache to consume the same download. If you render all audio through AVAudioEngine, you can use a single downloader and skip the resource loader.

Can I use AVAssetReader instead of AudioToolbox?

Yes for local files or assets that are already downloaded. For a stream, you need bytes available to the decoder, so you either download them yourself or intercept AVPlayer’s requests. AVAssetReader is not a streaming downloader.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.