本文已授權微信公眾號:鴻洋(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檔案
Step2. 通過滑塊選擇剪下範圍然後點選剪下按鈕
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
單元測試
如果沒有手機或其他原因不方便使用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.