|
@@ -12,6 +12,12 @@ export class FallbackAudioPlayer {
|
|
|
private audio: HTMLAudioElement | null = null
|
|
private audio: HTMLAudioElement | null = null
|
|
|
private config: AudioPlayerConfig
|
|
private config: AudioPlayerConfig
|
|
|
private playTimeout: number | null = null
|
|
private playTimeout: number | null = null
|
|
|
|
|
+ private audioReady = false // 标记Audio元素是否已激活
|
|
|
|
|
+ private pendingUrl: string | null = null // 待播放的URL
|
|
|
|
|
+ private audioContext: AudioContext | null = null // Web Audio API上下文
|
|
|
|
|
+ private gainNode: GainNode | null = null // 音量增益节点
|
|
|
|
|
+ private mediaSource: MediaElementAudioSourceNode | null = null // 保存source节点,避免重复创建
|
|
|
|
|
+ private sourceConnected = false // 标记是否已连接增益节点
|
|
|
|
|
|
|
|
constructor(config: AudioPlayerConfig = {}) {
|
|
constructor(config: AudioPlayerConfig = {}) {
|
|
|
this.config = config
|
|
this.config = config
|
|
@@ -21,6 +27,127 @@ export class FallbackAudioPlayer {
|
|
|
console.log('使用降级音频播放器(合并播放模式)')
|
|
console.log('使用降级音频播放器(合并播放模式)')
|
|
|
return Promise.resolve()
|
|
return Promise.resolve()
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 在用户交互时调用此方法,激活Audio元素
|
|
|
|
|
+ * 这样后续异步播放时就不会被Safari阻止
|
|
|
|
|
+ */
|
|
|
|
|
+ prepareForPlayback() {
|
|
|
|
|
+ if (this.audioReady) {
|
|
|
|
|
+ console.log('🎯 Audio已激活,跳过重复激活')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log('🎯 在用户交互中激活Audio元素(解决Safari自动播放限制)')
|
|
|
|
|
+
|
|
|
|
|
+ // 创建并激活Audio元素
|
|
|
|
|
+ this.audio = new Audio()
|
|
|
|
|
+ this.audio.autoplay = false
|
|
|
|
|
+ this.audio.volume = 1 // ⭐ 先设置为最大音量,避免忘记恢复
|
|
|
|
|
+
|
|
|
|
|
+ // ⭐ 创建Web Audio API上下文和增益节点(音量放大)
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 兼容性处理
|
|
|
|
|
+ const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext
|
|
|
|
|
+ this.audioContext = new AudioContextClass()
|
|
|
|
|
+
|
|
|
|
|
+ // 创建增益节点(音量放大器)
|
|
|
|
|
+ this.gainNode = this.audioContext.createGain()
|
|
|
|
|
+
|
|
|
|
|
+ // ⭐ 设置增益值:4.5倍音量(可调整 1.0-5.0)
|
|
|
|
|
+ // 1.0 = 原音量, 2.0 = 2倍, 3.0 = 3倍, 4.5 = 4.5倍
|
|
|
|
|
+ // 移动端通常需要更高的增益才能听清
|
|
|
|
|
+ this.gainNode.gain.value = 4.5
|
|
|
|
|
+ console.log('🔊 音频增益设置为:', this.gainNode.gain.value, '倍')
|
|
|
|
|
+
|
|
|
|
|
+ // 将增益节点连接到输出
|
|
|
|
|
+ this.gainNode.connect(this.audioContext.destination)
|
|
|
|
|
+
|
|
|
|
|
+ console.log('✅ Web Audio API增益节点创建成功')
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.warn('⚠️ Web Audio API初始化失败,将使用默认音量:', error)
|
|
|
|
|
+ // 降级:不使用增益,继续正常播放
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 播放一个极短的静音以激活音频上下文
|
|
|
|
|
+ // 使用空的audio/wav格式(最小的有效WAV文件)
|
|
|
|
|
+ const silentWav = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
|
|
|
|
|
+ this.audio.src = silentWav
|
|
|
|
|
+
|
|
|
|
|
+ // 临时降低音量播放静音(实际听不到)
|
|
|
|
|
+ const originalVolume = this.audio.volume
|
|
|
|
|
+ this.audio.volume = 0
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试播放(可能失败,但没关系)
|
|
|
|
|
+ this.audio.play().then(() => {
|
|
|
|
|
+ console.log('✅ Audio元素激活成功')
|
|
|
|
|
+ this.audioReady = true
|
|
|
|
|
+
|
|
|
|
|
+ // 恢复AudioContext(Safari需要用户交互才能启动)
|
|
|
|
|
+ if (this.audioContext && this.audioContext.state === 'suspended') {
|
|
|
|
|
+ this.audioContext.resume().then(() => {
|
|
|
|
|
+ console.log('✅ AudioContext已激活')
|
|
|
|
|
+
|
|
|
|
|
+ // ⭐ 在用户交互上下文中连接Audio元素到增益节点(关键!)
|
|
|
|
|
+ this.connectAudioToGainNode()
|
|
|
|
|
+ })
|
|
|
|
|
+ } else if (this.audioContext) {
|
|
|
|
|
+ // 如果AudioContext已经是running状态,直接连接
|
|
|
|
|
+ this.connectAudioToGainNode()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 立即暂停,恢复音量
|
|
|
|
|
+ if (this.audio) {
|
|
|
|
|
+ this.audio.pause()
|
|
|
|
|
+ this.audio.volume = originalVolume // 恢复为最大音量
|
|
|
|
|
+ console.log('🔊 播放器音量已设置为:', this.audio.volume)
|
|
|
|
|
+ }
|
|
|
|
|
+ }).catch((err) => {
|
|
|
|
|
+ console.warn('⚠️ 首次激活失败(预期行为):', err.message)
|
|
|
|
|
+ // 即使失败也要恢复音量并标记为ready
|
|
|
|
|
+ if (this.audio) {
|
|
|
|
|
+ this.audio.volume = originalVolume
|
|
|
|
|
+ }
|
|
|
|
|
+ this.audioReady = true
|
|
|
|
|
+
|
|
|
|
|
+ // 失败时也尝试连接增益节点
|
|
|
|
|
+ this.connectAudioToGainNode()
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 连接Audio元素到增益节点(只连接一次)
|
|
|
|
|
+ * 必须在用户交互上下文中调用,否则移动端可能失败
|
|
|
|
|
+ */
|
|
|
|
|
+ private connectAudioToGainNode() {
|
|
|
|
|
+ // 如果已经连接过,跳过
|
|
|
|
|
+ if (this.sourceConnected) {
|
|
|
|
|
+ console.log('🎯 增益节点已连接,跳过重复连接')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 检查必要条件
|
|
|
|
|
+ if (!this.audio || !this.audioContext || !this.gainNode) {
|
|
|
|
|
+ console.warn('⚠️ 无法连接增益节点:缺少必要组件')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ console.log('🔗 开始连接Audio元素到增益节点...')
|
|
|
|
|
+
|
|
|
|
|
+ // ⭐ 关键:只能创建一次MediaElementSource
|
|
|
|
|
+ this.mediaSource = this.audioContext.createMediaElementSource(this.audio)
|
|
|
|
|
+
|
|
|
|
|
+ // 连接链路: Audio元素 -> 增益节点 -> 输出
|
|
|
|
|
+ this.mediaSource.connect(this.gainNode)
|
|
|
|
|
+
|
|
|
|
|
+ this.sourceConnected = true
|
|
|
|
|
+ console.log('✅ Audio元素已成功连接到增益节点,音量将放大', this.gainNode.gain.value, '倍')
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('❌ 连接增益节点失败:', error)
|
|
|
|
|
+ // 连接失败不影响播放,只是没有增益效果
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
appendAudioChunk(chunk: ArrayBuffer) {
|
|
appendAudioChunk(chunk: ArrayBuffer) {
|
|
|
const totalChunks = this.audioChunks.length + 1
|
|
const totalChunks = this.audioChunks.length + 1
|
|
@@ -211,10 +338,34 @@ export class FallbackAudioPlayer {
|
|
|
const blob = new Blob([data.buffer as ArrayBuffer], { type: mimeType })
|
|
const blob = new Blob([data.buffer as ArrayBuffer], { type: mimeType })
|
|
|
const url = URL.createObjectURL(blob)
|
|
const url = URL.createObjectURL(blob)
|
|
|
|
|
|
|
|
- // 创建Audio元素
|
|
|
|
|
- this.audio = new Audio(url)
|
|
|
|
|
|
|
+ // 如果没有激活的Audio元素,创建一个新的
|
|
|
|
|
+ if (!this.audio) {
|
|
|
|
|
+ console.log('⚠️ Audio元素未激活,创建新元素(可能被Safari阻止)')
|
|
|
|
|
+ this.audio = new Audio()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ⭐ 确保音量设置为最大(移动端可能被降低)
|
|
|
|
|
+ this.audio.volume = 1.0
|
|
|
|
|
+ console.log('🔊 设置播放音量为:', this.audio.volume)
|
|
|
|
|
|
|
|
- // 监听播放开始事件(使用onplaying而不是onplay,因为onplaying是真正开始播放)
|
|
|
|
|
|
|
+ // ⭐ 确保增益节点已连接(如果还没连接的话)
|
|
|
|
|
+ if (!this.sourceConnected && this.audioContext && this.gainNode) {
|
|
|
|
|
+ console.log('⚠️ 增益节点尚未连接,尝试连接...')
|
|
|
|
|
+ this.connectAudioToGainNode()
|
|
|
|
|
+ } else if (this.sourceConnected) {
|
|
|
|
|
+ console.log('✅ 增益节点已连接,音量增益:', this.gainNode?.gain.value, '倍')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 清理旧的URL
|
|
|
|
|
+ if (this.pendingUrl) {
|
|
|
|
|
+ URL.revokeObjectURL(this.pendingUrl)
|
|
|
|
|
+ this.pendingUrl = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 保存新URL
|
|
|
|
|
+ this.pendingUrl = url
|
|
|
|
|
+
|
|
|
|
|
+ // 监听播放开始事件
|
|
|
this.audio.onplaying = () => {
|
|
this.audio.onplaying = () => {
|
|
|
console.log('🎵 音频开始播放')
|
|
console.log('🎵 音频开始播放')
|
|
|
this.config.onPlay?.()
|
|
this.config.onPlay?.()
|
|
@@ -222,7 +373,10 @@ export class FallbackAudioPlayer {
|
|
|
|
|
|
|
|
this.audio.onended = () => {
|
|
this.audio.onended = () => {
|
|
|
console.log('音频播放完成')
|
|
console.log('音频播放完成')
|
|
|
- URL.revokeObjectURL(url)
|
|
|
|
|
|
|
+ if (this.pendingUrl) {
|
|
|
|
|
+ URL.revokeObjectURL(this.pendingUrl)
|
|
|
|
|
+ this.pendingUrl = null
|
|
|
|
|
+ }
|
|
|
this.playing = false
|
|
this.playing = false
|
|
|
this.config.onEnded?.()
|
|
this.config.onEnded?.()
|
|
|
}
|
|
}
|
|
@@ -230,19 +384,29 @@ export class FallbackAudioPlayer {
|
|
|
this.audio.onerror = (error) => {
|
|
this.audio.onerror = (error) => {
|
|
|
console.error('音频播放错误:', error)
|
|
console.error('音频播放错误:', error)
|
|
|
console.error('Blob大小:', blob.size, 'MIME:', blob.type)
|
|
console.error('Blob大小:', blob.size, 'MIME:', blob.type)
|
|
|
- URL.revokeObjectURL(url)
|
|
|
|
|
|
|
+ if (this.pendingUrl) {
|
|
|
|
|
+ URL.revokeObjectURL(this.pendingUrl)
|
|
|
|
|
+ this.pendingUrl = null
|
|
|
|
|
+ }
|
|
|
this.playing = false
|
|
this.playing = false
|
|
|
|
|
|
|
|
// 尝试降级播放策略
|
|
// 尝试降级播放策略
|
|
|
this.tryFallbackPlayback(data, mimeType)
|
|
this.tryFallbackPlayback(data, mimeType)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // 更新src并播放
|
|
|
|
|
+ this.audio.src = url
|
|
|
|
|
+ this.audio.load() // 确保加载新的src
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
await this.audio.play()
|
|
await this.audio.play()
|
|
|
- console.log('播放命令已执行')
|
|
|
|
|
|
|
+ console.log('✅ 播放命令已执行(使用已激活的Audio元素)')
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- console.error('播放失败:', error)
|
|
|
|
|
- URL.revokeObjectURL(url)
|
|
|
|
|
|
|
+ console.error('❌ 播放失败:', error)
|
|
|
|
|
+ if (this.pendingUrl) {
|
|
|
|
|
+ URL.revokeObjectURL(this.pendingUrl)
|
|
|
|
|
+ this.pendingUrl = null
|
|
|
|
|
+ }
|
|
|
this.playing = false
|
|
this.playing = false
|
|
|
|
|
|
|
|
// 尝试降级播放策略
|
|
// 尝试降级播放策略
|
|
@@ -364,7 +528,7 @@ export class FallbackAudioPlayer {
|
|
|
const chunksCount = this.audioChunks.length
|
|
const chunksCount = this.audioChunks.length
|
|
|
console.log(`🛑 停止播放 - 清理 ${chunksCount} 个音频块`)
|
|
console.log(`🛑 停止播放 - 清理 ${chunksCount} 个音频块`)
|
|
|
|
|
|
|
|
- // 清除超时器(虽然现在不再使用)
|
|
|
|
|
|
|
+ // 清除超时器
|
|
|
if (this.playTimeout) {
|
|
if (this.playTimeout) {
|
|
|
clearTimeout(this.playTimeout)
|
|
clearTimeout(this.playTimeout)
|
|
|
this.playTimeout = null
|
|
this.playTimeout = null
|
|
@@ -373,15 +537,19 @@ export class FallbackAudioPlayer {
|
|
|
// 停止音频播放
|
|
// 停止音频播放
|
|
|
if (this.audio) {
|
|
if (this.audio) {
|
|
|
this.audio.pause()
|
|
this.audio.pause()
|
|
|
- if (this.audio.src) {
|
|
|
|
|
- URL.revokeObjectURL(this.audio.src)
|
|
|
|
|
- }
|
|
|
|
|
- this.audio = null
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 清理URL
|
|
|
|
|
+ if (this.pendingUrl) {
|
|
|
|
|
+ URL.revokeObjectURL(this.pendingUrl)
|
|
|
|
|
+ this.pendingUrl = null
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 清空音频块
|
|
// 清空音频块
|
|
|
this.audioChunks = []
|
|
this.audioChunks = []
|
|
|
this.playing = false
|
|
this.playing = false
|
|
|
|
|
+
|
|
|
|
|
+ // 注意:不清理audio元素,保持激活状态供下次使用
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
clear() {
|
|
clear() {
|
|
@@ -395,23 +563,59 @@ export class FallbackAudioPlayer {
|
|
|
this.playTimeout = null
|
|
this.playTimeout = null
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 停止当前播放(但不清理audioChunks,让stop()做)
|
|
|
|
|
|
|
+ // 停止当前播放
|
|
|
if (this.audio && isPlaying) {
|
|
if (this.audio && isPlaying) {
|
|
|
this.audio.pause()
|
|
this.audio.pause()
|
|
|
- if (this.audio.src) {
|
|
|
|
|
- URL.revokeObjectURL(this.audio.src)
|
|
|
|
|
- }
|
|
|
|
|
- this.audio = null
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 清理URL
|
|
|
|
|
+ if (this.pendingUrl) {
|
|
|
|
|
+ URL.revokeObjectURL(this.pendingUrl)
|
|
|
|
|
+ this.pendingUrl = null
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 清空音频块数组
|
|
// 清空音频块数组
|
|
|
this.audioChunks = []
|
|
this.audioChunks = []
|
|
|
this.playing = false
|
|
this.playing = false
|
|
|
|
|
+
|
|
|
|
|
+ // 注意:不清理audio元素,保持激活状态供下次使用
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
destroy() {
|
|
destroy() {
|
|
|
this.stop()
|
|
this.stop()
|
|
|
- this.audio = null
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 清理URL
|
|
|
|
|
+ if (this.pendingUrl) {
|
|
|
|
|
+ URL.revokeObjectURL(this.pendingUrl)
|
|
|
|
|
+ this.pendingUrl = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 销毁时才真正清理audio元素
|
|
|
|
|
+ if (this.audio) {
|
|
|
|
|
+ this.audio.pause()
|
|
|
|
|
+ this.audio = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 清理Web Audio API资源
|
|
|
|
|
+ if (this.mediaSource) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ this.mediaSource.disconnect()
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.warn('MediaSource断开连接失败:', err)
|
|
|
|
|
+ }
|
|
|
|
|
+ this.mediaSource = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (this.audioContext) {
|
|
|
|
|
+ this.audioContext.close().catch((err) => {
|
|
|
|
|
+ console.warn('AudioContext关闭失败:', err)
|
|
|
|
|
+ })
|
|
|
|
|
+ this.audioContext = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.gainNode = null
|
|
|
|
|
+ this.sourceConnected = false
|
|
|
|
|
+ this.audioReady = false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
isPlaying(): boolean {
|
|
isPlaying(): boolean {
|
|
@@ -425,5 +629,27 @@ export class FallbackAudioPlayer {
|
|
|
getBufferedProgress(): number {
|
|
getBufferedProgress(): number {
|
|
|
return this.audioChunks.length
|
|
return this.audioChunks.length
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 设置音量增益倍数(动态调整音量)
|
|
|
|
|
+ * @param gain 增益倍数 (1.0-5.0),1.0为原音量,2.0为2倍,3.0为3倍,4.5为4.5倍
|
|
|
|
|
+ */
|
|
|
|
|
+ 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('⚠️ Web Audio API未初始化,无法调整增益')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取当前音量增益倍数
|
|
|
|
|
+ */
|
|
|
|
|
+ getGain(): number {
|
|
|
|
|
+ return this.gainNode?.gain.value || 1.0
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|