This blog was written by Akshat Gupta an Agora Developer Evangelist Intern who had started his journey in Agora as a Superstar. The Agora Superstar program empowers developers around the world to share their passion and technical expertise, and create innovative real-time communications apps and projects using Agora’s customizable SDKs.
We all want our remote video conference attendees to feel like they can participate and are truly a part of the meeting. Interruptions and being talked over are two of the biggest meeting challenges for both remote and on-site workers.
Visually highlighting or differentiating the active speaker creates an organic, conversational atmosphere that engages remote participants far more than on a normal video call. The attention of the participants is shifted to the right person, so they know where to look on the screen.In this tutorial, we will develop a web application that highlights active speakers in a group video calling application using the Agora Web SDK.

Prerequisites
- Basic working knowledge of JavaScript, JQuery, and Bootstrap
- An Agora developer account (see How to Get Started with Agora)
- The Agora Web SDK
Project Setup
Let’s start by laying out our basic HTML structure. A few UI elements are necessary, such as the local audio stream uid, the remote audio streams’ uids, and buttons for joining, leaving, and muting and unmuting. I’ve also imported the necessary CDNs and linked the custom CSS and JS files.
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<!-- Meta Tags For SEO --> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | |
<title>Agora Find Active Speaker</title> | |
<link rel="icon" href="./favicon.png" type="image/png"> | |
<link rel="apple-touch-icon" href="./apple-touch-icon.png" type="image/png"> | |
<!-- CSS only --> | |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" rel="stylesheet" | |
integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous"> | |
<link rel="stylesheet" href="./index.css"> | |
</head> | |
<body> | |
<!-- Container --> | |
<div class="container-fluid banner"> | |
<p class="banner-text">Agora Find Active Speaker</p> | |
</div> | |
<div class="container mt-5 mb-5"> | |
<!-- Form for joining and leaving --> | |
<form id="join-form"> | |
<!-- Inputs --> | |
<div class="row"> | |
<div class="col-sm"> | |
<p class="join-info-text">AppID</p> | |
<input id="appid" type="text" placeholder="Enter AppID" required class="form-control"> | |
</div> | |
<div class="col-sm"> | |
<p class="join-info-text">Channel</p> | |
<input id="channel" type="text" placeholder="Enter Channel Name" required class="form-control"> | |
</div> | |
</div> | |
<!-- Button controls --> | |
<div class="button-group mt-2"> | |
<button id="join" type="submit" class="btn btn-sm">Join</button> | |
<button id="mic-btn" type="button" class="btn btn-sm" disabled> | |
Mute Audio | |
</button> | |
<button id="video-btn" type="button" class="btn btn-sm" disabled> | |
Mute Video | |
</button> | |
<button id="leave" type="button" class="btn btn-sm" disabled>Leave</button> | |
</div> | |
</form> | |
<div class="row"> | |
<div class="col"> | |
<div class="row video-group"> | |
<!-- Local Video --> | |
<div class="col"> | |
<p id="local-player-name" class="player-name"></p> | |
<div id="local-player" class="player"></div> | |
</div> | |
<div class="w-100"></div> | |
<!-- Remote Players --> | |
<div id="remote-playerlist" class="row justify-content-center"></div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Scripts --> | |
<script src="https://code.jquery.com/jquery-3.6.0.min.js" | |
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> | |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js" | |
integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf" crossorigin="anonymous"> | |
</script> | |
<script src="https://download.agora.io/sdk/release/AgoraRTC_N.js"></script> | |
<script src="./index.js"></script> | |
</body> | |
</html> |

Adding Color
Now that our basic layout is ready, it’s time to add some CSS.I’ve already added basic Bootstrap classes to the HTML to make the site presentable, but we’ll use custom CSS to match the site with a blue Agora-based theme.
.banner { | |
padding: 10px; | |
background-color: #1A0374; | |
color: white; | |
} | |
.banner-text { | |
padding: 8px 20px; | |
margin: 0; | |
font-size: 18px; | |
} | |
#join-form { | |
margin-top: 10px; | |
} | |
.join-info-text { | |
margin-bottom: 2px; | |
} | |
input { | |
width: 100%; | |
margin-bottom: 2px; | |
} | |
.player { | |
width: 480px; | |
height: 320px; | |
} | |
.player-name { | |
margin: 8px 0; | |
} | |
@media (max-width: 640px) { | |
.player { | |
width: 320px; | |
height: 240px; | |
} | |
} | |
#join, | |
#leave, | |
#mic-btn, | |
#video-btn { | |
background-color: #0C9DFD; | |
color: white; | |
border-radius: 5px; | |
} | |
#join:hover, | |
#leave:hover, | |
#mic-btn:hover, | |
#video-btn:hover { | |
background-color: rgba(2, 133, 221, 0.911); | |
} | |
.all-users-text { | |
background: rgba(12, 157, 253, 0.897); | |
padding: 30px; | |
color: white; | |
border-radius: 15px; | |
max-height: 500px; | |
overflow: scroll; | |
} | |
.btn-control { | |
outline: none; | |
border: 1px solid white; | |
box-shadow: none; | |
} | |
.btn-control:hover, | |
.btn-control:focus { | |
outline: none; | |
box-shadow: none; | |
border: 1px solid white; | |
background: rgb(12, 157, 253); | |
} | |
.remoteMicrophone, | |
.remoteCamera { | |
cursor: pointer; | |
} |

Core Functionality (JS)
Now that we have the HTML/DOM structure laid out, we can add the JS, which uses the Agora Web SDK. It may look intimidating at first, but if you follow Agora’s official docs and demos and put in a little practice, it’ll be a piece of cake.
In the following snippet we first create a client and then create a microphone audio and camera video track.
Tokens are used for incorporating additional security into our application. If you don’t use tokens, specify the tokens as null.
When a user joins a channel by clicking the button, you begin playing the tracks specified while creating the client. The user’s stream is then published and subscribed, which can be toggled using the UI controls.
When a user leaves, remove the uid from the user’s screen.
Finally, we give the user the option to end the call and leave the channel.
// create Agora client | |
var client = AgoraRTC.createClient({ | |
mode: "rtc", | |
codec: "vp8" | |
}); | |
var localTracks = { | |
videoTrack: null, | |
audioTrack: null | |
}; | |
var localTrackState = { | |
videoTrackEnabled: true, | |
audioTrackEnabled: true | |
} | |
var remoteUsers = {}; | |
// Agora client options | |
var options = { | |
appid: null, | |
channel: null, | |
uid: null, | |
token: null | |
}; | |
$("#join-form").submit(async function (e) { | |
e.preventDefault(); | |
$("#join").attr("disabled", true); | |
try { | |
options.appid = $("#appid").val(); | |
options.channel = $("#channel").val(); | |
await join(); | |
} catch (error) { | |
console.error(error); | |
} finally { | |
$("#leave").attr("disabled", false); | |
} | |
}); | |
$("#leave").click(function (e) { | |
leave(); | |
}); | |
$("#mic-btn").click(function (e) { | |
if (localTrackState.audioTrackEnabled) { | |
muteAudio(); | |
} else { | |
unmuteAudio(); | |
} | |
}); | |
$("#video-btn").click(function (e) { | |
if (localTrackState.videoTrackEnabled) { | |
muteVideo(); | |
} else { | |
unmuteVideo(); | |
} | |
}) | |
async function join() { | |
$("#mic-btn").prop("disabled", false); | |
$("#video-btn").prop("disabled", false); | |
// add event listener to play remote tracks when remote users join, publish and leave. | |
client.on("user-published", handleUserPublished); | |
client.on("user-joined", handleUserJoined); | |
client.on("user-left", handleUserLeft); | |
// join a channel and create local tracks, we can use Promise.all to run them concurrently | |
[options.uid, localTracks.audioTrack, localTracks.videoTrack] = await Promise.all([ | |
// join the channel | |
client.join(options.appid, options.channel, options.token || null), | |
// create local tracks, using microphone and camera | |
AgoraRTC.createMicrophoneAudioTrack(), | |
AgoraRTC.createCameraVideoTrack() | |
]); | |
showMuteButton(); | |
// play local video track | |
localTracks.videoTrack.play("local-player"); | |
$("#local-player-name").text(`localVideo(${options.uid})`); | |
// publish local tracks to channel | |
await client.publish(Object.values(localTracks)); | |
console.log("publish success"); | |
} | |
async function leave() { | |
for (trackName in localTracks) { | |
var track = localTracks[trackName]; | |
if (track) { | |
track.stop(); | |
track.close(); | |
$('#mic-btn').prop('disabled', true); | |
$('#video-btn').prop('disabled', true); | |
localTracks[trackName] = undefined; | |
} | |
} | |
// remove remote users and player views | |
remoteUsers = {}; | |
$("#remote-playerlist").html(""); | |
// leave the channel | |
await client.leave(); | |
$("#local-player-name").text(""); | |
$("#join").attr("disabled", false); | |
$("#leave").attr("disabled", true); | |
hideMuteButton(); | |
console.log("client leaves channel success"); | |
} | |
async function subscribe(user, mediaType) { | |
const uid = user.uid; | |
// subscribe to a remote user | |
await client.subscribe(user, mediaType); | |
console.log("subscribe success"); | |
// if the video wrapper element is not exist, create it. | |
if (mediaType === 'video') { | |
if ($(`#player-wrapper-${uid}`).length === 0) { | |
const player = $(` | |
<div id="player-wrapper-${uid}" class="col col-xl-6"> | |
<p class="player-name">remoteUser(${uid})</p> | |
<div id="player-${uid}" class="player"></div> | |
</div> | |
`); | |
$("#remote-playerlist").append(player); | |
} | |
// play the remote video. | |
user.videoTrack.play(`player-${uid}`); | |
} | |
if (mediaType === 'audio') { | |
user.audioTrack.play(); | |
} | |
} | |
// Handle user join | |
function handleUserJoined(user) { | |
const id = user.uid; | |
remoteUsers[id] = user; | |
} | |
// Handle user leave | |
function handleUserLeft(user) { | |
const id = user.uid; | |
delete remoteUsers[id]; | |
$(`#player-wrapper-${id}`).remove(); | |
} | |
// Handle user published | |
function handleUserPublished(user, mediaType) { | |
subscribe(user, mediaType); | |
} | |
// Hide mute button | |
function hideMuteButton() { | |
$("#video-btn").css("display", "none"); | |
$("#mic-btn").css("display", "none"); | |
} | |
// Display mute button | |
function showMuteButton() { | |
$("#video-btn").css("display", "inline-block"); | |
$("#mic-btn").css("display", "inline-block"); | |
} | |
// Mute audio function | |
async function muteAudio() { | |
if (!localTracks.audioTrack) return; | |
await localTracks.audioTrack.setEnabled(false); | |
localTrackState.audioTrackEnabled = false; | |
$("#mic-btn").text("Unmute Audio"); | |
$("#local-player").css({ | |
"box-shadow": "none" | |
}); | |
} | |
// Mute video function | |
async function muteVideo() { | |
if (!localTracks.videoTrack) return; | |
await localTracks.videoTrack.setEnabled(false); | |
localTrackState.videoTrackEnabled = false; | |
$("#video-btn").text("Unmute Video"); | |
} | |
// Unmute audio function | |
async function unmuteAudio() { | |
if (!localTracks.audioTrack) return; | |
await localTracks.audioTrack.setEnabled(true); | |
localTrackState.audioTrackEnabled = true; | |
$("#mic-btn").text("Mute Audio"); | |
} | |
// Unmute video function | |
async function unmuteVideo() { | |
if (!localTracks.videoTrack) return; | |
await localTracks.videoTrack.setEnabled(true); | |
localTrackState.videoTrackEnabled = true; | |
$("#video-btn").text("Mute Video"); | |
} |
Now that our application supports group video calling, it’s time to incorporate the main functionality: highlighting any user who’s speaking through a blue box shadow around the user’s video stream.
Highlighting a Speaker
Following the Agora Docs, we enable the volume indicator. This method enables the SDK to regularly report the remote users who are speaking and their volumes.
After the volume indicator is enabled, the SDK triggers the AgoraRTCClient.on(“volume-indicator”) callback to report the volumes every two seconds, regardless of whether active speakers are in the channel.
The volume-indicator callback reports all the speaking remote users and their volumes. It is disabled by default. It’s enabled only via the enableAudioVolumeIndicator. If enabled, it reports the users’ volumes every two seconds regardless of whether users are speaking.
The volume is an integer ranging from 0 to 100. Usually, a user with a volume level above 5 is a speaking user.
// Find active speakers | |
client.enableAudioVolumeIndicator(); | |
client.on("volume-indicator", volumes => { | |
volumes.forEach((volume) => { | |
console.log(`UID ${volume.uid} Level ${volume.level}`); | |
if (options.uid == volume.uid && volume.level > 5) { | |
$("#local-player").css({ | |
"box-shadow": "0 2px 4px 0 #0C9DFD, 0 2px 5px 0 #0C9DFD" | |
}); | |
} else if (options.uid == volume.uid && volume.level < 5) { | |
$("#local-player").css({ | |
"box-shadow": "none" | |
}); | |
} | |
if (options.uid != volume.uid && volume.level > 5) { | |
$("#player-" + volume.uid).css({ | |
"box-shadow": "0 2px 4px 0 #0C9DFD, 0 2px 5px 0 #0C9DFD" | |
}); | |
} else if (options.uid != volume.uid && volume.level < 5) { | |
$("#player-" + volume.uid).css({ | |
"box-shadow": "none" | |
}); | |
} | |
}); | |
}) |
You can now run and test the application.
Note: For testing, you can use multiple browser tabs to simulate multiple users on the call.
Conclusion
You did it!
We have successfully made our own video calling application that highlights the active speaker. In case you weren’t coding along or want to see the finished product all together, I have uploaded all the code to GitHub:

If you would like to see the demo in action, check out the demo of the code in action:

Thanks for taking the time to read my tutorial. If you have questions, please let me know with a comment. If you see room for improvement, feel free to fork the repo and make a pull request!