com.commontime.cordova.audio.AudioPlayer.java Source code

Java tutorial

Introduction

Here is the source code for com.commontime.cordova.audio.AudioPlayer.java

Source

/*
   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 com.commontime.cordova.audio;

import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import android.media.MediaPlayer.OnErrorListener;
import android.media.MediaPlayer.OnPreparedListener;
import android.media.MediaRecorder;
import android.os.Environment;
import android.util.Log;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

/**
 * This class implements the audio playback and recording capabilities used by Cordova.
 * It is called by the AudioHandler Cordova class.
 * Only one file can be played or recorded per class instance.
 *
 * Local audio files must reside in one of two places:
 *      android_asset:      file name must start with /android_asset/sound.mp3
 *      sdcard:             file name is just sound.mp3
 */
public class AudioPlayer implements OnCompletionListener, OnPreparedListener, OnErrorListener {

    // AudioPlayer modes
    public enum MODE {
        NONE, PLAY, RECORD
    };

    // AudioPlayer states
    public enum STATE {
        MEDIA_NONE, MEDIA_STARTING, MEDIA_RUNNING, MEDIA_PAUSED, MEDIA_STOPPED, MEDIA_LOADING
    };

    private static final String LOG_TAG = "AudioPlayer";

    // AudioPlayer message ids
    private static int MEDIA_STATE = 1;
    private static int MEDIA_DURATION = 2;
    private static int MEDIA_POSITION = 3;
    private static int MEDIA_ERROR = 9;

    // Media error codes
    private static int MEDIA_ERR_NONE_ACTIVE = 0;
    private static int MEDIA_ERR_ABORTED = 1;
    //    private static int MEDIA_ERR_NETWORK        = 2;
    //    private static int MEDIA_ERR_DECODE         = 3;
    //    private static int MEDIA_ERR_NONE_SUPPORTED = 4;

    private AudioHandler handler; // The AudioHandler object
    private String id; // The id of this player (used to identify Media object in JavaScript)
    private MODE mode = MODE.NONE; // Playback or Recording mode
    private STATE state = STATE.MEDIA_NONE; // State of recording or playback

    private String audioFile = null; // File name to play or record to
    private float duration = -1; // Duration of audio

    private MediaRecorder recorder = null; // Audio recording object
    private String tempFile = null; // Temporary recording file name

    private MediaPlayer player = null; // Audio player object
    private boolean prepareOnly = true; // playback after file prepare flag
    private int seekOnPrepared = 0; // seek to this location once media is prepared

