yzx 1 tydzień temu
rodzic
commit
31dec6f580

+ 3 - 2
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/ApiController.java

@@ -963,11 +963,12 @@ public class ApiController extends BaseController {
         if (StringUtils.isBlank(voiceCode) || StringUtils.isBlank(voiceSource)) {
             return false;
         }
-        // voiceSource仅支持aliyun_tts、aliyun_tts_flow、doubao_vcl_tts 和 xf_tts
+        // voiceSource仅支持aliyun_tts、aliyun_tts_flow、doubao_vcl_tts、xf_ttstx_tts1
         if (!"aliyun_tts".equals(voiceSource)
                 && !"aliyun_tts_flow".equals(voiceSource)
                 && !"doubao_vcl_tts".equals(voiceSource)
-                && !"xf_tts".equals(voiceSource)) {
+                && !"xf_tts".equals(voiceSource)
+                && !"tx_tts1".equals(voiceSource)) {
             return false;
         }
         // voiceCode必须是存在的

+ 468 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/TxVoiceCloneController.java

@@ -0,0 +1,468 @@
+package com.ruoyi.aicall.controller;
+
+import com.alibaba.fastjson.JSON;
+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 org.springframework.web.multipart.MultipartFile;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import javax.servlet.http.HttpServletRequest;
+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/txvoiceclone")
+@Slf4j
+public class TxVoiceCloneController extends BaseController {
+    private static final MediaType JSON_TYPE = MediaType.parse("application/json; charset=utf-8");
+    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd").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_ENDPOINT = "mps.tencentcloudapi.com";
+    private static final String DEFAULT_ACTION = "SyncDubbing";
+    private static final String DEFAULT_VERSION = "2019-06-12";
+    private static final String DEFAULT_TEXT_LANG = "zh";
+    private static final int DEFAULT_SAMPLE_RATE = 8000;
+    private static final int DEFAULT_PITCH = 0;
+    private final String prefix = "aicall/txvoiceclone";
+
+    @Autowired
+    private IFsConfService fsConfService;
+
+    @Autowired
+    private ICcTtsAliyunService ttsAliyunService;
+
+    @RequiresPermissions("aicall:txvoiceclone:view")
+    @GetMapping("voiceclone")
+    public String voiceClone() {
+        return prefix + "/voiceclone";
+    }
+
+    @RequiresPermissions("aicall:txvoiceclone:uploadAndClone")
+    @Log(title = "腾讯音色克隆-上传音频", businessType = BusinessType.IMPORT)
+    @PostMapping("/uploadAndClone")
+    @ResponseBody
+    public AjaxResult uploadAndClone(HttpServletRequest request, @RequestParam("file") MultipartFile file) {
+        try {
+            if (file == null || file.isEmpty()) {
+                return AjaxResult.error("请先选择录音文件");
+            }
+
+            String voiceName = trimToEmpty(request.getParameter("voice_name"));
+            if (StringUtils.isBlank(voiceName)) {
+                return AjaxResult.error("请填写音色名称");
+            }
+
+            String audioLang = normalizeLanguage(trimToEmpty(request.getParameter("audio_lang")));
+            String gender = normalizeGender(trimToEmpty(request.getParameter("gender")));
+            String age = normalizeAge(trimToEmpty(request.getParameter("age")));
+            String description = trimToEmpty(request.getParameter("description"));
+            Float rangeStart = parseFloat(request.getParameter("range_start"));
+            Float rangeEnd = parseFloat(request.getParameter("range_end"));
+            JSONObject account = loadTencentAccount();
+
+            JSONObject body = new JSONObject(true);
+            body.put("AudioData", Base64.getEncoder().encodeToString(file.getBytes()));
+            body.put("AudioLang", audioLang);
+            body.put("VoiceProfile", buildVoiceProfile(voiceName, gender, age, audioLang, description));
+
+            String resourceId = config(account, "resource-id", "");
+            if (StringUtils.isNotBlank(resourceId)) {
+                body.put("ResourceId", resourceId);
+            }
+
+            JSONObject extParam = buildCloneExtParam(rangeStart, rangeEnd);
+            if (!extParam.isEmpty()) {
+                body.put("ExtParam", extParam.toJSONString());
+            }
+
+            JSONObject response = doTencentRequest(account, body);
+            JSONObject rsp = response.getJSONObject("Response");
+            if (rsp == null) {
+                return AjaxResult.error("腾讯云返回结果为空");
+            }
+
+            String voiceId = trimToEmpty(rsp.getString("VoiceId"));
+            if (StringUtils.isBlank(voiceId)) {
+                return AjaxResult.error("腾讯云未返回可用的 VoiceId:\n" + response.toJSONString());
+            }
+
+            CcTtsAliyun speaker = saveCloneVoice(voiceName, voiceId, audioLang);
+
+            JSONObject data = new JSONObject(true);
+            data.put("voiceId", voiceId);
+            data.put("voiceName", speaker.getVoiceName());
+            data.put("requestId", rsp.getString("RequestId"));
+            data.put("saved", true);
+            data.put("languageCode", speaker.getLanguageCode());
+            data.put("languageName", speaker.getLanguageName());
+            return AjaxResult.success("音色克隆成功,已返回 VoiceId 并写入音色表。", data);
+        } catch (Exception e) {
+            log.error("uploadAndClone error", e);
+            return AjaxResult.error("腾讯音色克隆失败:\n" + e.getMessage());
+        }
+    }
+
+    @RequiresPermissions("aicall:txvoiceclone:ttsTest")
+    @PostMapping("/ttsTest")
+    @ResponseBody
+    public AjaxResult ttsTest(HttpServletRequest request) {
+        try {
+            String voiceId = trimToEmpty(request.getParameter("voiceId"));
+            String text = trimToEmpty(request.getParameter("text"));
+            String textLang = normalizeLanguage(trimToEmpty(request.getParameter("text_lang")));
+            if (StringUtils.isBlank(voiceId)) {
+                return AjaxResult.error("请先完成克隆并获取 VoiceId");
+            }
+            if (StringUtils.isBlank(text)) {
+                return AjaxResult.error("请输入测试文本");
+            }
+
+            JSONObject account = loadTencentAccount();
+            JSONObject body = new JSONObject(true);
+            body.put("Text", text);
+            body.put("TextLang", textLang);
+            body.put("VoiceId", voiceId);
+
+            String resourceId = config(account, "resource-id", "");
+            if (StringUtils.isNotBlank(resourceId)) {
+                body.put("ResourceId", resourceId);
+            }
+
+            JSONObject extParam = new JSONObject(true);
+            JSONObject synExt = new JSONObject(true);
+            synExt.put("sampleRate", configInt(account, "sample-rate", DEFAULT_SAMPLE_RATE));
+            synExt.put("pitch", configInt(account, "pitch", DEFAULT_PITCH));
+            extParam.put("synExt", synExt);
+            body.put("ExtParam", extParam.toJSONString());
+
+            JSONObject response = doTencentRequest(account, body);
+            JSONObject rsp = response.getJSONObject("Response");
+            if (rsp == null) {
+                return AjaxResult.error("腾讯云返回结果为空");
+            }
+
+            String audioData = trimToEmpty(rsp.getString("AudioData"));
+            if (StringUtils.isBlank(audioData)) {
+                return AjaxResult.error("腾讯云未返回试听音频:\n" + response.toJSONString());
+            }
+
+            JSONObject data = new JSONObject(true);
+            data.put("audioBase64", audioData);
+            data.put("audioMime", "audio/wav");
+            data.put("audioUrl", trimToEmpty(rsp.getString("AudioUrl")));
+            data.put("requestId", trimToEmpty(rsp.getString("RequestId")));
+            return AjaxResult.success("试听合成成功", data);
+        } catch (Exception e) {
+            log.error("ttsTest error", e);
+            return AjaxResult.error("腾讯在线试听失败:\n" + e.getMessage());
+        }
+    }
+
+    private JSONObject loadTencentAccount() {
+        JSONObject conf = fsConfService.getAsrConf("/autoload_configs/tx_tts1.conf.xml");
+        String secretId = trimToEmpty(conf.getString("secret-id"));
+        String secretKey = trimToEmpty(conf.getString("secret-key"));
+        if (StringUtils.isBlank(secretId) || StringUtils.isBlank(secretKey)) {
+            throw new IllegalStateException("请先在“腾讯云TTS配置”中填写 SecretId 和 SecretKey。");
+        }
+
+        JSONObject account = new JSONObject(true);
+        account.put("secret-id", secretId);
+        account.put("secret-key", secretKey);
+        account.put("endpoint", config(conf, "endpoint", DEFAULT_ENDPOINT));
+        account.put("action", config(conf, "action", DEFAULT_ACTION));
+        account.put("version", config(conf, "version", DEFAULT_VERSION));
+        account.put("resource-id", config(conf, "resource-id", ""));
+        account.put("sample-rate", config(conf, "sample-rate", String.valueOf(DEFAULT_SAMPLE_RATE)));
+        account.put("pitch", config(conf, "pitch", String.valueOf(DEFAULT_PITCH)));
+        account.put("verify-peer", config(conf, "verify-peer", "false"));
+        return account;
+    }
+
+    private JSONObject buildCloneExtParam(Float rangeStart, Float rangeEnd) {
+        JSONObject extParam = new JSONObject(true);
+        if (rangeStart != null && rangeEnd != null && rangeEnd > rangeStart && rangeStart >= 0) {
+            JSONObject cloneExt = new JSONObject(true);
+            cloneExt.put("timeRanges", JSON.parseArray(String.format("[[%s,%s]]", trimZero(rangeStart), trimZero(rangeEnd))));
+            extParam.put("cloneExt", cloneExt);
+        }
+        return extParam;
+    }
+
+    private JSONObject buildVoiceProfile(String voiceName, String gender, String age, String audioLang, String description) {
+        JSONObject profile = new JSONObject(true);
+        profile.put("Name", voiceName);
+        if (StringUtils.isNotBlank(gender)) {
+            profile.put("Gender", gender);
+        }
+        if (StringUtils.isNotBlank(age)) {
+            profile.put("Age", age);
+        }
+        profile.put("Languages", JSON.parseArray("[\"" + audioLang + "\"]"));
+        if (StringUtils.isNotBlank(description)) {
+            profile.put("Description", description);
+        }
+        return profile;
+    }
+
+    private JSONObject doTencentRequest(JSONObject account, JSONObject bodyJson) throws Exception {
+        String endpoint = config(account, "endpoint", DEFAULT_ENDPOINT);
+        String action = config(account, "action", DEFAULT_ACTION);
+        String version = config(account, "version", DEFAULT_VERSION);
+        String body = bodyJson.toJSONString();
+        long timestamp = System.currentTimeMillis() / 1000L;
+
+        Request.Builder builder = new Request.Builder()
+                .url("https://" + endpoint + "/")
+                .post(RequestBody.create(JSON_TYPE, body))
+                .addHeader("Content-Type", "application/json; charset=utf-8")
+                .addHeader("Host", endpoint)
+                .addHeader("Authorization", buildAuthorization(account, body, timestamp, endpoint, action))
+                .addHeader("X-TC-Action", action)
+                .addHeader("X-TC-Version", version)
+                .addHeader("X-TC-Timestamp", String.valueOf(timestamp))
+                .addHeader("X-TC-Language", "zh-CN");
+
+        boolean verifyPeer = configBool(account, "verify-peer", false);
+        OkHttpClient client = verifyPeer ? HTTP_CLIENT : HTTP_CLIENT.newBuilder()
+                .hostnameVerifier((hostname, session) -> true)
+                .build();
+
+        try (Response response = client.newCall(builder.build()).execute()) {
+            String responseBody = response.body() == null ? "" : response.body().string();
+            log.info("txvoiceclone request={}, status={}, body={}", body, response.code(), responseBody);
+            if (!response.isSuccessful()) {
+                throw new IOException("HTTP " + response.code() + ": " + responseBody);
+            }
+            JSONObject json = JSON.parseObject(responseBody);
+            JSONObject rsp = json.getJSONObject("Response");
+            if (rsp == null) {
+                throw new IOException("腾讯云返回结构异常: " + responseBody);
+            }
+            if (rsp.containsKey("Error")) {
+                throw new IOException("腾讯云接口错误: " + rsp.getJSONObject("Error").toJSONString());
+            }
+            Integer errorCode = rsp.getInteger("ErrorCode");
+            if (errorCode != null && errorCode != 0) {
+                throw new IOException("腾讯云业务错误 code=" + errorCode + ", msg=" + rsp.getString("Msg") + ", body=" + responseBody);
+            }
+            return json;
+        }
+    }
+
+    private CcTtsAliyun saveCloneVoice(String inputVoiceName, String voiceId, String audioLang) {
+        String voiceName = buildStoredVoiceName(inputVoiceName, voiceId);
+        String languageCode = toLanguageCode(audioLang);
+        String languageName = toLanguageName(audioLang);
+
+        CcTtsAliyun speaker = ttsAliyunService.selectCcTtsAliyunByVoiceCode(voiceId);
+        boolean update = speaker != null;
+        if (!update) {
+            speaker = new CcTtsAliyun();
+        }
+        speaker.setVoiceName(voiceName);
+        speaker.setVoiceCode(voiceId);
+        speaker.setVoiceEnabled(1);
+        speaker.setVoiceSource("tx_tts1");
+        speaker.setPriority(0);
+        speaker.setProvider("tx_tts1");
+        speaker.setLanguageCode(languageCode);
+        speaker.setLanguageName(languageName);
+        speaker.setTtsModels("clone");
+
+        if (update) {
+            ttsAliyunService.updateCcTtsAliyun(speaker);
+        } else {
+            ttsAliyunService.insertCcTtsAliyun(speaker);
+        }
+        return speaker;
+    }
+
+    private String buildStoredVoiceName(String inputVoiceName, String voiceId) {
+        String name = trimToEmpty(inputVoiceName).replace("'", "").replace("\"", "");
+        if (name.length() > 30) {
+            name = name.substring(0, 30);
+        }
+        String shortId = voiceId.length() > 12 ? voiceId.substring(0, 12) : voiceId;
+        return "腾讯克隆-" + name + "-" + shortId;
+    }
+
+    private String trimToEmpty(String value) {
+        return value == null ? "" : value.trim();
+    }
+
+    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 int configInt(JSONObject obj, String key, int defaultValue) {
+        try {
+            return Integer.parseInt(config(obj, key, String.valueOf(defaultValue)));
+        } catch (Exception e) {
+            return defaultValue;
+        }
+    }
+
+    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 normalizeLanguage(String value) {
+        if (StringUtils.isBlank(value)) {
+            return DEFAULT_TEXT_LANG;
+        }
+        String lang = value.trim().replace('_', '-');
+        if ("zh-CN".equalsIgnoreCase(lang) || "zh".equalsIgnoreCase(lang)) return "zh";
+        if ("en-US".equalsIgnoreCase(lang) || "en".equalsIgnoreCase(lang)) return "en";
+        if ("ja-JP".equalsIgnoreCase(lang) || "ja".equalsIgnoreCase(lang)) return "ja";
+        if ("ko-KR".equalsIgnoreCase(lang) || "ko".equalsIgnoreCase(lang)) return "ko";
+        if ("yue-HK".equalsIgnoreCase(lang) || "yue".equalsIgnoreCase(lang)) return "yue";
+        int idx = lang.indexOf('-');
+        return idx > 0 ? lang.substring(0, idx) : lang;
+    }
+
+    private String normalizeGender(String value) {
+        String input = trimToEmpty(value).toLowerCase();
+        if ("male".equals(input) || "男".equals(input) || "man".equals(input)) {
+            return "male";
+        }
+        if ("female".equals(input) || "女".equals(input) || "woman".equals(input)) {
+            return "female";
+        }
+        return "";
+    }
+
+    private String normalizeAge(String value) {
+        String input = trimToEmpty(value).toLowerCase();
+        if ("child".equals(input) || "儿童".equals(input)) return "child";
+        if ("youth".equals(input) || "青年".equals(input)) return "youth";
+        if ("middle".equals(input) || "中年".equals(input) || "middleaged".equals(input)) return "middle";
+        if ("old".equals(input) || "老年".equals(input) || "senior".equals(input)) return "old";
+        return "";
+    }
+
+    private String toLanguageCode(String apiLang) {
+        if ("en".equalsIgnoreCase(apiLang)) return "en-US";
+        if ("ja".equalsIgnoreCase(apiLang)) return "ja-JP";
+        if ("ko".equalsIgnoreCase(apiLang)) return "ko-KR";
+        if ("yue".equalsIgnoreCase(apiLang)) return "yue-HK";
+        return "zh-CN";
+    }
+
+    private String toLanguageName(String apiLang) {
+        if ("en".equalsIgnoreCase(apiLang)) return "英文";
+        if ("ja".equalsIgnoreCase(apiLang)) return "日语";
+        if ("ko".equalsIgnoreCase(apiLang)) return "韩语";
+        if ("yue".equalsIgnoreCase(apiLang)) return "粤语";
+        return "中文";
+    }
+
+    private Float parseFloat(String value) {
+        try {
+            String trimmed = trimToEmpty(value);
+            return StringUtils.isBlank(trimmed) ? null : Float.parseFloat(trimmed);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private String trimZero(Float value) {
+        if (value == null) {
+            return "";
+        }
+        if (Math.abs(value - value.intValue()) < 0.0001) {
+            return String.valueOf(value.intValue());
+        }
+        return String.valueOf(value);
+    }
+
+    private String buildAuthorization(JSONObject account, String body, long timestamp, String endpoint, String action) throws Exception {
+        String secretId = config(account, "secret-id", "");
+        String secretKey = config(account, "secret-key", "");
+        String date = DATE_FORMATTER.format(Instant.ofEpochSecond(timestamp));
+        String canonicalHeaders = "content-type:application/json; charset=utf-8\n" +
+                "host:" + endpoint + "\n" +
+                "x-tc-action:" + action.toLowerCase() + "\n";
+        String signedHeaders = "content-type;host;x-tc-action";
+        String canonicalRequest = "POST\n/\n\n" +
+                canonicalHeaders + "\n" +
+                signedHeaders + "\n" +
+                sha256Hex(body);
+        String credentialScope = date + "/mps/tc3_request";
+        String stringToSign = "TC3-HMAC-SHA256\n" +
+                timestamp + "\n" +
+                credentialScope + "\n" +
+                sha256Hex(canonicalRequest);
+
+        byte[] kDate = hmacSha256("TC3" + secretKey, date);
+        byte[] kService = hmacSha256(kDate, "mps");
+        byte[] kSigning = hmacSha256(kService, "tc3_request");
+        String signature = bytesToHex(hmacSha256(kSigning, stringToSign));
+        return "TC3-HMAC-SHA256 Credential=" + secretId + "/" + credentialScope +
+                ", SignedHeaders=" + signedHeaders +
+                ", Signature=" + signature;
+    }
+
+    private String sha256Hex(String value) throws Exception {
+        MessageDigest digest = MessageDigest.getInstance("SHA-256");
+        return bytesToHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
+    }
+
+    private byte[] hmacSha256(String key, String message) throws Exception {
+        return hmacSha256(key.getBytes(StandardCharsets.UTF_8), message);
+    }
+
+    private byte[] hmacSha256(byte[] key, String message) throws Exception {
+        Mac mac = Mac.getInstance("HmacSHA256");
+        mac.init(new SecretKeySpec(key, "HmacSHA256"));
+        return mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private String bytesToHex(byte[] bytes) {
+        StringBuilder sb = new StringBuilder(bytes.length * 2);
+        for (byte b : bytes) {
+            sb.append(String.format("%02x", b));
+        }
+        return sb.toString();
+    }
+}

+ 89 - 19
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/FsConfController.java

@@ -236,6 +236,16 @@ public class FsConfController extends BaseController {
         return "cc/xfttsconf/xfttsconf";
     }
 
+    /**
+     * TTS(腾讯云)参数配置
+     * @return
+     */
+    @RequiresPermissions("cc:txtts1conf:view")
+    @GetMapping(value = "/txtts1conf")
+    public String txTts1Conf() {
+        return "cc/txtts1conf/txtts1conf";
+    }
+
     /**
      * 获取阿里云tts配置
      * @return
@@ -258,6 +268,17 @@ public class FsConfController extends BaseController {
         return getConfigFileJsonData(asrFileName, 5);
     }
 
+    /**
+     * 获取腾讯云tts配置
+     * @return
+     */
+    @GetMapping(value = "/getTxTts1Conf")
+    @ResponseBody
+    public AjaxResult getTxTts1Conf() {
+        String asrFileName = "/autoload_configs/tx_tts1.conf.xml";
+        return getConfigFileJsonData(asrFileName, 5);
+    }
+
     /**
      *  豆包tts 参数配置
      * @return
@@ -465,6 +486,10 @@ public class FsConfController extends BaseController {
     private AjaxResult saveAndReloadAsrModule(String asrFileName, String moduleName, JSONArray params, String asrProvider){
         String result = fsConfService.setAsrConf(params, asrFileName);
         if(StringUtils.isEmpty(result)) {
+            result = fsConfService.ensureAsrengineLoaded(moduleName);
+            if (!StringUtils.isEmpty(result)) {
+                return AjaxResult.error(result);
+            }
 
             // 更新cc_params参数表
             ccParamsService.updateParamsValue("sys-asr-provider", asrProvider);
@@ -473,19 +498,54 @@ public class FsConfController extends BaseController {
                 return error("参数修改成功, 但是刷新失败, 请手动重启 call-center!");
             }
 
-            // reload
-            EslMessage resp = EslConnectionUtil.sendSyncApiCommand("reload", moduleName);
-            String respText = CommonUtils.ListToString(resp.getBodyLines());
-            if(respText.contains("OK module loaded")) {
+            AjaxResult loadResult = loadOrReloadAsrModule(moduleName);
+            if (loadResult.isSuccess()) {
                 return AjaxResult.success("配置写入成功!\n  模块已成功加载!");
-            }else{
-                return AjaxResult.error("配置写入成功!\n  但是模块加载失败! \n" + respText);
             }
+            return AjaxResult.error("配置写入成功!\n  但是模块加载失败! \n" + loadResult.get("msg"));
         }else{
             return AjaxResult.error("配置文件写入失败!\n " + result);
         }
     }
 
+    private String asrProviderByModule(String asrengine) {
+        if (StringUtils.isEmpty(asrengine)) {
+            return "";
+        }
+        switch (asrengine.trim()) {
+            case "mod_xunfei_asr":
+                return "xunfeiasr";
+            case "mod_aliyun_asr":
+                return "aliyun";
+            case "mod_ali_asr":
+                return "ali";
+            case "mod_tx_asr":
+                return "tx";
+            case "mod_tx_asr1":
+                return "tx1";
+            case "mod_chinatelecom_asr":
+                return "chinatelecom";
+            case "mod_funasr":
+                return "funasr";
+            default:
+                return "";
+        }
+    }
+
+    private AjaxResult loadOrReloadAsrModule(String moduleName) {
+        EslMessage resp = EslConnectionUtil.sendSyncApiCommand("reload", moduleName.trim());
+        String text = CommonUtils.ListToString(resp.getBodyLines(), '\n');
+        if(text.contains("+OK")) {
+            return AjaxResult.success("设置成功!");
+        }
+        resp = EslConnectionUtil.sendSyncApiCommand("load", moduleName.trim());
+        text = CommonUtils.ListToString(resp.getBodyLines(), '\n');
+        if(text.contains("+OK")) {
+            return AjaxResult.success("设置成功!");
+        }
+        return AjaxResult.error("设置失败!\n " + text);
+    }
+
     private AjaxResult saveAndReloadTtsModule(String paramCode, String asrFileName, String moduleName, JSONArray params){
         String result = fsConfService.setAsrConf(params, asrFileName);
         if(StringUtils.isEmpty(result)) {
@@ -617,6 +677,20 @@ public class FsConfController extends BaseController {
         return result;
     }
 
+    /**
+     * 保存腾讯云TTS配置
+     * @param params
+     * @return
+     */
+    @PostMapping(value = "/setTxTts1Conf")
+    @ResponseBody
+    public AjaxResult setTxTts1Conf(@RequestBody JSONArray params) {
+        String asrFileName = "/autoload_configs/tx_tts1.conf.xml";
+        String moduleName = "mod_tx_tts1";
+        AjaxResult result = saveAndReloadTtsModule("tx-tts1-account-json", asrFileName, moduleName, params);
+        return result;
+    }
+
     /**
      * 保存TTS配置
      * @param params
@@ -684,20 +758,16 @@ public class FsConfController extends BaseController {
         String result = fsConfService.setAsrengine(asrengine);
         if(StringUtils.isEmpty(result)){
             if (StringUtils.isNotEmpty(asrengine)) {
-                EslConnectionUtil.sendSyncApiCommand("unload", "mod_xunfei_asr");
-                EslConnectionUtil.sendSyncApiCommand("unload", "mod_funasr");
-                EslConnectionUtil.sendSyncApiCommand("unload", "mod_aliyun_asr");
-                EslConnectionUtil.sendSyncApiCommand("unload", "mod_ali_asr");
-                EslConnectionUtil.sendSyncApiCommand("unload", "mod_tx_asr");
-                EslConnectionUtil.sendSyncApiCommand("unload", "mod_tx_asr1");
-
-                EslMessage resp = EslConnectionUtil.sendSyncApiCommand("load", asrengine.trim());
-                String text = CommonUtils.ListToString(resp.getBodyLines() , '\n');
-                if(text.contains("+OK")) {
-                    return AjaxResult.success("设置成功!");
-                }else{
-                    return AjaxResult.success("设置失败!\n " + text);
+                String asrProvider = asrProviderByModule(asrengine);
+                if (StringUtils.isNotEmpty(asrProvider)) {
+                    ccParamsService.updateParamsValue("fs_call_asr_engine", asrProvider);
+                    ccParamsService.updateParamsValue("sys-asr-provider", asrProvider);
+                    String reloadRsp = ccParamsService.reloadParams();
+                    if(!reloadRsp.equalsIgnoreCase("success")){
+                        return error("参数修改成功, 但是刷新失败, 请手动重启 call-center!");
+                    }
                 }
+                return loadOrReloadAsrModule(asrengine);
             }else{
                 return AjaxResult.error("参数错误!");
             }

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/service/IFsConfService.java

@@ -47,6 +47,13 @@ public interface IFsConfService {
      */
     String setAsrengine(String asrengine);
 
+    /**
+     * 确保指定ASR模块已写入 modules.conf.xml
+     * @param asrengine
+     * @return
+     */
+    String ensureAsrengineLoaded(String asrengine);
+
     /**
      * 设置vars.xml文件的参数值
      */

+ 64 - 35
ruoyi-admin/src/main/java/com/ruoyi/cc/service/impl/FsConfServiceImpl.java

@@ -32,6 +32,7 @@ import java.nio.file.Files;
 import java.nio.file.Paths;
 import java.nio.file.StandardCopyOption;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 
 /**
@@ -41,6 +42,16 @@ import java.util.List;
 @Slf4j
 public class FsConfServiceImpl implements IFsConfService {
 
+    private static final List<String> ASR_MODULES = Arrays.asList(
+            "mod_xunfei_asr",
+            "mod_funasr",
+            "mod_aliyun_asr",
+            "mod_ali_asr",
+            "mod_tx_asr",
+            "mod_tx_asr1",
+            "mod_chinatelecom_asr"
+    );
+
     @Autowired
     private ICcParamsService ccParamsService;
 
@@ -202,6 +213,11 @@ public class FsConfServiceImpl implements IFsConfService {
 
     @Override
     public String getAsrengine() {
+        String defaultProvider = ccParamsService.getParamValueByCode("fs_call_asr_engine", "");
+        String defaultModule = moduleNameByProvider(defaultProvider);
+        if (StringUtils.isNotEmpty(defaultModule)) {
+            return defaultModule;
+        }
         String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
         try {
             // 创建DocumentBuilderFactory对象
@@ -218,23 +234,9 @@ public class FsConfServiceImpl implements IFsConfService {
             for (int i = 0; i < nodes.getLength(); i++) {
                 if (nodes.item(i) instanceof Element) {
                     Element element = (Element) nodes.item(i);
-                    if ("mod_xunfei_asr".equals(element.getAttribute("module"))){
-                        return "mod_xunfei_asr";
-                    }
-                    if ("mod_aliyun_asr".equals(element.getAttribute("module"))){
-                        return "mod_aliyun_asr";
-                    }
-                    if ("mod_ali_asr".equals(element.getAttribute("module"))){
-                        return "mod_ali_asr";
-                    }
-                    if ("mod_tx_asr".equals(element.getAttribute("module"))){
-                        return "mod_tx_asr";
-                    }
-                    if ("mod_tx_asr1".equals(element.getAttribute("module"))){
-                        return "mod_tx_asr1";
-                    }
-                    if ("mod_funasr".equals(element.getAttribute("module"))){
-                        return "mod_funasr";
+                    String moduleName = element.getAttribute("module");
+                    if (isAsrModule(moduleName)){
+                        return moduleName;
                     }
                 }
             }
@@ -246,6 +248,17 @@ public class FsConfServiceImpl implements IFsConfService {
 
     @Override
     public String setAsrengine(String asrengine) {
+        return ensureAsrengineLoaded(asrengine);
+    }
+
+    @Override
+    public String ensureAsrengineLoaded(String asrengine) {
+        if (StringUtils.isEmpty(asrengine)) {
+            return "参数错误!";
+        }
+        if (!isAsrModule(asrengine.trim())) {
+            return "不支持的ASR模块: " + asrengine;
+        }
         String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");
         try {
             // 创建DocumentBuilderFactory对象
@@ -259,30 +272,18 @@ public class FsConfServiceImpl implements IFsConfService {
             Element modules = (Element)document.getElementsByTagName("modules").item(0);
             // 获取属性值
             NodeList nodes = modules.getElementsByTagName("load");
-            Boolean existAsrMod = false;
+            String targetModule = asrengine.trim();
             for (int i = 0; i < nodes.getLength(); i++) {
                 if (nodes.item(i) instanceof Element) {
                     Element element = (Element) nodes.item(i);
-                    if ("mod_xunfei_asr".equals(element.getAttribute("module"))
-                            || "mod_funasr".equals(element.getAttribute("module"))
-                            || "mod_aliyun_asr".equals(element.getAttribute("module"))
-                            || "mod_ali_asr".equals(element.getAttribute("module"))
-                            || "mod_tx_asr".equals(element.getAttribute("module"))
-                            || "mod_tx_asr1".equals(element.getAttribute("module"))){
-                        existAsrMod = true;
-                        if (StringUtils.isNotEmpty(asrengine)) {
-                            element.setAttribute("module", asrengine);
-                        } else {
-                            modules.removeChild(element);
-                        }
+                    if (targetModule.equals(element.getAttribute("module"))){
+                        return "";
                     }
                 }
             }
-            if (!existAsrMod && StringUtils.isNotEmpty(asrengine)) {
-                Element element = document.createElement("load");
-                element.setAttribute("module", asrengine);
-                modules.appendChild(element);
-            }
+            Element element = document.createElement("load");
+            element.setAttribute("module", targetModule);
+            modules.appendChild(element);
             // 将更新后的Document对象写回XML文件
             TransformerFactory transformerFactory = TransformerFactory.newInstance();
             Transformer transformer = transformerFactory.newTransformer();
@@ -302,6 +303,34 @@ public class FsConfServiceImpl implements IFsConfService {
         return "";
     }
 
+    private boolean isAsrModule(String moduleName) {
+        return ASR_MODULES.contains(moduleName);
+    }
+
+    private String moduleNameByProvider(String provider) {
+        if (StringUtils.isEmpty(provider)) {
+            return "";
+        }
+        switch (provider.trim()) {
+            case "xunfeiasr":
+                return "mod_xunfei_asr";
+            case "aliyun":
+                return "mod_aliyun_asr";
+            case "ali":
+                return "mod_ali_asr";
+            case "tx":
+                return "mod_tx_asr";
+            case "tx1":
+                return "mod_tx_asr1";
+            case "funasr":
+                return "mod_funasr";
+            case "chinatelecom":
+                return "mod_chinatelecom_asr";
+            default:
+                return "";
+        }
+    }
+
     @Override
     public void setVarsConf(JSONArray params) {
         String fsConfDirectory = ccParamsService.getParamValueByCode("fs_conf_directory", "");

+ 3 - 0
ruoyi-admin/src/main/resources/static/i18n/messages.properties

@@ -217,6 +217,7 @@ switchconf.asr.chinatelecom.header=电信ASR参数配置
 switchconf.tts.ali.header=阿里云tts参数配置
 switchconf.tts.doubao.header=豆包tts参数配置
 switchconf.tts.chinatelecom.header=电信tts参数配置
+switchconf.tts.tx1.header=腾讯云TTS参数配置
 switchconf.tts.xf.header=科大讯飞TTS参数配置
 switchconf.asr.engine.header=asr引擎选择
 switchconf.asr.engine.option1=无
@@ -869,6 +870,7 @@ _menu.faqExport=FAQ设置-导出
 _menu.faqPublish=FAQ设置-发布
 _menu.call_center=呼叫中心
 _menu.alittsconf=阿里云tts配置
+_menu.txtts1conf=腾讯云TTS配置
 _menu.ASRConf=语音识别配置
 _menu.TTSConf=语音合成配置
 _menu.funasrconf=FunASR配置
@@ -1943,6 +1945,7 @@ _cc.params.call-center-api-token=语音通知的访问Token
 _cc.params.outbound-max-line-number=全局参数; 可用的最大外呼并发数
 _cc.params.outbound-enable-prediction-algorithm=群呼转人工坐席的场景下,是否开启预测外呼算法
 _cc.params.aliyun-tts-account-json=阿里云tts账号参数json
+_cc.params.tx-tts1-account-json=腾讯云tts账号参数json
 _cc.params.empty-number-detection-enabled=空号识别功能是否开启
 _cc.params.empty-number-detection-config=空号识别定义
 _cc.params.default_interrupt_ignore_keywords=打断忽略关键字列表默认值

+ 3 - 0
ruoyi-admin/src/main/resources/static/i18n/messages_en.properties

@@ -217,6 +217,7 @@ switchconf.asr.chinatelecom.header=ChinaTele Parameter Configuration
 switchconf.tts.ali.header=Alibaba Cloud TTS Parameter Configuration
 switchconf.tts.doubao.header=Doubao TTS Parameter Configuration
 switchconf.tts.chinatelecom.header=ChinaTele TTS Parameter Configuration
+switchconf.tts.tx1.header=Tencent Cloud TTS Parameter Configuration
 switchconf.tts.xf.header=IFlytek TTS Parameter Configuration
 switchconf.asr.engine.header=ASR Engine Selection
 switchconf.asr.engine.option1=None
@@ -926,6 +927,7 @@ _menu.llmAccountEdit = Edit Large Model Configuration
 _menu.llmAccountDel = Delete Large Model Configuration
 _menu.inboundllm=Inbound Configuration
 _menu.call_center=Call Center
+_menu.txtts1conf=Tencent TTS Configuration
 _menu.doubaovcl=Doubao Voice Cloning
 _menu.doubaottsconf=Doubao TTS Configuration
 _menu.chinatelecomttsconf=ChinaTele TTS Configuration
@@ -1981,6 +1983,7 @@ _cc.params.call-center-api-token=Voice Notification Access Token
 _cc.params.outbound-max-line-number=Global Parameter - Max Outbound Concurrency
 _cc.params.outbound-enable-prediction-algorithm=Enable Predictive Dialing Algorithm for Batch Calls
 _cc.params.aliyun-tts-account-json=Aliyun TTS Account Parameters JSON
+_cc.params.tx-tts1-account-json=Tencent Cloud TTS Account Parameters JSON
 _cc.params.empty-number-detection-enabled=Enable Empty Number Detection
 _cc.params.empty-number-detection-config=Empty Number Detection Configuration
 _cc.params.default_interrupt_ignore_keywords=Default Interruption Ignore Keywords List

+ 3 - 0
ruoyi-admin/src/main/resources/static/i18n/messages_en_US.properties

@@ -217,6 +217,7 @@ switchconf.asr.chinatelecom.header=ChinaTele Parameter Configuration
 switchconf.tts.ali.header=Alibaba Cloud TTS Parameter Configuration
 switchconf.tts.doubao.header=Doubao TTS Parameter Configuration
 switchconf.tts.chinatelecom.header=ChinaTele TTS Parameter Configuration
+switchconf.tts.tx1.header=Tencent Cloud TTS Parameter Configuration
 switchconf.tts.xf.header=IFlytek TTS Parameter Configuration
 switchconf.asr.engine.header=ASR Engine Selection
 switchconf.asr.engine.option1=None
@@ -888,6 +889,7 @@ _menu.llmAccountEdit = Edit Large Model Configuration
 _menu.llmAccountDel = Delete Large Model Configuration
 _menu.inboundllm=Inbound Configuration
 _menu.call_center=Call Center
+_menu.txtts1conf=Tencent TTS Configuration
 _menu.doubaovcl=Doubao Voice Cloning
 _menu.doubaottsconf=Doubao TTS Configuration
 _menu.chinatelecomttsconf=ChinaTele TTS Configuration
@@ -1943,6 +1945,7 @@ _cc.params.call-center-api-token=Voice Notification Access Token
 _cc.params.outbound-max-line-number=Global Parameter - Max Outbound Concurrency
 _cc.params.outbound-enable-prediction-algorithm=Enable Predictive Dialing Algorithm for Batch Calls
 _cc.params.aliyun-tts-account-json=Aliyun TTS Account Parameters JSON
+_cc.params.tx-tts1-account-json=Tencent Cloud TTS Account Parameters JSON
 _cc.params.empty-number-detection-enabled=Enable Empty Number Detection
 _cc.params.empty-number-detection-config=Empty Number Detection Configuration
 _cc.params.default_interrupt_ignore_keywords=Default Interruption Ignore Keywords List

+ 3 - 0
ruoyi-admin/src/main/resources/static/i18n/messages_ja_JP.properties

@@ -217,6 +217,7 @@ switchconf.asr.chinatelecom.header=ChinaTele Parameter Configuration
 switchconf.tts.ali.header=Alibaba Cloud TTS Parameter Configuration
 switchconf.tts.doubao.header=Doubao TTS Parameter Configuration
 switchconf.tts.chinatelecom.header=ChinaTele TTS Parameter Configuration
+switchconf.tts.tx1.header=Tencent Cloud TTS Parameter Configuration
 switchconf.tts.xf.header=IFlytek TTS Parameter Configuration
 switchconf.asr.engine.header=ASR Engine Selection
 switchconf.asr.engine.option1=None
@@ -888,6 +889,7 @@ _menu.llmAccountEdit = Edit Large Model Configuration
 _menu.llmAccountDel = Delete Large Model Configuration
 _menu.inboundllm=Inbound Configuration
 _menu.call_center=Call Center
+_menu.txtts1conf=Tencent TTS Configuration
 _menu.doubaovcl=Doubao Voice Cloning
 _menu.doubaottsconf=Doubao TTS Configuration
 _menu.chinatelecomttsconf=ChinaTele TTS Configuration
@@ -1943,6 +1945,7 @@ _cc.params.call-center-api-token=Voice Notification Access Token
 _cc.params.outbound-max-line-number=Global Parameter - Max Outbound Concurrency
 _cc.params.outbound-enable-prediction-algorithm=Enable Predictive Dialing Algorithm for Batch Calls
 _cc.params.aliyun-tts-account-json=Aliyun TTS Account Parameters JSON
+_cc.params.tx-tts1-account-json=Tencent Cloud TTS Account Parameters JSON
 _cc.params.empty-number-detection-enabled=Enable Empty Number Detection
 _cc.params.empty-number-detection-config=Empty Number Detection Configuration
 _cc.params.default_interrupt_ignore_keywords=Default Interruption Ignore Keywords List

+ 3 - 0
ruoyi-admin/src/main/resources/static/i18n/messages_zh.properties

@@ -217,6 +217,7 @@ switchconf.asr.chinatelecom.header=电信ASR参数配置
 switchconf.tts.ali.header=阿里云tts参数配置
 switchconf.tts.doubao.header=豆包tts参数配置
 switchconf.tts.chinatelecom.header=电信tts参数配置
+switchconf.tts.tx1.header=腾讯云TTS参数配置
 switchconf.tts.xf.header=科大讯飞TTS参数配置
 switchconf.asr.engine.header=asr引擎选择
 switchconf.asr.engine.option1=无
@@ -907,6 +908,7 @@ _menu.faqExport=FAQ设置-导出
 _menu.faqPublish=FAQ设置-发布
 _menu.call_center=呼叫中心
 _menu.alittsconf=阿里云tts配置
+_menu.txtts1conf=腾讯云TTS配置
 _menu.ASRConf=语音识别配置
 _menu.TTSConf=语音合成配置
 _menu.funasrconf=FunASR配置
@@ -1981,6 +1983,7 @@ _cc.params.call-center-api-token=语音通知的访问Token
 _cc.params.outbound-max-line-number=全局参数; 可用的最大外呼并发数
 _cc.params.outbound-enable-prediction-algorithm=群呼转人工坐席的场景下,是否开启预测外呼算法
 _cc.params.aliyun-tts-account-json=阿里云tts账号参数json
+_cc.params.tx-tts1-account-json=腾讯云tts账号参数json
 _cc.params.empty-number-detection-enabled=空号识别功能是否开启
 _cc.params.empty-number-detection-config=空号识别定义
 _cc.params.default_interrupt_ignore_keywords=打断忽略关键字列表默认值

+ 3 - 0
ruoyi-admin/src/main/resources/static/i18n/messages_zh_CN.properties

@@ -217,6 +217,7 @@ switchconf.asr.chinatelecom.header=电信ASR参数配置
 switchconf.tts.ali.header=阿里云tts参数配置
 switchconf.tts.doubao.header=豆包tts参数配置
 switchconf.tts.chinatelecom.header=电信tts参数配置
+switchconf.tts.tx1.header=腾讯云TTS参数配置
 switchconf.tts.xf.header=科大讯飞TTS参数配置
 switchconf.asr.engine.header=asr引擎选择
 switchconf.asr.engine.option1=无
@@ -869,6 +870,7 @@ _menu.faqExport=FAQ设置-导出
 _menu.faqPublish=FAQ设置-发布
 _menu.call_center=呼叫中心
 _menu.alittsconf=阿里云tts配置
+_menu.txtts1conf=腾讯云TTS配置
 _menu.ASRConf=语音识别配置
 _menu.TTSConf=语音合成配置
 _menu.funasrconf=FunASR配置
@@ -1943,6 +1945,7 @@ _cc.params.call-center-api-token=语音通知的访问Token
 _cc.params.outbound-max-line-number=全局参数; 可用的最大外呼并发数
 _cc.params.outbound-enable-prediction-algorithm=群呼转人工坐席的场景下,是否开启预测外呼算法
 _cc.params.aliyun-tts-account-json=阿里云tts账号参数json
+_cc.params.tx-tts1-account-json=腾讯云tts账号参数json
 _cc.params.empty-number-detection-enabled=空号识别功能是否开启
 _cc.params.empty-number-detection-config=空号识别定义
 _cc.params.default_interrupt_ignore_keywords=打断忽略关键字列表默认值

+ 250 - 0
ruoyi-admin/src/main/resources/templates/aicall/txvoiceclone/voiceclone.html

@@ -0,0 +1,250 @@
+<!DOCTYPE html>
+<html lang="zh" xmlns:th="http://www.thymeleaf.org">
+<head>
+    <th:block th:include="include :: header('腾讯云音色克隆')" />
+</head>
+<body class="gray-bg">
+<div class="container-div">
+    <div class="row">
+        <div class="col-sm-12 search-collapse">
+            <pre><strong>腾讯云音色克隆注意事项:</strong>
+1. 本页面复用“腾讯云TTS配置”中的 SecretId、SecretKey、ResourceId。
+2. 按腾讯云 SyncDubbing 文档的“示例3 音色克隆自定义属性”调用,同步返回可用 VoiceId。
+3. 上传时会同时传入 VoiceProfile,支持自定义名称、性别、年龄、语言、描述。
+4. 建议上传 5-20 秒、单人单声道、尽量干净的 wav/mp3/m4a 音频。
+5. 克隆成功后会自动把 VoiceId 写入音色管理,voice_source=tx_tts1,可直接用于外呼任务。
+6. 如需只截取音频中的一小段做克隆,可填写开始/结束秒数,留空则默认取前 20 秒。</pre>
+        </div>
+
+        <div class="col-sm-12 search-collapse">
+            <form id="cloneForm">
+                <div class="select-list">
+                    <ul>
+                        <li>
+                            <label>音色名称:</label>
+                            <input type="text" id="voice_name" name="voice_name" />
+                        </li>
+                        <li>
+                            <label>音频语言:</label>
+                            <select id="audio_lang" name="audio_lang">
+                                <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="female">女声</option>
+                                <option value="male">男声</option>
+                            </select>
+                        </li>
+                        <li>
+                            <label>年龄:</label>
+                            <select id="age" name="age">
+                                <option value="">默认</option>
+                                <option value="child">儿童</option>
+                                <option value="youth">青年</option>
+                                <option value="middle">中年</option>
+                                <option value="old">老年</option>
+                            </select>
+                        </li>
+                        <li>
+                            <label>开始秒数:</label>
+                            <input type="number" step="0.1" min="0" id="range_start" name="range_start" placeholder="可留空" />
+                        </li>
+                        <li>
+                            <label>结束秒数:</label>
+                            <input type="number" step="0.1" min="0" id="range_end" name="range_end" placeholder="可留空" />
+                        </li>
+                        <li style="width: 100%">
+                            <label>音色描述:</label>
+                            <input type="text" id="description" name="description" style="width: 520px" placeholder="可选,例如:温柔亲切的客服女声" />
+                        </li>
+                        <li>
+                            <a class="btn btn-info btn-xs" href="javascript:void(0)" onclick="$.operate.importFile()">
+                                <i class="fa fa-upload"></i> &nbsp;上传音频并克隆
+                            </a>
+                        </li>
+                    </ul>
+                </div>
+            </form>
+        </div>
+
+        <div class="col-sm-12 search-collapse">
+            <form id="resultForm">
+                <div class="select-list">
+                    <ul>
+                        <li style="width: 100%">
+                            <label>返回 VoiceId:</label>
+                            <input type="text" id="voiceId" name="voiceId" style="width: 700px" />
+                        </li>
+                        <li>
+                            <label>音色名称:</label>
+                            <input type="text" id="savedVoiceName" readonly="readonly" />
+                        </li>
+                        <li>
+                            <label>RequestId:</label>
+                            <input type="text" id="requestId" readonly="readonly" />
+                        </li>
+                    </ul>
+                </div>
+            </form>
+        </div>
+
+        <div class="col-sm-12 search-collapse" id="tts_area" style="display: none">
+            <form id="ttsForm">
+                <div class="select-list">
+                    <ul>
+                        <li>
+                            <label>测试语言:</label>
+                            <select id="text_lang" name="text_lang">
+                                <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 style="width: 100%">
+                            <label>测试文本:</label>
+                            <textarea id="tts_text" name="tts_text" cols="80" rows="4" placeholder="请输入在线试听文本"></textarea>
+                        </li>
+                        <li>
+                            <a class="btn btn-primary btn-rounded btn-sm" href="javascript:void(0)" onclick="$.operate.ttsTest()">
+                                <i class="fa fa-volume-up"></i> &nbsp;在线试听
+                            </a>
+                        </li>
+                        <li style="width: 100%">
+                            <audio id="test_player" controls="controls" style="width: 520px; display: none"></audio>
+                        </li>
+                    </ul>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+
+<th:block th:include="include :: footer" />
+<script th:inline="javascript">
+    var prefix = ctx + "aicall/txvoiceclone";
+
+    $.operate.importFile = function() {
+        var voiceName = $("#voice_name").val().trim();
+        if (voiceName === "") {
+            $.modal.msgError("请先填写音色名称");
+            $("#voice_name")[0].focus();
+            return;
+        }
+
+        var rangeStart = $("#range_start").val().trim();
+        var rangeEnd = $("#range_end").val().trim();
+        if (rangeStart !== "" && rangeEnd !== "" && parseFloat(rangeEnd) <= parseFloat(rangeStart)) {
+            $.modal.msgError("结束秒数必须大于开始秒数");
+            return;
+        }
+
+        var input = document.createElement('input');
+        input.type = 'file';
+        input.accept = '.wav,.mp3,.m4a,.aac,.ogg';
+        input.onchange = function(event) {
+            var file = event.target.files[0];
+            if (!file) {
+                return;
+            }
+            var formData = new FormData();
+            formData.append('file', file);
+            formData.append('voice_name', voiceName);
+            formData.append('audio_lang', $("#audio_lang").val());
+            formData.append('gender', $("#gender").val());
+            formData.append('age', $("#age").val());
+            formData.append('description', $("#description").val().trim());
+            formData.append('range_start', rangeStart);
+            formData.append('range_end', rangeEnd);
+
+            $.modal.msg("正在上传音频并调用腾讯云克隆,请稍后...");
+            $.ajax({
+                url: prefix + "/uploadAndClone",
+                type: "POST",
+                data: formData,
+                processData: false,
+                contentType: false,
+                success: function(respJson) {
+                    if (respJson.code !== 0) {
+                        $.modal.msgError(respJson.msg || "腾讯音色克隆失败");
+                        return;
+                    }
+                    var data = respJson.data || {};
+                    $("#voiceId").val(data.voiceId || "");
+                    $("#savedVoiceName").val(data.voiceName || "");
+                    $("#requestId").val(data.requestId || "");
+                    $("#tts_area").css("display", "block");
+                    $.modal.msgSuccess(respJson.msg || "腾讯音色克隆成功");
+                },
+                error: function() {
+                    $.modal.msgError("腾讯音色克隆失败");
+                }
+            });
+        };
+        input.click();
+    };
+
+    $.operate.ttsTest = function() {
+        if ($.operate.ttsTest.processing === true) {
+            $.modal.msg("请等待当前试听完成");
+            return;
+        }
+
+        var voiceId = $("#voiceId").val().trim();
+        if (voiceId === "") {
+            $.modal.msgError("请先完成音色克隆并拿到 VoiceId");
+            $("#voiceId")[0].focus();
+            return;
+        }
+
+        var text = $("#tts_text").val().trim();
+        if (text === "") {
+            $.modal.msgError("请输入测试文本");
+            $("#tts_text")[0].focus();
+            return;
+        }
+
+        $.operate.ttsTest.processing = true;
+        var formData = new FormData();
+        formData.append('voiceId', voiceId);
+        formData.append('text', text);
+        formData.append('text_lang', $("#text_lang").val());
+        $.ajax({
+            url: prefix + "/ttsTest",
+            type: "POST",
+            data: formData,
+            processData: false,
+            contentType: false,
+            success: function(respJson) {
+                if (respJson.code !== 0) {
+                    $.modal.msgError(respJson.msg || "腾讯在线试听失败");
+                    return;
+                }
+                var data = respJson.data || {};
+                var player = $("#test_player");
+                var audioSrc = 'data:' + (data.audioMime || 'audio/wav') + ';base64,' + data.audioBase64;
+                player.css("display", "block");
+                player.attr("src", audioSrc);
+                player[0].play();
+                $.modal.msgSuccess(respJson.msg || "腾讯在线试听成功");
+            },
+            complete: function() {
+                $.operate.ttsTest.processing = false;
+            },
+            error: function() {
+                $.operate.ttsTest.processing = false;
+                $.modal.msgError("腾讯在线试听失败");
+            }
+        });
+    };
+</script>
+</body>
+</html>

+ 59 - 2
ruoyi-admin/src/main/resources/templates/cc/txasr1bridgeconf/txasr1bridgeconf.html

@@ -11,6 +11,9 @@
 <body>
 <div class="main-content">
     <div class="h4 form-header" th:text="#{switchconf.asr.tx1.header}"></div>
+    <div class="alert alert-info">
+        `tx_asr1` 采样率与音频格式需保持和腾讯 DEMO 一致:`16000 Hz -> packet-format=1`,`8000 Hz -> packet-format=2`。
+    </div>
     <div id="baseConfigs"></div>
 
     <div class="row"></div>
@@ -25,6 +28,54 @@
 <th:block th:include="include :: layout-latest-js" />
 <script>
     var prefix = ctx + "cc/fsconf";
+
+    function buildConfigInput(config) {
+        if (config.name === 'sample-rate') {
+            return '<select class="config-value form-control" id="' + config.name + '">' +
+                '<option value="16000"' + (config.value === '16000' ? ' selected' : '') + '>16000 Hz</option>' +
+                '<option value="8000"' + (config.value === '8000' ? ' selected' : '') + '>8000 Hz</option>' +
+                '</select>' +
+                '<small class="text-muted">腾讯 DEMO 最新约定:16K 音频使用 format=1,8K 音频使用 format=2。</small>';
+        }
+        if (config.name === 'packet-format') {
+            return '<select class="config-value form-control" id="' + config.name + '">' +
+                '<option value="1"' + (config.value === '1' ? ' selected' : '') + '>1 (16K)</option>' +
+                '<option value="2"' + (config.value === '2' ? ' selected' : '') + '>2 (8K)</option>' +
+                '</select>' +
+                '<small class="text-muted">建议不要手工改错,保存时会校验并和采样率联动。</small>';
+        }
+        return '<input class="config-value form-control" type="text" id="' + config.name + '" value="' + config.value + '" />';
+    }
+
+    function syncPacketFormatBySampleRate() {
+        var sampleRate = $('#sample-rate').val();
+        if (!sampleRate || $('#packet-format').length === 0) {
+            return;
+        }
+        if (sampleRate === '16000') {
+            $('#packet-format').val('1');
+        } else if (sampleRate === '8000') {
+            $('#packet-format').val('2');
+        }
+    }
+
+    function validateTxAsr1FormatMapping() {
+        var sampleRate = $('#sample-rate').val();
+        var packetFormat = $('#packet-format').val();
+        if (!sampleRate || !packetFormat) {
+            return true;
+        }
+        if (sampleRate === '16000' && packetFormat !== '1') {
+            $.modal.msgError('腾讯 ASR1 配置不匹配:16000 Hz 必须对应 packet-format=1');
+            return false;
+        }
+        if (sampleRate === '8000' && packetFormat !== '2') {
+            $.modal.msgError('腾讯 ASR1 配置不匹配:8000 Hz 必须对应 packet-format=2');
+            return false;
+        }
+        return true;
+    }
+
     $(function() {
         $.ajax({
             url: prefix + '/getTxBridgeAsr1Conf',
@@ -36,11 +87,13 @@
                 $.each(data, function(index, config) {
                     baseConfigsHtml += '<div className="row">';
                     baseConfigsHtml += '<div class="col-sm-12">';
-                    baseConfigsHtml += '<label class="col-sm-2 control-label">' + config.aliasName + '</label><div class="col-sm-10"><input class="config-value form-control" type="text" id="' + config.name + '" value="' + config.value + '" /></div>';
+                    baseConfigsHtml += '<label class="col-sm-2 control-label">' + config.aliasName + '</label><div class="col-sm-10">' + buildConfigInput(config) + '</div>';
                     baseConfigsHtml += '</div>';
                     baseConfigsHtml += '</div>';
                 });
                 $('#baseConfigs').html(baseConfigsHtml);
+                syncPacketFormatBySampleRate();
+                $('#sample-rate').on('change', syncPacketFormatBySampleRate);
             },
             error: function(error) {
                 console.error('Error fetching configuration:', error);
@@ -49,8 +102,12 @@
     });
 
     $('#saveConfig').click(function() {
+        syncPacketFormatBySampleRate();
+        if (!validateTxAsr1FormatMapping()) {
+            return;
+        }
         var configs = [];
-        $('input.config-value').each(function() {
+        $('.config-value').each(function() {
             var config = {
                 name: $(this).attr('id'),
                 value: $(this).val().trim()

+ 80 - 0
ruoyi-admin/src/main/resources/templates/cc/txtts1conf/txtts1conf.html

@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<html lang="zh" xmlns:th="http://www.thymeleaf.org" xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">
+<head>
+    <th:block th:include="include :: header('腾讯云TTS参数配置')" />
+    <th:block th:include="include :: layout-latest-css" />
+    <th:block th:include="include :: ztree-css" />
+    <style>
+    </style>
+</head>
+
+<body>
+<div class="main-content">
+    <div class="h4 form-header" th:text="#{switchconf.tts.tx1.header}"></div>
+    <div id="baseConfigs"></div>
+
+    <div class="row"></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><span th:text="#{switchconf.btn.save}"></span></button>&nbsp;
+        </div>
+    </div>
+</div>
+
+<th:block th:include="include :: footer" />
+<th:block th:include="include :: layout-latest-js" />
+<script>
+    var prefix = ctx + "cc/fsconf";
+    $(function() {
+        $.ajax({
+            url: prefix + '/getTxTts1Conf',
+            type: 'GET',
+            success: function(_data) {
+                var baseConfigsHtml = '';
+                var data = _data.data;
+
+                $.each(data, function(index, config) {
+                    baseConfigsHtml += '<div className="row">';
+                    baseConfigsHtml += '<div class="col-sm-12">';
+                    baseConfigsHtml += '<label class="col-sm-2 control-label">' + config.aliasName + '</label><div class="col-sm-10"><input class="config-value form-control" type="text" id="' + config.name + '" value="' + config.value + '" /></div>';
+                    baseConfigsHtml += '</div>';
+                    baseConfigsHtml += '</div>';
+                });
+                $('#baseConfigs').html(baseConfigsHtml);
+            },
+            error: function(error) {
+                console.error('Error fetching configuration:', error);
+            }
+        });
+    });
+
+    $('#saveConfig').click(function() {
+        var configs = [];
+        $('input.config-value').each(function() {
+            var config = {
+                name: $(this).attr('id'),
+                value: $(this).val().trim()
+            };
+            configs.push(config);
+        });
+        $.ajax({
+            url: prefix + '/setTxTts1Conf',
+            type: 'POST',
+            contentType: 'application/json',
+            data: JSON.stringify(configs),
+            beforeSend: function () {
+                $.modal.loading(i18n("common.tip.loading"));
+                $.modal.disable();
+            },
+            success: function(response) {
+                processAjaxReponseJson(response);
+            },
+            error: function(error) {
+            }
+        });
+    });
+
+</script>
+
+</body>
+</html>

+ 0 - 17
sql/v20260602.sql

@@ -1,17 +0,0 @@
--- 2026-06-02
-DELETE FROM `sys_config`
-WHERE `config_key` IN ('config_tts_provider_xfyun', 'config_tts_language_xf_tts');
-
-INSERT INTO `sys_config` (`config_id`, `config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
-VALUES (136, '科大讯飞', 'config_tts_provider_xfyun', 'xf_tts', 'Y', 'admin', NOW(), '', NULL, 'TTS厂商-科大讯飞');
-
-INSERT INTO `sys_config` (`config_id`, `config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
-VALUES (145, '讯飞tts语言设置', 'config_tts_language_xf_tts', '{"zh-CN":"中文"}', 'Y', 'admin', NOW(), '', NULL, '讯飞tts语言设置');
-
-DELETE FROM `cc_tts_aliyun`
-WHERE `voice_source` = 'xf_tts';
-
-INSERT INTO `cc_tts_aliyun`
-(`voice_name`, `voice_code`, `voice_enabled`, `voice_source`, `priority`, `provider`, `language_code`, `language_name`, `tts_models`)
-VALUES
-('科大讯飞-小燕', 'x4_xiaoyan', 1, 'xf_tts', 1, 'xfyun', 'zh-CN', '中文', 'standard');

+ 233 - 0
sql/v20260616_tx_asr_full.sql

@@ -0,0 +1,233 @@
+-- 2026-06-16
+-- Tencent ASR (mod_tx_asr) full SQL.
+-- Based on current database inspection:
+-- 1. sys_config 已存在 config_asr_provider_tx
+-- 2. cc_asr_languages 已存在 provider=tx 数据
+-- 3. fs_variables 已存在部分共享字段,但缺少若干 tx_asr 专属参数
+-- 4. sys_menu 中缺少 tx_asr 配置菜单,需要补齐
+
+START TRANSACTION;
+
+-- 1) ASR provider 配置项
+INSERT INTO `sys_config` (`config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `remark`)
+SELECT '腾讯ASR', 'config_asr_provider_tx', 'tx', 'Y', 'admin', NOW(), 'ASR厂商-mod_tx_asr'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `sys_config` WHERE `config_key` = 'config_asr_provider_tx'
+);
+
+UPDATE `sys_config`
+SET `config_name` = '腾讯ASR',
+    `config_value` = 'tx',
+    `config_type` = 'Y',
+    `remark` = 'ASR厂商-mod_tx_asr'
+WHERE `config_key` = 'config_asr_provider_tx';
+
+-- 2) 腾讯 ASR 配置菜单
+UPDATE `sys_menu`
+SET `menu_name` = '腾讯ASR配置',
+    `menu_code` = 'txAsrBridgeConf',
+    `parent_id` = 3018,
+    `order_num` = 8,
+    `url` = '/cc/fsconf/txasrbridgeconf',
+    `target` = 'menuItem',
+    `menu_type` = 'C',
+    `visible` = '0',
+    `is_refresh` = '1',
+    `perms` = 'cc:txasrbridgeconf:view',
+    `icon` = '#',
+    `update_by` = 'admin',
+    `update_time` = NOW(),
+    `remark` = 'mod_tx_asr 参数配置菜单'
+WHERE `perms` = 'cc:txasrbridgeconf:view'
+   OR `url` = '/cc/fsconf/txasrbridgeconf'
+   OR `menu_name` = '腾讯ASR配置';
+
+INSERT INTO `sys_menu` (`menu_name`, `menu_code`, `parent_id`, `order_num`, `url`, `target`, `menu_type`, `visible`, `is_refresh`, `perms`, `icon`, `create_by`, `create_time`, `remark`)
+SELECT '腾讯ASR配置', 'txAsrBridgeConf', 3018, 8, '/cc/fsconf/txasrbridgeconf', 'menuItem', 'C', '0', '1', 'cc:txasrbridgeconf:view', '#', 'admin', NOW(), 'mod_tx_asr 参数配置菜单'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1
+    FROM `sys_menu`
+    WHERE `perms` = 'cc:txasrbridgeconf:view'
+       OR `url` = '/cc/fsconf/txasrbridgeconf'
+);
+
+SET @tx_asr_menu_id := (
+    SELECT `menu_id`
+    FROM `sys_menu`
+    WHERE `perms` = 'cc:txasrbridgeconf:view'
+       OR `url` = '/cc/fsconf/txasrbridgeconf'
+    ORDER BY `menu_id` DESC
+    LIMIT 1
+);
+
+INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
+SELECT 2, @tx_asr_menu_id
+FROM DUAL
+WHERE @tx_asr_menu_id IS NOT NULL
+  AND NOT EXISTS (
+      SELECT 1
+      FROM `sys_role_menu`
+      WHERE `role_id` = 2
+        AND `menu_id` = @tx_asr_menu_id
+  );
+
+-- 3) fs_variables:共享字段缺了就补,已存在则保留
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'appid', '腾讯 ASR AppID'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'appid'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'secret-id', '腾讯 ASR SecretId'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'secret-id'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'secret-key', '腾讯 ASR SecretKey'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'secret-key'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'websocket-host', 'WebSocket Host'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'websocket-host'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'engine-model-type', '引擎模型'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'engine-model-type'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'voice-format', '音频编码格式'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'voice-format'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'need-vad', '启用 VAD'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'need-vad'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'filter-dirty', '脏词过滤'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'filter-dirty'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'filter-modal', '语气词过滤'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'filter-modal'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'filter-punc', '标点过滤'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'filter-punc'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'filter-empty-result', '过滤空识别结果'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'filter-empty-result'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'convert-num-mode', '数字转换模式'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'convert-num-mode'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'word-info', '词级时间戳'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'word-info'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'vad-silence-time', '句尾静音时长(毫秒)'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'vad-silence-time'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'max-speak-time', '强制断句时长(毫秒)'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'max-speak-time'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'hotword-id', '热词表 ID'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'hotword-id'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'hotword-list', '临时热词表'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'hotword-list'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'customization-id', '自学习模型 ID'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'customization-id'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'noise-threshold', '噪音阈值'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'noise-threshold'
+);
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`)
+SELECT 5, 'signature-expire-seconds', '签名有效期(秒)'
+FROM DUAL
+WHERE NOT EXISTS (
+    SELECT 1 FROM `fs_variables` WHERE `cat` = 5 AND `var_field_name` = 'signature-expire-seconds'
+);
+
+-- 4) 腾讯 realtime ASR 模型/语言
+DELETE FROM `cc_asr_languages`
+WHERE `asr_provider` = 'tx';
+
+INSERT INTO `cc_asr_languages` (`asr_provider`, `models`, `language_code`, `language_name`) VALUES
+('tx', '8k_zh', 'zh-CN', '中文(8k)'),
+('tx', '16k_zh', 'zh-CN', '中文(16k)'),
+('tx', '8k_en', 'en-US', '英文(8k)'),
+('tx', '16k_en', 'en-US', '英文(16k)'),
+('tx', '16k_yue', 'zh-HK', '粤语');
+
+-- 5) 双向ASR默认引擎描述补齐
+UPDATE `cc_params`
+SET `param_name` = '双向asr语音识别,使用哪个asr引擎(chinatelecom/funasr/aliyun/ali/tx/tx1)'
+WHERE `param_code` = 'fs_call_asr_engine';
+
+COMMIT;

+ 63 - 0
sql/v20260616_tx_tts1.sql

@@ -0,0 +1,63 @@
+-- 2026-06-16
+-- Add Tencent Cloud TTS bridge configuration for mod_tx_tts1.
+
+DELETE FROM `sys_role_menu`
+WHERE `menu_id` = 4027;
+
+DELETE FROM `sys_menu`
+WHERE `menu_id` = 4027
+   OR `perms` = 'cc:txtts1conf:view'
+   OR `url` = '/cc/fsconf/txtts1conf';
+
+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 (4027, '腾讯云TTS配置', 'txtts1conf', 3019, 9, '/cc/fsconf/txtts1conf', 'menuItem', 'C', '0', '1', 'cc:txtts1conf:view', '#', 'admin', NOW(), 'mod_tx_tts1 参数配置菜单');
+
+INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES (2, 4027);
+
+DELETE FROM `sys_config`
+WHERE `config_key` = 'config_tts_provider_tx_tts1';
+
+INSERT INTO `sys_config` (`config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `remark`)
+VALUES ('腾讯云', 'config_tts_provider_tx_tts1', 'tx_tts1', 'Y', 'admin', NOW(), 'TTS厂商-腾讯云');
+
+DELETE FROM `cc_params`
+WHERE `param_code` = 'tx-tts1-account-json';
+
+INSERT INTO `cc_params` (`param_name`, `param_code`, `param_value`, `param_type`, `hide_value`)
+VALUES ('腾讯云tts账号参数json', 'tx-tts1-account-json', '{"secret-id":"","secret-key":"","voice-id":"","endpoint":"mps.tencentcloudapi.com","version":"2019-06-12","action":"SyncDubbing","region":"","text-lang":"zh","resource-id":"","sample-rate":"8000","pitch":"0","connect-timeout-ms":"10000","verify-peer":"false"}', 'tts', 1);
+
+DELETE FROM `fs_variables`
+WHERE `cat` = 5
+  AND `var_field_name` IN (
+    'secret-id',
+    'secret-key',
+    'voice-id',
+    'endpoint',
+    'version',
+    'action',
+    'region',
+    'text-lang',
+    'resource-id',
+    'sample-rate',
+    'pitch',
+    'connect-timeout-ms',
+    'verify-peer'
+  );
+
+INSERT INTO `fs_variables` (`cat`, `var_field_name`, `var_field_alias`) VALUES
+(5, 'secret-id', 'SecretId'),
+(5, 'secret-key', 'SecretKey'),
+(5, 'voice-id', '音色ID'),
+(5, 'endpoint', '接口域名'),
+(5, 'version', '接口版本'),
+(5, 'action', '接口动作'),
+(5, 'region', '地域'),
+(5, 'text-lang', '文本语言'),
+(5, 'resource-id', '资源ID'),
+(5, 'sample-rate', '采样率'),
+(5, 'pitch', '音调'),
+(5, 'connect-timeout-ms', '连接超时(毫秒)'),
+(5, 'verify-peer', '校验证书');
+
+-- 音色列表请在音色管理中维护 `voice_source=tx_tts1` 的系统音色或克隆音色。

+ 27 - 0
sql/v20260616_tx_voiceclone.sql

@@ -0,0 +1,27 @@
+-- 2026-06-16
+-- Add Tencent Cloud voice clone menu and permissions.
+
+DELETE FROM `sys_role_menu`
+WHERE `menu_id` IN (4028, 4029, 4030);
+
+DELETE FROM `sys_menu`
+WHERE `menu_id` IN (4028, 4029, 4030)
+   OR `perms` IN ('aicall:txvoiceclone:view', 'aicall:txvoiceclone:uploadAndClone', 'aicall:txvoiceclone:ttsTest')
+   OR `url` = '/aicall/txvoiceclone/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
+(4028, '腾讯云音色克隆', 'txvoiceclone', 3019, 10, '/aicall/txvoiceclone/voiceclone', 'menuItem', 'C', '0', '1', 'aicall:txvoiceclone:view', 'fa fa-microphone', 'admin', NOW(), '腾讯云音色克隆工具');
+
+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
+(4029, '腾讯云音色克隆上传', 'txvoicecloneUpload', 4028, 1, '#', '', 'F', '0', '1', 'aicall:txvoiceclone:uploadAndClone', '#', 'admin', NOW(), '腾讯云音色克隆上传权限'),
+(4030, '腾讯云音色克隆试听', 'txvoicecloneTtsTest', 4028, 2, '#', '', 'F', '0', '1', 'aicall:txvoiceclone:ttsTest', '#', 'admin', NOW(), '腾讯云音色克隆试听权限');
+
+INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES
+(2, 4028),
+(2, 4029),
+(2, 4030);