spring-boot & ffmpeg 搭建一個音頻轉碼服務

利用FFMPEG實現一個音頻轉碼服務

提供一個音頻轉碼服務,主要是利用ffmpeg實現轉碼,利用java web對外提供http服務接口java

背景

音頻轉碼服務算是比較基礎的了,以前一直沒作,最近有個需求背景,是將微信的amr格式音頻,轉換爲mp3格式,不然h5頁面的音頻將沒法播放ios

出於這個轉碼的場景,順帶着搭建一個多媒體處理服務應用(目標是圖片的基本操做,音頻、視頻的經常使用操做等)git

擬採用的技術github

  1. 圖片
  • imageMagic/graphicMagic + im4java
  1. 音頻
  • ffmpeg + Runtime.getRuntime().exec(cmd);
  1. Spring Boot + Spring Mvc 提供http服務接口

本篇重點web

使用ffmpeg提供音頻轉碼的服務接口緩存

準備

1. 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文件,而後用播放器,打開確認是否有問題

2. 工程搭建

使用Spring-Boot 搭建一個Web工程

直接用官網的建立方式便可,這裏不作敘述

3. 編碼實現

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,所以咱們須要的參數有

  • 源文件 source.mar
  • 輸出文件 output.mp3
  • 執行命令 ffmpeg
  • 可選參數 (ffmpeg帶的一些參數)
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();
    }
}

Audio處理封裝類 AudioWrapper

對外暴露的接口,全部音頻相關的操做都經過它來執行,正如上面的測試用例

  1. 對輸入源,咱們預留三種調用方式
  • 傳入path路徑(相對路徑,絕對路徑,網絡路徑)
  • URI 方式 (即傳入網絡連接方式,等同於上面的網絡路徑方式)
  • InputStream (文件輸入流)
  1. 命令行調用,一般可選參數比較多,因此咱們採用Builder模式來作參數的設置

  2. 源碼以下

@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();
  • java執行cmd命令 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()));
        }
    }

1. 輸入源爲String時

三種路徑的區分,對於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));
    }
}

2. 輸入源爲URI時

網絡資源,須要先把文件下載過來,因此就須要一個下載的工具類

一個很是初級的下載工具類: 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;
    }
}

3. 輸入源爲InpuStream時

將輸入流保存到文件

這是一個比較基礎的功能了,但真正的實現起來,就沒有那麼順暢了,須要注意一下幾點

  • 確保臨時文件所在的目錄存在
  • 輸入輸出流的關閉,輸出流的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));
        }
    }

}

說明

  • 內部類方式的單例模式
  • 線程池,開獨立的線程來處理命令行的輸出流、異常流
    • 若是不清空這兩個流,可能直接致使rt隨着併發數的增長而線性增長
  • 獨立的線程執行命令行操做,支持超時設置
    • 超時設置,確保服務不會掛住
    • 異步執行命令行操做,能夠併發執行後續的步驟

填坑之旅

上面實現了一個較好用的封裝類,可是在實際的開發過程當中,有些問題有必要單獨的拎出來講一說

1. -y 參數

覆蓋寫,若是輸出的文件名對應的文件已經存在,這個參數就表示使用新的文件覆蓋老的

在控制檯執行轉碼時,會發現這種場景會要求用戶輸入一個y/n來表是否繼續轉碼,因此在代碼中,若是不加上這個參數,將一直得不到執行

2. mac/ios 的音頻長度與實際不符合

將 amr 音頻轉換 mp3 格式音頻,若是直接使用命令ffmpeg -i test.amr -y out.mp3

會發現輸出的音頻時間長度比實際的小,可是在播放的時候又是沒有問題的;測試在mac和iphone會有這個問題

解決方案,加一個參數 write_xing 0

3. 併發訪問時,RT線性增長

執行命令: 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

我的博客主頁: 一灰的博客網站

公衆號獲取更多:

https://static.oschina.net/uploads/img/201707/09205944_0PzS.jpgG

相關文章
相關標籤/搜索