yzx 2 هفته پیش
والد
کامیت
e94ac83179
1فایلهای تغییر یافته به همراه408 افزوده شده و 83 حذف شده
  1. 408 83
      src/main/java/com/telerobot/fs/tts/tencent/TencentTTSWebApi.java

+ 408 - 83
src/main/java/com/telerobot/fs/tts/tencent/TencentTTSWebApi.java

@@ -3,35 +3,78 @@ package com.telerobot.fs.tts.tencent;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.telerobot.fs.config.SystemConfig;
-import okhttp3.*;
+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.Map;
+import java.util.TreeMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.TimeUnit;
 
 public class TencentTTSWebApi {
     private static final Logger log = LoggerFactory.getLogger(TencentTTSWebApi.class);
-    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 CLIENT = new OkHttpClient.Builder()
             .connectTimeout(10, TimeUnit.SECONDS)
-            .readTimeout(20, TimeUnit.SECONDS)
+            .readTimeout(0, TimeUnit.MILLISECONDS)
             .build();
 
     private static volatile String ttsAccountJson;
     private static volatile JSONObject ttsAccount;
 
+    private TencentTTSWebApi() {
+    }
+
+    private static final class WsResult {
+        private final CountDownLatch latch = new CountDownLatch(1);
+        private final ByteArrayOutputStream audioOutput = new ByteArrayOutputStream();
+        private volatile boolean handshakeDone = false;
+        private volatile boolean completed = false;
+        private volatile boolean failed = false;
+        private volatile String errorMessage = "";
+        private volatile String negotiatedFormat = "";
+        private volatile int negotiatedSampleRate = 0;
+        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("tx-tts1-account-json", "");
         if (StringUtils.isBlank(latestJson)) {
@@ -71,6 +114,21 @@ public class TencentTTSWebApi {
         return value == null ? defaultValue : value;
     }
 
+    private static double configDouble(JSONObject account, String key, double defaultValue) {
+        if (account == null) {
+            return defaultValue;
+        }
+        String value = account.getString(key);
+        if (StringUtils.isBlank(value)) {
+            return defaultValue;
+        }
+        try {
+            return Double.parseDouble(value.trim());
+        } catch (Throwable e) {
+            return defaultValue;
+        }
+    }
+
     private static boolean configBool(JSONObject account, String key, boolean defaultValue) {
         if (account == null) {
             return defaultValue;
@@ -96,6 +154,54 @@ public class TencentTTSWebApi {
         return idx > 0 ? lang.substring(0, idx) : lang;
     }
 
+    private static String normalizeFormat(String value) {
+        if (StringUtils.isBlank(value)) {
+            return "wav";
+        }
+        return value.trim().toLowerCase();
+    }
+
+    private static int normalizeTimeout(int timeoutSec) {
+        if (timeoutSec <= 0) {
+            return 30;
+        }
+        return Math.min(timeoutSec, 120);
+    }
+
+    private static String formatDouble(double value) {
+        String raw = String.format(java.util.Locale.US, "%.3f", value);
+        while (raw.contains(".") && raw.endsWith("0")) {
+            raw = raw.substring(0, raw.length() - 1);
+        }
+        if (raw.endsWith(".")) {
+            raw = raw.substring(0, raw.length() - 1);
+        }
+        return raw;
+    }
+
+    private static String percentEncode(String value) {
+        if (value == null) {
+            return "";
+        }
+        byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+        StringBuilder sb = new StringBuilder(bytes.length * 3);
+        for (byte b : bytes) {
+            int ch = b & 0xFF;
+            if ((ch >= 'a' && ch <= 'z') ||
+                    (ch >= 'A' && ch <= 'Z') ||
+                    (ch >= '0' && ch <= '9') ||
+                    ch == '-' || ch == '_' || ch == '.' || ch == '~') {
+                sb.append((char) ch);
+            } else {
+                sb.append('%');
+                char hi = Character.toUpperCase(Character.forDigit((ch >> 4) & 0xF, 16));
+                char lo = Character.toUpperCase(Character.forDigit(ch & 0xF, 16));
+                sb.append(hi).append(lo);
+            }
+        }
+        return sb.toString();
+    }
+
     private static String sha256Hex(String value) throws Exception {
         MessageDigest digest = MessageDigest.getInstance("SHA-256");
         byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
@@ -120,18 +226,54 @@ public class TencentTTSWebApi {
         return sb.toString();
     }
 
-    private static String buildAuthorization(JSONObject account, String body, long timestamp, String endpoint, String action) throws Exception {
+    private static String buildWsUrl(JSONObject account,
+                                     String voiceId,
+                                     String format,
+                                     String language,
+                                     int sampleRate,
+                                     int timeoutSec,
+                                     double speed,
+                                     double volume,
+                                     String resId) throws Exception {
+        String host = config(account, "websocket-host", "mps.cloud.tencent.com");
+        String appId = config(account, "appid", "");
         String secretId = config(account, "secret-id", "");
         String secretKey = config(account, "secret-key", "");
+        long timestamp = System.currentTimeMillis() / 1000L;
+        long expired = timestamp + Math.max(60, configInt(account, "signature-expire-seconds", 3600));
+        String nonce = String.valueOf(ThreadLocalRandom.current().nextLong(100000000L, 9999999999L));
+        String path = "/tts/v1/" + appId;
+
+        TreeMap<String, String> params = new TreeMap<String, String>();
+        params.put("voiceId", voiceId);
+        params.put("format", format);
+        params.put("sampleRate", String.valueOf(sampleRate));
+        params.put("language", language);
+        params.put("timeoutSec", String.valueOf(normalizeTimeout(timeoutSec)));
+        if (Math.abs(speed) > 0.000001d) {
+            params.put("speed", formatDouble(speed));
+        }
+        if (Math.abs(volume) > 0.000001d) {
+            params.put("vol", formatDouble(volume));
+        }
+        if (StringUtils.isNotBlank(resId)) {
+            params.put("resId", resId);
+        }
+        params.put("secretId", secretId);
+        params.put("nonce", nonce);
+        params.put("timeStamp", String.valueOf(timestamp));
+        params.put("expired", String.valueOf(expired));
+
+        String canonicalQuery = buildCanonicalQuery(params);
+        String canonicalRequest = "post\n" +
+                path + "\n" +
+                canonicalQuery + "\n" +
+                "content-type:application/json; charset=utf-8\n" +
+                "host:" + host + "\n\n" +
+                "content-type;host\n" +
+                sha256Hex("");
+
         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" +
@@ -142,96 +284,279 @@ public class TencentTTSWebApi {
         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;
+
+        StringBuilder queryBuilder = new StringBuilder();
+        boolean first = true;
+        for (Map.Entry<String, String> entry : params.entrySet()) {
+            if (!first) {
+                queryBuilder.append('&');
+            }
+            first = false;
+            queryBuilder.append(percentEncode(entry.getKey()))
+                    .append('=')
+                    .append(percentEncode(entry.getValue()));
+        }
+        queryBuilder.append("&signature=").append(signature);
+        return "wss://" + host + path + "?" + queryBuilder;
+    }
+
+    private static String buildCanonicalQuery(TreeMap<String, String> params) {
+        StringBuilder builder = new StringBuilder();
+        boolean first = true;
+        for (Map.Entry<String, String> entry : params.entrySet()) {
+            if (!first) {
+                builder.append('&');
+            }
+            first = false;
+            builder.append(entry.getKey()).append('=').append(percentEncode(entry.getValue()));
+        }
+        return builder.toString();
+    }
+
+    private static JSONObject buildTextPacket(String text, boolean finalFlag) {
+        JSONObject packet = new JSONObject(true);
+        packet.put("Text", text);
+        packet.put("Final", finalFlag);
+        return packet;
     }
 
-    private static boolean processPostRequest(JSONObject account, String voiceCode, String text, String audioSaveFile) {
+    private static OkHttpClient buildClient(boolean verifyPeer) throws Exception {
+        if (verifyPeer) {
+            return CLIENT;
+        }
+
+        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());
+        return CLIENT.newBuilder()
+                .sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0])
+                .hostnameVerifier((hostname, session) -> true)
+                .build();
+    }
+
+    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, String format, int sampleRate) throws Exception {
+        int audioFormat;
+        int bitsPerSample;
+        if ("pcm".equals(format)) {
+            audioFormat = 1;
+            bitsPerSample = 16;
+        } else if ("alaw".equals(format)) {
+            audioFormat = 6;
+            bitsPerSample = 8;
+        } else if ("ulaw".equals(format)) {
+            audioFormat = 7;
+            bitsPerSample = 8;
+        } else {
+            throw new IllegalArgumentException("unsupported wav wrap format: " + format);
+        }
+
+        int channels = 1;
+        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, audioFormat);
+        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, String negotiatedFormat, int sampleRate, byte[] audioBytes) {
         try {
-            String endpoint = config(account, "endpoint", "mps.tencentcloudapi.com");
-            String action = config(account, "action", "SyncDubbing");
-            String version = config(account, "version", "2019-06-12");
-            String region = config(account, "region", "");
-            String resourceId = config(account, "resource-id", "");
+            File target = new File(targetPath);
+            String lowerPath = targetPath.toLowerCase();
+            if ("wav".equals(negotiatedFormat)) {
+                try (FileOutputStream fout = new FileOutputStream(target)) {
+                    fout.write(audioBytes);
+                }
+                return true;
+            }
+            if ("pcm".equals(negotiatedFormat) || "ulaw".equals(negotiatedFormat) || "alaw".equals(negotiatedFormat)) {
+                writeWavFile(targetPath, audioBytes, negotiatedFormat, sampleRate);
+                return true;
+            }
+            if (lowerPath.endsWith(".wav")) {
+                log.error("Tencent tts negotiated format={} cannot be safely saved to wav path={}", negotiatedFormat, targetPath);
+                return false;
+            }
+            try (FileOutputStream fout = new FileOutputStream(target)) {
+                fout.write(audioBytes);
+            }
+            return true;
+        } catch (Throwable e) {
+            log.error("save Tencent tts audio 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 secretId = config(account, "secret-id", "");
+            String secretKey = config(account, "secret-key", "");
+            String voiceId = StringUtils.isNotBlank(voiceCode) ? voiceCode : config(account, "voice-id", "");
             String language = normalizeLanguage(config(account, "text-lang", "zh"));
-            int sampleRate = configInt(account, "sample-rate", 8000);
-            int pitch = configInt(account, "pitch", 0);
+            String requestedFormat = normalizeFormat(config(account, "format", "wav"));
+            int requestedSampleRate = configInt(account, "sample-rate", 8000);
+            int timeoutSec = configInt(account, "timeout-sec", 60);
+            double speed = configDouble(account, "speed", 0d);
+            double volume = configDouble(account, "vol", 0d);
+            String resId = config(account, "res-id", "");
             boolean verifyPeer = configBool(account, "verify-peer", false);
+            int connectTimeoutMs = configInt(account, "connect-timeout-ms", 10000);
 
-            JSONObject synExt = new JSONObject();
-            synExt.put("sampleRate", sampleRate);
-            synExt.put("pitch", pitch);
-            JSONObject extParam = new JSONObject();
-            extParam.put("synExt", synExt);
-
-            JSONObject bodyJson = new JSONObject();
-            bodyJson.put("Text", text);
-            bodyJson.put("TextLang", language);
-            bodyJson.put("VoiceId", StringUtils.isNotBlank(voiceCode) ? voiceCode : config(account, "voice-id", ""));
-            if (StringUtils.isNotBlank(resourceId)) {
-                bodyJson.put("ResourceId", resourceId);
-            }
-            bodyJson.put("ExtParam", extParam.toJSONString());
-            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");
-            if (StringUtils.isNotBlank(region)) {
-                builder.addHeader("X-TC-Region", region);
+            if (StringUtils.isBlank(appId) || StringUtils.isBlank(secretId) || StringUtils.isBlank(secretKey) || StringUtils.isBlank(voiceId)) {
+                log.error("Tencent tts websocket config missing appid/secret-id/secret-key/voice-id");
+                return false;
             }
 
-            OkHttpClient client = verifyPeer ? CLIENT : CLIENT.newBuilder()
-                    .hostnameVerifier((hostname, session) -> true)
+            String wsUrl = buildWsUrl(account, voiceId, requestedFormat, language, requestedSampleRate, timeoutSec, speed, volume, resId);
+            OkHttpClient client = buildClient(verifyPeer).newBuilder()
+                    .connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS)
+                    .readTimeout(0, TimeUnit.MILLISECONDS)
+                    .build();
+
+            WsResult result = new WsResult();
+            Request request = new Request.Builder()
+                    .url(wsUrl)
+                    .header("User-Agent", "TencentTTSWebApi/1.0")
                     .build();
 
-            try (Response response = client.newCall(builder.build()).execute()) {
-                String responseBody = response.body() == null ? "" : response.body().string();
-                if (!response.isSuccessful()) {
-                    log.error("Tencent tts http request failed, code={}, body={}", response.code(), responseBody);
-                    return false;
+            client.newWebSocket(request, new WebSocketListener() {
+                @Override
+                public void onOpen(WebSocket webSocket, Response response) {
+                    result.webSocket = webSocket;
                 }
 
-                JSONObject json = JSON.parseObject(responseBody);
-                JSONObject rsp = json.getJSONObject("Response");
-                if (rsp == null) {
-                    log.error("Tencent tts invalid response: {}", responseBody);
-                    return false;
+                @Override
+                public void onMessage(WebSocket webSocket, String textMessage) {
+                    try {
+                        JSONObject json = JSON.parseObject(textMessage);
+                        String notificationType = json.getString("NotificationType");
+                        if ("Handshake".equals(notificationType)) {
+                            JSONObject handshake = json.getJSONObject("HandshakeResult");
+                            int code = handshake == null ? -1 : handshake.getIntValue("Code");
+                            String message = handshake == null ? "missing HandshakeResult" : handshake.getString("Message");
+                            if (code != 0) {
+                                result.fail("tencent handshake failed code=" + code + " message=" + message);
+                                webSocket.close(1000, "handshake-failed");
+                                return;
+                            }
+                            result.negotiatedFormat = normalizeFormat(handshake.getString("Format"));
+                            result.negotiatedSampleRate = handshake.getIntValue("SampleRate");
+                            result.handshakeDone = true;
+                            if (!webSocket.send(buildTextPacket(text, false).toJSONString()) ||
+                                    !webSocket.send(buildTextPacket("", true).toJSONString())) {
+                                result.fail("send websocket text packet failed");
+                                webSocket.close(1000, "send-failed");
+                            }
+                            return;
+                        }
+
+                        if ("ProcessEof".equals(notificationType)) {
+                            JSONObject eofInfo = json.getJSONObject("ProcessEofInfo");
+                            int code = eofInfo == null ? -1 : eofInfo.getIntValue("Code");
+                            String message = eofInfo == null ? "missing ProcessEofInfo" : eofInfo.getString("Message");
+                            if (code != 0) {
+                                result.fail("tencent process eof code=" + code + " message=" + message);
+                            } else {
+                                result.finish();
+                            }
+                            webSocket.close(1000, "done");
+                        }
+                    } catch (Throwable e) {
+                        result.fail("parse websocket text failed: " + e.getMessage());
+                        webSocket.close(1000, "parse-error");
+                    }
                 }
-                if (rsp.containsKey("Error")) {
-                    log.error("Tencent tts api error: {}", rsp.getJSONObject("Error").toJSONString());
-                    return false;
+
+                @Override
+                public void onMessage(WebSocket webSocket, ByteString bytes) {
+                    try {
+                        result.audioOutput.write(bytes.toByteArray());
+                    } catch (Throwable e) {
+                        result.fail("buffer websocket audio failed: " + e.getMessage());
+                        webSocket.close(1000, "audio-buffer-error");
+                    }
                 }
-                Integer errorCode = rsp.getInteger("ErrorCode");
-                if (errorCode != null && errorCode != 0) {
-                    log.error("Tencent tts business error, code={}, msg={}", errorCode, rsp.getString("Msg"));
-                    return false;
+
+                @Override
+                public void onFailure(WebSocket webSocket, Throwable t, Response response) {
+                    result.fail("websocket failure: " + (t == null ? "" : t.getMessage()));
                 }
 
-                String audioData = rsp.getString("AudioData");
-                if (StringUtils.isBlank(audioData)) {
-                    log.error("Tencent tts response AudioData is empty: {}", responseBody);
-                    return false;
+                @Override
+                public void onClosed(WebSocket webSocket, int code, String reason) {
+                    if (!result.completed && !result.failed) {
+                        result.fail("websocket closed before ProcessEof, code=" + code + ", reason=" + reason);
+                    }
                 }
+            });
 
-                byte[] wavBytes = Base64.getDecoder().decode(audioData);
-                File target = new File(audioSaveFile);
-                try (FileOutputStream fout = new FileOutputStream(target)) {
-                    fout.write(wavBytes);
+            if (!result.latch.await(Math.max(15, normalizeTimeout(timeoutSec) + 5), TimeUnit.SECONDS)) {
+                if (result.webSocket != null) {
+                    result.webSocket.close(1000, "timeout");
                 }
-                return true;
+                log.error("Tencent tts websocket timeout, voiceId={}, text={}", voiceId, text);
+                return false;
+            }
+
+            if (result.failed) {
+                log.error("Tencent tts websocket synthesize failed: {}", result.errorMessage);
+                return false;
             }
+            if (!result.handshakeDone) {
+                log.error("Tencent tts websocket missing handshake success.");
+                return false;
+            }
+            if (result.audioOutput.size() == 0) {
+                log.error("Tencent tts websocket audio payload is empty.");
+                return false;
+            }
+
+            return saveAudioFile(audioSaveFile, result.negotiatedFormat, result.negotiatedSampleRate, result.audioOutput.toByteArray());
         } catch (Throwable e) {
-            log.error("Tencent tts synthesize failed: {}", e.toString());
+            log.error("Tencent tts synthesize failed: {}", e.toString(), e);
             return false;
         }
     }
@@ -245,6 +570,6 @@ public class TencentTTSWebApi {
         if (account == null) {
             return false;
         }
-        return processPostRequest(account, voiceCode, text, ttsPath);
+        return processWebSocketRequest(account, voiceCode, text, ttsPath);
     }
 }