/*
 *  FSSoundConstructor.java
 *  Transform Utilities
 *
 * Copyright (c) 2001-2008 Flagstone Software Ltd. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification, 
 * are permitted provided that the following conditions are met:
 *
 *  * Redistributions of source code must retain the above copyright notice, this
 *    list of conditions and the following disclaimer.
 *  * Redistributions in binary form must reproduce the above copyright notice, 
 *    this list of conditions and the following disclaimer in the documentation 
 *    and/or other materials provided with the distribution.
 *  * Neither the name of Flagstone Software Ltd. nor the names of its contributors 
 *    may be used to endorse or promote products derived from this software 
 *    without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 
 * IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 
 * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 
 * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 
 * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 
 * OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package com.flagstone.transform.util;

import java.io.*;
import java.util.zip.*;
import com.flagstone.transform.*;

/** 
 * The FSSoundConstructor class is used to generate the objects used to define and 
 * control the sounds that are played in a Flash movie. The FSSoundConstructor can 
 * be used to generate definitions for:
 *
 * <ul>
 * <li>Event sounds that are played in response to a particular event such as a button 
 * being clicked.</li>
 * <li>Streaming sound that is played as movie is being displayed.</li>
 * </ul>
 *
 * The FSSoundConstructor contains constructors and methods that allow sound data to 
 * be loaded from an external file simplifying the process of adding sound to a movie. 
 * The FSSoundConstructor currently supports uncompressed PCM format (with either 
 * big-endian or little-endian byte ordering) and MP3 format files.
 *
 * Once loaded, the PCM sound data should be compressed to ADPCM format as the relatively 
 * large sizes make PCM coded sounds generally unsuitable for inclusion in a Flash movie. 
 * ADPCM is a compressed format and Flash supports a modified ADPCM algorithm with 
 * compressed samples taking 2, 3, 4 or 5 bits. This results in much smaller file sizes 
 * when a movie is encoded. Code, developed at Centre for Mathematics and Computer Science,
 * Amsterdam, The Netherlands, is available on Flagstone's web site to perform the 
 * ADPCM compression.
 * 
 * For sounds containing more than one channel, the sound levels for each channel are 
 * interleaved for each sample. For example a stereo sound the order of the samples and 
 * channels levels are:
 *
 *<pre>
 * Sample       0          1          2
 *          +---+---+  +---+---+  +---+---+
 *          | 1 | 2 |  | 1 | 2 |  | 1 | 2 | ....
 *          +---+---+  +---+---+  +---+---+
 *</pre>
 *
 * NOTE: The byte order for the PCM data in WAVE sound files may vary according to the 
 * platform on which the sound file was created. The FSSoundConstructor currently only
 * supports WAVE files with little-endian byte order.
 *
 * <b>Examples</b>
 *
 * The following code samples illustrate how to use the FSSoundConstructor class to add 
 * sounds to a Flash file.
 *
 * 1. Playing an uncompressed WAVE file.\n
 *
 *<pre>
 * int soundId = movie.newIdentifier();
 *
 * // Generate an FSDefineSound object using the attributes defined in the wave file.
 * // An FSSound object is used to instruct the Flash Player to start playing a sound.
 *
 * FSSoundConstructor soundGenerator = new FSSoundGenerator("sound.wav");
 *
 * movie.add(soundGenerator.defineSound(soundId));
 * movie.add(new FSSound(soundId, FSSound.Start));
 *</pre>
 *
 * 2. Streaming Sounds.\n
 *
 * Larger sound files may be streamed to the Flash Player - splitting the sound data into a 
 * sequence of blocks which is synchronised with the frames as they are displayed. Typically 
 * block of sound data is generated for each frame displayed.
 *
 *<pre>
 * int framesPerSecond = 12;
 *
 * FSSoundConstructor soundGenerator = new FSSoundGenerator("soundTrack.wav");
 *   
 * // Calculate the number of decoded sound samples played for each frame
 *
 * int samplesPerBlock = soundGenerator.getSampleRate() / framesPerSecond;
 * int numberOfBlocks = soundGenerator.getSamplesPerChannel() / samplesPerBlock;
 *
 * // An FSSoundStreamHeader2 object defines the attributes of the streaming sound.
 * 
 * movie.add(soundGenerator.streamHeader(samplesPerBlock));
 *
 * // Add a streaming block for each frame so the sound is played as each frame is displayed.
 *
 * for (int i=0; i&lt;numberOfBlocks; i++)
 * {
 *     movie.add(soundGenerator.streamBlock(i, samplesPerBlock));
 *     movie.add(new FSShowFrame());
 * }
 * </pre>
 *
 */
