// 🚀 真正的流式音频播放器 - 使用Web Audio API解码OGG // 解决OGG容器格式无法分段播放的问题 export interface AudioPlayerConfig { onPlay?: () => void onEnded?: () => void onError?: (error: Error) => void } export class StreamingAudioPlayer { private audioChunks: ArrayBuffer[] = [] private playing = false private audioContext: AudioContext | null = null private gainNode: GainNode | null = null private config: AudioPlayerConfig private audioReady = false private currentSource: AudioBufferSourceNode | null = null private playQueue: AudioBuffer[] = [] // 已解码的音频队列 private isDecoding = false private allChunksReceived = false private lastPlayTime = 0 // 上次播放的结束时间 private startTime = 0 // 开始播放的时间戳 private lastDecodedDuration = 0 // 上次解码的总时长(用于计算增量) private decodedChunksCount = 0 // 已解码的块数 private hasEnded = false // 防止多次触发 onEnded private activeSources = 0 // 正在播放的音频源数量 constructor(config: AudioPlayerConfig = {}) { this.config = config } async init(): Promise { console.log('🎵 初始化流式音频播放器(Web Audio API)') return Promise.resolve() } /** * 在用户交互时调用此方法,激活AudioContext */ prepareForPlayback() { if (this.audioReady && this.audioContext?.state === 'running') { console.log('🎯 AudioContext已激活且运行中,跳过重复激活') return } console.log('🎯 在用户交互中激活AudioContext(解决Safari自动播放限制)') try { // 如果AudioContext不存在,创建新的 if (!this.audioContext) { const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext this.audioContext = new AudioContextClass() // 创建增益节点(音量放大器) this.gainNode = this.audioContext.createGain() this.gainNode.gain.value = 4.5 // 4.5倍音量增益 console.log('🔊 音频增益设置为:', this.gainNode.gain.value, '倍') // 连接到输出 this.gainNode.connect(this.audioContext.destination) } // 激活AudioContext(Safari需要用户交互) if (this.audioContext.state === 'suspended') { this.audioContext.resume().then(() => { console.log('✅ AudioContext已恢复,状态:', this.audioContext?.state) this.audioReady = true }) } else if (this.audioContext.state === 'running') { console.log('✅ AudioContext运行中,状态:', this.audioContext.state) this.audioReady = true } else { console.warn('⚠️ AudioContext状态异常:', this.audioContext.state) } } catch (error) { console.error('❌ AudioContext初始化失败:', error) } } /** * 确保AudioContext处于运行状态并验证音量增益 * 🔑 关键:在每次播放前调用,防止移动端AudioContext被挂起导致无声或音量小 */ private async ensureAudioContextReady(): Promise { if (!this.audioContext || !this.gainNode) { console.error('❌ AudioContext或GainNode未初始化') return false } // 检查AudioContext状态 const currentState = this.audioContext.state console.log(`🔍 AudioContext状态检查: ${currentState}`) if (currentState === 'suspended') { console.warn('⚠️ AudioContext被挂起,尝试恢复...') try { await this.audioContext.resume() console.log('✅ AudioContext已恢复:', this.audioContext.state) } catch (error) { console.error('❌ AudioContext恢复失败:', error) return false } } else if (currentState === 'closed') { console.error('❌ AudioContext已关闭,需要重新初始化') return false } // 验证并恢复音量增益 const currentGain = this.gainNode.gain.value const targetGain = 4.5 if (Math.abs(currentGain - targetGain) > 0.1) { console.warn(`⚠️ 音量增益异常 (当前: ${currentGain}, 目标: ${targetGain}),正在恢复...`) this.gainNode.gain.value = targetGain console.log(`✅ 音量增益已恢复为: ${targetGain}倍`) } else { console.log(`✅ 音量增益正常: ${currentGain}倍`) } return true } appendAudioChunk(chunk: ArrayBuffer) { const totalChunks = this.audioChunks.length + 1 console.log(`📦 收集音频块: ${chunk.byteLength} bytes (总计: ${totalChunks} 块)`) // 收集音频块 this.audioChunks.push(chunk) // 🚀 流式解码策略:收到3个块就开始解码并播放 if (totalChunks === 1) { console.log('🎵 AI开始生成语音,等待音频块...') } else if (totalChunks === 3 && !this.playing) { console.log('⚡ 收到3个音频块,开始流式播放!') this.startStreamingPlayback() } else if (totalChunks > 3) { // 继续解码新块 this.decodeAndQueue() } } /** * 开始流式播放 */ private async startStreamingPlayback() { if (!this.audioContext || !this.gainNode) { console.error('❌ AudioContext未初始化') return } // 🔑 关键:每次播放前都检查并恢复AudioContext状态 const isReady = await this.ensureAudioContextReady() if (!isReady) { console.error('❌ AudioContext未就绪,无法开始播放') this.config.onError?.(new Error('音频环境未就绪')) return } this.playing = true this.startTime = this.audioContext.currentTime this.lastPlayTime = this.startTime // 触发onPlay回调 this.config.onPlay?.() // 开始解码并排队播放 await this.decodeAndQueue() } /** * 解码音频块并加入播放队列 * 🔑 关键:每次都解码所有已收到的块(OGG需要完整上下文),但只播放新增部分 */ private async decodeAndQueue() { if (this.isDecoding || !this.audioContext) { return } // 检查是否有新块需要解码 if (this.audioChunks.length === this.decodedChunksCount) { console.log('⏭️ 没有新的音频块,跳过解码') return } this.isDecoding = true try { // ⭐ 关键:合并所有已收到的块(包括之前的),保证OGG编码上下文完整 const totalLength = this.audioChunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) const combined = new Uint8Array(totalLength) let offset = 0 for (const chunk of this.audioChunks) { combined.set(new Uint8Array(chunk), offset) offset += chunk.byteLength } console.log(`🔄 累积解码: ${totalLength} bytes (${this.audioChunks.length} 块, 新增 ${this.audioChunks.length - this.decodedChunksCount} 块)`) // 使用Web Audio API解码完整的OGG数据 const fullAudioBuffer = await this.audioContext.decodeAudioData(combined.buffer.slice(0)) console.log(`✅ 解码成功: ${fullAudioBuffer.duration.toFixed(2)}秒 (总时长), 新增: ${(fullAudioBuffer.duration - this.lastDecodedDuration).toFixed(2)}秒`) // 只播放新增的部分 this.playIncrementalAudio(fullAudioBuffer, this.lastDecodedDuration) // 更新已解码的信息 this.lastDecodedDuration = fullAudioBuffer.duration this.decodedChunksCount = this.audioChunks.length } catch (error) { console.error('❌ 音频解码失败:', error) // 如果是部分数据解码失败,等待更多数据 if (!this.allChunksReceived) { console.log('⏳ 等待更多音频数据...') } else { this.config.onError?.(new Error('音频解码失败')) } } finally { this.isDecoding = false // 🔑 关键修复:解码完成后,检查是否还有新的音频块需要解码 // 这样可以处理在解码过程中新到达的音频块 if (this.audioChunks.length > this.decodedChunksCount) { console.log(`🔄 检测到 ${this.audioChunks.length - this.decodedChunksCount} 个未解码的块,继续解码...`) // 使用 setTimeout 避免递归调用栈过深 setTimeout(() => this.decodeAndQueue(), 0) } } } /** * 播放增量音频(只播放新增的部分) * @param fullBuffer 完整的音频缓冲(包含之前的+新增的) * @param offsetTime 从哪个时间点开始播放(跳过已播放的部分) */ private playIncrementalAudio(fullBuffer: AudioBuffer, offsetTime: number) { if (!this.audioContext || !this.gainNode) { console.error('❌ AudioContext未初始化') return } // 计算新增音频的时长 const newDuration = fullBuffer.duration - offsetTime if (newDuration <= 0) { console.log('⏭️ 没有新的音频需要播放') return } // 🔑 关键:确保AudioContext处于运行状态(同步检查) if (this.audioContext.state === 'suspended') { console.warn('⚠️ AudioContext被挂起,尝试快速恢复...') this.audioContext.resume().then(() => { console.log('✅ AudioContext已快速恢复') }).catch((error) => { console.error('❌ AudioContext恢复失败:', error) }) } // 验证音量增益(同步) const currentGain = this.gainNode.gain.value if (currentGain < 4.0) { console.warn(`⚠️ 播放前检测到音量过低 (${currentGain}),恢复至4.5`) this.gainNode.gain.value = 4.5 } // 创建音频源节点 const source = this.audioContext.createBufferSource() source.buffer = fullBuffer // 连接到增益节点 source.connect(this.gainNode) // 计算播放时间(紧接着上一段) const playTime = Math.max(this.lastPlayTime, this.audioContext.currentTime) // 增加活跃音频源计数 this.activeSources++ console.log(`🎵 播放增量音频: ${newDuration.toFixed(2)}秒 (跳过前${offsetTime.toFixed(2)}秒), 延迟: ${((playTime - this.audioContext.currentTime) * 1000).toFixed(0)}ms, 活跃源: ${this.activeSources}`) // 设置结束回调 source.onended = () => { // 减少活跃音频源计数 this.activeSources-- console.log(`🎵 音频段播放完成 (剩余活跃源: ${this.activeSources})`) // 检查是否还有未解码的数据 const hasNewChunks = this.audioChunks.length > this.decodedChunksCount if (hasNewChunks) { console.log(`🔄 继续解码新的 ${this.audioChunks.length - this.decodedChunksCount} 个块`) this.decodeAndQueue() } else if (this.allChunksReceived && this.activeSources === 0 && !this.hasEnded) { // ⭐ 关键:所有块已接收 + 所有音频源都播放完成 + 未触发过结束 console.log('✅ 所有音频播放完成 - 触发 onEnded 回调') this.playing = false this.hasEnded = true // 标记已结束,防止重复触发 this.config.onEnded?.() } else if (this.hasEnded) { console.log('⏭️ 已触发过 onEnded,跳过重复调用') } else if (this.activeSources > 0) { console.log(`⏳ 还有 ${this.activeSources} 个音频源在播放...`) } else { console.log('⏳ 等待更多音频块...') } } // ⭐ 关键:使用offset参数跳过已播放的部分,只播放新增部分 // start(when, offset, duration) source.start(playTime, offsetTime, newDuration) this.currentSource = source // 更新下次播放时间 this.lastPlayTime = playTime + newDuration } async play() { console.log(`🎵 play()被调用 (ai_response_end) - 音频块数: ${this.audioChunks.length}, 播放状态: ${this.playing ? '播放中' : '空闲'}`) // 🔑 关键:每次播放前都检查并恢复AudioContext状态 if (this.audioContext && this.gainNode) { const isReady = await this.ensureAudioContextReady() if (!isReady) { console.error('❌ AudioContext未就绪,播放可能失败') } } // 标记所有音频块已接收完毕 this.allChunksReceived = true // 如果还没开始播放,立即开始 if (!this.playing && this.audioChunks.length > 0) { console.log('⚡ 开始播放所有音频') await this.startStreamingPlayback() } else if (this.playing && this.audioChunks.length > 0) { // 已经在播放,解码剩余的块 console.log('🔄 继续解码剩余音频块') await this.decodeAndQueue() } else if (!this.playing && this.audioChunks.length === 0) { console.log('⚠️ 没有音频数据可播放') } } pause() { if (this.currentSource) { this.currentSource.stop() this.currentSource = null } this.playing = false } stop() { console.log('🛑 停止播放') if (this.currentSource) { try { this.currentSource.stop() } catch (e) { // 忽略已经停止的错误 } this.currentSource = null } this.playing = false // 注意:不重置 activeSources,让 onended 自然触发递减 } clear() { const chunksCount = this.audioChunks.length console.log(`🧹 清理播放器状态 - 丢弃 ${chunksCount} 个音频块`) this.stop() this.audioChunks = [] this.playQueue = [] this.allChunksReceived = false this.lastPlayTime = 0 this.startTime = 0 this.lastDecodedDuration = 0 this.decodedChunksCount = 0 this.hasEnded = false // 重置结束标志 this.activeSources = 0 // 重置活跃音频源计数 } destroy() { this.stop() this.audioChunks = [] this.playQueue = [] if (this.audioContext) { this.audioContext.close().catch((err) => { console.warn('AudioContext关闭失败:', err) }) this.audioContext = null } this.gainNode = null this.audioReady = false } isPlaying(): boolean { return this.playing } getCurrentTime(): number { if (!this.audioContext || !this.playing) { return 0 } return this.audioContext.currentTime - this.startTime } getBufferedProgress(): number { return this.audioChunks.length } /** * 设置音量增益倍数 */ setGain(gain: number) { if (this.gainNode) { const clampedGain = Math.max(0.5, Math.min(5.0, gain)) this.gainNode.gain.value = clampedGain console.log('🔊 音量增益已调整为:', clampedGain, '倍') } else { console.warn('⚠️ GainNode未初始化,无法调整增益') } } getGain(): number { return this.gainNode?.gain.value || 1.0 } }