How to Build a Supersaw Synthesizer with the Web Audio API
If you’ve ever built a virtual synth before, you know that trying to reproduce the analog sound is really hard1. It’s better to compete where digital has a comparative advantage, like spawning hundreds of oscillators dynamically. That’s the essence of the supersaw synth: stack a bunch of sawtooth oscillators on top of each other, each at a slightly different frequency.
Here’s the demo and the code. There are only two knobs: one to control the number of oscillators; the other to control how out-of-tune each oscillator is. It’s simple, but it can make some cool sounds. Read on if you want to learn how to build your own.
Overview
The supersaw sound first appeared in 1996 with the Roland JP-8000 synth. The JP-8000’s supersaw waveform is comprised of 7 detuned saw waves. Later the Access Virus TI came out with the hypersaw: 9 detuned sawtooth oscillators. The synth we’re building today can do hundreds of detuned oscillators.
We’ll build this synth in three stages. The first thing we’ll build is what I like to call the “audio circuit”: that is, the code that actually makes noise. Second, we’ll add a keyboard so we can play some notes, and finally, we’ll add a UI to make it look good (and a couple of knobs to tweak the sound).
Audio Circuit
We’re going to make this synthesizer polyphonic, meaning that multiple notes can be played simultaneously (i.e., chords). The way I like to implement this is to have a low-level class with the audio code needed for a single note (“voice”), and a high-level class responsible for managing these voices.
Let’s start with the high-level class.
class Scissor
constructor: (@context) ->
@numSaws = 3
@detune = 12
@voices = []
@output = @context.createGain()
noteOn: (note, time) ->
return if @voices[note]?
time ?= @context.currentTime
freq = noteToFrequency note
voice = new ScissorVoice(@context, freq, @numSaws, @detune)
voice.connect @output
voice.start time
@voices[note] = voice
noteOff: (note, time) ->
return unless @voices[note]?
time ?= @context.currentTime
@voices[note].stop time
delete @voices[note]
connect: (target) ->
@output.connect target
This is the high-level “synth object”, responsible for handling noteOn
and noteOff
messages. The way it does this is to keep track of a @voices
array. When a noteOn
message is received, it converts the note
parameter (the MIDI note number) to a frequency, then constructs a ScissorVoice
object with the appropriate parameters. It stores a reference to this new voice in the @voices
array, so it can be used later when the noteOff
method is called.
The conversion from MIDI note number to frequency looks like this:
noteToFrequency = (note) ->
Math.pow(2, (note - 69) / 12) * 440.0
What does this mean? Well, first of all, note number 69 corresponds to a frequency of 440 Hz (A4), since 20/12 * 440.0 = 440 Hz. After that, the notes follow a progression where each note is 21/12 higher frequency than the previous note2. For example, note number 70 is 21/12 * 440.0 = 466.16 Hz.
Once the voice has been created, it’s connected to the @output
node, and instructed to start playing at time
(if the time
parameter isn’t specified, the voice starts immediately).
Now we can write the ScissorVoice
class.
class ScissorVoice
constructor: (@context, @frequency, @numSaws, @detune) ->
@output = @context.createGain()
@maxGain = 1 / @numSaws
@saws = []
for i in [0...@numSaws]
saw = @context.createOscillator()
saw.type = saw.SAWTOOTH
saw.frequency.value = @frequency
saw.detune.value = -@detune + i * 2 * @detune / (@numSaws - 1)
saw.start @context.currentTime
saw.connect @output
@saws.push saw
start: (time) ->
@output.gain.setValueAtTime @maxGain, time
stop: (time) ->
@output.gain.setValueAtTime 0, time
setTimeout (=>
# remove old saws
@saws.forEach (saw) ->
saw.disconnect()
), Math.floor((time - @context.currentTime) * 1000)
connect: (target) ->
@output.connect target
As you can see, the class creates @numSaws
sawtooth oscillators, with the detune
parameter spread from -@detune
to @detune
.
The start
method just sets the @output
gain to @maxGain
. @maxGain
is set to 1 / @numSaws
, to keep the audio output in the range [-1.0, 1.0]. When the stop
method is called, the @output
gain is set to 0, and the oscillators are disconnected from the audio graph. The garbage collector will eventually remove these disconnected nodes, which saves CPU cycles.
At this point, you can test it out in the console:
var audioContext = new webkitAudioContext();
var scissor = new Scissor();
scissor.connect(audioContext.destination);
scissor.noteOn(60); // C4
scissor.noteOn(64); // E4
scissor.noteOn(67); // G4
User Interface
We’ll be building the interface with LESS. The first thing I like to do when starting a new design is to define some additional LESS variables:
@phi: 1.61803399;
@size-nano: unit(pow(@phi, -4), rem);
@size-micro: unit(pow(@phi, -3), rem);
@size-tiny: unit(pow(@phi, -2), rem);
@size-small: unit(pow(@phi, -1), rem);
@size-base: unit(pow(@phi, 0), rem);
@size-large: unit(pow(@phi, 1), rem);
@size-huge: unit(pow(@phi, 2), rem);
@size-massive: unit(pow(@phi, 3), rem);
@size-epic: unit(pow(@phi, 4), rem);
This is probably a bit strange if you haven’t seen anything like this before. It’s called a modular scale. Whenever I need a measurement in the design, be it font size, margin, border size, or anything else, I only use measurements from the scale. It keeps things visually consistent and means I don’t have to worry about whether the spacing should be 3px or 4px.
Here’s the markup we’ll use for the synth UI:
<div id="scissor">
<div id="controls">
<div class="panel">
<div class="knob">
<input id="saws" type="range" min="1" max="15" data-width="62" data-height="62" data-angleOffset="220" data-angleRange="280" />
<label>Num. Saws</label>
</div>
</div>
<div class="title panel">
<h1><a href="http://noisehack.com/">Scissor</a></h1>
<p>Web Audio Supersaw Synthesizer</p>
</div>
<div class="panel">
<div class="knob">
<input id="detune" type="range" min="0" max="100" data-width="62" data-height="62" data-angleOffset="220" data-angleRange="280" />
<label>Detune</label>
</div>
</div>
</div>
<div id="keyboard"></div>
</div>
To get started on the CSS, we’ll first import normalize.css, Preboot, and these flexbox mixins. If you haven’t heard of it, Preboot is basically “Bootstrap: The Good Parts”. It has all the Bootstrap LESS mixins and variables, without actually styling anything. If you tend to override the default Bootstrap styles, I recommend giving Preboot a try.
@import "normalize.less";
@import "preboot.less";
@import "flexbox.less";
@synth-color: #737373;
@header-color: #c30909;
I decided to make the page background a radial gradient. Feel free to change this if you’d prefer something more subtle.
html {
width: 100%;
height: 100%;
}
body {
width: 100%;
height: 100%;
font-family: @font-family-base;
#gradient.radial(@header-color, darken(@header-color, 34%));
.user-select(none);
}
To style the main body of the synth, use:
#scissor {
#gradient.vertical(lighten(@synth-color, 30%), @synth-color);
padding-left: @size-micro;
padding-right: @size-micro;
border-radius: @size-nano;
.box-shadow(0 0 @size-base rgba(0,0,0,0.5));
}
There was a recent post about absolute centering with CSS. The modern way to do this is to use flexbox3. Here’s how to center the synth on the page with flexbox:
body {
.flex-display;
.align-items(center);
.justify-content(center);
}
No need to manually specify heights or widths—flexbox adapts to you, not the other way around.
Here’s the CSS for the #controls
div:
#controls {
padding: @size-base;
margin-top: @size-small;
.border-top-radius(@size-nano);
.box-sizing(border-box);
.flex-display;
.panel {
.flex(1);
.justify-content(center);
.align-items(center);
text-align: center;
.knob {
div {
text-align: center;
width: 100% !important;
}
label {
margin: 0;
padding: 0;
text-transform: uppercase;
font-weight: 700;
color: darken(@synth-color, 5%);
text-shadow: 0 1px 0 lighten(@synth-color, 40%);
}
}
&.title {
.flex(2);
.flex-display;
.flex-direction(column);
h1 {
margin: 0;
padding: 0;
a {
color: @header-color;
text-shadow: 0 1px 0 lighten(@synth-color, 40%);
font-size: @size-huge;
line-height: 1;
font-family: "Stalinist One", sans-serif;
font-weight: 400;
text-transform: uppercase;
text-decoration: none;
}
}
p {
margin: 0;
padding: 0;
text-transform: uppercase;
font-weight: 700;
color: darken(@synth-color, 5%);
text-shadow: 0 1px 0 lighten(@synth-color, 40%);
}
}
}
}
There are a few interesting things going on here. The text gets a 1px text-shadow
to make it look like it’s engraved into the surface of the synth. Also notice the .flex(2);
directive in the .panel.title
CSS. That means it will take up two columns, while the other .panel
divs take up only one.
Keyboard
We have the audio circuit and the synth control panel; now we need a keyboard. I’m putting all the functionality into a single VirtualKeyboard
class. Instead of hard-coding functionality, it will accept callbacks for noteOn
and noteOff
events, so that it can be used with any instrument, not just the synth we’re building today.
class VirtualKeyboard
constructor: (@$el, params) ->
@lowestNote = params.lowestNote ? 48
@letters = params.letters ? "awsedftgyhujkolp;'".split ''
@noteOn = params.noteOn ? (note) -> console.log "noteOn: #{note}"
@noteOff = params.noteOff ? (note) -> console.log "noteOff: #{note}"
@keysPressed = {}
@render()
@bindKeys()
@bindMouse()
_noteOn: (note) ->
return if note of @keysPressed
$(@$el.find('li').get(note - @lowestNote)).addClass 'active'
@keysPressed[note] = true
@noteOn note
_noteOff: (note) ->
return unless note of @keysPressed
$(@$el.find('li').get(note - @lowestNote)).removeClass 'active'
delete @keysPressed[note]
@noteOff note
We’ll be using Mousetrap to handle key events:
bindKeys: ->
for letter, i in @letters
do (letter, i) =>
Mousetrap.bind letter, (=>
@_noteOn (@lowestNote + i)
), 'keydown'
Mousetrap.bind letter, (=>
@_noteOff (@lowestNote + i)
), 'keyup'
Mousetrap.bind 'z', =>
# shift one octave down
@lowestNote -= 12
Mousetrap.bind 'x', =>
# shift one octave up
@lowestNote += 12
Note that pressing z shifts down an octave and pressing x shifts up an octave. This works in the demo, too—if you have a good sound system, try playing some basslines!
We also want the keys to respond if the user clicks on them:
bindMouse: ->
@$el.find('li').each (i, key) =>
$(key).mousedown =>
@_noteOn (@lowestNote + i)
$(key).mouseup =>
@_noteOff (@lowestNote + i)
Once that’s done, we want to add the CSS. I decided to go for a design that looks like a cross between a computer keyboard and a piano keyboard, similar to the one in Garageband:
@white-key: rgb(236, 236, 236);
@black-key: rgb(70, 70, 70);
.piano-key(@color) {
background-color: @color;
color: darken(@color, 30%);
text-shadow: 0 1px 0 lighten(@color, 10%);
border-top: @size-nano solid darken(@color, 10%);
border-left: @size-tiny solid darken(@color, 15%);
border-right: @size-tiny solid darken(@color, 15%);
border-bottom: @size-micro solid darken(@color, 25%);
.box-shadow(0 @size-micro @size-small rgba(0,0,0,0.5));
&.active {
.box-shadow(0 @size-nano @size-micro rgba(0,0,0,0.5));
text-shadow: 0 1px 0 @color;
#gradient.vertical(@color, darken(@color, 10%));
}
}
#keyboard {
cursor: pointer;
ul {
margin: 0 auto;
padding: 0;
list-style: none;
li {
float: left;
width: @size-massive;
height: @size-epic;
text-transform: uppercase;
font-style: italic;
padding-left: @size-tiny;
padding-bottom: @size-micro;
margin-right: @size-nano;
.box-sizing(border-box);
.flex-display;
.align-items(flex-end);
.border-bottom-radius(@size-micro);
.piano-key(@white-key);
&.accidental {
position: relative;
margin-left: -@size-large;
margin-right: -(@size-large + (@size-massive / 2));
height: @size-massive;
.piano-key(@black-key);
}
&:last-child {
margin-right: 0;
}
}
}
}
The white and black keys have a lot of CSS in common, so I made a .piano-key
mixin to reduce repetition. This also means it’s easy to change the keyboard color; just change the values of @white-key
and @black-key
.
Putting It Together
That’s it for the individual components; now it’s time to instantiate and connect them to create the finished synth:
$ ->
audioContext = new (AudioContext ? webkitAudioContext)
masterGain = audioContext.createGain()
masterGain.gain.value = 0.7
masterGain.connect audioContext.destination
window.scissor = new Scissor(audioContext)
scissor.connect masterGain
keyboard = new VirtualKeyboard $("#keyboard"),
noteOn: (note) ->
scissor.noteOn note
noteOff: (note) ->
scissor.noteOff note
setNumSaws = (numSaws) ->
scissor.numSaws = numSaws
setDetune = (detune) ->
scissor.detune = detune
sawsKnob = new Knob($("#saws")[0], new Ui.P2())
sawsKnob.changed = ->
Knob.prototype.changed.apply this, arguments
setNumSaws @value
$("#saws").val scissor.numSaws
sawsKnob.changed 0
detuneKnob = new Knob($("#detune")[0], new Ui.P2())
detuneKnob.changed = ->
Knob.prototype.changed.apply this, arguments
setDetune @value
$("#detune").val scissor.detune
detuneKnob.changed 0
Conclusion
Even though the synth has only two knobs, it can produce some interesting sounds. With 3-5 oscillators, and detune at 9 o’clock, you get the classic 90s trance lead. With 20 oscillators, and detune just above the minimum, you get something that starts off as a nice pluck sound, but evolves into something else entirely when held for a while.
With that said, we’ve come pretty far.↩
Since there are 12 notes in an octave, this means that if you have a note, say, C4, and transpose it one octave to C5, you’ll have doubled its frequency. For instance, A4 is 440 Hz, A5 is 880 Hz, A6 is 1760 Hz, and so on.↩
Granted, flexbox doesn’t work in older browsers—but hey, neither does the Web Audio API.↩
If you enjoyed this post, subscribe to the newsletter or follow Noisehack on Twitter.