yzx hai 4 días
pai
achega
70802a5a08

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

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

+ 587 - 0
ruoyi-admin/src/main/java/com/ruoyi/aicall/controller/XfVoiceCloneController.java

@@ -0,0 +1,587 @@
+package com.ruoyi.aicall.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+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.MultipartBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.WebSocket;
+import okhttp3.WebSocketListener;
+import okio.Buffer;
+import okio.ByteString;
+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.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * 科大讯飞声音克隆工具
+ *
+ * @author ruoyi
+ * @date 2026-06-01
+ */
+@Controller
+@RequestMapping("/aicall/xfvoiceclone")
+@Slf4j
+public class XfVoiceCloneController extends BaseController {
+    private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
+    private static final MediaType OCTET_STREAM = MediaType.parse("application/octet-stream");
+    private static final String TOKEN_URL = "http://avatar-hci.xfyousheng.com/aiauth/v1/token";
+    private static final String TRAIN_TEXT_URL = "http://opentrain.xfyousheng.com/voice_train/task/traintext";
+    private static final String TASK_ADD_URL = "http://opentrain.xfyousheng.com/voice_train/task/add";
+    private static final String TASK_SUBMIT_WITH_AUDIO_URL = "http://opentrain.xfyousheng.com/voice_train/task/submitWithAudio";
+    private static final String TASK_RESULT_URL = "http://opentrain.xfyousheng.com/voice_train/task/result";
+    private static final String CLONE_TTS_WS_URL = "wss://cn-huabei-1.xf-yun.com/v1/private/voice_clone";
+    private static final String CLONE_TTS_HOST = "cn-huabei-1.xf-yun.com";
+    private static final String CLONE_TTS_PATH = "/v1/private/voice_clone";
+    private static final Long DEFAULT_TEXT_ID = 5001L;
+    private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder()
+            .connectTimeout(30, TimeUnit.SECONDS)
+            .readTimeout(60, TimeUnit.SECONDS)
+            .writeTimeout(60, TimeUnit.SECONDS)
+            .build();
+    private final String prefix = "aicall/xfvoiceclone";
+
+    @Autowired
+    private IFsConfService fsConfService;
+
+    @RequiresPermissions("aicall:xfvoiceclone:view")
+    @GetMapping("voiceclone")
+    public String voiceClone() {
+        return prefix + "/voiceclone";
+    }
+
+    @GetMapping("/getTrainTexts")
+    @ResponseBody
+    public AjaxResult getTrainTexts() {
+        try {
+            JSONObject account = loadCloneAccount();
+            String token = fetchAccessToken(account);
+            JSONArray textSegs = loadTrainTextsInternal(account, token);
+            return AjaxResult.success("success", textSegs);
+        } catch (Exception e) {
+            log.error("getTrainTexts error", e);
+            return AjaxResult.error("获取训练文本失败:\n" + e.getMessage());
+        }
+    }
+
+    @RequiresPermissions("aicall:xfvoiceclone:uploadAndTrain")
+    @Log(title = "讯飞声音克隆-上传训练", businessType = BusinessType.IMPORT)
+    @PostMapping("/uploadAndTrain")
+    @ResponseBody
+    public AjaxResult uploadAndTrain(HttpServletRequest request, @RequestParam("file") MultipartFile file) {
+        try {
+            if (file == null || file.isEmpty()) {
+                return AjaxResult.error("请先选择录音文件");
+            }
+
+            JSONObject account = loadCloneAccount();
+            String token = fetchAccessToken(account);
+
+            String voiceName = request.getParameter("voice_name");
+            String taskName = defaultIfBlank(request.getParameter("task_name"), voiceName);
+            String resourceName = defaultIfBlank(request.getParameter("resource_name"), voiceName);
+            String language = normalizeLanguage(request.getParameter("language"));
+            String engineVersion = trimToEmpty(request.getParameter("engine_version"));
+            int sex = parseIntOrDefault(request.getParameter("sex"), 1);
+            int ageGroup = parseIntOrDefault(request.getParameter("age_group"), 2);
+            float mosRatio = parseFloatOrDefault(request.getParameter("mos_ratio"), 0.0F);
+            int denoise = parseIntOrDefault(request.getParameter("denoise"), 0);
+            String textSegId = request.getParameter("text_seg_id");
+
+            if (StringUtils.isBlank(voiceName)) {
+                return AjaxResult.error("请填写音色名称");
+            }
+            if (StringUtils.isBlank(textSegId)) {
+                return AjaxResult.error("请选择训练文本");
+            }
+
+            String taskId = createTrainTask(account, token, taskName, resourceName, language, sex, ageGroup, mosRatio, denoise, engineVersion);
+            submitWithAudio(account, token, taskId, textSegId, mosRatio, file);
+            JSONObject taskResult = queryTaskResultInternal(account, token, taskId);
+
+            JSONObject data = new JSONObject(true);
+            data.put("taskId", taskId);
+            data.put("voiceName", voiceName);
+            data.put("language", language);
+            data.put("engineVersion", engineVersion);
+            if (taskResult != null) {
+                data.put("assetId", taskResult.getString("assetId"));
+                data.put("trainVid", taskResult.getString("trainVid"));
+                data.put("trainStatus", taskResult.getInteger("trainStatus"));
+                data.put("failedDesc", taskResult.getString("failedDesc"));
+            }
+
+            String tips = buildTrainStatusTips(taskResult);
+            return AjaxResult.success("训练请求已提交。\n" + tips, data);
+        } catch (Exception e) {
+            log.error("uploadAndTrain error", e);
+            return AjaxResult.error("训练失败:\n" + e.getMessage());
+        }
+    }
+
+    @PostMapping("/queryTrainResult")
+    @ResponseBody
+    public AjaxResult queryTrainResult(@RequestParam("taskId") String taskId) {
+        try {
+            if (StringUtils.isBlank(taskId)) {
+                return AjaxResult.error("taskId不能为空");
+            }
+            JSONObject account = loadCloneAccount();
+            String token = fetchAccessToken(account);
+            JSONObject taskResult = queryTaskResultInternal(account, token, taskId);
+            return AjaxResult.success(buildTrainStatusTips(taskResult), taskResult);
+        } catch (Exception e) {
+            log.error("queryTrainResult error", e);
+            return AjaxResult.error("查询训练状态失败:\n" + e.getMessage());
+        }
+    }
+
+    @RequiresPermissions("aicall:xfvoiceclone:ttsTest")
+    @PostMapping("/ttsTest")
+    @ResponseBody
+    public AjaxResult ttsTest(HttpServletRequest request) {
+        try {
+            String assetId = request.getParameter("assetId");
+            String text = request.getParameter("text");
+            String engineVersion = trimToEmpty(request.getParameter("engine_version"));
+            if (StringUtils.isBlank(assetId)) {
+                return AjaxResult.error("请先完成训练并获取音色ID");
+            }
+            if (StringUtils.isBlank(text)) {
+                return AjaxResult.error("请输入测试文本");
+            }
+
+            JSONObject account = loadCloneAccount();
+            String audioBase64 = synthesizeCloneAudio(account, assetId, text, engineVersion);
+            JSONObject data = new JSONObject(true);
+            data.put("audioBase64", audioBase64);
+            data.put("audioMime", "audio/mp3");
+            return AjaxResult.success("试听合成成功", data);
+        } catch (Exception e) {
+            log.error("ttsTest error", e);
+            return AjaxResult.error("试听合成失败:\n" + e.getMessage());
+        }
+    }
+
+    private JSONObject loadCloneAccount() {
+        JSONObject conf = fsConfService.getAsrConf("/autoload_configs/xf_tts.conf.xml");
+        String appId = trimToEmpty(conf.getString("app-id"));
+        String apiKey = trimToEmpty(conf.getString("api-key"));
+        String apiSecret = trimToEmpty(conf.getString("api-secret"));
+        if (StringUtils.isBlank(appId) || StringUtils.isBlank(apiKey) || StringUtils.isBlank(apiSecret)) {
+            throw new IllegalStateException("请先在“科大讯飞TTS配置”中填写 app-id、api-key、api-secret。注意这里必须使用已开通“讯飞一句话复刻”服务的应用凭证。");
+        }
+
+        JSONObject account = new JSONObject(true);
+        account.put("appId", appId);
+        account.put("apiKey", apiKey);
+        account.put("apiSecret", apiSecret);
+        return account;
+    }
+
+    private String fetchAccessToken(JSONObject account) throws IOException {
+        long timestamp = System.currentTimeMillis();
+        JSONObject base = new JSONObject(true);
+        base.put("appid", account.getString("appId"));
+        base.put("version", "v1");
+        base.put("timestamp", String.valueOf(timestamp));
+
+        JSONObject body = new JSONObject(true);
+        body.put("base", base);
+        body.put("model", "remote");
+        String bodyText = body.toJSONString();
+
+        String keySign = md5Hex(account.getString("apiKey") + timestamp);
+        String sign = md5Hex(keySign + bodyText);
+        Map<String, String> headers = new LinkedHashMap<>();
+        headers.put("Content-Type", "application/json");
+        headers.put("Authorization", sign);
+
+        JSONObject responseJson = postJson(TOKEN_URL, body, headers);
+        if (!"000000".equals(responseJson.getString("retcode"))) {
+            throw new IOException("讯飞鉴权失败: " + responseJson.toJSONString());
+        }
+        return responseJson.getString("accesstoken");
+    }
+
+    private JSONArray loadTrainTextsInternal(JSONObject account, String token) throws IOException {
+        JSONObject body = new JSONObject(true);
+        body.put("textId", DEFAULT_TEXT_ID);
+        JSONObject responseJson = postJson(TRAIN_TEXT_URL, body,
+                buildVoiceTrainHeaders(account.getString("apiKey"), account.getString("appId"), token, body.toJSONString()));
+        if (responseJson.getIntValue("code") != 0) {
+            throw new IOException("获取训练文本失败: " + responseJson.toJSONString());
+        }
+        JSONObject data = responseJson.getJSONObject("data");
+        return data == null ? new JSONArray() : data.getJSONArray("textSegs");
+    }
+
+    private String createTrainTask(JSONObject account, String token, String taskName, String resourceName,
+                                   String language, int sex, int ageGroup, float mosRatio,
+                                   int denoise, String engineVersion) throws IOException {
+        JSONObject body = new JSONObject(true);
+        body.put("taskName", taskName);
+        body.put("sex", sex);
+        body.put("ageGroup", ageGroup);
+        body.put("resourceType", 12);
+        body.put("thirdUser", "easycallcenter365");
+        body.put("denoise", denoise);
+        body.put("mosRatio", mosRatio);
+        body.put("resourceName", resourceName);
+        if (StringUtils.isNotBlank(language)) {
+            body.put("language", language);
+        }
+        if (StringUtils.isNotBlank(engineVersion)) {
+            body.put("engineVersion", engineVersion);
+        }
+
+        JSONObject responseJson = postJson(TASK_ADD_URL, body,
+                buildVoiceTrainHeaders(account.getString("apiKey"), account.getString("appId"), token, body.toJSONString()));
+        if (responseJson.getIntValue("code") != 0) {
+            throw new IOException("创建训练任务失败: " + responseJson.toJSONString());
+        }
+        return responseJson.getString("data");
+    }
+
+    private void submitWithAudio(JSONObject account, String token, String taskId, String textSegId,
+                                 float mosRatio, MultipartFile file) throws IOException {
+        RequestBody fileBody = RequestBody.create(OCTET_STREAM, file.getBytes());
+        MultipartBody.Builder builder = new MultipartBody.Builder().setType(MultipartBody.FORM)
+                .addFormDataPart("file", file.getOriginalFilename(), fileBody)
+                .addFormDataPart("taskId", taskId)
+                .addFormDataPart("textId", String.valueOf(DEFAULT_TEXT_ID))
+                .addFormDataPart("textSegId", textSegId);
+        if (mosRatio > 0) {
+            builder.addFormDataPart("mosRatio", String.valueOf(mosRatio));
+        }
+        MultipartBody body = builder.build();
+        Buffer buffer = new Buffer();
+        body.writeTo(buffer);
+        String bodyMd5 = md5Hex(buffer.readByteArray());
+
+        Map<String, String> headers = new LinkedHashMap<>();
+        String requestTime = String.valueOf(System.currentTimeMillis());
+        headers.put("X-Time", requestTime);
+        headers.put("X-AppId", account.getString("appId"));
+        headers.put("X-Token", token);
+        headers.put("X-Sign", md5Hex(account.getString("apiKey") + requestTime + bodyMd5));
+
+        JSONObject responseJson = postMultipart(TASK_SUBMIT_WITH_AUDIO_URL, body, headers);
+        if (responseJson.getIntValue("code") != 0) {
+            throw new IOException("上传训练音频失败: " + responseJson.toJSONString());
+        }
+    }
+
+    private JSONObject queryTaskResultInternal(JSONObject account, String token, String taskId) throws IOException {
+        JSONObject body = new JSONObject(true);
+        body.put("taskId", taskId);
+        JSONObject responseJson = postJson(TASK_RESULT_URL, body,
+                buildVoiceTrainHeaders(account.getString("apiKey"), account.getString("appId"), token, body.toJSONString()));
+        if (responseJson.getIntValue("code") != 0) {
+            throw new IOException("查询训练任务失败: " + responseJson.toJSONString());
+        }
+        return responseJson.getJSONObject("data");
+    }
+
+    private String synthesizeCloneAudio(JSONObject account, String assetId, String text, String engineVersion) throws Exception {
+        String authUrl = buildCloneWebsocketUrl(account.getString("apiKey"), account.getString("apiSecret"));
+        CountDownLatch latch = new CountDownLatch(1);
+        AtomicReference<String> errRef = new AtomicReference<>();
+        ByteArrayOutputStream audioOutput = new ByteArrayOutputStream();
+
+        Request request = new Request.Builder().url(authUrl).build();
+        HTTP_CLIENT.newWebSocket(request, new WebSocketListener() {
+            @Override
+            public void onOpen(WebSocket webSocket, Response response) {
+                JSONObject payload = buildCloneTtsPayload(account.getString("appId"), assetId, text, engineVersion);
+                webSocket.send(payload.toJSONString());
+            }
+
+            @Override
+            public void onMessage(WebSocket webSocket, String textMessage) {
+                try {
+                    JSONObject json = JSON.parseObject(textMessage);
+                    int code = json.getIntValue("code");
+                    if (code != 0) {
+                        errRef.set("讯飞试音接口返回错误: " + json.toJSONString());
+                        webSocket.close(1000, "error");
+                        latch.countDown();
+                        return;
+                    }
+
+                    JSONObject payload = json.getJSONObject("payload");
+                    if (payload == null) {
+                        return;
+                    }
+                    JSONObject audio = payload.getJSONObject("audio");
+                    if (audio == null) {
+                        return;
+                    }
+                    String audioChunk = audio.getString("audio");
+                    if (StringUtils.isNotBlank(audioChunk)) {
+                        audioOutput.write(Base64.getDecoder().decode(audioChunk));
+                    }
+                    if (audio.getIntValue("status") == 2) {
+                        webSocket.close(1000, "done");
+                        latch.countDown();
+                    }
+                } catch (Exception e) {
+                    errRef.set("解析试听音频失败: " + e.getMessage());
+                    webSocket.close(1000, "parse-error");
+                    latch.countDown();
+                }
+            }
+
+            @Override
+            public void onMessage(WebSocket webSocket, ByteString bytes) {
+                onMessage(webSocket, bytes.utf8());
+            }
+
+            @Override
+            public void onFailure(WebSocket webSocket, Throwable t, Response response) {
+                errRef.set(buildWebsocketFailureMessage(t, response));
+                latch.countDown();
+            }
+
+            @Override
+            public void onClosed(WebSocket webSocket, int code, String reason) {
+                latch.countDown();
+            }
+        });
+
+        if (!latch.await(45, TimeUnit.SECONDS)) {
+            throw new IOException("试听超时,请稍后再试");
+        }
+        if (StringUtils.isNotBlank(errRef.get())) {
+            throw new IOException(errRef.get());
+        }
+        if (audioOutput.size() == 0) {
+            throw new IOException("未收到试听音频数据");
+        }
+        return Base64.getEncoder().encodeToString(audioOutput.toByteArray());
+    }
+
+    private JSONObject buildCloneTtsPayload(String appId, String assetId, String text, String engineVersion) {
+        JSONObject root = new JSONObject(true);
+        JSONObject header = new JSONObject(true);
+        header.put("app_id", appId);
+        header.put("status", 2);
+        header.put("res_id", assetId);
+        root.put("header", header);
+
+        JSONObject audio = new JSONObject(true);
+        audio.put("encoding", "lame");
+        audio.put("sample_rate", 24000);
+
+        JSONObject tts = new JSONObject(true);
+        tts.put("vcn", "omni_v1".equalsIgnoreCase(engineVersion) ? "x6_clone" : "x5_clone");
+        tts.put("volume", 50);
+        tts.put("rhy", 0);
+        tts.put("pybuffer", 1);
+        tts.put("speed", 50);
+        tts.put("pitch", 50);
+        tts.put("bgs", 0);
+        tts.put("reg", 0);
+        tts.put("rdn", 0);
+        tts.put("audio", audio);
+
+        JSONObject parameter = new JSONObject(true);
+        parameter.put("tts", tts);
+        root.put("parameter", parameter);
+
+        JSONObject textNode = new JSONObject(true);
+        textNode.put("encoding", "utf8");
+        textNode.put("compress", "raw");
+        textNode.put("format", "plain");
+        textNode.put("status", 2);
+        textNode.put("seq", 0);
+        textNode.put("text", text);
+
+        JSONObject payload = new JSONObject(true);
+        payload.put("text", textNode);
+        root.put("payload", payload);
+        return root;
+    }
+
+    private String buildCloneWebsocketUrl(String apiKey, String apiSecret) throws Exception {
+        String date = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT")));
+        String signatureOrigin = "host: " + CLONE_TTS_HOST + "\n" +
+                "date: " + date + "\n" +
+                "GET " + CLONE_TTS_PATH + " HTTP/1.1";
+        Mac mac = Mac.getInstance("HmacSHA256");
+        mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+        String signature = Base64.getEncoder().encodeToString(mac.doFinal(signatureOrigin.getBytes(StandardCharsets.UTF_8)));
+        String authorizationOrigin = String.format("api_key=\"%s\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"%s\"",
+                apiKey, signature);
+        String authorization = Base64.getEncoder().encodeToString(authorizationOrigin.getBytes(StandardCharsets.UTF_8));
+        return CLONE_TTS_WS_URL + "?authorization=" + urlEncode(authorization) +
+                "&date=" + urlEncode(date) +
+                "&host=" + urlEncode(CLONE_TTS_HOST);
+    }
+
+    private String buildWebsocketFailureMessage(Throwable t, Response response) {
+        StringBuilder sb = new StringBuilder("讯飞试听WebSocket失败");
+        if (response != null) {
+            sb.append(": HTTP ").append(response.code());
+            String bodyText = "";
+            try {
+                bodyText = response.body() == null ? "" : response.body().string();
+            } catch (Exception e) {
+                log.warn("read xfvoiceclone websocket error body failed", e);
+            }
+            if (StringUtils.isNotBlank(bodyText)) {
+                sb.append(", body=").append(bodyText);
+            }
+            if (response.code() == 403) {
+                sb.append("。请确认当前 app-id/api-key/api-secret 是否属于“讯飞一句话复刻”服务应用,且该应用已开通 voice_clone/一句话复刻权限;普通在线TTS应用凭证无法调用该接口。");
+            }
+        } else if (t != null && StringUtils.isNotBlank(t.getMessage())) {
+            sb.append(": ").append(t.getMessage());
+        }
+        return sb.toString();
+    }
+
+    private Map<String, String> buildVoiceTrainHeaders(String apiKey, String appId, String token, String bodyText) {
+        Map<String, String> headers = new LinkedHashMap<>();
+        String requestTime = String.valueOf(System.currentTimeMillis());
+        headers.put("X-Time", requestTime);
+        headers.put("X-AppId", appId);
+        headers.put("X-Token", token);
+        headers.put("X-Sign", md5Hex(apiKey + requestTime + md5Hex(bodyText)));
+        return headers;
+    }
+
+    private JSONObject postJson(String url, JSONObject body, Map<String, String> headers) throws IOException {
+        RequestBody requestBody = RequestBody.create(JSON_MEDIA_TYPE, body.toJSONString());
+        Request.Builder requestBuilder = new Request.Builder().url(url).post(requestBody);
+        for (Map.Entry<String, String> header : headers.entrySet()) {
+            requestBuilder.addHeader(header.getKey(), header.getValue());
+        }
+        try (Response response = HTTP_CLIENT.newCall(requestBuilder.build()).execute()) {
+            String bodyText = response.body() == null ? "" : response.body().string();
+            log.info("xfvoiceclone http {} status={} body={}", url, response.code(), bodyText);
+            if (!response.isSuccessful()) {
+                throw new IOException("HTTP " + response.code() + ": " + bodyText);
+            }
+            return JSON.parseObject(bodyText);
+        }
+    }
+
+    private JSONObject postMultipart(String url, MultipartBody body, Map<String, String> headers) throws IOException {
+        Request.Builder requestBuilder = new Request.Builder().url(url).post(body);
+        for (Map.Entry<String, String> header : headers.entrySet()) {
+            requestBuilder.addHeader(header.getKey(), header.getValue());
+        }
+        try (Response response = HTTP_CLIENT.newCall(requestBuilder.build()).execute()) {
+            String bodyText = response.body() == null ? "" : response.body().string();
+            log.info("xfvoiceclone multipart {} status={} body={}", url, response.code(), bodyText);
+            if (!response.isSuccessful()) {
+                throw new IOException("HTTP " + response.code() + ": " + bodyText);
+            }
+            return JSON.parseObject(bodyText);
+        }
+    }
+
+    private String buildTrainStatusTips(JSONObject taskResult) {
+        if (taskResult == null) {
+            return "训练任务已提交,请稍后查询结果。";
+        }
+        int trainStatus = taskResult.getIntValue("trainStatus");
+        if (trainStatus == 1) {
+            return "训练成功,音色ID: " + taskResult.getString("assetId");
+        }
+        if (trainStatus == -1) {
+            return "训练中,请等待30-60秒后再次查询。";
+        }
+        if (trainStatus == 2) {
+            return "任务已创建,等待训练中。";
+        }
+        return "训练失败: " + defaultIfBlank(taskResult.getString("failedDesc"), "未知原因");
+    }
+
+    private String trimToEmpty(String value) {
+        return value == null ? "" : value.trim();
+    }
+
+    private String defaultIfBlank(String value, String defaultValue) {
+        return StringUtils.isBlank(value) ? defaultValue : value.trim();
+    }
+
+    private int parseIntOrDefault(String value, int defaultValue) {
+        try {
+            return Integer.parseInt(trimToEmpty(value));
+        } catch (Exception e) {
+            return defaultValue;
+        }
+    }
+
+    private float parseFloatOrDefault(String value, float defaultValue) {
+        try {
+            return Float.parseFloat(trimToEmpty(value));
+        } catch (Exception e) {
+            return defaultValue;
+        }
+    }
+
+    private String normalizeLanguage(String language) {
+        String value = trimToEmpty(language);
+        return "zh".equalsIgnoreCase(value) ? "" : value;
+    }
+
+    private String urlEncode(String value) throws Exception {
+        return URLEncoder.encode(value, "UTF-8");
+    }
+
+    private String md5Hex(String value) {
+        return md5Hex(value.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private String md5Hex(byte[] bytes) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(bytes);
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b & 0xff));
+            }
+            return sb.toString();
+        } catch (Exception e) {
+            throw new IllegalStateException("MD5计算失败", e);
+        }
+    }
+}

