Introduction

Every synthesizer that ever made you tap your foot is doing something surprisingly simple at its core: it is reading a tiny table of numbers, over and over, thousands of times per second.

Wavetable synthesis stores a single cycle of a periodic waveform — the shape of one complete oscillation — in a table of samples. To play a note, the synthesizer races a phase accumulator through that table at a speed proportional to the desired frequency. High note? Faster read. Low note? Slower read. The output is the waveform, looping seamlessly at exactly the right pitch.

The beauty is in the math. Suppose your table has NN samples and your audio runs at a sample rate fsf_s. To produce a frequency ff, you must advance the read pointer by Δϕ=N⋅ffs\Delta\phi = \frac{N \cdot f}{f_s} samples on every output tick. Most of the time Δϕ\Delta\phi is not a whole number, so the synth uses linear interpolation — or higher-order variants — to blend between adjacent table entries.

Switch which table you are reading mid-note and the timbre changes smoothly. That is wavetable morphing, and it is how a single cheap chip could sound like a piano, a flute, or a string section — just by crossfading between stored cycles. Julius O. Smith III at Stanford and Wolfgang Palm (PPG Wave, 1978) are among the pioneers who turned this idea into instruments millions of people play today.

Morph Saw to Sine

The demo below builds two single-cycle wavetables — a sawtooth (rich in harmonics) and a sine (just the fundamental) — and lets you crossfade between them in real time using the Web Audio API.

<!-- Wavetable morphing demo — two single-cycle tables blended in real time -->
<div class="controls">
  <label>Morph: <span id="morphVal">0%</span>
    <input type="range" id="morphSlider" min="0" max="100" value="0">
  </label>
  <label>Frequency: <span id="freqVal">220 Hz</span>
    <input type="range" id="freqSlider" min="55" max="880" value="220">
  </label>
  <div class="btn-row">
    <button id="playBtn" type="button">Play</button>
    <button id="stopBtn" type="button" class="ghost">Stop</button>
  </div>
