Building a Basic Next.js Synthesizer
If you've followed my blog for the last year or two, you'll know that toward the end of 2024 I was learning about the Web Audio API and how to use it in React. In those posts, I wrote about audio nodes and generating some pretty basic synth sounds, but all of that barely scratched the surface of bringing these two frameworks together. Then last year I focused on other topics, some Sass and CSS stuff, and the 30 Days Lost In Space kit, but I kept tinkering with Web Audio off and on.
Around summertime, I finished a version of a modular synthesizer, complete with a keyboard, a three oscillator synth bank, a filter, and an ADSR envelope. It was pretty sweet, but it was also extremely buggy. I had my suspicions about what was causing the bugs, but time and attention ran short once more so it sat in its buggy state for a few more months.
Over the holidays a few weeks ago, I finally had the space to revisit the project and refactor it completely from the ground up, resulting in a very robust and elegant modular synth that is written in TypeScript and the Next.js App router. I squashed pretty much all of the known bugs in the process. I'm incredibly proud of it.
Over the next few blog posts, I'll write about how I built it, how it works, and the underlying architecture patterns that fix my previous pain points. Let's dive in.
What we're building
Here's what I'll be building in this first blog post in this series:
Loading Synth V1...
This synthesizer has all of the basic stuff we need to make sound: a functional keyboard (which you can use with your computer keyboard), volume, and a three oscillator bank with configurable wave types, detune, and the ability to mute or adjust the volume of each oscillator. We'll add more complex stuff in the coming posts, but for now this is enough to start demonstrating the basic architecture of this app.
Keyboard component
The first step is to create a basic keyboard that plays notes. We'll layer on advanced features as we go, but first let's create a Synth component to serve as the root of this entire app:
// Synth/index.tsx:"use client";import React, { useRef, useCallback } from "react";import Keyboard from "./components/Keyboard";import { useSynthEngine } from "./hooks/useSynthEngine";import type { SynthSettings } from "./types";import "./synth.scss";import { Orbitron } from "next/font/google";const synthFont = Orbitron({subsets: ["latin"],variable: "--font-synth",display: "swap",});export default function Synth() {const settingsRef = useRef<SynthSettings>({masterVolume: 1,oscillator1: {type: "square",octave: 8,detune: 0,volume: 0.35,isMuted: false,},easing: 0.005,});const { playNote: enginePlayNote, stopNote: engineStopNote } =useSynthEngine(settingsRef);const playNote = useCallback((note: string, freq: number) => {enginePlayNote(note, freq);},[enginePlayNote]);const stopNote = useCallback((note: string) => {engineStopNote(note);},[engineStopNote]);return (<div className={`synth-container ${synthFont.variable}`}><div className="synth"><div className="title"><h1 className="synth-title">next.js synth mk1</h1></div><div className="synth-padding"><div className="synth-modules"><Keyboard onKeyDown={playNote} onKeyUp={stopNote} /></div></div></div></div>);}
There's quite a bit going on here, but I'll try to walk through it all. To start, there is a settingsRef that holds the settings for our synth. Right now it's just the masterVolume, settings for a single oscillator (wave type, an octave value, a detune value, a volume value, and mute boolean), and an easing value. We'll be adding to this as we go, but this is sufficient to start us off.
Next, we are rendering a <Keyboard /> child component, which accepts onKeyDown and onKeyUp props, the values of which are playNote and stopNote functions. These are functions that we get from a useSynthEngine custom hook:
// Synth/hooks/useSynthEngine.ts:"use client";import React, { useRef, useEffect } from "react";import Oscillator from "../constructors/Oscillator";import type { SynthSettings } from "../types";export function useSynthEngine(settingsRef: React.RefObject<SynthSettings>) {const audioCtx = useRef<AudioContext | null>(null);const masterGain = useRef<GainNode | null>(null);const activeNotes = useRef(new Map());function octaveToFrequency(baseFrequency: number, octave: number) {const multipliers: Record<number, number> = {2: 4,4: 2,8: 1,16: 0.5,32: 0.25,64: 0.125,};return baseFrequency * (multipliers[octave] || 1);}useEffect(() => {if (!audioCtx.current) {audioCtx.current = new (window.AudioContext ||(window as any).webKitAudioContext)();masterGain.current = audioCtx.current.createGain();masterGain.current.connect(audioCtx.current.destination);}return () => {audioCtx.current?.close();audioCtx.current = null;};}, []);function playNote(note: string, frequency: number) {const ctx = audioCtx.current;if (!ctx || !masterGain.current) return;if (ctx.state === "suspended") ctx.resume();const settings = settingsRef.current;masterGain.current.gain.value = settings.masterVolume ?? 1;const oscillators = [new Oscillator({...settings.oscillator1,audioContext: ctx,type: settings.oscillator1.type,frequency: octaveToFrequency(frequency, settings.oscillator1.octave),detune: settings.oscillator1.detune,volume: settings.oscillator1.volume / 3,connection: masterGain.current,easing: settings.easing,version: 1,isMuted: settings.oscillator1.isMuted,}),];activeNotes.current.set(note, oscillators);}function stopNote(note: string) {const voice = activeNotes.current.get(note);if (voice) {voice.forEach((osc: any) => osc.stopOscillatorConstructor());activeNotes.current.delete(note);}}return {playNote,stopNote,};}
useSynthEngine is the heart of our synth. It is where we create our AudioContext and its various gain and oscillator nodes and wire them up together as well as tell it to play some notes.
We hold the AudioContext (audioCtx), the main GainNode (masterGain), and the notes we're playing (activeNotes) in various refs. Pretty soon we're going to be wiring up some controlled inputs to be able to change things like volume or the oscillator's wave type, and we'll be using state to do so. As those controlled inputs change, React is going to trigger rerenders. However, the AudioContext should persist across renders and its notes and gain nodes should also persist across renders.
In a previous iteration of this project, I threw the AudioContext into React context. I kind of managed to make it work. It played notes and chords, and I was able to use controlled inputs for volume and wave types. But I started running into issues when I introduced a LFO and Envelope Filter. When I started adjusting those modules, I would get really weird phantom noise that persisted forever, and if I made too many adjustments to the controlled inputs too quickly while playing a bunch of notes, the entire app would crash. It was buggy and noisy and kind of cool.
It turns out the problem was using React context to store AudioContext instead of a ref. When you create an AudioContext, the browser spins up a dedicated Real-Time Audio Thread, which is designed to process sound. This operates independently of the main thread. However, when you initialize AudioContext in a Context Provider, that initialization runs every time the component renders. So as I was adjusting controlled inputs, I was changing state a bunch and triggering a ton of renders. That's just how React works, right? But with the Context Provider spinning up AudioContext on each rerender, I was also stacking up AudioContexts on the Audio Thread, and then I was hitting browser limitations and it would crash.
So in our hook, we're creating refs to hold these browser nodes and persist their values across renders. Since this is a browser API, we need to run a mount-only useEffect to create the AudioContext and GainNode and save them into their respective refs. For now, we connect the masterGain GainNode to the AudioContext destination, which is our speakers.
useSynthEngine is also where we define our functionality to play and stop notes. Here's the playNote function:
function playNote(note: string, frequency: number) {const ctx = audioCtx.current;if (!ctx || !masterGain.current) return;if (ctx.state === "suspended") ctx.resume();const settings = settingsRef.current;masterGain.current.gain.value = settings.masterVolume ?? 1;const oscillators = [new Oscillator({...settings.oscillator1,audioContext: ctx,type: settings.oscillator1.type,frequency: octaveToFrequency(frequency, settings.oscillator1.octave),detune: settings.oscillator1.detune,volume: settings.oscillator1.volume / 3,connection: masterGain.current,easing: settings.easing,version: 1,isMuted: settings.oscillator1.isMuted,}),];activeNotes.current.set(note, oscillators);}
We're using a class constructor to create an Oscillator node (more on that in a second) that plays a specific note (frequency), and we save that into the activeNotes ref, which holds a Map. We derive the other oscillator and synth settings (volume, detune, easing) from the settingRef back in our parent Synth component.
Here's our stop note function:
function stopNote(note: string) {const voice = activeNotes.current.get(note);if (voice) {voice.forEach((osc: any) => osc.stopOscillatorConstructor());activeNotes.current.delete(note);}}
It's a little simpler. When we release a key, a function fires with the note of the key that was released. We target that note within the activeNotes map, and tell that one to stop playing. That way we can hold a chord, stop playing a single note within that chord, and the other notes will continue to play.
Finally, we return playNote and stopNote from this hook so that the <Keyboard /> component can consume them via props.
I mentioned a class constructor for the Oscillator a second ago. Well, here it is:
// Synth/constructors/Oscillator.ts:import type { OscillatorConstructorProps } from "../types";export default class Oscillator {private audioContext: AudioContext;private oscillator: OscillatorNode;private gateGain: GainNode;private easing: number;private targetVolume: number;public version: number;constructor(props: OscillatorConstructorProps) {const {audioContext,type,frequency,detune,volume,connection,easing,version,isMuted,} = props;this.version = version;this.audioContext = audioContext;this.easing = easing;this.targetVolume = isMuted ? 0 : volume;this.oscillator = this.audioContext.createOscillator();this.oscillator.frequency.value = frequency;this.oscillator.detune.value = detune;this.oscillator.type = type;this.gateGain = this.audioContext.createGain();this.gateGain.gain.value = 0;this.oscillator.connect(this.gateGain);this.gateGain.connect(connection);this.oscillator.start();this.startOscillatorConstructor();}startOscillatorConstructor(): void {const { currentTime } = this.audioContext;this.gateGain.gain.cancelScheduledValues(currentTime);this.gateGain.gain.setValueAtTime(this.targetVolume,currentTime + this.easing);}stopOscillatorConstructor(): void {const { currentTime } = this.audioContext;this.gateGain.gain.cancelScheduledValues(currentTime);this.gateGain.gain.setValueAtTime(0, currentTime);this.oscillator.stop();setTimeout(() => {this.oscillator.disconnect();this.gateGain.disconnect();}, 1000);}}
Again, quite a bit going on, but the gist is that we pass along the AudioContext so we can create our OscillatorNode and its respective GainNode, and give that OscillatorNode some parameters, like the note it should be (frequency), its volume (the value of the GainNode), the waveform (type), and what it should connect to (the masterGain GainNode). We also further refine behaviors for starting and stopping notes here in the startOscillatorConstructor and stopOscillatorConstructor methods. These methods are where we can use an envelope filter to fine-tune how we ease into and out of notes. We are only using it in one place so far, but as this synth develops, the easing we defined in settingsRef is going to get a lot of use here.
You may wonder why I went with a class constructor here. Great question. Back in settingsRef, you may have noticed that there is a property named oscillator1. Right now the synth only has this one oscillator, but by the end of this blog post, we will add two more, oscillator2 and oscillator3. Having a bank of three oscillators, with their own independent volumes, wave types, and detunes gives us a very rich, layered sound. We need a factory-type mechanism to construct these OscillatorNodes from the AudioContext. JavaScript's class constructor pattern is perfectly suited to do this.
The last major thing to cover at this stage is the Keyboard component:
// Synth/components/Keyboard/index.tsx"use client";import React, { useEffect, useRef } from "react";import QwertyHancock from "qwerty-hancock";interface KeyboardProps {onKeyDown: (note: string, freq: number) => void;onKeyUp: (note: string, freq?: number) => void;}export default function Keyboard({ onKeyDown, onKeyUp }: KeyboardProps) {const onKeyDownRef = useRef(onKeyDown);const onKeyUpRef = useRef(onKeyUp);useEffect(() => {onKeyDownRef.current = onKeyDown;onKeyUpRef.current = onKeyUp;}, [onKeyDown, onKeyUp]);useEffect(() => {const widthAtLoad = window.innerWidth;const isLargeScreen = widthAtLoad >= 768;const keyboard = new QwertyHancock({id: "keyboard",width: isLargeScreen ? 500 : 250,height: 152,octaves: isLargeScreen ? 2 : 1,startNote: "C4",activeColour: "#f7f3a2",});keyboard.keyDown = (note, freq) => onKeyDownRef.current(note, freq);keyboard.keyUp = (note, freq) => onKeyUpRef.current(note, freq);return () => {const keyboardDiv = document.getElementById("keyboard");if (keyboardDiv) {keyboardDiv.innerHTML = "";}};}, []);return (<div className="keyboard"><div id="keyboard" /></div>);}
We're using the qwerty-hancock library to give us a keyboard. Qwerty-hancock has event listeners that fire when we press and release keys on the home row (and some keys on the row above that) of our computer keyboard to generate notes. In our component we have a useEffect that defines aspects of our keyboard, like height and width, the id of the container that the library will mount the keyboard, and so on. We're setting the width and number of octaves of the keyboard based on the width of the window when the page loads.
We also attach callback functions to be called on the keyDown and keyUp methods for the qwerty-hancock keyboard. Those callback functions need to accept note and freq arguments that correspond to the note and frequency of the key that was either pressed or released. Those callback functions then define what we want to happen when a key is pressed or released. We have already defined that functionality as playNote and stopNote in the useSynthEngine custom hook and passed them through to the Keyboard component as onKeyDown and onKeyUp props. Additionally, to prevent redefining those functions on rerenders, we hold those functions within refs (these functions are also memoized in Synth/index.tsx using the useCallback hook).
The last things to cover at this junction are our type interfaces:
// Synth/types.ts:export interface OscillatorConstructorProps {audioContext: AudioContext;type: "sine" | "square" | "sawtooth" | "triangle";frequency: number;detune: number;volume: number;connection: GainNode;easing: number;version: number;isMuted: boolean;}export interface OscillatorSettings {type: "sine" | "square" | "sawtooth" | "triangle";octave: number;detune: number;volume: number;isMuted: boolean;}export interface SynthSettings {masterVolume: number;oscillator1: OscillatorSettings;easing: number;}
OscillatorConstructorProps corresponds to the properties of the Oscillator constructor class in constructors/Oscillator.ts and SynthSettings corresponds to the settingsRef that holds our overall synth settings in the main Synth component.
With all of that together, we have a synth that accepts keyboard inputs to play certain notes when keys are pressed and stops playing the correct notes when keys are released. That is amazing, we've hooked up the Web Audio API's various nodes and created an instrument that produces real sounds.
Adding controlled inputs
However, that's only half the battle. We need to be able to control the sounds we create, and we'll do that through controlled inputs. This is where things get complicated. We are holding our AudioContext and its nodes entirely in refs, but controlled inputs require state. We are going to bridge the two using handler functions that keep our refs and state in sync.
I'll start with the Master Volume as it's a fairly simple component that demonstrates the pattern. Here is the Volume component that will control the overall volume of our synth:
// Synth/components/Volume/index.tsx:import React from "react";interface VolumeComponentProps {masterVolume: number;handleMasterVolumeChange: (val: number) => void;}export default function Volume({masterVolume,handleMasterVolumeChange,}: VolumeComponentProps) {function onVolumeChange(e: React.ChangeEvent<HTMLInputElement>) {handleMasterVolumeChange(parseFloat(e.target.value));}return (<div className="module volume"><div className="header"><h2>Volume</h2></div><div className="controls"><div className="range-container"><label htmlFor="volume">Master Volume <span className="right">{masterVolume}</span></label><inputtype="range"id="volume"max="2"min="0"step="0.1"value={masterVolume}onChange={onVolumeChange}/></div></div></div>);}
It's pretty straightforward. It accepts a masterVolume number and a handleMasterVolumeChange handler function as its props and creates a controlled range slider input. Back in our parent Synth component, let's import Volume, create that bit of state, and pass it along:
// Synth/index.tsx:import React, { useRef, useCallback, useState } from "react";import Volume from "./components/Volume";// ...other imports...export default function Synth() {const [masterVolume, setMasterVolume] = useState<number>(1);// ...settingsRef...// destructure updateMasterVolume from useSynthEngine hook:const {playNote: enginePlayNote,stopNote: engineStopNote,updateMasterVolume,} = useSynthEngine(settingsRef);// ...playNote and StopNote callbacks...function handleMasterVolumeChange(nextMasterVolume: number) {setMasterVolume(nextMasterVolume);settingsRef.current.masterVolume = nextMasterVolume;updateMasterVolume(nextMasterVolume);}return (<div className={`synth-container ${synthFont.variable}`}><div className="synth"><div className="title"><h1 className="synth-title">next.js synth mk1</h1></div><div className="synth-padding"><div className="synth-modules"><VolumemasterVolume={masterVolume}handleMasterVolumeChange={handleMasterVolumeChange}/><Keyboard onKeyDown={playNote} onKeyUp={stopNote} /></div></div></div></div>);}
So we define a masterVolume state variable to manage the controlled input. We also destructure a new function, updateMasterVolume from the useSynthEngine hook (we'll cover that in a minute), and define a handleMasterVolumeChange function that does three things:
- updates the state variable
- updates the
masterVolumeproperty value withinsettingsRef - runs the
updateMasterVolumefunction in theuseSynthEnginehook
By and large, this is the pattern we will use to keep state variables and refs in sync: combine them in the handler functions that run when a controlled input is changed. The rerenders update the UI and at the same time we directly update the values held in refs. State is solely responsible for UI while refs are solely responsible for the values in the AudioContext, but they are updated at the same time.
So what is the third piece here, where we are tapping back into the useSynthEngine hook? Well let's take a look at that code over in useSynthEngine.ts:
// Synth/hooks/useSynthEngine.ts:export function useSynthEngine(settingsRef: React.RefObject<SynthSettings>) {// ...everything that exists so far...function updateMasterVolume(val: number) {if (masterGain.current) {masterGain.current.gain.value = val;}}return {playNote,stopNote,updateMasterVolume,};
Pretty simple, it's just updating the value of the masterGain, which is another ref holding a GainNode. However, if you look that ref up, it only holds a value while a note is playing. This allows us to press and hold a key to hold a note, and we can change the volume using the range input to adjust the volume and that volume changes while we're playing that note. Without this third step, the new volume would only be reflected when we stop playing a note and then play a new one. So this is the third pattern that we will opt into from time to time where we can update sounds while a note is actively playing. We don't want this for absolutely everything, but for something like volume it is desirable.
Styling
This is coming together nicely, but let's pause for a moment to make it look a little bit better.
You might have noticed this in our parent Synth component:
// in Synth/index.tsx:// ...existing imports...const synthFont = Orbitron({subsets: ["latin"],variable: "--font-synth",display: "swap",});// ...other functionality...return (<div className={`synth-container ${synthFont.variable}`}>{/* ...other synth stuff... */}</div>);
All I'm really doing here is importing the Orbitron font from Google fonts to give it that ~ futuristic ~ look. This puts that font-family into a CSS custom property named --font-synth that we can use in our stylesheet.
For ease and simplicity, I'll throw all styles into synth.scss:
/* Synth/synth.scss */@use "../../../styles/breakpoints";.synth-container {flex-grow: 1;display: flex;justify-content: center;align-items: center;}.synth {h1,h2,h3,h4,h5,h6,label,p {font-family: var(--font-synth);font-size: 12px;text-transform: lowercase;}--accent-color: #d9a443;--input-background-color: #f7f3a2;--outline-color: #000000;--disabled-color: #525252;--synth-background-color: #f4f4d5;background-color: var(--synth-background-color);padding-bottom: 20px;.title {background-color: var(--accent-color);padding: 16px 20px;border-radius: 29px 29px 0 0;.synth-title {font-size: 16px;}}// layout.synth-padding {padding: 20px;}.synth-modules {display: grid;gap: 12px;grid-template-columns: repeat(2, minmax(0, 1fr));@include breakpoints.mobile {grid-template-areas:"oscillator1 oscillator2""oscillator3 volume""keyboard keyboard";}@include breakpoints.tablet {grid-template-columns: repeat(4, minmax(0, 1fr));grid-template-areas:"oscillator1 oscillator2 oscillator3 volume""keyboard keyboard keyboard keyboard";}@include breakpoints.desktop {grid-template-columns: repeat(4, minmax(0, 1fr));grid-template-areas:"oscillator1 oscillator2 oscillator3 volume""keyboard keyboard keyboard keyboard";}@include breakpoints.large-desktop {grid-template-columns: repeat(4, minmax(0, 1fr));grid-template-areas:"oscillator1 oscillator2 oscillator3 volume""keyboard keyboard keyboard keyboard";}}height: 100%;border: 2px solid var(--accent-color);border-radius: 32px;// module.module {border: 1px solid var(--accent-color);border-radius: 12px;.header {min-height: 26px;background-color: var(--accent-color);display: flex;align-items: center;padding: 4px;border-radius: 10px 10px 0 0;h2 {margin-bottom: 0;}input:where([type="checkbox"][role="switch"]) {margin-left: auto;-webkit-appearance: none;-moz-appearance: none;appearance: none;position: relative;font-size: inherit;width: 2em;height: 1em;box-sizing: content-box;border: 1px solid;border-radius: 1em;vertical-align: text-bottom;color: inherit;&:checked {color: var(--disabled-color);}&:not(:checked) {&::before {left: 1em;}}&::before {content: "";position: absolute;top: 50%;left: 0;transform: translate(0, -50%);box-sizing: border-box;width: 0.7em;height: 0.7em;margin: 0 0.15em;border: 1px solid;border-radius: 50%;background: currentcolor;}&:focus {outline: 2px solid var(--outline-color);border-radius: 4px;border-color: var(--outline-color);box-shadow: 0 0 0 2px var(--outline-color);}}label:has(+ input[type="checkbox"]) {position: absolute;overflow: hidden;height: 1px;width: 1px;margin: -1px;padding: 0;border: 0;clip: rect(0 0 0 0);clip-path: inset(50%);}}.controls {padding: 8px;display: flex;flex-direction: column;gap: 12px;}}// labellabel {display: flex;padding-bottom: 4px;.right {margin-left: auto;}}// range slider.range-container {input[type="range"] {appearance: none;-webkit-appearance: none;width: 100%;height: 16px;background: var(--input-background-color);border: black solid 1px;border-radius: 4px;margin: 0;&::-webkit-slider-thumb {appearance: none;-webkit-appearance: none;background: black;width: 6px;height: 16px;opacity: 1;}&:focus {outline: none;border-color: var(--accent-color);box-shadow: 0 0 0 2px var(--accent-color);}}}// select.select-container {select {width: 100%;padding: 4px 4px;font-size: 12px;line-height: 1.5;border: 0px solid transparent;border-radius: 0;background-color: var(--input-background-color);border: var(--outline-color) solid 1px;border-radius: 4px;cursor: pointer;color: var(--outline-color);font-family: var(--font-synth);text-transform: lowercase;appearance: none;background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 448 512'%3E%3Cpath fill='%23000000' d='M443.5 162.6l-7.1-7.1c-4.7-4.7-12.3-4.7-17 0L224 351 28.5 155.5c-4.7-4.7-12.3-4.7-17 0l-7.1 7.1c-4.7 4.7-4.7 12.3 0 17l211 211.1c4.7 4.7 12.3 4.7 17 0l211-211.1c4.8-4.7 4.8-12.3.1-17z'%3E%3C/path%3E%3C/svg%3E");background-repeat: no-repeat, repeat;background-position: right 0.7em top 50%;background-size: 1em;&:hover {border-color: var(--accent-color);}&:focus {outline: none;border-color: var(--accent-color);box-shadow: 0 0 0 2px var(--accent-color);}}}// place modules on grid.oscillator {&-1 {grid-area: oscillator1;}&-2 {grid-area: oscillator2;}&-3 {grid-area: oscillator3;}}.volume {grid-area: volume;}.keyboard {grid-area: keyboard;display: flex;justify-content: center;div#keyboard {@media screen and (min-width: 768px) {width: 500px !important ;}ul {display: initial;}}}}
I won't dig too deep into this since CSS isn't the focus of this blog post (and you have all of the code, you're welcome to look anything up that you want). If you're curious about what I'm doing with breakpoints here, I recommend you checkout my blog post on Sass mixins, specifically the Breakpoint variables section.
The main thing going on here is setting up a layout grid with named grid areas:
/* in Synth/synth.scss */display: grid;grid-template-columns: repeat(4, minmax(0, 1fr));grid-template-areas:"oscillator1 oscillator2 oscillator3 volume""keyboard keyboard keyboard keyboard";gap: 12px;
As well as styling inputs and modules. A "module" is one of the control areas of our synth. I lay these out in grid areas. For example, the Volume component we created earlier is given a module class and it occupies the volume grid area.
Oscillator controlled inputs
So now that it doesn't look like complete garbage, let's move on to adding controlled inputs for the settings on the oscillator. We'll start small, adding a select dropdown to change the wave type of the oscillator.
Here is a starting point for the Oscillator component:
// Synth/components/Oscillator/index.tsx:import React, { useId } from "react";import type { OscillatorSettings } from "../../types";interface OscillatorComponentProps {version: number;oscillatorSettings: OscillatorSettings;handleOscillatorSettingsChange: (version: number,vals: OscillatorSettings) => void;}export default function Oscillator({version,oscillatorSettings,handleOscillatorSettingsChange,}: OscillatorComponentProps) {const uniqueId = useId();const { type, octave, detune, volume, isMuted } = oscillatorSettings;function onTypeChange(e: React.ChangeEvent<HTMLSelectElement>) {const id = e.target.id;const prop = id.split("-").pop() as keyof OscillatorSettings;const val = e.target.value;const nextOscillatorSettings = {...oscillatorSettings,[prop]: val,};handleOscillatorSettingsChange(version, nextOscillatorSettings);}return (<div className={`module oscillator-${version}`}><div className="header"><h2>Oscillator {version}</h2></div><div className="controls"><div className="select-container"><label htmlFor={`${uniqueId}-type`}>Wave Type</label><selectid={`${uniqueId}-type`}value={type}onChange={onTypeChange}className="select"><option value="sine">Sine</option><option value="square">Square</option><option value="sawtooth">Sawtooth</option><option value="triangle">Triangle</option></select></div></div></div>);}
Most of this is pretty similar to what we did previously in the Volume component. In our props, we're passing in the current state settings of the Oscillator as well as the handler function to call when our controlled inputs change. The only other thing we have is a version prop. Right now we only have one oscillator, but this will come into play when we add the other two oscillators. When a controlled input changes, we need to specify which of those three oscillators we want to change. We'll use the version number to do that.
Also, because we'll have three Oscillator components with controlled inputs and associated labels, we need to leverage the useId hook to ensure unique id/label pairing, which keeps our different controls accessible. Otherwise we define the function that fires when the Select input for the type is changed.
Back in the parent Synth component, we need to do a couple of things.
We need to create the state variables that will be associated with the controlled inputs, we need to define the handleOscillatorSettingsChange function, and we need to drop the Oscillator into our UI. Here's the updated Synth component that accommodates all of that:
// Synth/index.tsx:"use client";import React, { useRef, useCallback, useState } from "react";import Oscillator from "./components/Oscillator";import Volume from "./components/Volume";import Keyboard from "./components/Keyboard";import { useSynthEngine } from "./hooks/useSynthEngine";import type { SynthSettings, OscillatorSettings } from "./types";import "./synth.scss";import { Orbitron } from "next/font/google";const synthFont = Orbitron({subsets: ["latin"],variable: "--font-synth",display: "swap",});interface OscillatorBank {[key: string]: OscillatorSettings;}export default function Synth() {const [masterVolume, setMasterVolume] = useState<number>(1);const [oscillators, setOscillators] = useState<OscillatorBank>({oscillator1: {type: "square",octave: 8,detune: 0,volume: 0.35,isMuted: false,},});const settingsRef = useRef<SynthSettings>({masterVolume: 1,oscillator1: {type: "square",octave: 8,detune: 0,volume: 0.35,isMuted: false,},easing: 0.005,});const {playNote: enginePlayNote,stopNote: engineStopNote,updateMasterVolume,} = useSynthEngine(settingsRef);const playNote = useCallback((note: string, freq: number) => {enginePlayNote(note, freq);},[enginePlayNote]);const stopNote = useCallback((note: string) => {engineStopNote(note);},[engineStopNote]);function handleMasterVolumeChange(nextMasterVolume: number) {setMasterVolume(nextMasterVolume);settingsRef.current.masterVolume = nextMasterVolume;updateMasterVolume(nextMasterVolume);}function handleOscillatorSettingsChange(version: number,nextOscillatorSettings: OscillatorSettings) {const whichOscillator = `oscillator${version}` as keyof SynthSettings;setOscillators((prev) => ({...prev,[whichOscillator]: nextOscillatorSettings,}));(settingsRef.current[whichOscillator] as OscillatorSettings) =nextOscillatorSettings;}return (<div className={`synth-container ${synthFont.variable}`}><div className="synth"><div className="title"><h1 className="synth-title">next.js synth mk1</h1></div><div className="synth-padding"><div className="synth-modules"><Oscillatorversion={1}oscillatorSettings={oscillators.oscillator1!}handleOscillatorSettingsChange={handleOscillatorSettingsChange}/><VolumemasterVolume={masterVolume}handleMasterVolumeChange={handleMasterVolumeChange}/><Keyboard onKeyDown={playNote} onKeyUp={stopNote} /></div></div></div></div>);}
Our state variable is going to hold all of the oscillators in an oscillator bank:
const [oscillators, setOscillators] = useState<OscillatorBank>({oscillator1: {type: "square",octave: 8,detune: 0,volume: 0.35,isMuted: false,},});
Our handleOscillatorSettingsChange function uses the version to figure out which oscillator we're changing settings for and applies the changes to it both in state and by updating the ref:
function handleOscillatorSettingsChange(version: number,nextOscillatorSettings: OscillatorSettings) {const whichOscillator = `oscillator${version}` as keyof SynthSettings;setOscillators((prev) => ({...prev,[whichOscillator]: nextOscillatorSettings,}));(settingsRef.current[whichOscillator] as OscillatorSettings) =nextOscillatorSettings;}
The big difference between this settings change function and handleMasterVolumeChange is that handleOscillatorSettingsChange doesn't touch the useSynthEngine custom hook (whereas handleMasterVolumeChange used updateMasterVolume from useSynthEngine). That's because when we change an oscillator's wave type (or the other settings we'll be adding in shortly) we don't want them to update while a note is playing, but instead to apply when the next note is played. That's how hardware synths like this commonly work, so we are following that pattern here.
Lastly we throw the Oscillator component itself into the synth UI:
<div className="synth-modules"><Oscillatorversion={1}oscillatorSettings={oscillators.oscillator1!}handleOscillatorSettingsChange={handleOscillatorSettingsChange}/><VolumemasterVolume={masterVolume}handleMasterVolumeChange={handleMasterVolumeChange}/><Keyboard onKeyDown={playNote} onKeyUp={stopNote} /></div>
So with that in place, we now have a control for Oscillator 1 that lets us change its wave type and hear how a triangle wave sounds vs. a square wave and so on. Pretty cool! What I think is extra neat is that we haven't had to update our shared types to make this work, we are able to use what exists already.
So now let's add the rest of our controls for the oscillator component. I'll add a checkbox to mute and unmute the oscillator, a dropdown select for the octave that the oscillator's pitch plays at (this uses the octaveToFrequency function within the useSynthEngine hook), a range slider for detune and a range slider for that specific oscillator's volume.
Here is our fully updated Oscillator component:
// Synth/components/Oscillator/index.tsx:import React, { useId } from "react";import type { OscillatorSettings } from "../../types";interface OscillatorComponentProps {version: number;oscillatorSettings: OscillatorSettings;handleOscillatorSettingsChange: (version: number,vals: OscillatorSettings) => void;}export default function Oscillator({version,oscillatorSettings,handleOscillatorSettingsChange,}: OscillatorComponentProps) {const uniqueId = useId();const { type, octave, detune, volume, isMuted } = oscillatorSettings;function onChange(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) {const id = e.target.id;const prop = id.split("-").pop() as keyof OscillatorSettings;const val = parseFloat(e.target.value);const nextOscillatorSettings = {...oscillatorSettings,[prop]: val,};handleOscillatorSettingsChange(version, nextOscillatorSettings);}function onTypeChange(e: React.ChangeEvent<HTMLSelectElement>) {const id = e.target.id;const prop = id.split("-").pop() as keyof OscillatorSettings;const val = e.target.value;const nextOscillatorSettings = {...oscillatorSettings,[prop]: val,};handleOscillatorSettingsChange(version, nextOscillatorSettings);}function onMuteChange(e: React.ChangeEvent<HTMLInputElement>) {const nextOscillatorSettings = {...oscillatorSettings,isMuted: e.target.checked,};handleOscillatorSettingsChange(version, nextOscillatorSettings);}return (<div className={`module oscillator-${version}`}><div className="header"><h2>Oscillator {version}</h2><label htmlFor={`${uniqueId}-mute`}>Mute</label><inputid={`${uniqueId}-mute`}type="checkbox"checked={isMuted}onChange={onMuteChange}role="switch"/></div><div className="controls"><div className="select-container"><label htmlFor={`${uniqueId}-type`}>Wave Type</label><selectid={`${uniqueId}-type`}value={type}onChange={onTypeChange}className="select"><option value="sine">Sine</option><option value="square">Square</option><option value="sawtooth">Sawtooth</option><option value="triangle">Triangle</option></select></div><div className="select-container"><label htmlFor={`${uniqueId}-octave`}>Octave</label><select id={`${uniqueId}-octave`} value={octave} onChange={onChange}><option value="32">32</option><option value="16">16</option><option value="8">8</option><option value="4">4</option><option value="2">2</option></select></div><div className="range-container"><label htmlFor={`${uniqueId}-detune`}>Detune <span className="right">{detune}</span></label><inputtype="range"min="-10"max="10"id={`${uniqueId}-detune`}value={detune}onChange={onChange}/></div><div className="range-container"><label htmlFor={`${uniqueId}-volume`}>Volume <span className="right">{volume}</span></label><inputtype="range"id={`${uniqueId}-volume`}max="0.4"min="0.3"step="0.01"value={volume}onChange={onChange}/></div></div></div>);}
Nothing majorly new here, we just build on what we previously wrote. There's an onMuteChange function that handles the controlled mute checkbox input, and then an onChange function that is more general-purpose and handles changes for all of the other controlled inputs. These three functions mostly break down to the types of values they return to update state/refs: onMutechange returns a boolean, onTypeChange returns a string, and onChange returns some kind of float number.
With that in place, our first oscillator is done and you can produce some interesting sounds from it (or mute it if it becomes too annoying).
Three oscillator bank
The last step for now is to make our synth way more robust by filling our bank of oscillators with two more oscillators. The three oscillators, when each set to different wave types, octaves, and volumes will build up to a nice, robust blend of sounds. We built our Oscillator node to accommodate multiple versions, so this won't be as demanding as it might initially seem.
Over in the Synth component, we need to update the oscillators state to have two new oscillator setting objects:
// Synth/index.tsx:const [oscillators, setOscillators] = useState<OscillatorBank>({oscillator1: {type: "square",octave: 8,detune: 0,volume: 0.35,isMuted: false,},oscillator2: {type: "sawtooth",octave: 4,detune: 0,volume: 0.35,isMuted: false,},oscillator3: {type: "triangle",octave: 16,detune: 0,volume: 0.35,isMuted: false,},});
We also need to add the matching oscillator setting objects to the settingsRef:
const settingsRef = useRef<SynthSettings>({masterVolume: 1,oscillator1: {type: "square",octave: 8,detune: 0,volume: 0.35,isMuted: false,},oscillator2: {type: "sawtooth",octave: 4,detune: 0,volume: 0.35,isMuted: false,},oscillator3: {type: "triangle",octave: 16,detune: 0,volume: 0.35,isMuted: false,},easing: 0.005,});
With that in place, we can drop two new Oscillator components in that use these new settings:
return (<div className={`synth-container ${synthFont.variable}`}><div className="synth"><div className="title"><h1 className="synth-title">next.js synth mk1</h1></div><div className="synth-padding"><div className="synth-modules"><Oscillatorversion={1}oscillatorSettings={oscillators.oscillator1!}handleOscillatorSettingsChange={handleOscillatorSettingsChange}/><Oscillatorversion={2}oscillatorSettings={oscillators.oscillator2!}handleOscillatorSettingsChange={handleOscillatorSettingsChange}/><Oscillatorversion={3}oscillatorSettings={oscillators.oscillator3!}handleOscillatorSettingsChange={handleOscillatorSettingsChange}/><VolumemasterVolume={masterVolume}handleMasterVolumeChange={handleMasterVolumeChange}/><Keyboard onKeyDown={playNote} onKeyUp={stopNote} /></div></div></div></div>);
Next up, we need to update the SynthSettings interface to accommodate these two new oscillators. Fortunately, we can continue to use what we already have:
// in Synth/types.ts:export interface SynthSettings {masterVolume: number;oscillator1: OscillatorSettings;oscillator2: OscillatorSettings;oscillator3: OscillatorSettings;easing: number;}
To make these synths actually produce sound, we need to update the useSynthEngine custom hook. Just like we used the Oscillator class constructor here for oscillator1, we'll do the same but with oscillator2 and oscillator3:
// in Synth/hooks/useSynthEngine.ts:const oscillators = [new Oscillator({...settings.oscillator1,audioContext: ctx,type: settings.oscillator1.type,frequency: octaveToFrequency(frequency, settings.oscillator1.octave),detune: settings.oscillator1.detune,volume: settings.oscillator1.volume / 3,connection: masterGain.current,easing: settings.easing,version: 1,isMuted: settings.oscillator1.isMuted,}),new Oscillator({...settings.oscillator2,audioContext: ctx,type: settings.oscillator2.type,frequency: octaveToFrequency(frequency, settings.oscillator2.octave),detune: settings.oscillator2.detune,volume: settings.oscillator2.volume / 3,connection: masterGain.current,easing: settings.easing,version: 2,isMuted: settings.oscillator2.isMuted,}),new Oscillator({...settings.oscillator3,audioContext: ctx,type: settings.oscillator3.type,frequency: octaveToFrequency(frequency, settings.oscillator3.octave),detune: settings.oscillator3.detune,volume: settings.oscillator3.volume / 3,connection: masterGain.current,easing: settings.easing,version: 3,isMuted: settings.oscillator3.isMuted,}),];
With all of that in place, we now have our bank of independently controllable oscillators and our basic Next.js synthesizer is done (for now)!
We covered a lot of ground in this blog post, from putting our Web Audio API nodes into refs, synchronizing those refs with state, the split between state controlling UI and refs controlling the audio, how to elect into adjusting actively-playing sounds, using class constructors to create an OscillatorNode factory, and we used a lot of React features along the way (useId, useCallback, useState, useRef), and so much more. Having covered all of that, there's still a lot more that we can do to make this synth even cooler, so I hope you'll stick around for the next couple of posts! In the meantime, we have a great foundation for our instrument.