Introduction
A-frame sound
audio wraps the three.js positional audio API, which in turns wraps native html 5 audio. Most solutions out there are tailored for either pure html5 or for pure three.js. Since A-frame is a hybrid of the two apis, none of the provided solution are great fits for A-frame.
After two false starts at coming up with something, I disovered tween.js, which is not only built-in to A-frame (don't even have to download the library), but is also a useful API to know for other forms of computer animation. I provide the main solution here as well as a plunker in the hopes that others can find something useful.
Note that you don't need to do this for short burst sounds like bullets firing. These sounds have a fixed lifetime, so presumably whoever creates the waveform makes sure to taper them in and out. Also, I only deal with fade out, not fade in becuase the sound I needed only had problems with fadeout. A general solution would include fadein as well.
Solution
1) We start off with creating a real basic scene onto which we can our audio:
<a-scene>
<a-assets>
<audio id="space-rumble" src="https://raw.githubusercontent.com/vt5491/public/master/assets/sounds/space-rumble.ogg" type="audio/ogg"></audio>
crossorigin="anonymous"
type="audio/ogg"></audio>
</a-assets>
<a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9"
sound="src: #space-rumble; volume: 0.9"
></a-box>
</a-scene>
The cube and scene in this solution are really just placeholders -- you don't need to enter VR mode to click the buttons and test the sound.
2) The code presents three buttons: one to start the sound, one to "hard" stop it using the A-frame default, and a third to "easy" stop it using tween to taper it down to zero. A fourth input allows you to vary the taper time. While it might look like quite a bit of code, keep in mind about 50% is just html boilerplate for the buttons, and is not part of the solution "proper":
// created 2017-10-04
function init() {
let main = new Main();
}
function Main() {
let factory = {};
console.log("entered main");
factory.boxEntity = document.querySelector('a-box');
factory.sound = factory.boxEntity.components.sound;
factory.volume = {vol: factory.sound.data.volume};
factory.boxEntity.addEventListener('sound-loaded', ()=> {console.log('sound loaded')});
factory.startBtn =document.querySelector('#btn-start');
factory.startBtn.onclick = ( function() {
this.sound.stopSound();
let initVol = factory.sound.data.volume;
this.volume = {vol: initVol}; //need to do this every time
this.sound.pool.children[0].setVolume(initVol);
console.log(`onClick: volume=${this.sound.pool.children[0].getVolume()}`);
this.sound.currentTime = 0.0;
if( this.tween) {
this.tween.stop();
}
this.sound.playSound();
}).bind(factory);
factory.hardStopBtn =document.querySelector('#btn-hard-stop');
factory.hardStopBtn.onclick = (function() {
this.sound.stopSound();
}).bind(factory);
factory.easyStopBtn =document.querySelector('#btn-easy-stop');
factory.easyStopBtn.onclick = (function() {
let sound = factory.sound;
this.tween = new TWEEN.Tween(this.volume);
this.tween.to(
{vol: 0.0}
, document.querySelector('#fade-out-duration').value);
this.tween.onUpdate(function(obj) {
console.log(`onUpdate: this.vol=${this.vol}`);
sound.pool.children[0].setVolume(this.vol);
console.log(`onUpdate: pool.children[0].getVolume=${sound.pool.children[0].getVolume()}`);
});
// Note: do *not* bind to parent context as tween passes it's info via 'this'
// and not just via callback parms.
// .bind(factory));
this.tween.onComplete(function() {
sound.stopSound();
console.log(`tween is done`);
});
this.tween.start();
// animate is actually optional in this case. Tween will count down on it's
// own clock, but you might want to synchronize with your other updates. If this
// is an a-frame component, then you can just use the 'tick' method.
this.animate();
}).bind(factory);
factory.animate = () => {
let id = requestAnimationFrame(factory.animate);
console.log(`now in animate`);
let result = TWEEN.update();
// cancelAnimationFrame is optional. You might want to invoke this to avoid
// the overhead of repeated animation calls. If you are putting this in an
// a-frame 'tick' callback, and there's other tick activity, you
// don't want to call this.
if(!result) cancelAnimationFrame(id);
}
return factory;
}
Analysis
Here are some relevant items to be aware of.
Mixed API's
I am calling some native A-frame level calls:
sound.playSound()
sound.stopSound()
and one html5 level call:
this.sound.currentTime = 0.0;
but most of the "work" is in three.js level calls:
this.sound.pool.children[0].setVolume(initVol);
This does make it a little confusing, but no single api is "complete" and thus I had to use all three. In particular, we have to do a lot at the level that is wrapped by A-frame. I learned most of this by looking at the aframe source for the sound component
Sound Pools
Aframe allows multiple threads for each sound, so that you can have the same sound fire off before the prior one has completed. This is controlled by the poolSize
property on the sound component. I'm only dealing with the first sound. I should probably loop over the pool elements like so:
this.pool.children.forEach(function (sound) {
..do stuff
}
});
But doing the first one has worked well enough so far. Time will tell if this is sustainable.
'this' binding
I chose to implement all the functionality using a factory object pattern, and not placing all the methods and variables in the global document space. This mimics the enviornment you would have if you're implementing in Angular2 or as a native A-frame component. I mention this because we now have callbacks nested inside function nested inside a wrapping "main" function. Thus be aware that "this" binding can come into play. I bound most of the support functions to the factory object, but do not bind the tween callbacks, as they are passed information in their "this" context, and not passed via parms. I had to resort to closures for the callbacks to get access to the instances variables of the containing class. This is just standard javascript "callback hell" stuff, but just keep in mind it can get confusing if you're not careful.
canceled animation
If you have a tick function already, use that to call TWEEN.update()
. If you're only fading out sound, then it's overkill to have an animation loop running all the time, so in this example I dynamically start and stop the animation loop.
tween can be chained.
Tweens can be chained in jquery fluent API style as well.
Conclusion
Using tween.js to phase out the sound definitely feels like the right solution. It takes care of a lot of the overhead, and design considerations. It also feels much faster, smoother, and robust than the native html5 calls I previously used. However, it's pretty obvious that it's not trivial to get this working at the application level. A fadeout property, implemented in Tween.js, seems like it should be part of the A-frame sound component itself. But until that time, maybe some people will find some of what I provide here useful in some form. I'm only currently learning about html audio myself so apologies if I'm making this seem harder than it really is.