public class FSSoundConstructor
{
    private static final int[] riffSignature = { 82, 73, 70, 70 };
    private static final int[] wavSignature = { 87, 65, 86, 69 };

    private static final int FMT = 0x20746d66;
    private static final int DATA = 0x61746164;

    private static final int MPEG1 = 3;
    
    private static final int frameSizeMP3[] = { 576, 576, 576, 1152 };
    private static final int channelCount[] = { 2, 2, 2, 1 };

    private static final int bitRates[][] =
    {
        { -1,  8, 16, 24, 32, 40, 48, 56,  64,  80,  96, 112, 128, 144, 160, -1 }, // MPEG 2.5
        { -1, -1, -1, -1, -1, -1, -1, -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, -1 }, // Reserved
        { -1,  8, 16, 24, 32, 40, 48, 56,  64,  80,  96, 112, 128, 144, 160, -1 }, // MPEG 2.0
        { -1, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, -1 }, // MPEG 1.0
    };

    private static final int samplingRates[][] =
    {
        { 11025, -1, -1, -1 },
        {    -1, -1, -1, -1 },
        { 22050, -1, -1, -1 },
        { 44100, -1, -1, -1 }
    };
    
    private int format = FSSound.PCM;
    private int numberOfChannels = 0;
    private int samplesPerChannel = 0;
    private int sampleRate = 0;
    private int sampleSize = 0;

    private byte[] sound = null;
    
    private int[][] frameTable = null;
    private int samplesPerFrame = 0;

    /**
     * Creates a new uninitialized FSSoundConstructor object.  Use setSound to 
     * provide the content.
     */
    public FSSoundConstructor() 
    {
    }
    /** 
     * Creates a new FSSoundConstructor object initialised with the contents of 
     * the specified sound file.
     *
     * @param fileName the name of the file containing the sound.
     * 
     * @throws FileNotFoundException - if the file does not exist, is a 
     * directory rather than a regular file, or for some other reason cannot be 
     * opened for reading.
     * 
     * @throws IOException - if an I/O error occurs while reading the file.
     * 
     * @throws DataFormatException if the file contains a sound format not 
     * supported by the FSSoundConstructor.
     */
    public FSSoundConstructor(String fileName) throws IOException, DataFormatException
    {
        setSound(fileName);
    }
    /** 
     * Accessor method returning the format for the encoded sound data.
     *
     * @return the format of the encoded sound, either FSSound.NATIVE_PCM (platform
     * dependent byte-order), FSSound.PCM (PCM little-endian byte-order), FSSound.ADPCM or
     * FSSound.MP3.
     */
    public int getFormat()
    {
        return format;
    }

    /** 
     * Accessor method returning the number of channels in the sound.
     * 
     * @return the number of sound channels, typically this will be 1 (mono) or 2 (stereo).
     */
    public int getNumberOfChannels()
    {
        return numberOfChannels;
    }

    /** 
     * Accessor method returning the number of samples in each channel.
     *
     * @return the number of samples in each channel.
     */
    public int getSamplesPerChannel()
    {
        return samplesPerChannel;
    }

