Self Service Using Audio Files

In this tutorial, we will guide you through implementing a basic self service IVR application with FreeClimb that utilizes user DTMF (e.g. keypad) and voice input. This system will perform the following actions:

  • Receive an incoming call via a FreeClimb application
  • Get DTMF (e.g. keypad) or speech input from a user
  • Redirect a user to the appropriate endpoint
  • Play back pre-recorded audio files
  • Return information to the user based on previous input

This self service tutorial relies on the Play PerCL command for IVR content. To build this tutorial using the Say PerCL command instead, try the Self Service Using Text-to-Speech tutorial.

You can also find the code for this sample app on GitHub

👍

You're ready for this tutorial if you have:

Followed the IVR sample app set-up instructions


Step 1: Add audio files

Throughout this tutorial we will be making use of the Play PerCL command, which plays back pre-recorded audio files as opposed to using the text-to-speech technology of our Say PerCL command.

We have created a folder of audio files to use for the purpose of this tutorial. You can download the folder directly here or access it via this sample app's Github repo.

🚧

If you download audio file using either of the options above, make sure to rename the downloaded audio file folder audioFiles and add it to the main directory of your project. This is the file name the tutorial will be referencing in the sample code provided below.

You can also add your own audio files if you so choose. Read more about the audio FreeClimb supports here.


Step 2: Create the express server

The server will provide endpoints where user input can be captured. Create an index.js
file in your project directory, import the needed dependencies, and create/configure the Express application:

require('dotenv-safe').config()
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
const freeclimbSDK = require('@freeclimb/sdk')

const port = process.env.PORT || 3000

Step 3: Handle an incoming call

The first step in our call routing application will be to handle incoming calls. To do this, add the following to index.js:

const host = process.env.HOST
const accountId = process.env.ACCOUNT_ID
const apiKey = process.env.API_KEY
const freeclimb = freeclimbSDK(accountId, apiKey)

app.post('/incomingCall', (req, res) => {
    res.status(200).json(
        freeclimb.percl.build(
            freeclimb.percl.play(`${host}/indexAudio?audio=greeting.wav`), // this code is based on audio we supply in this tutorial's github repo. Make sure to adjust these file names as necessary if you use your own audio files
            freeclimb.percl.pause(100),
            freeclimb.percl.redirect(`${host}/mainMenuPrompt`)
        )
    )
})

module.exports = { app }

We'll also use index.js to contain the routes for handling call end and transfer. To create these add the following to index.js after /incomingCall but before app.listen:

app.post('/transfer', (req, res) => {
    res.status(200).json(
        freeclimb.percl.build(
            freeclimb.percl.play(`${host}/indexAudio?audio=transfer.wav`),
            freeclimb.percl.redirect(`${host}/endCall`)
        )
    )
})

app.post('/endCall', (req, res) => {
    res.status(200).json(
        freeclimb.percl.build(
            freeclimb.percl.play(`${host}/indexAudio?audio=hangup.wav`),
            freeclimb.percl.hangup()
        )
    )
})

Lastly, we'll add the route we'll use to supply/load audio files for our Play PerCL commands after the /endCall endpoint:

app.get('/indexAudio', function (req, res) {
    const file = `${__dirname}/audioFiles/index/${req.param('audio')}`
    res.download(file)
})

🚧

Are you using your own audio files?

Make sure to update any percl.play file paths to the appropriate names and paths if using your own audio files. If you would like to use the audio files we've created for this tutorial, you can download the folder directly here or access it via this sample app's Github repo. Make sure to name this folder audioFiles and add it to the main directory of your project if you do so.


Step 4: Collect speech and digits via a main menu

Next we'll create a main menu for

  • collecting DTMF and speech input from the user and
  • routing their call appropriately

Create a main menu

To do this create a new file in your project directory called mainMenu.js and add the following:

require('dotenv-safe').config()
const express = require('express')
const freeclimbSDK = require('@freeclimb/sdk')
const host = process.env.HOST
const accountId = process.env.ACCOUNT_ID
const apiKey = process.env.API_KEY
const freeclimb = freeclimbSDK(accountId, apiKey)

router = express.Router()

let mainMenuErrCount = 0

