Release MIDI decoder module in open-source
PiperOrigin-RevId: 537034577
This commit is contained in:
parent
c52130a212
commit
757247e2ae
@ -29,6 +29,9 @@
|
|||||||
* Smooth Streaming Extension:
|
* Smooth Streaming Extension:
|
||||||
* RTSP Extension:
|
* RTSP Extension:
|
||||||
* Decoder Extensions (FFmpeg, VP9, AV1, etc.):
|
* 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:
|
* Cast Extension:
|
||||||
* Test Utilities:
|
* Test Utilities:
|
||||||
* Remove deprecated symbols:
|
* Remove deprecated symbols:
|
||||||
|
@ -26,6 +26,12 @@ allprojects {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
url 'https://jitpack.io'
|
||||||
|
content {
|
||||||
|
includeGroup "com.github.philburk"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (it.hasProperty('externalBuildDir')) {
|
if (it.hasProperty('externalBuildDir')) {
|
||||||
if (!new File(externalBuildDir).isAbsolute()) {
|
if (!new File(externalBuildDir).isAbsolute()) {
|
||||||
|
@ -70,6 +70,8 @@ include modulePrefix + 'lib-decoder-ffmpeg'
|
|||||||
project(modulePrefix + 'lib-decoder-ffmpeg').projectDir = new File(rootDir, 'libraries/decoder_ffmpeg')
|
project(modulePrefix + 'lib-decoder-ffmpeg').projectDir = new File(rootDir, 'libraries/decoder_ffmpeg')
|
||||||
include modulePrefix + 'lib-decoder-flac'
|
include modulePrefix + 'lib-decoder-flac'
|
||||||
project(modulePrefix + 'lib-decoder-flac').projectDir = new File(rootDir, 'libraries/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'
|
include modulePrefix + 'lib-decoder-opus'
|
||||||
project(modulePrefix + 'lib-decoder-opus').projectDir = new File(rootDir, 'libraries/decoder_opus')
|
project(modulePrefix + 'lib-decoder-opus').projectDir = new File(rootDir, 'libraries/decoder_opus')
|
||||||
include modulePrefix + 'lib-decoder-vp9'
|
include modulePrefix + 'lib-decoder-vp9'
|
||||||
|
@ -90,6 +90,7 @@ dependencies {
|
|||||||
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-flac')
|
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-flac')
|
||||||
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-opus')
|
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-opus')
|
||||||
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-vp9')
|
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-vp9')
|
||||||
|
withDecoderExtensionsImplementation project(modulePrefix + 'lib-decoder-midi')
|
||||||
withDecoderExtensionsImplementation project(modulePrefix + 'lib-datasource-rtmp')
|
withDecoderExtensionsImplementation project(modulePrefix + 'lib-datasource-rtmp')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
42
libraries/decoder_midi/README.md
Normal file
42
libraries/decoder_midi/README.md
Normal 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
|
||||||
|
|
41
libraries/decoder_midi/build.gradle
Normal file
41
libraries/decoder_midi/build.gradle
Normal 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'
|
19
libraries/decoder_midi/src/main/AndroidManifest.xml
Normal file
19
libraries/decoder_midi/src/main/AndroidManifest.xml
Normal 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>
|
4438
libraries/decoder_midi/src/main/assets/sonivox_wave_data.dat
Normal file
4438
libraries/decoder_midi/src/main/assets/sonivox_wave_data.dat
Normal file
File diff suppressed because it is too large
Load Diff
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
19
libraries/decoder_midi/src/test/AndroidManifest.xml
Normal file
19
libraries/decoder_midi/src/test/AndroidManifest.xml
Normal 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>
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -25,6 +25,10 @@
|
|||||||
-keepclassmembers class androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer {
|
-keepclassmembers class androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer {
|
||||||
<init>(android.os.Handler, androidx.media3.exoplayer.audio.AudioRendererEventListener, androidx.media3.exoplayer.audio.AudioSink);
|
<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
|
# Constructors accessed via reflection in DefaultDownloaderFactory
|
||||||
-dontnote androidx.media3.exoplayer.dash.offline.DashDownloader
|
-dontnote androidx.media3.exoplayer.dash.offline.DashDownloader
|
||||||
|
@ -471,6 +471,7 @@ public class DefaultRenderersFactory implements RenderersFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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");
|
Class<?> clazz = Class.forName("androidx.media3.decoder.midi.MidiRenderer");
|
||||||
Constructor<?> constructor = clazz.getConstructor(Context.class);
|
Constructor<?> constructor = clazz.getConstructor(Context.class);
|
||||||
Renderer renderer = (Renderer) constructor.newInstance(context);
|
Renderer renderer = (Renderer) constructor.newInstance(context);
|
||||||
|
@ -9,6 +9,10 @@
|
|||||||
-keepclassmembers class androidx.media3.decoder.flac.FlacLibrary {
|
-keepclassmembers class androidx.media3.decoder.flac.FlacLibrary {
|
||||||
public static boolean isAvailable();
|
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
|
# Don't warn about checkerframework and Kotlin annotations
|
||||||
-dontwarn org.checkerframework.**
|
-dontwarn org.checkerframework.**
|
||||||
|
Loading…
x
Reference in New Issue
Block a user