yzx 3 днів тому
батько
коміт
ddfb746c37

+ 295 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/VtVoiceCloneController.java

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

+ 144 - 0
ruoyi-admin/src/main/resources/templates/aicall/vtvoiceclone/voiceclone.html

@@ -0,0 +1,144 @@
+<!DOCTYPE html>
+<html lang="zh" xmlns:th="http://www.thymeleaf.org">
+<head>
+    <th:block th:include="include :: header('ViiTorTTS音色注册')" />
+</head>
+<body class="gray-bg">
+<div class="container-div">
+    <div class="row">
+        <div class="col-sm-12 search-collapse">
+            <pre><strong>ViiTorTTS 音色注册注意事项:</strong>
+1. 本页面复用“ViiTorTTS配置”中的 AppId 和 SecretKey。
+2. 请输入可公网访问的音频 URL,ViiTorTTS 会直接下载该文件做音色注册。
+3. 建议音频为单人干净录音,语种、文本可不填;如已知内容,建议一并填写以提高注册准确性。
+4. 注册成功后会自动把返回的 `voiceName` 写入音色管理,`voice_source=vt_tts`、`tts_models=clone`,可直接用于外呼任务。
+</pre>
+        </div>
+
+        <div class="col-sm-12 search-collapse">
+            <form id="registerForm">
+                <div class="select-list">
+                    <ul>
+                        <li>
+                            <label>音色名称:</label>
+                            <input type="text" id="voice_name" name="voice_name" placeholder="可留空,留空则由平台生成" />
+                        </li>
+                        <li>
+                            <label>语种:</label>
+                            <select id="language" name="language">
+                                <option value="zh-CN">中文</option>
+                                <option value="yue-HK">粤语</option>
+                                <option value="en-US">英文</option>
+                                <option value="ja-JP">日语</option>
+                                <option value="ko-KR">韩语</option>
+                            </select>
+                        </li>
+                        <li>
+                            <label>性别:</label>
+                            <select id="gender" name="gender">
+                                <option value="">自动/默认</option>
+                                <option value="0">女声</option>
+                                <option value="1">男声</option>
+                            </select>
+                        </li>
+                        <li style="width: 100%">
+                            <label>音频 URL:</label>
+                            <input type="text" id="audio_url" name="audio_url" style="width: 720px" placeholder="例如:https://example.com/demo.wav" />
+                        </li>
+                        <li style="width: 100%">
+                            <label>音频文本:</label>
+                            <textarea id="text" name="text" cols="80" rows="4" placeholder="可留空;若已知录音准确文本,建议填写"></textarea>
+                        </li>
+                        <li>
+                            <a class="btn btn-info btn-xs" href="javascript:void(0)" onclick="$.operate.registerVoice()">
+                                <i class="fa fa-microphone"></i> &nbsp;提交音色注册
+                            </a>
+                        </li>
+                    </ul>
+                </div>
+            </form>
+        </div>
+
+        <div class="col-sm-12 search-collapse">
+            <form id="resultForm">
+                <div class="select-list">
+                    <ul>
+                        <li>
+                            <label>返回音色名:</label>
+                            <input type="text" id="savedVoiceCode" readonly="readonly" />
+                        </li>
+                        <li>
+                            <label>展示名称:</label>
+                            <input type="text" id="savedVoiceName" readonly="readonly" />
+                        </li>
+                        <li>
+                            <label>语种:</label>
+                            <input type="text" id="savedLanguageName" readonly="readonly" />
+                        </li>
+                        <li>
+                            <label>实际训练音频:</label>
+                            <input type="text" id="audioToTrain" readonly="readonly" style="width: 520px" />
+                        </li>
+                        <li style="width: 100%">
+                            <label>实际训练文本:</label>
+                            <textarea id="textToTrain" cols="80" rows="4" readonly="readonly"></textarea>
+                        </li>
+                    </ul>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+
+<th:block th:include="include :: footer" />
+<script th:inline="javascript">
+    var prefix = ctx + "aicall/vtvoiceclone";
+
+    $.operate.registerVoice = function() {
+        var audioUrl = $("#audio_url").val().trim();
+        if (audioUrl === "") {
+            $.modal.msgError("请先填写音频 URL");
+            $("#audio_url")[0].focus();
+            return;
+        }
+        if (audioUrl.indexOf("http://") !== 0 && audioUrl.indexOf("https://") !== 0) {
+            $.modal.msgError("音频 URL 必须以 http:// 或 https:// 开头");
+            $("#audio_url")[0].focus();
+            return;
+        }
+
+        var formData = new FormData();
+        formData.append("audio_url", audioUrl);
+        formData.append("voice_name", $("#voice_name").val().trim());
+        formData.append("language", $("#language").val());
+        formData.append("text", $("#text").val().trim());
+        formData.append("gender", $("#gender").val());
+
+        $.modal.msg("正在提交 ViiTorTTS 音色注册,请稍后...");
+        $.ajax({
+            url: prefix + "/registerVoice",
+            type: "POST",
+            data: formData,
+            processData: false,
+            contentType: false,
+            success: function(respJson) {
+                if (respJson.code !== 0) {
+                    $.modal.msgError(respJson.msg || "ViiTorTTS 音色注册失败");
+                    return;
+                }
+                var data = respJson.data || {};
+                $("#savedVoiceCode").val(data.voiceCode || "");
+                $("#savedVoiceName").val(data.voiceName || "");
+                $("#savedLanguageName").val(data.languageName || "");
+                $("#audioToTrain").val(data.audioToTrain || "");
+                $("#textToTrain").val(data.textToTrain || "");
+                $.modal.msgSuccess(respJson.msg || "ViiTorTTS 音色注册成功");
+            },
+            error: function() {
+                $.modal.msgError("ViiTorTTS 音色注册失败");
+            }
+        });
+    };
+</script>
+</body>
+</html>

