Merge branch 'jingquan' into dyf-device

This commit is contained in:
2025-10-28 10:32:31 +08:00
9 changed files with 1403 additions and 219 deletions

View File

@ -118,6 +118,22 @@
<artifactId>fys-customer</artifactId>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv-platform</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.nls</groupId>
<artifactId>nls-sdk-common</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>3.9.1</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>

View File

@ -0,0 +1,384 @@
package com.fuyuanshen.equipment.utils;
import com.alibaba.nls.client.AccessToken;
import com.fuyuanshen.common.redis.utils.RedisUtils;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static cn.dev33.satoken.SaManager.log;
/**
* 阿里巴巴语音合成工具类
* 提供文本转语音功能,支持多种声音、语速、音量等参数调节
*/
@Component
public class AlibabaTTSUtil {
// ========== 常量配置 ==========
/** 阿里云TTS服务基础URL */
private static final String BASE_URL = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/tts";
/** 音频内容类型标识 */
private static final String CONTENT_TYPE_AUDIO = "audio/mpeg";
// ========== 默认参数值 ==========
/** 默认发音人 - 小云 */
private static final String DEFAULT_VOICE = "xiaoyun";
/** 默认音量 50% */
private static final int DEFAULT_VOLUME = 50;
/** 默认语速 0正常 */
private static final int DEFAULT_SPEECH_RATE = 1;
/** 默认语调 0正常 */
private static final int DEFAULT_PITCH_RATE = 0;
/** 默认音频格式 pcm */
private static final String DEFAULT_FORMAT = "pcm";
/** 默认采样率 16000Hz */
private static final int DEFAULT_SAMPLE_RATE = 16000;
// ========== Token管理配置 ==========
/** Token刷新缓冲时间提前5分钟刷新单位毫秒 */
private static final long TOKEN_REFRESH_BUFFER = 5 * 60 * 1000L;
// ========== 配置参数(从配置文件读取) ==========
@Value("${alibaba.tts.appKey:}")
private String appkey;
@Value("${alibaba.tts.akId:}")
private String aliyunAkId;
@Value("${alibaba.tts.akSecret:}")
private String aliyunAkSecret;
private final String redisKey = "alibaba:TTS:AccessToken";
// ========== HTTP客户端单例复用 ==========
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) // 连接超时30秒
.readTimeout(60, TimeUnit.SECONDS) // 读取超时60秒音频生成较慢
.build();
/**
* 生成语音文件 - 简化版(使用默认参数)
* @param text 要转换的文本内容
* @param audioSaveFile 音频文件保存路径
* @return true-成功 false-失败
*/
public boolean generateSpeech(String text, String audioSaveFile) {
return generateSpeech(text, audioSaveFile, DEFAULT_FORMAT, DEFAULT_SAMPLE_RATE, DEFAULT_VOICE);
}
/**
* 生成语音文件 - 标准版
* @param text 要转换的文本内容
* @param audioSaveFile 音频文件保存路径
* @param format 音频格式mp3, wav等
* @param sampleRate 采样率16000, 22050, 44100等
* @param voice 发音人xiaoyun, xiaoqian等
* @return true-成功 false-失败
*/
public boolean generateSpeech(String text, String audioSaveFile, String format,
int sampleRate, String voice) {
return generateSpeech(text, audioSaveFile, format, sampleRate, voice,
DEFAULT_VOLUME, DEFAULT_SPEECH_RATE, DEFAULT_PITCH_RATE);
}
/**
* 生成语音文件 - 完整版(支持所有参数调节)
* @param text 要转换的文本内容
* @param audioSaveFile 音频文件保存路径
* @param format 音频格式
* @param sampleRate 采样率
* @param voice 发音人
* @param volume 音量0-100
* @param speechRate 语速(-500~500
* @param pitchRate 语调(-500~500
* @return true-成功 false-失败
*/
public boolean generateSpeech(String text, String audioSaveFile, String format,
int sampleRate, String voice, int volume,
int speechRate, int pitchRate) {
try {
// 参数校验
validateParameters(text, audioSaveFile, format, sampleRate);
// 获取访问令牌
String token = getValidAccessToken();
if (token == null) {
log.error("获取访问令牌失败");
return false;
}
// 构建请求URL并执行请求
String url = buildRequestUrl(text, format, sampleRate, voice, volume, speechRate, pitchRate, token);
log.debug("TTS请求URL: {}", url.replace(token, "***")); // 隐藏token日志
return executeRequest(url, audioSaveFile);
} catch (IllegalArgumentException e) {
log.error("参数验证失败: {}", e.getMessage());
return false;
} catch (Exception e) {
log.error("语音合成请求失败: {}", e.getMessage(), e);
return false;
}
}
/**
* 生成标准PCM数据单声道16K采样率16bit深度包含44字节WAV头
* 数据总大小不超过2MB内存操作无临时文件
*/
public byte[] generateStandardPcmData(String text) throws IOException {
String token = getValidAccessToken();
if (token == null) {
throw new IOException("获取访问令牌失败");
}
String url = buildRequestUrl(text, "pcm", 16000, DEFAULT_VOICE,
DEFAULT_VOLUME, DEFAULT_SPEECH_RATE, DEFAULT_PITCH_RATE, token);
// 直接从响应中读取 raw PCM 数据(不写文件)
byte[] rawPcmData = executeRequestAndGetBytes(url);
if (rawPcmData == null || rawPcmData.length == 0) {
throw new IOException("TTS返回音频数据为空");
}
return rawPcmData;
}
/**
* 获取有效的访问令牌(优先从缓存获取,缓存不存在则重新生成)
* @return 访问令牌获取失败返回null
*/
private String getValidAccessToken() {
try {
// 1. 尝试从Redis缓存获取
String cachedToken = RedisUtils.getCacheObject(redisKey);
if (cachedToken != null && !cachedToken.trim().isEmpty()) {
log.debug("从缓存获取访问令牌成功");
return cachedToken;
}
// 2. 缓存不存在,重新生成
log.info("缓存中未找到访问令牌,重新生成...");
return refreshAccessToken();
} catch (Exception e) {
log.error("从Redis获取访问令牌失败: {}", e.getMessage());
return refreshAccessToken(); // 降级处理:直接刷新
}
}
/**
* 刷新访问令牌调用阿里云API获取新令牌并缓存
* @return 新的访问令牌获取失败返回null
*/
private String refreshAccessToken() {
try {
// 调用阿里云API获取访问令牌
AccessToken accessToken = new AccessToken(aliyunAkId, aliyunAkSecret);
accessToken.apply();
String token = accessToken.getToken();
// 缓存令牌到Redis
RedisUtils.setCacheObject(redisKey, token, Duration.ofMillis(60));
log.info("访问令牌刷新成功");
return token;
} catch (Exception e) {
log.error("刷新访问令牌失败: {}", e.getMessage(), e);
return null;
}
}
/**
* 参数验证
* @throws IllegalArgumentException 参数不合法时抛出异常
*/
private void validateParameters(String text, String audioSaveFile, String format, int sampleRate) {
if (text == null || text.trim().isEmpty()) {
throw new IllegalArgumentException("文本内容不能为空");
}
// 文本长度限制阿里云TTS限制
if (text.length() > 500) {
throw new IllegalArgumentException("文本长度超过限制最大500字符");
}
if (audioSaveFile == null || audioSaveFile.trim().isEmpty()) {
throw new IllegalArgumentException("音频文件保存路径不能为空");
}
if (format == null || format.trim().isEmpty()) {
throw new IllegalArgumentException("音频格式不能为空");
}
// 采样率范围验证
if (sampleRate <= 0 || sampleRate > 48000) {
throw new IllegalArgumentException("采样率必须在1-48000范围内");
}
}
/**
* 构建TTS请求URL
*/
private String buildRequestUrl(String text, String format, int sampleRate,
String voice, int volume, int speechRate,
int pitchRate, String token) {
try {
// URL编码文本内容防止特殊字符问题
String encodedText = URLEncoder.encode(text, StandardCharsets.UTF_8.name());
// 使用默认发音人如果未指定
String actualVoice = (voice != null && !voice.trim().isEmpty()) ? voice : DEFAULT_VOICE;
// 构建请求URL使用字符串拼接性能更优
return BASE_URL +
"?appkey=" + appkey +
"&token=" + token +
"&text=" + encodedText +
"&format=" + format +
"&sample_rate=" + sampleRate;
//"&voice=" + actualVoice +
//"&volume=" + Math.max(0, Math.min(100, volume)) + // 音量范围限制
//"&speech_rate=" + Math.max(-500, Math.min(500, speechRate)) + // 语速范围限制
//"&pitch_rate=" + Math.max(-500, Math.min(500, pitchRate)); // 语调范围限制
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("UTF-8编码不支持", e);
}
}
/**
* 执行HTTP请求
*/
private boolean executeRequest(String url, String audioSaveFile) {
Request request = new Request.Builder()
.url(url)
.header("User-Agent", "AlibabaTTSClient/1.0.0")
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
// 检查HTTP响应状态
if (!response.isSuccessful()) {
log.error("HTTP请求失败状态码: {}", response.code());
handleErrorResponse(response);
return false;
}
// 处理成功响应
return handleResponse(response, audioSaveFile);
} catch (IOException e) {
log.error("HTTP请求执行失败: {}", e.getMessage());
return false;
}
}
private byte[] executeRequestAndGetBytes(String url) {
Request request = new Request.Builder()
.url(url)
.header("User-Agent", "AlibabaTTSClient/1.0.0")
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
handleErrorResponse(response);
return null;
}
String contentType = response.header("Content-Type");
if (!CONTENT_TYPE_AUDIO.equals(contentType) && !contentType.contains("pcm")) {
handleErrorResponse(response);
return null;
}
byte[] audioData = response.body().bytes();
log.info("TTS原始PCM获取成功大小: {} 字节", audioData.length);
return audioData;
} catch (IOException e) {
log.error("HTTP请求执行失败: {}", e.getMessage());
return null;
}
}
/**
* 处理HTTP响应
*/
private boolean handleResponse(Response response, String audioSaveFile) throws IOException {
String contentType = response.header("Content-Type");
// 根据内容类型处理响应
if (CONTENT_TYPE_AUDIO.equals(contentType)) {
return saveAudioFile(response, audioSaveFile); // 保存音频文件
} else {
handleErrorResponse(response); // 处理错误响应
return false;
}
}
/**
* 保存音频文件到本地
*/
private boolean saveAudioFile(Response response, String audioSaveFile) {
File file = new File(audioSaveFile);
File parentDir = file.getParentFile();
// 确保保存目录存在
if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) {
log.error("创建目录失败: {}", parentDir.getAbsolutePath());
return false;
}
try (FileOutputStream fos = new FileOutputStream(file)) {
// 读取音频数据并写入文件
byte[] audioData = response.body().bytes();
fos.write(audioData);
log.info("音频文件保存成功: {} ({} 字节)", audioSaveFile, audioData.length);
return true;
} catch (IOException e) {
log.error("保存音频文件失败: {}", e.getMessage());
// 清理可能已创建的不完整文件
deleteFileSilently(file);
return false;
}
}
/**
* 处理错误响应(非音频内容)
*/
private void handleErrorResponse(Response response) throws IOException {
String errorMessage = "未知错误";
if (response.body() != null) {
errorMessage = response.body().string();
}
log.error("TTS请求失败。状态码: {}, 错误信息: {}", response.code(), errorMessage);
}
/**
* 静默删除文件(不抛出异常)
*/
private void deleteFileSilently(File file) {
try {
if (file.exists() && !file.delete()) {
log.warn("删除文件失败: {}", file.getAbsolutePath());
}
} catch (Exception e) {
log.warn("删除文件时发生异常: {}", e.getMessage());
}
}
}

