本文已授权微信公众号:鸿洋(hongyangAndroid)在微信公众号平台原创首发。
app&技术介绍
该app使用了MD规范,界面风格简洁,功能上mp3剪切铃声制作,实用性比较强。
功能上虽然简洁,但是技术上该项目“麻雀虽小,五脏俱全”。
下面从技术层面上做一些简单介绍:
- 首页使用了CoordinatorLayout+AppBarLayout+DrawerLayout+NavigationView的经典MD设计风格。
- 项目整体采用了MVP+databinding+rxjava2+rxandroid2+dagger2框架设计,数据缓存使用了greendao。
- 音频频谱的绘制主要是通过Visualizer中获取到的波形数据来进行绘制。
- 剪切功能上,mp3剪切核心功能使用了jaudiotagger jar包获取mp3元数据获取字节位置并进行文件io操作生成目标文件。此功能作为重点,本文后续会做详细的说明。
- 动画方面,欢迎页使用了lottie动画,如感兴趣可以看这篇博客做了详尽的步骤介绍,制作lottie动画并应用到android项目。 项目中文件选择页以及关于页面使用了属性动画和属性动画组件AVLoadingIndicatorView。
- 自定义控件,范围选取控件CustomRangeSeekBar,不是本文重点可以看之前的博文android 自定义范围选取控件CustomRangeSeekBar。
使用说明+gif
Step1. 选择mp3文件
[图片上传失败...(image-ab3be8-1515397778291)]
Step2. 通过滑块选择剪切范围然后点击剪切按钮
[图片上传失败...(image-b30645-1515397778291)]
Tips:主界面上可以看到三个按钮,从左到右的功能分别为:
- 播放\暂停
- 切换播放的滑块(切换当前播放的位置,前滑块or后滑块)
- 音乐剪切
mp3剪切实现思想
实现思想主要有两点
- 获取mp3开始时间(要剪切的开始时间)所在的文件字节位置及结束时间所在文件的字节位置
- 根据开始时间的字节位置和结束时间的字节位置结合源文件生成我们的目标文件
mp3剪切实现技术点
那么如何来获取mp3开始时间所在文件的字节位置呢?
这里用到了jaudiotagger.jar。
它的主页是这样描述它的
Jaudiotagger is a Java API for audio metatagging. Both a common API and format specific APIs are available, currently supports reading and writing metadata for:Mp3、Flac、OggVorbis、Mp4、Aiff、Wav、Wma、Dsf
它是一个音频元标记的java库,可以支持mp3等特定格式进行读写元数据操作。
mp3剪切实现细节:
一、我们要做的事通过Jaudiotagger获取到mp3的元数据,通过元数据取到mp3的首帧字节位置以及比特率。然后根据首帧字节位置以及比特率和开始时间可以其对应文件的字节位置。最后得到开始字节位置和结束字节位置。
- 获取mp3元数据
MP3File mp3 = new MP3File(this.mp3File);
//获取mp3的元数据
MP3AudioHeader header = (MP3AudioHeader) mp3.getAudioHeader();
- 根据元数据获取mp3比特率
//根据元数据获取比特率
long bitRateKbps = header.getBitRateAsNumber();
可能你会问,什么是比特率?
比特率是每秒传输的比特(bit)数
来看我们取mp3比特率的方法看注释
long bitRate = header.getBitRateAsNumber();
看该方法源码注释如下:
/**
*
* @return bitrate in kbps, no indicator is provided as to
* whether or not it is vbr
*/
public long getBitRateAsNumber()
{
return bitrate;
}
通过注释得知,此方法返回的比特率单位为kbps(每秒千字节) ,而我们需要的比特率的单位是(每毫秒位),下一步进行单位转换计算。
- 转换比特率
这里我们需要换算它为每毫秒位数,1字节是8位,1秒是1000毫秒,千字节是1024字节,那么转换后算到的也就是getBitRateAsNumber() *1024L / 8L / 1000L。代码如下:
//计算出开始字节位置
long bitRatebpm = bitRateKbps *1024L / 8L / 1000L * beginTime;
- 计算开始字节
这个值就是开始时间所在文件的字节位置吗?当然不是,我们的mp3文件当中并不只包含音乐的数据,还包含有音乐的信息头数据。同样我们可以从头信息中取到我们的mp3首帧字节位置。首帧字节位置+每毫秒位为单位比特率,就是我们要的mp3开始字节位置了。代码如下:
long firstFrameByte = header.getMp3StartByte();
long beginByte = firstFrameByte + beginBitRateBpm;
- 计算结束字节位置
同理, 利用上面计算出来的开始字节beginType+时间差(剪切结束时间-开始时间)的比特率(单位为每毫秒位)就可以计算出结束的字节位置了,代码入下:
//计算出结束字节位置
long endByte = beginByte + convertKbpsToBpm(bitRateKbps) * (endTime - beginTime);
long endIndex(截取结束字节位置) = beginIndex(截取开始字节位置) + bitRate *1024L / 8L / 1000L(比特率每毫秒位) * (endTime - beginTime)(截取的时长毫秒单位);
二、 有了开始时间的字节位置和结束时间的字节位置,那我们就可以结合源文件生成我们的目标文件拉。读写文件我们可以使用RandomAccessFile实现随机的读写操作,通过RandomAccessFile.seek()
方法调到指定位置。
- 问题&解决方案
如果我们要操作的mp3文件很大,比如我们截取的字节大小为100MB,这时候我们的app就会因为OOM直接crash掉了。
这里我的解决方案是通过一个缓存数组来限制每次读写的数据大小,每次操作指定大小的数据,这样无论文件多大,我们都不会出现OOM问题啦。
- 首先我们写一个工具方法,以缓存的方式来生成目标文件,源文件读取指定大小的数据读取写入到目标文件,代码如下:
/**
*
*
* @param targetFile 输出的文件
* @param sourceFile 读取的文件
* @param buffer 输入输出的缓存容器
* @param offset 读入文件时seek的偏移值
*/
private static void writeSourceToTargetFile(RandomAccessFile targetFile, RandomAccessFile sourceFile,
byte buffer[], long offset) throws Exception {
sourceFile.seek(offset);
sourceFile.read(buffer);
long fileLength = targetFile.length();
// 将写文件指针移到文件尾。
targetFile.seek(fileLength);
targetFile.write(buffer);
}
- 需要根据需要剪切文件的字节大小,分别考虑小于缓存以及大于等于缓存的情况,分别进行操作。代码如下:
private static void writeSourceToTargetFileWithBuffer(RandomAccessFile targetFile, RandomAccessFile sourceFile,
long totalSize, long offset) throws Exception {
//缓存大小,每次写入指定数据防止内存泄漏
int buffersize = BUFFER_SIZE;
long count = totalSize / buffersize;
if (count <= 1) {
//文件总长度小于小于缓存大小情况
writeSourceToTargetFile(targetFile, sourceFile, new byte[(int) totalSize], offset);
} else {
//计算出整除后剩余的数据数
long remainSize = totalSize % buffersize;
byte data[] = new byte[buffersize];
//读入文件时seek的偏移量
for (int i = 0; i < count; i++) {
writeSourceToTargetFile(targetFile, sourceFile, data, offset);
offset += BUFFER_SIZE;
}
//写入剩余数据
if (remainSize > 0) {
writeSourceToTargetFile(targetFile, sourceFile, new byte[(int) remainSize], offset);
}
}
}
- 最后要考虑不但要讲mp3乐音帧相关数据写入, 还要讲头信息写入进去,代码如下:
/**
* 生成目标mp3文件
*
* @param targetFile
* @param beginByte
* @param endByte
* @param firstFrameByte
* @throws Exception
*/
private void generateTargetMp3File(RandomAccessFile targetFile,
long beginByte, long endByte, long firstFrameByte) throws Exception {
RandomAccessFile sourceFile = new RandomAccessFile(mSourceMp3File, "rw");
try {
//write mp3 header info
writeSourceToTargetFileWithBuffer(targetFile, sourceFile, firstFrameByte, 0);
//write mp3 frame info
int size = (int) (endByte - beginByte);
writeSourceToTargetFileWithBuffer(targetFile, sourceFile, size, beginByte);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (sourceFile != null)
sourceFile.close();
}
}
到这里就结束啦,能力有限,写的不对好的地方,请多提意见。
项目计划讲一直进行维护升级,谢谢您的关注!!!
源码&apk
代码已上传Github
github APK下载
蒲公英 APK下载
单元测试
如果没有手机或其他原因不方便使用app。项目中提供了单元测试和mp3文件,可以通过单元测试来体验mp3剪切功能。
- laozi.mp3是源mp3
- test.mp3是运行完单元测试,生成的mp3文件。
-
startTime、endTime为剪切的开始时间及结束时间
后续
博文被鸿洋发布后,github受到了很多关注,有人提了issue, “部分MP3文件剪切失败”。原因是之前的mp3剪切中只是对恒定比特率做了支持,在可变比特率那一块逻辑没有实现,直接抛了异常。
public void generateNewMp3ByTime(String targetFileStr, long beginTime, long endTime) throws Exception {
MP3File mp3 = new MP3File(this.mSourceMp3File);
MP3AudioHeader header = (MP3AudioHeader) mp3.getAudioHeader();
if (header.isVariableBitRate()) {
throw new Exception("This is nonsupport variableBitRate!!!");
} else {
...
}
}
可以看到之前版本并没有支持可变比特率。这里讲述一下对实现可变比特率mp3剪切的实现思想。
- 重要的一点:每帧的时间是相等的
- 公式:每帧比特大小 = ( 每帧采样次数 × 比特率(bit/s) ÷ 8 ÷采样率) + Padding
- mp3总比特大小 = mp3帧数*每帧比特大小
- 开始时间占总时长比例 = 开始时间/mp3总时长 、结束时间占总时长比例 = 结束时间/mp3总时长
- 开始时间对应比特 = mp3总比特大小 *开始时间占总时长比例、结束时间对应比特 = mp3总比特大小*结束时间占总时长比例
上代码:
/**
* 根据时间和源文件生成MP3文件 (源文件mp3 比特率为vbr可变比特率)
*
* @param header
* @param targetFileStr
* @param beginTime
* @param endTime
* @throws IOException
*/
private void generateMp3ByTimeAndVBR(MP3AudioHeader header, String targetFileStr, long beginTime, long endTime) throws IOException {
long frameCount = header.getNumberOfFrames();
int sampleRate = header.getSampleRateAsNumber();
int sampleCount = 1152;//header.getNoOfSample();
int paddingLength = header.isPadding() ? 1 : 0;
//帧大小 = ( 每帧采样次数 × 比特率(bit/s) ÷ 8 ÷采样率) + Padding
//getBitRateAsNumber 返回的为kbps 所以要*1000
float frameSize = sampleCount * header.getBitRateAsNumber() / 8f / sampleRate * 1000 + paddingLength;
//获取音轨时长
int trackLengthMs = header.getTrackLength() * 1000;
//开始时间与总时间的比值
float beginRatio = (float) beginTime / (float) trackLengthMs;
//结束时间与总时间的比值
float endRatio = (float) endTime / (float) trackLengthMs;
long startFrameSize = (long) (beginRatio * frameCount * frameSize);
long endFrameSize = (long) (endRatio * frameCount * frameSize);
//返回音乐数据的第一个字节
long firstFrameByte = header.getMp3StartByte();
generateTargetMp3File(targetFileStr, startFrameSize, endFrameSize, firstFrameByte);
}
感谢
- jaudiotagger
- RXJava
- RxAndroid
- greendao
- StatusBarUtil
- Dagger2
- PermissionsDispatcher
- logger
- AVLoadingIndicatorView
- baseAdapter
- CustomRangeSeekBar
License
Mp3Cutter is under CC BY-NC-SA license.