+ 152 - 0
ruoyi-admin/src/main/resources/templates/cc/vtttsconf/vtttsconf.html

@@ -0,0 +1,152 @@
+<!DOCTYPE html>
+<html lang="zh" xmlns:th="http://www.thymeleaf.org">
+<head>
+    <th:block th:include="include :: header('ViiTorTTS参数配置')" />
+    <th:block th:include="include :: layout-latest-css" />
+</head>
+<body>
+<div class="main-content">
+    <div class="h4 form-header">ViiTorTTS 参数配置</div>
+    <div class="alert alert-info">
+        1. 本页面用于配置 `mod_vt_tts` 以及 `callcenter-private-master` 共用的云上曲率账号参数。
+        2. 电话实时播报建议使用 `pcm + 8000`,语种默认可填写 `zh-CN`。
+        3. 保存后会尝试 reload `mod_vt_tts`,如失败请检查 FreeSWITCH 编译部署是否已包含该模块。
+    </div>
+    <div id="baseConfigs"></div>
+    <div class="row">
+        <div class="col-sm-offset-5 col-sm-10">
+            <button id="saveConfig" type="button" class="btn btn-sm btn-primary" onclick="submitHandler()">
+                <i class="fa fa-check"></i>保存
+            </button>
+        </div>
+    </div>
+</div>
+
+<th:block th:include="include :: footer" />
+<th:block th:include="include :: layout-latest-js" />
+<script>
+    var prefix = ctx + "cc/fsconf";
+
+    function escapeHtml(value) {
+        return $('<div/>').text(value || '').html();
+    }
+
+    function renderInput(config) {
+        var name = config.name;
+        var value = config.value || '';
+        var safeValue = escapeHtml(value);
+
+        if (name === 'format') {
+            return '' +
+                '<select class="config-value form-control" id="' + name + '">' +
+                '<option value="pcm"' + (value === 'pcm' ? ' selected' : '') + '>pcm</option>' +
+                '</select>';
+        }
+
+        if (name === 'sample-rate') {
+            return '' +
+                '<select class="config-value form-control" id="' + name + '">' +
+                '<option value="8000"' + (value === '8000' ? ' selected' : '') + '>8000</option>' +
+                '<option value="16000"' + (value === '16000' ? ' selected' : '') + '>16000</option>' +
+                '<option value="22050"' + (value === '22050' ? ' selected' : '') + '>22050</option>' +
+                '<option value="24000"' + (value === '24000' ? ' selected' : '') + '>24000</option>' +
+                '</select>';
+        }
+
+        if (name === 'verify-peer') {
+            return '' +
+                '<select class="config-value form-control" id="' + name + '">' +
+                '<option value="false"' + (value === 'false' ? ' selected' : '') + '>false</option>' +
+                '<option value="true"' + (value === 'true' ? ' selected' : '') + '>true</option>' +
+                '</select>';
+        }
+
+        var inputType = name === 'secret-key' ? 'password' : 'text';
+        return '<input class="config-value form-control" type="' + inputType + '" id="' + name + '" value="' + safeValue + '" />';
+    }
+
+    function validateConfigs(configs) {
+        var configMap = {};
+        $.each(configs, function(index, item) {
+            configMap[item.name] = item.value;
+        });
+        if (!configMap['appid']) {
+            $.modal.alertError('`appid` 不能为空');
+            return false;
+        }
+        if (!configMap['secret-key']) {
+            $.modal.alertError('`secret-key` 不能为空');
+            return false;
+        }
+        if (!configMap['voice-name']) {
+            $.modal.alertError('`voice-name` 不能为空');
+            return false;
+        }
+        if (configMap['connect-timeout-ms']) {
+            var timeout = parseInt(configMap['connect-timeout-ms'], 10);
+            if (isNaN(timeout) || timeout <= 0 || timeout > 120000) {
+                $.modal.alertError('`connect-timeout-ms` 必须是 1 到 120000 之间的整数');
+                return false;
+            }
+        }
+        return true;
+    }
+
+    $(function() {
+        $.ajax({
+            url: prefix + '/getVtTtsConf',
+            type: 'GET',
+            success: function(resp) {
+                var html = '';
+                var data = resp.data || [];
+                $.each(data, function(index, config) {
+                    html += '<div class="row">';
+                    html += '<div class="col-sm-12">';
+                    html += '<label class="col-sm-2 control-label">' + config.aliasName + '</label><div class="col-sm-10">' + renderInput(config) + '</div>';
+                    html += '</div>';
+                    html += '</div>';
+                });
+                $('#baseConfigs').html(html);
+            },
+            error: function(error) {
+                console.error('Error fetching configuration:', error);
+            }
+        });
+    });
+
+    $('#saveConfig').click(function() {
+        var configs = [];
+        $('input.config-value').each(function() {
+            configs.push({
+                name: $(this).attr('id'),
+                value: $(this).val().trim()
+            });
+        });
+        $('select.config-value').each(function() {
+            configs.push({
+                name: $(this).attr('id'),
+                value: ($(this).val() || '').trim()
+            });
+        });
+
+        if (!validateConfigs(configs)) {
+            return;
+        }
+
+        $.ajax({
+            url: prefix + '/setVtTtsConf',
+            type: 'POST',
+            contentType: 'application/json',
+            data: JSON.stringify(configs),
+            beforeSend: function () {
+                $.modal.loading(i18n("common.tip.loading"));
+                $.modal.disable();
+            },
+            success: function(response) {
+                processAjaxReponseJson(response);
+            }
+        });
+    });
+</script>
+</body>
+</html>

