howler.js是一款非常强大的声音引擎,功能强大,简单使用,性能良好,并且支持多种声音格式(如MP3、WEBM、MPEG、MP4等),多应用在各个项目中。
背景
近期协助开发了一个移动端游戏页面,需要增加音频以及音效,需求如下:
-
用户进入首页需要播放音乐,当用户没有关闭音乐,首页音乐要循环播放;
-
用户在碰撞到部分物品产生碰撞音效,且碰撞一次音效就需要播放一次。
从技术选型上自然而然选择了howler作为音频音效的主力引擎,应用到页面开发。
首页音乐循环播放的解决方案
参考官网示例,代码如下:
import {Howl} from 'howler';
var sound = new Howl({
src: ['sound.mp3'],
loop: true,
html5: true,
})
// 寻找时机触发播放
sound.play();
通过简单配置就可实现音乐循环播放。但是在实际测试过程中,却发现有部分机型在无任何用户操作行为下,播放音乐一段时间后,声音会莫名消失,再间隔一段时间后,音乐又会继续播放。
出现这个问题,第一时间思考的是
- 声音为何会消失?
- 以什么样的形式消失(播放失败、静音、暂停、音量减少了)?
- 为何会间隔一段时间才会继续播放?
针对以上问题,我就开始查找相关文档,在该段音频上注册了n个监听事件后,等待音乐循环播放的时机,以期待能够发现真正的问题原因是什么。
import {Howl} from 'howler';
var sound = new Howl({
src: ['sound.mp3'],
loop: true,
html5: true,
})
sound.play();
// 播放监听事件
sound.on('play', function () {
console.log('音乐播放了');
})
// 播放失败监听
sound.on('playerror', function () {
console.log('音乐播放失败');
})
// 静音监听
sound.on('mute', function () {
console.log('音乐静音了');
})
// 暂停监听
sound.on('pause', function () {
console.log('音乐暂停了');
})
// 音量改变监听
sound.on('volume', function() {
console.log('音乐的音量改变了')
})
// 音频结束监听
sound.on('end', function () {
console.log('音乐播放停止')
})
自测过程中,我发现页面播放音乐过程中,先打印了音乐播放停止后,后重新打印了音乐播放了.随后重复触发了play和end的事件,但是其他事件均未触发。且问题机型音乐多半会一次有声音一次无声音,时间大概是音频的一次播放时间,每次有声的音频还都可以重新正常播放。
为了获取到具体音频时间,查看了下API,我在play的事件里面增加了对音频播放时长的获取。
sound.on('play', function (id) {
console.log('音乐播放了',id);
const duration = sound.duration(id);
console.log(duration, 'duration');
})
继续自测,结论依然如此,没发现其他现象。我之前在使用howler.js时,就发现使用howler有一个很头疼的问题:
一段音乐正在播放,如果重复触发音乐的播放,音乐会有一定机率出现无声。
针对无声的解决方案,查询了一下网上结论,如:
若想重复触发音乐,且完美实现音乐无异常播放,建议先将先前的音乐实例销毁再重新创建继而播放,这样在播放的时候就可以正常放声了。
但是这里需要注意,音乐实例销毁是会直接打断当前音乐的播放进程的。如果音乐播放到一定阶段(没有完全播放完毕),销毁实例再重新创建播放,一段不完整的音乐就会立马出现,且如果音乐很短时,甚至会出现所谓的“杂音”,所以音乐的播放进度也是一个非常重要的关切点,毕竟没有人愿意听一段不完美的音乐。
简单查看上述代码,在创建音乐实例的时候,仅有loop属性是涉及音乐循环的,自然就成为“怀疑对象”。所以就想看看howler是如何实现音频的循环播放的。
loop: function() {
var self = this;
var args = arguments;
var loop, id, sound;
// Determine the values for loop and id.
if (args.length === 0) {
// Return the grou's loop value.
return self._loop;
} else if (args.length === 1) {
if (typeof args[0] === 'boolean') {
loop = args[0];
self._loop = loop;
} else {
// Return this sound's loop value.
sound = self._soundById(parseInt(args[0], 10));
return sound ? sound._loop : false;
}
} else if (args.length === 2) {
loop = args[0];
id = parseInt(args[1], 10);
}
// If no id is passed, get all ID's to be looped.
var ids = self._getSoundIds(id);
for (var i=0; i<ids.length; i++) {
sound = self._soundById(ids[i]);
if (sound) {
sound._loop = loop;
if (self._webAudio && sound._node && sound._node.bufferSource) {
sound._node.bufferSource.loop = loop;
if (loop) {
sound._node.bufferSource.loopStart = sound._start || 0;
sound._node.bufferSource.loopEnd = sound._stop;
// If playing, restart playback to ensure looping updates.
if (self.playing(ids[i])) {
self.pause(ids[i], true);
self.play(ids[i], true);
}
}
}
}
}
return self;
}
查看了下源码,发现howler在实现循环播放时,若音乐正在播放,会先暂停了音乐,后又触发播放方法。所以失声的原因,很可能是暂停音乐和播放音乐的时机导致部分机型音乐失声。
既然依赖简单配置loop会出现问题,那么可以人为地实现音乐的循环播放。同时为了避免该音乐在使用过程中还会有失声的情况,又在音乐播放结束后,选择先将其实例进行销毁且重新创建,继而触发音频的播放。代码示意如下:
import {Howl} from 'howler';
// 创建音频实例
var sound = new Howl({
src: ['sound.mp3'],
// loop: true,
html5: true,
})
sound.play();
// 音频结束监听
sound.on('end', function () {
console.log('音乐播放停止了')
// 销毁实例
sound.unload();
sound = null;
// 设置定时器延迟创建实例的时机
setTimeout(()=> {
sound = new Howl({
src: ['sound.mp3'],
// loop: true,
html5: true,
})
sound.play();
})
})
就这样通过监听音乐的播放结束事件,在结束后,销毁实例并重新创建,调用播放方法,解决了首页音乐的失声问题。
撞击音频的解决方案
刚才提到了一个问题,那就是如果当前音频正在播放,一旦重复触发该音频的声音播放,那么当前的音频大概率是无声的。所以如果在玩游戏过程中,多次碰撞产生音效,无声这个问题还是绕不开。
按照上面代码思路,直接使用销毁再重新创建的方法,因为高频率触发事件,可能音乐没等播放完,实例就销毁了,反复多次,不仅音乐无法正常播放,还会出现“群音乱舞”。所以,为了尽可能让音乐正常播放完毕,尽可能避免播放一半就销毁的场景,我想到的方案就是:
先将音频的时间缩短,让音频在还没触发前尽可能播放完,触发频率能控制在音频时间以内最好,这样不妨碍音频的正常播放。
判断当前音乐是否播放,减少高频触发条件。如果没播放,音频播放。如果音频播放着,就不作处理了。从侧面减少触发次数,不去打断音频的播放,让音频一次播放完。
在音频播放完毕后,销毁掉实例,待下次播放时,再手动创建实例。
import {Howl} from 'howler';
var sound = new Howl({
src: ['sound.mp3'], // 1. 缩短相应的音频时长
html5: true,
});
// 2.判断当前音频是否播放
const isPlaying = sound.isPlaying;
if(!isPlaying) {
sound.play();
}
sound.on('end', function () {
// 3. 销毁实例
sound.unload();
sound = null;
})
看不出一点毛病,哈哈哈,感觉离“成功”又进了一步。部署上线后,重新测试,发现依然会有这个问题:短时间内碰撞产生第一段音频,第一段音频看似播放完了,在相隔很短时间再次碰撞触发音频,音频还是会“失声”。这个“失声”貌似真的无法绕开了!
这时候,测试过程中突然发现一个业务场景:同时触发两段不同音频,音频是不会干扰的!
import {Howl} from 'howler';
// 第一段音频
var sound = new Howl({
src: ['sound-a.mp3'],
html5: true,
});
// 第二段音频
var sound1 = new Howl({
src: ['sound-b.mp3'],
html5: true,
});
// 同时播放两段不同的音频,音频播放正常
sound.play();
sound1.play();
既然两段不同的音频同时触发没毛病,那么相同的音频又该如何触发呢?
首先创建两个变量,接入相同的音频。
然后判断第一段音频没有播放,就播放第一段音频;如果第一段音频没有播放,那么播放第二段音频。
同时,为了防止失声的情况,还将销毁创建逻辑一并加上了。
import {Howl} from 'howler';
// 第一段音频
var sound = new Howl({
src: ['sound.mp3'],
html5: true,
});
// 第二段音频
var sound1 = new Howl({
src: ['sound.mp3'],
html5: true,
});
// 判断第一段音频是否播放
const isPlaying = sound.isPlaying;
if(!isPlaying) {
sound.play();
} else {
// 判断当前第二段音频是否播放
const isPlaying = sound1.isPlaying;
if(!isPlaying) {
sound1.play();
}
}
sound.on('end', function () {
console.log('sound播放结束');
// 销毁实例
sound.unload();
sound = null;
})
sound1.on('end', function () {
console.log('sound1播放结束');
// 销毁实例
sound1.unload();
sound1 = null;
})
简单跑了一下,音频暂时播放正常,随后就部署上线测试了。结果没过一会儿,测试反馈: 在玩了几次游戏后,页面会很卡,游戏体验降至“最差“。
反复捉摸上面的代码,音频时间、音频状态判断以及销毁都做了,不仅没有改好原来的问题,还让游戏崩溃了。排查卡顿原因可能是高频碰撞生成音频,不断创建了新的变量,变量不能及时清空导致内存不断增加,页面卡顿。既然不能一直销毁重建,那就暂时先将音频销毁的流程取消试试,看看后续结果,自己也是听天由命了,默默做好了“投降”的准备。
import {Howl} from 'howler';
// 第一段音频
var sound = new Howl({
src: ['sound.mp3'],
html5: true,
});
// 第二段音频
var sound1 = new Howl({
src: ['sound.mp3'],
html5: true,
});
// 判断第一段音频是否播放
const isPlaying = sound.isPlaying;
if(!isPlaying) {
sound.play();
} else {
// 判断当前第二段音频是否播放
const isPlaying = sound1.isPlaying;
if(!isPlaying) {
sound1.play();
}
}
就这样,两段相同的音频没有重复创建销毁,只是在循环播放。触发播放方法时,优先播放第一段音频,如果第一段音频播放着,那就播放第二段音频。两段音频交替播放,有着足够的时间去缓冲。
亲自测试后,卡顿的问题不再发生了,同时在大部分情况下,音频也无失声的风险。卡顿现象消失了,音乐正常播放了,暂时无复现失声的情况,同时满足了产品的需求了,自己也深深的叹了一口气。
自此,解决howler连续播放失声的问题,有了三个参考方向:
-
在保证音频音质的情况下减少音频的时长;
-
低频触发音频的时候可以考虑创建销毁再播放;
-
高频触发播放使用双音频交替方式。
本次梳理也算是告一段落,真的很庆幸自己一直在很努力尝试的解决这个”失声“的问题。
题外话
第一次写问题分享,写的不好,望大佬们批评指正。如果有额外的经验,能够保证音频在高频触发时正常播放,可以交流下,感恩~~~~!