Drum Machine

Intro

This is the third project in the freeCodeCamp Front End Development Libraries Certification. The objective is to build a drum machine. It will have nine drum pads. When the user click a drum pad or presses the key shown on it, it should play the audio clip related to that pad and show the name of the sound on the display of the drum machine. The suggested method is to use a front end library like React. Read more about the project at Build a Drum Machine

Check out the end product at https://drum-machine.projects.yasakdogra.com

Planning

We will use React, Redux and Tailwind CSS for this project. We will also use Font Awesome for power button icon. Check out how to setup a basic project at: React with Redux, Tailwind and More

Thinking in React components, we can make the UI with the main DrumMachine component, which will have a Drum component and a Controls component. The Drum component will have several instances of DrumPad component. The Controls component will have a Power component and a Display component. The Power component will use a FontAwesome component for the power button.

We can split the state into drum state and power state. The drum state will keep track of the key presses and audio clip played. The power state will keep track of power on/off condition.

State

Power State

Let’ start with the easy part. We just need to use a boolean to save the power state and create an action to toggle the power.

Create the file src/state/powerState.ts and import createSlice

import { createSlice } from "@reduxjs/toolkit";

Create the interface and initial state for power

interface PowerState {
    on: boolean
}

const initialState: PowerState = {
    on: true
}

Create and export action creators, action reminders and reducer

const powerState = createSlice({
    name: 'power',
    initialState,
    reducers: {
        togglePower(state) {
            state.on = !state.on
        }
    }
})

export const { togglePower } = powerState.actions
export default powerState.reducer

Drum State

For the drum state, we need to keep track of currently playing pads so we can highlight the drum pads as long as the audio associated with them is playing. We can activate multiple pads at the same time, so we will need to keep track of each of them individually. We also need to keep track of the last played track to show it on the display.

Create the file src/state/drumState.ts. This time we will also import PayloadAction so we can send the id of the drum pad with every play action

import { PayloadAction, createSlice } from "@reduxjs/toolkit"

Create interface and set an initial state

interface State {
    playing: { [key: string]: boolean },
    latest: string
}

const initialState: State = {
    playing: {
        'Heater-1': false,
        'Heater-2': false,
        'Heater-3': false,
        'Heater-4': false,
        'Clap': false,
        'Open-HH': false,
        'Kick-n-Hat': false,
        'Kick': false,
        'Closed-HH': false
    },
    latest: ''
}

We need two actions for the drum state. setActive will set the drum pad state to playing on pad click or key press. setInactive will set the drum pad state to inactive once the audio clip has finished playing.

Create and export the action type, action creators and reducer

const drumState = createSlice({
    name: 'drum',
    initialState,
    reducers: {
        setActive(state, action: PayloadAction<{id: string}>) {
            state.playing[action.payload.id] = true
            state.latest = action.payload.id
        },
        setInactive(state, action: PayloadAction<{id: string}>) {
            state.playing[action.payload.id] = false
        },
    }
})

export const { setActive, setInactive } = drumState.actions
export default drumState.reducer

State Store

Now we just create a store to and use both the reducers we created above. We also export RootState type and AppDispatch type.

Create src/state/store.ts

import { configureStore } from "@reduxjs/toolkit"
import drumReducer from "./drumState"
import powerReducer from "./powerState"

