|
@@ -0,0 +1,295 @@
|
|
|
|
|
+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 "中文";
|
|
|
|
|
+ }
|
|
|
|
|
+}
|