shuziren/src/components/Render/VoiceRender.vue

489 lines
17 KiB
Vue
Raw Normal View History

2026-10-20 16:32:45 +08:00
<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>