fix: align Windows dev, App compiler, and ASR

This commit is contained in:
key
2026-07-26 21:27:37 +08:00
parent f57e0a53c6
commit 9ae5f750c8
22 changed files with 698 additions and 791 deletions
@@ -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;
}
@@ -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"));
}
}