+ 2 - 2
ruoyi-admin/src/main/java/com/ruoyi/aicall/domain/CcTtsAliyun.java

@@ -36,8 +36,8 @@ public class CcTtsAliyun implements Serializable {
     @Excel(name = "是否启用")
     private Integer voiceEnabled;
 
-    /** 声音源,aliyun_tts、aliyun_tts_flow */
-    @Excel(name = "声音源,aliyun_tts、aliyun_tts_flow")
+    /** 声音源,aliyun_tts、aliyun_tts_flow、doubao_vcl_tts、xf_tts */
+    @Excel(name = "声音源,aliyun_tts、aliyun_tts_flow、doubao_vcl_tts、xf_tts")
     private String voiceSource;
 
     /** priority */

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/cc/controller/FsConfController.java

@@ -226,6 +226,16 @@ public class FsConfController extends BaseController {
         return "cc/alittsconf/alittsconf";
     }
 
+    /**
+     * TTS(科大讯飞)参数配置
+     * @return
+     */
+    @RequiresPermissions("cc:xfttsconf:view")
+    @GetMapping(value = "/xfttsconf")
+    public String xfTtsConf() {
+        return "cc/xfttsconf/xfttsconf";
+    }
+
     /**
      * 获取阿里云tts配置
      * @return
@@ -237,6 +247,17 @@ public class FsConfController extends BaseController {
         return getConfigFileJsonData(asrFileName, 5);
     }
 
+    /**
+     * 获取讯飞tts配置
+     * @return
+     */
+    @GetMapping(value = "/getXfTtsConf")
+    @ResponseBody
+    public AjaxResult getXfTtsConf() {
+        String asrFileName = "/autoload_configs/xf_tts.conf.xml";
+        return getConfigFileJsonData(asrFileName, 5);
+    }
+
     /**
      *  豆包tts 参数配置
      * @return
@@ -514,6 +535,20 @@ public class FsConfController extends BaseController {
         return result;
     }
 
+    /**
+     * 保存讯飞TTS配置
+     * @param params
+     * @return
+     */
+    @PostMapping(value = "/setXfTtsConf")
+    @ResponseBody
+    public AjaxResult setXfTtsConf(@RequestBody JSONArray params) {
+        String asrFileName = "/autoload_configs/xf_tts.conf.xml";
+        String moduleName = "mod_xf_tts";
+        AjaxResult result = saveAndReloadTtsModule("", asrFileName, moduleName, params);
+        return result;
+    }
+
     /**
      * 保存TTS配置
      * @param params

+ 1 - 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.xf.header=科大讯飞TTS参数配置
 switchconf.asr.engine.header=asr引擎选择
 switchconf.asr.engine.option1=无
 switchconf.asr.engine.option2=FunASR(免费)

+ 39 - 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.xf.header=IFlytek TTS Parameter Configuration
 switchconf.asr.engine.header=ASR Engine Selection
 switchconf.asr.engine.option1=None
 switchconf.asr.engine.option2=FunASR (Free)
@@ -563,6 +564,44 @@ doubaovcl.admin.model-status-not-ready = Model is not ready, please wait 1 minut
 doubaovcl.admin.tts-test.success-tips = Speech synthesis successful
 doubaovcl.admin.tts-test.failed-tips = Speech synthesis failed
 
+# Xunfei Voice Clone
+xfvoiceclone.form.upload-success-tips=Training request submitted successfully
+xfvoiceclone.form.upload-failed-tips=Training request submission failed
+xfvoiceclone.form.voice-name=Voice Name:
+xfvoiceclone.form.task-name=Task Name:
+xfvoiceclone.form.resource-name=Resource Name:
+xfvoiceclone.form.language=Language:
+xfvoiceclone.form.engine-version=Engine Version:
+xfvoiceclone.form.sex=Gender:
+xfvoiceclone.form.age-group=Age Group:
+xfvoiceclone.form.denoise=Denoise:
+xfvoiceclone.form.mos-ratio=Detection Threshold:
+xfvoiceclone.form.train-text=Training Text:
+xfvoiceclone.form.train-content=Text Content:
+xfvoiceclone.form.load-train-texts=Load Training Texts
+xfvoiceclone.form.load-train-texts-failed=Failed to load training texts
+xfvoiceclone.form.choose-audio-file=Upload Audio File
+xfvoiceclone.form.task-id=Task ID:
+xfvoiceclone.form.asset-id=Voice Asset ID:
+xfvoiceclone.form.train-status=Training Status:
+xfvoiceclone.form.query-train-result=Query Training Result
+xfvoiceclone.form.query-train-result-success=Training result queried successfully
+xfvoiceclone.form.query-train-result-failed=Failed to query training result
+xfvoiceclone.form.test-text=Test Text:
+xfvoiceclone.form.voice-name-required=Please enter a voice name first
+xfvoiceclone.form.train-text-required=Please choose a training text first
+xfvoiceclone.form.task-id-required=Please enter the task ID first
+xfvoiceclone.form.asset-id-required=Please enter the voice asset ID first
+xfvoiceclone.form.test-text-required=Please enter test text
+xfvoiceclone.form.training-tips=Uploading audio and submitting training, please wait...
+xfvoiceclone.form.tts-processing=Synthesizing, please wait...
+xfvoiceclone.status.success=Training successful
+xfvoiceclone.status.training=Training in progress
+xfvoiceclone.status.pending=Queued
+xfvoiceclone.status.failed=Training failed
+xfvoiceclone.admin.tts-test.success-tips=Speech synthesis successful
+xfvoiceclone.admin.tts-test.failed-tips=Speech synthesis failed
+
 # AI外呼记录
 callPhone.query.uuid=Call UUID:
 callPhone.query.telephone=Outbound Number:

+ 1 - 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.xf.header=IFlytek TTS Parameter Configuration
 switchconf.asr.engine.header=ASR Engine Selection
 switchconf.asr.engine.option1=None
 switchconf.asr.engine.option2=FunASR (Free)

+ 1 - 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.xf.header=IFlytek TTS Parameter Configuration
 switchconf.asr.engine.header=ASR Engine Selection
 switchconf.asr.engine.option1=None
 switchconf.asr.engine.option2=FunASR (Free)

+ 39 - 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.xf.header=科大讯飞TTS参数配置
 switchconf.asr.engine.header=asr引擎选择
 switchconf.asr.engine.option1=无
 switchconf.asr.engine.option2=FunASR(免费)
@@ -563,6 +564,44 @@ doubaovcl.admin.model-status-not-ready = 模型未就绪,需要等待1分钟
 doubaovcl.admin.tts-test.success-tips = 语音合成成功
 doubaovcl.admin.tts-test.failed-tips = 语音合成失败
 
+# 讯飞声音克隆
+xfvoiceclone.form.upload-success-tips=训练请求提交成功
+xfvoiceclone.form.upload-failed-tips=训练请求提交失败
+xfvoiceclone.form.voice-name=音色名称:
+xfvoiceclone.form.task-name=任务名称:
+xfvoiceclone.form.resource-name=音库名称:
+xfvoiceclone.form.language=语 言:
+xfvoiceclone.form.engine-version=模型版本:
+xfvoiceclone.form.sex=性 别:
+xfvoiceclone.form.age-group=年龄段:
+xfvoiceclone.form.denoise=降 噪:
+xfvoiceclone.form.mos-ratio=检测阈值:
+xfvoiceclone.form.train-text=训练文本:
+xfvoiceclone.form.train-content=文本内容:
+xfvoiceclone.form.load-train-texts=加载训练文本
+xfvoiceclone.form.load-train-texts-failed=加载训练文本失败
+xfvoiceclone.form.choose-audio-file=上传录音文件
+xfvoiceclone.form.task-id=任务ID:
+xfvoiceclone.form.asset-id=音色ID:
+xfvoiceclone.form.train-status=训练状态:
+xfvoiceclone.form.query-train-result=查询训练结果
+xfvoiceclone.form.query-train-result-success=训练结果查询成功
+xfvoiceclone.form.query-train-result-failed=训练结果查询失败
+xfvoiceclone.form.test-text=试听文本:
+xfvoiceclone.form.voice-name-required=请先填写音色名称
+xfvoiceclone.form.train-text-required=请先选择训练文本
+xfvoiceclone.form.task-id-required=请先填写任务ID
+xfvoiceclone.form.asset-id-required=请先填写音色ID
+xfvoiceclone.form.test-text-required=请输入试听文本
+xfvoiceclone.form.training-tips=正在上传并提交训练,请稍后...
+xfvoiceclone.form.tts-processing=正在合成,请稍后...
+xfvoiceclone.status.success=训练成功
+xfvoiceclone.status.training=训练中
+xfvoiceclone.status.pending=排队中
+xfvoiceclone.status.failed=训练失败
+xfvoiceclone.admin.tts-test.success-tips=试听合成成功
+xfvoiceclone.admin.tts-test.failed-tips=试听合成失败
+
 # AI外呼记录
 callPhone.query.uuid=通话uuid:
 callPhone.query.telephone=外呼号码:

+ 1 - 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.xf.header=科大讯飞TTS参数配置
 switchconf.asr.engine.header=asr引擎选择
 switchconf.asr.engine.option1=无
 switchconf.asr.engine.option2=FunASR(免费)

+ 347 - 0
ruoyi-admin/src/main/resources/templates/aicall/xfvoiceclone/voiceclone.html

@@ -0,0 +1,347 @@
+<!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('科大讯飞声音克隆')" />
+</head>
+<body class="gray-bg">
+<div class="container-div">
+    <div class="row">
+        <div class="col-sm-12 search-collapse">
+            <pre><strong>科大讯飞声音克隆注意事项:</strong>
+1. 请先在“科大讯飞TTS配置”中配置 app-id、api-key、api-secret,本页面直接复用该配置。
+2. 先点击“加载训练文本”,按选中的文本内容录音,录音内容必须与训练文本一致。
+3. 建议上传 24kHz 及以上、单声道、16bit 的 wav 或 mp3 文件,时长尽量控制在 40 秒内。
+4. 训练提交后一般需要等待几十秒,成功后会返回音色ID,可直接在页面里试听。</pre>
+        </div>
+
+        <div class="col-sm-12 search-collapse">
+            <form id="trainForm">
+                <div class="select-list">
+                    <ul>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.voice-name}"></label>
+                            <input type="text" name="voice_name" id="voice_name" />
+                        </li>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.task-name}"></label>
+                            <input type="text" name="task_name" id="task_name" />
+                        </li>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.resource-name}"></label>
+                            <input type="text" name="resource_name" id="resource_name" />
+                        </li>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.language}"></label>
+                            <select name="language" id="language">
+                                <option value="zh">中文</option>
+                                <option value="en">英文</option>
+                                <option value="jp">日语</option>
+                                <option value="ko">韩语</option>
+                                <option value="ru">俄语</option>
+                            </select>
+                        </li>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.engine-version}"></label>
+                            <select name="engine_version" id="engine_version">
+                                <option value="">标准版 x5_clone</option>
+                                <option value="omni_v1">多风格版 x6_clone</option>
+                            </select>
+                        </li>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.sex}"></label>
+                            <select name="sex" id="sex">
+                                <option value="1">男</option>
+                                <option value="2">女</option>
+                            </select>
+                        </li>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.age-group}"></label>
+                            <select name="age_group" id="age_group">
+                                <option value="1">儿童</option>
+                                <option value="2" selected>青年</option>
+                                <option value="3">中年</option>
+                                <option value="4">中老年</option>
+                            </select>
+                        </li>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.denoise}"></label>
+                            <select name="denoise" id="denoise">
+                                <option value="0">关闭</option>
+                                <option value="1">开启</option>
+                            </select>
+                        </li>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.mos-ratio}"></label>
+                            <input type="number" step="0.1" min="0" max="5" name="mos_ratio" id="mos_ratio" value="0.0" />
+                        </li>
+                        <li style="width: 100%">
+                            <label th:text="#{xfvoiceclone.form.train-text}"></label>
+                            <select name="text_seg_id" id="text_seg_id" style="width: 480px;"></select>
+                            <a class="btn btn-success btn-xs" href="javascript:void(0)" onclick="$.operate.loadTrainTexts()">
+                                <i class="fa fa-refresh"></i> &nbsp;<span th:text="#{xfvoiceclone.form.load-train-texts}"></span>
+                            </a>
+                        </li>
+                        <li style="width: 100%">
+                            <label th:text="#{xfvoiceclone.form.train-content}"></label>
+                            <textarea id="seg_text" cols="80" rows="4" readonly="readonly"></textarea>
+                        </li>
+                        <li>
+                            <a class="btn btn-info btn-xs" href="javascript:void(0)" onclick="$.operate.importFile()">
+                                <i class="fa fa-upload"></i> &nbsp;<span th:text="#{xfvoiceclone.form.choose-audio-file}"></span>
+                            </a>
+                        </li>
+                    </ul>
+                </div>
+            </form>
+        </div>
+
+        <div class="col-sm-12 search-collapse">
+            <form id="resultForm">
+                <div class="select-list">
+                    <ul>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.task-id}"></label>
+                            <input type="text" name="taskId" id="taskId" />
+                            <a class="btn btn-primary btn-xs" href="javascript:void(0)" onclick="$.operate.queryTrainResult()">
+                                <i class="fa fa-search"></i> &nbsp;<span th:text="#{xfvoiceclone.form.query-train-result}"></span>
+                            </a>
+                        </li>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.asset-id}"></label>
+                            <input type="text" name="assetId" id="assetId" />
+                        </li>
+                        <li>
+                            <label th:text="#{xfvoiceclone.form.train-status}"></label>
+                            <input type="text" id="trainStatusText" 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 style="width: 100%">
+                            <label th:text="#{xfvoiceclone.form.test-text}"></label>
+                            <textarea placeholder="请输入试听文本" name="tts_text" id="tts_text" cols="80" rows="4"></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;<span th:text="#{btn.ok}"></span>
+                            </a>
+                        </li>
+                        <li style="width: 100%">
+                            <audio id="test_player" controls="controls" style="width: 480px; display: none"></audio>
+                        </li>
+                    </ul>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+
+<th:block th:include="include :: footer" />
+<script th:inline="javascript">
+    var prefix = ctx + "aicall/xfvoiceclone";
+    var trainTextMap = {};
+
+    $(function() {
+        $.operate.loadTrainTexts();
+        $("#text_seg_id").on("change", function() {
+            var segId = $(this).val();
+            $("#seg_text").val(trainTextMap[segId] || "");
+        });
+    });
+
+    $.operate.loadTrainTexts = function() {
+        $.get(prefix + "/getTrainTexts", function(respJson) {
+            if (respJson.code !== 0) {
+                $.modal.msgError(respJson.msg || i18n('xfvoiceclone.form.load-train-texts-failed'));
+                return;
+            }
+            var select = $("#text_seg_id");
+            select.empty();
+            trainTextMap = {};
+            var rows = respJson.data || [];
+            for (var i = 0; i < rows.length; i++) {
+                var row = rows[i];
+                var segId = row.segId + "";
+                var segText = row.segText || "";
+                trainTextMap[segId] = segText;
+                select.append('<option value="' + segId + '">' + segId + ' - ' + segText + '</option>');
+            }
+            if (rows.length > 0) {
+                select.val(rows[0].segId + "");
+                $("#seg_text").val(rows[0].segText || "");
+            } else {
+                $("#seg_text").val("");
+            }
+        }).fail(function() {
+            $.modal.msgError(i18n('xfvoiceclone.form.load-train-texts-failed'));
+        });
+    };
+
+    $.operate.importFile = function() {
+        var voiceName = $("#voice_name").val().trim();
+        if (voiceName === "") {
+            $.modal.msgError(i18n('xfvoiceclone.form.voice-name-required'));
+            $("#voice_name")[0].focus();
+            return;
+        }
+
+        var textSegId = $("#text_seg_id").val();
+        if (!textSegId) {
+            $.modal.msgError(i18n('xfvoiceclone.form.train-text-required'));
+            return;
+        }
+
+        var input = document.createElement('input');
+        input.type = 'file';
+        input.accept = '.wav,.mp3,.m4a,.pcm';
+        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('task_name', $("#task_name").val().trim());
+            formData.append('resource_name', $("#resource_name").val().trim());
+            formData.append('language', $("#language").val());
+            formData.append('engine_version', $("#engine_version").val());
+            formData.append('sex', $("#sex").val());
+            formData.append('age_group', $("#age_group").val());
+            formData.append('denoise', $("#denoise").val());
+            formData.append('mos_ratio', $("#mos_ratio").val().trim());
+            formData.append('text_seg_id', textSegId);
+
+            $.modal.msg(i18n('xfvoiceclone.form.training-tips'));
+            $.ajax({
+                url: prefix + "/uploadAndTrain",
+                type: "POST",
+                data: formData,
+                processData: false,
+                contentType: false,
+                success: function(respJson) {
+                    if (respJson.code !== 0) {
+                        $.modal.msgError(respJson.msg || i18n('xfvoiceclone.form.upload-failed-tips'));
+                        return;
+                    }
+                    $.modal.msgSuccess(respJson.msg || i18n('xfvoiceclone.form.upload-success-tips'));
+                    var data = respJson.data || {};
+                    if (data.taskId) {
+                        $("#taskId").val(data.taskId);
+                    }
+                    if (data.assetId) {
+                        $("#assetId").val(data.assetId);
+                    }
+                    $("#trainStatusText").val($.operate.renderTrainStatus(data.trainStatus, data.failedDesc));
+                    $("#tts_area").css("display", "block");
+                },
+                error: function() {
+                    $.modal.msgError(i18n('xfvoiceclone.form.upload-failed-tips'));
+                }
+            });
+        };
+        input.click();
+    };
+
+    $.operate.queryTrainResult = function() {
+        var taskId = $("#taskId").val().trim();
+        if (taskId === "") {
+            $.modal.msgError(i18n('xfvoiceclone.form.task-id-required'));
+            $("#taskId")[0].focus();
+            return;
+        }
+        $.post(prefix + "/queryTrainResult", {taskId: taskId}, function(respJson) {
+            if (respJson.code !== 0) {
+                $.modal.msgError(respJson.msg || i18n('xfvoiceclone.form.query-train-result-failed'));
+                return;
+            }
+            var data = respJson.data || {};
+            if (data.assetId) {
+                $("#assetId").val(data.assetId);
+                $("#tts_area").css("display", "block");
+            }
+            $("#trainStatusText").val($.operate.renderTrainStatus(data.trainStatus, data.failedDesc));
+            $.modal.msgSuccess(respJson.msg || i18n('xfvoiceclone.form.query-train-result-success'));
+        }).fail(function() {
+            $.modal.msgError(i18n('xfvoiceclone.form.query-train-result-failed'));
+        });
+    };
+
+    $.operate.renderTrainStatus = function(status, failedDesc) {
+        if (status === 1) {
+            return i18n('xfvoiceclone.status.success');
+        }
+        if (status === -1) {
+            return i18n('xfvoiceclone.status.training');
+        }
+        if (status === 2) {
+            return i18n('xfvoiceclone.status.pending');
+        }
+        if (status === 0) {
+            return i18n('xfvoiceclone.status.failed') + (failedDesc ? ': ' + failedDesc : '');
+        }
+        return '';
+    };
+
+    $.operate.ttsTest = function() {
+        if ($.operate.ttsTest.processing === true) {
+            $.modal.msg(i18n('xfvoiceclone.form.tts-processing'));
+            return;
+        }
+
+        var assetId = $("#assetId").val().trim();
+        if (assetId === "") {
+            $.modal.msgError(i18n('xfvoiceclone.form.asset-id-required'));
+            $("#assetId")[0].focus();
+            return;
+        }
+
+        var text = $("#tts_text").val().trim();
+        if (text === "") {
+            $.modal.msgError(i18n('xfvoiceclone.form.test-text-required'));
+            $("#tts_text")[0].focus();
+            return;
+        }
+
+        $.operate.ttsTest.processing = true;
+        var formData = new FormData();
+        formData.append('assetId', assetId);
+        formData.append('text', text);
+        formData.append('engine_version', $("#engine_version").val());
+        $.ajax({
+            url: prefix + "/ttsTest",
+            type: "POST",
+            data: formData,
+            processData: false,
+            contentType: false,
+            success: function(respJson) {
+                if (respJson.code !== 0) {
+                    $.modal.msgError(respJson.msg || i18n('xfvoiceclone.admin.tts-test.failed-tips'));
+                    return;
+                }
+                var data = respJson.data || {};
+                var player = $("#test_player");
+                var audioSrc = 'data:' + (data.audioMime || 'audio/mp3') + ';base64,' + data.audioBase64;
+                player.css("display", "block");
+                player.attr("src", audioSrc);
+                player[0].play();
+                $.modal.msgSuccess(respJson.msg || i18n('xfvoiceclone.admin.tts-test.success-tips'));
+            },
+            complete: function() {
+                $.operate.ttsTest.processing = false;
+            },
+            error: function() {
+                $.operate.ttsTest.processing = false;
+                $.modal.msgError(i18n('xfvoiceclone.admin.tts-test.failed-tips'));
+            }
+        });
+    };
+</script>
+</body>
+</html>

+ 80 - 0
ruoyi-admin/src/main/resources/templates/cc/xfttsconf/xfttsconf.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.xf.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 + '/getXfTtsConf',
+            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 + '/setXfTtsConf',
+            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>

+ 19 - 3
sql/easycallcenter365.sql

@@ -5048,6 +5048,10 @@ insert  into `sys_menu`(`menu_id`,`menu_name`,`menu_code`,`parent_id`,`order_num
 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`,`update_by`,`update_time`,`remark`) values (3017,'阿里云tts配置','alittsconf',3019,6,'/cc/fsconf/alittsconf','menuItem','C','0','1','cc:alittsconf:view','#','admin','2025-05-04 21:56:07','admin','2025-05-05 10:48:02','');
 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`,`update_by`,`update_time`,`remark`) values (3018,'语音识别配置','ASRConf',2000,2,'#','menuItem','M','0','1','','fa fa-volume-down','admin','2025-05-05 10:42:52','admin','2025-05-05 13:24:20','');
 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`,`update_by`,`update_time`,`remark`) values (3019,'语音合成配置','TTSConf',2000,3,'#','menuItem','M','0','1','','fa fa-volume-up','admin','2025-05-05 10:47:06','admin','2025-05-05 13:24:31','');
+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`,`update_by`,`update_time`,`remark`) values (4023,'科大讯飞TTS配置','xfttsconf',3019,7,'/cc/fsconf/xfttsconf','menuItem','C','0','1','cc:xfttsconf:view','#','admin','2026-06-01 00:00:00','admin','2026-06-01 00:00:00','');
+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`,`update_by`,`update_time`,`remark`) values (4024,'讯飞声音克隆','xfvoiceclone',3019,8,'/aicall/xfvoiceclone/voiceclone','menuItem','C','0','1','aicall:xfvoiceclone:view','fa fa-microphone','admin','2026-06-01 00:00:00','admin','2026-06-01 00:00:00','讯飞一句话复刻工具');
+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`,`update_by`,`update_time`,`remark`) values (4025,'讯飞声音克隆上传训练','xfvoicecloneUploadAndTrain',4024,1,'#','','F','0','1','aicall:xfvoiceclone:uploadAndTrain','#','admin','2026-06-01 00:00:00','admin','2026-06-01 00:00:00','讯飞声音克隆上传训练权限');
+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`,`update_by`,`update_time`,`remark`) values (4026,'讯飞声音克隆试听','xfvoicecloneTtsTest',4024,2,'#','','F','0','1','aicall:xfvoiceclone:ttsTest','#','admin','2026-06-01 00:00:00','admin','2026-06-01 00:00:00','讯飞声音克隆试听权限');
 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`,`update_by`,`update_time`,`remark`) values (3020,'FunASR配置','funasrconf',3018,2,'/cc/fsconf/funasrconf','menuItem','C','0','1','cc:funasrconf:view','#','admin','2025-05-05 10:51:22','',NULL,'');
 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`,`update_by`,`update_time`,`remark`) values (3022,'FreeSWITCH配置','FreeswitchConf',2000,1,'#','menuItem','M','0','1','','#','admin','2025-05-05 13:05:05','admin','2025-05-05 13:24:01','');
 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`,`update_by`,`update_time`,`remark`) values (3030,'外呼任务管理','callTask',3000,1,'/aicall/callTask','menuItem','C','0','1','aicall:callTask:view','#','admin','2025-05-29 08:59:09','admin','2025-08-07 09:33:35','');
@@ -5094,6 +5098,17 @@ CREATE TABLE `sys_notice` (
 -- Records of sys_notice
 -- ----------------------------
 
+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语言设置');
+
+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');
+
 -- ----------------------------
 -- Table structure for sys_oper_log
 -- ----------------------------
@@ -5249,6 +5264,10 @@ INSERT INTO `sys_role_menu` VALUES ('2', '3017');
 INSERT INTO `sys_role_menu` VALUES ('2', '3018');
 INSERT INTO `sys_role_menu` VALUES ('2', '3019');
 INSERT INTO `sys_role_menu` VALUES ('2', '3020');
+INSERT INTO `sys_role_menu` VALUES ('2', '4023');
+INSERT INTO `sys_role_menu` VALUES ('2', '4024');
+INSERT INTO `sys_role_menu` VALUES ('2', '4025');
+INSERT INTO `sys_role_menu` VALUES ('2', '4026');
 INSERT INTO `sys_role_menu` VALUES ('2', '3022');
 INSERT INTO `sys_role_menu` VALUES ('2', '3030');
 INSERT INTO `sys_role_menu` VALUES ('2', '3031');
@@ -5416,6 +5435,3 @@ INSERT  INTO `sys_post`(`post_id`,`post_code`,`post_name`,`post_sort`,`status`,`
 INSERT  INTO `sys_post`(`post_id`,`post_code`,`post_name`,`post_sort`,`status`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) VALUES (-1,'_agent','坐席',5,'0','admin','2025-09-03 17:41:43','',NULL,NULL);
 
 ALTER TABLE `cc_llm_agent_account` ADD COLUMN concurrent_num INT(3) DEFAULT 1 COMMENT '模型并发数';
-
-
-

+ 34 - 0
sql/v20260601.sql

@@ -0,0 +1,34 @@
+-- 2026-06-01
+DELETE FROM `sys_role_menu`
+WHERE `menu_id` IN (4023, 4024, 4025, 4026);
+
+DELETE FROM `sys_menu`
+WHERE `menu_id` IN (4023, 4024, 4025, 4026)
+   OR `perms` = 'cc:xfttsconf:view'
+   OR `perms` = 'aicall:xfvoiceclone:view'
+   OR `perms` = 'aicall:xfvoiceclone:uploadAndTrain'
+   OR `perms` = 'aicall:xfvoiceclone:ttsTest'
+   OR `url` = '/cc/fsconf/xfttsconf'
+   OR `url` = '/aicall/xfvoiceclone/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 (4023, '科大讯飞TTS配置', 'xfttsconf', 3019, 7, '/cc/fsconf/xfttsconf', 'menuItem', 'C', '0', '1', 'cc:xfttsconf:view', '#', 'admin', NOW(), '科大讯飞 TTS 参数配置菜单');
+
+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 (4024, '讯飞声音克隆', 'xfvoiceclone', 3019, 8, '/aicall/xfvoiceclone/voiceclone', 'menuItem', 'C', '0', '1', 'aicall:xfvoiceclone: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 (4025, '讯飞声音克隆上传训练', 'xfvoicecloneUploadAndTrain', 4024, 1, '#', '', 'F', '0', '1', 'aicall:xfvoiceclone:uploadAndTrain', '#', '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 (4026, '讯飞声音克隆试听', 'xfvoicecloneTtsTest', 4024, 2, '#', '', 'F', '0', '1', 'aicall:xfvoiceclone:ttsTest', '#', 'admin', NOW(), '讯飞声音克隆试听权限');
+
+INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES (2, 4023);
+
+INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
+VALUES (2, 4024),
+       (2, 4025),
+       (2, 4026);
+
+ 

+ 17 - 0
sql/v20260602.sql

@@ -0,0 +1,17 @@
+-- 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');