    /** 
     * Accessor method returning the rate at which the sound will be played. The playback 
     * rate should be a value supported by the Flash Player 5512, 11025, 22050 or 44100 
     * Hertz. Although the sound rate may be changed using the changeSampleRate method 
     * the FSSoundConstructor class does not support the filtering required to change 
     * the sample rate from an arbitrary value to one of the rates supported by the Flash 
     * Player.
     *
     * MP3 formatted sounds have playback rates of 11025, 22050 or 44100.
     *
     * @return the playback rate for the sound, either 5512, 11025, 22050 or 44100 Hertz.
     */
    public int getSampleRate()
    {
        return sampleRate;
    }

    /** 
     * Accessor method returning the number of bytes for each decoded sound sample.
     * 
     * @return the number of bytes in each sample.
     */
    public int getSampleSize()
    {
        return sampleSize;
    }

    /** 
     * Accessor method returning a copy of the encoded sound data.
     * 
     * @return encoded sound data.
     */
    public byte[] getSound()
    {
        byte[] bytes = new byte[sound.length];
        
        System.arraycopy(sound, 0, bytes, 0, sound.length);

        return bytes;
    }

    /** 
     * Initialises the FSSoundConstructor with the contents of the specified file.
     * 
     * @param filename the name of a file containing encoded sound data.
     * 
     * @throws FileNotFoundException - if the file does not exist, is a directory 
     * rather than a regular file, or for some other reason cannot be opened for 
     * reading.
     * 
     * @throws IOException - if an I/O error occurs while reading the file.
     * 
     * @throws DataFormatException if the file contains a sound format not 
     * supported by the FSSoundConstructor.
     */
    public void setSound(String filename) throws IOException, DataFormatException
    {
        if (filename.toLowerCase().endsWith(".mp3"))
            decodeMP3(dataFromFile(filename));
        else if (filename.toLowerCase().endsWith(".wav"))
            decodeWAV(filename);
    }
    /** 
     * Initialises the FSSoundConstructor with the sound data and set of 
     * parameters. This method can be used to update the sound following 
     * processing for example to convert a stereo sound to mono to reduce the 
     * size of the encoded data.
     * 
     * @param format the format of the encoded sound either FSSound.PCM, 
     * FSSound.ADPCM or FSSound.MP3.
     * 
     * @param channelCount the number of sound channels, 1 = mono, 2 = stereo.
     * @param sampleCount the number of samples in each channel.
     * 
     * @param sampleRate the rate at which the sound is played in kiloHertz. 
     * Flash supports 5512, 11025, 22050 or 44100.
     * 
     * @param sampleSize the number of bytes for each uncompressed sound sample, 
     * either 1 or 2.
     * 
     * @param bytes an array of sound samples encoding in the specified format.
     */
    public void setSound(int format, int channelCount, int sampleCount, int sampleRate, int sampleSize, byte[] bytes)
    {
        this.format = format;
        this.numberOfChannels = channelCount;
        this.samplesPerChannel = sampleCount;
        this.sampleRate = sampleRate;
        this.sampleSize = sampleSize;

        sound = new byte[bytes.length];
        System.arraycopy(bytes, 0, sound, 0, bytes.length);
        
        if (format == FSSound.MP3)
            initFrameTable(bytes);
    }

    /** 
     * Generates an FSDefineSound object from the sound data. The FSDefineSound object is 
     * created using the encoded sound data so if the sound was decoded in order to change 
     * the number of channels, sample rate or sample size it must be re-encoded.
     * 
     * @param anIdentifier a unique identifier for the FSDefineSound object.
     * 
     * @return an FSDefineSound object initialised with the encoded sound data.
     */
    public FSDefineSound defineSound(int anIdentifier)
    {
        byte[] bytes = null;
        
        switch (format)
        {
            case FSSound.PCM:
            case FSSound.ADPCM:
                bytes = new byte[sound.length];
                System.arraycopy(sound, 0, bytes, 0, sound.length);
                break;
            case FSSound.MP3:
                bytes = new byte[2+sound.length];
                bytes[0] = 0;
                bytes[1] = 0;
                System.arraycopy(sound, 0, bytes, 2, sound.length);
                break;
        }
        return new FSDefineSound(anIdentifier, format, sampleRate, numberOfChannels, sampleSize, samplesPerChannel, bytes);
    }