router.post('/mainMenuPrompt', (req, res) => {
    res.status(200).json(
        freeclimb.percl.build(
            freeclimb.percl.getSpeech(`${host}/mainMenu`, `${host}/mainMenuGrammar`, { // set actionURL -> will redirect here on user input
                grammarType: freeclimb.enums.grammarType.URL,
                grammarRule: 'option',
                prompts: [freeclimb.percl.play(`${host}/mainMenuAudio?audio=mainMenuPrompt.wav`)]
            })
        )
    )
})

router.post('/mainMenu', (req, res) => {
    let menuOpts
    const getSpeechResponse = req.body
    const response = getSpeechResponse.recognitionResult
    if (req.body.reason === freeclimb.enums.getSpeechReason.DIGIT) { // get digit input from request body
        menuOpts = new Map([
            [
                '1',
                {
                    audioUrl: `${host}/mainMenuAudio?audio=existingOrder.wav`,
                    redirect: `${host}/accountNumberPrompt`
                }
            ],
            [
                '2',
                {
                    audioUrl: `${host}/mainMenuAudio?audio=newOrder.wav`,
                    redirect: `${host}/transfer`
                }
            ],
            [
                '0',
                {
                    audioUrl: `${host}/mainMenuAudio?audio=operator.wav`,
                    redirect: `${host}/transfer`
                }
            ]
        ])
    } else if (req.body.reason === freeclimb.enums.getSpeechReason.RECOGNITION) { // get speech input from request body
        menuOpts = new Map([
            [
                'EXISTING',
                {
                    audioUrl: `${host}/mainMenuAudio?audio=existingOrder.wav`,
                    redirect: `${host}/accountNumberPrompt`
                }
            ],
            [
                'NEW',
                {
                    audioUrl: `${host}/mainMenuAudio?audio=newOrder.wav`,
                    redirect: `${host}/transfer`
                }
            ],
            [
                'OPERATOR',
                {
                    audioUrl: `${host}/mainMenuAudio?audio=operator.wav`,
                    redirect: `${host}/transfer`
                }
            ]
        ])
    }

    if ((!response || !menuOpts.get(response)) && mainMenuErrCount < 3) {
        mainMenuErrCount++
        res.status(200).json(
            freeclimb.percl.build( // if no or incorrect input and errors below max allowed limit return PerCL commands for redirect back to input collection at /mainMenuPrompt
                freeclimb.percl.play(`${host}/mainMenuAudio?audio=error.wav`),
                freeclimb.percl.redirect(`${host}/mainMenuPrompt`)
            )
        )
    } else if (mainMenuErrCount >= 3) {
        mainMenuErrCount = 0
        res.status(200).json(
            freeclimb.percl.build( // if max error limit reached return PerCL commands for redirect to /endCall
                freeclimb.percl.play(`${host}/mainMenuAudio?audio=maxRetry.wav`),
                freeclimb.percl.pause(100),
                freeclimb.percl.redirect(`${host}/endCall`)
            )
        )
    } else {
        mainMenuErrCount = 0
        res.status(200).json(
            freeclimb.percl.build( // on successful input redirect return play and redirect PerCL commands based on option selected by the user
                freeclimb.percl.play(menuOpts.get(response).audioUrl),
                freeclimb.percl.redirect(menuOpts.get(response).redirect)
            )
        )
    }
})

router.get('/mainMenuAudio', function (req, res) { // add route to supply audio for play percl commands
    const file = `${__dirname}/audioFiles/mainMenu/${req.param('audio')}`
    res.download(file)
})

module.exports = router

To make these routes available from our /incomingCall endpoint defined in index.js, add the following content to your index.js file before the /incomingCall endpoint:

const mainMenuRoutes = require('./mainMenu')

app.use('/', mainMenuRoutes)

Create a grammar file

We'll also create a grammar file to be used by the GetSpeech function located in the /mainMenu prompt endpoint.. To do so, create a file called mainMenuGrammar.xml in the root directory of your project with the following content:

<?xml version="1.0" encoding="UTF-8"?>

