Add support for audio fades and gain automation

This change introduces `GainProcessor` to apply gain automation over an
audio stream. The gain automation is defined with a `GainProvider`.
`DefaultGainProvider` implements a basic `GainProvider` that allows
populating a gain automation line additively by adding fade shapes
(`FadeProvider`) at specific positions with specific durations.
`DefaultGainProvider` also implements basic equal gain and equal power
fades.

The current `DefaultGainProvider` implementation does not support adding
fades at relative offsets from the stream end. Users must know the
stream duration in advance and add fades at absolute positions.

PiperOrigin-RevId: 733311314
This commit is contained in:
ivanbuper 2025-03-04 06:49:53 -08:00 committed by Copybara-Service
parent b36d0483b2
commit c3d734066d
6 changed files with 988 additions and 0 deletions

View File

@ -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.
*
* <p>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<Long, Function<Pair<Long, Integer>, 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}).
*
* <p>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.
*
* <p>Position and duration are unit agnostic and work as a numerator/denominator pair.
*
* <p>You can implement a basic linear fade as follows:
*
* <pre>{@code
* @Override
* public float getGainFactorAt(long index, long duration) {
* return (float) index / duration;
* }
* }</pre>
*
* @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.
*
* <p>Ramps linearly from 0 to 1.
*
* <p>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.
*
* <p>Ramps linearly from 1 to 0.
*
* <p>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.
*
* <p>Ramps from 0 to 1 using an equal power curve.
*
* <p>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.
*
* <p>Ramps from 1 to 0 using an equal power curve.
*
* <p>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.
*
* <p>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<Long, Function<Pair<Long, Integer>, Float>> gainMap;
private final float defaultGain;
private DefaultGainProvider(
TreeRangeMap<Long, Function<Pair<Long, Integer>, 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<Range<Long>, Function<Pair<Long, Integer>, 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);
}
}

View File

@ -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}.
*
* <p>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.
*
* <p>If the range continues until the end of the stream, this method returns {@link
* C#TIME_END_OF_SOURCE}.
*
* <p>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;
}
}

View File

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

View File

@ -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();
}
}

View File

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

View File

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