    /** 
     * Generates an FSSoundStreamHead2 object to stream the sound data to the Flash Player.
     * 
     * @param samplesPerBlock the number of samples in each subsequent FSSoundStreamBlock 
     * object.
     * 
     * @return an FSDefineSound object initialised with the encoded sound data.
     */
    public FSSoundStreamHead2 streamHeader(int samplesPerBlock)
    {
       return new FSSoundStreamHead2(format, sampleRate, numberOfChannels, sampleSize, sampleRate, numberOfChannels, sampleSize, samplesPerBlock, 0);
   }

    /** 
     * Generates an FSSoundStreamBlock object containing a block sound data. This method is 
     * used to divide the encoded sound into a sequence of blocks to allow a sound to be 
     * streamed to the Flash Player.
     *
     * If the number of samples in the last block to stream is less than the samplesPerBlock,
     * the size of the FSSoundStreamBlock object is reduced accordingly. It is not padded 
     * to maintain the specified length.
     * 
     * @param blockNumber the nth block of samples.
     * 
     * @param samplesPerBlock the number of samples to stream.
     * 
     * @return an FSSoundStreamBlock object containing a block of bytes from the encoded 
     * sound.
     */
    public FSSoundStreamBlock streamBlock(int blockNumber, int samplesPerBlock)
    {
        int firstSample = 0;
        int firstSampleOffset = 0;
        int bytesPerBlock = 0;
        int bytesRemaining = 0;
        int numberOfBytes = 0;
        
        int framesToSend = 0;
        int framesSent = 0;
        int frameCount = 0;
        int sampleCount = 0;
        int seek = 0;
        
        byte[] bytes = null;

        switch (format)
        {
            case FSSound.PCM:
                firstSample = blockNumber * samplesPerBlock;
                firstSampleOffset = firstSample * sampleSize * numberOfChannels;
                bytesPerBlock = samplesPerBlock * sampleSize * numberOfChannels;
                bytesRemaining = sound.length - firstSampleOffset;
                
                numberOfBytes = (bytesRemaining < bytesPerBlock) ? bytesRemaining : bytesPerBlock;
            
                bytes = new byte[numberOfBytes];
                System.arraycopy(sound, firstSampleOffset, bytes, 0, numberOfBytes);
                break;
            case FSSound.ADPCM:
                break;
            case FSSound.MP3:
                framesToSend = ((blockNumber+1) * samplesPerBlock) / samplesPerFrame;
                framesSent = (blockNumber * samplesPerBlock) / samplesPerFrame;
                frameCount = framesToSend - framesSent;
                sampleCount = frameCount * samplesPerFrame;
                seek = (blockNumber * samplesPerBlock) - (framesSent * samplesPerFrame);
            
                numberOfBytes = 4;
                
                for (int i=0, j=framesSent; i<frameCount; i++, j++)
                     numberOfBytes += frameTable[j][1];
                
                bytes = new byte[numberOfBytes];
            
                bytes[0] = (byte)sampleCount;
                bytes[1] = (byte)(sampleCount >> 8);
                bytes[2] = (byte)seek;
                bytes[3] = (byte)(seek >> 8);
                
                int offset = 4; 
                
                for (int i=0, j=framesSent; i<frameCount; i++, j++)
                {
                    System.arraycopy(sound, frameTable[j][0], bytes, offset, frameTable[j][1]);
                    offset += frameTable[j][1];
                }
                break;
        }
        
        if (bytes != null)
            return new FSSoundStreamBlock(bytes);
        else
            return null;
    }

