/* * RED5 Open Source Flash Server - http://code.google.com/p/red5/ * * Copyright 2006-2012 by respective authors (see below). All rights reserved. * * 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 * * 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.red5.io.mp3.impl; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.channels.FileChannel; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.concurrent.Semaphore; import org.apache.mina.core.buffer.IoBuffer; import org.jaudiotagger.audio.AudioFileIO; import org.jaudiotagger.audio.mp3.MP3AudioHeader; import org.jaudiotagger.audio.mp3.MP3File; import org.jaudiotagger.tag.FieldKey; import org.jaudiotagger.tag.TagException; import org.jaudiotagger.tag.datatype.Artwork; import org.jaudiotagger.tag.datatype.DataTypes; import org.jaudiotagger.tag.id3.ID3v24Tag; import org.jaudiotagger.tag.id3.framebody.FrameBodyAPIC; import org.red5.io.IKeyFrameMetaCache; import org.red5.io.IStreamableFile; import org.red5.io.ITag; import org.red5.io.ITagReader; import org.red5.io.IoConstants; import org.red5.io.amf.Output; import org.red5.io.flv.IKeyFrameDataAnalyzer; import org.red5.io.flv.impl.Tag; import org.red5.io.object.Serializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Read MP3 files */ public class MP3Reader implements ITagReader, IKeyFrameDataAnalyzer { protected static Logger log = LoggerFactory.getLogger(MP3Reader.class); /** * File */ private File file; /** * File input stream */ private FileInputStream fis; /** * File channel */ private FileChannel channel; /** * Byte buffer */ private ByteBuffer buf = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN); /** * Last read tag object */ private ITag tag; /** * Previous tag size */ private int prevSize; /** * Current time */ private double currentTime; /** * Frame metadata */ private KeyFrameMeta frameMeta; /** * Positions and time map */ private HashMap<Long, Double> posTimeMap; private int dataRate; /** * File duration */ private long duration; /** * Frame cache */ static private IKeyFrameMetaCache frameCache; /** * Holder for ID3 meta data */ private MetaData metaData; /** * Container for metadata and any other tags that should * be sent prior to media data. */ private LinkedList<ITag> firstTags = new LinkedList<ITag>(); private long fileSize; private final Semaphore lock = new Semaphore(1, true); MP3Reader() { // Only used by the bean startup code to initialize the frame cache } /** * Creates reader from file input stream * * @param file file input * @throws IOException */ public MP3Reader(File file) throws IOException { this.file = file; // parse the id3 info try { MP3File mp3file = (MP3File) AudioFileIO.read(file); MP3AudioHeader audioHeader = (MP3AudioHeader) mp3file.getAudioHeader(); if (audioHeader != null) { log.debug("Track length: {}", audioHeader.getTrackLength()); log.debug("Sample rate: {}", audioHeader.getSampleRateAsNumber()); log.debug("Channels: {}", audioHeader.getChannels()); log.debug("Variable bit rate: {}", audioHeader.isVariableBitRate()); log.debug("Track length (2): {}", audioHeader.getTrackLengthAsString()); log.debug("Mpeg version: {}", audioHeader.getMpegVersion()); log.debug("Mpeg layer: {}", audioHeader.getMpegLayer()); log.debug("Original: {}", audioHeader.isOriginal()); log.debug("Copyrighted: {}", audioHeader.isCopyrighted()); log.debug("Private: {}", audioHeader.isPrivate()); log.debug("Protected: {}", audioHeader.isProtected()); log.debug("Bitrate: {}", audioHeader.getBitRate()); log.debug("Encoding type: {}", audioHeader.getEncodingType()); log.debug("Encoder: {}", audioHeader.getEncoder()); } ID3v24Tag idTag = mp3file.getID3v2TagAsv24(); if (idTag != null) { // create meta data holder metaData = new MetaData(); metaData.setAlbum(idTag.getFirst(FieldKey.ALBUM)); metaData.setArtist(idTag.getFirst(FieldKey.ARTIST)); metaData.setComment(idTag.getFirst(FieldKey.COMMENT)); metaData.setGenre(idTag.getFirst(FieldKey.GENRE)); metaData.setSongName(idTag.getFirst(FieldKey.TITLE)); metaData.setTrack(idTag.getFirst(FieldKey.TRACK)); metaData.setYear(idTag.getFirst(FieldKey.YEAR)); //send album image if included List<Artwork> tagFieldList = idTag.getArtworkList(); if (tagFieldList == null || tagFieldList.isEmpty()) { log.debug("No cover art was found"); } else { Artwork imageField = tagFieldList.get(0); log.debug("Picture type: {}", imageField.getPictureType()); FrameBodyAPIC imageFrameBody = new FrameBodyAPIC(); imageFrameBody.setImageData(imageField.getBinaryData()); if (!imageFrameBody.isImageUrl()) { byte[] imageBuffer = (byte[]) imageFrameBody.getObjectValue(DataTypes.OBJ_PICTURE_DATA); //set the cover image on the metadata metaData.setCovr(imageBuffer); // Create tag for onImageData event IoBuffer buf = IoBuffer.allocate(imageBuffer.length); buf.setAutoExpand(true); Output out = new Output(buf); out.writeString("onImageData"); Map<Object, Object> props = new HashMap<Object, Object>(); props.put("trackid", 1); props.put("data", imageBuffer); out.writeMap(props, new Serializer()); buf.flip(); //Ugh i hate flash sometimes!! //Error #2095: flash.net.NetStream was unable to invoke callback onImageData. ITag result = new Tag(IoConstants.TYPE_METADATA, 0, buf.limit(), null, 0); result.setBody(buf); //add to first frames firstTags.add(result); } } } else { log.info("File did not contain ID3v2 data: {}", file.getName()); } mp3file = null; } catch (TagException e) { log.error("MP3Reader (tag error) {}", e); } catch (Exception e) { log.error("MP3Reader {}", e); } fis = new FileInputStream(file); // grab file channel and map it to memory-mapped byte buffer in read-only mode channel = fis.getChannel(); // get the total bytes / file size fileSize = channel.size(); log.debug("File size: {}", fileSize); // analyze keyframes data analyzeKeyFrames(); // create file metadata object firstTags.addFirst(createFileMeta()); // MP3 header is length of 32 bits, that is, 4 bytes // read further if there's still data if ((fileSize - channel.position()) > 4) { // look for next frame searchNextFrame(); // get current position long pos = channel.position(); // Data in MP3 file goes header-data-header-data...header-data // Read header... MP3Header header = null; try { header = readHeader(); } catch (Exception e) { log.warn("Exception reading initial header", e); } // set position channel.position(pos); // Check header if (header != null) { checkValidHeader(header); } else { throw new RuntimeException("No initial header found"); } } } /** * A MP3 stream never has video. * * @return always returns <code>false</code> */ public boolean hasVideo() { return false; } public void setFrameCache(IKeyFrameMetaCache frameCache) { MP3Reader.frameCache = frameCache; } /** * Check if the file can be played back with Flash. Supported sample rates * are 44KHz, 22KHz, 11KHz and 5.5KHz * * @param header * Header to check */ private void checkValidHeader(MP3Header header) { switch (header.getSampleRate()) { case 48000: case 44100: case 22050: case 11025: case 5513: break; default: throw new RuntimeException("Unsupported sample rate: " + header.getSampleRate()); } } /** * Creates file metadata object * * @return Tag */ private ITag createFileMeta() { log.debug("createFileMeta"); // create tag for onMetaData event IoBuffer in = IoBuffer.allocate(1024); in.setAutoExpand(true); Output out = new Output(in); out.writeString("onMetaData"); Map<Object, Object> props = new HashMap<Object, Object>(); props.put("duration", frameMeta.timestamps[frameMeta.timestamps.length - 1] / 1000.0); props.put("audiocodecid", IoConstants.FLAG_FORMAT_MP3); if (dataRate > 0) { props.put("audiodatarate", dataRate); } props.put("canSeekToEnd", true); //set id3 meta data if it exists if (metaData != null) { props.put("artist", metaData.getArtist()); props.put("album", metaData.getAlbum()); props.put("songName", metaData.getSongName()); props.put("genre", metaData.getGenre()); props.put("year", metaData.getYear()); props.put("track", metaData.getTrack()); props.put("comment", metaData.getComment()); if (metaData.hasCoverImage()) { Map<Object, Object> covr = new HashMap<Object, Object>(1); covr.put("covr", new Object[] { metaData.getCovr() }); props.put("tags", covr); } //clear meta for gc metaData = null; } out.writeMap(props, new Serializer()); in.flip(); ITag result = new Tag(IoConstants.TYPE_METADATA, 0, in.limit(), null, prevSize); result.setBody(in); return result; } /** * Search for next frame sync word. Sync word identifies valid frame. */ public void searchNextFrame() { log.debug("searchNextFrame"); ByteBuffer in = ByteBuffer.allocate(1).order(ByteOrder.BIG_ENDIAN); try { while ((fileSize - channel.position()) > 1) { // read byte in.clear(); channel.read(in); in.flip(); // check byte int ch = in.get() & 0xff; if (ch != 0xff) { continue; } // read next byte in.clear(); channel.read(in); in.flip(); // check next byte if ((in.get() & 0xe0) == 0xe0) { // found it log.debug("Found frame"); channel.position(channel.position() - 2); break; } } } catch (IOException e) { log.warn("Exception getting next frame", e); } finally { in.clear(); in = null; } } /** {@inheritDoc} */ public IStreamableFile getFile() { return null; } /** {@inheritDoc} */ public int getOffset() { return 0; } /** {@inheritDoc} */ public long getBytesRead() { try { return channel.position(); } catch (IOException e) { log.warn("Exception getting bytes read", e); } return 0; } /** {@inheritDoc} */ public long getDuration() { return duration; } /** * Get the total readable bytes in a file or ByteBuffer. * * @return Total readable bytes */ public long getTotalBytes() { return fileSize; } /** {@inheritDoc} */ public boolean hasMoreTags() { log.debug("hasMoreTags"); MP3Header header = null; try { header = readHeader(); } catch (IOException e) { log.warn("Exception reading header", e); } catch (Exception e) { searchNextFrame(); } //log.debug("Header: {}", header); if (header == null || header.frameSize() == 0) { // TODO find better solution how to deal with broken files return false; } try { if (channel.position() + header.frameSize() - 4 > fileSize) { // last frame is incomplete channel.position(fileSize); return false; } channel.position(channel.position() - 4); } catch (IOException e) { log.warn("Exception checking for more tags", e); } return true; } private MP3Header readHeader() throws Exception { log.debug("readHeader at {}", channel.position()); buf.clear(); channel.read(buf); buf.flip(); return new MP3Header(buf.getInt()); } /** {@inheritDoc} */ public ITag readTag() { log.debug("readTag"); try { lock.acquire(); if (!firstTags.isEmpty()) { // Return first tags before media data return firstTags.removeFirst(); } MP3Header header = readHeader(); int frameSize = header.frameSize(); if (frameSize == 0) { // TODO find better solution how to deal with broken files return null; } if (channel.position() + frameSize - 4 > fileSize) { // last frame is incomplete channel.position(fileSize); return null; } tag = new Tag(IoConstants.TYPE_AUDIO, (int) currentTime, frameSize + 1, null, prevSize); prevSize = frameSize + 1; currentTime += header.frameDuration(); IoBuffer body = IoBuffer.allocate(tag.getBodySize()); body.setAutoExpand(true); byte tagType = (IoConstants.FLAG_FORMAT_MP3 << 4) | (IoConstants.FLAG_SIZE_16_BIT << 1); switch (header.getSampleRate()) { case 48000: tagType |= IoConstants.FLAG_RATE_48_KHZ << 2; break; case 44100: tagType |= IoConstants.FLAG_RATE_44_KHZ << 2; break; case 22050: tagType |= IoConstants.FLAG_RATE_22_KHZ << 2; break; case 11025: tagType |= IoConstants.FLAG_RATE_11_KHZ << 2; break; default: tagType |= IoConstants.FLAG_RATE_5_5_KHZ << 2; } tagType |= (header.isStereo() ? IoConstants.FLAG_TYPE_STEREO : IoConstants.FLAG_TYPE_MONO); body.put(tagType); body.putInt(header.getData()); int bufferSize = (frameSize - 4); log.trace("Allocating {} buffer", bufferSize); if (bufferSize > 0) { ByteBuffer in = ByteBuffer.allocate(bufferSize).order(ByteOrder.BIG_ENDIAN); channel.read(in); in.flip(); body.put(in); body.flip(); tag.setBody(body); } else { log.warn("Buffer size was invalid: {}", bufferSize); } } catch (InterruptedException e) { log.warn("Exception acquiring lock", e); } catch (Exception e) { log.warn("Exception reading tag", e); } finally { lock.release(); } return tag; } /** {@inheritDoc} */ public void close() { if (posTimeMap != null) { posTimeMap.clear(); } if (buf != null) { buf.clear(); buf = null; } try { fis.close(); channel.close(); } catch (IOException e) { log.error("Exception on close", e); } } /** {@inheritDoc} */ public void decodeHeader() { } /** {@inheritDoc} */ public void position(long pos) { try { if (pos == Long.MAX_VALUE) { // seek at EOF channel.position(fileSize); currentTime = duration; return; } channel.position(pos); // advance to next frame searchNextFrame(); Double time = posTimeMap.get(channel.position()); if (time != null) { currentTime = time; } else { // Unknown frame position - this should never happen currentTime = 0; } } catch (IOException e) { log.warn("Exception setting position", e); } } /** {@inheritDoc} */ public KeyFrameMeta analyzeKeyFrames() { log.debug("analyzeKeyFrames"); if (frameMeta != null) { return frameMeta; } try { lock.acquire(); // check for cached frame informations if (frameCache != null) { frameMeta = frameCache.loadKeyFrameMeta(file); if (frameMeta != null && frameMeta.duration > 0) { // Frame data loaded, create other mappings duration = frameMeta.duration; frameMeta.audioOnly = true; posTimeMap = new HashMap<Long, Double>(); for (int i = 0; i < frameMeta.positions.length; i++) { posTimeMap.put(frameMeta.positions[i], (double) frameMeta.timestamps[i]); } return frameMeta; } } List<Long> positionList = new ArrayList<Long>(); List<Double> timestampList = new ArrayList<Double>(); dataRate = 0; long rate = 0; int count = 0; long origPos = channel.position(); double time = 0; channel.position(0); searchNextFrame(); while (hasMoreTags()) { MP3Header header = readHeader(); if (header == null || header.frameSize() == 0) { // TODO find better solution how to deal with broken files... // See APPSERVER-62 for details break; } long pos = channel.position() - 4; if (pos + header.frameSize() > fileSize) { // last frame is incomplete break; } positionList.add(pos); timestampList.add(time); rate += header.getBitRate() / 1000; time += header.frameDuration(); channel.position(pos + header.frameSize()); count++; } // restore the pos channel.position(origPos); duration = (long) time; dataRate = (int) (rate / count); posTimeMap = new HashMap<Long, Double>(); frameMeta = new KeyFrameMeta(); frameMeta.duration = duration; frameMeta.positions = new long[positionList.size()]; frameMeta.timestamps = new int[timestampList.size()]; frameMeta.audioOnly = true; for (int i = 0; i < frameMeta.positions.length; i++) { frameMeta.positions[i] = positionList.get(i); frameMeta.timestamps[i] = timestampList.get(i).intValue(); posTimeMap.put(positionList.get(i), timestampList.get(i)); } if (frameCache != null) { frameCache.saveKeyFrameMeta(file, frameMeta); } } catch (InterruptedException e) { log.warn("Exception acquiring lock", e); } catch (Exception e) { log.warn("Exception analyzing frames", e); } finally { lock.release(); } log.debug("Analysis complete"); return frameMeta; } /** * Simple holder for id3 meta data */ static class MetaData { String album = ""; String artist = ""; String genre = ""; String songName = ""; String track = ""; String year = ""; String comment = ""; byte[] covr = null; public String getAlbum() { return album; } public void setAlbum(String album) { this.album = album; } public String getArtist() { return artist; } public void setArtist(String artist) { this.artist = artist; } public String getGenre() { return genre; } public void setGenre(String genre) { this.genre = genre; } public String getSongName() { return songName; } public void setSongName(String songName) { this.songName = songName; } public String getTrack() { return track; } public void setTrack(String track) { this.track = track; } public String getYear() { return year; } public void setYear(String year) { this.year = year; } public String getComment() { return comment; } public void setComment(String comment) { this.comment = comment; } public byte[] getCovr() { return covr; } public void setCovr(byte[] covr) { this.covr = covr; log.debug("Cover image array size: {}", covr.length); } public boolean hasCoverImage() { return covr != null; } } }