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
22 changes: 22 additions & 0 deletions tika-core/src/main/java/org/apache/tika/metadata/Audio.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,26 @@ 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).
* 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");

/**
* 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 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");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}

Expand All @@ -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());
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
*/
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading