提供一个音频转码服务,主要是利用ffmpeg实现转码,利用java web对外提供http服务接口
音频转码服务算是比较基础的了,之前一直没做,最近有个需求背景,是将微信的amr格式音频,转换为mp3格式,否则h5页面的音频将无法播放
出于这个转码的场景,顺带着搭建一个多媒体处理服务应用(目标是图片的基本操作,音频、视频的常用操作等)
Runtime.getRuntime().exec(cmd);
使用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
的可执行文件,主要利用它来实现音频转码
./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