即时通讯
即时通信的要点就是消息内容不大,并且传输迅速,并且是即时到达,实时通知的。
所以我们对语音进行一些处理,语音处理的过程如下:
- 录制录音
- 获取数据
- 编码保存
- 接收数据
- 数据解码
- 播放录音
为什么我们需要对数据进行编解码呢?原始的声音数据是非常大的,如果进行直接传输的话可能完全符合不了即时通讯的要求,所以我们要进行压缩。
所需要的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();
}
});
}
AudioTrack
与上面那种方法一样我们也是要新增一个Button,然后我们在其中增加一个OnClicks事件,然后我们在StreamActivity.java中新增几个方法,来进行播放的处理,我们来说下其中的配置的传输模式吧,因为在代码中就这个没有进行注释,其他的代码中注释非常详细,就不进行叙述了。
Java和native层数据传输模式有两种:
- 流模式:AudioTrack.MODE_STREAM//循环一遍一遍的写
- 静态模式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();
}
}
总结
文件模式使用方便,字节流模式,使用起来比较复杂,但是非常灵活
因为录音和播放的处理都是耗时操作,所以我们要防止后台线程进行操作,不要照成主线程的阻塞,然后后台的线程为单线程,为了防止JNI函数的闪退,然后主线程要与后台线程数据同步(所以我们要用到volatile关键字)