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, Float>> gainMap; + private final float defaultGain; + + /** + * Returns a {@link DefaultGainProvider} builder. + * + * @param defaultGain Default gain value. + */ + public Builder(float defaultGain) { + gainMap = TreeRangeMap.create(); + // Add default value for all possible positions. + this.defaultGain = defaultGain; + gainMap.put(Range.all(), (a) -> GAIN_UNSET); + } + + /** + * Adds a {@code shape} to be applied between [{@code positionUs}; {@code positionUs} + {@code + * durationUs}). + * + *

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: + * + *

{@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. + * + *

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, Float>> gainMap; + + private final float defaultGain; + + private DefaultGainProvider( + TreeRangeMap, Float>> gainMap, float defaultGain) { + this.gainMap = TreeRangeMap.create(); + this.gainMap.putAll(gainMap); + this.defaultGain = defaultGain; + } + + @Override + public float getGainFactorAtSamplePosition( + @IntRange(from = 0) long samplePosition, @IntRange(from = 1) int sampleRate) { + checkState(sampleRate > 0); + checkArgument(samplePosition >= 0); + + // gainMap has a default value set for all possible values, so it should never return null. + float gain = + checkNotNull(gainMap.get(sampleCountToDurationUs(samplePosition, sampleRate))) + .apply(Pair.create(samplePosition, sampleRate)); + if (gain == GAIN_UNSET) { + return defaultGain; + } + return gain; + } + + @Override + // TODO (b/400418589): Add support for non-default value unity ranges. + public long isUnityUntil( + @IntRange(from = 0) long samplePosition, @IntRange(from = 1) int sampleRate) { + checkState(sampleRate > 0); + checkArgument(samplePosition >= 0); + + long positionUs = sampleCountToDurationUs(samplePosition, sampleRate); + Entry, Function, Float>> entry = + checkNotNull(gainMap.getEntry(positionUs)); + + if (defaultGain != 1f + || entry.getValue().apply(Pair.create(samplePosition, sampleRate)) != GAIN_UNSET) { + return C.TIME_UNSET; + } + + if (!entry.getKey().hasUpperBound()) { + return C.TIME_END_OF_SOURCE; + } + + return durationUsToSampleCount(entry.getKey().upperEndpoint(), sampleRate); + } +} diff --git a/libraries/common/src/main/java/androidx/media3/common/audio/GainProcessor.java b/libraries/common/src/main/java/androidx/media3/common/audio/GainProcessor.java new file mode 100644 index 0000000000..ba6b70301f --- /dev/null +++ b/libraries/common/src/main/java/androidx/media3/common/audio/GainProcessor.java @@ -0,0 +1,154 @@ +/* + * 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 java.lang.Math.min; + +import androidx.annotation.IntRange; +import androidx.media3.common.C; +import androidx.media3.common.util.UnstableApi; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.nio.ByteBuffer; +import java.util.Objects; + +/** Applies {@linkplain GainProvider gain automation} over an audio stream. */ +@UnstableApi +public final class GainProcessor extends BaseAudioProcessor { + + /** Interface that provides sample-level gain automation to be applied on an audio stream. */ + public interface GainProvider { + /** + * Returns a gain factor between [0f; 1f] to apply at the given sample position relative to + * {@code sampleRate}. + * + *

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; }