package com.ruoyi.aicall.controller; import com.alibaba.fastjson.JSONObject; import com.ruoyi.aicall.domain.CcTtsAliyun; import com.ruoyi.aicall.service.ICcTtsAliyunService; import com.ruoyi.cc.service.IFsConfService; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.utils.StringUtils; import lombok.extern.slf4j.Slf4j; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import org.apache.shiro.authz.annotation.RequiresPermissions; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Base64; import java.util.concurrent.TimeUnit; @Controller @RequestMapping("/aicall/vtvoiceclone") @Slf4j public class VtVoiceCloneController extends BaseController { private static final MediaType JSON_TYPE = MediaType.parse("application/json; charset=utf-8"); private static final DateTimeFormatter UTC_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") .withZone(ZoneOffset.UTC); private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) .build(); private static final String DEFAULT_REGISTER_URL = "https://tts.ilivedata.com/api/v1/speech/synthesis/voice/register"; private static final String DEFAULT_REGISTER_PATH = "/api/v1/speech/synthesis/voice/register"; private static final String DEFAULT_HOST = "tts.ilivedata.com"; private final String prefix = "aicall/vtvoiceclone"; @Autowired private IFsConfService fsConfService; @Autowired private ICcTtsAliyunService ttsAliyunService; @RequiresPermissions("aicall:vtvoiceclone:view") @GetMapping("voiceclone") public String voiceClone() { return prefix + "/voiceclone"; } @RequiresPermissions("aicall:vtvoiceclone:registerVoice") @Log(title = "ViiTorTTS音色克隆", businessType = BusinessType.INSERT) @PostMapping("/registerVoice") @ResponseBody public AjaxResult registerVoice(@RequestParam("audio_url") String audioUrl, @RequestParam(value = "voice_name", required = false) String voiceName, @RequestParam(value = "language", required = false) String language, @RequestParam(value = "text", required = false) String text, @RequestParam(value = "gender", required = false) Integer gender) { try { String audio = trimToEmpty(audioUrl); if (StringUtils.isBlank(audio)) { return AjaxResult.error("请填写可公网访问的音频 URL"); } if (!audio.startsWith("http://") && !audio.startsWith("https://")) { return AjaxResult.error("音频 URL 必须以 http:// 或 https:// 开头"); } JSONObject account = loadViiTorAccount(); JSONObject body = new JSONObject(true); body.put("audio", audio); if (StringUtils.isNotBlank(voiceName)) { body.put("voiceName", trimToEmpty(voiceName)); } if (StringUtils.isNotBlank(language)) { body.put("language", normalizeLanguage(language)); } if (StringUtils.isNotBlank(text)) { body.put("text", trimToEmpty(text)); } if (gender != null && (gender == 0 || gender == 1)) { body.put("gender", gender); } JSONObject response = doRegisterRequest(account, body); Integer errorCode = response.getInteger("errorCode"); if (errorCode != null && errorCode != 0) { return AjaxResult.error("ViiTorTTS 返回错误: " + response.toJSONString()); } JSONObject data = response.getJSONObject("data"); if (data == null) { return AjaxResult.error("ViiTorTTS 返回结果为空"); } String registeredVoiceName = trimToEmpty(data.getString("voiceName")); if (StringUtils.isBlank(registeredVoiceName)) { return AjaxResult.error("ViiTorTTS 未返回音色名称:\n" + response.toJSONString()); } CcTtsAliyun speaker = saveCloneVoice( registeredVoiceName, data.getString("language"), data.getInteger("gender") ); JSONObject result = new JSONObject(true); result.put("voiceName", speaker.getVoiceName()); result.put("voiceCode", speaker.getVoiceCode()); result.put("languageCode", speaker.getLanguageCode()); result.put("languageName", speaker.getLanguageName()); result.put("gender", data.getInteger("gender")); result.put("audioToTrain", trimToEmpty(data.getString("audioToTrain"))); result.put("textToTrain", trimToEmpty(data.getString("textToTrain"))); return AjaxResult.success("ViiTorTTS 音色注册成功,已写入音色表。", result); } catch (Exception e) { log.error("registerVoice error", e); return AjaxResult.error("ViiTorTTS 音色注册失败:\n" + e.getMessage()); } } private JSONObject loadViiTorAccount() { JSONObject conf = fsConfService.getAsrConf("/autoload_configs/vt_tts.conf.xml"); String appId = trimToEmpty(conf.getString("appid")); String secretKey = trimToEmpty(conf.getString("secret-key")); if (StringUtils.isBlank(appId) || StringUtils.isBlank(secretKey)) { throw new IllegalStateException("请先在“ViiTorTTS配置”中填写 AppId 和 SecretKey。"); } JSONObject account = new JSONObject(true); account.put("appid", appId); account.put("secret-key", secretKey); account.put("verify-peer", config(conf, "verify-peer", "false")); return account; } private JSONObject doRegisterRequest(JSONObject account, JSONObject bodyJson) throws Exception { String body = bodyJson.toJSONString(); String timestamp = UTC_FORMATTER.format(Instant.now()); String authorization = buildAuthorization(account, body, timestamp, DEFAULT_HOST, DEFAULT_REGISTER_PATH); boolean verifyPeer = configBool(account, "verify-peer", false); OkHttpClient client = verifyPeer ? HTTP_CLIENT : HTTP_CLIENT.newBuilder() .hostnameVerifier((hostname, session) -> true) .build(); Request request = new Request.Builder() .url(DEFAULT_REGISTER_URL) .post(RequestBody.create(JSON_TYPE, body)) .addHeader("Content-Type", "application/json;charset=UTF-8") .addHeader("Accept", "application/json;charset=UTF-8") .addHeader("X-AppId", config(account, "appid", "")) .addHeader("X-TimeStamp", timestamp) .addHeader("Authorization", authorization) .build(); try (Response response = client.newCall(request).execute()) { String responseBody = response.body() == null ? "" : response.body().string(); log.info("vtvoiceclone request={}, status={}, body={}", body, response.code(), responseBody); if (!response.isSuccessful()) { throw new IOException("HTTP " + response.code() + ": " + responseBody); } return JSONObject.parseObject(responseBody); } } private CcTtsAliyun saveCloneVoice(String voiceName, String language, Integer gender) { String voiceCode = trimToEmpty(voiceName); String languageCode = normalizeLanguageCode(language); String languageName = toLanguageName(languageCode); String storedVoiceName = buildStoredVoiceName(voiceName, gender, languageName); CcTtsAliyun speaker = ttsAliyunService.selectCcTtsAliyunByVoiceCode(voiceCode); boolean update = speaker != null; if (!update) { speaker = new CcTtsAliyun(); } speaker.setVoiceName(storedVoiceName); speaker.setVoiceCode(voiceCode); speaker.setVoiceEnabled(1); speaker.setVoiceSource("vt_tts"); speaker.setPriority(0); speaker.setProvider("vt_tts"); speaker.setLanguageCode(languageCode); speaker.setLanguageName(languageName); speaker.setTtsModels("clone"); if (update) { ttsAliyunService.updateCcTtsAliyun(speaker); } else { ttsAliyunService.insertCcTtsAliyun(speaker); } return speaker; } private String buildStoredVoiceName(String voiceName, Integer gender, String languageName) { String cleanName = trimToEmpty(voiceName).replace("'", "").replace("\"", ""); if (cleanName.length() > 30) { cleanName = cleanName.substring(0, 30); } String genderText = gender != null && gender == 1 ? "男声" : "女声"; return "ViiTor克隆-" + cleanName + "-" + languageName + "-" + genderText; } private String buildAuthorization(JSONObject account, String body, String timestamp, String host, String requestPath) throws Exception { String appId = config(account, "appid", ""); String secretKey = config(account, "secret-key", ""); String bodyHash = sha256Hex(body); String stringToSign = "POST\n" + host + "\n" + requestPath + "\n" + bodyHash + "\n" + "X-AppId:" + appId + "\n" + "X-TimeStamp:" + timestamp; return Base64.getEncoder().encodeToString(hmacSha256(secretKey, stringToSign)); } private byte[] hmacSha256(String key, String message) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); return mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); } private String sha256Hex(String value) throws Exception { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(hash.length * 2); for (byte b : hash) { sb.append(String.format("%02x", b)); } return sb.toString(); } private String config(JSONObject obj, String key, String defaultValue) { if (obj == null) { return defaultValue; } String value = trimToEmpty(obj.getString(key)); return StringUtils.isBlank(value) ? defaultValue : value; } private boolean configBool(JSONObject obj, String key, boolean defaultValue) { String value = config(obj, key, String.valueOf(defaultValue)); if (StringUtils.isBlank(value)) { return defaultValue; } return "1".equals(value) || "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value); } private String trimToEmpty(String value) { return value == null ? "" : value.trim(); } private String normalizeLanguage(String value) { String input = trimToEmpty(value).replace('_', '-'); if (StringUtils.isBlank(input)) { return "zh-CN"; } if ("zh".equalsIgnoreCase(input)) return "zh-CN"; if ("en".equalsIgnoreCase(input)) return "en-US"; if ("ja".equalsIgnoreCase(input)) return "ja-JP"; if ("ko".equalsIgnoreCase(input)) return "ko-KR"; if ("yue".equalsIgnoreCase(input)) return "yue-HK"; return input; } private String normalizeLanguageCode(String value) { return normalizeLanguage(value); } private String toLanguageName(String languageCode) { if ("en-US".equalsIgnoreCase(languageCode)) return "英文"; if ("ja-JP".equalsIgnoreCase(languageCode)) return "日语"; if ("ko-KR".equalsIgnoreCase(languageCode)) return "韩语"; if ("yue-HK".equalsIgnoreCase(languageCode)) return "粤语"; return "中文"; } }