即时通讯-语音录制及播放

即时通讯

即时通信的要点就是消息内容不大,并且传输迅速,并且是即时到达,实时通知的
所以我们对语音进行一些处理,语音处理的过程如下:

  1. 录制录音
  2. 获取数据
  3. 编码保存
  4. 接收数据
  5. 数据解码
  6. 播放录音

为什么我们需要对数据进行编解码呢?原始的声音数据是非常大的,如果进行直接传输的话可能完全符合不了即时通讯的要求,所以我们要进行压缩。

所需要的API

  • 声音采集:MediaRecorder(直接录制成文件并且保存下来),AudioRecord(把声音的实时的字节数据返回)
  • 声音播放:MediaPlayer(基于声音文件播放的API),AudioTrack(基于字节数据播放的API)
  • 多线程:ExecutorService(因为对声音的处理比较耗时,所以我们不能在主线程进行处理,所以就需要多线程,有人可能会说why,因为主线程有个16ms的执行限制,一定要刷新的,所以不能执行I/O等耗时操作)

数据传输

  • 基于文件:HTTP文件上传下载(耗时)
  • 基于字节流:TCP/WebSocket(先比之下耗时稍微少一点)

语音录制

语音录制的方法上面也介绍过了,又两种方法,各有各的好处,一个是直接录制成文件存储,另一个是直接返回字节数据,其实区别是MediaRecorder录制的音频文件是经过压缩后的,需要设置编码器。并且录制的音频文件可以用系统自带的Music播放器播放;而AudioRecord录制的是PCM格式的音频文件,需要用AudioTrack来播放,AudioTrack更接近底层;在用MediaRecorder进行录制音视频时,最终还是会创建AudioRecord用来与AudioFlinger进行交互。下面我们来介绍下两种方法录制的方法是怎么写的:

MediaRecorder

我们着重介绍FileActivity类,而对于其中录制最重要的是doStart()以及doStop()方法,下面的代码有详细的介绍,我就不累述了,然后对于异常我们要进行抛出,并且对用户进行提示,因为提示总比直接闪退来的效果要好。

package com.xjh.gin.im;

import android.graphics.Color;
import android.media.MediaRecorder;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

import java.io.File;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Created by Gin on 2017/11/28.
 */

public class FileActivity extends AppCompatActivity {
    private TextView mTvLog, mTvPressToSay;

    private ExecutorService mExecutorService;
    private MediaRecorder mMediaRecorder;
    private File mAudioFile;
    private long mStartRecordTime, mStopRecordTime;

