|
|
@@ -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);
|
|
|
}
|
|
|
}
|