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