    private Handler mMainThreadHandler;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_file);
        initView();
        initEvent();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        //activity销毁时,停止后台任务,避免内存泄露
        mExecutorService.shutdownNow();
        releaseRecorder();
    }

    private void initView() {
        mTvLog = findViewById(R.id.mTvLog);
        mTvPressToSay = findViewById(R.id.mTvPressToSay);
        //录音的JNI函数不具备线程安全性,所以要用单线程
        mExecutorService = Executors.newSingleThreadExecutor();
        //主线程的Handler
        mMainThreadHandler = new Handler(Looper.getMainLooper());
    }

    private void initEvent() {
        //按下说话,释放发送,所以我们不能使用OnClickListener
        //用OnTouchListener
        mTvPressToSay.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                //根据不同的touch action做出相应的处理
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN:
                        startRecord();
                        break;
                    case MotionEvent.ACTION_UP:
                    case MotionEvent.ACTION_CANCEL:
                        stopRecord();
                        break;
                }
                return true;
            }
        });
    }

    //开始录音
    private void startRecord() {
        mTvPressToSay.setText("正在说话...");
        mTvPressToSay.setBackgroundColor(Color.GRAY);
        //提交后台任务,执行录音逻辑
        mExecutorService.submit(new Runnable() {
            @Override
            public void run() {
                //释放之前录音的 MediaRecorder
                releaseRecorder();
                //执行录音逻辑,如果失败提示用户
                if (!doStart()) {
                    recordFail();
                }
            }
        });
    }

    //结束录音
    private void stopRecord() {
        mTvPressToSay.setText("按住说话");
        mTvPressToSay.setBackgroundColor(Color.WHITE);
        //提交后台任务,执行停止逻辑
        mExecutorService.submit(new Runnable() {
            @Override
            public void run() {
                //执行停止录音逻辑,失败就提醒用户
                if (!doStop()) {
                    recordFail();
                }
                //释放 MediaRecorder
                releaseRecorder();
            }
        });
    }

    /**
     * 启动录音
     **/
    private boolean doStart() {
        try {
            //创建 MediaRecorder
            mMediaRecorder = new MediaRecorder();
            //创建录音文件
            mAudioFile = new File(Environment.getExternalStorageDirectory().getAbsolutePath()+"/sound/" + System.currentTimeMillis() + ".m4a");//获取绝对路径
            mAudioFile.getParentFile().mkdirs();//保证路径是存在的
            mAudioFile.createNewFile();

            //配置 MediaRecorder
            mMediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);//从麦克风采集
            mMediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);//保存为MP4格式
            mMediaRecorder.setAudioSamplingRate(44100);//采样频率(越高效果越好,但是文件相应也越大,44100是所有安卓系统都支持的采样频率)
            mMediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);//编码格式,AAC是通用的格式
            mMediaRecorder.setAudioEncodingBitRate(96000);//编码频率,96000是音质较好的频率
            mMediaRecorder.setOutputFile(mAudioFile.getAbsolutePath());

            //开始录音
            mMediaRecorder.prepare();//准备开始录音
            mMediaRecorder.start();//开始录音

            //记录开始录音时间,统计时长
            mStartRecordTime = System.currentTimeMillis();

            return true;
        } catch (IOException | RuntimeException e) {
            e.printStackTrace();//捕获异常,避免闪退 返回false 提醒用户失败
            return false;
        }
    }

    /**
     * 停止录音
     **/
    private boolean doStop() {
        //停止录音
        try {
            mMediaRecorder.stop();

            //记录停止时间
            mStopRecordTime = System.currentTimeMillis();

            //只接受超过3秒的录音,在UI上显示出来
            final int times = (int) ((mStopRecordTime - mStartRecordTime) / 1000);
            if (times > 3) {
                //在主线程改变UI,显示出来
                mMainThreadHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        mTvLog.setText(mTvLog.getText() + "\n录音成功 " + times + "秒");
                    }
                });
                //停止成功
                return true;
            }
            return false;
        } catch (RuntimeException e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 释放 MediaRecorder
     **/
    private void releaseRecorder() {
        //检查 MediaRecorder 不为空
        if (mMediaRecorder != null) {
            mMediaRecorder.release();
            mMediaRecorder = null;
        }
    }

    /**
     * 录音失败
     **/
    private void recordFail() {
        mAudioFile = null;
        //Toast必须要在主线程才会显示,所有不能直接在这里写
        mMainThreadHandler.post(new Runnable() {
            @Override
            public void run() {
                Toast.makeText(FileActivity.this, "录音失败", Toast.LENGTH_SHORT).show();
            }
        });
    }
}

布局文件呢就是特别简单的布局,这个就没有必要详细的讲了,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/background_dark"
    android:padding="16dp">

    <TextView
        android:id="@+id/mTvLog"
        android:layout_marginTop="80dp"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:textColor="@android:color/white"
        android:textSize="30sp"
        android:text="123"/>

    <TextView
        android:id="@+id/mTvPressToSay"
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:layout_gravity="bottom"
        android:layout_marginBottom="20dp"
        android:layout_marginLeft="5dp"
        android:layout_marginRight="5dp"
        android:background="@android:color/white"
        android:gravity="center"
        android:text="按住说话"
        android:textColor="#333333"
        android:textSize="30sp"
        />

</FrameLayout>

效果我们就在后面编写完播放代码的时候一起截出来

AudioRecord

这个与之前的操作其实相差不大,只是这边是流的形式读取的,所以需要循环录制,所以我们需要主线程和后台线程进行状态的同步,因为后台线程在循环中读取状态值,所以需要主线程改变状态值让后台线程得以w,然后我们是直接对AudioRecord进行配置的,详细的配置可以看startRecord()方法,代码注释非常详细,可以帮助大家进行理解。

package com.xjh.gin.im;

import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Created by Gin on 2017/11/28.
 */

public class StreamActivity extends AppCompatActivity implements View.OnClickListener {
    private Button btn_start;
    private TextView mTvLog;

    private volatile boolean mIsRecording;//volatile保证多线程内存同步
    private ExecutorService mExecutorService;
    private Handler mMainThreadHandler;

    private byte[] mBuffer;//不能太大
    private static final int BUFFER_SIZE = 2048;
    private File mAudioFile;
    private long mStartRecordTime, mStopRecordTime;

    private FileOutputStream mFileOutputStream;
    private AudioRecord mAudioRecord;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_stream);
        initView();
        initEvent();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        //activity销毁时,停止后台任务,避免内存泄露
        mExecutorService.shutdownNow();
    }

    private void initEvent() {
        btn_start.setOnClickListener(this);
    }

    private void initView() {
        btn_start = findViewById(R.id.mBtnStart);
        mTvLog = findViewById(R.id.mTvLogs);
        //录音的JNI函数不具备线程安全性,所以要用单线程
        mExecutorService = Executors.newSingleThreadExecutor();
        //主线程的Handler
        mMainThreadHandler = new Handler(Looper.getMainLooper());
        mBuffer = new byte[BUFFER_SIZE];
    }

    @Override
    public void onClick(View v) {
        if (mIsRecording) {
            mIsRecording = false;
            btn_start.setText("开始");
        } else {
            mIsRecording = true;
            btn_start.setText("停止");

            //提交后台任务,执行录音逻辑
            mExecutorService.submit(new Runnable() {
                @Override
                public void run() {
                    if (!startRecord()) {
                        recordFail();
                    }
                }
            });
        }
    }

    private boolean startRecord() {
        try {
            //创建录音文件
            mAudioFile = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + "/sound/" + System.currentTimeMillis() + ".pcm");//获取绝对路径
            mAudioFile.getParentFile().mkdirs();//保证路径是存在的
            mAudioFile.createNewFile();

            //创建文件输入流
            mFileOutputStream = new FileOutputStream(mAudioFile);

            //配置 AudioRecord
            int audioSource = MediaRecorder.AudioSource.MIC;//从麦克风采集
            int sampleRate = 44100;//采样频率(越高效果越好,但是文件相应也越大,44100是所有安卓系统都支持的采样频率)
            int channelConfig = AudioFormat.CHANNEL_IN_MONO;//单声道输入
            int audioFormat = AudioFormat.ENCODING_PCM_16BIT;//PCM 16 是所有安卓系统都支持的量化精度,同样也是精度越高音质越好,文件越大
            int minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);//计算 AudioRecord 内部 buffer 最小的大小

            mAudioRecord = new AudioRecord(audioSource, sampleRate, channelConfig, audioFormat, Math.max(minBufferSize, BUFFER_SIZE));            //buffer 不能小于最低要求,也不能小于我们每次读取的大小

            //开始录音
            mAudioRecord.startRecording();

            //记录开始时间
            mStartRecordTime = System.currentTimeMillis();

            //循环读取数据,写入输出流中
            while (mIsRecording) {
                int read = mAudioRecord.read(mBuffer, 0, BUFFER_SIZE);//返回长度
                if (read > 0) {
                    //读取成功,写入文件
                    mFileOutputStream.write(mBuffer, 0, read);
                } else {
                    //读取失败,提示用户
                    return false;
                }
            }

            //退出循环,停止录音,释放资源
            return stopRecord();
        } catch (IOException | RuntimeException e) {
            e.printStackTrace();
            return false;
        } finally {
            //释放资源
            if (mAudioRecord != null) {
                mAudioRecord.release();
                mAudioRecord = null;
            }
        }
    }

    /**
     * 结束录音
     **/
    private boolean stopRecord() {
        try {
            //停止录音,关闭文件输出流
            mAudioRecord.stop();
            mAudioRecord.release();
            //mAudioRecord = null;

            mFileOutputStream.close();

            //记录结束时间
            mStopRecordTime = System.currentTimeMillis();

            //大于3秒才成功,在主线程改变UI
            final int times = (int) ((mStopRecordTime - mStartRecordTime) / 1000);
            if (times > 3) {
                //在主线程改变UI,显示出来
                mMainThreadHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        mTvLog.setText(mTvLog.getText() + "\n录音成功 " + times + "秒");
                    }
                });
                //停止成功
                return true;
            }
            return false;
        } catch (IOException e) {
            e.printStackTrace();
            return false;
        }
    }

    private void recordFail() {
        //Toast必须要在主线程才会显示,所有不能直接在这里写
        mMainThreadHandler.post(new Runnable() {
            @Override
            public void run() {
                Toast.makeText(StreamActivity.this, "录音失败", Toast.LENGTH_SHORT).show();

                //重置录音状态,以及UI状态
                mIsRecording = false;
                btn_start.setText("开始");
            }
        });
    }
}

