|
|
@@ -35,14 +35,22 @@ import javax.crypto.spec.SecretKeySpec;
|
|
|
import javax.servlet.http.HttpServletRequest;
|
|
|
import java.io.ByteArrayOutputStream;
|
|
|
import java.io.IOException;
|
|
|
+import java.io.InputStream;
|
|
|
+import java.io.OutputStream;
|
|
|
+import java.net.HttpURLConnection;
|
|
|
+import java.net.URL;
|
|
|
import java.net.URLEncoder;
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.nio.file.Paths;
|
|
|
import java.security.MessageDigest;
|
|
|
import java.time.ZoneId;
|
|
|
import java.time.ZonedDateTime;
|
|
|
import java.time.format.DateTimeFormatter;
|
|
|
import java.util.Base64;
|
|
|
import java.util.LinkedHashMap;
|
|
|
+import java.util.Locale;
|
|
|
import java.util.Map;
|
|
|
import java.util.concurrent.CountDownLatch;
|
|
|
import java.util.concurrent.TimeUnit;
|
|
|
@@ -68,6 +76,8 @@ public class XfVoiceCloneController extends BaseController {
|
|
|
private static final String CLONE_TTS_WS_URL = "wss://cn-huabei-1.xf-yun.com/v1/private/voice_clone";
|
|
|
private static final String CLONE_TTS_HOST = "cn-huabei-1.xf-yun.com";
|
|
|
private static final String CLONE_TTS_PATH = "/v1/private/voice_clone";
|
|
|
+ private static final DateTimeFormatter CLONE_AUTH_DATE_FORMATTER =
|
|
|
+ DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US);
|
|
|
private static final Long DEFAULT_TEXT_ID = 5001L;
|
|
|
private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder()
|
|
|
.connectTimeout(30, TimeUnit.SECONDS)
|
|
|
@@ -319,15 +329,53 @@ public class XfVoiceCloneController extends BaseController {
|
|
|
}
|
|
|
|
|
|
private String synthesizeCloneAudio(JSONObject account, String assetId, String text, String engineVersion) throws Exception {
|
|
|
- String authUrl = buildCloneWebsocketUrl(account.getString("apiKey"), account.getString("apiSecret"));
|
|
|
+ CloneWebsocketAuth auth = buildCloneWebsocketAuth(account.getString("apiKey"), account.getString("apiSecret"));
|
|
|
CountDownLatch latch = new CountDownLatch(1);
|
|
|
AtomicReference<String> errRef = new AtomicReference<>();
|
|
|
ByteArrayOutputStream audioOutput = new ByteArrayOutputStream();
|
|
|
|
|
|
- Request request = new Request.Builder().url(authUrl).build();
|
|
|
+ // #region debug-point A:clone-auth-shape
|
|
|
+ reportDebugEvent("A", "XfVoiceCloneController.synthesizeCloneAudio",
|
|
|
+ "[DEBUG] clone websocket auth built",
|
|
|
+ new JSONObject(true)
|
|
|
+ .fluentPut("url", auth.url)
|
|
|
+ .fluentPut("date", auth.date)
|
|
|
+ .fluentPut("authorizationLength", auth.authorization == null ? 0 : auth.authorization.length())
|
|
|
+ .fluentPut("systemZone", ZoneId.systemDefault().getId())
|
|
|
+ .fluentPut("systemNow", ZonedDateTime.now().toString())
|
|
|
+ .fluentPut("engineVersion", engineVersion)
|
|
|
+ .fluentPut("assetIdSuffix", StringUtils.right(assetId, 8)));
|
|
|
+ log.info("clone websocket auth built url={}, date={}, authorizationLength={}, systemZone={}, systemNow={}, engineVersion={}, assetIdSuffix={}",
|
|
|
+ auth.url,
|
|
|
+ auth.date,
|
|
|
+ auth.authorization == null ? 0 : auth.authorization.length(),
|
|
|
+ ZoneId.systemDefault().getId(),
|
|
|
+ ZonedDateTime.now(),
|
|
|
+ engineVersion,
|
|
|
+ StringUtils.right(assetId, 8));
|
|
|
+ // #endregion
|
|
|
+
|
|
|
+ Request request = new Request.Builder()
|
|
|
+ .url(auth.url)
|
|
|
+ .addHeader("Host", CLONE_TTS_HOST)
|
|
|
+ .addHeader("Date", auth.date)
|
|
|
+ .addHeader("Authorization", auth.authorization)
|
|
|
+ .build();
|
|
|
HTTP_CLIENT.newWebSocket(request, new WebSocketListener() {
|
|
|
@Override
|
|
|
public void onOpen(WebSocket webSocket, Response response) {
|
|
|
+ // #region debug-point B:clone-ws-open
|
|
|
+ reportDebugEvent("B", "XfVoiceCloneController.onOpen",
|
|
|
+ "[DEBUG] clone websocket opened",
|
|
|
+ new JSONObject(true)
|
|
|
+ .fluentPut("httpCode", response == null ? -1 : response.code())
|
|
|
+ .fluentPut("serverDate", response == null ? "" : String.valueOf(response.header("Date")))
|
|
|
+ .fluentPut("requestDate", auth.date));
|
|
|
+ log.info("clone websocket opened httpCode={}, serverDate={}, requestDate={}",
|
|
|
+ response == null ? -1 : response.code(),
|
|
|
+ response == null ? "" : String.valueOf(response.header("Date")),
|
|
|
+ auth.date);
|
|
|
+ // #endregion
|
|
|
JSONObject payload = buildCloneTtsPayload(account.getString("appId"), assetId, text, engineVersion);
|
|
|
webSocket.send(payload.toJSONString());
|
|
|
}
|
|
|
@@ -335,8 +383,10 @@ public class XfVoiceCloneController extends BaseController {
|
|
|
@Override
|
|
|
public void onMessage(WebSocket webSocket, String textMessage) {
|
|
|
try {
|
|
|
+ log.info("clone websocket message text={}", textMessage);
|
|
|
JSONObject json = JSON.parseObject(textMessage);
|
|
|
- int code = json.getIntValue("code");
|
|
|
+ JSONObject header = json.getJSONObject("header");
|
|
|
+ int code = header == null ? json.getIntValue("code") : header.getIntValue("code");
|
|
|
if (code != 0) {
|
|
|
errRef.set("讯飞试音接口返回错误: " + json.toJSONString());
|
|
|
webSocket.close(1000, "error");
|
|
|
@@ -374,12 +424,29 @@ public class XfVoiceCloneController extends BaseController {
|
|
|
|
|
|
@Override
|
|
|
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
|
|
|
+ // #region debug-point C:clone-ws-failure
|
|
|
+ reportDebugEvent("C", "XfVoiceCloneController.onFailure",
|
|
|
+ "[DEBUG] clone websocket failure",
|
|
|
+ new JSONObject(true)
|
|
|
+ .fluentPut("throwable", t == null ? "" : StringUtils.defaultString(t.getMessage()))
|
|
|
+ .fluentPut("httpCode", response == null ? -1 : response.code())
|
|
|
+ .fluentPut("responseBody", safeReadResponseBody(response))
|
|
|
+ .fluentPut("requestDate", auth.date)
|
|
|
+ .fluentPut("authorizationLength", auth.authorization == null ? 0 : auth.authorization.length()));
|
|
|
+ log.warn("clone websocket failure throwable={}, httpCode={}, responseBody={}, requestDate={}, authorizationLength={}",
|
|
|
+ t == null ? "" : StringUtils.defaultString(t.getMessage()),
|
|
|
+ response == null ? -1 : response.code(),
|
|
|
+ safeReadResponseBody(response),
|
|
|
+ auth.date,
|
|
|
+ auth.authorization == null ? 0 : auth.authorization.length());
|
|
|
+ // #endregion
|
|
|
errRef.set(buildWebsocketFailureMessage(t, response));
|
|
|
latch.countDown();
|
|
|
}
|
|
|
|
|
|
@Override
|
|
|
public void onClosed(WebSocket webSocket, int code, String reason) {
|
|
|
+ log.info("clone websocket closed code={}, reason={}", code, reason);
|
|
|
latch.countDown();
|
|
|
}
|
|
|
});
|
|
|
@@ -430,7 +497,7 @@ public class XfVoiceCloneController extends BaseController {
|
|
|
textNode.put("format", "plain");
|
|
|
textNode.put("status", 2);
|
|
|
textNode.put("seq", 0);
|
|
|
- textNode.put("text", text);
|
|
|
+ textNode.put("text", Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8)));
|
|
|
|
|
|
JSONObject payload = new JSONObject(true);
|
|
|
payload.put("text", textNode);
|
|
|
@@ -438,8 +505,8 @@ public class XfVoiceCloneController extends BaseController {
|
|
|
return root;
|
|
|
}
|
|
|
|
|
|
- private String buildCloneWebsocketUrl(String apiKey, String apiSecret) throws Exception {
|
|
|
- String date = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT")));
|
|
|
+ private CloneWebsocketAuth buildCloneWebsocketAuth(String apiKey, String apiSecret) throws Exception {
|
|
|
+ String date = CLONE_AUTH_DATE_FORMATTER.format(ZonedDateTime.now(ZoneId.of("GMT")));
|
|
|
String signatureOrigin = "host: " + CLONE_TTS_HOST + "\n" +
|
|
|
"date: " + date + "\n" +
|
|
|
"GET " + CLONE_TTS_PATH + " HTTP/1.1";
|
|
|
@@ -449,9 +516,10 @@ public class XfVoiceCloneController extends BaseController {
|
|
|
String authorizationOrigin = String.format("api_key=\"%s\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"%s\"",
|
|
|
apiKey, signature);
|
|
|
String authorization = Base64.getEncoder().encodeToString(authorizationOrigin.getBytes(StandardCharsets.UTF_8));
|
|
|
- return CLONE_TTS_WS_URL + "?authorization=" + urlEncode(authorization) +
|
|
|
+ String url = CLONE_TTS_WS_URL + "?authorization=" + urlEncode(authorization) +
|
|
|
"&date=" + urlEncode(date) +
|
|
|
"&host=" + urlEncode(CLONE_TTS_HOST);
|
|
|
+ return new CloneWebsocketAuth(url, date, authorization);
|
|
|
}
|
|
|
|
|
|
private String buildWebsocketFailureMessage(Throwable t, Response response) {
|
|
|
@@ -468,7 +536,12 @@ public class XfVoiceCloneController extends BaseController {
|
|
|
sb.append(", body=").append(bodyText);
|
|
|
}
|
|
|
if (response.code() == 403) {
|
|
|
- sb.append("。请确认当前 app-id/api-key/api-secret 是否属于“讯飞一句话复刻”服务应用,且该应用已开通 voice_clone/一句话复刻权限;普通在线TTS应用凭证无法调用该接口。");
|
|
|
+ if (StringUtils.containsIgnoreCase(bodyText, "valid date or x-date")
|
|
|
+ || StringUtils.containsIgnoreCase(bodyText, "HMAC signature cannot be verified")) {
|
|
|
+ sb.append("。当前更像是 WebSocket 时间头/签名校验失败,请先确认部署机器系统时间准确,并已同步标准时间;本次请求已自动补充 Date/X-Date 头,如仍失败,再检查当前 app-id/api-key/api-secret 是否属于“一句话复刻/voice_clone”服务应用。");
|
|
|
+ } else {
|
|
|
+ sb.append("。请确认当前 app-id/api-key/api-secret 是否属于“讯飞一句话复刻”服务应用,且该应用已开通 voice_clone/一句话复刻权限;普通在线TTS应用凭证无法调用该接口。");
|
|
|
+ }
|
|
|
}
|
|
|
} else if (t != null && StringUtils.isNotBlank(t.getMessage())) {
|
|
|
sb.append(": ").append(t.getMessage());
|
|
|
@@ -476,6 +549,76 @@ public class XfVoiceCloneController extends BaseController {
|
|
|
return sb.toString();
|
|
|
}
|
|
|
|
|
|
+ // #region debug-point D:debug-report-helper
|
|
|
+ private void reportDebugEvent(String hypothesisId, String location, String msg, JSONObject data) {
|
|
|
+ HttpURLConnection connection = null;
|
|
|
+ try {
|
|
|
+ String debugUrl = "http://127.0.0.1:7777/event";
|
|
|
+ String sessionId = "xfvoiceclone-403";
|
|
|
+ Path envPath = Paths.get(System.getProperty("user.dir"), ".dbg", "xfvoiceclone-403.env");
|
|
|
+ if (Files.exists(envPath)) {
|
|
|
+ for (String line : Files.readAllLines(envPath, StandardCharsets.UTF_8)) {
|
|
|
+ if (line.startsWith("DEBUG_SERVER_URL=")) {
|
|
|
+ debugUrl = line.substring("DEBUG_SERVER_URL=".length()).trim();
|
|
|
+ } else if (line.startsWith("DEBUG_SESSION_ID=")) {
|
|
|
+ sessionId = line.substring("DEBUG_SESSION_ID=".length()).trim();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ JSONObject payload = new JSONObject(true);
|
|
|
+ payload.put("sessionId", sessionId);
|
|
|
+ payload.put("runId", "pre-fix");
|
|
|
+ payload.put("hypothesisId", hypothesisId);
|
|
|
+ payload.put("location", location);
|
|
|
+ payload.put("msg", msg);
|
|
|
+ payload.put("data", data == null ? new JSONObject(true) : data);
|
|
|
+ payload.put("ts", System.currentTimeMillis());
|
|
|
+
|
|
|
+ connection = (HttpURLConnection) new URL(debugUrl).openConnection();
|
|
|
+ connection.setRequestMethod("POST");
|
|
|
+ connection.setConnectTimeout(800);
|
|
|
+ connection.setReadTimeout(800);
|
|
|
+ connection.setDoOutput(true);
|
|
|
+ connection.setRequestProperty("Content-Type", "application/json");
|
|
|
+ try (OutputStream os = connection.getOutputStream()) {
|
|
|
+ os.write(payload.toJSONString().getBytes(StandardCharsets.UTF_8));
|
|
|
+ }
|
|
|
+ try (InputStream ignored = connection.getInputStream()) {
|
|
|
+ // no-op
|
|
|
+ }
|
|
|
+ } catch (Exception ignore) {
|
|
|
+ // ignore debug reporting errors
|
|
|
+ } finally {
|
|
|
+ if (connection != null) {
|
|
|
+ connection.disconnect();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String safeReadResponseBody(Response response) {
|
|
|
+ if (response == null || response.body() == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return response.peekBody(4096).string();
|
|
|
+ } catch (Exception ignore) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // #endregion
|
|
|
+
|
|
|
+ private static class CloneWebsocketAuth {
|
|
|
+ private final String url;
|
|
|
+ private final String date;
|
|
|
+ private final String authorization;
|
|
|
+
|
|
|
+ private CloneWebsocketAuth(String url, String date, String authorization) {
|
|
|
+ this.url = url;
|
|
|
+ this.date = date;
|
|
|
+ this.authorization = authorization;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private Map<String, String> buildVoiceTrainHeaders(String apiKey, String appId, String token, String bodyText) {
|
|
|
Map<String, String> headers = new LinkedHashMap<>();
|
|
|
String requestTime = String.valueOf(System.currentTimeMillis());
|