<grammar xml:lang="en-US" tag-format="semantics-ms/1.0" version="1.0" root="option" mode="voice" xmlns="http://www.w3.org/2001/06/grammar">
<rule id="option" scope="public">
        <item>
                <item repeat="0-1"><ruleref uri="#UMFILLER"/></item>
                <item>
                    <one-of>
                        <item>existing<tag>$._value = "EXISTING";</tag></item>
                        <item>new<tag>$._value = "NEW";</tag></item>
                        <item>operator<tag>$._value = "OPERATOR";</tag></item>
                    </one-of>
                </item>
                <item repeat="0-1"><ruleref uri="#TRAILERS"/></item>
        </item>
</rule>

<rule id="UMFILLER">
        <one-of>
                <item> uh </item>
                <item> um </item>
                <item> hm </item>
                <item> ah </item>
                <item> er </item>
        </one-of>
</rule>

<rule id="TRAILERS">
        <one-of>
                <item> maam </item>
                <item> sir </item>
        </one-of>
</rule>

</grammar>

In order to get our main menu up and fully functional, we'll add a route to load our grammar file for use. To do this, add the following to mainMenu.js before /mainMenuAudio:

router.get('/mainMenuGrammar', function (req, res) {
    const file = `${__dirname}/mainMenuGrammar.xml`
    res.download(file)
})

📘

To learn more about grammars and creating your own XML files, check out our How to Write Grammars guide.


Step 5: Get an account number

Next we'll create routes to

  • ask the user for an account number and
  • redirect according to their input.

To do this, create a new file in your project directory called accountNumberEntry.js and add the following:

require('dotenv-safe').config()
const express = require('express')
const freeclimbSDK = require('@freeclimb/sdk')
const host = process.env.HOST
const accountId = process.env.ACCOUNT_ID
const apiKey = process.env.API_KEY
const freeclimb = freeclimbSDK(accountId, apiKey)

router = express.Router()
let acctMenuErrCount = 0

router.post('/accountNumberPrompt', (req, res) => {
    res.status(200).json(
        freeclimb.percl.build(
            freeclimb.percl.getSpeech(
                `${host}/accountNumber`, // on input collection complete redirect to /accountNumber
                freeclimb.enums.grammarFileBuiltIn.ANY_DIG, // use built in grammar file instead of writing our own 
                {
                    grammarType: freeclimb.enums.grammarType.BUILTIN,
                    prompts: [
                        freeclimb.percl.play(
                            `${host}/accountNumberEntryAudio?audio=accountNumberPrompt.wav` // prompt for acct number
                        )
                    ]
                }
            )
        )
    )
})

router.post('/accountNumber', (req, res) => {
    const getSpeechResponse = req.body
    const input = getSpeechResponse.recognitionResult // get input result from request body
    const response = input ? input.replace(/\s+/g, '') : null
    if ((!response || response.length < 6) && acctMenuErrCount < 2) {
        acctMenuErrCount++
        res.status(200).json(
            freeclimb.percl.build( // if no or incorrect input and errors < max error limit return PerCL commands for redirect back to /accountNumberPrompt for acct number re-entry
                freeclimb.percl.play(`${host}/accountNumberEntryAudio?audio=shortError.wav`),
                freeclimb.percl.pause(100),
                freeclimb.percl.redirect(`${host}/accountNumberPrompt`)
            )
        )
    } else if (acctMenuErrCount >= 2) {
        acctMenuErrCount = 0
        res.status(200).json(
            freeclimb.percl.build( // if max error limit reached redirect return percl commands to redirect to operator at /transfer
                freeclimb.percl.play(`${host}/accountNumberEntryAudio?audio=maxRetry.wav`),
                freeclimb.percl.pause(100),
                freeclimb.percl.redirect(`${host}/transfer`)
            )
        )
    } else {
        acctMenuErrCount = 0
        res.status(200).json(
            freeclimb.percl.build(
                freeclimb.percl.redirect(`${host}/confirmAccountNumberPrompt?acct=${response}`)
            )
        )
    }
})

router.get('/accountNumberEntryAudio', function (req, res) { // add route to supply audio for play percl commands
    const file = `${__dirname}/audioFiles/accountNumberEntry/${req.param('audio')}`
    res.download(file)
})
module.exports = router

To make these routes available from our /incomingCall endpoint defined in index.js, add the following content to your index.js file before the /incomingCall endpoint:

