mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(stt): repair DashScope audio decoding (#580)
This commit is contained in:
parent
314b9ff82e
commit
197e697173
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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"}}};
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 兼容端点。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '音乐生成',
|
||||
|
||||
57
mateclaw-ui/src/utils/__tests__/wavEncoder.test.ts
Normal file
57
mateclaw-ui/src/utils/__tests__/wavEncoder.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user