    private void decodeWAV(String filename) throws FileNotFoundException, IOException, DataFormatException
    {
        FSCoder coder = new FSCoder(FSCoder.LITTLE_ENDIAN, dataFromFile(filename));
        
        int length = 0;
        int chunkType = 0;
        boolean moreChunks = true;

        for (int i=0; i<4; i++)
        {
            if (coder.readWord(1, false) != riffSignature[i])
                throw new DataFormatException("Not a valid RIFF file");
        }
        
        coder.readWord(4, false);
        
        for (int i=0; i<4; i++)
        {
            if (coder.readWord(1, false) != wavSignature[i])
                throw new DataFormatException("Not a valid WAV file");
        }
       
        while (moreChunks)
        {
            chunkType = coder.readWord(4, false);
            length = coder.readWord(4, false);
            
            int blockStart = coder.getPointer();
            
            switch (chunkType)
            {
                case FMT: 
                	decodeFMT(coder); 
                	break;
                case DATA: 
                	decodeDATA(coder, length); 
                	break;
                default: 
                	coder.adjustPointer(length << 3); 
                	break;
            }

            int nextBlock = blockStart + (length << 3);

/*
            int bytesRead = (coder.getPointer() - blockStart) >> 3;
            
            if (bytesRead < length)
                System.err.println(chunkType + " chunk underflowed by " + (length - bytesRead) + " bytes.");
            else if (bytesRead > length)
                System.err.println(chunkType + " chunk overflowed by " + (bytesRead - length) + " bytes.");
 */
            coder.setPointer(nextBlock);
            moreChunks = coder.eof() == false;
        }
    }

    private void decodeFMT(FSCoder coder) throws DataFormatException
    {
        format = FSSound.PCM;
        
        if (coder.readWord(2, false) != 1)
            throw new DataFormatException("Compressed WAV files are not currently supported.");
        
        numberOfChannels = coder.readWord(2, false);
        sampleRate = coder.readWord(4, false);
        coder.readWord(4, false); // total data length
        coder.readWord(2, false); // total bytes per sample
        sampleSize = coder.readWord(2, false) / 8;
    }
    
    private void decodeDATA(FSCoder coder, int length)
    {
        samplesPerChannel = length / (sampleSize*numberOfChannels);

        sound = new byte[length];
        coder.readBytes(sound);
    }

    private void decodeMP3(byte[] bytes) throws DataFormatException
    {
        FSCoder coder = new FSCoder(FSCoder.BIG_ENDIAN, bytes);
        
        int numberOfFrames = 0;
        int frameStart = 0;

        format = FSSound.MP3;
        sampleSize = 2;
        
        while (coder.eof() == false)
        {        
            if (coder.scanWord(3, false) == 0x494433) // ID3
            {
                coder.adjustPointer(24); // ID3 signature                
                coder.adjustPointer(8); // version number
                coder.adjustPointer(8); // revision number
                
                coder.adjustPointer(1); // unsynchronized
                coder.adjustPointer(1); // extendedHeader
                coder.adjustPointer(1); // experimental
                int hasFooter = coder.readBits(1, false);
                
                coder.adjustPointer(4);
                
                int totalLength = (hasFooter == 1) ? 10 : 0;

                totalLength += coder.readWord(1, false) << 21;
                totalLength += coder.readWord(1, false) << 14;
                totalLength += coder.readWord(1, false) << 7;
                totalLength += coder.readWord(1, false);

                coder.adjustPointer(totalLength<<3);
            }
            else if (coder.scanWord(3, false) == 0x544147) // ID3 V1
            {
                coder.adjustPointer(128<<3);
            }
            else if (coder.scanBits(11, false) == 0x7FF) // MP3 frame
            {
                if (numberOfFrames == 0)
                    frameStart = coder.getPointer();
                
                coder.adjustPointer(MP3FrameSize(coder) << 3);
                numberOfFrames++;
            }
            else
            {
                /*
                 * If we get here it means we jumped into the middle of either 
                 * a frame or tag information. This appears to be a common
                 * occurrence. Goto the end of the file so we can keep the 
                 * frames found so far.
                 */
                coder.setPointer(bytes.length<<3);
            }
        }

        int dataLength = bytes.length - (frameStart >> 3);
        
        sound = new byte[dataLength];
        
        System.arraycopy(bytes, frameStart>>3, sound, 0, dataLength);
        
        frameTable = new int[numberOfFrames][2];
        
        for (int i=0; i<numberOfFrames; i++)
        {
            frameTable[i][0] = -1;
            frameTable[i][1] = 0;
        }

        coder.setPointer(frameStart);
        
        int frameNumber = 0;
        
        while (coder.findBits(0x7FF, 11, 8))
        {
            frameTable[frameNumber][0] = (coder.getPointer()- frameStart) >> 3;
            
            coder.adjustPointer(11); // skip start of frame marker

            int version = coder.readBits(2, false);
            
            samplesPerFrame = frameSizeMP3[version];
            
            if (coder.readBits(2, false) != 1)
                throw new DataFormatException("Flash only supports MPEG Layer 3");
                
            coder.readBits(1, false); // crc follows header
            
            int bitRate = bitRates[version][coder.readBits(4, false)];
            
            if (bitRate == -1)
                throw new DataFormatException("Unsupported Bit-rate");
            
            sampleRate = samplingRates[version][coder.readBits(2, false)];
            
            if (sampleRate == -1)
                throw new DataFormatException("Unsupported Sampling-rate");
            
            int padding = coder.readBits(1, false);
            coder.readBits(1, false); // reserved

            numberOfChannels = channelCount[coder.readBits(2, false)];

            coder.adjustPointer(6); // skip modeExtension, copyright, original and emphasis
            
            samplesPerChannel += samplesPerFrame;
            
            int frameSize = (((version == MPEG1) ? 144 : 72) * bitRate*1000 / sampleRate + padding) - 4;
            
            frameTable[frameNumber++][1] = 4 + frameSize;
    
            coder.adjustPointer(frameSize << 3);
        }
    }
    
