Release MIDI decoder module in open-source

PiperOrigin-RevId: 537034577
This commit is contained in:
christosts 2023-06-01 15:45:21 +00:00 committed by Tofunmi Adigun-Hameed
parent c52130a212
commit 757247e2ae
24 changed files with 10670 additions and 0 deletions

View File

@ -29,6 +29,9 @@
* Smooth Streaming Extension:
* RTSP Extension:
* Decoder Extensions (FFmpeg, VP9, AV1, etc.):
* MIDI extension:
* Release the MIDI decoder module, which provides support for playback of
standard MIDI files using the Jsyn library to synthesize audio.
* Cast Extension:
* Test Utilities:
* Remove deprecated symbols:

View File

@ -26,6 +26,12 @@ allprojects {
repositories {
google()
mavenCentral()
maven {
url 'https://jitpack.io'
content {
includeGroup "com.github.philburk"
}
}
}
if (it.hasProperty('externalBuildDir')) {
if (!new File(externalBuildDir).isAbsolute()) {

View File

@ -70,6 +70,8 @@ include modulePrefix + 'lib-decoder-ffmpeg'
project(modulePrefix + 'lib-decoder-ffmpeg').projectDir = new File(rootDir, 'libraries/decoder_ffmpeg')
include modulePrefix + 'lib-decoder-flac'
project(modulePrefix + 'lib-decoder-flac').projectDir = new File(rootDir, 'libraries/decoder_flac')
include modulePrefix + 'lib-decoder-midi'
project(modulePrefix + 'lib-decoder-midi').projectDir = new File(rootDir, 'libraries/decoder_midi')
include modulePrefix + 'lib-decoder-opus'
project(modulePrefix + 'lib-decoder-opus').projectDir = new File(rootDir, 'libraries/decoder_opus')
include modulePrefix + 'lib-decoder-vp9'

View File

@ -90,6 +90,7 @@ dependencies {
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-flac')
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-opus')
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-vp9')
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-midi')
withDecoderExtensionsImplementation project(modulePrefix + 'lib-datasource-rtmp')
}

View File

@ -0,0 +1,42 @@
# MIDI decoder module
The MIDI module provides `MidiExtractor` for parsing standard MIDI files, and
`MidiRenderer` which uses the audio synthesization library [JSyn][] to process
MIDI commands and render the PCM output.
## Getting the module
The easiest way to get the module is to add it as a gradle dependency:
```gradle
implementation 'androidx.media3:lib-decoder-midi:1.X.X'
```
where `1.X.X` is the version, which must match the version of the other media
modules being used.
Alternatively, you can clone this GitHub project and depend on the module
locally. Instructions for doing this can be found in the [top level README][].
The module depends on [JSyn][] as a maven dependency from
[jitpack.io](https://jitpack.io) and you will need to define the maven
repository in your build scripts. For example, add
```gradle
repositories {
maven { url 'https://jitpack.io' }
}
```
in the `build.gradle` of module in your app that is using the MIDI module.
## Use in the demo app
Modify the demo app's `build.script` file and uncomment the definition of the
`jitpack.io` maven repository, as well as uncomment the dependency to the MIDI
module in the `dependencies` section.
[JSyn]: https://github.com/philburk/jsyn
[top level README]: ../../README.md

View File

@ -0,0 +1,41 @@
// 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.
apply from: "$gradle.ext.androidxMediaSettingsDir/common_library_config.gradle"
android {
namespace 'androidx.media3.decoder.midi'
sourceSets.test.assets.srcDir '../test_data/src/test/assets/'
}
dependencies {
implementation project(modulePrefix + 'lib-exoplayer')
implementation project(modulePrefix + 'lib-decoder')
implementation project(modulePrefix + 'lib-extractor')
implementation project(modulePrefix + 'lib-common')
implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
// Jsyn v17.1.0
implementation 'com.github.philburk:jsyn:40a41092cbab558d7d410ec43d93bb1e4121e86a'
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
testImplementation 'androidx.test:core:' + androidxTestCoreVersion
testImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion
testImplementation project(modulePrefix + 'test-utils')
testImplementation project(modulePrefix + 'test-data')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {
javadocTitle = 'MIDI extension'
}
apply from: '../../javadoc_library.gradle'

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<manifest package="androidx.media3.decoder.midi">
<uses-sdk />
</manifest>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,233 @@
/*
* 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.decoder.midi;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import android.content.Context;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.util.Util;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.decoder.SimpleDecoder;
import androidx.media3.decoder.SimpleDecoderOutputBuffer;
import com.jsyn.JSyn;
import com.jsyn.Synthesizer;
import com.jsyn.midi.MidiSynthesizer;
import com.jsyn.util.AudioStreamReader;
import com.jsyn.util.MultiChannelSynthesizer;
import java.nio.ByteBuffer;
import org.checkerframework.checker.initialization.qual.UnknownInitialization;
import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
/** Decodes MIDI commands into PCM. */
/* package */ final class MidiDecoder
extends SimpleDecoder<DecoderInputBuffer, SimpleDecoderOutputBuffer, MidiDecoderException> {
/** The number of channels output by the decoder. */
public static final int NUM_OUTPUT_CHANNELS = 2;
/** The default input buffer count. */
public static final int DEFAULT_INPUT_BUFFER_COUNT = 16;
/** The default output buffer count. */
public static final int DEFAULT_OUTPUT_BUFFER_COUNT = 16;
/** The standard number of MIDI channels. */
public static final int CHANNEL_COUNT = 16;
/** The default sample rate, measured in Hertz. */
public static final int DEFAULT_SAMPLE_RATE = 44100;
/** The time interval in seconds for the synthesizer to produce PCM samples for. */
private static final double PCM_GENERATION_STEP_SECS = .1;
private static final int DEFAULT_AUDIO_OUTPUT_BUFFER_SIZE =
(int) (PCM_GENERATION_STEP_SECS * DEFAULT_SAMPLE_RATE * NUM_OUTPUT_CHANNELS);
private static final int PCM_SAMPLE_SIZE_BYTES = 2;
/** Returns the format output by MIDI Decoders. */
public static Format getDecoderOutputFormat() {
// MidiDecoder only supports outputting float PCM, two channels, and the specified sample rate.
return Util.getPcmFormat(C.ENCODING_PCM_FLOAT, NUM_OUTPUT_CHANNELS, DEFAULT_SAMPLE_RATE);
}
private final Context context;
private Synthesizer synth;
private MultiChannelSynthesizer multiSynth;
private MidiSynthesizer midiSynthesizer;
private AudioStreamReader reader;
private double[] audioStreamOutputBuffer;
private long lastReceivedTimestampUs;
private long outputTimeUs;
/**
* Creates a MIDI decoder with {@link #DEFAULT_INPUT_BUFFER_COUNT} input buffers and {@link
* #DEFAULT_OUTPUT_BUFFER_COUNT} output buffers.
*/
public MidiDecoder(Context context) throws MidiDecoderException {
this(
context,
/* inputBufferCount= */ DEFAULT_INPUT_BUFFER_COUNT,
/* outputBufferCount= */ DEFAULT_OUTPUT_BUFFER_COUNT);
}
/**
* Creates an instance.
*
* @param context The application context.
* @param inputBufferCount The {@link DecoderInputBuffer} size.
* @param outputBufferCount The {@link SimpleDecoderOutputBuffer} size.
* @throws MidiDecoderException if there is an error initializing the decoder.
*/
public MidiDecoder(Context context, int inputBufferCount, int outputBufferCount)
throws MidiDecoderException {
super(
new DecoderInputBuffer[inputBufferCount], new SimpleDecoderOutputBuffer[outputBufferCount]);
this.context = context;
audioStreamOutputBuffer = new double[DEFAULT_AUDIO_OUTPUT_BUFFER_SIZE];
lastReceivedTimestampUs = C.TIME_UNSET;
createSynthesizers();
}
@Override
public String getName() {
return "MidiDecoder";
}
@Override
protected DecoderInputBuffer createInputBuffer() {
return new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL);
}
@Override
protected SimpleDecoderOutputBuffer createOutputBuffer() {
return new SimpleDecoderOutputBuffer(this::releaseOutputBuffer);
}
@Override
protected MidiDecoderException createUnexpectedDecodeException(Throwable error) {
return new MidiDecoderException("Unexpected decode error", error);
}
@Override
@Nullable
@SuppressWarnings(
"ByteBufferBackingArray") // ByteBuffers are created using allocate. See createInputBuffer().
protected MidiDecoderException decode(
DecoderInputBuffer inputBuffer, SimpleDecoderOutputBuffer outputBuffer, boolean reset) {
ByteBuffer inputBufferData = checkNotNull(inputBuffer.data);
if (reset) {
lastReceivedTimestampUs = C.TIME_UNSET;
try {
resetSynthesizers();
} catch (MidiDecoderException e) {
return e;
}
}
if (lastReceivedTimestampUs == C.TIME_UNSET) {
outputTimeUs = inputBuffer.timeUs;
}
try {
if (!inputBuffer.isDecodeOnly()) {
// Yield the thread to the Synthesizer to produce PCM samples up to this buffer's timestamp.
if (lastReceivedTimestampUs != C.TIME_UNSET) {
double timeToSleepSecs = (inputBuffer.timeUs - lastReceivedTimestampUs) * 0.000001D;
synth.sleepFor(timeToSleepSecs);
}
lastReceivedTimestampUs = inputBuffer.timeUs;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
// Only pass buffers populated with MIDI command data to the synthesizer, ignoring empty buffers
// that act as a flag to render previously passed commands for the correct time.
if (inputBufferData.remaining() > 0) {
midiSynthesizer.onReceive(
/* bytes= */ inputBufferData.array(),
/* offset= */ inputBufferData.position(),
/* length= */ inputBufferData.remaining());
}
int availableSamples = reader.available();
// Ensure there are no remaining bytes if the input buffer is decode only.
checkState(!inputBuffer.isDecodeOnly() || reader.available() == 0);
if (availableSamples > audioStreamOutputBuffer.length) {
// Increase the size of the buffer by 25% of the availableSamples (arbitrary number).
// This way we give some overhead so that having to resizing it again is less likely.
int newSize = (availableSamples * 125) / 100;
audioStreamOutputBuffer = new double[newSize];
}
int synthOutputSamplesRead = 0;
while (synthOutputSamplesRead < availableSamples) {
synthOutputSamplesRead +=
reader.read(
/* buffer= */ audioStreamOutputBuffer,
/* start= */ synthOutputSamplesRead,
/* count= */ availableSamples - synthOutputSamplesRead);
}
outputBuffer.init(
outputTimeUs,
synthOutputSamplesRead * /* bytesPerSample= */ PCM_SAMPLE_SIZE_BYTES * NUM_OUTPUT_CHANNELS);
ByteBuffer outputBufferData = checkNotNull(outputBuffer.data);
for (int i = 0; i < synthOutputSamplesRead; i++) {
outputBufferData.putFloat((float) audioStreamOutputBuffer[i]);
}
outputBufferData.flip();
// Divide synthOutputSamplesRead by channel count to get the frame rate,
// and then divide by the sample rate to get the duration in seconds.
// Multiply by 1_000_000 to convert to microseconds.
outputTimeUs =
outputTimeUs
+ synthOutputSamplesRead * 1_000_000L / NUM_OUTPUT_CHANNELS / DEFAULT_SAMPLE_RATE;
return null;
}
@Override
public void release() {
synth.stop();
super.release();
}
private void resetSynthesizers() throws MidiDecoderException {
synth.stop();
multiSynth.getOutput().disconnectAll();
createSynthesizers();
}
@EnsuresNonNull({"synth", "multiSynth", "reader", "midiSynthesizer"})
private void createSynthesizers(@UnknownInitialization MidiDecoder this)
throws MidiDecoderException {
synth = JSyn.createSynthesizer();
synth.setRealTime(false);
multiSynth = new MultiChannelSynthesizer();
multiSynth.setup(
synth,
/* startChannel= */ 0,
/* numChannels= */ CHANNEL_COUNT,
/* voicesPerChannel= */ 4,
SonivoxVoiceDescription.getInstance(checkNotNull(context)));
midiSynthesizer = new MidiSynthesizer(multiSynth);
reader = new AudioStreamReader(synth, /* samplesPerFrame= */ 2);
multiSynth.getOutput().connect(/* thisPartNum= */ 0, reader.getInput(), /* otherPartNum= */ 0);
multiSynth.getOutput().connect(/* thisPartNum= */ 0, reader.getInput(), /* otherPartNum= */ 1);
synth.start();
}
}

View File

@ -0,0 +1,28 @@
/*
* 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.decoder.midi;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.decoder.DecoderException;
/** Thrown when {@link MidiDecoder} encounters any errors. */
@UnstableApi
public final class MidiDecoderException extends DecoderException {
public MidiDecoderException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,360 @@
/*
* 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.decoder.midi;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static java.lang.annotation.ElementType.TYPE_USE;
import androidx.annotation.IntDef;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.extractor.DummyTrackOutput;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.PositionHolder;
import androidx.media3.extractor.SeekMap;
import androidx.media3.extractor.SeekPoint;
import androidx.media3.extractor.TrackOutput;
import com.google.common.primitives.Ints;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.PriorityQueue;
/** Extracts data from MIDI containers. */
@UnstableApi
public final class MidiExtractor implements Extractor, SeekMap {
@SuppressWarnings("ConstantCaseForConstants")
private static final int FOURCC_MThd = 0x4d546864;
@SuppressWarnings("ConstantCaseForConstants")
private static final int FOURCC_MTrk = 0x4d54726b;
/**
* Extractor state for parsing files. One of {@link #STATE_INITIALIZED}, {@link #STATE_LOADING},
* {@link #STATE_PREPARING_CHUNKS}, {@link #STATE_PARSING_SAMPLES}, or {@link #STATE_RELEASED}.
*/
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef({
STATE_INITIALIZED,
STATE_LOADING,
STATE_PREPARING_CHUNKS,
STATE_PARSING_SAMPLES,
STATE_RELEASED
})
private @interface State {}
private static final int STATE_INITIALIZED = 0;
private static final int STATE_LOADING = 1;
private static final int STATE_PREPARING_CHUNKS = 2;
private static final int STATE_PARSING_SAMPLES = 3;
private static final int STATE_RELEASED = 4;
/**
* The maximum timestamp difference between two consecutive samples output to {@link
* TrackOutput#sampleMetadata}.
*
* <p>The {@link MidiDecoder} will only be called for each sample output by this extractor,
* meaning that the size of the decoder's PCM output buffers is proportional to the time between
* two samples output by the extractor. In order to make the PCM output buffers manageable, we
* periodically produce samples (which may be empty) so as to allow the decoder to produce buffers
* of a small pre-determined size, which at most can be the PCM that corresponds to the period
* described by this variable.
*/
private static final long MAX_SAMPLE_DURATION_US = 100_000;
private static final int HEADER_LEN_BYTES = 14;
private final ArrayList<TrackChunk> trackChunkList;
private final PriorityQueue<TrackChunk> trackPriorityQueue;
private final ParsableByteArray midiFileData;
private @State int state;
private int bytesRead;
private int ticksPerQuarterNote;
private long currentTimestampUs;
private long startTimeUs;
private TrackOutput trackOutput;
public MidiExtractor() {
state = STATE_INITIALIZED;
trackChunkList = new ArrayList<>();
trackPriorityQueue = new PriorityQueue<>();
midiFileData = new ParsableByteArray(/* limit= */ 512);
trackOutput = new DummyTrackOutput();
}
// Extractor implementation.
@Override
public void init(ExtractorOutput output) {
if (state != STATE_INITIALIZED) {
throw new IllegalStateException();
}
trackOutput = output.track(0, C.TRACK_TYPE_AUDIO);
trackOutput.format(
new Format.Builder()
.setCodecs(MimeTypes.AUDIO_MIDI)
.setSampleMimeType(MimeTypes.AUDIO_EXOPLAYER_MIDI)
.build());
output.endTracks();
output.seekMap(this);
state = STATE_LOADING;
}
@Override
public boolean sniff(ExtractorInput input) throws IOException {
ParsableByteArray buffer = new ParsableByteArray(/* limit= */ 4);
input.peekFully(buffer.getData(), /* offset= */ 0, 4);
return isMidiHeaderIdentifier(buffer);
}
@Override
public void seek(long position, long timeUs) {
checkState(state != STATE_RELEASED);
startTimeUs = timeUs;
if (state == STATE_LOADING) {
midiFileData.setPosition(0);
bytesRead = 0;
} else {
state = STATE_PREPARING_CHUNKS;
}
}
@Override
public int read(final ExtractorInput input, PositionHolder seekPosition) throws IOException {
switch (state) {
case STATE_LOADING:
int inputFileSize = Ints.checkedCast(input.getLength());
int currentDataLength = midiFileData.getData().length;
// Increase the size of the input byte array if needed.
if (bytesRead == currentDataLength) {
// Resize the array to the final file size length, or if unknown, to the current_size *
// 1.5.
midiFileData.ensureCapacity(
(inputFileSize != C.LENGTH_UNSET ? inputFileSize : currentDataLength) * 3 / 2);
}
int actualBytesRead =
input.read(
/* buffer= */ midiFileData.getData(),
/* offset= */ bytesRead,
/* length= */ midiFileData.capacity() - bytesRead);
if (actualBytesRead != C.RESULT_END_OF_INPUT) {
bytesRead += actualBytesRead;
// Continue reading if the final file size is unknown or the amount already read isn't
// equal to the final file size yet.
if (inputFileSize == C.LENGTH_UNSET || bytesRead != inputFileSize) {
return RESULT_CONTINUE;
}
}
midiFileData.setLimit(bytesRead);
parseTracks();
state = STATE_PREPARING_CHUNKS;
return RESULT_CONTINUE;
case STATE_PREPARING_CHUNKS:
trackPriorityQueue.clear();
for (TrackChunk chunk : trackChunkList) {
chunk.reset();
chunk.populateFrontTrackEvent();
}
trackPriorityQueue.addAll(trackChunkList);
seekChunksTo(startTimeUs);
currentTimestampUs = startTimeUs;
long nextTimestampUs = checkNotNull(trackPriorityQueue.peek()).peekNextTimestampUs();
if (nextTimestampUs > currentTimestampUs) {
outputEmptySample();
}
state = STATE_PARSING_SAMPLES;
return RESULT_CONTINUE;
case STATE_PARSING_SAMPLES:
TrackChunk nextChunk = checkNotNull(trackPriorityQueue.poll());
int result = RESULT_END_OF_INPUT;
long nextCommandTimestampUs = nextChunk.peekNextTimestampUs();
if (nextCommandTimestampUs != C.TIME_UNSET) {
if (currentTimestampUs + MAX_SAMPLE_DURATION_US < nextCommandTimestampUs) {
currentTimestampUs += MAX_SAMPLE_DURATION_US;
outputEmptySample();
} else { // Event time is sooner than the maximum threshold.
currentTimestampUs = nextCommandTimestampUs;
nextChunk.outputFrontSample(trackOutput);
nextChunk.populateFrontTrackEvent();
}
result = RESULT_CONTINUE;
}
trackPriorityQueue.add(nextChunk);
return result;
case STATE_INITIALIZED:
case STATE_RELEASED:
throw new IllegalStateException();
default:
throw new IllegalStateException(); // Should never happen.
}
}
@Override
public void release() {
trackChunkList.clear();
trackPriorityQueue.clear();
midiFileData.reset(/* data= */ Util.EMPTY_BYTE_ARRAY);
state = STATE_RELEASED;
}
// SeekMap implementation.
@Override
public boolean isSeekable() {
return true;
}
@Override
public long getDurationUs() {
return C.TIME_UNSET;
}
@Override
public SeekPoints getSeekPoints(long timeUs) {
if (state == STATE_PREPARING_CHUNKS || state == STATE_PARSING_SAMPLES) {
return new SeekPoints(new SeekPoint(timeUs, HEADER_LEN_BYTES));
}
return new SeekPoints(SeekPoint.START);
}
// Internal methods.
private void parseTracks() throws ParserException {
if (midiFileData.bytesLeft() < HEADER_LEN_BYTES) {
throw ParserException.createForMalformedContainer(/* message= */ null, /* cause= */ null);
}
if (!isMidiHeaderIdentifier(midiFileData)) {
throw ParserException.createForMalformedContainer(/* message= */ null, /* cause= */ null);
}
midiFileData.skipBytes(4); // length (4 bytes)
int fileFormat = midiFileData.readShort();
int trackCount = midiFileData.readShort();
if (trackCount <= 0) {
throw ParserException.createForMalformedContainer(/* message= */ null, /* cause= */ null);
}
ticksPerQuarterNote = midiFileData.readShort();
for (int currTrackIndex = 0; currTrackIndex < trackCount; currTrackIndex++) {
int trackLengthBytes = parseTrackChunkHeader();
byte[] trackEventsBytes = new byte[trackLengthBytes];
if (midiFileData.bytesLeft() < trackLengthBytes) {
throw ParserException.createForMalformedContainer(/* message= */ null, /* cause= */ null);
}
midiFileData.readBytes(
/* buffer= */ trackEventsBytes, /* offset= */ 0, /* length= */ trackLengthBytes);
// TODO(b/228838584): Parse slices of midiFileData instead of instantiating a new array of the
// event bytes from the entire track.
ParsableByteArray currentChunkData = new ParsableByteArray(trackEventsBytes);
TrackChunk trackChunk =
new TrackChunk(fileFormat, ticksPerQuarterNote, currentChunkData, this::onTempoChanged);
trackChunkList.add(trackChunk);
}
}
private int parseTrackChunkHeader() throws ParserException {
if (midiFileData.bytesLeft() < 8) {
throw ParserException.createForMalformedContainer(/* message= */ null, /* cause= */ null);
}
int trackHeaderIdentifier = midiFileData.readInt();
if (trackHeaderIdentifier != FOURCC_MTrk) {
throw ParserException.createForMalformedContainer(/* message= */ null, /* cause= */ null);
}
int trackLength = midiFileData.readInt();
if (trackLength <= 0) {
throw ParserException.createForMalformedContainer(/* message= */ null, /* cause= */ null);
}
return trackLength;
}
private static boolean isMidiHeaderIdentifier(ParsableByteArray input) {
int fileHeaderIdentifier = input.readInt();
return fileHeaderIdentifier == FOURCC_MThd;
}
private void onTempoChanged(int tempoBpm, long ticks) {
// Use the list to notify all chunks because the priority queue has a chunk removed from it
// in the parsing samples state.
for (TrackChunk trackChunk : trackChunkList) {
trackChunk.addTempoChange(tempoBpm, ticks);
}
}
private void outputEmptySample() {
trackOutput.sampleMetadata(
currentTimestampUs,
/* flags= */ C.BUFFER_FLAG_KEY_FRAME,
/* size= */ 0,
/* offset= */ 0,
/* cryptoData= */ null);
}
private void seekChunksTo(long seekTimeUs) throws ParserException {
while (!trackPriorityQueue.isEmpty()) {
TrackChunk nextChunk = checkNotNull(trackPriorityQueue.poll());
long nextTimestampUs = nextChunk.peekNextTimestampUs();
if (nextTimestampUs != C.TIME_UNSET && nextTimestampUs < seekTimeUs) {
nextChunk.outputFrontSample(
trackOutput,
C.BUFFER_FLAG_KEY_FRAME | C.BUFFER_FLAG_DECODE_ONLY,
/* skipNoteEvents= */ true);
nextChunk.populateFrontTrackEvent();
trackPriorityQueue.add(nextChunk);
}
}
trackPriorityQueue.addAll(trackChunkList);
}
}

View File

@ -0,0 +1,75 @@
/*
* 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.decoder.midi;
import android.content.Context;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.decoder.CryptoConfig;
import androidx.media3.exoplayer.audio.DecoderAudioRenderer;
/** Decodes and renders MIDI audio. */
@UnstableApi
public final class MidiRenderer extends DecoderAudioRenderer<MidiDecoder> {
private final Context context;
/** Creates the renderer instance. */
public MidiRenderer(Context context) {
this.context = context.getApplicationContext();
}
@Override
public String getName() {
return "MidiRenderer";
}
@Override
protected @C.FormatSupport int supportsFormatInternal(Format format) {
if (!MimeTypes.AUDIO_EXOPLAYER_MIDI.equals(format.sampleMimeType)) {
return C.FORMAT_UNSUPPORTED_TYPE;
}
if (!sinkSupportsFormat(MidiDecoder.getDecoderOutputFormat())) {
return C.FORMAT_UNSUPPORTED_SUBTYPE;
}
return C.FORMAT_HANDLED;
}
/**
* {@inheritDoc}
*
* @hide
*/
@Override
protected MidiDecoder createDecoder(Format format, @Nullable CryptoConfig cryptoConfig)
throws MidiDecoderException {
return new MidiDecoder(context);
}
/**
* {@inheritDoc}
*
* @hide
*/
@Override
protected Format getOutputFormat(MidiDecoder decoder) {
return MidiDecoder.getDecoderOutputFormat();
}
}

View File

@ -0,0 +1,289 @@
/*
* Copyright 2023 The Android Open Source Project
* Copyright 2009 Sonic Network Inc.
*
* 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.decoder.midi;
import static androidx.media3.common.util.Assertions.checkNotNull;
import androidx.annotation.Nullable;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.decoder.midi.SonivoxWaveData.Envelope;
import androidx.media3.decoder.midi.SonivoxWaveData.WavetableRegion;
import com.jsyn.data.ShortSample;
import com.jsyn.ports.UnitInputPort;
import com.jsyn.ports.UnitOutputPort;
import com.jsyn.unitgen.Add;
import com.jsyn.unitgen.Circuit;
import com.jsyn.unitgen.EnvelopeDAHDSR;
import com.jsyn.unitgen.FilterLowPass;
import com.jsyn.unitgen.Multiply;
import com.jsyn.unitgen.UnitVoice;
import com.jsyn.unitgen.VariableRateDataReader;
import com.jsyn.unitgen.VariableRateMonoReader;
import com.jsyn.unitgen.WhiteNoise;
import com.softsynth.math.AudioMath;
import com.softsynth.shared.time.TimeStamp;
/**
* Synthesizer voice with a wavetable oscillator. Modulates the amplitude and filter using DAHDSR
* envelopes. This synth uses the {@linkplain SonivoxWaveData Sonivox wave and instrument data} to
* implement General MIDI.
*/
@UnstableApi
/* package */ final class SonivoxSynthVoice extends Circuit implements UnitVoice {
// TODO(b/228838584): Replace with automatic gain control.
private static final double AMPLITUDE_SCALER = 10.0;
// TODO(b/228838584): Remove when modulation tuning is complete.
private static final boolean FILTER_ENABLED = false;
private static final double DEFAULT_FILTER_CUTOFF = 4000.0;
private final ShortSample sonivoxSample;
private final UnitInputPort amplitude;
private final UnitInputPort frequency;
private final VariableRateDataReader waveOscillator;
private final WhiteNoise whiteNoise;
private final FilterLowPass filter;
private final EnvelopeDAHDSR ampEnv;
private final EnvelopeDAHDSR filterEnv;
private final Multiply amplitudeMultiplier;
private final UnitInputPort cutoff;
private final UnitInputPort filterEnvDepth;
private int waveOffset;
private int waveSize;
private int presetIndex;
@Nullable private WavetableRegion region;
/** Creates a new instance with the supplied wave table data. */
public SonivoxSynthVoice(short[] waveData) {
sonivoxSample = new ShortSample(waveData);
amplitudeMultiplier = new Multiply();
waveOscillator = new VariableRateMonoReader();
whiteNoise = new WhiteNoise();
ampEnv = new EnvelopeDAHDSR();
filterEnv = new EnvelopeDAHDSR();
filterEnvDepth = filterEnv.amplitude;
filter = new FilterLowPass();
amplitude = amplitudeMultiplier.inputB;
Multiply frequencyMultiplier = new Multiply();
frequency = frequencyMultiplier.inputA;
Add cutoffAdder = new Add();
cutoff = cutoffAdder.inputB;
// Scales the frequency value. You can use this to modulate a group of instruments using a
// shared LFO and they will stay in tune. Set to 1.0 for no modulation.
UnitInputPort frequencyScaler = frequencyMultiplier.inputB;
Multiply amplitudeBoost = new Multiply();
add(frequencyMultiplier);
add(amplitudeMultiplier);
add(amplitudeBoost);
add(waveOscillator);
add(whiteNoise);
// Use an envelope to control the amplitude.
add(ampEnv);
// Use an envelope to control the filter cutoff.
add(filterEnv);
add(filter);
add(cutoffAdder);
filterEnv.output.connect(cutoffAdder.inputA);
cutoffAdder.output.connect(filter.frequency);
frequencyMultiplier.output.connect(waveOscillator.rate);
if (FILTER_ENABLED) {
amplitudeMultiplier.output.connect(filter.input);
filter.output.connect(amplitudeBoost.inputA);
} else {
amplitudeMultiplier.output.connect(amplitudeBoost.inputA);
}
amplitudeBoost.output.connect(ampEnv.amplitude);
addPort(amplitude, PORT_NAME_AMPLITUDE);
addPort(frequency, PORT_NAME_FREQUENCY);
addPort(cutoff, PORT_NAME_CUTOFF);
addPortAlias(cutoff, PORT_NAME_TIMBRE);
addPort(frequencyScaler, PORT_NAME_FREQUENCY_SCALER);
addPort(filterEnvDepth, /* name= */ "FilterEnvDepth");
filterEnv.export(this, /* prefix= */ "Filter");
ampEnv.export(this, /* prefix= */ "Amp");
frequency.setup(waveOscillator.rate);
frequencyScaler.setup(/* minimum= */ 0.2, /* value= */ 1.0, /* maximum= */ 4.0);
cutoff.setup(filter.frequency);
// Allow negative filter sweeps
filterEnvDepth.setup(/* minimum= */ -4000.0, /* value= */ 2000.0, /* maximum= */ 4000.0);
waveOscillator.amplitude.set(0.5);
// Make the circuit turn off when the envelope finishes to reduce CPU load.
ampEnv.setupAutoDisable(this);
// Add named port for mapping pressure.
amplitudeBoost.inputB.setup(/* minimum= */ 1.0, /* value= */ 1.0, /* maximum= */ 4.0);
addPortAlias(amplitudeBoost.inputB, PORT_NAME_PRESSURE);
usePreset(/* presetIndex= */ 0);
}
@Override
public void noteOff(TimeStamp timeStamp) {
if (region == null) {
return;
}
ampEnv.input.off(timeStamp);
filterEnv.input.off(timeStamp);
WavetableRegion region = checkNotNull(this.region);
if (region.useNoise()) {
if (region.isLooped()) {
if ((region.loopEnd + 1) < waveSize) {
int releaseStart = waveOffset + region.loopEnd;
int releaseSize = waveSize - releaseStart;
// Queue release portion.
waveOscillator.dataQueue.queue(sonivoxSample, releaseStart, releaseSize, timeStamp);
}
}
}
}
@Override
public void noteOn(double freq, double ampl, TimeStamp timeStamp) {
// TODO(b/228838584): add noteOnByPitch
double pitch = AudioMath.frequencyToPitch(freq);
region = selectRegionByNoteNumber(region, (int) (pitch + 0.5), timeStamp);
if (region == null) {
return;
}
WavetableRegion region = checkNotNull(this.region);
double rate = 22050.0 * calculateRateScaler(region, pitch);
double velocityGain = ampl * ampl; // Sonivox squares the velocity.
double regionGain = region.gain * (1.0 / 32768.0);
double gain = velocityGain * regionGain * AMPLITUDE_SCALER;
frequency.set(rate, timeStamp);
amplitude.set(gain, timeStamp);
ampEnv.input.on(timeStamp);
filterEnv.input.on(timeStamp);
waveOscillator.output.disconnectAll();
whiteNoise.output.disconnectAll();
if (region.useNoise()) {
// TODO(b/228838584): Use a switching gate instead of connecting.
whiteNoise.output.connect(amplitudeMultiplier.inputA);
} else {
waveOscillator.output.connect(amplitudeMultiplier.inputA);
waveOscillator.dataQueue.clear(timeStamp);
if (region.isLooped()) {
int loopStart = waveOffset + region.loopStart;
int loopSize = waveOffset + region.loopEnd - loopStart;
// Queue attack portion.
if (loopStart > waveOffset) {
waveOscillator.dataQueue.queue(
sonivoxSample, waveOffset, loopStart - waveOffset, timeStamp);
}
waveOscillator.dataQueue.queueLoop(sonivoxSample, loopStart, loopSize, timeStamp);
} else {
waveOscillator.dataQueue.queue(sonivoxSample, waveOffset, waveSize, timeStamp);
}
}
}
@Nullable
private WavetableRegion selectRegionByNoteNumber(
@Nullable WavetableRegion region, int noteNumber, TimeStamp timeStamp) {
if ((region == null) || !region.isNoteInRange(noteNumber)) {
int regionIndex = SonivoxWaveData.getProgramRegion(presetIndex);
region = SonivoxWaveData.extractRegion(regionIndex);
while (!region.isNoteInRange(noteNumber) && !region.isLast()) {
regionIndex++; // Try next region
region = SonivoxWaveData.extractRegion(regionIndex);
}
}
int waveIndex = region.waveIndex;
if (region.useNoise()) {
waveOffset = -1;
waveSize = 0;
} else {
if (waveIndex >= SonivoxWaveData.getWaveCount()) {
return null;
}
waveOffset = SonivoxWaveData.getWaveOffset(waveIndex);
waveSize = SonivoxWaveData.getWaveSize(waveIndex);
}
SonivoxWaveData.Articulation articulation =
SonivoxWaveData.extractArticulation(region.artIndex);
applyToEnvelope(articulation.eg1, ampEnv, timeStamp);
applyToEnvelope(articulation.eg2, filterEnv, timeStamp);
int cutoffCents = articulation.filterCutoff;
if (cutoffCents > 0) {
double filterCutoffHertz = AudioMath.pitchToFrequency(cutoffCents * 0.01);
cutoff.set(filterCutoffHertz);
} else {
cutoff.set(DEFAULT_FILTER_CUTOFF);
}
return region;
}
@Override
public UnitOutputPort getOutput() {
return ampEnv.output;
}
@Override
public void usePreset(int presetIndex) {
if (this.presetIndex == presetIndex) {
return;
}
reset();
this.presetIndex = presetIndex;
if (presetIndex == 0) {
ampEnv.attack.set(0.1);
ampEnv.decay.set(0.9);
ampEnv.sustain.set(0.1);
ampEnv.release.set(0.1);
cutoff.set(300.0);
filterEnvDepth.set(500.0);
filter.Q.set(3.0);
}
}
private void reset() {
ampEnv.attack.set(0.1);
ampEnv.decay.set(0.9);
ampEnv.sustain.set(0.1);
ampEnv.release.set(0.1);
filterEnv.attack.set(0.01);
filterEnv.decay.set(0.6);
filterEnv.sustain.set(0.4);
filterEnv.release.set(1.0);
filter.Q.set(1.0);
cutoff.set(5000.0);
filterEnvDepth.set(500.0);
region = null;
}
private static double calculateRateScaler(WavetableRegion region, double pitch) {
double detuneSemitones = pitch + (region.tuning * 0.01);
return Math.pow(2.0, detuneSemitones / 12.0);
}
private static void applyToEnvelope(Envelope eg, EnvelopeDAHDSR dahdsr, TimeStamp timeStamp) {
dahdsr.attack.set(eg.getAttackTimeInSeconds(), timeStamp);
dahdsr.decay.set(eg.getDecayTimeInSeconds(), timeStamp);
dahdsr.sustain.set(eg.getSustainLevel(), timeStamp);
dahdsr.release.set(eg.getReleaseTimeInSeconds(), timeStamp);
}
}

View File

@ -0,0 +1,64 @@
/*
* Copyright 2023 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.decoder.midi;
import android.content.Context;
import androidx.media3.common.util.UnstableApi;
import com.jsyn.unitgen.UnitVoice;
import com.jsyn.util.VoiceDescription;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** Synthesizer voice description, used for obtaining {@link SonivoxSynthVoice} instances. */
@UnstableApi
/* package */ final class SonivoxVoiceDescription extends VoiceDescription {
private static final String VOICE_CLASS_NAME = "SonivoxVoiceDescription";
private static final String[] tags = {"wavetable", "GM2", "ringtone"};
private static final Object LOCK = new Object();
private static @MonotonicNonNull SonivoxVoiceDescription instance;
public static SonivoxVoiceDescription getInstance(Context context) throws MidiDecoderException {
synchronized (LOCK) {
if (instance == null) {
instance = new SonivoxVoiceDescription(SonivoxWaveData.loadWaveTableData(context));
}
return instance;
}
}
private final short[] waveTableData;
private SonivoxVoiceDescription(short[] waveTableData) {
super(VOICE_CLASS_NAME, SonivoxWaveData.getProgramNames());
this.waveTableData = waveTableData;
}
@Override
public UnitVoice createUnitVoice() {
// We must return a new instance every time.
return new SonivoxSynthVoice(waveTableData);
}
@Override
public String[] getTags(int presetIndex) {
return tags;
}
@Override
public String getVoiceClassName() {
return VOICE_CLASS_NAME;
}
}

View File

@ -0,0 +1,249 @@
/*
* 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.decoder.midi;
import static androidx.media3.common.util.Assertions.checkState;
import static java.lang.Math.min;
import android.util.Pair;
import androidx.media3.common.C;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.TrackOutput;
import com.google.common.collect.Iterables;
import java.util.ArrayList;
/** Parses track chunk bytes from standard MIDI files. */
@UnstableApi
/* package */ final class TrackChunk implements Comparable<TrackChunk> {
/** A listener for changes to track tempo. */
public interface TempoChangedListener {
/**
* Called when a meta tempo change event is encountered in the chunk.
*
* @param tempoBpm The new tempo in beats per minute.
* @param ticks The elapsed ticks since the start of the file.
*/
void onTempoChanged(int tempoBpm, long ticks);
}
private static final int DEFAULT_TRACK_TEMPO_BPM = 120;
private final int fileFormat;
private final int ticksPerQuarterNote;
private final ParsableByteArray trackEventsBytes;
private final TempoChangedListener tempoListener;
private final ParsableByteArray scratch;
private final TrackEvent currentTrackEvent;
private final ArrayList<Pair<Long, Integer>> tempoChanges;
private int previousEventStatus;
private long lastOutputEventTimestampUs;
private long totalElapsedTicks;
/** Creates a new track chunk with event bytes read from a standard MIDI file. */
public TrackChunk(
int fileFormat,
int ticksPerQuarterNote,
ParsableByteArray trackEventsBytes,
TempoChangedListener tempoListener) {
this.fileFormat = fileFormat;
this.ticksPerQuarterNote = ticksPerQuarterNote;
this.trackEventsBytes = trackEventsBytes;
this.tempoListener = tempoListener;
scratch = new ParsableByteArray(TrackEvent.MIDI_MESSAGE_LENGTH_BYTES);
currentTrackEvent = new TrackEvent();
tempoChanges = new ArrayList<>();
previousEventStatus = TrackEvent.DATA_FIELD_UNSET;
tempoChanges.add(Pair.create(0L, DEFAULT_TRACK_TEMPO_BPM));
}
/**
* Returns the absolute time of the current track event in microseconds, or {@link C#TIME_UNSET}
* if it's not populated.
*/
public long peekNextTimestampUs() {
if (!currentTrackEvent.isPopulated()) {
return C.TIME_UNSET;
}
return lastOutputEventTimestampUs
+ adjustTicksToUs(
tempoChanges,
currentTrackEvent.elapsedTimeDeltaTicks,
totalElapsedTicks,
ticksPerQuarterNote);
}
/**
* Outputs the front sample to {@code trackOutput}, flagged as a {@linkplain
* C#BUFFER_FLAG_KEY_FRAME key frame}.
*/
public void outputFrontSample(TrackOutput trackOutput) {
outputFrontSample(
/* trackOutput= */ trackOutput,
/* flags= */ C.BUFFER_FLAG_KEY_FRAME,
/* skipNoteEvents= */ false);
}
/**
* Outputs the current track event to {@code trackOutput}.
*
* @param trackOutput The {@link TrackOutput} to output samples to.
* @param flags {@link C.BufferFlags} to mark the buffer with.
* @param skipNoteEvents Whether note events should be skipped.
*/
public void outputFrontSample(TrackOutput trackOutput, int flags, boolean skipNoteEvents) {
if (!currentTrackEvent.isPopulated()) {
return;
}
lastOutputEventTimestampUs +=
adjustTicksToUs(
tempoChanges,
currentTrackEvent.elapsedTimeDeltaTicks,
totalElapsedTicks,
ticksPerQuarterNote);
if (skipNoteEvents && currentTrackEvent.isNoteChannelEvent()) {
trackEventsBytes.skipBytes(currentTrackEvent.eventFileSizeBytes);
previousEventStatus = currentTrackEvent.statusByte;
currentTrackEvent.reset();
return;
}
ParsableByteArray sampleData = trackEventsBytes;
int sampleSize = currentTrackEvent.eventFileSizeBytes - currentTrackEvent.timestampSize;
// Skip the delta time data for now, we only want to send event bytes to the decoder.
trackEventsBytes.skipBytes(currentTrackEvent.timestampSize);
if (currentTrackEvent.isMidiEvent()) {
trackEventsBytes.skipBytes(sampleSize);
scratch.setPosition(0);
currentTrackEvent.writeTo(scratch.getData());
sampleData = scratch;
// The decoder does not keep track of running status being applied to an event message. We
// need to adjust the sample size to pass the full message, which is otherwise represented
// by fewer bytes in the file.
sampleSize = currentTrackEvent.eventDecoderSizeBytes;
} else if (currentTrackEvent.isMetaEvent()) {
if (currentTrackEvent.usPerQuarterNote != C.TIME_UNSET) {
int tempoBpm = (int) (60_000_000 / currentTrackEvent.usPerQuarterNote);
notifyTempoChange(tempoBpm, totalElapsedTicks);
}
}
trackOutput.sampleData(sampleData, sampleSize);
trackOutput.sampleMetadata(
lastOutputEventTimestampUs,
/* flags= */ flags,
/* size= */ sampleSize,
/* offset= */ 0,
/* cryptoData= */ null);
if (tempoChanges.size() > 1) {
// All tempo events up to this point have been accounted for. Update the current tempo to
// the latest value from the list.
Pair<Long, Integer> latestTempoChange = Iterables.getLast(tempoChanges);
tempoChanges.clear();
tempoChanges.add(latestTempoChange);
}
previousEventStatus = currentTrackEvent.statusByte;
currentTrackEvent.reset();
}
/**
* Populates the current track event data from the next MIDI command in {@code trackEventsBytes}.
*/
public void populateFrontTrackEvent() throws ParserException {
if (!currentTrackEvent.isPopulated()) {
boolean parsingSuccess =
currentTrackEvent.populateFrom(trackEventsBytes, previousEventStatus);
if (parsingSuccess) {
totalElapsedTicks += currentTrackEvent.elapsedTimeDeltaTicks;
}
}
}
/** Adds a new tempo change, and when it occured in ticks since the start of the file. */
public void addTempoChange(int tempoBpm, long ticks) {
tempoChanges.add(Pair.create(ticks, tempoBpm));
}
/** Resets the state of the chunk. */
public void reset() {
lastOutputEventTimestampUs = 0;
totalElapsedTicks = 0;
previousEventStatus = TrackEvent.DATA_FIELD_UNSET;
trackEventsBytes.setPosition(0);
scratch.setPosition(0);
currentTrackEvent.reset();
tempoChanges.clear();
tempoChanges.add(Pair.create(0L, DEFAULT_TRACK_TEMPO_BPM));
}
@Override
public int compareTo(TrackChunk otherTrack) {
long thisTimestampUs = peekNextTimestampUs();
long otherTimestampUs = otherTrack.peekNextTimestampUs();
if (thisTimestampUs == otherTimestampUs) {
return 0;
} else if (thisTimestampUs == C.TIME_UNSET) {
return 1;
} else if (otherTimestampUs == C.TIME_UNSET) {
return -1;
} else if (thisTimestampUs < otherTimestampUs) {
return -1;
} else {
return 1;
}
}
private void notifyTempoChange(int tempoBpm, long ticks) {
// Tempo changes in format '2' files do not affect other tracks according to the spec; see page
// 5, section "Formats 0, 1, and 2".
// https://www.midi.org/component/edocman/rp-001-v1-0-standard-midi-files-specification-96-1-4-pdf/fdocument?Itemid=9999
if (fileFormat == 2) {
addTempoChange(tempoBpm, ticks);
} else {
tempoListener.onTempoChanged(tempoBpm, ticks);
}
}
private static long adjustTicksToUs(
ArrayList<Pair<Long, Integer>> tempoChanges,
long ticks,
long ticksOffset,
int ticksPerQuarterNote) {
long resultUs = 0;
for (int i = tempoChanges.size() - 1; i >= 0; i--) {
Pair<Long, Integer> tempoChange = tempoChanges.get(i);
long ticksAffectedByThisTempo = min(ticks, ticksOffset - tempoChange.first);
checkState(ticksAffectedByThisTempo >= 0);
resultUs += ticksToUs(tempoChange.second, ticksAffectedByThisTempo, ticksPerQuarterNote);
ticks -= ticksAffectedByThisTempo;
ticksOffset -= ticksAffectedByThisTempo;
}
resultUs += ticksToUs(Iterables.getLast(tempoChanges).second, ticks, ticksPerQuarterNote);
return resultUs;
}
private static long ticksToUs(int tempoBpm, long ticks, int ticksPerQuarterNote) {
return ticks * 60_000_000 / ((long) tempoBpm * ticksPerQuarterNote);
}
}

View File

@ -0,0 +1,208 @@
/*
* 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.decoder.midi;
import androidx.media3.common.C;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
/**
* Represents a standard MIDI file track event.
*
* <p>A track event is a sequence of bytes in the track chunk, consisting of an elapsed time delta
* and Midi, Meta, or SysEx command bytes. A track event is followed by either another track event,
* or end of chunk marker bytes.
*/
@UnstableApi
/* package */ final class TrackEvent {
/** The length of a MIDI event message in bytes. */
public static final int MIDI_MESSAGE_LENGTH_BYTES = 3;
/** A default or unset data value. */
public static final int DATA_FIELD_UNSET = Integer.MIN_VALUE;
private static final int TICKS_UNSET = -1;
private static final int META_EVENT_STATUS = 0xFF;
private static final int META_END_OF_TRACK = 0x2F;
private static final int META_TEMPO_CHANGE = 0x51;
private static final int[] CHANNEL_BYTE_LENGTHS = {3, 3, 3, 3, 2, 2, 3};
private static final int[] SYSTEM_BYTE_LENGTHS = {1, 2, 3, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
public int timestampSize;
public int eventFileSizeBytes;
public int eventDecoderSizeBytes;
public int statusByte;
public long usPerQuarterNote;
public long elapsedTimeDeltaTicks;
private int data1;
private int data2;
private boolean isPopulated;
public TrackEvent() {
reset();
}
public void writeTo(byte[] data) {
data[0] = (byte) statusByte;
data[1] = (byte) data1;
data[2] = (byte) data2;
}
public boolean populateFrom(ParsableByteArray parsableTrackEventBytes, int previousEventStatus)
throws ParserException {
reset();
int startingPosition = parsableTrackEventBytes.getPosition();
// At least two bytes must remain for there to be a valid MIDI event present to parse.
if (parsableTrackEventBytes.bytesLeft() < 2) {
return false;
}
elapsedTimeDeltaTicks = readVariableLengthInt(parsableTrackEventBytes);
timestampSize = parsableTrackEventBytes.getPosition() - startingPosition;
int firstByte = parsableTrackEventBytes.readUnsignedByte();
eventDecoderSizeBytes = 1;
if ((firstByte & 0xF0) != 0xF0) {
// Most significant nibble is not 0xF, this is a MIDI channel event.
// Check for running status, an occurrence where the statusByte has been omitted from the
// bytes of this event. The standard expects us to assume that this command has the same
// statusByte as the last command.
boolean isRunningStatus = firstByte < 0x80;
if (isRunningStatus) {
if (previousEventStatus == DATA_FIELD_UNSET) {
throw ParserException.createForMalformedContainer(
/* message= */ "Running status in the first event.", /* cause= */ null);
}
data1 = firstByte;
firstByte = previousEventStatus;
eventDecoderSizeBytes++;
}
int messageLength = getMidiMessageLengthBytes(firstByte);
if (!isRunningStatus) {
if (messageLength > eventDecoderSizeBytes) {
data1 = parsableTrackEventBytes.readUnsignedByte();
eventDecoderSizeBytes++;
}
}
// Only read the next data byte if expected to be present.
if (messageLength > eventDecoderSizeBytes) {
data2 = parsableTrackEventBytes.readUnsignedByte();
eventDecoderSizeBytes++;
}
statusByte = firstByte;
} else {
if (firstByte == META_EVENT_STATUS) { // This is a Meta event.
int metaEventMessageType = parsableTrackEventBytes.readUnsignedByte();
int eventLength = readVariableLengthInt(parsableTrackEventBytes);
statusByte = firstByte;
switch (metaEventMessageType) {
case META_TEMPO_CHANGE:
usPerQuarterNote = parsableTrackEventBytes.readUnsignedInt24();
if (usPerQuarterNote <= 0) {
throw ParserException.createForUnsupportedContainerFeature(
"Tempo event data value must be a non-zero positive value. Parsed value: "
+ usPerQuarterNote);
}
parsableTrackEventBytes.skipBytes(eventLength - /* tempoDataLength */ 3);
break;
case META_END_OF_TRACK:
parsableTrackEventBytes.setPosition(startingPosition);
reset();
return false;
default: // Ignore all other Meta events.
parsableTrackEventBytes.skipBytes(eventLength);
}
} else {
// TODO(b/228838584): Handle this gracefully.
throw ParserException.createForUnsupportedContainerFeature(
"SysEx track events are not yet supported.");
}
}
eventFileSizeBytes = parsableTrackEventBytes.getPosition() - startingPosition;
parsableTrackEventBytes.setPosition(startingPosition);
isPopulated = true;
return true;
}
public boolean isMidiEvent() {
// TODO(b/228838584): Update with SysEx event check when implemented.
return statusByte != META_EVENT_STATUS;
}
public boolean isNoteChannelEvent() {
int highNibble = statusByte >>> 4;
return isMidiEvent() && (highNibble == 8 || highNibble == 9);
}
public boolean isMetaEvent() {
return statusByte == META_EVENT_STATUS;
}
public boolean isPopulated() {
return isPopulated;
}
public void reset() {
isPopulated = false;
timestampSize = C.LENGTH_UNSET;
statusByte = DATA_FIELD_UNSET;
data1 = DATA_FIELD_UNSET;
data2 = DATA_FIELD_UNSET;
elapsedTimeDeltaTicks = TICKS_UNSET;
eventFileSizeBytes = C.LENGTH_UNSET;
eventDecoderSizeBytes = C.LENGTH_UNSET;
usPerQuarterNote = C.TIME_UNSET;
}
private static int readVariableLengthInt(ParsableByteArray data) {
int result = 0;
int currentByte;
int bytesRead = 0;
do {
currentByte = data.readUnsignedByte();
result = result << 7 | (currentByte & 0x7F);
bytesRead++;
} while (((currentByte & 0x80) != 0) && bytesRead <= /* maxByteLength= */ 4);
return result;
}
private static int getMidiMessageLengthBytes(int status) {
if ((status < 0x80) || (status > 0xFF)) {
return 0;
} else if (status >= 0xF0) {
return SYSTEM_BYTE_LENGTHS[status & 0x0F];
} else {
return CHANNEL_BYTE_LENGTHS[(status >> 4) & 0x07];
}
}
}

View File

@ -0,0 +1,19 @@
/*
* 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.
*/
@NonNullApi
package androidx.media3.decoder.midi;
import androidx.media3.common.util.NonNullApi;

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<manifest package="androidx.media3.decoder.midi.test">
<uses-sdk/>
</manifest>

View File

@ -0,0 +1,162 @@
/*
* 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.decoder.midi;
import static com.google.common.truth.Truth.assertThat;
import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.PositionHolder;
import androidx.media3.test.utils.DumpFileAsserts;
import androidx.media3.test.utils.ExtractorAsserts;
import androidx.media3.test.utils.ExtractorAsserts.SimulationConfig;
import androidx.media3.test.utils.FakeExtractorInput;
import androidx.media3.test.utils.FakeExtractorOutput;
import androidx.media3.test.utils.TestUtil;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.runner.RunWith;
import org.robolectric.ParameterizedRobolectricTestRunner;
import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
@RunWith(Enclosed.class)
public final class MidiExtractorTest {
@RunWith(ParameterizedRobolectricTestRunner.class)
public static class MidiExtractorAssertsTests {
@Parameters(name = "{0}")
public static ImmutableList<SimulationConfig> params() {
return ExtractorAsserts.configs();
}
@Parameter public ExtractorAsserts.SimulationConfig simulationConfig;
@Test
public void testExtractorAsserts() throws Exception {
ExtractorAsserts.assertBehavior(
MidiExtractor::new, "media/midi/Twinkle.mid", simulationConfig);
}
}
@RunWith(AndroidJUnit4.class)
public static class MidiExtractorSeekTest {
/**
* Tests that when seeking to arbitrary points, the extractor will output all MIDI commands
* preceding the seek point, excluding channel note events which are omitted. The commands
* before the seek point are marked as decode only. The remaining samples are output normally
* until the end of the track.
*/
@Test
public void testSeekingToArbitraryPoint() throws IOException {
// The structure of this file is as follows:
// Tick 1920 (2 seconds) -> Note1 ON
// Tick 4800 (5 seconds) -> Pitch Event
// Tick 5760 (6 seconds) -> Note2 ON
// Tick 6760 (7.04 seconds) -> End of Track
byte[] fileBytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(),
/* fileName= */ "media/midi/seek_test_with_non_note_events.mid");
FakeExtractorOutput fakeExtractorOutput = new FakeExtractorOutput();
PositionHolder positionHolder = new PositionHolder();
MidiExtractor midiExtractor = new MidiExtractor();
FakeExtractorInput fakeExtractorInput =
new FakeExtractorInput.Builder().setData(fileBytes).build();
midiExtractor.init(fakeExtractorOutput);
while (fakeExtractorOutput.trackOutputs.get(0).getSampleCount() == 0) {
assertThat(midiExtractor.read(fakeExtractorInput, positionHolder))
.isEqualTo(Extractor.RESULT_CONTINUE);
}
// Clear the outputs in preparation for a seek.
fakeExtractorOutput.clearTrackOutputs();
// Seek to just after a non-note event (pitch bend).
midiExtractor.seek(/* position= */ 0, /* timeUs= */ 5_500_000);
do {} while (midiExtractor.read(fakeExtractorInput, positionHolder)
!= Extractor.RESULT_END_OF_INPUT);
DumpFileAsserts.assertOutput(
ApplicationProvider.getApplicationContext(),
fakeExtractorOutput,
"extractordumps/midi/seek_test_with_non_note_events.mid.dump");
}
}
@RunWith(AndroidJUnit4.class)
public static class MidiExtractorTempoTest {
/**
* Tests that the absolute output timestamps of events in the file are adjusted according to any
* tempo changes that might occur mid-note.
*/
@Test
public void testMidNoteTempoChanges() throws IOException {
// The structure of this file is as follows:
// Tempo is at default (120bpm).
// (0us) Tick 0 -> Note ON. (+480 ticks at 120bpm = 500_000us)
// (500_000us) Tick 480 -> Tempo changes to 180bpm. (+480 ticks at 180bpm = 333_333us)
// (833_333us) Tick 960 -> Tempo changes to 240bpm. (+480 ticks at 240bpm = 250_000us)
// (1_083_333us) Tick 1440 -> Tempo changes to 300bpm. (+480 ticks at 300bpm = 200_000us)
// (1_283_333us) Tick 1920 -> End of file.
byte[] fileBytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(),
/* fileName= */ "media/midi/mid_note_tempo_changes_simple.mid");
FakeExtractorInput fakeExtractorInput =
new FakeExtractorInput.Builder().setData(fileBytes).build();
FakeExtractorOutput fakeExtractorOutput = new FakeExtractorOutput();
PositionHolder positionHolder = new PositionHolder();
MidiExtractor midiExtractor = new MidiExtractor();
midiExtractor.init(fakeExtractorOutput);
do {} while (midiExtractor.read(fakeExtractorInput, positionHolder)
!= Extractor.RESULT_END_OF_INPUT);
DumpFileAsserts.assertOutput(
ApplicationProvider.getApplicationContext(),
fakeExtractorOutput,
"extractordumps/midi/mid_note_tempo_changes_simple.mid.dump");
}
@Test
public void testMultiNoteTempoChanges() throws IOException {
byte[] fileBytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(),
/* fileName= */ "media/midi/multi_note_tempo_changes.mid");
FakeExtractorInput fakeExtractorInput =
new FakeExtractorInput.Builder().setData(fileBytes).build();
FakeExtractorOutput fakeExtractorOutput = new FakeExtractorOutput();
PositionHolder positionHolder = new PositionHolder();
MidiExtractor midiExtractor = new MidiExtractor();
midiExtractor.init(fakeExtractorOutput);
do {} while (midiExtractor.read(fakeExtractorInput, positionHolder)
!= Extractor.RESULT_END_OF_INPUT);
DumpFileAsserts.assertOutput(
ApplicationProvider.getApplicationContext(),
fakeExtractorOutput,
"extractordumps/midi/multi_note_tempo_changes.mid.dump");
}
}
}

View File

@ -0,0 +1,66 @@
/*
* 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.decoder.midi;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.mock;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.test.utils.FakeTrackOutput;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.IOException;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public class TrackChunkTest {
/**
* Tests that mid-note-event tempo changes are correctly accounted for in the event's duration.
* Each duration affected by a tempo change is a segment calculated individually. The duration of
* the sample is the sum of these segments.
*/
@Test
public void testMidNoteTempoChanges() throws IOException {
FakeTrackOutput fakeTrackOutput = new FakeTrackOutput(false);
fakeTrackOutput.format(new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_MIDI).build());
// Chunk format:
// Note ON at absolute ticks 0.
// Note OFF at absolute ticks 1920.
// End of track.
ParsableByteArray trackData =
new ParsableByteArray(new byte[] {0, -112, 72, 127, -113, 0, -128, 72, 127, 0, -1, 47, 0});
TrackChunk trackChunk =
new TrackChunk(
/* fileFormat= */ 1,
/* ticksPerQuarterNote= */ 480,
/* trackEventsBytes= */ trackData,
/* tempoListener= */ mock(TrackChunk.TempoChangedListener.class));
trackChunk.populateFrontTrackEvent();
trackChunk.outputFrontSample(fakeTrackOutput);
assertThat(fakeTrackOutput.getSampleTimeUs(/* index= */ 0)).isEqualTo(/* expected= */ 0);
trackChunk.addTempoChange(/* tempoBpm= */ 180, /* ticks= */ 480);
trackChunk.addTempoChange(/* tempoBpm= */ 240, /* ticks= */ 960);
trackChunk.addTempoChange(/* tempoBpm= */ 300, /* ticks= */ 1440);
trackChunk.populateFrontTrackEvent();
trackChunk.outputFrontSample(fakeTrackOutput);
assertThat(fakeTrackOutput.getSampleTimeUs(/* index= */ 1)).isEqualTo(/* expected= */ 1283333);
}
}

View File

@ -25,6 +25,10 @@
-keepclassmembers class androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer {
<init>(android.os.Handler, androidx.media3.exoplayer.audio.AudioRendererEventListener, androidx.media3.exoplayer.audio.AudioSink);
}
-dontnote androidx.media3.decoder.midi.MidiRenderer
-keepclassmembers class androidx.media3.decoder.midi.MidiRenderer {
<init>(android.content.Context);
}
# Constructors accessed via reflection in DefaultDownloaderFactory
-dontnote androidx.media3.exoplayer.dash.offline.DashDownloader

View File

@ -471,6 +471,7 @@ public class DefaultRenderersFactory implements RenderersFactory {
}
try {
// Full class names used for constructor args so the LINT rule triggers if any of them move.
Class<?> clazz = Class.forName("androidx.media3.decoder.midi.MidiRenderer");
Constructor<?> constructor = clazz.getConstructor(Context.class);
Renderer renderer = (Renderer) constructor.newInstance(context);

View File

@ -9,6 +9,10 @@
-keepclassmembers class androidx.media3.decoder.flac.FlacLibrary {
public static boolean isAvailable();
}
-dontnote androidx.media3.decoder.midi.MidiExtractor
-keepclassmembers class androidx.media3.decoder.midi.MidiExtractor {
<init>();
}
# Don't warn about checkerframework and Kotlin annotations
-dontwarn org.checkerframework.**