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:
|
||||
* 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:
|
||||
|
@ -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()) {
|
||||
|
@ -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'
|
||||
|
@ -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')
|
||||
}
|
||||
|
||||
|
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 {
|
||||
<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
|
||||
|
@ -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);
|
||||
|
@ -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.**
|
||||
|
Loading…
x
Reference in New Issue
Block a user