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

View File

@ -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.
*
* <p>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.
* <p>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.
*
* <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. */
static String extensionOf(String fileName) {
if (fileName == null) return null;

View File

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

View File

@ -57,7 +57,7 @@ import java.util.Map;
* <pre>{@code
* {"model":"qwen3-asr-flash",
* "messages":[{"role":"user","content":[
* {"type":"input_audio","input_audio":{"data":"data:;base64,...","format":"wav"}}]}],
* {"type":"input_audio","input_audio":{"data":"data:audio/wav;base64,..."}}]}],
* "stream":false,
* "asr_options":{"language":"zh"}} // omitted auto language detection
* }</pre>
@ -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<String, Object> 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<String, Object> 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"}}};

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

View File

@ -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 兼容端点
---

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 {
@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() {

View File

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

View File

@ -20,8 +20,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* parts with encoding rules worth pinning:
*
* <ul>
* <li>The audio must ride as a {@code data:;base64,} URI plus an explicit
* {@code format} field dropping either breaks decoding server-side.</li>
* <li>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.</li>
* <li>{@code asr_options} must be omitted entirely when no language hint
* is supplied, so the service auto-detects.</li>
* <li>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() {

View File

@ -65,7 +65,7 @@ const props = defineProps<{
conversationId?: string
}>()
const emit = defineEmits<{
defineEmits<{
close: []
}>()
@ -83,6 +83,8 @@ const transcript = ref<Array<{ role: 'user' | 'assistant'; text: string }>>([])
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<void> | 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()

View File

@ -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',

View File

@ -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 RealtimeWebSocket 流式),中文识别效果优秀,亚秒级延迟。',
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-1FunASR 通常是 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: '音乐生成',

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
* and encodes it directly to a 16-bit PCM WAV blob.
*
* <p>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.
* <p>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.
*
* <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
@ -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<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 processor: ScriptProcessorNode | null = null;
/** 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.
*/
async warmUp(): Promise<void> {
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<void> {
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<void> {
// 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<WavRecording | null> {
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<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
* 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;

View File

@ -28,7 +28,7 @@
<select v-model="settings.sttProvider" class="form-input" :disabled="!settings.sttEnabled">
<option value="auto">{{ t('settings.sttProviderOptions.auto') }}</option>
<option value="openai">OpenAI Whisper</option>
<option value="dashscope">DashScope (Paraformer Realtime)</option>
<option value="dashscope">DashScope (Qwen3-ASR Flash)</option>
</select>
</div>
</div>
@ -99,7 +99,7 @@
</div>
<div class="provider-section">
<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>
</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>