const accountNumberEntryRoutes = require('./accountNumberEntry')

app.use('/', accountNumberEntryRoutes)

Step 6: Confirm the account number

Next we'll create routes to :

  • read the input from the user back to them and
  • ask the to confirm their previous entry

Confirm user input

To do this, create a file in your project directory called accountNumberConfirmation.js and add the following:

require('dotenv-safe').config()
const express = require('express')
const freeclimbSDK = require('@freeclimb/sdk')
const host = process.env.HOST
const accountId = process.env.ACCOUNT_ID
const apiKey = process.env.API_KEY
const freeclimb = freeclimbSDK(accountId, apiKey)

router = express.Router()

let confirmNumberErrCount = 0
let retries = 0

router.post('/confirmAccountNumberPrompt', (req, res) => {
    res.status(200).json(
        freeclimb.percl.build(
            freeclimb.percl.getSpeech(
                `${host}/confirmAccountNumber?acct=${req.param('acct')}`, // redirect to /confirmAccountNumber with acct number from incoming request
                `${host}/accountNumberConfirmationGrammar`,
                {
                    grammarType: freeclimb.enums.grammarType.URL,
                    grammarRule: 'option',
                    prompts: [
                        freeclimb.percl.say(
                            `You entered ${req.param(
                                'acct'
                            )} is that correct? Press 1 or say yes to confirm your account number or press 2 or say no to try again`
                        )
                    ]
                }
            )
        )
    )
})

router.post('/confirmAccountNumber', (req, res) => {
    const getSpeechResponse = req.body
    const response = getSpeechResponse.recognitionResult // get input result from request body
    let menuOpts
    if (req.body.reason === freeclimb.enums.getSpeechReason.DIGIT) {
        menuOpts = new Map([
            [
                '1',
                {
                    audioUrl: `${host}/accountNumberConfirmationAudio?audio=proceed.wav`,
                    redirect: `${host}/accountLookup?acct=${req.param('acct')}`
                }
            ],
            [
                '2',
                {
                    audioUrl: `${host}/accountNumberConfirmationAudio?audio=retry.wav`,
                    redirect: `${host}/accountNumberPrompt`
                }
            ],
            [
                '0',
                {
                    audioUrl: `${host}/accountNumberConfirmationAudio?audio=operator.wav`,
                    redirect: `${host}/transfer`
                }
            ]
        ])
    } else if (req.body.reason === freeclimb.enums.getSpeechReason.RECOGNITION) {
        menuOpts = new Map([
            [
                'YES',
                {
                    audioUrl: `${host}/accountNumberConfirmationAudio?audio=proceed.wav`,
                    redirect: `${host}/accountLookup?acct=${req.param('acct')}`
                }
            ],
            [
                'NO',
                {
                    audioUrl: `${host}/accountNumberConfirmationAudio?audio=retry.wav`,
                    redirect: `${host}/accountNumberPrompt`
                }
            ],
            [
                'OPERATOR',
                {
                    audioUrl: `${host}/accountNumberConfirmationAudio?audio=operator.wav`,
                    redirect: `${host}/transfer`
                }
            ]
        ])
    }

    if ((!response || !menuOpts.get(response)) && confirmNumberErrCount < 3) {
        confirmNumberErrCount++
        res.status(200).json(
            freeclimb.percl.build( // if no or incorrect input and errors < max error limit return PerCL commands to redirect back to /confirmAccountNumberPrompt for retry
                freeclimb.percl.play(`${host}/accountNumberConfirmationAudio?audio=shortError.wav`),
                freeclimb.percl.redirect(
                    `${host}/confirmAccountNumberPrompt?acct=${req.param('acct')}`
                )
            )
        )
    } else if (confirmNumberErrCount >= 3 || retries >= 2) {
        confirmNumberErrCount = 0
        res.status(200).json(
            freeclimb.percl.build(
                freeclimb.percl.play(`${host}/accountNumberConfirmationAudio?audio=operator.wav`),
                freeclimb.percl.pause(100),
                freeclimb.percl.redirect(`${host}/transfer`)
            )
        )
    } else {
        confirmNumberErrCount = 0
        if (response === '2' || response === 'NO') { // increment tracker for user selected retries if selected
            retries++ // retries tracked separately from input errors
        } else if (response === '1' || response === 'YES') {
            retries = 0 // reset user selected retries once this menu has been successfully passed
        }
        res.status(200).json(
            freeclimb.percl.build( // return PerCL play and redirect commands based on user choice
                freeclimb.percl.play(menuOpts.get(response).audioUrl),
                freeclimb.percl.redirect(menuOpts.get(response).redirect)
            )
        )
    }
})

