|
|
|
@ -0,0 +1,524 @@ |
|
|
|
/** |
|
|
|
* 统一音频管理器 |
|
|
|
* 实现单实例音频管理,避免多个音频同时播放造成的冲突 |
|
|
|
*/ |
|
|
|
class AudioManager { |
|
|
|
constructor() { |
|
|
|
// 当前音频实例
|
|
|
|
this.currentAudio = null; |
|
|
|
// 音频类型:'sentence' | 'word'
|
|
|
|
this.currentAudioType = null; |
|
|
|
// 音频状态
|
|
|
|
this.isPlaying = false; |
|
|
|
// 复用的音频实例池
|
|
|
|
this.audioInstance = null; |
|
|
|
// 事件监听器
|
|
|
|
this.listeners = { |
|
|
|
play: [], |
|
|
|
pause: [], |
|
|
|
ended: [], |
|
|
|
error: [], |
|
|
|
timeupdate: [], |
|
|
|
canplay: [] |
|
|
|
}; |
|
|
|
// 播放速度支持检测
|
|
|
|
this.playbackRateSupported = true; |
|
|
|
|
|
|
|
// 统一音频配置
|
|
|
|
this.globalPlaybackRate = 1.0; // 全局播放速度
|
|
|
|
this.globalVoiceId = ''; // 全局音色ID
|
|
|
|
this.speedOptions = [0.5, 0.8, 1.0, 1.25, 1.5, 2.0]; // 支持的播放速度选项
|
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 创建HTML5 Audio实例并包装为uni-app兼容接口 |
|
|
|
*/ |
|
|
|
createHTML5Audio() { |
|
|
|
const audio = new Audio(); |
|
|
|
|
|
|
|
// 包装为uni-app兼容的接口
|
|
|
|
const wrappedAudio = { |
|
|
|
// 原生HTML5 Audio实例
|
|
|
|
_nativeAudio: audio, |
|
|
|
|
|
|
|
// 基本属性
|
|
|
|
get src() { return audio.src; }, |
|
|
|
set src(value) { audio.src = value; }, |
|
|
|
|
|
|
|
get duration() { return audio.duration || 0; }, |
|
|
|
get currentTime() { return audio.currentTime || 0; }, |
|
|
|
|
|
|
|
get paused() { return audio.paused; }, |
|
|
|
|
|
|
|
// 支持倍速的关键属性
|
|
|
|
get playbackRate() { return audio.playbackRate; }, |
|
|
|
set playbackRate(value) { |
|
|
|
try { |
|
|
|
audio.playbackRate = value; |
|
|
|
console.log(`🎵 HTML5 Audio倍速设置成功: ${value}x`); |
|
|
|
} catch (error) { |
|
|
|
console.error('❌ HTML5 Audio倍速设置失败:', error); |
|
|
|
} |
|
|
|
}, |
|
|
|
|
|
|
|
// 基本方法
|
|
|
|
play() { |
|
|
|
return audio.play().catch(error => { |
|
|
|
console.error('HTML5 Audio播放失败:', error); |
|
|
|
}); |
|
|
|
}, |
|
|
|
|
|
|
|
pause() { |
|
|
|
audio.pause(); |
|
|
|
}, |
|
|
|
|
|
|
|
stop() { |
|
|
|
audio.pause(); |
|
|
|
audio.currentTime = 0; |
|
|
|
}, |
|
|
|
|
|
|
|
seek(time) { |
|
|
|
audio.currentTime = time; |
|
|
|
}, |
|
|
|
|
|
|
|
destroy() { |
|
|
|
audio.pause(); |
|
|
|
audio.src = ''; |
|
|
|
audio.load(); |
|
|
|
}, |
|
|
|
|
|
|
|
// 事件绑定方法
|
|
|
|
onCanplay(callback) { |
|
|
|
audio.addEventListener('canplay', callback); |
|
|
|
}, |
|
|
|
|
|
|
|
onPlay(callback) { |
|
|
|
audio.addEventListener('play', callback); |
|
|
|
}, |
|
|
|
|
|
|
|
onPause(callback) { |
|
|
|
audio.addEventListener('pause', callback); |
|
|
|
}, |
|
|
|
|
|
|
|
onEnded(callback) { |
|
|
|
audio.addEventListener('ended', callback); |
|
|
|
}, |
|
|
|
|
|
|
|
onTimeUpdate(callback) { |
|
|
|
audio.addEventListener('timeupdate', callback); |
|
|
|
}, |
|
|
|
|
|
|
|
onError(callback) { |
|
|
|
// 包装错误事件,过滤掉非关键错误
|
|
|
|
const wrappedCallback = (error) => { |
|
|
|
// 只在有src且音频正在播放时才传递错误事件
|
|
|
|
if (audio.src && audio.src.trim() !== '' && !audio.paused) { |
|
|
|
callback(error); |
|
|
|
} else { |
|
|
|
console.log('🔇 HTML5 Audio错误(已忽略):', { |
|
|
|
hasSrc: !!audio.src, |
|
|
|
paused: audio.paused, |
|
|
|
errorType: error.type || 'unknown' |
|
|
|
}); |
|
|
|
} |
|
|
|
}; |
|
|
|
audio.addEventListener('error', wrappedCallback); |
|
|
|
}, |
|
|
|
|
|
|
|
// 移除事件监听
|
|
|
|
offCanplay(callback) { |
|
|
|
audio.removeEventListener('canplay', callback); |
|
|
|
}, |
|
|
|
|
|
|
|
offPlay(callback) { |
|
|
|
audio.removeEventListener('play', callback); |
|
|
|
}, |
|
|
|
|
|
|
|
offPause(callback) { |
|
|
|
audio.removeEventListener('pause', callback); |
|
|
|
}, |
|
|
|
|
|
|
|
offEnded(callback) { |
|
|
|
audio.removeEventListener('ended', callback); |
|
|
|
}, |
|
|
|
|
|
|
|
offTimeUpdate(callback) { |
|
|
|
audio.removeEventListener('timeupdate', callback); |
|
|
|
}, |
|
|
|
|
|
|
|
offError(callback) { |
|
|
|
audio.removeEventListener('error', callback); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
return wrappedAudio; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 创建音频实例 |
|
|
|
* 优先使用HTML5 Audio(支持倍速),降级到uni.createInnerAudioContext |
|
|
|
* 复用已存在的音频实例以提高性能 |
|
|
|
*/ |
|
|
|
createAudioInstance() { |
|
|
|
// 如果已有音频实例,直接复用
|
|
|
|
if (this.audioInstance) { |
|
|
|
console.log('🔄 复用现有音频实例'); |
|
|
|
return this.audioInstance; |
|
|
|
} |
|
|
|
|
|
|
|
// 在H5环境下优先使用HTML5 Audio
|
|
|
|
// #ifdef H5
|
|
|
|
try { |
|
|
|
this.audioInstance = this.createHTML5Audio(); |
|
|
|
console.log('🎵 创建新的HTML5 Audio实例'); |
|
|
|
} catch (error) { |
|
|
|
console.warn('⚠️ HTML5 Audio创建失败,降级到uni音频:', error); |
|
|
|
audio = uni.createInnerAudioContext(); |
|
|
|
this.playbackRateSupported = false; |
|
|
|
} |
|
|
|
// #endif
|
|
|
|
|
|
|
|
// 在非H5环境下使用uni音频
|
|
|
|
// #ifndef H5
|
|
|
|
this.audioInstance = uni.createInnerAudioContext(); |
|
|
|
this.playbackRateSupported = false; |
|
|
|
console.log('🎵 创建新的uni音频实例'); |
|
|
|
// #endif
|
|
|
|
|
|
|
|
return this.audioInstance; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 停止当前音频 |
|
|
|
*/ |
|
|
|
stopCurrentAudio() { |
|
|
|
if (this.currentAudio) { |
|
|
|
console.log(`🛑 停止当前音频 (类型: ${this.currentAudioType})`); |
|
|
|
|
|
|
|
try { |
|
|
|
this.currentAudio.pause(); |
|
|
|
// 不销毁音频实例,保留以供复用
|
|
|
|
// this.currentAudio.destroy();
|
|
|
|
} catch (error) { |
|
|
|
console.error('⚠️ 停止音频时出错:', error); |
|
|
|
} |
|
|
|
|
|
|
|
this.currentAudio = null; |
|
|
|
this.currentAudioType = null; |
|
|
|
this.isPlaying = false; |
|
|
|
|
|
|
|
// 触发暂停事件
|
|
|
|
this.emit('pause'); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 播放音频 |
|
|
|
* @param {string} audioUrl - 音频URL |
|
|
|
* @param {string} audioType - 音频类型 ('sentence' | 'word') |
|
|
|
* @param {Object} options - 播放选项 |
|
|
|
*/ |
|
|
|
async playAudio(audioUrl, audioType = 'word', options = {}) { |
|
|
|
try { |
|
|
|
console.log(`🎵 开始播放${audioType}音频:`, audioUrl); |
|
|
|
|
|
|
|
// 停止当前播放的音频
|
|
|
|
this.stopCurrentAudio(); |
|
|
|
|
|
|
|
// 创建新的音频实例
|
|
|
|
const audio = this.createAudioInstance(); |
|
|
|
audio.src = audioUrl; |
|
|
|
|
|
|
|
// 设置播放选项,优先使用全局播放速度
|
|
|
|
const playbackRate = options.playbackRate || this.globalPlaybackRate; |
|
|
|
if (playbackRate && this.playbackRateSupported) { |
|
|
|
audio.playbackRate = playbackRate; |
|
|
|
console.log(`🎵 设置音频播放速度: ${playbackRate}x`); |
|
|
|
} |
|
|
|
|
|
|
|
// 绑定事件监听器
|
|
|
|
this.bindAudioEvents(audio, audioType); |
|
|
|
|
|
|
|
// 保存当前音频实例
|
|
|
|
this.currentAudio = audio; |
|
|
|
this.currentAudioType = audioType; |
|
|
|
|
|
|
|
// 延迟播放,确保音频实例完全准备好
|
|
|
|
setTimeout(() => { |
|
|
|
if (this.currentAudio === audio) { |
|
|
|
try { |
|
|
|
audio.play(); |
|
|
|
console.log(`✅ ${audioType}音频开始播放`); |
|
|
|
} catch (playError) { |
|
|
|
console.error('❌ 播放命令失败:', playError); |
|
|
|
this.emit('error', playError); |
|
|
|
} |
|
|
|
} |
|
|
|
}, 100); |
|
|
|
|
|
|
|
return audio; |
|
|
|
} catch (error) { |
|
|
|
console.error('❌ 播放音频异常:', error); |
|
|
|
this.emit('error', error); |
|
|
|
throw error; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 绑定音频事件监听器 |
|
|
|
*/ |
|
|
|
bindAudioEvents(audio, audioType) { |
|
|
|
// 播放开始
|
|
|
|
audio.onPlay(() => { |
|
|
|
console.log(`🎵 ${audioType}音频播放开始`); |
|
|
|
this.isPlaying = true; |
|
|
|
this.emit('play', { audioType, audio }); |
|
|
|
}); |
|
|
|
|
|
|
|
// 播放暂停
|
|
|
|
audio.onPause(() => { |
|
|
|
console.log(`⏸️ ${audioType}音频播放暂停`); |
|
|
|
this.isPlaying = false; |
|
|
|
this.emit('pause', { audioType, audio }); |
|
|
|
}); |
|
|
|
|
|
|
|
// 播放结束
|
|
|
|
audio.onEnded(() => { |
|
|
|
console.log(`🏁 ${audioType}音频播放结束`); |
|
|
|
this.isPlaying = false; |
|
|
|
|
|
|
|
// 不销毁音频实例,保留以供复用
|
|
|
|
// 只清理当前播放状态
|
|
|
|
if (this.currentAudio === audio) { |
|
|
|
this.currentAudio = null; |
|
|
|
this.currentAudioType = null; |
|
|
|
} |
|
|
|
|
|
|
|
this.emit('ended', { audioType, audio }); |
|
|
|
}); |
|
|
|
|
|
|
|
// 播放错误
|
|
|
|
audio.onError((error) => { |
|
|
|
console.error(`❌ ${audioType}音频播放失败:`, error); |
|
|
|
this.isPlaying = false; |
|
|
|
|
|
|
|
// 发生错误时才销毁音频实例
|
|
|
|
try { |
|
|
|
audio.destroy(); |
|
|
|
} catch (destroyError) { |
|
|
|
console.error('⚠️ 销毁音频实例时出错:', destroyError); |
|
|
|
} |
|
|
|
|
|
|
|
if (this.currentAudio === audio) { |
|
|
|
this.currentAudio = null; |
|
|
|
this.currentAudioType = null; |
|
|
|
} |
|
|
|
|
|
|
|
// 清理复用的音频实例引用
|
|
|
|
if (this.audioInstance === audio) { |
|
|
|
this.audioInstance = null; |
|
|
|
} |
|
|
|
|
|
|
|
this.emit('error', { error, audioType, audio }); |
|
|
|
}); |
|
|
|
|
|
|
|
// 时间更新
|
|
|
|
audio.onTimeUpdate(() => { |
|
|
|
this.emit('timeupdate', { |
|
|
|
currentTime: audio.currentTime, |
|
|
|
duration: audio.duration, |
|
|
|
audioType, |
|
|
|
audio |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
// 可以播放
|
|
|
|
audio.onCanplay(() => { |
|
|
|
this.emit('canplay', { audioType, audio }); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 暂停当前音频 |
|
|
|
*/ |
|
|
|
pause() { |
|
|
|
if (this.currentAudio && this.isPlaying) { |
|
|
|
this.currentAudio.pause(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 恢复播放 |
|
|
|
*/ |
|
|
|
resume() { |
|
|
|
if (this.currentAudio && !this.isPlaying) { |
|
|
|
this.currentAudio.play(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 设置播放速度 |
|
|
|
*/ |
|
|
|
setPlaybackRate(rate) { |
|
|
|
if (this.currentAudio && this.playbackRateSupported) { |
|
|
|
this.currentAudio.playbackRate = rate; |
|
|
|
return true; |
|
|
|
} |
|
|
|
return false; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 设置全局播放速度 |
|
|
|
* @param {number} rate - 播放速度 (0.5 - 2.0) |
|
|
|
*/ |
|
|
|
setGlobalPlaybackRate(rate) { |
|
|
|
if (rate < 0.5 || rate > 2.0) { |
|
|
|
console.warn('⚠️ 播放速度超出支持范围 (0.5-2.0):', rate); |
|
|
|
return false; |
|
|
|
} |
|
|
|
|
|
|
|
this.globalPlaybackRate = rate; |
|
|
|
console.log(`🎵 设置全局播放速度: ${rate}x`); |
|
|
|
|
|
|
|
// 如果当前有音频在播放,立即应用新的播放速度
|
|
|
|
if (this.currentAudio && this.playbackRateSupported) { |
|
|
|
try { |
|
|
|
this.currentAudio.playbackRate = rate; |
|
|
|
console.log(`✅ 当前音频播放速度已更新: ${rate}x`); |
|
|
|
} catch (error) { |
|
|
|
console.error('❌ 更新当前音频播放速度失败:', error); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return true; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 获取全局播放速度 |
|
|
|
*/ |
|
|
|
getGlobalPlaybackRate() { |
|
|
|
return this.globalPlaybackRate; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 设置全局音色ID |
|
|
|
* @param {string|number} voiceId - 音色ID |
|
|
|
*/ |
|
|
|
setGlobalVoiceId(voiceId) { |
|
|
|
this.globalVoiceId = String(voiceId); |
|
|
|
console.log(`🎵 设置全局音色ID: ${this.globalVoiceId}`); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 获取全局音色ID |
|
|
|
*/ |
|
|
|
getGlobalVoiceId() { |
|
|
|
return this.globalVoiceId; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 获取支持的播放速度选项 |
|
|
|
*/ |
|
|
|
getSpeedOptions() { |
|
|
|
return [...this.speedOptions]; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 切换到下一个播放速度 |
|
|
|
*/ |
|
|
|
togglePlaybackRate() { |
|
|
|
const currentIndex = this.speedOptions.indexOf(this.globalPlaybackRate); |
|
|
|
const nextIndex = (currentIndex + 1) % this.speedOptions.length; |
|
|
|
const nextRate = this.speedOptions[nextIndex]; |
|
|
|
|
|
|
|
this.setGlobalPlaybackRate(nextRate); |
|
|
|
return nextRate; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 获取当前播放状态 |
|
|
|
*/ |
|
|
|
getPlaybackState() { |
|
|
|
return { |
|
|
|
isPlaying: this.isPlaying, |
|
|
|
currentAudioType: this.currentAudioType, |
|
|
|
hasAudio: !!this.currentAudio, |
|
|
|
playbackRateSupported: this.playbackRateSupported, |
|
|
|
currentTime: this.currentAudio ? this.currentAudio.currentTime : 0, |
|
|
|
duration: this.currentAudio ? this.currentAudio.duration : 0 |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 添加事件监听器 |
|
|
|
*/ |
|
|
|
on(event, callback) { |
|
|
|
if (this.listeners[event]) { |
|
|
|
// 检查是否已经绑定过相同的回调函数,避免重复绑定
|
|
|
|
if (this.listeners[event].indexOf(callback) === -1) { |
|
|
|
this.listeners[event].push(callback); |
|
|
|
console.log(`🎵 添加事件监听器: ${event}, 当前监听器数量: ${this.listeners[event].length}`); |
|
|
|
} else { |
|
|
|
console.warn(`⚠️ 事件监听器已存在,跳过重复绑定: ${event}`); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 移除事件监听器 |
|
|
|
*/ |
|
|
|
off(event, callback) { |
|
|
|
if (this.listeners[event]) { |
|
|
|
const index = this.listeners[event].indexOf(callback); |
|
|
|
if (index > -1) { |
|
|
|
this.listeners[event].splice(index, 1); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 触发事件 |
|
|
|
*/ |
|
|
|
emit(event, data) { |
|
|
|
if (this.listeners[event]) { |
|
|
|
this.listeners[event].forEach(callback => { |
|
|
|
try { |
|
|
|
callback(data); |
|
|
|
} catch (error) { |
|
|
|
console.error(`事件监听器执行错误 (${event}):`, error); |
|
|
|
} |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 销毁音频管理器 |
|
|
|
*/ |
|
|
|
destroy() { |
|
|
|
this.stopCurrentAudio(); |
|
|
|
|
|
|
|
// 销毁复用的音频实例
|
|
|
|
// if (this.audioInstance) {
|
|
|
|
// try {
|
|
|
|
// this.audioInstance.destroy();
|
|
|
|
// } catch (error) {
|
|
|
|
// console.error('⚠️ 销毁音频实例时出错:', error);
|
|
|
|
// }
|
|
|
|
// this.audioInstance = null;
|
|
|
|
// }
|
|
|
|
|
|
|
|
this.listeners = { |
|
|
|
play: [], |
|
|
|
pause: [], |
|
|
|
ended: [], |
|
|
|
error: [], |
|
|
|
timeupdate: [], |
|
|
|
canplay: [] |
|
|
|
}; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 创建全局单例
|
|
|
|
const audioManager = new AudioManager(); |
|
|
|
|
|
|
|
export default audioManager; |