forked from dyf/fys-Multi-tenant
feat(equipment): 添加阿里巴巴TTS语音合成工具类
- 实现文本转语音功能,支持多种声音、语速、音量等参数调节 - 集成阿里云TTS服务,支持访问令牌自动刷新与缓存 - 提供HTTP客户端配置与请求处理逻辑 - 支持生成标准PCM数据及WAV格式音频文件 - 实现音频文件保存与错误处理机制 - 添加参数校验与日志记录功能 - 集成Redis缓存管理访问令牌- 支持URL编码与请求构建逻辑 - 实现响应处理与音频数据写入文件功能 - 添加静默删除临时文件与错误响应处理机制
This commit is contained in:
@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user