AudioTimestampPoller.java

/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package androidx.media3.exoplayer.audio;

import static java.lang.annotation.ElementType.TYPE_USE;

import android.annotation.TargetApi;
import android.media.AudioTimestamp;
import android.media.AudioTrack;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.media3.common.C;
import androidx.media3.common.util.Util;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Polls the {@link AudioTrack} timestamp, if the platform supports it, taking care of polling at
 * the appropriate rate to detect when the timestamp starts to advance.
 *
 * <p>When the audio track isn't paused, call {@link #maybePollTimestamp(long)} regularly to check
 * for timestamp updates. If it returns {@code true}, call {@link #getTimestampPositionFrames()} and
 * {@link #getTimestampSystemTimeUs()} to access the updated timestamp, then call {@link
 * #acceptTimestamp()} or {@link #rejectTimestamp()} to accept or reject it.
 *
 * <p>If {@link #hasTimestamp()} returns {@code true}, call {@link #getTimestampSystemTimeUs()} to
 * get the system time at which the latest timestamp was sampled and {@link
 * #getTimestampPositionFrames()} to get its position in frames. If {@link #hasAdvancingTimestamp()}
 * returns {@code true}, the caller should assume that the timestamp has been increasing in real
 * time since it was sampled. Otherwise, it may be stationary.
 *
 * <p>Call {@link #reset()} when pausing or resuming the track.
 */
/* package */ final class AudioTimestampPoller {

  /** Timestamp polling states. */
  @Documented
  @Retention(RetentionPolicy.SOURCE)
  @Target(TYPE_USE)
  @IntDef({
    STATE_INITIALIZING,
    STATE_TIMESTAMP,
    STATE_TIMESTAMP_ADVANCING,
    STATE_NO_TIMESTAMP,
    STATE_ERROR
  })
  private @interface State {}
  /** State when first initializing. */
  private static final int STATE_INITIALIZING = 0;
  /** State when we have a timestamp and we don't know if it's advancing. */
  private static final int STATE_TIMESTAMP = 1;
  /** State when we have a timestamp and we know it is advancing. */
  private static final int STATE_TIMESTAMP_ADVANCING = 2;
  /** State when the no timestamp is available. */
  private static final int STATE_NO_TIMESTAMP = 3;
  /** State when the last timestamp was rejected as invalid. */
  private static final int STATE_ERROR = 4;

  /** The polling interval for {@link #STATE_INITIALIZING} and {@link #STATE_TIMESTAMP}. */
  private static final int FAST_POLL_INTERVAL_US = 10_000;
  /**
   * The polling interval for {@link #STATE_TIMESTAMP_ADVANCING} and {@link #STATE_NO_TIMESTAMP}.
   */
  private static final int SLOW_POLL_INTERVAL_US = 10_000_000;
  /** The polling interval for {@link #STATE_ERROR}. */
  private static final int ERROR_POLL_INTERVAL_US = 500_000;

  /**
   * The minimum duration to remain in {@link #STATE_INITIALIZING} if no timestamps are being
   * returned before transitioning to {@link #STATE_NO_TIMESTAMP}.
   */
  private static final int INITIALIZING_DURATION_US = 500_000;

  @Nullable private final AudioTimestampV19 audioTimestamp;

  private @State int state;
  private long initializeSystemTimeUs;
  private long sampleIntervalUs;
  private long lastTimestampSampleTimeUs;
  private long initialTimestampPositionFrames;

  /**
   * Creates a new audio timestamp poller.
   *
   * @param audioTrack The audio track that will provide timestamps, if the platform supports it.
   */
  public AudioTimestampPoller(AudioTrack audioTrack) {
    if (Util.SDK_INT >= 19) {
      audioTimestamp = new AudioTimestampV19(audioTrack);
      reset();
    } else {
      audioTimestamp = null;
      updateState(STATE_NO_TIMESTAMP);
    }
  }

  /**
   * Polls the timestamp if required and returns whether it was updated. If {@code true}, the latest
   * timestamp is available via {@link #getTimestampSystemTimeUs()} and {@link
   * #getTimestampPositionFrames()}, and the caller should call {@link #acceptTimestamp()} if the
   * timestamp was valid, or {@link #rejectTimestamp()} otherwise. The values returned by {@link
   * #hasTimestamp()} and {@link #hasAdvancingTimestamp()} may be updated.
   *
   * @param systemTimeUs The current system time, in microseconds.
   * @return Whether the timestamp was updated.
   */
  @TargetApi(19) // audioTimestamp will be null if Util.SDK_INT < 19.
  public boolean maybePollTimestamp(long systemTimeUs) {
    if (audioTimestamp == null || (systemTimeUs - lastTimestampSampleTimeUs) < sampleIntervalUs) {
      return false;
    }
    lastTimestampSampleTimeUs = systemTimeUs;
    boolean updatedTimestamp = audioTimestamp.maybeUpdateTimestamp();
    switch (state) {
      case STATE_INITIALIZING:
        if (updatedTimestamp) {
          if (audioTimestamp.getTimestampSystemTimeUs() >= initializeSystemTimeUs) {
            // We have an initial timestamp, but don't know if it's advancing yet.
            initialTimestampPositionFrames = audioTimestamp.getTimestampPositionFrames();
            updateState(STATE_TIMESTAMP);
          } else {
            // Drop the timestamp, as it was sampled before the last reset.
            updatedTimestamp = false;
          }
        } else if (systemTimeUs - initializeSystemTimeUs > INITIALIZING_DURATION_US) {
          // We haven't received a timestamp for a while, so they probably aren't available for the
          // current audio route. Poll infrequently in case the route changes later.
          // TODO: Ideally we should listen for audio route changes in order to detect when a
          // timestamp becomes available again.
          updateState(STATE_NO_TIMESTAMP);
        }
        break;
      case STATE_TIMESTAMP:
        if (updatedTimestamp) {
          long timestampPositionFrames = audioTimestamp.getTimestampPositionFrames();
          if (timestampPositionFrames > initialTimestampPositionFrames) {
            updateState(STATE_TIMESTAMP_ADVANCING);
          }
        } else {
          reset();
        }
        break;
      case STATE_TIMESTAMP_ADVANCING:
        if (!updatedTimestamp) {
          // The audio route may have changed, so reset polling.
          reset();
        }
        break;
      case STATE_NO_TIMESTAMP:
        if (updatedTimestamp) {
          // The audio route may have changed, so reset polling.
          reset();
        }
        break;
      case STATE_ERROR:
        // Do nothing. If the caller accepts any new timestamp we'll reset polling.
        break;
      default:
        throw new IllegalStateException();
    }
    return updatedTimestamp;
  }

  /**
   * Rejects the timestamp last polled in {@link #maybePollTimestamp(long)}. The instance will enter
   * the error state and poll timestamps infrequently until the next call to {@link
   * #acceptTimestamp()}.
   */
  public void rejectTimestamp() {
    updateState(STATE_ERROR);
  }

  /**
   * Accepts the timestamp last polled in {@link #maybePollTimestamp(long)}. If the instance is in
   * the error state, it will begin to poll timestamps frequently again.
   */
  public void acceptTimestamp() {
    if (state == STATE_ERROR) {
      reset();
    }
  }

  /**
   * Returns whether this instance has a timestamp that can be used to calculate the audio track
   * position. If {@code true}, call {@link #getTimestampSystemTimeUs()} and {@link
   * #getTimestampSystemTimeUs()} to access the timestamp.
   */
  public boolean hasTimestamp() {
    return state == STATE_TIMESTAMP || state == STATE_TIMESTAMP_ADVANCING;
  }

  /**
   * Returns whether this instance has an advancing timestamp. If {@code true}, call {@link
   * #getTimestampSystemTimeUs()} and {@link #getTimestampSystemTimeUs()} to access the timestamp. A
   * current position for the track can be extrapolated based on elapsed real time since the system
   * time at which the timestamp was sampled.
   */
  public boolean hasAdvancingTimestamp() {
    return state == STATE_TIMESTAMP_ADVANCING;
  }

  /** Resets polling. Should be called whenever the audio track is paused or resumed. */
  public void reset() {
    if (audioTimestamp != null) {
      updateState(STATE_INITIALIZING);
    }
  }

  /**
   * If {@link #maybePollTimestamp(long)} or {@link #hasTimestamp()} returned {@code true}, returns
   * the system time at which the latest timestamp was sampled, in microseconds.
   */
  @TargetApi(19) // audioTimestamp will be null if Util.SDK_INT < 19.
  public long getTimestampSystemTimeUs() {
    return audioTimestamp != null ? audioTimestamp.getTimestampSystemTimeUs() : C.TIME_UNSET;
  }

  /**
   * If {@link #maybePollTimestamp(long)} or {@link #hasTimestamp()} returned {@code true}, returns
   * the latest timestamp's position in frames.
   */
  @TargetApi(19) // audioTimestamp will be null if Util.SDK_INT < 19.
  public long getTimestampPositionFrames() {
    return audioTimestamp != null ? audioTimestamp.getTimestampPositionFrames() : C.POSITION_UNSET;
  }

  private void updateState(@State int state) {
    this.state = state;
    switch (state) {
      case STATE_INITIALIZING:
        // Force polling a timestamp immediately, and poll quickly.
        lastTimestampSampleTimeUs = 0;
        initialTimestampPositionFrames = C.POSITION_UNSET;
        initializeSystemTimeUs = System.nanoTime() / 1000;
        sampleIntervalUs = FAST_POLL_INTERVAL_US;
        break;
      case STATE_TIMESTAMP:
        sampleIntervalUs = FAST_POLL_INTERVAL_US;
        break;
      case STATE_TIMESTAMP_ADVANCING:
      case STATE_NO_TIMESTAMP:
        sampleIntervalUs = SLOW_POLL_INTERVAL_US;
        break;
      case STATE_ERROR:
        sampleIntervalUs = ERROR_POLL_INTERVAL_US;
        break;
      default:
        throw new IllegalStateException();
    }
  }

  @RequiresApi(19)
  private static final class AudioTimestampV19 {

    private final AudioTrack audioTrack;
    private final AudioTimestamp audioTimestamp;

    private long rawTimestampFramePositionWrapCount;
    private long lastTimestampRawPositionFrames;
    private long lastTimestampPositionFrames;

    /**
     * Creates a new {@link AudioTimestamp} wrapper.
     *
     * @param audioTrack The audio track that will provide timestamps.
     */
    public AudioTimestampV19(AudioTrack audioTrack) {
      this.audioTrack = audioTrack;
      audioTimestamp = new AudioTimestamp();
    }

    /**
     * Attempts to update the audio track timestamp. Returns {@code true} if the timestamp was
     * updated, in which case the updated timestamp system time and position can be accessed with
     * {@link #getTimestampSystemTimeUs()} and {@link #getTimestampPositionFrames()}. Returns {@code
     * false} if no timestamp is available, in which case those methods should not be called.
     */
    public boolean maybeUpdateTimestamp() {
      boolean updated = audioTrack.getTimestamp(audioTimestamp);
      if (updated) {
        long rawPositionFrames = audioTimestamp.framePosition;
        if (lastTimestampRawPositionFrames > rawPositionFrames) {
          // The value must have wrapped around.
          rawTimestampFramePositionWrapCount++;
        }
        lastTimestampRawPositionFrames = rawPositionFrames;
        lastTimestampPositionFrames =
            rawPositionFrames + (rawTimestampFramePositionWrapCount << 32);
      }
      return updated;
    }

    public long getTimestampSystemTimeUs() {
      return audioTimestamp.nanoTime / 1000;
    }

    public long getTimestampPositionFrames() {
      return lastTimestampPositionFrames;
    }
  }
}