/* * 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.m4a.impl; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.MappedByteBuffer; import java.nio.channels.FileChannel; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Vector; import java.util.concurrent.Semaphore; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.mina.core.buffer.IoBuffer; 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.impl.Tag; import org.red5.io.mp4.MP4Atom; import org.red5.io.mp4.MP4DataStream; import org.red5.io.mp4.MP4Descriptor; import org.red5.io.mp4.MP4Frame; import org.red5.io.mp4.impl.MP4Reader; import org.red5.io.object.Serializer; import org.red5.io.utils.HexDump; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * A Reader is used to read the contents of a M4A file. * NOTE: This class is not implemented as threading-safe. The caller * should make sure the threading-safety. * * @author The Red5 Project (red5@osflash.org) * @author Paul Gregoire, (mondain@gmail.com) */ public class M4AReader implements IoConstants, ITagReader { /** * Logger */ private static Logger log = LoggerFactory.getLogger(M4AReader.class); /** * File */ private File file; /** * Input stream */ private MP4DataStream fis; /** * File channel */ private FileChannel channel; /** * Memory-mapped buffer for file content */ private MappedByteBuffer mappedFile; /** * Input byte buffer */ private IoBuffer in; private String audioCodecId = "mp4a"; //decoder bytes / configs private byte[] audioDecoderBytes; /** Duration in milliseconds. */ private long duration; private int timeScale; //audio sample rate kHz private double audioTimeScale; private int audioChannels; //default to aac lc private int audioCodecType = 1; private String formattedDuration; private long moovOffset; private long mdatOffset; //samples to chunk mappings private Vector<MP4Atom.Record> audioSamplesToChunks; //samples private Vector<Integer> audioSamples; //chunk offsets private Vector<Long> audioChunkOffsets; //sample duration private int audioSampleDuration = 1024; //keep track of current sample private int currentFrame = 1; private int prevFrameSize = 0; private List<MP4Frame> frames = new ArrayList<MP4Frame>(); /** * Container for metadata and any other tags that should * be sent prior to media data. */ private LinkedList<ITag> firstTags = new LinkedList<ITag>(); private final Semaphore lock = new Semaphore(1, true); /** Constructs a new M4AReader. */ M4AReader() { } /** * Creates M4A reader from file input stream, sets up metadata generation flag. * * @param f File input stream */ public M4AReader(File f) throws IOException { if (null == f) { log.warn("Reader was passed a null file"); log.debug("{}", ToStringBuilder.reflectionToString(this)); } this.file = f; this.fis = new MP4DataStream(new FileInputStream(f)); channel = fis.getChannel(); try { mappedFile = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size()); } catch (IOException e) { log.error("M4AReader {}", e); } // Wrap mapped byte buffer to MINA buffer in = IoBuffer.wrap(mappedFile); //decode all the info that we want from the atoms decodeHeader(); //analyze the samples/chunks and build the keyframe meta data analyzeFrames(); //add meta data firstTags.add(createFileMeta()); //create / add the pre-streaming (decoder config) tags createPreStreamingTags(); } /** * Accepts mapped file bytes to construct internal members. * * @param buffer Byte buffer */ public M4AReader(IoBuffer buffer) throws IOException { in = buffer; //decode all the info that we want from the atoms decodeHeader(); //analyze the samples/chunks and build the keyframe meta data analyzeFrames(); //add meta data firstTags.add(createFileMeta()); //create / add the pre-streaming (decoder config) tags createPreStreamingTags(); } /** * This handles the moov atom being at the beginning or end of the file, so the mdat may also * be before or after the moov atom. */ public void decodeHeader() { try { // the first atom will/should be the type MP4Atom type = MP4Atom.createAtom(fis); // expect ftyp log.debug("Type {}", MP4Atom.intToType(type.getType())); //log.debug("Atom int types - free={} wide={}", MP4Atom.typeToInt("free"), MP4Atom.typeToInt("wide")); // keep a running count of the number of atoms found at the "top" levels int topAtoms = 0; // we want a moov and an mdat, anything else throw the invalid file type error while (topAtoms < 2) { MP4Atom atom = MP4Atom.createAtom(fis); switch (atom.getType()) { case 1836019574: //moov topAtoms++; MP4Atom moov = atom; // expect moov log.debug("Type {}", MP4Atom.intToType(moov.getType())); log.debug("moov children: {}", moov.getChildren()); moovOffset = fis.getOffset() - moov.getSize(); MP4Atom mvhd = moov.lookup(MP4Atom.typeToInt("mvhd"), 0); if (mvhd != null) { log.debug("Movie header atom found"); //get the initial timescale timeScale = mvhd.getTimeScale(); duration = mvhd.getDuration(); log.debug("Time scale {} Duration {}", timeScale, duration); } /* nothing needed here yet MP4Atom meta = moov.lookup(MP4Atom.typeToInt("meta"), 0); if (meta != null) { log.debug("Meta atom found"); log.debug("{}", ToStringBuilder.reflectionToString(meta)); } */ MP4Atom trak = moov.lookup(MP4Atom.typeToInt("trak"), 0); if (trak != null) { log.debug("Track atom found"); log.debug("trak children: {}", trak.getChildren()); // trak: tkhd, edts, mdia MP4Atom edts = trak.lookup(MP4Atom.typeToInt("edts"), 0); if (edts != null) { log.debug("Edit atom found"); log.debug("edts children: {}", edts.getChildren()); } MP4Atom mdia = trak.lookup(MP4Atom.typeToInt("mdia"), 0); if (mdia != null) { log.debug("Media atom found"); // mdia: mdhd, hdlr, minf int scale = 0; //get the media header atom MP4Atom mdhd = mdia.lookup(MP4Atom.typeToInt("mdhd"), 0); if (mdhd != null) { log.debug("Media data header atom found"); //this will be for either video or audio depending media info scale = mdhd.getTimeScale(); log.debug("Time scale {}", scale); } MP4Atom hdlr = mdia.lookup(MP4Atom.typeToInt("hdlr"), 0); if (hdlr != null) { log.debug("Handler ref atom found"); // soun or vide log.debug("Handler type: {}", MP4Atom.intToType(hdlr.getHandlerType())); String hdlrType = MP4Atom.intToType(hdlr.getHandlerType()); if ("soun".equals(hdlrType)) { if (scale > 0) { audioTimeScale = scale * 1.0; log.debug("Audio time scale: {}", audioTimeScale); } } } MP4Atom minf = mdia.lookup(MP4Atom.typeToInt("minf"), 0); if (minf != null) { log.debug("Media info atom found"); // minf: (audio) smhd, dinf, stbl / (video) vmhd, // dinf, stbl MP4Atom smhd = minf.lookup(MP4Atom.typeToInt("smhd"), 0); if (smhd != null) { log.debug("Sound header atom found"); MP4Atom dinf = minf.lookup(MP4Atom.typeToInt("dinf"), 0); if (dinf != null) { log.debug("Data info atom found"); // dinf: dref log.debug("Sound dinf children: {}", dinf.getChildren()); MP4Atom dref = dinf.lookup(MP4Atom.typeToInt("dref"), 0); if (dref != null) { log.debug("Data reference atom found"); } } MP4Atom stbl = minf.lookup(MP4Atom.typeToInt("stbl"), 0); if (stbl != null) { log.debug("Sample table atom found"); // stbl: stsd, stts, stss, stsc, stsz, stco, // stsh log.debug("Sound stbl children: {}", stbl.getChildren()); // stsd - sample description // stts - time to sample // stsc - sample to chunk // stsz - sample size // stco - chunk offset //stsd - has codec child MP4Atom stsd = stbl.lookup(MP4Atom.typeToInt("stsd"), 0); if (stsd != null) { //stsd: mp4a log.debug("Sample description atom found"); MP4Atom mp4a = stsd.getChildren().get(0); //could set the audio codec here setAudioCodecId(MP4Atom.intToType(mp4a.getType())); //log.debug("{}", ToStringBuilder.reflectionToString(mp4a)); log.debug("Sample size: {}", mp4a.getSampleSize()); int ats = mp4a.getTimeScale(); //skip invalid audio time scale if (ats > 0) { audioTimeScale = ats * 1.0; } audioChannels = mp4a.getChannelCount(); log.debug("Sample rate (audio time scale): {}", audioTimeScale); log.debug("Channels: {}", audioChannels); //mp4a: esds if (mp4a.getChildren().size() > 0) { log.debug("Elementary stream descriptor atom found"); MP4Atom esds = mp4a.getChildren().get(0); log.debug("{}", ToStringBuilder.reflectionToString(esds)); MP4Descriptor descriptor = esds.getEsd_descriptor(); log.debug("{}", ToStringBuilder.reflectionToString(descriptor)); if (descriptor != null) { Vector<MP4Descriptor> children = descriptor.getChildren(); for (int e = 0; e < children.size(); e++) { MP4Descriptor descr = children.get(e); log.debug("{}", ToStringBuilder.reflectionToString(descr)); if (descr.getChildren().size() > 0) { Vector<MP4Descriptor> children2 = descr.getChildren(); for (int e2 = 0; e2 < children2.size(); e2++) { MP4Descriptor descr2 = children2.get(e2); log.debug("{}", ToStringBuilder.reflectionToString(descr2)); if (descr2.getType() == MP4Descriptor.MP4DecSpecificInfoDescriptorTag) { //we only want the MP4DecSpecificInfoDescriptorTag audioDecoderBytes = descr2.getDSID(); //compare the bytes to get the aacaot/aottype //match first byte switch (audioDecoderBytes[0]) { case 0x12: default: //AAC LC - 12 10 audioCodecType = 1; break; case 0x0a: //AAC Main - 0A 10 audioCodecType = 0; break; case 0x11: case 0x13: //AAC LC SBR - 11 90 & 13 xx audioCodecType = 2; break; } //we want to break out of top level for loop e = 99; break; } } } } } } } //stsc - has Records MP4Atom stsc = stbl.lookup(MP4Atom.typeToInt("stsc"), 0); if (stsc != null) { log.debug("Sample to chunk atom found"); audioSamplesToChunks = stsc.getRecords(); log.debug("Record count: {}", audioSamplesToChunks.size()); MP4Atom.Record rec = audioSamplesToChunks.firstElement(); log.debug("Record data: Description index={} Samples per chunk={}", rec.getSampleDescriptionIndex(), rec.getSamplesPerChunk()); } //stsz - has Samples MP4Atom stsz = stbl.lookup(MP4Atom.typeToInt("stsz"), 0); if (stsz != null) { log.debug("Sample size atom found"); audioSamples = stsz.getSamples(); //vector full of integers log.debug("Sample size: {}", stsz.getSampleSize()); log.debug("Sample count: {}", audioSamples.size()); } //stco - has Chunks MP4Atom stco = stbl.lookup(MP4Atom.typeToInt("stco"), 0); if (stco != null) { log.debug("Chunk offset atom found"); //vector full of integers audioChunkOffsets = stco.getChunks(); log.debug("Chunk count: {}", audioChunkOffsets.size()); } //stts - has TimeSampleRecords MP4Atom stts = stbl.lookup(MP4Atom.typeToInt("stts"), 0); if (stts != null) { log.debug("Time to sample atom found"); Vector<MP4Atom.TimeSampleRecord> records = stts.getTimeToSamplesRecords(); log.debug("Record count: {}", records.size()); MP4Atom.TimeSampleRecord rec = records.firstElement(); log.debug("Record data: Consecutive samples={} Duration={}", rec.getConsecutiveSamples(), rec.getSampleDuration()); //if we have 1 record then all samples have the same duration if (records.size() > 1) { //TODO: handle audio samples with varying durations log.warn("Audio samples have differing durations, audio playback may fail"); } audioSampleDuration = rec.getSampleDuration(); } } } } } } //real duration StringBuilder sb = new StringBuilder(); double clipTime = ((double) duration / (double) timeScale); log.debug("Clip time: {}", clipTime); int minutes = (int) (clipTime / 60); if (minutes > 0) { sb.append(minutes); sb.append('.'); } //formatter for seconds / millis NumberFormat df = DecimalFormat.getInstance(); df.setMaximumFractionDigits(2); sb.append(df.format((clipTime % 60))); formattedDuration = sb.toString(); log.debug("Time: {}", formattedDuration); break; case 1835295092: //mdat topAtoms++; long dataSize = 0L; MP4Atom mdat = atom; dataSize = mdat.getSize(); log.debug("{}", ToStringBuilder.reflectionToString(mdat)); mdatOffset = fis.getOffset() - dataSize; log.debug("File size: {} mdat size: {}", file.length(), dataSize); break; case 1718773093: //free case 2003395685: //wide break; default: log.warn("Unexpected atom: {}", MP4Atom.intToType(atom.getType())); } } //add the tag name (size) to the offsets moovOffset += 8; mdatOffset += 8; log.debug("Offsets moov: {} mdat: {}", moovOffset, mdatOffset); } catch (IOException e) { log.error("Exception decoding header / atoms", e); } } public long getTotalBytes() { try { return channel.size(); } catch (Exception e) { log.error("Error getTotalBytes", e); return 0; } } /** {@inheritDoc} */ public boolean hasVideo() { return false; } /** * Returns the file buffer. * * @return File contents as byte buffer */ public IoBuffer getFileData() { return null; } /** {@inheritDoc} */ public IStreamableFile getFile() { // TODO wondering if we need to have a reference return null; } /** {@inheritDoc} */ public int getOffset() { // XXX what's the difference from getBytesRead return 0; } /** {@inheritDoc} */ public long getBytesRead() { return in.position(); } /** {@inheritDoc} */ public long getDuration() { return duration; } public String getAudioCodecId() { return audioCodecId; } /** {@inheritDoc} */ public boolean hasMoreTags() { return currentFrame < frames.size(); } /** * Create tag for metadata event. * * @return Metadata event tag */ ITag createFileMeta() { log.debug("Creating onMetaData"); // Create tag for onMetaData event IoBuffer buf = IoBuffer.allocate(1024); buf.setAutoExpand(true); Output out = new Output(buf); out.writeString("onMetaData"); Map<Object, Object> props = new HashMap<Object, Object>(); // Duration property props.put("duration", ((double) duration / (double) timeScale)); // Audio codec id - watch for mp3 instead of aac props.put("audiocodecid", audioCodecId); props.put("aacaot", audioCodecType); props.put("audiosamplerate", audioTimeScale); props.put("audiochannels", audioChannels); props.put("moovposition", moovOffset); //tags will only appear if there is an "ilst" atom in the file //props.put("tags", ""); props.put("canSeekToEnd", false); out.writeMap(props, new Serializer()); buf.flip(); //now that all the meta properties are done, update the duration duration = Math.round(duration * 1000d); ITag result = new Tag(IoConstants.TYPE_METADATA, 0, buf.limit(), null, 0); result.setBody(buf); return result; } /** * Tag sequence * MetaData, Audio config, remaining audio * * Packet prefixes: * af 00 ... 06 = Audio extra data (first audio packet) * af 01 = Audio frame * * Audio extra data(s): * af 00 = Prefix * 11 90 4f 14 = AAC Main = aottype 0 * 12 10 = AAC LC = aottype 1 * 13 90 56 e5 a5 48 00 = HE-AAC SBR = aottype 2 * 06 = Suffix * * Still not absolutely certain about this order or the bytes - need to verify later */ private void createPreStreamingTags() { log.debug("Creating pre-streaming tags"); if (audioDecoderBytes != null) { IoBuffer body = IoBuffer.allocate(audioDecoderBytes.length + 3); body.put(new byte[] { (byte) 0xaf, (byte) 0 }); //prefix if (log.isDebugEnabled()) { log.debug("Audio decoder bytes: {}", HexDump.byteArrayToHexString(audioDecoderBytes)); } body.put(audioDecoderBytes); body.put((byte) 0x06); //suffix ITag tag = new Tag(IoConstants.TYPE_AUDIO, 0, body.position(), null, prevFrameSize); body.flip(); tag.setBody(body); //add tag firstTags.add(tag); } else { //default to aac-lc when the esds doesnt contain descripter bytes log.warn("Audio decoder bytes were not available"); } } /** * Packages media data for return to providers. * */ public ITag readTag() { //log.debug("Read tag"); ITag tag = null; try { lock.acquire(); //empty-out the pre-streaming tags first if (!firstTags.isEmpty()) { log.debug("Returning pre-tag"); // Return first tags before media data return firstTags.removeFirst(); } //log.debug("Read tag - sample {} prevFrameSize {} audio: {} video: {}", new Object[]{currentSample, prevFrameSize, audioCount, videoCount}); //get the current frame MP4Frame frame = frames.get(currentFrame); log.debug("Playback {}", frame); int sampleSize = frame.getSize(); int time = (int) Math.round(frame.getTime() * 1000.0); //log.debug("Read tag - dst: {} base: {} time: {}", new Object[]{frameTs, baseTs, time}); long samplePos = frame.getOffset(); //log.debug("Read tag - samplePos {}", samplePos); //determine frame type and packet body padding byte type = frame.getType(); //create a byte buffer of the size of the sample ByteBuffer data = ByteBuffer.allocate(sampleSize + 2); try { //log.debug("Writing audio prefix"); data.put(MP4Reader.PREFIX_AUDIO_FRAME); //do we need to add the mdat offset to the sample position? channel.position(samplePos); channel.read(data); } catch (IOException e) { log.error("Error on channel position / read", e); } //chunk the data IoBuffer payload = IoBuffer.wrap(data.array()); //create the tag tag = new Tag(type, time, payload.limit(), payload, prevFrameSize); //log.debug("Read tag - type: {} body size: {}", (type == TYPE_AUDIO ? "Audio" : "Video"), tag.getBodySize()); //increment the sample number currentFrame++; //set the frame / tag size prevFrameSize = tag.getBodySize(); } catch (InterruptedException e) { log.warn("Exception acquiring lock", e); } finally { lock.release(); } //log.debug("Tag: {}", tag); return tag; } /** * Performs frame analysis and generates metadata for use in seeking. All the frames * are analyzed and sorted together based on time and offset. */ public void analyzeFrames() { log.debug("Analyzing frames"); // tag == sample int sample = 1; Long pos = null; //add the audio frames / samples / chunks for (int i = 0; i < audioSamplesToChunks.size(); i++) { MP4Atom.Record record = audioSamplesToChunks.get(i); int firstChunk = record.getFirstChunk(); int lastChunk = audioChunkOffsets.size(); if (i < audioSamplesToChunks.size() - 1) { MP4Atom.Record nextRecord = audioSamplesToChunks.get(i + 1); lastChunk = nextRecord.getFirstChunk() - 1; } for (int chunk = firstChunk; chunk <= lastChunk; chunk++) { int sampleCount = record.getSamplesPerChunk(); pos = audioChunkOffsets.elementAt(chunk - 1); while (sampleCount > 0) { //calculate ts double ts = (audioSampleDuration * (sample - 1)) / audioTimeScale; //sample size int size = (audioSamples.get(sample - 1)).intValue(); //create a frame MP4Frame frame = new MP4Frame(); frame.setOffset(pos); frame.setSize(size); frame.setTime(ts); frame.setType(TYPE_AUDIO); frames.add(frame); log.debug("Sample #{} {}", sample, frame); //inc and dec stuff pos += size; sampleCount--; sample++; } } } //sort the frames Collections.sort(frames); log.debug("Frames count: {}", frames.size()); //log.debug("Frames: {}", frames); } /** * Put the current position to pos. The caller must ensure the pos is a valid one. * * @param pos position to move to in file / channel */ public void position(long pos) { log.debug("position: {}", pos); currentFrame = getFrame(pos); log.debug("Setting current sample: {}", currentFrame); } /** * Search through the frames by offset / position to find the sample. * * @param pos * @return */ private int getFrame(long pos) { int sample = 1; int len = frames.size(); MP4Frame frame = null; for (int f = 0; f < len; f++) { frame = frames.get(f); if (pos == frame.getOffset()) { sample = f; break; } } return sample; } /** {@inheritDoc} */ public void close() { log.debug("Close"); if (in != null) { in.free(); in = null; } if (channel != null) { try { channel.close(); fis.close(); fis = null; } catch (IOException e) { log.error("Channel close {}", e); } finally { if (frames != null) { frames.clear(); frames = null; } } } } public void setAudioCodecId(String audioCodecId) { this.audioCodecId = audioCodecId; } public ITag readTagHeader() { return null; } }