前言,音视频这块,确实比较难入门,本着学习的态度,我这边也跟着 Android 音视频开发入门指南 打怪升级,留下个脚印,大家共勉。
今天要完成的功能如下;
由于声音不好上动图,只能来一张静图了,具体代码看工程:音视频学习Demo
首先,我们先要了解声音是怎么被保存的起来的。在我们的世界中,声音是连续不断的,是一种模拟信号,那如何把声音保存起来呢?计算机能识别的就是二进制,所以,对声音这种模拟信号,采用数字化,即转换成数字信号,就能保存了。
从上面知道,声音是一种波,有自己的振幅和频率,如果要保存声音,就要保存各个时间点上的振幅;而数字信号并不能保存所有时间点的振幅,事实上,并不需要保存连续的信号,就可以还原到人耳可接受的声音;根据奈奎斯特定律:为了不失真地恢复模拟信号,采样频率应该不小于模拟信号频谱中最高频率的2倍。
音频数据的承载方式,最常用的就是 脉冲编码调制,即 PCM
根据上面的分析,PCM 的采集步骤可以以下步骤:
模拟信号 -> 采样 -> 量化 -> 编码 -> 数字信号
上面提到,采样率要大于原声波频率的2倍,人耳能听到的最高频率为 20khz,所以,为了满足人耳的听觉要求,采样率至少为40khz,通常就是为 44.1khz,更高则是 48 khz。一般我们都采用 44.1khz 即可达到无损音质。
上面说到模拟信号是连续的样本值,而数字信号一般是不连续的,所以模拟信号量化,只能取一个近似的整数值,为了记录这些振幅值,采样器会采用一个固定的位数来记录这些振幅值,通常有 8 位,16位,32位。
位数 | 最小值 | 最大值 |
---|---|---|
8 | 0 | 255 |
16 | -32468 | 32767 |
32 | -2147483648 | 2147483648 |
位数越大,记录的值越准确,还原度越高。
最后就是编码了,数字信号由0,1组成的,因此,需要将振幅转换成一系列 0和1进行存储,也就是编码,最后得到的数据就是数字信号:一串0和1组成的数据:
指支持能 不同发声(注意是不同声音) 的音响的个数。
上面了解音频的基础知识后,我们接着使用 AudioRecord 来录制原始数据,即 PCM 数据;
当手机的硬件录音之后,AudioRecord 可以从该硬件提取音频资源;读取的方法可以使用 read() 方法来实现。那么我们现在开始,首先,先申请好权限:
在 button 的down 事件开始录音,在up 的时候停止录音:
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//开始录制
startRecord();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
if (mAudioThread != null) {
mAudioThread.done();
}
break;
#startRecord()
private void startRecord() {
//如果存在,先停止线程
if (mAudioThread != null) {
mAudioThread.done();
mAudioThread = null;
}
//开启线程录制
mAudioThread = new AudioThread();
mAudioThread.start();
}
接着初始化 AudioRecord:
/**
* 获取最小 buffer 大小,即一帧的buffer
* 采样率为 44100,双声道,采样位数为 16bit
*/
minBufferSize = AudioRecord.getMinBufferSize(AUDIO_RATE, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT);
//使用 AudioRecord 去录音
record = new AudioRecord(
MediaRecorder.AudioSource.MIC,
AUDIO_RATE,
AudioFormat.CHANNEL_IN_STEREO,
AudioFormat.ENCODING_PCM_16BIT,
minBufferSize
);
上面通过 AudioRecord.getMinBufferSize() 来获取最小一帧的buffer 大小,这样我们能保证每一帧都能被录制。它的参数如下:
接着创建 AudioRecord ,参数也不难理解,这里不再赘述。
怎么去录制呢?说白了,就是通过 AudioRecord 的read方法,它会把数据读写到 byte[] 数组中,然后返回写入的大小,根据 byte 就可以保存到文件中了,代码如下:
@Override
public void run() {
super.run();
FileOutputStream fos = null;
try {
//没有先创建文件夹
File dir = new File(PATH);
if (!dir.exists()) {
dir.mkdirs();
}
//创建 pcm 文件
File pcmFile = getFile(PATH, "test.pcm");
fos = new FileOutputStream(pcmFile);
//开始录制
record.startRecording();
byte[] buffer = new byte[minBufferSize];
while (!isDone) {
//读取数据
int read = record.read(buffer, 0, buffer.length);
if (AudioRecord.ERROR_INVALID_OPERATION != read) {
//写 pcm 数据
fos.write(buffer, 0, read);
}
}
//录制结束
record.stop();
record.release();
fos.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
CloseUtils.close(fos);
record.release();
}
}
首先,使用 record.startRecording() 开始,此时它会开始监听硬件音频数据,然后通过 read() 方法读取数据,接着把它保存到文件中。
然后我们发现,已经保存了音频的原始数据 PCM 文件:
上面只保存了 pcm 文件,但这是原始的 pcm 文件,它是不支持播放的。我们需要将它转换成 wav 这种可以被识别解码的音频格式。
想要把 pcm 格式转换成 wav,只需要在pcm的文件起始位置加上至少44个字节的WAV头信息即可,
这个文件头记录着音频流的编码参数。数据块的记录方式是little-endian字节顺序,来一张官方图:
关于 wav 的说明,这里不重点介绍,它的头部生成方法如下:
/**
* 任何一种文件在头部添加相应的头文件才能够确定的表示这种文件的格式,
* wave是RIFF文件结构,每一部分为一个chunk,其中有RIFF WAVE chunk,
* FMT Chunk,Fact chunk,Data chunk,其中Fact chunk是可以选择的
*
* @param pcmAudioByteCount 不包括header的音频数据总长度
* @param longSampleRate 采样率,也就是录制时使用的频率
* @param channels audioRecord的频道数量
*/
private byte[] generateWavFileHeader(long pcmAudioByteCount, long longSampleRate, int channels) {
long totalDataLen = pcmAudioByteCount + 36; // 不包含前8个字节的WAV文件总长度
long byteRate = longSampleRate * 2 * channels;
byte[] header = new byte[44];
header[0] = 'R'; // RIFF
header[1] = 'I';
header[2] = 'F';
header[3] = 'F';
header[4] = (byte) (totalDataLen & 0xff);//数据大小
header[5] = (byte) ((totalDataLen >> 8) & 0xff);
header[6] = (byte) ((totalDataLen >> 16) & 0xff);
header[7] = (byte) ((totalDataLen >> 24) & 0xff);
header[8] = 'W';//WAVE
header[9] = 'A';
header[10] = 'V';
header[11] = 'E';
//FMT Chunk
header[12] = 'f'; // 'fmt '
header[13] = 'm';
header[14] = 't';
header[15] = ' ';//过渡字节
//数据大小
header[16] = 16; // 4 bytes: size of 'fmt ' chunk
header[17] = 0;
header[18] = 0;
header[19] = 0;
//编码方式 10H为PCM编码格式
header[20] = 1; // format = 1
header[21] = 0;
//通道数
header[22] = (byte) channels;
header[23] = 0;
//采样率,每个通道的播放速度
header[24] = (byte) (longSampleRate & 0xff);
header[25] = (byte) ((longSampleRate >> 8) & 0xff);
header[26] = (byte) ((longSampleRate >> 16) & 0xff);
header[27] = (byte) ((longSampleRate >> 24) & 0xff);
//音频数据传送速率,采样率*通道数*采样深度/8
header[28] = (byte) (byteRate & 0xff);
header[29] = (byte) ((byteRate >> 8) & 0xff);
header[30] = (byte) ((byteRate >> 16) & 0xff);
header[31] = (byte) ((byteRate >> 24) & 0xff);
// 确定系统一次要处理多少个这样字节的数据,确定缓冲区,通道数*采样位数
header[32] = (byte) (2 * channels);
header[33] = 0;
//每个样本的数据位数
header[34] = 16;
header[35] = 0;
//Data chunk
header[36] = 'd';//data
header[37] = 'a';
header[38] = 't';
header[39] = 'a';
header[40] = (byte) (pcmAudioByteCount & 0xff);
header[41] = (byte) ((pcmAudioByteCount >> 8) & 0xff);
header[42] = (byte) ((pcmAudioByteCount >> 16) & 0xff);
header[43] = (byte) ((pcmAudioByteCount >> 24) & 0xff);
return header;
}
在上面的方法中,通过先后才能下载了 pcm 文件;为了方便,我们可以在 下载 pcm 之前,把头部信息先存储起来,接着再填充 pcm 文件,完成代码如下:
@Override
public void run() {
super.run();
FileOutputStream fos = null;
FileOutputStream wavFos = null;
RandomAccessFile wavRaf = null;
try {
//没有先创建文件夹
File dir = new File(PATH);
if (!dir.exists()) {
dir.mkdirs();
}
//创建 pcm 文件
File pcmFile = getFile(PATH, "test.pcm");
//创建 wav 文件
File wavFile = getFile(PATH, "test.wav");
fos = new FileOutputStream(pcmFile);
wavFos = new FileOutputStream(wavFile);
//先写头部,刚才是,我们并不知道 pcm 文件的大小
byte[] headers = generateWavFileHeader(0, AUDIO_RATE, record.getChannelCount());
wavFos.write(headers, 0, headers.length);
//开始录制
record.startRecording();
byte[] buffer = new byte[minBufferSize];
while (!isDone) {
//读取数据
int read = record.read(buffer, 0, buffer.length);
if (AudioRecord.ERROR_INVALID_OPERATION != read) {
//写 pcm 数据
fos.write(buffer, 0, read);
//写 wav 格式数据
wavFos.write(buffer, 0, read);
}
}
//录制结束
record.stop();
record.release();
fos.flush();
wavFos.flush();
//修改头部的 pcm文件 大小
wavRaf = new RandomAccessFile(wavFile, "rw");
byte[] header = generateWavFileHeader(pcmFile.length(), AUDIO_RATE, record.getChannelCount());
wavRaf.seek(0);
wavRaf.write(header);
} catch (IOException e) {
e.printStackTrace();
Log.d(TAG, "zsr run: " + e.getMessage());
} finally {
CloseUtils.close(fos, wavFos,wavRaf);
}
}
public void done() {
interrupt();
isDone = true;
}
});
可以看到,我们先新建了一个 test.wav 的文件,先写入 头部信息,由于无法确定pcm的大小,先传入0,接着再把 pcm 写入到 wav 文件中,当录制结束,再把 pcm 的文件大小写入header头部即可。
当点击 test.wav 就可以播放啦,就可以听到你自己的骚声音了。
这里使用Android自带的播放器就可以了,当然你也可以使用 MediaPlayer,使用Android 自带的如下:
public void playwav(View view) {
File file = new File(PATH, "test.wav");
if (file.exists()) {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
Uri uri;
//Android 7.0 以上,需要使用 FileProvider
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N) {
uri = FileProvider.getUriForFile(this, "com.zhengsr.videodemo.fileprovider", file);
} else {
uri = Uri.fromFile(file.getAbsoluteFile());
}
intent.setDataAndType(uri, "audio");
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(intent);
} else {
Toast.makeText(this, "请先录制", Toast.LENGTH_SHORT).show();
}
}
注意,如果是7.0 及以上,不能使用显性的 Uri了,所以需要使用FileProvider,它其实也是一个 contentprovider,记得在 AndroidMinefest 也写上:
新建一个 xml,添加一个 file_paths.xml 文件:
上面我们通过转换 PCM 为 WAV ,使其变成能够被多媒体解码识别的文件,但如果我想播放 pcm 文件呢?
这里可以通过 AudioTrack 来实现该功能,它为 Android 管理和播放音频的管理类,允许 PCM 音频通过write() 方法将数据流推送到 AudioTrack 来实现音频的播放。(当然也不局限 pcm,其他音频格式也支持的)
AudioTrack 有两种模式:流模式和静态模式
流模式:在流模式,当使用 write() 方法时,会向 AudioTrack 写入连续的数据流,数据会从 Java 层传输到底层,并排队阻塞等待播放;在播放音频块数据时,流模式比较好用:
静态模式:静态模式,它需要一次性把数据写到buffer中,适合小音频,小延迟的音频播放,常用在UI和游戏中比较实用。
这里,我们粉笔用两种模式去读取刚才的录音。
上面说到,静态模式下,需要一次性把音频数据写到buffer中,所以这个 buffer 肯定不能太大,不过我们刚才的录音不算大,所以可以拿到上面录制的 pcm 来实践。
public void playpcm2(View view) {
try {
File file = new File(PATH, "test.pcm");
InputStream is = new FileInputStream(file);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int len;
//创建一个数组
byte[] buffer = new byte[1024];
while ((len = is.read(buffer)) > 0) {
//把数据存到ByteArrayOutputStream中
baos.write(buffer, 0, len);
}
//拿到音频数据
byte[] bytes = baos.toByteArray();
//双声道
int channelConfig = AudioFormat.CHANNEL_IN_STEREO;
/**
* 设置音频信息属性
* 1.设置支持多媒体属性,比如audio,video
* 2.设置音频格式,比如 music
*/
AudioAttributes attributes = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build();
/**
* 设置音频哥特式
* 1. 设置采样率
* 2. 设置采样位数
* 3. 设置声道
*/
AudioFormat format = new AudioFormat.Builder()
.setSampleRate(AUDIO_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(channelConfig)
.build();
//注意 bufferSizeInBytes 使用音频的大小
AudioTrack audioTrack = new AudioTrack(
attributes,
format,
bytes.length,
AudioTrack.MODE_STATIC, //设置为静态模式
AudioManager.AUDIO_SESSION_ID_GENERATE //音频识别id
);
//一次性写入
audioTrack.write(bytes, 0, bytes.length);
//开始播放
audioTrack.play();
} catch (Exception e) {
e.printStackTrace();
Log.d(TAG, "zsr playpcm2: " + e);
}
}
注释都比较清晰了,先把 test.pcm 文件的数据取出来,放到 ByteArrayOutputStream,然后再通过 audioTrack.write() 写入到 audiotrack中,点击播放即可。
流模式,数据会从 Java 层传输到底层,并排队阻塞等待播放,所以,这里我们开启一个线程,读取数据后等待播放,初始化与 static 模式没啥区别:
public AudioTrackThread() {
int channelConfig = AudioFormat.CHANNEL_IN_STEREO;
/**
* 设置音频信息属性
* 1.设置支持多媒体属性,比如audio,video
* 2.设置音频格式,比如 music
*/
AudioAttributes attributes = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build();
/**
* 设置音频哥特式
* 1. 设置采样率
* 2. 设置采样位数
* 3. 设置声道
*/
AudioFormat format = new AudioFormat.Builder()
.setSampleRate(AUDIO_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(channelConfig)
.build();
//拿到一帧的最小buffer大小
bufferSize = AudioTrack.getMinBufferSize(AUDIO_RATE, channelConfig, AudioFormat.ENCODING_PCM_16BIT);
audioTrack = new AudioTrack(
attributes,
format,
bufferSize,
AudioTrack.MODE_STREAM,
AudioManager.AUDIO_SESSION_ID_GENERATE
);
//播放,等待数据
audioTrack.play();
}
就是初始化 AudioTrack 时,由于是流模式,所以大小只需要设置一帧的最小buffer 即可,然后调用 play() 方法去等待数据,当AudioTrack 的 write() 有数据到来时,就会播放音频:
@Override
public void run() {
super.run();
File file = new File(PATH, "test.pcm");
if (file.exists()) {
FileInputStream fis = null;
try {
fis = new FileInputStream(file);
byte[] buffer = new byte[bufferSize];
int len;
while (!isDone && (len = fis.read(buffer)) > 0) {
// 写数据到 AudioTrack中,等到播放
audioTrack.write(buffer, 0, len);
}
audioTrack.stop();
audioTrack.release();
} catch (Exception e) {
e.printStackTrace();
Log.d(TAG, "zsr run: " + e);
} finally {
CloseUtils.close(fis);
}
}
}
这样,关于 AudioRecord 和 AudioTrack 就学习完啦,后面继续打怪升级。
参考:
https://developer.android.google.cn/reference/kotlin/android/media/AudioTrack?hl=en
https://www.jianshu.com/p/1749d2d43ecb