diff --git a/fys-admin/src/main/java/com/fuyuanshen/app/controller/AppVideoController.java b/fys-admin/src/main/java/com/fuyuanshen/app/controller/AppVideoController.java index 3b807b41..11bc7540 100644 --- a/fys-admin/src/main/java/com/fuyuanshen/app/controller/AppVideoController.java +++ b/fys-admin/src/main/java/com/fuyuanshen/app/controller/AppVideoController.java @@ -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> 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 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 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> uploadVideo(@RequestParam("file") MultipartFile file) { + return R.ok(videoProcessService.processVideo(file)); } /** - * rgb565 转 hex + * 上传音频文件并转码 */ - private List bytesToHexList(byte[] bytes) { - List 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> 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 extractFramesFromVideo(File videoFile) throws Exception { - List 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 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 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> uploadAudioTTS(@RequestParam String text) throws IOException { + return R.ok(audioProcessService.generateStandardPcmData(text)); } } diff --git a/fys-admin/src/main/java/com/fuyuanshen/app/service/AudioProcessService.java b/fys-admin/src/main/java/com/fuyuanshen/app/service/AudioProcessService.java new file mode 100644 index 00000000..a36e8f11 --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/app/service/AudioProcessService.java @@ -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 SUPPORTED_FORMATS = Arrays.asList( + ".wav", ".mp3", ".aac", ".flac", ".m4a", ".ogg" + ); + + private final AudioProcessUtil audioProcessUtil; + private final AlibabaTTSUtil alibabaTTSUtil; + + /** + * 处理上传的音频文件 + */ + public List 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 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 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 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()); + } + } + } + + +} \ No newline at end of file diff --git a/fys-admin/src/main/java/com/fuyuanshen/app/service/VideoProcessService.java b/fys-admin/src/main/java/com/fuyuanshen/app/service/VideoProcessService.java new file mode 100644 index 00000000..a141f52f --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/app/service/VideoProcessService.java @@ -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 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 processVideo(MultipartFile file) { + // 1. 参数校验 + validateVideoFile(file); + + File tempFile = null; + try { + // 2. 创建临时文件 + tempFile = videoProcessUtil.createTempVideoFile(file); + + // 3. 处理视频并提取帧数据 + List 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); + } +} \ No newline at end of file diff --git a/fys-admin/src/main/java/com/fuyuanshen/web/util/VideoProcessUtil.java b/fys-admin/src/main/java/com/fuyuanshen/web/util/VideoProcessUtil.java new file mode 100644 index 00000000..06f96d5e --- /dev/null +++ b/fys-admin/src/main/java/com/fuyuanshen/web/util/VideoProcessUtil.java @@ -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 processVideoToHex(File videoFile, int frameRate, int duration, int width, int height) throws Exception { + // 1. 提取视频帧 + List frames = extractFramesFromVideo(videoFile, frameRate, duration, width, height); + + // 2. 转换为RGB565格式 + byte[] binaryData = convertFramesToRGB565(frames); + + // 3. 转换为Hex字符串列表 + return bytesToHexList(binaryData); + } + + /** + * 从视频中提取帧 + */ + private List extractFramesFromVideo(File videoFile, int frameRate, int duration, int width, int height) throws Exception { + List 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 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 bytesToHexList(byte[] bytes) { + List 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 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); + } + } + } +} \ No newline at end of file diff --git a/fys-admin/src/main/resources/application-prod.yml b/fys-admin/src/main/resources/application-prod.yml index 67cca4f5..349574a4 100644 --- a/fys-admin/src/main/resources/application-prod.yml +++ b/fys-admin/src/main/resources/application-prod.yml @@ -287,6 +287,12 @@ mqtt: pubTopic: B/# pubClientId: fys_pubClient +# TTS语音交互配置 +alibaba: + tts: + appKey: KTwSUKMrf2olFfjC + akId: LTAI5t6RsfCvQh57qojzbEoe + akSecret: MTqvK2mXYeCRkl1jVPndiNumyaad0R # 文件存储路径 file: diff --git a/fys-modules/fys-equipment/pom.xml b/fys-modules/fys-equipment/pom.xml index ee98be42..87edeb66 100644 --- a/fys-modules/fys-equipment/pom.xml +++ b/fys-modules/fys-equipment/pom.xml @@ -118,6 +118,22 @@ fys-customer + + org.bytedeco + javacv-platform + + + + com.alibaba.nls + nls-sdk-common + + + + com.squareup.okhttp3 + okhttp + 3.9.1 + + com.alibaba easyexcel diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/utils/AlibabaTTSUtil.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/utils/AlibabaTTSUtil.java new file mode 100644 index 00000000..d983401d --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/utils/AlibabaTTSUtil.java @@ -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()); + } + } +} \ No newline at end of file diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/utils/AudioProcessUtil.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/utils/AudioProcessUtil.java new file mode 100644 index 00000000..4a8cf309 --- /dev/null +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/utils/AudioProcessUtil.java @@ -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 bytesToHexList(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return new ArrayList<>(); + } + + List 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 bytesToDecList(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return new ArrayList<>(); + } + + List 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; + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 3adbdc0c..fbd091a7 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,8 @@ 3.3.4 1.2.83 + + 2.2.1 8.7.2-20250101 @@ -364,6 +366,12 @@ ${fastjson.version} + + com.alibaba.nls + nls-sdk-common + ${nls.version} + + com.fuyuanshen fys-system