fix: align Windows dev, App compiler, and ASR
This commit is contained in:
+116
-5
@@ -17,6 +17,7 @@ import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -24,7 +25,7 @@ import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* ASR/TTS 网关:调用 OpenAI 兼容 audio 接口(硅基流动 SenseVoice/CosyVoice2 等)。
|
||||
* ASR/TTS 网关:兼容 OpenAI audio 接口,并适配阿里云百炼 Qwen ASR/TTS。
|
||||
* 模型未配置或调用失败返回 empty,由前端降级为纯文本对练。
|
||||
*/
|
||||
@Service
|
||||
@@ -34,8 +35,10 @@ public class AihrSpeechService {
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration ASR_REQUEST_TIMEOUT = Duration.ofSeconds(30);
|
||||
private static final Duration DASHSCOPE_TTS_TIMEOUT = Duration.ofSeconds(20);
|
||||
private static final String DEFAULT_TTS_VOICE = "anna";
|
||||
private static final String QWEN_ASR_MODEL_PREFIX = "qwen3-asr-flash";
|
||||
private static final String QWEN_TTS_MODEL = "qwen-audio-3.0-tts-flash";
|
||||
private static final String QWEN_TTS_DEFAULT_VOICE = "longanhuan_v3.6";
|
||||
private static final Pattern SAFE_VOICE = Pattern.compile("[A-Za-z0-9_./:-]{1,200}");
|
||||
@@ -123,7 +126,7 @@ public class AihrSpeechService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音转写:POST {base}/audio/transcriptions(multipart)。
|
||||
* 语音转写:OpenAI audio 兼容供应商走 multipart;阿里 Qwen3-ASR 走 chat/completions + input_audio。
|
||||
*/
|
||||
public Optional<String> transcribe(byte[] audio, String filename, String contentType) {
|
||||
Optional<SpeechModel> model = modelService.speechModel("asr");
|
||||
@@ -131,10 +134,17 @@ public class AihrSpeechService {
|
||||
return Optional.empty();
|
||||
}
|
||||
SpeechModel runtime = model.get();
|
||||
if (isDashScopeQwenAsr(runtime)) {
|
||||
return transcribeDashScopeQwen(runtime, audio, filename, contentType);
|
||||
}
|
||||
return transcribeOpenAiAudio(runtime, audio, filename, contentType);
|
||||
}
|
||||
|
||||
private Optional<String> transcribeOpenAiAudio(SpeechModel runtime, byte[] audio, String filename, String contentType) {
|
||||
try {
|
||||
String boundary = "----aihr-" + UUID.randomUUID();
|
||||
byte[] body = multipartBody(boundary, runtime.modelName(), audio, filename, contentType);
|
||||
HttpRequest httpRequest = authorized(runtime, "/audio/transcriptions")
|
||||
HttpRequest httpRequest = authorized(runtime, "/audio/transcriptions", ASR_REQUEST_TIMEOUT)
|
||||
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
|
||||
.build();
|
||||
@@ -151,6 +161,91 @@ public class AihrSpeechService {
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<String> transcribeDashScopeQwen(SpeechModel runtime, byte[] audio, String filename, String contentType) {
|
||||
try {
|
||||
ObjectNode body = buildDashScopeAsrRequestBody(objectMapper, runtime, audio, filename, contentType);
|
||||
HttpRequest request = authorized(runtime, "/chat/completions", ASR_REQUEST_TIMEOUT)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
||||
.build();
|
||||
HttpResponse<String> response = client().send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
log.warn("dashscope asr http {}(外部响应体已隐藏)", response.statusCode());
|
||||
return Optional.empty();
|
||||
}
|
||||
return dashScopeAsrText(objectMapper, response.body());
|
||||
} catch (Exception e) {
|
||||
log.warn("dashscope asr call failed(处理错误已隐藏)");
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
static ObjectNode buildDashScopeAsrRequestBody(ObjectMapper objectMapper, SpeechModel runtime, byte[] audio,
|
||||
String filename, String contentType) {
|
||||
String mediaType = asrMediaType(filename, contentType);
|
||||
String dataUri = "data:" + mediaType + ";base64," + Base64.getEncoder().encodeToString(audio);
|
||||
|
||||
ObjectNode inputAudio = objectMapper.createObjectNode();
|
||||
inputAudio.put("data", dataUri);
|
||||
ObjectNode content = objectMapper.createObjectNode();
|
||||
content.put("type", "input_audio");
|
||||
content.set("input_audio", inputAudio);
|
||||
ObjectNode message = objectMapper.createObjectNode();
|
||||
message.put("role", "user");
|
||||
message.putArray("content").add(content);
|
||||
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("model", runtime.modelName());
|
||||
body.putArray("messages").add(message);
|
||||
body.put("stream", false);
|
||||
body.putObject("asr_options").put("enable_itn", true);
|
||||
return body;
|
||||
}
|
||||
|
||||
static Optional<String> dashScopeAsrText(ObjectMapper objectMapper, String responseBody) {
|
||||
try {
|
||||
var root = objectMapper.readTree(responseBody == null ? "" : responseBody);
|
||||
String text = root.path("choices").path(0).path("message").path("content").asText("").trim();
|
||||
return text.isBlank() ? Optional.empty() : Optional.of(text);
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
static String asrMediaType(String filename, String contentType) {
|
||||
String supplied = contentType == null ? "" : contentType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
|
||||
if (supplied.startsWith("audio/") && supplied.matches("audio/[a-z0-9.+-]{1,64}")) {
|
||||
return switch (supplied) {
|
||||
case "audio/mp3", "audio/x-mp3" -> "audio/mpeg";
|
||||
case "audio/x-wav", "audio/wave" -> "audio/wav";
|
||||
default -> supplied;
|
||||
};
|
||||
}
|
||||
String name = filename == null ? "" : filename.trim().toLowerCase(Locale.ROOT);
|
||||
if (name.endsWith(".wav")) {
|
||||
return "audio/wav";
|
||||
}
|
||||
if (name.endsWith(".m4a") || name.endsWith(".mp4")) {
|
||||
return "audio/mp4";
|
||||
}
|
||||
if (name.endsWith(".webm")) {
|
||||
return "audio/webm";
|
||||
}
|
||||
if (name.endsWith(".ogg") || name.endsWith(".opus")) {
|
||||
return "audio/ogg";
|
||||
}
|
||||
if (name.endsWith(".flac")) {
|
||||
return "audio/flac";
|
||||
}
|
||||
if (name.endsWith(".aac")) {
|
||||
return "audio/aac";
|
||||
}
|
||||
if (name.endsWith(".aiff") || name.endsWith(".aif")) {
|
||||
return "audio/aiff";
|
||||
}
|
||||
return "audio/mpeg";
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音合成:POST {base}/audio/speech,返回 mp3 字节。
|
||||
*/
|
||||
@@ -402,12 +497,28 @@ public class AihrSpeechService {
|
||||
&& QWEN_TTS_MODEL.equals(runtime.modelName());
|
||||
}
|
||||
|
||||
static boolean isDashScopeQwenAsr(SpeechModel runtime) {
|
||||
if (runtime == null
|
||||
|| !("dashscope".equalsIgnoreCase(runtime.providerCode()) || "qianwen".equalsIgnoreCase(runtime.providerCode()))
|
||||
|| runtime.modelName() == null) {
|
||||
return false;
|
||||
}
|
||||
String modelName = runtime.modelName().toLowerCase(Locale.ROOT);
|
||||
return modelName.startsWith(QWEN_ASR_MODEL_PREFIX)
|
||||
&& !modelName.contains("-realtime")
|
||||
&& !modelName.contains("-filetrans");
|
||||
}
|
||||
|
||||
private HttpRequest.Builder authorized(SpeechModel runtime, String path) {
|
||||
return authorized(runtime, path, REQUEST_TIMEOUT);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder authorized(SpeechModel runtime, String path, Duration timeout) {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + path))
|
||||
.timeout(REQUEST_TIMEOUT);
|
||||
.timeout(timeout);
|
||||
if (runtime.apiKey() != null && !runtime.apiKey().isBlank()) {
|
||||
builder.header("Authorization", "Bearer " + runtime.apiKey());
|
||||
builder.header("Authorization", "Bearer " + runtime.apiKey().trim());
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
|
||||
+46
@@ -113,4 +113,50 @@ class AihrSpeechServiceTest {
|
||||
);
|
||||
assertTrue(AihrSpeechService.dashScopeAudioUrl(mapper, "{\"output\":{}}").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsAndParsesDashScopeQwenAsrCompatibleRequest() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
SpeechModel runtime = new SpeechModel(
|
||||
"dashscope",
|
||||
"qwen3-asr-flash",
|
||||
"https://ws-example.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
||||
"test-key"
|
||||
);
|
||||
|
||||
var body = AihrSpeechService.buildDashScopeAsrRequestBody(
|
||||
mapper, runtime, new byte[]{1, 2, 3}, "recording.mp3", "audio/mp3; charset=binary");
|
||||
|
||||
assertTrue(AihrSpeechService.isDashScopeQwenAsr(runtime));
|
||||
assertEquals("qwen3-asr-flash", body.path("model").asText());
|
||||
assertFalse(body.path("stream").asBoolean());
|
||||
assertEquals("user", body.path("messages").path(0).path("role").asText());
|
||||
assertEquals("input_audio", body.path("messages").path(0).path("content").path(0).path("type").asText());
|
||||
assertEquals(
|
||||
"data:audio/mpeg;base64,AQID",
|
||||
body.path("messages").path(0).path("content").path(0).path("input_audio").path("data").asText()
|
||||
);
|
||||
assertTrue(body.path("asr_options").path("enable_itn").asBoolean());
|
||||
assertEquals(
|
||||
"欢迎使用阿里云。",
|
||||
AihrSpeechService.dashScopeAsrText(
|
||||
mapper,
|
||||
"{\"choices\":[{\"message\":{\"content\":\" 欢迎使用阿里云。 \"}}]}"
|
||||
).orElseThrow()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictsDashScopeCompatibleAsrToSynchronousQwenFlashModels() {
|
||||
assertTrue(AihrSpeechService.isDashScopeQwenAsr(new SpeechModel(
|
||||
"qianwen", "qwen3-asr-flash-2026-02-10", "https://example.com/v1", "key")));
|
||||
assertFalse(AihrSpeechService.isDashScopeQwenAsr(new SpeechModel(
|
||||
"dashscope", "qwen3-asr-flash-realtime", "https://example.com/v1", "key")));
|
||||
assertFalse(AihrSpeechService.isDashScopeQwenAsr(new SpeechModel(
|
||||
"dashscope", "qwen3-asr-flash-filetrans", "https://example.com/v1", "key")));
|
||||
assertFalse(AihrSpeechService.isDashScopeQwenAsr(new SpeechModel(
|
||||
"siliconflow", "qwen3-asr-flash", "https://example.com/v1", "key")));
|
||||
assertEquals("audio/webm", AihrSpeechService.asrMediaType("recording.webm", ""));
|
||||
assertEquals("audio/wav", AihrSpeechService.asrMediaType("recording.bin", "audio/x-wav"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user