router.get('/accountNumberConfirmationAudio', function (req, res) { // add route to supply audio for play percl commands
    const file = `${__dirname}/audioFiles/accountNumberConfirmation/${req.param('audio')}`
    res.download(file)
})

module.exports = router

To make these routes available from our /incomingCall endpoint defined in index.js, add the following content to your index.js file before the /incomingCall endpoint:

const accountNumberConfirmationRoutes = require('./accountNumberConfirmation')

app.use('/', accountNumberConfirmationRoutes)

Create a grammar file

We'll also create a grammar file to be used by the GetSpeech function located in the /confirmAccountNumber prompt endpoint.. To do so, create a file called accountNumberConfirmationGrammar.xml in the root directory of your project with the following content:

<?xml version="1.0" encoding="UTF-8"?>

<grammar xml:lang="en-US" tag-format="semantics-ms/1.0" version="1.0" root="option" mode="voice" xmlns="http://www.w3.org/2001/06/grammar">
<rule id="option" scope="public">
        <item>
                <item repeat="0-1"><ruleref uri="#UMFILLER"/></item>
                <item>
                    <one-of>
                        <item>yes<tag>$._value = "YES";</tag></item>
                        <item>no<tag>$._value = "NO";</tag></item>
                        <item>operator<tag>$._value = "OPERATOR";</tag></item>
                    </one-of>
                </item>
                <item repeat="0-1"><ruleref uri="#TRAILERS"/></item>
        </item>
</rule>

<rule id="UMFILLER">
        <one-of>
                <item> uh </item>
                <item> um </item>
                <item> hm </item>
                <item> ah </item>
                <item> er </item>
        </one-of>
</rule>

<rule id="TRAILERS">
        <one-of>
                <item> maam </item>
                <item> sir </item>
        </one-of>
</rule>

</grammar>

To access the above grammar file during account number confirmation, we'll add an endpoint used by the GetSpeech PerCL command to load our grammar file for use. To do this, add the following to accountNumberConfirmation.js:

router.get('/accountNumberConfirmationGrammar', function (req, res) {
    const file = `${__dirname}/accountNumberConfirmationGrammar.xml`
    res.download(file)
})

Step 7: Create an accounts file for testing

In order to simulate having an accounts service that returns accounts with several different statuses, we'll create a new file for testing purposes. To do this, create a new file called accounts.js in your main directory and add the following:

const accounts = new Map([
    [
        '111222',
        {
            open: true,
            frequentBuyer: true,
            name: 'John Smith',
            mostRecentOrderDate: 'March 30th 2020'
        }
    ],
    [
        '222333',
        {
            open: true,
            frequentBuyer: false,
            name: 'Jane Smith',
            mostRecentOrderDate: 'March 30th 2020'
        }
    ],
    [
        '333444',
        {
            open: false,
            frequentBuyer: true,
            name: 'Sam Smith',
            mostRecentOrderDate: 'March 30th 2020'
        }
    ]
])

module.exports = accounts

Step 8: Look up the account number

Next we'll create a route to take the account number entered and confirmed by the user in steps 5 and 6 and search for it in the data structure we created in step 7. We'll also direct the call based on the status of the account.

To do this create a new file in your project directory called accountLookup.js and add the following content:

require('dotenv-safe').config()
const express = require('express')
const freeclimbSDK = require('@freeclimb/sdk')
const accounts = require('./accounts')
const host = process.env.HOST
const accountId = process.env.ACCOUNT_ID
const apiKey = process.env.API_KEY
const freeclimb = freeclimbSDK(accountId, apiKey)

router = express.Router()
let retries = 0

