3 changed files with 245 additions and 2 deletions
@ -0,0 +1,188 @@ |
|||
package com.threecloud.dataserviceyy.util; |
|||
|
|||
import org.bytedeco.javacpp.Loader; |
|||
import org.slf4j.Logger; |
|||
import org.slf4j.LoggerFactory; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.io.BufferedReader; |
|||
import java.io.File; |
|||
import java.io.IOException; |
|||
import java.io.InputStreamReader; |
|||
import java.io.RandomAccessFile; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
/** |
|||
* G.729 录音转码工具 |
|||
* |
|||
* 【背景】录音盒导出的 WAV 部分为 G.729 压缩编码(wFormatTag=0x729a), |
|||
* 浏览器无法直接播放,需转码为标准 PCM WAV。 |
|||
* 【方式】调用系统 ffmpeg 命令完成转码(ffmpeg 内置 G.729 解码器,无需额外库)。 |
|||
* 【降级】ffmpeg 不存在或转码失败时抛出异常,由调用方决定是否回退原文件。 |
|||
*/ |
|||
@Component |
|||
public class G729Converter { |
|||
|
|||
private static final Logger logger = LoggerFactory.getLogger(G729Converter.class); |
|||
|
|||
/** G.729 的 WAV 格式标签(FFmpeg TwoCC,0x729a) */ |
|||
private static final int WAVE_FORMAT_G729 = 0x729a; |
|||
|
|||
/** 转码超时时间(秒),防止异常文件卡死 */ |
|||
private static final long TRANSCODE_TIMEOUT_SECONDS = 600; |
|||
|
|||
/** 转码方式:system=系统ffmpeg(默认),javacv=bytedeco内置ffmpeg */ |
|||
@Value("${ffmpeg.mode:javacv}") |
|||
private String ffmpegMode; |
|||
|
|||
/** 系统 ffmpeg 可执行文件路径(mode=system 时生效),默认从 PATH 中查找 */ |
|||
@Value("${ffmpeg.path:ffmpeg}") |
|||
private String ffmpegPath; |
|||
|
|||
/** |
|||
* 判断文件是否为 G.729 编码的 WAV |
|||
* |
|||
* @param file 音频文件 |
|||
* @return true 表示为 G.729(wFormatTag=0x729a),需要转码 |
|||
*/ |
|||
public boolean isG729(File file) { |
|||
if (file == null || !file.isFile()) { |
|||
return false; |
|||
} |
|||
try { |
|||
Integer format = readWavAudioFormat(file); |
|||
return format != null && format == WAVE_FORMAT_G729; |
|||
} catch (Exception e) { |
|||
logger.warn("判断 WAV 编码失败: {}, 原因={}", file.getName(), e.getMessage()); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 将 G.729 WAV 转码为 PCM WAV(16bit 单声道 8kHz) |
|||
* |
|||
* @param src 源 G.729 文件 |
|||
* @return 转码后的 PCM 文件(生成在源文件同目录,文件名为源文件名 + ".pcm.wav") |
|||
* @throws IOException IO 异常或 ffmpeg 转码失败 |
|||
* @throws InterruptedException 等待被中断 |
|||
*/ |
|||
public File convertToPcm(File src) throws IOException, InterruptedException { |
|||
if (src == null || !src.isFile()) { |
|||
throw new IOException("待转码文件不存在: " + (src == null ? "null" : src.getPath())); |
|||
} |
|||
File dest = new File(src.getParentFile(), src.getName() + ".pcm.wav"); |
|||
|
|||
List<String> command = new ArrayList<>(); |
|||
command.add(resolveFfmpegPath()); |
|||
command.add("-y"); |
|||
command.add("-i"); |
|||
command.add(src.getAbsolutePath()); |
|||
command.add("-ar"); |
|||
command.add("8000"); |
|||
command.add("-ac"); |
|||
command.add("1"); |
|||
command.add("-c:a"); |
|||
command.add("pcm_s16le"); |
|||
command.add(dest.getAbsolutePath()); |
|||
|
|||
logger.info("执行 ffmpeg 转码: {}", String.join(" ", command)); |
|||
ProcessBuilder pb = new ProcessBuilder(command); |
|||
pb.redirectErrorStream(true); |
|||
Process process = pb.start(); |
|||
|
|||
boolean finished = process.waitFor(TRANSCODE_TIMEOUT_SECONDS, TimeUnit.SECONDS); |
|||
if (!finished) { |
|||
process.destroyForcibly(); |
|||
throw new IOException("ffmpeg 转码超时(>" + TRANSCODE_TIMEOUT_SECONDS + "s): " + src.getName()); |
|||
} |
|||
|
|||
// 进程已结束,读取输出用于日志与错误排查
|
|||
StringBuilder output = new StringBuilder(); |
|||
try (BufferedReader reader = new BufferedReader( |
|||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { |
|||
String line; |
|||
while ((line = reader.readLine()) != null) { |
|||
output.append(line).append('\n'); |
|||
} |
|||
} |
|||
|
|||
int exitCode = process.exitValue(); |
|||
if (exitCode != 0) { |
|||
String err = output.length() > 500 ? output.substring(output.length() - 500) : output.toString(); |
|||
throw new IOException("ffmpeg 转码失败(exit=" + exitCode + "): " + src.getName() + " -> " + err); |
|||
} |
|||
if (!dest.isFile() || dest.length() == 0) { |
|||
throw new IOException("ffmpeg 转码未生成有效文件: " + dest.getAbsolutePath()); |
|||
} |
|||
return dest; |
|||
} |
|||
|
|||
/** |
|||
* 根据配置解析 ffmpeg 可执行文件路径 |
|||
* |
|||
* mode=system:使用系统安装的 ffmpeg(ffmpeg.path 配置) |
|||
* mode=javacv:使用 bytedeco 打包在依赖中的 ffmpeg(无需服务器安装) |
|||
* |
|||
* @return ffmpeg 可执行文件路径 |
|||
*/ |
|||
private String resolveFfmpegPath() { |
|||
if ("javacv".equalsIgnoreCase(ffmpegMode)) { |
|||
return Loader.load(org.bytedeco.ffmpeg.ffmpeg.class); |
|||
} |
|||
return ffmpegPath; |
|||
} |
|||
|
|||
/** |
|||
* 读取 WAV 文件的音频格式标签(wFormatTag) |
|||
* |
|||
* 遍历 RIFF chunk,定位 fmt chunk 后读取前 2 字节(小端)。 |
|||
* |
|||
* @param file WAV 文件 |
|||
* @return wFormatTag;非 WAV 或解析失败返回 null |
|||
*/ |
|||
private Integer readWavAudioFormat(File file) throws IOException { |
|||
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { |
|||
if (raf.length() < 12) { |
|||
return null; |
|||
} |
|||
byte[] riff = new byte[12]; |
|||
raf.readFully(riff); |
|||
// RIFF....WAVE
|
|||
if (!(riff[0] == 'R' && riff[1] == 'I' && riff[2] == 'F' && riff[3] == 'F' |
|||
&& riff[8] == 'W' && riff[9] == 'A' && riff[10] == 'V' && riff[11] == 'E')) { |
|||
return null; |
|||
} |
|||
|
|||
long offset = 12; |
|||
while (offset + 8 <= raf.length()) { |
|||
raf.seek(offset); |
|||
byte[] chunkHeader = new byte[8]; |
|||
raf.readFully(chunkHeader); |
|||
String chunkId = new String(chunkHeader, 0, 4, StandardCharsets.US_ASCII); |
|||
long chunkSize = (chunkHeader[4] & 0xFFL) |
|||
| ((chunkHeader[5] & 0xFFL) << 8) |
|||
| ((chunkHeader[6] & 0xFFL) << 16) |
|||
| ((chunkHeader[7] & 0xFFL) << 24); |
|||
if ("fmt ".equals(chunkId)) { |
|||
if (chunkSize < 2) { |
|||
return null; |
|||
} |
|||
raf.seek(offset + 8); |
|||
int b0 = raf.readUnsignedByte(); |
|||
int b1 = raf.readUnsignedByte(); |
|||
return b0 | (b1 << 8); |
|||
} |
|||
offset += 8 + chunkSize; |
|||
// RIFF chunk 按 2 字节对齐
|
|||
if (chunkSize % 2 == 1) { |
|||
offset += 1; |
|||
} |
|||
} |
|||
} |
|||
return null; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue