Compare commits

...

2 Commits

Author SHA1 Message Date
zhonghua1
8f35c605d1 小程序端直接把音频转成文本 2026-01-05 20:53:07 +08:00
zhonghua1
1167707631 语音转文本提取为通用方法 2026-01-04 22:06:14 +08:00
5 changed files with 400 additions and 7 deletions

View File

@@ -1,6 +1,5 @@
package com.rj.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@@ -18,9 +17,4 @@ public class RestTemplateConfig {
public RestTemplate restTemplate() {
return new RestTemplate();
}
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
}

View File

@@ -1,9 +1,13 @@
package com.rj.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.rj.dto.AsrRequest;
import com.rj.dto.AsrResponse;
import com.rj.entity.AudioManagementSegments;
import com.rj.service.IAudioManagementSegmentsService;
import com.rj.service.ITtsRequestLogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -17,10 +21,15 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.File;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import com.rj.service.MinIOService;
/**
* <p>
@@ -149,6 +158,201 @@ public class AudioManagementSegmentsController {
return ResponseEntity.internalServerError().build();
}
}
@Autowired
private ITtsRequestLogService ttsRequestLogService;
@Autowired
private MinIOService minIOService;
/**
* 转文本请求
*/
@PostMapping("/transcribe/{id}")
@Operation(summary = "转文本", description = "分段音频文件转文本")
public ResponseEntity<Map<String, Object>> transcribeSegmentById(
@Parameter(description = "分段ID", required = true)
@PathVariable String id) {
Map<String, Object> result = new HashMap<>();
try {
// 先查询记录是否存在
AudioManagementSegments segment = audioManagementSegmentsService.getById(id);
if (segment == null) {
result.put("success", false);
result.put("message", "录音分段不存在ID: " + id);
return ResponseEntity.notFound().build();
}
// 检查本地文件路径是否存在
String audioFilePath = segment.getAudioFilePath();
if (audioFilePath == null || audioFilePath.trim().isEmpty()) {
result.put("success", false);
result.put("message", "音频文件路径为空");
return ResponseEntity.badRequest().body(result);
}
File audioFile = new File(audioFilePath);
if (!audioFile.exists() || !audioFile.isFile()) {
result.put("success", false);
result.put("message", "音频文件不存在: " + audioFilePath);
return ResponseEntity.badRequest().body(result);
}
// TODO 1: 上传文件到MinIO
String minioUrl = null;
try {
// 读取本地文件
byte[] fileBytes = Files.readAllBytes(audioFile.toPath());
String fileName = segment.getAudioFileOriginalName() != null
? segment.getAudioFileOriginalName()
: audioFile.getName();
// 获取文件扩展名确定contentType
String contentType = getContentType(fileName);
// 创建MultipartFile对象
MultipartFile multipartFile = new MockMultipartFile(
"file",
fileName,
contentType,
fileBytes
);
// 上传到MinIO
minioUrl = minIOService.uploadFile(multipartFile);
log.info("文件上传到MinIO成功ID: {}, minioUrl: {}", id, minioUrl);
} catch (Exception e) {
log.error("上传文件到MinIO失败ID: {}, error: {}", id, e.getMessage(), e);
result.put("success", false);
result.put("message", "上传文件到MinIO失败: " + e.getMessage());
return ResponseEntity.internalServerError().body(result);
}
// 根据id修改audio_management_segments 表字段: needTranscribe为 1 transcribeRequestTime为当前时间
AudioManagementSegments updateEntity = new AudioManagementSegments();
updateEntity.setNeedTranscribe(true);
updateEntity.setTranscribeRequestTime(LocalDateTime.now());
updateEntity.setAudioFileUrl(minioUrl);
LambdaUpdateWrapper<AudioManagementSegments> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(AudioManagementSegments::getId, id);
boolean success = audioManagementSegmentsService.update(updateEntity, updateWrapper);
if (!success) {
result.put("success", false);
result.put("message", "更新转文本状态失败");
return ResponseEntity.badRequest().body(result);
}
// 从minioUrl中提取objectName生成presignedUrl
String presignedUrl = null;
try {
String objectName = extractObjectNameFromUrl(minioUrl);
if (objectName != null) {
// 生成7天有效期的预签名URL
presignedUrl = minIOService.getPresignedUrl(objectName, 7 * 24 * 60 * 60);
log.info("生成预签名URL成功ID: {}, presignedUrl: {}", id, presignedUrl);
} else {
// 如果无法提取objectName直接使用minioUrl
presignedUrl = minioUrl;
log.warn("无法从minioUrl提取objectName使用原始URL: {}", minioUrl);
}
} catch (Exception e) {
log.error("生成预签名URL失败使用原始URL: {}", e.getMessage(), e);
presignedUrl = minioUrl;
}
// TODO 2: 启用异步线程处理转文本
final String finalPresignedUrl = presignedUrl;
final String segmentId = id;
CompletableFuture.runAsync(() -> {
try {
log.info("开始异步转文本处理ID: {}", segmentId);
AsrRequest asrRequest = new AsrRequest();
asrRequest.setAudioUrl(finalPresignedUrl);
asrRequest.setAudioName(segment.getAudioFileOriginalName());
asrRequest.setModel("paraformer-v2"); // 使用默认模型
asrRequest.setFormat("wav"); // 默认格式
asrRequest.setSampleRate(16000); // 默认采样率
asrRequest.setEnablePunctuation(true); // 启用标点符号
asrRequest.setEnableNumberConversion(true); // 启用数字转换
asrRequest.setEnableSpeakerDiarization(false); // 不启用说话人分离
// 调用ASR服务进行语音识别
AsrResponse asrResponse = ttsRequestLogService.speechToText(asrRequest);
if (asrResponse != null && asrResponse.isSuccess() && asrResponse.getText() != null) {
// 更新转文本结果到数据库
AudioManagementSegments textUpdateEntity = new AudioManagementSegments();
textUpdateEntity.setRecordingText(asrResponse.getText());
textUpdateEntity.setTranscribeEndTime(LocalDateTime.now());
LambdaUpdateWrapper<AudioManagementSegments> textUpdateWrapper = new LambdaUpdateWrapper<>();
textUpdateWrapper.eq(AudioManagementSegments::getId, segmentId);
boolean updateSuccess = audioManagementSegmentsService.update(textUpdateEntity, textUpdateWrapper);
if (updateSuccess) {
log.info("转文本结果保存成功ID: {}, 文本长度: {}", segmentId, asrResponse.getText().length());
} else {
log.error("转文本结果保存失败ID: {}", segmentId);
}
} else {
String errorMsg = asrResponse != null ? asrResponse.getMessage() : "ASR响应为空";
log.error("转文本失败ID: {}, error: {}", segmentId, errorMsg);
}
} catch (Exception e) {
log.error("异步转文本处理异常ID: {}, error: {}", segmentId, e.getMessage(), e);
}
});
result.put("success", true);
result.put("message", "转文本请求已提交,正在后台处理");
result.put("data", updateEntity);
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("转文本请求异常ID: {}, error: {}", id, e.getMessage(), e);
result.put("success", false);
result.put("message", "转文本请求异常:" + e.getMessage());
return ResponseEntity.internalServerError().body(result);
}
}
/**
* 从MinIO URL中提取对象名
* @param url MinIO文件URL
* @return 对象名
*/
private String extractObjectNameFromUrl(String url) {
if (url == null || url.trim().isEmpty()) {
return null;
}
try {
// 去掉查询参数
String urlWithoutParams = url;
if (url.contains("?")) {
urlWithoutParams = url.substring(0, url.indexOf("?"));
}
// 按"/"分割URL获取最后一个部分作为对象名
String[] parts = urlWithoutParams.split("/");
if (parts.length > 0) {
// 获取最后一个非空部分作为对象名
for (int i = parts.length - 1; i >= 0; i--) {
if (parts[i] != null && !parts[i].trim().isEmpty()) {
return parts[i];
}
}
}
return null;
} catch (Exception e) {
log.error("从URL提取对象名失败: {}", url, e);
return null;
}
}
/**
* 判断文件是否为音频文件

View File

@@ -149,5 +149,17 @@ public class AudioManagementSegments implements Serializable {
@TableField("summary")
private String summary;
@Schema(description = "需要转文本(是或否,默认:否)")
@TableField("need_transcribe")
private Boolean needTranscribe;
@Schema(description = "需要转文本请求的时间")
@TableField("transcribe_request_time")
private LocalDateTime transcribeRequestTime;
@Schema(description = "需要转文本结束的时间")
@TableField("transcribe_end_time")
private LocalDateTime transcribeEndTime;
}

View File

@@ -4,6 +4,7 @@ import com.alibaba.dashscope.audio.asr.transcription.*;
import com.rj.entity.TtsRequestLog;
import com.rj.mapper.TtsRequestLogMapper;
import com.rj.service.ITtsRequestLogService;
import com.rj.service.MinIOService;
import com.rj.utils.MinIOUrlGenerator;
import com.rj.dto.AsrRequest;
import com.rj.dto.AsrResponse;
@@ -12,9 +13,13 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.mock.web.MockMultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.File;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
@@ -36,6 +41,9 @@ public class TtsRequestLogServiceImpl implements ITtsRequestLogService {
@Autowired
private MinIOUrlGenerator urlGenerator;
@Autowired
private MinIOService minIOService;
@Value("${dashscope.api.key}")
private String apiKey;
@@ -111,6 +119,175 @@ public class TtsRequestLogServiceImpl implements ITtsRequestLogService {
return false;
}
/**
* 音频转文本的公共方法
* 支持本地文件路径或URL
*
* @param audioPathOrUrl 音频文件路径本地路径或URL
* @return 转换后的文本内容失败时返回null
*/
public String transcribeAudioToText(String audioPathOrUrl) {
return transcribeAudioToText(audioPathOrUrl, defaultAsrModel);
}
/**
* 音频转文本的公共方法
* 支持本地文件路径或URL
*
* @param audioPathOrUrl 音频文件路径本地路径或URL
* @param model ASR模型名称如果为null则使用默认模型
* @return 转换后的文本内容失败时返回null
*/
public String transcribeAudioToText(String audioPathOrUrl, String model) {
if (audioPathOrUrl == null || audioPathOrUrl.trim().isEmpty()) {
log.error("音频文件路径或URL不能为空");
return null;
}
if (!isAsrServiceAvailable()) {
log.error("ASR服务不可用请检查配置");
return null;
}
try {
// 判断是URL还是本地文件路径
String audioUrl = audioPathOrUrl;
// 判断是否为URL以http://或https://开头)
if (!audioPathOrUrl.startsWith("http://") && !audioPathOrUrl.startsWith("https://")) {
log.info("检测到本地文件路径: {}", audioPathOrUrl);
// 检查文件是否存在
File file = new File(audioPathOrUrl);
if (!file.exists() || !file.isFile()) {
log.error("本地文件不存在或不是文件: {}", audioPathOrUrl);
return null;
}
// 上传本地文件到MinIO获取URL
try {
String fileName = file.getName();
String contentType = getContentType(fileName);
// 读取文件内容
byte[] fileBytes = Files.readAllBytes(file.toPath());
// 创建MultipartFile对象
MultipartFile multipartFile = new MockMultipartFile(
"file",
fileName,
contentType,
fileBytes
);
// 上传到MinIO
audioUrl = minIOService.uploadFile(multipartFile);
log.info("本地文件已上传到MinIOURL: {}", audioUrl);
} catch (Exception e) {
log.error("上传本地文件到MinIO失败: {}", e.getMessage(), e);
return null;
}
} else {
log.info("使用音频URL: {}", audioUrl);
}
// 使用默认模型或指定模型
String asrModel = (model != null && !model.trim().isEmpty()) ? model : defaultAsrModel;
log.info("开始语音识别 - 音频URL: {}, 模型: {}", audioUrl, asrModel);
// 创建转写请求参数
TranscriptionParam param = TranscriptionParam.builder()
.apiKey(apiKey)
.model(asrModel)
.fileUrls(Arrays.asList(audioUrl))
.build();
// 创建转写对象
Transcription transcription = new Transcription();
// 提交转写请求
TranscriptionResult result = transcription.asyncCall(param);
String taskId = result.getTaskId();
log.info("ASR任务已提交 - TaskId: {}", taskId);
// 等待任务完成
TranscriptionQueryParam queryParam = TranscriptionQueryParam.FromTranscriptionParam(param, taskId);
result = transcription.wait(queryParam);
log.info("语音识别完成 - TaskId: {}", taskId);
if (result.getResults() != null && !result.getResults().isEmpty()) {
// 解析识别结果
StringBuilder fullText = new StringBuilder();
// 遍历所有结果获取transcriptionUrl并下载识别文本
for (TranscriptionTaskResult taskResult : result.getResults()) {
if (taskResult.getTranscriptionUrl() != null) {
try {
// 从transcriptionUrl下载识别结果
String transcriptionResult = downloadTranscriptionResult(taskResult.getTranscriptionUrl());
if (transcriptionResult != null && !transcriptionResult.trim().isEmpty()) {
// 解析transcription结果JSON
String extractedText = parseTranscriptionResult(transcriptionResult);
if (extractedText != null && !extractedText.trim().isEmpty()) {
if (fullText.length() > 0) {
fullText.append(" ");
}
fullText.append(extractedText);
}
}
} catch (Exception e) {
log.warn("下载transcription结果失败: {}", e.getMessage());
}
}
}
String resultText = fullText.toString().trim();
if (resultText.isEmpty()) {
log.warn("ASR识别结果为空 - TaskId: {}", taskId);
return null;
}
log.info("语音识别完成 - 识别文本长度: {}, TaskId: {}", resultText.length(), taskId);
return resultText;
} else {
log.warn("ASR识别结果为空 - TaskId: {}", taskId);
return null;
}
} catch (Exception e) {
log.error("语音识别失败: {}", e.getMessage(), e);
return null;
}
}
/**
* 根据文件名获取Content-Type
*/
private String getContentType(String fileName) {
if (fileName == null) {
return "application/octet-stream";
}
String lowerName = fileName.toLowerCase();
if (lowerName.endsWith(".mp3")) {
return "audio/mpeg";
} else if (lowerName.endsWith(".wav")) {
return "audio/wav";
} else if (lowerName.endsWith(".m4a")) {
return "audio/mp4";
} else if (lowerName.endsWith(".aac")) {
return "audio/aac";
} else if (lowerName.endsWith(".ogg")) {
return "audio/ogg";
} else if (lowerName.endsWith(".flac")) {
return "audio/flac";
} else if (lowerName.endsWith(".wma")) {
return "audio/x-ms-wma";
}
return "application/octet-stream";
}
@Override
public AsrResponse speechToText(AsrRequest request) {
long startTime = System.currentTimeMillis();

View File

@@ -34,6 +34,9 @@
<result column="audio_file_extension" property="audioFileExtension"/>
<result column="recording_text" property="recordingText"/>
<result column="summary" property="summary"/>
<result column="need_transcribe" property="needTranscribe"/>
<result column="transcribe_request_time" property="transcribeRequestTime"/>
<result column="transcribe_end_time" property="transcribeEndTime"/>
</resultMap>
<!-- 通用查询结果列 -->
@@ -67,7 +70,10 @@
audio_file_original_name,
audio_file_extension,
recording_text,
summary
summary,
need_transcribe,
transcribe_request_time,
transcribe_end_time
</sql>
</mapper>