音频GIF同步方案实施注意事项.md 16 KB

音频 GIF 同步方案实施注意事项

实施步骤概览

  1. 修改 AudioPlayer:添加播放开始回调机制
  2. 修改 ChatBotPage:移除现有 GIF 切换逻辑,改为基于播放事件
  3. 测试验证:确保各种场景正常工作

⚠️ 关键注意点

1. AudioPlayer 修改注意事项

1.1 添加播放开始标记和回调

位置:frontend/src/utils/audioPlayer.ts

需要添加的成员变量:

private hasStartedPlaying = false; // 🔥 播放开始标记
private onStartCallback: (() => void) | null = null; // 🔥 播放开始回调

⚠️ 注意点:

  • hasStartedPlaying 必须在三个地方重置:
    1. addChunk() 中检测到新流时(isStreamEnded 重置时)
    2. stop() 方法中
    3. release() 方法中(如果调用了)

1.2 修改 addChunk() 方法

当前代码(第 84-87 行):

if (this.isStreamEnded) {
  this.isStreamEnded = false;
}

需要改为:

if (this.isStreamEnded) {
  this.isStreamEnded = false;
  this.hasStartedPlaying = false; // 🔥 新增:重置播放标记
}

⚠️ 注意点:

  • 必须在这里重置 hasStartedPlaying,否则新流不会触发 onStart 回调
  • 这个重置逻辑很关键,确保每次新音频流都能正确触发开始回调

1.3 修改 processQueue() 方法

插入位置:在 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 必须在调用回调之前,避免重复触发

1.4 修改 stop() 方法