export const store = configureStore({
    reducer: {
        power: powerReducer,
        drum: drumReducer
    }
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

Components

For this project, let’s use bottom-up approach to create the components. We will go with this sequence

PDDDDOIRRRWSUUUEPMMMRLAPPPYAAADDDCDORNUTMROLSDRUMMACHINEAPP

Power component

Create a file src/components/Power.tsx with imports for Redux, state store, and font awesome

import { useDispatch, useSelector } from "react-redux"
import { AppDispatch, RootState } from "../state/store"
import { togglePower } from "../state/powerState"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { faPowerOff } from "@fortawesome/free-solid-svg-icons"

Create the Power component. Inside, get power state using useSelector and state.power.on. Set the font awesome power icon to different colors depending on power state. On click, dispatch an action to switch power state using togglePower action creator.

export default function Power() {
    const power = useSelector((state: RootState) => state.power.on)
    const dispatch = useDispatch<AppDispatch>()
    const handlePowerButton = () => {
        dispatch(togglePower())
    };

    return (
        <div id="power" className="flex flex-col items-center w-4/5">
            <p className="text-2xl">Power</p>
            <FontAwesomeIcon className={`${power ? 'text-amber-600' : 'text-amber-900'}`} size="3x" icon={faPowerOff} onClick={handlePowerButton} />
        </div>
    )
}

Display component

Create src/components/Display.tsx with imports for Redux and state store

import { useSelector } from "react-redux"
import { RootState } from "../state/store"

Create the Display component. Inside we will use state.power.on to activate or deactivate the display, state.drum.latest to set the display text and state.drum.playing[latest] to set the color of the display text

export default function Display() {
  const power = useSelector((state: RootState) => state.power.on)
  const latest = useSelector((state: RootState) => state.drum.latest)
  const active = useSelector((state: RootState) => state.drum.playing[latest])
  return (
    <div className="w-full flex flex-wrap justify-center items-center">
      <div className="w-3/4"><p className="w-fit m-auto text-2xl">Display</p></div>
      <div className="w-3/4 border-2  bg-zinc-400 border-orange-800 h-12 flex flex-wrap items-center"><p id="display" className={`w-fit m-auto text-2xl font-bold ${active ? 'text-orange-400' : 'text-orange-900'}`}>{power ? latest: ""}</p></div>
    </div>
  )
}

Drumpad component

We will render the Drumpad component multiple times in our Drum component with different text and audio attached. We can create a generic component and pass it the fields that are different for each pad. For handling clicks, we will pass it a click handler function from the Drum component.

Create a file src/components/Drumpad.tsx and add imports

import { useSelector } from "react-redux";
import { RootState } from "../state/store";

Create a type for props. We will send it an id string, a text string to display on the drum pad, audio string to set the URL of the audio element and onActivate function to use on mouse click

type DrumPadProps = {
    id: string;
    text: string;
    audio: string;
    onActivate: (id: string, text: string) => void;
};

Create the functional component. Inside the function, we will use state.power.on to check if the drum pad should be on and state.drum.playing[id] to check if the current pad is playing. We will translate the pad and its shadow and use CSS transition to animate pad press

export default function DrumPad ({ id, text, audio, onActivate }: DrumPadProps) {
    const power = useSelector((state: RootState) => state.power.on);
    const playing = useSelector((state: RootState) => state.drum.playing[id]);

    return (
        <div
            id={id}
            className={`drum-pad h-16 w-16 flex items-center justify-center
            select-none font-bold cursor-pointer rounded-md text-3xl transition-all duration-75
            ${ playing ? 
                "bg-stone-400 text-orange-400 ring-2 ring-orange-400 shadow-[0_0px_0px_0px_rgba(0,0,0,0.3)] translate-y-1" 
                : power? "bg-stone-400 text-amber-700 ring-2 ring-amber-700 shadow-[0_5px_0px_0px_rgba(0,0,0,0.3)]"
                : "bg-stone-400 text-amber-900 ring-2 ring-amber-900 shadow-[0_5px_0px_0px_rgba(0,0,0,0.3)]"
            }`}
            onClick={() => onActivate(id, text)}
        >
            {text}
            <audio id={text} className="clip" src={audio} />
        </div>
    );
};

Controls component

The Controls component is just a composition of the Power and Display component.

Create src/components/Controls.tsx and import Power and Display

import Display from "./Display"
import Power from "./Power"

Create the Controls component. We will wrap these children in a flex div.

export default function Controls() {
    return (
        <div id="controls" className="flex flex-wrap items-center justify-center">
            <Power />
            <Display />
        </div>
    )
}

Drum component

The Drum component has a bit more going on compared to the Control component. We will need to handle keyboard key presses, create a function to pass to drum pads for handling clicks and handle the power on and off as well. Since there are multiple DrumPad components, we can create a single function in the parent Drum to handle the clicks.

Create src/components/Drum.tsx and add imports. We will need useEffect hook to handle power on and off.

import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch, RootState } from "../state/store";
import { setActive, setInactive } from "../state/drumState";
import DrumPad from "./DrumPad";

Create a type that holds fields for pads

type PAD = {
    key: string;
    id: string;
    src: string;
};

Use the type above and create a const that has all our drum pads


const PADS: PAD[] = [
    {
        key: "Q",
        id: "Heater-1",
        src: "/assets/audio/Heater-1.mp3",
    },
    {
        key: "W",
        id: "Heater-2",
        src: "/assets/audio/Heater-2.mp3",
    },
    {
        key: "E",
        id: "Heater-3",
        src: "/assets/audio/Heater-3.mp3",
    },
    {
        key: "A",
        id: "Heater-4",
        src: "/assets/audio/Heater-4_1.mp3",
    },
    {
        key: "S",
        id: "Clap",
        src: "/assets/audio/Heater-6.mp3",
    },
    {
        key: "D",
        id: "Open-HH",
        src: "/assets/audio/Dsc_Oh.mp3",
    },
    {
        key: "Z",
        id: "Kick-n'-Hat",
        src: "/assets/audio/Kick_n_Hat.mp3",
    },
    {
        key: "X",
        id: "Kick",
        src: "/assets/audio/RP4_KICK_1.mp3",
    },
    {
        key: "C",
        id: "Closed-HH",
        src: "/assets/audio/Cev_H2.mp3",
    },
];

Now we are going to create the functional component.

Let’s start by creating a function playSound to handle the clicks. This function will dispatch setActive action with the id of the clicked drum pad. Then it will find the audio element inside the clicked drum pad and play the audio. When the audio clip finishes, it will dispatch setInactive action with the id of the clicked drum pad.

We will over the PADS constant we created and add a DrumPad for each element and pass in fields and the playSound function as props

export default function Drum() {
    const power = useSelector((state: RootState) => state.power.on);
    const dispatch = useDispatch<AppDispatch>();

    const playSound = (id: string, text: string) => {
        if (!power) return;
        dispatch(setActive({ id }));
        const ae = document.getElementById(text) as HTMLAudioElement;
        ae.currentTime = 0;
        ae.onended = () => dispatch(setInactive({ id }));
        ae.play();
    };

    return (
        <div id="drum" className="grid grid-cols-3 gap-4 w-72 place-items-stretch">
            {PADS.map((pad) => (
                <DrumPad key={pad.key} id={pad.id} text={pad.key} audio={pad.src} onActivate={playSound} />
            ))}
        </div>
    );
};

We also need to handle key presses to activate the drum pads. We can do that by adding a keydown event handler on the window. We will do that inside useEffect which will be run at the first render and also when the power state changes. Let’s add that to our functional component

export default function Drum() {
    const power = useSelector((state: RootState) => state.power.on);
    const dispatch = useDispatch<AppDispatch>();

    const playSound = (id: string, text: string) => {
        if (!power) return;
        dispatch(setActive({ id }));
        const ae = document.getElementById(text) as HTMLAudioElement;
        ae.currentTime = 0;
        ae.onended = () => dispatch(setInactive({ id }));
        ae.play();
    };

    useEffect(() => {
        const handleKeyDown = (e: KeyboardEvent) => {
            const key = e.key.toUpperCase();
            const pad = PADS.find((pad) => pad.key === key);
            if (pad) {
                playSound(pad.id, pad.key);
            }
        };
        if (power) {
            window.addEventListener("keydown", handleKeyDown);
        }
        else {
            window.removeEventListener("keydown", handleKeyDown);
        }
        return () => window.removeEventListener("keydown", handleKeyDown);
    }, [power]);


    return (
        <div id="drum" className="grid grid-cols-3 gap-4 w-72 place-items-stretch">
            {PADS.map((pad) => (
                <DrumPad key={pad.key} id={pad.id} text={pad.key} audio={pad.src} onActivate={playSound} />
            ))}
        </div>
    );
};

Drum Machine component

The drum machine is a simple wrapper around the Drum and Controls components. We will use div with grid view and set number of columns to one on small screens, so the drum and controls stack vertically. We will set columns to two on wider devices. Throw in a little gap and round the borders and we are done!

Create src/components/DrumMachine.tsx

import Controls from "./Controls";
import Drum from "./Drum";

export default function DrumMachine() {
    return (
        <div id="drum-machine" className="grid grid-cols-1 md:grid-cols-2 gap-6 bg-gray-300 p-8 rounded-2xl">
            <Drum />
            <Controls />
        </div>
    );
};

App component

Finally, we will add the drum machine to our App component

import "./App.css";
import DrumMachine from "./components/DrumMachine";

function App() {
  return (
      <DrumMachine />
  );
}

export default App;

Thank you for reading. You can also check out my other projects for this series below.