+ 55 - 0
sql/v20260715_vt_tts.sql

@@ -0,0 +1,55 @@
+-- 2026-07-15
+-- Add ViiTorTTS bridge configuration for mod_vt_tts.
+
+DELETE FROM `sys_role_menu`
+WHERE `menu_id` = 4033;
+
+DELETE FROM `sys_menu`
+WHERE `menu_id` = 4033
+   OR `perms` = 'cc:vtttsconf:view'
+   OR `url` = '/cc/fsconf/vtttsconf';
+
+INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `menu_code`, `parent_id`, `order_num`, `url`, `target`, `menu_type`, `visible`, `is_refresh`, `perms`, `icon`, `create_by`, `create_time`, `remark`)
+VALUES (4033, 'ViiTorTTS配置', 'vtttsconf', 3019, 11, '/cc/fsconf/vtttsconf', 'menuItem', 'C', '0', '1', 'cc:vtttsconf:view', '#', 'admin', NOW(), 'mod_vt_tts 参数配置菜单');
+
+INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES (2, 4033);
+
+DELETE FROM `sys_config`
+WHERE `config_key` = 'config_tts_provider_vt_tts';
+
+INSERT INTO `sys_config` (`config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `remark`)
+VALUES ('ViiTorTTS', 'config_tts_provider_vt_tts', 'vt_tts', 'Y', 'admin', NOW(), 'TTS厂商-ViiTorTTS');
+
+DELETE FROM `cc_params`
+WHERE `param_code` = 'vt-tts-account-json';
+
+INSERT INTO `cc_params` (`param_name`, `param_code`, `param_value`, `param_type`, `hide_value`)
+VALUES ('ViiTorTTS账号参数json', 'vt-tts-account-json', '{"appid":"","secret-key":"","voice-name":"juvenile","language":"zh-CN","format":"pcm","sample-rate":"8000","token-url":"https://tts.ilivedata.com/api/v2/speech/synthesis/ws-token","ws-url":"wss://tts.ilivedata.com/api/v1/speech/synthesis/ws","connect-timeout-ms":"10000","verify-peer":"false"}', 'tts', 1);
+
+DELETE FROM `fs_variables`
+WHERE `cat` = 5
+  AND `var_field_name` IN (
+    'appid',
+    'secret-key',
+    'voice-name',
+    'language',
+    'format',
+    'sample-rate',
+    'token-url',
+    'ws-url',
+    'connect-timeout-ms',
+    'verify-peer'
+  );
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`) VALUES
+(5, 'appid', 'AppId'),
+(5, 'secret-key', 'SecretKey'),
+(5, 'voice-name', '默认音色名'),
+(5, 'language', '默认语种'),
+(5, 'format', '音频格式'),
+(5, 'sample-rate', '采样率'),
+(5, 'token-url', 'Token接口地址'),
+(5, 'ws-url', 'WebSocket地址'),
+(5, 'connect-timeout-ms', '连接超时(毫秒)'),
+(5, 'verify-peer', '校验证书');

