mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 07:03:53 +08:00
@@ -1,29 +1,56 @@
|
||||
# 火山双向流式TTS+声音克隆配置教程
|
||||
单模块部署下,使用火山引擎双向流式语音合成服务的同时进行声音克隆,支持WebSocket协议流式调用。
|
||||
# 智控台 火山双流式语音合成+音色克隆配置教程
|
||||
|
||||
本教程分为3个阶段:准备阶段、配置阶段、克隆阶段、使用阶段。主要是介绍通过智控台配置火山双流式语音合成+音色克隆的过程。
|
||||
|
||||
## 第一阶段:准备阶段
|
||||
超级管理员先预先把火山引擎服务开通好,获取到App Id,Access Token。默认火上引擎会赠送一个音色资源。这个音色资源需要把它复制到本项目里。
|
||||
|
||||
如果你想克隆多个音色,需要购买开通多个音色资源。只要把每个音色资源的声音ID(S_xxxxx)复制到本项目。然后分配给系统的账号使用即可。以下是详细步骤:
|
||||
|
||||
### 1.开通火山引擎服务
|
||||
访问 https://console.volcengine.com/speech/app 在应用管理创建应用,勾选语音合成大模型和声音复刻大模型,左边列表点击声音复刻大模型后下滑获得App Id,Access Token,Cluster ID以及声音ID(S_xxxxx)
|
||||
### 2.克隆音色
|
||||
克隆音色请参照教程 https://github.com/104gogo/huoshan-voice-copy
|
||||
访问 https://console.volcengine.com/speech/app 在应用管理创建应用,勾选语音合成大模型和声音复刻大模型。
|
||||
|
||||
准备一段 10-30 秒的音频文件(.wav格式)添加到克隆的项目中,将平台获得的密钥填入```uploadAndStatus.py```和```tts_http_demo.py```
|
||||
### 2.获取音色资源ID
|
||||
访问 https://console.volcengine.com/speech/service/9999 复制三项内容,分别是App Id,Access Token以及声音ID(S_xxxxx)。如图
|
||||
|
||||
在uploadAndStatus.py中,将 audio_path=修改成自己的.wav文件名称
|
||||
```python
|
||||
train(appid=appid, token=token, audio_path=r".\audios\xiaohe.wav", spk_id=spk_id)
|
||||
```
|
||||

