Next.js Synth: Adding Filter, Envelopes, LFO, and Compressor
This is the second of three blog posts where I walk through how I built a synthesizer in Next.js's App router using the Web Audio API and TypeScript. In the first blog post, I wired up the keyboard, a bank of three oscillators (with configurable wave type, octave, detune, and volume controls), and a master volume module. This post is going to build on that work, so if you haven't read that one first, I highly recommend it.
While the basic Mk1 synth we built last time around is really cool and makes some interesting sounds, I am aiming for something far more robust. To achieve this, I'm going to be adding a filter, some envelopes, an LFO, and a compressor.
A lot of this is pretty complicated territory: the ADSR envelope on the main gain adjusts timing curves for the overall sound the synth produces (I wrote about ADSR envelopes previously in the context of Sonic Pi, but the concepts are the same here); the LFO is a low volume, ornamental note that, when added to three oscillator tones, contributes rich sonic texture; the filter is a biquad filter applied to the three oscillator tones, and it in turn will also have its own ADSR envelope; and the compressor softens some of the rough edges and clipping that sometimes occurs with the synth's overall volume level.
None of this is meant to scare you off! If there is stuff you don't understand, that is perfectly fine – I honestly still struggle to understand a lot of it myself. I am going to walk through what I've built, but I encourage you to build along with me, tinkering with the upper/lower limits and default parameters yourself.
I also ask for some leniency in this post. In the previous post, I could easily take it module by module, adding one feature, getting it to produce sound, then repeating with the next. The filters, envelopes, LFO, and compressor that we are building today are all very interconnected, so things might not fully come together until the very end of the post. I will also gloss over some of the details around timing stuff. I think that could be a whole blog post in and of itself, but I don't really have the depth of knowledge for that, nor is math my forté. Lastly, even if you don't understand the nitty gritty of this stuff, by and large we are just following the same pattern set up in the first blog post:
- UI is handled by state
- sounds are handled by refs
- the
useSynthEngineproduces the Web Audio API context and nodes- when we update actively-playing sounds, we also make that update in
useSynthEngine
- when we update actively-playing sounds, we also make that update in
- we have handler functions that update state, refs, and optionally
useSynthEngine, keeping everything synced - we also have an
Oscillatorclass constructor for each oscillator in the oscillator bank. When we add LFOs, the filter, and the filter envelope, they pass through theOscillatorclass constructor
What we're building
Same as last time, I want to show off what we'll be building in this post:
Loading Synth V2...
Again, this uses the Mk1 synth from the last post as its foundation. But as you play, I invite you to adjust the LFO depth or the filter's envelope amount and envelope parameters. You'll see how much more rich the sound is in Mk2 compared to Mk1. It really elevates what was previously a novelty project to something far more sophisticated. Without further ado, let's get into it!
Adding a Filter
Alright, let's dive into some of the code. I think the best place to start is with the Filter module. Here is the Filter component:
// components/Filter/index.tsximport React from "react";import type { FilterSettings } from "../../types";interface FilterComponentProps {filterSettings: FilterSettings;handleFilterSettingsChange: (nextFilterSettings: FilterSettings) => void;}export default function Filter({filterSettings,handleFilterSettingsChange,}: FilterComponentProps) {const { type, frequency, detune, Q, gain, filterEnvelopeAmount } =filterSettings;const MIN_FREQ = 20;const MAX_FREQ = 18000;function toLog(val: number) {const minF = Math.log(MIN_FREQ);const maxF = Math.log(MAX_FREQ);const scale = maxF - minF;return Math.round(Math.exp(minF + scale * val));}function fromLog(freq: number) {const minF = Math.log(MIN_FREQ);const maxF = Math.log(MAX_FREQ);const scale = maxF - minF;return (Math.log(freq) - minF) / scale;}function isLowshelfOrHighshelf(type: string) {if (type === "lowshelf") {return true;}if (type === "highshelf") {return true;}return false;}function onTypeChange(e: React.ChangeEvent<HTMLSelectElement>) {const prop = e.target.id;const val = e.target.value;const nextFilterSettings = {...filterSettings,[prop]: val,};handleFilterSettingsChange(nextFilterSettings);}function onChange(e: React.ChangeEvent<HTMLInputElement>) {const prop = e.target.id;let val = parseFloat(e.target.value);if (prop === "frequency") {val = toLog(val);}const nextFilterSettings = {...filterSettings,[prop]: val,};handleFilterSettingsChange(nextFilterSettings);}return (<div className="module filter"><div className="header"><h2>Filter</h2></div><div className="controls"><div className="select-container"><label htmlFor="type">Filter Type</label><select id="type" value={type} onChange={onTypeChange}><option value="lowpass">Lowpass</option><option value="highpass">Highpass</option><option value="notch">Notch</option><option value="lowshelf">Lowshelf</option><option value="highshelf">Highshelf</option></select></div><div className="range-container"><label htmlFor="frequency">Frequency <span className="right">{frequency}</span></label><inputonChange={onChange}type="range"id="frequency"value={fromLog(frequency)}min="0"max="1"step="0.001"/></div><div className="range-container"><label htmlFor="detune">Detune <span className="right">{detune}</span></label><inputtype="range"onChange={onChange}id="detune"value={detune}min="-100"max="100"/></div>{/* Q is used for Lowpass, Highpass, and Notch */}<div className="range-container"><label htmlFor="Q">Q <span className="helper"> - Lowpass, Highpass, Notch</span><span className="right">{Q}</span></label><inputtype="range"onChange={onChange}id="Q"max="10"value={Q}step="0.1"disabled={isLowshelfOrHighshelf(type)}/></div>{/* Gain is used for Lowshelf and Highshelf */}<div className="range-container"><label htmlFor="gain">Gain <span className="helper"> - Lowshelf, Highshelf</span><span className="right">{gain}</span></label><inputtype="range"onChange={onChange}id="gain"max="10"value={gain}step="0.1"disabled={!isLowshelfOrHighshelf(type)}/></div><div className="range-container"><label htmlFor="filterEnvelopeAmount">Filter Envelope Amount <span className="right">{filterEnvelopeAmount}</span></label><inputtype="range"id="filterEnvelopeAmount"min="0"max="10000"step="100"value={filterEnvelopeAmount}onChange={onChange}/></div></div></div>);}
There's quite a bit going on, but it's not really too different from what we've seen previously with our Oscillator component. But instead of adjusting parameters on an OscillatorNode, we've created controlled inputs to control parameters on a BiquadFilterNode. This encompasses the type of filter (Lowpass, Highpass, Notch, Lowshelf, or Highshelf), the frequency and detune of the filter, as well as its Q value (which is used for Lowpass, Highpass, and Notch) or Gain value (which is used for Lowshelf and Highshelf). There is also a filter envelope amount. Later in this post, we are going to connect this filter to an ADSR envelope, and this input controls how much of that envelope gets applied to the filter.
You may notice there are a pair of functions, toLog and fromLog. We use these for the frequency control to make the range slider apply logarithmic values instead of linear values. This enables the spacing of values on the slider to more naturally map to how human ears actually perceive changes in frequency. If it were a linear scale, the first octave would occupy a tiny portion of the slider and the last octave would take up almost half of the bar.
The Filter component uses a FilterSettings type interface, which I'll add to types.ts:
// types.tsexport interface OscillatorConstructorProps {audioContext: AudioContext;type: "sine" | "square" | "sawtooth" | "triangle";frequency: number;detune: number;envelopeSettings: EnvelopeSettings;volume: number;connection: GainNode;easing: number;version: number;isMuted: boolean;lfoSettings: LFOSettings;filterSettings: FilterSettings;}export interface EnvelopeSettings {attack: number;decay: number;sustain: number;release: number;}export interface OscillatorSettings {type: "sine" | "square" | "sawtooth" | "triangle";octave: number;detune: number;volume: number;isMuted: boolean;}export interface SynthSettings {masterVolume: number;oscillator1: OscillatorSettings;oscillator2: OscillatorSettings;oscillator3: OscillatorSettings;easing: number;filterSettings: FilterSettings;}export interface FilterSettings {type: BiquadFilterType;frequency: number;detune: number;Q: number;gain: number;filterEnvelopeAmount: number;filterEnvelope: EnvelopeSettings;}export interface LFOSettings {type: "sine" | "square" | "sawtooth" | "triangle";rate: number;depth: number;}
FilterSettings pretty much corresponds to all of the controlled inputs we saw in the Filter component. We've also added LFOSettings and EnvelopeSettings interfaces and used them throughout our other interfaces. More on those soon.
Now that we have our Filter component, let's drop it into our parent Synth component:
// index.tsx"use client";import React, { useRef, useCallback, useState } from "react";import Oscillator from "./components/Oscillator";import Volume from "./components/Volume";import Filter from "./components/Filter";import Keyboard from "./components/Keyboard";import { useSynthEngine } from "./hooks/useSynthEngine";import type {SynthSettings,OscillatorSettings,FilterSettings,} 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,},oscillator2: {type: "sawtooth",octave: 4,detune: 0,volume: 0.35,isMuted: false,},oscillator3: {type: "triangle",octave: 16,detune: 0,volume: 0.35,isMuted: false,},});const [filterSettings, setFilterSettings] = useState<FilterSettings>({type: "lowpass",frequency: 350,detune: 0,Q: 1,gain: 0,filterEnvelopeAmount: 5000,filterEnvelope: {attack: 0.1,decay: 0.2,sustain: 0.2,release: 0.5,},});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,filterSettings: {type: "lowpass",frequency: 350,detune: 0,Q: 1,gain: 0,filterEnvelopeAmount: 5000,filterEnvelope: {attack: 0.1,decay: 0.2,sustain: 0.2,release: 0.5,},},});const {playNote: enginePlayNote,stopNote: engineStopNote,updateMasterVolume,updateFilter,} = 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;}function handleFilterSettingsChange(nextFilterSettings: FilterSettings) {setFilterSettings(nextFilterSettings);settingsRef.current.filterSettings = nextFilterSettings;updateFilter(nextFilterSettings);}return (<div className={`synth-container ${synthFont.variable}`}><div className="synth"><div className="title"><h1 className="synth-title">next.js synth mk2</h1></div><div className="synth-padding"><div className="synth-modules-mk2"><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}/><FilterfilterSettings={filterSettings}handleFilterSettingsChange={handleFilterSettingsChange}/><Keyboard onKeyDown={playNote} onKeyUp={stopNote} /></div></div></div></div>);}
All of this is pretty standard stuff: import the component and type interface, drop it into the UI, add our filterSettings state variable/setter function, as well as the corresponding settings into the settingsRef, and then define the handler function for when the controlled inputs change, handleFilterSettingsChange:
function handleFilterSettingsChange(nextFilterSettings: FilterSettings) {setFilterSettings(nextFilterSettings);settingsRef.current.filterSettings = nextFilterSettings;updateFilter(nextFilterSettings);}
Above this, we are destructuring the updateFilter function from the useSynthEngine custom hook:
// hooks/useSynthEngine.ts"use client";import React, { useRef, useEffect } from "react";import Oscillator from "../constructors/Oscillator";import type { SynthSettings, FilterSettings } 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,filterSettings: settings.filterSettings,}),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,filterSettings: settings.filterSettings,}),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,filterSettings: settings.filterSettings,}),];activeNotes.current.set(note, oscillators);}function stopNote(note: string) {const voice = activeNotes.current.get(note);if (voice) {voice.forEach((oscillator: any) => oscillator.stopOscillatorConstructor());activeNotes.current.delete(note);}}function updateMasterVolume(val: number) {if (masterGain.current) {masterGain.current.gain.value = val;}}function updateFilter(settings: FilterSettings) {const ctx = audioCtx.current;if (!ctx) return;const { currentTime } = ctx;activeNotes.current.forEach((oscillators: Oscillator[]) => {oscillators.forEach((oscillator) => {if (oscillator.filterNode) {const safeFreq = Math.min(Math.max(settings.frequency, 20), 18000);oscillator.filterNode.type = settings.type;oscillator.filterNode.Q.setTargetAtTime(settings.Q,currentTime,0.02,);oscillator.filterNode.gain.setTargetAtTime(settings.gain,currentTime,0.02,);oscillator.filterNode.frequency.setTargetAtTime(safeFreq,currentTime,0.02,);}});});}return {playNote,stopNote,updateMasterVolume,updateFilter,};}
We've added our filter settings to each of the oscillators and created an updateFilter function that runs when we change a controlled input in the Filter component while a note is playing. Finally, we need to update our Oscillator class constructor to have a filterNode that gets updated:
// constructors/Oscillator.tsimport type { OscillatorConstructorProps, FilterSettings } from "../types";export default class Oscillator {private audioContext: AudioContext;private oscillator: OscillatorNode;private gateGain: GainNode;private easing: number;private targetVolume: number;public version: number;public filterNode: BiquadFilterNode;private filterSettings: FilterSettings;constructor(props: OscillatorConstructorProps) {const {audioContext,type,frequency,detune,volume,connection,easing,version,isMuted,filterSettings,} = props;this.version = version;this.audioContext = audioContext;this.easing = easing;this.targetVolume = isMuted ? 0 : volume;this.filterSettings = filterSettings;this.oscillator = this.audioContext.createOscillator();this.oscillator.frequency.value = frequency;this.oscillator.detune.value = detune;this.oscillator.type = type;this.filterNode = this.audioContext.createBiquadFilter();this.filterNode.type = filterSettings.type;this.filterNode.Q.value = filterSettings.Q;this.filterNode.gain.value = filterSettings.gain;this.gateGain = this.audioContext.createGain();this.gateGain.gain.value = 0;this.oscillator.connect(this.filterNode);this.filterNode.connect(this.gateGain);this.gateGain.connect(connection);this.oscillator.start();this.startOscillatorConstructor();}startOscillatorConstructor(): void {const { currentTime } = this.audioContext;const filterSettings = this.filterSettings;const filterEnvelope = filterSettings.filterEnvelope;this.gateGain.gain.cancelScheduledValues(currentTime);this.gateGain.gain.setValueAtTime(this.targetVolume,currentTime + this.easing,);const baseFreq = filterSettings.frequency;const peakFreq = Math.min(baseFreq + filterSettings.filterEnvelopeAmount,20000,);const sustainFreq = Math.min(baseFreq + filterSettings.filterEnvelopeAmount * filterEnvelope.sustain,20000,);this.filterNode.frequency.cancelScheduledValues(currentTime);this.filterNode.frequency.setValueAtTime(baseFreq,currentTime + this.easing,);this.filterNode.frequency.linearRampToValueAtTime(peakFreq,currentTime + filterEnvelope.attack + this.easing,);this.filterNode.frequency.exponentialRampToValueAtTime(Math.max(20, sustainFreq),currentTime + filterEnvelope.attack + filterEnvelope.decay + this.easing,);}stopOscillatorConstructor(): void {const { currentTime } = this.audioContext;const filterEnvelope = this.filterSettings.filterEnvelope;this.gateGain.gain.cancelScheduledValues(currentTime);this.gateGain.gain.setValueAtTime(0, currentTime);this.filterNode.frequency.cancelScheduledValues(currentTime);this.filterNode.frequency.setTargetAtTime(this.filterSettings.frequency,currentTime,filterEnvelope.release / 4,);this.oscillator.stop();setTimeout(() => {this.oscillator.disconnect();this.gateGain.disconnect();this.filterNode.disconnect();}, 1000);}}
There's a lot going on here (and it's only going to get more complicated when we start adding the envelopes). The condensed list of changes here includes:
- adding the
filterSettingsto the available props - creating the actual
BiquadFilterNodewith thetype,Q, andgainvalues from settings - updating the
startOscillatorConstructor()method to usefilterSettingsandfilterEnvelopein its timing functions, determining how quickly/slowly/gradually/etc. to begin playing notes - updating the
stopOscillatorConstructor()method to stop thefilterNodesounds, using thefilterEnvelopereleaseproperty to determine how quickly or slowly to drop the volume of thefilterNode's frequency.
You may be asking yourself, "Where is this filterEnvelope coming from?" That is a great question, and one that we will address shortly. But first, I want to drop in the CSS that will lay out what we've created so far as well as everything else we're going to add in this blog post:
/* synth.scss */@use "../../../styles/breakpoints";.synth-container {flex-grow: 1;display: flex;justify-content: center;align-items: center;+ p {margin-top: 20px;}}.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-mk2 {display: grid;gap: 12px;grid-template-columns: repeat(2, minmax(0, 1fr));@include breakpoints.mobile {grid-template-areas:"oscillator1 oscillator2""oscillator3 volumelfo""envelopegain .""filter envelopefilter""keyboard keyboard";}@include breakpoints.tablet {grid-template-columns: repeat(4, minmax(0, 1fr));grid-template-areas:"oscillator1 oscillator2 oscillator3 volumelfo""envelopegain filter envelopefilter .""keyboard keyboard keyboard keyboard";}@include breakpoints.desktop {grid-template-columns: repeat(4, minmax(0, 1fr));grid-template-areas:"oscillator1 oscillator2 oscillator3 volumelfo""envelopegain filter envelopefilter .""keyboard keyboard keyboard keyboard";}@include breakpoints.large-desktop {grid-template-columns: repeat(4, minmax(0, 1fr));grid-template-areas:"oscillator1 oscillator2 oscillator3 volumelfo""envelopegain filter envelopefilter .""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;&.header--no-border-radius {border-radius: 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-lfo {grid-area: volumelfo;}.envelope--gain {grid-area: envelopegain;}.filter {grid-area: filter;}.envelope--filter {grid-area: envelopefilter;}.keyboard {grid-area: keyboard;display: flex;justify-content: center;div#keyboard {@media screen and (min-width: 768px) {width: 500px !important ;}ul {display: initial;}}}}
None of this should look too wild, it's mostly just specifying where our new synth modules are going to sit in the synth layout. The CSS here isn't the focus of this blog post, so I encourage you to explore this in your own time.
With our styles out of the way, let's move on to adding our first Envelope.
Adding an Envelope to our Filter
Let's create our Envelope component:
import React, { useId } from "react";import type { EnvelopeSettings } from "../../types";interface EnvelopeComponentProps {envelopeSettings: EnvelopeSettings;handleEnvelopeSettingsChange: (nextEnvelopeSettings: EnvelopeSettings,) => void;variant: string;}export default function Envelope({envelopeSettings,handleEnvelopeSettingsChange,variant,}: EnvelopeComponentProps) {const uniqueId = useId();const { attack, decay, sustain, release } = envelopeSettings;function onChange(e: React.ChangeEvent<HTMLInputElement>) {const id = e.target.id;const prop = id.split("-").pop() as keyof EnvelopeSettings;const val = parseFloat(e.target.value);const nextEnvelopeSettings = {...envelopeSettings,[prop]: val,};handleEnvelopeSettingsChange(nextEnvelopeSettings);}return (<div className={`module envelope envelope--${variant.toLocaleLowerCase()}`}><div className="header"><h2>{variant} envelope</h2></div><div className="controls"><div className="range-container"><label htmlFor={`${uniqueId}-attack`}>Attack <span className="right">{attack}</span></label><inputonChange={onChange}type="range"value={attack}min="0"max="2"step="0.1"id={`${uniqueId}-attack`}/></div><div className="range-container"><label htmlFor={`${uniqueId}-decay`}>Decay <span className="right">{decay}</span></label><inputonChange={onChange}type="range"value={decay}min="0"max="1"step="0.01"id={`${uniqueId}-decay`}/></div><div className="range-container"><label htmlFor={`${uniqueId}-sustain`}>Sustain <span className="right">{sustain}</span></label><inputonChange={onChange}type="range"value={sustain}min="0"max="1"step="0.01"id={`${uniqueId}-sustain`}/></div><div className="range-container"><label htmlFor={`${uniqueId}-release`}>Release <span className="right">{release}</span></label><inputonChange={onChange}type="range"value={release}min="0"max="2"step="0.01"id={`${uniqueId}-release`}/></div></div></div>);}
We're first going to hook the Filter up to this Envelope, but down the road we are also going to have a Gain Envelope with the same set of controls. This Envelope component will be used in both places, so we need to add a variant prop and use useId() to ensure we have unique form label/id pairings, similar to what we did with version and useId() in the Oscillator components. Otherwise, everything in this component is controlled input stuff we have seen before.
Next, let's import Envelope and add it to the UI in parent Synth component:
// in index.tsx:"use client";import React, { useRef, useCallback, useState } from "react";import Oscillator from "./components/Oscillator";import Volume from "./components/Volume";import Filter from "./components/Filter";import Envelope from "./components/Envelope";import Keyboard from "./components/Keyboard";// ...other imports...export default function Synth() {// state variables, refs, handler functions unchanged...return (<div className={`synth-container ${synthFont.variable}`}><div className="synth"><div className="title"><h1 className="synth-title">next.js synth mk2</h1></div><div className="synth-padding"><div className="synth-modules-mk2"><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}/><FilterfilterSettings={filterSettings}handleFilterSettingsChange={handleFilterSettingsChange}/><EnvelopeenvelopeSettings={filterSettings.filterEnvelope}handleEnvelopeSettingsChange={(nextFilterEnvelope) => {handleFilterSettingsChange({...filterSettings,filterEnvelope: nextFilterEnvelope,});}}variant="filter"/><Keyboard onKeyDown={playNote} onKeyUp={stopNote} /></div></div></div></div>);}
The main difference between this and other components is that instead of defining a new handleFilterEnvelopeSettingsChange function that updates the filterEnvelope (which is a property of filterSettings), we're just hooking into the already-existing handleFilterSettingsChange function.
You can really see this come to life if you hold a note and start adjusting the Filter and Filter Envelope controls, there's a rich, sweeping sound effect that is lush and lovely. However, you might notice that the Release controlled input doesn't really do anything. It really ties more into the overall Gain Envelope, so let's add that in now and get that Release working.
Adding a Gain Envelope
For this next step, we'll start by adding another Envelope component, but this time its variant prop will be 'Gain'. This Envelope will adjust the overall volume of the synth. Along the way, we also need to import the EnvelopeSettings type, define our envelopeSettings in state and the settingsRef, and define our handleEnvelopeSettingsChange function:
// index.tsx"use client";// ...other imports...import type {SynthSettings,OscillatorSettings,FilterSettings,EnvelopeSettings,} from "./types";// ...other imports, font stuff, and OscillatorBank interfaceimport "./synth.scss";import { Orbitron } from "next/font/google";export default function Synth() {// ...masterVolume, oscillators, and filterSettings state variablesconst [envelopeSettings, setEnvelopeSettings] = useState<EnvelopeSettings>({attack: 0.1,decay: 0.24,sustain: 0.44,release: 0.56,});const settingsRef = useRef<SynthSettings>({// ...master volume, oscillators, easing, and filterSettings ref propertiesenvelopeSettings: {attack: 0.1,decay: 0.24,sustain: 0.44,release: 0.56,},});// ...playNote, stopNote, and other handler functions...function handleEnvelopeSettingsChange(nextEnvelopeSettings: EnvelopeSettings,) {setEnvelopeSettings(nextEnvelopeSettings);settingsRef.current.envelopeSettings = nextEnvelopeSettings;}return (<div className={`synth-container ${synthFont.variable}`}><div className="synth"><div className="title"><h1 className="synth-title">next.js synth mk2</h1></div><div className="synth-padding"><div className="synth-modules-mk2"><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}/><EnvelopeenvelopeSettings={envelopeSettings}handleEnvelopeSettingsChange={handleEnvelopeSettingsChange}variant="Gain"/><FilterfilterSettings={filterSettings}handleFilterSettingsChange={handleFilterSettingsChange}/><EnvelopeenvelopeSettings={filterSettings.filterEnvelope}handleEnvelopeSettingsChange={(nextFilterEnvelope) => {handleFilterSettingsChange({...filterSettings,filterEnvelope: nextFilterEnvelope,});}}variant="filter"/><Keyboard onKeyDown={playNote} onKeyUp={stopNote} /></div></div></div></div>);}
Now, over in useSynthEngine, we need to add envelopeSettings to each of the three Oscillator constructor calls:
// ...in playNote in hooks/useSynthEngine.ts:// repeat for Oscillator versions 2 and 3 as wellnew 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,filterSettings: settings.filterSettings,envelopeSettings: settings.envelopeSettings,}),
Now we can put this gain envelope to work in the Oscillator constructor:
// constructors/Oscillator.tsimport type {OscillatorConstructorProps,FilterSettings,EnvelopeSettings,} from "../types";export default class Oscillator {private audioContext: AudioContext;private oscillator: OscillatorNode;private gateGain: GainNode;private easing: number;private targetVolume: number;public version: number;public filterNode: BiquadFilterNode;private filterSettings: FilterSettings;private envelope: EnvelopeSettings;constructor(props: OscillatorConstructorProps) {const {audioContext,type,frequency,detune,volume,connection,easing,version,isMuted,filterSettings,envelopeSettings,} = props;this.version = version;this.audioContext = audioContext;this.easing = easing;this.targetVolume = isMuted ? 0 : volume;this.filterSettings = filterSettings;this.oscillator = this.audioContext.createOscillator();this.oscillator.frequency.value = frequency;this.oscillator.detune.value = detune;this.oscillator.type = type;this.filterNode = this.audioContext.createBiquadFilter();this.filterNode.type = filterSettings.type;this.filterNode.Q.value = filterSettings.Q;this.filterNode.gain.value = filterSettings.gain;this.envelope = envelopeSettings || {attack: 0.005,decay: 0.1,sustain: 0.6,release: 0.1,};this.gateGain = this.audioContext.createGain();this.gateGain.gain.value = 0;this.oscillator.connect(this.filterNode);this.filterNode.connect(this.gateGain);this.gateGain.connect(connection);this.oscillator.start();this.startOscillatorConstructor();}startOscillatorConstructor(): void {const { currentTime } = this.audioContext;const filterSettings = this.filterSettings;const filterEnvelope = filterSettings.filterEnvelope;this.gateGain.gain.cancelScheduledValues(currentTime);this.gateGain.gain.setValueAtTime(0, currentTime + this.easing);this.gateGain.gain.linearRampToValueAtTime(this.targetVolume,currentTime + this.envelope.attack + this.easing,);this.gateGain.gain.linearRampToValueAtTime(this.targetVolume * this.envelope.sustain,currentTime + this.envelope.attack + this.envelope.decay + this.easing,);const baseFreq = filterSettings.frequency;const peakFreq = Math.min(baseFreq + filterSettings.filterEnvelopeAmount,20000,);const sustainFreq = Math.min(baseFreq + filterSettings.filterEnvelopeAmount * filterEnvelope.sustain,20000,);this.filterNode.frequency.cancelScheduledValues(currentTime);this.filterNode.frequency.setValueAtTime(baseFreq,currentTime + this.easing,);this.filterNode.frequency.linearRampToValueAtTime(peakFreq,currentTime + filterEnvelope.attack + this.easing,);this.filterNode.frequency.exponentialRampToValueAtTime(Math.max(20, sustainFreq),currentTime + filterEnvelope.attack + filterEnvelope.decay + this.easing,);}stopOscillatorConstructor(): void {const { currentTime } = this.audioContext;const filterEnvelope = this.filterSettings.filterEnvelope;this.gateGain.gain.cancelScheduledValues(currentTime);this.gateGain.gain.setTargetAtTime(0,currentTime,this.envelope.release / 4,);this.filterNode.frequency.cancelScheduledValues(currentTime);this.filterNode.frequency.setTargetAtTime(this.filterSettings.frequency,currentTime,filterEnvelope.release / 4,);const releaseDuration = Math.max(this.envelope.release,filterEnvelope.release,);const stopTime = currentTime + releaseDuration + this.easing;this.oscillator.stop(stopTime);setTimeout(() => {this.oscillator.disconnect();this.gateGain.disconnect();this.filterNode.disconnect();},(releaseDuration + 0.5) * 1000,);}}
The key stuff going on here:
- Import
EnvelopeSettingstype - Define
private Envelopeproperty - Destructure
envelopeSettingsfrom props - Define the envelope on the constructor
- Adjust timing of start and stop note functions to apply the envelope values to the
gateGain
Finally, we just need to update the OscillatorConstructorProps and SynthSettings interfaces to have envelopeSettings: EnvelopeSettings;:
// types.tsexport interface OscillatorConstructorProps {audioContext: AudioContext;type: "sine" | "square" | "sawtooth" | "triangle";frequency: number;detune: number;volume: number;connection: GainNode;easing: number;version: number;isMuted: boolean;lfoSettings: LFOSettings;filterSettings: FilterSettings;envelopeSettings: EnvelopeSettings;}export interface EnvelopeSettings {attack: number;decay: number;sustain: number;release: number;}export interface OscillatorSettings {type: "sine" | "square" | "sawtooth" | "triangle";octave: number;detune: number;volume: number;isMuted: boolean;}export interface SynthSettings {masterVolume: number;oscillator1: OscillatorSettings;oscillator2: OscillatorSettings;oscillator3: OscillatorSettings;easing: number;filterSettings: FilterSettings;lfoSettings: LFOSettings;envelopeSettings: EnvelopeSettings;}export interface FilterSettings {type: BiquadFilterType;frequency: number;detune: number;Q: number;gain: number;filterEnvelopeAmount: number;filterEnvelope: EnvelopeSettings;}export interface LFOSettings {type: "sine" | "square" | "sawtooth" | "triangle";rate: number;depth: number;}
With all of the above in place, we now have two ADSR Envelopes in place: one for the Filter module and one for the overall volume of the synth by way of the master gain. This is coming together nicely, but we can definitely enhance the sound of each of the three oscillators by adding an LFO into the mix.
Adding a LFO
An LFO, or Low-Frequency Oscillator, is a slow vibration (usually below the range of human hearing) that adds a subtle bit of texture to an Oscillator. By configuring the LFO's wave type, rate, and depth, you can add a lot of sophisticated color to your synth.
Since we're running out of space, we're going to add our LFO to the Volume component. This could just as easily be its own, dedicated LFO component though. I'll rename Volume to VolumeLFO:
// components/VolumeLFO.tsximport React from "react";import type { LFOSettings } from "../../types";interface VolumeLFOComponentProps {masterVolume: number;handleMasterVolumeChange: (val: number) => void;lfoSettings: LFOSettings;handleLFOSettingsChange: (nextLFOSettings: LFOSettings) => void;}export default function VolumeLFO({masterVolume,handleMasterVolumeChange,lfoSettings,handleLFOSettingsChange,}: VolumeLFOComponentProps) {const { type, rate, depth } = lfoSettings;function onVolumeChange(e: React.ChangeEvent<HTMLInputElement>) {handleMasterVolumeChange(parseFloat(e.target.value));}function onLFOChange(e: React.ChangeEvent<HTMLInputElement>) {const id = e.target.id;const prop = id.split("-").pop() as keyof LFOSettings;const val = parseFloat(e.target.value);const nextLFOSettings = {...lfoSettings,[prop]: val,};handleLFOSettingsChange(nextLFOSettings);}function onLFOTypeChange(e: React.ChangeEvent<HTMLSelectElement>) {const val = e.target.value as LFOSettings["type"];const nextLFOSettings = {...lfoSettings,type: val,};handleLFOSettingsChange(nextLFOSettings);}return (<div className="module volume-lfo"><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 className="header header--no-border-radius"><h2>LFO</h2></div><div className="controls"><div className="select-container"><label htmlFor="lfo-type">Wave Type</label><select id="lfo-type" value={type} onChange={onLFOTypeChange}><option value="sine">Sine</option><option value="square">Square</option><option value="sawtooth">Sawtooth</option><option value="triangle">Triangle</option></select></div><div className="range-container"><label htmlFor="lfo-rate">Rate <span className="right">{rate}</span></label><inputtype="range"id="lfo-rate"max="5"min="0"step="0.1"value={rate}onChange={onLFOChange}/></div><div className="range-container"><label htmlFor="lfo-depth">Depth <span className="right">{depth}</span></label><inputtype="range"id="lfo-depth"max="20"min="0"step="1"value={depth}onChange={onLFOChange}/></div></div></div>);}
By now all of this should be really familiar. Just pulling in the LFOSettings type interface, adding lfoSettings and handleLFOSettingsChange props, defining two functions to handle an LFO wave type change and a general LFO change for the other two controlled inputs, and adding the controlled inputs for the LFO to the UI of this module.
Next, over in the parent Synth component, I'll import the LFOSettings type interface, define the lfoSettings state variable, the lfoSettings property in the settingsRef, the handleLFOSettingsChange handler function, then update Volume to VolumeLFO and pass the lfoSettings and handler function as the new props:
// index.tsx"use client";// ...other imports...import type {SynthSettings,OscillatorSettings,FilterSettings,EnvelopeSettings,LFOSettings,} from "./types";// ...other imports, font stuff, and OscillatorBank interface...export default function Synth() {// ...masterVolume, oscillators, filterSettings, and envelopeSettings state variables...const [lfoSettings, setLFOSettings] = useState<LFOSettings>({type: "sine",rate: 5,depth: 4,});const settingsRef = useRef<SynthSettings>({// ...masterVolume, oscillators, easing, filterSettings, and envelopeSettings ref settings...lfoSettings: {type: "sine",rate: 5,depth: 4,},});// ...the other handler functions...function handleLFOSettingsChange(nextLFOSettings: LFOSettings) {setLFOSettings(nextLFOSettings);settingsRef.current.lfoSettings = nextLFOSettings;}return (<div className={`synth-container ${synthFont.variable}`}><div className="synth"><div className="title"><h1 className="synth-title">next.js synth mk2</h1></div><div className="synth-padding"><div className="synth-modules-mk2"><Oscillatorversion={1}oscillatorSettings={oscillators.oscillator1!}handleOscillatorSettingsChange={handleOscillatorSettingsChange}/><Oscillatorversion={2}oscillatorSettings={oscillators.oscillator2!}handleOscillatorSettingsChange={handleOscillatorSettingsChange}/><Oscillatorversion={3}oscillatorSettings={oscillators.oscillator3!}handleOscillatorSettingsChange={handleOscillatorSettingsChange}/><VolumeLFOmasterVolume={masterVolume}handleMasterVolumeChange={handleMasterVolumeChange}lfoSettings={lfoSettings}handleLFOSettingsChange={handleLFOSettingsChange}/><EnvelopeenvelopeSettings={envelopeSettings}handleEnvelopeSettingsChange={handleEnvelopeSettingsChange}variant="Gain"/><FilterfilterSettings={filterSettings}handleFilterSettingsChange={handleFilterSettingsChange}/><EnvelopeenvelopeSettings={filterSettings.filterEnvelope}handleEnvelopeSettingsChange={(nextFilterEnvelope) => {handleFilterSettingsChange({...filterSettings,filterEnvelope: nextFilterEnvelope,});}}variant="filter"/><Keyboard onKeyDown={playNote} onKeyUp={stopNote} /></div></div></div></div>);}
Now we need to update the Oscillator constructor calls in useSynthEngine, passing them the lfoSettings:
// ...in playNote in hooks/useSynthEngine.ts:// repeat for Oscillator versions 2 and 3 as wellnew 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,filterSettings: settings.filterSettings,envelopeSettings: settings.envelopeSettings,lfoSettings: settings.lfoSettings,}),
With that in place, we can update the Oscillator constructor itself:
// constructors/Oscillator.tsimport type {OscillatorConstructorProps,FilterSettings,EnvelopeSettings,} from "../types";export default class Oscillator {private audioContext: AudioContext;private oscillator: OscillatorNode;private gateGain: GainNode;private easing: number;private targetVolume: number;public version: number;public filterNode: BiquadFilterNode;private filterSettings: FilterSettings;private envelope: EnvelopeSettings;private lfo: OscillatorNode;private lfoGain: GainNode;constructor(props: OscillatorConstructorProps) {const {audioContext,type,frequency,detune,volume,connection,easing,version,isMuted,filterSettings,envelopeSettings,lfoSettings,} = props;this.version = version;this.audioContext = audioContext;this.easing = easing;this.targetVolume = isMuted ? 0 : volume;this.filterSettings = filterSettings;this.oscillator = this.audioContext.createOscillator();this.oscillator.frequency.value = frequency;this.oscillator.detune.value = detune;this.oscillator.type = type;this.filterNode = this.audioContext.createBiquadFilter();this.filterNode.type = filterSettings.type;this.filterNode.Q.value = filterSettings.Q;this.filterNode.gain.value = filterSettings.gain;this.envelope = envelopeSettings || {attack: 0.005,decay: 0.1,sustain: 0.6,release: 0.1,};this.gateGain = this.audioContext.createGain();this.gateGain.gain.value = 0;this.lfo = this.audioContext.createOscillator();this.lfo.type = lfoSettings.type;this.lfo.frequency.value = lfoSettings.rate;this.lfoGain = this.audioContext.createGain();this.lfoGain.gain.value = lfoSettings.depth;this.oscillator.connect(this.filterNode);this.filterNode.connect(this.gateGain);this.gateGain.connect(connection);this.lfo.connect(this.lfoGain);this.lfoGain.connect(this.oscillator.frequency);this.lfo.start();this.oscillator.start();this.startOscillatorConstructor();}startOscillatorConstructor(): void {const { currentTime } = this.audioContext;const filterSettings = this.filterSettings;const filterEnvelope = filterSettings.filterEnvelope;this.gateGain.gain.cancelScheduledValues(currentTime);this.gateGain.gain.setValueAtTime(0, currentTime + this.easing);this.gateGain.gain.linearRampToValueAtTime(this.targetVolume,currentTime + this.envelope.attack + this.easing,);this.gateGain.gain.linearRampToValueAtTime(this.targetVolume * this.envelope.sustain,currentTime + this.envelope.attack + this.envelope.decay + this.easing,);const baseFreq = filterSettings.frequency;const peakFreq = Math.min(baseFreq + filterSettings.filterEnvelopeAmount,20000,);const sustainFreq = Math.min(baseFreq + filterSettings.filterEnvelopeAmount * filterEnvelope.sustain,20000,);this.filterNode.frequency.cancelScheduledValues(currentTime);this.filterNode.frequency.setValueAtTime(baseFreq,currentTime + this.easing,);this.filterNode.frequency.linearRampToValueAtTime(peakFreq,currentTime + filterEnvelope.attack + this.easing,);this.filterNode.frequency.exponentialRampToValueAtTime(Math.max(20, sustainFreq),currentTime + filterEnvelope.attack + filterEnvelope.decay + this.easing,);}stopOscillatorConstructor(): void {const { currentTime } = this.audioContext;const filterEnvelope = this.filterSettings.filterEnvelope;this.gateGain.gain.cancelScheduledValues(currentTime);this.gateGain.gain.setTargetAtTime(0,currentTime,this.envelope.release / 4,);this.filterNode.frequency.cancelScheduledValues(currentTime);this.filterNode.frequency.setTargetAtTime(this.filterSettings.frequency,currentTime,filterEnvelope.release / 4,);const releaseDuration = Math.max(this.envelope.release,filterEnvelope.release,);const stopTime = currentTime + releaseDuration + this.easing;this.lfo.stop(stopTime);this.oscillator.stop(stopTime);setTimeout(() => {this.lfo.disconnect();this.oscillator.disconnect();this.lfoGain.disconnect();this.gateGain.disconnect();this.filterNode.disconnect();},(releaseDuration + 0.5) * 1000,);}}
The list of changes here:
- Adding the
private lfoandprivate lfoGainproperties - Destructuring the
lfoSettingsprop - Creating the
lfoOscillatorNodeand setting its type and frequency (what we call "rate") - Creating the
lfoGainGainNodeand setting its gain value (what we call "depth") - Connecting
lfotolfoGainand thenlfoGainto theoscillator'sfrequency - Starting the
lfooscillator - When a note stops playing, also stopping the
lfo, and disconnecting thelfoandlfoGain
Finally, over in types.ts we just need to add lfoSettings: LFOSettings; to OscillatorConstructorProps and SynthSettings:
// types.tsexport interface OscillatorConstructorProps {audioContext: AudioContext;type: "sine" | "square" | "sawtooth" | "triangle";frequency: number;detune: number;volume: number;connection: GainNode;easing: number;version: number;isMuted: boolean;filterSettings: FilterSettings;envelopeSettings: EnvelopeSettings;lfoSettings: LFOSettings;}export interface EnvelopeSettings {attack: number;decay: number;sustain: number;release: number;}export interface OscillatorSettings {type: "sine" | "square" | "sawtooth" | "triangle";octave: number;detune: number;volume: number;isMuted: boolean;}export interface SynthSettings {masterVolume: number;oscillator1: OscillatorSettings;oscillator2: OscillatorSettings;oscillator3: OscillatorSettings;easing: number;filterSettings: FilterSettings;envelopeSettings: EnvelopeSettings;lfoSettings: LFOSettings;}export interface FilterSettings {type: BiquadFilterType;frequency: number;detune: number;Q: number;gain: number;filterEnvelopeAmount: number;filterEnvelope: EnvelopeSettings;}export interface LFOSettings {type: "sine" | "square" | "sawtooth" | "triangle";rate: number;depth: number;}
Now you can make some really far out sounds by toggling the rate, depth, and wave type on the LFO in addition to everything else that we've built.
Adding a Compressor
There is one last thing to do to really make this thing production-ready, and that's to add a compressor. This will serve a few purposes for our synth:
- It will prevent clipping that can happen when multiple oscillators stack up and get too loud
- It will soften any initial clicking from having too-short of an attack stage in our envelope
- It will lessen the volume differences between playing a single note and playing multiple notes at once
This sounds like a lot for something to handle, but thankfully it's pretty simple to implement, it just gets added to useSynthEngine:
useEffect(() => {if (!audioCtx.current) {audioCtx.current = new (window.AudioContext || (window as any).webkitAudioContext)();masterGain.current = audioCtx.current.createGain();const compressor = audioCtx.current.createDynamicsCompressor();compressor.threshold.setValueAtTime(-12, audioCtx.current.currentTime);compressor.knee.setValueAtTime(30, audioCtx.current.currentTime);compressor.ratio.setValueAtTime(12, audioCtx.current.currentTime);compressor.attack.setValueAtTime(0.003, audioCtx.current.currentTime);compressor.release.setValueAtTime(0.25, audioCtx.current.currentTime);masterGain.current.connect(compressor);compressor.connect(audioCtx.current.destination);}return () => {audioCtx.current?.close();audioCtx.current = null;};}, []);
Where previously we had just connected masterGain to the AudioContext destination (otherwise known as "your computer speakers"), we now connect the masterGain to a DynamicsCompressorNode first with some sensible defaults values.
With that, our synth is built. A quick review of everything we covered in this blog post:
- Creating a filter module
- Creating a reusable ADSR envelope module
- Adding an envelope to the filter
- Adding an envelope to the overall volume of the synth
- Adding an LFO to each of the three oscillators
- Adding a compressor to the overall volume of the synth
Even though I glossed over a ton, this was still a very involved blog post, and if you've followed along you should have a fairly robust and sophisticated instrument on your hands, loosely based on the Minimoog. There are some differences, but I think it's super cool to be able to create something like this only using software! Kudos if you made it through this post.
There is one more in this series, but it won't be adjusting sound creation or output anymore. Instead, we'll be adding an oscilloscope so we can better visualize the sound waves we're creating. See you soon!