当前代码(第 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() 后,下次播放都能正确触发开始回调

1.5 添加 onStart() 方法

位置:在 onEnd() 方法之后

需要添加的代码:

/**
 * 设置播放开始回调
 * @param callback 播放开始时的回调函数
 */
onStart(callback: () => void): void {
  this.onStartCallback = callback;
}

⚠️ 注意点:

  • 方法签名与 onEnd() 保持一致
  • 回调函数应该是同步的,避免阻塞播放队列处理

2. ChatBotPage 修改注意事项

2.1 移除现有的 GIF 切换逻辑

需要移除的位置:

  1. 首句消息处理(第 1998-2031 行):

    • 移除 if (currentState !== 'playing') 块中的 GIF 切换逻辑
    • 保留:setState('playing') 和性能日志记录
    • 移除:所有 showPositiveGif()、showNegativeGif()、showTalkingGif() 调用
    • 移除:emotionGifTimerRef 的设置逻辑
  2. 普通消息处理(第 2070-2109 行):

    • 同样移除 if (currentState !== 'playing') 块中的 GIF 切换逻辑
    • 保留:setState('playing') 和性能日志记录

⚠️ 注意点:

  • 不要完全删除 if (currentState !== 'playing') 块
  • 只移除 GIF 切换相关代码,保留状态设置和性能日志
  • 确保 setState('playing') 仍然在正确的位置

2.2 添加播放开始回调

位置:在 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 在组件中已定义

2.3 确保播放结束回调正确

当前代码(第 1642-1657 行)已经正确,但需要确认:

player.onEnd(() => {
  // ... 性能日志 ...
  
  // 🔥 清理情绪 GIF 切换定时器(无论是否已触发)
  if (emotionGifTimerRef.current) {
    clearTimeout(emotionGifTimerRef.current);
    emotionGifTimerRef.current = null;
  }
  
  // 🔥 强制切换回 default GIF(无论当前是什么状态)
  showDefaultGif();
  
  setState('idle');
  reset();
});

⚠️ 注意点:

  • 确保定时器清理逻辑存在
  • 确保 showDefaultGif() 调用存在
  • 这个回调会在音频播放完毕后自动触发

2.4 用户中断播放处理

当前代码(第 2224-2235 行)已经正确,但需要确认:

if (wasPlaying) {
  playerRef.current?.stop(); // 🔥 这会重置 hasStartedPlaying
  
  // 清理情绪 GIF 切换定时器
  if (emotionGifTimerRef.current) {
    clearTimeout(emotionGifTimerRef.current);
    emotionGifTimerRef.current = null;
  }
  
  // 立即恢复默认头像 GIF
  showDefaultGif();
  
  // ... 其他清理逻辑 ...
}

⚠️ 注意点:

  • stop() 调用会重置 hasStartedPlaying,这是正确的
  • 确保定时器清理和 GIF 切换逻辑存在

🔍 需要特别注意的场景

场景 1:快速连续播放

问题:用户快速连续发送多条消息,音频流可能重叠

处理:

  • hasStartedPlaying 标记确保每次新流都会重置
  • stop() 方法会清理所有状态
  • 确保每次新流都能正确触发开始回调

场景 2:移动端 AudioContext 恢复延迟

问题:移动端 AudioContext 可能处于 suspended 状态

当前处理:

  • processQueue() 中有 await this.resumeAudioContext()
  • 但 resumeAudioContext() 可能需要用户交互

建议:

  • 先实施基础版本
  • 测试移动端表现
  • 如有问题,添加 AudioContext 状态检查:

    if (this.audioContext?.state !== 'running') {
    // AudioContext 未恢复,延迟触发回调
    return;
    }
    

    场景 3:情绪消息延迟到达

    问题:emotion 消息可能在 audio_response 之后到达

    当前处理:

    • 在 onStart 回调中使用 useAppStore.getState().currentEmotion 获取最新状态
    • 如果情绪消息还未到达,会使用默认值('neutral')

    ⚠️ 注意点:

    • 这个场景下,GIF 可能先显示 talking,然后情绪消息到达后不会更新
    • 如果需要处理这种情况,可以在 emotion 消息处理中添加逻辑

    场景 4:音频流中断

    问题:网络问题导致音频流中断

    当前处理:

    • markStreamEnd() 会标记流结束
    • 如果队列为空,会立即触发 onEnd 回调
    • 如果队列不为空,会等待所有音频播放完毕

    ⚠️ 注意点:

    • 确保 onEnd 回调能正确处理中断情况
    • 确保 GIF 能正确切换回 default

    ✅ 实施检查清单

    AudioPlayer 修改

    • 添加 hasStartedPlaying 成员变量
    • 添加 onStartCallback 成员变量
    • 在 addChunk() 中重置 hasStartedPlaying(当 isStreamEnded 重置时)
    • 在 processQueue() 中添加播放开始回调触发逻辑
    • 在 stop() 中重置 hasStartedPlaying
    • 添加 onStart() 方法

    ChatBotPage 修改

    • 移除首句消息处理中的 GIF 切换逻辑(保留状态设置)
    • 移除普通消息处理中的 GIF 切换逻辑(保留状态设置)
    • 添加 player.onStart() 回调设置
    • 确认 player.onEnd() 回调中有定时器清理和 GIF 切换
    • 确认用户中断播放时有定时器清理和 GIF 切换

    测试验证

    • 测试正常播放流程(有情绪)
    • 测试正常播放流程(无情绪)
    • 测试用户中断播放
    • 测试快速连续播放
    • 测试移动端表现
    • 测试网络中断情况

    🐛 常见问题排查

    问题 1:播放开始回调不触发

    可能原因:

    1. hasStartedPlaying 未正确重置
    2. processQueue() 中的条件判断有误
    3. AudioContext 未恢复

    排查步骤:

    1. 检查 addChunk() 中是否重置了 hasStartedPlaying
    2. 检查 stop() 中是否重置了 hasStartedPlaying
    3. 添加日志确认 processQueue() 是否被调用
    4. 检查 AudioContext 状态

    问题 2:播放开始回调触发多次

    可能原因:

    1. hasStartedPlaying 未正确设置
    2. processQueue() 被多次调用

    排查步骤:

    1. 确认 hasStartedPlaying = true 在回调调用之前设置
    2. 检查 isProcessingQueue 标记是否正确工作

    问题 3:GIF 切换时机不对

    可能原因:

    1. 播放开始回调触发时机不对
    2. 情绪状态获取不正确

    排查步骤:

    1. 添加日志确认回调触发时机
    2. 检查情绪状态获取逻辑
    3. 确认 GIF 切换逻辑正确

    📝 代码修改示例

    AudioPlayer 完整修改示例

    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;
    }
      
    // ... 其他方法 ...
    }
    

情绪处理注意事项(新增)

1. 添加情绪缓存 ref