</div>
<canvas id="waveCanvas" width="480" height="130" aria-label="Waveform preview — morphed between sawtooth and sine"></canvas>
<p class="hint">Drag Morph to blend a sawtooth (left, buzzy) into a sine (right, pure). Frequency changes the phase step Δφ, not the waveform shape.</p>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .55rem; margin-bottom: .7rem; }
label { display: flex; align-items: center; gap: .5rem; font-size: .9rem; flex-wrap: wrap; }
input[type=range] { flex: 1; min-width: 140px; accent-color: #1d3557; }
span[id] { font-variant-numeric: tabular-nums; min-width: 4rem; }
.btn-row { display: flex; gap: .5rem; }
button { font: 600 14px system-ui; padding: .4rem .9rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
canvas { display: block; width: 100%; border: 1px solid #cdd9e3; border-radius: 8px;
         background: #f4f7fa; }
.hint { font-size: .82rem; color: #555; margin: .5rem 0 0; line-height: 1.4; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Wavetable synthesis: store one cycle of a waveform in a table,
// then read it in a loop at a speed that sets the output frequency.

const TABLE_SIZE = 2048; // samples per single-cycle table
const SAMPLE_RATE = 44100;

// Build a sawtooth: ramps from -1 to +1 over N samples
function buildSawtooth(n) {
  const t = new Float32Array(n);
  for (let i = 0; i < n; i++) t[i] = 2 * (i / n) - 1;
  return t;
}

// Build a sine wave using one period of sin(2*pi*i/N)
function buildSine(n) {
  const t = new Float32Array(n);
  for (let i = 0; i < n; i++) t[i] = Math.sin(2 * Math.PI * i / n);
  return t;
}

const sawTable = buildSawtooth(TABLE_SIZE);
const sineTable = buildSine(TABLE_SIZE);

// Linear interpolation between adjacent table entries: blends sample i and i+1 by fractional phase
function lerp(t, i, frac) {
  const a = t[i % TABLE_SIZE];
  const b = t[(i + 1) % TABLE_SIZE];
  return a + frac * (b - a);
}

// Read from both tables and crossfade by morph weight (0=saw, 1=sine)
function morphRead(phase, morph) {
  const i = Math.floor(phase) % TABLE_SIZE;
  const frac = phase - Math.floor(phase);
  const saw = lerp(sawTable, i, frac);
  const sine = lerp(sineTable, i, frac);
  return (1 - morph) * saw + morph * sine; // linear crossfade between the two wavetables
}

// Draw one cycle of the morphed waveform on the canvas
function drawWave(morph) {
  const canvas = document.getElementById('waveCanvas');
  const ctx = canvas.getContext('2d');
  const W = canvas.width, H = canvas.height;
  ctx.clearRect(0, 0, W, H);
  ctx.strokeStyle = '#1d3557';
  ctx.lineWidth = 2;
  ctx.beginPath();
  for (let px = 0; px < W; px++) {
    const phase = (px / W) * TABLE_SIZE;
    const y = morphRead(phase, morph);
    const sy = H / 2 - y * (H / 2 - 6);
    if (px === 0) ctx.moveTo(px, sy); else ctx.lineTo(px, sy);
  }
  ctx.stroke();
}

// Audio state: context, script processor, phase accumulator, parameters
let audioCtx = null;
let scriptNode = null;
let phase = 0;
let currentMorph = 0;
let currentFreq = 220;
let playing = false;

function startAudio() {
  if (playing) return;
  audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
  const bufSize = 2048;
  scriptNode = audioCtx.createScriptProcessor(bufSize, 0, 1);
  scriptNode.onaudioprocess = function(e) {
    const out = e.outputBuffer.getChannelData(0);
    const step = (TABLE_SIZE * currentFreq) / SAMPLE_RATE; // phase step = table_size * freq / sample_rate
    for (let i = 0; i < out.length; i++) {
      out[i] = morphRead(phase, currentMorph) * 0.3; // attenuate to avoid clipping
      phase = (phase + step) % TABLE_SIZE;
    }
  };
  scriptNode.connect(audioCtx.destination);
  playing = true;
}

function stopAudio() {
  if (!playing) return;
  scriptNode.disconnect();
  audioCtx.close();
  playing = false;
  phase = 0;
}

const morphSlider = document.getElementById('morphSlider');
const freqSlider = document.getElementById('freqSlider');
const morphVal = document.getElementById('morphVal');
const freqVal = document.getElementById('freqVal');

morphSlider.addEventListener('input', function() {
  currentMorph = this.value / 100;
  morphVal.textContent = i18nFmt('%pct%%', { pct: this.value });
  drawWave(currentMorph);
});

freqSlider.addEventListener('input', function() {
  currentFreq = +this.value;
  freqVal.textContent = i18nFmt('%hz% Hz', { hz: this.value });
});

document.getElementById('playBtn').addEventListener('click', startAudio);
document.getElementById('stopBtn').addEventListener('click', stopAudio);

drawWave(0); // Draw the initial sawtooth waveform on page load
Result
Console

Drag the Morph slider all the way left and you hear a buzzy saw. All the way right: a smooth, pure sine. Anywhere in between is a weighted blend of the two tables, computed sample by sample. The Frequency slider changes Δϕ\Delta\phi, the phase step, so the same tables play at any pitch. Notice that the waveform shape on screen changes continuously — that is linear interpolation working at 44 100 times per second.

How Interpolation Works

The elegance of wavetable synthesis hides a subtle numerical problem: the phase step Δϕ\Delta\phi is almost never an integer.

Say your table has N=2048N = 2048 samples and you want f=440 Hzf = 440\,\text{Hz} at fs=44100 Hzf_s = 44100\,\text{Hz}:

Δϕ=2048×44044100≈20.43\Delta\phi = \frac{2048 \times 440}{44100} \approx 20.43

So on each output sample the read head advances 20.4320.43 steps. The integer part selects a table entry; the fractional part α=0.43\alpha = 0.43 drives the blend:

y=(1−α) x[i]+α x[i+1]y = (1 - \alpha)\,x[i] + \alpha\,x[i+1]

This linear interpolation is cheap — one multiply-add per sample — and good enough for many timbres. But it subtly rounds off high harmonics, introducing a small high-frequency droop. Better quality comes from cubic or sinc interpolation, which look at more neighbours at the cost of more computation.

The other axis is table size. Larger tables alias less (higher frequency resolution) but use more memory and cache bandwidth. In practice 2 048 or 4 096 samples per cycle is the sweet spot for audio work. Multiple tables at different band-limited versions of the waveform handle the aliasing that arises when you play the same table at very high pitches — a technique pioneered by fast-multiplication-aware DSP engineers in the 1980s.

Morphing between tables adds one more ingredient: a crossfade weight t∈[0,1]t \in [0, 1] that linearly blends the outputs of two (or more) interpolated reads. The result is smooth perceptual change rather than a sudden jump in timbre.

Where It Matters

Wavetable synthesis escaped the lab and conquered every corner of digital audio:

  • Hardware synthesizers: the PPG Wave (1978) was the first commercial wavetable synth; the Waldorf Microwave and Ensoniq Transwave followed, defining an era of digital textures unavailable from analog oscillators.
  • Software instruments: Xfer Serum, u-he Zebra, and Ableton's Wavetable are among the most-used production tools in modern music, all built on the same single-cycle-loop principle.
  • Sample playback / soundfonts: the General MIDI standard relies on wavetable ROM chips; virtually every computer sound card from the 1990s contained one.
  • Game and mobile audio: embedded wavetable engines (e.g., Roland MT-32 emulation, tiny synth chips in phones) deliver polyphonic music in a few kilobytes of memory.
  • Speech and signal processing: the same phase-accumulator idea underpins numerically controlled oscillators (NCOs) in software-defined radio and arbitrary waveform generators in test equipment.

The technique also teaches a core idea in compression: instead of storing or transmitting every sample of every note, store one carefully chosen cycle and reconstruct the rest algorithmically — a trade between memory and computation that appears throughout computing.

Conclusion

Wavetable synthesis reduces music to arithmetic: maintain a counter, scale its step by frequency, index a table, blend neighbours, repeat 44 100 times per second. That loop — barely a dozen operations — produces every pitch at every timbre a wavetable oscillator can offer.

The clever part is not the reading; it is the choice of what to store. A sawtooth cycle, a voice vowel, a vinyl crackle — anything periodic can live in a table and be transposed to any key without losing quality. Crossfade two tables and you have morphing. Stack several phase accumulators and you have polyphony. Layer dozens of morphing voices and you have a modern software synthesizer.

Next time you hear a lush pad or a plucked string on a digital synth, you are listening to a handful of stored cycles being re-read millions of times. It is one of computing's most elegant tricks — and it works because compression and interpolation together let a tiny memory punch far above its weight.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/wavetable-synthesis/Content licensed under CC BY-NC 4.0.