开源一款超级好用的mp3剪切器app

本文已授权微信公众号:鸿洋(hongyangAndroid)在微信公众号平台原创首发。

app&技术介绍

该app使用了MD规范,界面风格简洁,功能上mp3剪切铃声制作,实用性比较强。
功能上虽然简洁,但是技术上该项目“麻雀虽小,五脏俱全”。</br>
下面从技术层面上做一些简单介绍:</br>

  • 首页使用了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的首帧字节位置以及比特率。然后根据首帧字节位置以及比特率和开始时间可以其对应文件的字节位置。最后得到开始字节位置和结束字节位置。

  1. 获取mp3元数据
MP3File mp3 = new MP3File(this.mp3File);
//获取mp3的元数据
MP3AudioHeader header = (MP3AudioHeader) mp3.getAudioHeader(); 
  1. 根据元数据获取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. 转换比特率

    这里我们需要换算它为每毫秒位数,1字节是8位,1秒是1000毫秒,千字节是1024字节,那么转换后算到的也就是getBitRateAsNumber() *1024L / 8L / 1000L。代码如下:
//计算出开始字节位置
long bitRatebpm = bitRateKbps *1024L / 8L / 1000L * beginTime; 
  1. 计算开始字节

    这个值就是开始时间所在文件的字节位置吗?当然不是,我们的mp3文件当中并不只包含音乐的数据,还包含有音乐的信息头数据。同样我们可以从头信息中取到我们的mp3首帧字节位置。首帧字节位置+每毫秒位为单位比特率,就是我们要的mp3开始字节位置了。代码如下:
long firstFrameByte = header.getMp3StartByte();
long beginByte = firstFrameByte + beginBitRateBpm;
  1. 计算结束字节位置

    同理, 利用上面计算出来的开始字节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问题啦。
  1. 首先我们写一个工具方法,以缓存的方式来生成目标文件,源文件读取指定大小的数据读取写入到目标文件,代码如下:
  /**
     * 
     *
     * @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);
    }
  1. 需要根据需要剪切文件的字节大小,分别考虑小于缓存以及大于等于缓存的情况,分别进行操作。代码如下:
 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);
            }
        }
    }
  1. 最后要考虑不但要讲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

单元测试

如果没有手机或其他原因不方便使用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);
    }

感谢

License

Mp3Cutter is under CC BY-NC-SA license.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,881评论 4 368
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,052评论 1 301
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,598评论 0 250
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,407评论 0 217
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,823评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,872评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,037评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,778评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,505评论 1 247
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,745评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,233评论 1 264
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,568评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,231评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,141评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,939评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,954评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,784评论 2 275