mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"}}};
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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 兼容端点。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
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() {
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 Realtime(WebSocket 流式),中文识别效果优秀,亚秒级延迟。',
|
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-1;FunASR 通常是 paraformer-large;其他厂商按其文档填写。',
|
sttOpenAiCompatModel: '发送给端点的模型名(multipart "model" 字段)。OpenAI 默认 whisper-1;FunASR 通常是 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: '音乐生成',
|
||||||
|
|||||||
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
|
* 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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user