Building a real-time metronome app with React: Tailoring beats on the fly

Building a real-time metronome app with React: Tailoring beats on the fly

In this quick article, we'll provide guidelines for the development of a dynamic metronome app for musicians using React, powered by the Next.js framework and styled with Tailwind CSS. This application offers users the flexibility to customize the tempo, note duration, beat count, type of click sound, and individual beat intensity, providing a flexible metronome that adapts to musicians' preferences and needs.

The application

The application we are developing, ChronoBump, is user-friendly and pretty straightforward to use. It is virtually a single-page application because all its core functionality is presented on the main page, as shown in the figure below.

Each of the blue boxes shown on the top represents a beat and the number of bars inside it stands for its intensity, which can be adjusted by click. Below the play/stop button, we have various controls for configuring the metronome parameters, such as tempo, note duration, etc., which you can adjust even while the metronome is playing. Check how simple and dynamic the application is by watching the short video below:

You can also try the application out by accessing it on Vercel.

Project source code overview

The source code of the project can be found on this GitHub repository The initial boilerplate code was generated using create-next-app and choosing to use Typescript, Tailwind CSS, ESLint and the App Router. Here's how the source code files are structured:

Within the src folder, you'll find:

  • The app directory, which contains the layout and pages of the application. It is structured according to the Next.js App Router, defining three different pages for our application: main page, help page and about page.

  • The components folder, where client components for the main page are stored.

  • The types folder, which includes TypeScript files for interfaces and enums.

  • A folder dedicated to custom hooks.

  • A separate folder for services associated with sound reproduction in the browser.

Implementing the metronome

As previously stated, the main page of the application renders all the metronome functionality. The figure below shows all components rendered on the page and how the selectors provide configuration parameters for the metronome component, which is responsible for rendering the beats and reproducing the click sounds.

The Metronome component receives a configuration object and an isPlaying boolean as input. It does not manage the isPlaying state internally, so it can be reused in more advanced use cases in the future (e.g. on another page with predefined advanced training sessions). The configuration type is defined in the src/types/metronome-config.ts file:

// src/types/metronome-config.tsx
import ClickType from "./click-type";
import NoteValue from "./note-value";

namespace MetronomeConfig {
  export interface Config {
    beatCount: number;
    tempo: number;
    noteValue: NoteValue;
    clickType: ClickType;
  }

  export const MIN_TEMPO = 40;
  export const MAX_TEMPO = 220;
  export const MIN_BEAT_COUNT = 1;
  export const MAX_BEAT_COUNT = 7;

  export interface Action {
    type: "setTempo" | "setBeatCount" | "setNoteValue" | "setClickType";
    data: Partial<Config>;
  }

  export function reducer(state: Type, action: Action) {
    const newState = { ...state };

    switch (action.type) {
        case "setTempo":
            let newTempo = action.data.tempo;
            if (newTempo === undefined) return state;

            newTempo = Math.min(newTempo, MAX_TEMPO);
            newTempo = Math.max(newTempo, MIN_TEMPO);
            newState.tempo = newTempo;
            break;

        // More state updating logic
        ...
    }

    return newState;
  }
}

export default MetronomeConfig;

The file also defines a reducer function for the configuration state to encapsulate application-wide configuration constraints (keep in mind that we could reuse metronome logic in other future use cases), such as minimum and maximum tempo values, beat count, etc.

The metronome configuration state is managed by the main page component, which provides it to the Metronome component and updates it accordingly to the values selected by the user when interacting with the selector components.

// src/app/page.tsx
"use client";
// Imports
...
import { useReducer, useState } from "react";

export default function Home() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [config, dispatch] = useReducer(MetronomeConfig.reducer, {
    beatCount: 4,
    tempo: 100,
    noteValue: NoteValue.CROTCHET,
    clickType: ClickType.SYNTHETIC,
  });

  return (
    <div className="mt-2 flex flex-col items-center">
      <Metronome config={config} isPlaying={isPlaying} />

      <PlayStopButton onPlayStopChanged={setIsPlaying} />

      <div className="flex flex-col gap-5 items-center mt-5">
        {/* Selectors for metronome configuration */}
        <NoteSelector
          onNoteChanged={(noteValue) => dispatch({ type: "setNoteValue", data: { noteValue } })}
          noteValue={config.noteValue}
        />
        <TempoSelector
          onTempoChanged={(tempo) => dispatch({ type: "setTempo", data: { tempo } })}
          tempo={config.tempo}
        />
        <BeatCountSelector
          onBeatCountChanged={(beatCount) => dispatch({ type: "setBeatCount", data: { beatCount } })}
          beatCount={config.beatCount}
        />
        <ClickTypeSelector
          onClickTypeChanged={(clickType) => dispatch({ type: "setClickType", data: { clickType } })}
          clickType={config.clickType}
        />
      </div>
    </div>
  );
}

Notice that each one of the selectors is a React controlled component, meaning they receive their current value from the parent component and a callback function to notify when the value must be updated by the parent component. For example, let's take a look at the BeatCountSelector component:

