DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 968,873 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
Cover image for Building a Virtual Beat Box in Redwood
Milecia
Milecia

Posted on

Building a Virtual Beat Box in Redwood

Sometimes you don't need to make a serious app to practice your JavaScript skills. We're going to play with a full-stack music app! It'll be a virtual beat box that you can make music with and store it in a database.

Setting up the app

We'll just jump in and start building the Redwood app because it has integrations to make it easier to set up the front-end and back-end. So in a terminal, run this command:

yarn create redwood-app virtual-music-box
Enter fullscreen mode Exit fullscreen mode

This generates a new Redwood project with a lot of new files and directories for us and we'll be focused on the web and api directories. The web directory will hold all of the front-end code, which we'll get to a little later. The api directory contains all of the back-end code.

To get started, let's write the back-end code.

Building the back-end

Redwood uses GraphQL to handle the back-end and Prisma to work with the database. We'll start by setting up a local Postgres instance. If you don't have Postgres installed, you can download it here.

Now you're going to add a new file to the root of the project called .env. Inside that file, you'll need to add the connection string for your Postgres instance. It should look similar to this:

DATABASE_URL=postgres://postgres:admin@localhost:5432/mixes
Enter fullscreen mode Exit fullscreen mode

With this connection string in place, let's move to the schema.prisma file in the api > db directory. This is where you can add the models for your database. In this file, you'll see a provider with sqlite as the value. We're going to update that to postgresql like this:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
Enter fullscreen mode Exit fullscreen mode

This is where we connect to the database using the connection string in that .env file we made. Next, we'll add a model to hold the music we make.

Making the model

You can delete the example model in prisma.schema and replace it with this:

model Mix {
  id     String @id @default(cuid())
  name   String
  sample String
}
Enter fullscreen mode Exit fullscreen mode

We're creating a new table called Mix that has a cuid for the id, a name for the song, and the sample of notes that make up the song. Since we have the model we need in place, we can run a database migration now with this command:

yarn rw prisma migrate dev
Enter fullscreen mode Exit fullscreen mode

This will create a new database on your local Postgres server and it will create a new migrations directory inside api > db with the SQL to update the database.

Creating the GraphQL types and resolvers

With the database ready to go, we can start working on the GraphQL server. A cool feature that Redwood has is autogenerating the types and resolvers for the basic CRUD functionality we need to get going. We'll take advantage of this with the following command:

yarn rw g sdl mix --crud
Enter fullscreen mode Exit fullscreen mode

This creates the GraphQL types and resolvers we need to create, update, delete, and read mixes we want to work with. If you take a look in api > src > graphql, you'll see a new file called mixes.sdl.ts. This has all of the types we need based on the model we created earlier.

Next, take a look in api > src > services > mixes. This holds the file for our resolvers and testing. If you open mixes.ts, you'll see all of the resolvers for create, read, update, and delete functionality already written for us.

So now we have a fully functional back-end! That means we can switch our focus to the front-end where we actually get to make music.

Moving to the front-end

We have to set up an interface for our users to select notes to play. We'll use a grid to handle this. There are a few libraries we need to install before we start working on the component.

In a terminal, go to the web directory and run these commands:

yarn add tone
yarn add styled-components
Enter fullscreen mode Exit fullscreen mode

The tone library is how we'll add sound to the browser. We'll use styled-components to help make the grid.

Let's start by creating a new page in Redwood. In a terminal, go back to the root directory of the project and run this:

yarn rw g page mixer /
Enter fullscreen mode Exit fullscreen mode

This will create a new page for the main view of our app. It automatically updates Routes.tsx for us and if you take a look in web > src > pages > MixerPage, you'll see the component, a Storybook story, and a unit test. Redwood generates all of this for us from that command above.

Adding the mixer

Go ahead and open MixerPage.tsx and delete everything out of it. We'll be making a completely new component. To start, we'll add all of the imports we need.

import { useState } from 'react'
import { useMutation } from '@redwoodjs/web'
import * as Tone from 'tone'
import styled from 'styled-components'
Enter fullscreen mode Exit fullscreen mode

Now we can define the MixerPage component and a few styled components to get started. We'll write the code and then walk through it.

const Flex = styled.div`
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
`

const Square = styled.div`
  background-color: #ABABAB;
  border: 2px solid #313131;
  height: 250px;
  width: 250px;
`

const MixerPage = () => {
  const notes = ['G3', 'A6', 'C9', 'B5', 'D7', 'F1', 'E8', 'A7', 'G6', 'B1', 'F4', 'C5']

  return (
    <>
      <h1>Mixer Page</h1>
      <Flex>
        {notes.map(note => (
          <Square key={note} onClick={() => console.log(note)} />
        ))}
      </Flex>
      <button onClick={() => console.log(mix)}>Save Sounds</button>
    </>
  )
}

export default MixerPage
Enter fullscreen mode Exit fullscreen mode

First, we make a couple of styled components. The Flex component is a flexbox we're able to make the grid shape we need for the beat box. The Square component is a colored box that represents a square in our grid.

Then we define the MixerPage component and add the export statement at the bottom of the file. Inside the component, we add a notes array that holds the notes we want users to be able to play.

Next, we add the return statement where we create our grid based on the number of notes in the array. We map over the notes array and add an onClick callback to work with notes. Then there's a save button that will eventually connect to the back-end and store all of the beats we make.

If you run the app with yarn rw dev, you should see something like this in your browser.

initial beat box grid

Connecting the back-end to save beats

