Build a Drop-In Audio Chat Application Using Django, React and Twilio Programmable Voice
Time to read: 19 minutes
There has recently been an explosion of audio chat applications lately. Clubhouse, which allows a user to join on-going audio conversations or start a conversation that others can join, launched in April 2020 and has seen explosive growth. Other companies have also shipped audio chat products or announced they are working on them. Twitter launched Twitter spaces and many are following suit. Facebook, LinkedIn, Slack, Spotify are among companies that have confirmed they will be introducing live-audio features in the future.
In this tutorial we will cover building an audio chat application using Django and React by leveraging the Twilio Programmable Voice API to allow us to make conference calls from the browser.
Requirements
- Python 3.6 or higher: If you don’t have it installed, get it here.
- Node.js and yarn: If you don't have them installed, install them before starting the tutorial.
- Twilio account: If you are new to Twilio, sign up for an account here.
- ngrok: A tool that allows us to expose local servers to the public internet. We will use it to create a public URL that Twilio can use to securely communicate with our web server. You can get it here.
Creating the API
To begin this project, we are going to build the Django API.
Project setup
Create the project directory
Create a virtual environment. This will give us an isolated Python environment that we can use for our project.
This creates a directory called env
inside the current directory. This directory will have a copy of the Python interpreter, the standard library and the python packages we install for this project.
The next step is to activate the environment. If you are following the tutorial on Linux or Mac OS:
If you are following the tutorial on a Windows computer:
Our Python virtual environment is now active. We will need to install the following packages in it:
- Django (web framework)
- twilio (Twilio Python helper library)
- python-dotenv (for working with environment variables)
Let's run pip install
:
Initialize Django project:
The directory should have a structure similar to the one below:
TwiML
When Twilio receives a request to connect to a conference call, it makes a request to our application. Our application is supposed to respond with instructions for a conference call in TwiML.
For example, the TwiML below instructs Twilio to connect the caller to join the conference called named Room foobar
(under our account):
With TwiML , elements are divided into three groups: the root <Response>
element, verbs and nouns. We will create a simple view that returns TwiML for a conference call. This view will live inside a Django app called api.
Ensure you are in the root directory, audio-chat-api and run the command below:
This will create a new directory called api with a structure that looks like this:
Open config/settings.py and add api
to the list of INSTALLED_APPS
.
Rooms View
We will use class based views for our view classes. If you are not familiar with Django Class based views, check out this introduction in the Django documentation.
In this section we are going to create a view called RoomView
and implement the post
method. This is because Twilio makes a POST
request to our endpoint. The Twilio JavaScript SDK allows us to include additional parameters in the request. These parameters will be included in the body of the POST
request Twilio will make to our API. The parameters we will send from the front end are roomName
and participantName
.
Let's go ahead and write the code to handle Twilio's request.
Open api/views.py and add the code below:
In the post
method, we start by retrieving the roomName
and participantName
from the request. The dial.conference
method instructs Twilio to connect us to a conference call. We then specify the name of our call and provide the name of the caller. We convert the response to XML since that is what Twilio expects, and finally return the response.
Configure URLs
In the api directory, create a file called urls.py and update it as follows:
We have associated RoomView
View with /rooms. Let's add the URL configuration for our API app to the project URL configuration. Modify config/urls.py as follows:
Our endpoint for returning TwiML is ready and can be accessed at /api/rooms.
Testing the Rooms View
Run the initial database migrations and then start the server:
Once the server is running on port 8000, open another terminal window and start ngrok on it as follows:
The ngrok output will show the temporary public URL that was assigned to your application in the “Forwarding” lines.
In this tutorial we will use the https:// address.
If you try accessing the web server using this URL, you will get a DisallowedHost
error. This is because Django is not aware of the ngrok URL, so it prevents it from connecting. Open the file config/settings.py in your text editor or IDE and locate the line that reads:
Edit this line to look as follows:
This tells Django that any URLs that end in .ngrok.io are allowed as hosts for the application. Ngrok URLs are randomly generated and expire in 2 hours when you use the free version, so to avoid having to edit the configuration every time a new instance of ngrok is started we leave the randomly generated part of the URL out.
Congratulations, now your web server is exposed publicly! Let's test it out.
From another terminal window, you can use the curl
utility to send a test request to the API. In the following command, make sure you replace the placeholder with your assigned ngrok URL:
You should get an XML response that looks like the one below:
Create new TwiML App on the Twilio console
A TwiML App allows us to specify a URL that Twilio will ping when someone makes a connection.
Login to the Twilio Console. Click on the “...” button under the home menu and select “Programmable Voice”. Then select “TwiML”, and then “TwiML Apps”. Finally, click on “Create new TwiML App”. You should see the pop up below:
The only fields we need to fill are "Friendly Name" and "Request URL" under the “Voice Configuration” section. I decided to call my TwiML APP drop_in_audio_chat
. For the voice request URL, enter your ngrok URL with the suffix /api/rooms
. For example, if my ngrok URL is https://69cca1f0dbab.ngrok.io, I will provide https://69cca1f0dbab.ngrok.io/api/rooms as the value for the voice request URL.
Click "Create" when you are done. That is all for our TwiML App.
Key to note is that the ngrok URL expires after 2 hours meaning you will need to restart ngrok. Don't forget to update the voice URL on your TwiML App appropriately after each ngrok restart.
Generating access tokens
To use the Twilio JavaScript client on the front end, we will need to authenticate it with Twilio Access Tokens. Access Tokens are short-lived credentials that are signed with a Twilio API Key Secret and contain grants which govern the actions the client holding the token is permitted to perform. In our case, the token will have a Voice grant since we are using the voice API. Generally, Twilio Access Tokens are JSON Web Tokens and include the following information:
- A Twilio Account SID, which is the public identifier of our Twilio account.
- An API Key SID, which is the public identifier of the key used to sign the token.
- Grant(s): Defines the scope of what the token can do.
- The API Key Secret associated with the API Key SID is used to sign the Access Token and verify that it is associated with your Twilio account.
To be able to generate Access Tokens, the server needs a few configuration values, which we will save in an environment file. In the root directory, create a file called .env and populate it with the following variables:
Let’s review how to obtain these values.
Head to the Twilio Console home and you will find the Account SID and Auth Token. Click on “Show” to reveal the Auth token. Copy them and save them in the correct places in the .env file.
Open your TwiML App on the Console ("..." > "Programmable Voice" > “TwiML” > "TwiML Apps") and grab the TwiML App SID:
Now we need to create an API key. You can create API keys through the Twilio Console or using the REST API. We will create ours using the Twilio console. Log in to the Console, click "Settings" on the left-side menu. On the menu that appears, click "API keys". Click on "Create API Key". You will be prompted for a friendly name and a key type. For the key type, select “Standard”. Copy the "SID" and the "SECRET" values to the .env file in the appropriate places.
Let's add these variables to our settings for easy retrieval in our code. Open settings.py in the config directory and add the code below right after the last import.
The load_dotenv function looks for a file called ".env" in the root directory and loads the environment variables defined there, saving us the burden of explicitly setting the environment variables on the terminal. We then read the environment variables.
The /token/<username> endpoint will return an access token for the given username. For example, making a GET
request to /api/token/alice will return a token for alice
. How will this work? Our view will extract the username from the incoming request, then create an access token for this nickname.
Open api/views.py and add the following code:
We begin by importing the modules we need to create and return a token.
We then create a VoiceGrant
, binding it to our TwiML App by passing its SID. We also specify incoming_allow=True
, which allows us to receive incoming calls.
We then create a JWT token by instantiating AccessToken
and passing the required arguments. When creating an Access Token, we must provide the Twilio Account SID, API key, API secret and a user identity or username. We then add the voice grant to the token and return the token in a json response.
Configuring URLs
Open api/urls.py and modify it as follows:
- Import
TokenView
fromapi
. - Add the /token/<username> URL to the urlpatterns
The api/urls.py file should now look like this:
Testing the Access Token route
We will use the curl
utility to request for a token from our API. Open the terminal and make the request:
Replace the ngrok URL with your ngrok address and provide a valid string as the username. For instance, if my ngrok URL is https://41f747f0adcd.ngrok.io and my preferred username is alex
, I would make a GET
request to https://05667ff0f765.ngrok.io/api/token/alex.
Once you have the token, you can decode it at jwt.io. Notice the identity field is the value of the username we provided in the URL.
Fetching the room list
We will use the Twilio Python client to retrieve the list of active rooms (conference calls).
In the api/views.py we will make the following changes:
- Import and instantiate the Twilio client.
- Add a
get
method to classRoomView
. OurRoomView
View will now have two instance methods:get
for handlingGET
requests to/rooms
andpost
for handlingPOST
requests to/rooms
.
Add the following code to api/views.py:
In the get
method, we request for conference calls that are in-progress, and the names of participants in the room at that moment. We then return a JSON response with that information.
Testing the room list
Make a GET
request to /api/rooms as follows, replacing the ngrok URL appropriately:
It should return a json response like the one below: We should expect an empty response since we have not initiated a conference call yet.
Since there are no active conference calls at this time, you will see an empty room list.
Creating the front end
Our users will interact with our application using the browser. We will create a React front end that will allow users to chat with other users via audio conference calls. The twilio.js
library will allow us to use the Twilio Conference API on the browser.
Create new React application
We will use Create React App to set up a new react project.
Create React App allows us to easily scaffold a single page React application.
To create a project, find a suitable location outside of the back end project, and run:
We will use react-router to configure routing on the browser and Twilio voice sdk to make calls from the browser.
Let’s install them by running the command below:
Proxying API Requests
Our Django server will be running on localhost:8000
. To tell the development server to proxy any unknown requests to our API server in development, add a proxy
field to the package.json as follows:
This way, when we make a fetch(“/api/rooms”)
in development, the development server will recognize that it’s not a static asset, and will proxy our request to http://localhost:8000/api/rooms as a fallback. This also conveniently avoids CORS issues since our application and back end run on different origins.
Run yarn start. It will automatically open the React application in your default browser window. If the setup ran successfully, you should see a spinning react logo.
State management
Our application state will hold the following data:
nickname
: The name a user will identify themselves with; used in the<SignupForm>
component.selectedRoom
: This will be the room currently selected by the user; used in the<NewRoom>
component.rooms
: We will obtain a list of active rooms from our API.createdRoomTopic
: Name of a new room,twilioToken
: We will obtain this from our API and use it to set up our Twilio Device.device
: we will initialize a Twilio Device object and pass it to<RoomList>
and<Room>
components. This will be used to dial into ongoing conference calls.
There are different patterns and different libraries for managing state. Sometimes we have components that share state and one way to solve it would be to pass down props down the component tree. This is known as props drilling. However, if we are passing props through many components down the tree, then this approach can get messy. Luckily, React's Context API can help.
Since nickname
, selectedRoom
, rooms
, createdRoomTopic
, twilioToken
, and device
are state values that will be shared across different components, we will store them in a global state object and we will use React's Context API to achieve this.
To use the context API, we need to:
- Initialize a Context object like so
const Context = React.createContext(defaultValue);
. This will store our values. - Provider: Once we have a Context object, we need to create a component that accepts a value that will be passed down to consuming components that are descendants of the Provider. Any component that needs access to the state needs to be nested under the Provider.
<Context.Provider value={/* some value */}>
. - Consuming context: In function components, we can get the value of context by using the
useContext
hook. In class components, we can get the value of context by using theConsumer
component. In this caseContext.Consumer
. The component expects a function as its child, because it will pass the current context value into it.
As your application grows, it is advisable to only make global the state that truly needs to be shared because:
- As the state grows, it can get harder to debug
- Every time
<Context.Provider
gets a new value, all components that consume the value have to render again and this can adversely affect performance.
In the src directory, create a file called RoomContextProvider.js and enter the following code in it:
Let's walk through the changes we just made.
We begin with some necessary imports. createContext
is used to create a new Context object, useContext
and useState
are built-in hooks in React. If you are not familiar with React hooks, here is a gentle introduction. The useContext
hook returns the value
passed to a Provider
component while useState
accepts an initial state and returns a pair; the current state and a helper function for updating it.
We provide some default values for our initial state and create a Context
object called RoomContext
. Since every Context object comes with a Provider component that allows consuming components to subscribe to context changes, we create RoomContextProvider
. The Provider component expects a prop called value
which is passed to consuming components that are children of this Provider. We pass state
and setState
as the value
since we want to be able to access our global state and update it from different components.
Finally, we create a custom hook called useGlobalState
that simply calls useContext
under the hood. Since the value
we passed was a state object and a function for updating state, calling useContext(RoomContext)
returns the array [state, setState]
. We throw an error if value
is undefined
. Can you guess why the value would be undefined
? value
will be undefined
if the useContext
hook is called from a component that is not a child of <RoomContext.Provider>
.
Create SignupForm component
Create a directory called components inside src . The first component we will create is <SignupForm>
. This component will collect nicknames from users and fetch Twilio auth tokens from the back end. Create a file called SignupForm.js inside src/components/ and modify it as follows:
We have defined a component called <SignupForm>
that renders a HTML <form>
element.
The <input type="text">
defines a single-line text input field that will be responsible for collecting the provided nickname
and updating the state with the new nickname
value.
The onChange
prop on <input>
tells React to set up a change event listener, so every time the value changes, this handler calls the function we provided for the onChange
prop. Since we passed {e => updateNickname(e.target.value)}
, updateNickname
is called with the value every time it changes.
The <input type="submit">
defines a submit button which submits all form values to a form submit handler. When the button is clicked, this handler calls the function passed to the <form>
onSubmit
prop.
We haven't defined handleSubmit
and updateNickname
yet so let's go ahead and define them. updateNickname
is a function that accepts a string and updates the state with the new value. Inside <SignupForm>
, define a function called updateNickname
that accepts a nickname
argument like so:
Every time the value in the <input>
element changes, updateNickname
is called with the new value. We then update our state with the new nickname value.
Let's also define handleSubmit
. Once we have a nickname
, we want to do several things:
- Use the nickname to get a token from the back end,
- Use the token to setup the Twilio device,
- Request the list of rooms from /api/rooms and display it.
Open src/components/SignupForm.js. At the top level, import useHistory
from React-Router and Device
from the @twilio/voice-sdk
:
The useHistory
hook comes from React-Router. It returns a history object that we can use to programmatically navigate between routes. Device
allows us to make and receive calls from the browser using the Twilio Voice JavaScript SDK.
Let's modify <SignupForm>
by adding the functions handleSubmit
and setupTwilio
:
We passed handleSubmit
as the form onSubmit
event handler. When the user clicks the Submit
button, handleSubmit
is called. React automatically passes the onSubmit
event as an argument to handleSubmit
.
By default, the onSubmit
event sends the form to the server for processing. Since we don't want to do that, we call event.preventDefault()
to prevent the default action from happening.
We then retrieve the nickname
from the state and call the setupTwilio
function with the nickname
. Finally, we use React Router's history object to navigate to /rooms.
Inside setupTwilio
, we request a token for the user from the API by making a fetch to /api/token/<nickname>
. Once we receive the token, we instantiate the device and call device.updateOptions
which updates the device’s with the provided configuration. You can see possible device configurations here. The Twilio device has several event handlers. The Device.on(error, callback)
event handler is emitted when the device receives an error. Finally, we call setState
and update our application state with the device instance and token.
You can see the full code for our SignupForm component here.
Create hook to fetch a list of rooms from the API
Create a directory called hooks inside src. We will create a custom hook that fetches a list of rooms from the API and returns a Promise. Create a file called useFetchRooms.js and modify it as follows:
Inside useFetchRooms
, we create and return a callback that allows us to fetch the list of rooms from the API and returns a promise that resolves with the list of rooms if the fetch was successful. We use useCallback
to ensure we get the same function instance across renderings as long as url
does not change.
We will use this hook inside the <Room>
and <RoomList>
components.
Create Room component
The <Room>
component will display:
- Room details (room name, active participants)
- A button for refreshing rooms (retrieve latest participants in a room)
- A button for leaving the room (without ending the room)
- A button for ending the room (shown only when a room has 1 participant)
Create a file called Room.js inside src/components/ and modify it as follows:
The <Room>
component accepts a prop called room
which is an object that stores the room name and a list of participants in the room. device.connect
is an asynchronous function that makes an outgoing call and returns a Promise which returns a Call instance when fulfilled. We add a local state variable called call
to store the call instance. The call instance is specific per room and thus does not need to be added to the global state. The useEffect
hook is the perfect place to perform side effects in a functional component. Inside the useEffect
hook, we check if a call instance exists (to avoid connecting to the same call twice). If it doesn’t exist, we call device.connect
and when the promise is fulfilled, we update the call
state variable by calling setCall
with the call instance.
The parameters we pass to device.connect
are included in the POST request Twilio makes to our API. Our API returns the TwiML code that allows the user with the name participantLabel
to join a conference call named roomName
. If a conference called roomName
exists, Twilio connects us to the ongoing call. If the conference does not exist, Twilio starts a new conference call with that name.
We also check if the current user is included in the list of participants and add their nickname to the list of participants if it's not already present. Ideally, the back end is the source of truth for our ongoing rooms and their participants. However, since users need a fast experience, we want our users to immediately see their nickname
when they join a room.
Let's look at the code inside the return
. We display the room name as a <h1>
heading, loop through the list of participants and display each participant as a list <li>
item in an unordered <ul>
list.
We also render the following buttons; Refresh
, Leave Quietly
and End room
.
We pass refreshRooms
as the onClick
event handler for the Refresh
button. refreshRooms
calls fetchRooms
, which queries our API for the latest rooms and their participants. We find the room with the same name as the current room name and update our application state by setting it as the value of selectedRoom
.
We pass handleLeaveRoom
as the onClick
event handler for the Leave Quietly
button. Whenever the button is clicked, React calls handleLeaveRoom()
which in turn calls call.disconnect()
and navigates to the route /rooms. Don't worry about the routing for now, we will configure it later on. The call.disconnect
function instructs the Twilio device to disconnect from the ongoing call. If a room has only one participant, calling .disconnect()
terminates the ongoing call. If the room has more than one participant, calling .disconnect()
disconnects the current user without terminating the call.
The End room
button is only displayed if a room has only one participant. In that case, we want to give the user the ability to end the room. The End room
button receives handleLeaveRoom
as the onClick
event handler. This function calls handleLeaveRoom
and removes the current room from the application state. In the <RoomList>
component which we are going to define next, we will update the global state by setting the current room as the value of selectedRoom
whenever a user selects or starts a new room.
Create RoomList component
Create a file called RoomList.js inside src/components/. This file will hold our <RoomList>
component. The <RoomList>
component will display:
- A list of active rooms
- A form for creating a new room
Let’s go ahead and update it as follows:
Inside the useEffect
hook, we call fetchRooms
and update the application state with the list of rooms we receive. We pass setState
and fetchRooms
in the useEffect
dependency array since we reference them inside useEffect
.
Our component returns a <div>
that has a <h1>
heading, a list of rooms and a <NewRoom>
component. Each room name is nested inside a React Router <Link>, making them clickable links. We add an onClick
event handler to the <Link>
elements. When a room is clicked, we update our state and set the clicked room as the value for selectedRoom
. We also check if the active rooms array is empty. If it is empty, we add a prompt for a user to create a new room. We also render <NewRoom>
,a component that renders a form for creating a new room. <NewRoom>
is not yet defined. Let's go ahead and define it.
NewRoom component
Users will have the option of starting a new room. Create a file called NewRoom.js inside src/components/ and update it as follows:
We render a HTML form that asks the user for a room topic. The <input type="text">
element accepts a function that calls updateRoomName
with the value provided. updateRoomName
updates state by setting the room name provided as the value for createdRoomTopic
. We pass handleRoomCreate
as the onSubmit
event handler for our form. handleRoomCreate
prevents the default action by calling event.preventDefault
. We construct a room
object called selectedRoom
and set the value of the room_name
property to the provided name and the value of the participants
property to an array with one element: the user's provided nickname
. We then add the created room to the array of rooms and update the state with the new array. We also update state by setting the created room object as the value of selectedRoom
. Finally, we navigate to the room's route (/rooms/${roomId}). Let’s now configure our routing to enable smooth and seamless navigation in our application.
Configure client-side routing
Create a file called Pages.js inside src/components/ and modify it as follows:
We render a list of <Route>
objects representing the paths /rooms/:roomId, /rooms and /. The paths /rooms and / should render <RoomList>
if the twilioToken
has been set on the state object (user already provided nickname and Twilio token retrieved) else we render <SignupForm>
and ask the user to provide a nickname
. The path /rooms/:roomId renders a specific room, which represents the selected room; the value of selectedRoom
can be a room the user has clicked or started.
Tying everything together
Open src/App.js and replace the contents with the code below:
Our <Pages>
component defines our routes and which components should be rendered depending on the given route. Our <App>
component renders <Pages>
and <RoomContextProvider>
, the context provider we defined earlier. Since we want our context to be available to all our components, we make <RoomContextProvider> the top level component in the <App>
component.
Testing the application
Our application is now ready to be tested!
If the back end is not running, navigate to audio-chat-api (our Django API) in a terminal window, activate the Python virtual environment, and run the following command to start it:
If ngrok isn’t running you will need to start it now. You may also have a running ngrok with an expired session, in which case you will need to stop the expired ngrok and start a fresh one. To start ngrok, run the following ocmmand:
If you started a new ngrok session, you will need to update the TwiML App configuration in the Twilio Console with the new ngrok URL.
Run the front end application by navigating to audio-chat (our React application) in another terminal window and typing the following command:
This should open our application on the browser.
On a fourth terminal window, run ngrok http 3000
to expose our React application to the internet. This gives us a public URL we can share with our friends.
Navigate to the public ngrok URL for the front end application on your browser. Provide a nickname
and click "Submit". You should see a page prompting you to start a new room. Provide a name for the new chat room and click “Start room”.
Share the ngrok URL for the front end application with a friend, or if you prefer, open it yourself in a second browser window or tab. After entering a username, the second participant will be given the option to join the existing chat room, or create a new one.
Conclusion
It has been a long journey building out the back end and front end pieces of our application. Pat yourself on the back for making it this far. We went over building a Django API and React front end while leveraging the Twilio Voice API to implement a real time audio chat application.
Our application allows users to start audio chats and listen in on ongoing conversations. We have barely scratched the surface of what is possible. The Twilio client allows us to add WebRTC-powered voice and video calling capabilities into our applications. We can further improve our application by implementing mute and unmute features, adding moderation capabilities and a more robust identity management approach.
Go forth and build!
Alex is a developer and technical writer. He enjoys building web APIs and backend systems.
Related Posts
Related Resources
Twilio Docs
From APIs to SDKs to sample apps
API reference documentation, SDKs, helper libraries, quickstarts, and tutorials for your language and platform.
Resource Center
The latest ebooks, industry reports, and webinars
Learn from customer engagement experts to improve your own communication.
Ahoy
Twilio's developer community hub
Best practices, code samples, and inspiration to build communications and digital engagement experiences.