| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634 |
- 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<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 (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<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 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);
- }
- }
|