489 lines
17 KiB
Vue
489 lines
17 KiB
Vue
<template>
|
||
<span class="sr-only" aria-live="polite">{{ assistiveStatus }}</span>
|
||
</template>
|
||
|
||
<script setup>
|
||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
|
||
import {ElMessage} from 'element-plus';
|
||
|
||
const DEEPSEEK_TIMEOUT_MS = 30_000;
|
||
const OUTPUT_SAMPLE_RATE = 16_000;
|
||
const SILENCE_AFTER_SPEECH_MS = 1_200;
|
||
const TRAILING_SILENCE_PADDING_MS = 180;
|
||
const MIN_VOICED_AUDIO_MS = 260;
|
||
const MAX_UTTERANCE_MS = 55_000;
|
||
const PRE_ROLL_MS = 350;
|
||
const PROCESSOR_BUFFER_SIZE = 1_024;
|
||
const MIN_VOLUME_THRESHOLD = 0.012;
|
||
const VOICE_TRIGGER_FRAMES = 2;
|
||
|
||
const props = defineProps({
|
||
speechBusy: {type: Boolean, default: false},
|
||
playbackPending: {type: Boolean, default: false}
|
||
});
|
||
const emit = defineEmits([
|
||
'questionUpdated',
|
||
'questionRecognized',
|
||
'answerReady',
|
||
'requestTimeout',
|
||
'requestError',
|
||
'voiceError',
|
||
'voiceStatus'
|
||
]);
|
||
|
||
const listening = ref(false);
|
||
const recognizing = ref(false);
|
||
const loading = ref(false);
|
||
const assistiveStatus = computed(() => {
|
||
if (recognizing.value) return '正在将语音转换成文字';
|
||
if (loading.value) return '正在向 DeepSeek 提问';
|
||
if (props.speechBusy || props.playbackPending) return '数字人正在回答';
|
||
if (listening.value) return '麦克风已开启,正在聆听';
|
||
return '语音助手正在准备';
|
||
});
|
||
|
||
let mediaStream = null;
|
||
let audioContext = null;
|
||
let sourceNode = null;
|
||
let processorNode = null;
|
||
let silentGainNode = null;
|
||
let requestController = null;
|
||
let asrController = null;
|
||
let disposed = false;
|
||
let initializing = false;
|
||
let inputSampleRate = 48_000;
|
||
let noiseFloor = 0.003;
|
||
let consecutiveVoiceFrames = 0;
|
||
let recording = false;
|
||
let speechStartedAt = 0;
|
||
let lastVoiceAt = 0;
|
||
let voicedAudioMs = 0;
|
||
let audioFrames = [];
|
||
let audioSampleCount = 0;
|
||
let preRollFrames = [];
|
||
let preRollSampleCount = 0;
|
||
let microphonePermission = null;
|
||
|
||
const canCapture = () => !disposed && !recognizing.value && !loading.value && !props.speechBusy && !props.playbackPending;
|
||
|
||
const reportVoiceError = (message) => {
|
||
listening.value = false;
|
||
emit('voiceStatus', message);
|
||
emit('voiceError', message);
|
||
};
|
||
|
||
const resetUtterance = () => {
|
||
consecutiveVoiceFrames = 0;
|
||
recording = false;
|
||
speechStartedAt = 0;
|
||
lastVoiceAt = 0;
|
||
voicedAudioMs = 0;
|
||
audioFrames = [];
|
||
audioSampleCount = 0;
|
||
preRollFrames = [];
|
||
preRollSampleCount = 0;
|
||
};
|
||
|
||
const mergeFrames = (frames, sampleCount) => {
|
||
const merged = new Float32Array(sampleCount);
|
||
let offset = 0;
|
||
frames.forEach(frame => {
|
||
merged.set(frame, offset);
|
||
offset += frame.length;
|
||
});
|
||
return merged;
|
||
};
|
||
|
||
const resampleTo16k = (samples, sourceRate) => {
|
||
if (sourceRate === OUTPUT_SAMPLE_RATE) return samples;
|
||
const ratio = sourceRate / OUTPUT_SAMPLE_RATE;
|
||
const outputLength = Math.max(1, Math.floor(samples.length / ratio));
|
||
const output = new Float32Array(outputLength);
|
||
|
||
if (ratio < 1) {
|
||
for (let index = 0; index < outputLength; index += 1) {
|
||
const sourcePosition = index * ratio;
|
||
const leftIndex = Math.floor(sourcePosition);
|
||
const rightIndex = Math.min(samples.length - 1, leftIndex + 1);
|
||
const fraction = sourcePosition - leftIndex;
|
||
output[index] = samples[leftIndex] * (1 - fraction) + samples[rightIndex] * fraction;
|
||
}
|
||
return output;
|
||
}
|
||
|
||
for (let index = 0; index < outputLength; index += 1) {
|
||
const start = Math.floor(index * ratio);
|
||
const end = Math.min(samples.length, Math.max(start + 1, Math.floor((index + 1) * ratio)));
|
||
let sum = 0;
|
||
for (let sourceIndex = start; sourceIndex < end; sourceIndex += 1) sum += samples[sourceIndex];
|
||
output[index] = sum / (end - start);
|
||
}
|
||
return output;
|
||
};
|
||
|
||
const encodePcm16 = (samples) => {
|
||
const pcm = new Int16Array(samples.length);
|
||
for (let index = 0; index < samples.length; index += 1) {
|
||
const value = Math.max(-1, Math.min(1, samples[index]));
|
||
pcm[index] = value < 0 ? value * 0x8000 : value * 0x7fff;
|
||
}
|
||
return pcm;
|
||
};
|
||
|
||
const askDeepSeek = async (question) => {
|
||
loading.value = true;
|
||
listening.value = false;
|
||
emit('voiceStatus', '已识别问题,正在等待 DeepSeek 回答…');
|
||
const controller = new AbortController();
|
||
requestController = controller;
|
||
let timedOut = false;
|
||
const timeoutId = window.setTimeout(() => {
|
||
timedOut = true;
|
||
emit('requestTimeout');
|
||
controller.abort();
|
||
}, DEEPSEEK_TIMEOUT_MS);
|
||
|
||
try {
|
||
const response = await fetch('/api/deepseek/chat', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({question}),
|
||
signal: controller.signal
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data.error || 'DeepSeek 请求失败,请稍后重试');
|
||
const answer = typeof data.answer === 'string' ? data.answer.trim() : '';
|
||
if (!answer) throw new Error('DeepSeek 没有返回可播报的答案');
|
||
emit('answerReady', answer);
|
||
emit('voiceStatus', 'DeepSeek 已返回,数字人正在准备口播');
|
||
} catch (error) {
|
||
if (timedOut || error?.name === 'AbortError') {
|
||
ElMessage.warning('DeepSeek 30 秒未返回,数字人已自动断开');
|
||
} else {
|
||
const message = error?.message || 'DeepSeek 请求失败,请稍后重试';
|
||
emit('requestError', message);
|
||
emit('voiceStatus', '回答失败,已继续监听');
|
||
ElMessage.error(message);
|
||
}
|
||
} finally {
|
||
window.clearTimeout(timeoutId);
|
||
if (requestController === controller) {
|
||
requestController = null;
|
||
loading.value = false;
|
||
if (!props.speechBusy && !props.playbackPending) {
|
||
listening.value = true;
|
||
emit('voiceStatus', '麦克风已开启,请直接说话');
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const recognizePcm = async (pcm) => {
|
||
recognizing.value = true;
|
||
listening.value = false;
|
||
emit('voiceStatus', '已检测到停顿,正在识别语音…');
|
||
const controller = new AbortController();
|
||
asrController = controller;
|
||
|
||
try {
|
||
const response = await fetch('/api/baidu/asr', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'audio/pcm;rate=16000'},
|
||
body: pcm,
|
||
signal: controller.signal
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data.error || '百度语音识别失败,请重新说一遍');
|
||
const question = typeof data.text === 'string' ? data.text.trim() : '';
|
||
if (!question) throw new Error('没有识别到清晰语音,请重新说一遍');
|
||
|
||
emit('voiceError', '');
|
||
emit('questionUpdated', question);
|
||
emit('questionRecognized', question);
|
||
recognizing.value = false;
|
||
await askDeepSeek(question);
|
||
} catch (error) {
|
||
if (error?.name !== 'AbortError') {
|
||
const message = error?.message || '语音识别失败,请重新说一遍';
|
||
emit('voiceStatus', `${message},正在继续监听`);
|
||
if (!message.includes('没有识别') && !message.includes('录音时间太短')) {
|
||
emit('voiceError', message);
|
||
ElMessage.error(message);
|
||
}
|
||
}
|
||
} finally {
|
||
if (asrController === controller) asrController = null;
|
||
recognizing.value = false;
|
||
if (!loading.value && !props.speechBusy && !props.playbackPending) {
|
||
listening.value = true;
|
||
}
|
||
}
|
||
};
|
||
|
||
const finishUtterance = () => {
|
||
if (!recording || recognizing.value || loading.value) return;
|
||
const frames = audioFrames;
|
||
const sampleCount = audioSampleCount;
|
||
const enoughVoice = voicedAudioMs >= MIN_VOICED_AUDIO_MS;
|
||
const trailingSilenceMs = Math.max(0, performance.now() - lastVoiceAt);
|
||
resetUtterance();
|
||
|
||
if (!enoughVoice || sampleCount === 0) {
|
||
emit('voiceStatus', '声音太短,正在继续监听');
|
||
return;
|
||
}
|
||
|
||
const merged = mergeFrames(frames, sampleCount);
|
||
const removableSilenceMs = Math.max(0, trailingSilenceMs - TRAILING_SILENCE_PADDING_MS);
|
||
const removableSamples = Math.min(
|
||
Math.max(0, merged.length - 1),
|
||
Math.floor(inputSampleRate * removableSilenceMs / 1000)
|
||
);
|
||
const trimmed = removableSamples > 0
|
||
? merged.subarray(0, merged.length - removableSamples)
|
||
: merged;
|
||
const pcm = encodePcm16(resampleTo16k(trimmed, inputSampleRate));
|
||
void recognizePcm(pcm);
|
||
};
|
||
|
||
const pushPreRollFrame = (frame) => {
|
||
preRollFrames.push(frame);
|
||
preRollSampleCount += frame.length;
|
||
const maxSamples = Math.ceil(inputSampleRate * PRE_ROLL_MS / 1000);
|
||
while (preRollSampleCount > maxSamples && preRollFrames.length > 1) {
|
||
const removed = preRollFrames.shift();
|
||
preRollSampleCount -= removed.length;
|
||
}
|
||
};
|
||
|
||
const processAudioFrame = (input) => {
|
||
if (!canCapture()) {
|
||
if (recording || preRollFrames.length) resetUtterance();
|
||
return;
|
||
}
|
||
|
||
const frame = new Float32Array(input.length);
|
||
frame.set(input);
|
||
let squareSum = 0;
|
||
for (let index = 0; index < frame.length; index += 1) squareSum += frame[index] * frame[index];
|
||
const rms = Math.sqrt(squareSum / frame.length);
|
||
const threshold = Math.min(0.08, Math.max(MIN_VOLUME_THRESHOLD, noiseFloor * 3.2));
|
||
const voiceDetected = rms >= threshold;
|
||
const now = performance.now();
|
||
const frameDurationMs = frame.length / inputSampleRate * 1000;
|
||
|
||
if (!recording) {
|
||
pushPreRollFrame(frame);
|
||
if (voiceDetected) consecutiveVoiceFrames += 1;
|
||
else {
|
||
consecutiveVoiceFrames = 0;
|
||
noiseFloor = Math.min(0.02, noiseFloor * 0.96 + rms * 0.04);
|
||
}
|
||
|
||
if (consecutiveVoiceFrames >= VOICE_TRIGGER_FRAMES) {
|
||
recording = true;
|
||
speechStartedAt = now;
|
||
lastVoiceAt = now;
|
||
voicedAudioMs = frameDurationMs * consecutiveVoiceFrames;
|
||
audioFrames = preRollFrames;
|
||
audioSampleCount = preRollSampleCount;
|
||
preRollFrames = [];
|
||
preRollSampleCount = 0;
|
||
emit('voiceStatus', '正在听你说话,说完后稍作停顿即可');
|
||
}
|
||
return;
|
||
}
|
||
|
||
audioFrames.push(frame);
|
||
audioSampleCount += frame.length;
|
||
if (voiceDetected) {
|
||
lastVoiceAt = now;
|
||
voicedAudioMs += frameDurationMs;
|
||
}
|
||
|
||
if (now - lastVoiceAt >= SILENCE_AFTER_SPEECH_MS || now - speechStartedAt >= MAX_UTTERANCE_MS) {
|
||
finishUtterance();
|
||
}
|
||
};
|
||
|
||
const stopAudioCapture = async () => {
|
||
processorNode && (processorNode.onaudioprocess = null);
|
||
processorNode?.disconnect();
|
||
sourceNode?.disconnect();
|
||
silentGainNode?.disconnect();
|
||
mediaStream?.getTracks().forEach(track => track.stop());
|
||
const context = audioContext;
|
||
processorNode = null;
|
||
sourceNode = null;
|
||
silentGainNode = null;
|
||
mediaStream = null;
|
||
audioContext = null;
|
||
resetUtterance();
|
||
if (context && context.state !== 'closed') await context.close().catch(() => undefined);
|
||
};
|
||
|
||
const initializeVoice = async () => {
|
||
if (disposed || initializing) return;
|
||
if (!window.isSecureContext) {
|
||
reportVoiceError('语音交互必须通过 HTTPS 访问;HTTP 页面不能使用手机麦克风');
|
||
return;
|
||
}
|
||
if (!navigator.mediaDevices?.getUserMedia) {
|
||
reportVoiceError('当前浏览器不支持麦克风录音,请使用最新版 Chrome、Edge 或 Safari');
|
||
return;
|
||
}
|
||
|
||
initializing = true;
|
||
emit('voiceStatus', '正在申请麦克风权限…');
|
||
try {
|
||
await stopAudioCapture();
|
||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||
audio: {
|
||
channelCount: 1,
|
||
echoCancellation: true,
|
||
noiseSuppression: true,
|
||
autoGainControl: true
|
||
}
|
||
});
|
||
if (disposed) {
|
||
mediaStream.getTracks().forEach(track => track.stop());
|
||
return;
|
||
}
|
||
|
||
const AudioContextConstructor = window.AudioContext || window.webkitAudioContext;
|
||
if (!AudioContextConstructor) throw new Error('当前浏览器不支持网页录音处理');
|
||
audioContext = new AudioContextConstructor({latencyHint: 'interactive'});
|
||
inputSampleRate = audioContext.sampleRate;
|
||
sourceNode = audioContext.createMediaStreamSource(mediaStream);
|
||
processorNode = audioContext.createScriptProcessor(PROCESSOR_BUFFER_SIZE, 1, 1);
|
||
silentGainNode = audioContext.createGain();
|
||
silentGainNode.gain.value = 0;
|
||
processorNode.onaudioprocess = event => processAudioFrame(event.inputBuffer.getChannelData(0));
|
||
sourceNode.connect(processorNode);
|
||
processorNode.connect(silentGainNode);
|
||
silentGainNode.connect(audioContext.destination);
|
||
await audioContext.resume();
|
||
|
||
emit('voiceError', '');
|
||
listening.value = true;
|
||
emit('voiceStatus', '麦克风已开启,请直接说话');
|
||
} catch (error) {
|
||
const messages = {
|
||
NotAllowedError: '未获得麦克风权限,请在网站设置中允许麦克风;授权后页面会自动开始监听',
|
||
SecurityError: '浏览器安全策略阻止了麦克风,请允许权限;授权后页面会自动开始监听',
|
||
NotFoundError: '没有检测到可用麦克风,请检查手机或耳机麦克风',
|
||
NotReadableError: '麦克风无法读取,可能正在被其他应用占用',
|
||
AbortError: '麦克风启动失败,页面将在恢复后自动重试'
|
||
};
|
||
reportVoiceError(messages[error?.name] || error?.message || '无法打开麦克风,请检查设备和浏览器权限');
|
||
await stopAudioCapture();
|
||
} finally {
|
||
initializing = false;
|
||
}
|
||
};
|
||
|
||
const activateVoice = async () => {
|
||
if (disposed) return;
|
||
emit('voiceError', '');
|
||
resetUtterance();
|
||
if (!audioContext || !mediaStream?.active) {
|
||
await initializeVoice();
|
||
return;
|
||
}
|
||
await audioContext.resume().catch(() => undefined);
|
||
listening.value = true;
|
||
emit('voiceStatus', '麦克风已开启,请直接说话');
|
||
};
|
||
|
||
const handleVisibilityChange = () => {
|
||
if (!document.hidden) void resumeAutomaticListening();
|
||
};
|
||
|
||
const resumeAutomaticListening = async () => {
|
||
if (disposed || document.hidden || initializing) return;
|
||
if (mediaStream?.active && audioContext) {
|
||
await audioContext.resume().catch(() => undefined);
|
||
if (audioContext.state === 'running') {
|
||
listening.value = !recognizing.value && !loading.value && !props.speechBusy && !props.playbackPending;
|
||
if (listening.value) emit('voiceStatus', '麦克风已开启,请直接说话');
|
||
}
|
||
return;
|
||
}
|
||
if (microphonePermission?.state !== 'denied') await initializeVoice();
|
||
};
|
||
|
||
const handleMicrophonePermissionChange = () => {
|
||
if (microphonePermission?.state === 'granted') {
|
||
emit('voiceError', '');
|
||
void resumeAutomaticListening();
|
||
} else if (microphonePermission?.state === 'denied') {
|
||
reportVoiceError('麦克风权限已被禁止,请在浏览器网站设置中改为允许;授权后会自动开始监听');
|
||
}
|
||
};
|
||
|
||
const monitorMicrophonePermission = async () => {
|
||
if (!navigator.permissions?.query) return;
|
||
try {
|
||
microphonePermission = await navigator.permissions.query({name: 'microphone'});
|
||
microphonePermission.addEventListener('change', handleMicrophonePermissionChange);
|
||
} catch {
|
||
microphonePermission = null;
|
||
}
|
||
};
|
||
|
||
const startAutomaticListening = async () => {
|
||
await monitorMicrophonePermission();
|
||
await resumeAutomaticListening();
|
||
};
|
||
|
||
const handleDeviceChange = () => {
|
||
if (!mediaStream?.active) void resumeAutomaticListening();
|
||
};
|
||
|
||
watch(() => [props.speechBusy, props.playbackPending], ([speechBusy, playbackPending]) => {
|
||
resetUtterance();
|
||
if (speechBusy || playbackPending) {
|
||
listening.value = false;
|
||
emit('voiceStatus', '数字人正在回答');
|
||
} else if (!recognizing.value && !loading.value && mediaStream?.active) {
|
||
listening.value = true;
|
||
emit('voiceStatus', '麦克风已开启,请直接说话');
|
||
}
|
||
});
|
||
|
||
defineExpose({activateVoice});
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||
window.addEventListener('focus', resumeAutomaticListening);
|
||
window.addEventListener('pageshow', resumeAutomaticListening);
|
||
navigator.mediaDevices?.addEventListener?.('devicechange', handleDeviceChange);
|
||
void startAutomaticListening();
|
||
});
|
||
onUnmounted(() => {
|
||
disposed = true;
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||
window.removeEventListener('focus', resumeAutomaticListening);
|
||
window.removeEventListener('pageshow', resumeAutomaticListening);
|
||
navigator.mediaDevices?.removeEventListener?.('devicechange', handleDeviceChange);
|
||
microphonePermission?.removeEventListener('change', handleMicrophonePermissionChange);
|
||
requestController?.abort();
|
||
asrController?.abort();
|
||
void stopAudioCapture();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.sr-only {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip: rect(0, 0, 0, 0);
|
||
white-space: nowrap;
|
||
border: 0;
|
||
}
|
||
</style>
|