audioPlayerStreaming.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. // 🚀 真正的流式音频播放器 - 使用Web Audio API解码OGG
  2. // 解决OGG容器格式无法分段播放的问题
  3. export interface AudioPlayerConfig {
  4. onPlay?: () => void
  5. onEnded?: () => void
  6. onError?: (error: Error) => void
  7. }
  8. export class StreamingAudioPlayer {
  9. private audioChunks: ArrayBuffer[] = []
  10. private playing = false
  11. private audioContext: AudioContext | null = null
  12. private gainNode: GainNode | null = null
  13. private config: AudioPlayerConfig
  14. private audioReady = false
  15. private currentSource: AudioBufferSourceNode | null = null
  16. private playQueue: AudioBuffer[] = [] // 已解码的音频队列
  17. private isDecoding = false
  18. private allChunksReceived = false
  19. private lastPlayTime = 0 // 上次播放的结束时间
  20. private startTime = 0 // 开始播放的时间戳
  21. private lastDecodedDuration = 0 // 上次解码的总时长(用于计算增量)
  22. private decodedChunksCount = 0 // 已解码的块数
  23. private hasEnded = false // 防止多次触发 onEnded
  24. private activeSources = 0 // 正在播放的音频源数量
  25. constructor(config: AudioPlayerConfig = {}) {
  26. this.config = config
  27. }
  28. async init(): Promise<void> {
  29. console.log('🎵 初始化流式音频播放器(Web Audio API)')
  30. return Promise.resolve()
  31. }
  32. /**
  33. * 在用户交互时调用此方法,激活AudioContext
  34. */
  35. prepareForPlayback() {
  36. if (this.audioReady) {
  37. console.log('🎯 AudioContext已激活,跳过重复激活')
  38. return
  39. }
  40. console.log('🎯 在用户交互中激活AudioContext(解决Safari自动播放限制)')
  41. try {
  42. // 创建AudioContext
  43. const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext
  44. this.audioContext = new AudioContextClass()
  45. // 创建增益节点(音量放大器)
  46. this.gainNode = this.audioContext.createGain()
  47. this.gainNode.gain.value = 4.5 // 4.5倍音量增益
  48. console.log('🔊 音频增益设置为:', this.gainNode.gain.value, '倍')
  49. // 连接到输出
  50. this.gainNode.connect(this.audioContext.destination)
  51. // 激活AudioContext(Safari需要用户交互)
  52. if (this.audioContext.state === 'suspended') {
  53. this.audioContext.resume().then(() => {
  54. console.log('✅ AudioContext已激活,状态:', this.audioContext?.state)
  55. this.audioReady = true
  56. })
  57. } else {
  58. console.log('✅ AudioContext已激活,状态:', this.audioContext.state)
  59. this.audioReady = true
  60. }
  61. } catch (error) {
  62. console.error('❌ AudioContext初始化失败:', error)
  63. }
  64. }
  65. appendAudioChunk(chunk: ArrayBuffer) {
  66. const totalChunks = this.audioChunks.length + 1
  67. console.log(`📦 收集音频块: ${chunk.byteLength} bytes (总计: ${totalChunks} 块)`)
  68. // 收集音频块
  69. this.audioChunks.push(chunk)
  70. // 🚀 流式解码策略:收到3个块就开始解码并播放
  71. if (totalChunks === 1) {
  72. console.log('🎵 AI开始生成语音,等待音频块...')
  73. } else if (totalChunks === 3 && !this.playing) {
  74. console.log('⚡ 收到3个音频块,开始流式播放!')
  75. this.startStreamingPlayback()
  76. } else if (totalChunks > 3) {
  77. // 继续解码新块
  78. this.decodeAndQueue()
  79. }
  80. }
  81. /**
  82. * 开始流式播放
  83. */
  84. private async startStreamingPlayback() {
  85. if (!this.audioContext || !this.gainNode) {
  86. console.error('❌ AudioContext未初始化')
  87. return
  88. }
  89. this.playing = true
  90. this.startTime = this.audioContext.currentTime
  91. this.lastPlayTime = this.startTime
  92. // 触发onPlay回调
  93. this.config.onPlay?.()
  94. // 开始解码并排队播放
  95. await this.decodeAndQueue()
  96. }
  97. /**
  98. * 解码音频块并加入播放队列
  99. * 🔑 关键:每次都解码所有已收到的块(OGG需要完整上下文),但只播放新增部分
  100. */
  101. private async decodeAndQueue() {
  102. if (this.isDecoding || !this.audioContext) {
  103. return
  104. }
  105. // 检查是否有新块需要解码
  106. if (this.audioChunks.length === this.decodedChunksCount) {
  107. console.log('⏭️ 没有新的音频块,跳过解码')
  108. return
  109. }
  110. this.isDecoding = true
  111. try {
  112. // ⭐ 关键:合并所有已收到的块(包括之前的),保证OGG编码上下文完整
  113. const totalLength = this.audioChunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
  114. const combined = new Uint8Array(totalLength)
  115. let offset = 0
  116. for (const chunk of this.audioChunks) {
  117. combined.set(new Uint8Array(chunk), offset)
  118. offset += chunk.byteLength
  119. }
  120. console.log(`🔄 累积解码: ${totalLength} bytes (${this.audioChunks.length} 块, 新增 ${this.audioChunks.length - this.decodedChunksCount} 块)`)
  121. // 使用Web Audio API解码完整的OGG数据
  122. const fullAudioBuffer = await this.audioContext.decodeAudioData(combined.buffer.slice(0))
  123. console.log(`✅ 解码成功: ${fullAudioBuffer.duration.toFixed(2)}秒 (总时长), 新增: ${(fullAudioBuffer.duration - this.lastDecodedDuration).toFixed(2)}秒`)
  124. // 只播放新增的部分
  125. this.playIncrementalAudio(fullAudioBuffer, this.lastDecodedDuration)
  126. // 更新已解码的信息
  127. this.lastDecodedDuration = fullAudioBuffer.duration
  128. this.decodedChunksCount = this.audioChunks.length
  129. } catch (error) {
  130. console.error('❌ 音频解码失败:', error)
  131. // 如果是部分数据解码失败,等待更多数据
  132. if (!this.allChunksReceived) {
  133. console.log('⏳ 等待更多音频数据...')
  134. } else {
  135. this.config.onError?.(new Error('音频解码失败'))
  136. }
  137. } finally {
  138. this.isDecoding = false
  139. }
  140. }
  141. /**
  142. * 播放增量音频(只播放新增的部分)
  143. * @param fullBuffer 完整的音频缓冲(包含之前的+新增的)
  144. * @param offsetTime 从哪个时间点开始播放(跳过已播放的部分)
  145. */
  146. private playIncrementalAudio(fullBuffer: AudioBuffer, offsetTime: number) {
  147. if (!this.audioContext || !this.gainNode) {
  148. console.error('❌ AudioContext未初始化')
  149. return
  150. }
  151. // 计算新增音频的时长
  152. const newDuration = fullBuffer.duration - offsetTime
  153. if (newDuration <= 0) {
  154. console.log('⏭️ 没有新的音频需要播放')
  155. return
  156. }
  157. // 创建音频源节点
  158. const source = this.audioContext.createBufferSource()
  159. source.buffer = fullBuffer
  160. // 连接到增益节点
  161. source.connect(this.gainNode)
  162. // 计算播放时间(紧接着上一段)
  163. const playTime = Math.max(this.lastPlayTime, this.audioContext.currentTime)
  164. // 增加活跃音频源计数
  165. this.activeSources++
  166. console.log(`🎵 播放增量音频: ${newDuration.toFixed(2)}秒 (跳过前${offsetTime.toFixed(2)}秒), 延迟: ${((playTime - this.audioContext.currentTime) * 1000).toFixed(0)}ms, 活跃源: ${this.activeSources}`)
  167. // 设置结束回调
  168. source.onended = () => {
  169. // 减少活跃音频源计数
  170. this.activeSources--
  171. console.log(`🎵 音频段播放完成 (剩余活跃源: ${this.activeSources})`)
  172. // 检查是否还有未解码的数据
  173. const hasNewChunks = this.audioChunks.length > this.decodedChunksCount
  174. if (hasNewChunks) {
  175. console.log(`🔄 继续解码新的 ${this.audioChunks.length - this.decodedChunksCount} 个块`)
  176. this.decodeAndQueue()
  177. } else if (this.allChunksReceived && this.activeSources === 0 && !this.hasEnded) {
  178. // ⭐ 关键:所有块已接收 + 所有音频源都播放完成 + 未触发过结束
  179. console.log('✅ 所有音频播放完成 - 触发 onEnded 回调')
  180. this.playing = false
  181. this.hasEnded = true // 标记已结束,防止重复触发
  182. this.config.onEnded?.()
  183. } else if (this.hasEnded) {
  184. console.log('⏭️ 已触发过 onEnded,跳过重复调用')
  185. } else if (this.activeSources > 0) {
  186. console.log(`⏳ 还有 ${this.activeSources} 个音频源在播放...`)
  187. } else {
  188. console.log('⏳ 等待更多音频块...')
  189. }
  190. }
  191. // ⭐ 关键:使用offset参数跳过已播放的部分,只播放新增部分
  192. // start(when, offset, duration)
  193. source.start(playTime, offsetTime, newDuration)
  194. this.currentSource = source
  195. // 更新下次播放时间
  196. this.lastPlayTime = playTime + newDuration
  197. }
  198. async play() {
  199. console.log(`🎵 play()被调用 (ai_response_end) - 音频块数: ${this.audioChunks.length}, 播放状态: ${this.playing ? '播放中' : '空闲'}`)
  200. // 标记所有音频块已接收完毕
  201. this.allChunksReceived = true
  202. // 如果还没开始播放,立即开始
  203. if (!this.playing && this.audioChunks.length > 0) {
  204. console.log('⚡ 开始播放所有音频')
  205. await this.startStreamingPlayback()
  206. } else if (this.playing && this.audioChunks.length > 0) {
  207. // 已经在播放,解码剩余的块
  208. console.log('🔄 继续解码剩余音频块')
  209. await this.decodeAndQueue()
  210. } else if (!this.playing && this.audioChunks.length === 0) {
  211. console.log('⚠️ 没有音频数据可播放')
  212. }
  213. }
  214. pause() {
  215. if (this.currentSource) {
  216. this.currentSource.stop()
  217. this.currentSource = null
  218. }
  219. this.playing = false
  220. }
  221. stop() {
  222. console.log('🛑 停止播放')
  223. if (this.currentSource) {
  224. try {
  225. this.currentSource.stop()
  226. } catch (e) {
  227. // 忽略已经停止的错误
  228. }
  229. this.currentSource = null
  230. }
  231. this.playing = false
  232. // 注意:不重置 activeSources,让 onended 自然触发递减
  233. }
  234. clear() {
  235. const chunksCount = this.audioChunks.length
  236. console.log(`🧹 清理播放器状态 - 丢弃 ${chunksCount} 个音频块`)
  237. this.stop()
  238. this.audioChunks = []
  239. this.playQueue = []
  240. this.allChunksReceived = false
  241. this.lastPlayTime = 0
  242. this.startTime = 0
  243. this.lastDecodedDuration = 0
  244. this.decodedChunksCount = 0
  245. this.hasEnded = false // 重置结束标志
  246. this.activeSources = 0 // 重置活跃音频源计数
  247. }
  248. destroy() {
  249. this.stop()
  250. this.audioChunks = []
  251. this.playQueue = []
  252. if (this.audioContext) {
  253. this.audioContext.close().catch((err) => {
  254. console.warn('AudioContext关闭失败:', err)
  255. })
  256. this.audioContext = null
  257. }
  258. this.gainNode = null
  259. this.audioReady = false
  260. }
  261. isPlaying(): boolean {
  262. return this.playing
  263. }
  264. getCurrentTime(): number {
  265. if (!this.audioContext || !this.playing) {
  266. return 0
  267. }
  268. return this.audioContext.currentTime - this.startTime
  269. }
  270. getBufferedProgress(): number {
  271. return this.audioChunks.length
  272. }
  273. /**
  274. * 设置音量增益倍数
  275. */
  276. setGain(gain: number) {
  277. if (this.gainNode) {
  278. const clampedGain = Math.max(0.5, Math.min(5.0, gain))
  279. this.gainNode.gain.value = clampedGain
  280. console.log('🔊 音量增益已调整为:', clampedGain, '倍')
  281. } else {
  282. console.warn('⚠️ GainNode未初始化,无法调整增益')
  283. }
  284. }
  285. getGain(): number {
  286. return this.gainNode?.gain.value || 1.0
  287. }
  288. }