| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- 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 "中文";
- }
- }
|