+ 34 - 0
sql/v20260715_vt_voiceclone.sql

@@ -0,0 +1,34 @@
+-- 2026-07-15
+-- Add ViiTorTTS voice clone menu and permissions.
+
+DELETE FROM `sys_role_menu`
+WHERE `menu_id` IN (4034, 4035);
+
+DELETE FROM `sys_menu`
+WHERE `menu_id` IN (4034, 4035)
+   OR `perms` IN ('aicall:vtvoiceclone:view', 'aicall:vtvoiceclone:registerVoice')
+   OR `url` = '/aicall/vtvoiceclone/voiceclone';
+
+INSERT INTO `sys_menu`
+(`menu_id`, `menu_name`, `menu_code`, `parent_id`, `order_num`, `url`, `target`, `menu_type`, `visible`, `is_refresh`, `perms`, `icon`, `create_by`, `create_time`, `remark`)
+VALUES
+(4034, 'ViiTorTTS音色注册', 'vtvoiceclone', 3019, 12, '/aicall/vtvoiceclone/voiceclone', 'menuItem', 'C', '0', '1', 'aicall:vtvoiceclone:view', 'fa fa-microphone', 'admin', NOW(), 'ViiTorTTS音色注册工具');
+
+INSERT INTO `sys_menu`
+(`menu_id`, `menu_name`, `menu_code`, `parent_id`, `order_num`, `url`, `target`, `menu_type`, `visible`, `is_refresh`, `perms`, `icon`, `create_by`, `create_time`, `remark`)
+VALUES
+(4035, 'ViiTorTTS音色注册提交', 'vtvoicecloneRegister', 4032, 1, '#', '', 'F', '0', '1', 'aicall:vtvoiceclone:registerVoice', '#', 'admin', NOW(), 'ViiTorTTS音色注册提交权限');
+
+INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES
+(2, 4034),
+(2, 4035);
+
+DELETE FROM `cc_tts_aliyun`
+WHERE `voice_code` = 'juvenile'
+  AND `voice_source` = 'vt_tts';
+
+INSERT INTO `cc_tts_aliyun`
+(`voice_name`, `voice_code`, `voice_enabled`, `voice_source`, `priority`, `provider`, `language_code`, `language_name`, `tts_models`)
+VALUES
+('ViiTor默认女声 - juvenile', 'juvenile', 1, 'vt_tts', 0, 'vt_tts', 'zh-CN', '中文', 'standard');