Making a Clubhouse clone

Our Video APIs can do more than video: in this guide, we will build an audio-only application inspired by the popular Clubhouse. Here is what we are going to build:

Overview

We are going to build an audio-only application inspired by the popular Clubhouse. Our application will run on the browser and will be composed by a frontend written in React, and a small server in Node.js. We will use the SignalWire JavaScript SDK to provide high-quality communication functionality to our application.

Before starting, a few resources:

Getting Started

Clone the repository with the following command:

git clone -b livewire https://github.com/signalwire/browser-audioconf-example.git

We will work from the "livewire" branch, which contains some basic UI to get started.

To start the project:

npm install
npm run start  # starts both backend and frontend

Your server will listen at http://localhost:8080, while the frontend will be available at http://localhost:3000.

Server

We need to build a small server to obtain Room Tokens from SignalWire, and to get the list of active rooms. In essence, the server will do the following:

  • listen on a /get_token endpoint for POST requests from the browser. Our server will obtain a Room Token for the requested user name and room name, and will send it back to the browser.
  • send events on a WebSocket, to provide the clients with an updated list of rooms and participants whenever a change happens.

We will write the server in Node.js. Node.js is not a requirement, but it allows us to use the handy SignalWire Realtime SDK.

Obtaining your SignalWire API Token

First, we need to obtain some authentication information from SignalWire. Login to your space at https://signalwire.com/signin, and from the left menu go to the "API" page. Once you have navigated to the API page, click on "Create Token". Give it a name so that you can identify it later, and make sure that the "Video" scope is enabled. Then hit "Save". After the token is created, you will see it listed in the table.

Here are the three pieces of information that you need to copy:

  • Project ID
  • Space URL
  • Token
The three pieces of information that the server will use for authentication.The three pieces of information that the server will use for authentication.

The three pieces of information that the server will use for authentication.

You need to put these values in the .env file inside the backend folder:

PROJECT_ID=...
API_KEY=...
SPACE=...

We are now ready to make requests to the SignalWire REST APIs.

🚧

API Tokens are confidential

It is important that the API tokens are kept confidential. They can be used to make API requests on your behalf. Take extreme care to make sure that the tokens don't get pushed to GitHub. Make sure that the tokens aren't publicly accessible, for example they must not be exposed in frontend code. For Node.js backends, you can use dotenv files or similar mechanisms to safely store confidential constants.

Endpoint: /get_token

The code of the server is located in the file backend/src/index.js.

We are now going to create a /get_token endpoint to be used by the client to obtain a Room Token for a given room and username. The core instructions in the /get_token endpoint look like this:

// Endpoint to request token for a room
app.post("/get_token", async (req, res) => {
  const { user_name, room_name } = req.body;

  const response = await axios.post(
    apiurl + "/room_tokens", {
      user_name,
      room_name: room_name,
      permissions: normalPermissions
    }, {
      auth
    }
  );

  const token = response.data?.token;

  return res.json({ token });
});

What we are doing is to listen on POST requests on our /get_token endpoint, for a message which contains a JSON-encoded payload such as {"user_name": "...", "room_name": "..."}. We use the user name and room name to request a Room Token from SignalWire by making a POST request on the /room_tokens endpoint. Note that in this code example, apiurl is a SignalWire URL such as https://your_space.signalwire.com/api/video. Finally, we send the token back to the client that initiated the request.

📘

What is the difference between the API token and the Room Token?

The API token gives full access to SignalWire APIs. Whoever owns the API token can for example delete any room, mute or unmute any participant, and so on without limitations. You must only use the API token in your server to communicate with SignalWire.

The Room Token is a limited-scope token that can be used by clients to access SignalWire APIs without knowing the API token. Clients must ask for a Room Token to your own server, which in turn will obtain it from SignalWire servers and pass it back to the client. Room Tokens are associated to a given <user, room> pair, so you can think of them as a personal key to access a given room, by a given user. Your server decides the permissions for each individual Room Token, for example whether they are allowed to mute other users.

WebSocket: rooms_updated

The implementation for this feature is also located in backend/src/index.js.

We use Socket.IO to create a WebSocket over which to send a list of rooms and, for each room, the list of participants inside. We want to send the updates whenever there is a change.

First, we write a function to obtain the list of rooms and participants using the REST APIs:

async function getRoomsAndParticipants() {
  // Get all most recent room sessions
  let rooms = await axios.get(`${apiurl}/room_sessions`, { auth });
  rooms = rooms.data.data; // In real applications, check the "next" field.

  // Filter to get only the in-progress room sessions
  rooms = rooms.filter((r) => r.status === "in-progress");

  // Augment each room session object with the list of participants in it
  rooms = await Promise.all(
    rooms.map(async (r) => ({
      ...r,
      members: (
        await axios.get(`${apiurl}/room_sessions/${r.id}/members`, { auth })
      ).data.data
    }))
  );

  return rooms
}

In this case we use two different SignalWire endpoints: the first, /room_sessions, gives us a list of room sessions. This however does not include information about the participants, so we use the endpoint /room_sessions/:id/members to get a list of members for the given room session id. We pack everything in an array, and we return it asynchronously.

We use the getRoomsAndParticipants function whenever we detect an update using the SignalWire Realtime SDK. The Realtime SDK allows listening to events from rooms, sessions, and members. Here is how we do it:

  // We create a SignalWire Realtime SDK client.
  const realtimeClient = await createClient({
    project: auth.username,
    token: auth.password
  })

  // Function that sends a `rooms_updated` events over Socket.IO.
  const emitRoomsUpdated = async () => io.emit('rooms_updated', await getRoomsAndParticipants())

  // When a new Socket.IO client connects, send them the list of rooms
  io.on('connection', (socket) => emitRoomsUpdated())

  // When something changes in the list of rooms or members, trigger a new
  // event.
  realtimeClient.video.on('room.started', async (room) => {
    emitRoomsUpdated()
    room.on('member.joined', () => emitRoomsUpdated())
    room.on('member.left', () => emitRoomsUpdated())
  })
  realtimeClient.video.on('room.ended', () => emitRoomsUpdated())

  await realtimeClient.connect()

We use Socket.IO to emit a rooms_updated event that the clients will listen to and update their user interface.

Frontend

File structure

We implement the frontend as a React application. We have structured the UI around three pages:

We also have several components, but we are mainly interested in two files:

We will start by writing the logic in Server.js.

Server.js

getToken

First, we implement the getToken function. This function performs a POST request to our /get_token endpoint specifying a room name and a user name, and returns a Room Token.

export async function getToken(user, room) {
  const response = await axios.post(`${url}/get_token`, {
    user_name: user,
    room_name: room,
  });
  return response.data.token;
}

This is all we needed for what concerns the communication with the server. Indeed, refreshing the list of rooms is handles in RoomListPage.js, like this:

const socket = socketIOClient(Server.url);
socket.on("rooms_updated", rooms => {
  setRooms(rooms)
  setIsLoading(false)
});

The above code connects the WebSocket and, whenever it receives a rooms_updated events, refreshes the list of rooms in the UI.

We can now connect to a room using the SignalWire JavaScript SDK.

Audio.js

In frontend/src/components/Audio.js we have the logic to connect with SignalWire by using the SignalWire JavaScript SDK. In particular, we have a function declared as follows:

async function Audio({
  room,
  user,
  onParticipantsUpdated = () => { },
  onParticipantTalking = () => { },
  onMutedUnmuted = () => { },
})

Here, room and user are strings that indicate the respective names. The parameter onParticipantsUpdated is a function that we must call whenever the list of participants changes, and receives the list of participants. The UI will handle it. Similarly, we must call onParticipantTalking when a given participant starts or stops talking, and onMutedUnmuted when we get muted or unmuted. The React UI in the application uses these callbacks to update the interface.

We will proceed in steps:

  1. We will create a RoomSession object with a Room Token.
  2. We will connect events to detect whenever the list of participants has been updated.
  3. We will connect events to detect whenever we get muted or unmuted.
  4. We will connect events to detect when a participant is talking.
  5. We will join the room session.

Step 1: creating a RoomSession object

Make sure that the package @signalwire/js is installed, and is at version v3.5.0 or higher.

First, let's import both the SignalWire SDK and our Server file:

import * as SignalWire from "@signalwire/js"
import * as Server from './Server'

We use the Server component to get a token:

  const token = await Server.getToken(user, room)

Then, we create a RoomSession object specifying the token and some settings:

  const roomSession = new SignalWire.Video.RoomSession({
    token: token,
    audio: true,
    video: false
  })

Here, we have specified that we only want to use audio functionality.

Step 2: participants-related events

Before actually joining the room session, we are going to connect some events. Here, we connect all events that indicate a variation in the list of members. In the event handlers, we call onParticipantsUpdated to let the UI know the updated list of members.

  // Internal list of members
  let members = []

  roomSession.on('room.joined', async (e) => {
    console.log('Event: room.joined')
    const currMembers = await roomSession.getMembers()
    members = [...currMembers.members];
    onParticipantsUpdated(members);
  })

  roomSession.on('member.joined', (e) => {
    console.log('Event: member.joined')
    members = [...members, e.member]
    onParticipantsUpdated(members)
  })

  roomSession.on('member.updated', (e) => {
    console.log('Event: member.updated')
    const memberIndex = members.findIndex(
      x => x.id === e.member.id
    )
    if (memberIndex < 0) return
    members[memberIndex] = {
      ...members[memberIndex],
      ...e.member
    }
    onParticipantsUpdated([...members])
  })

  roomSession.on('member.left', (e) => {
    console.log('Event: member.left')
    members = members.filter(
      (m) => m.id !== e.member.id
    );
    onParticipantsUpdated([...members]);
  })

Step 3: muted/unmuted events

We need to detect when we get muted or unmuted. This may happen for example if an administrator mutes us. If we have been muted or unmuted, we call onMutedUnmuted.

  roomSession.on('member.updated', (e) => {
    // Have we been muted/unmuted? If so, trigger an event.
    if (e.member.id === roomSession.memberId) {
      if (e.member.updated.includes('audio_muted')) {
        onMutedUnmuted(e.member.audio_muted)
      }
    }
  })

Step 4: talking-related events

We need to detect when a user starts or stops speaking, so that the UI can update accordingly. Whenever we detect such event, we call onParticipantTalking passing the id of the participant and whether they are currently talking.

  roomSession.on('member.talking', (e) => {
    console.log('Event: member.talking')
    onParticipantTalking(e.member.id, e.member.talking)
  })

Step 5: join the room session

Now that all events are connected, we can join the room!

  await roomSession.join()
  console.log("Joined!")

  return roomSession

We return the RoomSession object so that its methods (e.g., audioMute) can be used from the outside.

Find the full code at frontend/src/components/Audio.js.

Muting the microphone

Right now, the button to mute the microphone does not work. We can make it work by connecting it in frontend/src/pages/RoomPage.js. In that file, there is a function named toggleMute, which is called whenever a user clicks on the mute button. In addition, inside RoomPage we have a reference to the RoomObject returned by Audio.js: it is stored as roomSession.current.

We can then mute or unmute the user as follows:

  function toggleMute() {
    if (!roomSession.current) return

    // The RoomSession object returned by the Audio function is
    // stored in `roomSession.current`.

    if (muted) {
      // We need to unmute
      roomSession.current.audioUnmute()  // <-- add this
    } else {
      // We need to mute
      roomSession.current.audioMute()  // <-- add this
    }

    setMuted(!muted)
  }

Wrap up

We have built a Clubhouse-like application with the SignalWire JavaScript SDK.

You can find the full code for this application in the master branch of our GitHub repository at https://github.com/signalwire/browser-audioconf-example.


Did this page help you?