|
|
@@ -1,98 +1,134 @@
|
|
|
-// 流式音频播放器 - 优化版
|
|
|
-// 不等待所有音频收集完,收到一定量数据就开始播放
|
|
|
+// 🚀 真正的流式音频播放器 - 使用Web Audio API解码OGG
|
|
|
+// 解决OGG容器格式无法分段播放的问题
|
|
|
|
|
|
export interface AudioPlayerConfig {
|
|
|
onPlay?: () => void
|
|
|
onEnded?: () => void
|
|
|
onError?: (error: Error) => void
|
|
|
- minChunksToStart?: number // 开始播放需要的最小音频块数
|
|
|
- preloadMs?: number // 预加载的毫秒数
|
|
|
}
|
|
|
|
|
|
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 playStarted = false
|
|
|
- private playedDuration = 0
|
|
|
- private startTime = 0
|
|
|
- private gainNode: GainNode | null = null
|
|
|
- private playedChunksCount = 0 // 已播放的块数
|
|
|
- private isDecoding = false // 是否正在解码
|
|
|
- private scheduledSources: AudioBufferSourceNode[] = [] // 已调度的音频源
|
|
|
-
|
|
|
+ private playQueue: AudioBuffer[] = [] // 已解码的音频队列
|
|
|
+ private isDecoding = false
|
|
|
+ private allChunksReceived = false
|
|
|
+ private lastPlayTime = 0 // 上次播放的结束时间
|
|
|
+ private startTime = 0 // 开始播放的时间戳
|
|
|
+ private lastDecodedDuration = 0 // 上次解码的总时长(用于计算增量)
|
|
|
+ private decodedChunksCount = 0 // 已解码的块数
|
|
|
+
|
|
|
constructor(config: AudioPlayerConfig = {}) {
|
|
|
- this.config = {
|
|
|
- minChunksToStart: 5, // 收到5个音频块再播放(确保音频完整性)
|
|
|
- preloadMs: 500, // 预加载500ms
|
|
|
- ...config
|
|
|
- }
|
|
|
+ this.config = config
|
|
|
}
|
|
|
|
|
|
async init(): Promise<void> {
|
|
|
- console.log('🎵 初始化流式音频播放器')
|
|
|
+ console.log('🎵 初始化流式音频播放器(Web Audio API)')
|
|
|
+ return Promise.resolve()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 在用户交互时调用此方法,激活AudioContext
|
|
|
+ */
|
|
|
+ prepareForPlayback() {
|
|
|
+ if (this.audioReady) {
|
|
|
+ console.log('🎯 AudioContext已激活,跳过重复激活')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('🎯 在用户交互中激活AudioContext(解决Safari自动播放限制)')
|
|
|
+
|
|
|
try {
|
|
|
- this.audioContext = new AudioContext({
|
|
|
- latencyHint: 'interactive', // 低延迟模式
|
|
|
- sampleRate: 24000 // 匹配豆包TTS输出
|
|
|
- })
|
|
|
+ // 创建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)
|
|
|
- this.gainNode.gain.value = 1.5 // 提升音量50%
|
|
|
|
|
|
- this.audioReady = true
|
|
|
- console.log('✅ 流式音频播放器初始化成功')
|
|
|
+ // 激活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('❌ 初始化音频上下文失败:', error)
|
|
|
- throw error
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- prepareForPlayback() {
|
|
|
- if (this.audioContext?.state === 'suspended') {
|
|
|
- this.audioContext.resume()
|
|
|
- console.log('🎯 音频上下文已恢复')
|
|
|
+ console.error('❌ AudioContext初始化失败:', error)
|
|
|
}
|
|
|
- this.audioReady = true
|
|
|
- console.log('✅ 流式播放器已准备就绪')
|
|
|
}
|
|
|
|
|
|
- async appendAudioChunk(chunk: ArrayBuffer) {
|
|
|
+ appendAudioChunk(chunk: ArrayBuffer) {
|
|
|
const totalChunks = this.audioChunks.length + 1
|
|
|
- console.log(`📦 收到音频块: ${chunk.byteLength} bytes, 总计: ${totalChunks} 块`)
|
|
|
+ console.log(`📦 收集音频块: ${chunk.byteLength} bytes (总计: ${totalChunks} 块)`)
|
|
|
+
|
|
|
+ // 收集音频块
|
|
|
this.audioChunks.push(chunk)
|
|
|
|
|
|
- // 策略改进:收集音频但不自动播放
|
|
|
- // Web Audio API的AudioBufferSourceNode只能播放一次
|
|
|
- // 需要等收到所有音频块后一次性播放
|
|
|
+ // 🚀 流式解码策略:收到3个块就开始解码并播放
|
|
|
if (totalChunks === 1) {
|
|
|
- console.log('🎵 开始收集音频...')
|
|
|
- } else if (totalChunks === 5) {
|
|
|
- console.log('📊 已收集5个音频块,继续等待完整音频...')
|
|
|
+ console.log('🎵 AI开始生成语音,等待音频块...')
|
|
|
+ } else if (totalChunks === 3 && !this.playing) {
|
|
|
+ console.log('⚡ 收到3个音频块,开始流式播放!')
|
|
|
+ this.startStreamingPlayback()
|
|
|
+ } else if (totalChunks > 3) {
|
|
|
+ // 继续解码新块
|
|
|
+ this.decodeAndQueue()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- async play() {
|
|
|
- if (this.playing || this.audioChunks.length === 0) {
|
|
|
+ /**
|
|
|
+ * 开始流式播放
|
|
|
+ */
|
|
|
+ private async startStreamingPlayback() {
|
|
|
+ if (!this.audioContext || !this.gainNode) {
|
|
|
+ console.error('❌ AudioContext未初始化')
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- if (!this.audioContext || !this.gainNode) {
|
|
|
- console.error('❌ 音频上下文未初始化')
|
|
|
+ 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
|
|
|
}
|
|
|
|
|
|
- this.playing = true
|
|
|
- this.playStarted = true
|
|
|
- console.log(`🎵 开始流式播放,当前有 ${this.audioChunks.length} 个音频块`)
|
|
|
+ // 检查是否有新块需要解码
|
|
|
+ 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
|
|
|
@@ -101,78 +137,164 @@ export class StreamingAudioPlayer {
|
|
|
combined.set(new Uint8Array(chunk), offset)
|
|
|
offset += chunk.byteLength
|
|
|
}
|
|
|
-
|
|
|
- // 检测格式并解码
|
|
|
- const blob = new Blob([combined.buffer], { type: 'audio/ogg' })
|
|
|
- const arrayBuffer = await blob.arrayBuffer()
|
|
|
|
|
|
- // 解码音频
|
|
|
- const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)
|
|
|
+ console.log(`🔄 累积解码: ${totalLength} bytes (${this.audioChunks.length} 块, 新增 ${this.audioChunks.length - this.decodedChunksCount} 块)`)
|
|
|
|
|
|
- // 创建音频源并播放
|
|
|
- this.currentSource = this.audioContext.createBufferSource()
|
|
|
- this.currentSource.buffer = audioBuffer
|
|
|
- this.currentSource.connect(this.gainNode)
|
|
|
+ // 使用Web Audio API解码完整的OGG数据
|
|
|
+ const fullAudioBuffer = await this.audioContext.decodeAudioData(combined.buffer.slice(0))
|
|
|
|
|
|
- this.currentSource.onended = () => {
|
|
|
- console.log('✅ 音频播放完成')
|
|
|
- this.playing = false
|
|
|
- this.playStarted = false
|
|
|
- this.audioChunks = []
|
|
|
- this.config.onEnded?.()
|
|
|
- }
|
|
|
+ console.log(`✅ 解码成功: ${fullAudioBuffer.duration.toFixed(2)}秒 (总时长), 新增: ${(fullAudioBuffer.duration - this.lastDecodedDuration).toFixed(2)}秒`)
|
|
|
|
|
|
- this.startTime = this.audioContext.currentTime
|
|
|
- this.currentSource.start(0)
|
|
|
+ // 只播放新增的部分
|
|
|
+ this.playIncrementalAudio(fullAudioBuffer, this.lastDecodedDuration)
|
|
|
|
|
|
- console.log(`🔊 流式播放开始,音频时长: ${audioBuffer.duration.toFixed(2)}s`)
|
|
|
- this.config.onPlay?.()
|
|
|
+ // 更新已解码的信息
|
|
|
+ this.lastDecodedDuration = fullAudioBuffer.duration
|
|
|
+ this.decodedChunksCount = this.audioChunks.length
|
|
|
|
|
|
} catch (error) {
|
|
|
- console.error('❌ 播放失败:', error)
|
|
|
- this.playing = false
|
|
|
- this.playStarted = false
|
|
|
- this.config.onError?.(error as 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)
|
|
|
+
|
|
|
+ console.log(`🎵 播放增量音频: ${newDuration.toFixed(2)}秒 (跳过前${offsetTime.toFixed(2)}秒), 延迟: ${((playTime - this.audioContext.currentTime) * 1000).toFixed(0)}ms`)
|
|
|
+
|
|
|
+ // 设置结束回调
|
|
|
+ source.onended = () => {
|
|
|
+ console.log('🎵 音频段播放完成')
|
|
|
+
|
|
|
+ // 检查是否还有未解码的数据
|
|
|
+ const hasNewChunks = this.audioChunks.length > this.decodedChunksCount
|
|
|
+
|
|
|
+ if (hasNewChunks) {
|
|
|
+ console.log(`🔄 继续解码新的 ${this.audioChunks.length - this.decodedChunksCount} 个块`)
|
|
|
+ this.decodeAndQueue()
|
|
|
+ } else if (this.allChunksReceived) {
|
|
|
+ console.log('✅ 所有音频播放完成')
|
|
|
+ this.playing = false
|
|
|
+ this.config.onEnded?.()
|
|
|
+ } 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.playing = false
|
|
|
- console.log('⏸️ 暂停播放')
|
|
|
+ this.currentSource = null
|
|
|
}
|
|
|
+ this.playing = false
|
|
|
}
|
|
|
|
|
|
stop() {
|
|
|
- console.log('🛑 停止流式播放')
|
|
|
+ console.log('🛑 停止播放')
|
|
|
+
|
|
|
if (this.currentSource) {
|
|
|
try {
|
|
|
this.currentSource.stop()
|
|
|
- this.currentSource.disconnect()
|
|
|
} catch (e) {
|
|
|
// 忽略已经停止的错误
|
|
|
}
|
|
|
this.currentSource = null
|
|
|
}
|
|
|
- this.audioChunks = []
|
|
|
+
|
|
|
this.playing = false
|
|
|
- this.playStarted = false
|
|
|
}
|
|
|
|
|
|
clear() {
|
|
|
- console.log('🧹 清理流式播放器状态')
|
|
|
+ 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
|
|
|
}
|
|
|
|
|
|
destroy() {
|
|
|
this.stop()
|
|
|
+ this.audioChunks = []
|
|
|
+ this.playQueue = []
|
|
|
+
|
|
|
if (this.audioContext) {
|
|
|
- this.audioContext.close()
|
|
|
+ this.audioContext.close().catch((err) => {
|
|
|
+ console.warn('AudioContext关闭失败:', err)
|
|
|
+ })
|
|
|
this.audioContext = null
|
|
|
}
|
|
|
+
|
|
|
+ this.gainNode = null
|
|
|
this.audioReady = false
|
|
|
- console.log('🗑️ 流式播放器已销毁')
|
|
|
}
|
|
|
|
|
|
isPlaying(): boolean {
|
|
|
@@ -180,7 +302,7 @@ export class StreamingAudioPlayer {
|
|
|
}
|
|
|
|
|
|
getCurrentTime(): number {
|
|
|
- if (!this.audioContext || !this.currentSource) {
|
|
|
+ if (!this.audioContext || !this.playing) {
|
|
|
return 0
|
|
|
}
|
|
|
return this.audioContext.currentTime - this.startTime
|
|
|
@@ -190,11 +312,20 @@ export class StreamingAudioPlayer {
|
|
|
return this.audioChunks.length
|
|
|
}
|
|
|
|
|
|
- setVolume(volume: number) {
|
|
|
+ /**
|
|
|
+ * 设置音量增益倍数
|
|
|
+ */
|
|
|
+ setGain(gain: number) {
|
|
|
if (this.gainNode) {
|
|
|
- this.gainNode.gain.value = Math.max(0, Math.min(3.0, volume))
|
|
|
- console.log(`🔊 音量设置为: ${volume}`)
|
|
|
+ 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
|
|
|
+ }
|
|
|
+}
|