From 197e69717337e943ced2b7b67828cd5dbab9f45c Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Mon, 10 Aug 2026 21:43:02 +0800 Subject: [PATCH] fix(stt): repair DashScope audio decoding (#580) --- .../channel/web/TalkModeWebSocketHandler.java | 14 +- .../java/vip/mate/stt/AudioMimeTypes.java | 29 ++- .../java/vip/mate/stt/WavPcmExtractor.java | 18 +- .../stt/provider/DashScopeSttProvider.java | 94 +++++++--- .../src/main/resources/docs/en/multimodal.md | 4 +- .../src/main/resources/docs/zh/multimodal.md | 4 +- .../web/TalkModeWebSocketHandlerTest.java | 21 +++ .../java/vip/mate/stt/AudioMimeTypesTest.java | 15 ++ .../vip/mate/stt/WavPcmExtractorTest.java | 6 +- .../provider/DashScopeSttProviderTest.java | 42 ++--- mateclaw-ui/src/components/chat/TalkMode.vue | 32 +++- mateclaw-ui/src/i18n/locales/en-US.ts | 4 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 6 +- .../src/utils/__tests__/wavEncoder.test.ts | 57 ++++++ mateclaw-ui/src/utils/wavEncoder.ts | 170 +++++++++++++----- mateclaw-ui/src/views/Settings/Stt/index.vue | 4 +- 16 files changed, 395 insertions(+), 125 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/web/TalkModeWebSocketHandlerTest.java create mode 100644 mateclaw-ui/src/utils/__tests__/wavEncoder.test.ts diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java index f0a407d8..d4eadf91 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java @@ -16,6 +16,7 @@ import vip.mate.tts.TtsService; import vip.mate.workspace.conversation.ConversationService; import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -105,7 +106,10 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler { return; } - byte[] audioData = message.getPayload().array(); + // Respect the ByteBuffer's position/limit. Calling array() can include + // unrelated capacity bytes when a WebSocket container hands us a + // sliced or pooled buffer, corrupting the WAV data URL sent to STT. + byte[] audioData = copyPayload(message.getPayload()); log.info("[TalkMode] Received audio: {} bytes", audioData.length); // 异步处理:STT -> Agent -> TTS @@ -225,4 +229,12 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler { session.sendMessage(new TextMessage(objectMapper.writeValueAsString(data))); } } + + /** Copy exactly the readable WebSocket payload, independent of backing-array capacity/offset. */ + static byte[] copyPayload(ByteBuffer source) { + ByteBuffer payload = source.slice(); + byte[] audioData = new byte[payload.remaining()]; + payload.get(audioData); + return audioData; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/stt/AudioMimeTypes.java b/mateclaw-server/src/main/java/vip/mate/stt/AudioMimeTypes.java index 98ecadfc..d5202dfd 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/AudioMimeTypes.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/AudioMimeTypes.java @@ -13,11 +13,10 @@ import java.util.Map; * the multipart Content-Type from the extension we pass. Hence this * helper picks an extension that matches the actual bytes. * - *

Previous bug (pre-fix): both providers hardcoded {@code "audio.ogg"} - * as the default filename even when the upstream content was WebM/Opus, - * which DashScope's HTTP path then tried to decode as Ogg and 400'd. - * That bug + DashScope's HTTP STT are both gone now (DashScope went to - * WebSocket); this class survives because Whisper still cares. + *

Previous bug (pre-fix): providers hardcoded {@code "audio.ogg"} as the + * default filename even when upstream content was WebM/Opus. The helper now + * serves both multipart filenames (Whisper-compatible endpoints) and MIME- + * qualified data URLs (Qwen3-ASR). */ public final class AudioMimeTypes { @@ -70,6 +69,26 @@ public final class AudioMimeTypes { return "audio." + (extension != null ? extension : DEFAULT_EXTENSION); } + /** + * Resolve the MIME type that describes the actual encoded bytes. + * + *

Data-URL based APIs (notably Qwen3-ASR) inspect the media type in + * {@code data:audio/wav;base64,...}. Sending an empty media type can make + * the service mis-detect or truncate otherwise valid audio while still + * returning HTTP 200, so callers must never emit {@code data:;base64,...}. + */ + public static String resolveContentType(String fileName, String contentType) { + String extension = extensionForContentType(contentType); + if (extension != null) { + return EXTENSION_TO_CONTENT_TYPE.get(extension); + } + String resolvedFileName = resolveFileName(fileName, null); + String fileExtension = extensionOf(resolvedFileName); + return fileExtension != null + ? EXTENSION_TO_CONTENT_TYPE.get(fileExtension) + : EXTENSION_TO_CONTENT_TYPE.get(DEFAULT_EXTENSION); + } + /** Extract the lower-cased extension (without the dot), or null. Package-private for tests. */ static String extensionOf(String fileName) { if (fileName == null) return null; diff --git a/mateclaw-server/src/main/java/vip/mate/stt/WavPcmExtractor.java b/mateclaw-server/src/main/java/vip/mate/stt/WavPcmExtractor.java index e357477a..efec3c07 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/WavPcmExtractor.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/WavPcmExtractor.java @@ -30,14 +30,20 @@ public final class WavPcmExtractor { private WavPcmExtractor() {} /** - * True when the bytes carry the RIFF/WAVE magic and are long enough to - * hold the canonical 44-byte header. Cheap gate for callers that only - * want PCM diagnostics on inputs {@link #extract} can actually handle. + * True only for the 44-byte PCM16/mono layout produced by MateClaw's web + * recorder. Stereo WAVs and files with extra chunks are still valid audio, + * but callers must send them directly to STT instead of applying the + * mono-specific sample math in this helper. */ public static boolean isCanonicalWav(byte[] bytes) { return bytes != null && bytes.length >= CANONICAL_HEADER_BYTES && bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F' - && bytes[8] == 'W' && bytes[9] == 'A' && bytes[10] == 'V' && bytes[11] == 'E'; + && bytes[8] == 'W' && bytes[9] == 'A' && bytes[10] == 'V' && bytes[11] == 'E' + && bytes[12] == 'f' && bytes[13] == 'm' && bytes[14] == 't' && bytes[15] == ' ' + && unsignedShort(bytes, 20) == 1 + && unsignedShort(bytes, 22) == 1 + && unsignedShort(bytes, 34) == 16 + && bytes[36] == 'd' && bytes[37] == 'a' && bytes[38] == 't' && bytes[39] == 'a'; } /** @@ -74,4 +80,8 @@ public final class WavPcmExtractor { .getInt(); } + private static int unsignedShort(byte[] bytes, int offset) { + return (bytes[offset] & 0xFF) | ((bytes[offset + 1] & 0xFF) << 8); + } + } diff --git a/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java b/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java index 68985c54..bbec76a8 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java @@ -57,7 +57,7 @@ import java.util.Map; *

{@code
  * {"model":"qwen3-asr-flash",
  *  "messages":[{"role":"user","content":[
- *      {"type":"input_audio","input_audio":{"data":"data:;base64,...","format":"wav"}}]}],
+ *      {"type":"input_audio","input_audio":{"data":"data:audio/wav;base64,..."}}]}],
  *  "stream":false,
  *  "asr_options":{"language":"zh"}}          // omitted → auto language detection
  * }
@@ -80,6 +80,12 @@ public class DashScopeSttProvider implements SttProvider { /** Overall budget for the single HTTP round trip. */ static final int HTTP_TIMEOUT_MS = 60_000; + /** Qwen3-ASR-Flash OpenAI-compatible request limit. */ + static final int MAX_AUDIO_BYTES = 10 * 1024 * 1024; + + /** Reject electrical noise that would otherwise be hallucinated as a filler such as “嗯”. */ + static final int MIN_SPEECH_RMS = 16; + private final ModelProviderService modelProviderService; private final ObjectMapper objectMapper; @@ -122,29 +128,40 @@ public class DashScopeSttProvider implements SttProvider { if (audio == null || audio.length == 0) { return SttResult.failure("音频为空"); } + if (audio.length > MAX_AUDIO_BYTES) { + return SttResult.failure("音频超过 Qwen3-ASR 10 MB 限制"); + } // Silence gate — only for WAV, where we can read PCM directly. // "Mic captured nothing" is by far the most common voice-input // failure; catching it here yields a precise error instead of an // empty transcript from the model. Non-WAV inputs (IM voice // notes) skip the gate and go straight to the API. + double localDurationSeconds = -1; if (WavPcmExtractor.isCanonicalWav(audio)) { byte[] pcm = WavPcmExtractor.extract(audio); int[] peakRms = computePcmPeakRms(pcm); - if (peakRms[0] == 0) { - log.warn("[DashScope STT] PCM is silent (peak=0, bytes={}) — check mic permission / frontend recording", - pcm.length); - return SttResult.failure( - "音频为静音(PCM peak=0)— 检查麦克风权限或前端录制实现"); + int sampleRate = WavPcmExtractor.sampleRate(audio); + if (sampleRate <= 0) { + return SttResult.failure("WAV 采样率无效: " + sampleRate); } - log.debug("[DashScope STT] PCM stats — bytes={} peak={} rms={}", - pcm.length, peakRms[0], peakRms[1]); + localDurationSeconds = (double) pcm.length / (sampleRate * 2L); + if (peakRms[1] < MIN_SPEECH_RMS) { + log.warn("[DashScope STT] PCM is silent/near-silent (peak={}, rms={}, bytes={}) — check mic permission / frontend recording", + peakRms[0], peakRms[1], pcm.length); + return SttResult.failure( + "音频为静音或音量过低(PCM peak=" + peakRms[0] + + ", rms=" + peakRms[1] + ")— 请检查麦克风权限和输入音量"); + } + log.debug("[DashScope STT] PCM stats — bytes={} peak={} rms={} duration={}s", + pcm.length, peakRms[0], peakRms[1], localDurationSeconds); } String model = (request.getModel() != null && !request.getModel().isBlank()) ? request.getModel() : DEFAULT_MODEL; - String format = resolveFormat(request.getFileName(), request.getContentType()); - String body = buildRequestBody(model, audio, format, request.getLanguage()); + String mimeType = AudioMimeTypes.resolveContentType( + request.getFileName(), request.getContentType()); + String body = buildRequestBody(model, audio, mimeType, request.getLanguage()); HttpResponse response = HttpRequest.post(ASR_ENDPOINT) .header("Authorization", "Bearer " + apiKey.trim()) @@ -180,8 +197,19 @@ public class DashScopeSttProvider implements SttProvider { } String text = parseTranscript(responseBody); - log.info("[DashScope STT] Transcribed {} chars (model={}, format={}, audioBytes={})", - text.length(), model, format, audio.length); + if (text.isBlank()) { + return SttResult.failure("DashScope 未返回识别文本,请检查录音内容和输入音量"); + } + int recognizedSeconds = parseRecognizedSeconds(responseBody); + if (isSuspiciouslyTruncated(localDurationSeconds, recognizedSeconds)) { + log.warn("[DashScope STT] decoded duration mismatch — local={}s remote={}s, mimeType={}, bytes={}", + localDurationSeconds, recognizedSeconds, mimeType, audio.length); + return SttResult.failure("DashScope 仅解码了约 " + recognizedSeconds + + " 秒音频,但本地录音约 " + Math.round(localDurationSeconds) + + " 秒;请检查录音编码或网关是否截断了音频"); + } + log.info("[DashScope STT] Transcribed {} chars (model={}, mimeType={}, audioBytes={}, localDuration={}s, recognizedDuration={}s)", + text.length(), model, mimeType, audio.length, localDurationSeconds, recognizedSeconds); return SttResult.success(text); } catch (Exception e) { log.error("[DashScope STT] Error: {}", e.getMessage(), e); @@ -195,13 +223,14 @@ public class DashScopeSttProvider implements SttProvider { /** * Build the recognition request. The audio rides in a - * {@code data:;base64,} URI — the separate {@code format} field tells the - * service how to decode it, so the URI needs no media type. + * MIME-qualified data URI. Qwen3-ASR uses the media type to decode the + * file; unlike Qwen audio/translation models it does not define a + * separate {@code input_audio.format} request field. */ - String buildRequestBody(String model, byte[] audio, String format, String language) throws Exception { + String buildRequestBody(String model, byte[] audio, String mimeType, String language) throws Exception { Map inputAudio = new LinkedHashMap<>(); - inputAudio.put("data", "data:;base64," + Base64.getEncoder().encodeToString(audio)); - inputAudio.put("format", format); + inputAudio.put("data", "data:" + mimeType + ";base64," + + Base64.getEncoder().encodeToString(audio)); Map payload = new LinkedHashMap<>(); payload.put("model", model); @@ -230,17 +259,6 @@ public class DashScopeSttProvider implements SttProvider { return dash > 0 ? hint.substring(0, dash) : hint; } - /** - * Derive the {@code input_audio.format} value ("wav", "mp3", ...) from - * the upload's filename/content-type. Falls back to "wav", matching the - * web recorder's output. - */ - static String resolveFormat(String fileName, String contentType) { - String resolved = AudioMimeTypes.resolveFileName(fileName, contentType); - int dot = resolved.lastIndexOf('.'); - return dot >= 0 ? resolved.substring(dot + 1) : "wav"; - } - /** * Extract the transcript from a chat-completion response. Content is * normally a plain string; tolerate the content-part array form @@ -262,6 +280,26 @@ public class DashScopeSttProvider implements SttProvider { return ""; } + /** Duration decoded by Qwen3-ASR, reported in the response usage object. */ + int parseRecognizedSeconds(String json) { + if (json == null || json.isBlank()) return -1; + try { + return objectMapper.readTree(json).path("usage").path("seconds").asInt(-1); + } catch (Exception ignored) { + return -1; + } + } + + /** + * A large local/remote duration mismatch means the API decoded only the + * beginning of the clip. Do not accept a plausible one-character filler + * as success in that state; fail so provider fallback and diagnostics run. + */ + static boolean isSuspiciouslyTruncated(double localSeconds, int recognizedSeconds) { + return localSeconds >= 3.0 && recognizedSeconds >= 0 + && recognizedSeconds + 1.0 < localSeconds * 0.6; + } + /** * Pull a human-readable message out of an error body. DashScope's * compatible mode wraps errors as {@code {"error":{"code","message"}}}; diff --git a/mateclaw-server/src/main/resources/docs/en/multimodal.md b/mateclaw-server/src/main/resources/docs/en/multimodal.md index 1129ca38..ca231aec 100644 --- a/mateclaw-server/src/main/resources/docs/en/multimodal.md +++ b/mateclaw-server/src/main/resources/docs/en/multimodal.md @@ -106,7 +106,7 @@ Click the speaker icon on any assistant message to read it aloud. The voice is w ### Speech-to-text (STT) — two providers -- **DashScope Paraformer** — Chinese-first, low latency +- **DashScope Qwen3-ASR Flash** — multilingual transcription with strong Chinese and dialect support - **OpenAI Whisper** — the standard multilingual benchmark Hold the mic button in the chat input to speak. Release to transcribe. Edit the result before sending if you want to. @@ -178,7 +178,7 @@ It works the way you'd expect: the image appears inside the same bubble where th - **Video** — short-form demos, social content, product animations. Runway for quality, MiniMax for Chinese scenarios, DashScope for cloud-local. - **Music** — background tracks, demo jingles, creative exploration. Two providers today; expect the surface to evolve. - **TTS** — accessibility, audiobook-style reading, multilingual content. CosyVoice for Chinese, OpenAI for English variety. -- **STT** — voice-first input, meeting transcription, dictation workflows. Paraformer for Chinese, Whisper for everything else. +- **STT** — voice-first input, meeting transcription, dictation workflows. Qwen3-ASR for Chinese and multilingual recordings, Whisper-compatible endpoints as an alternative. --- diff --git a/mateclaw-server/src/main/resources/docs/zh/multimodal.md b/mateclaw-server/src/main/resources/docs/zh/multimodal.md index f9de968e..64de6c2e 100644 --- a/mateclaw-server/src/main/resources/docs/zh/multimodal.md +++ b/mateclaw-server/src/main/resources/docs/zh/multimodal.md @@ -106,7 +106,7 @@ Google 的图像生成走 **Nano Banana Pro**(`gemini-3-pro-image-preview`) ### 语音识别(STT)—— 两个供应商 -- **DashScope Paraformer**——中文优先,低延迟 +- **DashScope Qwen3-ASR Flash**——支持多语种,强化中文及方言识别 - **OpenAI Whisper**——多语言行业基准 在聊天输入框按住麦克风图标讲话,松手转文本。识别结果可以在发送前再改一遍。 @@ -178,7 +178,7 @@ Agent 调用它们和调用任何其他工具一样。工具层负责供应商 - **视频**——短视频 demo、社交内容、产品动画。追求质量用 Runway,中文场景用 MiniMax,想本地云就 DashScope。 - **音乐**——背景音乐、Demo 音效、创意尝试。目前两家,后面还会扩。 - **TTS**——无障碍朗读、有声书式阅读、多语言内容。中文用 CosyVoice,英语要多样化就 OpenAI。 -- **STT**——语音输入、会议转写、口述工作流。中文用 Paraformer,其他语言用 Whisper。 +- **STT**——语音输入、会议转写、口述工作流。中文及多语种录音可使用 Qwen3-ASR,也可接入 Whisper 兼容端点。 --- diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/TalkModeWebSocketHandlerTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/TalkModeWebSocketHandlerTest.java new file mode 100644 index 00000000..f2369f9e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/TalkModeWebSocketHandlerTest.java @@ -0,0 +1,21 @@ +package vip.mate.channel.web; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +class TalkModeWebSocketHandlerTest { + + @Test + @DisplayName("copyPayload respects a pooled ByteBuffer's position and limit") + void copyPayload_respectsReadableRange() { + ByteBuffer pooled = ByteBuffer.wrap(new byte[]{99, 98, 1, 2, 3, 97}); + pooled.position(2); + pooled.limit(5); + + assertArrayEquals(new byte[]{1, 2, 3}, TalkModeWebSocketHandler.copyPayload(pooled)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/stt/AudioMimeTypesTest.java b/mateclaw-server/src/test/java/vip/mate/stt/AudioMimeTypesTest.java index 1f5b39f3..bf358529 100644 --- a/mateclaw-server/src/test/java/vip/mate/stt/AudioMimeTypesTest.java +++ b/mateclaw-server/src/test/java/vip/mate/stt/AudioMimeTypesTest.java @@ -14,6 +14,21 @@ import static org.junit.jupiter.api.Assertions.assertEquals; */ class AudioMimeTypesTest { + @Test + @DisplayName("resolveContentType prefers a known content type and strips codec parameters") + void resolveContentType_prefersContentType() { + assertEquals("audio/webm", AudioMimeTypes.resolveContentType("clip.wav", "audio/webm; codecs=opus")); + assertEquals("audio/mpeg", AudioMimeTypes.resolveContentType(null, "audio/mpeg")); + } + + @Test + @DisplayName("resolveContentType infers from filename and safely defaults to WAV") + void resolveContentType_filenameAndFallback() { + assertEquals("audio/ogg", AudioMimeTypes.resolveContentType("note.ogg", null)); + assertEquals("audio/wav", AudioMimeTypes.resolveContentType("blob.bin", null)); + assertEquals("audio/wav", AudioMimeTypes.resolveContentType(null, null)); + } + @Test @DisplayName("resolveFileName: trusts a caller filename with a known extension") void resolveFileName_trustsKnownExtension() { diff --git a/mateclaw-server/src/test/java/vip/mate/stt/WavPcmExtractorTest.java b/mateclaw-server/src/test/java/vip/mate/stt/WavPcmExtractorTest.java index 888ee2ed..32f7a1d7 100644 --- a/mateclaw-server/src/test/java/vip/mate/stt/WavPcmExtractorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/stt/WavPcmExtractorTest.java @@ -51,9 +51,13 @@ class WavPcmExtractorTest { } @Test - @DisplayName("isCanonicalWav: true for RIFF/WAVE, false for junk / short / null") + @DisplayName("isCanonicalWav accepts PCM16 mono and rejects non-canonical WAV layouts") void isCanonicalWav_gates() { assertTrue(WavPcmExtractor.isCanonicalWav(buildWav(16_000, 16, new byte[8]))); + byte[] stereo = buildWav(16_000, 16, new byte[8]); + ByteBuffer.wrap(stereo).order(ByteOrder.LITTLE_ENDIAN).putShort(22, (short) 2); + assertFalse(WavPcmExtractor.isCanonicalWav(stereo)); + assertFalse(WavPcmExtractor.isCanonicalWav(buildWav(16_000, 24, new byte[8]))); assertFalse(WavPcmExtractor.isCanonicalWav(new byte[64])); // no magic assertFalse(WavPcmExtractor.isCanonicalWav(new byte[10])); // too short assertFalse(WavPcmExtractor.isCanonicalWav(null)); diff --git a/mateclaw-server/src/test/java/vip/mate/stt/provider/DashScopeSttProviderTest.java b/mateclaw-server/src/test/java/vip/mate/stt/provider/DashScopeSttProviderTest.java index 62295d02..bea3ea4b 100644 --- a/mateclaw-server/src/test/java/vip/mate/stt/provider/DashScopeSttProviderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/stt/provider/DashScopeSttProviderTest.java @@ -20,8 +20,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * parts with encoding rules worth pinning: * *