|
|
|
@ -6,12 +6,13 @@ 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.nio.file.AtomicMoveNotSupportedException; |
|
|
|
import java.nio.file.Files; |
|
|
|
import java.nio.file.StandardCopyOption; |
|
|
|
import java.util.ArrayList; |
|
|
|
import java.util.List; |
|
|
|
import java.util.concurrent.TimeUnit; |
|
|
|
@ -75,10 +76,17 @@ public class G729Converter { |
|
|
|
throw new IOException("待转码文件不存在: " + (src == null ? "null" : src.getPath())); |
|
|
|
} |
|
|
|
File dest = new File(src.getParentFile(), src.getName() + ".pcm.wav"); |
|
|
|
File tempDest = new File(dest.getAbsolutePath() + ".part"); |
|
|
|
File ffmpegLog = new File(dest.getAbsolutePath() + ".ffmpeg.log"); |
|
|
|
Files.deleteIfExists(tempDest.toPath()); |
|
|
|
Files.deleteIfExists(ffmpegLog.toPath()); |
|
|
|
|
|
|
|
List<String> command = new ArrayList<>(); |
|
|
|
command.add(resolveFfmpegPath()); |
|
|
|
command.add("-y"); |
|
|
|
command.add("-nostdin"); |
|
|
|
// 部分录音盒生成的 G.729 WAV 中 fact.sample_count 填的是压缩字节数,
|
|
|
|
// 与实际采样数不一致。FFmpeg 会按数据流解码,这里不依赖该字段计算输出。
|
|
|
|
command.add("-i"); |
|
|
|
command.add(src.getAbsolutePath()); |
|
|
|
command.add("-ar"); |
|
|
|
@ -87,40 +95,108 @@ public class G729Converter { |
|
|
|
command.add("1"); |
|
|
|
command.add("-c:a"); |
|
|
|
command.add("pcm_s16le"); |
|
|
|
command.add(dest.getAbsolutePath()); |
|
|
|
command.add("-f"); |
|
|
|
command.add("wav"); |
|
|
|
command.add(tempDest.getAbsolutePath()); |
|
|
|
|
|
|
|
logger.info("执行 ffmpeg 转码: {}", String.join(" ", command)); |
|
|
|
ProcessBuilder pb = new ProcessBuilder(command); |
|
|
|
pb.redirectErrorStream(true); |
|
|
|
// FFmpeg 日志可能较多,必须在进程运行期间持续排出,否则管道写满会导致转码卡死。
|
|
|
|
pb.redirectOutput(ffmpegLog); |
|
|
|
Process process = pb.start(); |
|
|
|
|
|
|
|
boolean finished = process.waitFor(TRANSCODE_TIMEOUT_SECONDS, TimeUnit.SECONDS); |
|
|
|
if (!finished) { |
|
|
|
process.destroyForcibly(); |
|
|
|
Files.deleteIfExists(tempDest.toPath()); |
|
|
|
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(); |
|
|
|
String err = readLogTail(ffmpegLog, 1000); |
|
|
|
Files.deleteIfExists(tempDest.toPath()); |
|
|
|
throw new IOException("ffmpeg 转码失败(exit=" + exitCode + "): " + src.getName() + " -> " + err); |
|
|
|
} |
|
|
|
if (!dest.isFile() || dest.length() == 0) { |
|
|
|
throw new IOException("ffmpeg 转码未生成有效文件: " + dest.getAbsolutePath()); |
|
|
|
if (!isValidPcmWav(tempDest)) { |
|
|
|
Files.deleteIfExists(tempDest.toPath()); |
|
|
|
throw new IOException("ffmpeg 转码结果不是有效PCM WAV: " + tempDest.getAbsolutePath() |
|
|
|
+ ", 日志=" + readLogTail(ffmpegLog, 1000)); |
|
|
|
} |
|
|
|
try { |
|
|
|
Files.move(tempDest.toPath(), dest.toPath(), |
|
|
|
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); |
|
|
|
} catch (AtomicMoveNotSupportedException e) { |
|
|
|
Files.move(tempDest.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING); |
|
|
|
} finally { |
|
|
|
Files.deleteIfExists(ffmpegLog.toPath()); |
|
|
|
} |
|
|
|
return dest; |
|
|
|
} |
|
|
|
|
|
|
|
/** 判断文件是否为具有非空 data 块的标准 PCM WAV,防止复用上次遗留的半成品。 */ |
|
|
|
public boolean isValidPcmWav(File file) { |
|
|
|
if (file == null || !file.isFile() || file.length() <= 44) { |
|
|
|
return false; |
|
|
|
} |
|
|
|
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { |
|
|
|
if (raf.length() < 12) { |
|
|
|
return false; |
|
|
|
} |
|
|
|
byte[] riff = new byte[12]; |
|
|
|
raf.readFully(riff); |
|
|
|
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 false; |
|
|
|
} |
|
|
|
boolean pcm = false; |
|
|
|
boolean hasAudioData = false; |
|
|
|
long offset = 12; |
|
|
|
while (offset + 8 <= raf.length()) { |
|
|
|
raf.seek(offset); |
|
|
|
byte[] header = new byte[8]; |
|
|
|
raf.readFully(header); |
|
|
|
String chunkId = new String(header, 0, 4, StandardCharsets.US_ASCII); |
|
|
|
long chunkSize = littleEndianUInt32(header, 4); |
|
|
|
long dataOffset = offset + 8; |
|
|
|
if (chunkSize > raf.length() - dataOffset) { |
|
|
|
return false; |
|
|
|
} |
|
|
|
if ("fmt ".equals(chunkId) && chunkSize >= 2) { |
|
|
|
raf.seek(dataOffset); |
|
|
|
pcm = (raf.readUnsignedByte() | (raf.readUnsignedByte() << 8)) == 1; |
|
|
|
} else if ("data".equals(chunkId)) { |
|
|
|
hasAudioData = chunkSize > 0; |
|
|
|
} |
|
|
|
offset = dataOffset + chunkSize + (chunkSize & 1L); |
|
|
|
} |
|
|
|
return pcm && hasAudioData; |
|
|
|
} catch (IOException e) { |
|
|
|
return false; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private long littleEndianUInt32(byte[] bytes, int offset) { |
|
|
|
return (bytes[offset] & 0xFFL) |
|
|
|
| ((bytes[offset + 1] & 0xFFL) << 8) |
|
|
|
| ((bytes[offset + 2] & 0xFFL) << 16) |
|
|
|
| ((bytes[offset + 3] & 0xFFL) << 24); |
|
|
|
} |
|
|
|
|
|
|
|
private String readLogTail(File file, int maxChars) { |
|
|
|
if (file == null || !file.isFile()) { |
|
|
|
return ""; |
|
|
|
} |
|
|
|
try { |
|
|
|
byte[] bytes = Files.readAllBytes(file.toPath()); |
|
|
|
String text = new String(bytes, StandardCharsets.UTF_8); |
|
|
|
return text.length() > maxChars ? text.substring(text.length() - maxChars) : text; |
|
|
|
} catch (IOException e) { |
|
|
|
return "读取FFmpeg日志失败: " + e.getMessage(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 根据配置解析 ffmpeg 可执行文件路径 |
|
|
|
* |
|
|
|
|