diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3Extractor.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3Extractor.java index f8cdce744b..942a40ee0a 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3Extractor.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3Extractor.java @@ -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; @@ -289,6 +290,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) @@ -298,7 +305,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()); } @@ -579,6 +586,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 diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3InfoReplayGain.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3InfoReplayGain.java new file mode 100644 index 0000000000..ff9d12b583 --- /dev/null +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/Mp3InfoReplayGain.java @@ -0,0 +1,152 @@ +/* + * 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 androidx.media3.common.Metadata; +import androidx.media3.common.util.UnstableApi; +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". + * + *
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. + * + *
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; + + /** + * NAME of Gain adjustment in the first field, also called "Radio Replay Gain" field: + * + *
b000 = not set + * + *
b001 = radio + * + *
b010 = audiophile + */ + public final byte field1Name; + + /** + * ORIGINATOR of Gain adjustment in the first field, also called "Radio Replay Gain" field: + * + *
b000 = not set + * + *
b001 = set by artist + * + *
b010 = set by user + * + *
b011 = set by ReplayGain model + * + *
b100 = set by simple RMS average + */ + public final byte field1Originator; + + /** + * Absolute gain adjustment in the first field, also called "Radio Replay Gain" field. + * + *
Stored in the header with 1 decimal of precision by being multiplied by 10; this field is + * already divided by 10 again. + */ + public final float field1Value; + + /** + * NAME of Gain adjustment in the second field, also called "Audiophile Replay Gain" field: + * + *
b000 = not set + * + *
b001 = radio + * + *
b010 = audiophile + */ + public final byte field2Name; + + /** + * ORIGINATOR of Gain adjustment in the second field, also called "Audiophile Replay Gain" field: + * + *
b000 = not set + * + *
b001 = set by artist + * + *
b010 = set by user + * + *
b011 = set by ReplayGain model + * + *
b100 = set by simple RMS average + */ + public final byte field2Originator; + + /** + * Absolute gain adjustment in the second field, also called "Audiophile Replay Gain" field. + * + *
Stored in the header with 1 decimal of precision by being multiplied by 10; this field is + * already divided by 10 again. + */ + public final float field2Value; + + /* package */ Mp3InfoReplayGain(XingFrame frame) { + this.peak = frame.replayGainPeak; + this.field1Name = frame.replayGainField1Name; + this.field1Originator = frame.replayGainField1Originator; + this.field1Value = frame.replayGainField1Value; + this.field2Name = frame.replayGainField2Name; + this.field2Originator = frame.replayGainField2Originator; + this.field2Value = frame.replayGainField2Value; + } + + @Override + public String toString() { + return "ReplayGain Xing/Info: " + + "peak=" + + peak + + ", f1 name=" + + field1Name + + ", f1 orig=" + + field1Originator + + ", f1 val=" + + field1Value + + ", f2 name=" + + field2Name + + ", f2 orig=" + + field2Originator + + ", f2 val=" + + field2Value; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Mp3InfoReplayGain)) return false; + Mp3InfoReplayGain that = (Mp3InfoReplayGain) o; + return Float.compare(peak, that.peak) == 0 + && field1Name == that.field1Name + && field1Originator == that.field1Originator + && Float.compare(field1Value, that.field1Value) == 0 + && field2Name == that.field2Name + && field2Originator == that.field2Originator + && Float.compare(field2Value, that.field2Value) == 0; + } + + @Override + public int hashCode() { + return Objects.hash( + peak, field1Name, field1Originator, field1Value, field2Name, field2Originator, field2Value); + } +} diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/XingFrame.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/XingFrame.java index 421f05ac20..82c43b9a82 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/XingFrame.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp3/XingFrame.java @@ -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; @@ -35,6 +36,44 @@ */ public final long dataSize; + /** Whether this frame is the LAME variant of a Xing frame, and hence has ReplayGain data. */ + public final boolean hasReplayGain; + + /** + * @see Mp3InfoReplayGain#peak + */ + public final float replayGainPeak; + + /** + * @see Mp3InfoReplayGain#field1Name + */ + public final byte replayGainField1Name; + + /** + * @see Mp3InfoReplayGain#field1Originator + */ + public final byte replayGainField1Originator; + + /** + * @see Mp3InfoReplayGain#field1Value + */ + public final float replayGainField1Value; + + /** + * @see Mp3InfoReplayGain#field2Name + */ + public final byte replayGainField2Name; + + /** + * @see Mp3InfoReplayGain#field2Originator + */ + public final byte replayGainField2Originator; + + /** + * @see Mp3InfoReplayGain#field2Value + */ + public final float replayGainField2Value; + /** * The number of samples to skip at the start of the stream, or {@link C#LENGTH_UNSET} if not * present in the header. @@ -58,12 +97,28 @@ private XingFrame( long frameCount, long dataSize, @Nullable long[] tableOfContents, + boolean hasReplayGain, + float replayGainPeak, + byte replayGainField1Name, + byte replayGainField1Originator, + float replayGainField1Value, + byte replayGainField2Name, + byte replayGainField2Originator, + float replayGainField2Value, int encoderDelay, int encoderPadding) { this.header = new MpegAudioUtil.Header(header); this.frameCount = frameCount; this.dataSize = dataSize; this.tableOfContents = tableOfContents; + this.hasReplayGain = hasReplayGain; + this.replayGainPeak = replayGainPeak; + this.replayGainField1Name = replayGainField1Name; + this.replayGainField1Originator = replayGainField1Originator; + this.replayGainField1Value = replayGainField1Value; + this.replayGainField2Name = replayGainField2Name; + this.replayGainField2Originator = replayGainField2Originator; + this.replayGainField2Value = replayGainField2Value; this.encoderDelay = encoderDelay; this.encoderPadding = encoderPadding; } @@ -98,23 +153,56 @@ public static XingFrame parse(MpegAudioUtil.Header mpegAudioHeader, ParsableByte frame.skipBytes(4); // Quality indicator } - 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); - int encoderDelayAndPadding = frame.readUnsignedInt24(); - encoderDelay = (encoderDelayAndPadding & 0xFFF000) >> 12; - encoderPadding = (encoderDelayAndPadding & 0xFFF); - } else { - encoderDelay = C.LENGTH_UNSET; - encoderPadding = C.LENGTH_UNSET; + boolean hasReplayGain = false; + float replayGainPeak = 0f; + byte replayGainField1Name = 0; + byte replayGainField1Originator = 0; + float replayGainField1Value = 0; + byte replayGainField2Name = 0; + byte replayGainField2Originator = 0; + float replayGainField2Value = 0; + int encoderDelay = C.LENGTH_UNSET; + int encoderPadding = C.LENGTH_UNSET; + // Skip: version string (9), revision & VBR method (1), lowpass filter (1). + int bytesToSkipBeforeReplayGain = 9 + 1 + 1; + if (frame.bytesLeft() >= bytesToSkipBeforeReplayGain + 8) { + frame.skipBytes(bytesToSkipBeforeReplayGain); + hasReplayGain = true; + replayGainPeak = frame.readFloat(); + short field1 = frame.readShort(); + replayGainField1Name = (byte) ((field1 >> 13) & 7); + replayGainField1Originator = (byte) ((field1 >> 10) & 7); + replayGainField1Value = ((field1 & 0x1ff) * ((field1 & 0x200) != 0 ? -1 : 1)) / 10f; + short field2 = frame.readShort(); + replayGainField2Name = (byte) ((field2 >> 13) & 7); + replayGainField2Originator = (byte) ((field2 >> 10) & 7); + replayGainField2Value = ((field2 & 0x1ff) * ((field2 & 0x200) != 0 ? -1 : 1)) / 10f; + + // Skip: encoding flags & ATH type (1), bitrate (1). + int bytesToSkipBeforeEncoderDelayAndPadding = 1 + 1; + if (frame.bytesLeft() >= bytesToSkipBeforeEncoderDelayAndPadding + 3) { + frame.skipBytes(bytesToSkipBeforeEncoderDelayAndPadding); + int encoderDelayAndPadding = frame.readUnsignedInt24(); + encoderDelay = (encoderDelayAndPadding & 0xFFF000) >> 12; + encoderPadding = (encoderDelayAndPadding & 0xFFF); + } } return new XingFrame( - mpegAudioHeader, frameCount, dataSize, tableOfContents, encoderDelay, encoderPadding); + mpegAudioHeader, + frameCount, + dataSize, + tableOfContents, + hasReplayGain, + replayGainPeak, + replayGainField1Name, + replayGainField1Originator, + replayGainField1Value, + replayGainField2Name, + replayGainField2Originator, + replayGainField2Value, + encoderDelay, + encoderPadding); } /** @@ -132,4 +220,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 (hasReplayGain) { + return new Metadata(new Mp3InfoReplayGain(this)); + } + return null; + } }