There's one more thing we need to add and that's the connection to the back-end. We'll add our GraphQL mutation for saving new beats right below the Square styled component.

const CREATE_MIX_MUTATION = gql`
  mutation CreateMixMutation($input: CreateMixInput!) {
    createMix(input: $input) {
      id
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

Now we can start adding the real functionality to our grid. Inside the MixerPage component, add this code above the notes array:

const [createMix] = useMutation(CREATE_MIX_MUTATION)
const [mix, setMix] = useState([])
Enter fullscreen mode Exit fullscreen mode

This gives us access to the createMix mutation defined in the GraphQL resolvers we made earlier. It also creates the mix state we'll use to store the notes on in the database.

Now we get to do the fun thing and add the sound to our app. Below the mix state, add this line:

const mixer = new Tone.MembraneSynth().toDestination()
Enter fullscreen mode Exit fullscreen mode

This is how we use the tone library to play some kind of sound through our speakers. You can check out some of the others in their docs.

Playing the notes

With the mixer object ready, we need to add the function that will play the notes when a user clicks on a Square.

const playNote = (note) => {
  mixer.triggerAttackRelease(note, "6n")

  const isSet = mix.includes(note)

  if (!isSet) {
    setMix([...mix, note])
  } else {
    const updateMix = mix.filter((mixNote) => mixNote !== note)
    setMix(updateMix)
  }
}
Enter fullscreen mode Exit fullscreen mode

This playNote function takes in a string for the note value, which will be the note for the clicked Square. Then we use the mixer to actually play the sound with the triggerAttackRelease method and we pass it the note and a string for how we want the note to sound. You can play with this value and see how it changes the sound.

Next, we do a quick check to see if the note is already in the mix state. If it is not in the mix state, we'll update the state. Otherwise, we will filter out the note from the existing state and update the mix state.

The other function we need to make will handle saving the mixes we make.

const saveMix = (mix) => {
  const input = { name: `mix-${mix[0]}`, sample: mix.join() }
  createMix({ variables: { input } })
}
Enter fullscreen mode Exit fullscreen mode

This function takes the mix state and creates the input value we need to pass to the GraphQL mutation. Then we call the createMix mutation with the input value and save the mix to the database.

Now we're ready to wrap things up by calling these functions in our elements.

Updating the elements

We need to update some props on the Square element.

<Square key={note} selected={mix.includes(note)} onClick={() => playNote(note)} />
Enter fullscreen mode Exit fullscreen mode

We're using the selected prop to update the color of a square. That means we'll have to make a minor update to the Square styled component to take advantage of this prop.

const Square = styled.div`
  background-color: ${props => props.selected ? '#ABABAB' : '#EFEFEF'};
  border: 2px solid #313131;
  height: 250px;
  width: 250px;
`
Enter fullscreen mode Exit fullscreen mode

Now when a note is selected or unselected, the color of the square will update.

Next, we need to call the saveMix function when the button is clicked.

<button onClick={() => saveMix(mix)}>Save Sounds</button>
Enter fullscreen mode Exit fullscreen mode

This takes the current mix state and passes it to the GraphQL mutation. If you run the app and click a few squares, you should see something like this.

selected notes in the grid

There's one more thing we can add to take this app to the next level. We can play specific videos after the mix has been saved.

Adding media

We'll start by adding an array with links to different videos hosted in Cloudinary. Using Cloudinary just makes it easier to work with media files instead of worrying about hosting them ourselves in AWS or storing things in the database.

So right under the notes array, add the following videos array:

const videos = ['https://res.cloudinary.com/milecia/video/upload/v1606580790/elephant_herd.mp4', 'https://res.cloudinary.com/milecia/video/upload/v1606580788/sea-turtle.mp4', 'https://res.cloudinary.com/milecia/video/upload/v1625835105/test0/tq0ejpc2uz5jakz54dsj.mp4', 'https://res.cloudinary.com/milecia/video/upload/v1625799334/test0/ebxcgjdw8fvgnj4zdson.mp4']
Enter fullscreen mode Exit fullscreen mode

Feel free to make your own Cloudinary account and use some videos you like!

This has a few video links that we'll use to display something when a mix has been saved. Now we need to create a new state to store the video source URL for when we get ready to render. You can add this below the mix state:

const [video, setVideo] = useState('')
Enter fullscreen mode Exit fullscreen mode

We also need to add a video element below the button and its source is the video state. The video element will only display when the video state is not an empty string.

{video !== '' &&
  <video src={video} width='480' height='360' controls>
  </video>
}
Enter fullscreen mode Exit fullscreen mode

The last bit of code we need is to update the video state when we've successfully saved a beat. We'll add this to the saveMix method after we call the mutation.

const randomInt = Math.floor(Math.random() * (videos.length - 1))
setVideo(videos[randomInt])
Enter fullscreen mode Exit fullscreen mode

This gets a random video from the array and makes it the video that plays after a successful submission. After you save a mix, you should see something like this in the browser.

video playing after saving the mix

Finished code

You can take a look at the front-end code in this Code Sandbox or you can check out the whole project in the virtual-music-box folder of this repo.

Conclusion

There are a lot of different ways you can play with Tone.js to improve your apps. You could use it to make things more accessible for users. You can add a different level of entertainment for users that work with your app frequently. Or you can start teaching music theory online.

Web apps with sound give users a different experience and it's also fun to work with. Don't be afraid to try new things! You never know what you might find useful or interesting.

Top comments (0)

Visualizing Promises and Async/Await 🀯

async await

☝️ Check out this all-time classic DEV post