router.post('/accountLookup', (req, res) => {
    if (!accounts.get(req.param('acct')) && retries < 2) {
        retries++
        res.status(200).json(
            freeclimb.percl.build( // if account doesnt exist return redirect to /accountNumber prompt to allow for retry
                freeclimb.percl.play(`${host}/accountLookupAudio?audio=accountNotFound.wav`),
                freeclimb.percl.redirect(`${host}/accountNumberPrompt`)
            )
        )
    } else if (retries >= 2) {
        retries = 0
        res.status(200).json(
            freeclimb.percl.build( // if max retry reached return PerCl commands for redirect to operator at /transfer
                freeclimb.percl.play(`${host}/accountLookupAudio?audio=maxRetry.wav`),
                freeclimb.percl.pause(100),
                freeclimb.percl.redirect(`${host}/transfer`)
            )
        )
    } else {
        res.status(200).json(
            freeclimb.percl.build( // on successful lookup of acct return PerCL commands to redirect to /accountRead and pass account number as param
                freeclimb.percl.redirect(`${host}/accountRead?acct=${req.param('acct')}`)
            )
        )
    }
})

router.get('/accountLookupAudio', function (req, res) { // add route to supply audio for play percl commands
    const file = `${__dirname}/audioFiles/accountLookup/${req.param('audio')}`
    res.download(file)
})

module.exports = router

To make these routes available from our /incomingCall endpoint defined in index.js, add the following content to your index.js file before the /incomingCall endpoint:

const accountLookupRoutes = require('./accountLookup')

app.use('/', accountLookupRoutes)

Step 9: Read account information back to caller

Finally, we'll add the route for reading information about the user's account back to the caller. To do this, create a new file called accountRead.js and add the following:

require('dotenv-safe').config()
const express = require('express')
const freeclimbSDK = require('@freeclimb/sdk')
const accounts = require('./accounts')
const host = process.env.HOST
const accountId = process.env.ACCOUNT_ID
const apiKey = process.env.API_KEY
const freeclimb = freeclimbSDK(accountId, apiKey)

router = express.Router()

router.post('/accountRead', (req, res) => {
    account = accounts.get(req.param('acct'))

    if (account.open) {
        if (account.frequentBuyer) {
            res.status(200).json(
                freeclimb.percl.build( // return PerCl commands for say w/frequent buyer script and redirect to /transfer
                    freeclimb.percl.play(`${host}/accountReadAudio?audio=platinumAccount.wav`),
                    freeclimb.percl.pause(100),
                    freeclimb.percl.redirect(`${host}/transfer`)
                )
            )
        } else {
            res.status(200).json(
                freeclimb.percl.build( // return PerCL commands for say w/ normal customer script and redirect to /transfer
                    freeclimb.percl.say(
                        `Welcome back ${account.name}, I've found your most recent order from ${account.mostRecentOrderDate}, please hold while I connect you with a customer service representative. `
                    ),
                    freeclimb.percl.pause(100),
                    freeclimb.percl.redirect(`${host}/transfer`)
                )
            )
        }
    } else {
        res.status(200).json(
            freeclimb.percl.build( // return PerCL commands for say w/ error script and redirect to /transfer
                freeclimb.percl.play(`${host}/accountReadAudio?audio=closedAccount.wav`),
                freeclimb.percl.redirect(`${host}/transfer`)
            )
        )
    }
})

router.get('/accountReadAudio', function (req, res) { // add route to supply audio for play percl commands
    const file = `${__dirname}/audioFiles/accountRead/${req.param('audio')}`
    res.download(file)
})

module.exports = router

To make these routes available from our /incomingCall endpoint defined in index.js, add the following content to your index.js file before the /incomingCall endpoint:

const accountReadRoutes = require('./accountRead')

app.use('/', accountReadRoutes)

Step 10: Run your app

To hear your IVR voice-enabled DTMF self service app in action, run the following command at the command line:

yarn start

Once you do this, call the FreeClimb number associated with your application. From there you should be able to experience your self service application.

Congrats! You can now build your own custom IVR voice-enabled DTMF self service application. 🥳🥳

Or, to use text-to-speech instead of your own pre-recroded audio files, try our Self Service Using Text-to-Speech tutorial.

For a more advanced IVR tutorial, try out our Pay by Phone with Voice Calling sample app.