四零语境前端代码仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

523 lines
13 KiB

  1. /**
  2. * 统一音频管理器
  3. * 实现单实例音频管理避免多个音频同时播放造成的冲突
  4. */
  5. class AudioManager {
  6. constructor() {
  7. // 当前音频实例
  8. this.currentAudio = null;
  9. // 音频类型:'sentence' | 'word'
  10. this.currentAudioType = null;
  11. // 音频状态
  12. this.isPlaying = false;
  13. // 复用的音频实例池
  14. this.audioInstance = null;
  15. // 事件监听器
  16. this.listeners = {
  17. play: [],
  18. pause: [],
  19. ended: [],
  20. error: [],
  21. timeupdate: [],
  22. canplay: []
  23. };
  24. // 播放速度支持检测
  25. this.playbackRateSupported = true;
  26. // 统一音频配置
  27. this.globalPlaybackRate = 1.0; // 全局播放速度
  28. this.globalVoiceId = ''; // 全局音色ID
  29. this.speedOptions = [0.5, 0.8, 1.0, 1.25, 1.5, 2.0]; // 支持的播放速度选项
  30. }
  31. /**
  32. * 创建HTML5 Audio实例并包装为uni-app兼容接口
  33. */
  34. createHTML5Audio() {
  35. const audio = new Audio();
  36. // 包装为uni-app兼容的接口
  37. const wrappedAudio = {
  38. // 原生HTML5 Audio实例
  39. _nativeAudio: audio,
  40. // 基本属性
  41. get src() { return audio.src; },
  42. set src(value) { audio.src = value; },
  43. get duration() { return audio.duration || 0; },
  44. get currentTime() { return audio.currentTime || 0; },
  45. get paused() { return audio.paused; },
  46. // 支持倍速的关键属性
  47. get playbackRate() { return audio.playbackRate; },
  48. set playbackRate(value) {
  49. try {
  50. audio.playbackRate = value;
  51. console.log(`🎵 HTML5 Audio倍速设置成功: ${value}x`);
  52. } catch (error) {
  53. console.error('❌ HTML5 Audio倍速设置失败:', error);
  54. }
  55. },
  56. // 基本方法
  57. play() {
  58. return audio.play().catch(error => {
  59. console.error('HTML5 Audio播放失败:', error);
  60. });
  61. },
  62. pause() {
  63. audio.pause();
  64. },
  65. stop() {
  66. audio.pause();
  67. audio.currentTime = 0;
  68. },
  69. seek(time) {
  70. audio.currentTime = time;
  71. },
  72. destroy() {
  73. audio.pause();
  74. audio.src = '';
  75. audio.load();
  76. },
  77. // 事件绑定方法
  78. onCanplay(callback) {
  79. audio.addEventListener('canplay', callback);
  80. },
  81. onPlay(callback) {
  82. audio.addEventListener('play', callback);
  83. },
  84. onPause(callback) {
  85. audio.addEventListener('pause', callback);
  86. },
  87. onEnded(callback) {
  88. audio.addEventListener('ended', callback);
  89. },
  90. onTimeUpdate(callback) {
  91. audio.addEventListener('timeupdate', callback);
  92. },
  93. onError(callback) {
  94. // 包装错误事件,过滤掉非关键错误
  95. const wrappedCallback = (error) => {
  96. // 只在有src且音频正在播放时才传递错误事件
  97. if (audio.src && audio.src.trim() !== '' && !audio.paused) {
  98. callback(error);
  99. } else {
  100. console.log('🔇 HTML5 Audio错误(已忽略):', {
  101. hasSrc: !!audio.src,
  102. paused: audio.paused,
  103. errorType: error.type || 'unknown'
  104. });
  105. }
  106. };
  107. audio.addEventListener('error', wrappedCallback);
  108. },
  109. // 移除事件监听
  110. offCanplay(callback) {
  111. audio.removeEventListener('canplay', callback);
  112. },
  113. offPlay(callback) {
  114. audio.removeEventListener('play', callback);
  115. },
  116. offPause(callback) {
  117. audio.removeEventListener('pause', callback);
  118. },
  119. offEnded(callback) {
  120. audio.removeEventListener('ended', callback);
  121. },
  122. offTimeUpdate(callback) {
  123. audio.removeEventListener('timeupdate', callback);
  124. },
  125. offError(callback) {
  126. audio.removeEventListener('error', callback);
  127. }
  128. };
  129. return wrappedAudio;
  130. }
  131. /**
  132. * 创建音频实例
  133. * 优先使用HTML5 Audio支持倍速降级到uni.createInnerAudioContext
  134. * 复用已存在的音频实例以提高性能
  135. */
  136. createAudioInstance() {
  137. // 如果已有音频实例,直接复用
  138. if (this.audioInstance) {
  139. console.log('🔄 复用现有音频实例');
  140. return this.audioInstance;
  141. }
  142. // 在H5环境下优先使用HTML5 Audio
  143. // #ifdef H5
  144. try {
  145. this.audioInstance = this.createHTML5Audio();
  146. console.log('🎵 创建新的HTML5 Audio实例');
  147. } catch (error) {
  148. console.warn('⚠️ HTML5 Audio创建失败,降级到uni音频:', error);
  149. audio = uni.createInnerAudioContext();
  150. this.playbackRateSupported = false;
  151. }
  152. // #endif
  153. // 在非H5环境下使用uni音频
  154. // #ifndef H5
  155. this.audioInstance = uni.createInnerAudioContext();
  156. this.playbackRateSupported = false;
  157. console.log('🎵 创建新的uni音频实例');
  158. // #endif
  159. return this.audioInstance;
  160. }
  161. /**
  162. * 停止当前音频
  163. */
  164. stopCurrentAudio() {
  165. if (this.currentAudio) {
  166. console.log(`🛑 停止当前音频 (类型: ${this.currentAudioType})`);
  167. try {
  168. this.currentAudio.pause();
  169. // 不销毁音频实例,保留以供复用
  170. // this.currentAudio.destroy();
  171. } catch (error) {
  172. console.error('⚠️ 停止音频时出错:', error);
  173. }
  174. this.currentAudio = null;
  175. this.currentAudioType = null;
  176. this.isPlaying = false;
  177. // 触发暂停事件
  178. this.emit('pause');
  179. }
  180. }
  181. /**
  182. * 播放音频
  183. * @param {string} audioUrl - 音频URL
  184. * @param {string} audioType - 音频类型 ('sentence' | 'word')
  185. * @param {Object} options - 播放选项
  186. */
  187. async playAudio(audioUrl, audioType = 'word', options = {}) {
  188. try {
  189. console.log(`🎵 开始播放${audioType}音频:`, audioUrl);
  190. // 停止当前播放的音频
  191. this.stopCurrentAudio();
  192. // 创建新的音频实例
  193. const audio = this.createAudioInstance();
  194. audio.src = audioUrl;
  195. // 设置播放选项,优先使用全局播放速度
  196. const playbackRate = options.playbackRate || this.globalPlaybackRate;
  197. if (playbackRate && this.playbackRateSupported) {
  198. audio.playbackRate = playbackRate;
  199. console.log(`🎵 设置音频播放速度: ${playbackRate}x`);
  200. }
  201. // 绑定事件监听器
  202. this.bindAudioEvents(audio, audioType);
  203. // 保存当前音频实例
  204. this.currentAudio = audio;
  205. this.currentAudioType = audioType;
  206. // 延迟播放,确保音频实例完全准备好
  207. setTimeout(() => {
  208. if (this.currentAudio === audio) {
  209. try {
  210. audio.play();
  211. console.log(`${audioType}音频开始播放`);
  212. } catch (playError) {
  213. console.error('❌ 播放命令失败:', playError);
  214. this.emit('error', playError);
  215. }
  216. }
  217. }, 100);
  218. return audio;
  219. } catch (error) {
  220. console.error('❌ 播放音频异常:', error);
  221. this.emit('error', error);
  222. throw error;
  223. }
  224. }
  225. /**
  226. * 绑定音频事件监听器
  227. */
  228. bindAudioEvents(audio, audioType) {
  229. // 播放开始
  230. audio.onPlay(() => {
  231. console.log(`🎵 ${audioType}音频播放开始`);
  232. this.isPlaying = true;
  233. this.emit('play', { audioType, audio });
  234. });
  235. // 播放暂停
  236. audio.onPause(() => {
  237. console.log(`⏸️ ${audioType}音频播放暂停`);
  238. this.isPlaying = false;
  239. this.emit('pause', { audioType, audio });
  240. });
  241. // 播放结束
  242. audio.onEnded(() => {
  243. console.log(`🏁 ${audioType}音频播放结束`);
  244. this.isPlaying = false;
  245. // 不销毁音频实例,保留以供复用
  246. // 只清理当前播放状态
  247. if (this.currentAudio === audio) {
  248. this.currentAudio = null;
  249. this.currentAudioType = null;
  250. }
  251. this.emit('ended', { audioType, audio });
  252. });
  253. // 播放错误
  254. audio.onError((error) => {
  255. console.error(`${audioType}音频播放失败:`, error);
  256. this.isPlaying = false;
  257. // 发生错误时才销毁音频实例
  258. try {
  259. audio.destroy();
  260. } catch (destroyError) {
  261. console.error('⚠️ 销毁音频实例时出错:', destroyError);
  262. }
  263. if (this.currentAudio === audio) {
  264. this.currentAudio = null;
  265. this.currentAudioType = null;
  266. }
  267. // 清理复用的音频实例引用
  268. if (this.audioInstance === audio) {
  269. this.audioInstance = null;
  270. }
  271. this.emit('error', { error, audioType, audio });
  272. });
  273. // 时间更新
  274. audio.onTimeUpdate(() => {
  275. this.emit('timeupdate', {
  276. currentTime: audio.currentTime,
  277. duration: audio.duration,
  278. audioType,
  279. audio
  280. });
  281. });
  282. // 可以播放
  283. audio.onCanplay(() => {
  284. this.emit('canplay', { audioType, audio });
  285. });
  286. }
  287. /**
  288. * 暂停当前音频
  289. */
  290. pause() {
  291. if (this.currentAudio && this.isPlaying) {
  292. this.currentAudio.pause();
  293. }
  294. }
  295. /**
  296. * 恢复播放
  297. */
  298. resume() {
  299. if (this.currentAudio && !this.isPlaying) {
  300. this.currentAudio.play();
  301. }
  302. }
  303. /**
  304. * 设置播放速度
  305. */
  306. setPlaybackRate(rate) {
  307. if (this.currentAudio && this.playbackRateSupported) {
  308. this.currentAudio.playbackRate = rate;
  309. return true;
  310. }
  311. return false;
  312. }
  313. /**
  314. * 设置全局播放速度
  315. * @param {number} rate - 播放速度 (0.5 - 2.0)
  316. */
  317. setGlobalPlaybackRate(rate) {
  318. if (rate < 0.5 || rate > 2.0) {
  319. console.warn('⚠️ 播放速度超出支持范围 (0.5-2.0):', rate);
  320. return false;
  321. }
  322. this.globalPlaybackRate = rate;
  323. console.log(`🎵 设置全局播放速度: ${rate}x`);
  324. // 如果当前有音频在播放,立即应用新的播放速度
  325. if (this.currentAudio && this.playbackRateSupported) {
  326. try {
  327. this.currentAudio.playbackRate = rate;
  328. console.log(`✅ 当前音频播放速度已更新: ${rate}x`);
  329. } catch (error) {
  330. console.error('❌ 更新当前音频播放速度失败:', error);
  331. }
  332. }
  333. return true;
  334. }
  335. /**
  336. * 获取全局播放速度
  337. */
  338. getGlobalPlaybackRate() {
  339. return this.globalPlaybackRate;
  340. }
  341. /**
  342. * 设置全局音色ID
  343. * @param {string|number} voiceId - 音色ID
  344. */
  345. setGlobalVoiceId(voiceId) {
  346. this.globalVoiceId = String(voiceId);
  347. console.log(`🎵 设置全局音色ID: ${this.globalVoiceId}`);
  348. }
  349. /**
  350. * 获取全局音色ID
  351. */
  352. getGlobalVoiceId() {
  353. return this.globalVoiceId;
  354. }
  355. /**
  356. * 获取支持的播放速度选项
  357. */
  358. getSpeedOptions() {
  359. return [...this.speedOptions];
  360. }
  361. /**
  362. * 切换到下一个播放速度
  363. */
  364. togglePlaybackRate() {
  365. const currentIndex = this.speedOptions.indexOf(this.globalPlaybackRate);
  366. const nextIndex = (currentIndex + 1) % this.speedOptions.length;
  367. const nextRate = this.speedOptions[nextIndex];
  368. this.setGlobalPlaybackRate(nextRate);
  369. return nextRate;
  370. }
  371. /**
  372. * 获取当前播放状态
  373. */
  374. getPlaybackState() {
  375. return {
  376. isPlaying: this.isPlaying,
  377. currentAudioType: this.currentAudioType,
  378. hasAudio: !!this.currentAudio,
  379. playbackRateSupported: this.playbackRateSupported,
  380. currentTime: this.currentAudio ? this.currentAudio.currentTime : 0,
  381. duration: this.currentAudio ? this.currentAudio.duration : 0
  382. };
  383. }
  384. /**
  385. * 添加事件监听器
  386. */
  387. on(event, callback) {
  388. if (this.listeners[event]) {
  389. // 检查是否已经绑定过相同的回调函数,避免重复绑定
  390. if (this.listeners[event].indexOf(callback) === -1) {
  391. this.listeners[event].push(callback);
  392. console.log(`🎵 添加事件监听器: ${event}, 当前监听器数量: ${this.listeners[event].length}`);
  393. } else {
  394. console.warn(`⚠️ 事件监听器已存在,跳过重复绑定: ${event}`);
  395. }
  396. }
  397. }
  398. /**
  399. * 移除事件监听器
  400. */
  401. off(event, callback) {
  402. if (this.listeners[event]) {
  403. const index = this.listeners[event].indexOf(callback);
  404. if (index > -1) {
  405. this.listeners[event].splice(index, 1);
  406. }
  407. }
  408. }
  409. /**
  410. * 触发事件
  411. */
  412. emit(event, data) {
  413. if (this.listeners[event]) {
  414. this.listeners[event].forEach(callback => {
  415. try {
  416. callback(data);
  417. } catch (error) {
  418. console.error(`事件监听器执行错误 (${event}):`, error);
  419. }
  420. });
  421. }
  422. }
  423. /**
  424. * 销毁音频管理器
  425. */
  426. destroy() {
  427. this.stopCurrentAudio();
  428. // 销毁复用的音频实例
  429. // if (this.audioInstance) {
  430. // try {
  431. // this.audioInstance.destroy();
  432. // } catch (error) {
  433. // console.error('⚠️ 销毁音频实例时出错:', error);
  434. // }
  435. // this.audioInstance = null;
  436. // }
  437. this.listeners = {
  438. play: [],
  439. pause: [],
  440. ended: [],
  441. error: [],
  442. timeupdate: [],
  443. canplay: []
  444. };
  445. }
  446. }
  447. // 创建全局单例
  448. const audioManager = new AudioManager();
  449. export default audioManager;