布局文件也非常简单就不累赘讲述了,不懂的话可以看代码:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/background_dark"
    android:orientation="vertical"
    android:padding="16dp">

    <Button
        android:id="@+id/mBtnStart"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="开始"
        android:textSize="30dp"/>

    <TextView
        android:id="@+id/mTvLogs"
        android:layout_marginTop="10dp"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:textColor="@android:color/white"
        android:textSize="30sp"
        android:text="录音文件:"/>

</LinearLayout>

效果也是我们在后面编写完播放代码的时候会一起截出来

语音播放

语音的播放同样有两种模式,一种是文件格式的语音播放对应上面那个文件方式进行录制,使用MediaPlayer;还有一种是字节流模式的播放,对应的是字节流模式的录制,使用AudioTrack。
我们来分别介绍一下。

MediaPlayer

这个我们在前面的activity_file.xml中增加一个Button(播放),然后我们在FileActivity.java中新增几个方法,先加一个OnClick事件来触发播放功能:

mBtn.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        //检查当前状态
        if(mAudioFile != null&&!mIsPlaying){
            //设置当前的播放状态
            mIsPlaying=true;

            //提交后台任务,播放
            mExecutorService.submit(new Runnable() {
                @Override
                public void run() {
                    doPlay(mAudioFile);
                }
            });
        }
    }
});

然后新增下面几个方法来进行播放的逻辑

//播放逻辑
private void doPlay(File mAudioFile) {
    //配置播放器 MediaPlayer
    mMediaPlayer = new MediaPlayer();
    try{
        //设置声音文件
        mMediaPlayer.setDataSource(mAudioFile.getAbsolutePath());

        //监听回调
        mMediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {
            @Override
            public void onCompletion(MediaPlayer mp) {
                //播放结束,释放播放器
                stopPlay();
            }
        });
        mMediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() {
            @Override
            public boolean onError(MediaPlayer mp, int what, int extra) {
                //提示
                playFail();

                //释放播放器
                stopPlay();

                //错误已经处理
                return true;
            }
        });

        mMediaPlayer.setVolume(1,1);//配置音量(范围0~1,0为静音,1为原音量)
        mMediaPlayer.setLooping(false);//是否循环
        mMediaPlayer.prepare();//准备
        mMediaPlayer.start();//开始
    }catch (RuntimeException | IOException e){
        //异常处理,防止闪退
        e.printStackTrace();
        playFail();
        //释放播放器
        stopPlay();
    }
}

//停止逻辑
private void stopPlay() {
    //重置状态
    mIsPlaying=false;

    //释放播放器
    if(mMediaPlayer != null){
        //重置监听器,防止内存泄漏
        mMediaPlayer.setOnCompletionListener(null);
        mMediaPlayer.setOnErrorListener(null);

        mMediaPlayer.stop();
        mMediaPlayer.reset();
        mMediaPlayer.release();
        mMediaPlayer=null;
    }
}

//提醒用户播放失败
private void playFail() {
    //在主线程Toast提示
    mMainThreadHandler.post(new Runnable() {
        @Override
        public void run() {
            Toast.makeText(FileActivity.this,"播放失败",Toast.LENGTH_SHORT).show();
        }
    });
}

