Web Audio API in React

  • Code
  • React
  • JavaScript
  • Web Audio
  • Music
  • Audio

Happy Thanksgiving! 🦃

I have a tremendous amount to be grateful for this year: my family, my friends, my community, my arts, good health, fun travel, a new home, and a new engagement. That's right, a few weeks ago I proposed to Tessa and she said yes!

In this month's blog post I will be continuing my exploration of the Web Audio API. In last month's blog post, I created a small, basic HTML / CSS / JavaScript app that uses the Web Audio API to start playing a note and stop playing a note.

This month I want to take things a little bit further in a couple of different directions. First, I want to utilize the Web Audio API within a React context, and second, I want to add a few more controls - for wave type, frequency, and detune.

Bootstrapping a Create React App

I'll start by creating a Create React App:

npx create-react-app web-audio-api && cd web-audio-api

Install the dependencies:

npm install

Then start the app up:

npm start

I'll do a little bit of cleanup by deleting the following:

  • src/App.test.js
  • src/index.css
  • src/reportWebVitals.js
  • src/setupTests.js

I'll rename App.css to App.scss and install the SASS package:

npm install --save sass

I'll remove the deleted imports from src/index.js:

// src/index.jsimport React from "react";import ReactDOM from "react-dom/client";import App from "./App";
const root = ReactDOM.createRoot(document.getElementById("root"));root.render(  <React.StrictMode>    <App />  </React.StrictMode>);

I'll clean up App.js, updating the SASS file import and deleting the placeholder content:

// src/App.jsimport "./App.scss";
function App() {  return (    <div className="App">      <header className="App-header">        <h1>Web Audio API in React</h1>      </header>    </div>  );}
export default App;

And finally, I will add some very basic styling to App.scss:

/* src/App.scss */body {  background: #000;  color: white;  font-family: sans-serif;}
.App-header {  display: flex;  flex-direction: column;  align-items: center;  justify-content: center;  gap: 1.5rem;  .Start-and-stop {    display: flex;    gap: 1.5rem;  }}

Building an oscillator

With our app set up, we can start to build our oscillator. In the last blog post, we saw how to create a new AudioContext, destination, oscillator, and gain, and how to hook them all up:

let actx = new AudioContext();let osc1 = actx.createOscillator();let gain1 = actx.createGain();let out = actx.destination;
osc1.connect(gain1);gain1.connect(out);
const startButton = document.querySelector('button#startOsc');
startButton.addEventListener('click', () => {  osc1.start();});
const stopButton = document.querySelector('button#stopOsc');
stopButton.addEventListener('click', () => {  osc1.stop();});

In React, we can do something similar in App.js:

// src/App.jsimport "./App.scss";
let actx = new AudioContext();let osc1 = actx.createOscillator();let gain1 = actx.createGain();let out = actx.destination;
osc1.connect(gain1);gain1.connect(out);
function App() {  return (    <div className="App">      <header className="App-header">        <h1>Web Audio API in React</h1>        <div className="Start-and-stop">          <button            onClick={() => {              osc1.start();            }}          >            start          </button>          <button            onClick={() => {              osc1.stop();            }}          >            stop          </button>        </div>      </header>    </div>  );}
export default App;

The main difference is that we now use the onClick handlers in React instead of event listeners like in traditional JavaScript.

So at this point we have essentially recreated the same app we built with basic JavaScript last month, but now we're working in React world. Neat. However, our basic oscillator still isn't doing much, so let's fix that.

Changing the wave type

In this section, I'll start building out an oscillator control component so we can change the wave type and eventually change the frequency and detune.

I'll start by creating an OscillatorControls component in /src/components/OscillatorControls.js:

// src/components/OscillatorControls.jsexport default function OscillatorControls() {  return (    <div className="control">      <div className="param">        <label htmlFor="type">Wave type</label>        <select id="type">          <option value="sine">Sine</option>          <option value="square">Square</option>          <option value="sawtooth">Sawtooth</option>          <option value="triangle">Triangle</option>        </select>      </div>    </div>  );}

This is an uncontrolled input (which we will change in a moment) and we will import it and use it in App.js:

// src/App.jsimport "./App.scss";import OscillatorControls from "./components/OscillatorControls";
let actx = new AudioContext();let osc1 = actx.createOscillator();let gain1 = actx.createGain();let out = actx.destination;
osc1.connect(gain1);gain1.connect(out);
function App() {  return (    <div className="App">      <header className="App-header">        <h1>Web Audio API in React</h1>        <div className="Start-and-stop">          <button            onClick={() => {              osc1.start();            }}          >            start          </button>          <button            onClick={() => {              osc1.stop();            }}          >            stop          </button>        </div>      </header>      <OscillatorControls />    </div>  );}
export default App;

We'll also apply some generic styling in App.scss for this control area:

/* src/App.scss */body {  background: #1f38d8;  color: white;  font-family: sans-serif;}
.header {  display: flex;  flex-direction: column;  align-items: center;  justify-content: center;  gap: 1.5rem;  .startStop {    display: flex;    gap: 1.5rem;  }}
.control {  background: #0c0505d7;  margin: 1.5rem;  border-radius: 2.5rem;  padding: 0.5rem;  h2 {    text-align: center;  }  .param {    display: flex;    flex-direction: column;    justify-content: center;    align-items: center;    label {      width: 100%;      text-align: center;    }    input {      width: 25rem;    }  }}

In order to make this component work for us, we need to tie it to a piece of state. I'll create a state variable in App.js that will be an object that holds the oscillator settings:

// src/App.jsimport { useState } from "react";
import "./App.scss";import OscillatorControls from "./components/OscillatorControls";
let actx = new AudioContext();let osc1 = actx.createOscillator();let gain1 = actx.createGain();let out = actx.destination;
osc1.connect(gain1);gain1.connect(out);
function App() {  const [osc1Settings, setOsc1Settings] = useState({    type: osc1.type,  });
  return (    <div className="App">      <header className="App-header">        <h1>Web Audio API in React</h1>        <div className="Start-and-stop">          <button            onClick={() => {              osc1.start();            }}          >            start          </button>          <button            onClick={() => {              osc1.stop();            }}          >            stop          </button>        </div>      </header>      <OscillatorControls />    </div>  );}
export default App;

So we are deriving the osc1Settings.type from osc1's default type property, which is "sine". We can now create a function that handles changing this type, and pass that handler function and the state variable down to OscillatorControls through props:

// src/App.jsimport { useState } from "react";
import "./App.scss";import OscillatorControls from "./components/OscillatorControls";
let actx = new AudioContext();let osc1 = actx.createOscillator();let gain1 = actx.createGain();let out = actx.destination;
osc1.connect(gain1);gain1.connect(out);
function App() {  const [osc1Settings, setOsc1Settings] = useState({    type: osc1.type,  });
  const changeOsc1Type = (e) => {    let { value, id } = e.target;    setOsc1Settings({      ...osc1Settings,      [id]: value,    });    osc1[id] = value;  };
  return (    <div className="App">      <header className="App-header">        <h1>Web Audio API in React</h1>        <div className="Start-and-stop">          <button            onClick={() => {              osc1.start();            }}          >            start          </button>          <button            onClick={() => {              osc1.stop();            }}          >            stop          </button>        </div>      </header>      <OscillatorControls        settings={osc1Settings}        changeType={changeOsc1Type}      />    </div>  );}
export default App;

In changeOsc1Type, we're destructuring the id and value from the wave type select input we created, then updating the osc1Settings state variable object for that id, in this case "type" to be equal to the value, in this case either "sine", "square", "sawtooth", or "triangle". We then update the actual osc1.value prop so that this change is also reflected in the AudioContext oscillator object.

In order to get this to fully work, we need to destructure those props within the OscillatorControls component and use them to make the wave type select input into a controlled input:

// src/components/OscillatorControlsexport default function OscillatorControls({ settings, changeType }) {  const { type } = settings;  return (    <div className="control">      <div className="param">        <label htmlFor="type">Wave type</label>        <select id="type" value={type} onChange={changeType}>          <option value="sine">Sine</option>          <option value="square">Square</option>          <option value="sawtooth">Sawtooth</option>          <option value="triangle">Triangle</option>        </select>      </div>    </div>  );}

And there you have it. If you play your oscillator and select a new wave type, it will change that wave type. Cool!

Changing the frequency

I'm getting pretty tired of playing that A 440 tone, so the next thing I want to do is give us the ability to change the frequency of our oscillator. We've done most of the work already by creating our osc1Settings state variable, we just need to update it when we add more controls.

First, let's update the OscillatorControls component to have another input for frequency. This time I will use a Range input that will give us a slider we can use:

// src/components/OscillatorControlsexport default function OscillatorControls({ settings, changeType }) {  const { type } = settings;  return (    <div className="control">      <div className="param">        <label htmlFor="frequency">Frequency</label>        <input type="range" id="frequency" max="5000" />      </div>      <div className="param">        <label htmlFor="type">Wave type</label>        <select id="type" value={type} onChange={changeType}>          <option value="sine">Sine</option>          <option value="square">Square</option>          <option value="sawtooth">Sawtooth</option>          <option value="triangle">Triangle</option>        </select>      </div>    </div>  );}

Once again I'm starting with an uncontrolled input. We'll do the same steps as above, first updating our state variable with the property that we want, creating a change handler that updates both the state variable and the AudioContext oscillator property values, then pass that state and change handler back down to the OscillatorControls and using them to make the new input into a controlled input.

Back in App.js, let's update state and create our change handler:

// src/App.jsimport { useState } from "react";
import "./App.scss";import OscillatorControls from "./components/OscillatorControls";
let actx = new AudioContext();let osc1 = actx.createOscillator();let gain1 = actx.createGain();let out = actx.destination;
osc1.connect(gain1);gain1.connect(out);
function App() {  const [osc1Settings, setOsc1Settings] = useState({    frequency: osc1.frequency.value,    type: osc1.type,  });
  const changeOsc1 = (e) => {    let { value, id } = e.target;    setOsc1Settings({      ...osc1Settings,      [id]: value,    });    osc1[id].value = value;  };
  const changeOsc1Type = (e) => {    let { value, id } = e.target;    setOsc1Settings({      ...osc1Settings,      [id]: value,    });    osc1[id] = value;  };
  return (    <div className="App">      <header className="App-header">        <h1>Web Audio API in React</h1>        <div className="Start-and-stop">          <button            onClick={() => {              osc1.start();            }}          >            start          </button>          <button            onClick={() => {              osc1.stop();            }}          >            stop          </button>        </div>      </header>      <OscillatorControls        settings={osc1Settings}        change={changeOsc1}        changeType={changeOsc1Type}      />    </div>  );}
export default App;

It's pretty much identical to the work we did above, but the main difference is that the structure for frequency is osc1.frequency.value in the Web Audio API, whereas for type that structure is osc1.type. I'm using a different function that handles that more nested structure, but otherwise everything remains the same.

As an aside, you might be wondering about this little bit of code in the change handler:

setOsc1Settings({  ...osc1Settings,  [id]: value,});

All this is doing is creating a new state variable object, spreading in the current state variable object properties and values, and then updating the specific property value pair that we want. In this way if we update frequency but not type, type will remain the same but frequency will be updated, and vice versa.

We're now also passing the changeOsc1 change handler function to OscillatorControls through the change prop, so we can use that to convert the Range input to a controlled input that actually adjusts frequency:

// src/components/OscillatorControlsexport default function OscillatorControls({ settings, change, changeType }) {  const { frequency, type } = settings;  return (    <div className="control">      <div className="param">        <label htmlFor="frequency">Frequency</label>        <input          type="range"          id="frequency"          onChange={change}          max="5000"          value={frequency}        />      </div>      <div className="param">        <label htmlFor="type">Wave type</label>        <select id="type" value={type} onChange={changeType}>          <option value="sine">Sine</option>          <option value="square">Square</option>          <option value="sawtooth">Sawtooth</option>          <option value="triangle">Triangle</option>        </select>      </div>    </div>  );}

Sadly the Range slider doesn't give you the current value number by default, but adding it to the markup is trivial:

// src/components/OscillatorControlsexport default function OscillatorControls({ settings, change, changeType }) {  const { frequency, type } = settings;  return (    <div className="control">      <div className="param">        <label htmlFor="frequency">Frequency</label>        <input          type="range"          id="frequency"          onChange={change}          max="5000"          value={frequency}        />        <p>{frequency}</p>      </div>      <div className="param">        <label htmlFor="type">Wave type</label>        <select id="type" value={type} onChange={changeType}>          <option value="sine">Sine</option>          <option value="square">Square</option>          <option value="sawtooth">Sawtooth</option>          <option value="triangle">Triangle</option>        </select>      </div>    </div>  );}

Adding detune

The last thing I want to add to this control is detune, which further adjusts the tuning of the note. The process is incredibly similar to frequency. In fact, the data structure is pretty much identical. Where frequency was adjusted by changing osc1.frequency.value, detune is adjusted by changing osc1.detune.value. This means we can use most of the same code.

Still in OscillatorControls, we can add another Range input for detune. I'll go ahead and add everything I'll need to make this a controlled input as well:

export default function OscillatorControls({ settings, change, changeType }) {  const { frequency, detune, type } = settings;  return (    <div className="control">      <div className="param">        <label htmlFor="frequency">Frequency</label>        <input          type="range"          id="frequency"          onChange={change}          max="5000"          value={frequency}        />        <p>{frequency}</p>      </div>      <div className="param">        <label htmlFor="detune">Detune</label>        <input          type="range"          id="detune"          onChange={change}          max="5000"          value={detune}        />        <p>{detune}</p>      </div>      <div className="param">        <label htmlFor="type">Wave type</label>        <select id="type" value={type} onChange={changeType}>          <option value="sine">Sine</option>          <option value="square">Square</option>          <option value="sawtooth">Sawtooth</option>          <option value="triangle">Triangle</option>        </select>      </div>    </div>  );}

Now, back in App.js, the only thing we need to add is a detune property to the osc1Settings state variable. changeOsc1 takes care of everything else for us:

// src/App.jsimport { useState } from "react";
import "./App.scss";import OscillatorControls from "./components/OscillatorControls";
let actx = new AudioContext();let osc1 = actx.createOscillator();let gain1 = actx.createGain();let out = actx.destination;
osc1.connect(gain1);gain1.connect(out);
function App() {  const [osc1Settings, setOsc1Settings] = useState({    frequency: osc1.frequency.value,    detune: osc1.detune.value,    type: osc1.type,  });
  const changeOsc1 = (e) => {    let { value, id } = e.target;    setOsc1Settings({      ...osc1Settings,      [id]: value,    });    osc1[id].value = value;  };
  const changeOsc1Type = (e) => {    let { value, id } = e.target;    setOsc1Settings({      ...osc1Settings,      [id]: value,    });    osc1[id] = value;  };
  return (    <div className="App">      <header className="App-header">        <h1>Web Audio API in React</h1>        <div className="Start-and-stop">          <button            onClick={() => {              osc1.start();            }}          >            start          </button>          <button            onClick={() => {              osc1.stop();            }}          >            stop          </button>        </div>      </header>      <OscillatorControls        settings={osc1Settings}        change={changeOsc1}        changeType={changeOsc1Type}      />    </div>  );}
export default App;

And voila! We have expanded our little synth to offer us different options for the wave type, frequency (notes), and detune (the intonation of those notes).

Online demo

You can use a live version of this app over at https://admirable-marzipan-715e06.netlify.app/! I'm continuing to work on it so it might not look exactly like what we've built in this blog post, but feel free to give it a spin and pass along any feedback!