forked from dyf/fys-Multi-tenant
Merge branch 'jingquan' into dyf-device
This commit is contained in:
@ -1,249 +1,54 @@
|
||||
package com.fuyuanshen.app.controller;
|
||||
|
||||
import com.fuyuanshen.app.service.AudioProcessService;
|
||||
import com.fuyuanshen.app.service.VideoProcessService;
|
||||
import com.fuyuanshen.common.core.domain.R;
|
||||
import com.fuyuanshen.common.idempotent.annotation.RepeatSubmit;
|
||||
import com.fuyuanshen.common.web.core.BaseController;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.bytedeco.javacv.FFmpegFrameGrabber;
|
||||
import org.bytedeco.javacv.Frame;
|
||||
import org.bytedeco.javacv.Java2DFrameUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* APP 视频处理
|
||||
* @date 2025-09-15
|
||||
* APP 视频处理控制器
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/app/video")
|
||||
public class AppVideoController extends BaseController {
|
||||
// 可配置项:建议从 application.yml 中读取
|
||||
private static final int MAX_VIDEO_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
private static final int FRAME_RATE = 15; // 每秒抽15帧
|
||||
private static final int DURATION = 2; // 抽2秒
|
||||
private static final int TOTAL_FRAMES = FRAME_RATE * DURATION;
|
||||
private static final int WIDTH = 160;
|
||||
private static final int HEIGHT = 80;
|
||||
private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
|
||||
|
||||
private final VideoProcessService videoProcessService;
|
||||
private final AudioProcessService audioProcessService;
|
||||
|
||||
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public R<List<String>> upload(@RequestParam("file") MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
return R.fail("上传文件不能为空");
|
||||
}
|
||||
|
||||
if (!isVideo(file.getOriginalFilename())) {
|
||||
return R.fail("只允许上传视频文件");
|
||||
}
|
||||
|
||||
if (file.getSize() > MAX_VIDEO_SIZE) {
|
||||
return R.fail("视频大小不能超过10MB");
|
||||
}
|
||||
|
||||
File tempFile = null;
|
||||
try {
|
||||
// 创建临时文件保存上传的视频
|
||||
tempFile = createTempVideoFile(file);
|
||||
|
||||
|
||||
List<BufferedImage> frames = extractFramesFromVideo(tempFile);
|
||||
if (frames.isEmpty()) {
|
||||
return R.fail("无法提取任何帧");
|
||||
}
|
||||
|
||||
// ✅ 新增:保存帧为图片
|
||||
//saveFramesToLocal(frames, "extracted_frame");
|
||||
|
||||
byte[] binaryData = convertFramesToRGB565(frames);
|
||||
// String base64Data = Base64.getEncoder().encodeToString(binaryData);
|
||||
//
|
||||
// return R.ok(base64Data);
|
||||
// 构造响应头
|
||||
// 将二进制数据转为 Hex 字符串
|
||||
// 转换为 Hex 字符串列表
|
||||
List<String> hexList = bytesToHexList(binaryData);
|
||||
|
||||
return R.ok(hexList);
|
||||
|
||||
} catch (Exception e) {
|
||||
return R.fail("视频处理失败:" + e.getMessage());
|
||||
} finally {
|
||||
deleteTempFile(tempFile);
|
||||
}
|
||||
@RepeatSubmit(interval = 2, timeUnit = TimeUnit.SECONDS,message = "请勿重复提交!")
|
||||
public R<List<String>> uploadVideo(@RequestParam("file") MultipartFile file) {
|
||||
return R.ok(videoProcessService.processVideo(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* rgb565 转 hex
|
||||
* 上传音频文件并转码
|
||||
*/
|
||||
private List<String> bytesToHexList(byte[] bytes) {
|
||||
List<String> hexList = new ArrayList<>();
|
||||
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);
|
||||
}
|
||||
return hexList;
|
||||
@PostMapping(value = "/audio", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RepeatSubmit(interval = 2, timeUnit = TimeUnit.SECONDS,message = "请勿重复提交!")
|
||||
public R<List<String>> uploadAudio(@RequestParam("file") MultipartFile file) {
|
||||
return R.ok(audioProcessService.processAudio(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临时文件并保存上传的视频
|
||||
* 文字转音频TTS服务
|
||||
*/
|
||||
private File createTempVideoFile(MultipartFile file) throws Exception {
|
||||
File tempFile = Files.createTempFile("upload-", ".mp4").toFile();
|
||||
file.transferTo(tempFile);
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从视频中按时间均匀提取指定数量的帧
|
||||
*/
|
||||
private List<BufferedImage> extractFramesFromVideo(File videoFile) throws Exception {
|
||||
List<BufferedImage> frames = new ArrayList<>();
|
||||
|
||||
try (FFmpegFrameGrabber grabber = FFmpegFrameGrabber.createDefault(videoFile)) {
|
||||
grabber.start();
|
||||
|
||||
// 获取视频总帧数和帧率
|
||||
long totalFramesInVideo = grabber.getLengthInFrames();
|
||||
int fps = (int) Math.round(grabber.getFrameRate());
|
||||
if (fps <= 0) fps = 30;
|
||||
|
||||
double durationSeconds = (double) totalFramesInVideo / fps;
|
||||
|
||||
if (durationSeconds < DURATION) {
|
||||
throw new IllegalArgumentException("视频太短,至少需要 " + DURATION + " 秒");
|
||||
}
|
||||
|
||||
// 计算每帧之间的间隔(浮点以实现更精确跳转)
|
||||
double frameInterval = (double) totalFramesInVideo / TOTAL_FRAMES;
|
||||
|
||||
for (int i = 0; i < TOTAL_FRAMES; i++) {
|
||||
int targetFrameNumber = (int) Math.round(i * frameInterval);
|
||||
|
||||
// 避免设置无效帧号
|
||||
if (targetFrameNumber >= totalFramesInVideo) {
|
||||
throw new IllegalArgumentException("目标帧超出范围: " + targetFrameNumber + " ");
|
||||
}
|
||||
|
||||
grabber.setFrameNumber(targetFrameNumber);
|
||||
Frame frame = grabber.grab();
|
||||
|
||||
if (frame != null && frame.image != null) {
|
||||
BufferedImage bufferedImage = Java2DFrameUtils.toBufferedImage(frame);
|
||||
frames.add(cropImage(bufferedImage, WIDTH, HEIGHT));
|
||||
} else {
|
||||
throw new IllegalArgumentException("无法获取第 " + targetFrameNumber + "帧 ");
|
||||
}
|
||||
}
|
||||
|
||||
grabber.stop();
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将抽取的帧保存到本地,用于调试
|
||||
*/
|
||||
private void saveFramesToLocal(List<BufferedImage> frames, String prefix) {
|
||||
// 指定输出目录
|
||||
File outputDir = new File("output_frames");
|
||||
if (!outputDir.exists()) {
|
||||
outputDir.mkdirs();
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
for (BufferedImage frame : frames) {
|
||||
try {
|
||||
File outputImage = new File(outputDir, prefix + "_" + (index++) + ".png");
|
||||
ImageIO.write(frame, "png", outputImage);
|
||||
System.out.println("保存帧图片成功: " + outputImage.getAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("保存帧图片失败 " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将所有帧转换为 RGB565 格式字节数组
|
||||
*/
|
||||
private byte[] convertFramesToRGB565(List<BufferedImage> frames) throws Exception {
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
|
||||
for (BufferedImage image : frames) {
|
||||
byte[] rgb565Bytes = convertToRGB565(image);
|
||||
byteArrayOutputStream.write(rgb565Bytes);
|
||||
}
|
||||
|
||||
return byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是支持的视频格式
|
||||
*/
|
||||
private boolean isVideo(String filename) {
|
||||
String ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
return Arrays.asList(".mp4", ".avi", ".mov", ".mkv").contains(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪图像到目标尺寸
|
||||
*/
|
||||
private BufferedImage cropImage(BufferedImage img, int targetWidth, int targetHeight) {
|
||||
int w = Math.min(img.getWidth(), targetWidth);
|
||||
int h = Math.min(img.getHeight(), targetHeight);
|
||||
return img.getSubimage(0, 0, w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 BufferedImage 转换为 RGB565 格式的字节数组
|
||||
*/
|
||||
private byte[] convertToRGB565(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
byte[] result = new byte[width * height * 2]; // RGB565: 2 bytes per pixel
|
||||
int index = 0;
|
||||
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int rgb = image.getRGB(x, y);
|
||||
int r = ((rgb >> 16) & 0xFF) >> 3;
|
||||
int g = ((rgb >> 8) & 0xFF) >> 2;
|
||||
int b = (rgb & 0xFF) >> 3;
|
||||
short pixel = (short) ((r << 11) | (g << 5) | b);
|
||||
|
||||
result[index++] = (byte) (pixel >> 8); // High byte first
|
||||
result[index++] = (byte) pixel;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除临时文件
|
||||
*/
|
||||
private void deleteTempFile(File file) {
|
||||
if (file != null && file.exists()) {
|
||||
if (!file.delete()) {
|
||||
throw new IllegalArgumentException("无法删除临时文件: " + file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
@GetMapping("/audioTTS")
|
||||
@RepeatSubmit(interval = 2, timeUnit = TimeUnit.SECONDS,message = "请勿重复提交!")
|
||||
public R<List<String>> uploadAudioTTS(@RequestParam String text) throws IOException {
|
||||
return R.ok(audioProcessService.generateStandardPcmData(text));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,174 @@
|
||||
package com.fuyuanshen.app.service;
|
||||
|
||||
import com.fuyuanshen.equipment.utils.AlibabaTTSUtil;
|
||||
import com.fuyuanshen.equipment.utils.AudioProcessUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 音频处理服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AudioProcessService {
|
||||
|
||||
// 配置参数
|
||||
private static final int MAX_AUDIO_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
private static final List<String> SUPPORTED_FORMATS = Arrays.asList(
|
||||
".wav", ".mp3", ".aac", ".flac", ".m4a", ".ogg"
|
||||
);
|
||||
|
||||
private final AudioProcessUtil audioProcessUtil;
|
||||
private final AlibabaTTSUtil alibabaTTSUtil;
|
||||
|
||||
/**
|
||||
* 处理上传的音频文件
|
||||
*/
|
||||
public List<String> processAudio(MultipartFile file) {
|
||||
// 1. 参数校验
|
||||
validateAudioFile(file);
|
||||
|
||||
File tempFile = null;
|
||||
try {
|
||||
// 2. 创建临时文件
|
||||
tempFile = createTempAudioFile(file);
|
||||
|
||||
// 3. 转码为标准PCM-WAV格式
|
||||
byte[] pcmData = audioProcessUtil.convertToStandardWav(tempFile);
|
||||
log.info("音频处理成功,输出数据大小: {} bytes", pcmData.length);
|
||||
|
||||
// 获取音频信息
|
||||
// String audioInfo = audioProcessUtil.getAudioInfo(pcmData);
|
||||
// log.info("音频处理成功,音频信息: {}", audioInfo);
|
||||
//
|
||||
// // 保存测试文件(用于验证)
|
||||
// String savedPath = audioProcessUtil.saveWavToFile(pcmData, "test_output.wav");
|
||||
// if (savedPath != null) {
|
||||
// log.info("测试文件已保存: {}", savedPath);
|
||||
// }
|
||||
|
||||
// 将byte[]转换为16进制字符串列表
|
||||
List<String> hexList = audioProcessUtil.bytesToHexList(pcmData);
|
||||
|
||||
log.info("音频处理完成,原始数据大小: {} bytes, 16进制数据长度: {}",
|
||||
pcmData.length, hexList.size());
|
||||
|
||||
return hexList;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("音频处理失败", e);
|
||||
throw new RuntimeException("音频处理失败", e);
|
||||
} finally {
|
||||
// 4. 清理临时文件
|
||||
deleteTempFile(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标准PCM数据(单声道,16K采样率,16bit深度,包含44字节WAV头)
|
||||
* 数据总大小不超过2MB,如果超过将抛出异常
|
||||
* @param text 要转换的文本内容
|
||||
* @return 标准PCM数据字节数组(WAV格式)
|
||||
* @throws IOException 处理失败时抛出
|
||||
* @throws IllegalArgumentException 如果生成的数据超过2MB
|
||||
*/
|
||||
public List<String> generateStandardPcmData(String text) throws IOException {
|
||||
// 参数校验
|
||||
if (text == null || text.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("文本内容不能为空");
|
||||
}
|
||||
if (text.length() > 100) {
|
||||
throw new IllegalArgumentException("文本长度超过限制(最大100字符)");
|
||||
}
|
||||
log.info("输入文本长度: {}", text.length());
|
||||
try {
|
||||
byte[] rawPcmData = alibabaTTSUtil.generateStandardPcmData(text);
|
||||
|
||||
// 使用AudioProcessUtil转换成带头44字节 PCM
|
||||
byte[] pcmData = audioProcessUtil.rawPcmToStandardWav(rawPcmData);
|
||||
|
||||
// String savedPath = audioProcessUtil.saveWavToFile(pcmData, "test_output.wav");
|
||||
// if (savedPath != null) {
|
||||
// log.info("测试文件已保存: {}", savedPath);
|
||||
// }
|
||||
|
||||
// 将byte[]转换为16进制字符串列表
|
||||
List<String> hexList = audioProcessUtil.bytesToHexList(pcmData);
|
||||
|
||||
log.info("generateStandardPcmData音频处理完成,原始数据大小: {} bytes, 16进制数据长度: {}",
|
||||
pcmData.length, hexList.size());
|
||||
|
||||
return hexList;
|
||||
} finally {
|
||||
// 4. 清理临时文件
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证音频文件
|
||||
*/
|
||||
private void validateAudioFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("上传文件不能为空");
|
||||
}
|
||||
|
||||
if (!isAudioFile(file.getOriginalFilename())) {
|
||||
throw new IllegalArgumentException("只允许上传音频文件");
|
||||
}
|
||||
|
||||
if (file.getSize() > MAX_AUDIO_SIZE) {
|
||||
throw new IllegalArgumentException("音频大小不能超过5MB");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是支持的音频格式
|
||||
*/
|
||||
private boolean isAudioFile(String filename) {
|
||||
if (filename == null || filename.lastIndexOf('.') == -1) {
|
||||
return false;
|
||||
}
|
||||
String ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
return SUPPORTED_FORMATS.contains(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临时音频文件
|
||||
*/
|
||||
private File createTempAudioFile(MultipartFile file) throws IOException {
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String extension = "";
|
||||
if (originalFilename != null && originalFilename.contains(".")) {
|
||||
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
|
||||
}
|
||||
|
||||
File tempFile = File.createTempFile("audio-", extension);
|
||||
file.transferTo(tempFile);
|
||||
log.debug("创建临时音频文件: {}", tempFile.getAbsolutePath());
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除临时文件
|
||||
*/
|
||||
private void deleteTempFile(File file) {
|
||||
if (file != null && file.exists()) {
|
||||
if (file.delete()) {
|
||||
log.debug("删除临时文件成功: {}", file.getAbsolutePath());
|
||||
} else {
|
||||
log.warn("无法删除临时文件: {}", file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package com.fuyuanshen.app.service;
|
||||
|
||||
import com.fuyuanshen.web.util.VideoProcessUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 视频处理服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VideoProcessService {
|
||||
|
||||
// 配置参数
|
||||
private static final int MAX_VIDEO_SIZE = 10 * 1024 * 1024;
|
||||
private static final List<String> SUPPORTED_FORMATS = Arrays.asList(".mp4", ".avi", ".mov", ".mkv");
|
||||
private static final int FRAME_RATE = 15;
|
||||
private static final int DURATION = 2;
|
||||
private static final int WIDTH = 160;
|
||||
private static final int HEIGHT = 80;
|
||||
|
||||
private final VideoProcessUtil videoProcessUtil;
|
||||
|
||||
public List<String> processVideo(MultipartFile file) {
|
||||
// 1. 参数校验
|
||||
validateVideoFile(file);
|
||||
|
||||
File tempFile = null;
|
||||
try {
|
||||
// 2. 创建临时文件
|
||||
tempFile = videoProcessUtil.createTempVideoFile(file);
|
||||
|
||||
// 3. 处理视频并提取帧数据
|
||||
List<String> hexList = videoProcessUtil.processVideoToHex(
|
||||
tempFile, FRAME_RATE, DURATION, WIDTH, HEIGHT
|
||||
);
|
||||
|
||||
log.info("视频处理成功,生成Hex数据长度: {}", hexList.size());
|
||||
return hexList;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("视频处理失败", e);
|
||||
throw new RuntimeException("视频处理失败", e);
|
||||
} finally {
|
||||
// 4. 清理临时文件
|
||||
videoProcessUtil.deleteTempFile(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证视频文件
|
||||
*/
|
||||
private void validateVideoFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("上传文件不能为空");
|
||||
}
|
||||
|
||||
if (!isVideoFile(file.getOriginalFilename())) {
|
||||
throw new IllegalArgumentException("只允许上传视频文件");
|
||||
}
|
||||
|
||||
if (file.getSize() > MAX_VIDEO_SIZE) {
|
||||
throw new IllegalArgumentException("视频大小不能超过10MB");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是支持的视频格式
|
||||
*/
|
||||
private boolean isVideoFile(String filename) {
|
||||
if (filename == null || filename.lastIndexOf('.') == -1) {
|
||||
return false;
|
||||
}
|
||||
String ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
return SUPPORTED_FORMATS.contains(ext);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
package com.fuyuanshen.web.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bytedeco.javacv.FFmpegFrameGrabber;
|
||||
import org.bytedeco.javacv.Frame;
|
||||
import org.bytedeco.javacv.Java2DFrameUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 视频处理工具类
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class VideoProcessUtil {
|
||||
|
||||
private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
|
||||
|
||||
/**
|
||||
* 创建临时视频文件
|
||||
*/
|
||||
public File createTempVideoFile(MultipartFile file) throws Exception {
|
||||
File tempFile = Files.createTempFile("upload-", ".mp4").toFile();
|
||||
file.transferTo(tempFile);
|
||||
log.debug("创建临时视频文件: {}", tempFile.getAbsolutePath());
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理视频并转换为Hex字符串列表
|
||||
*/
|
||||
public List<String> processVideoToHex(File videoFile, int frameRate, int duration, int width, int height) throws Exception {
|
||||
// 1. 提取视频帧
|
||||
List<BufferedImage> frames = extractFramesFromVideo(videoFile, frameRate, duration, width, height);
|
||||
|
||||
// 2. 转换为RGB565格式
|
||||
byte[] binaryData = convertFramesToRGB565(frames);
|
||||
|
||||
// 3. 转换为Hex字符串列表
|
||||
return bytesToHexList(binaryData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从视频中提取帧
|
||||
*/
|
||||
private List<BufferedImage> extractFramesFromVideo(File videoFile, int frameRate, int duration, int width, int height) throws Exception {
|
||||
List<BufferedImage> frames = new ArrayList<>();
|
||||
int totalFramesToExtract = frameRate * duration;
|
||||
|
||||
try (FFmpegFrameGrabber grabber = FFmpegFrameGrabber.createDefault(videoFile)) {
|
||||
grabber.start();
|
||||
|
||||
long totalFramesInVideo = grabber.getLengthInFrames();
|
||||
int fps = (int) Math.round(grabber.getFrameRate());
|
||||
if (fps <= 0) fps = 30;
|
||||
|
||||
double durationSeconds = (double) totalFramesInVideo / fps;
|
||||
if (durationSeconds < duration) {
|
||||
throw new IllegalArgumentException("视频太短,至少需要 " + duration + " 秒");
|
||||
}
|
||||
|
||||
double frameInterval = (double) totalFramesInVideo / totalFramesToExtract;
|
||||
|
||||
for (int i = 0; i < totalFramesToExtract; i++) {
|
||||
int targetFrameNumber = (int) Math.round(i * frameInterval);
|
||||
|
||||
if (targetFrameNumber >= totalFramesInVideo) {
|
||||
throw new IllegalArgumentException("目标帧超出范围: " + targetFrameNumber);
|
||||
}
|
||||
|
||||
grabber.setFrameNumber(targetFrameNumber);
|
||||
Frame frame = grabber.grab();
|
||||
|
||||
if (frame != null && frame.image != null) {
|
||||
BufferedImage bufferedImage = Java2DFrameUtils.toBufferedImage(frame);
|
||||
frames.add(cropImage(bufferedImage, width, height));
|
||||
} else {
|
||||
throw new IllegalArgumentException("无法获取第 " + targetFrameNumber + "帧");
|
||||
}
|
||||
}
|
||||
|
||||
grabber.stop();
|
||||
}
|
||||
|
||||
log.debug("从视频中提取了 {} 帧", frames.size());
|
||||
return frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将所有帧转换为 RGB565 格式字节数组
|
||||
*/
|
||||
private byte[] convertFramesToRGB565(List<BufferedImage> frames) throws Exception {
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
|
||||
for (BufferedImage image : frames) {
|
||||
byte[] rgb565Bytes = convertToRGB565(image);
|
||||
byteArrayOutputStream.write(rgb565Bytes);
|
||||
}
|
||||
|
||||
byte[] result = byteArrayOutputStream.toByteArray();
|
||||
log.debug("转换RGB565数据完成,总字节数: {}", result.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字节数组转换为Hex字符串列表
|
||||
*/
|
||||
private List<String> bytesToHexList(byte[] bytes) {
|
||||
List<String> hexList = new ArrayList<>();
|
||||
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);
|
||||
}
|
||||
return hexList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除临时文件
|
||||
*/
|
||||
public void deleteTempFile(File file) {
|
||||
if (file != null && file.exists()) {
|
||||
if (file.delete()) {
|
||||
log.debug("删除临时文件成功: {}", file.getAbsolutePath());
|
||||
} else {
|
||||
log.warn("无法删除临时文件: {}", file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁剪图像到目标尺寸
|
||||
*/
|
||||
private BufferedImage cropImage(BufferedImage img, int targetWidth, int targetHeight) {
|
||||
int w = Math.min(img.getWidth(), targetWidth);
|
||||
int h = Math.min(img.getHeight(), targetHeight);
|
||||
return img.getSubimage(0, 0, w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 BufferedImage 转换为 RGB565 格式的字节数组
|
||||
*/
|
||||
private byte[] convertToRGB565(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
byte[] result = new byte[width * height * 2];
|
||||
int index = 0;
|
||||
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int rgb = image.getRGB(x, y);
|
||||
int r = ((rgb >> 16) & 0xFF) >> 3;
|
||||
int g = ((rgb >> 8) & 0xFF) >> 2;
|
||||
int b = (rgb & 0xFF) >> 3;
|
||||
short pixel = (short) ((r << 11) | (g << 5) | b);
|
||||
|
||||
result[index++] = (byte) (pixel >> 8);
|
||||
result[index++] = (byte) pixel;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存帧到本地(用于调试)
|
||||
*/
|
||||
public void saveFramesToLocal(List<BufferedImage> frames, String prefix) {
|
||||
File outputDir = new File("output_frames");
|
||||
if (!outputDir.exists()) {
|
||||
outputDir.mkdirs();
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
for (BufferedImage frame : frames) {
|
||||
try {
|
||||
File outputImage = new File(outputDir, prefix + "_" + (index++) + ".png");
|
||||
ImageIO.write(frame, "png", outputImage);
|
||||
log.debug("保存帧图片成功: {}", outputImage.getAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
log.error("保存帧图片失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -287,6 +287,12 @@ mqtt:
|
||||
pubTopic: B/#
|
||||
pubClientId: fys_pubClient
|
||||
|
||||
# TTS语音交互配置
|
||||
alibaba:
|
||||
tts:
|
||||
appKey: KTwSUKMrf2olFfjC
|
||||
akId: LTAI5t6RsfCvQh57qojzbEoe
|
||||
akSecret: MTqvK2mXYeCRkl1jVPndiNumyaad0R
|
||||
|
||||
# 文件存储路径
|
||||
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>
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
8
pom.xml
8
pom.xml
@ -48,6 +48,8 @@
|
||||
<sms4j.version>3.3.4</sms4j.version>
|
||||
<!-- 限制框架中的fastjson版本 -->
|
||||
<fastjson.version>1.2.83</fastjson.version>
|
||||
<!-- 阿里语音交互SDK nls版本 -->
|
||||
<nls.version>2.2.1</nls.version>
|
||||
<!-- 面向运行时的D-ORM依赖 -->
|
||||
<anyline.version>8.7.2-20250101</anyline.version>
|
||||
<!-- 工作流配置 -->
|
||||
@ -364,6 +366,12 @@
|
||||
<version>${fastjson.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.nls</groupId>
|
||||
<artifactId>nls-sdk-common</artifactId>
|
||||
<version>${nls.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fuyuanshen</groupId>
|
||||
<artifactId>fys-system</artifactId>
|
||||
|
||||
Reference in New Issue
Block a user