From 64d5342d2664e08c64c0eee07bdfc1816e9877cb Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 14 Jul 2026 15:27:52 +0200 Subject: [PATCH 1/2] [TIKA-4779] Add audio:bitrate, audio:is-variable-bitrate and audio:has-drm Part 2 of TIKA-4779, covering the audio properties that need parser work rather than plain mapping. MP3: the parser already walks every audio frame for the duration, and frame duration does not depend on the bitrate, so the plain mean over the frames is the true average bitrate and differing frame bitrates are an exact variable bitrate signal. The only special case is the leading Xing/Info/VBRI tag frame some encoders write: it is a valid MPEG frame that carries no audio and may use a different bitrate, so it is detected (via a new peek method on MpegStream) and kept out of the statistics. Vorbis: the identification header declares upper/nominal/lower rates. The nominal rate is mapped to audio:bitrate (or the shared bound when a zero-width bracket is declared without a nominal rate), and the stream counts as fixed rate when all three declared rates agree or when only a zero-width bracket is declared. MP4: a TikaMp4SoundHandler (same pattern as the box handler extensions from TIKA-2861) walks the sound track's sample description entries. Protected sample entry formats ('drms', 'enca') set audio:has-drm; the esds elementary stream descriptor is located structurally below the entry (honoring the version-dependent entry layout, QuickTime 'wave' wrappers and the optional ES descriptor fields) and its average bitrate is mapped to audio:bitrate. Verified against the real files in the repo where they cover the behavior: testVORBIS.ogg (copied from the integration tests; a real quality-mode encoding with nominal 80000 and no bounds), the 192 kbps testMP3lyrics.mp3 and the 128 kbps ID3 fixtures for constant bitrate, and testMP4.m4a whose esds declares an average bitrate of 256000. Synthetic fixtures fill only the gaps the repo has no real specimen for: a variable bitrate MP3 (frames alternating 128/192 kbps), a constant bitrate MP3 with an 'Info' tag frame at a different rate, a DRM-protected M4A ('drms' sample entry) and an M4A whose ES descriptor uses the optional dependency/URL/OCR fields. --- .../java/org/apache/tika/metadata/Audio.java | 20 ++ .../org/apache/tika/parser/mp3/Mp3Parser.java | 51 +++++ .../apache/tika/parser/mp3/MpegStream.java | 34 ++- .../tika/parser/mp4/TikaMp4BoxHandler.java | 6 + .../tika/parser/mp4/TikaMp4SoundHandler.java | 193 ++++++++++++++++++ .../apache/tika/parser/ogg/VorbisParser.java | 18 ++ .../apache/tika/parser/mp3/Mp3ParserTest.java | 36 ++++ .../apache/tika/parser/mp4/MP4ParserTest.java | 23 +++ .../tika/parser/ogg/VorbisParserTest.java | 104 ++++++++++ .../test-documents/testMP3cbrInfoTag.mp3 | Bin 0 -> 6681 bytes .../resources/test-documents/testMP3vbr.mp3 | Bin 0 -> 3129 bytes .../resources/test-documents/testMP4_drm.m4a | Bin 0 -> 464 bytes .../test-documents/testMP4_esdsFlags.m4a | Bin 0 -> 512 bytes .../resources/test-documents/testVORBIS.ogg | Bin 0 -> 4241 bytes 14 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java create mode 100644 tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java create mode 100644 tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP3cbrInfoTag.mp3 create mode 100644 tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP3vbr.mp3 create mode 100644 tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_drm.m4a create mode 100644 tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_esdsFlags.m4a create mode 100644 tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testVORBIS.ogg diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Audio.java b/tika-core/src/main/java/org/apache/tika/metadata/Audio.java index f20463bfd3c..96d79ac0a63 100644 --- a/tika-core/src/main/java/org/apache/tika/metadata/Audio.java +++ b/tika-core/src/main/java/org/apache/tika/metadata/Audio.java @@ -49,4 +49,24 @@ public interface Audio { * The disc value exactly as tagged, see {@link #RAW_TRACK_NUMBER}. */ Property RAW_DISC_NUMBER = Property.internalText("audio:raw-disc-number"); + + /** + * Average or nominal bitrate in bits per second (averaged over the MP3 + * frames, the Vorbis nominal bitrate, or the MP4 'esds' average bitrate). + */ + Property BITRATE = Property.internalInteger("audio:bitrate"); + + /** + * True if the stream is variable bitrate: the MP3 frames declare differing + * bitrates, or the Vorbis identification header does not declare one fixed + * rate for upper, nominal and lower. + */ + Property IS_VARIABLE_BITRATE = Property.internalBoolean("audio:is-variable-bitrate"); + + /** + * True if the container declares DRM protection, e.g. protected MP4 sample + * entries ('drms', 'enca') or a protection scheme ('sinf') box. Only set + * when protection is detected. + */ + Property HAS_DRM = Property.internalBoolean("audio:has-drm"); } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java index 2f51477eb9a..82fb40dd4f3 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/Mp3Parser.java @@ -96,12 +96,30 @@ protected static ID3TagsAndAudio getAllTagHandlers(InputStream tis, ContentHandl // Now iterate over all audio frames in the file AudioFrame frame = mpegStream.nextFrame(); float duration = 0; + long bitRateSum = 0; + long frameCount = 0; + int baselineBitRate = -1; + boolean variableBitRate = false; + boolean firstFrame = true; boolean skipped = true; while (frame != null && skipped) { duration += frame.getDuration(); if (firstAudio == null) { firstAudio = frame; } + //the Xing/Info/VBRI header is a valid MPEG frame but carries no + //audio and may use a different bitrate, so keep it out of the stats + if (!(firstFrame && isMetadataFrame(mpegStream))) { + int bitRate = frame.getBitRate(); + bitRateSum += bitRate; + frameCount++; + if (baselineBitRate < 0) { + baselineBitRate = bitRate; + } else if (bitRate != baselineBitRate) { + variableBitRate = true; + } + } + firstFrame = false; skipped = mpegStream.skipFrame(); if (skipped) { frame = mpegStream.nextFrame(); @@ -136,6 +154,12 @@ protected static ID3TagsAndAudio getAllTagHandlers(InputStream tis, ContentHandl ret.lyrics = lyrics; ret.tags = tags.toArray(new ID3Tags[0]); ret.duration = duration; + if (frameCount > 0) { + //MP3 frame duration does not depend on the bitrate, so the plain + //mean over the frames is the true average bitrate + ret.averageBitRate = (int) (bitRateSum / frameCount); + ret.variableBitRate = variableBitRate; + } return ret; } @@ -159,6 +183,10 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata metadata.set(XMPDM.DURATION, audioAndTags.durationSeconds()); } + if (audioAndTags.averageBitRate > 0) { + metadata.set(Audio.BITRATE, audioAndTags.averageBitRate); + metadata.set(Audio.IS_VARIABLE_BITRATE, audioAndTags.variableBitRate); + } if (audioAndTags.audio != null) { metadata.set("channels", String.valueOf(audioAndTags.audio.getChannels())); metadata.set("version", audioAndTags.audio.getVersion()); @@ -278,11 +306,34 @@ public void setMaxRecordSize(int maxRecordSize) { public int getMaxRecordSize() { return ID3v2Frame.getMaxRecordSize(); } + /** + * Does the current frame's payload start with a Xing, Info or VBRI + * header? Those tag frames describe the stream rather than carrying + * audio. The markers sit at small fixed offsets that depend on version + * and channel mode, all within the first 40 payload bytes. + */ + private static boolean isMetadataFrame(MpegStream mpegStream) throws IOException { + byte[] payload = mpegStream.peekFramePayload(40); + for (int i = 0; i + 4 <= payload.length; i++) { + if ((payload[i] == 'X' && payload[i + 1] == 'i' + && payload[i + 2] == 'n' && payload[i + 3] == 'g') + || (payload[i] == 'I' && payload[i + 1] == 'n' + && payload[i + 2] == 'f' && payload[i + 3] == 'o') + || (payload[i] == 'V' && payload[i + 1] == 'B' + && payload[i + 2] == 'R' && payload[i + 3] == 'I')) { + return true; + } + } + return false; + } + protected static class ID3TagsAndAudio { private ID3Tags[] tags; private AudioFrame audio; private LyricsHandler lyrics; private float duration; // Milliseconds + private int averageBitRate; // bits per second, 0 if no frame was seen + private boolean variableBitRate; private float durationSeconds() { return duration / 1000; diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/MpegStream.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/MpegStream.java index 28b7a71fe6e..aebe2af7100 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/MpegStream.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp3/MpegStream.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.PushbackInputStream; +import java.util.Arrays; import org.apache.commons.io.IOUtils; @@ -105,6 +106,12 @@ class MpegStream extends PushbackInputStream { */ private static final int HEADER_SIZE = 4; + /** + * Pushback capacity: enough for the header handling plus + * {@link #peekFramePayload(int)} peeks into the frame payload. + */ + private static final int PEEK_BUFFER_SIZE = 48; + /** * The current MPEG header. */ @@ -122,7 +129,7 @@ class MpegStream extends PushbackInputStream { * @param in the underlying audio stream */ public MpegStream(InputStream in) { - super(in, 2 * HEADER_SIZE); + super(in, PEEK_BUFFER_SIZE); } /** @@ -264,7 +271,9 @@ public AudioFrame nextFrame() throws IOException { public boolean skipFrame() throws IOException { if (currentHeader != null) { long toSkip = currentHeader.getLength() - HEADER_SIZE; - long skipped = IOUtils.skip(in, toSkip); + //skip through this stream, not the wrapped one, so bytes pushed + //back by peekFramePayload are honored + long skipped = IOUtils.skip(this, toSkip); currentHeader = null; if (skipped < toSkip) { return false; @@ -274,6 +283,27 @@ public boolean skipFrame() throws IOException { return false; } + /** + * Reads up to {@code count} bytes of the current frame's payload and + * pushes them back, leaving the stream position and the frame accounting + * undisturbed. Used to recognize metadata-only frames (Xing/Info/VBRI). + */ + byte[] peekFramePayload(int count) throws IOException { + byte[] buffer = new byte[count]; + int read = 0; + while (read < count) { + int r = read(buffer, read, count - read); + if (r < 0) { + break; + } + read += r; + } + if (read > 0) { + unread(buffer, 0, read); + } + return read == count ? buffer : Arrays.copyOf(buffer, Math.max(read, 0)); + } + /** * Advances the underlying stream until the first byte of frame sync is * found. diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java index 9f65e48e323..69befa61cae 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java @@ -88,6 +88,12 @@ public Mp4Handler processBox(@NotNull String box, @Nullable byte[] payload, } else if (box.equals("ilst")) { processQuickTimeItemList(payload); return this; + } else if (box.equals("hdlr") && payload != null && payload.length >= 12 + && payload[8] == 's' && payload[9] == 'o' + && payload[10] == 'u' && payload[11] == 'n') { + //sound track: our handler additionally reads DRM markers and the + //esds average bitrate from the sample description + return new TikaMp4SoundHandler(metadata, context, tikaMetadata); } return super.processBox(box, payload, size, context); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java new file mode 100644 index 00000000000..770c4cd7228 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.tika.parser.mp4; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import com.drew.imaging.mp4.Mp4Handler; +import com.drew.metadata.Metadata; +import com.drew.metadata.mp4.Mp4Context; +import com.drew.metadata.mp4.media.Mp4SoundHandler; + +import org.apache.tika.io.EndianUtils; +import org.apache.tika.metadata.Audio; + +/** + * Extends the sound track handling with what the base handler does not read + * from the sample description: DRM protection markers (protected sample entry + * formats such as 'drms' or 'enca') and the average bitrate from the 'esds' + * elementary stream descriptor. See TIKA-4779. + */ +class TikaMp4SoundHandler extends Mp4SoundHandler { + + private final org.apache.tika.metadata.Metadata tikaMetadata; + + TikaMp4SoundHandler(Metadata metadata, Mp4Context context, + org.apache.tika.metadata.Metadata tikaMetadata) { + super(metadata, context); + this.tikaMetadata = tikaMetadata; + } + + @Override + public Mp4Handler processBox(String type, byte[] payload, long boxSize, + Mp4Context context) throws IOException { + if ("stsd".equals(type) && payload != null) { + extractFromSampleDescriptions(payload); + } + return super.processBox(type, payload, boxSize, context); + } + + /** + * Walks the sample description entries: 4 bytes version and flags, a + * 4 byte entry count, then one sample entry per count, each starting with + * its own size and format fourcc. + */ + private void extractFromSampleDescriptions(byte[] b) { + if (b.length < 8) { + return; + } + long entryCount = EndianUtils.getUIntBE(b, 4); + int pos = 8; + for (long i = 0; i < entryCount && pos + 8 <= b.length; i++) { + long size = EndianUtils.getUIntBE(b, pos); + if (size < 16 || size > b.length - pos) { + break; + } + int end = pos + (int) size; + String format = fourCc(b, pos + 4); + //protected streams replace the codec fourcc with a protected + //entry format: 'drms' (FairPlay) or 'enca' (ISO common encryption) + if ("drms".equals(format) || "enca".equals(format)) { + tikaMetadata.set(Audio.HAS_DRM, true); + } + if (pos + 18 <= end) { + //sample entry: 8 byte header, 6 reserved, 2 data ref index, + //then version-dependent fixed sound fields before child boxes + int version = EndianUtils.getUShortBE(b, pos + 16); + int bitRate = findEsdsAverageBitRate(b, pos + soundEntrySize(version), end); + if (bitRate > 0) { + tikaMetadata.set(Audio.BITRATE, bitRate); + } + } + pos = end; + } + } + + /** + * Size of the fixed part of a sound sample entry, after which the child + * boxes start: 36 bytes for version 0, 52 for version 1 (four extra + * 32-bit QuickTime fields), 72 for version 2. + */ + private static int soundEntrySize(int version) { + if (version == 1) { + return 52; + } + if (version == 2) { + return 72; + } + return 36; + } + + /** + * Scans the child boxes of a sample entry for an 'esds' box and returns + * its average bitrate, or 0 if there is none. QuickTime version 1/2 + * entries may nest the 'esds' inside a 'wave' extension box. + */ + private static int findEsdsAverageBitRate(byte[] b, int pos, int end) { + while (pos >= 0 && pos + 8 <= end) { + long size = EndianUtils.getUIntBE(b, pos); + if (size < 8 || size > end - pos) { + return 0; + } + String type = fourCc(b, pos + 4); + if ("esds".equals(type)) { + return readEsdsAverageBitRate(b, pos + 8, pos + (int) size); + } + if ("wave".equals(type)) { + int nested = findEsdsAverageBitRate(b, pos + 8, pos + (int) size); + if (nested > 0) { + return nested; + } + } + pos += (int) size; + } + return 0; + } + + /** + * Extracts the average bitrate from an 'esds' box body, or returns 0 if + * the descriptors cannot be walked. The chain is an ES_Descriptor (tag + * 0x03) with three optional fields signalled by its flags byte, followed + * by a DecoderConfigDescriptor (tag 0x04) whose fixed fields end with the + * maximum and average bitrates. + */ + private static int readEsdsAverageBitRate(byte[] b, int pos, int end) { + //4 bytes version and flags, then the ES descriptor + pos += 4; + if (pos >= end || b[pos] != 0x03) { + return 0; + } + pos = skipDescriptorLength(b, pos + 1); + if (pos + 3 > end) { + return 0; + } + //ES_ID (2 bytes), then a flags/priority byte announcing the + //optional stream dependence, URL and OCR fields + int flags = b[pos + 2] & 0xFF; + pos += 3; + if ((flags & 0x80) != 0) { + pos += 2; + } + if ((flags & 0x40) != 0) { + if (pos >= end) { + return 0; + } + pos += 1 + (b[pos] & 0xFF); + } + if ((flags & 0x20) != 0) { + pos += 2; + } + if (pos >= end || b[pos] != 0x04) { + return 0; + } + pos = skipDescriptorLength(b, pos + 1); + //object type (1), stream type (1), buffer size (3), max bitrate (4) + pos += 9; + if (pos + 4 > end) { + return 0; + } + long averageBitRate = EndianUtils.getUIntBE(b, pos); + return averageBitRate > 0 && averageBitRate <= Integer.MAX_VALUE + ? (int) averageBitRate : 0; + } + + /** + * Skips a descriptor's variable length encoding (bytes with the high bit + * set continue the length) and returns the position of the payload. + */ + private static int skipDescriptorLength(byte[] b, int pos) { + while (pos < b.length && (b[pos] & 0x80) != 0) { + pos++; + } + return pos + 1; + } + + private static String fourCc(byte[] b, int pos) { + return new String(b, pos, 4, StandardCharsets.ISO_8859_1); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java index a4263db45ac..dc6b78f9958 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/ogg/VorbisParser.java @@ -32,6 +32,7 @@ import org.apache.tika.annotation.TikaComponent; import org.apache.tika.exception.TikaException; import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Audio; import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.XMPDM; import org.apache.tika.mime.MediaType; @@ -88,6 +89,23 @@ protected void extractInfo(Metadata metadata, VorbisInfo info) throws TikaExcept metadata.set(XMPDM.AUDIO_SAMPLE_RATE, (int) info.getRate()); metadata.add("version", "Vorbis " + info.getVersion()); + int upper = info.getBitrateUpper(); + int nominal = info.getBitrateNominal(); + int lower = info.getBitrateLower(); + if (nominal > 0 || upper > 0 || lower > 0) { + if (nominal > 0) { + metadata.set(Audio.BITRATE, nominal); + } else if (upper > 0 && upper == lower) { + //a zero-width bitrate bracket declares an exact rate as well + metadata.set(Audio.BITRATE, upper); + } + //fixed rate when all three declared rates agree, or when a + //zero-width bracket is declared without a nominal rate + boolean fixedRate = (nominal > 0 && upper == nominal && lower == nominal) + || (nominal <= 0 && upper > 0 && upper == lower); + metadata.set(Audio.IS_VARIABLE_BITRATE, !fixedRate); + } + extractChannelInfo(metadata, info); } } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java index 0bf7053c728..241f53352b7 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp3/Mp3ParserTest.java @@ -104,6 +104,8 @@ public void testMp3ParsingID3v2() throws Exception { assertEquals("MPEG 3 Layer III Version 1", metadata.get("version")); assertEquals("44100", metadata.get(XMPDM.AUDIO_SAMPLE_RATE)); assertEquals("1", metadata.get("channels")); + assertEquals("128000", metadata.get(Audio.BITRATE)); + assertEquals("false", metadata.get(Audio.IS_VARIABLE_BITRATE)); // Check XMPDM-typed audio properties assertEquals("Test Album", metadata.get(XMPDM.ALBUM)); @@ -166,6 +168,36 @@ public void testMp3ParsingID3v1v2() throws Exception { checkDuration(metadata, 2); } + /** + * The frames of testMP3vbr.mp3 alternate between 128 and 192 kbps, so + * the reported bitrate is the average and the stream is variable rate. + */ + @Test + public void testMp3VariableBitRate() throws Exception { + Metadata metadata = new Metadata(); + getText("testMP3vbr.mp3", metadata); + + assertEquals("audio/mpeg", metadata.get(Metadata.CONTENT_TYPE)); + assertEquals("160000", metadata.get(Audio.BITRATE)); + assertEquals("true", metadata.get(Audio.IS_VARIABLE_BITRATE)); + } + + /** + * The first frame of testMP3cbrInfoTag.mp3 is an 'Info' tag frame + * written at 128 kbps while the audio frames are 320 kbps. The tag frame + * must not show up in the bitrate statistics: the file is constant rate + * at 320 kbps, not variable rate. + */ + @Test + public void testMp3ConstantBitRateWithInfoTagFrame() throws Exception { + Metadata metadata = new Metadata(); + getText("testMP3cbrInfoTag.mp3", metadata); + + assertEquals("audio/mpeg", metadata.get(Metadata.CONTENT_TYPE)); + assertEquals("320000", metadata.get(Audio.BITRATE)); + assertEquals("false", metadata.get(Audio.IS_VARIABLE_BITRATE)); + } + /** * Test that with only ID3v2 tags, of version 2.4, we get the full * set of information out. @@ -271,6 +303,10 @@ public void testMp3ParsingLyrics() throws Exception { Metadata metadata = new Metadata(); String content = getText("testMP3lyrics.mp3", metadata); + //a real 192 kbps constant bitrate file + assertEquals("192000", metadata.get(Audio.BITRATE)); + assertEquals("false", metadata.get(Audio.IS_VARIABLE_BITRATE)); + assertEquals("audio/mpeg", metadata.get(Metadata.CONTENT_TYPE)); assertEquals("Test Title", metadata.get(TikaCoreProperties.TITLE)); assertEquals("Test Artist", metadata.get(TikaCoreProperties.CREATOR)); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java index afa8d976199..fd77f5c37f2 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java @@ -18,6 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Arrays; @@ -102,6 +103,9 @@ public void testMP4ParsingAudio() throws Exception { assertEquals("Test Genre", metadata.get(XMPDM.GENRE)); assertEquals("Test Comments", metadata.get(XMPDM.LOG_COMMENT.getName())); assertEquals("1", metadata.get(XMPDM.TRACK_NUMBER)); + //average bitrate from the esds elementary stream descriptor + assertEquals("256000", metadata.get(Audio.BITRATE)); + assertNull(metadata.get(Audio.HAS_DRM)); //the totals from the trkn/disk atoms were previously read and discarded assertEquals("42", metadata.get(Audio.TRACK_COUNT)); assertEquals("Test Album Artist", metadata.get(XMPDM.ALBUM_ARTIST)); @@ -286,6 +290,25 @@ private Set getVals(Metadata m, String k) { return vals; } */ + @Test + public void testDrmProtectedM4a() throws Exception { + //the sample description declares a protected 'drms' sample entry + Metadata metadata = new Metadata(); + getText("testMP4_drm.m4a", metadata); + assertEquals("true", metadata.get(Audio.HAS_DRM)); + } + + @Test + public void testEsdsWithDescriptorFlags() throws Exception { + //the ES descriptor declares the optional stream dependence, URL and + //OCR fields, which shift the DecoderConfigDescriptor; the URL string + //deliberately reads "sinf" so a raw fourcc scan would misreport DRM + Metadata metadata = new Metadata(); + getText("testMP4_esdsFlags.m4a", metadata); + assertEquals("96000", metadata.get(Audio.BITRATE)); + assertNull(metadata.get(Audio.HAS_DRM)); + } + @Test public void testQuickTimeMetadataKeys() throws Exception { //QuickTime item-list metadata (moov/meta/keys+ilst, the com.apple.quicktime.* diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java new file mode 100644 index 00000000000..142d202da2c --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/ogg/VorbisParserTest.java @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.tika.parser.ogg; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.gagravarr.vorbis.VorbisInfo; +import org.junit.jupiter.api.Test; +import org.xml.sax.helpers.DefaultHandler; + +import org.apache.tika.TikaTest; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Audio; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.XMPDM; +import org.apache.tika.parser.ParseContext; + +/** + * Tests the bitrate mapping from the Vorbis identification header, end to end + * against a real file and at unit level with directly constructed info + * objects for the header combinations the real fixture does not cover. + */ +public class VorbisParserTest extends TikaTest { + + @Test + public void testRealFile() throws Exception { + //a real quality-mode encoding: nominal 80000, no upper/lower bounds + Metadata metadata = new Metadata(); + try (TikaInputStream tis = TikaInputStream.get( + getResourceAsStream("/test-documents/testVORBIS.ogg"))) { + new VorbisParser().parse(tis, new DefaultHandler(), metadata, + new ParseContext()); + } + assertEquals("80000", metadata.get(Audio.BITRATE)); + assertEquals("true", metadata.get(Audio.IS_VARIABLE_BITRATE)); + assertEquals("44100", metadata.get(XMPDM.AUDIO_SAMPLE_RATE)); + } + + private static Metadata extractInfo(int upper, int nominal, int lower) throws Exception { + VorbisInfo info = new VorbisInfo(); + info.setRate(44100); + info.setBitrateUpper(upper); + info.setBitrateNominal(nominal); + info.setBitrateLower(lower); + + Metadata metadata = new Metadata(); + new VorbisParser().extractInfo(metadata, info); + return metadata; + } + + @Test + public void testNominalOnlyIsVariable() throws Exception { + //the common quality-mode encoding declares only a nominal rate + Metadata metadata = extractInfo(0, 192000, 0); + assertEquals("192000", metadata.get(Audio.BITRATE)); + assertEquals("true", metadata.get(Audio.IS_VARIABLE_BITRATE)); + } + + @Test + public void testAllRatesEqualIsFixed() throws Exception { + Metadata metadata = extractInfo(192000, 192000, 192000); + assertEquals("192000", metadata.get(Audio.BITRATE)); + assertEquals("false", metadata.get(Audio.IS_VARIABLE_BITRATE)); + } + + @Test + public void testZeroWidthBracketIsFixed() throws Exception { + //managed-mode encoding without a nominal rate: an upper and lower + //bound that agree still pin the stream to one exact rate + Metadata metadata = extractInfo(128000, 0, 128000); + assertEquals("128000", metadata.get(Audio.BITRATE)); + assertEquals("false", metadata.get(Audio.IS_VARIABLE_BITRATE)); + } + + @Test + public void testDivergentBracketIsVariable() throws Exception { + //a real bracket without a nominal rate: variable, no single bitrate + Metadata metadata = extractInfo(256000, 0, 96000); + assertNull(metadata.get(Audio.BITRATE)); + assertEquals("true", metadata.get(Audio.IS_VARIABLE_BITRATE)); + } + + @Test + public void testNoDeclaredRates() throws Exception { + Metadata metadata = extractInfo(0, 0, 0); + assertNull(metadata.get(Audio.BITRATE)); + assertNull(metadata.get(Audio.IS_VARIABLE_BITRATE)); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP3cbrInfoTag.mp3 b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP3cbrInfoTag.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..25cfcb0e499f185ca503836474ba61f5c6e7811e GIT binary patch literal 6681 zcmeIyu?fI15Cg!hmqk)Kt7OFN;ldull?uVReIuTG(3;4ZjfhKUyRV?noUi5(ant3_sV6772qqPDhJ`K^{TF G&UpYa?+={- literal 0 HcmV?d00001 diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_drm.m4a b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_drm.m4a new file mode 100644 index 0000000000000000000000000000000000000000..4a37c1282bfc5e6cf12d0b41d54efe010466e1d4 GIT binary patch literal 464 zcmZQzV30^FsVwj{aa3Rc0uY^BU}D6;z_=qfKfeqpl#^SQkphxK0Hz2A1|VhxszL}- z%3=bV=2B9Wm<=>2rX(BPPD+i3>wl1&l9>qPDdeW0+p)%lfdPcUKp`U~rwGK#&B!S# z&M(b_2*b%4xtV!sK%PKxE{Zt~#U)8OKyj1el44l6fr3dTr6{);t{Xc;ptz(2Sq+e1 VoQ$MY1jw&K;)9q#HOcufF#z5tD&znF literal 0 HcmV?d00001 diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_esdsFlags.m4a b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_esdsFlags.m4a new file mode 100644 index 0000000000000000000000000000000000000000..5fda062fb7cd54ac401b7187eead304d9debb274 GIT binary patch literal 512 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 V`8Vz~;_n)ofZ}+fEEMX7{tc_^4%Pqw literal 0 HcmV?d00001 From 2418ccbbc90a1217fa16e3e10adfdf860d4d7d6e Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 15 Jul 2026 16:00:23 +0200 Subject: [PATCH 2/2] [TIKA-4779] Align Audio javadoc with the implementation The has-drm doc still mentioned a standalone 'sinf' scan that the review removed; it now names the protected sample entry formats only. Both properties now state their multi-track semantics explicitly: bitrate is per-stream (last sound track), has-drm is file-level (any protected track sets it). --- .../src/main/java/org/apache/tika/metadata/Audio.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Audio.java b/tika-core/src/main/java/org/apache/tika/metadata/Audio.java index 96d79ac0a63..9b84b0eb3f3 100644 --- a/tika-core/src/main/java/org/apache/tika/metadata/Audio.java +++ b/tika-core/src/main/java/org/apache/tika/metadata/Audio.java @@ -53,6 +53,8 @@ public interface Audio { /** * Average or nominal bitrate in bits per second (averaged over the MP3 * frames, the Vorbis nominal bitrate, or the MP4 'esds' average bitrate). + * A per-stream value: in a file with several audio tracks it reflects + * the last sound track's sample description. */ Property BITRATE = Property.internalInteger("audio:bitrate"); @@ -64,9 +66,9 @@ public interface Audio { Property IS_VARIABLE_BITRATE = Property.internalBoolean("audio:is-variable-bitrate"); /** - * True if the container declares DRM protection, e.g. protected MP4 sample - * entries ('drms', 'enca') or a protection scheme ('sinf') box. Only set - * when protection is detected. + * True if the container declares DRM protection through a protected + * sample entry format such as 'drms' or 'enca'. A file-level flag: any + * protected audio track sets it. Only set when protection is detected. */ Property HAS_DRM = Property.internalBoolean("audio:has-drm"); }