View Sidebar

A Million Little Pieces Of My Mind

Step 4: Promises, Promises

By: Paul S. Cilwa Viewed: 4/19/2024
Posted: 11/17/2017
Page Views: 717
Topics: #Computers #Programming #Projects #WebAudioAPI #JavaScript #MusicPlayer #Cross-fadingMusicPlayer #OrganicaAudio
Wrapping JavaScript promises around asynchronous operations.

Because Javascript is an odd combination of synchronous and asynchronous activities—you want things to happen, but you must also be responsive to further user input—it has long needed a mechanism to allow asynchronous operations to complete before continuing to the next operation. The mechanism to accomplish this is called Promises and it is newly implemented in Javascript in all current browsers.

A "promise" occurs when you ask something asynchronous to happen. That "something" may succeed, but it could also fail; and the success or failure won't happen until sometime later, when the operation completes.

The magic of a Promise is that it includes a then() function that will eventually be excuted when/if the operation succeeds. If it fails, there is a catch() function that will be called instead. You can provide either, both, or none of those functions (although there's little point to a promise that doesn't know when it has been fulfilled).

That allows you to write code that sets several operations in motion, then waits for some or all of them to complete before executing the next step.

A Promise is a Javascript object type. At its simplest, it includes code that might succeed or fail.

