作者:郑童宇
GitHub:https://github.com/CrazyZty
1.前言
音频合成在现实生活中应用广泛,在网上可以搜索到不少相关的讲解和代码实现,但个人感觉在网上搜索到的音频合成相关文章的讲解都并非十分透彻,故而写下本篇博文,计划通过讲解如何使用代码实现音频合成功能从而将本人对音频合成的理解阐述给各位,力图读完的各位可以对音频合成整体过程有一个清晰的了解。本篇博文以Java为示例语言,以Android为示例平台。
本篇博文着力于讲解音频合成实现原理与过程中的细节和潜在问题,目的是让各位不被编码语言所限制,在本质上理解如何实现音频合成的功能。
2.音频合成
2.1.功能简介
本次实现的音频合成功能参考"唱吧"的音频合成,功能流程是:录音生成PCM文件,接着根据录音时长对背景音乐文件进行解码加裁剪,同时将解码后的音频调制到与录音文件相同的采样率,采样点字节数,声道数,接着根据指定系数对两个音频文件进行音量调节并合成为PCM文件,最后进行压缩编码生成MP3文件。2.2.功能实现
2.2.1.录音
录音功能生成的目标音频格式是PCM格式,对于PCM的定义,维基百科上是这么写到的:"Pulse-code modulation (PCM) is a method used to digitally represent sampled analog signals. It is the standard form of digital audio in computers, Compact Discs, digital telephony and other digital audio applications. In a PCM stream, the amplitude of the analog signal is sampled regularly at uniform intervals, and each sample is quantized to the nearest value within a range of digital steps.",大致意思是PCM是用来采样模拟信号的一种方法,是现在数字音频应用中数字音频的标准格式,而PCM采样的原理,是均匀间隔的将模拟信号的振幅量化成指定数据范围内最贴近的数值。PCM文件存储的数据是不经压缩的纯音频数据,当然只是这么说可能有些抽象,我们拉上大家熟知的MP3文件进行对比,MP3文件存储的是压缩后的音频,PCM与MP3两者之间的关系简单说就是:PCM文件经过MP3压缩算法处理后生成的文件就是MP3文件。我们简单比较一下双方存储所消耗的空间,1分钟的每采样点16位的双声道的44.1kHz采样率PCM文件大小为:1*60*16/8*2*44.1*1000/1024=10335.9375KB,约为10MB,而对应的128kps的MP3文件大小仅为1MB左右,既然PCM文件占用存储空间这么大,我们是不是应该放弃使用PCM格式存储录音,恰恰相反,注意第一句话:"PCM文件存储的数据是不经压缩的纯音频数据",这意味只有PCM格式的音频数据是可以用来直接进行声音处理,例如进行音量调节,声音滤镜等操作,相对的其他的音频编码格式都是必须解码后才能进行处理(PCM编码的WAV文件也得先读取文件头),当然这不代表PCM文件就好用,因为没有文件头,所以进行处理或者播放之前我们必须事先知道PCM文件的声道数,采样点字节数,采样率,编码大小端,这在大多数情况下都是不可能的,事实上就我所知没有播放器是直接支持PCM文件的播放。不过现在录音的各项系数都是我们定义的,所以我们就不用担心这个问题。
背景知识了解这些就足够了,下面我给出实现代码,综合代码讲解实现过程。
if (recordVoice) {
audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
Constant.RecordSampleRate, AudioFormat.CHANNEL_IN_MONO,
pcmFormat.getAudioFormat(), audioRecordBufferSize);
try {
audioRecord.startRecording();
} catch (Exception e) {
NoRecordPermission();
continue;
}
BufferedOutputStream bufferedOutputStream = FileFunction
.GetBufferedOutputStreamFromFile(recordFileUrl);
while (recordVoice) {
int audioRecordReadDataSize =
audioRecord.read(audioRecordBuffer, 0, audioRecordBufferSize);
if (audioRecordReadDataSize > 0) {
calculateRealVolume(audioRecordBuffer, audioRecordReadDataSize);
if (bufferedOutputStream != null) {
try {
byte[] outputByteArray = CommonFunction
.GetByteBuffer(audioRecordBuffer,
audioRecordReadDataSize, Variable.isBigEnding);
bufferedOutputStream.write(outputByteArray);
} catch (IOException e) {
e.printStackTrace();
}
}
} else {
NoRecordPermission();
continue;
}
}
if (bufferedOutputStream != null) {
try {
bufferedOutputStream.close();
} catch (Exception e) {
LogFunction.error("关闭录音输出数据流异常", e);
}
}
audioRecord.stop();
audioRecord.release();
audioRecord = null;
}
录音的实际实现和控制代码较多,在此仅抽出核心的录音代码进行讲解。在此为获取录音的原始数据,我使用了Android原生的AudioRecord,其他的平台基本也会提供类似的工具类。这段代码实现的功能是当录音开始后,应用会根据设定的采样率和声道数以及采样字节数来不断从MIC中获取原始的音频数据,然后将获取的音频数据写入到指定文件中,直至录音结束。这段代码逻辑比较清晰的,我就不过多讲解了。潜在问题的话,手机平台上是需要申请录音权限的,如果没有录音权限就无法生成正确的录音文件。
2.2.2.解码与裁剪背景音乐
如前文所说,除了PCM格式以外的所有音频编码格式的音频都必须解码后才可以处理,因此要让背景音乐参与合成必须事先对背景音乐进行解码,同时为减少合成的MP3文件的大小,需要根据录音时长对解码的音频文件进行裁剪。本节不会详细解释解码算法,因为每个平台都会有对应封装的工具类,直接使用即可。背景知识先讲这些,本次功能实现过程中的潜在问题较多,下面我给出实现代码,综合代码讲解实现过程。
private boolean decodeMusicFile(String musicFileUrl, String decodeFileUrl, int startSecond, int endSecond,
Handler handler,
DecodeOperateInterface decodeOperateInterface) {
int sampleRate = 0;
int channelCount = 0;
long duration = 0;
String mime = null;
MediaExtractor mediaExtractor = new MediaExtractor();
MediaFormat mediaFormat = null;
MediaCodec mediaCodec = null;
try {
mediaExtractor.setDataSource(musicFileUrl);
} catch (Exception e) {
LogFunction.error("设置解码音频文件路径错误", e);
return false;
}
mediaFormat = mediaExtractor.getTrackFormat(0);
sampleRate = mediaFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE) ?
mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) : 44100;
channelCount = mediaFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT) ?
mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) : 1;
duration = mediaFormat.containsKey(MediaFormat.KEY_DURATION) ? mediaFormat.getLong(MediaFormat.KEY_DURATION) : 0;
mime = mediaFormat.containsKey(MediaFormat.KEY_MIME) ? mediaFormat.getString(MediaFormat.KEY_MIME) : "";
LogFunction.log("歌曲信息",
"Track info: mime:" + mime + " 采样率sampleRate:" + sampleRate + " channels:" +
channelCount + " duration:" + duration);
if (CommonFunction.isEmpty(mime) || !mime.startsWith("audio/")) {
LogFunction.error("解码文件不是音频文件", "mime:" + mime);
return false;
}
if (mime.equals("audio/ffmpeg")) {
mime = "audio/mpeg";
mediaFormat.setString(MediaFormat