diff --git a/libraries/common/src/main/java/androidx/media3/common/audio/DefaultGainProvider.java b/libraries/common/src/main/java/androidx/media3/common/audio/DefaultGainProvider.java new file mode 100644 index 0000000000..a0641e2467 --- /dev/null +++ b/libraries/common/src/main/java/androidx/media3/common/audio/DefaultGainProvider.java @@ -0,0 +1,221 @@ +/* + * Copyright 2025 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 + * + * https://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.common.audio; + +import static androidx.media3.common.util.Assertions.checkArgument; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; +import static androidx.media3.common.util.Util.durationUsToSampleCount; +import static androidx.media3.common.util.Util.sampleCountToDurationUs; + +import android.util.Pair; +import androidx.annotation.IntRange; +import androidx.media3.common.C; +import androidx.media3.common.audio.GainProcessor.GainProvider; +import androidx.media3.common.util.UnstableApi; +import com.google.common.base.Function; +import com.google.common.collect.Range; +import com.google.common.collect.RangeMap; +import com.google.common.collect.TreeRangeMap; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.Map.Entry; + +/** + * Provides gain automation information to be applied on an audio stream. + * + *
The class allows combining multiple {@linkplain FadeProvider fade shapes} into one single
+ * automation line, with common fade shapes already implemented (e.g. {@link #FADE_IN_LINEAR}).
+ *
+ * @see GainProcessor
+ */
+@UnstableApi
+public final class DefaultGainProvider implements GainProvider {
+
+ /** A builder for {@link DefaultGainProvider} instances. */
+ public static final class Builder {
+ private final TreeRangeMap This fade overwrites the shape of any previously added fade if they overlap.
+ */
+ @CanIgnoreReturnValue
+ public Builder addFadeAt(
+ @IntRange(from = 0) long positionUs,
+ @IntRange(from = 1) long durationUs,
+ FadeProvider shape) {
+ checkArgument(positionUs >= 0);
+ checkArgument(durationUs > 1);
+ gainMap.put(
+ Range.closedOpen(positionUs, positionUs + durationUs),
+ (positionSampleRatePair) -> {
+ int sampleRate = positionSampleRatePair.second;
+ long relativeSamplePosition =
+ positionSampleRatePair.first - durationUsToSampleCount(positionUs, sampleRate);
+ return shape.getGainFactorAt(
+ relativeSamplePosition, durationUsToSampleCount(durationUs, sampleRate));
+ });
+ return this;
+ }
+
+ /** Returns a new {@link DefaultGainProvider} instance. */
+ public DefaultGainProvider build() {
+ return new DefaultGainProvider(gainMap, defaultGain);
+ }
+ }
+
+ /** Represents a time unit-agnostic fade shape to be applied over an automation. */
+ public interface FadeProvider {
+
+ /**
+ * Returns the gain factor within [0f; 1f] to apply to an audio sample for a specific fade
+ * shape.
+ *
+ * Position and duration are unit agnostic and work as a numerator/denominator pair.
+ *
+ * You can implement a basic linear fade as follows:
+ *
+ * Ramps linearly from 0 to 1.
+ *
+ * Summing this with {@link #FADE_OUT_LINEAR} returns a constant gain of 1 for all valid
+ * indexes.
+ */
+ public static final FadeProvider FADE_IN_LINEAR = (index, duration) -> (float) index / duration;
+
+ /**
+ * Equal gain fade out.
+ *
+ * Ramps linearly from 1 to 0.
+ *
+ * Summing this with {@link #FADE_IN_LINEAR} returns a constant gain of 1 for all valid
+ * indexes.
+ */
+ public static final FadeProvider FADE_OUT_LINEAR =
+ (index, duration) -> (float) (duration - index) / duration;
+
+ /**
+ * Equal power fade in.
+ *
+ * Ramps from 0 to 1 using an equal power curve.
+ *
+ * Summing this with {@link #FADE_OUT_EQUAL_POWER} returns a constant power of 1 for all valid
+ * indexes.
+ */
+ public static final FadeProvider FADE_IN_EQUAL_POWER =
+ (index, duration) -> (float) Math.sin((Math.PI / 2.0) * index / duration);
+
+ /**
+ * Equal power fade out.
+ *
+ * Ramps from 1 to 0 using an equal power curve.
+ *
+ * Summing this with {@link #FADE_IN_EQUAL_POWER} returns a constant power of 1 for all valid
+ * indexes.
+ */
+ public static final FadeProvider FADE_OUT_EQUAL_POWER =
+ (index, duration) -> (float) Math.cos((Math.PI / 2.0) * index / duration);
+
+ private static final float GAIN_UNSET = C.RATE_UNSET;
+
+ /**
+ * {@link RangeMap} for representing a sequence of fades applied at specific time ranges over a
+ * default gain value.
+ *
+ * Keys correspond to the position range in microseconds. Entry values correspond to a generic
+ * {@link Function} that returns a gain value based on a sample position and sample rate.
+ */
+ // Use TreeRangeMap instead of ImmutableRangeMap to allow overlapping ranges.
+ private final TreeRangeMap Returned values must not change for the same pair of parameter values within the lifetime
+ * of the instance.
+ */
+ float getGainFactorAtSamplePosition(
+ @IntRange(from = 0) long samplePosition, @IntRange(from = 1) int sampleRate);
+
+ /**
+ * Returns the exclusive upper limit of the range starting at {@code samplePosition} where the
+ * gain value is 1f (unity), or {@link C#TIME_UNSET} if {@code samplePosition} does not
+ * correspond to a gain of 1f.
+ *
+ * If the range continues until the end of the stream, this method returns {@link
+ * C#TIME_END_OF_SOURCE}.
+ *
+ * Returned values must not change for the same pair of parameter values within the lifetime
+ * of the instance.
+ *
+ * @param samplePosition Inclusive starting position of the unity range.
+ * @param sampleRate Sample rate in Hertz related to {@code samplePosition}.
+ */
+ long isUnityUntil(@IntRange(from = 0) long samplePosition, @IntRange(from = 1) int sampleRate);
+ }
+
+ private final GainProvider gainProvider;
+ private long readFrames;
+
+ public GainProcessor(GainProvider gainProvider) {
+ this.gainProvider = checkNotNull(gainProvider);
+ }
+
+ @CanIgnoreReturnValue
+ @Override
+ public AudioFormat onConfigure(AudioFormat inputAudioFormat)
+ throws UnhandledAudioFormatException {
+ int encoding = inputAudioFormat.encoding;
+ if (encoding != C.ENCODING_PCM_16BIT && encoding != C.ENCODING_PCM_FLOAT) {
+ throw new UnhandledAudioFormatException(
+ "Invalid PCM encoding. Expected 16 bit PCM or float PCM.", inputAudioFormat);
+ }
+ return inputAudioFormat;
+ }
+
+ @Override
+ public boolean isActive() {
+ return super.isActive()
+ && !Objects.equals(inputAudioFormat, AudioFormat.NOT_SET)
+ && gainProvider.isUnityUntil(/* samplePosition= */ 0, inputAudioFormat.sampleRate)
+ != C.TIME_END_OF_SOURCE;
+ }
+
+ @Override
+ public void queueInput(ByteBuffer inputBuffer) {
+ checkState(
+ !Objects.equals(inputAudioFormat, AudioFormat.NOT_SET),
+ "Audio processor must be configured and flushed before calling queueInput().");
+
+ if (!inputBuffer.hasRemaining()) {
+ return;
+ }
+
+ checkArgument(
+ inputBuffer.remaining() % inputAudioFormat.bytesPerFrame == 0,
+ "Queued an incomplete frame.");
+
+ ByteBuffer buffer = replaceOutputBuffer(inputBuffer.remaining());
+
+ // Each iteration handles one frame.
+ while (inputBuffer.hasRemaining()) {
+ float gain =
+ gainProvider.getGainFactorAtSamplePosition(readFrames, inputAudioFormat.sampleRate);
+ if (gain == 1f) {
+ int oldLimit = inputBuffer.limit();
+
+ long regionEnd = gainProvider.isUnityUntil(readFrames, inputAudioFormat.sampleRate);
+ checkState(regionEnd != C.TIME_UNSET, "Expected a valid end boundary for unity region.");
+
+ // Only set limit if unity does not last until EoS.
+ if (regionEnd != C.TIME_END_OF_SOURCE) {
+ long limitOffsetBytes = (regionEnd - readFrames) * inputAudioFormat.bytesPerFrame;
+ inputBuffer.limit(min(oldLimit, (int) limitOffsetBytes + inputBuffer.position()));
+ }
+
+ readFrames += inputBuffer.remaining() / inputAudioFormat.bytesPerFrame;
+ buffer.put(inputBuffer);
+ inputBuffer.limit(oldLimit);
+ } else {
+ for (int i = 0; i < inputAudioFormat.channelCount; i++) {
+ switch (inputAudioFormat.encoding) {
+ case C.ENCODING_PCM_16BIT:
+ buffer.putShort((short) (inputBuffer.getShort() * gain));
+ break;
+ case C.ENCODING_PCM_FLOAT:
+ buffer.putFloat(inputBuffer.getFloat() * gain);
+ break;
+ default:
+ throw new IllegalStateException(
+ "Unexpected PCM encoding: " + inputAudioFormat.encoding);
+ }
+ }
+ readFrames++;
+ }
+ }
+ buffer.flip();
+ }
+
+ @Override
+ public void onFlush() {
+ readFrames = 0;
+ }
+
+ @Override
+ public void onReset() {
+ readFrames = 0;
+ }
+}
diff --git a/libraries/common/src/test/java/androidx/media3/common/audio/DefaultGainProviderTest.java b/libraries/common/src/test/java/androidx/media3/common/audio/DefaultGainProviderTest.java
new file mode 100644
index 0000000000..e84ae47c7f
--- /dev/null
+++ b/libraries/common/src/test/java/androidx/media3/common/audio/DefaultGainProviderTest.java
@@ -0,0 +1,316 @@
+/*
+ * Copyright 2025 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
+ *
+ * https://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.common.audio;
+
+import static androidx.media3.common.audio.DefaultGainProvider.FADE_IN_EQUAL_POWER;
+import static androidx.media3.common.audio.DefaultGainProvider.FADE_IN_LINEAR;
+import static androidx.media3.common.audio.DefaultGainProvider.FADE_OUT_EQUAL_POWER;
+import static androidx.media3.common.audio.DefaultGainProvider.FADE_OUT_LINEAR;
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.media3.common.C;
+import androidx.media3.common.audio.DefaultGainProvider.FadeProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit test for {@link DefaultGainProvider}. */
+@RunWith(AndroidJUnit4.class)
+public class DefaultGainProviderTest {
+
+ private static final int SAMPLE_RATE = 50000;
+
+ private static final FadeProvider CONSTANT_VALUE_FADE = (index, duration) -> 0.5f;
+
+ @Test
+ public void getGainFactorAtSamplePosition_withoutFades_returnsDefaultValue() {
+ DefaultGainProvider provider = new DefaultGainProvider.Builder(/* defaultGain= */ 1f).build();
+ assertThat(provider.getGainFactorAtSamplePosition(0, SAMPLE_RATE)).isEqualTo(1f);
+ }
+
+ @Test
+ public void getGainFactorAtSamplePosition_withConstantFade_returnsFadeValue() {
+ DefaultGainProvider provider =
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(
+ /* positionUs= */ 0L, /* durationUs= */ C.MICROS_PER_SECOND, CONSTANT_VALUE_FADE)
+ .build();
+ assertThat(provider.getGainFactorAtSamplePosition(/* samplePosition= */ 0, SAMPLE_RATE))
+ .isEqualTo(0.5f);
+ }
+
+ @Test
+ public void getGainFactorAtSamplePosition_withFadeIn_returnsFadeValue() {
+ DefaultGainProvider provider =
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(/* positionUs= */ 0L, /* durationUs= */ C.MICROS_PER_SECOND, FADE_IN_LINEAR)
+ .build();
+ assertThat(provider.getGainFactorAtSamplePosition(/* samplePosition= */ 0, SAMPLE_RATE))
+ .isEqualTo(0f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ SAMPLE_RATE / 4, SAMPLE_RATE))
+ .isEqualTo(0.25f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ SAMPLE_RATE / 2, SAMPLE_RATE))
+ .isEqualTo(0.5f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 3 * SAMPLE_RATE / 4, SAMPLE_RATE))
+ .isEqualTo(0.75f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(/* samplePosition= */ SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(1f);
+ }
+
+ @Test
+ public void getGainFactorAtSamplePosition_withNonTrivialFadeDuration_scalesFade() {
+ DefaultGainProvider provider =
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(
+ /* positionUs= */ 0L, /* durationUs= */ 4 * C.MICROS_PER_SECOND, FADE_IN_LINEAR)
+ .build();
+ assertThat(provider.getGainFactorAtSamplePosition(/* samplePosition= */ 0, SAMPLE_RATE))
+ .isEqualTo(0f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(/* samplePosition= */ SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0.25f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 2 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0.5f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 3 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0.75f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 4 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(1f);
+ }
+
+ @Test
+ public void getGainFactorAtSamplePosition_withSubsequentSampleRateChange_rescalesFades() {
+ DefaultGainProvider provider =
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(/* positionUs= */ 0L, /* durationUs= */ C.MICROS_PER_SECOND, FADE_IN_LINEAR)
+ .build();
+
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 0, /* sampleRate= */ SAMPLE_RATE))
+ .isEqualTo(0f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ SAMPLE_RATE / 2, /* sampleRate= */ SAMPLE_RATE))
+ .isEqualTo(0.5f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ SAMPLE_RATE, /* sampleRate= */ SAMPLE_RATE))
+ .isEqualTo(1f);
+
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 0, /* sampleRate= */ 2 * SAMPLE_RATE))
+ .isEqualTo(0f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ SAMPLE_RATE, /* sampleRate= */ 2 * SAMPLE_RATE))
+ .isEqualTo(0.5f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 2 * SAMPLE_RATE, /* sampleRate= */ 2 * SAMPLE_RATE))
+ .isEqualTo(1f);
+ }
+
+ @Test
+ public void getGainFactorAtSamplePosition_afterAddFadeAt_appliesFadeInCorrectly() {
+ DefaultGainProvider provider =
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(
+ 5 * C.MICROS_PER_SECOND, /* durationUs= */ 2 * C.MICROS_PER_SECOND, FADE_IN_LINEAR)
+ .build();
+
+ assertThat(provider.getGainFactorAtSamplePosition(/* samplePosition= */ 0, SAMPLE_RATE))
+ .isEqualTo(1f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 3 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(1f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 5 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 6 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0.5f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 7 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(1f);
+ }
+
+ @Test
+ public void getGainFactorAtSamplePosition_afterAddFadeAt_appliesFadeOutCorrectly() {
+ DefaultGainProvider provider =
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(
+ /* positionUs= */ 5 * C.MICROS_PER_SECOND,
+ /* durationUs= */ 4 * C.MICROS_PER_SECOND,
+ FADE_OUT_LINEAR)
+ .build();
+
+ assertThat(provider.getGainFactorAtSamplePosition(/* samplePosition= */ 0, SAMPLE_RATE))
+ .isEqualTo(1f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 3 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(1f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 5 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(1f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 6 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0.75f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 7 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0.5f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 8 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0.25f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 9 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(1f);
+ }
+
+ @Test
+ public void getGainFactorAtSamplePosition_superposedFades_keepsLastAddedFadeOnTop() {
+ DefaultGainProvider provider =
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(
+ /* positionUs= */ 5 * C.MICROS_PER_SECOND,
+ /* durationUs= */ 5 * C.MICROS_PER_SECOND,
+ FADE_IN_LINEAR)
+ .addFadeAt(
+ /* positionUs= */ 7 * C.MICROS_PER_SECOND,
+ /* durationUs= */ C.MICROS_PER_SECOND,
+ CONSTANT_VALUE_FADE)
+ .build();
+
+ assertThat(provider.getGainFactorAtSamplePosition(/* samplePosition= */ 0, SAMPLE_RATE))
+ .isEqualTo(1f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 5 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 6 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0.2f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 7 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0.5f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ (long) (7.5 * SAMPLE_RATE), SAMPLE_RATE))
+ .isEqualTo(0.5f);
+ assertThat(
+ provider.getGainFactorAtSamplePosition(
+ /* samplePosition= */ 8 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(0.6f);
+ }
+
+ @Test
+ public void linearFades_maintainEqualGain() {
+ int duration = 100;
+ for (int i = 0; i <= duration; i++) {
+ float inGain = FADE_IN_LINEAR.getGainFactorAt(/* index= */ i, /* duration= */ duration);
+ float outGain = FADE_OUT_LINEAR.getGainFactorAt(/* index= */ i, /* duration= */ duration);
+ assertThat(inGain + outGain).isWithin(Math.ulp(1.0f)).of(1f);
+ }
+ }
+
+ @Test
+ public void constantPowerFades_maintainEqualPower() {
+ int duration = 100;
+ for (int i = 0; i <= duration; i++) {
+ float inGain = FADE_IN_EQUAL_POWER.getGainFactorAt(/* index= */ i, /* duration= */ 10);
+ float outGain = FADE_OUT_EQUAL_POWER.getGainFactorAt(/* index= */ i, /* duration= */ 10);
+ assertThat(inGain * inGain + outGain * outGain).isWithin(Math.ulp(1.0f)).of(1.0f);
+ }
+ }
+
+ @Test
+ public void isUnityUntil_withDefaultValueSetToUnity_returnsTimeEndOfStream() {
+ DefaultGainProvider provider = new DefaultGainProvider.Builder(/* defaultGain= */ 1f).build();
+ assertThat(provider.isUnityUntil(/* samplePosition= */ 0, SAMPLE_RATE))
+ .isEqualTo(C.TIME_END_OF_SOURCE);
+ }
+
+ @Test
+ public void isUnityUntil_withDefaultValueSetToZero_returnsTimeUnset() {
+ DefaultGainProvider provider = new DefaultGainProvider.Builder(/* defaultGain= */ 0f).build();
+ assertThat(provider.isUnityUntil(/* samplePosition= */ 0, SAMPLE_RATE)).isEqualTo(C.TIME_UNSET);
+ }
+
+ @Test
+ public void isUnityUntil_withMultipleNonUnityRegions_resolvesResultingUnityRegions() {
+ DefaultGainProvider provider =
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(
+ /* positionUs= */ C.MICROS_PER_SECOND,
+ /* durationUs= */ C.MICROS_PER_SECOND,
+ CONSTANT_VALUE_FADE)
+ .addFadeAt(
+ /* positionUs= */ 3 * C.MICROS_PER_SECOND,
+ /* durationUs= */ C.MICROS_PER_SECOND,
+ CONSTANT_VALUE_FADE)
+ .build();
+ assertThat(provider.isUnityUntil(/* samplePosition= */ 0, SAMPLE_RATE)).isEqualTo(SAMPLE_RATE);
+ assertThat(provider.isUnityUntil(/* samplePosition= */ SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(C.TIME_UNSET);
+ assertThat(provider.isUnityUntil(/* samplePosition= */ 2 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(3 * SAMPLE_RATE);
+ assertThat(provider.isUnityUntil(/* samplePosition= */ 3 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(C.TIME_UNSET);
+ assertThat(provider.isUnityUntil(/* samplePosition= */ 4 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(C.TIME_END_OF_SOURCE);
+ }
+
+ @Test
+ public void isUnityUntil_withNonUnityRegionStartingAtUnity_doesNotSkipNonUnityRegion() {
+ DefaultGainProvider provider =
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(
+ /* positionUs= */ C.MICROS_PER_SECOND,
+ /* durationUs= */ C.MICROS_PER_SECOND,
+ FADE_OUT_LINEAR)
+ .build();
+ assertThat(provider.isUnityUntil(/* samplePosition= */ 0, SAMPLE_RATE)).isEqualTo(SAMPLE_RATE);
+ assertThat(provider.isUnityUntil(/* samplePosition= */ SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(C.TIME_UNSET);
+ assertThat(provider.isUnityUntil(/* samplePosition= */ 2 * SAMPLE_RATE, SAMPLE_RATE))
+ .isEqualTo(C.TIME_END_OF_SOURCE);
+ }
+}
diff --git a/libraries/common/src/test/java/androidx/media3/common/audio/GainProcessorTest.java b/libraries/common/src/test/java/androidx/media3/common/audio/GainProcessorTest.java
new file mode 100644
index 0000000000..3e4e4a5a08
--- /dev/null
+++ b/libraries/common/src/test/java/androidx/media3/common/audio/GainProcessorTest.java
@@ -0,0 +1,257 @@
+/*
+ * Copyright 2025 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
+ *
+ * https://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.common.audio;
+
+import static androidx.media3.common.audio.AudioProcessor.EMPTY_BUFFER;
+import static androidx.media3.test.utils.TestUtil.createByteBuffer;
+import static androidx.media3.test.utils.TestUtil.createFloatArray;
+import static androidx.media3.test.utils.TestUtil.createShortArray;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import androidx.media3.common.C;
+import androidx.media3.common.audio.AudioProcessor.AudioFormat;
+import androidx.media3.common.audio.AudioProcessor.UnhandledAudioFormatException;
+import androidx.media3.common.audio.DefaultGainProvider.FadeProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.nio.ByteBuffer;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit test for {@link GainProcessor}. */
+@RunWith(AndroidJUnit4.class)
+public class GainProcessorTest {
+
+ private static final FadeProvider CONSTANT_VALUE_FADE = (index, duration) -> 0.5f;
+
+ private static final DefaultGainProvider HUNDRED_US_FADE_IN_PROVIDER =
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(
+ /* positionUs= */ 0L, /* durationUs= */ 100, DefaultGainProvider.FADE_IN_LINEAR)
+ .build();
+
+ private static final AudioFormat MONO_50KHZ_16BIT_FORMAT =
+ new AudioFormat(/* sampleRate= */ 50000, /* channelCount= */ 1, C.ENCODING_PCM_16BIT);
+ private static final AudioFormat MONO_100KHZ_16BIT_FORMAT =
+ new AudioFormat(/* sampleRate= */ 100000, /* channelCount= */ 1, C.ENCODING_PCM_16BIT);
+
+ private static final AudioFormat MONO_50KHZ_FLOAT_FORMAT =
+ new AudioFormat(/* sampleRate= */ 50000, /* channelCount= */ 1, C.ENCODING_PCM_FLOAT);
+
+ @Test
+ public void applyGain_withMutingGainProvider_returnsAllZeroes()
+ throws UnhandledAudioFormatException {
+ GainProcessor processor =
+ new GainProcessor(new DefaultGainProvider.Builder(/* defaultGain= */ 0f).build());
+ processor.configure(MONO_50KHZ_16BIT_FORMAT);
+ processor.flush();
+
+ ByteBuffer input = createByteBuffer(new short[] {1, 1, 1, 1, 1, 1, 1, 1, 1, 1});
+ processor.queueInput(input);
+
+ ByteBuffer output = processor.getOutput();
+ assertThat(output.remaining()).isEqualTo(20);
+ while (output.hasRemaining()) {
+ assertThat(output.getShort()).isEqualTo(0);
+ }
+ }
+
+ @Test
+ public void applyGain_withFadeIn_returnsScaledSamples() throws UnhandledAudioFormatException {
+ GainProcessor processor = new GainProcessor(HUNDRED_US_FADE_IN_PROVIDER);
+ processor.configure(MONO_50KHZ_16BIT_FORMAT);
+ processor.flush();
+
+ ByteBuffer input = createByteBuffer(new short[] {100, 100, 100, 100, 100, 100, 100});
+ processor.queueInput(input);
+ ByteBuffer output = processor.getOutput();
+
+ short[] outputSamples = createShortArray(output);
+ assertThat(outputSamples).isEqualTo(new short[] {0, 20, 40, 60, 80, 100, 100});
+ }
+
+ @Test
+ public void applyGain_withFloatSamples_returnsScaledSamples()
+ throws UnhandledAudioFormatException {
+ GainProcessor processor = new GainProcessor(HUNDRED_US_FADE_IN_PROVIDER);
+ processor.configure(MONO_50KHZ_FLOAT_FORMAT);
+ processor.flush();
+
+ ByteBuffer input = createByteBuffer(new float[] {1, 1, 1, 1, 1, 1, 1});
+ processor.queueInput(input);
+ ByteBuffer output = processor.getOutput();
+
+ float[] outputSamples = createFloatArray(output);
+ assertThat(outputSamples).isEqualTo(new float[] {0f, 0.2f, 0.4f, 0.6f, 0.8f, 1f, 1f});
+ }
+
+ @Test
+ public void applyGain_afterSampleRateChange_stretchesFade() throws UnhandledAudioFormatException {
+ GainProcessor processor = new GainProcessor(HUNDRED_US_FADE_IN_PROVIDER);
+ processor.configure(MONO_50KHZ_16BIT_FORMAT);
+ processor.flush();
+
+ ByteBuffer input = createByteBuffer(new short[] {100, 100, 100, 100, 100, 100, 100});
+ processor.queueInput(input);
+ ByteBuffer output = processor.getOutput();
+
+ short[] outputSamples = createShortArray(output);
+ assertThat(outputSamples).isEqualTo(new short[] {0, 20, 40, 60, 80, 100, 100});
+
+ processor.configure(MONO_100KHZ_16BIT_FORMAT);
+ processor.flush();
+ input.rewind();
+ processor.queueInput(input);
+ output.clear();
+ output = processor.getOutput();
+
+ outputSamples = createShortArray(output);
+ assertThat(outputSamples).isEqualTo(new short[] {0, 10, 20, 30, 40, 50, 60});
+ }
+
+ @Test
+ public void applyGain_withMultipleQueueInputCalls_appliesGainAtCorrectPosition()
+ throws UnhandledAudioFormatException {
+ GainProcessor processor =
+ new GainProcessor(
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(/* positionUs= */ 100, /* durationUs= */ 100, CONSTANT_VALUE_FADE)
+ .build());
+ processor.configure(MONO_50KHZ_16BIT_FORMAT);
+ processor.flush();
+
+ ByteBuffer input = createByteBuffer(new short[] {100, 100, 100, 100, 100});
+ processor.queueInput(input);
+ ByteBuffer output = processor.getOutput();
+
+ short[] outputSamples = createShortArray(output);
+ assertThat(outputSamples).isEqualTo(new short[] {100, 100, 100, 100, 100});
+
+ input.rewind();
+ processor.queueInput(input);
+ output.clear();
+ output = processor.getOutput();
+
+ outputSamples = createShortArray(output);
+ assertThat(outputSamples).isEqualTo(new short[] {50, 50, 50, 50, 50});
+
+ input.rewind();
+ processor.queueInput(input);
+ output.clear();
+ output = processor.getOutput();
+
+ outputSamples = createShortArray(output);
+ assertThat(outputSamples).isEqualTo(new short[] {100, 100, 100, 100, 100});
+ }
+
+ @Test
+ public void applyGain_withSingleQueueInputCall_appliesGainAtCorrectPosition()
+ throws UnhandledAudioFormatException {
+ GainProcessor processor =
+ new GainProcessor(
+ new DefaultGainProvider.Builder(/* defaultGain= */ 1f)
+ .addFadeAt(/* positionUs= */ 100, /* durationUs= */ 100, CONSTANT_VALUE_FADE)
+ .build());
+ processor.configure(MONO_50KHZ_16BIT_FORMAT);
+ processor.flush();
+
+ // 15 mono frames set to 100.
+ ByteBuffer input =
+ createByteBuffer(
+ new short[] {
+ 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100
+ });
+ processor.queueInput(input);
+ ByteBuffer output = processor.getOutput();
+
+ short[] outputSamples = createShortArray(output);
+ // 5 frames at unity + 5 frames with gain 0.5 (100 * 0.5 = 50) + 5 frames with at unity.
+ assertThat(outputSamples)
+ .isEqualTo(
+ new short[] {100, 100, 100, 100, 100, 50, 50, 50, 50, 50, 100, 100, 100, 100, 100});
+ }
+
+ @Test
+ public void isEnded_afterQueueEndOfStreamWithNoPendingOutput_returnsTrue()
+ throws UnhandledAudioFormatException {
+ GainProcessor processor = new GainProcessor(HUNDRED_US_FADE_IN_PROVIDER);
+ processor.configure(MONO_50KHZ_16BIT_FORMAT);
+ processor.flush();
+
+ ByteBuffer input = createByteBuffer(new short[] {100, 100, 100, 100, 100, 100, 100});
+ processor.queueInput(input);
+ processor.queueEndOfStream();
+
+ assertThat(processor.isEnded()).isFalse();
+ processor.getOutput();
+ assertThat(processor.isEnded()).isTrue();
+ }
+
+ @Test
+ public void queueInput_beforeConfigureAndFlush_throwsIllegalStateException()
+ throws UnhandledAudioFormatException {
+ GainProcessor processor = new GainProcessor(HUNDRED_US_FADE_IN_PROVIDER);
+
+ assertThrows(IllegalStateException.class, () -> processor.queueInput(EMPTY_BUFFER));
+ processor.configure(MONO_50KHZ_16BIT_FORMAT);
+ assertThrows(IllegalStateException.class, () -> processor.queueInput(EMPTY_BUFFER));
+ }
+
+ @Test
+ public void configure_withUnsupportedEncoding_throwsUnhandledAudioFormatException() {
+ GainProcessor processor = new GainProcessor(HUNDRED_US_FADE_IN_PROVIDER);
+ assertThrows(
+ UnhandledAudioFormatException.class,
+ () ->
+ processor.configure(
+ new AudioFormat(
+ /* sampleRate= */ 50000,
+ /* channelCount= */ 1,
+ C.ENCODING_PCM_16BIT_BIG_ENDIAN)));
+ assertThrows(
+ UnhandledAudioFormatException.class,
+ () ->
+ processor.configure(
+ new AudioFormat(
+ /* sampleRate= */ 50000,
+ /* channelCount= */ 1,
+ C.ENCODING_PCM_24BIT_BIG_ENDIAN)));
+ assertThrows(
+ UnhandledAudioFormatException.class,
+ () ->
+ processor.configure(
+ new AudioFormat(
+ /* sampleRate= */ 50000,
+ /* channelCount= */ 1,
+ C.ENCODING_PCM_32BIT_BIG_ENDIAN)));
+ assertThrows(
+ UnhandledAudioFormatException.class,
+ () ->
+ processor.configure(
+ new AudioFormat(
+ /* sampleRate= */ 50000, /* channelCount= */ 1, C.ENCODING_INVALID)));
+ }
+
+ @Test
+ public void isActive_withConstantGainProviderAtUnity_returnsFalse()
+ throws UnhandledAudioFormatException {
+ GainProcessor processor =
+ new GainProcessor(new DefaultGainProvider.Builder(/* defaultGain= */ 1).build());
+ processor.configure(MONO_50KHZ_FLOAT_FORMAT);
+ processor.flush();
+ assertThat(processor.isActive()).isFalse();
+ }
+}
diff --git a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java
index dd49883a33..e9b3f9cbe2 100644
--- a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java
+++ b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/AndroidTestUtil.java
@@ -1057,6 +1057,9 @@ public final class AndroidTestUtil {
public static final AssetInfo WAV_192KHZ_ASSET =
new AssetInfo.Builder("asset:///media/wav/sample_192khz.wav").build();
+ public static final AssetInfo FLAC_STEREO_ASSET =
+ new AssetInfo.Builder("asset:///media/flac/bear.flac").build();
+
/** A {@link GlEffect} that adds delay in the video pipeline by putting the thread to sleep. */
public static final class DelayEffect implements GlEffect {
private final long delayMs;
diff --git a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java
index 9a9a555397..0de771c3ec 100644
--- a/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java
+++ b/libraries/transformer/src/androidTest/java/androidx/media3/transformer/TransformerEndToEndTest.java
@@ -82,6 +82,8 @@ import androidx.media3.common.audio.AudioProcessor;
import androidx.media3.common.audio.AudioProcessor.AudioFormat;
import androidx.media3.common.audio.ChannelMixingAudioProcessor;
import androidx.media3.common.audio.ChannelMixingMatrix;
+import androidx.media3.common.audio.DefaultGainProvider;
+import androidx.media3.common.audio.GainProcessor;
import androidx.media3.common.audio.SonicAudioProcessor;
import androidx.media3.common.audio.SpeedProvider;
import androidx.media3.common.util.CodecSpecificDataUtil;
@@ -113,6 +115,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import java.io.File;
import java.nio.ByteBuffer;
+import java.nio.ShortBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
@@ -2493,6 +2496,40 @@ public class TransformerEndToEndTest {
assertThat(new File(result.filePath).length()).isGreaterThan(0);
}
+ @Test
+ public void export_withMutingGainProvider_processesMutedAudio() throws Exception {
+ Transformer transformer = new Transformer.Builder(context).build();
+ DefaultGainProvider provider = new DefaultGainProvider.Builder(/* defaultGain= */ 0f).build();
+ GainProcessor gainProcessor = new GainProcessor(provider);
+ TeeAudioProcessor teeAudioProcessor =
+ new TeeAudioProcessor(
+ new TeeAudioProcessor.AudioBufferSink() {
+ @Override
+ public void flush(int sampleRateHz, int channelCount, @C.PcmEncoding int encoding) {}
+
+ @Override
+ public void handleBuffer(ByteBuffer buffer) {
+ ShortBuffer samplesBuffer = buffer.asShortBuffer();
+ while (samplesBuffer.hasRemaining()) {
+ assertThat(samplesBuffer.get()).isEqualTo(0);
+ }
+ }
+ });
+
+ EditedMediaItem item =
+ new EditedMediaItem.Builder(MediaItem.fromUri(WAV_ASSET.uri))
+ .setEffects(
+ new Effects(ImmutableList.of(gainProcessor, teeAudioProcessor), ImmutableList.of()))
+ .build();
+
+ ExportTestResult result =
+ new TransformerAndroidTestRunner.Builder(context, transformer).build().run(testId, item);
+
+ // Tolerance required due to bug in durationMs (b/355201372).
+ assertThat(result.exportResult.durationMs).isWithin(40).of(1000);
+ assertThat(new File(result.filePath).length()).isGreaterThan(0);
+ }
+
private static boolean shouldSkipDeviceForAacObjectHeProfileEncoding() {
return Util.SDK_INT < 29;
}
{@code
+ * @Override
+ * public float getGainFactorAt(long index, long duration) {
+ * return (float) index / duration;
+ * }
+ * }
+ *
+ * @param index Position (numerator) between [0; {@code duration}].
+ * @param duration Duration (denominator).
+ */
+ float getGainFactorAt(@IntRange(from = 0) long index, @IntRange(from = 1) long duration);
+ }
+
+ /**
+ * Equal gain fade in.
+ *
+ *