画面优化
This commit is contained in:
parent
e74900bcb0
commit
fefb6b1d3c
@ -198,10 +198,15 @@ export const createBaiduAsrMiddleware = () => async (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const audio = await readAudioBody(req);
|
const audio = await readAudioBody(req);
|
||||||
|
const requestStartedAt = Date.now();
|
||||||
|
const audioDurationMs = Math.round(audio.length / 2 / 16_000 * 1000);
|
||||||
|
console.info(`[Baidu ASR] start bytes=${audio.length} durationMs=${audioDurationMs}`);
|
||||||
const text = await recognizeAudio(audio);
|
const text = await recognizeAudio(audio);
|
||||||
|
console.info(`[Baidu ASR] success durationMs=${audioDurationMs} elapsedMs=${Date.now() - requestStartedAt}`);
|
||||||
sendJson(res, 200, {text});
|
sendJson(res, 200, {text});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
|
console.warn(`[Baidu ASR] failed status=${error.statusCode} message=${error.message}`);
|
||||||
sendJson(res, error.statusCode, {error: error.message});
|
sendJson(res, error.statusCode, {error: error.message});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
188
src/App.vue
188
src/App.vue
@ -2,7 +2,15 @@
|
|||||||
<main class="realtime-dh-wrapper">
|
<main class="realtime-dh-wrapper">
|
||||||
<section class="realtime-dh-stage" @pointerdown.capture.once="unlockAudioAndPlay">
|
<section class="realtime-dh-stage" @pointerdown.capture.once="unlockAudioAndPlay">
|
||||||
<div class="visual-stage">
|
<div class="visual-stage">
|
||||||
<div class="standby-scene" :class="{thinking: waitingForAnswer}">
|
<div
|
||||||
|
class="standby-scene"
|
||||||
|
:class="{
|
||||||
|
thinking: waitingForAnswer,
|
||||||
|
ready: standbyVisualReady,
|
||||||
|
'human-mounted': showHuman,
|
||||||
|
'human-active': humanVisualReady
|
||||||
|
}"
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
v-for="videoIndex in 2"
|
v-for="videoIndex in 2"
|
||||||
:key="videoIndex"
|
:key="videoIndex"
|
||||||
@ -19,6 +27,7 @@
|
|||||||
playsinline
|
playsinline
|
||||||
preload="auto"
|
preload="auto"
|
||||||
disablepictureinpicture
|
disablepictureinpicture
|
||||||
|
@loadeddata="handleStandbyLoaded(videoIndex - 1)"
|
||||||
@timeupdate="handleStandbyProgress(videoIndex - 1)"
|
@timeupdate="handleStandbyProgress(videoIndex - 1)"
|
||||||
@ended="handleStandbyEnded(videoIndex - 1)"
|
@ended="handleStandbyEnded(videoIndex - 1)"
|
||||||
/>
|
/>
|
||||||
@ -104,6 +113,8 @@
|
|||||||
ref="voiceRenderRef"
|
ref="voiceRenderRef"
|
||||||
:speech-busy="speechBusy"
|
:speech-busy="speechBusy"
|
||||||
:playback-pending="playbackPending"
|
:playback-pending="playbackPending"
|
||||||
|
@speech-detected="handleSpeechDetected"
|
||||||
|
@speech-aborted="handleSpeechAborted"
|
||||||
@question-updated="handleQuestionUpdated"
|
@question-updated="handleQuestionUpdated"
|
||||||
@question-recognized="handleQuestionRecognized"
|
@question-recognized="handleQuestionRecognized"
|
||||||
@answer-ready="handleAnswerReady"
|
@answer-ready="handleAnswerReady"
|
||||||
@ -131,9 +142,10 @@ import {checkPlayUnMute} from './utils/audioUtils';
|
|||||||
|
|
||||||
const ReadyState = {UNINSTANTIATED: -1, CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3};
|
const ReadyState = {UNINSTANTIATED: -1, CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3};
|
||||||
const TOOLBAR_HIDE_DELAY_MS = 3_000;
|
const TOOLBAR_HIDE_DELAY_MS = 3_000;
|
||||||
const POST_SPEECH_DISCONNECT_DELAY_MS = 10_000;
|
const HUMAN_SCENE_FADE_OUT_MS = 80;
|
||||||
const HUMAN_SCENE_FADE_OUT_MS = 920;
|
const HUMAN_SCENE_REVEAL_DELAY_MS = 650;
|
||||||
const STANDBY_CROSSFADE_MS = 650;
|
const STANDBY_CROSSFADE_MS = 760;
|
||||||
|
const AUDIO_RESTORE_DELAYS_MS = [0, 180, 600, 1_200];
|
||||||
const dhIframe = new DHIframe('digital-human-iframe');
|
const dhIframe = new DHIframe('digital-human-iframe');
|
||||||
const realTimeVideoReady = ref(false);
|
const realTimeVideoReady = ref(false);
|
||||||
const humanVisualReady = ref(false);
|
const humanVisualReady = ref(false);
|
||||||
@ -157,18 +169,20 @@ const voiceRenderRef = ref(null);
|
|||||||
const recognizedQuestion = ref('');
|
const recognizedQuestion = ref('');
|
||||||
const deepSeekAnswer = ref('');
|
const deepSeekAnswer = ref('');
|
||||||
const waitingForAnswer = ref(false);
|
const waitingForAnswer = ref(false);
|
||||||
|
const standbyVisualReady = ref(false);
|
||||||
const standbyActiveVideo = ref(0);
|
const standbyActiveVideo = ref(0);
|
||||||
const standbyIncomingVideo = ref(-1);
|
const standbyIncomingVideo = ref(-1);
|
||||||
const standbyVideos = [null, null];
|
const standbyVideos = [null, null];
|
||||||
let connectionGeneration = 0;
|
let connectionGeneration = 0;
|
||||||
let toolbarHideTimer = 0;
|
let toolbarHideTimer = 0;
|
||||||
let postSpeechDisconnectTimer = 0;
|
|
||||||
let humanVisualTimer = 0;
|
let humanVisualTimer = 0;
|
||||||
let humanReleaseTimer = 0;
|
let humanReleaseTimer = 0;
|
||||||
let standbyCrossfadeTimer = 0;
|
let standbyCrossfadeTimer = 0;
|
||||||
let standbyCrossfadeRunning = false;
|
let standbyCrossfadeRunning = false;
|
||||||
|
let audioRestoreTimers = [];
|
||||||
|
let videoPlaybackStartedGeneration = -1;
|
||||||
|
|
||||||
const humanReady = computed(() => realTimeVideoReady.value && humanVisualReady.value && wsConnected.value && showHuman.value);
|
const humanReady = computed(() => realTimeVideoReady.value && wsConnected.value && showHuman.value);
|
||||||
const connectionState = computed(() => !showHuman.value ? 'disconnected' : (humanReady.value ? 'connected' : 'connecting'));
|
const connectionState = computed(() => !showHuman.value ? 'disconnected' : (humanReady.value ? 'connected' : 'connecting'));
|
||||||
const connectionLabel = computed(() => ({
|
const connectionLabel = computed(() => ({
|
||||||
disconnected: '已断开 · 不计费', connecting: '连接中', connected: '已连接'
|
disconnected: '已断开 · 不计费', connecting: '连接中', connected: '已连接'
|
||||||
@ -178,6 +192,9 @@ const playbackPending = computed(() => Boolean(pendingSpeechText.value) || playb
|
|||||||
const setStandbyVideoRef = (element, index) => {
|
const setStandbyVideoRef = (element, index) => {
|
||||||
standbyVideos[index] = element;
|
standbyVideos[index] = element;
|
||||||
};
|
};
|
||||||
|
const handleStandbyLoaded = (index) => {
|
||||||
|
if (index === standbyActiveVideo.value) standbyVisualReady.value = true;
|
||||||
|
};
|
||||||
const finishStandbyCrossfade = (outgoingIndex, incomingIndex) => {
|
const finishStandbyCrossfade = (outgoingIndex, incomingIndex) => {
|
||||||
const outgoing = standbyVideos[outgoingIndex];
|
const outgoing = standbyVideos[outgoingIndex];
|
||||||
standbyActiveVideo.value = incomingIndex;
|
standbyActiveVideo.value = incomingIndex;
|
||||||
@ -244,6 +261,52 @@ const showToolbar = () => {
|
|||||||
toolbarVisible.value = true;
|
toolbarVisible.value = true;
|
||||||
scheduleToolbarHide();
|
scheduleToolbarHide();
|
||||||
};
|
};
|
||||||
|
const syncAudioUnlockFromBrowser = () => {
|
||||||
|
if (soundEnabled.value && navigator.userActivation?.hasBeenActive) {
|
||||||
|
audioUnlocked.value = true;
|
||||||
|
soundUnlockTip.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const clearAudioRestoreTimers = () => {
|
||||||
|
audioRestoreTimers.forEach(timer => window.clearTimeout(timer));
|
||||||
|
audioRestoreTimers = [];
|
||||||
|
};
|
||||||
|
const startVideoPlaybackOnce = () => {
|
||||||
|
if (!showHuman.value || !realTimeVideoReady.value || !wsConnected.value
|
||||||
|
|| !soundEnabled.value || !audioUnlocked.value
|
||||||
|
|| videoPlaybackStartedGeneration === connectionGeneration) return;
|
||||||
|
dhIframe.playVideo(true);
|
||||||
|
videoPlaybackStartedGeneration = connectionGeneration;
|
||||||
|
};
|
||||||
|
const scheduleAudioRestore = () => {
|
||||||
|
clearAudioRestoreTimers();
|
||||||
|
if (!showHuman.value || !soundEnabled.value || !audioUnlocked.value) return;
|
||||||
|
startVideoPlaybackOnce();
|
||||||
|
const generation = connectionGeneration;
|
||||||
|
audioRestoreTimers = AUDIO_RESTORE_DELAYS_MS.map(delay => window.setTimeout(() => {
|
||||||
|
if (generation !== connectionGeneration || !showHuman.value
|
||||||
|
|| !soundEnabled.value || !audioUnlocked.value) return;
|
||||||
|
dhIframe.muteAudio(false);
|
||||||
|
// 官方事件只会上报初始静音,不会上报解除静音;命令发出后以会话状态为准。
|
||||||
|
videoIsMuted.value = false;
|
||||||
|
if (delay === AUDIO_RESTORE_DELAYS_MS.at(-1)) void tryPlayPendingAnswer();
|
||||||
|
}, delay));
|
||||||
|
};
|
||||||
|
const scheduleHumanReveal = (delay = HUMAN_SCENE_REVEAL_DELAY_MS) => {
|
||||||
|
if (humanVisualTimer) {
|
||||||
|
window.clearTimeout(humanVisualTimer);
|
||||||
|
humanVisualTimer = 0;
|
||||||
|
}
|
||||||
|
if (!showHuman.value || !realTimeVideoReady.value || !wsConnected.value) return;
|
||||||
|
const generation = connectionGeneration;
|
||||||
|
humanVisualTimer = window.setTimeout(() => {
|
||||||
|
humanVisualTimer = 0;
|
||||||
|
if (generation === connectionGeneration && showHuman.value
|
||||||
|
&& realTimeVideoReady.value && wsConnected.value) {
|
||||||
|
humanVisualReady.value = true;
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
const cancelHumanRelease = () => {
|
const cancelHumanRelease = () => {
|
||||||
if (humanReleaseTimer) {
|
if (humanReleaseTimer) {
|
||||||
window.clearTimeout(humanReleaseTimer);
|
window.clearTimeout(humanReleaseTimer);
|
||||||
@ -251,6 +314,8 @@ const cancelHumanRelease = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const resetConnectionState = () => {
|
const resetConnectionState = () => {
|
||||||
|
clearAudioRestoreTimers();
|
||||||
|
videoPlaybackStartedGeneration = -1;
|
||||||
if (humanVisualTimer) {
|
if (humanVisualTimer) {
|
||||||
window.clearTimeout(humanVisualTimer);
|
window.clearTimeout(humanVisualTimer);
|
||||||
humanVisualTimer = 0;
|
humanVisualTimer = 0;
|
||||||
@ -263,8 +328,9 @@ const resetConnectionState = () => {
|
|||||||
activeSpeechRequestId.value = '';
|
activeSpeechRequestId.value = '';
|
||||||
videoIsMuted.value = true;
|
videoIsMuted.value = true;
|
||||||
};
|
};
|
||||||
const connectHuman = () => {
|
const connectHuman = (allowWithoutAnswer = false) => {
|
||||||
if (showHuman.value || !pendingSpeechText.value) return;
|
if (showHuman.value || (!pendingSpeechText.value && !allowWithoutAnswer)) return;
|
||||||
|
syncAudioUnlockFromBrowser();
|
||||||
cancelHumanRelease();
|
cancelHumanRelease();
|
||||||
connectionGeneration += 1;
|
connectionGeneration += 1;
|
||||||
resetConnectionState();
|
resetConnectionState();
|
||||||
@ -282,10 +348,6 @@ const finalizeHumanRelease = (preservePendingAnswer = false) => {
|
|||||||
showToolbar();
|
showToolbar();
|
||||||
};
|
};
|
||||||
const releaseHuman = (preservePendingAnswer = false, smooth = false) => {
|
const releaseHuman = (preservePendingAnswer = false, smooth = false) => {
|
||||||
if (postSpeechDisconnectTimer) {
|
|
||||||
window.clearTimeout(postSpeechDisconnectTimer);
|
|
||||||
postSpeechDisconnectTimer = 0;
|
|
||||||
}
|
|
||||||
cancelHumanRelease();
|
cancelHumanRelease();
|
||||||
if (smooth && showHuman.value && humanVisualReady.value) {
|
if (smooth && showHuman.value && humanVisualReady.value) {
|
||||||
humanVisualReady.value = false;
|
humanVisualReady.value = false;
|
||||||
@ -302,23 +364,20 @@ const disconnectHuman = () => {
|
|||||||
releaseHuman(false, true);
|
releaseHuman(false, true);
|
||||||
};
|
};
|
||||||
const sendSpeech = (text) => {
|
const sendSpeech = (text) => {
|
||||||
if (postSpeechDisconnectTimer) {
|
|
||||||
window.clearTimeout(postSpeechDisconnectTimer);
|
|
||||||
postSpeechDisconnectTimer = 0;
|
|
||||||
}
|
|
||||||
cancelHumanRelease();
|
cancelHumanRelease();
|
||||||
const requestId = uuidV4();
|
const requestId = uuidV4();
|
||||||
activeSpeechRequestId.value = requestId;
|
activeSpeechRequestId.value = requestId;
|
||||||
currentSpeechText.value = text;
|
currentSpeechText.value = text;
|
||||||
speechBusy.value = true;
|
speechBusy.value = true;
|
||||||
dhIframe.sendMessage({action: 'TEXT_RENDER', body: text, requestId});
|
dhIframe.sendMessage({action: 'TEXT_RENDER', body: text, requestId});
|
||||||
|
scheduleAudioRestore();
|
||||||
};
|
};
|
||||||
const tryPlayPendingAnswer = async () => {
|
const tryPlayPendingAnswer = async () => {
|
||||||
if (!pendingSpeechText.value || !humanReady.value || playbackPreparing.value || speechBusy.value) return;
|
if (!pendingSpeechText.value || !humanReady.value || playbackPreparing.value || speechBusy.value) return;
|
||||||
playbackPreparing.value = true;
|
playbackPreparing.value = true;
|
||||||
const generation = connectionGeneration;
|
const generation = connectionGeneration;
|
||||||
try {
|
try {
|
||||||
if (soundEnabled.value && videoIsMuted.value) {
|
if (soundEnabled.value) {
|
||||||
const canPlayWithSound = audioUnlocked.value || await checkPlayUnMute();
|
const canPlayWithSound = audioUnlocked.value || await checkPlayUnMute();
|
||||||
if (generation !== connectionGeneration || !humanReady.value) return;
|
if (generation !== connectionGeneration || !humanReady.value) return;
|
||||||
if (!canPlayWithSound) {
|
if (!canPlayWithSound) {
|
||||||
@ -330,10 +389,8 @@ const tryPlayPendingAnswer = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
audioUnlocked.value = true;
|
audioUnlocked.value = true;
|
||||||
dhIframe.playVideo(true);
|
scheduleAudioRestore();
|
||||||
dhIframe.muteAudio(false);
|
await new Promise(resolve => window.setTimeout(resolve, 140));
|
||||||
videoIsMuted.value = false;
|
|
||||||
await new Promise(resolve => window.setTimeout(resolve, 240));
|
|
||||||
} else if (!soundEnabled.value && !videoIsMuted.value) {
|
} else if (!soundEnabled.value && !videoIsMuted.value) {
|
||||||
dhIframe.muteAudio(true);
|
dhIframe.muteAudio(true);
|
||||||
videoIsMuted.value = true;
|
videoIsMuted.value = true;
|
||||||
@ -355,17 +412,25 @@ const handleAnswerReady = async (text) => {
|
|||||||
if (!showHuman.value) connectHuman();
|
if (!showHuman.value) connectHuman();
|
||||||
else {
|
else {
|
||||||
cancelHumanRelease();
|
cancelHumanRelease();
|
||||||
if (realTimeVideoReady.value) humanVisualReady.value = true;
|
|
||||||
void tryPlayPendingAnswer();
|
void tryPlayPendingAnswer();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleQuestionUpdated = (text) => {
|
const handleQuestionUpdated = (text) => {
|
||||||
recognizedQuestion.value = text;
|
recognizedQuestion.value = text;
|
||||||
};
|
};
|
||||||
|
const handleSpeechDetected = () => {
|
||||||
|
cancelHumanRelease();
|
||||||
|
// 录音期间不启动数字人 WebRTC。部分安卓浏览器在第二轮重连 WebRTC 时
|
||||||
|
// 会切换系统音频路由,导致正在录制的麦克风数据异常并触发百度识别超时。
|
||||||
|
// 等 DeepSeek 返回答案后再由 handleAnswerReady 建立连接,不改变画面过渡效果。
|
||||||
|
};
|
||||||
|
// 识别失败时保持现有连接,继续等待下一次语音输入。
|
||||||
|
const handleSpeechAborted = () => {};
|
||||||
const handleQuestionRecognized = (text) => {
|
const handleQuestionRecognized = (text) => {
|
||||||
recognizedQuestion.value = text;
|
recognizedQuestion.value = text;
|
||||||
deepSeekAnswer.value = '';
|
deepSeekAnswer.value = '';
|
||||||
waitingForAnswer.value = true;
|
waitingForAnswer.value = true;
|
||||||
|
cancelHumanRelease();
|
||||||
};
|
};
|
||||||
const handleDeepSeekTimeout = () => {
|
const handleDeepSeekTimeout = () => {
|
||||||
waitingForAnswer.value = false;
|
waitingForAnswer.value = false;
|
||||||
@ -389,9 +454,7 @@ const unlockAudioAndPlay = () => {
|
|||||||
audioUnlocked.value = true;
|
audioUnlocked.value = true;
|
||||||
soundUnlockTip.value = false;
|
soundUnlockTip.value = false;
|
||||||
if (showHuman.value) {
|
if (showHuman.value) {
|
||||||
dhIframe.playVideo(true);
|
scheduleAudioRestore();
|
||||||
dhIframe.muteAudio(false);
|
|
||||||
videoIsMuted.value = false;
|
|
||||||
window.setTimeout(() => void tryPlayPendingAnswer(), 120);
|
window.setTimeout(() => void tryPlayPendingAnswer(), 120);
|
||||||
} else if (pendingSpeechText.value) {
|
} else if (pendingSpeechText.value) {
|
||||||
connectHuman();
|
connectHuman();
|
||||||
@ -402,9 +465,7 @@ const enableSound = () => {
|
|||||||
audioUnlocked.value = true;
|
audioUnlocked.value = true;
|
||||||
soundUnlockTip.value = false;
|
soundUnlockTip.value = false;
|
||||||
if (showHuman.value) {
|
if (showHuman.value) {
|
||||||
dhIframe.playVideo(true);
|
scheduleAudioRestore();
|
||||||
dhIframe.muteAudio(false);
|
|
||||||
videoIsMuted.value = false;
|
|
||||||
window.setTimeout(() => void tryPlayPendingAnswer(), 120);
|
window.setTimeout(() => void tryPlayPendingAnswer(), 120);
|
||||||
} else if (pendingSpeechText.value) {
|
} else if (pendingSpeechText.value) {
|
||||||
connectHuman();
|
connectHuman();
|
||||||
@ -421,18 +482,32 @@ const onMessage = (event) => {
|
|||||||
const {type, content = {}} = event.data;
|
const {type, content = {}} = event.data;
|
||||||
if (type === 'rtcState') {
|
if (type === 'rtcState') {
|
||||||
if (content.action === 'remoteVideoConnected') {
|
if (content.action === 'remoteVideoConnected') {
|
||||||
|
// 这里只表示已获取流,画面可能仍处于白色初始化帧。
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (content.action === 'remotevideoon') {
|
||||||
realTimeVideoReady.value = true;
|
realTimeVideoReady.value = true;
|
||||||
if (humanVisualTimer) window.clearTimeout(humanVisualTimer);
|
scheduleAudioRestore();
|
||||||
humanVisualTimer = window.setTimeout(() => {
|
} else if (content.action === 'remotevideoloading') {
|
||||||
|
realTimeVideoReady.value = false;
|
||||||
|
humanVisualReady.value = false;
|
||||||
|
if (humanVisualTimer) {
|
||||||
|
window.clearTimeout(humanVisualTimer);
|
||||||
humanVisualTimer = 0;
|
humanVisualTimer = 0;
|
||||||
if (showHuman.value && realTimeVideoReady.value) humanVisualReady.value = true;
|
}
|
||||||
}, 220);
|
|
||||||
} else if (content.action === 'remoteVideoDisconnected') {
|
} else if (content.action === 'remoteVideoDisconnected') {
|
||||||
realTimeVideoReady.value = false;
|
realTimeVideoReady.value = false;
|
||||||
|
if (humanVisualTimer) {
|
||||||
|
window.clearTimeout(humanVisualTimer);
|
||||||
|
humanVisualTimer = 0;
|
||||||
|
}
|
||||||
humanVisualReady.value = false;
|
humanVisualReady.value = false;
|
||||||
}
|
}
|
||||||
if (content.action === 'localVideoMuted') {
|
if (content.action === 'localVideoMuted') {
|
||||||
if (parseMutedState(content.body)) videoIsMuted.value = true;
|
if (parseMutedState(content.body)) {
|
||||||
|
videoIsMuted.value = true;
|
||||||
|
if (audioUnlocked.value && soundEnabled.value) scheduleAudioRestore();
|
||||||
|
}
|
||||||
if (soundEnabled.value && videoIsMuted.value && !audioUnlocked.value
|
if (soundEnabled.value && videoIsMuted.value && !audioUnlocked.value
|
||||||
&& (pendingSpeechText.value || speechBusy.value)) {
|
&& (pendingSpeechText.value || speechBusy.value)) {
|
||||||
soundUnlockTip.value = true;
|
soundUnlockTip.value = true;
|
||||||
@ -440,17 +515,29 @@ const onMessage = (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type === 'wsState') {
|
} else if (type === 'wsState') {
|
||||||
if (content.readyState === ReadyState.OPEN) wsConnected.value = true;
|
if (content.readyState === ReadyState.OPEN) {
|
||||||
|
wsConnected.value = true;
|
||||||
|
scheduleAudioRestore();
|
||||||
|
}
|
||||||
else if (content.readyState === ReadyState.CLOSED || content.readyState === ReadyState.CLOSING) {
|
else if (content.readyState === ReadyState.CLOSED || content.readyState === ReadyState.CLOSING) {
|
||||||
wsConnected.value = false;
|
wsConnected.value = false;
|
||||||
|
humanVisualReady.value = false;
|
||||||
|
if (humanVisualTimer) {
|
||||||
|
window.clearTimeout(humanVisualTimer);
|
||||||
|
humanVisualTimer = 0;
|
||||||
|
}
|
||||||
speechBusy.value = false;
|
speechBusy.value = false;
|
||||||
activeSpeechRequestId.value = '';
|
activeSpeechRequestId.value = '';
|
||||||
}
|
}
|
||||||
} else if (type === 'msg') {
|
} else if (type === 'msg') {
|
||||||
const {action, requestId} = content;
|
const {action, requestId} = content;
|
||||||
|
if (requestId === activeSpeechRequestId.value && action === 'RENDER_START') {
|
||||||
|
scheduleAudioRestore();
|
||||||
|
scheduleHumanReveal(HUMAN_SCENE_REVEAL_DELAY_MS);
|
||||||
|
}
|
||||||
if (requestId === activeSpeechRequestId.value && ['FINISHED', 'RENDER_ERROR', 'RENDER_INTERRUPTED'].includes(action)) {
|
if (requestId === activeSpeechRequestId.value && ['FINISHED', 'RENDER_ERROR', 'RENDER_INTERRUPTED'].includes(action)) {
|
||||||
const replayAfterMutedPlayback = action === 'FINISHED' && soundEnabled.value
|
const replayAfterMutedPlayback = action === 'FINISHED' && soundEnabled.value
|
||||||
&& videoIsMuted.value && currentSpeechText.value;
|
&& !audioUnlocked.value && videoIsMuted.value && currentSpeechText.value;
|
||||||
const replayText = replayAfterMutedPlayback ? currentSpeechText.value : '';
|
const replayText = replayAfterMutedPlayback ? currentSpeechText.value : '';
|
||||||
speechBusy.value = false;
|
speechBusy.value = false;
|
||||||
activeSpeechRequestId.value = '';
|
activeSpeechRequestId.value = '';
|
||||||
@ -458,33 +545,30 @@ const onMessage = (event) => {
|
|||||||
if (replayAfterMutedPlayback) {
|
if (replayAfterMutedPlayback) {
|
||||||
pendingSpeechText.value = replayText;
|
pendingSpeechText.value = replayText;
|
||||||
soundUnlockTip.value = true;
|
soundUnlockTip.value = true;
|
||||||
releaseHuman(true, true);
|
showToolbar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
postSpeechDisconnectTimer = window.setTimeout(() => {
|
|
||||||
postSpeechDisconnectTimer = 0;
|
|
||||||
if (!pendingSpeechText.value && !speechBusy.value) releaseHuman(false, true);
|
|
||||||
}, POST_SPEECH_DISCONNECT_DELAY_MS);
|
|
||||||
}
|
}
|
||||||
if (action === 'DISCONNECT_ALERT') {
|
if (action === 'DISCONNECT_ALERT') {
|
||||||
showTimeoutTip.value = true;
|
// cp-inactiveDisconnectSec=0 后通常不会触发;收到旧会话提醒也保持连接。
|
||||||
showToolbar();
|
showTimeoutTip.value = false;
|
||||||
} else if (action === 'TIMEOUT_EXIT') disconnectHuman();
|
} else if (action === 'TIMEOUT_EXIT') disconnectHuman();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch([humanReady, videoIsMuted, soundEnabled, pendingSpeechText], () => void tryPlayPendingAnswer(), {flush: 'post'});
|
watch([humanReady, videoIsMuted, soundEnabled, pendingSpeechText], () => void tryPlayPendingAnswer(), {flush: 'post'});
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
syncAudioUnlockFromBrowser();
|
||||||
dhIframe.registerMessageReceived(onMessage);
|
dhIframe.registerMessageReceived(onMessage);
|
||||||
scheduleToolbarHide();
|
scheduleToolbarHide();
|
||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
connectionGeneration += 1;
|
connectionGeneration += 1;
|
||||||
clearToolbarTimer();
|
clearToolbarTimer();
|
||||||
if (postSpeechDisconnectTimer) window.clearTimeout(postSpeechDisconnectTimer);
|
|
||||||
if (humanVisualTimer) window.clearTimeout(humanVisualTimer);
|
if (humanVisualTimer) window.clearTimeout(humanVisualTimer);
|
||||||
if (humanReleaseTimer) window.clearTimeout(humanReleaseTimer);
|
if (humanReleaseTimer) window.clearTimeout(humanReleaseTimer);
|
||||||
if (standbyCrossfadeTimer) window.clearTimeout(standbyCrossfadeTimer);
|
if (standbyCrossfadeTimer) window.clearTimeout(standbyCrossfadeTimer);
|
||||||
|
clearAudioRestoreTimers();
|
||||||
standbyVideos.forEach(video => video?.pause());
|
standbyVideos.forEach(video => video?.pause());
|
||||||
dhIframe.removeMessageReceived(onMessage);
|
dhIframe.removeMessageReceived(onMessage);
|
||||||
});
|
});
|
||||||
@ -493,18 +577,22 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
#digital-human-iframe { width: 100%; height: 100%; border: none; display: block; }
|
#digital-human-iframe { width: 100%; height: 100%; border: none; display: block; }
|
||||||
.realtime-dh-wrapper, .realtime-dh-stage { width: 100%; min-height: 100dvh; }
|
.realtime-dh-wrapper, .realtime-dh-stage { width: 100%; min-height: 100dvh; }
|
||||||
.realtime-dh-wrapper { background: #f3f4fb; }
|
.realtime-dh-wrapper { background: #5a2f1b; }
|
||||||
.realtime-dh-stage { position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; background: #f3f4fb; }
|
.realtime-dh-stage { position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; background: #5a2f1b; }
|
||||||
.visual-stage { position: absolute; z-index: 0; left: 50%; top: 50%; width: min(100%, 177.7778dvh); aspect-ratio: 16 / 9; overflow: hidden; transform: translate(-50%, -50%); background: #5a2f1b; }
|
.visual-stage { position: absolute; z-index: 0; left: 50%; top: 50%; width: min(100%, 177.7778dvh); aspect-ratio: 16 / 9; overflow: hidden; transform: translate(-50%, -50%); background: #5a2f1b; }
|
||||||
.standby-scene, .human-frame { position: absolute; inset: 0; }
|
.standby-scene, .human-frame { position: absolute; inset: 0; }
|
||||||
.standby-scene { z-index: 0; overflow: hidden; background: #5a2f1b; }
|
.standby-scene { z-index: 2; overflow: hidden; opacity: 0; background: #5a2f1b; transform: translateZ(0) scale(1.008); filter: brightness(.92); backface-visibility: hidden; will-change: opacity, transform, filter; transition: opacity 420ms ease-out, transform 1150ms cubic-bezier(.22, 1, .36, 1), filter 900ms ease; }
|
||||||
.standby-video { position: absolute; inset: 0; width: 100%; height: 100%; display: block; object-fit: cover; object-position: center; opacity: 0; background: #5a2f1b; transition: opacity 650ms ease-in-out, filter 500ms ease; }
|
.standby-scene.ready { opacity: 1; transform: translateZ(0) scale(1); filter: brightness(1); }
|
||||||
|
.standby-scene.ready.human-mounted { transition: none; }
|
||||||
|
.standby-scene.ready.human-mounted.human-active { opacity: 0; pointer-events: none; transition: opacity 180ms ease-out; }
|
||||||
|
.standby-video { position: absolute; inset: 0; width: 100%; height: 100%; display: block; object-fit: cover; object-position: center; opacity: 0; background: #5a2f1b; transform: translateZ(0); backface-visibility: hidden; will-change: opacity, filter; transition: opacity 760ms cubic-bezier(.4, 0, .2, 1), filter 650ms ease; }
|
||||||
.standby-video.visible, .standby-video.incoming { opacity: 1; }
|
.standby-video.visible, .standby-video.incoming { opacity: 1; }
|
||||||
.standby-video.visible { z-index: 1; }.standby-video.incoming { z-index: 2; }
|
.standby-video.visible { z-index: 1; }.standby-video.incoming { z-index: 2; }
|
||||||
.standby-scene.thinking .standby-video { filter: saturate(.92) brightness(.86); }
|
.standby-scene.thinking .standby-video { filter: saturate(.92) brightness(.86); }
|
||||||
.standby-shade { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(18, 8, 4, .03) 42%, rgba(17, 8, 4, .58) 100%); }
|
.standby-shade { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(18, 8, 4, .03) 42%, rgba(17, 8, 4, .58) 100%); }
|
||||||
.human-frame { z-index: 1; opacity: 0; overflow: hidden; background: #5a2f1b; pointer-events: none; transform: translateZ(0) scale(1.004); filter: blur(1.5px); backface-visibility: hidden; will-change: opacity, transform, filter; transition: opacity 900ms cubic-bezier(.4, 0, .2, 1), transform 1100ms cubic-bezier(.22, 1, .36, 1), filter 700ms ease-out; }
|
.human-frame { z-index: 1; overflow: hidden; background: #5a2f1b; pointer-events: none; transform: translateZ(0); backface-visibility: hidden; }
|
||||||
.human-frame.ready { opacity: 1; pointer-events: auto; transform: translateZ(0) scale(1); filter: blur(0); }
|
.human-frame.ready { pointer-events: auto; }
|
||||||
|
.human-frame :deep(#digital-human-iframe) { position: relative; z-index: 1; width: 100%; height: 100%; border: 0; display: block; }
|
||||||
.thinking-panel { position: absolute; z-index: 2; left: 50%; bottom: clamp(120px, 17vh, 190px); display: flex; align-items: center; gap: 13px; min-width: min(360px, calc(100% - 36px)); padding: 13px 17px; transform: translateX(-50%); border: 1px solid rgba(255, 238, 220, .35); border-radius: 18px; color: #fff7ed; background: rgba(37, 18, 10, .7); box-shadow: 0 18px 48px rgba(20, 7, 2, .3); backdrop-filter: blur(13px); }
|
.thinking-panel { position: absolute; z-index: 2; left: 50%; bottom: clamp(120px, 17vh, 190px); display: flex; align-items: center; gap: 13px; min-width: min(360px, calc(100% - 36px)); padding: 13px 17px; transform: translateX(-50%); border: 1px solid rgba(255, 238, 220, .35); border-radius: 18px; color: #fff7ed; background: rgba(37, 18, 10, .7); box-shadow: 0 18px 48px rgba(20, 7, 2, .3); backdrop-filter: blur(13px); }
|
||||||
.thinking-orbit { position: relative; flex: 0 0 36px; width: 36px; height: 36px; }
|
.thinking-orbit { position: relative; flex: 0 0 36px; width: 36px; height: 36px; }
|
||||||
.thinking-orbit span { position: absolute; inset: 0; border: 1px solid rgba(255, 221, 181, .44); border-radius: 50%; }
|
.thinking-orbit span { position: absolute; inset: 0; border: 1px solid rgba(255, 221, 181, .44); border-radius: 50%; }
|
||||||
@ -551,5 +639,5 @@ onUnmounted(() => {
|
|||||||
.realtime-dh-right-tip { bottom: calc(38dvh + 22px); }
|
.realtime-dh-right-tip { bottom: calc(38dvh + 22px); }
|
||||||
}
|
}
|
||||||
@media (max-width: 520px) { .connection-toolbar { left: 8px; right: 8px; gap: 6px; padding: 6px; }.connection-badge { margin-right: auto; max-width: 102px; overflow: hidden; text-overflow: ellipsis; font-size: 11px; }.connection-toolbar :deep(.el-button) { padding: 7px 10px; }.thinking-orbit { flex-basis: 30px; width: 30px; height: 30px; }.thinking-orbit span:nth-child(3) { inset: 11px; } }
|
@media (max-width: 520px) { .connection-toolbar { left: 8px; right: 8px; gap: 6px; padding: 6px; }.connection-badge { margin-right: auto; max-width: 102px; overflow: hidden; text-overflow: ellipsis; font-size: 11px; }.connection-toolbar :deep(.el-button) { padding: 7px 10px; }.thinking-orbit { flex-basis: 30px; width: 30px; height: 30px; }.thinking-orbit span:nth-child(3) { inset: 11px; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .standby-video, .human-frame { transition-duration: 0s; }.thinking-panel.active .thinking-orbit span, .thinking-dots i { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .standby-scene, .standby-video, .human-frame { transition-duration: 0s; }.thinking-panel.active .thinking-orbit span, .thinking-dots i { animation: none; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Binary file not shown.
@ -19,13 +19,15 @@ export default {
|
|||||||
ttsPerson: null,
|
ttsPerson: null,
|
||||||
'cp-ttsSample': '16000',
|
'cp-ttsSample': '16000',
|
||||||
initMode: 'noAudio',
|
initMode: 'noAudio',
|
||||||
videoBg: '#F3F4FB',
|
videoBg: '#5A2F1B',
|
||||||
resolutionWidth: 1920,
|
resolutionWidth: 1920,
|
||||||
resolutionHeight: 1080,
|
resolutionHeight: 1080,
|
||||||
showDebugger: true,
|
showDebugger: true,
|
||||||
ttsPer: '20008742',
|
ttsPer: '20008742',
|
||||||
backgroundImageUrl: 'https://meta-human-editor-test.cdn.bcebos.com/17f8e526-1530-48ab-b02a-1c6138e1da1e/ffda0bfd-a0f5-49cc-bff4-1501ac1aa155/defaultBg.png',
|
backgroundImageUrl: 'https://meta-human-editor-test.cdn.bcebos.com/17f8e526-1530-48ab-b02a-1c6138e1da1e/ffda0bfd-a0f5-49cc-bff4-1501ac1aa155/defaultBg.png',
|
||||||
'cp-preAlertSec': 120,
|
// 连接由外层页面按 DeepSeek 30 秒超时规则管理,关闭百度组件自身的闲置退出提醒。
|
||||||
|
'cp-inactiveDisconnectSec': 0,
|
||||||
|
'cp-preAlertSec': 0,
|
||||||
'cp-positionV2': '{"location":{"top":0,"left":0,"width":1920,"height":1080}}'
|
'cp-positionV2': '{"location":{"top":0,"left":0,"width":1920,"height":1080}}'
|
||||||
};
|
};
|
||||||
const QUERY_OVERRIDE_KEYS = new Set([
|
const QUERY_OVERRIDE_KEYS = new Set([
|
||||||
@ -36,6 +38,7 @@ export default {
|
|||||||
'resolutionHeight',
|
'resolutionHeight',
|
||||||
'showDebugger',
|
'showDebugger',
|
||||||
'backgroundImageUrl',
|
'backgroundImageUrl',
|
||||||
|
'cp-inactiveDisconnectSec',
|
||||||
'cp-preAlertSec',
|
'cp-preAlertSec',
|
||||||
'cp-positionV2'
|
'cp-positionV2'
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -11,11 +11,12 @@ const OUTPUT_SAMPLE_RATE = 16_000;
|
|||||||
const SILENCE_AFTER_SPEECH_MS = 1_200;
|
const SILENCE_AFTER_SPEECH_MS = 1_200;
|
||||||
const TRAILING_SILENCE_PADDING_MS = 180;
|
const TRAILING_SILENCE_PADDING_MS = 180;
|
||||||
const MIN_VOICED_AUDIO_MS = 260;
|
const MIN_VOICED_AUDIO_MS = 260;
|
||||||
const MAX_UTTERANCE_MS = 55_000;
|
const MAX_UTTERANCE_MS = 20_000;
|
||||||
const PRE_ROLL_MS = 350;
|
const PRE_ROLL_MS = 350;
|
||||||
const PROCESSOR_BUFFER_SIZE = 1_024;
|
const PROCESSOR_BUFFER_SIZE = 1_024;
|
||||||
const MIN_VOLUME_THRESHOLD = 0.012;
|
const MIN_VOLUME_THRESHOLD = 0.012;
|
||||||
const VOICE_TRIGGER_FRAMES = 2;
|
const VOICE_TRIGGER_FRAMES = 2;
|
||||||
|
const SPEECH_PEAK_HANGOVER_RATIO = 0.15;
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
speechBusy: {type: Boolean, default: false},
|
speechBusy: {type: Boolean, default: false},
|
||||||
@ -24,6 +25,8 @@ const props = defineProps({
|
|||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'questionUpdated',
|
'questionUpdated',
|
||||||
'questionRecognized',
|
'questionRecognized',
|
||||||
|
'speechDetected',
|
||||||
|
'speechAborted',
|
||||||
'answerReady',
|
'answerReady',
|
||||||
'requestTimeout',
|
'requestTimeout',
|
||||||
'requestError',
|
'requestError',
|
||||||
@ -58,11 +61,13 @@ let recording = false;
|
|||||||
let speechStartedAt = 0;
|
let speechStartedAt = 0;
|
||||||
let lastVoiceAt = 0;
|
let lastVoiceAt = 0;
|
||||||
let voicedAudioMs = 0;
|
let voicedAudioMs = 0;
|
||||||
|
let peakVoiceRms = 0;
|
||||||
let audioFrames = [];
|
let audioFrames = [];
|
||||||
let audioSampleCount = 0;
|
let audioSampleCount = 0;
|
||||||
let preRollFrames = [];
|
let preRollFrames = [];
|
||||||
let preRollSampleCount = 0;
|
let preRollSampleCount = 0;
|
||||||
let microphonePermission = null;
|
let microphonePermission = null;
|
||||||
|
let captureLifecyclePromise = Promise.resolve();
|
||||||
|
|
||||||
const canCapture = () => !disposed && !recognizing.value && !loading.value && !props.speechBusy && !props.playbackPending;
|
const canCapture = () => !disposed && !recognizing.value && !loading.value && !props.speechBusy && !props.playbackPending;
|
||||||
|
|
||||||
@ -78,6 +83,7 @@ const resetUtterance = () => {
|
|||||||
speechStartedAt = 0;
|
speechStartedAt = 0;
|
||||||
lastVoiceAt = 0;
|
lastVoiceAt = 0;
|
||||||
voicedAudioMs = 0;
|
voicedAudioMs = 0;
|
||||||
|
peakVoiceRms = 0;
|
||||||
audioFrames = [];
|
audioFrames = [];
|
||||||
audioSampleCount = 0;
|
audioSampleCount = 0;
|
||||||
preRollFrames = [];
|
preRollFrames = [];
|
||||||
@ -188,7 +194,10 @@ const recognizePcm = async (pcm) => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/baidu/asr', {
|
const response = await fetch('/api/baidu/asr', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'audio/pcm;rate=16000'},
|
headers: {
|
||||||
|
'Content-Type': 'audio/pcm;rate=16000',
|
||||||
|
'X-Audio-Duration-Ms': String(Math.round(pcm.byteLength / 2 / OUTPUT_SAMPLE_RATE * 1000))
|
||||||
|
},
|
||||||
body: pcm,
|
body: pcm,
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
@ -203,6 +212,7 @@ const recognizePcm = async (pcm) => {
|
|||||||
recognizing.value = false;
|
recognizing.value = false;
|
||||||
await askDeepSeek(question);
|
await askDeepSeek(question);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
emit('speechAborted');
|
||||||
if (error?.name !== 'AbortError') {
|
if (error?.name !== 'AbortError') {
|
||||||
const message = error?.message || '语音识别失败,请重新说一遍';
|
const message = error?.message || '语音识别失败,请重新说一遍';
|
||||||
emit('voiceStatus', `${message},正在继续监听`);
|
emit('voiceStatus', `${message},正在继续监听`);
|
||||||
@ -229,6 +239,7 @@ const finishUtterance = () => {
|
|||||||
resetUtterance();
|
resetUtterance();
|
||||||
|
|
||||||
if (!enoughVoice || sampleCount === 0) {
|
if (!enoughVoice || sampleCount === 0) {
|
||||||
|
emit('speechAborted');
|
||||||
emit('voiceStatus', '声音太短,正在继续监听');
|
emit('voiceStatus', '声音太短,正在继续监听');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -267,7 +278,10 @@ const processAudioFrame = (input) => {
|
|||||||
let squareSum = 0;
|
let squareSum = 0;
|
||||||
for (let index = 0; index < frame.length; index += 1) squareSum += frame[index] * frame[index];
|
for (let index = 0; index < frame.length; index += 1) squareSum += frame[index] * frame[index];
|
||||||
const rms = Math.sqrt(squareSum / frame.length);
|
const rms = Math.sqrt(squareSum / frame.length);
|
||||||
const threshold = Math.min(0.08, Math.max(MIN_VOLUME_THRESHOLD, noiseFloor * 3.2));
|
const baseThreshold = Math.min(0.08, Math.max(MIN_VOLUME_THRESHOLD, noiseFloor * 3.2));
|
||||||
|
const threshold = recording && peakVoiceRms > 0
|
||||||
|
? Math.max(baseThreshold, peakVoiceRms * SPEECH_PEAK_HANGOVER_RATIO)
|
||||||
|
: baseThreshold;
|
||||||
const voiceDetected = rms >= threshold;
|
const voiceDetected = rms >= threshold;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
const frameDurationMs = frame.length / inputSampleRate * 1000;
|
const frameDurationMs = frame.length / inputSampleRate * 1000;
|
||||||
@ -282,6 +296,8 @@ const processAudioFrame = (input) => {
|
|||||||
|
|
||||||
if (consecutiveVoiceFrames >= VOICE_TRIGGER_FRAMES) {
|
if (consecutiveVoiceFrames >= VOICE_TRIGGER_FRAMES) {
|
||||||
recording = true;
|
recording = true;
|
||||||
|
peakVoiceRms = rms;
|
||||||
|
emit('speechDetected');
|
||||||
speechStartedAt = now;
|
speechStartedAt = now;
|
||||||
lastVoiceAt = now;
|
lastVoiceAt = now;
|
||||||
voicedAudioMs = frameDurationMs * consecutiveVoiceFrames;
|
voicedAudioMs = frameDurationMs * consecutiveVoiceFrames;
|
||||||
@ -297,6 +313,7 @@ const processAudioFrame = (input) => {
|
|||||||
audioFrames.push(frame);
|
audioFrames.push(frame);
|
||||||
audioSampleCount += frame.length;
|
audioSampleCount += frame.length;
|
||||||
if (voiceDetected) {
|
if (voiceDetected) {
|
||||||
|
peakVoiceRms = Math.max(peakVoiceRms, rms);
|
||||||
lastVoiceAt = now;
|
lastVoiceAt = now;
|
||||||
voicedAudioMs += frameDurationMs;
|
voicedAudioMs += frameDurationMs;
|
||||||
}
|
}
|
||||||
@ -354,6 +371,7 @@ const initializeVoice = async () => {
|
|||||||
if (!AudioContextConstructor) throw new Error('当前浏览器不支持网页录音处理');
|
if (!AudioContextConstructor) throw new Error('当前浏览器不支持网页录音处理');
|
||||||
audioContext = new AudioContextConstructor({latencyHint: 'interactive'});
|
audioContext = new AudioContextConstructor({latencyHint: 'interactive'});
|
||||||
inputSampleRate = audioContext.sampleRate;
|
inputSampleRate = audioContext.sampleRate;
|
||||||
|
noiseFloor = 0.003;
|
||||||
sourceNode = audioContext.createMediaStreamSource(mediaStream);
|
sourceNode = audioContext.createMediaStreamSource(mediaStream);
|
||||||
processorNode = audioContext.createScriptProcessor(PROCESSOR_BUFFER_SIZE, 1, 1);
|
processorNode = audioContext.createScriptProcessor(PROCESSOR_BUFFER_SIZE, 1, 1);
|
||||||
silentGainNode = audioContext.createGain();
|
silentGainNode = audioContext.createGain();
|
||||||
@ -400,7 +418,7 @@ const handleVisibilityChange = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resumeAutomaticListening = async () => {
|
const resumeAutomaticListening = async () => {
|
||||||
if (disposed || document.hidden || initializing) return;
|
if (disposed || document.hidden || initializing || props.speechBusy || props.playbackPending) return;
|
||||||
if (mediaStream?.active && audioContext) {
|
if (mediaStream?.active && audioContext) {
|
||||||
await audioContext.resume().catch(() => undefined);
|
await audioContext.resume().catch(() => undefined);
|
||||||
if (audioContext.state === 'running') {
|
if (audioContext.state === 'running') {
|
||||||
@ -412,6 +430,16 @@ const resumeAutomaticListening = async () => {
|
|||||||
if (microphonePermission?.state !== 'denied') await initializeVoice();
|
if (microphonePermission?.state !== 'denied') await initializeVoice();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queueCaptureLifecycle = (operation) => {
|
||||||
|
captureLifecyclePromise = captureLifecyclePromise
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(operation)
|
||||||
|
.catch(error => {
|
||||||
|
if (!disposed) reportVoiceError(error?.message || '麦克风恢复失败,请检查浏览器权限');
|
||||||
|
});
|
||||||
|
return captureLifecyclePromise;
|
||||||
|
};
|
||||||
|
|
||||||
const handleMicrophonePermissionChange = () => {
|
const handleMicrophonePermissionChange = () => {
|
||||||
if (microphonePermission?.state === 'granted') {
|
if (microphonePermission?.state === 'granted') {
|
||||||
emit('voiceError', '');
|
emit('voiceError', '');
|
||||||
@ -440,11 +468,21 @@ const handleDeviceChange = () => {
|
|||||||
if (!mediaStream?.active) void resumeAutomaticListening();
|
if (!mediaStream?.active) void resumeAutomaticListening();
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => [props.speechBusy, props.playbackPending], ([speechBusy, playbackPending]) => {
|
watch(() => [props.speechBusy, props.playbackPending], ([speechBusy, playbackPending], previous = [false, false]) => {
|
||||||
|
const answering = speechBusy || playbackPending;
|
||||||
|
const wasAnswering = previous[0] || previous[1];
|
||||||
resetUtterance();
|
resetUtterance();
|
||||||
if (speechBusy || playbackPending) {
|
if (answering) {
|
||||||
listening.value = false;
|
listening.value = false;
|
||||||
emit('voiceStatus', '数字人正在回答');
|
emit('voiceStatus', '数字人正在回答');
|
||||||
|
if (!wasAnswering) {
|
||||||
|
// 口播开始前彻底释放麦克风,避免数字人扬声器声音和 WebRTC 音频路由
|
||||||
|
// 污染下一轮录音。浏览器已授权后,重新获取不会再次弹权限窗口。
|
||||||
|
void queueCaptureLifecycle(() => stopAudioCapture());
|
||||||
|
}
|
||||||
|
} else if (wasAnswering && !recognizing.value && !loading.value) {
|
||||||
|
// 口播结束后创建全新的 MediaStream 与 AudioContext;不能只修改 listening 状态。
|
||||||
|
void queueCaptureLifecycle(() => initializeVoice());
|
||||||
} else if (!recognizing.value && !loading.value && mediaStream?.active) {
|
} else if (!recognizing.value && !loading.value && mediaStream?.active) {
|
||||||
listening.value = true;
|
listening.value = true;
|
||||||
emit('voiceStatus', '麦克风已开启,请直接说话');
|
emit('voiceStatus', '麦克风已开启,请直接说话');
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
color: rgba(255, 255, 255, 0.87);
|
||||||
background-color: #242424;
|
background-color: #5a2f1b;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@ -20,6 +20,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
background-color: #5a2f1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
@ -29,6 +30,7 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
background-color: #5a2f1b;
|
||||||
}
|
}
|
||||||
/* a {
|
/* a {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user