Several improvements to ARI’s media manipulation are coming in Asterisk 14. In this post, we’ll explore media playlists, how they are used, and the problem they solve.
A Simple Application
It’s fairly common in Asterisk to construct complex sound prompts from multiple simpler sound files. Say, for example, we wanted to emulate the simple IVR menu from this dialplan example on the Asterisk wiki:
[demo-menu] exten => s,1,Answer(500) same => n(loop),Background(press-1&or&press-2) same => n,WaitExten()
To emulate this in ARI, we’d need to do the following:
- Play back a sequence of media URIs, starting each of the subsequent URIs when each of the previous URIs finishes.
- When a DTMF key is pressed during any of the sound files, cancel the current media being played and break out of the sequence.
Using node-ari-client, we could write this in JavaScript in NodeJS. Before we complicate our lives with a list of media URIs, let’s start with a simple skeleton application that plays back a single media file (demo-congrats ) and that stops the playback if 1 or 2 is pressed:
const ari = require('ari-client'); // Replace the address/credentials with your ARI application ari.connect('http://localhost:8088', 'user', 'secret') .then((client) => { client.once('StasisStart', (event, incoming) => { incoming.answer() .then(() => { const playback = client.Playback(); incoming.on('ChannelDtmfReceived', (dtmfEvent) => { const digit = dtmfEvent.digit; switch (digit) { case '1': console.log('User pressed 1'); playback.stop(); // Execute menu option 1 break; case '2': console.log('User pressed 2'); playback.stop(); // Execute menu option 2 break; default: console.log('Unknown DTMF digit: %s', digit); } }); return incoming.play( { media: 'sound:demo-congrats' }, playback); }); }); client.start('playlist-example'); });
So what does this look like if we wanted to replace demo-congrats with the sound files press-1 , or , and press-2 ?
Building a Media State Machine
Unlike the dialplan, ARI in versions of Asterisk prior to 14 do not expose a mechanism to tell Asterisk to ‘chain’ the sound files together. This means that we have to create the chain of sound files ourselves. Starting with the sample application above, we can replace demo-congrats with a list of medias to play back:
const medias = ['sound:press-1', 'sound:or', 'sound:press-2']; medias.forEach((media) => { const playback = client.Playback();
Inside our forEach loop, we can instruct ARI to play back the current media item on the incoming channel:
return incoming.play( { media }, playback);
Easy, right?
Not so fast!
When we issue play on incoming , the call does not block until the sound file has completed. Instead, the above code will loop and immediately call play for each media in medias in succession on the incoming channel. ARI does a “nice” thing here and queues up the sound files for us. However, consider our DTMF handler:
const medias = ['sound:press-1', 'sound:or', 'sound:press-2']; medias.forEach((media) => { const playback = client.Playback(); incoming.on('ChannelDtmfReceived', (dtmfEvent) => { const digit = dtmfEvent.digit; switch (digit) { case '1': console.log('User pressed 1'); playback.stop(); break; case '2': console.log('User pressed 2'); playback.stop(); break; default: console.log('Unknown DTMF digit: %s', digit); } }); return incoming.play( { media }, playback); });
When someone presses 1 and fires our ChannelDtmfReceived handler, what Playback object does playback reference? The answer is: all three! This is a subtle bug in the code above that is hard to see or recognize unless you’re very familiar with the node-ari client. Because we are registering for the ChannelDtmfReceived event in a loop, we are actually placing three event handlers onto the incoming channel! When someone presses a digit, all three event handlers will fire, each of which references one of the Playback objects (press-1 , or , and press-2 ). We will then attempt to stop all three, where only one out of the three will be in the correct state. This can result in a lot of errors being fired in addition to the sound file not actually being stopped correctly! Clearly we need to be a bit more careful than what we’ve written above.
First, let’s pull that event handler out of the loop. Because the ChannelDtmfReceived event handler will need to use the current playback object, we’ll pull that out as well, leaving it as undefined for now:
const medias = ['sound:press-1', 'sound:or', 'sound:press-2']; let playback; incoming.on('ChannelDtmfReceived', (dtmfEvent) => { const digit = dtmfEvent.digit; if (!playback) { return; } switch (digit) { case '1': console.log('User pressed 1'); playback.stop(); break; case '2': console.log('User pressed 2'); playback.stop(); break; default: console.log('Unknown DTMF digit: %s', digit); } });
Now we have to make it so that playback always references the current playing sound. To do that, we’ll need some extra events:
medias.forEach((media) => { playback = client.Playback(); playback.once('PlaybackStarted', (_, _playback) => { playback = _playback; }); playback.once('PlaybackFinished', (_, _playback) => { playback = null; }); return incoming.play( { media }, playback); });
This will mostly work (which is almost as good as working correctly, but not quite). It will cause us to have a single DTMF event handler, which will stop the correct playback object when a DTMF is pressed. Unfortunately, it will also leave all of the other playback objects still queued up to be played within ARI. So, if a user presses 1 or 2 during press-1 , it will absolutely stop the playback of press-1 – but will still playback or and press-2 . Nuts!
At this point, we really need to abandon our forEach loop. Instead of looping immediately over all of the medias entries, we need to just issue a play for the current media item, and only start the next media item when the previous is finished. We’ll also need to instruct our state changes to stop processing sounds in the medias list if a DTMF key has been pressed.
An implementation that meets these requirements might look like the following:
incoming.answer() .then(() => { const medias = ['sound:press-1', 'sound:or', 'sound:press-2']; let playback; let i = 0; incoming.on('ChannelDtmfReceived', (dtmfEvent) => { const digit = dtmfEvent.digit; if (!playback) { return; } switch (digit) { case '1': console.log('User pressed 1'); i = medias.length; playback.stop(); break; case '2': console.log('User pressed 2'); i = medias.length; playback.stop(); break; default: console.log('Unknown DTMF digit: %s', digit); } }); client.on('PlaybackStarted', (_, _playback) => { playback = _playback; }); client.on('PlaybackFinished', (playbackEvent, _playback) => { playback = null; i += 1; if (i < medias.length) { incoming.play({ media: medias[i] }); } }); return incoming.play({ media: medias[0] }); }); });
All of this is a bit messy, and – as you can tell from the above process – very easy to get wrong! Since Asterisk already has the ability internally to maintain the state in a list of media files during channel playback, why not just use that?
Media Playlists
Coming soon in Asterisk 14, we can bypass all of the state machine work we just went through and simply instruct Asterisk to play a list of media URIs in a single play command. The media URIs will be played back sequentially in order; if a stop command is issued, the entire playlist is stopped. If we wanted to know more about the media URIs being played back, a new event – PlaybackContinuing – is issued as the media URIs transition during the list playback. That allows us to write much simpler code in our external application:
client.once('StasisStart', (event, incoming) => { incoming.answer() .then(() => { const medias = ['sound:press-1', 'sound:or', 'sound:press-2']; const playback = client.Playback(); incoming.on('ChannelDtmfReceived', (dtmfEvent) => { const digit = dtmfEvent.digit; switch (digit) { case '1': console.log('User pressed 1'); playback.stop(); break; case '2': console.log('User pressed 2'); playback.stop(); break; default: console.log('Unknown DTMF digit: %s', digit); } }); return incoming.play({ media: medias }, playback); }); });
The above code is much cleaner, and allows us to more easily handle a very common use case for Asterisk application developers.
Be sure to check out what else is coming in Asterisk 14 – and happy coding!
Note: When I wrote this blog post, I ran into that great error that so many developers stumble into. While I had written several tests for this feature, I had not written a test for what is probably the most obvious use case – the one described in this blog post, wherein a list is stopped mid playback. And, as is so often the case with code that doesn’t have a test, there was a bug. Yikes. Luckily writing this helped to catch the problem, which will be fixed before the first release candidate of Asterisk 14.0.0. Always write tests!
2 Responses
how can i play audio files using uri as http://server:port/audo/audio1.wav
Hey Luis,
Did you find the way to do that