yzx há 1 dia atrás
pai
commit
edb6bfb481

+ 2 - 2
src/main/java/com/telerobot/fs/tts/TtsUtil.java

@@ -2,7 +2,7 @@ package com.telerobot.fs.tts;
 
 import com.telerobot.fs.tts.aliyun.AliyunTTSWebApi;
 import com.telerobot.fs.tts.tencent.TencentTTSWebApi;
-import com.telerobot.fs.tts.viitor.ViiTorTTSWebApi;
+
 import link.thingscloud.freeswitch.esl.CommonUtils;
 import link.thingscloud.freeswitch.esl.util.CurrentTimeMillisClock;
 import org.apache.commons.lang.StringUtils;
@@ -65,7 +65,7 @@ public class TtsUtil {
             }
         } else if("vt_tts".equals(voiceSource)) {
             try {
-                result = ViiTorTTSWebApi.shortTextTTSWebAPI(voiceCode, text, wavSavePath);
+                result = TencentTTSWebApi.shortTextTTSWebAPI(voiceCode, text, wavSavePath);
             } catch (Throwable e) {
                 logger.error(" ViiTorTTSWebApi.shortTextTTSWebAPI error! voiceCode={}, wavSavePath={}, text={}, {} {}",
                         voiceCode, wavSavePath, text,

+ 468 - 0
src/main/java/com/telerobot/fs/tts/viitor/ViiTorTTSWebApi.java

@@ -0,0 +1,468 @@
+package com.telerobot.fs.tts.viitor;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.telerobot.fs.config.SystemConfig;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.WebSocket;
+import okhttp3.WebSocketListener;
+import okio.ByteString;
+import org.apache.commons.lang.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Base64;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+public class ViiTorTTSWebApi {
+    private static final Logger log = LoggerFactory.getLogger(ViiTorTTSWebApi.class);
+    private static final MediaTypeHolder MEDIA_TYPE_HOLDER = new MediaTypeHolder();
+    private static final DateTimeFormatter UTC_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
+            .withZone(ZoneOffset.UTC);
+    private static final String DEFAULT_TOKEN_URL = "https://tts.ilivedata.com/api/v2/speech/synthesis/ws-token";
+    private static final String DEFAULT_SIGN_PATH = "/api/v2/speech/synthesis/ws-token";
+    private static final String DEFAULT_WS_URL = "wss://tts.ilivedata.com/api/v1/speech/synthesis/ws";
+    private static final OkHttpClient CLIENT = new OkHttpClient.Builder()
+            .connectTimeout(10, TimeUnit.SECONDS)
+            .readTimeout(0, TimeUnit.MILLISECONDS)
+            .build();
+
+    private static volatile String ttsAccountJson;
+    private static volatile JSONObject ttsAccount;
+
+    private ViiTorTTSWebApi() {
+    }
+
+    private static final class MediaTypeHolder {
+        private final okhttp3.MediaType jsonType = okhttp3.MediaType.parse("application/json; charset=utf-8");
+    }
+
+    private static final class WsResult {
+        private final CountDownLatch latch = new CountDownLatch(1);
+        private final ByteArrayOutputStream audioOutput = new ByteArrayOutputStream();
+        private volatile boolean initReceived = false;
+        private volatile boolean completed = false;
+        private volatile boolean failed = false;
+        private volatile String errorMessage = "";
+        private volatile int sampleRate = 8000;
+        private volatile WebSocket webSocket;
+
+        private void fail(String message) {
+            if (!failed) {
+                failed = true;
+                completed = true;
+                errorMessage = message;
+                latch.countDown();
+            }
+        }
+
+        private void finish() {
+            if (!completed) {
+                completed = true;
+                latch.countDown();
+            }
+        }
+    }
+
+    private static JSONObject getAccount() {
+        String latestJson = SystemConfig.getValue("vt-tts-account-json", "");
+        if (StringUtils.isBlank(latestJson)) {
+            log.error("param `vt-tts-account-json` can not be blank.");
+            return null;
+        }
+        if (!latestJson.equals(ttsAccountJson) || ttsAccount == null) {
+            synchronized (ViiTorTTSWebApi.class) {
+                latestJson = SystemConfig.getValue("vt-tts-account-json", "");
+                if (!latestJson.equals(ttsAccountJson) || ttsAccount == null) {
+                    ttsAccountJson = latestJson;
+                    try {
+                        ttsAccount = JSON.parseObject(latestJson);
+                    } catch (Throwable e) {
+                        log.error("parse `vt-tts-account-json` error: {}", e.toString(), e);
+                        ttsAccount = null;
+                    }
+                }
+            }
+        }
+        return ttsAccount;
+    }
+
+    private static String config(JSONObject account, String key, String defaultValue) {
+        if (account == null) {
+            return defaultValue;
+        }
+        String value = account.getString(key);
+        return StringUtils.isBlank(value) ? defaultValue : value.trim();
+    }
+
+    private static int configInt(JSONObject account, String key, int defaultValue) {
+        String value = config(account, key, "");
+        if (StringUtils.isBlank(value)) {
+            return defaultValue;
+        }
+        try {
+            return Integer.parseInt(value);
+        } catch (Throwable ignore) {
+            return defaultValue;
+        }
+    }
+
+    private static boolean configBool(JSONObject account, String key, boolean defaultValue) {
+        String value = config(account, key, String.valueOf(defaultValue));
+        if (StringUtils.isBlank(value)) {
+            return defaultValue;
+        }
+        return "1".equals(value) || "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value);
+    }
+
+    private static String normalizeFormat(String value) {
+        String format = StringUtils.trimToEmpty(value).toLowerCase();
+        return StringUtils.isBlank(format) ? "pcm" : format;
+    }
+
+    private static String sha256Hex(String value) throws Exception {
+        MessageDigest digest = MessageDigest.getInstance("SHA-256");
+        byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
+        return bytesToHex(hash);
+    }
+
+    private static byte[] hmacSha256(String key, String message) throws Exception {
+        Mac mac = Mac.getInstance("HmacSHA256");
+        mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+        return mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static String hmacSha256Base64(String key, String message) throws Exception {
+        return Base64.getEncoder().encodeToString(hmacSha256(key, message));
+    }
+
+    private static String bytesToHex(byte[] bytes) {
+        StringBuilder sb = new StringBuilder(bytes.length * 2);
+        for (byte b : bytes) {
+            sb.append(String.format("%02x", b));
+        }
+        return sb.toString();
+    }
+
+    private static String formatUtcNow() {
+        return UTC_FORMATTER.format(Instant.now());
+    }
+
+    private static OkHttpClient buildClient(boolean verifyPeer, int connectTimeoutMs) throws Exception {
+        OkHttpClient.Builder builder = CLIENT.newBuilder()
+                .connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS)
+                .readTimeout(0, TimeUnit.MILLISECONDS);
+        if (verifyPeer) {
+            return builder.build();
+        }
+        final TrustManager[] trustAllCerts = new TrustManager[]{
+                new X509TrustManager() {
+                    @Override
+                    public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) {
+                    }
+
+                    @Override
+                    public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) {
+                    }
+
+                    @Override
+                    public java.security.cert.X509Certificate[] getAcceptedIssuers() {
+                        return new java.security.cert.X509Certificate[0];
+                    }
+                }
+        };
+        SSLContext sslContext = SSLContext.getInstance("TLS");
+        sslContext.init(null, trustAllCerts, new SecureRandom());
+        builder.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0]);
+        builder.hostnameVerifier((hostname, session) -> true);
+        return builder.build();
+    }
+
+    private static String parseHost(String url, String defaultHost) {
+        if (StringUtils.isBlank(url)) {
+            return defaultHost;
+        }
+        String target = url;
+        int schemeIndex = target.indexOf("://");
+        if (schemeIndex >= 0) {
+            target = target.substring(schemeIndex + 3);
+        }
+        int slashIndex = target.indexOf('/');
+        return slashIndex >= 0 ? target.substring(0, slashIndex) : target;
+    }
+
+    private static String parsePath(String url, String defaultPath) {
+        if (StringUtils.isBlank(url)) {
+            return defaultPath;
+        }
+        int schemeIndex = url.indexOf("://");
+        int hostStart = schemeIndex >= 0 ? schemeIndex + 3 : 0;
+        int slashIndex = url.indexOf('/', hostStart);
+        if (slashIndex < 0) {
+            return defaultPath;
+        }
+        String path = url.substring(slashIndex);
+        int queryIndex = path.indexOf('?');
+        return queryIndex >= 0 ? path.substring(0, queryIndex) : path;
+    }
+
+    private static String fetchWsUrl(JSONObject account, OkHttpClient client) throws Exception {
+        String tokenUrl = config(account, "token-url", DEFAULT_TOKEN_URL);
+        String host = parseHost(tokenUrl, "tts.ilivedata.com");
+        String path = parsePath(tokenUrl, DEFAULT_SIGN_PATH);
+        String appId = config(account, "appid", "");
+        String secretKey = config(account, "secret-key", "");
+        String timestamp = formatUtcNow();
+        String stringToSign = "GET\n" +
+                host + "\n" +
+                path + "\n" +
+                "X-AppId:" + appId + "\n" +
+                "X-TimeStamp:" + timestamp;
+        String authorization = hmacSha256Base64(secretKey, stringToSign);
+
+        Request request = new Request.Builder()
+                .url(tokenUrl)
+                .get()
+                .header("Accept", "application/json")
+                .header("X-AppId", appId)
+                .header("X-TimeStamp", timestamp)
+                .header("Authorization", authorization)
+                .build();
+        try (Response response = client.newCall(request).execute()) {
+            String body = response.body() == null ? "" : response.body().string();
+            log.info("viitor fetch token status={}, body={}", response.code(), body);
+            if (!response.isSuccessful()) {
+                throw new IllegalStateException("fetch ws token failed http=" + response.code() + ", body=" + body);
+            }
+            JSONObject json = JSON.parseObject(body);
+            Integer errorCode = json.getInteger("errorCode");
+            if (errorCode != null && errorCode != 0) {
+                throw new IllegalStateException("fetch ws token errorCode=" + errorCode + ", msg=" + json.getString("errorMessage"));
+            }
+            String token = json.getString("token");
+            String wsUrl = json.getString("wsUrl");
+            if (StringUtils.isBlank(token) || StringUtils.isBlank(wsUrl)) {
+                throw new IllegalStateException("fetch ws token response missing token/wsUrl");
+            }
+            return wsUrl.contains("?") ? wsUrl + "&token=" + token : wsUrl + "?token=" + token;
+        }
+    }
+
+    private static JSONObject buildRequestPayload(JSONObject account, String voiceCode, String text) {
+        JSONObject root = new JSONObject(true);
+        JSONObject request = new JSONObject(true);
+        JSONObject voice = new JSONObject(true);
+        JSONObject output = new JSONObject(true);
+        String appId = config(account, "appid", "");
+        String sessionId = UUID.randomUUID().toString().replace("-", "");
+        String voiceName = StringUtils.isNotBlank(voiceCode) ? voiceCode : config(account, "voice-name", "");
+        String language = config(account, "language", "zh-CN");
+        String outputFormat = normalizeFormat(config(account, "format", "pcm"));
+
+        root.put("appId", Long.parseLong(appId));
+        root.put("sessionId", sessionId);
+        request.put("appId", Long.parseLong(appId));
+        request.put("text", text);
+        request.put("language", language);
+        voice.put("name", voiceName);
+        output.put("format", outputFormat);
+        request.put("voice", voice);
+        request.put("output", output);
+        root.put("request", request);
+        return root;
+    }
+
+    private static void writeLittleEndianInt(ByteArrayOutputStream out, int value) {
+        out.write(value & 0xFF);
+        out.write((value >> 8) & 0xFF);
+        out.write((value >> 16) & 0xFF);
+        out.write((value >> 24) & 0xFF);
+    }
+
+    private static void writeLittleEndianShort(ByteArrayOutputStream out, int value) {
+        out.write(value & 0xFF);
+        out.write((value >> 8) & 0xFF);
+    }
+
+    private static void writeWavFile(String targetPath, byte[] audioBytes, int sampleRate) throws Exception {
+        int channels = 1;
+        int bitsPerSample = 16;
+        int blockAlign = channels * bitsPerSample / 8;
+        int byteRate = sampleRate * blockAlign;
+        ByteArrayOutputStream out = new ByteArrayOutputStream(44 + audioBytes.length);
+        out.write(new byte[]{'R', 'I', 'F', 'F'});
+        writeLittleEndianInt(out, 36 + audioBytes.length);
+        out.write(new byte[]{'W', 'A', 'V', 'E'});
+        out.write(new byte[]{'f', 'm', 't', ' '});
+        writeLittleEndianInt(out, 16);
+        writeLittleEndianShort(out, 1);
+        writeLittleEndianShort(out, channels);
+        writeLittleEndianInt(out, sampleRate);
+        writeLittleEndianInt(out, byteRate);
+        writeLittleEndianShort(out, blockAlign);
+        writeLittleEndianShort(out, bitsPerSample);
+        out.write(new byte[]{'d', 'a', 't', 'a'});
+        writeLittleEndianInt(out, audioBytes.length);
+        out.write(audioBytes);
+        try (FileOutputStream fout = new FileOutputStream(new File(targetPath))) {
+            fout.write(out.toByteArray());
+        }
+    }
+
+    private static boolean saveAudioFile(String targetPath, byte[] audioBytes, int sampleRate) {
+        try {
+            writeWavFile(targetPath, audioBytes, sampleRate > 0 ? sampleRate : 8000);
+            return true;
+        } catch (Throwable e) {
+            log.error("save viitor wav file failed, path={}, err={}", targetPath, e.toString(), e);
+            return false;
+        }
+    }
+
+    private static boolean processWebSocketRequest(JSONObject account, String voiceCode, String text, String audioSaveFile) {
+        try {
+            String appId = config(account, "appid", "");
+            String secretKey = config(account, "secret-key", "");
+            String voiceName = StringUtils.isNotBlank(voiceCode) ? voiceCode : config(account, "voice-name", "");
+            if (StringUtils.isBlank(appId) || StringUtils.isBlank(secretKey) || StringUtils.isBlank(voiceName)) {
+                log.error("viitor tts config missing appid/secret-key/voice-name");
+                return false;
+            }
+
+            boolean verifyPeer = configBool(account, "verify-peer", false);
+            int connectTimeoutMs = configInt(account, "connect-timeout-ms", 10000);
+            String wsUrl = fetchWsUrl(account, buildClient(verifyPeer, connectTimeoutMs));
+            OkHttpClient wsClient = buildClient(verifyPeer, connectTimeoutMs);
+            JSONObject payload = buildRequestPayload(account, voiceCode, text);
+            WsResult result = new WsResult();
+            Request request = new Request.Builder()
+                    .url(wsUrl)
+                    .header("User-Agent", "ViiTorTTSWebApi/1.0")
+                    .build();
+
+            wsClient.newWebSocket(request, new WebSocketListener() {
+                @Override
+                public void onOpen(WebSocket webSocket, Response response) {
+                    result.webSocket = webSocket;
+                    if (!webSocket.send(payload.toJSONString())) {
+                        result.fail("send viitor websocket payload failed");
+                        webSocket.close(1000, "send-failed");
+                    }
+                }
+
+                @Override
+                public void onMessage(WebSocket webSocket, String textMessage) {
+                    try {
+                        JSONObject json = JSON.parseObject(textMessage);
+                        Integer errorCode = json.getInteger("errorCode");
+                        if (errorCode != null && errorCode != 0) {
+                            result.fail("viitor errorCode=" + errorCode + ", msg=" + json.getString("errorMessage"));
+                            webSocket.close(1000, "error-code");
+                            return;
+                        }
+                        String event = json.getString("event");
+                        if ("init".equals(event)) {
+                            result.initReceived = true;
+                            return;
+                        }
+                        if ("audio".equals(event)) {
+                            Integer sampleRate = json.getInteger("sampleRate");
+                            if (sampleRate != null && sampleRate > 0) {
+                                result.sampleRate = sampleRate;
+                            }
+                            String audioBase64 = json.getString("audioBase64");
+                            if (StringUtils.isNotBlank(audioBase64)) {
+                                result.audioOutput.write(Base64.getDecoder().decode(audioBase64));
+                            }
+                            return;
+                        }
+                        if ("done".equals(event)) {
+                            result.finish();
+                            webSocket.close(1000, "done");
+                            return;
+                        }
+                        if ("error".equals(event)) {
+                            result.fail("viitor ws error: " + json.toJSONString());
+                            webSocket.close(1000, "ws-error");
+                        }
+                    } catch (Throwable e) {
+                        result.fail("parse viitor websocket message failed: " + e.getMessage());
+                        webSocket.close(1000, "parse-error");
+                    }
+                }
+
+                @Override
+                public void onMessage(WebSocket webSocket, ByteString bytes) {
+                    result.fail("unexpected binary frame from viitor websocket");
+                    webSocket.close(1000, "unexpected-binary");
+                }
+
+                @Override
+                public void onFailure(WebSocket webSocket, Throwable t, Response response) {
+                    result.fail("viitor websocket failure: " + (t == null ? "" : t.getMessage()));
+                }
+
+                @Override
+                public void onClosed(WebSocket webSocket, int code, String reason) {
+                    if (!result.completed && !result.failed) {
+                        result.fail("viitor websocket closed before done, code=" + code + ", reason=" + reason);
+                    }
+                }
+            });
+
+            if (!result.latch.await(40, TimeUnit.SECONDS)) {
+                if (result.webSocket != null) {
+                    result.webSocket.close(1000, "timeout");
+                }
+                log.error("viitor tts websocket timeout, voiceName={}, text={}", voiceName, text);
+                return false;
+            }
+            if (result.failed) {
+                log.error("viitor tts websocket synthesize failed: {}", result.errorMessage);
+                return false;
+            }
+            if (!result.initReceived) {
+                log.error("viitor tts websocket missing init event");
+                return false;
+            }
+            if (result.audioOutput.size() == 0) {
+                log.error("viitor tts websocket audio payload is empty");
+                return false;
+            }
+            return saveAudioFile(audioSaveFile, result.audioOutput.toByteArray(), result.sampleRate);
+        } catch (Throwable e) {
+            log.error("viitor tts synthesize failed: {}", e.toString(), e);
+            return false;
+        }
+    }
+
+    public static boolean shortTextTTSWebAPI(String voiceCode, String text, String ttsPath) {
+        if (!StringUtils.isNotBlank(StringUtils.trim(text))) {
+            log.info("tts text can not be null, ttsPath={}", ttsPath);
+            return true;
+        }
+        JSONObject account = getAccount();
+        if (account == null) {
+            return false;
+        }
+        return processWebSocketRequest(account, voiceCode, text, ttsPath);
+    }
+}