audioPlayerStreaming.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  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 && this.audioContext?.state === 'running') {
  37. console.log('🎯 AudioContext已激活且运行中,跳过重复激活')
  38. return
  39. }
  40. console.log('🎯 在用户交互中激活AudioContext(解决Safari自动播放限制)')
  41. try {
  42. // 如果AudioContext不存在,创建新的
  43. if (!this.audioContext) {
  44. const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext
  45. this.audioContext = new AudioContextClass()
  46. // 创建增益节点(音量放大器)
  47. this.gainNode = this.audioContext.createGain()
  48. this.gainNode.gain.value = 4.5 // 4.5倍音量增益
  49. console.log('🔊 音频增益设置为:', this.gainNode.gain.value, '倍')
  50. // 连接到输出
  51. this.gainNode.connect(this.audioContext.destination)
  52. }
  53. // 激活AudioContext(Safari需要用户交互)
  54. if (this.audioContext.state === 'suspended') {
  55. this.audioContext.resume().then(() => {
  56. console.log('✅ AudioContext已恢复,状态:', this.audioContext?.state)
  57. this.audioReady = true
  58. })
  59. } else if (this.audioContext.state === 'running') {
  60. console.log('✅ AudioContext运行中,状态:', this.audioContext.state)
  61. this.audioReady = true
  62. } else {
  63. console.warn('⚠️ AudioContext状态异常:', this.audioContext.state)
  64. }
  65. } catch (error) {
  66. console.error('❌ AudioContext初始化失败:', error)
  67. }
  68. }
  69. /**
  70. * 确保AudioContext处于运行状态并验证音量增益
  71. * 🔑 关键:在每次播放前调用,防止移动端AudioContext被挂起导致无声或音量小
  72. */
  73. private async ensureAudioContextReady(): Promise<boolean> {
  74. if (!this.audioContext || !this.gainNode) {
  75. console.error('❌ AudioContext或GainNode未初始化')
  76. return false
  77. }
  78. // 检查AudioContext状态
  79. const currentState = this.audioContext.state
  80. console.log(`🔍 AudioContext状态检查: ${currentState}`)
  81. if (currentState === 'suspended') {
  82. console.warn('⚠️ AudioContext被挂起,尝试恢复...')
  83. try {
  84. await this.audioContext.resume()
  85. console.log('✅ AudioContext已恢复:', this.audioContext.state)
  86. } catch (error) {
  87. console.error('❌ AudioContext恢复失败:', error)
  88. return false
  89. }
  90. } else if (currentState === 'closed') {
  91. console.error('❌ AudioContext已关闭,需要重新初始化')
  92. return false
  93. }
  94. // 验证并恢复音量增益
  95. const currentGain = this.gainNode.gain.value
  96. const targetGain = 4.5
  97. if (Math.abs(currentGain - targetGain) > 0.1) {
  98. console.warn(`⚠️ 音量增益异常 (当前: ${currentGain}, 目标: ${targetGain}),正在恢复...`)
  99. this.gainNode.gain.value = targetGain
  100. console.log(`✅ 音量增益已恢复为: ${targetGain}倍`)
  101. } else {
  102. console.log(`✅ 音量增益正常: ${currentGain}倍`)
  103. }
  104. return true
  105. }
  106. appendAudioChunk(chunk: ArrayBuffer) {
  107. const totalChunks = this.audioChunks.length + 1
  108. console.log(`📦 收集音频块: ${chunk.byteLength} bytes (总计: ${totalChunks} 块)`)
  109. // 收集音频块
  110. this.audioChunks.push(chunk)
  111. // 🚀 流式解码策略:收到3个块就开始解码并播放
  112. if (totalChunks === 1) {
  113. console.log('🎵 AI开始生成语音,等待音频块...')
  114. } else if (totalChunks === 3 && !this.playing) {
  115. console.log('⚡ 收到3个音频块,开始流式播放!')
  116. this.startStreamingPlayback()
  117. } else if (totalChunks > 3) {
  118. // 继续解码新块
  119. this.decodeAndQueue()
  120. }
  121. }
  122. /**
  123. * 开始流式播放
  124. */
  125. private async startStreamingPlayback() {
  126. if (!this.audioContext || !this.gainNode) {
  127. console.error('❌ AudioContext未初始化')
  128. return
  129. }
  130. // 🔑 关键:每次播放前都检查并恢复AudioContext状态
  131. const isReady = await this.ensureAudioContextReady()
  132. if (!isReady) {
  133. console.error('❌ AudioContext未就绪,无法开始播放')
  134. this.config.onError?.(new Error('音频环境未就绪'))
  135. return
  136. }
  137. this.playing = true
  138. this.startTime = this.audioContext.currentTime
  139. this.lastPlayTime = this.startTime
  140. // 触发onPlay回调
  141. this.config.onPlay?.()
  142. // 开始解码并排队播放
  143. await this.decodeAndQueue()
  144. }
  145. /**
  146. * 解码音频块并加入播放队列
  147. * 🔑 关键:每次都解码所有已收到的块(OGG需要完整上下文),但只播放新增部分
  148. */
  149. private async decodeAndQueue() {
  150. if (this.isDecoding || !this.audioContext) {
  151. return
  152. }
  153. // 检查是否有新块需要解码
  154. if (this.audioChunks.length === this.decodedChunksCount) {
  155. console.log('⏭️ 没有新的音频块,跳过解码')
  156. return
  157. }
  158. this.isDecoding = true
  159. try {
  160. // ⭐ 关键:合并所有已收到的块(包括之前的),保证OGG编码上下文完整
  161. const totalLength = this.audioChunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
  162. const combined = new Uint8Array(totalLength)
  163. let offset = 0
  164. for (const chunk of this.audioChunks) {
  165. combined.set(new Uint8Array(chunk), offset)
  166. offset += chunk.byteLength
  167. }
  168. console.log(`🔄 累积解码: ${totalLength} bytes (${this.audioChunks.length} 块, 新增 ${this.audioChunks.length - this.decodedChunksCount} 块)`)
  169. // 使用Web Audio API解码完整的OGG数据
  170. const fullAudioBuffer = await this.audioContext.decodeAudioData(combined.buffer.slice(0))
  171. console.log(`✅ 解码成功: ${fullAudioBuffer.duration.toFixed(2)}秒 (总时长), 新增: ${(fullAudioBuffer.duration - this.lastDecodedDuration).toFixed(2)}秒`)
  172. // 只播放新增的部分
  173. this.playIncrementalAudio(fullAudioBuffer, this.lastDecodedDuration)
  174. // 更新已解码的信息
  175. this.lastDecodedDuration = fullAudioBuffer.duration
  176. this.decodedChunksCount = this.audioChunks.length
  177. } catch (error) {
  178. console.error('❌ 音频解码失败:', error)
  179. // 如果是部分数据解码失败,等待更多数据
  180. if (!this.allChunksReceived) {
  181. console.log('⏳ 等待更多音频数据...')
  182. } else {
  183. this.config.onError?.(new Error('音频解码失败'))
  184. }
  185. } finally {
  186. this.isDecoding = false
  187. // 🔑 关键修复:解码完成后,检查是否还有新的音频块需要解码
  188. // 这样可以处理在解码过程中新到达的音频块
  189. if (this.audioChunks.length > this.decodedChunksCount) {
  190. console.log(`🔄 检测到 ${this.audioChunks.length - this.decodedChunksCount} 个未解码的块,继续解码...`)
  191. // 使用 setTimeout 避免递归调用栈过深
  192. setTimeout(() => this.decodeAndQueue(), 0)
  193. }
  194. }
  195. }
  196. /**
  197. * 播放增量音频(只播放新增的部分)
  198. * @param fullBuffer 完整的音频缓冲(包含之前的+新增的)
  199. * @param offsetTime 从哪个时间点开始播放(跳过已播放的部分)
  200. */
  201. private playIncrementalAudio(fullBuffer: AudioBuffer, offsetTime: number) {
  202. if (!this.audioContext || !this.gainNode) {
  203. console.error('❌ AudioContext未初始化')
  204. return
  205. }
  206. // 计算新增音频的时长
  207. const newDuration = fullBuffer.duration - offsetTime
  208. if (newDuration <= 0) {
  209. console.log('⏭️ 没有新的音频需要播放')
  210. return
  211. }
  212. // 🔑 关键:确保AudioContext处于运行状态(同步检查)
  213. if (this.audioContext.state === 'suspended') {
  214. console.warn('⚠️ AudioContext被挂起,尝试快速恢复...')
  215. this.audioContext.resume().then(() => {
  216. console.log('✅ AudioContext已快速恢复')
  217. }).catch((error) => {
  218. console.error('❌ AudioContext恢复失败:', error)
  219. })
  220. }
  221. // 验证音量增益(同步)
  222. const currentGain = this.gainNode.gain.value
  223. if (currentGain < 4.0) {
  224. console.warn(`⚠️ 播放前检测到音量过低 (${currentGain}),恢复至4.5`)
  225. this.gainNode.gain.value = 4.5
  226. }
  227. // 创建音频源节点
  228. const source = this.audioContext.createBufferSource()
  229. source.buffer = fullBuffer
  230. // 连接到增益节点
  231. source.connect(this.gainNode)
  232. // 计算播放时间(紧接着上一段)
  233. const playTime = Math.max(this.lastPlayTime, this.audioContext.currentTime)
  234. // 增加活跃音频源计数
  235. this.activeSources++
  236. console.log(`🎵 播放增量音频: ${newDuration.toFixed(2)}秒 (跳过前${offsetTime.toFixed(2)}秒), 延迟: ${((playTime - this.audioContext.currentTime) * 1000).toFixed(0)}ms, 活跃源: ${this.activeSources}`)
  237. // 设置结束回调
  238. source.onended = () => {
  239. // 减少活跃音频源计数
  240. this.activeSources--
  241. console.log(`🎵 音频段播放完成 (剩余活跃源: ${this.activeSources})`)
  242. // 检查是否还有未解码的数据
  243. const hasNewChunks = this.audioChunks.length > this.decodedChunksCount
  244. if (hasNewChunks) {
  245. console.log(`🔄 继续解码新的 ${this.audioChunks.length - this.decodedChunksCount} 个块`)
  246. this.decodeAndQueue()
  247. } else if (this.allChunksReceived && this.activeSources === 0 && !this.hasEnded) {
  248. // ⭐ 关键:所有块已接收 + 所有音频源都播放完成 + 未触发过结束
  249. console.log('✅ 所有音频播放完成 - 触发 onEnded 回调')
  250. this.playing = false
  251. this.hasEnded = true // 标记已结束,防止重复触发
  252. this.config.onEnded?.()
  253. } else if (this.hasEnded) {
  254. console.log('⏭️ 已触发过 onEnded,跳过重复调用')
  255. } else if (this.activeSources > 0) {
  256. console.log(`⏳ 还有 ${this.activeSources} 个音频源在播放...`)
  257. } else {
  258. console.log('⏳ 等待更多音频块...')
  259. }
  260. }
  261. // ⭐ 关键:使用offset参数跳过已播放的部分,只播放新增部分
  262. // start(when, offset, duration)
  263. source.start(playTime, offsetTime, newDuration)
  264. this.currentSource = source
  265. // 更新下次播放时间
  266. this.lastPlayTime = playTime + newDuration
  267. }
  268. async play() {
  269. console.log(`🎵 play()被调用 (ai_response_end) - 音频块数: ${this.audioChunks.length}, 播放状态: ${this.playing ? '播放中' : '空闲'}`)
  270. // 🔑 关键:每次播放前都检查并恢复AudioContext状态
  271. if (this.audioContext && this.gainNode) {
  272. const isReady = await this.ensureAudioContextReady()
  273. if (!isReady) {
  274. console.error('❌ AudioContext未就绪,播放可能失败')
  275. }
  276. }
  277. // 标记所有音频块已接收完毕
  278. this.allChunksReceived = true
  279. // 如果还没开始播放,立即开始
  280. if (!this.playing && this.audioChunks.length > 0) {
  281. console.log('⚡ 开始播放所有音频')
  282. await this.startStreamingPlayback()
  283. } else if (this.playing && this.audioChunks.length > 0) {
  284. // 已经在播放,解码剩余的块
  285. console.log('🔄 继续解码剩余音频块')
  286. await this.decodeAndQueue()
  287. } else if (!this.playing && this.audioChunks.length === 0) {
  288. console.log('⚠️ 没有音频数据可播放')
  289. }
  290. }
  291. pause() {
  292. if (this.currentSource) {
  293. this.currentSource.stop()
  294. this.currentSource = null
  295. }
  296. this.playing = false
  297. }
  298. stop() {
  299. console.log('🛑 停止播放')
  300. if (this.currentSource) {
  301. try {
  302. this.currentSource.stop()
  303. } catch (e) {
  304. // 忽略已经停止的错误
  305. }
  306. this.currentSource = null
  307. }
  308. this.playing = false
  309. // 注意:不重置 activeSources,让 onended 自然触发递减
  310. }
  311. clear() {
  312. const chunksCount = this.audioChunks.length
  313. console.log(`🧹 清理播放器状态 - 丢弃 ${chunksCount} 个音频块`)
  314. this.stop()
  315. this.audioChunks = []
  316. this.playQueue = []
  317. this.allChunksReceived = false
  318. this.lastPlayTime = 0
  319. this.startTime = 0
  320. this.lastDecodedDuration = 0
  321. this.decodedChunksCount = 0
  322. this.hasEnded = false // 重置结束标志
  323. this.activeSources = 0 // 重置活跃音频源计数
  324. }
  325. destroy() {
  326. this.stop()
  327. this.audioChunks = []
  328. this.playQueue = []
  329. if (this.audioContext) {
  330. this.audioContext.close().catch((err) => {
  331. console.warn('AudioContext关闭失败:', err)
  332. })
  333. this.audioContext = null
  334. }
  335. this.gainNode = null
  336. this.audioReady = false
  337. }
  338. isPlaying(): boolean {
  339. return this.playing
  340. }
  341. getCurrentTime(): number {
  342. if (!this.audioContext || !this.playing) {
  343. return 0
  344. }
  345. return this.audioContext.currentTime - this.startTime
  346. }
  347. getBufferedProgress(): number {
  348. return this.audioChunks.length
  349. }
  350. /**
  351. * 设置音量增益倍数
  352. */
  353. setGain(gain: number) {
  354. if (this.gainNode) {
  355. const clampedGain = Math.max(0.5, Math.min(5.0, gain))
  356. this.gainNode.gain.value = clampedGain
  357. console.log('🔊 音量增益已调整为:', clampedGain, '倍')
  358. } else {
  359. console.warn('⚠️ GainNode未初始化,无法调整增益')
  360. }
  361. }
  362. getGain(): number {
  363. return this.gainNode?.gain.value || 1.0
  364. }
  365. }