var MyPromise = new Promise(function (Resolve, Reject)
	{
	DoSomethingAsynchronous(function (Result)
		{
		if (Result)
			Resolve (Result);
		else
			Reject ("Back to the drawing board!");
		});
	}

A major feature of JavaScript is that of "callback functions" They are used everywhere as a means to return complex values to the original called. In the above example, the call to DoSomethingAsynchronous() will return immediately; but sometime later, when the asynchronous operation has completed, the nested callback function will be called.

To use a Promise, just provide either then() or catch() sometime afterwards.

MyPromise().then(function(Result) 
	{ 
	console.log ("It worked!"); 
	});

console.log("Will display BEFORE MyPromise completes. Probably.");

or

function Celebrate()
	{
	console.log ("It worked!");
	}

MyPromise().then(Celebrate);

Now, the first two asynchronous operations we'll need to perform are:

  1. Load the physical music file into memory.

  2. Encode the file so it can be played.

The typical way to accomplish this is with nested callback functions. Unfortunately, these can be very hard to follow or debug. Nevertheless, that's how we're going to do it so let's write the outer part of the code first, then add the nested functions.

We need a Load() function that will request the music file be loaded into memory. The function needs to be part of the OrganicaAudioTrack class; if it succeeds, it will use the result of the function to encode the audio file. Here's the basic framework, minus the part that gets called when the operation is completed.

OrganicaAudioTrack.prototype.Load = function()
	{
	console.log("Load");
	
	return new Promise(function(Resolve, Reject)
		{
		console.log ("Creating a Promise...");
		var Request = new XMLHttpRequest();
		Request.open("GET", this.Filename, true);
		Request.responseType = "arraybuffer";
		Request.send();
		console.log("Request sent...");
		});
	};

So, what we have is a function that returns a Promise; the Promise itself, when invoked, will call the asynchronous XMLHttpRequest function. However, there's not yet any code to do something when that operation has completed.

Let's take the easiest result first: Suppose the operation fails? the XMLHttpRequest object Request has an onerror property that points to a function to be called if the operation fails. We can provide that function (which should be defined before invoking the send() method).

OrganicaAudioTrack.prototype.Load = function()
	{
	console.log("Load");
	
	return new Promise(function(Resolve, Reject)
		{
		console.log ("Creating a Promise...");
		var Request = new XMLHttpRequest();
		Request.open("GET", this.Filename, true);
		Request.responseType = "arraybuffer";
		Request.onerror = function()
			{
			Reject('OrganicaAudioTrack: Load XHR error');
			}
		Request.send();
		console.log("Request sent...");
		});
	};

So far, so good. Our Promise can complete with failure, if indeed a failure occurs. But, hopefully, that will be a rare occurrence.

So what do we do in case of success? Well, the result of the file load isn't directly useful to us. It needs to be encoded by the Web Audio API, another asynchronous operation, which means another callback function.

OrganicaAudioTrack.prototype.Load = function()
	{
	console.log("Load");
	
	return new Promise(function(Resolve, Reject)
		{
		console.log ("Creating a Promise...");
		var Request = new XMLHttpRequest();
		Request.open("GET", this.Filename, true);
		Request.responseType = "arraybuffer";
		Request.onload = function()
			{
			if (Request.response)
				{
				console.log("Request completed");
				}
			else
				Reject("Disaster! " + Me.Filename);
			}
		Request.onerror = function()
			{
			Reject('OrganicaAudioTrack: Load XHR error');
			}
		Request.send();
		console.log("Request sent...");
		});
	};

Now, you might have noticed two things:

  1. We don't actually do anything with the request result.

  2. We call Reject() if it fails, but do not call Resolve() if it succeeds.

The thing is, now that we've loaded the raw file data, we must convert it into a format Web Audio API can use…and that is also an asynchronous call! Which means another, nested, callback function!

However, we will need to access object properties in that nested function, and here's a JavaScript quirk: this doesn't always mean what you think it does, especially in nested functions. Since we can't rely on it, we need to store the object's identity outside of the nested function for use in it:

OrganicaAudioTrack.prototype.Load = function()
	{
	console.log("Load");
	
	var Me = this;
	Me.Loading = true;
	
	...

In this snippet, this.Loading = true; and Me.Loading = true; are exactly equivalent. But that gives us a way to send the object's identity to the nested function.

OrganicaAudioTrack.prototype.Load = function()
	{
	console.log("Load");
	
	var Me = this;
	Me.Loading = true;
	
	return new Promise(function(Resolve, Reject)
		{
		console.log ("Creating a Promise...");
		var Request = new XMLHttpRequest();
		Request.open("GET", this.Filename, true);
		Request.responseType = "arraybuffer";
		Request.onload = function()
			{
			if (Request.response)
				{
				console.log("Request completed");
				Me.Context.decodeAudioData(Request.response, 
					function (Result)
						{
						console.log("Decoded...");
						Me.SoundSource = Me.Context.createBufferSource();
						Me.SoundSource.buffer = Result;
						Me.Duration = Result.duration;
						Me.Loading = false;
						Me.Loaded = true;
						Resolve(Me);
						},
					function ()
						{
						Reject(Me.Filename);
						});
				}
			else
				Reject("Disaster! " + Me.Filename);
			}
		Request.onerror = function()
			{
			Reject('OrganicaAudioTrack: Load XHR error');
			}
		Request.send();
		console.log("Request sent...");
		});
	};

So: After the asynchronous fetch is complete, we'll pass that on to the Web Audio API for decoding. This is also an asynchronous operation; but, when it completes, we'll be able to save the actual Web Audio API sound source as another object property.

Did you see how I also copy from the buffer the duration of the piece? I could have kept it where it was, but this will let us access the value with simplified syntax. Please note: The duration of a music piece cannot be guessed by the Web Audio API; the file must be decoded, and then the duration is available. (This is a requirement because there are many audio formats the Web Audio API will work with; and some include variable bit rate variants, which makes guessing duration from the file size alone inadequate.

So now we are ready to add a consumer for the Load() promise. This is the function that will be called in order to actually play the track:

OrganicaAudioTrack.prototype.Play = function(StartTime)
	{
	var Me = this;
	if (isNaN(StartTime))
		StartTime = 0;
	}

Obviously, this doesn't yet do any actual work. The Me variable is there to assist with a callback function, as shown previously. (We haven't put that function in, yet.) The argument to the function, StartTime, is optional so we can start playing a track at a future time, if we wish. A StartTime of zero (will start playing immediately, so if the argument wasn't supplied we set it to zero for that result.

Now, in the Load() function, when the asynchronous operations have all completed, the object property Loaded is set to true. We can't actually play the file until it's loaded, obviously; so we can test for that:

OrganicaAudioTrack.prototype.Play = function(StartTime)
	{
	var Me = this;
	
	if (isNaN(StartTime))
		StartTime = 0;
		
	if (! Me.Loaded)
		{
		Me.Load().then(function(Me) 
			{
			Me.Play(StartTime);
			});
		return;
		}
	}

But wait…did I actually call the Play() method from within the Play() method? Why yes; yes, I did. That's called a recursive function. You just have to be careful to not make recursive calls indefinitely.

OrganicaAudioTrack.prototype.Play = function(StartTime)
	{
	var Me = this;
	
	if (isNaN(StartTime))
		StartTime = 0;
		
	if (! Me.Loaded)
		{
		Me.Load().then(function(Me)
			{
			Me.Play(StartTime);
			 });
		return;
		}
	else
		{
		//Actually play the damned thing...
		Me.SoundSource.onended = function()
			{
			console.log("Ended!");
			Me.Playing = false;
			};
		Me.SoundSource.connect(Me.Context.destination);
		Me.Playing = true;
		Me.SoundSource.start(StartTime + Me.Context.currentTime);
		}
	}

At this point, we have something we can test. So open up the AudioTest() web page file and add the following:

<!DOCTYPE html>
<html>

<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<title>Organica Audio Test</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src='OrganicaAudio.js'></script>
</head>

<body style='background-color: aquamarine; text-align: center'>
<h1>Let's test the Web Audio API!</h1>

<audio controls id="Song1">
	<source src="COBOLin'.mp3">
	Your browser does not support the audio element.
</audio>

<script>
$(document).ready(function()
	{
	var T = new OrganicaAudioTrack("COBOLin'.mp3");
	T.Play();
	});
</script>
</body>
</html>

Save the page, drag the file onto Firefox, click the button—and you should hear music!

Try it! —and then we'll be ready to start working with a Playlist!

  1. Load the physical music file into memory.

  2. Encode the file so it can be played.

The typical way to accomplish this is with nested callback functions. Unfortunately, these can be very hard to follow or debug. Nevertheless, that's how we're going to do it so let's write the outer part of the code first, then add the nested functions.