多媒体开发(11):Android平台上裁剪m4a
Android手机上设置铃声的操作比较灵活,你听到一首喜欢的歌曲,马上就可以对这首歌曲进行裁剪,裁剪到片段后,再通过系统的接口设置为铃声(电话铃声、闹钟铃声等)。前提是,播放这首歌的APP,需要提供裁剪歌曲的功能。
那么,怎么样实现截取音频文件的功能呢?
基于之前的介绍,你可能很自然就想到使用FFmpeg命令来实现,比如:
ffmpeg -ss 10 -i audio.mp3 -t 5 out.mp3
上面的命令,从第10秒开始,提取5秒的片段,于是就成功截取了一个片段。但是,FFmpeg命令在pc上可以很方便地使用,但在手机APP上,就不能直接使用了(其实,也是可以的,可以在APP中直接调用ffmpeg命令,但这个不是这里的重点)。
这里针对Android平台,介绍裁剪音频文件的办法,并且,这里假定原音频文件是m4a封装格式。
本文介绍如何在Android平台上裁剪m4a音频文件,并得到一个音频片段。
实现这个功能,基本有两个方案:
- 一是解码原音频文件,然后提取相应片段,再对这个片段进行编码。
- 二是直接定位到裁剪的起点,提取出片段,再保存成新的音频文件。
相比之下,第一个方案在性能上有更明显的消耗,但这个方案可以通吃各种音频格式(只要能解码,并能最终编码为固定格式即可)。
第二个方案,需要考虑不同格式(包括原音频,以及最终音频的格式)的实现,但在性能上占优,比第一个方案更省时间。
小程这里介绍第二个方案的实现,并且只考虑m4a文件的截取与生成。第二个方案,概括来说,就是m4a格式的解析及m4a文件的生成过程。
(一)m4a介绍
m4a文件,实际是mp4文件(mp4a),一般只存放音频流。m4a是苹果公司起的名字,用来区分带有视频帧的一般的mp4文件。
解析m4a文件格式就是解析mp4文件格式,这对于写文件也是同样的道理。
要截取m4a的片段,有必要先解析m4a文件格式,获取相关信息(比如采样率、声道数、一帧的样本数、总帧数、每一帧的长度、每一帧的偏移等等),而解析文件格式,就需要理解mp4的文件格式。
mp4以atom(或者叫box)构成,所有的数据(包括各种信息以及裸的音频数据)都放在atom中。
每个atom由三个字段组成:
len(整个atom的长度,4Byte)、
type(atom的类型,4Byte)、
data(atom保存的数据)。
atom可以嵌套。
atom的类型有很多,并不是所有类型都要存在才能组成有效的mp4文件。但有几个类型的atom是一定要有的:
ftyp(标识文件格式)、
stts(每一帧的样本数)、
stsz(每一帧的长度)、
stsc(帧与chunk的关系表)、
mvhd(时长等信息)、
mdat(裸数据)、
moov等。
具体的结构(包括每个atom的含意、每个字段的大小与含意)可以查看网络上的资源(最好能看到atom的字段表格)。
比如:
所有atom
atom解释
时长信息
(二)方案实现
第二个方案的实现,可以使用ringdroid这个开源的项目。
ringdroid在git上维护,它最新的版本使用解码再编码的方案,而这个不版本不是本文需要的。怎么办呢?可以找回ringdroid早期的版本,里面有CheapAAC、CheapMP3等,分别对不同格式的音频作处理,并且是直接截取。
CheapAAC的ReadFile完成m4a文件的解析,WriteFile完成新的m4a文件的写入。
CheapAAC还实现了增益的计算,可以用来显示音频的波形图。
对于截取,有几个信息是很重要的:{帧的长度即字节数}、{帧的偏移量},根据这两个集合就可以实现截取。
帧的长度(以及总帧数)在解析stsz时确定,帧的偏移在解析mdat时确定。
你可以详细阅读CheapAAC的代码,来理解截取的过程。小程这里只提一下CheapAAC存在的问题,也是你可能遇到的问题。
(1)不兼容neroAacEnc编码的m4a文件
对于neroAacEnc编码出来的m4a文件,CheapAAC在parseMdat时,不能正常解析裸数据,原因是neroAacEnc在裸数据之前多加了8个字节,这8个字节会使得计算出来的每一帧的偏移都不对,导致后继WriteFile时写出来的每一帧的数据都不对。
可以考虑跳过8个字节来解决这个问题(在判断为nero编码出来的m4a时):
if (mMdatOffset > 0 && mMdatLength > 0) {
final int neroAACFrom = 570;
int neroSkip = 0;
if (mMdatOffset - neroAACFrom > 0) {
FileInputStream cs = new FileInputStream(mInputFile);
cs.skip(mMdatOffset - neroAACFrom);
final int flagSize = 14;
byte[] buffer = new byte[flagSize];
cs.read(buffer, 0, flagSize);
if (buffer[0] == 'N' && buffer[1] == 'e' && buffer[2] == 'r' && buffer[3] == 'o' && buffer[5] == 'A'
&& buffer[6] == 'A' && buffer[7] == 'C' && buffer[9] == 'c' && buffer[10] == 'o'
&& buffer[11] == 'd' && buffer[12] == 'e' && buffer[13] == 'c') {
neroSkip = 8;
}
cs.close();
}
stream = new FileInputStream(mInputFile);
mMdatOffset += neroSkip; // slip 8 Bytes if need
stream.skip(mMdatOffset);
mOffset = mMdatOffset;
parseMdat(stream, mMdatLength);
} else {
throw new java.io.IOException("Didn't find mdat");
}
(2)截取片段的时长不对
截取出来的片段的时长没有重新设置,仍使用原文件的时长。
可以在WriteFile里面重新设置片段的时长,但要注意,如果最终是使用mediaplayer来播放,则不能加以下代码,因为mediaplayer解码的处理跟FFmpeg等不一致。如果最终是交给FFmpeg等来解码,则需要重新设置片段的时长。
// 在写完stco之后,增加:
long time = System.currentTimeMillis() / 1000;
time += (66 * 365 + 16) * 24 * 60 * 60; // number of seconds between 1904 and 1970
byte[] createTime = new byte[4];
createTime[0] = (byte)((time >> 24) & 0xFF);
createTime[1] = (byte)((time >> 16) & 0xFF);
createTime[2] = (byte)((time >> 8) & 0xFF);
createTime[3] = (byte)(time & 0xFF);
long numSamples = 1024 * numFrames;
long durationMS = (numSamples * 1000) / mSampleRate;
if ((numSamples * 1000) % mSampleRate > 0) { // round the duration up.
durationMS++;
}
byte[] numSaplesBytes = new byte[] {
(byte)((numSamples >> 26) & 0XFF),
(byte)((numSamples >> 16) & 0XFF),
(byte)((numSamples >> 8) & 0XFF),
(byte)(numSamples & 0XFF)
};
byte[] durationMSBytes = new byte[] {
(byte)((durationMS >> 26) & 0XFF),
(byte)((durationMS >> 16) & 0XFF),
(byte)((durationMS >> 8) & 0XFF),
(byte)(durationMS & 0XFF)
};
int type = kMDHD;
Atom atom = mAtomMap.get(type);
if (atom == null) {
atom = new Atom();
mAtomMap.put(type, atom);
}
atom.data = new byte[] {
0, // version, 0 or 1
0, 0, 0, // flag
createTime[0], createTime[1], createTime[2], createTime[3], // creation time.
createTime[0], createTime[1], createTime[2], createTime[3], // modification time.
0, 0, 0x03, (byte)0xE8, // timescale = 1000 => duration expressed in ms. 1000为单位
durationMSBytes[0], durationMSBytes[1], durationMSBytes[2], durationMSBytes[3], // duration in ms.
0, 0, // languages
0, 0 // pre-defined;
};
atom.len = atom.data.length + 8;
type = kMVHD;
atom = mAtomMap.get(type);
if (atom == null) {
atom = new Atom();
mAtomMap.put(type, atom);
}
atom.data = new byte[] {
0, // version, 0 or 1
0, 0, 0, // flag
createTime[0], createTime[1], createTime[2], createTime[3], // creation time.
createTime[0], createTime[1], createTime[2], createTime[3], // modification time.
0, 0, 0x03, (byte)0xE8, // timescale = 1000 => duration expressed in ms. 1000为单位
durationMSBytes[0], durationMSBytes[1], durationMSBytes[2], durationMSBytes[3], // duration in ms.
0, 1, 0, 0, // rate = 1.0
1, 0, // volume = 1.0
0, 0, // reserved
0, 0, 0, 0, // reserved
0, 0, 0, 0, // reserved
0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // unity matrix for video, 36bytes
0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0x40, 0, 0, 0,
0, 0, 0, 0, // pre-defined
0, 0, 0, 0, // pre-defined
0, 0, 0, 0, // pre-defined
0, 0, 0, 0, // pre-defined
0, 0, 0, 0, // pre-defined
0, 0, 0, 0, // pre-defined
0, 0, 0, 2 // next track ID, 4bytes
};
atom.len = atom.data.length + 8;
(三)其它概念
在CheapAAC中涉及到一些音频概念,小程简单解释一下。
track,即轨道(音频或视频),也叫流;
sample,理解为帧(跟样本的概念不同),对于aac来说一帧包括的样本数是固定的,都为1024个;
chunk,即块,是帧的集合。
neroAcc命令使用示例:
ffmpeg -i "1.mp3" -f wav - | neroAacEnc -br 32000 -ignorelength -if - -of "1.m4a"
-br 码率
-lc/-he/-hev2 编码方式,默认是he
-if 输入文件
-of 输出文件
-ignorelength 在以其它输出(如ffmpeg)作为输入时使用
至此,在Android平台裁剪m4a的实现就介绍完毕了。
总结一下,本文介绍了在Android平台上,使用CheapAAC来裁剪m4a得到片段文件的实现办法,同时也介绍了m4a结构的概念,以及可能遇到的问题。
hello