View File

@ -0,0 +1,513 @@
package com.fuyuanshen.equipment.utils;
import lombok.extern.slf4j.Slf4j;
import org.bytedeco.ffmpeg.global.avcodec;
import org.bytedeco.ffmpeg.global.avutil;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.FFmpegFrameRecorder;
import org.bytedeco.javacv.Frame;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 使用 FFmpeg 的音频转码工具
*/
@Slf4j
@Component
public class AudioProcessUtil {
// 目标音频参数
private static final int TARGET_SAMPLE_RATE = 16000;
private static final int TARGET_CHANNELS = 1;
private static final int TARGET_BIT_RATE = 256000;
// 最大输出大小1MB
private static final int MAX_OUTPUT_SIZE = 1024 * 1024;
private static final int WAV_HEADER_SIZE = 44;
// 16进制字符数组
private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
/**
* 使用 FFmpeg 将音频文件转码为标准 PCM-WAV
*/
public byte[] uploadToStandardPcm(File file) throws IOException {
if (file == null || !file.exists()) {
throw new IOException("音频文件不存在");
}
FFmpegFrameGrabber grabber = null;
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
grabber = new FFmpegFrameGrabber(file);
grabber.setSampleRate(TARGET_SAMPLE_RATE);
grabber.setAudioChannels(TARGET_CHANNELS);
grabber.start();
// 创建内存录制器
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(
outputStream,
grabber.getAudioChannels()
);
recorder.setSampleRate(TARGET_SAMPLE_RATE);
recorder.setAudioChannels(TARGET_CHANNELS);
recorder.setAudioBitrate(TARGET_BIT_RATE);
recorder.setAudioCodec(avcodec.AV_CODEC_ID_PCM_S16LE);
recorder.setFormat("wav");
recorder.start();
Frame frame;
int frameCount = 0;
int maxFrames = 1000; // 防止无限循环的安全限制
while (frameCount < maxFrames &&
outputStream.size() < MAX_OUTPUT_SIZE &&
(frame = grabber.grab()) != null) {
if (frame.samples != null) {
recorder.record(frame);
frameCount++;
}
}
recorder.stop();
recorder.release();
byte[] wavData = outputStream.toByteArray();
log.info("音频转码成功,输入文件: {}, 输出大小: {} bytes",
file.getName(), wavData.length);
return wavData;
} catch (Exception e) {
log.error("音频转码失败", e);
throw new IOException("音频转码失败: " + e.getMessage(), e);
} finally {
try {
if (grabber != null) {
grabber.stop();
grabber.release();
}
} catch (Exception e) {
log.warn("关闭音频抓取器失败", e);
}
}
}
/**
* 按时间截取音频(保证内容完整性)
*/
public byte[] uploadToStandardPcmWithFixedDuration(File file, int durationSeconds) throws IOException {
if (file == null || !file.exists()) {
throw new IOException("音频文件不存在");
}
FFmpegFrameGrabber grabber = null;
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
grabber = new FFmpegFrameGrabber(file);
grabber.setSampleRate(TARGET_SAMPLE_RATE);
grabber.setAudioChannels(TARGET_CHANNELS);
grabber.start();
// 计算目标样本数
int targetSamples = TARGET_SAMPLE_RATE * durationSeconds;
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(
outputStream,
grabber.getAudioChannels()
);
recorder.setSampleRate(TARGET_SAMPLE_RATE);
recorder.setAudioChannels(TARGET_CHANNELS);
recorder.setAudioBitrate(TARGET_BIT_RATE);
recorder.setAudioCodec(avcodec.AV_CODEC_ID_PCM_S16LE);
recorder.setFormat("wav");
recorder.start();
Frame frame;
int samplesCollected = 0;
while (samplesCollected < targetSamples && (frame = grabber.grab()) != null) {
if (frame.samples != null) {
// 计算这一帧的样本数
int samplesInFrame = frame.samples[0].remaining() / 2; // 16bit = 2 bytes per sample
// 如果这一帧会超过目标,创建部分帧
if (samplesCollected + samplesInFrame > targetSamples) {
int samplesNeeded = targetSamples - samplesCollected;
frame = createPartialFrame(frame, samplesNeeded);
}
recorder.record(frame);
samplesCollected += samplesInFrame;
}
}
// 如果音频太短,用静音填充剩余部分
if (samplesCollected < targetSamples) {
int silenceSamples = targetSamples - samplesCollected;
Frame silenceFrame = createSilenceFrame(silenceSamples);
recorder.record(silenceFrame);
}
recorder.stop();
recorder.release();
byte[] wavData = outputStream.toByteArray();
log.info("音频按时间截取成功,目标时长: {}秒, 实际输出: {} bytes",
durationSeconds, wavData.length);
return wavData;
} catch (Exception e) {
log.error("音频处理失败", e);
throw new IOException("音频处理失败: " + e.getMessage(), e);
} finally {
// 资源清理...
}
}
/**
* 创建部分帧(用于精确控制时长)
*/
private Frame createPartialFrame(Frame originalFrame, int samplesNeeded) {
Frame partialFrame = new Frame();
partialFrame.sampleRate = originalFrame.sampleRate;
partialFrame.audioChannels = originalFrame.audioChannels;
ByteBuffer originalBuffer = (ByteBuffer) originalFrame.samples[0];
ByteBuffer partialBuffer = ByteBuffer.allocate(samplesNeeded * 2); // 16bit
// 复制需要的样本数
for (int i = 0; i < samplesNeeded * 2 && originalBuffer.hasRemaining(); i++) {
partialBuffer.put(originalBuffer.get());
}
partialBuffer.flip();
partialFrame.samples = new Buffer[]{partialBuffer};
return partialFrame;
}
/**
* 创建静音帧
*/
private Frame createSilenceFrame(int samples) {
Frame silenceFrame = new Frame();
silenceFrame.sampleRate = TARGET_SAMPLE_RATE;
silenceFrame.audioChannels = TARGET_CHANNELS;
ByteBuffer silenceBuffer = ByteBuffer.allocate(samples * 2); // 16bit
// 静音数据就是全0
while (silenceBuffer.hasRemaining()) {
silenceBuffer.put((byte) 0);
}
silenceBuffer.flip();
silenceFrame.samples = new Buffer[]{silenceBuffer};
return silenceFrame;
}
/**
* 将 WAV 字节数据保存为文件(用于测试验证)
* @param wavData 完整的 WAV 文件数据(包含头部)
* @param filename 保存的文件名,如 "test_audio.wav"
* @return 保存的文件路径
*/
public String saveWavToFile(byte[] wavData, String filename) {
if (wavData == null || wavData.length == 0) {
log.warn("无法保存空的 WAV 数据");
return null;
}
// 创建测试目录
File testDir = new File("audio_test_output");
if (!testDir.exists()) {
testDir.mkdirs();
}
try {
// 生成带时间戳的文件名
String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
String safeFilename = filename.replaceAll("[^a-zA-Z0-9.-]", "_");
String fullFilename = timestamp + "_" + safeFilename;
File outputFile = new File(testDir, fullFilename);
// 写入文件
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
fos.write(wavData);
}
String absolutePath = outputFile.getAbsolutePath();
log.info("WAV 文件保存成功: {}, 文件大小: {} bytes", absolutePath, wavData.length);
// 验证文件有效性
if (validateWavFile(outputFile)) {
log.info("WAV 文件验证成功: {}", absolutePath);
} else {
log.warn("WAV 文件验证失败,文件可能损坏: {}", absolutePath);
}
return absolutePath;
} catch (Exception e) {
log.error("保存 WAV 文件失败", e);
return null;
}
}
/**
* 验证 WAV 文件的有效性
*/
private boolean validateWavFile(File wavFile) {
try {
if (!wavFile.exists() || wavFile.length() < WAV_HEADER_SIZE) {
return false;
}
byte[] fileData = Files.readAllBytes(wavFile.toPath());
// 检查 RIFF 头
if (fileData[0] != 'R' || fileData[1] != 'I' || fileData[2] != 'F' || fileData[3] != 'F') {
return false;
}
// 检查 WAVE 标识
if (fileData[8] != 'W' || fileData[9] != 'A' || fileData[10] != 'V' || fileData[11] != 'E') {
return false;
}
// 检查文件大小是否匹配
int statedSize = ByteBuffer.wrap(fileData, 4, 4)
.order(ByteOrder.LITTLE_ENDIAN)
.getInt();
int actualSize = fileData.length - 8; // 减去 "RIFF" 和大小字段
return statedSize == actualSize;
} catch (Exception e) {
log.warn("WAV 文件验证异常", e);
return false;
}
}
/**
* 获取音频信息(用于调试)
*/
public String getAudioInfo(byte[] wavData) {
if (wavData == null || wavData.length < WAV_HEADER_SIZE) {
return "无效的 WAV 数据";
}
try {
ByteBuffer buffer = ByteBuffer.wrap(wavData).order(ByteOrder.LITTLE_ENDIAN);
// 读取 WAV 头信息
int audioFormat = buffer.getShort(20); // 音频格式
int channels = buffer.getShort(22); // 声道数
int sampleRate = buffer.getInt(24); // 采样率
int byteRate = buffer.getInt(28); // 字节率
int blockAlign = buffer.getShort(32); // 块对齐
int bitsPerSample = buffer.getShort(34); // 位深
int dataSize = buffer.getInt(40); // 数据大小
int durationMs = (dataSize * 1000) / byteRate; // 计算时长(毫秒)
return String.format(
"格式: %s, 声道: %d, 采样率: %d Hz, 位深: %d bit, 时长: %.2f 秒, 数据大小: %d bytes",
audioFormat == 1 ? "PCM" : "未知(" + audioFormat + ")",
channels,
sampleRate,
bitsPerSample,
durationMs / 1000.0,
dataSize
);
} catch (Exception e) {
return "解析音频信息失败: " + e.getMessage();
}
}
/**
* 备用的手动 WAV 文件生成方法
*/
public byte[] convertToStandardWav(File file) throws IOException {
try {
// 使用 FFmpeg 读取音频数据
byte[] pcmData = extractPcmData(file);
// 生成 WAV 文件头
byte[] wavHeader = createWavHeader(pcmData.length);
// 合并头和数据
byte[] wavData = new byte[wavHeader.length + pcmData.length];
System.arraycopy(wavHeader, 0, wavData, 0, wavHeader.length);
System.arraycopy(pcmData, 0, wavData, wavHeader.length, pcmData.length);
return wavData;
} catch (Exception e) {
throw new IOException("音频转换失败: " + e.getMessage(), e);
}
}
/**
* 将原始 PCM 数据(无头)转换为标准 WAV 格式带44字节头
* 输入单声道、16bit、16000Hz 的 raw PCM
* 输出:完整 WAV 文件字节数组
*/
public byte[] rawPcmToStandardWav(byte[] rawPcmData) throws IOException {
try {
// 生成WAV头
byte[] wavHeader = createWavHeader(rawPcmData.length);
// 合并头 + 数据
byte[] wavData = new byte[wavHeader.length + rawPcmData.length];
System.arraycopy(wavHeader, 0, wavData, 0, wavHeader.length);
System.arraycopy(rawPcmData, 0, wavData, wavHeader.length, rawPcmData.length);
log.info("原始PCM转为标准WAV成功原始大小: {} bytes, 输出大小: {} bytes",
rawPcmData.length, wavData.length);
return wavData;
} catch (Exception e) {
throw new IOException("音频转换失败: " + e.getMessage(), e);
}
}
/**
* 提取 PCM 数据
*/
private byte[] extractPcmData(File file) throws Exception {
FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(file);
grabber.setSampleRate(TARGET_SAMPLE_RATE);
grabber.setAudioChannels(TARGET_CHANNELS);
grabber.start();
ByteArrayOutputStream pcmStream = new ByteArrayOutputStream();
Frame frame;
int samplesProcessed = 0;
int maxSamples = TARGET_SAMPLE_RATE * 60; // 最多 60 秒
try {
while (samplesProcessed < maxSamples &&
pcmStream.size() < MAX_OUTPUT_SIZE &&
(frame = grabber.grab()) != null) {
if (frame.samples != null && frame.samples[0] != null) {
ShortBuffer shortBuf = (ShortBuffer) frame.samples[0];
int remaining = shortBuf.remaining();
byte[] frameBytes = new byte[remaining * 2];
ByteBuffer.wrap(frameBytes)
.order(ByteOrder.LITTLE_ENDIAN)
.asShortBuffer()
.put(shortBuf);
pcmStream.write(frameBytes);
samplesProcessed += remaining;
}
}
} finally {
grabber.stop();
grabber.release();
}
return pcmStream.toByteArray();
}
/**
* 创建 WAV 文件头
*/
private byte[] createWavHeader(int dataLength) {
short channels = TARGET_CHANNELS;
short sampleBits = 16; // 16-bit
int sampleRate = TARGET_SAMPLE_RATE;
short blockAlign = (short) (channels * sampleBits / 8);
int byteRate = sampleRate * blockAlign;
int riffLength = dataLength + WAV_HEADER_SIZE - 8;
ByteBuffer buffer = ByteBuffer.allocate(WAV_HEADER_SIZE)
.order(ByteOrder.LITTLE_ENDIAN);
buffer.put("RIFF".getBytes());
buffer.putInt(riffLength);
buffer.put("WAVE".getBytes());
buffer.put("fmt ".getBytes());
buffer.putInt(16);
buffer.putShort((short) 1); // PCM format
buffer.putShort(channels);
buffer.putInt(sampleRate);
buffer.putInt(byteRate);
buffer.putShort(blockAlign);
buffer.putShort(sampleBits);
buffer.put("data".getBytes());
buffer.putInt(dataLength);
return buffer.array();
}
/**
* 获取支持的音频格式列表
*/
public String[] getSupportedFormats() {
return new String[] {
"mp3", "wav", "aac", "flac", "m4a", "ogg", "wma",
"amr", "aiff", "au", "mp2", "ac3", "opus"
};
}
/**
* 将字节数组转换为16进制字符串列表
* 每个字节转换为两个16进制字符作为一个字符串元素
*/
public List<String> bytesToHexList(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return new ArrayList<>();
}
List<String> hexList = new ArrayList<>(bytes.length);
for (byte b : bytes) {
int value = b & 0xFF;
char high = HEX_ARRAY[value >>> 4];
char low = HEX_ARRAY[value & 0x0F];
hexList.add(String.valueOf(high) + low);
}
log.debug("字节数组转换为16进制列表字节数: {}, 16进制元素数: {}",
bytes.length, hexList.size());
return hexList;
}
/**
* 将字节数组转换为十进制字符串列表
* 每个字节转换为一个十进制字符串元素
*/
public List<String> bytesToDecList(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return new ArrayList<>();
}
List<String> decList = new ArrayList<>(bytes.length);
for (byte b : bytes) {
int value = b & 0xFF; // 0-255
decList.add(String.valueOf(value));
}
log.debug("字节数组转换为十进制列表,字节数: {}, 十进制元素数: {}", bytes.length, decList.size());
return decList;
}
}