Skip to content
Merged
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
2 changes: 2 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
media speed with `setSpeed()`.
* Track Selection:
* Extractors:
* MP3: Parse LAME ReplayGain data
([#2840](https://github.com/androidx/media/pull/2840)).
* Inspector:
* Audio:
* Change underlying decoding library for IAMF decoder extension from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ public final class Mp3Extractor implements Extractor {

private int synchronizedHeaderData;

@Nullable private Metadata metadata;
@Nullable private Metadata id3Metadata;
@Nullable private Metadata xingMetadata;
private long basisTimeUs;
private long samplesRead;
private long firstSamplePosition;
Expand Down Expand Up @@ -293,6 +294,15 @@ private int readInternal(ExtractorInput input) throws IOException {
if (seeker == null) {
seeker = computeSeeker(input);
extractorOutput.seekMap(seeker);
Metadata metadata;
if (id3Metadata != null && (flags & FLAG_DISABLE_ID3_METADATA) == 0) {
metadata =
xingMetadata != null
? id3Metadata.copyWithAppendedEntriesFrom(xingMetadata)
: id3Metadata;
} else {
metadata = xingMetadata;
}
Format.Builder format =
new Format.Builder()
.setContainerMimeType(MimeTypes.AUDIO_MPEG)
Expand All @@ -302,7 +312,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(metadata);
if (seeker.getAverageBitrate() != C.RATE_UNSET_INT) {
format.setAverageBitrate(seeker.getAverageBitrate());
}
Expand Down Expand Up @@ -388,9 +398,9 @@ private boolean synchronize(ExtractorInput input, boolean sniffing) throws IOExc
boolean parseAllId3Frames = (flags & FLAG_DISABLE_ID3_METADATA) == 0;
Id3Decoder.FramePredicate id3FramePredicate =
parseAllId3Frames ? null : REQUIRED_ID3_FRAME_PREDICATE;
metadata = id3Peeker.peekId3Data(input, id3FramePredicate, MAX_SEARCH_BYTES);
if (metadata != null) {
gaplessInfoHolder.setFromMetadata(metadata);
id3Metadata = id3Peeker.peekId3Data(input, id3FramePredicate, MAX_SEARCH_BYTES);
if (id3Metadata != null) {
gaplessInfoHolder.setFromMetadata(id3Metadata);
}
peekedId3Bytes = (int) input.getPeekPosition();
if (!sniffing) {
Expand Down Expand Up @@ -475,7 +485,7 @@ private Seeker computeSeeker(ExtractorInput input) throws IOException {
// Read past any seek frame and set the seeker based on metadata or a seek frame. Metadata
// takes priority as it can provide greater precision.
Seeker seekFrameSeeker = maybeReadSeekFrame(input);
Seeker metadataSeeker = maybeHandleSeekMetadata(metadata, input.getPosition());
Seeker metadataSeeker = maybeHandleSeekMetadata(id3Metadata, input.getPosition());

if (disableSeeking) {
return new UnseekableSeeker();
Expand Down Expand Up @@ -583,6 +593,7 @@ private Seeker maybeReadSeekFrame(ExtractorInput input) throws IOException {
gaplessInfoHolder.encoderDelay = xingFrame.encoderDelay;
gaplessInfoHolder.encoderPadding = xingFrame.encoderPadding;
}
xingMetadata = 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,255 @@
/*
* Copyright 2026 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 androidx.annotation.IntDef;
import androidx.annotation.Nullable;
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 {

/** A gain field can store one gain adjustment with name and originator metadata. */
public static final class GainField {

/** The name of a gain field. */
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef(
value = {NAME_RADIO, NAME_AUDIOPHILE},
open = true)
public @interface Name {}

/**
* A gain adjustment that makes all the tracks sound equally loud.
*
* <p>This behaves like tracks 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;

/**
* A gain adjustment that represents the ideal listening gain for each track.
*
* <p>The problem with {@link #NAME_RADIO} 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 originator of a gain field. */
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE)
@IntDef(
value = {
ORIGINATOR_UNSET,
ORIGINATOR_ARTIST,
ORIGINATOR_USER,
ORIGINATOR_REPLAYGAIN,
ORIGINATOR_SIMPLE_RMS
},
open = true)
public @interface Originator {}

/** The origin of this gain adjustment is not set. */
public static final int ORIGINATOR_UNSET = 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;

/** Name/type of the gain field. */
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;

// Lint incorrectly thinks we're doing bitwise flag manipulation.
@SuppressWarnings("WrongConstant")
private GainField(@Name int name, @Originator int originator, float gain) {
this.name = name;
this.originator = originator;
this.gain = gain;
}

/**
* Parses an instance from the packed representation.
*
* <p>Returns {@code null} if the representation is invalid or should be ignored (e.g. {@code
* name} is unset).
*/
// Lint incorrectly thinks we're doing bitwise flag manipulation.
@SuppressWarnings("WrongConstant")
@Nullable
private static GainField parse(int field) {
int name = (field >> 13) & 7;
if (name == 0) {
return null;
}
int originator = (field >> 10) & 7;
float gain = ((field & 0x1ff) * ((field & 0x200) != 0 ? -1 : 1)) / 10f;
return new GainField(name, originator, gain);
}

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

@Override
public boolean equals(@Nullable 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() {
int result = name;
result = 31 * result + originator;
result = 31 * result + Float.hashCode(gain);
return result;
}
}

/**
* 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;

/**
* The first of two gain fields in the LAME MP3 Info header, or null if the field is invalid or
* unset.
*/
@Nullable public GainField field1;

/**
* The second of two gain fields in the LAME MP3 Info header, or null if the field is invalid or
* unset.
*/
@Nullable public GainField field2;

private Mp3InfoReplayGain(float peak, @Nullable GainField field1, @Nullable GainField field2) {
this.peak = peak;
this.field1 = field1;
this.field2 = field2;
}

/**
* Parses an instance from the packed representation.
*
* <p>Returns null if the representation is invalid or should be ignored.
*/
@Nullable
public static Mp3InfoReplayGain parse(float peak, int field1, int field2) {
GainField parsedField1 = GainField.parse(field1);
GainField parsedField2 = GainField.parse(field2);
if (peak <= 0 && parsedField1 == null && parsedField2 == null) {
return null;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should also check for peak == 0f. I am not sure if there are files out there that have peak set but no fields, but it would be more correct I suppose.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks

I included a negative check too since it looks like that's possible in the binary representation but meaningless based on my reading of the peak field docs in this PR - does that look right to you as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think you're right, if it's negative it's entirely nonsense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I intended to say I think it should be if (peak <= 0 && parsedField1 == null && parsedField2 == null) {. There are valid files with no peak but with gain field, for example the replaygain-fast sample

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, sorry - thanks, fixed now

}
return new Mp3InfoReplayGain(peak, parsedField1, parsedField2);
}

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

@Override
public boolean equals(@Nullable 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() {
int result = Float.hashCode(peak);
result = 31 * result + (field1 != null ? field1.hashCode() : 0);
result = 31 * result + (field2 != null ? field2.hashCode() : 0);
return result;
}
}
Loading