package com.telerobot.fs.tts.tencent; 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.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 DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC); 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 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)) { log.error("param `tx-tts1-account-json` can not be blank."); return null; } if (!latestJson.equals(ttsAccountJson) || ttsAccount == null) { synchronized (TencentTTSWebApi.class) { latestJson = SystemConfig.getValue("tx-tts1-account-json", ""); if (!latestJson.equals(ttsAccountJson) || ttsAccount == null) { ttsAccountJson = latestJson; try { ttsAccount = JSON.parseObject(latestJson); } catch (Throwable e) { log.error("parse `tx-tts1-account-json` error: {}", e.toString()); 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) { if (account == null) { return defaultValue; } Integer value = account.getInteger(key); 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; } String value = account.getString(key); if (StringUtils.isBlank(value)) { return defaultValue; } return "1".equals(value) || "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value); } private static String normalizeLanguage(String value) { if (StringUtils.isBlank(value)) { return "zh"; } String lang = value.trim().replace('_', '-'); if ("zh-CN".equalsIgnoreCase(lang) || "zh".equalsIgnoreCase(lang)) return "zh"; if ("en-US".equalsIgnoreCase(lang) || "en".equalsIgnoreCase(lang)) return "en"; if ("ja-JP".equalsIgnoreCase(lang) || "ja".equalsIgnoreCase(lang)) return "ja"; if ("ko-KR".equalsIgnoreCase(lang) || "ko".equalsIgnoreCase(lang)) return "ko"; if ("yue-HK".equalsIgnoreCase(lang) || "yue".equalsIgnoreCase(lang)) return "yue"; int idx = lang.indexOf('-'); return idx > 0 ? lang.substring(0, idx) : lang; } private 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)); return bytesToHex(hash); } private static byte[] hmacSha256(byte[] key, String message) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key, "HmacSHA256")); return mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); } private static byte[] hmacSha256(String key, String message) throws Exception { return hmacSha256(key.getBytes(StandardCharsets.UTF_8), 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 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 params = new TreeMap(); 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"; String date = DATE_FORMATTER.format(Instant.ofEpochSecond(timestamp)); String credentialScope = date + "/mps/tc3_request"; String stringToSign = "TC3-HMAC-SHA256\n" + timestamp + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest); byte[] kDate = hmacSha256("TC3" + secretKey, date); byte[] kService = hmacSha256(kDate, "mps"); byte[] kSigning = hmacSha256(kService, "tc3_request"); String signature = bytesToHex(hmacSha256(kSigning, stringToSign)); StringBuilder queryBuilder = new StringBuilder(); boolean first = true; for (Map.Entry 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 params) { StringBuilder builder = new StringBuilder(); boolean first = true; for (Map.Entry 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 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 { 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")); 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); 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; } 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(); client.newWebSocket(request, new WebSocketListener() { @Override public void onOpen(WebSocket webSocket, Response response) { result.webSocket = webSocket; } @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"); } } @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"); } } @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { result.fail("websocket failure: " + (t == null ? "" : t.getMessage())); } @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); } } }); if (!result.latch.await(Math.max(15, normalizeTimeout(timeoutSec) + 5), TimeUnit.SECONDS)) { if (result.webSocket != null) { result.webSocket.close(1000, "timeout"); } 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(), 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); } }