位置:frontend/src/utils/audioPlayer.ts
需要添加的成员变量:
private hasStartedPlaying = false; // 🔥 播放开始标记
private onStartCallback: (() => void) | null = null; // 🔥 播放开始回调
⚠️ 注意点:
hasStartedPlaying 必须在三个地方重置:
addChunk() 中检测到新流时(isStreamEnded 重置时)stop() 方法中release() 方法中(如果调用了)当前代码(第 84-87 行):
if (this.isStreamEnded) {
this.isStreamEnded = false;
}
需要改为:
if (this.isStreamEnded) {
this.isStreamEnded = false;
this.hasStartedPlaying = false; // 🔥 新增:重置播放标记
}
⚠️ 注意点:
hasStartedPlaying,否则新流不会触发 onStart 回调插入位置:在 await this.resumeAudioContext() 之后,while 循环之前
需要添加的代码:
// 🔥 第一个音频块开始播放时触发回调
if (!this.hasStartedPlaying && this.audioQueue.length > 0) {
this.hasStartedPlaying = true;
if (this.onStartCallback) {
this.onStartCallback();
}
}
⚠️ 注意点:
while 循环之前,确保只触发一次audioQueue.length > 0 确保有音频块要播放hasStartedPlaying = true 必须在调用回调之前,避免重复触发当前代码(第 201-215 行):
stop(): void {
this.currentSources.forEach(source => {
try {
source.stop();
} catch (e) {
// 忽略已停止的错误
}
});
this.currentSources = [];
this.audioQueue = [];
this.nextStartTime = 0;
this.isStreamEnded = false;
}
需要添加:
stop(): void {
// ... 现有代码 ...
this.isStreamEnded = false;
this.hasStartedPlaying = false; // 🔥 新增:重置播放标记
}
⚠️ 注意点:
hasStartedPlaying,否则用户中断后重新播放时不会触发回调stop() 后,下次播放都能正确触发开始回调位置:在 onEnd() 方法之后
需要添加的代码:
/**
* 设置播放开始回调
* @param callback 播放开始时的回调函数
*/
onStart(callback: () => void): void {
this.onStartCallback = callback;
}
⚠️ 注意点:
onEnd() 保持一致需要移除的位置:
首句消息处理(第 1998-2031 行):
if (currentState !== 'playing') 块中的 GIF 切换逻辑setState('playing') 和性能日志记录showPositiveGif()、showNegativeGif()、showTalkingGif() 调用emotionGifTimerRef 的设置逻辑普通消息处理(第 2070-2109 行):
if (currentState !== 'playing') 块中的 GIF 切换逻辑setState('playing') 和性能日志记录⚠️ 注意点:
if (currentState !== 'playing') 块setState('playing') 仍然在正确的位置位置:在 player.onEnd() 回调设置之后(第 1642-1657 行之后)
需要添加的代码:
// 🔥 播放开始回调:立即切换 GIF
player.onStart(() => {
debugLog('[AudioPlayer] 音频开始播放,切换 GIF');
// 获取最新的情绪状态
const latestEmotion = useAppStore.getState().currentEmotion;
const emotionCategory = getEmotionCategory(latestEmotion);
// 清理之前的定时器(如果有)
if (emotionGifTimerRef.current) {
clearTimeout(emotionGifTimerRef.current);
emotionGifTimerRef.current = null;
}
// 根据情绪类型切换 GIF
if (emotionCategory === 'positive') {
showPositiveGif();
const currentAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
const duration = getEmotionGifDuration(currentAvatarId, emotionCategory);
emotionGifTimerRef.current = window.setTimeout(() => {
showTalkingGif();
emotionGifTimerRef.current = null;
}, duration);
} else if (emotionCategory === 'negative') {
showNegativeGif();
const currentAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
const duration = getEmotionGifDuration(currentAvatarId, emotionCategory);
emotionGifTimerRef.current = window.setTimeout(() => {
showTalkingGif();
emotionGifTimerRef.current = null;
}, duration);
} else {
showTalkingGif();
}
});
⚠️ 注意点:
useAppStore.getState() 获取最新情绪状态,避免闭包问题emotionGifTimerRef 在组件中已定义当前代码(第 1642-1657 行)已经正确,但需要确认:
player.onEnd(() => {
// ... 性能日志 ...
// 🔥 清理情绪 GIF 切换定时器(无论是否已触发)
if (emotionGifTimerRef.current) {
clearTimeout(emotionGifTimerRef.current);
emotionGifTimerRef.current = null;
}
// 🔥 强制切换回 default GIF(无论当前是什么状态)
showDefaultGif();
setState('idle');
reset();
});
⚠️ 注意点:
showDefaultGif() 调用存在当前代码(第 2224-2235 行)已经正确,但需要确认:
if (wasPlaying) {
playerRef.current?.stop(); // 🔥 这会重置 hasStartedPlaying
// 清理情绪 GIF 切换定时器
if (emotionGifTimerRef.current) {
clearTimeout(emotionGifTimerRef.current);
emotionGifTimerRef.current = null;
}
// 立即恢复默认头像 GIF
showDefaultGif();
// ... 其他清理逻辑 ...
}
⚠️ 注意点:
stop() 调用会重置 hasStartedPlaying,这是正确的问题:用户快速连续发送多条消息,音频流可能重叠
处理:
hasStartedPlaying 标记确保每次新流都会重置stop() 方法会清理所有状态问题:移动端 AudioContext 可能处于 suspended 状态
当前处理:
processQueue() 中有 await this.resumeAudioContext()resumeAudioContext() 可能需要用户交互建议:
如有问题,添加 AudioContext 状态检查:
if (this.audioContext?.state !== 'running') {
// AudioContext 未恢复,延迟触发回调
return;
}
问题:emotion 消息可能在 audio_response 之后到达
当前处理:
onStart 回调中使用 useAppStore.getState().currentEmotion 获取最新状态⚠️ 注意点:
emotion 消息处理中添加逻辑问题:网络问题导致音频流中断
当前处理:
markStreamEnd() 会标记流结束onEnd 回调⚠️ 注意点:
onEnd 回调能正确处理中断情况hasStartedPlaying 成员变量onStartCallback 成员变量addChunk() 中重置 hasStartedPlaying(当 isStreamEnded 重置时)processQueue() 中添加播放开始回调触发逻辑stop() 中重置 hasStartedPlayingonStart() 方法player.onStart() 回调设置player.onEnd() 回调中有定时器清理和 GIF 切换可能原因:
hasStartedPlaying 未正确重置processQueue() 中的条件判断有误排查步骤:
addChunk() 中是否重置了 hasStartedPlayingstop() 中是否重置了 hasStartedPlayingprocessQueue() 是否被调用可能原因:
hasStartedPlaying 未正确设置processQueue() 被多次调用排查步骤:
hasStartedPlaying = true 在回调调用之前设置isProcessingQueue 标记是否正确工作可能原因:
排查步骤:
export class AudioPlayer {
private audioContext: AudioContext | null = null;
private onEndCallback: (() => void) | null = null;
private onStartCallback: (() => void) | null = null; // 🔥 新增
private hasStartedPlaying = false; // 🔥 新增
// ... 其他成员变量 ...
async addChunk(data: Uint8Array, sampleRate: number = 24000): Promise<void> {
// ... 现有代码 ...
if (this.isStreamEnded) {
this.isStreamEnded = false;
this.hasStartedPlaying = false; // 🔥 新增
}
// ... 现有代码 ...
}
private async processQueue(): Promise<void> {
// ... 现有代码 ...
await this.resumeAudioContext();
// 🔥 新增:播放开始回调触发
if (!this.hasStartedPlaying && this.audioQueue.length > 0) {
this.hasStartedPlaying = true;
if (this.onStartCallback) {
this.onStartCallback();
}
}
// ... 现有代码 ...
}
stop(): void {
// ... 现有代码 ...
this.isStreamEnded = false;
this.hasStartedPlaying = false; // 🔥 新增
}
onStart(callback: () => void): void { // 🔥 新增
this.onStartCallback = callback;
}
// ... 其他方法 ...
}
位置:在 ChatBotPage 组件顶部,与其他 ref 一起定义
const emotionGifTimerRef = useRef<number | null>(null);
const pendingEmotionRef = useRef<string | null>(null); // 🔥 新增:缓存待处理的情绪
⚠️ 注意点:
useRef 而不是 useState,避免不必要的重渲染null,表示没有待处理的情绪需要移除的逻辑:
showPositiveGif()、showNegativeGif()、showTalkingGif() 调用emotionGifTimerRef 的设置逻辑if (appState.currentState === 'playing' || ...) 的条件判断需要保留的逻辑:
performanceLogger.recordEmotion() 调用setEmotion() 调用需要添加的逻辑:
// 🔥 缓存情绪,等待音频播放开始
pendingEmotionRef.current = message.emotion;
⚠️ 注意点:
位置:player.onStart() 回调中
关键代码:
// 🔥 获取缓存的情绪状态(优先使用缓存的,否则使用 store 中的)
const cachedEmotion = pendingEmotionRef.current;
const latestEmotion = cachedEmotion || useAppStore.getState().currentEmotion;
// 🔥 清除缓存(已使用)
pendingEmotionRef.current = null;
⚠️ 注意点:
pendingEmotionRef.current)需要清理的位置:
音频播放开始后(已使用):
player.onStart(() => {
// ...
pendingEmotionRef.current = null; // 🔥 清除缓存
});
typescript
player.onEnd(() => {
// ...
pendingEmotionRef.current = null; // 🔥 清除缓存
});
用户中断播放时:
const handleStartRecord = async () => {
// ...
pendingEmotionRef.current = null; // 🔥 清除缓存
// ...
};
⚠️ 注意点:
场景 1:emotion 消息先到达
pendingEmotionRef.current场景 2:audio_response 消息先到达
pendingEmotionRef.current 为 nullemotion 消息延迟到达,会被缓存但不会被使用场景 3:emotion 消息延迟到达(音频已开始播放)
⚠️ 注意点:
实施这个方案的关键注意点:
hasStartedPlaying 标记(三个地方)按照这个清单逐步实施,可以确保方案正确实现。