Merge branch 'jingquan' into dyf-device

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

View File

@ -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));
}
}

View File

@ -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());
}
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}
}

View File

@ -287,6 +287,12 @@ mqtt:
pubTopic: B/#
pubClientId: fys_pubClient
# TTS语音交互配置
alibaba:
tts:
appKey: KTwSUKMrf2olFfjC
akId: LTAI5t6RsfCvQh57qojzbEoe
akSecret: MTqvK2mXYeCRkl1jVPndiNumyaad0R
# 文件存储路径
file:

View File

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

View File

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

View File

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

View File

@ -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>