|
@@ -0,0 +1,196 @@
|
|
|
|
|
+package com.etotem.cfc.service;
|
|
|
|
|
+
|
|
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
|
|
+import okhttp3.MediaType;
|
|
|
|
|
+import okhttp3.OkHttpClient;
|
|
|
|
|
+import okhttp3.Request;
|
|
|
|
|
+import okhttp3.RequestBody;
|
|
|
|
|
+import okhttp3.Response;
|
|
|
|
|
+import org.slf4j.Logger;
|
|
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import javax.crypto.Mac;
|
|
|
|
|
+import javax.crypto.spec.SecretKeySpec;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.security.MessageDigest;
|
|
|
|
|
+import java.util.Base64;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.TreeMap;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 腾讯云语音合成(TTS)服务封装。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 直接调用腾讯云 TTS TextToVoice API(TC3-HMAC-SHA256 签名),
|
|
|
|
|
+ * 不依赖腾讯云 Java SDK(避免新增 Maven 依赖)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 文档: https://cloud.tencent.com/document/product/1073/37995
|
|
|
|
|
+ */
|
|
|
|
|
+@Service
|
|
|
|
|
+public class TencentTtsService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(TencentTtsService.class);
|
|
|
|
|
+
|
|
|
|
|
+ private static final String TTS_ENDPOINT = "https://tts.tencentcloudapi.com";
|
|
|
|
|
+ private static final String SERVICE = "tts";
|
|
|
|
|
+ private static final String HOST = "tts.tencentcloudapi.com";
|
|
|
|
|
+ private static final String ACTION = "TextToVoice";
|
|
|
|
|
+ private static final String VERSION = "2019-08-23";
|
|
|
|
|
+ private static final String REGION = "ap-guangzhou";
|
|
|
|
|
+ private static final String ALGORITHM = "TC3-HMAC-SHA256";
|
|
|
|
|
+ private static final String CT = "application/json; charset=utf-8";
|
|
|
|
|
+ private static final MediaType JSON_MEDIA = MediaType.parse("application/json; charset=utf-8");
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${tencent.tts.secret-id:}")
|
|
|
|
|
+ private String secretId;
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${tencent.tts.secret-key:}")
|
|
|
|
|
+ private String secretKey;
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${tencent.tts.voice-type:101002}")
|
|
|
|
|
+ private int voiceType;
|
|
|
|
|
+
|
|
|
|
|
+ @Value("${tencent.tts.codec:mp3}")
|
|
|
|
|
+ private String codec;
|
|
|
|
|
+
|
|
|
|
|
+ /** 单次合成最大字符数(腾讯云 TextToVoice 限制 150 字,留余量取 120) */
|
|
|
|
|
+ private static final int MAX_CHARS_PER_CALL = 120;
|
|
|
|
|
+
|
|
|
|
|
+ private final OkHttpClient okHttpClient = new OkHttpClient.Builder()
|
|
|
|
|
+ .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
|
|
|
|
+ .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
|
|
|
|
+ .build();
|
|
|
|
|
+
|
|
|
|
|
+ public boolean isConfigured() {
|
|
|
|
|
+ return secretId != null && !secretId.isEmpty()
|
|
|
|
|
+ && secretKey != null && !secretKey.isEmpty();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 将一段文本合成为音频字节(mp3),单次调用建议不超过 120 字符。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param text 待合成文本
|
|
|
|
|
+ * @return 音频字节(mp3)
|
|
|
|
|
+ * @throws IllegalStateException 未配置密钥或合成失败
|
|
|
|
|
+ */
|
|
|
|
|
+ public byte[] synthesize(String text) {
|
|
|
|
|
+ if (!isConfigured()) {
|
|
|
|
|
+ throw new IllegalStateException("腾讯云TTS未配置(tencent.tts.secret-id/secret-key)");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (text == null || text.trim().isEmpty()) {
|
|
|
|
|
+ throw new IllegalArgumentException("待合成文本为空");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ JSONObject payload = new JSONObject();
|
|
|
|
|
+ payload.put("Text", text);
|
|
|
|
|
+ payload.put("SessionId", java.util.UUID.randomUUID().toString());
|
|
|
|
|
+ payload.put("ModelType", 1);
|
|
|
|
|
+ payload.put("VoiceType", voiceType);
|
|
|
|
|
+ payload.put("Codec", codec);
|
|
|
|
|
+ payload.put("SampleRate", 16000);
|
|
|
|
|
+ payload.put("Volume", 0);
|
|
|
|
|
+ payload.put("Speed", 0);
|
|
|
|
|
+ payload.put("PrimaryLanguage", 1);
|
|
|
|
|
+
|
|
|
|
|
+ String body = payload.toJSONString();
|
|
|
|
|
+ String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
|
|
|
|
|
+ String authorization = buildAuthorization(body, timestamp);
|
|
|
|
|
+
|
|
|
|
|
+ Request request = new Request.Builder()
|
|
|
|
|
+ .url(TTS_ENDPOINT)
|
|
|
|
|
+ .post(RequestBody.create(JSON_MEDIA, body))
|
|
|
|
|
+ .header("Host", HOST)
|
|
|
|
|
+ .header("X-TC-Action", ACTION)
|
|
|
|
|
+ .header("X-TC-Version", VERSION)
|
|
|
|
|
+ .header("X-TC-Timestamp", timestamp)
|
|
|
|
|
+ .header("X-TC-Region", REGION)
|
|
|
|
|
+ .header("X-TC-Token", "")
|
|
|
|
|
+ .header("Authorization", authorization)
|
|
|
|
|
+ .header("Content-Type", CT)
|
|
|
|
|
+ .build();
|
|
|
|
|
+
|
|
|
|
|
+ try (Response response = okHttpClient.newCall(request).execute()) {
|
|
|
|
|
+ String respBody = response.body() == null ? "" : response.body().string();
|
|
|
|
|
+ if (!response.isSuccessful()) {
|
|
|
|
|
+ log.warn("腾讯云TTS请求失败: code={}, body={}", response.code(), respBody);
|
|
|
|
|
+ throw new RuntimeException("TTS合成失败: HTTP " + response.code());
|
|
|
|
|
+ }
|
|
|
|
|
+ JSONObject resp = JSON.parseObject(respBody);
|
|
|
|
|
+ JSONObject error = resp.getJSONObject("Error");
|
|
|
|
|
+ if (error != null) {
|
|
|
|
|
+ String msg = error.getString("Message");
|
|
|
|
|
+ log.warn("腾讯云TTS业务错误: {}", msg);
|
|
|
|
|
+ throw new RuntimeException("TTS合成失败: " + msg);
|
|
|
|
|
+ }
|
|
|
|
|
+ String audioBase64 = resp.getJSONObject("Response").getString("Audio");
|
|
|
|
|
+ if (audioBase64 == null || audioBase64.isEmpty()) {
|
|
|
|
|
+ throw new RuntimeException("TTS合成返回空音频");
|
|
|
|
|
+ }
|
|
|
|
|
+ return Base64.getDecoder().decode(audioBase64);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (IllegalStateException | IllegalArgumentException e) {
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("TTS合成异常", e);
|
|
|
|
|
+ throw new RuntimeException("TTS合成异常: " + e.getMessage(), e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 计算 TC3-HMAC-SHA256 签名头。
|
|
|
|
|
+ */
|
|
|
|
|
+ private String buildAuthorization(String body, String timestamp) throws Exception {
|
|
|
|
|
+ String date = timestamp == null || timestamp.length() < 8
|
|
|
|
|
+ ? "" : timestamp.substring(0, 8); // 取前8位 yyyyMMdd
|
|
|
|
|
+ String service = SERVICE;
|
|
|
|
|
+ String version = "tc3_request";
|
|
|
|
|
+
|
|
|
|
|
+ // 1. CanonicalRequest
|
|
|
|
|
+ String canonicalUri = "/";
|
|
|
|
|
+ String canonicalQuery = "";
|
|
|
|
|
+ String canonicalHeaders = "content-type:" + CT + "\nhost:" + HOST + "\n";
|
|
|
|
|
+ String signedHeaders = "content-type;host";
|
|
|
|
|
+ String hashedPayload = sha256Hex(body);
|
|
|
|
|
+ String canonicalRequest = "POST\n" + canonicalUri + "\n" + canonicalQuery + "\n"
|
|
|
|
|
+ + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedPayload;
|
|
|
|
|
+
|
|
|
|
|
+ // 2. StringToSign
|
|
|
|
|
+ String credentialScope = date + "/" + service + "/" + version;
|
|
|
|
|
+ String hashedCanonicalRequest = sha256Hex(canonicalRequest);
|
|
|
|
|
+ String stringToSign = ALGORITHM + "\n" + timestamp + "\n" + credentialScope + "\n"
|
|
|
|
|
+ + hashedCanonicalRequest;
|
|
|
|
|
+
|
|
|
|
|
+ // 3. Signature
|
|
|
|
|
+ byte[] secretDate = hmacSha256((secretKey).getBytes(StandardCharsets.UTF_8), date);
|
|
|
|
|
+ byte[] secretService = hmacSha256(secretDate, service);
|
|
|
|
|
+ byte[] secretSigning = hmacSha256(secretService, version);
|
|
|
|
|
+ byte[] signatureBytes = hmacSha256(secretSigning, stringToSign);
|
|
|
|
|
+ String signature = hex(signatureBytes);
|
|
|
|
|
+
|
|
|
|
|
+ // 4. Authorization
|
|
|
|
|
+ return ALGORITHM + " Credential=" + secretId + "/" + credentialScope
|
|
|
|
|
+ + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static byte[] hmacSha256(byte[] key, String data) throws Exception {
|
|
|
|
|
+ Mac mac = Mac.getInstance("HmacSHA256");
|
|
|
|
|
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
|
|
|
|
+ return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String sha256Hex(String data) throws Exception {
|
|
|
|
|
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
|
|
|
|
|
+ return hex(md.digest(data.getBytes(StandardCharsets.UTF_8)));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String hex(byte[] bytes) {
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ for (byte b : bytes) {
|
|
|
|
|
+ sb.append(String.format("%02x", b));
|
|
|
|
|
+ }
|
|
|
|
|
+ return sb.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|