    private byte[] dataFromFile(String filename) throws IOException
    {
        File aFile = new File(filename);
        FileInputStream imageContents = null;
        
        byte[] bytes = new byte[(int)aFile.length()];
        
        imageContents = new FileInputStream(aFile);            
        imageContents.read(bytes);
        imageContents.close();

        return bytes;
    }
    
    private void initFrameTable(byte[] bytes)
    {
        FSCoder coder = new FSCoder(FSCoder.LITTLE_ENDIAN, bytes);
                
        coder.findBits(0x7FF, 11, 8);
        
        int frameStart = coder.getPointer();
        int numberOfFrames = 0;
        
        while (coder.findBits(0x7FF, 11, 8))
        {
            coder.adjustPointer(MP3FrameSize(coder) << 3);
            numberOfFrames++;
        }
        
        frameTable = new int[numberOfFrames][2];
        
        coder.setPointer(frameStart);
        
        int frameNumber = 0;
        
        while (coder.findBits(0x7FF, 11, 8))
        {
            frameTable[frameNumber][0] = (coder.getPointer()- frameStart) >> 3;
            
            coder.adjustPointer(11); // skip start of frame marker

            int version = coder.readBits(2, false);
            
            coder.adjustPointer(3);
            
            int bitRate = bitRates[version][coder.readBits(4, false)];
            int samplingRate = samplingRates[version][coder.readBits(2, false)];
            int padding = coder.readBits(1, false);

            frameTable[frameNumber++][1] = 4 + (((version == MPEG1) ? 144 : 72) * bitRate*1000 / samplingRate + padding) - 4;
    
            coder.adjustPointer((MP3FrameSize(coder) << 3)-23);
        }
    }
    
    private int MP3FrameSize(FSCoder coder)
    {
        int frameSize = 4;
        
        coder.adjustPointer(11);

        int version = coder.readBits(2, false);
            
        coder.adjustPointer(3);
            
        int bitRate = bitRates[version][coder.readBits(4, false)];
        int samplingRate = samplingRates[version][coder.readBits(2, false)];
           int padding = coder.readBits(1, false);

        coder.adjustPointer(-23);
            
        frameSize += (((version == MPEG1) ? 144 : 72) * bitRate*1000 / samplingRate + padding) - 4;
        
        return frameSize;
    }
}
