| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346 |
- // 🚀 真正的流式音频播放器 - 使用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<void> {
- console.log('🎵 初始化流式音频播放器(Web Audio API)')
- return Promise.resolve()
- }
- /**
- * 在用户交互时调用此方法,激活AudioContext
- */
- prepareForPlayback() {
- if (this.audioReady) {
- console.log('🎯 AudioContext已激活,跳过重复激活')
- return
- }
-
- console.log('🎯 在用户交互中激活AudioContext(解决Safari自动播放限制)')
-
- try {
- // 创建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 {
- console.log('✅ AudioContext已激活,状态:', this.audioContext.state)
- this.audioReady = true
- }
- } catch (error) {
- console.error('❌ AudioContext初始化失败:', error)
- }
- }
- 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
- }
- 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
- }
- }
- /**
- * 播放增量音频(只播放新增的部分)
- * @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
- }
- // 创建音频源节点
- 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 ? '播放中' : '空闲'}`)
-
- // 标记所有音频块已接收完毕
- 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
- }
- }
|