Asterisk 14 ARI: Media Playlists

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:

  1. Play back a sequence of media URIs, starting each of the subsequent URIs when each of the previous URIs finishes.
  2. 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

Leave a Reply

Your email address will not be published. Required fields are marked *


The reCAPTCHA verification period has expired. Please reload the page.

About the Author

What can we help you find?