    /**
     * Constructor.
     *
     * @param handler           The audio handler object
     * @param id                The id of this audio player
     */
    public AudioPlayer(AudioHandler handler, String id, String file) {
        this.handler = handler;
        this.id = id;
        this.audioFile = file;
        this.recorder = new MediaRecorder();

        if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
            this.tempFile = Environment.getExternalStorageDirectory().getAbsolutePath() + "/tmprecording.m4a";
        } else {
            this.tempFile = "/data/data/" + handler.cordova.getActivity().getPackageName()
                    + "/cache/tmprecording.m4a";
        }

    }

    /**
     * Destroy player and stop audio playing or recording.
     */
    public void destroy() {
        // Stop any play or record
        if (this.player != null) {
            if ((this.state == STATE.MEDIA_RUNNING) || (this.state == STATE.MEDIA_PAUSED)) {
                this.player.stop();
                this.setState(STATE.MEDIA_STOPPED);
            }
            this.player.release();
            this.player = null;
        }
        if (this.recorder != null) {
            this.stopRecording();
            this.recorder.release();
            this.recorder = null;
        }
    }

    /**
     * Start recording the specified file.
     *
     * @param file              The name of the file, should use .m4a extension
     */
    public void startRecording(String file) {
        switch (this.mode) {
        case PLAY:
            Log.d(LOG_TAG, "AudioPlayer Error: Can't record in play mode.");
            sendErrorStatus(MEDIA_ERR_ABORTED);
            break;
        case NONE:
            // if file exists, delete it
            // Only new file are created with startRecording

            File f = new File(file);
            f.delete();

            this.audioFile = file;
            this.recorder.setAudioSource(MediaRecorder.AudioSource.MIC);

            //Modified by REM 06/15/2015 to generate MPEG_4 output
            this.recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
            this.recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
            this.recorder.setAudioChannels(1); // single channel
            this.recorder.setAudioSamplingRate(44100); // 44.1 kHz for decent sound, similar to stock iOS media plugin
            this.recorder.setAudioEncodingBitRate(32000); // low bit rate

            this.recorder.setOutputFile(this.tempFile);
            try {
                this.recorder.prepare();
                this.recorder.start();
                this.setState(STATE.MEDIA_RUNNING);
                return;
            } catch (IllegalStateException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }

            sendErrorStatus(MEDIA_ERR_ABORTED);
            break;
        case RECORD:
            Log.d(LOG_TAG, "AudioPlayer Error: Already recording.");
            sendErrorStatus(MEDIA_ERR_ABORTED);
        }
    }

    /**
     * Start recording the specified file with compression.
     *
     * @param file              The name of the file, should use .m4a extension
     * @param channels          audio channels, 1 or 2, optional, default value is 1
     * @param sampleRate        sample rate in hz, 8000 to 48000, optional, default value is 44100
     */
    public void startRecordingWithCompression(String file, Integer channels, Integer sampleRate) {
        switch (this.mode) {
        case PLAY:
            Log.d(LOG_TAG, "AudioPlayer Error: Can't record in play mode.");
            sendErrorStatus(MEDIA_ERR_ABORTED);
            break;
        case NONE:
            // if file exists, delete it
            // Only new file are created with startRecording

            File f = new File(file);
            f.delete();
            this.audioFile = file;

            this.recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
            this.recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
            this.recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);

            this.recorder.setAudioChannels(channels);
            this.recorder.setAudioSamplingRate(sampleRate);

            // On Android with MPEG4/AAC, bitRate affects file size, surprisingly, sample rate does not.
            // So we adjust the bit rate for better compression, based on requested sample rate.
            Integer bitRate = 32000; // default bit rate
            if (sampleRate < 30000) {
                bitRate = 16384;
            }
            if (sampleRate < 16000) {
                bitRate = 8192;
            }
            this.recorder.setAudioEncodingBitRate(bitRate);

            Log.d(LOG_TAG, "MPEG-4 recording started with bit rate of " + bitRate + ", sample rate of " + sampleRate
                    + "hz, " + channels + " audio channel(s)");

            this.recorder.setOutputFile(this.tempFile);
            try {
                this.recorder.prepare();
                this.recorder.start();
                this.setState(STATE.MEDIA_RUNNING);
                return;
            } catch (IllegalStateException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }

            sendErrorStatus(MEDIA_ERR_ABORTED);
            break;
        case RECORD:
            Log.d(LOG_TAG, "AudioPlayer Error: Already recording.");
            sendErrorStatus(MEDIA_ERR_ABORTED);
        }
    }

    /**
     * Resume recording the specified file.
     *
     * @param file              The name of the file
     * @param channels          audio channels, 1 or 2, optional, default value is 1
     * @param sampleRate        sample rate in hz, 8000 to 48000, optional, default value is 44100
     */
    public void resumeRecording(String file, Integer channels, Integer sampleRate) {
        switch (this.mode) {
        case PLAY:
            Log.d(LOG_TAG, "AudioPlayer Error: Can't record in play mode.");
            sendErrorStatus(MEDIA_ERR_ABORTED);
            break;

        case NONE:
            this.audioFile = file;
            this.recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
            this.recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
            this.recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);

            this.recorder.setAudioChannels(channels);
            this.recorder.setAudioSamplingRate(sampleRate);

            // On Android with MPEG4/AAC, bitRate affects file size, surprisingly, sample rate does not.
            // So we adjust the bit rate for better compression, based on requested sample rate.
            Integer bitRate = 32000; // default bit rate
            if (sampleRate < 30000) {
                bitRate = 16384;
            }
            if (sampleRate < 16000) {
                bitRate = 8192;
            }
            this.recorder.setAudioEncodingBitRate(bitRate);
            Log.d(LOG_TAG, "MPEG-4 recording started with bit rate of " + bitRate + ", sample rate of " + sampleRate
                    + "hz, " + channels + " audio channel(s)");

            this.recorder.setOutputFile(this.tempFile);
            try {
                this.recorder.prepare();
                this.recorder.start();
                this.setState(STATE.MEDIA_RUNNING);
                return;
            } catch (IllegalStateException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }

            sendErrorStatus(MEDIA_ERR_ABORTED);
            break;

        case RECORD:
            Log.d(LOG_TAG, "AudioPlayer Error: Already recording.");
            sendErrorStatus(MEDIA_ERR_ABORTED);
        }
    }

    /**
     * Save temporary recorded file to specified name
     *
     * @param file
     */
    public void moveFile(String file) {
        /* this is a hack to save the file as the specified name */
        File f = new File(this.tempFile);

        if (!file.startsWith("/")) {
            if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
                file = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + file;
            } else {
                file = "/data/data/" + handler.cordova.getActivity().getPackageName() + "/cache/" + file;
            }
        }

        String logMsg = "renaming " + this.tempFile + " to " + file;
        Log.d(LOG_TAG, logMsg);

        if (f.exists()) {
            if (!f.renameTo(new File(file)))
                Log.e(LOG_TAG, "FAILED " + logMsg);
        }
    }

    /**
     * Append temporary recorded file to specified filename
     *
     * @param file
     */
    public void appendFile(String file) {

        if (!file.startsWith("/")) {
            if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
                file = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + file;
            } else {
                file = "/data/data/" + handler.cordova.getActivity().getPackageName() + "/cache/" + file;
            }
        }

        String logMsg = "appending" + this.tempFile + " to " + file;
        Log.d(LOG_TAG, logMsg);
        mp4ParserWrapper.append(file, this.tempFile);

    }

    /**
     * Stop recording and save to the file specified when recording started.
     */
    public void stopRecording() {
        if (this.recorder != null) {
            try {
                if (this.state == STATE.MEDIA_RUNNING) {
                    this.recorder.stop();
                    this.setState(STATE.MEDIA_STOPPED);
                }
                this.recorder.reset();
                this.moveFile(this.audioFile);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * Pause recording (simulated), stop recording and append to the file specified when recording started.
     */
    public void pauseRecording() {
        if (this.recorder != null) {
            try {
                if (this.state == STATE.MEDIA_RUNNING) {
                    this.recorder.stop();
                    Log.d(LOG_TAG, "pauseRecording is calling stopped.");
                    this.setState(STATE.MEDIA_STOPPED);
                }
                this.recorder.reset();
                this.appendFile(this.audioFile);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    //==========================================================================
    // Playback
    //==========================================================================

    /**
     * Start or resume playing audio file.
     *
     * @param file              The name of the audio file.
     */
    public void startPlaying(String file) {
        if (this.readyPlayer(file) && this.player != null) {
            this.player.start();
            this.setState(STATE.MEDIA_RUNNING);
            this.seekOnPrepared = 0; //insures this is always reset
        } else {
            this.prepareOnly = false;
        }
    }

    /**
     * Seek or jump to a new time in the track.
     */
    public void seekToPlaying(int milliseconds) {
        if (this.readyPlayer(this.audioFile)) {
            this.player.seekTo(milliseconds);
            Log.d(LOG_TAG, "Send a onStatus update for the new seek");
            sendStatusChange(MEDIA_POSITION, null, (milliseconds / 1000.0f));
        } else {
            this.seekOnPrepared = milliseconds;
        }
    }

    /**
     * Pause playing.
     */
    public void pausePlaying() {

        // If playing, then pause
        if (this.state == STATE.MEDIA_RUNNING && this.player != null) {
            this.player.pause();
            this.setState(STATE.MEDIA_PAUSED);
        } else {
            Log.d(LOG_TAG,
                    "AudioPlayer Error: pausePlaying() called during invalid state: " + this.state.ordinal());
            sendErrorStatus(MEDIA_ERR_NONE_ACTIVE);
        }
    }

    /**
     * Stop playing the audio file.
     */
    public void stopPlaying() {
        if ((this.state == STATE.MEDIA_RUNNING) || (this.state == STATE.MEDIA_PAUSED)) {
            this.player.pause();
            this.player.seekTo(0);
            Log.d(LOG_TAG, "stopPlaying is calling stopped");
            this.setState(STATE.MEDIA_STOPPED);
        } else {
            Log.d(LOG_TAG, "AudioPlayer Error: stopPlaying() called during invalid state: " + this.state.ordinal());
            sendErrorStatus(MEDIA_ERR_NONE_ACTIVE);
        }
    }

    /**
     * Get db Recording Level.
     *
     * @return    double dbLevel
     */

    public float getRecordDbLevel() {

        double reference = 0.00002; // Pa reference minimum

        if (this.state == STATE.MEDIA_RUNNING) {
            int maxAmplitude = this.recorder.getMaxAmplitude();

            /*
            /  Warning!
            /
            /  This is a desperate attempt to determine dB (SPL).
            /  Without proper calibration for each device, the reference value is a best guess.
            /  Please do not count on these values for anything other than to provide at least some feedback that
            /  something is being recorded.
            /
            /  The pascal pressure is calculated based on the idea that the max amplitude (being a value between 0 and 32767)
            /  is relative to the pressure.
                
            /  The value 51805.5336 used below is derived from the assumption that when x = 32767, pressure = 0.6325 Pa and that
            /  when x = 1, pressure = 0.0002 Pa (the reference value)
            /
            */

            double pressure = maxAmplitude / 51805.5336;
            double dB = 20 * Math.log10(pressure / reference);
            return (float) dB;
        } else {
            return -1;
        }
    }

    /**
     * Callback to be invoked when playback of a media source has completed.
     *
     * @param player           The MediaPlayer that reached the end of the file
     */
    public void onCompletion(MediaPlayer player) {
        Log.d(LOG_TAG, "on completion is calling stopped");
        this.setState(STATE.MEDIA_STOPPED);
    }

    /**
     * Get current position of playback.
     *
     * @return                  position in msec or -1 if not playing
     */
    public long getCurrentPosition() {
        if ((this.state == STATE.MEDIA_RUNNING) || (this.state == STATE.MEDIA_PAUSED)) {
            int curPos = this.player.getCurrentPosition();
            sendStatusChange(MEDIA_POSITION, null, (curPos / 1000.0f));
            return curPos;
        } else {
            return -1;
        }
    }

    /**
     * Determine if playback file is streaming or local.
     * It is streaming if file name starts with "http://"
     *
     * @param file              The file name
     * @return                  T=streaming, F=local
     */
    public boolean isStreaming(String file) {
        if (file.contains("http://") || file.contains("https://")) {
            return true;
        } else {
            return false;
        }
    }

    /**
      * Get the duration of the audio file.
      *
      * @param file             The name of the audio file.
      * @return                 The duration in msec.
      *                             -1=can't be determined
      *                             -2=not allowed
      */
    public float getDuration(String file) {

        // Can't get duration of recording
        if (this.recorder != null) {
            return (-2); // not allowed
        }

        // If audio file already loaded and started, then return duration
        if (this.player != null) {
            return this.duration;
        }

        // If no player yet, then create one
        else {
            this.prepareOnly = true;
            this.startPlaying(file);

            // This will only return value for local, since streaming
            // file hasn't been read yet.
            return this.duration;
        }
    }

    /**
     * Callback to be invoked when the media source is ready for playback.
     *
     * @param player           The MediaPlayer that is ready for playback
     */
    public void onPrepared(MediaPlayer player) {
        // Listen for playback completion
        this.player.setOnCompletionListener(this);
        // seek to any location received while not prepared
        this.seekToPlaying(this.seekOnPrepared);
        // If start playing after prepared
        if (!this.prepareOnly) {
            this.player.start();
            this.setState(STATE.MEDIA_RUNNING);
            this.seekOnPrepared = 0; //reset only when played
        } else {
            this.setState(STATE.MEDIA_STARTING);
        }
        // Save off duration
        this.duration = getDurationInSeconds();
        // reset prepare only flag
        this.prepareOnly = true;

        // Send status notification to JavaScript
        sendStatusChange(MEDIA_DURATION, null, this.duration);
    }

    /**
     * By default Android returns the length of audio in mills but we want seconds
     *
     * @return length of clip in seconds
     */
    private float getDurationInSeconds() {
        return (this.player.getDuration() / 1000.0f);
    }

    /**
     * Callback to be invoked when there has been an error during an asynchronous operation
     *  (other errors will throw exceptions at method call time).
     *
     * @param player           the MediaPlayer the error pertains to
     * @param arg1              the type of error that has occurred: (MEDIA_ERROR_UNKNOWN, MEDIA_ERROR_SERVER_DIED)
     * @param arg2              an extra code, specific to the error.
     */
    public boolean onError(MediaPlayer player, int arg1, int arg2) {
        Log.d(LOG_TAG, "AudioPlayer.onError(" + arg1 + ", " + arg2 + ")");

        // TODO: Not sure if this needs to be sent?
        this.player.stop();
        this.player.release();

        // Send error notification to JavaScript
        sendErrorStatus(arg1);
        return false;
    }

    /**
     * Set the state and send it to JavaScript.
     *
     * @param state
     */
    private void setState(STATE state) {
        if (this.state != state) {
            sendStatusChange(MEDIA_STATE, null, (float) state.ordinal());
        }
        this.state = state;
    }

    /**
     * Set the mode and send it to JavaScript.
     *
     * @param mode
     */
    private void setMode(MODE mode) {
        if (this.mode != mode) {
            //mode is not part of the expected behavior, so no notification
            //this.handler.webView.sendJavascript("cordova.require('cordova-plugin-media.Media').onStatus('" + this.id + "', " + MEDIA_STATE + ", " + mode + ");");
        }
        this.mode = mode;
    }

    /**
     * Get the audio state.
     *
     * @return int
     */
    public int getState() {
        return this.state.ordinal();
    }

    /**
     * Set the volume for audio player
     *
     * @param volume
     */
    public void setVolume(float volume) {
        this.player.setVolume(volume, volume);
    }

    /**
     * attempts to put the player in play mode
     * @return true if in playmode, false otherwise
     */
    private boolean playMode() {
        switch (this.mode) {
        case NONE:
            this.setMode(MODE.PLAY);
            break;
        case PLAY:
            break;
        case RECORD:
            Log.d(LOG_TAG, "AudioPlayer Error: Can't play in record mode.");
            sendErrorStatus(MEDIA_ERR_ABORTED);
            return false; //player is not ready
        }
        return true;
    }

    /**
     * attempts to initialize the media player for playback
     * @param file the file to play
     * @return false if player not ready, reports if in wrong mode or state
     */
    private boolean readyPlayer(String file) {
        if (playMode()) {
            switch (this.state) {
            case MEDIA_NONE:
                if (this.player == null) {
                    this.player = new MediaPlayer();
                }
                try {
                    this.loadAudioFile(file);
                } catch (Exception e) {
                    sendErrorStatus(MEDIA_ERR_ABORTED);
                }
                return false;
            case MEDIA_LOADING:
                //cordova js is not aware of MEDIA_LOADING, so we send MEDIA_STARTING instead
                Log.d(LOG_TAG, "AudioPlayer Loading: startPlaying() called during media preparation: "
                        + STATE.MEDIA_STARTING.ordinal());
                this.prepareOnly = false;
                return false;
            case MEDIA_STARTING:
            case MEDIA_RUNNING:
            case MEDIA_PAUSED:
                return true;
            case MEDIA_STOPPED:
                //if we are readying the same file
                if (this.audioFile.compareTo(file) == 0) {
                    //reset the audio file
                    player.seekTo(0);
                    player.pause();
                    return true;
                } else {
                    //reset the player
                    this.player.reset();
                    try {
                        this.loadAudioFile(file);
                    } catch (Exception e) {
                        sendErrorStatus(MEDIA_ERR_ABORTED);
                    }
                    //if we had to prepare the file, we won't be in the correct state for playback
                    return false;
                }
            default:
                Log.d(LOG_TAG, "AudioPlayer Error: startPlaying() called during invalid state: " + this.state);
                sendErrorStatus(MEDIA_ERR_ABORTED);
            }
        }
        return false;
    }

    /**
     * load audio file
     * @throws IOException
     * @throws IllegalStateException
     * @throws SecurityException
     * @throws IllegalArgumentException
     */
    private void loadAudioFile(String file)
            throws IllegalArgumentException, SecurityException, IllegalStateException, IOException {
        if (this.isStreaming(file)) {
            this.player.setDataSource(file);
            this.player.setAudioStreamType(AudioManager.STREAM_MUSIC);
            //if it's a streaming file, play mode is implied
            this.setMode(MODE.PLAY);
            this.setState(STATE.MEDIA_STARTING);
            this.player.setOnPreparedListener(this);
            this.player.prepareAsync();
        } else {
            if (file.startsWith("/android_asset/")) {
                String f = file.substring(15);
                android.content.res.AssetFileDescriptor fd = this.handler.cordova.getActivity().getAssets()
                        .openFd(f);
                this.player.setDataSource(fd.getFileDescriptor(), fd.getStartOffset(), fd.getLength());
            } else {
                File fp = new File(file);
                if (fp.exists()) {
                    FileInputStream fileInputStream = new FileInputStream(file);
                    this.player.setDataSource(fileInputStream.getFD());
                    fileInputStream.close();
                } else {
                    this.player.setDataSource(Environment.getExternalStorageDirectory().getPath() + "/" + file);
                }
            }
            this.setState(STATE.MEDIA_STARTING);
            this.player.setOnPreparedListener(this);
            this.player.prepare();

            // Get duration
            this.duration = getDurationInSeconds();
        }
    }

    private void sendErrorStatus(int errorCode) {
        sendStatusChange(MEDIA_ERROR, errorCode, null);
    }

    private void sendStatusChange(int messageType, Integer additionalCode, Float value) {

        if (additionalCode != null && value != null) {
            throw new IllegalArgumentException("Only one of additionalCode or value can be specified, not both");
        }

        JSONObject statusDetails = new JSONObject();
        try {
            statusDetails.put("id", this.id);
            statusDetails.put("msgType", messageType);
            if (additionalCode != null) {
                JSONObject code = new JSONObject();
                code.put("code", additionalCode.intValue());
                statusDetails.put("value", code);
            } else if (value != null) {
                statusDetails.put("value", value.floatValue());
            }
        } catch (JSONException e) {
            Log.e(LOG_TAG, "Failed to create status details", e);
        }

        this.handler.sendEventMessage("status", statusDetails);
    }
}