TrimmingAudioProcessor.java

/*
 * Copyright (C) 2017 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.Math.min;

import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.util.Util;
import java.nio.ByteBuffer;

/** Audio processor for trimming samples from the start/end of data. */
/* package */ final class TrimmingAudioProcessor extends BaseAudioProcessor {

  private static final @C.PcmEncoding int OUTPUT_ENCODING = C.ENCODING_PCM_16BIT;

  private int trimStartFrames;
  private int trimEndFrames;
  private boolean reconfigurationPending;

  private int pendingTrimStartBytes;
  private byte[] endBuffer;
  private int endBufferSize;
  private long trimmedFrameCount;

  /** Creates a new audio processor for trimming samples from the start/end of data. */
  public TrimmingAudioProcessor() {
    endBuffer = Util.EMPTY_BYTE_ARRAY;
  }

  /**
   * Sets the number of audio frames to trim from the start and end of audio passed to this
   * processor. After calling this method, call {@link #configure(AudioFormat)} to apply the new
   * trimming frame counts.
   *
   * <p>See {@link AudioSink#configure(Format, int, int[])}.
   *
   * @param trimStartFrames The number of audio frames to trim from the start of audio.
   * @param trimEndFrames The number of audio frames to trim from the end of audio.
   */
  public void setTrimFrameCount(int trimStartFrames, int trimEndFrames) {
    this.trimStartFrames = trimStartFrames;
    this.trimEndFrames = trimEndFrames;
  }

  /** Sets the trimmed frame count returned by {@link #getTrimmedFrameCount()} to zero. */
  public void resetTrimmedFrameCount() {
    trimmedFrameCount = 0;
  }

  /**
   * Returns the number of audio frames trimmed since the last call to {@link
   * #resetTrimmedFrameCount()}.
   */
  public long getTrimmedFrameCount() {
    return trimmedFrameCount;
  }

  @Override
  public AudioFormat onConfigure(AudioFormat inputAudioFormat)
      throws UnhandledAudioFormatException {
    if (inputAudioFormat.encoding != OUTPUT_ENCODING) {
      throw new UnhandledAudioFormatException(inputAudioFormat);
    }
    reconfigurationPending = true;
    return trimStartFrames != 0 || trimEndFrames != 0 ? inputAudioFormat : AudioFormat.NOT_SET;
  }

  @Override
  public void queueInput(ByteBuffer inputBuffer) {
    int position = inputBuffer.position();
    int limit = inputBuffer.limit();
    int remaining = limit - position;

    if (remaining == 0) {
      return;
    }

    // Trim any pending start bytes from the input buffer.
    int trimBytes = min(remaining, pendingTrimStartBytes);
    trimmedFrameCount += trimBytes / inputAudioFormat.bytesPerFrame;
    pendingTrimStartBytes -= trimBytes;
    inputBuffer.position(position + trimBytes);
    if (pendingTrimStartBytes > 0) {
      // Nothing to output yet.
      return;
    }
    remaining -= trimBytes;

    // endBuffer must be kept as full as possible, so that we trim the right amount of media if we
    // don't receive any more input. After taking into account the number of bytes needed to keep
    // endBuffer as full as possible, the output should be any surplus bytes currently in endBuffer
    // followed by any surplus bytes in the new inputBuffer.
    int remainingBytesToOutput = endBufferSize + remaining - endBuffer.length;
    ByteBuffer buffer = replaceOutputBuffer(remainingBytesToOutput);

    // Output from endBuffer.
    int endBufferBytesToOutput = Util.constrainValue(remainingBytesToOutput, 0, endBufferSize);
    buffer.put(endBuffer, 0, endBufferBytesToOutput);
    remainingBytesToOutput -= endBufferBytesToOutput;

    // Output from inputBuffer, restoring its limit afterwards.
    int inputBufferBytesToOutput = Util.constrainValue(remainingBytesToOutput, 0, remaining);
    inputBuffer.limit(inputBuffer.position() + inputBufferBytesToOutput);
    buffer.put(inputBuffer);
    inputBuffer.limit(limit);
    remaining -= inputBufferBytesToOutput;

    // Compact endBuffer, then repopulate it using the new input.
    endBufferSize -= endBufferBytesToOutput;
    System.arraycopy(endBuffer, endBufferBytesToOutput, endBuffer, 0, endBufferSize);
    inputBuffer.get(endBuffer, endBufferSize, remaining);
    endBufferSize += remaining;

    buffer.flip();
  }

  @Override
  public ByteBuffer getOutput() {
    if (super.isEnded() && endBufferSize > 0) {
      // Because audio processors may be drained in the middle of the stream we assume that the
      // contents of the end buffer need to be output. For gapless transitions, configure will
      // always be called, so the end buffer is cleared in onQueueEndOfStream.
      replaceOutputBuffer(endBufferSize).put(endBuffer, 0, endBufferSize).flip();
      endBufferSize = 0;
    }
    return super.getOutput();
  }

  @Override
  public boolean isEnded() {
    return super.isEnded() && endBufferSize == 0;
  }

  @Override
  protected void onQueueEndOfStream() {
    if (reconfigurationPending) {
      // Trim audio in the end buffer.
      if (endBufferSize > 0) {
        trimmedFrameCount += endBufferSize / inputAudioFormat.bytesPerFrame;
      }
      endBufferSize = 0;
    }
  }

  @Override
  protected void onFlush() {
    if (reconfigurationPending) {
      // Flushing activates the new configuration, so prepare to trim bytes from the start/end.
      reconfigurationPending = false;
      endBuffer = new byte[trimEndFrames * inputAudioFormat.bytesPerFrame];
      pendingTrimStartBytes = trimStartFrames * inputAudioFormat.bytesPerFrame;
    }

    // TODO(internal b/77292509): Flushing occurs to activate a configuration (handled above) but
    // also when seeking within a stream. This implementation currently doesn't handle seek to start
    // (where we need to trim at the start again), nor seeks to non-zero positions before start
    // trimming has occurred (where we should set pendingTrimStartBytes to zero). These cases can be
    // fixed by trimming in queueInput based on timestamp, once that information is available.

    // Any data in the end buffer should no longer be output if we are playing from a different
    // position, so discard it and refill the buffer using new input.
    endBufferSize = 0;
  }

  @Override
  protected void onReset() {
    endBuffer = Util.EMPTY_BYTE_ARRAY;
  }
}