diff --git a/.gitignore b/.gitignore index d75b3761..339c4b27 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/ruoyi-modules/ruoyi-aihuman/pom.xml b/ruoyi-modules/ruoyi-aihuman/pom.xml index d594c634..65327e0e 100644 --- a/ruoyi-modules/ruoyi-aihuman/pom.xml +++ b/ruoyi-modules/ruoyi-aihuman/pom.xml @@ -18,6 +18,8 @@ 3.2.1 + 5.13.0 + 1.5.5 @@ -70,5 +72,24 @@ org.ruoyi ruoyi-common-excel + + + net.java.dev.jna + jna + ${jna.version} + + + + net.java.dev.jna + jna-platform + ${jna.version} + + + + org.java-websocket + Java-WebSocket + ${java-websocket.version} + + \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/config/WebConfig.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/config/WebConfig.java new file mode 100644 index 00000000..5ccaf134 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/config/WebConfig.java @@ -0,0 +1,16 @@ +package org.ruoyi.aihuman.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + // 映射/voice/**路径到classpath:/voice/目录 + registry.addResourceHandler("/voice/**") + .addResourceLocations("classpath:/voice/") + .setCachePeriod(3600); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/controller/AihumanRealConfigController.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/controller/AihumanRealConfigController.java new file mode 100644 index 00000000..e1732db0 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/controller/AihumanRealConfigController.java @@ -0,0 +1,157 @@ +package org.ruoyi.aihuman.controller; + +import java.util.List; + +import cn.dev33.satoken.annotation.SaIgnore; +import lombok.RequiredArgsConstructor; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.*; +import cn.dev33.satoken.annotation.SaCheckPermission; +import org.ruoyi.common.log.enums.OperatorType; +import org.springframework.web.bind.annotation.*; +import org.springframework.validation.annotation.Validated; +import org.ruoyi.common.idempotent.annotation.RepeatSubmit; +import org.ruoyi.common.log.annotation.Log; +import org.ruoyi.common.web.core.BaseController; +import org.ruoyi.core.page.PageQuery; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.core.validate.AddGroup; +import org.ruoyi.common.core.validate.EditGroup; +import org.ruoyi.common.log.enums.BusinessType; +import org.ruoyi.common.excel.utils.ExcelUtil; +import org.ruoyi.aihuman.domain.vo.AihumanRealConfigVo; +import org.ruoyi.aihuman.domain.bo.AihumanRealConfigBo; +import org.ruoyi.aihuman.service.AihumanRealConfigService; +import org.ruoyi.core.page.TableDataInfo; + +/** + * 真人交互数字人配置 + * + * @author ageerle + * @date Tue Oct 21 11:46:52 GMT+08:00 2025 + */ +//临时免登录 +@SaIgnore + +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/aihuman/aihumanRealConfig") +public class AihumanRealConfigController extends BaseController { + + private final AihumanRealConfigService aihumanRealConfigService; + +/** + * 查询真人交互数字人配置列表 + */ +@SaCheckPermission("aihuman:aihumanRealConfig:list") +@GetMapping("/list") + public TableDataInfo list(AihumanRealConfigBo bo, PageQuery pageQuery) { + return aihumanRealConfigService.queryPageList(bo, pageQuery); + } + + /** + * 导出真人交互数字人配置列表 + */ + @SaCheckPermission("aihuman:aihumanRealConfig:export") + @Log(title = "真人交互数字人配置", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(AihumanRealConfigBo bo, HttpServletResponse response) { + List list = aihumanRealConfigService.queryList(bo); + ExcelUtil.exportExcel(list, "真人交互数字人配置", AihumanRealConfigVo.class, response); + } + + /** + * 获取真人交互数字人配置详细信息 + * + * @param id 主键 + */ + @SaCheckPermission("aihuman:aihumanRealConfig:query") + @GetMapping("/{id}") + public R getInfo(@NotNull(message = "主键不能为空") + @PathVariable Integer id) { + return R.ok(aihumanRealConfigService.queryById(id)); + } + + /** + * 新增真人交互数字人配置 + */ + @SaCheckPermission("aihuman:aihumanRealConfig:add") + @Log(title = "真人交互数字人配置", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping() + public R add(@Validated(AddGroup.class) @RequestBody AihumanRealConfigBo bo) { + return toAjax(aihumanRealConfigService.insertByBo(bo)); + } + + /** + * 修改真人交互数字人配置 + */ + @SaCheckPermission("aihuman:aihumanRealConfig:edit") + @Log(title = "真人交互数字人配置", businessType = BusinessType.UPDATE) + @RepeatSubmit() + @PutMapping() + public R edit(@Validated(EditGroup.class) @RequestBody AihumanRealConfigBo bo) { + return toAjax(aihumanRealConfigService.updateByBo(bo)); + } + + /** + * 删除真人交互数字人配置 + * + * @param ids 主键串 + */ + @SaCheckPermission("aihuman:aihumanRealConfig:remove") + @Log(title = "真人交互数字人配置", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@NotEmpty(message = "主键不能为空") + @PathVariable Integer[] ids) { + return toAjax(aihumanRealConfigService.deleteWithValidByIds(List.of(ids), true)); + } + + /** + * 1.执行以下命令: + * cd F:\Projects\AI-Human\LiveTalking + * conda activate D:\zg117\C\Users\zg117\.conda\envs\livetalking_new + * python app.py --transport webrtc --model wav2lip --avatar_id wav2lip256_avatar1 + * + * 2.监听 python app.py --transport webrtc --model wav2lip --avatar_id wav2lip256_avatar1 执行情况 + * + * 3.返回执行结果并打开页面 + * http://127.0.0.1:8010/webrtcapi-diy.html + */ + @SaCheckPermission("aihuman:aihumanRealConfig:run") + //@Log(title = "真人交互数字人配置", businessType = BusinessType.UPDATE, operatorType = OperatorType.OTHER) + @RepeatSubmit() + @PutMapping("/run") + public R run(@Validated(EditGroup.class) @RequestBody AihumanRealConfigBo bo) { + boolean result = aihumanRealConfigService.runByBo(bo); + if (result) { + // 返回前端页面URL,前端可以根据这个URL跳转或打开新页面 + // http://127.0.0.1:8010/webrtcapi-diy.html 其中的 http://127.0.0.1 获取当前java服务的IP地址 + // return R.ok("http://127.0.0.1:8010/webrtcapi-diy.html"); + // 运行状态 + bo.setRunStatus("1"); + return R.ok("http://127.0.0.1:8010/webrtcapi-diy.html"); + } else { + return R.fail("启动真人交互数字人失败"); + } + } + + /** + * 停止真人交互数字人配置任务 + */ + @SaCheckPermission("aihuman:aihumanRealConfig:stop") + //@Log(title = "真人交互数字人配置", businessType = BusinessType.UPDATE, operatorType = OperatorType.OTHER) + @RepeatSubmit() + @PutMapping("/stop") + public R stop(@Validated(EditGroup.class) @RequestBody AihumanRealConfigBo bo) { + boolean result = aihumanRealConfigService.stopByBo(bo); + if (result) { + // 运行状态 + bo.setRunStatus("0"); + return R.ok("真人交互数字人任务已停止"); + } else { + return R.fail("停止真人交互数字人任务失败或没有正在运行的任务"); + } + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/controller/AihumanVolcengineController.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/controller/AihumanVolcengineController.java new file mode 100644 index 00000000..c396ba0a --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/controller/AihumanVolcengineController.java @@ -0,0 +1,508 @@ +package org.ruoyi.aihuman.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.core.io.ResourceLoader; +import org.springframework.beans.factory.annotation.Autowired; +import org.ruoyi.aihuman.domain.VoiceRequest; +import org.ruoyi.aihuman.protocol.EventType; +import org.ruoyi.aihuman.protocol.Message; +import org.ruoyi.aihuman.protocol.MsgType; +import org.ruoyi.aihuman.protocol.SpeechWebSocketClient; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.HashMap; +import java.util.UUID; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.concurrent.TimeUnit; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 火山引擎相关接口 + * + * @author ruoyi + */ +// 临时免登录 +@SaIgnore + +@Validated +@RequiredArgsConstructor +@Slf4j +@RestController +@RequestMapping("/aihuman/volcengine") +public class AihumanVolcengineController { + + @Autowired + private ResourceLoader resourceLoader; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final Logger logger = LoggerFactory.getLogger(AihumanVolcengineController.class); + + + @PostMapping("/generate-voice-direct") + public ResponseEntity generateVoiceDirect(@RequestBody VoiceRequest request) { + try { + // 生成唯一的语音ID + String voiceId = UUID.randomUUID().toString().replace("-", ""); + + log.info("开始生成语音,voiceId: {}", voiceId); + + // 调用火山引擎TTS API获取音频数据 + byte[] audioData = generateVoiceData(request, voiceId); + + // 设置响应头,返回音频数据 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("audio/wav")); + headers.setContentDispositionFormData("attachment", "voice_" + System.currentTimeMillis() + ".wav"); + headers.setContentLength(audioData.length); + + log.info("语音生成成功并返回,长度: {} bytes", audioData.length); + return new ResponseEntity<>(audioData, headers, HttpStatus.OK); + } catch (Exception e) { + log.error("生成语音失败", e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private byte[] generateVoiceData(VoiceRequest request, String voiceId) { + try { + // 这里是调用火山引擎TTS API的核心逻辑 + // 您需要根据火山引擎的API文档实现具体的调用逻辑 + // 注意:这只是一个示例框架,您需要根据实际情况进行实现 + + // 调用火山引擎API并获取音频数据 + // 假设您已经有现有的调用逻辑,这里保留原有的实现 + String endpoint = request.getEndpoint(); + String appId = request.getAppId(); + String accessToken = request.getAccessToken(); + String resourceId = request.getResourceId(); + String voice = request.getVoice(); + String text = request.getText(); + String encoding = request.getEncoding(); + + // 调用原有的火山引擎API调用方法(如果有) + // 或者直接在这里实现API调用逻辑 + byte[] audioData = callVolcengineTtsApiByte(endpoint, appId, accessToken, + resourceId, voice, text, encoding); + + log.info("成功生成语音数据,大小: {} bytes", audioData.length); + return audioData; + } catch (Exception e) { + log.error("生成语音数据失败", e); + throw new RuntimeException("生成语音数据失败", e); + } + + } + + private byte[] callVolcengineTtsApiByte(String endpoint, String appId, String accessToken, + String resourceId, String voice, String text, String encoding) { + try { + // 确保resourceId不为空,如果为空则根据voice类型获取默认值 + if (resourceId == null || resourceId.isEmpty()) { + resourceId = voiceToResourceId(voice); + } + + // 设置请求头 + Map headers = new HashMap<>(); + headers.put("X-Api-App-Key", appId); + headers.put("X-Api-Access-Key", accessToken); + headers.put("X-Api-Resource-Id", resourceId); + headers.put("X-Api-Connect-Id", UUID.randomUUID().toString()); + + // 创建WebSocket客户端 + SpeechWebSocketClient client = new SpeechWebSocketClient(new URI(endpoint), headers); + ByteArrayOutputStream totalAudioStream = new ByteArrayOutputStream(); + boolean audioReceived = false; + + try { + // 连接WebSocket + client.connectBlocking(); + + // 构建请求参数 + Map request = new HashMap<>(); + request.put("user", Map.of("uid", UUID.randomUUID().toString())); + request.put("namespace", "BidirectionalTTS"); + + Map reqParams = new HashMap<>(); + reqParams.put("speaker", voice); + + Map audioParams = new HashMap<>(); + audioParams.put("format", encoding); + audioParams.put("sample_rate", 24000); + audioParams.put("enable_timestamp", true); + + reqParams.put("audio_params", audioParams); + reqParams.put("additions", objectMapper.writeValueAsString(Map.of("disable_markdown_filter", false))); + + request.put("req_params", reqParams); + + // 开始连接 + client.sendStartConnection(); + // 等待连接成功 + client.waitForMessage(MsgType.FULL_SERVER_RESPONSE, EventType.CONNECTION_STARTED); + + // 处理每个句子 + String[] sentences = text.split("。"); + for (int i = 0; i < sentences.length; i++) { + if (sentences[i].trim().isEmpty()) { + continue; + } + + String sessionId = UUID.randomUUID().toString(); + ByteArrayOutputStream sentenceAudioStream = new ByteArrayOutputStream(); + + // 开始会话 + Map startReq = new HashMap<>(); + startReq.put("user", request.get("user")); + startReq.put("namespace", request.get("namespace")); + startReq.put("req_params", request.get("req_params")); + startReq.put("event", EventType.START_SESSION.getValue()); + client.sendStartSession(objectMapper.writeValueAsBytes(startReq), sessionId); + // 等待会话开始 + client.waitForMessage(MsgType.FULL_SERVER_RESPONSE, EventType.SESSION_STARTED); + + // 发送文本内容 + for (char c : sentences[i].toCharArray()) { + @SuppressWarnings("unchecked") + Map currentReqParams = new HashMap<>((Map) request.get("req_params")); + currentReqParams.put("text", String.valueOf(c)); + + Map currentRequest = new HashMap<>(); + currentRequest.put("user", request.get("user")); + currentRequest.put("namespace", request.get("namespace")); + currentRequest.put("req_params", currentReqParams); + currentRequest.put("event", EventType.TASK_REQUEST.getValue()); + + client.sendTaskRequest(objectMapper.writeValueAsBytes(currentRequest), sessionId); + } + + // 结束会话 + client.sendFinishSession(sessionId); + + // 接收响应 + while (true) { + Message msg = client.receiveMessage(); + switch (msg.getType()) { + case FULL_SERVER_RESPONSE: + break; + case AUDIO_ONLY_SERVER: + if (!audioReceived && sentenceAudioStream.size() > 0) { + audioReceived = true; + } + if (msg.getPayload() != null) { + sentenceAudioStream.write(msg.getPayload()); + } + break; + default: + // 不抛出异常,记录日志并继续处理 + log.warn("Unexpected message type: {}", msg.getType()); + } + if (msg.getEvent() == EventType.SESSION_FINISHED) { + break; + } + } + + // 将当前句子的音频追加到总音频流 + if (sentenceAudioStream.size() > 0) { + totalAudioStream.write(sentenceAudioStream.toByteArray()); + } + } + + // 验证是否收到音频数据 + if (totalAudioStream.size() > 0) { + log.info("Audio data generated successfully, size: {} bytes", totalAudioStream.size()); + return totalAudioStream.toByteArray(); + } else { + throw new RuntimeException("No audio data received"); + } + } finally { + // 结束连接 + client.sendFinishConnection(); + client.closeBlocking(); + } + } catch (Exception e) { + log.error("Error calling Volcengine TTS API: {}", e.getMessage(), e); + throw new RuntimeException("Failed to generate voice", e); + } + } + + + /** + * 生成语音文件接口 + * 用户传入JSON参数,返回音频文件的播放地址 + */ + @PostMapping("/generate-voice") + public ResponseEntity generateVoice(@RequestBody VoiceRequest request) { + try { + // 1. 解析请求参数 + String endpoint = request.getEndpoint(); + String appId = request.getAppId(); + String accessToken = request.getAccessToken(); + String resourceId = request.getResourceId(); + String voice = request.getVoice(); + String text = request.getText(); + String encoding = request.getEncoding(); + + // 1.1 验证必要参数 + if (endpoint == null || endpoint.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "endpoint cannot be null or empty")); + } + if (appId == null || appId.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "appId cannot be null or empty")); + } + if (accessToken == null || accessToken.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "accessToken cannot be null or empty")); + } + if (text == null || text.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "text cannot be null or empty")); + } + + // 1.2 设置默认值 + if (encoding == null || encoding.isEmpty()) { + encoding = "mp3"; + } + + // 2. 调用火山引擎API生成音频文件 + String audioUrl = callVolcengineTtsApi(endpoint, appId, accessToken, resourceId, voice, text, encoding); + + // 3. 构造并返回响应 + Map response = new HashMap<>(); + response.put("audioUrl", audioUrl); + + return ResponseEntity.ok(response); + } catch (Exception e) { + // 处理异常情况 + Map errorResponse = new HashMap<>(); + errorResponse.put("error", "生成音频文件失败: " + e.getMessage()); + return ResponseEntity.status(500).body(errorResponse); + } + } + + /** + * 调用火山引擎TTS API生成音频文件 + */ + private String callVolcengineTtsApi(String endpoint, String appId, String accessToken, + String resourceId, String voice, String text, String encoding) { + try { + // 确保resourceId不为空,如果为空则根据voice类型获取默认值 + if (resourceId == null || resourceId.isEmpty()) { + resourceId = voiceToResourceId(voice); + } + + // 生成唯一的文件名 + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + String randomId = UUID.randomUUID().toString().substring(0, 8); + String fileName = "voice_" + timestamp + "_" + randomId + "." + encoding; + + // 获取resources/voice目录路径 + String voiceDirPath = getVoiceDirectoryPath(); + File voiceDir = new File(voiceDirPath); + if (!voiceDir.exists()) { + voiceDir.mkdirs(); + } + + String filePath = voiceDirPath + File.separator + fileName; + + // 设置请求头 + Map headers = new HashMap<>(); + headers.put("X-Api-App-Key", appId); + headers.put("X-Api-Access-Key", accessToken); + headers.put("X-Api-Resource-Id", resourceId); + headers.put("X-Api-Connect-Id", UUID.randomUUID().toString()); + + // 创建WebSocket客户端 + SpeechWebSocketClient client = new SpeechWebSocketClient(new URI(endpoint), headers); + ByteArrayOutputStream totalAudioStream = new ByteArrayOutputStream(); + boolean audioReceived = false; + + try { + // 连接WebSocket + client.connectBlocking(); + + // 构建请求参数 + Map request = new HashMap<>(); + request.put("user", Map.of("uid", UUID.randomUUID().toString())); + request.put("namespace", "BidirectionalTTS"); + + Map reqParams = new HashMap<>(); + reqParams.put("speaker", voice); + + Map audioParams = new HashMap<>(); + audioParams.put("format", encoding); + audioParams.put("sample_rate", 24000); + audioParams.put("enable_timestamp", true); + + reqParams.put("audio_params", audioParams); + reqParams.put("additions", objectMapper.writeValueAsString(Map.of("disable_markdown_filter", false))); + + request.put("req_params", reqParams); + + // 开始连接 + client.sendStartConnection(); + // 等待连接成功 + client.waitForMessage(MsgType.FULL_SERVER_RESPONSE, EventType.CONNECTION_STARTED); + + // 处理每个句子 + String[] sentences = text.split("。"); + for (int i = 0; i < sentences.length; i++) { + if (sentences[i].trim().isEmpty()) { + continue; + } + + String sessionId = UUID.randomUUID().toString(); + ByteArrayOutputStream sentenceAudioStream = new ByteArrayOutputStream(); + + // 开始会话 + Map startReq = new HashMap<>(); + startReq.put("user", request.get("user")); + startReq.put("namespace", request.get("namespace")); + startReq.put("req_params", request.get("req_params")); + startReq.put("event", EventType.START_SESSION.getValue()); + client.sendStartSession(objectMapper.writeValueAsBytes(startReq), sessionId); + // 等待会话开始 + client.waitForMessage(MsgType.FULL_SERVER_RESPONSE, EventType.SESSION_STARTED); + + // 发送文本内容 + for (char c : sentences[i].toCharArray()) { + @SuppressWarnings("unchecked") + Map currentReqParams = new HashMap<>((Map) request.get("req_params")); + currentReqParams.put("text", String.valueOf(c)); + + Map currentRequest = new HashMap<>(); + currentRequest.put("user", request.get("user")); + currentRequest.put("namespace", request.get("namespace")); + currentRequest.put("req_params", currentReqParams); + currentRequest.put("event", EventType.TASK_REQUEST.getValue()); + + client.sendTaskRequest(objectMapper.writeValueAsBytes(currentRequest), sessionId); + } + + // 结束会话 + client.sendFinishSession(sessionId); + + // 接收响应 + while (true) { + Message msg = client.receiveMessage(); + switch (msg.getType()) { + case FULL_SERVER_RESPONSE: + break; + case AUDIO_ONLY_SERVER: + if (!audioReceived && sentenceAudioStream.size() > 0) { + audioReceived = true; + } + if (msg.getPayload() != null) { + sentenceAudioStream.write(msg.getPayload()); + } + break; + default: + // 不抛出异常,记录日志并继续处理 + log.warn("Unexpected message type: {}", msg.getType()); + } + if (msg.getEvent() == EventType.SESSION_FINISHED) { + break; + } + } + + // 将当前句子的音频追加到总音频流 + if (sentenceAudioStream.size() > 0) { + totalAudioStream.write(sentenceAudioStream.toByteArray()); + } + } + + // 保存音频文件 + if (totalAudioStream.size() > 0) { + Files.write(Paths.get(filePath), totalAudioStream.toByteArray(), StandardOpenOption.CREATE); + log.info("Audio saved to file: {}", filePath); + } else { + throw new RuntimeException("No audio data received"); + } + + // 结束连接 + client.sendFinishConnection(); + } finally { + client.closeBlocking(); + } + + // 返回音频文件的访问路径 + return "/voice/" + fileName; + } catch (Exception e) { + log.error("Error calling Volcengine TTS API: {}", e.getMessage(), e); + throw new RuntimeException("Failed to generate voice", e); + } + } + + /** + * 根据voice类型获取resourceId + */ + private String voiceToResourceId(String voice) { + if (voice != null && voice.startsWith("S_")) { + return "volc.megatts.default"; + } + return "volc.service_type.10029"; + } + + /** + * 获取voice目录路径 + */ + private String getVoiceDirectoryPath() { + try { + // 获取当前项目根目录 + String projectRoot = System.getProperty("user.dir"); + + // 构建目标目录路径:ruoyi-ai/ruoyi-modules/ruoyi-aihuman/src/main/resources/voice + File targetDir = new File(projectRoot, "ruoyi-modules/ruoyi-aihuman/src/main/resources/voice"); + + // 确保目录存在 + if (!targetDir.exists()) { + boolean created = targetDir.mkdirs(); + if (created) { + logger.info("成功创建目录: {}", targetDir.getAbsolutePath()); + } else { + logger.warn("无法创建目录: {}", targetDir.getAbsolutePath()); + + // 降级方案:直接使用项目根目录下的voice文件夹 + File fallbackDir = new File(projectRoot, "voice"); + if (!fallbackDir.exists()) { + fallbackDir.mkdirs(); + } + return fallbackDir.getAbsolutePath(); + } + } + + return targetDir.getAbsolutePath(); + } catch (Exception e) { + logger.error("获取音频目录路径失败", e); + + // 异常情况下的安全降级 + File safeDir = new File("voice"); + if (!safeDir.exists()) { + safeDir.mkdirs(); + } + return safeDir.getAbsolutePath(); + } + } +} + + diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/AihumanRealConfig.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/AihumanRealConfig.java new file mode 100644 index 00000000..2c9c207d --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/AihumanRealConfig.java @@ -0,0 +1,101 @@ +package org.ruoyi.aihuman.domain; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; +import java.io.Serializable; + +/** + * 真人交互数字人配置对象 aihuman_real_config + * + * @author ageerle + * @date Tue Oct 21 11:46:52 GMT+08:00 2025 + */ +@Data +@TableName("aihuman_real_config") +public class AihumanRealConfig implements Serializable { + + + /** + * 主键id + */ + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 场景名称 + */ + private String name; + + /** + * 真人形象名称 + */ + private String avatars; + + /** + * 模型名称 + */ + private String models; + + /** + * 形象参数(预留) + */ + private String avatarsParams; + + /** + * 模型参数(预留) + */ + private String modelsParams; + + /** + * 智能体参数(扣子) + */ + private String agentParams; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; + + /** + * 状态 + */ + private Integer status; + + /** + * 发布状态 + */ + private Integer publish; + + /** + * 运行参数 + */ + private String runParams; + + /** + * 运行状态 + */ + private String runStatus; + + /** + * 创建部门 + */ + private String createDept; + + /** + * 创建用户 + */ + private String createBy; + + /** + * 更新用户 + */ + private String updateBy; + + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/VoiceRequest.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/VoiceRequest.java new file mode 100644 index 00000000..a0ce0c65 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/VoiceRequest.java @@ -0,0 +1,21 @@ +package org.ruoyi.aihuman.domain; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + + +/** + * 语音请求参数实体类 + */ +@Data +public class VoiceRequest { + + @JsonProperty("ENDPOINT") + private String endpoint; + private String appId; + private String accessToken; + private String resourceId; + private String voice; + private String text; + private String encoding; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/bo/AihumanRealConfigBo.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/bo/AihumanRealConfigBo.java new file mode 100644 index 00000000..e162f81a --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/bo/AihumanRealConfigBo.java @@ -0,0 +1,87 @@ +package org.ruoyi.aihuman.domain.bo; + +import org.ruoyi.aihuman.domain.AihumanRealConfig; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; + +import java.time.LocalDateTime; +import java.io.Serializable; + +/** + * 真人交互数字人配置业务对象 aihuman_real_config + * + * @author ageerle + * @date Tue Oct 21 11:46:52 GMT+08:00 2025 + */ +@Data + +@AutoMapper(target = AihumanRealConfig.class, reverseConvertGenerate = false) +public class AihumanRealConfigBo implements Serializable { + + private Integer id; + + /** + * 场景名称 + */ + private String name; + /** + * 真人形象名称 + */ + private String avatars; + /** + * 模型名称 + */ + private String models; + /** + * 形象参数(预留) + */ + private String avatarsParams; + /** + * 模型参数(预留) + */ + private String modelsParams; + /** + * 智能体参数(扣子) + */ + private String agentParams; + /** + * 创建时间 + */ + private LocalDateTime createTime; + /** + * 更新时间 + */ + private LocalDateTime updateTime; + /** + * 状态 + */ + private Integer status; + /** + * 发布状态 + */ + private Integer publish; + + /** + * 运行参数 + */ + private String runParams; + + /** + * 运行状态 + */ + private String runStatus; + + /** + * 创建部门 + */ + private String createDept; + /** + * 创建用户 + */ + private String createBy; + /** + * 更新用户 + */ + private String updateBy; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/vo/AihumanRealConfigVo.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/vo/AihumanRealConfigVo.java new file mode 100644 index 00000000..da71fc70 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/domain/vo/AihumanRealConfigVo.java @@ -0,0 +1,108 @@ +package org.ruoyi.aihuman.domain.vo; + +import java.time.LocalDateTime; +import java.io.Serializable; +import org.ruoyi.aihuman.domain.AihumanRealConfig; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.ruoyi.common.excel.annotation.ExcelDictFormat; +import org.ruoyi.common.excel.convert.ExcelDictConvert; + + +/** + * 真人交互数字人配置视图对象 aihuman_real_config + * + * @author ageerle + * @date Tue Oct 21 11:46:52 GMT+08:00 2025 + */ +@Data +@ExcelIgnoreUnannotated +@AutoMapper(target = AihumanRealConfig.class) +public class AihumanRealConfigVo implements Serializable { + + private Integer id; + /** + * 场景名称 + */ + @ExcelProperty(value = "场景名称") + private String name; + /** + * 真人形象名称 + */ + @ExcelProperty(value = "真人形象名称") + private String avatars; + /** + * 模型名称 + */ + @ExcelProperty(value = "模型名称") + private String models; + /** + * 形象参数(预留) + */ + @ExcelProperty(value = "形象参数", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "$column.readConverterExp()") + private String avatarsParams; + /** + * 模型参数(预留) + */ + @ExcelProperty(value = "模型参数", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "$column.readConverterExp()") + private String modelsParams; + /** + * 智能体参数(扣子) + */ + @ExcelProperty(value = "智能体参数", converter = ExcelDictConvert.class) + @ExcelDictFormat(readConverterExp = "$column.readConverterExp()") + private String agentParams; + /** + * 创建时间 + */ + @ExcelProperty(value = "创建时间") + private LocalDateTime createTime; + /** + * 更新时间 + */ + @ExcelProperty(value = "更新时间") + private LocalDateTime updateTime; + /** + * 状态 + */ + @ExcelProperty(value = "状态") + private Integer status; + /** + * 发布状态 + */ + @ExcelProperty(value = "发布状态") + private Integer publish; + + /** + * 运行参数 + */ + @ExcelProperty(value = "运行参数") + private String runParams; + + /** + * 运行状态 + */ + @ExcelProperty(value = "运行状态") + private String runStatus; + + /** + * 创建部门 + */ + @ExcelProperty(value = "创建部门") + private String createDept; + /** + * 创建用户 + */ + @ExcelProperty(value = "创建用户") + private String createBy; + /** + * 更新用户 + */ + @ExcelProperty(value = "更新用户") + private String updateBy; + +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/mapper/AihumanRealConfigMapper.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/mapper/AihumanRealConfigMapper.java new file mode 100644 index 00000000..9aae74d7 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/mapper/AihumanRealConfigMapper.java @@ -0,0 +1,17 @@ +package org.ruoyi.aihuman.mapper; + +import org.ruoyi.aihuman.domain.AihumanRealConfig; +import org.ruoyi.aihuman.domain.vo.AihumanRealConfigVo; +import org.ruoyi.core.mapper.BaseMapperPlus; +import org.apache.ibatis.annotations.Mapper; + +/** + * 真人交互数字人配置Mapper接口 + * + * @author ageerle + * @date Tue Oct 21 11:46:52 GMT+08:00 2025 + */ +@Mapper +public interface AihumanRealConfigMapper extends BaseMapperPlus { + +} diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/CompressionBits.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/CompressionBits.java new file mode 100644 index 00000000..78794aa4 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/CompressionBits.java @@ -0,0 +1,26 @@ +package org.ruoyi.aihuman.protocol; + +import lombok.Getter; + +@Getter +public enum CompressionBits { + None_((byte) 0), + Gzip((byte) 0b1), + Custom((byte) 0b11), + ; + + private final byte value; + + CompressionBits(byte b) { + this.value = b; + } + + public static CompressionBits fromValue(int value) { + for (CompressionBits type : CompressionBits.values()) { + if (type.value == value) { + return type; + } + } + throw new IllegalArgumentException("Unknown CompressionBits value: " + value); + } +} diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/EventType.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/EventType.java new file mode 100644 index 00000000..ece69af3 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/EventType.java @@ -0,0 +1,90 @@ +package org.ruoyi.aihuman.protocol; + +import lombok.Getter; + +@Getter +public enum EventType { + // Default event + NONE(0), + + // Upstream Connection events (1-49) + START_CONNECTION(1), + START_TASK(1), + FINISH_CONNECTION(2), + FINISH_TASK(2), + + // Downstream Connection events (50-99) + CONNECTION_STARTED(50), + TASK_STARTED(50), + CONNECTION_FAILED(51), + TASK_FAILED(51), + CONNECTION_FINISHED(52), + TASK_FINISHED(52), + + // Upstream Session events (100-149) + START_SESSION(100), + CANCEL_SESSION(101), + FINISH_SESSION(102), + + // Downstream Session events (150-199) + SESSION_STARTED(150), + SESSION_CANCELED(151), + SESSION_FINISHED(152), + SESSION_FAILED(153), + USAGE_RESPONSE(154), + CHARGE_DATA(154), + + // Upstream General events (200-249) + TASK_REQUEST(200), + UPDATE_CONFIG(201), + + // Downstream General events (250-299) + AUDIO_MUTED(250), + + // Upstream TTS events (300-349) + SAY_HELLO(300), + + // Downstream TTS events (350-399) + TTS_SENTENCE_START(350), + TTS_SENTENCE_END(351), + TTS_RESPONSE(352), + TTS_ENDED(359), + PODCAST_ROUND_START(360), + PODCAST_ROUND_RESPONSE(361), + PODCAST_ROUND_END(362), + + // Downstream ASR events (450-499) + ASR_INFO(450), + ASR_RESPONSE(451), + ASR_ENDED(459), + + // Upstream Chat events (500-549) + CHAT_TTS_TEXT(500), + + // Downstream Chat events (550-599) + CHAT_RESPONSE(550), + CHAT_ENDED(559), + + // Subtitle events (650-699) + SOURCE_SUBTITLE_START(650), + SOURCE_SUBTITLE_RESPONSE(651), + SOURCE_SUBTITLE_END(652), + TRANSLATION_SUBTITLE_START(653), + TRANSLATION_SUBTITLE_RESPONSE(654), + TRANSLATION_SUBTITLE_END(655); + + private final int value; + + EventType(int value) { + this.value = value; + } + + public static EventType fromValue(int value) { + for (EventType type : EventType.values()) { + if (type.value == value) { + return type; + } + } + throw new IllegalArgumentException("Unknown EventType value: " + value); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/HeaderSizeBits.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/HeaderSizeBits.java new file mode 100644 index 00000000..6c09a20d --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/HeaderSizeBits.java @@ -0,0 +1,27 @@ +package org.ruoyi.aihuman.protocol; + +import lombok.Getter; + +@Getter +public enum HeaderSizeBits { + HeaderSize4((byte) 1), + HeaderSize8((byte) 2), + HeaderSize12((byte) 3), + HeaderSize16((byte) 4), + ; + + private final byte value; + + HeaderSizeBits(byte b) { + this.value = b; + } + + public static HeaderSizeBits fromValue(int value) { + for (HeaderSizeBits type : HeaderSizeBits.values()) { + if (type.value == value) { + return type; + } + } + throw new IllegalArgumentException("Unknown HeaderSizeBits value: " + value); + } +} diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/Message.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/Message.java new file mode 100644 index 00000000..d3605edc --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/Message.java @@ -0,0 +1,220 @@ +package org.ruoyi.aihuman.protocol; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Data +public class Message { + private byte version = VersionBits.Version1.getValue(); + private byte headerSize = HeaderSizeBits.HeaderSize4.getValue(); + private MsgType type; + private MsgTypeFlagBits flag; + private byte serialization = SerializationBits.JSON.getValue(); + private byte compression = 0; + + private EventType event; + private String sessionId; + private String connectId; + private int sequence; + private int errorCode; + + private byte[] payload; + + public Message(MsgType type, MsgTypeFlagBits flag) { + this.type = type; + this.flag = flag; + } + + public static Message unmarshal(byte[] data) throws Exception { + ByteBuffer buffer = ByteBuffer.wrap(data); + + byte type_and_flag = data[1]; + MsgType type = MsgType.fromValue((type_and_flag >> 4) & 0x0F); + MsgTypeFlagBits flag = MsgTypeFlagBits.fromValue(type_and_flag & 0x0F); + + // Read version and header size + int versionAndHeaderSize = buffer.get(); + VersionBits version = VersionBits.fromValue((versionAndHeaderSize >> 4) & 0x0F); + HeaderSizeBits headerSize = HeaderSizeBits.fromValue(versionAndHeaderSize & 0x0F); + + // Skip second byte + buffer.get(); + + // Read serialization and compression method + int serializationCompression = buffer.get(); + SerializationBits serialization = SerializationBits.fromValue((serializationCompression >> 4) & 0x0F); + CompressionBits compression = CompressionBits.fromValue(serializationCompression & 0x0F); + + // Skip padding bytes + int headerSizeInt = 4 * (int) headerSize.getValue(); + int paddingSize = headerSizeInt - 3; + while (paddingSize > 0) { + buffer.get(); + paddingSize -= 1; + } + + Message message = new Message(type, flag); + message.setVersion(version.getValue()); + message.setHeaderSize(headerSize.getValue()); + message.setSerialization(serialization.getValue()); + message.setCompression(compression.getValue()); + + // Read sequence if present + if (flag == MsgTypeFlagBits.POSITIVE_SEQ || flag == MsgTypeFlagBits.NEGATIVE_SEQ) { + // Read 4 bytes from ByteBuffer and parse as int (big-endian) + byte[] sequeueBytes = new byte[4]; + if (buffer.remaining() >= 4) { + buffer.get(sequeueBytes); // Read 4 bytes into array + ByteBuffer wrapper = ByteBuffer.wrap(sequeueBytes); + wrapper.order(ByteOrder.BIG_ENDIAN); // Set big-endian order + message.setSequence(wrapper.getInt()); + } + } + + // Read event if present + if (flag == MsgTypeFlagBits.WITH_EVENT) { + // Read 4 bytes from ByteBuffer and parse as int (big-endian) + byte[] eventBytes = new byte[4]; + if (buffer.remaining() >= 4) { + buffer.get(eventBytes); // Read 4 bytes into array + ByteBuffer wrapper = ByteBuffer.wrap(eventBytes); + wrapper.order(ByteOrder.BIG_ENDIAN); // Set big-endian order + message.setEvent(EventType.fromValue(wrapper.getInt())); + } + + if (type != MsgType.ERROR && !(message.event == EventType.START_CONNECTION + || message.event == EventType.FINISH_CONNECTION || + message.event == EventType.CONNECTION_STARTED + || message.event == EventType.CONNECTION_FAILED || + message.event == EventType.CONNECTION_FINISHED)) { + // Read sessionId if present + int sessionIdLength = buffer.getInt(); + if (sessionIdLength > 0) { + byte[] sessionIdBytes = new byte[sessionIdLength]; + buffer.get(sessionIdBytes); + message.setSessionId(new String(sessionIdBytes, StandardCharsets.UTF_8)); + } + } + + if (message.event == EventType.CONNECTION_STARTED || message.event == EventType.CONNECTION_FAILED + || message.event == EventType.CONNECTION_FINISHED) { + // Read connectId if present + int connectIdLength = buffer.getInt(); + if (connectIdLength > 0) { + byte[] connectIdBytes = new byte[connectIdLength]; + buffer.get(connectIdBytes); + message.setConnectId(new String(connectIdBytes, StandardCharsets.UTF_8)); + } + } + } + + // Read errorCode if present + if (type == MsgType.ERROR) { + // Read 4 bytes from ByteBuffer and parse as int (big-endian) + byte[] errorCodeBytes = new byte[4]; + if (buffer.remaining() >= 4) { + buffer.get(errorCodeBytes); // Read 4 bytes into array + ByteBuffer wrapper = ByteBuffer.wrap(errorCodeBytes); + wrapper.order(ByteOrder.BIG_ENDIAN); // Set big-endian order + message.setErrorCode(wrapper.getInt()); + } + } + + // Read remaining bytes as payload + if (buffer.remaining() > 0) { + // 4 bytes length + int payloadLength = buffer.getInt(); + if (payloadLength > 0) { + byte[] payloadBytes = new byte[payloadLength]; + buffer.get(payloadBytes); + message.setPayload(payloadBytes); + } + } + + return message; + } + + public byte[] marshal() throws Exception { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + // Write header + buffer.write((version & 0x0F) << 4 | (headerSize & 0x0F)); + buffer.write((type.getValue() & 0x0F) << 4 | (flag.getValue() & 0x0F)); + buffer.write((serialization & 0x0F) << 4 | (compression & 0x0F)); + + int headerSizeInt = 4 * (int) headerSize; + int padding = headerSizeInt - buffer.size(); + while (padding > 0) { + buffer.write(0); + padding -= 1; + } + + // Write event if present + if (event != null) { + byte[] eventBytes = ByteBuffer.allocate(4).putInt(event.getValue()).array(); + buffer.write(eventBytes); + } + + // Write sessionId if present + if (sessionId != null) { + byte[] sessionIdBytes = sessionId.getBytes(StandardCharsets.UTF_8); + buffer.write(ByteBuffer.allocate(4).putInt(sessionIdBytes.length).array()); + buffer.write(sessionIdBytes); + } + + // Write connectId if present + if (connectId != null) { + byte[] connectIdBytes = connectId.getBytes(StandardCharsets.UTF_8); + buffer.write(ByteBuffer.allocate(4).putInt(connectIdBytes.length).array()); + buffer.write(connectIdBytes); + } + + // Write sequence if present + if (sequence != 0) { + buffer.write(ByteBuffer.allocate(4).putInt(sequence).array()); + } + + // Write errorCode if present + if (errorCode != 0) { + buffer.write(ByteBuffer.allocate(4).putInt(errorCode).array()); + } + + // Write payload if present + if (payload != null && payload.length > 0) { + buffer.write(ByteBuffer.allocate(4).putInt(payload.length).array()); + buffer.write(payload); + } + return buffer.toByteArray(); + } + + @Override + public String toString() { + switch (this.type) { + case AUDIO_ONLY_SERVER: + case AUDIO_ONLY_CLIENT: + if (this.flag == MsgTypeFlagBits.POSITIVE_SEQ || this.flag == MsgTypeFlagBits.NEGATIVE_SEQ) { + return String.format("MsgType: %s, EventType: %s, Sequence: %d, PayloadSize: %d", this.type, this.event, this.sequence, + this.payload != null ? this.payload.length : 0); + } + return String.format("MsgType: %s, EventType: %s, PayloadSize: %d", this.type, this.event, + this.payload != null ? this.payload.length : 0); + case ERROR: + return String.format("MsgType: %s, EventType: %s, ErrorCode: %d, Payload: %s", this.type, this.event, this.errorCode, + this.payload != null ? new String(this.payload) : "null"); + default: + if (this.flag == MsgTypeFlagBits.POSITIVE_SEQ || this.flag == MsgTypeFlagBits.NEGATIVE_SEQ) { + return String.format("MsgType: %s, EventType: %s, Sequence: %d, Payload: %s", + this.type, this.event, this.sequence, + this.payload != null ? new String(this.payload) : "null"); + } + return String.format("MsgType: %s, EventType: %s, Payload: %s", this.type, this.event, + this.payload != null ? new String(this.payload) : "null"); + } + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/MsgType.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/MsgType.java new file mode 100644 index 00000000..965f40e8 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/MsgType.java @@ -0,0 +1,29 @@ +package org.ruoyi.aihuman.protocol; + +import lombok.Getter; + +@Getter +public enum MsgType { + INVALID((byte) 0), + FULL_CLIENT_REQUEST((byte) 0b1), + AUDIO_ONLY_CLIENT((byte) 0b10), + FULL_SERVER_RESPONSE((byte) 0b1001), + AUDIO_ONLY_SERVER((byte) 0b1011), + FRONT_END_RESULT_SERVER((byte) 0b1100), + ERROR((byte) 0b1111); + + private final byte value; + + MsgType(byte value) { + this.value = value; + } + + public static MsgType fromValue(int value) { + for (MsgType type : MsgType.values()) { + if (type.value == value) { + return type; + } + } + throw new IllegalArgumentException("Unknown MsgType value: " + value); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/MsgTypeFlagBits.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/MsgTypeFlagBits.java new file mode 100644 index 00000000..393a24dc --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/MsgTypeFlagBits.java @@ -0,0 +1,27 @@ +package org.ruoyi.aihuman.protocol; + +import lombok.Getter; + +@Getter +public enum MsgTypeFlagBits { + NO_SEQ((byte) 0), // Non-terminating packet without sequence number + POSITIVE_SEQ((byte) 0b1), // Non-terminating packet with positive sequence number + LAST_NO_SEQ((byte) 0b10), // Terminating packet without sequence number + NEGATIVE_SEQ((byte) 0b11), // Terminating packet with negative sequence number + WITH_EVENT((byte) 0b100); // Packet containing event number + + private final byte value; + + MsgTypeFlagBits(byte value) { + this.value = value; + } + + public static MsgTypeFlagBits fromValue(int value) { + for (MsgTypeFlagBits flag : MsgTypeFlagBits.values()) { + if (flag.value == value) { + return flag; + } + } + throw new IllegalArgumentException("Unknown MsgTypeFlagBits value: " + value); + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/SerializationBits.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/SerializationBits.java new file mode 100644 index 00000000..01e8f102 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/SerializationBits.java @@ -0,0 +1,27 @@ +package org.ruoyi.aihuman.protocol; + +import lombok.Getter; + +@Getter +public enum SerializationBits { + Raw((byte) 0), + JSON((byte) 0b1), + Thrift((byte) 0b11), + Custom((byte) 0b1111), + ; + + private final byte value; + + SerializationBits(byte b) { + this.value = b; + } + + public static SerializationBits fromValue(int value) { + for (SerializationBits type : SerializationBits.values()) { + if (type.value == value) { + return type; + } + } + throw new IllegalArgumentException("Unknown SerializationBits value: " + value); + } +} diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/SpeechWebSocketClient.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/SpeechWebSocketClient.java new file mode 100644 index 00000000..c41e57b3 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/SpeechWebSocketClient.java @@ -0,0 +1,115 @@ +package org.ruoyi.aihuman.protocol; + +import lombok.extern.slf4j.Slf4j; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +@Slf4j +public class SpeechWebSocketClient extends WebSocketClient { + private final BlockingQueue messageQueue = new LinkedBlockingQueue<>(); + + public SpeechWebSocketClient(URI serverUri, Map headers) { + super(serverUri, headers); + } + + @Override + public void onOpen(ServerHandshake handshakedata) { + log.info("WebSocket connection established, Logid: {}", handshakedata.getFieldValue("x-tt-logid")); + } + + @Override + public void onMessage(String message) { + log.warn("Received unexpected text message: {}", message); + } + + @Override + public void onMessage(ByteBuffer bytes) { + try { + Message message = Message.unmarshal(bytes.array()); + messageQueue.put(message); + } catch (Exception e) { + log.error("Failed to parse message", e); + } + } + + @Override + public void onClose(int code, String reason, boolean remote) { + log.info("WebSocket connection closed: code={}, reason={}, remote={}", code, reason, remote); + } + + @Override + public void onError(Exception ex) { + log.error("WebSocket error", ex); + } + + public void sendStartConnection() throws Exception { + Message message = new Message(MsgType.FULL_CLIENT_REQUEST, MsgTypeFlagBits.WITH_EVENT); + message.setEvent(EventType.START_CONNECTION); + message.setPayload("{}".getBytes()); + sendMessage(message); + } + + public void sendFinishConnection() throws Exception { + Message message = new Message(MsgType.FULL_CLIENT_REQUEST, MsgTypeFlagBits.WITH_EVENT); + message.setEvent(EventType.FINISH_CONNECTION); + sendMessage(message); + } + + public void sendStartSession(byte[] payload, String sessionId) throws Exception { + Message message = new Message(MsgType.FULL_CLIENT_REQUEST, MsgTypeFlagBits.WITH_EVENT); + message.setEvent(EventType.START_SESSION); + message.setSessionId(sessionId); + message.setPayload(payload); + sendMessage(message); + } + + public void sendFinishSession(String sessionId) throws Exception { + Message message = new Message(MsgType.FULL_CLIENT_REQUEST, MsgTypeFlagBits.WITH_EVENT); + message.setEvent(EventType.FINISH_SESSION); + message.setSessionId(sessionId); + message.setPayload("{}".getBytes()); + sendMessage(message); + } + + public void sendTaskRequest(byte[] payload, String sessionId) throws Exception { + Message message = new Message(MsgType.FULL_CLIENT_REQUEST, MsgTypeFlagBits.WITH_EVENT); + message.setEvent(EventType.TASK_REQUEST); + message.setSessionId(sessionId); + message.setPayload(payload); + sendMessage(message); + } + + public void sendFullClientMessage(byte[] payload) throws Exception { + Message message = new Message(MsgType.FULL_CLIENT_REQUEST, MsgTypeFlagBits.NO_SEQ); + message.setPayload(payload); + sendMessage(message); + } + + public void sendMessage(Message message) throws Exception { + log.info("Send: {}", message); + send(message.marshal()); + } + + public Message receiveMessage() throws InterruptedException { + Message message = messageQueue.take(); + log.info("Receive: {}", message); + return message; + } + + public Message waitForMessage(MsgType type, EventType event) throws InterruptedException { + while (true) { + Message message = receiveMessage(); + if (message.getType() == type && message.getEvent() == event) { + return message; + } else { + throw new RuntimeException("Unexpected message: " + message); + } + } + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/VersionBits.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/VersionBits.java new file mode 100644 index 00000000..6f4a8174 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/protocol/VersionBits.java @@ -0,0 +1,27 @@ +package org.ruoyi.aihuman.protocol; + +import lombok.Getter; + +@Getter +public enum VersionBits { + Version1((byte) 1), + Version2((byte) 2), + Version3((byte) 3), + Version4((byte) 4), + ; + + private final byte value; + + VersionBits(byte b) { + this.value = b; + } + + public static VersionBits fromValue(int value) { + for (VersionBits type : VersionBits.values()) { + if (type.value == value) { + return type; + } + } + throw new IllegalArgumentException("Unknown VersionBits value: " + value); + } +} diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/AihumanRealConfigService.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/AihumanRealConfigService.java new file mode 100644 index 00000000..0f258ffa --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/AihumanRealConfigService.java @@ -0,0 +1,56 @@ +package org.ruoyi.aihuman.service; + +import org.ruoyi.aihuman.domain.vo.AihumanRealConfigVo; +import org.ruoyi.aihuman.domain.bo.AihumanRealConfigBo; + import org.ruoyi.core.page.TableDataInfo; + import org.ruoyi.core.page.PageQuery; + +import java.util.Collection; +import java.util.List; + +/** + * 真人交互数字人配置Service接口 + * + * @author ageerle + * @date Tue Oct 21 11:46:52 GMT+08:00 2025 + */ +public interface AihumanRealConfigService { + + /** + * 查询真人交互数字人配置 + */ + AihumanRealConfigVo queryById(Integer id); + + /** + * 查询真人交互数字人配置列表 + */ + TableDataInfo queryPageList(AihumanRealConfigBo bo, PageQuery pageQuery); + + /** + * 查询真人交互数字人配置列表 + */ + List queryList(AihumanRealConfigBo bo); + + /** + * 新增真人交互数字人配置 + */ + Boolean insertByBo(AihumanRealConfigBo bo); + + /** + * 修改真人交互数字人配置 + */ + Boolean updateByBo(AihumanRealConfigBo bo); + + /** + * 执行真人交互数字人配置 + */ + Boolean runByBo(AihumanRealConfigBo bo); + + /** + * 校验并批量删除真人交互数字人配置信息 + */ + Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + + // 在AihumanRealConfigService接口中添加 + Boolean stopByBo(AihumanRealConfigBo bo); +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/AihumanVolcengineService.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/AihumanVolcengineService.java new file mode 100644 index 00000000..6c99f013 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/AihumanVolcengineService.java @@ -0,0 +1,4 @@ +package org.ruoyi.aihuman.service; + +public interface AihumanVolcengineService { +} diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/impl/AihumanRealConfigServiceImpl.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/impl/AihumanRealConfigServiceImpl.java new file mode 100644 index 00000000..e1a09f6f --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/impl/AihumanRealConfigServiceImpl.java @@ -0,0 +1,532 @@ +package org.ruoyi.aihuman.service.impl; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import jakarta.annotation.PreDestroy; +import org.ruoyi.common.core.utils.MapstructUtils; +import org.ruoyi.core.page.TableDataInfo; +import org.ruoyi.core.page.PageQuery; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.ruoyi.aihuman.domain.bo.AihumanRealConfigBo; +import org.ruoyi.aihuman.domain.vo.AihumanRealConfigVo; +import org.ruoyi.aihuman.domain.AihumanRealConfig; +import org.ruoyi.aihuman.mapper.AihumanRealConfigMapper; +import org.ruoyi.aihuman.service.AihumanRealConfigService; +import org.ruoyi.common.core.utils.StringUtils; +import org.ruoyi.common.redis.utils.RedisUtils; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Collection; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.sun.jna.platform.win32.WinNT; +import com.sun.jna.Pointer; +import java.util.concurrent.TimeUnit; + +/** + * 真人交互数字人配置Service业务层处理 + * + * @author ageerle + * @date Tue Oct 21 11:46:52 GMT+08:00 2025 + */ +@RequiredArgsConstructor +@Service +public class AihumanRealConfigServiceImpl implements AihumanRealConfigService { + + private final AihumanRealConfigMapper baseMapper; + // 存储当前运行的进程,用于停止操作 + private volatile Process runningProcess = null; + + /** + * 查询真人交互数字人配置 + */ + @Override + public AihumanRealConfigVo queryById(Integer id) { + return baseMapper.selectVoById(id); + } + + /** + * 查询真人交互数字人配置列表 + */ + @Override + public TableDataInfo queryPageList(AihumanRealConfigBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + Page result = baseMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(result); + } + + /** + * 查询真人交互数字人配置列表 + */ + @Override + public List queryList(AihumanRealConfigBo bo) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + return baseMapper.selectVoList(lqw); + } + + private LambdaQueryWrapper buildQueryWrapper(AihumanRealConfigBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getName()), AihumanRealConfig::getName, bo.getName()); + lqw.like(StringUtils.isNotBlank(bo.getAvatars()), AihumanRealConfig::getAvatars, bo.getAvatars()); + lqw.like(StringUtils.isNotBlank(bo.getModels()), AihumanRealConfig::getModels, bo.getModels()); + lqw.eq(StringUtils.isNotBlank(bo.getAvatarsParams()), AihumanRealConfig::getAvatarsParams, bo.getAvatarsParams()); + lqw.eq(StringUtils.isNotBlank(bo.getModelsParams()), AihumanRealConfig::getModelsParams, bo.getModelsParams()); + lqw.eq(StringUtils.isNotBlank(bo.getAgentParams()), AihumanRealConfig::getAgentParams, bo.getAgentParams()); + lqw.eq(bo.getCreateTime() != null, AihumanRealConfig::getCreateTime, bo.getCreateTime()); + lqw.eq(bo.getUpdateTime() != null, AihumanRealConfig::getUpdateTime, bo.getUpdateTime()); + lqw.eq(bo.getStatus() != null, AihumanRealConfig::getStatus, bo.getStatus()); + lqw.eq(bo.getPublish() != null, AihumanRealConfig::getPublish, bo.getPublish()); + lqw.eq(StringUtils.isNotBlank(bo.getRunParams()), AihumanRealConfig::getRunParams, bo.getRunParams()); + // 添加runStatus字段的查询条件 + lqw.eq(StringUtils.isNotBlank(bo.getRunStatus()), AihumanRealConfig::getRunStatus, bo.getRunStatus()); + lqw.eq(StringUtils.isNotBlank(bo.getCreateDept()), AihumanRealConfig::getCreateDept, bo.getCreateDept()); + lqw.eq(StringUtils.isNotBlank(bo.getCreateBy()), AihumanRealConfig::getCreateBy, bo.getCreateBy()); + lqw.eq(StringUtils.isNotBlank(bo.getUpdateBy()), AihumanRealConfig::getUpdateBy, bo.getUpdateBy()); + return lqw; + } + + /** + * 新增真人交互数字人配置 + */ + @Override + public Boolean insertByBo(AihumanRealConfigBo bo) { + AihumanRealConfig add = MapstructUtils.convert(bo, AihumanRealConfig. class); + validEntityBeforeSave(add); + boolean flag = baseMapper.insert(add) > 0; + if (flag) { + bo.setId(add.getId()); + } + return flag; + } + + /** + * 修改真人交互数字人配置 + */ + @Override + public Boolean updateByBo(AihumanRealConfigBo bo) { + AihumanRealConfig update = MapstructUtils.convert(bo, AihumanRealConfig. class); + validEntityBeforeSave(update); + return baseMapper.updateById(update) > 0; + } + + /** + * 保存前的数据校验 + */ + private void validEntityBeforeSave(AihumanRealConfig entity) { + //TODO 做一些数据校验,如唯一约束 + } + + /** + * 批量删除真人交互数字人配置 + */ + @Override + public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { + if (isValid) { + //TODO 做一些业务上的校验,判断是否需要校验 + } + return baseMapper.deleteBatchIds(ids) > 0; + } + + private static final Logger log = LoggerFactory.getLogger(AihumanRealConfigServiceImpl.class); + + /** + * 执行真人交互数字人配置 + * 通过主键获取数据库记录,然后从run_params字段读取命令并执行 + */ + @Override + public Boolean runByBo(AihumanRealConfigBo bo) { + try { + // 1. 通过主键获取数据库记录 + Integer id = bo.getId(); + if (id == null) { + log.error("执行命令失败:主键ID为空"); + throw new RuntimeException("执行命令失败:主键ID为空"); + } + + // 检查是否已经有对应的进程在运行 + String redisKey = "aihuman:process:" + id; + String existingPid = RedisUtils.getCacheObject(redisKey); + if (StringUtils.isNotEmpty(existingPid) && isProcessRunning(existingPid)) { + log.warn("ID为{}的配置已有进程在运行,进程ID: {}", id, existingPid); + // 刷新run_status状态为运行中 + AihumanRealConfig updateStatus = new AihumanRealConfig(); + updateStatus.setId(id); + updateStatus.setRunStatus("1"); // 1表示运行中 + baseMapper.updateById(updateStatus); + return true; + } + + // 查询数据库记录 + AihumanRealConfig config = baseMapper.selectById(id); + if (config == null) { + log.error("执行命令失败:未找到ID为{}的配置记录", id); + throw new RuntimeException("执行命令失败:未找到对应的配置记录"); + } + + // 2. 从记录中获取run_params字段 + String runParams = config.getRunParams(); + if (StringUtils.isBlank(runParams)) { + log.error("执行命令失败:ID为{}的记录中run_params字段为空", id); + throw new RuntimeException("执行命令失败:run_params字段为空"); + } + + // 3. 解析并执行命令 + // 将多行命令合并为一个命令字符串 + String[] commands = runParams.split("\\r?\\n"); + if (commands.length == 0) { + log.error("执行命令失败:runParams中没有有效的命令"); + throw new RuntimeException("执行命令失败:runParams中没有有效的命令"); + } + + // 将所有命令合并到一个命令字符串中,使用&&连接,确保在同一个进程中执行 + StringBuilder mergedCmd = new StringBuilder(); + for (int i = 0; i < commands.length; i++) { + String command = commands[i].trim(); + if (command.isEmpty()) { + continue; + } + + if (mergedCmd.length() > 0) { + mergedCmd.append(" && "); + } + + mergedCmd.append(command); + } + + String cmd = "cmd.exe /c " + mergedCmd.toString(); + log.info("准备执行合并命令:{}", cmd); + + // 更新数据库中的运行状态为运行中 + AihumanRealConfig updateStatus = new AihumanRealConfig(); + updateStatus.setId(id); + updateStatus.setRunStatus("1"); // 1表示运行中 + baseMapper.updateById(updateStatus); + + // 使用线程池执行命令并监听输出 + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.submit(() -> { + try { + Process process = Runtime.getRuntime().exec(cmd); + // 保存进程引用,用于后续停止操作 + runningProcess = process; + + // 获取进程ID并保存到Redis + String pid = getProcessId(process); + if (!"unknown".equals(pid)) { + RedisUtils.setCacheObject(redisKey, pid); + log.info("保存进程ID到Redis:key={}, pid={}", redisKey, pid); + } + + // 读取标准输出 + new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.info("[LiveTalking] {}", line); + } + } catch (IOException e) { + log.error("读取命令输出失败", e); + } + }).start(); + + // 读取debug输出 + new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.debug("[LiveTalking DEBUG] {}", line); + } + } catch (IOException e) { + log.error("读取命令debug输出失败", e); + } + }).start(); + + // 等待进程结束 + int exitCode = process.waitFor(); + log.info("LiveTalking进程结束,退出码: {}", exitCode); + + // 进程结束后更新数据库状态为已停止 + AihumanRealConfig endStatus = new AihumanRealConfig(); + endStatus.setId(id); + endStatus.setRunStatus("0"); // 0表示已停止 + baseMapper.updateById(endStatus); + + // 进程结束后从Redis中删除进程ID + RedisUtils.deleteObject(redisKey); + log.info("从Redis中删除进程ID:key={}", redisKey); + + // 进程结束后清空引用 + runningProcess = null; + } catch (Exception e) { + log.error("执行命令失败", e); + // 发生异常时更新数据库状态为失败 + try { + AihumanRealConfig errorStatus = new AihumanRealConfig(); + errorStatus.setId(id); + errorStatus.setRunStatus("2"); // 2表示启动失败 + baseMapper.updateById(errorStatus); + } catch (Exception ex) { + log.error("更新状态失败", ex); + } + // 发生异常时从Redis中删除进程ID + RedisUtils.deleteObject(redisKey); + // 发生异常时清空引用 + runningProcess = null; + } + }); + + executor.shutdown(); + return true; + } catch (Exception e) { + log.error("执行命令过程中发生异常", e); + return false; + } + } + + /** + * 检查进程是否正在运行 + * @param pid 进程ID + * @return 是否正在运行 + */ + private boolean isProcessRunning(String pid) { + if (StringUtils.isEmpty(pid) || "unknown".equals(pid)) { + return false; + } + + try { + boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); + ProcessBuilder processBuilder; + + if (isWindows) { + processBuilder = new ProcessBuilder("tasklist", "/FI", "PID eq " + pid); + } else { + processBuilder = new ProcessBuilder("ps", "-p", pid); + } + + Process process = processBuilder.start(); + int exitCode = process.waitFor(); + + // 在Windows上,tasklist命令如果找不到进程,退出码也是0,但输出中不会包含PID + if (isWindows) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.contains(pid)) { + return true; + } + } + } + return false; + } else { + // 在Linux/Mac上,ps命令如果找不到进程,退出码不为0 + return exitCode == 0; + } + } catch (Exception e) { + log.error("检查进程是否运行失败, pid={}", pid, e); + return false; + } + } + + /** + * 停止正在运行的真人交互数字人配置任务 + */ + @Override + public Boolean stopByBo(AihumanRealConfigBo bo) { + try { + Integer id = bo.getId(); + String redisKey = "aihuman:process:" + id; + + // 首先检查Redis中是否有对应的进程ID + String pid = RedisUtils.getCacheObject(redisKey); + if (StringUtils.isNotEmpty(pid)) { + // 如果Redis中有进程ID,先尝试通过进程ID停止进程 + try { + // 根据操作系统类型,使用不同的命令终止进程树 + if (System.getProperty("os.name").toLowerCase().contains("win")) { + // Windows系统使用taskkill命令终止进程树 + log.info("通过Redis中的PID停止进程: taskkill /F /T /PID {}", pid); + Process killProcess = Runtime.getRuntime().exec("taskkill /F /T /PID " + pid); + // 等待kill命令执行完成 + killProcess.waitFor(5, TimeUnit.SECONDS); + } else { + // Linux/Mac系统使用pkill命令终止进程树 + Runtime.getRuntime().exec("pkill -P " + pid); + } + } catch (Exception e) { + log.error("通过Redis中的PID停止进程失败", e); + } + } + + // 然后检查本地runningProcess引用 + if (runningProcess != null && runningProcess.isAlive()) { + log.info("正在停止LiveTalking进程..."); + // 强制销毁进程树,确保完全停止 + destroyProcessTree(runningProcess); + + // 更新数据库中的运行状态为已停止 + AihumanRealConfig updateStatus = new AihumanRealConfig(); + updateStatus.setId(id); + updateStatus.setRunStatus("0"); // 0表示已停止 + baseMapper.updateById(updateStatus); + + runningProcess = null; + log.info("LiveTalking进程已停止"); + } else { + log.warn("没有正在运行的LiveTalking进程"); + // 确保数据库中的状态也是已停止 + AihumanRealConfig updateStatus = new AihumanRealConfig(); + updateStatus.setId(id); + updateStatus.setRunStatus("0"); // 0表示已停止 + baseMapper.updateById(updateStatus); + } + + // 无论如何都从Redis中删除进程ID + RedisUtils.deleteObject(redisKey); + log.info("从Redis中删除进程ID:key={}", redisKey); + + return true; + } catch (Exception e) { + log.error("停止进程时发生异常", e); + // 发生异常时也尝试从Redis中删除进程ID + try { + RedisUtils.deleteObject("aihuman:process:" + bo.getId()); + } catch (Exception ex) { + log.error("从Redis中删除进程ID失败", ex); + } + return false; + } + } + + /** + * 销毁进程及其子进程(进程树) + * @param process 要销毁的进程 + */ + private void destroyProcessTree(Process process) { + try { + if (process.isAlive()) { + // 获取进程ID + String pid = getProcessId(process); + log.info("获取到进程ID: {}", pid); + + // 根据操作系统类型,使用不同的命令终止进程树 + if (System.getProperty("os.name").toLowerCase().contains("win")) { + // Windows系统使用taskkill命令终止进程树 + log.info("执行taskkill命令终止进程树: taskkill /F /T /PID {}", pid); + Process killProcess = Runtime.getRuntime().exec("taskkill /F /T /PID " + pid); + // 等待kill命令执行完成 + killProcess.waitFor(5, TimeUnit.SECONDS); + } else { + // Linux/Mac系统使用pkill命令终止进程树 + Runtime.getRuntime().exec("pkill -P " + pid); + process.destroy(); + } + } + } catch (Exception e) { + log.error("销毁进程树时发生异常", e); + // 如果出现异常,尝试使用普通销毁方法 + process.destroy(); + try { + // 强制销毁 + if (process.isAlive()) { + process.destroyForcibly(); + } + } catch (Exception ex) { + log.error("强制销毁进程失败", ex); + } + } + } + + /** + * 获取进程ID + * @param process 进程对象 + * @return 进程ID + */ + private String getProcessId(Process process) { + try { + // 不同JVM实现可能有所不同,这里尝试通过反射获取 + if (process.getClass().getName().equals("java.lang.Win32Process") || + process.getClass().getName().equals("java.lang.ProcessImpl")) { + Field f = process.getClass().getDeclaredField("handle"); + f.setAccessible(true); + long handl = f.getLong(process); + Kernel32 kernel = Kernel32.INSTANCE; + WinNT.HANDLE handle = new WinNT.HANDLE(); + handle.setPointer(Pointer.createConstant(handl)); + return String.valueOf(kernel.GetProcessId(handle)); + } else if (process.getClass().getName().equals("java.lang.UNIXProcess")) { + Field f = process.getClass().getDeclaredField("pid"); + f.setAccessible(true); + return String.valueOf(f.getInt(process)); + } + } catch (Exception e) { + log.error("获取进程ID失败", e); + } + + // 如果反射获取失败,尝试通过wmic命令获取 + try { + // 对于Windows系统,可以尝试使用wmic命令获取进程ID + if (System.getProperty("os.name").toLowerCase().contains("win")) { + ProcessHandle.Info info = process.toHandle().info(); + return String.valueOf(process.toHandle().pid()); + } + } catch (Exception e) { + log.error("通过ProcessHandle获取进程ID失败", e); + } + + return "unknown"; + } + + // JNA接口定义,用于Windows系统获取进程ID + interface Kernel32 extends Library { + Kernel32 INSTANCE = (Kernel32) Native.loadLibrary("kernel32", Kernel32.class); + int GetProcessId(WinNT.HANDLE hProcess); + } + + @PreDestroy + public void onDestroy() { + if (runningProcess != null && runningProcess.isAlive()) { + try { + log.info("应用关闭,正在停止数字人进程"); + destroyProcessTree(runningProcess); + + // 查找所有运行状态为运行中的配置,并更新为已停止 + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AihumanRealConfig::getRunStatus, "1"); + List runningConfigs = baseMapper.selectList(lqw); + for (AihumanRealConfig config : runningConfigs) { + config.setRunStatus("0"); + baseMapper.updateById(config); + + // 从Redis中删除对应的进程ID记录 + String redisKey = "aihuman:process:" + config.getId(); + RedisUtils.deleteObject(redisKey); + log.info("应用关闭,从Redis中删除进程ID:key={}", redisKey); + } + } catch (Exception e) { + log.error("停止数字人进程失败", e); + // 即使发生异常,也尝试清理Redis中的进程ID记录 + try { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(AihumanRealConfig::getRunStatus, "1"); + List runningConfigs = baseMapper.selectList(lqw); + for (AihumanRealConfig config : runningConfigs) { + RedisUtils.deleteObject("aihuman:process:" + config.getId()); + } + } catch (Exception ex) { + log.error("清理Redis中的进程ID记录失败", ex); + } + } + } + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/impl/AihumanVolcengineServiceImpl.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/impl/AihumanVolcengineServiceImpl.java new file mode 100644 index 00000000..aa11aabd --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/service/impl/AihumanVolcengineServiceImpl.java @@ -0,0 +1,4 @@ +package org.ruoyi.aihuman.service.impl; + +public class AihumanVolcengineServiceImpl { +} diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/sql/aihuman_real_config_menu.sql b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/sql/aihuman_real_config_menu.sql new file mode 100644 index 00000000..c1ef85a6 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/sql/aihuman_real_config_menu.sql @@ -0,0 +1,19 @@ +-- 菜单 SQL +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(1980480880138051584, '真人交互数字人配置', '2000', '1', 'aihumanRealConfig', 'aihuman/aihumanRealConfig/index', 1, 0, 'C', '0', '0', 'aihuman:aihumanRealConfig:list', '#', 103, 1, sysdate(), null, null, '真人交互数字人配置菜单'); + +-- 按钮 SQL +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(1980480880138051585, '真人交互数字人配置查询', 1980480880138051584, '1', '#', '', 1, 0, 'F', '0', '0', 'aihuman:aihumanRealConfig:query', '#', 103, 1, sysdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(1980480880138051586, '真人交互数字人配置新增', 1980480880138051584, '2', '#', '', 1, 0, 'F', '0', '0', 'aihuman:aihumanRealConfig:add', '#', 103, 1, sysdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(1980480880138051587, '真人交互数字人配置修改', 1980480880138051584, '3', '#', '', 1, 0, 'F', '0', '0', 'aihuman:aihumanRealConfig:edit', '#', 103, 1, sysdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(1980480880138051588, '真人交互数字人配置删除', 1980480880138051584, '4', '#', '', 1, 0, 'F', '0', '0', 'aihuman:aihumanRealConfig:remove', '#', 103, 1, sysdate(), null, null, ''); + +insert into sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_dept, create_by, create_time, update_by, update_time, remark) +values(1980480880138051589, '真人交互数字人配置导出', 1980480880138051584, '5', '#', '', 1, 0, 'F', '0', '0', 'aihuman:aihumanRealConfig:export', '#', 103, 1, sysdate(), null, null, ''); diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/volcengine/Bidirection.java b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/volcengine/Bidirection.java new file mode 100644 index 00000000..f4dd19e3 --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/java/org/ruoyi/aihuman/volcengine/Bidirection.java @@ -0,0 +1,160 @@ +package org.ruoyi.aihuman.volcengine; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.ruoyi.aihuman.protocol.EventType; +import org.ruoyi.aihuman.protocol.Message; +import org.ruoyi.aihuman.protocol.MsgType; +import org.ruoyi.aihuman.protocol.SpeechWebSocketClient; +import lombok.extern.slf4j.Slf4j; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.net.URI; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Slf4j +public class Bidirection { + private static final String ENDPOINT = "wss://openspeech.bytedance.com/api/v3/tts/bidirection"; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Get resource ID based on voice type + * + * @param voice Voice type string + * @return Corresponding resource ID + */ + public static String voiceToResourceId(String voice) { + // Map different voice types to resource IDs based on actual needs + if (voice.startsWith("S_")) { + return "volc.megatts.default"; + } + return "volc.service_type.10029"; + } + + public static void main(String[] args) throws Exception { + // Configure parameters + String appId = System.getProperty("appId", "1055299334"); + String accessToken = System.getProperty("accessToken", "fOHuq4R4dirMYiOruCU3Ek9q75zV0KVW"); + String resourceId = System.getProperty("resourceId", "seed-tts-2.0"); + String voice = System.getProperty("voice", "zh_female_vv_uranus_bigtts"); + String text = System.getProperty("text", "你好呀!我是AI合成的语音,很高兴认识你。"); + String encoding = System.getProperty("encoding", "mp3"); + + if (appId.isEmpty() || accessToken.isEmpty()) { + throw new IllegalArgumentException("Please set appId and accessToken system properties"); + } + + // Set request headers + Map headers = Map.of( + "X-Api-App-Key", appId, + "X-Api-Access-Key", accessToken, + "X-Api-Resource-Id", resourceId.isEmpty() ? voiceToResourceId(voice) : resourceId, + "X-Api-Connect-Id", UUID.randomUUID().toString()); + + // Create WebSocket client + SpeechWebSocketClient client = new SpeechWebSocketClient(new URI(ENDPOINT), headers); + try { + client.connectBlocking(); + Map request = Map.of( + "user", Map.of("uid", UUID.randomUUID().toString()), + "namespace", "BidirectionalTTS", + "req_params", Map.of( + "speaker", voice, + "audio_params", Map.of( + "format", encoding, + "sample_rate", 24000, + "enable_timestamp", true), + // additions requires a JSON string + "additions", objectMapper.writeValueAsString(Map.of( + "disable_markdown_filter", false)))); + + // Start connection + client.sendStartConnection(); + // Wait for connection started + client.waitForMessage(MsgType.FULL_SERVER_RESPONSE, EventType.CONNECTION_STARTED); + + // Process each sentence + String[] sentences = text.split("。"); + boolean audioReceived = false; + for (int i = 0; i < sentences.length; i++) { + if (sentences[i].trim().isEmpty()) { + continue; + } + + String sessionId = UUID.randomUUID().toString(); + ByteArrayOutputStream audioStream = new ByteArrayOutputStream(); + + // Start session + Map startReq = Map.of( + "user", request.get("user"), + "namespace", request.get("namespace"), + "req_params", request.get("req_params"), + "event", EventType.START_SESSION.getValue()); + client.sendStartSession(objectMapper.writeValueAsBytes(startReq), sessionId); + // Wait for session started + client.waitForMessage(MsgType.FULL_SERVER_RESPONSE, EventType.SESSION_STARTED); + + // Send text + for (char c : sentences[i].toCharArray()) { + // Create new req_params with text + @SuppressWarnings("unchecked") + Map currentReqParams = new HashMap<>( + (Map) request.get("req_params")); + currentReqParams.put("text", String.valueOf(c)); + + // Create current request + Map currentRequest = Map.of( + "user", request.get("user"), + "namespace", request.get("namespace"), + "req_params", currentReqParams, + "event", EventType.TASK_REQUEST.getValue()); + + client.sendTaskRequest(objectMapper.writeValueAsBytes(currentRequest), sessionId); + } + + // End session + client.sendFinishSession(sessionId); + + // Receive response + while (true) { + Message msg = client.receiveMessage(); + switch (msg.getType()) { + case FULL_SERVER_RESPONSE: + break; + case AUDIO_ONLY_SERVER: + if (!audioReceived && audioStream.size() > 0) { + audioReceived = true; + } + if (msg.getPayload() != null) { + audioStream.write(msg.getPayload()); + } + break; + default: + throw new RuntimeException("Unexpected message: " + msg); + } + if (msg.getEvent() == EventType.SESSION_FINISHED) { + break; + } + } + + if (audioStream.size() > 0) { + String fileName = String.format("%s_session_%d.%s", voice, i, encoding); + Files.write(new File(fileName).toPath(), audioStream.toByteArray()); + log.info("Audio saved to file: {}", fileName); + } + } + + if (!audioReceived) { + throw new RuntimeException("No audio data received"); + } + + // End connection + client.sendFinishConnection(); + } finally { + client.closeBlocking(); + } + } +} \ No newline at end of file diff --git a/ruoyi-modules/ruoyi-aihuman/src/main/resources/mapper/aihuman/AihumanRealConfigMapper.xml b/ruoyi-modules/ruoyi-aihuman/src/main/resources/mapper/aihuman/AihumanRealConfigMapper.xml new file mode 100644 index 00000000..8c7011ca --- /dev/null +++ b/ruoyi-modules/ruoyi-aihuman/src/main/resources/mapper/aihuman/AihumanRealConfigMapper.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/ruoyi-modules/ruoyi-generator/src/main/resources/generator.yml b/ruoyi-modules/ruoyi-generator/src/main/resources/generator.yml index fea39ec6..7f5955fb 100644 --- a/ruoyi-modules/ruoyi-generator/src/main/resources/generator.yml +++ b/ruoyi-modules/ruoyi-generator/src/main/resources/generator.yml @@ -3,8 +3,8 @@ gen: # 作者 author: ageerle # 默认生成包路径 system 需改成自己的模块名称 如 system monitor tool - packageName: org.ruoyi.system + packageName: org.ruoyi.aihuman # 自动去除表前缀,默认是false autoRemovePre: false # 表前缀(生成类名不会包含表前缀,多个用逗号分隔) - tablePrefix: sys_ + tablePrefix: aihuman_