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;
*
@@ -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:
*
*
- *
The audio must ride as a {@code data:;base64,} URI plus an explicit
- * {@code format} field — dropping either breaks decoding server-side.
+ *
The audio must ride as a MIME-qualified data URI. Qwen3-ASR does not
+ * use the separate {@code format} field supported by other Qwen audio models.
*
{@code asr_options} must be omitted entirely when no language hint
* is supplied, so the service auto-detects.
*
Transcript extraction must tolerate both plain-string and
@@ -40,10 +40,10 @@ class DashScopeSttProviderTest {
}
@Test
- @DisplayName("buildRequestBody serialises model, base64 audio, format and stream=false")
+ @DisplayName("buildRequestBody serialises model and MIME-qualified base64 audio")
void buildRequestBody_coreShape() throws Exception {
byte[] audio = "fake-wav-bytes".getBytes(StandardCharsets.UTF_8);
- String json = provider.buildRequestBody("qwen3-asr-flash", audio, "wav", null);
+ String json = provider.buildRequestBody("qwen3-asr-flash", audio, "audio/wav", null);
JsonNode node = mapper.readTree(json);
assertEquals("qwen3-asr-flash", node.path("model").asText());
@@ -52,18 +52,19 @@ class DashScopeSttProviderTest {
JsonNode content = node.path("messages").path(0).path("content").path(0);
assertEquals("user", node.path("messages").path(0).path("role").asText());
assertEquals("input_audio", content.path("type").asText());
- assertEquals("wav", content.path("input_audio").path("format").asText());
+ assertTrue(content.path("input_audio").path("format").isMissingNode());
String data = content.path("input_audio").path("data").asText();
- assertTrue(data.startsWith("data:;base64,"), "audio must be a base64 data URI");
+ assertTrue(data.startsWith("data:audio/wav;base64,"),
+ "audio must carry its real MIME type in the data URI");
assertEquals(Base64.getEncoder().encodeToString(audio),
- data.substring("data:;base64,".length()));
+ data.substring("data:audio/wav;base64,".length()));
}
@Test
@DisplayName("buildRequestBody adds asr_options.language with locale stripped")
void buildRequestBody_languageHint() throws Exception {
- String json = provider.buildRequestBody("qwen3-asr-flash", new byte[]{1}, "wav", "zh-CN");
+ String json = provider.buildRequestBody("qwen3-asr-flash", new byte[]{1}, "audio/wav", "zh-CN");
JsonNode node = mapper.readTree(json);
assertEquals("zh", node.path("asr_options").path("language").asText());
}
@@ -74,9 +75,9 @@ class DashScopeSttProviderTest {
// An empty or null language means "let the service detect the
// language"; sending asr_options with a null/blank language field
// would be rejected as a parameter error.
- String json = provider.buildRequestBody("qwen3-asr-flash", new byte[]{1}, "wav", null);
+ String json = provider.buildRequestBody("qwen3-asr-flash", new byte[]{1}, "audio/wav", null);
assertTrue(mapper.readTree(json).path("asr_options").isMissingNode());
- String jsonBlank = provider.buildRequestBody("qwen3-asr-flash", new byte[]{1}, "wav", " ");
+ String jsonBlank = provider.buildRequestBody("qwen3-asr-flash", new byte[]{1}, "audio/wav", " ");
assertTrue(mapper.readTree(jsonBlank).path("asr_options").isMissingNode());
}
@@ -90,17 +91,6 @@ class DashScopeSttProviderTest {
assertNull(DashScopeSttProvider.stripLocale(" "));
}
- @Test
- @DisplayName("resolveFormat maps filename/content-type to the input_audio format value")
- void resolveFormat_variants() {
- assertEquals("wav", DashScopeSttProvider.resolveFormat("clip.wav", null));
- assertEquals("mp3", DashScopeSttProvider.resolveFormat(null, "audio/mpeg"));
- assertEquals("ogg", DashScopeSttProvider.resolveFormat("note.ogg", "audio/ogg"));
- assertEquals("webm", DashScopeSttProvider.resolveFormat(null, "audio/webm; codecs=opus"));
- // Unknown everything → wav (matches the web recorder's output).
- assertEquals("wav", DashScopeSttProvider.resolveFormat(null, null));
- }
-
@Test
@DisplayName("parseTranscript reads plain-string message content")
void parseTranscript_stringContent() throws Exception {
@@ -127,6 +117,16 @@ class DashScopeSttProviderTest {
assertEquals("", provider.parseTranscript("{\"choices\":[]}"));
}
+ @Test
+ @DisplayName("duration mismatch detects a truncated decode but tolerates rounding")
+ void durationMismatch() {
+ assertTrue(DashScopeSttProvider.isSuspiciouslyTruncated(8.0, 2));
+ assertEquals(false, DashScopeSttProvider.isSuspiciouslyTruncated(8.0, 7));
+ assertEquals(false, DashScopeSttProvider.isSuspiciouslyTruncated(2.0, 1));
+ assertEquals(6, provider.parseRecognizedSeconds("{\"usage\":{\"seconds\":6}}"));
+ assertEquals(-1, provider.parseRecognizedSeconds("{}"));
+ }
+
@Test
@DisplayName("parseErrorMessage handles compatible-mode and native error bodies")
void parseErrorMessage_variants() {
diff --git a/mateclaw-ui/src/components/chat/TalkMode.vue b/mateclaw-ui/src/components/chat/TalkMode.vue
index 4acb4858..c8f619aa 100644
--- a/mateclaw-ui/src/components/chat/TalkMode.vue
+++ b/mateclaw-ui/src/components/chat/TalkMode.vue
@@ -65,7 +65,7 @@ const props = defineProps<{
conversationId?: string
}>()
-const emit = defineEmits<{
+defineEmits<{
close: []
}>()
@@ -83,6 +83,8 @@ const transcript = ref>([])
let ws: WebSocket | null = null
let recorder: WavRecorder | null = null
+/** In-flight recorder start; release can arrive while getUserMedia is still resolving. */
+let recorderStartPromise: Promise | null = null
/**
* Persistent warmed-up recorder kept alive for the modal's lifetime so the
* press-and-hold gesture doesn't race a first-time mic permission dialog.
@@ -243,19 +245,24 @@ async function startListening() {
retryConnection()
return
}
- if (state.value !== 'idle') return
+ // Keep the gesture idempotent while getUserMedia/AudioContext start is in
+ // flight. Touch browsers can synthesize a second mouse event for the same
+ // press; without this guard it replaces the active recorder mid-start.
+ if (state.value !== 'idle' || recorderStartPromise || recorder) return
try {
// Web Audio API + manual WAV encode (utils/wavEncoder.ts) — replaces
- // MediaRecorder/WebM. DashScope Paraformer rejects webm; WAV is the
- // lowest common denominator every STT provider accepts.
+ // MediaRecorder/WebM. A canonical WAV gives every STT provider an
+ // unambiguous format and lets the backend run PCM quality diagnostics.
//
// Reuse the warmed-up recorder so we skip the permission dialog if
// it was successfully acquired in onMounted. If warm-up failed (or
// is still pending), fall back to a fresh recorder.
recorder = warmRecorder ?? new WavRecorder()
warmRecorder = null // ownership transferred for the duration of recording
- await recorder.start()
+ const startPromise = recorder.start()
+ recorderStartPromise = startPromise
+ await startPromise
state.value = 'listening'
console.debug('[TalkMode] listening started')
} catch (err) {
@@ -263,6 +270,8 @@ async function startListening() {
mcToast.error(t('talk.micError'))
state.value = 'idle'
recorder = null
+ } finally {
+ recorderStartPromise = null
}
}
@@ -274,7 +283,18 @@ async function stopListening() {
if (state.value === 'listening') state.value = 'idle'
return
}
- const result = await recorder.stop()
+ const activeRecorder = recorder
+ const pendingStart = recorderStartPromise
+ if (pendingStart) {
+ try {
+ await pendingStart
+ } catch {
+ // startListening owns the user-facing error and recorder cleanup.
+ return
+ }
+ }
+ if (recorder !== activeRecorder) return
+ const result = await activeRecorder.stop()
recorder = null
// Re-warm for the next press so subsequent PTTs also skip permission.
warmRecorder = new WavRecorder()
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index fcdb0191..11142c85 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -1239,7 +1239,7 @@ export default {
sttProvider: 'Select preferred STT provider. Auto mode picks the first available one.',
sttFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.',
openaiSttInfo: 'Reuses OpenAI API Key from Model Management. Whisper model, supports multilingual auto-detection.',
- dashscopeSttInfo: 'Reuses DashScope API Key from Model Management. Paraformer Realtime over WebSocket — strong Chinese recognition, sub-second latency.',
+ dashscopeSttInfo: 'Reuses the DashScope API Key from Model Management. Qwen3-ASR Flash transcribes each complete recording over HTTP with multilingual and Chinese dialect support.',
// Issue #76
sttOpenAiCompatProviderId: 'Pick any OpenAI-compatible provider from Model Management as the credential source (baseUrl + API key). Beyond OpenAI itself this covers self-hosted FunASR, SiliconFlow, Groq, Together, Volcano, Qiniu, and any custom provider you add with the OpenAI-compatible protocol.',
sttOpenAiCompatModel: 'Model id sent in the multipart "model" field. Defaults to whisper-1; use paraformer-large for FunASR, or whatever id your vendor documents.',
@@ -1348,7 +1348,7 @@ export default {
saveFail: 'Save failed',
},
sttTitle: 'Speech Recognition',
- sttDesc: 'Configure STT speech-to-text with OpenAI Whisper and DashScope Paraformer',
+ sttDesc: 'Configure STT speech-to-text with OpenAI Whisper and DashScope Qwen3-ASR',
sttProviderOptions: { auto: 'Auto Select' },
sttProviderTags: { reuseLlmKey: 'Reuses LLM API Key' },
musicTitle: 'Music Generation',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index 241a1228..1b88251a 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -1104,11 +1104,11 @@ export default {
searxngBaseUrl: '自部署 SearXNG 实例地址。Docker 部署时自动配置。',
searchProviderAuto: '让系统按优先级自动挑选一个已配置好的 provider。',
// STT 语音识别
- sttEnabled: '开启后支持语音消息转文字。OpenAI Whisper 和 DashScope Paraformer 均复用已有 Key。',
+ sttEnabled: '开启后支持语音消息转文字。OpenAI Whisper 和 DashScope Qwen3-ASR 均复用已有 Key。',
sttProvider: '选择首选 STT 提供商,auto 模式自动选择可用的提供商。',
sttFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
openaiSttInfo: '复用模型管理中的 OpenAI API Key。使用 Whisper 模型,支持多语言自动识别。',
- dashscopeSttInfo: '复用模型管理中的 DashScope API Key。使用 Paraformer Realtime(WebSocket 流式),中文识别效果优秀,亚秒级延迟。',
+ dashscopeSttInfo: '复用模型管理中的 DashScope API Key。使用 Qwen3-ASR Flash 通过 HTTP 转写完整录音,支持多语种和中文方言。',
// Issue #76
sttOpenAiCompatProviderId: '从模型管理选一个 OpenAI 兼容 provider 行作为凭证(baseUrl + API Key)来源。除官方 OpenAI 外,FunASR 私有部署 / 硅基流动 / Groq / Together / 火山 / 七牛等都可以用——在模型管理新增自定义 provider 后即可在此选用。',
sttOpenAiCompatModel: '发送给端点的模型名(multipart "model" 字段)。OpenAI 默认 whisper-1;FunASR 通常是 paraformer-large;其他厂商按其文档填写。',
@@ -1222,7 +1222,7 @@ export default {
saveFail: '保存失败',
},
sttTitle: '语音识别',
- sttDesc: '配置 STT 语音转文字,支持 OpenAI Whisper 和 DashScope Paraformer',
+ sttDesc: '配置 STT 语音转文字,支持 OpenAI Whisper 和 DashScope Qwen3-ASR',
sttProviderOptions: { auto: '自动选择' },
sttProviderTags: { reuseLlmKey: '复用 LLM API Key' },
musicTitle: '音乐生成',
diff --git a/mateclaw-ui/src/utils/__tests__/wavEncoder.test.ts b/mateclaw-ui/src/utils/__tests__/wavEncoder.test.ts
new file mode 100644
index 00000000..88972450
--- /dev/null
+++ b/mateclaw-ui/src/utils/__tests__/wavEncoder.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from 'vitest'
+import { WavRecorder } from '@/utils/wavEncoder'
+
+class FakeNode {
+ connect() { return this }
+ disconnect() {}
+}
+
+class FakeProcessor extends FakeNode {
+ onaudioprocess: ((event: AudioProcessingEvent) => void) | null = null
+}
+
+class FakeAudioContext {
+ sampleRate = 48_000
+ state = 'running'
+ destination = new FakeNode()
+ createMediaStreamSource() { return new FakeNode() }
+ createScriptProcessor() { return new FakeProcessor() }
+ createGain() { return Object.assign(new FakeNode(), { gain: { value: 1 } }) }
+ async resume() {}
+ async close() {}
+}
+
+describe('WavRecorder microphone lifecycle', () => {
+ it('shares one pending getUserMedia call between warmUp and start', async () => {
+ vi.stubGlobal('AudioContext', FakeAudioContext)
+
+ let resolveStream!: (stream: MediaStream) => void
+ const getUserMedia = vi.fn(() => new Promise((resolve) => {
+ resolveStream = resolve
+ }))
+ Object.defineProperty(navigator, 'mediaDevices', {
+ configurable: true,
+ value: { getUserMedia },
+ })
+
+ const stopTrack = vi.fn()
+ const stream = {
+ getAudioTracks: () => [{ readyState: 'live' }],
+ getTracks: () => [{ stop: stopTrack }],
+ } as unknown as MediaStream
+
+ const recorder = new WavRecorder()
+ const warmUp = recorder.warmUp()
+ const start = recorder.start()
+ const duplicateStart = recorder.start()
+
+ expect(getUserMedia).toHaveBeenCalledTimes(1)
+ resolveStream(stream)
+ await Promise.all([warmUp, start, duplicateStart])
+ await recorder.stop()
+
+ expect(getUserMedia).toHaveBeenCalledTimes(1)
+ expect(stopTrack).toHaveBeenCalled()
+ vi.unstubAllGlobals()
+ })
+})
diff --git a/mateclaw-ui/src/utils/wavEncoder.ts b/mateclaw-ui/src/utils/wavEncoder.ts
index de5fb383..e2b2669f 100644
--- a/mateclaw-ui/src/utils/wavEncoder.ts
+++ b/mateclaw-ui/src/utils/wavEncoder.ts
@@ -2,11 +2,10 @@
* Browser-side recorder that captures microphone audio via the Web Audio API
* and encodes it directly to a 16-bit PCM WAV blob.
*
- *
Why this exists: MediaRecorder produces WebM/Opus, which DashScope
- * Paraformer rejects (it accepts wav/mp3/m4a/flac/aac/amr/ogg-Vorbis only).
- * OpenAI Whisper claims WebM support but is finicky with the codecs string
- * MediaRecorder picks. WAV is the lowest common denominator that every STT
- * provider accepts without server-side transcoding.
+ *
Why this exists: MediaRecorder output and codec strings vary by browser.
+ * A canonical PCM WAV is the lowest common denominator that every STT
+ * provider accepts without server-side transcoding, and its samples can be
+ * inspected locally for silence and duration before an API call.
*
*
Trade-off: WAV files are ~10x larger than Opus. For typical
* conversational STT (5-30s clips at 16 kHz / 16-bit / mono) that's
@@ -54,6 +53,12 @@ export interface WavRecording {
export class WavRecorder {
private audioContext: AudioContext | null = null;
private mediaStream: MediaStream | null = null;
+ /** Deduplicates warm-up/start getUserMedia calls so they cannot overwrite each other's stream. */
+ private mediaStreamPromise: Promise | null = null;
+ /** Lets stop() wait for a permission/start sequence that has not finished yet. */
+ private starting: Promise | null = null;
+ /** Warm-up recorders are disposable; a released instance must never retain a late stream. */
+ private released = false;
private source: MediaStreamAudioSourceNode | null = null;
private processor: ScriptProcessorNode | null = null;
/** Silent sink node — keeps the processor graph alive without echoing through speakers. */
@@ -73,8 +78,8 @@ export class WavRecorder {
*
Safe to call multiple times. Subsequent calls return immediately.
*/
async warmUp(): Promise {
- if (this.mediaStream) return;
- this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ if (this.released) throw new DOMException('Recorder has been released', 'InvalidStateError');
+ await this.ensureMediaStream();
}
/**
@@ -82,7 +87,20 @@ export class WavRecorder {
* Already-started recorders are idempotent — calling start twice is a no-op.
*/
async start(): Promise {
+ if (this.starting) return this.starting;
if (this.audioContext) return;
+ if (this.released) throw new DOMException('Recorder has been released', 'InvalidStateError');
+
+ const pending = this.startInternal();
+ this.starting = pending;
+ try {
+ await pending;
+ } finally {
+ if (this.starting === pending) this.starting = null;
+ }
+ }
+
+ private async startInternal(): Promise {
// sampleRate hint: browsers honour it on Chrome/Edge but Safari may
// ignore it and run at the device default. We resample manually in
@@ -90,45 +108,57 @@ export class WavRecorder {
const ctx = new AudioContext();
this.audioContext = ctx;
this.inputSampleRate = ctx.sampleRate;
- // Reuse the warmed-up stream when present so we skip the permission
- // dialog on the press-and-hold path.
- if (!this.mediaStream) {
- this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ try {
+ // Reuse the warmed-up stream when present so we skip the permission
+ // dialog on the press-and-hold path.
+ const mediaStream = await this.ensureMediaStream();
+ // Modern Chromium AudioContexts created from a user gesture may still
+ // arrive suspended if mic permission was prompted asynchronously.
+ // Force-resume so onaudioprocess actually fires.
+ if (ctx.state === 'suspended') {
+ await ctx.resume();
+ }
+ this.source = ctx.createMediaStreamSource(mediaStream);
+ this.processor = ctx.createScriptProcessor(4096, 1, 1);
+ this.processor.onaudioprocess = (e) => {
+ const channel = e.inputBuffer.getChannelData(0);
+ // Defensive copy — the underlying buffer is reused on the next callback.
+ this.chunks.push(new Float32Array(channel));
+ };
+ // Wire source → processor → silent gain → destination. The gain=0
+ // node muzzles the echo through the speakers but keeps the chain
+ // attached to destination, which Chrome requires to fire
+ // onaudioprocess. Connecting processor directly to destination
+ // would echo the mic input back through speakers (feedback) AND
+ // some browsers stop calling onaudioprocess if they decide the
+ // chain "produces no audible output" — the explicit GainNode
+ // makes that decision unambiguous.
+ const silentSink = ctx.createGain();
+ silentSink.gain.value = 0;
+ this.silentSink = silentSink;
+ this.source.connect(this.processor);
+ this.processor.connect(silentSink);
+ silentSink.connect(ctx.destination);
+ this.startTimeMs = Date.now();
+ // Diagnostic — paste from devtools console when the recording silently
+ // produces 0 bytes. Includes sample rate so we can confirm Safari
+ // is at 44.1kHz vs Chrome's 48kHz.
+ console.debug('[WavRecorder] started',
+ 'sampleRate=', ctx.sampleRate,
+ 'state=', ctx.state);
+ } catch (error) {
+ this.processor?.disconnect();
+ this.source?.disconnect();
+ this.silentSink?.disconnect();
+ this.mediaStream?.getTracks().forEach((track) => track.stop());
+ await ctx.close().catch(() => {});
+ this.audioContext = null;
+ this.mediaStream = null;
+ this.source = null;
+ this.processor = null;
+ this.silentSink = null;
+ throw error;
}
- // Modern Chromium AudioContexts created from a user gesture may still
- // arrive suspended if mic permission was prompted asynchronously.
- // Force-resume so onaudioprocess actually fires.
- if (ctx.state === 'suspended') {
- await ctx.resume();
- }
- this.source = ctx.createMediaStreamSource(this.mediaStream);
- this.processor = ctx.createScriptProcessor(4096, 1, 1);
- this.processor.onaudioprocess = (e) => {
- const channel = e.inputBuffer.getChannelData(0);
- // Defensive copy — the underlying buffer is reused on the next callback.
- this.chunks.push(new Float32Array(channel));
- };
- // Wire source → processor → silent gain → destination. The gain=0
- // node muzzles the echo through the speakers but keeps the chain
- // attached to destination, which Chrome requires to fire
- // onaudioprocess. Connecting processor directly to destination
- // would echo the mic input back through speakers (feedback) AND
- // some browsers stop calling onaudioprocess if they decide the
- // chain "produces no audible output" — the explicit GainNode
- // makes that decision unambiguous.
- const silentSink = ctx.createGain();
- silentSink.gain.value = 0;
- this.silentSink = silentSink;
- this.source.connect(this.processor);
- this.processor.connect(silentSink);
- silentSink.connect(ctx.destination);
- this.startTimeMs = Date.now();
- // Diagnostic — paste from devtools console when the recording silently
- // produces 0 bytes. Includes sample rate so we can confirm Safari
- // is at 44.1kHz vs Chrome's 48kHz.
- console.debug('[WavRecorder] started',
- 'sampleRate=', ctx.sampleRate,
- 'state=', ctx.state);
}
/**
@@ -136,6 +166,14 @@ export class WavRecorder {
* Returns null when nothing was captured (e.g. start failed silently).
*/
async stop(): Promise {
+ const pendingStart = this.starting;
+ if (pendingStart) {
+ try {
+ await pendingStart;
+ } catch {
+ return null;
+ }
+ }
if (!this.audioContext) return null;
const durationSeconds = Math.round((Date.now() - this.startTimeMs)) / 1000;
@@ -149,6 +187,8 @@ export class WavRecorder {
await this.audioContext.close();
this.audioContext = null;
this.mediaStream = null;
+ this.mediaStreamPromise = null;
+ this.released = true;
this.source = null;
this.processor = null;
this.silentSink = null;
@@ -169,7 +209,7 @@ export class WavRecorder {
}
const merged = mergeFloat32(collected);
// Resample to 16 kHz if we captured at a higher rate (Safari often runs
- // the AudioContext at 44.1 kHz). 16 kHz is what Whisper / Paraformer
+ // the AudioContext at 44.1 kHz). 16 kHz is what speech recognizers
// expect, and shrinks the WAV by ~3x at no quality cost for speech.
const resampled = sampleRate === TARGET_SAMPLE_RATE
? merged
@@ -188,9 +228,43 @@ export class WavRecorder {
* Pair with {@link warmUp} on modal close.
*/
releaseWarmUp(): void {
+ this.released = true;
if (this.audioContext) return; // active recording owns the stream
this.mediaStream?.getTracks().forEach((t) => t.stop());
this.mediaStream = null;
+ // getUserMedia may resolve after the component was unmounted. Stop
+ // that late stream immediately instead of leaving the mic indicator on.
+ this.mediaStreamPromise?.then((stream) => {
+ stream.getTracks().forEach((track) => track.stop());
+ }).catch(() => {});
+ this.mediaStreamPromise = null;
+ }
+
+ /** Acquire one stable mono speech stream shared by warm-up and recording start. */
+ private async ensureMediaStream(): Promise {
+ if (this.mediaStream?.getAudioTracks().some((track) => track.readyState === 'live')) {
+ return this.mediaStream;
+ }
+ if (!this.mediaStreamPromise) {
+ this.mediaStreamPromise = navigator.mediaDevices.getUserMedia({
+ audio: {
+ channelCount: 1,
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ },
+ }).then((stream) => {
+ if (this.released) {
+ stream.getTracks().forEach((track) => track.stop());
+ throw new DOMException('Recorder was released while requesting microphone access', 'AbortError');
+ }
+ this.mediaStream = stream;
+ return stream;
+ }).finally(() => {
+ this.mediaStreamPromise = null;
+ });
+ }
+ return this.mediaStreamPromise;
}
}
@@ -212,8 +286,8 @@ function mergeFloat32(chunks: Float32Array[]): Float32Array {
/**
* Decimating linear-interpolation resampler. Adequate for 16 kHz speech —
- * the accuracy gap vs polyphase resampling is inaudible to Whisper /
- * Paraformer at the input sample rates we see in practice (44.1k → 16k).
+ * the accuracy gap vs polyphase resampling is immaterial to speech
+ * recognizers at the input sample rates we see in practice (44.1k → 16k).
*/
function downsample(samples: Float32Array, fromRate: number, toRate: number): Float32Array {
if (fromRate === toRate) return samples;
diff --git a/mateclaw-ui/src/views/Settings/Stt/index.vue b/mateclaw-ui/src/views/Settings/Stt/index.vue
index 39692903..c9db8b6f 100644
--- a/mateclaw-ui/src/views/Settings/Stt/index.vue
+++ b/mateclaw-ui/src/views/Settings/Stt/index.vue
@@ -28,7 +28,7 @@
@@ -99,7 +99,7 @@