|
||||
|
||||
运行以下命令生成test_submit.mp3,点击播放试听克隆效果
|
||||
## 第二阶段:配置火山引擎服务
|
||||
|
||||
```python
|
||||
python uploadAndStatus.py
|
||||
python tts_http_demo.py
|
||||
```
|
||||
回到火山引擎控制台页面,刷新可以看到声音复刻详情的状态是复刻成功。
|
||||
### 3.填写配置文件
|
||||
将火山引擎服务申请到的密钥填入.config.yaml的HuoshanDoubleStreamTTS配置文件中
|
||||
### 1.填写火山引擎配置
|
||||
|
||||
修改 resource_id的参数为``` volc.megatts.default```
|
||||
(参考官方文档 https://www.volcengine.com/docs/6561/1329505)
|
||||
speaker的参数填入声音ID(S_xxxxx)
|
||||
使用超级管理员账号登录智控台,点击顶部【模型配置】,再点击模型配置页面左侧的【语音合成】,搜索找到“火山双流式语音合成”,点击修改,将你火山引擎的`App Id`填入到【应用ID】字段里,将`Access Token`填入到【访问令牌】字段里。然后保存。
|
||||
|
||||
启动服务,唤醒小智发出的声音是克隆的音色即成功。
|
||||
### 2.将音色资源ID分配给系统账号
|
||||
|
||||
使用超级管理员账号登录智控台,点击顶部【音色克隆】、【音色资源】。
|
||||
|
||||
点击新增按钮,在【平台名称】选择“火山双流式语音合成”;
|
||||
|
||||
在【音色资源ID】填入你火山引擎的声音资源ID(S_xxxxx),填入后按回车;
|
||||
|
||||
在【归属账号】选择你要分配给的系统账号,你可以分配给你自己。然后点击保存
|
||||
|
||||
## 第三阶段:克隆阶段
|
||||
|
||||
如果登录后,点击顶部【音色克隆】》【音色克隆】,显示【您的账号暂无音色资源请联系管理员分配音色资源】,说明你在第二阶段还没有把音色资源ID分配给这个账号。那就是回到第二阶段,分配音色资源给对应的账号。
|
||||
|
||||
如果登录后,点击顶部【音色克隆】》【音色克隆】,能看到对应的音色列表。请继续。
|
||||
|
||||
在列表里会看到对应的音色列表。选择其中一个音色资源,点击【上传音频】按钮。上传后,可以试听一下声音或者截取某段声音。确认后点击【上传音频】按钮。
|
||||

|
||||
|
||||
上传音频后,在列表里会看到对应的音色会变成“待复刻”状态。点击【立即复刻】按钮。等1~2秒会返回结果。
|
||||
|
||||
如果复刻失败,请将鼠标放到“错误信息”图标上,会显示失败的原因。
|
||||
|
||||
如果复刻成功,在列表里会看到对应的音色会变成“训练成功”状态。此时你可以点击【声音名称】栏的修改按钮,修改音色资源的名称,方便后期选择使用。
|
||||
|
||||
## 第三阶段:使用阶段
|
||||
|
||||
点击顶部【智能体管理】,选择任意一个智能体,点击【配置角色】按钮。
|
||||
|
||||
语音合成(TTS)选择“火山双流式语音合成”。在列表里,找到名字带有“克隆音色”的音色资源(如图),选择它,点击保存。
|
||||

|
||||
|
||||
接下来,可以唤醒小智和它对话。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
@@ -146,6 +146,11 @@ public interface Constant {
|
||||
*/
|
||||
String MEMORY_NO_MEM = "Memory_nomem";
|
||||
|
||||
/**
|
||||
* 火山引擎双声道语音克隆
|
||||
*/
|
||||
String VOICE_CLONE_HUOSHAN_DOUBLE_STREAM = "huoshan_double_stream";
|
||||
|
||||
enum SysBaseParam {
|
||||
/**
|
||||
* ICP备案号
|
||||
@@ -171,6 +176,38 @@ public interface Constant {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 训练状态
|
||||
*/
|
||||
enum TrainStatus {
|
||||
/**
|
||||
* 未训练
|
||||
*/
|
||||
NOT_TRAINED(0),
|
||||
/**
|
||||
* 训练中
|
||||
*/
|
||||
TRAINING(1),
|
||||
/**
|
||||
* 已训练
|
||||
*/
|
||||
TRAINED(2),
|
||||
/**
|
||||
* 训练失败
|
||||
*/
|
||||
TRAIN_FAILED(3);
|
||||
|
||||
private final int code;
|
||||
|
||||
TrainStatus(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统短信
|
||||
*/
|
||||
|
||||
@@ -55,7 +55,7 @@ public interface ErrorCode {
|
||||
|
||||
int OTA_DEVICE_NOT_FOUND = 10041;
|
||||
int OTA_DEVICE_NEED_BIND = 10042;
|
||||
|
||||
|
||||
// 新增错误编码
|
||||
int DELETE_DATA_FAILED = 10043;
|
||||
int USER_NOT_LOGIN = 10044;
|
||||
@@ -66,7 +66,7 @@ public interface ErrorCode {
|
||||
int INVALID_LLM_TYPE = 10049;
|
||||
int TOKEN_GENERATE_ERROR = 10050;
|
||||
int RESOURCE_NOT_FOUND = 10051;
|
||||
|
||||
|
||||
// 新增错误编码
|
||||
int DEFAULT_AGENT_NOT_FOUND = 10052;
|
||||
int AGENT_NOT_FOUND = 10053;
|
||||
@@ -89,7 +89,7 @@ public interface ErrorCode {
|
||||
int LLM_NOT_EXIST = 10092; // 设置的LLM不存在
|
||||
int MODEL_REFERENCED_BY_AGENT = 10093; // 该模型配置已被智能体引用,无法删除
|
||||
int LLM_REFERENCED_BY_INTENT = 10094; // 该LLM模型已被意图识别配置引用,无法删除
|
||||
|
||||
|
||||
// 登录相关错误码
|
||||
int ADD_DATA_FAILED = 10065; // 新增数据失败
|
||||
int UPDATE_DATA_FAILED = 10066; // 修改数据失败
|
||||
@@ -102,17 +102,17 @@ public interface ErrorCode {
|
||||
int RETRIEVE_PASSWORD_DISABLED = 10073; // 未开启找回密码功能
|
||||
int PHONE_FORMAT_ERROR = 10074; // 手机号码格式不正确
|
||||
int SMS_CODE_ERROR = 10075; // 手机验证码错误
|
||||
|
||||
|
||||
// 字典类型相关错误码
|
||||
int DICT_TYPE_NOT_EXIST = 10076; // 字典类型不存在
|
||||
int DICT_TYPE_DUPLICATE = 10077; // 字典类型编码重复
|
||||
|
||||
|
||||
// 资源处理相关错误码
|
||||
int RESOURCE_READ_ERROR = 10078; // 读取资源失败
|
||||
|
||||
|
||||
// 智能体相关错误码
|
||||
int LLM_INTENT_PARAMS_MISMATCH = 10079; // LLM大模型和Intent意图识别,选择参数不匹配
|
||||
|
||||
|
||||
// 声纹相关错误码
|
||||
int VOICEPRINT_ALREADY_REGISTERED = 10080; // 此声音声纹已经注册
|
||||
int VOICEPRINT_DELETE_ERROR = 10081; // 删除声纹出现错误
|
||||
@@ -126,12 +126,12 @@ public interface ErrorCode {
|
||||
int VOICEPRINT_UNREGISTER_REQUEST_ERROR = 10089; // 声纹注销请求失败
|
||||
int VOICEPRINT_UNREGISTER_PROCESS_ERROR = 10090; // 声纹注销处理失败
|
||||
int VOICEPRINT_IDENTIFY_REQUEST_ERROR = 10091; // 声纹识别请求失败
|
||||
|
||||
|
||||
// 服务端管理相关错误码
|
||||
int INVALID_SERVER_ACTION = 10095; // 无效服务端操作
|
||||
int SERVER_WEBSOCKET_NOT_CONFIGURED = 10096; // 未配置服务端WebSocket地址
|
||||
int TARGET_WEBSOCKET_NOT_EXIST = 10097; // 目标WebSocket地址不存在
|
||||
|
||||
|
||||
// 参数验证相关错误码
|
||||
int WEBSOCKET_URLS_EMPTY = 10098; // WebSocket地址列表不能为空
|
||||
int WEBSOCKET_URL_LOCALHOST = 10099; // WebSocket地址不能使用localhost或127.0.0.1
|
||||
@@ -175,7 +175,7 @@ public interface ErrorCode {
|
||||
int DOWNLOAD_LINK_INVALID = 10137; // 下载链接无效
|
||||
int CHAT_ROLE_USER = 10138; // 用户角色
|
||||
int CHAT_ROLE_AGENT = 10139; // 智能体角色
|
||||
|
||||
|
||||
// 声音克隆相关错误码
|
||||
int VOICE_CLONE_AUDIO_EMPTY = 10140; // 音频文件不能为空
|
||||
int VOICE_CLONE_NOT_AUDIO_FILE = 10141; // 只支持音频文件
|
||||
@@ -188,4 +188,14 @@ public interface ErrorCode {
|
||||
int VOICE_RESOURCE_ACCOUNT_EMPTY = 10148; // 归属账号不能为空
|
||||
int VOICE_RESOURCE_DELETE_ID_EMPTY = 10149; // 删除的音色资源ID不能为空
|
||||
int VOICE_RESOURCE_NO_PERMISSION = 10150; // 您没有权限操作该记录
|
||||
int VOICE_CLONE_AUDIO_NOT_UPLOADED = 10151; // 请先上传音频文件
|
||||
int VOICE_CLONE_MODEL_CONFIG_NOT_FOUND = 10152; // 模型配置未找到
|
||||
int VOICE_CLONE_MODEL_TYPE_NOT_FOUND = 10153; // 模型类型未找到
|
||||
int VOICE_CLONE_TRAINING_FAILED = 10154; // 训练失败
|
||||
int VOICE_CLONE_HUOSHAN_CONFIG_MISSING = 10155; // 火山引擎缺少配置
|
||||
int VOICE_CLONE_RESPONSE_FORMAT_ERROR = 10156; // 响应格式错误
|
||||
int VOICE_CLONE_REQUEST_FAILED = 10157; // 请求失败
|
||||
int VOICE_CLONE_PREFIX = 10158; // 复刻音色前缀
|
||||
int VOICE_ID_ALREADY_EXISTS = 10159; // 音色ID已存在
|
||||
int VOICE_CLONE_HUOSHAN_VOICE_ID_ERROR = 10160; // 火山引擎音色ID格式错误
|
||||
}
|
||||
|
||||
@@ -11,15 +11,15 @@ import org.springframework.context.i18n.LocaleContextHolder;
|
||||
public class MessageUtils {
|
||||
private static MessageSource messageSource;
|
||||
|
||||
static {
|
||||
messageSource = (MessageSource) SpringContextUtils.getBean("messageSource");
|
||||
}
|
||||
|
||||
public static String getMessage(int code) {
|
||||
return getMessage(code, new String[0]);
|
||||
}
|
||||
|
||||
public static String getMessage(int code, String... params) {
|
||||
if (messageSource == null) {
|
||||
// 延迟初始化,确保Spring上下文已完全初始化
|
||||
messageSource = (MessageSource) SpringContextUtils.getBean("messageSource");
|
||||
}
|
||||
return messageSource.getMessage(code + "", params, LocaleContextHolder.getLocale());
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -42,6 +42,7 @@ import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
import xiaozhi.modules.agent.vo.AgentInfoVO;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.model.dto.ModelProviderDTO;
|
||||
import xiaozhi.modules.model.dto.VoiceDTO;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.modules.model.service.ModelProviderService;
|
||||
@@ -373,6 +374,22 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
entity.setLlmModelId(template.getLlmModelId());
|
||||
entity.setVllmModelId(template.getVllmModelId());
|
||||
entity.setTtsModelId(template.getTtsModelId());
|
||||
|
||||
if (template.getTtsVoiceId() == null && template.getTtsModelId() != null) {
|
||||
ModelConfigEntity ttsModel = modelConfigService.selectById(template.getTtsModelId());
|
||||
if (ttsModel != null && ttsModel.getConfigJson() != null) {
|
||||
Map<String, Object> config = ttsModel.getConfigJson();
|
||||
String voice = (String) config.get("voice");
|
||||
if (StringUtils.isBlank(voice)) {
|
||||
voice = (String) config.get("speaker");
|
||||
}
|
||||
VoiceDTO timbre = timbreModelService.getByVoiceCode(template.getTtsModelId(), voice);
|
||||
if (timbre != null) {
|
||||
template.setTtsVoiceId(timbre.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entity.setTtsVoiceId(template.getTtsVoiceId());
|
||||
entity.setMemModelId(template.getMemModelId());
|
||||
entity.setIntentModelId(template.getIntentModelId());
|
||||
|
||||
+17
@@ -38,6 +38,8 @@ import xiaozhi.modules.sys.dto.SysParamsDTO;
|
||||
import xiaozhi.modules.sys.service.SysParamsService;
|
||||
import xiaozhi.modules.timbre.service.TimbreService;
|
||||
import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
import xiaozhi.modules.voiceclone.service.VoiceCloneService;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@@ -51,6 +53,7 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
private final TimbreService timbreService;
|
||||
private final AgentPluginMappingService agentPluginMappingService;
|
||||
private final AgentMcpAccessPointService agentMcpAccessPointService;
|
||||
private final VoiceCloneService cloneVoiceService;
|
||||
private final AgentVoicePrintDao agentVoicePrintDao;
|
||||
|
||||
@Override
|
||||
@@ -124,6 +127,11 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
voice = timbre.getTtsVoice();
|
||||
referenceAudio = timbre.getReferenceAudio();
|
||||
referenceText = timbre.getReferenceText();
|
||||
} else {
|
||||
VoiceCloneEntity voice_print = cloneVoiceService.selectById(agent.getTtsVoiceId());
|
||||
if (voice_print != null) {
|
||||
voice = voice_print.getVoiceId();
|
||||
}
|
||||
}
|
||||
// 构建返回数据
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
@@ -392,6 +400,15 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
((Map<String, Object>) model.getConfigJson()).put("ref_audio", referenceAudio);
|
||||
if (referenceText != null)
|
||||
((Map<String, Object>) model.getConfigJson()).put("ref_text", referenceText);
|
||||
|
||||
// 火山引擎声音克隆需要替换resource_id
|
||||
Map<String, Object> map = (Map<String, Object>) model.getConfigJson();
|
||||
if (Constant.VOICE_CLONE_HUOSHAN_DOUBLE_STREAM.equals(map.get("type"))) {
|
||||
// 如果voice是”S_“开头的,使用seed-icl-1.0
|
||||
if (voice != null && voice.startsWith("S_")) {
|
||||
map.put("resource_id", "seed-icl-1.0");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果是Intent类型,且type=intent_llm,则给他添加附加模型
|
||||
if ("Intent".equals(modelTypes[i])) {
|
||||
|
||||
@@ -64,4 +64,13 @@ public interface TimbreService extends BaseService<TimbreEntity> {
|
||||
* @return 音色名称
|
||||
*/
|
||||
String getTimbreNameById(String id);
|
||||
|
||||
/**
|
||||
* 根据音色编码获取音色信息
|
||||
*
|
||||
* @param ttsModelId 音色模型ID
|
||||
* @param voiceCode 音色编码
|
||||
* @return 音色信息
|
||||
*/
|
||||
VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode);
|
||||
}
|
||||
+52
-3
@@ -1,9 +1,11 @@
|
||||
package xiaozhi.modules.timbre.service.impl;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -15,18 +17,23 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import cn.hutool.core.collection.CollectionUtil;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.MessageUtils;
|
||||
import xiaozhi.modules.model.dto.VoiceDTO;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.timbre.dao.TimbreDao;
|
||||
import xiaozhi.modules.timbre.dto.TimbreDataDTO;
|
||||
import xiaozhi.modules.timbre.dto.TimbrePageDTO;
|
||||
import xiaozhi.modules.timbre.entity.TimbreEntity;
|
||||
import xiaozhi.modules.timbre.service.TimbreService;
|
||||
import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
|
||||
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
|
||||
/**
|
||||
* 音色的业务层的实现
|
||||
@@ -39,6 +46,7 @@ import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
|
||||
public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity> implements TimbreService {
|
||||
|
||||
private final TimbreDao timbreDao;
|
||||
private final VoiceCloneDao voiceCloneDao;
|
||||
private final RedisUtils redisUtils;
|
||||
|
||||
@Override
|
||||
@@ -121,11 +129,30 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
queryWrapper.like("name", voiceName);
|
||||
}
|
||||
List<TimbreEntity> timbreEntities = timbreDao.selectList(queryWrapper);
|
||||
if (CollectionUtil.isEmpty(timbreEntities)) {
|
||||
return null;
|
||||
if (timbreEntities == null) {
|
||||
timbreEntities = new ArrayList<>();
|
||||
}
|
||||
List<VoiceDTO> voiceDTOs = timbreEntities.stream()
|
||||
.map(entity -> new VoiceDTO(entity.getId(), entity.getName()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 获取当前登录用户ID
|
||||
Long currentUserId = SecurityUser.getUser().getId();
|
||||
if (currentUserId != null) {
|
||||
// 查询用户的所有克隆音色记录
|
||||
List<VoiceDTO> cloneEntities = voiceCloneDao.getTrainSuccess(ttsModelId, currentUserId);
|
||||
for (VoiceDTO entity : cloneEntities) {
|
||||
// 只添加训练成功的克隆音色,且模型ID匹配
|
||||
VoiceDTO voiceDTO = new VoiceDTO();
|
||||
voiceDTO.setId(entity.getId());
|
||||
voiceDTO.setName(MessageUtils.getMessage(ErrorCode.VOICE_CLONE_PREFIX) + entity.getName());
|
||||
redisUtils.set(RedisKeys.getTimbreNameById(voiceDTO.getId()), voiceDTO.getName(),
|
||||
RedisUtils.NOT_EXPIRE);
|
||||
voiceDTOs.add(0, voiceDTO);
|
||||
}
|
||||
}
|
||||
|
||||
return ConvertUtils.sourceToTarget(timbreEntities, VoiceDTO.class);
|
||||
return CollectionUtil.isEmpty(voiceDTOs) ? null : voiceDTOs;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,8 +181,30 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
redisUtils.set(RedisKeys.getTimbreNameById(id), name);
|
||||
}
|
||||
return name;
|
||||
} else {
|
||||
VoiceCloneEntity cloneEntity = voiceCloneDao.selectById(id);
|
||||
if (cloneEntity != null) {
|
||||
String name = MessageUtils.getMessage(ErrorCode.VOICE_CLONE_PREFIX) + cloneEntity.getName();
|
||||
redisUtils.set(RedisKeys.getTimbreNameById(id), name);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode) {
|
||||
if (StringUtils.isBlank(voiceCode)) {
|
||||
return null;
|
||||
}
|
||||
QueryWrapper<TimbreEntity> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("tts_model_id", ttsModelId);
|
||||
queryWrapper.eq("tts_voice", voiceCode);
|
||||
List<TimbreEntity> list = timbreDao.selectList(queryWrapper);
|
||||
if (list.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return new VoiceDTO(list.get(0).getId(), list.get(0).getName());
|
||||
}
|
||||
}
|
||||
+3
@@ -116,6 +116,7 @@ public class VoiceCloneController {
|
||||
checkPermission(id);
|
||||
|
||||
voiceCloneService.updateName(id, name);
|
||||
redisUtils.delete(RedisKeys.getTimbreNameById(id));
|
||||
return new Result<String>();
|
||||
} catch (Exception e) {
|
||||
return new Result<String>().error(ErrorCode.UPDATE_DATA_FAILED, "更新失败: " + e.getMessage());
|
||||
@@ -175,6 +176,8 @@ public class VoiceCloneController {
|
||||
public Result<String> cloneAudio(@RequestBody Map<String, String> params) {
|
||||
String cloneId = params.get("cloneId");
|
||||
checkPermission(cloneId);
|
||||
// 调用服务层进行语音克隆训练
|
||||
voiceCloneService.cloneAudio(cloneId);
|
||||
return new Result<String>();
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -80,6 +80,8 @@ public class VoiceResourceController {
|
||||
try {
|
||||
voiceCloneService.save(dto);
|
||||
return new Result<Void>();
|
||||
} catch (xiaozhi.common.exception.RenException e) {
|
||||
return new Result<Void>().error(e.getCode(), e.getMsg());
|
||||
} catch (RuntimeException e) {
|
||||
return new Result<Void>().error(ErrorCode.ADD_DATA_FAILED, e.getMessage());
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package xiaozhi.modules.voiceclone.dao;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
import xiaozhi.modules.model.dto.VoiceDTO;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
|
||||
/**
|
||||
@@ -11,5 +14,13 @@ import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
*/
|
||||
@Mapper
|
||||
public interface VoiceCloneDao extends BaseMapper<VoiceCloneEntity> {
|
||||
/**
|
||||
* 获取用户训练成功的音色列表
|
||||
*
|
||||
* @param modelId 模型ID
|
||||
* @param userId 用户ID
|
||||
* @return 训练成功的音色列表
|
||||
*/
|
||||
List<VoiceDTO> getTrainSuccess(String modelId, Long userId);
|
||||
|
||||
}
|
||||
|
||||
+13
-3
@@ -33,19 +33,22 @@ public interface VoiceCloneService extends BaseService<VoiceCloneEntity> {
|
||||
|
||||
/**
|
||||
* 根据用户ID查询声音克隆列表
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 声音克隆列表
|
||||
*/
|
||||
List<VoiceCloneEntity> getByUserId(Long userId);
|
||||
|
||||
|
||||
/**
|
||||
* 分页查询带模型名称和用户名称的声音克隆列表
|
||||
*/
|
||||
PageData<VoiceCloneResponseDTO> pageWithNames(Map<String, Object> params);
|
||||
|
||||
|
||||
/**
|
||||
* 根据ID查询带模型名称和用户名称的声音克隆信息
|
||||
*/
|
||||
VoiceCloneResponseDTO getByIdWithNames(String id);
|
||||
|
||||
|
||||
/**
|
||||
* 根据用户ID查询带模型名称的声音克隆列表
|
||||
*/
|
||||
@@ -65,4 +68,11 @@ public interface VoiceCloneService extends BaseService<VoiceCloneEntity> {
|
||||
* 获取音频数据
|
||||
*/
|
||||
byte[] getVoiceData(String id);
|
||||
|
||||
/**
|
||||
* 克隆音频,调用火山引擎进行语音复刻训练
|
||||
*
|
||||
* @param cloneId 语音克隆记录ID
|
||||
*/
|
||||
void cloneAudio(String cloneId);
|
||||
}
|
||||
|
||||
+166
-2
@@ -1,7 +1,13 @@
|
||||
package xiaozhi.modules.voiceclone.service.impl;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -12,14 +18,19 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.DateUtils;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.modules.sys.service.SysUserService;
|
||||
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
|
||||
@@ -28,6 +39,7 @@ import xiaozhi.modules.voiceclone.dto.VoiceCloneResponseDTO;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
import xiaozhi.modules.voiceclone.service.VoiceCloneService;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceCloneEntity>
|
||||
@@ -35,6 +47,7 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
|
||||
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final SysUserService sysUserService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public PageData<VoiceCloneEntity> page(Map<String, Object> params) {
|
||||
@@ -61,6 +74,36 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void save(VoiceCloneDTO dto) {
|
||||
ModelConfigEntity modelConfig = modelConfigService.getModelByIdFromCache(dto.getModelId());
|
||||
if (modelConfig == null || modelConfig.getConfigJson() == null) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_MODEL_CONFIG_NOT_FOUND);
|
||||
}
|
||||
Map<String, Object> config = modelConfig.getConfigJson();
|
||||
String type = (String) config.get("type");
|
||||
if (StringUtils.isBlank(type)) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_MODEL_TYPE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 检查Voice ID是否已经被使用
|
||||
for (String voiceId : dto.getVoiceIds()) {
|
||||
if (StringUtils.isBlank(voiceId)) {
|
||||
continue;
|
||||
}
|
||||
if (Constant.VOICE_CLONE_HUOSHAN_DOUBLE_STREAM.equals(type)) {
|
||||
if (voiceId.indexOf("S_") == -1) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_HUOSHAN_VOICE_ID_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
QueryWrapper<VoiceCloneEntity> wrapper = new QueryWrapper<>();
|
||||
wrapper.eq("voice_id", voiceId);
|
||||
wrapper.eq("model_id", dto.getModelId());
|
||||
Long count = baseDao.selectCount(wrapper);
|
||||
if (count > 0) {
|
||||
throw new RenException(ErrorCode.VOICE_ID_ALREADY_EXISTS, "音色ID " + voiceId + " 已存在");
|
||||
}
|
||||
}
|
||||
|
||||
// 遍历选择的音色ID,为每个音色ID创建一条记录
|
||||
int index = 0;
|
||||
String namePrefix = DateUtils.format(new java.util.Date(), "MMddHHmm");
|
||||
@@ -207,4 +250,125 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
|
||||
}
|
||||
return entity.getVoice();
|
||||
}
|
||||
|
||||
@Override
|
||||
// @Transactional(rollbackFor = Exception.class)
|
||||
public void cloneAudio(String cloneId) {
|
||||
VoiceCloneEntity entity = baseDao.selectById(cloneId);
|
||||
if (entity == null) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_RECORD_NOT_EXIST);
|
||||
}
|
||||
if (entity.getVoice() == null || entity.getVoice().length == 0) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_AUDIO_NOT_UPLOADED);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
ModelConfigEntity modelConfig = modelConfigService.getModelByIdFromCache(entity.getModelId());
|
||||
if (modelConfig == null || modelConfig.getConfigJson() == null) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_MODEL_CONFIG_NOT_FOUND);
|
||||
}
|
||||
Map<String, Object> config = modelConfig.getConfigJson();
|
||||
String type = (String) config.get("type");
|
||||
if (StringUtils.isBlank(type)) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_MODEL_TYPE_NOT_FOUND);
|
||||
}
|
||||
if (Constant.VOICE_CLONE_HUOSHAN_DOUBLE_STREAM.equals(type)) {
|
||||
huoshanClone(config, entity);
|
||||
}
|
||||
} catch (RenException re) {
|
||||
entity.setTrainStatus(3);
|
||||
entity.setTrainError(re.getMsg());
|
||||
baseDao.updateById(entity);
|
||||
throw re;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
entity.setTrainStatus(3);
|
||||
entity.setTrainError(e.getMessage());
|
||||
baseDao.updateById(entity);
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_TRAINING_FAILED, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用火山引擎进行语音复刻训练
|
||||
*
|
||||
* @param config 模型配置
|
||||
* @param entity 语音克隆记录实体
|
||||
* @throws Exception
|
||||
*/
|
||||
private void huoshanClone(Map<String, Object> config, VoiceCloneEntity entity) throws Exception {
|
||||
String appid = (String) config.get("appid");
|
||||
String accessToken = (String) config.get("access_token");
|
||||
|
||||
if (StringUtils.isAnyBlank(appid, accessToken)) {
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_HUOSHAN_CONFIG_MISSING);
|
||||
}
|
||||
|
||||
String audioBase64 = Base64.getEncoder().encodeToString(entity.getVoice());
|
||||
Map<String, Object> reqBody = new HashMap<>();
|
||||
reqBody.put("appid", appid);
|
||||
List<Map<String, String>> audios = new ArrayList<>();
|
||||
Map<String, String> audioMap = new HashMap<>();
|
||||
audioMap.put("audio_bytes", audioBase64);
|
||||
audioMap.put("audio_format", "wav");
|
||||
audios.add(audioMap);
|
||||
reqBody.put("audios", audios);
|
||||
reqBody.put("source", 2);
|
||||
reqBody.put("language", 0);
|
||||
reqBody.put("model_type", 1);
|
||||
reqBody.put("speaker_id", entity.getVoiceId());
|
||||
|
||||
String apiUrl = "https://openspeech.bytedance.com/api/v1/mega_tts/audio/upload";
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(apiUrl))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer;" + accessToken)
|
||||
.header("Resource-Id", "seed-icl-1.0")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(reqBody)))
|
||||
.build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
System.out.println(">>> HTTP status = " + response.statusCode());
|
||||
System.out.println(">>> response body = " + response.body());
|
||||
|
||||
Map<String, Object> rsp = objectMapper.readValue(response.body(),
|
||||
new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
|
||||
// 获取BaseResp对象
|
||||
Map<String, Object> baseResp = objectMapper.convertValue(rsp.get("BaseResp"),
|
||||
new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
if (baseResp != null) {
|
||||
Integer statusCode = objectMapper.convertValue(baseResp.get("StatusCode"), Integer.class);
|
||||
String statusMessage = objectMapper.convertValue(baseResp.getOrDefault("StatusMessage", ""),
|
||||
String.class);
|
||||
|
||||
// 获取speaker_id
|
||||
String speakerId = objectMapper.convertValue(rsp.get("speaker_id"), String.class);
|
||||
|
||||
// StatusCode == 0 表示成功
|
||||
if (statusCode != null && statusCode == 0 && StringUtils.isNotBlank(speakerId)) {
|
||||
entity.setTrainStatus(2);
|
||||
entity.setVoiceId(speakerId);
|
||||
entity.setTrainError("");
|
||||
baseDao.updateById(entity);
|
||||
} else {
|
||||
// 失败时使用StatusMessage作为错误信息
|
||||
String errorMsg = StringUtils.isNotBlank(statusMessage) ? statusMessage : "训练失败";
|
||||
throw new RenException(errorMsg);
|
||||
}
|
||||
} else {
|
||||
String errorMsg = objectMapper.convertValue(rsp.get("message"),
|
||||
new TypeReference<String>() {
|
||||
});
|
||||
if (StringUtils.isNotBlank(errorMsg)) {
|
||||
throw new RenException(errorMsg);
|
||||
}
|
||||
throw new RenException(ErrorCode.VOICE_CLONE_RESPONSE_FORMAT_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -11,5 +11,9 @@ CREATE TABLE `ai_voice_clone` (
|
||||
`train_error` VARCHAR(255) COMMENT '训练错误原因',
|
||||
`creator` BIGINT COMMENT '创建者 ID',
|
||||
`create_date` DATETIME COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`)
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX idx_ai_voice_clone_user_id_model_id_train_status (model_id,user_id, train_status),
|
||||
INDEX idx_ai_voice_clone_voice_id (voice_id),
|
||||
INDEX idx_ai_voice_clone_user_id (user_id),
|
||||
INDEX idx_ai_voice_clone_model_id_voice_id (model_id, voice_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='声音克隆表';
|
||||
@@ -0,0 +1,2 @@
|
||||
UPDATE `ai_model_config` SET model_code = 'XunfeiSparkLLM' WHERE id = 'LLM_XunfeiSparkLLM';
|
||||
UPDATE `ai_model_config` SET model_code = 'XunfeiStreamASR' WHERE id = 'ASR_XunfeiStream';
|
||||
@@ -389,9 +389,16 @@ databaseChangeLog:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202509220958.sql
|
||||
- changeSet:
|
||||
id: 202510071521
|
||||
id: 202510071522
|
||||
author: hrz
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202510071521.sql
|
||||
path: classpath:db/changelog/202510071522.sql
|
||||
- changeSet:
|
||||
id: 202510191042
|
||||
author: hrz
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202510191042.sql
|
||||
|
||||
@@ -135,7 +135,9 @@
|
||||
10124=\u60A8\u7684mqtt\u5BC6\u94A5\u957F\u5EA6\u4E0D\u5B89\u5168\uFF0Cmqtt\u5BC6\u94A5\u5FC5\u987B\u540C\u65F6\u5305\u542B\u5927\u5C0F\u5199\u5B57\u6BCD
|
||||
10125=\u60A8\u7684mqtt\u5BC6\u94A5\u5305\u542B\u5F31\u5BC6\u7801
|
||||
10128=\u5B57\u5178\u6807\u7B7E\u91CD\u590D
|
||||
10129=modelType\u548CprovideCode\u4E0D\u80FD\u4E3A\u7A7A
|
||||
10129=SM2\u5BC6\u94A5\u672A\u914D\u7F6E
|
||||
10130=SM2\u89E3\u5BC6\u5931\u8D25
|
||||
10131=modelType\u548CprovideCode\u4E0D\u80FD\u4E3A\u7A7A
|
||||
10132=\u6CA1\u6709\u6743\u9650\u67E5\u770B\u8BE5\u667A\u80FD\u4F53\u7684\u804A\u5929\u8BB0\u5F55
|
||||
10133=\u4F1A\u8BDDID\u4E0D\u80FD\u4E3A\u7A7A
|
||||
10134=\u667A\u80FD\u4F53ID\u4E0D\u80FD\u4E3A\u7A7A
|
||||
@@ -154,4 +156,14 @@
|
||||
10147=\u97F3\u8272ID\u4E0D\u80FD\u4E3A\u7A7A
|
||||
10148=\u5F52\u5C5E\u8D26\u53F7\u4E0D\u80FD\u4E3A\u7A7A
|
||||
10149=\u5220\u9664\u7684\u97F3\u8272\u8D44\u6E90ID\u4E0D\u80FD\u4E3A\u7A7A
|
||||
10150=\u60A8\u6CA1\u6709\u6743\u9650\u64CD\u4F5C\u8BE5\u8BB0\u5F55
|
||||
10150=\u60A8\u6CA1\u6709\u6743\u9650\u64CD\u4F5C\u8BE5\u8BB0\u5F55
|
||||
10151=\u8BF7\u5148\u4E0A\u4F20\u97F3\u9891\u6587\u4EF6
|
||||
10152=\u6A21\u578B\u914D\u7F6E\u672A\u627E\u5230
|
||||
10153=\u6A21\u578B\u7C7B\u578B\u672A\u627E\u5230
|
||||
10154=\u8BAD\u7EC3\u5931\u8D25
|
||||
10155=\u706B\u5C71\u5F15\u64CE\u7F3A\u5C11\u914D\u7F6E
|
||||
10156=\u54CD\u5E94\u683C\u5F0F\u9519\u8BEF\uFF0C\u7F3A\u5C11BaseResp\u5B57\u6BB5
|
||||
10157=\u8BF7\u6C42\u5931\u8D25
|
||||
10158=\u8BF7\u6C42\u5931\u8D25\uFF0C\u72B6\u6001\u7801{0}
|
||||
10159=\u97F3\u8272ID\u5DF2\u5B58\u5728
|
||||
10160=\u706B\u5C71\u5F15\u64CE\u97F3\u8272ID\u683C\u5F0F\u9519\u8BEF\uFF0C\u5FC5\u987B\u4EE5S_\u5F00\u5934
|
||||
@@ -156,4 +156,14 @@
|
||||
10147=Voice ID cannot be empty
|
||||
10148=Owner account cannot be empty
|
||||
10149=Voice resource ID to delete cannot be empty
|
||||
10150=You do not have permission to operate this record
|
||||
10150=You do not have permission to operate this record
|
||||
10151=Please upload audio file first
|
||||
10152=Model configuration not found
|
||||
10153=Model type not found
|
||||
10154=Training failed: {0}
|
||||
10155=Huoshan Engine configuration missing
|
||||
10156=Response format error, missing BaseResp field
|
||||
10157=Request failed
|
||||
10158=Clone Voice:
|
||||
10159=Voice ID already exists
|
||||
10160=Huoshan Engine voice ID format error, must start with S_
|
||||
@@ -156,4 +156,14 @@
|
||||
10147=\u97F3\u8272ID\u4E0D\u80FD\u4E3A\u7A7A
|
||||
10148=\u5F52\u5C5E\u8D26\u53F7\u4E0D\u80FD\u4E3A\u7A7A
|
||||
10149=\u5220\u9664\u7684\u97F3\u8272\u8D44\u6E90ID\u4E0D\u80FD\u4E3A\u7A7A
|
||||
10150=\u60A8\u6CA1\u6709\u6743\u9650\u64CD\u4F5C\u8BE5\u8BB0\u5F55
|
||||
10150=\u60A8\u6CA1\u6709\u6743\u9650\u64CD\u4F5C\u8BE5\u8BB0\u5F55
|
||||
10151=\u8BF7\u5148\u4E0A\u4F20\u97F3\u9891\u6587\u4EF6
|
||||
10152=\u6A21\u578B\u914D\u7F6E\u672A\u627E\u5230
|
||||
10153=\u6A21\u578B\u7C7B\u578B\u672A\u627E\u5230
|
||||
10154=\u8BAD\u7EC3\u5931\u8D25
|
||||
10155=\u706B\u5C71\u5F15\u64CE\u7F3A\u5C11\u914D\u7F6E
|
||||
10156=\u54CD\u5E94\u683C\u5F0F\u9519\u8BEF\uFF0C\u7F3A\u5C11BaseResp\u5B57\u6BB5
|
||||
10157=\u8BF7\u6C42\u5931\u8D25
|
||||
10158=\u514B\u9686\u8272\u97F3:
|
||||
10159=\u97F3\u8272ID\u5DF2\u5B58\u5728
|
||||
10160=\u706B\u5C71\u5F15\u64CE\u97F3\u8272ID\u683C\u5F0F\u9519\u8BEF\uFF0C\u5FC5\u987B\u4EE5S_\u5F00\u5934
|
||||
@@ -156,4 +156,14 @@
|
||||
10147=\u97F3\u8272ID\u4E0D\u80FD\u70BA\u7A7A
|
||||
10148=\u6B78\u5C6C\u5E33\u865F\u4E0D\u80FD\u70BA\u7A7A
|
||||
10149=\u522A\u9664\u7684\u97F3\u8272\u8CC7\u6E90ID\u4E0D\u80FD\u70BA\u7A7A
|
||||
10150=\u60A8\u6C92\u6709\u6B0A\u9650\u64CD\u4F5C\u8A72\u8A18\u9304
|
||||
10150=\u60A8\u6C92\u6709\u6B0A\u9650\u64CD\u4F5C\u8A72\u8A18\u9304
|
||||
10151=\u8ACB\u5148\u4E0A\u50B3\u97F3\u983B\u6587\u4EF6
|
||||
10152=\u6A21\u578B\u914D\u7F6E\u672A\u627E\u5230
|
||||
10153=\u6A21\u578B\u985E\u578B\u672A\u627E\u5230
|
||||
10154=\u6A21\u578B\u8AB2\u7DF4\u5931\u6557: {0}
|
||||
10155=\u706B\u5C71\u5F15\u64CE\u7F3A\u5C11appid\u6216access_token
|
||||
10156=\u97FF\u61C9\u683C\u5F0F\u932F\u8AA4\uFF0C\u7F3A\u5C11BaseResp\u5B57\u6BB5
|
||||
10157=\u8ACB\u6C42\u5931\u6557
|
||||
10158=\u514B\u9686\u8A9E\u97F3:
|
||||
10159=\u97F3\u8272ID\u5DF2\u5B58\u5728
|
||||
10160=\u706B\u5C71\u5F15\u64CE\u97F3\u8272ID\u683C\u5F0F\u932F\u8AA4\uFF0C\u5FC5\u9808\u4EE5S_\u958B\u982D
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
|
||||
<mapper namespace="xiaozhi.modules.voiceclone.dao.VoiceCloneDao">
|
||||
<select id="getTrainSuccess" resultType="xiaozhi.modules.model.dto.VoiceDTO">
|
||||
select id, name
|
||||
from ai_voice_clone
|
||||
where model_id = #{modelId} and user_id = #{userId} and train_status = 2
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
},
|
||||
|
||||
// 复刻音频
|
||||
cloneAudio(params, callback) {
|
||||
cloneAudio(params, callback, errorCallback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceClone/cloneAudio`)
|
||||
.method('POST')
|
||||
@@ -88,10 +88,19 @@ export default {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.fail((res) => {
|
||||
// 业务失败回调
|
||||
RequestService.clearRequestTime();
|
||||
if (errorCallback) {
|
||||
errorCallback(res);
|
||||
} else {
|
||||
callback(res);
|
||||
}
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('上传失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.cloneAudio(params, callback);
|
||||
this.cloneAudio(params, callback, errorCallback);
|
||||
});
|
||||
}).send();
|
||||
}
|
||||
|
||||
@@ -876,6 +876,8 @@ export default {
|
||||
'ttsModel.operationClosed': 'Operation closed',
|
||||
'ttsModel.edit': 'Edit',
|
||||
'ttsModel.delete': 'Delete',
|
||||
'ttsModel.deleteVoiceSuccess': 'Successfully deleted {count} voices',
|
||||
'ttsModel.deleteFailed': 'Failed to delete voices',
|
||||
'ttsModel.save': 'Save',
|
||||
'ttsModel.deselectAll': 'Deselect All',
|
||||
'ttsModel.selectAll': 'Select All',
|
||||
@@ -885,6 +887,8 @@ export default {
|
||||
'ttsModel.unnamedVoice': 'Unnamed Voice',
|
||||
'ttsModel.finishEditingFirst': 'Please finish current editing first',
|
||||
'ttsModel.selectVoiceToDelete': 'Please select voices to delete',
|
||||
'ttsModel.warning': 'Warning',
|
||||
'ttsModel.confirmDeleteVoice': 'Are you sure to delete {count} voices?',
|
||||
|
||||
// OTA Management Page Text
|
||||
'otaManagement.firmwareManagement': 'Firmware Management',
|
||||
@@ -1019,7 +1023,7 @@ export default {
|
||||
'warning': 'Warning',
|
||||
'info': 'Info',
|
||||
'common.networkError': 'Network request failed',
|
||||
|
||||
|
||||
// SM2 Encryption
|
||||
'sm2.publicKeyNotConfigured': 'SM2 public key not configured, please contact administrator',
|
||||
'sm2.encryptionFailed': 'Password encryption failed',
|
||||
@@ -1095,6 +1099,8 @@ export default {
|
||||
'voiceClone.play': 'Play',
|
||||
'voiceClone.pause': 'Pause',
|
||||
'voiceClone.cancel': 'Cancel',
|
||||
'voiceClone.warning': 'Warning',
|
||||
'voiceClone.ok': 'OK',
|
||||
'voiceClone.nextStep': 'Next',
|
||||
'voiceClone.prevStep': 'Previous',
|
||||
'voiceClone.upload': 'Upload Audio',
|
||||
@@ -1108,4 +1114,5 @@ export default {
|
||||
'voiceClone.updateNameSuccess': 'Name updated successfully',
|
||||
'voiceClone.updateNameFailed': 'Failed to update name',
|
||||
'voiceClone.playFailed': 'Play failed',
|
||||
'voiceClone.Details': 'Error Details',
|
||||
}
|
||||
@@ -466,7 +466,7 @@ export default {
|
||||
'chatHistory.downloadCurrentSession': '下载本会话聊天记录',
|
||||
'chatHistory.downloadCurrentWithPreviousSessions': '下载本会话及前20条会话聊天记录',
|
||||
'chatHistory.downloadLinkFailed': '获取下载链接失败',
|
||||
|
||||
|
||||
'cache.status': '缓存状态',
|
||||
'cache.cdnEnabled': 'CDN模式已启用',
|
||||
'cache.cdnDisabled': 'CDN模式已禁用',
|
||||
@@ -876,6 +876,8 @@ export default {
|
||||
'ttsModel.operationClosed': '操作已关闭',
|
||||
'ttsModel.edit': '编辑',
|
||||
'ttsModel.delete': '删除',
|
||||
'ttsModel.deleteVoiceSuccess': '成功删除{count}个音色',
|
||||
'ttsModel.deleteFailed': '删除音色失败',
|
||||
'ttsModel.save': '保存',
|
||||
'ttsModel.deselectAll': '取消全选',
|
||||
'ttsModel.selectAll': '全选',
|
||||
@@ -885,6 +887,8 @@ export default {
|
||||
'ttsModel.unnamedVoice': '未命名音色',
|
||||
'ttsModel.finishEditingFirst': '请先完成当前编辑',
|
||||
'ttsModel.selectVoiceToDelete': '请选择要删除的音色',
|
||||
'ttsModel.warning': '警告',
|
||||
'ttsModel.confirmDeleteVoice': '确定要删除{count}个音色吗?',
|
||||
|
||||
// OTA管理页面文本
|
||||
'otaManagement.firmwareManagement': '固件管理',
|
||||
@@ -1019,7 +1023,7 @@ export default {
|
||||
'warning': '警告',
|
||||
'info': '提示',
|
||||
'common.networkError': '网络请求失败',
|
||||
|
||||
|
||||
// SM2加密相关错误消息
|
||||
'sm2.publicKeyNotConfigured': 'SM2公钥未配置,请联系管理员',
|
||||
'sm2.encryptionFailed': '密码加密失败',
|
||||
@@ -1095,6 +1099,8 @@ export default {
|
||||
'voiceClone.play': '播放',
|
||||
'voiceClone.pause': '暂停',
|
||||
'voiceClone.cancel': '取消',
|
||||
'voiceClone.warning': '警告',
|
||||
'voiceClone.ok': '确定',
|
||||
'voiceClone.nextStep': '下一步',
|
||||
'voiceClone.prevStep': '上一步',
|
||||
'voiceClone.upload': '上传音频',
|
||||
@@ -1108,4 +1114,5 @@ export default {
|
||||
'voiceClone.updateNameSuccess': '名称更新成功',
|
||||
'voiceClone.updateNameFailed': '名称更新失败',
|
||||
'voiceClone.playFailed': '播放失败',
|
||||
'voiceClone.Details': '错误详情',
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
'header.paramDictionary': '參數字典',
|
||||
'header.paramManagement': '參數管理',
|
||||
'header.dictManagement': '字典管理',
|
||||
'header.agentTemplate': '預設角色模板',
|
||||
'header.agentTemplate': '預設角色模板',
|
||||
'header.searchHistory': '搜索歷史',
|
||||
'header.clearHistory': '清空歷史',
|
||||
'header.providerManagement': '字段管理',
|
||||
@@ -874,6 +874,8 @@ export default {
|
||||
'ttsModel.operation': '操作',
|
||||
'ttsModel.edit': '編輯',
|
||||
'ttsModel.delete': '刪除',
|
||||
'ttsModel.deleteVoiceSuccess': '成功刪除{count}個音色',
|
||||
'ttsModel.deleteFailed': '刪除音色失敗',
|
||||
'ttsModel.save': '保存',
|
||||
'ttsModel.deselectAll': '取消全選',
|
||||
'ttsModel.selectAll': '全選',
|
||||
@@ -883,6 +885,8 @@ export default {
|
||||
'ttsModel.unnamedVoice': '未命名音色',
|
||||
'ttsModel.finishEditingFirst': '請先完成目前編輯',
|
||||
'ttsModel.selectVoiceToDelete': '請選擇要刪除的音色',
|
||||
'ttsModel.warning': '警告',
|
||||
'ttsModel.confirmDeleteVoice': '確定要刪除{count}個音色嗎?',
|
||||
'ttsModel.operationFailed': '操作失敗',
|
||||
'ttsModel.operationClosed': '操作已關閉',
|
||||
|
||||
@@ -1095,6 +1099,8 @@ export default {
|
||||
'voiceClone.play': '播放',
|
||||
'voiceClone.pause': '暫停',
|
||||
'voiceClone.cancel': '取消',
|
||||
'voiceClone.warning': '警告',
|
||||
'voiceClone.ok': '確定',
|
||||
'voiceClone.nextStep': '下一步',
|
||||
'voiceClone.prevStep': '上一步',
|
||||
'voiceClone.upload': '上傳音頻',
|
||||
@@ -1108,4 +1114,5 @@ export default {
|
||||
'voiceClone.updateNameSuccess': '名稱更新成功',
|
||||
'voiceClone.updateNameFailed': '名稱更新失敗',
|
||||
'voiceClone.playFailed': '播放失敗',
|
||||
'voiceClone.Details': '錯誤詳情',
|
||||
}
|
||||
@@ -36,9 +36,22 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.trainStatus')" prop="trainStatus" align="center">
|
||||
<template slot-scope="scope">
|
||||
{{ getTrainStatusText(scope.row) }}
|
||||
<div class="status-button" :class="getStatusButtonClass(scope.row)">
|
||||
<span>{{ getTrainStatusText(scope.row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="$t('voiceClone.Details')" align="center" width="80">
|
||||
<template slot-scope="scope">
|
||||
<el-tooltip :content="getTooltipContent(scope.row)" placement="top">
|
||||
<el-button size="mini" type="text" icon="el-icon-info"
|
||||
@click="handleViewDetails(scope.row)">
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="$t('voiceClone.action')" align="center" width="180">
|
||||
<template slot-scope="scope">
|
||||
<el-button v-if="scope.row.hasVoice" size="mini" type="text"
|
||||
@@ -164,6 +177,29 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getTooltipContent(row) {
|
||||
if (!row.hasVoice) {
|
||||
return '待上传';
|
||||
}
|
||||
switch (row.trainStatus) {
|
||||
case 0:
|
||||
return '待复刻';
|
||||
case 2:
|
||||
return '训练成功';
|
||||
case 3:
|
||||
// 训练失败时,根据错误信息智能展示
|
||||
if (row.trainError) {
|
||||
return `训练失败:${row.trainError}`;
|
||||
}
|
||||
return '训练失败';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
handleViewDetails(row) {
|
||||
console.log('查看详情:', row);
|
||||
// 可以在这里添加查看详情的逻辑
|
||||
},
|
||||
handlePageSizeChange(val) {
|
||||
this.pageSize = val;
|
||||
this.currentPage = 1;
|
||||
@@ -226,8 +262,6 @@ export default {
|
||||
switch (row.trainStatus) {
|
||||
case 0:
|
||||
return this.$t('voiceClone.waitingTraining');
|
||||
case 1:
|
||||
return this.$t('voiceClone.training');
|
||||
case 2:
|
||||
return this.$t('voiceClone.trainSuccess');
|
||||
case 3:
|
||||
@@ -236,19 +270,100 @@ export default {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
// 获取状态按钮样式
|
||||
getStatusButtonClass(row) {
|
||||
if (!row.hasVoice || row.trainStatus === 0) {
|
||||
return 'status-waiting';
|
||||
} else if (row.trainStatus === 2) {
|
||||
return 'status-success';
|
||||
} else if (row.trainStatus === 3) {
|
||||
return 'status-failed';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
// 处理复刻操作
|
||||
handleClone(row) {
|
||||
// 防止重复提交
|
||||
if (row._submitting) {
|
||||
return;
|
||||
}
|
||||
row._submitting = true;
|
||||
|
||||
const params = {
|
||||
cloneId: row.id
|
||||
};
|
||||
Api.voiceClone.cloneAudio(params, (res) => {
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
this.$message.success(this.$t('message.success'));
|
||||
} else {
|
||||
this.$message.error(res.msg || this.$t('message.error'));
|
||||
try {
|
||||
Api.voiceClone.cloneAudio(params, (res) => {
|
||||
try {
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
this.$message.success(this.$t('message.success'));
|
||||
// 复刻成功后刷新列表
|
||||
this.fetchVoiceCloneList();
|
||||
} else {
|
||||
// 复刻失败时刷新列表以获取完整的错误信息
|
||||
console.log('API返回错误,刷新列表获取详细错误信息');
|
||||
this.$message.error(res.msg || this.$t('message.error'));
|
||||
// 刷新列表以获取后端保存的完整错误详情
|
||||
this.fetchVoiceCloneList();
|
||||
}
|
||||
} catch (error) {
|
||||
// 处理响应时出错,刷新列表
|
||||
console.error('处理响应时出错:', error);
|
||||
this.$message.error('处理响应时出错');
|
||||
this.fetchVoiceCloneList();
|
||||
} finally {
|
||||
row._submitting = false;
|
||||
}
|
||||
}, (error) => {
|
||||
// API调用失败,刷新列表以获取最新状态
|
||||
console.error('API调用失败:', error);
|
||||
this.$message.error('克隆失败,请将鼠标悬停在错误提示上,查看错误详情');
|
||||
this.fetchVoiceCloneList();
|
||||
row._submitting = false;
|
||||
});
|
||||
} catch (error) {
|
||||
// 调用API时出错,刷新列表
|
||||
console.error('调用API时出错:', error);
|
||||
this.$message.error('调用API时出错');
|
||||
this.fetchVoiceCloneList();
|
||||
row._submitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新行状态并触发视图更新
|
||||
updateRowStatus(row, status, statusCode = null) {
|
||||
// 在Vue中直接修改数组中的对象属性可能不会触发视图更新
|
||||
const index = this.voiceCloneList.findIndex(item => item.id === row.id);
|
||||
const updateData = {
|
||||
trainStatus: status
|
||||
};
|
||||
|
||||
// 如果提供了状态码,也更新状态码信息
|
||||
if (statusCode !== null) {
|
||||
updateData.statusCode = statusCode;
|
||||
}
|
||||
|
||||
if (index !== -1) {
|
||||
// 使用Vue.set来确保响应式更新
|
||||
this.$set(this.voiceCloneList, index, {
|
||||
...this.voiceCloneList[index],
|
||||
...updateData
|
||||
});
|
||||
// 强制表格重新渲染
|
||||
if (this.$refs.paramsTable) {
|
||||
this.$refs.paramsTable.doLayout();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果找不到索引,直接更新row对象
|
||||
row.trainStatus = status;
|
||||
if (statusCode !== null) {
|
||||
row.statusCode = statusCode;
|
||||
}
|
||||
// 强制整个表格重新渲染
|
||||
this.$forceUpdate();
|
||||
}
|
||||
console.log('更新行状态:', row.id, '状态:', status, '状态码:', statusCode);
|
||||
},
|
||||
// 复刻成功后的回调
|
||||
handleCloneSuccess() {
|
||||
@@ -619,6 +734,35 @@ export default {
|
||||
color: #5a64b5 !important;
|
||||
}
|
||||
|
||||
/* 状态按钮样式 */
|
||||
.status-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-waiting {
|
||||
background-color: #f5f7fa;
|
||||
color: #909399;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
background-color: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ffccc7;
|
||||
}
|
||||
|
||||
.name-view {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -249,9 +249,9 @@ export default {
|
||||
}
|
||||
|
||||
const itemCount = items.length;
|
||||
this.$confirm(this.$t('voiceClone.confirmDelete', { count: itemCount }), 'Warning', {
|
||||
confirmButtonText: 'OK',
|
||||
cancelButtonText: 'Cancel',
|
||||
this.$confirm(this.$t('voiceClone.confirmDelete', { count: itemCount }), this.$t('voiceClone.warning'), {
|
||||
confirmButtonText: this.$t('voiceClone.ok'),
|
||||
cancelButtonText: this.$t('voiceClone.cancel'),
|
||||
type: 'warning',
|
||||
distinguishCancelAndClose: true
|
||||
}).then(() => {
|
||||
|
||||
Reference in New Issue
Block a user