IMFile

AudioTrack

与上面那种方法一样我们也是要新增一个Button,然后我们在其中增加一个OnClicks事件,然后我们在StreamActivity.java中新增几个方法,来进行播放的处理,我们来说下其中的配置的传输模式吧,因为在代码中就这个没有进行注释,其他的代码中注释非常详细,就不进行叙述了。
Java和native层数据传输模式有两种:

  1. 流模式:AudioTrack.MODE_STREAM//循环一遍一遍的写
  2. 静态模式AudioTrack.MODE_STATIC//一次性写完
    先新建个OnClick事件:
case R.id.mBtnPlay:
    if(mAudioFile != null&&!mIsPlaying){
        //设置当前的播放状态
        mIsPlaying=true;

        //提交后台任务,播放
        mExecutorService.submit(new Runnable() {
            @Override
            public void run() {
                doPlay(mAudioFile);
            }
        });
    }
break;

然后我们写以下的几个方法来执行播放逻辑,在使用AudioTrack 结束以后一定要记得把文件输入流关闭,然后把AudioTrack给关闭

//播放逻辑
private void doPlay(File mAudioFile) {
    Log.e("TAGSS",""+mAudioFile);
    //配置播放器 MediaPlayer
    int streamType = AudioManager.STREAM_MUSIC;//音乐类型,扬声器播放
    int sampleRate = 44100;//采样频率,要与录制时一样
    int channelConfig = AudioFormat.CHANNEL_OUT_MONO;//声道设置,要与录制时一样
    int audioFormat = AudioFormat.ENCODING_PCM_16BIT;//要与录制时一样
    int mode = AudioTrack.MODE_STREAM;//流模式

    int minBufferSize = AudioTrack.getMinBufferSize(sampleRate,channelConfig,audioFormat);//计算最小 buffer 的大小

    Log.e("TAGSS",""+minBufferSize);
    //创建 AudioTrack
    AudioTrack audioTrack = new AudioTrack(streamType,sampleRate,channelConfig,audioFormat,Math.max(minBufferSize,BUFFER_SIZE),mode);
    audioTrack.play();//启动AudioTrack

    //从文件流中读取数据
    FileInputStream inputStream = null;
    try{
        inputStream = new FileInputStream(mAudioFile);

        //循环读数据,写到播放器中去
        int read;
        while((read=inputStream.read(mBuffer))>0){
            Log.e("TAGSS",""+read);
            int ret = audioTrack.write(mBuffer,0,read);
            //检查返回值
            switch (ret){
                case AudioTrack.ERROR_INVALID_OPERATION:
                case AudioTrack.ERROR_BAD_VALUE:
                case AudioManager.ERROR_DEAD_OBJECT:
                    playFail();
                    return;
                default:
                    break;
            }
        }
    }catch (RuntimeException | IOException e){
        //异常处理,防止闪退
        e.printStackTrace();
        playFail();
    }finally {
        mIsPlaying = false;
        //关闭文件输入流
        if(inputStream != null){
            colseQuietly(inputStream);
        }
        //播放器释放
        resetQuietly(audioTrack);
    }
}

//提醒用户播放失败
private void playFail() {
    //在主线程Toast提示
    mMainThreadHandler.post(new Runnable() {
        @Override
        public void run() {
            Toast.makeText(StreamActivity.this,"播放失败",Toast.LENGTH_SHORT).show();
        }
    });
}

//关闭文件输入流
private void colseQuietly(FileInputStream inputStream) {
    try {
        inputStream.close();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

//播放器释放
private void resetQuietly(AudioTrack audioTrack) {
    try {
        audioTrack.stop();
        audioTrack.release();
    }catch (RuntimeException e){
        e.printStackTrace();
    }
}

IMStream

总结

文件模式使用方便,字节流模式,使用起来比较复杂,但是非常灵活
因为录音和播放的处理都是耗时操作,所以我们要防止后台线程进行操作,不要照成主线程的阻塞,然后后台的线程为单线程,为了防止JNI函数的闪退,然后主线程要与后台线程数据同步(所以我们要用到volatile关键字)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值