react sequencer
5 July 2017As an exercise I'm going to build a simple sequencer in React. You can view it in action here. The final source code is on Github.
I'll start with create-react-app, which provides the necessary initial bootstrapping so we can get going quickly. If you don't already have it installed, run
npm install -g create-react-app
Then to create a new app:
create-react-app sequencer
When that's finished, run npm start
to open the base project in the browser.
The first thing we need to do is consider the structure of the app. At the most basic level we're going to want a controls
component and an area to contain the pads. So open up src/App.js
and replace it with this skeleton structure:
import React, { Component } from 'react';
import './App.css';
import Pads from './components/Pads';
import Controls from './components/Controls';
class App extends Component {
render() {
return (
<div className="App">
<Controls />
<Pads />
</div>
);
}
}
export default App;
All we've done is import two non-existent components, Pads
and Controls
and inserted them in the App
component. For now create a components
folder in src
and then create two empty components:
Pad
import React from 'react';
class Pads extends React.Component {
constructor() {
super();
}
render() {
return (
<div>
Pads
</div>
);
}
}
export default Pads;
Controls
import React from 'react';
const Controls = (props) => {
return (
<div className="controls">
Controls
</div>
);
}
export default Controls;
Let's look at building out the Pads
component first. We're going to want 8 rows, each row having 8 pads. Because we want to keep track of the state of each pad (on or off), we need to define a state object in the constructor
of the Pads
component:
this.state = {
pads: [
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0]
]
}
I'm just using an array of 8 rows, each row containing 8 elements which will be 0
or 1
depending on their state. We can use the map
function to display these in the render
function:
return (
<div className="pads">
{this.state.pads.map((row, rowIndex) => {
return (
<div className="row" key={rowIndex}>a
{row.map((pad, index) => {
return <Pad key={index} />
})}
</div>
)
})}
</div>
);
This iterates over each row in the pads
state, and for each row returns a div
, in which we map again over each element in that row, for each element returning a Pad
component. We have to pass the key
attribute so that React can identify which elements have changed at any given point.
We need to quickly create this Pad
component in components/Pad.js
:
import React from 'react';
const Pad = (props) => (
<div className="pad"></div>
);
export default Pad;
And let's add some styling: in src/App.css
delete everything and add these styles:
.row {
clear: both;
}
.pad {
width: 50px;
height: 50px;
float: left;
margin: 0 5px 5px 0;
cursor: pointer;
background: grey;
}
The next step is to allow the active state of a pad to be changed when you click on it. In Pads
create a new function to toggle a pad's state - we'll just console.log
the output for now:
toggleActive(rowIndex, id) {
console.log('Changed', rowIndex, id);
}
This will take the index of a row and the index of a pad within that row. We'll also need to bind toggleActive
to this
so we can reference the state later. So in the constructor put this line:
this.toggleActive = this.toggleActive.bind(this);
We then need to pass it - along with rowIndex
, the pad's index, and the pad's active state - as properties to the Pad
component, so modify Pads
like so:
return <Pad
key={index}
rowIndex={rowIndex}
id={index}
state={pad}
toggleActive={this.toggleActive} />
Then in Pad
we just need to add an onClick
event that calls the toggleActive
method in its parent component, passing its rowIndex
and id
:
return (
<div
className="pad"
onClick={() => props.toggleActive(props.rowIndex, props.id)}>
</div>
);
Now when you click on a pad you should see its coordinates in the console. Now we need to find the appropriate element in the state
and toggle its value between 0
and 1
. In the toggleActive
function in Pads
first we make a copy of the pads
state and get the current value of the pad that's been clicked:
let pads = [...this.state.pads];
let padActive = pads[rowIndex][id];
We're using the spread operator to make a clone of the pads array, then getting the clicked pad's current active state (0
or 1
). Then we just need to toggle it and update the state:
toggleActive(rowIndex, id) {
let pads = [...this.state.pads];
let padState = pads[rowIndex][id];
if (padState === 1) {
pads[rowIndex][id] = 0;
} else {
pads[rowIndex][id] = 1;
}
this.setState({ pads: pads });
}
We now need to conditionally add an active
class to the Pad
component depending on whether its value is 0
or 1
:
<div
className={"pad " + (props.state === 1 ? 'active' : '')}
onClick={() => props.toggleActive(props.rowIndex, props.id)}>
</div>
Finally, add a quick active
style to App.css
so we can see it in action:
.pad.active {
background: green;
}
Now when you click on a pad, it should turn green, and go back to grey when you click on it again.
Onto the controls... We'll need a piece of state that keeps track of whether the sequencer is playing or not. This needs to go in the root App
component, so add a constructor
and put it there. We'll also put a pos
variable there to keep track of our position in the grid when the sequencer's playing, and a bpm
value for when we set up our timer:
constructor() {
super();
this.state = {
playing: false,
pos: 0,
bpm: 220
}
this.togglePlaying = this.togglePlaying.bind(this);
}
I've added a binding for the next method we'll need, which will toggle between playing and not-playing. Add this to App.js
:
togglePlaying() {
if (this.state.playing) {
this.setState({ playing: false });
} else {
this.setState({ playing: true });
}
}
This grabs the current state, and updates it with its opposite. Pass this method as a property to the Controls
component, along with the playing
state:
<Controls playing={this.state.playing} togglePlaying={this.togglePlaying} />
Then pick it up in Controls.js
. I'm adding a line to set the button text according to whether the playing
state is true
or false
:
let buttonText = props.playing ? 'Stop' : 'Play';
return (
<div className="controls">
<button onClick={() => props.togglePlaying()}>{buttonText}</button>
</div>
);
We now need to be able to set and clear a timer that will call a tick
method every x
seconds, where x
is calculated from our bpm
. Let's start with the set method, remaining in App.js
:
setTimer() {
this.timerId = setInterval(() => this.tick(), this.calculateTempo(this.state.bpm));
}
This uses Javascript's setInterval
method to call tick()
on a regular basis. We store it in a timerId
variable so we can clear it when the user clicks Stop. calculateTempo()
is pretty simple:
calculateTempo(bpm) {
return 60000 / bpm;
}
Our tick
method will increment the current pos
and reset it to 0
if it reaches 7
:
tick() {
let pos = this.state.pos;
pos++;
if (pos > 7) {
pos = 0;
}
this.setState({ pos: pos });
console.log(pos);
}
Now hook this all up to the togglePlaying
method:
togglePlaying() {
if (this.state.playing) {
clearInterval(this.timerId);
this.setState({ playing: false });
} else {
this.setTimer();
this.setState({ playing: true });
}
}
Notice that now if the Play/Stop button is clicked and we are currently playing, we call clearInterval
on the timer, which will stop it. If we are not currently playing we start the timer. If you run this now you should see the pos
variable being logged to the console when you click Play. But we want the actual pads to be highlighted when their position corresponds to the root pos
state. To do this we need to pass the pos
state down, via the Pads
component, to the Pad
component. In App.js
:
<Pads pos={this.state.pos} />
Then in Pads.js
add the pos to pass down to Pad
:
return <Pad
key={index}
rowIndex={rowIndex}
id={index}
state={pad}
pos={this.props.pos}
toggleActive={this.toggleActive} />
Finally in Pad
we can add another class called playing
depending on whether pos
corresponds to the pad's position in the grid:
<div
className={"pad " + (props.state === 1 ? 'active' : '') + (props.pos === props.id ? ' playing' : '')}
onClick={() => props.toggleActive(props.rowIndex, props.id)}>
</div>
And add that class to App.css
, as well as a state for when a pad is both active and playing:
.pad.playing {
background: silver;
}
.pad.active.playing {
background: lime;
}
Now when you click Play the current column will be highlighted.
Our next task is to check, each time pos
advances, whether or not any pads at that position in the row are active. We can add a method checkPad()
and call it in tick()
. Put this at the end of the tick
method:
this.checkPad();
Now we're ready to create the function:
checkPad() {
}
But here we encounter a problem. Ideally we'd like to iterate over the pads
state to check which pads are active - but we've put the pads
state in a child component (Pads
), so we can't access it from its parent. The solution is to follow the React best practices and lift the state up. This means shifting the "source of truth", when it comes to pads, from the Pads
component to its parent, App
. So remove the entire pads
state from the Pads
component, and add it to the App
state:
this.state = {
pads: [
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0]
],
playing: false,
pos: 0,
bpm: 220
}
We then pass it back down to Pads
:
<Pads pos={this.state.pos} pads={this.state.pads} />
Since Pads
no longer has state we can make it a stateless functional component by removing the class declaration and replacing it with
const Pads = (props) => (
We also need to remove the render()
method and the return
statement, and replace all references to this.props
with props
. See the source on Github if this isn't clear.
Now in Pads
change
{this.state.pads.map((row, rowIndex) => {
to
{props.pads.map((row, rowIndex) => {
Now we've moved the pads
state the toggleActive
method in Pads
won't work. Take it and move it to App
, then add
this.toggleActive = this.toggleActive.bind(this);
to App
's constructor. We then need to pass it back down as a prop to Pads
:
<Pads pos={this.state.pos} pads={this.state.pads} toggleActive={this.toggleActive} />
and modify Pads
to call it:
return <Pad
key={index}
rowIndex={rowIndex}
id={index}
state={pad}
pos={props.pos}
toggleActive={() => props.toggleActive(rowIndex, index)} />
Now everything should work as it did before, except now we have access to pads
in our root component. This means we can now write our checkPad
method, which simply loops through the pads
state and calls a new method, playSound
, if the pad
at the current pos
is active:
checkPad() {
this.state.pads.forEach((row, rowIndex) => {
row.forEach((pad, index) => {
if (index === this.state.pos && pad === 1) {
this.playSound(rowIndex);
};
})
});
}
For simplicity's sake we're going to use the audio API to play a note depending on the row's position in the grid. Of course we could also play a sample instead. Let's set some frequencies in our constructor (source):
this.frequencies = [261.63, 277.18, 293.66, 311.13, 329.63, 349.23, 369.99, 392];
Also in the constructor
we'll initialise the audio API (since this tutorial isn't focused on the API I won't go into details here):
this.audioCx = new (window.AudioContext || window.webkitAudioContext)();
this.gain = this.audioCx.createGain();
this.gain.connect(this.audioCx.destination);
this.gain.gain.value = 1;
Then our playSound
method, which has already been given the rowIndex
, grabs the appropriate frequency from the frequencies
array, and plays a sine wave at that frequency:
playSound(rowIndex) {
let freq = this.frequencies[rowIndex];
let node = this.audioCx.createOscillator();
let currentTime = this.audioCx.currentTime;
node.frequency.value = freq;
node.detune.value = 0;
node.type = 'sine';
node.connect(this.gain);
node.start(currentTime);
node.stop(currentTime + 0.2);
}
There's a couple more things to do: add a control for the BPM, and make the whole thing a bit prettier.
The BPM control is a range input
, which goes under the button
tag in the Controls
component:
<div className="bpm">
<label>BPM:</label>
<input
type="range"
id="bpm"
min="1"
max="420"
step="1"
defaultValue={props.bpm}
onChange={props.handleChange} />
<output>
{ props.bpm }
</output>
</div>
It takes two properties passed from App
: bpm
and handleChange
. So back in App
, first add these as props to the render
method:
<Controls
bpm={this.state.bpm}
handleChange={this.changeBpm}
playing={this.state.playing}
togglePlaying={this.togglePlaying} />
Then add the changeBpm
method:
changeBpm(bpm) {
this.setState({ bpm: bpm.target.value });
if (this.state.playing) {
clearInterval(this.timerId);
this.setTimer();
}
}
This sets the new bpm
in state, and if we're already playing, stop the timer (since it's tied to the previous bpm
) and restart it.
That's it for the functionality. All that remains is to add some CSS polish - if you want, head over to my raw CSS file and copy it into App.css
.