fix(stt): repair DashScope audio decoding (#580)

This commit is contained in:
mateaix 2026-08-10 21:43:02 +08:00
parent 314b9ff82e
commit 197e697173
16 changed files with 395 additions and 125 deletions

View File

@ -16,6 +16,7 @@ import vip.mate.tts.TtsService;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@ -105,7 +106,10 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
return; 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); log.info("[TalkMode] Received audio: {} bytes", audioData.length);
// 异步处理STT -> Agent -> TTS // 异步处理STT -> Agent -> TTS
@ -225,4 +229,12 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(data))); 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;
}
} }

View File

@ -13,11 +13,10 @@ import java.util.Map;
* the multipart Content-Type from the extension we pass. Hence this * the multipart Content-Type from the extension we pass. Hence this
* helper picks an extension that matches the actual bytes. * helper picks an extension that matches the actual bytes.
* *
* <p>Previous bug (pre-fix): both providers hardcoded {@code "audio.ogg"} * <p>Previous bug (pre-fix): providers hardcoded {@code "audio.ogg"} as the
* as the default filename even when the upstream content was WebM/Opus, * default filename even when upstream content was WebM/Opus. The helper now
* which DashScope's HTTP path then tried to decode as Ogg and 400'd. * serves both multipart filenames (Whisper-compatible endpoints) and MIME-
* That bug + DashScope's HTTP STT are both gone now (DashScope went to * qualified data URLs (Qwen3-ASR).
* WebSocket); this class survives because Whisper still cares.
*/ */
public final class AudioMimeTypes { public final class AudioMimeTypes {
@ -70,6 +69,26 @@ public final class AudioMimeTypes {
return "audio." + (extension != null ? extension : DEFAULT_EXTENSION); return "audio." + (extension != null ? extension : DEFAULT_EXTENSION);
} }
/**
* Resolve the MIME type that describes the actual encoded bytes.
*
* <p>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. */ /** Extract the lower-cased extension (without the dot), or null. Package-private for tests. */
static String extensionOf(String fileName) { static String extensionOf(String fileName) {
if (fileName == null) return null; if (fileName == null) return null;

View File

@ -30,14 +30,20 @@ public final class WavPcmExtractor {
private WavPcmExtractor() {} private WavPcmExtractor() {}
/** /**
* True when the bytes carry the RIFF/WAVE magic and are long enough to * True only for the 44-byte PCM16/mono layout produced by MateClaw's web
* hold the canonical 44-byte header. Cheap gate for callers that only * recorder. Stereo WAVs and files with extra chunks are still valid audio,
* want PCM diagnostics on inputs {@link #extract} can actually handle. * 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) { public static boolean isCanonicalWav(byte[] bytes) {
return bytes != null && bytes.length >= CANONICAL_HEADER_BYTES return bytes != null && bytes.length >= CANONICAL_HEADER_BYTES
&& bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F' && 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(); .getInt();
} }
private static int unsignedShort(byte[] bytes, int offset) {
return (bytes[offset] & 0xFF) | ((bytes[offset + 1] & 0xFF) << 8);
}
} }

View File

@ -57,7 +57,7 @@ import java.util.Map;
* <pre>{@code * <pre>{@code
* {"model":"qwen3-asr-flash", * {"model":"qwen3-asr-flash",
* "messages":[{"role":"user","content":[ * "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, * "stream":false,
* "asr_options":{"language":"zh"}} // omitted auto language detection * "asr_options":{"language":"zh"}} // omitted auto language detection
* }</pre> * }</pre>
@ -80,6 +80,12 @@ public class DashScopeSttProvider implements SttProvider {
/** Overall budget for the single HTTP round trip. */ /** Overall budget for the single HTTP round trip. */
static final int HTTP_TIMEOUT_MS = 60_000; 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 ModelProviderService modelProviderService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@ -122,29 +128,40 @@ public class DashScopeSttProvider implements SttProvider {
if (audio == null || audio.length == 0) { if (audio == null || audio.length == 0) {
return SttResult.failure("音频为空"); 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. // Silence gate only for WAV, where we can read PCM directly.
// "Mic captured nothing" is by far the most common voice-input // "Mic captured nothing" is by far the most common voice-input
// failure; catching it here yields a precise error instead of an // failure; catching it here yields a precise error instead of an
// empty transcript from the model. Non-WAV inputs (IM voice // empty transcript from the model. Non-WAV inputs (IM voice
// notes) skip the gate and go straight to the API. // notes) skip the gate and go straight to the API.
double localDurationSeconds = -1;
if (WavPcmExtractor.isCanonicalWav(audio)) { if (WavPcmExtractor.isCanonicalWav(audio)) {
byte[] pcm = WavPcmExtractor.extract(audio); byte[] pcm = WavPcmExtractor.extract(audio);
int[] peakRms = computePcmPeakRms(pcm); int[] peakRms = computePcmPeakRms(pcm);
if (peakRms[0] == 0) { int sampleRate = WavPcmExtractor.sampleRate(audio);
log.warn("[DashScope STT] PCM is silent (peak=0, bytes={}) — check mic permission / frontend recording", if (sampleRate <= 0) {
pcm.length); return SttResult.failure("WAV 采样率无效: " + sampleRate);
return SttResult.failure(
"音频为静音PCM peak=0— 检查麦克风权限或前端录制实现");
} }
log.debug("[DashScope STT] PCM stats — bytes={} peak={} rms={}", localDurationSeconds = (double) pcm.length / (sampleRate * 2L);
pcm.length, peakRms[0], peakRms[1]); 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()) String model = (request.getModel() != null && !request.getModel().isBlank())
? request.getModel() : DEFAULT_MODEL; ? request.getModel() : DEFAULT_MODEL;
String format = resolveFormat(request.getFileName(), request.getContentType()); String mimeType = AudioMimeTypes.resolveContentType(
String body = buildRequestBody(model, audio, format, request.getLanguage()); request.getFileName(), request.getContentType());
String body = buildRequestBody(model, audio, mimeType, request.getLanguage());
HttpResponse response = HttpRequest.post(ASR_ENDPOINT) HttpResponse response = HttpRequest.post(ASR_ENDPOINT)
.header("Authorization", "Bearer " + apiKey.trim()) .header("Authorization", "Bearer " + apiKey.trim())
@ -180,8 +197,19 @@ public class DashScopeSttProvider implements SttProvider {
} }
String text = parseTranscript(responseBody); String text = parseTranscript(responseBody);
log.info("[DashScope STT] Transcribed {} chars (model={}, format={}, audioBytes={})", if (text.isBlank()) {
text.length(), model, format, audio.length); 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); return SttResult.success(text);
} catch (Exception e) { } catch (Exception e) {
log.error("[DashScope STT] Error: {}", e.getMessage(), 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 * Build the recognition request. The audio rides in a
* {@code data:;base64,} URI the separate {@code format} field tells the * MIME-qualified data URI. Qwen3-ASR uses the media type to decode the
* service how to decode it, so the URI needs no media type. * 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<String, Object> inputAudio = new LinkedHashMap<>(); Map<String, Object> inputAudio = new LinkedHashMap<>();
inputAudio.put("data", "data:;base64," + Base64.getEncoder().encodeToString(audio)); inputAudio.put("data", "data:" + mimeType + ";base64,"
inputAudio.put("format", format); + Base64.getEncoder().encodeToString(audio));
Map<String, Object> payload = new LinkedHashMap<>(); Map<String, Object> payload = new LinkedHashMap<>();
payload.put("model", model); payload.put("model", model);
@ -230,17 +259,6 @@ public class DashScopeSttProvider implements SttProvider {
return dash > 0 ? hint.substring(0, dash) : hint; 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 * Extract the transcript from a chat-completion response. Content is
* normally a plain string; tolerate the content-part array form * normally a plain string; tolerate the content-part array form
@ -262,6 +280,26 @@ public class DashScopeSttProvider implements SttProvider {
return ""; 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 * Pull a human-readable message out of an error body. DashScope's
* compatible mode wraps errors as {@code {"error":{"code","message"}}}; * compatible mode wraps errors as {@code {"error":{"code","message"}}};

View File

@ -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 ### 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 - **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. 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. - **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. - **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. - **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.
--- ---

View File

@ -106,7 +106,7 @@ Google 的图像生成走 **Nano Banana Pro**`gemini-3-pro-image-preview`
### 语音识别STT—— 两个供应商 ### 语音识别STT—— 两个供应商
- **DashScope Paraformer**——中文优先,低延迟 - **DashScope Qwen3-ASR Flash**——支持多语种,强化中文及方言识别
- **OpenAI Whisper**——多语言行业基准 - **OpenAI Whisper**——多语言行业基准
在聊天输入框按住麦克风图标讲话,松手转文本。识别结果可以在发送前再改一遍。 在聊天输入框按住麦克风图标讲话,松手转文本。识别结果可以在发送前再改一遍。
@ -178,7 +178,7 @@ Agent 调用它们和调用任何其他工具一样。工具层负责供应商
- **视频**——短视频 demo、社交内容、产品动画。追求质量用 Runway中文场景用 MiniMax想本地云就 DashScope。 - **视频**——短视频 demo、社交内容、产品动画。追求质量用 Runway中文场景用 MiniMax想本地云就 DashScope。
- **音乐**——背景音乐、Demo 音效、创意尝试。目前两家,后面还会扩。 - **音乐**——背景音乐、Demo 音效、创意尝试。目前两家,后面还会扩。
- **TTS**——无障碍朗读、有声书式阅读、多语言内容。中文用 CosyVoice英语要多样化就 OpenAI。 - **TTS**——无障碍朗读、有声书式阅读、多语言内容。中文用 CosyVoice英语要多样化就 OpenAI。
- **STT**——语音输入、会议转写、口述工作流。中文用 Paraformer其他语言用 Whisper - **STT**——语音输入、会议转写、口述工作流。中文及多语种录音可使用 Qwen3-ASR也可接入 Whisper 兼容端点
--- ---

View File

@ -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));
}
}

View File

@ -14,6 +14,21 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
*/ */
class AudioMimeTypesTest { 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 @Test
@DisplayName("resolveFileName: trusts a caller filename with a known extension") @DisplayName("resolveFileName: trusts a caller filename with a known extension")
void resolveFileName_trustsKnownExtension() { void resolveFileName_trustsKnownExtension() {

View File

@ -51,9 +51,13 @@ class WavPcmExtractorTest {
} }
@Test @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() { void isCanonicalWav_gates() {
assertTrue(WavPcmExtractor.isCanonicalWav(buildWav(16_000, 16, new byte[8]))); 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[64])); // no magic
assertFalse(WavPcmExtractor.isCanonicalWav(new byte[10])); // too short assertFalse(WavPcmExtractor.isCanonicalWav(new byte[10])); // too short
assertFalse(WavPcmExtractor.isCanonicalWav(null)); assertFalse(WavPcmExtractor.isCanonicalWav(null));

View File

@ -20,8 +20,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* parts with encoding rules worth pinning: * parts with encoding rules worth pinning:
* *
* <ul> * <ul>
* <li>The audio must ride as a {@code data:;base64,} URI plus an explicit * <li>The audio must ride as a MIME-qualified data URI. Qwen3-ASR does not
* {@code format} field dropping either breaks decoding server-side.</li> * use the separate {@code format} field supported by other Qwen audio models.</li>
* <li>{@code asr_options} must be omitted entirely when no language hint * <li>{@code asr_options} must be omitted entirely when no language hint
* is supplied, so the service auto-detects.</li> * is supplied, so the service auto-detects.</li>
* <li>Transcript extraction must tolerate both plain-string and * <li>Transcript extraction must tolerate both plain-string and
@ -40,10 +40,10 @@ class DashScopeSttProviderTest {
} }
@Test @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 { void buildRequestBody_coreShape() throws Exception {
byte[] audio = "fake-wav-bytes".getBytes(StandardCharsets.UTF_8); 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); JsonNode node = mapper.readTree(json);
assertEquals("qwen3-asr-flash", node.path("model").asText()); 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); JsonNode content = node.path("messages").path(0).path("content").path(0);
assertEquals("user", node.path("messages").path(0).path("role").asText()); assertEquals("user", node.path("messages").path(0).path("role").asText());
assertEquals("input_audio", content.path("type").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(); 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), assertEquals(Base64.getEncoder().encodeToString(audio),
data.substring("data:;base64,".length())); data.substring("data:audio/wav;base64,".length()));
} }
@Test @Test
@DisplayName("buildRequestBody adds asr_options.language with locale stripped") @DisplayName("buildRequestBody adds asr_options.language with locale stripped")
void buildRequestBody_languageHint() throws Exception { 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); JsonNode node = mapper.readTree(json);
assertEquals("zh", node.path("asr_options").path("language").asText()); 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 // An empty or null language means "let the service detect the
// language"; sending asr_options with a null/blank language field // language"; sending asr_options with a null/blank language field
// would be rejected as a parameter error. // 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()); 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()); assertTrue(mapper.readTree(jsonBlank).path("asr_options").isMissingNode());
} }
@ -90,17 +91,6 @@ class DashScopeSttProviderTest {
assertNull(DashScopeSttProvider.stripLocale(" ")); 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 @Test
@DisplayName("parseTranscript reads plain-string message content") @DisplayName("parseTranscript reads plain-string message content")
void parseTranscript_stringContent() throws Exception { void parseTranscript_stringContent() throws Exception {
@ -127,6 +117,16 @@ class DashScopeSttProviderTest {
assertEquals("", provider.parseTranscript("{\"choices\":[]}")); 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 @Test
@DisplayName("parseErrorMessage handles compatible-mode and native error bodies") @DisplayName("parseErrorMessage handles compatible-mode and native error bodies")
void parseErrorMessage_variants() { void parseErrorMessage_variants() {

View File

@ -65,7 +65,7 @@ const props = defineProps<{
conversationId?: string conversationId?: string
}>() }>()
const emit = defineEmits<{ defineEmits<{
close: [] close: []
}>() }>()
@ -83,6 +83,8 @@ const transcript = ref<Array<{ role: 'user' | 'assistant'; text: string }>>([])
let ws: WebSocket | null = null let ws: WebSocket | null = null
let recorder: WavRecorder | null = null let recorder: WavRecorder | null = null
/** In-flight recorder start; release can arrive while getUserMedia is still resolving. */
let recorderStartPromise: Promise<void> | null = null
/** /**
* Persistent warmed-up recorder kept alive for the modal's lifetime so the * 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. * press-and-hold gesture doesn't race a first-time mic permission dialog.
@ -243,19 +245,24 @@ async function startListening() {
retryConnection() retryConnection()
return 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 { try {
// Web Audio API + manual WAV encode (utils/wavEncoder.ts) replaces // Web Audio API + manual WAV encode (utils/wavEncoder.ts) replaces
// MediaRecorder/WebM. DashScope Paraformer rejects webm; WAV is the // MediaRecorder/WebM. A canonical WAV gives every STT provider an
// lowest common denominator every STT provider accepts. // unambiguous format and lets the backend run PCM quality diagnostics.
// //
// Reuse the warmed-up recorder so we skip the permission dialog if // Reuse the warmed-up recorder so we skip the permission dialog if
// it was successfully acquired in onMounted. If warm-up failed (or // it was successfully acquired in onMounted. If warm-up failed (or
// is still pending), fall back to a fresh recorder. // is still pending), fall back to a fresh recorder.
recorder = warmRecorder ?? new WavRecorder() recorder = warmRecorder ?? new WavRecorder()
warmRecorder = null // ownership transferred for the duration of recording warmRecorder = null // ownership transferred for the duration of recording
await recorder.start() const startPromise = recorder.start()
recorderStartPromise = startPromise
await startPromise
state.value = 'listening' state.value = 'listening'
console.debug('[TalkMode] listening started') console.debug('[TalkMode] listening started')
} catch (err) { } catch (err) {
@ -263,6 +270,8 @@ async function startListening() {
mcToast.error(t('talk.micError')) mcToast.error(t('talk.micError'))
state.value = 'idle' state.value = 'idle'
recorder = null recorder = null
} finally {
recorderStartPromise = null
} }
} }
@ -274,7 +283,18 @@ async function stopListening() {
if (state.value === 'listening') state.value = 'idle' if (state.value === 'listening') state.value = 'idle'
return 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 recorder = null
// Re-warm for the next press so subsequent PTTs also skip permission. // Re-warm for the next press so subsequent PTTs also skip permission.
warmRecorder = new WavRecorder() warmRecorder = new WavRecorder()

View File

@ -1239,7 +1239,7 @@ export default {
sttProvider: 'Select preferred STT provider. Auto mode picks the first available one.', sttProvider: 'Select preferred STT provider. Auto mode picks the first available one.',
sttFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.', 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.', 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 // 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.', 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.', 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', saveFail: 'Save failed',
}, },
sttTitle: 'Speech Recognition', 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' }, sttProviderOptions: { auto: 'Auto Select' },
sttProviderTags: { reuseLlmKey: 'Reuses LLM API Key' }, sttProviderTags: { reuseLlmKey: 'Reuses LLM API Key' },
musicTitle: 'Music Generation', musicTitle: 'Music Generation',

View File

@ -1104,11 +1104,11 @@ export default {
searxngBaseUrl: '自部署 SearXNG 实例地址。Docker 部署时自动配置。', searxngBaseUrl: '自部署 SearXNG 实例地址。Docker 部署时自动配置。',
searchProviderAuto: '让系统按优先级自动挑选一个已配置好的 provider。', searchProviderAuto: '让系统按优先级自动挑选一个已配置好的 provider。',
// STT 语音识别 // STT 语音识别
sttEnabled: '开启后支持语音消息转文字。OpenAI Whisper 和 DashScope Paraformer 均复用已有 Key。', sttEnabled: '开启后支持语音消息转文字。OpenAI Whisper 和 DashScope Qwen3-ASR 均复用已有 Key。',
sttProvider: '选择首选 STT 提供商auto 模式自动选择可用的提供商。', sttProvider: '选择首选 STT 提供商auto 模式自动选择可用的提供商。',
sttFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。', sttFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
openaiSttInfo: '复用模型管理中的 OpenAI API Key。使用 Whisper 模型,支持多语言自动识别。', openaiSttInfo: '复用模型管理中的 OpenAI API Key。使用 Whisper 模型,支持多语言自动识别。',
dashscopeSttInfo: '复用模型管理中的 DashScope API Key。使用 Paraformer RealtimeWebSocket 流式),中文识别效果优秀,亚秒级延迟。', dashscopeSttInfo: '复用模型管理中的 DashScope API Key。使用 Qwen3-ASR Flash 通过 HTTP 转写完整录音,支持多语种和中文方言。',
// Issue #76 // Issue #76
sttOpenAiCompatProviderId: '从模型管理选一个 OpenAI 兼容 provider 行作为凭证baseUrl + API Key来源。除官方 OpenAI 外FunASR 私有部署 / 硅基流动 / Groq / Together / 火山 / 七牛等都可以用——在模型管理新增自定义 provider 后即可在此选用。', sttOpenAiCompatProviderId: '从模型管理选一个 OpenAI 兼容 provider 行作为凭证baseUrl + API Key来源。除官方 OpenAI 外FunASR 私有部署 / 硅基流动 / Groq / Together / 火山 / 七牛等都可以用——在模型管理新增自定义 provider 后即可在此选用。',
sttOpenAiCompatModel: '发送给端点的模型名multipart "model" 字段。OpenAI 默认 whisper-1FunASR 通常是 paraformer-large其他厂商按其文档填写。', sttOpenAiCompatModel: '发送给端点的模型名multipart "model" 字段。OpenAI 默认 whisper-1FunASR 通常是 paraformer-large其他厂商按其文档填写。',
@ -1222,7 +1222,7 @@ export default {
saveFail: '保存失败', saveFail: '保存失败',
}, },
sttTitle: '语音识别', sttTitle: '语音识别',
sttDesc: '配置 STT 语音转文字,支持 OpenAI Whisper 和 DashScope Paraformer', sttDesc: '配置 STT 语音转文字,支持 OpenAI Whisper 和 DashScope Qwen3-ASR',
sttProviderOptions: { auto: '自动选择' }, sttProviderOptions: { auto: '自动选择' },
sttProviderTags: { reuseLlmKey: '复用 LLM API Key' }, sttProviderTags: { reuseLlmKey: '复用 LLM API Key' },
musicTitle: '音乐生成', musicTitle: '音乐生成',

View File

@ -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<MediaStream>((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()
})
})

View File

@ -2,11 +2,10 @@
* Browser-side recorder that captures microphone audio via the Web Audio API * Browser-side recorder that captures microphone audio via the Web Audio API
* and encodes it directly to a 16-bit PCM WAV blob. * and encodes it directly to a 16-bit PCM WAV blob.
* *
* <p>Why this exists: MediaRecorder produces WebM/Opus, which DashScope * <p>Why this exists: MediaRecorder output and codec strings vary by browser.
* Paraformer rejects (it accepts wav/mp3/m4a/flac/aac/amr/ogg-Vorbis only). * A canonical PCM WAV is the lowest common denominator that every STT
* OpenAI Whisper claims WebM support but is finicky with the codecs string * provider accepts without server-side transcoding, and its samples can be
* MediaRecorder picks. WAV is the lowest common denominator that every STT * inspected locally for silence and duration before an API call.
* provider accepts without server-side transcoding.
* *
* <p>Trade-off: WAV files are ~10x larger than Opus. For typical * <p>Trade-off: WAV files are ~10x larger than Opus. For typical
* conversational STT (5-30s clips at 16 kHz / 16-bit / mono) that's * conversational STT (5-30s clips at 16 kHz / 16-bit / mono) that's
@ -54,6 +53,12 @@ export interface WavRecording {
export class WavRecorder { export class WavRecorder {
private audioContext: AudioContext | null = null; private audioContext: AudioContext | null = null;
private mediaStream: MediaStream | null = null; private mediaStream: MediaStream | null = null;
/** Deduplicates warm-up/start getUserMedia calls so they cannot overwrite each other's stream. */
private mediaStreamPromise: Promise<MediaStream> | null = null;
/** Lets stop() wait for a permission/start sequence that has not finished yet. */
private starting: Promise<void> | 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 source: MediaStreamAudioSourceNode | null = null;
private processor: ScriptProcessorNode | null = null; private processor: ScriptProcessorNode | null = null;
/** Silent sink node — keeps the processor graph alive without echoing through speakers. */ /** Silent sink node — keeps the processor graph alive without echoing through speakers. */
@ -73,8 +78,8 @@ export class WavRecorder {
* <p>Safe to call multiple times. Subsequent calls return immediately. * <p>Safe to call multiple times. Subsequent calls return immediately.
*/ */
async warmUp(): Promise<void> { async warmUp(): Promise<void> {
if (this.mediaStream) return; if (this.released) throw new DOMException('Recorder has been released', 'InvalidStateError');
this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); await this.ensureMediaStream();
} }
/** /**
@ -82,7 +87,20 @@ export class WavRecorder {
* Already-started recorders are idempotent calling start twice is a no-op. * Already-started recorders are idempotent calling start twice is a no-op.
*/ */
async start(): Promise<void> { async start(): Promise<void> {
if (this.starting) return this.starting;
if (this.audioContext) return; 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<void> {
// sampleRate hint: browsers honour it on Chrome/Edge but Safari may // sampleRate hint: browsers honour it on Chrome/Edge but Safari may
// ignore it and run at the device default. We resample manually in // ignore it and run at the device default. We resample manually in
@ -90,45 +108,57 @@ export class WavRecorder {
const ctx = new AudioContext(); const ctx = new AudioContext();
this.audioContext = ctx; this.audioContext = ctx;
this.inputSampleRate = ctx.sampleRate; this.inputSampleRate = ctx.sampleRate;
// Reuse the warmed-up stream when present so we skip the permission try {
// dialog on the press-and-hold path. // Reuse the warmed-up stream when present so we skip the permission
if (!this.mediaStream) { // dialog on the press-and-hold path.
this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); 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). * Returns null when nothing was captured (e.g. start failed silently).
*/ */
async stop(): Promise<WavRecording | null> { async stop(): Promise<WavRecording | null> {
const pendingStart = this.starting;
if (pendingStart) {
try {
await pendingStart;
} catch {
return null;
}
}
if (!this.audioContext) return null; if (!this.audioContext) return null;
const durationSeconds = Math.round((Date.now() - this.startTimeMs)) / 1000; const durationSeconds = Math.round((Date.now() - this.startTimeMs)) / 1000;
@ -149,6 +187,8 @@ export class WavRecorder {
await this.audioContext.close(); await this.audioContext.close();
this.audioContext = null; this.audioContext = null;
this.mediaStream = null; this.mediaStream = null;
this.mediaStreamPromise = null;
this.released = true;
this.source = null; this.source = null;
this.processor = null; this.processor = null;
this.silentSink = null; this.silentSink = null;
@ -169,7 +209,7 @@ export class WavRecorder {
} }
const merged = mergeFloat32(collected); const merged = mergeFloat32(collected);
// Resample to 16 kHz if we captured at a higher rate (Safari often runs // 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. // expect, and shrinks the WAV by ~3x at no quality cost for speech.
const resampled = sampleRate === TARGET_SAMPLE_RATE const resampled = sampleRate === TARGET_SAMPLE_RATE
? merged ? merged
@ -188,9 +228,43 @@ export class WavRecorder {
* Pair with {@link warmUp} on modal close. * Pair with {@link warmUp} on modal close.
*/ */
releaseWarmUp(): void { releaseWarmUp(): void {
this.released = true;
if (this.audioContext) return; // active recording owns the stream if (this.audioContext) return; // active recording owns the stream
this.mediaStream?.getTracks().forEach((t) => t.stop()); this.mediaStream?.getTracks().forEach((t) => t.stop());
this.mediaStream = null; 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<MediaStream> {
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 * Decimating linear-interpolation resampler. Adequate for 16 kHz speech
* the accuracy gap vs polyphase resampling is inaudible to Whisper / * the accuracy gap vs polyphase resampling is immaterial to speech
* Paraformer at the input sample rates we see in practice (44.1k 16k). * recognizers at the input sample rates we see in practice (44.1k 16k).
*/ */
function downsample(samples: Float32Array, fromRate: number, toRate: number): Float32Array { function downsample(samples: Float32Array, fromRate: number, toRate: number): Float32Array {
if (fromRate === toRate) return samples; if (fromRate === toRate) return samples;

View File

@ -28,7 +28,7 @@
<select v-model="settings.sttProvider" class="form-input" :disabled="!settings.sttEnabled"> <select v-model="settings.sttProvider" class="form-input" :disabled="!settings.sttEnabled">
<option value="auto">{{ t('settings.sttProviderOptions.auto') }}</option> <option value="auto">{{ t('settings.sttProviderOptions.auto') }}</option>
<option value="openai">OpenAI Whisper</option> <option value="openai">OpenAI Whisper</option>
<option value="dashscope">DashScope (Paraformer Realtime)</option> <option value="dashscope">DashScope (Qwen3-ASR Flash)</option>
</select> </select>
</div> </div>
</div> </div>
@ -99,7 +99,7 @@
</div> </div>
<div class="provider-section"> <div class="provider-section">
<div class="provider-header"> <div class="provider-header">
<span class="provider-name">DashScope (Paraformer Realtime)</span> <span class="provider-name">DashScope (Qwen3-ASR Flash)</span>
<span class="provider-tag">{{ t('settings.sttProviderTags.reuseLlmKey') }}</span> <span class="provider-tag">{{ t('settings.sttProviderTags.reuseLlmKey') }}</span>
</div> </div>
<div class="settings-card"><div class="setting-item"><div class="setting-info"><div class="setting-hint">{{ t('settings.hints.dashscopeSttInfo') }}</div></div></div></div> <div class="settings-card"><div class="setting-item"><div class="setting-info"><div class="setting-hint">{{ t('settings.hints.dashscopeSttInfo') }}</div></div></div></div>