Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ public final class Mp3Extractor implements Extractor {
private int synchronizedHeaderData;

@Nullable private Metadata metadata;
@Nullable private Metadata infoMetadata;
private long basisTimeUs;
private long samplesRead;
private long firstSamplePosition;
Expand Down Expand Up @@ -290,6 +291,12 @@ private int readInternal(ExtractorInput input) throws IOException {
if (seeker == null) {
seeker = computeSeeker(input);
extractorOutput.seekMap(seeker);
@Nullable Metadata finalMetadata = (flags & FLAG_DISABLE_ID3_METADATA) != 0 ? null : metadata;
if (finalMetadata != null) {
finalMetadata = finalMetadata.copyWithAppendedEntriesFrom(infoMetadata);
} else {
finalMetadata = infoMetadata;
}
Format.Builder format =
new Format.Builder()
.setContainerMimeType(MimeTypes.AUDIO_MPEG)
Expand All @@ -299,7 +306,7 @@ private int readInternal(ExtractorInput input) throws IOException {
.setSampleRate(synchronizedHeader.sampleRate)
.setEncoderDelay(gaplessInfoHolder.encoderDelay)
.setEncoderPadding(gaplessInfoHolder.encoderPadding)
.setMetadata((flags & FLAG_DISABLE_ID3_METADATA) != 0 ? null : metadata);
.setMetadata(finalMetadata);
if (seeker.getAverageBitrate() != C.RATE_UNSET_INT) {
format.setAverageBitrate(seeker.getAverageBitrate());
}
Expand Down Expand Up @@ -575,6 +582,7 @@ private Seeker maybeReadSeekFrame(ExtractorInput input) throws IOException {
gaplessInfoHolder.encoderDelay = xingFrame.encoderDelay;
gaplessInfoHolder.encoderPadding = xingFrame.encoderPadding;
}
infoMetadata = xingFrame.getMetadata();
long startPosition = input.getPosition();
if (input.getLength() != C.LENGTH_UNSET
&& xingFrame.dataSize != C.LENGTH_UNSET
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.extractor.mp3;

import static java.lang.annotation.ElementType.TYPE_USE;

import android.annotation.SuppressLint;
import androidx.annotation.IntDef;
import androidx.media3.common.Metadata;
import androidx.media3.common.util.UnstableApi;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.Objects;

/** Representation of the ReplayGain data stored in a LAME Xing or Info frame. */
@UnstableApi
public final class Mp3InfoReplayGain implements Metadata.Entry {
/**
* 32 bit floating point "Peak signal amplitude".
*
* <p>1.0 is maximal signal amplitude store-able in decoding format. 0.8 is 80% of maximal signal
* amplitude store-able in decoding format. 1.5 is 150% of maximal signal amplitude store-able in
* decoding format.
*
* <p>A value above 1.0 can occur for example due to "true peak" measurement. A value of 0.0 means
* the peak signal amplitude is unknown.
*/
public final float peak;

/** A gain field can store one gain adjustment with name and originator metadata. */
public static final class GainField {
/** This gain field contains no valid data, and should be ignored. */
public static final int NAME_INVALID = 0;

/**
* This gain field contains a gain adjustment that will make all the tracks sound equally loud
* (as they do on the radio, hence the name!). If the ReplayGain is calculated on a
* track-by-track basis (i.e. an individual ReplayGain calculation is carried out for each
* track), this will be the result.
*/
public static final int NAME_RADIO = 1;

/**
* The problem with the "Radio" setting is that tracks which should be quiet will be brought up
* to the level of all the rest.
*
* <p>To solve this problem, the "Audiophile" setting represents the ideal listening gain for
* each track. ReplayGain can have a good guess at this too, by reading the entire CD, and
* calculating a single gain adjustment for the whole disc. This works because quiet tracks then
* stay quieter than the rest, since the gain won't be changed for each track. It still solves
* the basic problem (annoying, unwanted level differences between discs) because quiet or loud
* discs are still adjusted overall.
*
* <p>Where ReplayGain will fail is if you have an entire CD of quiet music. It will bring it up
* to an average level. This is why the "Audiophile" Replay Gain adjustment must be user
* adjustable. The ReplayGain whole disc value represents a good guess, and should be stored in
* the file. Later, the user can tweak it if required. If the file has originated from the
* artist, then the "Audiophile" setting can be specified by the artist. Naturally, the user is
* free to change the value if they desire.
*/
public static final int NAME_AUDIOPHILE = 2;

/** The origin of this gain adjustment is not known. */
public static final int ORIGINATOR_UNKNOWN = 0;

/** This gain adjustment was manually determined by the artist. */
public static final int ORIGINATOR_ARTIST = 1;

/** This gain adjustment was manually determined by the user. */
public static final int ORIGINATOR_USER = 2;

/** This gain adjustment was automatically determined by the ReplayGain algorithm. */
public static final int ORIGINATOR_REPLAYGAIN = 3;

/** This gain adjustment was automatically determined by a simple RMS algorithm. */
public static final int ORIGINATOR_SIMPLE_RMS = 4;

/** Creates a gain field from already unpacked values. */
public GainField(@Name int name, @Originator int originator, float gain) {
this.name = name;
this.originator = originator;
this.gain = gain;
}

/** Creates a gain field from the packed representation. */
@SuppressLint("WrongConstant")
public GainField(short field) {
this.name = (field >> 13) & 7;
this.originator = (field >> 10) & 7;
this.gain = ((field & 0x1ff) * ((field & 0x200) != 0 ? -1 : 1)) / 10f;
}

@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef({NAME_INVALID, NAME_RADIO, NAME_AUDIOPHILE})
public @interface Name {}

@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef({
ORIGINATOR_UNKNOWN,
ORIGINATOR_ARTIST,
ORIGINATOR_USER,
ORIGINATOR_REPLAYGAIN,
ORIGINATOR_SIMPLE_RMS
})
public @interface Originator {}

/**
* Name/type of the gain field.
*
* <p>If equal to {@link #NAME_INVALID}, or an unknown name, the entire {@link GainField} should
* be ignored.
*/
public final @Name int name;

/**
* Originator of the gain field, i.e. who determined the value / in what way it was determined.
*
* <p>Either a human (user / artist) set the value according to their preferences, or an
* algorithm like ReplayGain or simple RMS average was used to determine it.
*/
public final @Originator int originator;

/**
* Absolute gain adjustment in decibels.
*
* <p>Positive values mean the signal should be amplified, negative values mean it should be
* attenuated.
*
* <p>Due to limitations of the storage format, this is only accurate to the first decimal
* place.
*/
public final float gain;

/**
* @return Whether the name field is set to a valid value, hence, whether this gain field should
* be considered or not. If false, the entire field should be ignored.
*/
public boolean isValid() {
return name == NAME_RADIO || name == NAME_AUDIOPHILE;
}

@Override
public String toString() {
return "GainField{" + "name=" + name + ", originator=" + originator + ", gain=" + gain + '}';
}

@Override
public boolean equals(Object o) {
if (!(o instanceof GainField)) {
return false;
}
GainField gainField = (GainField) o;
return name == gainField.name
&& originator == gainField.originator
&& Float.compare(gain, gainField.gain) == 0;
}

@Override
public int hashCode() {
return Objects.hash(name, originator, gain);
}
}

/** The first of two gain fields in the LAME MP3 Info header. */
public GainField field1;

/** The second of two gain fields in the LAME MP3 Info header. */
public GainField field2;

/** Creates the gain field from already unpacked values. */
public Mp3InfoReplayGain(float peak, GainField field1, GainField field2) {
this.peak = peak;
this.field1 = field1;
this.field2 = field2;
}

/** Creates the gain fields from the packed representation. */
public Mp3InfoReplayGain(float peak, short field1, short field2) {
this(peak, new GainField(field1), new GainField(field2));
}

@Override
public String toString() {
return "ReplayGain Xing/Info: "
+ "peak="
+ peak
+ ", field 1="
+ field1
+ ", field 2="
+ field2;
}

@Override
public boolean equals(Object o) {
if (!(o instanceof Mp3InfoReplayGain)) {
return false;
}
Mp3InfoReplayGain that = (Mp3InfoReplayGain) o;
return Float.compare(peak, that.peak) == 0
&& Objects.equals(field1, that.field1)
&& Objects.equals(field2, that.field2);
}

@Override
public int hashCode() {
return Objects.hash(peak, field1, field2);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Metadata;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.Util;
import androidx.media3.extractor.MpegAudioUtil;
Expand All @@ -35,6 +36,9 @@
*/
public final long dataSize;

/** ReplayGain data. Only present if this frame is an Info or the LAME variant of a Xing frame. */
public final @Nullable Mp3InfoReplayGain replayGain;

/**
* The number of samples to skip at the start of the stream, or {@link C#LENGTH_UNSET} if not
* present in the header.
Expand All @@ -58,12 +62,14 @@ private XingFrame(
long frameCount,
long dataSize,
@Nullable long[] tableOfContents,
@Nullable Mp3InfoReplayGain replayGain,
int encoderDelay,
int encoderPadding) {
this.header = new MpegAudioUtil.Header(header);
this.frameCount = frameCount;
this.dataSize = dataSize;
this.tableOfContents = tableOfContents;
this.replayGain = replayGain;
this.encoderDelay = encoderDelay;
this.encoderPadding = encoderPadding;
}
Expand Down Expand Up @@ -98,23 +104,39 @@ public static XingFrame parse(MpegAudioUtil.Header mpegAudioHeader, ParsableByte
frame.skipBytes(4); // Quality indicator
}

@Nullable Mp3InfoReplayGain replayGain;
int encoderDelay;
int encoderPadding;
// Skip: version string (9), revision & VBR method (1), lowpass filter (1), replay gain (8),
// encoding flags & ATH type (1), bitrate (1).
int bytesToSkipBeforeEncoderDelayAndPadding = 9 + 1 + 1 + 8 + 1 + 1;
if (frame.bytesLeft() >= bytesToSkipBeforeEncoderDelayAndPadding + 3) {
frame.skipBytes(bytesToSkipBeforeEncoderDelayAndPadding);
// Skip: version string (9), revision & VBR method (1), lowpass filter (1).
int bytesToSkipBeforeReplayGain = 9 + 1 + 1;
// Skip: encoding flags & ATH type (1), bitrate (1).
int bytesToSkipAfterReplayGain = 1 + 1;
// And account for values we parse, ReplayGain (8) and encoder delay & padding (3).
if (frame.bytesLeft() >= bytesToSkipBeforeReplayGain + 8 + bytesToSkipAfterReplayGain + 3) {
frame.skipBytes(bytesToSkipBeforeReplayGain);
float peak = frame.readFloat();
short field1 = frame.readShort();
short field2 = frame.readShort();
replayGain = new Mp3InfoReplayGain(peak, field1, field2);

frame.skipBytes(bytesToSkipAfterReplayGain);
int encoderDelayAndPadding = frame.readUnsignedInt24();
encoderDelay = (encoderDelayAndPadding & 0xFFF000) >> 12;
encoderPadding = (encoderDelayAndPadding & 0xFFF);
} else {
replayGain = null;
encoderDelay = C.LENGTH_UNSET;
encoderPadding = C.LENGTH_UNSET;
}

return new XingFrame(
mpegAudioHeader, frameCount, dataSize, tableOfContents, encoderDelay, encoderPadding);
mpegAudioHeader,
frameCount,
dataSize,
tableOfContents,
replayGain,
encoderDelay,
encoderPadding);
}

/**
Expand All @@ -132,4 +154,12 @@ public long computeDurationUs() {
return Util.sampleCountToDurationUs(
(frameCount * header.samplesPerFrame) - 1, header.sampleRate);
}

/** Provide the metadata derived from this Xing frame, such as ReplayGain data. */
public @Nullable Metadata getMetadata() {
if (replayGain != null) {
return new Metadata(replayGain);
}
return null;
}
}