mirror of
https://github.com/samsonjs/media.git
synced 2026-04-13 12:35:48 +00:00
Add an audio mixer that combines concurrent audio sources
Design document: go/me-android-audio-mixer PiperOrigin-RevId: 501048758
This commit is contained in:
parent
2331142930
commit
9a9da0ab8e
3 changed files with 847 additions and 0 deletions
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.transformer.audio;
|
||||
|
||||
import androidx.media3.common.audio.AudioProcessor.AudioFormat;
|
||||
import androidx.media3.common.audio.AudioProcessor.UnhandledAudioFormatException;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* An audio component which combines audio data from multiple sources into a single output.
|
||||
*
|
||||
* <p>The mixer supports an arbitrary number of concurrent sources and will ensure audio data from
|
||||
* all sources are aligned and mixed before producing output. Any periods without sources will be
|
||||
* filled with silence. The total duration of the mixed track is controlled with {@link
|
||||
* #setEndTimeUs}, or is unbounded if left unset.
|
||||
*
|
||||
* <p><b>Updates:</b> The mixer supports the following updates at any time without the need for a
|
||||
* {@link #reset()}.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@linkplain #addSource Add source}. Source audio will be included in future mixed output
|
||||
* only.
|
||||
* <li>{@linkplain #removeSource Remove source}.
|
||||
* <li>{@linkplain #setSourceVolume Change source volume}. The new volume will apply only to
|
||||
* future source samples.
|
||||
* <li>{@linkplain #setEndTimeUs Change end time}. The new end time may cause an immediate change
|
||||
* to the mixer {@linkplain #isEnded() ended state}.
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@linkplain #configure Changes} to the output audio format, buffer size, or mixer start time
|
||||
* require the mixer to first be {@linkplain #reset() reset}, discarding all buffered data.
|
||||
*
|
||||
* <p><b>Operation:</b> The mixer must be {@linkplain #configure configured} before any methods are
|
||||
* called. Once configured, sources can queue audio data via {@link #queueInput} and the mixer will
|
||||
* consume input audio up to the configured buffer size and end time. Once all sources have produced
|
||||
* data for a period then {@link getOutput()} will return the mixed result. The cycle repeats until
|
||||
* the mixer {@link #isEnded()}.
|
||||
*/
|
||||
@UnstableApi
|
||||
public interface AudioMixer {
|
||||
/** Creates an unconfigured instance. */
|
||||
public static AudioMixer create() {
|
||||
return new AudioMixerImpl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the mixer.
|
||||
*
|
||||
* <p>The mixer must be configured before use and can only be reconfigured after a call to {@link
|
||||
* reset()}.
|
||||
*
|
||||
* <p>The mixing buffer size is set by {@code bufferSizeMs} and indicates how much audio can be
|
||||
* queued before {@link getOutput()} is called.
|
||||
*
|
||||
* @param outputAudioFormat The audio format of buffers returned from {@link getOutput()}.
|
||||
* @param bufferSizeMs The mixing buffer size in milliseconds.
|
||||
* @param startTimeUs The start time of the mixer output in microseconds.
|
||||
* @throws UnhandledAudioFormatException If the output audio format is not supported.
|
||||
*/
|
||||
void configure(AudioFormat outputAudioFormat, int bufferSizeMs, long startTimeUs)
|
||||
throws UnhandledAudioFormatException;
|
||||
|
||||
/**
|
||||
* Sets the end time of the output audio.
|
||||
*
|
||||
* <p>The mixer will not accept input nor produce output past this point.
|
||||
*
|
||||
* @param endTimeUs The end time in microseconds.
|
||||
* @throws IllegalArgumentException If {@code endTimeUs} is before the configured start time.
|
||||
*/
|
||||
void setEndTimeUs(long endTimeUs);
|
||||
|
||||
/** Indicates whether the mixer supports mixing sources with the given audio format. */
|
||||
boolean supportsSourceAudioFormat(AudioFormat sourceFormat);
|
||||
|
||||
/**
|
||||
* Adds an audio source to mix starting at the given time.
|
||||
*
|
||||
* <p>If the mixer has already {@linkplain #getOutput() output} samples past the {@code
|
||||
* startTimeUs}, audio from this source will be discarded up to the last output end timestamp.
|
||||
*
|
||||
* <p>If the source start time is earlier than the configured mixer start time then audio from
|
||||
* this source will be discarded up to the mixer start time.
|
||||
*
|
||||
* <p>All audio sources start with a volume of 1.0 on all channels.
|
||||
*
|
||||
* @param sourceFormat Audio format of source buffers.
|
||||
* @param startTimeUs Source start time in microseconds.
|
||||
* @return Non-negative integer identifying the source ({@code sourceId}).
|
||||
* @throws UnhandledAudioFormatException If the source format is not supported.
|
||||
*/
|
||||
int addSource(AudioFormat sourceFormat, long startTimeUs) throws UnhandledAudioFormatException;
|
||||
|
||||
/**
|
||||
* Sets the volume applied to future samples queued from the given source.
|
||||
*
|
||||
* @param sourceId Source identifier from {@link #addSource}.
|
||||
* @param volume Non-negative scalar applied to all source channels.
|
||||
*/
|
||||
void setSourceVolume(int sourceId, float volume);
|
||||
|
||||
/**
|
||||
* Removes an audio source.
|
||||
*
|
||||
* <p>No more audio can be queued from this source. All audio queued before removal will be
|
||||
* output.
|
||||
*
|
||||
* @param sourceId Source identifier from {@link #addSource}.
|
||||
*/
|
||||
void removeSource(int sourceId);
|
||||
|
||||
/**
|
||||
* Queues audio data between the position and limit of the {@code sourceBuffer}.
|
||||
*
|
||||
* <p>After calling this method output may be available via {@link #getOutput()} if all sources
|
||||
* have queued data.
|
||||
*
|
||||
* @param sourceId Source identifier from {@link #addSource}.
|
||||
* @param sourceBuffer The source buffer to mix. It must be a direct byte buffer with native byte
|
||||
* order. Its contents are treated as read-only. Its position will be advanced by the number
|
||||
* of bytes consumed (which may be zero). The caller retains ownership of the provided buffer.
|
||||
*/
|
||||
void queueInput(int sourceId, ByteBuffer sourceBuffer);
|
||||
|
||||
/**
|
||||
* Returns a buffer containing output audio data between its position and limit.
|
||||
*
|
||||
* <p>The buffer will be no larger than the configured buffer size and will include no more than
|
||||
* the frames that have been queued from all sources, up to the {@linkplain #setEndTimeUs end
|
||||
* time}. Silence will be generated for any periods with no sources.
|
||||
*
|
||||
* <p>The buffer will always be a direct byte buffer with native byte order. Calling this method
|
||||
* invalidates any previously returned buffer. The buffer will be empty if no output is available.
|
||||
*
|
||||
* @return A buffer containing output data between its position and limit.
|
||||
*/
|
||||
ByteBuffer getOutput();
|
||||
|
||||
/**
|
||||
* Returns whether the mixer can accept more {@linkplain #queueInput input} or produce more
|
||||
* {@linkplain #getOutput() output}, based on the {@link #setEndTimeUs end time}.
|
||||
*
|
||||
* <p><b>Note:</b> If no end time is set this will always return {@code false}.
|
||||
*/
|
||||
boolean isEnded();
|
||||
|
||||
/** Resets the mixer to its unconfigured state, releasing any resources. */
|
||||
void reset();
|
||||
}
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.transformer.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.Assertions.checkStateNotNull;
|
||||
import static java.lang.Math.min;
|
||||
|
||||
import android.util.SparseArray;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.audio.AudioProcessor.AudioFormat;
|
||||
import androidx.media3.common.audio.AudioProcessor.UnhandledAudioFormatException;
|
||||
import androidx.media3.common.util.Util;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
/** An {@link AudioMixer} that incrementally mixes source audio into a fixed size mixing buffer. */
|
||||
/* package */ final class AudioMixerImpl implements AudioMixer {
|
||||
|
||||
private static final ByteBuffer EMPTY_BUFFER =
|
||||
ByteBuffer.allocateDirect(0).order(ByteOrder.nativeOrder());
|
||||
|
||||
private final SparseArray<SourceInfo> sources;
|
||||
private int nextSourceId;
|
||||
private AudioFormat outputAudioFormat;
|
||||
@Nullable private AudioMixingAlgorithm mixingAlgorithm;
|
||||
private int bufferSizeFrames;
|
||||
private MixingBuffer[] mixingBuffers;
|
||||
private long mixerStartTimeUs;
|
||||
|
||||
/** Limit (in frames) of source audio that can be queued, relative to the mixer start. */
|
||||
private long inputLimit;
|
||||
|
||||
/** Position (in frames) of the next output audio frame, relative to the mixer start. */
|
||||
private long outputPosition;
|
||||
|
||||
/** Position (in frames) of the mixer end point, relative to the mixer start. */
|
||||
private long endPosition;
|
||||
|
||||
public AudioMixerImpl() {
|
||||
sources = new SparseArray<>();
|
||||
outputAudioFormat = AudioFormat.NOT_SET;
|
||||
bufferSizeFrames = C.LENGTH_UNSET;
|
||||
mixingBuffers = new MixingBuffer[0];
|
||||
mixerStartTimeUs = C.TIME_UNSET;
|
||||
inputLimit = C.LENGTH_UNSET;
|
||||
endPosition = Long.MAX_VALUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(AudioFormat outputAudioFormat, int bufferSizeMs, long startTimeUs)
|
||||
throws UnhandledAudioFormatException {
|
||||
checkState(!isConfigured(), "Audio mixer already configured.");
|
||||
|
||||
// Create algorithm first in case it throws.
|
||||
mixingAlgorithm = AudioMixingAlgorithm.create(outputAudioFormat);
|
||||
this.outputAudioFormat = outputAudioFormat;
|
||||
bufferSizeFrames = bufferSizeMs * outputAudioFormat.sampleRate / 1000;
|
||||
mixerStartTimeUs = startTimeUs;
|
||||
|
||||
mixingBuffers =
|
||||
new MixingBuffer[] {allocateMixingBuffer(0), allocateMixingBuffer(bufferSizeFrames)};
|
||||
updateInputFrameLimit();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEndTimeUs(long endTimeUs) {
|
||||
checkStateIsConfigured();
|
||||
checkArgument(
|
||||
endTimeUs >= mixerStartTimeUs, "End time must be at least the configured start time.");
|
||||
|
||||
endPosition =
|
||||
Util.scaleLargeTimestamp(
|
||||
endTimeUs - mixerStartTimeUs,
|
||||
/* multiplier= */ outputAudioFormat.sampleRate,
|
||||
/* divisor= */ C.MICROS_PER_SECOND);
|
||||
updateInputFrameLimit();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSourceAudioFormat(AudioFormat sourceFormat) {
|
||||
checkStateIsConfigured();
|
||||
return checkStateNotNull(mixingAlgorithm).supportsSourceAudioFormat(sourceFormat);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int addSource(AudioFormat sourceFormat, long startTimeUs)
|
||||
throws UnhandledAudioFormatException {
|
||||
checkStateIsConfigured();
|
||||
if (!supportsSourceAudioFormat(sourceFormat)) {
|
||||
throw new UnhandledAudioFormatException(sourceFormat);
|
||||
}
|
||||
|
||||
long startFrameOffset =
|
||||
Util.scaleLargeTimestamp(
|
||||
startTimeUs - mixerStartTimeUs,
|
||||
/* multiplier= */ sourceFormat.sampleRate,
|
||||
/* divisor= */ C.MICROS_PER_SECOND);
|
||||
int sourceId = nextSourceId++;
|
||||
sources.append(
|
||||
sourceId,
|
||||
new SourceInfo(
|
||||
sourceFormat,
|
||||
ChannelMixingMatrix.create(sourceFormat.channelCount, outputAudioFormat.channelCount),
|
||||
startFrameOffset));
|
||||
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSourceVolume(int sourceId, float volume) {
|
||||
checkStateIsConfigured();
|
||||
checkArgument(volume >= 0f, "Volume must be non-negative.");
|
||||
|
||||
SourceInfo source = getSourceById(sourceId);
|
||||
source.setVolume(volume);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeSource(int sourceId) {
|
||||
checkStateIsConfigured();
|
||||
sources.delete(sourceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void queueInput(int sourceId, ByteBuffer sourceBuffer) {
|
||||
checkStateIsConfigured();
|
||||
|
||||
SourceInfo source = getSourceById(sourceId);
|
||||
if (source.position >= inputLimit) {
|
||||
return;
|
||||
}
|
||||
|
||||
long newSourcePosition = min(source.getPositionAfterBuffer(sourceBuffer), inputLimit);
|
||||
if (source.getChannelMixingMatrix().isZero()) {
|
||||
// Fast path for silent sources that avoids mixing.
|
||||
source.discardTo(sourceBuffer, newSourcePosition);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.position < outputPosition) {
|
||||
// Discard early frames.
|
||||
source.discardTo(sourceBuffer, min(newSourcePosition, outputPosition));
|
||||
if (source.position == newSourcePosition) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (MixingBuffer mixingBuffer : mixingBuffers) {
|
||||
if (source.position >= mixingBuffer.limit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int mixingBufferPositionOffset =
|
||||
(int) (source.position - mixingBuffer.position) * outputAudioFormat.bytesPerFrame;
|
||||
mixingBuffer.buffer.position(mixingBuffer.buffer.position() + mixingBufferPositionOffset);
|
||||
source.mixTo(
|
||||
sourceBuffer,
|
||||
min(newSourcePosition, mixingBuffer.limit),
|
||||
checkNotNull(mixingAlgorithm),
|
||||
mixingBuffer.buffer);
|
||||
mixingBuffer.buffer.reset();
|
||||
|
||||
if (source.position == newSourcePosition) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ByteBuffer getOutput() {
|
||||
checkStateIsConfigured();
|
||||
|
||||
long minSourcePosition = endPosition;
|
||||
for (int i = 0; i < sources.size(); i++) {
|
||||
minSourcePosition = min(minSourcePosition, sources.valueAt(i).position);
|
||||
}
|
||||
|
||||
if (minSourcePosition <= outputPosition) {
|
||||
return EMPTY_BUFFER;
|
||||
}
|
||||
|
||||
MixingBuffer mixingBuffer = mixingBuffers[0];
|
||||
long newOutputPosition = min(minSourcePosition, mixingBuffer.limit);
|
||||
ByteBuffer outputBuffer = mixingBuffer.buffer.duplicate();
|
||||
outputBuffer
|
||||
.position((int) (outputPosition - mixingBuffer.position) * outputAudioFormat.bytesPerFrame)
|
||||
.limit((int) (newOutputPosition - mixingBuffer.position) * outputAudioFormat.bytesPerFrame);
|
||||
outputBuffer = outputBuffer.slice().order(ByteOrder.nativeOrder());
|
||||
|
||||
if (newOutputPosition == mixingBuffer.limit) {
|
||||
// TODO(b/264926272): Generalize for >2 mixing buffers.
|
||||
mixingBuffers[0] = mixingBuffers[1];
|
||||
mixingBuffers[1] = allocateMixingBuffer(mixingBuffers[1].limit);
|
||||
}
|
||||
|
||||
outputPosition = newOutputPosition;
|
||||
updateInputFrameLimit();
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnded() {
|
||||
checkStateIsConfigured();
|
||||
return outputPosition >= endPosition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset() {
|
||||
sources.clear();
|
||||
nextSourceId = 0;
|
||||
outputAudioFormat = AudioFormat.NOT_SET;
|
||||
mixingAlgorithm = null;
|
||||
bufferSizeFrames = C.LENGTH_UNSET;
|
||||
mixingBuffers = new MixingBuffer[0];
|
||||
mixerStartTimeUs = C.TIME_UNSET;
|
||||
inputLimit = C.LENGTH_UNSET;
|
||||
outputPosition = 0;
|
||||
endPosition = Long.MAX_VALUE;
|
||||
}
|
||||
|
||||
private boolean isConfigured() {
|
||||
return mixingAlgorithm != null;
|
||||
}
|
||||
|
||||
private void checkStateIsConfigured() {
|
||||
checkState(isConfigured(), "Audio mixer is not configured.");
|
||||
}
|
||||
|
||||
private MixingBuffer allocateMixingBuffer(long position) {
|
||||
ByteBuffer buffer =
|
||||
ByteBuffer.allocateDirect(bufferSizeFrames * outputAudioFormat.bytesPerFrame)
|
||||
.order(ByteOrder.nativeOrder());
|
||||
buffer.mark();
|
||||
return new MixingBuffer(buffer, position, position + bufferSizeFrames);
|
||||
}
|
||||
|
||||
private void updateInputFrameLimit() {
|
||||
inputLimit = min(endPosition, outputPosition + bufferSizeFrames);
|
||||
}
|
||||
|
||||
private SourceInfo getSourceById(int sourceId) {
|
||||
return checkStateNotNull(sources.get(sourceId), "Source not found.");
|
||||
}
|
||||
|
||||
/** A buffer holding partially-mixed audio within an interval. */
|
||||
private static class MixingBuffer {
|
||||
public final ByteBuffer buffer;
|
||||
|
||||
/** Position (in frames) of the first frame in {@code buffer} relative to the mixer start. */
|
||||
public final long position;
|
||||
|
||||
/**
|
||||
* Position (in frames) one past the last frame in {@code buffer} relative to the mixer start.
|
||||
*/
|
||||
public final long limit;
|
||||
|
||||
public MixingBuffer(ByteBuffer buffer, long position, long limit) {
|
||||
this.buffer = buffer;
|
||||
this.position = position;
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-source information. */
|
||||
private static class SourceInfo {
|
||||
/**
|
||||
* Position (in frames) of the next source audio frame to be input by the source, relative to
|
||||
* the mixer start.
|
||||
*
|
||||
* <p>Note: The position can be negative if the source start time is less than the mixer start
|
||||
* time.
|
||||
*/
|
||||
public long position;
|
||||
|
||||
private final AudioFormat audioFormat;
|
||||
private final ChannelMixingMatrix baseChannelMixingMatrix;
|
||||
private ChannelMixingMatrix channelMixingMatrix;
|
||||
|
||||
public SourceInfo(
|
||||
AudioFormat audioFormat,
|
||||
ChannelMixingMatrix baseChannelMixingMatrix,
|
||||
long startFrameOffset) {
|
||||
this.audioFormat = audioFormat;
|
||||
this.baseChannelMixingMatrix = baseChannelMixingMatrix;
|
||||
position = startFrameOffset;
|
||||
channelMixingMatrix = baseChannelMixingMatrix; // Volume = 1f.
|
||||
}
|
||||
|
||||
public ChannelMixingMatrix getChannelMixingMatrix() {
|
||||
return channelMixingMatrix;
|
||||
}
|
||||
|
||||
public void setVolume(float volume) {
|
||||
channelMixingMatrix = baseChannelMixingMatrix.scaleBy(volume);
|
||||
}
|
||||
|
||||
/** Returns the position of the next audio frame after {@code sourceBuffer}. */
|
||||
public long getPositionAfterBuffer(ByteBuffer sourceBuffer) {
|
||||
int sourceBufferFrameCount = sourceBuffer.remaining() / audioFormat.bytesPerFrame;
|
||||
return position + sourceBufferFrameCount;
|
||||
}
|
||||
|
||||
/** Discards audio frames within {@code sourceBuffer} to the new source position. */
|
||||
public void discardTo(ByteBuffer sourceBuffer, long newPosition) {
|
||||
checkArgument(newPosition >= position);
|
||||
int framesToDiscard = (int) (newPosition - position);
|
||||
sourceBuffer.position(sourceBuffer.position() + framesToDiscard * audioFormat.bytesPerFrame);
|
||||
position = newPosition;
|
||||
}
|
||||
|
||||
/** Mixes audio frames from {@code sourceBuffer} to the new source position. */
|
||||
public void mixTo(
|
||||
ByteBuffer sourceBuffer,
|
||||
long newPosition,
|
||||
AudioMixingAlgorithm mixingAlgorithm,
|
||||
ByteBuffer mixingBuffer) {
|
||||
checkArgument(newPosition >= position);
|
||||
int framesToMix = (int) (newPosition - position);
|
||||
mixingAlgorithm.mix(
|
||||
sourceBuffer, audioFormat, channelMixingMatrix, framesToMix, mixingBuffer);
|
||||
position = newPosition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.transformer.audio;
|
||||
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.audio.AudioProcessor.AudioFormat;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.FloatBuffer;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/** Unit tests for {@link AudioMixerImpl}. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class AudioMixerImplTest {
|
||||
|
||||
private static final int SAMPLE_RATE = 1000; // 1 ms = 1 frame.
|
||||
private static final AudioFormat AUDIO_FORMAT_STEREO_PCM_FLOAT =
|
||||
new AudioFormat(SAMPLE_RATE, /* channelCount= */ 2, C.ENCODING_PCM_FLOAT);
|
||||
private static final AudioFormat AUDIO_FORMAT_STEREO_PCM_16BIT =
|
||||
new AudioFormat(SAMPLE_RATE, /* channelCount= */ 2, C.ENCODING_PCM_16BIT);
|
||||
|
||||
private final AudioMixer mixer = new AudioMixerImpl();
|
||||
|
||||
@Test
|
||||
public void output_withNoSource_isSilence() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput())).isEqualTo(new float[6]);
|
||||
// Repeated calls produce more silence.
|
||||
assertThat(createFloatArray(mixer.getOutput())).isEqualTo(new float[6]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneSource_isInput() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
ByteBuffer sourceBuffer = createByteBuffer(new float[] {0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f});
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
assertThat(sourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput()))
|
||||
.isEqualTo(new float[] {0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withTwoConcurrentSources_isMixed() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int firstSourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
ByteBuffer firstSourceBuffer =
|
||||
createByteBuffer(new float[] {0.0625f, 0.125f, 0.1875f, 0.25f, 0.3125f, 0.375f});
|
||||
mixer.queueInput(firstSourceId, firstSourceBuffer);
|
||||
assertThat(firstSourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
int secondSourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
ByteBuffer secondSourceBuffer =
|
||||
createByteBuffer(new float[] {0.4375f, 0.375f, 0.3125f, 0.25f, 0.1875f, 0.125f});
|
||||
mixer.queueInput(secondSourceId, secondSourceBuffer);
|
||||
assertThat(secondSourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput()))
|
||||
.isEqualTo(new float[] {0.5f, 0.5f, 0.5f, 0.5f, 0.5f, 0.5f});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withTwoConcurrentSources_isMixedToSmallerInput() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int firstSourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
ByteBuffer firstSourceBuffer = createByteBuffer(new float[] {0.5f, -0.5f, 0.25f, -0.25f});
|
||||
mixer.queueInput(firstSourceId, firstSourceBuffer);
|
||||
assertThat(firstSourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
int secondSourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
ByteBuffer secondSourceBuffer = createByteBuffer(new float[] {-0.25f, 0.25f});
|
||||
mixer.queueInput(secondSourceId, secondSourceBuffer);
|
||||
assertThat(secondSourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput())).isEqualTo(new float[] {0.25f, -0.25f});
|
||||
assertThat(createFloatArray(mixer.getOutput())).isEqualTo(new float[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void input_afterPartialOutput_isConsumedToBufferSize() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int firstSourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
|
||||
int secondSourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
|
||||
mixer.queueInput(firstSourceId, createByteBuffer(new float[] {0.5f, -0.5f, 0.25f, -0.25f}));
|
||||
mixer.queueInput(secondSourceId, createByteBuffer(new float[] {-0.25f, 0.25f}));
|
||||
assertThat(mixer.getOutput().remaining()).isEqualTo(8 /* 2 floats = 1 frame */);
|
||||
|
||||
ByteBuffer firstSourceBuffer =
|
||||
createByteBuffer(new float[] {0.125f, -0.125f, 0.0625f, -0.0625f, 0.75f, -0.75f});
|
||||
mixer.queueInput(firstSourceId, firstSourceBuffer);
|
||||
assertThat(firstSourceBuffer.remaining()).isEqualTo(8 /* 2 floats = 1 frame */);
|
||||
|
||||
ByteBuffer secondSourceBuffer =
|
||||
createByteBuffer(new float[] {-0.375f, 0.375f, -0.5f, 0.5f, -0.625f, 0.625f});
|
||||
mixer.queueInput(secondSourceId, secondSourceBuffer);
|
||||
assertThat(secondSourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput()))
|
||||
.isEqualTo(new float[] {-0.125f, 0.125f, -0.375f, 0.375f});
|
||||
assertThat(createFloatArray(mixer.getOutput())).isEqualTo(new float[] {-0.5625f, 0.5625f});
|
||||
assertThat(createFloatArray(mixer.getOutput())).isEqualTo(new float[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneLaterSource_isSilenceThenInput() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 2_000);
|
||||
ByteBuffer sourceBuffer = createByteBuffer(new float[] {0.1f, -0.1f, 0.2f, -0.2f, 0.3f, -0.3f});
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
assertThat(sourceBuffer.remaining()).isEqualTo(16 /* 4 floats = 2 frames */);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput()))
|
||||
.isEqualTo(new float[] {0f, 0f, 0f, 0f, 0.1f, -0.1f});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneEarlierSource_omitsEarlyInput() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 2_000);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
ByteBuffer sourceBuffer = createByteBuffer(new float[] {0.1f, -0.1f, 0.2f, -0.2f, 0.3f, -0.3f});
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
assertThat(sourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
// First two frames are discarded.
|
||||
assertThat(createFloatArray(mixer.getOutput())).isEqualTo(new float[] {0.3f, -0.3f});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneSourceTwoSmallInputs_isConcatenatedInput() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
ByteBuffer firstSourceBuffer = createByteBuffer(new float[] {0.1f, -0.1f, 0.2f, -0.2f});
|
||||
mixer.queueInput(sourceId, firstSourceBuffer);
|
||||
assertThat(firstSourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
ByteBuffer secondSourceBuffer = createByteBuffer(new float[] {0.3f, -0.3f});
|
||||
mixer.queueInput(sourceId, secondSourceBuffer);
|
||||
assertThat(secondSourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput()))
|
||||
.isEqualTo(new float[] {0.1f, -0.1f, 0.2f, -0.2f, 0.3f, -0.3f});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneSourceTwoLargeInputs_isConcatenatedInput() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
ByteBuffer sourceBuffer =
|
||||
createByteBuffer(new float[] {0.1f, -0.1f, 0.2f, -0.2f, 0.3f, -0.3f, 0.4f, -0.4f});
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
assertThat(sourceBuffer.remaining()).isEqualTo(8 /* 2 floats = 1 frame */);
|
||||
|
||||
assertThat(mixer.getOutput().remaining()).isEqualTo(24 /* 6 floats = 3 frames */);
|
||||
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
assertThat(sourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput())).isEqualTo(new float[] {0.4f, -0.4f});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneSourceHavingOneSmallOneLargeInput_isConcatenatedInput()
|
||||
throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
ByteBuffer firstSourceBuffer = createByteBuffer(new float[] {0.1f, -0.1f, 0.2f, -0.2f});
|
||||
mixer.queueInput(sourceId, firstSourceBuffer);
|
||||
assertThat(firstSourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
ByteBuffer secondSourceBuffer =
|
||||
createByteBuffer(new float[] {0.3f, -0.3f, 0.4f, -0.4f, 0.5f, 5f});
|
||||
mixer.queueInput(sourceId, secondSourceBuffer);
|
||||
assertThat(secondSourceBuffer.remaining()).isEqualTo(16 /* 4 floats = 2 frames */);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput()))
|
||||
.isEqualTo(new float[] {0.1f, -0.1f, 0.2f, -0.2f, 0.3f, -0.3f});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneSourceHalfVolume_isInputHalfAmplitude() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
mixer.setSourceVolume(sourceId, 0.5f);
|
||||
ByteBuffer sourceBuffer = createByteBuffer(new float[] {0.25f, 0.5f, 0.25f, 0.5f, 0.25f, 0.5f});
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput()))
|
||||
.isEqualTo(new float[] {0.125f, 0.25f, 0.125f, 0.25f, 0.125f, 0.25f});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneEndedSource_isInputThenSilence() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 0);
|
||||
mixer.queueInput(sourceId, createByteBuffer(new float[] {0.1f, -0.1f}));
|
||||
mixer.removeSource(sourceId);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput()))
|
||||
.isEqualTo(new float[] {0.1f, -0.1f, 0f, 0f, 0f, 0f});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneSourceAndEndTime_isInputUntilEndTime() throws Exception {
|
||||
mixer.configure(
|
||||
AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 10_000);
|
||||
mixer.setEndTimeUs(11_000);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 10_000);
|
||||
ByteBuffer sourceBuffer = createByteBuffer(new float[] {0.1f, -0.1f, 0.2f, -0.2f, 0.3f, -0.3f});
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
assertThat(sourceBuffer.remaining()).isEqualTo(16 /* 4 floats = 2 frames */);
|
||||
|
||||
assertThat(mixer.isEnded()).isFalse();
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput())).isEqualTo(new float[] {0.1f, -0.1f});
|
||||
assertThat(mixer.isEnded()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void input_whileIsEnded_isNotConsumed() throws Exception {
|
||||
mixer.configure(
|
||||
AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 10_000);
|
||||
mixer.setEndTimeUs(11_000);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 10_000);
|
||||
ByteBuffer sourceBuffer = createByteBuffer(new float[] {0.1f, -0.1f, 0.2f, -0.2f, 0.3f, -0.3f});
|
||||
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
mixer.getOutput();
|
||||
assertThat(mixer.isEnded()).isTrue();
|
||||
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
assertThat(sourceBuffer.remaining()).isEqualTo(16 /* 4 floats = 2 frames */);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void setEndTime_afterIsEnded_changesIsEnded() throws Exception {
|
||||
mixer.configure(
|
||||
AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 10_000);
|
||||
mixer.setEndTimeUs(11_000);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ 10_000);
|
||||
ByteBuffer sourceBuffer = createByteBuffer(new float[] {0.1f, -0.1f, 0.2f, -0.2f, 0.3f, -0.3f});
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
|
||||
mixer.getOutput();
|
||||
assertThat(mixer.isEnded()).isTrue();
|
||||
|
||||
mixer.setEndTimeUs(12_000);
|
||||
assertThat(mixer.isEnded()).isFalse();
|
||||
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
assertThat(sourceBuffer.remaining()).isEqualTo(8 /* 2 floats = 2 frames */);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput())).isEqualTo(new float[] {0.2f, -0.2f});
|
||||
assertThat(mixer.isEnded()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneInt16Source_isInputConvertedToFloat() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
int sourceId = mixer.addSource(AUDIO_FORMAT_STEREO_PCM_16BIT, /* startTimeUs= */ 0);
|
||||
ByteBuffer sourceBuffer =
|
||||
createByteBuffer(
|
||||
new short[] {
|
||||
-16384 /* -0.5f */,
|
||||
8192 /* 0.25000762962f */,
|
||||
-8192 /* -0.25f */,
|
||||
16384 /* 0.50001525925f */
|
||||
});
|
||||
mixer.queueInput(sourceId, sourceBuffer);
|
||||
assertThat(sourceBuffer.remaining()).isEqualTo(0);
|
||||
|
||||
assertThat(createFloatArray(mixer.getOutput()))
|
||||
.usingTolerance(1f / Short.MAX_VALUE)
|
||||
.containsExactly(new float[] {-0.5f, 0.25f, -0.25f, 0.5f})
|
||||
.inOrder();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void output_withOneEarlySource_isEmpty() throws Exception {
|
||||
mixer.configure(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* bufferSizeMs= */ 3, /* startTimeUs= */ 0);
|
||||
|
||||
mixer.addSource(AUDIO_FORMAT_STEREO_PCM_FLOAT, /* startTimeUs= */ -1_000);
|
||||
|
||||
assertThat(mixer.getOutput().remaining()).isEqualTo(0);
|
||||
}
|
||||
|
||||
private static ByteBuffer createByteBuffer(float[] content) {
|
||||
ByteBuffer buffer =
|
||||
ByteBuffer.allocateDirect(content.length * 4).order(ByteOrder.nativeOrder());
|
||||
buffer.asFloatBuffer().put(content);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static ByteBuffer createByteBuffer(short[] content) {
|
||||
ByteBuffer byteBuffer =
|
||||
ByteBuffer.allocateDirect(content.length * 2).order(ByteOrder.nativeOrder());
|
||||
byteBuffer.asShortBuffer().put(content);
|
||||
return byteBuffer;
|
||||
}
|
||||
|
||||
private static float[] createFloatArray(ByteBuffer byteBuffer) {
|
||||
FloatBuffer buffer = byteBuffer.asFloatBuffer();
|
||||
float[] content = new float[buffer.remaining()];
|
||||
buffer.get(content);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue