I'm trying to build an accessible audio indicator for a WebRTC video chat. It should basically show how loud you're talking, when you're talking. Here is the isolated code (for Codesandbox, you need to install styled-components
).
import React, { useEffect, useRef, useState } from "react";
import styled, { css } from "styled-components";
import "./styles.css";
function useInterval(callback, delay) {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
const VolumeMeterWrapper = styled.div`
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.75);
display: flex;
height: 32px;
width: 32px;
`;
const VolumeBar = styled.div`
background: #25a968;
border-radius: 1px;
height: 4px;
margin: 0 2px;
width: 8px;
transition: all 0.2s linear;
transform: ${({ level }) => css`scaleY(${level + 1})`};
`;
function VolumeMeter({ level }) {
return (
<VolumeMeterWrapper>
<VolumeBar level={level > 3 ? ((level - 2) * 5) / 4 : 0} />
<VolumeBar level={level} />
<VolumeBar level={level > 3 ? ((level - 2) * 5) / 4 : 0} />
</VolumeMeterWrapper>
);
}
export default function App() {
const [level, setLevel] = useState(0);
const [count, setCount] = useState(0);
useInterval(() => {
setCount((c) => c + 1);
if (count % 10 > 4) {
setLevel((l) => l - 1);
} else {
setLevel((l) => l + 1);
}
}, 200);
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
Level: {level}
<VolumeMeter level={level} />
</div>
);
}
In this example there is no real audio input as you can see. The "level" fakes how loud the person would be talking.
How would you make something like this accessible? Would you even need to do that (because inspecting the UI of various providers didn't show any special tags or aria-labels)?
This was an interesting one!
Firstly a small apology, the example is a little messy as I have tried to leave in a few parts of my process to give you options, if anything doesn't make sense then let me know!
It should be easy to tidy up and turn into a reusable component.
The actual part for announcing is simple, you just need a visually hidden paragraph on the page with aria-live
on it and update the text inside it.
<p class="visually-hidden" aria-live="assertive">
//update the text in here and it will be announced.
</p>
What is more difficult is making a nice screen reader interface and announcer experience.
What I ended up doing was taking the average and peak volume over time and announcing a message based on those two parameters.
If the volume goes above 95 (an arbitrary number assuming volume goes to 100) or is consistently above 80 then I announce that the microphone is too loud.
If the volume is below 40 on average then I announce the microphone is too quiet.
If the average volume is below 10 then I assume the microphone is not working or they are not talking.
Otherwise I announce that the volume is ok.
The numbers will need some tweaking as I obviously simulated fluctuating volume levels and real world numbers may be different.
The other thing I did was make sure that the aria-live
region only updated every 2 seconds. However I have a slow announcer speed on my screen reader so you could probably get away with about 1200ms.
This is to avoid flooding the announcer queue.
Alternatively you could set this to a much larger value (say 10 seconds) as it would then be useful for monitoring on an ongoing basis throughout a call. If you decide to do this then set the announcer to aria-live="polite"
so it doesn't interrupt other screen reader announcements.
I didn't implement this but there are two things I can think of to make this more accurate (and less annoying, at the moment if would not be usable throughout a whole call) if you want it to be used as an ongoing monitoring tool throughout a call.
Firstly I would discard any values less than 10 for volume and only take an average of volumes above 10.
This would be more indicative of the actual volume level when the user is actively speaking.
Secondly I would discard all volume level announcements if someone else is talking. You would expect the user to be quiet at that point so no point telling them their microphone is quiet! Plus you don't really want announcements while others are talking.
I did try announcing the volume as an integer value every 500ms, but as this was a snapshot I felt it was not very accurate as to what was happening. That is why I went for average volume.
I then realised that you could get an average of 50 but peak at 100 (clipping) so added peak volume to the checks as well.
I made it so that when you focus the EQ graph level thing (no idea what to call it! hehe) it announced while it had focus at first.
I realised this was better as a toggle button so that users can switch it on and off at will.
having a toggle is preferable as it allows you to fiddle with volume levels while listening to the announcements, it also allows everyone else to switch it on and off as per their preference
The other advantage to taking the average and then announcing a phrase rather than a number is user experience if the indicator is left on.
It only announces when there is a change to the aria-live
region, so as long as the volume stays in acceptable levels it won't announce anything. Obviously if they stop speaking it will announce "the microphone is too quiet", but yet again it will only announce this once.
Users with vestibular disorders (sensitivity to movement disorders) may be distracted by the bars.
As such I have hidden the EQ altogether for those users.
Now because I have the EQ switched off by default this is not necessary. However if you decide to have the EQ switched on by default you can use the prefers-reduced-motion
media query to set a much slower animation speed etc. That is why I have left it in, as an example of how to use it (it was an overhang from when I made the EQ work on focus rather than a toggle so isn't needed anymore).
I decided that the most accessible way to implement this was with the EQ switched off by default. This also helps people with attention disorders who may get easily distracted by the bars as well as making the page safe for people with epilepsy / seizure disorders as the bars could "flash" more than 3 times in a second.
I take advantage of our visually-hidden
class when the EQ is switched on.
I change the text in the EQ and then visually hide it so that screen reader users still get useful button text when they focus the EQ but everyone else just gets to see the EQ bars.
Ignore the first part of the JS that is just my shoddy way to make the fake EQ.
I have added a very large comment block to mark the start of the actual parts you need. It starts around line 65 in the JS.
There are parts left in for your reference for alternative ways to do things (announce on focus and prefers-reduced-motion in the CSS) so it is a little messy.
Any questions just ask.
//just making the random bars work, ignore this except for globalVars.currentVolume which is defined in the next section as that is what we use to announce the volume.
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
function removeClass(selector, className){
var el = document.querySelectorAll(selector);
for (var i = 0; i < el.length; i++) {
el[i].classList.remove(className);
}
}
function addClass(selector, className){
var el = document.querySelectorAll(selector);
for (var i = 0; i < el.length; i++) {
el[i].classList.add(className);
}
}
var generateRandomVolume = function(){
var stepHeight = 0.8;
globalVars.currentVolume = randomInt(0,100);
setBars(globalVars.currentVolume * stepHeight);
setTimeout(generateRandomVolume, randomInt(102,250));
return;
}
function setBars(volume){
var bar1 = document.querySelector('.bar1');
var bar2 = document.querySelector('.bar2');
var bar3 = document.querySelector('.bar3');
var smallHeightProportion = 0.75;
var smallHeight = volume * smallHeightProportion;
bar1.style.height = smallHeight + "px";
bar2.style.height = volume + "px";
bar3.style.height = smallHeight + "px";
//console.log(globalVars.currentVolume);
if(globalVars.currentVolume < 80){
addClass('.bar', 'green');
removeClass('.bar', 'orange');
removeClass('.bar', 'red');
}else if(globalVars.currentVolume >= 90){
addClass('.bar', 'red');
removeClass('.bar', 'orange');
removeClass('.bar', 'green');
}else{
addClass('.bar', 'orange');
removeClass('.bar', 'red');
removeClass('.bar', 'green');
}
}
window.addEventListener('load', function() {
setTimeout(generateRandomVolume, 250);
});
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////////// ACTUAL ANSWER ///////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
//actual code you need, sorry it is a bit messy but you should be able to clean it up (and make it "Reactified")
// global variables, obviously only the currentVolume needs to be global so it can be used by both the bars and the announcer
var globalVars = {};
globalVars.currentVolume = 0;
globalVars.volumes = [];
globalVars.averageVolume = 0;
globalVars.announcementsOn = false;
//globalVars.indicatorFocused = false;
var audioIndicator = document.getElementById('audio-indicator');
var liveRegion = document.querySelector('.audio-indicator-announce');
var buttonText = document.querySelector('.button-text');
var announceTimeout;
//adjust the speech interval, 2 seconds felt right for me but I have a slow announcer speed, I would imagine real screen reader users could handle about 1200ms update times easily.
var config = {};
config.speakInterval = 2000;
//push volume level every 100ms for use in getting averages
window.addEventListener('load', function() {
setInterval(sampleVolume, 100);
});
var sampleVolume = function(){
globalVars.volumes.push(globalVars.currentVolume);
}
audioIndicator.addEventListener('click', function(e) {
toggleActive();
});
audioIndicator.addEventListener('keyup',function(e){
if (e.keyCode === 13) {
toggleActive();
}
});
function toggleActive(){
if(!audioIndicator.classList.contains('on')) {
audioIndicator.classList.add('on');
announceTimeout = setTimeout(announceVolumeInfo, config.speakInterval);
liveRegion.innerHTML = "announcing your microphone volume is now on";
buttonText.classList.add('visually-hidden');
buttonText.innerHTML = 'Mic<span class="visually-hidden">rophone</span> Level indicator on (click to turn off)';
console.log("SPEAK:","announcing your microphone volume is on");
}else{
audioIndicator.classList.remove('on');
clearTimeout(announceTimeout);
liveRegion.innerHTML = "announcing your microphone volume is now off";
buttonText.classList.remove('visually-hidden');
buttonText.innerHTML = 'Mic<span class="visually-hidden">rophone</span> Level indicator off (click to turn on)';
console.log("SPEAK:","announcing your microphone volume is off");
}
}
//switch on the announcer - deprecated idea, instead used toggle switch
//audioIndicator.addEventListener('focus', (e) => {
// setTimeout(announceVolumeInfo, config.speakInterval);
//});
//we take the average over the speakInterval. We also take the peak so we can see if the users microphone is clipping.
function getVolumeInfo(){
var samples = globalVars.volumes.length;
var totalVol = 0;
var avgVol, peakVol = 0;
var sample;
for(var x = 0; x < samples; x++){
sample = globalVars.volumes[x]
totalVol += sample;
if(sample > peakVol){
peakVol = sample;
}
}
globalVars.volumes = [];
var volumes = {};
volumes.average = totalVol / samples;
volumes.peak = peakVol;
return volumes;
}
var announceVolumeInfo = function(){
var volumeInfo = getVolumeInfo();
updateLiveRegion (volumeInfo.average, volumeInfo.peak);
//part of deprecated idea of announcing only on focus
//if(document.activeElement == document.getElementById('audio-indicator')){
// setTimeout(announceVolumeInfo, config.speakInterval);
//}
if(audioIndicator.classList.contains('on')) {
announceTimeout = setTimeout(announceVolumeInfo, config.speakInterval);
}
}
//we announce using this function, if you just want to read the current volume this can be as simple as "liveRegion.innerHTML = globalVars.currentVolume"
var updateLiveRegion = function(avgVolume, peak){
var speak = "Your microphone is ";
//doing it this way has a few advantages detailed in the post.
if(peak > 95 || avgVolume > 80){
speak = speak + "too loud";
}else if(avgVolume < 40){
speak = speak + "too quiet";
}else if(avgVolume < 10){
speak = speak + "not working or you are not talking";
}else{
speak = speak + "at a good volume";
}
console.log("SPEAK:", speak);
console.log("average volume:", avgVolume, "peak volume:", peak);
liveRegion.innerHTML = speak;
//optionally you could just read out the current volume level and that would do away with the need for tracking averages etc..
//liveRegion.innerHTML = "Your microphone volums level is " + globalVars.currentVolume;
}
#audio-indicator{
width: 100px;
height: 100px;
position: relative;
background-color: #333;
}
/** make sure we have a focus indicator, if you decide to make the item interactive with the mouse then also add a different hover state. **/
#audio-indicator:focus{
outline: 2px solid #666;
outline-offset: 3px;
}
#audio-indicator:hover{
background-color: #666;
cursor: pointer;
}
/*************we simply hide the indicator if the user has indicated that they do not want to see animations**********/
@media (prefers-reduced-motion) {
#audio-indicator{
display: none;
}
}
/***********my visually hidden class for hiding content visually but still making it screen reader accessible, preferable to sr-only etc as futureproofed and better compatibility********/
.visually-hidden {
border: 0;
padding: 0;
margin: 0;
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px); /* IE6, IE7 - a 0 height clip, off to the bottom right of the visible 1px box */
clip: rect(1px, 1px, 1px, 1px); /*maybe deprecated but we need to support legacy browsers */
clip-path: inset(50%); /*modern browsers, clip-path works inwards from each corner*/
white-space: nowrap; /* added line to stop words getting smushed together (as they go onto seperate lines and some screen readers do not understand line feeds as a space */
}
#audio-indicator .bar{
display: none;
}
#audio-indicator.on .bar{
display: block;
}
.bar, .button-text{
position: absolute;
top: 50%;
left: 50%;
min-height: 2px;
width: 25%;
transition: all 0.1s linear;
}
.button-text{
width: 90px;
transform: translate(-50%,-50%);
text-align: center;
color: #fff;
}
.bar1{
transform: translate(-175%,-50%);
}
.bar2{
transform: translate(-50%,-50%);
}
.bar3{
transform: translate(75%,-50%);
}
.green{
background-color: green;
}
.orange{
background-color: orange;
}
.red{
background-color: red;
}
<a href="#">dummy link for focus</a>
<div class="audio-indicator-announce visually-hidden" aria-live="assertive">
</div>
<div id="audio-indicator" tabindex="0">
<div class="button-text">Mic<span class="visually-hidden">rophone</span> Level indicator off (click to turn on)</div>
<div class="bar bar1"></div>
<div class="bar bar2"></div>
<div class="bar bar3"></div>
</div>
<a href="#">dummy link for focus</a>
While the above would work the question is "is an EQ graph relevant / a good experience from a UX perspective?".
You would have to user test to find that out.
Using a method similar to Zoom might be preferable (with a minor tweak and an accessible interface).
This is obviously just a thought and you might have a good use case of an EQ graph way of doing things, as I said it was just a thought!