提供一個音頻轉碼服務,主要是利用ffmpeg實現轉碼,利用java web對外提供http服務接口java
音頻轉碼服務算是比較基礎的了,以前一直沒作,最近有個需求背景,是將微信的amr格式音頻,轉換爲mp3格式,不然h5頁面的音頻將沒法播放ios
出於這個轉碼的場景,順帶着搭建一個多媒體處理服務應用(目標是圖片的基本操做,音頻、視頻的經常使用操做等)git
擬採用的技術github
Runtime.getRuntime().exec(cmd);
本篇重點web
使用ffmpeg提供音頻轉碼的服務接口緩存
#!/bin/bash ## download ffmpge cmd wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-64bit-static.tar.xz ## exact package xz -d ffmpeg-release-64bit-static.tar.xz tar -xvf ffmpeg-release-64bit-static.tar mv ffmpeg-release-64bit-static ffmpeg cd ffmpeg
進入下載的目錄,內部有一個 ffmpeg
的可執行文件,主要利用它來實現音頻轉碼bash
./ffmpeg -version
查看ffmpeg的版本微信
轉碼測試網絡
先準備一個測試文件 test.amr (不要直接從微信的文件夾中獲取語音文件,微信作過處理,非標準的amr文件,若是手頭沒有,可使用這個測試 amrTestAudio.amr )數據結構
轉碼命令
./ffmpeg -i test.amr test.mp3
而後能夠看到新增一個mp3文件,而後用播放器,打開確認是否有問題
使用Spring-Boot 搭建一個Web工程
直接用官網的建立方式便可,這裏不作敘述
java利用命令行操做方式調用ffmpeg,實現音頻轉碼,一個最簡單的實現以下
// cmd 爲待執行的命令行 String cmd = "ffmpeg -i src.amr test.mp3"; Process process = Runtime.getRuntime().exec(cmd); process.waitFor();
就這樣就能夠了麼? 顯然並無這麼簡陋,先談談直接這麼用有什麼問題
出於以上幾點,着手實現咱們的目標,先看最後的測試case:
@Test public void testAudioParse() { String[] arys = new String[]{ "test.amr", "/Users/yihui/GitHub/quick-media/common/src/test/resources/test.amr", "http://s11.mogucdn.com/mlcdn/c45406/170713_3g25ec8fak8jch5349jd2dcafh61c.amr" }; for (String src : arys) { try { String output = AudioWrapper.of(src) .setOutputType("mp3") .asFile(); System.out.println(output); } catch (Exception e) { e.printStackTrace(); } } }
從使用的角度來看就非常簡潔了,輸出結果以下
/Users/yihui/GitHub/quick-media/common/target/test-classes/test_out.mp3 /Users/yihui/GitHub/quick-media/common/src/test/resources/test_out.mp3 /tmp/audio/170713_3g25ec8fak8jch5349jd2dcafh61c_out.mp3
前面準備作好,測試的case也提早放出,那麼能夠看下如何實現了
AudioOptions
保存最終命令的配置相關信息,用於生成最終的執行命令行
對於音頻轉碼,最終的cmd命令應該是: ffmpeg -i source.amr output.mp3
,所以咱們須要的參數有
public class AudioOptions { private String cmd = "ffmpeg -i "; private String src; private String dest; private Map<String, Object> options = new HashMap<>(); public String getCmd() { return cmd; } public AudioOptions setCmd(String cmd) { this.cmd = cmd; return this; } public String getSrc() { return src; } public AudioOptions setSrc(String src) { this.src = src; return this; } public String getDest() { return dest; } public AudioOptions setDest(String dest) { this.dest = dest; return this; } public Map<String, Object> getOptions() { return options; } public AudioOptions addOption(String conf, Object value) { options.put("-" + conf, value); return this; } public String build() { StringBuilder builder = new StringBuilder(this.cmd); builder.append(" ").append(this.src); for (Map.Entry<String, Object> entry : options.entrySet()) { builder.append(entry.getKey().startsWith("-") ? " " : " -") .append(entry.getKey()) .append(" ").append(entry.getValue()); } builder.append(" ").append(this.dest); return builder.toString(); } }
AudioWrapper
對外暴露的接口,全部音頻相關的操做都經過它來執行,正如上面的測試用例
命令行調用,一般可選參數比較多,因此咱們採用Builder模式來作參數的設置
源碼以下
@Slf4j public class AudioWrapper { public static Builder<String> of(String str) { Builder<String> builder = new Builder<>(); return builder.setSource(str); } public static Builder<URI> of(URI uri) { Builder<URI> builder = new Builder<>(); return builder.setSource(uri); } public static Builder<InputStream> of(InputStream inputStream) { Builder<InputStream> builder = new Builder<>(); return builder.setSource(inputStream); } private static void checkNotNull(Object obj, String msg) { if (obj == null) { throw new IllegalStateException(msg); } } private static boolean run(String cmd) { try { return ProcessUtil.instance().process(cmd); } catch (Exception e) { log.error("operate audio error! cmd: {}, e: {}", cmd, e); return false; } } public static class Builder<T> { /** * 輸入源 */ private T source; /** * 源音頻格式 */ private String inputType; /** * 輸出音頻格式 */ private String outputType; /** * 命令行參數 */ private Map<String, Object> options = new HashMap<>(); /** * 臨時文件信息 */ private FileUtil.FileInfo tempFileInfo; private String tempOutputFile; public Builder<T> setSource(T source) { this.source = source; return this; } public Builder<T> setInputType(String inputType) { this.inputType = inputType; return this; } public Builder<T> setOutputType(String outputType) { this.outputType = outputType; return this; } public Builder<T> addOption(String conf, Object val) { this.options.put(conf, val); return this; } private String builder() throws Exception { checkNotNull(source, "src file should not be null!"); checkNotNull(outputType, "output Audio type should not be null!"); tempFileInfo = FileUtil.saveFile(source, inputType); tempOutputFile = tempFileInfo.getPath() + "/" + tempFileInfo.getFilename() + "_out." + outputType; return new AudioOptions().setSrc(tempFileInfo.getAbsFile()) .setDest(tempOutputFile) .addOption("y", "") // 覆蓋寫 .addOption("write_xing", 0) // 解決mac/ios 顯示音頻時間不對的問題 .addOption("loglevel", "quiet") // 不輸出日誌 .build(); } public InputStream asStream() throws Exception { String output = asFile(); if (output == null) { return null; } return new FileInputStream(new File(output)); } public String asFile() throws Exception { String cmd = builder(); return !run(cmd) ? null : tempOutputFile; } } }
上面的邏輯仍是比較清晰的,可是有幾個地方須要注意
tempFileInfo = FileUtil.saveFile(source, inputType);
new AudioOptions().setSrc(tempFileInfo.getAbsFile()) .setDest(tempOutputFile) .addOption("y", "") // 覆蓋寫 .addOption("write_xing", 0) // 解決mac/ios 顯示音頻時間不對的問題 .addOption("loglevel", "quiet") // 不輸出日誌 .build();
private static boolean run(String cmd)
FileUtil
這個工具類的目的比較清晰, 將源文件保存到指定的臨時目錄下,根據咱們支持的三種方式,進行區分處理
咱們定義一個數據結構 FileInfo 保存文件名相關信息
@Getter @Setter @ToString @NoArgsConstructor @AllArgsConstructor public static class FileInfo { /** * 文件所在的目錄 */ private String path; /** * 文件名 (不包含後綴) */ private String filename; /** * 文件類型 */ private String fileType; public String getAbsFile() { return path + "/" + filename + "." + fileType; } }
根據輸入,選擇不一樣的實現方式保存,並返回文件信息
public static <T> FileInfo saveFile(T src, String inputType) throws Exception { if (src instanceof String) { // 給的文件路徑,區分三中,本地絕對路徑,相對路徑,網絡地址 return saveFileByPath((String) src); } else if (src instanceof URI) { // 網絡資源文件時,須要下載到本地臨時目錄下 return saveFileByURI((URI) src); } else if (src instanceof InputStream) { // 輸入流保存在到臨時目錄 return saveFileByStream((InputStream) src, inputType); } else { throw new IllegalStateException("save file parameter only support String/URI/InputStream type! but input type is: " + (src == null ? null : src.getClass())); } }
三種路徑的區分,對於http的格式,直接走URI輸入源的方式
相對路徑時,須要優先獲取文件的絕對路徑
/** * 根據path路徑 生成源文件信息 * * @param path * @return * @throws Exception */ private static FileInfo saveFileByPath(String path) throws Exception { if (path.startsWith("http")) { return saveFileByURI(URI.create(path)); } String tmpAbsFile; if (path.startsWith("/")) { // 絕對路徑 tmpAbsFile = path; } else { // 相對路徑轉絕對路徑 tmpAbsFile = FileUtil.class.getClassLoader().getResource(path).getFile(); } // 根據絕對路徑,解析 目錄 + 文件名 + 文件後綴 return parseAbsFileToFileInfo(tmpAbsFile); } /** * 根據絕對路徑解析出 目錄 + 文件名 + 文件後綴 * * @param absFile 全路徑文件名 * @return */ public static FileInfo parseAbsFileToFileInfo(String absFile) { FileInfo fileInfo = new FileInfo(); extraFilePath(absFile, fileInfo); extraFileName(fileInfo.getFilename(), fileInfo); return fileInfo; } /** * 根據絕對路徑解析 目錄 + 文件名(帶後綴) * * @param absFilename * @param fileInfo */ private static void extraFilePath(String absFilename, FileInfo fileInfo) { int index = absFilename.lastIndexOf("/"); if (index < 0) { fileInfo.setPath(TEMP_PATH); fileInfo.setFilename(absFilename); } else { fileInfo.setPath(absFilename.substring(0, index)); fileInfo.setFilename(index + 1 == absFilename.length() ? "" : absFilename.substring(index + 1)); } } /** * 根據帶後綴文件名解析 文件名 + 後綴 * * @param fileName * @param fileInfo */ private static void extraFileName(String fileName, FileInfo fileInfo) { int index = fileName.lastIndexOf("."); if (index < 0) { fileInfo.setFilename(fileName); fileInfo.setFileType(""); } else { fileInfo.setFilename(fileName.substring(0, index)); fileInfo.setFileType(index + 1 == fileName.length() ? "" : fileName.substring(index + 1)); } }
網絡資源,須要先把文件下載過來,因此就須要一個下載的工具類
一個很是初級的下載工具類: HttpUtil.java
@Slf4j public class HttpUtil { public static InputStream downFile(String src) throws IOException { return downFile(URI.create(src)); } /** * 從網絡上下載文件 * * @param uri * @return * @throws IOException */ public static InputStream downFile(URI uri) throws IOException { HttpResponse httpResponse; try { Request request = Request.Get(uri); HttpHost httpHost = URIUtils.extractHost(uri); if (StringUtils.isNotEmpty(httpHost.getHostName())) { request.setHeader("Host", httpHost.getHostName()); } request.addHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"); httpResponse = request.execute().returnResponse(); } catch (Exception e) { log.error("遠程請求失敗,url=" + uri, e); throw new FileNotFoundException(); } int code = httpResponse.getStatusLine().getStatusCode(); if (code != 200) { throw new FileNotFoundException(); } return httpResponse.getEntity().getContent(); } }
具體的保存代碼,比較簡單,從網絡上下載的InputStream直接轉換第三種使用方式便可
/** * 下載遠程文件, 保存到臨時目錄, 病生成文件信息 * * @param uri * @return * @throws Exception */ private static FileInfo saveFileByURI(URI uri) throws Exception { String path = uri.getPath(); if (path.endsWith("/")) { throw new IllegalArgumentException("a select uri should be choosed! but input path is: " + path); } int index = path.lastIndexOf("/"); String filename = path.substring(index + 1); FileInfo fileInfo = new FileInfo(); extraFileName(filename, fileInfo); fileInfo.setPath(TEMP_PATH); try { InputStream inputStream = HttpUtil.downFile(uri); return saveFileByStream(inputStream, fileInfo); } catch (Exception e) { log.error("down file from url: {} error! e: {}", uri, e); throw e; } }
將輸入流保存到文件
這是一個比較基礎的功能了,但真正的實現起來,就沒有那麼順暢了,須要注意一下幾點
flush()
方法不要忘記[0-1000)隨機數
"_out.輸出格式"
public static FileInfo saveFileByStream(InputStream inputStream, String fileType) throws Exception { // 臨時文件生成規則 當前時間戳 + 隨機數 + 後綴 return saveFileByStream(inputStream, TEMP_PATH, genTempFileName(), fileType); } /** * 將字節流保存到文件中 * * @param stream * @param filename * @return */ public static FileInfo saveFileByStream(InputStream stream, String path, String filename, String fileType) throws FileNotFoundException { return saveFileByStream(stream, new FileInfo(path, filename, fileType)); } public static FileInfo saveFileByStream(InputStream stream, FileInfo fileInfo) throws FileNotFoundException { if (!StringUtils.isBlank(fileInfo.getPath())) { mkDir(new File(fileInfo.getPath())); } String tempAbsFile = fileInfo.getPath() + "/" + fileInfo.getFilename() + "." + fileInfo.getFileType(); BufferedOutputStream outputStream = null; InputStream inputStream = null; try { inputStream = new BufferedInputStream(stream); outputStream = new BufferedOutputStream(new FileOutputStream(tempAbsFile)); int len = inputStream.available(); //判斷長度是否大於4k if (len <= 4096) { byte[] bytes = new byte[len]; inputStream.read(bytes); outputStream.write(bytes); } else { int byteCount = 0; //1M逐個讀取 byte[] bytes = new byte[4096]; while ((byteCount = inputStream.read(bytes)) != -1) { outputStream.write(bytes, 0, byteCount); } } return fileInfo; } catch (Exception e) { log.error("save stream into file error! filename: {} e: {}", tempAbsFile, e); return null; } finally { try { if (outputStream != null) { outputStream.flush(); outputStream.close(); } if (inputStream != null) { inputStream.close(); } } catch (IOException e) { log.error("close stream error! e: {}", e); } } } /** * 臨時文件名生成: 時間戳 + 0-1000隨機數 * * @return */ private static String genTempFileName() { return System.currentTimeMillis() + "_" + ((int) (Math.random() * 1000)); } /** * 遞歸建立文件夾 * * @param file 由目錄建立的file對象 * @throws FileNotFoundException */ public static void mkDir(File file) throws FileNotFoundException { if (file.getParentFile().exists()) { if (!file.exists() && !file.mkdir()) { throw new FileNotFoundException(); } } else { mkDir(file.getParentFile()); if (!file.exists() && !file.mkdir()) { throw new FileNotFoundException(); } } }
ProcessUtil
這個就是將最上面的三行代碼封裝的工具類,基本上快兩百行...
源碼先貼出
@Slf4j public class ProcessUtil { /** * Buffer size of process input-stream (used for reading the * output (sic!) of the process). Currently 64KB. */ public static final int BUFFER_SIZE = 65536; public static final int EXEC_TIME_OUT = 2; private ExecutorService exec; private ProcessUtil() { exec = new ThreadPoolExecutor(6, 12, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>(10), new CustomThreadFactory("cmd-process"), new ThreadPoolExecutor.CallerRunsPolicy()); } public static ProcessUtil instance() { return InputStreamConsumer.instance; } /** * 簡單的封裝, 執行cmd命令 * * @param cmd 待執行的操做命令 * @return * @throws IOException * @throws InterruptedException */ public boolean process(String cmd) throws Exception { Process process = Runtime.getRuntime().exec(cmd); waitForProcess(process); return true; } /** * Perform process input/output and wait for process to terminate. * * 源碼參考 im4java 的實現修改而來 * */ private int waitForProcess(final Process pProcess) throws IOException, InterruptedException, TimeoutException, ExecutionException { // Process stdout and stderr of subprocess in parallel. // This prevents deadlock under Windows, if there is a lot of // stderr-output (e.g. from ghostscript called by convert) FutureTask<Object> outTask = new FutureTask<Object>(() -> { processOutput(pProcess.getInputStream(), InputStreamConsumer.DEFAULT_CONSUMER); return null; }); exec.submit(outTask); FutureTask<Object> errTask = new FutureTask<Object>(() -> { processError(pProcess.getErrorStream(), InputStreamConsumer.DEFAULT_CONSUMER); return null; }); exec.submit(errTask); // Wait and check IO exceptions (FutureTask.get() blocks). try { outTask.get(); errTask.get(); } catch (ExecutionException e) { Throwable t = e.getCause(); if (t instanceof IOException) { throw (IOException) t; } else if (t instanceof RuntimeException) { throw (RuntimeException) t; } else { throw new IllegalStateException(e); } } FutureTask<Integer> processTask = new FutureTask<Integer>(() -> { pProcess.waitFor(); return pProcess.exitValue(); }); exec.submit(processTask); // 設置超時時間,防止死等 int rc = processTask.get(EXEC_TIME_OUT, TimeUnit.SECONDS); // just to be on the safe side try { pProcess.getInputStream().close(); pProcess.getOutputStream().close(); pProcess.getErrorStream().close(); } catch (Exception e) { log.error("close stream error! e: {}", e); } return rc; } ////////////////////////////////////////////////////////////////////////////// /** * Let the OutputConsumer process the output of the command. * <p> * 方便後續對輸出流的擴展 */ private void processOutput(InputStream pInputStream, InputStreamConsumer pConsumer) throws IOException { pConsumer.consume(pInputStream); } ////////////////////////////////////////////////////////////////////////////// /** * Let the ErrorConsumer process the stderr-stream. * <p> * 方便對後續異常流的處理 */ private void processError(InputStream pInputStream, InputStreamConsumer pConsumer) throws IOException { pConsumer.consume(pInputStream); } private static class InputStreamConsumer { static ProcessUtil instance = new ProcessUtil(); static InputStreamConsumer DEFAULT_CONSUMER = new InputStreamConsumer(); void consume(InputStream stream) throws IOException { StringBuilder builder = new StringBuilder(); BufferedReader reader = new BufferedReader(new InputStreamReader(stream), BUFFER_SIZE); String temp; while ((temp = reader.readLine()) != null) { builder.append(temp); } if (log.isDebugEnabled()) { log.debug("cmd process input stream: {}", builder.toString()); } reader.close(); } } private static class CustomThreadFactory implements ThreadFactory { private String name; private AtomicInteger count = new AtomicInteger(0); public CustomThreadFactory(String name) { this.name = name; } @Override public Thread newThread(Runnable r) { return new Thread(r, name + "-" + count.addAndGet(1)); } } }
說明
上面實現了一個較好用的封裝類,可是在實際的開發過程當中,有些問題有必要單獨的拎出來講一說
-y
參數覆蓋寫,若是輸出的文件名對應的文件已經存在,這個參數就表示使用新的文件覆蓋老的
在控制檯執行轉碼時,會發現這種場景會要求用戶輸入一個y/n來表是否繼續轉碼,因此在代碼中,若是不加上這個參數,將一直得不到執行
將 amr 音頻轉換 mp3 格式音頻,若是直接使用命令ffmpeg -i test.amr -y out.mp3
會發現輸出的音頻時間長度比實際的小,可是在播放的時候又是沒有問題的;測試在mac和iphone會有這個問題
解決方案,加一個參數 write_xing 0
執行命令: ffmpeg -i song.ogg -y -write_xing 0 song.mp3
當咱們沒有手動清空輸出流,異常流時,會發現併發請求量越高,rt越高
主要緣由是輸出信息 & 異常信息沒有被消費,而緩存這些數據的空間是有限制的,所以上面咱們的ProcessUtil
類中,有兩個任務來處理輸出流和異常流
還有一種方法就是加一個參數
ffmpeg -i song.ogg -y -write_xing 0 song.mp3 -loglevel quiet
項目源碼: https://github.com/liuyueyi/quick-media
我的博客主頁: 一灰的博客網站
公衆號獲取更多:
G