VtVoiceCloneController.java 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. package com.ruoyi.aicall.controller;
  2. import com.alibaba.fastjson.JSONObject;
  3. import com.ruoyi.aicall.domain.CcTtsAliyun;
  4. import com.ruoyi.aicall.service.ICcTtsAliyunService;
  5. import com.ruoyi.cc.service.IFsConfService;
  6. import com.ruoyi.common.annotation.Log;
  7. import com.ruoyi.common.core.controller.BaseController;
  8. import com.ruoyi.common.core.domain.AjaxResult;
  9. import com.ruoyi.common.enums.BusinessType;
  10. import com.ruoyi.common.utils.StringUtils;
  11. import lombok.extern.slf4j.Slf4j;
  12. import okhttp3.MediaType;
  13. import okhttp3.OkHttpClient;
  14. import okhttp3.Request;
  15. import okhttp3.RequestBody;
  16. import okhttp3.Response;
  17. import org.apache.shiro.authz.annotation.RequiresPermissions;
  18. import org.springframework.beans.factory.annotation.Autowired;
  19. import org.springframework.stereotype.Controller;
  20. import org.springframework.web.bind.annotation.GetMapping;
  21. import org.springframework.web.bind.annotation.PostMapping;
  22. import org.springframework.web.bind.annotation.RequestMapping;
  23. import org.springframework.web.bind.annotation.RequestParam;
  24. import org.springframework.web.bind.annotation.ResponseBody;
  25. import javax.crypto.Mac;
  26. import javax.crypto.spec.SecretKeySpec;
  27. import java.io.IOException;
  28. import java.nio.charset.StandardCharsets;
  29. import java.security.MessageDigest;
  30. import java.time.Instant;
  31. import java.time.ZoneOffset;
  32. import java.time.format.DateTimeFormatter;
  33. import java.util.Base64;
  34. import java.util.concurrent.TimeUnit;
  35. @Controller
  36. @RequestMapping("/aicall/vtvoiceclone")
  37. @Slf4j
  38. public class VtVoiceCloneController extends BaseController {
  39. private static final MediaType JSON_TYPE = MediaType.parse("application/json; charset=utf-8");
  40. private static final DateTimeFormatter UTC_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
  41. .withZone(ZoneOffset.UTC);
  42. private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder()
  43. .connectTimeout(30, TimeUnit.SECONDS)
  44. .readTimeout(60, TimeUnit.SECONDS)
  45. .writeTimeout(60, TimeUnit.SECONDS)
  46. .build();
  47. private static final String DEFAULT_REGISTER_URL = "https://tts.ilivedata.com/api/v1/speech/synthesis/voice/register";
  48. private static final String DEFAULT_REGISTER_PATH = "/api/v1/speech/synthesis/voice/register";
  49. private static final String DEFAULT_HOST = "tts.ilivedata.com";
  50. private final String prefix = "aicall/vtvoiceclone";
  51. @Autowired
  52. private IFsConfService fsConfService;
  53. @Autowired
  54. private ICcTtsAliyunService ttsAliyunService;
  55. @RequiresPermissions("aicall:vtvoiceclone:view")
  56. @GetMapping("voiceclone")
  57. public String voiceClone() {
  58. return prefix + "/voiceclone";
  59. }
  60. @RequiresPermissions("aicall:vtvoiceclone:registerVoice")
  61. @Log(title = "ViiTorTTS音色克隆", businessType = BusinessType.INSERT)
  62. @PostMapping("/registerVoice")
  63. @ResponseBody
  64. public AjaxResult registerVoice(@RequestParam("audio_url") String audioUrl,
  65. @RequestParam(value = "voice_name", required = false) String voiceName,
  66. @RequestParam(value = "language", required = false) String language,
  67. @RequestParam(value = "text", required = false) String text,
  68. @RequestParam(value = "gender", required = false) Integer gender) {
  69. try {
  70. String audio = trimToEmpty(audioUrl);
  71. if (StringUtils.isBlank(audio)) {
  72. return AjaxResult.error("请填写可公网访问的音频 URL");
  73. }
  74. if (!audio.startsWith("http://") && !audio.startsWith("https://")) {
  75. return AjaxResult.error("音频 URL 必须以 http:// 或 https:// 开头");
  76. }
  77. JSONObject account = loadViiTorAccount();
  78. JSONObject body = new JSONObject(true);
  79. body.put("audio", audio);
  80. if (StringUtils.isNotBlank(voiceName)) {
  81. body.put("voiceName", trimToEmpty(voiceName));
  82. }
  83. if (StringUtils.isNotBlank(language)) {
  84. body.put("language", normalizeLanguage(language));
  85. }
  86. if (StringUtils.isNotBlank(text)) {
  87. body.put("text", trimToEmpty(text));
  88. }
  89. if (gender != null && (gender == 0 || gender == 1)) {
  90. body.put("gender", gender);
  91. }
  92. JSONObject response = doRegisterRequest(account, body);
  93. Integer errorCode = response.getInteger("errorCode");
  94. if (errorCode != null && errorCode != 0) {
  95. return AjaxResult.error("ViiTorTTS 返回错误: " + response.toJSONString());
  96. }
  97. JSONObject data = response.getJSONObject("data");
  98. if (data == null) {
  99. return AjaxResult.error("ViiTorTTS 返回结果为空");
  100. }
  101. String registeredVoiceName = trimToEmpty(data.getString("voiceName"));
  102. if (StringUtils.isBlank(registeredVoiceName)) {
  103. return AjaxResult.error("ViiTorTTS 未返回音色名称:\n" + response.toJSONString());
  104. }
  105. CcTtsAliyun speaker = saveCloneVoice(
  106. registeredVoiceName,
  107. data.getString("language"),
  108. data.getInteger("gender")
  109. );
  110. JSONObject result = new JSONObject(true);
  111. result.put("voiceName", speaker.getVoiceName());
  112. result.put("voiceCode", speaker.getVoiceCode());
  113. result.put("languageCode", speaker.getLanguageCode());
  114. result.put("languageName", speaker.getLanguageName());
  115. result.put("gender", data.getInteger("gender"));
  116. result.put("audioToTrain", trimToEmpty(data.getString("audioToTrain")));
  117. result.put("textToTrain", trimToEmpty(data.getString("textToTrain")));
  118. return AjaxResult.success("ViiTorTTS 音色注册成功,已写入音色表。", result);
  119. } catch (Exception e) {
  120. log.error("registerVoice error", e);
  121. return AjaxResult.error("ViiTorTTS 音色注册失败:\n" + e.getMessage());
  122. }
  123. }
  124. private JSONObject loadViiTorAccount() {
  125. JSONObject conf = fsConfService.getAsrConf("/autoload_configs/vt_tts.conf.xml");
  126. String appId = trimToEmpty(conf.getString("appid"));
  127. String secretKey = trimToEmpty(conf.getString("secret-key"));
  128. if (StringUtils.isBlank(appId) || StringUtils.isBlank(secretKey)) {
  129. throw new IllegalStateException("请先在“ViiTorTTS配置”中填写 AppId 和 SecretKey。");
  130. }
  131. JSONObject account = new JSONObject(true);
  132. account.put("appid", appId);
  133. account.put("secret-key", secretKey);
  134. account.put("verify-peer", config(conf, "verify-peer", "false"));
  135. return account;
  136. }
  137. private JSONObject doRegisterRequest(JSONObject account, JSONObject bodyJson) throws Exception {
  138. String body = bodyJson.toJSONString();
  139. String timestamp = UTC_FORMATTER.format(Instant.now());
  140. String authorization = buildAuthorization(account, body, timestamp, DEFAULT_HOST, DEFAULT_REGISTER_PATH);
  141. boolean verifyPeer = configBool(account, "verify-peer", false);
  142. OkHttpClient client = verifyPeer ? HTTP_CLIENT : HTTP_CLIENT.newBuilder()
  143. .hostnameVerifier((hostname, session) -> true)
  144. .build();
  145. Request request = new Request.Builder()
  146. .url(DEFAULT_REGISTER_URL)
  147. .post(RequestBody.create(JSON_TYPE, body))
  148. .addHeader("Content-Type", "application/json;charset=UTF-8")
  149. .addHeader("Accept", "application/json;charset=UTF-8")
  150. .addHeader("X-AppId", config(account, "appid", ""))
  151. .addHeader("X-TimeStamp", timestamp)
  152. .addHeader("Authorization", authorization)
  153. .build();
  154. try (Response response = client.newCall(request).execute()) {
  155. String responseBody = response.body() == null ? "" : response.body().string();
  156. log.info("vtvoiceclone request={}, status={}, body={}", body, response.code(), responseBody);
  157. if (!response.isSuccessful()) {
  158. throw new IOException("HTTP " + response.code() + ": " + responseBody);
  159. }
  160. return JSONObject.parseObject(responseBody);
  161. }
  162. }
  163. private CcTtsAliyun saveCloneVoice(String voiceName, String language, Integer gender) {
  164. String voiceCode = trimToEmpty(voiceName);
  165. String languageCode = normalizeLanguageCode(language);
  166. String languageName = toLanguageName(languageCode);
  167. String storedVoiceName = buildStoredVoiceName(voiceName, gender, languageName);
  168. CcTtsAliyun speaker = ttsAliyunService.selectCcTtsAliyunByVoiceCode(voiceCode);
  169. boolean update = speaker != null;
  170. if (!update) {
  171. speaker = new CcTtsAliyun();
  172. }
  173. speaker.setVoiceName(storedVoiceName);
  174. speaker.setVoiceCode(voiceCode);
  175. speaker.setVoiceEnabled(1);
  176. speaker.setVoiceSource("vt_tts");
  177. speaker.setPriority(0);
  178. speaker.setProvider("vt_tts");
  179. speaker.setLanguageCode(languageCode);
  180. speaker.setLanguageName(languageName);
  181. speaker.setTtsModels("clone");
  182. if (update) {
  183. ttsAliyunService.updateCcTtsAliyun(speaker);
  184. } else {
  185. ttsAliyunService.insertCcTtsAliyun(speaker);
  186. }
  187. return speaker;
  188. }
  189. private String buildStoredVoiceName(String voiceName, Integer gender, String languageName) {
  190. String cleanName = trimToEmpty(voiceName).replace("'", "").replace("\"", "");
  191. if (cleanName.length() > 30) {
  192. cleanName = cleanName.substring(0, 30);
  193. }
  194. String genderText = gender != null && gender == 1 ? "男声" : "女声";
  195. return "ViiTor克隆-" + cleanName + "-" + languageName + "-" + genderText;
  196. }
  197. private String buildAuthorization(JSONObject account,
  198. String body,
  199. String timestamp,
  200. String host,
  201. String requestPath) throws Exception {
  202. String appId = config(account, "appid", "");
  203. String secretKey = config(account, "secret-key", "");
  204. String bodyHash = sha256Hex(body);
  205. String stringToSign = "POST\n" +
  206. host + "\n" +
  207. requestPath + "\n" +
  208. bodyHash + "\n" +
  209. "X-AppId:" + appId + "\n" +
  210. "X-TimeStamp:" + timestamp;
  211. return Base64.getEncoder().encodeToString(hmacSha256(secretKey, stringToSign));
  212. }
  213. private byte[] hmacSha256(String key, String message) throws Exception {
  214. Mac mac = Mac.getInstance("HmacSHA256");
  215. mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
  216. return mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
  217. }
  218. private String sha256Hex(String value) throws Exception {
  219. MessageDigest digest = MessageDigest.getInstance("SHA-256");
  220. byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
  221. StringBuilder sb = new StringBuilder(hash.length * 2);
  222. for (byte b : hash) {
  223. sb.append(String.format("%02x", b));
  224. }
  225. return sb.toString();
  226. }
  227. private String config(JSONObject obj, String key, String defaultValue) {
  228. if (obj == null) {
  229. return defaultValue;
  230. }
  231. String value = trimToEmpty(obj.getString(key));
  232. return StringUtils.isBlank(value) ? defaultValue : value;
  233. }
  234. private boolean configBool(JSONObject obj, String key, boolean defaultValue) {
  235. String value = config(obj, key, String.valueOf(defaultValue));
  236. if (StringUtils.isBlank(value)) {
  237. return defaultValue;
  238. }
  239. return "1".equals(value) || "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value);
  240. }
  241. private String trimToEmpty(String value) {
  242. return value == null ? "" : value.trim();
  243. }
  244. private String normalizeLanguage(String value) {
  245. String input = trimToEmpty(value).replace('_', '-');
  246. if (StringUtils.isBlank(input)) {
  247. return "zh-CN";
  248. }
  249. if ("zh".equalsIgnoreCase(input)) return "zh-CN";
  250. if ("en".equalsIgnoreCase(input)) return "en-US";
  251. if ("ja".equalsIgnoreCase(input)) return "ja-JP";
  252. if ("ko".equalsIgnoreCase(input)) return "ko-KR";
  253. if ("yue".equalsIgnoreCase(input)) return "yue-HK";
  254. return input;
  255. }
  256. private String normalizeLanguageCode(String value) {
  257. return normalizeLanguage(value);
  258. }
  259. private String toLanguageName(String languageCode) {
  260. if ("en-US".equalsIgnoreCase(languageCode)) return "英文";
  261. if ("ja-JP".equalsIgnoreCase(languageCode)) return "日语";
  262. if ("ko-KR".equalsIgnoreCase(languageCode)) return "韩语";
  263. if ("yue-HK".equalsIgnoreCase(languageCode)) return "粤语";
  264. return "中文";
  265. }
  266. }