位置:在 ChatBotPage 组件顶部,与其他 ref 一起定义

const emotionGifTimerRef = useRef<number | null>(null);
const pendingEmotionRef = useRef<string | null>(null); // 🔥 新增:缓存待处理的情绪

⚠️ 注意点:

  • 使用 useRef 而不是 useState,避免不必要的重渲染
  • 初始值为 null,表示没有待处理的情绪

2. 修改 emotion 消息处理

需要移除的逻辑:

  • ❌ 所有 showPositiveGif()、showNegativeGif()、showTalkingGif() 调用
  • ❌ 所有 emotionGifTimerRef 的设置逻辑
  • ❌ 所有 if (appState.currentState === 'playing' || ...) 的条件判断

需要保留的逻辑:

  • ✅ performanceLogger.recordEmotion() 调用
  • ✅ setEmotion() 调用
  • ✅ 请求 ID 匹配检查

需要添加的逻辑:

// 🔥 缓存情绪,等待音频播放开始
pendingEmotionRef.current = message.emotion;

⚠️ 注意点:

  • 首句消息和普通消息都需要缓存情绪
  • 不要立即切换 GIF,只保存状态

3. 在播放开始回调中使用缓存情绪

位置:player.onStart() 回调中

关键代码:

// 🔥 获取缓存的情绪状态(优先使用缓存的,否则使用 store 中的)
const cachedEmotion = pendingEmotionRef.current;
const latestEmotion = cachedEmotion || useAppStore.getState().currentEmotion;

// 🔥 清除缓存(已使用)
pendingEmotionRef.current = null;

⚠️ 注意点:

  • 优先使用缓存的情绪(pendingEmotionRef.current)
  • 如果缓存为空,使用 store 中的情绪(兜底)
  • 使用后立即清除缓存,避免影响下次播放

4. 清理情绪缓存的位置

需要清理的位置:

  1. 音频播放开始后(已使用):

    player.onStart(() => {
     // ...
     pendingEmotionRef.current = null; // 🔥 清除缓存
    });
    
    1. 音频播放结束时: typescript player.onEnd(() => { // ... pendingEmotionRef.current = null; // 🔥 清除缓存 });
  2. 用户中断播放时:

    const handleStartRecord = async () => {
     // ...
     pendingEmotionRef.current = null; // 🔥 清除缓存
     // ...
    };
    

    ⚠️ 注意点:

    • 确保在所有清理位置都清除缓存
    • 避免缓存残留影响下次播放

    5. 消息顺序处理

    场景 1:emotion 消息先到达

    • ✅ 情绪被缓存到 pendingEmotionRef.current
    • ✅ 音频播放开始时使用缓存的情绪
    • ✅ 这是正常流程

    场景 2:audio_response 消息先到达

    • ⚠️ 音频播放开始时,pendingEmotionRef.current 为 null
    • ✅ 使用 store 中的情绪(可能为默认值 'neutral')
    • ⚠️ 如果 emotion 消息延迟到达,会被缓存但不会被使用

    场景 3:emotion 消息延迟到达(音频已开始播放)

    • ⚠️ 情绪会被缓存,但不会影响当前播放
    • ✅ 下次播放时会使用(如果缓存未被清理)
    • ⚠️ 或者会被清理,取决于清理时机

    ⚠️ 注意点:

    • 消息顺序不确定是正常情况
    • 优先使用缓存的情绪,如果没有则使用 store 中的
    • 确保缓存不会影响已开始的播放

    总结

    实施这个方案的关键注意点:

    1. AudioPlayer:正确重置 hasStartedPlaying 标记(三个地方)
    2. ChatBotPage:移除现有 GIF 切换逻辑,改为基于播放事件
    3. 情绪处理:添加情绪缓存机制,等待音频播放开始
    4. 测试:覆盖各种场景,特别是移动端和网络中断情况

    情绪处理核心原则

    • ✅ 情绪消息不立即切换 GIF:只保存状态和缓存
    • ✅ 音频播放开始 = 情绪动画开始:在播放开始回调中统一处理
    • ✅ 情绪动画只播放一次:使用固定时长定时器
    • ✅ 清理缓存:在所有清理位置清除情绪缓存

    按照这个清单逐步实施,可以确保方案正确实现。