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 Integer configNullableInt(JSONObject account, String... keys) { if (account == null || keys == null) { return null; } for (String key : keys) { if (StringUtils.isBlank(key)) { continue; } String value = account.getString(key); if (StringUtils.isBlank(value)) { continue; } try { return Integer.parseInt(value.trim()); } catch (Throwable ignore) { } } return null; } 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 boolean isEffectInRange(Integer value) { return value != null && value >= -100 && value <= 100; } private static String normalizeSoundEffect(String value) { if (StringUtils.isBlank(value)) { return ""; } String trimmed = value.trim(); if ("spacious_echo".equals(trimmed) || "auditorium_echo".equals(trimmed) || "lofi_telephone".equals(trimmed) || "robotic".equals(trimmed)) { return trimmed; } return ""; } 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, Integer pitch, Integer intensity, Integer timbre, String soundEffect) 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 (isEffectInRange(pitch)) { params.put("pitch", String.valueOf(pitch)); } if (isEffectInRange(intensity)) { params.put("intensity", String.valueOf(intensity)); } if (isEffectInRange(timbre)) { params.put("timbre", String.valueOf(timbre)); } if (StringUtils.isNotBlank(soundEffect)) { params.put("soundEffect", soundEffect); } 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); Integer pitch = configNullableInt(account, "pitch"); Integer intensity = configNullableInt(account, "intensity"); Integer timbre = configNullableInt(account, "timbre"); String soundEffect = normalizeSoundEffect(config(account, "sound-effect", config(account, "soundEffect", ""))); 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, pitch, intensity, timbre, soundEffect); 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); } }