// src/components/BeatCountSelector.tsx
"use client";
import IncDec from "./IncDec";

interface Props {
  beatCount: number;
  onBeatCountChanged: (beatCount: number) => void;
}

export default function BeatCountSelector({ beatCount, onBeatCountChanged }: Props) {
  return (
    <div className="flex flex-col items-center">
      <p>Number of beats</p>

      <IncDec
        label={"" + beatCount}
        onInc={() => onBeatCountChanged(beatCount + 1)}
        onDec={() => onBeatCountChanged(beatCount - 1)}
      />
    </div>
  );
}

The component does not manage the beatCount value as a state, receiving it via props instead. Whenever the user triggers an increment or a decrement, the component calls the onBeatCountChanged function, which should update the state externally and feed it back to the component via props. This approach makes the code easier to maintain and enhances the application's performance by reducing the number of components that handle states. In our main page component, the callback function passed to the selector dispatches an action to the MetronomeConfig.reducer function, so the configuration state can be updated cleanly and safely:

// src/app/page.tsx
...
export default function Home() {
  const [config, dispatch] = useReducer(MetronomeConfig.reducer, {
    beatCount: 4,
    tempo: 100,
    noteValue: NoteValue.CROTCHET,
    clickType: ClickType.SYNTHETIC,
  });
  ...
  return (
    ...
        <BeatCountSelector
          onBeatCountChanged={(beatCount) => dispatch({ type: "setBeatCount", data: { beatCount } })}
          beatCount={config.beatCount}
        />
    ...
}

The Metronome component

The Metronome component is responsible for managing the core logic of our application. It gets both a config object and an isPlaying flag via props and manages two states: an array of beats and the active beat index (which is null whenever the metronome is not playing). The component basically handles two things:

  • Rendering a MetronomeBeat component for each beat

  • Periodically updating the activeBeat state according to the selected tempo and note duration, reproducing a proper click sound each time it updates

// src/components/Metronome.tsx
"use client";
// Imports

interface Props {
  config: MetronomeConfig.Type;
  isPlaying: boolean;
}

const defaultBeats: BeatLevel[] = [BeatLevel.STRONG, BeatLevel.NORMAL, BeatLevel.NORMAL, BeatLevel.NORMAL];

export default function Metronome({ config, isPlaying }: Props) {
  const [beats, setBeats] = useState<BeatLevel[]>(defaultBeats);
  const [activeBeat, setActiveBeat] = useStepMetronome(
    config.tempo,
    config.beatCount,
    config.noteValue,
    isPlaying
  );
  usePlayClickSound(activeBeat, activeBeat !== null ? beats[activeBeat] : null, config.clickType, isPlaying);

  useEffect(() => {
    setBeats((curr) => {
      const newBeats = [...curr];
      // Adds or removes elements to/from the array, 
      // according to number of beats
      ...
      return newBeats;
    });
    setActiveBeat(null);
  }, [config.beatCount, setActiveBeat]);

  const beatLevelChanged = (index: number, level: BeatLevel) => {
    setBeats((curr) => {
      const newBeats = [...curr];
      newBeats[index] = level;
      return newBeats;
    });
  };

  return (
    <div className="flex justify-center gap-1 md:gap-4 mb-5">
      {beats.map((b, index) => (
        <MetronomeBeat
          key={index}
          beatLevel={b}
          onBeatLevelChanged={(level) => beatLevelChanged(index, level)}
          active={activeBeat == index}
        />
      ))}
    </div>
  );
}

To keep the code clean, we defined two custom hooks to be used in the Metronome component:

  • useStepMetronome: Uses the useEffect hook to call the window.setInterval method for recurrently updating the activeBeat state ("stepping" the metronome according to the configured tempo and note duration). Whenever the parameters tempo or noteValue is changed, the previous interval is cleared and a new interval is set, but the activeBeat state is not affected, so the metronome can keep playing normally while the user is making adjustments.
// src/hooks/useStepMetronome.ts
import NoteValue from "@/types/note-value";
import { Dispatch, SetStateAction, useEffect, useState } from "react";

export default function useStepMetronome(
  tempo: number,      // Resets setInterval when tempo changes
  beatCount: number,  // Resets setInterval when the beatCount changes
  noteValue: NoteValue, // Resets setInterval when the noteValue changes
  isPlaying: boolean // Clear interval e does nothing when isPlaying changes to false
): [number | null, Dispatch<SetStateAction<number | null>>] {
  const [activeBeat, setActiveBeat] = useState<number | null>(null);

  useEffect(() => {
    if (isPlaying) {
      const stepMetronome = () => setActiveBeat((curr) => {
          if (curr == null) return 0;

          return curr < beatCount - 1 ? curr + 1 : 0;
        });

      let period = Math.round((1000 * 60) / tempo);
      if (noteValue == NoteValue.QUAVER) period = Math.round(period / 2);
      else if (noteValue == NoteValue.QUAVER_TRIPLET) period = Math.round(period / 3);

      const intervalId = setInterval(stepMetronome, period);
      stepMetronome(); // Play first click immediately

      return () => clearInterval(intervalId);
    } else {
      setActiveBeat(null);
    }
  }, [tempo, noteValue, beatCount, isPlaying]);

  return [activeBeat, setActiveBeat];
}
  • usePlayClickSound: Also uses the useEffect hook to emit a click sound every time the activeBeat value is changed
// src/hooks/usePlayClickSound.ts
// Imports
...

export default function usePlayClickSound(
  activeBeatIndex: number | null,
  level: BeatLevel | null,  // Defines the intensity of the click
  clickType: ClickType,
  isPlaying: boolean
) {
  useEffect(() => {
    if (activeBeatIndex !== null && level !== null && isPlaying) {
      if (clickType === ClickType.SYNTHETIC) synthClickService.play(level);
      else recordedClickService.play(level);
    }
  }, [activeBeatIndex, level, isPlaying, clickType]);
}

The usePlayClickSound hook uses one of two services to reproduce sound, depending on the click type configuration. The SynthClickService uses the Web Audio API to emit synthetic beats, while the RecordedClickService reproduces pre-recorded click sounds from MP3 files located in the public folder. The first should be preferred for mobile devices because their browsers tend to give lower priority to audio reproduction, which can result in timing issues.

Handling the beats

When the beat count is increased or decreased, we have to update the beats array and render the corresponding instances of the MetronomeBeat component accordingly, maintaining the level configuration set up by the user. This is why instead of creating a new array with whole new elements, we must add elements to the end of the array if beatCount is increased or remove elements from the end of the array if beatCount is decreased:

// src/components/Metronome.tsx
...
export default function Metronome({ config, isPlaying }: Props) {
    ...
    useEffect(() => {
        setBeats((curr) => {
          const newBeats = [...curr];

          if (config.beatCount > curr.length) {
            for (let i = curr.length; i < config.beatCount; i++) newBeats.push(BeatLevel.NORMAL);
          } else if (config.beatCount < curr.length) {
            for (let i = curr.length; i > config.beatCount; i--) newBeats.pop();
          }

          return newBeats;
        });
        setActiveBeat(null);
    }, [config.beatCount, setActiveBeat]);

    ...

    return (
    <div className="flex justify-center gap-1 md:gap-4 mb-5">
      {beats.map((b, index) => (
        <MetronomeBeat
          key={index}
          beatLevel={b}
          onBeatLevelChanged={(level) => beatLevelChanged(index, level)}
          active={activeBeat == index}
        />
      ))}
    </div>
  );
}

MetronomeBeat is a controlled component that renders a box with a few bars inside, representing the corresponding beat level. It also gets an active property as input, so it can display a different background color when its corresponding beat is being played:

// src/components/MetronomeBeat.tsx
"use client";
import BeatLevel from "@/types/beat-level";

interface Props {
  beatLevel: BeatLevel;
  onBeatLevelChanged: (beatLevel: BeatLevel) => void;
  active: boolean;
}

export default function MetronomeBeat({ beatLevel, onBeatLevelChanged, active }: Props) {
  const clickHandler = () => {
    const newLevel = (beatLevel === BeatLevel.STRONG ? BeatLevel.WEAK : beatLevel + 1) as BeatLevel;
    onBeatLevelChanged(newLevel);
  };

  return (
    <div
      className={`${
        active ? "bg-cyan-400" : "bg-transparent"
      } border border-cyan-600 rounded py-2 px-1 md:px-2 md:py-3 
      flex flex-col gap-1 items-center justify-end 
      w-[45px] md:w-[80px] h-[60px] md:h-[80px]
      cursor-pointer`}
      onClick={clickHandler}
    >
      {Array.from(Array(beatLevel + 1).keys()).map((l) => (
        <span key={l} className={`${active ? "bg-gray-50" : "bg-cyan-600"} rounded w-full h-3 block`}></span>
      ))}
    </div>
  );
}

When the user clicks on the box, the onBeatLevelChanged callback function is called, so the associated beatLevel can be updated by its parent, which is the Metronome component:

// src/components/Metronome.tsx
...
export default function Metronome({ config, isPlaying }: Props) {
    ...
    const beatLevelChanged = (index: number, level: BeatLevel) => {
      setBeats((curr) => {
        const newBeats = [...curr];
        newBeats[index] = level;
        return newBeats;
      });
    };
    ...
}

While useStepMetronome depends on the beatCount state, it operates independently of the individual elements of the beats array, so the user can make real-time adjustments to beat levels without resetting the metronome.

Conclusion

In summary, we've presented guidelines and insight into the development of a flexible, dynamic and user-friendly metronome application in React, bolstered by Next.js. Our application, ChronoBump, offers extensive customization options, enabling musicians to adjust tempo, note duration, beat count, click sound, and intensity for each individual beat. We've explored the application's architecture and coordination of components and hooks. We hope this article has provided valuable insights and inspired your own creative journey in the realm of web application development. Feel free to leave a comment below if you have a question or a suggestion for improving this project.

Resources